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 reposRouteImport } from "./routes/repos";
|
||||||
import { Route as indexRouteImport } from "./routes/index";
|
import { Route as indexRouteImport } from "./routes/index";
|
||||||
import { Route as reposDotrepoIdRouteImport } from "./routes/repos.$repoId";
|
import { Route as reposDotrepoIdRouteImport } from "./routes/repos.$repoId";
|
||||||
|
import { Route as reposDotrepoIdDotblobDotsplatRouteImport } from "./routes/repos.$repoId.blob.$";
|
||||||
|
|
||||||
const reposRoute = reposRouteImport.update({
|
const reposRoute = reposRouteImport.update({
|
||||||
id: "/repos",
|
id: "/repos",
|
||||||
@@ -24,35 +25,45 @@ const reposDotrepoIdRoute = reposDotrepoIdRouteImport.update({
|
|||||||
path: "/repos/$repoId",
|
path: "/repos/$repoId",
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any);
|
} as any);
|
||||||
|
const reposDotrepoIdDotblobDotsplatRoute =
|
||||||
|
reposDotrepoIdDotblobDotsplatRouteImport.update({
|
||||||
|
id: "/repos/$repoId/blob/$",
|
||||||
|
path: "/repos/$repoId/blob/$",
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any);
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
"/": typeof indexRoute;
|
"/": typeof indexRoute;
|
||||||
"/repos": typeof reposRoute;
|
"/repos": typeof reposRoute;
|
||||||
"/repos/$repoId": typeof reposDotrepoIdRoute;
|
"/repos/$repoId": typeof reposDotrepoIdRoute;
|
||||||
|
"/repos/$repoId/blob/$": typeof reposDotrepoIdDotblobDotsplatRoute;
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
"/": typeof indexRoute;
|
"/": typeof indexRoute;
|
||||||
"/repos": typeof reposRoute;
|
"/repos": typeof reposRoute;
|
||||||
"/repos/$repoId": typeof reposDotrepoIdRoute;
|
"/repos/$repoId": typeof reposDotrepoIdRoute;
|
||||||
|
"/repos/$repoId/blob/$": typeof reposDotrepoIdDotblobDotsplatRoute;
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport;
|
__root__: typeof rootRouteImport;
|
||||||
"/": typeof indexRoute;
|
"/": typeof indexRoute;
|
||||||
"/repos": typeof reposRoute;
|
"/repos": typeof reposRoute;
|
||||||
"/repos/$repoId": typeof reposDotrepoIdRoute;
|
"/repos/$repoId": typeof reposDotrepoIdRoute;
|
||||||
|
"/repos/$repoId/blob/$": typeof reposDotrepoIdDotblobDotsplatRoute;
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath;
|
fileRoutesByFullPath: FileRoutesByFullPath;
|
||||||
fullPaths: "/" | "/repos" | "/repos/$repoId";
|
fullPaths: "/" | "/repos" | "/repos/$repoId" | "/repos/$repoId/blob/$";
|
||||||
fileRoutesByTo: FileRoutesByTo;
|
fileRoutesByTo: FileRoutesByTo;
|
||||||
to: "/" | "/repos" | "/repos/$repoId";
|
to: "/" | "/repos" | "/repos/$repoId" | "/repos/$repoId/blob/$";
|
||||||
id: "__root__" | "/" | "/repos" | "/repos/$repoId";
|
id: "__root__" | "/" | "/repos" | "/repos/$repoId" | "/repos/$repoId/blob/$";
|
||||||
fileRoutesById: FileRoutesById;
|
fileRoutesById: FileRoutesById;
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
indexRoute: typeof indexRoute;
|
indexRoute: typeof indexRoute;
|
||||||
reposRoute: typeof reposRoute;
|
reposRoute: typeof reposRoute;
|
||||||
reposDotrepoIdRoute: typeof reposDotrepoIdRoute;
|
reposDotrepoIdRoute: typeof reposDotrepoIdRoute;
|
||||||
|
reposDotrepoIdDotblobDotsplatRoute: typeof reposDotrepoIdDotblobDotsplatRoute;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "@tanstack/react-router" {
|
declare module "@tanstack/react-router" {
|
||||||
@@ -78,6 +89,13 @@ declare module "@tanstack/react-router" {
|
|||||||
preLoaderRoute: typeof reposDotrepoIdRouteImport;
|
preLoaderRoute: typeof reposDotrepoIdRouteImport;
|
||||||
parentRoute: typeof rootRouteImport;
|
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,
|
indexRoute: indexRoute,
|
||||||
reposRoute: reposRoute,
|
reposRoute: reposRoute,
|
||||||
reposDotrepoIdRoute: reposDotrepoIdRoute,
|
reposDotrepoIdRoute: reposDotrepoIdRoute,
|
||||||
|
reposDotrepoIdDotblobDotsplatRoute: reposDotrepoIdDotblobDotsplatRoute,
|
||||||
};
|
};
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export const routes = rootRoute("root.tsx", [
|
|||||||
index("index.tsx"),
|
index("index.tsx"),
|
||||||
route("/repos", "repos.tsx"),
|
route("/repos", "repos.tsx"),
|
||||||
route("/repos/$repoId", "repos.$repoId.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 };
|
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 {
|
export interface CommitInfo {
|
||||||
oid: string;
|
oid: string;
|
||||||
message: 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";
|
type Tab = "code" | "commits";
|
||||||
|
|
||||||
function RepoTabs({
|
function RepoTabs({
|
||||||
|
repoId,
|
||||||
treeEntries,
|
treeEntries,
|
||||||
treeLoading,
|
treeLoading,
|
||||||
commits,
|
commits,
|
||||||
@@ -85,6 +86,7 @@ function RepoTabs({
|
|||||||
readme,
|
readme,
|
||||||
readmeLoading,
|
readmeLoading,
|
||||||
}: {
|
}: {
|
||||||
|
repoId: string;
|
||||||
treeEntries: TreeEntry[] | undefined;
|
treeEntries: TreeEntry[] | undefined;
|
||||||
treeLoading: boolean;
|
treeLoading: boolean;
|
||||||
commits: CommitInfo[] | undefined;
|
commits: CommitInfo[] | undefined;
|
||||||
@@ -125,7 +127,11 @@ function RepoTabs({
|
|||||||
{/* Tab content */}
|
{/* Tab content */}
|
||||||
{tab === "code" && (
|
{tab === "code" && (
|
||||||
<>
|
<>
|
||||||
<RepoTreeSection entries={treeEntries} isLoading={treeLoading} />
|
<RepoTreeSection
|
||||||
|
entries={treeEntries}
|
||||||
|
isLoading={treeLoading}
|
||||||
|
repoId={repoId}
|
||||||
|
/>
|
||||||
<RepoReadmeSection readme={readme} isLoading={readmeLoading} />
|
<RepoReadmeSection readme={readme} isLoading={readmeLoading} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -256,6 +262,7 @@ export function RepoDetailPage() {
|
|||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<RepoTabs
|
<RepoTabs
|
||||||
|
repoId={repoId}
|
||||||
treeEntries={treeEntries}
|
treeEntries={treeEntries}
|
||||||
treeLoading={treeLoading}
|
treeLoading={treeLoading}
|
||||||
commits={commits}
|
commits={commits}
|
||||||
|
|||||||
@@ -1,26 +1,42 @@
|
|||||||
import { File, Folder } from "lucide-react";
|
import { File, Folder } from "lucide-react";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
import type { TreeEntry } from "../git-client";
|
import type { TreeEntry } from "../git-client";
|
||||||
|
|
||||||
function TreeRow({ entry }: { entry: TreeEntry }) {
|
function TreeRow({ entry, repoId }: { entry: TreeEntry; repoId: string }) {
|
||||||
const isDir = entry.type === "tree";
|
if (entry.type === "tree") {
|
||||||
return (
|
// Sub-tree navigation is deferred — show folders as visibly non-clickable
|
||||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2 text-sm last:border-b-0">
|
// so the affordance matches the behaviour.
|
||||||
{isDir ? (
|
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" />
|
<Folder className="h-4 w-4 shrink-0 text-blue-400" />
|
||||||
) : (
|
<span className="font-medium">{entry.name}</span>
|
||||||
<File className="h-4 w-4 shrink-0 text-muted-foreground" />
|
</div>
|
||||||
)}
|
);
|
||||||
<span className={isDir ? "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({
|
export function RepoTreeSection({
|
||||||
entries,
|
entries,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
repoId,
|
||||||
}: {
|
}: {
|
||||||
entries: TreeEntry[] | undefined;
|
entries: TreeEntry[] | undefined;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
repoId: string;
|
||||||
}) {
|
}) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -46,7 +62,7 @@ export function RepoTreeSection({
|
|||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<div className="overflow-hidden rounded-lg border border-border">
|
<div className="overflow-hidden rounded-lg border border-border">
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
<TreeRow key={entry.name} entry={entry} />
|
<TreeRow key={entry.name} entry={entry} repoId={repoId} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import {
|
|||||||
ensureClone,
|
ensureClone,
|
||||||
findReadme,
|
findReadme,
|
||||||
getCommitLog,
|
getCommitLog,
|
||||||
readFileContent,
|
readBlobView,
|
||||||
readTreeEntries,
|
readTreeEntries,
|
||||||
|
resolveHtmlAssets,
|
||||||
} from "./git-client";
|
} 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(
|
export function useGitBlob(
|
||||||
owner: string,
|
owner: string,
|
||||||
repoName: string,
|
repoName: string,
|
||||||
@@ -101,9 +102,37 @@ export function useGitBlob(
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { fs, dir } = cloneQuery.data!;
|
const { fs, dir } = cloneQuery.data!;
|
||||||
const oid = await resolveRef({ fs, dir, ref });
|
const oid = await resolveRef({ fs, dir, ref });
|
||||||
return readFileContent(fs, dir, oid, filepath);
|
return readBlobView(fs, dir, oid, filepath);
|
||||||
},
|
},
|
||||||
enabled: !!cloneQuery.data && !!filepath,
|
enabled: !!cloneQuery.data && !!filepath,
|
||||||
staleTime: 5 * 60_000,
|
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