diff --git a/pkg/github/__toolsnaps__/pull_request_read.snap b/pkg/github/__toolsnaps__/pull_request_read.snap index 9bb14cc07..0f92fe505 100644 --- a/pkg/github/__toolsnaps__/pull_request_read.snap +++ b/pkg/github/__toolsnaps__/pull_request_read.snap @@ -1,4 +1,9 @@ { + "_meta": { + "ui": { + "resourceUri": "ui://github-mcp-server/pr-read" + } + }, "annotations": { "readOnlyHint": true, "title": "Get details for a single pull request" diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 7eb9c6d64..c79bc9343 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -22,6 +22,9 @@ import ( "github.com/github/github-mcp-server/pkg/utils" ) +// PullRequestReadUIResourceURI is the URI for the pull_request_read tool's MCP App UI resource. +const PullRequestReadUIResourceURI = "ui://github-mcp-server/pr-read" + // PullRequestRead creates a tool to get details of a specific pull request. func PullRequestRead(t translations.TranslationHelperFunc) inventory.ServerTool { schema := &jsonschema.Schema{ @@ -69,6 +72,11 @@ Possible options: ReadOnlyHint: true, }, InputSchema: schema, + Meta: mcp.Meta{ + "ui": map[string]any{ + "resourceUri": PullRequestReadUIResourceURI, + }, + }, }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { diff --git a/pkg/github/ui_resources.go b/pkg/github/ui_resources.go index c41d2ac3f..11f5532cd 100644 --- a/pkg/github/ui_resources.go +++ b/pkg/github/ui_resources.go @@ -86,4 +86,26 @@ func RegisterUIResources(s *mcp.Server) { }, nil }, ) + + // Register the pull_request_read UI resource + s.AddResource( + &mcp.Resource{ + URI: PullRequestReadUIResourceURI, + Name: "pr_read_ui", + Description: "MCP App UI for viewing pull request details and diffs", + MIMEType: MCPAppMIMEType, + }, + func(_ context.Context, _ *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + html := MustGetUIAsset("pr-read.html") + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{ + { + URI: PullRequestReadUIResourceURI, + MIMEType: MCPAppMIMEType, + Text: html, + }, + }, + }, nil + }, + ) } diff --git a/ui/package.json b/ui/package.json index 6b26ca316..eefc51a64 100644 --- a/ui/package.json +++ b/ui/package.json @@ -5,10 +5,11 @@ "type": "module", "description": "MCP App UIs for github-mcp-server using Primer React", "scripts": { - "build": "npm run build:get-me && npm run build:issue-write && npm run build:pr-write", + "build": "npm run build:get-me && npm run build:issue-write && npm run build:pr-write && npm run build:pr-read", "build:get-me": "cross-env APP=get-me vite build", "build:issue-write": "cross-env APP=issue-write vite build", "build:pr-write": "cross-env APP=pr-write vite build", + "build:pr-read": "cross-env APP=pr-read vite build", "dev": "npm run build", "typecheck": "tsc --noEmit", "clean": "rm -rf dist" diff --git a/ui/src/apps/pr-read/App.ts b/ui/src/apps/pr-read/App.ts new file mode 100644 index 000000000..bbeb82a1c --- /dev/null +++ b/ui/src/apps/pr-read/App.ts @@ -0,0 +1,314 @@ +import { + App, + applyDocumentTheme, + applyHostStyleVariables, + applyHostFonts, +} from "@modelcontextprotocol/ext-apps"; +import type { CallToolResult } from "@modelcontextprotocol/ext-apps"; +import { parseDiff } from "./diff-parser"; +import { renderDiff, setViewMode, getViewMode } from "./diff-renderer"; +import { renderPRDetails } from "./pr-details-renderer"; +import "./styles.css"; + +type Tab = "details" | "diff"; + +let app: App | null = null; +let activeTab: Tab = "details"; + +// Stored params for making subsequent tool calls when switching tabs +let prOwner = ""; +let prRepo = ""; +let prPullNumber = 0; + +// Cache fetched data to avoid re-fetching on tab switch +let cachedDetails: Record | null = null; +let cachedDiff: string | null = null; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function handleHostContextChanged(ctx: any): void { + if (ctx.theme) { + applyDocumentTheme(ctx.theme); + } + if (ctx.styles?.variables) { + applyHostStyleVariables(ctx.styles.variables); + } + if (ctx.styles?.css?.fonts) { + applyHostFonts(ctx.styles.css.fonts); + } + + // Apply safe area insets + if (ctx.safeAreaInsets) { + document.body.style.paddingTop = `${ctx.safeAreaInsets.top}px`; + document.body.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`; + document.body.style.paddingLeft = `${ctx.safeAreaInsets.left}px`; + document.body.style.paddingRight = `${ctx.safeAreaInsets.right}px`; + } + + // Update fullscreen button visibility and state + const fullscreenBtn = document.getElementById("fullscreen-btn"); + if (fullscreenBtn) { + if (ctx.availableDisplayModes) { + const canFullscreen = ctx.availableDisplayModes.includes("fullscreen"); + fullscreenBtn.style.display = canFullscreen ? "flex" : "none"; + } + if (ctx.displayMode) { + const isFullscreen = ctx.displayMode === "fullscreen"; + fullscreenBtn.textContent = isFullscreen ? "✕" : "⛶"; + fullscreenBtn.title = isFullscreen ? "Exit fullscreen" : "Fullscreen"; + document.body.classList.toggle("fullscreen", isFullscreen); + } + } +} + +async function toggleFullscreen(): Promise { + if (!app) return; + const ctx = app.getHostContext(); + const currentMode = ctx?.displayMode || "inline"; + const newMode = currentMode === "fullscreen" ? "inline" : "fullscreen"; + if (ctx?.availableDisplayModes?.includes(newMode)) { + await app.requestDisplayMode({ mode: newMode }); + } +} + +function toggleViewMode(): void { + const currentMode = getViewMode(); + const newMode = currentMode === "unified" ? "split" : "unified"; + setViewMode(newMode); + updateViewModeButton(); +} + +function updateViewModeButton(): void { + const btn = document.getElementById("view-mode-btn"); + if (btn) { + const mode = getViewMode(); + btn.textContent = mode === "unified" ? "Split" : "Unified"; + btn.title = mode === "unified" ? "Switch to split view" : "Switch to unified view"; + } +} + +function updateTitle(owner: string, repo: string, pullNumber: number): void { + const title = document.getElementById("title"); + if (title) { + const prUrl = `https://github.com/${owner}/${repo}/pull/${pullNumber}`; + title.innerHTML = `${escapeHtml(owner)}/${escapeHtml(repo)} #${pullNumber}`; + const link = title.querySelector(".pr-link"); + if (link) { + link.addEventListener("click", () => { + app?.openLink({ url: prUrl }); + }); + } + } +} + +function escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} + +function switchTab(tab: Tab): void { + if (tab === activeTab) return; + activeTab = tab; + + // Update tab bar + document.querySelectorAll(".tab").forEach((el) => { + el.classList.toggle("active", (el as HTMLElement).dataset.tab === tab); + }); + + // Toggle content visibility + const contentArea = document.getElementById("content-area"); + const diffContainer = document.getElementById("diff-container"); + const viewModeBtn = document.getElementById("view-mode-btn"); + + if (contentArea) contentArea.style.display = tab === "details" ? "block" : "none"; + if (diffContainer) diffContainer.style.display = tab === "diff" ? "flex" : "none"; + if (viewModeBtn) viewModeBtn.style.display = tab === "diff" ? "flex" : "none"; + + // Fetch data if not cached + if (tab === "diff" && cachedDiff === null) { + fetchDiff(); + } else if (tab === "details" && cachedDetails === null) { + fetchDetails(); + } +} + +function parseToolResultText(result: CallToolResult): string | null { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const content = result.content as any[]; + if (!content || content.length === 0) return null; + const textBlock = content.find((c) => c.type === "text"); + return textBlock?.text ?? null; +} + +async function fetchDiff(): Promise { + if (!app || !prOwner || !prRepo || !prPullNumber) return; + + const diffContainer = document.getElementById("diff-container"); + if (diffContainer) diffContainer.innerHTML = '
Loading diff...
'; + + const result = await app.callTool("pull_request_read", { + method: "get_diff", + owner: prOwner, + repo: prRepo, + pullNumber: prPullNumber, + }); + + const text = parseToolResultText(result); + if (text) { + cachedDiff = text; + const parsed = parseDiff(text); + renderDiff(parsed); + } +} + +async function fetchDetails(): Promise { + if (!app || !prOwner || !prRepo || !prPullNumber) return; + + const contentArea = document.getElementById("content-area"); + if (contentArea) contentArea.innerHTML = '
Loading details...
'; + + const result = await app.callTool("pull_request_read", { + method: "get", + owner: prOwner, + repo: prRepo, + pullNumber: prPullNumber, + }); + + const text = parseToolResultText(result); + if (text) { + try { + cachedDetails = JSON.parse(text); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + renderPRDetails(cachedDetails as any); + } catch { + if (contentArea) contentArea.innerHTML = `
Failed to parse PR details
`; + } + } +} + +function handleInitialResult(result: CallToolResult, method: string): void { + const text = parseToolResultText(result); + if (!text) return; + + if (method === "get_diff") { + cachedDiff = text; + const parsed = parseDiff(text); + renderDiff(parsed); + switchTab("diff"); + } else if (method === "get") { + try { + cachedDetails = JSON.parse(text); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + renderPRDetails(cachedDetails as any); + switchTab("details"); + } catch { + // fall through + } + } +} + +function init(): void { + app = new App({ name: "github-mcp-server-pr-read", version: "1.0.0" }); + + // Handle tool input to extract params and determine initial tab + app.ontoolinput = (input: Record) => { + const owner = input.owner as string; + const repo = input.repo as string; + const pullNumber = input.pullNumber as number; + const method = (input.method as string) || "get"; + + if (owner) prOwner = owner; + if (repo) prRepo = repo; + if (pullNumber) prPullNumber = pullNumber; + + if (prOwner && prRepo && prPullNumber) { + updateTitle(prOwner, prRepo, prPullNumber); + } + + // Set initial tab based on method + if (method === "get_diff") { + switchTab("diff"); + } else { + switchTab("details"); + } + }; + + // Handle tool results + app.ontoolresult = (result: CallToolResult) => { + // Determine which method this result is for based on active tab / cached state + // If we don't have either cached, this is the initial result + if (cachedDetails === null && cachedDiff === null) { + // Peek at the content to determine the type + const text = parseToolResultText(result); + if (text) { + // If it looks like a unified diff, it's a diff result + if (text.startsWith("diff --git") || text.includes("\n---\n")) { + cachedDiff = text; + const parsed = parseDiff(text); + renderDiff(parsed); + if (activeTab === "diff") { + // Already on diff tab, just render + } + } else { + // Try to parse as JSON (PR details) + try { + cachedDetails = JSON.parse(text); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + renderPRDetails(cachedDetails as any); + } catch { + // Unknown format - show as text + const contentArea = document.getElementById("content-area"); + if (contentArea) contentArea.textContent = text; + } + } + } + } + }; + + // Handle streaming partial input for progressive diff rendering + app.ontoolinputpartial = (input: Record) => { + const diff = input.diff as string | undefined; + if (diff && activeTab === "diff") { + const parsed = parseDiff(diff); + renderDiff(parsed); + } + }; + + // Handle host context changes (theme, etc.) + app.onhostcontextchanged = handleHostContextChanged; + + // Set up tab bar + document.querySelectorAll(".tab").forEach((tab) => { + tab.addEventListener("click", () => { + const tabName = (tab as HTMLElement).dataset.tab as Tab; + if (tabName) switchTab(tabName); + }); + }); + + // Set up view mode toggle button + const viewModeBtn = document.getElementById("view-mode-btn"); + if (viewModeBtn) { + viewModeBtn.addEventListener("click", toggleViewMode); + } + + // Set up fullscreen button + const fullscreenBtn = document.getElementById("fullscreen-btn"); + if (fullscreenBtn) { + fullscreenBtn.addEventListener("click", toggleFullscreen); + } + + // Connect to host + app.connect().then(() => { + const ctx = app?.getHostContext(); + if (ctx) { + handleHostContextChanged(ctx); + } + }); +} + +// Initialize when DOM is ready +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); +} else { + init(); +} diff --git a/ui/src/apps/pr-read/diff-parser.ts b/ui/src/apps/pr-read/diff-parser.ts new file mode 100644 index 000000000..af168a366 --- /dev/null +++ b/ui/src/apps/pr-read/diff-parser.ts @@ -0,0 +1,205 @@ +export interface DiffLine { + type: "addition" | "deletion" | "context"; + content: string; + oldLineNumber: number | null; + newLineNumber: number | null; +} + +export interface DiffHunk { + header: string; + oldStart: number; + oldCount: number; + newStart: number; + newCount: number; + lines: DiffLine[]; +} + +export interface DiffFile { + oldPath: string; + newPath: string; + hunks: DiffHunk[]; + additions: number; + deletions: number; +} + +export interface ParsedDiff { + files: DiffFile[]; + totalAdditions: number; + totalDeletions: number; +} + +export function parseDiff(diffText: string): ParsedDiff { + const files: DiffFile[] = []; + let totalAdditions = 0; + let totalDeletions = 0; + + const lines = diffText.split("\n"); + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + // Look for file header + if (line.startsWith("diff --git")) { + const file = parseFile(lines, i); + if (file) { + files.push(file.file); + totalAdditions += file.file.additions; + totalDeletions += file.file.deletions; + i = file.nextIndex; + continue; + } + } + + i++; + } + + return { files, totalAdditions, totalDeletions }; +} + +function parseFile( + lines: string[], + startIndex: number +): { file: DiffFile; nextIndex: number } | null { + let i = startIndex; + const diffLine = lines[i]; + + // Parse file paths from diff --git line + const gitMatch = diffLine.match(/^diff --git a\/(.+) b\/(.+)$/); + if (!gitMatch) return null; + + let oldPath = gitMatch[1]; + let newPath = gitMatch[2]; + i++; + + // Skip extended headers until we find --- or the next diff + while (i < lines.length) { + const line = lines[i]; + + if (line.startsWith("--- ")) { + const oldMatch = line.match(/^--- (?:a\/)?(.+)$/); + if (oldMatch && oldMatch[1] !== "/dev/null") { + oldPath = oldMatch[1]; + } + i++; + continue; + } + + if (line.startsWith("+++ ")) { + const newMatch = line.match(/^\+\+\+ (?:b\/)?(.+)$/); + if (newMatch && newMatch[1] !== "/dev/null") { + newPath = newMatch[1]; + } + i++; + continue; + } + + if (line.startsWith("@@") || line.startsWith("diff --git")) { + break; + } + + i++; + } + + // Parse hunks + const hunks: DiffHunk[] = []; + let additions = 0; + let deletions = 0; + + while (i < lines.length && !lines[i].startsWith("diff --git")) { + if (lines[i].startsWith("@@")) { + const hunkResult = parseHunk(lines, i); + if (hunkResult) { + hunks.push(hunkResult.hunk); + additions += hunkResult.hunk.lines.filter( + (l) => l.type === "addition" + ).length; + deletions += hunkResult.hunk.lines.filter( + (l) => l.type === "deletion" + ).length; + i = hunkResult.nextIndex; + continue; + } + } + i++; + } + + return { + file: { oldPath, newPath, hunks, additions, deletions }, + nextIndex: i, + }; +} + +function parseHunk( + lines: string[], + startIndex: number +): { hunk: DiffHunk; nextIndex: number } | null { + const headerLine = lines[startIndex]; + const headerMatch = headerLine.match( + /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/ + ); + if (!headerMatch) return null; + + const oldStart = parseInt(headerMatch[1], 10); + const oldCount = headerMatch[2] ? parseInt(headerMatch[2], 10) : 1; + const newStart = parseInt(headerMatch[3], 10); + const newCount = headerMatch[4] ? parseInt(headerMatch[4], 10) : 1; + const headerContext = headerMatch[5] || ""; + + const hunkLines: DiffLine[] = []; + let oldLine = oldStart; + let newLine = newStart; + let i = startIndex + 1; + + while (i < lines.length) { + const line = lines[i]; + + // Stop at next hunk or file + if (line.startsWith("@@") || line.startsWith("diff --git")) { + break; + } + + // Handle "\ No newline at end of file" + if (line.startsWith("\\ ")) { + i++; + continue; + } + + if (line.startsWith("+")) { + hunkLines.push({ + type: "addition", + content: line.slice(1), + oldLineNumber: null, + newLineNumber: newLine++, + }); + } else if (line.startsWith("-")) { + hunkLines.push({ + type: "deletion", + content: line.slice(1), + oldLineNumber: oldLine++, + newLineNumber: null, + }); + } else if (line.startsWith(" ") || line === "") { + hunkLines.push({ + type: "context", + content: line.slice(1), + oldLineNumber: oldLine++, + newLineNumber: newLine++, + }); + } + + i++; + } + + return { + hunk: { + header: `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@${headerContext}`, + oldStart, + oldCount, + newStart, + newCount, + lines: hunkLines, + }, + nextIndex: i, + }; +} diff --git a/ui/src/apps/pr-read/diff-renderer.ts b/ui/src/apps/pr-read/diff-renderer.ts new file mode 100644 index 000000000..ee94f0163 --- /dev/null +++ b/ui/src/apps/pr-read/diff-renderer.ts @@ -0,0 +1,192 @@ +import type { ParsedDiff, DiffFile, DiffHunk, DiffLine } from "./diff-parser"; + +export type DiffViewMode = "unified" | "split"; + +let currentMode: DiffViewMode = "unified"; +let currentParsedDiff: ParsedDiff | null = null; + +export function setViewMode(mode: DiffViewMode): void { + currentMode = mode; + if (currentParsedDiff) { + renderDiff(currentParsedDiff); + } +} + +export function getViewMode(): DiffViewMode { + return currentMode; +} + +export function renderDiff(parsed: ParsedDiff): void { + currentParsedDiff = parsed; + const container = document.getElementById("diff-container"); + const stats = document.getElementById("stats"); + + if (!container || !stats) return; + + // Render stats + stats.innerHTML = ` + +${parsed.totalAdditions} + -${parsed.totalDeletions} + across ${parsed.files.length} file${parsed.files.length !== 1 ? "s" : ""} + `; + + // Render files + if (parsed.files.length === 0) { + container.innerHTML = '
No changes in this diff
'; + return; + } + + // Update container class for view mode + container.className = `diff-container ${currentMode}`; + container.innerHTML = parsed.files.map((file) => renderFile(file, currentMode)).join(""); + + // Add toggle handlers + container.querySelectorAll(".diff-file-header").forEach((header) => { + header.addEventListener("click", () => { + const file = header.closest(".diff-file"); + if (file) { + file.classList.toggle("collapsed"); + const content = file.querySelector(".diff-file-content"); + if (content) { + content.classList.toggle("collapsed"); + } + } + }); + }); +} + +function renderFile(file: DiffFile, mode: DiffViewMode): string { + const displayPath = file.newPath || file.oldPath; + + return ` +
+
+ ${escapeHtml(displayPath)} +
+ +${file.additions} + -${file.deletions} + +
+
+
+ ${file.hunks.map((hunk) => renderHunk(hunk, mode)).join("")} +
+
+ `; +} + +function renderHunk(hunk: DiffHunk, mode: DiffViewMode): string { + if (mode === "split") { + return renderHunkSplit(hunk); + } + return renderHunkUnified(hunk); +} + +function renderHunkUnified(hunk: DiffHunk): string { + return ` +
+
${escapeHtml(hunk.header)}
+ ${hunk.lines.map(renderLineUnified).join("")} +
+ `; +} + +function renderLineUnified(line: DiffLine): string { + const prefix = + line.type === "addition" ? "+" : line.type === "deletion" ? "-" : " "; + const oldNum = line.oldLineNumber !== null ? line.oldLineNumber : ""; + const newNum = line.newLineNumber !== null ? line.newLineNumber : ""; + + return ` +
+
+ ${oldNum} + ${newNum} +
+
${prefix}${escapeHtml(line.content)}
+
+ `; +} + +function renderHunkSplit(hunk: DiffHunk): string { + // Pair up deletions and additions for side-by-side view + const rows = buildSplitRows(hunk.lines); + + return ` +
+
${escapeHtml(hunk.header)}
+
+ ${rows.map(renderSplitRow).join("")} +
+
+ `; +} + +interface SplitRow { + left: DiffLine | null; + right: DiffLine | null; +} + +function buildSplitRows(lines: DiffLine[]): SplitRow[] { + const rows: SplitRow[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + if (line.type === "context") { + rows.push({ left: line, right: line }); + i++; + } else if (line.type === "deletion") { + // Collect consecutive deletions + const deletions: DiffLine[] = []; + while (i < lines.length && lines[i].type === "deletion") { + deletions.push(lines[i]); + i++; + } + // Collect consecutive additions + const additions: DiffLine[] = []; + while (i < lines.length && lines[i].type === "addition") { + additions.push(lines[i]); + i++; + } + // Pair them up + const maxLen = Math.max(deletions.length, additions.length); + for (let j = 0; j < maxLen; j++) { + rows.push({ + left: deletions[j] || null, + right: additions[j] || null, + }); + } + } else if (line.type === "addition") { + // Addition without preceding deletion + rows.push({ left: null, right: line }); + i++; + } else { + i++; + } + } + + return rows; +} + +function renderSplitRow(row: SplitRow): string { + return ` +
+
+ ${row.left?.oldLineNumber ?? ""} +
${row.left ? escapeHtml(row.left.content) : ""}
+
+
+ ${row.right?.newLineNumber ?? ""} +
${row.right ? escapeHtml(row.right.content) : ""}
+
+
+ `; +} + +function escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} diff --git a/ui/src/apps/pr-read/index.html b/ui/src/apps/pr-read/index.html new file mode 100644 index 000000000..efefe8fe3 --- /dev/null +++ b/ui/src/apps/pr-read/index.html @@ -0,0 +1,29 @@ + + + + + + PR Viewer + + +
+ +
+ + +
+
+ +
+ + + diff --git a/ui/src/apps/pr-read/pr-details-renderer.ts b/ui/src/apps/pr-read/pr-details-renderer.ts new file mode 100644 index 000000000..ad28de905 --- /dev/null +++ b/ui/src/apps/pr-read/pr-details-renderer.ts @@ -0,0 +1,136 @@ +interface PRDetails { + number: number; + title: string; + body?: string; + state: string; + draft: boolean; + merged: boolean; + mergeable_state?: string; + html_url: string; + user?: { login: string; html_url?: string }; + labels?: string[]; + assignees?: string[]; + requested_reviewers?: string[]; + merged_by?: string; + head?: { ref: string; sha: string; repo?: { full_name: string } }; + base?: { ref: string; sha: string; repo?: { full_name: string } }; + additions?: number; + deletions?: number; + changed_files?: number; + commits?: number; + comments?: number; + created_at?: string; + updated_at?: string; + closed_at?: string; + merged_at?: string; + milestone?: string; +} + +export function renderPRDetails(data: PRDetails): void { + const container = document.getElementById("content-area"); + const stats = document.getElementById("stats"); + + if (!container) return; + if (stats) stats.innerHTML = ""; + + const state = getStateDisplay(data); + + container.innerHTML = ` +
+
+ ${escapeHtml(state.label)} +

${escapeHtml(data.title)} #${data.number}

+
+ +
+ ${data.user ? `
Author${escapeHtml(data.user.login)}
` : ""} + ${data.head && data.base ? `
Branches${escapeHtml(data.head.ref)}${escapeHtml(data.base.ref)}
` : ""} + ${data.created_at ? `
Created${formatDate(data.created_at)}
` : ""} + ${data.updated_at ? `
Updated${formatDate(data.updated_at)}
` : ""} + ${data.merged_at ? `
Merged${formatDate(data.merged_at)}${data.merged_by ? ` by ${escapeHtml(data.merged_by)}` : ""}
` : ""} + ${data.closed_at && !data.merged ? `
Closed${formatDate(data.closed_at)}
` : ""} + ${data.milestone ? `
Milestone${escapeHtml(data.milestone)}
` : ""} +
+ + ${renderStatsBadges(data)} + + ${data.labels && data.labels.length > 0 ? ` +
+ Labels +
${data.labels.map((l) => `${escapeHtml(l)}`).join("")}
+
+ ` : ""} + + ${data.assignees && data.assignees.length > 0 ? ` +
+ Assignees + ${data.assignees.map((a) => escapeHtml(a)).join(", ")} +
+ ` : ""} + + ${data.requested_reviewers && data.requested_reviewers.length > 0 ? ` +
+ Reviewers + ${data.requested_reviewers.map((r) => escapeHtml(r)).join(", ")} +
+ ` : ""} + + ${data.body ? ` +
+
${escapeHtml(data.body)}
+
+ ` : ""} +
+ `; +} + +function getStateDisplay(data: PRDetails): { label: string; className: string } { + if (data.merged) return { label: "Merged", className: "merged" }; + if (data.draft) return { label: "Draft", className: "draft" }; + if (data.state === "closed") return { label: "Closed", className: "closed" }; + return { label: "Open", className: "open" }; +} + +function renderStatsBadges(data: PRDetails): string { + const badges: string[] = []; + + if (data.commits !== undefined) { + badges.push(`${data.commits} commit${data.commits !== 1 ? "s" : ""}`); + } + if (data.changed_files !== undefined) { + badges.push(`${data.changed_files} file${data.changed_files !== 1 ? "s" : ""} changed`); + } + if (data.additions !== undefined) { + badges.push(`+${data.additions}`); + } + if (data.deletions !== undefined) { + badges.push(`-${data.deletions}`); + } + if (data.comments !== undefined && data.comments > 0) { + badges.push(`${data.comments} comment${data.comments !== 1 ? "s" : ""}`); + } + + if (badges.length === 0) return ""; + return `
${badges.join("")}
`; +} + +function formatDate(isoDate: string): string { + try { + const date = new Date(isoDate); + return date.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return isoDate; + } +} + +function escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} diff --git a/ui/src/apps/pr-read/styles.css b/ui/src/apps/pr-read/styles.css new file mode 100644 index 000000000..4357d98f4 --- /dev/null +++ b/ui/src/apps/pr-read/styles.css @@ -0,0 +1,537 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif); + font-size: var(--font-text-sm-size, 14px); + line-height: var(--font-text-sm-line-height, 1.5); + background: var(--color-background-primary, #ffffff); + color: var(--color-text-primary, #1f2937); +} + +#app { + padding: 16px; + max-width: 100%; +} + +/* Header */ +#header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--color-border-secondary, #e5e7eb); +} + +#header-left { + flex: 1; +} + +#title { + font-size: var(--font-text-lg-size, 18px); + font-weight: var(--font-weight-semibold, 600); + color: var(--color-text-primary, #1f2937); + margin-bottom: 8px; +} + +.pr-link { + color: var(--color-accent-primary, #2563eb); + cursor: pointer; + text-decoration: underline; +} + +.pr-link:hover { + color: var(--color-accent-secondary, #1d4ed8); +} + +#header-controls { + display: flex; + gap: 8px; +} + +#view-mode-btn, +#fullscreen-btn { + display: flex; + align-items: center; + justify-content: center; + height: 32px; + padding: 0 12px; + border: 1px solid var(--color-border-secondary, #e5e7eb); + border-radius: var(--border-radius-md, 6px); + background: var(--color-background-secondary, #f9fafb); + color: var(--color-text-secondary, #6b7280); + font-size: 13px; + font-family: inherit; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +#fullscreen-btn { + width: 32px; + padding: 0; + font-size: 16px; +} + +#view-mode-btn:hover, +#fullscreen-btn:hover { + background: var(--color-background-tertiary, #f3f4f6); + color: var(--color-text-primary, #1f2937); +} + +#stats { + font-size: var(--font-text-sm-size, 14px); + color: var(--color-text-secondary, #6b7280); +} + +.stat-additions { + color: #16a34a; + font-weight: 500; +} + +.stat-deletions { + color: #dc2626; + font-weight: 500; +} + +/* Tab bar */ +#tab-bar { + display: flex; + gap: 0; + margin-bottom: 16px; + border-bottom: 1px solid var(--color-border-secondary, #e5e7eb); +} + +.tab { + padding: 8px 16px; + border: none; + background: none; + font-size: var(--font-text-sm-size, 14px); + font-family: inherit; + color: var(--color-text-secondary, #6b7280); + cursor: pointer; + border-bottom: 2px solid transparent; + transition: color 0.15s, border-color 0.15s; +} + +.tab:hover { + color: var(--color-text-primary, #1f2937); +} + +.tab.active { + color: var(--color-accent-primary, #2563eb); + border-bottom-color: var(--color-accent-primary, #2563eb); + font-weight: var(--font-weight-medium, 500); +} + +/* Diff view */ +#diff-container { + display: flex; + flex-direction: column; + gap: 16px; +} + +.diff-file { + border: 1px solid var(--color-border-secondary, #e5e7eb); + border-radius: var(--border-radius-md, 6px); + overflow: hidden; +} + +.diff-file-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + background: var(--color-background-secondary, #f9fafb); + border-bottom: 1px solid var(--color-border-secondary, #e5e7eb); + cursor: pointer; + user-select: none; +} + +.diff-file-header:hover { + background: var(--color-background-tertiary, #f3f4f6); +} + +.diff-file-path { + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, monospace); + font-size: var(--font-text-sm-size, 14px); + font-weight: var(--font-weight-medium, 500); + color: var(--color-text-primary, #1f2937); +} + +.diff-file-stats { + display: flex; + gap: 8px; + font-size: var(--font-text-xs-size, 12px); +} + +.diff-file-additions { + color: #16a34a; +} + +.diff-file-deletions { + color: #dc2626; +} + +.diff-file-content { + overflow-x: auto; +} + +.diff-file-content.collapsed { + display: none; +} + +.diff-hunk { + border-top: 1px solid var(--color-border-secondary, #e5e7eb); +} + +.diff-hunk:first-child { + border-top: none; +} + +.diff-hunk-header { + padding: 6px 12px; + background: var(--color-background-tertiary, #f3f4f6); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, monospace); + font-size: var(--font-text-xs-size, 12px); + color: var(--color-text-secondary, #6b7280); +} + +.diff-line { + display: flex; + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, monospace); + font-size: var(--font-text-sm-size, 13px); + line-height: 1.5; +} + +.diff-line-numbers { + display: flex; + flex-shrink: 0; + user-select: none; +} + +.diff-line-number { + width: 50px; + padding: 0 8px; + text-align: right; + color: var(--color-text-tertiary, #9ca3af); + background: var(--color-background-secondary, #f9fafb); + border-right: 1px solid var(--color-border-secondary, #e5e7eb); +} + +.diff-line-content { + flex: 1; + padding: 0 12px; + white-space: pre; + overflow-x: visible; +} + +.diff-line-prefix { + display: inline-block; + width: 1ch; + user-select: none; +} + +/* Line type styles */ +.diff-line.addition { + background: rgba(34, 197, 94, 0.15); +} + +.diff-line.addition .diff-line-number { + background: rgba(34, 197, 94, 0.2); +} + +.diff-line.addition .diff-line-prefix { + color: #16a34a; +} + +.diff-line.deletion { + background: rgba(239, 68, 68, 0.15); +} + +.diff-line.deletion .diff-line-number { + background: rgba(239, 68, 68, 0.2); +} + +.diff-line.deletion .diff-line-prefix { + color: #dc2626; +} + +.diff-line.context { + background: transparent; +} + +/* Collapse indicator */ +.collapse-indicator { + font-size: 12px; + color: var(--color-text-secondary, #6b7280); + transition: transform 0.2s; +} + +.diff-file.collapsed .collapse-indicator { + transform: rotate(-90deg); +} + +/* Split view styles */ +.diff-split-container { + display: flex; + flex-direction: column; +} + +.diff-split-row { + display: flex; +} + +.diff-split-side { + flex: 1; + display: flex; + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, monospace); + font-size: var(--font-text-sm-size, 13px); + line-height: 1.5; + min-width: 0; +} + +.diff-split-side .diff-line-number { + flex-shrink: 0; + width: 50px; + padding: 0 8px; + text-align: right; + color: var(--color-text-tertiary, #9ca3af); + background: var(--color-background-secondary, #f9fafb); + border-right: 1px solid var(--color-border-secondary, #e5e7eb); +} + +.diff-split-side .diff-line-content { + flex: 1; + padding: 0 12px; + white-space: pre; + overflow-x: auto; +} + +.diff-split-side.left { + border-right: 1px solid var(--color-border-secondary, #e5e7eb); +} + +.diff-split-side.deletion { + background: rgba(239, 68, 68, 0.15); +} + +.diff-split-side.deletion .diff-line-number { + background: rgba(239, 68, 68, 0.2); +} + +.diff-split-side.addition { + background: rgba(34, 197, 94, 0.15); +} + +.diff-split-side.addition .diff-line-number { + background: rgba(34, 197, 94, 0.2); +} + +.diff-split-side.context { + background: transparent; +} + +.diff-split-side.empty { + background: var(--color-background-tertiary, #f3f4f6); +} + +.diff-split-side.empty .diff-line-number { + background: var(--color-background-tertiary, #f3f4f6); +} + +/* PR Details view */ +.pr-details { + display: flex; + flex-direction: column; + gap: 16px; +} + +.pr-details-header { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.pr-state { + display: inline-flex; + align-items: center; + padding: 4px 12px; + border-radius: 999px; + font-size: var(--font-text-xs-size, 12px); + font-weight: var(--font-weight-semibold, 600); + white-space: nowrap; + flex-shrink: 0; + margin-top: 2px; +} + +.pr-state.open { + background: rgba(34, 197, 94, 0.15); + color: #16a34a; +} + +.pr-state.closed { + background: rgba(239, 68, 68, 0.15); + color: #dc2626; +} + +.pr-state.merged { + background: rgba(139, 92, 246, 0.15); + color: #7c3aed; +} + +.pr-state.draft { + background: var(--color-background-tertiary, #f3f4f6); + color: var(--color-text-secondary, #6b7280); +} + +.pr-title { + font-size: var(--font-text-lg-size, 18px); + font-weight: var(--font-weight-semibold, 600); + color: var(--color-text-primary, #1f2937); + line-height: 1.4; +} + +.pr-number { + color: var(--color-text-secondary, #6b7280); + font-weight: var(--font-weight-normal, 400); +} + +.pr-meta { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 8px; +} + +.pr-meta-item { + display: flex; + gap: 8px; + align-items: baseline; +} + +.pr-meta-label { + font-size: var(--font-text-xs-size, 12px); + font-weight: var(--font-weight-semibold, 600); + color: var(--color-text-secondary, #6b7280); + text-transform: uppercase; + letter-spacing: 0.05em; + flex-shrink: 0; +} + +.pr-meta-value { + font-size: var(--font-text-sm-size, 14px); + color: var(--color-text-primary, #1f2937); +} + +.pr-branches code { + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, monospace); + font-size: var(--font-text-xs-size, 12px); + padding: 2px 6px; + border-radius: var(--border-radius-sm, 4px); + background: var(--color-background-tertiary, #f3f4f6); + color: var(--color-text-primary, #1f2937); +} + +.pr-stats-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.pr-stat-badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: var(--border-radius-md, 6px); + font-size: var(--font-text-xs-size, 12px); + font-weight: var(--font-weight-medium, 500); + background: var(--color-background-secondary, #f9fafb); + color: var(--color-text-secondary, #6b7280); + border: 1px solid var(--color-border-secondary, #e5e7eb); +} + +.pr-stat-badge.additions { + color: #16a34a; +} + +.pr-stat-badge.deletions { + color: #dc2626; +} + +.pr-labels { + display: flex; + align-items: center; + gap: 8px; +} + +.pr-label-list { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.pr-label { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border-radius: 999px; + font-size: var(--font-text-xs-size, 12px); + font-weight: var(--font-weight-medium, 500); + background: var(--color-background-tertiary, #f3f4f6); + color: var(--color-text-primary, #1f2937); + border: 1px solid var(--color-border-secondary, #e5e7eb); +} + +.pr-people { + display: flex; + align-items: baseline; + gap: 8px; +} + +.pr-body { + border-top: 1px solid var(--color-border-secondary, #e5e7eb); + padding-top: 16px; +} + +.pr-body-content { + font-size: var(--font-text-sm-size, 14px); + line-height: 1.6; + color: var(--color-text-primary, #1f2937); + white-space: pre-wrap; + word-wrap: break-word; +} + +/* Loading state */ +.loading { + text-align: center; + padding: 40px; + color: var(--color-text-secondary, #6b7280); +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 40px; + color: var(--color-text-secondary, #6b7280); +} + +/* Fullscreen mode */ +body.fullscreen #app { + padding: 24px; + max-width: 100%; +} + +body.fullscreen .diff-file { + border-radius: var(--border-radius-lg, 8px); +} + +body.fullscreen .diff-line-number { + width: 60px; +} + +body.fullscreen #title { + font-size: var(--font-text-xl-size, 20px); +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 5b1777c70..4b48ef4d5 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -30,8 +30,11 @@ function renameOutput(): Plugin { }; } +// Only use React plugin for React-based apps +const isReactApp = app !== "pr-read"; + export default defineConfig({ - plugins: [react(), viteSingleFile(), renameOutput()], + plugins: [...(isReactApp ? [react()] : []), viteSingleFile(), renameOutput()], build: { outDir: resolve(__dirname, "../pkg/github/ui_dist"), emptyOutDir: false,