├── bunfig.toml ├── src ├── browser │ ├── ui │ │ ├── diff │ │ │ └── utils │ │ │ │ ├── index.ts │ │ │ │ └── guess-lang.ts │ │ ├── skeleton.tsx │ │ ├── checkbox.tsx │ │ ├── hover-card.tsx │ │ ├── popover.tsx │ │ ├── radio-group.tsx │ │ ├── tooltip.tsx │ │ ├── tabs.tsx │ │ ├── button.tsx │ │ ├── pagination.tsx │ │ ├── dialog.tsx │ │ ├── keycap.tsx │ │ ├── context-menu.tsx │ │ └── dropdown-menu.tsx │ ├── cn.ts │ ├── contexts │ │ ├── pr-review │ │ │ ├── useIsLineCommenting.ts │ │ │ ├── useIsLineFocused.ts │ │ │ ├── useIsLineInCommentRange.ts │ │ │ ├── useIsLineInCommentingRange.ts │ │ │ ├── useCommentCountsByFile.ts │ │ │ ├── useIsCurrentFileLoading.ts │ │ │ ├── useCurrentDiff.ts │ │ │ ├── usePendingCommentCountsByFile.ts │ │ │ ├── useCurrentFile.ts │ │ │ ├── useCommentingRange.ts │ │ │ ├── useCurrentUserLoader.ts │ │ │ ├── useCommentRangeLookup.ts │ │ │ ├── useCurrentFileComments.ts │ │ │ ├── useCommentsByFile.ts │ │ │ ├── useCurrentFilePendingComments.ts │ │ │ ├── useSelectionRange.ts │ │ │ ├── useIsLineInSelection.ts │ │ │ ├── useThreadActions.ts │ │ │ ├── useFileCopyActions.ts │ │ │ ├── useSelectionState.ts │ │ │ ├── usePendingReviewLoader.ts │ │ │ ├── useSelectionBoundary.ts │ │ │ ├── useSkipBlockExpansion.ts │ │ │ ├── useHashNavigation.ts │ │ │ ├── useReviewActions.ts │ │ │ ├── useCommentActions.ts │ │ │ ├── useKeyboardNavigation.ts │ │ │ └── useDiffLoader.ts │ │ ├── telemetry.tsx │ │ └── tabs.tsx │ ├── index.html │ ├── index.tsx │ ├── components │ │ ├── pr-header.tsx │ │ ├── file-header.tsx │ │ └── bookmarklet.tsx │ └── lib │ │ └── diff.ts ├── api │ ├── client.ts │ ├── types.ts │ └── api.ts ├── types │ └── assets.d.ts ├── node │ └── main.ts ├── index.ts └── electron │ └── main.ts ├── docs └── screenshots │ ├── overview.png │ ├── search.png │ ├── filtering.png │ ├── viewed-files.png │ └── keybind-driven.png ├── .prettierrc ├── vercel.json ├── eslint.config.mjs ├── components.json ├── .gitignore ├── .github ├── workflows │ ├── ci.yml │ └── release.yml └── actions │ └── setup-mux │ └── action.yml ├── tsconfig.json ├── scripts ├── build-vercel.ts ├── check_pr_reviews.sh ├── build-electron.ts ├── build-browser.ts ├── check_codex_comments.sh ├── electron-dev.ts ├── generate-icons.ts ├── extract_pr_logs.sh └── wait_pr_checks.sh ├── README.md ├── AGENTS.md ├── electron-builder.json └── package.json /bunfig.toml: -------------------------------------------------------------------------------- 1 | [serve.static] 2 | plugins = ["bun-plugin-tailwind"] 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/browser/ui/diff/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./parse"; 2 | export * from "./guess-lang"; 3 | -------------------------------------------------------------------------------- /docs/screenshots/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coder/pulldash/HEAD/docs/screenshots/overview.png -------------------------------------------------------------------------------- /docs/screenshots/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coder/pulldash/HEAD/docs/screenshots/search.png -------------------------------------------------------------------------------- /docs/screenshots/filtering.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coder/pulldash/HEAD/docs/screenshots/filtering.png -------------------------------------------------------------------------------- /docs/screenshots/viewed-files.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coder/pulldash/HEAD/docs/screenshots/viewed-files.png -------------------------------------------------------------------------------- /docs/screenshots/keybind-driven.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coder/pulldash/HEAD/docs/screenshots/keybind-driven.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "es5", 4 | "singleQuote": false, 5 | "printWidth": 80, 6 | "tabWidth": 2, 7 | "useTabs": false 8 | } 9 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://openapi.vercel.sh/vercel.json", 3 | "installCommand": "bun install", 4 | "buildCommand": "bun run ./scripts/build-vercel.ts", 5 | "bunVersion": "1.3" 6 | } 7 | -------------------------------------------------------------------------------- /src/browser/cn.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /src/api/client.ts: -------------------------------------------------------------------------------- 1 | import { hc } from "hono/client"; 2 | import type { AppType } from "./api"; 3 | 4 | export const createApiClient = (baseUrl: string = window.location.origin) => { 5 | return hc(baseUrl); 6 | }; 7 | 8 | export type ApiClient = ReturnType; 9 | -------------------------------------------------------------------------------- /src/browser/contexts/pr-review/useIsLineCommenting.ts: -------------------------------------------------------------------------------- 1 | import { usePRReviewSelector } from "."; 2 | 3 | /** Check if a specific line is being commented on */ 4 | export function useIsLineCommenting(lineNumber: number): boolean { 5 | return usePRReviewSelector((s) => s.commentingOnLine?.line === lineNumber); 6 | } 7 | -------------------------------------------------------------------------------- /src/browser/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/browser/cn"; 2 | 3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) { 4 | return ( 5 |
10 | ); 11 | } 12 | 13 | export { Skeleton }; 14 | -------------------------------------------------------------------------------- /src/browser/contexts/pr-review/useIsLineFocused.ts: -------------------------------------------------------------------------------- 1 | import { usePRReviewSelector } from "."; 2 | 3 | /** Check if a specific line is focused (for DiffLine component) */ 4 | export function useIsLineFocused( 5 | lineNumber: number, 6 | side: "old" | "new" 7 | ): boolean { 8 | return usePRReviewSelector( 9 | (s) => s.focusedLine === lineNumber && s.focusedLineSide === side 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/browser/contexts/pr-review/useIsLineInCommentRange.ts: -------------------------------------------------------------------------------- 1 | import { usePRReviewSelector } from "."; 2 | 3 | /** Check if a specific line is within a comment's range (for multi-line comments) */ 4 | export function useIsLineInCommentRange(lineNumber: number): boolean { 5 | // Use pre-computed lookup for O(1) check (Fix 3) 6 | return usePRReviewSelector((s) => { 7 | if (!s.selectedFile) return false; 8 | const lookup = s.commentRangeLookup[s.selectedFile]; 9 | return lookup?.has(lineNumber) ?? false; 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /src/browser/contexts/pr-review/useIsLineInCommentingRange.ts: -------------------------------------------------------------------------------- 1 | import { usePRReviewSelector } from "."; 2 | 3 | /** Check if a specific line is in the commenting range */ 4 | export function useIsLineInCommentingRange(lineNumber: number): boolean { 5 | return usePRReviewSelector((s) => { 6 | if (!s.commentingOnLine) return false; 7 | const start = s.commentingOnLine.startLine ?? s.commentingOnLine.line; 8 | const end = s.commentingOnLine.line; 9 | return lineNumber >= start && lineNumber <= end; 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /src/browser/contexts/pr-review/useCommentCountsByFile.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { usePRReviewSelector } from "."; 3 | 4 | /** Get comment counts per file */ 5 | export function useCommentCountsByFile(): Record { 6 | const comments = usePRReviewSelector((s) => s.comments); 7 | return useMemo(() => { 8 | const counts: Record = {}; 9 | for (const c of comments) { 10 | counts[c.path] = (counts[c.path] || 0) + 1; 11 | } 12 | return counts; 13 | }, [comments]); 14 | } 15 | -------------------------------------------------------------------------------- /src/browser/contexts/pr-review/useIsCurrentFileLoading.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { usePRReviewSelector } from "."; 3 | 4 | /** Check if current file is loading */ 5 | export function useIsCurrentFileLoading(): boolean { 6 | const selectedFile = usePRReviewSelector((s) => s.selectedFile); 7 | const loadingFiles = usePRReviewSelector((s) => s.loadingFiles); 8 | return useMemo(() => { 9 | if (!selectedFile) return false; 10 | return loadingFiles.has(selectedFile); 11 | }, [selectedFile, loadingFiles]); 12 | } 13 | -------------------------------------------------------------------------------- /src/browser/contexts/pr-review/useCurrentDiff.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { usePRReviewSelector, type ParsedDiff } from "."; 3 | 4 | /** Get the current file's diff */ 5 | export function useCurrentDiff(): ParsedDiff | null { 6 | const selectedFile = usePRReviewSelector((s) => s.selectedFile); 7 | const loadedDiffs = usePRReviewSelector((s) => s.loadedDiffs); 8 | return useMemo(() => { 9 | if (!selectedFile) return null; 10 | return loadedDiffs[selectedFile] ?? null; 11 | }, [selectedFile, loadedDiffs]); 12 | } 13 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig, globalIgnores } from "eslint/config"; 2 | import nextVitals from "eslint-config-next/core-web-vitals"; 3 | import nextTs from "eslint-config-next/typescript"; 4 | 5 | const eslintConfig = defineConfig([ 6 | ...nextVitals, 7 | ...nextTs, 8 | // Override default ignores of eslint-config-next. 9 | globalIgnores([ 10 | // Default ignores of eslint-config-next: 11 | ".next/**", 12 | "out/**", 13 | "build/**", 14 | "next-env.d.ts", 15 | ]), 16 | ]); 17 | 18 | export default eslintConfig; 19 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/browser/index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "iconLibrary": "lucide", 14 | "aliases": { 15 | "components": "@/browser/components", 16 | "utils": "@/browser/cn", 17 | "ui": "@/browser/ui", 18 | "lib": "@/browser", 19 | "hooks": "@/browser/hooks" 20 | }, 21 | "registries": {} 22 | } 23 | -------------------------------------------------------------------------------- /src/browser/contexts/pr-review/usePendingCommentCountsByFile.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { usePRReviewSelector } from "."; 3 | 4 | /** Get pending comments count per file */ 5 | export function usePendingCommentCountsByFile(): Record { 6 | const pendingComments = usePRReviewSelector((s) => s.pendingComments); 7 | return useMemo(() => { 8 | const counts: Record = {}; 9 | for (const c of pendingComments) { 10 | counts[c.path] = (counts[c.path] || 0) + 1; 11 | } 12 | return counts; 13 | }, [pendingComments]); 14 | } 15 | -------------------------------------------------------------------------------- /src/browser/contexts/pr-review/useCurrentFile.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import type { PullRequestFile } from "@/api/types"; 3 | import { usePRReviewSelector } from "."; 4 | 5 | /** Get the current file object */ 6 | export function useCurrentFile(): PullRequestFile | null { 7 | const selectedFile = usePRReviewSelector((s) => s.selectedFile); 8 | const files = usePRReviewSelector((s) => s.files); 9 | return useMemo(() => { 10 | if (!selectedFile) return null; 11 | return files.find((f) => f.filename === selectedFile) ?? null; 12 | }, [selectedFile, files]); 13 | } 14 | -------------------------------------------------------------------------------- /src/browser/contexts/pr-review/useCommentingRange.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { usePRReviewSelector } from "."; 3 | 4 | /** 5 | * Get commenting range computed once at parent level. 6 | */ 7 | export function useCommentingRange(): { start: number; end: number } | null { 8 | const commentingOnLine = usePRReviewSelector((s) => s.commentingOnLine); 9 | 10 | return useMemo(() => { 11 | if (!commentingOnLine) return null; 12 | const start = commentingOnLine.startLine ?? commentingOnLine.line; 13 | const end = commentingOnLine.line; 14 | return { start, end }; 15 | }, [commentingOnLine]); 16 | } 17 | -------------------------------------------------------------------------------- /src/browser/contexts/pr-review/useCurrentUserLoader.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useGitHubStore, useGitHubSelector } from "@/browser/contexts/github"; 3 | import { usePRReviewStore } from "."; 4 | 5 | export function useCurrentUserLoader() { 6 | const store = usePRReviewStore(); 7 | const github = useGitHubStore(); 8 | const ready = useGitHubSelector((s) => s.ready); 9 | const currentUser = github.getState().currentUser?.login ?? null; 10 | 11 | useEffect(() => { 12 | if (ready && currentUser) { 13 | store.setCurrentUser(currentUser); 14 | } 15 | }, [ready, currentUser, store]); 16 | } 17 | -------------------------------------------------------------------------------- /src/browser/contexts/pr-review/useCommentRangeLookup.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { usePRReviewSelector } from "."; 3 | 4 | /** 5 | * Get pre-computed comment range lookup for current file. 6 | * Returns a Set for O(1) lookups. 7 | */ 8 | export function useCommentRangeLookup(): Set | null { 9 | const selectedFile = usePRReviewSelector((s) => s.selectedFile); 10 | const commentRangeLookup = usePRReviewSelector((s) => s.commentRangeLookup); 11 | 12 | return useMemo(() => { 13 | if (!selectedFile) return null; 14 | return commentRangeLookup[selectedFile] ?? null; 15 | }, [selectedFile, commentRangeLookup]); 16 | } 17 | -------------------------------------------------------------------------------- /src/browser/contexts/pr-review/useCurrentFileComments.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import type { ReviewComment } from "@/api/types"; 3 | import { usePRReviewSelector } from "."; 4 | 5 | const EMPTY_COMMENTS: ReviewComment[] = []; 6 | 7 | /** Get comments for current file */ 8 | export function useCurrentFileComments(): ReviewComment[] { 9 | const selectedFile = usePRReviewSelector((s) => s.selectedFile); 10 | const comments = usePRReviewSelector((s) => s.comments); 11 | return useMemo(() => { 12 | if (!selectedFile) return EMPTY_COMMENTS; 13 | return comments.filter((c) => c.path === selectedFile); 14 | }, [selectedFile, comments]); 15 | } 16 | -------------------------------------------------------------------------------- /src/browser/contexts/pr-review/useCommentsByFile.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import type { ReviewComment } from "@/api/types"; 3 | import { usePRReviewSelector } from "."; 4 | 5 | /** Get comments grouped by file path */ 6 | export function useCommentsByFile(): Record { 7 | const comments = usePRReviewSelector((s) => s.comments); 8 | return useMemo(() => { 9 | const grouped: Record = {}; 10 | for (const comment of comments) { 11 | if (!grouped[comment.path]) grouped[comment.path] = []; 12 | grouped[comment.path].push(comment); 13 | } 14 | return grouped; 15 | }, [comments]); 16 | } 17 | -------------------------------------------------------------------------------- /src/browser/contexts/pr-review/useCurrentFilePendingComments.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { usePRReviewSelector, type LocalPendingComment } from "."; 3 | 4 | const EMPTY_PENDING_COMMENTS: LocalPendingComment[] = []; 5 | 6 | /** Get pending comments for current file */ 7 | export function useCurrentFilePendingComments(): LocalPendingComment[] { 8 | const selectedFile = usePRReviewSelector((s) => s.selectedFile); 9 | const pendingComments = usePRReviewSelector((s) => s.pendingComments); 10 | return useMemo(() => { 11 | if (!selectedFile) return EMPTY_PENDING_COMMENTS; 12 | return pendingComments.filter((c) => c.path === selectedFile); 13 | }, [selectedFile, pendingComments]); 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | 43 | dist/ 44 | release/ 45 | public/ 46 | .env*.local 47 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | ci: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup Bun 18 | uses: oven-sh/setup-bun@v2 19 | with: 20 | bun-version: latest 21 | 22 | - name: Install dependencies 23 | run: bun install 24 | 25 | - name: Check formatting 26 | run: bun run fmt:check 27 | 28 | - name: Typecheck 29 | run: bun run typecheck 30 | 31 | - name: Lint 32 | run: bun run lint 33 | 34 | - name: Test 35 | run: bun run test 36 | -------------------------------------------------------------------------------- /src/browser/contexts/pr-review/useSelectionRange.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { usePRReviewSelector } from "."; 3 | 4 | /** Get the selection range for line highlighting */ 5 | export function useSelectionRange(): { start: number; end: number } | null { 6 | const focusedLine = usePRReviewSelector((s) => s.focusedLine); 7 | const selectionAnchor = usePRReviewSelector((s) => s.selectionAnchor); 8 | return useMemo(() => { 9 | if (!focusedLine) return null; 10 | if (!selectionAnchor) return { start: focusedLine, end: focusedLine }; 11 | return { 12 | start: Math.min(focusedLine, selectionAnchor), 13 | end: Math.max(focusedLine, selectionAnchor), 14 | }; 15 | }, [focusedLine, selectionAnchor]); 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "react-jsx", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": [ 26 | "next-env.d.ts", 27 | "**/*.ts", 28 | "**/*.tsx", 29 | ".next/types/**/*.ts", 30 | ".next/dev/types/**/*.ts", 31 | "**/*.mts" 32 | ], 33 | "exclude": ["node_modules"] 34 | } 35 | -------------------------------------------------------------------------------- /src/types/assets.d.ts: -------------------------------------------------------------------------------- 1 | // Type declarations for asset imports 2 | 3 | declare module "*.svg" { 4 | const content: string; 5 | export default content; 6 | } 7 | 8 | declare module "*.png" { 9 | const content: string; 10 | export default content; 11 | } 12 | 13 | declare module "*.jpg" { 14 | const content: string; 15 | export default content; 16 | } 17 | 18 | declare module "*.jpeg" { 19 | const content: string; 20 | export default content; 21 | } 22 | 23 | declare module "*.gif" { 24 | const content: string; 25 | export default content; 26 | } 27 | 28 | declare module "*.webp" { 29 | const content: string; 30 | export default content; 31 | } 32 | 33 | declare module "*.ico" { 34 | const content: string; 35 | export default content; 36 | } 37 | 38 | declare module "*.css" { 39 | const content: string; 40 | export default content; 41 | } 42 | -------------------------------------------------------------------------------- /src/browser/contexts/pr-review/useIsLineInSelection.ts: -------------------------------------------------------------------------------- 1 | import { usePRReviewSelector } from "."; 2 | 3 | /** Check if a specific line is in the selection range */ 4 | export function useIsLineInSelection( 5 | lineNumber: number, 6 | side: "old" | "new" 7 | ): boolean { 8 | return usePRReviewSelector((s) => { 9 | if (!s.focusedLine || !s.focusedLineSide) return false; 10 | // Must match side 11 | if (s.focusedLineSide !== side) return false; 12 | if (!s.selectionAnchor) return s.focusedLine === lineNumber; 13 | // For selection ranges, we currently only support same-side selection 14 | if (s.selectionAnchorSide !== side) 15 | return s.focusedLine === lineNumber && s.focusedLineSide === side; 16 | const start = Math.min(s.focusedLine, s.selectionAnchor); 17 | const end = Math.max(s.focusedLine, s.selectionAnchor); 18 | return lineNumber >= start && lineNumber <= end; 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /src/node/main.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "@hono/node-server"; 2 | import { serveStatic } from "@hono/node-server/serve-static"; 3 | import api from "@/api/api"; 4 | import { resolve } from "path"; 5 | import { Hono } from "hono"; 6 | import { readFileSync } from "fs"; 7 | 8 | const app = new Hono(); 9 | 10 | const distDir = resolve(__dirname, "..", "..", "dist", "browser"); 11 | 12 | console.log("distDir", distDir); 13 | 14 | // API routes first 15 | app.route("/", api); 16 | 17 | // Static files 18 | app.use("/*", serveStatic({ root: distDir })); 19 | 20 | // SPA fallback - serve index.html for client-side routing 21 | app.get("*", (c) => { 22 | if (c.req.path === "/favicon.ico") { 23 | return c.body(null, 404); 24 | } 25 | const indexPath = resolve(distDir, "index.html"); 26 | const html = readFileSync(indexPath, "utf-8"); 27 | return c.html(html.replaceAll("./", "/")); 28 | }); 29 | 30 | serve( 31 | { 32 | fetch: app.fetch, 33 | port: 3002, 34 | }, 35 | (address) => { 36 | console.log(`🚀 pulldash running at http://localhost:${address.port}`); 37 | } 38 | ); 39 | -------------------------------------------------------------------------------- /scripts/build-vercel.ts: -------------------------------------------------------------------------------- 1 | import { $ } from "bun"; 2 | import { cp, mkdir, readdir } from "fs/promises"; 3 | import { resolve } from "path"; 4 | 5 | const rootDir = resolve(import.meta.dir, ".."); 6 | const publicDir = resolve(rootDir, "public"); 7 | const browserDistDir = resolve(rootDir, "dist/browser"); 8 | 9 | // Clean and create public directory 10 | await $`rm -rf ${publicDir}`; 11 | await mkdir(publicDir, { recursive: true }); 12 | 13 | // Build browser files 14 | console.log("Building browser..."); 15 | await import("./build-browser"); 16 | 17 | // Copy browser build contents to public (served by Vercel CDN) 18 | const browserFiles = await readdir(browserDistDir); 19 | for (const file of browserFiles) { 20 | await cp(resolve(browserDistDir, file), resolve(publicDir, file), { 21 | recursive: true, 22 | }); 23 | } 24 | console.log("Copied browser files to public/"); 25 | 26 | // Vercel auto-detects and compiles src/index.ts as the Hono server 27 | // SPA fallback is handled by rewrites in vercel.json 28 | console.log("✅ Vercel build complete!"); 29 | console.log(" Static files: public/ (CDN)"); 30 | console.log(" Server: src/index.ts (auto-compiled by Vercel)"); 31 | -------------------------------------------------------------------------------- /src/browser/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; 3 | import { CheckIcon } from "lucide-react"; 4 | 5 | import { cn } from "@/browser/cn"; 6 | 7 | function Checkbox({ 8 | className, 9 | ...props 10 | }: React.ComponentProps) { 11 | return ( 12 | 20 | 24 | 25 | 26 | 27 | ); 28 | } 29 | 30 | export { Checkbox }; 31 | -------------------------------------------------------------------------------- /src/browser/ui/hover-card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as HoverCardPrimitive from "@radix-ui/react-hover-card"; 3 | 4 | import { cn } from "@/browser/cn"; 5 | 6 | const HoverCard = HoverCardPrimitive.Root; 7 | 8 | const HoverCardTrigger = HoverCardPrimitive.Trigger; 9 | 10 | const HoverCardContent = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 14 | 15 | 25 | 26 | )); 27 | HoverCardContent.displayName = HoverCardPrimitive.Content.displayName; 28 | 29 | export { HoverCard, HoverCardTrigger, HoverCardContent }; 30 | -------------------------------------------------------------------------------- /.github/actions/setup-mux/action.yml: -------------------------------------------------------------------------------- 1 | name: "Setup Mux" 2 | description: "Setup Bun and install dependencies with caching" 3 | runs: 4 | using: "composite" 5 | steps: 6 | - name: Setup Bun 7 | uses: oven-sh/setup-bun@v2 8 | with: 9 | bun-version: latest 10 | 11 | - name: Get Bun version 12 | id: bun-version 13 | shell: bash 14 | run: echo "version=$(bun --version)" >> $GITHUB_OUTPUT 15 | 16 | - name: Cache node_modules 17 | id: cache-node-modules 18 | uses: actions/cache@v4 19 | with: 20 | path: node_modules 21 | key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.bun-version.outputs.version }}-node-modules-${{ hashFiles('**/bun.lock') }} 22 | restore-keys: | 23 | ${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.bun-version.outputs.version }}-node-modules- 24 | 25 | - name: Cache bun install cache 26 | if: steps.cache-node-modules.outputs.cache-hit != 'true' 27 | id: cache-bun-install 28 | uses: actions/cache@v4 29 | with: 30 | path: ~/.bun/install/cache 31 | key: ${{ runner.os }}-bun-cache-${{ hashFiles('**/bun.lock') }} 32 | restore-keys: | 33 | ${{ runner.os }}-bun-cache- 34 | 35 | - name: Install dependencies 36 | if: steps.cache-node-modules.outputs.cache-hit != 'true' 37 | shell: bash 38 | run: bun install --frozen-lockfile 39 | -------------------------------------------------------------------------------- /src/browser/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Pulldash – Fast, Filterable PR Review 8 | 12 | 13 | 17 | 18 | 19 | 20 | 24 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
37 | 38 | 39 | -------------------------------------------------------------------------------- /src/browser/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as PopoverPrimitive from "@radix-ui/react-popover"; 3 | 4 | import { cn } from "@/browser/cn"; 5 | 6 | const Popover = PopoverPrimitive.Root; 7 | 8 | const PopoverTrigger = PopoverPrimitive.Trigger; 9 | 10 | const PopoverAnchor = PopoverPrimitive.Anchor; 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )); 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName; 30 | 31 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; 32 | -------------------------------------------------------------------------------- /src/browser/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import { BrowserRouter, Routes, Route } from "react-router-dom"; 3 | import { AuthProvider } from "./contexts/auth"; 4 | import { GitHubProvider } from "./contexts/github"; 5 | import { TelemetryProvider } from "./contexts/telemetry"; 6 | import { TabProvider } from "./contexts/tabs"; 7 | import { CommandPaletteProvider } from "./components/command-palette"; 8 | import { AppShell } from "./components/app-shell"; 9 | import { WelcomeDialog } from "./components/welcome-dialog"; 10 | import "./index.css"; 11 | 12 | createRoot(document.getElementById("app")!).render( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {/* Home */} 21 | } /> 22 | {/* PR review - URL like /:owner/:repo/pull/:number */} 23 | } 26 | /> 27 | 28 | {/* Auth dialog - shown when not authenticated */} 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | -------------------------------------------------------------------------------- /src/browser/contexts/pr-review/useThreadActions.ts: -------------------------------------------------------------------------------- 1 | import { useGitHub } from "@/browser/contexts/github"; 2 | import { usePRReviewStore } from "."; 3 | 4 | export function useThreadActions() { 5 | const store = usePRReviewStore(); 6 | const github = useGitHub(); 7 | 8 | const resolveThread = async (threadId: string) => { 9 | try { 10 | await github.resolveThread(threadId); 11 | // Update local state - mark all comments in this thread as resolved 12 | const state = store.getSnapshot(); 13 | const updatedComments = state.comments.map((c) => 14 | c.pull_request_review_thread_id === threadId 15 | ? { ...c, is_resolved: true } 16 | : c 17 | ); 18 | store.setComments(updatedComments); 19 | } catch (error) { 20 | console.error("Failed to resolve thread:", error); 21 | } 22 | }; 23 | 24 | const unresolveThread = async (threadId: string) => { 25 | try { 26 | await github.unresolveThread(threadId); 27 | // Update local state - mark all comments in this thread as unresolved 28 | const state = store.getSnapshot(); 29 | const updatedComments = state.comments.map((c) => 30 | c.pull_request_review_thread_id === threadId 31 | ? { ...c, is_resolved: false } 32 | : c 33 | ); 34 | store.setComments(updatedComments); 35 | } catch (error) { 36 | console.error("Failed to unresolve thread:", error); 37 | } 38 | }; 39 | 40 | return { resolveThread, unresolveThread }; 41 | } 42 | -------------------------------------------------------------------------------- /scripts/check_pr_reviews.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Check for unresolved PR review comments 3 | # Usage: ./scripts/check_pr_reviews.sh 4 | # Exits 0 if all resolved, 1 if unresolved comments exist 5 | 6 | set -e 7 | 8 | if [ -z "$1" ]; then 9 | echo "Usage: $0 " 10 | exit 1 11 | fi 12 | 13 | PR_NUMBER="$1" 14 | 15 | # Query for unresolved review threads 16 | UNRESOLVED=$(gh api graphql -f query=" 17 | { 18 | repository(owner: \"coder\", name: \"mux\") { 19 | pullRequest(number: $PR_NUMBER) { 20 | reviewThreads(first: 100) { 21 | nodes { 22 | id 23 | isResolved 24 | comments(first: 1) { 25 | nodes { 26 | author { login } 27 | body 28 | diffHunk 29 | commit { oid } 30 | } 31 | } 32 | } 33 | } 34 | } 35 | } 36 | }" --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false) | {thread_id: .id, user: .comments.nodes[0].author.login, body: .comments.nodes[0].body, diff_hunk: .comments.nodes[0].diffHunk, commit_id: .comments.nodes[0].commit.oid}') 37 | 38 | if [ -n "$UNRESOLVED" ]; then 39 | echo "❌ Unresolved review comments found:" 40 | echo "$UNRESOLVED" | jq -r '" \(.user): \(.body)"' 41 | echo "" 42 | echo "To resolve a comment thread, use:" 43 | echo "$UNRESOLVED" | jq -r '" ./scripts/resolve_pr_comment.sh \(.thread_id)"' 44 | echo "" 45 | echo "View PR: https://github.com/coder/mux/pull/$PR_NUMBER" 46 | exit 1 47 | fi 48 | 49 | echo "✅ All review comments resolved" 50 | exit 0 51 | -------------------------------------------------------------------------------- /src/browser/contexts/pr-review/useFileCopyActions.ts: -------------------------------------------------------------------------------- 1 | import { useGitHub } from "@/browser/contexts/github"; 2 | import { usePRReviewSelector } from "."; 3 | 4 | export function useFileCopyActions() { 5 | const github = useGitHub(); 6 | const owner = usePRReviewSelector((s) => s.owner); 7 | const repo = usePRReviewSelector((s) => s.repo); 8 | const pr = usePRReviewSelector((s) => s.pr); 9 | const files = usePRReviewSelector((s) => s.files); 10 | 11 | const copyDiff = (filename: string) => { 12 | const file = files.find((f) => f.filename === filename); 13 | if (file?.patch) { 14 | navigator.clipboard.writeText(file.patch); 15 | } 16 | }; 17 | 18 | const copyFile = async (filename: string) => { 19 | try { 20 | const content = await github.getFileContent( 21 | owner, 22 | repo, 23 | filename, 24 | pr.head.sha 25 | ); 26 | await navigator.clipboard.writeText(content); 27 | } catch (error) { 28 | console.error("Failed to copy file:", error); 29 | } 30 | }; 31 | 32 | const copyMainVersion = async (filename: string) => { 33 | try { 34 | const file = files.find((f) => f.filename === filename); 35 | const basePath = file?.previous_filename || filename; 36 | const content = await github.getFileContent( 37 | owner, 38 | repo, 39 | basePath, 40 | pr.base.sha 41 | ); 42 | await navigator.clipboard.writeText(content); 43 | } catch (error) { 44 | console.error("Failed to copy base version:", error); 45 | } 46 | }; 47 | 48 | return { copyDiff, copyFile, copyMainVersion }; 49 | } 50 | -------------------------------------------------------------------------------- /src/browser/ui/radio-group.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; 3 | import { CircleIcon } from "lucide-react"; 4 | 5 | import { cn } from "@/browser/cn"; 6 | 7 | function RadioGroup({ 8 | className, 9 | ...props 10 | }: React.ComponentProps) { 11 | return ( 12 | 17 | ); 18 | } 19 | 20 | function RadioGroupItem({ 21 | className, 22 | ...props 23 | }: React.ComponentProps) { 24 | return ( 25 | 33 | 37 | 38 | 39 | 40 | ); 41 | } 42 | 43 | export { RadioGroup, RadioGroupItem }; 44 | -------------------------------------------------------------------------------- /src/browser/contexts/pr-review/useSelectionState.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { usePRReviewSelector } from "."; 3 | 4 | export interface SelectionState { 5 | focusedLine: number | null; 6 | focusedLineSide: "old" | "new" | null; 7 | selectionAnchor: number | null; 8 | selectionAnchorSide: "old" | "new" | null; 9 | selectionStart: number | null; 10 | selectionEnd: number | null; 11 | } 12 | 13 | /** 14 | * Get the complete selection state computed once at the parent level. 15 | * This replaces per-line subscriptions with a single subscription (Fix 1). 16 | */ 17 | export function useSelectionState(): SelectionState { 18 | const focusedLine = usePRReviewSelector((s) => s.focusedLine); 19 | const focusedLineSide = usePRReviewSelector((s) => s.focusedLineSide); 20 | const selectionAnchor = usePRReviewSelector((s) => s.selectionAnchor); 21 | const selectionAnchorSide = usePRReviewSelector((s) => s.selectionAnchorSide); 22 | 23 | return useMemo(() => { 24 | let selectionStart: number | null = null; 25 | let selectionEnd: number | null = null; 26 | 27 | if (focusedLine !== null) { 28 | if (selectionAnchor !== null) { 29 | selectionStart = Math.min(focusedLine, selectionAnchor); 30 | selectionEnd = Math.max(focusedLine, selectionAnchor); 31 | } else { 32 | selectionStart = focusedLine; 33 | selectionEnd = focusedLine; 34 | } 35 | } 36 | 37 | return { 38 | focusedLine, 39 | focusedLineSide, 40 | selectionAnchor, 41 | selectionAnchorSide, 42 | selectionStart, 43 | selectionEnd, 44 | }; 45 | }, [focusedLine, focusedLineSide, selectionAnchor, selectionAnchorSide]); 46 | } 47 | -------------------------------------------------------------------------------- /src/browser/contexts/pr-review/usePendingReviewLoader.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useGitHubStore, useGitHubSelector } from "@/browser/contexts/github"; 3 | import { 4 | usePRReviewStore, 5 | usePRReviewSelector, 6 | type LocalPendingComment, 7 | } from "."; 8 | 9 | export function usePendingReviewLoader() { 10 | const store = usePRReviewStore(); 11 | const github = useGitHubStore(); 12 | const ready = useGitHubSelector((s) => s.ready); 13 | const owner = usePRReviewSelector((s) => s.owner); 14 | const repo = usePRReviewSelector((s) => s.repo); 15 | const pr = usePRReviewSelector((s) => s.pr); 16 | 17 | useEffect(() => { 18 | if (!ready) return; 19 | 20 | const fetchPendingReview = async () => { 21 | try { 22 | const result = await github.getPendingReview(owner, repo, pr.number); 23 | if (!result) return; // No pending review 24 | 25 | // Store the review node ID for submission 26 | store.setPendingReviewNodeId(result.id); 27 | 28 | // Convert to local comments 29 | const localComments: LocalPendingComment[] = result.comments.nodes.map( 30 | (c) => ({ 31 | id: `github-${c.databaseId}`, 32 | nodeId: c.id, 33 | databaseId: c.databaseId, 34 | path: c.path, 35 | line: c.line, 36 | start_line: c.startLine || undefined, 37 | body: c.body, 38 | side: "RIGHT" as const, 39 | }) 40 | ); 41 | 42 | store.setPendingComments(localComments); 43 | } catch (error) { 44 | console.error("Failed to fetch pending review:", error); 45 | } 46 | }; 47 | 48 | fetchPendingReview(); 49 | }, [github, owner, repo, pr.number, store]); 50 | } 51 | -------------------------------------------------------------------------------- /src/browser/contexts/pr-review/useSelectionBoundary.ts: -------------------------------------------------------------------------------- 1 | import { usePRReviewSelector } from "."; 2 | 3 | /** 4 | * Get selection boundary info for a specific line (for drawing selection outline). 5 | * Uses a single selector that returns primitives to avoid re-renders of unaffected lines. 6 | */ 7 | export function useSelectionBoundary( 8 | lineNumber: number, 9 | side: "old" | "new" 10 | ): { isFirst: boolean; isLast: boolean; isInSelection: boolean } { 11 | // Use separate selectors that return booleans - only re-renders when THIS line's state changes 12 | const isInSelection = usePRReviewSelector((s) => { 13 | if (!s.focusedLine || !s.focusedLineSide) return false; 14 | if (s.focusedLineSide !== side) return false; 15 | if (!s.selectionAnchor || s.selectionAnchorSide !== side) 16 | return s.focusedLine === lineNumber; 17 | const start = Math.min(s.focusedLine, s.selectionAnchor); 18 | const end = Math.max(s.focusedLine, s.selectionAnchor); 19 | return lineNumber >= start && lineNumber <= end; 20 | }); 21 | 22 | const isFirst = usePRReviewSelector((s) => { 23 | if (!s.focusedLine || !s.focusedLineSide) return false; 24 | if (s.focusedLineSide !== side) return false; 25 | if (!s.selectionAnchor || s.selectionAnchorSide !== side) 26 | return s.focusedLine === lineNumber; 27 | return lineNumber === Math.min(s.focusedLine, s.selectionAnchor); 28 | }); 29 | 30 | const isLast = usePRReviewSelector((s) => { 31 | if (!s.focusedLine || !s.focusedLineSide) return false; 32 | if (s.focusedLineSide !== side) return false; 33 | if (!s.selectionAnchor || s.selectionAnchorSide !== side) 34 | return s.focusedLine === lineNumber; 35 | return lineNumber === Math.max(s.focusedLine, s.selectionAnchor); 36 | }); 37 | 38 | return { isFirst, isLast, isInSelection }; 39 | } 40 | -------------------------------------------------------------------------------- /src/browser/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"; 3 | 4 | import { cn } from "@/browser/cn"; 5 | 6 | function TooltipProvider({ 7 | delayDuration = 0, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 16 | ); 17 | } 18 | 19 | function Tooltip({ 20 | ...props 21 | }: React.ComponentProps) { 22 | return ( 23 | 24 | 25 | 26 | ); 27 | } 28 | 29 | function TooltipTrigger({ 30 | ...props 31 | }: React.ComponentProps) { 32 | return ; 33 | } 34 | 35 | function TooltipContent({ 36 | className, 37 | sideOffset = 4, 38 | children, 39 | ...props 40 | }: React.ComponentProps) { 41 | return ( 42 | 43 | 52 | {children} 53 | 54 | 55 | ); 56 | } 57 | 58 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; 59 | -------------------------------------------------------------------------------- /src/api/types.ts: -------------------------------------------------------------------------------- 1 | import { components } from "@octokit/openapi-types"; 2 | 3 | // REST API types - re-exported from Octokit schemas 4 | // Extended types include body_html from GitHub's HTML media type (application/vnd.github.html+json) 5 | export type PullRequest = components["schemas"]["pull-request"] & { 6 | body_html?: string; 7 | }; 8 | export type PullRequestFile = components["schemas"]["diff-entry"]; 9 | export type ReviewComment = 10 | components["schemas"]["pull-request-review-comment"] & { 11 | // Thread resolution info (enriched from GraphQL) 12 | pull_request_review_thread_id?: string; 13 | is_resolved?: boolean; 14 | resolved_by?: { login: string; avatar_url: string } | null; 15 | /** Pre-rendered HTML with signed attachment URLs from GitHub's API */ 16 | body_html?: string; 17 | }; 18 | export type Review = components["schemas"]["pull-request-review"] & { 19 | body_html?: string; 20 | }; 21 | export type CheckRun = components["schemas"]["check-run"]; 22 | export type CombinedStatus = components["schemas"]["combined-commit-status"]; 23 | export type IssueComment = components["schemas"]["issue-comment"] & { 24 | body_html?: string; 25 | }; 26 | export type GitHubUser = components["schemas"]["public-user"]; 27 | 28 | // GraphQL-only types (not in REST API schemas) 29 | export interface PendingReviewComment { 30 | path: string; 31 | line: number; 32 | start_line?: number; 33 | body: string; 34 | side: "LEFT" | "RIGHT"; 35 | start_side?: "LEFT" | "RIGHT"; 36 | } 37 | 38 | export interface ReviewThread { 39 | id: string; 40 | isResolved: boolean; 41 | resolvedBy: { login: string; avatarUrl: string } | null; 42 | comments: Array<{ 43 | id: string; 44 | databaseId: number; 45 | body: string; 46 | /** Pre-rendered HTML with signed attachment URLs from GitHub's GraphQL API */ 47 | bodyHTML?: string; 48 | path: string; 49 | line: number | null; 50 | originalLine: number | null; 51 | startLine: number | null; 52 | author: { login: string; avatarUrl: string } | null; 53 | createdAt: string; 54 | updatedAt: string; 55 | replyTo: { databaseId: number } | null; 56 | }>; 57 | } 58 | -------------------------------------------------------------------------------- /src/browser/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as TabsPrimitive from "@radix-ui/react-tabs"; 3 | 4 | import { cn } from "@/browser/cn"; 5 | 6 | function Tabs({ 7 | className, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 16 | ); 17 | } 18 | 19 | function TabsList({ 20 | className, 21 | ...props 22 | }: React.ComponentProps) { 23 | return ( 24 | 32 | ); 33 | } 34 | 35 | function TabsTrigger({ 36 | className, 37 | ...props 38 | }: React.ComponentProps) { 39 | return ( 40 | 48 | ); 49 | } 50 | 51 | function TabsContent({ 52 | className, 53 | ...props 54 | }: React.ComponentProps) { 55 | return ( 56 | 61 | ); 62 | } 63 | 64 | export { Tabs, TabsList, TabsTrigger, TabsContent }; 65 | -------------------------------------------------------------------------------- /src/browser/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/browser/cn"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 15 | outline: 16 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: 20 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 25 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 26 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 27 | icon: "size-9", 28 | "icon-sm": "size-8", 29 | "icon-lg": "size-10", 30 | }, 31 | }, 32 | defaultVariants: { 33 | variant: "default", 34 | size: "default", 35 | }, 36 | } 37 | ); 38 | 39 | function Button({ 40 | className, 41 | variant, 42 | size, 43 | asChild = false, 44 | ...props 45 | }: React.ComponentProps<"button"> & 46 | VariantProps & { 47 | asChild?: boolean; 48 | }) { 49 | const Comp = asChild ? Slot : "button"; 50 | 51 | return ( 52 | 57 | ); 58 | } 59 | 60 | export { Button, buttonVariants }; 61 | -------------------------------------------------------------------------------- /src/browser/ui/diff/utils/guess-lang.ts: -------------------------------------------------------------------------------- 1 | const extToLang: Record = { 2 | // JavaScript/TypeScript 3 | js: "javascript", 4 | jsx: "jsx", 5 | ts: "typescript", 6 | tsx: "tsx", 7 | mjs: "javascript", 8 | cjs: "javascript", 9 | 10 | // Web 11 | html: "markup", 12 | htm: "markup", 13 | xml: "markup", 14 | svg: "markup", 15 | css: "css", 16 | scss: "scss", 17 | sass: "sass", 18 | less: "less", 19 | stylus: "stylus", 20 | 21 | // Python 22 | py: "python", 23 | pyw: "python", 24 | pyi: "python", 25 | 26 | // Java/JVM 27 | java: "java", 28 | kt: "kotlin", 29 | kts: "kotlin", 30 | scala: "scala", 31 | groovy: "groovy", 32 | 33 | // C/C++ 34 | c: "c", 35 | cpp: "cpp", 36 | cc: "cpp", 37 | cxx: "cpp", 38 | h: "cpp", 39 | hpp: "cpp", 40 | hh: "cpp", 41 | hxx: "cpp", 42 | 43 | // C#/.NET 44 | cs: "csharp", 45 | vb: "vbnet", 46 | fs: "fsharp", 47 | 48 | // Rust 49 | rs: "rust", 50 | 51 | // Go 52 | go: "go", 53 | 54 | // Ruby 55 | rb: "ruby", 56 | rake: "ruby", 57 | 58 | // PHP 59 | php: "php", 60 | phtml: "php", 61 | 62 | // Shell 63 | sh: "bash", 64 | bash: "bash", 65 | zsh: "bash", 66 | fish: "bash", 67 | 68 | // Data formats 69 | json: "json", 70 | json5: "json5", 71 | yml: "yaml", 72 | yaml: "yaml", 73 | toml: "toml", 74 | ini: "ini", 75 | csv: "csv", 76 | 77 | // Markdown/Docs 78 | md: "markdown", 79 | markdown: "markdown", 80 | tex: "latex", 81 | 82 | // Swift/Objective-C 83 | swift: "swift", 84 | m: "objectivec", 85 | mm: "objectivec", 86 | 87 | // SQL 88 | sql: "sql", 89 | 90 | // Other languages 91 | r: "r", 92 | lua: "lua", 93 | perl: "perl", 94 | pl: "perl", 95 | dart: "dart", 96 | elm: "elm", 97 | ex: "elixir", 98 | exs: "elixir", 99 | erl: "erlang", 100 | clj: "clojure", 101 | cljs: "clojure", 102 | lisp: "lisp", 103 | hs: "haskell", 104 | ml: "ocaml", 105 | 106 | // Config files 107 | dockerfile: "docker", 108 | gitignore: "ignore", 109 | 110 | // Other 111 | graphql: "graphql", 112 | proto: "protobuf", 113 | wasm: "wasm", 114 | vim: "vim", 115 | zig: "zig", 116 | mermaid: "mermaid", 117 | }; 118 | 119 | export const guessLang = (filename?: string): string => { 120 | const ext = filename?.split(".").pop()?.toLowerCase() ?? ""; 121 | return extToLang[ext] ?? "tsx"; 122 | }; 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | pulldash logo 3 | Pulldash 4 |

5 | 6 | ![GitHub Release](https://img.shields.io/github/v/release/coder/pulldash) ![GitHub License](https://img.shields.io/github/license/coder/pulldash) ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/coder/pulldash/ci.yml) 7 | 8 | Fast, filterable PR review. Entirely client-side. 9 | 10 | > [!WARNING] 11 | > Pulldash is WIP. Expect bugs. 12 | 13 | ## Try It 14 | 15 | **Browser**: [pulldash.com](https://pulldash.com). Replace `github.com` with `pulldash.com` in any PR URL. 16 | 17 | **Desktop**: [Latest release](https://github.com/coder/pulldash/releases) for Linux, macOS, Windows. 18 | 19 | [![Example](./docs/screenshots/overview.png)](https://pulldash.com) 20 | 21 | ## Features 22 | 23 | - **Custom filters**: Add repos and filter by review requests, authored PRs, or all activity. 24 | 25 | ![Filtering PRs](./docs/screenshots/filtering.png) 26 | 27 | - **Keyboard-driven**: `j`/`k` to navigate files, arrows for lines, `c` to comment, `s` to submit. 28 | 29 | ![Keybinds](./docs/screenshots/keybind-driven.png) 30 | 31 | - **Fast file search**: `Ctrl+K` to fuzzy-find across hundreds of changed files. 32 | 33 | ![Search](./docs/screenshots/search.png) 34 | 35 | ## Why 36 | 37 | - GitHub's review UI is slow (especially for large diffs) 38 | - No central view to filter PRs you care about 39 | - AI tooling has produced more PRs than ever before—making a snappy review UI essential 40 | 41 | ## How It Works 42 | 43 | GitHub's API supports [CORS](https://docs.github.com/en/rest/using-the-rest-api/using-cors-and-jsonp-to-make-cross-origin-requests), so Pulldash runs entirely client-side. No backend proxying your requests. 44 | 45 | - **Web Worker pool**: Diff parsing and syntax highlighting run in workers sized to `navigator.hardwareConcurrency`. The main thread stays free for scrolling. 46 | 47 | - **Pre-computed navigation**: When a diff loads, we index all navigable lines. Arrow keys are O(1)—no DOM queries. 48 | 49 | - **External store**: State lives outside React ([`useSyncExternalStore`](https://react.dev/reference/react/useSyncExternalStore)). Focusing line 5000 doesn't re-render the file tree. 50 | 51 | - **Virtualized rendering**: Diffs, file lists, and the command palette only render visible rows. 52 | 53 | ## Development 54 | 55 | ```bash 56 | bun install 57 | bun dev 58 | ``` 59 | 60 | ## License 61 | 62 | [AGPL](./LICENSE) 63 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { readFileSync, readdirSync, existsSync } from "fs"; 3 | import { resolve, dirname } from "path"; 4 | import api from "./api/api"; 5 | import { serveStatic } from "@hono/node-server/serve-static"; 6 | 7 | const app = new Hono(); 8 | 9 | // Debug route to see filesystem structure on Vercel 10 | app.get("/_debug", (c) => { 11 | const listDir = (path: string, depth = 0): string[] => { 12 | const results: string[] = []; 13 | const indent = " ".repeat(depth); 14 | try { 15 | if (!existsSync(path)) { 16 | results.push(`${indent}[NOT FOUND: ${path}]`); 17 | return results; 18 | } 19 | const entries = readdirSync(path, { withFileTypes: true }); 20 | for (const entry of entries.slice(0, 50)) { 21 | // Limit to 50 entries 22 | if (entry.isDirectory()) { 23 | results.push(`${indent}${entry.name}/`); 24 | if (depth < 2) { 25 | // Limit depth 26 | results.push(...listDir(resolve(path, entry.name), depth + 1)); 27 | } 28 | } else { 29 | results.push(`${indent}${entry.name}`); 30 | } 31 | } 32 | } catch (e) { 33 | results.push(`${indent}[ERROR: ${e}]`); 34 | } 35 | return results; 36 | }; 37 | 38 | const cwd = process.cwd(); 39 | const metaDirname = import.meta.dirname; 40 | 41 | const info = { 42 | cwd, 43 | metaDirname, 44 | cwdContents: listDir(cwd), 45 | metaDirnameContents: listDir(metaDirname), 46 | publicFromCwd: listDir(resolve(cwd, "public")), 47 | parentDir: listDir(resolve(metaDirname, "..")), 48 | }; 49 | 50 | return c.json(info, 200, { "Content-Type": "application/json" }); 51 | }); 52 | 53 | // API routes first 54 | app.route("/", api); 55 | 56 | app.use("/*", serveStatic({ root: resolve(process.cwd(), "public") })); 57 | 58 | // SPA fallback - serve index.html for client-side routing 59 | // Static files are served by Vercel CDN from public/ 60 | app.get("*", (c) => { 61 | const path = c.req.path; 62 | 63 | // Skip if it looks like a static file request 64 | if (path.includes(".") && !path.endsWith(".html")) { 65 | return c.notFound(); 66 | } 67 | 68 | // Serve index.html for SPA routes 69 | try { 70 | const indexPath = resolve(process.cwd(), "public", "index.html"); 71 | const html = readFileSync(indexPath, "utf-8"); 72 | return c.html(html); 73 | } catch { 74 | return c.notFound(); 75 | } 76 | }); 77 | 78 | export default app; 79 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # pulldash 2 | 3 | Pulldash is the fastest way to review pull requests. 4 | 5 | ## Development 6 | 7 | Use `bun` for everything - package management, tests. 8 | 9 | _NEVER_ use browser tools - they will not work with this project. 10 | 11 | ## Principles 12 | 13 | - Performance is P1. If things feel laggy or are not smooth, it is of the utmost importance to fix. Pulldash exists because GitHub's PR review is slow. 14 | - Tests should primarily occur at the data-layer, and the frontend should mostly dummily render the data. 15 | 16 | ## Testing 17 | 18 | Tests should be minimal, not conflict with each other, and not be race-prone. 19 | 20 | If you make changes to a file that has tests, you should ensure the tests pass and add a test-case if one does not already exist covering it. 21 | We do not want duplicative tiny tests, but we want cases to be covered. 22 | 23 | Good: 24 | 25 | - `import { test } from "bun:test"` 26 | - File being tested: "file-name.tsx" -> test name: "file-name.test.tsx" 27 | 28 | Bad: 29 | 30 | - `import { it } from "bun:test"` 31 | - Any form of timing-based test (e.g. `setTimeout`, `setImmediate`, etc) 32 | - Deep nesting of tests 33 | - File being tested: "my-component.tsx" -> test name: "debug.test.tsx" 34 | 35 | ## Linting 36 | 37 | Always run `bun typecheck` and `bun fmt` after changes to ensure that files are formatted and have no type errors. 38 | 39 | ## Debugging 40 | 41 | If the user provides a PR identifier, you should use the `gh` CLI to inspect the API so we can fix our implementation if it appears incorrect. 42 | 43 | ## PR + Release Workflow 44 | 45 | - Reuse existing PRs; never close or recreate without instruction. Force-push updates. 46 | - After every push run: 47 | 48 | ```bash 49 | gh pr view --json mergeable,mergeStateStatus | jq '.' 50 | ./scripts/wait_pr_checks.sh 51 | ``` 52 | 53 | - Generally run `wait_pr_checks` after submitting a PR to ensure CI passes. 54 | - Status decoding: `mergeable=MERGEABLE` clean; `CONFLICTING` needs resolution. `mergeStateStatus=CLEAN` ready, `BLOCKED` waiting for CI, `BEHIND` rebase, `DIRTY` conflicts. 55 | - If behind: `git fetch origin && git rebase origin/main && git push --force-with-lease`. 56 | - Never enable auto-merge or merge at all unless the user explicitly says "merge it". 57 | - PR descriptions: include only information a busy reviewer cannot infer; focus on implementation nuances or validation steps. 58 | - Title prefixes: `perf|refactor|fix|feat|ci|bench`, e.g., `🤖 fix: handle workspace rename edge cases`. 59 | -------------------------------------------------------------------------------- /electron-builder.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", 3 | "appId": "com.coder.pulldash", 4 | "productName": "Pulldash", 5 | "copyright": "Copyright © 2025 Coder", 6 | 7 | "directories": { 8 | "output": "release", 9 | "buildResources": "build", 10 | "app": "dist" 11 | }, 12 | 13 | "files": ["electron/**/*", "browser/**/*", "!browser/**/*.map"], 14 | 15 | "extraResources": [ 16 | { 17 | "from": "dist/browser", 18 | "to": "browser", 19 | "filter": ["**/*", "!**/*.map"] 20 | } 21 | ], 22 | 23 | "mac": { 24 | "category": "public.app-category.developer-tools", 25 | "icon": "build/icon.icns", 26 | "darkModeSupport": true, 27 | "hardenedRuntime": true, 28 | "gatekeeperAssess": false, 29 | "target": [ 30 | { 31 | "target": "dmg", 32 | "arch": ["x64", "arm64"] 33 | }, 34 | { 35 | "target": "zip", 36 | "arch": ["x64", "arm64"] 37 | } 38 | ] 39 | }, 40 | 41 | "dmg": { 42 | "icon": "build/icon.icns", 43 | "iconSize": 128, 44 | "contents": [ 45 | { 46 | "x": 130, 47 | "y": 220 48 | }, 49 | { 50 | "x": 410, 51 | "y": 220, 52 | "type": "link", 53 | "path": "/Applications" 54 | } 55 | ], 56 | "window": { 57 | "width": 540, 58 | "height": 380 59 | } 60 | }, 61 | 62 | "win": { 63 | "icon": "build/icon.ico", 64 | "target": [ 65 | { 66 | "target": "nsis", 67 | "arch": ["x64", "arm64"] 68 | }, 69 | { 70 | "target": "portable", 71 | "arch": ["x64"] 72 | } 73 | ] 74 | }, 75 | 76 | "nsis": { 77 | "oneClick": false, 78 | "perMachine": false, 79 | "allowToChangeInstallationDirectory": true, 80 | "deleteAppDataOnUninstall": true, 81 | "installerIcon": "build/icon.ico", 82 | "uninstallerIcon": "build/icon.ico", 83 | "installerHeaderIcon": "build/icon.ico", 84 | "createDesktopShortcut": true, 85 | "createStartMenuShortcut": true, 86 | "shortcutName": "Pulldash" 87 | }, 88 | 89 | "linux": { 90 | "icon": "build/icon.png", 91 | "category": "Development", 92 | "target": [ 93 | { 94 | "target": "AppImage", 95 | "arch": ["x64", "arm64"] 96 | }, 97 | { 98 | "target": "tar.gz", 99 | "arch": ["x64", "arm64"] 100 | } 101 | ], 102 | "synopsis": "Fast GitHub PR Reviews", 103 | "description": "A lightning-fast, local PR review dashboard with syntax highlighting." 104 | }, 105 | 106 | "publish": { 107 | "provider": "github", 108 | "owner": "coder", 109 | "repo": "pulldash", 110 | "releaseType": "release" 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /scripts/build-electron.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Build the Electron app 3 | * 4 | * 1. Build the browser bundle 5 | * 2. Compile the Electron main process 6 | * 3. Package with electron-builder 7 | */ 8 | 9 | import { $ } from "bun"; 10 | import { resolve, join } from "path"; 11 | import { 12 | mkdirSync, 13 | existsSync, 14 | copyFileSync, 15 | readFileSync, 16 | writeFileSync, 17 | } from "fs"; 18 | 19 | const ROOT_DIR = resolve(__dirname, ".."); 20 | const DIST_DIR = join(ROOT_DIR, "dist"); 21 | const ELECTRON_DIST = join(DIST_DIR, "electron"); 22 | 23 | async function build() { 24 | console.log("🔨 Building Pulldash Electron App\n"); 25 | 26 | // Step 1: Build browser bundle 27 | console.log("📦 Step 1: Building browser bundle..."); 28 | await $`bun run build:browser`; 29 | console.log(" ✅ Browser bundle complete\n"); 30 | 31 | // Step 2: Generate icons if they don't exist 32 | const iconPath = join(ROOT_DIR, "build", "icon.png"); 33 | if (!existsSync(iconPath)) { 34 | console.log("🎨 Step 2: Generating app icons..."); 35 | await $`bun run scripts/generate-icons.ts`; 36 | console.log(" ✅ Icons generated\n"); 37 | } else { 38 | console.log("🎨 Step 2: Icons already exist, skipping...\n"); 39 | } 40 | 41 | // Step 3: Compile Electron main process with esbuild/bun 42 | console.log("⚡ Step 3: Compiling Electron main process..."); 43 | 44 | // Ensure electron dist directory exists 45 | if (!existsSync(ELECTRON_DIST)) { 46 | mkdirSync(ELECTRON_DIST, { recursive: true }); 47 | } 48 | 49 | // Use Bun to bundle the main process 50 | const result = await Bun.build({ 51 | entrypoints: [join(ROOT_DIR, "src", "electron", "main.ts")], 52 | outdir: ELECTRON_DIST, 53 | target: "node", 54 | format: "cjs", // Electron requires CommonJS 55 | external: ["electron"], // Don't bundle electron 56 | minify: false, // Keep readable for debugging 57 | sourcemap: "external", 58 | }); 59 | 60 | if (!result.success) { 61 | console.error("Build failed:"); 62 | for (const log of result.logs) { 63 | console.error(log); 64 | } 65 | process.exit(1); 66 | } 67 | 68 | console.log(" ✅ Electron main process compiled\n"); 69 | 70 | // Step 4: Create package.json for electron-builder 71 | console.log("📝 Step 4: Creating electron package.json..."); 72 | 73 | const mainPkg = JSON.parse( 74 | readFileSync(join(ROOT_DIR, "package.json"), "utf-8") 75 | ); 76 | 77 | const electronPkg = { 78 | name: mainPkg.name, 79 | version: mainPkg.version, 80 | description: mainPkg.description, 81 | main: "electron/main.js", 82 | author: mainPkg.author || { 83 | name: "Coder", 84 | email: "support@coder.com", 85 | }, 86 | license: mainPkg.license, 87 | // Note: no "type": "module" since we bundle as CommonJS 88 | }; 89 | 90 | writeFileSync( 91 | join(DIST_DIR, "package.json"), 92 | JSON.stringify(electronPkg, null, 2) 93 | ); 94 | console.log(" ✅ Package.json created\n"); 95 | 96 | console.log("✅ Build preparation complete!"); 97 | console.log( 98 | " Run 'bun run electron:package' to create distributable packages.\n" 99 | ); 100 | } 101 | 102 | build().catch((err) => { 103 | console.error("Build failed:", err); 104 | process.exit(1); 105 | }); 106 | -------------------------------------------------------------------------------- /src/browser/ui/pagination.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { 3 | ChevronLeftIcon, 4 | ChevronRightIcon, 5 | MoreHorizontalIcon, 6 | } from "lucide-react"; 7 | 8 | import { cn } from "@/browser/cn"; 9 | import { Button, buttonVariants } from "@/browser/ui/button"; 10 | 11 | function Pagination({ className, ...props }: React.ComponentProps<"nav">) { 12 | return ( 13 |