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 @@
+
+
+
+
+ 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
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 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 Validation { get; set; } = _ => null;
+
+ [Inject]
+ private SettingsManager SettingsManager { get; init; } = null!;
+
+ [Inject]
+ public RustService RustService { get; set; } = null!;
+
+ [Inject]
+ protected ILogger Logger { get; init; } = null!;
+
+ private static readonly Dictionary 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;
+
+///
+/// Data structure for selecting a file.
+///
+/// Was the file selection canceled?
+/// The selected file, if any.
+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;
+
+///
+/// Data structure for selecting a file when a previous file was selected.
+///
+/// The path of the previous file.
+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(this.jsonRustSerializerOptions);
}
+
+ public async Task 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(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?", data = "")]
+pub fn select_file(_token: APIToken, title: &str, previous_file: Option>) -> Json {
+ 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,