@using Microsoft.AspNetCore.Components.Web @using Microsoft.JSInterop @using MudBlazor @using RobotNet10.ScriptEditor.Clients @using RobotNet10.ScriptEditor.Services @using RobotNet10.ScriptEditor.Models @using RobotNet10.ScriptEditor.Components @using RobotNet10.ScriptEditor.Dialogs @using RobotNet10.ScriptEngine.Shared @inject FileManagerHubClient FileManagerClient @inject ScriptManagerHubClient ScriptManagerClient @inject ScriptWorkspace Workspace @inject IJSRuntime JSRuntime @inject IDialogService DialogService @inject ISnackbar Snackbar @implements IAsyncDisposable @foreach (var folder in Workspace.Folders.OrderBy(f => f.Name)) { } @foreach (var file in Workspace.Files.OrderBy(f => f.Name)) { } Create Folder Create File @code { private ElementReference TreeContainerRef; private ElementReference ContextMenuActivator; private readonly string _contextMenuActivatorId = $"context-menu-activator-{Guid.NewGuid()}"; private const string RadioName = "script-explorer-item"; private bool ShowContextMenu { get; set; } private IJSObjectReference? _jsModule; private DotNetObjectReference? _dotNetRef; private double _contextMenuX; private double _contextMenuY; protected override void OnInitialized() { Workspace.RootChanged += OnWorkspaceRootChanged; // Subscribe to FileManagerHubClient events to update workspace FileManagerClient.FileCreated += OnFileCreated; FileManagerClient.FileDeleted += OnFileDeleted; FileManagerClient.FolderCreated += OnFolderCreated; FileManagerClient.FolderDeleted += OnFolderDeleted; } protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { _jsModule = await JSRuntime.InvokeAsync( "import", "./_content/RobotNet10.ScriptEditor/fileExplorer.js"); if (_jsModule != null) { await _jsModule.InvokeVoidAsync("init", TreeContainerRef); // Register Esc key handler at document level _dotNetRef = DotNetObjectReference.Create(this); await _jsModule.InvokeVoidAsync("registerEscapeKeyHandler", _dotNetRef); } } } [JSInvokable] public async Task HandleEscapeKey() { // Reset selection when Esc is pressed Workspace.SelectedFile = null; Workspace.SelectedFolder = null; // Uncheck all radio buttons if (_jsModule != null) { await _jsModule.InvokeVoidAsync("UncheckRadioByName", RadioName); } StateHasChanged(); } private void OnWorkspaceRootChanged() { InvokeAsync(() => { StateHasChanged(); }); } private void HandleContainerClick(MouseEventArgs e) { // Clear selection when clicking on empty area (container itself, not children) // Children will handle their own clicks and stop propagation Workspace.SelectedFile = null; Workspace.SelectedFolder = null; StateHasChanged(); } private async Task HandleContainerRightClick(MouseEventArgs e) { // Right click on empty area - show context menu // Only show if clicking directly on container (not on file/folder items) // Clear selection first Workspace.SelectedFile = null; Workspace.SelectedFolder = null; // Store click position for context menu (use ClientX/ClientY for viewport coordinates) _contextMenuX = e.ClientX; _contextMenuY = e.ClientY; // Update activator position via JavaScript if (_jsModule != null) { await _jsModule.InvokeVoidAsync("setElementPosition", _contextMenuActivatorId, _contextMenuX, _contextMenuY); } // Update activator position and show menu await InvokeAsync(() => { ShowContextMenu = true; StateHasChanged(); }); } private async Task HandleCreateFolderFromContext() { ShowContextMenu = false; var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; var dialog = await DialogService.ShowAsync("Create New Folder", options); var result = await dialog.Result; if (result is not null && !result.Canceled && result.Data is string folderName) { try { // Create folder at root level (no parent) var newPath = folderName; await FileManagerClient.CreateFolderAsync(newPath); // Add folder to workspace at root level var level = 1; var folderDto = new ScriptFolderDto(folderName, level, [], []); Workspace.AddFolder(folderDto, null); } catch (Exception ex) { Snackbar.Add($"Failed to create folder: {ex.Message}", Severity.Error); } } } private async Task HandleCreateFileFromContext() { ShowContextMenu = false; var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; var dialog = await DialogService.ShowAsync("Create New File", options); var result = await dialog.Result; if (result is not null && !result.Canceled && result.Data is string fileName) { try { // Create file at root level (no parent) var newPath = fileName; await FileManagerClient.CreateFileAsync(newPath, ""); // Add file to workspace at root level var level = 1; var fileDto = new ScriptFileDto(fileName, level, ""); Workspace.AddFile(fileDto, null); } catch (Exception ex) { Snackbar.Add($"Failed to create file: {ex.Message}", Severity.Error); } } } private async Task HandleAddFolder(MouseEventArgs e) { var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; var dialog = await DialogService.ShowAsync("Create New Folder", options); var result = await dialog.Result; if (result is not null && !result.Canceled && result.Data is string folderName) { try { var parentFolder = Workspace.SelectedFolder; var parentPath = parentFolder?.Path ?? ""; var newPath = string.IsNullOrEmpty(parentPath) ? folderName : System.IO.Path.Combine(parentPath, folderName); await FileManagerClient.CreateFolderAsync(newPath); // Add folder to workspace directly var level = parentFolder != null ? parentFolder.Level + 1 : 1; var folderDto = new ScriptFolderDto(folderName, level, [], []); Workspace.AddFolder(folderDto, parentFolder); } catch (Exception ex) { Snackbar.Add($"Failed to create folder: {ex.Message}", Severity.Error); } } } private async Task HandleAddFile(MouseEventArgs e) { var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; var dialog = await DialogService.ShowAsync("Create New File", options); var result = await dialog.Result; if (result is not null && !result.Canceled && result.Data is string fileName) { try { var parentFolder = Workspace.SelectedFolder; var parentPath = parentFolder?.Path ?? ""; var newPath = string.IsNullOrEmpty(parentPath) ? fileName : System.IO.Path.Combine(parentPath, fileName); await FileManagerClient.CreateFileAsync(newPath, ""); // Add file to workspace directly var level = parentFolder != null ? parentFolder.Level + 1 : 1; var fileDto = new ScriptFileDto(fileName, level, ""); Workspace.AddFile(fileDto, parentFolder); // Expand parent folder if it exists (ChildrenChanged event will handle the expansion) // The expansion will be handled by FolderExplorerItem.OnFolderChildrenChanged } catch (Exception ex) { Snackbar.Add($"Failed to create file: {ex.Message}", Severity.Error); } } } private async Task HandleSaveAll(MouseEventArgs e) { // Save all modified files var modifiedFiles = GetAllModifiedFiles(Workspace.Folders).Concat(Workspace.Files.Where(f => f.IsModified)); foreach (var file in modifiedFiles) { try { await FileManagerClient.SaveFileAsync(file.Path, file.Code); file.Saved(); } catch (Exception ex) { Snackbar.Add($"Failed to save file '{file.Name}': {ex.Message}", Severity.Error); } } StateHasChanged(); } private IEnumerable GetAllModifiedFiles(IEnumerable folders) { foreach (var folder in folders) { foreach (var file in folder.Files.Where(f => f.IsModified)) { yield return file; } foreach (var modifiedFile in GetAllModifiedFiles(folder.Folders)) { yield return modifiedFile; } } } private async Task HandleBackup(MouseEventArgs e) { var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; var dialog = await DialogService.ShowAsync("Create Backup", options); var result = await dialog.Result; if (result is not null && !result.Canceled && result.Data is string backupName) { try { var backupFileName = await FileManagerClient.CreateBackupAsync(backupName); Snackbar.Add($"Backup created successfully: {backupFileName}", Severity.Success); } catch (Exception ex) { Snackbar.Add($"Failed to create backup: {ex.Message}", Severity.Error); } } } private async Task HandleRestore(MouseEventArgs e) { try { var backups = await FileManagerClient.ListBackupsAsync(); var parameters = new DialogParameters { { x => x.Backups, backups } }; var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; var dialog = await DialogService.ShowAsync("Restore Backup", parameters, options); var result = await dialog.Result; if (result is not null && !result.Canceled && result.Data is string backupFileName) { try { await FileManagerClient.RestoreBackupAsync(backupFileName, replaceExisting: true); // Request edit permission before reinitializing workspace try { await FileManagerClient.RequestEditPermissionAsync(); } catch (Exception) { // Ignore if edit permission cannot be requested (e.g., state is not Idle) } // Reinitialize workspace with new root folder var rootFolder = await FileManagerClient.GetRootFolderAsync(); await Workspace.ReinitializeAsync(rootFolder); // Reset selections Workspace.SelectedFile = null; Workspace.SelectedFolder = null; StateHasChanged(); } catch (Exception ex) { Snackbar.Add($"Failed to restore backup: {ex.Message}", Severity.Error); } } } catch (Exception ex) { Snackbar.Add($"Failed to list backups: {ex.Message}", Severity.Error); } } private void OnFileCreated(string path, string? userId) { // Skip if this is our own action (we already updated workspace) // Only handle actions from other clients try { var file = Workspace.FindFileByPath(path); if (file == null) { // File doesn't exist in workspace, need to add it // Parse path to get name and parent var pathParts = path.Split(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar); var fileName = pathParts[^1]; var parentPath = pathParts.Length > 1 ? string.Join(System.IO.Path.DirectorySeparatorChar.ToString(), pathParts.Take(pathParts.Length - 1)) : ""; var parentFolder = string.IsNullOrEmpty(parentPath) ? null : Workspace.FindFolderByPath(parentPath); var level = parentFolder != null ? parentFolder.Level + 1 : 1; // Get file content from server (we need to read it) // For now, create with empty content - it will be loaded when opened var fileDto = new ScriptFileDto(fileName, level, ""); Workspace.AddFile(fileDto, parentFolder); } } catch (Exception ex) { Snackbar.Add($"Error updating workspace after file creation: {ex.Message}", Severity.Error); } } private void OnFolderCreated(string path, string? userId) { // Skip if this is our own action (we already updated workspace) // Only handle actions from other clients try { var folder = Workspace.FindFolderByPath(path); if (folder == null) { // Folder doesn't exist in workspace, need to add it // Parse path to get name and parent var pathParts = path.Split(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar); var folderName = pathParts[^1]; var parentPath = pathParts.Length > 1 ? string.Join(System.IO.Path.DirectorySeparatorChar.ToString(), pathParts.Take(pathParts.Length - 1)) : ""; var parentFolder = string.IsNullOrEmpty(parentPath) ? null : Workspace.FindFolderByPath(parentPath); var level = parentFolder != null ? parentFolder.Level + 1 : 1; var folderDto = new ScriptFolderDto(folderName, level, [], []); Workspace.AddFolder(folderDto, parentFolder); } } catch (Exception ex) { Snackbar.Add($"Error updating workspace after folder creation: {ex.Message}", Severity.Error); } } private void OnFileDeleted(string path, string? userId) { // Remove file from workspace directly InvokeAsync(() => { try { var file = Workspace.FindFileByPath(path); if (file != null) { Workspace.RemoveFile(file); // RootChanged event will trigger OnWorkspaceRootChanged which calls StateHasChanged // But we also call StateHasChanged here to ensure immediate UI update StateHasChanged(); } } catch (Exception ex) { Snackbar.Add($"Error updating workspace after file deletion: {ex.Message}", Severity.Error); } }); } private void OnFolderDeleted(string path, string? userId) { // Remove folder from workspace directly InvokeAsync(() => { try { var folder = Workspace.FindFolderByPath(path); if (folder != null) { Workspace.RemoveFolder(folder); // RootChanged event will trigger OnWorkspaceRootChanged which calls StateHasChanged // But we also call StateHasChanged here to ensure immediate UI update StateHasChanged(); } } catch (Exception ex) { Snackbar.Add($"Error updating workspace after folder deletion: {ex.Message}", Severity.Error); } }); } public async ValueTask DisposeAsync() { Workspace.RootChanged -= OnWorkspaceRootChanged; // Unsubscribe from FileManagerHubClient events FileManagerClient.FileCreated -= OnFileCreated; FileManagerClient.FolderCreated -= OnFolderCreated; FileManagerClient.FileDeleted -= OnFileDeleted; FileManagerClient.FolderDeleted -= OnFolderDeleted; // Unregister Esc key handler if (_jsModule != null) { try { await _jsModule.InvokeVoidAsync("unregisterEscapeKeyHandler"); await _jsModule.DisposeAsync(); } catch (JSDisconnectedException) { // Ignore if JS context is disconnected } } _dotNetRef?.Dispose(); } }