worker: make/jq/gopls in base image + per-repo .lvmh/setup.sh hook (agent-driven persistent setup)
This commit is contained in:
+17
-17
@@ -7,27 +7,27 @@ SRC="${LVMH_PI_AGENT_DIR:-$HOME/.dotfiles/pi/agent}"
|
|||||||
DEST="$(dirname "$0")/../docker/pi-agent"
|
DEST="$(dirname "$0")/../docker/pi-agent"
|
||||||
|
|
||||||
if [ ! -f "$SRC/settings.json" ]; then
|
if [ ! -f "$SRC/settings.json" ]; then
|
||||||
echo "rsync-pi-agent: $SRC/settings.json not found — skipping dotfiles sync" >&2
|
echo "rsync-pi-agent: $SRC/settings.json not found — skipping dotfiles sync" >&2
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p "$DEST"
|
mkdir -p "$DEST"
|
||||||
rsync -a --delete \
|
rsync -a --delete \
|
||||||
--exclude 'auth.json' \
|
--exclude 'auth.json' \
|
||||||
--exclude 'models.json' \
|
--exclude 'models.json' \
|
||||||
--exclude 'models-store.json' \
|
--exclude 'models-store.json' \
|
||||||
--exclude 'sessions/' \
|
--exclude 'sessions/' \
|
||||||
--exclude 'cache/' \
|
--exclude 'cache/' \
|
||||||
--exclude 'npm/' \
|
--exclude 'npm/' \
|
||||||
--exclude 'git/' \
|
--exclude 'git/' \
|
||||||
--exclude 'trust.json' \
|
--exclude 'trust.json' \
|
||||||
--exclude 'mcp-*' \
|
--exclude 'mcp-*' \
|
||||||
--exclude 'run-history*' \
|
--exclude 'run-history*' \
|
||||||
--exclude 'vibes' \
|
--exclude 'vibes' \
|
||||||
--exclude '*.log' \
|
--exclude '*.log' \
|
||||||
--exclude 'scratch/' \
|
--exclude 'scratch/' \
|
||||||
--exclude '.pi/' \
|
--exclude '.pi/' \
|
||||||
"$SRC/" "$DEST/"
|
"$SRC/" "$DEST/"
|
||||||
|
|
||||||
# Container sessions cannot run git: package postinstalls (e.g. husky) — keep
|
# Container sessions cannot run git: package postinstalls (e.g. husky) — keep
|
||||||
# registry (npm:) packages only in the baked settings.json.
|
# registry (npm:) packages only in the baked settings.json.
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# docker/ — worker image & bridge
|
||||||
|
|
||||||
|
## lvmh-worker image (`worker.Dockerfile`)
|
||||||
|
|
||||||
|
Headless pi worker: node 24, pi (global npm), golang, git, ripgrep, make, jq,
|
||||||
|
gopls, plus the user's pi config baked from dotfiles (`deploy/rsync-pi-agent.sh`:
|
||||||
|
settings/skills/agents/extensions; no auth.json/sessions/cache; `git:` packages
|
||||||
|
dropped — postinstalls crash headless installs).
|
||||||
|
|
||||||
|
## Per-repo setup hook (agent-driven image setup)
|
||||||
|
|
||||||
|
The agent works as root with network access, so it can `apt-get install` /
|
||||||
|
`go install` anything **in-session**. Containers are ephemeral, so system
|
||||||
|
packages vanish on container stop. For durability, a repo can ship:
|
||||||
|
|
||||||
|
/workspace/.lvmh/setup.sh
|
||||||
|
|
||||||
|
The bridge (`bridge/index.mjs`) runs it with bash on **every spawn** before the
|
||||||
|
pi session starts (10-min timeout, failure is non-fatal — logged, session
|
||||||
|
continues). Since `/workspace` is a persistent per-repo volume, the hook
|
||||||
|
survives container death. Agents that need extra tooling should write that
|
||||||
|
script into the repo (e.g. `apt-get update && apt-get install -y sqlite3`).
|
||||||
|
|
||||||
|
## bridge/index.mjs
|
||||||
|
|
||||||
|
Hosts the SDK session (`createAgentSession` + `bindExtensions({mode:"rpc"})` —
|
||||||
|
the SDK never fires `session_start` without the explicit bind, and the lvmh
|
||||||
|
plugin dials home off that event).
|
||||||
@@ -2,13 +2,51 @@
|
|||||||
// at /root/.pi/agent/extensions/lvmh-agent.ts) dials the daemon and delivers
|
// at /root/.pi/agent/extensions/lvmh-agent.ts) dials the daemon and delivers
|
||||||
// web prompts through pi.sendUserMessage. This process just hosts the session.
|
// web prompts through pi.sendUserMessage. This process just hosts the session.
|
||||||
// Global npm layout: resolve pi via absolute path (NODE_PATH does not apply to ESM).
|
// Global npm layout: resolve pi via absolute path (NODE_PATH does not apply to ESM).
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
import { createAgentSession } from "/usr/local/lib/node_modules/@earendil-works/pi-coding-agent/dist/index.js";
|
import { createAgentSession } from "/usr/local/lib/node_modules/@earendil-works/pi-coding-agent/dist/index.js";
|
||||||
|
|
||||||
|
const SETUP_PATH = "/workspace/.lvmh/setup.sh";
|
||||||
|
const SETUP_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
// Repo-level setup hook: if the repo ships .lvmh/setup.sh, run it before the
|
||||||
|
// session starts (apt-get/go install/pip — anything the agent needs).
|
||||||
|
// /workspace is a persistent per-repo volume, so the hook survives container
|
||||||
|
// restarts and re-runs on every spawn. Failure is non-fatal: log and continue.
|
||||||
|
async function runRepoSetup() {
|
||||||
|
if (!existsSync(SETUP_PATH)) return;
|
||||||
|
console.error(`[lvmh-bridge] running repo setup: ${SETUP_PATH}`);
|
||||||
|
const code = await new Promise((resolve) => {
|
||||||
|
const child = spawn("bash", [SETUP_PATH], {
|
||||||
|
cwd: "/workspace",
|
||||||
|
stdio: ["ignore", "inherit", "inherit"],
|
||||||
|
});
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
console.error(`[lvmh-bridge] setup timed out after ${SETUP_TIMEOUT_MS}ms, killing`);
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
resolve(124);
|
||||||
|
}, SETUP_TIMEOUT_MS);
|
||||||
|
timer.unref?.();
|
||||||
|
child.on("error", (err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
console.error("[lvmh-bridge] setup spawn error:", err);
|
||||||
|
resolve(1);
|
||||||
|
});
|
||||||
|
child.on("exit", (c) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(c ?? 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (code === 0) console.error("[lvmh-bridge] setup completed");
|
||||||
|
else console.error(`[lvmh-bridge] setup exited ${code} — continuing anyway`);
|
||||||
|
}
|
||||||
|
|
||||||
process.on("unhandledRejection", (err) => {
|
process.on("unhandledRejection", (err) => {
|
||||||
console.error("[lvmh-bridge] unhandledRejection:", err);
|
console.error("[lvmh-bridge] unhandledRejection:", err);
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await runRepoSetup();
|
||||||
const { session } = await createAgentSession();
|
const { session } = await createAgentSession();
|
||||||
// SDK does not bind extensions implicitly (unlike TUI/RPC modes); without
|
// SDK does not bind extensions implicitly (unlike TUI/RPC modes); without
|
||||||
// bindExtensions the session_start event never fires, so the lvmh plugin
|
// bindExtensions the session_start event never fires, so the lvmh plugin
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ FROM node:24-bookworm-slim
|
|||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends \
|
||||||
bash ca-certificates git ripgrep curl xz-utils \
|
bash ca-certificates git ripgrep curl xz-utils make jq \
|
||||||
golang-go \
|
golang-go \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN GOBIN=/usr/local/bin go install golang.org/x/tools/gopls@latest || true
|
||||||
|
|
||||||
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
||||||
|
|
||||||
# User's pi config from dotfiles (settings, skills, agents, extensions,
|
# User's pi config from dotfiles (settings, skills, agents, extensions,
|
||||||
|
|||||||
+261
-228
@@ -1,6 +1,12 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import type { GitlabStatus, Repo, SessionListItem, SpawnJob, SpawnResponse } from "./protocol";
|
import type {
|
||||||
|
GitlabStatus,
|
||||||
|
Repo,
|
||||||
|
SessionListItem,
|
||||||
|
SpawnJob,
|
||||||
|
SpawnResponse,
|
||||||
|
} from "./protocol";
|
||||||
import { Route } from "./protocol";
|
import { Route } from "./protocol";
|
||||||
import { errMessage, fetchJson } from "./api";
|
import { errMessage, fetchJson } from "./api";
|
||||||
import type { SessionsStore } from "./store";
|
import type { SessionsStore } from "./store";
|
||||||
@@ -9,252 +15,279 @@ const POLL_MS: number = 1500;
|
|||||||
const POLL_MAX_TICKS: number = 400; // ~10min then give up polling (spawn may still finish)
|
const POLL_MAX_TICKS: number = 400; // ~10min then give up polling (spawn may still finish)
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
store: SessionsStore;
|
store: SessionsStore;
|
||||||
pushToast: (text: string) => void;
|
pushToast: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SpawnView({ store, pushToast }: Props) {
|
export default function SpawnView({ store, pushToast }: Props) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [status, setStatus] = useState<GitlabStatus | null>(null);
|
const [status, setStatus] = useState<GitlabStatus | null>(null);
|
||||||
const [pat, setPat] = useState<string>("");
|
const [pat, setPat] = useState<string>("");
|
||||||
const [repos, setRepos] = useState<Repo[] | null>(null);
|
const [repos, setRepos] = useState<Repo[] | null>(null);
|
||||||
const [query, setQuery] = useState<string>("");
|
const [query, setQuery] = useState<string>("");
|
||||||
const [selected, setSelected] = useState<Repo | null>(null);
|
const [selected, setSelected] = useState<Repo | null>(null);
|
||||||
const [branch, setBranch] = useState<string>("");
|
const [branch, setBranch] = useState<string>("");
|
||||||
const [busy, setBusy] = useState<boolean>(false);
|
const [busy, setBusy] = useState<boolean>(false);
|
||||||
const [error, setError] = useState<string>("");
|
const [error, setError] = useState<string>("");
|
||||||
const [spawning, setSpawning] = useState<SpawnResponse | null>(null);
|
const [spawning, setSpawning] = useState<SpawnResponse | null>(null);
|
||||||
|
|
||||||
const timerRef = useRef<number | null>(null);
|
const timerRef = useRef<number | null>(null);
|
||||||
const tickRef = useRef<number>(0);
|
const tickRef = useRef<number>(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (timerRef.current !== null) window.clearInterval(timerRef.current);
|
if (timerRef.current !== null) window.clearInterval(timerRef.current);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadStatus = async (): Promise<GitlabStatus> => {
|
const loadStatus = async (): Promise<GitlabStatus> => {
|
||||||
const s = await fetchJson<GitlabStatus>(Route.GitlabStatus);
|
const s = await fetchJson<GitlabStatus>(Route.GitlabStatus);
|
||||||
setStatus(s);
|
setStatus(s);
|
||||||
return s;
|
return s;
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadRepos = async (): Promise<void> => {
|
const loadRepos = async (): Promise<void> => {
|
||||||
const list = await fetchJson<Repo[]>(Route.GitlabRepos);
|
const list = await fetchJson<Repo[]>(Route.GitlabRepos);
|
||||||
setRepos(list);
|
setRepos(list);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const s = await loadStatus();
|
const s = await loadStatus();
|
||||||
if (s.connected) await loadRepos();
|
if (s.connected) await loadRepos();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errMessage(err));
|
setError(errMessage(err));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const connect = async (): Promise<void> => {
|
const connect = async (): Promise<void> => {
|
||||||
if (pat.trim().length === 0) {
|
if (pat.trim().length === 0) {
|
||||||
setError("token required");
|
setError("token required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
await fetchJson(Route.GitlabConnect, { method: "POST", body: JSON.stringify({ token: pat.trim() }) });
|
await fetchJson(Route.GitlabConnect, {
|
||||||
setPat("");
|
method: "POST",
|
||||||
await loadStatus();
|
body: JSON.stringify({ token: pat.trim() }),
|
||||||
await loadRepos();
|
});
|
||||||
} catch (err) {
|
setPat("");
|
||||||
setError(errMessage(err));
|
await loadStatus();
|
||||||
} finally {
|
await loadRepos();
|
||||||
setBusy(false);
|
} catch (err) {
|
||||||
}
|
setError(errMessage(err));
|
||||||
};
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const pick = (repo: Repo): void => {
|
const pick = (repo: Repo): void => {
|
||||||
setSelected(repo);
|
setSelected(repo);
|
||||||
setBranch(repo.defaultBranch);
|
setBranch(repo.defaultBranch);
|
||||||
};
|
};
|
||||||
|
|
||||||
const filtered: Repo[] = (repos ?? []).filter((r) => r.path.toLowerCase().includes(query.toLowerCase()));
|
const filtered: Repo[] = (repos ?? []).filter((r) =>
|
||||||
|
r.path.toLowerCase().includes(query.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
const spawn = async (): Promise<void> => {
|
const spawn = async (): Promise<void> => {
|
||||||
if (selected === null) {
|
if (selected === null) {
|
||||||
setError("pick a repo first");
|
setError("pick a repo first");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const body = { repo: selected.path, ...(branch.trim().length > 0 ? { branch: branch.trim() } : {}) };
|
const body = {
|
||||||
const res = await fetchJson<SpawnResponse>(Route.Spawn, { method: "POST", body: JSON.stringify(body) });
|
repo: selected.path,
|
||||||
setSpawning(res);
|
...(branch.trim().length > 0 ? { branch: branch.trim() } : {}),
|
||||||
startPolling(res.sessionId);
|
};
|
||||||
} catch (err) {
|
const res = await fetchJson<SpawnResponse>(Route.Spawn, {
|
||||||
setError(errMessage(err));
|
method: "POST",
|
||||||
} finally {
|
body: JSON.stringify(body),
|
||||||
setBusy(false);
|
});
|
||||||
}
|
setSpawning(res);
|
||||||
};
|
startPolling(res.sessionId);
|
||||||
|
} catch (err) {
|
||||||
|
setError(errMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const startPolling = (sessionId: string): void => {
|
const startPolling = (sessionId: string): void => {
|
||||||
tickRef.current = 0;
|
tickRef.current = 0;
|
||||||
if (timerRef.current !== null) window.clearInterval(timerRef.current);
|
if (timerRef.current !== null) window.clearInterval(timerRef.current);
|
||||||
timerRef.current = window.setInterval(() => {
|
timerRef.current = window.setInterval(() => {
|
||||||
tickRef.current += 1;
|
tickRef.current += 1;
|
||||||
if (tickRef.current > POLL_MAX_TICKS) {
|
if (tickRef.current > POLL_MAX_TICKS) {
|
||||||
if (timerRef.current !== null) window.clearInterval(timerRef.current);
|
if (timerRef.current !== null) window.clearInterval(timerRef.current);
|
||||||
timerRef.current = null;
|
timerRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const list = await fetchJson<SessionListItem[]>(Route.Sessions);
|
const list = await fetchJson<SessionListItem[]>(Route.Sessions);
|
||||||
const s = list.find((x) => x.id === sessionId);
|
const s = list.find((x) => x.id === sessionId);
|
||||||
await store.refresh();
|
await store.refresh();
|
||||||
if (s !== undefined && s.online) {
|
if (s !== undefined && s.online) {
|
||||||
if (timerRef.current !== null) window.clearInterval(timerRef.current);
|
if (timerRef.current !== null)
|
||||||
timerRef.current = null;
|
window.clearInterval(timerRef.current);
|
||||||
navigate(`/s/${sessionId}`);
|
timerRef.current = null;
|
||||||
}
|
navigate(`/s/${sessionId}`);
|
||||||
} catch (err) {
|
}
|
||||||
pushToast(errMessage(err));
|
} catch (err) {
|
||||||
}
|
pushToast(errMessage(err));
|
||||||
})();
|
}
|
||||||
}, POLL_MS);
|
})();
|
||||||
};
|
}, POLL_MS);
|
||||||
|
};
|
||||||
|
|
||||||
const jobLine = (): string => {
|
const jobLine = (): string => {
|
||||||
if (spawning === null) return "";
|
if (spawning === null) return "";
|
||||||
const job: SpawnJob | undefined = store.spawnJobs.find((j) => j.sessionId === spawning.sessionId);
|
const job: SpawnJob | undefined = store.spawnJobs.find(
|
||||||
if (job !== undefined) return `${job.repo}: ${job.state}`;
|
(j) => j.sessionId === spawning.sessionId,
|
||||||
return "waiting for session to come online…";
|
);
|
||||||
};
|
if (job !== undefined) return `${job.repo}: ${job.state}`;
|
||||||
|
return "waiting for session to come online…";
|
||||||
|
};
|
||||||
|
|
||||||
if (status === null) {
|
if (status === null) {
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<h1>Spawn</h1>
|
<h1>Spawn</h1>
|
||||||
<p className="empty">{error.length > 0 ? `gitlab status failed: ${error}` : "checking gitlab…"}</p>
|
<p className="empty">
|
||||||
</div>
|
{error.length > 0
|
||||||
);
|
? `gitlab status failed: ${error}`
|
||||||
}
|
: "checking gitlab…"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!status.connected) {
|
if (!status.connected) {
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<h1>Spawn</h1>
|
<h1>Spawn</h1>
|
||||||
<form
|
<form
|
||||||
className="spawn-card"
|
className="spawn-card"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void connect();
|
void connect();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2>Connect GitLab</h2>
|
<h2>Connect GitLab</h2>
|
||||||
<p className="repo-meta" style={{ margin: "0 0 10px" }}>
|
<p className="repo-meta" style={{ margin: "0 0 10px" }}>
|
||||||
Paste a personal access token (api scope). Stored daemon-side only.
|
Paste a personal access token (api scope). Stored daemon-side only.
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={pat}
|
value={pat}
|
||||||
placeholder="glpat-…"
|
placeholder="glpat-…"
|
||||||
aria-label="GitLab personal access token"
|
aria-label="GitLab personal access token"
|
||||||
onChange={(e) => setPat(e.target.value)}
|
onChange={(e) => setPat(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{error.length > 0 && <p className="error-text">{error}</p>}
|
{error.length > 0 && <p className="error-text">{error}</p>}
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button className="btn-primary" type="submit" disabled={busy}>
|
<button className="btn-primary" type="submit" disabled={busy}>
|
||||||
{busy ? "Connecting…" : "Connect"}
|
{busy ? "Connecting…" : "Connect"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<h1>Spawn</h1>
|
<h1>Spawn</h1>
|
||||||
|
|
||||||
{spawning !== null && (
|
{spawning !== null && (
|
||||||
<div className="spawn-card">
|
<div className="spawn-card">
|
||||||
<h2>Spawning…</h2>
|
<h2>Spawning…</h2>
|
||||||
<p className="spawn-job-line">{jobLine()}</p>
|
<p className="spawn-job-line">{jobLine()}</p>
|
||||||
<p className="repo-meta">container {spawning.containerId.slice(0, 12)}</p>
|
<p className="repo-meta">
|
||||||
{error.length > 0 && <p className="error-text">{error}</p>}
|
container {spawning.containerId.slice(0, 12)}
|
||||||
</div>
|
</p>
|
||||||
)}
|
{error.length > 0 && <p className="error-text">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{spawning === null && (
|
{spawning === null && (
|
||||||
<>
|
<>
|
||||||
<div className="spawn-card">
|
<div className="spawn-card">
|
||||||
<h2>Repository</h2>
|
<h2>Repository</h2>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
placeholder="filter repos…"
|
placeholder="filter repos…"
|
||||||
aria-label="Filter repositories"
|
aria-label="Filter repositories"
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{repos === null && <p className="repo-meta">loading repos…</p>}
|
{repos === null && <p className="repo-meta">loading repos…</p>}
|
||||||
{repos !== null && filtered.length === 0 && <p className="repo-meta">no matching repos</p>}
|
{repos !== null && filtered.length === 0 && (
|
||||||
<div className="repo-list" style={{ marginTop: 10 }}>
|
<p className="repo-meta">no matching repos</p>
|
||||||
{filtered.map((r) => (
|
)}
|
||||||
<div
|
<div className="repo-list" style={{ marginTop: 10 }}>
|
||||||
key={r.path}
|
{filtered.map((r) => (
|
||||||
className={`repo-item ${selected?.path === r.path ? "selected" : ""}`}
|
<div
|
||||||
role="button"
|
key={r.path}
|
||||||
tabIndex={0}
|
className={`repo-item ${selected?.path === r.path ? "selected" : ""}`}
|
||||||
aria-pressed={selected?.path === r.path}
|
role="button"
|
||||||
onClick={() => pick(r)}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => {
|
aria-pressed={selected?.path === r.path}
|
||||||
if (e.key === "Enter" || e.key === " ") pick(r);
|
onClick={() => pick(r)}
|
||||||
}}
|
onKeyDown={(e) => {
|
||||||
>
|
if (e.key === "Enter" || e.key === " ") pick(r);
|
||||||
<div style={{ minWidth: 0 }}>
|
}}
|
||||||
<div className="repo-path">{r.path}</div>
|
>
|
||||||
<div className="repo-meta">
|
<div style={{ minWidth: 0 }}>
|
||||||
default {r.defaultBranch} · {r.lastActivityAt.slice(0, 10)}
|
<div className="repo-path">{r.path}</div>
|
||||||
</div>
|
<div className="repo-meta">
|
||||||
</div>
|
default {r.defaultBranch} ·{" "}
|
||||||
</div>
|
{r.lastActivityAt.slice(0, 10)}
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="spawn-card">
|
<div className="spawn-card">
|
||||||
<h2>Branch</h2>
|
<h2>Branch</h2>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={branch}
|
value={branch}
|
||||||
aria-label="Branch"
|
aria-label="Branch"
|
||||||
placeholder={selected?.defaultBranch ?? "main"}
|
placeholder={selected?.defaultBranch ?? "main"}
|
||||||
disabled={selected === null}
|
disabled={selected === null}
|
||||||
onChange={(e) => setBranch(e.target.value)}
|
onChange={(e) => setBranch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
disabled={selected === null || busy}
|
disabled={selected === null || busy}
|
||||||
aria-label="Spawn container"
|
aria-label="Spawn container"
|
||||||
onClick={() => void spawn()}
|
onClick={() => void spawn()}
|
||||||
>
|
>
|
||||||
{busy ? "Spawning…" : "Spawn"}
|
{busy ? "Spawning…" : "Spawn"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{selected === null && <p className="repo-meta" style={{ marginTop: 8 }}>select a repo above</p>}
|
{selected === null && (
|
||||||
{error.length > 0 && <p className="error-text">{error}</p>}
|
<p className="repo-meta" style={{ marginTop: 8 }}>
|
||||||
</div>
|
select a repo above
|
||||||
</>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
{error.length > 0 && <p className="error-text">{error}</p>}
|
||||||
);
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user