Improve patch timeline and add description

Signed-off-by: Sebastian Martinez <me@sebastinez.dev>
This commit is contained in:
Sebastian Martinez
2023-05-10 09:15:19 +02:00
parent bb51de6a9a
commit d8d049518b
23 changed files with 686 additions and 380 deletions
+16 -2
View File
@@ -1,5 +1,11 @@
import type { BaseUrl } from "./lib/fetcher.js";
import type { Blob, Project, Remote, Tree } from "./lib/project.js";
import type {
Blob,
Project,
Remote,
Tree,
DiffResponse,
} from "./lib/project.js";
import type { Comment } from "./lib/project/comment.js";
import type {
Commit,
@@ -9,7 +15,13 @@ import type {
HunkLine,
} from "./lib/project/commit.js";
import type { Issue, IssueState } from "./lib/project/issue.js";
import type { Merge, Patch, PatchState, Review } from "./lib/project/patch.js";
import type {
Merge,
Patch,
PatchState,
Review,
Revision,
} from "./lib/project/patch.js";
import type { RequestOptions, Method } from "./lib/fetcher.js";
import type { ZodSchema } from "zod";
@@ -27,6 +39,7 @@ export type {
CommitHeader,
Diff,
DiffAddedDeletedModifiedChangeset,
DiffResponse,
HunkLine,
Issue,
IssueState,
@@ -36,6 +49,7 @@ export type {
Project,
Remote,
Review,
Revision,
Tree,
};
+1 -1
View File
@@ -153,7 +153,7 @@ const remoteSchema = strictObject({
const remotesSchema = array(remoteSchema) satisfies ZodSchema<Remote[]>;
interface DiffResponse {
export interface DiffResponse {
commits: CommitHeader[];
diff: Diff;
}
+1 -1
View File
@@ -85,7 +85,7 @@ const reviewSchema = strictObject({
timestamp: number(),
}) satisfies ZodSchema<Review>;
interface Revision {
export interface Revision {
id: string;
description: string;
base: string;
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/bash
set -e
REV=414477a31676d5e75efa7f3e8dc6624bcf2b2e52
REV=b29321dbf7999ec7ec9b7ac9192071e512ada407
REPO_ROOT=$(git rev-parse --show-toplevel)
FIXTURE=$REPO_ROOT/tests/fixtures/seeds/palm.tar.bz2
+11 -9
View File
@@ -15,7 +15,7 @@
.authorship {
display: inline-flex;
align-items: center;
color: var(--color-foreground-6);
color: inherit;
padding: 0.125rem 0;
gap: 0.25rem;
}
@@ -29,7 +29,7 @@
}
</style>
<span class="authorship txt-tiny" title={relativeTimestamp(timestamp)}>
<span class="authorship txt-tiny">
{#if !noAvatar}
<Avatar inline nodeId={authorId} />
{/if}
@@ -39,14 +39,16 @@
<span class="id layout-mobile">
{formatNodeId(authorId).replace("did:key:", "")}
</span>
<span class="body">
{#if !caption}
<slot />
{:else}
{#if !caption}
<slot />
{:else}
<span class="body">
{caption}
{/if}
</span>
</span>
{/if}
{#if timestamp}
{formatTimestamp(timestamp)}
<span title={relativeTimestamp(timestamp)}>
{formatTimestamp(timestamp)}
</span>
{/if}
</span>
+3 -9
View File
@@ -20,13 +20,12 @@
<style>
.comment {
margin-bottom: 1rem;
display: flex;
}
.card {
flex: 1;
border: 1px solid var(--color-foreground-4);
border-radius: var(--border-radius);
background-color: var(--color-foreground-1);
}
.card-header {
display: flex;
@@ -37,7 +36,7 @@
}
.card-body {
font-size: var(--font-size-small);
padding: 0rem 1rem 1rem 1rem;
padding: 0 1rem 1rem 1rem;
}
.actions {
display: flex;
@@ -52,12 +51,7 @@
<div class="comment" {id}>
<div class="card">
<div class="card-header">
<div class="layout-desktop">
<Authorship {caption} {authorId} {timestamp} />
</div>
<div class="layout-mobile">
<Authorship {authorId} {timestamp} />
</div>
<Authorship {caption} {authorId} {timestamp} />
<div class="actions">
{#if showReplyIcon}
<Button
+1 -1
View File
@@ -57,7 +57,7 @@
on:click={() => onSelect(item)}
title={item.title}>
<slot name="item" {item}>
{item.value}
{item.title}
{#if item.badge}
<Badge variant="primary">{item.badge}</Badge>
{/if}
+3
View File
@@ -8,11 +8,14 @@
<style>
.error {
padding: 1rem;
font-size: var(--font-size-regular);
font-family: var(--font-family-sans-serif);
color: var(--color-negative);
border-radius: var(--border-radius);
background-color: var(--color-negative-3);
display: flex;
align-items: center;
width: 100%;
}
.stack-trace {
display: flex;
+3 -2
View File
@@ -6,7 +6,7 @@
import { twemoji } from "@app/lib/utils";
export let content: string;
export let fontSize: "small" | "medium" = "small";
export let fontSize: "tiny" | "small" | "medium" = "small";
marked.use({
renderer,
@@ -34,6 +34,7 @@
class="markdown"
use:twemoji
class:txt-medium={fontSize === "medium"}
class:txt-small={fontSize === "small"}>
class:txt-small={fontSize === "small"}
class:txt-tiny={fontSize === "tiny"}>
{@html render(content)}
</span>
+4
View File
@@ -280,6 +280,10 @@
.markdown :global(.list-content) {
margin: 1rem 0;
}
/* Allows the parent to specify its own bottom margin */
.markdown :global(:last-child) {
margin-bottom: 0;
}
.markdown :global(li > ul) {
margin-bottom: 0rem;
}
-6
View File
@@ -14,8 +14,6 @@
export let disabled: boolean = false;
export let loading: boolean = false;
export let valid: boolean = false;
// Changes the background color to the background of the page
export let transparent: boolean = false;
export let validationMessage: string | undefined = undefined;
const dispatch = createEventDispatcher<{
@@ -74,9 +72,6 @@
color: var(--color-secondary);
cursor: not-allowed;
}
.transparent {
background: var(--color-background) !important;
}
.regular {
border: 1px solid var(--color-secondary);
padding: 1rem 1.5rem;
@@ -148,7 +143,6 @@
</div>
<input
class:transparent
class:regular={variant === "regular"}
class:form={variant === "form"}
style:padding-left={leftContainerWidth
+12 -10
View File
@@ -1,4 +1,4 @@
<script lang="ts">
<script lang="ts" strictEvents>
import type { Comment } from "@httpd-client";
import Button from "@app/components/Button.svelte";
@@ -10,7 +10,6 @@
export let thread: { root: Comment; replies: Comment[] };
export let rawPath: string;
export let isDescription = false;
export let showReplyTextarea = false;
let replyText = "";
@@ -53,6 +52,7 @@
<style>
.reply {
margin-left: 3rem;
margin-top: 1rem;
}
.actions {
display: flex;
@@ -62,14 +62,16 @@
}
</style>
<CommentComponent
{rawPath}
id={root.id}
authorId={root.author.id}
timestamp={root.timestamp}
body={root.body}
showReplyIcon={Boolean($sessionStore) && !isDescription}
on:toggleReply={toggleReply} />
<div style:margin-top="1rem">
<CommentComponent
{rawPath}
id={root.id}
authorId={root.author.id}
timestamp={root.timestamp}
body={root.body}
showReplyIcon={Boolean($sessionStore)}
on:toggleReply={toggleReply} />
</div>
{#each replies as reply}
<div class="reply">
<CommentComponent
+10 -13
View File
@@ -11,7 +11,7 @@
const dispatch = createEventDispatcher<{ save: string[] }>();
export let action: "create" | "edit" | "view";
export let edit: boolean = false;
export let editInProgress: boolean = false;
export let assignees: string[] = [];
let updatedAssignees: string[] = assignees;
@@ -45,9 +45,6 @@
</script>
<style>
.metadata-section {
margin-bottom: 4rem;
}
.header {
display: flex;
gap: 1rem;
@@ -75,28 +72,28 @@
}
</style>
<div class="metadata-section">
<div>
<div class="header">
<span>Assignees</span>
{#if action === "edit"}
{#if !edit}
{#if editInProgress}
<Button
size="tiny"
variant="text"
on:click={() => {
edit = !edit;
dispatch("save", updatedAssignees);
editInProgress = !editInProgress;
}}>
edit
save
</Button>
{:else}
<Button
size="tiny"
variant="text"
on:click={() => {
dispatch("save", updatedAssignees);
edit = !edit;
editInProgress = !editInProgress;
}}>
save
edit
</Button>
{/if}
{/if}
@@ -105,7 +102,7 @@
{#each updatedAssignees as assignee, key (assignee)}
<Chip
on:remove={removeAssignee}
removeable={edit || action === "create"}
removeable={editInProgress || action === "create"}
{key}>
<div class="chip-content">
<Avatar inline nodeId={assignee} />
@@ -116,7 +113,7 @@
<div class="empty">No assignees</div>
{/each}
</div>
{#if edit || action === "create"}
{#if editInProgress || action === "create"}
<div style:margin-bottom="1rem">
<TextInput
bind:value={inputValue}
+48 -57
View File
@@ -1,8 +1,7 @@
<script lang="ts" strictEvents>
import { createEventDispatcher } from "svelte";
import { formatObjectId } from "@app/lib/utils";
import * as utils from "@app/lib/utils";
import Button from "@app/components/Button.svelte";
import Clipboard from "@app/components/Clipboard.svelte";
import InlineMarkdown from "@app/components/InlineMarkdown.svelte";
@@ -19,76 +18,58 @@
<style>
header {
background: var(--color-foreground-1);
display: flex;
flex-direction: column;
gap: 0.3rem;
border-radius: var(--border-radius);
margin-bottom: 1rem;
border: 1px solid var(--color-foreground-3);
padding: 1rem;
}
.summary {
display: flex;
flex-direction: row;
gap: 1rem;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
padding-right: 0.5rem;
}
.summary-title {
display: flex;
flex-direction: row;
align-items: baseline;
gap: 0.5rem;
width: 90%;
}
.id {
display: flex;
flex-direction: row;
align-items: center;
font-size: var(--font-size-tiny);
font-family: var(--font-family-monospace);
color: var(--color-foreground-6);
}
.title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.summary-state {
.subtitle {
display: flex;
flex-direction: row;
gap: 1rem;
border-radius: var(--border-radius);
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
font-size: var(--font-size-tiny);
font-family: var(--font-family-monospace);
color: var(--color-foreground-6);
}
.summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.id {
display: flex;
align-items: center;
}
.description {
font-size: var(--font-size-small);
margin-top: 1rem;
}
</style>
<header>
<div class="summary">
<div class="summary-title txt-medium">
{#if editable}
<TextInput transparent variant="form" bind:value={title} />
{:else}
{#if title}
<div class="title">
<InlineMarkdown fontSize="medium" content={title} />
</div>
{:else}
<span class="txt-missing">No title</span>
{/if}
<slot name="revision" />
{#if id}
<div class="id">
<div class="layout-desktop">{id}</div>
<div class="layout-mobile">
{formatObjectId(id)}
</div>
<Clipboard small text={id} />
</div>
{/if}
{/if}
</div>
<div class="summary txt-medium">
{#if editable}
<TextInput variant="form" placeholder="Title" bind:value={title} />
{:else if title}
<div class="title">
<InlineMarkdown fontSize="medium" content={title} />
</div>
{:else}
<span class="txt-missing">No title</span>
{/if}
{#if action === "edit"}
<Button
variant="text"
variant="foreground"
size="small"
on:click={() => {
editable = !editable;
@@ -102,7 +83,17 @@
</Button>
{/if}
</div>
<div class="summary-state">
<div class="subtitle">
<slot name="state" />
{#if id}
<div class="id">
{utils.formatObjectId(id)}
<Clipboard text={id} small />
</div>
{/if}
<slot name="author" />
</div>
<div class="description">
<slot name="description" />
</div>
</header>
+263
View File
@@ -0,0 +1,263 @@
<script lang="ts">
import type { BaseUrl, DiffResponse } from "@httpd-client";
import type { Timeline } from "@app/views/projects/Patch.svelte";
import * as utils from "@app/lib/utils";
import { onMount } from "svelte";
import { HttpdClient } from "@httpd-client";
import Authorship from "@app/components/Authorship.svelte";
import Avatar from "@app/components/Avatar.svelte";
import Clipboard from "@app/components/Clipboard.svelte";
import CommentComponent from "@app/components/Comment.svelte";
import DiffStatBadge from "@app/components/DiffStatBadge.svelte";
import ErrorMessage from "@app/components/ErrorMessage.svelte";
import Icon from "@app/components/Icon.svelte";
import InlineMarkdown from "@app/components/InlineMarkdown.svelte";
import ProjectLink from "@app/components/ProjectLink.svelte";
import ThreadComponent from "@app/components/Thread.svelte";
export let authorId: string;
export let baseUrl: BaseUrl;
export let expanded: boolean = true;
export let patchId: string;
export let projectHead: string;
export let projectId: string;
export let revisionBase: string;
export let revisionId: string;
export let revisionOid: string;
export let revisionTimestamp: number;
export let timelines: Timeline[];
const api = new HttpdClient(baseUrl);
function formatVerdict(revision: string, verdict?: string | null) {
switch (verdict) {
case "accept":
return `accepted revision ${utils.formatObjectId(revision)}`;
case "reject":
return `rejected revision ${utils.formatObjectId(revision)}`;
default:
return `left a comment on revision ${utils.formatObjectId(revision)}`;
}
}
let response: DiffResponse | undefined = undefined;
let error: any | undefined = undefined;
onMount(async () => {
try {
response = await api.project.getDiff(
projectId,
revisionBase,
revisionOid,
);
} catch (err: any) {
error = err;
}
});
</script>
<style>
.action {
background-color: var(--color-foreground-1);
border-radius: var(--border-radius);
padding: 0.5rem 1rem;
}
.action-content {
display: flex;
align-items: center;
justify-content: space-between;
}
.merge {
color: var(--color-primary);
background-color: var(--color-primary-3);
}
.positive-review {
color: var(--color-positive);
background-color: var(--color-positive-3);
}
.revision {
border: 1px solid var(--color-foreground-3);
border-radius: var(--border-radius-small);
margin-bottom: 1rem;
}
.revision-header {
height: 3rem;
display: flex;
justify-content: space-between;
align-items: center;
background: none;
padding: 1rem;
padding-right: 1.5rem;
}
.revision-name {
display: flex;
user-select: none;
}
.revision-data {
gap: 0.5rem;
display: flex;
}
.revision-body {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0 1.5rem;
margin-bottom: 1rem;
border-radius: var(--border-radius-small);
}
.expand-button {
margin-right: 0.5rem;
user-select: none;
cursor: pointer;
}
.commit-event {
color: var(--color-foreground-6);
padding: 0.5rem 0.5rem 0.5rem 1rem;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.commit-event span {
display: flex;
gap: 0.5rem;
align-items: center;
}
</style>
<div class="revision">
<div class="revision-header">
<div class="revision-name">
<div class="expand-button">
<Icon
name={expanded ? "chevron-down" : "chevron-right"}
on:click={() => (expanded = !expanded)} />
</div>
<span>
Revision {utils.formatObjectId(revisionId)}
</span>
<Clipboard text={revisionId} small />
</div>
<div class="txt-small" />
<div class="revision-data">
{#if response?.diff.stats}
{@const { insertions, deletions } = response.diff.stats}
<DiffStatBadge {insertions} {deletions} />
{/if}
<span class="layout-desktop txt-small">
{utils.formatTimestamp(revisionTimestamp)}
</span>
</div>
</div>
{#if expanded}
<div class="revision-body">
{#each timelines as element}
{#if element.type === "thread"}
<ThreadComponent
rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
thread={element.inner}
on:reply />
{:else if element.type === "revision"}
{@const caption =
patchId === element.inner.id
? "opened this patch"
: `updated to ${utils.formatObjectId(element.inner.id)}`}
{#if element.inner.description}
<CommentComponent
{caption}
{authorId}
timestamp={element.timestamp}
rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
body={element.inner.description} />
{:else}
<div class="action txt-tiny">
<Authorship {authorId} timestamp={element.timestamp}>
{caption}
</Authorship>
</div>
{/if}
{#if response?.commits}
<div class="action txt-tiny">
{#each response.commits as commit}
<div class="commit-event">
<span>
<Avatar inline nodeId={authorId} />
<ProjectLink
projectParams={{
view: { resource: "commits" },
revision: commit.id,
search: undefined,
}}>
<InlineMarkdown
content={commit.summary}
fontSize="tiny" />
</ProjectLink>
</span>
<span>
{utils.formatCommit(commit.id)}
</span>
</div>
{/each}
</div>
{/if}
{#if error}
<div class="txt-monospace">
<ErrorMessage
message="Failed to load diff for this revision."
stackTrace={error.stack.toString()} />
</div>
{/if}
{:else if element.type === "merge"}
<div class="action merge layout-desktop txt-tiny">
<div class="action-content">
<Authorship
authorId={element.inner.node}
timestamp={element.timestamp}>
merged
{utils.formatCommit(element.inner.commit)}
</Authorship>
</div>
</div>
<div class="action merge layout-mobile txt-tiny">
<div class="action-content">
<Authorship authorId={element.inner.node}>
merged
{utils.formatCommit(element.inner.commit)}
</Authorship>
</div>
</div>
{:else if element.type === "review"}
{@const [revisionId, author, review] = element.inner}
<div
class="action layout-desktop txt-tiny"
class:positive-review={element.inner[2].verdict === "accept"}>
<div class="action-content">
<Authorship authorId={author} timestamp={element.timestamp}>
{formatVerdict(revisionId, review.verdict)}
</Authorship>
</div>
</div>
<div
class="layout-mobile txt-tiny"
class:positive-review={element.inner[2].verdict === "accept"}>
<div class="action-content">
<Authorship authorId={author}>
{formatVerdict(revisionId, review.verdict)}
</Authorship>
</div>
</div>
{#if review.comment}
<CommentComponent
caption="left a comment"
authorId={author}
timestamp={review.timestamp}
rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
body={review.comment} />
{/if}
{/if}
{/each}
</div>
{/if}
</div>
+10 -13
View File
@@ -8,7 +8,7 @@
const dispatch = createEventDispatcher<{ save: string[] }>();
export let action: "create" | "edit" | "view" = "view";
export let edit: boolean = false;
export let editInProgress: boolean = false;
export let tags: string[] = [];
let updatedTags: string[] = tags;
@@ -42,9 +42,6 @@
</script>
<style>
.metadata-section {
margin-bottom: 4rem;
}
.metadata-section-header {
display: flex;
gap: 1rem;
@@ -70,28 +67,28 @@
}
</style>
<div class="metadata-section">
<div>
<div class="metadata-section-header">
<span>Tags</span>
{#if action === "edit"}
{#if !edit}
{#if editInProgress}
<Button
size="tiny"
variant="text"
on:click={() => {
edit = !edit;
dispatch("save", updatedTags);
editInProgress = !editInProgress;
}}>
edit
save
</Button>
{:else}
<Button
size="tiny"
variant="text"
on:click={() => {
dispatch("save", updatedTags);
edit = !edit;
editInProgress = !editInProgress;
}}>
save
edit
</Button>
{/if}
{/if}
@@ -100,7 +97,7 @@
{#each updatedTags as tag, key (tag)}
<Chip
on:remove={removeTag}
removeable={edit || action === "create"}
removeable={editInProgress || action === "create"}
{key}>
<div class="tag">{tag}</div>
</Chip>
@@ -108,7 +105,7 @@
<div class="metadata-section-empty">No tags</div>
{/each}
</div>
{#if edit || action === "create"}
{#if editInProgress || action === "create"}
<div style:margin-bottom="1rem">
<TextInput
bind:value={inputValue}
@@ -47,7 +47,6 @@
}
.browse {
display: flex;
z-index: 10;
width: 100%;
height: 100%;
}
+53 -36
View File
@@ -14,6 +14,7 @@
import Button from "@app/components/Button.svelte";
import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
import CobStateButton from "@app/views/projects/Cob/CobStateButton.svelte";
import Markdown from "@app/components/Markdown.svelte";
import TagInput from "./Cob/TagInput.svelte";
import Textarea from "@app/components/Textarea.svelte";
import Thread from "@app/components/Thread.svelte";
@@ -147,6 +148,7 @@
$: selectedItem = issue.state.status === "closed" ? items[0] : items[1];
$: threads = issue.discussion
.slice(1) // Skip the first comment, which is the issue description
.filter(comment => !comment.replyTo)
.map(thread => {
return {
@@ -168,6 +170,9 @@
margin-bottom: 4.5rem;
}
.metadata {
display: flex;
flex-direction: column;
gap: 2rem;
border-radius: var(--border-radius);
font-size: var(--font-size-small);
padding-left: 1rem;
@@ -181,6 +186,15 @@
margin: 0 0 2.5rem 0;
gap: 1rem;
}
.author {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 0.5rem;
}
.comments {
margin: 1rem 0;
}
@media (max-width: 960px) {
.issue {
@@ -212,47 +226,50 @@
{issue.state.reason}
</Badge>
{/if}
<Authorship
timestamp={issue.discussion[0].timestamp}
authorId={issue.author.id}
caption="opened this issue" />
</svelte:fragment>
<div slot="description">
<Markdown
content={issue.discussion[0].body}
rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)} />
</div>
<div class="author" slot="author">
opened by <Authorship authorId={issue.author.id} />
{utils.formatTimestamp(issue.discussion[0].timestamp)}
</div>
</CobHeader>
<div>
{#each threads as thread, index (thread.root.id)}
<Thread
{thread}
{rawPath}
isDescription={index === 0}
on:reply={createReply} />
<div class="comments">
{#each threads as thread (thread.root.id)}
<Thread {thread} {rawPath} on:reply={createReply} />
{/each}
{#if $sessionStore}
<Textarea
resizable
on:submit={() => {
createComment(commentBody);
commentBody = "";
}}
bind:value={commentBody}
placeholder="Leave your comment" />
<div class="actions txt-small">
<CobStateButton
{items}
{selectedItem}
state={issue.state}
on:saveStatus={saveStatus} />
<Button
variant="secondary"
size="small"
disabled={!commentBody}
on:click={() => {
<div style:margin-top="1rem">
{#if $sessionStore}
<Textarea
resizable
on:submit={() => {
createComment(commentBody);
commentBody = "";
}}>
Comment
</Button>
</div>
{/if}
}}
bind:value={commentBody}
placeholder="Leave your comment" />
<div class="actions txt-small">
<CobStateButton
{items}
{selectedItem}
state={issue.state}
on:saveStatus={saveStatus} />
<Button
variant="secondary"
size="small"
disabled={!commentBody}
on:click={() => {
createComment(commentBody);
commentBody = "";
}}>
Comment
</Button>
</div>
{/if}
</div>
</div>
</div>
<div class="metadata">
+43 -17
View File
@@ -15,8 +15,9 @@
import Badge from "@app/components/Badge.svelte";
import Button from "@app/components/Button.svelte";
import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
import Comment from "@app/components/Comment.svelte";
import Markdown from "@app/components/Markdown.svelte";
import TagInput from "@app/views/projects/Cob/TagInput.svelte";
import Textarea from "@app/components/Textarea.svelte";
export let session: StoredSession;
export let projectId: string;
@@ -32,7 +33,7 @@
: "view";
let issueTitle = "";
let issueText = "";
let issueText: string | undefined = undefined;
let assignees: string[] = [];
let tags: string[] = [];
@@ -44,7 +45,7 @@
projectId,
{
title: issueTitle,
description: issueText,
description: issueText ?? "",
assignees: utils.stripDidPrefix(assignees),
tags: tags,
},
@@ -82,6 +83,9 @@
margin-top: 1rem;
}
.metadata {
display: flex;
flex-direction: column;
gap: 4rem;
border-radius: var(--border-radius);
font-size: var(--font-size-small);
padding-left: 1rem;
@@ -91,6 +95,11 @@
flex: 2;
padding-right: 1rem;
}
.author {
display: flex;
align-items: center;
gap: 0.5rem;
}
@media (max-width: 960px) {
main {
padding-left: 2rem;
@@ -100,6 +109,14 @@
.form {
grid-template-columns: minmax(0, 1fr);
}
.editor {
padding-right: 0;
}
.metadata {
margin-left: 0;
padding-left: 0;
gap: 2rem;
}
}
</style>
@@ -108,22 +125,31 @@
<div class="editor">
<CobHeader {action} bind:title={issueTitle}>
<svelte:fragment slot="state">
<Badge variant="positive">open</Badge>
<Authorship
timestamp={Date.now()}
authorId={session.publicKey}
caption="opened this issue" />
{#if action === "view"}
<Badge variant="positive">open</Badge>
{/if}
</svelte:fragment>
<svelte:fragment slot="description">
{#if action === "create"}
<Textarea
resizable
bind:value={issueText}
on:submit={createIssue}
placeholder="Write a description" />
{:else if !issueText}
<p class="txt-missing">No description</p>
{:else}
<Markdown
content={issueText}
rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)} />
{/if}
</svelte:fragment>
<div class="author" slot="author">
{#if action === "view"}
opened by <Authorship authorId={session.publicKey} /> now
{/if}
</div>
</CobHeader>
<div class="comments">
<Comment
bind:body={issueText}
on:submit={createIssue}
authorId={session.publicKey}
timestamp={Date.now()}
{action}
rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)} />
</div>
<div class="actions">
<Button
size="small"
+191 -191
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import type { BaseUrl, Comment, Merge, Patch, Review } from "@httpd-client";
import type { Variant } from "@app/components/Badge.svelte";
<script lang="ts" context="module">
import type { Comment, Review, Revision, Merge } from "@httpd-client";
interface Thread {
root: Comment;
@@ -8,11 +7,17 @@
}
interface TimelineReview {
inner: [string, Review];
inner: [string, string, Review];
type: "review";
timestamp: number;
}
interface TimelineRevision {
inner: Revision;
type: "revision";
timestamp: number;
}
interface TimelineMerge {
inner: Merge;
type: "merge";
@@ -25,32 +30,44 @@
timestamp: number;
}
import capitalize from "lodash/capitalize";
export type Timeline =
| TimelineMerge
| TimelineReview
| TimelineRevision
| TimelineThread;
</script>
<script lang="ts">
import type { BaseUrl, Patch } from "@httpd-client";
import type { Variant } from "@app/components/Badge.svelte";
import * as router from "@app/lib/router";
import * as utils from "@app/lib/utils";
import * as router from "@app/lib/router";
import { capitalize } from "lodash";
import { HttpdClient } from "@httpd-client";
import { sessionStore } from "@app/lib/session";
import Authorship from "@app/components/Authorship.svelte";
import Badge from "@app/components/Badge.svelte";
import Changeset from "./SourceBrowser/Changeset.svelte";
import Changeset from "@app/views/projects/SourceBrowser/Changeset.svelte";
import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
import CommentComponent from "@app/components/Comment.svelte";
import CommitTeaser from "./Commit/CommitTeaser.svelte";
import CommitTeaser from "@app/views/projects/Commit/CommitTeaser.svelte";
import Dropdown from "@app/components/Dropdown.svelte";
import ErrorMessage from "@app/components/ErrorMessage.svelte";
import Floating from "@app/components/Floating.svelte";
import Markdown from "@app/components/Markdown.svelte";
import Placeholder from "@app/components/Placeholder.svelte";
import ProjectLink from "@app/components/ProjectLink.svelte";
import RevisionComponent from "@app/views/projects/Cob/Revision.svelte";
import SquareButton from "@app/components/SquareButton.svelte";
import TagInput from "./Cob/TagInput.svelte";
import ThreadComponent from "@app/components/Thread.svelte";
import TagInput from "@app/views/projects/Cob/TagInput.svelte";
export let projectId: string;
export let baseUrl: BaseUrl;
export let patch: Patch;
export let projectHead: string;
export let revision: string | undefined = undefined;
export let currentTab: "activity" | "commits";
export let revision: string;
export let currentTab: "activity" | "commits" | "files";
const api = new HttpdClient(baseUrl);
@@ -75,6 +92,19 @@
patch = await api.project.getPatchById(projectId, patch.id);
}
}
function badgeColor(status: string): 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";
}
}
async function saveTags({ detail: tags }: CustomEvent<string[]>) {
if ($sessionStore) {
@@ -92,80 +122,63 @@
}
}
function formatVerdict(verdict?: string | null) {
switch (verdict) {
case "accept":
return "accepted this revision";
case "reject":
return "rejected this revision";
default:
return "left a comment";
}
}
const action: "create" | "edit" | "view" =
$sessionStore && utils.isLocal(baseUrl.hostname) ? "edit" : "view";
// Reactive due to eventual changes in patch.revisions
$: enumeratedRevisions = patch.revisions.map((r, i) => [r, i] as const);
$: currentRevisionTuple =
enumeratedRevisions.find(([rev]) => rev.id === revision) ||
enumeratedRevisions[enumeratedRevisions.length - 1];
$: [currentRevision, currentRevisionIndex] = currentRevisionTuple;
$: options = ["activity", "commits", "files"].map(o => ({
const options = ["activity", "commits", "files"].map(o => ({
value: o,
title: capitalize(o),
disabled: false,
}));
$: reviews = currentRevision.reviews.map<TimelineReview>(
([author, review]) => ({
timestamp: review.timestamp,
type: "review",
inner: [author, review],
}),
);
$: merges = currentRevision.merges.map<TimelineMerge>(inner => ({
timestamp: inner.timestamp,
type: "merge",
inner,
}));
$: threads = currentRevision.discussions
.filter(comment => !comment.replyTo)
.map<TimelineThread>(
thread => ({
timestamp: thread.timestamp,
type: "thread",
inner: {
root: thread,
replies: currentRevision.discussions
.filter(comment => comment.replyTo === thread.id)
.sort((a, b) => a.timestamp - b.timestamp),
},
}),
[],
);
$: timeline = [...reviews, ...merges, ...threads].sort(
(a, b) => a.timestamp - b.timestamp,
);
$: diffPromise = api.project.getDiff(
projectId,
currentRevision.base,
currentRevision.oid,
);
function badgeColor(status: string): 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";
}
}
const currentRevision =
patch.revisions.find(r => r.id === revision) || patch.revisions[0];
$: timelineTuple = patch.revisions.map<
[
{
revisionId: string;
revisionTimestamp: number;
revisionBase: string;
revisionOid: string;
},
Timeline[],
]
>(rev => [
{
revisionId: rev.id,
revisionTimestamp: rev.timestamp,
revisionBase: rev.base,
revisionOid: rev.oid,
},
[
...rev.reviews.map<TimelineReview>(([author, review]) => ({
timestamp: review.timestamp,
type: "review",
inner: [rev.id, author, review],
})),
...rev.merges.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),
},
})),
{
type: "revision",
timestamp: rev.timestamp,
inner: rev,
} as TimelineRevision,
].sort((a, b) => a.timestamp - b.timestamp),
]);
</script>
<style>
@@ -176,20 +189,32 @@
margin-bottom: 4.5rem;
}
.metadata {
display: flex;
flex-direction: column;
gap: 2rem;
border-radius: var(--border-radius);
font-size: var(--font-size-small);
padding-left: 1rem;
margin-left: 1rem;
}
.action {
margin: 1rem;
color: var(--color-foreground-5);
}
.commit-list {
border-radius: var(--border-radius);
overflow: hidden;
margin-top: 1rem;
}
.tab-line {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin: 1rem 0;
}
.author {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 0.5rem;
}
@media (max-width: 1092px) {
.patch {
@@ -210,25 +235,78 @@
<div class="patch">
<div>
<CobHeader id={patch.id} title={patch.title}>
<span slot="revision" class="txt-monospace txt-tiny">
<svelte:fragment slot="state">
<Badge variant={badgeColor(patch.state.status)}>
{patch.state.status}
</Badge>
</svelte:fragment>
<svelte:fragment slot="description">
{#if patch.description}
<Markdown
content={patch.description}
rawPath={utils.getRawBasePath(
projectId,
baseUrl,
currentRevision.oid,
)} />
{:else}
<span class="txt-missing">No description available</span>
{/if}
</svelte:fragment>
<div class="author" slot="author">
opened by <Authorship authorId={patch.author.id} />
{utils.formatTimestamp(patch.revisions[0].timestamp)}
</div>
</CobHeader>
<div class="tab-line">
<div style="display: flex; gap: 0.5rem;">
{#each options as option}
{#if !option.disabled}
<ProjectLink
projectParams={{
search: `tab=${option.value}`,
}}>
<SquareButton
size="small"
clickable={option.disabled}
active={option.value === currentTab}
disabled={option.disabled}>
{option.title}
</SquareButton>
</ProjectLink>
{:else}
<SquareButton
size="small"
clickable={option.disabled}
active={option.value === currentTab}
disabled={option.disabled}>
{option.title}
</SquareButton>
{/if}
{/each}
</div>
{#if currentTab !== "activity"}
<Floating disabled={patch.revisions.length === 1}>
<svelte:fragment slot="toggle">
<SquareButton
size="small"
clickable={patch.revisions.length > 1}
disabled={patch.revisions.length === 1}>
Revision {currentRevisionIndex}
Revision {utils.formatObjectId(currentRevision.id)}
</SquareButton>
</svelte:fragment>
<svelte:fragment slot="modal">
<Dropdown
items={enumeratedRevisions.map(([r, i]) => {
items={patch.revisions.map(r => {
return {
title: `Revision ${i} (${utils.formatObjectId(r.id)})`,
title: `Revision ${utils.formatObjectId(r.id)}`,
value: r.id,
badge: null,
};
})}
selected={currentRevision.toString()}
selected={currentRevision.id}
on:select={({ detail: item }) => {
router.updateProjectRoute({
view: {
@@ -243,121 +321,43 @@
</Dropdown>
</svelte:fragment>
</Floating>
</span>
<svelte:fragment slot="state">
<Badge variant={badgeColor(patch.state.status)}>
{patch.state.status}
</Badge>
<div class="layout-desktop">
<Authorship
timestamp={patch.revisions[0].timestamp}
authorId={patch.author.id}
caption="opened this patch" />
</div>
<div class="layout-mobile">
<Authorship authorId={patch.author.id} />
</div>
</svelte:fragment>
</CobHeader>
<div style="display: flex; gap: 0.5rem;">
{#each options as option}
{#if !option.disabled}
<ProjectLink
projectParams={{
search: `tab=${option.value}`,
}}>
<SquareButton
size="small"
clickable={option.disabled}
active={option.value === currentTab}
disabled={option.disabled}>
{option.title}
</SquareButton>
</ProjectLink>
{:else}
<SquareButton
size="small"
clickable={option.disabled}
active={option.value === currentTab}
disabled={option.disabled}>
{option.title}
</SquareButton>
{/if}
{/each}
{/if}
</div>
{#if currentTab === "activity"}
<div style:margin-top="1rem">
<div class="txt-tiny">
<CommentComponent
caption="created this revision"
authorId={patch.author.id}
timestamp={currentRevision.timestamp}
rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
body={currentRevisionIndex === 0
? patch.description
: currentRevision.description} />
</div>
{#each timeline as element}
{#if element.type === "thread"}
<!-- TODO: Implement reply creation and comment editing -->
<ThreadComponent
rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
isDescription={false}
thread={element.inner}
on:reply={createReply}
on:select={({ detail: index }) => (currentRevision = index)} />
{:else if element.type === "merge"}
<div class="action layout-desktop txt-tiny">
<Authorship
authorId={element.inner.node}
timestamp={element.timestamp}>
merged
{utils.formatCommit(element.inner.commit)}
</Authorship>
</div>
<div class="action layout-mobile txt-tiny">
<Authorship authorId={element.inner.node}>
merged
{utils.formatCommit(element.inner.commit)}
</Authorship>
</div>
{:else if element.type === "review"}
<!-- TODO: Implement inline code comments -->
{@const [author, review] = element.inner}
<div class="action layout-desktop txt-tiny">
<Authorship authorId={author} timestamp={element.timestamp}>
{formatVerdict(review.verdict)}
</Authorship>
</div>
<div class="action layout-mobile txt-tiny">
<Authorship authorId={author}>
{formatVerdict(review.verdict)}
</Authorship>
</div>
{#if review.comment}
<CommentComponent
caption="left a comment"
authorId={author}
timestamp={review.timestamp}
rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
body={review.comment} />
{/if}
{/if}
{/each}
</div>
{#each timelineTuple as [revision, timelines], index}
<RevisionComponent
{baseUrl}
{projectId}
{timelines}
{projectHead}
{...revision}
on:reply={createReply}
patchId={patch.id}
authorId={patch.author.id}
expanded={index === patch.revisions.length - 1} />
{:else}
<Placeholder emoji="🍂">
<div slot="title">No activity</div>
<div slot="body">No activity on this patch yet</div>
</Placeholder>
{/each}
{:else if currentTab === "commits"}
{#await diffPromise then diff}
{#await api.project.getDiff(projectId, currentRevision.base, currentRevision.oid) then diff}
<div class="commit-list">
{#each diff.commits as commit}
<CommitTeaser {commit} />
{/each}
</div>
{:catch e}
<ErrorMessage message="Not able to load commits." stackTrace={e} />
{/await}
{:else if currentTab === "files"}
{#await diffPromise then diff}
{#await api.project.getDiff(projectId, currentRevision.base, currentRevision.oid) then diff}
<div style:margin-top="1rem">
<Changeset revision={currentRevision.oid} diff={diff.diff} />
</div>
{:catch e}
<ErrorMessage message="Not able to load files diff." stackTrace={e} />
{/await}
{/if}
</div>
+6 -4
View File
@@ -34,7 +34,7 @@
$: searchParams = new URLSearchParams(activeRoute.params.search || "");
$: issueFilter = (searchParams.get("state") as IssueStatus) || "open";
$: patchTabFilter =
(searchParams.get("tab") as "activity" | "commits") || "activity";
(searchParams.get("tab") as "activity" | "commits" | "files") || "activity";
$: patchFilter = (searchParams.get("state") as PatchStatus) || "open";
$: baseUrl = utils.extractBaseUrl(activeRoute.params.hostnamePort);
$: api = new HttpdClient(baseUrl);
@@ -254,13 +254,15 @@
{#await api.project.getPatchById(project.id, activeRoute.params.view.params.patch)}
<Loading center />
{:then patch}
{@const latestRevision = patch.revisions[patch.revisions.length - 1]}
<Patch
{patch}
{baseUrl}
projectId={project.id}
projectHead={project.head}
revision={activeRoute.params.view.params.revision}
currentTab={patchTabFilter}
{patch} />
revision={activeRoute.params.view.params.revision ??
latestRevision.id}
currentTab={patchTabFilter} />
{:catch e}
<div class="message">
<ErrorMessage message="Couldn't load patch." stackTrace={e} />
+3 -3
View File
@@ -267,9 +267,9 @@ test("peer and branch switching", async ({ page }) => {
await page.getByTitle("Change peer").click();
await page.locator(`text=${aliceRemote}`).click();
await expect(page.getByTitle("Change peer")).toHaveText(
` did:key:${aliceRemote.substring(8).substring(0, 6)}…${aliceRemote.slice(
-6,
)} `,
` did:key:${aliceRemote
.substring(8)
.substring(0, 6)}…${aliceRemote.slice(-6)} delegate`,
);
await expect(
page.locator(
+3 -3
View File
@@ -15,9 +15,9 @@ test("peer and branch switching", async ({ page }) => {
await page.getByTitle("Change peer").click();
await page.locator(`text=${aliceRemote}`).click();
await expect(page.getByTitle("Change peer")).toHaveText(
` did:key:${aliceRemote.substring(8).substring(0, 6)}…${aliceRemote.slice(
-6,
)} `,
` did:key:${aliceRemote
.substring(8)
.substring(0, 6)}…${aliceRemote.slice(-6)} delegate`,
);
await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible();