UI rework: per-row actions, drop sidebar

Replace the sidebar + hero + single big-button layout with a flat list of
per-version cards. Each card carries its own action button:
- Installed: ▶ Lancer (green, primary)
- Available on server only: ⬇ Installer (blue, accent) + lighter blue card
- Busy: inline mini progress bar with %, card tinted green

Each card also exposes a "..." menu (left-click opens it) with:
- Voir les release notes (works for installed and remote-only versions)
- Ouvrir le dossier (installed only)
- Supprimer cette version (installed only, with confirmation dialog)

VersionRowViewModel owns its state (InstalledIdle / AvailableIdle /
Downloading / Installing / Uninstalling) and its commands; MainViewModel
wires per-row handlers after instantiation so the row VM stays UI-only
and the services live one layer up.

ReleaseNotesViewerDialog: separate dialog reused by the menu — same
Markdown rendering as UpdateAvailableDialog but no download CTA.

Theme: AccentButton + IconButton + dark ContextMenu/MenuItem styles.

Behavior changes:
- The single global SelectedVersion + AvailableUpdate are gone; each row
  is independently actionable.
- The list now merges installed + remote: a version present on the
  server but not locally appears as a "remote-only" row, and vice-versa.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 09:40:50 +02:00
parent 3a00b0e677
commit 4e9f757ce1
7 changed files with 730 additions and 342 deletions

View File

@@ -28,23 +28,11 @@ public sealed partial class MainViewModel : ObservableObject
private readonly IZipInstaller _zipInstaller;
private readonly ILogger<MainViewModel> _logger;
private CancellationTokenSource? _downloadCts;
private RemoteManifest? _lastManifest;
private CancellationTokenSource? _activeDownloadCts;
private VersionRowViewModel? _activeRow;
public ObservableCollection<InstalledVersionViewModel> InstalledVersions { get; } = new();
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(StatusBadge))]
[NotifyPropertyChangedFor(nameof(PrimaryActionLabel))]
[NotifyPropertyChangedFor(nameof(PrimaryActionEnabled))]
[NotifyPropertyChangedFor(nameof(HeroTitle))]
[NotifyCanExecuteChangedFor(nameof(PrimaryActionCommand))]
private InstalledVersionViewModel? _selectedVersion;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(StatusBadge))]
[NotifyPropertyChangedFor(nameof(PrimaryActionLabel))]
[NotifyPropertyChangedFor(nameof(PrimaryActionEnabled))]
private VersionManifest? _availableUpdate;
public ObservableCollection<VersionRowViewModel> Versions { get; } = new();
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FooterText))]
@@ -54,76 +42,29 @@ public sealed partial class MainViewModel : ObservableObject
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FooterText))]
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
[NotifyPropertyChangedFor(nameof(StatusBadge))]
[NotifyPropertyChangedFor(nameof(PrimaryActionLabel))]
[NotifyPropertyChangedFor(nameof(PrimaryActionEnabled))]
[NotifyCanExecuteChangedFor(nameof(PrimaryActionCommand))]
[NotifyCanExecuteChangedFor(nameof(CheckForUpdatesCommand))]
private bool _isBusy;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FooterText))]
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
private double _progressPercent;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FooterText))]
private string? _progressDetail;
public string StatusBadge
{
get
{
if (IsBusy) return "● En cours";
if (AvailableUpdate is not null) return "🔔 MAJ disponible";
if (SelectedVersion is null) return "Aucune version";
return "● Installée";
}
}
public string PrimaryActionLabel
{
get
{
if (IsBusy) return "Veuillez patienter…";
if (AvailableUpdate is not null && SelectedVersion is null)
return $"⬇ INSTALLER v{AvailableUpdate.Version}";
if (AvailableUpdate is not null)
return $"⬇ TÉLÉCHARGER v{AvailableUpdate.Version}";
return SelectedVersion is null ? "Aucune version disponible" : "▶ LANCER";
}
}
public bool PrimaryActionEnabled => !IsBusy && (SelectedVersion is not null || AvailableUpdate is not null);
public string HeroTitle
{
get
{
if (AvailableUpdate is not null) return $"PROSERVE — v{AvailableUpdate.Version}";
return SelectedVersion is null ? "PROSERVE" : $"PROSERVE — {SelectedVersion.Display}";
}
}
[ObservableProperty] private double _progressPercent;
[ObservableProperty] private string? _progressDetail;
public string LicenseSummary => "License : non configurée (v0.4)";
public string EmptyHint =>
$"Aucune version trouvée dans :\n{_config.InstallRoot}\n\n" +
"Configure l'URL serveur dans config.json puis clique « Vérifier les mises à jour », " +
"ou place un dossier « Proserve v{X.Y.Z} » contenant PROSERVE_UE_5_5.exe à cet emplacement.";
"Aucune version locale ni distante.\n" +
"Clique « Vérifier les mises à jour » pour récupérer le catalogue depuis le serveur, " +
"ou place un dossier « Proserve v{X.Y.Z} » contenant PROSERVE_UE_5_5.exe dans :\n" +
_config.InstallRoot;
public Visibility EmptyHintVisibility => InstalledVersions.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
public Visibility EmptyHintVisibility =>
Versions.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
public Visibility FooterVisibility => IsBusy || !string.IsNullOrEmpty(StatusMessage) ? Visibility.Visible : Visibility.Collapsed;
public Visibility FooterVisibility =>
IsBusy || !string.IsNullOrEmpty(StatusMessage) ? Visibility.Visible : Visibility.Collapsed;
public string FooterText
{
get
{
if (!string.IsNullOrEmpty(ProgressDetail)) return ProgressDetail;
return StatusMessage ?? string.Empty;
}
}
public string FooterText =>
!string.IsNullOrEmpty(ProgressDetail) ? ProgressDetail :
StatusMessage ?? string.Empty;
public MainViewModel(
IInstallationRegistry registry,
@@ -145,25 +86,49 @@ public sealed partial class MainViewModel : ObservableObject
_downloadManager = downloadManager;
_zipInstaller = zipInstaller;
_logger = logger;
Refresh();
RebuildList();
}
[RelayCommand]
private void Refresh()
/// <summary>Recompose la liste affichée à partir du scan local + du dernier manifest distant.</summary>
private void RebuildList()
{
InstalledVersions.Clear();
foreach (var v in _registry.Scan())
InstalledVersions.Add(new InstalledVersionViewModel(v));
var installed = _registry.Scan().ToDictionary(v => v.Version);
var remote = _lastManifest?.Versions ?? new List<VersionManifest>();
var remoteByVer = remote.ToDictionary(v => v.Version);
SelectedVersion = !string.IsNullOrEmpty(_config.LastLaunchedVersion)
? InstalledVersions.FirstOrDefault(v => v.Model.Version == _config.LastLaunchedVersion)
?? InstalledVersions.FirstOrDefault()
: InstalledVersions.FirstOrDefault();
var allVersions = installed.Keys.Union(remoteByVer.Keys)
.OrderByDescending(v => SemVer.Parse(v))
.ToList();
Versions.Clear();
foreach (var ver in allVersions)
{
VersionRowViewModel row;
if (installed.TryGetValue(ver, out var inst))
{
row = VersionRowViewModel.ForInstalled(inst, remoteByVer.GetValueOrDefault(ver));
}
else
{
row = VersionRowViewModel.ForRemote(remoteByVer[ver]);
}
WireRowHandlers(row);
Versions.Add(row);
}
OnPropertyChanged(nameof(EmptyHint));
OnPropertyChanged(nameof(EmptyHintVisibility));
}
private void WireRowHandlers(VersionRowViewModel row)
{
row.LaunchHandler = LaunchVersion;
row.InstallHandler = r => _ = InstallVersionAsync(r);
row.UninstallHandler = r => _ = UninstallVersionAsync(r);
row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r);
row.OpenFolderHandler = OpenVersionFolder;
}
[RelayCommand(CanExecute = nameof(CanCheckUpdates))]
private async Task CheckForUpdatesAsync()
{
@@ -175,22 +140,18 @@ public sealed partial class MainViewModel : ObservableObject
if (result.Error is not null)
{
StatusMessage = $"Erreur : {result.Error}";
AvailableUpdate = null;
return;
}
_lastManifest = result.Manifest;
RebuildList();
if (result.IsLatestNewerThanInstalled && result.LatestRemote is not null)
{
AvailableUpdate = result.LatestRemote;
StatusMessage = $"Nouvelle version disponible : v{result.LatestRemote.Version}";
}
else if (result.LatestRemote is not null)
StatusMessage = $"À jour (v{result.LatestRemote.Version})";
else
{
AvailableUpdate = null;
StatusMessage = result.LatestRemote is not null
? $"À jour (v{result.LatestRemote.Version})"
: "Aucune version distante trouvée";
}
StatusMessage = "Aucune version distante trouvée";
}
catch (Exception ex)
{
@@ -202,142 +163,217 @@ public sealed partial class MainViewModel : ObservableObject
IsBusy = false;
}
}
private bool CanCheckUpdates() => !IsBusy;
[RelayCommand(CanExecute = nameof(PrimaryActionEnabled))]
private async Task PrimaryActionAsync()
{
if (AvailableUpdate is not null)
{
await DownloadAndInstallAsync(AvailableUpdate);
return;
}
if (SelectedVersion is null) return;
try
{
_processLauncher.Launch(SelectedVersion.Model);
_config.LastLaunchedVersion = SelectedVersion.Model.Version;
_configStore.Save(_config);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to launch");
MessageBox.Show(
$"Impossible de lancer la version :\n\n{ex.Message}",
"Erreur de lancement",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private async Task DownloadAndInstallAsync(VersionManifest version)
{
// 1) Récupère release notes et propose dialog
string notes;
try
{
notes = !string.IsNullOrEmpty(version.ReleaseNotesUrl)
? await _manifestService.FetchReleaseNotesAsync(version.ReleaseNotesUrl, CancellationToken.None)
: "_Aucune release note fournie._";
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch release notes");
notes = "_Release notes indisponibles._";
}
var dialog = new UpdateAvailableDialog(version, notes)
{
Owner = Application.Current.MainWindow
};
dialog.ShowDialog();
if (!dialog.DownloadRequested) return;
IsBusy = true;
_downloadCts = new CancellationTokenSource();
try
{
var url = new Uri(version.Download.Url);
var job = new DownloadJob(version.Version, url, version.Download.SizeBytes, version.Download.Sha256);
var progress = new Progress<DownloadProgress>(p => UpdateDlProgress(version.Version, p));
StatusMessage = $"Téléchargement v{version.Version}…";
var zipPath = await _downloadManager.DownloadAsync(job, progress, _downloadCts.Token);
// 2) Extraction
StatusMessage = $"Installation v{version.Version}…";
var folderName = version.GetInstallFolderName();
var target = Path.Combine(_config.InstallRoot, folderName);
var installProgress = new Progress<InstallProgress>(ip =>
{
if (ip.BytesTotal <= 0) return;
ProgressPercent = (double)ip.BytesDone / ip.BytesTotal * 100.0;
ProgressDetail = $"Extraction : {ip.EntriesDone}/{ip.EntriesTotal} fichiers ({FormatSize(ip.BytesDone)} / {FormatSize(ip.BytesTotal)})";
});
await _zipInstaller.InstallAsync(zipPath, target, installProgress, _downloadCts.Token);
// 3) Nettoyage du ZIP téléchargé (le user peut le rétablir depuis le serveur s'il en a besoin)
try { File.Delete(zipPath); } catch { /* non critique */ }
StatusMessage = $"v{version.Version} installée avec succès";
ProgressDetail = null;
ProgressPercent = 0;
AvailableUpdate = null;
Refresh();
}
catch (OperationCanceledException)
{
StatusMessage = "Téléchargement annulé";
}
catch (Exception ex)
{
_logger.LogError(ex, "Download/install failed");
MessageBox.Show(
$"Le téléchargement ou l'installation a échoué :\n\n{ex.Message}",
"Erreur",
MessageBoxButton.OK, MessageBoxImage.Error);
StatusMessage = $"Erreur : {ex.Message}";
}
finally
{
_downloadCts?.Dispose();
_downloadCts = null;
IsBusy = false;
}
}
private void UpdateDlProgress(string version, DownloadProgress p)
{
if (p.TotalBytes > 0)
ProgressPercent = (double)p.BytesDownloaded / p.TotalBytes * 100.0;
var sb = new System.Text.StringBuilder();
sb.Append("⬇ v").Append(version).Append(" : ");
sb.Append(FormatSize(p.BytesDownloaded));
if (p.TotalBytes > 0) sb.Append(" / ").Append(FormatSize(p.TotalBytes));
if (p.BytesPerSecond > 0) sb.Append(" • ").Append(FormatSize((long)p.BytesPerSecond)).Append("/s");
if (p.Eta is { } eta && eta.TotalSeconds > 1) sb.Append(" • ETA ").Append(FormatEta(eta));
ProgressDetail = sb.ToString();
}
[RelayCommand]
private void OpenFolder()
private void OpenInstallRoot()
{
var path = SelectedVersion?.Model.FolderPath ?? _config.InstallRoot;
if (string.IsNullOrEmpty(path) || !Directory.Exists(path)) return;
if (!Directory.Exists(_config.InstallRoot)) return;
Process.Start(new ProcessStartInfo
{
FileName = path,
FileName = _config.InstallRoot,
UseShellExecute = true,
Verb = "open"
});
}
[RelayCommand]
private void CancelDownload()
private void CancelDownload() => _activeDownloadCts?.Cancel();
// ------------ Per-row actions ------------
private void LaunchVersion(VersionRowViewModel row)
{
_downloadCts?.Cancel();
if (row.Installed is null) return;
try
{
_processLauncher.Launch(row.Installed);
_config.LastLaunchedVersion = row.Version;
_configStore.Save(_config);
}
catch (Exception ex)
{
_logger.LogError(ex, "Launch failed");
MessageBox.Show($"Impossible de lancer la version :\n\n{ex.Message}",
"Erreur de lancement", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private async Task InstallVersionAsync(VersionRowViewModel row)
{
if (row.Remote is null) return;
if (IsBusy) { MessageBox.Show("Une autre opération est déjà en cours.", "Patience", MessageBoxButton.OK, MessageBoxImage.Information); return; }
IsBusy = true;
_activeRow = row;
_activeDownloadCts = new CancellationTokenSource();
var ct = _activeDownloadCts.Token;
try
{
// 1) Récupère release notes (best effort)
string notes = "_Aucune release note fournie._";
if (!string.IsNullOrEmpty(row.Remote.ReleaseNotesUrl))
{
try
{
notes = await _manifestService.FetchReleaseNotesAsync(row.Remote.ReleaseNotesUrl, ct);
}
catch (Exception ex) { _logger.LogWarning(ex, "Release notes fetch failed"); notes = "_Release notes indisponibles._"; }
}
// 2) Dialog de confirmation avec release notes
var dialog = new UpdateAvailableDialog(row.Remote, notes) { Owner = Application.Current.MainWindow };
dialog.ShowDialog();
if (!dialog.DownloadRequested) return;
// 3) Download
row.State = VersionRowState.Downloading;
var url = new Uri(row.Remote.Download.Url);
var job = new DownloadJob(row.Version, url, row.Remote.Download.SizeBytes, row.Remote.Download.Sha256);
var dlProgress = new Progress<DownloadProgress>(p => UpdateDlProgress(row, p));
StatusMessage = $"Téléchargement v{row.Version}…";
var zipPath = await _downloadManager.DownloadAsync(job, dlProgress, ct);
// 4) Install
row.State = VersionRowState.Installing;
StatusMessage = $"Installation v{row.Version}…";
var folderName = row.Remote.GetInstallFolderName();
var target = Path.Combine(_config.InstallRoot, folderName);
var installProgress = new Progress<InstallProgress>(ip =>
{
if (ip.BytesTotal <= 0) return;
var pct = (double)ip.BytesDone / ip.BytesTotal * 100.0;
ProgressPercent = pct;
row.ProgressPercent = pct;
ProgressDetail = $"Extraction v{row.Version} : {ip.EntriesDone}/{ip.EntriesTotal} fichiers ({FormatSize(ip.BytesDone)} / {FormatSize(ip.BytesTotal)})";
row.ProgressDetail = ProgressDetail;
});
await _zipInstaller.InstallAsync(zipPath, target, installProgress, ct);
try { File.Delete(zipPath); } catch { /* non critique */ }
StatusMessage = $"v{row.Version} installée avec succès";
ProgressDetail = null;
ProgressPercent = 0;
RebuildList();
}
catch (OperationCanceledException)
{
StatusMessage = "Téléchargement annulé";
row.State = VersionRowState.AvailableIdle;
row.ProgressDetail = null;
row.ProgressPercent = 0;
}
catch (Exception ex)
{
_logger.LogError(ex, "Install failed for {Version}", row.Version);
row.State = VersionRowState.AvailableIdle;
row.ProgressDetail = null;
row.ProgressPercent = 0;
MessageBox.Show(
$"Le téléchargement ou l'installation a échoué :\n\n{ex.Message}",
"Erreur", MessageBoxButton.OK, MessageBoxImage.Error);
StatusMessage = $"Erreur : {ex.Message}";
}
finally
{
_activeDownloadCts?.Dispose();
_activeDownloadCts = null;
_activeRow = null;
IsBusy = false;
}
}
private async Task UninstallVersionAsync(VersionRowViewModel row)
{
if (row.Installed is null) return;
var sizeStr = FormatSize(row.SizeBytes);
var confirm = MessageBox.Show(
$"Supprimer la version v{row.Version} ?\n\nDossier : {row.FolderPath}\nLibérera {sizeStr}.\n\nCette action est irréversible.",
"Confirmer la suppression",
MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
if (IsBusy) { MessageBox.Show("Une autre opération est déjà en cours.", "Patience", MessageBoxButton.OK, MessageBoxImage.Information); return; }
IsBusy = true;
row.State = VersionRowState.Uninstalling;
StatusMessage = $"Suppression v{row.Version}…";
try
{
await _registry.DeleteAsync(row.Version, null, CancellationToken.None);
StatusMessage = $"v{row.Version} supprimée";
RebuildList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Uninstall failed for {Version}", row.Version);
row.State = VersionRowState.InstalledIdle;
MessageBox.Show($"Suppression échouée : {ex.Message}", "Erreur",
MessageBoxButton.OK, MessageBoxImage.Error);
StatusMessage = $"Erreur : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
private async Task ShowReleaseNotesAsync(VersionRowViewModel row)
{
var url = row.Remote?.ReleaseNotesUrl;
if (string.IsNullOrEmpty(url))
{
MessageBox.Show("Aucune release note disponible pour cette version.",
"Release notes", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
string notes;
try { notes = await _manifestService.FetchReleaseNotesAsync(url, CancellationToken.None); }
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch release notes");
MessageBox.Show($"Impossible de récupérer les release notes :\n{ex.Message}",
"Erreur", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
var dlg = new ReleaseNotesViewerDialog(row.Version, notes) { Owner = Application.Current.MainWindow };
dlg.ShowDialog();
}
private void OpenVersionFolder(VersionRowViewModel row)
{
if (string.IsNullOrEmpty(row.FolderPath) || !Directory.Exists(row.FolderPath)) return;
Process.Start(new ProcessStartInfo
{
FileName = row.FolderPath,
UseShellExecute = true,
Verb = "open"
});
}
private void UpdateDlProgress(VersionRowViewModel row, DownloadProgress p)
{
if (p.TotalBytes > 0)
{
var pct = (double)p.BytesDownloaded / p.TotalBytes * 100.0;
ProgressPercent = pct;
row.ProgressPercent = pct;
}
var sb = new System.Text.StringBuilder();
sb.Append("⬇ v").Append(row.Version).Append(" : ");
sb.Append(FormatSize(p.BytesDownloaded));
if (p.TotalBytes > 0) sb.Append(" / ").Append(FormatSize(p.TotalBytes));
if (p.BytesPerSecond > 0) sb.Append(" • ").Append(FormatSize((long)p.BytesPerSecond)).Append("/s");
if (p.Eta is { } eta && eta.TotalSeconds > 1) sb.Append(" • ETA ").Append(FormatEta(eta));
var s = sb.ToString();
ProgressDetail = s;
row.ProgressDetail = s;
}
private static string FormatSize(long bytes)

View File

@@ -0,0 +1,139 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PSLauncher.Models;
namespace PSLauncher.App.ViewModels;
public enum VersionRowState
{
InstalledIdle, // Installée localement, pas d'action en cours
AvailableIdle, // Disponible côté serveur, pas installée
Downloading,
Installing,
Uninstalling,
}
public sealed partial class VersionRowViewModel : ObservableObject
{
public string Version { get; }
public DateTime ReleasedAt { get; }
public string ExecutablePath { get; }
public string FolderPath { get; }
public long SizeBytes { get; }
public InstalledVersion? Installed { get; }
public VersionManifest? Remote { get; }
public bool HasReleaseNotes => Remote?.ReleaseNotesUrl is not null;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(StateLabel))]
[NotifyPropertyChangedFor(nameof(IsBusy))]
[NotifyPropertyChangedFor(nameof(IsInstalled))]
[NotifyPropertyChangedFor(nameof(IsRemoteOnly))]
[NotifyCanExecuteChangedFor(nameof(LaunchCommand))]
[NotifyCanExecuteChangedFor(nameof(InstallCommand))]
[NotifyCanExecuteChangedFor(nameof(UninstallCommand))]
private VersionRowState _state;
[ObservableProperty] private double _progressPercent;
[ObservableProperty] private string? _progressDetail;
public bool IsInstalled => State is VersionRowState.InstalledIdle or VersionRowState.Uninstalling;
public bool IsRemoteOnly => State is VersionRowState.AvailableIdle;
public bool IsBusy => State is VersionRowState.Downloading or VersionRowState.Installing or VersionRowState.Uninstalling;
public string StateLabel => State switch
{
VersionRowState.InstalledIdle => "● Installée",
VersionRowState.AvailableIdle => "○ Disponible",
VersionRowState.Downloading => "⬇ Téléchargement…",
VersionRowState.Installing => "📦 Installation…",
VersionRowState.Uninstalling => "🗑 Suppression…",
_ => string.Empty
};
public string PrimaryDate => ReleasedAt > DateTime.MinValue
? ReleasedAt.ToLocalTime().ToString("dd/MM/yyyy")
: "—";
public string SizeDisplay => FormatSize(SizeBytes);
/// <summary>Constructeur ligne « installée localement » (peut aussi être présente en remote).</summary>
public static VersionRowViewModel ForInstalled(InstalledVersion installed, VersionManifest? remote)
{
return new VersionRowViewModel(
version: installed.Version,
releasedAt: remote?.ReleasedAt ?? installed.InstalledAt,
executablePath: installed.ExecutablePath,
folderPath: installed.FolderPath,
sizeBytes: installed.SizeBytes,
installed: installed,
remote: remote,
initialState: VersionRowState.InstalledIdle);
}
/// <summary>Constructeur ligne « disponible côté serveur uniquement ».</summary>
public static VersionRowViewModel ForRemote(VersionManifest remote)
{
return new VersionRowViewModel(
version: remote.Version,
releasedAt: remote.ReleasedAt,
executablePath: string.Empty,
folderPath: string.Empty,
sizeBytes: remote.Download.SizeBytes,
installed: null,
remote: remote,
initialState: VersionRowState.AvailableIdle);
}
private VersionRowViewModel(
string version, DateTime releasedAt, string executablePath, string folderPath,
long sizeBytes, InstalledVersion? installed, VersionManifest? remote,
VersionRowState initialState)
{
Version = version;
ReleasedAt = releasedAt;
ExecutablePath = executablePath;
FolderPath = folderPath;
SizeBytes = sizeBytes;
Installed = installed;
Remote = remote;
_state = initialState;
}
// Les commandes sont câblées par le MainViewModel après instanciation
// (les actions ont besoin de services).
public Action<VersionRowViewModel>? LaunchHandler { get; set; }
public Action<VersionRowViewModel>? InstallHandler { get; set; }
public Action<VersionRowViewModel>? UninstallHandler { get; set; }
public Action<VersionRowViewModel>? ShowReleaseNotesHandler { get; set; }
public Action<VersionRowViewModel>? OpenFolderHandler { get; set; }
[RelayCommand(CanExecute = nameof(CanLaunch))]
private void Launch() => LaunchHandler?.Invoke(this);
private bool CanLaunch() => State == VersionRowState.InstalledIdle;
[RelayCommand(CanExecute = nameof(CanInstall))]
private void Install() => InstallHandler?.Invoke(this);
private bool CanInstall() => State == VersionRowState.AvailableIdle;
[RelayCommand(CanExecute = nameof(CanUninstall))]
private void Uninstall() => UninstallHandler?.Invoke(this);
private bool CanUninstall() => State == VersionRowState.InstalledIdle;
[RelayCommand]
private void ShowReleaseNotes() => ShowReleaseNotesHandler?.Invoke(this);
[RelayCommand]
private void OpenFolder() => OpenFolderHandler?.Invoke(this);
private static string FormatSize(long bytes)
{
if (bytes <= 0) return "—";
string[] units = { "o", "Ko", "Mo", "Go", "To" };
double v = bytes;
int u = 0;
while (v >= 1024 && u < units.Length - 1) { v /= 1024; u++; }
return $"{v:0.#} {units[u]}";
}
}