From c7b9eb27e0c0a3546315bb72e9500c39cbdf879c Mon Sep 17 00:00:00 2001 From: Simon McConnell Date: Thu, 27 Aug 2026 11:16:18 +0100 Subject: [PATCH 1/6] #89: Capability-aware compact Projects Settings; fix cross-project test path bleed. --- docs/SETTINGS.md | 4 + .../SettingsProjectCapabilityPolicyTests.cs | 174 ++++++++++++++++++ .../Rules/SettingsProjectCapabilityPolicy.cs | 60 ++++++ src/Core/Rules/TestProjectPathRules.cs | 55 ++++++ .../Services/LaunchProfileDiscovery.cs | 48 +++++ src/TrayApp/SettingsWindow.xaml | 36 ++-- src/TrayApp/SettingsWindow.xaml.cs | 164 +++++++++++++---- 7 files changed, 491 insertions(+), 50 deletions(-) create mode 100644 src/BuildMonitor.Tests/SettingsProjectCapabilityPolicyTests.cs create mode 100644 src/Core/Rules/SettingsProjectCapabilityPolicy.cs create mode 100644 src/Core/Rules/TestProjectPathRules.cs diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md index 4e9a8f8..11f26f3 100644 --- a/docs/SETTINGS.md +++ b/docs/SETTINGS.md @@ -176,6 +176,10 @@ VSTest can print `Test run for ` **before** it opens the file. That banner **Project file** is used for build/run/watch (usually the app `.csproj`). **Test project / solution** (optional) targets `dotnet test` — leave blank to auto-detect a `.sln`/`.slnx` in the repo root or `*Tests.csproj` files. Running tests against the app `.csproj` only restores packages and does not execute tests. +Paths that do not resolve under the project's **Root folder** (or do not exist) are rejected on load/save so another project's relative test path cannot stick. When blank, Settings shows the effective auto-detect target as a hint. + +Projects Settings is capability-aware: with **Run mode = None**, launch profile / site URL / restart-app controls are hidden; **Tests** and build/watch options remain. Preferred site URL appears only when launch profiles declare `applicationUrl`. + Output uses `--verbosity normal` and a detailed console logger (per-test pass/fail lines plus a summary in the finish banner). **Stop processes locking build output** applies before builds and when a full test rebuild is needed (or on lock-error retry). It is not used for the normal `--no-build` test path while the site stays up. Enable it when the app is started outside Build Monitor and locks `bin` output during rebuilds. diff --git a/src/BuildMonitor.Tests/SettingsProjectCapabilityPolicyTests.cs b/src/BuildMonitor.Tests/SettingsProjectCapabilityPolicyTests.cs new file mode 100644 index 0000000..5c7f99e --- /dev/null +++ b/src/BuildMonitor.Tests/SettingsProjectCapabilityPolicyTests.cs @@ -0,0 +1,174 @@ +using BuildMonitor.Core.Models; +using BuildMonitor.Core.Rules; +using BuildMonitor.Core.Settings; + +namespace BuildMonitor.Tests; + +public sealed class SettingsProjectCapabilityPolicyTests +{ + [Fact] + public void RunMode_None_hides_launch_site_and_restart_keeps_tests() + { + var project = LocalProject(ProjectRunMode.None); + var caps = SettingsProjectCapabilityPolicy.Evaluate( + project, + launchProfilesAvailable: true, + siteUrlApplicable: true); + + Assert.True(caps.HasLocalAttachment); + Assert.True(caps.RunModeNone); + Assert.False(caps.Runnable); + Assert.False(caps.LaunchProfilesAvailable); + Assert.False(caps.SiteUrlApplicable); + Assert.False(caps.RestartApplicable); + Assert.False(caps.WatchRestartApplicable); + Assert.True(caps.WatchApplicable); + Assert.True(caps.TestsApplicable); + } + + [Fact] + public void Non_web_runnable_shows_launch_not_site_url() + { + var project = LocalProject(ProjectRunMode.Run); + var caps = SettingsProjectCapabilityPolicy.Evaluate( + project, + launchProfilesAvailable: true, + siteUrlApplicable: false); + + Assert.True(caps.Runnable); + Assert.True(caps.LaunchProfilesAvailable); + Assert.False(caps.SiteUrlApplicable); + Assert.True(caps.RestartApplicable); + Assert.True(caps.TestsApplicable); + } + + [Fact] + public void Web_runnable_shows_launch_and_site_url() + { + var project = LocalProject(ProjectRunMode.Watch); + var caps = SettingsProjectCapabilityPolicy.Evaluate( + project, + launchProfilesAvailable: true, + siteUrlApplicable: true); + + Assert.True(caps.LaunchProfilesAvailable); + Assert.True(caps.SiteUrlApplicable); + Assert.True(caps.WatchRestartApplicable); + Assert.True(caps.RestartApplicable); + } + + [Fact] + public void Azure_only_project_hides_local_capabilities() + { + var project = new MonitoredProjectSettings + { + DisplayName = "Azure only", + Local = null, + Azure = new AzureDevOpsProjectAttachment { ConnectionId = "c1" } + }; + + var caps = SettingsProjectCapabilityPolicy.Evaluate(project, true, true); + Assert.False(caps.HasLocalAttachment); + Assert.False(caps.TestsApplicable); + Assert.False(caps.Runnable); + } + + [Fact] + public void Capability_visibility_does_not_require_hard_restart_classification() + { + // Presentation-only AppBehavior change remains Presentation under #87. + var before = new AppSettings + { + Projects = [LocalProject(ProjectRunMode.None)], + AppBehavior = new AppBehaviorSettings { TrayMenuLayout = TrayMenuLayout.ByOperation } + }; + var after = System.Text.Json.JsonSerializer.Deserialize( + System.Text.Json.JsonSerializer.Serialize(before))!; + after.AppBehavior.TrayMenuLayout = TrayMenuLayout.ByProject; + + Assert.Equal( + SettingsApplyImpact.Presentation, + SettingsApplyImpactClassifier.Classify(before, after)); + } + + private static MonitoredProjectSettings LocalProject(ProjectRunMode mode) => new() + { + Id = "p1", + DisplayName = "Sample", + Local = new LocalProjectAttachment + { + RootFolder = @"C:\src\Sample", + ProjectFile = "Sample.csproj", + RunOptions = { RunMode = mode } + } + }; +} + +public sealed class TestProjectPathRulesTests +{ + [Fact] + public void Empty_test_path_is_valid() + { + Assert.True(TestProjectPathRules.IsValidForRoot(@"C:\src\BuildMonitor", "")); + Assert.Equal("", TestProjectPathRules.SanitizeForRoot(@"C:\src\BuildMonitor", " ")); + } + + [Fact] + public void Foreign_relative_path_is_rejected() + { + var root = Path.Combine(Path.GetTempPath(), "bm-root-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try + { + var foreign = @"WitherbyConnect.Tests\WitherbyConnect.Tests.csproj"; + Assert.False(TestProjectPathRules.IsValidForRoot(root, foreign)); + Assert.Equal("", TestProjectPathRules.SanitizeForRoot(root, foreign)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void Owned_existing_test_project_is_accepted() + { + var root = Path.Combine(Path.GetTempPath(), "bm-root-" + Guid.NewGuid().ToString("N")); + var testsDir = Path.Combine(root, "src", "BuildMonitor.Tests"); + Directory.CreateDirectory(testsDir); + var testsProj = Path.Combine(testsDir, "BuildMonitor.Tests.csproj"); + File.WriteAllText(testsProj, ""); + try + { + var relative = Path.Combine("src", "BuildMonitor.Tests", "BuildMonitor.Tests.csproj"); + Assert.True(TestProjectPathRules.IsValidForRoot(root, relative)); + Assert.Equal(relative, TestProjectPathRules.SanitizeForRoot(root, relative)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void Switching_projects_does_not_keep_foreign_test_path_on_sanitize() + { + var buildMonitor = new MonitoredProjectSettings + { + Id = "bm", + DisplayName = "BuildMonitor.TrayApp", + Local = new LocalProjectAttachment + { + RootFolder = @"C:\src\BuildMonitor", + ProjectFile = @"src\TrayApp\BuildMonitor.TrayApp.csproj", + TestProjectFile = @"WitherbyConnect.Tests\WitherbyConnect.Tests.csproj" + } + }; + + buildMonitor.Local.TestProjectFile = TestProjectPathRules.SanitizeForRoot( + buildMonitor.Local.RootFolder, + buildMonitor.Local.TestProjectFile); + + Assert.Equal(string.Empty, buildMonitor.Local.TestProjectFile); + } +} diff --git a/src/Core/Rules/SettingsProjectCapabilityPolicy.cs b/src/Core/Rules/SettingsProjectCapabilityPolicy.cs new file mode 100644 index 0000000..48194ef --- /dev/null +++ b/src/Core/Rules/SettingsProjectCapabilityPolicy.cs @@ -0,0 +1,60 @@ +using BuildMonitor.Core.Models; +using BuildMonitor.Core.Settings; + +namespace BuildMonitor.Core.Rules; + +/// +/// Capability flags for Projects Settings presentation. Derived from attachment + run mode + +/// launch-profile evidence — never from project display names. +/// +public sealed record SettingsProjectCapabilities( + bool HasLocalAttachment, + bool RunModeNone, + bool Runnable, + bool LaunchProfilesAvailable, + bool SiteUrlApplicable, + bool WatchApplicable, + bool RestartApplicable, + bool WatchRestartApplicable, + bool TestsApplicable); + +/// Computes which Settings controls are meaningful for the selected project. +public static class SettingsProjectCapabilityPolicy +{ + public static SettingsProjectCapabilities Evaluate( + MonitoredProjectSettings? project, + bool launchProfilesAvailable = false, + bool siteUrlApplicable = false) + { + var local = project?.Local; + if (local is null) + { + return new SettingsProjectCapabilities( + HasLocalAttachment: false, + RunModeNone: true, + Runnable: false, + LaunchProfilesAvailable: false, + SiteUrlApplicable: false, + WatchApplicable: false, + RestartApplicable: false, + WatchRestartApplicable: false, + TestsApplicable: false); + } + + var runMode = local.RunOptions.RunMode; + var runModeNone = runMode == ProjectRunMode.None; + var runnable = !runModeNone; + var watch = runMode == ProjectRunMode.Watch; + + return new SettingsProjectCapabilities( + HasLocalAttachment: true, + RunModeNone: runModeNone, + Runnable: runnable, + LaunchProfilesAvailable: runnable && launchProfilesAvailable, + SiteUrlApplicable: runnable && siteUrlApplicable, + WatchApplicable: true, + RestartApplicable: runnable, + WatchRestartApplicable: watch, + TestsApplicable: true); + } +} diff --git a/src/Core/Rules/TestProjectPathRules.cs b/src/Core/Rules/TestProjectPathRules.cs new file mode 100644 index 0000000..2d2f9dd --- /dev/null +++ b/src/Core/Rules/TestProjectPathRules.cs @@ -0,0 +1,55 @@ +namespace BuildMonitor.Core.Rules; + +/// +/// Validates that a persisted Test project / solution path belongs to the project's root folder. +/// Prevents cross-project relative paths (e.g. Witherby tests under a BuildMonitor root) from sticking. +/// +public static class TestProjectPathRules +{ + /// + /// Empty is valid (auto-discover). Otherwise the path must resolve under + /// and exist as a file when the root exists. + /// + public static bool IsValidForRoot(string? rootFolder, string? testProjectFile) + { + if (string.IsNullOrWhiteSpace(testProjectFile)) + { + return true; + } + + if (string.IsNullOrWhiteSpace(rootFolder)) + { + return false; + } + + string full; + try + { + full = Path.IsPathRooted(testProjectFile) + ? Path.GetFullPath(testProjectFile) + : Path.GetFullPath(Path.Combine(rootFolder, testProjectFile)); + var rootFull = Path.GetFullPath(rootFolder); + var rootPrefix = rootFull.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + if (!full.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) + && !string.Equals(full, rootFull, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + catch + { + return false; + } + + return File.Exists(full); + } + + /// + /// Returns when valid; otherwise empty (auto-discover). + /// + public static string SanitizeForRoot(string? rootFolder, string? testProjectFile) => + IsValidForRoot(rootFolder, testProjectFile) + ? (testProjectFile ?? string.Empty).Trim() + : string.Empty; +} diff --git a/src/TrayApp/Services/LaunchProfileDiscovery.cs b/src/TrayApp/Services/LaunchProfileDiscovery.cs index 5b17349..89ec9ab 100644 --- a/src/TrayApp/Services/LaunchProfileDiscovery.cs +++ b/src/TrayApp/Services/LaunchProfileDiscovery.cs @@ -72,6 +72,54 @@ public static IReadOnlyList DiscoverProfiles(string rootFolder, string p return profiles[0]; } + /// + /// True when any launch profile declares applicationUrl (web/site-ready projects). + /// + public static bool AnyProfileHasApplicationUrl(string rootFolder, string projectFile) + { + var fullProjectPath = ResolveProjectPath(rootFolder, projectFile); + if (string.IsNullOrWhiteSpace(fullProjectPath) || !File.Exists(fullProjectPath)) + { + return false; + } + + var projectDir = Path.GetDirectoryName(fullProjectPath); + if (string.IsNullOrWhiteSpace(projectDir)) + { + return false; + } + + var launchSettingsPath = Path.Combine(projectDir, "Properties", "launchSettings.json"); + if (!File.Exists(launchSettingsPath)) + { + return false; + } + + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(launchSettingsPath)); + if (!doc.RootElement.TryGetProperty("profiles", out var profiles)) + { + return false; + } + + foreach (var profile in profiles.EnumerateObject()) + { + if (profile.Value.TryGetProperty("applicationUrl", out var url) + && !string.IsNullOrWhiteSpace(url.GetString())) + { + return true; + } + } + } + catch + { + return false; + } + + return false; + } + public static string ToRelativePath(string rootFolder, string absolutePath) { if (string.IsNullOrWhiteSpace(rootFolder) || !Directory.Exists(rootFolder)) diff --git a/src/TrayApp/SettingsWindow.xaml b/src/TrayApp/SettingsWindow.xaml index 0bb57e3..8e34867 100644 --- a/src/TrayApp/SettingsWindow.xaml +++ b/src/TrayApp/SettingsWindow.xaml @@ -6,18 +6,12 @@ Title="Local Build Monitor Settings" - Width="980" - - Height="820" - - MinWidth="900" - - MinHeight="760" - + Width="1160" + Height="800" + MinWidth="1000" + MinHeight="700" SizeToContent="Manual" - WindowStartupLocation="Manual" - Loaded="WindowLoaded"> @@ -261,8 +255,9 @@ - + + + + + - + @@ -330,7 +331,8 @@ - + + + @@ -414,9 +418,13 @@ IsEditable="True" - Margin="0,4,0,10" + Margin="0,4,0,4" LostFocus="TestProjectComboLostFocus" /> + diff --git a/src/TrayApp/SettingsWindow.xaml.cs b/src/TrayApp/SettingsWindow.xaml.cs index 5fc369e..7f65d14 100644 --- a/src/TrayApp/SettingsWindow.xaml.cs +++ b/src/TrayApp/SettingsWindow.xaml.cs @@ -3,6 +3,7 @@ using System.Windows; using System.Windows.Controls; using BuildMonitor.Core.Models; +using BuildMonitor.Core.Rules; using BuildMonitor.Core.Settings; using BuildMonitor.Infrastructure.AzureDevOps; using BuildMonitor.Infrastructure.ControlPlane; @@ -125,6 +126,7 @@ public SettingsWindow(AppSettings settings, AppWindowsLayoutStore windowsLayoutS foreach (var project in Settings.Projects) { + SanitizePersistedTestTarget(project); projectItems.Add(project); } @@ -243,6 +245,7 @@ private void LoadEditorFromProject(MonitoredProjectSettings project) if (local is not null) { + SanitizePersistedTestTarget(project); RootFolderText.Text = local.RootFolder; ProjectFileText.Text = local.ProjectFile; ExtraArgsText.Text = local.ExtraDotNetArgs; @@ -263,17 +266,27 @@ private void LoadEditorFromProject(MonitoredProjectSettings project) ReleaseOutputLocksCheck.IsChecked = local.RunOptions.ReleaseOutputLocksBeforeBuild; ForceCompleteWarningCountsCheck.IsChecked = local.RunOptions.ForceCompleteWarningCounts; AutoRepairCorruptedOutputCheck.IsChecked = local.RunOptions.AutoRepairCorruptedOutput; + // Clear combo text before reload so prior project values cannot bleed into current. + TestProjectCombo.ItemsSource = null; + TestProjectCombo.Text = string.Empty; + LaunchProfileCombo.ItemsSource = null; + LaunchProfileCombo.Text = string.Empty; ReloadLaunchProfiles(selectCurrent: true); - ReloadTestProjectCandidates(selectCurrent: true); + ReloadTestProjectCandidates(selectCurrent: true, preferModelValue: true); RefreshAgentSkillStatus(); + ApplyCapabilityPresentation(project); } else { RootFolderText.Text = string.Empty; ProjectFileText.Text = string.Empty; + TestProjectCombo.ItemsSource = null; + TestProjectCombo.Text = string.Empty; + TestTargetEffectiveHint.Text = string.Empty; AgentSkillStatusSummary.Text = "Azure-only project"; AgentSkillStatusDetail.Text = "Associate a local folder to enable agent skill install and local build options."; InstallAgentSkillButton.IsEnabled = false; + ApplyCapabilityPresentation(project); } } finally @@ -282,6 +295,62 @@ private void LoadEditorFromProject(MonitoredProjectSettings project) } } + private static void SanitizePersistedTestTarget(MonitoredProjectSettings project) + { + if (project.Local is null) + { + return; + } + + project.Local.TestProjectFile = TestProjectPathRules.SanitizeForRoot( + project.Local.RootFolder, + project.Local.TestProjectFile); + } + + private void RunModeComboSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (isLoadingEditor || selectedProject is null) + { + return; + } + + if (RunModeCombo.SelectedItem is ProjectRunMode mode && selectedProject.Local is not null) + { + selectedProject.Local.RunOptions.RunMode = mode; + } + + ApplyCapabilityPresentation(selectedProject); + } + + private void ApplyCapabilityPresentation(MonitoredProjectSettings? project) + { + var root = project?.Local?.RootFolder ?? RootFolderText.Text.Trim(); + var projectFile = project?.Local?.ProjectFile ?? ProjectFileText.Text.Trim(); + var profiles = string.IsNullOrWhiteSpace(root) || string.IsNullOrWhiteSpace(projectFile) + ? Array.Empty() + : LaunchProfileDiscovery.DiscoverProfiles(root, projectFile); + var siteUrl = !string.IsNullOrWhiteSpace(root) + && !string.IsNullOrWhiteSpace(projectFile) + && LaunchProfileDiscovery.AnyProfileHasApplicationUrl(root, projectFile); + + // Use combo RunMode when editing so visibility updates before commit. + if (project?.Local is not null && RunModeCombo.SelectedItem is ProjectRunMode mode) + { + project.Local.RunOptions.RunMode = mode; + } + + var caps = SettingsProjectCapabilityPolicy.Evaluate( + project, + launchProfilesAvailable: profiles.Count > 0, + siteUrlApplicable: siteUrl); + + LaunchProfilePanel.Visibility = caps.LaunchProfilesAvailable ? Visibility.Visible : Visibility.Collapsed; + SiteUrlPanel.Visibility = caps.SiteUrlApplicable ? Visibility.Visible : Visibility.Collapsed; + RestartOptionsPanel.Visibility = caps.RestartApplicable ? Visibility.Visible : Visibility.Collapsed; + AutoRestartOnWatchChangesCheck.Visibility = + caps.WatchRestartApplicable ? Visibility.Visible : Visibility.Collapsed; + } + private void SetLocalEditorEnabled(bool enabled) { RootFolderText.IsEnabled = enabled; @@ -457,7 +526,7 @@ private void ReloadLaunchProfiles(bool selectCurrent) } } - private void ReloadTestProjectCandidates(bool selectCurrent) + private void ReloadTestProjectCandidates(bool selectCurrent, bool preferModelValue = false) { var root = RootFolderText.Text.Trim(); var projectFile = ProjectFileText.Text.Trim(); @@ -466,11 +535,24 @@ private void ReloadTestProjectCandidates(bool selectCurrent) .Select(path => LaunchProfileDiscovery.ToRelativePath(root, path)) .ToList(); - var current = selectCurrent - ? (TestProjectCombo.Text.Trim().Length > 0 ? TestProjectCombo.Text.Trim() : selectedProject?.Local?.TestProjectFile) - : selectedProject?.Local?.TestProjectFile; + // Prefer model when loading a project. Preferring combo Text caused cross-project bleed: + // WitherbyConnect's test path remained in the ComboBox and was written onto BuildMonitor. + string? current; + if (preferModelValue || !selectCurrent) + { + current = selectedProject?.Local?.TestProjectFile; + } + else + { + current = TestProjectCombo.Text.Trim().Length > 0 + ? TestProjectCombo.Text.Trim() + : selectedProject?.Local?.TestProjectFile; + } + + current = TestProjectPathRules.SanitizeForRoot(root, current); TestProjectCombo.ItemsSource = candidates; + TestTargetEffectiveHint.Text = string.Empty; if (!string.IsNullOrWhiteSpace(current)) { @@ -483,32 +565,26 @@ private void ReloadTestProjectCandidates(bool selectCurrent) { TestProjectCombo.Text = current; } + + return; } - else + + TestProjectCombo.Text = string.Empty; + TestProjectCombo.SelectedItem = null; + var resolution = TestProjectDiscovery.Resolve(root, projectFile, null); + if (resolution.AutoDiscovered && resolution.Targets.Count >= 1) { - var resolution = TestProjectDiscovery.Resolve(root, projectFile, null); - if (resolution.AutoDiscovered && resolution.Targets.Count == 1) + var relative = LaunchProfileDiscovery.ToRelativePath(root, resolution.Targets[0]); + TestTargetEffectiveHint.Text = $"Auto-detects: {relative}"; + if (selectedProject is not null && preferModelValue) { - var relative = LaunchProfileDiscovery.ToRelativePath(root, resolution.Targets[0]); - if (candidates.Contains(relative, StringComparer.OrdinalIgnoreCase)) - { - TestProjectCombo.SelectedItem = relative; - } - else - { - TestProjectCombo.Text = relative; - } - - if (selectedProject is not null && selectCurrent) - { - EnsureLocal(selectedProject).TestProjectFile = string.Empty; - } - } - else - { - TestProjectCombo.Text = string.Empty; + EnsureLocal(selectedProject).TestProjectFile = string.Empty; } } + else + { + TestTargetEffectiveHint.Text = resolution.DiscoveryNote; + } } private void CommitEditorToSelected() @@ -527,22 +603,38 @@ private void CommitEditorToSelected() var local = selectedProject.Local; local.RootFolder = RootFolderText.Text.Trim(); local.ProjectFile = ProjectFileText.Text.Trim(); - local.LaunchProfile = LaunchProfileCombo.Text.Trim(); - local.TestProjectFile = TestProjectCombo.Text.Trim(); + if (LaunchProfilePanel.Visibility == Visibility.Visible) + { + local.LaunchProfile = LaunchProfileCombo.Text.Trim(); + } + + local.TestProjectFile = TestProjectPathRules.SanitizeForRoot( + local.RootFolder, + TestProjectCombo.Text.Trim()); local.ExtraDotNetArgs = ExtraArgsText.Text.Trim(); local.RunOptions.RunMode = (ProjectRunMode)(RunModeCombo.SelectedItem ?? ProjectRunMode.Watch); local.BuildControlMode = ResolveBuildControlMode(); - local.PreferredSiteUrlScheme = ResolvePreferredSiteUrlScheme(); - local.StartOnLaunch = StartOnLaunchCheck.IsChecked == true; - local.RunOptions.RestartOnCrash = RestartOnCrashCheck.IsChecked == true; - if (int.TryParse(MaxRetriesText.Text, out var retries)) + if (SiteUrlPanel.Visibility == Visibility.Visible) { - local.RunOptions.MaxRestartRetries = retries; + local.PreferredSiteUrlScheme = ResolvePreferredSiteUrlScheme(); } - local.RunOptions.AutoRestartOnWatchChanges = AutoRestartOnWatchChangesCheck.IsChecked == true; - local.RunOptions.AutoRestartOnHotReloadRequest = AutoRestartOnHotReloadRequestCheck.IsChecked == true; - local.RunOptions.RestartAppAfterRebuild = RestartAppAfterRebuildCheck.IsChecked == true; + local.StartOnLaunch = StartOnLaunchCheck.IsChecked == true; + if (RestartOptionsPanel.Visibility == Visibility.Visible) + { + local.RunOptions.RestartOnCrash = RestartOnCrashCheck.IsChecked == true; + if (int.TryParse(MaxRetriesText.Text, out var retries)) + { + local.RunOptions.MaxRestartRetries = retries; + } + + local.RunOptions.AutoRestartOnHotReloadRequest = AutoRestartOnHotReloadRequestCheck.IsChecked == true; + local.RunOptions.RestartAppAfterRebuild = RestartAppAfterRebuildCheck.IsChecked == true; + if (AutoRestartOnWatchChangesCheck.Visibility == Visibility.Visible) + { + local.RunOptions.AutoRestartOnWatchChanges = AutoRestartOnWatchChangesCheck.IsChecked == true; + } + } local.RunOptions.RunTests = (TestRunTrigger)(RunTestsCombo.SelectedItem ?? TestRunTrigger.Off); local.RunOptions.AutoOpenLog = (AutoOpenLogMode)(AutoOpenLogCombo.SelectedItem ?? AutoOpenLogMode.Never); From 72f6a14c5bcc78a421610ad54962ed6a6d283090 Mon Sep 17 00:00:00 2001 From: Simon McConnell Date: Thu, 27 Aug 2026 11:31:32 +0100 Subject: [PATCH 2/6] #89: Align Projects Settings peer columns on a shared *|12|* grid. --- src/TrayApp/SettingsWindow.xaml | 306 ++++++++------------------------ 1 file changed, 78 insertions(+), 228 deletions(-) diff --git a/src/TrayApp/SettingsWindow.xaml b/src/TrayApp/SettingsWindow.xaml index 8e34867..06b181d 100644 --- a/src/TrayApp/SettingsWindow.xaml +++ b/src/TrayApp/SettingsWindow.xaml @@ -6,9 +6,9 @@ Title="Local Build Monitor Settings" - Width="1160" + Width="1200" Height="800" - MinWidth="1000" + MinWidth="1020" MinHeight="700" SizeToContent="Manual" WindowStartupLocation="Manual" @@ -106,68 +106,46 @@ + + + + + + - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - + + + - @@ -204,13 +182,9 @@ Visibility="Collapsed" /> - - - +