mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
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:
1 parent
696377acc2
commit
059fd8a69b
192 files changed
+23668
-2076
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user