mirror of
https://seed.radicle.dev/rad:z4D5UCArafTzTQpDZNQRuqswh3ury.git
synced 2026-10-31 00:50:47 +08:00
Virtualize the commit diff view
Render only visible diff rows (via virtua) so commit diffs stay smooth at any size.
This commit is contained in:
@@ -1,17 +1,105 @@
|
||||
<script lang="ts">
|
||||
import type { CodeComments } from "@app/components/Diff.svelte";
|
||||
import type { Diff } from "@bindings/diff/Diff";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
|
||||
import type { DiffRow } from "@app/lib/diffRows";
|
||||
import { diffRowKey, flattenDiff } from "@app/lib/diffRows";
|
||||
|
||||
import DiffRowView from "@app/components/DiffRow.svelte";
|
||||
import FileDiffComponent from "@app/components/FileDiff.svelte";
|
||||
import VirtualList from "@app/components/VirtualList.svelte";
|
||||
|
||||
interface Props {
|
||||
codeComments?: CodeComments;
|
||||
diff: Diff;
|
||||
expanded?: boolean;
|
||||
head: string;
|
||||
// Rendered as the first virtualized row (e.g. commit/patch metadata), so
|
||||
// the virtualizer owns the whole scroll content and starts at offset 0.
|
||||
header?: Snippet;
|
||||
}
|
||||
|
||||
const { codeComments, diff, expanded = true, head }: Props = $props();
|
||||
const { codeComments, diff, expanded = true, head, header }: Props = $props();
|
||||
|
||||
// Files the user collapsed (virtualized read-only path only).
|
||||
const collapsed = new SvelteSet<number>();
|
||||
function isFileExpanded(fileIndex: number): boolean {
|
||||
return expanded && !collapsed.has(fileIndex);
|
||||
}
|
||||
function toggleFile(fileIndex: number) {
|
||||
if (collapsed.has(fileIndex)) {
|
||||
collapsed.delete(fileIndex);
|
||||
} else {
|
||||
collapsed.add(fileIndex);
|
||||
}
|
||||
}
|
||||
|
||||
type Row = { type: "header" } | DiffRow;
|
||||
|
||||
const rows = $derived<Row[]>(
|
||||
header
|
||||
? [{ type: "header" }, ...flattenDiff(diff, isFileExpanded)]
|
||||
: flattenDiff(diff, isFileExpanded),
|
||||
);
|
||||
|
||||
function rowKey(row: Row): string {
|
||||
return row.type === "header" ? "header" : diffRowKey(row);
|
||||
}
|
||||
|
||||
let topIndex = $state(0);
|
||||
let scrollOffset = $state(0);
|
||||
let itemOffset = $state<(index: number) => number>(() => 0);
|
||||
let stickyBarHeight = $state(0);
|
||||
|
||||
function onScrollState(s: {
|
||||
topIndex: number;
|
||||
scrollOffset: number;
|
||||
itemOffset: (index: number) => number;
|
||||
}) {
|
||||
topIndex = s.topIndex;
|
||||
scrollOffset = s.scrollOffset;
|
||||
itemOffset = s.itemOffset;
|
||||
}
|
||||
|
||||
// The file whose header is pinned: the topmost row's file, or the file above a
|
||||
// gap row (so the bar persists while transitioning between files). Pinned from
|
||||
// the moment any of a file's rows reaches the top, including its own header.
|
||||
const stickyFileIndex = $derived.by(() => {
|
||||
const r = rows[topIndex];
|
||||
if (!r) return undefined;
|
||||
if (
|
||||
r.type === "file-header" ||
|
||||
r.type === "hunk-header" ||
|
||||
r.type === "line"
|
||||
) {
|
||||
return r.fileIndex;
|
||||
}
|
||||
if (r.type === "file-gap") {
|
||||
return r.fileIndex - 1;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// The next file's header row, used to push the current sticky bar up and out
|
||||
// as the next header rises into it (VSCode-style sticky scroll).
|
||||
const nextHeaderIndex = $derived.by(() => {
|
||||
for (let i = topIndex + 1; i < rows.length; i++) {
|
||||
if (rows[i].type === "file-header") {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const pushOffset = $derived.by(() => {
|
||||
if (nextHeaderIndex === undefined || stickyBarHeight === 0) {
|
||||
return 0;
|
||||
}
|
||||
const nextTop = itemOffset(nextHeaderIndex) - scrollOffset;
|
||||
return nextTop < stickyBarHeight ? stickyBarHeight - nextTop : 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -19,16 +107,63 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.diff:not(:last-of-type) {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
/* Zero-height sticky anchor pinned to the viewport top; the bar overflows
|
||||
from it (absolute) so it overlays the rows without taking flow space. */
|
||||
.sticky-anchor {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
.sticky-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="diff-list">
|
||||
{#each diff.files as file}
|
||||
<div class="diff">
|
||||
<FileDiffComponent {codeComments} {expanded} {file} {head} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if codeComments}
|
||||
<!-- Patch review keeps the comment-capable path until it is virtualized. -->
|
||||
<div class="diff-list">
|
||||
{#each diff.files as file}
|
||||
<div class="diff">
|
||||
<FileDiffComponent {codeComments} {expanded} {file} {head} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="sticky-anchor">
|
||||
{#if stickyFileIndex !== undefined}
|
||||
<div
|
||||
class="sticky-bar"
|
||||
bind:clientHeight={stickyBarHeight}
|
||||
style:transform="translateY({-pushOffset}px)">
|
||||
<DiffRowView
|
||||
row={{
|
||||
type: "file-header",
|
||||
fileIndex: stickyFileIndex,
|
||||
file: diff.files[stickyFileIndex],
|
||||
standalone: false,
|
||||
}}
|
||||
expanded={isFileExpanded(stickyFileIndex)}
|
||||
onToggleFile={toggleFile} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<VirtualList items={rows} getKey={rowKey} {onScrollState}>
|
||||
{#snippet row(r)}
|
||||
{#if r.type === "header"}
|
||||
{@render header?.()}
|
||||
{:else}
|
||||
<DiffRowView
|
||||
row={r}
|
||||
expanded={isFileExpanded(r.fileIndex)}
|
||||
onToggleFile={toggleFile} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</VirtualList>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
<script lang="ts">
|
||||
import type { Modification } from "@bindings/diff/Modification";
|
||||
|
||||
import escape from "lodash/escape";
|
||||
|
||||
import type { DiffRow } from "@app/lib/diffRows";
|
||||
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import Path from "@app/components/Path.svelte";
|
||||
|
||||
interface Props {
|
||||
row: DiffRow;
|
||||
// Whether the row's file is expanded (only meaningful for file-header rows).
|
||||
expanded?: boolean;
|
||||
onToggleFile?: (fileIndex: number) => void;
|
||||
}
|
||||
|
||||
const { row, expanded = true, onToggleFile }: Props = $props();
|
||||
|
||||
function lineNumber(line: Modification, side: "left" | "right") {
|
||||
if (side === "left") {
|
||||
if (line.type === "context") return line.lineNoOld;
|
||||
if (line.type === "deletion") return line.lineNo;
|
||||
} else {
|
||||
if (line.type === "context") return line.lineNoNew;
|
||||
if (line.type === "addition") return line.lineNo;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.file-gap {
|
||||
height: 1rem;
|
||||
}
|
||||
.file-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
height: 2.5rem;
|
||||
margin: 0 1rem;
|
||||
padding: 0 0.5rem;
|
||||
background-color: var(--color-surface-canvas);
|
||||
/* Bottom border present in both states: it's the separator above the lines
|
||||
when expanded, and the card bottom when standalone. Keeping it always
|
||||
avoids a 1px reflow when toggling collapse. */
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-top-left-radius: var(--border-radius-md);
|
||||
border-top-right-radius: var(--border-radius-md);
|
||||
font: var(--txt-body-m-regular);
|
||||
}
|
||||
.file-header.standalone {
|
||||
border-bottom-left-radius: var(--border-radius-md);
|
||||
border-bottom-right-radius: var(--border-radius-md);
|
||||
}
|
||||
.collapse {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.collapse:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.arrow {
|
||||
padding: 0 0.25rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.status {
|
||||
padding: 0 0.375rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
.status.added {
|
||||
color: var(--color-feedback-success-text);
|
||||
background-color: var(--color-feedback-success-bg);
|
||||
}
|
||||
.status.deleted {
|
||||
color: var(--color-feedback-error-text);
|
||||
background-color: var(--color-feedback-error-bg);
|
||||
}
|
||||
.status.moved,
|
||||
.status.copied {
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-subtle);
|
||||
}
|
||||
.stats {
|
||||
margin-left: auto;
|
||||
font: var(--txt-code-regular);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.add {
|
||||
color: var(--color-feedback-success-text);
|
||||
}
|
||||
.del {
|
||||
color: var(--color-feedback-error-text);
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
position: relative;
|
||||
white-space: pre-wrap;
|
||||
margin: 0 1rem;
|
||||
font: var(--txt-code-regular);
|
||||
border-left: 1px solid var(--color-border-subtle);
|
||||
border-right: 1px solid var(--color-border-subtle);
|
||||
background-color: var(--color-surface-canvas);
|
||||
}
|
||||
.line.last {
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
border-bottom-left-radius: var(--border-radius-md);
|
||||
border-bottom-right-radius: var(--border-radius-md);
|
||||
}
|
||||
.hunk-header {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.file-note {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 0;
|
||||
font: var(--txt-body-m-regular);
|
||||
}
|
||||
.addition {
|
||||
background-color: var(--color-feedback-success-bg);
|
||||
}
|
||||
.deletion {
|
||||
background-color: var(--color-feedback-error-bg);
|
||||
}
|
||||
.addition > .num,
|
||||
.addition > .sign {
|
||||
color: var(--color-feedback-success-text);
|
||||
}
|
||||
.deletion > .num,
|
||||
.deletion > .sign {
|
||||
color: var(--color-feedback-error-text);
|
||||
}
|
||||
.context > .num,
|
||||
.context > .sign {
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
.num {
|
||||
min-width: 3rem;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sign {
|
||||
min-width: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.code {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
width: 100%;
|
||||
word-break: break-word;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
{#if row.type === "file-gap"}
|
||||
<div class="file-gap"></div>
|
||||
{:else if row.type === "file-header"}
|
||||
<div class="file-header" class:standalone={row.standalone}>
|
||||
<button
|
||||
class="collapse"
|
||||
onclick={() => onToggleFile?.(row.fileIndex)}
|
||||
title={expanded ? "Collapse file" : "Expand file"}>
|
||||
<Icon name={expanded ? "chevron-down" : "chevron-right"} />
|
||||
</button>
|
||||
{#if row.file.status === "moved" || row.file.status === "copied"}
|
||||
<Path fullPath={row.file.oldPath} />
|
||||
<span class="arrow">→</span>
|
||||
<Path fullPath={row.file.newPath} />
|
||||
{:else}
|
||||
<Path fullPath={row.file.path} />
|
||||
{/if}
|
||||
{#if row.file.status === "added"}
|
||||
<span class="status added">Added</span>
|
||||
{:else if row.file.status === "deleted"}
|
||||
<span class="status deleted">Deleted</span>
|
||||
{:else if row.file.status === "moved"}
|
||||
<span class="status moved">Moved</span>
|
||||
{:else if row.file.status === "copied"}
|
||||
<span class="status copied">Copied</span>
|
||||
{/if}
|
||||
|
||||
<span class="stats">
|
||||
{#if row.file.diff.type === "plain" && row.file.diff.hunks.length > 0}
|
||||
<span class="add">+{row.file.diff.stats.additions}</span>
|
||||
<span class="del">-{row.file.diff.stats.deletions}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{:else if row.type === "hunk-header"}
|
||||
<div class="line hunk-header">
|
||||
<div class="num"></div>
|
||||
<div class="num"></div>
|
||||
<div class="sign"></div>
|
||||
<div class="code">{row.header}</div>
|
||||
</div>
|
||||
{:else if row.type === "file-note"}
|
||||
<div class="line last file-note">
|
||||
{#if row.note === "binary"}
|
||||
<Icon name="binary" />
|
||||
Binary file
|
||||
{:else}
|
||||
Empty file
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{@const line = row.modification}
|
||||
<div
|
||||
class="line"
|
||||
class:addition={line.type === "addition"}
|
||||
class:deletion={line.type === "deletion"}
|
||||
class:context={line.type === "context"}
|
||||
class:last={row.last}>
|
||||
<div class="num">{lineNumber(line, "left") ?? ""}</div>
|
||||
<div class="num">{lineNumber(line, "right") ?? ""}</div>
|
||||
<div class="sign">
|
||||
{#if line.type === "addition"}+{:else if line.type === "deletion"}-{/if}
|
||||
</div>
|
||||
{#if line.highlight && line.highlight.items.length > 0}
|
||||
<div class="code">
|
||||
{@html line.highlight.items
|
||||
.map(
|
||||
paint =>
|
||||
`<span class="global-syntax ${paint.style}">${escape(paint.item)}</span>`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
{:else if line.line !== ""}
|
||||
<div class="code">{line.line}</div>
|
||||
{:else}
|
||||
<div class="code"><br /></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -31,6 +31,13 @@
|
||||
scrollOffset: number;
|
||||
cache: ListCacheSnapshot;
|
||||
}) => void;
|
||||
// Reports scroll state for sticky headers: the topmost visible item index,
|
||||
// the current scroll offset, and a way to get any item's offset from start.
|
||||
onScrollState?: (state: {
|
||||
topIndex: number;
|
||||
scrollOffset: number;
|
||||
itemOffset: (index: number) => number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -47,6 +54,7 @@
|
||||
initialCache = undefined,
|
||||
initialScrollOffset = undefined,
|
||||
onState = undefined,
|
||||
onScrollState = undefined,
|
||||
}: Props = $props();
|
||||
|
||||
const getViewport = getScrollViewport();
|
||||
@@ -75,6 +83,22 @@
|
||||
});
|
||||
}
|
||||
|
||||
function reportScrollState() {
|
||||
const h = handle;
|
||||
if (!h || !onScrollState) return;
|
||||
const scrollOffset = h.getScrollOffset();
|
||||
onScrollState({
|
||||
topIndex: h.findItemIndex(scrollOffset),
|
||||
scrollOffset,
|
||||
itemOffset: index => h.getItemOffset(index),
|
||||
});
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
maybeLoadMore();
|
||||
reportScrollState();
|
||||
}
|
||||
|
||||
function maybeLoadMore() {
|
||||
if (!onLoadMore || !hasMore || loadingMore) return;
|
||||
const el = viewport;
|
||||
@@ -90,7 +114,10 @@
|
||||
// frame, once Virtualizer has sized the scroll container.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
(items.length, hasMore, loadingMore, viewport);
|
||||
const id = requestAnimationFrame(() => maybeLoadMore());
|
||||
const id = requestAnimationFrame(() => {
|
||||
maybeLoadMore();
|
||||
reportScrollState();
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
});
|
||||
</script>
|
||||
@@ -105,7 +132,7 @@
|
||||
{bufferSize}
|
||||
{startMargin}
|
||||
cache={initialCache}
|
||||
onscroll={maybeLoadMore}
|
||||
onscroll={onScroll}
|
||||
onscrollend={reportState}>
|
||||
{#snippet children(item: T, index: number)}
|
||||
{@render row(item, index)}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { Diff } from "@bindings/diff/Diff";
|
||||
import type { FileDiff } from "@bindings/diff/FileDiff";
|
||||
import type { Modification } from "@bindings/diff/Modification";
|
||||
|
||||
// A changeset flattened into a single stream of rows so it can be rendered
|
||||
// through one virtualizer. Collapsed files contribute only their header row.
|
||||
export type DiffRow =
|
||||
// Spacing between file cards; its own row so file-header cards start flush at
|
||||
// the row top and line up exactly with the sticky header bar.
|
||||
| { type: "file-gap"; fileIndex: number }
|
||||
| {
|
||||
type: "file-header";
|
||||
fileIndex: number;
|
||||
file: FileDiff;
|
||||
// No body rows follow (collapsed), so the header is a self-contained
|
||||
// card and needs a full border.
|
||||
standalone: boolean;
|
||||
}
|
||||
| {
|
||||
type: "hunk-header";
|
||||
fileIndex: number;
|
||||
hunkIndex: number;
|
||||
header: string;
|
||||
}
|
||||
// The file's only body row when there are no hunks to render, telling the
|
||||
// user why (binary content vs. an empty file) instead of a bare header.
|
||||
| {
|
||||
type: "file-note";
|
||||
fileIndex: number;
|
||||
note: "binary" | "empty";
|
||||
}
|
||||
| {
|
||||
type: "line";
|
||||
fileIndex: number;
|
||||
hunkIndex: number;
|
||||
lineIndex: number;
|
||||
file: FileDiff;
|
||||
modification: Modification;
|
||||
// Last line of the file, so it closes the card (bottom border + radius).
|
||||
last: boolean;
|
||||
};
|
||||
|
||||
export function flattenDiff(
|
||||
diff: Diff,
|
||||
isExpanded: (fileIndex: number) => boolean,
|
||||
): DiffRow[] {
|
||||
const rows: DiffRow[] = [];
|
||||
diff.files.forEach((file, fileIndex) => {
|
||||
if (fileIndex > 0) {
|
||||
rows.push({ type: "file-gap", fileIndex });
|
||||
}
|
||||
const expanded = isExpanded(fileIndex);
|
||||
rows.push({ type: "file-header", fileIndex, file, standalone: !expanded });
|
||||
if (!expanded) {
|
||||
return;
|
||||
}
|
||||
if (file.diff.type !== "plain" || file.diff.hunks.length === 0) {
|
||||
rows.push({
|
||||
type: "file-note",
|
||||
fileIndex,
|
||||
note: file.diff.type === "binary" ? "binary" : "empty",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const lineRows: DiffRow[] = [];
|
||||
file.diff.hunks.forEach((hunk, hunkIndex) => {
|
||||
lineRows.push({
|
||||
type: "hunk-header",
|
||||
fileIndex,
|
||||
hunkIndex,
|
||||
header: hunk.header,
|
||||
});
|
||||
hunk.lines.forEach((modification, lineIndex) => {
|
||||
lineRows.push({
|
||||
type: "line",
|
||||
fileIndex,
|
||||
hunkIndex,
|
||||
lineIndex,
|
||||
file,
|
||||
modification,
|
||||
last: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
const lastLine = lineRows.findLast(r => r.type === "line");
|
||||
if (lastLine && lastLine.type === "line") {
|
||||
lastLine.last = true;
|
||||
}
|
||||
rows.push(...lineRows);
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function diffRowKey(row: DiffRow): string {
|
||||
switch (row.type) {
|
||||
case "file-gap":
|
||||
return `g:${row.fileIndex}`;
|
||||
case "file-header":
|
||||
return `f:${row.fileIndex}`;
|
||||
case "hunk-header":
|
||||
return `h:${row.fileIndex}:${row.hunkIndex}`;
|
||||
case "file-note":
|
||||
return `n:${row.fileIndex}`;
|
||||
case "line":
|
||||
return `l:${row.fileIndex}:${row.hunkIndex}:${row.lineIndex}`;
|
||||
}
|
||||
}
|
||||
@@ -56,14 +56,8 @@
|
||||
.breadcrumb-link:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.content {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.meta {
|
||||
padding: 0.25rem 0 0.5rem;
|
||||
padding: 1rem 1rem 0.5rem;
|
||||
}
|
||||
.meta-header {
|
||||
display: flex;
|
||||
@@ -172,72 +166,72 @@
|
||||
</div>
|
||||
</Topbar>
|
||||
<ScrollArea style="height: 100%; min-width: 0;">
|
||||
<div class="content">
|
||||
<section class="meta">
|
||||
<div class="meta-header">
|
||||
<div class="meta-title">
|
||||
<div class="summary txt-selectable">
|
||||
{#if !commit.summary}
|
||||
<span class="txt-missing">No commit message</span>
|
||||
{:else}
|
||||
{commit.summary}
|
||||
{/if}
|
||||
<Changeset {diff} head={commit.id}>
|
||||
{#snippet header()}
|
||||
<section class="meta">
|
||||
<div class="meta-header">
|
||||
<div class="meta-title">
|
||||
<div class="summary txt-selectable">
|
||||
{#if !commit.summary}
|
||||
<span class="txt-missing">No commit message</span>
|
||||
{:else}
|
||||
{commit.summary}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="summary-meta">
|
||||
<span class="summary-author">
|
||||
<img
|
||||
class="summary-avatar"
|
||||
alt=""
|
||||
src={gravatarURL(commit.author.email)} />
|
||||
<span class="txt-selectable">{commit.author.name}</span>
|
||||
</span>
|
||||
committed
|
||||
<Id id={commit.id} clipboard={commit.id} />
|
||||
<span
|
||||
class="summary-timestamp"
|
||||
title={absoluteTimestamp(commit.committer.time * 1000)}>
|
||||
{formatTimestamp(commit.committer.time * 1000)}
|
||||
</span>
|
||||
<JobCob rid={repo.rid} commit={commit.id} />
|
||||
</div>
|
||||
<div class="summary-parents">
|
||||
<span class="summary-parents-label">
|
||||
{commit.parents.length === 1 ? "parent" : "parents"}
|
||||
</span>
|
||||
{#if commit.parents.length === 0}
|
||||
<span>Initial commit</span>
|
||||
{:else}
|
||||
{#each commit.parents as parent}
|
||||
<button
|
||||
class="parent-link txt-id"
|
||||
onclick={() => {
|
||||
void router.push({
|
||||
resource: "repo.commit",
|
||||
rid: repo.rid,
|
||||
commit: parent,
|
||||
});
|
||||
}}>
|
||||
{formatOid(parent)}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<pre class="summary-message txt-selectable">{commit.message
|
||||
.replace(commit.summary, "")
|
||||
.trim()}</pre>
|
||||
</div>
|
||||
<div class="summary-meta">
|
||||
<span class="summary-author">
|
||||
<img
|
||||
class="summary-avatar"
|
||||
alt=""
|
||||
src={gravatarURL(commit.author.email)} />
|
||||
<span class="txt-selectable">{commit.author.name}</span>
|
||||
</span>
|
||||
committed
|
||||
<Id id={commit.id} clipboard={commit.id} />
|
||||
<span
|
||||
class="summary-timestamp"
|
||||
title={absoluteTimestamp(commit.committer.time * 1000)}>
|
||||
{formatTimestamp(commit.committer.time * 1000)}
|
||||
</span>
|
||||
<JobCob rid={repo.rid} commit={commit.id} />
|
||||
<div class="chips">
|
||||
<div class="files-chip">
|
||||
{diff.stats.filesChanged}
|
||||
{pluralize("file", diff.stats.filesChanged)} changed
|
||||
</div>
|
||||
<DiffStatBadge stats={diff.stats} />
|
||||
</div>
|
||||
<div class="summary-parents">
|
||||
<span class="summary-parents-label">
|
||||
{commit.parents.length === 1 ? "parent" : "parents"}
|
||||
</span>
|
||||
{#if commit.parents.length === 0}
|
||||
<span>Initial commit</span>
|
||||
{:else}
|
||||
{#each commit.parents as parent}
|
||||
<button
|
||||
class="parent-link txt-id"
|
||||
onclick={() => {
|
||||
void router.push({
|
||||
resource: "repo.commit",
|
||||
rid: repo.rid,
|
||||
commit: parent,
|
||||
});
|
||||
}}>
|
||||
{formatOid(parent)}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<pre class="summary-message txt-selectable">{commit.message
|
||||
.replace(commit.summary, "")
|
||||
.trim()}</pre>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<div class="files-chip">
|
||||
{diff.stats.filesChanged}
|
||||
{pluralize("file", diff.stats.filesChanged)} changed
|
||||
</div>
|
||||
<DiffStatBadge stats={diff.stats} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Changeset {diff} head={commit.id} />
|
||||
</div>
|
||||
</section>
|
||||
{/snippet}
|
||||
</Changeset>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
Reference in New Issue
Block a user