Added drag-and-drop support for loading file content

This commit is contained in:
Thorsten Sommer 2026-07-06 16:52:48 +02:00
parent 0476595f2d
commit 38fe814ff1
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
6 changed files with 208 additions and 28 deletions

View File

@ -1,6 +1,7 @@
@attribute [Route(Routes.ASSISTANT_GRAMMAR_SPELLING)]
@inherits AssistantBaseCore<AIStudio.Dialogs.Settings.SettingsDialogGrammarSpelling>
<ReadFileContent Text="@T("Load text from file")" @bind-FileContent="@this.inputText" EnableDragDrop="true" Layer="@DropLayers.ASSISTANTS" CatchAllDocuments="true"/>
<MudTextField T="string" @bind-Text="@this.inputText" Validation="@this.ValidateText" AdornmentIcon="@Icons.Material.Filled.DocumentScanner" Adornment="Adornment.Start" Label="@T("Your input to check")" Variant="Variant.Outlined" Lines="6" AutoGrow="@true" MaxLines="12" Class="mb-3" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
<EnumSelection T="CommonLanguages" NameFunc="@(language => language.NameSelectingOptional())" @bind-Value="@this.selectedTargetLanguage" Icon="@Icons.Material.Filled.Translate" Label="@T("Language")" AllowOther="@true" OtherValue="CommonLanguages.OTHER" @bind-OtherInput="@this.customTargetLanguage" ValidateOther="@this.ValidateCustomLanguage" LabelOther="@T("Custom language")" />
<ProviderSelection @bind-ProviderSettings="@this.ProviderSettings" ValidateProvider="@this.ValidatingProvider"/>

View File

@ -1405,6 +1405,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::PROGRAMMINGLANGUAGESEXTENSIONS::T342
-- Please provide a text as input. You might copy the desired text from a document or a website.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::GRAMMARSPELLING::ASSISTANTGRAMMARSPELLING::T137304886"] = "Please provide a text as input. You might copy the desired text from a document or a website."
-- Load text from file
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::GRAMMARSPELLING::ASSISTANTGRAMMARSPELLING::T2210807298"] = "Load text from file"
-- Proofread
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::GRAMMARSPELLING::ASSISTANTGRAMMARSPELLING::T2325568297"] = "Proofread"
@ -2797,6 +2800,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T900237532"] = "Provid
-- Failed to load file content
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T1989554334"] = "Failed to load file content"
-- Drop one file here to load its content.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T2274562398"] = "Drop one file here to load its content."
-- Use file content as input
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T3499386973"] = "Use file content as input"

View File

@ -1,11 +1,23 @@
@inherits MSGComponentBase
<MudButton StartIcon="@Icons.Material.Filled.Description" OnClick="@(async () => await this.SelectFile())" Variant="Variant.Filled" Class="mb-3" Disabled="@this.Disabled">
@if (string.IsNullOrWhiteSpace(this.Text))
{
@T("Use file content as input")
}
else
{
@this.Text
}
</MudButton>
@if (this.EnableDragDrop)
{
<div @onmouseenter="@this.OnMouseEnter" @onmouseleave="@this.OnMouseLeave">
<MudPaper Outlined="true" Class="@this.dragClass">
<MudStack Row="true" AlignItems="AlignItems.Center" StretchItems="StretchItems.None" Wrap="Wrap.Wrap">
<MudButton StartIcon="@Icons.Material.Filled.Description" OnClick="@(async () => await this.SelectFile())" Variant="Variant.Filled" Disabled="@this.Disabled">
@this.ButtonText
</MudButton>
<MudText Typo="Typo.body2">
@T("Drop one file here to load its content.")
</MudText>
</MudStack>
</MudPaper>
</div>
}
else
{
<MudButton StartIcon="@Icons.Material.Filled.Description" OnClick="@(async () => await this.SelectFile())" Variant="Variant.Filled" Class="mb-3" Disabled="@this.Disabled">
@this.ButtonText
</MudButton>
}

View File

