repos: sidebar actions (sync/edit/remove) + edit modal
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:
+20
@@ -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"
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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'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",
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user