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
+86
View File
@@ -187,6 +187,92 @@ pub async fn list_connections(
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.
/// `ON DELETE CASCADE` cleans out its repos; the underlying app_connections
/// row + broker secret stay (the workspace may reuse the token elsewhere).