<script lang="ts" context="module">
import type {
Author,
Comment,
Review,
Merge,
Repo,
Revision,
Diff,
} from "@http-client";
interface Thread {
root: Comment;
replies: Comment[];
}
interface ReviewWithThreads extends Review {
threads: Thread[];
}
interface TimelineReview {
inner: [string, ReviewWithThreads];
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;
export type PatchReviews = Record<
string,
{ latest: boolean; review: Review }
>;
</script>
<script lang="ts">
import type { BaseUrl, Patch } from "@http-client";
import type { PatchView } from "./router";
import type { Route } from "@app/lib/router";
import type { ComponentProps } from "svelte";
import * as utils from "@app/lib/utils";
import capitalize from "lodash/capitalize";
import uniqBy from "lodash/uniqBy";
import Badge from "@app/components/Badge.svelte";
import Button from "@app/components/Button.svelte";
import Changeset from "@app/views/repos/Changeset.svelte";
import CheckoutButton from "@app/views/repos/Patch/CheckoutButton.svelte";
import CobHeader from "@app/views/repos/Cob/CobHeader.svelte";
import CompareButton from "@app/views/repos/Patch/CompareButton.svelte";
import DiffStatBadge from "@app/components/DiffStatBadge.svelte";
import Embeds from "@app/views/repos/Cob/Embeds.svelte";
import Icon from "@app/components/Icon.svelte";
import Id from "@app/components/Id.svelte";
import InlineTitle from "@app/views/repos/components/InlineTitle.svelte";
import Labels from "@app/views/repos/Cob/Labels.svelte";
import Layout from "@app/views/repos/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 Reactions from "@app/components/Reactions.svelte";
import Reviews from "@app/views/repos/Cob/Reviews.svelte";
import RevisionComponent from "@app/views/repos/Cob/Revision.svelte";
import RevisionSelector from "@app/views/repos/Patch/RevisionSelector.svelte";
import Separator from "./Separator.svelte";
import Share from "@app/views/repos/Share.svelte";
export let baseUrl: BaseUrl;
export let patch: Patch;
export let stats: Diff["stats"];
export let rawPath: (commit?: string) => string;
export let repo: Repo;
export let view: PatchView;
export let nodeAvatarUrl: string | undefined;
function badgeColor(status: string): ComponentProps<Badge>["variant"] {
if (status === "draft") {
return "draft";
} else if (status === "open") {
return "open";
} else if (status === "archived") {
return "archived";
} else if (status === "merged") {
return "merged";
} else {
return "draft";
}
}
type Tab = "activity" | "changes";
let tabs: Record<Tab, { icon: ComponentProps<Icon>["name"]; route: Route }>;
$: {
const baseRoute = {
resource: "repo.patch",
repo: repo.rid,
node: baseUrl,
patch: patch.id,
} as const;
// For cleaner URLs, we omit 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;
}
// eslint-disable-next-line no-useless-assignment
$: revisionId =
view.name === "diff"
? patch.revisions[patch.revisions.length - 1].id
: 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);
$: reviews = computeReviews(patch);
$: timelineTuple = patch.revisions.map<
[
{
revisionId: string;
revisionTimestamp: number;
revisionBase: string;
revisionOid: string;
revisionEdits: Revision["edits"];
revisionReactions: Revision["reactions"];
revisionAuthor: Author;
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 => {
const reviewThreads: Thread[] = review.comments
.filter(comment => !comment.replyTo)
.map(thread => ({
root: thread,
replies: review.comments
.filter(comment => comment.replyTo === thread.id)
.sort((a, b) => a.timestamp - b.timestamp),
}));
return {
timestamp: review.timestamp,
type: "review",
inner: [review.author.id, { ...review, threads: reviewThreads }],
};
}),
...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];
</script>
<style>
.patch {
display: flex;
flex: 1;
min-height: 100%;
}
.main {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
background-color: var(--color-surface-canvas);
}
.metadata {
display: flex;
flex-direction: column;
gap: 1.5rem;
font: var(--txt-body-m-regular);
padding: 1rem;
border-left: 1px solid var(--color-border-subtle);
width: 20rem;
}
.title {
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 0.5rem;
font: var(--txt-heading-l);
word-break: break-word;
}
.bottom {
background-color: var(--color-surface-base);
padding: 1rem 1rem 0.5rem 1rem;
height: 100%;
}
.tabs {
display: flex;
align-items: center;
gap: 0.25rem;
flex-wrap: wrap;
padding: 1rem;
border-top: 1px solid var(--color-border-subtle);
border-bottom: 1px solid var(--color-border-subtle);
background-color: var(--color-surface-base);
}
.author-metadata {
color: var(--color-text-tertiary);
font: var(--txt-body-m-regular);
}
.revision-description {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
@media (max-width: 719.98px) {
.patch {
display: block;
}
.bottom {
padding: 1rem 0 0 0;
}
}
</style>
<Layout
{baseUrl}
{repo}
{nodeAvatarUrl}
activeTab="patches"
stylePaddingBottom="0">
<svelte:fragment slot="breadcrumb">
<Separator />
<Link
route={{
resource: "repo.patches",
repo: repo.rid,
node: baseUrl,
}}>
Patches
</Link>
<Separator />
<span class="txt-id">
<div class="global-hide-on-small-desktop-down">
{patch.id}
</div>
<div class="global-hide-on-medium-desktop-up">
{utils.formatObjectId(patch.id)}
</div>
</span>
</svelte:fragment>
<div class="patch">
<div class="main">
<CobHeader>
<svelte:fragment slot="title">
{#if patch.title}
<div class="title">
<InlineTitle fontSize="heading-l" content={patch.title} />
</div>
{:else}
<span style:color="var(--color-text-tertiary)">No title</span>
{/if}
<div class="global-flex-item">
<Share />
<div class="global-hide-on-mobile-down">
<CheckoutButton id={patch.id} />
</div>
</div>
</svelte:fragment>
<svelte:fragment slot="state">
<Badge size="tiny" variant={badgeColor(patch.state.status)}>
<Icon
name={patch.state.status === "draft"
? "patch-draft"
: patch.state.status === "merged"
? "patch-merged"
: patch.state.status === "archived"
? "patch-archived"
: "patch"} />
{capitalize(patch.state.status)}
</Badge>
<Link
route={{
resource: "repo.patch",
repo: repo.rid,
node: baseUrl,
patch: patch.id,
view: { name: "changes", revision: latestRevision.id },
}}>
<DiffStatBadge
hoverable
insertions={stats.insertions}
deletions={stats.deletions} />
</Link>
<NodeId
{baseUrl}
nodeId={patch.author.id}
alias={patch.author.alias} />
opened
<Id id={patch.id} />
<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"
title={utils.formatEditedCaption(
lastEdit.author,
lastEdit.timestamp,
)}>
• edited
</div>
{/if}
</svelte:fragment>
<div slot="subtitle" class="global-hide-on-desktop-up">
<div
style:margin-top="2rem"
style="display: flex; flex-direction: column; gap: 0.5rem;">
<Reviews {baseUrl} {reviews} />
<Labels labels={patch.labels} />
<Embeds embeds={uniqueEmbeds} />
</div>
</div>
<svelte:fragment slot="description">
<div class="revision-description">
{#if description}
<Markdown
breaks
content={description}
rawPath={rawPath(patch.id)} />
{:else}
<span style:color="var(--color-text-tertiary)">
No description available
</span>
{/if}
{#if firstRevision.revisionReactions.length > 0}
<Reactions reactions={firstRevision.revisionReactions} />
{/if}
</div>
</svelte:fragment>
</CobHeader>
<div class="tabs">
{#each Object.entries(tabs) as [name, { route, icon }]}
<Link {route}>
<Button
variant={name === view.name ||
(view.name === "diff" && name === "changes")
? "gray"
: "background"}>
<Icon name={icon} />
{capitalize(name)}
</Button>
</Link>
{/each}
{#if view.name === "changes"}
<div class="global-hide-on-mobile-down" style="margin-left: auto;">
<RevisionSelector {view} {baseUrl} {patch} {repo} />
</div>
{/if}
{#if view.name === "diff"}
<div class="global-hide-on-mobile-down" style="margin-left: auto;">
<CompareButton
fromCommit={view.fromCommit}
toCommit={view.toCommit} />
</div>
{/if}
</div>
<div class="bottom">
{#if view.name === "changes"}
<div
style:width="100%"
style:padding="0 1rem"
style:display="flex"
class="global-hide-on-small-desktop-up">
<RevisionSelector {view} {baseUrl} {patch} {repo} />
</div>
{/if}
{#if view.name === "diff"}
<div
style:width="100%"
style:padding="0 1rem"
style:display="flex"
class="global-hide-on-small-desktop-up">
<CompareButton
fromCommit={view.fromCommit}
toCommit={view.toCommit} />
</div>
<Changeset
{baseUrl}
repoId={repo.rid}
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}
repoId={repo.rid}
{timelines}
{...revision}
first={index === 0}
patchId={patch.id}
patchState={patch.state}
initiallyExpanded={index === patch.revisions.length - 1}
previousRevId={previousRevision?.id}
previousRevBase={previousRevision?.base}
previousRevOid={previousRevision?.oid} />
{: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}
repoId={repo.rid}
revision={view.oid}
files={view.files}
diff={view.diff} />
{:else}
{utils.unreachable(view)}
{/if}
</div>
</div>
<div class="metadata global-hide-on-medium-desktop-down">
<Reviews {baseUrl} {reviews} />
<Labels labels={patch.labels} />
<Embeds embeds={uniqueEmbeds} />
</div>
</div>
</Layout>