From 6ffe12f1af0234609abaa55ab4a01f5da707c4f5 Mon Sep 17 00:00:00 2001 From: Simon McConnell Date: Thu, 2 Jul 2026 12:44:50 +0100 Subject: [PATCH] #38: Status panel UX, build suppression, and tray health fixes Replace tray hint with hover status panel anchored above the tray icon. Add agent-aware build suppression, incremental warning count resolution for amber tray health, rebuild countdown, site-ready dismiss, and enforce Show status panel while building. Includes Tier 2 tests and docs updates. --- docs/SETTINGS.md | 19 +- docs/features/health-and-logs.md | 13 +- .../AutoOpenLogTransitionEvaluatorTests.cs | 80 ++++ .../BuildIntelligenceSnapshotTests.cs | 61 ++- .../BuildIssueCountResolverTests.cs | 45 ++ src/BuildMonitor.Tests/BuildLogParserTests.cs | 59 +++ src/BuildMonitor.Tests/BuildLogStoreTests.cs | 62 +++ .../BuildSuppressionPolicyTests.cs | 37 ++ .../BuildTriggerDetailFormatterTests.cs | 26 + .../DotNetBuildArgumentsTests.cs | 24 + .../EditActivityEvaluatorTests.cs | 57 +++ .../EditGatingDetailFormatterTests.cs | 24 + .../HealthIssueCountsFormatterTests.cs | 14 + .../IncrementalBuildDetectorTests.cs | 71 +++ .../ProjectHealthEvaluatorTests.cs | 12 + ...tatusPanelBuildVisibilityEvaluatorTests.cs | 226 +++++++++ .../TrayTooltipFormatterTests.cs | 160 ++++++- .../WatchIgnoreRulesTests.cs | 2 + src/Core/Models/AutoOpenLogMode.cs | 10 + src/Core/Models/LocalProjectModels.cs | 12 +- src/Core/Models/PendingRebuildHoldReason.cs | 21 + .../Rules/AutoOpenLogTransitionEvaluator.cs | 104 ++++ src/Core/Rules/BuildSuppressionPolicy.cs | 53 +++ src/Core/Rules/EditActivityEvaluator.cs | 66 +++ src/Core/Rules/EditGatingDetailFormatter.cs | 93 ++++ src/Core/Rules/HealthIssueCountsFormatter.cs | 18 +- .../Rules/LocalTrayIconRollupEvaluator.cs | 9 +- src/Core/Rules/ProjectHealthEvaluator.cs | 5 +- .../StatusPanelBuildVisibilityEvaluator.cs | 121 +++++ src/Core/Rules/TrayTooltipFormatter.cs | 330 ++++++++++++- src/Core/Settings/LocalAppSettings.cs | 14 +- .../Diagnostics/BuildIntelligenceSnapshot.cs | 122 ++++- .../BuildTriggerDetailFormatter.cs | 46 ++ .../LocalBuild/AgentActivityWatcher.cs | 81 ++++ .../LocalBuild/BuildIssueCountResolver.cs | 102 ++++ .../LocalBuild/BuildLogMetadataDto.cs | 4 +- .../LocalBuild/BuildLogParser.cs | 170 ++++++- .../LocalBuild/BuildLogStore.cs | 33 +- .../LocalBuild/BuildMonitorLogBanner.cs | 18 + .../LocalBuild/DebouncedFileWatcher.cs | 22 + .../LocalBuild/DotNetBuildArguments.cs | 17 + .../LocalBuild/DotNetCliRunner.cs | 66 ++- .../LocalBuild/IncrementalBuildDetector.cs | 25 + .../LocalBuild/WatchIgnoreRules.cs | 11 +- .../Services/ProjectOrchestrator.cs | 23 +- .../Services/ProjectRuntime.Build.cs | 223 +++++++-- .../Services/ProjectRuntime.BuildOutput.cs | 13 +- .../Services/ProjectRuntime.EditGating.cs | 122 +++++ .../Services/ProjectRuntime.RunOutput.cs | 12 + .../Services/ProjectRuntime.Test.cs | 4 + src/Infrastructure/Services/ProjectRuntime.cs | 63 ++- src/TrayApp/App.xaml.cs | 450 +++++++++++++++++- src/TrayApp/BuildDiagnosticsWindow.xaml | 9 +- src/TrayApp/BuildDiagnosticsWindow.xaml.cs | 191 +++++++- src/TrayApp/BuildIntelligencePanel.xaml | 220 ++++++--- src/TrayApp/BuildLogViewerWindow.xaml.cs | 99 +++- src/TrayApp/HoverStatusPanel.xaml | 10 +- src/TrayApp/HoverStatusPanel.xaml.cs | 229 +++++---- src/TrayApp/Services/AppWindowsLayout.cs | 7 +- .../Services/DiagnosticsGridLayoutService.cs | 52 ++ src/TrayApp/Services/SettingsStore.cs | 43 +- src/TrayApp/Services/StatusPanelVisuals.cs | 228 ++++++--- src/TrayApp/Services/TrayIconShellInterop.cs | 116 +++++ src/TrayApp/Services/TrayScreenPlacement.cs | 35 +- src/TrayApp/SettingsWindow.xaml | 32 +- src/TrayApp/SettingsWindow.xaml.cs | 13 +- 66 files changed, 4333 insertions(+), 426 deletions(-) create mode 100644 src/BuildMonitor.Tests/AutoOpenLogTransitionEvaluatorTests.cs create mode 100644 src/BuildMonitor.Tests/BuildIssueCountResolverTests.cs create mode 100644 src/BuildMonitor.Tests/BuildLogStoreTests.cs create mode 100644 src/BuildMonitor.Tests/BuildSuppressionPolicyTests.cs create mode 100644 src/BuildMonitor.Tests/BuildTriggerDetailFormatterTests.cs create mode 100644 src/BuildMonitor.Tests/DotNetBuildArgumentsTests.cs create mode 100644 src/BuildMonitor.Tests/EditActivityEvaluatorTests.cs create mode 100644 src/BuildMonitor.Tests/EditGatingDetailFormatterTests.cs create mode 100644 src/BuildMonitor.Tests/IncrementalBuildDetectorTests.cs create mode 100644 src/BuildMonitor.Tests/StatusPanelBuildVisibilityEvaluatorTests.cs create mode 100644 src/Core/Models/AutoOpenLogMode.cs create mode 100644 src/Core/Models/PendingRebuildHoldReason.cs create mode 100644 src/Core/Rules/AutoOpenLogTransitionEvaluator.cs create mode 100644 src/Core/Rules/BuildSuppressionPolicy.cs create mode 100644 src/Core/Rules/EditActivityEvaluator.cs create mode 100644 src/Core/Rules/EditGatingDetailFormatter.cs create mode 100644 src/Core/Rules/StatusPanelBuildVisibilityEvaluator.cs create mode 100644 src/Infrastructure/Diagnostics/BuildTriggerDetailFormatter.cs create mode 100644 src/Infrastructure/LocalBuild/AgentActivityWatcher.cs create mode 100644 src/Infrastructure/LocalBuild/BuildIssueCountResolver.cs create mode 100644 src/Infrastructure/LocalBuild/DotNetBuildArguments.cs create mode 100644 src/Infrastructure/LocalBuild/IncrementalBuildDetector.cs create mode 100644 src/Infrastructure/Services/ProjectRuntime.EditGating.cs create mode 100644 src/TrayApp/Services/DiagnosticsGridLayoutService.cs create mode 100644 src/TrayApp/Services/TrayIconShellInterop.cs diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md index 9081fd6..f41485a 100644 --- a/docs/SETTINGS.md +++ b/docs/SETTINGS.md @@ -46,9 +46,13 @@ File: `%LOCALAPPDATA%/BuildMonitor/settings.json` ## Settings UI tabs - **Projects** — per-project folder, csproj/sln, launch profile, run/watch options, **start build when app launches**, and **active in session** checkbox (left of each project name). Unchecked projects remain in the list but are not built or run until checked and settings are saved. -- **Monitor** — concurrency, debounce, **batch watch-mode rebuilds**, health refresh, auto-open log on failure, **auto-open Build Monitor Health on startup**, max log bytes. +- **Monitor** — concurrency, debounce, **batch watch-mode rebuilds**, health refresh, **auto-open Build Monitor Health on startup**, max log bytes. - **App** — theme (`System`, `Light`, `Dark`) and startup behavior. **Run when Windows starts** adds/removes an entry under `HKCU\...\Run` named `LocalBuildMonitor`. +Per project (**Projects** tab → **File watching**): + +- **`autoOpenLog`**: `Never` (default), `Errors`, `Warnings`, or `Always` — when to open the log viewer automatically after a build or test. `Warnings` opens on amber health (build succeeded with warnings) as well as failures. `Always` opens after every build or test completes. Replaces the old global `monitor.autoOpenLogOnFailure` flag (schema v11). + ## Health colors - **Green (Success)** — build/run healthy; no errors in the active context. @@ -69,6 +73,9 @@ See [features/health-and-logs.md](features/health-and-logs.md) for how build vs - **`fileChangeDebounceMode`**: `Manual` (default) or `Auto`. **Auto** learns per project from save burst length (time from first to last file change before rebuild), using p90 × 1.25 smoothed into **1500–12000 ms**. The manual ms value is used until **five** bursts are recorded. Stats persist in `%LOCALAPPDATA%/BuildMonitor/debounce-stats.json`. - **Agent session coalescing** — after the first file-triggered build in a 90-second window, further saves wait for a full quiet period since the **last** change (not a fixed 3 s post-build cooldown). Debounce increases up to **2×** when multiple file-triggered builds happen in that window. Turn on **Auto** debounce mode for longer agent sessions. - **`coalesceWatchRebuilds`** (default **true**) — in **Watch** run mode, BuildMonitor watches the project folder, waits for edits to settle, then runs one `dotnet build` and restarts the app. This replaces per-save `dotnet watch` rebuilds during agent sessions. Turn off to use `dotnet watch` hot reload instead (more rebuilds, faster feedback on single-file edits). +- **`deferStartupBuildUntilQuiet`** (default **true**) — when starting Build Monitor while an agent is still saving, wait for the quiet period before the first `dotnet build`. +- **`cancelSupersededBuilds`** (default **true**) — cancel in-flight **startup** or **file-change** builds when newer saves arrive; coalesce into one rebuild after edits settle. Manual tray rebuilds are never cancelled. +- **`useAgentTranscriptActivity`** (default **true**) — treat writes under `agent-transcripts` / `.cursor` as “agent still active” for gating (signal only; does not trigger rebuilds). Restart the project from the tray after changing this option so the run process switches between `dotnet run` and `dotnet watch`. @@ -110,13 +117,13 @@ Output uses `--verbosity normal` and a detailed console logger (per-test pass/fa ## Build diagnostics -Tray → **Build diagnostics…** opens **one tab per project**: a compact **rebuild timing** panel (wait bar, learning progress, save-burst chart) and today's build triggers for that project. +Tray → **Build diagnostics…** opens **one tab per active project**: a compact **rebuild timing** panel (metric tiles, save-burst and build-duration charts, rebuild countdown) and today's build triggers for that project. | Column | Meaning | |--------|---------| | **Kind** | Session start, file watcher, manual rebuild, hot reload, `dotnet watch`, etc. | | **Files** | Paths that triggered a debounced file-watcher rebuild (relative to project root) | -| **Detail** | Extra context (e.g. a `dotnet watch` output line) | +| **Detail** | Extra context — file-watcher debounce/hold timing, or a `dotnet watch` / hot-reload output line | | **Verdict** | Mark **Expected** or **Unexpected** to track spurious rebuilds | Persisted at `%LOCALAPPDATA%/BuildMonitor/diagnostics/build-triggers.jsonl` (**today's entries only**, local calendar day; up to 500 per day). Mark **Unexpected** triggers to spot spurious rebuilds during agent sessions. @@ -125,12 +132,13 @@ Persisted at `%LOCALAPPDATA%/BuildMonitor/diagnostics/build-triggers.jsonl` (**t **Likely cause** is a heuristic from trigger kind and changed file paths (e.g. Cursor/agent tooling folders vs source edits). **Your note** is free text — use it to record what you were doing (e.g. “Cursor ask mode chat”) when marking unexpected rebuilds. -Window size and position are saved in `%LOCALAPPDATA%/BuildMonitor/windows-layout.json` (Settings, build log, diagnostics, and status panel width — height auto-fits content up to 520 px). +Window size and position are saved in `%LOCALAPPDATA%/BuildMonitor/windows-layout.json` (Settings, build log, diagnostics — including trigger grid column widths — and status panel width — height auto-fits content up to 460 px). ## Watch / file-watcher excludes - **`watchExcludeSegments`** — semicolon-separated folder names ignored by BuildMonitor’s debounced file watcher (`TriggerRebuild` mode). Defaults include `.cursor`, `agent-transcripts`, `logs`, `bin`, `obj`, and similar tooling/output folders. -- Noisy file types (`.log`, `.dll`, `.pdb`, `.tmp`, etc.) are also ignored so build output and tooling writes are less likely to trigger rebuilds. +- Noisy file types (`.log`, `.dll`, `.pdb`, `.tmp`, common image formats under `wwwroot`, etc.) are also ignored so build output and static assets are less likely to trigger rebuilds. +- **`wwwroot/Images`** — image saves are ignored by default (`.png`, `.jpg`, `.gif`, `.webp`, `.svg`, `.ico`, …). **`wwwroot/Files`** (PDFs, Office docs, etc.) still trigger rebuilds unless you add `Files` or `wwwroot` to **`watchExcludeSegments`**. - For **dotnet watch**, also add `` (and similar) to the monitored `.csproj`. Defaults and behaviour: [features/health-and-logs.md](features/health-and-logs.md). ## Developer environment (not in settings UI) @@ -154,3 +162,4 @@ Window size and position are saved in `%LOCALAPPDATA%/BuildMonitor/windows-layou - **Restart app after rebuild** — when run mode is Watch or Run, start (or restart) the app after a successful rebuild, including the first successful build after a prior failure. - **Restart app** — stop and start run/watch with `--no-build` (no full rebuild). - **Rebuild & restart** — full `dotnet build`, then start run/watch (shows build progress in status panel). +- **Show status panel while building** (default **off**) — per project. Opens the hover status panel when a build starts and hides it when the build finishes. Does not auto-hide if you already had the panel open before the build started. diff --git a/docs/features/health-and-logs.md b/docs/features/health-and-logs.md index 9317113..c81bb56 100644 --- a/docs/features/health-and-logs.md +++ b/docs/features/health-and-logs.md @@ -11,10 +11,17 @@ How BuildMonitor decides what failed and what the tray, status panel, and log vi | 3 | Tray receives immutable snapshot list at bounded rate | `ProjectOrchestrator.HealthUpdated` → `App.OnHealthUpdated` (`ApplicationIdle`, coalesced) | | 4 | Run/watch output updates `runErrorCount` / `runWarningCount` | `DotNetRunOutputParser`, `OnRunProcessOutputLine` (coalesced same as build) | | 5 | Snapshot picks display counts by lifecycle state | `HealthIssueCountsFormatter.SelectPrimaryCounts` | -| 6 | Tray tooltip uses headline project + failure phase + error preview | `App.FormatTrayTooltip` | +| 5b | Incremental file builds with 0/0 MSBuild summary reuse counts from metadata, `.prev`, or the BuildMonitor incremental note | `IncrementalBuildDetector`, `BuildIssueCountResolver`, `BuildLogStore` | +| 5c | **Startup**, **Rebuild**, and **Rebuild & restart** pass `--no-incremental` so MSBuild recompiles and reports real warning/error counts | `DotNetBuildArguments` | +| 5d | Post-build tests no longer clear build warning/error counts used for tray health | `ProjectRuntime.TestAsync` | +| 5e | **Agent-aware build suppression** — defer startup until edits settle; cancel superseded startup/file-change builds; optional agent-transcript activity signal | `EditActivityEvaluator`, `BuildSuppressionPolicy`, `ProjectRuntime` | +| 5f | Edit gating auto-shows status panel with hold detail + countdown | `App.AutoShowStatusPanelForEditGating`, `HoverStatusPanel` | +| 6 | Tray hover opens the status panel (project health, counts, gating detail, actions); native shell tooltip is suppressed | `HoverStatusPanel`, `TrayIconShellInterop` | | 7 | Status panel shows `IssueCountsText` (build vs run context) | `HoverStatusPanel` | | 8 | Log viewer parses Build / Run / Test tabs with matching parsers | `BuildLogViewerWindow.ParseIssuesForCurrentLog` | -| 9 | Failure auto-opens log on correct tab + Errors filter | `App.AutoOpenLogsOnFailureTransition` | +| 8b | Log viewer footer and issues summary use the same resolved counts (metadata, `.prev`, incremental note); incremental 0/0 builds show a carry-forward note in the issues pane | `BuildLogViewerWindow.RefreshResolvedIssueCounts` | +| 9 | Auto-open log per project (`Never` / `Errors` / `Warnings` / `Always`) | `App.AutoOpenLogsOnTransition`, `AutoOpenLogTransitionEvaluator` | +| 9b | Auto-show status panel while building (per project, default off) | `App.AutoShowStatusPanelWhileBuilding`, `StatusPanelBuildVisibilityEvaluator` | | 10 | **Restart app** stops run/watch and starts with `--no-build` | `ProjectRuntime.RestartAppCoreAsync(rebuildFirst: false)` | | 11 | **Rebuild & restart** runs full build then starts app | `ProjectRuntime.RestartAppCoreAsync(rebuildFirst: true)` | | 12 | Hot-reload “requires restart/rebuild” lines trigger auto-restart when enabled | `HotReloadRestartDetector`, `ProjectRuntime.TryHandleHotReloadRestartRequest` | @@ -38,7 +45,7 @@ Per-project dirty flags; up to `MaxConcurrentActiveProjects` (default 3) share o **Extension points:** add run-error heuristics in `DotNetRunOutputParser`; adjust status formatting in `HealthIssueCountsFormatter`. -**Failure / fallback:** tray tooltip truncated to 63 characters; issue scroll uses text-match fallback when line index drifts after log truncation. +**Failure / fallback:** native shell tooltip is suppressed via empty `NotifyIcon.Text` (custom hint only). `TrayIconShellInterop` resolves icon bounds for hint dismiss. Issue scroll uses text-match fallback when line index drifts after log truncation. ## Watch excludes (dotnet watch) diff --git a/src/BuildMonitor.Tests/AutoOpenLogTransitionEvaluatorTests.cs b/src/BuildMonitor.Tests/AutoOpenLogTransitionEvaluatorTests.cs new file mode 100644 index 0000000..786c2fd --- /dev/null +++ b/src/BuildMonitor.Tests/AutoOpenLogTransitionEvaluatorTests.cs @@ -0,0 +1,80 @@ +using BuildMonitor.Core.Models; +using BuildMonitor.Core.Rules; + +namespace BuildMonitor.Tests; + +public sealed class AutoOpenLogTransitionEvaluatorTests +{ + [Theory] + [InlineData(AutoOpenLogMode.Never, MonitorHealth.Green, MonitorHealth.Red, ProjectLifecycleState.Building, ProjectLifecycleState.BuildFailed, false)] + [InlineData(AutoOpenLogMode.Errors, MonitorHealth.Green, MonitorHealth.Red, ProjectLifecycleState.Watching, ProjectLifecycleState.BuildFailed, false)] + [InlineData(AutoOpenLogMode.Errors, MonitorHealth.Green, MonitorHealth.Red, ProjectLifecycleState.Building, ProjectLifecycleState.BuildFailed, true)] + [InlineData(AutoOpenLogMode.Errors, MonitorHealth.Red, MonitorHealth.Red, ProjectLifecycleState.BuildFailed, ProjectLifecycleState.BuildFailed, false)] + [InlineData(AutoOpenLogMode.Errors, MonitorHealth.Green, MonitorHealth.Red, ProjectLifecycleState.Building, ProjectLifecycleState.Building, false)] + [InlineData(AutoOpenLogMode.Warnings, MonitorHealth.Green, MonitorHealth.Amber, ProjectLifecycleState.Watching, ProjectLifecycleState.Watching, true)] + [InlineData(AutoOpenLogMode.Warnings, MonitorHealth.Amber, MonitorHealth.Red, ProjectLifecycleState.Watching, ProjectLifecycleState.BuildFailed, true)] + [InlineData(AutoOpenLogMode.Always, MonitorHealth.Green, MonitorHealth.Green, ProjectLifecycleState.Building, ProjectLifecycleState.Watching, true)] + [InlineData(AutoOpenLogMode.Always, MonitorHealth.Green, MonitorHealth.Green, ProjectLifecycleState.Testing, ProjectLifecycleState.TestOk, true)] + public void ShouldOpen_respects_mode_and_transition( + AutoOpenLogMode mode, + MonitorHealth previousHealth, + MonitorHealth currentHealth, + ProjectLifecycleState previousState, + ProjectLifecycleState currentState, + bool expected) => + Assert.Equal( + expected, + AutoOpenLogTransitionEvaluator.ShouldOpen( + mode, + previousHealth, + currentHealth, + previousState, + currentState, + errorCount: 0)); + + [Fact] + public void ShouldOpen_errors_mode_on_run_crash() + { + Assert.True( + AutoOpenLogTransitionEvaluator.ShouldOpen( + AutoOpenLogMode.Errors, + MonitorHealth.Green, + MonitorHealth.Red, + ProjectLifecycleState.Running, + ProjectLifecycleState.Crashed)); + } + + [Fact] + public void ResolveIssueFilters_selects_errors_for_failed_build_without_parsed_count() + { + var snapshot = new ProjectHealthSnapshot( + "p1", + "Demo", + MonitorHealth.Red, + "Failed", + ProjectLifecycleState.BuildFailed, + LastExitCode: 1, + LastDuration: TimeSpan.FromSeconds(3), + LastErrorPreview: "targets(269,5): error : asset missing", + ErrorCount: 0, + WarningCount: 1065, + LastChangedUtc: DateTimeOffset.UtcNow, + LastBuildFinishedAtUtc: DateTimeOffset.UtcNow, + IsActive: true, + ProgressSteps: []); + + var (errors, warnings) = AutoOpenLogTransitionEvaluator.ResolveIssueFilters( + AutoOpenLogMode.Errors, + snapshot); + + Assert.True(errors); + Assert.False(warnings); + } + + [Fact] + public void PreferWarningsFilter_only_when_no_errors() + { + Assert.True(AutoOpenLogTransitionEvaluator.PreferWarningsFilter(0, 3)); + Assert.False(AutoOpenLogTransitionEvaluator.PreferWarningsFilter(1, 3)); + } +} diff --git a/src/BuildMonitor.Tests/BuildIntelligenceSnapshotTests.cs b/src/BuildMonitor.Tests/BuildIntelligenceSnapshotTests.cs index 07166c5..674418b 100644 --- a/src/BuildMonitor.Tests/BuildIntelligenceSnapshotTests.cs +++ b/src/BuildMonitor.Tests/BuildIntelligenceSnapshotTests.cs @@ -1,3 +1,4 @@ +using BuildMonitor.Core.Models; using BuildMonitor.Core.Settings; using BuildMonitor.Infrastructure.Diagnostics; using BuildMonitor.Infrastructure.LocalBuild; @@ -115,13 +116,65 @@ public void BurstBars_scale_relative_to_largest_sample() Assert.Equal(1.0, snapshot.BurstBars.Last().HeightRatio, 3); } + [Fact] + public void NextRebuildReasonText_timer_reset_mentions_files_and_restart() + { + var snapshot = CreateSnapshot( + pendingFileChangeRebuild: true, + rebuildQuietUntilUtc: DateTimeOffset.UtcNow.AddSeconds(3), + holdReason: PendingRebuildHoldReason.EditsStillArriving, + pendingRebuildFileCount: 3, + pendingRebuildSamplePaths: ["Foo.cs", "Bar.cs"], + rebuildTimerResetCount: 2); + + Assert.Contains("Wait timer reset", snapshot.NextRebuildReasonText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("3 file(s)", snapshot.NextRebuildReasonText, StringComparison.Ordinal); + Assert.Contains("Quiet period restarted", snapshot.NextRebuildReasonText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Foo.cs", snapshot.NextRebuildReasonText, StringComparison.Ordinal); + } + + [Fact] + public void NextRebuildReasonText_build_in_progress_explains_wait() + { + var snapshot = CreateSnapshot( + pendingFileChangeRebuild: true, + holdReason: PendingRebuildHoldReason.BuildInProgress); + + Assert.Contains("current build", snapshot.NextRebuildReasonText, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void NextRebuildReasonText_startup_deferred_mentions_quiet_period() + { + var snapshot = CreateSnapshot( + pendingFileChangeRebuild: true, + holdReason: PendingRebuildHoldReason.StartupDeferred); + + Assert.Contains("Startup build deferred", snapshot.NextRebuildReasonText, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData(new[] { "src/A.cs" }, 1, " (src/A.cs)")] + [InlineData(new[] { "A.cs", "B.cs" }, 4, " (A.cs, B.cs +2 more)")] + public void FormatPendingFileSample_formats_path_suffix( + string[] paths, + int totalCount, + string expectedSuffix) => + Assert.EndsWith( + expectedSuffix, + BuildIntelligenceSnapshot.FormatPendingFileSample(paths, totalCount)); + private static BuildIntelligenceSnapshot CreateSnapshot( FileChangeDebounceMode debounceMode = FileChangeDebounceMode.Auto, int baseEffectiveDebounceMs = 3000, int liveEffectiveDebounceMs = 3000, int recentFileChangeBuildsIn90s = 0, bool pendingFileChangeRebuild = false, - DateTimeOffset? rebuildQuietUntilUtc = null) => + DateTimeOffset? rebuildQuietUntilUtc = null, + PendingRebuildHoldReason holdReason = PendingRebuildHoldReason.None, + int pendingRebuildFileCount = 0, + IReadOnlyList? pendingRebuildSamplePaths = null, + int rebuildTimerResetCount = 0) => BuildIntelligenceSnapshot.Create( SampleProject(), new GlobalMonitorSettings { FileChangeDebounceMode = debounceMode }, @@ -134,7 +187,11 @@ private static BuildIntelligenceSnapshot CreateSnapshot( coalesceWatchRebuilds: true, lastMeaningfulFileChangeUtc: null, pendingFileChangeRebuild: pendingFileChangeRebuild, - rebuildQuietUntilUtc: rebuildQuietUntilUtc); + rebuildQuietUntilUtc: rebuildQuietUntilUtc, + holdReason: holdReason, + pendingRebuildFileCount: pendingRebuildFileCount, + pendingRebuildSamplePaths: pendingRebuildSamplePaths, + rebuildTimerResetCount: rebuildTimerResetCount); private static LocalProjectDefinition SampleProject() => new() { diff --git a/src/BuildMonitor.Tests/BuildIssueCountResolverTests.cs b/src/BuildMonitor.Tests/BuildIssueCountResolverTests.cs new file mode 100644 index 0000000..22b19c9 --- /dev/null +++ b/src/BuildMonitor.Tests/BuildIssueCountResolverTests.cs @@ -0,0 +1,45 @@ +using BuildMonitor.Infrastructure.LocalBuild; + +namespace BuildMonitor.Tests; + +public sealed class BuildIssueCountResolverTests +{ + private const string FullLog = """ + C:\app\Pages\Index.razor(1,1): warning CS8618: Field required [C:\app\app.csproj] + + Build succeeded. + 1066 Warning(s) + 0 Error(s) + """; + + private const string IncrementalLog = """ + WitherbyConnect -> C:\app\bin\Debug\net9.0\WitherbyConnect.dll + + Build succeeded. + 0 Warning(s) + 0 Error(s) + """; + + [Fact] + public void Resolve_incremental_output_uses_previous_log_counts() + { + var dir = Path.Combine(Path.GetTempPath(), $"bm-resolver-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + var logPath = Path.Combine(dir, "last-build.log"); + var prevPath = logPath + ".prev"; + + try + { + File.WriteAllText(prevPath, FullLog); + + var (errors, warnings) = BuildIssueCountResolver.Resolve(IncrementalLog, logPath); + + Assert.Equal(0, errors); + Assert.Equal(1066, warnings); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } +} diff --git a/src/BuildMonitor.Tests/BuildLogParserTests.cs b/src/BuildMonitor.Tests/BuildLogParserTests.cs index 375013a..1be656a 100644 --- a/src/BuildMonitor.Tests/BuildLogParserTests.cs +++ b/src/BuildMonitor.Tests/BuildLogParserTests.cs @@ -58,4 +58,63 @@ public void ParseErrorCount_reads_combined_terminal_logger_summary() Assert.Equal(2, BuildLogParser.ParseErrorCount(log)); Assert.Equal(17, BuildLogParser.ParseWarningCount(log)); } + + [Fact] + public void ParseWarningCount_reads_incremental_health_note() + { + const string log = """ + Build succeeded. + 0 Warning(s) + 0 Error(s) + + [BuildMonitor] Incremental build — compiler skipped (outputs up-to-date). Tray health uses 1065 warning(s) from the previous full build log. + """; + + Assert.Equal(1065, BuildLogParser.ParseWarningCount(log)); + } + + [Fact] + public void ParseErrorCount_finds_msbuild_error_before_build_failed_line() + { + const string log = """ + [BuildMonitor] ===== Build #3 started 2026-06-24 10:00:00 — file change ===== + C:\proj\Microsoft.NET.Sdk.StaticWebAssets.Compression.targets(269,5): error : The asset 'C:\proj\obj\Debug\net9.0\compressed\foo.gz' can not be found at any of the searched locations 'wwwroot\css\app.css'. + Build FAILED. + C:\proj\Microsoft.NET.Sdk.StaticWebAssets.Compression.targets(269,5): error : The asset 'C:\proj\obj\Debug\net9.0\compressed\foo.gz' can not be found at any of the searched locations 'wwwroot\css\app.css'. + """; + + Assert.Equal(1, BuildLogParser.ParseErrorCount(log)); + } + + [Fact] + public void ResolveBuildIssues_falls_back_to_previous_log_when_incremental() + { + var dir = Path.Combine(Path.GetTempPath(), $"bm-prev-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + var currentPath = Path.Combine(dir, "last-build.log"); + var prevPath = currentPath + ".prev"; + try + { + File.WriteAllText(prevPath, """ + C:\app\Foo.cs(1,1): warning CS8618: required [C:\app\app.csproj] + + Build succeeded. + 1 Warning(s) + 0 Error(s) + """); + File.WriteAllText(currentPath, """ + Build succeeded. + 0 Warning(s) + 0 Error(s) + """); + + var issues = BuildLogParser.ResolveBuildIssues(File.ReadAllText(currentPath), currentPath); + Assert.Single(issues); + Assert.False(issues[0].IsError); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } } diff --git a/src/BuildMonitor.Tests/BuildLogStoreTests.cs b/src/BuildMonitor.Tests/BuildLogStoreTests.cs new file mode 100644 index 0000000..c5c0249 --- /dev/null +++ b/src/BuildMonitor.Tests/BuildLogStoreTests.cs @@ -0,0 +1,62 @@ +using BuildMonitor.Core.Models; +using BuildMonitor.Infrastructure.LocalBuild; + +namespace BuildMonitor.Tests; + +public sealed class BuildLogStoreTests +{ + private const string FullLog = """ + C:\app\Pages\Index.razor(1,1): warning CS8618: Field required [C:\app\app.csproj] + + Build succeeded. + 1065 Warning(s) + 0 Error(s) + """; + + private const string IncrementalLog = """ + WitherbyConnect -> C:\app\bin\Debug\net9.0\WitherbyConnect.dll + + Build succeeded. + 0 Warning(s) + 0 Error(s) + """; + + [Fact] + public async Task SaveAsync_preserves_warning_counts_when_incremental_overwrites_log() + { + var dir = Path.Combine(Path.GetTempPath(), $"bm-store-{Guid.NewGuid():N}"); + var store = new BuildLogStore(dir); + const string projectId = "demo"; + var started = DateTimeOffset.UtcNow; + + try + { + await store.SaveAsync( + projectId, + BuildLogKind.Build, + "dotnet build", + exitCode: 0, + started, + FullLog); + + var incremental = await store.SaveAsync( + projectId, + BuildLogKind.Build, + "dotnet build", + exitCode: 0, + started, + IncrementalLog); + + Assert.Equal(0, incremental.ErrorCount); + Assert.Equal(1065, incremental.WarningCount); + + var loaded = await store.LoadMetadataAsync(projectId, BuildLogKind.Build); + Assert.NotNull(loaded); + Assert.Equal(1065, loaded.WarningCount); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } +} diff --git a/src/BuildMonitor.Tests/BuildSuppressionPolicyTests.cs b/src/BuildMonitor.Tests/BuildSuppressionPolicyTests.cs new file mode 100644 index 0000000..c8add1f --- /dev/null +++ b/src/BuildMonitor.Tests/BuildSuppressionPolicyTests.cs @@ -0,0 +1,37 @@ +using BuildMonitor.Core.Models; +using BuildMonitor.Core.Rules; + +namespace BuildMonitor.Tests; + +public sealed class BuildSuppressionPolicyTests +{ + private static readonly BuildSuppressionSettings Enabled = new(true, true); + + [Theory] + [InlineData("startup", true)] + [InlineData("file change", true)] + [InlineData("file change (queued)", true)] + [InlineData("manual rebuild", false)] + [InlineData("rebuild & restart", false)] + [InlineData("startup (lock retry)", false)] + public void ShouldCancelInFlightBuild_respects_build_reason(string reason, bool expected) => + Assert.Equal(expected, BuildSuppressionPolicy.ShouldCancelInFlightBuild(Enabled, reason)); + + [Fact] + public void ShouldDeferStartupBuild_when_activity_active() + { + var activity = new EditActivitySnapshot(true, DateTimeOffset.UtcNow.AddSeconds(5), "pending"); + Assert.True(BuildSuppressionPolicy.ShouldDeferStartupBuild(Enabled, activity)); + } + + [Fact] + public void IsEditGatingActive_for_startup_deferred_hold() + { + var activity = EditActivitySnapshot.Inactive; + Assert.True(BuildSuppressionPolicy.IsEditGatingActive( + Enabled, + pendingFileChangeRebuild: true, + activity, + PendingRebuildHoldReason.StartupDeferred)); + } +} diff --git a/src/BuildMonitor.Tests/BuildTriggerDetailFormatterTests.cs b/src/BuildMonitor.Tests/BuildTriggerDetailFormatterTests.cs new file mode 100644 index 0000000..bb6a817 --- /dev/null +++ b/src/BuildMonitor.Tests/BuildTriggerDetailFormatterTests.cs @@ -0,0 +1,26 @@ +using BuildMonitor.Core.Models; +using BuildMonitor.Infrastructure.Diagnostics; + +namespace BuildMonitor.Tests; + +public sealed class BuildTriggerDetailFormatterTests +{ + [Fact] + public void FormatImmediateDebounce_includes_ms() + { + var detail = BuildTriggerDetailFormatter.FormatImmediateDebounce(3000); + Assert.Contains("3000", detail, StringComparison.Ordinal); + } + + [Fact] + public void FormatCoalescedBuild_includes_hold_reason() + { + var detail = BuildTriggerDetailFormatter.FormatCoalescedBuild( + 4500, + PendingRebuildHoldReason.BuildInProgress, + timerResetCount: 0); + + Assert.Contains("4500", detail, StringComparison.Ordinal); + Assert.Contains("build finished", detail, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/BuildMonitor.Tests/DotNetBuildArgumentsTests.cs b/src/BuildMonitor.Tests/DotNetBuildArgumentsTests.cs new file mode 100644 index 0000000..191c8f4 --- /dev/null +++ b/src/BuildMonitor.Tests/DotNetBuildArgumentsTests.cs @@ -0,0 +1,24 @@ +using BuildMonitor.Infrastructure.LocalBuild; + +namespace BuildMonitor.Tests; + +public sealed class DotNetBuildArgumentsTests +{ + [Theory] + [InlineData("manual rebuild", true)] + [InlineData("rebuild & restart", true)] + [InlineData("startup", true)] + [InlineData("file change", false)] + public void RequiresFullRebuild_matches_explicit_user_actions(string reason, bool expected) => + Assert.Equal(expected, DotNetBuildArguments.RequiresFullRebuild(reason)); + + [Fact] + public void ApplyFullRebuildFlag_adds_no_incremental_once() + { + var args = new List { "build", "App.csproj" }; + DotNetBuildArguments.ApplyFullRebuildFlag(args, forceFullRebuild: true); + DotNetBuildArguments.ApplyFullRebuildFlag(args, forceFullRebuild: true); + Assert.Equal(3, args.Count); + Assert.Contains("--no-incremental", args); + } +} diff --git a/src/BuildMonitor.Tests/EditActivityEvaluatorTests.cs b/src/BuildMonitor.Tests/EditActivityEvaluatorTests.cs new file mode 100644 index 0000000..84d27c6 --- /dev/null +++ b/src/BuildMonitor.Tests/EditActivityEvaluatorTests.cs @@ -0,0 +1,57 @@ +using BuildMonitor.Core.Rules; + +namespace BuildMonitor.Tests; + +public sealed class EditActivityEvaluatorTests +{ + [Fact] + public void Evaluate_inactive_when_no_signals() + { + var snapshot = EditActivitySnapshot.Evaluate( + new EditActivityInput( + SourceWatcherHasPendingChanges: false, + SourceBurstStartedUtc: null, + LastMeaningfulFileChangeUtc: DateTimeOffset.MinValue, + LastAgentActivityUtc: null, + DebounceMs: 3000, + UseAgentTranscriptActivity: true), + DateTimeOffset.UtcNow); + + Assert.False(snapshot.IsActive); + } + + [Fact] + public void Evaluate_active_when_watcher_has_pending_changes() + { + var now = DateTimeOffset.UtcNow; + var snapshot = EditActivitySnapshot.Evaluate( + new EditActivityInput( + SourceWatcherHasPendingChanges: true, + SourceBurstStartedUtc: now, + LastMeaningfulFileChangeUtc: DateTimeOffset.MinValue, + LastAgentActivityUtc: null, + DebounceMs: 3000, + UseAgentTranscriptActivity: false), + now); + + Assert.True(snapshot.IsActive); + Assert.True(snapshot.QuietUntilUtc > now); + } + + [Fact] + public void Evaluate_active_from_recent_agent_transcript_activity() + { + var now = DateTimeOffset.UtcNow; + var snapshot = EditActivitySnapshot.Evaluate( + new EditActivityInput( + SourceWatcherHasPendingChanges: false, + SourceBurstStartedUtc: null, + LastMeaningfulFileChangeUtc: DateTimeOffset.MinValue, + LastAgentActivityUtc: now.AddMilliseconds(-500), + DebounceMs: 3000, + UseAgentTranscriptActivity: true), + now); + + Assert.True(snapshot.IsActive); + } +} diff --git a/src/BuildMonitor.Tests/EditGatingDetailFormatterTests.cs b/src/BuildMonitor.Tests/EditGatingDetailFormatterTests.cs new file mode 100644 index 0000000..1556f36 --- /dev/null +++ b/src/BuildMonitor.Tests/EditGatingDetailFormatterTests.cs @@ -0,0 +1,24 @@ +using BuildMonitor.Core.Rules; + +namespace BuildMonitor.Tests; + +public sealed class EditGatingDetailFormatterTests +{ + [Fact] + public void FormatCountdownRemaining_shows_whole_seconds() + { + var now = DateTimeOffset.UtcNow; + var text = EditGatingDetailFormatter.FormatCountdownRemaining(now.AddSeconds(12.2), now); + + Assert.Equal("Rebuild in 13 s", text); + } + + [Fact] + public void FormatCountdownRemaining_at_zero_shows_starting() + { + var now = DateTimeOffset.UtcNow; + var text = EditGatingDetailFormatter.FormatCountdownRemaining(now, now); + + Assert.Equal("Rebuild starting…", text); + } +} diff --git a/src/BuildMonitor.Tests/HealthIssueCountsFormatterTests.cs b/src/BuildMonitor.Tests/HealthIssueCountsFormatterTests.cs index f55fe5c..3396750 100644 --- a/src/BuildMonitor.Tests/HealthIssueCountsFormatterTests.cs +++ b/src/BuildMonitor.Tests/HealthIssueCountsFormatterTests.cs @@ -32,6 +32,20 @@ public void SelectPrimaryCounts_uses_run_counts_when_crashed_with_run_errors() Assert.Equal(2, warnings); } + [Fact] + public void SelectPrimaryCounts_uses_build_warnings_when_running_with_clean_run_output() + { + var (errors, warnings) = HealthIssueCountsFormatter.SelectPrimaryCounts( + ProjectLifecycleState.Running, + 0, + 1067, + 0, + 0); + + Assert.Equal(0, errors); + Assert.Equal(1067, warnings); + } + [Fact] public void FormatFailurePhase_maps_crashed_to_run_failed() { diff --git a/src/BuildMonitor.Tests/IncrementalBuildDetectorTests.cs b/src/BuildMonitor.Tests/IncrementalBuildDetectorTests.cs new file mode 100644 index 0000000..01e3420 --- /dev/null +++ b/src/BuildMonitor.Tests/IncrementalBuildDetectorTests.cs @@ -0,0 +1,71 @@ +using BuildMonitor.Infrastructure.LocalBuild; + +namespace BuildMonitor.Tests; + +public sealed class IncrementalBuildDetectorTests +{ + private const string IncrementalLog = """ + WitherbyConnect -> C:\app\bin\Debug\net9.0\WitherbyConnect.dll + + Build succeeded. + 0 Warning(s) + 0 Error(s) + """; + + private const string FullLog = """ + C:\app\Pages\Index.razor(1,1): warning CS8618: Field required [C:\app\app.csproj] + + Build succeeded. + 1065 Warning(s) + 0 Error(s) + """; + + [Fact] + public void WasCompileSkipped_true_for_up_to_date_summary_without_diagnostics() => + Assert.True(IncrementalBuildDetector.WasCompileSkipped(IncrementalLog)); + + [Fact] + public void WasCompileSkipped_false_when_warnings_in_summary() => + Assert.False(IncrementalBuildDetector.WasCompileSkipped(FullLog)); + + [Fact] + public void Resolve_reuses_previous_log_counts_when_compile_skipped() + { + var dir = Path.Combine(Path.GetTempPath(), $"bm-prev-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + var logPath = Path.Combine(dir, "last-build.log"); + try + { + File.WriteAllText(logPath + ".prev", FullLog); + File.WriteAllText(logPath, IncrementalLog); + var (errors, warnings) = BuildIssueCountResolver.Resolve(IncrementalLog, logPath); + Assert.Equal(0, errors); + Assert.Equal(1065, warnings); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void Resolve_reads_warning_count_from_saved_metadata_when_logs_are_incremental() + { + var dir = Path.Combine(Path.GetTempPath(), $"bm-meta-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + var logPath = Path.Combine(dir, "last-build.log"); + var metaPath = Path.Combine(dir, "last-build.meta.json"); + try + { + File.WriteAllText(logPath, IncrementalLog); + File.WriteAllText(metaPath, """{"ErrorCount":0,"WarningCount":1065}"""); + + var (_, warnings) = BuildIssueCountResolver.Resolve(IncrementalLog, logPath); + Assert.Equal(1065, warnings); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } +} diff --git a/src/BuildMonitor.Tests/ProjectHealthEvaluatorTests.cs b/src/BuildMonitor.Tests/ProjectHealthEvaluatorTests.cs index abc5a26..ee4dfd8 100644 --- a/src/BuildMonitor.Tests/ProjectHealthEvaluatorTests.cs +++ b/src/BuildMonitor.Tests/ProjectHealthEvaluatorTests.cs @@ -65,6 +65,18 @@ public void Evaluate_returns_green_when_watching_despite_failed_last_build_exit_ Assert.Equal(MonitorHealth.Green, health); } + [Fact] + public void Evaluate_returns_amber_after_tests_when_build_had_warnings() + { + var health = ProjectHealthEvaluator.Evaluate( + ProjectLifecycleState.TestOk, + lastBuildExitCode: 0, + errorCount: 0, + warningCount: 1065); + + Assert.Equal(MonitorHealth.Amber, health); + } + [Fact] public void Evaluate_returns_green_when_restarting_despite_crashed_state() { diff --git a/src/BuildMonitor.Tests/StatusPanelBuildVisibilityEvaluatorTests.cs b/src/BuildMonitor.Tests/StatusPanelBuildVisibilityEvaluatorTests.cs new file mode 100644 index 0000000..16c5245 --- /dev/null +++ b/src/BuildMonitor.Tests/StatusPanelBuildVisibilityEvaluatorTests.cs @@ -0,0 +1,226 @@ +using BuildMonitor.Core.Models; +using BuildMonitor.Core.Rules; + +namespace BuildMonitor.Tests; + +public sealed class StatusPanelBuildVisibilityEvaluatorTests +{ + [Theory] + [InlineData(true, ProjectLifecycleState.Watching, ProjectLifecycleState.Building, true)] + [InlineData(true, ProjectLifecycleState.Building, ProjectLifecycleState.Building, false)] + [InlineData(false, ProjectLifecycleState.Watching, ProjectLifecycleState.Building, false)] + [InlineData(true, ProjectLifecycleState.Building, ProjectLifecycleState.BuildOk, false)] + public void ShouldAutoShow_respects_enabled_and_build_transition( + bool enabled, + ProjectLifecycleState previous, + ProjectLifecycleState current, + bool expected) => + Assert.Equal( + expected, + StatusPanelBuildVisibilityEvaluator.ShouldAutoShow(enabled, previous, current)); + + [Fact] + public void ShouldAutoHide_when_auto_shown_and_no_enabled_project_building() + { + var projects = new[] + { + (ShowWhileBuildingEnabled: true, State: ProjectLifecycleState.Watching), + (ShowWhileBuildingEnabled: false, State: ProjectLifecycleState.Building) + }; + + Assert.True(StatusPanelBuildVisibilityEvaluator.ShouldAutoHide(true, projects)); + } + + [Fact] + public void ShouldAutoHide_false_while_enabled_project_still_building() + { + var projects = new[] + { + (ShowWhileBuildingEnabled: true, State: ProjectLifecycleState.Building), + (ShowWhileBuildingEnabled: true, State: ProjectLifecycleState.Watching) + }; + + Assert.False(StatusPanelBuildVisibilityEvaluator.ShouldAutoHide(true, projects)); + } + + [Fact] + public void ShouldAutoShowForEditGating_on_transition_to_active() + { + Assert.True(StatusPanelBuildVisibilityEvaluator.ShouldAutoShowForEditGating( + suppressionEnabled: true, + isGatingActive: true, + wasGatingActive: false)); + } + + [Fact] + public void ShouldAutoHideForEditGating_when_gating_ends() + { + Assert.True(StatusPanelBuildVisibilityEvaluator.ShouldAutoHideForEditGating( + autoShownForEditGating: true, + isGatingActive: false)); + } + + [Theory] + [InlineData(true, true, ProjectLifecycleState.Watching, ProjectLifecycleState.Building, true)] + [InlineData(true, false, ProjectLifecycleState.Watching, ProjectLifecycleState.Building, false)] + [InlineData(true, true, ProjectLifecycleState.Building, ProjectLifecycleState.Building, false)] + [InlineData(true, true, ProjectLifecycleState.Watching, ProjectLifecycleState.WaitingForEdits, true)] + [InlineData(true, false, ProjectLifecycleState.Watching, ProjectLifecycleState.WaitingForEdits, true)] + public void ShouldAutoShowForBusyWork_respects_show_while_building_for_build_states( + bool suppressionEnabled, + bool showWhileBuilding, + ProjectLifecycleState previous, + ProjectLifecycleState current, + bool expected) => + Assert.Equal( + expected, + StatusPanelBuildVisibilityEvaluator.ShouldAutoShowForBusyWork( + suppressionEnabled, + showWhileBuilding, + previous, + current)); + + [Theory] + [InlineData(false, ProjectLifecycleState.Watching, ProjectLifecycleState.Building, true, true)] + [InlineData(true, ProjectLifecycleState.Watching, ProjectLifecycleState.Building, true, false)] + [InlineData(false, ProjectLifecycleState.Watching, ProjectLifecycleState.Building, false, false)] + public void ShouldHideWhenBuildStartsWithoutShowSetting( + bool showWhileBuilding, + ProjectLifecycleState previous, + ProjectLifecycleState current, + bool autoShownForEditGatingOnly, + bool expected) => + Assert.Equal( + expected, + StatusPanelBuildVisibilityEvaluator.ShouldHideWhenBuildStartsWithoutShowSetting( + showWhileBuilding, + previous, + current, + autoShownForEditGatingOnly)); + + [Theory] + [InlineData(ProjectLifecycleState.Watching, ProjectLifecycleState.Building, true)] + [InlineData(ProjectLifecycleState.Building, ProjectLifecycleState.Building, false)] + [InlineData(ProjectLifecycleState.Watching, ProjectLifecycleState.WaitingForEdits, true)] + public void ShouldAutoShowForBusyWork_when_show_while_building_enabled( + ProjectLifecycleState previous, + ProjectLifecycleState current, + bool expected) => + Assert.Equal( + expected, + StatusPanelBuildVisibilityEvaluator.ShouldAutoShowForBusyWork( + suppressionEnabled: true, + showStatusPanelWhileBuilding: true, + previous, + current)); + + [Fact] + public void ShouldAutoHideForBusyWork_when_no_project_busy() + { + Assert.True(StatusPanelBuildVisibilityEvaluator.ShouldAutoHideForBusyWork( + autoShown: true, + [ProjectLifecycleState.Watching, ProjectLifecycleState.Running])); + } + + [Fact] + public void ShouldKeepPanelVisibleUntilSiteReady_while_listen_url_not_ready() + { + var snapshot = new ProjectHealthSnapshot( + "p1", + "Demo", + MonitorHealth.Green, + "Success", + ProjectLifecycleState.Running, + 0, + null, + null, + 0, + 0, + DateTimeOffset.UtcNow, + null, + true, + [], + "http://localhost:5000", + ListenUrlReady: false, + SupportsAppRestart: true); + + Assert.True(StatusPanelBuildVisibilityEvaluator.ShouldKeepPanelVisibleUntilSiteReady([snapshot])); + } + + [Fact] + public void ShouldKeepPanelVisibleUntilSiteReady_false_when_site_is_ready() + { + var snapshot = new ProjectHealthSnapshot( + "p1", + "Demo", + MonitorHealth.Green, + "Success", + ProjectLifecycleState.Watching, + 0, + null, + null, + 0, + 0, + DateTimeOffset.UtcNow, + null, + true, + [], + "http://localhost:5000", + ListenUrlReady: true, + SupportsAppRestart: true); + + Assert.False(StatusPanelBuildVisibilityEvaluator.ShouldKeepPanelVisibleUntilSiteReady([snapshot])); + } + + [Fact] + public void ShouldShowSiteStatus_false_while_building() + { + var snapshot = new ProjectHealthSnapshot( + "p1", + "Demo", + MonitorHealth.Green, + "Success", + ProjectLifecycleState.Building, + 0, + null, + null, + 0, + 0, + DateTimeOffset.UtcNow, + null, + true, + [], + "http://localhost:5000", + ListenUrlReady: false, + SupportsAppRestart: true); + + Assert.False(StatusPanelBuildVisibilityEvaluator.ShouldShowSiteStatus(snapshot)); + Assert.False(StatusPanelBuildVisibilityEvaluator.IsAwaitingSiteReady(snapshot)); + } + + [Fact] + public void ShouldShowSiteStatus_true_when_running_and_not_ready() + { + var snapshot = new ProjectHealthSnapshot( + "p1", + "Demo", + MonitorHealth.Green, + "Success", + ProjectLifecycleState.Running, + 0, + null, + null, + 0, + 0, + DateTimeOffset.UtcNow, + null, + true, + [], + "http://localhost:5000", + ListenUrlReady: false, + SupportsAppRestart: true); + + Assert.True(StatusPanelBuildVisibilityEvaluator.ShouldShowSiteStatus(snapshot)); + Assert.True(StatusPanelBuildVisibilityEvaluator.IsAwaitingSiteReady(snapshot)); + } +} diff --git a/src/BuildMonitor.Tests/TrayTooltipFormatterTests.cs b/src/BuildMonitor.Tests/TrayTooltipFormatterTests.cs index 9fb15ee..83584aa 100644 --- a/src/BuildMonitor.Tests/TrayTooltipFormatterTests.cs +++ b/src/BuildMonitor.Tests/TrayTooltipFormatterTests.cs @@ -1,77 +1,211 @@ using BuildMonitor.Core.Models; + using BuildMonitor.Core.Rules; + + namespace BuildMonitor.Tests; + + public sealed class TrayTooltipFormatterTests + { + [Fact] - public void Format_building_uses_project_name() + + public void Format_building_merges_project_name_and_status() + { + var snapshot = new ProjectHealthSnapshot( + "p1", + "Alpha", + MonitorHealth.Green, + "OK", + ProjectLifecycleState.Building, + null, + null, + null, + 0, + 0, + DateTimeOffset.UtcNow, + null, + true, + [], + null, + false, + true, + null, + null, + false); - var text = TrayTooltipFormatter.Format(snapshot, MonitorHealth.Green, isBuilding: true); + + + var text = TrayTooltipFormatter.FormatMultiline(snapshot, MonitorHealth.Green, isBuilding: true); + + Assert.Equal("Building — Alpha", text); + } + + [Fact] - public void Format_failure_includes_error_preview_truncated() + + public void FormatMultiline_merges_project_name_with_build_results() + { + var longError = new string('x', 80); + + var snapshot = new ProjectHealthSnapshot( + + "p1", + + "Vessel Compliance", + + MonitorHealth.Amber, + + "Warnings", + + ProjectLifecycleState.Watching, + + 0, + + null, + + longError, + + 0, + + 1065, + + DateTimeOffset.UtcNow, + + null, + + true, + + [], + + null, + + false, + + true, + + "Build: 0 errors | 1065 warnings", + + "Watching", + + false); + + + + var text = TrayTooltipFormatter.FormatMultiline(snapshot, MonitorHealth.Amber, isBuilding: false); + + + + Assert.StartsWith("Vessel Compliance — Warnings", text, StringComparison.Ordinal); + + Assert.Contains("Build: 0 errors | 1065 warnings", text, StringComparison.Ordinal); + + Assert.Contains(longError, text, StringComparison.Ordinal); + + } + + + + [Fact] + + public void FormatShort_is_empty_for_suppressed_shell_tooltip() + + { + var snapshot = new ProjectHealthSnapshot( + "p1", + "Beta", + MonitorHealth.Red, + "Failed", + ProjectLifecycleState.BuildFailed, + 1, - TimeSpan.FromSeconds(3), - longError, - 2, + + null, + + "error", + + 1, + 0, + DateTimeOffset.UtcNow, - DateTimeOffset.UtcNow, + + null, + true, + [], + null, + false, + true, - "Build failed", - "Build", + + null, + + null, + false); - var text = TrayTooltipFormatter.Format(snapshot, MonitorHealth.Red, isBuilding: false); - Assert.StartsWith("Beta — Build: ", text); - Assert.True(text.Length <= TrayTooltipFormatter.MaxTooltipLength); - Assert.EndsWith("…", text); + + Assert.Equal(string.Empty, TrayTooltipFormatter.FormatShort(snapshot, MonitorHealth.Red, isBuilding: false)); + } + + [Fact] + public void DescribeHealthTooltip_maps_rollup_colours() + { + Assert.Equal("Build monitor - Success", TrayTooltipFormatter.DescribeHealthTooltip(MonitorHealth.Green)); + Assert.Equal("Build monitor - Failed", TrayTooltipFormatter.DescribeHealthTooltip(MonitorHealth.Red)); + } + } + + diff --git a/src/BuildMonitor.Tests/WatchIgnoreRulesTests.cs b/src/BuildMonitor.Tests/WatchIgnoreRulesTests.cs index 27b2bde..9dd22cb 100644 --- a/src/BuildMonitor.Tests/WatchIgnoreRulesTests.cs +++ b/src/BuildMonitor.Tests/WatchIgnoreRulesTests.cs @@ -11,6 +11,8 @@ public class WatchIgnoreRulesTests [InlineData(@"C:\proj\logs\last-build.log", true)] [InlineData(@"C:\proj\src\Page.razor", false)] [InlineData(@"C:\proj\Thumbs.db", true)] + [InlineData(@"C:\proj\wwwroot\Images\logo.png", true)] + [InlineData(@"C:\proj\wwwroot\Files\guide.pdf", false)] public void ShouldIgnorePath_classifies_noise_and_source(string path, bool expected) => Assert.Equal( expected, diff --git a/src/Core/Models/AutoOpenLogMode.cs b/src/Core/Models/AutoOpenLogMode.cs new file mode 100644 index 0000000..650ffae --- /dev/null +++ b/src/Core/Models/AutoOpenLogMode.cs @@ -0,0 +1,10 @@ +namespace BuildMonitor.Core.Models; + +/// When to open the log viewer automatically for this project. +public enum AutoOpenLogMode +{ + Never = 0, + Errors = 1, + Warnings = 2, + Always = 3 +} diff --git a/src/Core/Models/LocalProjectModels.cs b/src/Core/Models/LocalProjectModels.cs index 165975e..72666c1 100644 --- a/src/Core/Models/LocalProjectModels.cs +++ b/src/Core/Models/LocalProjectModels.cs @@ -32,7 +32,9 @@ public enum ProjectLifecycleState Crashed = 6, Testing = 7, TestFailed = 8, - TestOk = 9 + TestOk = 9, + /// Waiting for source edits to settle before building. + WaitingForEdits = 10 } public enum BuildLogKind @@ -70,7 +72,8 @@ public sealed record BuildLogRecord( DateTimeOffset FinishedAtUtc, string LogFilePath, int ErrorCount, - IReadOnlyList ErrorLines); + IReadOnlyList ErrorLines, + int WarningCount = 0); public sealed record LiveBuildLogView( string Text, @@ -100,7 +103,10 @@ public sealed record ProjectHealthSnapshot( bool SupportsAppRestart = false, string? IssueCountsText = null, string? FailurePhase = null, - bool IsRestarting = false); + bool IsRestarting = false, + bool IsEditGatingActive = false, + string? EditGatingDetailText = null, + DateTimeOffset? RebuildQuietUntilUtc = null); public enum BuildTriggerKind { diff --git a/src/Core/Models/PendingRebuildHoldReason.cs b/src/Core/Models/PendingRebuildHoldReason.cs new file mode 100644 index 0000000..c6b0667 --- /dev/null +++ b/src/Core/Models/PendingRebuildHoldReason.cs @@ -0,0 +1,21 @@ +namespace BuildMonitor.Core.Models; + +/// Why a file-triggered rebuild is waiting instead of building immediately. +public enum PendingRebuildHoldReason +{ + None = 0, + /// Quiet period after the last save (agent session coalescing). + EditsSettling = 1, + /// New saves arrived — the wait timer was restarted. + EditsStillArriving = 2, + /// A build is already running. + BuildInProgress = 3, + /// Tests are running. + TestsInProgress = 4, + /// Post-build cooldown window. + PostBuildCooldown = 5, + /// Startup build waiting for edit quiet period. + StartupDeferred = 6, + /// In-flight build cancelled; waiting for edits to settle before rebuild. + SupersededByNewEdits = 7 +} diff --git a/src/Core/Rules/AutoOpenLogTransitionEvaluator.cs b/src/Core/Rules/AutoOpenLogTransitionEvaluator.cs new file mode 100644 index 0000000..d9eb650 --- /dev/null +++ b/src/Core/Rules/AutoOpenLogTransitionEvaluator.cs @@ -0,0 +1,104 @@ +using BuildMonitor.Core.Models; + +namespace BuildMonitor.Core.Rules; + +public static class AutoOpenLogTransitionEvaluator +{ + public static bool ShouldOpen( + AutoOpenLogMode mode, + MonitorHealth previousHealth, + MonitorHealth currentHealth, + ProjectLifecycleState previousState, + ProjectLifecycleState currentState, + int errorCount = 0) + { + if (mode == AutoOpenLogMode.Never) + { + return false; + } + + if (mode == AutoOpenLogMode.Always) + { + return CompletedBuild(previousState, currentState) + || CompletedTest(previousState, currentState); + } + + if (mode == AutoOpenLogMode.Errors) + { + if (currentState is ProjectLifecycleState.Building or ProjectLifecycleState.Testing) + { + return false; + } + + if (previousState == ProjectLifecycleState.Building + && currentState == ProjectLifecycleState.BuildFailed) + { + return true; + } + + if (previousState == ProjectLifecycleState.Testing + && currentState == ProjectLifecycleState.TestFailed) + { + return true; + } + + return currentState == ProjectLifecycleState.Crashed + && previousState is ProjectLifecycleState.Running or ProjectLifecycleState.Watching; + } + + if (mode == AutoOpenLogMode.Warnings) + { + return currentHealth is MonitorHealth.Amber or MonitorHealth.Red + && previousHealth != currentHealth; + } + + return false; + } + + public static (bool SelectErrorsFilter, bool SelectWarningsFilter) ResolveIssueFilters( + AutoOpenLogMode mode, + ProjectHealthSnapshot snapshot) + { + if (mode == AutoOpenLogMode.Warnings + && snapshot.Health == MonitorHealth.Amber + && snapshot.ErrorCount == 0 + && snapshot.WarningCount > 0) + { + return (false, true); + } + + if (mode == AutoOpenLogMode.Errors + || snapshot.State is ProjectLifecycleState.BuildFailed + or ProjectLifecycleState.TestFailed + or ProjectLifecycleState.Crashed + || snapshot.ErrorCount > 0) + { + return (true, false); + } + + return (false, false); + } + + public static bool ShouldResetOpenLatch(AutoOpenLogMode mode, MonitorHealth currentHealth) => + mode switch + { + AutoOpenLogMode.Never => true, + AutoOpenLogMode.Errors => currentHealth != MonitorHealth.Red, + AutoOpenLogMode.Warnings => currentHealth is MonitorHealth.Green or MonitorHealth.Unknown, + AutoOpenLogMode.Always => false, + _ => true + }; + + public static bool PreferErrorsFilter(int errorCount, int warningCount) => errorCount > 0; + + public static bool PreferWarningsFilter(int errorCount, int warningCount) => + errorCount == 0 && warningCount > 0; + + private static bool CompletedBuild(ProjectLifecycleState previous, ProjectLifecycleState current) => + previous == ProjectLifecycleState.Building + && current is not ProjectLifecycleState.Building; + + private static bool CompletedTest(ProjectLifecycleState previous, ProjectLifecycleState current) => + previous == ProjectLifecycleState.Testing + && current is ProjectLifecycleState.TestOk or ProjectLifecycleState.TestFailed; +} diff --git a/src/Core/Rules/BuildSuppressionPolicy.cs b/src/Core/Rules/BuildSuppressionPolicy.cs new file mode 100644 index 0000000..b2f208f --- /dev/null +++ b/src/Core/Rules/BuildSuppressionPolicy.cs @@ -0,0 +1,53 @@ +namespace BuildMonitor.Core.Rules; + +using BuildMonitor.Core.Models; + +public sealed record BuildSuppressionSettings( + bool DeferStartupBuildUntilQuiet, + bool CancelSupersededBuilds); + +public static class BuildSuppressionPolicy +{ + public static bool ShouldDeferStartupBuild( + BuildSuppressionSettings settings, + EditActivitySnapshot activity) => + settings.DeferStartupBuildUntilQuiet && activity.IsActive; + + public static bool ShouldCancelInFlightBuild( + BuildSuppressionSettings settings, + string? buildReason) + { + if (!settings.CancelSupersededBuilds || string.IsNullOrWhiteSpace(buildReason)) + { + return false; + } + + if (buildReason.Contains("(lock retry)", StringComparison.OrdinalIgnoreCase) + || buildReason.Contains("(output repair retry)", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return IsStartupReason(buildReason) + || IsFileChangeReason(buildReason); + } + + public static bool IsEditGatingActive( + BuildSuppressionSettings settings, + bool pendingFileChangeRebuild, + EditActivitySnapshot activity, + PendingRebuildHoldReason holdReason) => + (settings.DeferStartupBuildUntilQuiet || settings.CancelSupersededBuilds) + && (pendingFileChangeRebuild + || holdReason is PendingRebuildHoldReason.StartupDeferred + or PendingRebuildHoldReason.SupersededByNewEdits + || activity.IsActive); + + private static bool IsStartupReason(string buildReason) => + string.Equals(buildReason, "startup", StringComparison.OrdinalIgnoreCase) + || buildReason.StartsWith("startup ", StringComparison.OrdinalIgnoreCase); + + private static bool IsFileChangeReason(string buildReason) => + string.Equals(buildReason, "file change", StringComparison.OrdinalIgnoreCase) + || string.Equals(buildReason, "file change (queued)", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Core/Rules/EditActivityEvaluator.cs b/src/Core/Rules/EditActivityEvaluator.cs new file mode 100644 index 0000000..638d636 --- /dev/null +++ b/src/Core/Rules/EditActivityEvaluator.cs @@ -0,0 +1,66 @@ +namespace BuildMonitor.Core.Rules; + +public sealed record EditActivityInput( + bool SourceWatcherHasPendingChanges, + DateTimeOffset? SourceBurstStartedUtc, + DateTimeOffset LastMeaningfulFileChangeUtc, + DateTimeOffset? LastAgentActivityUtc, + int DebounceMs, + bool UseAgentTranscriptActivity); + +public sealed record EditActivitySnapshot( + bool IsActive, + DateTimeOffset QuietUntilUtc, + string PrimaryReason) +{ + public static EditActivitySnapshot Inactive { get; } = new(false, DateTimeOffset.UtcNow, string.Empty); + + public static EditActivitySnapshot Evaluate(EditActivityInput input, DateTimeOffset utcNow) + { + var debounceMs = Math.Clamp(input.DebounceMs, 1500, 12000); + var reasons = new List(); + + if (input.SourceWatcherHasPendingChanges) + { + reasons.Add("source saves in debounce window"); + } + + if (input.LastMeaningfulFileChangeUtc != DateTimeOffset.MinValue) + { + var quietUntil = ComputeQuietUntil(input.LastMeaningfulFileChangeUtc, debounceMs); + if (quietUntil > utcNow) + { + reasons.Add("quiet period after last save"); + return new EditActivitySnapshot(true, quietUntil, string.Join("; ", reasons)); + } + } + + if (input.UseAgentTranscriptActivity + && input.LastAgentActivityUtc is { } agentActivity) + { + var agentQuietUntil = ComputeQuietUntil(agentActivity, debounceMs); + if (agentQuietUntil > utcNow) + { + reasons.Add("agent tooling activity"); + return new EditActivitySnapshot(true, agentQuietUntil, string.Join("; ", reasons)); + } + } + + if (input.SourceWatcherHasPendingChanges) + { + var burstStart = input.SourceBurstStartedUtc ?? utcNow; + var burstQuietUntil = ComputeQuietUntil(input.LastMeaningfulFileChangeUtc != DateTimeOffset.MinValue + ? input.LastMeaningfulFileChangeUtc + : burstStart, debounceMs); + if (burstQuietUntil > utcNow) + { + return new EditActivitySnapshot(true, burstQuietUntil, string.Join("; ", reasons)); + } + } + + return Inactive; + } + + private static DateTimeOffset ComputeQuietUntil(DateTimeOffset lastChangeUtc, int debounceMs) => + lastChangeUtc.AddMilliseconds(debounceMs); +} diff --git a/src/Core/Rules/EditGatingDetailFormatter.cs b/src/Core/Rules/EditGatingDetailFormatter.cs new file mode 100644 index 0000000..1de59be --- /dev/null +++ b/src/Core/Rules/EditGatingDetailFormatter.cs @@ -0,0 +1,93 @@ +using BuildMonitor.Core.Models; + +namespace BuildMonitor.Core.Rules; + +/// Shared hold-reason text for status panel and build intelligence. +public static class EditGatingDetailFormatter +{ + public static string FormatHoldReason( + PendingRebuildHoldReason holdReason, + int pendingFileCount, + IReadOnlyList? samplePaths, + int timerResetCount, + int liveDebounceMs, + bool agentSessionBackoff) + { + if (holdReason == PendingRebuildHoldReason.None) + { + return string.Empty; + } + + var files = FormatPendingFileSample(samplePaths, pendingFileCount); + + return holdReason switch + { + PendingRebuildHoldReason.EditsStillArriving when timerResetCount > 1 => + $"Wait timer reset ({timerResetCount}×) — {pendingFileCount} file(s) just saved{files}. Quiet period restarted.", + PendingRebuildHoldReason.EditsStillArriving => + $"Wait timer reset — {pendingFileCount} file(s) just saved{files}. Quiet period restarted.", + PendingRebuildHoldReason.EditsSettling => agentSessionBackoff + ? $"Agent session — waiting {FormatDuration(liveDebounceMs)} after the last save{files}." + : $"Waiting {FormatDuration(liveDebounceMs)} after the last save{files}.", + PendingRebuildHoldReason.BuildInProgress => + "Rebuild queued — waiting for the current build to finish.", + PendingRebuildHoldReason.TestsInProgress => + "Rebuild queued — waiting for tests to finish.", + PendingRebuildHoldReason.PostBuildCooldown => + $"Rebuild queued — post-build cooldown; {pendingFileCount} file(s) arrived{files}.", + PendingRebuildHoldReason.StartupDeferred => + $"Startup build deferred — waiting {FormatDuration(liveDebounceMs)} for edits to settle{files}.", + PendingRebuildHoldReason.SupersededByNewEdits => + $"Build cancelled — newer changes detected; rebuilding when edits settle{files}.", + _ => string.Empty + }; + } + + public static string FormatCountdownRemaining(DateTimeOffset? quietUntilUtc, DateTimeOffset utcNow) + { + if (quietUntilUtc is not { } quietUntil) + { + return string.Empty; + } + + var remainingMs = (int)Math.Max(0, (quietUntil - utcNow).TotalMilliseconds); + if (remainingMs <= 0) + { + return "Rebuild starting…"; + } + + var remainingSeconds = (remainingMs + 999) / 1000; + return remainingSeconds == 1 + ? "Rebuild in 1 s" + : $"Rebuild in {remainingSeconds} s"; + } + + public static string FormatPendingFileSample(IReadOnlyList? paths, int totalCount) + { + if (paths is not { Count: > 0 }) + { + return string.Empty; + } + + var shown = string.Join(", ", paths.Take(2)); + if (totalCount > paths.Count) + { + return $" ({shown} +{totalCount - paths.Count} more)"; + } + + return $" ({shown})"; + } + + private static string FormatDuration(int milliseconds) + { + if (milliseconds < 1000) + { + return $"{milliseconds} ms"; + } + + var seconds = milliseconds / 1000.0; + return seconds < 60 + ? $"{seconds:0.#} s" + : $"{(int)Math.Round(seconds / 60)} min"; + } +} diff --git a/src/Core/Rules/HealthIssueCountsFormatter.cs b/src/Core/Rules/HealthIssueCountsFormatter.cs index bfc73f2..3aa11d2 100644 --- a/src/Core/Rules/HealthIssueCountsFormatter.cs +++ b/src/Core/Rules/HealthIssueCountsFormatter.cs @@ -39,13 +39,19 @@ public static (int DisplayErrors, int DisplayWarnings) SelectPrimaryCounts( int buildErrors, int buildWarnings, int runErrors, - int runWarnings) => - state is ProjectLifecycleState.Crashed + int runWarnings) + { + var isRunPhase = state is ProjectLifecycleState.Crashed or ProjectLifecycleState.Running - or ProjectLifecycleState.Watching - && (runErrors > 0 || runWarnings > 0 || state == ProjectLifecycleState.Crashed) - ? (runErrors, runWarnings) - : (buildErrors, buildWarnings); + or ProjectLifecycleState.Watching; + + if (isRunPhase && (runErrors > 0 || runWarnings > 0 || state == ProjectLifecycleState.Crashed)) + { + return (runErrors, runWarnings); + } + + return (buildErrors, buildWarnings); + } public static string FormatFailurePhase(ProjectLifecycleState state) => state switch diff --git a/src/Core/Rules/LocalTrayIconRollupEvaluator.cs b/src/Core/Rules/LocalTrayIconRollupEvaluator.cs index 57f65a4..5ecd6ff 100644 --- a/src/Core/Rules/LocalTrayIconRollupEvaluator.cs +++ b/src/Core/Rules/LocalTrayIconRollupEvaluator.cs @@ -16,7 +16,7 @@ public static MonitorHealth Rollup(IReadOnlyList activePr return MonitorHealth.Red; } - if (activeProjects.Any(p => p.Health == MonitorHealth.Amber)) + if (activeProjects.Any(p => p.Health == MonitorHealth.Amber || p.WarningCount > 0)) { return MonitorHealth.Amber; } @@ -31,7 +31,9 @@ public static MonitorHealth Rollup(IReadOnlyList activePr public static bool IsBuilding(IReadOnlyList activeProjects) => activeProjects.Any(p => p.IsRestarting - || p.State is ProjectLifecycleState.Building or ProjectLifecycleState.Testing); + || p.State is ProjectLifecycleState.Building + or ProjectLifecycleState.Testing + or ProjectLifecycleState.WaitingForEdits); public static bool IsWebReady(ProjectHealthSnapshot? headline) => headline is not null @@ -50,7 +52,8 @@ headline is not null .Where(p => p.State is ProjectLifecycleState.Building or ProjectLifecycleState.Running or ProjectLifecycleState.Watching - or ProjectLifecycleState.Testing) + or ProjectLifecycleState.Testing + or ProjectLifecycleState.WaitingForEdits) .OrderByDescending(p => p.LastChangedUtc) .FirstOrDefault(); diff --git a/src/Core/Rules/ProjectHealthEvaluator.cs b/src/Core/Rules/ProjectHealthEvaluator.cs index 30c545f..aa17c99 100644 --- a/src/Core/Rules/ProjectHealthEvaluator.cs +++ b/src/Core/Rules/ProjectHealthEvaluator.cs @@ -11,7 +11,10 @@ public static MonitorHealth Evaluate( int warningCount, bool inProgress = false) { - if (inProgress || state is ProjectLifecycleState.Building or ProjectLifecycleState.Testing) + if (inProgress + || state is ProjectLifecycleState.Building + or ProjectLifecycleState.Testing + or ProjectLifecycleState.WaitingForEdits) { if (errorCount > 0) { diff --git a/src/Core/Rules/StatusPanelBuildVisibilityEvaluator.cs b/src/Core/Rules/StatusPanelBuildVisibilityEvaluator.cs new file mode 100644 index 0000000..8830140 --- /dev/null +++ b/src/Core/Rules/StatusPanelBuildVisibilityEvaluator.cs @@ -0,0 +1,121 @@ +using BuildMonitor.Core.Models; + + + +namespace BuildMonitor.Core.Rules; + + + +public static class StatusPanelBuildVisibilityEvaluator + +{ + + public static bool ShouldAutoShow( + + bool showWhileBuildingEnabled, + + ProjectLifecycleState previousState, + + ProjectLifecycleState currentState) => + + showWhileBuildingEnabled + + && currentState == ProjectLifecycleState.Building + + && previousState != ProjectLifecycleState.Building; + + + + public static bool ShouldAutoHide( + + bool autoShownForBuild, + + IEnumerable<(bool ShowWhileBuildingEnabled, ProjectLifecycleState State)> activeProjects) => + + autoShownForBuild + + && !activeProjects.Any(p => + + p.ShowWhileBuildingEnabled && p.State == ProjectLifecycleState.Building); + + + + public static bool ShouldAutoShowForEditGating( + + bool suppressionEnabled, + + bool isGatingActive, + + bool wasGatingActive) => + + suppressionEnabled && isGatingActive && !wasGatingActive; + + + + public static bool ShouldAutoHideForEditGating( + + bool autoShownForEditGating, + + bool isGatingActive) => + + autoShownForEditGating && !isGatingActive; + + public static bool IsBusyWorkState(ProjectLifecycleState state) => + state is ProjectLifecycleState.Building + or ProjectLifecycleState.WaitingForEdits + or ProjectLifecycleState.Testing; + + public static bool ShouldAutoShowForBusyWork( + bool suppressionEnabled, + bool showStatusPanelWhileBuilding, + ProjectLifecycleState previousState, + ProjectLifecycleState currentState) + { + if (!suppressionEnabled + || !IsBusyWorkState(currentState) + || IsBusyWorkState(previousState)) + { + return false; + } + + return currentState switch + { + ProjectLifecycleState.Building or ProjectLifecycleState.Testing => showStatusPanelWhileBuilding, + _ => true + }; + } + + public static bool ShouldHideWhenBuildStartsWithoutShowSetting( + bool showStatusPanelWhileBuilding, + ProjectLifecycleState previousState, + ProjectLifecycleState currentState, + bool autoShownForEditGatingOnly) => + !showStatusPanelWhileBuilding + && autoShownForEditGatingOnly + && currentState == ProjectLifecycleState.Building + && previousState != ProjectLifecycleState.Building; + + public static bool ShouldAutoHideForBusyWork( + bool autoShown, + IEnumerable activeStates) => + autoShown && !activeStates.Any(IsBusyWorkState); + + public static bool IsAwaitingSiteReady(ProjectHealthSnapshot snapshot) => + ShouldShowSiteStatus(snapshot) + && !snapshot.ListenUrlReady; + + public static bool ShouldShowSiteStatus(ProjectHealthSnapshot snapshot) => + snapshot.IsActive + && snapshot.SupportsAppRestart + && !string.IsNullOrWhiteSpace(snapshot.ListenUrl) + && (snapshot.IsRestarting + || snapshot.State is ProjectLifecycleState.Running or ProjectLifecycleState.Watching); + + public static bool HasSiteLaunchConfigured(ProjectHealthSnapshot snapshot) => + snapshot.SupportsAppRestart && !string.IsNullOrWhiteSpace(snapshot.ListenUrl); + + public static bool ShouldKeepPanelVisibleUntilSiteReady( + IEnumerable activeProjects) => + activeProjects.Any(IsAwaitingSiteReady); +} + diff --git a/src/Core/Rules/TrayTooltipFormatter.cs b/src/Core/Rules/TrayTooltipFormatter.cs index 82ebeda..52d14c9 100644 --- a/src/Core/Rules/TrayTooltipFormatter.cs +++ b/src/Core/Rules/TrayTooltipFormatter.cs @@ -1,71 +1,369 @@ using BuildMonitor.Core.Models; + + namespace BuildMonitor.Core.Rules; + + public static class TrayTooltipFormatter + { + + /// Legacy shell tooltip limit — native tooltip is suppressed; full text is in the custom hover hint. + public const int MaxTooltipLength = 63; + + public static string Format( + + ProjectHealthSnapshot? headline, + + MonitorHealth health, + + bool isBuilding) => + + FormatMultiline(headline, health, isBuilding); + + + + public static string FormatMultiline( + ProjectHealthSnapshot? headline, + MonitorHealth health, + bool isBuilding) + { + if (isBuilding) + { - var name = headline?.DisplayName ?? "project"; - return Truncate($"Building — {name}"); + + var lines = new List(); + + if (headline is not null) + + { + + lines.Add($"Building — {headline.DisplayName}"); + + AppendIssueCountLine(lines, headline.ErrorCount, headline.WarningCount, headline.IssueCountsText); + + } + + else + + { + + lines.Add("Building…"); + + } + + + + return JoinLines(lines); + } + + if (headline is null) + { + return DescribeHealthTooltip(health); + } - if (headline.Health == MonitorHealth.Red) + + + var result = new List + { - var phase = string.IsNullOrWhiteSpace(headline.FailurePhase) - ? "Failed" - : headline.FailurePhase; - if (!string.IsNullOrWhiteSpace(headline.LastErrorPreview)) - { - return Truncate($"{headline.DisplayName} — {phase}: {headline.LastErrorPreview}"); - } - return Truncate($"{headline.DisplayName} — {phase}"); + $"{headline.DisplayName} — {BuildStatusLine(headline)}" + + }; + + AppendIssueCountLine(result, headline.ErrorCount, headline.WarningCount, headline.IssueCountsText); + + + + if (!string.IsNullOrWhiteSpace(headline.LastErrorPreview)) + + { + + result.Add(headline.LastErrorPreview.Trim()); + } - if (headline.Health == MonitorHealth.Amber) + else if (headline.ListenUrlReady && !string.IsNullOrWhiteSpace(headline.ListenUrl)) + { - return Truncate($"{headline.DisplayName} — Warnings"); + + result.Add(headline.ListenUrl.Trim()); + } - if (headline.ListenUrlReady && !string.IsNullOrWhiteSpace(headline.ListenUrl)) + + + return JoinLines(result); + + } + + + + public static string FormatMultiline( + + IReadOnlyList activeProjects, + + MonitorHealth rollupHealth, + + bool isBuilding) + + { + + var active = activeProjects.Where(p => p.IsActive).ToList(); + + if (active.Count == 0) + + { + + return DescribeHealthTooltip(rollupHealth); + + } + + + + if (active.Count == 1) + { - return Truncate($"{headline.DisplayName} — Site up · {headline.ListenUrl}"); + + return FormatMultiline(active[0], rollupHealth, isBuilding); + } - return Truncate($"{headline.DisplayName} — OK"); + + + var blocks = active + + .OrderBy(p => p.DisplayName, StringComparer.OrdinalIgnoreCase) + + .Select(p => FormatMultiline( + + p, + + p.Health, + + p.State is ProjectLifecycleState.Building or ProjectLifecycleState.Testing)) + + .Where(block => !string.IsNullOrWhiteSpace(block)); + + + + return JoinBlocks(blocks); + } + + + public static string FormatShort( + + ProjectHealthSnapshot? headline, + + MonitorHealth health, + + bool isBuilding) => + + string.Empty; + + + + public static string FormatCompactIssueCounts(int errorCount, int warningCount) + + { + + if (errorCount <= 0 && warningCount <= 0) + + { + + return string.Empty; + + } + + + + if (errorCount > 0 && warningCount > 0) + + { + + return $" · {errorCount}e/{warningCount}w"; + + } + + + + return errorCount > 0 + + ? $" · {errorCount} err" + + : $" · {warningCount} warn"; + + } + + + public static string Truncate(string text, int maxLength = MaxTooltipLength) => + text.Length <= maxLength ? text : text[..(maxLength - 1)] + "…"; + + public static string DescribeHealth(MonitorHealth health) => + health switch + { + MonitorHealth.Green => "OK", + MonitorHealth.Amber => "Warnings", + MonitorHealth.Red => "Errors", + _ => "Unknown" + }; + + public static string DescribeHealthTooltip(MonitorHealth health) => + health switch + { + MonitorHealth.Green => "Build monitor - Success", + MonitorHealth.Amber => "Build monitor - Warnings", + MonitorHealth.Red => "Build monitor - Failed", + _ => "Build Monitor" + }; + + + + private static string BuildStatusLine(ProjectHealthSnapshot headline) + + { + + if (headline.Health == MonitorHealth.Red) + + { + + return string.IsNullOrWhiteSpace(headline.FailurePhase) + + ? "Failed" + + : headline.FailurePhase; + + } + + + + if (headline.Health == MonitorHealth.Amber) + + { + + return "Warnings"; + + } + + + + if (headline.ListenUrlReady && !string.IsNullOrWhiteSpace(headline.ListenUrl)) + + { + + return "Site up"; + + } + + + + return "OK"; + + } + + + + private static void AppendIssueCountLine( + + List lines, + + int errorCount, + + int warningCount, + + string? issueCountsText) + + { + + if (!string.IsNullOrWhiteSpace(issueCountsText)) + + { + + lines.Add(issueCountsText); + + return; + + } + + + + if (errorCount <= 0 && warningCount <= 0) + + { + + return; + + } + + + + lines.Add(errorCount > 0 && warningCount > 0 + + ? $"{errorCount} errors · {warningCount} warnings" + + : errorCount > 0 + + ? $"{errorCount} errors" + + : $"{warningCount} warnings"); + + } + + + + private static string JoinLines(IEnumerable lines) => + + string.Join( + + Environment.NewLine, + + lines.Where(line => !string.IsNullOrWhiteSpace(line))); + + + + private static string JoinBlocks(IEnumerable blocks) => + + string.Join($"{Environment.NewLine}{Environment.NewLine}", blocks); + } + + diff --git a/src/Core/Settings/LocalAppSettings.cs b/src/Core/Settings/LocalAppSettings.cs index 7da39bb..827aaf8 100644 --- a/src/Core/Settings/LocalAppSettings.cs +++ b/src/Core/Settings/LocalAppSettings.cs @@ -1,3 +1,4 @@ +using System; using System.ComponentModel; using System.Runtime.CompilerServices; using BuildMonitor.Core.Models; @@ -6,7 +7,7 @@ namespace BuildMonitor.Core.Settings; public sealed class AppSettings { - public int SchemaVersion { get; set; } = 10; + public int SchemaVersion { get; set; } = 13; public List Projects { get; set; } = []; public GlobalMonitorSettings Monitor { get; set; } = new(); public AppBehaviorSettings AppBehavior { get; set; } = new(); @@ -125,6 +126,10 @@ public sealed class ProjectRunOptions /// Path segments ignored by file watcher (semicolon-separated). Default includes IDE folders. public string WatchExcludeSegments { get; set; } = ".cursor;agent-transcripts;terminals;mcps;.specstory;plans;.idea;.vscode"; + /// When to open the log viewer automatically after builds or tests. + public AutoOpenLogMode AutoOpenLog { get; set; } = AutoOpenLogMode.Never; + /// When true, open the hover status panel when a build starts and hide it when the build finishes. + public bool ShowStatusPanelWhileBuilding { get; set; } } public enum FileChangeDebounceMode @@ -142,12 +147,19 @@ public sealed class GlobalMonitorSettings /// When watch mode is enabled, batch file changes and rebuild once edits settle (instead of dotnet watch per-save rebuilds). public bool CoalesceWatchRebuilds { get; set; } = true; public int MaxConcurrentActiveProjects { get; set; } = 3; + [Obsolete("Migrated to per-project ProjectRunOptions.AutoOpenLog (schema v11).")] public bool AutoOpenLogOnFailure { get; set; } /// Open the Build Monitor Health window when the app starts. public bool AutoOpenBuildMonitorHealthOnStartup { get; set; } = true; public bool PlaySoundOnBuildError { get; set; } = true; public bool PlaySoundOnBuildSuccess { get; set; } public int MaxLogDisplayBytes { get; set; } = 2_097_152; + /// Wait for edit quiet before the first startup build. + public bool DeferStartupBuildUntilQuiet { get; set; } = true; + /// Cancel startup/file-change builds when newer saves arrive. + public bool CancelSupersededBuilds { get; set; } = true; + /// Treat agent-transcripts / .cursor writes as active editing. + public bool UseAgentTranscriptActivity { get; set; } = true; } public enum AppThemePreference diff --git a/src/Infrastructure/Diagnostics/BuildIntelligenceSnapshot.cs b/src/Infrastructure/Diagnostics/BuildIntelligenceSnapshot.cs index 0a721ed..5b378f3 100644 --- a/src/Infrastructure/Diagnostics/BuildIntelligenceSnapshot.cs +++ b/src/Infrastructure/Diagnostics/BuildIntelligenceSnapshot.cs @@ -1,3 +1,5 @@ +using BuildMonitor.Core.Models; +using BuildMonitor.Core.Rules; using BuildMonitor.Core.Settings; using BuildMonitor.Infrastructure.LocalBuild; @@ -23,8 +25,17 @@ public sealed record BuildIntelligenceSnapshot( string? LastFileChangeLocal, bool PendingFileChangeRebuild, IReadOnlyList RecentBurstSamplesMs, - DateTimeOffset? RebuildQuietUntilUtc) + IReadOnlyList RecentBuildDurationSamplesMs, + int TodayTriggerCount, + DateTimeOffset? RebuildQuietUntilUtc, + PendingRebuildHoldReason HoldReason = PendingRebuildHoldReason.None, + int PendingRebuildFileCount = 0, + IReadOnlyList? PendingRebuildSamplePaths = null, + int RebuildTimerResetCount = 0) { + /// Why the rebuild wait was deferred or the quiet timer restarted. + public string NextRebuildReasonText => BuildNextRebuildReasonText(); + /// One-line summary of what the monitor is doing for file-change rebuilds. public string StatusHeadline => BuildStatusHeadline(); @@ -59,8 +70,44 @@ public sealed record BuildIntelligenceSnapshot( public bool HasBurstChartData => RecentBurstSamplesMs.Count > 0; + public bool HasBuildDurationChartData => RecentBuildDurationSamplesMs.Count > 0; + public IReadOnlyList BurstBars => BuildBurstBars(); + public IReadOnlyList BuildDurationBars => BuildDurationBarVisuals(); + + public string CountdownRemainingText => BuildCountdownRemainingText(); + + public string TodayTriggersText => TodayTriggerCount switch + { + 0 => "No triggers logged today.", + 1 => "1 trigger today.", + _ => $"{TodayTriggerCount} triggers today." + }; + + public string AverageRecentBuildDurationText + { + get + { + if (RecentBuildDurationSamplesMs.Count == 0) + { + return "No build times yet."; + } + + var avg = (int)Math.Round(RecentBuildDurationSamplesMs.Average()); + return $"Typical build ~{FormatDuration(avg)}"; + } + } + + public string RecentBuildDurationLabel => + RecentBuildDurationSamplesMs.Count == 0 + ? "—" + : FormatDuration((int)Math.Round(RecentBuildDurationSamplesMs.Average())); + + public string BuildDurationChartCaption => HasBuildDurationChartData + ? $"Newest on the right · {AverageRecentBuildDurationText}" + : "Build times appear after file-triggered rebuilds complete."; + public string StatusChipText => BuildStatusChipText(); public bool ShowStatusChip => StatusChipText.Length > 0; @@ -105,6 +152,8 @@ public static BuildIntelligenceSnapshot FromStoredStats( null, false, TakeRecentBurstSamples(stats.BurstSamplesMs), + TakeRecentBuildDurationSamples(stats.BuildSamplesMs), + 0, null); } @@ -120,7 +169,12 @@ internal static BuildIntelligenceSnapshot Create( bool coalesceWatchRebuilds, DateTimeOffset? lastMeaningfulFileChangeUtc, bool pendingFileChangeRebuild, - DateTimeOffset? rebuildQuietUntilUtc) + DateTimeOffset? rebuildQuietUntilUtc, + int todayTriggerCount = 0, + PendingRebuildHoldReason holdReason = PendingRebuildHoldReason.None, + int pendingRebuildFileCount = 0, + IReadOnlyList? pendingRebuildSamplePaths = null, + int rebuildTimerResetCount = 0) { var agentBackoff = recentFileChangeBuildsIn90s >= 1; return new BuildIntelligenceSnapshot( @@ -143,7 +197,24 @@ internal static BuildIntelligenceSnapshot Create( FormatLastFileChangeLocal(lastMeaningfulFileChangeUtc), pendingFileChangeRebuild, TakeRecentBurstSamples(stats.BurstSamplesMs), - rebuildQuietUntilUtc); + TakeRecentBuildDurationSamples(stats.BuildSamplesMs), + todayTriggerCount, + rebuildQuietUntilUtc, + holdReason, + pendingRebuildFileCount, + pendingRebuildSamplePaths ?? [], + rebuildTimerResetCount); + } + + private string BuildCountdownRemainingText() + { + if (!ShowRebuildCountdown || RebuildQuietUntilUtc is not { } quietUntil) + { + return string.Empty; + } + + var remainingMs = (int)Math.Max(0, (quietUntil - DateTimeOffset.UtcNow).TotalMilliseconds); + return remainingMs > 0 ? FormatDuration(remainingMs) : "now"; } private string BuildNextRebuildText() @@ -169,6 +240,25 @@ private string BuildNextRebuildText() : "Rebuild starting…"; } + private string BuildNextRebuildReasonText() + { + if (!PendingFileChangeRebuild || HoldReason == PendingRebuildHoldReason.None) + { + return string.Empty; + } + + return EditGatingDetailFormatter.FormatHoldReason( + HoldReason, + PendingRebuildFileCount, + PendingRebuildSamplePaths, + RebuildTimerResetCount, + LiveEffectiveDebounceMs, + AgentSessionBackoff); + } + + internal static string FormatPendingFileSample(IReadOnlyList? paths, int totalCount) => + EditGatingDetailFormatter.FormatPendingFileSample(paths, totalCount); + private double BuildRebuildCountdownPercent() { if (!ShowRebuildCountdown || LiveEffectiveDebounceMs <= 0) @@ -302,7 +392,10 @@ private string BuildStatusChipText() if (PendingFileChangeRebuild) { - return "Rebuild queued"; + var remaining = BuildCountdownRemainingText(); + return string.IsNullOrWhiteSpace(remaining) || remaining == "now" + ? "Rebuild queued" + : $"Rebuild queued · ~{remaining}"; } if (AgentSessionBackoff && LiveEffectiveDebounceMs > BaseEffectiveDebounceMs) @@ -334,11 +427,32 @@ private IReadOnlyList BuildBurstBars() .ToList(); } + private IReadOnlyList BuildDurationBarVisuals() + { + if (RecentBuildDurationSamplesMs.Count == 0) + { + return []; + } + + var max = Math.Max( + RecentBuildDurationSamplesMs.Max(), + AdaptiveFileChangeDebounce.MinDebounceMs); + + return RecentBuildDurationSamplesMs + .Select(ms => new BurstBarVisual(FormatDuration(ms), Math.Max(0.12, ms / (double)max))) + .ToList(); + } + private static IReadOnlyList TakeRecentBurstSamples(IReadOnlyList burstSamplesMs) => burstSamplesMs.Count == 0 ? [] : burstSamplesMs.TakeLast(5).ToList(); + private static IReadOnlyList TakeRecentBuildDurationSamples(IReadOnlyList buildSamplesMs) => + buildSamplesMs.Count == 0 + ? [] + : buildSamplesMs.TakeLast(5).ToList(); + private static string? FormatLastFileChangeLocal(DateTimeOffset? utc) => utc is { } t && t != DateTimeOffset.MinValue ? t.ToLocalTime().ToString("t") diff --git a/src/Infrastructure/Diagnostics/BuildTriggerDetailFormatter.cs b/src/Infrastructure/Diagnostics/BuildTriggerDetailFormatter.cs new file mode 100644 index 0000000..0aaeae2 --- /dev/null +++ b/src/Infrastructure/Diagnostics/BuildTriggerDetailFormatter.cs @@ -0,0 +1,46 @@ +using BuildMonitor.Core.Models; + +namespace BuildMonitor.Infrastructure.Diagnostics; + +public static class BuildTriggerDetailFormatter +{ + public static string FormatImmediateDebounce(int debounceMs) => + $"Quiet period {debounceMs} ms (saved immediately)"; + + public static string FormatCoalescedBuild( + int debounceMs, + PendingRebuildHoldReason holdReason, + int timerResetCount) + { + var parts = new List { $"Quiet period {debounceMs} ms" }; + + if (holdReason != PendingRebuildHoldReason.None) + { + parts.Add(DescribeHoldReason(holdReason, timerResetCount)); + } + + return string.Join(" · ", parts); + } + + private static string DescribeHoldReason(PendingRebuildHoldReason reason, int timerResetCount) => + reason switch + { + PendingRebuildHoldReason.EditsStillArriving when timerResetCount > 1 => + $"timer reset {timerResetCount}× while edits continued", + PendingRebuildHoldReason.EditsStillArriving => + "timer reset while edits continued", + PendingRebuildHoldReason.EditsSettling => + "waiting for edits to settle", + PendingRebuildHoldReason.BuildInProgress => + "held until build finished", + PendingRebuildHoldReason.TestsInProgress => + "held until tests finished", + PendingRebuildHoldReason.PostBuildCooldown => + "held for post-build cooldown", + PendingRebuildHoldReason.StartupDeferred => + "startup deferred until edits settle", + PendingRebuildHoldReason.SupersededByNewEdits => + "build superseded by newer edits", + _ => string.Empty + }; +} diff --git a/src/Infrastructure/LocalBuild/AgentActivityWatcher.cs b/src/Infrastructure/LocalBuild/AgentActivityWatcher.cs new file mode 100644 index 0000000..f3bf1a1 --- /dev/null +++ b/src/Infrastructure/LocalBuild/AgentActivityWatcher.cs @@ -0,0 +1,81 @@ +namespace BuildMonitor.Infrastructure.LocalBuild; + +/// +/// Watches agent tooling folders for activity signals only — never triggers rebuilds. +/// +public sealed class AgentActivityWatcher : IDisposable +{ + private static readonly string[] ActivitySegments = + [ + "agent-transcripts", + ".cursor" + ]; + + private readonly FileSystemWatcher watcher; + private readonly object sync = new(); + private DateTimeOffset lastActivityUtc = DateTimeOffset.MinValue; + private bool isSuspended; + + public AgentActivityWatcher(string rootPath) + { + watcher = new FileSystemWatcher(rootPath) + { + IncludeSubdirectories = true, + NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size + }; + + watcher.Changed += OnFsEvent; + watcher.Created += OnFsEvent; + watcher.Renamed += OnFsEvent; + watcher.EnableRaisingEvents = true; + } + + public DateTimeOffset LastActivityUtc + { + get + { + lock (sync) + { + return lastActivityUtc; + } + } + } + + public void Suspend() => isSuspended = true; + + public void Resume() => isSuspended = false; + + private void OnFsEvent(object sender, FileSystemEventArgs e) + { + if (isSuspended || !IsAgentActivityPath(e.FullPath)) + { + return; + } + + lock (sync) + { + lastActivityUtc = DateTimeOffset.UtcNow; + } + } + + internal static bool IsAgentActivityPath(string fullPath) + { + if (string.IsNullOrWhiteSpace(fullPath)) + { + return false; + } + + var parts = fullPath.Split(['\\', '/'], StringSplitOptions.RemoveEmptyEntries); + foreach (var segment in ActivitySegments) + { + if (parts.Any(p => string.Equals(p, segment, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + } + + return false; + } + + public void Dispose() => watcher.Dispose(); +} diff --git a/src/Infrastructure/LocalBuild/BuildIssueCountResolver.cs b/src/Infrastructure/LocalBuild/BuildIssueCountResolver.cs new file mode 100644 index 0000000..4db4a6c --- /dev/null +++ b/src/Infrastructure/LocalBuild/BuildIssueCountResolver.cs @@ -0,0 +1,102 @@ +using System.Text.Json; + +namespace BuildMonitor.Infrastructure.LocalBuild; + +public static class BuildIssueCountResolver +{ + public static (int Errors, int Warnings) Resolve(string buildOutput, string? existingLogPath) + { + if (!IncrementalBuildDetector.WasCompileSkipped(buildOutput)) + { + return ( + BuildLogParser.ParseErrorCount(buildOutput), + BuildLogParser.ParseWarningCount(buildOutput)); + } + + if (!string.IsNullOrWhiteSpace(existingLogPath)) + { + var prevPath = existingLogPath + ".prev"; + if (File.Exists(prevPath)) + { + var fromPrev = ReadCountsFromLogText(File.ReadAllText(prevPath)); + if (fromPrev.Errors > 0 || fromPrev.Warnings > 0) + { + return fromPrev; + } + } + + var fromMeta = TryReadMetadataCounts(existingLogPath); + if (fromMeta.Errors > 0 || fromMeta.Warnings > 0) + { + return fromMeta; + } + } + + var fromNote = BuildLogParser.TryParseIncrementalHealthNote(buildOutput); + if (fromNote.Errors > 0 || fromNote.Warnings > 0) + { + return fromNote; + } + + return ( + BuildLogParser.ParseErrorCount(buildOutput), + BuildLogParser.ParseWarningCount(buildOutput)); + } + + private static (int Errors, int Warnings) ReadCountsFromLogText(string logText) + { + var errors = BuildLogParser.ParseErrorCount(logText); + var warnings = BuildLogParser.ParseWarningCount(logText); + if (errors > 0 || warnings > 0) + { + return (errors, warnings); + } + + return BuildLogParser.TryParseIncrementalHealthNote(logText); + } + + private static (int Errors, int Warnings) TryReadMetadataCounts(string logFilePath) + { + var metaPath = ResolveMetadataPath(logFilePath); + if (metaPath is null || !File.Exists(metaPath)) + { + return (0, 0); + } + + try + { + var json = File.ReadAllText(metaPath); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + var errors = root.TryGetProperty("ErrorCount", out var errorNode) + ? errorNode.GetInt32() + : 0; + var warnings = root.TryGetProperty("WarningCount", out var warningNode) + ? warningNode.GetInt32() + : 0; + return (errors, warnings); + } + catch + { + return (0, 0); + } + } + + internal static string? ResolveMetadataPath(string? logFilePath) + { + if (string.IsNullOrWhiteSpace(logFilePath)) + { + return null; + } + + var directory = Path.GetDirectoryName(logFilePath) ?? string.Empty; + var fileName = Path.GetFileName(logFilePath); + if (fileName.EndsWith(".log.prev", StringComparison.OrdinalIgnoreCase)) + { + fileName = fileName[..^5]; + } + + var stem = Path.GetFileNameWithoutExtension(fileName); + return Path.Combine(directory, stem + ".meta.json"); + } +} diff --git a/src/Infrastructure/LocalBuild/BuildLogMetadataDto.cs b/src/Infrastructure/LocalBuild/BuildLogMetadataDto.cs index 9de8974..981263c 100644 --- a/src/Infrastructure/LocalBuild/BuildLogMetadataDto.cs +++ b/src/Infrastructure/LocalBuild/BuildLogMetadataDto.cs @@ -12,6 +12,7 @@ internal sealed class BuildLogMetadataDto public DateTimeOffset FinishedAtUtc { get; set; } public string LogFilePath { get; set; } = string.Empty; public int ErrorCount { get; set; } + public int WarningCount { get; set; } public List ErrorLines { get; set; } = []; public BuildLogRecord ToRecord() => new( @@ -23,5 +24,6 @@ internal sealed class BuildLogMetadataDto FinishedAtUtc, LogFilePath, ErrorCount, - ErrorLines); + ErrorLines, + WarningCount); } diff --git a/src/Infrastructure/LocalBuild/BuildLogParser.cs b/src/Infrastructure/LocalBuild/BuildLogParser.cs index ec2c2ee..1a48c02 100644 --- a/src/Infrastructure/LocalBuild/BuildLogParser.cs +++ b/src/Infrastructure/LocalBuild/BuildLogParser.cs @@ -26,6 +26,14 @@ public static class BuildLogParser @"\berror\s+(CS|MSB|NU|BC|SA|IDE|CA|FS|VB|AD|SYSLIB|NETSDK|CS)\d+\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex IncrementalHealthWarningRegex = new( + @"(\d+)\s+warning\(s\)", + RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex IncrementalHealthErrorRegex = new( + @"(\d+)\s+error\(s\)", + RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CompilerWarningRegex = new( @"\bwarning\s+(CS|MSB|NU|BC|SA|IDE|CA|FS|VB|AD|SYSLIB|NETSDK|CS)\d+\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); @@ -92,43 +100,126 @@ public static (int ErrorCount, IReadOnlyList ErrorLines) ParseErrors(str return (ParseErrorCount(logText), errors); } + public static int ParseWarningCount(string logText) + { + var segment = ExtractLatestBuildResultSegment(logText); + var summary = ParseBuildSummaryCount(segment, warnings: true); + if (summary > 0) + { + return summary; + } + + var fromIssues = ParseIssues(segment).Count(i => !i.IsError); + if (fromIssues > 0) + { + return fromIssues; + } + + var fromNote = TryParseIncrementalHealthNote(logText); + return fromNote.Warnings > 0 ? fromNote.Warnings : summary >= 0 ? summary : 0; + } + public static int ParseErrorCount(string logText) { var segment = ExtractLatestBuildResultSegment(logText); var summary = ParseBuildSummaryCount(segment, warnings: false); - if (summary >= 0) + if (summary > 0) { return summary; } - return ParseIssues(segment).Count(i => i.IsError); + var fromIssues = ParseIssues(segment) + .Where(i => i.IsError) + .Select(i => i.Text) + .Distinct(StringComparer.Ordinal) + .Count(); + if (fromIssues > 0) + { + return fromIssues; + } + + var fromNote = TryParseIncrementalHealthNote(logText); + return fromNote.Errors > 0 ? fromNote.Errors : summary >= 0 ? summary : 0; } - public static bool IsOutputLockError(string logText) + /// Reads resolved tray-health counts from a BuildMonitor incremental-build note line. + public static (int Errors, int Warnings) TryParseIncrementalHealthNote(string logText) { if (string.IsNullOrWhiteSpace(logText)) { - return false; + return (0, 0); } - return OutputLockMarkers.Any(marker => - logText.Contains(marker, StringComparison.OrdinalIgnoreCase)); + var marker = "Tray health uses"; + var index = logText.LastIndexOf(marker, StringComparison.OrdinalIgnoreCase); + if (index < 0) + { + return (0, 0); + } + + var note = logText[index..]; + var warnings = ParseLastMatchCount(note, IncrementalHealthWarningRegex); + var errors = ParseLastMatchCount(note, IncrementalHealthErrorRegex); + return (Math.Max(0, errors), Math.Max(0, warnings)); } - public static int ParseWarningCount(string logText) + /// + /// Returns compiler issues from the log, or from the previous on-disk build log when this build was incremental. + /// + public static IReadOnlyList ResolveBuildIssues(string logText, string? logFilePath) { - var segment = ExtractLatestBuildResultSegment(logText); - var summary = ParseBuildSummaryCount(segment, warnings: true); - if (summary >= 0) + var issues = ParseIssues(logText); + if (issues.Count > 0 || !IncrementalBuildDetector.WasCompileSkipped(logText)) { - return summary; + return issues; } - return ParseIssues(segment).Count(i => !i.IsError); + if (string.IsNullOrWhiteSpace(logFilePath)) + { + return issues; + } + + var prevPath = logFilePath + ".prev"; + if (!File.Exists(prevPath)) + { + return issues; + } + + var previousIssues = ParseIssues(File.ReadAllText(prevPath)); + return previousIssues.Count > 0 ? previousIssues : issues; + } + + internal static IEnumerable CandidatePreviousLogPaths(string? logFilePath) + { + if (string.IsNullOrWhiteSpace(logFilePath)) + { + yield break; + } + + yield return logFilePath; + + var previous = logFilePath + ".prev"; + if (!string.Equals(previous, logFilePath, StringComparison.OrdinalIgnoreCase)) + { + yield return previous; + } + } + + public static bool IsOutputLockError(string logText) + { + if (string.IsNullOrWhiteSpace(logText)) + { + return false; + } + + return OutputLockMarkers.Any(marker => + logText.Contains(marker, StringComparison.OrdinalIgnoreCase)); } /// /// Limits parsing to the most recent MSBuild result in accumulated dotnet watch output. + /// When BuildMonitor banners are present, uses the latest build block so errors above + /// Build FAILED are not dropped from count/issue parsing. /// internal static string ExtractLatestBuildResultSegment(string logText) { @@ -138,11 +229,46 @@ internal static string ExtractLatestBuildResultSegment(string logText) } var normalized = logText.Replace("\r\n", "\n"); + const string buildBanner = "[BuildMonitor] ===== Build #"; + var lastBanner = normalized.LastIndexOf(buildBanner, StringComparison.Ordinal); + if (lastBanner >= 0) + { + return normalized[lastBanner..]; + } + var lastSucceeded = normalized.LastIndexOf("Build succeeded", StringComparison.OrdinalIgnoreCase); var lastFailed = normalized.LastIndexOf("Build FAILED", StringComparison.OrdinalIgnoreCase); var lastFailedSentence = normalized.LastIndexOf("The build failed", StringComparison.OrdinalIgnoreCase); var start = Math.Max(Math.Max(lastSucceeded, lastFailed), lastFailedSentence); - return start < 0 ? normalized : normalized[start..]; + if (start < 0) + { + return normalized; + } + + if (lastFailed >= 0 && start == lastFailed) + { + // MSBuild prints diagnostics before the Build FAILED line — include them. + var lineStart = lastFailed > 0 + ? normalized.LastIndexOf('\n', lastFailed - 1) + : -1; + var searchFrom = lineStart >= 0 ? lineStart + 1 : 0; + var previousFailedEnd = lastFailed > 0 ? lastFailed - 1 : 0; + var previousBoundary = Math.Max( + normalized.LastIndexOf(buildBanner, lastFailed, StringComparison.Ordinal), + Math.Max( + normalized.LastIndexOf("Build succeeded", lastFailed, StringComparison.OrdinalIgnoreCase), + lastFailed > 0 + ? normalized.LastIndexOf("Build FAILED", previousFailedEnd, StringComparison.OrdinalIgnoreCase) + : -1)); + if (previousBoundary >= 0 && previousBoundary < lastFailed) + { + searchFrom = previousBoundary; + } + + return normalized[searchFrom..]; + } + + return normalized[start..]; } /// @@ -272,11 +398,19 @@ private static int ParseLastMatchCount(string text, Regex regex) return int.Parse(matches[^1].Groups[1].Value); } - private static bool IsSummaryLine(string line) => - ClassicErrorSummaryRegex.IsMatch(line) - || ClassicWarningSummaryRegex.IsMatch(line) - || TerminalErrorCountRegex.IsMatch(line) - || TerminalWarningCountRegex.IsMatch(line); + private static bool IsSummaryLine(string line) + { + var trimmed = line.TrimEnd('.', ' '); + if (trimmed.Equals("Build FAILED", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return ClassicErrorSummaryRegex.IsMatch(line) + || ClassicWarningSummaryRegex.IsMatch(line) + || TerminalErrorCountRegex.IsMatch(line) + || TerminalWarningCountRegex.IsMatch(line); + } private static bool IsErrorLine(string line) => ErrorMarkers.Any(marker => line.Contains(marker, StringComparison.OrdinalIgnoreCase)) diff --git a/src/Infrastructure/LocalBuild/BuildLogStore.cs b/src/Infrastructure/LocalBuild/BuildLogStore.cs index 4991f38..7ec072d 100644 --- a/src/Infrastructure/LocalBuild/BuildLogStore.cs +++ b/src/Infrastructure/LocalBuild/BuildLogStore.cs @@ -6,6 +6,19 @@ namespace BuildMonitor.Infrastructure.LocalBuild; public sealed class BuildLogStore(string logsRootDirectory) { + public string GetLogPath(string projectId, BuildLogKind kind) + { + var fileName = kind switch + { + BuildLogKind.Test => "last-test.log", + BuildLogKind.WatchCompile => "last-watch.log", + BuildLogKind.Run => "last-run.log", + _ => "last-build.log" + }; + + return Path.Combine(logsRootDirectory, projectId, fileName); + } + public async Task SaveAsync( string projectId, BuildLogKind kind, @@ -35,7 +48,20 @@ public async Task SaveAsync( await File.WriteAllTextAsync(logPath, logText, cancellationToken); - var (errorCount, errorLines) = BuildLogParser.ParseErrors(logText); + var (resolvedErrors, resolvedWarnings) = BuildIssueCountResolver.Resolve(logText, logPath); + var (parsedErrors, errorLines) = BuildLogParser.ParseErrors(logText); + if (resolvedErrors == 0 && resolvedWarnings == 0 + && IncrementalBuildDetector.WasCompileSkipped(logText) + && File.Exists(prevPath)) + { + var prevText = await File.ReadAllTextAsync(prevPath, cancellationToken); + (parsedErrors, errorLines) = BuildLogParser.ParseErrors(prevText); + resolvedErrors = parsedErrors; + resolvedWarnings = BuildLogParser.ParseWarningCount(prevText); + } + + var errorCount = Math.Max(parsedErrors, resolvedErrors); + var warningCount = Math.Max(BuildLogParser.ParseWarningCount(logText), resolvedWarnings); var finishedAt = DateTimeOffset.UtcNow; var record = new BuildLogRecord( projectId, @@ -46,7 +72,8 @@ public async Task SaveAsync( finishedAt, logPath, errorCount, - errorLines); + errorLines, + warningCount); var metaPath = Path.Combine(projectDir, $"{Path.GetFileNameWithoutExtension(fileName)}.meta.json"); var dto = new BuildLogMetadataDto @@ -59,6 +86,7 @@ public async Task SaveAsync( FinishedAtUtc = record.FinishedAtUtc, LogFilePath = record.LogFilePath, ErrorCount = record.ErrorCount, + WarningCount = record.WarningCount, ErrorLines = record.ErrorLines.ToList() }; await File.WriteAllTextAsync(metaPath, JsonSerializer.Serialize(dto), cancellationToken); @@ -129,3 +157,4 @@ public static string TruncateTailForDisplay(string text, int maxBytes) return $"... (truncated, showing last {maxBytes} bytes)\n{tail}"; } } + diff --git a/src/Infrastructure/LocalBuild/BuildMonitorLogBanner.cs b/src/Infrastructure/LocalBuild/BuildMonitorLogBanner.cs index 7f6bedb..f02bf0c 100644 --- a/src/Infrastructure/LocalBuild/BuildMonitorLogBanner.cs +++ b/src/Infrastructure/LocalBuild/BuildMonitorLogBanner.cs @@ -14,6 +14,24 @@ public static string FormatFinished(int buildNumber, int exitCode) return $"[BuildMonitor] ===== Build #{buildNumber} finished — {status} (exit {exitCode}) ====="; } + public static string FormatIncrementalNote(int errorCount, int warningCount) + { + var parts = new List(); + if (errorCount > 0) + { + parts.Add($"{errorCount} error(s)"); + } + + if (warningCount > 0) + { + parts.Add($"{warningCount} warning(s)"); + } + + var counts = parts.Count > 0 ? string.Join(", ", parts) : "prior issue counts"; + return $"[BuildMonitor] Incremental build — compiler skipped (outputs up-to-date). " + + $"Tray health uses {counts} from the previous full build log."; + } + public static string FormatTest(int testNumber, string reason, DateTimeOffset? timestamp = null) { var ts = (timestamp ?? DateTimeOffset.Now).ToString("yyyy-MM-dd HH:mm:ss"); diff --git a/src/Infrastructure/LocalBuild/DebouncedFileWatcher.cs b/src/Infrastructure/LocalBuild/DebouncedFileWatcher.cs index 47a5815..5641541 100644 --- a/src/Infrastructure/LocalBuild/DebouncedFileWatcher.cs +++ b/src/Infrastructure/LocalBuild/DebouncedFileWatcher.cs @@ -14,6 +14,28 @@ public sealed class DebouncedFileWatcher : IDisposable public bool IsSuspended { get; private set; } + public bool HasPendingChanges + { + get + { + lock (pendingPathsSync) + { + return pendingPaths.Count > 0 || debounceTimer.Enabled; + } + } + } + + public DateTimeOffset? BurstStartedUtc + { + get + { + lock (pendingPathsSync) + { + return burstStartedUtc; + } + } + } + public DebouncedFileWatcher(string rootPath, int debounceMs, IEnumerable? extraIgnoreSegments = null) { ignoreSegments = new HashSet( diff --git a/src/Infrastructure/LocalBuild/DotNetBuildArguments.cs b/src/Infrastructure/LocalBuild/DotNetBuildArguments.cs new file mode 100644 index 0000000..128b194 --- /dev/null +++ b/src/Infrastructure/LocalBuild/DotNetBuildArguments.cs @@ -0,0 +1,17 @@ +namespace BuildMonitor.Infrastructure.LocalBuild; + +public static class DotNetBuildArguments +{ + public static bool RequiresFullRebuild(string? buildReason) => + string.Equals(buildReason, "startup", StringComparison.OrdinalIgnoreCase) + || string.Equals(buildReason, "manual rebuild", StringComparison.OrdinalIgnoreCase) + || string.Equals(buildReason, "rebuild & restart", StringComparison.OrdinalIgnoreCase); + + public static void ApplyFullRebuildFlag(IList arguments, bool forceFullRebuild) + { + if (forceFullRebuild && !arguments.Contains("--no-incremental", StringComparer.OrdinalIgnoreCase)) + { + arguments.Add("--no-incremental"); + } + } +} diff --git a/src/Infrastructure/LocalBuild/DotNetCliRunner.cs b/src/Infrastructure/LocalBuild/DotNetCliRunner.cs index 3e9a097..b91dae2 100644 --- a/src/Infrastructure/LocalBuild/DotNetCliRunner.cs +++ b/src/Infrastructure/LocalBuild/DotNetCliRunner.cs @@ -7,10 +7,14 @@ public sealed record CliRunResult( int ExitCode, string Output, TimeSpan Duration, - string CommandLine); + string CommandLine, + bool WasCancelled = false); public sealed class DotNetCliRunner { + private readonly object processSync = new(); + private Process? activeProcess; + public async Task RunAsync( string workingDirectory, IReadOnlyList arguments, @@ -39,6 +43,11 @@ public async Task RunAsync( DotNetProcessConfigurator.Apply(psi, dotnetArgs); using var process = new Process { StartInfo = psi, EnableRaisingEvents = true }; + lock (processSync) + { + activeProcess = process; + } + process.OutputDataReceived += (_, e) => { if (e.Data is not null) @@ -56,14 +65,67 @@ public async Task RunAsync( } }; + using var registration = cancellationToken.Register(() => TryKillActiveProcess(process)); + process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); - await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + try + { + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + TryKillActiveProcess(process); + if (!process.HasExited) + { + try + { + await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); + } + catch + { + // Process may already be gone after kill. + } + } + + var cancelledDuration = DateTimeOffset.UtcNow - started; + var cancelledText = BuildLogTextNormalizer.Normalize( + BuildLogParser.DeduplicateConsecutiveLines(output.ToString())); + return new CliRunResult(-1, cancelledText, cancelledDuration, commandLine, WasCancelled: true); + } + finally + { + lock (processSync) + { + if (ReferenceEquals(activeProcess, process)) + { + activeProcess = null; + } + } + } + var duration = DateTimeOffset.UtcNow - started; var text = BuildLogTextNormalizer.Normalize( BuildLogParser.DeduplicateConsecutiveLines(output.ToString())); return new CliRunResult(process.ExitCode, text, duration, commandLine); } + + private void TryKillActiveProcess(Process process) + { + if (process.HasExited) + { + return; + } + + try + { + process.Kill(entireProcessTree: true); + } + catch + { + // Best effort — caller handles cancellation outcome. + } + } } diff --git a/src/Infrastructure/LocalBuild/IncrementalBuildDetector.cs b/src/Infrastructure/LocalBuild/IncrementalBuildDetector.cs new file mode 100644 index 0000000..b46feb2 --- /dev/null +++ b/src/Infrastructure/LocalBuild/IncrementalBuildDetector.cs @@ -0,0 +1,25 @@ +namespace BuildMonitor.Infrastructure.LocalBuild; + +public static class IncrementalBuildDetector +{ + /// + /// True when MSBuild succeeded with a 0/0 summary and no compiler diagnostic lines — + /// typical of an incremental build where outputs were already up-to-date. + /// + public static bool WasCompileSkipped(string logText) + { + if (string.IsNullOrWhiteSpace(logText) + || !logText.Contains("Build succeeded", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (BuildLogParser.ParseErrorCount(logText) > 0 + || BuildLogParser.ParseWarningCount(logText) > 0) + { + return false; + } + + return BuildLogParser.ParseIssues(logText, maxWarnings: 1).Count == 0; + } +} diff --git a/src/Infrastructure/LocalBuild/WatchIgnoreRules.cs b/src/Infrastructure/LocalBuild/WatchIgnoreRules.cs index 3707211..769ef6d 100644 --- a/src/Infrastructure/LocalBuild/WatchIgnoreRules.cs +++ b/src/Infrastructure/LocalBuild/WatchIgnoreRules.cs @@ -24,7 +24,16 @@ public static class WatchIgnoreRules ".bak", ".swp", ".mdb", - ".meta" + ".meta", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".svg", + ".ico", + ".bmp", + ".avif" }; private static readonly HashSet IgnoredFileNames = new(StringComparer.OrdinalIgnoreCase) diff --git a/src/Infrastructure/Services/ProjectOrchestrator.cs b/src/Infrastructure/Services/ProjectOrchestrator.cs index 381e93f..1c872b7 100644 --- a/src/Infrastructure/Services/ProjectOrchestrator.cs +++ b/src/Infrastructure/Services/ProjectOrchestrator.cs @@ -72,20 +72,33 @@ public IReadOnlyList GetBuildIntelligenceSnapshots() lock (sync) { var monitor = settings.Monitor; + var activeProjects = settings.Projects + .Where(p => p.IsActiveInSession) + .ToList(); + var activeIds = new HashSet( + activeProjects.Select(p => p.Id), + StringComparer.OrdinalIgnoreCase); + var triggerCounts = triggerJournal.GetEntries() + .GroupBy(e => e.ProjectId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase); var snapshots = new List(); - var activeIds = new HashSet(runtimes.Keys, StringComparer.OrdinalIgnoreCase); - foreach (var runtime in runtimes.Values) + foreach (var runtime in runtimes.Values.Where(r => activeIds.Contains(r.ProjectId))) { - snapshots.Add(runtime.GetIntelligenceSnapshot(monitor)); + var count = triggerCounts.GetValueOrDefault(runtime.ProjectId); + snapshots.Add(runtime.GetIntelligenceSnapshot(monitor, count)); } - foreach (var project in settings.Projects.Where(p => !activeIds.Contains(p.Id))) + foreach (var project in activeProjects.Where(p => !runtimes.ContainsKey(p.Id))) { + var count = triggerCounts.GetValueOrDefault(project.Id); snapshots.Add(BuildIntelligenceSnapshot.FromStoredStats( project, monitor, - burstStatsStore.GetOrDefault(project.Id))); + burstStatsStore.GetOrDefault(project.Id)) with + { + TodayTriggerCount = count + }); } return snapshots diff --git a/src/Infrastructure/Services/ProjectRuntime.Build.cs b/src/Infrastructure/Services/ProjectRuntime.Build.cs index 1174b28..52bf316 100644 --- a/src/Infrastructure/Services/ProjectRuntime.Build.cs +++ b/src/Infrastructure/Services/ProjectRuntime.Build.cs @@ -62,18 +62,21 @@ public async Task BuildAsync(CancellationToken cancellationToken) RecordBuildTrigger( BuildTriggerKindFormatter.FromBuildReason(buildReason, triggeredByFileChange), buildReason, - detail: null, + detail: triggeredByFileChange ? lastFileChangeTriggerDetail : null, fileChangePaths); + lastFileChangeTriggerDetail = null; - fileWatcher?.Suspend(); + buildCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + currentBuildReasonInFlight = buildReason; + var buildToken = buildCancellationSource.Token; try { if (runProcess is not null) { SetProjectCurrentAction("Building — stopping app"); - await StopRunProcessAsync(cancellationToken); - await Task.Delay(500, cancellationToken); + await StopRunProcessAsync(buildToken); + await Task.Delay(500, buildToken); } lock (liveOutputSync) @@ -83,8 +86,6 @@ public async Task BuildAsync(CancellationToken cancellationToken) watchRebuildInProgress = false; Interlocked.Exchange(ref liveOutputRevision, 0); - buildErrorCount = 0; - buildWarningCount = 0; lastErrorPreview = null; var buildBanner = WriteBuildStartBanner(buildReason); @@ -100,12 +101,18 @@ public async Task BuildAsync(CancellationToken cancellationToken) if (releaseLocks) { SetProjectCurrentAction("Building — releasing output locks"); - await ReleaseOutputLocksAsync(cancellationToken); + await ReleaseOutputLocksAsync(buildToken); } SetProjectCurrentAction("Building — dotnet build"); - var args = BuildProjectArgs(); - var result = await RunBuildAttemptAsync(args, cancellationToken, buildBanner); + var args = BuildProjectArgs(DotNetBuildArguments.RequiresFullRebuild(buildReason)); + var result = await RunBuildAttemptAsync(args, buildToken, buildBanner); + + if (result.WasCancelled) + { + await HandleCancelledBuildAsync(buildReason, result, buildBanner, cancellationToken); + return; + } if (releaseLocks && result.ExitCode != 0 @@ -126,7 +133,12 @@ public async Task BuildAsync(CancellationToken cancellationToken) progressSteps = buildProgressTracker.Steps; NotifyProgressChanged(force: true); - result = await RunBuildAttemptAsync(args, cancellationToken, retryBanner); + result = await RunBuildAttemptAsync(args, buildToken, retryBanner); + if (result.WasCancelled) + { + await HandleCancelledBuildAsync(buildReason, result, retryBanner, cancellationToken); + return; + } } if (result.ExitCode != 0 @@ -155,7 +167,12 @@ public async Task BuildAsync(CancellationToken cancellationToken) buildProgressTracker.Reset(); progressSteps = buildProgressTracker.Steps; NotifyProgressChanged(force: true); - result = await RunBuildAttemptAsync(args, cancellationToken, repairBanner); + result = await RunBuildAttemptAsync(args, buildToken, repairBanner); + if (result.WasCancelled) + { + await HandleCancelledBuildAsync(buildReason, result, repairBanner, cancellationToken); + return; + } } } @@ -163,8 +180,30 @@ public async Task BuildAsync(CancellationToken cancellationToken) lastExitCode = result.ExitCode; lastDuration = result.Duration; + var existingLogPath = logStore.GetLogPath(definition.Id, BuildLogKind.Build); + var (resolvedErrors, resolvedWarnings) = BuildIssueCountResolver.Resolve( + result.Output, + existingLogPath); + var parsedErrors = BuildLogParser.ParseErrorCount(result.Output); + buildErrorCount = Math.Max(resolvedErrors, parsedErrors); + buildWarningCount = resolvedWarnings; + if (result.ExitCode != 0 && buildErrorCount == 0) + { + var (_, errorLines) = BuildLogParser.ParseErrors(result.Output); + if (errorLines.Count > 0) + { + buildErrorCount = errorLines.Count; + } + } + var finishBanner = BuildMonitorLogBanner.FormatFinished(buildNumber, result.ExitCode); var logText = result.Output + Environment.NewLine + finishBanner; + if (IncrementalBuildDetector.WasCompileSkipped(result.Output) + && (resolvedErrors > 0 || resolvedWarnings > 0)) + { + logText += Environment.NewLine + + BuildMonitorLogBanner.FormatIncrementalNote(resolvedErrors, resolvedWarnings); + } var buildLog = await logStore.SaveAsync( definition.Id, @@ -176,8 +215,6 @@ public async Task BuildAsync(CancellationToken cancellationToken) cancellationToken); lastBuildFinishedAtUtc = buildLog.FinishedAtUtc; - buildErrorCount = buildLog.ErrorCount; - buildWarningCount = BuildLogParser.ParseWarningCount(result.Output); lastErrorPreview = buildLog.ErrorLines.FirstOrDefault(); if (result.Duration.TotalMilliseconds > 0) { @@ -186,6 +223,7 @@ public async Task BuildAsync(CancellationToken cancellationToken) if (result.ExitCode == 0) { + progressSteps = []; SetState(ProjectLifecycleState.BuildOk); if (definition.RunOptions.RunTests == TestRunTrigger.OnBuildSuccess) { @@ -217,7 +255,7 @@ public async Task BuildAsync(CancellationToken cancellationToken) { if (triggeredByFileChange) { - await Task.Delay(1500, cancellationToken); + await Task.Delay(1500, buildToken); } StartRunProcess(skipEmbeddedBuild: true); @@ -228,9 +266,11 @@ public async Task BuildAsync(CancellationToken cancellationToken) } finally { + buildCancellationSource?.Dispose(); + buildCancellationSource = null; + currentBuildReasonInFlight = null; Interlocked.Exchange(ref buildInProgress, 0); Interlocked.Exchange(ref buildTriggeredByFileChange, 0); - fileWatcher?.Resume(); if (triggeredByFileChange) { @@ -246,21 +286,66 @@ public async Task BuildAsync(CancellationToken cancellationToken) } } - if (pendingFileChangeRebuild && lastFileChangePaths.Count > 0) + if (pendingFileChangeRebuild) { - pendingFileChangeRebuild = false; - _ = ScheduleCoalescedFileChangeRebuildAsync(); + var nextReason = pendingRebuildHoldReason == PendingRebuildHoldReason.StartupDeferred + ? "startup" + : "file change (queued)"; + _ = WaitForEditQuietThenBuildAsync(nextReason); } } } - private async Task ScheduleCoalescedFileChangeRebuildAsync() + private async Task HandleCancelledBuildAsync( + string buildReason, + CliRunResult result, + string? buildBanner, + CancellationToken cancellationToken) + { + var cancelBanner = "[BuildMonitor] Build cancelled — superseded by newer source changes."; + var logText = result.Output; + if (!string.IsNullOrWhiteSpace(logText) && !logText.EndsWith('\n')) + { + logText += Environment.NewLine; + } + + logText += cancelBanner; + + await logStore.SaveAsync( + definition.Id, + BuildLogKind.Build, + result.CommandLine, + result.ExitCode, + DateTimeOffset.UtcNow - result.Duration, + logText, + cancellationToken); + + progressSteps = []; + buildProgressTracker = null; + EnterWaitingForEditsState("Build cancelled — waiting for edits to settle"); + + notifyUser?.Invoke( + definition.Id, + $"Build cancelled — {definition.DisplayName}", + "Newer source changes detected. Rebuilding when edits settle.", + UserNotificationKind.Info, + UserNotificationCategory.FileChangeDetected); + } + + private async Task WaitForEditQuietThenBuildAsync(string buildReason) { var generation = Interlocked.Increment(ref fileChangeRebuildScheduleGeneration); + EnterWaitingForEditsState("Waiting for edits to settle…"); while (generation == Volatile.Read(ref fileChangeRebuildScheduleGeneration)) { + var activity = EvaluateEditActivity(); var waitUntil = GetFileChangeQuietUntilUtc(); + if (activity.IsActive && activity.QuietUntilUtc > waitUntil) + { + waitUntil = activity.QuietUntilUtc; + } + if (fileChangeBuildCooldownUntil > waitUntil) { waitUntil = fileChangeBuildCooldownUntil; @@ -275,11 +360,16 @@ private async Task ScheduleCoalescedFileChangeRebuildAsync() if (Volatile.Read(ref buildInProgress) != 0) { - pendingFileChangeRebuild = true; + QueuePendingRebuild( + PendingRebuildHoldReason.BuildInProgress, + lastFileChangePaths, + wasAlreadyPending: true, + pathsAlreadyRelative: true); return; } - if (DateTimeOffset.UtcNow < GetFileChangeQuietUntilUtc()) + activity = EvaluateEditActivity(); + if (activity.IsActive || DateTimeOffset.UtcNow < GetFileChangeQuietUntilUtc()) { continue; } @@ -294,11 +384,28 @@ private async Task ScheduleCoalescedFileChangeRebuildAsync() if (Volatile.Read(ref buildInProgress) != 0) { - pendingFileChangeRebuild = true; + QueuePendingRebuild( + PendingRebuildHoldReason.BuildInProgress, + lastFileChangePaths, + wasAlreadyPending: true, + pathsAlreadyRelative: true); return; } + lastFileChangeTriggerDetail = BuildTriggerDetailFormatter.FormatCoalescedBuild( + GetSessionAdjustedFileChangeDebounceMs(), + pendingRebuildHoldReason, + pendingRebuildTimerResetCount); + ClearPendingRebuildHold(); pendingFileChangeRebuild = false; + + if (string.Equals(buildReason, "startup", StringComparison.OrdinalIgnoreCase)) + { + pendingBuildReason = "startup"; + await BuildAsync(CancellationToken.None); + return; + } + Interlocked.Exchange(ref buildTriggeredByFileChange, 1); pendingBuildReason = "file change (queued)"; @@ -325,14 +432,18 @@ private async Task HydrateLastBuildFromStoreAsync(CancellationToken cancellation lastDuration = metadata.FinishedAtUtc - metadata.StartedAtUtc; lastBuildFinishedAtUtc = metadata.FinishedAtUtc; buildErrorCount = metadata.ErrorCount; + buildWarningCount = metadata.WarningCount; lastErrorPreview = metadata.ErrorLines.FirstOrDefault(); - var logText = await logStore.LoadLogTextAsync(metadata, maxBytes: 512_000, cancellationToken); - if (!string.IsNullOrWhiteSpace(logText)) + if (buildWarningCount == 0) { - buildWarningCount = BuildLogParser.ParseWarningCount(logText); - if (buildErrorCount == 0) + var logText = await logStore.LoadLogTextAsync(metadata, maxBytes: 512_000, cancellationToken); + if (!string.IsNullOrWhiteSpace(logText)) { - buildErrorCount = BuildLogParser.ParseErrorCount(logText); + var (resolvedErrors, resolvedWarnings) = BuildIssueCountResolver.Resolve( + logText, + metadata.LogFilePath); + buildErrorCount = Math.Max(buildErrorCount, resolvedErrors); + buildWarningCount = Math.Max(buildWarningCount, resolvedWarnings); } } @@ -437,34 +548,49 @@ private void OnFileWatcherChanged(IReadOnlyList changedPaths, int burstD lastFileChangePaths = RelativizePaths(meaningful); SyncFileWatcherDebounceMs(); + var wasAlreadyPending = pendingFileChangeRebuild; if (DateTimeOffset.UtcNow < fileChangeBuildCooldownUntil) { - pendingFileChangeRebuild = true; + QueuePendingRebuild(PendingRebuildHoldReason.PostBuildCooldown, meaningful, wasAlreadyPending); return; } if (Volatile.Read(ref testInProgress) != 0) { - pendingFileChangeRebuild = true; + QueuePendingRebuild(PendingRebuildHoldReason.TestsInProgress, meaningful, wasAlreadyPending); return; } if (Volatile.Read(ref buildInProgress) != 0) { - pendingFileChangeRebuild = true; + QueuePendingRebuild(PendingRebuildHoldReason.BuildInProgress, meaningful, wasAlreadyPending); + if (BuildSuppressionPolicy.ShouldCancelInFlightBuild( + GetSuppressionSettings(), + currentBuildReasonInFlight)) + { + pendingRebuildHoldReason = PendingRebuildHoldReason.SupersededByNewEdits; + RequestBuildCancellation(); + Interlocked.Increment(ref fileChangeRebuildScheduleGeneration); + } + return; } - if (IsAgentEditSessionActive()) + if (IsAgentEditSessionActive() || EvaluateEditActivity().IsActive) { - pendingFileChangeRebuild = true; - _ = ScheduleCoalescedFileChangeRebuildAsync(); + var reason = wasAlreadyPending + ? PendingRebuildHoldReason.EditsStillArriving + : PendingRebuildHoldReason.EditsSettling; + QueuePendingRebuild(reason, meaningful, wasAlreadyPending); + _ = WaitForEditQuietThenBuildAsync("file change (queued)"); return; } Interlocked.Exchange(ref buildTriggeredByFileChange, 1); pendingBuildReason = "file change"; + lastFileChangeTriggerDetail = BuildTriggerDetailFormatter.FormatImmediateDebounce( + GetSessionAdjustedFileChangeDebounceMs()); notifyUser?.Invoke( definition.Id, @@ -476,4 +602,35 @@ private void OnFileWatcherChanged(IReadOnlyList changedPaths, int burstD _ = BuildAsync(CancellationToken.None); } + private void QueuePendingRebuild( + PendingRebuildHoldReason reason, + IReadOnlyList meaningfulPaths, + bool wasAlreadyPending, + bool pathsAlreadyRelative = false) + { + pendingFileChangeRebuild = true; + + pendingRebuildHoldReason = reason == PendingRebuildHoldReason.EditsSettling && wasAlreadyPending + ? PendingRebuildHoldReason.EditsStillArriving + : reason; + + if (pendingRebuildHoldReason == PendingRebuildHoldReason.EditsStillArriving) + { + pendingRebuildTimerResetCount++; + } + + pendingRebuildHoldFileCount = meaningfulPaths.Count; + pendingRebuildHoldSamplePaths = pathsAlreadyRelative + ? meaningfulPaths.Take(3).ToList() + : RelativizePaths(meaningfulPaths).Take(3).ToList(); + } + + private void ClearPendingRebuildHold() + { + pendingRebuildHoldReason = PendingRebuildHoldReason.None; + pendingRebuildHoldFileCount = 0; + pendingRebuildHoldSamplePaths = []; + pendingRebuildTimerResetCount = 0; + } + } diff --git a/src/Infrastructure/Services/ProjectRuntime.BuildOutput.cs b/src/Infrastructure/Services/ProjectRuntime.BuildOutput.cs index 8658d21..28d25dd 100644 --- a/src/Infrastructure/Services/ProjectRuntime.BuildOutput.cs +++ b/src/Infrastructure/Services/ProjectRuntime.BuildOutput.cs @@ -188,8 +188,7 @@ private bool RefreshBuildIssueCountsFromWatchOutput(bool force) return false; } - var parsedErrors = BuildLogParser.ParseErrorCount(output); - var parsedWarnings = BuildLogParser.ParseWarningCount(output); + var (parsedErrors, parsedWarnings) = CountLiveBuildIssues(output); if (parsedErrors == buildErrorCount && parsedWarnings == buildWarningCount) { return false; @@ -222,8 +221,10 @@ private bool RefreshLiveIssueCounts(bool force) : liveBuildOutput.ToString(); } - var parsedErrors = BuildLogParser.ParseErrorCount(output); - var parsedWarnings = BuildLogParser.ParseWarningCount(output); + var normalized = BuildLogTextNormalizer.Normalize(output); + var (parsedErrors, parsedWarnings) = state == ProjectLifecycleState.Testing + ? CountLiveIssues(BuildLogKind.Test, normalized) + : CountLiveBuildIssues(normalized); if (parsedErrors == buildErrorCount && parsedWarnings == buildWarningCount) { return false; @@ -236,9 +237,11 @@ private bool RefreshLiveIssueCounts(bool force) private void NotifyProgressChanged(bool force = false) => RequestHealthCoalesce(force); - private List BuildProjectArgs() + + private List BuildProjectArgs(bool forceFullRebuild = false) { var args = new List { "build", ResolveProjectFileArg() }; + DotNetBuildArguments.ApplyFullRebuildFlag(args, forceFullRebuild); AppendExtraArgs(args); return args; } diff --git a/src/Infrastructure/Services/ProjectRuntime.EditGating.cs b/src/Infrastructure/Services/ProjectRuntime.EditGating.cs new file mode 100644 index 0000000..37dc327 --- /dev/null +++ b/src/Infrastructure/Services/ProjectRuntime.EditGating.cs @@ -0,0 +1,122 @@ +using BuildMonitor.Core.Models; +using BuildMonitor.Core.Rules; +using BuildMonitor.Core.Settings; +using BuildMonitor.Infrastructure.LocalBuild; + +namespace BuildMonitor.Infrastructure.Services; + +internal sealed partial class ProjectRuntime +{ + private AgentActivityWatcher? agentActivityWatcher; + private bool deferStartupBuildUntilQuiet = true; + private bool cancelSupersededBuilds = true; + private bool useAgentTranscriptActivity = true; + private CancellationTokenSource? buildCancellationSource; + private string? currentBuildReasonInFlight; + + private BuildSuppressionSettings GetSuppressionSettings() => + new(deferStartupBuildUntilQuiet, cancelSupersededBuilds); + + private void ApplyMonitorSuppressionSettings(GlobalMonitorSettings monitor) + { + deferStartupBuildUntilQuiet = monitor.DeferStartupBuildUntilQuiet; + cancelSupersededBuilds = monitor.CancelSupersededBuilds; + useAgentTranscriptActivity = monitor.UseAgentTranscriptActivity; + } + + private EditActivitySnapshot EvaluateEditActivity() + { + var agentActivity = useAgentTranscriptActivity + && agentActivityWatcher is not null + && agentActivityWatcher.LastActivityUtc != DateTimeOffset.MinValue + ? agentActivityWatcher.LastActivityUtc + : (DateTimeOffset?)null; + + return EditActivitySnapshot.Evaluate( + new EditActivityInput( + fileWatcher?.HasPendingChanges == true, + fileWatcher?.BurstStartedUtc, + lastMeaningfulFileChangeUtc, + agentActivity, + GetSessionAdjustedFileChangeDebounceMs(), + useAgentTranscriptActivity), + DateTimeOffset.UtcNow); + } + + private bool IsEditGatingActive() + { + var activity = EvaluateEditActivity(); + return BuildSuppressionPolicy.IsEditGatingActive( + GetSuppressionSettings(), + pendingFileChangeRebuild, + activity, + pendingRebuildHoldReason); + } + + private DateTimeOffset? GetEditGatingQuietUntilUtc() + { + if (pendingFileChangeRebuild && lastMeaningfulFileChangeUtc != DateTimeOffset.MinValue) + { + return AdaptiveFileChangeDebounce.ComputeQuietUntilUtc( + lastMeaningfulFileChangeUtc, + GetSessionAdjustedFileChangeDebounceMs()); + } + + var activity = EvaluateEditActivity(); + return activity.IsActive ? activity.QuietUntilUtc : null; + } + + private string? BuildEditGatingDetailText() + { + if (!IsEditGatingActive() && pendingRebuildHoldReason == PendingRebuildHoldReason.None) + { + return null; + } + + PruneRecentFileChangeBuildStarts(); + return EditGatingDetailFormatter.FormatHoldReason( + pendingRebuildHoldReason, + pendingRebuildHoldFileCount, + pendingRebuildHoldSamplePaths, + pendingRebuildTimerResetCount, + GetSessionAdjustedFileChangeDebounceMs(), + recentFileChangeBuildStarts.Count >= 1); + } + + private void TryStartAgentActivityWatcher() + { + if (!useAgentTranscriptActivity || agentActivityWatcher is not null) + { + return; + } + + try + { + agentActivityWatcher = new AgentActivityWatcher(definition.RootFolder); + } + catch + { + agentActivityWatcher = null; + } + } + + private void RequestBuildCancellation() + { + try + { + buildCancellationSource?.Cancel(); + } + catch (ObjectDisposedException) + { + // Build already finished. + } + } + + private void EnterWaitingForEditsState(string action) + { + SetState(ProjectLifecycleState.WaitingForEdits); + SetProjectCurrentAction(action); + MarkHealthDirty(); + HealthCoalesceRequested?.Invoke(true); + } +} diff --git a/src/Infrastructure/Services/ProjectRuntime.RunOutput.cs b/src/Infrastructure/Services/ProjectRuntime.RunOutput.cs index 1b38efc..36c331a 100644 --- a/src/Infrastructure/Services/ProjectRuntime.RunOutput.cs +++ b/src/Infrastructure/Services/ProjectRuntime.RunOutput.cs @@ -254,6 +254,9 @@ private async Task RestartAppCoreAsync( try { + SetProjectCurrentAction(rebuildFirst + ? "Restarting — rebuild then start app" + : "Restarting app (dotnet run --no-build)"); await StopRunProcessAsync(cancellationToken); restartCount = 0; runErrorCount = 0; @@ -271,6 +274,15 @@ private async Task RestartAppCoreAsync( "Hot reload requested app restart (no rebuild)", detail: null); } + else + { + notifyUser?.Invoke( + definition.Id, + $"Restarting app — {definition.DisplayName}", + "Stopping run/watch and starting again with --no-build.", + UserNotificationKind.Info, + UserNotificationCategory.Info); + } EnsureRunProcessStartedAfterBuild(); } diff --git a/src/Infrastructure/Services/ProjectRuntime.Test.cs b/src/Infrastructure/Services/ProjectRuntime.Test.cs index 2f811f1..d0e2026 100644 --- a/src/Infrastructure/Services/ProjectRuntime.Test.cs +++ b/src/Infrastructure/Services/ProjectRuntime.Test.cs @@ -39,6 +39,8 @@ public async Task TestAsync(CancellationToken cancellationToken) var wasRunProcessActive = runProcess?.IsRunning == true; var releaseLocksSetting = definition.RunOptions.ReleaseOutputLocksBeforeBuild; var stoppedAppForTests = false; + var preservedBuildErrors = buildErrorCount; + var preservedBuildWarnings = buildWarningCount; fileWatcher?.Suspend(); fileChangeBuildCooldownUntil = DateTimeOffset.UtcNow.AddMinutes(2); @@ -142,6 +144,8 @@ await logStore.SaveAsync( if (effectiveExitCode == 0) { + buildErrorCount = preservedBuildErrors; + buildWarningCount = preservedBuildWarnings; SetState(ProjectLifecycleState.TestOk); } else diff --git a/src/Infrastructure/Services/ProjectRuntime.cs b/src/Infrastructure/Services/ProjectRuntime.cs index 70fbcc4..ee9866d 100644 --- a/src/Infrastructure/Services/ProjectRuntime.cs +++ b/src/Infrastructure/Services/ProjectRuntime.cs @@ -61,6 +61,11 @@ internal sealed partial class ProjectRuntime : IDisposable private int fileChangeRebuildScheduleGeneration; private readonly Queue recentFileChangeBuildStarts = new(); private IReadOnlyList lastFileChangePaths = []; + private string? lastFileChangeTriggerDetail; + private PendingRebuildHoldReason pendingRebuildHoldReason; + private int pendingRebuildHoldFileCount; + private IReadOnlyList pendingRebuildHoldSamplePaths = []; + private int pendingRebuildTimerResetCount; private int runProcessGeneration; private Action? runProcessExitedHandler; private string? pendingListenUrl; @@ -119,7 +124,10 @@ public ProjectHealthSnapshot BuildSnapshot() runErrorCount, runWarningCount), HealthIssueCountsFormatter.FormatFailurePhase(state), - isRestarting); + isRestarting, + IsEditGatingActive(), + BuildEditGatingDetailText(), + GetEditGatingQuietUntilUtc()); } public void MarkHealthDirty() => Interlocked.Exchange(ref healthDirty, 1); @@ -144,6 +152,12 @@ public void ForceCoalesceHealth() private void CoalesceHealthCore() { RefreshLiveIssueCounts(force: true); + if (UsesDotNetWatchProcess() + && state is ProjectLifecycleState.Watching or ProjectLifecycleState.Running) + { + RefreshBuildIssueCountsFromWatchOutput(force: false); + } + RefreshHealth(); } @@ -188,6 +202,7 @@ public void UpdateDefinition(LocalProjectDefinition updated, GlobalMonitorSettin fileChangeDebounceMs = ResolveFileChangeDebounceMs(); fileWatcher?.SetDebounceMs(fileChangeDebounceMs); coalesceWatchRebuilds = monitor.CoalesceWatchRebuilds; + ApplyMonitorSuppressionSettings(monitor); } private int ResolveFileChangeDebounceMs() => @@ -244,7 +259,7 @@ private bool IsAgentEditSessionActive() return recentFileChangeBuildStarts.Count >= 1; } - public BuildIntelligenceSnapshot GetIntelligenceSnapshot(GlobalMonitorSettings monitor) + public BuildIntelligenceSnapshot GetIntelligenceSnapshot(GlobalMonitorSettings monitor, int todayTriggerCount = 0) { PruneRecentFileChangeBuildStarts(); var stats = burstStatsStore.GetOrDefault(definition.Id); @@ -268,7 +283,12 @@ public BuildIntelligenceSnapshot GetIntelligenceSnapshot(GlobalMonitorSettings m coalesceWatchRebuilds, lastMeaningfulFileChangeUtc == DateTimeOffset.MinValue ? null : lastMeaningfulFileChangeUtc, pendingFileChangeRebuild, - rebuildQuietUntilUtc); + rebuildQuietUntilUtc, + todayTriggerCount, + pendingRebuildHoldReason, + pendingRebuildHoldFileCount, + pendingRebuildHoldSamplePaths, + pendingRebuildTimerResetCount); } private bool UsesCoalescedWatchRebuilds() => @@ -296,6 +316,11 @@ private bool ShouldStartFileWatcher() public void SetUserNotifier(Action? notifier) => notifyUser = notifier; + private (int Errors, int Warnings) CountLiveBuildIssues(string normalized) => + BuildIssueCountResolver.Resolve( + normalized, + logStore.GetLogPath(definition.Id, BuildLogKind.Build)); + private static (int Errors, int Warnings) CountLiveIssues(BuildLogKind kind, string normalized) => kind switch { @@ -361,7 +386,9 @@ private static (int Errors, int Warnings) CountTestIssues(string normalized) var revision = kind == BuildLogKind.Test ? Volatile.Read(ref liveTestOutputRevision) : Volatile.Read(ref liveOutputRevision); - var (liveErrors, liveWarnings) = CountLiveIssues(kind, normalized); + var (liveErrors, liveWarnings) = kind == BuildLogKind.Build + ? CountLiveBuildIssues(normalized) + : CountLiveIssues(kind, normalized); return new LiveBuildLogView( normalized, true, @@ -375,7 +402,23 @@ public async Task StartAsync(CancellationToken cancellationToken) { SetProjectCurrentAction("Starting — loading saved build state"); await HydrateLastBuildFromStoreAsync(cancellationToken); - await BuildAsync(cancellationToken); + TryStartFileWatcher(); + TryStartAgentActivityWatcher(); + + var activity = EvaluateEditActivity(); + if (BuildSuppressionPolicy.ShouldDeferStartupBuild(GetSuppressionSettings(), activity)) + { + pendingFileChangeRebuild = true; + QueuePendingRebuild( + PendingRebuildHoldReason.StartupDeferred, + [], + wasAlreadyPending: false); + await WaitForEditQuietThenBuildAsync("startup"); + } + else + { + await BuildAsync(cancellationToken); + } if (definition.RunOptions.RunMode == ProjectRunMode.None) { @@ -390,7 +433,10 @@ public async Task StartAsync(CancellationToken cancellationToken) // Build already completed above — skip watch/run's embedded rebuild. StartRunProcess(skipEmbeddedBuild: true); - TryStartFileWatcher(); + if (fileWatcher is null) + { + TryStartFileWatcher(); + } } private void SetState(ProjectLifecycleState newState) { @@ -416,6 +462,7 @@ private static string FormatLifecycleAction(ProjectLifecycleState state) => ProjectLifecycleState.Testing => "Running tests", ProjectLifecycleState.TestOk => "Tests passed", ProjectLifecycleState.TestFailed => "Tests failed", + ProjectLifecycleState.WaitingForEdits => "Waiting for edits to settle", _ => state.ToString() }; @@ -434,7 +481,8 @@ private void RefreshHealth() displayWarnings, inProgress: isRestarting || state is ProjectLifecycleState.Building - || state is ProjectLifecycleState.Testing); + || state is ProjectLifecycleState.Testing + || state is ProjectLifecycleState.WaitingForEdits); } private void RecordBuildTrigger( BuildTriggerKind kind, @@ -485,6 +533,7 @@ public void Dispose() StopListenUrlPolling(); StopRunLogSaveTimer(); fileWatcher?.Dispose(); + agentActivityWatcher?.Dispose(); runProcess?.Dispose(); } diff --git a/src/TrayApp/App.xaml.cs b/src/TrayApp/App.xaml.cs index 3e3bba3..9554316 100644 --- a/src/TrayApp/App.xaml.cs +++ b/src/TrayApp/App.xaml.cs @@ -1,3 +1,4 @@ +using System.Drawing; using System.IO; using System.Windows; using System.Windows.Threading; @@ -21,17 +22,28 @@ public partial class App : System.Windows.Application private BuildMonitorHealthWindow? buildMonitorHealthWindow; private DispatcherHealthProbe? dispatcherHealthProbe; private HoverStatusPanel? hoverPanel; + private DispatcherTimer? trayHoverPollTimer; private SettingsStore? settingsStore; private AppWindowsLayoutStore? windowsLayoutStore; private AppSettings currentSettings = new(); private string appDataDirectory = string.Empty; private DispatcherTimer? hideStatusPanelTimer; + private DispatcherTimer? siteReadyDismissTimer; private DispatcherTimer? statusPanelLayoutSaveTimer; private bool pointerOverStatusPanel; private readonly Dictionary previousProjectHealth = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary openLogViewers = new(StringComparer.OrdinalIgnoreCase); - private readonly HashSet autoOpenedLogForFailure = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet autoOpenedLogForTransition = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary previousProjectLifecycleState = + new(StringComparer.OrdinalIgnoreCase); private readonly HashSet fileChangeBuildStarts = new(StringComparer.OrdinalIgnoreCase); + private bool statusPanelAutoShownForBuild; + private bool statusPanelAutoShownForEditGating; + private bool statusPanelPinnedAutoFlow; + private bool statusPanelDismissScheduled; + private DateTimeOffset trayHoverStatusPanelSuppressedUntil = DateTimeOffset.MinValue; + private readonly Dictionary previousEditGatingActive = + new(StringComparer.OrdinalIgnoreCase); private readonly BuildLifecycleToastNotifier buildLifecycleToastNotifier = new(); private readonly TrayContextMenuBuilder trayMenuBuilder = new(); private int settingsApplyVersion; @@ -134,7 +146,9 @@ private async Task ApplySettingsAndStartAsync() previousProjectHealth.Clear(); buildLifecycleToastNotifier.Reset(); - autoOpenedLogForFailure.Clear(); + autoOpenedLogForTransition.Clear(); + previousProjectLifecycleState.Clear(); + statusPanelAutoShownForBuild = false; fileChangeBuildStarts.Clear(); ToastNotificationService.ApplySettings(currentSettings.AppBehavior); await orchestrator.StopAllAsync(); @@ -257,7 +271,10 @@ private void ApplyPendingHealthUi() if (Volatile.Read(ref trayMenuOpen) == 0) { - AutoOpenLogsOnFailureTransition(snapshots); + AutoOpenLogsOnTransition(snapshots); + AutoShowStatusPanelWhileBuilding(snapshots); + AutoShowStatusPanelForEditGating(snapshots); + UpdateStatusPanelSiteReadyPin(snapshots); buildLifecycleToastNotifier.Process(snapshots, fileChangeBuildStarts); PlayBuildNotificationSounds(snapshots); } @@ -274,34 +291,293 @@ private void ApplyPendingHealthUi() } } - private void AutoOpenLogsOnFailureTransition(IReadOnlyList snapshots) + private void AutoOpenLogsOnTransition(IReadOnlyList snapshots) { - if (!currentSettings.Monitor.AutoOpenLogOnFailure) - { - return; - } - foreach (var snapshot in snapshots.Where(s => s.IsActive)) { - previousProjectHealth.TryGetValue(snapshot.ProjectId, out var previousHealth); + var project = currentSettings.Projects.FirstOrDefault(p => + p.Id.Equals(snapshot.ProjectId, StringComparison.OrdinalIgnoreCase)); + var mode = project?.RunOptions.AutoOpenLog ?? AutoOpenLogMode.Never; + if (mode == AutoOpenLogMode.Never) + { + continue; + } - if (snapshot.Health == MonitorHealth.Red && previousHealth != MonitorHealth.Red) + previousProjectHealth.TryGetValue(snapshot.ProjectId, out var previousHealth); + previousProjectLifecycleState.TryGetValue(snapshot.ProjectId, out var previousState); + + if (AutoOpenLogTransitionEvaluator.ShouldOpen( + mode, + previousHealth, + snapshot.Health, + previousState, + snapshot.State, + snapshot.ErrorCount)) { - if (autoOpenedLogForFailure.Add(snapshot.ProjectId)) + var useLatch = mode is AutoOpenLogMode.Errors or AutoOpenLogMode.Warnings; + if (!useLatch || autoOpenedLogForTransition.Add(snapshot.ProjectId)) { - var logKind = LogKindForFailure(snapshot.State); + var logKind = LogKindForAutoOpen(snapshot.State, previousState); + var (selectErrorsFilter, selectWarningsFilter) = + AutoOpenLogTransitionEvaluator.ResolveIssueFilters(mode, snapshot); OpenLogViewer( snapshot.ProjectId, snapshot.DisplayName, logKind, - selectErrorsFilter: snapshot.ErrorCount > 0); + selectErrorsFilter, + selectWarningsFilter); + } + } + else if (AutoOpenLogTransitionEvaluator.ShouldResetOpenLatch(mode, snapshot.Health)) + { + autoOpenedLogForTransition.Remove(snapshot.ProjectId); + } + } + } + + private void AutoShowStatusPanelWhileBuilding(IReadOnlyList snapshots) + { + var active = snapshots.Where(s => s.IsActive).ToList(); + foreach (var snapshot in active) + { + var enabled = currentSettings.Projects + .FirstOrDefault(p => p.Id.Equals(snapshot.ProjectId, StringComparison.OrdinalIgnoreCase)) + ?.RunOptions.ShowStatusPanelWhileBuilding == true; + if (!enabled) + { + continue; + } + + previousProjectLifecycleState.TryGetValue(snapshot.ProjectId, out var previousState); + if (!StatusPanelBuildVisibilityEvaluator.ShouldAutoShow(enabled, previousState, snapshot.State)) + { + continue; + } + + if (hoverPanel is not { IsVisible: true }) + { + ShowStatusPanel(); + statusPanelAutoShownForBuild = true; + MarkStatusPanelAutoPinned(); + } + } + + var visibilityProjects = active.Select(snapshot => + { + var enabled = currentSettings.Projects + .FirstOrDefault(p => p.Id.Equals(snapshot.ProjectId, StringComparison.OrdinalIgnoreCase)) + ?.RunOptions.ShowStatusPanelWhileBuilding == true; + return (ShowWhileBuildingEnabled: enabled == true, snapshot.State); + }); + + if (StatusPanelBuildVisibilityEvaluator.ShouldAutoHide(statusPanelAutoShownForBuild, visibilityProjects)) + { + if (!statusPanelAutoShownForEditGating + && !statusPanelPinnedAutoFlow + && !StatusPanelBuildVisibilityEvaluator.ShouldKeepPanelVisibleUntilSiteReady(active)) + { + HideAutoStatusPanel(); + statusPanelAutoShownForBuild = false; + } + } + } + + private void MarkStatusPanelAutoPinned() => statusPanelPinnedAutoFlow = true; + + private void UpdateStatusPanelSiteReadyPin(IReadOnlyList snapshots) + { + if (!statusPanelPinnedAutoFlow) + { + statusPanelDismissScheduled = false; + return; + } + + var active = snapshots.Where(s => s.IsActive).ToList(); + var awaitingSite = StatusPanelBuildVisibilityEvaluator.ShouldKeepPanelVisibleUntilSiteReady(active); + var hasListenUrl = active.Any(StatusPanelBuildVisibilityEvaluator.HasSiteLaunchConfigured); + var busy = active.Any(s => + StatusPanelBuildVisibilityEvaluator.IsBusyWorkState(s.State) + || s.IsEditGatingActive + || s.IsRestarting + || s.State is ProjectLifecycleState.Building); + + if (hasListenUrl) + { + if (awaitingSite || busy) + { + statusPanelDismissScheduled = false; + siteReadyDismissTimer?.Stop(); + return; + } + + ScheduleSiteReadyDismissOnce(TimeSpan.FromSeconds(4)); + return; + } + + if (!busy) + { + ScheduleSiteReadyDismissOnce(TimeSpan.FromSeconds(2)); + } + else + { + statusPanelDismissScheduled = false; + siteReadyDismissTimer?.Stop(); + } + } + + private void ScheduleSiteReadyDismissOnce(TimeSpan delay) + { + if (statusPanelDismissScheduled) + { + return; + } + + statusPanelDismissScheduled = true; + ScheduleSiteReadyDismiss(delay); + } + + private void ScheduleSiteReadyDismiss(TimeSpan delay) + { + siteReadyDismissTimer ??= new DispatcherTimer(); + siteReadyDismissTimer.Interval = delay; + siteReadyDismissTimer.Tick -= SiteReadyDismissTick; + siteReadyDismissTimer.Tick += SiteReadyDismissTick; + siteReadyDismissTimer.Stop(); + siteReadyDismissTimer.Start(); + } + + private void SiteReadyDismissTick(object? sender, EventArgs e) + { + siteReadyDismissTimer?.Stop(); + if (!statusPanelPinnedAutoFlow) + { + return; + } + + if (pointerOverStatusPanel) + { + statusPanelDismissScheduled = false; + ScheduleSiteReadyDismiss(TimeSpan.FromSeconds(2)); + return; + } + + HideAutoStatusPanel(suppressTrayHover: true); + statusPanelAutoShownForBuild = false; + statusPanelAutoShownForEditGating = false; + } + + private void AutoShowStatusPanelForEditGating(IReadOnlyList snapshots) + { + var suppressionEnabled = currentSettings.Monitor.DeferStartupBuildUntilQuiet + || currentSettings.Monitor.CancelSupersededBuilds; + if (!suppressionEnabled) + { + return; + } + + var active = snapshots.Where(s => s.IsActive).ToList(); + var anyGatingActive = false; + var anyBusyWork = false; + + foreach (var snapshot in active) + { + var showWhileBuilding = currentSettings.Projects + .FirstOrDefault(p => p.Id.Equals(snapshot.ProjectId, StringComparison.OrdinalIgnoreCase)) + ?.RunOptions.ShowStatusPanelWhileBuilding == true; + previousEditGatingActive.TryGetValue(snapshot.ProjectId, out var wasGating); + previousProjectLifecycleState.TryGetValue(snapshot.ProjectId, out var previousState); + var isBusy = StatusPanelBuildVisibilityEvaluator.IsBusyWorkState(snapshot.State); + + if (StatusPanelBuildVisibilityEvaluator.ShouldHideWhenBuildStartsWithoutShowSetting( + showWhileBuilding, + previousState, + snapshot.State, + statusPanelAutoShownForEditGating && !statusPanelAutoShownForBuild)) + { + HideAutoStatusPanel(); + statusPanelAutoShownForEditGating = false; + continue; + } + + if (StatusPanelBuildVisibilityEvaluator.ShouldAutoShowForEditGating( + suppressionEnabled, + snapshot.IsEditGatingActive, + wasGating) + || StatusPanelBuildVisibilityEvaluator.ShouldAutoShowForBusyWork( + suppressionEnabled, + showWhileBuilding, + previousState, + snapshot.State)) + { + if (hoverPanel is not { IsVisible: true }) + { + ShowStatusPanel(); } + + statusPanelAutoShownForEditGating = true; + MarkStatusPanelAutoPinned(); + } + + if (snapshot.IsEditGatingActive) + { + anyGatingActive = true; } - else if (snapshot.Health != MonitorHealth.Red) + + if (isBusy) { - autoOpenedLogForFailure.Remove(snapshot.ProjectId); + anyBusyWork = true; } + + previousEditGatingActive[snapshot.ProjectId] = snapshot.IsEditGatingActive; + } + + if (statusPanelAutoShownForEditGating + && !anyGatingActive + && !anyBusyWork + && !statusPanelAutoShownForBuild + && !statusPanelPinnedAutoFlow + && !StatusPanelBuildVisibilityEvaluator.ShouldKeepPanelVisibleUntilSiteReady(active)) + { + HideAutoStatusPanel(); + statusPanelAutoShownForEditGating = false; + } + } + + private void HideAutoStatusPanel(bool suppressTrayHover = false) + { + CancelStatusPanelTimers(); + siteReadyDismissTimer?.Stop(); + statusPanelPinnedAutoFlow = false; + statusPanelDismissScheduled = false; + if (suppressTrayHover) + { + trayHoverStatusPanelSuppressedUntil = DateTimeOffset.UtcNow.AddSeconds(5); + trayHoverPollTimer?.Stop(); + } + pointerOverStatusPanel = false; + hoverPanel?.Hide(); + FlushStatusPanelLayout(); + } + + private static BuildLogKind? LogKindForAutoOpen( + ProjectLifecycleState currentState, + ProjectLifecycleState previousState) + { + if (previousState == ProjectLifecycleState.Testing + || currentState is ProjectLifecycleState.Testing + or ProjectLifecycleState.TestOk + or ProjectLifecycleState.TestFailed) + { + return BuildLogKind.Test; + } + + if (currentState is ProjectLifecycleState.Crashed + && previousState is ProjectLifecycleState.Running or ProjectLifecycleState.Watching) + { + return BuildLogKind.Run; } + + return BuildLogKind.Build; } protected override void OnExit(ExitEventArgs e) @@ -330,7 +606,7 @@ private Forms.NotifyIcon BuildNotifyIcon() { var icon = new Forms.NotifyIcon { - Text = "Local Build Monitor", + Text = string.Empty, Icon = TrafficLightIconFactory.GetIcon(MonitorHealth.Unknown) }; @@ -379,38 +655,142 @@ private Forms.NotifyIcon BuildNotifyIcon() } }; + icon.MouseMove += (_, _) => OnNotifyIconMouseMove(); + return icon; } + private void OnNotifyIconMouseMove() + { + if (!CanShowStatusPanelOnTrayHover()) + { + ScheduleHideStatusPanel(); + return; + } + + ShowStatusPanel(); + EnsureTrayHoverPollTimer(); + } + + private bool CanShowStatusPanelOnTrayHover() => + Volatile.Read(ref trayMenuOpen) == 0 + && DateTimeOffset.UtcNow >= trayHoverStatusPanelSuppressedUntil; + + private void EnsureTrayHoverPollTimer() + { + trayHoverPollTimer ??= new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(120) }; + trayHoverPollTimer.Tick -= TrayHoverPollTick; + trayHoverPollTimer.Tick += TrayHoverPollTick; + if (!trayHoverPollTimer.IsEnabled) + { + trayHoverPollTimer.Start(); + } + } + + private void TrayHoverPollTick(object? sender, EventArgs e) + { + if (!CanShowStatusPanelOnTrayHover()) + { + trayHoverPollTimer?.Stop(); + hoverPanel?.Hide(); + return; + } + + if (notifyIcon is null) + { + trayHoverPollTimer?.Stop(); + hoverPanel?.Hide(); + return; + } + + if (TrayIconShellInterop.TryGetIconScreenBounds(notifyIcon, out _) + && !TrayIconShellInterop.IsCursorOverIcon(notifyIcon) + && !pointerOverStatusPanel + && !statusPanelPinnedAutoFlow) + { + ScheduleHideStatusPanel(); + trayHoverPollTimer?.Stop(); + return; + } + + if (hoverPanel is { IsVisible: true }) + { + hoverPanel.Update(orchestrator?.GetHealthSnapshots() ?? []); + } + } + + private void RefreshStatusPanelIfVisible() + { + if (hoverPanel is not { IsVisible: true }) + { + return; + } + + if (notifyIcon is not null + && TrayIconShellInterop.TryGetIconScreenBounds(notifyIcon, out _) + && !TrayIconShellInterop.IsCursorOverIcon(notifyIcon) + && !pointerOverStatusPanel + && !statusPanelPinnedAutoFlow) + { + ScheduleHideStatusPanel(); + return; + } + + hoverPanel.Update(orchestrator?.GetHealthSnapshots() ?? []); + } + private void ToggleStatusPanel() { CancelStatusPanelTimers(); - TrayScreenPlacement.CaptureFromCursor(); EnsureHoverPanel(); if (hoverPanel is { IsVisible: true }) { hoverPanel.Hide(); + statusPanelPinnedAutoFlow = false; + statusPanelAutoShownForBuild = false; + statusPanelAutoShownForEditGating = false; + siteReadyDismissTimer?.Stop(); return; } hoverPanel!.Update(orchestrator?.GetHealthSnapshots() ?? []); - hoverPanel.ShowNearTray(); + ShowStatusPanelNearTray(); + } + + private void ShowStatusPanelNearTray() + { + if (hoverPanel is null) + { + return; + } + + Rectangle? trayIconBounds = null; + if (notifyIcon is not null && TrayIconShellInterop.TryGetIconScreenBounds(notifyIcon, out var bounds)) + { + trayIconBounds = bounds; + } + + hoverPanel.ShowNearTray(trayIconBounds); } private void ShowStatusPanel() { CancelStatusPanelTimers(); - TrayScreenPlacement.CaptureFromCursor(); EnsureHoverPanel(); hoverPanel!.Update(orchestrator?.GetHealthSnapshots() ?? []); - hoverPanel.ShowNearTray(); + ShowStatusPanelNearTray(); } private void CancelStatusPanelTimers() => hideStatusPanelTimer?.Stop(); private void ScheduleHideStatusPanel() { + if (statusPanelPinnedAutoFlow) + { + return; + } + pointerOverStatusPanel = false; hideStatusPanelTimer ??= new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(400) }; hideStatusPanelTimer.Tick -= HideStatusPanelTick; @@ -422,11 +802,13 @@ private void ScheduleHideStatusPanel() private void HideStatusPanelTick(object? sender, EventArgs e) { hideStatusPanelTimer?.Stop(); - if (!pointerOverStatusPanel) + if (statusPanelPinnedAutoFlow || pointerOverStatusPanel) { - hoverPanel?.Hide(); - FlushStatusPanelLayout(); + return; } + + hoverPanel?.Hide(); + FlushStatusPanelLayout(); } private void EnsureHoverPanel() @@ -595,7 +977,8 @@ private void OpenLogViewer( string projectId, string? displayName = null, BuildLogKind? logKind = null, - bool selectErrorsFilter = false) + bool selectErrorsFilter = false, + bool selectWarningsFilter = false) { if (orchestrator is null) { @@ -634,6 +1017,10 @@ private void OpenLogViewer( { existing.SelectErrorsFilter(); } + else if (selectWarningsFilter) + { + existing.SelectWarningsFilter(); + } existing.Activate(); existing.Focus(); @@ -666,6 +1053,10 @@ private void OpenLogViewer( { viewer.SelectErrorsFilter(); } + else if (selectWarningsFilter) + { + viewer.SelectWarningsFilter(); + } } private void ShowBuildDiagnostics() @@ -1042,12 +1433,14 @@ private void PlayBuildNotificationSounds(IReadOnlyList sn } previousProjectHealth[snapshot.ProjectId] = snapshot.Health; + previousProjectLifecycleState[snapshot.ProjectId] = snapshot.State; } var activeIds = snapshots.Where(s => s.IsActive).Select(s => s.ProjectId).ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var staleId in previousProjectHealth.Keys.Where(id => !activeIds.Contains(id)).ToList()) { previousProjectHealth.Remove(staleId); + previousProjectLifecycleState.Remove(staleId); } } @@ -1161,7 +1554,12 @@ private void ApplyTrayIconFrame() currentTrayBuilding, buildIconAnimationFrame, currentTrayWebReady); - notifyIcon.Text = TrayTooltipFormatter.Format(currentTrayHeadline, currentTrayHealth, currentTrayBuilding); + notifyIcon.Text = string.Empty; + + if (hoverPanel is { IsVisible: true }) + { + RefreshStatusPanelIfVisible(); + } } private static void MigrateLegacyAppDataIfNeeded(string newAppDataDirectory) diff --git a/src/TrayApp/BuildDiagnosticsWindow.xaml b/src/TrayApp/BuildDiagnosticsWindow.xaml index ea49a34..b527550 100644 --- a/src/TrayApp/BuildDiagnosticsWindow.xaml +++ b/src/TrayApp/BuildDiagnosticsWindow.xaml @@ -30,7 +30,7 @@ TextWrapping="Wrap" Opacity="0.8" FontSize="11" - Text="One tab per project — rebuild timing at the top, today's triggers below. Mark unexpected triggers to spot spurious rebuilds." /> + Text="One tab per active project — rebuild timing at the top, today's triggers below. Mark unexpected triggers to spot spurious rebuilds." /> @@ -58,7 +58,9 @@ CanUserDeleteRows="False" SelectionMode="Single" HeadersVisibility="Column" - GridLinesVisibility="Horizontal"> + GridLinesVisibility="Horizontal" + Loaded="TriggersGridLoaded" + LayoutUpdated="TriggersGridLayoutUpdated"> @@ -75,6 +77,7 @@ BorderThickness="0" Background="Transparent" Tag="{Binding}" + GotFocus="UserNoteGotFocus" LostFocus="UserNoteLostFocus" /> @@ -110,7 +113,7 @@ ApplyTheme(theme); private void ApplyTheme(ResolvedTheme theme) @@ -98,16 +150,9 @@ private void RefreshAll() var snapshots = orchestrator.GetBuildIntelligenceSnapshots(); var unexpectedOnly = UnexpectedOnlyCheck.IsChecked == true; var entries = journal.GetEntries(); - var projectIds = snapshots.Select(s => s.ProjectId) - .Concat(entries.Select(e => e.ProjectId)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - var order = snapshots .OrderBy(s => s.ProjectDisplayName, StringComparer.OrdinalIgnoreCase) .Select(s => s.ProjectId) - .Concat(projectIds.Where(id => snapshots.All(s => !s.ProjectId.Equals(id, StringComparison.OrdinalIgnoreCase)))) - .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); suppressSelectionTracking = true; @@ -154,7 +199,14 @@ private void RefreshAll() new GlobalMonitorSettings(), new FileChangeBurstStats()); - tab.RefreshTriggers(entries, unexpectedOnly, journal); + if (!IsNoteEditorFocused()) + { + tab.RefreshTriggers(entries, unexpectedOnly, journal); + } + else + { + refreshTriggersAfterNoteEdit = true; + } } var hasProjects = projectTabs.Count > 0; @@ -208,12 +260,41 @@ private void ClearVerdictClicked(object sender, RoutedEventArgs e) } } + private void UserNoteGotFocus(object sender, RoutedEventArgs e) + { + if (sender is WpfTextBox { Tag: BuildTriggerRowViewModel }) + { + noteEditorFocused = true; + } + } + private void UserNoteLostFocus(object sender, RoutedEventArgs e) { - if (sender is WpfTextBox { Tag: BuildTriggerRowViewModel row }) + if (sender is not WpfTextBox { Tag: BuildTriggerRowViewModel row }) + { + return; + } + + row.SaveUserNote(); + noteEditorFocused = false; + + if (!refreshTriggersAfterNoteEdit) + { + return; + } + + refreshTriggersAfterNoteEdit = false; + RefreshAll(); + } + + private bool IsNoteEditorFocused() + { + if (noteEditorFocused) { - row.SaveUserNote(); + return true; } + + return Keyboard.FocusedElement is WpfTextBox { Tag: BuildTriggerRowViewModel }; } private void FilterChanged(object sender, RoutedEventArgs e) => RefreshAll(); @@ -274,16 +355,40 @@ public void RefreshTriggers( bool unexpectedOnly, BuildTriggerJournal triggerJournal) { - Triggers.Clear(); - foreach (var entry in entries.Where(e => - e.ProjectId.Equals(ProjectId, StringComparison.OrdinalIgnoreCase))) + var filtered = entries + .Where(e => e.ProjectId.Equals(ProjectId, StringComparison.OrdinalIgnoreCase)) + .Where(e => !unexpectedOnly || e.Verdict == BuildTriggerVerdict.Unexpected) + .ToList(); + + var desiredIds = new HashSet( + filtered.Select(e => e.Id), + StringComparer.OrdinalIgnoreCase); + + for (var i = Triggers.Count - 1; i >= 0; i--) { - if (unexpectedOnly && entry.Verdict != BuildTriggerVerdict.Unexpected) + if (!desiredIds.Contains(Triggers[i].Record.Id)) { + Triggers.RemoveAt(i); + } + } + + for (var i = 0; i < filtered.Count; i++) + { + var entry = filtered[i]; + var existing = Triggers.FirstOrDefault(t => + t.Record.Id.Equals(entry.Id, StringComparison.OrdinalIgnoreCase)); + if (existing is null) + { + Triggers.Insert(i, new BuildTriggerRowViewModel(entry, triggerJournal)); continue; } - Triggers.Add(new BuildTriggerRowViewModel(entry, triggerJournal)); + existing.SyncFrom(entry); + var currentIndex = Triggers.IndexOf(existing); + if (currentIndex != i) + { + Triggers.Move(currentIndex, i); + } } } @@ -294,16 +399,17 @@ private void OnPropertyChanged([CallerMemberName] string? propertyName = null) = private sealed class BuildTriggerRowViewModel : INotifyPropertyChanged { private readonly BuildTriggerJournal journal; + private BuildTriggerRecord record; private string userNote; public BuildTriggerRowViewModel(BuildTriggerRecord record, BuildTriggerJournal journal) { - Record = record; + this.record = record; this.journal = journal; userNote = record.UserNote ?? string.Empty; } - public BuildTriggerRecord Record { get; } + public BuildTriggerRecord Record => record; public event PropertyChangedEventHandler? PropertyChanged; @@ -349,7 +455,56 @@ public string UserNote } } - public void SaveUserNote() => journal.SetUserNote(Record.Id, userNote); + public void SaveUserNote() => journal.SetUserNote(record.Id, userNote); + + public void SyncFrom(BuildTriggerRecord latest) + { + if (record.Id.Equals(latest.Id, StringComparison.OrdinalIgnoreCase) + && record.Verdict == latest.Verdict + && string.Equals(record.UserNote, latest.UserNote, StringComparison.Ordinal) + && string.Equals(record.Summary, latest.Summary, StringComparison.Ordinal) + && string.Equals(record.Detail, latest.Detail, StringComparison.Ordinal) + && string.Equals(record.InferredCause, latest.InferredCause, StringComparison.Ordinal) + && record.OccurredAtUtc == latest.OccurredAtUtc + && PathsEqual(record.ChangedPaths, latest.ChangedPaths)) + { + return; + } + + record = latest; + userNote = latest.UserNote ?? string.Empty; + OnPropertyChanged(nameof(WhenLocal)); + OnPropertyChanged(nameof(KindLabel)); + OnPropertyChanged(nameof(Summary)); + OnPropertyChanged(nameof(InferredCause)); + OnPropertyChanged(nameof(Detail)); + OnPropertyChanged(nameof(ChangedPathsText)); + OnPropertyChanged(nameof(VerdictLabel)); + OnPropertyChanged(nameof(UserNote)); + } + + private static bool PathsEqual(IReadOnlyList? left, IReadOnlyList? right) + { + if (left is null || left.Count == 0) + { + return right is null || right.Count == 0; + } + + if (right is null || left.Count != right.Count) + { + return false; + } + + for (var i = 0; i < left.Count; i++) + { + if (!string.Equals(left[i], right[i], StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return true; + } private void OnPropertyChanged([CallerMemberName] string? propertyName = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); diff --git a/src/TrayApp/BuildIntelligencePanel.xaml b/src/TrayApp/BuildIntelligencePanel.xaml index ce3ecf5..1ffa958 100644 --- a/src/TrayApp/BuildIntelligencePanel.xaml +++ b/src/TrayApp/BuildIntelligencePanel.xaml @@ -5,7 +5,7 @@ Focusable="False" IsTabStop="False" KeyboardNavigation.TabNavigation="None" - MinHeight="88"> + MinHeight="140"> @@ -23,6 +23,14 @@ + - + - - + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + - - + Text="Save burst length" + ToolTip="Each bar is how long a multi-file save burst lasted." /> + + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "Build in progress…" }; FooterText.Text = FormatFooterText( - allIssues.Count(i => i.IsError), - allIssues.Count(i => !i.IsError), + resolvedDisplayErrorCount, + resolvedDisplayWarningCount, isLive: true); if (followTail) @@ -300,10 +328,9 @@ private async Task LoadSelectedLogCoreAsync(BuildLogKind kind) await logStore.LoadLogTextAsync(currentRecord, maxDisplayBytes)); allIssues = ParseIssuesForCurrentLog(); RenderLogText(); + RefreshResolvedIssueCounts(); ApplyIssueFilter(selectFirstIssue: allIssues.Count > 0); - var errorCount = allIssues.Count(i => i.IsError); - var warningCount = allIssues.Count(i => !i.IsError); var finishedLocal = BuildTimestampFormatter.FormatLocal(currentRecord.FinishedAtUtc); var kindLabel = kind switch { @@ -313,7 +340,7 @@ private async Task LoadSelectedLogCoreAsync(BuildLogKind kind) }; BuildTimeText.Text = $"{kindLabel}: {finishedLocal}"; FooterText.Text = - $"{currentRecord.CommandLine} | exit {currentRecord.ExitCode} | {FormatFooterText(errorCount, warningCount, isLive: false)} | duration {currentRecord.FinishedAtUtc - currentRecord.StartedAtUtc:g}"; + $"{currentRecord.CommandLine} | exit {currentRecord.ExitCode} | {FormatFooterText(resolvedDisplayErrorCount, resolvedDisplayWarningCount, isLive: false)} | duration {currentRecord.FinishedAtUtc - currentRecord.StartedAtUtc:g}"; if (ShouldFollowOutput()) { @@ -373,21 +400,25 @@ private void ApplyIssueFilter(bool selectFirstIssue = false) ErrorsList.ItemsSource = visibleIssues; - var errorCount = allIssues.Count(i => i.IsError); - var warningCount = allIssues.Count(i => !i.IsError); + var errorCount = resolvedDisplayErrorCount; + var warningCount = resolvedDisplayWarningCount; UpdateErrorBanner(errorCount); + var carryNote = issuesCarriedFromPreviousBuild + ? " — issues from last full compile (this build reported 0/0)" + : string.Empty; + IssueSummaryText.Text = filter switch { IssueFilter.Errors => currentLogKind == BuildLogKind.Test - ? FormatIssueCountLabel("failure", "failures", visibleIssues.Count, errorCount) - : FormatIssueCountLabel("error", "errors", visibleIssues.Count, errorCount), + ? FormatIssueCountLabel("failure", "failures", visibleIssues.Count, errorCount) + carryNote + : FormatIssueCountLabel("error", "errors", visibleIssues.Count, errorCount) + carryNote, IssueFilter.Warnings => currentLogKind == BuildLogKind.Test - ? FormatIssueCountLabel("skipped test", "skipped tests", visibleIssues.Count, warningCount) - : FormatIssueCountLabel("warning", "warnings", visibleIssues.Count, warningCount), + ? FormatIssueCountLabel("skipped test", "skipped tests", visibleIssues.Count, warningCount) + carryNote + : FormatIssueCountLabel("warning", "warnings", visibleIssues.Count, warningCount) + carryNote, _ => currentLogKind == BuildLogKind.Test - ? $"{errorCount} failed, {warningCount} skipped" - : $"{errorCount} errors, {warningCount} warnings", + ? $"{errorCount} failed, {warningCount} skipped{carryNote}" + : $"{errorCount} errors, {warningCount} warnings{carryNote}", }; if (filter == IssueFilter.Errors && errorCount == 0) @@ -407,13 +438,49 @@ private void ApplyIssueFilter(bool selectFirstIssue = false) } } - private IReadOnlyList ParseIssuesForCurrentLog() => - currentLogKind switch + private IReadOnlyList ParseIssuesForCurrentLog() + { + var logPath = currentRecord?.LogFilePath ?? logStore.GetLogPath(projectId, currentLogKind); + return currentLogKind switch { BuildLogKind.Test => DotNetTestOutputParser.ParseIssues(currentLogText), BuildLogKind.Run => DotNetRunOutputParser.ParseIssues(currentLogText), - _ => BuildLogParser.ParseIssues(currentLogText) + _ => BuildLogParser.ResolveBuildIssues(currentLogText, logPath) }; + } + + private void RefreshResolvedIssueCounts(LiveBuildLogView? live = null) + { + var parsedErrors = allIssues.Count(i => i.IsError); + var parsedWarnings = allIssues.Count(i => !i.IsError); + + if (currentLogKind is BuildLogKind.Test or BuildLogKind.Run) + { + issuesCarriedFromPreviousBuild = false; + resolvedDisplayErrorCount = parsedErrors; + resolvedDisplayWarningCount = parsedWarnings; + return; + } + + var logPath = currentRecord?.LogFilePath ?? logStore.GetLogPath(projectId, currentLogKind); + var resolved = BuildIssueCountResolver.Resolve(currentLogText, logPath); + var metaErrors = live?.ErrorCount ?? currentRecord?.ErrorCount ?? 0; + var metaWarnings = live?.WarningCount ?? currentRecord?.WarningCount ?? 0; + var note = BuildLogParser.TryParseIncrementalHealthNote(currentLogText); + + resolvedDisplayErrorCount = Math.Max( + parsedErrors, + Math.Max(metaErrors, Math.Max(note.Errors, resolved.Errors))); + resolvedDisplayWarningCount = Math.Max( + parsedWarnings, + Math.Max(metaWarnings, Math.Max(note.Warnings, resolved.Warnings))); + + issuesCarriedFromPreviousBuild = + IncrementalBuildDetector.WasCompileSkipped(currentLogText) + && resolvedDisplayWarningCount + resolvedDisplayErrorCount > 0 + && BuildLogParser.ParseWarningCount(currentLogText) == 0 + && BuildLogParser.ParseErrorCount(currentLogText) == 0; + } private string FormatFooterText(int errorCount, int warningCount, bool isLive) { diff --git a/src/TrayApp/HoverStatusPanel.xaml b/src/TrayApp/HoverStatusPanel.xaml index 4536ce2..0906e12 100644 --- a/src/TrayApp/HoverStatusPanel.xaml +++ b/src/TrayApp/HoverStatusPanel.xaml @@ -2,9 +2,9 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Build Status" - Width="480" - MinHeight="120" - MaxHeight="520" + Width="400" + MinHeight="96" + MaxHeight="460" WindowStyle="None" SizeToContent="Manual" AllowsTransparency="True" @@ -12,9 +12,9 @@ ResizeMode="NoResize" ShowInTaskbar="False" Topmost="True"> - + - + diff --git a/src/TrayApp/HoverStatusPanel.xaml.cs b/src/TrayApp/HoverStatusPanel.xaml.cs index fd69e78..18a38b9 100644 --- a/src/TrayApp/HoverStatusPanel.xaml.cs +++ b/src/TrayApp/HoverStatusPanel.xaml.cs @@ -1,10 +1,10 @@ -using System.Diagnostics; +using System.Drawing; using System.Windows; using System.Windows.Controls; -using System.Windows.Documents; using System.Windows.Media; +using System.Windows.Threading; using BuildMonitor.Core.Models; -using BuildMonitor.Infrastructure.LocalBuild; +using BuildMonitor.Core.Rules; using BuildMonitor.TrayApp.Services; using WpfBrush = System.Windows.Media.Brush; using WpfColor = System.Windows.Media.Color; @@ -18,6 +18,9 @@ namespace BuildMonitor.TrayApp; public partial class HoverStatusPanel : Window { private ResolvedTheme currentTheme = ResolvedTheme.Light; + private readonly DispatcherTimer countdownTimer; + private IReadOnlyList lastSnapshots = []; + private Rectangle? lastTrayIconBounds; public event Action? ViewLogRequested; public event Action? CopyErrorsRequested; @@ -28,6 +31,9 @@ public partial class HoverStatusPanel : Window public HoverStatusPanel() { InitializeComponent(); + countdownTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; + countdownTimer.Tick += (_, _) => OnCountdownTick(); + Closed += (_, _) => countdownTimer.Stop(); } public void ApplyTheme(ResolvedTheme theme) @@ -47,6 +53,7 @@ private static SolidColorBrush BrushFromResource(string key, WpfColor fallback) public void Update(IReadOnlyList snapshots) { + lastSnapshots = snapshots; var palette = ThemeService.GetPalette(currentTheme); ProjectCards.Items.Clear(); @@ -58,8 +65,8 @@ public void Update(IReadOnlyList snapshots) { BorderBrush = new SolidColorBrush(palette.Border), BorderThickness = new Thickness(1), - Margin = new Thickness(0, 0, 0, 8), - Padding = new Thickness(8), + Margin = new Thickness(0, 0, 0, 5), + Padding = new Thickness(6, 5, 6, 5), Background = new SolidColorBrush(palette.CardBackground) }; @@ -68,44 +75,78 @@ public void Update(IReadOnlyList snapshots) { Text = snapshot.DisplayName, FontWeight = FontWeights.SemiBold, + FontSize = 12, Foreground = new SolidColorBrush(palette.Foreground) }); + + var statusLine = snapshot.IsRestarting + ? "Restarting app…" + : snapshot.State == ProjectLifecycleState.WaitingForEdits + ? $"Waiting — {snapshot.HealthLabel}" + : $"{snapshot.HealthLabel} — {snapshot.State}"; + var issueSuffix = snapshot.IssueCountsText + ?? (snapshot.ErrorCount > 0 || snapshot.WarningCount > 0 + ? $" · {snapshot.ErrorCount}e / {snapshot.WarningCount}w" + : string.Empty); panel.Children.Add(new TextBlock { - Text = $"{snapshot.HealthLabel} — {snapshot.State}", + Text = statusLine + issueSuffix, Foreground = healthBrush, - Margin = new Thickness(0, 4, 0, 0) - }); - panel.Children.Add(new TextBlock - { - Text = snapshot.IsRestarting - ? "Restarting app…" - : snapshot.IssueCountsText - ?? $"Errors: {snapshot.ErrorCount} | Warnings: {snapshot.WarningCount}", - Foreground = new SolidColorBrush(palette.Foreground), - Opacity = 0.85, + FontSize = 11, Margin = new Thickness(0, 2, 0, 0) }); panel.Children.Add(new TextBlock { Text = FormatLastBuildLine(snapshot), Foreground = new SolidColorBrush(palette.Foreground), - Opacity = 0.85, - Margin = new Thickness(0, 2, 0, 0) + Opacity = 0.8, + FontSize = 11, + Margin = new Thickness(0, 1, 0, 0) }); - if (snapshot.SupportsAppRestart - && !string.IsNullOrWhiteSpace(snapshot.ListenUrl) - && ShouldShowListenUrl(snapshot)) + if (!string.IsNullOrWhiteSpace(snapshot.EditGatingDetailText) + || snapshot.RebuildQuietUntilUtc is not null) + { + var countdown = EditGatingDetailFormatter.FormatCountdownRemaining( + snapshot.RebuildQuietUntilUtc, + DateTimeOffset.UtcNow); + if (!string.IsNullOrWhiteSpace(snapshot.EditGatingDetailText)) + { + panel.Children.Add(new TextBlock + { + Text = snapshot.EditGatingDetailText, + Foreground = new SolidColorBrush(palette.Foreground), + Opacity = 0.9, + FontSize = 11, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 3, 0, 0) + }); + } + + if (!string.IsNullOrWhiteSpace(countdown)) + { + panel.Children.Add(new TextBlock + { + Text = countdown, + Foreground = new SolidColorBrush(WpfColor.FromRgb(255, 193, 7)), + FontWeight = FontWeights.SemiBold, + FontSize = 11, + Margin = new Thickness(0, 2, 0, 0) + }); + } + } + + if (StatusPanelBuildVisibilityEvaluator.ShouldShowSiteStatus(snapshot)) { - var showLink = snapshot.ListenUrlReady - && snapshot.State is ProjectLifecycleState.Running or ProjectLifecycleState.Watching; - panel.Children.Add(showLink - ? BuildListenUrlBlock(snapshot.ListenUrl, palette) - : BuildListenUrlPendingBlock(snapshot.ListenUrl, palette)); + panel.Children.Add(snapshot.ListenUrlReady + ? StatusPanelVisuals.BuildSiteReadyBlock(snapshot.ListenUrl!, palette) + : StatusPanelVisuals.BuildSiteAwaitingBlock(snapshot.ListenUrl!, palette)); } - if (snapshot.ProgressSteps.Count > 0) + if (snapshot.ProgressSteps.Count > 0 + && snapshot.State is ProjectLifecycleState.Building + or ProjectLifecycleState.Testing + or ProjectLifecycleState.BuildFailed) { panel.Children.Add(StatusPanelVisuals.BuildStepProgressChart(snapshot.ProgressSteps, palette)); } @@ -115,8 +156,9 @@ public void Update(IReadOnlyList snapshots) { Text = snapshot.LastErrorPreview, TextWrapping = TextWrapping.Wrap, + FontSize = 11, Foreground = new SolidColorBrush(WpfColor.FromRgb(220, 53, 69)), - Margin = new Thickness(0, 4, 0, 0) + Margin = new Thickness(0, 3, 0, 0) }); } else if (snapshot.State is ProjectLifecycleState.Building or ProjectLifecycleState.Testing) @@ -125,13 +167,22 @@ public void Update(IReadOnlyList snapshots) } else if (snapshot.ErrorCount > 0 || snapshot.WarningCount > 0) { - panel.Children.Add(StatusPanelVisuals.BuildIssueMeter(snapshot.ErrorCount, snapshot.WarningCount, palette)); + panel.Children.Add(StatusPanelVisuals.BuildIssueSummary(snapshot.ErrorCount, snapshot.WarningCount, palette)); + } + + if (snapshot.ProgressSteps.Count > 0 + && snapshot.State is ProjectLifecycleState.Building + or ProjectLifecycleState.Testing + or ProjectLifecycleState.BuildFailed + && (snapshot.ErrorCount > 0 || snapshot.WarningCount > 0)) + { + panel.Children.Add(StatusPanelVisuals.BuildIssueSummary(snapshot.ErrorCount, snapshot.WarningCount, palette)); } var actions = new StackPanel { Orientation = WpfOrientation.Horizontal, - Margin = new Thickness(0, 6, 0, 0) + Margin = new Thickness(0, 4, 0, 0) }; var viewLog = new WpfButton @@ -210,18 +261,51 @@ public void Update(IReadOnlyList snapshots) var activeCount = snapshots.Count(s => s.IsActive); HeaderText.Text = activeCount switch { - 0 => "Local build status", - 1 => "Local build status", - _ => $"Local build status ({activeCount} projects)" + 0 => "Build status", + 1 => "Build status", + _ => $"Build status ({activeCount})" }; FitHeightToContent(); + SyncCountdownTimer(snapshots); + ApplyTrayPlacement(); + } + + private void OnCountdownTick() + { + if (!IsVisible || !HasActiveCountdown(lastSnapshots)) + { + countdownTimer.Stop(); + return; + } + + Update(lastSnapshots); + } + + private void SyncCountdownTimer(IReadOnlyList snapshots) + { + if (IsVisible && HasActiveCountdown(snapshots)) + { + if (!countdownTimer.IsEnabled) + { + countdownTimer.Start(); + } + } + else + { + countdownTimer.Stop(); + } } + private static bool HasActiveCountdown(IReadOnlyList snapshots) => + snapshots.Any(s => + s.RebuildQuietUntilUtc is { } until + && until > DateTimeOffset.UtcNow); + private void FitHeightToContent() { - const double chrome = 52; - var innerWidth = Math.Max(200, Width - 22); + const double chrome = 40; + var innerWidth = Math.Max(180, Width - 18); ProjectCards.Measure(new WpfSize(innerWidth, double.PositiveInfinity)); var contentHeight = ProjectCards.DesiredSize.Height; var maxBody = MaxHeight - chrome; @@ -240,59 +324,6 @@ private void FitHeightToContent() } } - private static bool ShouldShowListenUrl(ProjectHealthSnapshot snapshot) => - snapshot.IsRestarting - || snapshot.State is ProjectLifecycleState.Running - or ProjectLifecycleState.Watching - or ProjectLifecycleState.Building - or ProjectLifecycleState.BuildOk; - - private static UIElement BuildListenUrlPendingBlock(string listenUrl, ThemePalette palette) => - new TextBlock - { - Text = $"Starting {listenUrl}…", - Foreground = new SolidColorBrush(palette.Foreground) { Opacity = 0.7 }, - Margin = new Thickness(0, 2, 0, 0) - }; - - private static UIElement BuildListenUrlBlock(string listenUrl, ThemePalette palette) - { - var openUrl = LocalPortProbe.NormalizeBrowserUrl(listenUrl); - var labelBrush = new SolidColorBrush(palette.Foreground) { Opacity = 0.9 }; - var block = new TextBlock { Margin = new Thickness(0, 2, 0, 0) }; - block.Inlines.Add(new Run("URL: ") { Foreground = labelBrush }); - - if (!Uri.TryCreate(openUrl, UriKind.Absolute, out var uri)) - { - block.Inlines.Add(new Run(openUrl) { Foreground = labelBrush }); - return block; - } - - var link = new Hyperlink - { - NavigateUri = uri, - Foreground = new SolidColorBrush(palette.Accent), - TextDecorations = TextDecorations.Underline, - Cursor = System.Windows.Input.Cursors.Hand - }; - link.Inlines.Add(openUrl); - link.RequestNavigate += (_, e) => - { - try - { - Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true }); - } - catch - { - // Best effort only. - } - - e.Handled = true; - }; - block.Inlines.Add(link); - return block; - } - private static string FormatLastBuildLine(ProjectHealthSnapshot snapshot) { var isBuilding = snapshot.State is ProjectLifecycleState.Building or ProjectLifecycleState.Testing; @@ -325,14 +356,32 @@ public void ApplyLayout(WindowLayoutState layout) public void CaptureLayout(WindowLayoutState layout) => WindowLayoutService.Capture(this, layout, sizeOnly: true); - public void ShowNearTray() + public void ShowNearTray(Rectangle? trayIconBounds = null) { - TrayScreenPlacement.PlaceNearTrayBottomRight(this); + if (trayIconBounds is { Width: > 0, Height: > 0 } bounds) + { + lastTrayIconBounds = bounds; + } + if (!IsVisible) { Show(); } FitHeightToContent(); + SyncCountdownTimer(lastSnapshots); + ApplyTrayPlacement(); + } + + private void ApplyTrayPlacement() + { + if (lastTrayIconBounds is { Width: > 0, Height: > 0 } bounds) + { + TrayScreenPlacement.PlaceAboveTrayIcon(this, bounds); + } + else + { + TrayScreenPlacement.PlaceNearTrayBottomRight(this); + } } } diff --git a/src/TrayApp/Services/AppWindowsLayout.cs b/src/TrayApp/Services/AppWindowsLayout.cs index 465e531..c445d13 100644 --- a/src/TrayApp/Services/AppWindowsLayout.cs +++ b/src/TrayApp/Services/AppWindowsLayout.cs @@ -11,6 +11,11 @@ public class WindowLayoutState public int WindowState { get; set; } } +public sealed class DiagnosticsWindowLayoutState : WindowLayoutState +{ + public Dictionary TriggerGridColumnWidths { get; set; } = new(StringComparer.Ordinal); +} + public sealed class BuildLogViewerLayoutState : WindowLayoutState { public double LogPanelRatio { get; set; } = 0.65; @@ -21,7 +26,7 @@ public sealed class AppWindowsLayout { public BuildLogViewerLayoutState BuildLog { get; set; } = new(); public WindowLayoutState Settings { get; set; } = new(); - public WindowLayoutState Diagnostics { get; set; } = new(); + public DiagnosticsWindowLayoutState Diagnostics { get; set; } = new(); [JsonPropertyName("threadHealth")] public WindowLayoutState BuildMonitorHealth { get; set; } = new(); public WindowLayoutState StatusPanel { get; set; } = new() { Width = 480, Height = 420 }; diff --git a/src/TrayApp/Services/DiagnosticsGridLayoutService.cs b/src/TrayApp/Services/DiagnosticsGridLayoutService.cs new file mode 100644 index 0000000..175d7c7 --- /dev/null +++ b/src/TrayApp/Services/DiagnosticsGridLayoutService.cs @@ -0,0 +1,52 @@ +using System.Windows.Controls; +using WpfDataGrid = System.Windows.Controls.DataGrid; + +namespace BuildMonitor.TrayApp.Services; + +internal static class DiagnosticsGridLayoutService +{ + private static readonly string[] ColumnKeys = + [ + "When", + "Kind", + "Summary", + "Files", + "LikelyCause", + "Detail", + "Verdict", + "YourNote", + "Mark" + ]; + + public static void ApplyColumnWidths(WpfDataGrid grid, IReadOnlyDictionary? savedWidths) + { + if (savedWidths is null || savedWidths.Count == 0) + { + return; + } + + for (var i = 0; i < grid.Columns.Count && i < ColumnKeys.Length; i++) + { + var key = ColumnKeys[i]; + if (savedWidths.TryGetValue(key, out var width) && width >= 40) + { + grid.Columns[i].Width = new DataGridLength(width); + } + } + } + + public static Dictionary CaptureColumnWidths(WpfDataGrid grid) + { + var widths = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < grid.Columns.Count && i < ColumnKeys.Length; i++) + { + var width = grid.Columns[i].ActualWidth; + if (width >= 40) + { + widths[ColumnKeys[i]] = width; + } + } + + return widths; + } +} diff --git a/src/TrayApp/Services/SettingsStore.cs b/src/TrayApp/Services/SettingsStore.cs index fffdfd8..f3ccaf2 100644 --- a/src/TrayApp/Services/SettingsStore.cs +++ b/src/TrayApp/Services/SettingsStore.cs @@ -1,5 +1,6 @@ using System.IO; using System.Text.Json; +using BuildMonitor.Core.Models; using BuildMonitor.Core.Settings; namespace BuildMonitor.TrayApp.Services; @@ -89,9 +90,49 @@ public async Task LoadOrCreateDefaultAsync() settings.SchemaVersion = 10; } + if (settings.SchemaVersion < 11) + { + MigrateAutoOpenLog(json, settings); + settings.SchemaVersion = 11; + } + + if (settings.SchemaVersion < 12) + { + settings.SchemaVersion = 12; + } + + if (settings.SchemaVersion < 13) + { + settings.SchemaVersion = 13; + } + return settings; } + private static void MigrateAutoOpenLog(string json, AppSettings settings) + { + var legacyErrorsOnly = false; + try + { + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("monitor", out var monitor) + && monitor.TryGetProperty("autoOpenLogOnFailure", out var legacy)) + { + legacyErrorsOnly = legacy.GetBoolean(); + } + } + catch (JsonException) + { + // keep default false + } + + var migratedMode = legacyErrorsOnly ? AutoOpenLogMode.Errors : AutoOpenLogMode.Never; + foreach (var project in settings.Projects) + { + project.RunOptions.AutoOpenLog = migratedMode; + } + } + private static void MigrateStartOnLaunch(string json, AppSettings settings) { var autoStart = true; @@ -143,7 +184,7 @@ private static void MigrateAutoOpenBuildMonitorHealth(string json, AppSettings s public Task SaveAsync(AppSettings settings) { - settings.SchemaVersion = 10; + settings.SchemaVersion = 13; var json = JsonSerializer.Serialize(settings, JsonOptions); return File.WriteAllTextAsync(settingsPath, json); } diff --git a/src/TrayApp/Services/StatusPanelVisuals.cs b/src/TrayApp/Services/StatusPanelVisuals.cs index 7c0c0ff..ed5c6ba 100644 --- a/src/TrayApp/Services/StatusPanelVisuals.cs +++ b/src/TrayApp/Services/StatusPanelVisuals.cs @@ -1,8 +1,11 @@ +using System.Diagnostics; using System.Windows; using System.Windows.Controls; +using System.Windows.Documents; using System.Windows.Media; using System.Windows.Shapes; using BuildMonitor.Core.Models; +using BuildMonitor.Infrastructure.LocalBuild; using WpfColor = System.Windows.Media.Color; using WpfHorizontalAlignment = System.Windows.HorizontalAlignment; using WpfOrientation = System.Windows.Controls.Orientation; @@ -14,19 +17,19 @@ internal static class StatusPanelVisuals { public static UIElement BuildStepProgressChart(IReadOnlyList steps, ThemePalette palette) { - var container = new StackPanel { Margin = new Thickness(0, 8, 0, 0) }; + var container = new StackPanel { Margin = new Thickness(0, 4, 0, 0) }; container.Children.Add(new TextBlock { Text = "Build pipeline", - FontSize = 11, + FontSize = 10, FontWeight = FontWeights.SemiBold, Foreground = new SolidColorBrush(palette.Foreground), Opacity = 0.9, - Margin = new Thickness(0, 0, 0, 6) + Margin = new Thickness(0, 0, 0, 4) }); - var bar = new Grid { Height = 10 }; + var bar = new Grid { Height = 8 }; foreach (var _ in steps) { bar.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); @@ -47,7 +50,7 @@ public static UIElement BuildStepProgressChart(IReadOnlyList container.Children.Add(bar); - var legend = new WrapPanel { Margin = new Thickness(0, 6, 0, 0) }; + var legend = new WrapPanel { Margin = new Thickness(0, 4, 0, 0) }; foreach (var step in steps) { legend.Children.Add(new TextBlock @@ -63,21 +66,21 @@ public static UIElement BuildStepProgressChart(IReadOnlyList return container; } - public static UIElement BuildIssueMeter(int errors, int warnings, ThemePalette palette) + public static UIElement BuildIssueSummary(int errors, int warnings, ThemePalette palette) { - var container = new StackPanel { Margin = new Thickness(0, 8, 0, 0) }; - container.Children.Add(new TextBlock - { - Text = "Issue counts", - FontSize = 11, - FontWeight = FontWeights.SemiBold, - Foreground = new SolidColorBrush(palette.Foreground), - Opacity = 0.9, - Margin = new Thickness(0, 0, 0, 6) - }); - - container.Children.Add(MeterRow("Errors", errors, palette, WpfColor.FromRgb(220, 53, 69))); - container.Children.Add(MeterRow("Warnings", warnings, palette, WpfColor.FromRgb(255, 193, 7))); + var container = new WrapPanel { Margin = new Thickness(0, 4, 0, 0) }; + container.Children.Add(IssueChip( + errors, + errors == 1 ? "error" : "errors", + WpfColor.FromRgb(220, 53, 69), + palette, + errors > 0)); + container.Children.Add(IssueChip( + warnings, + warnings == 1 ? "warning" : "warnings", + WpfColor.FromRgb(255, 193, 7), + palette, + warnings > 0)); return container; } @@ -97,20 +100,20 @@ public static UIElement BuildActivityIndicator(ProjectLifecycleState state, Them _ => WpfColor.FromRgb(255, 193, 7) }; - var panel = new StackPanel { Margin = new Thickness(0, 8, 0, 0) }; + var panel = new StackPanel { Margin = new Thickness(0, 4, 0, 0) }; panel.Children.Add(new TextBlock { Text = label, - FontSize = 11, + FontSize = 10, FontWeight = FontWeights.SemiBold, Foreground = new SolidColorBrush(palette.Foreground), Opacity = 0.9, - Margin = new Thickness(0, 0, 0, 6) + Margin = new Thickness(0, 0, 0, 4) }); var track = new Grid { - Height = 8, + Height = 6, Background = new SolidColorBrush(palette.Border) { Opacity = 0.35 } }; track.Children.Add(new WpfRectangle @@ -125,57 +128,45 @@ public static UIElement BuildActivityIndicator(ProjectLifecycleState state, Them return panel; } - private static UIElement MeterRow(string label, int value, ThemePalette palette, WpfColor fill) + private static UIElement IssueChip( + int count, + string label, + WpfColor accent, + ThemePalette palette, + bool emphasized) { - var row = new Grid { Margin = new Thickness(0, 0, 0, 4) }; - row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(68) }); - row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(28) }); - - row.Children.Add(new TextBlock - { - Text = label, - FontSize = 10, - VerticalAlignment = VerticalAlignment.Center, - Foreground = new SolidColorBrush(palette.Foreground), - Opacity = 0.8 - }); - - var track = new Border - { - Height = 8, - Margin = new Thickness(0, 0, 8, 0), - Background = new SolidColorBrush(palette.Border) { Opacity = 0.35 }, - CornerRadius = new CornerRadius(2), - VerticalAlignment = VerticalAlignment.Center - }; + var foreground = emphasized + ? accent + : palette.Foreground; + var background = emphasized + ? Blend(accent, palette.CardBackground, 0.82f) + : Blend(palette.Border, palette.CardBackground, 0.55f); - var max = Math.Max(10, value); - var fillWidth = value == 0 ? 0 : Math.Max(4, 120.0 * value / max); - track.Child = new Border + return new Border { - Width = fillWidth, - HorizontalAlignment = WpfHorizontalAlignment.Left, - Background = new SolidColorBrush(fill), - CornerRadius = new CornerRadius(2) - }; - Grid.SetColumn(track, 1); - row.Children.Add(track); - - var count = new TextBlock - { - Text = value.ToString(), - FontSize = 10, - FontWeight = FontWeights.SemiBold, - VerticalAlignment = VerticalAlignment.Center, - HorizontalAlignment = WpfHorizontalAlignment.Right, - Foreground = new SolidColorBrush(value > 0 ? fill : palette.Foreground), - Opacity = value > 0 ? 1 : 0.5 + Background = new SolidColorBrush(background), + BorderBrush = new SolidColorBrush(emphasized ? accent : palette.Border) { Opacity = emphasized ? 0.85 : 0.5 }, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(8, 3, 8, 3), + Margin = new Thickness(0, 0, 6, 4), + Child = new TextBlock + { + Text = $"{count:N0} {label}", + FontSize = 11, + FontWeight = emphasized ? FontWeights.SemiBold : FontWeights.Normal, + Foreground = new SolidColorBrush(foreground) { Opacity = emphasized ? 1 : 0.65 } + } }; - Grid.SetColumn(count, 2); - row.Children.Add(count); + } - return row; + private static WpfColor Blend(WpfColor from, WpfColor to, float amount) + { + amount = Math.Clamp(amount, 0f, 1f); + var r = (byte)(from.R + (to.R - from.R) * amount); + var g = (byte)(from.G + (to.G - from.G) * amount); + var b = (byte)(from.B + (to.B - from.B) * amount); + return WpfColor.FromRgb(r, g, b); } private static string FormatLegendStep(BuildProgressStep step) => @@ -195,4 +186,103 @@ private static SolidColorBrush StepBrush(BuildStepStatus status, ThemePalette pa BuildStepStatus.Failed => new SolidColorBrush(WpfColor.FromRgb(220, 53, 69)), _ => new SolidColorBrush(palette.Foreground) { Opacity = 0.35 } }; + + public static UIElement BuildSiteAwaitingBlock(string listenUrl, ThemePalette palette) + { + var openUrl = LocalPortProbe.NormalizeBrowserUrl(listenUrl); + var accent = WpfColor.FromRgb(255, 193, 7); + return new Border + { + Margin = new Thickness(0, 4, 0, 0), + Padding = new Thickness(8, 5, 8, 5), + CornerRadius = new CornerRadius(4), + BorderThickness = new Thickness(1), + BorderBrush = new SolidColorBrush(accent) { Opacity = 0.75 }, + Background = new SolidColorBrush(Blend(accent, palette.CardBackground, 0.88f)), + Child = new StackPanel + { + Children = + { + new TextBlock + { + Text = "Site starting…", + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = new SolidColorBrush(accent) + }, + new TextBlock + { + Text = openUrl, + FontSize = 10, + TextWrapping = TextWrapping.Wrap, + Foreground = new SolidColorBrush(palette.Foreground) { Opacity = 0.75 }, + Margin = new Thickness(0, 2, 0, 0) + } + } + } + }; + } + + public static UIElement BuildSiteReadyBlock(string listenUrl, ThemePalette palette) + { + var openUrl = LocalPortProbe.NormalizeBrowserUrl(listenUrl); + var readyGreen = WpfColor.FromRgb(40, 167, 69); + var panel = new StackPanel(); + panel.Children.Add(new TextBlock + { + Text = "Site ready", + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = new SolidColorBrush(readyGreen) + }); + + var linkRow = new TextBlock { Margin = new Thickness(0, 3, 0, 0) }; + if (Uri.TryCreate(openUrl, UriKind.Absolute, out var uri)) + { + var link = new Hyperlink + { + NavigateUri = uri, + Foreground = new SolidColorBrush(palette.Accent), + FontWeight = FontWeights.SemiBold, + TextDecorations = TextDecorations.Underline, + Cursor = System.Windows.Input.Cursors.Hand + }; + link.Inlines.Add($"Open {openUrl}"); + link.RequestNavigate += (_, e) => + { + try + { + Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true }); + } + catch + { + // Best effort only. + } + + e.Handled = true; + }; + linkRow.Inlines.Add(link); + } + else + { + linkRow.Inlines.Add(new Run(openUrl) + { + Foreground = new SolidColorBrush(palette.Foreground), + FontWeight = FontWeights.SemiBold + }); + } + + panel.Children.Add(linkRow); + + return new Border + { + Margin = new Thickness(0, 4, 0, 0), + Padding = new Thickness(8, 5, 8, 5), + CornerRadius = new CornerRadius(4), + BorderThickness = new Thickness(1.5), + BorderBrush = new SolidColorBrush(readyGreen), + Background = new SolidColorBrush(Blend(readyGreen, palette.CardBackground, 0.86f)), + Child = panel + }; + } } diff --git a/src/TrayApp/Services/TrayIconShellInterop.cs b/src/TrayApp/Services/TrayIconShellInterop.cs new file mode 100644 index 0000000..56d3bda --- /dev/null +++ b/src/TrayApp/Services/TrayIconShellInterop.cs @@ -0,0 +1,116 @@ +using System.Reflection; +using System.Runtime.InteropServices; +using System.Windows.Forms; + +namespace BuildMonitor.TrayApp.Services; + +/// +/// Resolves tray icon screen bounds for the custom hover hint (hide when cursor leaves the icon). +/// Native shell tooltip is suppressed via = empty — do not call +/// Shell_NotifyIcon(NIM_MODIFY) with NIF_MESSAGE; that clears the callback and breaks the menu. +/// +internal static class TrayIconShellInterop +{ + [StructLayout(LayoutKind.Sequential)] + private struct NotifyIconIdentifier + { + public int cbSize; + public IntPtr hWnd; + public int uID; + public Guid guidItem; + } + + [StructLayout(LayoutKind.Sequential)] + private struct RectNative + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [DllImport("shell32.dll", SetLastError = true)] + private static extern int Shell_NotifyIconGetRect(ref NotifyIconIdentifier identifier, out RectNative rect); + + public static bool TryGetIconScreenBounds(NotifyIcon notifyIcon, out Rectangle bounds) + { + bounds = Rectangle.Empty; + if (!OperatingSystem.IsWindows()) + { + return false; + } + + var windowHandle = GetNotifyIconWindowHandle(notifyIcon); + var iconId = GetNotifyIconId(notifyIcon); + if (windowHandle == IntPtr.Zero || iconId == 0) + { + return false; + } + + var identifier = new NotifyIconIdentifier + { + cbSize = Marshal.SizeOf(), + hWnd = windowHandle, + uID = (int)iconId + }; + + if (Shell_NotifyIconGetRect(ref identifier, out var rect) != 0) + { + return false; + } + + bounds = Rectangle.FromLTRB(rect.Left, rect.Top, rect.Right, rect.Bottom); + return bounds.Width > 0 && bounds.Height > 0; + } + + public static bool IsCursorOverIcon(NotifyIcon notifyIcon, int inflatePixels = 12) + { + if (!TryGetIconScreenBounds(notifyIcon, out var bounds)) + { + // Cannot resolve icon bounds — do not treat as "left icon". + return true; + } + + if (inflatePixels > 0) + { + bounds.Inflate(inflatePixels, inflatePixels); + } + + return bounds.Contains(Control.MousePosition); + } + + private static IntPtr GetNotifyIconWindowHandle(NotifyIcon notifyIcon) + { + if (GetInstanceField(notifyIcon, "_window", "window") is NativeWindow window) + { + return window.Handle; + } + + return IntPtr.Zero; + } + + private static uint GetNotifyIconId(NotifyIcon notifyIcon) + { + var value = GetInstanceField(notifyIcon, "_id", "id"); + return value switch + { + uint uintId => uintId, + int intId => (uint)intId, + _ => 0 + }; + } + + private static object? GetInstanceField(NotifyIcon notifyIcon, params string[] names) + { + foreach (var name in names) + { + var field = typeof(NotifyIcon).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); + if (field is not null) + { + return field.GetValue(notifyIcon); + } + } + + return null; + } +} diff --git a/src/TrayApp/Services/TrayScreenPlacement.cs b/src/TrayApp/Services/TrayScreenPlacement.cs index 44760d4..cdab121 100644 --- a/src/TrayApp/Services/TrayScreenPlacement.cs +++ b/src/TrayApp/Services/TrayScreenPlacement.cs @@ -5,7 +5,7 @@ namespace BuildMonitor.TrayApp.Services; /// -/// Places WPF windows on the monitor where the user last interacted with the tray icon. +/// Places WPF windows relative to the system tray (icon bounds or captured work area). /// public static class TrayScreenPlacement { @@ -43,6 +43,39 @@ public static void PlaceNearTrayBottomRight(Window window, double margin = 12) window.Top = area.Bottom - height - margin; } + /// + /// Positions the window above the tray notify icon with its bottom edge above the icon, + /// clamped to that monitor's work area so it does not cover the taskbar. + /// + public static void PlaceAboveTrayIcon(Window window, Rectangle iconBounds, double margin = 12) + { + window.WindowStartupLocation = WindowStartupLocation.Manual; + window.UpdateLayout(); + + var area = FormsScreen.FromRectangle(iconBounds).WorkingArea; + var width = ResolveDimension(window.ActualWidth, window.Width, 360); + var height = ResolveDimension(window.ActualHeight, window.Height, 96); + + var maxBottom = iconBounds.Top - margin; + var availableHeight = Math.Max(96, maxBottom - area.Top); + if (height > availableHeight) + { + height = availableHeight; + } + + var top = maxBottom - height; + var left = iconBounds.Left + (iconBounds.Width - width) / 2.0; + left = Math.Clamp(left, area.Left, Math.Max(area.Left, area.Right - width)); + top = Math.Clamp(top, area.Top, Math.Max(area.Top, maxBottom - height)); + + window.Left = left; + window.Top = top; + if (Math.Abs(window.Height - height) > 0.5) + { + window.Height = height; + } + } + private static double ResolveDimension(double actual, double design, double fallback) { if (actual > 0) diff --git a/src/TrayApp/SettingsWindow.xaml b/src/TrayApp/SettingsWindow.xaml index 3420fb6..ecf9c92 100644 --- a/src/TrayApp/SettingsWindow.xaml +++ b/src/TrayApp/SettingsWindow.xaml @@ -394,7 +394,16 @@ - + + + + + + + @@ -549,6 +558,21 @@ ToolTip="When enabled, watch mode uses a debounced file watcher and rebuilds once after edits stop instead of dotnet watch rebuilding on every save. Turn off for dotnet watch hot reload." /> + + + + + + @@ -569,12 +593,6 @@ - - (); RunTestsCombo.ItemsSource = Enum.GetValues(); + AutoOpenLogCombo.ItemsSource = Enum.GetValues(); FileChangesCombo.ItemsSource = Enum.GetValues(); ThemeCombo.ItemsSource = Enum.GetValues(); ThemeCombo.SelectedItem = Settings.AppBehavior.Theme; @@ -52,8 +53,10 @@ public SettingsWindow(AppSettings settings, AppWindowsLayoutStore windowsLayoutS DebounceModeCombo.SelectedItem = Settings.Monitor.FileChangeDebounceMode; UpdateDebounceModeUi(); CoalesceWatchRebuildsCheck.IsChecked = Settings.Monitor.CoalesceWatchRebuilds; + DeferStartupBuildUntilQuietCheck.IsChecked = Settings.Monitor.DeferStartupBuildUntilQuiet; + CancelSupersededBuildsCheck.IsChecked = Settings.Monitor.CancelSupersededBuilds; + UseAgentTranscriptActivityCheck.IsChecked = Settings.Monitor.UseAgentTranscriptActivity; HealthRefreshText.Text = Settings.Monitor.HealthRefreshSeconds.ToString(); - AutoOpenLogCheck.IsChecked = Settings.Monitor.AutoOpenLogOnFailure; AutoOpenBuildMonitorHealthCheck.IsChecked = Settings.Monitor.AutoOpenBuildMonitorHealthOnStartup; PlaySoundOnErrorCheck.IsChecked = Settings.Monitor.PlaySoundOnBuildError; PlaySoundOnSuccessCheck.IsChecked = Settings.Monitor.PlaySoundOnBuildSuccess; @@ -149,6 +152,8 @@ private void LoadEditorFromProject(LocalProjectDefinition project) AutoRestartOnHotReloadRequestCheck.IsChecked = project.RunOptions.AutoRestartOnHotReloadRequest; RestartAppAfterRebuildCheck.IsChecked = project.RunOptions.RestartAppAfterRebuild; RunTestsCombo.SelectedItem = project.RunOptions.RunTests; + AutoOpenLogCombo.SelectedItem = project.RunOptions.AutoOpenLog; + ShowStatusPanelWhileBuildingCheck.IsChecked = project.RunOptions.ShowStatusPanelWhileBuilding; FileChangesCombo.SelectedItem = project.RunOptions.FileChanges; WatchExcludeSegmentsText.Text = project.RunOptions.WatchExcludeSegments; ReleaseOutputLocksCheck.IsChecked = project.RunOptions.ReleaseOutputLocksBeforeBuild; @@ -316,6 +321,8 @@ private void CommitEditorToSelected() selectedProject.RunOptions.RestartAppAfterRebuild = RestartAppAfterRebuildCheck.IsChecked == true; selectedProject.RunOptions.RunTests = (TestRunTrigger)(RunTestsCombo.SelectedItem ?? TestRunTrigger.Off); + selectedProject.RunOptions.AutoOpenLog = (AutoOpenLogMode)(AutoOpenLogCombo.SelectedItem ?? AutoOpenLogMode.Never); + selectedProject.RunOptions.ShowStatusPanelWhileBuilding = ShowStatusPanelWhileBuildingCheck.IsChecked == true; selectedProject.RunOptions.FileChanges = (FileChangeMode)(FileChangesCombo.SelectedItem ?? FileChangeMode.WatchOnly); selectedProject.RunOptions.WatchExcludeSegments = WatchExcludeSegmentsText.Text.Trim(); selectedProject.RunOptions.ReleaseOutputLocksBeforeBuild = ReleaseOutputLocksCheck.IsChecked == true; @@ -420,7 +427,9 @@ private void CommitMonitorAndAppSettings() } Settings.Monitor.CoalesceWatchRebuilds = CoalesceWatchRebuildsCheck.IsChecked == true; - Settings.Monitor.AutoOpenLogOnFailure = AutoOpenLogCheck.IsChecked == true; + Settings.Monitor.DeferStartupBuildUntilQuiet = DeferStartupBuildUntilQuietCheck.IsChecked == true; + Settings.Monitor.CancelSupersededBuilds = CancelSupersededBuildsCheck.IsChecked == true; + Settings.Monitor.UseAgentTranscriptActivity = UseAgentTranscriptActivityCheck.IsChecked == true; Settings.Monitor.AutoOpenBuildMonitorHealthOnStartup = AutoOpenBuildMonitorHealthCheck.IsChecked == true; Settings.Monitor.PlaySoundOnBuildError = PlaySoundOnErrorCheck.IsChecked == true; Settings.Monitor.PlaySoundOnBuildSuccess = PlaySoundOnSuccessCheck.IsChecked == true;