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} + + )} + + + +
+ + {/* 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 ( +
+ + {isOpen && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ); + } + + const isActive = activeFile === node.path; + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// 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} +
+ )} +