Files
grimoire/src/components/nostr/kinds/RepositoryFilesSection.tsx
Claude 8183c4e798 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
2026-01-30 08:50:08 +00:00

206 lines
6.7 KiB
TypeScript

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>
);
}