feat: add repository file tree visualization

Add file tree explorer to the Repository detail renderer (kind 30617)
using @fiatjaf/git-natural-api for fetching git trees via HTTP.

New files:
- src/lib/git-types.ts: TypeScript types for DirectoryTree, SelectedFile, etc.
- src/hooks/useGitTree.ts: Hook to fetch git repository tree from clone URLs
  - Tries multiple clone URLs in sequence
  - Uses getDirectoryTreeAt with filter capability when available
  - Falls back to shallowCloneRepositoryAt otherwise
- src/hooks/useGitBlob.ts: Hook to fetch individual file content by hash
  - Detects binary files
  - Returns both raw Uint8Array and decoded text
- src/components/ui/FileTreeView.tsx: Recursive tree view component
  - Collapsible directories with chevron icons
  - File icons based on extension (code, json, text, image, etc.)
  - Alphabetical sorting with directories first
- src/components/nostr/kinds/RepositoryFilesSection.tsx: Main integration
  - Side-by-side tree and file preview layout
  - Syntax-highlighted file content using existing SyntaxHighlight
  - Binary file detection with appropriate UI
  - Loading/error states

Modified:
- RepositoryDetailRenderer.tsx: Added RepositoryFilesSection below relays

Dependencies:
- Added @fiatjaf/git-natural-api from JSR
This commit is contained in:
Claude
2026-01-02 11:43:37 +00:00
parent 90d1607eec
commit 8183c4e798
9 changed files with 852 additions and 1 deletions

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
@jsr:registry=https://npm.jsr.io

24
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@fiatjaf/git-natural-api": "npm:@jsr/fiatjaf__git-natural-api@^0.1.3",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
@@ -1479,6 +1480,28 @@
}
}
},
"node_modules/@fiatjaf/git-natural-api": {
"name": "@jsr/fiatjaf__git-natural-api",
"version": "0.1.3",
"resolved": "https://npm.jsr.io/~/11/@jsr/fiatjaf__git-natural-api/0.1.3.tgz",
"integrity": "sha512-Xi7k0hF8UzI4ukEBXbyo/ZvymQzyBy+89TwejU90NXPbez3vItUlr0KNKjpsPQxsXyv+1RJYl2sV7PVdd2iF0Q==",
"dependencies": {
"@noble/hashes": "2.0.1",
"fflate": "0.8.2"
}
},
"node_modules/@fiatjaf/git-natural-api/node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
@@ -8007,7 +8030,6 @@
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"dev": true,
"license": "MIT"
},
"node_modules/file-entry-cache": {

View File

@@ -18,6 +18,7 @@
"generate-icons": "node scripts/generate-pwa-icons.mjs"
},
"dependencies": {
"@fiatjaf/git-natural-api": "npm:@jsr/fiatjaf__git-natural-api@^0.1.3",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",

View File

@@ -3,6 +3,7 @@ import { Globe, Copy, Users, CopyCheck, Server } from "lucide-react";
import { UserName } from "../UserName";
import { RelayLink } from "../RelayLink";
import { useCopy } from "@/hooks/useCopy";
import { RepositoryFilesSection } from "./RepositoryFilesSection";
import type { NostrEvent } from "@/types/nostr";
import {
getRepositoryName,
@@ -123,6 +124,9 @@ export function RepositoryDetailRenderer({ event }: { event: NostrEvent }) {
</ul>
</section>
)}
{/* Files Section */}
{cloneUrls.length > 0 && <RepositoryFilesSection cloneUrls={cloneUrls} />}
</div>
);
}

View File

