This repository has no description
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 padded={false} class="relative mx-auto w-full p-4">
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>