repos: sidebar actions (sync/edit/remove) + edit modal
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 35s
ci / rust (push) Successful in 2m43s
ci / e2e (push) Has been skipped
ci / publish (push) Successful in 4m7s

Sidebar:
- Each connection header now has three inline icon buttons: Sync now
  (spins while in flight), Edit (opens the modal), Remove (opens an
  inline confirm strip). Removes cascade repos via ON DELETE CASCADE.
- The connection's last_sync_error surfaces as a red inline banner
  under the header — no more 'error status with nowhere to see why'.
- Sync is POST /api/repos/connections/:id/sync (already existed);
  after either sync or delete the sidebar re-fetches so state stays
  consistent.

Edit modal (RepoConnectionEditModal):
- Loads GET /api/repos/connections/:id, pre-fills owner/base_url/label
- PATCHes only the fields that actually changed; empty string on a
  Some(&str) field sends explicit null so the backend clears it
- Sync-now + Remove reachable from inside the modal too
- Rotating the token is out of scope: the modal says as much and
  points the user at delete + re-create through the wizard (the
  broker doesn't expose an update path, and rotating in place would
  require duplicating the whole broker->store_secret flow here)

Backend:
- GET /api/repos/connections/:id — same ConnectionSummary shape
- PATCH /api/repos/connections/:id — owner/base_url use Option<Option<T>>
  double-nesting so 'omit = leave alone' and 'null = clear' round-trip
  distinctly through serde
- repo_connections::update with COALESCE-per-field so the SQL matches
  the double-Option semantics without an OR-chain per field
