Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions web/src/app/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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" {
Expand All @@ -78,13 +89,21 @@ 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;
};
}
}

const rootRouteChildren: RootRouteChildren = {
indexRoute: indexRoute,
reposRoute: reposRoute,
reposDotrepoIdRoute: reposDotrepoIdRoute,
reposDotrepoIdDotblobDotsplatRoute: reposDotrepoIdDotblobDotsplatRoute,
};
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
Expand Down
1 change: 1 addition & 0 deletions web/src/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]);
6 changes: 6 additions & 0 deletions web/src/app/routes/repos.$repoId.blob.$.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 232 additions & 0 deletions web/src/features/repos/git-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading