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:
@@ -94,6 +94,77 @@
|
|||||||
<Setter Property="Padding" Value="14,8" />
|
<Setter Property="Padding" Value="14,8" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="AccentButton" TargetType="Button" BasedOn="{StaticResource PrimaryButton}">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Brush.Accent}" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border x:Name="Bd"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
CornerRadius="4"
|
||||||
|
Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.AccentHover}" />
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsEnabled" Value="False">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#444" />
|
||||||
|
<Setter Property="Foreground" Value="#888" />
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="IconButton" TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Secondary}" />
|
||||||
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
|
<Setter Property="Padding" Value="8,4" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border x:Name="Bd"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
CornerRadius="4"
|
||||||
|
Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#2C2C32" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Style ContextMenu sombre, cohérent avec le thème -->
|
||||||
|
<Style TargetType="ContextMenu">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Brush.Bg.Card}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Brush.Border}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
|
||||||
|
<Setter Property="Padding" Value="4" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="MenuItem">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
|
||||||
|
<Setter Property="Padding" Value="10,6" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="Separator">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Brush.Border}" />
|
||||||
|
<Setter Property="Margin" Value="4,4" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<Style x:Key="VersionPill" TargetType="Border">
|
<Style x:Key="VersionPill" TargetType="Border">
|
||||||
<Setter Property="Background" Value="#2C2C32" />
|
<Setter Property="Background" Value="#2C2C32" />
|
||||||
<Setter Property="CornerRadius" Value="12" />
|
<Setter Property="CornerRadius" Value="12" />
|
||||||
|
|||||||
@@ -28,23 +28,11 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
private readonly IZipInstaller _zipInstaller;
|
private readonly IZipInstaller _zipInstaller;
|
||||||
private readonly ILogger<MainViewModel> _logger;
|
private readonly ILogger<MainViewModel> _logger;
|
||||||
|
|
||||||
private CancellationTokenSource? _downloadCts;
|
private RemoteManifest? _lastManifest;
|
||||||
|
private CancellationTokenSource? _activeDownloadCts;
|
||||||
|
private VersionRowViewModel? _activeRow;
|
||||||
|
|
||||||
public ObservableCollection<InstalledVersionViewModel> InstalledVersions { get; } = new();
|
public ObservableCollection<VersionRowViewModel> Versions { 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;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(FooterText))]
|
[NotifyPropertyChangedFor(nameof(FooterText))]
|
||||||
@@ -54,76 +42,29 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(FooterText))]
|
[NotifyPropertyChangedFor(nameof(FooterText))]
|
||||||
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
|
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
|
||||||
[NotifyPropertyChangedFor(nameof(StatusBadge))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(PrimaryActionLabel))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(PrimaryActionEnabled))]
|
|
||||||
[NotifyCanExecuteChangedFor(nameof(PrimaryActionCommand))]
|
|
||||||
[NotifyCanExecuteChangedFor(nameof(CheckForUpdatesCommand))]
|
[NotifyCanExecuteChangedFor(nameof(CheckForUpdatesCommand))]
|
||||||
private bool _isBusy;
|
private bool _isBusy;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty] private double _progressPercent;
|
||||||
[NotifyPropertyChangedFor(nameof(FooterText))]
|
[ObservableProperty] private string? _progressDetail;
|
||||||
[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}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public string LicenseSummary => "License : non configurée (v0.4)";
|
public string LicenseSummary => "License : non configurée (v0.4)";
|
||||||
|
|
||||||
public string EmptyHint =>
|
public string EmptyHint =>
|
||||||
$"Aucune version trouvée dans :\n{_config.InstallRoot}\n\n" +
|
"Aucune version locale ni distante.\n" +
|
||||||
"Configure l'URL serveur dans config.json puis clique « Vérifier les mises à jour », " +
|
"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 à cet emplacement.";
|
"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
|
public string FooterText =>
|
||||||
{
|
!string.IsNullOrEmpty(ProgressDetail) ? ProgressDetail :
|
||||||
get
|
StatusMessage ?? string.Empty;
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(ProgressDetail)) return ProgressDetail;
|
|
||||||
return StatusMessage ?? string.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public MainViewModel(
|
public MainViewModel(
|
||||||
IInstallationRegistry registry,
|
IInstallationRegistry registry,
|
||||||
@@ -145,25 +86,49 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
_downloadManager = downloadManager;
|
_downloadManager = downloadManager;
|
||||||
_zipInstaller = zipInstaller;
|
_zipInstaller = zipInstaller;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
Refresh();
|
RebuildList();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
/// <summary>Recompose la liste affichée à partir du scan local + du dernier manifest distant.</summary>
|
||||||
private void Refresh()
|
private void RebuildList()
|
||||||
{
|
{
|
||||||
InstalledVersions.Clear();
|
var installed = _registry.Scan().ToDictionary(v => v.Version);
|
||||||
foreach (var v in _registry.Scan())
|
var remote = _lastManifest?.Versions ?? new List<VersionManifest>();
|
||||||
InstalledVersions.Add(new InstalledVersionViewModel(v));
|
var remoteByVer = remote.ToDictionary(v => v.Version);
|
||||||
|
|
||||||
SelectedVersion = !string.IsNullOrEmpty(_config.LastLaunchedVersion)
|
var allVersions = installed.Keys.Union(remoteByVer.Keys)
|
||||||
? InstalledVersions.FirstOrDefault(v => v.Model.Version == _config.LastLaunchedVersion)
|
.OrderByDescending(v => SemVer.Parse(v))
|
||||||
?? InstalledVersions.FirstOrDefault()
|
.ToList();
|
||||||
: InstalledVersions.FirstOrDefault();
|
|
||||||
|
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(EmptyHint));
|
||||||
OnPropertyChanged(nameof(EmptyHintVisibility));
|
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))]
|
[RelayCommand(CanExecute = nameof(CanCheckUpdates))]
|
||||||
private async Task CheckForUpdatesAsync()
|
private async Task CheckForUpdatesAsync()
|
||||||
{
|
{
|
||||||
@@ -175,22 +140,18 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
if (result.Error is not null)
|
if (result.Error is not null)
|
||||||
{
|
{
|
||||||
StatusMessage = $"Erreur : {result.Error}";
|
StatusMessage = $"Erreur : {result.Error}";
|
||||||
AvailableUpdate = null;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_lastManifest = result.Manifest;
|
||||||
|
RebuildList();
|
||||||
|
|
||||||
if (result.IsLatestNewerThanInstalled && result.LatestRemote is not null)
|
if (result.IsLatestNewerThanInstalled && result.LatestRemote is not null)
|
||||||
{
|
|
||||||
AvailableUpdate = result.LatestRemote;
|
|
||||||
StatusMessage = $"Nouvelle version disponible : v{result.LatestRemote.Version}";
|
StatusMessage = $"Nouvelle version disponible : v{result.LatestRemote.Version}";
|
||||||
}
|
else if (result.LatestRemote is not null)
|
||||||
|
StatusMessage = $"À jour (v{result.LatestRemote.Version})";
|
||||||
else
|
else
|
||||||
{
|
StatusMessage = "Aucune version distante trouvée";
|
||||||
AvailableUpdate = null;
|
|
||||||
StatusMessage = result.LatestRemote is not null
|
|
||||||
? $"À jour (v{result.LatestRemote.Version})"
|
|
||||||
: "Aucune version distante trouvée";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -202,142 +163,217 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
IsBusy = false;
|
IsBusy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool CanCheckUpdates() => !IsBusy;
|
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]
|
[RelayCommand]
|
||||||
private void OpenFolder()
|
private void OpenInstallRoot()
|
||||||
{
|
{
|
||||||
var path = SelectedVersion?.Model.FolderPath ?? _config.InstallRoot;
|
if (!Directory.Exists(_config.InstallRoot)) return;
|
||||||
if (string.IsNullOrEmpty(path) || !Directory.Exists(path)) return;
|
|
||||||
Process.Start(new ProcessStartInfo
|
Process.Start(new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = path,
|
FileName = _config.InstallRoot,
|
||||||
UseShellExecute = true,
|
UseShellExecute = true,
|
||||||
Verb = "open"
|
Verb = "open"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[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)
|
private static string FormatSize(long bytes)
|
||||||
|
|||||||
139
src/PSLauncher.App/ViewModels/VersionRowViewModel.cs
Normal file
139
src/PSLauncher.App/ViewModels/VersionRowViewModel.cs
Normal 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]}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,9 @@
|
|||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="clr-namespace:PSLauncher.App.ViewModels"
|
xmlns:vm="clr-namespace:PSLauncher.App.ViewModels"
|
||||||
xmlns:views="clr-namespace:PSLauncher.App.Views"
|
|
||||||
Title="PROSERVE Launcher"
|
Title="PROSERVE Launcher"
|
||||||
Width="1280" Height="800"
|
Width="960" Height="720"
|
||||||
MinWidth="900" MinHeight="600"
|
MinWidth="780" MinHeight="500"
|
||||||
WindowStartupLocation="CenterScreen"
|
WindowStartupLocation="CenterScreen"
|
||||||
d:DataContext="{d:DesignInstance Type=vm:MainViewModel}"
|
d:DataContext="{d:DesignInstance Type=vm:MainViewModel}"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
@@ -16,7 +15,7 @@
|
|||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto" /> <!-- Top bar -->
|
<RowDefinition Height="Auto" /> <!-- Top bar -->
|
||||||
<RowDefinition Height="*" /> <!-- Body -->
|
<RowDefinition Height="*" /> <!-- Body -->
|
||||||
<RowDefinition Height="Auto" /> <!-- Footer (download) -->
|
<RowDefinition Height="Auto" /> <!-- Footer -->
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- Top bar -->
|
<!-- Top bar -->
|
||||||
@@ -24,7 +23,8 @@
|
|||||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,0,0,1"
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,0,0,1"
|
||||||
Padding="20,12">
|
Padding="20,12">
|
||||||
<Grid>
|
<Grid>
|
||||||
<TextBlock Text="PROSERVE" FontSize="18" FontWeight="Bold"
|
<TextBlock Text="PROSERVE Launcher"
|
||||||
|
FontSize="18" FontWeight="Bold"
|
||||||
Foreground="{StaticResource Brush.Text.Primary}"
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal" VerticalAlignment="Center">
|
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
@@ -35,116 +35,160 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Body : sidebar + content -->
|
<!-- Body -->
|
||||||
<Grid Grid.Row="1">
|
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
|
||||||
<Grid.ColumnDefinitions>
|
<StackPanel Margin="32,24">
|
||||||
<ColumnDefinition Width="200" />
|
|
||||||
<ColumnDefinition Width="*" />
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Header / actions -->
|
||||||
<Border Grid.Column="0" Background="{StaticResource Brush.Bg.Sidebar}"
|
<Grid Margin="0,0,0,16">
|
||||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,0,1,0">
|
|
||||||
<StackPanel>
|
|
||||||
<RadioButton Style="{StaticResource SidebarItem}" GroupName="Nav"
|
|
||||||
Content="📚 Bibliothèque" IsChecked="True" />
|
|
||||||
<RadioButton Style="{StaticResource SidebarItem}" GroupName="Nav"
|
|
||||||
Content="📰 Nouveautés" IsEnabled="False" />
|
|
||||||
<RadioButton Style="{StaticResource SidebarItem}" GroupName="Nav"
|
|
||||||
Content="⚙️ Paramètres" IsEnabled="False" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Library content -->
|
|
||||||
<ScrollViewer Grid.Column="1" VerticalScrollBarVisibility="Auto">
|
|
||||||
<StackPanel Margin="40,30">
|
|
||||||
|
|
||||||
<!-- Hero -->
|
|
||||||
<Border Background="#2A2A30" CornerRadius="8" Height="280"
|
|
||||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1">
|
|
||||||
<Grid>
|
|
||||||
<TextBlock Text="PROSERVE"
|
|
||||||
FontSize="64" FontWeight="Bold"
|
|
||||||
Foreground="#3D3D45"
|
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
|
||||||
<Border VerticalAlignment="Bottom" HorizontalAlignment="Left"
|
|
||||||
Margin="24" Background="#000000B0"
|
|
||||||
CornerRadius="4" Padding="12,6">
|
|
||||||
<TextBlock Text="{Binding HeroTitle}"
|
|
||||||
FontSize="22" FontWeight="SemiBold"
|
|
||||||
Foreground="White" />
|
|
||||||
</Border>
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Action row -->
|
|
||||||
<Grid Margin="0,24,0,0">
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="Auto" />
|
|
||||||
<ColumnDefinition Width="*" />
|
|
||||||
<ColumnDefinition Width="Auto" />
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
|
|
||||||
<!-- Version selector -->
|
|
||||||
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
|
|
||||||
<TextBlock Text="Version :" Foreground="{StaticResource Brush.Text.Secondary}"
|
|
||||||
VerticalAlignment="Center" Margin="0,0,10,0" />
|
|
||||||
<ComboBox Width="160"
|
|
||||||
ItemsSource="{Binding InstalledVersions}"
|
|
||||||
DisplayMemberPath="Display"
|
|
||||||
SelectedItem="{Binding SelectedVersion, Mode=TwoWay}" />
|
|
||||||
<Border Style="{StaticResource VersionPill}" Margin="12,0,0,0">
|
|
||||||
<TextBlock Text="{Binding StatusBadge}" FontSize="12"
|
|
||||||
Foreground="{StaticResource Brush.Text.Primary}" />
|
|
||||||
</Border>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Primary action -->
|
|
||||||
<Button Grid.Column="2"
|
|
||||||
Style="{StaticResource PrimaryButton}"
|
|
||||||
Content="{Binding PrimaryActionLabel}"
|
|
||||||
Command="{Binding PrimaryActionCommand}"
|
|
||||||
IsEnabled="{Binding PrimaryActionEnabled}" />
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- Secondary actions -->
|
|
||||||
<StackPanel Orientation="Horizontal" Margin="0,16,0,0">
|
|
||||||
<Button Style="{StaticResource SecondaryButton}"
|
|
||||||
Content="🔄 Vérifier les mises à jour"
|
|
||||||
Command="{Binding CheckForUpdatesCommand}"
|
|
||||||
Margin="0,0,10,0" />
|
|
||||||
<Button Style="{StaticResource SecondaryButton}"
|
|
||||||
Content="📁 Ouvrir le dossier"
|
|
||||||
Command="{Binding OpenFolderCommand}"
|
|
||||||
Margin="0,0,10,0" />
|
|
||||||
<Button Style="{StaticResource SecondaryButton}"
|
|
||||||
Content="🔍 Rescanner local"
|
|
||||||
Command="{Binding RefreshCommand}" />
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Installed versions list -->
|
|
||||||
<TextBlock Text="Versions installées" FontSize="16" FontWeight="SemiBold"
|
|
||||||
Margin="0,30,0,12"
|
|
||||||
Foreground="{StaticResource Brush.Text.Primary}" />
|
|
||||||
<ItemsControl ItemsSource="{Binding InstalledVersions}">
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate>
|
|
||||||
<Border Background="{StaticResource Brush.Bg.Card}"
|
|
||||||
CornerRadius="6" Margin="0,0,0,8" Padding="16,12"
|
|
||||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1">
|
|
||||||
<Grid>
|
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<StackPanel Grid.Column="0">
|
<StackPanel Grid.Column="0">
|
||||||
<TextBlock Text="{Binding Display}"
|
<TextBlock Text="Versions"
|
||||||
FontSize="15" FontWeight="SemiBold" />
|
FontSize="22" FontWeight="SemiBold"
|
||||||
<TextBlock Text="{Binding Subtitle}"
|
Foreground="{StaticResource Brush.Text.Primary}" />
|
||||||
|
<TextBlock Text="Lance, installe ou supprime une version. Les versions installées et celles disponibles sur le serveur cohabitent."
|
||||||
FontSize="12"
|
FontSize="12"
|
||||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
Margin="0,2,0,0" />
|
Margin="0,4,0,0"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<Button Style="{StaticResource SecondaryButton}"
|
||||||
|
Content="🔄 Vérifier les MAJ"
|
||||||
|
Command="{Binding CheckForUpdatesCommand}"
|
||||||
|
Margin="0,0,8,0" />
|
||||||
|
<Button Style="{StaticResource SecondaryButton}"
|
||||||
|
Content="📁 Dossier"
|
||||||
|
Command="{Binding OpenInstallRootCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Version list -->
|
||||||
|
<ItemsControl ItemsSource="{Binding Versions}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate DataType="{x:Type vm:VersionRowViewModel}">
|
||||||
|
<Border Margin="0,0,0,8"
|
||||||
|
CornerRadius="6"
|
||||||
|
BorderThickness="1">
|
||||||
|
<Border.Style>
|
||||||
|
<Style TargetType="Border">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Brush.Bg.Card}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Brush.Border}" />
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsRemoteOnly}" Value="True">
|
||||||
|
<Setter Property="Background" Value="#1F2A3A" />
|
||||||
|
<Setter Property="BorderBrush" Value="#2C4D7A" />
|
||||||
|
</DataTrigger>
|
||||||
|
<DataTrigger Binding="{Binding IsBusy}" Value="True">
|
||||||
|
<Setter Property="Background" Value="#2A2F26" />
|
||||||
|
<Setter Property="BorderBrush" Value="#5C8C3F" />
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Border.Style>
|
||||||
|
|
||||||
|
<Grid Margin="16,12">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Left : version, state, meta -->
|
||||||
|
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal">
|
||||||
|
<TextBlock Text="{Binding Version, StringFormat='v{0}'}"
|
||||||
|
FontSize="16" FontWeight="SemiBold"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}" />
|
||||||
|
<Border Background="#0F0F12" CornerRadius="10"
|
||||||
|
Padding="8,3" Margin="12,0,0,0"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding StateLabel}"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}" />
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="0"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,4,0,0">
|
||||||
|
<Run Text="{Binding SizeDisplay, Mode=OneWay}" />
|
||||||
|
<Run Text=" • " />
|
||||||
|
<Run Text="{Binding PrimaryDate, Mode=OneWay}" />
|
||||||
|
</TextBlock>
|
||||||
|
|
||||||
|
<!-- Center : action button (Lancer / Installer / Annuler) -->
|
||||||
|
<StackPanel Grid.Row="0" Grid.RowSpan="2" Grid.Column="1"
|
||||||
|
Orientation="Horizontal"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="12,0,0,0">
|
||||||
|
|
||||||
|
<!-- Bouton LANCER (installée) -->
|
||||||
|
<Button Style="{StaticResource PrimaryButton}"
|
||||||
|
Content="▶ Lancer"
|
||||||
|
Padding="22,8"
|
||||||
|
FontSize="14"
|
||||||
|
Command="{Binding LaunchCommand}"
|
||||||
|
Visibility="{Binding IsInstalled, Converter={StaticResource BoolToVisibility}}" />
|
||||||
|
|
||||||
|
<!-- Bouton INSTALLER (remote uniquement) -->
|
||||||
|
<Button Style="{StaticResource AccentButton}"
|
||||||
|
Content="⬇ Installer"
|
||||||
|
Padding="22,8"
|
||||||
|
FontSize="14"
|
||||||
|
Command="{Binding InstallCommand}"
|
||||||
|
Visibility="{Binding IsRemoteOnly, Converter={StaticResource BoolToVisibility}}" />
|
||||||
|
|
||||||
|
<!-- Pendant DL/install : barre de progression compacte -->
|
||||||
|
<StackPanel Orientation="Horizontal"
|
||||||
|
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<ProgressBar Width="160" Height="6"
|
||||||
|
Value="{Binding ProgressPercent}"
|
||||||
|
Maximum="100"
|
||||||
|
Foreground="{StaticResource Brush.Play}"
|
||||||
|
Background="#2A2A30"
|
||||||
|
BorderThickness="0"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock Text="{Binding ProgressPercent, StringFormat={}{0:0}%}"
|
||||||
|
FontSize="11"
|
||||||
|
Margin="8,0,0,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Right : ... menu -->
|
||||||
|
<Button Grid.Row="0" Grid.RowSpan="2" Grid.Column="2"
|
||||||
|
Style="{StaticResource IconButton}"
|
||||||
|
Content="⋯"
|
||||||
|
FontSize="18" FontWeight="Bold"
|
||||||
|
Margin="8,0,0,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
ToolTip="Plus d'actions"
|
||||||
|
Tag="{Binding}"
|
||||||
|
Click="OnMoreMenuClick">
|
||||||
|
<Button.ContextMenu>
|
||||||
|
<ContextMenu>
|
||||||
|
<MenuItem Header="📜 Voir les release notes"
|
||||||
|
Command="{Binding PlacementTarget.Tag.ShowReleaseNotesCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
|
||||||
|
<MenuItem Header="📁 Ouvrir le dossier"
|
||||||
|
Command="{Binding PlacementTarget.Tag.OpenFolderCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||||
|
Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
||||||
|
<Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
||||||
|
<MenuItem Header="🗑 Supprimer cette version…"
|
||||||
|
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||||
|
Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
||||||
|
</ContextMenu>
|
||||||
|
</Button.ContextMenu>
|
||||||
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
@@ -152,16 +196,20 @@
|
|||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
|
<Border Background="{StaticResource Brush.Bg.Card}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||||
|
CornerRadius="6"
|
||||||
|
Padding="32"
|
||||||
|
Visibility="{Binding EmptyHintVisibility}">
|
||||||
<TextBlock Text="{Binding EmptyHint}"
|
<TextBlock Text="{Binding EmptyHint}"
|
||||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
TextWrapping="Wrap" Margin="0,12,0,0"
|
TextWrapping="Wrap" />
|
||||||
Visibility="{Binding EmptyHintVisibility}" />
|
</Border>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- Footer (download/install status) -->
|
<!-- Footer (busy / status) -->
|
||||||
<Border Grid.Row="2" Background="{StaticResource Brush.Bg.Footer}"
|
<Border Grid.Row="2" Background="{StaticResource Brush.Bg.Footer}"
|
||||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,1,0,0"
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,1,0,0"
|
||||||
Padding="20,8"
|
Padding="20,8"
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Controls.Primitives;
|
||||||
|
|
||||||
namespace PSLauncher.App.Views;
|
namespace PSLauncher.App.Views;
|
||||||
|
|
||||||
@@ -8,4 +10,17 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Le bouton "..." ouvre son ContextMenu sur clic gauche (par défaut, ContextMenu ne s'ouvre qu'au clic droit).
|
||||||
|
/// </summary>
|
||||||
|
private void OnMoreMenuClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is Button btn && btn.ContextMenu is { } menu)
|
||||||
|
{
|
||||||
|
menu.PlacementTarget = btn;
|
||||||
|
menu.Placement = PlacementMode.Bottom;
|
||||||
|
menu.IsOpen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/PSLauncher.App/Views/ReleaseNotesViewerDialog.xaml
Normal file
42
src/PSLauncher.App/Views/ReleaseNotesViewerDialog.xaml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<Window x:Class="PSLauncher.App.Views.ReleaseNotesViewerDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="Notes de version"
|
||||||
|
Width="640" Height="540"
|
||||||
|
MinWidth="480" MinHeight="400"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
Background="{StaticResource Brush.Bg.Window}">
|
||||||
|
<Grid Margin="24">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="0"
|
||||||
|
Text="{Binding Title}"
|
||||||
|
FontSize="22" FontWeight="SemiBold"
|
||||||
|
Margin="0,0,0,18"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}" />
|
||||||
|
|
||||||
|
<Border Grid.Row="1"
|
||||||
|
Background="{StaticResource Brush.Bg.Card}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||||
|
CornerRadius="6"
|
||||||
|
Padding="16">
|
||||||
|
<FlowDocumentScrollViewer
|
||||||
|
Document="{Binding ReleaseNotesDocument}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
Background="Transparent" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Button Grid.Row="2"
|
||||||
|
Style="{StaticResource SecondaryButton}"
|
||||||
|
Content="Fermer"
|
||||||
|
IsCancel="True"
|
||||||
|
IsDefault="True"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Margin="0,18,0,0"
|
||||||
|
Click="OnClose" />
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
37
src/PSLauncher.App/Views/ReleaseNotesViewerDialog.xaml.cs
Normal file
37
src/PSLauncher.App/Views/ReleaseNotesViewerDialog.xaml.cs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Documents;
|
||||||
|
using Markdig;
|
||||||
|
using Markdig.Wpf;
|
||||||
|
|
||||||
|
namespace PSLauncher.App.Views;
|
||||||
|
|
||||||
|
public partial class ReleaseNotesViewerDialog : Window
|
||||||
|
{
|
||||||
|
public ReleaseNotesViewerDialog(string version, string releaseNotesMarkdown)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
var pipeline = new MarkdownPipelineBuilder().UseSupportedExtensions().Build();
|
||||||
|
FlowDocument doc;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
doc = Markdig.Wpf.Markdown.ToFlowDocument(releaseNotesMarkdown, pipeline);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
doc = new FlowDocument(new Paragraph(new Run(releaseNotesMarkdown)));
|
||||||
|
}
|
||||||
|
|
||||||
|
DataContext = new
|
||||||
|
{
|
||||||
|
Title = $"Notes de version — v{version}",
|
||||||
|
ReleaseNotesDocument = doc
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClose(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
DialogResult = false;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user