From 2102724b8fa6d7c877984834dfdfa91249445e46 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Wed, 14 Oct 2026 17:05:41 +0200 Subject: [PATCH 01/21] feat: back up persistent rules and resolve rule precedence deterministically - Configuration export now includes persistent process rules (schema 3.0), stays compatible with 2.0 bundles, validates before writing, previews what an import replaces and rolls back on failure. - Matching rules apply broad-to-specific (name, wildcard path, exact path, then most recently edited), shared by the engine and the process summary, which now lists the apply order and flags conflicting values. - Localized the new strings in all seven languages. --- Locales/de-DE.xaml | 7 + Locales/en-US.xaml | 7 + Locales/es-ES.xaml | 7 + Locales/fr-FR.xaml | 7 + Locales/it-IT.xaml | 7 + Locales/ru-RU.xaml | 7 + Locales/zh-CN.xaml | 7 + Services/ConfigurationBackupService.cs | 161 ++++ Services/PersistentRuleResolution.cs | 26 + Services/PersistentRulesEngine.cs | 5 +- Services/ServiceConfiguration.cs | 1 + .../ConfigurationBackupTests.cs | 80 ++ .../PersistentRuleResolutionTests.cs | 38 + ViewModels/SelectedProcessSummaryViewModel.cs | 831 +++++++++--------- ViewModels/SettingsViewModel.cs | 113 +-- docs/IMPLEMENTATION_PLAN.md | 27 + 16 files changed, 820 insertions(+), 511 deletions(-) create mode 100644 Services/ConfigurationBackupService.cs create mode 100644 Services/PersistentRuleResolution.cs create mode 100644 Tests/ThreadPilot.Core.Tests/ConfigurationBackupTests.cs create mode 100644 Tests/ThreadPilot.Core.Tests/PersistentRuleResolutionTests.cs create mode 100644 docs/IMPLEMENTATION_PLAN.md diff --git a/Locales/de-DE.xaml b/Locales/de-DE.xaml index eba6c6d..0f87acd 100644 --- a/Locales/de-DE.xaml +++ b/Locales/de-DE.xaml @@ -762,4 +762,11 @@ Power Throttling ist auf diesem System nicht verfügbar PriorityControl Registrierungsschlüssel nicht gefunden Desktop-Registrierungsschlüssel nicht gefunden + + + Regeln in Anwendungsreihenfolge: {0}. Bei Überschneidungen gilt die spätere Regel. + Widersprüchliche Werte: Regeln prüfen. + Einstellungen und {0} Energiesparplan-Zuordnungen ersetzen. Prozessregeln: {1}. Fortfahren? + vorhandene Regeln behalten + durch {0} importierte Regeln ersetzen diff --git a/Locales/en-US.xaml b/Locales/en-US.xaml index 46003aa..1517f5b 100644 --- a/Locales/en-US.xaml +++ b/Locales/en-US.xaml @@ -762,4 +762,11 @@ Power Throttling not available on this system PriorityControl registry key not found Desktop registry key not found + + + Rules in apply order: {0}. Later rules win for overlapping settings. + Conflicting values: review these rules. + Replace settings and {0} power plan associations. Persistent process rules: {1}. Continue? + keep existing rules + replace with {0} imported rules diff --git a/Locales/es-ES.xaml b/Locales/es-ES.xaml index 95e2efa..e96c55c 100644 --- a/Locales/es-ES.xaml +++ b/Locales/es-ES.xaml @@ -762,4 +762,11 @@ Power Throttling no disponible en este sistema PriorityControl clave de registro no encontrada No se encontró la clave de registro del escritorio + + + Reglas en orden de aplicación: {0}. En ajustes superpuestos prevalece la última. + Valores en conflicto: revisa estas reglas. + Reemplazar la configuración y {0} asociaciones de planes de energía. Reglas de procesos: {1}. ¿Continuar? + mantener las reglas existentes + reemplazar con {0} reglas importadas diff --git a/Locales/fr-FR.xaml b/Locales/fr-FR.xaml index d01b035..e71fb5f 100644 --- a/Locales/fr-FR.xaml +++ b/Locales/fr-FR.xaml @@ -762,4 +762,11 @@ Power Throttling non disponible sur ce système Clé de registre PriorityControl introuvable Clé de registre de bureau introuvable + + + Règles dans l'ordre d'application : {0}. En cas de chevauchement, la dernière l'emporte. + Valeurs en conflit : vérifiez ces règles. + Remplacer les paramètres et {0} associations de modes d'alimentation. Règles de processus : {1}. Continuer ? + conserver les règles existantes + remplacer par {0} règles importées diff --git a/Locales/it-IT.xaml b/Locales/it-IT.xaml index c2a7a16..84da79d 100644 --- a/Locales/it-IT.xaml +++ b/Locales/it-IT.xaml @@ -762,4 +762,11 @@ Power Throttling non disponibile su questo sistema Chiave di registro PriorityControl non trovata Chiave di registro del desktop non trovata + + + Regole in ordine di applicazione: {0}. Per le impostazioni sovrapposte vale l'ultima. + Valori in conflitto: controlla queste regole. + Sostituire le impostazioni e {0} associazioni ai piani di alimentazione? Regole dei processi: {1}. Continuare? + mantieni le regole esistenti + sostituisci con {0} regole importate diff --git a/Locales/ru-RU.xaml b/Locales/ru-RU.xaml index 115905a..058abe2 100644 --- a/Locales/ru-RU.xaml +++ b/Locales/ru-RU.xaml @@ -762,4 +762,11 @@ Power Throttling недоступен в этой системе Ключ реестра PriorityControl не найден Ключ реестра рабочего стола не найден + + + Правила в порядке применения: {0}. При пересечении действует последнее. + Конфликтующие значения: проверьте эти правила. + Заменить настройки и {0} привязок к схемам электропитания. Правила процессов: {1}. Продолжить? + сохранить существующие правила + заменить {0} импортированными правилами diff --git a/Locales/zh-CN.xaml b/Locales/zh-CN.xaml index 0a96554..cc212a3 100644 --- a/Locales/zh-CN.xaml +++ b/Locales/zh-CN.xaml @@ -762,4 +762,11 @@ 此系统上不支持电源限流 未找到 PriorityControl 注册表键 未找到 Desktop 注册表键 + + + 按应用顺序的规则:{0}。设置重叠时以后者为准。 + 存在冲突的值:请检查这些规则。 + 替换设置和 {0} 个电源计划关联。进程规则:{1}。是否继续? + 保留现有规则 + 替换为 {0} 条导入的规则 diff --git a/Services/ConfigurationBackupService.cs b/Services/ConfigurationBackupService.cs new file mode 100644 index 0000000..1d271c7 --- /dev/null +++ b/Services/ConfigurationBackupService.cs @@ -0,0 +1,161 @@ +namespace ThreadPilot.Services +{ + using System.Diagnostics; + using System.IO; + using System.Text.Json; + using ThreadPilot.Models; + + public sealed class ConfigurationBackupService( + IApplicationSettingsService settings, + IProcessPowerPlanAssociationService associations, + IPersistentProcessRuleStore rules) + { + internal static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + public async Task CaptureAsync(ApplicationSettingsModel source) => new() + { + Settings = Clone(source), + ProcessMonitorConfiguration = Clone(associations.Configuration), + PersistentProcessRules = Clone((await rules.LoadAsync()).ToList()), + }; + + public static string Serialize(ConfigurationBackup backup) => JsonSerializer.Serialize(backup, JsonOptions); + + public static ConfigurationBackup? Parse(string json) + { + using var document = JsonDocument.Parse(json, new JsonDocumentOptions { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip }); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException("Configuration must be a JSON object."); + } + + if (!document.RootElement.EnumerateObject().Any(property => property.Name.Equals("settings", StringComparison.OrdinalIgnoreCase))) + { + return null; + } + + var backup = JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new InvalidDataException("Empty configuration."); + backup.ProcessMonitorConfiguration ??= backup.RulesConfiguration; + Validate(backup); + return backup; + } + + public static void Validate(ConfigurationBackup backup) + { + if (backup.SchemaVersion is not ("2.0" or "3.0") || backup.Settings == null || + backup.ProcessMonitorConfiguration?.Associations == null || + (backup.SchemaVersion == "3.0" && backup.PersistentProcessRules == null)) + { + throw new InvalidDataException("Unsupported or incomplete configuration bundle."); + } + + if (!backup.Settings.Validate().IsValid || backup.ProcessMonitorConfiguration.Validate().Count != 0) + { + throw new InvalidDataException("Invalid settings or power plan associations."); + } + + var ids = new HashSet(StringComparer.Ordinal); + foreach (var rule in backup.PersistentProcessRules ?? []) + { + if (rule == null || string.IsNullOrWhiteSpace(rule.Id) || !ids.Add(rule.Id) || + (string.IsNullOrWhiteSpace(rule.ProcessName) && string.IsNullOrWhiteSpace(rule.ExecutablePath)) || + !Enum.IsDefined(rule.CpuAssignmentMode) || + (rule.Priority.HasValue && (!Enum.IsDefined(rule.Priority.Value) || rule.Priority == ProcessPriorityClass.RealTime)) || + (rule.MemoryPriority.HasValue && !Enum.IsDefined(rule.MemoryPriority.Value)) || + (rule.IoPriority.HasValue && !Enum.IsDefined(rule.IoPriority.Value)) || + (rule.ApplyPriorityOnStart && !rule.Priority.HasValue) || + (rule.ApplyMemoryPriorityOnStart && !rule.MemoryPriority.HasValue) || + (rule.ApplyIoPriorityOnStart && !rule.IoPriority.HasValue) || + (rule.ApplyAffinityOnStart && rule.CpuSelection == null && rule.LegacyAffinityMask.GetValueOrDefault() == 0)) + { + throw new InvalidDataException("Invalid persistent process rule."); + } + } + } + + public async Task ApplyAsync(ConfigurationBackup backup) + { + Validate(backup); + var previous = await this.CaptureAsync(settings.Settings); + try + { + if (backup.PersistentProcessRules != null) + { + await rules.SaveAsync(backup.PersistentProcessRules); + } + + if (!await associations.ReplaceConfigurationAsync(backup.ProcessMonitorConfiguration!)) + { + throw new IOException("Could not save power plan associations."); + } + + await settings.UpdateSettingsAsync(backup.Settings!); + } + catch (Exception importError) + { + var errors = new List { importError }; + try + { + await rules.SaveAsync(previous.PersistentProcessRules!); + } + catch (Exception ex) + { + errors.Add(ex); + } + + try + { + if (!await associations.ReplaceConfigurationAsync(previous.ProcessMonitorConfiguration!)) + { + throw new IOException("Could not restore power plan associations."); + } + } + catch (Exception ex) + { + errors.Add(ex); + } + + try + { + await settings.UpdateSettingsAsync(previous.Settings!); + } + catch (Exception ex) + { + errors.Add(ex); + } + + if (errors.Count > 1) + { + throw new AggregateException("Import failed and restoration was incomplete.", errors); + } + + throw; + } + } + + private static T Clone(T source) => JsonSerializer.Deserialize(JsonSerializer.Serialize(source, JsonOptions), JsonOptions)!; + } + + public sealed class ConfigurationBackup + { + public string SchemaVersion { get; set; } = "3.0"; + + public DateTime ExportedAtUtc { get; set; } = DateTime.UtcNow; + + public ApplicationSettingsModel? Settings { get; set; } + + public ProcessMonitorConfiguration? ProcessMonitorConfiguration { get; set; } + + public ProcessMonitorConfiguration? RulesConfiguration { get; set; } + + public List? PersistentProcessRules { get; set; } + } +} diff --git a/Services/PersistentRuleResolution.cs b/Services/PersistentRuleResolution.cs new file mode 100644 index 0000000..c94d04d --- /dev/null +++ b/Services/PersistentRuleResolution.cs @@ -0,0 +1,26 @@ +namespace ThreadPilot.Services +{ + using ThreadPilot.Helpers; + using ThreadPilot.Models; + + public static class PersistentRuleResolution + { + // Apply broad rules first; more specific and then more recently edited rules win per setting. + public static IReadOnlyList MatchInApplyOrder( + IEnumerable rules, ProcessModel process, IPersistentProcessRuleMatcher matcher) => + rules.Where(rule => matcher.IsMatch(rule, process)) + .OrderBy(rule => string.IsNullOrWhiteSpace(rule.ExecutablePath) ? 0 : PathPatternMatcher.HasWildcard(rule.ExecutablePath) ? 1 : 2) + .ThenBy(rule => rule.ExecutablePath?.Count(character => character is not ('*' or '?')) ?? 0) + .ThenBy(rule => rule.UpdatedAt) + .ThenBy(rule => rule.Id, StringComparer.Ordinal) + .ToArray(); + + public static bool HasConflicts(IReadOnlyList rules) => + rules.Where(r => r.ApplyPriorityOnStart).Select(r => r.Priority).Distinct().Skip(1).Any() || + rules.Where(r => r.ApplyMemoryPriorityOnStart).Select(r => r.MemoryPriority).Distinct().Skip(1).Any() || + rules.Where(r => r.ApplyIoPriorityOnStart).Select(r => r.IoPriority).Distinct().Skip(1).Any() || + rules.Where(r => r.ApplyAffinityOnStart) + .Select(r => $"{r.CpuAssignmentMode}:{r.LegacyAffinityMask}:{string.Join(",", (r.CpuSelection?.GlobalLogicalProcessorIndexes ?? []).Order())}") + .Distinct().Skip(1).Any(); + } +} diff --git a/Services/PersistentRulesEngine.cs b/Services/PersistentRulesEngine.cs index c21fce8..98b3926 100644 --- a/Services/PersistentRulesEngine.cs +++ b/Services/PersistentRulesEngine.cs @@ -63,9 +63,8 @@ public async Task> ApplyMatchingRulesAs var rules = await this.ruleStore.LoadAsync().ConfigureAwait(false); var results = new List(); - foreach (var rule in rules.Where(rule => - (ruleFilter == null || ruleFilter(rule)) && - this.matcher.IsMatch(rule, process))) + foreach (var rule in PersistentRuleResolution.MatchInApplyOrder(rules, process, this.matcher) + .Where(rule => ruleFilter == null || ruleFilter(rule))) { cancellationToken.ThrowIfCancellationRequested(); results.Add(await this.ApplyRuleAsync(rule, process, cancellationToken).ConfigureAwait(false)); diff --git a/Services/ServiceConfiguration.cs b/Services/ServiceConfiguration.cs index 2a2d598..a129940 100644 --- a/Services/ServiceConfiguration.cs +++ b/Services/ServiceConfiguration.cs @@ -124,6 +124,7 @@ private static IServiceCollection ConfigureApplicationLevelServices(this IServic { // Application configuration and settings services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/Tests/ThreadPilot.Core.Tests/ConfigurationBackupTests.cs b/Tests/ThreadPilot.Core.Tests/ConfigurationBackupTests.cs new file mode 100644 index 0000000..a0e9dd5 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/ConfigurationBackupTests.cs @@ -0,0 +1,80 @@ +namespace ThreadPilot.Core.Tests +{ + using System.Diagnostics; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class ConfigurationBackupTests + { + [Fact] + public async Task ExportRoundTrip_PreservesPersistentRules() + { + var harness = new Harness(); + var backup = await harness.Service.CaptureAsync(harness.Settings.Object.Settings); + var imported = ConfigurationBackupService.Parse(ConfigurationBackupService.Serialize(backup)); + Assert.Equal("old", Assert.Single(imported!.PersistentProcessRules!).Id); + } + + [Fact] + public async Task LegacyBundle_KeepsPersistentRules() + { + var harness = new Harness(); + var backup = await harness.Service.CaptureAsync(harness.Settings.Object.Settings); + backup.SchemaVersion = "2.0"; + backup.PersistentProcessRules = null; + await harness.Service.ApplyAsync(ConfigurationBackupService.Parse(ConfigurationBackupService.Serialize(backup))!); + harness.Rules.Verify(x => x.SaveAsync(It.IsAny>()), Times.Never); + } + + [Fact] + public async Task FailedImport_RestoresAllPreviousConfiguration() + { + var harness = new Harness(); + harness.Associations.SetupSequence(x => x.ReplaceConfigurationAsync(It.IsAny())) + .ReturnsAsync(false).ReturnsAsync(true); + var backup = await harness.Service.CaptureAsync(harness.Settings.Object.Settings); + backup.PersistentProcessRules = [new() { Id = "new", ProcessName = "new.exe" }]; + await Assert.ThrowsAsync(() => harness.Service.ApplyAsync(backup)); + harness.Rules.Verify(x => x.SaveAsync(It.Is>(r => r.Single().Id == "old")), Times.Once); + harness.Settings.Verify(x => x.UpdateSettingsAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task InvalidRule_IsRejectedBeforeAnyWrite() + { + var harness = new Harness(); + var backup = await harness.Service.CaptureAsync(harness.Settings.Object.Settings); + backup.PersistentProcessRules = [new() { ProcessName = "game.exe", Priority = ProcessPriorityClass.RealTime }]; + await Assert.ThrowsAsync(() => harness.Service.ApplyAsync(backup)); + harness.Rules.Verify(x => x.SaveAsync(It.IsAny>()), Times.Never); + harness.Associations.Verify(x => x.ReplaceConfigurationAsync(It.IsAny()), Times.Never); + } + + [Fact] + public void IncompleteBundle_DoesNotFallBackToLegacySettingsImport() + { + Assert.Throws(() => ConfigurationBackupService.Parse("{\"settings\":{}}")); + } + + private sealed class Harness + { + public Mock Settings { get; } = new(); + + public Mock Associations { get; } = new(); + + public Mock Rules { get; } = new(); + + public ConfigurationBackupService Service { get; } + + public Harness() + { + this.Settings.SetupGet(x => x.Settings).Returns(new ApplicationSettingsModel()); + this.Associations.SetupGet(x => x.Configuration).Returns(new ProcessMonitorConfiguration()); + this.Associations.Setup(x => x.ReplaceConfigurationAsync(It.IsAny())).ReturnsAsync(true); + this.Rules.Setup(x => x.LoadAsync()).ReturnsAsync([new() { Id = "old", ProcessName = "old.exe" }]); + this.Service = new(this.Settings.Object, this.Associations.Object, this.Rules.Object); + } + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/PersistentRuleResolutionTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentRuleResolutionTests.cs new file mode 100644 index 0000000..9597d76 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/PersistentRuleResolutionTests.cs @@ -0,0 +1,38 @@ +namespace ThreadPilot.Core.Tests +{ + using System.Diagnostics; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class PersistentRuleResolutionTests + { + private static readonly ProcessModel Process = new() { Name = "game.exe", ExecutablePath = @"C:\Games\Foo\game.exe" }; + + [Fact] + public void MatchInApplyOrder_PutsMoreSpecificRulesLast() + { + var byName = Rule("name", processName: "game.exe"); + var exact = Rule("exact", path: @"C:\Games\Foo\game.exe"); + var wildcard = Rule("wildcard", path: @"C:\Games\*\game.exe"); + var other = Rule("other", processName: "other.exe"); + + var order = PersistentRuleResolution.MatchInApplyOrder([exact, other, wildcard, byName], Process, new PersistentProcessRuleMatcher()); + + Assert.Equal(["name", "wildcard", "exact"], order.Select(rule => rule.Id)); + } + + [Fact] + public void HasConflicts_OnlyWhenAppliedValuesDiffer() + { + var high = Rule("a", processName: "game.exe") with { Priority = ProcessPriorityClass.High, ApplyPriorityOnStart = true }; + var low = Rule("b", processName: "game.exe") with { Priority = ProcessPriorityClass.BelowNormal, ApplyPriorityOnStart = true }; + var unapplied = low with { Id = "c", ApplyPriorityOnStart = false }; + + Assert.True(PersistentRuleResolution.HasConflicts([high, low])); + Assert.False(PersistentRuleResolution.HasConflicts([high, unapplied])); + } + + private static PersistentProcessRule Rule(string id, string? processName = null, string? path = null) => + new() { Id = id, IsEnabled = true, ProcessName = processName, ExecutablePath = path }; + } +} diff --git a/ViewModels/SelectedProcessSummaryViewModel.cs b/ViewModels/SelectedProcessSummaryViewModel.cs index 4173dcd..3fbae50 100644 --- a/ViewModels/SelectedProcessSummaryViewModel.cs +++ b/ViewModels/SelectedProcessSummaryViewModel.cs @@ -1,413 +1,418 @@ -/* - * ThreadPilot - lightweight selected process summary view model. - */ -namespace ThreadPilot.ViewModels -{ - using System.Diagnostics; - using System.Threading; - using CommunityToolkit.Mvvm.ComponentModel; - using ThreadPilot.Models; - using ThreadPilot.Services; - - public sealed class SelectedProcessSummaryViewModel : ObservableObject - { - private readonly IProcessMemoryPriorityService? memoryPriorityService; - private readonly IPersistentProcessRuleStore? persistentRuleStore; - private readonly IPersistentProcessRuleMatcher? persistentRuleMatcher; - private readonly ILocalizationService? localizationService; - private bool hasSelection; - private int processId; - private string processName = string.Empty; - private string executablePath = string.Empty; - private double cpuUsage; - private long memoryUsage; - private ProcessPriorityClass cpuPriority; - private long processorAffinity; - private ProcessMemoryPriority? memoryPriority; - private ProcessIoPriority? ioPriority; - private string processTitle = "No process selected"; - private string currentProcessStatusText = "No process selected"; - private string cpuUsageText = "CPU: unavailable"; - private string memoryUsageText = "Memory: unavailable"; - private string cpuPriorityText = "CPU priority: unavailable"; - private string memoryPriorityText = "Memory priority unavailable"; - private string ioPriorityText = "I/O priority unavailable"; - private string affinityText = "Affinity: unavailable"; - private string ruleStatusText = "No saved rule"; - private string lastOperationMessage = "No recent ThreadPilot action"; - private string lastOperationSeverity = "Information"; - private bool isProtectedOrAccessDenied; - private bool hasThreadPilotRule; - private int updateVersion; - - public SelectedProcessSummaryViewModel( - IProcessMemoryPriorityService? memoryPriorityService = null, - IPersistentProcessRuleStore? persistentRuleStore = null, - IPersistentProcessRuleMatcher? persistentRuleMatcher = null, - ILocalizationService? localizationService = null) - { - this.memoryPriorityService = memoryPriorityService; - this.persistentRuleStore = persistentRuleStore; - this.persistentRuleMatcher = persistentRuleMatcher; - this.localizationService = localizationService; - this.Clear(version: 0, lastOperationMessage: null, lastOperationIsError: false); - } - - public bool HasSelection - { - get => this.hasSelection; - private set => this.SetProperty(ref this.hasSelection, value); - } - - public int ProcessId - { - get => this.processId; - private set => this.SetProperty(ref this.processId, value); - } - - public string ProcessName - { - get => this.processName; - private set => this.SetProperty(ref this.processName, value); - } - - public string ExecutablePath - { - get => this.executablePath; - private set => this.SetProperty(ref this.executablePath, value); - } - - public double CpuUsage - { - get => this.cpuUsage; - private set => this.SetProperty(ref this.cpuUsage, value); - } - - public long MemoryUsage - { - get => this.memoryUsage; - private set => this.SetProperty(ref this.memoryUsage, value); - } - - public ProcessPriorityClass CpuPriority - { - get => this.cpuPriority; - private set => this.SetProperty(ref this.cpuPriority, value); - } - - public long ProcessorAffinity - { - get => this.processorAffinity; - private set => this.SetProperty(ref this.processorAffinity, value); - } - - public ProcessMemoryPriority? MemoryPriority - { - get => this.memoryPriority; - private set => this.SetProperty(ref this.memoryPriority, value); - } - - public ProcessIoPriority? IoPriority - { - get => this.ioPriority; - private set => this.SetProperty(ref this.ioPriority, value); - } - - public string ProcessTitle - { - get => this.processTitle; - private set => this.SetProperty(ref this.processTitle, value); - } - - public string CurrentProcessStatusText - { - get => this.currentProcessStatusText; - private set => this.SetProperty(ref this.currentProcessStatusText, value); - } - - public string CpuUsageText - { - get => this.cpuUsageText; - private set => this.SetProperty(ref this.cpuUsageText, value); - } - - public string MemoryUsageText - { - get => this.memoryUsageText; - private set => this.SetProperty(ref this.memoryUsageText, value); - } - - public string CpuPriorityText - { - get => this.cpuPriorityText; - private set => this.SetProperty(ref this.cpuPriorityText, value); - } - - public string MemoryPriorityText - { - get => this.memoryPriorityText; - private set => this.SetProperty(ref this.memoryPriorityText, value); - } - - public string IoPriorityText - { - get => this.ioPriorityText; - private set => this.SetProperty(ref this.ioPriorityText, value); - } - - public string AffinityText - { - get => this.affinityText; - private set => this.SetProperty(ref this.affinityText, value); - } - - public string RuleStatusText - { - get => this.ruleStatusText; - private set => this.SetProperty(ref this.ruleStatusText, value); - } - - public string LastOperationMessage - { - get => this.lastOperationMessage; - private set => this.SetProperty(ref this.lastOperationMessage, value); - } - - public string LastOperationSeverity - { - get => this.lastOperationSeverity; - private set => this.SetProperty(ref this.lastOperationSeverity, value); - } - - public bool IsProtectedOrAccessDenied - { - get => this.isProtectedOrAccessDenied; - private set => this.SetProperty(ref this.isProtectedOrAccessDenied, value); - } - - public bool HasThreadPilotRule - { - get => this.hasThreadPilotRule; - private set => this.SetProperty(ref this.hasThreadPilotRule, value); - } - - public async Task UpdateAsync( - ProcessModel? process, - string? lastOperationMessage = null, - bool lastOperationIsError = false) - { - var version = Interlocked.Increment(ref this.updateVersion); - if (process == null) - { - this.Clear(version, lastOperationMessage, lastOperationIsError); - return; - } - - this.HasSelection = true; - this.ProcessId = process.ProcessId; - this.ProcessName = process.Name ?? string.Empty; - this.ExecutablePath = process.ExecutablePath ?? string.Empty; - this.CpuUsage = process.CpuUsage; - this.MemoryUsage = process.MemoryUsage; - this.CpuPriority = process.Priority; - this.ProcessorAffinity = process.ProcessorAffinity; - this.IsProtectedOrAccessDenied = process.Classification == ProcessClassification.ProtectedOrAccessDenied; - this.ProcessTitle = this.L("ProcessSummary_SelectedProcessFormat", "Selected process: {0} (PID {1})", this.ProcessName, this.ProcessId); - this.CurrentProcessStatusText = this.IsProtectedOrAccessDenied - ? this.L("ProcessSummary_StatusProtected", "Current process status: protected or access denied") - : this.L("ProcessSummary_StatusSelected", "Current process status: selected"); - this.CpuUsageText = this.L("ProcessSummary_CpuFormat", "CPU: {0:N1}%", this.CpuUsage); - this.MemoryUsageText = this.L("ProcessSummary_MemoryFormat", "Memory: {0}", FormatMemory(this.MemoryUsage)); - this.CpuPriorityText = this.L("ProcessSummary_CpuPriorityFormat", "CPU priority: {0}", this.CpuPriority); - this.AffinityText = this.L("ProcessSummary_AffinityLegacyFormat", "Affinity: legacy mask 0x{0:X}", this.ProcessorAffinity); - this.UpdateLastOperation(lastOperationMessage, lastOperationIsError); - - await this.UpdateMemoryPriorityAsync(process, version); - if (!this.IsCurrentVersion(version)) - { - return; - } - - await this.UpdateIoPriorityAsync(process, version); - if (!this.IsCurrentVersion(version)) - { - return; - } - - await this.UpdateRuleStatusAsync(process, version); - } - - private static string FormatMemory(long bytes) - { - if (bytes <= 0) - { - return "0 MB"; - } - - var megabytes = bytes / 1024d / 1024d; - return $"{megabytes:N0} MB"; - } - - private void Clear(int version, string? lastOperationMessage, bool lastOperationIsError) - { - if (!this.IsCurrentVersion(version)) - { - return; - } - - this.HasSelection = false; - this.ProcessId = 0; - this.ProcessName = string.Empty; - this.ExecutablePath = string.Empty; - this.CpuUsage = 0; - this.MemoryUsage = 0; - this.CpuPriority = default; - this.ProcessorAffinity = 0; - this.MemoryPriority = null; - this.IoPriority = null; - this.IsProtectedOrAccessDenied = false; - this.HasThreadPilotRule = false; - this.ProcessTitle = this.L("ProcessView_NoProcessSelected", "No process selected"); - this.CurrentProcessStatusText = this.L("ProcessView_NoProcessSelected", "No process selected"); - this.CpuUsageText = this.L("ProcessSummary_CpuUnavailable", "CPU: unavailable"); - this.MemoryUsageText = this.L("ProcessSummary_MemoryUnavailable", "Memory: unavailable"); - this.CpuPriorityText = this.L("ProcessSummary_CpuPriorityUnavailable", "CPU priority: unavailable"); - this.MemoryPriorityText = this.L("ProcessSummary_MemoryPriorityUnavailable", "Memory priority unavailable"); - this.IoPriorityText = this.L("ProcessSummary_IoPriorityUnavailable", "I/O priority unavailable"); - this.AffinityText = this.L("ProcessSummary_AffinityUnavailable", "Affinity: unavailable"); - this.RuleStatusText = this.L("ProcessSummary_NoSavedRule", "No saved rule"); - this.UpdateLastOperation(lastOperationMessage, lastOperationIsError); - } - - private async Task UpdateMemoryPriorityAsync(ProcessModel process, int version) - { - this.MemoryPriority = null; - this.MemoryPriorityText = this.L("ProcessSummary_MemoryPriorityUnavailable", "Memory priority unavailable"); - - if (this.memoryPriorityService == null) - { - return; - } - - try - { - var priority = await this.memoryPriorityService.GetMemoryPriorityAsync(process); - if (!this.IsCurrentVersion(version)) - { - return; - } - - if (priority == null) - { - return; - } - - this.MemoryPriority = priority.Value; - this.MemoryPriorityText = this.L("ProcessSummary_MemoryPriorityFormat", "Memory priority: {0}", priority.Value); - } - catch (Exception) - { - if (!this.IsCurrentVersion(version)) - { - return; - } - - this.MemoryPriority = null; - this.MemoryPriorityText = this.L("ProcessSummary_MemoryPriorityUnavailable", "Memory priority unavailable"); - } - } - - private async Task UpdateIoPriorityAsync(ProcessModel process, int version) - { - this.IoPriority = null; - this.IoPriorityText = this.L("ProcessSummary_IoPriorityUnavailable", "I/O priority unavailable"); - if (this.memoryPriorityService == null) - { - return; - } - - try - { - var priority = await this.memoryPriorityService.GetIoPriorityAsync(process); - if (!this.IsCurrentVersion(version) || priority == null) - { - return; - } - - this.IoPriority = priority.Value; - this.IoPriorityText = this.L("ProcessSummary_IoPriorityFormat", "I/O priority: {0}", priority.Value); - } - catch (Exception) - { - this.IoPriority = null; - } - } - - private async Task UpdateRuleStatusAsync(ProcessModel process, int version) - { - this.HasThreadPilotRule = false; - this.RuleStatusText = this.L("ProcessSummary_NoSavedRule", "No saved rule"); - - if (this.persistentRuleStore == null || this.persistentRuleMatcher == null) - { - return; - } - - try - { - var rules = await this.persistentRuleStore.LoadAsync(); - if (!this.IsCurrentVersion(version)) - { - return; - } - - var matchingRule = rules.FirstOrDefault(rule => this.persistentRuleMatcher.IsMatch(rule, process)); - if (matchingRule == null) - { - return; - } - - this.HasThreadPilotRule = true; - var ruleName = string.IsNullOrWhiteSpace(matchingRule.Name) - ? this.L("ProcessSummary_SavedRuleFallback", "saved rule") - : matchingRule.Name.Trim(); - this.RuleStatusText = this.L("ProcessSummary_SavedRuleExistsFormat", "Saved rule exists: {0}", ruleName); - } - catch (Exception) - { - if (!this.IsCurrentVersion(version)) - { - return; - } - - this.HasThreadPilotRule = false; - this.RuleStatusText = this.L("ProcessSummary_NoSavedRule", "No saved rule"); - } - } - - private bool IsCurrentVersion(int version) => Volatile.Read(ref this.updateVersion) == version; - - private void UpdateLastOperation(string? message, bool isError) - { - if (string.IsNullOrWhiteSpace(message)) - { - this.LastOperationMessage = this.L("ProcessSummary_NoRecentAction", "No recent ThreadPilot action"); - this.LastOperationSeverity = "Information"; - return; - } - - this.LastOperationMessage = message.Trim(); - this.LastOperationSeverity = isError ? "Error" : "Information"; - } - - private string L(string key, string fallback, params object[] args) - { - var localized = this.localizationService?.GetString(key); - var format = string.IsNullOrWhiteSpace(localized) || string.Equals(localized, key, StringComparison.Ordinal) - ? fallback - : localized; - - return args.Length == 0 ? format : string.Format(format, args); - } - } -} +/* + * ThreadPilot - lightweight selected process summary view model. + */ +namespace ThreadPilot.ViewModels +{ + using System.Diagnostics; + using System.Threading; + using CommunityToolkit.Mvvm.ComponentModel; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class SelectedProcessSummaryViewModel : ObservableObject + { + private readonly IProcessMemoryPriorityService? memoryPriorityService; + private readonly IPersistentProcessRuleStore? persistentRuleStore; + private readonly IPersistentProcessRuleMatcher? persistentRuleMatcher; + private readonly ILocalizationService? localizationService; + private bool hasSelection; + private int processId; + private string processName = string.Empty; + private string executablePath = string.Empty; + private double cpuUsage; + private long memoryUsage; + private ProcessPriorityClass cpuPriority; + private long processorAffinity; + private ProcessMemoryPriority? memoryPriority; + private ProcessIoPriority? ioPriority; + private string processTitle = "No process selected"; + private string currentProcessStatusText = "No process selected"; + private string cpuUsageText = "CPU: unavailable"; + private string memoryUsageText = "Memory: unavailable"; + private string cpuPriorityText = "CPU priority: unavailable"; + private string memoryPriorityText = "Memory priority unavailable"; + private string ioPriorityText = "I/O priority unavailable"; + private string affinityText = "Affinity: unavailable"; + private string ruleStatusText = "No saved rule"; + private string lastOperationMessage = "No recent ThreadPilot action"; + private string lastOperationSeverity = "Information"; + private bool isProtectedOrAccessDenied; + private bool hasThreadPilotRule; + private int updateVersion; + + public SelectedProcessSummaryViewModel( + IProcessMemoryPriorityService? memoryPriorityService = null, + IPersistentProcessRuleStore? persistentRuleStore = null, + IPersistentProcessRuleMatcher? persistentRuleMatcher = null, + ILocalizationService? localizationService = null) + { + this.memoryPriorityService = memoryPriorityService; + this.persistentRuleStore = persistentRuleStore; + this.persistentRuleMatcher = persistentRuleMatcher; + this.localizationService = localizationService; + this.Clear(version: 0, lastOperationMessage: null, lastOperationIsError: false); + } + + public bool HasSelection + { + get => this.hasSelection; + private set => this.SetProperty(ref this.hasSelection, value); + } + + public int ProcessId + { + get => this.processId; + private set => this.SetProperty(ref this.processId, value); + } + + public string ProcessName + { + get => this.processName; + private set => this.SetProperty(ref this.processName, value); + } + + public string ExecutablePath + { + get => this.executablePath; + private set => this.SetProperty(ref this.executablePath, value); + } + + public double CpuUsage + { + get => this.cpuUsage; + private set => this.SetProperty(ref this.cpuUsage, value); + } + + public long MemoryUsage + { + get => this.memoryUsage; + private set => this.SetProperty(ref this.memoryUsage, value); + } + + public ProcessPriorityClass CpuPriority + { + get => this.cpuPriority; + private set => this.SetProperty(ref this.cpuPriority, value); + } + + public long ProcessorAffinity + { + get => this.processorAffinity; + private set => this.SetProperty(ref this.processorAffinity, value); + } + + public ProcessMemoryPriority? MemoryPriority + { + get => this.memoryPriority; + private set => this.SetProperty(ref this.memoryPriority, value); + } + + public ProcessIoPriority? IoPriority + { + get => this.ioPriority; + private set => this.SetProperty(ref this.ioPriority, value); + } + + public string ProcessTitle + { + get => this.processTitle; + private set => this.SetProperty(ref this.processTitle, value); + } + + public string CurrentProcessStatusText + { + get => this.currentProcessStatusText; + private set => this.SetProperty(ref this.currentProcessStatusText, value); + } + + public string CpuUsageText + { + get => this.cpuUsageText; + private set => this.SetProperty(ref this.cpuUsageText, value); + } + + public string MemoryUsageText + { + get => this.memoryUsageText; + private set => this.SetProperty(ref this.memoryUsageText, value); + } + + public string CpuPriorityText + { + get => this.cpuPriorityText; + private set => this.SetProperty(ref this.cpuPriorityText, value); + } + + public string MemoryPriorityText + { + get => this.memoryPriorityText; + private set => this.SetProperty(ref this.memoryPriorityText, value); + } + + public string IoPriorityText + { + get => this.ioPriorityText; + private set => this.SetProperty(ref this.ioPriorityText, value); + } + + public string AffinityText + { + get => this.affinityText; + private set => this.SetProperty(ref this.affinityText, value); + } + + public string RuleStatusText + { + get => this.ruleStatusText; + private set => this.SetProperty(ref this.ruleStatusText, value); + } + + public string LastOperationMessage + { + get => this.lastOperationMessage; + private set => this.SetProperty(ref this.lastOperationMessage, value); + } + + public string LastOperationSeverity + { + get => this.lastOperationSeverity; + private set => this.SetProperty(ref this.lastOperationSeverity, value); + } + + public bool IsProtectedOrAccessDenied + { + get => this.isProtectedOrAccessDenied; + private set => this.SetProperty(ref this.isProtectedOrAccessDenied, value); + } + + public bool HasThreadPilotRule + { + get => this.hasThreadPilotRule; + private set => this.SetProperty(ref this.hasThreadPilotRule, value); + } + + public async Task UpdateAsync( + ProcessModel? process, + string? lastOperationMessage = null, + bool lastOperationIsError = false) + { + var version = Interlocked.Increment(ref this.updateVersion); + if (process == null) + { + this.Clear(version, lastOperationMessage, lastOperationIsError); + return; + } + + this.HasSelection = true; + this.ProcessId = process.ProcessId; + this.ProcessName = process.Name ?? string.Empty; + this.ExecutablePath = process.ExecutablePath ?? string.Empty; + this.CpuUsage = process.CpuUsage; + this.MemoryUsage = process.MemoryUsage; + this.CpuPriority = process.Priority; + this.ProcessorAffinity = process.ProcessorAffinity; + this.IsProtectedOrAccessDenied = process.Classification == ProcessClassification.ProtectedOrAccessDenied; + this.ProcessTitle = this.L("ProcessSummary_SelectedProcessFormat", "Selected process: {0} (PID {1})", this.ProcessName, this.ProcessId); + this.CurrentProcessStatusText = this.IsProtectedOrAccessDenied + ? this.L("ProcessSummary_StatusProtected", "Current process status: protected or access denied") + : this.L("ProcessSummary_StatusSelected", "Current process status: selected"); + this.CpuUsageText = this.L("ProcessSummary_CpuFormat", "CPU: {0:N1}%", this.CpuUsage); + this.MemoryUsageText = this.L("ProcessSummary_MemoryFormat", "Memory: {0}", FormatMemory(this.MemoryUsage)); + this.CpuPriorityText = this.L("ProcessSummary_CpuPriorityFormat", "CPU priority: {0}", this.CpuPriority); + this.AffinityText = this.L("ProcessSummary_AffinityLegacyFormat", "Affinity: legacy mask 0x{0:X}", this.ProcessorAffinity); + this.UpdateLastOperation(lastOperationMessage, lastOperationIsError); + + await this.UpdateMemoryPriorityAsync(process, version); + if (!this.IsCurrentVersion(version)) + { + return; + } + + await this.UpdateIoPriorityAsync(process, version); + if (!this.IsCurrentVersion(version)) + { + return; + } + + await this.UpdateRuleStatusAsync(process, version); + } + + private static string FormatMemory(long bytes) + { + if (bytes <= 0) + { + return "0 MB"; + } + + var megabytes = bytes / 1024d / 1024d; + return $"{megabytes:N0} MB"; + } + + private void Clear(int version, string? lastOperationMessage, bool lastOperationIsError) + { + if (!this.IsCurrentVersion(version)) + { + return; + } + + this.HasSelection = false; + this.ProcessId = 0; + this.ProcessName = string.Empty; + this.ExecutablePath = string.Empty; + this.CpuUsage = 0; + this.MemoryUsage = 0; + this.CpuPriority = default; + this.ProcessorAffinity = 0; + this.MemoryPriority = null; + this.IoPriority = null; + this.IsProtectedOrAccessDenied = false; + this.HasThreadPilotRule = false; + this.ProcessTitle = this.L("ProcessView_NoProcessSelected", "No process selected"); + this.CurrentProcessStatusText = this.L("ProcessView_NoProcessSelected", "No process selected"); + this.CpuUsageText = this.L("ProcessSummary_CpuUnavailable", "CPU: unavailable"); + this.MemoryUsageText = this.L("ProcessSummary_MemoryUnavailable", "Memory: unavailable"); + this.CpuPriorityText = this.L("ProcessSummary_CpuPriorityUnavailable", "CPU priority: unavailable"); + this.MemoryPriorityText = this.L("ProcessSummary_MemoryPriorityUnavailable", "Memory priority unavailable"); + this.IoPriorityText = this.L("ProcessSummary_IoPriorityUnavailable", "I/O priority unavailable"); + this.AffinityText = this.L("ProcessSummary_AffinityUnavailable", "Affinity: unavailable"); + this.RuleStatusText = this.L("ProcessSummary_NoSavedRule", "No saved rule"); + this.UpdateLastOperation(lastOperationMessage, lastOperationIsError); + } + + private async Task UpdateMemoryPriorityAsync(ProcessModel process, int version) + { + this.MemoryPriority = null; + this.MemoryPriorityText = this.L("ProcessSummary_MemoryPriorityUnavailable", "Memory priority unavailable"); + + if (this.memoryPriorityService == null) + { + return; + } + + try + { + var priority = await this.memoryPriorityService.GetMemoryPriorityAsync(process); + if (!this.IsCurrentVersion(version)) + { + return; + } + + if (priority == null) + { + return; + } + + this.MemoryPriority = priority.Value; + this.MemoryPriorityText = this.L("ProcessSummary_MemoryPriorityFormat", "Memory priority: {0}", priority.Value); + } + catch (Exception) + { + if (!this.IsCurrentVersion(version)) + { + return; + } + + this.MemoryPriority = null; + this.MemoryPriorityText = this.L("ProcessSummary_MemoryPriorityUnavailable", "Memory priority unavailable"); + } + } + + private async Task UpdateIoPriorityAsync(ProcessModel process, int version) + { + this.IoPriority = null; + this.IoPriorityText = this.L("ProcessSummary_IoPriorityUnavailable", "I/O priority unavailable"); + if (this.memoryPriorityService == null) + { + return; + } + + try + { + var priority = await this.memoryPriorityService.GetIoPriorityAsync(process); + if (!this.IsCurrentVersion(version) || priority == null) + { + return; + } + + this.IoPriority = priority.Value; + this.IoPriorityText = this.L("ProcessSummary_IoPriorityFormat", "I/O priority: {0}", priority.Value); + } + catch (Exception) + { + this.IoPriority = null; + } + } + + private async Task UpdateRuleStatusAsync(ProcessModel process, int version) + { + this.HasThreadPilotRule = false; + this.RuleStatusText = this.L("ProcessSummary_NoSavedRule", "No saved rule"); + + if (this.persistentRuleStore == null || this.persistentRuleMatcher == null) + { + return; + } + + try + { + var rules = await this.persistentRuleStore.LoadAsync(); + if (!this.IsCurrentVersion(version)) + { + return; + } + + var matches = PersistentRuleResolution.MatchInApplyOrder(rules, process, this.persistentRuleMatcher); + if (matches.Count == 0) + { + return; + } + + this.HasThreadPilotRule = true; + var names = string.Join(" → ", matches.Select(rule => string.IsNullOrWhiteSpace(rule.Name) + ? this.L("ProcessSummary_SavedRuleFallback", "saved rule") : rule.Name.Trim())); + this.RuleStatusText = matches.Count == 1 + ? this.L("ProcessSummary_SavedRuleExistsFormat", "Saved rule exists: {0}", names) + : this.L("ProcessSummary_RuleOrder", "Rules in apply order: {0}. Later rules win for overlapping settings.", names); + if (PersistentRuleResolution.HasConflicts(matches)) + { + this.RuleStatusText += " " + this.L("ProcessSummary_RuleConflict", "Conflicting values: review these rules."); + } + } + catch (Exception) + { + if (!this.IsCurrentVersion(version)) + { + return; + } + + this.HasThreadPilotRule = false; + this.RuleStatusText = this.L("ProcessSummary_NoSavedRule", "No saved rule"); + } + } + + private bool IsCurrentVersion(int version) => Volatile.Read(ref this.updateVersion) == version; + + private void UpdateLastOperation(string? message, bool isError) + { + if (string.IsNullOrWhiteSpace(message)) + { + this.LastOperationMessage = this.L("ProcessSummary_NoRecentAction", "No recent ThreadPilot action"); + this.LastOperationSeverity = "Information"; + return; + } + + this.LastOperationMessage = message.Trim(); + this.LastOperationSeverity = isError ? "Error" : "Information"; + } + + private string L(string key, string fallback, params object[] args) + { + var localized = this.localizationService?.GetString(key); + var format = string.IsNullOrWhiteSpace(localized) || string.Equals(localized, key, StringComparison.Ordinal) + ? fallback + : localized; + + return args.Length == 0 ? format : string.Format(format, args); + } + } +} diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs index 463ff9c..cdd1c7d 100644 --- a/ViewModels/SettingsViewModel.cs +++ b/ViewModels/SettingsViewModel.cs @@ -35,21 +35,13 @@ public partial class SettingsViewModel : BaseViewModel private readonly IApplicationVersionProvider versionProvider; private readonly ILocalizationService localizationService; private readonly ICpuTopologyProvider? cpuTopologyProvider; + private readonly ConfigurationBackupService? backupService; private ApplicationSettingsModel savedSettingsSnapshot; private bool isSyncingFromService = false; private bool? appliedThemePreference; private string cachedDefaultPowerPlanGuid = string.Empty; private string cachedDefaultPowerPlanName = string.Empty; private UpdateReleaseInfo? availableUpdate; - private static readonly JsonSerializerOptions ImportExportJsonOptions = new() - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - ReadCommentHandling = JsonCommentHandling.Skip, - AllowTrailingCommas = true, - }; - [ObservableProperty] private ApplicationSettingsModel settings; @@ -113,7 +105,8 @@ public SettingsViewModel( ILocalizationService localizationService, IEnhancedLoggingService? enhancedLoggingService = null, IActivityAuditService? activityAuditService = null, - ICpuTopologyProvider? cpuTopologyProvider = null) + ICpuTopologyProvider? cpuTopologyProvider = null, + ConfigurationBackupService? backupService = null) : base(logger, enhancedLoggingService, activityAuditService) { this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); @@ -128,6 +121,7 @@ public SettingsViewModel( this.versionProvider = versionProvider ?? throw new ArgumentNullException(nameof(versionProvider)); this.localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService)); this.cpuTopologyProvider = cpuTopologyProvider; + this.backupService = backupService; this.ApplicationVersion = this.versionProvider.DisplayVersion; @@ -467,18 +461,9 @@ private async Task ExportSettingsAsync() return; } - var settingsSnapshot = (ApplicationSettingsModel)this.Settings.Clone(); - var rulesSnapshot = CloneConfiguration(this.associationService.Configuration); - - var bundle = new ConfigurationBundle - { - SchemaVersion = "2.0", - ExportedAtUtc = DateTime.UtcNow, - Settings = settingsSnapshot, - ProcessMonitorConfiguration = rulesSnapshot, - }; - - var json = JsonSerializer.Serialize(bundle, ImportExportJsonOptions); + var backup = this.backupService ?? throw new InvalidOperationException("Backup service unavailable."); + var bundle = await backup.CaptureAsync(this.Settings); + var json = ConfigurationBackupService.Serialize(bundle); await AtomicFileWriter.WriteAllTextAsync(saveDialog.FileName, json, Encoding.UTF8); this.StatusMessage = this.GetLocalizedString("Settings_StatusExportedFormat", "Configuration exported to: {0}", saveDialog.FileName); @@ -531,17 +516,21 @@ private async Task ImportSettingsAsync() var importPath = openDialog.FileName; var json = await File.ReadAllTextAsync(importPath); - if (TryParseBundle(json, out var bundle)) + var bundle = ConfigurationBackupService.Parse(json); + if (bundle != null) { - await this.settingsService.UpdateSettingsAsync(bundle.Settings); - - var importedConfiguration = bundle.ProcessMonitorConfiguration ?? new ProcessMonitorConfiguration(); - var replaced = await this.associationService.ReplaceConfigurationAsync(importedConfiguration); - if (!replaced) + var preview = this.GetLocalizedString("Settings_ImportPreview", "Replace settings and {0} power plan associations. Persistent process rules: {1}. Continue?", + bundle.ProcessMonitorConfiguration!.Associations.Count, + bundle.PersistentProcessRules == null + ? this.GetLocalizedString("Settings_ImportKeepRules", "keep existing rules") + : this.GetLocalizedString("Settings_ImportReplaceRules", "replace with {0} imported rules", bundle.PersistentProcessRules.Count)); + if (MessageBox.Show(preview, this.GetLocalizedString("Settings_DialogImportTitle", "Import ThreadPilot Configuration"), + MessageBoxButton.OKCancel, MessageBoxImage.Question, MessageBoxResult.Cancel) != MessageBoxResult.OK) { - throw new InvalidOperationException("Failed to apply imported rules configuration"); + return; } + await (this.backupService ?? throw new InvalidOperationException("Backup service unavailable.")).ApplyAsync(bundle); await this.processMonitorManagerService.RefreshConfigurationAsync(); this.processMonitorManagerService.UpdateSettings(); await this.RefreshSettingsAsync(); @@ -920,71 +909,5 @@ private void SetSavedSettingsSnapshot(ApplicationSettingsModel settingsSnapshot) this.HasUnsavedChanges = false; } - private static bool TryParseBundle(string json, out ConfigurationBundle bundle) - { - bundle = new ConfigurationBundle(); - - try - { - using var document = JsonDocument.Parse(json); - if (document.RootElement.ValueKind != JsonValueKind.Object) - { - return false; - } - - if (!document.RootElement.TryGetProperty("settings", out var settingsElement)) - { - return false; - } - - if (!document.RootElement.TryGetProperty("processMonitorConfiguration", out var rulesElement) && - !document.RootElement.TryGetProperty("rulesConfiguration", out rulesElement)) - { - return false; - } - - var parsedBundle = JsonSerializer.Deserialize(json, ImportExportJsonOptions); - if (parsedBundle?.Settings == null) - { - return false; - } - - parsedBundle.ProcessMonitorConfiguration = - parsedBundle.ProcessMonitorConfiguration - ?? parsedBundle.RulesConfiguration - ?? JsonSerializer.Deserialize(rulesElement.GetRawText(), ImportExportJsonOptions) - ?? new ProcessMonitorConfiguration(); - - parsedBundle.ProcessMonitorConfiguration.Associations ??= new List(); - bundle = parsedBundle; - return true; - } - catch (JsonException) - { - return false; - } - } - - private static ProcessMonitorConfiguration CloneConfiguration(ProcessMonitorConfiguration source) - { - var serialized = JsonSerializer.Serialize(source, ImportExportJsonOptions); - var clone = JsonSerializer.Deserialize(serialized, ImportExportJsonOptions) - ?? new ProcessMonitorConfiguration(); - clone.Associations ??= new List(); - return clone; - } - - private sealed class ConfigurationBundle - { - public string SchemaVersion { get; set; } = "2.0"; - - public DateTime ExportedAtUtc { get; set; } = DateTime.UtcNow; - - public ApplicationSettingsModel Settings { get; set; } = new ApplicationSettingsModel(); - - public ProcessMonitorConfiguration? ProcessMonitorConfiguration { get; set; } - - public ProcessMonitorConfiguration? RulesConfiguration { get; set; } - } } } diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..aff8cf9 --- /dev/null +++ b/docs/IMPLEMENTATION_PLAN.md @@ -0,0 +1,27 @@ +# Miglioramenti ThreadPilot — 12 ottobre 2026 + +Base: main `70b7f6d` (PR #65). Piano approvato da Lorenzo; implementazione locale sul branch `codex/ux-rules-reliability`. + +## Ordine di esecuzione + +- [ ] 1. Backup completo: esportare anche le regole persistenti, mantenere compatibilità con i backup precedenti, validare prima di applicare, mostrare il riepilogo delle sostituzioni e ripristinare in caso di errore. +- [ ] 2. Regole coerenti: precedenza deterministica, elenco delle corrispondenze e avviso dei conflitti; usare la stessa selezione nel motore e nel riepilogo. +- [ ] 3. Processi: elenco con colonne opzionali, pannello dettagli adattivo, priorità CPU/memoria/I/O visibili, comandi applica/salva chiari, sezione avanzata chiusa inizialmente, contatore comprensibile. +- [ ] 4. Editor: distinguere automazioni energetiche e impostazioni dei processi; preparare tutte le modifiche e salvarle insieme; segnalare modifiche pendenti. +- [ ] 5. Pausa automazioni: mantenere disponibile il controllo manuale e mostrare un avviso persistente. +- [ ] 6. Accessibilità e lingua: testi operativi leggibili, descrizioni dinamiche localizzate, focus/tastiera, etichette e layout ridimensionabile. +- [ ] 7. Ripristino: annullare le modifiche manuali di priorità sullo stesso processo; per i tweak indicare ambito, riavvio e comportamento di ripristino, usando i backup esistenti. +- [ ] 8. Reattività: misurare startup/consumi, eliminare le attese sincrone evitabili nell'avvio conservando elevazione, istanza unica e gestione errori; mantenere la sospensione degli aggiornamenti nascosti. +- [ ] 9. Verifica finale: test mirati, suite completa, build Debug/Release, formattazione, controllo XAML/localizzazioni e misure ripetibili; documentare limiti del collaudo grafico. + +## Ambito e cautele + +Servizi di persistenza/regole, view model e viste Processi/Regole/Impostazioni/Tweak, startup, dizionari delle sette lingue e relativi test. Nessuna nuova dipendenza o migrazione del framework. Importazioni e rollback devono preservare i dati; i comandi di ripristino non devono agire su PID riutilizzati. Le precedenze nuove devono essere visibili e testate. + +## Pulizia e verifica + +Sostituito `IMPROVE_APP_PERFORMANCE_PLAN.md`, relativo alle release 1.4.3 e PR #35/#36 ormai superate. Le ottimizzazioni già presenti (cache regole, caricamento pagine differito, aggiornamenti sospesi in tray) vengono conservate. Vecchia misura storica: tray 0,0000% CPU nel campione, 140,24 MB working set, zero processi figli; non è una baseline dell'attuale macchina/versione. Documenti di release e licenze restano documentazione storica, non piani da cancellare. + +## Risultati + +Da aggiornare durante l'implementazione. From 2eeec2170288c631a499d92962d3c108e06a53b6 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Wed, 14 Oct 2026 17:06:42 +0200 Subject: [PATCH 02/21] feat: clearer process and rule editors - Process details: CPU, memory and I/O priority pickers with an explicit Apply, undo of the last manual priority change (guarded against reused PIDs), adaptive side/bottom details panel. - Manual process control stays available while automation is paused, with a persistent notice instead of an overlay. - Saved-rule editor stages all edits (including the core mask) and saves them together, showing pending changes. - Localized the new strings in all seven languages. --- Locales/de-DE.xaml | 7 + Locales/en-US.xaml | 7 + Locales/es-ES.xaml | 7 + Locales/fr-FR.xaml | 7 + Locales/it-IT.xaml | 7 + Locales/ru-RU.xaml | 7 + Locales/zh-CN.xaml | 7 + Services/ProcessRuleCreationService.cs | 29 +++- .../ProcessViewModelContextMenuTests.cs | 3 + .../ProcessViewXamlBindingTests.cs | 85 +++++------ .../ProcessPowerPlanAssociationViewModel.cs | 37 +++-- .../ProcessViewModel.Behaviors.partial.cs | 140 +++++++++--------- ViewModels/ProcessViewModel.PriorityEditor.cs | 121 +++++++++++++++ ViewModels/ProcessViewModel.cs | 9 +- Views/ProcessPowerPlanAssociationView.xaml | 21 ++- Views/ProcessView.xaml | 98 +++++++----- Views/ProcessView.xaml.cs | 9 ++ docs/IMPLEMENTATION_PLAN.md | 56 +++---- 18 files changed, 444 insertions(+), 213 deletions(-) create mode 100644 ViewModels/ProcessViewModel.PriorityEditor.cs diff --git a/Locales/de-DE.xaml b/Locales/de-DE.xaml index 0f87acd..755ccf6 100644 --- a/Locales/de-DE.xaml +++ b/Locales/de-DE.xaml @@ -769,4 +769,11 @@ Einstellungen und {0} Energiesparplan-Zuordnungen ersetzen. Prozessregeln: {1}. Fortfahren? vorhandene Regeln behalten durch {0} importierte Regeln ersetzen + + + Anwenden + Automatisierung pausiert. Manuelle Änderungen funktionieren weiterhin; gespeicherte Regeln werden erst nach Fortsetzen der Überwachung angewendet. + Letzte Prioritätsänderung rückgängig machen + Prozesse angezeigt + Nicht gespeicherte Änderungen diff --git a/Locales/en-US.xaml b/Locales/en-US.xaml index 1517f5b..36a7e97 100644 --- a/Locales/en-US.xaml +++ b/Locales/en-US.xaml @@ -769,4 +769,11 @@ Replace settings and {0} power plan associations. Persistent process rules: {1}. Continue? keep existing rules replace with {0} imported rules + + + Apply + Automation is paused. Manual changes still work; saved rules are not applied until monitoring resumes. + Undo last priority change + processes shown + Unsaved changes diff --git a/Locales/es-ES.xaml b/Locales/es-ES.xaml index e96c55c..d6c5226 100644 --- a/Locales/es-ES.xaml +++ b/Locales/es-ES.xaml @@ -769,4 +769,11 @@ Reemplazar la configuración y {0} asociaciones de planes de energía. Reglas de procesos: {1}. ¿Continuar? mantener las reglas existentes reemplazar con {0} reglas importadas + + + Aplicar + Automatización en pausa. Los cambios manuales siguen funcionando; las reglas guardadas no se aplican hasta reanudar la supervisión. + Deshacer el último cambio de prioridad + procesos mostrados + Cambios sin guardar diff --git a/Locales/fr-FR.xaml b/Locales/fr-FR.xaml index e71fb5f..4471838 100644 --- a/Locales/fr-FR.xaml +++ b/Locales/fr-FR.xaml @@ -769,4 +769,11 @@ Remplacer les paramètres et {0} associations de modes d'alimentation. Règles de processus : {1}. Continuer ? conserver les règles existantes remplacer par {0} règles importées + + + Appliquer + Automatisation en pause. Les modifications manuelles fonctionnent toujours ; les règles enregistrées ne s'appliquent qu'à la reprise de la surveillance. + Annuler la dernière modification de priorité + processus affichés + Modifications non enregistrées diff --git a/Locales/it-IT.xaml b/Locales/it-IT.xaml index 84da79d..08952e8 100644 --- a/Locales/it-IT.xaml +++ b/Locales/it-IT.xaml @@ -769,4 +769,11 @@ Sostituire le impostazioni e {0} associazioni ai piani di alimentazione? Regole dei processi: {1}. Continuare? mantieni le regole esistenti sostituisci con {0} regole importate + + + Applica + Automazione in pausa. Le modifiche manuali funzionano; le regole salvate non vengono applicate finché il monitoraggio non riprende. + Annulla ultima modifica di priorità + processi visibili + Modifiche non salvate diff --git a/Locales/ru-RU.xaml b/Locales/ru-RU.xaml index 058abe2..6f72c3b 100644 --- a/Locales/ru-RU.xaml +++ b/Locales/ru-RU.xaml @@ -769,4 +769,11 @@ Заменить настройки и {0} привязок к схемам электропитания. Правила процессов: {1}. Продолжить? сохранить существующие правила заменить {0} импортированными правилами + + + Применить + Автоматизация приостановлена. Ручные изменения работают; сохранённые правила не применяются до возобновления мониторинга. + Отменить последнее изменение приоритета + процессов показано + Несохранённые изменения diff --git a/Locales/zh-CN.xaml b/Locales/zh-CN.xaml index cc212a3..82322b3 100644 --- a/Locales/zh-CN.xaml +++ b/Locales/zh-CN.xaml @@ -769,4 +769,11 @@ 替换设置和 {0} 个电源计划关联。进程规则:{1}。是否继续? 保留现有规则 替换为 {0} 条导入的规则 + + + 应用 + 自动化已暂停。手动更改仍然有效;恢复监控之前不会应用已保存的规则。 + 撤销上次优先级更改 + 个进程已显示 + 未保存的更改 diff --git a/Services/ProcessRuleCreationService.cs b/Services/ProcessRuleCreationService.cs index cdbede8..29c499a 100644 --- a/Services/ProcessRuleCreationService.cs +++ b/Services/ProcessRuleCreationService.cs @@ -35,6 +35,11 @@ Task UpdateRuleAsync( PersistentProcessRule rule, CancellationToken cancellationToken = default); + Task UpdateRuleWithCoreSelectionAsync( + PersistentProcessRule rule, + IReadOnlyList? coreSelection, + CancellationToken cancellationToken = default); + Task UpdateRuleCoreSelectionAsync( string ruleId, IReadOnlyList coreSelection, @@ -207,8 +212,14 @@ public async Task DeleteRuleByIdAsync( // Editing a rule in place, from the saved-rules list. The affinity payload is not editable // there - that needs the core picker in the Process tab - so it is carried over untouched // rather than rebuilt, and only the fields the editor owns are replaced. - public async Task UpdateRuleAsync( + public Task UpdateRuleAsync( PersistentProcessRule rule, + CancellationToken cancellationToken = default) => + this.UpdateRuleWithCoreSelectionAsync(rule, null, cancellationToken); + + public async Task UpdateRuleWithCoreSelectionAsync( + PersistentProcessRule rule, + IReadOnlyList? coreSelection, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(rule); @@ -242,6 +253,22 @@ public async Task UpdateRuleAsync( UpdatedAt = DateTime.UtcNow, }; + if (coreSelection != null) + { + var affinity = await this.BuildAffinityPayloadFromCoreSelectionAsync(coreSelection, "Saved rule editor", cancellationToken).ConfigureAwait(false); + if (!affinity.Success || affinity.Payload == null) + { + return affinity; + } + + updated = updated with + { + CpuSelection = affinity.Payload.CpuSelection, + LegacyAffinityMask = affinity.Payload.LegacyAffinityMask, + ApplyAffinityOnStart = true, + }; + } + rules[existingIndex] = updated; cancellationToken.ThrowIfCancellationRequested(); diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs index d7c906a..0bfb9ac 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs @@ -224,6 +224,9 @@ public async Task ContextMemoryPriorityCommand_WhenServiceFails_ShowsSafeUserMes ProcessOperationUserMessages.AccessDenied, "Access is denied.", isAccessDenied: true)); + memoryPriorityService + .Setup(service => service.GetMemoryPriorityAsync(It.IsAny())) + .ReturnsAsync(ProcessMemoryPriority.Normal); var process = CreateProcess(); var audit = new ActivityAuditService(NullLogger.Instance); var viewModel = CreateViewModel( diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs index ef06947..a2373fe 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs @@ -35,7 +35,7 @@ public void LastOperationMessageBinding_IsDisplayOnly() } [Fact] - public void SelectedProcessSummaryBindings_AreNotUsedByEditableControls() + public void SelectedProcessSummaryBindings_AreNotUsedByEditableControls() { var editableControls = new HashSet(StringComparer.Ordinal) { @@ -61,22 +61,22 @@ public void SelectedProcessSummaryBindings_AreNotUsedByEditableControls() .Where(attribute => attribute.Value.Contains("SelectedProcessSummary.", StringComparison.Ordinal)) .ToList(); - Assert.Empty(editableSummaryBindings); - } - - [Fact] - public void CpuAssignmentMode_IsStagedTwoWayAndHasAccessibleHelp() - { - var serialized = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace) - .ToString(SaveOptions.DisableFormatting); - - Assert.Contains("SelectedValue=\"{Binding SelectedCpuAssignmentMode, Mode=TwoWay}\"", serialized, StringComparison.Ordinal); - Assert.Contains("CpuAssignmentMode.AffinityMask", serialized, StringComparison.Ordinal); - Assert.Contains("CpuAssignmentMode.IdealProcessor", serialized, StringComparison.Ordinal); - Assert.Contains("CpuAssignmentMode.CpuSets", serialized, StringComparison.Ordinal); - Assert.Contains("CpuAssignmentMode.Automatic", serialized, StringComparison.Ordinal); - Assert.Contains("AutomationProperties.HelpText=\"{DynamicResource ProcessView_CpuAssignmentModeTooltip}\"", serialized, StringComparison.Ordinal); - } + Assert.Empty(editableSummaryBindings); + } + + [Fact] + public void CpuAssignmentMode_IsStagedTwoWayAndHasAccessibleHelp() + { + var serialized = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace) + .ToString(SaveOptions.DisableFormatting); + + Assert.Contains("SelectedValue=\"{Binding SelectedCpuAssignmentMode, Mode=TwoWay}\"", serialized, StringComparison.Ordinal); + Assert.Contains("CpuAssignmentMode.AffinityMask", serialized, StringComparison.Ordinal); + Assert.Contains("CpuAssignmentMode.IdealProcessor", serialized, StringComparison.Ordinal); + Assert.Contains("CpuAssignmentMode.CpuSets", serialized, StringComparison.Ordinal); + Assert.Contains("CpuAssignmentMode.Automatic", serialized, StringComparison.Ordinal); + Assert.Contains("AutomationProperties.HelpText=\"{DynamicResource ProcessView_CpuAssignmentModeTooltip}\"", serialized, StringComparison.Ordinal); + } [Fact] public void ProcessGridRowStyle_HighlightsSelectedRowsWithNeutralTheme() @@ -197,39 +197,34 @@ public void ProcessGridContextMenu_ContainsExpectedActionsAndSubmenus() } [Fact] - public void ProcessToolbar_ExposesLockProcessListToggle() - { + public void ProcessToolbar_ExposesLockProcessListToggle() + { var document = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace); var serialized = document.ToString(SaveOptions.DisableFormatting); Assert.Contains("ProcessView_LockList", serialized, StringComparison.Ordinal); Assert.Contains("IsChecked=\"{Binding IsProcessListLocked}\"", serialized, StringComparison.Ordinal); - Assert.Contains("ProcessView_LockListTooltip", serialized, StringComparison.Ordinal); - } - - [Fact] - public void MonitoringDisabledOverlay_CoversProcessControlsWithSinglePrimaryMessage() - { - var document = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace); - var overlay = Assert.Single( - document.Descendants(), - element => element.Attributes().Any(attribute => - attribute.Name.LocalName == "Name" && - attribute.Value == "MonitoringDisabledOverlay")); - var serialized = overlay.ToString(SaveOptions.DisableFormatting); - - Assert.Equal("Border", overlay.Name.LocalName); - Assert.Contains("Grid.RowSpan=\"3\"", serialized, StringComparison.Ordinal); - Assert.Contains("Panel.ZIndex=\"10\"", serialized, StringComparison.Ordinal); - Assert.Contains("Background=\"{DynamicResource SurfaceMutedBrush}\"", serialized, StringComparison.Ordinal); - Assert.Contains("Foreground=\"{DynamicResource TextPrimaryBrush}\"", serialized, StringComparison.Ordinal); - Assert.Contains("InverseBoolToVisibilityConverter", serialized, StringComparison.Ordinal); - Assert.Single(overlay.Descendants(), element => element.Name.LocalName == "TextBlock"); - Assert.DoesNotContain("ProcessView_MonitoringDisabledDescription", serialized, StringComparison.Ordinal); - } - - [Fact] - public void MasksView_SelectedCpuTilesUseSubtleMaskSelectionResources() + Assert.Contains("ProcessView_LockListTooltip", serialized, StringComparison.Ordinal); + } + + [Fact] + public void PausedAutomation_ShowsNoticeWithoutBlockingManualControls() + { + // Pausing automation stops saved rules only; manual process control must stay usable. + var document = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace); + var serialized = document.ToString(SaveOptions.DisableFormatting); + + Assert.DoesNotContain("MonitoringDisabledOverlay", serialized, StringComparison.Ordinal); + var notice = Assert.Single( + document.Descendants(), + element => element.Attributes().Any(attribute => attribute.Value == "{DynamicResource ProcessView_AutomationPaused}")); + var visibility = notice.Attribute("Visibility")?.Value ?? string.Empty; + Assert.Contains("IsAutomationMonitoringEnabled", visibility, StringComparison.Ordinal); + Assert.Contains("InverseBoolToVisibilityConverter", visibility, StringComparison.Ordinal); + } + + [Fact] + public void MasksView_SelectedCpuTilesUseSubtleMaskSelectionResources() { var masksViewPath = Path.Combine( GetRepositoryRoot(), diff --git a/ViewModels/ProcessPowerPlanAssociationViewModel.cs b/ViewModels/ProcessPowerPlanAssociationViewModel.cs index b6858ba..f8c6db2 100644 --- a/ViewModels/ProcessPowerPlanAssociationViewModel.cs +++ b/ViewModels/ProcessPowerPlanAssociationViewModel.cs @@ -196,21 +196,27 @@ private async Task DeleteSavedProcessRuleAsync(SavedProcessRule? rule) private SavedProcessRule? selectedSavedProcessRule; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasPendingRuleChanges))] private bool savedRuleIsEnabled = true; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasPendingRuleChanges))] private bool savedRulePreventSleep; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasPendingRuleChanges))] private string savedRulePriority = NoneOption; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasPendingRuleChanges))] private string savedRuleMemoryPriority = NoneOption; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasPendingRuleChanges))] private string savedRuleIoPriority = NoneOption; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasPendingRuleChanges))] private string savedRuleCpuAssignmentMode = nameof(CpuAssignmentMode.AffinityMask); public const string NoneOption = "None"; @@ -243,6 +249,7 @@ partial void OnSelectedSavedProcessRuleChanged(SavedProcessRule? value) return; } + this.SavedRuleCoreMask = null; var rule = value.Source; this.SavedRuleIsEnabled = rule.IsEnabled; this.SavedRulePreventSleep = rule.PreventSystemSleepWhileRunning; @@ -257,26 +264,16 @@ partial void OnSelectedSavedProcessRuleChanged(SavedProcessRule? value) // was the one thing you could not change. The vocabulary here is the saved CPU masks, which // is what the Masks tab exists to define, rather than a second per-core picker. [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasPendingRuleChanges))] private CoreMask? savedRuleCoreMask; - [RelayCommand] - private async Task ApplySavedRuleCoreMaskAsync() - { - var selected = this.SelectedSavedProcessRule; - var mask = this.SavedRuleCoreMask; - if (selected == null || mask == null || this.ruleCreationService == null) - { - return; - } - - var result = await this.ruleCreationService - .UpdateRuleCoreSelectionAsync(selected.Id, mask.BoolMask) - .ConfigureAwait(true); - this.SetStatus(result.UserMessage, false); - await this.RefreshSavedProcessRulesAsync().ConfigureAwait(true); - this.SelectedSavedProcessRule = this.SavedProcessRules - .FirstOrDefault(row => string.Equals(row.Id, selected.Id, StringComparison.Ordinal)); - } + public bool HasPendingRuleChanges => this.SelectedSavedProcessRule is { } selected && + (this.SavedRuleCoreMask != null || this.SavedRuleIsEnabled != selected.Source.IsEnabled || + this.SavedRulePreventSleep != selected.Source.PreventSystemSleepWhileRunning || + this.SavedRulePriority != (selected.Source.Priority?.ToString() ?? NoneOption) || + this.SavedRuleMemoryPriority != (selected.Source.MemoryPriority?.ToString() ?? NoneOption) || + this.SavedRuleIoPriority != (selected.Source.IoPriority?.ToString() ?? NoneOption) || + this.SavedRuleCpuAssignmentMode != selected.Source.CpuAssignmentMode.ToString()); [RelayCommand] private async Task SaveSavedProcessRuleAsync() @@ -299,7 +296,9 @@ private async Task SaveSavedProcessRuleAsync() : selected.Source.CpuAssignmentMode, }; - var result = await this.ruleCreationService.UpdateRuleAsync(updated).ConfigureAwait(true); + var result = this.SavedRuleCoreMask == null + ? await this.ruleCreationService.UpdateRuleAsync(updated).ConfigureAwait(true) + : await this.ruleCreationService.UpdateRuleWithCoreSelectionAsync(updated, this.SavedRuleCoreMask.BoolMask).ConfigureAwait(true); this.SetStatus(result.UserMessage, false); await this.RefreshSavedProcessRulesAsync().ConfigureAwait(true); this.SelectedSavedProcessRule = this.SavedProcessRules diff --git a/ViewModels/ProcessViewModel.Behaviors.partial.cs b/ViewModels/ProcessViewModel.Behaviors.partial.cs index d214b29..851855d 100644 --- a/ViewModels/ProcessViewModel.Behaviors.partial.cs +++ b/ViewModels/ProcessViewModel.Behaviors.partial.cs @@ -84,7 +84,7 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: Power plans loaded"); // Load processes automatically only while automation monitoring is enabled. - if (this.IsAutomationMonitoringEnabled) + if (this.isProcessViewActive) { System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: About to load processes automatically"); await this.LoadProcessesCommand.ExecuteAsync(null); @@ -99,7 +99,7 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => System.Diagnostics.Debug.WriteLine($"ProcessViewModel.InitializeAsync: Processes loaded automatically, count: {processCount}"); // Start refresh timer only while automation monitoring is enabled. - if (this.IsAutomationMonitoringEnabled) + if (this.isProcessViewActive) { System.Diagnostics.Debug.WriteLine("ProcessViewModel.InitializeAsync: Starting refresh timer"); this.refreshTimer?.Start(); @@ -184,22 +184,22 @@ partial void OnSelectedCpuAssignmentModeChanged(CpuAssignmentMode value) private async Task<(CpuAssignmentMode Mode, bool FromRule)> ResolveCpuAssignmentModeAsync(ProcessModel process) { var defaultMode = this.settingsService?.Settings.DefaultCpuAssignmentMode ?? CpuAssignmentMode.Automatic; - if (this.persistentRuleStore == null || this.persistentRuleMatcher == null) - { - return (defaultMode, false); - } - - try - { - var rules = await this.persistentRuleStore.LoadAsync().ConfigureAwait(false); - var rule = rules.FirstOrDefault(candidate => candidate.IsEnabled && this.persistentRuleMatcher.IsMatch(candidate, process)); - return rule == null ? (defaultMode, false) : (rule.CpuAssignmentMode, true); - } - catch (Exception ex) - { - this.Logger.LogDebug(ex, "Could not resolve saved CPU assignment mode for {ProcessName}", process.Name); - return (defaultMode, false); - } + if (this.persistentRuleStore == null || this.persistentRuleMatcher == null) + { + return (defaultMode, false); + } + + try + { + var rules = await this.persistentRuleStore.LoadAsync().ConfigureAwait(false); + var rule = PersistentRuleResolution.MatchInApplyOrder(rules, process, this.persistentRuleMatcher).LastOrDefault(candidate => candidate.ApplyAffinityOnStart); + return rule == null ? (defaultMode, false) : (rule.CpuAssignmentMode, true); + } + catch (Exception ex) + { + this.Logger.LogDebug(ex, "Could not resolve saved CPU assignment mode for {ProcessName}", process.Name); + return (defaultMode, false); + } } private void SetCpuAssignmentMode(CpuAssignmentMode mode, bool overriddenByRule) @@ -241,22 +241,22 @@ private async Task HandleSelectedProcessChangedAsync(ProcessModel value) // Refresh process info to get current state from OS await this.processService.RefreshProcessInfo(value); - var assignment = await this.ResolveCpuAssignmentModeAsync(value); - var cpuSetIndexes = await this.processService.GetCpuAssignmentLogicalProcessorIndexesAsync( - value, - CpuAssignmentMode.CpuSets); - IReadOnlyList? softAssignmentIndexes; - if (cpuSetIndexes is { Count: > 0 }) - { - assignment = (CpuAssignmentMode.CpuSets, assignment.FromRule && assignment.Mode == CpuAssignmentMode.CpuSets); - softAssignmentIndexes = cpuSetIndexes; - } - else - { - softAssignmentIndexes = assignment.Mode == CpuAssignmentMode.IdealProcessor - ? await this.processService.GetCpuAssignmentLogicalProcessorIndexesAsync(value, assignment.Mode) - : null; - } + var assignment = await this.ResolveCpuAssignmentModeAsync(value); + var cpuSetIndexes = await this.processService.GetCpuAssignmentLogicalProcessorIndexesAsync( + value, + CpuAssignmentMode.CpuSets); + IReadOnlyList? softAssignmentIndexes; + if (cpuSetIndexes is { Count: > 0 }) + { + assignment = (CpuAssignmentMode.CpuSets, assignment.FromRule && assignment.Mode == CpuAssignmentMode.CpuSets); + softAssignmentIndexes = cpuSetIndexes; + } + else + { + softAssignmentIndexes = assignment.Mode == CpuAssignmentMode.IdealProcessor + ? await this.processService.GetCpuAssignmentLogicalProcessorIndexesAsync(value, assignment.Mode) + : null; + } // Update UI on main thread with fresh data await InvokeOnUiAsync(() => @@ -541,19 +541,19 @@ private long CalculateAffinityMask() return selectedCores.Aggregate(0L, (mask, core) => mask | core.AffinityMask); } - private List GetPendingCoreSelectionMask() - { + private List GetPendingCoreSelectionMask() + { return this.CpuCores .OrderBy(core => core.LogicalCoreId) .Select(core => core.IsSelected) - .ToList(); - } - - private static List GetSelectedLogicalProcessorIndexes(IReadOnlyList selection) => - selection.Select((selected, index) => (selected, index)) - .Where(item => item.selected) - .Select(item => item.index) - .ToList(); + .ToList(); + } + + private static List GetSelectedLogicalProcessorIndexes(IReadOnlyList selection) => + selection.Select((selected, index) => (selected, index)) + .Where(item => item.selected) + .Select(item => item.index) + .ToList(); private void UpdateAffinityDisplayState() { @@ -912,10 +912,10 @@ await this.LogUserActionAsync( } else { - this.UpdateCoreSelections( - GetSelectedLogicalProcessorIndexes(pendingSelection), - applyResult.EffectiveMode, - true); + this.UpdateCoreSelections( + GetSelectedLogicalProcessorIndexes(pendingSelection), + applyResult.EffectiveMode, + true); } process.ForceNotifyProcessorAffinityChanged(); @@ -1018,10 +1018,10 @@ await InvokeOnUiAsync(() => { if (result.EffectiveMode is CpuAssignmentMode.CpuSets or CpuAssignmentMode.IdealProcessor) { - this.UpdateCoreSelections( - GetSelectedLogicalProcessorIndexes(pendingSelection), - result.EffectiveMode, - true); + this.UpdateCoreSelections( + GetSelectedLogicalProcessorIndexes(pendingSelection), + result.EffectiveMode, + true); } this.HasPendingAffinityEdits = false; @@ -1243,7 +1243,7 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => pendingSelection, "Manual Process tab quick apply CPU selection", this.SelectedCpuAssignmentMode); - if (!result.Success) + if (!result.Success) { await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { @@ -1256,18 +1256,18 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => this.OnPropertyChanged(nameof(this.SelectedProcess)); this.SetStatus(result.Message, false); }); - return; - } - - if (result.EffectiveMode is CpuAssignmentMode.CpuSets or CpuAssignmentMode.IdealProcessor) - { - this.UpdateCoreSelections( - GetSelectedLogicalProcessorIndexes(pendingSelection), - result.EffectiveMode, - true); - } - - this.HasPendingAffinityEdits = false; + return; + } + + if (result.EffectiveMode is CpuAssignmentMode.CpuSets or CpuAssignmentMode.IdealProcessor) + { + this.UpdateCoreSelections( + GetSelectedLogicalProcessorIndexes(pendingSelection), + result.EffectiveMode, + true); + } + + this.HasPendingAffinityEdits = false; this.UpdateAffinityDisplayState(); affinityModeChangedOsAffinity = result.EffectiveMode == CpuAssignmentMode.AffinityMask; } @@ -1740,7 +1740,9 @@ await this.LogUserActionAsync( try { + var undo = CapturePriorityUndo(process, cpu: true); await this.processService.SetProcessPriority(process, priority, ProcessPriorityWriteSource.ManualUiAction); + this.RememberPriority(undo); await this.processService.RefreshProcessInfo(process); var warning = ProcessPriorityGuardrails.GetWarning(priority); @@ -1790,6 +1792,8 @@ private async Task SetContextMemoryPriorityAsync(ProcessModel? process, ProcessM try { + var previous = await this.memoryPriorityService.GetMemoryPriorityAsync(process); + var undo = previous.HasValue ? CapturePriorityUndo(process, memory: previous) : null; var result = await this.memoryPriorityService.SetMemoryPriorityAsync(process, priority); if (!result.Success) { @@ -1805,6 +1809,7 @@ await this.LogUserActionAsync( return; } + this.RememberPriority(undo); this.SetStatus($"Memory priority applied successfully to {process.Name}: {priority}.", false); await this.LogUserActionAsync( "ProcessMemoryPriorityChanged", @@ -1831,6 +1836,8 @@ private async Task SetContextIoPriorityAsync(ProcessModel? process, ProcessIoPri return; } + var previous = await this.memoryPriorityService.GetIoPriorityAsync(process); + var undo = previous.HasValue ? CapturePriorityUndo(process, io: previous) : null; var result = await this.memoryPriorityService.SetIoPriorityAsync(process, priority); if (!result.Success) { @@ -1843,6 +1850,7 @@ await this.LogUserActionAsync( return; } + this.RememberPriority(undo); this.SetStatus($"I/O priority applied successfully to {process.Name}: {priority}.", false); await this.LogUserActionAsync( "ProcessIoPriorityChanged", @@ -2107,7 +2115,7 @@ partial void OnIsProcessListLockedChanged(bool value) private bool ShouldRunProcessUiRefresh() { - return this.IsAutomationMonitoringEnabled && this.isProcessViewActive && !this.isUiRefreshPaused && !this.IsProcessListLocked; + return this.isProcessViewActive && !this.isUiRefreshPaused && !this.IsProcessListLocked; } private bool ShouldPreloadVirtualizedBatches() diff --git a/ViewModels/ProcessViewModel.PriorityEditor.cs b/ViewModels/ProcessViewModel.PriorityEditor.cs new file mode 100644 index 0000000..b4bd26e --- /dev/null +++ b/ViewModels/ProcessViewModel.PriorityEditor.cs @@ -0,0 +1,121 @@ +namespace ThreadPilot.ViewModels +{ + using System.Diagnostics; + using CommunityToolkit.Mvvm.ComponentModel; + using CommunityToolkit.Mvvm.Input; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public partial class ProcessViewModel + { + [ObservableProperty] + private ProcessPriorityClass pendingCpuPriority = ProcessPriorityClass.Normal; + + [ObservableProperty] + private ProcessMemoryPriority pendingMemoryPriority = ProcessMemoryPriority.Normal; + + [ObservableProperty] + private ProcessIoPriority pendingIoPriority = ProcessIoPriority.Normal; + + private PriorityUndoSnapshot? priorityUndo; + + [RelayCommand] + private Task ApplySelectedCpuPriority() => this.SetContextCpuPriorityAsync(this.SelectedProcess, this.PendingCpuPriority); + + [RelayCommand] + private Task ApplySelectedMemoryPriority() => this.SetContextMemoryPriorityAsync(this.SelectedProcess, this.PendingMemoryPriority); + + [RelayCommand] + private Task ApplySelectedIoPriority() => this.SetContextIoPriorityAsync(this.SelectedProcess, this.PendingIoPriority); + + private bool CanUndoPriority() => this.priorityUndo != null; + + private void RememberPriority(PriorityUndoSnapshot? snapshot) + { + this.priorityUndo = snapshot; + this.UndoPriorityCommand.NotifyCanExecuteChanged(); + } + + private static PriorityUndoSnapshot? CapturePriorityUndo( + ProcessModel process, bool cpu = false, ProcessMemoryPriority? memory = null, ProcessIoPriority? io = null) + { + try + { + using var native = Process.GetProcessById(process.ProcessId); + return new(process.ProcessId, native.StartTime.ToUniversalTime(), process.Name, + cpu ? native.PriorityClass : null, memory, io); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or System.ComponentModel.Win32Exception) + { + return null; + } + } + + [RelayCommand(CanExecute = nameof(CanUndoPriority))] + private async Task UndoPriorityAsync() + { + var undo = this.priorityUndo; + if (undo == null) + { + return; + } + + try + { + using var native = Process.GetProcessById(undo.ProcessId); + if (!undo.Matches(native.Id, native.StartTime.ToUniversalTime()) || native.HasExited) + { + this.RememberPriority(null); + this.SetContextError(ProcessOperationUserMessages.ProcessExited); + return; + } + + var process = new ProcessModel { ProcessId = undo.ProcessId, Name = undo.Name }; + if (undo.Cpu.HasValue) + { + await this.processService.SetProcessPriority(process, undo.Cpu.Value, ProcessPriorityWriteSource.ManualUiAction); + } + else + { + if (this.memoryPriorityService == null) + { + return; + } + + var result = undo.Memory.HasValue + ? await this.memoryPriorityService.SetMemoryPriorityAsync(process, undo.Memory.Value) + : await this.memoryPriorityService.SetIoPriorityAsync(process, undo.Io!.Value); + if (!result.Success) + { + this.SetContextError(result.UserMessage); + return; + } + } + + this.RememberPriority(null); + this.SetStatus(this.UiText("ProcessView_PriorityRestored", "Previous priority restored."), false); + await this.LogUserActionAsync("ProcessPriorityRestored", $"Previous priority restored for {undo.Name}", $"PID: {undo.ProcessId}"); + if (this.SelectedProcess?.ProcessId == undo.ProcessId) + { + await this.TryRefreshContextProcessSummaryAsync(this.SelectedProcess); + } + } + catch (Exception ex) + { + this.SetContextError(MapProcessOperationException(ex)); + } + } + + private string UiText(string key, string fallback) + { + var text = this.uiLocalization?.GetString(key); + return string.IsNullOrWhiteSpace(text) || text == key ? fallback : text; + } + + internal sealed record PriorityUndoSnapshot(int ProcessId, DateTime StartedUtc, string Name, + ProcessPriorityClass? Cpu, ProcessMemoryPriority? Memory, ProcessIoPriority? Io) + { + public bool Matches(int processId, DateTime startedUtc) => this.ProcessId == processId && this.StartedUtc == startedUtc; + } + } +} diff --git a/ViewModels/ProcessViewModel.cs b/ViewModels/ProcessViewModel.cs index fafeb38..5e2c877 100644 --- a/ViewModels/ProcessViewModel.cs +++ b/ViewModels/ProcessViewModel.cs @@ -37,6 +37,7 @@ public partial class ProcessViewModel : BaseViewModel private readonly ProcessPowerRequestService? powerRequestService; private readonly IProcessRuleCreationService? processRuleCreationService; private readonly IApplicationSettingsService? settingsService; + private readonly ILocalizationService? uiLocalization; private readonly IPersistentProcessRuleStore? persistentRuleStore; private readonly IPersistentProcessRuleMatcher? persistentRuleMatcher; private readonly Action clipboardSetter; @@ -224,6 +225,7 @@ public ProcessViewModel( this.memoryPriorityService = memoryPriorityService; this.powerRequestService = powerRequestService; this.settingsService = settingsService; + this.uiLocalization = localizationService; this.persistentRuleStore = persistentRuleStore; this.persistentRuleMatcher = persistentRuleMatcher; this.SelectedCpuAssignmentMode = settingsService?.Settings.DefaultCpuAssignmentMode ?? CpuAssignmentMode.Automatic; @@ -281,13 +283,6 @@ private void OnSettingsChanged(object? sender, ApplicationSettingsChangedEventAr { this.SetCpuAssignmentMode(e.NewSettings.DefaultCpuAssignmentMode, overriddenByRule: false); } - if (!this.IsAutomationMonitoringEnabled) - { - this.SetUiRefreshEnabled(false, refreshImmediately: false); - this.SelectedProcess = null; - return; - } - this.SetUiRefreshEnabled(this.isProcessViewActive && !this.IsProcessListLocked); } diff --git a/Views/ProcessPowerPlanAssociationView.xaml b/Views/ProcessPowerPlanAssociationView.xaml index 696040a..cd397a2 100644 --- a/Views/ProcessPowerPlanAssociationView.xaml +++ b/Views/ProcessPowerPlanAssociationView.xaml @@ -164,7 +164,7 @@ @@ -180,11 +180,11 @@ Background="{DynamicResource SurfaceAltBrush}" Padding="8" Margin="0,0,0,10" Visibility="{Binding HasSelectedExecutable, Converter={StaticResource BoolToVisibilityConverter}}"> - + + FontSize="13" Foreground="{DynamicResource TextSecondaryBrush}" TextWrapping="Wrap"/> @@ -192,7 +192,7 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ToolTip="{DynamicResource ProcessView_CpuAssignmentModeTooltip}" + AutomationProperties.Name="{DynamicResource ProcessView_CpuAssignmentMode}" + AutomationProperties.HelpText="{DynamicResource ProcessView_CpuAssignmentModeTooltip}"> + + + + - - - - - - + + + + + + + + + + + - - - - - - - + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - + + + + + + + - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/UX_REDESIGN_PLAN.md b/docs/UX_REDESIGN_PLAN.md index f3fea03..5c75e93 100644 --- a/docs/UX_REDESIGN_PLAN.md +++ b/docs/UX_REDESIGN_PLAN.md @@ -23,8 +23,10 @@ Branch `feat/ux-clarity`, impilato su #69 (EcoQoS). Nessuna modifica al formato - [x] Processi: ricerca con testo guida; filtri e colonne in due menu a tendina; dettagli in tre riquadri (Processo, Priorità, Core CPU) più la riga della regola; selettori allineati allo stato attuale; testi di aiuto nei tooltip. - [x] Regole: scheda "Regole dei processi" per prima, nomi delle schede più chiari, etichette localizzate nell'editor. - [x] Sottotitoli accorciati in tutte le lingue. -- [ ] Impostazioni e Ottimizzazioni con righe in stile Impostazioni di Windows 11 (`CardControl`): prossimo passo, richiede una riscrittura più ampia delle due viste. -- [ ] Area unica per gli avvisi della finestra principale: da valutare dopo la verifica sul campo. +- [x] Impostazioni e Ottimizzazioni in una colonna di righe in stile Impostazioni di Windows 11 (titolo e descrizione a sinistra, interruttore a destra); sezioni più usate in alto. +- [x] Avvisi della finestra principale: già mostrati uno alla volta (coda elevazione → avvio ridotto → modalità CPU in `MainWindow.Behaviors.partial.cs`); nessuna modifica necessaria. +- [x] Elenco regole: colonne "Applica" e "Modalità" localizzate. +- [x] Rimosse 76 stringhe non più usate da tutte le lingue. ## Verifica From 20abf7006c0fd2bc67859817f3ec1dc0a6cc236b Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Wed, 14 Oct 2026 18:42:05 +0200 Subject: [PATCH 08/21] fix: issues found testing the beta in a clean Windows 11 VM - Process counter could show more visible processes than the total ('157 / 151'): the batch total was a snapshot from the first load. - Narrow windows (or 150% scaling): toolbar buttons move under the search box instead of squeezing it to a few letters. - Rules list keeps the 'Applies' column readable when narrow. - Settings status message gets its own line instead of being cut. Tested in a Hyper-V Windows 11 VM: clean install, upgrade, uninstall, light and dark theme, 100% and 150% scaling. --- .../ProcessViewModelContextMenuTests.cs | 19 +++ .../ProcessViewModel.Behaviors.partial.cs | 7 ++ Views/ProcessPowerPlanAssociationView.xaml | 3 +- Views/ProcessView.xaml | 108 +++++++++--------- Views/ProcessView.xaml.cs | 6 + Views/SettingsView.xaml | 20 ++-- 6 files changed, 99 insertions(+), 64 deletions(-) diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs index 56a4ca6..0b026a7 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs @@ -285,6 +285,25 @@ public async Task PriorityPickers_StartFromTheSelectedProcessCurrentValues() Assert.Null(viewModel.PendingIoPriority); // unreadable: the picker stays empty instead of guessing } + [Fact] + public void ProcessCounter_NeverShowsMoreVisibleThanTotal() + { + var viewModel = CreateViewModel(CreateProcessService().Object); + viewModel.TotalProcessCount = 1; // stale snapshot from an earlier batch load + foreach (var pid in new[] { 101, 102, 103 }) + { + viewModel.Processes.Add(new ProcessModel { ProcessId = pid, Name = $"p{pid}" }); + } + + // Filtering is debounced through the dispatcher in the app; call the filter step directly. + typeof(ProcessViewModel) + .GetMethod("FilterProcesses", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .Invoke(viewModel, null); + + Assert.Equal(3, viewModel.FilteredProcesses.Count); + Assert.Equal(3, viewModel.TotalProcessCount); + } + [Fact] public async Task CopyContextProcessInfo_IncludesNamePidAndPath() { diff --git a/ViewModels/ProcessViewModel.Behaviors.partial.cs b/ViewModels/ProcessViewModel.Behaviors.partial.cs index 9171fec..b1ad7ba 100644 --- a/ViewModels/ProcessViewModel.Behaviors.partial.cs +++ b/ViewModels/ProcessViewModel.Behaviors.partial.cs @@ -2219,6 +2219,13 @@ private void FilterProcesses() var filteredResults = this.processFilterService.FilterAndSort(this.Processes, criteria); this.FilteredProcesses = new ObservableCollection(filteredResults); + + // The batch total is a snapshot from the first load; refreshes add processes, so the + // counter showed "157 / 151". Once everything is loaded the list itself is the total. + if (!this.HasMoreBatches || this.Processes.Count > this.TotalProcessCount) + { + this.TotalProcessCount = this.Processes.Count; + } } while (this.filterRefreshPending); } diff --git a/Views/ProcessPowerPlanAssociationView.xaml b/Views/ProcessPowerPlanAssociationView.xaml index e3e3912..f9305fe 100644 --- a/Views/ProcessPowerPlanAssociationView.xaml +++ b/Views/ProcessPowerPlanAssociationView.xaml @@ -120,7 +120,8 @@ Width="150"/> + Width="*" + MinWidth="160"/> diff --git a/Views/ProcessView.xaml b/Views/ProcessView.xaml index 007abb1..c29baac 100644 --- a/Views/ProcessView.xaml +++ b/Views/ProcessView.xaml @@ -194,66 +194,68 @@ - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - -