diff --git a/apps/dashboard/src/components/compare/compare-diff-view.tsx b/apps/dashboard/src/components/compare/compare-diff-view.tsx new file mode 100644 index 0000000..4b42b24 --- /dev/null +++ b/apps/dashboard/src/components/compare/compare-diff-view.tsx @@ -0,0 +1,248 @@ +import { FileIcon, GitCommitIcon } from "@diffkit/icons"; +import { cn } from "@diffkit/ui/lib/utils"; +import type { DiffLineAnnotation } from "@pierre/diffs/react"; +import { Link } from "@tanstack/react-router"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { ReviewFileDiffBlock } from "#/components/pulls/review/review-file-diff-block"; +import type { + PendingComment, + ReviewAnnotation, +} from "#/components/pulls/review/review-types"; +import { encodeFileId } from "#/components/pulls/review/review-utils"; +import { formatRelativeTime } from "#/lib/format-relative-time"; +import type { + PullCommit, + PullFile, + PullReviewComment, +} from "#/lib/github.types"; + +const EMPTY_ANNOTATIONS: DiffLineAnnotation[] = []; +const EMPTY_PENDING: PendingComment[] = []; +const EMPTY_REPLIES: ReadonlyMap = new Map(); +const EMPTY_THREAD_INFO: ReadonlyMap< + number, + { threadId: string; isResolved: boolean } +> = new Map(); + +const INITIAL_VISIBLE_COUNT = 30; +const LOAD_MORE_CHUNK = 12; + +const noop = () => {}; +const noopRange = () => {}; +const noopAnnotation = ( + _a: DiffLineAnnotation | PendingComment, +) => {}; +const noopEdit = (_a: PendingComment, _b: string) => {}; + +export function CompareDiffView({ + commits, + files, + owner, + repo, +}: { + commits: PullCommit[]; + files: PullFile[]; + owner: string; + repo: string; +}) { + const [diffStyle, setDiffStyle] = useState<"unified" | "split">("split"); + const [visibleCount, setVisibleCount] = useState(() => + Math.min(files.length, INITIAL_VISIBLE_COUNT), + ); + const loadMoreRef = useRef(null); + + useEffect(() => { + setVisibleCount((prev) => + Math.min( + files.length, + Math.max(files.length === 0 ? 0 : INITIAL_VISIBLE_COUNT, prev), + ), + ); + }, [files.length]); + + useEffect(() => { + if (visibleCount >= files.length) return; + const sentinel = loadMoreRef.current; + if (!sentinel) return; + + const observer = new IntersectionObserver( + (entries) => { + if (!entries[0]?.isIntersecting) return; + setVisibleCount((prev) => + Math.min(files.length, prev + LOAD_MORE_CHUNK), + ); + }, + { rootMargin: "3000px 0px" }, + ); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [files.length, visibleCount]); + + const visibleFiles = useMemo( + () => files.slice(0, visibleCount), + [files, visibleCount], + ); + + const totals = useMemo(() => { + let additions = 0; + let deletions = 0; + for (const f of files) { + additions += f.additions; + deletions += f.deletions; + } + return { additions, deletions }; + }, [files]); + + return ( +
+
+

+ + Commits + + ({commits.length}) + +

+ {commits.length === 0 ? ( +

+ No new commits. +

+ ) : ( +
    + {commits.map((commit) => { + const firstLine = commit.message.split("\n")[0]; + return ( +
  1. +
    + +
    + {commit.author ? ( + {commit.author.login} + ) : ( +
    + )} + + {firstLine} + + + {commit.sha.slice(0, 7)} + + {commit.createdAt ? ( + + {formatRelativeTime(commit.createdAt)} + + ) : null} +
  2. + ); + })} +
+ )} +
+ +
+
+

+ + Files changed + + ({files.length}) + + {files.length > 0 ? ( + + + +{totals.additions} + + + -{totals.deletions} + + + ) : null} +

+ {files.length > 0 ? ( +
+ + +
+ ) : null} +
+ {files.length === 0 ? ( +

+ No files changed. +

+ ) : ( +
+ {visibleFiles.map((file) => ( + + ))} + + {visibleCount < files.length ? ( + <> +
+
+ Showing {visibleCount} of {files.length} files +
+ + ) : null} +
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/components/compare/compare-form.tsx b/apps/dashboard/src/components/compare/compare-form.tsx new file mode 100644 index 0000000..fb09269 --- /dev/null +++ b/apps/dashboard/src/components/compare/compare-form.tsx @@ -0,0 +1,142 @@ +import { + ChevronDownIcon, + GitPullRequestDraftIcon, + GitPullRequestIcon, +} from "@diffkit/icons"; +import { Button } from "@diffkit/ui/components/button"; +import { + MarkdownEditor, + type MentionConfig, +} from "@diffkit/ui/components/markdown-editor"; +import { Spinner } from "@diffkit/ui/components/spinner"; +import { cn } from "@diffkit/ui/lib/utils"; +import { Link } from "@tanstack/react-router"; +import { type Dispatch, type SetStateAction, useRef, useState } from "react"; + +export function CompareForm({ + title, + body, + onTitleChange, + onBodyChange, + onSubmit, + submitting, + error, + canSubmit, + mentionConfig, + owner, + repo, +}: { + title: string; + body: string; + onTitleChange: (v: string) => void; + onBodyChange: Dispatch>; + onSubmit: (draft: boolean) => void; + submitting: boolean; + error: string | null; + canSubmit: boolean; + mentionConfig?: MentionConfig; + owner: string; + repo: string; +}) { + const [draftMode, setDraftMode] = useState(false); + const titleRef = useRef(null); + const label = draftMode ? "Create draft pull request" : "Create pull request"; + + const handleExecute = () => { + if (submitting) return; + if (!title.trim()) { + titleRef.current?.focus(); + return; + } + if (!canSubmit) return; + onSubmit(draftMode); + }; + + return ( +
+
+ + onTitleChange(e.target.value)} + placeholder="Pull request title" + // biome-ignore lint/a11y/noAutofocus: intentional — this is a dedicated PR-creation page + autoFocus + className="flex h-9 w-full rounded-md border bg-surface-1 px-3 py-1 text-sm outline-none transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]" + /> +
+ +
+ Description + +
+ + {error ? ( +
+ {error} +
+ ) : null} + +
+ + +
+ + +
+
+
+ ); +} diff --git a/apps/dashboard/src/components/compare/compare-header.tsx b/apps/dashboard/src/components/compare/compare-header.tsx new file mode 100644 index 0000000..a1add82 --- /dev/null +++ b/apps/dashboard/src/components/compare/compare-header.tsx @@ -0,0 +1,84 @@ +import { GitCompareIcon, GitPullRequestIcon } from "@diffkit/icons"; +import { Link } from "@tanstack/react-router"; +import type { CompareDetail } from "#/lib/github.functions"; + +export function CompareHeader({ + owner, + repo, + base, + head, + compare, +}: { + owner: string; + repo: string; + base: string; + head: string; + compare: CompareDetail; +}) { + const { aheadBy, behindBy, status } = compare; + const canPr = aheadBy > 0; + + return ( +
+
+ + {owner}/{repo} + + / + Compare +
+ +
+
+ +
+

+ Open a pull request +

+
+ +
+ + base: + + {base} + + + compare: + + {head} + + + {canPr ? ( + + + {aheadBy} + + {aheadBy === 1 ? "commit" : "commits"} ahead + + ) : null} + {behindBy > 0 ? ( + <> + · + + + {behindBy} + + {behindBy === 1 ? "commit" : "commits"} behind + + + ) : null} + {status === "identical" ? Branches are identical : null} + +
+
+ ); +} diff --git a/apps/dashboard/src/components/compare/compare-page.tsx b/apps/dashboard/src/components/compare/compare-page.tsx new file mode 100644 index 0000000..99e99e3 --- /dev/null +++ b/apps/dashboard/src/components/compare/compare-page.tsx @@ -0,0 +1,311 @@ +import type { MentionCandidate } from "@diffkit/ui/components/markdown-editor"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "@tanstack/react-router"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + DetailPageSkeletonLayout, + StaggerItem, +} from "#/components/details/detail-page"; +import { createPullRequest } from "#/lib/github.functions"; +import { + type GitHubQueryScope, + githubCompareDetailQueryOptions, + githubRepoCollaboratorsQueryOptions, + githubRepoOverviewQueryOptions, + githubRepoTemplateQueryOptions, + githubViewerQueryOptions, +} from "#/lib/github.query"; +import type { + GitHubLabel, + OrgTeam, + RepoCollaborator, +} from "#/lib/github.types"; +import { useHasMounted } from "#/lib/use-has-mounted"; +import { CompareDiffView } from "./compare-diff-view"; +import { CompareForm } from "./compare-form"; +import { CompareHeader } from "./compare-header"; +import { CompareSidebar } from "./compare-sidebar"; + +export function ComparePage({ + owner, + repo, + base, + head, + scope, + showForm = false, +}: { + owner: string; + repo: string; + base: string; + head: string; + scope: GitHubQueryScope; + showForm?: boolean; +}) { + const hasMounted = useHasMounted(); + const router = useRouter(); + const queryClient = useQueryClient(); + + const overviewQuery = useQuery({ + ...githubRepoOverviewQueryOptions(scope, { owner, repo }), + enabled: hasMounted, + }); + const viewerQuery = useQuery({ + ...githubViewerQueryOptions(scope), + enabled: hasMounted, + }); + const compareQuery = useQuery({ + ...githubCompareDetailQueryOptions(scope, { owner, repo, base, head }), + enabled: hasMounted, + }); + const collaboratorsQuery = useQuery({ + ...githubRepoCollaboratorsQueryOptions(scope, { owner, repo }), + enabled: hasMounted, + }); + const templateQuery = useQuery({ + ...githubRepoTemplateQueryOptions(scope, { owner, repo, kind: "pr" }), + enabled: hasMounted && showForm, + }); + + const repoData = overviewQuery.data; + const viewer = viewerQuery.data ?? null; + const compare = compareQuery.data; + + const mentionCandidates: MentionCandidate[] = useMemo( + () => + (collaboratorsQuery.data ?? []).map((c) => ({ + id: c.login, + label: c.login, + avatarUrl: c.avatarUrl, + })), + [collaboratorsQuery.data], + ); + const mentionConfig = useMemo( + () => ({ + candidates: mentionCandidates, + isLoading: collaboratorsQuery.isLoading, + }), + [mentionCandidates, collaboratorsQuery.isLoading], + ); + + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const templateAppliedRef = useRef(false); + useEffect(() => { + if (!showForm) return; + if (templateAppliedRef.current) return; + const template = templateQuery.data; + if (!template) return; + templateAppliedRef.current = true; + setBody((current) => (current ? current : template)); + }, [showForm, templateQuery.data]); + const [selectedLabels, setSelectedLabels] = useState([]); + const [selectedAssignees, setSelectedAssignees] = useState< + RepoCollaborator[] + >([]); + const [selectedReviewers, setSelectedReviewers] = useState< + RepoCollaborator[] + >([]); + const [selectedTeamReviewers, setSelectedTeamReviewers] = useState( + [], + ); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const toggleLabel = useCallback((label: GitHubLabel) => { + setSelectedLabels((prev) => + prev.some((l) => l.name === label.name) + ? prev.filter((l) => l.name !== label.name) + : [...prev, label], + ); + }, []); + + const toggleAssignee = useCallback((c: RepoCollaborator) => { + setSelectedAssignees((prev) => + prev.some((a) => a.login === c.login) + ? prev.filter((a) => a.login !== c.login) + : [...prev, c], + ); + }, []); + + const toggleReviewer = useCallback((c: RepoCollaborator) => { + setSelectedReviewers((prev) => + prev.some((r) => r.login === c.login) + ? prev.filter((r) => r.login !== c.login) + : [...prev, c], + ); + }, []); + + const toggleTeamReviewer = useCallback((t: OrgTeam) => { + setSelectedTeamReviewers((prev) => + prev.some((r) => r.slug === t.slug) + ? prev.filter((r) => r.slug !== t.slug) + : [...prev, t], + ); + }, []); + + const handleSubmit = useCallback( + async (draft: boolean) => { + if (!title.trim()) return; + setSubmitting(true); + setError(null); + + try { + const result = await createPullRequest({ + data: { + owner, + repo, + base, + head, + title: title.trim(), + body: body.trim() || undefined, + draft, + labels: + selectedLabels.length > 0 + ? selectedLabels.map((l) => l.name) + : undefined, + assignees: + selectedAssignees.length > 0 + ? selectedAssignees.map((a) => a.login) + : undefined, + reviewers: + selectedReviewers.length > 0 + ? selectedReviewers.map((r) => r.login) + : undefined, + teamReviewers: + selectedTeamReviewers.length > 0 + ? selectedTeamReviewers.map((t) => t.slug) + : undefined, + }, + }); + + if (result.ok) { + await queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.some( + (k) => + typeof k === "string" && + (k.includes("pulls") || k.includes("repoMeta")), + ) + ); + }, + }); + router.navigate({ + to: "/$owner/$repo/pull/$pullId", + params: { owner, repo, pullId: String(result.pullNumber) }, + }); + } else { + setError(result.error || "Failed to create pull request."); + } + } catch { + setError("Failed to create pull request. Please try again."); + } finally { + setSubmitting(false); + } + }, + [ + base, + body, + head, + owner, + queryClient, + repo, + router, + selectedAssignees, + selectedLabels, + selectedReviewers, + selectedTeamReviewers, + title, + ], + ); + + if (overviewQuery.error) throw overviewQuery.error; + if (compareQuery.error) throw compareQuery.error; + if (!repoData || !compare) return ; + + return ( +
+
+ {showForm ? ( +
+
+ + 0 && compare.aheadBy > 0} + mentionConfig={mentionConfig} + /> +
+ +
+ ) : ( + + )} + + +
+
+ ); +} + +function ComparePageSkeleton() { + return ( + + +
+
+
+
+ + +
+
+
+
+ + +
+ + + ); +} diff --git a/apps/dashboard/src/components/compare/compare-sidebar.tsx b/apps/dashboard/src/components/compare/compare-sidebar.tsx new file mode 100644 index 0000000..f8703cb --- /dev/null +++ b/apps/dashboard/src/components/compare/compare-sidebar.tsx @@ -0,0 +1,743 @@ +import { CheckIcon, CloseIcon, PlusSignIcon, SearchIcon } from "@diffkit/icons"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@diffkit/ui/components/popover"; +import { cn } from "@diffkit/ui/lib/utils"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { DetailSidebar } from "#/components/details/detail-sidebar"; +import { + type GitHubQueryScope, + githubOrgTeamsQueryOptions, + githubRepoCollaboratorsQueryOptions, + githubRepoLabelsQueryOptions, +} from "#/lib/github.query"; +import type { + GitHubLabel, + OrgTeam, + RepoCollaborator, +} from "#/lib/github.types"; + +export function CompareSidebar({ + owner, + repo, + scope, + viewerLogin, + selectedLabels, + onToggleLabel, + selectedAssignees, + onToggleAssignee, + selectedReviewers, + onToggleReviewer, + selectedTeamReviewers, + onToggleTeamReviewer, +}: { + owner: string; + repo: string; + scope: GitHubQueryScope; + viewerLogin: string | null; + selectedLabels: GitHubLabel[]; + onToggleLabel: (label: GitHubLabel) => void; + selectedAssignees: RepoCollaborator[]; + onToggleAssignee: (c: RepoCollaborator) => void; + selectedReviewers: RepoCollaborator[]; + onToggleReviewer: (c: RepoCollaborator) => void; + selectedTeamReviewers: OrgTeam[]; + onToggleTeamReviewer: (t: OrgTeam) => void; +}) { + return ( + + + + + + ); +} + +function SidebarSectionHeader({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} + +function PickerTrigger({ onPrefetch }: { onPrefetch?: () => void }) { + return ( + + ); +} + +function PickerSearchInput({ + value, + onChange, + onKeyDown, + placeholder, +}: { + value: string; + onChange: (v: string) => void; + onKeyDown: (e: React.KeyboardEvent) => void; + placeholder: string; +}) { + return ( +
+ + onChange(e.target.value)} + onKeyDown={onKeyDown} + placeholder={placeholder} + className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> +
+ ); +} + +function EmptyPickerState({ + isLoading, + hasSearch, + emptyLabel, + emptySearchLabel, +}: { + isLoading: boolean; + hasSearch: boolean; + emptyLabel: string; + emptySearchLabel: string; +}) { + return ( +

+ {isLoading ? "Loading…" : hasSearch ? emptySearchLabel : emptyLabel} +

+ ); +} + +function LabelsPicker({ + owner, + repo, + scope, + selectedLabels, + onToggleLabel, +}: { + owner: string; + repo: string; + scope: GitHubQueryScope; + selectedLabels: GitHubLabel[]; + onToggleLabel: (label: GitHubLabel) => void; +}) { + const [pickerOpen, setPickerOpen] = useState(false); + const [search, setSearch] = useState(""); + const [focusedIndex, setFocusedIndex] = useState(-1); + const listRef = useRef(null); + + const labelsQuery = useQuery( + githubRepoLabelsQueryOptions(scope, { owner, repo }), + ); + const repoLabels = labelsQuery.data ?? []; + + const selectedNames = useMemo( + () => new Set(selectedLabels.map((l) => l.name)), + [selectedLabels], + ); + + const filtered = useMemo(() => { + if (!search) return repoLabels; + const q = search.toLowerCase(); + return repoLabels.filter((l) => l.name.toLowerCase().includes(q)); + }, [repoLabels, search]); + + const scrollToFocused = useCallback((index: number) => { + listRef.current + ?.querySelector(`[data-index="${index}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (filtered.length === 0) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + const next = focusedIndex < filtered.length - 1 ? focusedIndex + 1 : 0; + setFocusedIndex(next); + scrollToFocused(next); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + const next = focusedIndex > 0 ? focusedIndex - 1 : filtered.length - 1; + setFocusedIndex(next); + scrollToFocused(next); + } else if (e.key === "Enter" && focusedIndex >= 0) { + e.preventDefault(); + onToggleLabel(filtered[focusedIndex]); + } + }; + + return ( +
+ + { + setPickerOpen(open); + if (!open) { + setSearch(""); + setFocusedIndex(-1); + } + }} + > + + + + + + + { + setSearch(v); + setFocusedIndex(-1); + }} + onKeyDown={handleKeyDown} + placeholder="Search labels…" + /> +
+ {filtered.length === 0 ? ( + 0} + emptyLabel="No labels available" + emptySearchLabel="No labels found" + /> + ) : ( + filtered.map((label, i) => { + const isSelected = selectedNames.has(label.name); + return ( + + ); + }) + )} +
+
+
+
+ {selectedLabels.length > 0 ? ( +
+ {selectedLabels.map((label) => ( + + {label.name} + + + ))} +
+ ) : ( +

No labels

+ )} +
+ ); +} + +function AssigneesPicker({ + owner, + repo, + scope, + selectedAssignees, + onToggleAssignee, +}: { + owner: string; + repo: string; + scope: GitHubQueryScope; + selectedAssignees: RepoCollaborator[]; + onToggleAssignee: (c: RepoCollaborator) => void; +}) { + const queryClient = useQueryClient(); + const [pickerOpen, setPickerOpen] = useState(false); + const [search, setSearch] = useState(""); + const [focusedIndex, setFocusedIndex] = useState(-1); + const listRef = useRef(null); + + const collaboratorsOptions = githubRepoCollaboratorsQueryOptions(scope, { + owner, + repo, + }); + const collaboratorsQuery = useQuery({ + ...collaboratorsOptions, + enabled: pickerOpen, + }); + const prefetch = useCallback( + () => void queryClient.prefetchQuery(collaboratorsOptions), + [queryClient, collaboratorsOptions], + ); + + const collaborators = collaboratorsQuery.data ?? []; + const selectedLogins = useMemo( + () => new Set(selectedAssignees.map((a) => a.login)), + [selectedAssignees], + ); + + const filtered = useMemo(() => { + const users = collaborators.filter((c) => c.type !== "Bot"); + if (!search) return users; + const q = search.toLowerCase(); + return users.filter((c) => c.login.toLowerCase().includes(q)); + }, [collaborators, search]); + + const scrollToFocused = useCallback((index: number) => { + listRef.current + ?.querySelector(`[data-index="${index}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (filtered.length === 0) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + const next = focusedIndex < filtered.length - 1 ? focusedIndex + 1 : 0; + setFocusedIndex(next); + scrollToFocused(next); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + const next = focusedIndex > 0 ? focusedIndex - 1 : filtered.length - 1; + setFocusedIndex(next); + scrollToFocused(next); + } else if (e.key === "Enter" && focusedIndex >= 0) { + e.preventDefault(); + onToggleAssignee(filtered[focusedIndex]); + } + }; + + return ( +
+ + { + setPickerOpen(open); + if (!open) { + setSearch(""); + setFocusedIndex(-1); + } + }} + > + + + + + + + { + setSearch(v); + setFocusedIndex(-1); + }} + onKeyDown={handleKeyDown} + placeholder="Search people…" + /> +
+ {filtered.length === 0 ? ( + 0} + emptyLabel="No collaborators available" + emptySearchLabel="No people found" + /> + ) : ( + filtered.map((c, i) => { + const isSelected = selectedLogins.has(c.login); + return ( + + ); + }) + )} +
+
+
+
+ {selectedAssignees.length > 0 ? ( +
+ {selectedAssignees.map((a) => ( +
+ {a.login} + {a.login} + +
+ ))} +
+ ) : ( +

No one assigned

+ )} +
+ ); +} + +function ReviewersPicker({ + owner, + repo, + scope, + viewerLogin, + selectedReviewers, + onToggleReviewer, + selectedTeamReviewers, + onToggleTeamReviewer, +}: { + owner: string; + repo: string; + scope: GitHubQueryScope; + viewerLogin: string | null; + selectedReviewers: RepoCollaborator[]; + onToggleReviewer: (c: RepoCollaborator) => void; + selectedTeamReviewers: OrgTeam[]; + onToggleTeamReviewer: (t: OrgTeam) => void; +}) { + const queryClient = useQueryClient(); + const [pickerOpen, setPickerOpen] = useState(false); + const [search, setSearch] = useState(""); + const [focusedIndex, setFocusedIndex] = useState(-1); + const listRef = useRef(null); + + const collaboratorsOptions = githubRepoCollaboratorsQueryOptions(scope, { + owner, + repo, + }); + const teamsOptions = githubOrgTeamsQueryOptions(scope, { + org: owner, + owner, + repo, + }); + const collaboratorsQuery = useQuery({ + ...collaboratorsOptions, + enabled: pickerOpen, + }); + const teamsQuery = useQuery({ + ...teamsOptions, + enabled: pickerOpen, + }); + const prefetch = useCallback(() => { + void queryClient.prefetchQuery(collaboratorsOptions); + void queryClient.prefetchQuery(teamsOptions); + }, [queryClient, collaboratorsOptions, teamsOptions]); + + const collaborators = collaboratorsQuery.data ?? []; + const teams = teamsQuery.data ?? []; + const selectedLogins = useMemo( + () => new Set(selectedReviewers.map((r) => r.login)), + [selectedReviewers], + ); + const selectedTeamSlugs = useMemo( + () => new Set(selectedTeamReviewers.map((t) => t.slug)), + [selectedTeamReviewers], + ); + + const candidates = useMemo( + () => collaborators.filter((c) => c.login !== viewerLogin), + [collaborators, viewerLogin], + ); + const filteredUsers = useMemo(() => { + if (!search) return candidates; + const q = search.toLowerCase(); + return candidates.filter((c) => c.login.toLowerCase().includes(q)); + }, [candidates, search]); + const filteredTeams = useMemo(() => { + if (!search) return teams; + const q = search.toLowerCase(); + return teams.filter( + (t) => + t.name.toLowerCase().includes(q) || t.slug.toLowerCase().includes(q), + ); + }, [teams, search]); + + type Item = + | { kind: "user"; data: RepoCollaborator } + | { kind: "team"; data: OrgTeam }; + const flatItems = useMemo( + () => [ + ...filteredTeams.map((t) => ({ kind: "team", data: t })), + ...filteredUsers.map((u) => ({ kind: "user", data: u })), + ], + [filteredTeams, filteredUsers], + ); + + const scrollToFocused = useCallback((index: number) => { + listRef.current + ?.querySelector(`[data-index="${index}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (flatItems.length === 0) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + const next = focusedIndex < flatItems.length - 1 ? focusedIndex + 1 : 0; + setFocusedIndex(next); + scrollToFocused(next); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + const next = focusedIndex > 0 ? focusedIndex - 1 : flatItems.length - 1; + setFocusedIndex(next); + scrollToFocused(next); + } else if (e.key === "Enter" && focusedIndex >= 0) { + e.preventDefault(); + const item = flatItems[focusedIndex]; + if (item.kind === "user") onToggleReviewer(item.data); + else onToggleTeamReviewer(item.data); + } + }; + + const hasSelections = + selectedReviewers.length > 0 || selectedTeamReviewers.length > 0; + + return ( +
+ + { + setPickerOpen(open); + if (!open) { + setSearch(""); + setFocusedIndex(-1); + } + }} + > + + + + + + + { + setSearch(v); + setFocusedIndex(-1); + }} + onKeyDown={handleKeyDown} + placeholder="Search reviewers…" + /> +
+ {flatItems.length === 0 ? ( + 0} + emptyLabel="No reviewers available" + emptySearchLabel="No matches found" + /> + ) : ( + flatItems.map((item, i) => { + const isSelected = + item.kind === "user" + ? selectedLogins.has(item.data.login) + : selectedTeamSlugs.has(item.data.slug); + return ( + + ); + }) + )} +
+
+
+
+ {hasSelections ? ( +
+ {selectedTeamReviewers.map((t) => ( +
+
+ T +
+ + {owner}/{t.slug} + + +
+ ))} + {selectedReviewers.map((r) => ( +
+ {r.login} + {r.login} + +
+ ))} +
+ ) : ( +

No reviewers

+ )} +
+ ); +} diff --git a/apps/dashboard/src/components/issues/new/new-issue-page.tsx b/apps/dashboard/src/components/issues/new/new-issue-page.tsx index bf835a1..dfc3a56 100644 --- a/apps/dashboard/src/components/issues/new/new-issue-page.tsx +++ b/apps/dashboard/src/components/issues/new/new-issue-page.tsx @@ -18,13 +18,14 @@ import { import { Spinner } from "@diffkit/ui/components/spinner"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { getRouteApi, Link, useRouter } from "@tanstack/react-router"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DetailSidebar } from "#/components/details/detail-sidebar"; import { createIssue } from "#/lib/github.functions"; import { type GitHubQueryScope, githubRepoCollaboratorsQueryOptions, githubRepoLabelsQueryOptions, + githubRepoTemplateQueryOptions, } from "#/lib/github.query"; import type { GitHubLabel, RepoCollaborator } from "#/lib/github.types"; @@ -69,6 +70,18 @@ function NewIssueForm({ githubRepoCollaboratorsQueryOptions(scope, { owner, repo }), ); + const templateQuery = useQuery( + githubRepoTemplateQueryOptions(scope, { owner, repo, kind: "issue" }), + ); + const templateAppliedRef = useRef(false); + useEffect(() => { + if (templateAppliedRef.current) return; + const template = templateQuery.data; + if (!template) return; + templateAppliedRef.current = true; + setBody((current) => (current ? current : template)); + }, [templateQuery.data]); + const mentionCandidates: MentionCandidate[] = useMemo( () => (collaboratorsQuery.data ?? []).map((c) => ({ @@ -197,7 +210,7 @@ function NewIssueForm({ value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Issue title" - className="flex h-9 w-full rounded-md border bg-surface-1 px-3 py-1 text-sm outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]" + className="flex h-9 w-full rounded-md border bg-surface-1 px-3 py-1 text-sm outline-none transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]" />
diff --git a/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx b/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx index 4ec975c..88e306d 100644 --- a/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx +++ b/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx @@ -1,4 +1,9 @@ import { FileIcon, GitCommitIcon, ReviewsIcon } from "@diffkit/icons"; +import { + Callout, + CalloutAction, + CalloutContent, +} from "@diffkit/ui/components/callout"; import { Tooltip, TooltipContent, @@ -88,19 +93,21 @@ export function PullDetailHeader({
{isReviewRequested && ( -
- + + Your review has been requested - - - Review changes - -
+ + + + Review changes + + + )}
diff --git a/apps/dashboard/src/components/repo/branch-comparison-banner.tsx b/apps/dashboard/src/components/repo/branch-comparison-banner.tsx new file mode 100644 index 0000000..74dcce6 --- /dev/null +++ b/apps/dashboard/src/components/repo/branch-comparison-banner.tsx @@ -0,0 +1,103 @@ +import { GitCompareIcon, GitPullRequestIcon } from "@diffkit/icons"; +import { + Callout, + CalloutAction, + CalloutContent, +} from "@diffkit/ui/components/callout"; +import { Skeleton } from "@diffkit/ui/components/skeleton"; +import { useQuery } from "@tanstack/react-query"; +import { + type GitHubQueryScope, + githubBranchComparisonQueryOptions, +} from "#/lib/github.query"; + +export function BranchComparisonBanner({ + owner, + repo, + scope, + currentBranch, + defaultBranch, +}: { + owner: string; + repo: string; + scope: GitHubQueryScope; + currentBranch: string; + defaultBranch: string; +}) { + const comparisonQuery = useQuery({ + ...githubBranchComparisonQueryOptions(scope, { + owner, + repo, + base: defaultBranch, + head: currentBranch, + }), + enabled: currentBranch !== defaultBranch, + }); + + if (comparisonQuery.isPending) { + return ( + + + + + + ); + } + + const comparison = comparisonQuery.data; + if (!comparison) return null; + + const { aheadBy, behindBy } = comparison; + if (aheadBy === 0 && behindBy === 0) return null; + + const compareUrl = `/${owner}/${repo}/compare/${defaultBranch}...${currentBranch}`; + const createPrUrl = `${compareUrl}?expand=1`; + + return ( + + + This branch is + {aheadBy > 0 && ( + + {aheadBy} {aheadBy === 1 ? "commit" : "commits"} ahead + + )} + {aheadBy > 0 && behindBy > 0 && of and} + {aheadBy === 0 && behindBy > 0 && of} + {behindBy > 0 && ( + + {behindBy} {behindBy === 1 ? "commit" : "commits"} behind + + )} + + {defaultBranch} + + . + + + + + + {aheadBy > 0 && ( + + + Create pull request + + )} + + + ); +} diff --git a/apps/dashboard/src/components/repo/recent-push-banner.tsx b/apps/dashboard/src/components/repo/recent-push-banner.tsx new file mode 100644 index 0000000..bce2821 --- /dev/null +++ b/apps/dashboard/src/components/repo/recent-push-banner.tsx @@ -0,0 +1,75 @@ +import { GitBranchIcon, GitPullRequestIcon } from "@diffkit/icons"; +import { + Callout, + CalloutAction, + CalloutContent, +} from "@diffkit/ui/components/callout"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { formatRelativeTime } from "#/lib/format-relative-time"; +import { + type GitHubQueryScope, + githubQueryKeys, + githubRecentPushableBranchQueryOptions, +} from "#/lib/github.query"; +import { githubRevalidationSignalKeys } from "#/lib/github-revalidation"; +import { useGitHubSignalStream } from "#/lib/use-github-signal-stream"; + +export function RecentPushBanner({ + owner, + repo, + scope, + defaultBranch, +}: { + owner: string; + repo: string; + scope: GitHubQueryScope; + defaultBranch: string; +}) { + const recentQuery = useQuery( + githubRecentPushableBranchQueryOptions(scope, { owner, repo }), + ); + + const webhookRefreshTargets = useMemo( + () => [ + { + queryKey: githubQueryKeys.repo.recentPushableBranch(scope, { + owner, + repo, + }), + signalKeys: [ + githubRevalidationSignalKeys.repoCode({ owner, repo }), + githubRevalidationSignalKeys.repoMeta({ owner, repo }), + ], + }, + ], + [owner, repo, scope], + ); + useGitHubSignalStream(webhookRefreshTargets); + + const recent = recentQuery.data; + if (!recent) return null; + + const createPrUrl = `/${owner}/${repo}/compare/${defaultBranch}...${recent.branch}?expand=1`; + + return ( + + + + {recent.branch} + + had recent pushes {formatRelativeTime(recent.pushedAt)} + + + + + + Compare & pull request + + + + ); +} diff --git a/apps/dashboard/src/components/repo/repo-explorer-layout.tsx b/apps/dashboard/src/components/repo/repo-explorer-layout.tsx index ae17e33..a1b3b7b 100644 --- a/apps/dashboard/src/components/repo/repo-explorer-layout.tsx +++ b/apps/dashboard/src/components/repo/repo-explorer-layout.tsx @@ -28,6 +28,7 @@ import { import type { RepoOverview } from "#/lib/github.types"; import { useHasMounted } from "#/lib/use-has-mounted"; import { useRegisterTab } from "#/lib/use-register-tab"; +import { BranchComparisonBanner } from "./branch-comparison-banner"; import { CodeExplorerToolbar } from "./code-explorer-toolbar"; import { CodeFileView } from "./code-file-view"; import { FolderView, FolderViewSkeleton } from "./folder-view"; @@ -212,6 +213,18 @@ export function RepoExplorerLayout({ isDesktop={isDesktop} /> + {activeRef !== repoData.defaultBranch && ( +
+ +
+ )} + {isDesktop ? ( diff --git a/apps/dashboard/src/components/repo/repo-overview-page.tsx b/apps/dashboard/src/components/repo/repo-overview-page.tsx index f97c244..53e9b9e 100644 --- a/apps/dashboard/src/components/repo/repo-overview-page.tsx +++ b/apps/dashboard/src/components/repo/repo-overview-page.tsx @@ -1,29 +1,38 @@ import { useQuery } from "@tanstack/react-query"; -import { getRouteApi } from "@tanstack/react-router"; -import { useMemo, useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { useCallback } from "react"; import { SidePanelPortal } from "#/components/layouts/dashboard-side-panel"; import { + type GitHubQueryScope, githubRepoOverviewQueryOptions, githubRepoTreeQueryOptions, } from "#/lib/github.query"; import { useHasMounted } from "#/lib/use-has-mounted"; import { useRegisterTab } from "#/lib/use-register-tab"; +import { BranchComparisonBanner } from "./branch-comparison-banner"; import { CodeExplorerToolbar } from "./code-explorer-toolbar"; import { FileTree } from "./file-tree"; import { LatestCommitBar } from "./latest-commit-bar"; +import { RecentPushBanner } from "./recent-push-banner"; import { RepoActivityCards } from "./repo-activity-cards"; import { RepoHeader } from "./repo-header"; import { RepoMarkdownFiles } from "./repo-markdown-files"; import { RepoOverviewSkeleton } from "./repo-overview-skeleton"; import { RepoSidebar } from "./repo-sidebar"; -const routeApi = getRouteApi("/_protected/$owner/$repo/"); - -export function RepoOverviewPage() { - const { user } = routeApi.useRouteContext(); - const { owner, repo } = routeApi.useParams(); - const scope = useMemo(() => ({ userId: user.id }), [user.id]); +export function RepoOverviewPage({ + owner, + repo, + scope, + currentRef, +}: { + owner: string; + repo: string; + scope: GitHubQueryScope; + currentRef?: string; +}) { const hasMounted = useHasMounted(); + const navigate = useNavigate(); const overviewQuery = useQuery({ ...githubRepoOverviewQueryOptions(scope, { owner, repo }), @@ -31,8 +40,26 @@ export function RepoOverviewPage() { }); const repoData = overviewQuery.data; - const [currentRef, setCurrentRef] = useState(null); const activeRef = currentRef ?? repoData?.defaultBranch ?? "main"; + const isDefaultBranch = !repoData || activeRef === repoData.defaultBranch; + + const handleBranchChange = useCallback( + (branch: string) => { + if (branch === activeRef) return; + if (branch === repoData?.defaultBranch) { + void navigate({ + to: "/$owner/$repo", + params: { owner, repo }, + }); + return; + } + void navigate({ + to: "/$owner/$repo/tree/$", + params: { owner, repo, _splat: branch }, + }); + }, + [activeRef, navigate, owner, repo, repoData?.defaultBranch], + ); useRegisterTab( repoData @@ -71,9 +98,28 @@ export function RepoOverviewPage() { repo={repoData} currentRef={activeRef} scope={scope} - onBranchChange={setCurrentRef} + onBranchChange={handleBranchChange} /> + {!isDefaultBranch && ( + + )} + + {isDefaultBranch && ( + + )} +
) + .handler(async ({ data }): Promise => { + const context = await getGitHubUserContextForRepository(data); + if (!context) { + return { ok: false, error: "Not authenticated" }; + } + + try { + const response = await context.octokit.rest.pulls.create({ + owner: data.owner, + repo: data.repo, + title: data.title, + body: data.body, + base: data.base, + head: data.head, + draft: data.draft ?? false, + }); + + const pullNumber = response.data.number; + + const followUps: Array> = []; + if (data.labels && data.labels.length > 0) { + followUps.push( + context.octokit.rest.issues.addLabels({ + owner: data.owner, + repo: data.repo, + issue_number: pullNumber, + labels: data.labels, + }), + ); + } + if (data.assignees && data.assignees.length > 0) { + followUps.push( + context.octokit.rest.issues.addAssignees({ + owner: data.owner, + repo: data.repo, + issue_number: pullNumber, + assignees: data.assignees, + }), + ); + } + if ( + (data.reviewers && data.reviewers.length > 0) || + (data.teamReviewers && data.teamReviewers.length > 0) + ) { + followUps.push( + context.octokit.rest.pulls.requestReviewers({ + owner: data.owner, + repo: data.repo, + pull_number: pullNumber, + reviewers: data.reviewers ?? [], + team_reviewers: data.teamReviewers ?? [], + }), + ); + } + if (followUps.length > 0) { + await Promise.allSettled(followUps); + } + + await bumpGitHubCacheNamespaces([ + githubRevalidationSignalKeys.pullsMine, + githubRevalidationSignalKeys.repoMeta({ + owner: data.owner, + repo: data.repo, + }), + ]); + + return { ok: true, pullNumber }; + } catch (error) { + const result = toMutationError("create pull request", error); + return { ok: false, error: result.ok ? "" : result.error }; + } + }); + export const createIssue = createServerFn({ method: "POST" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { @@ -8346,6 +8440,340 @@ export const getRepoBranches = createServerFn({ method: "GET" }) }); }); +// --------------------------------------------------------------------------- +// Branch comparison (ahead/behind vs base branch) +// --------------------------------------------------------------------------- + +type BranchComparisonInput = { + owner: string; + repo: string; + base: string; + head: string; +}; + +export const getBranchComparison = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + if (data.base === data.head) return null; + const context = await getGitHubContextForRepository(data); + if (!context) return null; + + return getCachedGitHubRequest< + Awaited< + ReturnType + >["data"], + BranchComparison + >({ + context, + resource: "repo.branchComparison.v1", + params: data, + freshForMs: githubCachePolicy.detail.staleTimeMs, + signalKeys: [githubRevalidationSignalKeys.repoCode(data)], + namespaceKeys: [githubRevalidationSignalKeys.repoCode(data)], + cacheMode: "split", + request: (headers) => + context.octokit.rest.repos.compareCommitsWithBasehead({ + owner: data.owner, + repo: data.repo, + basehead: `${data.base}...${data.head}`, + per_page: 1, + headers, + }), + mapData: (comparison) => ({ + aheadBy: comparison.ahead_by, + behindBy: comparison.behind_by, + status: comparison.status as BranchComparison["status"], + totalCommits: comparison.total_commits, + }), + }).catch(() => null); + }); + +// --------------------------------------------------------------------------- +// Repo templates — PR / issue body templates (single-file, simple case only). +// --------------------------------------------------------------------------- + +export type RepoTemplateKind = "pr" | "issue"; + +type RepoTemplateInput = { + owner: string; + repo: string; + kind: RepoTemplateKind; +}; + +const PR_TEMPLATE_PATHS = [ + ".github/pull_request_template.md", + ".github/PULL_REQUEST_TEMPLATE.md", + "docs/pull_request_template.md", + "docs/PULL_REQUEST_TEMPLATE.md", + "pull_request_template.md", + "PULL_REQUEST_TEMPLATE.md", +]; + +const ISSUE_TEMPLATE_PATHS = [ + ".github/ISSUE_TEMPLATE.md", + ".github/issue_template.md", + "docs/ISSUE_TEMPLATE.md", + "docs/issue_template.md", + "ISSUE_TEMPLATE.md", + "issue_template.md", +]; + +type RepoTemplateGraphQLResponse = { + repository: { + [alias: string]: { text: string | null } | null; + } | null; +}; + +export const getRepoTemplate = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContextForRepository(data); + if (!context) return null; + + const repoCodeKey = githubRevalidationSignalKeys.repoCode({ + owner: data.owner, + repo: data.repo, + }); + + try { + return await getOrRevalidateGitHubResource({ + userId: context.session.user.id, + resource: `repo.template.${data.kind}.v1`, + params: data, + freshForMs: githubCachePolicy.repoMeta.staleTimeMs, + signalKeys: [repoCodeKey], + namespaceKeys: [repoCodeKey], + cacheMode: "split", + fetcher: async () => { + const paths = + data.kind === "pr" ? PR_TEMPLATE_PATHS : ISSUE_TEMPLATE_PATHS; + const fields = paths + .map( + (path, i) => + `p${i}: object(expression: "HEAD:${path}") { ... on Blob { text } }`, + ) + .join("\n"); + + const response = + await executeGitHubGraphQL( + context, + `github repo template ${data.kind} ${data.owner}/${data.repo}`, + `query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + ${fields} + } + }`, + { owner: data.owner, repo: data.repo }, + ); + + const repository = response.repository; + let body: string | null = null; + if (repository) { + for (let i = 0; i < paths.length; i++) { + const entry = repository[`p${i}`]; + const text = entry?.text; + if (text && text.length > 0) { + body = text; + break; + } + } + } + + return { + kind: "success", + data: body, + metadata: createGitHubResponseMetadata(200, {}), + }; + }, + }); + } catch { + return null; + } + }); + +// --------------------------------------------------------------------------- +// Recent pushable branch — viewer's most recent push to a non-default branch +// that has no open PR and is ahead of the default branch. Used to render the +// "had recent pushes" banner on the repo overview. +// --------------------------------------------------------------------------- + +export type RecentPushableBranch = { + branch: string; + pushedAt: string; + aheadBy: number; +}; + +type RecentPushableBranchInput = { + owner: string; + repo: string; +}; + +export const getRecentPushableBranch = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContextForRepository(data); + if (!context) return null; + + const repoCodeKey = githubRevalidationSignalKeys.repoCode(data); + const repoMetaKey = githubRevalidationSignalKeys.repoMeta(data); + + try { + return await getOrRevalidateGitHubResource({ + userId: context.session.user.id, + resource: "repo.recentPushableBranch.v1", + params: data, + freshForMs: githubCachePolicy.detail.staleTimeMs, + signalKeys: [repoCodeKey, repoMetaKey], + namespaceKeys: [repoCodeKey, repoMetaKey], + cacheMode: "split", + fetcher: async () => { + const viewer = await getViewer(context); + if (!viewer.login) { + return { + kind: "success", + data: null, + metadata: createGitHubResponseMetadata(200, {}), + }; + } + + const overview = await context.octokit.rest.repos.get({ + owner: data.owner, + repo: data.repo, + }); + const defaultBranch = overview.data.default_branch; + if (!defaultBranch) { + return { + kind: "success", + data: null, + metadata: createGitHubResponseMetadata(200, {}), + }; + } + + const activities = await context.octokit.rest.repos.listActivities({ + owner: data.owner, + repo: data.repo, + actor: viewer.login, + activity_type: "push", + time_period: "day", + per_page: 10, + }); + + const seen = new Set(); + for (const activity of activities.data) { + const ref = activity.ref; + if (!ref.startsWith("refs/heads/")) continue; + const branch = ref.slice("refs/heads/".length); + if (branch === defaultBranch) continue; + if (seen.has(branch)) continue; + seen.add(branch); + + const existingPrs = await context.octokit.rest.pulls.list({ + owner: data.owner, + repo: data.repo, + head: `${data.owner}:${branch}`, + state: "open", + per_page: 1, + }); + if (existingPrs.data.length > 0) continue; + + const comparison = + await context.octokit.rest.repos.compareCommitsWithBasehead({ + owner: data.owner, + repo: data.repo, + basehead: `${defaultBranch}...${branch}`, + per_page: 1, + }); + if (comparison.data.ahead_by === 0) continue; + + return { + kind: "success", + data: { + branch, + pushedAt: activity.timestamp, + aheadBy: comparison.data.ahead_by, + }, + metadata: createGitHubResponseMetadata(200, {}), + }; + } + + return { + kind: "success", + data: null, + metadata: createGitHubResponseMetadata(200, {}), + }; + }, + }); + } catch { + return null; + } + }); + +// --------------------------------------------------------------------------- +// Compare detail (commits + files between two refs, for the compare page) +// --------------------------------------------------------------------------- + +export type CompareDetail = { + aheadBy: number; + behindBy: number; + status: "ahead" | "behind" | "diverged" | "identical"; + totalCommits: number; + commits: PullCommit[]; + files: PullFile[]; +}; + +export const getCompareDetail = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + if (data.base === data.head) return null; + const context = await getGitHubContextForRepository(data); + if (!context) return null; + + return getCachedGitHubRequest< + Awaited< + ReturnType + >["data"], + CompareDetail + >({ + context, + resource: "repo.compareDetail.v1", + params: data, + freshForMs: githubCachePolicy.detail.staleTimeMs, + signalKeys: [githubRevalidationSignalKeys.repoCode(data)], + namespaceKeys: [githubRevalidationSignalKeys.repoCode(data)], + cacheMode: "split", + request: (headers) => + context.octokit.rest.repos.compareCommitsWithBasehead({ + owner: data.owner, + repo: data.repo, + basehead: `${data.base}...${data.head}`, + per_page: 100, + headers, + }), + mapData: (comparison) => ({ + aheadBy: comparison.ahead_by, + behindBy: comparison.behind_by, + status: comparison.status as CompareDetail["status"], + totalCommits: comparison.total_commits, + commits: comparison.commits.map((c) => ({ + sha: c.sha, + message: c.commit.message, + createdAt: c.commit.committer?.date ?? c.commit.author?.date ?? "", + author: mapActor(c.author), + })), + files: (comparison.files ?? []).map((f) => ({ + sha: f.sha ?? null, + filename: f.filename, + status: f.status as PullFile["status"], + additions: f.additions, + deletions: f.deletions, + changes: f.changes, + patch: f.patch ?? null, + previousFilename: f.previous_filename ?? null, + })), + }), + }).catch(() => null); + }); + // --------------------------------------------------------------------------- // Repository tree contents // --------------------------------------------------------------------------- diff --git a/apps/dashboard/src/lib/github.query.ts b/apps/dashboard/src/lib/github.query.ts index 1b6466e..154f9a4 100644 --- a/apps/dashboard/src/lib/github.query.ts +++ b/apps/dashboard/src/lib/github.query.ts @@ -1,7 +1,9 @@ import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; import { type CommandPaletteSearchInput, + getBranchComparison, getCommentPage, + getCompareDetail, getFileLastCommit, getGitHubViewer, getIssueComments, @@ -23,6 +25,7 @@ import { getPullStatus, getPullsFromRepo, getPullsFromUser, + getRecentPushableBranch, getRefHeadCommit, getRepoBranches, getRepoCollaborators, @@ -34,6 +37,7 @@ import { getRepoOverview, getRepoParticipationStats, getReposHub, + getRepoTemplate, getRepoTree, getReviewThreadStatuses, getTimelineEventPage, @@ -43,6 +47,7 @@ import { getUserPinnedRepos, getUserProfile, getUserRepos, + type RepoTemplateKind, searchCommandPaletteGitHub, } from "./github.functions"; import { githubCachePolicy } from "./github-cache-policy"; @@ -204,6 +209,23 @@ export const githubQueryKeys = { scope: GitHubQueryScope, input: { owner: string; repo: string }, ) => ["github", scope.userId, "repo", "branches", input] as const, + branchComparison: ( + scope: GitHubQueryScope, + input: { owner: string; repo: string; base: string; head: string }, + ) => ["github", scope.userId, "repo", "branchComparison", input] as const, + compareDetail: ( + scope: GitHubQueryScope, + input: { owner: string; repo: string; base: string; head: string }, + ) => ["github", scope.userId, "repo", "compareDetail", input] as const, + recentPushableBranch: ( + scope: GitHubQueryScope, + input: { owner: string; repo: string }, + ) => + ["github", scope.userId, "repo", "recentPushableBranch", input] as const, + template: ( + scope: GitHubQueryScope, + input: { owner: string; repo: string; kind: RepoTemplateKind }, + ) => ["github", scope.userId, "repo", "template", input] as const, tree: ( scope: GitHubQueryScope, input: { owner: string; repo: string; ref: string; path: string }, @@ -689,6 +711,57 @@ export function githubRepoBranchesQueryOptions( }); } +export function githubBranchComparisonQueryOptions( + scope: GitHubQueryScope, + input: { owner: string; repo: string; base: string; head: string }, +) { + return queryOptions({ + queryKey: githubQueryKeys.repo.branchComparison(scope, input), + queryFn: () => getBranchComparison({ data: input }), + staleTime: githubCachePolicy.detail.staleTimeMs, + gcTime: githubCachePolicy.detail.gcTimeMs, + meta: tabPersistedMeta, + }); +} + +export function githubCompareDetailQueryOptions( + scope: GitHubQueryScope, + input: { owner: string; repo: string; base: string; head: string }, +) { + return queryOptions({ + queryKey: githubQueryKeys.repo.compareDetail(scope, input), + queryFn: () => getCompareDetail({ data: input }), + staleTime: githubCachePolicy.detail.staleTimeMs, + gcTime: githubCachePolicy.detail.gcTimeMs, + meta: tabPersistedMeta, + }); +} + +export function githubRecentPushableBranchQueryOptions( + scope: GitHubQueryScope, + input: { owner: string; repo: string }, +) { + return queryOptions({ + queryKey: githubQueryKeys.repo.recentPushableBranch(scope, input), + queryFn: () => getRecentPushableBranch({ data: input }), + staleTime: githubCachePolicy.detail.staleTimeMs, + gcTime: githubCachePolicy.detail.gcTimeMs, + }); +} + +export function githubRepoTemplateQueryOptions( + scope: GitHubQueryScope, + input: { owner: string; repo: string; kind: RepoTemplateKind }, +) { + return queryOptions({ + queryKey: githubQueryKeys.repo.template(scope, input), + queryFn: () => getRepoTemplate({ data: input }), + staleTime: githubCachePolicy.repoMeta.staleTimeMs, + gcTime: githubCachePolicy.repoMeta.gcTimeMs, + meta: persistedMeta, + }); +} + export function githubRepoParticipationQueryOptions( scope: GitHubQueryScope, input: { owner: string; repo: string }, diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index 8f7e797..590fbf6 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -567,6 +567,13 @@ export type RepoBranch = { isProtected: boolean; }; +export type BranchComparison = { + aheadBy: number; + behindBy: number; + status: "ahead" | "behind" | "diverged" | "identical"; + totalCommits: number; +}; + export type RepoContributor = { login: string; avatarUrl: string; diff --git a/apps/dashboard/src/lib/parse-repo-ref.ts b/apps/dashboard/src/lib/parse-repo-ref.ts index 8b100f3..c41426d 100644 --- a/apps/dashboard/src/lib/parse-repo-ref.ts +++ b/apps/dashboard/src/lib/parse-repo-ref.ts @@ -1,5 +1,21 @@ import type { RepoBranch } from "#/lib/github.types"; +/** + * Parse a compare splat like "main...feature" or "main...org:feature" + * into { base, head }. Returns null when the format is invalid. + */ +export function parseCompareRef( + splat: string, +): { base: string; head: string } | null { + if (!splat) return null; + const sep = splat.indexOf("..."); + if (sep <= 0 || sep >= splat.length - 3) return null; + const base = splat.slice(0, sep); + const head = splat.slice(sep + 3); + if (!base || !head) return null; + return { base, head }; +} + /** * Parse a splat string like "main/src/lib/foo.ts" into { ref, path }. * diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index 6c453d5..98651e0 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -37,6 +37,7 @@ import { Route as ProtectedOwnerRepoReviewPullIdRouteImport } from './routes/_pr import { Route as ProtectedOwnerRepoPullPullIdRouteImport } from './routes/_protected/$owner/$repo/pull.$pullId' import { Route as ProtectedOwnerRepoIssuesNewRouteImport } from './routes/_protected/$owner/$repo/issues.new' import { Route as ProtectedOwnerRepoIssuesIssueIdRouteImport } from './routes/_protected/$owner/$repo/issues.$issueId' +import { Route as ProtectedOwnerRepoCompareSplatRouteImport } from './routes/_protected/$owner/$repo/compare.$' import { Route as ProtectedOwnerRepoCommitShaRouteImport } from './routes/_protected/$owner/$repo/commit.$sha' import { Route as ProtectedOwnerRepoBlobSplatRouteImport } from './routes/_protected/$owner/$repo/blob.$' @@ -186,6 +187,12 @@ const ProtectedOwnerRepoIssuesIssueIdRoute = path: '/$owner/$repo/issues/$issueId', getParentRoute: () => ProtectedRoute, } as any) +const ProtectedOwnerRepoCompareSplatRoute = + ProtectedOwnerRepoCompareSplatRouteImport.update({ + id: '/$owner/$repo/compare/$', + path: '/$owner/$repo/compare/$', + getParentRoute: () => ProtectedRoute, + } as any) const ProtectedOwnerRepoCommitShaRoute = ProtectedOwnerRepoCommitShaRouteImport.update({ id: '/$owner/$repo/commit/$sha', @@ -223,6 +230,7 @@ export interface FileRoutesByFullPath { '/$owner/$repo/': typeof ProtectedOwnerRepoIndexRoute '/$owner/$repo/blob/$': typeof ProtectedOwnerRepoBlobSplatRoute '/$owner/$repo/commit/$sha': typeof ProtectedOwnerRepoCommitShaRoute + '/$owner/$repo/compare/$': typeof ProtectedOwnerRepoCompareSplatRoute '/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/$owner/$repo/issues/new': typeof ProtectedOwnerRepoIssuesNewRoute '/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute @@ -253,6 +261,7 @@ export interface FileRoutesByTo { '/$owner/$repo': typeof ProtectedOwnerRepoIndexRoute '/$owner/$repo/blob/$': typeof ProtectedOwnerRepoBlobSplatRoute '/$owner/$repo/commit/$sha': typeof ProtectedOwnerRepoCommitShaRoute + '/$owner/$repo/compare/$': typeof ProtectedOwnerRepoCompareSplatRoute '/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/$owner/$repo/issues/new': typeof ProtectedOwnerRepoIssuesNewRoute '/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute @@ -286,6 +295,7 @@ export interface FileRoutesById { '/_protected/$owner/$repo/': typeof ProtectedOwnerRepoIndexRoute '/_protected/$owner/$repo/blob/$': typeof ProtectedOwnerRepoBlobSplatRoute '/_protected/$owner/$repo/commit/$sha': typeof ProtectedOwnerRepoCommitShaRoute + '/_protected/$owner/$repo/compare/$': typeof ProtectedOwnerRepoCompareSplatRoute '/_protected/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/_protected/$owner/$repo/issues/new': typeof ProtectedOwnerRepoIssuesNewRoute '/_protected/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute @@ -319,6 +329,7 @@ export interface FileRouteTypes { | '/$owner/$repo/' | '/$owner/$repo/blob/$' | '/$owner/$repo/commit/$sha' + | '/$owner/$repo/compare/$' | '/$owner/$repo/issues/$issueId' | '/$owner/$repo/issues/new' | '/$owner/$repo/pull/$pullId' @@ -349,6 +360,7 @@ export interface FileRouteTypes { | '/$owner/$repo' | '/$owner/$repo/blob/$' | '/$owner/$repo/commit/$sha' + | '/$owner/$repo/compare/$' | '/$owner/$repo/issues/$issueId' | '/$owner/$repo/issues/new' | '/$owner/$repo/pull/$pullId' @@ -381,6 +393,7 @@ export interface FileRouteTypes { | '/_protected/$owner/$repo/' | '/_protected/$owner/$repo/blob/$' | '/_protected/$owner/$repo/commit/$sha' + | '/_protected/$owner/$repo/compare/$' | '/_protected/$owner/$repo/issues/$issueId' | '/_protected/$owner/$repo/issues/new' | '/_protected/$owner/$repo/pull/$pullId' @@ -600,6 +613,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProtectedOwnerRepoIssuesIssueIdRouteImport parentRoute: typeof ProtectedRoute } + '/_protected/$owner/$repo/compare/$': { + id: '/_protected/$owner/$repo/compare/$' + path: '/$owner/$repo/compare/$' + fullPath: '/$owner/$repo/compare/$' + preLoaderRoute: typeof ProtectedOwnerRepoCompareSplatRouteImport + parentRoute: typeof ProtectedRoute + } '/_protected/$owner/$repo/commit/$sha': { id: '/_protected/$owner/$repo/commit/$sha' path: '/$owner/$repo/commit/$sha' @@ -643,6 +663,7 @@ interface ProtectedRouteChildren { ProtectedOwnerRepoIndexRoute: typeof ProtectedOwnerRepoIndexRoute ProtectedOwnerRepoBlobSplatRoute: typeof ProtectedOwnerRepoBlobSplatRoute ProtectedOwnerRepoCommitShaRoute: typeof ProtectedOwnerRepoCommitShaRoute + ProtectedOwnerRepoCompareSplatRoute: typeof ProtectedOwnerRepoCompareSplatRoute ProtectedOwnerRepoIssuesIssueIdRoute: typeof ProtectedOwnerRepoIssuesIssueIdRoute ProtectedOwnerRepoIssuesNewRoute: typeof ProtectedOwnerRepoIssuesNewRoute ProtectedOwnerRepoPullPullIdRoute: typeof ProtectedOwnerRepoPullPullIdRoute @@ -664,6 +685,7 @@ const ProtectedRouteChildren: ProtectedRouteChildren = { ProtectedOwnerRepoIndexRoute: ProtectedOwnerRepoIndexRoute, ProtectedOwnerRepoBlobSplatRoute: ProtectedOwnerRepoBlobSplatRoute, ProtectedOwnerRepoCommitShaRoute: ProtectedOwnerRepoCommitShaRoute, + ProtectedOwnerRepoCompareSplatRoute: ProtectedOwnerRepoCompareSplatRoute, ProtectedOwnerRepoIssuesIssueIdRoute: ProtectedOwnerRepoIssuesIssueIdRoute, ProtectedOwnerRepoIssuesNewRoute: ProtectedOwnerRepoIssuesNewRoute, ProtectedOwnerRepoPullPullIdRoute: ProtectedOwnerRepoPullPullIdRoute, diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/compare.$.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/compare.$.tsx new file mode 100644 index 0000000..a6b3144 --- /dev/null +++ b/apps/dashboard/src/routes/_protected/$owner/$repo/compare.$.tsx @@ -0,0 +1,81 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { ComparePage } from "#/components/compare/compare-page"; +import { + githubCompareDetailQueryOptions, + githubRepoOverviewQueryOptions, + githubViewerQueryOptions, +} from "#/lib/github.query"; +import { parseCompareRef } from "#/lib/parse-repo-ref"; +import { buildSeo, formatPageTitle } from "#/lib/seo"; + +type CompareSearch = { + expand?: 1; +}; + +export const Route = createFileRoute("/_protected/$owner/$repo/compare/$")({ + ssr: false, + validateSearch: (search: Record): CompareSearch => ({ + expand: search.expand === 1 || search.expand === "1" ? 1 : undefined, + }), + loader: ({ context, params }) => { + const splat = params._splat ?? ""; + const parsed = parseCompareRef(splat); + if (!parsed) { + throw redirect({ + to: "/$owner/$repo", + params: { owner: params.owner, repo: params.repo }, + }); + } + + const scope = { userId: context.user.id }; + void context.queryClient.prefetchQuery( + githubRepoOverviewQueryOptions(scope, { + owner: params.owner, + repo: params.repo, + }), + ); + void context.queryClient.prefetchQuery(githubViewerQueryOptions(scope)); + void context.queryClient.prefetchQuery( + githubCompareDetailQueryOptions(scope, { + owner: params.owner, + repo: params.repo, + base: parsed.base, + head: parsed.head, + }), + ); + + return parsed; + }, + head: ({ match, params }) => + buildSeo({ + path: match.pathname, + title: formatPageTitle( + match.loaderData + ? `Compare ${match.loaderData.base}...${match.loaderData.head} — ${params.owner}/${params.repo}` + : `Compare — ${params.owner}/${params.repo}`, + ), + description: `Compare changes and open a pull request in ${params.owner}/${params.repo}.`, + robots: "noindex", + }), + component: CompareRoute, +}); + +function CompareRoute() { + const { user } = Route.useRouteContext(); + const { owner, repo } = Route.useParams(); + const { base, head } = Route.useLoaderData(); + const { expand } = Route.useSearch(); + const scope = useMemo(() => ({ userId: user.id }), [user.id]); + + return ( + + ); +} diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/index.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/index.tsx index 4897a14..3e349f0 100644 --- a/apps/dashboard/src/routes/_protected/$owner/$repo/index.tsx +++ b/apps/dashboard/src/routes/_protected/$owner/$repo/index.tsx @@ -1,5 +1,6 @@ import type { QueryClient } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; +import { useMemo } from "react"; import { RepoOverviewPage } from "#/components/repo/repo-overview-page"; import type { GitHubQueryScope } from "#/lib/github.query"; import { @@ -99,5 +100,12 @@ export const Route = createFileRoute("/_protected/$owner/$repo/")({ description: `Repository overview for ${params.owner}/${params.repo}.`, robots: "noindex", }), - component: RepoOverviewPage, + component: RepoOverviewRoute, }); + +function RepoOverviewRoute() { + const { user } = Route.useRouteContext(); + const { owner, repo } = Route.useParams(); + const scope = useMemo(() => ({ userId: user.id }), [user.id]); + return ; +} diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/tree.$.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/tree.$.tsx index df047dc..66698ca 100644 --- a/apps/dashboard/src/routes/_protected/$owner/$repo/tree.$.tsx +++ b/apps/dashboard/src/routes/_protected/$owner/$repo/tree.$.tsx @@ -1,6 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { useMemo } from "react"; import { RepoExplorerLayout } from "#/components/repo/repo-explorer-layout"; +import { RepoOverviewPage } from "#/components/repo/repo-overview-page"; import { githubRepoBranchesQueryOptions, githubRepoOverviewQueryOptions, @@ -89,6 +90,17 @@ function TreePage() { const { ref, path } = Route.useLoaderData(); const scope = useMemo(() => ({ userId: user.id }), [user.id]); + if (path === "") { + return ( + + ); + } + return ( & VariantProps) { + return ( +
+ ); +} + +function CalloutContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CalloutAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Callout, CalloutContent, CalloutAction, calloutVariants }; diff --git a/packages/ui/src/components/input.tsx b/packages/ui/src/components/input.tsx index ae23333..3e32d61 100644 --- a/packages/ui/src/components/input.tsx +++ b/packages/ui/src/components/input.tsx @@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) { type={type} data-slot="input" className={cn( - "border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow,border-color] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className, diff --git a/packages/ui/src/components/markdown-editor.tsx b/packages/ui/src/components/markdown-editor.tsx index 9993101..ada248e 100644 --- a/packages/ui/src/components/markdown-editor.tsx +++ b/packages/ui/src/components/markdown-editor.tsx @@ -408,7 +408,8 @@ export const MarkdownEditor = forwardRef< const rootProps = mediaUpload?.rootProps; const rootClassName = cn( - "flex flex-col rounded-lg border bg-surface-0 overflow-hidden", + "flex flex-col rounded-lg border bg-surface-0 overflow-hidden transition-[box-shadow,border-color]", + "has-[textarea:focus-visible]:border-ring has-[textarea:focus-visible]:ring-[3px] has-[textarea:focus-visible]:ring-ring/50", mediaUpload?.isDragActive && "ring-2 ring-primary/45 ring-inset", rootProps?.className, ); @@ -430,7 +431,10 @@ export const MarkdownEditor = forwardRef<