mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-09 13:40:28 +08:00
1117 lines
33 KiB
Svelte
1117 lines
33 KiB
Svelte
<script lang="ts" context="module">
|
|
import type {
|
|
Comment,
|
|
Review,
|
|
Merge,
|
|
Project,
|
|
LifecycleState,
|
|
PatchState,
|
|
Revision,
|
|
Diff,
|
|
} from "@httpd-client";
|
|
|
|
interface Thread {
|
|
root: Comment;
|
|
replies: Comment[];
|
|
}
|
|
|
|
interface TimelineReview {
|
|
inner: [string, Review];
|
|
type: "review";
|
|
timestamp: number;
|
|
}
|
|
|
|
interface TimelineMerge {
|
|
inner: Merge;
|
|
type: "merge";
|
|
timestamp: number;
|
|
}
|
|
|
|
interface TimelineThread {
|
|
inner: Thread;
|
|
type: "thread";
|
|
timestamp: number;
|
|
}
|
|
|
|
export type Timeline = TimelineMerge | TimelineReview | TimelineThread;
|
|
</script>
|
|
|
|
<script lang="ts">
|
|
import type { BaseUrl, Embed, Patch } from "@httpd-client";
|
|
import type { PatchView } from "./router";
|
|
import type { Route } from "@app/lib/router";
|
|
import type { ComponentProps } from "svelte";
|
|
import type { Session } from "@app/lib/httpd";
|
|
|
|
import * as modal from "@app/lib/modal";
|
|
import * as role from "@app/lib/roles";
|
|
import * as router from "@app/lib/router";
|
|
import * as utils from "@app/lib/utils";
|
|
import { experimental } from "@app/lib/appearance";
|
|
import capitalize from "lodash/capitalize";
|
|
import isEqual from "lodash/isEqual";
|
|
import partial from "lodash/partial";
|
|
import uniqBy from "lodash/uniqBy";
|
|
import { HttpdClient } from "@httpd-client";
|
|
import { httpdStore } from "@app/lib/httpd";
|
|
import { parseEmbedIntoMap } from "@app/lib/file";
|
|
|
|
import Badge from "@app/components/Badge.svelte";
|
|
import Button from "@app/components/Button.svelte";
|
|
import Changeset from "@app/views/projects/Changeset.svelte";
|
|
import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
|
|
import CobStateButton from "@app/views/projects/Cob/CobStateButton.svelte";
|
|
import CommentToggleInput from "@app/components/CommentToggleInput.svelte";
|
|
import CopyableId from "@app/components/CopyableId.svelte";
|
|
import DiffStatBadge from "@app/components/DiffStatBadge.svelte";
|
|
import DropdownList from "@app/components/DropdownList.svelte";
|
|
import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
|
|
import Embeds from "@app/views/projects/Cob/Embeds.svelte";
|
|
import ErrorModal from "@app/modals/ErrorModal.svelte";
|
|
import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
|
|
import IconSmall from "@app/components/IconSmall.svelte";
|
|
import InlineMarkdown from "@app/components/InlineMarkdown.svelte";
|
|
import LabelInput from "@app/views/projects/Cob/LabelInput.svelte";
|
|
import Layout from "@app/views/projects/Layout.svelte";
|
|
import Link from "@app/components/Link.svelte";
|
|
import Markdown from "@app/components/Markdown.svelte";
|
|
import NodeId from "@app/components/NodeId.svelte";
|
|
import Placeholder from "@app/components/Placeholder.svelte";
|
|
import Popover, { closeFocused } from "@app/components/Popover.svelte";
|
|
import Radio from "@app/components/Radio.svelte";
|
|
import ReactionSelector from "@app/components/ReactionSelector.svelte";
|
|
import Reactions from "@app/components/Reactions.svelte";
|
|
import RevisionComponent from "@app/views/projects/Cob/Revision.svelte";
|
|
import Share from "@app/views/projects/Share.svelte";
|
|
import TextInput from "@app/components/TextInput.svelte";
|
|
|
|
export let baseUrl: BaseUrl;
|
|
export let patch: Patch;
|
|
export let stats: Diff["stats"];
|
|
export let rawPath: (commit?: string) => string;
|
|
export let project: Project;
|
|
export let view: PatchView;
|
|
|
|
$: api = new HttpdClient(baseUrl);
|
|
|
|
const items: [string, LifecycleState][] = [
|
|
["Reopen patch", { status: "open" }],
|
|
["Archive patch", { status: "archived" }],
|
|
["Convert to draft", { status: "draft" }],
|
|
];
|
|
|
|
async function editPatch(sessionId: string, title: string) {
|
|
try {
|
|
await api.project.updatePatch(
|
|
project.id,
|
|
patch.id,
|
|
{ type: "edit", title, target: "delegates" },
|
|
sessionId,
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
modal.show({
|
|
component: ErrorModal,
|
|
props: {
|
|
title: "Patch title editing failed",
|
|
subtitle: [
|
|
"There was an error while updating the title of this patch.",
|
|
"Check your radicle-httpd logs for details.",
|
|
],
|
|
error: {
|
|
message: error.message,
|
|
stack: error.stack,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
} finally {
|
|
await refreshPatch();
|
|
}
|
|
}
|
|
|
|
async function editRevision(
|
|
sessionId: string,
|
|
revisionId: string,
|
|
description: string,
|
|
embeds: Embed[],
|
|
) {
|
|
try {
|
|
await api.project.updatePatch(
|
|
project.id,
|
|
patch.id,
|
|
{ type: "revision.edit", revision: revisionId, description, embeds },
|
|
sessionId,
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
modal.show({
|
|
component: ErrorModal,
|
|
props: {
|
|
title: "Revision editing failed",
|
|
subtitle: [
|
|
"There was an error while updating the revision.",
|
|
"Check your radicle-httpd logs for details.",
|
|
],
|
|
error: {
|
|
message: error.message,
|
|
stack: error.stack,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
} finally {
|
|
await refreshPatch();
|
|
}
|
|
}
|
|
|
|
async function createReply(
|
|
sessionId: string,
|
|
revisionId: string,
|
|
replyTo: string,
|
|
body: string,
|
|
embeds: Embed[],
|
|
) {
|
|
try {
|
|
await api.project.updatePatch(
|
|
project.id,
|
|
patch.id,
|
|
{
|
|
type: "revision.comment",
|
|
revision: revisionId,
|
|
body,
|
|
embeds,
|
|
replyTo,
|
|
},
|
|
sessionId,
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
modal.show({
|
|
component: ErrorModal,
|
|
props: {
|
|
title: "Patch comment reply creation failed",
|
|
subtitle: [
|
|
"There was an error while updating the patch.",
|
|
"Check your radicle-httpd logs for details.",
|
|
],
|
|
error: {
|
|
message: error.message,
|
|
stack: error.stack,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
} finally {
|
|
await refreshPatch();
|
|
}
|
|
}
|
|
|
|
async function reactOnComment(
|
|
session: Session,
|
|
revisionId: string,
|
|
commentId: string,
|
|
authors: Comment["reactions"][0]["authors"],
|
|
reaction: string,
|
|
) {
|
|
try {
|
|
await api.project.updatePatch(
|
|
project.id,
|
|
patch.id,
|
|
{
|
|
type: "revision.comment.react",
|
|
revision: revisionId,
|
|
comment: commentId,
|
|
reaction,
|
|
active: !authors.find(
|
|
({ id }) => utils.parseNodeId(id)?.pubkey === session.publicKey,
|
|
),
|
|
},
|
|
session.id,
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
modal.show({
|
|
component: ErrorModal,
|
|
props: {
|
|
title: "Patch comment reaction editing failed",
|
|
subtitle: [
|
|
"There was an error while updating the patch.",
|
|
"Check your radicle-httpd logs for details.",
|
|
],
|
|
error: {
|
|
message: error.message,
|
|
stack: error.stack,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
} finally {
|
|
await refreshPatch();
|
|
}
|
|
}
|
|
async function createComment(
|
|
sessionId: string,
|
|
revisionId: string,
|
|
comment: string,
|
|
embeds: Embed[],
|
|
) {
|
|
try {
|
|
await api.project.updatePatch(
|
|
project.id,
|
|
patch.id,
|
|
{
|
|
type: "revision.comment",
|
|
body: comment,
|
|
embeds,
|
|
revision: revisionId,
|
|
},
|
|
sessionId,
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
modal.show({
|
|
component: ErrorModal,
|
|
props: {
|
|
title: "Patch comment creation failed",
|
|
subtitle: [
|
|
"There was an error while updating the patch.",
|
|
"Check your radicle-httpd logs for details.",
|
|
],
|
|
error: {
|
|
message: error.message,
|
|
stack: error.stack,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
} finally {
|
|
await refreshPatch();
|
|
}
|
|
}
|
|
|
|
async function editComment(
|
|
sessionId: string,
|
|
revisionId: string,
|
|
id: string,
|
|
body: string,
|
|
embeds: Embed[],
|
|
) {
|
|
try {
|
|
await api.project.updatePatch(
|
|
project.id,
|
|
patch.id,
|
|
{
|
|
type: "revision.comment.edit",
|
|
comment: id,
|
|
body,
|
|
revision: revisionId,
|
|
embeds,
|
|
},
|
|
sessionId,
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
modal.show({
|
|
component: ErrorModal,
|
|
props: {
|
|
title: "Patch comment editing failed",
|
|
subtitle: [
|
|
"There was an error while updating the patch.",
|
|
"Check your radicle-httpd logs for details.",
|
|
],
|
|
error: {
|
|
message: error.message,
|
|
stack: error.stack,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
} finally {
|
|
await refreshPatch();
|
|
}
|
|
}
|
|
|
|
async function saveStatus(sessionId: string, state: PatchState) {
|
|
try {
|
|
if (state.status !== "merged") {
|
|
await api.project.updatePatch(
|
|
project.id,
|
|
patch.id,
|
|
{ type: "lifecycle", state },
|
|
sessionId,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
modal.show({
|
|
component: ErrorModal,
|
|
props: {
|
|
title: "Patch status change failed",
|
|
subtitle: [
|
|
"There was an error while updating the patch.",
|
|
"Check your radicle-httpd logs for details.",
|
|
],
|
|
error: {
|
|
message: error.message,
|
|
stack: error.stack,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
} finally {
|
|
void router.push({
|
|
resource: "project.patch",
|
|
project: project.id,
|
|
node: baseUrl,
|
|
patch: patch.id,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function reactOnRevision(
|
|
session: Session,
|
|
revisionId: string,
|
|
authors: Revision["reactions"][0]["authors"],
|
|
reaction: string,
|
|
) {
|
|
try {
|
|
await api.project.updatePatch(
|
|
project.id,
|
|
patch.id,
|
|
{
|
|
type: "revision.react",
|
|
revision: revisionId,
|
|
reaction,
|
|
active: !authors.find(
|
|
({ id }) => utils.parseNodeId(id)?.pubkey === session.publicKey,
|
|
),
|
|
},
|
|
session.id,
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
modal.show({
|
|
component: ErrorModal,
|
|
props: {
|
|
title: "Reacting on revision failed",
|
|
subtitle: [
|
|
"There was an error while trying to react to a revision.",
|
|
"Check your radicle-httpd logs for details.",
|
|
],
|
|
error: {
|
|
message: error.message,
|
|
stack: error.stack,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
} finally {
|
|
await refreshPatch();
|
|
}
|
|
}
|
|
|
|
async function saveLabels(sessionId: string, labels: string[]) {
|
|
try {
|
|
await api.project.updatePatch(
|
|
project.id,
|
|
patch.id,
|
|
{ type: "label", labels },
|
|
sessionId,
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
modal.show({
|
|
component: ErrorModal,
|
|
props: {
|
|
title: "Patch label change failed",
|
|
subtitle: [
|
|
"There was an error while updating the patch.",
|
|
"Check your radicle-httpd logs for details.",
|
|
],
|
|
error: {
|
|
message: error.message,
|
|
stack: error.stack,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
} finally {
|
|
await refreshPatch();
|
|
}
|
|
}
|
|
|
|
// Refreshes the given patch by fetching it from the server.
|
|
// If the fetch fails, the given patch is returned.
|
|
async function refreshPatch() {
|
|
try {
|
|
patch = await api.project.getPatchById(project.id, patch.id);
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
modal.show({
|
|
component: ErrorModal,
|
|
props: {
|
|
title: "Unable to fetch patch",
|
|
subtitle: [
|
|
"There was an error while refreshing this patch.",
|
|
"Check your radicle-httpd logs for details.",
|
|
],
|
|
error: {
|
|
message: error.message,
|
|
stack: error.stack,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function badgeColor(status: string): ComponentProps<Badge>["variant"] {
|
|
if (status === "draft") {
|
|
return "foreground";
|
|
} else if (status === "open") {
|
|
return "positive";
|
|
} else if (status === "archived") {
|
|
return "caution";
|
|
} else if (status === "merged") {
|
|
return "primary";
|
|
} else {
|
|
return "foreground";
|
|
}
|
|
}
|
|
|
|
type Tab = "activity" | "changes";
|
|
|
|
let tabs: Record<
|
|
Tab,
|
|
{ icon: ComponentProps<IconSmall>["name"]; route: Route }
|
|
>;
|
|
$: {
|
|
const baseRoute = {
|
|
resource: "project.patch",
|
|
project: project.id,
|
|
node: baseUrl,
|
|
patch: patch.id,
|
|
} as const;
|
|
// For cleaner URLs, we omit the the revision part when we link to the
|
|
// latest revision.
|
|
const latestRevisionId = patch.revisions[patch.revisions.length - 1].id;
|
|
const revision = latestRevisionId === revisionId ? undefined : revisionId;
|
|
tabs = {
|
|
activity: {
|
|
route: {
|
|
...baseRoute,
|
|
view: { name: "activity" },
|
|
},
|
|
icon: "activity",
|
|
},
|
|
changes: {
|
|
route: {
|
|
...baseRoute,
|
|
view: { name: "changes", revision },
|
|
},
|
|
icon: "diff",
|
|
},
|
|
};
|
|
}
|
|
|
|
function computeReviews(patch: Patch) {
|
|
const patchReviews: Record<string, { latest: boolean; review: Review }> =
|
|
{};
|
|
|
|
patch.revisions.forEach((rev, i) => {
|
|
const latest = i === patch.revisions.length - 1;
|
|
for (const review of rev.reviews) {
|
|
patchReviews[review.author.id] = { latest, review };
|
|
}
|
|
});
|
|
|
|
return patchReviews;
|
|
}
|
|
|
|
type State = "read" | "submit" | "edit";
|
|
|
|
let patchState: State = "read";
|
|
let labelState: State = "read";
|
|
|
|
let revisionId: string;
|
|
$: if (view.name === "diff") {
|
|
revisionId = patch.revisions[patch.revisions.length - 1].id;
|
|
} else {
|
|
revisionId = view.revision;
|
|
}
|
|
|
|
$: uniqueEmbeds = uniqBy(
|
|
patch.revisions.flatMap(({ discussions }) =>
|
|
discussions.flatMap(comment => comment.embeds),
|
|
),
|
|
"content",
|
|
);
|
|
$: description = patch.revisions[0].description;
|
|
$: lastEdit = patch.revisions[0].edits.at(-1);
|
|
$: newDescription = description;
|
|
$: patchReviews = computeReviews(patch);
|
|
$: selectedItem = patch.state.status === "open" ? items[1] : items[0];
|
|
$: timelineTuple = patch.revisions.map<
|
|
[
|
|
{
|
|
revisionId: string;
|
|
revisionTimestamp: number;
|
|
revisionBase: string;
|
|
revisionOid: string;
|
|
revisionEdits: Revision["edits"];
|
|
revisionReactions: Revision["reactions"];
|
|
revisionAuthor: { id: string; alias?: string | undefined };
|
|
revisionDescription: string;
|
|
},
|
|
Timeline[],
|
|
]
|
|
>(rev => [
|
|
{
|
|
revisionId: rev.id,
|
|
revisionTimestamp: rev.timestamp,
|
|
revisionBase: rev.base,
|
|
revisionOid: rev.oid,
|
|
revisionEdits: rev.edits,
|
|
revisionReactions: rev.reactions,
|
|
revisionAuthor: rev.author,
|
|
revisionDescription: rev.description,
|
|
},
|
|
[
|
|
...rev.reviews.map<TimelineReview>(review => ({
|
|
timestamp: review.timestamp,
|
|
type: "review",
|
|
inner: [review.author.id, review],
|
|
})),
|
|
...patch.merges
|
|
.filter(merge => merge.revision === rev.id)
|
|
.map<TimelineMerge>(inner => ({
|
|
timestamp: inner.timestamp,
|
|
type: "merge",
|
|
inner,
|
|
})),
|
|
...rev.discussions
|
|
.filter(comment => !comment.replyTo)
|
|
.map<TimelineThread>(thread => ({
|
|
timestamp: thread.timestamp,
|
|
type: "thread",
|
|
inner: {
|
|
root: thread,
|
|
replies: rev.discussions
|
|
.filter(comment => comment.replyTo === thread.id)
|
|
.sort((a, b) => a.timestamp - b.timestamp),
|
|
},
|
|
})),
|
|
].sort((a, b) => a.timestamp - b.timestamp),
|
|
]);
|
|
$: firstRevision = timelineTuple[0][0];
|
|
$: latestRevision = patch.revisions[patch.revisions.length - 1];
|
|
$: session =
|
|
$httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)
|
|
? $httpdStore.session
|
|
: undefined;
|
|
</script>
|
|
|
|
<style>
|
|
.patch {
|
|
display: flex;
|
|
flex: 1;
|
|
min-height: 100%;
|
|
}
|
|
.main {
|
|
display: flex;
|
|
flex: 1;
|
|
flex-direction: column;
|
|
background-color: var(--color-background-float);
|
|
}
|
|
.metadata {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.5rem;
|
|
font-size: var(--font-size-small);
|
|
padding: 1rem;
|
|
border-left: 1px solid var(--color-border-hint);
|
|
border-left: 1px solid var(--color-border-hint);
|
|
width: 20rem;
|
|
}
|
|
.title {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-weight: var(--font-weight-semibold);
|
|
font-size: var(--font-size-large);
|
|
}
|
|
.bottom {
|
|
background-color: var(--color-background-default);
|
|
padding: 1rem;
|
|
height: 100%;
|
|
}
|
|
.actions {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
.tabs {
|
|
font-size: var(--font-size-tiny);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: left;
|
|
flex-wrap: wrap;
|
|
position: relative;
|
|
margin-top: 1rem;
|
|
box-shadow: inset 0 -1px 0 var(--color-border-hint);
|
|
}
|
|
.tabs-spacer {
|
|
width: 1rem;
|
|
height: 100%;
|
|
}
|
|
.author {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: nowrap;
|
|
font-family: var(--font-family-sans-serif);
|
|
font-size: var(--font-size-small);
|
|
gap: 0.5rem;
|
|
}
|
|
.metadata-section-header {
|
|
font-size: var(--font-size-small);
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
.metadata-section-body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
.author-metadata {
|
|
color: var(--color-fill-gray);
|
|
font-size: var(--font-size-small);
|
|
}
|
|
.review {
|
|
color: var(--color-fill-gray);
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
.review-accept {
|
|
color: var(--color-foreground-success);
|
|
}
|
|
.review-reject {
|
|
color: var(--color-foreground-red);
|
|
}
|
|
.revision-description {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
width: 100%;
|
|
}
|
|
.diff-button-range {
|
|
font-family: var(--font-family-monospace);
|
|
font-weight: var(--font-weight-bold);
|
|
}
|
|
.connector {
|
|
width: 1px;
|
|
height: 1.5rem;
|
|
margin-left: 1.25rem;
|
|
background-color: var(--color-fill-separator);
|
|
}
|
|
@media (max-width: 720px) {
|
|
.patch {
|
|
display: block;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<Layout {baseUrl} {project} activeTab="patches">
|
|
<div class="patch">
|
|
<div class="main">
|
|
<CobHeader>
|
|
<svelte:fragment slot="title">
|
|
<div
|
|
style="display: flex; align-items: center; gap: 1rem; width: 100%;">
|
|
{#if patchState !== "read"}
|
|
<TextInput
|
|
placeholder="Title"
|
|
bind:value={patch.title}
|
|
showKeyHint={false} />
|
|
{:else if !patch.title}
|
|
<span class="txt-missing">No title</span>
|
|
{:else}
|
|
<div class="title">
|
|
<InlineMarkdown
|
|
stripEmphasizedStyling
|
|
fontSize="large"
|
|
content={patch.title} />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{#if session && role.isDelegateOrAuthor(session.publicKey, project.delegates, patch.author.id) && patchState === "read"}
|
|
<Button
|
|
variant="outline"
|
|
title="edit patch"
|
|
on:click={() => (patchState = "edit")}>
|
|
<IconSmall name={"edit"} />
|
|
Edit
|
|
</Button>
|
|
{/if}
|
|
{#if patchState === "read"}
|
|
<Share {baseUrl} />
|
|
{#if session && role.isDelegateOrAuthor(session.publicKey, project.delegates, patch.author.id)}
|
|
<CobStateButton
|
|
items={items.filter(
|
|
([, state]) => !isEqual(state, patch.state),
|
|
)}
|
|
{selectedItem}
|
|
state={patch.state}
|
|
save={partial(saveStatus, session.id)} />
|
|
{/if}
|
|
{/if}
|
|
</svelte:fragment>
|
|
<svelte:fragment slot="state">
|
|
<Badge size="tiny" variant={badgeColor(patch.state.status)}>
|
|
<IconSmall name="patch" />
|
|
{capitalize(patch.state.status)}
|
|
</Badge>
|
|
<Link
|
|
route={{
|
|
resource: "project.patch",
|
|
project: project.id,
|
|
node: baseUrl,
|
|
patch: patch.id,
|
|
view: { name: "changes", revision: latestRevision.id },
|
|
}}>
|
|
<DiffStatBadge
|
|
hoverable
|
|
insertions={stats.insertions}
|
|
deletions={stats.deletions} />
|
|
</Link>
|
|
</svelte:fragment>
|
|
<svelte:fragment slot="description">
|
|
<div class="revision-description">
|
|
{#if $experimental && session && patchState !== "read" && lastEdit}
|
|
<ExtendedTextarea
|
|
isValid={() => patch.title.length > 0}
|
|
enableAttachments
|
|
embeds={parseEmbedIntoMap(lastEdit.embeds)}
|
|
rawPath={rawPath(patch.revisions[0].id)}
|
|
body={newDescription}
|
|
submitCaption="Save"
|
|
submitInProgress={patchState === "submit"}
|
|
placeholder="Leave a description"
|
|
on:close={() => {
|
|
patchState = "read";
|
|
void refreshPatch();
|
|
}}
|
|
on:submit={async ({ detail: { comment, embeds } }) => {
|
|
patchState = "submit";
|
|
if (session) {
|
|
try {
|
|
await editPatch(session.id, patch.title);
|
|
await editRevision(
|
|
session.id,
|
|
patch.id,
|
|
comment,
|
|
Array.from(embeds.values()),
|
|
);
|
|
} finally {
|
|
patchState = "read";
|
|
}
|
|
}
|
|
}} />
|
|
{:else if description}
|
|
<Markdown content={description} rawPath={rawPath(patch.id)} />
|
|
{:else}
|
|
<span class="txt-missing">No description available</span>
|
|
{/if}
|
|
{#if ($experimental && session) || (firstRevision.revisionReactions && firstRevision.revisionReactions.length > 0)}
|
|
<div class="actions">
|
|
{#if session}
|
|
<ReactionSelector
|
|
reactions={firstRevision.revisionReactions}
|
|
on:select={async ({ detail: { emoji, authors } }) => {
|
|
if (session) {
|
|
try {
|
|
await reactOnRevision(
|
|
session,
|
|
patch.id,
|
|
authors,
|
|
emoji,
|
|
);
|
|
} finally {
|
|
closeFocused();
|
|
}
|
|
}
|
|
}} />
|
|
{/if}
|
|
{#if firstRevision.revisionReactions.length > 0}
|
|
<Reactions
|
|
handleReaction={session &&
|
|
partial(reactOnRevision, session, patch.id)}
|
|
reactions={firstRevision.revisionReactions} />
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</svelte:fragment>
|
|
<div class="author" slot="author">
|
|
<NodeId nodeId={patch.author.id} alias={patch.author.alias} />
|
|
opened
|
|
<CopyableId id={patch.id} style="oid">
|
|
{utils.formatObjectId(patch.id)}
|
|
</CopyableId>
|
|
<span title={utils.absoluteTimestamp(patch.revisions[0].timestamp)}>
|
|
{utils.formatTimestamp(patch.revisions[0].timestamp)}
|
|
</span>
|
|
{#if patch.revisions[0].edits.length > 1 && lastEdit}
|
|
<div class="author-metadata">•</div>
|
|
<div
|
|
class="author-metadata"
|
|
title={utils.formatEditedCaption(
|
|
lastEdit.author,
|
|
lastEdit.timestamp,
|
|
)}>
|
|
edited
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</CobHeader>
|
|
|
|
<div class="tabs">
|
|
<div class="tabs-spacer" />
|
|
<Radio styleGap="0.375rem">
|
|
{#each Object.entries(tabs) as [name, { route, icon }]}
|
|
<Link {route}>
|
|
<Button
|
|
size="large"
|
|
variant={name === view.name ? "tab-active" : "tab"}>
|
|
<IconSmall name={icon} />
|
|
{capitalize(name)}
|
|
</Button>
|
|
</Link>
|
|
{/each}
|
|
{#if view.name === "diff"}
|
|
<Link
|
|
route={{
|
|
resource: "project.patch",
|
|
project: project.id,
|
|
node: baseUrl,
|
|
patch: patch.id,
|
|
view: {
|
|
name: "diff",
|
|
fromCommit: view.fromCommit,
|
|
toCommit: view.toCommit,
|
|
},
|
|
}}>
|
|
<Button size="large" variant="tab-active">
|
|
Compare <span class="diff-button-range">
|
|
{view.fromCommit.substring(0, 6)}..{view.toCommit.substring(
|
|
0,
|
|
6,
|
|
)}
|
|
</span>
|
|
</Button>
|
|
</Link>
|
|
{/if}
|
|
</Radio>
|
|
|
|
<div style="margin-left: auto; margin-top: -0.5rem;">
|
|
{#if view.name === "changes"}
|
|
<div style="margin-left: auto; ">
|
|
<Popover
|
|
popoverPadding="0"
|
|
popoverPositionTop="2.5rem"
|
|
popoverBorderRadius="var(--border-radius-small)">
|
|
<Button
|
|
let:expanded
|
|
slot="toggle"
|
|
let:toggle
|
|
on:click={toggle}
|
|
size="regular"
|
|
disabled={patch.revisions.length === 1}>
|
|
<span style:color="var(--color-foreground-contrast)">
|
|
Revision
|
|
</span>
|
|
<span
|
|
style:color="var(--color-fill-secondary)"
|
|
style:font-family="var(--font-family-monospace)">
|
|
{utils.formatObjectId(view.revision)}
|
|
</span>
|
|
<IconSmall name={expanded ? "chevron-up" : "chevron-down"} />
|
|
</Button>
|
|
<DropdownList slot="popover" items={patch.revisions}>
|
|
<svelte:fragment slot="item" let:item>
|
|
<Link
|
|
on:afterNavigate={closeFocused}
|
|
route={{
|
|
resource: "project.patch",
|
|
project: project.id,
|
|
node: baseUrl,
|
|
patch: patch.id,
|
|
view: {
|
|
name: view.name,
|
|
revision: item.id,
|
|
},
|
|
}}>
|
|
<DropdownListItem selected={item.id === view.revision}>
|
|
<span
|
|
style:color={item.id === view.revision
|
|
? "var(--color-foreground-contrast)"
|
|
: "var(--color-fill-gray)"}>
|
|
Revision
|
|
</span>
|
|
<span
|
|
style:color="var(--color-fill-secondary)"
|
|
style:font-family="var(--font-family-monospace)">
|
|
{utils.formatObjectId(item.id)}
|
|
</span>
|
|
</DropdownListItem>
|
|
</Link>
|
|
</svelte:fragment>
|
|
</DropdownList>
|
|
</Popover>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<div class="tabs-spacer" />
|
|
</div>
|
|
<div class="bottom">
|
|
{#if view.name === "diff"}
|
|
<Changeset
|
|
{baseUrl}
|
|
projectId={project.id}
|
|
revision={view.toCommit}
|
|
files={view.files}
|
|
diff={view.diff} />
|
|
{:else if view.name === "activity"}
|
|
{#each timelineTuple as [revision, timelines], index}
|
|
{@const previousRevision =
|
|
index > 0 ? patch.revisions[index - 1] : undefined}
|
|
<RevisionComponent
|
|
{baseUrl}
|
|
{rawPath}
|
|
projectId={project.id}
|
|
{timelines}
|
|
{...revision}
|
|
first={index === 0}
|
|
canEdit={partial(
|
|
role.isDelegateOrAuthor,
|
|
session?.publicKey,
|
|
project.delegates,
|
|
)}
|
|
editRevision={$experimental &&
|
|
session &&
|
|
partial(editRevision, session.id, revision.revisionId)}
|
|
editComment={$experimental &&
|
|
session &&
|
|
partial(editComment, session.id, revision.revisionId)}
|
|
reactOnComment={$experimental &&
|
|
session &&
|
|
partial(reactOnComment, session, revision.revisionId)}
|
|
reactOnRevision={$experimental &&
|
|
session &&
|
|
partial(reactOnRevision, session, revision.revisionId)}
|
|
createReply={$experimental &&
|
|
session &&
|
|
partial(createReply, session.id, revision.revisionId)}
|
|
patchId={patch.id}
|
|
patchState={patch.state}
|
|
initiallyExpanded={index === patch.revisions.length - 1}
|
|
previousRevId={previousRevision?.id}
|
|
previousRevBase={previousRevision?.base}
|
|
previousRevOid={previousRevision?.oid}>
|
|
{#if index === patch.revisions.length - 1}
|
|
{#if $experimental && session && view.name === "activity"}
|
|
<div class="connector" />
|
|
<CommentToggleInput
|
|
rawPath={rawPath(patch.revisions[0].id)}
|
|
focus
|
|
enableAttachments
|
|
placeholder="Leave your comment"
|
|
submit={partial(
|
|
createComment,
|
|
session.id,
|
|
revision.revisionId,
|
|
)} />
|
|
{#if role.isDelegateOrAuthor(session.publicKey, project.delegates, patch.author.id)}
|
|
<div class="connector" />
|
|
<div style="display: flex;">
|
|
<CobStateButton
|
|
items={items.filter(
|
|
([, state]) => !isEqual(state, patch.state),
|
|
)}
|
|
{selectedItem}
|
|
state={patch.state}
|
|
save={partial(saveStatus, session.id)} />
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
{/if}
|
|
</RevisionComponent>
|
|
{:else}
|
|
<div style:margin="4rem 0">
|
|
<Placeholder
|
|
iconName="no-patches"
|
|
caption="No activity on this patch yet" />
|
|
</div>
|
|
{/each}
|
|
{:else if view.name === "changes"}
|
|
<Changeset
|
|
{baseUrl}
|
|
projectId={project.id}
|
|
revision={view.oid}
|
|
files={view.files}
|
|
diff={view.diff} />
|
|
{:else}
|
|
{utils.unreachable(view)}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="metadata global-hide-on-mobile">
|
|
<div>
|
|
<div class="metadata-section-header">Reviews</div>
|
|
<div class="metadata-section-body">
|
|
{#each Object.values(patchReviews) as { latest, review }}
|
|
<div class="review" class:txt-missing={!latest}>
|
|
<span
|
|
class:review-accept={review.verdict === "accept"}
|
|
class:review-reject={review.verdict === "reject"}>
|
|
{#if review.verdict === "accept"}
|
|
<IconSmall name="checkmark" />
|
|
{:else if review.verdict === "reject"}
|
|
<IconSmall name="cross" />
|
|
{:else}
|
|
<IconSmall name="chat" />
|
|
{/if}
|
|
</span>
|
|
<NodeId nodeId={review.author.id} alias={review.author.alias} />
|
|
</div>
|
|
{:else}
|
|
<div class="txt-missing">No reviews</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
<LabelInput
|
|
locallyAuthenticated={role.isDelegate(
|
|
session?.publicKey,
|
|
project.delegates,
|
|
)}
|
|
submitInProgress={labelState === "submit"}
|
|
labels={patch.labels}
|
|
on:save={async ({ detail: newLabels }) => {
|
|
if (session) {
|
|
labelState = "submit";
|
|
try {
|
|
await saveLabels(session.id, newLabels);
|
|
} finally {
|
|
labelState = "read";
|
|
}
|
|
}
|
|
}} />
|
|
<Embeds embeds={uniqueEmbeds} />
|
|
</div>
|
|
</div>
|
|
</Layout>
|