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
12 changes: 7 additions & 5 deletions examples/git/viewer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ TOML or restarting. Browsing a missing repository creates nothing. Bare names
and their `.git` URLs select the same repository. Exact policies may override
permissions or pin a WAL identity, format or default branch.

Credentials stay in page memory and clear on reload. Branch and path selections
Credentials stay in page memory and clear on reload. Branch, commit and path selections
stay in the URL. Press Ctrl-C in the startup terminal to stop both services and
remove their disposable data. Each restart begins with empty storage.

Expand All @@ -47,8 +47,8 @@ For an existing backend, use `bun run build`, then follow the

Hosted proxies must forward `/`, `/_viewer/api`, `/_viewer/client.js` and
`/_viewer/style.css` to Spin. `GET /_viewer/api` takes `repo` and optional
`ref` and `path` query parameters. Git traffic goes directly to the Git component;
the viewer has no repository-creation endpoint.
`ref`, `commit` (full SHA-1 or SHA-256 hash) and `path` query parameters. Git traffic
goes directly to the Git component; the viewer has no repository-creation endpoint.

## Build and checks

Expand All @@ -68,8 +68,10 @@ is listed in [THIRD_PARTY.md](../../../THIRD_PARTY.md).

## Scope

Only branch tips are selectable. The UI shows eight first-parent commits,
500 entries per directory and UTF-8 text previews up to 256 KiB. Binary and
Branch tips and commits in the authorized repository catalog are selectable.
Commit links retain branch context and load that commit's tree and eight first-parent
summaries; selecting an older entry continues history without scanning from the tip.
The UI shows 500 entries per directory and UTF-8 text previews up to 256 KiB. Binary and
larger files show a summary. Symlinks show their stored target; submodules show
their commit ID. Non-UTF-8 filenames appear as byte escapes without a browse link; valid sibling
files remain browsable. Names and file content render as text. Git metadata reads are
Expand Down
5 changes: 3 additions & 2 deletions examples/git/viewer/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ export async function browse(request: Request): Promise<Response> {
if (
[...query.keys()].some(
(key) =>
!["repo", "ref", "path"].includes(key) ||
!["repo", "ref", "commit", "path"].includes(key) ||
query.getAll(key).length !== 1 ||
(query.get(key)?.length ?? 0) > 4096,
) ||
(query.has("commit") && !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(query.get("commit") ?? "")) ||
(path &&
(path.split("/").length > 64 ||
path
Expand Down Expand Up @@ -95,7 +96,7 @@ export async function browse(request: Request): Promise<Response> {
});
} catch (error) {
if (error instanceof NotFound)
return new Response("Branch or path not found", { status: 404 });
return new Response("Branch, commit or path not found", { status: 404 });
if (attempt === 0 && (error as { payload?: { tag: string } }).payload?.tag === "expired") {
const fresh = session.refresh();
drop(session);
Expand Down
126 changes: 85 additions & 41 deletions examples/git/viewer/src/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,26 @@ import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
import type { Commit, Snapshot } from "./repository";

const messages: Record<number, string> = {
400: "Invalid repository, branch or path.",
400: "Invalid repository, branch, commit or path.",
401: "Authentication required.",
403: "This credential cannot read this repository.",
404: "Repository or path not found.",
404: "Repository, commit or path not found.",
};

function Icon({ kind }: { kind: "folder" | "file" | "code" | "chevron" }) {
const paths = {
folder: "M2 4h5l2 2h5v7H2z",
file: "M4 2h5l3 3v9H4zM9 2v4h3",
code: "M6 4 2 8l4 4m4-8 4 4-4 4",
chevron: "m4 6 4 4 4-4",
};
return (
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d={paths[kind]} stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
</svg>
);
}

function validRepository(name: string): boolean {
return (
name !== "" &&
Expand All @@ -21,22 +35,23 @@ function validRepository(name: string): boolean {
);
}

function History({ commits }: { commits: Commit[] }) {
function History({ commits, link }: { commits: Commit[]; link: (commit: Commit) => VNode }) {
return (
<section aria-label="Recent commits">
<p class="muted">Recent commits on this branch · first parent</p>
<p class="muted">
Recent commits · first parent · select a commit to explore earlier history
</p>
<div class="panel">
{commits.length ? (
commits.map((commit) => (
<article key={commit.id}>
<div>
<strong>{commit.title || "Untitled commit"}</strong>
{link(commit)}
<p class="muted">
{commit.author || "Unknown author"} committed on{" "}
<time dateTime={commit.date}>{new Date(commit.date).toLocaleDateString()}</time>
</p>
</div>
<code>{commit.id.slice(0, 7)}</code>
</article>
))
) : (
Expand All @@ -51,10 +66,12 @@ function Code({
view,
repository,
link,
commitLink,
}: {
view: Snapshot;
repository: string;
link: (label: string, path: string) => VNode;
commitLink: (commit: Commit) => VNode;
}) {
return (
<section aria-label="Code">
Expand All @@ -68,12 +85,7 @@ function Code({
))}
</nav>
<div class="panel">
{view.history[0] && (
<article class="tip">
<strong>{view.history[0].title || "Untitled commit"}</strong>
<code>{view.history[0].id.slice(0, 7)}</code>
</article>
)}
{view.history[0] && <article class="tip">{commitLink(view.history[0])}</article>}
{view.file ? (
<>
<article class="tip">
Expand All @@ -96,6 +108,12 @@ function Code({
<p class="empty">This repository is empty. Push a branch to start exploring.</p>
) : (
<>
{view.path && (
<article>
<Icon kind="folder" />
{link("..", view.path.split("/").slice(0, -1).join("/"))}
</article>
)}
{[...view.entries]
.sort(
(a, b) =>
Expand All @@ -104,7 +122,7 @@ function Code({
)
.map((entry) => (
<article key={`${entry.unavailable ? "bytes" : "text"}:${entry.name}`}>
<span aria-hidden="true">{entry.kind === "directory" ? "▸" : "·"}</span>
<Icon kind={entry.kind === "directory" ? "folder" : "file"} />
{entry.unavailable ? (
<span title="This filename is not UTF-8">{entry.name}</span>
) : (
Expand Down Expand Up @@ -177,11 +195,10 @@ function App() {
history.pushState(null, "", `?${params}`);
setSearch(location.search);
}
function pathLink(label: string, path: string) {
const params = new URLSearchParams(search);
params.set("path", path);
function link(params: URLSearchParams, label: string | VNode, className?: string) {
return (
<a
class={className}
href={`?${params}`}
onClick={(event) => {
if (event.button || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey)
Expand All @@ -194,6 +211,23 @@ function App() {
</a>
);
}
function pathLink(label: string, path: string) {
const params = new URLSearchParams(search);
params.set("path", path);
return link(params, label);
}
function commitLink(commit: Commit) {
return link(
new URLSearchParams({ repo: repository, ref: view?.branch ?? "", commit: commit.id }),
<>
<code>{commit.id.slice(0, 7)}</code>
<span class="commit-title" title={commit.title}>
{commit.title || "Untitled commit"}
</span>
</>,
"commit-link",
);
}

return (
<>
Expand Down Expand Up @@ -224,13 +258,16 @@ function App() {
) : !view ? (
<>
<p role="status">{result.error ?? "Loading repository…"}</p>
{result.status === 404 && !query.get("ref") && !query.get("path") && (
<p class="panel">
Push your first branch to create this repository:
<br />
<code>git push {cloneURL} HEAD</code>
</p>
)}
{result.status === 404 &&
!query.get("ref") &&
!query.get("commit") &&
!query.get("path") && (
<p class="panel">
Push your first branch to create this repository:
<br />
<code>git push {cloneURL} HEAD</code>
</p>
)}
{result.error && (
<form
class="panel"
Expand Down Expand Up @@ -290,27 +327,34 @@ function App() {
<div class="toolbar">
<label>
Branch
<select
value={view.branch}
disabled={!view.branches.length}
onChange={(event) =>
navigate(
new URLSearchParams({ repo: repository, ref: event.currentTarget.value }),
)
}
>
{view.branches.map((ref) => (
<option key={ref} value={ref}>
{ref.replace(/^refs\/heads\//, "")}
</option>
))}
</select>
<span class="select-control">
<select
value={view.branch}
disabled={!view.branches.length}
onChange={(event) =>
navigate(
new URLSearchParams({ repo: repository, ref: event.currentTarget.value }),
)
}
>
{view.branches.map((ref) => (
<option key={ref} value={ref}>
{ref.replace(/^refs\/heads\//, "")}
</option>
))}
</select>
<Icon kind="chevron" />
</span>
</label>
<span class="muted">
{view.branches.length} {view.branches.length === 1 ? "branch" : "branches"}
</span>
{query.has("commit") &&
link(new URLSearchParams({ repo: repository, ref: view.branch }), "Branch tip")}
<details>
<summary>Clone</summary>
<summary>
<Icon kind="code" /> Code <Icon kind="chevron" />
</summary>
<div class="clone-box">
<label>
Clone URL
Expand All @@ -333,9 +377,9 @@ function App() {
</details>
</div>
{tab === "commits" ? (
<History commits={view.history} />
<History commits={view.history} link={commitLink} />
) : (
<Code view={view} repository={repository} link={pathLink} />
<Code view={view} repository={repository} link={pathLink} commitLink={commitLink} />
)}
</>
)}
Expand Down
17 changes: 13 additions & 4 deletions examples/git/viewer/src/repository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type Catalog, hex } from "./wal";
import { type Catalog, hex, MissingObject } from "./wal";

export interface Commit {
id: string;
Expand Down Expand Up @@ -101,12 +101,21 @@ export function snapshot(catalog: Catalog, query: URLSearchParams): Snapshot {
entries: [],
more: false,
};
if (!branches.length && !query.get("ref") && !view.path) return view;
const selected = query.get("commit");
if (!branches.length && !query.get("ref") && !selected && !view.path) return view;
if (!branches.includes(branch)) throw new NotFound();
let id = catalog.root.Refs[branch],
if (selected && selected.length !== (catalog.root.Format === "sha256" ? 64 : 40))
throw new NotFound();
let id = selected || catalog.root.Refs[branch],
tip = "";
for (let i = 0; id && i < 8; i++) {
const current = commit(catalog, id);
let current: ReturnType<typeof commit>;
try {
current = commit(catalog, id);
} catch (error) {
if (i === 0 && selected && error instanceof MissingObject) throw new NotFound();
throw error;
}
if (i === 0) tip = current.tree;
view.history.push(current.summary);
id = current.parent;
Expand Down
Loading
Loading