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.
This commit is contained in:
doyaGu committed 2026-10-03 08:59:40 -04:00
1 parent f9f0e71453
commit c9523d0953
5 files changed
+61 -13

No files matched your search

@@ -27,10 +27,12 @@ internal sealed class ScriptedDialogService : IDialogService
return result ?? false;
}
public Task<string?> PickSaveFilePathAsync(string suggestedFileName)
public Task<Func<Task<Stream>>?> PickSaveFileAsync(string suggestedFileName)
{
SuggestedFileNames.Add(suggestedFileName);
return Task.FromResult(SaveFilePath);
string? path = SaveFilePath;
return Task.FromResult<Func<Task<Stream>>?>(
path is null ? null : () => Task.FromResult<Stream>(File.Create(path)));
}
/// <summary>Answers every confirmation dialog with the given choice.</summary>
@@ -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
}
+6 -2
View File
@@ -20,8 +20,12 @@ public interface IDialogService
/// Shows the system save-file dialog for a Markdown file.
/// </summary>
/// <param name="suggestedFileName">The proposed file name.</param>
/// <returns>The chosen local path, or <see langword="null"/> when the user cancelled.</returns>
Task<string?> PickSaveFilePathAsync(string suggestedFileName);
/// <returns>
/// A function opening the chosen file for writing, emptied first, or
/// <see langword="null"/> when the user cancelled. Nothing is created or
/// truncated until the function is called.
/// </returns>
Task<Func<Task<Stream>>?> PickSaveFileAsync(string suggestedFileName);
}
/// <summary>
+11 -7
View File
@@ -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<Task<Stream>>? 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)
{
+23 -2
View File
@@ -52,7 +52,7 @@ public sealed class DialogService : IDialogService
}
/// <inheritdoc/>
public async Task<string?> PickSaveFilePathAsync(string suggestedFileName)
public async Task<Func<Task<Stream>>?> 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<Stream>(new FileStream(localPath, FileMode.Create, FileAccess.Write));
}
return async () =>
{
Stream stream = await file.OpenWriteAsync();
if (stream.CanSeek)
{
stream.SetLength(0);
}
return stream;
};
}
/// <summary>