From 317b05528ee19333d40cce187a9d1ec1a496cce0 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:30:43 +0300 Subject: [PATCH] refactor(config): move grid-style Save off the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The style editor's Load was async but Save was synchronous, so `SaveCommand = ReactiveCommand.Create(Save, …)` blocked the dispatcher on the file write. Make the write path async end to end, matching Load (slice 2 of the #118 grid-style debloat). - IGridStyleEditorFacade.Save returns Task; the facade awaits GridStyleWriter.SaveAsync. - The writer's temp-file write and header read use WriteAllTextAsync / ReadAllLinesAsync; File.Move stays synchronous inside the atomic-write try/catch (no async move exists, and the rename is a fast metadata op). The Rule-B GridStyleSaveFailedError.CausedBy(ex) envelope is preserved. - The view model's Save becomes SaveAsync, wired via ReactiveCommand.CreateFromTask; SaveCommand stays ReactiveCommand, so the window's save->restart-prompt->close flow and the ThrownExceptions -> ReportSaveException routing are unchanged. Behavior-preserving: the bytes written are identical, only the write's threading changes. Validate stays synchronous (it runs per keystroke in RecomputeCanSave), and no CancellationToken is threaded (Save is a button-click). Slice 1's round-trip guards still hold. Co-Authored-By: Claude Opus 4.8 (1M context) --- Docs/architecture/grid-style-configuration.md | 15 ++-- .../Configuration/GridStyleEditorFacade.cs | 4 +- .../Configuration/IGridStyleEditorFacade.cs | 2 +- .../Configuration/Loaders/GridStyleWriter.cs | 14 +-- .../GridStyleEditorFacadeTests.cs | 10 +-- .../GridStyleOrientationTests.cs | 4 +- .../Configuration/GridStyleWriterTests.cs | 14 +-- .../GridStyleEditorViewModelTests.cs | 6 +- .../StyleEditor/GridStyleEditorViewModel.cs | 8 +- .../20260805-grid-style-async-save.md | 86 +++++++++++++++++++ 10 files changed, 127 insertions(+), 36 deletions(-) create mode 100644 docs/plans/completed/20260805-grid-style-async-save.md diff --git a/Docs/architecture/grid-style-configuration.md b/Docs/architecture/grid-style-configuration.md index 13eb0a3e..e345fe90 100644 --- a/Docs/architecture/grid-style-configuration.md +++ b/Docs/architecture/grid-style-configuration.md @@ -230,14 +230,19 @@ GridStyleEditorWindow - **Facade.** `GridStyleEditorFacade` is the only public Core seam for the editor: `Load(configDir)` → `Result`, `Validate(GridStyleOptions)` → `Result`, - `Save(configDir, GridStyleOptions)` → `Result`. The loader, validator, writer, and the ~12 DTOs stay - `internal`. The UI never touches Core internals directly. (Layering: the config stays in - `SemiStep.Core`, settled by review; the editor reaches it only through this facade.) + `Save(configDir, GridStyleOptions)` → `Task`. `Save` is async and runs the file write off the + UI thread (mirroring `Load`); the editor's `SaveCommand` awaits it. `Validate` stays synchronous — + `RecomputeCanSave` calls it per keystroke, and it is an in-memory pass with no I/O. The loader, + validator, writer, and the ~12 DTOs stay `internal`. The UI never touches Core internals directly. + (Layering: the config stays in `SemiStep.Core`, settled by review; the editor reaches it only through + this facade.) - **Writer.** `GridStyleWriter` maps the record back to the DTO (`GridStyleDtoMapper`), serializes with the underscored naming convention, re-prepends the file's leading comment block (the header), then normalizes to LF and writes UTF-8 no-BOM via a temp-then-move atomic replace in the same `ui/` dir. - Every color DTO property carries `[YamlMember(ScalarStyle = ScalarStyle.DoubleQuoted)]` so hex values - emit quoted and quoting stays uniform. + The temp write and the header read are async (`WriteAllTextAsync` / `ReadAllLinesAsync`); the final + `File.Move` stays synchronous — there is no async move, and it is a fast metadata rename inside the + same try/catch. Every color DTO property carries `[YamlMember(ScalarStyle = ScalarStyle.DoubleQuoted)]` + so hex values emit quoted and quoting stays uniform. The editor edits the **merged** record (defaults already applied), so `Save` writes a fully-populated file — every key is emitted, no omitted-key preservation. That is acceptable for a diff --git a/SemiStep/SemiStep.Core/Configuration/GridStyleEditorFacade.cs b/SemiStep/SemiStep.Core/Configuration/GridStyleEditorFacade.cs index 062e7347..cc4ce520 100644 --- a/SemiStep/SemiStep.Core/Configuration/GridStyleEditorFacade.cs +++ b/SemiStep/SemiStep.Core/Configuration/GridStyleEditorFacade.cs @@ -38,7 +38,7 @@ public Result Validate(GridStyleOptions options) return GridStyleValidator.Validate(GridStyleDtoMapper.Map(options)); } - public Result Save(string configDir, GridStyleOptions options) + public async Task Save(string configDir, GridStyleOptions options) { var validation = Validate(options); if (validation.IsFailed) @@ -46,6 +46,6 @@ public Result Save(string configDir, GridStyleOptions options) return validation; } - return _gridStyleWriter.Save(configDir, options); + return await _gridStyleWriter.SaveAsync(configDir, options); } } diff --git a/SemiStep/SemiStep.Core/Configuration/IGridStyleEditorFacade.cs b/SemiStep/SemiStep.Core/Configuration/IGridStyleEditorFacade.cs index 6c6abe72..f195e3b4 100644 --- a/SemiStep/SemiStep.Core/Configuration/IGridStyleEditorFacade.cs +++ b/SemiStep/SemiStep.Core/Configuration/IGridStyleEditorFacade.cs @@ -12,5 +12,5 @@ public interface IGridStyleEditorFacade Result Validate(GridStyleOptions options); - Result Save(string configDir, GridStyleOptions options); + Task Save(string configDir, GridStyleOptions options); } diff --git a/SemiStep/SemiStep.Core/Configuration/Loaders/GridStyleWriter.cs b/SemiStep/SemiStep.Core/Configuration/Loaders/GridStyleWriter.cs index fc0ba85a..2670d146 100644 --- a/SemiStep/SemiStep.Core/Configuration/Loaders/GridStyleWriter.cs +++ b/SemiStep/SemiStep.Core/Configuration/Loaders/GridStyleWriter.cs @@ -17,7 +17,7 @@ internal sealed class GridStyleWriter private static readonly UTF8Encoding _utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); - public Result Save(string configDirectory, GridStyleOptions options) + public async Task SaveAsync(string configDirectory, GridStyleOptions options) { var uiDir = Path.Combine(configDirectory, "ui"); var filePath = Path.Combine(uiDir, "grid_style.yaml"); @@ -26,11 +26,11 @@ public Result Save(string configDirectory, GridStyleOptions options) { Directory.CreateDirectory(uiDir); - var header = ReadLeadingCommentBlock(filePath); + var header = await ReadLeadingCommentBlockAsync(filePath); var body = _serializer.Serialize(GridStyleDtoMapper.Map(options)); var content = Normalize(header + body); - WriteAtomic(uiDir, filePath, content); + await WriteAtomicAsync(uiDir, filePath, content); return Result.Ok(); } @@ -40,14 +40,14 @@ public Result Save(string configDirectory, GridStyleOptions options) } } - private static string ReadLeadingCommentBlock(string filePath) + private static async Task ReadLeadingCommentBlockAsync(string filePath) { if (!File.Exists(filePath)) { return string.Empty; } - var lines = File.ReadAllLines(filePath); + var lines = await File.ReadAllLinesAsync(filePath); var headerLines = new List(); foreach (var line in lines) @@ -76,11 +76,11 @@ private static string Normalize(string content) return content.Replace("\r\n", "\n").Replace("\r", "\n"); } - private static void WriteAtomic(string uiDir, string filePath, string content) + private static async Task WriteAtomicAsync(string uiDir, string filePath, string content) { var tempPath = Path.Combine(uiDir, $".grid_style.{Guid.NewGuid():N}.tmp"); - File.WriteAllText(tempPath, content, _utf8NoBom); + await File.WriteAllTextAsync(tempPath, content, _utf8NoBom); try { diff --git a/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleEditorFacadeTests.cs b/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleEditorFacadeTests.cs index bbeb0be4..82190b4e 100644 --- a/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleEditorFacadeTests.cs +++ b/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleEditorFacadeTests.cs @@ -48,7 +48,7 @@ public async Task SaveThenLoad_RoundTrips() var original = (await facade.Load(tempDir.Path)).Value; - facade.Save(tempDir.Path, original).IsSuccess.Should().BeTrue(); + (await facade.Save(tempDir.Path, original)).IsSuccess.Should().BeTrue(); var reloaded = (await facade.Load(tempDir.Path)).Value; @@ -61,7 +61,7 @@ public async Task SaveThenLoad_DistinctFixture_PreservesEveryMappedField() using var tempDir = new TempDirectory(); var facade = new GridStyleEditorFacade(); - facade.Save(tempDir.Path, GridStyleOptionsTestData.Distinct()).IsSuccess.Should().BeTrue(); + (await facade.Save(tempDir.Path, GridStyleOptionsTestData.Distinct())).IsSuccess.Should().BeTrue(); var reloaded = (await facade.Load(tempDir.Path)).Value; @@ -101,7 +101,7 @@ public async Task Save_InvalidRecord_FailsValidationGuardBeforeWriting() with { ExecutionDepth0Color = "not-a-color" }; - facade.Save(tempDir.Path, invalid).IsFailed.Should().BeTrue(); + (await facade.Save(tempDir.Path, invalid)).IsFailed.Should().BeTrue(); var after = await File.ReadAllTextAsync(filePath, token); after.Should().Be(before); @@ -128,13 +128,13 @@ await File.WriteAllTextAsync( } [Fact] - public void Save_WriteFailure_CarriesOriginalExceptionOnCausedBy() + public async Task Save_WriteFailure_CarriesOriginalExceptionOnCausedBy() { using var tempDir = new TempDirectory(); var configDirAsFile = Path.Combine(tempDir.Path, "not-a-directory"); File.WriteAllText(configDirAsFile, string.Empty); - var result = new GridStyleWriter().Save(configDirAsFile, GridStyleOptions.Default); + var result = await new GridStyleWriter().SaveAsync(configDirAsFile, GridStyleOptions.Default); result.IsFailed.Should().BeTrue(); result.Errors.Should().ContainSingle().Which.Should().BeOfType(); diff --git a/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleOrientationTests.cs b/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleOrientationTests.cs index 8d6081c6..1130cdae 100644 --- a/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleOrientationTests.cs +++ b/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleOrientationTests.cs @@ -82,7 +82,7 @@ public async Task SaveRoundTrip_PreservesOrientation() await AppendOrientation(tempDir.Path, "columns_as_steps"); var original = await LoadValidated(tempDir.Path); - new GridStyleWriter().Save(tempDir.Path, original).IsSuccess.Should().BeTrue(); + (await new GridStyleWriter().SaveAsync(tempDir.Path, original)).IsSuccess.Should().BeTrue(); var reloaded = await LoadValidated(tempDir.Path); reloaded.Orientation.Should().Be(GridOrientation.ColumnsAsSteps); @@ -95,7 +95,7 @@ public async Task Save_MissingOrientation_EmitsExplicitDefault() using var tempDir = CopyShippedConfig("MOCVD"); var options = await LoadValidated(tempDir.Path); - new GridStyleWriter().Save(tempDir.Path, options).IsSuccess.Should().BeTrue(); + (await new GridStyleWriter().SaveAsync(tempDir.Path, options)).IsSuccess.Should().BeTrue(); var content = await File.ReadAllTextAsync( Path.Combine(tempDir.Path, "ui", "grid_style.yaml"), diff --git a/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleWriterTests.cs b/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleWriterTests.cs index 99051aad..cd0aab04 100644 --- a/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleWriterTests.cs +++ b/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleWriterTests.cs @@ -30,7 +30,7 @@ public async Task Save_SemanticRoundTrip_PreservesRecord(string equipment) var original = await LoadOptions(tempDir.Path); - new GridStyleWriter().Save(tempDir.Path, original).IsSuccess.Should().BeTrue(); + (await new GridStyleWriter().SaveAsync(tempDir.Path, original)).IsSuccess.Should().BeTrue(); var reloaded = await LoadOptions(tempDir.Path); @@ -50,7 +50,7 @@ public async Task Save_PreservesLeadingCommentBlock_WhenHeaderPresent(string equ originalHeader.Should().NotBeEmpty(); var options = await LoadOptions(tempDir.Path); - new GridStyleWriter().Save(tempDir.Path, options).IsSuccess.Should().BeTrue(); + (await new GridStyleWriter().SaveAsync(tempDir.Path, options)).IsSuccess.Should().BeTrue(); var writtenHeader = LeadingCommentBlock(await File.ReadAllTextAsync(filePath, token)); writtenHeader.Should().Be(originalHeader); @@ -67,7 +67,7 @@ public async Task Save_InjectsNoHeader_WhenSourceHasNone() LeadingCommentBlock(await File.ReadAllTextAsync(filePath, token)).Should().BeEmpty(); var options = await LoadOptions(tempDir.Path); - new GridStyleWriter().Save(tempDir.Path, options).IsSuccess.Should().BeTrue(); + (await new GridStyleWriter().SaveAsync(tempDir.Path, options)).IsSuccess.Should().BeTrue(); var written = (await File.ReadAllTextAsync(filePath, token)).Replace("\r\n", "\n"); var firstNonBlank = written.Split('\n').First(line => line.TrimStart().Length > 0); @@ -81,7 +81,7 @@ public async Task Save_EmitsColorScalarsDoubleQuoted() var filePath = GridStyleFilePath(tempDir.Path); var options = await LoadOptions(tempDir.Path); - new GridStyleWriter().Save(tempDir.Path, options).IsSuccess.Should().BeTrue(); + (await new GridStyleWriter().SaveAsync(tempDir.Path, options)).IsSuccess.Should().BeTrue(); var content = await File.ReadAllTextAsync(filePath, TestContext.Current.CancellationToken); content.Should().Contain("\"#"); @@ -109,7 +109,7 @@ public async Task Save_WhenMoveFails_FailsAndLeavesOriginalIntactWithNoTempOrpha // Hold an exclusive lock on the target so the atomic File.Move cannot replace it. using (var locking = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.None)) { - result = new GridStyleWriter().Save(tempDir.Path, options); + result = await new GridStyleWriter().SaveAsync(tempDir.Path, options); } result.IsFailed.Should().BeTrue(); @@ -127,7 +127,7 @@ public async Task Save_WritesLfLineEndingsWithoutBom() var filePath = GridStyleFilePath(tempDir.Path); var options = await LoadOptions(tempDir.Path); - new GridStyleWriter().Save(tempDir.Path, options).IsSuccess.Should().BeTrue(); + (await new GridStyleWriter().SaveAsync(tempDir.Path, options)).IsSuccess.Should().BeTrue(); var bytes = await File.ReadAllBytesAsync(filePath, TestContext.Current.CancellationToken); bytes.Take(3).Should().NotEqual(new byte[] { 0xEF, 0xBB, 0xBF }); @@ -145,7 +145,7 @@ public async Task Save_UnderCommaLocale_EmitsInvariantDecimals() try { CultureInfo.CurrentCulture = new CultureInfo("de-DE"); - new GridStyleWriter().Save(tempDir.Path, options).IsSuccess.Should().BeTrue(); + (await new GridStyleWriter().SaveAsync(tempDir.Path, options)).IsSuccess.Should().BeTrue(); } finally { diff --git a/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorViewModelTests.cs b/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorViewModelTests.cs index 22deb25f..50cc34d5 100644 --- a/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorViewModelTests.cs +++ b/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorViewModelTests.cs @@ -509,7 +509,7 @@ public Result Validate(GridStyleOptions options) return Result.Ok(); } - public Result Save(string configDir, GridStyleOptions options) + public Task Save(string configDir, GridStyleOptions options) { throw failure; } @@ -529,9 +529,9 @@ public Result Validate(GridStyleOptions options) return Result.Ok(); } - public Result Save(string configDir, GridStyleOptions options) + public Task Save(string configDir, GridStyleOptions options) { - return saveResult ?? Result.Ok(); + return Task.FromResult(saveResult ?? Result.Ok()); } } } diff --git a/SemiStep/SemiStep.UI/StyleEditor/GridStyleEditorViewModel.cs b/SemiStep/SemiStep.UI/StyleEditor/GridStyleEditorViewModel.cs index 21fcfbae..88b21040 100644 --- a/SemiStep/SemiStep.UI/StyleEditor/GridStyleEditorViewModel.cs +++ b/SemiStep/SemiStep.UI/StyleEditor/GridStyleEditorViewModel.cs @@ -62,8 +62,8 @@ public GridStyleEditorViewModel( _source = source; _logger = logger; - SaveCommand = ReactiveCommand.Create( - Save, + SaveCommand = ReactiveCommand.CreateFromTask( + SaveAsync, this.WhenAnyValue(viewModel => viewModel.CanSave)); // Modal editor: a save fault must surface on the editor's own ErrorMessage, not the @@ -284,7 +284,7 @@ public GridStyleOptions BuildRecord() }; } - private bool Save() + private async Task SaveAsync() { if (!CanSave) { @@ -292,7 +292,7 @@ private bool Save() return false; } - var result = _gridStyleEditorFacade.Save(_configDir, BuildRecord()); + var result = await _gridStyleEditorFacade.Save(_configDir, BuildRecord()); if (result.IsFailed) { ErrorMessage = string.Join("; ", result.Errors.Select(ReasonLocalizer.Localize)); diff --git a/docs/plans/completed/20260805-grid-style-async-save.md b/docs/plans/completed/20260805-grid-style-async-save.md new file mode 100644 index 00000000..65946d1c --- /dev/null +++ b/docs/plans/completed/20260805-grid-style-async-save.md @@ -0,0 +1,86 @@ +# Slice 2 — Grid-style editor: async Save off the UI thread + +## Overview + +Slice 2 of the #118 debloat. `GridStyleEditorFacade.Load` is `async Task>`, but `Save` is **synchronous** and +does file I/O (`File.WriteAllText` + `File.Move`) on the UI thread — the editor's `SaveCommand` is +`ReactiveCommand.Create(Save, …)`, so a save blocks the dispatcher. Make the write path async end to end, matching +`Load`. Small, contained, independent of the other #118 slices (nesting, `StyleColor`, the VM split). + +**Behavior-preserving.** Nothing about *what* is saved changes — `BuildRecord`, `Seed`, and the mappers are untouched, so +slice 1's round-trip guards keep holding. Only the *threading* of the write changes: the sync file calls become their +async equivalents and the command awaits them. + +**Scope decisions (verified against the code):** +- **`Validate` stays synchronous.** `RecomputeCanSave` calls `_gridStyleEditorFacade.Validate(BuildRecord())` on **every + property set** (per keystroke); it is an in-memory `GridStyleValidator` pass with no I/O. Making it async would push + per-keystroke work onto the task pool for nothing. Only `Save` (the file write) goes async. +- **The window is unchanged.** `SaveCommand` stays `ReactiveCommand` — `CreateFromTask(Func>)` + produces exactly that type. `GridStyleEditorWindow.OnSaveCompleted(bool saved)` and its restart-prompt/close flow, and + the `SaveCommand.ThrownExceptions.Subscribe(ReportSaveException)` routing, all keep working (`CreateFromTask` routes a + faulted task to `ThrownExceptions`). +- **`File.Move` stays synchronous.** There is no async `File.Move`; it is a fast metadata rename. The *write* of the temp + file becomes `File.WriteAllTextAsync` and the header read becomes `File.ReadAllLinesAsync`; the atomic move stays sync + inside the same try/catch. The slice-7 Rule-B envelope (`GridStyleSaveFailedError.CausedBy(ex)`) in the catch is kept. +- **No `CancellationToken`.** Save is a user button-click with no cancellation source (same rationale as the user-load + path). Do not thread a token. + +## Acceptance Evidence + +- `IGridStyleEditorFacade.Save` returns `Task`; `GridStyleEditorFacade.Save` and `GridStyleWriter` are async + (`WriteAllTextAsync`/`ReadAllLinesAsync`, `File.Move` still sync); `GridStyleEditorViewModel` has `SaveAsync` wired via + `ReactiveCommand.CreateFromTask`, `SaveCommand` still `ReactiveCommand`. +- `Validate` is still synchronous and `RecomputeCanSave` is unchanged. +- The window (`GridStyleEditorWindow.axaml.cs`) is untouched; the save→restart-prompt→close and the + `ReportSaveException` fault path still work. +- Slice 1's guards (`Seed_PopulatesEverySurfacedProperty…`, the perturbation test, `SaveThenLoad_DistinctFixture…`) stay + green (the last now awaits the async `Save`). +- `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green; `dotnet format` clean. + +## Task 1: Make the Save write-path async + +**Files:** +- Modify: `SemiStep/SemiStep.Core/Configuration/IGridStyleEditorFacade.cs` (`Save` → `Task`). +- Modify: `SemiStep/SemiStep.Core/Configuration/GridStyleEditorFacade.cs` (`Save` → `async Task`; `await _gridStyleWriter.SaveAsync(...)`; `Validate` unchanged). +- Modify: `SemiStep/SemiStep.Core/Configuration/Loaders/GridStyleWriter.cs` (`Save` → `async Task SaveAsync`; `WriteAtomic` → async write + sync `File.Move`; `ReadLeadingCommentBlock` → async; keep the `GridStyleSaveFailedError.CausedBy(ex)` catch). +- Modify: `SemiStep/SemiStep.UI/StyleEditor/GridStyleEditorViewModel.cs` (`Save()` → `private async Task SaveAsync()`; `SaveCommand = ReactiveCommand.CreateFromTask(SaveAsync, this.WhenAnyValue(vm => vm.CanSave))`; keep the `ThrownExceptions.Subscribe(ReportSaveException)`, the `!CanSave` early-return, and the slice-7 `ErrorMessage`-localize + `LogCausedByExceptions` failure handling). +- Modify: the affected tests so the suite compiles and passes (see blast radius). + +- [x] `IGridStyleEditorFacade.Save(string, GridStyleOptions)` → `Task`. `Validate`/`Load` unchanged. +- [x] `GridStyleEditorFacade.Save` → `async Task`: `var validation = Validate(options); if (validation.IsFailed) return validation; return await _gridStyleWriter.SaveAsync(configDir, options);`. +- [x] `GridStyleWriter`: rename `Save` → `SaveAsync` returning `Task`; `await File.WriteAllTextAsync(tempPath, content, _utf8NoBom)` in `WriteAtomic` (make it `async Task`), `File.Move(..., overwrite: true)` stays sync in the try/catch; `ReadLeadingCommentBlock` → `async Task` via `File.ReadAllLinesAsync`. The `catch (Exception ex)` still returns `Result.Fail(new GridStyleSaveFailedError(Path.GetFileName(filePath)).CausedBy(ex))`. +- [x] `GridStyleEditorViewModel`: `Save()` → `async Task SaveAsync()` (`await _gridStyleEditorFacade.Save(...)`), `SaveCommand = ReactiveCommand.CreateFromTask(SaveAsync, …)`. `SaveCommand` type stays `ReactiveCommand`. Leave `RecomputeCanSave`/`Validate` sync. +- [x] **Test blast radius** (grep for `.Save(` / `SaveAsync` / `SaveCommand` AND for `IGridStyleEditorFacade` implementers — grepping callers alone misses the fake-facade *declarations*): + - **Fake-facade implementations** `ThrowingFacade` and `CausedByFailingFacade` (`GridStyleEditorViewModelTests.cs` ~:500/:518) — their `Save` declaration must become `Task`. Bodies: `CausedByFailingFacade.Save` wraps its return in `Task.FromResult(...)`; `ThrowingFacade.Save` keeps its synchronous `throw failure;` under the non-async `Task` signature — that synchronous throw is still captured into `SaveAsync`'s task and routed to `ThrownExceptions` (the whole fault-path test depends on it, so keep it a plain throw, not a rejected task). + - **Direct writer callers** — `GridStyleWriterTests.cs` has **7** `new GridStyleWriter().Save(...)` sites (~:33/:53/:70/:84/:112/:130/:148) and `GridStyleOrientationTests.cs` **2** (~:85/:98) → `await ...SaveAsync(...)`. `GridStyleEditorFacadeTests.cs:137` likewise — and its method `Save_WriteFailure_CarriesOriginalExceptionOnCausedBy` (~:131) becomes `async Task` (was `void`), staying under a plain `[Fact]`. (`Save_UnderCommaLocale…` at `GridStyleWriterTests.cs:138` sets `CurrentCulture` around the call — culture flows across awaits via `ExecutionContext`, and `Serialize` runs before the first await anyway, so it stays green; `Save_WhenMoveFails…` awaits inside a `using` file-lock — fine.) + - **Facade callers** — `GridStyleEditorFacadeTests` `facade.Save(...)` at ~:51/:64/:104 → `(await facade.Save(...))`; the `SaveThenLoad_DistinctFixture…` guard awaits the async facade `Save` (behavior-preserving — identical bytes, only threading changes). + - **VM/window SaveCommand tests (verify-only, no edit expected)** — the slice-7 log-sink + `ReportSaveException` tests already `await ExecuteSwallowing(SaveCommand)` / `await SaveCommand.Execute()` under `[AvaloniaFact]`; `GridStyleEditorWindowTests.cs:~43` and `GridStyleEditorWindowOwnerRoutingTests.cs:~135` already `await viewModel.SaveCommand.Execute()`. With `CreateFromTask` these awaits work identically (headless dispatcher pumps the output-scheduler post), so no edit is expected — but **run `GridStyleEditorWindowOwnerRoutingTests` explicitly**: it is the highest async-timing risk in the slice (async `CreateFromTask` execution → the `async void OnSaveCompleted` → `ShowDialog` modal → manual `Dispatcher.UIThread.RunJobs()`/owned-window timing); confirm the extra async hop before the `SaveCommand` subscription fires doesn't disturb the `RunJobs`/`OwnedWindows` sequence. +- [x] `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green. + +## Task 2: Verify + doc + +**Files:** `Docs/architecture/grid-style-configuration.md`. + +- [x] Confirm `GridStyleEditorWindow.axaml.cs` is untouched and its `OnSaveCompleted(bool)` + `ReportSaveException` fault path behave as before (the async command still yields `bool` and routes exceptions to `ThrownExceptions`). **Doc update is mandatory:** `Docs/architecture/grid-style-configuration.md:~233` states the facade seam as `Save(configDir, GridStyleOptions) → Result` — change it to `Task` and note `Save` is now async off the UI thread (mirroring `Load`), while `Validate` stays sync (per-keystroke). Note the `File.Move`-stays-sync atomic-write detail where the doc covers the writer. +- [x] full `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green; `dotnet format`. + +## Post-Completion + +**Next slices of #118:** (3) nest `GridStyleOptions` to mirror the DTO groups (+ shared `DepthPalette`), rewiring the two +mappers and the ~10 runtime consumers under slice 1's guards — the big line-count win, and the one slice that ripples +the consumers; (4) type colors as a `StyleColor` value type, fold color-validation into the load mapper, delete +`HexColor`; (5) split the VM into per-group drafts (property-initializer = seed, positional `Build()` = compile guard) + +grouped compiled-binding AXAML. #118 closes after slice 5. + +**Executed by exec:** +- branch: grid-style-async-save + +## Verify it yourself + +The change is behavior-preserving threading — the file written is byte-identical; only *when/where* the write runs moves +off the UI thread. No manual repro (the freeze it removes is imperceptible for a small YAML). Verify by the diff + tests: + +1. **The write path is async end to end:** `git show master..HEAD -- SemiStep/SemiStep.Core/Configuration/IGridStyleEditorFacade.cs .../GridStyleEditorFacade.cs .../Loaders/GridStyleWriter.cs` — `Save` returns `Task`, the facade `await`s `_gridStyleWriter.SaveAsync`, and `WriteAtomicAsync` `await`s `File.WriteAllTextAsync` (the disk write leaves the dispatcher there); `File.Move` stays sync. `Validate` is untouched (still sync, per-keystroke). +2. **The command is async, window unchanged:** `git show master..HEAD -- SemiStep/SemiStep.UI/StyleEditor/GridStyleEditorViewModel.cs` shows `SaveCommand = ReactiveCommand.CreateFromTask(SaveAsync, …)`, still `ReactiveCommand`; `GridStyleEditorWindow.axaml.cs` is absent from `git diff master...HEAD --name-only`. +3. **Behavior + fault path preserved:** `dotnet test SemiStep/SemiStep.Tests/SemiStep.Tests.csproj --filter "FullyQualifiedName~GridStyleEditor|FullyQualifiedName~GridStyleWriter|FullyQualifiedName~GridStyleOrientation"` — the round-trip guard (`SaveThenLoad_DistinctFixture…`, now awaiting the async Save) is green, the atomic-write/`File.Move`-failure test passes, and the save-fault tests (`ThrowingFacade` throw → `ReportSaveException`; `CausedByFailingFacade` → `LogCausedByExceptions`) still fire. +4. **Whole suite:** `dotnet build SemiStep.slnx` (0 warnings) and `dotnet test` (1687 passed, 0 failed).