web: clickable repo tree + per-file blob viewer (#773)

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
tlongwell-block
2026-05-28 15:55:03 -04:00
committed by GitHub
co-authored by npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
parent 61297ac80a
commit 0f89ad1692
9 changed files with 690 additions and 18 deletions
+22 -3
View File
@@ -8,6 +8,7 @@ import { Route as rootRouteImport } from "./routes/root";
import { Route as reposRouteImport } from "./routes/repos";
import { Route as indexRouteImport } from "./routes/index";
import { Route as reposDotrepoIdRouteImport } from "./routes/repos.$repoId";
import { Route as reposDotrepoIdDotblobDotsplatRouteImport } from "./routes/repos.$repoId.blob.$";
const reposRoute = reposRouteImport.update({
id: "/repos",
@@ -24,35 +25,45 @@ const reposDotrepoIdRoute = reposDotrepoIdRouteImport.update({
path: "/repos/$repoId",
getParentRoute: () => rootRouteImport,
} as any);
const reposDotrepoIdDotblobDotsplatRoute =
reposDotrepoIdDotblobDotsplatRouteImport.update({
id: "/repos/$repoId/blob/$",
path: "/repos/$repoId/blob/$",
getParentRoute: () => rootRouteImport,
} as any);
export interface FileRoutesByFullPath {
"/": typeof indexRoute;
"/repos": typeof reposRoute;
"/repos/$repoId": typeof reposDotrepoIdRoute;
"/repos/$repoId/blob/$": typeof reposDotrepoIdDotblobDotsplatRoute;
}
export interface FileRoutesByTo {
"/": typeof indexRoute;
"/repos": typeof reposRoute;
"/repos/$repoId": typeof reposDotrepoIdRoute;
"/repos/$repoId/blob/$": typeof reposDotrepoIdDotblobDotsplatRoute;
}
export interface FileRoutesById {
__root__: typeof rootRouteImport;
"/": typeof indexRoute;
"/repos": typeof reposRoute;
"/repos/$repoId": typeof reposDotrepoIdRoute;
"/repos/$repoId/blob/$": typeof reposDotrepoIdDotblobDotsplatRoute;
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath;
fullPaths: "/" | "/repos" | "/repos/$repoId";
fullPaths: "/" | "/repos" | "/repos/$repoId" | "/repos/$repoId/blob/$";
fileRoutesByTo: FileRoutesByTo;
to: "/" | "/repos" | "/repos/$repoId";
id: "__root__" | "/" | "/repos" | "/repos/$repoId";
to: "/" | "/repos" | "/repos/$repoId" | "/repos/$repoId/blob/$";
id: "__root__" | "/" | "/repos" | "/repos/$repoId" | "/repos/$repoId/blob/$";
fileRoutesById: FileRoutesById;
}
export interface RootRouteChildren {
indexRoute: typeof indexRoute;
reposRoute: typeof reposRoute;
reposDotrepoIdRoute: typeof reposDotrepoIdRoute;
reposDotrepoIdDotblobDotsplatRoute: typeof reposDotrepoIdDotblobDotsplatRoute;
}
declare module "@tanstack/react-router" {
@@ -78,6 +89,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof reposDotrepoIdRouteImport;
parentRoute: typeof rootRouteImport;
};
"/repos/$repoId/blob/$": {
id: "/repos/$repoId/blob/$";
path: "/repos/$repoId/blob/$";
fullPath: "/repos/$repoId/blob/$";
preLoaderRoute: typeof reposDotrepoIdDotblobDotsplatRouteImport;
parentRoute: typeof rootRouteImport;
};
}
}
@@ -85,6 +103,7 @@ const rootRouteChildren: RootRouteChildren = {
indexRoute: indexRoute,
reposRoute: reposRoute,
reposDotrepoIdRoute: reposDotrepoIdRoute,
reposDotrepoIdDotblobDotsplatRoute: reposDotrepoIdDotblobDotsplatRoute,
};
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
+1
View File
@@ -4,4 +4,5 @@ export const routes = rootRoute("root.tsx", [
index("index.tsx"),
route("/repos", "repos.tsx"),
route("/repos/$repoId", "repos.$repoId.tsx"),
route("/repos/$repoId/blob/$", "repos.$repoId.blob.$.tsx"),
]);
@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { RepoBlobPage } from "@/features/repos/ui/RepoBlobViewer";
export const Route = createFileRoute("/repos/$repoId/blob/$")({
component: RepoBlobPage,
});
+232
View File
@@ -158,6 +158,238 @@ export async function readFileContent(
return { content, isBinary: false };
}
/**
* Inline-preview caps. Different ceilings per kind:
* - Text: a 1 MiB string is already big to render in the DOM. Over → download.
* - Image: raster decoders handle this cheaply; cap is a sanity ceiling, not
* a perf brake. Normal screenshots (≤ ~few MB) preview just fine.
* - Binary: no preview cap — we always offer download, regardless of size.
*
* The clone in IndexedDB always holds full bytes; these are display caps only.
*/
export const TEXT_PREVIEW_LIMIT_BYTES = 1 * 1024 * 1024;
export const IMAGE_PREVIEW_LIMIT_BYTES = 10 * 1024 * 1024;
/**
* Discriminated view of a blob, suitable for rendering. The viewer component
* is responsible for `URL.createObjectURL` / `revokeObjectURL` over `bytes` —
* we deliberately do NOT create object URLs inside the React Query cache.
*
* Image-by-extension is restricted to raster formats. SVG is intentionally
* absent: it can carry active content; we render SVG via the text path
* (where applicable) instead.
*
* `too-large` carries the cap that was hit, so the viewer can explain which
* limit applied without re-computing it.
*/
export type BlobView =
| { kind: "text"; content: string; sizeBytes: number }
| { kind: "markdown"; content: string; sizeBytes: number }
| { kind: "html"; content: string; sizeBytes: number }
| { kind: "image"; bytes: Uint8Array; contentType: string; sizeBytes: number }
| { kind: "binary"; bytes: Uint8Array; sizeBytes: number }
| {
kind: "too-large";
bytes: Uint8Array;
sizeBytes: number;
limitBytes: number;
};
const RASTER_IMAGE_MIME: Readonly<Record<string, string>> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
avif: "image/avif",
};
const MARKDOWN_EXTS = new Set(["md", "markdown"]);
const HTML_EXTS = new Set(["html", "htm"]);
function extOf(filepath: string): string {
const base = filepath.split("/").pop() ?? "";
const dot = base.lastIndexOf(".");
if (dot <= 0) return "";
return base.slice(dot + 1).toLowerCase();
}
function hasNulByte(bytes: Uint8Array): boolean {
const n = Math.min(bytes.length, 512);
for (let i = 0; i < n; i++) {
if (bytes[i] === 0) return true;
}
return false;
}
/**
* Classify a blob into a `BlobView`. Applies per-kind preview caps.
*
* Order: image-by-extension first (so a 2 MiB PNG isn't rejected as oversized
* text), then binary detection, then text/markdown decode.
*/
export async function readBlobView(
fs: LightningFS,
dir: string,
oid: string,
filepath: string,
): Promise<BlobView> {
const { blob } = await readBlob({ fs, dir, oid, filepath });
const bytes = blob as Uint8Array;
const sizeBytes = bytes.length;
const ext = extOf(filepath);
const mime = RASTER_IMAGE_MIME[ext];
if (mime) {
if (sizeBytes > IMAGE_PREVIEW_LIMIT_BYTES) {
return {
kind: "too-large",
bytes,
sizeBytes,
limitBytes: IMAGE_PREVIEW_LIMIT_BYTES,
};
}
return { kind: "image", bytes, contentType: mime, sizeBytes };
}
if (hasNulByte(bytes)) {
// Binary: no preview cap. Always download.
return { kind: "binary", bytes, sizeBytes };
}
if (sizeBytes > TEXT_PREVIEW_LIMIT_BYTES) {
return {
kind: "too-large",
bytes,
sizeBytes,
limitBytes: TEXT_PREVIEW_LIMIT_BYTES,
};
}
// Fatal decode: anything that *looks* binary-ish but slipped past the NUL
// sniff (rare-but-real for non-UTF formats without an early 0x00) falls
// through to the binary path instead of being rendered as mojibake.
let content: string;
try {
content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
return { kind: "binary", bytes, sizeBytes };
}
if (MARKDOWN_EXTS.has(ext)) {
return { kind: "markdown", content, sizeBytes };
}
if (HTML_EXTS.has(ext)) {
return { kind: "html", content, sizeBytes };
}
return { kind: "text", content, sizeBytes };
}
/**
* Inline a repo's same-repo relative assets into one self-contained HTML
* string, suitable for rendering inside a sandboxed iframe (which has no
* notion of the repo's directory tree and cannot fetch siblings).
*
* Only *relative* `<script src>`, `<link href>`, and `<img src>` are
* resolved — against the HTML file's own directory, scoped to paths that
* exist in the clone. Absolute paths (`/x`) and external URLs
* (`http(s):`, `data:`, `//host`, `#frag`) are left untouched: we never
* reach outside the repo or rewrite something the author meant for the
* network.
*
* Assets are inlined as `data:` URLs so the result is fully detached — it
* carries no live `blob:` handles that would need revoking. This is a
* display transform on a copy; the clone in IndexedDB is unchanged.
*/
const ASSET_MIME: Readonly<Record<string, string>> = {
...RASTER_IMAGE_MIME,
js: "text/javascript",
mjs: "text/javascript",
css: "text/css",
json: "application/json",
svg: "image/svg+xml",
woff: "font/woff",
woff2: "font/woff2",
};
function isExternalRef(ref: string): boolean {
// Absolute path, protocol URL, protocol-relative, fragment, or empty.
return (
ref === "" ||
ref.startsWith("/") ||
ref.startsWith("#") ||
ref.startsWith("data:") ||
/^[a-z][a-z0-9+.-]*:/i.test(ref) ||
ref.startsWith("//")
);
}
/** Resolve `dir`-relative `ref` (e.g. `../js/app.js`) to a clone path. */
function resolveRelative(baseDir: string, ref: string): string | null {
const clean = ref.split(/[?#]/)[0];
const parts = baseDir ? baseDir.split("/") : [];
for (const seg of clean.split("/")) {
if (seg === "" || seg === ".") continue;
if (seg === "..") {
if (parts.length === 0) return null; // escapes repo root
parts.pop();
} else {
parts.push(seg);
}
}
return parts.join("/");
}
function bytesToDataUrl(bytes: Uint8Array, mime: string): string {
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return `data:${mime};base64,${btoa(binary)}`;
}
export async function resolveHtmlAssets(
fs: LightningFS,
dir: string,
oid: string,
htmlPath: string,
html: string,
): Promise<string> {
const slash = htmlPath.lastIndexOf("/");
const baseDir = slash >= 0 ? htmlPath.slice(0, slash) : "";
const doc = new DOMParser().parseFromString(html, "text/html");
const targets: Array<{ el: Element; attr: string }> = [
...[...doc.querySelectorAll("script[src]")].map((el) => ({
el,
attr: "src",
})),
...[...doc.querySelectorAll("link[href]")].map((el) => ({
el,
attr: "href",
})),
...[...doc.querySelectorAll("img[src]")].map((el) => ({ el, attr: "src" })),
];
await Promise.all(
targets.map(async ({ el, attr }) => {
const ref = el.getAttribute(attr);
if (!ref || isExternalRef(ref)) return;
const path = resolveRelative(baseDir, ref);
if (!path) return;
const mime = ASSET_MIME[extOf(path)] ?? "application/octet-stream";
try {
const { blob } = await readBlob({ fs, dir, oid, filepath: path });
el.setAttribute(attr, bytesToDataUrl(blob as Uint8Array, mime));
} catch {
// Sibling not in the clone (e.g. a deferred subtree): leave the
// reference as-is. It will simply fail to load in the sandbox.
}
}),
);
return `<!doctype html>\n${doc.documentElement.outerHTML}`;
}
export interface CommitInfo {
oid: string;
message: string;
@@ -0,0 +1,327 @@
/**
* Renders a single repo blob fetched via `useGitBlob`. Designed to be safe by
* construction: no JS/HTML execution path, no SVG-as-image (SVG can carry
* active content; we render it as text instead), and a hard preview-size cap
* with a download fallback for anything over the limit.
*
* Object URLs for image/binary are created in a local effect and revoked on
* unmount or input change — they are never cached inside React Query results.
*/
import { ArrowLeft, Check, Copy, Download, FileText, Play } from "lucide-react";
import { useEffect, useState } from "react";
import { Link, useParams } from "@tanstack/react-router";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { toast } from "sonner";
import { Button } from "@/shared/ui/button";
import type { BlobView } from "../git-client";
import { useGitBlob, useGitHtmlDoc } from "../use-git-browse";
import { useRepoContext } from "../use-repo-context";
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`;
return `${(n / (1024 * 1024)).toFixed(2)} MiB`;
}
function basename(path: string): string {
return path.split("/").pop() ?? path;
}
/**
* Stable object-URL for a byte buffer. Revokes on dependency change / unmount.
* The viewer creates one per render-lifetime — the cache layer only stores bytes.
*/
function useObjectUrl(
bytes: Uint8Array | null,
contentType: string,
): string | null {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
if (!bytes) {
setUrl(null);
return;
}
// The cast normalises `Uint8Array<ArrayBufferLike>` (isomorphic-git's
// return shape) to `Uint8Array<ArrayBuffer>` so it's accepted as a `BlobPart`
// under strict TS lib types.
const blob = new Blob([bytes as Uint8Array<ArrayBuffer>], {
type: contentType,
});
const next = URL.createObjectURL(blob);
setUrl(next);
return () => {
URL.revokeObjectURL(next);
};
}, [bytes, contentType]);
return url;
}
function CopyTextButton({ content }: { content: string }) {
const [copied, setCopied] = useState(false);
return (
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
toast.success("Copied to clipboard");
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error("Failed to copy to clipboard");
}
}}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
<span className="ml-2">Copy</span>
</Button>
);
}
function DownloadButton({
bytes,
contentType,
filename,
}: {
bytes: Uint8Array;
contentType: string;
filename: string;
}) {
const url = useObjectUrl(bytes, contentType);
if (!url) return null;
return (
<Button asChild variant="outline" size="sm">
<a href={url} download={filename}>
<Download className="h-4 w-4" />
<span className="ml-2">Download</span>
</a>
</Button>
);
}
function TextView({ content }: { content: string }) {
// Plain monospace render. Line numbers would be nice but require a list of
// keyed children for an immutable text dump; not worth the linter dance for
// v1. The browser handles wrapping/scrolling via `<pre>`.
return (
<pre className="overflow-auto whitespace-pre rounded-lg border border-border bg-muted/30 p-4 font-mono text-sm leading-6">
{content}
</pre>
);
}
function ImageView({
bytes,
contentType,
filename,
}: {
bytes: Uint8Array;
contentType: string;
filename: string;
}) {
const url = useObjectUrl(bytes, contentType);
if (!url) return null;
return (
<div className="flex justify-center rounded-lg border border-border bg-muted/30 p-4">
<img
src={url}
alt={filename}
className="max-h-[80vh] max-w-full object-contain"
/>
</div>
);
}
/**
* Runs a repo's HTML in a sandboxed iframe.
*
* SECURITY — the entire trust boundary is the `sandbox` attribute below.
* `allow-scripts` lets the page's JS run; the deliberate ABSENCE of
* `allow-same-origin` forces the frame to an opaque (`null`) origin, so its
* scripts CANNOT read the parent's cookies, IndexedDB, localStorage, relay
* session, or NIP-98 auth — even though we render on the same document origin.
* Do not add `allow-same-origin`: that would hand pushed code the user's
* session. `srcDoc` carries the asset-inlined doc; nothing reaches the network
* for same-repo content.
*/
const RUN_SANDBOX = "allow-scripts";
function HtmlRunView({ doc }: { doc: string }) {
return (
<iframe
title="Repository page (sandboxed)"
srcDoc={doc}
sandbox={RUN_SANDBOX}
className="h-[80vh] w-full rounded-lg border border-border bg-white"
/>
);
}
function ViewerBody({
view,
filename,
htmlDoc,
}: {
view: BlobView;
filename: string;
htmlDoc: string | null;
}) {
switch (view.kind) {
case "text":
return <TextView content={view.content} />;
case "markdown":
return (
<div className="prose prose-sm dark:prose-invert max-w-none rounded-lg border border-border p-4">
<Markdown remarkPlugins={[remarkGfm]}>{view.content}</Markdown>
</div>
);
case "html":
// `htmlDoc` is the asset-inlined doc, present only once the user opts in
// via "Run"; until then (and while it resolves) we show the source.
return htmlDoc !== null ? (
<HtmlRunView doc={htmlDoc} />
) : (
<TextView content={view.content} />
);
case "image":
return (
<ImageView
bytes={view.bytes}
contentType={view.contentType}
filename={filename}
/>
);
case "binary":
return (
<div className="rounded-lg border border-border bg-muted/30 p-6 text-sm text-muted-foreground">
Binary file {formatBytes(view.sizeBytes)}. Use the Download button
above to save it.
</div>
);
case "too-large":
return (
<div className="rounded-lg border border-border bg-muted/30 p-6 text-sm text-muted-foreground">
File is {formatBytes(view.sizeBytes)}, over the{" "}
{formatBytes(view.limitBytes)} preview limit. Use the Download button
above to save it.
</div>
);
}
}
export function RepoBlobPage() {
const { repoId, _splat } = useParams({ from: "/repos/$repoId/blob/$" });
const filepath = _splat ?? "";
const {
owner,
repoName,
defaultRef,
isLoading: ctxLoading,
error: ctxError,
} = useRepoContext(repoId);
const {
data: view,
isLoading,
error,
} = useGitBlob(owner, repoName, defaultRef, filepath);
const [running, setRunning] = useState(false);
const isHtml = view?.kind === "html";
const { data: htmlDoc, isFetching: htmlFetching } = useGitHtmlDoc(
owner,
repoName,
defaultRef,
filepath,
isHtml ? view.content : "",
running && isHtml,
);
const filename = basename(filepath);
if (ctxError) {
return (
<div className="px-4 py-8">
<BackLink repoId={repoId} />
<p className="mt-4 text-sm text-destructive">
Failed to load repository: {ctxError.message}
</p>
</div>
);
}
return (
<div className="px-4 py-8">
<BackLink repoId={repoId} />
<div className="mt-4 flex flex-wrap items-center gap-3">
<FileText className="h-5 w-5 text-muted-foreground" />
<h1 className="min-w-0 truncate font-mono text-sm">{filepath}</h1>
<div className="ml-auto flex items-center gap-2">
{view &&
(view.kind === "text" ||
view.kind === "markdown" ||
view.kind === "html") && (
<CopyTextButton content={view.content} />
)}
{isHtml && (
<Button
variant={running ? "secondary" : "default"}
size="sm"
onClick={() => setRunning((r) => !r)}
>
<Play className="h-4 w-4" />
<span className="ml-2">{running ? "Show source" : "Run"}</span>
</Button>
)}
{view &&
view.kind !== "text" &&
view.kind !== "markdown" &&
view.kind !== "html" && (
<DownloadButton
bytes={view.bytes}
contentType={
view.kind === "image"
? view.contentType
: "application/octet-stream"
}
filename={filename}
/>
)}
</div>
</div>
<div className="mt-6">
{ctxLoading || isLoading ? (
<div className="h-32 animate-pulse rounded-lg bg-muted" />
) : error ? (
<p className="text-sm text-destructive">
Failed to load file: {(error as Error).message}
</p>
) : view ? (
<ViewerBody
view={view}
filename={filename}
htmlDoc={running && !htmlFetching ? (htmlDoc ?? null) : null}
/>
) : null}
</div>
</div>
);
}
function BackLink({ repoId }: { repoId: string }) {
return (
<Link
to="/repos/$repoId"
params={{ repoId }}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back to repository
</Link>
);
}
+8 -1
View File
@@ -78,6 +78,7 @@ function DetailSkeleton() {
type Tab = "code" | "commits";
function RepoTabs({
repoId,
treeEntries,
treeLoading,
commits,
@@ -85,6 +86,7 @@ function RepoTabs({
readme,
readmeLoading,
}: {
repoId: string;
treeEntries: TreeEntry[] | undefined;
treeLoading: boolean;
commits: CommitInfo[] | undefined;
@@ -125,7 +127,11 @@ function RepoTabs({
{/* Tab content */}
{tab === "code" && (
<>
<RepoTreeSection entries={treeEntries} isLoading={treeLoading} />
<RepoTreeSection
entries={treeEntries}
isLoading={treeLoading}
repoId={repoId}
/>
<RepoReadmeSection readme={readme} isLoading={readmeLoading} />
</>
)}
@@ -256,6 +262,7 @@ export function RepoDetailPage() {
{/* Tabs */}
<RepoTabs
repoId={repoId}
treeEntries={treeEntries}
treeLoading={treeLoading}
commits={commits}
+27 -11
View File
@@ -1,26 +1,42 @@
import { File, Folder } from "lucide-react";
import { Link } from "@tanstack/react-router";
import type { TreeEntry } from "../git-client";
function TreeRow({ entry }: { entry: TreeEntry }) {
const isDir = entry.type === "tree";
return (
<div className="flex items-center gap-2 border-b border-border px-3 py-2 text-sm last:border-b-0">
{isDir ? (
function TreeRow({ entry, repoId }: { entry: TreeEntry; repoId: string }) {
if (entry.type === "tree") {
// Sub-tree navigation is deferred — show folders as visibly non-clickable
// so the affordance matches the behaviour.
return (
<div
className="flex items-center gap-2 border-b border-border px-3 py-2 text-sm text-muted-foreground last:border-b-0"
aria-disabled="true"
>
<Folder className="h-4 w-4 shrink-0 text-blue-400" />
) : (
<File className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className={isDir ? "font-medium" : ""}>{entry.name}</span>
</div>
<span className="font-medium">{entry.name}</span>
</div>
);
}
return (
<Link
to="/repos/$repoId/blob/$"
params={{ repoId, _splat: entry.name }}
className="flex items-center gap-2 border-b border-border px-3 py-2 text-sm last:border-b-0 hover:bg-muted/50"
>
<File className="h-4 w-4 shrink-0 text-muted-foreground" />
<span>{entry.name}</span>
</Link>
);
}
export function RepoTreeSection({
entries,
isLoading,
repoId,
}: {
entries: TreeEntry[] | undefined;
isLoading: boolean;
repoId: string;
}) {
if (isLoading) {
return (
@@ -46,7 +62,7 @@ export function RepoTreeSection({
<div className="mt-8">
<div className="overflow-hidden rounded-lg border border-border">
{entries.map((entry) => (
<TreeRow key={entry.name} entry={entry} />
<TreeRow key={entry.name} entry={entry} repoId={repoId} />
))}
</div>
</div>
+32 -3
View File
@@ -11,8 +11,9 @@ import {
ensureClone,
findReadme,
getCommitLog,
readFileContent,
readBlobView,
readTreeEntries,
resolveHtmlAssets,
} from "./git-client";
/**
@@ -87,7 +88,7 @@ export function useGitReadme(owner: string, repoName: string, ref: string) {
});
}
/** Read a single file's content. */
/** Read a single file's content as a classified `BlobView`. */
export function useGitBlob(
owner: string,
repoName: string,
@@ -101,9 +102,37 @@ export function useGitBlob(
queryFn: async () => {
const { fs, dir } = cloneQuery.data!;
const oid = await resolveRef({ fs, dir, ref });
return readFileContent(fs, dir, oid, filepath);
return readBlobView(fs, dir, oid, filepath);
},
enabled: !!cloneQuery.data && !!filepath,
staleTime: 5 * 60_000,
});
}
/**
* Resolve an HTML file into a self-contained doc (relative assets inlined),
* ready to drop into a sandboxed iframe. Lazy: `enabled` is caller-gated so
* we only do the inlining work when the user clicks "Run". The decoded HTML
* is passed in (the blob view already has it) to avoid a second read.
*/
export function useGitHtmlDoc(
owner: string,
repoName: string,
ref: string,
filepath: string,
html: string,
enabled: boolean,
) {
const cloneQuery = useGitClone(owner, repoName, ref);
return useQuery({
queryKey: ["git-html-doc", owner, repoName, ref, filepath],
queryFn: async () => {
const { fs, dir } = cloneQuery.data!;
const oid = await resolveRef({ fs, dir, ref });
return resolveHtmlAssets(fs, dir, oid, filepath, html);
},
enabled: enabled && !!cloneQuery.data && !!filepath,
staleTime: 5 * 60_000,
});
}
@@ -0,0 +1,35 @@
/**
* Shared resolver for the `(owner, repoName, defaultRef)` triple every
* repo-scoped page needs. Combines the NIP-34 announcement (`useRepo`) with
* the refs query (`useRepoRefs`) so callers don't duplicate that wiring.
*
* `defaultRef` falls back to `"main"` until refs load, matching the
* pre-existing behaviour in `RepoDetailPage`.
*/
import { useRepo } from "./use-repos";
import { useRepoRefs } from "./use-repo-refs";
export interface RepoContext {
owner: string;
repoName: string;
defaultRef: string;
isLoading: boolean;
error: Error | null;
}
export function useRepoContext(repoId: string): RepoContext {
const {
data: repo,
isLoading: repoLoading,
error: repoError,
} = useRepo(repoId);
const { data: refs, isLoading: refsLoading } = useRepoRefs(repoId);
return {
owner: repo?.owner ?? "",
repoName: repo?.id ?? "",
defaultRef: refs?.head?.ref ?? "main",
isLoading: repoLoading || refsLoading,
error: (repoError as Error | null) ?? null,
};
}