feat(ui): direct-download links for downloadable local repo files #106
@@ -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 <a href download>
|
||||||
|
// 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<string, DownloadUrlBuilder> = {
|
||||||
|
// rpm locals are real yum repos; files are served at
|
||||||
|
// /api/v2/remotes/<repo>/files/<path>.
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -37,6 +37,15 @@
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-file-link {
|
||||||
|
color: var(--accent, #4c9aff);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-file-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.tree-dir {
|
.tree-dir {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useParams, useLocation, Link } from 'react-router-dom';
|
|||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { Artifact } from '../api/types';
|
import type { Artifact } from '../api/types';
|
||||||
import { formatBytes, timeAgo, truncateHash } from '../components/format';
|
import { formatBytes, timeAgo, truncateHash } from '../components/format';
|
||||||
|
import { downloadUrlFor } from '../components/downloads';
|
||||||
import './Objects.css';
|
import './Objects.css';
|
||||||
|
|
||||||
interface TreeNode {
|
interface TreeNode {
|
||||||
@@ -100,11 +101,16 @@ interface TreeRowProps {
|
|||||||
expanded: Set<string>;
|
expanded: Set<string>;
|
||||||
onToggle: (path: string) => void;
|
onToggle: (path: string) => void;
|
||||||
onEvict: (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 isDir = node.children.size > 0 && !node.artifact;
|
||||||
const isExpanded = expanded.has(node.path);
|
const isExpanded = expanded.has(node.path);
|
||||||
|
const downloadUrl = node.artifact
|
||||||
|
? downloadUrlFor(packageType, repo, node.artifact.path)
|
||||||
|
: null;
|
||||||
|
|
||||||
const sortedChildren = useMemo(() => {
|
const sortedChildren = useMemo(() => {
|
||||||
if (!isDir) return [];
|
if (!isDir) return [];
|
||||||
@@ -124,9 +130,20 @@ function TreeRow({ node, depth, expanded, onToggle, onEvict }: TreeRowProps) {
|
|||||||
{isDir && (
|
{isDir && (
|
||||||
<span className="tree-toggle">{isExpanded ? '▾' : '▸'}</span>
|
<span className="tree-toggle">{isExpanded ? '▾' : '▸'}</span>
|
||||||
)}
|
)}
|
||||||
<span className={isDir ? 'tree-dir-name' : 'mono tree-file-name'}>
|
{downloadUrl ? (
|
||||||
{node.name}{isDir ? '/' : ''}
|
<a
|
||||||
</span>
|
className="mono tree-file-name tree-file-link"
|
||||||
|
href={downloadUrl}
|
||||||
|
download
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{node.name}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className={isDir ? 'tree-dir-name' : 'mono tree-file-name'}>
|
||||||
|
{node.name}{isDir ? '/' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="num-cell">{formatBytes(node.totalSize)}</td>
|
<td className="num-cell">{formatBytes(node.totalSize)}</td>
|
||||||
@@ -163,6 +180,8 @@ function TreeRow({ node, depth, expanded, onToggle, onEvict }: TreeRowProps) {
|
|||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
onEvict={onEvict}
|
onEvict={onEvict}
|
||||||
|
repo={repo}
|
||||||
|
packageType={packageType}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@@ -175,6 +194,7 @@ export function Objects() {
|
|||||||
const isLocal = location.pathname.startsWith('/locals/');
|
const isLocal = location.pathname.startsWith('/locals/');
|
||||||
const backLink = isLocal ? `/locals/${name}` : `/remotes/${name}`;
|
const backLink = isLocal ? `/locals/${name}` : `/remotes/${name}`;
|
||||||
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
|
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
|
||||||
|
const [packageType, setPackageType] = useState<string | undefined>(undefined);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
@@ -190,6 +210,15 @@ export function Objects() {
|
|||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
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) => {
|
const handleEvict = async (path: string) => {
|
||||||
if (!name || !confirm(`Evict ${path}?`)) return;
|
if (!name || !confirm(`Evict ${path}?`)) return;
|
||||||
await (isLocal ? api.evictLocalObject(name, path) : api.evictObject(name, path));
|
await (isLocal ? api.evictLocalObject(name, path) : api.evictObject(name, path));
|
||||||
@@ -287,6 +316,8 @@ export function Objects() {
|
|||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
onToggle={toggleExpand}
|
onToggle={toggleExpand}
|
||||||
onEvict={handleEvict}
|
onEvict={handleEvict}
|
||||||
|
repo={name!}
|
||||||
|
packageType={packageType}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user