feat(web): browse git repos in-browser via isomorphic-git (#554)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-05-12 15:05:57 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent a33d73bf6d
commit 809275795a
21 changed files with 1537 additions and 209 deletions
+6
View File
@@ -16,16 +16,22 @@
"test:e2e:smoke": "pnpm build && playwright test --project=smoke"
},
"dependencies": {
"@isomorphic-git/lightning-fs": "^4.6.2",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.168.10",
"buffer": "^6.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"isomorphic-git": "^1.37.6",
"lucide-react": "^0.577.0",
"nostr-tools": "^2.23.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0"
},
+219
View File
@@ -0,0 +1,219 @@
/**
* isomorphic-git wrapper for in-browser repo browsing.
*
* Uses LightningFS (IndexedDB-backed) for persistence and NIP-98 auth
* for the relay's smart HTTP git transport.
*/
// isomorphic-git expects a global Buffer (Node API) for pack-file parsing,
// tree serialization, etc. The `buffer` package (feross/buffer) is the
// standard browser polyfill — we install it before any git imports run.
import { Buffer } from "buffer";
if (typeof (globalThis as Record<string, unknown>).Buffer === "undefined") {
(globalThis as Record<string, unknown>).Buffer = Buffer;
}
import LightningFS from "@isomorphic-git/lightning-fs";
import {
clone,
fetch,
log,
readBlob,
readTree,
resolveRef,
} from "isomorphic-git";
import http from "isomorphic-git/http/web";
import { makeNip98AuthHeader } from "@/shared/lib/nip98";
import { relayHttpBaseUrl } from "@/shared/lib/relay-url";
/** Get a repo-specific LightningFS instance backed by IndexedDB. */
export function getFs(owner: string, repoName: string): LightningFS {
return new LightningFS(`sprout-git-${owner}-${repoName}`);
}
/** Working directory inside the virtual FS. */
export function getDir(owner: string, repoName: string): string {
return `/${owner}/${repoName}`;
}
function repoGitUrl(owner: string, repoName: string): string {
return `${relayHttpBaseUrl()}/git/${owner}/${repoName}.git`;
}
/**
* The NIP-98 `u` tag URL — must match what transport.rs expects after
* stripping `/info/refs`, `/git-upload-pack`, `/git-receive-pack`.
* That means the full path including `.git`.
*/
function repoAuthUrl(owner: string, repoName: string): string {
return `${relayHttpBaseUrl()}/git/${owner}/${repoName}.git`;
}
function authHeaders(owner: string, repoName: string): Record<string, string> {
return {
Authorization: makeNip98AuthHeader(repoAuthUrl(owner, repoName), "GET"),
};
}
/**
* Ensure a shallow clone exists in IndexedDB. If it already exists, fetch
* the latest for the given ref.
*/
export async function ensureClone(
owner: string,
repoName: string,
ref: string,
): Promise<{ fs: LightningFS; dir: string }> {
const fs = getFs(owner, repoName);
const dir = getDir(owner, repoName);
const url = repoGitUrl(owner, repoName);
const headers = authHeaders(owner, repoName);
let exists = false;
try {
await fs.promises.stat(`${dir}/.git`);
exists = true;
} catch {
// repo not cloned yet
}
if (exists) {
try {
await fetch({
fs,
http,
dir,
url,
ref,
depth: 1,
singleBranch: true,
headers,
});
} catch {
// fetch may fail if ref hasn't changed — that's fine
}
} else {
await clone({
fs,
http,
dir,
url,
ref,
depth: 1,
singleBranch: true,
noTags: true,
headers,
});
}
return { fs, dir };
}
export interface TreeEntry {
name: string;
type: "blob" | "tree";
mode: string;
oid: string;
}
/** Read tree entries at a given path (or root if no filepath). */
export async function readTreeEntries(
fs: LightningFS,
dir: string,
oid: string,
filepath?: string,
): Promise<TreeEntry[]> {
const result = await readTree({ fs, dir, oid, filepath });
return result.tree.map((entry) => ({
name: entry.path,
type: entry.type as "blob" | "tree",
mode: entry.mode,
oid: entry.oid,
}));
}
export interface FileContent {
content: string;
isBinary: boolean;
}
/** Read a blob and decode as text. Detects binary by checking for NUL bytes. */
export async function readFileContent(
fs: LightningFS,
dir: string,
oid: string,
filepath: string,
): Promise<FileContent> {
const { blob } = await readBlob({ fs, dir, oid, filepath });
// Check first 512 bytes for NUL to detect binary
const checkLength = Math.min(blob.length, 512);
for (let i = 0; i < checkLength; i++) {
if (blob[i] === 0) {
return { content: "", isBinary: true };
}
}
const content = new TextDecoder().decode(blob);
return { content, isBinary: false };
}
export interface CommitInfo {
oid: string;
message: string;
author: {
name: string;
email: string;
timestamp: number;
};
}
/** Get recent commits for a ref. */
export async function getCommitLog(
fs: LightningFS,
dir: string,
ref: string,
depth = 20,
): Promise<CommitInfo[]> {
const commits = await log({ fs, dir, ref, depth });
return commits.map((c) => ({
oid: c.oid,
message: c.commit.message,
author: {
name: c.commit.author.name,
email: c.commit.author.email,
timestamp: c.commit.author.timestamp,
},
}));
}
export interface ReadmeResult {
filename: string;
content: string;
}
const README_PATTERNS = ["readme.md", "readme", "readme.rst", "readme.txt"];
/** Find and read a README file from the root tree. */
export async function findReadme(
fs: LightningFS,
dir: string,
ref: string,
): Promise<ReadmeResult | null> {
const oid = await resolveRef({ fs, dir, ref });
const entries = await readTreeEntries(fs, dir, oid);
for (const pattern of README_PATTERNS) {
const entry = entries.find(
(e) => e.type === "blob" && e.name.toLowerCase() === pattern,
);
if (entry) {
const file = await readFileContent(fs, dir, oid, entry.name);
if (!file.isBinary) {
return { filename: entry.name, content: file.content };
}
}
}
return null;
}
@@ -0,0 +1,70 @@
import { GitCommit } from "lucide-react";
import { relativeTime } from "@/shared/lib/relative-time";
import type { CommitInfo } from "../git-client";
function CommitRow({ commit }: { commit: CommitInfo }) {
const firstLine = commit.message.split("\n")[0];
return (
<div className="flex items-start gap-3 border-b border-border px-3 py-2.5 text-sm last:border-b-0">
<GitCommit className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{firstLine}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{commit.author.name} committed {relativeTime(commit.author.timestamp)}
</p>
</div>
<code className="shrink-0 self-center rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
{commit.oid.slice(0, 7)}
</code>
</div>
);
}
export function RepoCommitsSection({
commits,
isLoading,
}: {
commits: CommitInfo[] | undefined;
isLoading: boolean;
}) {
if (isLoading) {
return (
<div className="mt-8">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
<GitCommit className="h-4 w-4" />
Recent commits
</h2>
<div className="rounded-lg border border-border">
{["sk-1", "sk-2", "sk-3"].map((key) => (
<div
key={key}
className="flex items-center gap-3 border-b border-border px-3 py-2.5 last:border-b-0"
>
<div className="h-4 w-4 animate-pulse rounded bg-muted" />
<div className="flex-1 space-y-1">
<div className="h-4 w-48 animate-pulse rounded bg-muted" />
<div className="h-3 w-32 animate-pulse rounded bg-muted" />
</div>
</div>
))}
</div>
</div>
);
}
if (!commits || commits.length === 0) return null;
return (
<div className="mt-8">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
<GitCommit className="h-4 w-4" />
Recent commits
</h2>
<div className="overflow-hidden rounded-lg border border-border">
{commits.map((commit) => (
<CommitRow key={commit.oid} commit={commit} />
))}
</div>
</div>
);
}
+248 -106
View File
@@ -4,6 +4,7 @@ import {
Check,
Copy,
ExternalLink,
MessageSquare,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
@@ -12,28 +13,17 @@ import { toast } from "sonner";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { relativeTime } from "@/shared/lib/relative-time";
import { useRepoRefs } from "../use-repo-refs";
import { useRepo } from "../use-repos";
import type { CommitInfo, ReadmeResult, TreeEntry } from "../git-client";
import { useGitTree, useGitLog, useGitReadme } from "../use-git-browse";
import { ConnectButton } from "./ConnectButton";
import { PubkeyAvatar } from "./PubkeyAvatar";
function relativeTime(unix: number): string {
const now = Date.now();
const diff = now - unix * 1000;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 30) {
const months = Math.floor(days / 30);
return months === 1 ? "1 month ago" : `${months} months ago`;
}
if (days > 0) return days === 1 ? "1 day ago" : `${days} days ago`;
if (hours > 0) return hours === 1 ? "1 hour ago" : `${hours} hours ago`;
if (minutes > 0)
return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`;
return "just now";
}
import { RepoRefsSection } from "./RepoRefsSection";
import { RepoTreeSection } from "./RepoTreeSection";
import { RepoCommitsSection } from "./RepoCommitsSection";
import { RepoReadmeSection } from "./RepoReadmeSection";
function CopyableUrl({ url }: { url: string }) {
const [copied, setCopied] = useState(false);
@@ -70,14 +60,78 @@ function CopyableUrl({ url }: { url: string }) {
function DetailSkeleton() {
return (
<div className="mx-auto w-full max-w-3xl px-4 py-8">
<div className="h-5 w-24 animate-pulse rounded bg-muted" />
<div className="mt-6 h-8 w-64 animate-pulse rounded bg-muted" />
<div className="mt-3 h-5 w-96 animate-pulse rounded bg-muted" />
<div className="mt-8 space-y-3">
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
<div className="h-10 w-full animate-pulse rounded bg-muted" />
<div className="mx-auto flex w-full max-w-7xl gap-8 px-4 py-8">
<div className="min-w-0 flex-1">
<div className="h-5 w-24 animate-pulse rounded bg-muted" />
<div className="mt-6 h-8 w-64 animate-pulse rounded bg-muted" />
<div className="mt-3 h-5 w-96 animate-pulse rounded bg-muted" />
<div className="mt-8 space-y-3">
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
<div className="h-10 w-full animate-pulse rounded bg-muted" />
</div>
</div>
<aside className="hidden w-72 shrink-0 lg:block" />
</div>
);
}
type Tab = "code" | "commits";
function RepoTabs({
treeEntries,
treeLoading,
commits,
commitsLoading,
readme,
readmeLoading,
}: {
treeEntries: TreeEntry[] | undefined;
treeLoading: boolean;
commits: CommitInfo[] | undefined;
commitsLoading: boolean;
readme: ReadmeResult | null | undefined;
readmeLoading: boolean;
}) {
const [tab, setTab] = useState<Tab>("code");
return (
<div className="mt-6">
{/* Tab bar */}
<div className="flex gap-1 border-b border-border">
<button
type="button"
onClick={() => setTab("code")}
className={`px-4 py-2 text-sm font-medium transition-colors ${
tab === "code"
? "border-b-2 border-foreground text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Code
</button>
<button
type="button"
onClick={() => setTab("commits")}
className={`px-4 py-2 text-sm font-medium transition-colors ${
tab === "commits"
? "border-b-2 border-foreground text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Commits
</button>
</div>
{/* Tab content */}
{tab === "code" && (
<>
<RepoTreeSection entries={treeEntries} isLoading={treeLoading} />
<RepoReadmeSection readme={readme} isLoading={readmeLoading} />
</>
)}
{tab === "commits" && (
<RepoCommitsSection commits={commits} isLoading={commitsLoading} />
)}
</div>
);
}
@@ -85,6 +139,35 @@ function DetailSkeleton() {
export function RepoDetailPage() {
const { repoId } = useParams({ from: "/repos/$repoId" });
const { data: repo, isLoading, error } = useRepo(repoId);
const { data: refs, isLoading: refsLoading } = useRepoRefs(repoId);
const defaultRef = refs?.head?.ref ?? "main";
const owner = repo?.owner ?? "";
const repoName = repo?.id ?? "";
const {
data: treeEntries,
isLoading: treeLoading,
error: treeError,
} = useGitTree(owner, repoName, defaultRef);
const {
data: commits,
isLoading: commitsLoading,
error: commitsError,
} = useGitLog(owner, repoName, defaultRef);
const { data: readme, isLoading: readmeLoading } = useGitReadme(
owner,
repoName,
defaultRef,
);
// Surface clone/browse errors — these are otherwise silent
const browseError = treeError || commitsError;
useEffect(() => {
if (browseError) {
console.error("[git-browse]", browseError);
}
}, [browseError]);
useEffect(() => {
if (error) {
@@ -98,7 +181,34 @@ export function RepoDetailPage() {
if (!repo) {
return (
<div className="mx-auto w-full max-w-3xl px-4 py-8">
<div className="mx-auto flex w-full max-w-7xl gap-8 px-4 py-8">
<div className="min-w-0 flex-1">
<Link
to="/"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back to repositories
</Link>
<div className="mt-12 text-center">
<BookMarked className="mx-auto h-10 w-10 text-muted-foreground" />
<h1 className="mt-4 text-xl font-semibold">Repository not found</h1>
<p className="mt-1 text-sm text-muted-foreground">
This repository may have been removed or doesn't exist on this
relay.
</p>
</div>
</div>
<aside className="hidden w-72 shrink-0 lg:block" />
</div>
);
}
return (
<div className="mx-auto flex w-full max-w-7xl gap-8 px-4 py-8">
{/* Main content */}
<div className="min-w-0 flex-1">
{/* Back link */}
<Link
to="/"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
@@ -106,94 +216,126 @@ export function RepoDetailPage() {
<ArrowLeft className="h-4 w-4" />
Back to repositories
</Link>
<div className="mt-12 text-center">
<BookMarked className="mx-auto h-10 w-10 text-muted-foreground" />
<h1 className="mt-4 text-xl font-semibold">Repository not found</h1>
<p className="mt-1 text-sm text-muted-foreground">
This repository may have been removed or doesn't exist on this
relay.
{/* Mobile-only connect button */}
<div className="mt-4 lg:hidden">
<ConnectButton className="w-full" />
</div>
{/* Header */}
<div className="mt-6">
<div className="flex items-center gap-3">
<BookMarked className="h-6 w-6 shrink-0 text-muted-foreground" />
<h1 className="text-2xl font-semibold tracking-tight">
{repo.name}
</h1>
<Badge variant="outline">Public</Badge>
</div>
{repo.description && (
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
{repo.description}
</p>
)}
<p className="mt-2 text-xs text-muted-foreground">
Updated {relativeTime(repo.createdAt)}
</p>
</div>
</div>
);
}
return (
<div className="mx-auto w-full max-w-3xl px-4 py-8">
{/* Back link */}
<Link
to="/"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back to repositories
</Link>
{/* Refs & HEAD */}
<RepoRefsSection refs={refs} isLoading={refsLoading} />
{/* Header */}
<div className="mt-6">
<div className="flex items-center gap-3">
<BookMarked className="h-6 w-6 shrink-0 text-muted-foreground" />
<h1 className="text-2xl font-semibold tracking-tight">{repo.name}</h1>
<Badge variant="outline">Public</Badge>
</div>
{repo.description && (
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
{repo.description}
</p>
{/* Clone/browse error banner */}
{browseError && (
<div className="mt-6 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
Failed to load repository contents:{" "}
{browseError instanceof Error
? browseError.message
: String(browseError)}
</div>
)}
{/* Tabs */}
<RepoTabs
treeEntries={treeEntries}
treeLoading={treeLoading}
commits={commits}
commitsLoading={commitsLoading}
readme={readme}
readmeLoading={readmeLoading}
/>
{/* Clone URLs */}
{repo.cloneUrls.length > 0 && (
<div className="mt-8">
<h2 className="mb-3 text-sm font-semibold">Clone</h2>
<div className="space-y-2">
{repo.cloneUrls.map((url) => (
<CopyableUrl key={url} url={url} />
))}
</div>
</div>
)}
{/* External link — validate scheme to prevent javascript: XSS */}
{(() => {
if (!repo.webUrl) return null;
let safe: string | null = null;
try {
safe = /^https?:/.test(new URL(repo.webUrl).protocol)
? repo.webUrl
: null;
} catch {
safe = null;
}
if (!safe) return null;
return (
<div className="mt-6">
<Button variant="outline" asChild>
<a href={safe} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
View on web
</a>
</Button>
</div>
);
})()}
{/* Channel link */}
{repo.channelId && (
<div className="mt-8">
<Button variant="outline" asChild>
<a href={`/channels/${repo.channelId}`}>
<MessageSquare className="h-4 w-4" />
View channel
</a>
</Button>
</div>
)}
<p className="mt-2 text-xs text-muted-foreground">
Updated {relativeTime(repo.createdAt)}
</p>
</div>
{/* Clone URLs */}
{repo.cloneUrls.length > 0 && (
<div className="mt-8">
<h2 className="mb-3 text-sm font-semibold">Clone</h2>
<div className="space-y-2">
{repo.cloneUrls.map((url) => (
<CopyableUrl key={url} url={url} />
))}
{/* Sidebar */}
<aside className="hidden w-72 shrink-0 border-l border-border pl-8 lg:block">
<div className="space-y-6">
{/* Open in Sprout */}
<ConnectButton className="w-full" />
{/* People */}
<div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold">
<Users className="h-4 w-4" />
People
</h3>
<div className="flex flex-wrap gap-2">
<PubkeyAvatar pubkey={repo.owner} />
{repo.contributors
.filter((c) => c !== repo.owner)
.map((c) => (
<PubkeyAvatar key={c} pubkey={c} />
))}
</div>
</div>
</div>
)}
{/* External link */}
{repo.webUrl && (
<div className="mt-6">
<Button variant="outline" asChild>
<a href={repo.webUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
View on web
</a>
</Button>
</div>
)}
{/* Owner & Contributors */}
<div className="mt-8">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
<Users className="h-4 w-4" />
People
</h2>
<div className="flex flex-wrap gap-2">
<PubkeyAvatar pubkey={repo.owner} />
{repo.contributors
.filter((c) => c !== repo.owner)
.map((c) => (
<PubkeyAvatar key={c} pubkey={c} />
))}
</div>
</div>
{/* Open in Sprout CTA */}
<div className="mt-8 rounded-lg border border-border bg-muted/30 p-6 text-center">
<p className="mb-3 text-sm text-muted-foreground">
Open this relay in the Sprout desktop app to push code and
collaborate.
</p>
<ConnectButton />
</div>
</aside>
</div>
);
}
+1 -19
View File
@@ -3,6 +3,7 @@ import { Link } from "@tanstack/react-router";
import { Badge } from "@/shared/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { relativeTime } from "@/shared/lib/relative-time";
import type { Repo } from "../use-repos";
function truncateHex(hex: string): string {
@@ -10,25 +11,6 @@ function truncateHex(hex: string): string {
return `${hex.slice(0, 8)}...${hex.slice(-4)}`;
}
function relativeTime(unix: number): string {
const now = Date.now();
const diff = now - unix * 1000;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 30) {
const months = Math.floor(days / 30);
return months === 1 ? "1 month ago" : `${months} months ago`;
}
if (days > 0) return days === 1 ? "1 day ago" : `${days} days ago`;
if (hours > 0) return hours === 1 ? "1 hour ago" : `${hours} hours ago`;
if (minutes > 0)
return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`;
return "just now";
}
export function RepoListItem({ repo }: { repo: Repo }) {
return (
<div className="py-6">
@@ -0,0 +1,42 @@
import { BookOpen } from "lucide-react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { ReadmeResult } from "../git-client";
export function RepoReadmeSection({
readme,
isLoading,
}: {
readme: ReadmeResult | null | undefined;
isLoading: boolean;
}) {
if (isLoading) {
return (
<div className="mt-8">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
<BookOpen className="h-4 w-4" />
README
</h2>
<div className="space-y-2 rounded-lg border border-border p-4">
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
<div className="h-4 w-1/2 animate-pulse rounded bg-muted" />
<div className="h-4 w-5/6 animate-pulse rounded bg-muted" />
</div>
</div>
);
}
if (!readme) return null;
return (
<div className="mt-8">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
<BookOpen className="h-4 w-4" />
{readme.filename}
</h2>
<div className="prose prose-sm dark:prose-invert max-w-none rounded-lg border border-border p-4">
<Markdown remarkPlugins={[remarkGfm]}>{readme.content}</Markdown>
</div>
</div>
);
}
@@ -0,0 +1,54 @@
import { GitBranch, Hash, Tag } from "lucide-react";
import { Badge } from "@/shared/ui/badge";
import type { RepoRefs } from "../use-repo-refs";
export function RepoRefsSection({
refs,
isLoading,
}: {
refs: RepoRefs | undefined;
isLoading: boolean;
}) {
if (isLoading) return null;
const hasRefs = refs && (refs.branches.length > 0 || refs.tags.length > 0);
return (
<div className="mt-6">
{hasRefs ? (
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
{refs.head && (
<>
<div className="flex items-center gap-1.5">
<Badge variant="secondary">
<GitBranch className="mr-1 h-3 w-3" />
{refs.head.ref}
</Badge>
{refs.head.sha && (
<Badge variant="outline" className="font-mono text-xs">
<Hash className="mr-0.5 h-3 w-3" />
{refs.head.sha.slice(0, 7)}
</Badge>
)}
</div>
<span className="text-muted-foreground/60">&middot;</span>
</>
)}
<span className="flex items-center gap-1">
<GitBranch className="h-3.5 w-3.5" />
{refs.branches.length}{" "}
{refs.branches.length === 1 ? "branch" : "branches"}
</span>
<span className="text-muted-foreground/60">&middot;</span>
<span className="flex items-center gap-1">
<Tag className="h-3.5 w-3.5" />
{refs.tags.length} {refs.tags.length === 1 ? "tag" : "tags"}
</span>
</div>
) : (
<p className="text-sm text-muted-foreground">No commits yet</p>
)}
</div>
);
}
@@ -0,0 +1,54 @@
import { File, Folder } from "lucide-react";
import type { TreeEntry } from "../git-client";
function TreeRow({ entry }: { entry: TreeEntry }) {
const isDir = entry.type === "tree";
return (
<div className="flex items-center gap-2 border-b border-border px-3 py-2 text-sm last:border-b-0">
{isDir ? (
<Folder className="h-4 w-4 shrink-0 text-blue-400" />
) : (
<File className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className={isDir ? "font-medium" : ""}>{entry.name}</span>
</div>
);
}
export function RepoTreeSection({
entries,
isLoading,
}: {
entries: TreeEntry[] | undefined;
isLoading: boolean;
}) {
if (isLoading) {
return (
<div className="mt-8">
<div className="rounded-lg border border-border">
{["sk-1", "sk-2", "sk-3", "sk-4", "sk-5"].map((key) => (
<div
key={key}
className="flex items-center gap-2 border-b border-border px-3 py-2 last:border-b-0"
>
<div className="h-4 w-4 animate-pulse rounded bg-muted" />
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
</div>
))}
</div>
</div>
);
}
if (!entries || entries.length === 0) return null;
return (
<div className="mt-8">
<div className="overflow-hidden rounded-lg border border-border">
{entries.map((entry) => (
<TreeRow key={entry.name} entry={entry} />
))}
</div>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
/**
* React Query hooks for browsing git repos via isomorphic-git.
*
* All hooks depend on `useGitClone` which ensures the repo is shallow-cloned
* into IndexedDB before any reads happen.
*/
import { useQuery } from "@tanstack/react-query";
import { resolveRef } from "isomorphic-git";
import {
ensureClone,
findReadme,
getCommitLog,
readFileContent,
readTreeEntries,
} from "./git-client";
/**
* Ensure the repo is cloned (or fetched) into IndexedDB.
* Other hooks depend on this to get `fs` and `dir`.
*/
export function useGitClone(owner: string, repoName: string, ref: string) {
return useQuery({
queryKey: ["git-clone", owner, repoName, ref],
queryFn: () => ensureClone(owner, repoName, ref),
staleTime: 5 * 60_000,
enabled: !!owner && !!repoName && !!ref,
retry: false,
});
}
/** Read tree entries at a path (or root). Directories first, then files, alphabetical. */
export function useGitTree(
owner: string,
repoName: string,
ref: string,
path?: string,
) {
const cloneQuery = useGitClone(owner, repoName, ref);
return useQuery({
queryKey: ["git-tree", owner, repoName, ref, path ?? ""],
queryFn: async () => {
const { fs, dir } = cloneQuery.data!;
const oid = await resolveRef({ fs, dir, ref });
const entries = await readTreeEntries(fs, dir, oid, path || undefined);
// Sort: directories first, then files, alphabetical within each group
return entries.sort((a, b) => {
if (a.type === "tree" && b.type !== "tree") return -1;
if (a.type !== "tree" && b.type === "tree") return 1;
return a.name.localeCompare(b.name);
});
},
enabled: !!cloneQuery.data,
staleTime: 5 * 60_000,
});
}
/** Get recent commits for the given ref. */
export function useGitLog(owner: string, repoName: string, ref: string) {
const cloneQuery = useGitClone(owner, repoName, ref);
return useQuery({
queryKey: ["git-log", owner, repoName, ref],
queryFn: async () => {
const { fs, dir } = cloneQuery.data!;
return getCommitLog(fs, dir, ref);
},
enabled: !!cloneQuery.data,
staleTime: 5 * 60_000,
});
}
/** Find and read the README from the repo root. */
export function useGitReadme(owner: string, repoName: string, ref: string) {
const cloneQuery = useGitClone(owner, repoName, ref);
return useQuery({
queryKey: ["git-readme", owner, repoName, ref],
queryFn: async () => {
const { fs, dir } = cloneQuery.data!;
return findReadme(fs, dir, ref);
},
enabled: !!cloneQuery.data,
staleTime: 5 * 60_000,
});
}
/** Read a single file's content. */
export function useGitBlob(
owner: string,
repoName: string,
ref: string,
filepath: string,
) {
const cloneQuery = useGitClone(owner, repoName, ref);
return useQuery({
queryKey: ["git-blob", owner, repoName, ref, filepath],
queryFn: async () => {
const { fs, dir } = cloneQuery.data!;
const oid = await resolveRef({ fs, dir, ref });
return readFileContent(fs, dir, oid, filepath);
},
enabled: !!cloneQuery.data && !!filepath,
staleTime: 5 * 60_000,
});
}
+68
View File
@@ -0,0 +1,68 @@
import { useQuery } from "@tanstack/react-query";
import { queryEvents, type NostrEvent } from "@/shared/lib/nostr-client";
import { relayWsUrl } from "@/shared/lib/relay-url";
import { dedup } from "./use-repos";
export interface RepoRefs {
branches: string[];
tags: string[];
head: { ref: string; sha: string } | null;
}
function parseRefs(events: NostrEvent[]): RepoRefs {
const latest = dedup(events);
const branches: string[] = [];
const tags: string[] = [];
let head: RepoRefs["head"] = null;
for (const event of latest) {
for (const tag of event.tags) {
const [name, value] = tag;
if (!name || !value) continue;
if (name === "HEAD" && value.startsWith("ref: refs/heads/")) {
// HEAD points to a branch ref — find its SHA from a matching branch tag
const branchName = value.replace("ref: refs/heads/", "");
head = { ref: branchName, sha: "" };
} else if (name.startsWith("refs/heads/")) {
branches.push(name.replace("refs/heads/", ""));
} else if (name.startsWith("refs/tags/")) {
tags.push(name.replace("refs/tags/", ""));
}
}
}
// Resolve HEAD SHA from the matching branch
if (head) {
for (const event of latest) {
for (const tag of event.tags) {
if (tag[0] === `refs/heads/${head.ref}` && tag[1]) {
head = { ref: head.ref, sha: tag[1] };
break;
}
}
if (head.sha) break;
}
}
return { branches, tags, head };
}
async function fetchRepoRefs(repoId: string): Promise<RepoRefs> {
// TODO: Filter by `authors: [relayPubkey]` once the relay's own pubkey is
// exposed to the client. Without this, a user with ReposWrite permission
// could publish fake kind:30618 events with spoofed refs.
const events = await queryEvents(relayWsUrl(), {
kinds: [30618],
"#d": [repoId],
});
return parseRefs(events);
}
export function useRepoRefs(repoId: string) {
return useQuery({
queryKey: ["repo-refs", repoId],
queryFn: () => fetchRepoRefs(repoId),
staleTime: 60_000,
});
}
+5 -2
View File
@@ -8,13 +8,14 @@ export interface Repo {
description: string;
cloneUrls: string[];
webUrl: string | null;
channelId: string | null;
owner: string;
contributors: string[];
createdAt: number;
}
/** Extract the first value for a given tag name from a Nostr event. */
function getTag(event: NostrEvent, name: string): string | undefined {
export function getTag(event: NostrEvent, name: string): string | undefined {
return event.tags.find((t) => t[0] === name)?.[1];
}
@@ -29,6 +30,7 @@ function eventToRepo(event: NostrEvent): Repo {
const description = getTag(event, "description") || event.content || "";
const cloneUrls = getAllTags(event, "clone");
const webUrl = getTag(event, "web") ?? null;
const channelId = getTag(event, "sprout-channel") ?? null;
const contributors = getAllTags(event, "p");
const owner = event.pubkey;
@@ -38,6 +40,7 @@ function eventToRepo(event: NostrEvent): Repo {
description,
cloneUrls,
webUrl,
channelId,
owner,
contributors,
createdAt: event.created_at,
@@ -45,7 +48,7 @@ function eventToRepo(event: NostrEvent): Repo {
}
/** Deduplicate NIP-33 parameterized replaceable events, keeping the latest per (pubkey, kind, d-tag). */
function dedup(events: NostrEvent[]): NostrEvent[] {
export function dedup(events: NostrEvent[]): NostrEvent[] {
const best = new Map<string, NostrEvent>();
for (const e of events) {
const d = getTag(e, "d") ?? "";
+33
View File
@@ -0,0 +1,33 @@
/**
* NIP-98 HTTP Auth helper — signs a kind:27235 event for authenticating
* HTTP requests to the relay (used by isomorphic-git for smart HTTP transport).
*/
import { finalizeEvent } from "nostr-tools/pure";
import { getEphemeralKey } from "./nostr-client";
/**
* Build a NIP-98 Authorization header value.
*
* Creates a kind:27235 event with `u` and `method` tags, signs it with the
* session's ephemeral key, base64-encodes the JSON, and returns
* `"Nostr <base64>"`.
*/
export function makeNip98AuthHeader(url: string, method: string): string {
const event = finalizeEvent(
{
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
["u", url],
["method", method],
],
content: "",
},
getEphemeralKey(),
);
const json = JSON.stringify(event);
const base64 = btoa(json);
return `Nostr ${base64}`;
}
+1 -1
View File
@@ -32,7 +32,7 @@ const QUERY_TIMEOUT_MS = 10_000;
/** Lazily-generated ephemeral keypair for NIP-42 AUTH. */
let _secretKey: Uint8Array | null = null;
function getEphemeralKey(): Uint8Array {
export function getEphemeralKey(): Uint8Array {
if (!_secretKey) {
_secretKey = generateSecretKey();
}
+18
View File
@@ -0,0 +1,18 @@
/** Format a Unix timestamp (seconds) as a human-readable relative time string. */
export function relativeTime(unix: number): string {
const diff = Date.now() - unix * 1000;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 30) {
const months = Math.floor(days / 30);
return months === 1 ? "1 month ago" : `${months} months ago`;
}
if (days > 0) return days === 1 ? "1 day ago" : `${days} days ago`;
if (hours > 0) return hours === 1 ? "1 hour ago" : `${hours} hours ago`;
if (minutes > 0)
return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`;
return "just now";
}
+2 -1
View File
@@ -1,4 +1,5 @@
import tailwindcssAnimate from "tailwindcss-animate";
import tailwindcssTypography from "@tailwindcss/typography";
/** @type {import('tailwindcss').Config} */
export default {
@@ -67,5 +68,5 @@ export default {
},
},
},
plugins: [tailwindcssAnimate],
plugins: [tailwindcssAnimate, tailwindcssTypography],
};