Prevent file dialogs from opening multiple times

This commit is contained in:
Thorsten Sommer 2026-07-19 19:23:18 +02:00
parent f666760415
commit 7cb79dbc08
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
13 changed files with 213 additions and 88 deletions

View File

@ -102,13 +102,14 @@ public partial class AttachDocuments : MSGComponentBase
private uint numDropAreasAboveThis;
private bool isComponentHovered;
private bool isDraggingOver;
private bool isFileDialogOpen;
private MediaImportOwner EffectiveImportOwner => this.OwnerChat is not null
? MediaImportOwner.ForChat(this.OwnerChat.ChatId)
: this.ImportOwner ?? this.fallbackMediaImportOwner;
private MediaImportTarget EffectiveMediaImportTarget => new(this.EffectiveImportOwner, string.IsNullOrWhiteSpace(this.Name) ? "attachments" : this.Name);
private bool IsUnavailable => this.Disabled || this.MediaTranscriptionService.IsBusy(this.EffectiveImportOwner);
private bool IsUnavailable => this.Disabled || this.isFileDialogOpen || this.MediaTranscriptionService.IsBusy(this.EffectiveImportOwner);
#region Overrides of MSGComponentBase
@ -310,13 +311,21 @@ public partial class AttachDocuments : MSGComponentBase
if (this.IsUnavailable)
return;
var selectFiles = await this.RustService.SelectFiles(T("Select files to attach"));
if (selectFiles.UserCancelled)
return;
this.isFileDialogOpen = true;
try
{
var selectFiles = await this.RustService.SelectFiles(T("Select files to attach"));
if (selectFiles.UserCancelled)
return;
await this.AddFileBatchAsync(selectFiles.SelectedFilePaths);
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
await this.OnChange(this.DocumentPaths);
await this.AddFileBatchAsync(selectFiles.SelectedFilePaths);
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
await this.OnChange(this.DocumentPaths);
}
finally
{
this.isFileDialogOpen = false;
}
}
private async Task OpenAttachmentsDialog()
@ -397,7 +406,17 @@ public partial class AttachDocuments : MSGComponentBase
private async Task AddFileBatchAsync(IEnumerable<string> paths)
{
var existingPaths = paths.Where(File.Exists).ToList();
var pathList = paths.ToList();
var inaccessiblePaths = pathList.Where(path => !File.Exists(path)).ToList();
if (inaccessiblePaths.Count > 0)
{
this.Logger.LogWarning("Could not access {Count} dropped or selected file(s): {Paths}", inaccessiblePaths.Count, string.Join(", ", inaccessiblePaths));
await this.MessageBus.SendWarning(new(
Icons.Material.Filled.Warning,
this.T("Some files could not be accessed. Please select them with the file chooser instead.")));
}
var existingPaths = pathList.Except(inaccessiblePaths).ToList();
var mediaPaths = existingPaths.Where(IsTranscribableMedia).ToList();
var regularPaths = existingPaths.Except(mediaPaths).ToList();

View File

@ -19,7 +19,7 @@
Variant="Variant.Outlined"
Color="Color.Primary"
Size="Size.Small"
Disabled="@this.IsDisabled"
Disabled="@(this.IsDisabled || this.isFileDialogOpen)"
Class="mb-1"
OnClick="@this.OpenFileDialog">
@T("Choose File")

View File

@ -49,6 +49,7 @@ public partial class ConfigurationFile : ConfigurationBaseCore
private RustService RustService { get; init; } = null!;
private string internalText = string.Empty;
private bool isFileDialogOpen;
private readonly Timer timer = new(TimeSpan.FromMilliseconds(500))
{
AutoReset = false
@ -90,13 +91,24 @@ public partial class ConfigurationFile : ConfigurationBaseCore
private async Task OpenFileDialog()
{
var response = await this.RustService.SelectFile(this.FileDialogTitle, this.Filter, string.IsNullOrWhiteSpace(this.internalText) ? null : this.internalText);
if (response.UserCancelled)
if (this.isFileDialogOpen)
return;
this.timer.Stop();
this.internalText = response.SelectedFilePath;
await this.OptionChanged(response.SelectedFilePath);
this.isFileDialogOpen = true;
try
{
var response = await this.RustService.SelectFile(this.FileDialogTitle, this.Filter, string.IsNullOrWhiteSpace(this.internalText) ? null : this.internalText);
if (response.UserCancelled)
return;
this.timer.Stop();
this.internalText = response.SelectedFilePath;
await this.OptionChanged(response.SelectedFilePath);
}
finally
{
this.isFileDialogOpen = false;
}
}
private async Task OptionChanged(string updatedText)

View File

@ -74,9 +74,10 @@ public partial class ReadFileContent : MSGComponentBase
private string dragClass = DEFAULT_DRAG_CLASS;
private uint numDropAreasAboveThis;
private bool isComponentHovered;
private bool isFileDialogOpen;
private bool IsCurrentTargetBusy => this.MediaTranscriptionService.GetSnapshot(this.EffectiveImportOwner) is { IsBusy: true } snapshot
&& snapshot.Target == this.EffectiveMediaImportTarget;
private bool IsUnavailable => this.Disabled || this.MediaTranscriptionService.IsBusy(this.EffectiveImportOwner);
private bool IsUnavailable => this.Disabled || this.isFileDialogOpen || this.MediaTranscriptionService.IsBusy(this.EffectiveImportOwner);
#region Overrides of MSGComponentBase
@ -217,14 +218,22 @@ public partial class ReadFileContent : MSGComponentBase
if (this.IsUnavailable)
return;
var selectedFile = await this.RustService.SelectFile(T("Select file to read its content"));
if (selectedFile.UserCancelled)
this.isFileDialogOpen = true;
try
{
this.Logger.LogInformation("User cancelled the file selection");
return;
}
var selectedFile = await this.RustService.SelectFile(T("Select file to read its content"));
if (selectedFile.UserCancelled)
{
this.Logger.LogInformation("User cancelled the file selection");
return;
}
await this.LoadFileIfValid(selectedFile.SelectedFilePath);
await this.LoadFileIfValid(selectedFile.SelectedFilePath);
}
finally
{
this.isFileDialogOpen = false;
}
}
private async Task<bool> EnsurePandocAvailability()
@ -246,6 +255,15 @@ public partial class ReadFileContent : MSGComponentBase
private async Task LoadFirstValidFile(List<string> paths)
{
var inaccessiblePaths = paths.Where(path => !File.Exists(path)).ToList();
if (inaccessiblePaths.Count > 0)
{
this.Logger.LogWarning("Could not access {Count} dropped file(s): {Paths}", inaccessiblePaths.Count, string.Join(", ", inaccessiblePaths));
await this.MessageBus.SendWarning(new(
Icons.Material.Filled.Warning,
this.T("Some dropped files could not be accessed. Please select them with the file chooser instead.")));
}
foreach (var path in paths)
{
if (await this.LoadFileIfValid(path))

View File

@ -13,7 +13,7 @@
Variant="Variant.Outlined"
/>
<MudButton StartIcon="@Icons.Material.Filled.FolderOpen" Variant="Variant.Outlined" Color="Color.Primary" Disabled="this.Disabled" OnClick="@this.OpenDirectoryDialog">
<MudButton StartIcon="@Icons.Material.Filled.FolderOpen" Variant="Variant.Outlined" Color="Color.Primary" Disabled="@(this.Disabled || this.isDirectoryDialogOpen)" OnClick="@this.OpenDirectoryDialog">
@T("Choose Directory")
</MudButton>
</MudStack>

View File

@ -31,6 +31,7 @@ public partial class SelectDirectory : MSGComponentBase
protected ILogger<SelectDirectory> Logger { get; init; } = null!;
private static readonly Dictionary<string, object?> SPELLCHECK_ATTRIBUTES = new();
private bool isDirectoryDialogOpen;
#region Overrides of ComponentBase
@ -51,10 +52,21 @@ public partial class SelectDirectory : MSGComponentBase
private async Task OpenDirectoryDialog()
{
var response = await this.RustService.SelectDirectory(this.DirectoryDialogTitle, string.IsNullOrWhiteSpace(this.Directory) ? null : this.Directory);
this.Logger.LogInformation($"The user selected the directory '{response.SelectedDirectory}'.");
if (this.isDirectoryDialogOpen)
return;
if (!response.UserCancelled)
this.InternalDirectoryChanged(response.SelectedDirectory);
this.isDirectoryDialogOpen = true;
try
{
var response = await this.RustService.SelectDirectory(this.DirectoryDialogTitle, string.IsNullOrWhiteSpace(this.Directory) ? null : this.Directory);
this.Logger.LogInformation("The user selected the directory '{SelectedDirectory}'.", response.SelectedDirectory);
if (!response.UserCancelled)
this.InternalDirectoryChanged(response.SelectedDirectory);
}
finally
{
this.isDirectoryDialogOpen = false;
}
}
}

View File

@ -13,7 +13,7 @@
Variant="Variant.Outlined"
/>
<MudButton StartIcon="@Icons.Material.Filled.FolderOpen" Variant="Variant.Outlined" Color="Color.Primary" Disabled="this.Disabled" OnClick="@this.OpenFileDialog">
<MudButton StartIcon="@Icons.Material.Filled.FolderOpen" Variant="Variant.Outlined" Color="Color.Primary" Disabled="@(this.Disabled || this.isFileDialogOpen)" OnClick="@this.OpenFileDialog">
@T("Choose File")
</MudButton>
</MudStack>

View File

@ -35,6 +35,7 @@ public partial class SelectFile : MSGComponentBase
protected ILogger<SelectFile> Logger { get; init; } = null!;
private static readonly Dictionary<string, object?> SPELLCHECK_ATTRIBUTES = new();
private bool isFileDialogOpen;
#region Overrides of ComponentBase
@ -55,10 +56,21 @@ public partial class SelectFile : MSGComponentBase
private async Task OpenFileDialog()
{
var response = await this.RustService.SelectFile(this.FileDialogTitle, this.Filter, string.IsNullOrWhiteSpace(this.File) ? null : this.File);
this.Logger.LogInformation($"The user selected the file '{response.SelectedFilePath}'.");
if (this.isFileDialogOpen)
return;
if (!response.UserCancelled)
this.InternalFileChanged(response.SelectedFilePath);
this.isFileDialogOpen = true;
try
{
var response = await this.RustService.SelectFile(this.FileDialogTitle, this.Filter, string.IsNullOrWhiteSpace(this.File) ? null : this.File);
this.Logger.LogInformation("The user selected the file '{SelectedFilePath}'.", response.SelectedFilePath);
if (!response.UserCancelled)
this.InternalFileChanged(response.SelectedFilePath);
}
finally
{
this.isFileDialogOpen = false;
}
}
}

