556 lines
21 KiB
Plaintext
556 lines
21 KiB
Plaintext
@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
|
|
|
|
<SidebarAccordionItem TabName="workspace" Label="WORKSPACE">
|
|
<HeaderActions>
|
|
<IconButton Icon="folder-plus" Title="Add Folder" OnClick="HandleAddFolder" />
|
|
<IconButton Icon="file-plus" Title="Add File" OnClick="HandleAddFile" />
|
|
<IconButton Icon="content-save-all text-primary" Title="Save All" OnClick="HandleSaveAll" />
|
|
<IconButton Icon="package-down" Title="Backup" OnClick="HandleBackup" />
|
|
<IconButton Icon="package-up" Title="Restore" OnClick="HandleRestore" />
|
|
</HeaderActions>
|
|
<ChildContent>
|
|
<CascadingValue Value="@RadioName" Name="FileExplorerRadioName">
|
|
<div class="file-explorer-container" @onclick="HandleContainerClick" @oncontextmenu="HandleContainerRightClick" @oncontextmenu:preventDefault="true" @oncontextmenu:stopPropagation="true">
|
|
<div class="file-explorer-tree" @ref="TreeContainerRef">
|
|
@foreach (var folder in Workspace.Folders.OrderBy(f => f.Name))
|
|
{
|
|
<FolderExplorerItem Folder="@folder" @key="@folder.Path" />
|
|
}
|
|
@foreach (var file in Workspace.Files.OrderBy(f => f.Name))
|
|
{
|
|
<FileExplorerItem File="@file" @key="@file.Path" />
|
|
}
|
|
</div>
|
|
</div>
|
|
<MudMenu Class="flex-grow-1 w-100" AnchorOrigin="Origin.BottomRight" TransformOrigin="Origin.TopLeft" Size="@Size.Small" PositionAtCursor Dense ActivationEvent="@MouseEvent.RightClick">
|
|
<ActivatorContent>
|
|
<div class="w-100 h-100" id="@_contextMenuActivatorId" @ref="ContextMenuActivator"></div>
|
|
</ActivatorContent>
|
|
<ChildContent>
|
|
<MudMenuItem Icon="@Icons.Material.Filled.CreateNewFolder" OnClick="HandleCreateFolderFromContext">
|
|
Create Folder
|
|
</MudMenuItem>
|
|
<MudMenuItem Icon="@Icons.Material.Filled.NoteAdd" OnClick="HandleCreateFileFromContext">
|
|
Create File
|
|
</MudMenuItem>
|
|
</ChildContent>
|
|
</MudMenu>
|
|
</CascadingValue>
|
|
</ChildContent>
|
|
</SidebarAccordionItem>
|
|
|
|
@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<FileExplorer>? _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<IJSObjectReference>(
|
|
"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<CreateFolderDialog>("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<CreateFileDialog>("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<CreateFolderDialog>("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<CreateFileDialog>("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<ScriptFile> GetAllModifiedFiles(IEnumerable<ScriptFolder> 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<CreateBackupDialog>("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<RestoreBackupDialog>
|
|
{
|
|
{ x => x.Backups, backups }
|
|
};
|
|
|
|
var options = new DialogOptions
|
|
{
|
|
CloseOnEscapeKey = true,
|
|
MaxWidth = MaxWidth.Small,
|
|
FullWidth = true
|
|
};
|
|
|
|
var dialog = await DialogService.ShowAsync<RestoreBackupDialog>("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();
|
|
}
|
|
}
|