дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
feat(github): pull request tool window, timeline and review flow (#477)
Replaces the Pull Requests tool window with a full review workflow on top of gh's authentication. - List with search, quick filters and paging; a tab per pull request and a New Pull Request tab. - Details: commit picker, changes tree with viewed state, CI and reviewer status, merge, close and reopen. - Timeline editor: comments, review threads, reactions and suggested changes. - Review in the diff viewer and, for the checked-out branch, in the editor (review mode). - New generic vcs extension points: registerDiffViewerProvider, lineInlays, reviewGutter, branch widget extensions; new vcs_merge_base and gh_api commands. - CSP: img-src allows https://avatars.githubusercontent.com for avatars. Fixed while testing against a real repository: remotes behind an SSH host alias, mergeability stuck on UNKNOWN, Apply Locally staying disabled after a checkout, relative links navigating the app's page, and review inlays wider than the pane. Co-authored-by: imacross <[email protected]>
Цей коміт міститься в:
1 джерело
696377acc2
коміт
059fd8a69b
192 files changed
+23668
-2076
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}> {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>
|
||||
));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
<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> {commit.messageHeadline}
|
||||
</p>
|
||||
{actor && (
|
||||
<p className={styles.paragraph}>
|
||||
{actorLink(actor)}
|
||||
{author?.date && <> {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>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача