mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
parent
61297ac80a
commit
0f89ad1692
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user