Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/web/src/components/diffs/DiffCommentAnnotation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { MessageCircle, Trash2 } from "lucide-react";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";

import { Button } from "~/components/ui/button";
import { Textarea } from "~/components/ui/textarea";
import { MentionTextarea } from "~/components/ui/mention-textarea";

import { isCommentSubmitShortcut } from "./commentSubmitShortcut";

Expand Down Expand Up @@ -88,7 +88,7 @@ export function DiffCommentAnnotation({
contentEditable={false}
onPointerDown={(event) => event.stopPropagation()}
>
<Textarea
<MentionTextarea
ref={textareaRef}
autoFocus={focusOnMount}
unstyled
Expand All @@ -97,7 +97,7 @@ export function DiffCommentAnnotation({
value={displayedText}
placeholder={placeholder}
aria-label={`Comment on lines ${rangeLabel}`}
onChange={(event) => (onTextChange ?? setLocalDraftText)(event.target.value)}
onValueChange={onTextChange ?? setLocalDraftText}
onFocus={(event) => {
const end = event.currentTarget.value.length;
event.currentTarget.setSelectionRange(end, end);
Expand Down
18 changes: 17 additions & 1 deletion apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
import { usePullRequestStack } from "~/state/usePullRequestStack";
import { PullRequestMentionProvider } from "./PullRequestMentionProvider";
import { RefreshIcon } from "~/components/ui/refresh-icon";
import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
Expand Down Expand Up @@ -1460,7 +1461,7 @@ export function PullRequestDetailPanel({
return <PullRequestDetailGhost seed={matchingListEntry} />;
}

return (
const content = (
<div className="flex h-full min-h-0 w-full flex-col bg-background">
{threadPickerOpen && detail ? (
<PullRequestThreadLinks
Expand Down Expand Up @@ -2693,4 +2694,19 @@ export function PullRequestDetailPanel({
</AlertDialog>
</div>
);
return (
<PullRequestMentionProvider
key={JSON.stringify([
environmentId,
reference.projectId,
reference.repository,
reference.number,
])}
environmentId={environmentId}
reference={reference}
detail={detail}
>
{content}
</PullRequestMentionProvider>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
import { cn } from "~/lib/utils";

import { Button } from "../ui/button";
import { Textarea } from "../ui/textarea";
import { MentionTextarea } from "../ui/mention-textarea";
import { Toggle, ToggleGroup } from "../ui/toggle-group";
import { PullRequestMarkdown } from "./PullRequestMarkdown";

Expand Down Expand Up @@ -105,14 +105,14 @@ export function PullRequestMarkdownEditor({
)}
</div>
) : (
<Textarea
<MentionTextarea
autoFocus
disabled={saving}
value={draft}
rows={6}
placeholder={placeholder}
aria-label={label}
onChange={(event) => setDraft(event.target.value)}
onValueChange={setDraft}
/>
)}
<div className="flex justify-end gap-2">
Expand Down
58 changes: 58 additions & 0 deletions apps/web/src/components/pullRequest/PullRequestMentionProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { EnvironmentId, PullRequestDetailView, PullRequestRef } from "@t3tools/contracts";
import { useMemo, useState, type ReactNode } from "react";
import { pullRequestEnvironment } from "~/state/pullRequests";
import { useEnvironmentQuery } from "~/state/query";
import { MentionSuggestionsContext } from "../ui/mention-textarea";

export function PullRequestMentionProvider({
environmentId,
reference,
detail,
children,
}: {
environmentId: EnvironmentId;
reference: PullRequestRef;
detail: PullRequestDetailView | null;
children: ReactNode;
}) {
const [requested, setRequested] = useState(false);
// These hosts accept @login. Other hosts require identities their actor records do not carry.
const supported = detail && ["github", "gitea", "gitlab"].includes(detail.provider);
const loadCandidates =
supported &&
requested &&
detail.capabilities.reviewers.listCandidates &&
detail.viewerPermissions.requestReviewers;
const query = useEnvironmentQuery(
loadCandidates
? pullRequestEnvironment.reviewerCandidates({ environmentId, input: reference })
: null,
);
const candidates = useMemo(() => {
if (!detail) return [];
return [
...(detail.author ? [detail.author] : []),
...detail.reviewers,
...detail.comments.flatMap((comment) => (comment.author ? [comment.author] : [])),
...detail.reviewThreads.flatMap((thread) =>
thread.comments.flatMap((comment) => (comment.author ? [comment.author] : [])),
),
...(query.data?.candidates.filter((candidate) => candidate.kind === "user") ?? []),
];
}, [detail, query.data]);
return (
<MentionSuggestionsContext
value={
supported
? {
candidates,
onRequest: () => setRequested(true),
pending: !!loadCandidates && query.isPending,
}
: null
}
>
{children}
</MentionSuggestionsContext>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { formatRelativeTimeLabel } from "~/timestampFormat";
import { cn } from "~/lib/utils";

import { Button } from "../ui/button";
import { Textarea } from "../ui/textarea";
import { MentionTextarea } from "../ui/mention-textarea";
import { isCommentSubmitShortcut } from "../diffs/commentSubmitShortcut";
import {
editPullRequestThreadComment,
Expand Down Expand Up @@ -329,13 +329,13 @@ export function ReviewThreadCard({
{canReply ? (
replying ? (
<div className="mt-2">
<Textarea
<MentionTextarea
autoFocus
size="sm"
value={reply}
placeholder="Reply"
aria-label="Reply to this conversation"
onChange={(event) => setReply(event.target.value)}
onValueChange={setReply}
onKeyDown={submitKeys({
value: reply,
pending,
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/pullRequest/PullRequestReviewBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { pullRequestEnvironment } from "~/state/pullRequests";
import { useAtomCommand } from "~/state/use-atom-command";

import { Button } from "../ui/button";
import { Textarea } from "../ui/textarea";
import { MentionTextarea } from "../ui/mention-textarea";
import { toastManager } from "../ui/toast";
import {
pullRequestReviewKey,
Expand Down Expand Up @@ -124,13 +124,13 @@ export function PullRequestReviewBar({
</Button>
) : null}
</div>
<Textarea
<MentionTextarea
size="sm"
className="mt-2"
value={body}
placeholder="Summarize your review (optional)"
aria-label="Review summary"
onChange={(event) => setSummary(reviewKey, event.target.value)}
onValueChange={(value) => setSummary(reviewKey, value)}
/>
<div className="mt-2 flex flex-wrap justify-end gap-2">
{offered.map((verdict) => (
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { formatRelativeTimeLabel } from "~/timestampFormat";

import { Button } from "../ui/button";
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
import { Textarea } from "../ui/textarea";
import { MentionTextarea } from "../ui/mention-textarea";
import { toastManager } from "../ui/toast";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import {
Expand Down Expand Up @@ -375,15 +375,15 @@ function CommentComposer({

return (
<div className="mt-3 space-y-2">
<Textarea
<MentionTextarea
// Locked while posting: the body is cleared on success, which would otherwise throw
// away a new draft typed while the request was still in flight.
disabled={submitting !== null || actionPending}
value={body}
rows={3}
placeholder="Leave a comment"
aria-label="Comment on this pull request"
onChange={(event) => setBody(event.target.value)}
onValueChange={setBody}
/>
<div className="flex justify-end gap-2">
{followUpAction === null ? null : (
Expand Down
90 changes: 90 additions & 0 deletions apps/web/src/components/ui/mention-textarea.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { act, useState } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
import { MentionSuggestionsContext, MentionTextarea } from "./mention-textarea";

vi.mock("./textarea", () => ({
Textarea: (props: React.ComponentProps<"textarea">) => <textarea {...props} />,
}));

let renderer: ReactTestRenderer;
const onKeyDown = vi.fn();
const onRequest = vi.fn();
function Editor() {
const [value, setValue] = useState("Please @a review this");
return (
<MentionSuggestionsContext
value={{ candidates: [{ login: "alice" }, { login: "adam" }], pending: false, onRequest }}
>
<MentionTextarea value={value} onValueChange={setValue} onKeyDown={onKeyDown} />
<button type="button" onClick={() => setValue("")}>
Clear
</button>
</MentionSuggestionsContext>
);
}
const input = () => renderer.root.findByType("textarea");
const keyboard = (key: string, ctrlKey = false) => ({
key,
ctrlKey,
nativeEvent: { isComposing: false },
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});

beforeEach(async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("requestAnimationFrame", (callback: () => void) => {
callback();
return 1;
});
onKeyDown.mockClear();
onRequest.mockClear();
await act(async () => {
renderer = create(<Editor />);
});
await act(async () =>
input().props.onSelect({
currentTarget: { value: "Please @a review this", selectionStart: 9, selectionEnd: 9 },
}),
);
});
afterEach(async () => {
await act(async () => renderer.unmount());
vi.unstubAllGlobals();
});

it("completes the selected person without submitting or losing the rest of the review", async () => {
expect(onRequest).toHaveBeenCalledOnce();
await act(async () => input().props.onKeyDown(keyboard("ArrowDown")));
const enter = keyboard("Enter");
await act(async () => input().props.onKeyDown(enter));
expect(input().props.value).toBe("Please @adam review this");
expect(enter.preventDefault).toHaveBeenCalledOnce();
expect(onKeyDown).not.toHaveBeenCalled();
expect(renderer.root.findAllByProps({ role: "listbox" })).toHaveLength(0);
});

it("dismisses suggestions before Escape reaches the review editor", async () => {
await act(async () => input().props.onKeyDown(keyboard("Escape")));
expect(onKeyDown).not.toHaveBeenCalled();
expect(input().props.value).toBe("Please @a review this");
await act(async () => input().props.onKeyDown(keyboard("Escape")));
expect(onKeyDown).toHaveBeenCalledOnce();
});

it("keeps Ctrl+Enter available for submitting the review", async () => {
const shortcut = keyboard("Enter", true);
await act(async () => input().props.onKeyDown(shortcut));
expect(onKeyDown).toHaveBeenCalledWith(shortcut);
expect(input().props.value).toBe("Please @a review this");
});

it("dismisses a stale mention when the parent clears the comment without blurring", async () => {
expect(renderer.root.findAllByProps({ role: "listbox" })).toHaveLength(1);
await act(async () => renderer.root.findByType("button").props.onClick());
expect(renderer.root.findAllByProps({ role: "listbox" })).toHaveLength(0);
await act(async () => input().props.onKeyDown(keyboard("Enter")));
expect(input().props.value).toBe("");
expect(onKeyDown).toHaveBeenCalledOnce();
});
Loading
Loading