This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

web: add the blob page

Signed-off-by: dawn <dawn@tangled.org>

author
dawn
date (Aug 1, 2026, 2:21 AM +0300) commit 19e6e414 parent e0fb899c change-id tmpwrqzp
+852 -2
+175
web/src/lib/api/blob.ts
··· 1 + import { error } from "@sveltejs/kit"; 2 + import { ClientResponseError } from "$lib/api/client"; 3 + import { blob as gitBlob, gitTarget } from "$lib/api/gitclient"; 4 + import { toHttpError } from "$lib/api/load"; 5 + import { toTreeCommitSummary, type CommitSummary } from "$lib/api/repo"; 6 + import { isMarkdownFile } from "$lib/markup"; 7 + import { renderReadme, type RepoLoadEvent, type RepoParent } from "./repoIndex"; 8 + 9 + export type BlobKind = "code" | "markup" | "svg" | "image" | "video" | "submodule" | "other"; 10 + 11 + export type BlobViewMode = "code" | "rendered"; 12 + 13 + export interface RepoBlobView { 14 + ref: string; 15 + path: string; 16 + kind: BlobKind; 17 + sizeLabel: string | null; 18 + lines: number | null; 19 + contents: string | null; 20 + renderedHtml: string | null; 21 + fileTooLarge: boolean; 22 + defaultView: BlobViewMode; 23 + submodule: { name: string; url: string } | null; 24 + lastCommit: CommitSummary | null; 25 + } 26 + 27 + // same 1MiB inline cap as the appview's maxBlobSize 28 + export const MAX_INLINE_SIZE = 1 << 20; 29 + 30 + // go-humanize's Bytes 31 + export const formatBytes = (bytes: number): string => { 32 + if (bytes < 1000) return `${bytes} B`; 33 + const units = ["kB", "MB", "GB", "TB", "PB", "EB"]; 34 + let value = bytes; 35 + let unit = "B"; 36 + for (const next of units) { 37 + if (value < 1000) break; 38 + value /= 1000; 39 + unit = next; 40 + } 41 + let rendered = value < 10 ? value.toFixed(1) : String(Math.round(value)); 42 + // rounding can spill over the boundary ("1000 kB"), promote to the 43 + // next unit and render again ("1.0 MB") 44 + if (Number.parseFloat(rendered) >= 1000 && unit !== "EB") { 45 + value /= 1000; 46 + unit = units[units.indexOf(unit) + 1]; 47 + rendered = value < 10 ? value.toFixed(1) : String(Math.round(value)); 48 + } 49 + return `${rendered} ${unit}`; 50 + }; 51 + 52 + // textual application/* types (json, toml, ...) arrive with isBinary=false 53 + // instead of a text/* mime 54 + export const classifyBlob = (blob: { 55 + path: string; 56 + mimeType?: string; 57 + isBinary?: boolean; 58 + submodule?: unknown; 59 + }): BlobKind => { 60 + if (blob.submodule) return "submodule"; 61 + const mediaType = (blob.mimeType ?? "").split(";")[0].trim().toLowerCase(); 62 + if (mediaType === "image/svg+xml") return "svg"; 63 + if (mediaType.startsWith("image/")) return "image"; 64 + if (mediaType.startsWith("video/")) return "video"; 65 + if (blob.isBinary === false || mediaType.startsWith("text/")) { 66 + return isMarkdownFile(blob.path) ? "markup" : "code"; 67 + } 68 + return "other"; 69 + }; 70 + 71 + export const hasTextView = (kind: BlobKind): boolean => 72 + kind === "code" || kind === "markup" || kind === "svg"; 73 + 74 + export const hasRenderedView = (kind: BlobKind): boolean => kind === "markup" || kind === "svg"; 75 + 76 + // a trailing newline terminates the last line instead of starting a new one 77 + const countLines = (text: string): number => { 78 + if (text === "") return 0; 79 + const lines = text.split("\n").length; 80 + return text.endsWith("\n") ? lines - 1 : lines; 81 + }; 82 + 83 + export const loadRepoBlob = async ( 84 + event: RepoLoadEvent, 85 + parent: RepoParent, 86 + ref: string, 87 + path: string 88 + ): Promise<RepoBlobView> => { 89 + const git = gitTarget(parent.publicConfig, parent.repo, event.fetch); 90 + 91 + // the knot 404s directories and missing paths alike 92 + // knot2's repo_blob answers 413 over its serving limit instead of 93 + // sending size json (knot-xrpc reads.rs) 94 + const output = await gitBlob(git, { ref, path }, MAX_INLINE_SIZE).catch( 95 + (cause: unknown): Awaited<ReturnType<typeof gitBlob>> | null => { 96 + if (cause instanceof ClientResponseError && cause.status === 404) { 97 + error(404, `${path} does not exist at ${ref}`); 98 + } 99 + if ( 100 + cause instanceof ClientResponseError && 101 + (cause.status === 413 || cause.error === "BlobTooLarge") 102 + ) { 103 + return null; 104 + } 105 + return toHttpError(cause, "Could not load file"); 106 + } 107 + ); 108 + 109 + if (output === null) { 110 + return { 111 + ref, 112 + path, 113 + kind: "other", 114 + sizeLabel: null, 115 + lines: null, 116 + contents: null, 117 + renderedHtml: null, 118 + fileTooLarge: true, 119 + defaultView: "code", 120 + submodule: null, 121 + lastCommit: null 122 + }; 123 + } 124 + 125 + const kind = classifyBlob(output); 126 + const size = output.size ?? null; 127 + const fileTooLarge = 128 + output.fileTooLarge === true || (hasTextView(kind) && size !== null && size > MAX_INLINE_SIZE); 129 + 130 + const contents = 131 + hasTextView(kind) && !fileTooLarge && output.encoding === "utf-8" 132 + ? (output.content ?? null) 133 + : null; 134 + 135 + const renderedHtml = 136 + kind === "markup" && contents !== null 137 + ? await renderReadme( 138 + { filename: path, contents }, 139 + parent, 140 + event, 141 + ref, 142 + path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : undefined 143 + ) 144 + : null; 145 + 146 + // renderReadme gives up over its source limit, fall back to code 147 + const viewKind = kind === "markup" && renderedHtml === null ? "code" : kind; 148 + 149 + const lastCommit = output.lastCommit 150 + ? toTreeCommitSummary({ 151 + hash: output.lastCommit.hash, 152 + message: output.lastCommit.message, 153 + when: output.lastCommit.when, 154 + author: output.lastCommit.author 155 + }) 156 + : null; 157 + 158 + return { 159 + ref, 160 + path, 161 + kind: viewKind, 162 + sizeLabel: size === null ? null : formatBytes(size), 163 + lines: contents !== null ? countLines(contents) : null, 164 + contents, 165 + renderedHtml, 166 + fileTooLarge, 167 + defaultView: hasRenderedView(viewKind) 168 + ? event.url.searchParams.has("code") 169 + ? "code" 170 + : "rendered" 171 + : "code", 172 + submodule: output.submodule ? { name: output.submodule.name, url: output.submodule.url } : null, 173 + lastCommit 174 + }; 175 + };
+6 -1
web/src/lib/api/repo.ts
··· 132 132 133 133 // the tree endpoint uses lexicon casing where the log ones pass through go field 134 134 // names, so this cannot share `toCommitSummary` 135 - export const toTreeCommitSummary = (commit: Tree.LastCommit): CommitSummary => { 135 + export const toTreeCommitSummary = (commit: { 136 + hash: string; 137 + message?: string; 138 + when?: string; 139 + author?: { name?: string; email?: string; when?: string }; 140 + }): CommitSummary => { 136 141 const [subject, body] = splitMessage(commit.message ?? ""); 137 142 return { 138 143 hash: commit.hash,
+88
web/src/lib/components/repo/BlobHeader.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta, type StoryContext } from "@storybook/addon-svelte-csf"; 3 + import { expect } from "storybook/test"; 4 + import BlobHeader from "./BlobHeader.svelte"; 5 + 6 + type PlayContext = Pick< 7 + StoryContext<Record<string, unknown>>, 8 + "canvas" | "canvasElement" | "userEvent" 9 + >; 10 + 11 + const copiesContents = async ({ canvas, userEvent }: PlayContext) => { 12 + const written: string[] = []; 13 + Object.defineProperty(navigator, "clipboard", { 14 + value: { writeText: async (text: string) => written.push(text) }, 15 + configurable: true 16 + }); 17 + await userEvent.click(canvas.getByRole("button", { name: "Copy contents" })); 18 + await expect(written).toEqual(["export const answer = 42;\n"]); 19 + 20 + await expect(canvas.getByRole("link", { name: "View raw" })).toHaveAttribute( 21 + "href", 22 + "/dawn/tangled/raw/main/web/src/lib/components/repo/BlobView.svelte" 23 + ); 24 + const download = canvas.getByRole("link", { name: "Download" }); 25 + await expect(download).toHaveAttribute("download", "BlobView.svelte"); 26 + }; 27 + 28 + const showsLanguageChip = async ({ canvas, canvasElement }: PlayContext) => { 29 + await expect(canvas.getByText("Svelte")).toBeVisible(); 30 + await expect(canvasElement.querySelector(".size-2.rounded-full")).not.toBeNull(); 31 + }; 32 + 33 + const flipsView = async ({ canvas, userEvent }: PlayContext) => { 34 + await userEvent.click(canvas.getByRole("button", { name: "View code" })); 35 + await expect(canvas.getByRole("button", { name: "View rendered" })).toBeVisible(); 36 + }; 37 + 38 + const { Story } = defineMeta({ 39 + title: "Repo/BlobHeader", 40 + component: BlobHeader, 41 + tags: ["autodocs"], 42 + args: { 43 + ownerHandle: "dawn", 44 + repoName: "tangled", 45 + ref: "main", 46 + path: "web/src/lib/components/repo/BlobView.svelte", 47 + kind: "code", 48 + sizeLabel: "4.2 kB", 49 + lines: 87, 50 + copyText: "export const answer = 42;\n" 51 + } 52 + }); 53 + </script> 54 + 55 + <Story name="Code file" play={copiesContents} /> 56 + 57 + <Story 58 + name="Language chip" 59 + args={{ path: "web/src/App.svelte", language: "Svelte" }} 60 + play={showsLanguageChip} 61 + /> 62 + 63 + <Story 64 + name="Markdown rendered" 65 + args={{ kind: "markup", path: "docs/guide.md", view: "rendered" }} 66 + play={flipsView} 67 + /> 68 + 69 + <Story 70 + name="Wrap toggle" 71 + args={{ showWrap: true }} 72 + play={async ({ canvas, userEvent }: PlayContext) => { 73 + const toggle = canvas.getByRole("checkbox", { name: "Wrap" }); 74 + await expect(toggle).not.toBeChecked(); 75 + await userEvent.click(toggle); 76 + await expect(toggle).toBeChecked(); 77 + }} 78 + /> 79 + 80 + <Story 81 + name="Submodule" 82 + args={{ kind: "submodule", path: "vendor/spindle", sizeLabel: null, lines: null, copyText: null }} 83 + play={async ({ canvas }: PlayContext) => { 84 + await expect(canvas.getByText("spindle")).toBeVisible(); 85 + await expect(canvas.queryByRole("link", { name: "View raw" })).toBeNull(); 86 + await expect(canvas.queryByRole("button", { name: "Download" })).toBeNull(); 87 + }} 88 + />
+123
web/src/lib/components/repo/BlobHeader.svelte
··· 1 + <script lang="ts"> 2 + import Check from "$icon/check"; 3 + import Copy from "$icon/copy"; 4 + import Download from "$icon/download"; 5 + import { hasRenderedView, hasTextView, type BlobKind, type BlobViewMode } from "$lib/api/blob"; 6 + import { createCopyFeedback } from "$lib/copy.svelte"; 7 + import Button from "$lib/components/ui/Button.svelte"; 8 + import ButtonGroup from "$lib/components/ui/ButtonGroup.svelte"; 9 + import Checkbox from "$lib/components/ui/Checkbox.svelte"; 10 + import { LANGUAGE_COLORS, LANGUAGE_COLOR_FALLBACK } from "./language-colors"; 11 + import RefChip from "./RefChip.svelte"; 12 + import RepoCrumbs from "./RepoCrumbs.svelte"; 13 + import { baseName, rawBlobHref } from "./urls"; 14 + 15 + interface Props { 16 + ownerHandle: string; 17 + repoName: string; 18 + ref: string; 19 + path: string; 20 + kind: BlobKind; 21 + // enry language name. the blob xrpc does not expose one yet 22 + language?: string | null; 23 + sizeLabel?: string | null; 24 + lines?: number | null; 25 + view?: BlobViewMode; 26 + wrap?: boolean; 27 + showWrap?: boolean; 28 + copyText?: string | null; 29 + } 30 + 31 + let { 32 + ownerHandle, 33 + repoName, 34 + ref, 35 + path, 36 + kind, 37 + language = null, 38 + sizeLabel = null, 39 + lines = null, 40 + view = $bindable("code"), 41 + wrap = $bindable(false), 42 + showWrap = false, 43 + copyText = null 44 + }: Props = $props(); 45 + 46 + const rawHref = $derived(rawBlobHref(ownerHandle, repoName, ref, path)); 47 + const fileName = $derived(baseName(path)); 48 + 49 + const showLines = $derived(lines !== null && view === "code"); 50 + const showWrapToggle = $derived(showWrap && hasTextView(kind) && view === "code"); 51 + const languageColor = $derived( 52 + language ? (LANGUAGE_COLORS[language] ?? LANGUAGE_COLOR_FALLBACK) : null 53 + ); 54 + 55 + const copyFeedback = createCopyFeedback(); 56 + const copyContents = () => { 57 + if (copyText !== null) void copyFeedback.copy(copyText); 58 + }; 59 + </script> 60 + 61 + <div class="mb-3 flex flex-wrap items-center justify-between gap-2 typography-paragraph-regular"> 62 + <div class="flex min-w-0 items-center gap-3"> 63 + <RepoCrumbs {ownerHandle} {repoName} {ref} {path} /> 64 + 65 + <div class="flex items-center gap-2 text-foreground-muted"> 66 + {#if sizeLabel} 67 + <span>{sizeLabel}</span> 68 + {/if} 69 + {#if sizeLabel && showLines} 70 + <span class="select-none" aria-hidden="true">&middot;</span> 71 + {/if} 72 + {#if showLines} 73 + <span>{lines} {lines === 1 ? "line" : "lines"}</span> 74 + {/if} 75 + </div> 76 + </div> 77 + 78 + <div class="flex flex-wrap items-center gap-2 whitespace-nowrap max-md:order-3 max-md:w-full"> 79 + {#if showWrapToggle} 80 + <Checkbox bind:checked={wrap} class="px-2 text-foreground-muted">Wrap</Checkbox> 81 + {/if} 82 + <RefChip {ownerHandle} {repoName} {ref} /> 83 + {#if language && languageColor} 84 + <div 85 + class="flex w-fit items-center gap-2 rounded border border-border-default bg-background-inset p-2 whitespace-nowrap" 86 + > 87 + <div 88 + class="size-2 rounded-full" 89 + style:background="radial-gradient(circle at 35% 35%, color-mix(in srgb, {languageColor} 70%, 90 + white), {languageColor} 30%, color-mix(in srgb, {languageColor} 85%, black))" 91 + ></div> 92 + {language} 93 + </div> 94 + {/if} 95 + {#if kind !== "submodule"} 96 + <ButtonGroup> 97 + {#if hasRenderedView(kind)} 98 + <Button size="sm" onclick={() => (view = view === "code" ? "rendered" : "code")}> 99 + View {view === "rendered" ? "code" : "rendered"} 100 + </Button> 101 + {/if} 102 + <Button size="sm" href={rawHref}>View raw</Button> 103 + {#if copyText !== null} 104 + <Button 105 + size="sm" 106 + onclick={copyContents} 107 + title="Copy contents" 108 + aria-label="Copy contents" 109 + icon={copyFeedback.copied !== null ? Check : Copy} 110 + /> 111 + {/if} 112 + <Button 113 + size="sm" 114 + href={rawHref} 115 + download={fileName} 116 + title="Download" 117 + aria-label="Download" 118 + icon={Download} 119 + /> 120 + </ButtonGroup> 121 + {/if} 122 + </div> 123 + </div>
+152
web/src/lib/components/repo/BlobView.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta, type StoryContext } from "@storybook/addon-svelte-csf"; 3 + import { expect, waitFor } from "storybook/test"; 4 + import type { RepoBlobView } from "$lib/api/blob"; 5 + import BlobView from "./BlobView.svelte"; 6 + 7 + const lastCommit = { 8 + hash: "0123456789abcdef0123456789abcdef01234567", 9 + shortHash: "01234567", 10 + subject: "add the blob view", 11 + body: "", 12 + authorName: "dawn", 13 + authorEmail: "dawn@example.test", 14 + when: "2026-07-28T09:00:00Z" 15 + }; 16 + 17 + const codeBlob: RepoBlobView = { 18 + ref: "main", 19 + path: "web/src/main.ts", 20 + kind: "code", 21 + sizeLabel: "1.2 kB", 22 + lines: 3, 23 + contents: 'import { serve } from "./server";\n\nserve({ port: 5555 });\n', 24 + renderedHtml: null, 25 + fileTooLarge: false, 26 + defaultView: "code", 27 + submodule: null, 28 + lastCommit 29 + }; 30 + 31 + type PlayContext = Pick< 32 + StoryContext<Record<string, unknown>>, 33 + "canvas" | "canvasElement" | "userEvent" 34 + >; 35 + 36 + // pierre renders inside a shadow root, findByText cannot see the code 37 + const shadowText = (canvasElement: HTMLElement) => 38 + canvasElement.querySelector("diffs-container")?.shadowRoot?.textContent ?? ""; 39 + 40 + const showsCode = async ({ canvas, canvasElement }: PlayContext) => { 41 + await expect(canvas.getByText("main.ts")).toBeVisible(); 42 + await expect(canvas.getByText("3 lines")).toBeVisible(); 43 + await expect(canvas.getByRole("link", { name: "main" })).toBeVisible(); 44 + await expect(canvas.getByText("TypeScript")).toBeVisible(); 45 + await expect(canvas.getByRole("link", { name: "View raw" })).toBeVisible(); 46 + await waitFor(() => expect(shadowText(canvasElement)).toContain("serve({ port: 5555 })")); 47 + }; 48 + 49 + const { Story } = defineMeta({ 50 + title: "Repo/BlobView", 51 + component: BlobView, 52 + tags: ["autodocs"], 53 + args: { ownerHandle: "dawn", repoName: "tangled", blob: codeBlob, language: "TypeScript" } 54 + }); 55 + </script> 56 + 57 + <Story name="Code file" play={showsCode} /> 58 + 59 + <Story 60 + name="Markdown rendered" 61 + args={{ 62 + blob: { 63 + ...codeBlob, 64 + path: "docs/guide.md", 65 + kind: "markup", 66 + lines: 5, 67 + contents: "# tangled\n\nsocial code collaboration for the at protocol.\n", 68 + renderedHtml: "<h1>tangled</h1><p>social code collaboration for the at protocol.</p>", 69 + defaultView: "rendered" 70 + } 71 + }} 72 + play={async ({ canvas, canvasElement, userEvent }: PlayContext) => { 73 + await expect(canvas.getByRole("heading", { name: "tangled" })).toBeVisible(); 74 + await userEvent.click(canvas.getByRole("button", { name: "View code" })); 75 + await waitFor(() => expect(shadowText(canvasElement)).toContain("# tangled")); 76 + }} 77 + /> 78 + 79 + <Story 80 + name="Image" 81 + args={{ 82 + blob: { 83 + ...codeBlob, 84 + path: "assets/logo.png", 85 + kind: "image", 86 + sizeLabel: "18 kB", 87 + lines: null, 88 + contents: null 89 + } 90 + }} 91 + play={async ({ canvas }: PlayContext) => { 92 + const image = canvas.getByRole("img", { name: "assets/logo.png" }); 93 + await expect(image).toHaveAttribute("src", "/dawn/tangled/raw/main/assets/logo.png"); 94 + }} 95 + /> 96 + 97 + <Story 98 + name="Binary unsupported" 99 + args={{ 100 + blob: { 101 + ...codeBlob, 102 + path: "assets/data.bin", 103 + kind: "other", 104 + lines: null, 105 + contents: null 106 + } 107 + }} 108 + play={async ({ canvas }: PlayContext) => { 109 + await expect(canvas.getByText("Previews are not supported for this file type.")).toBeVisible(); 110 + }} 111 + /> 112 + 113 + <Story 114 + name="Submodule" 115 + args={{ 116 + blob: { 117 + ...codeBlob, 118 + path: "vendor/spindle", 119 + kind: "submodule", 120 + sizeLabel: null, 121 + lines: null, 122 + contents: null, 123 + submodule: { name: "spindle", url: "https://tangled.org/dawn/spindle" } 124 + } 125 + }} 126 + play={async ({ canvas }: PlayContext) => { 127 + const link = canvas.getByRole("link", { name: "https://tangled.org/dawn/spindle" }); 128 + await expect(link).toBeVisible(); 129 + await expect(canvas.getByText(/This directory is a git submodule of/)).toBeVisible(); 130 + }} 131 + /> 132 + 133 + <Story 134 + name="File too large" 135 + args={{ 136 + blob: { 137 + ...codeBlob, 138 + path: "data/dump.sql", 139 + sizeLabel: "3.4 MB", 140 + lines: null, 141 + contents: null, 142 + fileTooLarge: true 143 + } 144 + }} 145 + play={async ({ canvas }: PlayContext) => { 146 + await expect(canvas.getByText(/This file is too large to render/)).toBeVisible(); 147 + await expect(canvas.getByRole("link", { name: "View raw." })).toHaveAttribute( 148 + "href", 149 + "/dawn/tangled/raw/main/data/dump.sql" 150 + ); 151 + }} 152 + />
+157
web/src/lib/components/repo/BlobView.svelte
··· 1 + <script lang="ts"> 2 + import { untrack } from "svelte"; 3 + import { hasTextView, type BlobKind, type RepoBlobView } from "$lib/api/blob"; 4 + import BlobHeader from "./BlobHeader.svelte"; 5 + import { languageForPath } from "./language-colors"; 6 + import LastCommitPanel from "./LastCommitPanel.svelte"; 7 + import PierreFile from "./PierreFile.svelte"; 8 + import { baseName, rawBlobHref } from "./urls"; 9 + import TabPanel from "$lib/components/ui/TabPanel.svelte"; 10 + 11 + interface Props { 12 + ownerHandle: string; 13 + repoName: string; 14 + blob: RepoBlobView; 15 + // server-prerendered shadow dom for the code view, only for the initial ssr 16 + prerenderedHTML?: string; 17 + // enry language name override, defaults to the extension guess 18 + language?: string | null; 19 + } 20 + 21 + let { ownerHandle, repoName, blob, prerenderedHTML, language }: Props = $props(); 22 + 23 + const rawHref = $derived(rawBlobHref(ownerHandle, repoName, blob.ref, blob.path)); 24 + const fileName = $derived(baseName(blob.path)); 25 + // the xrpc doesn't name a language, fall back to the extension map 26 + const languageName = $derived(language ?? languageForPath(blob.path)); 27 + 28 + // view is seeded from the load and reseeds only when the kind category 29 + // changes, a "rendered" view must not leak onto a plain code file 30 + type ViewCategory = "code" | "markup" | "media" | "other"; 31 + const categoryOf = (kind: BlobKind): ViewCategory => { 32 + switch (kind) { 33 + case "code": 34 + return "code"; 35 + case "markup": 36 + return "markup"; 37 + case "image": 38 + case "svg": 39 + case "video": 40 + return "media"; 41 + default: 42 + return "other"; 43 + } 44 + }; 45 + 46 + let view = $state(untrack(() => blob.defaultView)); 47 + let lastCategory = untrack(() => categoryOf(blob.kind)); 48 + 49 + $effect(() => { 50 + const category = categoryOf(blob.kind); 51 + const defaultView = blob.defaultView; 52 + untrack(() => { 53 + if (category === lastCategory) return; 54 + lastCategory = category; 55 + view = defaultView; 56 + }); 57 + }); 58 + 59 + let wrap = $state(false); 60 + 61 + const textViewActive = $derived(hasTextView(blob.kind) && view === "code" && !blob.fileTooLarge); 62 + 63 + let overflows = $state(false); 64 + 65 + // pierre renders into a shadow root, measure its scroller element 66 + // directly 67 + const measure = (host: HTMLElement | undefined) => { 68 + const scroller = host?.shadowRoot?.querySelector("[data-code]"); 69 + overflows = scroller ? scroller.scrollWidth > scroller.clientWidth : false; 70 + }; 71 + 72 + // rAF races pierre's async shiki pass, the measurement happens in 73 + // pierre's own post-render hook 74 + const onPostRender = (node: HTMLElement) => measure(node); 75 + 76 + // sveltekit reuses this component across [...path] navigations, drop the 77 + // previous file's measurement before pierre reports the new one 78 + $effect(() => { 79 + void blob.path; 80 + void blob.contents; 81 + overflows = false; 82 + }); 83 + 84 + let codeHost: HTMLElement | undefined = $state(); 85 + 86 + $effect(() => { 87 + const host = codeHost; 88 + if (!host || wrap) return; 89 + const onResize = () => measure(host.querySelector("diffs-container") ?? undefined); 90 + window.addEventListener("resize", onResize); 91 + return () => window.removeEventListener("resize", onResize); 92 + }); 93 + 94 + const showWrap = $derived(textViewActive && (overflows || wrap)); 95 + </script> 96 + 97 + <TabPanel class="relative mx-auto mt-[1px] w-full"> 98 + <BlobHeader 99 + {ownerHandle} 100 + {repoName} 101 + ref={blob.ref} 102 + path={blob.path} 103 + kind={blob.kind} 104 + language={languageName} 105 + sizeLabel={blob.sizeLabel} 106 + lines={blob.lines} 107 + bind:view 108 + bind:wrap 109 + {showWrap} 110 + copyText={blob.contents} 111 + /> 112 + 113 + {#if blob.lastCommit} 114 + <LastCommitPanel {ownerHandle} {repoName} commit={blob.lastCommit} /> 115 + {/if} 116 + 117 + {#if blob.kind === "submodule" && blob.submodule} 118 + <p class="text-center text-foreground-muted"> 119 + This directory is a git submodule of 120 + <a 121 + href={blob.submodule.url} 122 + rel="external ugc nofollow noopener noreferrer" 123 + class="text-foreground-default no-underline hover:underline">{blob.submodule.url}</a 124 + >. 125 + </p> 126 + {:else if blob.fileTooLarge} 127 + <p class="py-8 text-center text-foreground-muted"> 128 + This file is too large to render. 129 + <a href={rawHref} class="text-foreground-default no-underline hover:underline">View raw.</a> 130 + </p> 131 + {:else if blob.kind === "image" || (blob.kind === "svg" && view === "rendered")} 132 + <div class="text-center"> 133 + <img 134 + src={rawHref} 135 + alt={blob.path} 136 + class="mx-auto h-auto max-w-full rounded border border-border-default" 137 + /> 138 + </div> 139 + {:else if blob.kind === "video"} 140 + <div class="text-center"> 141 + <video controls class="mx-auto h-auto max-w-full rounded border border-border-default"> 142 + <source src={rawHref} /> 143 + </video> 144 + </div> 145 + {:else if blob.kind === "markup" && view === "rendered" && blob.renderedHtml !== null} 146 + <!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in $lib/markup --> 147 + <div class="markup overflow-x-auto">{@html blob.renderedHtml}</div> 148 + {:else if blob.contents !== null} 149 + <div bind:this={codeHost}> 150 + <PierreFile name={fileName} contents={blob.contents} {wrap} {prerenderedHTML} {onPostRender} /> 151 + </div> 152 + {:else} 153 + <p class="py-8 text-center text-foreground-muted"> 154 + Previews are not supported for this file type. 155 + </p> 156 + {/if} 157 + </TabPanel>
+77
web/src/lib/components/repo/language-colors.ts
··· 670 670 }; 671 671 672 672 export const LANGUAGE_COLOR_FALLBACK = "#cccccc"; 673 + 674 + // the blob xrpc doesn't carry a language, fall back to extension and 675 + // special-filename guesses 676 + const EXTENSION_LANGUAGES: Record<string, string> = { 677 + astro: "Astro", 678 + c: "C", 679 + cc: "C++", 680 + clj: "Clojure", 681 + cpp: "C++", 682 + cs: "C#", 683 + css: "CSS", 684 + cxx: "C++", 685 + elm: "Elm", 686 + erl: "Erlang", 687 + ex: "Elixir", 688 + exs: "Elixir", 689 + go: "Go", 690 + h: "C", 691 + hh: "C++", 692 + hpp: "C++", 693 + hrl: "Erlang", 694 + hs: "Haskell", 695 + htm: "HTML", 696 + html: "HTML", 697 + java: "Java", 698 + jl: "Julia", 699 + js: "JavaScript", 700 + json: "JSON", 701 + jsx: "JSX", 702 + kt: "Kotlin", 703 + kts: "Kotlin", 704 + lua: "Lua", 705 + m: "Objective-C", 706 + md: "Markdown", 707 + mdx: "MDX", 708 + mjs: "JavaScript", 709 + ml: "OCaml", 710 + mli: "OCaml", 711 + nix: "Nix", 712 + nu: "Nushell", 713 + php: "PHP", 714 + pl: "Perl", 715 + py: "Python", 716 + r: "R", 717 + rb: "Ruby", 718 + rs: "Rust", 719 + sc: "Scala", 720 + scala: "Scala", 721 + sh: "Shell", 722 + sql: "SQL", 723 + svelte: "Svelte", 724 + swift: "Swift", 725 + toml: "TOML", 726 + ts: "TypeScript", 727 + tsx: "TSX", 728 + vue: "Vue", 729 + yaml: "YAML", 730 + yml: "YAML", 731 + zig: "Zig" 732 + }; 733 + 734 + const FILENAME_LANGUAGES: Record<string, string> = { 735 + dockerfile: "Dockerfile", 736 + makefile: "Makefile", 737 + justfile: "Just", 738 + "flake.lock": "JSON", 739 + "cargo.lock": "TOML" 740 + }; 741 + 742 + // best-effort enry name for a path, null when nothing matches 743 + export const languageForPath = (path: string): string | null => { 744 + const filename = (path.split("/").pop() ?? path).toLowerCase(); 745 + const byName = FILENAME_LANGUAGES[filename]; 746 + if (byName) return byName; 747 + const ext = filename.includes(".") ? filename.slice(filename.lastIndexOf(".") + 1) : ""; 748 + return EXTENSION_LANGUAGES[ext] ?? null; 749 + };
+22
web/src/lib/copy.svelte.ts
··· 1 + export const createCopyFeedback = (resetAfter = 1500) => { 2 + let lastCopied = $state<string | null>(null); 3 + let timer: ReturnType<typeof setTimeout> | undefined; 4 + 5 + $effect(() => () => clearTimeout(timer)); 6 + 7 + return { 8 + get copied() { 9 + return lastCopied; 10 + }, 11 + copy: async (label: string, value: string = label) => { 12 + try { 13 + await navigator.clipboard.writeText(value); 14 + } catch { 15 + return; 16 + } 17 + lastCopied = label; 18 + clearTimeout(timer); 19 + timer = setTimeout(() => (lastCopied = null), resetAfter); 20 + } 21 + }; 22 + };
+14
web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.svelte
··· 1 + <script lang="ts"> 2 + import BlobView from "$lib/components/repo/BlobView.svelte"; 3 + 4 + let { data } = $props(); 5 + 6 + const repo = $derived(data.repo); 7 + </script> 8 + 9 + <BlobView 10 + ownerHandle={repo.ownerHandle} 11 + repoName={repo.name} 12 + blob={data} 13 + prerenderedHTML={data.prerenderedHTML} 14 + />
+35
web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.ts
··· 1 + import { error } from "@sveltejs/kit"; 2 + import { browser } from "$app/environment"; 3 + import { hasTextView, loadRepoBlob } from "$lib/api/blob"; 4 + import { pierreFileOptions } from "$lib/components/repo/pierre"; 5 + import { baseName } from "$lib/components/repo/urls"; 6 + import type { PageLoad } from "./$types"; 7 + 8 + // the ref is a single encoded segment, `feature/x` arrives intact. the 9 + // rest param is already the path as git knows it 10 + export const load: PageLoad = async (event) => { 11 + // no path means the knot would 400 on the empty path, 404 instead 12 + if (event.params.path === "") error(404, "Not found"); 13 + const parent = await event.parent(); 14 + const blob = await loadRepoBlob(event, parent, event.params.ref, event.params.path); 15 + 16 + // same deal as the commit page: prerender the code view's shadow dom on 17 + // the server, pierre paints client navigations itself 18 + let prerenderedHTML: string | undefined; 19 + if ( 20 + !browser && 21 + blob.contents !== null && 22 + !blob.fileTooLarge && 23 + hasTextView(blob.kind) && 24 + blob.defaultView === "code" 25 + ) { 26 + const { preloadFile } = await import("@pierre/diffs/ssr"); 27 + const prerendered = await preloadFile({ 28 + file: { name: baseName(blob.path), contents: blob.contents }, 29 + options: pierreFileOptions(false) 30 + }); 31 + prerenderedHTML = prerendered.prerenderedHTML; 32 + } 33 + 34 + return { ...blob, prerenderedHTML }; 35 + };
+3 -1
web/tsconfig.json
··· 10 10 "skipLibCheck": true, 11 11 "sourceMap": true, 12 12 "strict": true, 13 - "moduleResolution": "bundler" 13 + "moduleResolution": "bundler", 14 + "allowImportingTsExtensions": true, 15 + "allowArbitraryExtensions": true 14 16 } 15 17 // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias 16 18 // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files