View File

@ -63,7 +63,7 @@
<MudMenuItem Icon="@Icons.Material.Filled.Link" OnClick="@(() => this.ExportChatTemplateWithSharedAttachmentPaths(context))">
@T("Use shared attachment paths")
</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Folder" OnClick="@(() => this.ExportChatTemplateWithPackagedAttachments(context))">
<MudMenuItem Icon="@Icons.Material.Filled.Folder" Disabled="@this.isPluginDirectoryDialogOpen" OnClick="@(() => this.ExportChatTemplateWithPackagedAttachments(context))">
@T("Copy attachments into plugin")
</MudMenuItem>
</MudMenu>

View File

@ -6,6 +6,8 @@ namespace AIStudio.Dialogs.Settings;
public partial class SettingsDialogChatTemplate : SettingsDialogBase
{
private bool isPluginDirectoryDialogOpen;
[Parameter]
public bool CreateTemplateFromExistingChatThread { get; set; }
@ -131,7 +133,7 @@ public partial class SettingsDialogChatTemplate : SettingsDialogBase
private async Task ExportChatTemplateWithPackagedAttachments(ChatTemplate chatTemplate)
{
if (!this.SettingsManager.ConfigurationData.App.ShowAdminSettings)
if (!this.SettingsManager.ConfigurationData.App.ShowAdminSettings || this.isPluginDirectoryDialogOpen)
return;
if (chatTemplate == ChatTemplate.NO_CHAT_TEMPLATE || chatTemplate.IsEnterpriseConfiguration)
@ -143,11 +145,19 @@ public partial class SettingsDialogChatTemplate : SettingsDialogBase
return;
}
var pluginDirectoryResponse = await this.RustService.SelectDirectory(T("Select configuration plugin folder"));
if (pluginDirectoryResponse.UserCancelled)
return;
this.isPluginDirectoryDialogOpen = true;
try
{
var pluginDirectoryResponse = await this.RustService.SelectDirectory(T("Select configuration plugin folder"));
if (pluginDirectoryResponse.UserCancelled)
return;
await this.CopyPackagedChatTemplateLuaToClipboard(chatTemplate, pluginDirectoryResponse.SelectedDirectory);
await this.CopyPackagedChatTemplateLuaToClipboard(chatTemplate, pluginDirectoryResponse.SelectedDirectory);
}
finally
{
this.isPluginDirectoryDialogOpen = false;
}
}
private async Task CopyChatTemplateLuaToClipboard(ChatTemplate chatTemplate)

