From cb744064d50f1866c4c9016cc1878eaa9e0ef07a Mon Sep 17 00:00:00 2001
From: Thorsten Sommer <mail@tsommer.org>
Date: Wed, 8 Jan 2025 20:50:48 +0100
Subject: [PATCH] Added possibility that the user selects a file

---
 .../Components/SelectFile.razor               | 17 +++++
 .../Components/SelectFile.razor.cs            | 63 +++++++++++++++++++
 .../Tools/Rust/FileSelectionResponse.cs       |  8 +++
 .../Tools/Rust/PreviousFile.cs                |  7 +++
 .../Tools/RustService.FileSystem.cs           | 13 ++++
 runtime/src/app_window.rs                     | 49 +++++++++++++++
 runtime/src/runtime_api.rs                    |  1 +
 7 files changed, 158 insertions(+)
 create mode 100644 app/MindWork AI Studio/Components/SelectFile.razor
 create mode 100644 app/MindWork AI Studio/Components/SelectFile.razor.cs
 create mode 100644 app/MindWork AI Studio/Tools/Rust/FileSelectionResponse.cs
 create mode 100644 app/MindWork AI Studio/Tools/Rust/PreviousFile.cs

diff --git a/app/MindWork AI Studio/Components/SelectFile.razor b/app/MindWork AI Studio/Components/SelectFile.razor
new file mode 100644
index 00000000..34842360
--- /dev/null
+++ b/app/MindWork AI Studio/Components/SelectFile.razor	
@@ -0,0 +1,17 @@
+<MudStack Row="@true" Spacing="3" Class="mb-3" StretchItems="StretchItems.None" AlignItems="AlignItems.Center">
+    <MudTextField
+        T="string"
+        Text="@this.File"
+        Label="@this.Label"
+        ReadOnly="@true"
+        Validation="@this.Validation"
+        Adornment="Adornment.Start"
+        AdornmentIcon="@Icons.Material.Filled.AttachFile"
+        UserAttributes="@SPELLCHECK_ATTRIBUTES"
+        Variant="Variant.Outlined"
+    />
+    
+    <MudButton StartIcon="@Icons.Material.Filled.FolderOpen" Variant="Variant.Outlined" Color="Color.Primary" Disabled="this.Disabled" OnClick="@this.OpenFileDialog">
+        Choose File
+    </MudButton>
+</MudStack>
\ 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
new file mode 100644
index 00000000..5d1b7f04
--- /dev/null
+++ b/app/MindWork AI Studio/Components/SelectFile.razor.cs	
@@ -0,0 +1,63 @@
+using AIStudio.Settings;
+
+using Microsoft.AspNetCore.Components;
+
+namespace AIStudio.Components;
+
+public partial class SelectFile : ComponentBase
+{
+    [Parameter]
+    public string File { get; set; } = string.Empty;
+    
+    [Parameter]
+    public EventCallback<string> FileChanged { get; set; }
+
+    [Parameter]
+    public bool Disabled { get; set; }
+
+    [Parameter]
+    public string Label { get; set; } = string.Empty;
+
+    [Parameter]
+    public string FileDialogTitle { get; set; } = "Select File";
+    
+    [Parameter]
+    public Func<string, string?> Validation { get; set; } = _ => null;
+    
+    [Inject]
+    private SettingsManager SettingsManager { get; init; } = null!;
+
+    [Inject]
+    public RustService RustService { get; set; } = null!;
+    
+    [Inject]
+    protected ILogger<SelectDirectory> Logger { get; init; } = null!;
+    
+    private static readonly Dictionary<string, object?> SPELLCHECK_ATTRIBUTES = new();
+    
+    #region Overrides of ComponentBase
+
+    protected override async Task OnInitializedAsync()
+    {
+        // Configure the spellchecking for the instance name input:
+        this.SettingsManager.InjectSpellchecking(SPELLCHECK_ATTRIBUTES);
+        await base.OnInitializedAsync();
+    }
+
+    #endregion
+
+    private void InternalFileChanged(string file)
+    {
+        this.File = file;
+        this.FileChanged.InvokeAsync(file);
+    }
+
+    private async Task OpenFileDialog()
+    {
+        var response = await this.RustService.SelectFile(this.FileDialogTitle, string.IsNullOrWhiteSpace(this.File) ? null : this.File);
+        this.Logger.LogInformation($"The user selected the file '{response.SelectedFilePath}'.");
+
+        if (!response.UserCancelled)
+            this.InternalFileChanged(response.SelectedFilePath);
+    }
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Tools/Rust/FileSelectionResponse.cs b/app/MindWork AI Studio/Tools/Rust/FileSelectionResponse.cs
new file mode 100644
index 00000000..fd3bed6f
--- /dev/null
+++ b/app/MindWork AI Studio/Tools/Rust/FileSelectionResponse.cs	
@@ -0,0 +1,8 @@
+namespace AIStudio.Tools.Rust;
+
+/// <summary>
+/// Data structure for selecting a file.
+/// </summary>
+/// <param name="UserCancelled">Was the file selection canceled?</param>
+/// <param name="SelectedFilePath">The selected file, if any.</param>
+public readonly record struct FileSelectionResponse(bool UserCancelled, string SelectedFilePath);
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Tools/Rust/PreviousFile.cs b/app/MindWork AI Studio/Tools/Rust/PreviousFile.cs
new file mode 100644
index 00000000..217ea064
--- /dev/null
+++ b/app/MindWork AI Studio/Tools/Rust/PreviousFile.cs	
@@ -0,0 +1,7 @@
+namespace AIStudio.Tools.Rust;
+
+/// <summary>
+/// Data structure for selecting a file when a previous file was selected.
+/// </summary>
+/// <param name="FilePath">The path of the previous file.</param>
+public readonly record struct PreviousFile(string FilePath);
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Tools/RustService.FileSystem.cs b/app/MindWork AI Studio/Tools/RustService.FileSystem.cs
index 6d5b931d..bd4c26a9 100644
--- a/app/MindWork AI Studio/Tools/RustService.FileSystem.cs	
+++ b/app/MindWork AI Studio/Tools/RustService.FileSystem.cs	
@@ -16,4 +16,17 @@ public sealed partial class RustService
         
         return await result.Content.ReadFromJsonAsync<DirectorySelectionResponse>(this.jsonRustSerializerOptions);
     }
