|
| 1 | +import { |
| 2 | + App, |
| 3 | + applyDocumentTheme, |
| 4 | + applyHostStyleVariables, |
| 5 | + applyHostFonts, |
| 6 | +} from "@modelcontextprotocol/ext-apps"; |
| 7 | +import type { CallToolResult } from "@modelcontextprotocol/ext-apps"; |
| 8 | +import { parseDiff } from "./diff-parser"; |
| 9 | +import { renderDiff, setViewMode, getViewMode } from "./diff-renderer"; |
| 10 | +import { renderPRDetails } from "./pr-details-renderer"; |
| 11 | +import "./styles.css"; |
| 12 | + |
| 13 | +type Tab = "details" | "diff"; |
| 14 | + |
| 15 | +let app: App | null = null; |
| 16 | +let activeTab: Tab = "details"; |
| 17 | + |
| 18 | +// Stored params for making subsequent tool calls when switching tabs |
| 19 | +let prOwner = ""; |
| 20 | +let prRepo = ""; |
| 21 | +let prPullNumber = 0; |
| 22 | + |
| 23 | +// Cache fetched data to avoid re-fetching on tab switch |
| 24 | +let cachedDetails: Record<string, unknown> | null = null; |
| 25 | +let cachedDiff: string | null = null; |
| 26 | + |
| 27 | +// eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 28 | +function handleHostContextChanged(ctx: any): void { |
| 29 | + if (ctx.theme) { |
| 30 | + applyDocumentTheme(ctx.theme); |
| 31 | + } |
| 32 | + if (ctx.styles?.variables) { |
| 33 | + applyHostStyleVariables(ctx.styles.variables); |
| 34 | + } |
| 35 | + if (ctx.styles?.css?.fonts) { |
| 36 | + applyHostFonts(ctx.styles.css.fonts); |
| 37 | + } |
| 38 | + |
| 39 | + // Apply safe area insets |
| 40 | + if (ctx.safeAreaInsets) { |
| 41 | + document.body.style.paddingTop = `${ctx.safeAreaInsets.top}px`; |
| 42 | + document.body.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`; |
| 43 | + document.body.style.paddingLeft = `${ctx.safeAreaInsets.left}px`; |
| 44 | + document.body.style.paddingRight = `${ctx.safeAreaInsets.right}px`; |
| 45 | + } |
| 46 | + |
| 47 | + // Update fullscreen button visibility and state |
| 48 | + const fullscreenBtn = document.getElementById("fullscreen-btn"); |
| 49 | + if (fullscreenBtn) { |
| 50 | + if (ctx.availableDisplayModes) { |
| 51 | + const canFullscreen = ctx.availableDisplayModes.includes("fullscreen"); |
| 52 | + fullscreenBtn.style.display = canFullscreen ? "flex" : "none"; |
| 53 | + } |
| 54 | + if (ctx.displayMode) { |
| 55 | + const isFullscreen = ctx.displayMode === "fullscreen"; |
| 56 | + fullscreenBtn.textContent = isFullscreen ? "✕" : "⛶"; |
| 57 | + fullscreenBtn.title = isFullscreen ? "Exit fullscreen" : "Fullscreen"; |
| 58 | + document.body.classList.toggle("fullscreen", isFullscreen); |
| 59 | + } |
| 60 | + } |
| 61 | +} |
| 62 | + |
| 63 | +async function toggleFullscreen(): Promise<void> { |
| 64 | + if (!app) return; |
| 65 | + const ctx = app.getHostContext(); |
| 66 | + const currentMode = ctx?.displayMode || "inline"; |
| 67 | + const newMode = currentMode === "fullscreen" ? "inline" : "fullscreen"; |
| 68 | + if (ctx?.availableDisplayModes?.includes(newMode)) { |
| 69 | + await app.requestDisplayMode({ mode: newMode }); |
| 70 | + } |
| 71 | +} |
| 72 | + |
| 73 | +function toggleViewMode(): void { |
| 74 | + const currentMode = getViewMode(); |
| 75 | + const newMode = currentMode === "unified" ? "split" : "unified"; |
| 76 | + setViewMode(newMode); |
| 77 | + updateViewModeButton(); |
| 78 | +} |
| 79 | + |
| 80 | +function updateViewModeButton(): void { |
| 81 | + const btn = document.getElementById("view-mode-btn"); |
| 82 | + if (btn) { |
| 83 | + const mode = getViewMode(); |
| 84 | + btn.textContent = mode === "unified" ? "Split" : "Unified"; |
| 85 | + btn.title = mode === "unified" ? "Switch to split view" : "Switch to unified view"; |
| 86 | + } |
| 87 | +} |
| 88 | + |
| 89 | +function updateTitle(owner: string, repo: string, pullNumber: number): void { |
| 90 | + const title = document.getElementById("title"); |
| 91 | + if (title) { |
| 92 | + const prUrl = `https://github.com/${owner}/${repo}/pull/${pullNumber}`; |
| 93 | + title.innerHTML = `<span class="pr-link">${escapeHtml(owner)}/${escapeHtml(repo)} #${pullNumber}</span>`; |
| 94 | + const link = title.querySelector(".pr-link"); |
| 95 | + if (link) { |
| 96 | + link.addEventListener("click", () => { |
| 97 | + app?.openLink({ url: prUrl }); |
| 98 | + }); |
| 99 | + } |
| 100 | + } |
| 101 | +} |
| 102 | + |
| 103 | +function escapeHtml(text: string): string { |
| 104 | + const div = document.createElement("div"); |
| 105 | + div.textContent = text; |
| 106 | + return div.innerHTML; |
| 107 | +} |
| 108 | + |
| 109 | +function switchTab(tab: Tab): void { |
| 110 | + if (tab === activeTab) return; |
| 111 | + activeTab = tab; |
| 112 | + |
| 113 | + // Update tab bar |
| 114 | + document.querySelectorAll(".tab").forEach((el) => { |
| 115 | + el.classList.toggle("active", (el as HTMLElement).dataset.tab === tab); |
| 116 | + }); |
| 117 | + |
| 118 | + // Toggle content visibility |
| 119 | + const contentArea = document.getElementById("content-area"); |
| 120 | + const diffContainer = document.getElementById("diff-container"); |
| 121 | + const viewModeBtn = document.getElementById("view-mode-btn"); |
| 122 | + |
| 123 | + if (contentArea) contentArea.style.display = tab === "details" ? "block" : "none"; |
| 124 | + if (diffContainer) diffContainer.style.display = tab === "diff" ? "flex" : "none"; |
| 125 | + if (viewModeBtn) viewModeBtn.style.display = tab === "diff" ? "flex" : "none"; |
| 126 | + |
| 127 | + // Fetch data if not cached |
| 128 | + if (tab === "diff" && cachedDiff === null) { |
| 129 | + fetchDiff(); |
| 130 | + } else if (tab === "details" && cachedDetails === null) { |
| 131 | + fetchDetails(); |
| 132 | + } |
| 133 | +} |
| 134 | + |
| 135 | +function parseToolResultText(result: CallToolResult): string | null { |
| 136 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 137 | + const content = result.content as any[]; |
| 138 | + if (!content || content.length === 0) return null; |
| 139 | + const textBlock = content.find((c) => c.type === "text"); |
| 140 | + return textBlock?.text ?? null; |
| 141 | +} |
| 142 | + |
| 143 | +async function fetchDiff(): Promise<void> { |
| 144 | + if (!app || !prOwner || !prRepo || !prPullNumber) return; |
| 145 | + |
| 146 | + const diffContainer = document.getElementById("diff-container"); |
| 147 | + if (diffContainer) diffContainer.innerHTML = '<div class="loading">Loading diff...</div>'; |
| 148 | + |
| 149 | + const result = await app.callTool("pull_request_read", { |
| 150 | + method: "get_diff", |
| 151 | + owner: prOwner, |
| 152 | + repo: prRepo, |
| 153 | + pullNumber: prPullNumber, |
| 154 | + }); |
| 155 | + |
| 156 | + const text = parseToolResultText(result); |
| 157 | + if (text) { |
| 158 | + cachedDiff = text; |
| 159 | + const parsed = parseDiff(text); |
| 160 | + renderDiff(parsed); |
| 161 | + } |
| 162 | +} |
| 163 | + |
| 164 | +async function fetchDetails(): Promise<void> { |
| 165 | + if (!app || !prOwner || !prRepo || !prPullNumber) return; |
| 166 | + |
| 167 | + const contentArea = document.getElementById("content-area"); |
| 168 | + if (contentArea) contentArea.innerHTML = '<div class="loading">Loading details...</div>'; |
| 169 | + |
| 170 | + const result = await app.callTool("pull_request_read", { |
| 171 | + method: "get", |
| 172 | + owner: prOwner, |
| 173 | + repo: prRepo, |
| 174 | + pullNumber: prPullNumber, |
| 175 | + }); |
| 176 | + |
| 177 | + const text = parseToolResultText(result); |
| 178 | + if (text) { |
| 179 | + try { |
| 180 | + cachedDetails = JSON.parse(text); |
| 181 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 182 | + renderPRDetails(cachedDetails as any); |
| 183 | + } catch { |
| 184 | + if (contentArea) contentArea.innerHTML = `<div class="empty-state">Failed to parse PR details</div>`; |
| 185 | + } |
| 186 | + } |
| 187 | +} |
| 188 | + |
| 189 | +function handleInitialResult(result: CallToolResult, method: string): void { |
| 190 | + const text = parseToolResultText(result); |
| 191 | + if (!text) return; |
| 192 | + |
| 193 | + if (method === "get_diff") { |
| 194 | + cachedDiff = text; |
| 195 | + const parsed = parseDiff(text); |
| 196 | + renderDiff(parsed); |
| 197 | + switchTab("diff"); |
| 198 | + } else if (method === "get") { |
| 199 | + try { |
| 200 | + cachedDetails = JSON.parse(text); |
| 201 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 202 | + renderPRDetails(cachedDetails as any); |
| 203 | + switchTab("details"); |
| 204 | + } catch { |
| 205 | + // fall through |
| 206 | + } |
| 207 | + } |
| 208 | +} |
| 209 | + |
| 210 | +function init(): void { |
| 211 | + app = new App({ name: "github-mcp-server-pr-read", version: "1.0.0" }); |
| 212 | + |
| 213 | + // Handle tool input to extract params and determine initial tab |
| 214 | + app.ontoolinput = (input: Record<string, unknown>) => { |
| 215 | + const owner = input.owner as string; |
| 216 | + const repo = input.repo as string; |
| 217 | + const pullNumber = input.pullNumber as number; |
| 218 | + const method = (input.method as string) || "get"; |
| 219 | + |
| 220 | + if (owner) prOwner = owner; |
| 221 | + if (repo) prRepo = repo; |
| 222 | + if (pullNumber) prPullNumber = pullNumber; |
| 223 | + |
| 224 | + if (prOwner && prRepo && prPullNumber) { |
| 225 | + updateTitle(prOwner, prRepo, prPullNumber); |
| 226 | + } |
| 227 | + |
| 228 | + // Set initial tab based on method |
| 229 | + if (method === "get_diff") { |
| 230 | + switchTab("diff"); |
| 231 | + } else { |
| 232 | + switchTab("details"); |
| 233 | + } |
| 234 | + }; |
| 235 | + |
| 236 | + // Handle tool results |
| 237 | + app.ontoolresult = (result: CallToolResult) => { |
| 238 | + // Determine which method this result is for based on active tab / cached state |
| 239 | + // If we don't have either cached, this is the initial result |
| 240 | + if (cachedDetails === null && cachedDiff === null) { |
| 241 | + // Peek at the content to determine the type |
| 242 | + const text = parseToolResultText(result); |
| 243 | + if (text) { |
| 244 | + // If it looks like a unified diff, it's a diff result |
| 245 | + if (text.startsWith("diff --git") || text.includes("\n---\n")) { |
| 246 | + cachedDiff = text; |
| 247 | + const parsed = parseDiff(text); |
| 248 | + renderDiff(parsed); |
| 249 | + if (activeTab === "diff") { |
| 250 | + // Already on diff tab, just render |
| 251 | + } |
| 252 | + } else { |
| 253 | + // Try to parse as JSON (PR details) |
| 254 | + try { |
| 255 | + cachedDetails = JSON.parse(text); |
| 256 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 257 | + renderPRDetails(cachedDetails as any); |
| 258 | + } catch { |
| 259 | + // Unknown format - show as text |
| 260 | + const contentArea = document.getElementById("content-area"); |
| 261 | + if (contentArea) contentArea.textContent = text; |
| 262 | + } |
| 263 | + } |
| 264 | + } |
| 265 | + } |
| 266 | + }; |
| 267 | + |
| 268 | + // Handle streaming partial input for progressive diff rendering |
| 269 | + app.ontoolinputpartial = (input: Record<string, unknown>) => { |
| 270 | + const diff = input.diff as string | undefined; |
| 271 | + if (diff && activeTab === "diff") { |
| 272 | + const parsed = parseDiff(diff); |
| 273 | + renderDiff(parsed); |
| 274 | + } |
| 275 | + }; |
| 276 | + |
| 277 | + // Handle host context changes (theme, etc.) |
| 278 | + app.onhostcontextchanged = handleHostContextChanged; |
| 279 | + |
| 280 | + // Set up tab bar |
| 281 | + document.querySelectorAll(".tab").forEach((tab) => { |
| 282 | + tab.addEventListener("click", () => { |
| 283 | + const tabName = (tab as HTMLElement).dataset.tab as Tab; |
| 284 | + if (tabName) switchTab(tabName); |
| 285 | + }); |
| 286 | + }); |
| 287 | + |
| 288 | + // Set up view mode toggle button |
| 289 | + const viewModeBtn = document.getElementById("view-mode-btn"); |
| 290 | + if (viewModeBtn) { |
| 291 | + viewModeBtn.addEventListener("click", toggleViewMode); |
| 292 | + } |
| 293 | + |
| 294 | + // Set up fullscreen button |
| 295 | + const fullscreenBtn = document.getElementById("fullscreen-btn"); |
| 296 | + if (fullscreenBtn) { |
| 297 | + fullscreenBtn.addEventListener("click", toggleFullscreen); |
| 298 | + } |
| 299 | + |
| 300 | + // Connect to host |
| 301 | + app.connect().then(() => { |
| 302 | + const ctx = app?.getHostContext(); |
| 303 | + if (ctx) { |
| 304 | + handleHostContextChanged(ctx); |
| 305 | + } |
| 306 | + }); |
| 307 | +} |
| 308 | + |
| 309 | +// Initialize when DOM is ready |
| 310 | +if (document.readyState === "loading") { |
| 311 | + document.addEventListener("DOMContentLoaded", init); |
| 312 | +} else { |
| 313 | + init(); |
| 314 | +} |
0 commit comments