import { Braces, ChevronRight, CodeXml, Database, FileArchive, FileAudio, FileCode2, FileCog, FileDiff, FileImage, FileJson, FileLock2, FileSpreadsheet, FileText, FileType, FileVideo, FolderGit2, Package, Settings, Terminal, } from "lucide-react"; import * as React from "react"; import type { ProjectRepoFile, ProjectRepoSnapshot, } from "@/features/projects/hooks"; import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; import { useUserSearchQuery } from "@/features/profile/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { SyntaxHighlightedCode } from "@/shared/ui/markdown"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { PROJECT_DETAIL_PANEL_CLASS, PROJECT_DETAIL_PANEL_MESSAGE_CLASS, } from "./projectPanelStyles"; import { type RepoSourceHeaderControls, RepoSourceDropdown, RepoSyncActionButton, RepositoryBranchDropdown, } from "./ProjectRepositorySource"; function pluralize(count: number, singular: string) { return `${count} ${singular}${count === 1 ? "" : "s"}`; } export function formatLastChangedAt(timestamp: number | null) { if (!timestamp) return "—"; return new Date(timestamp * 1_000).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }); } function formatFileSize(size: number | null) { if (size === null) return "—"; if (size < 1024) return `${size} B`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; return `${(size / (1024 * 1024)).toFixed(1)} MB`; } function normalizeAuthorLookupValue(value: string | null | undefined) { return value?.trim().toLowerCase() ?? ""; } type CommitAuthorProfile = { avatarUrl: string | null; displayName: string | null; isAgent?: boolean; nip05Handle?: string | null; ownerPubkey?: string | null; }; function profileMatchesCommitAuthor( commit: ProjectRepoFile["latestCommit"], profile: CommitAuthorProfile, pubkey?: string, ) { if (!commit) return false; const authorName = normalizeAuthorLookupValue(commit.authorName); const authorEmail = normalizeAuthorLookupValue(commit.authorEmail); if (!authorName && !authorEmail) return false; const candidates = [ pubkey, profile.displayName, profile.nip05Handle, profile.ownerPubkey, ].map(normalizeAuthorLookupValue); return candidates.includes(authorName) || candidates.includes(authorEmail); } function profileForCommitAuthor( commit: ProjectRepoFile["latestCommit"], profiles: UserProfileLookup | undefined, ) { if (!commit || !profiles) return null; for (const [pubkey, profile] of Object.entries(profiles)) { if (profileMatchesCommitAuthor(commit, profile, pubkey)) { return profile; } } return null; } function searchedProfileForCommitAuthor( commit: ProjectRepoFile["latestCommit"], users: UserSearchResult[] | undefined, ) { if (!commit || !users?.length) return null; return ( users.find((user) => profileMatchesCommitAuthor(commit, user, user.pubkey), ) ?? users[0] ); } function commitAuthorLabel( commit: ProjectRepoFile["latestCommit"], profile?: CommitAuthorProfile | null, ) { if (!commit) return ""; return ( profile?.displayName?.trim() || profile?.nip05Handle?.trim() || commit.authorName ); } function RepositoryCommitCell({ commit, profiles, }: { commit: ProjectRepoFile["latestCommit"]; profiles?: UserProfileLookup; }) { if (!commit) return —; return (

