-
Clone
-
- {repo.cloneUrls.map((url) => (
-
- ))}
+ {/* File tree */}
+
+
+ {/* Recent commits */}
+
+
+ {/* README */}
+
+
+ {/* Clone URLs */}
+ {repo.cloneUrls.length > 0 && (
+
+
Clone
+
+ {repo.cloneUrls.map((url) => (
+
+ ))}
+
-
- )}
+ )}
- {/* External link */}
- {repo.webUrl && (
-
- )}
-
- {/* Owner & Contributors */}
-
-
-
- People
-
-
-
- {repo.contributors
- .filter((c) => c !== repo.owner)
- .map((c) => (
-
- ))}
-
+ {/* 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 (
+
+ );
+ })()}
+
+ {/* Channel link */}
+ {repo.channelId && (
+
+ )}
- {/* Channel link */}
- {repo.channelId && (
-
);
}
diff --git a/web/src/features/repos/ui/RepoReadmeSection.tsx b/web/src/features/repos/ui/RepoReadmeSection.tsx
new file mode 100644
index 000000000..ee511f6a6
--- /dev/null
+++ b/web/src/features/repos/ui/RepoReadmeSection.tsx
@@ -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 (
+
+ );
+ }
+
+ if (!readme) return null;
+
+ return (
+
+
+
+ {readme.filename}
+
+
+ {readme.content}
+
+
+ );
+}
diff --git a/web/src/features/repos/ui/RepoTreeSection.tsx b/web/src/features/repos/ui/RepoTreeSection.tsx
new file mode 100644
index 000000000..86438ab6a
--- /dev/null
+++ b/web/src/features/repos/ui/RepoTreeSection.tsx
@@ -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 (
+
+ {isDir ? (
+
+ ) : (
+
+ )}
+ {entry.name}
+
+ );
+}
+
+export function RepoTreeSection({
+ entries,
+ isLoading,
+}: {
+ entries: TreeEntry[] | undefined;
+ isLoading: boolean;
+}) {
+ if (isLoading) {
+ return (
+
+
+ {["sk-1", "sk-2", "sk-3", "sk-4", "sk-5"].map((key) => (
+
+ ))}
+
+
+ );
+ }
+
+ if (!entries || entries.length === 0) return null;
+
+ return (
+
+
+ {entries.map((entry) => (
+
+ ))}
+
+
+ );
+}
diff --git a/web/src/features/repos/use-git-browse.ts b/web/src/features/repos/use-git-browse.ts
new file mode 100644
index 000000000..24b7cb594
--- /dev/null
+++ b/web/src/features/repos/use-git-browse.ts
@@ -0,0 +1,119 @@
+/**
+ * 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: async () => {
+ console.log("[git-clone] cloning", { owner, repoName, ref });
+ try {
+ const result = await ensureClone(owner, repoName, ref);
+ console.log("[git-clone] success");
+ return result;
+ } catch (err) {
+ console.error("[git-clone] failed", err);
+ throw err;
+ }
+ },
+ 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,
+ });
+}
diff --git a/web/src/shared/lib/nip98.ts b/web/src/shared/lib/nip98.ts
new file mode 100644
index 000000000..0e4da5df5
--- /dev/null
+++ b/web/src/shared/lib/nip98.ts
@@ -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
"`.
+ */
+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}`;
+}
diff --git a/web/src/shared/lib/nostr-client.ts b/web/src/shared/lib/nostr-client.ts
index d1df09a22..99e4718aa 100644
--- a/web/src/shared/lib/nostr-client.ts
+++ b/web/src/shared/lib/nostr-client.ts
@@ -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();
}
From 39ea729f5e76d4af1a8d1acaca16d1f0e4082c8e Mon Sep 17 00:00:00 2001
From: Wes
Date: Tue, 12 May 2026 14:50:46 -0700
Subject: [PATCH 3/4] feat(web): add Code/Commits tab layout to repo detail
page
Split the repo detail page into two tabs: "Code" (file tree + README)
and "Commits" (commit history), matching the standard GitHub-style
repo page pattern.
Co-Authored-By: Claude Opus 4.6
---
web/src/features/repos/ui/RepoDetailPage.tsx | 79 ++++++++++++++++++--
1 file changed, 71 insertions(+), 8 deletions(-)
diff --git a/web/src/features/repos/ui/RepoDetailPage.tsx b/web/src/features/repos/ui/RepoDetailPage.tsx
index 5a63be7b3..28399c8f0 100644
--- a/web/src/features/repos/ui/RepoDetailPage.tsx
+++ b/web/src/features/repos/ui/RepoDetailPage.tsx
@@ -15,6 +15,7 @@ import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
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";
@@ -92,6 +93,67 @@ function DetailSkeleton() {
);
}
+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("code");
+
+ return (
+
+ {/* Tab bar */}
+
+
+
+
+
+ {/* Tab content */}
+ {tab === "code" && (
+ <>
+
+
+ >
+ )}
+ {tab === "commits" && (
+
+ )}
+
+ );
+}
+
export function RepoDetailPage() {
const { repoId } = useParams({ from: "/repos/$repoId" });
const { data: repo, isLoading, error } = useRepo(repoId);
@@ -210,14 +272,15 @@ export function RepoDetailPage() {
{/* Clone URLs */}
{repo.cloneUrls.length > 0 && (
From ad2d9847fc8b5f9805e25791faae58a2eb66ac3f Mon Sep 17 00:00:00 2001
From: Wes