This commit is contained in:
Omar Sobh
2026-07-07 17:55:20 -07:00
parent b431d00f1a
commit 637e1bdd69
7 changed files with 795 additions and 4 deletions
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE repo_connections\n SET owner = CASE WHEN $3 THEN $4 ELSE owner END,\n base_url = CASE WHEN $5 THEN $6 ELSE base_url END,\n label = COALESCE($7, label),\n updated_at = now()\n WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Bool",
"Text",
"Bool",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "e9e2622dbd52c1d1145fc045adac33ba2eff3dc9341c86a2916a000ccb77ad5b"
}
+3 -1
View File
@@ -459,7 +459,9 @@ pub fn router(state: AppState) -> Router {
) )
.route( .route(
"/api/repos/connections/{id}", "/api/repos/connections/{id}",
delete(routes::repos::delete_connection), get(routes::repos::get_connection)
.patch(routes::repos::update_connection)
.delete(routes::repos::delete_connection),
) )
.route( .route(
"/api/repos/connections/{id}/sync", "/api/repos/connections/{id}/sync",
+86
View File
@@ -187,6 +187,92 @@ pub async fn list_connections(
Ok(Json(out)) Ok(Json(out))
} }
/// `GET /api/repos/connections/:id` — full detail for the edit modal.
pub async fn get_connection(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<ConnectionSummary>, ApiError> {
let c = cm_db::repo::repo_connections::get(&state.pool, id, user.workspace_id).await?;
Ok(Json(ConnectionSummary {
id: c.id.to_string(),
provider: c.provider,
owner: c.owner,
base_url: c.base_url,
label: c.label,
status: match c.last_sync_error.as_deref() {
Some(_) => "error".into(),
None if c.last_synced_at.is_some() => "connected".into(),
None => "pending".into(),
},
last_synced_at: c.last_synced_at.and_then(|t| t.format(&Rfc3339).ok()),
last_sync_error: c.last_sync_error,
created_at: c.created_at.format(&Rfc3339).unwrap_or_default(),
}))
}
/// `PATCH /api/repos/connections/:id` — edit owner / base_url / label on an
/// existing connection. Any field omitted from the body is left as-is;
/// explicit `null` on `owner` or `base_url` clears the value. Rotating the
/// PAT is out-of-band: delete + re-create through the wizard.
///
/// The response is the freshly-loaded connection so the client can react to
/// derived fields (`status`, `last_sync_error` cleared by a preceding sync).
pub async fn update_connection(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
Json(body): Json<UpdateConnectionRequest>,
) -> Result<Json<ConnectionSummary>, ApiError> {
let owner = body
.owner
.map(|opt| opt.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()));
let base_url = body
.base_url
.map(|opt| opt.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()));
let label = body
.label
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let owner_ref = owner.as_ref().map(|opt| opt.as_deref());
let base_url_ref = base_url.as_ref().map(|opt| opt.as_deref());
let ok = cm_db::repo::repo_connections::update(
&state.pool,
id,
user.workspace_id,
owner_ref,
base_url_ref,
label,
)
.await?;
if !ok {
return Err(ApiError::NotFound);
}
get_connection(State(state), Authed(user), Path(id)).await
}
#[derive(Deserialize)]
pub struct UpdateConnectionRequest {
/// `Some(None)` clears; `None` leaves unchanged.
#[serde(default, deserialize_with = "de_double_option")]
pub owner: Option<Option<String>>,
#[serde(default, deserialize_with = "de_double_option")]
pub base_url: Option<Option<String>>,
#[serde(default)]
pub label: Option<String>,
}
// serde default treats a missing field as `None` and an explicit `null` as
// `Some(None)` when the target type is Option<Option<T>>. Manual deserializer
// is needed because serde otherwise conflates the two.
fn de_double_option<'de, D>(d: D) -> Result<Option<Option<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<Option<String>>::deserialize(d)
}
/// `DELETE /api/repos/connections/:id` — remove the repo_connections row. /// `DELETE /api/repos/connections/:id` — remove the repo_connections row.
/// `ON DELETE CASCADE` cleans out its repos; the underlying app_connections /// `ON DELETE CASCADE` cleans out its repos; the underlying app_connections
/// row + broker secret stay (the workspace may reuse the token elsewhere). /// row + broker secret stay (the workspace may reuse the token elsewhere).
+39
View File
@@ -121,6 +121,45 @@ pub async fn get(
}) })
} }
/// Patch the mutable metadata of a connection. Any `None` field is left
/// untouched. Rotating the underlying PAT is a separate flow (delete + re-
/// create through the wizard) — the connection row doesn't own the credential.
pub async fn update(
pool: &PgPool,
id: Uuid,
workspace_id: WorkspaceId,
owner: Option<Option<&str>>,
base_url: Option<Option<&str>>,
label: Option<&str>,
) -> Result<bool, DbError> {
// COALESCE lets us pass a sentinel per-field: NULL means "leave alone",
// anything else means "set to this". `owner` and `base_url` need the
// second-nested Option so we can distinguish clear-to-null from no-op.
let owner_set = owner.is_some();
let owner_val = owner.and_then(|v| v.map(str::to_owned));
let base_url_set = base_url.is_some();
let base_url_val = base_url.and_then(|v| v.map(str::to_owned));
let label_val = label.map(str::to_owned);
let res = sqlx::query!(
"UPDATE repo_connections
SET owner = CASE WHEN $3 THEN $4 ELSE owner END,
base_url = CASE WHEN $5 THEN $6 ELSE base_url END,
label = COALESCE($7, label),
updated_at = now()
WHERE id = $1 AND workspace_id = $2",
id,
workspace_id.as_uuid(),
owner_set,
owner_val,
base_url_set,
base_url_val,
label_val,
)
.execute(pool)
.await?;
Ok(res.rows_affected() == 1)
}
pub async fn delete(pool: &PgPool, id: Uuid, workspace_id: WorkspaceId) -> Result<bool, DbError> { pub async fn delete(pool: &PgPool, id: Uuid, workspace_id: WorkspaceId) -> Result<bool, DbError> {
let res = sqlx::query!( let res = sqlx::query!(
"DELETE FROM repo_connections WHERE id = $1 AND workspace_id = $2", "DELETE FROM repo_connections WHERE id = $1 AND workspace_id = $2",
@@ -30,6 +30,7 @@ import { LoopsCanvas } from "./LoopsCanvas";
import { RepoList } from "./RepoList"; import { RepoList } from "./RepoList";
import { RepoCanvas } from "./RepoCanvas"; import { RepoCanvas } from "./RepoCanvas";
import { RepoConnectionWizardStub } from "./RepoConnectionWizardStub"; import { RepoConnectionWizardStub } from "./RepoConnectionWizardStub";
import { RepoConnectionEditModal } from "./RepoConnectionEditModal";
import { UserMenu } from "./UserMenu"; import { UserMenu } from "./UserMenu";
import { ToolPanel, type ToolKey } from "./ToolPanel"; import { ToolPanel, type ToolKey } from "./ToolPanel";
import { InfraNav, FleetConsole, FleetStatusBar, FleetPill } from "./fleet/FleetConsole"; import { InfraNav, FleetConsole, FleetStatusBar, FleetPill } from "./fleet/FleetConsole";
@@ -220,6 +221,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
const [repoSel, setRepoSel] = useState<string | null>(null); const [repoSel, setRepoSel] = useState<string | null>(null);
const [repoRefresh, setRepoRefresh] = useState(0); const [repoRefresh, setRepoRefresh] = useState(0);
const [repoWizardOpen, setRepoWizardOpen] = useState(false); const [repoWizardOpen, setRepoWizardOpen] = useState(false);
const [repoEditId, setRepoEditId] = useState<string | null>(null);
// Infrastructure tier: which nav view (local / cloud) + the connect-host wizard. // Infrastructure tier: which nav view (local / cloud) + the connect-host wizard.
const [infraSel, setInfraSel] = useState<string | null>("local"); const [infraSel, setInfraSel] = useState<string | null>("local");
const [infraConnectOpen, setInfraConnectOpen] = useState(false); const [infraConnectOpen, setInfraConnectOpen] = useState(false);
@@ -642,7 +644,9 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
selectedId={repoSel} selectedId={repoSel}
onSelect={setRepoSel} onSelect={setRepoSel}
onAdd={() => setRepoWizardOpen(true)} onAdd={() => setRepoWizardOpen(true)}
onEdit={(id) => setRepoEditId(id)}
refreshKey={repoRefresh} refreshKey={repoRefresh}
onRefresh={() => setRepoRefresh((n) => n + 1)}
/> />
) : isInfra ? ( ) : isInfra ? (
<InfraNav view={infraSel ?? "local"} onSelect={setInfraSel} onConnectHost={() => setInfraConnectOpen(true)} /> <InfraNav view={infraSel ?? "local"} onSelect={setInfraSel} onConnectHost={() => setInfraConnectOpen(true)} />
@@ -885,6 +889,17 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
}} }}
/> />
) : null} ) : null}
{repoEditId ? (
<RepoConnectionEditModal
connectionId={repoEditId}
onClose={() => setRepoEditId(null)}
onChanged={() => setRepoRefresh((n) => n + 1)}
onDeleted={() => {
setRepoSel(null);
setRepoRefresh((n) => n + 1);
}}
/>
) : null}
{historyOpen && clawAgent ? <BrainHistoryModal clawId={clawAgent.id} clawName={clawAgent.name} onClose={() => setHistoryOpen(false)} onRolledBack={() => setEnrichBump((b) => b + 1)} /> : null} {historyOpen && clawAgent ? <BrainHistoryModal clawId={clawAgent.id} clawName={clawAgent.name} onClose={() => setHistoryOpen(false)} onRolledBack={() => setEnrichBump((b) => b + 1)} /> : null}
{(() => { const sel = allNodes.find((n) => n.id === worldSel); return runsOpen && sel?.level === "team" ? <TeamRunsModal teamId={sel.id} teamName={sel.label} onClose={() => setRunsOpen(false)} /> : null; })()} {(() => { const sel = allNodes.find((n) => n.id === worldSel); return runsOpen && sel?.level === "team" ? <TeamRunsModal teamId={sel.id} teamName={sel.label} onClose={() => setRunsOpen(false)} /> : null; })()}
{reapOpen ? <ReapProgressModal items={selectedItems} kind={reapKind} onClose={() => setReapOpen(false)} onDone={() => { setReapOpen(false); setSelectMode(false); setSelectedAgents(new Set()); router.refresh(); }} /> : null} {reapOpen ? <ReapProgressModal items={selectedItems} kind={reapKind} onClose={() => setReapOpen(false)} onDone={() => { setReapOpen(false); setSelectMode(false); setSelectedAgents(new Set()); router.refresh(); }} /> : null}
@@ -0,0 +1,438 @@
"use client";
// Modal for editing an existing repo connection. Loads the current values
// from GET /api/repos/connections/:id, lets the user edit owner / base_url /
// label, then PATCHes the change and runs a fresh sync. PAT rotation isn't
// exposed here — that's a delete + re-create through the wizard so the
// broker's secret store stays the single write path for the token.
import { useEffect, useState } from "react";
import { RefreshCw, Trash2, X } from "lucide-react";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
interface ConnectionDetail {
id: string;
provider: string;
owner: string | null;
base_url: string | null;
label: string;
status: string;
last_synced_at: string | null;
last_sync_error: string | null;
created_at: string;
}
export function RepoConnectionEditModal({
connectionId,
onClose,
onChanged,
onDeleted,
}: {
connectionId: string;
onClose: () => void;
onChanged: () => void;
onDeleted: () => void;
}) {
const [initial, setInitial] = useState<ConnectionDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [owner, setOwner] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [label, setLabel] = useState("");
const [saving, setSaving] = useState(false);
const [syncing, setSyncing] = useState(false);
const [deleting, setDeleting] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [flash, setFlash] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError(null);
try {
const r = await fetch(`/api/repos/connections/${connectionId}`);
if (!r.ok) throw new Error(`load failed (${r.status})`);
const d = (await r.json()) as ConnectionDetail;
if (cancelled) return;
setInitial(d);
setOwner(d.owner ?? "");
setBaseUrl(d.base_url ?? "");
setLabel(d.label ?? "");
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "load failed");
} finally {
if (!cancelled) setLoading(false);
}
}
void load();
return () => {
cancelled = true;
};
}, [connectionId]);
const providerNeedsBase = initial?.provider === "gitea" || initial?.provider === "gitlab";
const dirty =
initial !== null &&
((initial.owner ?? "") !== owner.trim() ||
(initial.base_url ?? "") !== baseUrl.trim() ||
initial.label !== label.trim());
const canSave = dirty && !saving && !loading;
async function save() {
if (!canSave || !initial) return;
setSaving(true);
setError(null);
setFlash(null);
try {
// Explicit-null-clears semantics: send null when the user emptied the
// field, the string when they set one, and omit when unchanged.
const body: Record<string, unknown> = {};
if ((initial.owner ?? "") !== owner.trim()) {
body.owner = owner.trim() === "" ? null : owner.trim();
}
if ((initial.base_url ?? "") !== baseUrl.trim()) {
body.base_url = baseUrl.trim() === "" ? null : baseUrl.trim();
}
if (initial.label !== label.trim()) {
body.label = label.trim();
}
const r = await fetch(`/api/repos/connections/${connectionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`save failed (${r.status})`);
const updated = (await r.json()) as ConnectionDetail;
setInitial(updated);
setFlash("Saved");
onChanged();
} catch (e) {
setError(e instanceof Error ? e.message : "save failed");
} finally {
setSaving(false);
}
}
async function sync() {
if (syncing) return;
setSyncing(true);
setError(null);
setFlash(null);
try {
const r = await fetch(`/api/repos/connections/${connectionId}/sync`, { method: "POST" });
if (!r.ok) throw new Error(`sync failed (${r.status})`);
const d = (await r.json()) as { synced: number; sync_error: string | null };
setFlash(
d.sync_error ? `Sync failed: ${d.sync_error}` : `Synced ${d.synced} repos`,
);
// Reload the connection to pick up new last_synced_at / last_sync_error.
const rr = await fetch(`/api/repos/connections/${connectionId}`);
if (rr.ok) setInitial((await rr.json()) as ConnectionDetail);
onChanged();
} catch (e) {
setError(e instanceof Error ? e.message : "sync failed");
} finally {
setSyncing(false);
}
}
async function remove() {
if (deleting) return;
setDeleting(true);
setError(null);
try {
const r = await fetch(`/api/repos/connections/${connectionId}`, { method: "DELETE" });
if (!r.ok && r.status !== 204) throw new Error(`delete failed (${r.status})`);
onDeleted();
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "delete failed");
setDeleting(false);
}
}
return (
<div
onClick={onClose}
role="presentation"
style={{
position: "fixed",
inset: 0,
zIndex: 100,
background: "rgba(0,0,0,.62)",
backdropFilter: "blur(4px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
}}
>
<div
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="Edit provider connection"
style={{
width: "100%",
maxWidth: 520,
borderRadius: 16,
background: "#0d0d10",
border: "1px solid rgba(255,255,255,.1)",
padding: 22,
display: "flex",
flexDirection: "column",
gap: 14,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 700, color: "#f3f3f5" }}>
Edit provider connection
</div>
{initial ? (
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92", marginTop: 2 }}>
{initial.provider}
{initial.owner ? ` · ${initial.owner}` : ""}
</div>
) : null}
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
style={{
width: 28,
height: 28,
borderRadius: 8,
border: "1px solid rgba(255,255,255,.12)",
background: "transparent",
color: "#9a9aa2",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
}}
>
<X size={14} />
</button>
</div>
{loading ? (
<p style={hintStyle}>Loading…</p>
) : !initial ? (
<p style={{ ...hintStyle, color: "#ff8a7a" }}>{error ?? "not found"}</p>
) : (
<>
{initial.last_sync_error ? (
<div
style={{
padding: "10px 12px",
borderRadius: 8,
background: "rgba(255,138,122,.06)",
border: "1px solid rgba(255,138,122,.3)",
fontFamily: mono,
fontSize: 12,
color: "#ff8a7a",
lineHeight: 1.5,
}}
>
Last sync failed: {initial.last_sync_error}
</div>
) : initial.last_synced_at ? (
<div style={{ ...hintStyle, fontSize: 11 }}>
Last synced {new Date(initial.last_synced_at).toLocaleString()}
</div>
) : null}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={labelStyle}>Owner</span>
<input
value={owner}
onChange={(e) => setOwner(e.target.value)}
placeholder="org or username"
style={fieldStyle}
/>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={labelStyle}>Label</span>
<input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={`${initial.provider}${owner ? `/${owner}` : ""}`}
style={fieldStyle}
/>
</div>
</div>
{providerNeedsBase ? (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={labelStyle}>Base URL</span>
<input
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder={initial.provider === "gitea" ? "https://git.example.com" : "https://gitlab.com"}
style={{ ...fieldStyle, fontFamily: mono }}
/>
<p style={hintStyle}>
API base for the instance. Trailing <code style={{ color: "#eaeaee" }}>/api/v1</code> is appended
automatically if missing.
</p>
</div>
) : null}
<p style={hintStyle}>
To rotate the token: remove this connection and add a new one — the
broker owns the secret and doesn&apos;t expose an update path.
</p>
{error ? (
<p style={{ fontFamily: mono, fontSize: 12, color: "#ff8a7a", margin: 0 }}>
{error}
</p>
) : null}
{flash ? (
<p style={{ fontFamily: mono, fontSize: 12, color: "#7fd0a0", margin: 0 }}>
{flash}
</p>
) : null}
{confirmDelete ? (
<div
style={{
padding: "10px 12px",
borderRadius: 8,
background: "rgba(255,138,122,.06)",
border: "1px solid rgba(255,138,122,.35)",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<div style={{ fontSize: 12.5, color: "#eaeaee" }}>
Remove this connection? Its cached repos are deleted; the token
itself stays in the broker for now.
</div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button
type="button"
onClick={() => setConfirmDelete(false)}
disabled={deleting}
style={secondaryBtn}
>
Cancel
</button>
<button
type="button"
onClick={remove}
disabled={deleting}
style={{ ...primaryBtn, background: "#ff6f61" }}
>
{deleting ? "Removing…" : "Remove"}
</button>
</div>
</div>
) : (
<div style={{ display: "flex", gap: 8, justifyContent: "space-between", flexWrap: "wrap" }}>
<button
type="button"
onClick={() => setConfirmDelete(true)}
disabled={saving || syncing}
style={dangerBtn}
>
<Trash2 size={12} /> Remove
</button>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button
type="button"
onClick={sync}
disabled={syncing || saving}
style={secondaryBtn}
>
<RefreshCw size={12} /> {syncing ? "Syncing…" : "Sync now"}
</button>
<button
type="button"
onClick={save}
disabled={!canSave}
style={{ ...primaryBtn, opacity: canSave ? 1 : 0.4 }}
>
{saving ? "Saving…" : "Save changes"}
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}
const labelStyle: React.CSSProperties = {
fontFamily: mono,
fontSize: 10,
letterSpacing: ".12em",
textTransform: "uppercase",
color: "#6a6a72",
};
const fieldStyle: React.CSSProperties = {
padding: "9px 11px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.12)",
background: "#141417",
color: "#eaeaee",
fontSize: 12.5,
fontFamily: "inherit",
outline: 0,
};
const hintStyle: React.CSSProperties = {
fontFamily: mono,
fontSize: 11,
color: "#8a8a92",
margin: 0,
lineHeight: 1.5,
};
const primaryBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "9px 16px",
borderRadius: 8,
border: 0,
background: "#ff6f61",
color: "#1a0d0b",
fontSize: 12.5,
fontWeight: 700,
cursor: "pointer",
};
const secondaryBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "9px 16px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.14)",
background: "transparent",
color: "#cfcfd5",
fontSize: 12.5,
fontWeight: 600,
cursor: "pointer",
};
const dangerBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "9px 14px",
borderRadius: 8,
border: "1px solid rgba(255,138,122,.35)",
background: "transparent",
color: "#ff8a7a",
fontSize: 12.5,
fontWeight: 600,
cursor: "pointer",
};
+194 -3
View File
@@ -7,7 +7,7 @@
// v1 shows a placeholder until the backend routes land — see tasks #12–14. // v1 shows a placeholder until the backend routes land — see tasks #12–14.
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { GitBranch, Plus } from "lucide-react"; import { GitBranch, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
const mono = const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace"; "ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
@@ -30,7 +30,11 @@ export interface ConnectionSummary {
id: string; id: string;
provider: string; provider: string;
owner: string | null; owner: string | null;
base_url?: string | null;
label?: string;
status: string; status: string;
last_synced_at?: string | null;
last_sync_error?: string | null;
created_at: string; created_at: string;
} }
@@ -38,17 +42,52 @@ export function RepoList({
selectedId, selectedId,
onSelect, onSelect,
onAdd, onAdd,
onEdit,
refreshKey, refreshKey,
onRefresh,
}: { }: {
selectedId: string | null; selectedId: string | null;
onSelect: (id: string | null) => void; onSelect: (id: string | null) => void;
onAdd: () => void; onAdd: () => void;
onEdit: (connectionId: string) => void;
refreshKey: number; refreshKey: number;
onRefresh: () => void;
}) { }) {
const [connections, setConnections] = useState<ConnectionSummary[]>([]); const [connections, setConnections] = useState<ConnectionSummary[]>([]);
const [repos, setRepos] = useState<RepoSummary[]>([]); const [repos, setRepos] = useState<RepoSummary[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
async function sync(id: string) {
if (busyId) return;
setBusyId(id);
try {
await fetch(`/api/repos/connections/${id}/sync`, { method: "POST" });
} finally {
setBusyId(null);
onRefresh();
}
}
async function remove(id: string) {
if (busyId) return;
setBusyId(id);
try {
const res = await fetch(`/api/repos/connections/${id}`, { method: "DELETE" });
if (!res.ok && res.status !== 204) throw new Error(`delete failed (${res.status})`);
if (selectedId && repos.some((r) => r.id === selectedId && r.connection_id === id)) {
onSelect(null);
}
} catch (e) {
setError(e instanceof Error ? e.message : "delete failed");
} finally {
setBusyId(null);
setConfirmDeleteId(null);
onRefresh();
}
}
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -140,13 +179,15 @@ export function RepoList({
) : ( ) : (
connections.map((c) => { connections.map((c) => {
const list = byConn.get(c.id) ?? []; const list = byConn.get(c.id) ?? [];
const isError = c.status === "error";
const isBusy = busyId === c.id;
return ( return (
<div key={c.id} style={{ display: "flex", flexDirection: "column", marginBottom: 14 }}> <div key={c.id} style={{ display: "flex", flexDirection: "column", marginBottom: 14 }}>
<div <div
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 8, gap: 6,
padding: "0 4px 6px", padding: "0 4px 6px",
}} }}
> >
@@ -167,6 +208,9 @@ export function RepoList({
fontFamily: mono, fontFamily: mono,
fontSize: 10.5, fontSize: 10.5,
color: "#8a8a92", color: "#8a8a92",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}} }}
> >
{c.owner} {c.owner}
@@ -177,12 +221,109 @@ export function RepoList({
style={{ style={{
fontFamily: mono, fontFamily: mono,
fontSize: 10, fontSize: 10,
color: c.status === "connected" ? "#7fd0a0" : "#c8a464", color: isError ? "#ff8a7a" : c.status === "connected" ? "#7fd0a0" : "#c8a464",
}} }}
> >
{c.status} {c.status}
</span> </span>
<IconBtn
title="Sync now"
onClick={() => sync(c.id)}
disabled={isBusy}
spin={isBusy}
>
<RefreshCw size={11} />
</IconBtn>
<IconBtn title="Edit" onClick={() => onEdit(c.id)} disabled={isBusy}>
<Pencil size={11} />
</IconBtn>
<IconBtn
title="Remove"
onClick={() => setConfirmDeleteId(c.id)}
disabled={isBusy}
danger
>
<Trash2 size={11} />
</IconBtn>
</div> </div>
{isError && c.last_sync_error ? (
<div
style={{
margin: "0 4px 8px",
padding: "6px 8px",
borderRadius: 6,
background: "rgba(255,138,122,.08)",
border: "1px solid rgba(255,138,122,.25)",
color: "#ff8a7a",
fontFamily: mono,
fontSize: 10.5,
lineHeight: 1.45,
wordBreak: "break-word",
}}
title={c.last_sync_error}
>
{c.last_sync_error}
</div>
) : null}
{confirmDeleteId === c.id ? (
<div
style={{
margin: "0 4px 8px",
padding: "8px 10px",
borderRadius: 6,
background: "rgba(255,138,122,.06)",
border: "1px solid rgba(255,138,122,.3)",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<div style={{ fontFamily: mono, fontSize: 11, color: "#eaeaee" }}>
Remove this {c.provider} connection?
</div>
<div style={{ fontFamily: mono, fontSize: 10, color: "#8a8a92" }}>
Deletes the connection and its cached repos. The stored token stays in the
broker.
</div>
<div style={{ display: "flex", gap: 6, justifyContent: "flex-end" }}>
<button
type="button"
onClick={() => setConfirmDeleteId(null)}
disabled={isBusy}
style={{
padding: "5px 10px",
borderRadius: 6,
border: "1px solid rgba(255,255,255,.14)",
background: "transparent",
color: "#cfcfd5",
fontFamily: "inherit",
fontSize: 11,
cursor: isBusy ? "default" : "pointer",
}}
>
Cancel
</button>
<button
type="button"
onClick={() => remove(c.id)}
disabled={isBusy}
style={{
padding: "5px 10px",
borderRadius: 6,
border: 0,
background: "#ff6f61",
color: "#1a0d0b",
fontFamily: "inherit",
fontSize: 11,
fontWeight: 700,
cursor: isBusy ? "default" : "pointer",
}}
>
{isBusy ? "Removing…" : "Remove"}
</button>
</div>
</div>
) : null}
{list.length === 0 ? ( {list.length === 0 ? (
<p style={{ ...hintStyle, padding: "6px 4px" }}> <p style={{ ...hintStyle, padding: "6px 4px" }}>
No repos synced yet. No repos synced yet.
@@ -305,6 +446,56 @@ function EmptyState({ onAdd }: { onAdd: () => void }) {
); );
} }
function IconBtn({
children,
onClick,
title,
disabled,
danger,
spin,
}: {
children: React.ReactNode;
onClick: () => void;
title: string;
disabled?: boolean;
danger?: boolean;
spin?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
title={title}
aria-label={title}
disabled={disabled}
style={{
width: 22,
height: 22,
borderRadius: 5,
border: `1px solid ${danger ? "rgba(255,138,122,.28)" : "rgba(255,255,255,.08)"}`,
background: "transparent",
color: danger ? "#ff8a7a" : "#9a9aa2",
cursor: disabled ? "default" : "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
opacity: disabled ? 0.5 : 1,
transition: "background .12s ease",
}}
>
<span style={spin ? { animation: "repolist-spin 900ms linear infinite" } : undefined}>
{children}
</span>
<style>{`
@keyframes repolist-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</button>
);
}
const hintStyle: React.CSSProperties = { const hintStyle: React.CSSProperties = {
fontFamily: mono, fontFamily: mono,
fontSize: 11, fontSize: 11,