diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 700a8e7..db58fe0 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@cloudflare/vite-plugin": "^1.26.0", + "@pierre/diffs": "^1.1.12", "@quickhub/icons": "workspace:*", "@quickhub/ui": "workspace:*", "@tailwindcss/vite": "^4.1.18", diff --git a/apps/dashboard/src/components/layouts/dashboard-topbar.tsx b/apps/dashboard/src/components/layouts/dashboard-topbar.tsx index 6da67d4..ca61fc8 100644 --- a/apps/dashboard/src/components/layouts/dashboard-topbar.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-topbar.tsx @@ -58,6 +58,7 @@ const themeOptions = [ const tabIconMap = { pull: GitPullRequestIcon, issue: IssuesIcon, + review: ReviewsIcon, } as const; const primaryNavRoutes = ["/", "/pulls", "/issues", "/reviews"] as const; @@ -341,7 +342,20 @@ function DetailTab({ > {tab.title} - #{tab.number} + {tab.type === "review" ? ( + + {tab.additions != null && ( + +{tab.additions} + )} + {tab.deletions != null && ( + -{tab.deletions} + )} + + ) : ( + + #{tab.number} + + )} { diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 57142d5..4d4bc12 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -1,6 +1,7 @@ import { createServerFn } from "@tanstack/react-start"; import { type Octokit as OctokitType, RequestError } from "octokit"; import type { + CreateReviewCommentInput, GitHubActor, GitHubLabel, IssueComment, @@ -11,10 +12,13 @@ import type { MyPullsResult, PullComment, PullDetail, + PullFile, PullPageData, + PullReviewComment, PullStatus, PullSummary, RepositoryRef, + SubmitReviewInput, UserRepoSummary, } from "./github.types"; import { @@ -60,6 +64,12 @@ type RepoIssueDetail = Awaited< type AuthenticatedUser = Awaited< ReturnType >["data"]; +type RepoPullFile = Awaited< + ReturnType +>["data"][number]; +type RepoPullReviewComment = Awaited< + ReturnType +>["data"][number]; type RepoState = "all" | "closed" | "open"; type PullSort = "created" | "long-running" | "popularity" | "updated"; @@ -1364,3 +1374,172 @@ export const updatePullBranch = createServerFn({ method: "POST" }) return false; } }); + +async function getPullFilesResult( + context: GitHubContext, + data: PullFromRepoInput, +): Promise { + return getCachedGitHubRequest({ + context, + resource: "pulls.files", + params: data, + freshForMs: githubCachePolicy.detail.staleTimeMs, + request: (headers) => + context.octokit.rest.pulls.listFiles({ + owner: data.owner, + repo: data.repo, + pull_number: data.pullNumber, + per_page: 300, + headers, + }), + mapData: (files) => + files.map((file) => ({ + sha: file.sha, + filename: file.filename, + status: file.status as PullFile["status"], + additions: file.additions, + deletions: file.deletions, + changes: file.changes, + patch: file.patch ?? null, + previousFilename: file.previous_filename ?? null, + })), + }); +} + +export const getPullFiles = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) { + return []; + } + + return getPullFilesResult(context, data); + }); + +async function getPullReviewCommentsResult( + context: GitHubContext, + data: PullFromRepoInput, +): Promise { + return getCachedGitHubRequest({ + context, + resource: "pulls.reviewComments", + params: data, + freshForMs: githubCachePolicy.activity.staleTimeMs, + request: (headers) => + context.octokit.rest.pulls.listReviewComments({ + owner: data.owner, + repo: data.repo, + pull_number: data.pullNumber, + per_page: 100, + headers, + }), + mapData: (comments) => + comments.map((comment) => ({ + id: comment.id, + body: comment.body, + path: comment.path, + line: comment.line ?? null, + side: (comment.side?.toUpperCase() as "LEFT" | "RIGHT") ?? "RIGHT", + createdAt: comment.created_at, + updatedAt: comment.updated_at, + author: comment.user + ? { + login: comment.user.login, + avatarUrl: comment.user.avatar_url, + url: comment.user.html_url, + type: comment.user.type ?? "User", + } + : null, + inReplyToId: comment.in_reply_to_id ?? null, + diffHunk: comment.diff_hunk, + })), + }); +} + +export const getPullReviewComments = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) { + return []; + } + + return getPullReviewCommentsResult(context, data); + }); + +export const submitPullReview = createServerFn({ method: "POST" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) { + return false; + } + + try { + await context.octokit.rest.pulls.createReview({ + owner: data.owner, + repo: data.repo, + pull_number: data.pullNumber, + body: data.body, + event: data.event, + comments: data.comments?.map((c) => ({ + path: c.path, + line: c.line, + side: c.side, + body: c.body, + ...(c.startLine != null && c.startLine !== c.line + ? { start_line: c.startLine, start_side: c.startSide ?? c.side } + : {}), + })), + }); + return true; + } catch { + return false; + } + }); + +export const createReviewComment = createServerFn({ method: "POST" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) { + return null; + } + + try { + const response = await context.octokit.rest.pulls.createReviewComment({ + owner: data.owner, + repo: data.repo, + pull_number: data.pullNumber, + body: data.body, + commit_id: data.commitId, + path: data.path, + line: data.line, + side: data.side, + }); + + const comment = response.data; + return { + id: comment.id, + body: comment.body, + path: comment.path, + line: comment.line ?? null, + side: (comment.side?.toUpperCase() as "LEFT" | "RIGHT") ?? "RIGHT", + createdAt: comment.created_at, + updatedAt: comment.updated_at, + author: comment.user + ? { + login: comment.user.login, + avatarUrl: comment.user.avatar_url, + url: comment.user.html_url, + type: comment.user.type ?? "User", + } + : null, + inReplyToId: comment.in_reply_to_id ?? null, + diffHunk: comment.diff_hunk, + }; + } catch { + return null; + } + }); diff --git a/apps/dashboard/src/lib/github.query.ts b/apps/dashboard/src/lib/github.query.ts index 583db02..8ec4c43 100644 --- a/apps/dashboard/src/lib/github.query.ts +++ b/apps/dashboard/src/lib/github.query.ts @@ -9,8 +9,10 @@ import { getMyIssues, getMyPulls, getPullComments, + getPullFiles, getPullFromRepo, getPullPageData, + getPullReviewComments, getPullStatus, getPullsFromRepo, getPullsFromUser, @@ -117,6 +119,10 @@ export const githubQueryKeys = { ["github", scope.userId, "pulls", "comments", input] as const, status: (scope: GitHubQueryScope, input: PullFromRepoQueryInput) => ["github", scope.userId, "pulls", "status", input] as const, + files: (scope: GitHubQueryScope, input: PullFromRepoQueryInput) => + ["github", scope.userId, "pulls", "files", input] as const, + reviewComments: (scope: GitHubQueryScope, input: PullFromRepoQueryInput) => + ["github", scope.userId, "pulls", "reviewComments", input] as const, }, issues: { mine: (scope: GitHubQueryScope) => @@ -247,6 +253,34 @@ export function githubPullStatusQueryOptions( }); } +export function githubPullFilesQueryOptions( + scope: GitHubQueryScope, + input: PullFromRepoQueryInput, +) { + return queryOptions({ + queryKey: githubQueryKeys.pulls.files(scope, input), + queryFn: () => getPullFiles({ data: input }), + staleTime: githubCachePolicy.detail.staleTimeMs, + gcTime: githubCachePolicy.detail.gcTimeMs, + refetchOnMount: "always", + meta: tabPersistedMeta, + }); +} + +export function githubPullReviewCommentsQueryOptions( + scope: GitHubQueryScope, + input: PullFromRepoQueryInput, +) { + return queryOptions({ + queryKey: githubQueryKeys.pulls.reviewComments(scope, input), + queryFn: () => getPullReviewComments({ data: input }), + staleTime: githubCachePolicy.activity.staleTimeMs, + gcTime: githubCachePolicy.activity.gcTimeMs, + refetchOnMount: "always", + meta: tabPersistedMeta, + }); +} + export function githubMyIssuesQueryOptions(scope: GitHubQueryScope) { return queryOptions({ queryKey: githubQueryKeys.issues.mine(scope), diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index 3fa9c0d..1d257d9 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -156,3 +156,61 @@ export type PullPageData = { detail: PullDetail | null; comments: PullComment[]; }; + +export type PullFile = { + sha: string | null; + filename: string; + status: + | "added" + | "removed" + | "modified" + | "renamed" + | "copied" + | "changed" + | "unchanged"; + additions: number; + deletions: number; + changes: number; + patch: string | null; + previousFilename: string | null; +}; + +export type PullReviewComment = { + id: number; + body: string; + path: string; + line: number | null; + side: "LEFT" | "RIGHT"; + createdAt: string; + updatedAt: string; + author: GitHubActor | null; + inReplyToId: number | null; + diffHunk: string; +}; + +export type SubmitReviewInput = { + owner: string; + repo: string; + pullNumber: number; + body: string; + event: "APPROVE" | "REQUEST_CHANGES" | "COMMENT"; + comments?: Array<{ + path: string; + line: number; + side: "LEFT" | "RIGHT"; + body: string; + startLine?: number; + startSide?: "LEFT" | "RIGHT"; + }>; +}; + +export type CreateReviewCommentInput = { + owner: string; + repo: string; + pullNumber: number; + body: string; + commitId: string; + path: string; + line: number; + side: "LEFT" | "RIGHT"; +}; diff --git a/apps/dashboard/src/lib/tab-store.ts b/apps/dashboard/src/lib/tab-store.ts index c2cb67a..e2267e9 100644 --- a/apps/dashboard/src/lib/tab-store.ts +++ b/apps/dashboard/src/lib/tab-store.ts @@ -1,6 +1,6 @@ import { useSyncExternalStore } from "react"; -export type TabType = "pull" | "issue"; +export type TabType = "pull" | "issue" | "review"; export interface Tab { id: string; @@ -10,6 +10,8 @@ export interface Tab { url: string; repo: string; iconColor: string; + additions?: number; + deletions?: number; } export const TABS_STORAGE_KEY = "quickhub:tabs"; @@ -50,7 +52,21 @@ function getSnapshot() { } export function addTab(tab: Tab) { - if (tabs.some((t) => t.id === tab.id)) return; + const existing = tabs.find((t) => t.id === tab.id); + if (existing) { + // Update if any field changed (e.g. URL when navigating between PR detail and review) + if ( + existing.url === tab.url && + existing.title === tab.title && + existing.iconColor === tab.iconColor && + existing.additions === tab.additions && + existing.deletions === tab.deletions + ) + return; + tabs = tabs.map((t) => (t.id === tab.id ? tab : t)); + emitChange(); + return; + } tabs = [...tabs, tab]; emitChange(); } diff --git a/apps/dashboard/src/lib/use-register-tab.ts b/apps/dashboard/src/lib/use-register-tab.ts index c780c22..18edb80 100644 --- a/apps/dashboard/src/lib/use-register-tab.ts +++ b/apps/dashboard/src/lib/use-register-tab.ts @@ -9,6 +9,8 @@ export function useRegisterTab( url: string; repo: string; iconColor: string; + additions?: number; + deletions?: number; } | null, ) { useEffect(() => { @@ -21,6 +23,17 @@ export function useRegisterTab( url: tab.url, repo: tab.repo, iconColor: tab.iconColor, + additions: tab.additions, + deletions: tab.deletions, }); - }, [tab?.type, tab?.title, tab?.number, tab?.url, tab?.repo, tab?.iconColor]); + }, [ + tab?.type, + tab?.title, + tab?.number, + tab?.url, + tab?.repo, + tab?.iconColor, + tab?.additions, + tab?.deletions, + ]); } diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index 7e79475..f8d9805 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as ProtectedReviewsRouteImport } from './routes/_protected/review import { Route as ProtectedPullsRouteImport } from './routes/_protected/pulls' import { Route as ProtectedIssuesRouteImport } from './routes/_protected/issues' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' +import { Route as ProtectedOwnerRepoReviewPullIdRouteImport } from './routes/_protected/$owner/$repo/review.$pullId' import { Route as ProtectedOwnerRepoPullPullIdRouteImport } from './routes/_protected/$owner/$repo/pull.$pullId' import { Route as ProtectedOwnerRepoIssuesIssueIdRouteImport } from './routes/_protected/$owner/$repo/issues.$issueId' @@ -53,6 +54,12 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ path: '/api/auth/$', getParentRoute: () => rootRouteImport, } as any) +const ProtectedOwnerRepoReviewPullIdRoute = + ProtectedOwnerRepoReviewPullIdRouteImport.update({ + id: '/$owner/$repo/review/$pullId', + path: '/$owner/$repo/review/$pullId', + getParentRoute: () => ProtectedRoute, + } as any) const ProtectedOwnerRepoPullPullIdRoute = ProtectedOwnerRepoPullPullIdRouteImport.update({ id: '/$owner/$repo/pull/$pullId', @@ -75,6 +82,7 @@ export interface FileRoutesByFullPath { '/api/auth/$': typeof ApiAuthSplatRoute '/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute + '/$owner/$repo/review/$pullId': typeof ProtectedOwnerRepoReviewPullIdRoute } export interface FileRoutesByTo { '/login': typeof LoginRoute @@ -85,6 +93,7 @@ export interface FileRoutesByTo { '/api/auth/$': typeof ApiAuthSplatRoute '/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute + '/$owner/$repo/review/$pullId': typeof ProtectedOwnerRepoReviewPullIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -97,6 +106,7 @@ export interface FileRoutesById { '/api/auth/$': typeof ApiAuthSplatRoute '/_protected/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/_protected/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute + '/_protected/$owner/$repo/review/$pullId': typeof ProtectedOwnerRepoReviewPullIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -109,6 +119,7 @@ export interface FileRouteTypes { | '/api/auth/$' | '/$owner/$repo/issues/$issueId' | '/$owner/$repo/pull/$pullId' + | '/$owner/$repo/review/$pullId' fileRoutesByTo: FileRoutesByTo to: | '/login' @@ -119,6 +130,7 @@ export interface FileRouteTypes { | '/api/auth/$' | '/$owner/$repo/issues/$issueId' | '/$owner/$repo/pull/$pullId' + | '/$owner/$repo/review/$pullId' id: | '__root__' | '/_protected' @@ -130,6 +142,7 @@ export interface FileRouteTypes { | '/api/auth/$' | '/_protected/$owner/$repo/issues/$issueId' | '/_protected/$owner/$repo/pull/$pullId' + | '/_protected/$owner/$repo/review/$pullId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -189,6 +202,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiAuthSplatRouteImport parentRoute: typeof rootRouteImport } + '/_protected/$owner/$repo/review/$pullId': { + id: '/_protected/$owner/$repo/review/$pullId' + path: '/$owner/$repo/review/$pullId' + fullPath: '/$owner/$repo/review/$pullId' + preLoaderRoute: typeof ProtectedOwnerRepoReviewPullIdRouteImport + parentRoute: typeof ProtectedRoute + } '/_protected/$owner/$repo/pull/$pullId': { id: '/_protected/$owner/$repo/pull/$pullId' path: '/$owner/$repo/pull/$pullId' @@ -213,6 +233,7 @@ interface ProtectedRouteChildren { ProtectedIndexRoute: typeof ProtectedIndexRoute ProtectedOwnerRepoIssuesIssueIdRoute: typeof ProtectedOwnerRepoIssuesIssueIdRoute ProtectedOwnerRepoPullPullIdRoute: typeof ProtectedOwnerRepoPullPullIdRoute + ProtectedOwnerRepoReviewPullIdRoute: typeof ProtectedOwnerRepoReviewPullIdRoute } const ProtectedRouteChildren: ProtectedRouteChildren = { @@ -222,6 +243,7 @@ const ProtectedRouteChildren: ProtectedRouteChildren = { ProtectedIndexRoute: ProtectedIndexRoute, ProtectedOwnerRepoIssuesIssueIdRoute: ProtectedOwnerRepoIssuesIssueIdRoute, ProtectedOwnerRepoPullPullIdRoute: ProtectedOwnerRepoPullPullIdRoute, + ProtectedOwnerRepoReviewPullIdRoute: ProtectedOwnerRepoReviewPullIdRoute, } const ProtectedRouteWithChildren = ProtectedRoute._addFileChildren( diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx index a8e1c57..310623e 100644 --- a/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx +++ b/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx @@ -208,14 +208,23 @@ function PullDetailPage() { {" "} {pr.changedFiles === 1 ? "file" : "files"} changed - - - +{pr.additions} - - - -{pr.deletions} + + + + +{pr.additions} + + + -{pr.deletions} + + - + + Review changes + diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/review.$pullId.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/review.$pullId.tsx new file mode 100644 index 0000000..f15a678 --- /dev/null +++ b/apps/dashboard/src/routes/_protected/$owner/$repo/review.$pullId.tsx @@ -0,0 +1,1248 @@ +import type { SelectedLineRange } from "@pierre/diffs"; +import type { DiffLineAnnotation } from "@pierre/diffs/react"; + +// Lazy-load PatchDiff so @pierre/diffs (which bundles all shiki language grammars) +// is excluded from the server bundle, keeping it under the CF Workers 3 MiB limit. +const PatchDiff = lazy(() => + import("@pierre/diffs/react").then((mod) => ({ default: mod.PatchDiff })), +); + +import { + CloseIcon, + CommentIcon, + FileIcon, + FolderIcon, + GitBranchIcon, + GitMergeIcon, + GitPullRequestClosedIcon, + GitPullRequestDraftIcon, + GitPullRequestIcon, + SearchIcon, + TickIcon, +} from "@quickhub/icons"; +import { Markdown } from "@quickhub/ui/components/markdown"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@quickhub/ui/components/popover"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@quickhub/ui/components/resizable"; +import { vercelDark, vercelLight } from "@quickhub/ui/lib/shiki-themes"; +import { cn } from "@quickhub/ui/lib/utils"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useTheme } from "next-themes"; +import { + lazy, + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { formatRelativeTime } from "#/components/pulls/pull-request-row"; +import { submitPullReview } from "#/lib/github.functions"; +import { + githubPullFilesQueryOptions, + githubPullPageQueryOptions, + githubPullReviewCommentsQueryOptions, + githubQueryKeys, +} from "#/lib/github.query"; +import type { + PullDetail, + PullFile, + PullReviewComment, +} from "#/lib/github.types"; +import { useHasMounted } from "#/lib/use-has-mounted"; +import { useRegisterTab } from "#/lib/use-register-tab"; + +// Register custom themes lazily on the client to avoid pulling shiki into the server bundle. +// import.meta.env.SSR is statically replaced by Vite so the import is fully tree-shaken from SSR. +if (!import.meta.env.SSR) { + import("@pierre/diffs").then(({ registerCustomTheme }) => { + registerCustomTheme("vercel-light", () => Promise.resolve(vercelLight)); + registerCustomTheme("vercel-dark", () => Promise.resolve(vercelDark)); + }); +} + +export const Route = createFileRoute("/_protected/$owner/$repo/review/$pullId")( + { + loader: async ({ context, params }) => { + const pullNumber = Number(params.pullId); + const scope = { userId: context.user.id }; + const input = { owner: params.owner, repo: params.repo, pullNumber }; + + const primeQuery = (options: { queryKey: readonly unknown[] }) => { + if (context.queryClient.getQueryData(options.queryKey) !== undefined) { + return Promise.resolve(); + } + return context.queryClient.ensureQueryData(options); + }; + + await Promise.all([ + primeQuery(githubPullPageQueryOptions(scope, input)), + primeQuery(githubPullFilesQueryOptions(scope, input)), + primeQuery(githubPullReviewCommentsQueryOptions(scope, input)), + ]); + }, + component: ReviewPage, + }, +); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type PendingComment = { + path: string; + line: number; + startLine?: number; + side: "LEFT" | "RIGHT"; + startSide?: "LEFT" | "RIGHT"; + body: string; +}; + +type ReviewEvent = "APPROVE" | "REQUEST_CHANGES" | "COMMENT"; + +type FileTreeNode = { + name: string; + path: string; + type: "file" | "directory"; + status?: PullFile["status"]; + additions?: number; + deletions?: number; + children: FileTreeNode[]; +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function getPrStateConfig(pr: PullDetail) { + if (pr.isDraft) { + return { + icon: GitPullRequestDraftIcon, + color: "text-muted-foreground", + label: "Draft", + badgeClass: "bg-muted text-muted-foreground", + }; + } + if (pr.isMerged || pr.mergedAt) { + return { + icon: GitMergeIcon, + color: "text-purple-500", + label: "Merged", + badgeClass: "bg-purple-500/10 text-purple-500", + }; + } + if (pr.state === "closed") { + return { + icon: GitPullRequestClosedIcon, + color: "text-red-500", + label: "Closed", + badgeClass: "bg-red-500/10 text-red-500", + }; + } + return { + icon: GitPullRequestIcon, + color: "text-green-500", + label: "Open", + badgeClass: "bg-green-500/10 text-green-500", + }; +} + +function buildFileTree(files: PullFile[]): FileTreeNode[] { + const root: FileTreeNode = { + name: "", + path: "", + type: "directory", + children: [], + }; + + for (const file of files) { + const parts = file.filename.split("/"); + let current = root; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const isFile = i === parts.length - 1; + + let child = current.children.find((c) => c.name === part); + if (!child) { + child = { + name: part, + path: parts.slice(0, i + 1).join("/"), + type: isFile ? "file" : "directory", + status: isFile ? file.status : undefined, + additions: isFile ? file.additions : undefined, + deletions: isFile ? file.deletions : undefined, + children: [], + }; + current.children.push(child); + } + current = child; + } + } + + // Collapse single-child directories + function collapse(node: FileTreeNode): FileTreeNode { + if ( + node.type === "directory" && + node.children.length === 1 && + node.children[0].type === "directory" + ) { + const child = node.children[0]; + return collapse({ + ...child, + name: `${node.name}/${child.name}`, + children: child.children, + }); + } + return { + ...node, + children: node.children.map(collapse), + }; + } + + // Sort: directories first, then files, alphabetically + function sortTree(nodes: FileTreeNode[]): FileTreeNode[] { + return nodes + .map((n) => ({ ...n, children: sortTree(n.children) })) + .sort((a, b) => { + if (a.type !== b.type) return a.type === "directory" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + } + + return sortTree(root.children.map(collapse)); +} + +function buildPatchString(file: PullFile): string { + if (!file.patch) return ""; + const header = `diff --git a/${file.previousFilename ?? file.filename} b/${file.filename}\n--- a/${file.previousFilename ?? file.filename}\n+++ b/${file.filename}\n`; + return header + file.patch; +} + +function encodeFileId(filename: string): string { + return `diff-${filename.replaceAll("/", "-").replaceAll(".", "-")}`; +} + +// --------------------------------------------------------------------------- +// Main Component +// --------------------------------------------------------------------------- + +function ReviewPage() { + const { user } = Route.useRouteContext(); + const { owner, repo, pullId } = Route.useParams(); + const pullNumber = Number(pullId); + const scope = { userId: user.id }; + const hasMounted = useHasMounted(); + const queryClient = useQueryClient(); + const input = { owner, repo, pullNumber }; + + const pageQuery = useQuery({ + ...githubPullPageQueryOptions(scope, input), + enabled: hasMounted, + }); + + const filesQuery = useQuery({ + ...githubPullFilesQueryOptions(scope, input), + enabled: hasMounted, + }); + + const reviewCommentsQuery = useQuery({ + ...githubPullReviewCommentsQueryOptions(scope, input), + enabled: hasMounted, + }); + + const pr = pageQuery.data?.detail; + const files = filesQuery.data ?? []; + const reviewComments = reviewCommentsQuery.data ?? []; + + // Diff style state + const [diffStyle, setDiffStyle] = useState<"unified" | "split">("unified"); + + // Pending comments state + const [pendingComments, setPendingComments] = useState([]); + const [activeCommentForm, setActiveCommentForm] = useState<{ + path: string; + line: number; + startLine?: number; + side: "LEFT" | "RIGHT"; + startSide?: "LEFT" | "RIGHT"; + } | null>(null); + + // Track selected line range for highlighting during gutter drag + const [selectedLines, setSelectedLines] = useState( + null, + ); + + // Active file tracking + const [activeFile, setActiveFile] = useState(null); + const diffPanelRef = useRef(null); + + // File tree filter + const [fileFilter, setFileFilter] = useState(""); + + // Tab registration + useRegisterTab( + pr + ? { + type: "review", + title: pr.title, + number: pr.number, + url: `/${owner}/${repo}/review/${pullId}`, + repo: `${owner}/${repo}`, + iconColor: getPrStateConfig(pr).color, + additions: pr.additions, + deletions: pr.deletions, + } + : null, + ); + + // Build file tree + const fileTree = useMemo(() => buildFileTree(files), [files]); + + // Filtered files for tree + const filteredTree = useMemo(() => { + if (!fileFilter) return fileTree; + const lower = fileFilter.toLowerCase(); + + function filterNodes(nodes: FileTreeNode[]): FileTreeNode[] { + return nodes + .map((node) => { + if (node.type === "file") { + return node.name.toLowerCase().includes(lower) ? node : null; + } + const filtered = filterNodes(node.children); + return filtered.length > 0 ? { ...node, children: filtered } : null; + }) + .filter(Boolean) as FileTreeNode[]; + } + + return filterNodes(fileTree); + }, [fileTree, fileFilter]); + + // Scroll to file on click + const scrollToFile = useCallback((filename: string) => { + const element = document.getElementById(encodeFileId(filename)); + if (element) { + element.scrollIntoView({ behavior: "smooth", block: "start" }); + setActiveFile(filename); + } + }, []); + + // Intersection observer for active file tracking + useEffect(() => { + const panel = diffPanelRef.current; + if (!panel || files.length === 0) return; + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + const filename = entry.target.getAttribute("data-filename"); + if (filename) setActiveFile(filename); + } + } + }, + { + root: panel, + rootMargin: "-10% 0px -80% 0px", + threshold: 0, + }, + ); + + for (const file of files) { + const el = document.getElementById(encodeFileId(file.filename)); + if (el) observer.observe(el); + } + + return () => observer.disconnect(); + }, [files]); + + // Build annotations map per file + const annotationsByFile = useMemo(() => { + const map = new Map[]>(); + for (const comment of reviewComments) { + if (comment.line == null) continue; + const existing = map.get(comment.path) ?? []; + existing.push({ + side: comment.side === "LEFT" ? "deletions" : "additions", + lineNumber: comment.line, + metadata: comment, + }); + map.set(comment.path, existing); + } + return map; + }, [reviewComments]); + + // Add pending comment + const addPendingComment = useCallback((comment: PendingComment) => { + setPendingComments((prev) => [...prev, comment]); + setActiveCommentForm(null); + }, []); + + // Submit review + const [isSubmitting, setIsSubmitting] = useState(false); + const handleSubmitReview = useCallback( + async (body: string, event: ReviewEvent) => { + setIsSubmitting(true); + try { + const success = await submitPullReview({ + data: { + owner, + repo, + pullNumber, + body, + event, + comments: pendingComments.map((c) => ({ + path: c.path, + line: c.line, + side: c.side, + body: c.body, + ...(c.startLine != null && c.startLine !== c.line + ? { startLine: c.startLine, startSide: c.startSide ?? c.side } + : {}), + })), + }, + }); + + if (success) { + setPendingComments([]); + queryClient.invalidateQueries({ + queryKey: githubQueryKeys.all, + }); + } + } finally { + setIsSubmitting(false); + } + }, + [owner, repo, pullNumber, pendingComments, queryClient], + ); + + if (pageQuery.error) throw pageQuery.error; + + if (!pr) { + return ( + + + + ); + } + + const stateConfig = getPrStateConfig(pr); + const StateIcon = stateConfig.icon; + + const totalAdditions = files.reduce((s, f) => s + f.additions, 0); + const totalDeletions = files.reduce((s, f) => s + f.deletions, 0); + + return ( + + {/* Toolbar */} + + + + #{pr.number} + + + + + + + + + {pr.title} + + + + + + + + {files.length} + {" "} + {files.length === 1 ? "file" : "files"} + + + +{totalAdditions} + + + -{totalDeletions} + + + + + + {/* Diff style toggle */} + + setDiffStyle("unified")} + > + Unified + + setDiffStyle("split")} + > + Split + + + + + + {/* Submit review button */} + + + + + {/* Main content: file tree + diffs */} + + {/* File tree sidebar */} + + + {/* Filter */} + + + + setFileFilter(e.target.value)} + className="ml-2 w-full bg-transparent text-xs outline-none placeholder:text-muted-foreground" + /> + + + + {/* Tree */} + + {filteredTree.map((node) => ( + + ))} + + + {/* Summary */} + + {files.length} {files.length === 1 ? "file" : "files"} changed + + + + + + + {/* Diff panel */} + + + + {files.map((file) => ( + c.path === file.filename, + )} + activeCommentForm={ + activeCommentForm?.path === file.filename + ? activeCommentForm + : null + } + selectedLines={ + activeCommentForm?.path === file.filename + ? selectedLines + : null + } + onGutterClick={(range) => { + const side = range.side === "deletions" ? "LEFT" : "RIGHT"; + const isMultiLine = range.start !== range.end; + setActiveCommentForm({ + path: file.filename, + line: Math.max(range.start, range.end), + side, + ...(isMultiLine + ? { + startLine: Math.min(range.start, range.end), + startSide: + (range.endSide ?? range.side) === "deletions" + ? "LEFT" + : "RIGHT", + } + : {}), + }); + setSelectedLines(range); + }} + onCancelComment={() => { + setActiveCommentForm(null); + setSelectedLines(null); + }} + onAddComment={(comment) => { + addPendingComment(comment); + setSelectedLines(null); + }} + /> + ))} + + {files.length === 0 && !filesQuery.isLoading && ( + + No files changed in this pull request. + + )} + + + + + + ); +} + +// --------------------------------------------------------------------------- +// File Tree Node +// --------------------------------------------------------------------------- + +function FileTreeNodeComponent({ + node, + depth, + activeFile, + onFileClick, +}: { + node: FileTreeNode; + depth: number; + activeFile: string | null; + onFileClick: (path: string) => void; +}) { + const [isOpen, setIsOpen] = useState(true); + + if (node.type === "directory") { + return ( + + setIsOpen(!isOpen)} + > + + + + + {node.name} + + {isOpen && ( + + {node.children.map((child) => ( + + ))} + + )} + + ); + } + + const isActive = activeFile === node.path; + + return ( + onFileClick(node.path)} + > + + + {node.name} + + {(node.additions != null || node.deletions != null) && ( + + {node.additions != null && node.additions > 0 && ( + +{node.additions} + )} + {node.deletions != null && node.deletions > 0 && ( + -{node.deletions} + )} + + )} + + ); +} + +// --------------------------------------------------------------------------- +// File Diff Block +// --------------------------------------------------------------------------- + +function FileDiffBlock({ + file, + diffStyle, + annotations, + pendingComments, + activeCommentForm, + selectedLines, + onGutterClick, + onCancelComment, + onAddComment, +}: { + file: PullFile; + diffStyle: "unified" | "split"; + annotations: DiffLineAnnotation[]; + pendingComments: PendingComment[]; + activeCommentForm: { + path: string; + line: number; + startLine?: number; + side: "LEFT" | "RIGHT"; + startSide?: "LEFT" | "RIGHT"; + } | null; + selectedLines: SelectedLineRange | null; + onGutterClick: (range: SelectedLineRange) => void; + onCancelComment: () => void; + onAddComment: (comment: PendingComment) => void; +}) { + const [isCollapsed, setIsCollapsed] = useState(false); + const { resolvedTheme } = useTheme(); + const isDark = resolvedTheme === "dark"; + + // Combine existing review comments and pending comments into annotations + const allAnnotations = useMemo(() => { + const result: DiffLineAnnotation[] = [ + ...annotations, + ]; + + for (const pending of pendingComments) { + result.push({ + side: pending.side === "LEFT" ? "deletions" : "additions", + lineNumber: pending.line, + metadata: pending, + }); + } + + // Add active comment form as an annotation + if (activeCommentForm) { + result.push({ + side: activeCommentForm.side === "LEFT" ? "deletions" : "additions", + lineNumber: activeCommentForm.line, + metadata: { + path: activeCommentForm.path, + line: activeCommentForm.line, + startLine: activeCommentForm.startLine, + side: activeCommentForm.side, + startSide: activeCommentForm.startSide, + body: "__FORM__", + } satisfies PendingComment, + }); + } + + return result; + }, [annotations, pendingComments, activeCommentForm]); + + const mutedFg = isDark + ? "oklch(0.705 0.015 286.067)" + : "oklch(0.552 0.016 285.938)"; + + const diffOptions = useMemo( + () => ({ + diffStyle, + theme: { + dark: "vercel-dark" as const, + light: "vercel-light" as const, + }, + lineDiffType: "word" as const, + hunkSeparators: "line-info" as const, + overflow: "scroll" as const, + disableFileHeader: true, + enableGutterUtility: true, + enableLineSelection: true, + onGutterUtilityClick: onGutterClick, + unsafeCSS: [ + `:host { color-scheme: ${isDark ? "dark" : "light"}; ${isDark ? "" : "--diffs-light-bg: oklch(0.967 0.001 286.375);"} }`, + `:host { --diffs-font-family: 'Geist Mono Variable', 'SF Mono', ui-monospace, 'Cascadia Code', monospace; }`, + `:host { --diffs-selection-base: ${mutedFg}; }`, + `[data-utility-button] { background-color: ${mutedFg}; }`, + `[data-line-annotation] { font-family: 'Inter Variable', 'Inter', 'Avenir Next', ui-sans-serif, system-ui, sans-serif; }`, + `[data-line-annotation] code { font-family: var(--diffs-font-family, var(--diffs-font-fallback)); }`, + isDark + ? `:host { --diffs-bg-addition-override: color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-addition-base)); --diffs-bg-addition-number-override: color-mix(in lab, var(--diffs-bg) 88%, var(--diffs-addition-base)); --diffs-bg-addition-emphasis-override: color-mix(in lab, var(--diffs-bg) 75%, var(--diffs-addition-base)); --diffs-bg-deletion-override: color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-deletion-base)); --diffs-bg-deletion-number-override: color-mix(in lab, var(--diffs-bg) 88%, var(--diffs-deletion-base)); --diffs-bg-deletion-emphasis-override: color-mix(in lab, var(--diffs-bg) 75%, var(--diffs-deletion-base)); }` + : `:host { --diffs-bg-addition-override: color-mix(in lab, var(--diffs-bg) 82%, var(--diffs-addition-base)); --diffs-bg-addition-number-override: color-mix(in lab, var(--diffs-bg) 78%, var(--diffs-addition-base)); --diffs-bg-deletion-override: color-mix(in lab, var(--diffs-bg) 82%, var(--diffs-deletion-base)); --diffs-bg-deletion-number-override: color-mix(in lab, var(--diffs-bg) 78%, var(--diffs-deletion-base)); }`, + ].join("\n"), + }), + [diffStyle, onGutterClick, isDark, mutedFg], + ); + + if (!file.patch) { + return ( + + setIsCollapsed(!isCollapsed)} + /> + {!isCollapsed && ( + + Binary file or diff too large to display + + )} + + ); + } + + const patchString = buildPatchString(file); + + return ( + + setIsCollapsed(!isCollapsed)} + /> + {!isCollapsed && ( + + + { + const data = annotation.metadata as + | PendingComment + | PullReviewComment + | null; + if (!data) return null; + + // Pending comment form + if ("body" in data && data.body === "__FORM__") { + const formData = data as PendingComment; + return ( + + onAddComment({ + path: file.filename, + line: formData.line, + startLine: formData.startLine, + side: formData.side, + startSide: formData.startSide, + body, + }) + } + onCancel={onCancelComment} + /> + ); + } + + // Pending comment display + if ("body" in data && !("id" in data)) { + return ( + + ); + } + + // Existing review comment + if ("id" in data) { + return ( + + ); + } + + return null; + }} + /> + + + )} + + ); +} + +// --------------------------------------------------------------------------- +// File Header +// --------------------------------------------------------------------------- + +function FileHeader({ + file, + isCollapsed, + onToggleCollapse, +}: { + file: PullFile; + isCollapsed: boolean; + onToggleCollapse: () => void; +}) { + return ( + + + + + + + + + {file.previousFilename && file.previousFilename !== file.filename ? ( + <> + + {file.previousFilename} + + → + {file.filename} + > + ) : ( + file.filename + )} + + + + {file.additions > 0 && ( + +{file.additions} + )} + {file.deletions > 0 && ( + -{file.deletions} + )} + + + ); +} + +// --------------------------------------------------------------------------- +// Inline Comment Form +// --------------------------------------------------------------------------- + +function InlineCommentForm({ + isMultiLine, + startLine, + endLine, + onSubmit, + onCancel, +}: { + isMultiLine?: boolean; + startLine?: number; + endLine?: number; + onSubmit: (body: string) => void; + onCancel: () => void; +}) { + const [body, setBody] = useState(""); + const textareaRef = useRef(null); + + useEffect(() => { + textareaRef.current?.focus(); + }, []); + + return ( + + {isMultiLine && startLine != null && endLine != null && ( + + Commenting on lines {startLine}–{endLine} + + )} + setBody(e.target.value)} + placeholder="Leave a comment..." + className="min-h-[60px] w-full resize-y rounded-md border bg-background px-3 py-2 text-xs outline-none placeholder:text-muted-foreground focus:border-blue-500 focus:ring-1 focus:ring-blue-500" + /> + + + Cancel + + { + if (body.trim()) onSubmit(body.trim()); + }} + disabled={!body.trim()} + className="rounded-md bg-foreground px-3 py-1.5 text-xs font-medium text-background transition-opacity disabled:opacity-50" + > + Add comment + + + + ); +} + +// --------------------------------------------------------------------------- +// Comment Bubbles +// --------------------------------------------------------------------------- + +function ReviewCommentBubble({ comment }: { comment: PullReviewComment }) { + return ( + + + {comment.author && ( + <> + + {comment.author.login} + > + )} + + {formatRelativeTime(comment.createdAt)} + + + + {comment.body} + + + ); +} + +function PendingCommentBubble({ comment }: { comment: PendingComment }) { + const isMultiLine = + comment.startLine != null && comment.startLine !== comment.line; + + return ( + + + + + Pending + {isMultiLine ? ` (lines ${comment.startLine}–${comment.line})` : ""} + + + + {comment.body} + + + ); +} + +// --------------------------------------------------------------------------- +// Review Submit Popover +// --------------------------------------------------------------------------- + +function ReviewSubmitPopover({ + pendingCount, + isSubmitting, + onSubmit, +}: { + pendingCount: number; + isSubmitting: boolean; + onSubmit: (body: string, event: ReviewEvent) => void; +}) { + const [body, setBody] = useState(""); + const [event, setEvent] = useState("COMMENT"); + const [isOpen, setIsOpen] = useState(false); + + const handleSubmit = () => { + onSubmit(body, event); + setBody(""); + setIsOpen(false); + }; + + const reviewOptions: Array<{ + value: ReviewEvent; + label: string; + description: string; + icon: typeof CommentIcon; + color: string; + }> = [ + { + value: "COMMENT", + label: "Comment", + description: "Submit general feedback without explicit approval.", + icon: CommentIcon, + color: "text-foreground", + }, + { + value: "APPROVE", + label: "Approve", + description: "Submit feedback and approve merging these changes.", + icon: TickIcon, + color: "text-green-500", + }, + { + value: "REQUEST_CHANGES", + label: "Request changes", + description: "Submit feedback suggesting changes.", + icon: GitBranchIcon, + color: "text-red-500", + }, + ]; + + return ( + + + + Submit review + {pendingCount > 0 && ( + + {pendingCount} + + )} + + + + + + + Finish your review + setIsOpen(false)} + > + + + + + + setBody(e.target.value)} + placeholder="Leave a comment" + className="min-h-[80px] w-full resize-y rounded-md border bg-background px-3 py-2 text-xs outline-none placeholder:text-muted-foreground focus:border-blue-500 focus:ring-1 focus:ring-blue-500" + /> + + + + {reviewOptions.map((option) => { + const Icon = option.icon; + return ( + + setEvent(option.value)} + className="mt-0.5" + /> + + + + {option.label} + + + {option.description} + + + + ); + })} + + + + setIsOpen(false)} + className="rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground" + > + Cancel + + + {isSubmitting + ? "Submitting..." + : `Submit review${pendingCount > 0 ? ` (${pendingCount})` : ""}`} + + + + + + ); +} diff --git a/apps/dashboard/vite.config.ts b/apps/dashboard/vite.config.ts index 6955f50..f061b43 100644 --- a/apps/dashboard/vite.config.ts +++ b/apps/dashboard/vite.config.ts @@ -17,9 +17,47 @@ const worktreePersistState = isWorktreeCheckout(dashboardRoot) ? { persistState: { path: getSharedWranglerStatePath(dashboardRoot) } } : {}; +// Stub out shiki in the SSR (Cloudflare Worker) environment to prevent all +// language grammars (~1.5 MB) from being bundled. Shiki is only used +// client-side so the server never needs the real implementation. +function shikiSSRStub(): import("vite").Plugin { + const SHIKI_RE = /^(shiki|@shikijs\/)/; + const STUB = ` +export const bundledLanguages = {}; +export const bundledThemes = {}; +export const createHighlighter = () => Promise.resolve({}); +export const createJavaScriptRegexEngine = () => ({}); +export const createOnigurumaEngine = () => Promise.resolve({}); +export const createCssVariablesTheme = () => ({}); +export const codeToHtml = () => ""; +export const normalizeTheme = (t) => t; +export const getTokenStyleObject = () => ({}); +export const stringifyTokenStyle = () => ""; +export const transformerStyleToClass = () => ({}); +export default {};`; + + return { + name: "shiki-ssr-stub", + enforce: "pre", + resolveId: { + handler(source) { + if (this.environment?.name === "ssr" && SHIKI_RE.test(source)) { + return `\0shiki-stub:${source}`; + } + }, + }, + load(id) { + if (id.startsWith("\0shiki-stub:")) { + return STUB; + } + }, + }; +} + const config = defineConfig({ plugins: [ devtools(), + shikiSSRStub(), cloudflare({ viteEnvironment: { name: "ssr" }, ...worktreePersistState, diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index 8dfcd28..8d1623e 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -15,6 +15,7 @@ export { Copy01Icon as CopyIcon, DashboardSquare01Icon as DashboardIcon, File02Icon as FileIcon, + Folder01Icon as FolderIcon, FolderLibraryIcon, GitBranchIcon, GitCommitIcon, diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 7c65bd3..fa21438 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -3,7 +3,8 @@ import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; import rehypeRaw from "rehype-raw"; import remarkGfm from "remark-gfm"; -import type { BundledLanguage, Highlighter, ThemeRegistrationRaw } from "shiki"; +import type { BundledLanguage, Highlighter } from "shiki"; +import { vercelDark, vercelLight } from "../lib/shiki-themes"; import { cn } from "../lib/utils"; const PRELOADED_LANGS: BundledLanguage[] = [ @@ -34,191 +35,17 @@ const PRELOADED_LANGS: BundledLanguage[] = [ "toml", ]; -const vercelLightTokens: ThemeRegistrationRaw["tokenColors"] = [ - { - scope: ["comment", "punctuation.definition.comment"], - settings: { foreground: "#666666", fontStyle: "italic" }, - }, - { - scope: ["keyword", "storage", "storage.type", "storage.modifier"], - settings: { foreground: "#c41562" }, - }, - { - scope: ["string", "string.quoted", "string.template", "string.regexp"], - settings: { foreground: "#107d32" }, - }, - { - scope: [ - "constant", - "constant.numeric", - "constant.language", - "constant.character", - ], - settings: { foreground: "#005ff2" }, - }, - { - scope: ["entity.name.function", "support.function", "meta.function-call"], - settings: { foreground: "#7d00cc" }, - }, - { - scope: [ - "variable.parameter", - "meta.parameter", - "entity.name.variable.parameter", - ], - settings: { foreground: "#aa4d00" }, - }, - { - scope: [ - "variable.other.property", - "support.type.property-name", - "entity.name.tag", - "meta.object-literal.key", - ], - settings: { foreground: "#005ff2" }, - }, - { - scope: [ - "entity.name.type", - "entity.name.class", - "support.type", - "support.class", - ], - settings: { foreground: "#005ff2" }, - }, - { - scope: ["punctuation", "meta.brace", "meta.bracket"], - settings: { foreground: "#171717" }, - }, - { - scope: ["variable", "variable.other"], - settings: { foreground: "#171717" }, - }, - { - scope: [ - "entity.other.attribute-name", - "entity.other.attribute-name.jsx", - "entity.other.attribute-name.tsx", - ], - settings: { foreground: "#aa4d00" }, - }, - { - scope: ["markup.deleted", "punctuation.definition.deleted"], - settings: { foreground: "#c41562" }, - }, - { - scope: ["markup.inserted", "punctuation.definition.inserted"], - settings: { foreground: "#107d32" }, - }, -]; - -const vercelDarkTokens: ThemeRegistrationRaw["tokenColors"] = [ - { - scope: ["comment", "punctuation.definition.comment"], - settings: { foreground: "#a1a1a1", fontStyle: "italic" }, - }, - { - scope: ["keyword", "storage", "storage.type", "storage.modifier"], - settings: { foreground: "#ff4d8d" }, - }, - { - scope: ["string", "string.quoted", "string.template", "string.regexp"], - settings: { foreground: "#00ca50" }, - }, - { - scope: [ - "constant", - "constant.numeric", - "constant.language", - "constant.character", - ], - settings: { foreground: "#47a8ff" }, - }, - { - scope: ["entity.name.function", "support.function", "meta.function-call"], - settings: { foreground: "#c472fb" }, - }, - { - scope: [ - "variable.parameter", - "meta.parameter", - "entity.name.variable.parameter", - ], - settings: { foreground: "#ff9300" }, - }, - { - scope: [ - "variable.other.property", - "support.type.property-name", - "entity.name.tag", - "meta.object-literal.key", - ], - settings: { foreground: "#47a8ff" }, - }, - { - scope: [ - "entity.name.type", - "entity.name.class", - "support.type", - "support.class", - ], - settings: { foreground: "#47a8ff" }, - }, - { - scope: ["punctuation", "meta.brace", "meta.bracket"], - settings: { foreground: "#ededed" }, - }, - { - scope: ["variable", "variable.other"], - settings: { foreground: "#ededed" }, - }, - { - scope: [ - "entity.other.attribute-name", - "entity.other.attribute-name.jsx", - "entity.other.attribute-name.tsx", - ], - settings: { foreground: "#ff9300" }, - }, - { - scope: ["markup.deleted", "punctuation.definition.deleted"], - settings: { foreground: "#ff4d8d" }, - }, - { - scope: ["markup.inserted", "punctuation.definition.inserted"], - settings: { foreground: "#00ca50" }, - }, -]; - -const vercelLight: ThemeRegistrationRaw = { - name: "vercel-light", - type: "light", - settings: vercelLightTokens as ThemeRegistrationRaw["settings"], - colors: { - "editor.background": "#ffffff", - "editor.foreground": "#171717", - }, - tokenColors: vercelLightTokens, -}; - -const vercelDark: ThemeRegistrationRaw = { - name: "vercel-dark", - type: "dark", - settings: vercelDarkTokens as ThemeRegistrationRaw["settings"], - colors: { - "editor.background": "#1a1a1a", - "editor.foreground": "#ededed", - }, - tokenColors: vercelDarkTokens, -}; - -// Eagerly start loading the highlighter at module level -const highlighterPromise: Promise = import("shiki").then((shiki) => - shiki.createHighlighter({ - themes: [vercelLight, vercelDark], - langs: PRELOADED_LANGS, - }), -); +// Eagerly start loading the highlighter at module level (client-only to avoid +// bundling all shiki language grammars into the server bundle for CF Workers). +const highlighterPromise: Promise = + typeof window !== "undefined" + ? import("shiki").then((shiki) => + shiki.createHighlighter({ + themes: [vercelLight, vercelDark], + langs: PRELOADED_LANGS, + }), + ) + : new Promise(() => {}); // Never resolves on server → Suspense fallback const htmlCache = new Map>(); diff --git a/packages/ui/src/lib/shiki-themes.ts b/packages/ui/src/lib/shiki-themes.ts new file mode 100644 index 0000000..4ee300d --- /dev/null +++ b/packages/ui/src/lib/shiki-themes.ts @@ -0,0 +1,179 @@ +import type { ThemeRegistrationRaw } from "shiki"; + +const vercelLightTokens: ThemeRegistrationRaw["tokenColors"] = [ + { + scope: ["comment", "punctuation.definition.comment"], + settings: { foreground: "#666666", fontStyle: "italic" }, + }, + { + scope: ["keyword", "storage", "storage.type", "storage.modifier"], + settings: { foreground: "#c41562" }, + }, + { + scope: ["string", "string.quoted", "string.template", "string.regexp"], + settings: { foreground: "#107d32" }, + }, + { + scope: [ + "constant", + "constant.numeric", + "constant.language", + "constant.character", + ], + settings: { foreground: "#005ff2" }, + }, + { + scope: ["entity.name.function", "support.function", "meta.function-call"], + settings: { foreground: "#7d00cc" }, + }, + { + scope: [ + "variable.parameter", + "meta.parameter", + "entity.name.variable.parameter", + ], + settings: { foreground: "#aa4d00" }, + }, + { + scope: [ + "variable.other.property", + "support.type.property-name", + "entity.name.tag", + "meta.object-literal.key", + ], + settings: { foreground: "#005ff2" }, + }, + { + scope: [ + "entity.name.type", + "entity.name.class", + "support.type", + "support.class", + ], + settings: { foreground: "#005ff2" }, + }, + { + scope: ["punctuation", "meta.brace", "meta.bracket"], + settings: { foreground: "#171717" }, + }, + { + scope: ["variable", "variable.other"], + settings: { foreground: "#171717" }, + }, + { + scope: [ + "entity.other.attribute-name", + "entity.other.attribute-name.jsx", + "entity.other.attribute-name.tsx", + ], + settings: { foreground: "#aa4d00" }, + }, + { + scope: ["markup.deleted", "punctuation.definition.deleted"], + settings: { foreground: "#c41562" }, + }, + { + scope: ["markup.inserted", "punctuation.definition.inserted"], + settings: { foreground: "#107d32" }, + }, +]; + +const vercelDarkTokens: ThemeRegistrationRaw["tokenColors"] = [ + { + scope: ["comment", "punctuation.definition.comment"], + settings: { foreground: "#a1a1a1", fontStyle: "italic" }, + }, + { + scope: ["keyword", "storage", "storage.type", "storage.modifier"], + settings: { foreground: "#ff4d8d" }, + }, + { + scope: ["string", "string.quoted", "string.template", "string.regexp"], + settings: { foreground: "#00ca50" }, + }, + { + scope: [ + "constant", + "constant.numeric", + "constant.language", + "constant.character", + ], + settings: { foreground: "#47a8ff" }, + }, + { + scope: ["entity.name.function", "support.function", "meta.function-call"], + settings: { foreground: "#c472fb" }, + }, + { + scope: [ + "variable.parameter", + "meta.parameter", + "entity.name.variable.parameter", + ], + settings: { foreground: "#ff9300" }, + }, + { + scope: [ + "variable.other.property", + "support.type.property-name", + "entity.name.tag", + "meta.object-literal.key", + ], + settings: { foreground: "#47a8ff" }, + }, + { + scope: [ + "entity.name.type", + "entity.name.class", + "support.type", + "support.class", + ], + settings: { foreground: "#47a8ff" }, + }, + { + scope: ["punctuation", "meta.brace", "meta.bracket"], + settings: { foreground: "#ededed" }, + }, + { + scope: ["variable", "variable.other"], + settings: { foreground: "#ededed" }, + }, + { + scope: [ + "entity.other.attribute-name", + "entity.other.attribute-name.jsx", + "entity.other.attribute-name.tsx", + ], + settings: { foreground: "#ff9300" }, + }, + { + scope: ["markup.deleted", "punctuation.definition.deleted"], + settings: { foreground: "#ff4d8d" }, + }, + { + scope: ["markup.inserted", "punctuation.definition.inserted"], + settings: { foreground: "#00ca50" }, + }, +]; + +export const vercelLight: ThemeRegistrationRaw = { + name: "vercel-light", + type: "light", + settings: vercelLightTokens as ThemeRegistrationRaw["settings"], + colors: { + "editor.background": "#ffffff", + "editor.foreground": "#171717", + }, + tokenColors: vercelLightTokens, +}; + +export const vercelDark: ThemeRegistrationRaw = { + name: "vercel-dark", + type: "dark", + settings: vercelDarkTokens as ThemeRegistrationRaw["settings"], + colors: { + "editor.background": "#1a1a1a", + "editor.foreground": "#ededed", + }, + tokenColors: vercelDarkTokens, +}; diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index ae3e93f..1bee25d 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -284,6 +284,11 @@ input[type="search"] { font-size: 16px; } +/* @pierre/diffs — use our monospace font */ +[data-diffs] { + --diffs-font-family: var(--font-mono); +} + /* Shiki syntax highlighting — dual theme support */ .shiki, .shiki span { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ebb2204..45c7e90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@cloudflare/vite-plugin': specifier: ^1.26.0 version: 1.31.0(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))(workerd@1.20260401.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260405.1)) + '@pierre/diffs': + specifier: ^1.1.12 + version: 1.1.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@quickhub/icons': specifier: workspace:* version: link:../../packages/icons @@ -1593,6 +1596,16 @@ packages: resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} engines: {node: '>=14'} + '@pierre/diffs@1.1.12': + resolution: {integrity: sha512-InssHHM7f0nkazIRkuaiNCy6GkBLfwJlqc7LtTkMD/KSqsuc6bnL2V9sIQoG5PZu9jwinQiXUb/gT7itFa6U9A==} + peerDependencies: + react: ^18.3.1 || ^19.0.0 + react-dom: ^18.3.1 || ^19.0.0 + + '@pierre/theme@0.0.28': + resolution: {integrity: sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw==} + engines: {vscode: ^1.0.0} + '@poppinss/colors@4.1.6': resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} @@ -2421,18 +2434,30 @@ packages: cpu: [x64] os: [win32] + '@shikijs/core@3.23.0': + resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} + '@shikijs/core@4.0.2': resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==} engines: {node: '>=20'} + '@shikijs/engine-javascript@3.23.0': + resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==} + '@shikijs/engine-javascript@4.0.2': resolution: {integrity: sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==} engines: {node: '>=20'} + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + '@shikijs/engine-oniguruma@4.0.2': resolution: {integrity: sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==} engines: {node: '>=20'} + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + '@shikijs/langs@4.0.2': resolution: {integrity: sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==} engines: {node: '>=20'} @@ -2445,10 +2470,19 @@ packages: resolution: {integrity: sha512-cmPlKLD8JeojasNFoY64162ScpEdEdQUMuVodPCrv1nx1z3bjmGwoKWDruQWa/ejSznImlaeB0Ty6Q3zPaVQAA==} engines: {node: '>=20'} + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + '@shikijs/themes@4.0.2': resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==} engines: {node: '>=20'} + '@shikijs/transformers@3.23.0': + resolution: {integrity: sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ==} + + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + '@shikijs/types@4.0.2': resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==} engines: {node: '>=20'} @@ -3275,6 +3309,10 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff@8.0.3: + resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} + engines: {node: '>=0.3.1'} + diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} @@ -3807,6 +3845,9 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru_map@0.4.1: + resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==} + lucide-react@0.545.0: resolution: {integrity: sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==} peerDependencies: @@ -4267,6 +4308,9 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} + shiki@3.23.0: + resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} + shiki@4.0.2: resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==} engines: {node: '>=20'} @@ -5726,6 +5770,19 @@ snapshots: '@opentelemetry/semantic-conventions@1.40.0': {} + '@pierre/diffs@1.1.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@pierre/theme': 0.0.28 + '@shikijs/transformers': 3.23.0 + diff: 8.0.3 + hast-util-to-html: 9.0.5 + lru_map: 0.4.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + shiki: 3.23.0 + + '@pierre/theme@0.0.28': {} + '@poppinss/colors@4.1.6': dependencies: kleur: 4.1.5 @@ -6491,6 +6548,13 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.1': optional: true + '@shikijs/core@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + '@shikijs/core@4.0.2': dependencies: '@shikijs/primitive': 4.0.2 @@ -6499,17 +6563,32 @@ snapshots: '@types/hast': 3.0.4 hast-util-to-html: 9.0.5 + '@shikijs/engine-javascript@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.5 + '@shikijs/engine-javascript@4.0.2': dependencies: '@shikijs/types': 4.0.2 '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.5 + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/engine-oniguruma@4.0.2': dependencies: '@shikijs/types': 4.0.2 '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/langs@4.0.2': dependencies: '@shikijs/types': 4.0.2 @@ -6529,10 +6608,24 @@ snapshots: unified: 11.0.5 unist-util-visit: 5.1.0 + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/themes@4.0.2': dependencies: '@shikijs/types': 4.0.2 + '@shikijs/transformers@3.23.0': + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/types': 3.23.0 + + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + '@shikijs/types@4.0.2': dependencies: '@shikijs/vscode-textmate': 10.0.2 @@ -7393,6 +7486,8 @@ snapshots: dependencies: dequal: 2.0.3 + diff@8.0.3: {} + diff@8.0.4: {} dom-accessibility-api@0.5.16: {} @@ -7948,6 +8043,8 @@ snapshots: dependencies: yallist: 3.1.1 + lru_map@0.4.1: {} + lucide-react@0.545.0(react@19.2.4): dependencies: react: 19.2.4 @@ -8689,6 +8786,17 @@ snapshots: shell-quote@1.8.3: {} + shiki@3.23.0: + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/engine-javascript': 3.23.0 + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + shiki@4.0.2: dependencies: '@shikijs/core': 4.0.2