+    
+    public async Task<FileSelectionResponse> SelectFile(string title, string? initialFile = null)
+    {
+        PreviousFile? previousFile = initialFile is null ? null : new (initialFile);
+        var result = await this.http.PostAsJsonAsync($"/select/file?title={title}", previousFile, this.jsonRustSerializerOptions);
+        if (!result.IsSuccessStatusCode)
+        {
+            this.logger!.LogError($"Failed to select a file: '{result.StatusCode}'");
+            return new FileSelectionResponse(true, string.Empty);
+        }
+        
+        return await result.Content.ReadFromJsonAsync<FileSelectionResponse>(this.jsonRustSerializerOptions);
+    }
 }
\ No newline at end of file
diff --git a/runtime/src/app_window.rs b/runtime/src/app_window.rs
index e83b2b57..f6af44cd 100644
--- a/runtime/src/app_window.rs
+++ b/runtime/src/app_window.rs
@@ -270,4 +270,53 @@ pub struct PreviousDirectory {
 pub struct DirectorySelectionResponse {
     user_cancelled: bool,
     selected_directory: String,
+}
+
+/// Let the user select a file.
+#[post("/select/file?<title>", data = "<previous_file>")]
+pub fn select_file(_token: APIToken, title: &str, previous_file: Option<Json<PreviousFile>>) -> Json<FileSelectionResponse> {
+    let file_path = match previous_file {
+        Some(previous) => {
+            let previous_path = previous.file_path.as_str();
+            FileDialogBuilder::new()
+                .set_title(title)
+                .set_directory(previous_path)
+                .pick_file()
+        },
+
+        None => {
+            FileDialogBuilder::new()
+                .set_title(title)
+                .pick_file()
+        },
+    };
+
+    match file_path {
+        Some(path) => {
+            info!("User selected file: {path:?}");
+            Json(FileSelectionResponse {
+                user_cancelled: false,
+                selected_file_path: path.to_str().unwrap().to_string(),
+            })
+        },
+
+        None => {
+            info!("User cancelled file selection.");
+            Json(FileSelectionResponse {
+                user_cancelled: true,
+                selected_file_path: String::from(""),
+            })
+        },
+    }
+}
+
+#[derive(Clone, Deserialize)]
+pub struct PreviousFile {
+    file_path: String,
+}
+
+#[derive(Serialize)]
+pub struct FileSelectionResponse {
+    user_cancelled: bool,
+    selected_file_path: String,
 }
\ No newline at end of file
diff --git a/runtime/src/runtime_api.rs b/runtime/src/runtime_api.rs
index 963900d7..26bbbf90 100644
--- a/runtime/src/runtime_api.rs
+++ b/runtime/src/runtime_api.rs
@@ -85,6 +85,7 @@ pub fn start_runtime_api() {
                 crate::app_window::check_for_update,
                 crate::app_window::install_update,
                 crate::app_window::select_directory,
+                crate::app_window::select_file,
                 crate::secret::get_secret,
                 crate::secret::store_secret,
                 crate::secret::delete_secret,