diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..41583e3 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@jsr:registry=https://npm.jsr.io diff --git a/package-lock.json b/package-lock.json index 60fae40..0b00bbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index d2ec65b..52c1be7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/nostr/kinds/RepositoryDetailRenderer.tsx b/src/components/nostr/kinds/RepositoryDetailRenderer.tsx index 49e9c1e..93f3195 100644 --- a/src/components/nostr/kinds/RepositoryDetailRenderer.tsx +++ b/src/components/nostr/kinds/RepositoryDetailRenderer.tsx @@ -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 }) { )} + + {/* Files Section */} + {cloneUrls.length > 0 && } ); } diff --git a/src/components/nostr/kinds/RepositoryFilesSection.tsx b/src/components/nostr/kinds/RepositoryFilesSection.tsx new file mode 100644 index 0000000..af934af --- /dev/null +++ b/src/components/nostr/kinds/RepositoryFilesSection.tsx @@ -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(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 ( +
+

+ + Files +

+
+ + +
+
+ ); + } + + // Error state + if (treeError) { + return ( +
+

+ + Files +

+
+ +
+

Unable to load repository files

+

{treeError.message}

+
+
+
+ ); + } + + // No tree available + if (!tree) { + return null; + } + + return ( +
+

+ + Files + {serverUrl && ( + + from {new URL(serverUrl).hostname} + + )} +

+ +
+ {/* File Tree */} +
+ +
+ + {/* File Content Preview */} +
+ {selectedFile ? ( + contentLoading ? ( +
+ +
+ ) : contentError ? ( +
+ +
+

Failed to load file

+

+ {contentError.message} +

+
+
+ ) : isBinary ? ( +
+ +

Binary file

+

+ {rawContent && formatSize(rawContent.length)} +

+
+ ) : fileContent ? ( +
+
+ + {selectedFile.path} + + {rawContent && ( + + {formatSize(rawContent.length)} + + )} +
+ +
+ ) : ( +
+ +

Empty file

+
+ ) + ) : ( +
+ +

Select a file to view its contents

+
+ )} +
+
+
+ ); +} diff --git a/src/components/ui/FileTreeView.tsx b/src/components/ui/FileTreeView.tsx new file mode 100644 index 0000000..748a43a --- /dev/null +++ b/src/components/ui/FileTreeView.tsx @@ -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 ( +
+ + + {isDirectory && isOpen && content && ( + + )} +
+ ); +} + +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 ( +
+ {sortedEntries.dirs.map((dir) => ( + + ))} + {sortedEntries.files.map((file) => ( + + ))} +
+ ); +} + +/** + * File tree view component for displaying git repository structure + * + * @example + * console.log('Selected:', file.path)} + * selectedPath={selectedFile?.path} + * /> + */ +export function FileTreeView({ + tree, + onFileSelect, + selectedPath, + className, +}: FileTreeViewProps) { + return ( +
+ +
+ ); +} diff --git a/src/hooks/useGitBlob.ts b/src/hooks/useGitBlob.ts new file mode 100644 index 0000000..89a8df2 --- /dev/null +++ b/src/hooks/useGitBlob.ts @@ -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(null); + const [text, setText] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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, + }; +} diff --git a/src/hooks/useGitTree.ts b/src/hooks/useGitTree.ts new file mode 100644 index 0000000..6ad5a60 --- /dev/null +++ b/src/hooks/useGitTree.ts @@ -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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [serverUrl, setServerUrl] = useState(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, + }; +} diff --git a/src/lib/git-types.ts b/src/lib/git-types.ts new file mode 100644 index 0000000..54e8a7e --- /dev/null +++ b/src/lib/git-types.ts @@ -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; + capabilities: string[]; + symrefs: Record; +} + +/** + * 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; +}