View File

@ -1,3 +1,5 @@
using System.Diagnostics;
using AIStudio.Tools.Rust;
namespace AIStudio.Tools.Services;
@ -6,56 +8,90 @@ public sealed partial class RustService
{
public async Task<DirectorySelectionResponse> SelectDirectory(string title, string? initialDirectory = null)
{
var encodedTitle = Uri.EscapeDataString(title);
var result = initialDirectory is null
? await this.http.PostAsync($"/select/directory?title={encodedTitle}", null)
: await this.http.PostAsJsonAsync($"/select/directory?title={encodedTitle}", new PreviousDirectory(initialDirectory), this.jsonRustSerializerOptions);
if (!result.IsSuccessStatusCode)
return await this.RunFileDialog(
"select directory",
async () =>
{
var encodedTitle = Uri.EscapeDataString(title);
var result = initialDirectory is null
? await this.http.PostAsync($"/select/directory?title={encodedTitle}", null)
: await this.http.PostAsJsonAsync($"/select/directory?title={encodedTitle}", new PreviousDirectory(initialDirectory), this.jsonRustSerializerOptions);
if (result.IsSuccessStatusCode)
return await result.Content.ReadFromJsonAsync<DirectorySelectionResponse>(this.jsonRustSerializerOptions);
this.logger!.LogError("Failed to select a directory: '{StatusCode}'", result.StatusCode);
return new DirectorySelectionResponse(true, string.Empty);
},
new DirectorySelectionResponse(true, string.Empty));
}
private async Task<T> RunFileDialog<T>(string operation, Func<Task<T>> showDialog, T cancelledResult)
{
if (!await this.fileDialogLock.WaitAsync(0))
{
this.logger!.LogError($"Failed to select a directory: '{result.StatusCode}'");
return new DirectorySelectionResponse(true, string.Empty);
this.logger!.LogInformation("Ignored duplicate file dialog request for '{Operation}'.", operation);
return cancelledResult;
}
var stopwatch = Stopwatch.StartNew();
this.logger!.LogInformation("Opening file dialog for '{Operation}'.", operation);
try
{
return await showDialog();
}
finally
{
stopwatch.Stop();
this.fileDialogLock.Release();
this.logger!.LogInformation("File dialog for '{Operation}' completed after {ElapsedMilliseconds} ms.", operation, stopwatch.ElapsedMilliseconds);
}
return await result.Content.ReadFromJsonAsync<DirectorySelectionResponse>(this.jsonRustSerializerOptions);
}
public async Task<FileSelectionResponse> SelectFile(string title, FileTypeFilter[]? filter = null, string? initialFile = null)
{
var payload = new SelectFileOptions
{
Title = title,
PreviousFile = initialFile is null ? null : new (initialFile),
Filter = FileTypes.AsOneFileType(filter)
};
return await this.RunFileDialog(
"select file",
async () =>
{
var payload = new SelectFileOptions
{
Title = title,
PreviousFile = initialFile is null ? null : new (initialFile),
Filter = FileTypes.AsOneFileType(filter)
};
var result = await this.http.PostAsJsonAsync("/select/file", payload, this.jsonRustSerializerOptions);
if (!result.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to select a file: '{result.StatusCode}'");
return new FileSelectionResponse(true, string.Empty);
}
var result = await this.http.PostAsJsonAsync("/select/file", payload, this.jsonRustSerializerOptions);
if (result.IsSuccessStatusCode)
return await result.Content.ReadFromJsonAsync<FileSelectionResponse>(this.jsonRustSerializerOptions);
return await result.Content.ReadFromJsonAsync<FileSelectionResponse>(this.jsonRustSerializerOptions);
this.logger!.LogError("Failed to select a file: '{StatusCode}'", result.StatusCode);
return new FileSelectionResponse(true, string.Empty);
},
new FileSelectionResponse(true, string.Empty));
}
public async Task<FilesSelectionResponse> SelectFiles(string title, FileTypeFilter[]? filter = null, string? initialFile = null)
{
var payload = new SelectFileOptions
{
Title = title,
PreviousFile = initialFile is null ? null : new (initialFile),
Filter = FileTypes.AsOneFileType(filter)
};
return await this.RunFileDialog(
"select files",
async () =>
{
var payload = new SelectFileOptions
{
Title = title,
PreviousFile = initialFile is null ? null : new (initialFile),
Filter = FileTypes.AsOneFileType(filter)
};
var result = await this.http.PostAsJsonAsync("/select/files", payload, this.jsonRustSerializerOptions);
if (!result.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to select files: '{result.StatusCode}'");
return new FilesSelectionResponse(true, Array.Empty<string>());
}
var result = await this.http.PostAsJsonAsync("/select/files", payload, this.jsonRustSerializerOptions);
if (result.IsSuccessStatusCode)
return await result.Content.ReadFromJsonAsync<FilesSelectionResponse>(this.jsonRustSerializerOptions);
return await result.Content.ReadFromJsonAsync<FilesSelectionResponse>(this.jsonRustSerializerOptions);
this.logger!.LogError("Failed to select files: '{StatusCode}'", result.StatusCode);
return new FilesSelectionResponse(true, Array.Empty<string>());
},
new FilesSelectionResponse(true, Array.Empty<string>()));
}
/// <summary>
@ -68,21 +104,25 @@ public sealed partial class RustService
/// operation and whether the select operation was successful.</returns>
public async Task<FileSaveResponse> SaveFile(string title, FileTypeFilter[]? filter = null, string? initialFile = null)
{
var payload = new SaveFileOptions
{
Title = title,
PreviousFile = initialFile is null ? null : new (initialFile),
Filter = FileTypes.AsOneFileType(filter)
};
var result = await this.http.PostAsJsonAsync("/save/file", payload, this.jsonRustSerializerOptions);
if (!result.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to select a file for writing operation '{result.StatusCode}'");
return new FileSaveResponse(true, string.Empty);
}
return await result.Content.ReadFromJsonAsync<FileSaveResponse>(this.jsonRustSerializerOptions);
return await this.RunFileDialog(
"save file",
async () =>
{
var payload = new SaveFileOptions
{
Title = title,
PreviousFile = initialFile is null ? null : new (initialFile),
Filter = FileTypes.AsOneFileType(filter)
};
var result = await this.http.PostAsJsonAsync("/save/file", payload, this.jsonRustSerializerOptions);
if (result.IsSuccessStatusCode)
return await result.Content.ReadFromJsonAsync<FileSaveResponse>(this.jsonRustSerializerOptions);
this.logger!.LogError("Failed to select a file for writing operation: '{StatusCode}'", result.StatusCode);
return new FileSaveResponse(true, string.Empty);
},
new FileSaveResponse(true, string.Empty));
}
public async Task<OpenPathResponse> TryOpenPathInRuntimeFileManager(string path)

View File

@ -17,6 +17,7 @@ public sealed partial class RustService : BackgroundService
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(RustService).Namespace, nameof(RustService));
private readonly HttpClient http;
private readonly SemaphoreSlim fileDialogLock = new(1, 1);
private readonly SemaphoreSlim userLanguageLock = new(1, 1);
private readonly SemaphoreSlim userNameLock = new(1, 1);

View File

@ -8,6 +8,7 @@
- Improved the "My Tasks Assistant": you can now provide one or more documents in addition to text or use documents alone when asking to identify tasks.
- Improved update guidance for Flatpak installations and added an enterprise option that lets organizations manage updates entirely through their IT department.
- Improved secure API-key storage diagnostics on Linux. AI Studio now provides specific guidance when the default password collection is missing or locked, a password-manager prompt is dismissed, or no compatible Secret Service is available.
- Improved the file dialogs to prevent opening multiple times when you click "Open" or "Save" multiple times in a row.
- Fixed an issue that could leave AI Studio unresponsive after waking the computer from sleep. Yes, we know this was an annoying bug, and we apologize for the inconvenience.
- Fixed connections to internal HTTPS services and enterprise configuration servers that use organization-provided root certificates on Linux.
- Fixed enterprise configuration plugins from Windows-created ZIP files may not load correctly on Linux when the ZIP contained plugin files inside a folder.