Files
openidea/src/features/services/ops.ts
T
l0ng-ai b03983847c fix(i18n): Translate UI text built from template literals
Template literals such as `Remove data source '${name}'?` reached props and
calls that look text up exactly (dialog messages, labels, aria-labels, menu
items, action titles), so they always showed in English. Some notification
titles also spliced lowercase English words into a sentence.

- `tx` also works as a tag: tx`Remove data source '${name}'?` looks up
  "Remove data source '{name}'?" by its literal parts, fills in the values
  (translated when the catalog has them) and remembers the result, so the
  primitives that pass strings through `tx` translate it again after a locale
  change or once the catalog has loaded (actions registered at startup).
- 269 template literals at exact-lookup sites now use the tag; text that
  spliced words or plural suffixes is written as one sentence per variant
  (safe delete, Docker delete/prune, rebase, conflicts, VCS "No … in
  progress", database copy errors, inspection descriptions, ...).
- zh: 280 new entries; stale and duplicate-shape entries removed.
- textCatalog guard: untagged template literals at exact-lookup sites fail
  (with an allowlist for code-like text), tagged text is checked against the
  catalog by shape, case-changing values and `c ? "a" : "b"` suffixes inside
  translated templates fail, and a catalog may hold one entry per template shape.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 18:43:53 +08:00

207 lines
9.0 KiB
TypeScript

/** What the Services toolbar, context menu and `docker.*` actions do with a node of a connection. */
import { tx, txTemplate } from "@/platform/i18n";
import type { PruneTarget } from "@/platform/ipc";
import { executeAction } from "@/platform/actions";
import type { MenuItem } from "@/ui";
import { confirm, confirmChecked } from "@/workbench/messages";
import { promptText } from "./dialogs";
import { isRunning, nodeContainers, nodeLabel, shortId, type DockerTarget, type ServiceNode } from "./model";
import { composeOp, connectionState, containerOp, prune, pullImage, refresh, removeObjects, useServices } from "./store";
export function canStart(t: DockerTarget | null): boolean {
return !!t && nodeContainers(t.node).some((c) => !isRunning(c));
}
export function canStop(t: DockerTarget | null): boolean {
return !!t && nodeContainers(t.node).some(isRunning);
}
export function canRestart(t: DockerTarget | null): boolean {
return !!t && (t.node.kind === "container" || t.node.kind === "compose");
}
export function canRemove(t: DockerTarget | null): boolean {
return !!t && ["container", "compose", "image", "volume", "network"].includes(t.node.kind);
}
export async function start(t: DockerTarget | null) {
if (!t) return;
if (t.node.kind === "compose") return composeAction(t.conn, t.node, "start");
const ids = nodeContainers(t.node).filter((c) => !isRunning(c)).map((c) => c.id);
if (ids.length) await containerOp(t.conn, ids, "start");
}
export async function stop(t: DockerTarget | null) {
if (!t) return;
if (t.node.kind === "compose") return composeAction(t.conn, t.node, "stop");
const ids = nodeContainers(t.node).filter(isRunning).map((c) => c.id);
if (ids.length) await containerOp(t.conn, ids, "stop");
}
export async function restart(t: DockerTarget | null) {
if (!t) return;
if (t.node.kind === "compose") return composeAction(t.conn, t.node, "restart");
const ids = nodeContainers(t.node).map((c) => c.id);
if (ids.length) await containerOp(t.conn, ids, "restart");
}
function composeAction(conn: string, n: Extract<ServiceNode, { kind: "compose" }>, op: "start" | "stop" | "restart" | "down") {
const first = n.containers[0];
return composeOp(conn, n.project, first?.composeFiles ?? [], first?.composeWorkingDir ?? null, op).then(() => undefined);
}
function deleteQuestion(kind: "container" | "image" | "volume" | "network", label: string, running: boolean) {
switch (kind) {
case "container":
return {
title: "Delete Container",
message: running
? tx`Delete container '${label}'? It is running and will be stopped.`
: tx`Delete container '${label}'?`,
};
case "image":
return { title: "Delete Image", message: tx`Delete image '${label}'?` };
case "volume":
return { title: "Delete Volume", message: tx`Delete volume '${label}'?` };
case "network":
return { title: "Delete Network", message: tx`Delete network '${label}'?` };
}
}
export async function remove(t: DockerTarget | null) {
if (!t || !canRemove(t)) return;
const n = t.node;
const label = nodeLabel(n);
if (n.kind === "compose") {
const ok = await confirm({
title: "Compose Down",
message: tx`Stop and remove the containers and networks of '${label}'?`,
ok: "Down",
});
if (ok) await composeAction(t.conn, n, "down");
return;
}
if (n.kind !== "container" && n.kind !== "image" && n.kind !== "volume" && n.kind !== "network") return;
const what = n.kind;
const running = n.kind === "container" && isRunning(n.container);
const answer = await confirmChecked({
...deleteQuestion(what, label, running),
ok: "Delete",
checkbox: n.kind === "image" ? "Force (also untag / remove when used by stopped containers)" : undefined,
});
if (!answer) return;
const id = n.kind === "container" ? n.container.id : n.kind === "image" ? n.image.id : n.kind === "volume" ? n.volume.name : n.network.id;
await removeObjects(t.conn, what, [id], running || answer.checked);
}
/** Opens a terminal tab with a shell in the container (bash when present, else sh). */
export function execShell(t: DockerTarget | null) {
const c = t?.node.kind === "container" ? t.node.container : null;
if (!t || !c || !isRunning(c)) return;
const snap = connectionState(t.conn).snapshot;
void executeAction("terminal.runCommand", {
program: snap?.executable ?? "docker",
args: [...(snap?.globalArgs ?? []), "exec", "-it", c.id, "sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"],
name: c.name,
});
}
export async function pullPrompt(conn: string) {
const ref = await promptText("Pull Image", "Image to pull:", "e.g. postgres:16 or ghcr.io/org/app:latest");
if (ref) await pullImage(conn, ref);
}
const PRUNE: Array<[PruneTarget, string, string]> = [
["containers", "Stopped Containers", "Remove all stopped containers?"],
["images", "Dangling Images", "Remove all dangling (untagged) images?"],
["allImages", "Unused Images", "Remove all images not used by a container?"],
["volumes", "Unused Volumes", "Remove all anonymous volumes not used by a container? Their data is lost."],
["networks", "Unused Networks", "Remove all networks not used by a container?"],
["system", "Everything Unused (System Prune)", "Remove stopped containers, unused networks, dangling images and the build cache?"],
];
export async function prunePrompt(conn: string, target: PruneTarget) {
const entry = PRUNE.find(([t]) => t === target);
if (!entry) return;
const ok = await confirm({ title: txTemplate(`Prune ${entry[1]}`), message: entry[2], ok: "Prune" });
if (ok) await prune(conn, target);
}
function pruneLabel(target: PruneTarget): string {
switch (target) {
case "containers":
return tx("Prune Unused Containers…");
case "images":
return tx("Prune Dangling Images…");
case "allImages":
return tx("Prune Unused Images…");
case "volumes":
return tx("Prune Unused Volumes…");
case "networks":
return tx("Prune Unused Networks…");
case "system":
return tx("Prune Everything Unused…");
}
}
export function pruneMenu(conn: string): MenuItem[] {
return PRUNE.map(([target, label]) => ({ label, run: () => void prunePrompt(conn, target) }));
}
const copy = (text: string) => void navigator.clipboard?.writeText(text);
export function showDetail(tab: "log" | "inspect") {
useServices.setState({ detailTab: tab });
}
/** Context menu of a node of a connection's subtree. */
export function nodeMenu(t: DockerTarget): MenuItem[] {
const { conn, node: n } = t;
const items: MenuItem[] = [];
if (n.kind === "container" || n.kind === "compose") {
items.push(
{ label: "Start", icon: "run", disabled: !canStart(t), run: () => void start(t) },
{ label: "Stop", icon: "stop", disabled: !canStop(t), run: () => void stop(t) },
{ label: "Restart", icon: "rerun", run: () => void restart(t) },
);
}
if (n.kind === "container") {
const running = isRunning(n.container);
items.push(
n.container.state === "paused"
? { label: "Unpause", icon: "resume", run: () => void containerOp(conn, [n.container.id], "unpause") }
: { label: "Pause", icon: "pause", disabled: !running, run: () => void containerOp(conn, [n.container.id], "pause") },
{ kind: "separator" },
{ label: "Show Log", icon: "file-log", run: () => showDetail("log") },
{ label: "Inspect", icon: "info", run: () => showDetail("inspect") },
{ label: "Create Terminal", icon: "terminal", disabled: !running, run: () => execShell(t) },
{ kind: "separator" },
{ label: "Copy Container ID", icon: "copy", run: () => copy(n.container.id) },
{ label: "Copy Image Name", run: () => copy(n.container.image) },
);
}
if (n.kind === "image" || n.kind === "volume" || n.kind === "network") {
items.push({ label: "Inspect", icon: "info", run: () => showDetail("inspect") });
if (n.kind === "image")
items.push(
{ label: "Pull Again", icon: "git-pull", disabled: n.image.repository === "<none>", run: () => void pullImage(conn, nodeLabel(n)) },
{ label: "Copy Image ID", icon: "copy", run: () => copy(shortId(n.image.id)) },
);
}
if (n.kind === "group" && n.group === "images") items.push({ label: "Pull Image…", icon: "git-pull", run: () => void pullPrompt(conn) });
if (n.kind === "group") {
const target: PruneTarget = n.group === "containers" ? "containers" : n.group === "images" ? "images" : n.group === "volumes" ? "volumes" : "networks";
items.push({
label: pruneLabel(target),
run: () => void prunePrompt(conn, target),
});
}
if (n.kind === "docker")
items.push(
{ label: "Refresh", icon: "refresh", run: () => void refresh(conn) },
{ label: "Pull Image…", icon: "git-pull", run: () => void pullPrompt(conn) },
{ label: "Prune", submenu: pruneMenu(conn) },
);
if (canRemove(t)) {
items.push({ kind: "separator" }, { label: n.kind === "compose" ? "Compose Down" : "Delete…", icon: "trash", danger: true, run: () => void remove(t) });
}
return items;
}