diff --git a/crates/radicle-tauri/src/commands/cob/patch.rs b/crates/radicle-tauri/src/commands/cob/patch.rs index 399930325..36e687563 100644 --- a/crates/radicle-tauri/src/commands/cob/patch.rs +++ b/crates/radicle-tauri/src/commands/cob/patch.rs @@ -85,6 +85,16 @@ pub fn edit_patch( ctx.edit_patch(rid, cob_id, action, opts) } +#[tauri::command] +pub fn delete_patch( + ctx: tauri::State, + rid: identity::RepoId, + cob_id: git::Oid, + opts: cobs::CobOptions, +) -> Result<(), Error> { + ctx.delete_patch(rid, cob_id, opts) +} + #[tauri::command] pub fn create_patch_review( ctx: tauri::State, diff --git a/crates/radicle-tauri/src/lib.rs b/crates/radicle-tauri/src/lib.rs index a17dc83a3..44b6e9327 100644 --- a/crates/radicle-tauri/src/lib.rs +++ b/crates/radicle-tauri/src/lib.rs @@ -43,10 +43,10 @@ pub fn run() { cob::issue::rebuild_issue_cache, cob::job::list_jobs, cob::patch::activity_by_patch, + cob::patch::delete_patch, cob::patch::edit_patch, cob::patch::list_patches, cob::patch::patch_by_id, - cob::patch::edit_patch, cob::patch::create_patch_review, cob::patch::rebuild_patch_cache, cob::patch::review_by_patch_and_revision_and_id, diff --git a/crates/radicle-types/src/error.rs b/crates/radicle-types/src/error.rs index df882787d..ca38f2f96 100644 --- a/crates/radicle-types/src/error.rs +++ b/crates/radicle-types/src/error.rs @@ -179,6 +179,10 @@ pub enum Error { #[error(transparent)] Node(#[from] radicle::node::Error), + /// Cob remove error. + #[error(transparent)] + CobRemove(#[from] radicle::cob::error::Remove), + /// Serde JSON error. #[error(transparent)] SerdeJSON(#[from] serde_json::error::Error), diff --git a/crates/radicle-types/src/traits/patch.rs b/crates/radicle-types/src/traits/patch.rs index dfc61dced..b379170fc 100644 --- a/crates/radicle-types/src/traits/patch.rs +++ b/crates/radicle-types/src/traits/patch.rs @@ -332,4 +332,37 @@ pub trait PatchesMut: Profile { &aliases, )) } + + /// Remove a patch COB. Equivalent to `rad patch delete`. + /// + /// Only the COB ref under our own namespace is dropped. On a patch we did + /// not author we hold no such ref, so this returns `Ok` having deleted + /// nothing — while still evicting the cache entry, hiding the patch until + /// the next rebuild. Callers must restrict this to our own patches. + fn delete_patch( + &self, + rid: identity::RepoId, + cob_id: git::Oid, + opts: cobs::CobOptions, + ) -> Result<(), Error> { + let profile = self.profile(); + let mut node = Node::new(profile.home().socket_from_env()); + let repo = profile.storage.repository(rid)?; + let signer = profile.signer()?; + + // Remove via the cache-backed store so the patch is dropped from both + // the git refs and the COB cache that listings read from; otherwise the + // deleted patch keeps showing up in the patch list. + let mut patches = profile.patches_mut(&repo, &signer)?; + patches.remove(&cob_id.into())?; + drop(patches); + + if opts.announce() + && let Err(e) = node.announce_refs_for(rid, [profile.public_key]) + { + log::error!("Not able to announce changes: {}", e) + } + + Ok(()) + } } diff --git a/crates/test-http-api/src/api.rs b/crates/test-http-api/src/api.rs index 5e8bfd31f..69f2375a7 100644 --- a/crates/test-http-api/src/api.rs +++ b/crates/test-http-api/src/api.rs @@ -114,6 +114,7 @@ pub fn router(ctx: Context) -> Router { .route("/revisions_by_patch", post(revision_handler)) .route("/edit_patch", post(edit_patch_handler)) .route("/create_patch_review", post(create_patch_review_handler)) + .route("/delete_patch", post(delete_patch_handler)) .route("/get_embed", post(get_embeds_handler)) .route("/save_embed_by_path", post(save_embed_handler)) .route("/save_embed_by_clipboard", post(save_embed_handler)) @@ -715,6 +716,23 @@ async fn create_patch_review_handler( Ok::<_, Error>(Json(review_id)) } +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DeletePatchBody { + pub rid: identity::RepoId, + pub cob_id: git::Oid, + pub opts: CobOptions, +} + +async fn delete_patch_handler( + State(ctx): State, + Json(DeletePatchBody { rid, cob_id, opts }): Json, +) -> impl IntoResponse { + ctx.delete_patch(rid, cob_id, opts)?; + + Ok::<_, Error>(Json(())) +} + #[derive(Serialize, Deserialize)] struct JobsBody { pub rid: identity::RepoId, diff --git a/src/views/repo/Patch.svelte b/src/views/repo/Patch.svelte index c0b243791..e9952a795 100644 --- a/src/views/repo/Patch.svelte +++ b/src/views/repo/Patch.svelte @@ -36,6 +36,7 @@ didFromPublicKey, formatTimestamp, patchStatusLabel, + publicKeyFromDid, } from "@app/lib/utils"; import { announce } from "@app/components/AnnounceSwitch.svelte"; @@ -121,6 +122,14 @@ ), ); + // Deleting drops the COB ref under our own namespace, so peers prune it when + // they next fetch. We hold no such ref on anyone else's patch, where deleting + // would only evict the local cache and the patch would return on the next + // fetch — so the action is author-only. + const isOwnPatch = $derived( + publicKeyFromDid(patch.author.did) === config.publicKey, + ); + // svelte-ignore state_referenced_locally let tab: "patch" | "revisions" | "timeline" = $state( revisions.length > 1 ? "revisions" : "patch", @@ -560,6 +569,30 @@ cancelled = true; }; }); + + let deleteMenuExpanded = $state(false); + let deleting = $state(false); + async function deletePatch() { + if (deleting) return; + deleting = true; + try { + await invoke("delete_patch", { + rid: repo.rid, + cobId: patch.id, + opts: { announce: $nodeRunning && $announce }, + }); + void router.push({ + resource: "repo.patches", + rid: repo.rid, + status: patch.state.status, + }); + } catch (error) { + console.error("Deleting patch failed", error); + } finally { + deleting = false; + deleteMenuExpanded = false; + } + }