diff --git a/ui/src/components/downloads.ts b/ui/src/components/downloads.ts new file mode 100644 index 0000000..28965ef --- /dev/null +++ b/ui/src/components/downloads.ts @@ -0,0 +1,36 @@ +// Per-repo-type "downloadable" capability map. +// +// When a repo's package_type has an entry here, file entries in the object +// browser render as direct-download links pointing at the URL the entry +// builds. Types absent from the map render as plain text. Enabling a new +// type is a one-line addition below. +// +// Download routes are same-origin, unauthenticated GETs (the API serves local +// repos as real registries with no token on reads), so a bare +// works and carries no credentials. + +// buildUrl receives the repo name and the artifact's full path (may contain +// slashes) and returns the direct-download URL for that file. +type DownloadUrlBuilder = (repo: string, path: string) => string; + +export const downloadableTypes: Record = { + // rpm locals are real yum repos; files are served at + // /api/v2/remotes//files/. + rpm: (repo, path) => + `/api/v2/remotes/${encodeURIComponent(repo)}/files/${path + .split('/') + .map(encodeURIComponent) + .join('/')}`, +}; + +// downloadUrlFor returns the direct-download URL for a file when its repo type +// is downloadable, or null otherwise (render as plain text). +export function downloadUrlFor( + packageType: string | undefined, + repo: string, + path: string, +): string | null { + if (!packageType) return null; + const build = downloadableTypes[packageType]; + return build ? build(repo, path) : null; +} diff --git a/ui/src/pages/Objects.css b/ui/src/pages/Objects.css index fffe84d..7a688bb 100644 --- a/ui/src/pages/Objects.css +++ b/ui/src/pages/Objects.css @@ -37,6 +37,15 @@ word-break: break-all; } +.tree-file-link { + color: var(--accent, #4c9aff); + text-decoration: none; +} + +.tree-file-link:hover { + text-decoration: underline; +} + .tree-dir { cursor: pointer; } diff --git a/ui/src/pages/Objects.tsx b/ui/src/pages/Objects.tsx index 816e2b1..5e10f10 100644 --- a/ui/src/pages/Objects.tsx +++ b/ui/src/pages/Objects.tsx @@ -3,6 +3,7 @@ import { useParams, useLocation, Link } from 'react-router-dom'; import { api } from '../api/client'; import type { Artifact } from '../api/types'; import { formatBytes, timeAgo, truncateHash } from '../components/format'; +import { downloadUrlFor } from '../components/downloads'; import './Objects.css'; interface TreeNode { @@ -100,11 +101,16 @@ interface TreeRowProps { expanded: Set; onToggle: (path: string) => void; onEvict: (path: string) => void; + repo: string; + packageType?: string; } -function TreeRow({ node, depth, expanded, onToggle, onEvict }: TreeRowProps) { +function TreeRow({ node, depth, expanded, onToggle, onEvict, repo, packageType }: TreeRowProps) { const isDir = node.children.size > 0 && !node.artifact; const isExpanded = expanded.has(node.path); + const downloadUrl = node.artifact + ? downloadUrlFor(packageType, repo, node.artifact.path) + : null; const sortedChildren = useMemo(() => { if (!isDir) return []; @@ -124,9 +130,20 @@ function TreeRow({ node, depth, expanded, onToggle, onEvict }: TreeRowProps) { {isDir && ( {isExpanded ? '▾' : '▸'} )} - - {node.name}{isDir ? '/' : ''} - + {downloadUrl ? ( + e.stopPropagation()} + > + {node.name} + + ) : ( + + {node.name}{isDir ? '/' : ''} + + )} {formatBytes(node.totalSize)} @@ -163,6 +180,8 @@ function TreeRow({ node, depth, expanded, onToggle, onEvict }: TreeRowProps) { expanded={expanded} onToggle={onToggle} onEvict={onEvict} + repo={repo} + packageType={packageType} /> ))} @@ -175,6 +194,7 @@ export function Objects() { const isLocal = location.pathname.startsWith('/locals/'); const backLink = isLocal ? `/locals/${name}` : `/remotes/${name}`; const [artifacts, setArtifacts] = useState([]); + const [packageType, setPackageType] = useState(undefined); const [loading, setLoading] = useState(true); const [filter, setFilter] = useState(''); const [expanded, setExpanded] = useState>(new Set()); @@ -190,6 +210,15 @@ export function Objects() { useEffect(() => { load(); }, [load]); + // The repo's package_type is the modularity hook: it decides whether file + // names render as direct-download links (see downloadableTypes). + useEffect(() => { + if (!name) return; + api.getRemote(name) + .then(r => setPackageType(r.package_type)) + .catch(() => setPackageType(undefined)); + }, [name]); + const handleEvict = async (path: string) => { if (!name || !confirm(`Evict ${path}?`)) return; await (isLocal ? api.evictLocalObject(name, path) : api.evictObject(name, path)); @@ -287,6 +316,8 @@ export function Objects() { expanded={expanded} onToggle={toggleExpand} onEvict={handleEvict} + repo={name!} + packageType={packageType} /> )) )}