feat: improve issue list (#351)

This commit is contained in:
Maxwell
2026-05-31 20:58:09 +10:00
committed by GitHub
parent 033564d0e6
commit 8c69574243
12 changed files with 1293 additions and 352 deletions
+1
View File
@@ -36,6 +36,7 @@
"@sentry/nextjs": "^10", "@sentry/nextjs": "^10",
"@tanstack/react-hotkeys": "^0.3.0", "@tanstack/react-hotkeys": "^0.3.0",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@tanstack/react-virtual": "^3.13.26",
"@tiptap/extension-link": "^3.19.0", "@tiptap/extension-link": "^3.19.0",
"@tiptap/extension-mention": "^3.19.0", "@tiptap/extension-mention": "^3.19.0",
"@tiptap/extension-placeholder": "^3.19.0", "@tiptap/extension-placeholder": "^3.19.0",
+2 -2
View File
@@ -5,7 +5,6 @@ import { useRouter } from "next/navigation";
import { import {
Gauge, Gauge,
RefreshCw, RefreshCw,
Github,
Clock, Clock,
Zap, Zap,
ShieldAlert, ShieldAlert,
@@ -18,6 +17,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { signOut } from "@/lib/auth-client"; import { signOut } from "@/lib/auth-client";
import { GithubIcon } from "@/components/shared/icons/github-icon";
function parseRateLimitFromDigest(message: string) { function parseRateLimitFromDigest(message: string) {
// The error message is serialized by Next.js, try to detect rate limit // The error message is serialized by Next.js, try to detect rate limit
@@ -250,7 +250,7 @@ function RateLimitUI({ reset }: { reset: () => void }) {
{/* Info card */} {/* Info card */}
<div className="border border-border/40 rounded-lg p-4 space-y-3"> <div className="border border-border/40 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-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-[11px] font-mono text-muted-foreground/60"> <span className="text-[11px] font-mono text-muted-foreground/60">
GitHub API &middot;{" "} GitHub API &middot;{" "}
{rateLimitInfo {rateLimitInfo
@@ -1,6 +1,12 @@
"use server"; "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 { getErrorMessage } from "@/lib/utils";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { invalidateRepoCache } from "@/lib/repo-data-cache-vc"; 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 { export interface IssueTemplate {
name: string; name: string;
about: string; about: string;
@@ -1,10 +1,240 @@
"use server"; "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 { renderMarkdownToHtml } from "@/components/shared/markdown-renderer";
import { getErrorMessage } from "@/lib/utils"; import { getErrorMessage } from "@/lib/utils";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { invalidateRepoCache } from "@/lib/repo-data-cache-vc"; 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) { export async function fetchIssueComments(owner: string, repo: string, issueNumber: number) {
const comments = await getIssueComments(owner, repo, issueNumber); const comments = await getIssueComments(owner, repo, issueNumber);
@@ -1,7 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { getRepoIssuesPage } from "@/lib/github"; import { getRepoIssuesPage } from "@/lib/github";
import { IssuesList } from "@/components/issue/issues-list"; import { IssuesList } from "@/components/issue/issues-list";
import { fetchIssuesByAuthor } from "./actions"; import { fetchIssuesByAuthor, fetchIssuePage } from "./actions";
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -19,7 +19,7 @@ export default async function IssuesListPage({
}) { }) {
const { owner, repo } = await params; const { owner, repo } = await params;
const { openIssues, closedIssues, openCount, closedCount } = await getRepoIssuesPage( const { openIssues, openPageInfo, openCount, closedCount } = await getRepoIssuesPage(
owner, owner,
repo, repo,
); );
@@ -28,8 +28,8 @@ export default async function IssuesListPage({
<IssuesList <IssuesList
owner={owner} owner={owner}
repo={repo} repo={repo}
openIssues={openIssues} initialOpenIssues={openIssues}
closedIssues={closedIssues} initialOpenPageInfo={openPageInfo}
openCount={openCount} openCount={openCount}
closedCount={closedCount} closedCount={closedCount}
onAuthorFilter={ onAuthorFilter={
@@ -37,6 +37,7 @@ export default async function IssuesListPage({
typeof IssuesList typeof IssuesList
>[0]["onAuthorFilter"] >[0]["onAuthorFilter"]
} }
onFetchIssuePage={fetchIssuePage}
/> />
); );
} }
@@ -8,6 +8,9 @@ interface IssueDetailLayoutProps {
timeline: React.ReactNode; timeline: React.ReactNode;
commentForm?: React.ReactNode; commentForm?: React.ReactNode;
sidebar?: React.ReactNode; sidebar?: React.ReactNode;
contentGapClassName?: string;
mainScrollClassName?: string;
sidebarClassName?: string;
} }
export function IssueDetailLayout({ export function IssueDetailLayout({
@@ -15,6 +18,9 @@ export function IssueDetailLayout({
timeline, timeline,
commentForm, commentForm,
sidebar, sidebar,
contentGapClassName = "gap-6",
mainScrollClassName = "pr-4",
sidebarClassName = "w-[240px] xl:w-[280px] 2xl:w-[320px] pl-6",
}: IssueDetailLayoutProps) { }: IssueDetailLayoutProps) {
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollUp, setCanScrollUp] = useState(false); const [canScrollUp, setCanScrollUp] = useState(false);
@@ -44,7 +50,7 @@ export function IssueDetailLayout({
<div className="flex-1 min-h-0 flex flex-col"> <div className="flex-1 min-h-0 flex flex-col">
<div className="shrink-0 pt-3">{header}</div> <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 */} {/* Main thread */}
<div className="relative flex-1 min-w-0"> <div className="relative flex-1 min-w-0">
{/* Top shadow */} {/* Top shadow */}
@@ -63,7 +69,10 @@ export function IssueDetailLayout({
/> />
<div <div
ref={scrollRef} 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> <div>
{/* Mobile sidebar */} {/* Mobile sidebar */}
@@ -86,7 +95,12 @@ export function IssueDetailLayout({
{/* Right sidebar */} {/* Right sidebar */}
{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 className="space-y-5 pt-1">{sidebar}</div>
</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>
);
}
+612 -244
View File
@@ -15,6 +15,7 @@ import {
SlidersHorizontal, SlidersHorizontal,
Search, Search,
CircleSlash, CircleSlash,
Loader2,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { TimeAgo } from "@/components/ui/time-ago"; 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 { useClickOutside } from "@/hooks/use-click-outside";
import { useMutationSubscription } from "@/hooks/use-mutation-subscription"; import { useMutationSubscription } from "@/hooks/use-mutation-subscription";
import { isRepoEvent, type MutationEvent } from "@/lib/mutation-events"; import { isRepoEvent, type MutationEvent } from "@/lib/mutation-events";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; import { LoadingOverlay } from "@/components/shared/list-controls";
import { InfiniteScrollSentinel, LoadingOverlay } from "@/components/shared/list-controls";
import { LabelBadge } from "@/components/shared/label-badge"; import { LabelBadge } from "@/components/shared/label-badge";
import { useGlobalChat } from "@/components/shared/global-chat-provider"; import { useGlobalChat } from "@/components/shared/global-chat-provider";
import { useHotkey } from "@tanstack/react-hotkeys"; 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 { Zap, GitPullRequest } from "lucide-react";
import { UserTooltip } from "@/components/shared/user-tooltip"; 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 { interface IssueUser {
login: string; login: string;
@@ -69,19 +85,27 @@ const sortLabels: Record<SortType, string> = {
const sortCycle: SortType[] = ["updated", "newest", "oldest", "comments", "reactions"]; 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({ export function IssuesList({
owner, owner,
repo, repo,
openIssues, initialOpenIssues,
closedIssues, initialOpenPageInfo,
openCount, openCount,
closedCount, closedCount,
onAuthorFilter, onAuthorFilter,
onFetchIssuePage,
}: { }: {
owner: string; owner: string;
repo: string; repo: string;
openIssues: Issue[]; initialOpenIssues: Issue[];
closedIssues: Issue[]; initialOpenPageInfo: IssuesPageResult["pageInfo"];
openCount: number; openCount: number;
closedCount: number; closedCount: number;
onAuthorFilter?: ( onAuthorFilter?: (
@@ -89,6 +113,7 @@ export function IssuesList({
repo: string, repo: string,
author: string, author: string,
) => Promise<{ open: Issue[]; closed: Issue[] }>; ) => Promise<{ open: Issue[]; closed: Issue[] }>;
onFetchIssuePage?: FetchIssuePageFn;
}) { }) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const tabParam = searchParams.get("tab"); const tabParam = searchParams.get("tab");
@@ -116,10 +141,201 @@ export function IssuesList({
const [countAdjustments, setCountAdjustments] = useState({ open: 0, closed: 0 }); const [countAdjustments, setCountAdjustments] = useState({ open: 0, closed: 0 });
const filtersTriggerRef = useRef<HTMLButtonElement>(null); 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(() => { useEffect(() => {
setCountAdjustments({ open: 0, closed: 0 }); setCountAdjustments({ open: 0, closed: 0 });
}, [openIssues, closedIssues]); }, [openIssues, closedAllIssues]);
useMutationSubscription( useMutationSubscription(
["issue:closed", "issue:reopened", "issue:created"], ["issue:closed", "issue:reopened", "issue:created"],
@@ -149,8 +365,8 @@ export function IssuesList({
); );
const allIssues = useMemo( const allIssues = useMemo(
() => [...openIssues, ...closedIssues], () => [...openIssues, ...closedAllIssues],
[openIssues, closedIssues], [openIssues, closedAllIssues],
); );
const authors = useMemo(() => { const authors = useMemo(() => {
@@ -225,7 +441,11 @@ export function IssuesList({
}; };
const currentOpenIssues = authorIssues ? authorIssues.open : openIssues; const currentOpenIssues = authorIssues ? authorIssues.open : openIssues;
const currentClosedIssues = authorIssues ? authorIssues.closed : closedIssues; const currentClosedIssues = authorIssues
? authorIssues.closed
: closedIssuesLoaded
? closedAllIssues
: [];
const closedCompleted = useMemo( const closedCompleted = useMemo(
() => currentClosedIssues.filter((i) => i.state_reason !== "not_planned"), () => currentClosedIssues.filter((i) => i.state_reason !== "not_planned"),
@@ -342,20 +562,44 @@ export function IssuesList({
authorIssues, authorIssues,
]); ]);
const { visible, hasMore, loadMore, sentinelRef } = useInfiniteScroll(filtered, [ const activeQuery = authorIssues ? null : state === "open" ? openQuery : closedQuery;
state, const canFetchMore =
search, !!activeQuery && activeQuery.hasNextPage && !activeQuery.isFetchingNextPage;
sort, const isFetchingMore = activeQuery?.isFetchingNextPage ?? false;
selectedAuthor, const isFetchingInitialPage =
selectedLabel, !!activeQuery && activeQuery.isFetching && !activeQuery.isFetchingNextPage;
assigneeFilter, const hasReachedEnd =
activityFilter, filtered.length > 0 &&
selectedMilestone, (authorIssues
]); ? true
: !!activeQuery && !activeQuery.hasNextPage && !activeQuery.isFetching);
const showListFooter =
filtered.length > 0 && (isFetchingMore || isFetchingInitialPage || hasReachedEnd);
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
const issueLinksRef = useRef<(HTMLAnchorElement | null)[]>([]); const issueLinksRef = useRef<(HTMLElement | null)[]>([]);
const listContainerRef = useRef<HTMLDivElement>(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) // Focus search bar when issues tab is shown (keyboard-first UX)
useEffect(() => { useEffect(() => {
@@ -368,7 +612,7 @@ export function IssuesList({
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
if (!listContainerRef.current?.contains(document.activeElement)) return; if (!listContainerRef.current?.contains(document.activeElement)) return;
if (document.activeElement === searchInputRef.current) { if (document.activeElement === searchInputRef.current) {
if (visible.length > 0) { if (filtered.length > 0) {
e.preventDefault(); e.preventDefault();
issueLinksRef.current[0]?.focus(); issueLinksRef.current[0]?.focus();
} }
@@ -376,7 +620,7 @@ export function IssuesList({
const idx = issueLinksRef.current.findIndex( const idx = issueLinksRef.current.findIndex(
(el) => el === document.activeElement, (el) => el === document.activeElement,
); );
if (idx >= 0 && idx < visible.length - 1) { if (idx >= 0 && idx < filtered.length - 1) {
e.preventDefault(); e.preventDefault();
issueLinksRef.current[idx + 1]?.focus(); issueLinksRef.current[idx + 1]?.focus();
} }
@@ -933,48 +1177,31 @@ export function IssuesList({
key: "open" as TabState, key: "open" as TabState,
label: "Open", label: "Open",
icon: <CircleDot className="w-3 h-3" />, icon: <CircleDot className="w-3 h-3" />,
count: count: authorIssues
currentOpenIssues.length + ? currentOpenIssues.length
countAdjustments.open, : openCount + countAdjustments.open,
}, },
{ {
key: "closed" as TabState, key: "closed" as TabState,
label: "Closed", label: "Closed",
icon: <CheckCircle2 className="w-3 h-3" />, icon: <CheckCircle2 className="w-3 h-3" />,
count: count: authorIssues
closedCompleted.length + ? closedCompleted.length
countAdjustments.closed, : closedCount +
countAdjustments.closed,
}, },
{ {
key: "not_planned" as TabState, key: "not_planned" as TabState,
label: "Not Planned", label: "Not Planned",
icon: <CircleSlash className="w-3 h-3" />, icon: <CircleSlash className="w-3 h-3" />,
count: closedNotPlanned.length, count: authorIssues
? closedNotPlanned.length
: closedNotPlanned.length,
}, },
].map((tab) => ( ].map((tab) => (
<button <button
key={tab.key} key={tab.key}
onClick={() => { onClick={() => handleTabChange(tab.key)}
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(),
);
}}
className={cn( className={cn(
"relative flex items-center gap-1.5 px-3 pb-2.5 pt-1 text-[12px] transition-colors cursor-pointer", "relative flex items-center gap-1.5 px-3 pb-2.5 pt-1 text-[12px] transition-colors cursor-pointer",
state === tab.key state === tab.key
@@ -1005,215 +1232,341 @@ export function IssuesList({
</div> </div>
{/* Issue List */} {/* 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} /> <LoadingOverlay show={isPending} />
{visible.map((issue, index) => { <div
const reactionCount = issue.reactions?.["+1"] ?? 0; 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 ( const index = virtualItem.index;
<Link const issue = filtered[index];
key={issue.id} if (!issue) return null;
ref={(el) => { const reactionCount = issue.reactions?.["+1"] ?? 0;
issueLinksRef.current[index] = el; 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";
href={`/${owner}/${repo}/issues/${issue.number}`} const setRowRef = (el: HTMLElement | null) => {
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" issueLinksRef.current[index] = el;
tabIndex={0} };
>
{issue.state === "open" ? ( const rowContent = (
<CircleDot className="w-3.5 h-3.5 shrink-0 mt-0.5 text-success" /> <>
) : ( {issue.state === "open" ? (
<CheckCircle2 className="w-3.5 h-3.5 shrink-0 mt-0.5 text-alert-important" /> <CircleDot className="w-3.5 h-3.5 shrink-0 mt-0.5 text-success" />
)} ) : (
<div className="flex-1 min-w-0"> <CheckCircle2 className="w-3.5 h-3.5 shrink-0 mt-0.5 text-alert-important" />
{/* Row 1: Title + Milestone badge + Labels + Assignee avatars */} )}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex-1 min-w-0">
<span className="text-sm truncate group-hover:text-foreground transition-colors"> {/* Row 1: Title + Milestone badge + Labels + Assignee avatars */}
{issue.title} <div className="flex items-center gap-2 flex-wrap">
</span> <span className="text-sm truncate group-hover:text-foreground transition-colors">
{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 issue.title
.milestone
.title
} }
</span> </span>
)} {issue.milestone && (
{issue.labels <span className="text-[9px] font-mono px-1.5 py-0.5 border border-border/60 text-muted-foreground/70 shrink-0">
.map((l) => {
typeof l === issue
"string" .milestone
? { .title
name: l,
color: "888",
}
: l,
)
.filter(
(l) =>
l.name,
)
.slice(0, 3)
.map((label) => (
<LabelBadge
key={
label.name
} }
label={ </span>
label )}
{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> </span>
)} </div>
</div>
{/* Row 2: Author avatar + login + opened X ago */} {/* Row 3: #number + updated X ago + comments + reactions */}
<div className="flex items-center gap-3 mt-1"> <div className="flex items-center gap-3 mt-1">
{issue.user && ( <span className="text-[11px] font-mono text-muted-foreground/70">
<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" />
{ {
reactionCount issue.number
} }
</span> </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> </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> </div>
</Link> );
); })}
})} </div>
<InfiniteScrollSentinel {!activeQuery?.isFetching && filtered.length === 0 && (
sentinelRef={sentinelRef}
hasMore={hasMore}
loadMore={loadMore}
remaining={filtered.length - visible.length}
/>
{filtered.length === 0 && (
<div className="py-16 text-center"> <div className="py-16 text-center">
<CircleDot className="w-6 h-6 text-muted-foreground/30 mx-auto mb-3" /> <CircleDot className="w-6 h-6 text-muted-foreground/30 mx-auto mb-3" />
<p className="text-xs text-muted-foreground font-mono"> <p className="text-xs text-muted-foreground font-mono">
@@ -1224,6 +1577,21 @@ export function IssuesList({
</div> </div>
)} )}
</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> </div>
); );
} }
@@ -223,7 +223,7 @@ export function RepoLayoutWrapper({
{/* Main content */} {/* Main content */}
<div <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={ style={
{ {
"--repo-pr": collapsed "--repo-pr": collapsed
@@ -4,7 +4,6 @@ import { useState, useEffect } from "react";
import { import {
LogOut, LogOut,
Trash2, Trash2,
Github,
Shield, Shield,
ExternalLink, ExternalLink,
MapPin, MapPin,
@@ -18,6 +17,7 @@ import { SCOPE_GROUPS, scopesToGroupIds } from "@/lib/github-scopes";
import type { UserSettings } from "@/lib/user-settings-store"; import type { UserSettings } from "@/lib/user-settings-store";
import type { GitHubProfile } from "../settings-dialog"; import type { GitHubProfile } from "../settings-dialog";
import { PermissionBadge } from "@/components/shared/permission-badge"; import { PermissionBadge } from "@/components/shared/permission-badge";
import { GithubIcon } from "@/components/shared/icons/github-icon";
interface AccountTabProps { interface AccountTabProps {
user: { 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"> <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>
)} )}
</div> </div>
@@ -218,7 +218,7 @@ export function AccountTab({ user, settings, onUpdate, githubProfile }: AccountT
{/* GitHub */} {/* GitHub */}
<div className="flex items-center justify-between py-2"> <div className="flex items-center justify-between py-2">
<div className="flex items-center gap-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"> <span className="text-xs font-mono">
GitHub GitHub
</span> </span>
+124 -93
View File
@@ -4094,9 +4094,15 @@ export async function getRepoIssues(
// --- Combined issues page data via single GraphQL call --- // --- 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 { export interface RepoIssuesPageData {
openIssues: RepoIssueNode[]; openIssues: RepoIssueNode[];
closedIssues: RepoIssueNode[]; openPageInfo: { hasNextPage: boolean; endCursor: string | null };
openCount: number; openCount: number;
closedCount: number; closedCount: number;
} }
@@ -4912,71 +4918,41 @@ export async function invalidateRepoDiscussionsCache(owner: string, repo: string
await deleteGithubCacheByPrefix(authCtx.userId, prefix); await deleteGithubCacheByPrefix(authCtx.userId, prefix);
} }
const ISSUES_PAGE_GRAPHQL = ` const ISSUE_NODE_FRAGMENT = `
query($owner: String!, $repo: String!) { databaseId
repository(owner: $owner, name: $repo) { number
openIssues: issues(states: [OPEN], first: 50, orderBy: {field: CREATED_AT, direction: DESC}) { title
totalCount state
nodes { stateReason
databaseId updatedAt
number createdAt
title closedAt
state author { login avatarUrl }
stateReason labels(first: 20) { nodes { name color } }
updatedAt assignees(first: 10) { nodes { login avatarUrl } }
createdAt milestone { title }
closedAt comments { totalCount }
author { login avatarUrl } reactions { totalCount }
labels(first: 20) { nodes { name color } } thumbsUp: reactions(content: THUMBS_UP) { totalCount }
assignees(first: 10) { nodes { login avatarUrl } } timelineItems(first: 10, itemTypes: [CROSS_REFERENCED_EVENT]) {
milestone { title } nodes {
comments { totalCount } ... on CrossReferencedEvent {
reactions { totalCount } source {
thumbsUp: reactions(content: THUMBS_UP) { totalCount } ... on PullRequest { number url }
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 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 ── // ── GraphQL Issue node types ──
interface GQLIssueNode { interface GQLIssueNode {
@@ -5041,38 +5017,78 @@ function mapGraphQLIssueNode(node: GQLIssueNode): RepoIssueNode {
}; };
} }
async function fetchRepoIssuesPageGraphQL( export async function getRepoIssuesWithStats(
token: string,
owner: string, owner: string,
repo: string, repo: string,
): Promise<RepoIssuesPageData> { state: "open" | "closed" = "open",
const response = await fetch("https://api.github.com/graphql", { opts?: {
method: "POST", includeCounts?: boolean;
headers: { perPage?: number;
Authorization: `Bearer ${token}`, cursor?: string | null;
"Content-Type": "application/json", },
}, ): Promise<IssuesPageResult> {
body: JSON.stringify({ const token = await getGitHubToken();
query: ISSUES_PAGE_GRAPHQL, if (!token) return EMPTY_ISSUES_PAGE_RESULT;
variables: { owner, repo },
}),
});
if (!response.ok) { const statesArg = state === "closed" ? "[CLOSED]" : "[OPEN]";
throw new Error(`GraphQL request failed: ${response.status}`); const limit = opts?.perPage ?? 30;
} const wantCounts = !!opts?.includeCounts;
const json = await response.json();
const r = json.data?.repository;
if (!r) {
return { openIssues: [], closedIssues: [], openCount: 0, closedCount: 0 };
}
return { const countFields = wantCounts
openIssues: (r.openIssues?.nodes ?? []).map(mapGraphQLIssueNode), ? `
closedIssues: (r.closedIssues?.nodes ?? []).map(mapGraphQLIssueNode), openCount: issues(states: [OPEN]) { totalCount }
openCount: r.openIssues?.totalCount ?? 0, closedCount: issues(states: [CLOSED]) { totalCount }
closedCount: r.closedIssues?.totalCount ?? 0, `
}; : "";
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 { 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 authCtx = await getGitHubAuthContext();
const fallback: RepoIssuesPageData = { const fallback: RepoIssuesPageData = {
openIssues: [], openIssues: [],
closedIssues: [], openPageInfo: { hasNextPage: false, endCursor: null },
openCount: 0, openCount: 0,
closedCount: 0, closedCount: 0,
}; };
@@ -5096,7 +5112,16 @@ export async function getRepoIssuesPage(owner: string, repo: string): Promise<Re
jobPayload: { owner, repo, state: "all" }, jobPayload: { owner, repo, state: "all" },
fetchRemote: async () => { fetchRemote: async () => {
if (!authCtx) return fallback; 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(() => {}); }).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) { export async function invalidateRepoPullRequestsCache(owner: string, repo: string) {
@@ -5178,8 +5207,9 @@ export async function invalidateFileContentCache(
export async function invalidateRepoIssuesCache(owner: string, repo: string) { export async function invalidateRepoIssuesCache(owner: string, repo: string) {
const authCtx = await getGitHubAuthContext(); const authCtx = await getGitHubAuthContext();
if (!authCtx) return; if (!authCtx) return;
const prefix = `repo_issues:${normalizeRepoKey(owner, repo)}`; const key = normalizeRepoKey(owner, repo);
await deleteGithubCacheByPrefix(authCtx.userId, prefix); await deleteGithubCacheByPrefix(authCtx.userId, `repo_issues:${key}`);
await deleteGithubCacheByPrefix(authCtx.userId, `repo_issues_page:${key}`);
// Also invalidate nav counts so issue count updates immediately // Also invalidate nav counts so issue count updates immediately
const navCountsKey = buildRepoNavCountsCacheKey(owner, repo); const navCountsKey = buildRepoNavCountsCacheKey(owner, repo);
await deleteGithubCacheByPrefix(authCtx.userId, navCountsKey); 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:${key}:${issueNumber}`);
await deleteGithubCacheByPrefix(authCtx.userId, `issue_comments:${key}:${issueNumber}`); await deleteGithubCacheByPrefix(authCtx.userId, `issue_comments:${key}:${issueNumber}`);
await deleteGithubCacheByPrefix(authCtx.userId, `repo_issues:${key}`); 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 // Also invalidate shared cache so other users see fresh data
await deleteSharedCacheByPrefix(`issue:${key}:${issueNumber}`); await deleteSharedCacheByPrefix(`issue:${key}:${issueNumber}`);
await deleteSharedCacheByPrefix(`issue_comments:${key}:${issueNumber}`); await deleteSharedCacheByPrefix(`issue_comments:${key}:${issueNumber}`);
+5
View File
@@ -40,6 +40,7 @@
"@sentry/nextjs": "^10", "@sentry/nextjs": "^10",
"@tanstack/react-hotkeys": "^0.3.0", "@tanstack/react-hotkeys": "^0.3.0",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@tanstack/react-virtual": "^3.13.26",
"@tiptap/extension-link": "^3.19.0", "@tiptap/extension-link": "^3.19.0",
"@tiptap/extension-mention": "^3.19.0", "@tiptap/extension-mention": "^3.19.0",
"@tiptap/extension-placeholder": "^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-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/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/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=="], "@tiptap/extension-blockquote": ["@tiptap/extension-blockquote@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-LQzn6aGtL4WXz2+rYshl/7/VnP2qJTpD7fWL96GXAzhqviPEY1bJES7poqJb3MU/gzl8VJUVzVzU1VoVfUKlbA=="],