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]>
Цей коміт міститься в:
imacross authored and GitHub committed 2026-10-31 07:50:43 +08:00
1 джерело 696377acc2
коміт 059fd8a69b
192 files changed
+23668 -2076

No files matched your search

+97
Переглянути файл
@@ -0,0 +1,97 @@
/* The timeline editor (GHPRFileEditorComponentFactory): one scrolling column. */
.scroll {
height: 100%;
overflow: auto;
outline: none;
background: var(--bg-editor);
color: var(--fg);
font-size: var(--fs-ui);
}
.column {
display: flex;
flex-direction: column;
padding: 6px 0;
}
.title {
max-width: 42em;
margin: 0;
padding: 20px 16px;
color: var(--fg-strong);
font-size: 1.4em;
font-weight: 700;
line-height: 1.3;
overflow-wrap: anywhere;
user-select: text;
}
.link {
padding: 0;
border: 0;
background: none;
color: var(--link);
font: inherit;
text-decoration: none;
cursor: pointer;
}
.link:hover {
text-decoration: underline;
}
.paragraph {
margin: 0;
}
.paragraph + .paragraph {
margin-top: 4px;
}
.commits {
display: flex;
flex-direction: column;
gap: 4px;
}
.commit {
display: flex;
flex-direction: column;
}
.branch {
display: inline-flex;
padding: 0 4px 0 2px;
border-radius: 3px;
background: var(--bg-hover);
vertical-align: bottom;
}
.loadingThreads {
padding: 16px 10px 16px 60px;
color: var(--fg-secondary);
}
.status {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
max-width: calc(42em + 32px);
padding: 16px 10px;
text-align: center;
}
.errorTitle {
font-weight: 600;
}
.errorText {
color: var(--fg-secondary);
white-space: pre-wrap;
}
.mergedTitle {
font-weight: 700;
}
.mergedBody {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.composer {
padding: 10px 16px;
}
.placeholder {
display: flex;
height: 100%;
align-items: center;
justify-content: center;
}
+195
Переглянути файл
@@ -0,0 +1,195 @@
/**
* The pull request timeline editor (`GHPRFileEditorComponentFactory`): one scrolling column
* with the title (H2, `#N` link), the description item, the merged timeline items, the
* loading / error status, the merged banner and the new-comment composer. The timeline loads
* page by page as it is scrolled; Refresh Timeline (⌘R / Ctrl+F5) and the context menu's
* actions work on it. The tab title follows the details (`{title} #N`).
*
* `TimelineEditor({ input })` is the component of the editor kind `github.timeline`; an
* input of another repository than the connected one shows a message instead.
*/
import { useEffect, useMemo, useRef, type MouseEvent } from "react";
import { tx, txTemplate, useTx } from "@/platform/i18n";
import { Icon, Spinner, showContextMenu } from "@/ui";
import { notifyEditorDocumentChanged, type EditorInput } from "@/workbench/contributions";
import type { GHPullRequest } from "../api/types";
import { openInBrowser } from "../data/local";
import { addIssueComment, deleteMergedBranch, toggleReaction, updateDetails } from "../data/operations";
import { loadTimelinePage, usePullRequestData, usePullRequestTimeline } from "../data/pullRequest";
import { currentUserHasPermission, sameRepository, useGhRepository } from "../data/repository";
import { timelineData, type TimelineInputData } from "../toolwindow/timelineInput";
import { BranchChip } from "../ui/chips";
import { Placeholder } from "../ui/Placeholder";
import { ChatItem, CommentEditor, CommentItem, submitShortcut } from "./comments";
import { rich } from "./comments/ChatItem";
import { TimelineItem, WebLink } from "./items";
import { mergeTimeline } from "./model";
import styles from "./Timeline.module.css";
export function TimelineEditor({ input }: { input: EditorInput }) {
const data = timelineData(input);
const repo = useGhRepository((s) => s.connection?.coordinates ?? null);
if (!data || !sameRepository(repo, data))
return <Placeholder title="Connect the Pull Requests tool window to this repository to see the timeline" />;
return <Timeline data={data} />;
}
/** Requests the next page when the end is near (IntelliJ: on scrolling) or the column does not fill the view. */
const NEAR_END_PX = 400;
function Timeline({ data }: { data: TimelineInputData }) {
const t = useTx();
const n = data.number;
const details = usePullRequestData(n, "details");
const threads = usePullRequestData(n, "threads");
const timeline = usePullRequestTimeline(n);
const pr = details.value;
const scroll = useRef<HTMLDivElement>(null);
const entries = useMemo(() => mergeTimeline(timeline.items, import.meta.env.DEV), [timeline.items]);
// The tab title and icon are read from the details: let the tabs re-read them.
useEffect(() => notifyEditorDocumentChanged(), [pr?.title, pr?.state, pr?.isDraft]);
const more = () => {
const el = scroll.current;
if (!el || !timeline.hasMore || timeline.loading || timeline.error || timeline.initial) return;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - NEAR_END_PX) void loadTimelinePage(n);
};
useEffect(more);
const onContextMenu = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest("textarea, input, a")) return;
if (window.getSelection()?.toString()) return;
e.preventDefault();
showContextMenu(e, [
{ kind: "action", action: "github.pullRequest.timeline.refresh" },
{ kind: "action", action: "github.pullRequest.openInBrowser", arg: n },
{ kind: "action", action: "github.pullRequest.copyUrl", arg: n },
]);
};
if (!pr)
return (
<div className={styles.placeholder}>
{details.error ? (
<Placeholder title="Can't load pull request details">{details.error}</Placeholder>
) : (
<Spinner />
)}
</div>
);
return (
<div
ref={scroll}
className={styles.scroll}
tabIndex={-1}
data-github-timeline={n}
onScroll={more}
onContextMenu={onContextMenu}
>
<div className={styles.column}>
<h2 className={styles.title}>
{pr.title}{" "}
<a
className={styles.link}
href={pr.url}
title={t("View in browser")}
onClick={(e) => {
e.preventDefault();
void openInBrowser(pr.url);
}}
>
#{pr.number}
</a>
</h2>
<Description pr={pr} />
{entries.map((e) => (
<TimelineItem key={e.key} entry={e} pr={pr} threads={threads.value} />
))}
{(timeline.loading || timeline.error) && (
<div className={styles.status}>
{timeline.error ? (
<>
<span className={styles.errorTitle}>{t("Can't load timeline items")}</span>
<span className={styles.errorText}>{timeline.error}</span>
<button type="button" className={styles.link} onClick={() => void loadTimelinePage(n)}>
{t("Retry")}
</button>
</>
) : (
<Spinner />
)}
</div>
)}
{pr.state === "MERGED" && !timeline.error && <MergedBanner pr={pr} />}
{currentUserHasPermission("READ") && (
<div className={styles.composer}>
<CommentEditor
showAvatar
submitLabel="Comment"
submitHint={tx`${submitShortcut()} to comment`}
clearOnSubmit
onSubmit={(text) => addIssueComment(n, text)}
/>
</div>
)}
</div>
</div>
);
}
/** The description item: author, date, body (or "No description provided"), reactions; Edit and Add reaction. */
function Description({ pr }: { pr: GHPullRequest }) {
const n = pr.number;
return (
<CommentItem
author={pr.author}
createdAt={pr.createdAt}
body={pr.body}
emptyText="No description provided"
reactions={pr.reactions.nodes}
canEdit={pr.viewerCanUpdate}
canReact={pr.viewerCanReact}
onSave={(body) => updateDetails(n, { body })}
onToggleReaction={(content, add) => toggleReaction(n, pr.id, content, add, "details")}
/>
);
}
/** "Pull Request Successfully Merged": delete the head branch, or restore it on GitHub. */
function MergedBanner({ pr }: { pr: GHPullRequest }) {
const t = useTx();
const ref = pr.headRef;
const canDelete = pr.viewerCanDeleteHeadRef && !!ref;
const branch = pr.headRefName ? <BranchChip name={pr.headRefName} className={styles.branch} /> : "branch";
return (
<ChatItem
author={null}
icon={<Icon name="github-pr-merged" size={30} />}
title={<span className={styles.mergedTitle}>{t("Pull Request Successfully Merged")}</span>}
>
<span className={styles.mergedBody}>
{canDelete
? rich(txTemplate("You can now {delete} {branch}."), {
delete: (
<button
type="button"
className={styles.link}
onClick={() => void deleteMergedBranch(pr.number, ref!.id)}
>
{t("delete")}
</button>
),
branch,
})
: rich(txTemplate("{branch} has been deleted. {restore}"), {
branch,
restore: <WebLink url={pr.url}>{t("Restore…")}</WebLink>,
})}
{!canDelete && <Icon name="external-link" size={12} />}
</span>
</ChatItem>
);
}
+111
Переглянути файл
@@ -0,0 +1,111 @@
/**
* The row every timeline item, comment and reply uses (collaboration-tools'
* `CodeReviewChatItemUIUtil.build` with `CodeReviewTimelineUIUtil.createTitleTextPane`):
* the avatar (tooltip: the name), then the header (bold author link · pretty date, tags,
* the actions on the right) above the content.
*/
import { Fragment, type ReactNode } from "react";
import type { GHActor } from "../../api/types";
import { GHOST_USER } from "../../data/model";
import { openInBrowser } from "../../data/local";
import { Avatar, AVATAR_SIZE } from "../../ui/Avatar";
import { prettyDateTime } from "../../ui/prettyDate";
import { presentableName } from "../model";
import styles from "./comments.module.css";
/** FULL (timeline items), FULL_SECONDARY (thread replies in the timeline), COMPACT (diff inlays). */
export type ChatItemVariant = "full" | "secondary" | "compact";
export const avatarSize = (v: ChatItemVariant) => (v === "full" ? AVATAR_SIZE.TIMELINE : AVATAR_SIZE.BASE);
/** A user link: the presentable name in bold, opening the profile. */
export function AuthorLink({ actor }: { actor: GHActor | null }) {
const a = actor ?? GHOST_USER;
return (
<a
className={styles.author}
href={a.url}
onClick={(e) => {
e.preventDefault();
if (a.url) void openInBrowser(a.url);
}}
>
{presentableName(a)}
</a>
);
}
/** A tag pill (OUTDATED / RESOLVED / PENDING). */
export const Tag = ({ children }: { children: ReactNode }) => <span className={styles.tag}>{children}</span>;
export interface ChatItemProps {
variant?: ChatItemVariant;
author: GHActor | null;
/** The date after the name; none for undated items. */
date?: string | null;
/** Replaces the avatar (the merged banner's icon). */
icon?: ReactNode;
/** Replaces the name · date header. */
title?: ReactNode;
tags?: ReactNode;
actions?: ReactNode;
/** No content width limit (threads with their diffs). */
wide?: boolean;
className?: string;
children?: ReactNode;
}
export function ChatItem({
variant = "full",
author,
date,
icon,
title,
tags,
actions,
wide,
className,
children,
}: ChatItemProps) {
const a = author ?? GHOST_USER;
const name = presentableName(a);
return (
<div className={[styles.item, className].filter(Boolean).join(" ")} data-variant={variant}>
<div className={styles.icon}>{icon ?? <Avatar url={a.avatarUrl} name={name} size={avatarSize(variant)} />}</div>
<div className={styles.main} data-wide={wide || undefined}>
<div className={styles.header}>
<div className={styles.title}>
{title ?? (
<span>
<AuthorLink actor={a} />
{date && <span className={styles.date}>&nbsp;{prettyDateTime(date)}</span>}
</span>
)}
{tags}
</div>
{actions && <div className={styles.actions}>{actions}</div>}
</div>
{children}
</div>
</div>
);
}
/**
* A translated `{name}` template (the first argument a `txTemplate` call gave) with
* React nodes for its parameters: `rich(txTemplate("assigned {users}"), { users })`.
*/
export function rich(template: string, values: Record<string, ReactNode>): ReactNode {
const parts = template.split(/\{(\w+)\}/);
return parts.map((p, i) => <Fragment key={i}>{i % 2 ? (values[p] ?? `{${p}}`) : p}</Fragment>);
}
/** Nodes joined with ", " (IntelliJ's `joinToString`). */
export function joined(nodes: ReactNode[]): ReactNode {
return nodes.map((n, i) => (
<Fragment key={i}>
{i > 0 && ", "}
{n}
</Fragment>
));
}
+294
Переглянути файл
@@ -0,0 +1,294 @@
/**
* The comment text field (collaboration-tools' `CodeReviewCommentTextFieldFactory` with
* `CommentInputActionsComponentFactory`): an optional avatar, a growing Markdown field with
* @mention completion (the repository's mentionable users, `GHViewModelMentionCompletion`),
* then the hints ("{Enter} to add new line", the submit hint) and the buttons (Cancel, extra
* actions, the primary one). ⌘/Ctrl+Enter submits, Escape cancels.
*
* <CommentEditor submitLabel="Reply" submitHint={tx`${submitShortcut()} to reply`}
* onSubmit={(text) => replyToComment(n, id, text)} />
*/
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent } from "react";
import { tx, useTx } from "@/platform/i18n";
import { formatBinding, isMacHost } from "@/platform/keymap";
import { Button, Popup } from "@/ui";
import { errorMessage } from "../../api/client";
import type { GHUser } from "../../api/types";
import { useGhRepository, useRepositoryList } from "../../data/repository";
import { Avatar } from "../../ui/Avatar";
import { filterMentions, insertMention, mentionQuery } from "../model";
import { avatarSize, type ChatItemVariant } from "./ChatItem";
import styles from "./comments.module.css";
/** The submit shortcut as hints show it (`CommentInputActionsComponentFactory.submitShortcutText`). */
export const submitShortcut = () => formatBinding(isMacHost() ? "Meta+Enter" : "Ctrl+Enter");
/** The new-line shortcut (Enter). */
const newLineShortcut = () => formatBinding("Enter");
export interface CommentEditorAction {
label: string;
onClick: () => void | Promise<unknown>;
disabled?: boolean;
}
export interface CommentEditorProps {
initialText?: string;
/** The primary button: "Comment", "Reply", "Save Changes"… */
submitLabel: string;
/** "⌘⏎ to comment" and the like (translated by the caller). */
submitHint: string;
/** Sends the text; the field keeps it when this rejects (the error shows under it). */
onSubmit: (text: string) => Promise<unknown>;
/** Shows Cancel (and Escape cancels). */
onCancel?: () => void;
/** Escape only, no Cancel button (a diff's new comment: its gutter icon discards it). */
noCancelButton?: boolean;
/** Buttons before the primary one ("Resolve" of the reply box). */
extraActions?: CommentEditorAction[];
/** The author avatar on the left: the current user's (FULL / COMPACT inputs). */
showAvatar?: boolean;
variant?: ChatItemVariant;
placeholder?: string;
autoFocus?: boolean;
/** Empty the field after a successful submit (new comments and replies). */
clearOnSubmit?: boolean;
/** Allows submitting an empty text (editing a review body to nothing is not allowed). */
allowEmpty?: boolean;
/** Called as the text changes (a diff's new comment keeps its draft). */
onTextChange?: (text: string) => void;
/** Changing it moves the focus into the field again (a new comment requested twice). */
focusRequest?: number;
}
export function CommentEditor({
initialText = "",
submitLabel,
submitHint,
onSubmit,
onCancel,
noCancelButton,
extraActions = [],
showAvatar,
variant = "full",
placeholder,
autoFocus,
clearOnSubmit,
allowEmpty,
onTextChange,
focusRequest,
}: CommentEditorProps) {
const t = useTx();
const me = useGhRepository((s) => s.connection?.currentUser ?? null);
const [text, setText] = useState(initialText);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [mention, setMention] = useState<{ start: number; query: string } | null>(null);
const [active, setActive] = useState(0);
// The mentionable users load once per repository, on the first editor.
const mentionable = useRepositoryList("mentionableUsers").items;
const matches = mention ? filterMentions(mentionable, mention.query) : [];
const ref = useRef<HTMLTextAreaElement>(null);
const [field, setField] = useState<HTMLDivElement | null>(null);
// Grow with the text, like IntelliJ's comment field.
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight + 2}px`;
}, [text]);
useEffect(() => {
if (!autoFocus) return;
const el = ref.current;
el?.focus();
el?.setSelectionRange(el.value.length, el.value.length);
}, [autoFocus, focusRequest]);
const canSubmit = !busy && (allowEmpty || !!text.trim());
const submit = async () => {
if (!canSubmit) return;
setBusy(true);
setError(null);
try {
await onSubmit(text);
if (clearOnSubmit) {
setText("");
onTextChange?.("");
}
} catch (e) {
setError(errorMessage(e));
} finally {
setBusy(false);
}
};
const runExtra = async (a: CommentEditorAction) => {
setBusy(true);
setError(null);
try {
await a.onClick();
} catch (e) {
setError(errorMessage(e));
} finally {
setBusy(false);
}
};
const updateMention = (value: string, caret: number) => {
const m = mentionQuery(value, caret);
setMention(m);
setActive(0);
};
const pick = (u: GHUser) => {
const el = ref.current;
if (!el || !mention) return;
const r = insertMention(text, mention.start, el.selectionStart, u.login);
setText(r.text);
onTextChange?.(r.text);
setMention(null);
requestAnimationFrame(() => {
el.focus();
el.setSelectionRange(r.caret, r.caret);
});
};
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (mention && matches.length) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const d = e.key === "ArrowDown" ? 1 : -1;
setActive((a) => (a + d + matches.length) % matches.length);
return;
}
if ((e.key === "Enter" && !e.metaKey && !e.ctrlKey) || e.key === "Tab") {
e.preventDefault();
pick(matches[Math.min(active, matches.length - 1)]!);
return;
}
}
if (e.key === "Escape") {
if (mention) {
e.preventDefault();
e.stopPropagation();
setMention(null);
} else if (onCancel) {
e.preventDefault();
e.stopPropagation();
onCancel();
}
return;
}
if (e.key === "Enter" && (isMacHost() ? e.metaKey : e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
void submit();
}
};
return (
<div className={styles.editorRow} style={{ ["--gap" as string]: variant === "full" ? "14px" : "10px" }}>
{showAvatar && me && <Avatar url={me.avatarUrl} name={me.name || me.login} size={avatarSize(variant)} />}
<div className={styles.editor}>
<div ref={setField} className={styles.field}>
<textarea
ref={ref}
className={styles.textarea}
rows={1}
value={text}
disabled={busy}
placeholder={t(placeholder)}
aria-label={placeholder ? t(placeholder) : t(submitLabel)}
onChange={(e) => {
setText(e.target.value);
onTextChange?.(e.target.value);
updateMention(e.target.value, e.target.selectionStart);
}}
onSelect={(e) => {
const el = e.currentTarget;
if (mention) updateMention(el.value, el.selectionStart);
}}
onBlur={() => setTimeout(() => setMention(null), 150)}
onKeyDown={onKeyDown}
/>
{mention && (
<MentionPopup
anchor={field}
matches={matches}
active={active}
onPick={pick}
onClose={() => setMention(null)}
/>
)}
</div>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.editorFooter}>
<div className={styles.hints}>
<span>{submitHint}</span>
<span>{tx`${newLineShortcut()} to add new line`}</span>
</div>
<div className={styles.buttons}>
{onCancel && !noCancelButton && (
<Button disabled={busy} onClick={onCancel}>
Cancel
</Button>
)}
{extraActions.map((a) => (
<Button key={a.label} disabled={busy || a.disabled} onClick={() => void runExtra(a)}>
{t(a.label)}
</Button>
))}
<Button variant="primary" disabled={!canSubmit} onClick={() => void submit()}>
{t(submitLabel)}
</Button>
</div>
</div>
</div>
</div>
);
}
/** The completion list of the `@query` being typed. */
function MentionPopup(props: {
anchor: HTMLElement | null;
matches: GHUser[];
active: number;
onPick: (u: GHUser) => void;
onClose: () => void;
}) {
const { matches } = props;
if (!matches.length) return null;
return (
<Popup
open
anchor={props.anchor}
onClose={props.onClose}
placement="bottom-start"
autoFocus={false}
restoreFocus={false}
aria-label="Mentions"
>
<div className={styles.mentions} role="listbox">
{matches.map((u, i) => (
<div
key={u.id || u.login}
role="option"
aria-selected={i === props.active}
className={styles.mention}
data-active={i === props.active || undefined}
onMouseDown={(e) => {
e.preventDefault();
props.onPick(u);
}}
>
<Avatar url={u.avatarUrl} name={u.login} size={16} title={null} />
<span>{u.login}</span>
{u.name && <span className={styles.secondary}>{u.name}</span>}
</div>
))}
</div>
</Popup>
);
}
+126
Переглянути файл
@@ -0,0 +1,126 @@
/**
* A comment as a chat item (`GHPRTimelineItemComponentFactory` comments,
* `GHPRReviewThreadCommentComponentFactory`): the header actions Edit (an inline editor
* with "Save Changes"), Delete (after "Delete Comment" / "Are you sure you want to delete
* this comment?") and Add reaction, each shown when allowed, then the body and the
* reactions row.
*/
import { useState, type ReactNode } from "react";
import { tx } from "@/platform/i18n";
import { IconButton } from "@/ui";
import { confirm } from "@/workbench/messages";
import { errorMessage } from "../../api/client";
import type { GHActor, GHReaction } from "../../api/types";
import { Markdown } from "../../ui/Markdown";
import { ChatItem, type ChatItemVariant } from "./ChatItem";
import { CommentEditor, submitShortcut } from "./CommentEditor";
import { AddReactionButton, Reactions, useReactionsState, type ToggleReaction } from "./Reactions";
import styles from "./comments.module.css";
/** `CodeReviewCommentUIUtil.createDeleteCommentIconButton`'s question. */
export function confirmDeleteComment(): Promise<boolean> {
return confirm({
title: "Delete Comment",
message: tx("Are you sure you want to delete this comment?"),
ok: "Delete",
icon: "question",
});
}
export interface CommentItemProps {
variant?: ChatItemVariant;
author: GHActor | null;
createdAt: string | null;
/** Markdown. */
body: string;
/** Shown in grey italics for an empty body ("No description provided"). */
emptyText?: string;
/** The reactions row and the Add reaction button (with `canReact`); none when absent. */
reactions?: readonly GHReaction[];
canEdit?: boolean;
canDelete?: boolean;
canReact?: boolean;
onSave?: (body: string) => Promise<unknown>;
onDelete?: () => Promise<unknown>;
onToggleReaction?: ToggleReaction;
tags?: ReactNode;
/** Renders the body (suggested changes); Markdown by default. */
renderBody?: (body: string) => ReactNode;
/** Under the body (a review's status line). */
footer?: ReactNode;
/** Disables the actions (another request of the item runs). */
busy?: boolean;
}
export function CommentItem(props: CommentItemProps) {
const {
variant = "full",
body,
emptyText,
reactions,
canEdit,
canDelete,
canReact,
onSave,
onDelete,
onToggleReaction,
renderBody,
} = props;
const [editing, setEditing] = useState(false);
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const reactionsState = useReactionsState(reactions ?? [], onToggleReaction ?? (() => Promise.resolve()));
const busy = props.busy || deleting;
const remove = async () => {
if (!onDelete || !(await confirmDeleteComment())) return;
setDeleting(true);
setError(null);
try {
await onDelete();
} catch (e) {
setError(errorMessage(e));
} finally {
setDeleting(false);
}
};
const actions =
(canEdit && onSave) || (canDelete && onDelete) || (canReact && onToggleReaction && reactions) ? (
<>
{canEdit && onSave && (
<IconButton icon="edit" title="Edit" size="small" disabled={busy} onClick={() => setEditing(true)} />
)}
{canDelete && onDelete && (
<IconButton icon="trash" title="Delete" size="small" disabled={busy} onClick={() => void remove()} />
)}
{canReact && onToggleReaction && reactions && <AddReactionButton state={reactionsState} />}
</>
) : undefined;
return (
<ChatItem variant={variant} author={props.author} date={props.createdAt} tags={props.tags} actions={actions}>
{editing && onSave ? (
<CommentEditor
initialText={body}
submitLabel="Save Changes"
submitHint={tx`${submitShortcut()} to save changes`}
autoFocus
variant={variant}
onCancel={() => setEditing(false)}
onSubmit={async (text) => {
await onSave(text);
setEditing(false);
}}
/>
) : renderBody ? (
renderBody(body)
) : (
<Markdown text={body} emptyText={emptyText} />
)}
{error && <div className={styles.error}>{error}</div>}
{props.footer}
{reactions && <Reactions state={reactionsState} canReact={!!canReact && !!onToggleReaction} />}
</ChatItem>
);
}
+71
Переглянути файл
@@ -0,0 +1,71 @@
/**
* Small read-only diffs (`TimelineDiffComponentFactory`): a unified hunk with both line
* number columns, inserted / deleted lines coloured and the comment's anchor lines
* highlighted; and the rounded box with the file name header a thread's diff sits in
* (`createDiffWithHeader`), whose name opens the file's diff and whose chevron collapses it.
*/
import type { ReactNode } from "react";
import { IconButton } from "@/ui";
import { hunkLineNumbers, type PatchHunk } from "../model";
import styles from "./comments.module.css";
export function HunkView({ hunk, anchor }: { hunk: PatchHunk; anchor?: [number, number] | null }) {
const numbers = hunkLineNumbers(hunk);
return (
<div className={styles.hunk}>
<table>
<tbody>
{hunk.lines.map((l, i) => (
<tr key={i} data-type={l.type} data-anchor={anchor && i >= anchor[0] && i < anchor[1] ? true : undefined}>
<td className={styles.lineNo}>{numbers[i]![0] ?? ""}</td>
<td className={styles.lineNo}>{numbers[i]![1] ?? ""}</td>
<td className={styles.code}>{l.text || " "}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export function DiffBox({
path,
onOpen,
collapsible,
collapsed,
onToggleCollapsed,
children,
}: {
path: string;
onOpen?: () => void;
collapsible?: boolean;
collapsed?: boolean;
onToggleCollapsed?: () => void;
children?: ReactNode;
}) {
return (
<div className={styles.diffBox}>
<div className={styles.fileHeader}>
<span
className={styles.fileName}
role="link"
tabIndex={0}
title={path}
onClick={onOpen}
onKeyDown={(e) => e.key === "Enter" && onOpen?.()}
>
{path}
</span>
{collapsible && (
<IconButton
icon={collapsed ? "chevron-down" : "chevron-up"}
title={collapsed ? "Expand" : "Collapse"}
size="small"
onClick={onToggleCollapsed}
/>
)}
</div>
{!collapsed && children}
</div>
);
}
+150
Переглянути файл
@@ -0,0 +1,150 @@
/**
* Reactions of a description, comment or review comment (`GHReactionsComponentFactory`,
* `GHReactionsPickerComponentFactory`): a pill per reaction (emoji, count, outlined when
* the viewer reacted, tooltip "{users} reacted with '{name}' emoji") that toggles the
* viewer's reaction, then a pill opening the picker; and the "Add reaction" header button.
* The viewer's toggle shows at once and is undone when the request fails.
*/
import { useState } from "react";
import { tx } from "@/platform/i18n";
import { IconButton, Popup, tooltipAttrs } from "@/ui";
import type { GHReaction, GHReactionContent } from "../../api/types";
import { useGhRepository } from "../../data/repository";
import { groupReactions, presentableName, REACTIONS, toggleReactionLocally } from "../model";
import styles from "./comments.module.css";
export type ToggleReaction = (content: GHReactionContent, add: boolean) => Promise<unknown>;
const reactionOf = (c: GHReactionContent) => REACTIONS.find((r) => r.content === c)!;
/** What `Reactions` and `AddReactionButton` of one subject share. */
export interface ReactionsState {
shown: readonly GHReaction[];
toggle: (content: GHReactionContent) => void;
meId: string | null;
}
/** The reactions shown: the given ones with the viewer's pending toggle applied. */
export function useReactionsState(reactions: readonly GHReaction[], onToggle: ToggleReaction): ReactionsState {
const me = useGhRepository((s) => s.connection?.currentUser ?? null);
// The local toggle applies until the reactions are reloaded.
const [local, setLocal] = useState<{ base: readonly GHReaction[]; value: GHReaction[] } | null>(null);
const shown = local && local.base === reactions ? local.value : reactions;
const toggle = (content: GHReactionContent) => {
if (!me) return;
const add = !groupReactions(shown, me.id).find((r) => r.content === content)?.isReactedByCurrentUser;
setLocal({ base: reactions, value: toggleReactionLocally(shown, content, me, add) });
onToggle(content, add).catch(() => setLocal(null));
};
return { shown, toggle, meId: me?.id ?? null };
}
/** The emoji picker under `anchor`. */
function ReactionPicker({
anchor,
reacted,
onPick,
onClose,
}: {
anchor: Element | null;
reacted: ReadonlySet<GHReactionContent>;
onPick: (c: GHReactionContent) => void;
onClose: () => void;
}) {
return (
<Popup open anchor={anchor} onClose={onClose} placement="bottom-start" aria-label={tx("Add reaction")}>
<div className={styles.picker}>
{REACTIONS.map((r) => (
<button
key={r.content}
type="button"
className={styles.pickerEmoji}
data-own={reacted.has(r.content) || undefined}
aria-label={r.name}
{...tooltipAttrs(r.name)}
onClick={() => {
onPick(r.content);
onClose();
}}
>
{r.emoji}
</button>
))}
</div>
</Popup>
);
}
export interface ReactionsProps {
/** `useReactionsState` of the subject (shared with its `AddReactionButton`). */
state: ReactionsState;
/** The viewer may react (`viewerCanReact`); otherwise the pills only show. */
canReact: boolean;
}
/** The reactions row; nothing when there are none. */
export function Reactions({ state, canReact }: ReactionsProps) {
const { shown, toggle, meId } = state;
const [picker, setPicker] = useState<Element | null>(null);
const groups = groupReactions(shown, meId);
if (!groups.length) return null;
return (
<div className={styles.reactions}>
{groups.map((g) => {
const r = reactionOf(g.content);
const users = g.users.map((u) => presentableName(u)).join(", ");
const name = r.name;
return (
<button
key={g.content}
type="button"
className={styles.pill}
data-own={g.isReactedByCurrentUser || undefined}
disabled={!canReact}
{...tooltipAttrs(tx`${users} reacted with '${name}' emoji`)}
onClick={() => toggle(g.content)}
>
<span className={styles.emoji}>{r.emoji}</span>
{g.users.length}
</button>
);
})}
{canReact && (
<button
type="button"
className={styles.pill}
aria-label={tx("Add reaction")}
{...tooltipAttrs(tx("Add reaction"))}
onClick={(e) => setPicker(e.currentTarget)}
>
<span className={styles.emoji}>☺</span>+
</button>
)}
{picker && (
<ReactionPicker
anchor={picker}
reacted={new Set(groups.filter((g) => g.isReactedByCurrentUser).map((g) => g.content))}
onPick={toggle}
onClose={() => setPicker(null)}
/>
)}
</div>
);
}
/** The header's "Add reaction" button: the picker for the same reactions. */
export function AddReactionButton({ state }: { state: ReactionsState }) {
const { shown, toggle, meId } = state;
const [anchor, setAnchor] = useState<Element | null>(null);
const reacted = new Set(
groupReactions(shown, meId)
.filter((g) => g.isReactedByCurrentUser)
.map((g) => g.content),
);
return (
<>
<IconButton icon="add-emoji" title="Add reaction" size="small" onClick={(e) => setAnchor(e.currentTarget)} />
{anchor && <ReactionPicker anchor={anchor} reacted={reacted} onPick={toggle} onClose={() => setAnchor(null)} />}
</>
);
}
+168
Переглянути файл
@@ -0,0 +1,168 @@
/**
* A review comment's body with its suggested changes (`GHPRReviewCommentBodyComponentFactory`,
* `GHPRReviewSuggestedChangeComponentFactory`): Markdown parts as they are, each
* ```` ```suggestion ```` block as a rounded "Suggested change" box with the diff from the
* commented code to the suggestion and the "Apply Locally" option button with "Commit…".
* The button is disabled, with IntelliJ's tooltip, for resolved and outdated threads and
* when the pull request's branch is not checked out.
*/
import { useState } from "react";
import { useTx } from "@/platform/i18n";
import { Button, Icon, Menu, Popup, tooltipAttrs } from "@/ui";
import type { GHPullRequestReviewThread } from "../../api/types";
import { Markdown } from "../../ui/Markdown";
import {
splitSuggestions,
suggestionDisabledReason,
suggestionTarget,
threadDiffHunk,
type PatchHunk,
type SuggestionTarget,
} from "../model";
import { HunkView } from "./DiffHunk";
import { applySuggestion, useOnReviewBranch } from "./suggestion";
import styles from "./comments.module.css";
/** The diff of a suggestion: the commented lines removed, the suggested ones added. */
function suggestionHunk(target: SuggestionTarget, lines: readonly string[]): PatchHunk {
const start = target.startLineIndex ?? 0;
return {
startLineBefore: start,
startLineAfter: start,
lines: [
...target.originalLines.map((text) => ({ type: "REMOVE" as const, text })),
...lines.map((text) => ({ type: "ADD" as const, text })),
],
};
}
function CommitPopup({
anchor,
onCommit,
onClose,
}: {
anchor: Element | null;
onCommit: (m: string) => void;
onClose: () => void;
}) {
const t = useTx();
const [message, setMessage] = useState("");
const [empty, setEmpty] = useState(false);
const commit = () => {
if (!message.trim()) return setEmpty(true);
onCommit(message);
onClose();
};
return (
<Popup open anchor={anchor} onClose={onClose} placement="bottom-end" trapFocus aria-label={t("Commit")}>
<div className={styles.commitPopup}>
<textarea
data-autofocus
aria-label={t("Commit message")}
placeholder={t("Commit message")}
value={message}
onChange={(e) => {
setMessage(e.target.value);
setEmpty(false);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
commit();
}
}}
/>
{empty && (
<span className={styles.error}>
<Icon name="error" size={14} /> {t("Commit message is empty")}
</span>
)}
<div className={styles.buttons}>
<Button variant="primary" onClick={commit}>
Commit
</Button>
<Button onClick={onClose}>Cancel</Button>
</div>
</div>
</Popup>
);
}
function SuggestionBox({
number,
thread,
target,
lines,
}: {
number: number;
thread: GHPullRequestReviewThread;
target: SuggestionTarget;
lines: string[];
}) {
const t = useTx();
const onBranch = useOnReviewBranch(number);
const [busy, setBusy] = useState(false);
const [menu, setMenu] = useState(false);
const [commit, setCommit] = useState(false);
const [arrow, setArrow] = useState<HTMLButtonElement | null>(null);
const reason = suggestionDisabledReason(target.applicability, onBranch ?? false);
const disabled = busy || reason !== null;
const run = async (message?: string) => {
setBusy(true);
try {
await applySuggestion(number, thread, target, lines, message);
} finally {
setBusy(false);
}
};
return (
<div className={styles.suggestion}>
<div className={styles.suggestionTop}>
<span className={styles.suggestionTitle}>{t("Suggested change")}</span>
<span className={styles.split} {...(reason ? tooltipAttrs(t(reason)) : {})}>
<Button size="small" disabled={disabled} onClick={() => void run()}>
Apply Locally
</Button>
<Button ref={setArrow} size="small" disabled={disabled} aria-label={t("More")} onClick={() => setMenu(true)}>
<Icon name="chevron-down" size={14} />
</Button>
</span>
</div>
<HunkView hunk={suggestionHunk(target, lines)} />
<Menu
open={menu}
anchor={arrow}
onClose={() => setMenu(false)}
placement="bottom-end"
items={[{ label: "Commit…", run: () => setCommit(true) }]}
/>
{commit && <CommitPopup anchor={arrow} onCommit={(m) => void run(m)} onClose={() => setCommit(false)} />}
</div>
);
}
/** The body of a review comment in `thread`, its suggested changes as boxes. */
export function ReviewCommentBody({
number,
thread,
body,
}: {
number: number;
thread: GHPullRequestReviewThread;
body: string;
}) {
const blocks = splitSuggestions(body);
if (!blocks.some((b) => b.type === "suggestion")) return <Markdown text={body} />;
const target = suggestionTarget({ ...thread, diffHunk: threadDiffHunk(thread) });
return (
<div>
{blocks.map((b, i) =>
b.type === "markdown" ? (
<Markdown key={i} text={b.text} />
) : (
<SuggestionBox key={i} number={number} thread={thread} target={target} lines={b.lines} />
),
)}
</div>
);
}
+376
Переглянути файл
@@ -0,0 +1,376 @@
/**
* A review thread, in two variants:
*
* - `full` (`GHPRTimelineThreadComponentFactory`, the timeline): the first comment's author
* and date with the OUTDATED / RESOLVED / PENDING tags and its actions; the file header
* (opening the diff at the line) over the hunk with the anchor highlighted, then the
* comment. Resolved threads start collapsed: a 2-line grey preview above the file header.
* Replies start folded: their authors' avatars, "Reply" / "{n} replies", the last reply's
* date and Resolve / Unresolve; unfolded, the replies and the reply box.
* - `compact` (`GHPRReviewThreadComponentFactory`, diff inlays): the comments (with more
* than 3, the first, "… {n} more replies" and the last), then "Reply" and Resolve /
* Unresolve; "Reply" opens the reply box.
*
* Mutations go through `data/operations.ts`; the thread comes from the pull request's
* `threads` data, which they refresh.
*/
import { useState } from "react";
import { tx, useTx } from "@/platform/i18n";
import { IconButton } from "@/ui";
import { errorMessage } from "../../api/client";
import type { GHPullRequestReviewComment, GHPullRequestReviewThread } from "../../api/types";
import { openPullRequestFileDiff } from "../../data/local";
import {
deleteReviewComment,
replyToComment,
resolveThread,
toggleReaction,
unresolveThread,
updateReviewComment,
} from "../../data/operations";
import { AvatarStack } from "../../ui/Avatar";
import { Markdown } from "../../ui/Markdown";
import { prettyDateTime } from "../../ui/prettyDate";
import { presentableName, threadDiffHunk, threadDiffWithAnchor } from "../model";
import { ChatItem, Tag, type ChatItemVariant } from "./ChatItem";
import { CommentEditor, submitShortcut } from "./CommentEditor";
import { confirmDeleteComment, CommentItem } from "./CommentItem";
import { DiffBox, HunkView } from "./DiffHunk";
import { AddReactionButton, Reactions, useReactionsState } from "./Reactions";
import { ReviewCommentBody } from "./SuggestedChange";
import styles from "./comments.module.css";
export interface ThreadViewProps {
/** The pull request number. */
number: number;
thread: GHPullRequestReviewThread;
variant?: "full" | "compact";
}
export function ThreadView({ variant = "full", ...props }: ThreadViewProps) {
return variant === "full" ? <FullThread {...props} /> : <CompactThread {...props} />;
}
/** "Resolve" / "Unresolve" (`getResolveToggleActionText`). */
const resolveText = (resolved: boolean) => (resolved ? "Unresolve" : "Resolve");
/** The thread's resolve toggle and busy state; `onChanged` gets the new resolved state. */
function useResolve(number: number, thread: GHPullRequestReviewThread, onChanged?: (resolved: boolean) => void) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const canChange = thread.isResolved ? thread.viewerCanUnresolve : thread.viewerCanResolve;
const toggle = async () => {
setBusy(true);
setError(null);
try {
const t = thread.isResolved ? await unresolveThread(number, thread.id) : await resolveThread(number, thread.id);
onChanged?.(t.isResolved);
} catch (e) {
setError(errorMessage(e));
} finally {
setBusy(false);
}
};
return { busy, error, canChange, toggle };
}
/** The reply box: "Reply" plus Resolve / Unresolve. */
function ReplyBox({
number,
thread,
variant,
resolve,
autoFocus,
onCancel,
}: {
number: number;
thread: GHPullRequestReviewThread;
variant: ChatItemVariant;
resolve: ReturnType<typeof useResolve>;
autoFocus?: boolean;
onCancel?: () => void;
}) {
const first = thread.comments.nodes[0];
if (!thread.viewerCanReply || !first) return null;
return (
<div className={styles.replyBox} data-variant={variant === "compact" ? "compact" : undefined}>
<CommentEditor
submitLabel="Reply"
submitHint={tx`${submitShortcut()} to reply`}
showAvatar
variant={variant === "compact" ? "compact" : "secondary"}
autoFocus={autoFocus}
clearOnSubmit
onCancel={onCancel}
extraActions={
resolve.canChange ? [{ label: resolveText(thread.isResolved), onClick: resolve.toggle }] : undefined
}
onSubmit={(text) => replyToComment(number, first.id, text)}
/>
</div>
);
}
const tagsOf = (thread: GHPullRequestReviewThread, comment: GHPullRequestReviewComment | undefined, first: boolean) => (
<>
{first && thread.isOutdated && <Tag>OUTDATED</Tag>}
{first && thread.isResolved && <Tag>RESOLVED</Tag>}
{comment?.state === "PENDING" && <Tag>PENDING</Tag>}
</>
);
/** A review comment of `thread` as a chat item, editable, deletable and reactable as allowed. */
function ReviewComment({
number,
thread,
comment,
variant,
first,
}: {
number: number;
thread: GHPullRequestReviewThread;
comment: GHPullRequestReviewComment;
variant: ChatItemVariant;
first?: boolean;
}) {
return (
<CommentItem
variant={variant}
author={comment.author}
createdAt={comment.createdAt}
body={comment.body}
reactions={comment.reactions.nodes}
canEdit={comment.viewerCanUpdate}
canDelete={comment.viewerCanDelete}
canReact={comment.viewerCanReact}
tags={
variant === "compact" ? tagsOf(thread, comment, !!first) : comment.state === "PENDING" && <Tag>PENDING</Tag>
}
renderBody={(body) => <ReviewCommentBody number={number} thread={thread} body={body} />}
onSave={(body) => updateReviewComment(number, comment.id, body)}
onDelete={() => deleteReviewComment(number, comment.id)}
onToggleReaction={(content, add) => toggleReaction(number, comment.id, content, add, "threads")}
/>
);
}
function openThreadDiff(number: number, thread: GHPullRequestReviewThread) {
const line = thread.line != null && thread.diffSide === "RIGHT" ? thread.line - 1 : undefined;
void openPullRequestFileDiff(number, thread.path, { line });
}
function FullThread({ number, thread }: Omit<ThreadViewProps, "variant">) {
const t = useTx();
// Only resolved threads collapse (`collapsible = isResolved`): unresolved elsewhere, it opens.
const [collapsedState, setCollapsedState] = useState(thread.isResolved);
const collapsed = thread.isResolved && collapsedState;
const [repliesFolded, setRepliesFolded] = useState(true);
const [editing, setEditing] = useState(false);
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const setCollapsed = (c: boolean) => {
setCollapsedState(c);
if (c) setRepliesFolded(true);
};
const resolve = useResolve(number, thread, setCollapsed);
const [main, ...replies] = thread.comments.nodes;
const reactions = useReactionsState(main?.reactions.nodes ?? [], (content, add) =>
main ? toggleReaction(number, main.id, content, add, "threads") : Promise.resolve(),
);
const busy = resolve.busy || deleting;
const diff = threadDiffWithAnchor({ ...thread, diffHunk: threadDiffHunk(thread) });
const box = (
<DiffBox
path={thread.path}
onOpen={() => openThreadDiff(number, thread)}
collapsible={thread.isResolved}
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed(!collapsed)}
>
{diff ? (
<HunkView hunk={diff.hunk} anchor={diff.anchor} />
) : thread.subjectType === "FILE" ? null : (
<div className={styles.noDiff}>{t("Could not load diff hunk for thread")}</div>
)}
</DiffBox>
);
const remove = async () => {
if (!main || !(await confirmDeleteComment())) return;
setDeleting(true);
setError(null);
try {
await deleteReviewComment(number, main.id);
} catch (e) {
setError(errorMessage(e));
} finally {
setDeleting(false);
}
};
const actions = main && (main.viewerCanUpdate || main.viewerCanDelete || main.viewerCanReact) && (
<>
{main.viewerCanUpdate && (
<IconButton icon="edit" title="Edit" size="small" disabled={busy} onClick={() => setEditing(true)} />
)}
{main.viewerCanDelete && (
<IconButton icon="trash" title="Delete" size="small" disabled={busy} onClick={() => void remove()} />
)}
{main.viewerCanReact && <AddReactionButton state={reactions} />}
</>
);
const replyAuthors = [...new Map(replies.filter((r) => r.author).map((r) => [r.author!.login, r.author!])).values()];
const last = replies[replies.length - 1];
const n = replies.length;
return (
<div className={styles.thread} data-thread={thread.id}>
<ChatItem
author={main?.author ?? null}
date={main?.createdAt}
tags={tagsOf(thread, main, true)}
actions={actions || undefined}
wide
>
<div className={styles.threadContent}>
{!main ? (
box
) : collapsed ? (
<>
<div className={styles.preview}>
<Markdown text={main.body} />
</div>
{box}
</>
) : (
<>
{box}
{editing ? (
<CommentEditor
initialText={main.body}
submitLabel="Save Changes"
submitHint={tx`${submitShortcut()} to save changes`}
autoFocus
onCancel={() => setEditing(false)}
onSubmit={async (text) => {
await updateReviewComment(number, main.id, text);
setEditing(false);
}}
/>
) : (
<ReviewCommentBody number={number} thread={thread} body={main.body} />
)}
<Reactions state={reactions} canReact={main.viewerCanReact} />
</>
)}
</div>
{(error || resolve.error) && <div className={styles.error}>{error ?? resolve.error}</div>}
{repliesFolded && (n > 0 || thread.viewerCanReply || resolve.canChange) && (
<div className={styles.folded}>
{(n > 0 || thread.viewerCanReply) && (
<span className={styles.foldedReplies}>
{n > 0 && (
<AvatarStack
items={replyAuthors.map((a) => ({ key: a.login, url: a.avatarUrl, name: presentableName(a) }))}
max={replyAuthors.length}
size={20}
/>
)}
<button
type="button"
className={styles.link}
disabled={busy}
onClick={() => {
setRepliesFolded(false);
setCollapsedState(false);
}}
>
{n === 0 ? t("Reply") : n === 1 ? tx`${n} reply` : tx`${n} replies`}
</button>
{last && <span className={styles.secondary}>{prettyDateTime(last.createdAt)}</span>}
</span>
)}
{resolve.canChange && (
<button type="button" className={styles.link} disabled={busy} onClick={() => void resolve.toggle()}>
{t(resolveText(thread.isResolved))}
</button>
)}
</div>
)}
</ChatItem>
{!repliesFolded && (
<>
{replies.map((c) => (
<ReviewComment key={c.id} number={number} thread={thread} comment={c} variant="secondary" />
))}
<ReplyBox number={number} thread={thread} variant="secondary" resolve={resolve} autoFocus />
</>
)}
</div>
);
}
function CompactThread({ number, thread }: Omit<ThreadViewProps, "variant">) {
const t = useTx();
const [expanded, setExpanded] = useState(false);
const [replying, setReplying] = useState(false);
const resolve = useResolve(number, thread);
const comments = thread.comments.nodes;
const folded = !expanded && comments.length > 3;
const shown = folded ? [comments[0]!, comments[comments.length - 1]!] : comments;
const hidden = comments.length - 2;
return (
<div className={styles.thread} data-thread={thread.id}>
{shown.map((c, i) => (
<div key={c.id}>
<ReviewComment
number={number}
thread={thread}
comment={c}
variant="compact"
first={c.id === comments[0]?.id}
/>
{folded && i === 0 && (
<div className={styles.moreReplies}>
<button type="button" className={styles.link} onClick={() => setExpanded(true)}>
… {tx`${hidden} more replies`}
</button>
</div>
)}
</div>
))}
{resolve.error && <div className={styles.error}>{resolve.error}</div>}
{replying ? (
<ReplyBox
number={number}
thread={thread}
variant="compact"
resolve={resolve}
autoFocus
onCancel={() => setReplying(false)}
/>
) : (
(thread.viewerCanReply || resolve.canChange) && (
<div className={styles.compactFooter}>
{thread.viewerCanReply && (
<button type="button" className={styles.link} onClick={() => setReplying(true)}>
{t("Reply")}
</button>
)}
{resolve.canChange && (
<button
type="button"
className={styles.link}
disabled={resolve.busy}
onClick={() => void resolve.toggle()}
>
{t(resolveText(thread.isResolved))}
</button>
)}
</div>
)
)}
</div>
);
}
+438
Переглянути файл
@@ -0,0 +1,438 @@
/*
* Code review chat items (collaboration-tools' CodeReviewChatItemUIUtil): FULL timeline
* items (30px avatar, 14px gap, 10px 16px padding), FULL_SECONDARY replies (20px avatar
* under the FULL content) and COMPACT diff inlays (20px avatar, 10px padding).
*/
/* --- Chat item --------------------------------------------------------- */
.item {
--icon: 30px;
--gap: 14px;
display: flex;
gap: var(--gap);
padding: 10px 16px;
min-width: 0;
}
.item[data-variant="secondary"] {
--icon: 20px;
--gap: 10px;
padding: 4px 16px 4px 60px;
}
.item[data-variant="compact"] {
--icon: 20px;
--gap: 10px;
padding: 4px 10px;
}
.icon {
flex: none;
width: var(--icon);
}
.main {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
min-width: 0;
max-width: var(--gh-content-width, 42em);
}
.main[data-wide] {
max-width: none;
}
.header {
display: flex;
align-items: center;
gap: 8px;
min-height: 20px;
}
.title {
display: flex;
flex: 1;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 0;
}
.author {
color: var(--fg);
font-weight: 600;
text-decoration: none;
cursor: pointer;
}
.author:hover {
text-decoration: underline;
}
.date {
color: var(--fg);
}
.actions {
display: flex;
flex: none;
align-items: center;
gap: 2px;
margin-left: auto;
}
.tag {
display: inline-flex;
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);
white-space: nowrap;
}
.link {
padding: 0;
border: 0;
background: none;
color: var(--link);
font: inherit;
cursor: pointer;
}
.link:hover:not(:disabled) {
text-decoration: underline;
}
.link:disabled {
color: var(--fg-faint);
cursor: default;
}
.secondary {
color: var(--fg-secondary);
}
.error {
color: var(--danger);
white-space: pre-wrap;
}
/* --- Status line (StatusMessageComponentFactory) ------------------------ */
.status {
display: flex;
gap: 8px;
min-width: 0;
}
.status::before {
flex: none;
width: 6px;
border-radius: 3px;
background: var(--line);
content: "";
}
.status > * {
flex: 1;
min-width: 0;
padding: 2px 0;
}
.status[data-type="INFO"] {
--line: var(--gh-status-info);
}
.status[data-type="SECONDARY_INFO"] {
--line: var(--gh-status-secondary);
}
.status[data-type="SUCCESS"] {
--line: var(--gh-status-success);
}
.status[data-type="ERROR"],
.status[data-type="WARNING"] {
--line: var(--gh-status-error);
}
/* --- Comment editor (CodeReviewCommentTextFieldFactory) ----------------- */
.editorRow {
display: flex;
gap: var(--gap, 14px);
min-width: 0;
}
.editor {
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
min-width: 0;
max-width: var(--gh-content-width, 42em);
}
.field {
position: relative;
display: flex;
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-input);
}
.field:focus-within {
border-color: var(--focus-ring);
}
.textarea {
flex: 1;
min-height: 22px;
max-height: 320px;
padding: 4px 8px;
overflow-y: auto;
border: 0;
outline: none;
background: transparent;
color: var(--fg);
font: inherit;
line-height: 1.4;
resize: none;
}
.editorFooter {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.hints {
display: flex;
flex-wrap: wrap;
gap: 12px;
min-width: 0;
color: var(--fg-secondary);
font-size: var(--fs-small);
}
.buttons {
display: flex;
gap: 8px;
}
.mentions {
max-height: 240px;
min-width: 220px;
padding: 4px 0;
overflow-y: auto;
}
.mention {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
white-space: nowrap;
cursor: pointer;
}
.mention[data-active] {
background: var(--bg-list-selected);
}
/* --- Reactions ---------------------------------------------------------- */
.reactions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pill {
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 8px 0 6px;
border: 1px solid transparent;
border-radius: 12px;
background: var(--gh-reaction-bg);
color: var(--fg);
font: inherit;
font-size: 11px;
cursor: pointer;
}
.pill:hover:not(:disabled) {
background: var(--gh-reaction-bg-hover);
}
.pill[data-own] {
border-color: var(--gh-reaction-border-reacted);
}
.pill:disabled {
cursor: default;
}
.emoji {
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
font-size: 15px;
line-height: 1;
}
.picker {
display: flex;
gap: 7px;
padding: 7px;
}
.pickerEmoji {
width: 36px;
height: 36px;
padding: 0;
border: 0;
border-radius: var(--radius-toolbar-button);
background: none;
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
font-size: 22px;
cursor: pointer;
}
.pickerEmoji:hover,
.pickerEmoji[data-own] {
background: var(--bg-toolbar-hover);
}
/* --- Threads ------------------------------------------------------------ */
.thread {
display: flex;
flex-direction: column;
}
.threadContent {
display: flex;
flex-direction: column;
gap: 8px;
}
.preview {
display: -webkit-box;
overflow: hidden;
color: var(--fg-secondary);
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.preview * {
color: inherit !important;
}
.diffBox {
overflow: hidden;
border: 1px solid var(--gh-bubble-border);
border-radius: 8px;
background: var(--bg-editor);
}
.fileHeader {
display: flex;
align-items: center;
gap: 6px;
min-height: 28px;
padding: 2px 6px 2px 10px;
}
.fileName {
overflow: hidden;
color: var(--link);
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.fileName:hover {
text-decoration: underline;
}
.hunk {
overflow-x: auto;
border-top: 1px solid var(--gh-bubble-border);
font-family: var(--font-mono);
font-size: var(--fs-code);
line-height: 1.5;
}
.hunk table {
min-width: 100%;
border-collapse: collapse;
}
.hunk td {
padding: 0;
white-space: pre;
}
.lineNo {
width: 1px;
padding: 0 6px !important;
color: var(--fg-line-number);
text-align: right;
user-select: none;
}
.code {
padding: 0 8px !important;
}
.hunk tr[data-type="ADD"] {
background: var(--diff-inserted);
}
.hunk tr[data-type="REMOVE"] {
background: var(--diff-deleted);
}
.hunk tr[data-anchor] .code,
.hunk tr[data-anchor] .lineNo {
background: var(--gh-anchor-line);
}
.noDiff {
padding: 4px 10px;
border-top: 1px solid var(--gh-bubble-border);
color: var(--fg-secondary);
}
.folded {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 14px;
padding: 8px 0;
}
.foldedReplies {
display: flex;
align-items: center;
gap: 8px;
}
.compactFooter {
display: flex;
gap: 14px;
padding: 4px 10px 6px 40px;
}
.moreReplies {
padding: 2px 10px 2px 40px;
}
.replyBox {
padding: 6px 16px 6px 60px;
}
.replyBox[data-variant="compact"] {
padding: 6px 10px;
}
/* --- Suggested change (GHPRReviewSuggestedChangeComponentFactory) -------- */
.suggestion {
margin: 4px 0;
overflow: hidden;
border: 1px solid var(--gh-bubble-border);
border-radius: 8px;
}
.suggestionTop {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 8px;
border-bottom: 1px solid var(--gh-bubble-border);
background: var(--bg-editor);
}
.suggestionTitle {
color: var(--fg-secondary);
}
.split {
display: inline-flex;
}
.split > :first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.split > :last-child {
min-width: 0;
padding: 0 4px;
border-left: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.commitPopup {
display: flex;
flex-direction: column;
gap: 8px;
width: 450px;
padding: 10px;
}
.commitPopup textarea {
min-height: 90px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-input);
color: var(--fg);
font: inherit;
resize: vertical;
}
.commitPopup textarea:focus {
outline: none;
border-color: var(--focus-ring);
}
+23
Переглянути файл
@@ -0,0 +1,23 @@
/**
* Shared code review comment components (collaboration-tools' chat items and the GitHub
* plugin's comment / thread factories), used by the timeline and the review diff's inlays:
*
* - `ThreadView({ number, thread, variant })`: a review thread, `full` in the timeline,
* `compact` in diff inlays (first comment, "… n more replies", last; Reply / Resolve).
* - `CommentItem(props)`: one comment as a chat item with Edit / Delete / Add reaction.
* - `CommentEditor(props)`: the Markdown comment field with @mention completion, hints and
* submit / cancel / extra buttons (⌘/Ctrl+Enter submits, Escape cancels).
* - `Reactions` / `AddReactionButton` with `useReactionsState(reactions, onToggle)`.
* - `ChatItem`, `Tag`, `AuthorLink`: the bare row layout, for new-comment inlays.
* - `ReviewCommentBody`, `HunkView`, `DiffBox`: a review comment's body with suggested
* changes, a read-only hunk, the rounded file box.
*
* Every mutation goes through `data/operations.ts`.
*/
export * from "./ChatItem";
export * from "./CommentEditor";
export * from "./CommentItem";
export * from "./DiffHunk";
export * from "./Reactions";
export * from "./SuggestedChange";
export * from "./ThreadView";
+114
Переглянути файл
@@ -0,0 +1,114 @@
/**
* Applying a suggested change locally (`GHPRReviewCommentBodyViewModel.applySuggestionLocally`
* / `commitSuggestion`, `GHSuggestedChangeApplier`): the suggestion becomes a patch of the
* local file (`buildSuggestionPatch`) applied with `vcsApplyPatch`; "Commit…" then commits
* just the suggestion's lines of that file (a partial commit of its hunks). The thread is
* resolved afterwards when the viewer may. Progress shows in the tool window's banner.
*/
import { useEffect, useState } from "react";
import { useVcs } from "@/features/vcs/store";
import { call, commands } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import type { GHPullRequest, GHPullRequestReviewThread } from "../../api/types";
import { withBusy } from "../../data/local";
import { resolveThread } from "../../data/operations";
import { usePullRequestData } from "../../data/pullRequest";
import { buildSuggestionPatch, type SuggestionTarget } from "../model";
async function repositoryRoot(): Promise<string> {
const roots = await call(commands.vcsRoots());
const root = roots[0]?.root;
if (!root) throw new Error("The project is not a Git repository");
return String(root).replace(/[\\/]+$/, "");
}
/** Applies `suggestion` to the local file; with `commitMessage`, commits it. Whether it succeeded. */
export async function applySuggestion(
number: number,
thread: Pick<GHPullRequestReviewThread, "id" | "viewerCanResolve">,
target: SuggestionTarget,
suggestion: readonly string[],
commitMessage?: string,
): Promise<boolean> {
const title = commitMessage ? tx("Committing suggested change") : tx("Applying suggested change");
const done = await withBusy(title, async () => {
const root = await repositoryRoot();
const abs = `${root}/${target.path}`;
const text = await call(commands.fsReadText(abs));
const { patch, start, lines } = buildSuggestionPatch(text, target, suggestion);
const outcome = await call(commands.vcsApplyPatch(null, patch));
const files = outcome.conflicts.join(", ");
if (files) throw new Error(tx`Conflicts in ${files}`);
if (commitMessage) {
const head = (await call(commands.vcsFileAt(abs, "HEAD"))) ?? "";
const disk = await call(commands.fsReadText(abs));
const end = start + Math.max(lines, 1);
const hunks = (await commands.vcsDiffHunks(head, disk))
.filter((h) => h.start < end && h.start + Math.max(h.lines, 1) > start)
.map(({ start, lines, oldStart, oldLines }) => ({ start, lines, oldStart, oldLines }));
if (!hunks.length) throw new Error(tx`${target.path}: nothing to commit`);
await call(
commands.vcsCommit(null, commitMessage, {
amend: false,
signOff: false,
author: null,
runHooks: true,
partial: [{ path: abs, hunks }],
}),
);
}
return true;
});
if (!done) return false;
if (thread.viewerCanResolve) await withBusy(tx("Resolving thread"), () => resolveThread(number, thread.id));
return true;
}
let branchCache: {
at: number;
version: number;
value: Promise<{ head: string | null; upstream: string | null; commit: string | null }>;
} | null = null;
function branchInfo(version: number) {
if (!branchCache || branchCache.version !== version || Date.now() - branchCache.at > 2000) {
branchCache = {
at: Date.now(),
version,
value: call(commands.vcsBranchInfo(null)).then(
(b) => ({ head: b.head, upstream: b.upstream, commit: b.headCommit }),
() => ({ head: null, upstream: null, commit: null }),
),
};
}
return branchCache.value;
}
/** Whether the checked-out branch is the pull request's (it tracks its head branch, or is at its head). */
export function isReviewBranch(
pr: Pick<GHPullRequest, "headRefName" | "headRefOid">,
b: { head: string | null; upstream: string | null; commit: string | null },
): boolean {
if (b.commit && b.commit === pr.headRefOid) return true;
if (b.upstream) return b.upstream.slice(b.upstream.indexOf("/") + 1) === pr.headRefName;
return b.head === pr.headRefName;
}
/** `isOnReviewBranch`, checked again when the repository changes or the window regains focus; null while unknown. */
export function useOnReviewBranch(number: number): boolean | null {
const pr = usePullRequestData(number, "details").value;
const version = useVcs((s) => s.version);
const [value, setValue] = useState<boolean | null>(null);
useEffect(() => {
if (!pr) return;
let alive = true;
const check = () => void branchInfo(version).then((b) => alive && setValue(isReviewBranch(pr, b)));
check();
window.addEventListener("focus", check);
return () => {
alive = false;
window.removeEventListener("focus", check);
};
}, [pr, version]);
return value;
}
+387
Переглянути файл
@@ -0,0 +1,387 @@
/**
* The timeline's items (`GHPRTimelineItemComponentFactory`, `GHPRTimelineEventComponentFactoryImpl`):
* conversation comments, reviews with their status and threads, grouped commits, and the
* events with IntelliJ's texts — merged simple events as stacked paragraphs, merged state
* events as their last state.
*/
import type { ReactNode } from "react";
import { tx, txTemplate, useTx } from "@/platform/i18n";
import type {
GHActor,
GHLabel,
GHPullRequest,
GHPullRequestRequestedReviewer,
GHPullRequestReviewThread,
GHReferencedSubject,
} from "../api/types";
import { GHOST_USER } from "../data/model";
import { openInBrowser } from "../data/local";
import { deleteIssueComment, toggleReaction, updateIssueComment, updateReviewBody } from "../data/operations";
import { sameRepository, useGhRepository } from "../data/repository";
import { openPullRequest } from "../toolwindow/open";
import { BranchChip, LabelChip } from "../ui/chips";
import { Markdown } from "../ui/Markdown";
import { prettyDateTime } from "../ui/prettyDate";
import { ChatItem, CommentItem, ThreadView } from "./comments";
import { joined, rich } from "./comments/ChatItem";
import {
canEditReview,
commitsActor,
effectiveRename,
presentableName,
reviewStatus,
threadReviewId,
type ComplexEvent,
type SimpleGroup,
type StateGroup,
type StatusType,
type TimelineCommit,
type TimelineEntry,
} from "./model";
import cstyles from "./comments/comments.module.css";
import styles from "./Timeline.module.css";
/** A web link (opens the browser). */
export function WebLink({ url, children }: { url: string; children: ReactNode }) {
return (
<a
className={styles.link}
href={url}
onClick={(e) => {
e.preventDefault();
void openInBrowser(url);
}}
>
{children}
</a>
);
}
/** `StatusMessageComponentFactory`: the content with a coloured bar on its left. */
export function StatusLine({ type = "INFO", children }: { type?: StatusType; children: ReactNode }) {
return (
<div className={cstyles.status} data-type={type}>
<div>{children}</div>
</div>
);
}
const actorLink = (a: GHActor | GHPullRequestRequestedReviewer | null) => {
const r = a ?? GHOST_USER;
return <WebLink url={r.url}>{presentableName(r)}</WebLink>;
};
// ---------------------------------------------------------------------------
// Events
function labelsText(added: GHLabel[], removed: GHLabel[]): ReactNode {
const a = added.length ? joined(added.map((l) => <LabelChip key={l.id || l.name} label={l} />)) : null;
const r = removed.length ? joined(removed.map((l) => <LabelChip key={l.id || l.name} label={l} />)) : null;
if (a && r) return rich(txTemplate("added labels {added} and removed labels {removed}"), { added: a, removed: r });
if (a) return rich(txTemplate("added labels {labels}"), { labels: a });
if (r) return rich(txTemplate("removed labels {labels}"), { labels: r });
return null;
}
function assigneesText(assigned: GHActor[], unassigned: GHActor[]): ReactNode {
const a = assigned.length ? joined(assigned.map(actorLink)) : null;
const u = unassigned.length ? joined(unassigned.map(actorLink)) : null;
if (a && u)
return rich(txTemplate("assigned {assigned} and unassigned {unassigned}"), { assigned: a, unassigned: u });
if (a) return rich(txTemplate("assigned {users}"), { users: a });
if (u) return rich(txTemplate("unassigned {users}"), { users: u });
return null;
}
function reviewersText(
added: Array<GHPullRequestRequestedReviewer | null>,
removed: Array<GHPullRequestRequestedReviewer | null>,
): ReactNode {
const a = added.length ? joined(added.map(actorLink)) : null;
const r = removed.length ? joined(removed.map(actorLink)) : null;
if (a && r)
return rich(txTemplate("requested a review from {added} and removed review request from {removed}"), {
added: a,
removed: r,
});
if (a) return rich(txTemplate("requested a review from {reviewers}"), { reviewers: a });
if (r) return rich(txTemplate("removed review request from {reviewers}"), { reviewers: r });
return null;
}
const renameText = ([from, to]: [string, string]) =>
rich(txTemplate("renamed this pull request from '{from}' to '{to}'"), { from, to });
/** An event item: the actor's row with the text in a status line. */
function EventItem({
actor,
date,
type,
children,
}: {
actor: GHActor | null;
date: string | null;
type?: StatusType;
children: ReactNode;
}) {
return (
<ChatItem author={actor ?? GHOST_USER} date={date}>
<StatusLine type={type}>{children}</StatusLine>
</ChatItem>
);
}
function SimpleEventItem({ group }: { group: SimpleGroup }) {
const rename = effectiveRename(group);
const paragraphs = [
labelsText(group.addedLabels, group.removedLabels),
assigneesText(group.assigned, group.unassigned),
reviewersText(group.addedReviewers, group.removedReviewers),
rename && renameText(rename),
].filter(Boolean);
return (
<EventItem actor={group.actor} date={group.createdAt}>
{paragraphs.length === 1
? paragraphs[0]
: paragraphs.map((p, i) => (
<p key={i} className={styles.paragraph}>
{p}
</p>
))}
</EventItem>
);
}
function StateEventItem({ group }: { group: StateGroup }) {
const t = useTx();
if (group.newState === "CLOSED")
return (
<EventItem actor={group.actor} date={group.createdAt} type="SECONDARY_INFO">
{t("closed this")}
</EventItem>
);
if (group.newState === "OPEN")
return (
<EventItem actor={group.actor} date={group.createdAt}>
{t("reopened this")}
</EventItem>
);
const merged = group.last.__typename === "MergedEvent" ? group.last : null;
const commit = merged?.mergedEventCommit;
return (
<EventItem actor={group.actor} date={group.createdAt} type="SUCCESS">
{commit && merged
? rich(txTemplate("merged commit {commit} into {branch}"), {
commit: <WebLink url={commit.url}>{commit.abbreviatedOid}</WebLink>,
branch: <BranchChip name={merged.mergeRefName} className={styles.branch} />,
})
: t("merged this")}
</EventItem>
);
}
/** A referenced issue or pull request: "Title #N", the number opening it (in the IDE when it is of this repository). */
function ReferenceLink({ subject }: { subject: GHReferencedSubject }) {
const repo = useGhRepository((s) => s.connection?.coordinates ?? null);
const local = subject.__typename === "PullRequest" && sameRepository(repo, repoOfUrl(subject.url));
return (
<div>
{subject.title}&nbsp;
<a
className={styles.link}
href={subject.url}
onClick={(e) => {
e.preventDefault();
if (local) void openPullRequest(subject.number);
else void openInBrowser(subject.url);
}}
>
#{subject.number}
</a>
</div>
);
}
function repoOfUrl(url: string) {
const m = /^https?:\/\/[^/]+\/([^/]+)\/([^/]+)\//.exec(url);
return m ? { owner: m[1]!, name: m[2]! } : null;
}
function ComplexEventItem({ event }: { event: ComplexEvent }) {
const t = useTx();
const at = { actor: event.actor, date: event.createdAt };
const branch = (name: string) => <BranchChip name={name} className={styles.branch} />;
switch (event.__typename) {
case "BaseRefChangedEvent":
return <EventItem {...at}>{t("changed the base branch")}</EventItem>;
case "BaseRefForcePushedEvent":
case "HeadRefForcePushedEvent":
return (
<EventItem {...at}>
{rich(txTemplate("force-pushed the {branch} branch"), {
branch: event.ref
? branch(event.ref.name)
: event.__typename === "BaseRefForcePushedEvent"
? "base"
: "head",
})}
</EventItem>
);
case "HeadRefDeletedEvent":
return (
<EventItem {...at}>
{rich(txTemplate("deleted the {branch} branch"), { branch: branch(event.headRefName) })}
</EventItem>
);
case "HeadRefRestoredEvent":
return <EventItem {...at}>{t("restored head branch")}</EventItem>;
case "ReviewDismissedEvent": {
const login = (event.review?.author ?? GHOST_USER).login;
return (
<EventItem {...at}>
{rich(txTemplate("dismissed {login}'s stale review"), { login: <b>{login}</b> })}
{event.dismissalMessageHTML && <Markdown text={event.dismissalMessageHTML} />}
</EventItem>
);
}
case "ReadyForReviewEvent":
return <EventItem {...at}>{t("marked this pull request as ready for review")}</EventItem>;
case "ConvertToDraftEvent":
return <EventItem {...at}>{t("marked this pull request as draft")}</EventItem>;
case "CrossReferencedEvent":
case "ConnectedEvent":
case "DisconnectedEvent": {
const title =
event.__typename === "CrossReferencedEvent"
? t("mentioned this pull request")
: event.__typename === "ConnectedEvent"
? t("linked an issue that may be closed by this")
: t("removed a link to an issue");
const subject = event.__typename === "CrossReferencedEvent" ? event.source : event.subject;
return (
<ChatItem author={event.actor ?? GHOST_USER} date={event.createdAt}>
<div>{title}</div>
<StatusLine>
<ReferenceLink subject={subject} />
</StatusLine>
</ChatItem>
);
}
}
}
// ---------------------------------------------------------------------------
// Commits, comments, reviews
function CommitsItem({ commits, prAuthor }: { commits: TimelineCommit[]; prAuthor: GHActor | null }) {
const t = useTx();
const n = commits.length;
const single = n === 1 ? commits[0]!.commit.author : null;
return (
<ChatItem author={commitsActor(commits, prAuthor) ?? GHOST_USER} date={single?.date ?? null}>
<div>{n === 1 ? t("added a commit to this pull request") : tx`added ${n} commits to this pull request`}</div>
<StatusLine>
<div className={styles.commits}>
{commits.map(({ commit }) => {
const author = commit.author;
const actor = author ? (author.user ?? GHOST_USER) : null;
return (
<div key={commit.oid} className={styles.commit}>
<p className={styles.paragraph}>
<WebLink url={commit.url}>{commit.abbreviatedOid}</WebLink>&nbsp;{commit.messageHeadline}
</p>
{actor && (
<p className={styles.paragraph}>
{actorLink(actor)}
{author?.date && <>&nbsp;{prettyDateTime(author.date)}</>}
</p>
)}
</div>
);
})}
</div>
</StatusLine>
</ChatItem>
);
}
function ReviewItem({
number,
review,
threads,
}: {
number: number;
review: Extract<TimelineEntry, { kind: "review" }>["review"];
threads: GHPullRequestReviewThread[] | undefined;
}) {
const t = useTx();
const status = reviewStatus(review.state);
const own = threads?.filter((th) => threadReviewId(th) === review.id);
return (
<div>
<CommentItem
author={review.author}
createdAt={review.createdAt}
body={review.body}
canEdit={canEditReview(review)}
onSave={(body) => updateReviewBody(number, review.id, body)}
footer={<StatusLine type={status.type}>{t(status.text)}</StatusLine>}
/>
{own === undefined ? (
<div className={styles.loadingThreads}>{t("Loading…")}</div>
) : (
own.map((th) => <ThreadView key={th.id} number={number} thread={th} variant="full" />)
)}
</div>
);
}
/** One timeline entry. */
export function TimelineItem({
entry,
pr,
threads,
}: {
entry: TimelineEntry;
pr: GHPullRequest;
threads: GHPullRequestReviewThread[] | undefined;
}) {
const number = pr.number;
switch (entry.kind) {
case "comment": {
const c = entry.comment;
return (
<CommentItem
author={c.author}
createdAt={c.createdAt}
body={c.body}
reactions={c.reactions.nodes}
canEdit={c.viewerCanUpdate}
canDelete={c.viewerCanDelete}
canReact={c.viewerCanReact}
onSave={(body) => updateIssueComment(number, c.id, body)}
onDelete={() => deleteIssueComment(number, c.id)}
onToggleReaction={(content, add) => toggleReaction(number, c.id, content, add, "timeline")}
/>
);
}
case "review":
return <ReviewItem number={number} review={entry.review} threads={threads} />;
case "commits":
return <CommitsItem commits={entry.commits} prAuthor={pr.author} />;
case "simple":
return <SimpleEventItem group={entry} />;
case "state":
return <StateEventItem group={entry} />;
case "event":
return <ComplexEventItem event={entry.event} />;
case "unknown": {
const type = entry.type;
return (
<ChatItem author={pr.author ?? GHOST_USER} date={null}>
{tx`Cannot display item - ${type}`}
</ChatItem>
);
}
}
}
+264
Переглянути файл
@@ -0,0 +1,264 @@
import { describe, expect, it } from "vitest";
import type { GHActor, GHLabel, GHPRTimelineItem, GHPullRequestReviewThread, GHUser } from "../api/types";
import {
buildSuggestionPatch,
filterMentions,
groupReactions,
hunkLineNumbers,
insertMention,
mentionQuery,
mergeTimeline,
parsePatchHunk,
reviewStatus,
splitSuggestions,
suggestionDisabledReason,
suggestionTarget,
threadDiffWithAnchor,
toggleReactionLocally,
truncateHunkBefore,
type SimpleGroup,
type StateGroup,
} from "./model";
const user = (login: string, name: string | null = null): GHUser => ({
__typename: "User",
id: `id-${login}`,
login,
url: `https://github.com/${login}`,
avatarUrl: "",
name,
});
const alice = user("alice");
const bob = user("bob");
const label = (name: string): GHLabel => ({ id: `l-${name}`, url: "", name, color: "ff0000" });
const at = (min: number) => new Date(Date.UTC(2024, 0, 1, 12, min)).toISOString();
const ev = (type: string, actor: GHActor | null, min: number, extra: object = {}) =>
({ __typename: type, actor, createdAt: at(min), ...extra }) as GHPRTimelineItem;
const commit = (id: string): GHPRTimelineItem => ({
__typename: "PullRequestCommit",
id,
url: "",
commit: { id, oid: id, abbreviatedOid: id.slice(0, 7), url: "", messageHeadline: id, author: null },
});
describe("mergeTimeline", () => {
it("groups consecutive commits", () => {
const out = mergeTimeline([commit("a"), commit("b"), ev("ClosedEvent", alice, 0), commit("c")]);
expect(out.map((e) => e.kind)).toEqual(["commits", "state", "commits"]);
expect(out[0]).toMatchObject({ commits: [{ id: "a" }, { id: "b" }] });
});
it("merges simple events of one actor within 2 minutes", () => {
const out = mergeTimeline([
ev("LabeledEvent", alice, 0, { label: label("bug") }),
ev("AssignedEvent", alice, 1, { user: bob }),
ev("LabeledEvent", alice, 4, { label: label("ui") }),
ev("LabeledEvent", bob, 4, { label: label("x") }),
]);
expect(out).toHaveLength(3);
const first = out[0] as SimpleGroup;
expect(first.count).toBe(2);
expect(first.addedLabels.map((l) => l.name)).toEqual(["bug"]);
expect(first.assigned.map((u) => u.login)).toEqual(["bob"]);
expect((out[1] as SimpleGroup).addedLabels.map((l) => l.name)).toEqual(["ui"]);
});
it("drops merged events that cancel out", () => {
const out = mergeTimeline([
ev("LabeledEvent", alice, 0, { label: label("bug") }),
ev("UnlabeledEvent", alice, 1, { label: label("bug") }),
ev("ClosedEvent", bob, 5),
ev("ReopenedEvent", bob, 5),
]);
expect(out).toEqual([]);
});
it("keeps a single event even without changes and tracks renames", () => {
const out = mergeTimeline([
ev("RenamedTitleEvent", alice, 0, { previousTitle: "a", currentTitle: "b" }),
ev("RenamedTitleEvent", alice, 1, { previousTitle: "b", currentTitle: "c" }),
]);
expect((out[0] as SimpleGroup).rename).toEqual(["a", "c"]);
});
it("keeps merged as the state of merged state events", () => {
const merged = ev("MergedEvent", alice, 0, { mergedEventCommit: null, mergeRefName: "main" });
const out = mergeTimeline([merged, ev("ClosedEvent", alice, 1)]);
expect((out[0] as StateGroup).newState).toBe("MERGED");
expect((out[0] as StateGroup).last).toBe(merged);
});
it("hides unknown items unless asked", () => {
const items: GHPRTimelineItem[] = [{ __typename: "Unknown", type: "PinnedEvent" }];
expect(mergeTimeline(items)).toEqual([]);
expect(mergeTimeline(items, true)).toMatchObject([{ kind: "unknown", type: "PinnedEvent" }]);
});
it("does not merge across comments", () => {
const comment = { __typename: "IssueComment", id: "c1" } as unknown as GHPRTimelineItem;
const out = mergeTimeline([
ev("AssignedEvent", alice, 0, { user: bob }),
comment,
ev("AssignedEvent", alice, 0, { user: alice }),
]);
expect(out.map((e) => e.kind)).toEqual(["simple", "comment", "simple"]);
});
});
describe("reviewStatus", () => {
it("maps states to IntelliJ's texts", () => {
expect(reviewStatus("APPROVED")).toEqual({ text: "approved the changes", type: "SUCCESS" });
expect(reviewStatus("CHANGES_REQUESTED").type).toBe("ERROR");
expect(reviewStatus("PENDING").text).toBe("started a review");
expect(reviewStatus("DISMISSED").text).toBe("reviewed the changes");
});
});
const HUNK = ["@@ -10,4 +10,5 @@ class A", " a", "-b", "+B", "+B2", " c", " d"].join("\n");
describe("hunks", () => {
it("parses and numbers a hunk", () => {
const h = parsePatchHunk(HUNK)!;
expect(h.startLineBefore).toBe(9);
expect(h.lines.map((l) => l.type)).toEqual(["CONTEXT", "REMOVE", "ADD", "ADD", "CONTEXT", "CONTEXT"]);
expect(hunkLineNumbers(h)).toEqual([
[10, 10],
[11, null],
[null, 11],
[null, 12],
[12, 13],
[13, 14],
]);
});
it("truncates before an index", () => {
const h = truncateHunkBefore(parsePatchHunk(HUNK)!, 2);
expect(h.startLineBefore).toBe(11);
expect(h.startLineAfter).toBe(10);
expect(h.lines).toHaveLength(4);
});
it("keeps the anchor and 3 lines of context before it", () => {
const d = threadDiffWithAnchor({
diffHunk: HUNK,
diffSide: "RIGHT",
originalLine: 14,
startDiffSide: null,
originalStartLine: null,
})!;
expect(d.hunk.lines).toHaveLength(4);
expect(d.anchor).toEqual([3, 4]);
});
it("anchors a multi-line range", () => {
const d = threadDiffWithAnchor({
diffHunk: HUNK,
diffSide: "RIGHT",
originalLine: 14,
startDiffSide: "RIGHT",
originalStartLine: 13,
})!;
expect(d.anchor).toEqual([3, 5]);
expect(d.hunk.lines).toHaveLength(5);
});
it("has no diff for an empty hunk", () => {
expect(
threadDiffWithAnchor({
diffHunk: "",
diffSide: "RIGHT",
originalLine: 1,
startDiffSide: null,
originalStartLine: null,
}),
).toBeNull();
});
});
describe("suggestions", () => {
const thread = {
isResolved: false,
path: "src/a.ts",
line: 12,
startLine: 11,
originalLine: 12,
originalStartLine: 11,
diffHunk: HUNK,
} satisfies Partial<GHPullRequestReviewThread> & { diffHunk: string };
it("splits a body into Markdown and suggestion blocks", () => {
expect(splitSuggestions("Use this:\n```suggestion\nB\nC\n```\nthanks")).toEqual([
{ type: "markdown", text: "Use this:" },
{ type: "suggestion", lines: ["B", "C"] },
{ type: "markdown", text: "thanks" },
]);
expect(splitSuggestions("```suggestion\nx")).toEqual([{ type: "markdown", text: "```suggestion\nx" }]);
});
it("finds the commented code and the applicability", () => {
const t = suggestionTarget(thread);
expect(t).toMatchObject({ applicability: "APPLICABLE", startLineIndex: 10, endLineIndex: 11 });
expect(t.originalLines).toEqual(["B", "B2"]);
expect(suggestionTarget({ ...thread, line: null }).applicability).toBe("OUTDATED");
expect(suggestionTarget({ ...thread, isResolved: true }).applicability).toBe("RESOLVED");
expect(suggestionDisabledReason("APPLICABLE", false)).toMatch(/different branch/);
expect(suggestionDisabledReason("APPLICABLE", true)).toBeNull();
});
it("builds a patch with context against the local file", () => {
const text = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "B", "B2", "c", "d"].join("\n") + "\n";
const r = buildSuggestionPatch(text, suggestionTarget(thread), ["X"]);
expect(r.start).toBe(10);
expect(r.patch).toBe(
["--- a/src/a.ts", "+++ b/src/a.ts", "@@ -8,7 +8,6 @@", " 8", " 9", " a", "-B", "-B2", "+X", " c", " d", ""].join(
"\n",
),
);
});
it("finds moved code and fails when it is gone", () => {
const moved = ["0", "a", "B", "B2", "c"].join("\n");
const r = buildSuggestionPatch(moved, suggestionTarget(thread), ["X"]);
expect(r.start).toBe(2);
expect(r.patch).toContain("\\ No newline at end of file");
expect(() => buildSuggestionPatch("nothing\n", suggestionTarget(thread), ["X"])).toThrow(/does not apply/);
});
it("keeps CRLF line ends", () => {
const r = buildSuggestionPatch("a\r\nB\r\nB2\r\n", suggestionTarget(thread), ["X"]);
expect(r.patch).toContain("-B\r\n");
expect(r.patch).toContain("+X\r\n");
});
});
describe("reactions", () => {
it("groups by content and marks the viewer's", () => {
const rs = [
{ content: "HEART" as const, user: alice },
{ content: "EYES" as const, user: bob },
{ content: "HEART" as const, user: bob },
];
const g = groupReactions(rs, bob.id);
expect(g.map((r) => [r.content, r.users.length, r.isReactedByCurrentUser])).toEqual([
["HEART", 2, true],
["EYES", 1, true],
]);
expect(groupReactions(toggleReactionLocally(rs, "HEART", bob, false), bob.id)[0]!.users).toHaveLength(1);
});
});
describe("mentions", () => {
it("finds the query before the caret", () => {
expect(mentionQuery("hi @al", 6)).toEqual({ start: 3, query: "al" });
expect(mentionQuery("@", 1)).toEqual({ start: 0, query: "" });
expect(mentionQuery("mail@x", 6)).toBeNull();
expect(mentionQuery("hi @al ", 7)).toBeNull();
});
it("filters and inserts", () => {
const users = [user("bob", "Alice Bobson"), user("alice"), user("malik")];
expect(filterMentions(users, "al").map((u) => u.login)).toEqual(["alice", "bob", "malik"]);
expect(insertMention("hi @al!", 3, 6, "alice")).toEqual({ text: "hi @alice !", caret: 10 });
});
});
+672
Переглянути файл
@@ -0,0 +1,672 @@
/**
* Pure rules of the timeline, ported from IntelliJ: merging the raw timeline items
* (`GHPRTimelineMergingModel`, `GHPRTimelineMergedSimpleEvents`, `…StateEvents`), review
* statuses (`GHPRTimelineItemComponentFactory.createReviewStatus`), the thread's diff hunk
* with its anchor (`GHPRTimelineThreadViewModel.calcDiffWithAnchor`, `PatchHunkUtil`),
* suggested changes (`GHPRReviewCommentBodyViewModel`, `GHSuggestedChange`), reactions
* (`GHReactionsViewModel`) and the @mention completion of the comment editor.
*/
import type {
GHActor,
GHIssueComment,
GHLabel,
GHPRTimelineItem,
GHPullRequestRequestedReviewer,
GHPullRequestReview,
GHPullRequestReviewState,
GHPullRequestReviewThread,
GHPullRequestState,
GHReaction,
GHReactionContent,
GHUser,
} from "../api/types";
import { isTeam, reviewerKey } from "../data/model";
// ---------------------------------------------------------------------------
// Merging (`GHPRTimelineMergingModel`)
/** Events from one actor within this time are shown as one item. */
export const MERGE_THRESHOLD_MS = 2 * 60_000;
type Item<T extends GHPRTimelineItem["__typename"]> = Extract<GHPRTimelineItem, { __typename: T }>;
export type TimelineCommit = Item<"PullRequestCommit">;
/** `GHPRTimelineEvent.Simple`: labels, assignees, review requests, title. */
type SimpleEvent = Item<
| "LabeledEvent"
| "UnlabeledEvent"
| "AssignedEvent"
| "UnassignedEvent"
| "ReviewRequestedEvent"
| "ReviewRequestRemovedEvent"
| "RenamedTitleEvent"
>;
/** `GHPRTimelineEvent.State`. */
export type StateEvent = Item<"ClosedEvent" | "ReopenedEvent" | "MergedEvent">;
/** `GHPRTimelineEvent.Branch` and `.Complex`: never merged. */
export type ComplexEvent = Item<
| "BaseRefChangedEvent"
| "BaseRefForcePushedEvent"
| "HeadRefForcePushedEvent"
| "HeadRefDeletedEvent"
| "HeadRefRestoredEvent"
| "ReviewDismissedEvent"
| "ReadyForReviewEvent"
| "ConvertToDraftEvent"
| "CrossReferencedEvent"
| "ConnectedEvent"
| "DisconnectedEvent"
>;
/** Merged simple events (one event is a group of one): what changed overall. */
export interface SimpleGroup {
kind: "simple";
key: string;
actor: GHActor | null;
createdAt: string;
/** Raw events in the group. */
count: number;
addedLabels: GHLabel[];
removedLabels: GHLabel[];
assigned: GHUser[];
unassigned: GHUser[];
addedReviewers: Array<GHPullRequestRequestedReviewer | null>;
removedReviewers: Array<GHPullRequestRequestedReviewer | null>;
/** [from, to]; with several events only when the title really changed. */
rename: [string, string] | null;
}
/** Merged state events: the resulting state and the event that set it. */
export interface StateGroup {
kind: "state";
key: string;
actor: GHActor | null;
createdAt: string;
count: number;
newState: GHPullRequestState;
/** The event that set `newState` (a merge stays the last state). */
last: StateEvent;
inferredOriginalState: GHPullRequestState;
}
export type TimelineEntry =
| { kind: "commits"; key: string; commits: TimelineCommit[] }
| { kind: "comment"; key: string; comment: GHIssueComment }
| { kind: "review"; key: string; review: GHPullRequestReview }
| { kind: "event"; key: string; event: ComplexEvent }
| SimpleGroup
| StateGroup
| { kind: "unknown"; key: string; type: string };
const SIMPLE = new Set<string>([
"LabeledEvent",
"UnlabeledEvent",
"AssignedEvent",
"UnassignedEvent",
"ReviewRequestedEvent",
"ReviewRequestRemovedEvent",
"RenamedTitleEvent",
]);
const STATE = new Set<string>(["ClosedEvent", "ReopenedEvent", "MergedEvent"]);
const isSimple = (i: GHPRTimelineItem): i is SimpleEvent => SIMPLE.has(i.__typename);
const isState = (i: GHPRTimelineItem): i is StateEvent => STATE.has(i.__typename);
export function stateOf(e: StateEvent): GHPullRequestState {
return e.__typename === "ClosedEvent" ? "CLOSED" : e.__typename === "MergedEvent" ? "MERGED" : "OPEN";
}
const actorKey = (a: GHActor | null) => (a ? a.login : null);
const labelKey = (l: GHLabel) => l.id || l.name;
const userKey = (u: GHUser) => u.id || u.login;
const reviewerKeyOrNull = (r: GHPullRequestRequestedReviewer | null) => (r ? reviewerKey(r) : "ghost");
/** Adds `item` unless the opposite list holds it (which then loses it): a label added and removed is no change. */
function toggleIn<T>(add: T[], remove: T[], item: T, key: (t: T) => string) {
const i = remove.findIndex((r) => key(r) === key(item));
if (i >= 0) remove.splice(i, 1);
else if (!add.some((a) => key(a) === key(item))) add.push(item);
}
function addSimple(g: SimpleGroup, e: SimpleEvent) {
switch (e.__typename) {
case "LabeledEvent":
return toggleIn(g.addedLabels, g.removedLabels, e.label, labelKey);
case "UnlabeledEvent":
return toggleIn(g.removedLabels, g.addedLabels, e.label, labelKey);
case "AssignedEvent":
return e.user && toggleIn(g.assigned, g.unassigned, e.user, userKey);
case "UnassignedEvent":
return e.user && toggleIn(g.unassigned, g.assigned, e.user, userKey);
case "ReviewRequestedEvent":
return toggleIn(g.addedReviewers, g.removedReviewers, e.requestedReviewer, reviewerKeyOrNull);
case "ReviewRequestRemovedEvent":
return toggleIn(g.removedReviewers, g.addedReviewers, e.requestedReviewer, reviewerKeyOrNull);
case "RenamedTitleEvent":
g.rename = [g.rename?.[0] ?? e.previousTitle, e.currentTitle];
}
}
function simpleGroup(e: SimpleEvent, key: string): SimpleGroup {
const g: SimpleGroup = {
kind: "simple",
key,
actor: e.actor,
createdAt: e.createdAt,
count: 1,
addedLabels: [],
removedLabels: [],
assigned: [],
unassigned: [],
addedReviewers: [],
removedReviewers: [],
rename: null,
};
addSimple(g, e);
return g;
}
function stateGroup(e: StateEvent, key: string): StateGroup {
const s = stateOf(e);
return {
kind: "state",
key,
actor: e.actor,
createdAt: e.createdAt,
count: 1,
newState: s,
last: e,
inferredOriginalState: s === "OPEN" ? "CLOSED" : "OPEN",
};
}
const later = (a: string, b: string) => (Date.parse(b) > Date.parse(a) ? b : a);
/** `GHPRTimelineMergedSimpleEvents.hasAnyChanges`. */
export function simpleHasChanges(g: SimpleGroup): boolean {
return (
g.addedLabels.length > 0 ||
g.removedLabels.length > 0 ||
g.assigned.length > 0 ||
g.unassigned.length > 0 ||
g.addedReviewers.length > 0 ||
g.removedReviewers.length > 0 ||
effectiveRename(g) !== null
);
}
/** The rename to show: a single event always, a merged one only when the title changed. */
export function effectiveRename(g: SimpleGroup): [string, string] | null {
if (!g.rename) return null;
return g.count === 1 || g.rename[0] !== g.rename[1] ? g.rename : null;
}
/** Merged groups that change nothing (assigned and unassigned again, closed and reopened) are dropped. */
function isCollapsedMerge(e: TimelineEntry): boolean {
if (e.kind === "simple") return e.count > 1 && !simpleHasChanges(e);
if (e.kind === "state") return e.count > 1 && e.newState === e.inferredOriginalState;
return false;
}
function withinThreshold(prev: { actor: GHActor | null; createdAt: string }, e: { createdAt: string }) {
return Date.parse(e.createdAt) - Date.parse(prev.createdAt) <= MERGE_THRESHOLD_MS;
}
/**
* The items as the timeline shows them: consecutive commits grouped, simple and state
* events of one actor within `MERGE_THRESHOLD_MS` of the previous one merged, merges
* that change nothing dropped. Unknown types are kept only with `showUnknown` (IntelliJ
* shows them in internal / EAP builds); the API layer already drops the ignored types.
*/
export function mergeTimeline(items: readonly GHPRTimelineItem[], showUnknown = false): TimelineEntry[] {
const out: TimelineEntry[] = [];
let last: TimelineEntry | null = null;
const push = (e: TimelineEntry | null) => {
if (e && !isCollapsedMerge(e)) out.push(e);
};
items.forEach((item, index) => {
const key = `${index}`;
if (item.__typename === "Unknown") {
if (!showUnknown) return;
push(last);
last = { kind: "unknown", key: `unknown:${key}`, type: item.type };
return;
}
if (item.__typename === "PullRequestCommit") {
if (last?.kind === "commits") last.commits.push(item);
else {
push(last);
last = { kind: "commits", key: `commits:${item.id}`, commits: [item] };
}
return;
}
if (isSimple(item)) {
if (last?.kind === "simple" && actorKey(last.actor) === actorKey(item.actor) && withinThreshold(last, item)) {
last.count++;
last.actor = item.actor;
last.createdAt = later(last.createdAt, item.createdAt);
addSimple(last, item);
} else {
push(last);
last = simpleGroup(item, `simple:${key}`);
}
return;
}
if (isState(item)) {
if (last?.kind === "state" && actorKey(last.actor) === actorKey(item.actor) && withinThreshold(last, item)) {
last.count++;
last.actor = item.actor;
last.createdAt = later(last.createdAt, item.createdAt);
if (last.newState !== "MERGED") {
last.newState = stateOf(item);
last.last = item;
}
} else {
push(last);
last = stateGroup(item, `state:${key}`);
}
return;
}
push(last);
if (item.__typename === "IssueComment") last = { kind: "comment", key: `comment:${item.id}`, comment: item };
else if (item.__typename === "PullRequestReview") last = { kind: "review", key: `review:${item.id}`, review: item };
else last = { kind: "event", key: `event:${key}`, event: item };
});
push(last);
return out;
}
/** The actor an item is shown with (`event.actor ?: ghostUser`); commits: their author's user. */
export function commitsActor(commits: readonly TimelineCommit[], prAuthor: GHActor | null): GHActor | null {
return commits.length === 1 ? (commits[0]!.commit.author?.user ?? prAuthor) : prAuthor;
}
// ---------------------------------------------------------------------------
// Reviews
export type StatusType = "INFO" | "SECONDARY_INFO" | "SUCCESS" | "ERROR" | "WARNING";
/** The review's status line: text and colour. */
export function reviewStatus(state: GHPullRequestReviewState): { text: string; type: StatusType } {
switch (state) {
case "APPROVED":
return { text: "approved the changes", type: "SUCCESS" };
case "CHANGES_REQUESTED":
return { text: "requested changes", type: "ERROR" };
case "PENDING":
return { text: "started a review", type: "SECONDARY_INFO" };
default:
return { text: "reviewed the changes", type: "INFO" };
}
}
/** The review a thread belongs to (its first comment's review). */
export const threadReviewId = (t: GHPullRequestReviewThread) => t.comments.nodes[0]?.pullRequestReview?.id ?? null;
/** `canEdit` of a review: GitHub fails to edit reviews created with an empty body. */
export const canEditReview = (r: GHPullRequestReview) => r.viewerCanUpdate && r.body.length > 0;
/** `getPresentableName`: users' and bots' name, else the login. */
export function presentableName(a: GHActor | GHPullRequestRequestedReviewer | null | undefined): string {
if (!a) return "ghost";
if (isTeam(a)) return a.teamName || a.slug;
return (a as GHActor).name || (a as GHActor).login;
}
// ---------------------------------------------------------------------------
// Diff hunks (`PatchHunk`)
export type HunkLineType = "CONTEXT" | "ADD" | "REMOVE";
export interface HunkLine {
type: HunkLineType;
text: string;
}
/** A unified diff hunk; start lines are 0-based. */
export interface PatchHunk {
startLineBefore: number;
startLineAfter: number;
lines: HunkLine[];
}
/** Parses GitHub's `diffHunk` (`@@ -a,b +c,d @@ …` then the lines); null when it has none. */
export function parsePatchHunk(diffHunk: string): PatchHunk | null {
if (!diffHunk.trim()) return null;
const rows = diffHunk.replace(/\r\n?/g, "\n").split("\n");
if (rows.length > 1 && rows[rows.length - 1] === "") rows.pop();
const header = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(rows[0] ?? "");
if (!header) return null;
const lines: HunkLine[] = [];
for (const row of rows.slice(1)) {
if (row.startsWith("\\")) continue;
const c = row[0];
if (c === "+") lines.push({ type: "ADD", text: row.slice(1) });
else if (c === "-") lines.push({ type: "REMOVE", text: row.slice(1) });
else if (c === " " || row === "") lines.push({ type: "CONTEXT", text: row.slice(1) });
}
return {
startLineBefore: Math.max(0, Number(header[1]) - 1),
startLineAfter: Math.max(0, Number(header[2]) - 1),
lines,
};
}
/** `PatchHunkUtil.truncateHunkBefore`: drops the lines before index `start`, moving the start lines. */
export function truncateHunkBefore(hunk: PatchHunk, start: number): PatchHunk {
if (start <= 0) return hunk;
const dropped = hunk.lines.slice(0, start);
const before = dropped.filter((l) => l.type !== "ADD").length;
const after = dropped.filter((l) => l.type !== "REMOVE").length;
return {
startLineBefore: hunk.startLineBefore + before,
startLineAfter: hunk.startLineAfter + after,
lines: hunk.lines.slice(start),
};
}
/** Lines of the timeline's thread diffs around the anchor (`TimelineDiffComponentFactory.DIFF_CONTEXT_SIZE`). */
export const DIFF_CONTEXT_SIZE = 3;
/**
* The thread's diff (the end of its hunk, where GitHub anchors the comment) and the
* anchor line range in it (`[start, end)` indexes), as `calcDiffWithAnchor`; null when
* the hunk is empty (file comments, some outdated threads).
*/
export function threadDiffWithAnchor(
thread: Pick<GHPullRequestReviewThread, "diffSide" | "originalLine" | "startDiffSide" | "originalStartLine"> & {
diffHunk: string;
},
): { hunk: PatchHunk; anchor: [number, number] } | null {
const hunk = parsePatchHunk(thread.diffHunk);
if (!hunk || !hunk.lines.length) return null;
const anchor = thread.originalLine != null ? { side: thread.diffSide, line: thread.originalLine - 1 } : null;
const startAnchor =
thread.startDiffSide && thread.originalStartLine != null
? {
side: thread.startDiffSide,
line: Math.max(
thread.originalStartLine - 1,
thread.startDiffSide === "LEFT" ? hunk.startLineBefore : hunk.startLineAfter,
),
}
: null;
const anchorLength =
startAnchor?.side === anchor?.side ? Math.max(0, (anchor?.line ?? 0) - (startAnchor?.line ?? 0)) : 0;
const truncated = truncateHunkBefore(hunk, hunk.lines.length - 1 - (anchorLength + DIFF_CONTEXT_SIZE));
return { hunk: truncated, anchor: [truncated.lines.length - 1 - anchorLength, truncated.lines.length] };
}
/** The diff hunk of a thread (its first comment's). */
export const threadDiffHunk = (t: GHPullRequestReviewThread) => t.comments.nodes[0]?.diffHunk ?? "";
/** Line numbers (1-based) of each hunk line on both sides; null where the line does not exist on that side. */
export function hunkLineNumbers(hunk: PatchHunk): Array<[number | null, number | null]> {
let before = hunk.startLineBefore;
let after = hunk.startLineAfter;
return hunk.lines.map((l) => {
if (l.type === "ADD") return [null, ++after];
if (l.type === "REMOVE") return [++before, null];
return [++before, ++after];
});
}
// ---------------------------------------------------------------------------
// Suggested changes
export const SUGGESTION_BLOCK_START = "```suggestion";
const SUGGESTION_BLOCK_END = "```";
/**
* Whether a comment holds a suggested change (its range cannot be adjusted in the diff).
* @public
*/
export const containsSuggestedChange = (markdown: string) =>
markdown.split(/\r?\n/).some((l) => l.startsWith(SUGGESTION_BLOCK_START));
export type BodyBlock = { type: "markdown"; text: string } | { type: "suggestion"; lines: string[] };
/**
* A comment body split into Markdown and ```` ```suggestion ```` blocks (`getSuggestions`:
* a block starts with a line starting with the fence and ends at the next line ending with
* ```` ``` ````). An unterminated block stays Markdown.
*/
export function splitSuggestions(body: string): BodyBlock[] {
const rows = body.replace(/\r\n?/g, "\n").split("\n");
const out: BodyBlock[] = [];
let text: string[] = [];
let block: string[] | null = null;
let opening = "";
const flushText = () => {
if (text.join("\n").trim()) out.push({ type: "markdown", text: text.join("\n") });
text = [];
};
for (const row of rows) {
if (block === null && row.startsWith(SUGGESTION_BLOCK_START)) {
block = [];
opening = row;
continue;
}
if (block !== null && row.endsWith(SUGGESTION_BLOCK_END)) {
flushText();
out.push({ type: "suggestion", lines: block });
block = null;
continue;
}
if (block !== null) block.push(row);
else text.push(row);
}
if (block !== null) text.push(opening, ...block);
flushText();
return out;
}
export type SuggestionApplicability = "APPLICABLE" | "RESOLVED" | "OUTDATED";
/** Where a suggestion applies: 0-based line indexes of the current and original code. */
export interface SuggestionTarget {
path: string;
applicability: SuggestionApplicability;
/** The current lines (`line` / `startLine`), null when outdated. */
startLineIndex: number | null;
endLineIndex: number | null;
/** The commented code as the diff hunk shows it (its RIGHT side). */
originalLines: string[];
}
/** The lines the thread comments on and whether a suggestion in it can be applied. */
export function suggestionTarget(
thread: Pick<
GHPullRequestReviewThread,
"isResolved" | "path" | "line" | "startLine" | "originalLine" | "originalStartLine"
> & { diffHunk: string },
): SuggestionTarget {
const originalEnd = thread.originalLine != null ? thread.originalLine - 1 : null;
const originalStart = thread.originalStartLine != null ? thread.originalStartLine - 1 : originalEnd;
const end = thread.line != null ? thread.line - 1 : null;
const start = thread.startLine != null ? thread.startLine - 1 : end;
const hunk = parsePatchHunk(thread.diffHunk);
let originalLines: string[] = [];
if (hunk) {
if (originalStart != null && originalEnd != null) {
const numbers = hunkLineNumbers(hunk);
originalLines = hunk.lines
.filter((l, i) => {
const n = numbers[i]![1];
return l.type !== "REMOVE" && n != null && n - 1 >= originalStart && n - 1 <= originalEnd;
})
.map((l) => l.text);
} else {
originalLines = hunk.lines
.filter((l) => l.type !== "REMOVE")
.slice(-1)
.map((l) => l.text);
}
}
const applicability: SuggestionApplicability = thread.isResolved
? "RESOLVED"
: originalStart == null || originalEnd == null || start == null || end == null
? "OUTDATED"
: "APPLICABLE";
return { path: thread.path, applicability, startLineIndex: start, endLineIndex: end, originalLines };
}
/** The disabled tooltip of "Apply Locally" (null: it can be applied). */
export function suggestionDisabledReason(
applicability: SuggestionApplicability,
onReviewBranch: boolean,
): string | null {
if (!onReviewBranch) return "Unable to apply the suggested change from a pull request in a different branch";
if (applicability === "RESOLVED") return "This suggestion has been resolved";
if (applicability === "OUTDATED") return "Outdated suggestions cannot be applied";
return null;
}
/**
* A git patch replacing the target lines of `text` (the local file) with `suggestion`,
* with up to 3 lines of context, and where the new lines are (0-based start, count). The
* lines are looked up at the thread's line first, then at the nearest place holding the
* same code; throws when the file no longer has them.
*/
export function buildSuggestionPatch(
text: string,
target: SuggestionTarget,
suggestion: readonly string[],
): { patch: string; start: number; lines: number } {
if (target.startLineIndex == null) throw new Error("Outdated suggestions cannot be applied");
const crlf = text.includes("\r\n");
// Lines keep their "\r" so that the patch matches CRLF files.
const lines = text.split("\n");
const trailingNewline = lines[lines.length - 1] === "";
if (trailingNewline) lines.pop();
const bare = (l: string) => l.replace(/\r$/, "");
const original = target.originalLines;
const matchesAt = (at: number) =>
at >= 0 && at + original.length <= lines.length && original.every((l, i) => bare(lines[at + i]!) === l);
let at = -1;
const wanted = target.startLineIndex;
for (let d = 0; original.length && d <= lines.length + wanted && at < 0; d++) {
if (matchesAt(wanted - d)) at = wanted - d;
else if (d > 0 && matchesAt(wanted + d)) at = wanted + d;
}
if (at < 0) throw new Error(`${target.path}: the suggested change does not apply`);
const ctxStart = Math.max(0, at - 3);
const ctxEnd = Math.min(lines.length, at + original.length + 3);
const before = lines.slice(ctxStart, at);
const removed = lines.slice(at, at + original.length);
const after = lines.slice(at + original.length, ctxEnd);
const oldCount = before.length + removed.length + after.length;
const newCount = before.length + suggestion.length + after.length;
const body = [
...before.map((l) => ` ${l}`),
...removed.map((l) => `-${l}`),
...suggestion.map((l) => `+${l}${crlf ? "\r" : ""}`),
...after.map((l) => ` ${l}`),
];
if (ctxEnd === lines.length && !trailingNewline) body.push("\\ No newline at end of file");
const range = (n: number) => `${n ? ctxStart + 1 : ctxStart},${n}`;
const header = `@@ -${range(oldCount)} +${range(newCount)} @@`;
const patch = [`--- a/${target.path}`, `+++ b/${target.path}`, header, ...body].join("\n") + "\n";
return { patch, start: at, lines: suggestion.length };
}
// ---------------------------------------------------------------------------
// Reactions
/** GitHub's reactions, in its order, with the emoji and name (`presentableName`). */
export const REACTIONS: ReadonlyArray<{ content: GHReactionContent; emoji: string; name: string }> = [
{ content: "THUMBS_UP", emoji: "👍", name: "thumbs up" },
{ content: "THUMBS_DOWN", emoji: "👎", name: "thumbs down" },
{ content: "LAUGH", emoji: "😄", name: "laugh" },
{ content: "HOORAY", emoji: "🎉", name: "hooray" },
{ content: "CONFUSED", emoji: "😕", name: "confused" },
{ content: "HEART", emoji: "❤️", name: "heart" },
{ content: "ROCKET", emoji: "🚀", name: "rocket" },
{ content: "EYES", emoji: "👀", name: "eyes" },
];
export interface ReactionInfo {
content: GHReactionContent;
users: GHUser[];
isReactedByCurrentUser: boolean;
}
/** `reactionsWithInfo`: reactions grouped by content, in order of first appearance. */
export function groupReactions(
reactions: readonly GHReaction[],
currentUserId: string | null | undefined,
): ReactionInfo[] {
const map = new Map<GHReactionContent, ReactionInfo>();
for (const r of reactions) {
const info = map.get(r.content) ?? { content: r.content, users: [], isReactedByCurrentUser: false };
if (r.user) {
info.users.push(r.user);
if (currentUserId && r.user.id === currentUserId) info.isReactedByCurrentUser = true;
}
map.set(r.content, info);
}
return [...map.values()];
}
/** The viewer's reaction toggled locally (optimistic update before the reload). */
export function toggleReactionLocally(
reactions: readonly GHReaction[],
content: GHReactionContent,
me: GHUser,
add: boolean,
): GHReaction[] {
return add
? [...reactions, { content, user: me }]
: reactions.filter((r) => !(r.content === content && r.user?.id === me.id));
}
// ---------------------------------------------------------------------------
// @mentions
/** The `@query` being typed before the caret, with where it starts; null when not in a mention. */
export function mentionQuery(text: string, caret: number): { start: number; query: string } | null {
const before = text.slice(0, caret);
const m = /(^|[^\w`])@([\w-]*)$/.exec(before);
if (!m) return null;
return { start: caret - m[2]!.length - 1, query: m[2]! };
}
/** Users matching `query` (login or name, prefixes first), at most `limit`. */
export function filterMentions<U extends Pick<GHUser, "login" | "name">>(
users: readonly U[],
query: string,
limit = 10,
) {
const q = query.toLowerCase();
const scored: Array<{ u: U; score: number }> = [];
for (const u of users) {
const login = u.login.toLowerCase();
const name = (u.name ?? "").toLowerCase();
const score = !q
? 2
: login.startsWith(q)
? 0
: name.split(/\s+/).some((w) => w.startsWith(q))
? 1
: login.includes(q) || name.includes(q)
? 2
: -1;
if (score >= 0) scored.push({ u, score });
}
return scored
.sort((a, b) => a.score - b.score || a.u.login.localeCompare(b.u.login))
.slice(0, limit)
.map((s) => s.u);
}
/** `text` with the mention at `start…caret` replaced by `@login `; the new caret. */
export function insertMention(
text: string,
start: number,
caret: number,
login: string,
): { text: string; caret: number } {
const insert = `@${login} `;
return { text: text.slice(0, start) + insert + text.slice(caret), caret: start + insert.length };
}