485 lines
18 KiB
Plaintext
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;
|
|
}
|
|
}
|
|
}
|
|
|