From c9523d095346112df4a128f7fb74f5f027a3f623 Mon Sep 17 00:00:00 2001 From: doyaGu Date: Sat, 3 Oct 2026 08:59:40 -0400 Subject: [PATCH] fix(export): write through the storage provider when the target has no local path The save picker used to return only TryGetLocalPath(), so a target without a local path looked like a cancel and nothing was written. The dialog service now hands back an opener for the chosen file. The picker call is inside the error handler, and the workspace is read before the target is opened, so a failed read leaves an existing file untouched. --- .../TestUtilities/ScriptedDialogService.cs | 6 +++-- .../ViewModels/TagsAndSearchViewModelTests.cs | 17 +++++++++++++ YKanBan/ViewModels/Dialogs/IDialogService.cs | 8 ++++-- YKanBan/ViewModels/WorkspaceViewModel.cs | 18 +++++++------ YKanBan/Views/DialogService.cs | 25 +++++++++++++++++-- 5 files changed, 61 insertions(+), 13 deletions(-) diff --git a/YKanBan.Tests/TestUtilities/ScriptedDialogService.cs b/YKanBan.Tests/TestUtilities/ScriptedDialogService.cs index 8562ca2..3956b06 100644 --- a/YKanBan.Tests/TestUtilities/ScriptedDialogService.cs +++ b/YKanBan.Tests/TestUtilities/ScriptedDialogService.cs @@ -27,10 +27,12 @@ internal sealed class ScriptedDialogService : IDialogService return result ?? false; } - public Task PickSaveFilePathAsync(string suggestedFileName) + public Task>?> PickSaveFileAsync(string suggestedFileName) { SuggestedFileNames.Add(suggestedFileName); - return Task.FromResult(SaveFilePath); + string? path = SaveFilePath; + return Task.FromResult>?>( + path is null ? null : () => Task.FromResult(File.Create(path))); } /// Answers every confirmation dialog with the given choice. diff --git a/YKanBan.Tests/ViewModels/TagsAndSearchViewModelTests.cs b/YKanBan.Tests/ViewModels/TagsAndSearchViewModelTests.cs index ca638bb..dbebe41 100644 --- a/YKanBan.Tests/ViewModels/TagsAndSearchViewModelTests.cs +++ b/YKanBan.Tests/ViewModels/TagsAndSearchViewModelTests.cs @@ -414,5 +414,22 @@ public class TagsAndSearchViewModelTests Assert.IsTrue(message.HasDetails); } + [TestMethod] + public async Task ExportReadFailureIsReportedAndLeavesTheTargetUntouched() + { + using var fixture = new Fixture(); + WorkspaceViewModel workspace = fixture.CreateWorkspace(); + string path = Path.Combine(fixture.Directory.FullPath, "out.md"); + File.WriteAllText(path, "previous"); + fixture.Dialogs.SaveFilePath = path; + + fixture.Repository.Dispose(); + await workspace.ExportCommand.ExecuteAsync(null); + + var message = (MessageDialogViewModel)fixture.Dialogs.Shown.Single(); + Assert.AreEqual(Resources.Export_Failed_Title, message.Title); + Assert.AreEqual("previous", File.ReadAllText(path)); + } + #endregion } diff --git a/YKanBan/ViewModels/Dialogs/IDialogService.cs b/YKanBan/ViewModels/Dialogs/IDialogService.cs index 0be1ec6..8e1a156 100644 --- a/YKanBan/ViewModels/Dialogs/IDialogService.cs +++ b/YKanBan/ViewModels/Dialogs/IDialogService.cs @@ -20,8 +20,12 @@ public interface IDialogService /// Shows the system save-file dialog for a Markdown file. /// /// The proposed file name. - /// The chosen local path, or when the user cancelled. - Task PickSaveFilePathAsync(string suggestedFileName); + /// + /// A function opening the chosen file for writing, emptied first, or + /// when the user cancelled. Nothing is created or + /// truncated until the function is called. + /// + Task>?> PickSaveFileAsync(string suggestedFileName); } /// diff --git a/YKanBan/ViewModels/WorkspaceViewModel.cs b/YKanBan/ViewModels/WorkspaceViewModel.cs index 8343658..3a594bf 100644 --- a/YKanBan/ViewModels/WorkspaceViewModel.cs +++ b/YKanBan/ViewModels/WorkspaceViewModel.cs @@ -96,18 +96,22 @@ public sealed partial class WorkspaceViewModel : ViewModelBase private async Task ExportAsync() { DateTimeOffset exportedAt = DateTimeOffset.Now; - string? path = await _dialogs.PickSaveFilePathAsync(MarkdownExporter.BuildFileName(WorkspaceName, exportedAt)); - if (path is null) - { - return; - } - try { + Func>? openTarget = + await _dialogs.PickSaveFileAsync(MarkdownExporter.BuildFileName(WorkspaceName, exportedAt)); + if (openTarget is null) + { + return; + } + + // Read everything before opening the target, so a failed read leaves an existing file untouched. WorkspaceRepository repository = Session.Repository; string markdown = MarkdownExporter.Export( WorkspaceName, repository.GetColumns(), repository.GetCards(), repository.GetTagsWithUsage(), exportedAt); - await File.WriteAllTextAsync(path, markdown, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + await using Stream stream = await openTarget(); + await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + await writer.WriteAsync(markdown); } catch (Exception exception) { diff --git a/YKanBan/Views/DialogService.cs b/YKanBan/Views/DialogService.cs index ec54cba..e88d5cb 100644 --- a/YKanBan/Views/DialogService.cs +++ b/YKanBan/Views/DialogService.cs @@ -52,7 +52,7 @@ public sealed class DialogService : IDialogService } /// - public async Task PickSaveFilePathAsync(string suggestedFileName) + public async Task>?> PickSaveFileAsync(string suggestedFileName) { if (!_owners.TryPeek(out Window? owner)) { @@ -69,7 +69,28 @@ public sealed class DialogService : IDialogService new FilePickerFileType(YKanBan.Resources.Export_FileTypeMarkdown) { Patterns = ["*.md"] }, ], }); - return file?.TryGetLocalPath(); + if (file is null) + { + return null; + } + + // A local file is replaced through the file system; a target without a + // local path (a sandboxed or virtual location) is written through the + // storage provider, emptied first because it may already have content. + string? localPath = file.TryGetLocalPath(); + if (localPath is not null) + { + return () => Task.FromResult(new FileStream(localPath, FileMode.Create, FileAccess.Write)); + } + return async () => + { + Stream stream = await file.OpenWriteAsync(); + if (stream.CanSeek) + { + stream.SetLength(0); + } + return stream; + }; } ///