import { useState, useEffect, useCallback } from 'react'; import { useParams, useSearchParams, useNavigate, useLocation, Link } from 'react-router-dom'; import { PackageArtifact, Package, PaginatedResponse, AccessLevel, Dependency, DependentInfo } from '../types'; import { listPackageArtifacts, getDownloadUrl, getPackage, getMyProjectAccess, getArtifactDependencies, getReverseDependencies, getEnsureFile, UnauthorizedError, ForbiddenError } from '../api'; import { Breadcrumb } from '../components/Breadcrumb'; import { Badge } from '../components/Badge'; import { SearchInput } from '../components/SearchInput'; import { FilterChip, FilterChipGroup } from '../components/FilterChip'; import { DataTable } from '../components/DataTable'; import { Pagination } from '../components/Pagination'; import { DragDropUpload, UploadResult } from '../components/DragDropUpload'; import { useAuth } from '../contexts/AuthContext'; import DependencyGraph from '../components/DependencyGraph'; import './Home.css'; import './PackagePage.css'; function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); const handleCopy = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 2000); }; return ( ); } function PackagePage() { const { projectName, packageName } = useParams<{ projectName: string; packageName: string }>(); const navigate = useNavigate(); const location = useLocation(); const [searchParams, setSearchParams] = useSearchParams(); const { user } = useAuth(); const [pkg, setPkg] = useState(null); const [artifactsData, setArtifactsData] = useState | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [accessDenied, setAccessDenied] = useState(false); const [uploadSuccess, setUploadSuccess] = useState(null); const [accessLevel, setAccessLevel] = useState(null); // UI state const [showUploadModal, setShowUploadModal] = useState(false); const [openMenuId, setOpenMenuId] = useState(null); const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null); // Dependencies state const [selectedArtifact, setSelectedArtifact] = useState(null); const [dependencies, setDependencies] = useState([]); const [depsLoading, setDepsLoading] = useState(false); const [depsError, setDepsError] = useState(null); // Reverse dependencies state const [reverseDeps, setReverseDeps] = useState([]); const [reverseDepsLoading, setReverseDepsLoading] = useState(false); const [reverseDepsError, setReverseDepsError] = useState(null); const [reverseDepsPage, setReverseDepsPage] = useState(1); const [reverseDepsTotal, setReverseDepsTotal] = useState(0); const [reverseDepsHasMore, setReverseDepsHasMore] = useState(false); // Dependency graph modal state const [showGraph, setShowGraph] = useState(false); // Dependencies modal state const [showDepsModal, setShowDepsModal] = useState(false); // Artifact ID modal state const [showArtifactIdModal, setShowArtifactIdModal] = useState(false); const [viewArtifactId, setViewArtifactId] = useState(null); // Ensure file modal state const [showEnsureFile, setShowEnsureFile] = useState(false); const [ensureFileContent, setEnsureFileContent] = useState(null); const [ensureFileLoading, setEnsureFileLoading] = useState(false); const [ensureFileError, setEnsureFileError] = useState(null); const [ensureFileTagName, setEnsureFileTagName] = useState(null); // Derived permissions const canWrite = accessLevel === 'write' || accessLevel === 'admin'; // Detect system projects (convention: name starts with "_") const isSystemProject = projectName?.startsWith('_') ?? false; // Get params from URL // Valid sort fields for artifacts: created_at, size, original_name const page = parseInt(searchParams.get('page') || '1', 10); const search = searchParams.get('search') || ''; const sort = searchParams.get('sort') || 'created_at'; const order = (searchParams.get('order') || 'desc') as 'asc' | 'desc'; const updateParams = useCallback( (updates: Record) => { const newParams = new URLSearchParams(searchParams); Object.entries(updates).forEach(([key, value]) => { if (value === undefined || value === '' || (key === 'page' && value === '1')) { newParams.delete(key); } else { newParams.set(key, value); } }); setSearchParams(newParams); }, [searchParams, setSearchParams] ); const loadData = useCallback(async () => { if (!projectName || !packageName) return; try { setLoading(true); setAccessDenied(false); const [pkgData, artifactsResult, accessResult] = await Promise.all([ getPackage(projectName, packageName), listPackageArtifacts(projectName, packageName, { page, search, sort, order }), getMyProjectAccess(projectName), ]); setPkg(pkgData); setArtifactsData(artifactsResult); setAccessLevel(accessResult.access_level); setError(null); } catch (err) { if (err instanceof UnauthorizedError) { navigate('/login', { state: { from: location.pathname } }); return; } if (err instanceof ForbiddenError) { setAccessDenied(true); setError('You do not have access to this package'); setLoading(false); return; } setError(err instanceof Error ? err.message : 'Failed to load data'); } finally { setLoading(false); } }, [projectName, packageName, page, search, sort, order, navigate, location.pathname]); useEffect(() => { loadData(); }, [loadData]); // Auto-select artifact when artifacts are loaded (prefer first artifact) // Re-run when package changes to pick up new artifacts useEffect(() => { if (artifactsData?.items && artifactsData.items.length > 0) { // Fall back to first artifact setSelectedArtifact(artifactsData.items[0]); setDependencies([]); } }, [artifactsData, projectName, packageName]); // Fetch dependencies when selected tag changes const fetchDependencies = useCallback(async (artifactId: string) => { setDepsLoading(true); setDepsError(null); try { const result = await getArtifactDependencies(artifactId); setDependencies(result.dependencies); } catch (err) { setDepsError(err instanceof Error ? err.message : 'Failed to load dependencies'); setDependencies([]); } finally { setDepsLoading(false); } }, []); useEffect(() => { if (selectedArtifact) { fetchDependencies(selectedArtifact.id); } }, [selectedArtifact, fetchDependencies]); // Fetch reverse dependencies const fetchReverseDeps = useCallback(async (pageNum: number = 1) => { if (!projectName || !packageName) return; setReverseDepsLoading(true); setReverseDepsError(null); try { const result = await getReverseDependencies(projectName, packageName, { page: pageNum, limit: 10 }); setReverseDeps(result.dependents); setReverseDepsTotal(result.pagination.total); setReverseDepsHasMore(result.pagination.has_more); setReverseDepsPage(pageNum); } catch (err) { setReverseDepsError(err instanceof Error ? err.message : 'Failed to load reverse dependencies'); setReverseDeps([]); } finally { setReverseDepsLoading(false); } }, [projectName, packageName]); useEffect(() => { if (projectName && packageName && !loading) { fetchReverseDeps(1); } }, [projectName, packageName, loading, fetchReverseDeps]); // Fetch ensure file for a specific version or artifact const fetchEnsureFileForRef = useCallback(async (ref: string) => { if (!projectName || !packageName) return; setEnsureFileTagName(ref); setEnsureFileLoading(true); setEnsureFileError(null); try { const content = await getEnsureFile(projectName, packageName, ref); setEnsureFileContent(content); setShowEnsureFile(true); } catch (err) { setEnsureFileError(err instanceof Error ? err.message : 'Failed to load ensure file'); setShowEnsureFile(true); } finally { setEnsureFileLoading(false); } }, [projectName, packageName]); // Fetch ensure file for selected artifact const fetchEnsureFile = useCallback(async () => { if (!selectedArtifact) return; const version = getArtifactVersion(selectedArtifact); const ref = version || `artifact:${selectedArtifact.id}`; fetchEnsureFileForRef(ref); }, [selectedArtifact, fetchEnsureFileForRef]); // Keyboard navigation - go back with backspace useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Backspace' && !['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement).tagName)) { e.preventDefault(); navigate(`/project/${projectName}`); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [navigate, projectName]); const handleUploadComplete = useCallback((results: UploadResult[]) => { const count = results.length; const message = count === 1 ? `Uploaded successfully! Artifact ID: ${results[0].artifact_id}` : `${count} files uploaded successfully!`; setUploadSuccess(message); loadData(); // Auto-dismiss success message after 5 seconds setTimeout(() => setUploadSuccess(null), 5000); }, [loadData]); const handleUploadError = useCallback((errorMsg: string) => { setError(errorMsg); }, []); const handleSearchChange = (value: string) => { updateParams({ search: value, page: '1' }); }; const handleSortChange = (columnKey: string) => { const newOrder = columnKey === sort ? (order === 'asc' ? 'desc' : 'asc') : 'asc'; updateParams({ sort: columnKey, order: newOrder, page: '1' }); }; const handlePageChange = (newPage: number) => { updateParams({ page: String(newPage) }); }; const clearFilters = () => { setSearchParams({}); }; const hasActiveFilters = search !== ''; const artifacts = artifactsData?.items || []; const pagination = artifactsData?.pagination; const handleArtifactSelect = (artifact: PackageArtifact) => { setSelectedArtifact(artifact); }; const handleMenuOpen = (e: React.MouseEvent, artifactId: string) => { e.stopPropagation(); if (openMenuId === artifactId) { setOpenMenuId(null); setMenuPosition(null); } else { const rect = e.currentTarget.getBoundingClientRect(); setMenuPosition({ top: rect.bottom + 4, left: rect.right - 180 }); setOpenMenuId(artifactId); } }; // Helper to get version from artifact - prefer direct version field, fallback to metadata const getArtifactVersion = (a: PackageArtifact): string | null => { return a.version || (a.format_metadata?.version as string) || null; }; // Helper to get download ref - prefer version, fallback to artifact ID const getDownloadRef = (a: PackageArtifact): string => { const version = getArtifactVersion(a); return version || `artifact:${a.id}`; }; // System projects show Version first, regular projects show Tag first const columns = isSystemProject ? [ // System project columns: Version first, then Filename { key: 'version', header: 'Version', // version is from format_metadata, not a sortable DB field render: (a: PackageArtifact) => ( handleArtifactSelect(a)} style={{ cursor: 'pointer' }} > {getArtifactVersion(a) || a.id.slice(0, 12)} ), }, { key: 'original_name', header: 'Filename', sortable: true, className: 'cell-truncate', render: (a: PackageArtifact) => ( {a.original_name || a.id.slice(0, 12)} ), }, { key: 'size', header: 'Size', sortable: true, render: (a: PackageArtifact) => {formatBytes(a.size)}, }, { key: 'created_at', header: 'Cached', sortable: true, render: (a: PackageArtifact) => ( {new Date(a.created_at).toLocaleDateString()} ), }, { key: 'actions', header: '', render: (a: PackageArtifact) => (
), }, ] : [ // Regular project columns: Version, Filename, Size, Created // Valid sort fields: created_at, size, original_name { key: 'version', header: 'Version', // version is from format_metadata, not a sortable DB field render: (a: PackageArtifact) => ( handleArtifactSelect(a)} style={{ cursor: 'pointer' }} > {getArtifactVersion(a) || a.id.slice(0, 12)} ), }, { key: 'original_name', header: 'Filename', sortable: true, className: 'cell-truncate', render: (a: PackageArtifact) => ( {a.original_name || '—'} ), }, { key: 'size', header: 'Size', sortable: true, render: (a: PackageArtifact) => {formatBytes(a.size)}, }, { key: 'created_at', header: 'Created', sortable: true, render: (a: PackageArtifact) => ( {new Date(a.created_at).toLocaleDateString()} ), }, { key: 'actions', header: '', render: (a: PackageArtifact) => (
), }, ]; // Find the artifact for the open menu const openMenuArtifact = artifacts.find(a => a.id === openMenuId); // Close menu when clicking outside const handleClickOutside = () => { if (openMenuId) { setOpenMenuId(null); setMenuPosition(null); } }; // Render dropdown menu as a portal-like element const renderActionMenu = () => { if (!openMenuId || !menuPosition || !openMenuArtifact) return null; const a = openMenuArtifact; return (
e.stopPropagation()} >
); }; if (loading && !artifactsData) { return
Loading...
; } if (accessDenied) { return (

Access Denied

You do not have permission to view this package.

{!user && (

Sign in

)}
); } return (

{packageName}

{pkg && {pkg.format}} {user && canWrite && !isSystemProject && ( )}
{pkg?.description &&

{pkg.description}

}
in {projectName} {pkg && ( <> Created {new Date(pkg.created_at).toLocaleDateString()} {pkg.updated_at !== pkg.created_at && ( Updated {new Date(pkg.updated_at).toLocaleDateString()} )} )}
{pkg && pkg.artifact_count !== undefined && (
{pkg.artifact_count !== undefined && ( {pkg.artifact_count} {isSystemProject ? 'versions' : 'artifacts'} )} {pkg.total_size !== undefined && pkg.total_size > 0 && ( {formatBytes(pkg.total_size)} total )}
)}
{error &&
{error}
} {uploadSuccess &&
{uploadSuccess}
}

{isSystemProject ? 'Versions' : 'Artifacts'}

{hasActiveFilters && ( {search && handleSearchChange('')} />} )}
a.id} emptyMessage={ hasActiveFilters ? 'No artifacts match your filters. Try adjusting your search.' : 'No artifacts yet. Upload a file to get started!' } onSort={handleSortChange} sortKey={sort} sortOrder={order} />
{pagination && pagination.total_pages > 1 && ( )} {/* Used By (Reverse Dependencies) Section - only show if there are reverse deps or error */} {(reverseDeps.length > 0 || reverseDepsError) && (

Used By

{reverseDepsError && (
{reverseDepsError}
)}
{reverseDepsTotal} {reverseDepsTotal === 1 ? 'package depends' : 'packages depend'} on this:
    {reverseDeps.map((dep) => (
  • {dep.project}/{dep.package} {dep.version && ( v{dep.version} )} requires @ {dep.constraint_value}
  • ))}
{(reverseDepsHasMore || reverseDepsPage > 1) && (
Page {reverseDepsPage}
)}
)} {/* Dependency Graph Modal */} {showGraph && selectedArtifact && ( setShowGraph(false)} /> )} {/* Upload Modal */} {showUploadModal && (
setShowUploadModal(false)}>
e.stopPropagation()}>

