diff --git a/crates/radicle-tauri/src/commands/cob/issue.rs b/crates/radicle-tauri/src/commands/cob/issue.rs index 59d5e8ccb..44385a031 100644 --- a/crates/radicle-tauri/src/commands/cob/issue.rs +++ b/crates/radicle-tauri/src/commands/cob/issue.rs @@ -38,6 +38,16 @@ pub fn edit_issue( ctx.edit_issue(rid, cob_id, action, opts) } +#[tauri::command] +pub fn delete_issue( + ctx: tauri::State, + rid: identity::RepoId, + cob_id: git::Oid, + opts: types::cobs::CobOptions, +) -> Result<(), Error> { + ctx.delete_issue(rid, cob_id, opts) +} + #[tauri::command] pub(crate) async fn list_issues( ctx: tauri::State<'_, AppState>, diff --git a/crates/radicle-tauri/src/lib.rs b/crates/radicle-tauri/src/lib.rs index 659ef51e3..0cc25c67a 100644 --- a/crates/radicle-tauri/src/lib.rs +++ b/crates/radicle-tauri/src/lib.rs @@ -46,6 +46,7 @@ pub fn run() { cob::get_embed, cob::issue::activity_by_issue, cob::issue::comment_threads_by_issue_id, + cob::issue::delete_issue, cob::issue::create_issue, cob::issue::edit_issue, cob::issue::issue_by_id, diff --git a/crates/radicle-types/src/traits/issue.rs b/crates/radicle-types/src/traits/issue.rs index 89896c37e..53ec57171 100644 --- a/crates/radicle-types/src/traits/issue.rs +++ b/crates/radicle-types/src/traits/issue.rs @@ -164,4 +164,37 @@ pub trait IssuesMut: Profile { Ok::<_, Error>(cobs::issue::Issue::new(issue.id(), &issue, &aliases)) } + + /// Remove an issue COB. Equivalent to `rad issue delete`. + /// + /// Only the COB ref under our own namespace is dropped. On an issue we did + /// not author we hold no such ref, so this returns `Ok` having deleted + /// nothing — while still evicting the cache entry, hiding the issue until + /// the next rebuild. Callers must restrict this to our own issues. + fn delete_issue( + &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 issue is dropped from both + // the git refs and the COB cache that listings read from; otherwise the + // deleted issue keeps showing up in the issue list. + let mut issues = profile.issues_mut(&repo, &signer)?; + issues.remove(&cob_id.into())?; + drop(issues); + + 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 cf0cf43ad..baf0ba703 100644 --- a/crates/test-http-api/src/api.rs +++ b/crates/test-http-api/src/api.rs @@ -116,6 +116,7 @@ pub fn router(ctx: Context) -> Router { .route("/edit_issue", post(edit_issue_handler)) .route("/issue_by_id", post(issue_handler)) .route("/comment_threads_by_issue_id", post(issue_threads_handler)) + .route("/delete_issue", post(delete_issue_handler)) .route("/list_patches", post(patches_handler)) .route("/patch_by_id", post(patch_handler)) .route("/revisions_by_patch", post(revision_handler)) @@ -759,6 +760,23 @@ async fn create_patch_review_handler( Ok::<_, Error>(Json(review_id)) } +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DeleteIssueBody { + pub rid: identity::RepoId, + pub cob_id: git::Oid, + pub opts: CobOptions, +} + +async fn delete_issue_handler( + State(ctx): State, + Json(DeleteIssueBody { rid, cob_id, opts }): Json, +) -> impl IntoResponse { + ctx.delete_issue(rid, cob_id, opts)?; + + Ok::<_, Error>(Json(())) +} + #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct DeletePatchBody { diff --git a/src/components/ConfirmDeleteButton.svelte b/src/components/ConfirmDeleteButton.svelte new file mode 100644 index 000000000..703788475 --- /dev/null +++ b/src/components/ConfirmDeleteButton.svelte @@ -0,0 +1,114 @@ + + + + + + {#snippet toggle(onclick)} + + {/snippet} + {#snippet popover()} +
+
+
Delete this {noun} from your node?
+
+ Only your copy is removed. You won't be able to restore it here, and + peers who have already replicated the {noun} keep theirs. +
+
+
+ + +
+
+ {/snippet} +
diff --git a/src/components/IdChip.svelte b/src/components/IdChip.svelte new file mode 100644 index 000000000..4d364941a --- /dev/null +++ b/src/components/IdChip.svelte @@ -0,0 +1,88 @@ + + + + + diff --git a/src/components/IssueActivityItem.svelte b/src/components/IssueActivityItem.svelte index 494131861..6b7a832cf 100644 --- a/src/components/IssueActivityItem.svelte +++ b/src/components/IssueActivityItem.svelte @@ -2,12 +2,14 @@ import type { Author } from "@bindings/cob/Author"; import type { Action } from "@bindings/cob/issue/Action"; - export type FlattenedIssueOperation = Action & { - id: string; - author: Author; - timestamp: number; - previous?: Action; - }; + export type FlattenedIssueOperation = + | (Action & { + id: string; + author: Author; + timestamp: number; + previous?: Action; + }) + | { type: "opened"; id: string; author: Author; timestamp: number }; + + + +
+ {#if editing} + { + await editComment(comment, Array.from(embeds.values())); + editing = false; + }} + close={() => (editing = false)} /> + {:else} +
+ {#if body.trim() !== ""} + + {:else} + No description + {/if} +
+
+ { + try { + await reactOnComment(authors, emoji); + } finally { + closeFocused(); + } + }} /> +
+ {#if allowedToEdit} + + {/if} +
+
+ {/if} + {#if reactions.length > 0} +
+ +
+ {/if} +
diff --git a/src/components/IssueMetadata.svelte b/src/components/IssueMetadata.svelte new file mode 100644 index 000000000..c7a360752 --- /dev/null +++ b/src/components/IssueMetadata.svelte @@ -0,0 +1,108 @@ + + + + +
+
+ +
+ + + +
diff --git a/src/components/IssueStateButton.svelte b/src/components/IssueStateButton.svelte index 16fd9e9a1..99246b809 100644 --- a/src/components/IssueStateButton.svelte +++ b/src/components/IssueStateButton.svelte @@ -4,9 +4,12 @@ import capitalize from "lodash/capitalize"; import isEqual from "lodash/isEqual"; - import { issueStatusBackgroundColor, issueStatusColor } from "@app/lib/utils"; + import { + issueStatusBackgroundColor, + issueStatusColor, + issueStatusIcon, + } from "@app/lib/utils"; - import Button from "@app/components/Button.svelte"; import DropdownList from "@app/components/DropdownList.svelte"; import DropdownListItem from "@app/components/DropdownListItem.svelte"; import Icon from "@app/components/Icon.svelte"; @@ -22,83 +25,105 @@ const { selectedState, onSelect, disabled = false }: Props = $props(); let popoverExpanded: boolean = $state(false); + + function label(state: State): string { + return state.status === "closed" + ? `${capitalize(state.status)} as ${state.reason}` + : capitalize(state.status); + } - - {#snippet toggle(onclick)} - - {/snippet} - {#snippet popover()} -
- - {#snippet item(state)} - { - onSelect(state); - closeFocused(); - }}> - - - - - {capitalize(state.status)} - {state.status === "closed" ? `as ${state.reason}` : ""} - - - {/snippet} - -
- {/snippet} -
+ + +{#if disabled} + + + {label(selectedState)} + +{:else} + + {#snippet toggle(onclick)} + + {/snippet} + {#snippet popover()} +
+ + {#snippet item(state)} + { + onSelect(state); + closeFocused(); + }}> + + + + + {label(state)} + + + {/snippet} + +
+ {/snippet} +
+{/if} diff --git a/src/components/PatchMetadata.svelte b/src/components/PatchMetadata.svelte index 9c6676f70..db31df45f 100644 --- a/src/components/PatchMetadata.svelte +++ b/src/components/PatchMetadata.svelte @@ -8,10 +8,8 @@ import type { RepoInfo } from "@bindings/repo/RepoInfo"; import type { RepoRefs } from "@bindings/repo/RepoRefs"; - import debounce from "lodash/debounce"; - import { nodeRunning } from "@app/lib/events"; - import { invoke, writeToClipboard } from "@app/lib/invoke"; + import { invoke } from "@app/lib/invoke"; import type { ReviewEntry } from "@app/lib/reviewSummary"; import { entriesFromRevisions, @@ -24,7 +22,6 @@ import { authorForNodeId, defaultBranch, - formatOid, pluralize, publicKeyFromDid, unqualifyBranch, @@ -36,6 +33,7 @@ import DropdownList from "@app/components/DropdownList.svelte"; import DropdownListItem from "@app/components/DropdownListItem.svelte"; import Icon from "@app/components/Icon.svelte"; + import IdChip from "@app/components/IdChip.svelte"; import LabelInput from "@app/components/LabelInput.svelte"; import NodeId from "@app/components/NodeId.svelte"; import Popover, { closeFocused } from "@app/components/Popover.svelte"; @@ -100,16 +98,6 @@ }); } - let patchIdCopied = $state(false); - const resetPatchIdCopied = debounce(() => { - patchIdCopied = false; - }, 1000); - async function copyPatchId() { - await writeToClipboard(patch.id); - patchIdCopied = true; - resetPatchIdCopied(); - } - let labelSaveInProgress: boolean = $state(false); let assigneesSaveInProgress: boolean = $state(false); @@ -292,7 +280,6 @@ align-items: center; color: var(--color-text-brand); } - .patch-id-chip, .target-branch-chip, .author-chip { display: inline-flex; @@ -316,22 +303,12 @@ color: var(--color-text-tertiary); font: var(--txt-body-m-regular); } - .target-group .patch-id-chip, .target-group .target-branch-chip { height: 100%; border: 0; border-radius: 0; background: none; } - .patch-id-chip { - gap: 0.375rem; - cursor: pointer; - } - .patch-id-chip:hover, - .patch-id-chip:focus-visible { - background-color: var(--color-surface-subtle); - color: var(--color-text-primary); - } .target-arrow { display: inline-flex; align-items: center; @@ -345,26 +322,6 @@ background-color: var(--color-surface-subtle); color: var(--color-text-primary); } - .patch-id-value { - font: var(--txt-code-regular); - } - /* Patch icon by default, copy icon on hover, checkmark on click. */ - .pid-icon-default, - .pid-icon-hover { - display: inline-flex; - align-items: center; - } - .pid-icon-hover { - display: none; - } - .patch-id-chip:hover .pid-icon-default, - .patch-id-chip:focus-visible .pid-icon-default { - display: none; - } - .patch-id-chip:hover .pid-icon-hover, - .patch-id-chip:focus-visible .pid-icon-hover { - display: inline-flex; - }
@@ -372,19 +329,7 @@
- + {#if targetBranch} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index f7736e642..f0f37c59c 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -173,6 +173,11 @@ export const issueStatusBackgroundColor: Record< closed: "var(--color-surface-closed)", }; +export const issueStatusIcon = { + open: "issue", + closed: "issue-closed", +} as const satisfies Record; + export const issueStatusLabel: Record = { open: "Open Issues", closed: "Closed Issues", diff --git a/src/views/repo/Issue.svelte b/src/views/repo/Issue.svelte index 090c857ad..7b96a61d7 100644 --- a/src/views/repo/Issue.svelte +++ b/src/views/repo/Issue.svelte @@ -8,35 +8,31 @@ import type { Config } from "@bindings/config/Config"; import type { RepoInfo } from "@bindings/repo/RepoInfo"; - import partial from "lodash/partial"; - import { nodeRunning } from "@app/lib/events"; import { invoke } from "@app/lib/invoke"; import { show } from "@app/lib/modal"; import * as roles from "@app/lib/roles"; import * as router from "@app/lib/router"; import { - issueStatusBackgroundColor, - issueStatusColor, + issueStatusIcon, issueStatusLabel, publicKeyFromDid, } from "@app/lib/utils"; import { announce } from "@app/components/AnnounceSwitch.svelte"; - import AssigneeInput from "@app/components/AssigneeInput.svelte"; import Button from "@app/components/Button.svelte"; - import CommentComponent from "@app/components/Comment.svelte"; + import ConfirmDeleteButton from "@app/components/ConfirmDeleteButton.svelte"; import Discussion, { type ActivityItem, } from "@app/components/Discussion.svelte"; import EditableTitle from "@app/components/EditableTitle.svelte"; import Icon from "@app/components/Icon.svelte"; - import Id from "@app/components/Id.svelte"; import IssueActivityItem, { type FlattenedIssueOperation, } from "@app/components/IssueActivityItem.svelte"; + import IssueDescription from "@app/components/IssueDescription.svelte"; + import IssueMetadata from "@app/components/IssueMetadata.svelte"; import IssueStateButton from "@app/components/IssueStateButton.svelte"; - import LabelInput from "@app/components/LabelInput.svelte"; import ScrollArea from "@app/components/ScrollArea.svelte"; import ShareButton from "@app/components/ShareButton.svelte"; import Topbar from "@app/components/Topbar.svelte"; @@ -56,8 +52,17 @@ let { repo, issue, activity, config, threads }: Props = $props(); /* eslint-enable prefer-const */ - let labelSaveInProgress: boolean = $state(false); - let assigneesSaveInProgress: boolean = $state(false); + const canEditIssue = $derived( + roles.isDelegateOrAuthor( + config.publicKey, + repo.delegates.map(delegate => delegate.did), + issue.author.did, + ), + ); + + const isOwnIssue = $derived( + publicKeyFromDid(issue.author.did) === config.publicKey, + ); const activityItems: ActivityItem[] = $derived.by( () => { @@ -72,6 +77,18 @@ ]); const tracker: Partial> = {}; const items: ActivityItem[] = []; + const openedTimestamp = + issue.body?.edits[0]?.timestamp ?? issue.timestamp; + items.push({ + key: `${issue.id}:opened`, + timestamp: openedTimestamp, + data: { + type: "opened", + id: issue.id, + author: issue.author, + timestamp: openedTimestamp, + }, + }); activity.forEach(operation => { operation.actions.forEach((action, actionIndex) => { if (skipped.has(action.type)) { @@ -115,46 +132,6 @@ }, ); - async function saveLabels(labels: string[]) { - try { - labelSaveInProgress = true; - await invoke("edit_issue", { - rid: repo.rid, - cobId: issue.id, - action: { - type: "label", - labels, - }, - opts: { announce: $nodeRunning && $announce }, - }); - } catch (error) { - console.error("Editing labels failed", error); - } finally { - labelSaveInProgress = false; - await reload(); - } - } - - async function saveAssignees(assignees: Author[]) { - try { - assigneesSaveInProgress = true; - await invoke("edit_issue", { - rid: repo.rid, - cobId: issue.id, - action: { - type: "assign", - assignees, - }, - opts: { announce: $nodeRunning && $announce }, - }); - } catch (error) { - console.error("Editing assignees failed", error); - } finally { - assigneesSaveInProgress = false; - await reload(); - } - } - async function reload() { [issue, activity, threads] = await Promise.all([ invoke("issue_by_id", { @@ -276,6 +253,23 @@ } } + async function deleteIssue() { + try { + await invoke("delete_issue", { + rid: repo.rid, + cobId: issue.id, + opts: { announce: $nodeRunning && $announce }, + }); + void router.push({ + resource: "repo.issues", + rid: repo.rid, + status: issue.state.status, + }); + } catch (error) { + console.error("Deleting issue failed", error); + } + } + async function saveState(state: Issue["state"]) { try { await invoke("edit_issue", { @@ -305,6 +299,7 @@ display: flex; align-items: center; gap: 0.375rem; + min-width: 0; } .breadcrumb-link { cursor: pointer; @@ -317,59 +312,41 @@ .breadcrumb-link:hover { color: var(--color-text-primary); } - .content { - display: grid; - grid-template-columns: 1fr 22rem; + .breadcrumb-title { + color: var(--color-text-primary); + font: var(--txt-body-m-medium); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .main { - padding: 1.5rem 2rem; + padding: 1.5rem 6rem; min-width: 0; + max-width: 80rem; + margin: 0 auto; + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-areas: + "title" + "meta" + "content"; } .title { + grid-area: title; display: flex; align-items: center; gap: 0.75rem; + margin-top: 1.5rem; margin-bottom: 1rem; } - .status-chip { - padding: 0; - height: 2rem; - width: 2rem; - flex-shrink: 0; + .meta-bar { + grid-area: meta; + margin-bottom: 0.5rem; } - .issue-body { - margin: 1rem 0; - position: relative; - } - .sidebar { - display: flex; - flex-direction: column; - gap: 0.5rem; - border-left: 1px solid var(--color-border-subtle); - height: 100%; - padding: 1.5rem 1rem; - } - .sidebar-section { - padding: 0.5rem; - font: var(--txt-body-m-regular); - display: flex; - flex-direction: column; - align-items: flex-start; - } - @media (max-width: 1349.98px) { - .content { - grid-template-columns: 1fr; - } - .sidebar { - order: -1; - border-left: none; - border-bottom: 1px solid var(--color-border-subtle); - flex-direction: row; - align-items: flex-start; - } - .sidebar-section { - flex: 1; - } + .content { + grid-area: content; + min-width: 0; } @@ -377,7 +354,7 @@
-
+
+ {#if isOwnIssue} + + {/if} -
-
-
-
- -
- delegate.did), - issue.author.did, - )} - title={issue.title} - cobId={issue.id} /> -
+
+
+ + +
+
+ +
+ +
{#if issue.body} -
- 1 - ? issue.body.edits.at(-1) - : undefined} - author={issue.body.author} - caption="opened" - reactions={issue.body.reactions} - timestamp={issue.body.edits.slice(-1)[0].timestamp} - body={issue.body.edits.slice(-1)[0].body} - editComment={roles.isDelegateOrAuthor( - config.publicKey, - repo.delegates.map(delegate => delegate.did), - issue.body.author.did, - ) && partial(editComment, issue.body.id)} - reactOnComment={partial(reactOnComment, issue.body.id)}> - -
+ {@const body = issue.body} + editComment(body.id, text, embeds)} + reactOnComment={(authors, reaction) => + reactOnComment(body.id, authors, reaction)} /> {/if} {#snippet renderActivity( @@ -482,38 +448,6 @@ {renderActivity} authorOf={op => op.author} />
- -
diff --git a/src/views/repo/Patch.svelte b/src/views/repo/Patch.svelte index 891c5f5e4..e93fb113d 100644 --- a/src/views/repo/Patch.svelte +++ b/src/views/repo/Patch.svelte @@ -43,6 +43,7 @@ import { announce } from "@app/components/AnnounceSwitch.svelte"; import Button from "@app/components/Button.svelte"; import CheckoutPatchButton from "@app/components/CheckoutPatchButton.svelte"; + import ConfirmDeleteButton from "@app/components/ConfirmDeleteButton.svelte"; import DraftReviewBar from "@app/components/DraftReviewBar.svelte"; import DropdownList from "@app/components/DropdownList.svelte"; import DropdownListItem from "@app/components/DropdownListItem.svelte"; @@ -577,11 +578,7 @@ }; }); - let deleteMenuExpanded = $state(false); - let deleting = $state(false); async function deletePatch() { - if (deleting) return; - deleting = true; try { await invoke("delete_patch", { rid: repo.rid, @@ -595,9 +592,6 @@ }); } catch (error) { console.error("Deleting patch failed", error); - } finally { - deleting = false; - deleteMenuExpanded = false; } } @@ -613,54 +607,6 @@ align-items: center; gap: 0.375rem; } - .confirm-delete { - display: flex; - flex-direction: column; - gap: 0.75rem; - padding: 0.75rem; - min-width: 16rem; - /* Without a cap the prompt lays itself out on one line and spans the - window. */ - max-width: 24rem; - } - .confirm-delete-text { - display: flex; - flex-direction: column; - gap: 0.5rem; - color: var(--color-text-primary); - } - .confirm-delete-note { - color: var(--color-text-secondary); - } - .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; @@ -1002,56 +948,7 @@ a second Delete up here would be one word for two different targets. --> {#if isOwnPatch && !currentReview} - - {#snippet toggle(onclick)} - - {/snippet} - {#snippet popover()} -
-
-
-
- Delete this patch from your node? -
-
- Only your copy is removed. You won't be able to restore it - here, and peers who have already replicated the patch keep - theirs. -
-
-
- - -
-
-
- {/snippet} -
+ {/if} { await page.getByRole("link", { name: "cobs" }).click(); await page.getByRole("link", { name: "Issues" }).click(); await page.getByText("This title has **markdown**").click(); - const body = page.locator(".issue-body"); + const body = page.locator(".description-body"); await expect(body.getByText("This is a description")).toBeVisible(); const topLevelComments = await page.locator(".comments").all(); @@ -43,7 +43,9 @@ test("creation of top level comments", async ({ page }) => { ); await page.getByRole("button", { name: /^Save/ }).click(); await expect( - page.getByText("Make sure that comment creation is working"), + page.getByRole("button", { + name: "Make sure that comment creation is working", + }), ).toBeVisible(); await expect( page.getByText(