Add revision commit list

This commit is contained in:
Rūdolfs Ošiņš
2025-02-04 15:49:00 -06:00
parent f34522b689
commit 014a3e9eee
11 changed files with 500 additions and 179 deletions
+91
View File
@@ -0,0 +1,91 @@
<script lang="ts">
import type { Commit } from "@bindings/repo/Commit";
import { twemoji } from "@app/lib/utils";
import CompactCommitAuthorship from "@app/components/CompactCommitAuthorship.svelte";
import Icon from "@app/components/Icon.svelte";
import Id from "@app/components/Id.svelte";
import InlineTitle from "@app/components/InlineTitle.svelte";
import NakedButton from "@app/components/NakedButton.svelte";
interface Props {
commit: Commit;
}
const { commit }: Props = $props();
let commitMessageVisible = $state(false);
</script>
<style>
.teaser {
display: flex;
font-size: var(--font-size-small);
align-items: start;
padding: 0.125rem 0;
}
.message {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.left {
display: flex;
gap: 0.5rem;
padding: 0 0.5rem;
flex-direction: column;
}
.right {
display: flex;
align-items: center;
gap: 1rem;
margin-left: auto;
height: 21px;
}
.commit-message {
font-size: var(--font-size-tiny);
}
.commit-expand-button {
height: 21px;
display: flex;
align-items: center;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
</style>
<div class="teaser" aria-label="commit-teaser">
<div class="left">
<div class="message">
<div class="summary" use:twemoji>
<InlineTitle fontSize="small" content={commit.summary} />
</div>
{#if commit.message.trim() !== commit.summary.trim()}
<div class="commit-expand-button">
<NakedButton
stylePadding="0 4px"
variant="ghost"
onclick={() => {
commitMessageVisible = !commitMessageVisible;
}}>
<Icon name="ellipsis" />
</NakedButton>
</div>
{/if}
</div>
{#if commitMessageVisible}
<div class="commit-message">
<pre>{commit.message.replace(commit.summary, "").trim()}</pre>
</div>
{/if}
</div>
<div class="right">
<CompactCommitAuthorship {commit}>
<Id id={commit.id} variant="commit" />
</CompactCommitAuthorship>
</div>
</div>
+69
View File
@@ -0,0 +1,69 @@
<script lang="ts">
import type { Snippet } from "svelte";
import Border from "./Border.svelte";
import Icon from "./Icon.svelte";
import NakedButton from "./NakedButton.svelte";
interface Props {
leftHeader: Snippet;
children: Snippet;
expanded: boolean;
}
/* eslint-disable prefer-const */
let { leftHeader, children, expanded }: Props = $props();
/* eslint-enable prefer-const */
</script>
<style>
.header {
display: flex;
align-items: center;
height: 2rem;
padding-left: 0.25rem;
font-size: var(--font-size-small);
background-color: var(--color-background-default);
}
.left {
display: flex;
gap: 0.5rem;
align-items: center;
}
.divider {
width: calc(100% + 4px);
position: relative;
top: -6px;
left: -2px;
z-index: 1;
height: 2px;
background-color: var(--color-fill-ghost);
}
</style>
<Border
variant="ghost"
styleFlexDirection="column"
styleAlignItems="flex-start">
<div class="header" class:collapsed={!expanded}>
<div class="left">
<NakedButton
stylePadding="0 4px"
variant="ghost"
onclick={async () => {
expanded = !expanded;
}}>
<Icon name={expanded ? "chevron-down" : "chevron-right"} />
</NakedButton>
{@render leftHeader()}
</div>
</div>
{#if expanded}
<div class="divider"></div>
<div style:width="100%">
{@render children()}
</div>
{/if}
</Border>
@@ -0,0 +1,106 @@
<script lang="ts">
import type { Commit } from "@bindings/repo/Commit";
import type { Snippet } from "svelte";
import * as utils from "@app/lib/utils";
import HoverPopover from "./HoverPopover.svelte";
interface Props {
children: Snippet;
commit: Commit;
}
const { children, commit }: Props = $props();
</script>
<style>
.authorship {
display: flex;
font-size: var(--font-size-small);
column-gap: 0.5rem;
align-items: center;
white-space: nowrap;
}
.person {
display: flex;
align-items: center;
flex-wrap: nowrap;
white-space: nowrap;
gap: 0.5rem;
font-family: var(--font-family-monospace);
font-weight: var(--font-weight-semibold);
}
.label {
font-family: var(--font-family-sans-serif);
font-weight: var(--font-weight-regular);
color: var(--color-foreground-dim);
}
.avatar {
width: 1rem;
height: 1rem;
clip-path: var(--1px-corner-fill);
}
</style>
<div class="authorship">
<HoverPopover
stylePopoverPositionLeft="-8rem"
stylePopoverPositionBottom="1.5rem">
{#snippet toggle()}
<div style="display: flex;">
{#if commit.author.email === commit.committer.email}
<img
class="avatar"
alt="avatar"
src={utils.gravatarURL(commit.committer.email)} />
{:else}
<img
style:margin-right="0.25rem"
class="avatar"
alt="avatar"
src={utils.gravatarURL(commit.author.email)} />
<img
class="avatar"
alt="avatar"
src={utils.gravatarURL(commit.committer.email)} />
{/if}
</div>
{/snippet}
{#snippet popover()}
<div class="popover">
{#if commit.author.email === commit.committer.email}
<div class="person">
<div class="label">Author</div>
<img
class="avatar"
alt="avatar"
src={utils.gravatarURL(commit.committer.email)} />
{commit.author.name}
</div>
{:else}
<div class="person">
<div class="label">Author</div>
<img
class="avatar"
alt="avatar"
src={utils.gravatarURL(commit.author.email)} />
{commit.author.name}
</div>
<div class="person">
<div class="label">Committer</div>
<img
class="avatar"
alt="avatar"
src={utils.gravatarURL(commit.committer.email)} />
{commit.committer.name}
</div>
{/if}
</div>
{/snippet}
</HoverPopover>
{@render children()}
<div title={utils.absoluteTimestamp(commit.committer.time * 1000)}>
{utils.formatTimestamp(commit.committer.time * 1000)}
</div>
</div>
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts">
import type { Snippet } from "svelte";
import debounce from "lodash/debounce";
interface Props {
popover: Snippet;
stylePopoverPositionBottom: string | undefined;
stylePopoverPositionLeft: string | undefined;
toggle: Snippet;
}
const {
popover,
stylePopoverPositionBottom,
stylePopoverPositionLeft,
toggle,
}: Props = $props();
let visible: boolean = $state(false);
const setVisible = debounce((value: boolean) => {
visible = value;
}, 50);
</script>
<style>
.container {
position: relative;
display: inline-block;
}
.popover {
background: var(--color-fill-ghost);
border-radius: var(--border-radius-regular);
padding: 0.5rem 1rem;
box-shadow: var(--elevation-low);
position: absolute;
clip-path: var(--2px-corner-fill);
z-index: 10;
}
</style>
<div class="container">
<div
role="button"
tabindex="0"
onmouseenter={() => setVisible(true)}
onmouseleave={() => setVisible(false)}>
{@render toggle()}
{#if visible}
<div style:position="absolute">
<div
class="popover"
style:left={stylePopoverPositionLeft}
style:bottom={stylePopoverPositionBottom}>
{@render popover()}
</div>
</div>
{/if}
</div>
</div>
+5
View File
@@ -29,6 +29,7 @@
| "dashboard"
| "delegate"
| "diff"
| "ellipsis"
| "expand"
| "expand-panel"
| "eye"
@@ -432,6 +433,10 @@
<path d="M7 4H8V9H7V4Z" />
<path d="M5 6H10V7H5V6Z" />
<path d="M5 10H10V11H5V10Z" />
{:else if name === "ellipsis"}
<path d="M2 7H4V9H2V7Z" />
<path d="M7 7H9V9H7V7Z" />
<path d="M12 7H14V9H12V7Z" />
{:else if name === "expand"}
<path d="M9 1.5H8V2.5H9V1.5Z" />
<path d="M10 2.5H9V3.5H10V2.5Z" />
+90 -28
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import type { Author } from "@bindings/cob/Author";
import type { Commit } from "@bindings/repo/Commit";
import type { Config } from "@bindings/config/Config";
import type { Diff } from "@bindings/diff/Diff";
import type { Embed } from "@bindings/cob/thread/Embed";
@@ -16,9 +17,12 @@
import { publicKeyFromDid, scrollIntoView } from "@app/lib/utils";
import Changeset from "@app/components/Changeset.svelte";
import CobCommitTeaser from "./CobCommitTeaser.svelte";
import CommentComponent from "@app/components/Comment.svelte";
import CommentToggleInput from "@app/components/CommentToggleInput.svelte";
import CommitsContainer from "@app/components/CommitsContainer.svelte";
import Icon from "@app/components/Icon.svelte";
import Id from "./Id.svelte";
import NakedButton from "./NakedButton.svelte";
import ReviewTeaser from "@app/components/ReviewTeaser.svelte";
import ThreadComponent from "@app/components/Thread.svelte";
@@ -226,6 +230,14 @@
},
});
}
async function loadCommits(rid: string, base: string, head: string) {
return invoke<Commit[]>("list_commits", {
rid,
base,
head,
});
}
</script>
<style>
@@ -255,6 +267,32 @@
margin-left: 1.25rem;
background-color: var(--color-background-float);
}
.commits {
position: relative;
display: flex;
flex-direction: column;
font-size: 0.875rem;
margin-left: 0.5rem;
gap: 0.5rem;
padding: 1rem 0.5rem 0.5rem 1rem;
border-left: 1px solid var(--color-fill-separator);
}
.commit:last-of-type::after {
content: "";
position: absolute;
left: -18.5px;
top: 14px;
bottom: -0.5rem;
border-left: 4px solid var(--color-background-default);
}
.commit-dot {
width: 4px;
height: 4px;
position: absolute;
top: 0.625rem;
left: -18.5px;
background-color: var(--color-fill-separator);
}
</style>
<div class="txt-small patch-body">
@@ -282,15 +320,14 @@
</div>
<div style:margin="1rem 0">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
role="button"
tabindex="0"
class="txt-semibold global-flex"
style:margin-bottom="1rem"
style:cursor="pointer"
onclick={() => (hideDiscussion = !hideDiscussion)}>
<Icon name={hideDiscussion ? "chevron-right" : "chevron-down"} />Discussion
<div class="global-flex" style:margin-bottom="1rem">
<NakedButton
stylePadding="0 4px"
variant="ghost"
onclick={() => (hideDiscussion = !hideDiscussion)}>
<Icon name={hideDiscussion ? "chevron-right" : "chevron-down"} />
<div class="txt-semibold global-flex txt-regular">Discussion</div>
</NakedButton>
</div>
<div class:hide={hideDiscussion}>
{#each threads as thread}
@@ -325,17 +362,16 @@
{#if revision.reviews && revision.reviews.length}
<div style:margin="1rem 0">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
role="button"
tabindex="0"
class="txt-semibold global-flex"
style:margin-bottom="1rem"
style:cursor="pointer"
onclick={() => (hideReviews = !hideReviews)}>
<Icon name={hideReviews ? "chevron-right" : "chevron-down"} />Reviews
<div class="global-flex" style:margin-bottom="1rem">
<NakedButton
stylePadding="0 4px"
variant="ghost"
onclick={() => (hideReviews = !hideReviews)}>
<Icon name={hideReviews ? "chevron-right" : "chevron-down"} />
<div class="txt-semibold global-flex txt-regular">Reviews</div>
</NakedButton>
</div>
<div class:hide={hideReviews}>
<div class:hide={hideReviews} style:margin-top="1rem">
{#each revision.reviews as review}
<ReviewTeaser {rid} {review} />
{/each}
@@ -343,19 +379,16 @@
</div>
{/if}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="txt-semibold global-flex"
style:margin-bottom={hideChanges ? undefined : "1rem"}>
<div
style:cursor="pointer"
style:min-height="2rem"
class="global-flex"
role="button"
tabindex="0"
<NakedButton
stylePadding="0 4px"
variant="ghost"
onclick={() => (hideChanges = !hideChanges)}>
<Icon name={hideChanges ? "chevron-right" : "chevron-down"} />Changes
</div>
<Icon name={hideChanges ? "chevron-right" : "chevron-down"} />
<div class="txt-semibold global-flex txt-regular">Changes</div>
</NakedButton>
{#if !hideChanges}
<div style:margin-left="auto">
<NakedButton
@@ -372,7 +405,36 @@
</div>
{/if}
</div>
<div class:hide={hideChanges}>
{#await loadCommits(rid, revision.base, revision.head) then commits}
<div style:margin-bottom="1rem">
<CommitsContainer expanded={filesExpanded}>
{#snippet leftHeader()}
<div class="txt-semibold">Commits</div>
{/snippet}
{#snippet children()}
<div style:padding="0 1rem">
<div
class="global-flex txt-small"
style:color="var(--color-foreground-dim)">
<Icon name="branch" /><Id id={revision.base} variant="commit" />
<div class="global-counter">base</div>
</div>
<div class="commits">
{#each commits.reverse() as commit}
<div class="commit" style:position="relative">
<div class="commit-dot"></div>
<CobCommitTeaser {commit} />
</div>
{/each}
</div>
</div>
{/snippet}
</CommitsContainer>
</div>
{/await}
{#await loadHighlightedDiff(rid, revision.base, revision.head)}
<span class="txt-small">Loading…</span>
{:then diff}
+9
View File
@@ -6,6 +6,7 @@ import type { Patch } from "@bindings/cob/patch/Patch";
import bs58 from "bs58";
import twemojiModule from "twemoji";
import md5 from "md5";
import NodeId from "@app/components/NodeId.svelte";
@@ -211,3 +212,11 @@ export function parseNodeId(
return undefined;
}
// Get the gravatar URL of an email.
export function gravatarURL(email: string): string {
const address = email.trim().toLowerCase();
const hash = md5(address);
return `https://www.gravatar.com/avatar/${hash}`;
}