@ -1,3 +1,5 @@
using AIStudio.Tools;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services;
using AIStudio.Tools.Validation;
@ -18,6 +20,21 @@ public partial class ReadFileContent : MSGComponentBase
[Parameter]
public bool Disabled { get; set; }
[Parameter]
public bool EnableDragDrop { get; set; }
/// <summary>
/// On which layer to register the drop area. Higher layers have priority over lower layers.
/// </summary>
[Parameter]
public int Layer { get; set; }
/// <summary>
/// Catch all documents that are hovered over the AI Studio window and not only over the drop zone.
/// </summary>
[Parameter]
public bool CatchAllDocuments { get; set; }
[Inject]
private RustService RustService { get; init; } = null!;
@ -30,12 +47,104 @@ public partial class ReadFileContent : MSGComponentBase
[Inject]
private PandocAvailabilityService PandocAvailabilityService { get; init; } = null!;
private const string DEFAULT_DRAG_CLASS = "relative rounded-lg border-2 border-dashed pa-3 mb-3 mud-width-full";
private string ButtonText => string.IsNullOrWhiteSpace(this.Text) ? T("Use file content as input") : this.Text;
private string dragClass = DEFAULT_DRAG_CLASS;
private uint numDropAreasAboveThis;
private bool isComponentHovered;
#region Overrides of MSGComponentBase
protected override async Task OnInitializedAsync()
{
if (this.EnableDragDrop)
{
this.ApplyFilters([], [ Event.TAURI_EVENT_RECEIVED, Event.REGISTER_FILE_DROP_AREA, Event.UNREGISTER_FILE_DROP_AREA ]);
await this.MessageBus.SendMessage(this, Event.REGISTER_FILE_DROP_AREA, this.Layer);
}
await base.OnInitializedAsync();
}
protected override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
{
if (!this.EnableDragDrop)
return;
if (this.Disabled && triggeredEvent == Event.TAURI_EVENT_RECEIVED)
return;
switch (triggeredEvent)
{
case Event.REGISTER_FILE_DROP_AREA when sendingComponent != this:
{
if(data is int layer && layer > this.Layer)
{
this.numDropAreasAboveThis++;
this.ClearDragClass();
}
break;
}
case Event.UNREGISTER_FILE_DROP_AREA when sendingComponent != this:
{
if(data is int layer && layer > this.Layer && this.numDropAreasAboveThis > 0)
this.numDropAreasAboveThis--;
break;
}
case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_HOVERED }:
if(!this.CanCatchDroppedFile())
return;
this.SetDragClass();
this.StateHasChanged();
break;
case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_CANCELED }:
case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.WINDOW_NOT_FOCUSED }:
this.isComponentHovered = false;
this.ClearDragClass();
this.StateHasChanged();
break;
case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_DROPPED, Payload: var paths }:
if(!this.CanCatchDroppedFile())
return;
await this.LoadFirstValidFile(paths);
this.ClearDragClass();
this.StateHasChanged();
break;
}
}
#endregion
private async Task SelectFile()
{
if (this.Disabled)
return;
if (!await this.EnsurePandocAvailability())
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);
}
private async Task<bool> EnsurePandocAvailability()
{
// Ensure that Pandoc is installed and ready:
var pandocState = await this.PandocAvailabilityService.EnsureAvailabilityAsync(
showSuccessMessage: false,
@ -45,38 +154,78 @@ public partial class ReadFileContent : MSGComponentBase
if (!pandocState.IsAvailable)
{
this.Logger.LogWarning("The user cancelled the Pandoc installation or Pandoc is not available. Aborting file selection.");
return;
return false;
}
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 true;
}
private async Task LoadFirstValidFile(List<string> paths)
{
if (!await this.EnsurePandocAvailability())
return;
foreach (var path in paths)
{
if (await this.LoadFileIfValid(path))
return;
}
}
private async Task<bool> LoadFileIfValid(string filePath)
{
if(!File.Exists(filePath))
{
this.Logger.LogWarning("Selected file does not exist: '{FilePath}'", filePath);
return false;
}
if(!File.Exists(selectedFile.SelectedFilePath))
if (!await FileExtensionValidation.IsExtensionValidWithNotifyAsync(FileExtensionValidation.UseCase.DIRECTLY_LOADING_CONTENT, filePath))
{
this.Logger.LogWarning("Selected file does not exist: '{FilePath}'", selectedFile.SelectedFilePath);
return;
}
if (!await FileExtensionValidation.IsExtensionValidWithNotifyAsync(FileExtensionValidation.UseCase.DIRECTLY_LOADING_CONTENT, selectedFile.SelectedFilePath))
{
this.Logger.LogWarning("User attempted to load unsupported file: {FilePath}", selectedFile.SelectedFilePath);
return;
this.Logger.LogWarning("User attempted to load unsupported file: {FilePath}", filePath);
return false;
}
try
{
var fileContent = await UserFile.LoadFileData(selectedFile.SelectedFilePath, this.RustService, this.DialogService);
var fileContent = await UserFile.LoadFileData(filePath, this.RustService, this.DialogService);
await this.FileContentChanged.InvokeAsync(fileContent);
this.Logger.LogInformation("Successfully loaded file content: {FilePath}", selectedFile.SelectedFilePath);
this.Logger.LogInformation("Successfully loaded file content: {FilePath}", filePath);
return true;
}
catch (Exception ex)
{
this.Logger.LogError(ex, "Failed to load file content: {FilePath}", selectedFile.SelectedFilePath);
this.Logger.LogError(ex, "Failed to load file content: {FilePath}", filePath);
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Error, T("Failed to load file content")));
return false;
}
}
}
private bool CanCatchDroppedFile() => this.numDropAreasAboveThis is 0 && (this.isComponentHovered || this.CatchAllDocuments);
private void SetDragClass() => this.dragClass = $"{DEFAULT_DRAG_CLASS} mud-border-primary border-4";
private void ClearDragClass() => this.dragClass = DEFAULT_DRAG_CLASS;
private void OnMouseEnter(EventArgs _)
{
if(this.Disabled || this.numDropAreasAboveThis > 0)
return;
this.Logger.LogDebug("Read file content component is hovered.");
this.isComponentHovered = true;
this.SetDragClass();
this.StateHasChanged();
}
private void OnMouseLeave(EventArgs _)
{
if(this.Disabled)
return;
this.Logger.LogDebug("Read file content component is no longer hovered.");
this.isComponentHovered = false;
this.ClearDragClass();
this.StateHasChanged();
}
}

