Add patch deletion

This commit is contained in:
Brandon Oxendine
2026-08-10 12:54:39 +02:00
committed by Rūdolfs Ošiņš
parent 62e150c29a
commit d19d1f21f1
6 changed files with 185 additions and 1 deletions
@@ -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<AppState>,
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<AppState>,
+1 -1
View File
@@ -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,
+4
View File
@@ -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),
+33
View File
@@ -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(())
}
}
+18
View File
@@ -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<Context>,
Json(DeletePatchBody { rid, cob_id, opts }): Json<DeletePatchBody>,
) -> impl IntoResponse {
ctx.delete_patch(rid, cob_id, opts)?;
Ok::<_, Error>(Json(()))
}
#[derive(Serialize, Deserialize)]
struct JobsBody {
pub rid: identity::RepoId,
+119
View File
@@ -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;
}
}
</script>
<style>
@@ -573,6 +606,45 @@
align-items: center;
gap: 0.375rem;
}
.confirm-delete {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
min-width: 16rem;
}
.confirm-delete-text {
color: var(--color-text-primary);
}
.confirm-delete-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.confirm-delete-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
height: 2rem;
padding: 0 0.75rem;
border: 0;
border-radius: var(--border-radius-sm);
background-color: var(--color-feedback-error-fill);
color: var(--color-text-on-brand);
cursor: pointer;
transition: background-color 0.1s ease;
}
.confirm-delete-button:hover:not(:disabled),
.confirm-delete-button:focus-visible:not(:disabled) {
background-color: var(--color-feedback-error-fill-hover);
}
.confirm-delete-button:active:not(:disabled) {
background-color: var(--color-feedback-error-fill-active);
}
.confirm-delete-button:disabled {
cursor: default;
opacity: 0.6;
}
.breadcrumb-link {
cursor: pointer;
background: none;
@@ -886,6 +958,53 @@
style:margin-left="auto"
style:gap="0.5rem"
style:z-index="40">
{#if isOwnPatch}
<Popover
popoverPadding="0"
placement="bottom-end"
bind:expanded={deleteMenuExpanded}>
{#snippet toggle(onclick)}
<Button
variant="naked"
{onclick}
active={deleteMenuExpanded}
title="Delete patch from your node">
<Icon name="trash" />
<span class="global-hide-on-medium-desktop-down">Delete</span>
</Button>
{/snippet}
{#snippet popover()}
<div
style:border="1px solid var(--color-border-subtle)"
style:border-radius="var(--border-radius-sm)"
style:background-color="var(--color-surface-canvas)">
<div class="confirm-delete">
<div class="confirm-delete-text txt-body-m-regular">
Delete this patch from your node? This removes your copy
only — you won't be able to restore it here, and peers who
have already replicated the patch keep theirs.
</div>
<div class="confirm-delete-actions">
<Button
variant="outline"
disabled={deleting}
onclick={() => (deleteMenuExpanded = false)}>
Cancel
</Button>
<button
type="button"
class="confirm-delete-button txt-body-m-medium"
disabled={deleting}
onclick={deletePatch}>
<Icon name="trash" />
{deleting ? "Deleting…" : "Delete"}
</button>
</div>
</div>
</div>
{/snippet}
</Popover>
{/if}
<ShareButton
explorerPath={`${repo.rid}/patches/${patch.id}`}
id={patch.id}