Upload Artifact

{ handleUploadComplete(result); setShowUploadModal(false); }} onUploadError={handleUploadError} />
)} {/* Ensure File Modal */} {showEnsureFile && (
setShowEnsureFile(false)}>
e.stopPropagation()}>

orchard.ensure for {ensureFileTagName}

{ensureFileContent && ( )}
{ensureFileLoading ? (
Loading...
) : ensureFileError ? (
{ensureFileError}
) : ensureFileContent ? (
{ensureFileContent}
) : (
No dependencies defined for this artifact.
)}

Save this as orchard.ensure in your project root to declare dependencies.

)} {/* Dependencies Modal */} {showDepsModal && selectedArtifact && (
setShowDepsModal(false)}>
e.stopPropagation()}>

Dependencies for {selectedArtifact.original_name || selectedArtifact.id.slice(0, 12)}

{depsLoading ? (
Loading dependencies...
) : depsError ? (
{depsError}
) : dependencies.length === 0 ? (
No dependencies
) : (
{dependencies.length} {dependencies.length === 1 ? 'dependency' : 'dependencies'}:
    {dependencies.map((dep) => (
  • setShowDepsModal(false)} > {dep.project}/{dep.package} @ {dep.version}
  • ))}
)}
)} {/* Artifact ID Modal */} {showArtifactIdModal && viewArtifactId && (
setShowArtifactIdModal(false)}>
e.stopPropagation()}>

Artifact ID

SHA256 hash identifying this artifact:

{viewArtifactId}
)} {/* Action Menu Dropdown */} {renderActionMenu()}
); } export default PackagePage;