Add review page

This commit is contained in:
Rūdolfs Ošiņš
2025-02-18 12:06:03 -06:00
parent a918981ea7
commit 4add00d682
20 changed files with 736 additions and 271 deletions
Generated
+5 -10
View File
@@ -3975,8 +3975,7 @@ dependencies = [
[[package]]
name = "radicle"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd823aeed3ffe73eb82a213e62cb3811f9bdf453844d6e0b14684e0757fb389b"
source = "git+https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?rev=7c902b6905724345ba850eb6cca8f8becc9a9c72#7c902b6905724345ba850eb6cca8f8becc9a9c72"
dependencies = [
"amplify",
"base64 0.21.7",
@@ -4007,8 +4006,7 @@ dependencies = [
[[package]]
name = "radicle-cob"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90581a9508ccc310998e991d7acf139d2991297d3fb37d30de07536e10256afb"
source = "git+https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?rev=7c902b6905724345ba850eb6cca8f8becc9a9c72#7c902b6905724345ba850eb6cca8f8becc9a9c72"
dependencies = [
"fastrand",
"git2",
@@ -4026,8 +4024,7 @@ dependencies = [
[[package]]
name = "radicle-crypto"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1d6a67969719841ad06049597006368eb4238ca63a02d20207654dfd1d2d6ad"
source = "git+https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?rev=7c902b6905724345ba850eb6cca8f8becc9a9c72#7c902b6905724345ba850eb6cca8f8becc9a9c72"
dependencies = [
"amplify",
"cyphernet",
@@ -4047,8 +4044,7 @@ dependencies = [
[[package]]
name = "radicle-dag"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb41c7e10ada3a4df960190a96bfb4af56d33ada890f917acc8e3b122b614875"
source = "git+https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?rev=7c902b6905724345ba850eb6cca8f8becc9a9c72#7c902b6905724345ba850eb6cca8f8becc9a9c72"
dependencies = [
"fastrand",
]
@@ -4070,8 +4066,7 @@ dependencies = [
[[package]]
name = "radicle-ssh"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbee758010fb64482be4b18591fbeb3cbc15b16450d143edf4edb5484c7366c6"
source = "git+https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?rev=7c902b6905724345ba850eb6cca8f8becc9a9c72#7c902b6905724345ba850eb6cca8f8becc9a9c72"
dependencies = [
"byteorder",
"log",
+1 -1
View File
@@ -18,7 +18,7 @@ tauri-build = { version = "2.0.1", features = ["isolation"] }
anyhow = { version = "1.0.90" }
base64 = { version = "0.22.1" }
log = { version = "0.4.22" }
radicle = { version = "0.14.0" }
radicle = { git = "https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git", package = "radicle", rev = "7c902b6905724345ba850eb6cca8f8becc9a9c72" }
radicle-types = { version = "0.1.0", path = "../radicle-types" }
radicle-surf = { version = "0.22.1", features = ["serde"] }
serde = { version = "1.0.210", features = ["derive"] }
+1 -1
View File
@@ -11,7 +11,7 @@ localtime = { version = "1.3.1" }
log = { version = "0.4.22" }
infer = { version = "0.3" }
mime-infer = { version = "3.0.0" }
radicle = { version = "0.14.0", features = ["test"] }
radicle = { git = "https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git", package = "radicle", rev = "7c902b6905724345ba850eb6cca8f8becc9a9c72", features = ["test"] }
radicle-surf = { version = "0.22.1", features = ["serde"] }
serde = { version = "1.0.210", features = ["derive"] }
serde_json = { version = "1.0.132" }
@@ -11,4 +11,5 @@ export type Review = {
summary?: string;
comments: Array<Comment<CodeLocation>>;
timestamp: number;
labels: Array<string>;
};
@@ -253,6 +253,8 @@ pub struct Review {
comments: Vec<cobs::thread::Comment<cobs::thread::CodeLocation>>,
#[ts(type = "number")]
timestamp: cob::common::Timestamp,
#[ts(as = "Vec<String>")]
labels: Vec<cob::Label>,
}
impl Review {
@@ -262,6 +264,7 @@ impl Review {
author: cobs::Author::new(&review.author().id, aliases),
verdict: review.verdict().map(|v| v.into()),
summary: review.summary().map(|s| s.to_string()),
labels: review.labels().cloned().collect::<Vec<_>>(),
comments: review
.comments()
.map(|(id, c)| {
+1 -1
View File
@@ -10,7 +10,7 @@ anyhow = { version = "1.0.90" }
axum = { version = "0.7.5", default-features = false, features = ["json", "query", "tokio", "http1"] }
hyper = { version = "1.4", default-features = false }
lexopt = { version = "0.3.0" }
radicle = { version = "0.14.0" }
radicle = { git = "https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git", package = "radicle", rev = "7c902b6905724345ba850eb6cca8f8becc9a9c72" }
radicle-surf = { version = "0.22.1", default-features = false, features = ["serde"] }
radicle-types = { path = "../radicle-types" }
serde = { version = "1", features = ["derive"] }
+1 -1
View File
@@ -3,7 +3,7 @@
interface Props {
children: Snippet;
variant: "primary" | "secondary" | "ghost" | "float" | "danger";
variant: "primary" | "secondary" | "ghost" | "float" | "danger" | "success";
hoverable?: boolean;
onclick?: () => void;
stylePosition?: string;
+14 -4
View File
@@ -20,23 +20,27 @@
interface Props {
actions?: Snippet;
beforeTimestamp?: Snippet;
id?: string;
rid: string;
author: Author;
body: string;
body?: string;
reactions?: Reaction[];
embeds?: Map<string, Embed>;
caption?: string;
timestamp: number;
lastEdit?: Edit;
disallowEmptyBody?: boolean;
emptyBodyTooltip?: string;
editComment?: (body: string, embeds: Embed[]) => Promise<void>;
reactOnComment?: (authors: Author[], reaction: string) => Promise<void>;
styleWidth?: string;
}
/* eslint-disable prefer-const */
let {
actions,
beforeTimestamp,
id,
rid,
author,
@@ -49,6 +53,8 @@
disallowEmptyBody = false,
editComment,
reactOnComment,
styleWidth,
emptyBodyTooltip,
}: Props = $props();
/* eslint-enable prefer-const */
@@ -120,7 +126,7 @@
}
</style>
<div class="card" {id}>
<div class="card" {id} style:width={styleWidth}>
<div style:position="relative">
<div class="card-header">
<NodeId {...utils.authorForNodeId(author)} />
@@ -128,6 +134,9 @@
{#if id}
<Id {id} variant="oid" />
{/if}
{#if beforeTimestamp}
{@render beforeTimestamp()}
{/if}
<span class="timestamp" title={utils.absoluteTimestamp(timestamp)}>
{utils.formatTimestamp(timestamp)}
</span>
@@ -165,7 +174,7 @@
</div>
</div>
{#if body.trim() === "" && state === "read"}
{#if (body === undefined || body?.trim() === "") && state === "read"}
<div class="card-body">
<span class="txt-missing txt-small" style:line-height="1.625rem">
No description.
@@ -181,6 +190,7 @@
{rid}
{embeds}
{disallowEmptyBody}
{emptyBodyTooltip}
borderVariant="ghost"
submitInProgress={state === "submit"}
submitCaption="Save"
@@ -202,7 +212,7 @@
{:else}
<div style:width="100%">
<div style:overflow="hidden">
<Markdown {rid} breaks content={body} />
<Markdown {rid} breaks content={body ?? ""} />
</div>
</div>
{/if}
+6 -1
View File
@@ -67,6 +67,11 @@
class:disabled
{style}
{title}
{onclick}>
onclick={() => {
if (disabled) {
return;
}
onclick();
}}>
{@render children()}
</div>
+3
View File
@@ -29,6 +29,7 @@
submitInProgress?: boolean;
disableSubmit?: boolean;
disallowEmptyBody?: boolean;
emptyBodyTooltip?: string;
isValid?: () => boolean;
preview?: boolean;
stylePadding?: string;
@@ -55,6 +56,7 @@
submitInProgress = false,
disableSubmit = false,
disallowEmptyBody = false,
emptyBodyTooltip,
isValid = () => true,
stylePadding,
borderVariant = "float",
@@ -293,6 +295,7 @@
</OutlineButton>
<Button
variant="ghost"
title={emptyBodyTooltip}
disabled={!isValid() ||
submitInProgress ||
disableSubmit ||
+3 -4
View File
@@ -7,14 +7,14 @@
publicKey: string;
alias?: string;
styleFontSize?: string;
styleFontFamily?: string;
styleFontWeight?: string;
}
const {
publicKey,
alias,
styleFontSize = "var(--font-size-small)",
styleFontFamily = "var(--font-family-monospace)",
styleFontWeight = "var(--font-weight-semibold)",
}: Props = $props();
</script>
@@ -23,7 +23,6 @@
display: flex;
align-items: center;
gap: 0.375rem;
font-weight: var(--font-weight-semibold);
}
.no-alias {
color: var(--color-foreground-dim);
@@ -33,7 +32,7 @@
<div
class="avatar-alias"
style:font-size={styleFontSize}
style:font-family={styleFontFamily}>
style:font-weight={styleFontWeight}>
<Avatar {publicKey} />
{#if alias}
<span class="txt-overflow">
+19 -12
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Patch } from "@bindings/cob/patch/Patch";
import type { Stats } from "@bindings/cob/Stats";
import type { PatchStatus } from "@app/views/repo/router";
import type { Stats } from "@bindings/cob/Stats";
import {
authorForNodeId,
@@ -12,27 +12,28 @@
import { invoke } from "@app/lib/invoke";
import { push } from "@app/lib/router";
import DiffStatBadge from "./DiffStatBadge.svelte";
import Icon from "./Icon.svelte";
import Id from "./Id.svelte";
import InlineTitle from "./InlineTitle.svelte";
import NodeId from "./NodeId.svelte";
import DiffStatBadge from "@app/components/DiffStatBadge.svelte";
import Icon from "@app/components/Icon.svelte";
import Id from "@app/components/Id.svelte";
import InlineTitle from "@app/components/InlineTitle.svelte";
import Label from "@app/components/Label.svelte";
import NodeId from "@app/components/NodeId.svelte";
interface Props {
compact?: boolean;
loadPatch?: (rid: string, patchId: string) => void;
patch: Patch;
rid: string;
selected?: boolean;
compact?: boolean;
loadPatch?: (rid: string, patchId: string) => void;
status: PatchStatus | undefined;
}
const {
compact = false,
loadPatch,
patch,
rid,
selected = false,
compact = false,
loadPatch,
status,
}: Props = $props();
</script>
@@ -82,7 +83,13 @@
if (loadPatch) {
loadPatch(rid, patch.id);
} else {
void push({ resource: "repo.patch", rid, patch: patch.id, status });
void push({
resource: "repo.patch",
rid,
patch: patch.id,
status,
reviewId: undefined,
});
}
}}>
<div class="global-flex" style:align-items="flex-start">
@@ -116,7 +123,7 @@
{/await}
{#each patch.labels as label}
<div class="global-counter txt-small">{label}</div>
<Label {label} />
{/each}
{/if}
<div
+234
View File
@@ -0,0 +1,234 @@
<script lang="ts">
import type { Config } from "@bindings/config/Config";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import type { Review } from "@bindings/cob/patch/Review";
import type { Revision } from "@bindings/cob/patch/Revision";
import partial from "lodash/partial";
import * as roles from "@app/lib/roles";
import { announce } from "@app/components/AnnounceSwitch.svelte";
import { authorForNodeId, publicKeyFromDid } from "@app/lib/utils";
import { invoke } from "@app/lib/invoke";
import { nodeRunning } from "@app/lib/events";
import Border from "@app/components/Border.svelte";
import CommentComponent from "@app/components/Comment.svelte";
import Icon from "@app/components/Icon.svelte";
import Id from "@app/components/Id.svelte";
import LabelInput from "@app/components/LabelInput.svelte";
import NakedButton from "@app/components/NakedButton.svelte";
import NodeId from "@app/components/NodeId.svelte";
import VerdictButton from "@app/components/VerdictButton.svelte";
import VerdictBadge from "./VerdictBadge.svelte";
interface Props {
config: Config;
onNavigateBack: () => void;
patchId: string;
reload: (reviewId?: string) => Promise<void>;
review: Review;
revision: Revision;
repo: RepoInfo;
}
const {
config,
onNavigateBack,
patchId,
reload,
review,
revision,
repo,
}: Props = $props();
const contributors = [
review.author,
...review.comments.map(c => {
return c.author;
}),
];
let verdict: Review["verdict"] = $state(review.verdict);
let labelSaveInProgress: boolean = $state(false);
async function editReview(
reviewId: string,
verdict: Review["verdict"],
labels: string[],
summary?: string,
) {
if (summary?.trim() === "") {
summary = undefined;
} else {
summary = summary?.trim();
}
try {
labelSaveInProgress = true;
await invoke("edit_patch", {
rid: repo.rid,
cobId: patchId,
action: {
type: "review.edit",
review: reviewId,
summary,
verdict,
labels,
},
opts: { announce: $nodeRunning && $announce },
});
} catch (error) {
console.error("Editing review failed: ", error);
} finally {
labelSaveInProgress = false;
await reload(reviewId);
}
}
</script>
<style>
.content {
padding: 1rem 1rem 1rem 0;
}
.title {
font-size: var(--font-size-medium);
font-weight: var(--font-weight-medium);
-webkit-user-select: text;
user-select: text;
display: flex;
align-items: center;
white-space: nowrap;
min-height: 40px;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.metadata-divider {
width: 2px;
background-color: var(--color-fill-ghost);
height: calc(100% + 4px);
top: 0;
position: relative;
}
.metadata-section {
padding: 0.5rem;
font-size: var(--font-size-small);
display: flex;
flex-direction: column;
align-items: flex-start;
height: 100%;
}
.metadata-section-title {
margin-bottom: 0.5rem;
color: var(--color-foreground-dim);
}
.review-body {
margin-top: 1rem;
margin-bottom: 1rem;
position: relative;
z-index: 1;
}
/* We put the background and clip-path in a separate element to prevent
popovers being clipped in the main element. */
.review-body::after {
position: absolute;
z-index: -1;
content: " ";
background-color: var(--color-background-float);
clip-path: var(--2px-corner-fill);
width: 100%;
height: 100%;
top: 0;
}
</style>
<div class="content">
<div style:margin-bottom="0.5rem">
<div class="title">
<NakedButton
variant="ghost"
onclick={onNavigateBack}
stylePadding="0 4px">
<Icon name="arrow-left" />
</NakedButton>
<span class="global-flex" style:gap="0">
<NodeId
{...authorForNodeId(review.author)}
styleFontSize="var(--font-size-medium)"
styleFontWeight="var(--font-weight-medium)" />'s review
</span>
</div>
<Border variant="ghost" styleGap="0">
<div class="metadata-section" style:min-width="8rem">
<div class="metadata-section-title">Verdict</div>
{#if !!roles.isDelegateOrAuthor( config.publicKey, repo.delegates.map(delegate => delegate.did), review.author.did, )}
<VerdictButton
{verdict}
summaryMissing={review.summary === undefined ||
review.summary.trim() === ""}
onSelect={async newVerdict => {
verdict = newVerdict;
await editReview(
review.id,
verdict,
review.labels,
review.summary,
);
}} />
{:else}
<VerdictBadge {verdict} />
{/if}
</div>
<div class="metadata-divider"></div>
<div class="metadata-section" style:flex="1">
<LabelInput
allowedToEdit={!!roles.isDelegateOrAuthor(
config.publicKey,
repo.delegates.map(delegate => delegate.did),
review.author.did,
)}
labels={review.labels}
submitInProgress={labelSaveInProgress}
save={async labels => {
await editReview(review.id, verdict, labels, review.summary);
}} />
</div>
<div class="metadata-divider"></div>
<div class="metadata-section" style:flex="1">
<div class="metadata-section-title">Participants</div>
{#each contributors as contributor}
<NodeId {...authorForNodeId(contributor)} />
{/each}
</div>
</Border>
<div class="review-body">
<CommentComponent
rid={repo.rid}
disallowEmptyBody={review.verdict === undefined}
emptyBodyTooltip="Summary is mandatory when verdict is None"
styleWidth="100%"
caption="published review"
id={review.id}
author={review.author}
timestamp={review.timestamp}
editComment={(publicKeyFromDid(review.author.did) ===
config.publicKey ||
undefined) &&
partial(async (id: string, summary: string) => {
await editReview(id, verdict, review.labels, summary);
}, review.id)}
body={review.summary}>
{#snippet beforeTimestamp()}
on revision <Id id={revision.id} variant="oid" />
{/snippet}
</CommentComponent>
</div>
</div>
</div>
+79 -55
View File
@@ -1,71 +1,56 @@
<script lang="ts">
import type { PatchStatus } from "@app/views/repo/router";
import type { Review } from "@bindings/cob/patch/Review";
import {
absoluteTimestamp,
authorForNodeId,
formatTimestamp,
verdictIcon,
} from "@app/lib/utils";
import { push } from "@app/lib/router";
import Icon from "./Icon.svelte";
import Markdown from "./Markdown.svelte";
import Icon from "@app/components/Icon.svelte";
import Id from "@app/components/Id.svelte";
import Label from "@app/components/Label.svelte";
import Markdown from "@app/components/Markdown.svelte";
import NodeId from "@app/components/NodeId.svelte";
interface Props {
rid: string;
patchId: string;
review: Review;
rid: string;
status: PatchStatus | undefined;
}
const { rid, review }: Props = $props();
const header = $derived.by(() => {
if (!review.verdict) {
return "published a review";
}
return `${review.verdict ? `${review.verdict}ed` : "reviewed"} revision`;
});
function icon(verdict: Review["verdict"]) {
if (verdict === "accept") {
return "comment-checkmark";
} else if (verdict === "reject") {
return "comment-cross";
} else {
return "comment";
}
}
function backgroundColor(verdict: Review["verdict"]) {
if (verdict === undefined) {
return "var(--color-fill-float)";
} else if (verdict === "accept") {
return "var(--color-fill-diff-green-light)";
} else if (verdict === "reject") {
return "var(--color-fill-diff-red-light)";
}
}
function color(verdict: Review["verdict"]) {
if (verdict === undefined) {
return "var(--color-foreground-dim)";
} else if (verdict === "accept") {
return "var(--color-foreground-success)";
} else if (verdict === "reject") {
return "var(--color-foreground-red)";
}
}
const { patchId, review, rid, status }: Props = $props();
</script>
<style>
.review {
clip-path: var(--2px-corner-fill);
display: flex;
align-items: flex-start;
padding: 0.5rem 0.75rem;
gap: 0.75rem;
z-index: 1;
position: relative;
}
/* We put the background and clip-path in a separate element to prevent
popovers being clipped in the main element. */
.review::after {
position: absolute;
z-index: -1;
content: " ";
background-color: var(--color-fill-float);
clip-path: var(--2px-corner-fill);
width: 100%;
height: 100%;
top: 0;
}
.review:hover::after {
background-color: var(--color-fill-float-hover);
}
.review-content {
padding: 10px 0.75rem 0.5rem 0;
width: 100%;
font-size: var(--font-size-small);
display: flex;
@@ -81,29 +66,68 @@
align-items: center;
width: 100%;
}
.icon {
padding-top: 0.25rem;
.status {
padding: 0;
margin: 0.5rem 0 0 0.5rem;
}
.accepted {
background-color: var(--color-fill-diff-green-light);
}
.rejected {
background-color: var(--color-fill-diff-red-light);
}
.no-verdict {
background-color: var(--color-fill-ghost);
}
</style>
<div class="review" style:background-color={backgroundColor(review.verdict)}>
<div class="icon" style:color={color(review.verdict)}>
<Icon name={icon(review.verdict)} />
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
tabindex="0"
role="button"
class="review"
style:cursor="pointer"
onclick={() => {
void push({
resource: "repo.patch",
rid,
patch: patchId,
status,
reviewId: review.id,
});
}}>
<div
class:accepted={review.verdict === "accept"}
class:rejected={review.verdict === "reject"}
class:no-verdict={review.verdict === undefined}
class="global-counter status">
<Icon name={verdictIcon(review.verdict)} />
</div>
<div class="review-content">
<div class="review-header">
<div class="global-flex">
<NodeId {...authorForNodeId(review.author)} />
<span>{header}</span>
<span>published review</span>
<Id id={review.id} variant="oid" />
<div class="timestamp" title={absoluteTimestamp(review.timestamp)}>
{formatTimestamp(review.timestamp)}
</div>
{#if review.comments.length > 0}
<div class="global-flex" style:gap="0.25rem" style:margin-left="auto">
<Icon name="comment" />{review.comments.length}
</div>
{/if}
</div>
{#if review.comments.length > 0}
<div class="global-flex" style:gap="0.25rem" style:margin-left="auto">
<Icon name="comment" />{review.comments.length}
</div>
{/if}
{#if review.labels.length > 0}
<div class="global-flex" style:margin-left="auto">
{#each review.labels as label}
<Label {label} />
{/each}
</div>
{/if}
</div>
{#if review.summary?.trim()}
<div>
+4 -2
View File
@@ -4,6 +4,7 @@
import type { Config } from "@bindings/config/Config";
import type { Diff } from "@bindings/diff/Diff";
import type { Embed } from "@bindings/cob/thread/Embed";
import type { PatchStatus } from "@app/views/repo/router";
import type { Revision } from "@bindings/cob/patch/Revision";
import type { Thread } from "@bindings/cob/thread/Thread";
import type { Verdict } from "@bindings/cob/patch/Verdict";
@@ -39,11 +40,12 @@
patchId: string;
revision: Revision;
config: Config;
status: PatchStatus | undefined;
reload: () => Promise<void>;
}
/* eslint-disable prefer-const */
let { rid, repoDelegates, patchId, revision, config, reload }: Props =
let { rid, repoDelegates, patchId, revision, config, status, reload }: Props =
$props();
/* eslint-enable prefer-const */
@@ -407,7 +409,7 @@
{#if revision.reviews && revision.reviews.length}
<div class:hide={hideReviews} style:margin-top="1rem">
{#each revision.reviews as review}
<ReviewTeaser {rid} {review} />
<ReviewTeaser {rid} {review} {patchId} {status} />
{/each}
</div>
{/if}
+56
View File
@@ -0,0 +1,56 @@
<script lang="ts">
import type { Review } from "@bindings/cob/patch/Review";
import type { Snippet } from "svelte";
import capitalize from "lodash/capitalize.js";
import { verdictIcon, verdictIconColor } from "@app/lib/utils";
import Icon from "@app/components/Icon.svelte";
interface Props {
children?: Snippet;
verdict: Review["verdict"];
hoverable?: boolean;
}
const { children, verdict, hoverable = false }: Props = $props();
</script>
<style>
.badge {
gap: 6px;
padding-right: 10px;
}
.no-verdict {
background-color: var(--color-fill-ghost);
}
.no-verdict.hoverable:hover {
background-color: var(--color-fill-ghost-hover);
}
.accepted {
background-color: var(--color-fill-diff-green-light);
}
.accepted.hoverable:hover {
background-color: var(--color-fill-diff-green);
}
.rejected {
background-color: var(--color-fill-diff-red-light);
}
.rejected.hoverable:hover {
background-color: var(--color-fill-diff-red);
}
</style>
<span
class="global-counter badge"
style:color={verdictIconColor(verdict)}
class:hoverable
class:no-verdict={verdict === undefined}
class:accepted={verdict === "accept"}
class:rejected={verdict === "reject"}>
<Icon name={verdictIcon(verdict)} />
{verdict ? capitalize(`${verdict}ed`) : "None"}
{@render children?.()}
</span>
+70
View File
@@ -0,0 +1,70 @@
<script lang="ts">
import type { Review } from "@bindings/cob/patch/Review";
import capitalize from "lodash/capitalize.js";
import { closeFocused } from "./Popover.svelte";
import { verdictIcon, verdictIconColor } from "@app/lib/utils";
import Border from "@app/components/Border.svelte";
import DropdownList from "@app/components/DropdownList.svelte";
import DropdownListItem from "@app/components/DropdownListItem.svelte";
import Icon from "@app/components/Icon.svelte";
import Popover from "@app/components/Popover.svelte";
import VerdictBadge from "@app/components/VerdictBadge.svelte";
interface Props {
onSelect: (verdict: Review["verdict"]) => Promise<void>;
summaryMissing: boolean;
verdict: Review["verdict"];
}
const { onSelect, summaryMissing, verdict }: Props = $props();
</script>
<style>
button {
cursor: pointer;
border: 0;
background: none;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-small);
}
</style>
<Popover popoverPadding="0" popoverPositionLeft="0" popoverPositionTop="2rem">
{#snippet toggle(onclick)}
<button {onclick}>
<VerdictBadge {verdict} hoverable>
<Icon name="chevron-down" />
</VerdictBadge>
</button>
{/snippet}
{#snippet popover()}
<Border variant="ghost">
<DropdownList items={[undefined, "accept", "reject"] as const}>
{#snippet item(action)}
<DropdownListItem
title={action === undefined && summaryMissing
? "Set a summary to select verdict None"
: undefined}
disabled={action === undefined && summaryMissing}
selected={verdict === action}
onclick={async () => {
await onSelect(action);
closeFocused();
}}>
<span class="global-flex" style:color={verdictIconColor(action)}>
<Icon name={verdictIcon(action)} />
{action ? capitalize(`${action}ed`) : "None"}
</span>
</DropdownListItem>
{/snippet}
</DropdownList>
</Border>
{/snippet}
</Popover>
+21
View File
@@ -3,6 +3,7 @@ import type { ComponentProps } from "svelte";
import type { Author } from "@bindings/cob/Author";
import type { Issue } from "@bindings/cob/issue/Issue";
import type { Patch } from "@bindings/cob/patch/Patch";
import type { Review } from "@bindings/cob/patch/Review";
import bs58 from "bs58";
import twemojiModule from "twemoji";
@@ -237,3 +238,23 @@ export function gravatarURL(email: string): string {
return `https://www.gravatar.com/avatar/${hash}`;
}
export function verdictIcon(verdict: Review["verdict"]) {
if (verdict === "accept") {
return "comment-checkmark";
} else if (verdict === "reject") {
return "comment-cross";
} else {
return "comment";
}
}
export function verdictIconColor(verdict: Review["verdict"]) {
if (verdict === "accept") {
return "var(--color-foreground-success)";
} else if (verdict === "reject") {
return "var(--color-foreground-red)";
} else {
return "var(--color-foreground-dim)";
}
}
+203 -178
View File
@@ -7,6 +7,7 @@
import type { Patch } from "@bindings/cob/patch/Patch";
import type { PatchStatus } from "./router";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import type { Review } from "@bindings/cob/patch/Review";
import type { Revision } from "@bindings/cob/patch/Revision";
import capitalize from "lodash/capitalize";
@@ -37,6 +38,7 @@
import PatchTeaser from "@app/components/PatchTeaser.svelte";
import PatchTimeline from "@app/components/PatchTimeline.svelte";
import Popover, { closeFocused } from "@app/components/Popover.svelte";
import ReviewComponent from "@app/components/Review.svelte";
import RevisionBadges from "@app/components/RevisionBadges.svelte";
import RevisionComponent from "@app/components/Revision.svelte";
import RevisionSelector from "@app/components/RevisionSelector.svelte";
@@ -52,6 +54,7 @@
config: Config;
activity: Operation<Action>[];
status: PatchStatus | undefined;
review: Review | undefined;
}
/* eslint-disable prefer-const */
@@ -63,6 +66,7 @@
config,
status: initialStatus,
activity,
review,
}: Props = $props();
/* eslint-enable prefer-const */
@@ -110,6 +114,7 @@
rid: repo.rid,
id: patch.id,
});
review = undefined;
}
async function editTitle(rid: string, patchId: string, title: string) {
@@ -213,7 +218,7 @@
}
}
async function reload() {
async function reload(reviewId?: string) {
[config, repo, patches, patch, revisions, activity] = await Promise.all([
invoke<Config>("config"),
invoke<RepoInfo>("repo_by_id", {
@@ -236,6 +241,9 @@
id: patch.id,
}),
]);
review = revisions
.flatMap(r => r.reviews || [])
.find(review => review.id === reviewId);
}
async function loadPatches(filter: PatchStatus | undefined) {
@@ -430,55 +438,22 @@
</div>
{/snippet}
<div class="content">
<div style:margin-bottom="0.5rem">
{#if editingTitle}
<div class="title">
<div
class="global-counter status"
style:color={patchStatusColor[patch.state.status]}
style:background-color={patchStatusBackgroundColor[
patch.state.status
]}>
<Icon
name={patch.state.status === "open"
? "patch"
: `patch-${patch.state.status}`} />
</div>
<TextInput
valid={updatedTitle.trim().length > 0}
bind:value={updatedTitle}
autofocus
onSubmit={async () => {
if (updatedTitle.trim().length > 0) {
await editTitle(repo.rid, patch.id, updatedTitle);
}
}}
onDismiss={() => {
updatedTitle = patch.title;
editingTitle = !editingTitle;
}} />
<div class="title-icons">
<Icon
name="checkmark"
onclick={async () => {
if (updatedTitle.trim().length > 0) {
await editTitle(repo.rid, patch.id, updatedTitle);
}
}} />
<Icon
name="cross"
onclick={() => {
updatedTitle = patch.title;
editingTitle = !editingTitle;
}} />
<PatchStateButton patchState={patch.state} save={saveState} />
</div>
</div>
{:else}
<div class="title">
<div class="global-flex" style:gap="0">
{#if review}
<ReviewComponent
{config}
patchId={patch.id}
{repo}
{reload}
{review}
revision={selectedRevision}
onNavigateBack={() => {
review = undefined;
}} />
{:else}
<div class="content">
<div style:margin-bottom="0.5rem">
{#if editingTitle}
<div class="title">
<div
class="global-counter status"
style:color={patchStatusColor[patch.state.status]}
@@ -490,139 +465,189 @@
? "patch"
: `patch-${patch.state.status}`} />
</div>
<InlineTitle content={patch.title} fontSize="medium" />
</div>
{#if roles.isDelegateOrAuthor( config.publicKey, repo.delegates.map(delegate => delegate.did), patch.author.did, )}
<TextInput
valid={updatedTitle.trim().length > 0}
bind:value={updatedTitle}
autofocus
onSubmit={async () => {
if (updatedTitle.trim().length > 0) {
await editTitle(repo.rid, patch.id, updatedTitle);
}
}}
onDismiss={() => {
updatedTitle = patch.title;
editingTitle = !editingTitle;
}} />
<div class="title-icons">
<Icon name="pen" onclick={() => (editingTitle = !editingTitle)} />
<Icon
name="checkmark"
onclick={async () => {
if (updatedTitle.trim().length > 0) {
await editTitle(repo.rid, patch.id, updatedTitle);
}
}} />
<Icon
name="cross"
onclick={() => {
updatedTitle = patch.title;
editingTitle = !editingTitle;
}} />
<PatchStateButton patchState={patch.state} save={saveState} />
</div>
{/if}
</div>
{/if}
</div>
<Border variant="ghost" styleGap="0">
<div class="metadata-section" style:min-width="8rem">
<div class="metadata-section-title">Status</div>
<PatchStateBadge state={patch.state} />
</div>
<div class="metadata-divider"></div>
<div class="metadata-section" style:flex="1">
<LabelInput
allowedToEdit={!!roles.isDelegateOrAuthor(
config.publicKey,
repo.delegates.map(delegate => delegate.did),
patch.author.did,
)}
labels={patch.labels}
submitInProgress={labelSaveInProgress}
save={saveLabels} />
</div>
<div class="metadata-divider"></div>
<div class="metadata-section" style:flex="1">
<AssigneeInput
allowedToEdit={!!roles.isDelegateOrAuthor(
config.publicKey,
repo.delegates.map(delegate => delegate.did),
patch.author.did,
)}
assignees={patch.assignees}
submitInProgress={assigneesSaveInProgress}
save={saveAssignees} />
</div>
</Border>
<div class="global-flex" style:gap="0.5rem" style:margin-top="1rem">
<Border stylePosition="relative" variant="ghost" flatBottom>
<div
class="global-flex"
style:z-index="10"
style:gap="1rem"
style:padding="0 1rem"
style:width="100%">
<span class="txt-small" style:color="var(--color-foreground-dim)">
Revisions
</span>
<Tab
active={tab === "patch"}
onclick={() => {
tab = "patch";
}}>
{formatOid(patch.id)}
<span
class="global-counter"
style:height="24px"
style:color="var(--color-foreground-contrast)">
Initial
</span>
</Tab>
{#if revisions.length > 1}
<Tab
active={tab === "revisions"}
onclick={() => {
tab = "revisions";
}}>
{formatOid(selectedRevision.id)}
<div class="global-flex" style:gap="0.25rem">
<RevisionBadges revision={selectedRevision} {revisions} />
<RevisionSelector
{patch}
{revisions}
{selectedRevision}
selectRevision={rev => {
selectedRevision = rev;
tab = "revisions";
}} />
</div>
</Tab>
{/if}
<div style:margin-left="auto">
<Tab
active={tab === "timeline"}
onclick={() => {
tab = "timeline";
}}>
<Icon name="clock" />
Timeline
</Tab>
</div>
{:else}
<div class="title">
<div class="global-flex" style:gap="0">
<div
class="global-counter status"
style:color={patchStatusColor[patch.state.status]}
style:background-color={patchStatusBackgroundColor[
patch.state.status
]}>
<Icon
name={patch.state.status === "open"
? "patch"
: `patch-${patch.state.status}`} />
</div>
<InlineTitle content={patch.title} fontSize="medium" />
</div>
{#if roles.isDelegateOrAuthor( config.publicKey, repo.delegates.map(delegate => delegate.did), patch.author.did, )}
<div class="title-icons">
<Icon
name="pen"
onclick={() => (editingTitle = !editingTitle)} />
<PatchStateButton patchState={patch.state} save={saveState} />
</div>
{/if}
</div>
{/if}
</div>
<Border variant="ghost" styleGap="0">
<div class="metadata-section" style:min-width="8rem">
<div class="metadata-section-title">Status</div>
<PatchStateBadge state={patch.state} />
</div>
<div class="metadata-divider"></div>
<div class="metadata-section" style:flex="1">
<LabelInput
allowedToEdit={!!roles.isDelegateOrAuthor(
config.publicKey,
repo.delegates.map(delegate => delegate.did),
patch.author.did,
)}
labels={patch.labels}
submitInProgress={labelSaveInProgress}
save={saveLabels} />
</div>
<div class="metadata-divider"></div>
<div class="metadata-section" style:flex="1">
<AssigneeInput
allowedToEdit={!!roles.isDelegateOrAuthor(
config.publicKey,
repo.delegates.map(delegate => delegate.did),
patch.author.did,
)}
assignees={patch.assignees}
submitInProgress={assigneesSaveInProgress}
save={saveAssignees} />
</div>
</Border>
</div>
<Border
variant="ghost"
flatTop
styleWidth="100%"
stylePadding="1rem"
styleMinWidth="0"
styleDisplay="block"
styleFlexDirection="column"
styleAlignItems="flex-start">
{#if tab === "patch"}
<RevisionComponent
rid={repo.rid}
repoDelegates={repo.delegates}
patchId={patch.id}
{reload}
revision={revisions[0]}
{config} />
{:else if tab === "timeline"}
<PatchTimeline {activity} patchId={patch.id} />
{:else}
<RevisionComponent
rid={repo.rid}
repoDelegates={repo.delegates}
patchId={patch.id}
{reload}
revision={selectedRevision}
{config} />
{/if}
</Border>
</div>
<div class="global-flex" style:gap="0.5rem" style:margin-top="1rem">
<Border stylePosition="relative" variant="ghost" flatBottom>
<div
class="global-flex"
style:z-index="10"
style:gap="1rem"
style:padding="0 1rem"
style:width="100%">
<span class="txt-small" style:color="var(--color-foreground-dim)">
Revisions
</span>
<Tab
active={tab === "patch"}
onclick={() => {
tab = "patch";
}}>
{formatOid(patch.id)}
<span
class="global-counter"
style:height="24px"
style:color="var(--color-foreground-contrast)">
Initial
</span>
</Tab>
{#if revisions.length > 1}
<Tab
active={tab === "revisions"}
onclick={() => {
tab = "revisions";
}}>
{formatOid(selectedRevision.id)}
<div class="global-flex" style:gap="0.25rem">
<RevisionBadges revision={selectedRevision} {revisions} />
<RevisionSelector
{patch}
{revisions}
{selectedRevision}
selectRevision={rev => {
selectedRevision = rev;
tab = "revisions";
}} />
</div>
</Tab>
{/if}
<div style:margin-left="auto">
<Tab
active={tab === "timeline"}
onclick={() => {
tab = "timeline";
}}>
<Icon name="clock" />
Timeline
</Tab>
</div>
</div>
</Border>
</div>
<Border
variant="ghost"
flatTop
styleWidth="100%"
stylePadding="1rem"
styleMinWidth="0"
styleDisplay="block"
styleFlexDirection="column"
styleAlignItems="flex-start">
{#if tab === "patch"}
<RevisionComponent
rid={repo.rid}
repoDelegates={repo.delegates}
patchId={patch.id}
{reload}
{status}
revision={revisions[0]}
{config} />
{:else if tab === "timeline"}
<PatchTimeline {activity} patchId={patch.id} />
{:else}
<RevisionComponent
rid={repo.rid}
repoDelegates={repo.delegates}
patchId={patch.id}
{reload}
{status}
revision={selectedRevision}
{config} />
{/if}
</Border>
</div>
{/if}
</Layout>
+11 -1
View File
@@ -1,13 +1,14 @@
import type { Action as IssueAction } from "@bindings/cob/issue/Action";
import type { Action as PatchAction } from "@bindings/cob/patch/Action";
import type { Config } from "@bindings/config/Config";
import type { Thread } from "@bindings/cob/thread/Thread";
import type { Issue } from "@bindings/cob/issue/Issue";
import type { Operation } from "@bindings/cob/Operation";
import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery";
import type { Patch } from "@bindings/cob/patch/Patch";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import type { Review } from "@bindings/cob/patch/Review";
import type { Revision } from "@bindings/cob/patch/Revision";
import type { Thread } from "@bindings/cob/thread/Thread";
import { invoke } from "@app/lib/invoke";
import { unreachable } from "@app/lib/utils";
@@ -73,6 +74,7 @@ export interface RepoPatchRoute {
rid: string;
patch: string;
status: PatchStatus | undefined;
reviewId: string | undefined;
}
export interface LoadedRepoPatchRoute {
@@ -83,6 +85,7 @@ export interface LoadedRepoPatchRoute {
patch: Patch;
patches: PaginatedQuery<Patch[]>;
status: PatchStatus | undefined;
review: Review | undefined;
revisions: Revision[];
activity: Operation<PatchAction>[];
};
@@ -145,6 +148,10 @@ export async function loadPatch(
],
);
const review = revisions
.flatMap(r => r.reviews || [])
.find(review => review.id === route.reviewId);
return {
resource: "repo.patch",
params: {
@@ -154,6 +161,7 @@ export async function loadPatch(
patches,
revisions,
status: route.status,
review,
activity,
},
};
@@ -332,12 +340,14 @@ export function repoUrlToRoute(
const status = (searchParams.get("status") ?? undefined) as
| PatchStatus
| undefined;
const reviewId = searchParams.get("reviewId") ?? undefined;
if (id) {
return {
resource: "repo.patch",
rid,
patch: id,
status,
reviewId,
};
} else {
return { resource: "repo.patches", rid, status };