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:
@@ -14,7 +14,8 @@ It is designed to run headless – other crates (most notably
|
||||
roots are announced or served.
|
||||
- `PeerCommand` represents the small control surface exposed to the UI layer:
|
||||
`ListGames`, `GetGame`, `FetchLatestFromPeers`, `DownloadGameFiles`,
|
||||
`InstallGame`, `UninstallGame`, `SetGameDir`, and `GetPeerCount`.
|
||||
`InstallGame`, `UninstallGame`, `RemoveDownloadedGame`, `CancelDownload`,
|
||||
`SetGameDir`, and `GetPeerCount`.
|
||||
- `PeerEvent` enumerates everything the peer runtime reports back to the UI:
|
||||
library snapshots, download/install/uninstall lifecycle updates, runtime
|
||||
failures, and peer membership changes.
|
||||
@@ -82,6 +83,11 @@ When the UI asks to download a game:
|
||||
6. After a successful sentinel commit, `PeerEvent::DownloadGameFilesFinished`
|
||||
is emitted and the peer auto-runs the install transaction.
|
||||
|
||||
`PeerCommand::CancelDownload` cancels the tracked download token for an active
|
||||
transfer. The transfer task remains responsible for clearing `active_operations`,
|
||||
so the UI continues to treat active-operation snapshots as the single source of
|
||||
truth for whether a download is still running.
|
||||
|
||||
### Install Transactions
|
||||
|
||||
Install, update, uninstall, downloaded-file removal, and startup recovery live
|
||||
|
||||
@@ -408,6 +408,21 @@ pub async fn handle_remove_downloaded_game_command(
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn handle_cancel_download_command(
|
||||
ctx: &Ctx,
|
||||
_tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
) {
|
||||
let cancel_token = ctx.active_downloads.read().await.get(&id).cloned();
|
||||
let Some(cancel_token) = cancel_token else {
|
||||
log::warn!("Ignoring cancel request for inactive download {id}");
|
||||
return;
|
||||
};
|
||||
|
||||
log::info!("Cancelling download for game {id}");
|
||||
cancel_token.cancel();
|
||||
}
|
||||
|
||||
fn spawn_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
|
||||
let ctx = ctx.clone();
|
||||
let tx_notify_ui = tx_notify_ui.clone();
|
||||
@@ -1504,6 +1519,32 @@ mod tests {
|
||||
assert!(ctx.active_downloads.read().await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_download_command_only_cancels_active_token() {
|
||||
let temp = TempDir::new("lanspread-handler-cancel-download");
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let cancel = CancellationToken::new();
|
||||
ctx.active_operations
|
||||
.write()
|
||||
.await
|
||||
.insert("game".to_string(), OperationKind::Downloading);
|
||||
ctx.active_downloads
|
||||
.write()
|
||||
.await
|
||||
.insert("game".to_string(), cancel.clone());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
handle_cancel_download_command(&ctx, &tx, "game".to_string()).await;
|
||||
|
||||
assert!(cancel.is_cancelled());
|
||||
assert_eq!(
|
||||
ctx.active_operations.read().await.get("game"),
|
||||
Some(&OperationKind::Downloading),
|
||||
"the running transfer owns operation cleanup after cancellation"
|
||||
);
|
||||
assert_no_event(&mut rx).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_refreshes_settled_state_before_operation_clear() {
|
||||
let temp = TempDir::new("lanspread-handler-update");
|
||||
|
||||
@@ -61,6 +61,7 @@ use crate::{
|
||||
context::Ctx,
|
||||
handlers::{
|
||||
GameDetailSource,
|
||||
handle_cancel_download_command,
|
||||
handle_connect_peer_command,
|
||||
handle_download_game_files_command,
|
||||
handle_get_game_command,
|
||||
@@ -235,6 +236,8 @@ pub enum PeerCommand {
|
||||
UninstallGame { id: String },
|
||||
/// Remove downloaded archive files for an uninstalled game.
|
||||
RemoveDownloadedGame { id: String },
|
||||
/// Cancel an active peer download without emitting a user-facing failure.
|
||||
CancelDownload { id: String },
|
||||
/// Set the local game directory.
|
||||
SetGameDir(PathBuf),
|
||||
/// Request the current peer count.
|
||||
@@ -419,6 +422,9 @@ async fn handle_peer_commands(
|
||||
PeerCommand::RemoveDownloadedGame { id } => {
|
||||
handle_remove_downloaded_game_command(ctx, tx_notify_ui, id).await;
|
||||
}
|
||||
PeerCommand::CancelDownload { id } => {
|
||||
handle_cancel_download_command(ctx, tx_notify_ui, id).await;
|
||||
}
|
||||
PeerCommand::SetGameDir(game_dir) => {
|
||||
handle_set_game_dir_command(ctx, tx_notify_ui, game_dir).await;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::fs::File;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
net::SocketAddr,
|
||||
path::{Path, PathBuf},
|
||||
path::{Component, Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
@@ -279,6 +279,108 @@ async fn remove_downloaded_game(
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cancel_download(
|
||||
id: String,
|
||||
state: tauri::State<'_, LanSpreadState>,
|
||||
) -> tauri::Result<bool> {
|
||||
let is_active_download = {
|
||||
let active_operations = state.inner().active_operations.read().await;
|
||||
matches!(
|
||||
active_operations.get(&id),
|
||||
Some(UiOperationKind::Downloading)
|
||||
)
|
||||
};
|
||||
if !is_active_download {
|
||||
log::warn!("Ignoring cancel request for inactive download: {id}");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
|
||||
let peer_ctrl = peer_ctrl_arc.read().await.clone();
|
||||
if let Some(peer_ctrl) = peer_ctrl {
|
||||
if let Err(e) = peer_ctrl.send(PeerCommand::CancelDownload { id }) {
|
||||
log::error!("Failed to send message to peer: {e:?}");
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(true)
|
||||
} else {
|
||||
log::warn!("Peer system not initialized yet");
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_game_files(
|
||||
id: String,
|
||||
app_handle: AppHandle,
|
||||
state: tauri::State<'_, LanSpreadState>,
|
||||
) -> tauri::Result<bool> {
|
||||
let Some(target) = resolve_game_root_for_open(&id, &state).await else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
#[allow(deprecated)]
|
||||
if let Err(e) = app_handle.shell().open(target.display().to_string(), None) {
|
||||
log::error!("Failed to open file viewer for {}: {e}", target.display());
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn resolve_game_root_for_open(
|
||||
id: &str,
|
||||
state: &tauri::State<'_, LanSpreadState>,
|
||||
) -> Option<PathBuf> {
|
||||
if !is_single_component_game_id(id) {
|
||||
log::warn!("Ignoring file viewer request for invalid game id: {id}");
|
||||
return None;
|
||||
}
|
||||
if state
|
||||
.inner()
|
||||
.games
|
||||
.read()
|
||||
.await
|
||||
.get_game_by_id(id)
|
||||
.is_none()
|
||||
{
|
||||
log::warn!("Ignoring file viewer request for unknown game: {id}");
|
||||
return None;
|
||||
}
|
||||
|
||||
let games_folder = PathBuf::from(state.inner().games_folder.read().await.clone());
|
||||
let Ok(root) = games_folder.canonicalize() else {
|
||||
log::warn!(
|
||||
"Cannot open files because game directory is unavailable: {}",
|
||||
games_folder.display()
|
||||
);
|
||||
return None;
|
||||
};
|
||||
let target = root.join(id);
|
||||
let Ok(target) = target.canonicalize() else {
|
||||
log::warn!(
|
||||
"Cannot open files because game root is unavailable: {}",
|
||||
target.display()
|
||||
);
|
||||
return None;
|
||||
};
|
||||
if !target.is_dir() || !target.starts_with(&root) {
|
||||
log::warn!(
|
||||
"Refusing to open file viewer outside game directory: {}",
|
||||
target.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(target)
|
||||
}
|
||||
|
||||
fn is_single_component_game_id(id: &str) -> bool {
|
||||
let mut components = Path::new(id).components();
|
||||
matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_peer_count(state: tauri::State<'_, LanSpreadState>) -> tauri::Result<usize> {
|
||||
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
|
||||
@@ -1328,6 +1430,16 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_file_viewer_ids_must_be_single_path_components() {
|
||||
assert!(is_single_component_game_id("game"));
|
||||
assert!(is_single_component_game_id("game.v1"));
|
||||
assert!(!is_single_component_game_id(""));
|
||||
assert!(!is_single_component_game_id("../game"));
|
||||
assert!(!is_single_component_game_id("nested/game"));
|
||||
assert!(!is_single_component_game_id("/game"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_local_snapshot_replaces_local_state_without_overwriting_catalog_metadata() {
|
||||
let mut alpha = game_fixture("alpha", "Catalog Alpha");
|
||||
@@ -1398,6 +1510,8 @@ pub fn run() {
|
||||
update_game,
|
||||
uninstall_game,
|
||||
remove_downloaded_game,
|
||||
cancel_download,
|
||||
open_game_files,
|
||||
get_peer_count,
|
||||
get_game_thumbnail,
|
||||
get_unpack_logs
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -9,13 +9,15 @@ export interface GameActions {
|
||||
update: (id: string) => Promise<void>;
|
||||
uninstall: (id: string) => Promise<void>;
|
||||
removeDownload: (id: string) => Promise<void>;
|
||||
cancelDownload: (id: string) => Promise<void>;
|
||||
viewFiles: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrappers over the backend `run_game` / `install_game` / `update_game`
|
||||
* / `uninstall_game` / `remove_downloaded_game` commands. Peer-backed downloads
|
||||
* are marked as "checking peers" until the backend emits an authoritative
|
||||
* operation snapshot.
|
||||
* operation snapshot; cancellation waits for the backend to clear that snapshot.
|
||||
*/
|
||||
export const useGameActions = (games: UseGamesResult): GameActions => {
|
||||
const play = useCallback(async (id: string) => {
|
||||
@@ -65,5 +67,21 @@ export const useGameActions = (games: UseGamesResult): GameActions => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { play, install, update, uninstall, removeDownload };
|
||||
const cancelDownload = useCallback(async (id: string) => {
|
||||
try {
|
||||
await invoke('cancel_download', { id });
|
||||
} catch (err) {
|
||||
console.error('cancel_download failed:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const viewFiles = useCallback(async (id: string) => {
|
||||
try {
|
||||
await invoke('open_game_files', { id });
|
||||
} catch (err) {
|
||||
console.error('open_game_files failed:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { play, install, update, uninstall, removeDownload, cancelDownload, viewFiles };
|
||||
};
|
||||
|
||||
@@ -75,6 +75,7 @@ export const mergeGameUpdate = (
|
||||
|
||||
/** Visual card state — used for state chip color and action button styling. */
|
||||
export const deriveState = (game: Game): DerivedState => {
|
||||
if (game.install_status === InstallStatus.Downloading) return 'downloading';
|
||||
if (isInProgress(game.install_status)) return 'busy';
|
||||
if (game.installed) return 'installed';
|
||||
if (game.downloaded) return 'local';
|
||||
@@ -126,6 +127,48 @@ export const formatBytesPerSecond = (bytesPerSecond: number): string => {
|
||||
return `${value.toFixed(precision)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
const GB = 1024 * 1024 * 1024;
|
||||
const DECIMAL_MB = 1_000_000;
|
||||
|
||||
const stripTrailingDecimalZeros = (value: string): string =>
|
||||
value.replace(/(\.\d*?[1-9])0+$/, '$1').replace(/\.0+$/, '');
|
||||
|
||||
export const downloadProgressPercent = (game: Game): number => {
|
||||
const progress = game.download_progress;
|
||||
if (!progress || progress.total_bytes <= 0) return 0;
|
||||
|
||||
return Math.max(0, Math.min(100, (progress.downloaded_bytes / progress.total_bytes) * 100));
|
||||
};
|
||||
|
||||
export const formatDownloadSpeed = (bytesPerSecond: number): string => {
|
||||
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
|
||||
return mb >= 100 ? `${Math.round(mb)} MB/s` : `${mb.toFixed(1)} MB/s`;
|
||||
};
|
||||
|
||||
export const formatDownloadSpeedShort = (bytesPerSecond: number): string => {
|
||||
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
|
||||
return `${Math.round(mb)} MB/s`;
|
||||
};
|
||||
|
||||
export const formatDownloadBytes = (bytes: number): string => {
|
||||
const safeBytes = Math.max(0, bytes);
|
||||
if (safeBytes < GB) return `${Math.round(safeBytes / MB)} MB`;
|
||||
|
||||
const gb = safeBytes / GB;
|
||||
return `${stripTrailingDecimalZeros(gb >= 10 ? gb.toFixed(1) : gb.toFixed(2))} GB`;
|
||||
};
|
||||
|
||||
export const formatDownloadEta = (seconds: number): string => {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return '—';
|
||||
if (seconds < 60) return `${Math.round(seconds)} s`;
|
||||
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
|
||||
return `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
|
||||
};
|
||||
|
||||
export const inProgressLabel = (game: Game): string | undefined => {
|
||||
switch (game.install_status) {
|
||||
case InstallStatus.CheckingPeers:
|
||||
@@ -161,19 +204,22 @@ export interface FilterCounts {
|
||||
installed: number;
|
||||
}
|
||||
|
||||
const isDownloading = (game: Game): boolean =>
|
||||
game.install_status === InstallStatus.Downloading;
|
||||
|
||||
const isNetworkGame = (game: Game): boolean =>
|
||||
game.installed || game.downloaded || game.peer_count > 0;
|
||||
game.installed || game.downloaded || isDownloading(game) || game.peer_count > 0;
|
||||
|
||||
export const countByFilter = (games: Game[]): FilterCounts => ({
|
||||
all: games.filter(isNetworkGame).length,
|
||||
local: games.filter(g => g.installed || g.downloaded).length,
|
||||
local: games.filter(g => g.installed || g.downloaded || isDownloading(g)).length,
|
||||
installed: games.filter(g => g.installed).length,
|
||||
});
|
||||
|
||||
const matchesFilter = (game: Game, filter: GameFilter): boolean => {
|
||||
switch (filter) {
|
||||
case 'local':
|
||||
return game.installed || game.downloaded;
|
||||
return game.installed || game.downloaded || isDownloading(game);
|
||||
case 'installed':
|
||||
return game.installed;
|
||||
case 'all':
|
||||
@@ -182,10 +228,11 @@ const matchesFilter = (game: Game, filter: GameFilter): boolean => {
|
||||
};
|
||||
|
||||
const STATE_SORT_ORDER: Record<DerivedState, number> = {
|
||||
busy: 0,
|
||||
installed: 1,
|
||||
local: 2,
|
||||
none: 3,
|
||||
installed: 0,
|
||||
local: 1,
|
||||
downloading: 2,
|
||||
busy: 3,
|
||||
none: 4,
|
||||
};
|
||||
|
||||
const compareByState = (a: Game, b: Game): number => {
|
||||
|
||||
@@ -75,5 +75,5 @@ export type GameFilter = 'all' | 'local' | 'installed';
|
||||
/** Library sort order. */
|
||||
export type GameSort = 'az' | 'sizeDesc' | 'sizeAsc' | 'status';
|
||||
|
||||
/** Visual state of a card. Derived from install/download flags. */
|
||||
export type DerivedState = 'installed' | 'local' | 'none' | 'busy';
|
||||
/** Visual state of a card. Derived from backend operation status and local flags. */
|
||||
export type DerivedState = 'installed' | 'local' | 'downloading' | 'none' | 'busy';
|
||||
|
||||
@@ -629,6 +629,11 @@
|
||||
background: var(--warn);
|
||||
box-shadow: 0 0 8px var(--warn);
|
||||
}
|
||||
.state-chip[data-state="downloading"] .state-dot {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent);
|
||||
animation: state-busy 1.2s ease-in-out infinite;
|
||||
}
|
||||
.state-chip[data-state="busy"] .state-dot {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent);
|
||||
@@ -807,20 +812,6 @@
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--bd-1);
|
||||
}
|
||||
.act-downloading {
|
||||
min-width: 148px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.act-lg.act-downloading {
|
||||
min-width: 174px;
|
||||
}
|
||||
.act-progress-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: var(--download-progress, 0%);
|
||||
background: color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
transition: width 0.45s linear;
|
||||
}
|
||||
.act-busy::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
@@ -846,6 +837,264 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Download progress */
|
||||
.dl {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 7px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--t-1);
|
||||
font: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
container-type: inline-size;
|
||||
isolation: isolate;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04),
|
||||
0 0 0 1px color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
}
|
||||
.dl-full {
|
||||
width: 100%;
|
||||
}
|
||||
.dl-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
z-index: 0;
|
||||
width: var(--download-progress, 0%);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--accent) 38%, transparent) 0%,
|
||||
color-mix(in srgb, var(--accent) 26%, transparent) 100%
|
||||
);
|
||||
border-right: 1px solid color-mix(in srgb, var(--accent) 75%, transparent);
|
||||
box-shadow: 2px 0 8px color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
transition: width 0.48s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.dl-fill::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
115deg,
|
||||
transparent 0 14px,
|
||||
rgba(255, 255, 255, 0.05) 14px 22px
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: dl-stripe 1.4s linear infinite;
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0.85;
|
||||
}
|
||||
@keyframes dl-stripe {
|
||||
from {
|
||||
background-position: 0 0;
|
||||
}
|
||||
to {
|
||||
background-position: -36px 0;
|
||||
}
|
||||
}
|
||||
.dl-pulse {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 70%, transparent);
|
||||
animation: dl-pulse 1.4s ease-out infinite;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
@keyframes dl-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 70%, transparent);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 0%, transparent);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent);
|
||||
}
|
||||
}
|
||||
.dl-md {
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.dl-md-row {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.dl-md .dl-pct {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--t-1);
|
||||
}
|
||||
.dl-md .dl-pulse {
|
||||
margin-right: 6px;
|
||||
}
|
||||
.dl-pct-sym {
|
||||
opacity: 0.55;
|
||||
font-weight: 600;
|
||||
margin-left: 1px;
|
||||
}
|
||||
.dl-md .dl-speed {
|
||||
color: var(--t-2);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@container (max-width: 132px) {
|
||||
.dl-md .dl-speed {
|
||||
display: none;
|
||||
}
|
||||
.dl-md-row {
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
@container (max-width: 96px) {
|
||||
.dl-md .dl-pulse {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.density-compact .dl-md {
|
||||
height: 30px;
|
||||
padding: 0 9px;
|
||||
}
|
||||
.density-compact .dl-md-row {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.density-compact .dl-md .dl-speed {
|
||||
font-size: 10.5px;
|
||||
}
|
||||
.density-large .dl-md {
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.density-large .dl-md-row {
|
||||
font-size: 13px;
|
||||
}
|
||||
.density-large .dl-md .dl-speed {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.dl-lg {
|
||||
height: 56px;
|
||||
padding: 0;
|
||||
border-radius: 9px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 260px;
|
||||
}
|
||||
.dl-lg-grid {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
grid-template-rows: auto auto;
|
||||
grid-template-areas:
|
||||
"primary pct cancel"
|
||||
"secondary pct cancel";
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0 14px 0 16px;
|
||||
column-gap: 14px;
|
||||
row-gap: 2px;
|
||||
}
|
||||
.dl-lg-primary {
|
||||
grid-area: primary;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--accent) 80%, white);
|
||||
}
|
||||
.dl-lg-primary .dl-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dl-lg-secondary {
|
||||
grid-area: secondary;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--t-2);
|
||||
}
|
||||
.dl-lg-secondary .dl-bytes {
|
||||
color: var(--t-1);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dl-lg-secondary .dl-of {
|
||||
color: var(--t-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
.dl-lg-secondary .dl-speed {
|
||||
color: var(--t-1);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dl-lg-secondary .dl-eta {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dl-sep {
|
||||
opacity: 0.45;
|
||||
}
|
||||
@container (max-width: 380px) {
|
||||
.dl-lg-secondary .dl-eta,
|
||||
.dl-lg-secondary .dl-sep-eta {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.dl-lg-pct {
|
||||
grid-area: pct;
|
||||
color: var(--t-1);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.dl-lg-pct .dl-pct-sym {
|
||||
font-size: 12px;
|
||||
}
|
||||
.dl-cancel {
|
||||
grid-area: cancel;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--bd-2);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--t-2);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
.dl-cancel:hover {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* Ghost / secondary buttons */
|
||||
.ghost-btn {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -152,6 +152,7 @@ export const MainWindow = () => {
|
||||
getThumbnail={thumbnails.get}
|
||||
onOpen={(g) => setOpenGameId(g.id)}
|
||||
onPrimary={handlePrimary}
|
||||
onCancelDownload={(g) => actions.cancelDownload(g.id)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
@@ -170,6 +171,8 @@ export const MainWindow = () => {
|
||||
onPrimary={handlePrimary}
|
||||
onUninstall={handleUninstall}
|
||||
onRemoveDownload={handleRemoveDownload}
|
||||
onCancelDownload={(g) => actions.cancelDownload(g.id)}
|
||||
onViewFiles={(g) => actions.viewFiles(g.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import {
|
||||
actionLabel,
|
||||
activeStatusById,
|
||||
applyFilterAndSort,
|
||||
countByFilter,
|
||||
deriveState,
|
||||
downloadProgressPercent,
|
||||
formatDownloadBytes,
|
||||
formatBytesPerSecond,
|
||||
formatDownloadEta,
|
||||
formatDownloadSpeed,
|
||||
formatDownloadSpeedShort,
|
||||
mergeGameUpdate,
|
||||
} from '../src/lib/gameState.ts';
|
||||
import {
|
||||
@@ -134,3 +142,62 @@ Deno.test('downloading action label includes current speed', () => {
|
||||
'download label should include speed',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('downloading state is distinct and stays on the local filter', () => {
|
||||
const downloading = game({
|
||||
id: 'downloading',
|
||||
name: 'Downloading',
|
||||
install_status: InstallStatus.Downloading,
|
||||
});
|
||||
const local = game({
|
||||
id: 'local',
|
||||
name: 'Local',
|
||||
downloaded: true,
|
||||
});
|
||||
const remote = game({
|
||||
id: 'remote',
|
||||
name: 'Remote',
|
||||
peer_count: 1,
|
||||
});
|
||||
|
||||
assertEquals(
|
||||
deriveState(downloading),
|
||||
'downloading',
|
||||
'download operation should render the dedicated downloading state',
|
||||
);
|
||||
assertEquals(
|
||||
countByFilter([downloading, local, remote]).local,
|
||||
2,
|
||||
'local filter count should include in-flight downloads',
|
||||
);
|
||||
assertEquals(
|
||||
applyFilterAndSort([downloading, local, remote], 'local', 'status', '').length,
|
||||
2,
|
||||
'local filter should include in-flight downloads',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('download progress formatting matches the progress-bar layouts', () => {
|
||||
const downloading = game({
|
||||
install_status: InstallStatus.Downloading,
|
||||
download_progress: {
|
||||
downloaded_bytes: 12 * 1024 * 1024 * 1024,
|
||||
total_bytes: 35 * 1024 * 1024 * 1024,
|
||||
bytes_per_second: 49_400_000,
|
||||
},
|
||||
});
|
||||
|
||||
assertEquals(
|
||||
Math.round(downloadProgressPercent(downloading)),
|
||||
34,
|
||||
'progress percent should come from backend byte counters',
|
||||
);
|
||||
assertEquals(formatDownloadSpeed(49_400_000), '49.4 MB/s', 'large bar speed format');
|
||||
assertEquals(formatDownloadSpeedShort(49_400_000), '49 MB/s', 'card speed format');
|
||||
assertEquals(
|
||||
formatDownloadBytes(12 * 1024 * 1024 * 1024),
|
||||
'12 GB',
|
||||
'downloaded byte format should avoid noisy trailing decimals',
|
||||
);
|
||||
assertEquals(formatDownloadEta(485), '8 min', 'eta format should stay compact');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user