@@ -0,0 +1,205 @@
import { useState, useMemo } from "react";
import { FolderGit2, AlertCircle, FileQuestion, Binary } from "lucide-react";
import { useGitTree } from "@/hooks/useGitTree";
import { useGitBlob } from "@/hooks/useGitBlob";
import { FileTreeView } from "@/components/ui/FileTreeView";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
import { Skeleton } from "@/components/ui/skeleton/Skeleton";
import { cn } from "@/lib/utils";
import type { SelectedFile } from "@/lib/git-types";
interface RepositoryFilesSectionProps {
cloneUrls: string[];
className?: string;
}
/**
* Get file extension from filename
*/
function getExtension(filename: string): string {
const parts = filename.split(".");
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : "";
}
/**
* Format file size for display
*/
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/**
* Repository files section with tree view and file content preview
*
* Displays a collapsible file tree on the left and file content on the right.
* Files are fetched lazily when selected.
*/
export function RepositoryFilesSection({
cloneUrls,
className,
}: RepositoryFilesSectionProps) {
const [selectedFile, setSelectedFile] = useState<SelectedFile | null>(null);
// Fetch the repository tree
const {
tree,
loading: treeLoading,
error: treeError,
serverUrl,
} = useGitTree({
cloneUrls,
enabled: cloneUrls.length > 0,
});
// Fetch file content when a file is selected
const {
text: fileContent,
loading: contentLoading,
error: contentError,
isBinary,
content: rawContent,
} = useGitBlob({
serverUrl,
hash: selectedFile?.hash ?? null,
enabled: !!selectedFile && !!serverUrl,
});
// Get the language for syntax highlighting from the file extension
const language = useMemo(() => {
if (!selectedFile) return null;
return getExtension(selectedFile.name) || null;
}, [selectedFile]);
const handleFileSelect = (file: SelectedFile) => {
setSelectedFile(file);
};
// Don't render if no clone URLs
if (cloneUrls.length === 0) {
return null;
}
// Loading state
if (treeLoading) {
return (
<section className={cn("flex flex-col gap-4", className)}>
<h2 className="text-xl font-semibold flex items-center gap-2">
<FolderGit2 className="size-5" />
Files
</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Skeleton className="h-64" />
<Skeleton className="h-64" />
</div>
</section>
);
}
// Error state
if (treeError) {
return (
<section className={cn("flex flex-col gap-4", className)}>
<h2 className="text-xl font-semibold flex items-center gap-2">
<FolderGit2 className="size-5" />
Files
</h2>
<div className="flex items-start gap-3 p-4 bg-destructive/10 border border-destructive/20 rounded text-sm">
<AlertCircle className="size-5 text-destructive flex-shrink-0 mt-0.5" />
<div className="flex flex-col gap-1">
<p className="font-medium">Unable to load repository files</p>
<p className="text-muted-foreground text-xs">{treeError.message}</p>
</div>
</div>
</section>
);
}
// No tree available
if (!tree) {
return null;
}
return (
<section className={cn("flex flex-col gap-4", className)}>
<h2 className="text-xl font-semibold flex items-center gap-2">
<FolderGit2 className="size-5" />
Files
{serverUrl && (
<span className="text-xs text-muted-foreground font-normal ml-2">
from {new URL(serverUrl).hostname}
</span>
)}
</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* File Tree */}
<div className="border border-border rounded p-2 max-h-96 overflow-auto bg-muted/20">
<FileTreeView
tree={tree}
onFileSelect={handleFileSelect}
selectedPath={selectedFile?.path}
/>
</div>
{/* File Content Preview */}
<div className="border border-border rounded max-h-96 overflow-auto bg-muted/20">
{selectedFile ? (
contentLoading ? (
<div className="p-4">
<Skeleton className="h-48" />
</div>
) : contentError ? (
<div className="flex items-start gap-3 p-4 text-sm">
<AlertCircle className="size-5 text-destructive flex-shrink-0 mt-0.5" />
<div className="flex flex-col gap-1">
<p className="font-medium">Failed to load file</p>
<p className="text-muted-foreground text-xs">
{contentError.message}
</p>
</div>
</div>
) : isBinary ? (
<div className="flex flex-col items-center justify-center h-full p-8 text-muted-foreground">
<Binary className="size-12 mb-4 opacity-50" />
<p className="text-sm font-medium">Binary file</p>
<p className="text-xs mt-1">
{rawContent && formatSize(rawContent.length)}
</p>
</div>
) : fileContent ? (
<div className="relative">
<div className="sticky top-0 bg-muted/80 backdrop-blur-sm px-3 py-1.5 border-b border-border/50 flex items-center justify-between">
<span className="text-xs font-mono text-muted-foreground truncate">
{selectedFile.path}
</span>
{rawContent && (
<span className="text-xs text-muted-foreground">
{formatSize(rawContent.length)}
</span>
)}
</div>
<SyntaxHighlight
code={fileContent}
language={language}
className="p-3"
/>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full p-8 text-muted-foreground">
<FileQuestion className="size-12 mb-4 opacity-50" />
<p className="text-sm">Empty file</p>
</div>
)
) : (
<div className="flex flex-col items-center justify-center h-full min-h-48 p-8 text-muted-foreground">
<FileQuestion className="size-12 mb-4 opacity-30" />
<p className="text-sm">Select a file to view its contents</p>
</div>
)}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,296 @@
import { useState, useMemo } from "react";
import {
ChevronRight,
ChevronDown,
File,
FileCode,
FileJson,
FileText,
Folder,
FolderOpen,
Image,
FileArchive,
FileAudio,
FileVideo,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { DirectoryTree, SelectedFile } from "@/lib/git-types";
interface FileTreeViewProps {
tree: DirectoryTree;
onFileSelect: (file: SelectedFile) => void;
selectedPath?: string;
className?: string;
}
interface TreeNodeProps {
name: string;
hash: string;
path: string;
isDirectory: boolean;
content?: DirectoryTree | null;
onFileSelect: (file: SelectedFile) => void;
selectedPath?: string;
depth: number;
}
/**
* Get appropriate icon for a file based on extension
*/
function getFileIcon(filename: string) {
const ext = filename.split(".").pop()?.toLowerCase() || "";
// Code files
if (
[
"js",
"jsx",
"ts",
"tsx",
"py",
"rb",
"go",
"rs",
"java",
"c",
"cpp",
"h",
"hpp",
"cs",
"php",
"swift",
"kt",
"scala",
"zig",
"lua",
"sh",
"bash",
"zsh",
"fish",
"ps1",
"pl",
"r",
"ex",
"exs",
"erl",
"hs",
"ml",
"clj",
"vim",
"sol",
].includes(ext)
) {
return FileCode;
}
// JSON/Config
if (["json", "jsonc", "json5"].includes(ext)) {
return FileJson;
}
// Text/Docs
if (
[
"md",
"mdx",
"txt",
"rst",
"yaml",
"yml",
"toml",
"ini",
"cfg",
"conf",
"xml",
"html",
"htm",
"css",
"scss",
"sass",
"less",
"csv",
"log",
].includes(ext)
) {
return FileText;
}
// Images
if (
["png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "bmp"].includes(ext)
) {
return Image;
}
// Archives
if (["zip", "tar", "gz", "bz2", "7z", "rar", "xz"].includes(ext)) {
return FileArchive;
}
// Audio
if (["mp3", "wav", "ogg", "flac", "aac", "m4a"].includes(ext)) {
return FileAudio;
}
// Video
if (["mp4", "webm", "mkv", "avi", "mov", "wmv"].includes(ext)) {
return FileVideo;
}
return File;
}
/**
* Single tree node (file or directory)
*/
function TreeNode({
name,
hash,
path,
isDirectory,
content,
onFileSelect,
selectedPath,
depth,
}: TreeNodeProps) {
const [isOpen, setIsOpen] = useState(depth === 0);
const isSelected = selectedPath === path;
const handleClick = () => {
if (isDirectory) {
setIsOpen(!isOpen);
} else {
onFileSelect({ name, hash, path });
}
};
const Icon = isDirectory ? (isOpen ? FolderOpen : Folder) : getFileIcon(name);
return (
<div>
<button
onClick={handleClick}
className={cn(
"flex items-center gap-1.5 w-full text-left py-0.5 px-1 hover:bg-muted/50 text-sm",
isSelected && "bg-primary/20 text-primary",
isDirectory && "font-medium",
)}
style={{ paddingLeft: `${depth * 12 + 4}px` }}
>
{isDirectory ? (
isOpen ? (
<ChevronDown className="size-3 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="size-3 text-muted-foreground flex-shrink-0" />
)
) : (
<span className="size-3 flex-shrink-0" />
)}
<Icon
className={cn(
"size-4 flex-shrink-0",
isDirectory ? "text-yellow-500" : "text-muted-foreground",
)}
/>
<span className="truncate">{name}</span>
</button>
{isDirectory && isOpen && content && (
<TreeContents
tree={content}
basePath={path}
onFileSelect={onFileSelect}
selectedPath={selectedPath}
depth={depth + 1}
/>
)}
</div>
);
}
interface TreeContentsProps {
tree: DirectoryTree;
basePath: string;
onFileSelect: (file: SelectedFile) => void;
selectedPath?: string;
depth: number;
}
/**
* Render contents of a directory
*/
function TreeContents({
tree,
basePath,
onFileSelect,
selectedPath,
depth,
}: TreeContentsProps) {
// Sort: directories first, then alphabetically
const sortedEntries = useMemo(() => {
const dirs = [...tree.directories].sort((a, b) =>
a.name.localeCompare(b.name),
);
const files = [...tree.files].sort((a, b) => a.name.localeCompare(b.name));
return { dirs, files };
}, [tree]);
return (
<div>
{sortedEntries.dirs.map((dir) => (
<TreeNode
key={dir.hash}
name={dir.name}
hash={dir.hash}
path={basePath ? `${basePath}/${dir.name}` : dir.name}
isDirectory={true}
content={dir.content}
onFileSelect={onFileSelect}
selectedPath={selectedPath}
depth={depth}
/>
))}
{sortedEntries.files.map((file) => (
<TreeNode
key={file.hash}
name={file.name}
hash={file.hash}
path={basePath ? `${basePath}/${file.name}` : file.name}
isDirectory={false}
onFileSelect={onFileSelect}
selectedPath={selectedPath}
depth={depth}
/>
))}
</div>
);
}
/**
* File tree view component for displaying git repository structure
*
* @example
* <FileTreeView
* tree={directoryTree}
* onFileSelect={(file) => console.log('Selected:', file.path)}
* selectedPath={selectedFile?.path}
* />
*/
export function FileTreeView({
tree,
onFileSelect,
selectedPath,
className,
}: FileTreeViewProps) {
return (
<div className={cn("font-mono text-xs", className)}>
<TreeContents
tree={tree}
basePath=""
onFileSelect={onFileSelect}
selectedPath={selectedPath}
depth={0}
/>
</div>
);
}

122
src/hooks/useGitBlob.ts Normal file
View File

@@ -0,0 +1,122 @@
import { useState, useEffect, useCallback } from "react";
import { getObject } from "@fiatjaf/git-natural-api";
interface UseGitBlobOptions {
/** Git server URL */
serverUrl: string | null;
/** Blob hash to fetch */
hash: string | null;
/** Whether to fetch immediately */
enabled?: boolean;
}
interface UseGitBlobResult {
/** The blob content as Uint8Array */
content: Uint8Array | null;
/** Content decoded as text (if decodable) */
text: string | null;
/** Loading state */
loading: boolean;
/** Error if fetch failed */
error: Error | null;
/** Whether the content appears to be binary */
isBinary: boolean;
/** Refetch the blob */
refetch: () => void;
}
/**
* Check if content appears to be binary (contains null bytes or non-text characters)
*/
function detectBinary(data: Uint8Array): boolean {
// Check first 8KB for binary indicators
const checkLength = Math.min(data.length, 8192);
for (let i = 0; i < checkLength; i++) {
const byte = data[i];
// Null byte is a strong indicator of binary
if (byte === 0) return true;
// Non-printable characters (except common whitespace) suggest binary
if (byte < 9 || (byte > 13 && byte < 32)) return true;
}
return false;
}
/**
* Hook to fetch a git blob (file content) by its hash
*
* @example
* const { content, text, loading } = useGitBlob({
* serverUrl: 'https://github.com/user/repo.git',
* hash: 'abc123...'
* })
*/
export function useGitBlob({
serverUrl,
hash,
enabled = true,
}: UseGitBlobOptions): UseGitBlobResult {
const [content, setContent] = useState<Uint8Array | null>(null);
const [text, setText] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [isBinary, setIsBinary] = useState(false);
const fetchBlob = useCallback(async () => {
if (!serverUrl || !hash) {
return;
}
setLoading(true);
setError(null);
setContent(null);
setText(null);
setIsBinary(false);
try {
const object = await getObject(serverUrl, hash);
if (!object || !object.data) {
throw new Error("Empty or invalid blob");
}
const data = object.data;
setContent(data);
// Check if binary
const binary = detectBinary(data);
setIsBinary(binary);
// Try to decode as text if not binary
if (!binary) {
try {
const decoder = new TextDecoder("utf-8", { fatal: true });
setText(decoder.decode(data));
} catch {
// Decoding failed, treat as binary
setIsBinary(true);
}
}
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
console.warn(`[useGitBlob] Failed to fetch blob ${hash}:`, err.message);
setError(err);
} finally {
setLoading(false);
}
}, [serverUrl, hash]);
useEffect(() => {
if (enabled && serverUrl && hash) {
fetchBlob();
}
}, [enabled, serverUrl, hash, fetchBlob]);
return {
content,
text,
loading,
error,
isBinary,
refetch: fetchBlob,
};
}

150
src/hooks/useGitTree.ts Normal file
View File

@@ -0,0 +1,150 @@
import { useState, useEffect, useCallback } from "react";
import {
getInfoRefs,
getDirectoryTreeAt,
shallowCloneRepositoryAt,
MissingCapability,
} from "@fiatjaf/git-natural-api";
import type { DirectoryTree } from "@/lib/git-types";
interface UseGitTreeOptions {
/** Clone URLs to try in order */
cloneUrls: string[];
/** Branch, tag, or commit ref (defaults to HEAD) */
ref?: string;
/** Whether to fetch immediately */
enabled?: boolean;
}
interface UseGitTreeResult {
/** The directory tree if successfully fetched */
tree: DirectoryTree | null;
/** Loading state */
loading: boolean;
/** Error if fetch failed */
error: Error | null;
/** Which server URL succeeded */
serverUrl: string | null;
/** Refetch the tree */
refetch: () => void;
}
/**
* Hook to fetch a git repository tree from clone URLs
*
* Tries each clone URL in sequence until one succeeds.
* Uses the lightweight `getDirectoryTreeAt` if the server supports filtering,
* otherwise falls back to `shallowCloneRepositoryAt`.
*
* @example
* const { tree, loading, error } = useGitTree({
* cloneUrls: ['https://github.com/user/repo.git'],
* ref: 'main'
* })
*/
export function useGitTree({
cloneUrls,
ref = "HEAD",
enabled = true,
}: UseGitTreeOptions): UseGitTreeResult {
const [tree, setTree] = useState<DirectoryTree | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [serverUrl, setServerUrl] = useState<string | null>(null);
const fetchTree = useCallback(async () => {
if (cloneUrls.length === 0) {
setError(new Error("No clone URLs provided"));
return;
}
setLoading(true);
setError(null);
setTree(null);
setServerUrl(null);
const errors: Error[] = [];
for (const url of cloneUrls) {
try {
// Get server info to check capabilities and resolve refs
const info = await getInfoRefs(url);
const hasFilter = info.capabilities.includes("filter");
// Resolve the ref to a commit hash
let resolvedRef = ref;
if (ref === "HEAD" && info.symrefs["HEAD"]) {
// HEAD points to a branch like "refs/heads/main"
const headBranch = info.symrefs["HEAD"];
if (info.refs[headBranch]) {
resolvedRef = info.refs[headBranch];
}
} else if (ref.startsWith("refs/") && info.refs[ref]) {
resolvedRef = info.refs[ref];
} else if (!ref.match(/^[0-9a-f]{40}$/i)) {
// Try common ref patterns
const possibleRefs = [`refs/heads/${ref}`, `refs/tags/${ref}`];
for (const possibleRef of possibleRefs) {
if (info.refs[possibleRef]) {
resolvedRef = info.refs[possibleRef];
break;
}
}
}
// Fetch the tree
let fetchedTree: DirectoryTree;
if (hasFilter) {
// Server supports filter - use lightweight fetch (tree only, no blobs)
fetchedTree = await getDirectoryTreeAt(url, resolvedRef);
} else {
// No filter support - need to do shallow clone
fetchedTree = await shallowCloneRepositoryAt(url, resolvedRef);
}
setTree(fetchedTree);
setServerUrl(url);
setLoading(false);
return;
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
errors.push(err);
// Log specific error types for debugging
if (e instanceof MissingCapability) {
console.warn(
`[useGitTree] Server ${url} missing capability: ${e.capability}`,
);
} else {
console.warn(
`[useGitTree] Failed to fetch from ${url}:`,
err.message,
);
}
continue;
}
}
// All URLs failed
const message =
errors.length === 1
? errors[0].message
: `All ${cloneUrls.length} servers failed`;
setError(new Error(message));
setLoading(false);
}, [cloneUrls, ref]);
useEffect(() => {
if (enabled && cloneUrls.length > 0) {
fetchTree();
}
}, [enabled, fetchTree, cloneUrls.length]);
return {
tree,
loading,
error,
serverUrl,
refetch: fetchTree,
};
}

50
src/lib/git-types.ts Normal file
View File

@@ -0,0 +1,50 @@
/**
* Types for git repository tree visualization
* Based on @fiatjaf/git-natural-api library
*/
/**
* Directory tree structure returned by git-natural-api
*/
export interface DirectoryTree {
directories: Array<{
name: string;
hash: string;
content: DirectoryTree | null;
}>;
files: Array<{
name: string;
hash: string;
content: Uint8Array | null;
}>;
}
/**
* Git server info/refs response
*/
export interface GitInfoRefs {
service: string | null;
refs: Record<string, string>;
capabilities: string[];
symrefs: Record<string, string>;
}
/**
* Flattened file entry for tree display
*/
export interface FileEntry {
name: string;
hash: string;
path: string;
isDirectory: boolean;
children?: FileEntry[];
}
/**
* Selected file in the tree
*/
export interface SelectedFile {
name: string;
hash: string;
path: string;
}