Align issue page design with patch page

Rebuild the issue page as a single column matching the patch page:
the state dropdown sits next to the title, with author, issue ID,
labels and assignees in a row beneath it.

Let issue authors change the state, not only delegates. Add a
delete_issue command so authors can delete their own issues.

Share the delete confirmation and ID chip with the patch page.
This commit is contained in:
Brandon Oxendine
2026-09-25 12:48:35 +02:00
committed by Rūdolfs Ošiņš
parent 54835363a9
commit 1d2cdaf033
15 changed files with 828 additions and 504 deletions
@@ -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<AppState>,
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>,
+1
View File
@@ -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,
+33
View File
@@ -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(())
}
}
+18
View File
@@ -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<Context>,
Json(DeleteIssueBody { rid, cob_id, opts }): Json<DeleteIssueBody>,
) -> impl IntoResponse {
ctx.delete_issue(rid, cob_id, opts)?;
Ok::<_, Error>(Json(()))
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DeletePatchBody {
+114
View File
@@ -0,0 +1,114 @@
<script lang="ts">
import Button from "@app/components/Button.svelte";
import Icon from "@app/components/Icon.svelte";
import Popover, { closeFocused } from "@app/components/Popover.svelte";
interface Props {
noun: string;
onDelete: () => Promise<void>;
}
const { noun, onDelete }: Props = $props();
let expanded = $state(false);
let deleting = $state(false);
async function confirm() {
deleting = true;
try {
await onDelete();
} finally {
deleting = false;
closeFocused();
}
}
</script>
<style>
.confirm-delete {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
min-width: 16rem;
max-width: 24rem;
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-sm);
background-color: var(--color-surface-canvas);
}
.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;
}
</style>
<Popover popoverPadding="0" placement="bottom-end" bind:expanded>
{#snippet toggle(onclick)}
<Button
variant="naked"
{onclick}
active={expanded}
title={`Delete ${noun} from your node`}>
<Icon name="trash" />
<span class="global-hide-on-medium-desktop-down">Delete</span>
</Button>
{/snippet}
{#snippet popover()}
<div class="confirm-delete">
<div class="confirm-delete-text">
<div class="txt-body-m-medium">Delete this {noun} from your node?</div>
<div class="confirm-delete-note txt-body-m-regular">
Only your copy is removed. You won't be able to restore it here, and
peers who have already replicated the {noun} keep theirs.
</div>
</div>
<div class="confirm-delete-actions">
<Button variant="outline" disabled={deleting} onclick={closeFocused}>
Cancel
</Button>
<button
type="button"
class="confirm-delete-button txt-body-m-medium"
disabled={deleting}
onclick={confirm}>
<Icon name="trash" />
{deleting ? "Deleting…" : "Delete"}
</button>
</div>
</div>
{/snippet}
</Popover>
+88
View File
@@ -0,0 +1,88 @@
<script lang="ts">
import debounce from "lodash/debounce";
import { writeToClipboard } from "@app/lib/invoke";
import { formatOid } from "@app/lib/utils";
import Icon from "@app/components/Icon.svelte";
interface Props {
id: string;
label: string;
grouped?: boolean;
}
const { id, label, grouped = false }: Props = $props();
let copied = $state(false);
const resetCopied = debounce(() => {
copied = false;
}, 1000);
async function copy() {
await writeToClipboard(id);
copied = true;
resetCopied();
}
</script>
<style>
.id-chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
height: 2rem;
padding: 0 0.5rem;
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-sm);
background-color: var(--color-surface-canvas);
color: var(--color-text-tertiary);
font: var(--txt-body-m-regular);
cursor: pointer;
}
.id-chip.grouped {
height: 100%;
border: 0;
border-radius: 0;
background: none;
}
.id-chip:hover,
.id-chip:focus-visible {
background-color: var(--color-surface-subtle);
color: var(--color-text-primary);
}
.value {
font: var(--txt-code-regular);
}
.icon-default,
.icon-hover {
display: inline-flex;
align-items: center;
}
.icon-hover {
display: none;
}
.id-chip:hover .icon-default,
.id-chip:focus-visible .icon-default {
display: none;
}
.id-chip:hover .icon-hover,
.id-chip:focus-visible .icon-hover {
display: inline-flex;
}
</style>
<button
type="button"
class="id-chip"
class:grouped
title={copied ? "Copied to clipboard" : `Copy ${label}`}
onclick={copy}>
{#if copied}
<Icon name="checkmark" />
{:else}
<span class="icon-default"><Icon name="hash" /></span>
<span class="icon-hover"><Icon name="copy" /></span>
{/if}
<span class="value">{formatOid(id)}</span>
</button>
+96 -87
View File
@@ -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 };
</script>
<script lang="ts">
@@ -16,10 +18,12 @@
authorForNodeId,
formatTimestamp,
issueStatusColor,
issueStatusIcon,
pluralize,
} from "@app/lib/utils";
import Icon from "@app/components/Icon.svelte";
import Label from "@app/components/Label.svelte";
import NodeId from "@app/components/NodeId.svelte";
interface Props {
@@ -46,153 +50,158 @@
padding: 0.375rem 0.5rem;
min-height: 2.5rem;
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1rem;
}
.wrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
min-width: 0;
flex: 1 1 0;
}
.icon {
padding-top: 0;
}
.summary-line {
.summary {
display: flex;
align-items: center;
gap: 0.375rem;
flex: 1 1 0;
min-width: 0;
gap: 0.25rem;
white-space: nowrap;
overflow: hidden;
color: var(--color-text-primary);
}
/* Ellipsis has to sit on a child: it does not apply to the anonymous flex
items a flex container makes of its text nodes. */
.summary-text {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.timestamp {
flex-shrink: 0;
color: var(--color-text-quaternary);
}
</style>
{#if op.type === "lifecycle"}
{#snippet timestamp()}
<span class="timestamp" title={absoluteTimestamp(op.timestamp)}>
{formatTimestamp(op.timestamp)}
</span>
{/snippet}
{#if op.type === "opened"}
<div class="timeline-item txt-body-m-regular">
<div class="icon" style:color={issueStatusColor[op.state.status]}>
{#if op.state.status === "open"}
<Icon name="issue" />
{:else}
<Icon name="issue-closed" />
{/if}
<div class="icon" style:color={issueStatusColor.open}>
<Icon name={issueStatusIcon.open} />
</div>
<div class="wrapper">
{#if !hideAuthor}<NodeId {...authorForNodeId(op.author)} />{/if}
<div class="summary-line">
{#if op.state.status === "closed"}
closed issue as {op.state.reason}
{:else if op.state.status === "open"}
reopened issue
{/if}
</div>
<div class="timestamp" title={absoluteTimestamp(op.timestamp)}>
{formatTimestamp(op.timestamp)}
</div>
<span class="summary">
<span class="summary-text">opened this issue</span>
</span>
{@render timestamp()}
</div>
</div>
{:else if op.type === "lifecycle"}
<div class="timeline-item txt-body-m-regular">
<div class="icon" style:color={issueStatusColor[op.state.status]}>
<Icon name={issueStatusIcon[op.state.status]} />
</div>
<div class="wrapper">
{#if !hideAuthor}<NodeId {...authorForNodeId(op.author)} />{/if}
<span class="summary">
<span class="summary-text">
{#if op.state.status === "closed"}
closed this issue as {op.state.reason}
{:else}
reopened this issue
{/if}
</span>
</span>
{@render timestamp()}
</div>
</div>
{:else if op.type === "label"}
<div class="timeline-item txt-body-m-regular">
<div class="icon">
<Icon name="label" />
</div>
<div class="icon"><Icon name="label" /></div>
<div class="wrapper">
{#if !hideAuthor}<NodeId {...authorForNodeId(op.author)} />{/if}
<div class="summary-line">
<span class="summary">
{#if op.previous && op.previous.type === op.type}
{@const changed = itemDiff(op.previous?.labels ?? [], op.labels)}
{#if changed.added.length || changed.removed.length}
{#if changed.added.length}
added {pluralize("label", changed.removed.length)}
{#each changed.added as label}
<b>{label}</b>
{/each}
{/if}
{#if changed.removed.length}
{#if changed.added.length}
<span class="summary-text">
added {pluralize("label", changed.added.length)}
</span>
{#each changed.added as label}<Label {label} />{/each}
{/if}
{#if changed.removed.length}
<span class="summary-text">
removed {pluralize("label", changed.removed.length)}
{#each changed.removed as label}
<b>{label}</b>
{/each}
{/if}
</span>
{#each changed.removed as label}<Label {label} />{/each}
{/if}
{:else}
added {pluralize("label", op.labels.length)}
{#each op.labels as label}
<b>{label}</b>
{/each}
<span class="summary-text">
added {pluralize("label", op.labels.length)}
</span>
{#each op.labels as label}<Label {label} />{/each}
{/if}
</div>
<div class="timestamp" title={absoluteTimestamp(op.timestamp)}>
{formatTimestamp(op.timestamp)}
</div>
</span>
{@render timestamp()}
</div>
</div>
{:else if op.type === "assign"}
<div class="timeline-item txt-body-m-regular">
<div class="icon">
<Icon name="avatar-incognito" />
</div>
<div class="icon"><Icon name="avatar-incognito" /></div>
<div class="wrapper">
{#if !hideAuthor}<NodeId {...authorForNodeId(op.author)} />{/if}
<div class="summary-line">
<span class="summary">
{#if op.previous && op.previous.type === op.type}
{@const changed = itemDiff<Author>(
op.previous?.assignees ?? [],
op.assignees,
)}
{#if changed.added.length || changed.removed.length}
{#if changed.added.length}
assigned
{#each changed.added as assignee}
<NodeId {...authorForNodeId(assignee)} />
{/each}
{/if}
{#if changed.removed.length}
unassigned
{#each changed.removed as assignee}
<NodeId {...authorForNodeId(assignee)} />
{/each}
{/if}
{#if changed.added.length}
<span class="summary-text">assigned</span>
{#each changed.added as assignee}
<NodeId {...authorForNodeId(assignee)} />
{/each}
{/if}
{#if changed.removed.length}
<span class="summary-text">unassigned</span>
{#each changed.removed as assignee}
<NodeId {...authorForNodeId(assignee)} />
{/each}
{/if}
{:else}
assigned
<span class="summary-text">assigned</span>
{#each op.assignees as assignee}
<NodeId {...authorForNodeId(assignee)} />
{/each}
{/if}
</div>
<div class="timestamp" title={absoluteTimestamp(op.timestamp)}>
{formatTimestamp(op.timestamp)}
</div>
</span>
{@render timestamp()}
</div>
</div>
{:else if op.type === "edit"}
{#if op.previous && op.previous.type === op.type}
<div class="timeline-item txt-body-m-regular">
<div class="icon">
<Icon name="edit" />
</div>
<div class="icon"><Icon name="edit" /></div>
<div class="wrapper">
{#if !hideAuthor}<NodeId {...authorForNodeId(op.author)} />{/if}
<div class="summary-line">
<span class="summary-text">
changed title <s>{op.previous.title}</s>
→ {op.title}
<span class="summary">
<span
class="summary-text"
title={`changed the title from “${op.previous.title}” to “${op.title}”`}>
changed the title from “{op.previous.title}” to “{op.title}”
</span>
</div>
<div class="timestamp" title={absoluteTimestamp(op.timestamp)}>
{formatTimestamp(op.timestamp)}
</div>
</span>
{@render timestamp()}
</div>
</div>
{/if}
+135
View File
@@ -0,0 +1,135 @@
<script lang="ts">
import type { Author } from "@bindings/cob/Author";
import type { Reaction } from "@bindings/cob/Reaction";
import type { Embed } from "@bindings/cob/thread/Embed";
import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
import Icon from "@app/components/Icon.svelte";
import Markdown from "@app/components/Markdown.svelte";
import { closeFocused } from "@app/components/Popover.svelte";
import Reactions from "@app/components/Reactions.svelte";
import ReactionSelector from "@app/components/ReactionSelector.svelte";
interface Props {
rid: string;
body: string;
reactions: Reaction[];
currentUserNid?: string;
allowedToEdit: boolean;
editComment: (body: string, embeds: Embed[]) => Promise<void>;
reactOnComment: (authors: Author[], reaction: string) => Promise<void>;
}
const {
rid,
body,
reactions,
currentUserNid,
allowedToEdit,
editComment,
reactOnComment,
}: Props = $props();
let editing = $state(false);
</script>
<style>
.description {
position: relative;
margin: 1rem 0 1.5rem;
}
.description-body {
position: relative;
}
.description-body:has(.body-actions) {
padding-right: 4rem;
}
.body-actions {
position: absolute;
top: 0;
right: 0;
display: flex;
align-items: center;
gap: 0.125rem;
}
.body-action {
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: none;
padding: 0.25rem;
border-radius: var(--border-radius-sm);
cursor: pointer;
color: var(--color-text-tertiary);
opacity: 0;
transition: opacity 150ms ease;
}
.description:hover .body-action,
.description:focus-within .body-action,
.body-action:focus-visible {
opacity: 1;
}
.body-action:hover,
.body-action:focus-visible {
color: var(--color-text-primary);
background-color: var(--color-surface-subtle);
}
.reactions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
}
</style>
<div class="description">
{#if editing}
<ExtendedTextarea
{rid}
{body}
focus
submitCaption="Save"
submit={async ({ comment, embeds }) => {
await editComment(comment, Array.from(embeds.values()));
editing = false;
}}
close={() => (editing = false)} />
{:else}
<div class="description-body txt-body-m-regular">
{#if body.trim() !== ""}
<Markdown {rid} breaks content={body} />
{:else}
<span style:color="var(--color-text-tertiary)">No description</span>
{/if}
<div class="body-actions">
<div class="body-action">
<ReactionSelector
placement="bottom-end"
{reactions}
select={async ({ authors, emoji }) => {
try {
await reactOnComment(authors, emoji);
} finally {
closeFocused();
}
}} />
</div>
{#if allowedToEdit}
<button
type="button"
class="body-action"
title="Edit description"
onclick={() => (editing = true)}>
<Icon name="edit" />
</button>
{/if}
</div>
</div>
{/if}
{#if reactions.length > 0}
<div class="reactions">
<Reactions handleReaction={reactOnComment} {currentUserNid} {reactions} />
</div>
{/if}
</div>
+108
View File
@@ -0,0 +1,108 @@
<script lang="ts">
import type { Author } from "@bindings/cob/Author";
import type { Issue } from "@bindings/cob/issue/Issue";
import type { Config } from "@bindings/config/Config";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import { nodeRunning } from "@app/lib/events";
import { invoke } from "@app/lib/invoke";
import * as roles from "@app/lib/roles";
import { authorForNodeId } from "@app/lib/utils";
import { announce } from "@app/components/AnnounceSwitch.svelte";
import AssigneeInput from "@app/components/AssigneeInput.svelte";
import IdChip from "@app/components/IdChip.svelte";
import LabelInput from "@app/components/LabelInput.svelte";
import NodeId from "@app/components/NodeId.svelte";
interface Props {
config: Config;
issue: Issue;
repo: RepoInfo;
reload: () => Promise<void>;
}
const { config, issue, repo, reload }: Props = $props();
const isDelegate = $derived(
!!roles.isDelegate(
config.publicKey,
repo.delegates.map(delegate => delegate.did),
),
);
let labelSaveInProgress: boolean = $state(false);
let assigneesSaveInProgress: boolean = $state(false);
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();
}
}
</script>
<style>
.meta-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
font: var(--txt-body-m-regular);
}
.author-chip {
display: inline-flex;
align-items: center;
height: 2rem;
padding: 0 0.5rem;
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-sm);
background-color: var(--color-surface-canvas);
color: var(--color-text-tertiary);
font: var(--txt-body-m-regular);
}
</style>
<div class="meta-row">
<div class="author-chip" title="Issue author">
<NodeId {...authorForNodeId(issue.author)} />
</div>
<IdChip id={issue.id} label="issue ID" />
<LabelInput
allowedToEdit={isDelegate}
labels={issue.labels}
submitInProgress={labelSaveInProgress}
save={saveLabels} />
<AssigneeInput
allowedToEdit={isDelegate}
assignees={issue.assignees}
submitInProgress={assigneesSaveInProgress}
save={saveAssignees} />
</div>
+105 -80
View File
@@ -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);
}
</script>
<Popover
popoverPadding="0"
placement="bottom-start"
bind:expanded={popoverExpanded}>
{#snippet toggle(onclick)}
<Button
variant="outline"
{disabled}
{onclick}
active={popoverExpanded}
title={disabled
? "Only delegates can change the issue state"
: undefined}>
<span
class="global-chip"
style:padding="0"
style:margin-left="-0.25rem"
style:color={disabled
? undefined
: issueStatusColor[selectedState.status]}
style:background-color={disabled
? undefined
: issueStatusBackgroundColor[selectedState.status]}>
<Icon
name={selectedState.status === "open"
? "issue"
: `issue-${selectedState.status}`} />
</span>
<span style:color={disabled ? undefined : "var(--color-text-secondary)"}>
{capitalize(selectedState.status)}
{selectedState.status === "closed" ? `as ${selectedState.reason}` : ""}
</span>
<Icon name={popoverExpanded ? "chevron-up" : "chevron-down"} />
</Button>
{/snippet}
{#snippet popover()}
<div
style:border="1px solid var(--color-border-subtle)"
style:border-radius="var(--border-radius-sm)"
style:display="flex"
style:gap="0.5rem"
style:align-items="center"
style:background-color="var(--color-surface-canvas)">
<DropdownList
items={[
{ status: "open" },
{ status: "closed", reason: "solved" },
{ status: "closed", reason: "other" },
] as State[]}>
{#snippet item(state)}
<DropdownListItem
selected={isEqual(selectedState, state)}
styleGap="0.5rem"
onclick={() => {
onSelect(state);
closeFocused();
}}>
<span
class="global-chip"
style:padding="0"
style:margin-left="-0.5rem"
style:color={issueStatusColor[state.status]}
style:background-color={issueStatusBackgroundColor[state.status]}>
<Icon
name={state.status === "open"
? "issue"
: `issue-${state.status}`} />
</span>
<span style:color="var(--color-text-secondary)">
{capitalize(state.status)}
{state.status === "closed" ? `as ${state.reason}` : ""}
</span>
</DropdownListItem>
{/snippet}
</DropdownList>
</div>
{/snippet}
</Popover>
<style>
.status-button,
.status-chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
height: 2rem;
padding: 0 0.5rem;
border-radius: var(--border-radius-sm);
font: var(--txt-body-m-regular);
white-space: nowrap;
flex-shrink: 0;
}
.status-button {
border: none;
cursor: pointer;
}
.status-button:hover,
.status-button.active {
filter: brightness(0.95);
}
</style>
{#if disabled}
<span
class="status-chip"
style:color={issueStatusColor[selectedState.status]}
style:background-color={issueStatusBackgroundColor[selectedState.status]}
title="Only the issue author and delegates can change the issue state">
<Icon name={issueStatusIcon[selectedState.status]} />
<span>{label(selectedState)}</span>
</span>
{:else}
<Popover
popoverPadding="0"
placement="bottom-start"
bind:expanded={popoverExpanded}>
{#snippet toggle(onclick)}
<button
type="button"
class="status-button"
class:active={popoverExpanded}
style:color={issueStatusColor[selectedState.status]}
style:background-color={issueStatusBackgroundColor[
selectedState.status
]}
{onclick}>
<Icon name={issueStatusIcon[selectedState.status]} />
<span>{label(selectedState)}</span>
<Icon name={popoverExpanded ? "chevron-up" : "chevron-down"} />
</button>
{/snippet}
{#snippet popover()}
<div
style:border="1px solid var(--color-border-subtle)"
style:border-radius="var(--border-radius-sm)"
style:display="flex"
style:gap="0.5rem"
style:align-items="center"
style:background-color="var(--color-surface-canvas)">
<DropdownList
items={[
{ status: "open" },
{ status: "closed", reason: "solved" },
{ status: "closed", reason: "other" },
] as State[]}>
{#snippet item(state)}
<DropdownListItem
selected={isEqual(selectedState, state)}
styleGap="0.5rem"
onclick={() => {
onSelect(state);
closeFocused();
}}>
<span
class="global-chip"
style:padding="0"
style:margin-left="-0.5rem"
style:color={issueStatusColor[state.status]}
style:background-color={issueStatusBackgroundColor[
state.status
]}>
<Icon name={issueStatusIcon[state.status]} />
</span>
<span style:color="var(--color-text-secondary)">
{label(state)}
</span>
</DropdownListItem>
{/snippet}
</DropdownList>
</div>
{/snippet}
</Popover>
{/if}
+3 -58
View File
@@ -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;
}
</style>
<div class="meta-row">
@@ -372,19 +329,7 @@
<NodeId {...authorForNodeId(patch.author)} />
</div>
<div class="target-group">
<button
type="button"
class="patch-id-chip"
title={patchIdCopied ? "Copied to clipboard" : "Copy patch ID"}
onclick={copyPatchId}>
{#if patchIdCopied}
<Icon name="checkmark" />
{:else}
<span class="pid-icon-default"><Icon name="hash" /></span>
<span class="pid-icon-hover"><Icon name="copy" /></span>
{/if}
<span class="patch-id-value">{formatOid(patch.id)}</span>
</button>
<IdChip id={patch.id} label="patch ID" grouped />
{#if targetBranch}
<span class="target-arrow" title={targetBranchCaption}>
<Icon name="arrow-right" />
+5
View File
@@ -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<Issue["state"]["status"], string>;
export const issueStatusLabel: Record<Issue["state"]["status"], string> = {
open: "Open Issues",
closed: "Closed Issues",
+106 -172
View File
@@ -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<FlattenedIssueOperation>[] = $derived.by(
() => {
@@ -72,6 +77,18 @@
]);
const tracker: Partial<Record<Action["type"], Action>> = {};
const items: ActivityItem<FlattenedIssueOperation>[] = [];
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>("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;
}
</style>
@@ -377,7 +354,7 @@
<div class="page">
<Topbar>
<div class="breadcrumb">
<Icon name={issue.state.status === "open" ? "issue" : "issue-closed"} />
<Icon name={issueStatusIcon[issue.state.status]} />
<button
class="breadcrumb-link"
onclick={() =>
@@ -389,9 +366,16 @@
{issueStatusLabel[issue.state.status]}
</button>
<Icon name="chevron-right" />
<Id id={issue.id} clipboard={issue.id} label="issue ID" />
<span class="breadcrumb-title">{issue.title}</span>
</div>
<div style:margin-left="auto" style:display="flex" style:gap="0.5rem">
<div
style:margin-left="auto"
style:display="flex"
style:gap="0.5rem"
style:z-index="40">
{#if isOwnIssue}
<ConfirmDeleteButton noun="issue" onDelete={deleteIssue} />
{/if}
<ShareButton
explorerPath={`${repo.rid}/issues/${issue.id}`}
id={issue.id}
@@ -412,53 +396,35 @@
</Topbar>
<ScrollArea style="flex: 1; min-height: 0;">
<div class="content">
<div class="main">
<div class="title">
<div
class="global-chip status-chip"
style:color={issueStatusColor[issue.state.status]}
style:background-color={issueStatusBackgroundColor[
issue.state.status
]}>
<Icon
name={issue.state.status === "open"
? "issue"
: "issue-closed"} />
</div>
<EditableTitle
{updateTitle}
allowedToEdit={roles.isDelegateOrAuthor(
config.publicKey,
repo.delegates.map(delegate => delegate.did),
issue.author.did,
)}
title={issue.title}
cobId={issue.id} />
</div>
<div class="main">
<div class="title">
<IssueStateButton
selectedState={issue.state}
onSelect={saveState}
disabled={!canEditIssue} />
<EditableTitle
{updateTitle}
allowedToEdit={canEditIssue}
title={issue.title}
cobId={issue.id} />
</div>
<div class="meta-bar">
<IssueMetadata {config} {issue} {repo} {reload} />
</div>
<div class="content">
{#if issue.body}
<div class="issue-body">
<CommentComponent
rid={repo.rid}
currentUserNid={config.publicKey}
id={issue.id}
lastEdit={issue.body.edits.length > 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)}>
</CommentComponent>
</div>
{@const body = issue.body}
<IssueDescription
rid={repo.rid}
body={body.edits.slice(-1)[0].body}
reactions={body.reactions}
currentUserNid={config.publicKey}
allowedToEdit={!!canEditIssue}
editComment={(text, embeds) => 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} />
</div>
<div class="sidebar">
<div class="sidebar-section">
<IssueStateButton
selectedState={issue.state}
onSelect={saveState}
disabled={!roles.isDelegate(
config.publicKey,
repo.delegates.map(d => d.did),
)} />
</div>
<div class="sidebar-section">
<LabelInput
allowedToEdit={!!roles.isDelegate(
config.publicKey,
repo.delegates.map(delegate => delegate.did),
)}
labels={issue.labels}
submitInProgress={labelSaveInProgress}
save={saveLabels} />
</div>
<div class="sidebar-section">
<AssigneeInput
allowedToEdit={!!roles.isDelegate(
config.publicKey,
repo.delegates.map(delegate => delegate.did),
)}
assignees={issue.assignees}
submitInProgress={assigneesSaveInProgress}
save={saveAssignees} />
</div>
</div>
</div>
</ScrollArea>
</div>
+2 -105
View File
@@ -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;
}
}
</script>
@@ -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}
<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">
<div class="txt-body-m-medium">
Delete this patch from your node?
</div>
<div class="confirm-delete-note txt-body-m-regular">
Only your copy is removed. You won't be able to restore it
here, and peers who have already replicated the patch keep
theirs.
</div>
</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>
<ConfirmDeleteButton noun="patch" onDelete={deletePatch} />
{/if}
<ShareButton
explorerPath={`${repo.rid}/patches/${patch.id}`}
+4 -2
View File
@@ -12,7 +12,7 @@ test("correct order of threads", async ({ page }) => {
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(