/** Imperative prompt dialogs of the Services view, rendered by one overlay. */ import { Fragment, useState, type ReactNode } from "react"; import { create } from "zustand"; import type { DockerConnection, DockerEndpoint, DockerRegistry } from "@/platform/ipc"; import { Button, Dialog, Input, Select, T } from "@/ui"; import { errorMessage, newId, saveConnection, saveRegistry, testRegistryLogin } from "./store"; import styles from "./Services.module.css"; interface Entry { id: number; node: ReactNode; } const useDialogs = create<{ stack: Entry[] }>(() => ({ stack: [] })); let seq = 0; function open(render: (close: () => void) => ReactNode): void { const id = ++seq; const close = () => useDialogs.setState((s) => ({ stack: s.stack.filter((e) => e.id !== id) })); useDialogs.setState((s) => ({ stack: [...s.stack, { id, node: render(close) }] })); } export function ServicesDialogHost() { const stack = useDialogs((s) => s.stack); return ( <> {stack.map((e) => ( {e.node} ))} > ); } export function promptText(title: string, label: string, placeholder: string): Promise { return new Promise((resolve) => open((close) => ( { close(); resolve(v); }} /> )), ); } function PromptDialog(props: { title: string; label: string; placeholder: string; onDone: (v: string | null) => void; }) { const [value, setValue] = useState(""); const ok = value.trim().length > 0; return ( props.onDone(null)} width={420} buttons={[ { label: "OK", role: "default", disabled: !ok, onClick: () => props.onDone(value.trim()) }, { label: "Cancel", role: "cancel" }, ]} > { e.preventDefault(); if (ok) props.onDone(value.trim()); }} > {props.label} setValue(e.target.value)} /> ); } async function pickDirectory(title: string): Promise { const { open: openDialog } = await import("@tauri-apps/plugin-dialog"); const dir = await openDialog({ multiple: false, directory: true, title }); return typeof dir === "string" ? dir : null; } type EndpointKind = DockerEndpoint["kind"]; const ENDPOINT_KINDS: Array<{ value: EndpointKind; label: string }> = [ { value: "default", label: "Docker CLI default (DOCKER_HOST / current context)" }, { value: "socket", label: "Unix socket" }, { value: "tcp", label: "TCP / SSH host" }, { value: "context", label: "Docker context" }, ]; /** The endpoint described by the dialog's fields, or null while incomplete. */ export function endpointFrom( kind: EndpointKind, fields: { path: string; host: string; tls: string; context: string }, ): DockerEndpoint | null { switch (kind) { case "default": return { kind }; case "socket": return fields.path.trim() ? { kind, path: fields.path.trim() } : null; case "tcp": return fields.host.trim() ? { kind, host: fields.host.trim(), tlsCertDir: fields.tls.trim() || null } : null; case "context": { const name = fields.context.trim(); return name && !name.startsWith("-") ? { kind, name } : null; } } } /** "Add Service › Docker Connection…" (or edit `existing`). */ export function openConnectionDialog(existing?: DockerConnection) { open((close) => ); } function ConnectionDialog({ existing, onDone }: { existing?: DockerConnection; onDone: () => void }) { const ep = existing?.endpoint; const [name, setName] = useState(existing?.name ?? "Docker"); const [kind, setKind] = useState(ep?.kind ?? "socket"); const [path, setPath] = useState(ep?.kind === "socket" ? ep.path : "/var/run/docker.sock"); const [host, setHost] = useState(ep?.kind === "tcp" ? ep.host : ""); const [tls, setTls] = useState(ep?.kind === "tcp" ? (ep.tlsCertDir ?? "") : ""); const [context, setContext] = useState(ep?.kind === "context" ? ep.name : ""); const endpoint = endpointFrom(kind, { path, host, tls, context }); const ok = !!endpoint && name.trim().length > 0; const submit = async () => { if (!endpoint || !ok) return; if (await saveConnection({ id: existing?.id ?? newId("c"), name: name.trim(), endpoint })) onDone(); }; return ( void submit() }, { label: "Cancel", role: "cancel" }, ]} > { e.preventDefault(); void submit(); }} > Name: setName(e.target.value)} /> Connect to Docker daemon with: aria-label="Connection type" value={kind} options={ENDPOINT_KINDS} onChange={setKind} /> {kind === "socket" && ( <> Socket path or Engine API URL: setPath(e.target.value)} /> > )} {kind === "tcp" && ( <> Engine API URL: setHost(e.target.value)} /> Certificates folder (TLS, optional): setTls(e.target.value)} /> void pickDirectory("Certificates Folder").then((d) => d && setTls(d))}> Browse… > )} {kind === "context" && ( <> Context name: setContext(e.target.value)} /> > )} ); } /** "Add Service › Docker Registry…" (or edit `existing`). */ export function openRegistryDialog(existing?: DockerRegistry) { open((close) => ); } function RegistryDialog({ existing, onDone }: { existing?: DockerRegistry; onDone: () => void }) { const [name, setName] = useState(existing?.name ?? "Docker Registry"); const [address, setAddress] = useState(existing?.address ?? "docker.io"); const [username, setUsername] = useState(existing?.username ?? ""); // Left empty when editing: the saved password stays. const [password, setPassword] = useState(""); const [status, setStatus] = useState<{ ok: boolean; text: string } | null>(null); const [testing, setTesting] = useState(false); const [id] = useState(() => existing?.id ?? newId("r")); const ok = name.trim().length > 0 && address.trim().length > 0; const registry = (): DockerRegistry => ({ id, name: name.trim(), address: address.trim(), username: username.trim(), }); const submit = async () => { if (!ok) return; if (await saveRegistry(registry(), password ? password : existing ? undefined : null)) onDone(); }; const test = async () => { setTesting(true); setStatus(null); try { setStatus({ ok: true, text: await testRegistryLogin(registry(), password || null) }); } catch (e) { setStatus({ ok: false, text: errorMessage(e) }); } finally { setTesting(false); } }; return ( {status.text}} buttons={[ { label: "Test Connection", role: "extra", disabled: !ok || !username.trim() || testing, onClick: () => void test(), }, { label: "OK", role: "default", disabled: !ok, onClick: () => void submit() }, { label: "Cancel", role: "cancel" }, ]} > { e.preventDefault(); void submit(); }} > Name: setName(e.target.value)} /> Address: setAddress(e.target.value)} /> Username: setUsername(e.target.value)} /> Password: setPassword(e.target.value)} /> Images from this registry are pulled with these credentials. ); }