diff --git a/app/MindWork AI Studio/Components/AttachDocuments.razor.cs b/app/MindWork AI Studio/Components/AttachDocuments.razor.cs index c52bd115..87289024 100644 --- a/app/MindWork AI Studio/Components/AttachDocuments.razor.cs +++ b/app/MindWork AI Studio/Components/AttachDocuments.razor.cs @@ -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 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(); diff --git a/app/MindWork AI Studio/Components/ConfigurationFile.razor b/app/MindWork AI Studio/Components/ConfigurationFile.razor index ed2f9be2..06ec26b0 100644 --- a/app/MindWork AI Studio/Components/ConfigurationFile.razor +++ b/app/MindWork AI Studio/Components/ConfigurationFile.razor @@ -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") diff --git a/app/MindWork AI Studio/Components/ConfigurationFile.razor.cs b/app/MindWork AI Studio/Components/ConfigurationFile.razor.cs index 82d56d18..b9042586 100644 --- a/app/MindWork AI Studio/Components/ConfigurationFile.razor.cs +++ b/app/MindWork AI Studio/Components/ConfigurationFile.razor.cs @@ -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) diff --git a/app/MindWork AI Studio/Components/ReadFileContent.razor.cs b/app/MindWork AI Studio/Components/ReadFileContent.razor.cs index 4a200f1f..dd2887b0 100644 --- a/app/MindWork AI Studio/Components/ReadFileContent.razor.cs +++ b/app/MindWork AI Studio/Components/ReadFileContent.razor.cs @@ -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 EnsurePandocAvailability() @@ -246,6 +255,15 @@ public partial class ReadFileContent : MSGComponentBase private async Task LoadFirstValidFile(List 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)) diff --git a/app/MindWork AI Studio/Components/SelectDirectory.razor b/app/MindWork AI Studio/Components/SelectDirectory.razor index 1cf19ec4..096db371 100644 --- a/app/MindWork AI Studio/Components/SelectDirectory.razor +++ b/app/MindWork AI Studio/Components/SelectDirectory.razor @@ -13,7 +13,7 @@ Variant="Variant.Outlined" /> - + @T("Choose Directory") \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/SelectDirectory.razor.cs b/app/MindWork AI Studio/Components/SelectDirectory.razor.cs index a305f2b7..6f576435 100644 --- a/app/MindWork AI Studio/Components/SelectDirectory.razor.cs +++ b/app/MindWork AI Studio/Components/SelectDirectory.razor.cs @@ -31,6 +31,7 @@ public partial class SelectDirectory : MSGComponentBase protected ILogger Logger { get; init; } = null!; private static readonly Dictionary 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; + } } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/SelectFile.razor b/app/MindWork AI Studio/Components/SelectFile.razor index de3971e5..726965fd 100644 --- a/app/MindWork AI Studio/Components/SelectFile.razor +++ b/app/MindWork AI Studio/Components/SelectFile.razor @@ -13,7 +13,7 @@ Variant="Variant.Outlined" /> - + @T("Choose File") \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/SelectFile.razor.cs b/app/MindWork AI Studio/Components/SelectFile.razor.cs index 91c7a667..de1f89a3 100644 --- a/app/MindWork AI Studio/Components/SelectFile.razor.cs +++ b/app/MindWork AI Studio/Components/SelectFile.razor.cs @@ -35,6 +35,7 @@ public partial class SelectFile : MSGComponentBase protected ILogger Logger { get; init; } = null!; private static readonly Dictionary 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; + } } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor index 19680575..69483493 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor @@ -63,7 +63,7 @@ @T("Use shared attachment paths") - + @T("Copy attachments into plugin") diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor.cs b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor.cs index 54a2f631..d6dbb2da 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor.cs +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor.cs @@ -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) diff --git a/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs b/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs index 89fef1f4..81a64e8c 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs @@ -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 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(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 RunFileDialog(string operation, Func> 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(this.jsonRustSerializerOptions); } public async Task 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(this.jsonRustSerializerOptions); - return await result.Content.ReadFromJsonAsync(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 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()); - } + var result = await this.http.PostAsJsonAsync("/select/files", payload, this.jsonRustSerializerOptions); + if (result.IsSuccessStatusCode) + return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); - return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + this.logger!.LogError("Failed to select files: '{StatusCode}'", result.StatusCode); + return new FilesSelectionResponse(true, Array.Empty()); + }, + new FilesSelectionResponse(true, Array.Empty())); } /// @@ -68,21 +104,25 @@ public sealed partial class RustService /// operation and whether the select operation was successful. public async Task 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(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(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 TryOpenPathInRuntimeFileManager(string path) diff --git a/app/MindWork AI Studio/Tools/Services/RustService.cs b/app/MindWork AI Studio/Tools/Services/RustService.cs index 6bcef10c..6e979bb1 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.cs @@ -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); diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md b/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md index f17a9a46..66682a54 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md @@ -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.