diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index b437b197a..674f94e63 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -75,7 +75,7 @@ import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLa import { writeRemoteShareLink } from "@plannotator/server/share-url"; import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file"; import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; -import { statSync, rmSync, realpathSync, existsSync, appendFileSync } from "fs"; +import { statSync, rmSync, realpathSync, existsSync } from "fs"; import { parseRemoteUrl } from "@plannotator/shared/repo"; import { getReviewApprovedPrompt, @@ -108,7 +108,7 @@ import { isTopLevelHelpInvocation, } from "./cli"; import path from "path"; -import { tmpdir, homedir } from "os"; +import { tmpdir } from "os"; // Embed the built HTML at compile time // @ts-ignore - Bun import attribute for text diff --git a/apps/pi-extension/server/reference.ts b/apps/pi-extension/server/reference.ts index 5bb6cd5bb..f71dfe6fa 100644 --- a/apps/pi-extension/server/reference.ts +++ b/apps/pi-extension/server/reference.ts @@ -32,6 +32,7 @@ import { isWithinProjectRoot, warmFileListCache, } from "../generated/resolve-file.js"; +import { parseCodePath } from "../generated/code-file.js"; import { htmlToMarkdown } from "../generated/html-to-markdown.js"; import { preloadFile } from "@pierre/diffs/ssr"; @@ -116,7 +117,9 @@ export async function handleDocRequest(res: Res, url: URL): Promise { // Code files: try literal resolve first; on miss, fall back to smart resolver. if (isCodeFilePath(requestedPath)) { - const literalPath = resolveUserPath(requestedPath, resolvedBase || projectRoot); + const parsed = parseCodePath(requestedPath); + const cleanPath = parsed.filePath; + const literalPath = resolveUserPath(cleanPath, resolvedBase || projectRoot); const literalAllowed = resolvedBase || isWithinProjectRoot(literalPath, projectRoot); let resolvedCode: string | null = null; @@ -125,7 +128,7 @@ export async function handleDocRequest(res: Res, url: URL): Promise { } if (!resolvedCode) { - const result = await resolveCodeFile(requestedPath, projectRoot); + const result = await resolveCodeFile(cleanPath, projectRoot); if (result.kind === "found") { resolvedCode = result.path; } else if (result.kind === "ambiguous") { @@ -163,7 +166,7 @@ export async function handleDocRequest(res: Res, url: URL): Promise { } catch { // Fall back to client-side rendering } - json(res, { codeFile: true, contents, filepath: resolvedCode, prerenderedHTML }); + json(res, { codeFile: true, contents, filepath: resolvedCode, prerenderedHTML, line: parsed.line, lineEnd: parsed.lineEnd }); return; } catch { json(res, { error: `File not found: ${requestedPath}` }, 404); @@ -234,7 +237,8 @@ export async function handleDocExistsRequest(res: Res, req: IncomingMessage): Pr await Promise.all( (paths as string[]).map(async (p) => { - const r = await resolveCodeFile(p, projectRoot, baseDir); + const cleanP = parseCodePath(p).filePath; + const r = await resolveCodeFile(cleanP, projectRoot, baseDir); if (r.kind === "found") { results[p] = { status: "found", resolved: r.path }; } else if (r.kind === "ambiguous") { diff --git a/packages/editor/demoPlan.ts b/packages/editor/demoPlan.ts index 8fe0a94ff..9558be651 100644 --- a/packages/editor/demoPlan.ts +++ b/packages/editor/demoPlan.ts @@ -34,7 +34,7 @@ export const COLLAB_CONFIG = { | \`package.json\` | Root config | ## Overview -Add real-time collaboration features to the editor using _**[WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API)**_ and *[operational transforms](https://en.wikipedia.org/wiki/Operational_transformation)*. +Add real-time collaboration features to the editor using _**[WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API)**_ and *[operational transforms](https://en.wikipedia.org/wiki/Operational_transformation)*. The core rendering logic is in \`packages/ui/components/Viewer.tsx:140-180\` and the block parser at \`packages/ui/utils/parser.ts:261-286\`. ## Phase 1: Infrastructure diff --git a/packages/server/reference-handlers.ts b/packages/server/reference-handlers.ts index 947aee0b1..af18848d0 100644 --- a/packages/server/reference-handlers.ts +++ b/packages/server/reference-handlers.ts @@ -8,6 +8,7 @@ import { existsSync, statSync } from "fs"; import { resolve } from "path"; import { buildFileTree, FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; +import { parseCodePath } from "@plannotator/shared/code-file"; import { detectObsidianVaults } from "./integrations"; import { isAbsoluteUserPath, @@ -83,7 +84,9 @@ export async function handleDoc(req: Request): Promise { // Code files: try literal resolve first; on miss, fall back to the smart // resolver which walks the project for case-insensitive / suffix matches. if (isCodeFilePath(requestedPath)) { - const literalPath = resolveUserPath(requestedPath, resolvedBase || projectRoot); + const parsed = parseCodePath(requestedPath); + const cleanPath = parsed.filePath; + const literalPath = resolveUserPath(cleanPath, resolvedBase || projectRoot); const literalAllowed = resolvedBase || isWithinProjectRoot(literalPath, projectRoot); let resolvedCode: string | null = null; @@ -95,7 +98,7 @@ export async function handleDoc(req: Request): Promise { } if (!resolvedCode) { - const result = await resolveCodeFile(requestedPath, projectRoot); + const result = await resolveCodeFile(cleanPath, projectRoot); if (result.kind === "found") { resolvedCode = result.path; } else if (result.kind === "ambiguous") { @@ -134,7 +137,7 @@ export async function handleDoc(req: Request): Promise { } catch { // Fall back to client-side rendering } - return Response.json({ codeFile: true, contents, filepath: resolvedCode, prerenderedHTML }); + return Response.json({ codeFile: true, contents, filepath: resolvedCode, prerenderedHTML, line: parsed.line, lineEnd: parsed.lineEnd }); } catch { return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 }); } @@ -215,7 +218,8 @@ export async function handleDocExists(req: Request): Promise { await Promise.all( (paths as string[]).map(async (p) => { - const r = await resolveCodeFile(p, projectRoot, baseDir); + const cleanP = parseCodePath(p).filePath; + const r = await resolveCodeFile(cleanP, projectRoot, baseDir); if (r.kind === "found") { results[p] = { status: "found", resolved: r.path }; } else if (r.kind === "ambiguous") { diff --git a/packages/shared/code-file.ts b/packages/shared/code-file.ts index fc9d95b72..43633a766 100644 --- a/packages/shared/code-file.ts +++ b/packages/shared/code-file.ts @@ -1,6 +1,6 @@ export const CODE_FILE_REGEX = /(?:\.(tsx?|jsx?|py|rb|go|rs|java|c|cpp|h|hpp|cs|swift|kt|scala|sh|bash|zsh|sql|graphql|json|ya?ml|toml|ini|css|scss|less|xml|tf|lua|r|dart|ex|exs|vue|svelte|astro|zig|proto)|(?:^|\/)(Dockerfile|Makefile|Rakefile|Gemfile|Procfile|Vagrantfile|Brewfile|Justfile))$/i; -export const CODE_PATH_BARE_REGEX = /(?:\.{0,2}\/)?(?:[a-zA-Z0-9_@.\-\[\]]+\/)+[a-zA-Z0-9_.\-\[\]]+\.[a-zA-Z0-9]+/g; +export const CODE_PATH_BARE_REGEX = /(?:\.{0,2}\/)?(?:[a-zA-Z0-9_@.\-\[\]]+\/)+[a-zA-Z0-9_.\-\[\]]+\.[a-zA-Z0-9]+(?::\d+(?:-\d+)?)?/g; const IMPLAUSIBLE_CHARS = /[{},*?\s]/; @@ -8,9 +8,31 @@ export function isPlausibleCodeFilePath(input: string): boolean { return !IMPLAUSIBLE_CHARS.test(input); } +export interface ParsedCodePath { + filePath: string; + line?: number; + lineEnd?: number; +} + +const LINE_SUFFIX_RE = /:(\d+)(?:-(\d+))?$/; + +export function parseCodePath(input: string): ParsedCodePath { + const clean = input.replace(/#.*$/, ''); + const m = clean.match(LINE_SUFFIX_RE); + if (!m) return { filePath: clean }; + let line = Number.parseInt(m[1], 10); + let lineEnd = m[2] ? Number.parseInt(m[2], 10) : undefined; + if (lineEnd != null && lineEnd < line) { const tmp = line; line = lineEnd; lineEnd = tmp; } + return { filePath: clean.replace(LINE_SUFFIX_RE, ''), line, lineEnd }; +} + +export function stripLineRef(input: string): string { + return input.replace(/#.*$/, '').replace(LINE_SUFFIX_RE, ''); +} + export function isCodeFilePath(input: string): boolean { if (!isPlausibleCodeFilePath(input)) return false; - return CODE_FILE_REGEX.test(input.replace(/#.*$/, '')) + return CODE_FILE_REGEX.test(stripLineRef(input)) && !input.startsWith('http://') && !input.startsWith('https://'); } diff --git a/packages/shared/pfm-reminder.ts b/packages/shared/pfm-reminder.ts index 3430d899e..9edf68f65 100644 --- a/packages/shared/pfm-reminder.ts +++ b/packages/shared/pfm-reminder.ts @@ -46,6 +46,7 @@ Code-file links (highest leverage) Reference real source files inline. Plannotator validates the path and renders a clickable badge that opens the file in the reviewer's editor — prefer this over pasting code when you just need to point at something. \`packages/server/index.ts\` backticked path \`packages/server/index.ts:42\` path with line number + \`packages/server/index.ts:10-20\` line range — hover shows a code snippet preview [the handler](packages/server/index.ts:42) markdown link form Ambiguous paths (e.g. \`index.ts\`) still render and open a picker. diff --git a/packages/ui/components/GraphvizBlock.tsx b/packages/ui/components/GraphvizBlock.tsx index ca6d394c7..b84de1202 100644 --- a/packages/ui/components/GraphvizBlock.tsx +++ b/packages/ui/components/GraphvizBlock.tsx @@ -164,7 +164,15 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => { const cleaned = renderedSvg .replace(/ width="[^"]*"/, ' width="100%"') .replace(/ height="[^"]*"/, ' height="100%"') - .replace(/ style="[^"]*"/, ''); + .replace(/ style="[^"]*"/, '') + .replace(/]*fill="white"[^>]*\/>/, '') + .replace(/fill="black"/g, 'fill="var(--foreground)"') + .replace(/fill="#000000"/g, 'fill="var(--foreground)"') + .replace(/stroke="black"/g, 'stroke="var(--muted-foreground)"') + .replace(/stroke="#000000"/g, 'stroke="var(--muted-foreground)"') + .replace(/fill="lightgrey"/g, 'fill="var(--muted)"') + .replace(/fill="lightgray"/g, 'fill="var(--muted)"'); + if (!cancelled) { naturalBoundsRef.current = parseViewBoxFromMarkup(cleaned); setSvg(cleaned); @@ -454,10 +462,15 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => { ); + const naturalHeight = naturalBoundsRef.current + ? `min(65vh, ${Math.min(36 * 16, Math.max(4 * 16, Math.round(naturalBoundsRef.current.height * (800 / naturalBoundsRef.current.width))))}px)` + : 'min(65vh, 36rem)'; + const diagramBody = (
= ({ block }) => { <>
{!isExpanded && controls} - {showSource || !svg ? inlineSource : !isExpanded ? diagramBody :
} + {showSource || !svg ? inlineSource : !isExpanded ? diagramBody :
}
{!showSource && svg && isExpanded && typeof document !== 'undefined' && createPortal( diff --git a/packages/ui/components/InlineMarkdown.tsx b/packages/ui/components/InlineMarkdown.tsx index eb654df69..1871a86c7 100644 --- a/packages/ui/components/InlineMarkdown.tsx +++ b/packages/ui/components/InlineMarkdown.tsx @@ -1,5 +1,7 @@ -import React from "react"; -import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX } from "@plannotator/shared/code-file"; +import React, { useState, useRef, useCallback, useEffect, useMemo } from "react"; +import { createPortal } from "react-dom"; +import hljs from "highlight.js"; +import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX, parseCodePath } from "@plannotator/shared/code-file"; import { transformPlainText } from "../utils/inlineTransforms"; import { getImageSrc } from "./ImageThumbnail"; import { useCodePathValidation, type CodePathValidationContextValue } from "./CodePathValidationContext"; @@ -33,15 +35,148 @@ function gateCodePath( } } +function extToLanguage(filepath: string): string | undefined { + const ext = filepath.split('.').pop()?.toLowerCase(); + const map: Record = { + ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript', + py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java', + css: 'css', scss: 'scss', json: 'json', yml: 'yaml', yaml: 'yaml', + sql: 'sql', sh: 'bash', bash: 'bash', zsh: 'bash', md: 'markdown', + html: 'html', xml: 'xml', toml: 'toml', swift: 'swift', kt: 'kotlin', + }; + return ext ? map[ext] : undefined; +} + +const CodeSnippetPreview: React.FC<{ + anchorEl: HTMLElement | null; + contents: string; + filepath: string; + line: number; + lineEnd?: number; + onMouseEnter?: () => void; + onMouseLeave?: () => void; +}> = ({ anchorEl, contents, filepath, line, lineEnd, onMouseEnter, onMouseLeave }) => { + const allLines = contents.split('\n'); + const start = Math.max(0, line - 1); + const end = Math.min(allLines.length, (lineEnd ?? line)); + const snippet = allLines.slice(start, end).join('\n'); + + const highlightedLines = useMemo(() => { + const lang = extToLanguage(filepath); + const lines = snippet.split('\n'); + return lines.map(line => { + try { + if (lang) return hljs.highlight(line, { language: lang }).value; + return hljs.highlightAuto(line).value; + } catch { + return line.replace(/&/g, '&').replace(//g, '>'); + } + }); + }, [snippet, filepath]); + + if (!anchorEl) return null; + + const rect = anchorEl.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.bottom; + const showAbove = spaceBelow < 200 && rect.top > spaceBelow; + const top = showAbove ? undefined : rect.bottom + 4; + const bottom = showAbove ? window.innerHeight - rect.top + 4 : undefined; + const left = Math.max(8, Math.min(rect.left, window.innerWidth - 520)); + + return createPortal( +
+
+ {filepath.split('/').pop()} + {lineEnd && lineEnd !== line ? `lines ${line}–${lineEnd}` : `line ${line}`} +
+
+ + + {snippet.split('\n').map((_, i) => ( + + + + ))} + +
{start + i + 1} +
+
+
, + document.body, + ); +}; + const CodeFileLink: React.FC<{ candidate: string; display: string; onOpenCodeFile: (path: string) => void; -}> = ({ candidate, display, onOpenCodeFile }) => { + baseDir?: string; +}> = ({ candidate, display, onOpenCodeFile, baseDir }) => { const validation = useCodePathValidation(); const gate = gateCodePath(candidate, validation); - const [pickerOpen, setPickerOpen] = React.useState(false); - const anchorRef = React.useRef(null); + const [pickerOpen, setPickerOpen] = useState(false); + const [hoverPreview, setHoverPreview] = useState<{ contents: string; filepath: string } | null>(null); + const hoverPreviewRef = useRef(hoverPreview); + hoverPreviewRef.current = hoverPreview; + const anchorRef = useRef(null); + const showTimerRef = useRef | null>(null); + const hideTimerRef = useRef | null>(null); + const parsed = parseCodePath(candidate); + const hasLineRef = parsed.line != null; + + const cancelHide = useCallback(() => { + if (hideTimerRef.current) { clearTimeout(hideTimerRef.current); hideTimerRef.current = null; } + }, []); + + const scheduleHide = useCallback(() => { + cancelHide(); + hideTimerRef.current = setTimeout(() => { + setHoverPreview(null); + }, 200); + }, [cancelHide]); + + const handleMouseEnter = useCallback(() => { + if (!hasLineRef || gate.render === 'plain') return; + cancelHide(); + if (hoverPreviewRef.current) return; + showTimerRef.current = setTimeout(async () => { + try { + const params = new URLSearchParams({ path: candidate }); + if (baseDir) params.set('base', baseDir); + const res = await fetch(`/api/doc?${params}`); + const data = await res.json(); + if (data.contents) setHoverPreview({ contents: data.contents, filepath: data.filepath ?? candidate }); + } catch {} + }, 150); + }, [candidate, hasLineRef, gate.render, cancelHide, baseDir]); + + const handleMouseLeave = useCallback(() => { + if (showTimerRef.current) { clearTimeout(showTimerRef.current); showTimerRef.current = null; } + scheduleHide(); + }, [scheduleHide]); + + const handlePreviewEnter = useCallback(() => { + cancelHide(); + }, [cancelHide]); + + const handlePreviewLeave = useCallback(() => { + scheduleHide(); + }, [scheduleHide]); + + useEffect(() => { + return () => { + if (showTimerRef.current) clearTimeout(showTimerRef.current); + if (hideTimerRef.current) clearTimeout(hideTimerRef.current); + }; + }, []); if (gate.render === 'plain') { return ( @@ -52,12 +187,15 @@ const CodeFileLink: React.FC<{ } const isAmbiguous = gate.render === 'ambiguous-link'; + const lineSuffix = parsed.line != null ? `:${parsed.line}${parsed.lineEnd != null ? `-${parsed.lineEnd}` : ''}` : ''; const handleClick = () => { + handleMouseLeave(); if (isAmbiguous) { setPickerOpen(true); return; } - onOpenCodeFile(gate.render === 'link' && gate.resolved ? gate.resolved : candidate); + const resolvedPath = gate.render === 'link' && gate.resolved ? gate.resolved : candidate; + onOpenCodeFile(gate.render === 'link' && gate.resolved ? resolvedPath + lineSuffix : candidate); }; return ( @@ -69,6 +207,8 @@ const CodeFileLink: React.FC<{ data-ambiguous={isAmbiguous ? "true" : undefined} onClick={handleClick} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleClick(); } }} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} className="code-file-link px-1.5 py-0.5 rounded bg-muted text-sm font-mono cursor-pointer hover:text-primary inline-flex items-center gap-1 transition-colors" title={isAmbiguous ? `${display} — multiple matches` : `View: ${display}`} > @@ -78,11 +218,22 @@ const CodeFileLink: React.FC<{ {(gate as { matches: string[] }).matches.length} )} + {hoverPreview && hasLineRef && ( + + )} {pickerOpen && isAmbiguous && ( { setPickerOpen(false); onOpenCodeFile(path); }} + onPick={(path) => { setPickerOpen(false); onOpenCodeFile(path + lineSuffix); }} onDismiss={() => setPickerOpen(false)} /> )} @@ -142,6 +293,7 @@ function emitPlainTextWithBareUrls( nextKey: () => number, onOpenCodeFile?: (path: string) => void, validation?: CodePathValidationContextValue | null, + baseDir?: string, ): void { if (text.length === 0) return; @@ -213,6 +365,7 @@ function emitPlainTextWithBareUrls( candidate={cleanPath} display={span.value} onOpenCodeFile={onOpenCodeFile!} + baseDir={baseDir} />, ); } @@ -453,6 +606,7 @@ export const InlineMarkdown: React.FC<{ candidate={cleanPath} display={codeContent} onOpenCodeFile={onOpenCodeFile} + baseDir={imageBaseDir} />, ); } else { @@ -810,7 +964,7 @@ export const InlineMarkdown: React.FC<{ // detected inline via emitPlainTextWithBareUrls() below. const nextSpecial = remaining.slice(1).search(/[\*_`\[!~\\<#@]/); const plainText = nextSpecial === -1 ? remaining : remaining.slice(0, nextSpecial + 1); - emitPlainTextWithBareUrls(plainText, previousChar, parts, () => key++, onOpenCodeFile, validation); + emitPlainTextWithBareUrls(plainText, previousChar, parts, () => key++, onOpenCodeFile, validation, imageBaseDir); previousChar = plainText[plainText.length - 1] || previousChar; if (nextSpecial === -1) { break; diff --git a/packages/ui/hooks/pfm/useCodeFilePopout.ts b/packages/ui/hooks/pfm/useCodeFilePopout.ts new file mode 100644 index 000000000..fe7dfa08e --- /dev/null +++ b/packages/ui/hooks/pfm/useCodeFilePopout.ts @@ -0,0 +1,111 @@ +import { useState, useCallback } from "react"; +import { parseCodePath } from "@plannotator/shared/code-file"; + +interface CodeFileState { + filepath: string; + contents: string; + prerenderedHTML?: string; + error?: string; + requestedPath?: string; + line?: number; + lineEnd?: number; +} + +interface UseCodeFilePopoutOptions { + buildUrl: (codePath: string) => string; +} + +export interface UseCodeFilePopoutReturn { + open: (codePath: string) => void; + close: () => void; + isLoading: boolean; + popoutProps: { + open: boolean; + onClose: () => void; + filepath: string; + contents: string; + prerenderedHTML?: string; + error?: string; + requestedPath?: string; + line?: number; + lineEnd?: number; + } | null; +} + +export function useCodeFilePopout( + options: UseCodeFilePopoutOptions +): UseCodeFilePopoutReturn { + const { buildUrl } = options; + const [state, setState] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const close = useCallback(() => { + setState(null); + setIsLoading(false); + }, []); + + const open = useCallback( + async (codePath: string) => { + setIsLoading(true); + const parsed = parseCodePath(codePath); + try { + const res = await fetch(buildUrl(codePath)); + const data = (await res.json()) as { + codeFile?: boolean; + contents?: string; + filepath?: string; + prerenderedHTML?: string; + error?: string; + line?: number; + lineEnd?: number; + }; + if (!res.ok || data.error || !data.codeFile || typeof data.contents !== 'string' || !data.filepath) { + setState({ + filepath: codePath, + contents: "", + error: data.error ?? `File not found in repo: ${codePath}`, + requestedPath: codePath, + }); + setIsLoading(false); + return; + } + setState({ + filepath: data.filepath, + contents: data.contents, + prerenderedHTML: data.prerenderedHTML, + line: data.line ?? parsed.line, + lineEnd: data.lineEnd ?? parsed.lineEnd, + }); + setIsLoading(false); + } catch { + setState({ + filepath: codePath, + contents: "", + error: `Failed to load: ${codePath}`, + requestedPath: codePath, + }); + setIsLoading(false); + } + }, + [buildUrl] + ); + + return { + open, + close, + isLoading, + popoutProps: state + ? { + open: true, + onClose: close, + filepath: state.filepath, + contents: state.contents, + prerenderedHTML: state.prerenderedHTML, + error: state.error, + requestedPath: state.requestedPath, + line: state.line, + lineEnd: state.lineEnd, + } + : null, + }; +} diff --git a/packages/ui/hooks/useCodeFilePopout.ts b/packages/ui/hooks/useCodeFilePopout.ts index e5e0c3de4..21a3256b5 100644 --- a/packages/ui/hooks/useCodeFilePopout.ts +++ b/packages/ui/hooks/useCodeFilePopout.ts @@ -1,97 +1 @@ -import { useState, useCallback } from "react"; - -interface CodeFileState { - filepath: string; - contents: string; - prerenderedHTML?: string; - error?: string; - requestedPath?: string; -} - -interface UseCodeFilePopoutOptions { - buildUrl: (codePath: string) => string; -} - -export interface UseCodeFilePopoutReturn { - open: (codePath: string) => void; - close: () => void; - isLoading: boolean; - popoutProps: { - open: boolean; - onClose: () => void; - filepath: string; - contents: string; - prerenderedHTML?: string; - error?: string; - requestedPath?: string; - } | null; -} - -export function useCodeFilePopout( - options: UseCodeFilePopoutOptions -): UseCodeFilePopoutReturn { - const { buildUrl } = options; - const [state, setState] = useState(null); - const [isLoading, setIsLoading] = useState(false); - - const close = useCallback(() => { - setState(null); - setIsLoading(false); - }, []); - - const open = useCallback( - async (codePath: string) => { - setIsLoading(true); - try { - const res = await fetch(buildUrl(codePath)); - const data = (await res.json()) as { - codeFile?: boolean; - contents?: string; - filepath?: string; - prerenderedHTML?: string; - error?: string; - }; - if (!res.ok || data.error || !data.codeFile || typeof data.contents !== 'string' || !data.filepath) { - // Surface the not-found state so the popout can show a clear message - // instead of silently doing nothing. - setState({ - filepath: codePath, - contents: "", - error: data.error ?? `File not found in repo: ${codePath}`, - requestedPath: codePath, - }); - setIsLoading(false); - return; - } - setState({ filepath: data.filepath, contents: data.contents, prerenderedHTML: data.prerenderedHTML }); - setIsLoading(false); - } catch { - setState({ - filepath: codePath, - contents: "", - error: `Failed to load: ${codePath}`, - requestedPath: codePath, - }); - setIsLoading(false); - } - }, - [buildUrl] - ); - - return { - open, - close, - isLoading, - popoutProps: state - ? { - open: true, - onClose: close, - filepath: state.filepath, - contents: state.contents, - prerenderedHTML: state.prerenderedHTML, - error: state.error, - requestedPath: state.requestedPath, - } - : null, - }; -} +export { useCodeFilePopout, type UseCodeFilePopoutReturn } from "./pfm/useCodeFilePopout";