feat(ui): add download progress controls

Replace the downloading action button with a dedicated progress component in
both card and detail views. The card now shows percent plus current speed, while
the detail modal shows bytes, speed, ETA, percent, and an inline cancel affordance
using the same backend progress payload.

Expose download cancellation as a peer command that cancels the tracked transfer
token and lets the running operation clear the authoritative active-operation
snapshot. Add a View Files action that resolves the game root safely and opens it
with the platform file viewer through Tauri's shell plugin.

Test Plan:
- just fmt
- just frontend-test
- just test
- just build
- just clippy
- git diff --cached --check

Refs: design reference e308009a08
This commit is contained in:
2026-05-20 23:20:53 +02:00
parent e308009a08
commit 47e2bbd454
16 changed files with 776 additions and 48 deletions
@@ -1,6 +1,7 @@
import { CSSProperties, JSX, MouseEvent } from 'react';
import { JSX, MouseEvent } from 'react';
import { Icon } from './Icon';
import { DownloadProgress } from './DownloadProgress';
import { Game, InstallStatus } from '../lib/types';
import { actionLabel, primaryActionFor, PrimaryAction } from '../lib/gameState';
@@ -9,6 +10,7 @@ interface Props {
size?: 'md' | 'lg';
full?: boolean;
onClick: () => void;
onCancelDownload?: (game: Game) => void;
}
const ICON_FOR_ACTION: Partial<Record<PrimaryAction, JSX.Element>> = {
@@ -18,29 +20,34 @@ const ICON_FOR_ACTION: Partial<Record<PrimaryAction, JSX.Element>> = {
download: <Icon.download />,
};
const downloadProgressPercent = (game: Game): number | undefined => {
const progress = game.download_progress;
if (!progress || progress.total_bytes <= 0) return undefined;
return Math.max(0, Math.min(100, (progress.downloaded_bytes / progress.total_bytes) * 100));
};
/** Color-coded primary action: Play / Install / Update / Download / busy. */
export const ActionButton = ({ game, size = 'md', full = false, onClick }: Props) => {
export const ActionButton = ({
game,
size = 'md',
full = false,
onClick,
onCancelDownload,
}: Props) => {
const action = primaryActionFor(game);
const isDownloading = game.install_status === InstallStatus.Downloading;
const progressPercent = downloadProgressPercent(game);
if (isDownloading) {
return (
<DownloadProgress
game={game}
size={size}
full={full}
onCancel={size === 'lg' ? onCancelDownload : undefined}
/>
);
}
const cls = [
'act-btn',
`act-${action}`,
isDownloading ? 'act-downloading' : '',
size === 'lg' ? 'act-lg' : '',
full ? 'act-full' : '',
].filter(Boolean).join(' ');
const disabled = action === 'busy' || action === 'disabled';
const style = progressPercent === undefined
? undefined
: ({ '--download-progress': `${progressPercent}%` } as CSSProperties);
const handle = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
@@ -49,8 +56,7 @@ export const ActionButton = ({ game, size = 'md', full = false, onClick }: Props
};
return (
<button className={cls} onClick={handle} disabled={disabled} style={style}>
{isDownloading && <span className="act-progress-fill" aria-hidden />}
<button className={cls} onClick={handle} disabled={disabled}>
{ICON_FOR_ACTION[action]}
<span className="act-label">{actionLabel(game)}</span>
</button>
@@ -0,0 +1,122 @@
import { CSSProperties, MouseEvent } from 'react';
import { Game } from '../lib/types';
import {
downloadProgressPercent,
formatDownloadBytes,
formatDownloadEta,
formatDownloadSpeed,
formatDownloadSpeedShort,
} from '../lib/gameState';
import { Icon } from './Icon';
interface Props {
game: Game;
size?: 'md' | 'lg';
full?: boolean;
onCancel?: (game: Game) => void;
}
const progressStats = (game: Game) => {
const progress = game.download_progress;
const downloaded = progress?.downloaded_bytes ?? 0;
const total = progress?.total_bytes ?? game.size;
const speed = progress?.bytes_per_second ?? 0;
const remaining = Math.max(0, total - downloaded);
const etaSeconds = speed > 0 ? remaining / speed : Number.POSITIVE_INFINITY;
return {
pct: Math.min(99, Math.round(downloadProgressPercent(game))),
downloaded,
total,
speed,
eta: etaSeconds,
};
};
export const DownloadProgress = ({ game, size = 'md', full = false, onCancel }: Props) => {
const stats = progressStats(game);
const progressStyle = {
'--download-progress': `${stats.pct}%`,
} as CSSProperties;
const className = [
'dl',
size === 'lg' ? 'dl-lg' : 'dl-md',
full ? 'dl-full' : '',
].filter(Boolean).join(' ');
const handleCancel = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onCancel?.(game);
};
if (size === 'lg') {
return (
<div
className={className}
role="progressbar"
aria-label={`Downloading ${game.name}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={stats.pct}
style={progressStyle}
>
<div className="dl-fill" aria-hidden />
<div className="dl-lg-grid">
<div className="dl-lg-primary">
<span className="dl-pulse" aria-hidden />
<span className="dl-label">Downloading</span>
</div>
<div className="dl-lg-secondary">
<span className="dl-bytes">
<strong>{formatDownloadBytes(stats.downloaded)}</strong>
<span className="dl-of"> / {formatDownloadBytes(stats.total)}</span>
</span>
<span className="dl-sep">·</span>
<span className="dl-speed">{formatDownloadSpeed(stats.speed)}</span>
<span className="dl-sep dl-sep-eta">·</span>
<span className="dl-eta">{formatDownloadEta(stats.eta)} left</span>
</div>
<div className="dl-lg-pct">
{stats.pct}
<span className="dl-pct-sym">%</span>
</div>
{onCancel && (
<button
type="button"
className="dl-cancel"
onClick={handleCancel}
aria-label={`Cancel download of ${game.name}`}
>
<Icon.close />
</button>
)}
</div>
</div>
);
}
return (
<div
className={className}
role="progressbar"
aria-label={`Downloading ${game.name}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={stats.pct}
title={`${stats.pct}% · ${formatDownloadSpeed(stats.speed)} · ${formatDownloadEta(stats.eta)} left`}
style={progressStyle}
>
<div className="dl-fill" aria-hidden />
<div className="dl-md-row">
<span className="dl-pct">
<span className="dl-pulse" aria-hidden />
{stats.pct}
<span className="dl-pct-sym">%</span>
</span>
<span className="dl-speed">{formatDownloadSpeedShort(stats.speed)}</span>
</div>
</div>
);
};
@@ -4,6 +4,7 @@ import { deriveState } from '../lib/gameState';
const LABELS: Record<string, string> = {
installed: 'Installed',
local: 'Local',
downloading: 'Downloading',
busy: 'Working',
none: '',
};
@@ -15,6 +15,7 @@ interface Props {
thumbnailUrl: string | null;
onOpen: (game: Game) => void;
onPrimary: (game: Game) => void;
onCancelDownload: (game: Game) => void;
}
const metaSeparator = (...parts: Array<string | null | undefined>): JSX.Element[] => {
@@ -27,7 +28,14 @@ const metaSeparator = (...parts: Array<string | null | undefined>): JSX.Element[
return out;
};
export const GameCard = ({ game, aspect, thumbnailUrl, onOpen, onPrimary }: Props) => {
export const GameCard = ({
game,
aspect,
thumbnailUrl,
onOpen,
onPrimary,
onCancelDownload,
}: Props) => {
const onKey = (e: KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
@@ -61,7 +69,12 @@ export const GameCard = ({ game, aspect, thumbnailUrl, onOpen, onPrimary }: Prop
<div className={`card-status${game.status_level === 'error' ? ' is-error' : ''}`}>
{game.status_message ?? ''}
</div>
<ActionButton game={game} full onClick={() => onPrimary(game)} />
<ActionButton
game={game}
full
onClick={() => onPrimary(game)}
onCancelDownload={onCancelDownload}
/>
</div>
</button>
);
@@ -9,9 +9,17 @@ interface Props {
getThumbnail: (id: string) => string | null;
onOpen: (game: Game) => void;
onPrimary: (game: Game) => void;
onCancelDownload: (game: Game) => void;
}
export const GameGrid = ({ games, aspect, getThumbnail, onOpen, onPrimary }: Props) => (
export const GameGrid = ({
games,
aspect,
getThumbnail,
onOpen,
onPrimary,
onCancelDownload,
}: Props) => (
<div className="grid">
{games.map(g => (
<GameCard
@@ -21,6 +29,7 @@ export const GameGrid = ({ games, aspect, getThumbnail, onOpen, onPrimary }: Pro
thumbnailUrl={getThumbnail(g.id)}
onOpen={onOpen}
onPrimary={onPrimary}
onCancelDownload={onCancelDownload}
/>
))}
</div>
@@ -4,7 +4,7 @@ import { GameCover } from '../grid/GameCover';
import { StateChip } from '../StateChip';
import { ActionButton } from '../ActionButton';
import { Game } from '../../lib/types';
import { Game, InstallStatus } from '../../lib/types';
import { deriveState, isInProgress } from '../../lib/gameState';
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format';
@@ -15,6 +15,8 @@ interface Props {
onPrimary: (game: Game) => void;
onUninstall: (game: Game) => void;
onRemoveDownload: (game: Game) => void;
onCancelDownload: (game: Game) => void;
onViewFiles: (game: Game) => void;
}
const tagsFromGame = (game: Game): string[] => {
@@ -29,6 +31,7 @@ const statusLabelFor = (game: Game): string => {
switch (deriveState(game)) {
case 'installed': return 'Installed';
case 'local': return 'Downloaded';
case 'downloading': return 'Downloading';
case 'busy': return 'Working…';
case 'none': return 'Not downloaded';
}
@@ -41,11 +44,16 @@ export const GameDetailModal = ({
onPrimary,
onUninstall,
onRemoveDownload,
onCancelDownload,
onViewFiles,
}: Props) => {
const tags = tagsFromGame(game);
const canRemoveDownload = game.downloaded
&& !game.installed
&& !isInProgress(game.install_status);
const canViewFiles = game.downloaded
|| game.installed
|| game.install_status === InstallStatus.Downloading;
return (
<Modal onClose={onClose}>
<button className="modal-close" type="button" onClick={onClose} aria-label="Close">
@@ -102,7 +110,12 @@ export const GameDetailModal = ({
)}
<div className="modal-actions">
<ActionButton game={game} size="lg" onClick={() => onPrimary(game)} />
<ActionButton
game={game}
size="lg"
onClick={() => onPrimary(game)}
onCancelDownload={onCancelDownload}
/>
{game.installed && (
<button
type="button"
@@ -123,6 +136,19 @@ export const GameDetailModal = ({
<span>Remove files</span>
</button>
)}
{canViewFiles && (
<>
<div className="modal-actions-spacer" />
<button
type="button"
className="ghost-btn"
onClick={() => onViewFiles(game)}
>
<Icon.folder />
<span>View Files</span>
</button>
</>
)}
</div>
</div>
</Modal>