feat(github): pull request tool window, timeline and review flow (#477)

Replaces the Pull Requests tool window with a full review workflow on top of gh's authentication.

- List with search, quick filters and paging; a tab per pull request and a New Pull Request tab.
- Details: commit picker, changes tree with viewed state, CI and reviewer status, merge, close and reopen.
- Timeline editor: comments, review threads, reactions and suggested changes.
- Review in the diff viewer and, for the checked-out branch, in the editor (review mode).
- New generic vcs extension points: registerDiffViewerProvider, lineInlays, reviewGutter, branch widget extensions; new vcs_merge_base and gh_api commands.
- CSP: img-src allows https://avatars.githubusercontent.com for avatars.

Fixed while testing against a real repository: remotes behind an SSH host alias, mergeability stuck on UNKNOWN, Apply Locally staying disabled after a checkout, relative links navigating the app's page, and review inlays wider than the pane.

Co-authored-by: imacross <[email protected]>
This commit is contained in:
imacross authored and GitHub committed 2026-10-31 07:50:43 +08:00
1 parent 696377acc2
commit 059fd8a69b
192 files changed
+23668 -2076

No files matched your search

+132
View File
@@ -0,0 +1,132 @@
/**
* GitHub avatars (collaboration-tools' `Avatar` / `AvatarIconWithOutline`): a circle
* loaded from avatars.githubusercontent.com at twice the size for HiDPI, falling back
* to the user's initial when the image does not load. `ring` outlines it in a review
* state colour (2px, 1px gap), as the list's reviewer avatars and the status section.
*/
import { useState, type CSSProperties } from "react";
import clsx from "clsx";
import type { GHPRReviewerState } from "../api/types";
import styles from "./ui.module.css";
/** `Avatar.Sizes`. */
export const AVATAR_SIZE = {
/** Mentions in comments. */
SMALL: 15,
/** Reviewer selectors, replies, list rows. */
BASE: 20,
/** Avatars with a state ring (list reviewers, status section). */
OUTLINED: 18,
/** Timeline items. */
TIMELINE: 30,
ACCOUNT: 40,
} as const;
/** The ring colour of a reviewer state (approved green, changes requested orange, else grey). */
export function reviewerRingColor(state: GHPRReviewerState): string {
if (state === "APPROVED") return "var(--gh-review-approved)";
if (state === "CHANGES_REQUESTED") return "var(--gh-review-changes)";
return "var(--gh-review-neutral)";
}
/** `url` sized for `px` CSS pixels (GitHub serves `s=`); other URLs as they are. */
export function avatarSrc(url: string, px: number): string {
if (!/^https:\/\/avatars\.githubusercontent\.com\//.test(url)) return url;
const u = new URL(url);
u.searchParams.set("s", String(Math.round(px * 2)));
return u.toString();
}
const FALLBACK_COLORS = 8;
function fallbackColor(key: string): string {
let h = 0;
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) | 0;
return `var(--gh-avatar-${Math.abs(h) % FALLBACK_COLORS})`;
}
export interface AvatarProps {
/** Image URL (`avatarUrl` / `teamAvatarUrl`); none shows the initial. */
url: string | null | undefined;
/** Login or name: the fallback initial and the default tooltip. */
name: string;
size?: number;
/** Outline colour (a CSS colour or `reviewerRingColor(state)`). */
ring?: string;
/** Tooltip; defaults to `name`, `null` for none. */
title?: string | null;
className?: string;
style?: CSSProperties;
}
export function Avatar({ url, name, size = AVATAR_SIZE.BASE, ring, title, className, style: extra }: AvatarProps) {
const [failed, setFailed] = useState<string | null>(null);
const showImage = !!url && failed !== url;
const style = {
width: size,
height: size,
fontSize: Math.max(8, Math.round(size * 0.5)),
...(showImage ? {} : { background: fallbackColor(name) }),
...(ring ? { "--ring": ring } : {}),
...extra,
} as CSSProperties;
return (
<span
className={clsx(styles.avatar, ring && styles.ringed, className)}
style={style}
title={title === null ? undefined : (title ?? name)}
data-avatar={name}
>
{showImage ? (
<img src={avatarSrc(url, size)} alt="" draggable={false} onError={() => setFailed(url)} />
) : (
(name.trim()[0] ?? "?").toUpperCase()
)}
</span>
);
}
export interface AvatarStackItem {
key: string;
url: string | null | undefined;
name: string;
ring?: string;
}
/** Overlapping avatars, at most `max` of them (the list row's assignees and reviewers: 2). */
export function AvatarStack({
items,
max = 2,
size = AVATAR_SIZE.BASE,
title,
className,
}: {
items: readonly AvatarStackItem[];
max?: number;
size?: number;
/** Tooltip of the whole stack (IntelliJ shows a popup listing them on hover); `null` for none at all. */
title?: string | null;
className?: string;
}) {
if (!items.length) return null;
const shown = items.slice(0, max);
return (
<span
className={clsx(styles.stack, className)}
style={{ "--overlap": `${Math.round(size / 3)}px` } as CSSProperties}
title={title ?? undefined}
>
{shown.map((a, i) => (
<Avatar
key={a.key}
url={a.url}
name={a.name}
size={size}
ring={a.ring}
title={title || title === null ? null : undefined}
style={{ zIndex: shown.length - i }}
/>
))}
</span>
);
}
+232
View File
@@ -0,0 +1,232 @@
/**
* IntelliJ's chooser popups (`ChooserPopupUtil`): a search field over a list whose rows
* show an avatar / icon / colour swatch, a short text and a grey full text. Items may
* arrive incrementally (`loading` paints a busy row). The filter matches the short and
* full texts.
*
* - `single`: Enter / click chooses the item and closes (`onChoose`).
* - `multi`: Enter / Space / click toggles the check mark; the popup stays open and
* reports the chosen items when it closes (`onClose(chosen)`), as the reviewer,
* assignee and label pickers apply their change on close. The items chosen when it
* opened are listed first, even before they load.
*
* <ChooserPopup mode="multi" anchor={button} items={users.items} loading={users.loading}
* chosen={current} getKey={(u) => u.id} present={(u) => ({ text: u.login, detail: u.name,
* avatarUrl: u.avatarUrl })} onClose={(chosen) => chosen && apply(chosen)} />
*/
import { useMemo, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from "react";
import type { Placement } from "@floating-ui/react-dom";
import {
HighlightedText,
Icon,
List,
Popup,
SearchField,
Spinner,
fuzzyMatch,
listNavigate,
T,
type PopupAnchor,
} from "@/ui";
import { useTx } from "@/platform/i18n";
import { Avatar, AVATAR_SIZE } from "./Avatar";
import styles from "./ui.module.css";
/** `PopupItemPresentation`: what a row shows. */
export interface ChooserItemPresentation {
/** Short text (login, label name); matched by the filter. */
text: string;
/** Grey text after it (full name); also matched. */
detail?: string | null;
/** An avatar row (users, teams). */
avatarUrl?: string | null;
/** An icon name from `ui/icons`. */
icon?: string;
/** A leading element instead (a label's colour swatch). */
leading?: ReactNode;
}
interface CommonProps<T> {
anchor: PopupAnchor;
items: readonly T[];
getKey: (item: T) => string;
present: (item: T) => ChooserItemPresentation;
/** More items are still loading. */
loading?: boolean;
/** Loading failed ("Something went wrong" when there is no message). */
error?: string | null;
placeholder?: string;
placement?: Placement;
/** Initial size (IntelliJ: 250×300, resizable). */
width?: number;
height?: number;
"aria-label": string;
}
export interface SingleChooserProps<T> extends CommonProps<T> {
mode: "single";
onChoose: (item: T) => void;
onClose: () => void;
}
export interface MultiChooserProps<T> extends CommonProps<T> {
mode: "multi";
/** Chosen when the popup opens; listed first. */
chosen: readonly T[];
/** The chosen items when the popup closes (Escape included, as in IntelliJ). */
onClose: (chosen: T[]) => void;
}
export type ChooserPopupProps<T> = SingleChooserProps<T> | MultiChooserProps<T>;
const ROW_HEIGHT = 28;
interface Row<T> {
item: T;
key: string;
p: ChooserItemPresentation;
textMatch: number[] | null;
detailMatch: number[] | null;
}
/** Items to list: the initially chosen ones first, then the loaded ones (deduplicated by key). */
export function chooserItems<T>(chosenFirst: readonly T[], loaded: readonly T[], getKey: (item: T) => string): T[] {
const seen = new Set<string>();
const out: T[] = [];
for (const item of [...chosenFirst, ...loaded]) {
const k = getKey(item);
if (!seen.has(k)) {
seen.add(k);
out.push(item);
}
}
return out;
}
export function ChooserPopup<T>(props: ChooserPopupProps<T>) {
const { anchor, items, getKey, present, loading, error, placement = "bottom-start" } = props;
const tx = useTx();
const multi = props.mode === "multi";
const initialChosen = multi ? props.chosen : null;
const [initial] = useState(() => initialChosen ?? []);
const [chosen, setChosen] = useState<ReadonlySet<string>>(() => new Set((initialChosen ?? []).map(getKey)));
const [filter, setFilter] = useState("");
const [active, setActive] = useState(0);
const all = useMemo(() => chooserItems(initial, items, getKey), [initial, items, getKey]);
const rows = useMemo(() => {
const q = filter.trim();
return all.flatMap((item): Row<T>[] => {
const p = present(item);
if (!q) return [{ item, key: getKey(item), p, textMatch: null, detailMatch: null }];
const textMatch = fuzzyMatch(p.text, q);
const detailMatch = p.detail ? fuzzyMatch(p.detail, q) : null;
return textMatch || detailMatch ? [{ item, key: getKey(item), p, textMatch, detailMatch }] : [];
});
}, [all, filter, present, getKey]);
const current = Math.min(active, rows.length - 1);
const close = () => {
if (props.mode === "multi") props.onClose(all.filter((i) => chosen.has(getKey(i))));
else props.onClose();
};
const activate = (row: Row<T> | undefined) => {
if (!row) return;
if (props.mode === "single") {
props.onChoose(row.item);
props.onClose();
return;
}
setChosen((cur) => {
const next = new Set(cur);
if (next.has(row.key)) next.delete(row.key);
else next.add(row.key);
return next;
});
};
const onKeyDown = (e: KeyboardEvent) => {
const next = listNavigate(e.key, { items: rows, active: current, wrap: true, pageSize: 7 });
if (next !== null && e.key !== "Home" && e.key !== "End") {
e.preventDefault();
setActive(next);
} else if (e.key === "Enter" || (multi && e.key === " " && !filter)) {
e.preventDefault();
activate(rows[current]);
}
};
const status = error ? (
<div className={`${styles.chooserStatus} ${styles.chooserError}`}>{error || tx("Something went wrong")}</div>
) : loading ? (
<div className={styles.chooserStatus}>
<Spinner size={12} />
<T>Loading…</T>
</div>
) : null;
return (
<Popup
open
anchor={anchor}
placement={placement}
offset={4}
onClose={close}
role="dialog"
aria-label={props["aria-label"]}
className={styles.chooser}
style={
{
"--chooser-width": `${props.width ?? 250}px`,
"--chooser-height": `${props.height ?? 300}px`,
} as CSSProperties
}
>
<div className={styles.chooserSearch}>
<SearchField
value={filter}
onChange={(v) => {
setFilter(v);
setActive(0);
}}
onKeyDown={onKeyDown}
placeholder={props.placeholder}
aria-label={props["aria-label"]}
data-autofocus
/>
</div>
<List
items={rows}
getKey={(r) => r.key}
activeIndex={current}
onActiveChange={setActive}
onActivate={(r) => activate(r)}
focusable={false}
hoverSelects
rowHeight={ROW_HEIGHT}
className={styles.chooserList}
aria-label={props["aria-label"]}
empty={loading || error ? null : <div className={styles.chooserEmpty}>{tx("Nothing to show")}</div>}
renderItem={(r, state) => (
<div className={styles.chooserRow} data-active={state.active || undefined}>
{multi && (
<span className={styles.chooserCheck} aria-hidden>
{chosen.has(r.key) && <Icon name="check" size={16} />}
</span>
)}
{r.p.leading ??
(r.p.avatarUrl !== undefined ? (
<Avatar url={r.p.avatarUrl} name={r.p.text} size={AVATAR_SIZE.BASE} title={null} />
) : r.p.icon ? (
<Icon name={r.p.icon} size={16} />
) : null)}
<HighlightedText className={styles.chooserText} text={r.p.text} indices={r.textMatch} />
{r.p.detail && (
<HighlightedText className={styles.chooserDetail} text={r.p.detail} indices={r.detailMatch} />
)}
</div>
)}
/>
{status}
</Popup>
);
}
+42
View File
@@ -0,0 +1,42 @@
/**
* A GitHub body: Markdown (`body` of descriptions, comments, reviews) or GitHub's HTML
* (`dismissalMessageHTML`), rendered by the editor's Markdown pipeline (marked + DOMPurify,
* which also sanitizes HTML). Web links open in the browser.
*/
import clsx from "clsx";
import { useMarkupHtml } from "@/editor/lsp/markdown";
import { T } from "@/ui";
import { openInBrowser } from "../data/local";
import styles from "./ui.module.css";
export interface MarkdownProps {
/** Markdown, or HTML (Markdown passes HTML through). */
text: string;
/** Shown in grey italics for an empty body ("No description provided"); nothing when unset. */
emptyText?: string;
className?: string;
}
export function Markdown({ text, emptyText, className }: MarkdownProps) {
const html = useMarkupHtml({ kind: "markdown", value: text });
if (!text.trim())
return emptyText ? (
<div className={clsx(styles.markdownEmpty, className)}>
<T>{emptyText}</T>
</div>
) : null;
return (
<div
className={clsx(styles.markdown, className)}
onClick={(e) => {
const a = (e.target as HTMLElement).closest("a");
if (!a) return;
// Other links (relative ones in a pull request's text) must not navigate the app's page.
e.preventDefault();
const href = a.getAttribute("href") ?? "";
if (/^https?:/i.test(href)) void openInBrowser(href);
}}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
+15
View File
@@ -0,0 +1,15 @@
/** The marked placeholder of a part of the port that is not built yet (foundation stubs). */
import type { ReactNode } from "react";
import { T } from "@/ui";
import styles from "./ui.module.css";
export function Placeholder({ title, children }: { title: string; children?: ReactNode }) {
return (
<div className={styles.placeholder} data-placeholder={title}>
<div className={styles.placeholderTitle}>
<T>{title}</T>
</div>
{children}
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Small pull request badges: the state tag (IntelliJ's `Review.State` pill), label chips
* (the label's colour, as the timeline and create form show them) and branch chips.
*/
import clsx from "clsx";
import { Icon } from "@/ui";
import type { GHLabel, GHPullRequestState } from "../api/types";
import { stateTagText } from "../data/model";
import styles from "./ui.module.css";
/** DRAFT / CLOSED / MERGED pill; nothing for an open pull request. */
export function StateTag({
state,
isDraft,
className,
}: {
state: GHPullRequestState;
isDraft: boolean;
className?: string;
}) {
const text = stateTagText(state, isDraft);
if (!text) return null;
return <span className={clsx(styles.stateTag, className)}>{text}</span>;
}
/** Black or white, whichever reads better on `hex` (`RRGGBB`, no `#`). */
export function labelForeground(hex: string): string {
const n = Number.parseInt(hex.replace(/^#/, ""), 16);
if (Number.isNaN(n)) return "var(--fg)";
const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
return 0.299 * r + 0.587 * g + 0.114 * b > 150 ? "#000000" : "#ffffff";
}
/** `#RRGGBB` of a GitHub label colour. */
export const labelColor = (label: Pick<GHLabel, "color">) => `#${label.color.replace(/^#/, "")}`;
export function LabelChip({ label, className }: { label: Pick<GHLabel, "name" | "color">; className?: string }) {
return (
<span
className={clsx(styles.labelChip, className)}
style={{ background: labelColor(label), color: labelForeground(label.color) }}
title={label.name}
>
{label.name}
</span>
);
}
/** The 16px colour square of label pickers. */
export function LabelSwatch({ label }: { label: Pick<GHLabel, "color"> }) {
return <span className={styles.labelSwatch} style={{ background: labelColor(label) }} aria-hidden />;
}
/** A branch name with the branch icon (`current`: the checked-out branch's icon). */
export function BranchChip({ name, current, className }: { name: string; current?: boolean; className?: string }) {
return (
<span className={clsx(styles.branchChip, className)} title={name}>
<Icon name={current ? "current-branch-label" : "branch-label"} size={16} />
<span>{name}</span>
</span>
);
}
+133
View File
@@ -0,0 +1,133 @@
/**
* Promise-based dialogs of the GitHub feature, rendered by one overlay (`GithubDialogHost`):
*
* const message = await mergeCommitMessageDialog({ title: "Merge Review", message });
*
* `openDialog` lets other parts add their own (it resolves with what `done` is given,
* null when cancelled). Included: IntelliJ's merge commit message dialog, the pull request
* number prompt of Git › GitHub › Checkout Pull Request….
*/
import { Fragment, useState, type ReactNode } from "react";
import { create } from "zustand";
import { tx } from "@/platform/i18n";
import { Dialog, Input, T } from "@/ui";
import styles from "./ui.module.css";
interface Entry {
id: number;
node: ReactNode;
}
const useDialogs = create<{ stack: Entry[] }>(() => ({ stack: [] }));
let seq = 0;
/** Shows `render(done)` until `done` is called; resolves with its value (null: cancelled). */
export function openDialog<T>(render: (done: (v: T | null) => void) => ReactNode): Promise<T | null> {
return new Promise((resolve) => {
const id = ++seq;
const done = (v: T | null) => {
useDialogs.setState((s) => ({ stack: s.stack.filter((e) => e.id !== id) }));
resolve(v);
};
useDialogs.setState((s) => ({ stack: [...s.stack, { id, node: render(done) }] }));
});
}
export function GithubDialogHost() {
const stack = useDialogs((s) => s.stack);
return (
<>
{stack.map((e) => (
<Fragment key={e.id}>{e.node}</Fragment>
))}
</>
);
}
// ---------------------------------------------------------------------------
// Merge commit message (`ReviewMergeCommitMessageDialog`)
/**
* "Merge Review" / "Squash and Merge Review": the commit message to merge with (subject,
* blank line, body); resolves with the edited message.
*/
export function mergeCommitMessageDialog(opts: { title: string; message: string }) {
return openDialog<string>((done) => <MergeDialog {...opts} onDone={done} />);
}
function MergeDialog(props: { title: string; message: string; onDone: (v: string | null) => void }) {
const [message, setMessage] = useState(props.message);
return (
<Dialog
open
title={props.title}
onClose={() => props.onDone(null)}
width={540}
buttons={[
{ label: "Merge", role: "default", disabled: !message.trim(), onClick: () => props.onDone(message) },
{ label: "Cancel", role: "cancel" },
]}
>
<div className={styles.form}>
<label className={styles.fieldLabel}>
<T>Merge commit message:</T>
</label>
<textarea
data-autofocus
aria-label={tx("Merge commit message")}
className={styles.textarea}
style={{ minHeight: 85 }}
rows={6}
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
</div>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Pull request number
/** Asks for a pull request number (Git › GitHub › Checkout Pull Request…). */
export function numberDialog(title: string) {
return openDialog<number>((done) => <NumberDialog title={title} onDone={done} />);
}
function NumberDialog(props: { title: string; onDone: (v: number | null) => void }) {
const [value, setValue] = useState("");
const n = Number.parseInt(value.replace(/^#/, ""), 10);
const ok = Number.isInteger(n) && n > 0;
return (
<Dialog
open
title={props.title}
onClose={() => props.onDone(null)}
width={360}
buttons={[
{ label: "OK", role: "default", disabled: !ok, onClick: () => props.onDone(n) },
{ label: "Cancel", role: "cancel" },
]}
>
<form
className={styles.form}
onSubmit={(e) => {
e.preventDefault();
if (ok) props.onDone(n);
}}
>
<label className={styles.fieldLabel}>
<T>Pull request number:</T>
</label>
<Input
data-autofocus
mono
aria-label="Pull request number"
placeholder="#123"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</form>
</Dialog>
);
}
+40
View File
@@ -0,0 +1,40 @@
/* The pull request views' fixed colours, in the dark themes and the light ones; themes do not change them. */
:root {
--gh-unread: #548af7;
--gh-tag-fg: #868a91;
--gh-tag-bg: #43454a;
--gh-review-approved: #57965c;
--gh-review-changes: #e08855;
--gh-review-neutral: #6f737a;
--gh-conflict: #e05555;
--gh-status-info: #40b6e0b2;
--gh-status-secondary: #9aa7b0b3;
--gh-status-success: #62b543b3;
--gh-status-error: #f26522b3;
--gh-anchor-line: #544b2d;
--gh-reaction-bg: #2b2d30;
--gh-reaction-bg-hover: #393b40;
--gh-reaction-border-reacted: #548af7;
--gh-bubble-border: var(--border);
--gh-avatar-fg: #ffffff;
--gh-avatar-0: #5e8adf;
--gh-avatar-1: #c75450;
--gh-avatar-2: #4f9a62;
--gh-avatar-3: #b97e2f;
--gh-avatar-4: #8f62c8;
--gh-avatar-5: #3d9ba3;
--gh-avatar-6: #b9578e;
--gh-avatar-7: #6e7b8a;
}
:root[data-theme="light"] {
--gh-unread: #3574f0;
--gh-tag-fg: #6c707e;
--gh-tag-bg: #dfe1e5;
--gh-review-approved: #5fb865;
--gh-review-changes: #ec8f4c;
--gh-review-neutral: #818594;
--gh-anchor-line: #fbf1d1;
--gh-reaction-bg: #ebecf0;
--gh-reaction-bg-hover: #dfe1e5;
--gh-reaction-border-reacted: #3574f0;
}
+46
View File
@@ -0,0 +1,46 @@
/**
* IntelliJ's `DateFormatUtil.formatPrettyDate` / `formatPrettyDateTime`, honouring
* Settings › Date Formats: "Moments ago", "N minutes ago" within the hour, "Today" /
* "Yesterday" (with the time for `prettyDateTime`), else the date. With pretty
* formatting off, the plain date (and time).
*/
import { dateFormatOptions, formatClock, formatDay, type DateFormatOptions } from "@/platform/dateFormat";
import { tx } from "@/platform/i18n";
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
function pretty(ms: number, withTime: boolean, now: number, o: DateFormatOptions): string {
const d = new Date(ms);
const time = withTime ? ` ${formatClock(d, o)}` : "";
if (o.pretty) {
const delta = now - ms;
const today = new Date(now);
if (d.toDateString() === today.toDateString()) {
if (delta >= 0 && delta < MINUTE) return tx("Moments ago");
if (delta >= 0 && delta <= HOUR) {
const n = Math.floor(delta / MINUTE);
return n === 1 ? tx("1 minute ago") : tx`${n} minutes ago`;
}
return withTime ? tx`Today ${formatClock(d, o)}` : tx("Today");
}
if (d.toDateString() === new Date(now - 86_400_000).toDateString())
return withTime ? tx`Yesterday ${formatClock(d, o)}` : tx("Yesterday");
}
return `${formatDay(d, o)}${time}`;
}
const toMs = (date: string | number | Date) =>
typeof date === "number" ? date : date instanceof Date ? date.getTime() : Date.parse(date);
/** `formatPrettyDate`: list rows ("created {date}"). Empty for an invalid date. */
export function prettyDate(date: string | number | Date, now = Date.now(), o = dateFormatOptions()): string {
const ms = toMs(date);
return Number.isNaN(ms) ? "" : pretty(ms, false, now, o);
}
/** `formatPrettyDateTime`: timeline headers, commits, replies. */
export function prettyDateTime(date: string | number | Date, now = Date.now(), o = dateFormatOptions()): string {
const ms = toMs(date);
return Number.isNaN(ms) ? "" : pretty(ms, true, now, o);
}
+270
View File
@@ -0,0 +1,270 @@
/* Shared GitHub UI. */
/* Avatar: a circle; the ring (outlined avatars) is 2px with a 1px gap, outside the size. */
.avatar {
position: relative;
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 50%;
background: var(--bg-hover);
color: var(--gh-avatar-fg);
font-weight: 600;
line-height: 1;
user-select: none;
vertical-align: middle;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.ringed {
margin: 3px;
overflow: visible;
outline: 2px solid var(--ring);
outline-offset: 1px;
}
.ringed img {
border-radius: 50%;
}
/* AvatarStack: overlapping avatars, later ones under earlier ones. */
.stack {
display: inline-flex;
flex: none;
align-items: center;
}
.stack > * + * {
margin-left: calc(var(--overlap) * -1);
}
.stack > .ringed + .ringed {
margin-left: calc(var(--overlap) * -1 + 3px);
}
/* State tag (DRAFT / CLOSED / MERGED). */
.stateTag {
display: inline-flex;
flex: none;
align-items: center;
height: 16px;
padding: 0 4px;
border-radius: 4px;
background: var(--gh-tag-bg);
color: var(--gh-tag-fg);
font-size: var(--fs-tiny);
line-height: 16px;
white-space: nowrap;
}
/* Label chip: the label's colour as background, readable text on it. */
.labelChip {
display: inline-flex;
flex: none;
align-items: center;
max-width: 100%;
height: 18px;
padding: 0 6px;
overflow: hidden;
border-radius: 9px;
font-size: var(--fs-small);
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.labelSwatch {
display: inline-block;
flex: none;
width: 16px;
height: 16px;
border-radius: 3px;
}
/* Branch chip: branch icon and name. */
.branchChip {
display: inline-flex;
flex: none;
align-items: center;
gap: 2px;
max-width: 100%;
color: var(--fg);
white-space: nowrap;
}
.branchChip > span {
overflow: hidden;
text-overflow: ellipsis;
}
/* Chooser popup */
.chooser {
display: flex;
flex-direction: column;
width: var(--chooser-width, 250px);
min-width: 160px;
height: var(--chooser-height, 300px);
min-height: 120px;
overflow: hidden;
resize: both;
}
.chooserSearch {
flex: none;
padding: 6px 8px 4px;
}
.chooserList {
flex: 1;
min-height: 0;
padding-bottom: 4px;
}
.chooserRow {
display: flex;
align-items: center;
gap: 6px;
height: 100%;
padding: 0 8px;
overflow: hidden;
white-space: nowrap;
}
.chooserRow[data-active] {
background: var(--bg-list-selected);
border-radius: var(--radius-selection);
}
.chooserText {
flex: none;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.chooserDetail {
min-width: 0;
overflow: hidden;
color: var(--fg-secondary);
text-overflow: ellipsis;
}
.chooserCheck {
display: inline-flex;
flex: none;
width: 16px;
justify-content: center;
}
.chooserStatus {
display: flex;
flex: none;
align-items: center;
gap: 6px;
padding: 4px 10px 6px;
color: var(--fg-secondary);
font-size: var(--fs-small);
}
.chooserError {
color: var(--danger);
}
.chooserEmpty {
padding: 8px 10px;
color: var(--fg-faint);
}
/* Markdown bodies */
.markdown {
color: var(--fg);
line-height: 1.5;
overflow-wrap: anywhere;
user-select: text;
}
.markdown > :first-child {
margin-top: 0;
}
.markdown > :last-child {
margin-bottom: 0;
}
.markdown :global(pre) {
padding: 6px 8px;
overflow: auto;
background: var(--bg-editor);
border-radius: var(--radius-small);
font-family: var(--font-mono);
font-size: var(--fs-code);
}
.markdown :global(code) {
font-family: var(--font-mono);
}
.markdown :global(a) {
color: var(--link);
}
.markdown :global(img) {
max-width: 100%;
}
.markdown :global(blockquote) {
margin: 0 0 8px;
padding: 0 10px;
border-left: 3px solid var(--border-strong);
color: var(--fg-secondary);
}
.markdown :global(table) {
border-collapse: collapse;
}
.markdown :global(th),
.markdown :global(td) {
padding: 2px 8px;
border: 1px solid var(--border-strong);
}
.markdown :global(h1),
.markdown :global(h2),
.markdown :global(h3) {
font-size: var(--fs-ui);
color: var(--fg-strong);
}
.markdownEmpty {
color: var(--fg-secondary);
font-style: italic;
}
/* Stub panels of the parts still to be ported. */
.placeholder {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 0;
padding: 16px;
color: var(--fg-secondary);
text-align: center;
}
.placeholderTitle {
color: var(--fg);
font-weight: 600;
}
/* Dialogs */
.form {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
}
.formRow {
display: flex;
align-items: center;
gap: 10px;
}
.fieldLabel {
color: var(--fg-secondary);
font-size: var(--fs-small);
}
.textarea {
resize: vertical;
min-height: 48px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-small);
background: var(--bg-input);
color: var(--fg);
font: inherit;
}
.textarea:focus {
outline: none;
border-color: var(--focus-ring);
}
+161
View File
@@ -0,0 +1,161 @@
import { describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { DEFAULT_DATE_FORMAT } from "@/platform/dateFormat";
import * as openUrl from "@/platform/openUrl";
import { Avatar, AvatarStack, avatarSrc, reviewerRingColor } from "./Avatar";
import { BranchChip, LabelChip, StateTag, labelForeground } from "./chips";
import { ChooserPopup, chooserItems } from "./ChooserPopup";
import { Markdown } from "./Markdown";
import { prettyDate, prettyDateTime } from "./prettyDate";
describe("prettyDate", () => {
const o = { ...DEFAULT_DATE_FORMAT, overrideSystem: true, pattern: "dd.MM.yyyy", use24Hour: true };
const now = new Date(2026, 9, 30, 15, 0).getTime();
it("formats like DateFormatUtil.formatPrettyDate", () => {
expect(prettyDate(now - 20_000, now, o)).toBe("Moments ago");
expect(prettyDate(now - 60_000, now, o)).toBe("1 minute ago");
expect(prettyDate(now - 42 * 60_000, now, o)).toBe("42 minutes ago");
expect(prettyDate(now - 3 * 3_600_000, now, o)).toBe("Today");
expect(prettyDate(new Date(2026, 9, 29, 9, 5).getTime(), now, o)).toBe("Yesterday");
expect(prettyDate(new Date(2026, 8, 2, 9, 5).getTime(), now, o)).toBe("02.09.2026");
expect(prettyDate("not a date", now, o)).toBe("");
});
it("adds the time with formatPrettyDateTime, and honours pretty formatting off", () => {
expect(prettyDateTime(new Date(2026, 9, 30, 9, 5).getTime(), now, o)).toBe("Today 09:05");
expect(prettyDateTime(new Date(2026, 9, 29, 9, 5).getTime(), now, o)).toBe("Yesterday 09:05");
expect(prettyDateTime(new Date(2026, 8, 2, 9, 5).getTime(), now, o)).toBe("02.09.2026 09:05");
expect(prettyDate(now - 20_000, now, { ...o, pretty: false })).toBe("30.10.2026");
});
});
describe("Avatar", () => {
it("loads the avatar at twice its size and falls back to the initial", () => {
expect(avatarSrc("https://avatars.githubusercontent.com/u/1?v=4", 20)).toBe(
"https://avatars.githubusercontent.com/u/1?v=4&s=40",
);
expect(avatarSrc("https://example.com/a.png", 20)).toBe("https://example.com/a.png");
const { container } = render(<Avatar url="https://avatars.githubusercontent.com/u/1?v=4" name="octocat" />);
const img = container.querySelector("img")!;
expect(img).toBeTruthy();
fireEvent.error(img);
expect(container.querySelector("img")).toBeNull();
expect(container.textContent).toBe("O");
expect(container.querySelector("[title='octocat']")).toBeTruthy();
});
it("rings reviewer avatars in their state colour and stacks at most two", () => {
expect(reviewerRingColor("APPROVED")).toContain("approved");
expect(reviewerRingColor("RE_REQUESTED")).toContain("neutral");
const { container } = render(
<AvatarStack
items={["a", "b", "c"].map((n) => ({ key: n, url: null, name: n, ring: reviewerRingColor("APPROVED") }))}
title="Reviewers"
/>,
);
expect(container.querySelectorAll("[data-avatar]")).toHaveLength(2);
});
});
describe("chips", () => {
it("shows the state tag except for open pull requests", () => {
const { container, rerender } = render(<StateTag state="OPEN" isDraft={false} />);
expect(container.textContent).toBe("");
rerender(<StateTag state="OPEN" isDraft />);
expect(container.textContent).toBe("DRAFT");
});
it("draws labels in their colour with readable text", () => {
expect(labelForeground("ffffff")).toBe("#000000");
expect(labelForeground("0e1a8a")).toBe("#ffffff");
render(<LabelChip label={{ name: "bug", color: "d73a4a" }} />);
expect(screen.getByText("bug").getAttribute("style")).toContain("background");
render(<BranchChip name="feature/x" />);
expect(screen.getByTitle("feature/x")).toBeTruthy();
});
});
describe("ChooserPopup", () => {
const users = [
{ id: "1", login: "ann", name: "Ann Lee" },
{ id: "2", login: "bob", name: "Robert" },
{ id: "3", login: "cy", name: null },
];
const present = (u: (typeof users)[number]) => ({ text: u.login, detail: u.name, avatarUrl: null });
const getKey = (u: (typeof users)[number]) => u.id;
it("lists the chosen items first, without duplicates", () => {
expect(chooserItems([users[2]!], users, getKey).map((u) => u.login)).toEqual(["cy", "ann", "bob"]);
});
it("chooses one item and closes (single)", () => {
const onChoose = vi.fn();
const onClose = vi.fn();
render(
<ChooserPopup
mode="single"
anchor={{ x: 0, y: 0 }}
items={users}
getKey={getKey}
present={present}
onChoose={onChoose}
onClose={onClose}
aria-label="Author"
/>,
);
const search = screen.getAllByLabelText("Author").find((e) => e.tagName === "INPUT")!;
fireEvent.change(search, { target: { value: "rob" } });
fireEvent.keyDown(search, { key: "Enter" });
expect(onChoose).toHaveBeenCalledWith(users[1]);
expect(onClose).toHaveBeenCalled();
});
it("toggles items and reports them on close (multi)", async () => {
const onClose = vi.fn();
render(
<ChooserPopup
mode="multi"
anchor={{ x: 0, y: 0 }}
items={users}
chosen={[users[0]!]}
getKey={getKey}
present={present}
loading
onClose={onClose}
aria-label="Reviewers"
/>,
);
expect(screen.getByText("Loading…")).toBeTruthy();
const search = screen.getAllByLabelText("Reviewers").find((e) => e.tagName === "INPUT")!;
fireEvent.keyDown(search, { key: "Enter" }); // unchecks ann (first, active)
fireEvent.keyDown(search, { key: "ArrowDown" });
fireEvent.keyDown(search, { key: "Enter" }); // checks bob
await act(async () => {
fireEvent.keyDown(document, { key: "Escape" });
});
expect(onClose).toHaveBeenCalledWith([users[1]]);
});
});
describe("Markdown", () => {
it("opens web links in the browser and never navigates the page for other links", async () => {
const open = vi.spyOn(openUrl, "openExternalUrl").mockResolvedValue();
const { container } = render(<Markdown text="[web](https://example.com/a) [rel](docs/guide.md)" />);
const links = await vi.waitFor(() => {
const found = container.querySelectorAll("a");
expect(found).toHaveLength(2);
return found;
});
const click = (a: Element) => {
const e = new MouseEvent("click", { bubbles: true, cancelable: true });
a.dispatchEvent(e);
return e.defaultPrevented;
};
expect(click(links[0]!)).toBe(true);
expect(open).toHaveBeenCalledWith("https://example.com/a");
open.mockClear();
expect(click(links[1]!)).toBe(true);
expect(open).not.toHaveBeenCalled();
});
});