mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
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:
24
package-lock.json
generated
24
package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
205
src/components/nostr/kinds/RepositoryFilesSection.tsx
Normal file
205
src/components/nostr/kinds/RepositoryFilesSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
296
src/components/ui/FileTreeView.tsx
Normal file
296
src/components/ui/FileTreeView.tsx
Normal 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
122
src/hooks/useGitBlob.ts
Normal 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
150
src/hooks/useGitTree.ts
Normal 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
50
src/lib/git-types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user