From 0b814d8ed1e4ef02d8a2ec1b0bae431490c85ea5 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:23:17 +0300 Subject: [PATCH] feat(theme): apply a theme saved by any process An operator runs several windows, one process each, over one configuration directory. The theme is shared by every window, but a saved theme took effect only at the next start, and only in the process that saved it. Every process now watches the app/ section folder. A change is loaded once per quiet burst on the data scheduler and a changed theme is applied on the UI thread, so a theme saved by any window switches all of them within about two seconds. The watch also loads once when it starts, so a save made while a process was still starting reaches it. A buffer overflow reloads; any other watcher error, a refused or missing folder, is reported once and the folder is opened again every 5 s. A failed load reaches the window's message panel and keeps the theme. A theme-only save shows no restart notice, a save that also changes another key says the theme is already applied, and an open settings dialog follows the theme its window applied. Section files are read and copied with FileShare.ReadWrite | Delete, and the save probe shares the same way, so one process reading or saving never blocks another process's File.Replace. The lost update between two simultaneous saves is recorded in the backlog. Claude-Session: https://claude.ai/code/session_01PfZJa7neMMKPzycFR6iuhD --- CLAUDE.md | 3 +- .../Configuration/ConfigurationSection.cs | 16 +- .../ConfigurationSectionWriter.cs | 11 +- .../ConfigurationSectionTests.cs | 31 ++ .../ConfigurationSectionWriterTests.cs | 18 + .../UI/Settings/AppSectionWatcherTests.cs | 415 ++++++++++++++++++ .../UI/Settings/DirectoryAccessDenial.cs | 74 ++++ .../UI/Settings/DirectoryWriteDenial.cs | 49 --- .../UI/Settings/SettingsSaveTests.cs | 17 +- .../UI/Settings/SettingsViewModelTests.cs | 17 +- .../UI/Settings/SettingsViewTests.cs | 57 +++ .../UI/Startup/AppConfigurationTests.cs | 237 ++++++++++ .../UI/Startup/AppStateScope.cs | 20 +- SemiPlot/SemiPlot.UI/App.axaml.cs | 91 ++++ .../SemiPlot.UI/Localization/Resources.resx | 3 + .../Localization/Resources.ru.resx | 3 + .../SemiPlot.UI/Messages/ResultReporting.cs | 9 +- .../SemiPlot.UI/Settings/AppSectionWatcher.cs | 223 ++++++++++ .../SemiPlot.UI/Settings/SettingsDialog.axaml | 2 +- .../Settings/SettingsDialog.axaml.cs | 23 + SemiPlot/SemiPlot.UI/Settings/SettingsSave.cs | 9 +- .../SemiPlot.UI/Settings/SettingsViewModel.cs | 33 +- SemiPlot/SemiPlot.UI/Startup/AppSettings.cs | 6 + docs/architecture/bench.md | 4 +- docs/architecture/data-integration.md | 15 +- docs/architecture/overview.md | 94 +++- docs/architecture/testing-strategy.md | 13 + docs/architecture/ui-text.md | 3 +- docs/architecture/ui-theme.md | 8 + docs/plans/20260929-window-per-process.md | 135 ++++-- docs/plans/backlog.md | 9 + readme.md | 8 +- 32 files changed, 1523 insertions(+), 133 deletions(-) create mode 100644 SemiPlot/SemiPlot.Tests.Unit/UI/Settings/AppSectionWatcherTests.cs create mode 100644 SemiPlot/SemiPlot.Tests.Unit/UI/Settings/DirectoryAccessDenial.cs delete mode 100644 SemiPlot/SemiPlot.Tests.Unit/UI/Settings/DirectoryWriteDenial.cs create mode 100644 SemiPlot/SemiPlot.UI/Settings/AppSectionWatcher.cs diff --git a/CLAUDE.md b/CLAUDE.md index 693ab0a..f64befc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,8 @@ The settings window (`SemiPlot.UI/Settings`) is the viewer's only writer of the edits only keys that already exist, each in the file that owns it, and only the keys the operator changed; it creates no file, and the startup-failure window offers it only when both sections read and carry every key it edits. The production loaders validate a staged copy before any file is replaced, and every change takes effect at the -next start (`docs/architecture/overview.md#the-settings-window`). +next start except the theme, which every running process applies live +(`docs/architecture/overview.md#the-live-theme`). `.editorconfig`'s style and quality analyzer rules fail `dotnet build` (`TreatWarningsAsErrors`, `EnforceCodeStyleInBuild`) and `dotnet format SemiPlot.slnx --verify-no-changes` alike, so a diff --git a/SemiPlot/SemiPlot.Core/Configuration/ConfigurationSection.cs b/SemiPlot/SemiPlot.Core/Configuration/ConfigurationSection.cs index 290adc4..ed63881 100644 --- a/SemiPlot/SemiPlot.Core/Configuration/ConfigurationSection.cs +++ b/SemiPlot/SemiPlot.Core/Configuration/ConfigurationSection.cs @@ -128,7 +128,7 @@ private static Dictionary ScalarsOf(Dictionary { try { - var content = File.ReadAllText(file); + var content = ReadShared(file); var repeated = FindRepeatedKey(content); if (repeated is not null) @@ -146,6 +146,20 @@ private static Dictionary ScalarsOf(Dictionary } } + private static string ReadShared(string file) + { + using var stream = OpenShared(file); + using var reader = new StreamReader(stream); + + return reader.ReadToEnd(); + } + + /// Opens a section file for reading without blocking another process's File.Replace of it. + internal static FileStream OpenShared(string file) + { + return new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + } + // WithDuplicateKeyChecking refuses the file but names the key only inside its own message, and the // operator needs the key, so the top-level mapping is walked for it first. private static string? FindRepeatedKey(string content) diff --git a/SemiPlot/SemiPlot.Core/Configuration/ConfigurationSectionWriter.cs b/SemiPlot/SemiPlot.Core/Configuration/ConfigurationSectionWriter.cs index 2f910a5..2f126c3 100644 --- a/SemiPlot/SemiPlot.Core/Configuration/ConfigurationSectionWriter.cs +++ b/SemiPlot/SemiPlot.Core/Configuration/ConfigurationSectionWriter.cs @@ -163,7 +163,7 @@ private Result Copy(string file, string name) try { - content = File.ReadAllBytes(file); + content = ReadAllBytesShared(file); } catch (Exception exception) { @@ -184,6 +184,15 @@ private Result Copy(string file, string name) } } + private static byte[] ReadAllBytesShared(string file) + { + using var stream = ConfigurationSection.OpenShared(file); + using var content = new MemoryStream(); + stream.CopyTo(content); + + return content.ToArray(); + } + private Result Write(string name, string content) { try diff --git a/SemiPlot/SemiPlot.Tests.Unit/Core/Configuration/ConfigurationSectionTests.cs b/SemiPlot/SemiPlot.Tests.Unit/Core/Configuration/ConfigurationSectionTests.cs index 08862ae..9041155 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/Core/Configuration/ConfigurationSectionTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/Core/Configuration/ConfigurationSectionTests.cs @@ -145,6 +145,37 @@ public void AnEmptyFileContributesNothingAndFailsNothing() .Be(new KeyValuePair("locale", "ru")); } + [Fact] + public void AFileAnotherHandleHoldsOpenForWritingStillReads() + { + WriteFile("a.yaml", "locale: ru\n"); + using var writer = new FileStream( + Path.Combine(_directory, "a.yaml"), FileMode.Open, FileAccess.ReadWrite, FileShare.Read); + + var result = ConfigurationSection.Read(_directory, ConfigurationSectionName.App); + + result.IsSuccess.Should().BeTrue(Describe(result)); + MappingOf(result.Value).Should().Contain("locale", "ru"); + } + + [Fact] + public void AFileTheSectionReadHoldsOpenCanStillBeReplaced() + { + WriteFile("a.yaml", "locale: ru\n"); + var staged = Path.Combine(_directory, "a.yaml.staged"); + File.WriteAllText(staged, "locale: en\n"); + + using (ConfigurationSection.OpenShared(Path.Combine(_directory, "a.yaml"))) + { + File.Replace(staged, Path.Combine(_directory, "a.yaml"), destinationBackupFileName: null); + } + + var result = ConfigurationSection.Read(_directory, ConfigurationSectionName.App); + + result.IsSuccess.Should().BeTrue(Describe(result)); + MappingOf(result.Value).Should().Contain("locale", "en"); + } + [Fact] public void AFileWrittenWithAByteOrderMarkStillContributesItsFirstKey() { diff --git a/SemiPlot/SemiPlot.Tests.Unit/Core/Configuration/ConfigurationSectionWriterTests.cs b/SemiPlot/SemiPlot.Tests.Unit/Core/Configuration/ConfigurationSectionWriterTests.cs index 0e1c9b1..9e03e2e 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/Core/Configuration/ConfigurationSectionWriterTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/Core/Configuration/ConfigurationSectionWriterTests.cs @@ -188,6 +188,24 @@ public void ASectionFolderRemovedAfterTheReadFailsWithUnlistable() error.Directory.Should().Be(_section); } + [Fact] + public void AFileAnotherSavesWriteProbeHoldsOpenIsStillCopied() + { + WriteFile("a.yaml", "locale: ru\n"); + WriteFile("b.yaml", "theme: light\n"); + var owned = OwnedOf(ConfigurationSectionName.App); + var held = Path.Combine(_section, "b.yaml"); + Result> staged; + + using (new FileStream(held, FileMode.Open, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete)) + { + staged = Stage(owned, ConfigurationSectionName.App, ("locale", "en")); + } + + staged.IsSuccess.Should().BeTrue(Describe(staged)); + File.ReadAllBytes(Path.Combine(_staging, "b.yaml")).Should().Equal(File.ReadAllBytes(held)); + } + [Fact] public void ACopyThatCannotBeWrittenFailsWithUnwritableNamingTheFile() { diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/AppSectionWatcherTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/AppSectionWatcherTests.cs new file mode 100644 index 0000000..45d32c3 --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/AppSectionWatcherTests.cs @@ -0,0 +1,415 @@ +using System.ComponentModel; +using System.Reactive.Concurrency; +using System.Reactive.Subjects; + +using AwesomeAssertions; + +using FluentResults; + +using Microsoft.Reactive.Testing; + +using SemiPlot.UI.Settings; +using SemiPlot.UI.Startup; + +using Xunit; + +namespace SemiPlot.Tests.Unit.UI.Settings; + +[Trait("Component", "UI")] +[Trait("Area", "Di")] +[Trait("Category", "Unit")] +public sealed class AppSectionWatcherTests : IDisposable +{ + private static readonly TimeSpan _tick = TimeSpan.FromTicks(1); + + private readonly TestScheduler _dataScheduler = new(); + private readonly TestScheduler _uiScheduler = new(); + private readonly Subject _fileEvents = new(); + private readonly List> _emitted = []; + private Result _onDisk = Result.Ok(new AppSettings(UiLanguage.Ru, AppThemeVariant.Light)); + private Exception? _loadFailure; + private int _loads; + private int _opens; + private IDisposable? _subscription; + + public void Dispose() + { + _subscription?.Dispose(); + _fileEvents.Dispose(); + } + + [Fact] + public void AThemeChangeOnDiskAppliesTheTheme() + { + Watch(appliedTheme: AppThemeVariant.Light); + _onDisk = Result.Ok(new AppSettings(UiLanguage.Ru, AppThemeVariant.Dark)); + + _fileEvents.OnNext(Result.Ok()); + _dataScheduler.AdvanceBy(AppSectionWatcher.QuietPeriod.Ticks); + + _loads.Should().Be(1, "the load runs on the data scheduler"); + _emitted.Should().BeEmpty("the theme reaches the application on the UI scheduler"); + + _uiScheduler.AdvanceBy(_tick.Ticks); + + _emitted.Should().ContainSingle().Which.Value.Should().Be(AppThemeVariant.Dark); + } + + [Fact] + public void ALanguageChangeOnDiskAppliesNothing() + { + Watch(appliedTheme: AppThemeVariant.Light); + _onDisk = Result.Ok(new AppSettings(UiLanguage.En, AppThemeVariant.Light)); + + Signal(); + + _loads.Should().Be(1); + _emitted.Should().BeEmpty(); + } + + [Fact] + public void TheSameThemeTwiceAppliesOnce() + { + Watch(appliedTheme: AppThemeVariant.Light); + _onDisk = Result.Ok(new AppSettings(UiLanguage.Ru, AppThemeVariant.Dark)); + + Signal(); + Signal(); + + _loads.Should().Be(2); + _emitted.Should().ContainSingle().Which.Value.Should().Be(AppThemeVariant.Dark); + } + + [Fact] + public void AStartWithoutSettingsAppliesTheFirstThemeItLoads() + { + Watch(appliedTheme: null); + + Signal(); + + _emitted.Should().ContainSingle().Which.Value.Should().Be(AppThemeVariant.Light); + } + + [Fact] + public void AThemeSavedBeforeTheWatchStartsIsAppliedAfterOneQuietPeriod() + { + _onDisk = Result.Ok(new AppSettings(UiLanguage.Ru, AppThemeVariant.Dark)); + Watch(appliedTheme: AppThemeVariant.Light); + + AdvanceBoth(); + + _loads.Should().Be(1, "the save landed between the start's read and the watcher, so no event reports it"); + _emitted.Should().ContainSingle().Which.Value.Should().Be(AppThemeVariant.Dark); + } + + [Fact] + public void AStartWithoutSettingsLoadsNothingBeforeAFileEvent() + { + Watch(appliedTheme: null); + + AdvanceBoth(); + + _loads.Should().Be(0, "the start has already reported the section it could not load"); + } + + [Fact] + public void ABurstOfEventsCollapsesToOneLoad() + { + Watch(appliedTheme: AppThemeVariant.Light); + var withinQuietPeriod = AppSectionWatcher.QuietPeriod - _tick; + + for (var index = 0; index < 5; index++) + { + _fileEvents.OnNext(Result.Ok()); + _dataScheduler.AdvanceBy(withinQuietPeriod.Ticks); + } + + _loads.Should().Be(0, "each event restarts the quiet period"); + + _dataScheduler.AdvanceBy(_tick.Ticks); + + _loads.Should().Be(1); + } + + [Fact] + public void AFailedLoadReachesTheReportAndKeepsTheAppliedTheme() + { + Watch(appliedTheme: AppThemeVariant.Light); + var failure = new AppSettingsError("app", AppSettingsProblem.KeyMissing, AppSettingsLoader.ThemeKey); + _onDisk = Result.Fail(failure); + + Signal(); + + _emitted.Should().ContainSingle().Which.Errors.Should().ContainSingle().Which.Should().BeSameAs(failure); + + _onDisk = Result.Ok(new AppSettings(UiLanguage.Ru, AppThemeVariant.Light)); + Signal(); + + _emitted.Should().ContainSingle("the theme on disk is the one already applied"); + } + + [Fact] + public void ALoadThatThrowsIsEmittedAsAFailureAndTheWatchGoesOn() + { + Watch(appliedTheme: AppThemeVariant.Light); + var thrown = new IOException("The section read threw."); + _loadFailure = thrown; + + Signal(); + + _emitted.Should().ContainSingle().Which.Errors.Should().ContainSingle() + .Which.Should().BeOfType().Which.Exception.Should().BeSameAs(thrown); + + _loadFailure = null; + _onDisk = Result.Ok(new AppSettings(UiLanguage.Ru, AppThemeVariant.Dark)); + Signal(); + + _emitted.Should().HaveCount(2).And.Subject.Last().Value.Should().Be(AppThemeVariant.Dark); + } + + [Fact] + public void ABufferOverflowReloadsOnce() + { + using var watcher = new RaisingWatcher(); + WatchThrough(Opening(Opened(watcher))); + _onDisk = Result.Ok(new AppSettings(UiLanguage.Ru, AppThemeVariant.Dark)); + + watcher.RaiseError(new InternalBufferOverflowException()); + AdvanceBoth(); + + _loads.Should().Be(1); + _opens.Should().Be(1, "an overflow loses events, not the watcher"); + _emitted.Should().ContainSingle().Which.Value.Should().Be(AppThemeVariant.Dark); + } + + [Fact] + public void AStoppedWatcherIsReportedOnceAndItsWatchOpensAgain() + { + using var stopped = new RaisingWatcher(); + using var reopened = new RaisingWatcher(); + var networkNameDeleted = new Win32Exception(64); + WatchThrough(Opening(Opened(stopped), Result.Fail("The share is still gone."), Opened(reopened))); + AdvanceBoth(); + + stopped.RaiseError(networkNameDeleted); + AdvanceBoth(); + + _emitted.Should().ContainSingle().Which.Errors.Should().ContainSingle() + .Which.Should().BeOfType().Which.Exception.Should().BeSameAs(networkNameDeleted); + stopped.IsDisposed.Should().BeTrue(); + + _dataScheduler.AdvanceBy(AppSectionWatcher.ReopenInterval.Ticks); + AdvanceBoth(); + + _emitted.Should().ContainSingle("the outage is reported once"); + + _onDisk = Result.Ok(new AppSettings(UiLanguage.Ru, AppThemeVariant.Dark)); + _dataScheduler.AdvanceBy(AppSectionWatcher.ReopenInterval.Ticks); + AdvanceBoth(); + + _opens.Should().Be(3); + _loads.Should().Be(2, "the watch opened again loads once for the events it missed"); + _emitted.Should().HaveCount(2).And.Subject.Last().Value.Should().Be(AppThemeVariant.Dark); + } + + [Fact] + public void ARefusedOpenIsReportedOnceAndOpenedAgain() + { + using var watcher = new RaisingWatcher(); + var refusal = new UnauthorizedAccessException("The folder cannot be listed."); + WatchThrough(Opening( + Result.Fail(new ExceptionalError(refusal)), + Result.Fail(new ExceptionalError(refusal)), + Opened(watcher))); + AdvanceBoth(); + + _emitted.Should().ContainSingle().Which.Errors.Should().ContainSingle() + .Which.Should().BeOfType().Which.Exception.Should().BeSameAs(refusal); + + _dataScheduler.AdvanceBy(AppSectionWatcher.ReopenInterval.Ticks); + _onDisk = Result.Ok(new AppSettings(UiLanguage.Ru, AppThemeVariant.Dark)); + _dataScheduler.AdvanceBy(AppSectionWatcher.ReopenInterval.Ticks); + AdvanceBoth(); + + _opens.Should().Be(3); + _emitted.Should().HaveCount(2).And.Subject.Last().Value.Should().Be(AppThemeVariant.Dark); + } + + [Fact] + public void EveryWatcherEventReachesTheStream() + { + using var watcher = new RaisingWatcher(); + var events = 0; + using var subscription = new AppSectionWatcher(watcher).Events.Subscribe(_ => events++); + + watcher.RaiseEveryFileEvent(); + + events.Should().Be(4); + } + + [Fact] + public async Task AReplacedFileInARealFolderYieldsTheNewTheme() + { + var configDirectory = Directory.CreateTempSubdirectory("semiplot-app-watcher-").FullName; + try + { + var sectionDirectory = Directory.CreateDirectory(Path.Combine(configDirectory, "app")).FullName; + var stagingDirectory = Directory.CreateDirectory(Path.Combine(configDirectory, "staging")).FullName; + var target = Path.Combine(sectionDirectory, "app.yaml"); + var staged = Path.Combine(stagingDirectory, "app.yaml"); + File.WriteAllText(target, "locale: ru\ntheme: light\n"); + File.WriteAllText(staged, "locale: ru\ntheme: dark\n"); + var applied = new TaskCompletionSource>( + TaskCreationOptions.RunContinuationsAsynchronously); + + using var subscription = AppSectionWatcher.ThemeChanges( + AppSectionWatcher.Watch(() => AppSectionWatcher.Open(sectionDirectory), DefaultScheduler.Instance), + AppThemeVariant.Light, + () => AppSettingsLoader.Load(sectionDirectory), + DefaultScheduler.Instance, + ImmediateScheduler.Instance) + .Subscribe(theme => applied.TrySetResult(theme)); + + File.Replace(staged, target, Path.Combine(stagingDirectory, "app.yaml.replaced")); + + var theme = await applied.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + theme.IsSuccess.Should().BeTrue(string.Join("; ", theme.Errors.Select(error => error.Message))); + theme.Value.Should().Be(AppThemeVariant.Dark); + } + finally + { + Directory.Delete(configDirectory, recursive: true); + } + } + + [Fact] + public async Task AnAppFolderDeletedAndRecreatedIsWatchedAgain() + { + // Linux raises nothing when the watched folder itself goes: docs/architecture/overview.md#the-live-theme + if (!OperatingSystem.IsWindows()) + { + return; + } + + var configDirectory = Directory.CreateTempSubdirectory("semiplot-app-watcher-").FullName; + try + { + var sectionDirectory = Directory.CreateDirectory(Path.Combine(configDirectory, "app")).FullName; + var file = Path.Combine(sectionDirectory, "app.yaml"); + File.WriteAllText(file, "locale: ru\ntheme: light\n"); + var stopped = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var dark = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var subscription = AppSectionWatcher.ThemeChanges( + AppSectionWatcher.Watch(() => AppSectionWatcher.Open(sectionDirectory), DefaultScheduler.Instance), + AppThemeVariant.Light, + () => AppSettingsLoader.Load(sectionDirectory), + DefaultScheduler.Instance, + ImmediateScheduler.Instance) + .Subscribe(theme => + { + if (theme.IsFailed && theme.Errors.OfType().Any(error => error.Exception is Win32Exception)) + { + stopped.TrySetResult(); + } + else if (theme.IsSuccess && theme.Value == AppThemeVariant.Dark) + { + dark.TrySetResult(); + } + }); + + Directory.Delete(sectionDirectory, recursive: true); + await stopped.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + await Task.Delay(AppSectionWatcher.QuietPeriod * 3, TestContext.Current.CancellationToken); + Directory.CreateDirectory(sectionDirectory); + File.WriteAllText(file, "locale: ru\ntheme: dark\n"); + + await dark.Task.WaitAsync( + AppSectionWatcher.ReopenInterval + TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + } + finally + { + Directory.Delete(configDirectory, recursive: true); + } + } + + private void Watch(AppThemeVariant? appliedTheme) + { + _subscription = AppSectionWatcher.ThemeChanges(_fileEvents, appliedTheme, Load, _dataScheduler, _uiScheduler) + .Subscribe(_emitted.Add); + } + + private void WatchThrough(Func> open) + { + _subscription = AppSectionWatcher.ThemeChanges( + AppSectionWatcher.Watch(open, _dataScheduler), AppThemeVariant.Light, Load, _dataScheduler, _uiScheduler) + .Subscribe(_emitted.Add); + } + + private Func> Opening(params Result[] opens) + { + var pending = new Queue>(opens); + + return () => + { + _opens++; + + return pending.Dequeue(); + }; + } + + private static Result Opened(FileSystemWatcher watcher) + { + return Result.Ok(new AppSectionWatcher(watcher)); + } + + private Result Load() + { + _loads++; + + if (_loadFailure is { } failure) + { + throw failure; + } + + return _onDisk; + } + + private void Signal() + { + _fileEvents.OnNext(Result.Ok()); + AdvanceBoth(); + } + + private void AdvanceBoth() + { + _dataScheduler.AdvanceBy(AppSectionWatcher.QuietPeriod.Ticks); + _uiScheduler.AdvanceBy(_tick.Ticks); + } + + private sealed class RaisingWatcher : FileSystemWatcher + { + public bool IsDisposed { get; private set; } + + public void RaiseError(Exception error) + { + OnError(new ErrorEventArgs(error)); + } + + public void RaiseEveryFileEvent() + { + const string SectionDirectory = "app"; + + OnChanged(new FileSystemEventArgs(WatcherChangeTypes.Changed, SectionDirectory, "app.yaml")); + OnCreated(new FileSystemEventArgs(WatcherChangeTypes.Created, SectionDirectory, "app.yaml")); + OnDeleted(new FileSystemEventArgs(WatcherChangeTypes.Deleted, SectionDirectory, "app.yaml")); + OnRenamed(new RenamedEventArgs(WatcherChangeTypes.Renamed, SectionDirectory, "app.yaml", "app.yaml.tmp")); + } + + protected override void Dispose(bool disposing) + { + IsDisposed = true; + base.Dispose(disposing); + } + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/DirectoryAccessDenial.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/DirectoryAccessDenial.cs new file mode 100644 index 0000000..1355e32 --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/DirectoryAccessDenial.cs @@ -0,0 +1,74 @@ +using System.Security.AccessControl; +using System.Security.Principal; + +namespace SemiPlot.Tests.Unit.UI.Settings; + +/// Refuses one kind of access to a directory until disposed. +internal sealed class DirectoryAccessDenial : IDisposable +{ + private const UnixFileMode FullAccess = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + + private readonly string _directory; + private readonly FileSystemAccessRule? _denyRule; + + // Windows ignores the read-only attribute on a folder, so the refusal is an access rule there. + private DirectoryAccessDenial(string directory, DeniedAccess denied) + { + _directory = directory; + + if (OperatingSystem.IsWindows()) + { + var rights = denied == DeniedAccess.NewFolders + ? FileSystemRights.CreateDirectories + : FileSystemRights.ListDirectory; + var user = WindowsIdentity.GetCurrent().User!; + _denyRule = new FileSystemAccessRule(user, rights, AccessControlType.Deny); + var info = new DirectoryInfo(directory); + var security = info.GetAccessControl(); + security.AddAccessRule(_denyRule); + info.SetAccessControl(security); + } + else + { + File.SetUnixFileMode(directory, FullAccess & ~(denied == DeniedAccess.NewFolders + ? UnixFileMode.UserWrite + : UnixFileMode.UserRead)); + } + } + + private enum DeniedAccess + { + NewFolders, + Listing + } + + /// Refuses new folders inside the directory. + public static DirectoryAccessDenial OfNewFolders(string directory) + { + return new DirectoryAccessDenial(directory, DeniedAccess.NewFolders); + } + + /// Refuses listing the directory, while it still answers that it exists. + public static DirectoryAccessDenial OfListing(string directory) + { + return new DirectoryAccessDenial(directory, DeniedAccess.Listing); + } + + public void Dispose() + { + if (OperatingSystem.IsWindows()) + { + if (_denyRule is not null) + { + var info = new DirectoryInfo(_directory); + var security = info.GetAccessControl(); + security.RemoveAccessRule(_denyRule); + info.SetAccessControl(security); + } + } + else + { + File.SetUnixFileMode(_directory, FullAccess); + } + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/DirectoryWriteDenial.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/DirectoryWriteDenial.cs deleted file mode 100644 index 778552f..0000000 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/DirectoryWriteDenial.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Security.AccessControl; -using System.Security.Principal; - -namespace SemiPlot.Tests.Unit.UI.Settings; - -/// Refuses new folders inside a directory until disposed. -internal sealed class DirectoryWriteDenial : IDisposable -{ - private readonly string _directory; - private readonly FileSystemAccessRule? _denyRule; - - // Windows ignores the read-only attribute on a folder, so the refusal is an access rule there. - public DirectoryWriteDenial(string directory) - { - _directory = directory; - - if (OperatingSystem.IsWindows()) - { - var user = WindowsIdentity.GetCurrent().User!; - _denyRule = new FileSystemAccessRule(user, FileSystemRights.CreateDirectories, AccessControlType.Deny); - var info = new DirectoryInfo(directory); - var security = info.GetAccessControl(); - security.AddAccessRule(_denyRule); - info.SetAccessControl(security); - } - else - { - File.SetUnixFileMode(directory, UnixFileMode.UserRead | UnixFileMode.UserExecute); - } - } - - public void Dispose() - { - if (OperatingSystem.IsWindows()) - { - if (_denyRule is not null) - { - var info = new DirectoryInfo(_directory); - var security = info.GetAccessControl(); - security.RemoveAccessRule(_denyRule); - info.SetAccessControl(security); - } - } - else - { - File.SetUnixFileMode(_directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); - } - } -} diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsSaveTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsSaveTests.cs index ec7ebd2..57a3e18 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsSaveTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsSaveTests.cs @@ -141,7 +141,7 @@ public void AConfigDirectoryThatRefusesTheStagingFolderFailsTheSave() var before = _sandbox.Snapshot(); Result result; - using (new DirectoryWriteDenial(_sandbox.ConfigDirectory)) + using (DirectoryAccessDenial.OfNewFolders(_sandbox.ConfigDirectory)) { result = _sandbox.Save(Edit(ConfigurationSectionName.App, ("theme", "dark"))); } @@ -225,6 +225,21 @@ public void AReplaceRefusedAfterTheProbeFailsWithUnwritableNamingTheFile() _sandbox.ShouldMatch(before); } + [Fact] + public void ASaveReplacesATargetAnotherSavesWriteProbeHoldsOpen() + { + var target = _sandbox.ConnectionFile; + Result result; + + using (new FileStream(target, FileMode.Open, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete)) + { + result = _sandbox.Save(Edit(ConfigurationSectionName.Connection, ("host", "10.20.30.40"))); + } + + result.IsSuccess.Should().BeTrue(Describe(result)); + _sandbox.LoadConnection().Host.Should().Be("10.20.30.40"); + } + [Fact] public void ARefusalKeepsTheLoadersCause() { diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewModelTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewModelTests.cs index 1f2f75d..e26a7cf 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewModelTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewModelTests.cs @@ -225,11 +225,24 @@ public async Task ChangingOnlyTheThemeLeavesTheConnectionFileByteIdentical() await viewModel.SaveCommand.Execute(); _messagePanel.Entries.Should().BeEmpty(); - viewModel.IsRestartPending.Should().BeTrue("a saved theme takes effect at the next start"); AppSettingsLoader.Load(_appDirectory).Value.Theme.Should().Be(AppThemeVariant.Dark); File.ReadAllBytes(Path.Combine(_connectionDirectory, "connection.yaml")).Should().Equal(before); } + [AvaloniaFact] + public async Task AThemeOnlySaveSetsNoRestartNotice() + { + ShippedConfiguration.FillPassword(_configDirectory, "secret"); + using var viewModel = Build(); + viewModel.SelectedTheme = viewModel.Themes.Single(choice => choice.Token == "dark"); + + await viewModel.SaveCommand.Execute(); + + _messagePanel.Entries.Should().BeEmpty(); + viewModel.IsRestartPending.Should().BeFalse("every process applies a saved theme live"); + AppSettingsLoader.Load(_appDirectory).Value.Theme.Should().Be(AppThemeVariant.Dark); + } + [AvaloniaFact] public async Task ALanguageSaveSetsTheRestartNotice() { @@ -241,6 +254,7 @@ public async Task ALanguageSaveSetsTheRestartNotice() await viewModel.SaveCommand.Execute(); viewModel.IsRestartPending.Should().BeTrue(); + viewModel.RestartNotice.Should().Be(Resources.SettingsRestartNotice); } [AvaloniaFact] @@ -255,6 +269,7 @@ public async Task AThemeAndLanguageSaveSetsTheRestartNotice() await viewModel.SaveCommand.Execute(); viewModel.IsRestartPending.Should().BeTrue(); + viewModel.RestartNotice.Should().Be(Resources.SettingsRestartNoticeThemeApplied, "the theme half applies live"); } [AvaloniaFact] diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewTests.cs index d553dba..d16fd2f 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Settings/SettingsViewTests.cs @@ -1,3 +1,6 @@ +using System.Reactive.Linq; + +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Headless.XUnit; @@ -183,6 +186,7 @@ public async Task TheRestartNotice_IsHiddenBeforeASaveAndVisibleAfterOneThatSucc await HeadlessWait.Until(() => viewModel.IsRestartPending); notice.IsEffectivelyVisible.Should().BeTrue(); + notice.Text.Should().Be(Resources.SettingsRestartNotice); _messagePanel.Entries.Should().BeEmpty(); AppSettingsLoader.Load(_appDirectory).Value.Locale.Should().Be(UiLanguage.En); } @@ -192,6 +196,31 @@ public async Task TheRestartNotice_IsHiddenBeforeASaveAndVisibleAfterOneThatSucc } } + [AvaloniaFact] + public async Task TheRestartNotice_AfterASaveThatAlsoChangedTheTheme_SaysTheThemeIsApplied() + { + using var viewModel = Build(); + var dialog = Realise(viewModel); + try + { + var notice = Named(dialog, "SettingsRestartNotice"); + var otherLanguage = viewModel.Languages.First(choice => choice.Token != viewModel.SelectedLanguage!.Token); + var otherTheme = viewModel.Themes.First(choice => choice.Token != viewModel.SelectedTheme!.Token); + Pick(dialog, Named(dialog, "SettingsLanguage"), otherLanguage); + Pick(dialog, Named(dialog, "SettingsTheme"), otherTheme); + + HeadlessInput.Click(dialog, Named