Added progress messages and quick access to exported files (#1112)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-25 22:10:38 +01:00
1 parent 7f72da628e
commit e0d8e18f34
33 files changed
+1130 -23

No files matched your search

@@ -0,0 +1,32 @@
using AIStudio.Components;
namespace AIStudio.Tools;
/// <summary>
/// The notification that a file is being exported.
/// </summary>
/// <remarks>
/// It stays until the export ends, however long that takes, since a timeout would claim an end which
/// has not happened. Closing it is allowed: the export goes on all the same.
/// </remarks>
/// <param name="Key">The key of the export, shared with its success notification, which replaces this one.</param>
/// <param name="FilePath">The file the export writes.</param>
public readonly record struct DataFileExportRunningMessage(string Key, string FilePath)
{
public void Show(ISnackbar snackbar)
{
var parameters = new Dictionary<string, object>
{
[nameof(FileExportRunningMessage.FileName)] = Path.GetFileName(this.FilePath)
};
snackbar.Add<FileExportRunningMessage>(parameters, Severity.Info, config =>
{
config.HideIcon = true;
config.RequireInteraction = true;
config.HideTransitionDuration = 600;
}, this.Key);
}
public void Hide(ISnackbar snackbar) => snackbar.RemoveByKey(this.Key);
}
@@ -0,0 +1,44 @@
using AIStudio.Components;
namespace AIStudio.Tools;
/// <summary>
/// The notification that a file was exported, with the ways to reach it.
/// </summary>
/// <remarks>
/// By default, it stays until the user closes it, also after they took one of its ways: whoever exports
/// a file wants to get to it, often in both ways, and that may well happen later than a timeout would
/// allow. Users who prefer it otherwise let it close like other messages, see
/// DataApp.CloseExportMessagesAutomatically.
/// </remarks>
/// <param name="Key">The key of the export. The notification that the export runs has the same one and gets replaced.</param>
/// <param name="Icon">The icon of the notification.</param>
/// <param name="Message">What was exported.</param>
/// <param name="FilePath">The file which was written.</param>
/// <param name="OffersOpening">Whether the file may be opened with the default program. False for an
/// archive, which that program would only unpack, at least on macOS.</param>
public readonly record struct DataFileExportSuccessMessage(string Key, string Icon, string Message, string FilePath, bool OffersOpening)
{
/// <param name="snackbar">The notifications of the window.</param>
/// <param name="closesAutomatically">Whether the notification closes after a few seconds, like other success messages.</param>
public void Show(ISnackbar snackbar, bool closesAutomatically)
{
var parameters = new Dictionary<string, object>
{
[nameof(FileExportSuccessMessage.Message)] = this.Message,
[nameof(FileExportSuccessMessage.FilePath)] = this.FilePath,
[nameof(FileExportSuccessMessage.OffersOpening)] = this.OffersOpening
};
var icon = this.Icon;
snackbar.RemoveByKey(this.Key);
snackbar.Add<FileExportSuccessMessage>(parameters, Severity.Success, config =>
{
config.Icon = icon;
config.IconSize = Size.Large;
config.RequireInteraction = !closesAutomatically;
config.VisibleStateDuration = 10_000;
config.HideTransitionDuration = 600;
}, this.Key);
}
}
+16
View File
@@ -78,6 +78,22 @@ public enum Event
/// </summary>
SHOW_INFO,
/// <summary>
/// Requests display of the notification that a file is being exported.
/// </summary>
SHOW_FILE_EXPORT_RUNNING,
/// <summary>
/// Requests display of the notification that a file was exported, with the ways to reach it.
/// It replaces the notification that the export runs.
/// </summary>
SHOW_FILE_EXPORT_SUCCESS,
/// <summary>
/// Requests removal of the notification that a file is being exported.
/// </summary>
HIDE_FILE_EXPORT_RUNNING,
/// <summary>
/// Requests display of a prompt-injection alert dialog.
/// </summary>
@@ -0,0 +1,134 @@
namespace AIStudio.Tools;
/// <summary>
/// Tells the user that a file is being exported, and afterward where it went.
/// </summary>
/// <remarks>
/// Start it once the user chose the file, and dispose of it when the export is over, also after an error:
/// disposing removes the notification that the export runs, in case it still stands. That notification
/// appears only after a moment, so that a fast export shows its success right away instead of two
/// notifications which flicker one after the other.
/// <br/><br/>
/// The notification that the export runs must never appear after its end, where it would stay until the
/// user closes it. The delayed notification and the end of the export therefore take the same lock, and
/// whichever comes second sees what the first one did. That settles the order in which they are sent;
/// FileExportNotifications settles the order in which they take effect.
/// </remarks>
public sealed class FileExportFeedback : IAsyncDisposable
{
/// <summary>
/// How long an export runs before the user hears about it.
/// </summary>
private static readonly TimeSpan RUNNING_MESSAGE_DELAY = TimeSpan.FromMilliseconds(500);
private readonly Func<Event, object, Task> send;
private readonly Func<CancellationToken, Task> waitForRunningMessage;
private readonly string key = Guid.NewGuid().ToString("N");
private readonly SemaphoreSlim sendLock = new(1, 1);
private readonly CancellationTokenSource runningMessageCancellation = new();
private DataFileExportRunningMessage runningMessage;
private Task delayedRunningMessage = Task.CompletedTask;
private bool isRunningMessageShown;
private bool isFinished;
public FileExportFeedback() : this((triggeredEvent, data) => MessageBus.INSTANCE.SendMessage(null, triggeredEvent, data), token => Task.Delay(RUNNING_MESSAGE_DELAY, token))
{
}
/// <summary>
/// Creates the feedback with what it needs from outside, so that tests can stand in for both.
/// </summary>
/// <param name="send">Sends a notification, as the message bus does.</param>
/// <param name="waitForRunningMessage">Waits until the notification that the export runs is due.</param>
internal FileExportFeedback(Func<Event, object, Task> send, Func<CancellationToken, Task> waitForRunningMessage)
{
this.send = send;
this.waitForRunningMessage = waitForRunningMessage;
}
/// <summary>
/// Shows that the given file is being exported, unless the export ends before that is due.
/// </summary>
/// <param name="filePath">The file the export writes.</param>
public void ShowRunning(string filePath)
{
this.runningMessage = new DataFileExportRunningMessage(this.key, filePath);
this.delayedRunningMessage = this.ShowRunningWhenDueAsync(this.runningMessageCancellation.Token);
}
/// <summary>
/// Ends the export with the notification of its success, which replaces the one that it runs.
/// </summary>
/// <param name="icon">The icon of the notification.</param>
/// <param name="message">What was exported.</param>
/// <param name="filePath">The file which was written.</param>
/// <param name="offersOpening">Whether the file may be opened with the default program.</param>
public Task SucceededAsync(string icon, string message, string filePath, bool offersOpening) => this.FinishAsync(new DataFileExportSuccessMessage(this.key, icon, message, filePath, offersOpening));
private async Task ShowRunningWhenDueAsync(CancellationToken token)
{
try
{
await this.waitForRunningMessage(token);
}
catch (OperationCanceledException)
{
return;
}
await this.sendLock.WaitAsync();
try
{
if (this.isFinished)
return;
await this.send(Event.SHOW_FILE_EXPORT_RUNNING, this.runningMessage);
this.isRunningMessageShown = true;
}
finally
{
this.sendLock.Release();
}
}
/// <summary>
/// Ends the export, with the notification of its success or without any.
/// </summary>
/// <param name="success">The notification of the success, or null when the export failed.</param>
private async Task FinishAsync(DataFileExportSuccessMessage? success)
{
await this.runningMessageCancellation.CancelAsync();
await this.sendLock.WaitAsync();
try
{
if (this.isFinished)
return;
this.isFinished = true;
if (success is { } successMessage)
await this.send(Event.SHOW_FILE_EXPORT_SUCCESS, successMessage);
else if (this.isRunningMessageShown)
await this.send(Event.HIDE_FILE_EXPORT_RUNNING, this.runningMessage);
}
finally
{
this.sendLock.Release();
}
}
#region Implementation of IAsyncDisposable
public async ValueTask DisposeAsync()
{
await this.FinishAsync(null);
// The delayed notification may still wait for the lock; it sees the end and gives up:
await this.delayedRunningMessage;
this.runningMessageCancellation.Dispose();
this.sendLock.Dispose();
}
#endregion
}
@@ -0,0 +1,43 @@
namespace AIStudio.Tools;
/// <summary>
/// Shows the notifications of file exports in one window, and keeps a late notification that an export
/// runs from appearing after its end.
/// </summary>
/// <remarks>
/// FileExportFeedback sends its notifications in the right order, but they may take effect in another
/// one. The notification that an export runs comes late, from a timer, and waits in the queue of the
/// renderer. The end of an export usually comes from a component, on the thread of the renderer, and
/// takes effect right away: it overtakes the waiting notification, which would then appear after the
/// end and stay until the user closes it. So we remember which exports ended and drop what comes late.
/// <br/><br/>
/// One key stays per export which ended, for as long as the window lives. That is a few bytes per
/// file the user saved, which is not worth a cleanup that could drop a key too early.
/// </remarks>
/// <param name="snackbar">The notifications of the window.</param>
public sealed class FileExportNotifications(ISnackbar snackbar)
{
private readonly HashSet<string> finishedExports = [];
public void ShowRunning(DataFileExportRunningMessage message)
{
if (this.finishedExports.Contains(message.Key))
return;
message.Show(snackbar);
}
/// <param name="message">The notification of the success.</param>
/// <param name="closesAutomatically">Whether the notification closes after a few seconds, as the user chose.</param>
public void ShowSuccess(DataFileExportSuccessMessage message, bool closesAutomatically)
{
this.finishedExports.Add(message.Key);
message.Show(snackbar, closesAutomatically);
}
public void HideRunning(DataFileExportRunningMessage message)
{
this.finishedExports.Add(message.Key);
message.Hide(snackbar);
}
}
+7 -1
View File
@@ -157,13 +157,19 @@ public static class PandocExport
if (!pandocState.IsAvailable)
return false;
//
// The export runs from here on. Before, the user may still be looking at the dialog which
// offers to install Pandoc, and a notification that we save would be wrong there:
//
await using var feedback = new FileExportFeedback();
feedback.ShowRunning(response.SaveFilePath);
if (!await ConvertAsync(rustService, markdownText, response.SaveFilePath, format))
{
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Cancel, TB("The export failed.")));
return false;
}
await MessageBus.INSTANCE.SendSuccess(new(Icons.Material.Filled.CheckCircle, TB("The export succeeded.")));
await feedback.SucceededAsync(Icons.Material.Filled.CheckCircle, TB("The export succeeded."), response.SaveFilePath, true);
return true;
}
}
@@ -209,10 +209,12 @@ public static class PlainFileExport
LOGGER.LogInformation("The user chose the path '{SaveFilePath}' for the {ExportFormat} export.", response.SaveFilePath, format);
await using var feedback = new FileExportFeedback();
feedback.ShowRunning(response.SaveFilePath);
try
{
await File.WriteAllTextAsync(response.SaveFilePath, fileContent, format.ToFileEncoding());
await MessageBus.INSTANCE.SendSuccess(new(Icons.Material.Filled.CheckCircle, TB("The export succeeded.")));
await feedback.SucceededAsync(Icons.Material.Filled.CheckCircle, TB("The export succeeded."), response.SaveFilePath, true);
return true;
}
@@ -42,17 +42,19 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru
/// Studio restarts.
/// </remarks>
/// <param name="plugin">The local plugin to archive and share.</param>
/// <param name="exportFeedback">Shows that the archive is being written, once the user chose where.
/// The caller ends it with the success, which only the caller announces.</param>
/// <param name="token">Cancellation token for archive creation.</param>
/// <returns>The share result, including the archive path when successful. When the share sheet
/// failed or did not respond, the outcome is SHARE_SHEET_UNAVAILABLE, and ExportAsync can still
/// export the plugin.</returns>
public async Task<PluginShareResult> ShareAsync(IAvailablePlugin plugin, CancellationToken token)
public async Task<PluginShareResult> ShareAsync(IAvailablePlugin plugin, FileExportFeedback exportFeedback, CancellationToken token)
{
if (!this.TryGetShareablePluginRoot(plugin, out var pluginRoot, out var issue))
return ShareError(plugin, issue);
if (this.UsesExport)
return await this.ExportArchiveAsync(plugin, pluginRoot, token);
return await this.ExportArchiveAsync(plugin, pluginRoot, exportFeedback, token);
return await this.ShareViaNativeSheetAsync(plugin, pluginRoot, token);
}
@@ -62,14 +64,16 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru
/// be available. This is the way out after the share sheet failed or did not respond.
/// </summary>
/// <param name="plugin">The local plugin to archive and export.</param>
/// <param name="exportFeedback">Shows that the archive is being written, once the user chose where.
/// The caller ends it with the success, which only the caller announces.</param>
/// <param name="token">Cancellation token for archive creation.</param>
/// <returns>The export result, including the chosen archive path when successful.</returns>
public async Task<PluginShareResult> ExportAsync(IAvailablePlugin plugin, CancellationToken token)
public async Task<PluginShareResult> ExportAsync(IAvailablePlugin plugin, FileExportFeedback exportFeedback, CancellationToken token)
{
if (!this.TryGetShareablePluginRoot(plugin, out var pluginRoot, out var issue))
return ShareError(plugin, issue);
return await this.ExportArchiveAsync(plugin, pluginRoot, token);
return await this.ExportArchiveAsync(plugin, pluginRoot, exportFeedback, token);
}
/// <summary>
@@ -109,9 +113,10 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru
/// </summary>
/// <param name="plugin">The local plugin to archive.</param>
/// <param name="pluginRoot">The validated plugin root directory.</param>
/// <param name="exportFeedback">Shows that the archive is being written.</param>
/// <param name="token">Cancellation token for archive creation.</param>
/// <returns>The share result, including the chosen archive path when successful.</returns>
private async Task<PluginShareResult> ExportArchiveAsync(IAvailablePlugin plugin, string pluginRoot, CancellationToken token)
private async Task<PluginShareResult> ExportArchiveAsync(IAvailablePlugin plugin, string pluginRoot, FileExportFeedback exportFeedback, CancellationToken token)
{
var suggestedFileName = $"{CreateSafeFileNamePrefix(plugin.Name)}{PluginArchive.PLUGIN_FILE_EXTENSION}";
var saveResponse = await rustService.SaveFile(TB("Export plugin archive"), [FileTypes.PLUGIN_ARCHIVE], suggestedFileName);
@@ -119,6 +124,7 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru
return new(PluginShareOutcome.CANCELLED, plugin.Name, string.Empty, string.Empty);
var archivePath = saveResponse.SaveFilePath;
exportFeedback.ShowRunning(archivePath);
try
{
token.ThrowIfCancellationRequested();