diff --git a/web/src/lib/api/blob.ts b/web/src/lib/api/blob.ts new file mode 100644 index 00000000..c880167e --- /dev/null +++ b/web/src/lib/api/blob.ts @@ -0,0 +1,175 @@ +import { error } from "@sveltejs/kit"; +import { ClientResponseError } from "$lib/api/client"; +import { blob as gitBlob, gitTarget } from "$lib/api/gitclient"; +import { toHttpError } from "$lib/api/load"; +import { toTreeCommitSummary, type CommitSummary } from "$lib/api/repo"; +import { isMarkdownFile } from "$lib/markup"; +import { renderReadme, type RepoLoadEvent, type RepoParent } from "./repoIndex"; + +export type BlobKind = "code" | "markup" | "svg" | "image" | "video" | "submodule" | "other"; + +export type BlobViewMode = "code" | "rendered"; + +export interface RepoBlobView { + ref: string; + path: string; + kind: BlobKind; + sizeLabel: string | null; + lines: number | null; + contents: string | null; + renderedHtml: string | null; + fileTooLarge: boolean; + defaultView: BlobViewMode; + submodule: { name: string; url: string } | null; + lastCommit: CommitSummary | null; +} + +// same 1MiB inline cap as the appview's maxBlobSize +export const MAX_INLINE_SIZE = 1 << 20; + +// go-humanize's Bytes +export const formatBytes = (bytes: number): string => { + if (bytes < 1000) return `${bytes} B`; + const units = ["kB", "MB", "GB", "TB", "PB", "EB"]; + let value = bytes; + let unit = "B"; + for (const next of units) { + if (value < 1000) break; + value /= 1000; + unit = next; + } + let rendered = value < 10 ? value.toFixed(1) : String(Math.round(value)); + // rounding can spill over the boundary ("1000 kB"), promote to the + // next unit and render again ("1.0 MB") + if (Number.parseFloat(rendered) >= 1000 && unit !== "EB") { + value /= 1000; + unit = units[units.indexOf(unit) + 1]; + rendered = value < 10 ? value.toFixed(1) : String(Math.round(value)); + } + return `${rendered} ${unit}`; +}; + +// textual application/* types (json, toml, ...) arrive with isBinary=false +// instead of a text/* mime +export const classifyBlob = (blob: { + path: string; + mimeType?: string; + isBinary?: boolean; + submodule?: unknown; +}): BlobKind => { + if (blob.submodule) return "submodule"; + const mediaType = (blob.mimeType ?? "").split(";")[0].trim().toLowerCase(); + if (mediaType === "image/svg+xml") return "svg"; + if (mediaType.startsWith("image/")) return "image"; + if (mediaType.startsWith("video/")) return "video"; + if (blob.isBinary === false || mediaType.startsWith("text/")) { + return isMarkdownFile(blob.path) ? "markup" : "code"; + } + return "other"; +}; + +export const hasTextView = (kind: BlobKind): boolean => + kind === "code" || kind === "markup" || kind === "svg"; + +export const hasRenderedView = (kind: BlobKind): boolean => kind === "markup" || kind === "svg"; + +// a trailing newline terminates the last line instead of starting a new one +const countLines = (text: string): number => { + if (text === "") return 0; + const lines = text.split("\n").length; + return text.endsWith("\n") ? lines - 1 : lines; +}; + +export const loadRepoBlob = async ( + event: RepoLoadEvent, + parent: RepoParent, + ref: string, + path: string +): Promise => { + const git = gitTarget(parent.publicConfig, parent.repo, event.fetch); + + // the knot 404s directories and missing paths alike + // knot2's repo_blob answers 413 over its serving limit instead of + // sending size json (knot-xrpc reads.rs) + const output = await gitBlob(git, { ref, path }, MAX_INLINE_SIZE).catch( + (cause: unknown): Awaited> | null => { + if (cause instanceof ClientResponseError && cause.status === 404) { + error(404, `${path} does not exist at ${ref}`); + } + if ( + cause instanceof ClientResponseError && + (cause.status === 413 || cause.error === "BlobTooLarge") + ) { + return null; + } + return toHttpError(cause, "Could not load file"); + } + ); + + if (output === null) { + return { + ref, + path, + kind: "other", + sizeLabel: null, + lines: null, + contents: null, + renderedHtml: null, + fileTooLarge: true, + defaultView: "code", + submodule: null, + lastCommit: null + }; + } + + const kind = classifyBlob(output); + const size = output.size ?? null; + const fileTooLarge = + output.fileTooLarge === true || (hasTextView(kind) && size !== null && size > MAX_INLINE_SIZE); + + const contents = + hasTextView(kind) && !fileTooLarge && output.encoding === "utf-8" + ? (output.content ?? null) + : null; + + const renderedHtml = + kind === "markup" && contents !== null + ? await renderReadme( + { filename: path, contents }, + parent, + event, + ref, + path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : undefined + ) + : null; + + // renderReadme gives up over its source limit, fall back to code + const viewKind = kind === "markup" && renderedHtml === null ? "code" : kind; + + const lastCommit = output.lastCommit + ? toTreeCommitSummary({ + hash: output.lastCommit.hash, + message: output.lastCommit.message, + when: output.lastCommit.when, + author: output.lastCommit.author + }) + : null; + + return { + ref, + path, + kind: viewKind, + sizeLabel: size === null ? null : formatBytes(size), + lines: contents !== null ? countLines(contents) : null, + contents, + renderedHtml, + fileTooLarge, + defaultView: hasRenderedView(viewKind) + ? event.url.searchParams.has("code") + ? "code" + : "rendered" + : "code", + submodule: output.submodule ? { name: output.submodule.name, url: output.submodule.url } : null, + lastCommit + }; +}; diff --git a/web/src/lib/api/repo.ts b/web/src/lib/api/repo.ts index d09d68d8..9de27359 100644 --- a/web/src/lib/api/repo.ts +++ b/web/src/lib/api/repo.ts @@ -132,7 +132,12 @@ export const toCommitSummary = (commit: LogCommit): CommitSummary => { // the tree endpoint uses lexicon casing where the log ones pass through go field // names, so this cannot share `toCommitSummary` -export const toTreeCommitSummary = (commit: Tree.LastCommit): CommitSummary => { +export const toTreeCommitSummary = (commit: { + hash: string; + message?: string; + when?: string; + author?: { name?: string; email?: string; when?: string }; +}): CommitSummary => { const [subject, body] = splitMessage(commit.message ?? ""); return { hash: commit.hash, diff --git a/web/src/lib/components/repo/BlobHeader.stories.svelte b/web/src/lib/components/repo/BlobHeader.stories.svelte new file mode 100644 index 00000000..a068bee1 --- /dev/null +++ b/web/src/lib/components/repo/BlobHeader.stories.svelte @@ -0,0 +1,88 @@ + + + + + + + + + { + const toggle = canvas.getByRole("checkbox", { name: "Wrap" }); + await expect(toggle).not.toBeChecked(); + await userEvent.click(toggle); + await expect(toggle).toBeChecked(); + }} +/> + + { + await expect(canvas.getByText("spindle")).toBeVisible(); + await expect(canvas.queryByRole("link", { name: "View raw" })).toBeNull(); + await expect(canvas.queryByRole("button", { name: "Download" })).toBeNull(); + }} +/> diff --git a/web/src/lib/components/repo/BlobHeader.svelte b/web/src/lib/components/repo/BlobHeader.svelte new file mode 100644 index 00000000..6470291d --- /dev/null +++ b/web/src/lib/components/repo/BlobHeader.svelte @@ -0,0 +1,123 @@ + + +
+
+ + +
+ {#if sizeLabel} + {sizeLabel} + {/if} + {#if sizeLabel && showLines} + + {/if} + {#if showLines} + {lines} {lines === 1 ? "line" : "lines"} + {/if} +
+
+ +
+ {#if showWrapToggle} + Wrap + {/if} + + {#if language && languageColor} +
+
+ {language} +
+ {/if} + {#if kind !== "submodule"} + + {#if hasRenderedView(kind)} + + {/if} + + {#if copyText !== null} +
+
diff --git a/web/src/lib/components/repo/BlobView.stories.svelte b/web/src/lib/components/repo/BlobView.stories.svelte new file mode 100644 index 00000000..42e0e621 --- /dev/null +++ b/web/src/lib/components/repo/BlobView.stories.svelte @@ -0,0 +1,152 @@ + + + + +tangled

social code collaboration for the at protocol.

", + defaultView: "rendered" + } + }} + play={async ({ canvas, canvasElement, userEvent }: PlayContext) => { + await expect(canvas.getByRole("heading", { name: "tangled" })).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: "View code" })); + await waitFor(() => expect(shadowText(canvasElement)).toContain("# tangled")); + }} +/> + + { + const image = canvas.getByRole("img", { name: "assets/logo.png" }); + await expect(image).toHaveAttribute("src", "/dawn/tangled/raw/main/assets/logo.png"); + }} +/> + + { + await expect(canvas.getByText("Previews are not supported for this file type.")).toBeVisible(); + }} +/> + + { + const link = canvas.getByRole("link", { name: "https://tangled.org/dawn/spindle" }); + await expect(link).toBeVisible(); + await expect(canvas.getByText(/This directory is a git submodule of/)).toBeVisible(); + }} +/> + + { + await expect(canvas.getByText(/This file is too large to render/)).toBeVisible(); + await expect(canvas.getByRole("link", { name: "View raw." })).toHaveAttribute( + "href", + "/dawn/tangled/raw/main/data/dump.sql" + ); + }} +/> diff --git a/web/src/lib/components/repo/BlobView.svelte b/web/src/lib/components/repo/BlobView.svelte new file mode 100644 index 00000000..9a7420bd --- /dev/null +++ b/web/src/lib/components/repo/BlobView.svelte @@ -0,0 +1,157 @@ + + + + + + {#if blob.lastCommit} + + {/if} + + {#if blob.kind === "submodule" && blob.submodule} +

+ This directory is a git submodule of + {blob.submodule.url}. +

+ {:else if blob.fileTooLarge} +

+ This file is too large to render. + View raw. +

+ {:else if blob.kind === "image" || (blob.kind === "svg" && view === "rendered")} +
+ {blob.path} +
+ {:else if blob.kind === "video"} +
+ +
+ {:else if blob.kind === "markup" && view === "rendered" && blob.renderedHtml !== null} + +
{@html blob.renderedHtml}
+ {:else if blob.contents !== null} +
+ +
+ {:else} +

+ Previews are not supported for this file type. +

+ {/if} +
diff --git a/web/src/lib/components/repo/language-colors.ts b/web/src/lib/components/repo/language-colors.ts index 6caa51a8..43d07ec7 100644 --- a/web/src/lib/components/repo/language-colors.ts +++ b/web/src/lib/components/repo/language-colors.ts @@ -670,3 +670,80 @@ export const LANGUAGE_COLORS: Record = { }; export const LANGUAGE_COLOR_FALLBACK = "#cccccc"; + +// the blob xrpc doesn't carry a language, fall back to extension and +// special-filename guesses +const EXTENSION_LANGUAGES: Record = { + astro: "Astro", + c: "C", + cc: "C++", + clj: "Clojure", + cpp: "C++", + cs: "C#", + css: "CSS", + cxx: "C++", + elm: "Elm", + erl: "Erlang", + ex: "Elixir", + exs: "Elixir", + go: "Go", + h: "C", + hh: "C++", + hpp: "C++", + hrl: "Erlang", + hs: "Haskell", + htm: "HTML", + html: "HTML", + java: "Java", + jl: "Julia", + js: "JavaScript", + json: "JSON", + jsx: "JSX", + kt: "Kotlin", + kts: "Kotlin", + lua: "Lua", + m: "Objective-C", + md: "Markdown", + mdx: "MDX", + mjs: "JavaScript", + ml: "OCaml", + mli: "OCaml", + nix: "Nix", + nu: "Nushell", + php: "PHP", + pl: "Perl", + py: "Python", + r: "R", + rb: "Ruby", + rs: "Rust", + sc: "Scala", + scala: "Scala", + sh: "Shell", + sql: "SQL", + svelte: "Svelte", + swift: "Swift", + toml: "TOML", + ts: "TypeScript", + tsx: "TSX", + vue: "Vue", + yaml: "YAML", + yml: "YAML", + zig: "Zig" +}; + +const FILENAME_LANGUAGES: Record = { + dockerfile: "Dockerfile", + makefile: "Makefile", + justfile: "Just", + "flake.lock": "JSON", + "cargo.lock": "TOML" +}; + +// best-effort enry name for a path, null when nothing matches +export const languageForPath = (path: string): string | null => { + const filename = (path.split("/").pop() ?? path).toLowerCase(); + const byName = FILENAME_LANGUAGES[filename]; + if (byName) return byName; + const ext = filename.includes(".") ? filename.slice(filename.lastIndexOf(".") + 1) : ""; + return EXTENSION_LANGUAGES[ext] ?? null; +}; diff --git a/web/src/lib/copy.svelte.ts b/web/src/lib/copy.svelte.ts new file mode 100644 index 00000000..c055d278 --- /dev/null +++ b/web/src/lib/copy.svelte.ts @@ -0,0 +1,22 @@ +export const createCopyFeedback = (resetAfter = 1500) => { + let lastCopied = $state(null); + let timer: ReturnType | undefined; + + $effect(() => () => clearTimeout(timer)); + + return { + get copied() { + return lastCopied; + }, + copy: async (label: string, value: string = label) => { + try { + await navigator.clipboard.writeText(value); + } catch { + return; + } + lastCopied = label; + clearTimeout(timer); + timer = setTimeout(() => (lastCopied = null), resetAfter); + } + }; +}; diff --git a/web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.svelte b/web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.svelte new file mode 100644 index 00000000..bf7cbae6 --- /dev/null +++ b/web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.svelte @@ -0,0 +1,14 @@ + + + diff --git a/web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.ts b/web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.ts new file mode 100644 index 00000000..b9182a65 --- /dev/null +++ b/web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.ts @@ -0,0 +1,35 @@ +import { error } from "@sveltejs/kit"; +import { browser } from "$app/environment"; +import { hasTextView, loadRepoBlob } from "$lib/api/blob"; +import { pierreFileOptions } from "$lib/components/repo/pierre"; +import { baseName } from "$lib/components/repo/urls"; +import type { PageLoad } from "./$types"; + +// the ref is a single encoded segment, `feature/x` arrives intact. the +// rest param is already the path as git knows it +export const load: PageLoad = async (event) => { + // no path means the knot would 400 on the empty path, 404 instead + if (event.params.path === "") error(404, "Not found"); + const parent = await event.parent(); + const blob = await loadRepoBlob(event, parent, event.params.ref, event.params.path); + + // same deal as the commit page: prerender the code view's shadow dom on + // the server, pierre paints client navigations itself + let prerenderedHTML: string | undefined; + if ( + !browser && + blob.contents !== null && + !blob.fileTooLarge && + hasTextView(blob.kind) && + blob.defaultView === "code" + ) { + const { preloadFile } = await import("@pierre/diffs/ssr"); + const prerendered = await preloadFile({ + file: { name: baseName(blob.path), contents: blob.contents }, + options: pierreFileOptions(false) + }); + prerenderedHTML = prerendered.prerenderedHTML; + } + + return { ...blob, prerenderedHTML }; +}; diff --git a/web/tsconfig.json b/web/tsconfig.json index 2c2ed3c4..fc4aefb6 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -10,7 +10,9 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "allowArbitraryExtensions": true } // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files