mirror of
https://github.com/better-auth/better-hub
synced 2026-08-09 12:26:04 +00:00
feat: improve issue list (#351)
This commit is contained in:
@@ -36,6 +36,7 @@
|
||||
"@sentry/nextjs": "^10",
|
||||
"@tanstack/react-hotkeys": "^0.3.0",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-virtual": "^3.13.26",
|
||||
"@tiptap/extension-link": "^3.19.0",
|
||||
"@tiptap/extension-mention": "^3.19.0",
|
||||
"@tiptap/extension-placeholder": "^3.19.0",
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Gauge,
|
||||
RefreshCw,
|
||||
Github,
|
||||
Clock,
|
||||
Zap,
|
||||
ShieldAlert,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { signOut } from "@/lib/auth-client";
|
||||
import { GithubIcon } from "@/components/shared/icons/github-icon";
|
||||
|
||||
function parseRateLimitFromDigest(message: string) {
|
||||
// The error message is serialized by Next.js, try to detect rate limit
|
||||
@@ -250,7 +250,7 @@ function RateLimitUI({ reset }: { reset: () => void }) {
|
||||
{/* Info card */}
|
||||
<div className="border border-border/40 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Github className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<GithubIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="text-[11px] font-mono text-muted-foreground/60">
|
||||
GitHub API ·{" "}
|
||||
{rateLimitInfo
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"use server";
|
||||
|
||||
import { getAuthenticatedUser, getOctokit, invalidateRepoIssuesCache } from "@/lib/github";
|
||||
import {
|
||||
getAuthenticatedUser,
|
||||
getOctokit,
|
||||
invalidateRepoIssuesCache,
|
||||
getRepoIssuesWithStats,
|
||||
type IssuesPageResult,
|
||||
} from "@/lib/github";
|
||||
import { getErrorMessage } from "@/lib/utils";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { invalidateRepoCache } from "@/lib/repo-data-cache-vc";
|
||||
@@ -30,6 +36,19 @@ export async function fetchIssuesByAuthor(owner: string, repo: string, author: s
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchIssuePage(
|
||||
owner: string,
|
||||
repo: string,
|
||||
state: "open" | "closed",
|
||||
cursor: string | null,
|
||||
): Promise<{ issues: IssuesPageResult["issues"]; pageInfo: IssuesPageResult["pageInfo"] }> {
|
||||
const { issues, pageInfo } = await getRepoIssuesWithStats(owner, repo, state, {
|
||||
perPage: 30,
|
||||
cursor,
|
||||
});
|
||||
return { issues, pageInfo };
|
||||
}
|
||||
|
||||
export interface IssueTemplate {
|
||||
name: string;
|
||||
about: string;
|
||||
|
||||
@@ -1,10 +1,240 @@
|
||||
"use server";
|
||||
|
||||
import { getOctokit, getIssueComments, invalidateIssueCache } from "@/lib/github";
|
||||
import {
|
||||
getOctokit,
|
||||
getIssueComments,
|
||||
invalidateIssueCache,
|
||||
getIssue,
|
||||
getRepo,
|
||||
getCrossReferences,
|
||||
getIssueTimelineEvents,
|
||||
getAuthenticatedUser,
|
||||
extractRepoPermissions,
|
||||
type CrossReference,
|
||||
type IssueTimelineEvent,
|
||||
} from "@/lib/github";
|
||||
import { renderMarkdownToHtml } from "@/components/shared/markdown-renderer";
|
||||
import { getErrorMessage } from "@/lib/utils";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { invalidateRepoCache } from "@/lib/repo-data-cache-vc";
|
||||
import { extractParticipants } from "@/lib/github-utils";
|
||||
import type { IssueComment } from "@/components/issue/issue-comments-client";
|
||||
import type { IssueDescriptionEntry } from "@/components/issue/issue-conversation";
|
||||
|
||||
export type IssueDetailData = {
|
||||
issue: {
|
||||
title: string;
|
||||
number: number;
|
||||
state: string;
|
||||
body: string | null;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
closed_at?: string | null;
|
||||
state_reason?: string | null;
|
||||
locked?: boolean;
|
||||
active_lock_reason?: string | null;
|
||||
comments: number;
|
||||
user: { login: string; avatar_url: string } | null;
|
||||
labels: Array<{ name?: string; color?: string | null }>;
|
||||
assignees: Array<{ login: string; avatar_url: string }>;
|
||||
milestone: {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
open_issues?: number;
|
||||
closed_issues?: number;
|
||||
} | null;
|
||||
closed_by?: { login: string; avatar_url: string } | null;
|
||||
reactions?: Record<string, unknown>;
|
||||
};
|
||||
comments: IssueComment[];
|
||||
descriptionEntry: IssueDescriptionEntry;
|
||||
crossRefs: CrossReference[];
|
||||
timelineEvents: IssueTimelineEvent[];
|
||||
participants: Array<{ login: string; avatar_url: string }>;
|
||||
currentUserLogin?: string;
|
||||
userAvatarUrl?: string;
|
||||
userName?: string;
|
||||
canEditIssue: boolean;
|
||||
canClose: boolean;
|
||||
canReopen: boolean;
|
||||
viewerHasWriteAccess: boolean;
|
||||
isPullRequest: boolean;
|
||||
};
|
||||
|
||||
export async function fetchIssueDetail(
|
||||
owner: string,
|
||||
repo: string,
|
||||
issueNumber: number,
|
||||
): Promise<IssueDetailData | null> {
|
||||
const [issue, rawComments, repoData, crossRefs, currentUser, timelineEvents] =
|
||||
await Promise.all([
|
||||
getIssue(owner, repo, issueNumber),
|
||||
getIssueComments(owner, repo, issueNumber),
|
||||
getRepo(owner, repo),
|
||||
getCrossReferences(owner, repo, issueNumber),
|
||||
getAuthenticatedUser(),
|
||||
getIssueTimelineEvents(owner, repo, issueNumber),
|
||||
]);
|
||||
|
||||
if (!issue) return null;
|
||||
|
||||
const isPullRequest = (issue as { pull_request?: unknown }).pull_request != null;
|
||||
if (isPullRequest) {
|
||||
return {
|
||||
isPullRequest: true,
|
||||
issue: {
|
||||
title: issue.title,
|
||||
number: issue.number,
|
||||
state: issue.state,
|
||||
body: issue.body ?? null,
|
||||
created_at: issue.created_at,
|
||||
comments: issue.comments,
|
||||
user: issue.user
|
||||
? {
|
||||
login: issue.user.login,
|
||||
avatar_url: issue.user.avatar_url,
|
||||
}
|
||||
: null,
|
||||
labels: [],
|
||||
assignees: [],
|
||||
milestone: null,
|
||||
},
|
||||
comments: [],
|
||||
descriptionEntry: {
|
||||
type: "description",
|
||||
id: "description",
|
||||
user: issue.user
|
||||
? {
|
||||
login: issue.user.login,
|
||||
avatar_url: issue.user.avatar_url,
|
||||
}
|
||||
: null,
|
||||
body: issue.body || "",
|
||||
created_at: issue.created_at,
|
||||
},
|
||||
crossRefs: [],
|
||||
timelineEvents: [],
|
||||
participants: [],
|
||||
canEditIssue: false,
|
||||
canClose: false,
|
||||
canReopen: false,
|
||||
viewerHasWriteAccess: false,
|
||||
};
|
||||
}
|
||||
|
||||
const comments = (rawComments || []) as IssueComment[];
|
||||
const issueRefCtx = { owner, repo };
|
||||
const [descriptionHtml, ...commentHtmls] = await Promise.all([
|
||||
issue.body
|
||||
? renderMarkdownToHtml(issue.body, undefined, issueRefCtx)
|
||||
: Promise.resolve(""),
|
||||
...comments.map((c) =>
|
||||
c.body
|
||||
? renderMarkdownToHtml(c.body, undefined, issueRefCtx)
|
||||
: Promise.resolve(""),
|
||||
),
|
||||
]);
|
||||
|
||||
const commentsWithHtml: IssueComment[] = comments.map((c, i) => ({
|
||||
...c,
|
||||
bodyHtml: commentHtmls[i],
|
||||
}));
|
||||
|
||||
const descriptionEntry: IssueDescriptionEntry = {
|
||||
type: "description",
|
||||
id: "description",
|
||||
user: issue.user
|
||||
? { login: issue.user.login, avatar_url: issue.user.avatar_url }
|
||||
: null,
|
||||
body: issue.body || "",
|
||||
bodyHtml: descriptionHtml,
|
||||
created_at: issue.created_at,
|
||||
reactions:
|
||||
(issue as { reactions?: Record<string, unknown> }).reactions ?? undefined,
|
||||
};
|
||||
|
||||
const permissions = extractRepoPermissions(repoData ?? {});
|
||||
const currentUserLogin = (currentUser as { login?: string } | null)?.login;
|
||||
const isAuthor = currentUserLogin === issue.user?.login && currentUserLogin != null;
|
||||
const viewerHasWriteAccess = permissions.push || permissions.maintain || permissions.admin;
|
||||
const canTriage = viewerHasWriteAccess || permissions.triage;
|
||||
|
||||
const participants = extractParticipants([
|
||||
issue.user ? { login: issue.user.login, avatar_url: issue.user.avatar_url } : null,
|
||||
...comments.map((c) =>
|
||||
c.user ? { login: c.user.login, avatar_url: c.user.avatar_url } : null,
|
||||
),
|
||||
]);
|
||||
|
||||
const issueRecord = issue as {
|
||||
assignees?: Array<{ login: string; avatar_url: string }>;
|
||||
milestone?: {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
open_issues?: number;
|
||||
closed_issues?: number;
|
||||
} | null;
|
||||
state_reason?: string | null;
|
||||
updated_at?: string;
|
||||
closed_at?: string | null;
|
||||
closed_by?: { login: string; avatar_url: string } | null;
|
||||
locked?: boolean;
|
||||
active_lock_reason?: string | null;
|
||||
reactions?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
return {
|
||||
issue: {
|
||||
title: issue.title,
|
||||
number: issue.number,
|
||||
state: issue.state,
|
||||
body: issue.body ?? null,
|
||||
created_at: issue.created_at,
|
||||
updated_at: issueRecord.updated_at,
|
||||
closed_at: issueRecord.closed_at ?? null,
|
||||
state_reason: issueRecord.state_reason ?? null,
|
||||
locked: issueRecord.locked ?? false,
|
||||
active_lock_reason: issueRecord.active_lock_reason ?? null,
|
||||
comments: issue.comments,
|
||||
user: issue.user
|
||||
? { login: issue.user.login, avatar_url: issue.user.avatar_url }
|
||||
: null,
|
||||
labels: (issue.labels || []).map((l) =>
|
||||
typeof l === "string"
|
||||
? { name: l }
|
||||
: { name: l.name, color: l.color ?? null },
|
||||
),
|
||||
assignees: (issueRecord.assignees || []).map((a) => ({
|
||||
login: a.login,
|
||||
avatar_url: a.avatar_url,
|
||||
})),
|
||||
milestone: issueRecord.milestone
|
||||
? {
|
||||
title: issueRecord.milestone.title,
|
||||
description:
|
||||
issueRecord.milestone.description ?? null,
|
||||
open_issues: issueRecord.milestone.open_issues,
|
||||
closed_issues: issueRecord.milestone.closed_issues,
|
||||
}
|
||||
: null,
|
||||
closed_by: issueRecord.closed_by ?? null,
|
||||
reactions: issueRecord.reactions,
|
||||
},
|
||||
comments: commentsWithHtml,
|
||||
descriptionEntry,
|
||||
crossRefs,
|
||||
timelineEvents,
|
||||
participants,
|
||||
currentUserLogin,
|
||||
userAvatarUrl: (currentUser as { avatar_url?: string } | null)?.avatar_url,
|
||||
userName: currentUserLogin,
|
||||
canEditIssue: !!(currentUserLogin && (isAuthor || viewerHasWriteAccess)),
|
||||
canClose: canTriage || isAuthor,
|
||||
canReopen: canTriage,
|
||||
viewerHasWriteAccess,
|
||||
isPullRequest: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchIssueComments(owner: string, repo: string, issueNumber: number) {
|
||||
const comments = await getIssueComments(owner, repo, issueNumber);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getRepoIssuesPage } from "@/lib/github";
|
||||
import { IssuesList } from "@/components/issue/issues-list";
|
||||
import { fetchIssuesByAuthor } from "./actions";
|
||||
import { fetchIssuesByAuthor, fetchIssuePage } from "./actions";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -19,7 +19,7 @@ export default async function IssuesListPage({
|
||||
}) {
|
||||
const { owner, repo } = await params;
|
||||
|
||||
const { openIssues, closedIssues, openCount, closedCount } = await getRepoIssuesPage(
|
||||
const { openIssues, openPageInfo, openCount, closedCount } = await getRepoIssuesPage(
|
||||
owner,
|
||||
repo,
|
||||
);
|
||||
@@ -28,8 +28,8 @@ export default async function IssuesListPage({
|
||||
<IssuesList
|
||||
owner={owner}
|
||||
repo={repo}
|
||||
openIssues={openIssues}
|
||||
closedIssues={closedIssues}
|
||||
initialOpenIssues={openIssues}
|
||||
initialOpenPageInfo={openPageInfo}
|
||||
openCount={openCount}
|
||||
closedCount={closedCount}
|
||||
onAuthorFilter={
|
||||
@@ -37,6 +37,7 @@ export default async function IssuesListPage({
|
||||
typeof IssuesList
|
||||
>[0]["onAuthorFilter"]
|
||||
}
|
||||
onFetchIssuePage={fetchIssuePage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ interface IssueDetailLayoutProps {
|
||||
timeline: React.ReactNode;
|
||||
commentForm?: React.ReactNode;
|
||||
sidebar?: React.ReactNode;
|
||||
contentGapClassName?: string;
|
||||
mainScrollClassName?: string;
|
||||
sidebarClassName?: string;
|
||||
}
|
||||
|
||||
export function IssueDetailLayout({
|
||||
@@ -15,6 +18,9 @@ export function IssueDetailLayout({
|
||||
timeline,
|
||||
commentForm,
|
||||
sidebar,
|
||||
contentGapClassName = "gap-6",
|
||||
mainScrollClassName = "pr-4",
|
||||
sidebarClassName = "w-[240px] xl:w-[280px] 2xl:w-[320px] pl-6",
|
||||
}: IssueDetailLayoutProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollUp, setCanScrollUp] = useState(false);
|
||||
@@ -44,7 +50,7 @@ export function IssueDetailLayout({
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="shrink-0 pt-3">{header}</div>
|
||||
|
||||
<div className="flex-1 min-h-0 flex gap-6">
|
||||
<div className={cn("flex-1 min-h-0 flex", contentGapClassName)}>
|
||||
{/* Main thread */}
|
||||
<div className="relative flex-1 min-w-0">
|
||||
{/* Top shadow */}
|
||||
@@ -63,7 +69,10 @@ export function IssueDetailLayout({
|
||||
/>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="h-full overflow-y-auto pb-8 pl-1 pr-4"
|
||||
className={cn(
|
||||
"h-full overflow-y-auto pb-8 pl-1",
|
||||
mainScrollClassName,
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{/* Mobile sidebar */}
|
||||
@@ -86,7 +95,12 @@ export function IssueDetailLayout({
|
||||
|
||||
{/* Right sidebar */}
|
||||
{sidebar && (
|
||||
<div className="hidden lg:block w-[240px] xl:w-[280px] 2xl:w-[320px] shrink-0 border-l border-border/40 pl-6 overflow-y-auto pb-8">
|
||||
<div
|
||||
className={cn(
|
||||
"hidden lg:block shrink-0 border-l border-border/40 overflow-y-auto pb-8",
|
||||
sidebarClassName,
|
||||
)}
|
||||
>
|
||||
<div className="space-y-5 pt-1">{sidebar}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Loader2, Maximize2, X } from "lucide-react";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import { ResizeHandle } from "@/components/ui/resize-handle";
|
||||
import { IssueDetailLayout } from "@/components/issue/issue-detail-layout";
|
||||
import { IssueHeader } from "@/components/issue/issue-header";
|
||||
import { IssueCommentsClient } from "@/components/issue/issue-comments-client";
|
||||
import { IssueCommentForm } from "@/components/issue/issue-comment-form";
|
||||
import { IssueSidebar } from "@/components/issue/issue-sidebar";
|
||||
import { IssueParticipants } from "@/components/issue/issue-participants";
|
||||
import type { IssueDetailData } from "@/app/(app)/repos/[owner]/[repo]/issues/issue-actions";
|
||||
|
||||
export const ISSUE_SHEET_WIDTH_COOKIE = "issue_sheet_width";
|
||||
export const DEFAULT_ISSUE_SHEET_WIDTH = "60vw";
|
||||
export const MIN_ISSUE_SHEET_WIDTH = 600;
|
||||
|
||||
export function IssueDetailSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
owner,
|
||||
repo,
|
||||
issueNumber,
|
||||
detail,
|
||||
isLoading,
|
||||
sheetWidth,
|
||||
isResizing,
|
||||
onResize,
|
||||
onResizeEnd,
|
||||
onResetWidth,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
owner: string;
|
||||
repo: string;
|
||||
issueNumber: number | null;
|
||||
detail: IssueDetailData | null;
|
||||
isLoading: boolean;
|
||||
sheetWidth: number | null;
|
||||
isResizing: boolean;
|
||||
onResize: (clientX: number) => void;
|
||||
onResizeEnd: () => void;
|
||||
onResetWidth: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
title="Issue Details"
|
||||
side="right"
|
||||
className="p-0 overflow-hidden"
|
||||
showCloseButton={false}
|
||||
style={{
|
||||
width: sheetWidth ?? DEFAULT_ISSUE_SHEET_WIDTH,
|
||||
maxWidth: "90vw",
|
||||
minWidth: sheetWidth
|
||||
? `${MIN_ISSUE_SHEET_WIDTH}px`
|
||||
: DEFAULT_ISSUE_SHEET_WIDTH,
|
||||
transition: isResizing ? "none" : "width 0.2s ease-out",
|
||||
}}
|
||||
>
|
||||
<ResizeHandle
|
||||
onResize={onResize}
|
||||
onDragStart={() => {}}
|
||||
onDragEnd={onResizeEnd}
|
||||
onDoubleClick={onResetWidth}
|
||||
className="absolute left-0 inset-y-0 z-20"
|
||||
/>
|
||||
<div className="absolute top-4 right-4 z-10 flex items-center gap-2">
|
||||
{issueNumber && (
|
||||
<Link
|
||||
href={`/${owner}/${repo}/issues/${issueNumber}`}
|
||||
title="Open full page"
|
||||
className="rounded-sm p-1 opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
Open full page
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="rounded-sm p-1 opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : detail?.isPullRequest && issueNumber ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 px-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This item is a pull request.
|
||||
</p>
|
||||
<Link
|
||||
href={`/${owner}/${repo}/pulls/${issueNumber}`}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Open pull request #{issueNumber}
|
||||
</Link>
|
||||
</div>
|
||||
) : detail ? (
|
||||
<div className="h-full px-4 sm:px-6 flex flex-col">
|
||||
<IssueDetailLayout
|
||||
contentGapClassName="gap-4"
|
||||
mainScrollClassName="pr-3"
|
||||
sidebarClassName="w-[180px] xl:w-[200px] pl-4"
|
||||
header={
|
||||
<IssueHeader
|
||||
title={detail.issue.title}
|
||||
number={detail.issue.number}
|
||||
state={detail.issue.state}
|
||||
author={detail.issue.user}
|
||||
createdAt={
|
||||
detail.issue
|
||||
.created_at
|
||||
}
|
||||
commentsCount={
|
||||
detail.issue
|
||||
.comments
|
||||
}
|
||||
labels={detail.issue.labels.map(
|
||||
(l) => ({
|
||||
name: l.name,
|
||||
color:
|
||||
l.color ??
|
||||
undefined,
|
||||
}),
|
||||
)}
|
||||
owner={owner}
|
||||
repo={repo}
|
||||
crossRefs={detail.crossRefs}
|
||||
/>
|
||||
}
|
||||
timeline={
|
||||
<IssueCommentsClient
|
||||
owner={owner}
|
||||
repo={repo}
|
||||
issueNumber={
|
||||
detail.issue.number
|
||||
}
|
||||
initialComments={
|
||||
detail.comments
|
||||
}
|
||||
descriptionEntry={
|
||||
detail.descriptionEntry
|
||||
}
|
||||
canEdit={
|
||||
detail.canEditIssue
|
||||
}
|
||||
issueTitle={
|
||||
detail.issue.title
|
||||
}
|
||||
currentUserLogin={
|
||||
detail.currentUserLogin
|
||||
}
|
||||
viewerHasWriteAccess={
|
||||
detail.viewerHasWriteAccess
|
||||
}
|
||||
timelineEvents={
|
||||
detail.timelineEvents
|
||||
}
|
||||
/>
|
||||
}
|
||||
commentForm={
|
||||
<IssueCommentForm
|
||||
owner={owner}
|
||||
repo={repo}
|
||||
issueNumber={
|
||||
detail.issue.number
|
||||
}
|
||||
issueState={
|
||||
detail.issue.state
|
||||
}
|
||||
canClose={detail.canClose}
|
||||
canReopen={detail.canReopen}
|
||||
userAvatarUrl={
|
||||
detail.userAvatarUrl
|
||||
}
|
||||
userName={detail.userName}
|
||||
participants={
|
||||
detail.participants
|
||||
}
|
||||
/>
|
||||
}
|
||||
sidebar={
|
||||
<>
|
||||
<IssueSidebar
|
||||
assignees={
|
||||
detail.issue
|
||||
.assignees
|
||||
}
|
||||
labels={detail.issue.labels.map(
|
||||
(l) => ({
|
||||
name: l.name,
|
||||
color:
|
||||
l.color ??
|
||||
undefined,
|
||||
}),
|
||||
)}
|
||||
milestone={
|
||||
detail.issue
|
||||
.milestone
|
||||
}
|
||||
state={
|
||||
detail.issue
|
||||
.state
|
||||
}
|
||||
stateReason={
|
||||
detail.issue
|
||||
.state_reason ??
|
||||
null
|
||||
}
|
||||
createdAt={
|
||||
detail.issue
|
||||
.created_at
|
||||
}
|
||||
updatedAt={
|
||||
detail.issue
|
||||
.updated_at
|
||||
}
|
||||
closedAt={
|
||||
detail.issue
|
||||
.closed_at ??
|
||||
null
|
||||
}
|
||||
closedBy={
|
||||
detail.issue
|
||||
.closed_by ??
|
||||
null
|
||||
}
|
||||
locked={
|
||||
detail.issue
|
||||
.locked ??
|
||||
false
|
||||
}
|
||||
activeLockReason={
|
||||
detail.issue
|
||||
.active_lock_reason ??
|
||||
null
|
||||
}
|
||||
crossRefs={
|
||||
detail.crossRefs
|
||||
}
|
||||
owner={owner}
|
||||
repo={repo}
|
||||
/>
|
||||
<IssueParticipants
|
||||
participants={
|
||||
detail.participants
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Issue not found
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
SlidersHorizontal,
|
||||
Search,
|
||||
CircleSlash,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TimeAgo } from "@/components/ui/time-ago";
|
||||
@@ -22,13 +23,28 @@ import { CreateIssueDialog } from "./create-issue-dialog";
|
||||
import { useClickOutside } from "@/hooks/use-click-outside";
|
||||
import { useMutationSubscription } from "@/hooks/use-mutation-subscription";
|
||||
import { isRepoEvent, type MutationEvent } from "@/lib/mutation-events";
|
||||
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
||||
import { InfiniteScrollSentinel, LoadingOverlay } from "@/components/shared/list-controls";
|
||||
import { LoadingOverlay } from "@/components/shared/list-controls";
|
||||
import { LabelBadge } from "@/components/shared/label-badge";
|
||||
import { useGlobalChat } from "@/components/shared/global-chat-provider";
|
||||
import { useHotkey } from "@tanstack/react-hotkeys";
|
||||
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useServerInitialData } from "@/hooks/use-server-initial-data";
|
||||
import type { IssuesPageResult } from "@/lib/github";
|
||||
import { Zap, GitPullRequest } from "lucide-react";
|
||||
import { UserTooltip } from "@/components/shared/user-tooltip";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import {
|
||||
IssueDetailSheet,
|
||||
ISSUE_SHEET_WIDTH_COOKIE,
|
||||
MIN_ISSUE_SHEET_WIDTH,
|
||||
} from "@/components/issue/issue-detail-sheet";
|
||||
import {
|
||||
fetchIssueDetail,
|
||||
type IssueDetailData,
|
||||
} from "@/app/(app)/repos/[owner]/[repo]/issues/issue-actions";
|
||||
|
||||
const MAX_SHEET_WIDTH_RATIO = 0.9;
|
||||
|
||||
interface IssueUser {
|
||||
login: string;
|
||||
@@ -69,19 +85,27 @@ const sortLabels: Record<SortType, string> = {
|
||||
|
||||
const sortCycle: SortType[] = ["updated", "newest", "oldest", "comments", "reactions"];
|
||||
|
||||
type FetchIssuePageFn = (
|
||||
owner: string,
|
||||
repo: string,
|
||||
state: "open" | "closed",
|
||||
cursor: string | null,
|
||||
) => Promise<{ issues: IssuesPageResult["issues"]; pageInfo: IssuesPageResult["pageInfo"] }>;
|
||||
|
||||
export function IssuesList({
|
||||
owner,
|
||||
repo,
|
||||
openIssues,
|
||||
closedIssues,
|
||||
initialOpenIssues,
|
||||
initialOpenPageInfo,
|
||||
openCount,
|
||||
closedCount,
|
||||
onAuthorFilter,
|
||||
onFetchIssuePage,
|
||||
}: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
openIssues: Issue[];
|
||||
closedIssues: Issue[];
|
||||
initialOpenIssues: Issue[];
|
||||
initialOpenPageInfo: IssuesPageResult["pageInfo"];
|
||||
openCount: number;
|
||||
closedCount: number;
|
||||
onAuthorFilter?: (
|
||||
@@ -89,6 +113,7 @@ export function IssuesList({
|
||||
repo: string,
|
||||
author: string,
|
||||
) => Promise<{ open: Issue[]; closed: Issue[] }>;
|
||||
onFetchIssuePage?: FetchIssuePageFn;
|
||||
}) {
|
||||
const searchParams = useSearchParams();
|
||||
const tabParam = searchParams.get("tab");
|
||||
@@ -116,10 +141,201 @@ export function IssuesList({
|
||||
|
||||
const [countAdjustments, setCountAdjustments] = useState({ open: 0, closed: 0 });
|
||||
const filtersTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Sheet state
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [selectedIssueNumber, setSelectedIssueNumber] = useState<number | null>(null);
|
||||
const [issueDetail, setIssueDetail] = useState<IssueDetailData | null>(null);
|
||||
const [isLoadingDetail, setIsLoadingDetail] = useState(false);
|
||||
const [sheetWidth, setSheetWidth] = useState<number | null>(null);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const match = document.cookie.match(
|
||||
new RegExp(`(?:^|; )${ISSUE_SHEET_WIDTH_COOKIE}=([^;]*)`),
|
||||
);
|
||||
if (match) {
|
||||
const savedWidth = parseInt(match[1], 10);
|
||||
if (!isNaN(savedWidth) && savedWidth >= MIN_ISSUE_SHEET_WIDTH) {
|
||||
setSheetWidth(savedWidth);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const saveSheetWidthCookie = useCallback((width: number | null) => {
|
||||
if (width === null) {
|
||||
document.cookie = `${ISSUE_SHEET_WIDTH_COOKIE}=;path=/;max-age=0`;
|
||||
} else {
|
||||
document.cookie = `${ISSUE_SHEET_WIDTH_COOKIE}=${width};path=/;max-age=${365 * 24 * 60 * 60};samesite=lax`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSheetResize = useCallback((clientX: number) => {
|
||||
const newWidth = window.innerWidth - clientX;
|
||||
const maxWidth = window.innerWidth * MAX_SHEET_WIDTH_RATIO;
|
||||
setSheetWidth(Math.max(MIN_ISSUE_SHEET_WIDTH, Math.min(maxWidth, newWidth)));
|
||||
setIsResizing(true);
|
||||
}, []);
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
setIsResizing(false);
|
||||
if (sheetWidth !== null) {
|
||||
saveSheetWidthCookie(sheetWidth);
|
||||
}
|
||||
}, [sheetWidth, saveSheetWidthCookie]);
|
||||
|
||||
const resetSheetWidth = useCallback(() => {
|
||||
setSheetWidth(null);
|
||||
saveSheetWidthCookie(null);
|
||||
}, [saveSheetWidthCookie]);
|
||||
|
||||
const handleIssueClick = useCallback(
|
||||
async (issueNumber: number) => {
|
||||
setSelectedIssueNumber(issueNumber);
|
||||
setSheetOpen(true);
|
||||
setIsLoadingDetail(true);
|
||||
setIssueDetail(null);
|
||||
|
||||
const result = await fetchIssueDetail(owner, repo, issueNumber);
|
||||
setIssueDetail(result);
|
||||
setIsLoadingDetail(false);
|
||||
},
|
||||
[owner, repo],
|
||||
);
|
||||
|
||||
type IssuePage = {
|
||||
issues: Issue[];
|
||||
pageInfo: { hasNextPage: boolean; endCursor: string | null };
|
||||
};
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const openDataFingerprint = useMemo(() => {
|
||||
if (initialOpenIssues.length === 0) return "empty";
|
||||
const ids = initialOpenIssues
|
||||
.slice(0, 5)
|
||||
.map((issue) => issue.id)
|
||||
.join("-");
|
||||
return `${ids}:${initialOpenIssues.length}:${initialOpenPageInfo.endCursor ?? ""}`;
|
||||
}, [initialOpenIssues, initialOpenPageInfo]);
|
||||
|
||||
const openQueryKey = useMemo(() => ["issues", owner, repo, "open"], [owner, repo]);
|
||||
const closedQueryKey = useMemo(() => ["issues", owner, repo, "closed"], [owner, repo]);
|
||||
|
||||
useServerInitialData(
|
||||
openQueryKey,
|
||||
{
|
||||
pages: [{ issues: initialOpenIssues, pageInfo: initialOpenPageInfo }],
|
||||
pageParams: [null],
|
||||
},
|
||||
openDataFingerprint,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
queryClient.removeQueries({ queryKey: closedQueryKey });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [openDataFingerprint]);
|
||||
|
||||
const openQuery = useInfiniteQuery<
|
||||
IssuePage,
|
||||
Error,
|
||||
{ pages: IssuePage[]; pageParams: (string | null)[] },
|
||||
string[],
|
||||
string | null
|
||||
>({
|
||||
queryKey: openQueryKey,
|
||||
queryFn: async ({ pageParam }) => {
|
||||
if (!onFetchIssuePage) {
|
||||
return {
|
||||
issues: [],
|
||||
pageInfo: { hasNextPage: false, endCursor: null },
|
||||
};
|
||||
}
|
||||
return onFetchIssuePage(
|
||||
owner,
|
||||
repo,
|
||||
"open",
|
||||
pageParam,
|
||||
) as Promise<IssuePage>;
|
||||
},
|
||||
initialPageParam: null,
|
||||
initialData: {
|
||||
pages: [{ issues: initialOpenIssues, pageInfo: initialOpenPageInfo }],
|
||||
pageParams: [null],
|
||||
},
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.pageInfo.hasNextPage ? lastPage.pageInfo.endCursor : undefined,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const closedQuery = useInfiniteQuery<
|
||||
IssuePage,
|
||||
Error,
|
||||
{ pages: IssuePage[]; pageParams: (string | null)[] },
|
||||
string[],
|
||||
string | null
|
||||
>({
|
||||
queryKey: closedQueryKey,
|
||||
queryFn: async ({ pageParam }) => {
|
||||
if (!onFetchIssuePage) {
|
||||
return {
|
||||
issues: [],
|
||||
pageInfo: { hasNextPage: false, endCursor: null },
|
||||
};
|
||||
}
|
||||
return onFetchIssuePage(
|
||||
owner,
|
||||
repo,
|
||||
"closed",
|
||||
pageParam,
|
||||
) as Promise<IssuePage>;
|
||||
},
|
||||
initialPageParam: null,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.pageInfo.hasNextPage ? lastPage.pageInfo.endCursor : undefined,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const openIssues = useMemo(
|
||||
() => openQuery.data?.pages.flatMap((p) => p.issues) ?? initialOpenIssues,
|
||||
[openQuery.data, initialOpenIssues],
|
||||
);
|
||||
|
||||
const closedAllIssues = useMemo(
|
||||
() => closedQuery.data?.pages.flatMap((p) => p.issues) ?? [],
|
||||
[closedQuery.data],
|
||||
);
|
||||
|
||||
const closedIssuesLoaded = closedQuery.data !== undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (initialTab !== "open" && !closedQuery.data && !closedQuery.isFetching) {
|
||||
closedQuery.refetch();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(tab: TabState) => {
|
||||
setState(tab);
|
||||
const url = new URL(window.location.href);
|
||||
if (tab === "open") {
|
||||
url.searchParams.delete("tab");
|
||||
} else {
|
||||
url.searchParams.set("tab", tab);
|
||||
}
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
if (tab !== "open" && !closedQuery.data && !closedQuery.isFetching) {
|
||||
closedQuery.refetch();
|
||||
}
|
||||
},
|
||||
[closedQuery],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCountAdjustments({ open: 0, closed: 0 });
|
||||
}, [openIssues, closedIssues]);
|
||||
}, [openIssues, closedAllIssues]);
|
||||
|
||||
useMutationSubscription(
|
||||
["issue:closed", "issue:reopened", "issue:created"],
|
||||
@@ -149,8 +365,8 @@ export function IssuesList({
|
||||
);
|
||||
|
||||
const allIssues = useMemo(
|
||||
() => [...openIssues, ...closedIssues],
|
||||
[openIssues, closedIssues],
|
||||
() => [...openIssues, ...closedAllIssues],
|
||||
[openIssues, closedAllIssues],
|
||||
);
|
||||
|
||||
const authors = useMemo(() => {
|
||||
@@ -225,7 +441,11 @@ export function IssuesList({
|
||||
};
|
||||
|
||||
const currentOpenIssues = authorIssues ? authorIssues.open : openIssues;
|
||||
const currentClosedIssues = authorIssues ? authorIssues.closed : closedIssues;
|
||||
const currentClosedIssues = authorIssues
|
||||
? authorIssues.closed
|
||||
: closedIssuesLoaded
|
||||
? closedAllIssues
|
||||
: [];
|
||||
|
||||
const closedCompleted = useMemo(
|
||||
() => currentClosedIssues.filter((i) => i.state_reason !== "not_planned"),
|
||||
@@ -342,20 +562,44 @@ export function IssuesList({
|
||||
authorIssues,
|
||||
]);
|
||||
|
||||
const { visible, hasMore, loadMore, sentinelRef } = useInfiniteScroll(filtered, [
|
||||
state,
|
||||
search,
|
||||
sort,
|
||||
selectedAuthor,
|
||||
selectedLabel,
|
||||
assigneeFilter,
|
||||
activityFilter,
|
||||
selectedMilestone,
|
||||
]);
|
||||
const activeQuery = authorIssues ? null : state === "open" ? openQuery : closedQuery;
|
||||
const canFetchMore =
|
||||
!!activeQuery && activeQuery.hasNextPage && !activeQuery.isFetchingNextPage;
|
||||
const isFetchingMore = activeQuery?.isFetchingNextPage ?? false;
|
||||
const isFetchingInitialPage =
|
||||
!!activeQuery && activeQuery.isFetching && !activeQuery.isFetchingNextPage;
|
||||
const hasReachedEnd =
|
||||
filtered.length > 0 &&
|
||||
(authorIssues
|
||||
? true
|
||||
: !!activeQuery && !activeQuery.hasNextPage && !activeQuery.isFetching);
|
||||
const showListFooter =
|
||||
filtered.length > 0 && (isFetchingMore || isFetchingInitialPage || hasReachedEnd);
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const issueLinksRef = useRef<(HTMLAnchorElement | null)[]>([]);
|
||||
const issueLinksRef = useRef<(HTMLElement | null)[]>([]);
|
||||
const listContainerRef = useRef<HTMLDivElement>(null);
|
||||
const listScrollRef = useRef<HTMLDivElement>(null);
|
||||
const virtualCount = filtered.length + (showListFooter ? 1 : 0);
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: virtualCount,
|
||||
getScrollElement: () => listScrollRef.current,
|
||||
estimateSize: (index) => (index >= filtered.length ? 72 : 84),
|
||||
overscan: 8,
|
||||
getItemKey: (index) =>
|
||||
index < filtered.length
|
||||
? (filtered[index]?.id ?? index)
|
||||
: `issues-footer-${state}`,
|
||||
});
|
||||
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||
|
||||
useEffect(() => {
|
||||
const lastVirtualItem = virtualItems[virtualItems.length - 1];
|
||||
if (!lastVirtualItem || !activeQuery || !canFetchMore) return;
|
||||
if (lastVirtualItem.index >= filtered.length - 1) {
|
||||
activeQuery.fetchNextPage();
|
||||
}
|
||||
}, [activeQuery, canFetchMore, filtered.length, virtualItems]);
|
||||
|
||||
// Focus search bar when issues tab is shown (keyboard-first UX)
|
||||
useEffect(() => {
|
||||
@@ -368,7 +612,7 @@ export function IssuesList({
|
||||
(e: KeyboardEvent) => {
|
||||
if (!listContainerRef.current?.contains(document.activeElement)) return;
|
||||
if (document.activeElement === searchInputRef.current) {
|
||||
if (visible.length > 0) {
|
||||
if (filtered.length > 0) {
|
||||
e.preventDefault();
|
||||
issueLinksRef.current[0]?.focus();
|
||||
}
|
||||
@@ -376,7 +620,7 @@ export function IssuesList({
|
||||
const idx = issueLinksRef.current.findIndex(
|
||||
(el) => el === document.activeElement,
|
||||
);
|
||||
if (idx >= 0 && idx < visible.length - 1) {
|
||||
if (idx >= 0 && idx < filtered.length - 1) {
|
||||
e.preventDefault();
|
||||
issueLinksRef.current[idx + 1]?.focus();
|
||||
}
|
||||
@@ -933,48 +1177,31 @@ export function IssuesList({
|
||||
key: "open" as TabState,
|
||||
label: "Open",
|
||||
icon: <CircleDot className="w-3 h-3" />,
|
||||
count:
|
||||
currentOpenIssues.length +
|
||||
countAdjustments.open,
|
||||
count: authorIssues
|
||||
? currentOpenIssues.length
|
||||
: openCount + countAdjustments.open,
|
||||
},
|
||||
{
|
||||
key: "closed" as TabState,
|
||||
label: "Closed",
|
||||
icon: <CheckCircle2 className="w-3 h-3" />,
|
||||
count:
|
||||
closedCompleted.length +
|
||||
countAdjustments.closed,
|
||||
count: authorIssues
|
||||
? closedCompleted.length
|
||||
: closedCount +
|
||||
countAdjustments.closed,
|
||||
},
|
||||
{
|
||||
key: "not_planned" as TabState,
|
||||
label: "Not Planned",
|
||||
icon: <CircleSlash className="w-3 h-3" />,
|
||||
count: closedNotPlanned.length,
|
||||
count: authorIssues
|
||||
? closedNotPlanned.length
|
||||
: closedNotPlanned.length,
|
||||
},
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => {
|
||||
setState(tab.key);
|
||||
const url = new URL(
|
||||
window.location.href,
|
||||
);
|
||||
if (tab.key === "open") {
|
||||
url.searchParams.delete(
|
||||
"tab",
|
||||
);
|
||||
} else {
|
||||
url.searchParams.set(
|
||||
"tab",
|
||||
tab.key,
|
||||
);
|
||||
}
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
url.toString(),
|
||||
);
|
||||
}}
|
||||
onClick={() => handleTabChange(tab.key)}
|
||||
className={cn(
|
||||
"relative flex items-center gap-1.5 px-3 pb-2.5 pt-1 text-[12px] transition-colors cursor-pointer",
|
||||
state === tab.key
|
||||
@@ -1005,215 +1232,341 @@ export function IssuesList({
|
||||
</div>
|
||||
|
||||
{/* Issue List */}
|
||||
<div className="relative flex-1 min-h-0 overflow-y-auto divide-y divide-border">
|
||||
<div
|
||||
ref={listScrollRef}
|
||||
className="relative flex-1 min-h-0 overflow-y-auto"
|
||||
>
|
||||
<LoadingOverlay show={isPending} />
|
||||
{visible.map((issue, index) => {
|
||||
const reactionCount = issue.reactions?.["+1"] ?? 0;
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{ height: rowVirtualizer.getTotalSize() }}
|
||||
>
|
||||
{virtualItems.map((virtualItem) => {
|
||||
if (virtualItem.index >= filtered.length) {
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
data-index={
|
||||
virtualItem.index
|
||||
}
|
||||
ref={
|
||||
rowVirtualizer.measureElement
|
||||
}
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
{(isFetchingMore ||
|
||||
isFetchingInitialPage) && (
|
||||
<div className="py-6 border-t border-border/30 text-center">
|
||||
<Loader2 className="w-4 h-4 text-muted-foreground mx-auto mb-2 animate-spin" />
|
||||
<p className="text-xs text-muted-foreground/50 font-mono">
|
||||
Loading
|
||||
more
|
||||
issues…
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{hasReachedEnd && (
|
||||
<div className="py-6 border-t border-border/30 text-center">
|
||||
<p className="text-xs text-muted-foreground/40 font-mono">
|
||||
You've
|
||||
reached
|
||||
the
|
||||
end
|
||||
of
|
||||
the
|
||||
list
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={issue.id}
|
||||
ref={(el) => {
|
||||
issueLinksRef.current[index] = el;
|
||||
}}
|
||||
href={`/${owner}/${repo}/issues/${issue.number}`}
|
||||
className="group flex items-start gap-3 px-4 py-3 hover:bg-muted/50 dark:hover:bg-white/[0.02] transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background rounded-sm"
|
||||
tabIndex={0}
|
||||
>
|
||||
{issue.state === "open" ? (
|
||||
<CircleDot className="w-3.5 h-3.5 shrink-0 mt-0.5 text-success" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-3.5 h-3.5 shrink-0 mt-0.5 text-alert-important" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Row 1: Title + Milestone badge + Labels + Assignee avatars */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm truncate group-hover:text-foreground transition-colors">
|
||||
{issue.title}
|
||||
</span>
|
||||
{issue.milestone && (
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 border border-border/60 text-muted-foreground/70 shrink-0">
|
||||
const index = virtualItem.index;
|
||||
const issue = filtered[index];
|
||||
if (!issue) return null;
|
||||
const reactionCount = issue.reactions?.["+1"] ?? 0;
|
||||
const rowClassName =
|
||||
"group flex items-start gap-3 px-4 py-3 border-b border-border hover:bg-muted/50 dark:hover:bg-white/[0.02] transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background rounded-sm";
|
||||
const setRowRef = (el: HTMLElement | null) => {
|
||||
issueLinksRef.current[index] = el;
|
||||
};
|
||||
|
||||
const rowContent = (
|
||||
<>
|
||||
{issue.state === "open" ? (
|
||||
<CircleDot className="w-3.5 h-3.5 shrink-0 mt-0.5 text-success" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-3.5 h-3.5 shrink-0 mt-0.5 text-alert-important" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Row 1: Title + Milestone badge + Labels + Assignee avatars */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm truncate group-hover:text-foreground transition-colors">
|
||||
{
|
||||
issue
|
||||
.milestone
|
||||
.title
|
||||
issue.title
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
{issue.labels
|
||||
.map((l) =>
|
||||
typeof l ===
|
||||
"string"
|
||||
? {
|
||||
name: l,
|
||||
color: "888",
|
||||
}
|
||||
: l,
|
||||
)
|
||||
.filter(
|
||||
(l) =>
|
||||
l.name,
|
||||
)
|
||||
.slice(0, 3)
|
||||
.map((label) => (
|
||||
<LabelBadge
|
||||
key={
|
||||
label.name
|
||||
{issue.milestone && (
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 border border-border/60 text-muted-foreground/70 shrink-0">
|
||||
{
|
||||
issue
|
||||
.milestone
|
||||
.title
|
||||
}
|
||||
label={
|
||||
label
|
||||
</span>
|
||||
)}
|
||||
{issue.labels
|
||||
.map((l) =>
|
||||
typeof l ===
|
||||
"string"
|
||||
? {
|
||||
name: l,
|
||||
color: "888",
|
||||
}
|
||||
: l,
|
||||
)
|
||||
.filter(
|
||||
(
|
||||
l,
|
||||
) =>
|
||||
l.name,
|
||||
)
|
||||
.slice(0, 3)
|
||||
.map(
|
||||
(
|
||||
label,
|
||||
) => (
|
||||
<LabelBadge
|
||||
key={
|
||||
label.name
|
||||
}
|
||||
label={
|
||||
label
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{/* Assignee avatars — far right */}
|
||||
{(issue.assignees
|
||||
?.length ??
|
||||
0) > 0 && (
|
||||
<span className="flex items-center ml-auto shrink-0 -space-x-1.5">
|
||||
{(
|
||||
issue.assignees ??
|
||||
[]
|
||||
)
|
||||
.slice(
|
||||
0,
|
||||
3,
|
||||
)
|
||||
.map(
|
||||
(
|
||||
a,
|
||||
) => (
|
||||
<UserTooltip
|
||||
key={
|
||||
a.login
|
||||
}
|
||||
username={
|
||||
a.login
|
||||
}
|
||||
>
|
||||
<Link
|
||||
href={`/users/${a.login}`}
|
||||
onClick={(
|
||||
e,
|
||||
) =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
>
|
||||
<Image
|
||||
src={
|
||||
a.avatar_url
|
||||
}
|
||||
alt={
|
||||
a.login
|
||||
}
|
||||
width={
|
||||
16
|
||||
}
|
||||
height={
|
||||
16
|
||||
}
|
||||
className="rounded-full border border-border hover:ring-2 hover:ring-primary/50 transition-all"
|
||||
/>
|
||||
</Link>
|
||||
</UserTooltip>
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: Author avatar + login + opened X ago */}
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{issue.user && (
|
||||
<UserTooltip
|
||||
username={
|
||||
issue
|
||||
.user
|
||||
.login
|
||||
}
|
||||
>
|
||||
<Link
|
||||
href={`/users/${issue.user.login}`}
|
||||
onClick={(
|
||||
e,
|
||||
) =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
className="flex items-center gap-1 text-[11px] text-muted-foreground/60 hover:text-foreground transition-colors"
|
||||
>
|
||||
<Image
|
||||
src={
|
||||
issue
|
||||
.user
|
||||
.avatar_url
|
||||
}
|
||||
alt={
|
||||
issue
|
||||
.user
|
||||
.login
|
||||
}
|
||||
width={
|
||||
14
|
||||
}
|
||||
height={
|
||||
14
|
||||
}
|
||||
className="rounded-full"
|
||||
/>
|
||||
<span className="font-mono text-[10px] hover:underline">
|
||||
{
|
||||
issue
|
||||
.user
|
||||
.login
|
||||
}
|
||||
</span>
|
||||
</Link>
|
||||
</UserTooltip>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground/50">
|
||||
opened{" "}
|
||||
<TimeAgo
|
||||
date={
|
||||
issue.created_at
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{/* Assignee avatars — far right */}
|
||||
{(issue.assignees?.length ??
|
||||
0) > 0 && (
|
||||
<span className="flex items-center ml-auto shrink-0 -space-x-1.5">
|
||||
{(
|
||||
issue.assignees ??
|
||||
[]
|
||||
)
|
||||
.slice(
|
||||
0,
|
||||
3,
|
||||
)
|
||||
.map(
|
||||
(
|
||||
a,
|
||||
) => (
|
||||
<UserTooltip
|
||||
key={
|
||||
a.login
|
||||
}
|
||||
username={
|
||||
a.login
|
||||
}
|
||||
>
|
||||
<Link
|
||||
href={`/users/${a.login}`}
|
||||
>
|
||||
<Image
|
||||
src={
|
||||
a.avatar_url
|
||||
}
|
||||
alt={
|
||||
a.login
|
||||
}
|
||||
width={
|
||||
16
|
||||
}
|
||||
height={
|
||||
16
|
||||
}
|
||||
className="rounded-full border border-border hover:ring-2 hover:ring-primary/50 transition-all"
|
||||
/>
|
||||
</Link>
|
||||
</UserTooltip>
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Author avatar + login + opened X ago */}
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{issue.user && (
|
||||
<UserTooltip
|
||||
username={
|
||||
issue
|
||||
.user
|
||||
.login
|
||||
}
|
||||
>
|
||||
<Link
|
||||
href={`/users/${issue.user.login}`}
|
||||
className="flex items-center gap-1 text-[11px] text-muted-foreground/60 hover:text-foreground transition-colors"
|
||||
>
|
||||
<Image
|
||||
src={
|
||||
issue
|
||||
.user
|
||||
.avatar_url
|
||||
}
|
||||
alt={
|
||||
issue
|
||||
.user
|
||||
.login
|
||||
}
|
||||
width={
|
||||
14
|
||||
}
|
||||
height={
|
||||
14
|
||||
}
|
||||
className="rounded-full"
|
||||
/>
|
||||
<span className="font-mono text-[10px] hover:underline">
|
||||
{
|
||||
issue
|
||||
.user
|
||||
.login
|
||||
}
|
||||
</span>
|
||||
</Link>
|
||||
</UserTooltip>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground/50">
|
||||
opened{" "}
|
||||
<TimeAgo
|
||||
date={
|
||||
issue.created_at
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Row 3: #number + updated X ago + comments + reactions */}
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="text-[11px] font-mono text-muted-foreground/70">
|
||||
#{issue.number}
|
||||
</span>
|
||||
{issue.pull_request && (
|
||||
<span className="flex items-center gap-1 text-[11px] text-purple-400/70 font-mono">
|
||||
<GitPullRequest className="w-3 h-3" />
|
||||
Linked PR
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/50">
|
||||
<Clock className="w-3 h-3" />
|
||||
<TimeAgo
|
||||
date={
|
||||
issue.updated_at
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/50">
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{issue.comments ??
|
||||
0}
|
||||
</span>
|
||||
{reactionCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/50">
|
||||
<ThumbsUp className="w-3 h-3" />
|
||||
{/* Row 3: #number + updated X ago + comments + reactions */}
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="text-[11px] font-mono text-muted-foreground/70">
|
||||
#
|
||||
{
|
||||
reactionCount
|
||||
issue.number
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
{issue.pull_request && (
|
||||
<span className="flex items-center gap-1 text-[11px] text-purple-400/70 font-mono">
|
||||
<GitPullRequest className="w-3 h-3" />
|
||||
Linked
|
||||
PR
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/50">
|
||||
<Clock className="w-3 h-3" />
|
||||
<TimeAgo
|
||||
date={
|
||||
issue.updated_at
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/50">
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{issue.comments ??
|
||||
0}
|
||||
</span>
|
||||
{reactionCount >
|
||||
0 && (
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/50">
|
||||
<ThumbsUp className="w-3 h-3" />
|
||||
{
|
||||
reactionCount
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isMobile === undefined || isMobile) {
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
data-index={
|
||||
virtualItem.index
|
||||
}
|
||||
ref={
|
||||
rowVirtualizer.measureElement
|
||||
}
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
ref={setRowRef}
|
||||
href={`/${owner}/${repo}/issues/${issue.number}`}
|
||||
className={
|
||||
rowClassName
|
||||
}
|
||||
tabIndex={0}
|
||||
>
|
||||
{rowContent}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
ref={setRowRef}
|
||||
onClick={() =>
|
||||
handleIssueClick(
|
||||
issue.number,
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
rowClassName,
|
||||
"w-full text-left cursor-pointer",
|
||||
)}
|
||||
tabIndex={0}
|
||||
>
|
||||
{rowContent}
|
||||
</button>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<InfiniteScrollSentinel
|
||||
sentinelRef={sentinelRef}
|
||||
hasMore={hasMore}
|
||||
loadMore={loadMore}
|
||||
remaining={filtered.length - visible.length}
|
||||
/>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
{!activeQuery?.isFetching && filtered.length === 0 && (
|
||||
<div className="py-16 text-center">
|
||||
<CircleDot className="w-6 h-6 text-muted-foreground/30 mx-auto mb-3" />
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
@@ -1224,6 +1577,21 @@ export function IssuesList({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<IssueDetailSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
owner={owner}
|
||||
repo={repo}
|
||||
issueNumber={selectedIssueNumber}
|
||||
detail={issueDetail}
|
||||
isLoading={isLoadingDetail}
|
||||
sheetWidth={sheetWidth}
|
||||
isResizing={isResizing}
|
||||
onResize={handleSheetResize}
|
||||
onResizeEnd={handleResizeEnd}
|
||||
onResetWidth={resetSheetWidth}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ export function RepoLayoutWrapper({
|
||||
|
||||
{/* Main content */}
|
||||
<div
|
||||
className="flex-1 min-w-0 flex flex-col min-h-0"
|
||||
className="flex-1 min-w-0 flex flex-col min-h-0 px-4"
|
||||
style={
|
||||
{
|
||||
"--repo-pr": collapsed
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useState, useEffect } from "react";
|
||||
import {
|
||||
LogOut,
|
||||
Trash2,
|
||||
Github,
|
||||
Shield,
|
||||
ExternalLink,
|
||||
MapPin,
|
||||
@@ -18,6 +17,7 @@ import { SCOPE_GROUPS, scopesToGroupIds } from "@/lib/github-scopes";
|
||||
import type { UserSettings } from "@/lib/user-settings-store";
|
||||
import type { GitHubProfile } from "../settings-dialog";
|
||||
import { PermissionBadge } from "@/components/shared/permission-badge";
|
||||
import { GithubIcon } from "@/components/shared/icons/github-icon";
|
||||
|
||||
interface AccountTabProps {
|
||||
user: {
|
||||
@@ -137,7 +137,7 @@ export function AccountTab({ user, settings, onUpdate, githubProfile }: AccountT
|
||||
/>
|
||||
) : (
|
||||
<div className="w-[72px] h-[72px] rounded-full bg-muted flex items-center justify-center">
|
||||
<Github className="w-6 h-6 text-muted-foreground" />
|
||||
<GithubIcon className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -218,7 +218,7 @@ export function AccountTab({ user, settings, onUpdate, githubProfile }: AccountT
|
||||
{/* GitHub */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Github className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<GithubIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-mono">
|
||||
GitHub
|
||||
</span>
|
||||
|
||||
+124
-93
@@ -4094,9 +4094,15 @@ export async function getRepoIssues(
|
||||
|
||||
// --- Combined issues page data via single GraphQL call ---
|
||||
|
||||
export interface IssuesPageResult {
|
||||
issues: RepoIssueNode[];
|
||||
pageInfo: { hasNextPage: boolean; endCursor: string | null };
|
||||
counts: { open: number; closed: number };
|
||||
}
|
||||
|
||||
export interface RepoIssuesPageData {
|
||||
openIssues: RepoIssueNode[];
|
||||
closedIssues: RepoIssueNode[];
|
||||
openPageInfo: { hasNextPage: boolean; endCursor: string | null };
|
||||
openCount: number;
|
||||
closedCount: number;
|
||||
}
|
||||
@@ -4912,71 +4918,41 @@ export async function invalidateRepoDiscussionsCache(owner: string, repo: string
|
||||
await deleteGithubCacheByPrefix(authCtx.userId, prefix);
|
||||
}
|
||||
|
||||
const ISSUES_PAGE_GRAPHQL = `
|
||||
query($owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
openIssues: issues(states: [OPEN], first: 50, orderBy: {field: CREATED_AT, direction: DESC}) {
|
||||
totalCount
|
||||
nodes {
|
||||
databaseId
|
||||
number
|
||||
title
|
||||
state
|
||||
stateReason
|
||||
updatedAt
|
||||
createdAt
|
||||
closedAt
|
||||
author { login avatarUrl }
|
||||
labels(first: 20) { nodes { name color } }
|
||||
assignees(first: 10) { nodes { login avatarUrl } }
|
||||
milestone { title }
|
||||
comments { totalCount }
|
||||
reactions { totalCount }
|
||||
thumbsUp: reactions(content: THUMBS_UP) { totalCount }
|
||||
timelineItems(first: 10, itemTypes: [CROSS_REFERENCED_EVENT]) {
|
||||
nodes {
|
||||
... on CrossReferencedEvent {
|
||||
source {
|
||||
... on PullRequest { number url }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
closedIssues: issues(states: [CLOSED], first: 50, orderBy: {field: CREATED_AT, direction: DESC}) {
|
||||
totalCount
|
||||
nodes {
|
||||
databaseId
|
||||
number
|
||||
title
|
||||
state
|
||||
stateReason
|
||||
updatedAt
|
||||
createdAt
|
||||
closedAt
|
||||
author { login avatarUrl }
|
||||
labels(first: 20) { nodes { name color } }
|
||||
assignees(first: 10) { nodes { login avatarUrl } }
|
||||
milestone { title }
|
||||
comments { totalCount }
|
||||
reactions { totalCount }
|
||||
thumbsUp: reactions(content: THUMBS_UP) { totalCount }
|
||||
timelineItems(first: 10, itemTypes: [CROSS_REFERENCED_EVENT]) {
|
||||
nodes {
|
||||
... on CrossReferencedEvent {
|
||||
source {
|
||||
... on PullRequest { number url }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const ISSUE_NODE_FRAGMENT = `
|
||||
databaseId
|
||||
number
|
||||
title
|
||||
state
|
||||
stateReason
|
||||
updatedAt
|
||||
createdAt
|
||||
closedAt
|
||||
author { login avatarUrl }
|
||||
labels(first: 20) { nodes { name color } }
|
||||
assignees(first: 10) { nodes { login avatarUrl } }
|
||||
milestone { title }
|
||||
comments { totalCount }
|
||||
reactions { totalCount }
|
||||
thumbsUp: reactions(content: THUMBS_UP) { totalCount }
|
||||
timelineItems(first: 10, itemTypes: [CROSS_REFERENCED_EVENT]) {
|
||||
nodes {
|
||||
... on CrossReferencedEvent {
|
||||
source {
|
||||
... on PullRequest { number url }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const EMPTY_ISSUES_COUNTS = { open: 0, closed: 0 };
|
||||
|
||||
const EMPTY_ISSUES_PAGE_RESULT: IssuesPageResult = {
|
||||
issues: [],
|
||||
pageInfo: { hasNextPage: false, endCursor: null },
|
||||
counts: EMPTY_ISSUES_COUNTS,
|
||||
};
|
||||
|
||||
// ── GraphQL Issue node types ──
|
||||
|
||||
interface GQLIssueNode {
|
||||
@@ -5041,38 +5017,78 @@ function mapGraphQLIssueNode(node: GQLIssueNode): RepoIssueNode {
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchRepoIssuesPageGraphQL(
|
||||
token: string,
|
||||
export async function getRepoIssuesWithStats(
|
||||
owner: string,
|
||||
repo: string,
|
||||
): Promise<RepoIssuesPageData> {
|
||||
const response = await fetch("https://api.github.com/graphql", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: ISSUES_PAGE_GRAPHQL,
|
||||
variables: { owner, repo },
|
||||
}),
|
||||
});
|
||||
state: "open" | "closed" = "open",
|
||||
opts?: {
|
||||
includeCounts?: boolean;
|
||||
perPage?: number;
|
||||
cursor?: string | null;
|
||||
},
|
||||
): Promise<IssuesPageResult> {
|
||||
const token = await getGitHubToken();
|
||||
if (!token) return EMPTY_ISSUES_PAGE_RESULT;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GraphQL request failed: ${response.status}`);
|
||||
}
|
||||
const json = await response.json();
|
||||
const r = json.data?.repository;
|
||||
if (!r) {
|
||||
return { openIssues: [], closedIssues: [], openCount: 0, closedCount: 0 };
|
||||
}
|
||||
const statesArg = state === "closed" ? "[CLOSED]" : "[OPEN]";
|
||||
const limit = opts?.perPage ?? 30;
|
||||
const wantCounts = !!opts?.includeCounts;
|
||||
|
||||
return {
|
||||
openIssues: (r.openIssues?.nodes ?? []).map(mapGraphQLIssueNode),
|
||||
closedIssues: (r.closedIssues?.nodes ?? []).map(mapGraphQLIssueNode),
|
||||
openCount: r.openIssues?.totalCount ?? 0,
|
||||
closedCount: r.closedIssues?.totalCount ?? 0,
|
||||
};
|
||||
const countFields = wantCounts
|
||||
? `
|
||||
openCount: issues(states: [OPEN]) { totalCount }
|
||||
closedCount: issues(states: [CLOSED]) { totalCount }
|
||||
`
|
||||
: "";
|
||||
|
||||
const afterArg = opts?.cursor ? `, after: "${opts.cursor}"` : "";
|
||||
|
||||
const query = `query($owner: String!, $name: String!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
${countFields}
|
||||
issues(first: ${limit}, states: ${statesArg}, orderBy: { field: CREATED_AT, direction: DESC }${afterArg}) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes { ${ISSUE_NODE_FRAGMENT} }
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
try {
|
||||
const response = await fetch("https://api.github.com/graphql", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ query, variables: { owner, name: repo } }),
|
||||
});
|
||||
|
||||
if (!response.ok) return EMPTY_ISSUES_PAGE_RESULT;
|
||||
const json = await response.json();
|
||||
const repo_data = json.data?.repository;
|
||||
const issueConnection = repo_data?.issues;
|
||||
const nodes = issueConnection?.nodes;
|
||||
if (!nodes) return EMPTY_ISSUES_PAGE_RESULT;
|
||||
|
||||
const counts = wantCounts
|
||||
? {
|
||||
open: repo_data.openCount?.totalCount ?? 0,
|
||||
closed: repo_data.closedCount?.totalCount ?? 0,
|
||||
}
|
||||
: EMPTY_ISSUES_COUNTS;
|
||||
|
||||
const pageInfo = {
|
||||
hasNextPage: issueConnection.pageInfo?.hasNextPage ?? false,
|
||||
endCursor: issueConnection.pageInfo?.endCursor ?? null,
|
||||
};
|
||||
|
||||
const issues = (nodes as GQLIssueNode[]).map(mapGraphQLIssueNode);
|
||||
|
||||
return { issues, pageInfo, counts };
|
||||
} catch (error) {
|
||||
rethrowKnownGitHubErrors(error);
|
||||
return EMPTY_ISSUES_PAGE_RESULT;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRepoIssuesPageCacheKey(owner: string, repo: string): string {
|
||||
@@ -5083,7 +5099,7 @@ export async function getRepoIssuesPage(owner: string, repo: string): Promise<Re
|
||||
const authCtx = await getGitHubAuthContext();
|
||||
const fallback: RepoIssuesPageData = {
|
||||
openIssues: [],
|
||||
closedIssues: [],
|
||||
openPageInfo: { hasNextPage: false, endCursor: null },
|
||||
openCount: 0,
|
||||
closedCount: 0,
|
||||
};
|
||||
@@ -5096,7 +5112,16 @@ export async function getRepoIssuesPage(owner: string, repo: string): Promise<Re
|
||||
jobPayload: { owner, repo, state: "all" },
|
||||
fetchRemote: async () => {
|
||||
if (!authCtx) return fallback;
|
||||
return fetchRepoIssuesPageGraphQL(authCtx.token, owner, repo);
|
||||
const result = await getRepoIssuesWithStats(owner, repo, "open", {
|
||||
includeCounts: true,
|
||||
perPage: 30,
|
||||
});
|
||||
return {
|
||||
openIssues: result.issues,
|
||||
openPageInfo: result.pageInfo,
|
||||
openCount: result.counts.open,
|
||||
closedCount: result.counts.closed,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5107,7 +5132,11 @@ export async function getRepoIssuesPage(owner: string, repo: string): Promise<Re
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return data;
|
||||
// Old cached entries may lack pagination fields
|
||||
return {
|
||||
...data,
|
||||
openPageInfo: data.openPageInfo ?? { hasNextPage: false, endCursor: null },
|
||||
};
|
||||
}
|
||||
|
||||
export async function invalidateRepoPullRequestsCache(owner: string, repo: string) {
|
||||
@@ -5178,8 +5207,9 @@ export async function invalidateFileContentCache(
|
||||
export async function invalidateRepoIssuesCache(owner: string, repo: string) {
|
||||
const authCtx = await getGitHubAuthContext();
|
||||
if (!authCtx) return;
|
||||
const prefix = `repo_issues:${normalizeRepoKey(owner, repo)}`;
|
||||
await deleteGithubCacheByPrefix(authCtx.userId, prefix);
|
||||
const key = normalizeRepoKey(owner, repo);
|
||||
await deleteGithubCacheByPrefix(authCtx.userId, `repo_issues:${key}`);
|
||||
await deleteGithubCacheByPrefix(authCtx.userId, `repo_issues_page:${key}`);
|
||||
// Also invalidate nav counts so issue count updates immediately
|
||||
const navCountsKey = buildRepoNavCountsCacheKey(owner, repo);
|
||||
await deleteGithubCacheByPrefix(authCtx.userId, navCountsKey);
|
||||
@@ -5193,6 +5223,7 @@ export async function invalidateIssueCache(owner: string, repo: string, issueNum
|
||||
await deleteGithubCacheByPrefix(authCtx.userId, `issue:${key}:${issueNumber}`);
|
||||
await deleteGithubCacheByPrefix(authCtx.userId, `issue_comments:${key}:${issueNumber}`);
|
||||
await deleteGithubCacheByPrefix(authCtx.userId, `repo_issues:${key}`);
|
||||
await deleteGithubCacheByPrefix(authCtx.userId, `repo_issues_page:${key}`);
|
||||
// Also invalidate shared cache so other users see fresh data
|
||||
await deleteSharedCacheByPrefix(`issue:${key}:${issueNumber}`);
|
||||
await deleteSharedCacheByPrefix(`issue_comments:${key}:${issueNumber}`);
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"@sentry/nextjs": "^10",
|
||||
"@tanstack/react-hotkeys": "^0.3.0",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-virtual": "^3.13.26",
|
||||
"@tiptap/extension-link": "^3.19.0",
|
||||
"@tiptap/extension-mention": "^3.19.0",
|
||||
"@tiptap/extension-placeholder": "^3.19.0",
|
||||
@@ -1294,8 +1295,12 @@
|
||||
|
||||
"@tanstack/react-store": ["@tanstack/react-store@0.9.1", "", { "dependencies": { "@tanstack/store": "0.9.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA=="],
|
||||
|
||||
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.26", "", { "dependencies": { "@tanstack/virtual-core": "3.16.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-DosdgjOxCLahkn0o+ilmZYwEjo1glfMGuRT/j3PQ18yr5XqA8N/BCaL9IJ3B5TRl+nnzyK2IOFgAILwzN3a9xQ=="],
|
||||
|
||||
"@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="],
|
||||
|
||||
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.16.0", "", {}, "sha512-Er2N7q3WOiH6y2JLxsxNX+u2/sLqSsL0bxFgDjuiPiA7vKhZRm+IzcS17vRee3GNXr64UsesA5CAp9yTiIYw9A=="],
|
||||
|
||||
"@tiptap/core": ["@tiptap/core@3.20.0", "", { "peerDependencies": { "@tiptap/pm": "^3.20.0" } }, "sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ=="],
|
||||
|
||||
"@tiptap/extension-blockquote": ["@tiptap/extension-blockquote@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-LQzn6aGtL4WXz2+rYshl/7/VnP2qJTpD7fWL96GXAzhqviPEY1bJES7poqJb3MU/gzl8VJUVzVzU1VoVfUKlbA=="],
|
||||
|
||||
Reference in New Issue
Block a user