{commit.subject} {" "} · {commitAuthorLabel(commit, profileForCommitAuthor(commit, profiles))}

); } export function baseName(path: string) { return path.split("/").pop() || path; } const FILE_LANGUAGE_BY_EXTENSION: Record = { c: "c", cc: "cpp", cpp: "cpp", cs: "csharp", css: "css", dart: "dart", go: "go", h: "c", hpp: "cpp", html: "html", java: "java", js: "javascript", json: "json", jsx: "jsx", kt: "kotlin", kts: "kotlin", md: "markdown", mjs: "javascript", mts: "typescript", py: "python", rb: "ruby", rs: "rust", sh: "bash", sql: "sql", swift: "swift", toml: "toml", ts: "typescript", tsx: "tsx", yaml: "yaml", yml: "yaml", zig: "zig", }; export function languageForPath(path: string) { const fileName = baseName(path).toLowerCase(); if (fileName === "dockerfile") return "dockerfile"; if (fileName === "makefile") return "make"; const extension = fileName.split(".").pop(); return extension ? FILE_LANGUAGE_BY_EXTENSION[extension] : undefined; } type FileIconVisual = { Icon: React.ComponentType<{ className?: string }>; className: string; containerClassName: string; }; const CODE_EXTENSIONS = new Set([ "c", "cc", "cpp", "cs", "dart", "go", "java", "js", "jsx", "kt", "kts", "mjs", "mts", "py", "rb", "rs", "swift", "ts", "tsx", "zig", ]); const IMAGE_EXTENSIONS = new Set([ "gif", "heic", "jpeg", "jpg", "png", "svg", "webp", ]); const ARCHIVE_EXTENSIONS = new Set([ "7z", "bz2", "gz", "rar", "tar", "tgz", "zip", ]); const AUDIO_EXTENSIONS = new Set(["aac", "flac", "m4a", "mp3", "ogg", "wav"]); const VIDEO_EXTENSIONS = new Set(["avi", "m4v", "mov", "mp4", "webm"]); const SPREADSHEET_EXTENSIONS = new Set(["csv", "ods", "tsv", "xls", "xlsx"]); const TEXT_EXTENSIONS = new Set(["md", "mdx", "rst", "txt"]); function extensionForPath(path: string) { const name = baseName(path).toLowerCase(); if (!name.includes(".")) return ""; return name.split(".").pop() ?? ""; } function fileIconVisual(entry: RepositoryFileEntry): FileIconVisual { if (entry.type === "directory") { return { Icon: FolderGit2, className: "fill-sky-500/25 text-sky-500", containerClassName: "bg-sky-500/15", }; } const name = entry.name.toLowerCase(); const extension = extensionForPath(entry.name); if ( name === "dockerfile" || name === "containerfile" || name === "package.json" ) { return { Icon: Package, className: "fill-orange-500/20 text-orange-500", containerClassName: "bg-orange-500/15", }; } if (name.includes("lock") || extension === "pem" || extension === "key") { return { Icon: FileLock2, className: "fill-amber-500/20 text-amber-500", containerClassName: "bg-amber-500/15", }; } if (extension === "json") { return { Icon: FileJson, className: "fill-yellow-500/20 text-yellow-500", containerClassName: "bg-yellow-500/15", }; } if ( ["yaml", "yml", "toml", "ini", "conf", "config", "env"].includes(extension) ) { return { Icon: Settings, className: "fill-zinc-500/20 text-zinc-500", containerClassName: "bg-zinc-500/15", }; } if (["html", "xml"].includes(extension)) { return { Icon: CodeXml, className: "fill-rose-500/20 text-rose-500", containerClassName: "bg-rose-500/15", }; } if (extension === "css") { return { Icon: Braces, className: "fill-violet-500/20 text-violet-500", containerClassName: "bg-violet-500/15", }; } if (CODE_EXTENSIONS.has(extension)) { return { Icon: FileCode2, className: "fill-blue-500/20 text-blue-500", containerClassName: "bg-blue-500/15", }; } if (IMAGE_EXTENSIONS.has(extension)) { return { Icon: FileImage, className: "fill-pink-500/20 text-pink-500", containerClassName: "bg-pink-500/15", }; } if (ARCHIVE_EXTENSIONS.has(extension)) { return { Icon: FileArchive, className: "fill-orange-500/20 text-orange-500", containerClassName: "bg-orange-500/15", }; } if (AUDIO_EXTENSIONS.has(extension)) { return { Icon: FileAudio, className: "fill-purple-500/20 text-purple-500", containerClassName: "bg-purple-500/15", }; } if (VIDEO_EXTENSIONS.has(extension)) { return { Icon: FileVideo, className: "fill-red-500/20 text-red-500", containerClassName: "bg-red-500/15", }; } if (SPREADSHEET_EXTENSIONS.has(extension)) { return { Icon: FileSpreadsheet, className: "fill-emerald-500/20 text-emerald-500", containerClassName: "bg-emerald-500/15", }; } if (extension === "sql" || extension === "db" || extension === "sqlite") { return { Icon: Database, className: "fill-cyan-500/20 text-cyan-500", containerClassName: "bg-cyan-500/15", }; } if (["bash", "fish", "sh", "zsh"].includes(extension)) { return { Icon: Terminal, className: "fill-lime-500/20 text-lime-500", containerClassName: "bg-lime-500/15", }; } if (TEXT_EXTENSIONS.has(extension)) { return { Icon: FileText, className: "fill-slate-500/20 text-slate-500", containerClassName: "bg-slate-500/15", }; } if (extension === "pdf") { return { Icon: FileType, className: "fill-red-500/20 text-red-500", containerClassName: "bg-red-500/15", }; } return { Icon: FileCog, className: "fill-muted-foreground/20 text-muted-foreground", containerClassName: "bg-muted/70", }; } function RepositoryEntryIcon({ entry }: { entry: RepositoryFileEntry }) { const visual = fileIconVisual(entry); const Icon = visual.Icon; return ( ); } type RepositoryFileEntry = { file?: ProjectRepoFile; fileCount?: number; lastChangedAt: number | null; latestCommit: ProjectRepoFile["latestCommit"]; name: string; path: string; size: number | null; type: "directory" | "file"; }; function repositoryEntries( files: ProjectRepoFile[], currentPath: string, ): RepositoryFileEntry[] { const directories = new Map(); const entries: RepositoryFileEntry[] = []; const prefix = currentPath ? `${currentPath}/` : ""; for (const file of files) { if (currentPath && !file.path.startsWith(prefix)) continue; const relativePath = currentPath ? file.path.slice(prefix.length) : file.path; const [name, ...rest] = relativePath.split("/"); if (!name) continue; if (rest.length > 0) { const path = currentPath ? `${currentPath}/${name}` : name; const existing = directories.get(path); if (existing) { existing.fileCount = (existing.fileCount ?? 0) + 1; if ((file.lastChangedAt ?? 0) > (existing.lastChangedAt ?? 0)) { existing.lastChangedAt = file.lastChangedAt; existing.latestCommit = file.latestCommit; } } else { directories.set(path, { fileCount: 1, lastChangedAt: file.lastChangedAt, latestCommit: file.latestCommit, name, path, size: null, type: "directory", }); } continue; } entries.push({ file, lastChangedAt: file.lastChangedAt, latestCommit: file.latestCommit, name, path: file.path, size: file.size, type: "file", }); } return [...directories.values(), ...entries].sort((left, right) => { if (left.type !== right.type) return left.type === "directory" ? -1 : 1; return left.name.localeCompare(right.name); }); } function openRepositoryEntry( entry: RepositoryFileEntry, setCurrentPath: (path: string) => void, setSelectedFile: (file: ProjectRepoFile) => void, ) { if (entry.type === "directory") { setCurrentPath(entry.path); return; } if (entry.file) setSelectedFile(entry.file); } function handleRepositoryEntryKeyDown( event: React.KeyboardEvent, onOpen: () => void, ) { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); onOpen(); } function BreadcrumbButton({ children, onClick, }: { children: React.ReactNode; onClick: () => void; }) { return ( ); } function FileContentPanel({ file, onOpenPath, }: { file: ProjectRepoFile; onOpenPath: (path: string) => void; }) { const language = languageForPath(file.path); const pathSegments = file.path.split("/").filter(Boolean); const fileName = pathSegments[pathSegments.length - 1] ?? file.path; const directorySegments = pathSegments.slice(0, -1); return (
onOpenPath("")}> Files {directorySegments.map((segment, index) => { const nextPath = directorySegments.slice(0, index + 1).join("/"); return ( onOpenPath(nextPath)}> {segment} ); })} {fileName}
Last changed {formatLastChangedAt(file.lastChangedAt)} {formatFileSize(file.size)}
{formatFileSize(file.size)}
Last changed {formatLastChangedAt(file.lastChangedAt)}
{file.previewContent ? (
          {language ? (
            
          ) : (
            
              {file.previewContent}
            
          )}
        
) : (
Preview unavailable for this file. Large and binary files only show metadata.
)}
); } export function RepositoryFilesPanel({ files, snapshot, isLoading, error, profiles, fallbackAuthorPubkey, sourceControls, unavailableMessage, }: { files: ProjectRepoFile[]; snapshot: ProjectRepoSnapshot | null | undefined; isLoading: boolean; error: unknown; profiles?: UserProfileLookup; fallbackAuthorPubkey?: string; /** Branch picker + remote/local toggle rendered in the panel header. */ sourceControls?: RepoSourceHeaderControls; unavailableMessage?: string; }) { const [currentPath, setCurrentPath] = React.useState(""); const [selectedFile, setSelectedFile] = React.useState(null); const entries = React.useMemo( () => repositoryEntries(files, currentPath), [currentPath, files], ); const visibleEntries = entries.slice(0, 200); const latestCommit = snapshot?.latestCommit ?? null; const knownLatestCommitProfile = React.useMemo( () => profileForCommitAuthor(latestCommit, profiles), [latestCommit, profiles], ); const fallbackLatestCommitProfile = fallbackAuthorPubkey ? (profiles?.[normalizePubkey(fallbackAuthorPubkey)] ?? null) : null; const authorSearchQuery = useUserSearchQuery( latestCommit?.authorEmail || latestCommit?.authorName || "", { enabled: Boolean(latestCommit) && !knownLatestCommitProfile?.avatarUrl && !fallbackLatestCommitProfile?.avatarUrl, limit: 3, }, ); const searchedLatestCommitProfile = React.useMemo( () => searchedProfileForCommitAuthor(latestCommit, authorSearchQuery.data), [authorSearchQuery.data, latestCommit], ); const latestCommitProfile = knownLatestCommitProfile ?? searchedLatestCommitProfile ?? fallbackLatestCommitProfile; const latestCommitAuthorLabel = commitAuthorLabel( latestCommit, latestCommitProfile, ); const pathSegments = currentPath ? currentPath.split("/") : []; const filesKey = React.useMemo( () => files.map((file) => file.path).join("\0"), [files], ); React.useEffect(() => { if (!filesKey) return; setCurrentPath(""); setSelectedFile(null); }, [filesKey]); // Loading/error/empty states keep the header controls visible — the // remote/local toggle must stay reachable when one source fails to load. const stateMessage = isLoading ? "Loading repository files…" : unavailableMessage ? unavailableMessage : error ? "Could not load the repository file tree." : files.length === 0 ? "No files have been pushed yet." : null; if (stateMessage) { if (!sourceControls) { return (
{stateMessage}
); } return (
{stateMessage}
); } if (selectedFile) { return ( { setSelectedFile(null); setCurrentPath(path); }} /> ); } return (
{sourceControls || pathSegments.length > 0 ? (
{sourceControls ? ( <> ) : ( setCurrentPath("")}> Files )} {sourceControls && pathSegments.length > 0 ? ( <> setCurrentPath("")}> Files ) : null} {pathSegments.map((segment, index) => { const nextPath = pathSegments.slice(0, index + 1).join("/"); return ( setCurrentPath(nextPath)}> {segment} ); })} {sourceControls ? (
) : null}
) : null}
{visibleEntries.map((entry, index) => { const latestCommit = entry.latestCommit; const rowIsLast = index === visibleEntries.length - 1; const openEntry = () => openRepositoryEntry(entry, setCurrentPath, setSelectedFile); return ( handleRepositoryEntryKeyDown(event, openEntry) } tabIndex={0} > ); })}
{latestCommit ? (

{latestCommitAuthorLabel} {" "} committed {" "} {latestCommit.subject} {latestCommit.shortHash} · {pluralize(files.length, "file")}

) : (

Repository files · {files.length} tracked files

)}
{entry.name}
{latestCommit ? ( ) : ( "—" )}
{entries.length > 200 ? (

Showing the first 200 entries in this folder. Open a folder to narrow the list.

) : null}
); }