Improve code review usability

This patch moves the revision list from the tab bar drop-down to a sidebar
on wide screens and above the current revision on narrow screens. This
way all revisions are visible at all times.

We also move and nest the revision reviews into that list so that one
can see whether there's anything actionable at a glance.

Other tweaks:

  - added comment counts to revisions and reviews
  - displayed unresolved comment thread counts per review
  - swapped icons for accepted/rejected and neutral reviews to avoid clashing
    with resolved/unresolved comment icons
  - made label and assignee sections more compact with hover-triggered "+" buttons
  - added author and status sections to the sidebar
  - enabled patch state changes from the title bar
  - made "Review revision" the primary action and "Checkout patch" secondary
  - added "Draft" label to draft patches
  - capitalized all badges for consistent UI text
This commit is contained in:
Rūdolfs Ošiņš
2025-07-03 11:46:39 +02:00
parent 18f5aa9065
commit a55a138a7f
20 changed files with 933 additions and 649 deletions
+71 -45
View File
@@ -94,9 +94,14 @@
</script>
<style>
.header {
.add-icon {
display: none;
}
.title-button:hover .add-icon {
display: flex;
}
.title-button {
font-size: var(--font-size-small);
margin-bottom: 0.5rem;
color: var(--color-foreground-dim);
}
.body {
@@ -104,8 +109,9 @@
align-items: center;
flex-wrap: wrap;
flex-direction: row;
gap: 0.5rem;
gap: 1rem;
font-size: var(--font-size-small);
margin-top: 1rem;
}
.validation-message {
display: flex;
@@ -123,33 +129,75 @@
border: none;
display: flex;
color: var(--color-foreground-default);
padding: 0;
align-items: center;
}
</style>
<div style:width="100%">
<div class="global-flex" style:align-items="flex-start">
<div class="header">Assignees</div>
<div class="global-flex">
<button
disabled={!allowedToEdit}
style:color={allowedToEdit
? "var(--color-foreground-dim)"
: "var(--color-foreground-disabled)"}
title={allowedToEdit
? undefined
: "Only delegates are allowed to add assignees"}
style:cursor={allowedToEdit ? "pointer" : "default"}
class="title-button"
onclick={() => {
inputValue = "";
showInput = !showInput;
}}>
{#if updatedAssignees.length === 0}
Add assignees
{:else}
Assignees
{/if}
{#if allowedToEdit}
<div class="global-flex" style:margin-left="auto">
{#if showInput}
<Icon
onclick={addAssignee}
name="checkmark"
disabled={!valid || inputValue === ""} />
<Icon
onclick={() => {
inputValue = "";
showInput = false;
}}
name="cross" />
{:else}
<Icon name="add" onclick={() => (showInput = true)}></Icon>
{/if}
{#if !showInput && allowedToEdit}
<span class="add-icon">
<Icon name="add" />
</span>
{/if}
</button>
{#if allowedToEdit}
<div class="global-flex edit-icons">
{#if showInput}
<Icon
onclick={addAssignee}
name="checkmark"
disabled={!valid || inputValue === ""} />
<Icon
onclick={() => {
inputValue = "";
showInput = false;
}}
name="cross" />
{/if}
</div>
{/if}
</div>
{#if showInput}
<div style:margin-top="1rem">
<TextInput
autofocus
{valid}
disabled={submitInProgress}
placeholder="Assignee DID, e.g. did:key:z6MkwPUeUS2…"
bind:value={inputValue}
onSubmit={addAssignee} />
{#if !valid && validationMessage}
<div class="validation-message">
<Icon name="warning" />{validationMessage}
</div>
{/if}
</div>
{/if}
{#if updatedAssignees.length > 0}
<div class="body">
{#if allowedToEdit}
{#each updatedAssignees as assignee}
@@ -163,32 +211,10 @@
{/if}
</button>
{/each}
{#if updatedAssignees.length === 0 && !showInput}
<div class="txt-missing">Not assigned to anyone.</div>
{/if}
{:else}
{#each updatedAssignees as assignee}
<NodeId {...authorForNodeId(assignee)} />
{:else}
<div class="txt-missing">Not assigned to anyone.</div>
{/each}
{/if}
</div>
{#if showInput}
<div style:margin-top="0.5rem">
<TextInput
autofocus
{valid}
disabled={submitInProgress}
placeholder="Add assignee"
bind:value={inputValue}
onSubmit={addAssignee} />
{#if !valid && validationMessage}
<div class="validation-message">
<Icon name="warning" />{validationMessage}
</div>
{/if}
</div>
{/if}
</div>
{/if}
+1 -1
View File
@@ -171,7 +171,7 @@
<Id
id={revision.base}
variant={selectedCommit ? "none" : "commit"} />
<div class="global-counter">base</div>
<div class="global-counter">Base</div>
</div>
<div class="commits">
{#each commits.reverse() as commit}
+7 -5
View File
@@ -2,9 +2,9 @@
import { formatOid } from "@app/lib/utils";
import Border from "@app/components/Border.svelte";
import Button from "@app/components/Button.svelte";
import Command from "@app/components/Command.svelte";
import Icon from "@app/components/Icon.svelte";
import NakedButton from "@app/components/NakedButton.svelte";
import Popover from "@app/components/Popover.svelte";
interface Props {
@@ -31,13 +31,15 @@
popoverPositionTop="3rem"
bind:expanded={popoverExpanded}>
{#snippet toggle(onclick)}
<Button
<NakedButton
title="Checkout patch"
styleHeight="2.5rem"
variant="secondary"
variant="ghost"
{onclick}
active={popoverExpanded}>
<Icon name="checkout" />Checkout patch
</Button>
<Icon name="checkout" />
<span class="global-hide-on-medium-desktop-down">Checkout patch</span>
</NakedButton>
{/snippet}
{#snippet popover()}
<Border
+2 -2
View File
@@ -420,7 +420,7 @@
onclick={() => toggleCommentExpand(thread.root.id)} />
{:else}
<Icon
name="comment"
name="comment-cross"
onclick={() => toggleCommentExpand(thread.root.id)} />
{/if}
{/if}
@@ -439,7 +439,7 @@
{#if thread.root.resolved}
<div title="Unresolve comment thread">
<Icon
name="cross"
name="unresolve"
onclick={partial(
codeComments.changeCommentStatus,
thread.root.id,
+5 -5
View File
@@ -96,13 +96,13 @@
{/if}
{#if file.status === "added"}
<span class="global-counter added">added</span>
<span class="global-counter added">Added</span>
{:else if file.status === "deleted"}
<span class="global-counter deleted">deleted</span>
<span class="global-counter deleted">Deleted</span>
{:else if file.status === "moved"}
<span class="global-counter moved">moved</span>
<span class="global-counter moved">Moved</span>
{:else if file.status === "copied"}
<span class="global-counter copied">copied</span>
<span class="global-counter copied">Copied</span>
{/if}
{/snippet}
@@ -120,7 +120,7 @@
{#if commentsOfThisFile && commentsOfThisFile.threads.length > 0}
{#if unresolvedThreads > 0}
<div class="global-flex">
<Icon name="comment" />
<Icon name="comment-cross" />
{unresolvedThreads}
</div>
{/if}
+72
View File
@@ -71,12 +71,15 @@
| "plus"
| "reply"
| "repo"
| "review"
| "revision"
| "seedling"
| "seedling-filled"
| "settings"
| "stop"
| "sun"
| "thumb-up"
| "unresolve"
| "user"
| "warning";
}
@@ -1163,6 +1166,35 @@
<path d="M7 8H5V9L7 9L7 8Z" />
<path d="M7 10H5L5 11H7L7 10Z" />
<path d="M7 12H5L5 13H7L7 12Z" />
{:else if name === "review"}
<path d="M11 5L8 5V4L11 4V5Z" />
<path d="M9 7L10 7V6L9 6V7Z" />
<path d="M10 9H9L9 10H10V9Z" />
<path d="M7 6V7H6L6 6L7 6Z" />
<path d="M4 6L4 7H3L3 6H4Z" />
<path d="M12 10V9L13 9V10L12 10Z" />
<path d="M12 6L11 6V5L12 5V6Z" />
<path d="M7 10L8 10L8 11H7L7 10Z" />
<path d="M4 10H5L5 11L4 11L4 10Z" />
<path d="M5 11L7 11L7 12L5 12L5 11Z" />
<path d="M8 6L7 6V5L8 5V6Z" />
<path d="M5 6H4L4 5L5 5V6Z" />
<path d="M7 5L5 5L5 4L7 4V5Z" />
<path d="M11 10H12V11L11 11V10Z" />
<path d="M12 7V6L13 6V7L12 7Z" />
<path d="M7 9L7 10H6L6 9H7Z" />
<path d="M4 9L4 10H3L3 9H4Z" />
<path d="M8 11H11L11 12H8L8 11Z" />
<path d="M8 8L8 7H9L9 8H8Z" />
<path d="M11 8L11 9L10 9L10 8L11 8Z" />
<path d="M10 8V7L11 7V8L10 8Z" />
<path d="M9 8L9 9H8V8H9Z" />
<path d="M14 7V8H13V7L14 7Z" />
<path d="M5 9V8H6V9L5 9Z" />
<path d="M2 9L2 8H3L3 9L2 9Z" />
<path d="M6 7L6 8H5L5 7H6Z" />
<path d="M3 7L3 8H2L2 7H3Z" />
<path d="M13 9V8H14V9H13Z" />
{:else if name === "revision"}
<path d="M6 13H8V14H6V13Z" />
<path d="M8 13H9V14H8V13Z" />
@@ -1262,6 +1294,20 @@
<path d="M4 4L5 4L5 7H4V4Z" />
<path d="M11 9L12 9V12H11V9Z" />
<path d="M7 4L8 4V7L7 7V4Z" />
{:else if name === "stop"}
<path d="M3.5 5L3.5 13H2.5L2.5 5H3.5Z" />
<path d="M5.5 4L5.5 8H4.5L4.5 4H5.5Z" />
<path d="M7.5 3L7.5 8H6.5L6.5 3L7.5 3Z" />
<path d="M3.5 13L12.5 13V14L3.5 14L3.5 13Z" />
<path d="M3.5 4H5.5V5H3.5L3.5 4Z" />
<path d="M12.5 8L13.5 8L13.5 12H12.5L12.5 8Z" />
<path d="M11.5 7H12.5V8H11.5V7Z" />
<path d="M8.5 3H9.5V8H8.5V3Z" />
<path d="M10.5 4H11.5L11.5 10H10.5L10.5 4Z" />
<path d="M5.5 3L6.5 3L6.5 4H5.5L5.5 3Z" />
<path d="M7.5 2H8.5V3L7.5 3V2Z" />
<path d="M9.5 3L10.5 3V4L9.5 4V3Z" />
<path d="M11.5 12L12.5 12V13H11.5V12Z" />
{:else if name === "sun"}
<path d="M8 2H9V3H8V2Z" />
<path d="M14 8V9H13V8H14Z" />
@@ -1306,6 +1352,32 @@
<path d="M7 6H8V7H7L7 6Z" />
<path d="M7 10L8 10V11L7 11L7 10Z" />
<path d="M7 12L8 12L8 13H7L7 12Z" />
{:else if name === "unresolve"}
<path d="M7 11.5V12.5H6V11.5H7Z" />
<path d="M8 10.5V11.5L7 11.5L7 10.5H8Z" />
<path d="M10 8.5V9.5H9V8.5H10Z" />
<path d="M11 7.5V8.5L10 8.5L10 7.5H11Z" />
<path d="M10 7.5L10 8.5H9L9 7.5L10 7.5Z" />
<path d="M12 6.5V7.5H11V6.5H12Z" />
<path d="M13 5.5V6.5L12 6.5V5.5L13 5.5Z" />
<path d="M4 8.5V9.5H3L3 8.5H4Z" />
<path d="M5 8.5L5 9.5H4V8.5H5Z" />
<path d="M6 9.5L6 10.5H5V9.5H6Z" />
<path d="M7 10.5L7 11.5H6L6 10.5H7Z" />
<path d="M8 9.5V10.5H7V9.5H8Z" />
<path d="M11 6.5V7.5H10V6.5H11Z" />
<path d="M12 5.5V6.5H11V5.5H12Z" />
<path d="M5 9.5V10.5H4L4 9.5H5Z" />
<path d="M6 10.5L6 11.5H5L5 10.5H6Z" />
<path d="M12 11.5H13V12.5H12V11.5Z" />
<path d="M11 10.5H12L12 11.5H11V10.5Z" />
<path d="M10 9.5L11 9.5V10.5H10V9.5Z" />
<path d="M9 8.5H10V9.5H9V8.5Z" />
<path d="M8 7.5L9 7.5L9 8.5H8V7.5Z" />
<path d="M7 6.5H8V7.5H7V6.5Z" />
<path d="M6 5.5H7V6.5H6V5.5Z" />
<path d="M5 4.5H6V5.5L5 5.5V4.5Z" />
<path d="M4 3.5L5 3.5V4.5L4 4.5V3.5Z" />
{:else if name === "user"}
<path d="M5 3H6V4H5V3Z" />
<path d="M5 6L5 8H4V6H5Z" />
+79 -46
View File
@@ -73,9 +73,14 @@
</script>
<style>
.header {
.add-icon {
display: none;
}
.title-button:hover .add-icon {
display: flex;
}
.title-button {
font-size: var(--font-size-small);
margin-bottom: 0.5rem;
color: var(--color-foreground-dim);
}
.body {
@@ -85,6 +90,7 @@
flex-direction: row;
gap: 0.5rem;
font-size: var(--font-size-small);
margin-top: 1rem;
}
.validation-message {
display: flex;
@@ -97,74 +103,101 @@
button {
border: 0;
cursor: pointer;
color: var(--color-foreground-default);
gap: 0.5rem;
background-color: transparent;
border: none;
display: flex;
color: var(--color-foreground-default);
padding: 0;
align-items: center;
}
</style>
<div style:width="100%">
<div class="global-flex" style:align-items="flex-start">
<div class="header">Labels</div>
<div class="global-flex">
<button
disabled={!allowedToEdit}
style:color={allowedToEdit
? "var(--color-foreground-dim)"
: "var(--color-foreground-disabled)"}
title={allowedToEdit
? undefined
: "Only delegates are allowed to add labels"}
style:cursor={allowedToEdit ? "pointer" : "default"}
class="title-button"
onclick={() => {
inputValue = "";
showInput = !showInput;
}}>
{#if updatedLabels.length === 0}
Add labels
{:else}
Labels
{/if}
{#if allowedToEdit}
<div class="global-flex" style:margin-left="auto">
{#if showInput}
<Icon
onclick={addLabel}
disabled={!valid || inputValue === ""}
name="checkmark" />
<Icon
onclick={() => {
inputValue = "";
showInput = false;
}}
name="cross" />
{:else}
<Icon name="add" onclick={() => (showInput = true)}></Icon>
{/if}
{#if !showInput && allowedToEdit}
<span class="add-icon">
<Icon name="add"></Icon>
</span>
{/if}
</button>
{#if allowedToEdit}
<div class="global-flex edit-icons">
{#if showInput}
<Icon
onclick={addLabel}
name="checkmark"
disabled={!valid || inputValue === ""} />
<Icon
onclick={() => {
inputValue = "";
showInput = false;
}}
name="cross" />
{/if}
</div>
{/if}
</div>
{#if showInput}
<div style:margin-top="1rem">
<TextInput
autofocus
{valid}
disabled={submitInProgress}
placeholder="Add label"
bind:value={inputValue}
onSubmit={addLabel} />
{#if !valid && validationMessage}
<div class="validation-message">
<Icon name="warning" />{validationMessage}
</div>
{/if}
</div>
{/if}
{#if updatedLabels.length > 0}
<div class="body">
{#if allowedToEdit}
{#each updatedLabels as label}
<button
class="global-counter txt-small"
style:background-color="var(--color-fill-counter)"
style:padding="0 0.5rem"
style:max-width="10rem"
onclick={() => (removeToggles[label] = !removeToggles[label])}>
<div class="txt-overflow" title={label}>{label}</div>
{#if removeToggles[label]}
<Icon name="cross" onclick={() => removeLabel(label)} />
<span style:margin-right="0.5rem">
<Icon name="cross" onclick={() => removeLabel(label)} />
</span>
{/if}
</button>
{/each}
{#if updatedLabels.length === 0 && !showInput}
<div class="txt-missing">No labels.</div>
{/if}
{:else}
{#each updatedLabels as label}
<Label {label} />
{:else}
<div class="txt-missing">No labels.</div>
{/each}
{/if}
</div>
{#if showInput}
<div style:margin-top="0.5rem">
<TextInput
autofocus
{valid}
disabled={submitInProgress}
placeholder="Add label"
bind:value={inputValue}
onSubmit={addLabel} />
{#if !valid && validationMessage}
<div class="validation-message">
<Icon name="warning" />{validationMessage}
</div>
{/if}
</div>
{/if}
</div>
{/if}
+144
View File
@@ -0,0 +1,144 @@
<script lang="ts">
import type { Author } from "@bindings/cob/Author";
import type { Config } from "@bindings/config/Config";
import type { Patch } from "@bindings/cob/patch/Patch";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import * as roles from "@app/lib/roles";
import { announce } from "@app/components/AnnounceSwitch.svelte";
import { invoke } from "@app/lib/invoke";
import { nodeRunning } from "@app/lib/events";
import AssigneeInput from "@app/components/AssigneeInput.svelte";
import LabelInput from "@app/components/LabelInput.svelte";
import NodeId from "./NodeId.svelte";
import { authorForNodeId } from "@app/lib/utils";
import PatchStateButton from "./PatchStateButton.svelte";
interface Props {
config: Config;
horizontal?: boolean;
loadPatch: () => Promise<void>;
patch: Patch;
repo: RepoInfo;
saveState: (newState: Patch["state"]) => Promise<void>;
}
const {
config,
horizontal = false,
loadPatch,
patch,
repo,
saveState,
}: Props = $props();
let labelSaveInProgress: boolean = $state(false);
let assigneesSaveInProgress: boolean = $state(false);
async function saveLabels(labels: string[]) {
try {
labelSaveInProgress = true;
await invoke("edit_patch", {
rid: repo.rid,
cobId: patch.id,
action: {
type: "label",
labels,
},
opts: { announce: $nodeRunning && $announce },
});
} catch (error) {
console.error("Editing labels failed", error);
} finally {
labelSaveInProgress = false;
await loadPatch();
}
}
async function saveAssignees(assignees: Author[]) {
try {
assigneesSaveInProgress = true;
await invoke("edit_patch", {
rid: repo.rid,
cobId: patch.id,
action: {
type: "assign",
assignees,
},
opts: { announce: $nodeRunning && $announce },
});
} catch (error) {
console.error("Editing assignees failed", error);
} finally {
assigneesSaveInProgress = false;
await loadPatch();
}
}
</script>
<style>
.metadata-section {
padding: 0.5rem;
font-size: var(--font-size-small);
display: flex;
flex-direction: column;
align-items: flex;
height: 100%;
}
.metadata-section-title {
margin-bottom: 0.5rem;
color: var(--color-foreground-dim);
}
</style>
<div
class="global-flex"
style:flex-direction={horizontal ? "row" : "column"}
style:align-items="flex-start">
<div
class="metadata-section"
style={horizontal ? "flex: 1;" : "width: 100%;"}>
<div class="metadata-section-title">Author</div>
<NodeId {...authorForNodeId(patch.author)} />
</div>
<div
class="metadata-section"
style={horizontal ? "flex: 1;" : "width: 100%;"}>
<div class="metadata-section-title">Status</div>
<PatchStateButton
selectedState={patch.state}
onSelect={newState => {
void saveState(newState);
}} />
</div>
<div
class="metadata-section"
style={horizontal ? "flex: 1;" : "width: 100%;"}>
<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-section"
style={horizontal ? "flex: 1;" : "width: 100%;"}>
<AssigneeInput
allowedToEdit={!!roles.isDelegateOrAuthor(
config.publicKey,
repo.delegates.map(delegate => delegate.did),
patch.author.did,
)}
assignees={patch.assignees}
submitInProgress={assigneesSaveInProgress}
save={saveAssignees} />
</div>
</div>
+7 -1
View File
@@ -33,6 +33,7 @@
align-items: center;
justify-content: center;
font-size: var(--font-size-small);
background-color: none;
}
button:disabled {
cursor: inherit;
@@ -50,7 +51,12 @@
popoverPositionLeft="0"
bind:expanded={popoverExpanded}>
{#snippet toggle(onclick)}
<button disabled={selectedState.status === "merged"} {onclick}>
<button
disabled={selectedState.status === "merged"}
{onclick}
title={selectedState.status === "merged"
? "The state of merged patches can not be changed"
: "Click to change patch state"}>
<span
class="global-counter badge"
style:color={patchStatusColor[selectedState.status]}
@@ -0,0 +1,120 @@
<script lang="ts">
import type { ComponentProps } from "svelte";
import type { State } from "@bindings/cob/patch/State";
import capitalize from "lodash/capitalize";
import { closeFocused } from "@app/components/Popover.svelte";
import { patchStatusBackgroundColor, patchStatusColor } 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";
interface Props {
selectedState: State;
onSelect: (newState: State) => void;
}
const { selectedState, onSelect }: Props = $props();
let focus = $state(false);
let popoverExpanded: boolean = $state(false);
function icon(): ComponentProps<typeof Icon>["name"] {
if (selectedState.status === "merged") {
return "patch-merged";
} else if (focus) {
return "chevron-down";
} else {
if (selectedState.status === "open") {
return "patch";
} else {
return `patch-${selectedState.status}`;
}
}
}
</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);
}
button:disabled {
cursor: inherit;
}
.badge {
height: 2.5rem;
width: 2.5rem;
gap: 0.375rem;
padding-right: 0.625rem;
}
</style>
<Popover
popoverPadding="0"
popoverPositionTop="3rem"
popoverPositionLeft="0"
bind:expanded={popoverExpanded}>
{#snippet toggle(onclick)}
<button
onmouseenter={() => (focus = true)}
onfocus={() => (focus = true)}
onblur={() => (focus = false)}
onmouseleave={() => (focus = false)}
disabled={selectedState.status === "merged"}
{onclick}
title={selectedState.status === "merged"
? "The state of merged patches can not be changed"
: "Click to change patch state"}>
<span
class="global-counter badge"
style:color={patchStatusColor[selectedState.status]}
style:background-color={patchStatusBackgroundColor[
selectedState.status
]}>
<Icon name={icon()} />
</span>
</button>
{/snippet}
{#snippet popover()}
<Border variant="ghost">
<DropdownList
items={[
{ status: "open" },
{ status: "draft" },
{ status: "archived" },
] as State[]}>
{#snippet item(state)}
<DropdownListItem
selected={selectedState.status === state.status}
onclick={() => {
onSelect(state);
closeFocused();
}}>
<span
class="global-flex"
style:color={patchStatusColor[state.status]}>
<Icon
name={state.status === "open"
? "patch"
: `patch-${state.status}`} />
{capitalize(state.status)}
</span>
</DropdownListItem>
{/snippet}
</DropdownList>
</Border>
{/snippet}
</Popover>
+2 -2
View File
@@ -251,7 +251,7 @@
<div class="timeline-item">
{#if op.verdict === "accept"}
<div class="icon" style:color="var(--color-foreground-success)">
<Icon name="comment-checkmark" />
<Icon name="thumb-up" />
</div>
<div class="wrapper">
<NodeId {...authorForNodeId(op.author)} />
@@ -262,7 +262,7 @@
</div>
{:else if op.verdict === "reject"}
<div class="icon" style:color="var(--color-foreground-red)">
<Icon name="comment-cross" />
<Icon name="stop" />
</div>
<div class="wrapper">
<NodeId {...authorForNodeId(op.author)} />
+7 -3
View File
@@ -345,11 +345,15 @@
<NodeId
{...authorForNodeId(review.author)}
styleFontSize="var(--font-size-medium)"
styleFontWeight="var(--font-weight-medium)" />'s
styleFontWeight="var(--font-weight-medium)" />'s review
{#if "draft" in review}
draft
<span
class="global-counter"
style:margin-left="0.5rem"
title="This review is not yet visible to your peers">
Draft
</span>
{/if}
review
</span>
{#if "draft" in review}
<div style:margin-inline-start="auto">
-158
View File
@@ -1,158 +0,0 @@
<script lang="ts">
import type { DraftReview } from "@app/lib/draftReviewStorage";
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 "@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 {
patchId: string;
review: Review | DraftReview;
rid: string;
status: PatchStatus | undefined;
first?: boolean;
last?: boolean;
}
const { patchId, review, rid, status, first, last }: Props = $props();
const style = $derived(
(first && last && "--local-clip-path: var(--2px-corner-fill)") ||
(first && "--local-clip-path: var(--2px-top-corner-fill)") ||
(last && "--local-clip-path: var(--2px-bottom-corner-fill)") ||
"",
);
</script>
<style>
.review {
display: flex;
align-items: flex-start;
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(--local-clip-path);
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;
flex-direction: column;
gap: 0.5rem;
}
.timestamp {
color: var(--color-foreground-dim);
}
.review-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.status {
padding: 0;
margin: 0.5rem 0 0 0.5rem;
}
.accepted {
background-color: var(--color-fill-diff-green-light);
color: var(--color-foreground-success);
}
.rejected {
background-color: var(--color-fill-diff-red-light);
color: var(--color-foreground-red);
}
.no-verdict {
background-color: var(--color-fill-ghost);
color: var(--color-foreground-dim);
}
</style>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
tabindex="0"
role="button"
class="review"
{style}
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)} />
{#if "draft" in review}
<span>draft review</span>
{:else}
<span>published review</span>
<Id id={review.id} variant="oid" />
<div class="timestamp" title={absoluteTimestamp(review.timestamp)}>
{formatTimestamp(review.timestamp)}
</div>
{/if}
</div>
<div class="global-flex" style:gap="1rem">
{#if review.labels.length > 0}
<div class="global-flex" style:margin-left="auto">
{#each review.labels as label}
<Label {label} />
{/each}
</div>
{/if}
{#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>
</div>
{#if review.summary?.trim()}
<div>
<Markdown {rid} breaks content={review.summary} />
</div>
{/if}
</div>
</div>
-112
View File
@@ -1,112 +0,0 @@
<script lang="ts">
import type { Config } from "@bindings/config/Config";
import type { DraftReview } from "@app/lib/draftReviewStorage";
import type { PatchStatus } from "@app/views/repo/router";
import type { Review } from "@bindings/cob/patch/Review";
import type { Revision } from "@bindings/cob/patch/Revision";
import Icon from "@app/components/Icon.svelte";
import NakedButton from "@app/components/NakedButton.svelte";
import ReviewTeaser from "@app/components/ReviewTeaser.svelte";
import { didFromPublicKey } from "@app/lib/utils";
import { draftReviewStorage } from "@app/lib/draftReviewStorage";
import { push } from "@app/lib/router";
interface Props {
config: Config;
patchId: string;
revision: Revision;
rid: string;
status: PatchStatus | undefined;
}
const { config, patchId, revision, rid, status }: Props = $props();
let hideReviews = $derived.by(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
patchId;
return reviews.length === 0;
});
const reviews: Array<Review | DraftReview> = $derived(
[
draftReviewStorage.getForRevision(revision.id, {
did: didFromPublicKey(config.publicKey),
alias: config.alias,
}),
...(revision.reviews ?? []),
].filter((review): review is Review | DraftReview => Boolean(review)),
);
const hasOwnReview = $derived(
reviews.some(
value => value.author.did === didFromPublicKey(config.publicKey),
),
);
</script>
<style>
.review-list {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 2px;
}
</style>
<div style:margin={hideReviews ? "1.5rem 0" : "1.5rem 0 2.5rem 0"}>
<div class="global-flex">
<div class="global-flex">
<NakedButton
stylePadding="0 4px"
disabled={reviews.length === 0}
variant="ghost"
onclick={() => (hideReviews = !hideReviews)}>
<Icon name={hideReviews ? "chevron-right" : "chevron-down"} />
</NakedButton>
<div
class="txt-semibold global-flex txt-regular"
style:color={reviews.length === 0
? "var(--color-foreground-disabled)"
: undefined}>
Reviews <span style:font-weight="var(--font-weight-regular)">
{reviews.length}
</span>
</div>
</div>
<div class="global-flex" style:margin-left="auto">
<NakedButton
variant="ghost"
disabled={hasOwnReview}
onclick={() => {
const id = draftReviewStorage.create(rid, revision.id);
void push({
resource: "repo.patch",
rid,
patch: patchId,
reviewId: id,
status,
});
}}
title={hasOwnReview ? "You already published a review" : undefined}>
<Icon name="add" />
<span class="txt-small">Review</span>
</NakedButton>
</div>
</div>
<div style:display={hideReviews ? "none" : "flex"} class="review-list">
{#each reviews as review, idx}
<ReviewTeaser
{rid}
{review}
{patchId}
{status}
first={idx === 0}
last={idx === reviews.length - 1} />
{/each}
</div>
</div>
+2 -14
View File
@@ -2,7 +2,6 @@
import type { Author } from "@bindings/cob/Author";
import type { Config } from "@bindings/config/Config";
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";
@@ -17,7 +16,6 @@
import Changes from "@app/components/Changes.svelte";
import CommentComponent from "@app/components/Comment.svelte";
import Discussion from "@app/components/Discussion.svelte";
import Reviews from "@app/components/Reviews.svelte";
interface Props {
rid: string;
@@ -25,19 +23,11 @@
patchId: string;
revision: Revision;
config: Config;
status: PatchStatus | undefined;
loadPatch: () => Promise<void>;
}
const {
rid,
repoDelegates,
patchId,
revision,
config,
status,
loadPatch,
}: Props = $props();
const { rid, repoDelegates, patchId, revision, config, loadPatch }: Props =
$props();
const commentThreads = $derived(
((revision.discussion &&
@@ -219,8 +209,6 @@
</CommentComponent>
</div>
<Reviews {config} {patchId} {revision} {rid} {status} />
<Discussion
cobId={patchId}
{commentThreads}
+16 -15
View File
@@ -11,19 +11,20 @@
/* eslint-enable prefer-const */
</script>
{#if revision.id === revisions.slice(-1)[0].id}
<span
class="global-counter"
style:height="1.375rem"
style:color="var(--color-foreground-contrast)">
Latest
</span>
{/if}
{#if revision.id === revisions[0].id}
<span
class="global-counter"
style:height="1.375rem"
style:color="var(--color-foreground-contrast)">
Initial
</span>
{#if revisions.length > 1}
{#if revision.id === revisions[0].id}
<span
class="global-counter"
style:height="1.375rem"
style:color="var(--color-foreground-contrast)">
Initial
</span>
{:else if revision.id === revisions.slice(-1)[0].id}
<span
class="global-counter"
style:height="1.375rem"
style:color="var(--color-foreground-contrast)">
Latest
</span>
{/if}
{/if}
+124
View File
@@ -0,0 +1,124 @@
<script lang="ts">
import type { Config } from "@bindings/config/Config";
import type { DraftReview } from "@app/lib/draftReviewStorage";
import type { Patch } from "@bindings/cob/patch/Patch";
import type { PatchStatus } from "@app/views/repo/router";
import type { Review } from "@bindings/cob/patch/Review";
import type { Revision } from "@bindings/cob/patch/Revision";
import DropdownListItem from "./DropdownListItem.svelte";
import Icon from "./Icon.svelte";
import NodeId from "./NodeId.svelte";
import {
authorForNodeId,
didFromPublicKey,
verdictIcon,
} from "@app/lib/utils";
import { draftReviewStorage } from "@app/lib/draftReviewStorage";
import { push } from "@app/lib/router";
interface Props {
config: Config;
patch: Patch;
revision: Revision;
rid: string;
status: PatchStatus | undefined;
}
const { config, patch, revision, rid, status }: Props = $props();
const reviews: Array<Review | DraftReview> = $derived(
[
draftReviewStorage.getForRevision(revision.id, {
did: didFromPublicKey(config.publicKey),
alias: config.alias,
}),
...(revision.reviews ?? []),
].filter((review): review is Review | DraftReview => Boolean(review)),
);
function unresolvedCommentsCount(review: Review | DraftReview) {
return review.comments.filter(t => {
return t.resolved === false && t.location !== null && t.replyTo === null;
}).length;
}
</script>
<style>
.status {
padding: 0;
}
.accepted {
color: var(--color-foreground-success);
}
.rejected {
color: var(--color-foreground-red);
}
.no-verdict {
color: var(--color-foreground-dim);
}
</style>
{#each reviews as review}
<div style:margin="0.5rem 0">
<DropdownListItem
selected={false}
onclick={() => {
void push({
resource: "repo.patch",
rid,
patch: patch.id,
status,
reviewId: review.id,
});
}}>
<div
class="global-flex"
style:width="100%"
style:gap="0.25rem"
style:padding-left="1.5rem">
<div
style:margin-right="0.25rem"
class:accepted={review.verdict === "accept"}
class:rejected={review.verdict === "reject"}
class:no-verdict={review.verdict === undefined}
class="status">
<Icon name={verdictIcon(review.verdict)} />
</div>
<span class="global-flex" style:gap="0">
<NodeId
{...authorForNodeId(review.author)}
styleFontWeight="var(--font-weight-regular)" />'s
</span>
review
{#if "draft" in review}
<span
class="global-counter"
title="This review is not yet visible to your peers">
Draft
</span>
{/if}
<div class="global-flex" style:margin-left="auto">
{#if review.comments.length > 0}
{@const unresolved = unresolvedCommentsCount(review)}
{#if unresolved > 0}
<div class="global-flex" style:gap="0.25rem">
<Icon name="comment-cross" />
{unresolved}
</div>
{/if}
{#if unresolved === 0 || review.comments.length > unresolved}
<div class="global-flex" style:gap="0.25rem">
<Icon name="comment" />{review.comments.length}
</div>
{/if}
{/if}
</div>
</div>
</DropdownListItem>
</div>
{/each}
+113
View File
@@ -0,0 +1,113 @@
<script lang="ts">
import type { Config } from "@bindings/config/Config";
import type { Patch } from "@bindings/cob/patch/Patch";
import type { PatchStatus } from "@app/views/repo/router";
import type { Revision } from "@bindings/cob/patch/Revision";
import orderBy from "lodash/orderBy";
import uniqBy from "lodash/uniqBy";
import { authorForNodeId } from "@app/lib/utils";
import DropdownListItem from "./DropdownListItem.svelte";
import Icon from "./Icon.svelte";
import NodeId from "./NodeId.svelte";
import RevisionReviews from "./RevisionReviews.svelte";
import RevisionBadges from "./RevisionBadges.svelte";
interface Props {
config: Config;
patch: Patch;
revisions: Revision[];
rid: string;
selectRevision: (revision: Revision) => void;
selectedRevision: Revision;
status: PatchStatus | undefined;
}
const {
config,
patch,
revisions,
rid,
selectRevision,
selectedRevision,
status,
}: Props = $props();
const revisionAuthors = $derived(
orderBy(
uniqBy(
revisions.map(r => r.author),
"did",
),
[o => o.did === patch.author.did],
["desc"],
),
);
</script>
<style>
.author-revisions:not(:last-of-type) {
margin-bottom: 1.5rem;
}
</style>
{#each revisionAuthors as author}
<div class="author-revisions">
<div style:padding-bottom="0.5rem">
<span class="global-flex txt-small" style:gap="0">
<NodeId
{...authorForNodeId(author)}
styleFontWeight="var(--font-weight-regular)" />'s revisions
</span>
</div>
{#each orderBy( revisions.filter(r => {
return r.author.did === author.did;
}), "timestamp", ["asc"], ) as revision}
<div style:margin="0.5rem 0">
<DropdownListItem
selected={revision.id === selectedRevision.id}
onclick={() => {
selectRevision(revision);
}}>
<div class="global-flex txt-overflow" style:width="100%">
{#if patch.state.status === "merged" && patch.state.revision === revision.id}
<div style:color="var(--color-fill-primary)">
<Icon name="patch-merged" />
</div>
{:else}
<Icon name="revision" />
{/if}
<span class="global-oid">
{revision.id.substring(0, 4)}
</span>
<RevisionBadges {revision} {revisions} />
<span class="txt-overflow">
{#if revision.description[0].body.trim()}
{revision.description[0].body}
{:else}
<span
class="txt-missing"
style:font-weight="var(--font-weight-regular)">
No description.
</span>
{/if}
</span>
<div class="global-flex" style:margin-left="auto">
{#if revision.discussion && revision.discussion.length > 0}
<div
class="global-flex"
style:font-weight="var(--font-weight-regular)"
style:gap="0.25rem">
<Icon name="comment" />{revision.discussion.length}
</div>
{/if}
</div>
</div>
</DropdownListItem>
</div>
<RevisionReviews {config} {rid} {status} {revision} {patch} />
{/each}
</div>
{/each}
+3 -3
View File
@@ -241,11 +241,11 @@ export function gravatarURL(email: string): string {
export function verdictIcon(verdict: Review["verdict"]) {
if (verdict === "accept") {
return "comment-checkmark";
return "thumb-up";
} else if (verdict === "reject") {
return "comment-cross";
return "stop";
} else {
return "comment";
return "review";
}
}
+158 -237
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import type { Action } from "@bindings/cob/patch/Action";
import type { Author } from "@bindings/cob/Author";
import type { Config } from "@bindings/config/Config";
import type { DraftReview } from "@app/lib/draftReviewStorage";
import type { Operation } from "@bindings/cob/Operation";
@@ -13,51 +12,48 @@
import fuzzysort from "fuzzysort";
import * as roles from "@app/lib/roles";
import * as router from "@app/lib/router";
import { DEFAULT_TAKE } from "./router";
import { announce } from "@app/components/AnnounceSwitch.svelte";
import { draftReviewStorage } from "@app/lib/draftReviewStorage";
import {
didFromPublicKey,
explorerUrl,
formatOid,
patchStatusBackgroundColor,
patchStatusColor,
verdictIcon,
} from "@app/lib/utils";
import { draftReviewStorage } from "@app/lib/draftReviewStorage";
import { invoke } from "@app/lib/invoke";
import { modifierKey } from "@app/lib/utils";
import { nodeRunning } from "@app/lib/events";
import { push } from "@app/lib/router";
import AssigneeInput from "@app/components/AssigneeInput.svelte";
import Border from "@app/components/Border.svelte";
import Button from "@app/components/Button.svelte";
import CheckoutPatchButton from "@app/components/CheckoutPatchButton.svelte";
import DropdownListItem from "@app/components/DropdownListItem.svelte";
import EditableTitle from "@app/components/EditableTitle.svelte";
import Icon from "@app/components/Icon.svelte";
import InlineTitle from "@app/components/InlineTitle.svelte";
import LabelInput from "@app/components/LabelInput.svelte";
import Link from "@app/components/Link.svelte";
import MoreBreadcrumbsButton from "@app/components/MoreBreadcrumbsButton.svelte";
import NakedButton from "@app/components/NakedButton.svelte";
import NewPatchButton from "@app/components/NewPatchButton.svelte";
import NodeBreadcrumb from "@app/components/NodeBreadcrumb.svelte";
import PatchStateButton from "@app/components/PatchStateButton.svelte";
import PatchMetadata from "@app/components/PatchMetadata.svelte";
import PatchStateButtonCompact from "@app/components/PatchStateButtonCompact.svelte";
import PatchStateFilterButton from "@app/components/PatchStateFilterButton.svelte";
import PatchTeaser from "@app/components/PatchTeaser.svelte";
import PatchTimeline from "@app/components/PatchTimeline.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";
import Revisions from "@app/components/Revisions.svelte";
import Sidebar from "@app/components/Sidebar.svelte";
import Tab from "@app/components/Tab.svelte";
import TextInput from "@app/components/TextInput.svelte";
import BreadcrumbCopyButton from "./BreadcrumbCopyButton.svelte";
import Layout from "./Layout.svelte";
import PatchesBreadcrumb from "./PatchesBreadcrumb.svelte";
import RepoBreadcrumb from "./RepoBreadcrumb.svelte";
import BreadcrumbCopyButton from "./BreadcrumbCopyButton.svelte";
import MoreBreadcrumbsButton from "@app/components/MoreBreadcrumbsButton.svelte";
import DropdownListItem from "@app/components/DropdownListItem.svelte";
interface Props {
repo: RepoInfo;
@@ -89,10 +85,9 @@
let more: boolean = $state(false);
let patchTeasers: Patch[] = $state([]);
let hideTimeline = $state(true);
let patches = $state(initialPatches);
let status = $state(initialStatus);
let labelSaveInProgress: boolean = $state(false);
let assigneesSaveInProgress: boolean = $state(false);
let tab: "patch" | "revisions" | "timeline" = $state(
revisions.length > 1 ? "revisions" : "patch",
);
@@ -114,6 +109,24 @@
const project = $derived(repo.payloads["xyz.radicle.project"]!);
async function saveState(newState: Patch["state"]) {
try {
await invoke("edit_patch", {
rid: repo.rid,
cobId: patch.id,
action: {
type: "lifecycle",
state: newState,
},
opts: { announce: $nodeRunning && $announce },
});
} catch (error) {
console.error("Changing state failed", error);
} finally {
await loadPatch();
}
}
async function updateTitle(newTitle: string) {
try {
await invoke("edit_patch", {
@@ -134,64 +147,6 @@
}
}
async function saveLabels(labels: string[]) {
try {
labelSaveInProgress = true;
await invoke("edit_patch", {
rid: repo.rid,
cobId: patch.id,
action: {
type: "label",
labels,
},
opts: { announce: $nodeRunning && $announce },
});
} catch (error) {
console.error("Editing labels failed", error);
} finally {
labelSaveInProgress = false;
await loadPatch();
}
}
async function saveAssignees(assignees: Author[]) {
try {
assigneesSaveInProgress = true;
await invoke("edit_patch", {
rid: repo.rid,
cobId: patch.id,
action: {
type: "assign",
assignees,
},
opts: { announce: $nodeRunning && $announce },
});
} catch (error) {
console.error("Editing assignees failed", error);
} finally {
assigneesSaveInProgress = false;
await loadPatch();
}
}
async function saveState(newState: Patch["state"]) {
try {
await invoke("edit_patch", {
rid: repo.rid,
cobId: patch.id,
action: {
type: "lifecycle",
state: newState,
},
opts: { announce: $nodeRunning && $announce },
});
} catch (error) {
console.error("Changing state failed", error);
} finally {
await loadPatch();
}
}
async function loadMoreTeasers(all: boolean = false) {
if (more) {
const p = await invoke<PaginatedQuery<Patch[]>>("list_patches", {
@@ -320,37 +275,30 @@
}
}
}
const reviewsOfSelectedRevision: Array<Review | DraftReview> = $derived(
[
draftReviewStorage.getForRevision(selectedRevision.id, {
did: didFromPublicKey(config.publicKey),
alias: config.alias,
}),
...(selectedRevision.reviews ?? []),
].filter((review): review is Review | DraftReview => Boolean(review)),
);
const hasOwnReview = $derived(
reviewsOfSelectedRevision.some(
value => value.author.did === didFromPublicKey(config.publicKey),
),
);
</script>
<style>
.status {
padding: 0;
height: 2.5rem;
width: 2.5rem;
}
.content {
padding: 1rem 1rem 1rem 0;
}
.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%;
z-index: 20;
}
.metadata-section-title {
margin-bottom: 0.5rem;
color: var(--color-foreground-dim);
.container {
display: grid;
grid-template-columns: 1fr min-content;
grid-template-areas: "main-content right-sidebar";
}
.list {
display: flex;
@@ -593,162 +541,135 @@
{:else}
<div class="content">
<div class="global-flex" style:margin-bottom="1rem" style:gap="0.75rem">
<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>
<PatchStateButtonCompact
selectedState={patch.state}
onSelect={newState => {
void saveState(newState);
}} />
<EditableTitle
{updateTitle}
allowedToEdit={true}
title={patch.title}
cobId={patch.id} />
<div style:margin-left="auto" style:z-index="40">
<div
class="global-flex"
style:margin-left="auto"
style:z-index="40"
style:gap="1rem">
<CheckoutPatchButton
{tab}
selectedRevisionId={selectedRevision.id}
patchId={patch.id} />
</div>
</div>
<Border variant="ghost" styleGap="0">
<div class="metadata-section" style:min-width="8rem">
<div class="metadata-section-title">Status</div>
<PatchStateButton
selectedState={patch.state}
onSelect={newState => {
void saveState(newState);
if (status !== undefined && newState.status !== status) {
status = undefined;
void loadPatches(status);
}
}} />
</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
<Button
variant="secondary"
styleHeight="2.5rem"
disabled={hasOwnReview}
onclick={() => {
const id = draftReviewStorage.create(
repo.rid,
selectedRevision.id,
);
void push({
resource: "repo.patch",
rid: repo.rid,
patch: patch.id,
reviewId: id,
status,
});
}}
title={hasOwnReview
? "You already created a review for this revision"
: "Review revision"}>
<Icon name="review" />
<span class="txt-small global-hide-on-medium-desktop-down">
Review revision
</span>
<Tab
active={tab === "patch"}
onclick={() => {
tab = "patch";
}}>
{formatOid(patch.id)}
<span
class="global-counter"
style:height="1.5rem"
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>
</Button>
</div>
</div>
<Border
variant="ghost"
flatTop
styleWidth="100%"
stylePadding="1rem"
styleMinWidth="0"
styleDisplay="block"
styleFlexDirection="column"
styleAlignItems="flex-start">
{#if tab === "patch"}
<div class="global-hide-on-desktop-up" style:margin-top="1rem">
<PatchMetadata
{config}
{loadPatch}
{patch}
{repo}
{saveState}
horizontal />
</div>
<div
class="global-hide-on-desktop-up"
style:padding="0.5rem"
style:margin-bottom="2rem">
<div
class="txt-small"
style:margin-bottom="1rem"
style:color="var(--color-foreground-dim)">
Revisions
</div>
<Revisions
{config}
rid={repo.rid}
selectRevision={rev => {
selectedRevision = rev;
tab = "revisions";
}}
{patch}
{revisions}
{selectedRevision}
{status} />
</div>
<div class="container">
<div style:grid-area="main-content" style:min-width="0">
<RevisionComponent
rid={repo.rid}
repoDelegates={repo.delegates}
patchId={patch.id}
{loadPatch}
{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}
{loadPatch}
{status}
revision={selectedRevision}
{config} />
{/if}
</Border>
<div class="global-flex" style:margin-top="1.5rem">
<NakedButton
variant="ghost"
onclick={() => (hideTimeline = !hideTimeline)}
stylePadding="0 4px">
<Icon name={hideTimeline ? "chevron-right" : "chevron-down"} />
</NakedButton>
<div class="txt-semibold global-flex txt-regular">Timeline</div>
</div>
<div
style:display={hideTimeline ? "none" : "revert"}
style:margin-top="1rem">
<PatchTimeline {activity} patchId={patch.id} />
</div>
</div>
<div
class="global-hide-on-medium-desktop-down"
style:grid-area="right-sidebar"
style:margin-left="1rem"
style:width="22rem">
<PatchMetadata {config} {loadPatch} {patch} {repo} {saveState} />
<div style:margin-top="0.5rem" style:padding="0.5rem">
<div
class="txt-small"
style:margin-bottom="1rem"
style:color="var(--color-foreground-dim)">
Revisions
</div>
<Revisions
{config}
rid={repo.rid}
selectRevision={rev => {
selectedRevision = rev;
tab = "revisions";
}}
{patch}
{revisions}
{selectedRevision}
{status} />
</div>
</div>
</div>
</div>
{/if}
</Layout>