import { useState, useEffect } from "react"; import { Box, Text, useInput, useStdout } from "ink"; import type { UnifiedRepo, CommandConfig } from "../types/index.ts"; import { getMarkdownLineCount, MarkdownRenderer } from "./MarkdownRenderer.tsx"; export function getMaxReadmeScroll( content: string | null, width: number, viewportHeight: number, ): number { if (!content) return 0; return Math.max(0, getMarkdownLineCount(content, width) - Math.max(0, viewportHeight)); } export interface RepoDetailModalProps { repo: UnifiedRepo; readmeContent: string | null; readmeLoading: boolean; readmeError: string | null; scrollOffset: number; commands?: CommandConfig[]; onClose: () => void; onAction: (action: string) => void; onScroll: (offset: number) => void; onCommand?: (command: CommandConfig) => void; } /** GitHub's repository-size field is reported in kilobytes. */ export function formatGitHubSize(sizeKB: number): string { if (sizeKB < 1024) return `${sizeKB} KB`; if (sizeKB < 1024 * 1024) return `${(sizeKB / 1024).toFixed(1)} MB`; if (sizeKB < 1024 * 1024 * 1024) return `${(sizeKB / (1024 * 1024)).toFixed(1)} GB`; return `${(sizeKB / (1024 * 1024 * 1024)).toFixed(1)} TB`; } export function RepoDetailModal({ repo, readmeContent, readmeLoading, readmeError, scrollOffset, commands = [], onClose, onAction, onScroll, onCommand, }: RepoDetailModalProps) { const { stdout } = useStdout(); const [dimensions, setDimensions] = useState({ width: 80, height: 24 }); useEffect(() => { if (stdout) { const updateDimensions = () => { setDimensions({ width: stdout.columns || 80, height: stdout.rows || 24, }); }; updateDimensions(); stdout.on("resize", updateDimensions); return () => { stdout.off("resize", updateDimensions); }; } }, [stdout]); const maxWidth = Math.max(1, Math.min(100, dimensions.width - 4)); // The modal owns the terminal viewport; reserve only its two border rows. const maxHeight = Math.max(1, dimensions.height - 2); // Keep the fixed header/action/footer chrome inside the modal even on short // terminals; the README viewport is the part that yields space first. const readmeHeight = Math.max(1, maxHeight - 17); // Modal border/padding consumes eight columns before README text begins. const readmeWidth = Math.max(1, maxWidth - 8); useInput((input, key) => { if (key.escape) { onClose(); return; } // Scroll always targets the README — actions use letter keys, so there // is no conflict and no need to "focus" a section first. if (input === "j" || key.downArrow) { if (readmeContent) { const max = getMaxReadmeScroll(readmeContent, readmeWidth, readmeHeight - 2); onScroll(Math.min(max, scrollOffset + 1)); } return; } if (input === "k" || key.upArrow) { if (readmeContent) { onScroll(Math.max(0, scrollOffset - 1)); } return; } if (key.pageDown || input === " ") { if (readmeContent) { const max = getMaxReadmeScroll(readmeContent, readmeWidth, readmeHeight - 2); onScroll(Math.min(max, scrollOffset + 10)); } return; } if (key.pageUp) { if (readmeContent) { onScroll(Math.max(0, scrollOffset - 10)); } return; } if (input === "g") { onScroll(0); return; } if (input === "G") { if (readmeContent) onScroll(getMaxReadmeScroll(readmeContent, readmeWidth, readmeHeight - 2)); return; } if (key.return) { if (canClone) { onAction("clone"); } else { onAction("primary"); } return; } switch (input) { case "p": if (hasLocal) onAction("push"); return; case "P": if (hasLocal) onAction("pull"); return; case "f": if (hasLocal) onAction("fetch"); return; case "o": onAction("browser"); return; case "d": if (hasLocal) onAction("editor"); return; } if (onCommand && commands.length > 0) { const command = commands.find((c) => c.key === input); if (command) onCommand(command); } }); const githubInfo = repo.github; const localInfo = repo.local; const status = localInfo?.status; // Derive local path from multiple sources for robustness const localPath = repo.localPath || localInfo?.path; // Determine if we have local and remote presence const hasLocal = !!(localPath || repo.source === "local" || repo.source === "both"); const hasRemote = !!(githubInfo?.htmlUrl || repo.source === "github" || repo.source === "both"); // Action availability based on state const canClone = !hasLocal && hasRemote; const canPush = hasLocal && !!(status?.isAhead && status.unpushedCommits > 0); const canPull = hasLocal && !!(status?.hasRemote && status.unpulledCommits > 0); const canFetch = hasLocal && !!status?.hasRemote; const canOpenBrowser = hasRemote && !!githubInfo?.htmlUrl; const canOpenEditor = hasLocal && !!localPath; const footerWidth = Math.max(1, maxWidth - 4); const footerMetadata = [ githubInfo?.topics && githubInfo.topics.length > 0 ? `Topics: ${githubInfo.topics.join(", ")}` : null, githubInfo?.license ? `License: ${githubInfo.license}` : null, githubInfo?.language ? `Language: ${githubInfo.language}` : null, ].filter((value): value is string => value !== null).join(" "); const footerMetadataDisplay = footerMetadata.length > footerWidth ? `${footerMetadata.slice(0, Math.max(0, footerWidth - 1))}…` : footerMetadata; return ( {/* Header */} {githubInfo?.fullName || repo.name} {/* Repository Info */} {githubInfo && ( ☁ GitHub {githubInfo.fullName} )} {localInfo && ( 💻 Local {localInfo.path} )} {githubInfo && ( {githubInfo.isPrivate && 🔒 Private} ★{githubInfo.stargazersCount || 0} 🍴{githubInfo.forksCount || 0} )} {/* Status Section */} {status && ( {status.isDirty ? "● Dirty" : "○ Clean"} ⎇ {status.currentBranch} {status.modifiedCount} modified {localInfo?.type === "git-submodule" && ( (submodule) )} {githubInfo && ( {formatGitHubSize(githubInfo.size)} )} {status.stagedCount > 0 && ( {status.stagedCount} staged )} {status.untrackedCount > 0 && ( {status.untrackedCount} untracked )} ↑ {status.unpushedCommits} to push ↓ {status.unpulledCommits} to pull )} {/* Actions Section */} [Enter] Clone {canClone && (picker)} [p] Push [P] Pull [f] Fetch [o] Open Browser [d] Editor {!hasLocal && ( Clone to enable push, pull, fetch, and editor. )} {commands.length > 0 && ( {commands.slice(0, 5).map((cmd) => ( [{cmd.key}] {cmd.name} ))} {commands.length > 5 && ( +{commands.length - 5} more )} )} {/* README Section */} README.md {readmeLoading && ( Loading README... )} {readmeError && ( Error: {readmeError} )} {readmeContent && !readmeLoading && !readmeError && ( )} {!readmeContent && !readmeLoading && !readmeError && ( No README available )} {/* Footer */} {footerMetadataDisplay && {footerMetadataDisplay}} [Esc] Close [j/k ↑↓] Scroll [g/G] Top/Bottom ); }