View File

@ -1407,6 +1407,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::PROGRAMMINGLANGUAGESEXTENSIONS::T342
-- Please provide a text as input. You might copy the desired text from a document or a website.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::GRAMMARSPELLING::ASSISTANTGRAMMARSPELLING::T137304886"] = "Bitte geben Sie einen Text ein. Sie können den gewünschten Text aus einem Dokument oder einer Website kopieren."
-- Load text from file
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::GRAMMARSPELLING::ASSISTANTGRAMMARSPELLING::T2210807298"] = "Text aus Datei laden"
-- Proofread
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::GRAMMARSPELLING::ASSISTANTGRAMMARSPELLING::T2325568297"] = "Korrekturlesen"
@ -2799,6 +2802,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T900237532"] = "Anbiet
-- Failed to load file content
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T1989554334"] = "Laden des Dateiinhalts fehlgeschlagen"
-- Drop one file here to load its content.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T2274562398"] = "Datei hier ablegen, um ihren Inhalt zu laden."
-- Use file content as input
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T3499386973"] = "Dokumenteninhalt als Eingabe verwenden"

View File

@ -1407,6 +1407,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::PROGRAMMINGLANGUAGESEXTENSIONS::T342
-- Please provide a text as input. You might copy the desired text from a document or a website.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::GRAMMARSPELLING::ASSISTANTGRAMMARSPELLING::T137304886"] = "Please provide a text as input. You might copy the desired text from a document or a website."
-- Load text from file
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::GRAMMARSPELLING::ASSISTANTGRAMMARSPELLING::T2210807298"] = "Load text from file"
-- Proofread
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::GRAMMARSPELLING::ASSISTANTGRAMMARSPELLING::T2325568297"] = "Proofread"
@ -2799,6 +2802,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T900237532"] = "Provid
-- Failed to load file content
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T1989554334"] = "Failed to load file content"
-- Drop one file here to load its content.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T2274562398"] = "Drop one file here to load its content."
-- Use file content as input
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T3499386973"] = "Use file content as input"