Files
I150/srcs/RobotNet10/Components/RobotNet10.ScriptEditor/Components/FolderExplorerItem.razor
2026-07-03 16:37:12 +07:00

485 lines
18 KiB
Plaintext

@using Microsoft.AspNetCore.Components.Web
@using Microsoft.JSInterop
@using MudBlazor
@using RobotNet10.ScriptEditor.Models
@using RobotNet10.ScriptEditor.Clients
@using RobotNet10.ScriptEditor.Services
@using RobotNet10.ScriptEditor.Dialogs
@using RobotNet10.ScriptEngine.Shared
@inject FileManagerHubClient FileManagerClient
@inject ScriptWorkspace Workspace
@inject IDialogService DialogService
@inject IJSRuntime JSRuntime
@inject ISnackbar Snackbar
@implements IDisposable
<MudMenu Class="w-100" AnchorOrigin="Origin.BottomRight" TransformOrigin="Origin.TopLeft" Size="@Size.Small" PositionAtCursor Dense ActivationEvent="@MouseEvent.RightClick" @bind-Open="ShowContextMenu">
<ActivatorContent>
<div class="folder-explorer-item"
data-folder-path="@Folder.Path"
@onclick:stopPropagation="true"
@onclick:preventDefault="true"
@onclick="HandleLeftClick"
@oncontextmenu="HandleRightClick">
<input id="@ItemId" type="radio" name="@RadioName" hidden />
<div class="folder-item-content" data-folder-path="@Folder.Path" data-level="@Folder.Level">
@for (int i = 0; i < Folder.Level - 1; i++)
{
<div class="folder-indent-guide"></div>
}
<span class="folder-expand-icon @(Folder.IsExpanded ? "expanded" : "")"
@onclick:stopPropagation="true" @onclick="ToggleExpand">
<text>▶</text>
</span>
<span class="folder-icon mdi @(Folder.IsExpanded ? "mdi-folder-open" : "mdi-folder")"></span>
<span class="folder-name" data-folder-path="@Folder.Path" data-name="name">@Folder.Name</span>
<span class="folder-badge warning" data-folder-path="@Folder.Path" data-badge="warning" data-count="@Folder.WarningCount" title="@Folder.WarningCount warning(s)">@Folder.WarningCount</span>
<span class="folder-badge error" data-folder-path="@Folder.Path" data-badge="error" data-count="@Folder.ErrorCount" title="@Folder.ErrorCount error(s)">@Folder.ErrorCount</span>
<span class="folder-badge modified" data-folder-path="@Folder.Path" data-badge="modified" title="Modified">●</span>
</div>
</div>
</ActivatorContent>
<ChildContent>
<MudMenuItem Icon="@Icons.Material.Filled.CreateNewFolder" OnClick="HandleCreateFolder">
Create Folder
</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.NoteAdd" OnClick="HandleCreateFile">
Create File
</MudMenuItem>
<MudDivider />
<MudMenuItem Icon="@Icons.Material.Filled.Edit" OnClick="HandleRename">
Rename
</MudMenuItem>
<MudDivider />
<MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="HandleDelete">
<MudText Color="Color.Error">Delete</MudText>
</MudMenuItem>
</ChildContent>
</MudMenu>
@if (Folder.IsExpanded && (Folder.Folders.Any() || Folder.Files.Any()))
{
<div class="folder-children">
@foreach (var subFolder in Folder.Folders.OrderBy(f => f.Name))
{
<FolderExplorerItem Folder="@subFolder" @key="@subFolder.Path" />
}
@foreach (var file in Folder.Files.OrderBy(f => f.Name))
{
<FileExplorerItem File="@file" @key="@file.Path" />
}
</div>
}
@code {
[CascadingParameter(Name = "FileExplorerRadioName")]
protected string RadioName { get; set; } = "script-explorer-item";
[Parameter, EditorRequired]
public ScriptFolder Folder { get; set; } = null!;
private Guid ItemId = Guid.NewGuid();
private bool ShowContextMenu { get; set; }
private ScriptFolder? _previousFolder;
private bool HasChildren => Folder.Folders.Any() || Folder.Files.Any();
public override async Task SetParametersAsync(ParameterView parameters)
{
await base.SetParametersAsync(parameters);
// Try to get the Folder parameter using nameof for type safety
if (parameters.TryGetValue<ScriptFolder>(nameof(Folder), out var folderParameter))
{
// Unsubscribe from old folder events if folder changed
if (_previousFolder != null && _previousFolder != folderParameter)
{
_previousFolder.Modified -= OnFolderModified;
_previousFolder.NameChanged -= OnFolderNameChanged;
_previousFolder.DiagnosticsChanged -= OnFolderDiagnosticsChanged;
_previousFolder.ChildrenChanged -= OnFolderChildrenChanged;
}
folderParameter.Modified += OnFolderModified;
folderParameter.NameChanged += OnFolderNameChanged;
folderParameter.DiagnosticsChanged += OnFolderDiagnosticsChanged;
folderParameter.ChildrenChanged += OnFolderChildrenChanged;
_previousFolder = folderParameter;
// Update UI via JavaScript
await UpdateBadgesVisibility();
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
// Only update badges on first render or when explicitly needed
if (firstRender && Folder != null)
{
await UpdateBadgesVisibility();
}
// Radio button state is managed via CheckRadioById and UncheckRadioByName
}
private async Task UpdateBadgesVisibility()
{
try
{
var folderPath = Folder.Path;
await JSRuntime.InvokeVoidAsync("fileExplorer.updateFolderBadges",
folderPath,
Folder.WarningCount > 0,
Folder.ErrorCount > 0,
Folder.IsModified,
Folder.WarningCount.ToString(),
Folder.ErrorCount.ToString(),
Folder.Name);
}
catch
{
// Ignore JS errors
}
}
private void ToggleExpand()
{
Folder.IsExpanded = !Folder.IsExpanded;
StateHasChanged();
}
private async Task HandleLeftClick(MouseEventArgs e)
{
// Only toggle expand if this folder is already selected
var isCurrentlySelected = Workspace.SelectedFolder == Folder;
if (isCurrentlySelected && HasChildren)
{
ToggleExpand();
}
await JSRuntime.InvokeVoidAsync("fileExplorer.UncheckRadioByName", RadioName);
await JSRuntime.InvokeVoidAsync("fileExplorer.CheckRadioById", ItemId);
Workspace.SelectedFolder = Folder;
StateHasChanged();
}
private async Task HandleRightClick(MouseEventArgs e)
{
// Right click only selects the folder, does not toggle expand/collapse
await JSRuntime.InvokeVoidAsync("fileExplorer.UncheckRadioByName", RadioName);
await JSRuntime.InvokeVoidAsync("fileExplorer.CheckRadioById", ItemId);
Workspace.SelectedFolder = Folder;
StateHasChanged();
}
private void OnFolderModified()
{
InvokeAsync(async () =>
{
await UpdateBadgesVisibility();
StateHasChanged();
});
}
private void OnFolderNameChanged()
{
InvokeAsync(async () =>
{
await UpdateBadgesVisibility();
StateHasChanged();
});
}
private void OnFolderDiagnosticsChanged(int warningCount, int errorCount)
{
InvokeAsync(async () =>
{
await UpdateBadgesVisibility();
StateHasChanged();
});
}
private void OnFolderChildrenChanged()
{
InvokeAsync(() =>
{
// If folder was collapsed and now has children (new file/folder added), expand it
if (!Folder.IsExpanded && HasChildren)
{
Folder.IsExpanded = true;
}
StateHasChanged();
});
}
private async Task HandleRename()
{
ShowContextMenu = false;
var parameters = new DialogParameters<RenameDialog>
{
{ x => x.CurrentName, Folder.Name },
{ x => x.ItemType, "Folder" },
{ x => x.RequireCsExtension, false }
};
var options = new DialogOptions
{
CloseOnEscapeKey = true,
MaxWidth = MaxWidth.Small,
FullWidth = true
};
var dialog = await DialogService.ShowAsync<RenameDialog>("Rename Folder", parameters, options);
var result = await dialog.Result;
if (result is not null && !result.Canceled && result.Data is string newName)
{
try
{
var parentPath = Folder.Parent?.Path ?? "";
var newPath = string.IsNullOrEmpty(parentPath) ? newName : System.IO.Path.Combine(parentPath, newName);
await FileManagerClient.CreateFolderAsync(newPath);
// Move all files and subfolders recursively
await MoveFolderContentsAsync(Folder.Path, newPath);
await FileManagerClient.DeleteFolderAsync(Folder.Path);
Folder.Name = newName;
Snackbar.Add($"Folder renamed successfully to '{newName}'", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Failed to rename folder: {ex.Message}", Severity.Error);
}
}
}
private async Task HandleCreateFolder()
{
ShowContextMenu = false;
var options = new DialogOptions
{
CloseOnEscapeKey = true,
MaxWidth = MaxWidth.Small,
FullWidth = true
};
var dialog = await DialogService.ShowAsync<CreateFolderDialog>("Create New Folder", options);
var result = await dialog.Result;
if (result is not null && !result.Canceled && result.Data is string folderName)
{
try
{
var newPath = System.IO.Path.Combine(Folder.Path, folderName);
await FileManagerClient.CreateFolderAsync(newPath);
// Add folder to workspace directly
var level = Folder.Level + 1;
var folderDto = new ScriptFolderDto(folderName, level, [], []);
Workspace.AddFolder(folderDto, Folder);
}
catch (Exception ex)
{
Snackbar.Add($"Failed to create folder: {ex.Message}", Severity.Error);
}
}
}
private async Task HandleCreateFile()
{
ShowContextMenu = false;
var options = new DialogOptions
{
CloseOnEscapeKey = true,
MaxWidth = MaxWidth.Small,
FullWidth = true
};
var dialog = await DialogService.ShowAsync<CreateFileDialog>("Create New File", options);
var result = await dialog.Result;
if (result is not null && !result.Canceled && result.Data is string fileName)
{
try
{
var newPath = System.IO.Path.Combine(Folder.Path, fileName);
await FileManagerClient.CreateFileAsync(newPath, "");
// Add file to workspace directly
var level = Folder.Level + 1;
var fileDto = new ScriptFileDto(fileName, level, "");
Workspace.AddFile(fileDto, Folder);
// Expand folder if it's collapsed so the new file is visible
if (!Folder.IsExpanded)
{
Folder.IsExpanded = true;
StateHasChanged();
}
}
catch (Exception ex)
{
Snackbar.Add($"Failed to create file: {ex.Message}", Severity.Error);
}
}
}
private async Task HandleDelete()
{
ShowContextMenu = false;
var parameters = new DialogParameters<ConfirmDialog>
{
{ x => x.Title, "Delete Folder" },
{ x => x.Message, $"Are you sure you want to delete '{Folder.Name}' and all its contents?" },
{ x => x.ConfirmText, "Delete" },
{ x => x.ConfirmColor, Color.Error }
};
var options = new DialogOptions
{
CloseOnEscapeKey = true,
MaxWidth = MaxWidth.Small,
FullWidth = true
};
var dialog = await DialogService.ShowAsync<ConfirmDialog>("Delete Folder", parameters, options);
var result = await dialog.Result;
if (result is not null && !result.Canceled && result.Data is bool confirmed && confirmed)
{
try
{
await FileManagerClient.DeleteFolderAsync(Folder.Path);
// Update workspace immediately after successful deletion
// (Server may not send FolderDeleted event to the client that performed the deletion)
Workspace.RemoveFolder(Folder);
}
catch (Exception ex)
{
Snackbar.Add($"Failed to delete folder: {ex.Message}", Severity.Error);
}
}
}
/// <summary>
/// Recursively moves all files and subfolders from oldPath to newPath.
/// </summary>
private async Task MoveFolderContentsAsync(string oldPath, string newPath)
{
// Move all files in current folder
foreach (var file in Folder.Files.ToList())
{
try
{
// Calculate new file path by replacing old folder path with new folder path
var relativePath = file.Path.StartsWith(oldPath)
? file.Path.Substring(oldPath.Length).TrimStart(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar)
: file.Name;
var newFilePath = System.IO.Path.Combine(newPath, relativePath);
await FileManagerClient.CreateFileAsync(newFilePath, file.Code);
await FileManagerClient.DeleteFileAsync(file.Path);
}
catch (Exception ex)
{
Snackbar.Add($"Failed to move file '{file.Name}': {ex.Message}", Severity.Warning);
}
}
// Move all subfolders recursively
foreach (var subFolder in Folder.Folders.ToList())
{
try
{
// Calculate new subfolder path by replacing old folder path with new folder path
var relativePath = subFolder.Path.StartsWith(oldPath)
? subFolder.Path.Substring(oldPath.Length).TrimStart(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar)
: subFolder.Name;
var newSubFolderPath = System.IO.Path.Combine(newPath, relativePath);
await FileManagerClient.CreateFolderAsync(newSubFolderPath);
// Recursively move all files and subfolders in this subfolder
await MoveFolderContentsRecursiveAsync(subFolder, oldPath, newPath);
await FileManagerClient.DeleteFolderAsync(subFolder.Path);
}
catch (Exception ex)
{
Snackbar.Add($"Failed to move subfolder '{subFolder.Name}': {ex.Message}", Severity.Warning);
}
}
}
/// <summary>
/// Helper method to recursively move contents of a subfolder.
/// </summary>
private async Task MoveFolderContentsRecursiveAsync(ScriptFolder folder, string oldBasePath, string newBasePath)
{
// Move all files in this folder
foreach (var file in folder.Files.ToList())
{
try
{
var relativePath = file.Path.StartsWith(oldBasePath)
? file.Path.Substring(oldBasePath.Length).TrimStart(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar)
: file.Name;
var newFilePath = System.IO.Path.Combine(newBasePath, relativePath);
await FileManagerClient.CreateFileAsync(newFilePath, file.Code);
await FileManagerClient.DeleteFileAsync(file.Path);
}
catch (Exception ex)
{
Snackbar.Add($"Failed to move file '{file.Name}': {ex.Message}", Severity.Warning);
}
}
// Recursively move all nested subfolders
foreach (var nestedFolder in folder.Folders.ToList())
{
try
{
var relativePath = nestedFolder.Path.StartsWith(oldBasePath)
? nestedFolder.Path.Substring(oldBasePath.Length).TrimStart(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar)
: nestedFolder.Name;
var newNestedFolderPath = System.IO.Path.Combine(newBasePath, relativePath);
await FileManagerClient.CreateFolderAsync(newNestedFolderPath);
await MoveFolderContentsRecursiveAsync(nestedFolder, oldBasePath, newBasePath);
await FileManagerClient.DeleteFolderAsync(nestedFolder.Path);
}
catch (Exception ex)
{
Snackbar.Add($"Failed to move nested folder '{nestedFolder.Name}': {ex.Message}", Severity.Warning);
}
}
}
public void Dispose()
{
if (Folder != null)
{
Folder.Modified -= OnFolderModified;
Folder.NameChanged -= OnFolderNameChanged;
Folder.DiagnosticsChanged -= OnFolderDiagnosticsChanged;
Folder.ChildrenChanged -= OnFolderChildrenChanged;
}
}
}