Initial commit
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using RobotNet10.Components.Clients;
|
||||
using RobotNet10.ScriptEngine.Shared;
|
||||
|
||||
namespace RobotNet10.ScriptEditor.Clients;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR HubClient for ConsoleHub - handles console logging events.
|
||||
/// </summary>
|
||||
public class ConsoleHubClient : HubClient
|
||||
{
|
||||
public event Action<string>? ErrorReceived;
|
||||
public event Action<string>? InfoReceived;
|
||||
public event Action<string>? WarningReceived;
|
||||
|
||||
public ConsoleHubClient(NavigationManager navigationManager)
|
||||
: base(navigationManager.ToAbsoluteUri(HubEndpoints.ScriptConsoleHubPath))
|
||||
{
|
||||
Connection.On<string>("Error", message => ErrorReceived?.Invoke(message));
|
||||
Connection.On<string>("Info", message => InfoReceived?.Invoke(message));
|
||||
Connection.On<string>("Warning", message => WarningReceived?.Invoke(message));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers to receive console messages for a specific task.
|
||||
/// </summary>
|
||||
public Task RegisterTaskAsync(string name) => Connection.InvokeAsync("RegisterTask", name);
|
||||
|
||||
/// <summary>
|
||||
/// Unregisters from receiving console messages for a specific task.
|
||||
/// </summary>
|
||||
public Task UnregisterTaskAsync(string name) => Connection.InvokeAsync("UnregisterTask", name);
|
||||
|
||||
/// <summary>
|
||||
/// Registers to receive console messages for a specific mission.
|
||||
/// </summary>
|
||||
public Task RegisterMissionAsync(Guid missionId) => Connection.InvokeAsync("RegisterMission", missionId);
|
||||
|
||||
/// <summary>
|
||||
/// Unregisters from receiving console messages for a specific mission.
|
||||
/// </summary>
|
||||
public Task UnregisterMissionAsync(Guid missionId) => Connection.InvokeAsync("UnregisterMission", missionId);
|
||||
|
||||
/// <summary>
|
||||
/// Registers to receive all console messages.
|
||||
/// </summary>
|
||||
public Task RegisterAllAsync() => Connection.InvokeAsync("RegisterAll");
|
||||
|
||||
/// <summary>
|
||||
/// Unregisters from receiving all console messages.
|
||||
/// </summary>
|
||||
public Task UnregisterAllAsync() => Connection.InvokeAsync("UnregisterAll");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using RobotNet10.Components.Clients;
|
||||
using RobotNet10.ScriptEngine.Shared;
|
||||
|
||||
namespace RobotNet10.ScriptEditor.Clients;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR HubClient for FileManagerHub - handles file management operations.
|
||||
/// Note: Events are only triggered when other clients perform actions through Hub methods,
|
||||
/// not from FileManager class directly (FileManager doesn't have HubContext).
|
||||
/// </summary>
|
||||
public class FileManagerHubClient : HubClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Triggered when edit permission is revoked (sent to the previous connection).
|
||||
/// </summary>
|
||||
public event Action<string?>? EditPermissionRevoked;
|
||||
|
||||
/// <summary>
|
||||
/// Triggered when another client saves a file.
|
||||
/// </summary>
|
||||
public event Action<string, string?>? FileSaved;
|
||||
|
||||
/// <summary>
|
||||
/// Triggered when another client creates a file.
|
||||
/// </summary>
|
||||
public event Action<string, string?>? FileCreated;
|
||||
|
||||
/// <summary>
|
||||
/// Triggered when another client creates a folder.
|
||||
/// </summary>
|
||||
public event Action<string, string?>? FolderCreated;
|
||||
|
||||
/// <summary>
|
||||
/// Triggered when another client deletes a file.
|
||||
/// </summary>
|
||||
public event Action<string, string?>? FileDeleted;
|
||||
|
||||
/// <summary>
|
||||
/// Triggered when another client deletes a folder.
|
||||
/// </summary>
|
||||
public event Action<string, string?>? FolderDeleted;
|
||||
|
||||
/// <summary>
|
||||
/// Triggered when any client creates a backup.
|
||||
/// </summary>
|
||||
public event Action<string, string?>? BackupCreated;
|
||||
|
||||
/// <summary>
|
||||
/// Triggered when any client restores a backup.
|
||||
/// </summary>
|
||||
public event Action<string, string?>? BackupRestored;
|
||||
|
||||
/// <summary>
|
||||
/// Triggered when any client deletes a backup.
|
||||
/// </summary>
|
||||
public event Action<string, string?>? BackupDeleted;
|
||||
|
||||
public FileManagerHubClient(NavigationManager navigationManager)
|
||||
: base(navigationManager.ToAbsoluteUri(HubEndpoints.ScriptFileManagerHubPath))
|
||||
{
|
||||
Connection.On<string?>("EditPermissionRevoked", userId => EditPermissionRevoked?.Invoke(userId));
|
||||
Connection.On<string, string?>("FileSaved", (path, userId) => FileSaved?.Invoke(path, userId));
|
||||
Connection.On<string, string?>("FileCreated", (path, userId) => FileCreated?.Invoke(path, userId));
|
||||
Connection.On<string, string?>("FolderCreated", (path, userId) => FolderCreated?.Invoke(path, userId));
|
||||
Connection.On<string, string?>("FileDeleted", (path, userId) => FileDeleted?.Invoke(path, userId));
|
||||
Connection.On<string, string?>("FolderDeleted", (path, userId) => FolderDeleted?.Invoke(path, userId));
|
||||
Connection.On<string, string?>("BackupCreated", (fileName, userId) => BackupCreated?.Invoke(fileName, userId));
|
||||
Connection.On<string, string?>("BackupRestored", (fileName, userId) => BackupRestored?.Invoke(fileName, userId));
|
||||
Connection.On<string, string?>("BackupDeleted", (fileName, userId) => BackupDeleted?.Invoke(fileName, userId));
|
||||
|
||||
// Request edit permission when reconnected
|
||||
// This will always succeed and force out any previous connection
|
||||
Connection.Reconnected += async _ =>
|
||||
{
|
||||
if (IsConnected)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RequestEditPermissionAsync();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Ignore errors - permission request should always succeed
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task StartAsync()
|
||||
{
|
||||
await base.StartAsync();
|
||||
|
||||
// Automatically request edit permission when connected
|
||||
// This will always succeed and force out any previous connection
|
||||
if (IsConnected)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RequestEditPermissionAsync();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Ignore errors - permission request should always succeed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the root folder structure with all files and folders.
|
||||
/// </summary>
|
||||
public Task<ScriptFolderDto> GetRootFolderAsync() => Connection.InvokeAsync<ScriptFolderDto>("GetRootFolder");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current state of the FileManager.
|
||||
/// </summary>
|
||||
public Task<ScriptEngineState> GetStateAsync() => Connection.InvokeAsync<ScriptEngineState>("GetState");
|
||||
|
||||
/// <summary>
|
||||
/// Requests edit permission. If another connection has permission, it will be revoked and notified.
|
||||
/// </summary>
|
||||
public Task<bool> RequestEditPermissionAsync() => Connection.InvokeAsync<bool>("RequestEditPermission");
|
||||
|
||||
/// <summary>
|
||||
/// Revokes edit permission for the current connection.
|
||||
/// </summary>
|
||||
public Task RevokeEditPermissionAsync() => Connection.InvokeAsync("RevokeEditPermission");
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the current connection has edit permission.
|
||||
/// </summary>
|
||||
public Task<bool> HasEditPermissionAsync() => Connection.InvokeAsync<bool>("HasEditPermission");
|
||||
|
||||
/// <summary>
|
||||
/// Saves file content.
|
||||
/// </summary>
|
||||
public Task SaveFileAsync(string relativePath, string content) => Connection.InvokeAsync("SaveFile", relativePath, content);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new file.
|
||||
/// </summary>
|
||||
public Task CreateFileAsync(string relativePath, string content) => Connection.InvokeAsync("CreateFile", relativePath, content);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new folder.
|
||||
/// </summary>
|
||||
public Task CreateFolderAsync(string relativePath) => Connection.InvokeAsync("CreateFolder", relativePath);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a file.
|
||||
/// </summary>
|
||||
public Task DeleteFileAsync(string relativePath) => Connection.InvokeAsync("DeleteFile", relativePath);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a folder.
|
||||
/// </summary>
|
||||
public Task DeleteFolderAsync(string relativePath) => Connection.InvokeAsync("DeleteFolder", relativePath);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a backup of all scripts.
|
||||
/// </summary>
|
||||
public Task<string> CreateBackupAsync(string? backupName = null) => Connection.InvokeAsync<string>("CreateBackup", backupName);
|
||||
|
||||
/// <summary>
|
||||
/// Lists available backup files.
|
||||
/// </summary>
|
||||
public Task<ScriptBackupInfo[]> ListBackupsAsync() => Connection.InvokeAsync<ScriptBackupInfo[]>("ListBackups");
|
||||
|
||||
/// <summary>
|
||||
/// Restores scripts from a backup.
|
||||
/// </summary>
|
||||
public Task RestoreBackupAsync(string backupFileName, bool replaceExisting = true) =>
|
||||
Connection.InvokeAsync("RestoreBackup", backupFileName, replaceExisting);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a backup file.
|
||||
/// </summary>
|
||||
public Task DeleteBackupAsync(string backupFileName) => Connection.InvokeAsync("DeleteBackup", backupFileName);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using RobotNet10.Components.Clients;
|
||||
using RobotNet10.ScriptEngine.Shared;
|
||||
using RobotNet10.Shared;
|
||||
|
||||
namespace RobotNet10.ScriptEditor.Clients;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR HubClient for InstanceMissionHub - handles instance mission operations.
|
||||
/// </summary>
|
||||
public class InstanceMissionHubClient : HubClient
|
||||
{
|
||||
public InstanceMissionHubClient(NavigationManager navigationManager)
|
||||
: base(navigationManager.ToAbsoluteUri(HubEndpoints.InstanceMissionHubPath))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches instance missions with pagination and text search.
|
||||
/// </summary>
|
||||
public Task<SearchResult<InstanceMissionDto>> SearchInstanceMissionsAsync(SearchRequest request)
|
||||
{
|
||||
return Connection.InvokeAsync<SearchResult<InstanceMissionDto>>("SearchInstanceMissions", request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the log for a specific instance mission.
|
||||
/// </summary>
|
||||
public Task<string?> GetInstanceMissionLogAsync(Guid missionId)
|
||||
{
|
||||
return Connection.InvokeAsync<string?>("GetInstanceMissionLog", missionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a running or paused mission.
|
||||
/// </summary>
|
||||
public Task<MessageResult> CancelMissionAsync(Guid missionId, string reason)
|
||||
{
|
||||
return Connection.InvokeAsync<MessageResult>("CancelMission", missionId, reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pauses a running mission.
|
||||
/// </summary>
|
||||
public Task<MessageResult> PauseMissionAsync(Guid missionId)
|
||||
{
|
||||
return Connection.InvokeAsync<MessageResult>("PauseMission", missionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resumes a paused mission.
|
||||
/// </summary>
|
||||
public Task<MessageResult> ResumeMissionAsync(Guid missionId)
|
||||
{
|
||||
return Connection.InvokeAsync<MessageResult>("ResumeMission", missionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using RobotNet10.Components.Clients;
|
||||
using RobotNet10.ScriptEngine.Shared;
|
||||
using RobotNet10.Shared;
|
||||
|
||||
namespace RobotNet10.ScriptEditor.Clients;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR HubClient for ScriptManagerHub - handles ScriptEngine management operations.
|
||||
/// </summary>
|
||||
public class ScriptManagerHubClient : HubClient
|
||||
{
|
||||
public event Action<ScriptEngineState>? StateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current ScriptEngine state. Updated when connected.
|
||||
/// </summary>
|
||||
public ScriptEngineState State { get; private set; }
|
||||
|
||||
public ScriptManagerHubClient(NavigationManager navigationManager)
|
||||
: base(navigationManager.ToAbsoluteUri(HubEndpoints.ScriptManagerHubPath))
|
||||
{
|
||||
Connection.On<ScriptEngineState>("StateChanged", state =>
|
||||
{
|
||||
State = state;
|
||||
StateChanged?.Invoke(state);
|
||||
});
|
||||
|
||||
// Reload state when reconnected
|
||||
Connection.Reconnected += async _ =>
|
||||
{
|
||||
if (IsConnected)
|
||||
{
|
||||
try
|
||||
{
|
||||
var newState = await GetStateAsync();
|
||||
if (State != newState)
|
||||
{
|
||||
State = newState;
|
||||
StateChanged?.Invoke(newState);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Even if state hasn't changed, trigger event to update UI
|
||||
StateChanged?.Invoke(newState);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Ignore errors when reloading state on reconnect
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task StartAsync()
|
||||
{
|
||||
await base.StartAsync();
|
||||
|
||||
// Lưu state khi connected và trigger StateChanged event
|
||||
if (IsConnected)
|
||||
{
|
||||
var newState = await GetStateAsync();
|
||||
if (State != newState)
|
||||
{
|
||||
State = newState;
|
||||
StateChanged?.Invoke(newState);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Even if state hasn't changed, trigger event to update UI
|
||||
StateChanged?.Invoke(newState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current state of the ScriptEngine.
|
||||
/// </summary>
|
||||
public Task<ScriptEngineState> GetStateAsync() => Connection.InvokeAsync<ScriptEngineState>("GetState");
|
||||
|
||||
/// <summary>
|
||||
/// Builds scripts from all files. Only allowed when state is Idle or BuildError.
|
||||
/// </summary>
|
||||
public Task<MessageResult> BuildAsync() => Connection.InvokeAsync<MessageResult>("Build");
|
||||
|
||||
/// <summary>
|
||||
/// Starts the ScriptEngine. Only allowed when state is Ready.
|
||||
/// </summary>
|
||||
public Task<MessageResult> StartEingineAsync() => Connection.InvokeAsync<MessageResult>("Start");
|
||||
|
||||
/// <summary>
|
||||
/// Stops the ScriptEngine. Only allowed when state is Running.
|
||||
/// </summary>
|
||||
public Task<MessageResult> StopEingineAsync() => Connection.InvokeAsync<MessageResult>("Stop");
|
||||
|
||||
/// <summary>
|
||||
/// Resets the ScriptEngine. Allowed from Idle, Ready, BuildError, Running, or Fault.
|
||||
/// </summary>
|
||||
public Task<MessageResult> ResetAsync() => Connection.InvokeAsync<MessageResult>("Reset");
|
||||
|
||||
#region VariableManager Methods
|
||||
|
||||
/// <summary>
|
||||
/// Gets all script variables.
|
||||
/// </summary>
|
||||
public Task<ScriptVariableDto[]> GetScriptVariablesAsync() =>
|
||||
Connection.InvokeAsync<ScriptVariableDto[]>("GetScriptVariables");
|
||||
|
||||
/// <summary>
|
||||
/// Finds specific script variables by names.
|
||||
/// </summary>
|
||||
public Task<ScriptVariableDto[]> FindScriptVariablesAsync(string[] names) =>
|
||||
Connection.InvokeAsync<ScriptVariableDto[]>("FindScriptVariables", names);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the value of a script variable by name.
|
||||
/// </summary>
|
||||
public Task<MessageResult> SetValueAsync(string name, string value) =>
|
||||
Connection.InvokeAsync<MessageResult>("SetValue", name, value);
|
||||
|
||||
#endregion
|
||||
|
||||
#region TaskManager Methods
|
||||
|
||||
/// <summary>
|
||||
/// Gets all script tasks.
|
||||
/// </summary>
|
||||
public Task<ScriptTaskDto[]> GetScriptTasksAsync() =>
|
||||
Connection.InvokeAsync<ScriptTaskDto[]>("GetScriptTasks");
|
||||
|
||||
/// <summary>
|
||||
/// Finds specific script tasks by names.
|
||||
/// </summary>
|
||||
public Task<ScriptTaskDto[]> FindScriptTasksAsync(string[] names) =>
|
||||
Connection.InvokeAsync<ScriptTaskDto[]>("FindScriptTasks", names);
|
||||
|
||||
/// <summary>
|
||||
/// Enables a task (resumes if paused, starts if stopped).
|
||||
/// </summary>
|
||||
public Task<MessageResult> EnableTaskAsync(string name) =>
|
||||
Connection.InvokeAsync<MessageResult>("EnableTask", name);
|
||||
|
||||
/// <summary>
|
||||
/// Disables a task (pauses if running).
|
||||
/// </summary>
|
||||
public Task<MessageResult> DisableTaskAsync(string name) =>
|
||||
Connection.InvokeAsync<MessageResult>("DisableTask", name);
|
||||
|
||||
#endregion
|
||||
|
||||
#region MissionManager Methods
|
||||
|
||||
/// <summary>
|
||||
/// Gets all script missions.
|
||||
/// </summary>
|
||||
public Task<ScriptMissionDto[]> GetScriptMissionsAsync() =>
|
||||
Connection.InvokeAsync<ScriptMissionDto[]>("GetScriptMissions");
|
||||
|
||||
/// <summary>
|
||||
/// Finds specific script missions by names.
|
||||
/// </summary>
|
||||
public Task<ScriptMissionDto[]> FindScriptMissionsAsync(string[] names) =>
|
||||
Connection.InvokeAsync<ScriptMissionDto[]>("FindScriptMissions", names);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mission instance with parameters provided as a dictionary.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the mission model.</param>
|
||||
/// <param name="args">Dictionary of parameter values keyed by parameter name (as strings).</param>
|
||||
/// <returns>MessageResult containing the mission ID if successful.</returns>
|
||||
public Task<MessageResult<Guid>> CreateMissionAsync(string name, IDictionary<string, string> args) =>
|
||||
Connection.InvokeAsync<MessageResult<Guid>>("CreateMission", name, args);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a running or paused mission.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the mission instance to cancel.</param>
|
||||
/// <param name="reason">The reason for cancellation.</param>
|
||||
/// <returns>True if the mission was canceled successfully, false otherwise.</returns>
|
||||
public Task<bool> CancelMissionAsync(Guid id, string reason) =>
|
||||
Connection.InvokeAsync<bool>("CancelMission", id, reason);
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
@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
|
||||
@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="file-explorer-item"
|
||||
data-file-path="@File.Path"
|
||||
@onclick:stopPropagation="true"
|
||||
@onclick:preventDefault="true"
|
||||
@onclick="HandleClick"
|
||||
@oncontextmenu="HandleClick">
|
||||
<input id="@ItemId" type="radio" name="@RadioName" hidden />
|
||||
<div class="file-item-content" data-file-id="@File.Id" data-level="@File.Level">
|
||||
@for (int i = 0; i < File.Level - 1; i++)
|
||||
{
|
||||
<div class="file-indent-guide"></div>
|
||||
}
|
||||
<span class="file-icon-spacer"></span>
|
||||
<span class="file-icon mdi mdi-language-csharp"></span>
|
||||
<span class="file-name" data-file-id="@File.Id" data-name="name">@File.Name</span>
|
||||
<span class="file-badge warning" data-file-id="@File.Id" data-badge="warning" data-count="@File.WarningCount" title="@File.WarningCount warning(s)">@File.WarningCount</span>
|
||||
<span class="file-badge error" data-file-id="@File.Id" data-badge="error" data-count="@File.ErrorCount" title="@File.ErrorCount error(s)">@File.ErrorCount</span>
|
||||
<span class="file-badge modified" data-file-id="@File.Id" data-badge="modified" title="Modified">●</span>
|
||||
</div>
|
||||
</div>
|
||||
</ActivatorContent>
|
||||
<ChildContent>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Edit" OnClick="HandleRename">
|
||||
Rename
|
||||
</MudMenuItem>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Save" OnClick="HandleSave" Disabled="@(!File.IsModified)">
|
||||
Save
|
||||
</MudMenuItem>
|
||||
<MudDivider />
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="HandleDelete">
|
||||
<MudText Color="Color.Error">Delete</MudText>
|
||||
</MudMenuItem>
|
||||
</ChildContent>
|
||||
</MudMenu>
|
||||
|
||||
@code {
|
||||
[CascadingParameter(Name = "FileExplorerRadioName")]
|
||||
protected string RadioName { get; set; } = "script-explorer-item";
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public ScriptFile File { get; set; } = null!;
|
||||
|
||||
private Guid ItemId = Guid.NewGuid();
|
||||
private bool ShowContextMenu { get; set; }
|
||||
private ScriptFile? _previousFile;
|
||||
|
||||
public override async Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
// Try to get the File parameter using nameof for type safety
|
||||
if (parameters.TryGetValue<ScriptFile>(nameof(File), out var fileParameter))
|
||||
{
|
||||
// Unsubscribe from old file events if file changed
|
||||
if (_previousFile != null && _previousFile != fileParameter)
|
||||
{
|
||||
_previousFile.Modified -= OnFileModified;
|
||||
_previousFile.NameChanged -= OnFileNameChanged;
|
||||
_previousFile.DiagnosticsChanged -= OnFileDiagnosticsChanged;
|
||||
}
|
||||
}
|
||||
|
||||
// Set parameters first
|
||||
await base.SetParametersAsync(parameters);
|
||||
|
||||
// Subscribe to new file events using the parameter from TryGetValue
|
||||
if (parameters.TryGetValue<ScriptFile>(nameof(File), out var newFile) && newFile != null)
|
||||
{
|
||||
// Only subscribe if this is a different file
|
||||
if (_previousFile != newFile)
|
||||
{
|
||||
newFile.Modified += OnFileModified;
|
||||
newFile.NameChanged += OnFileNameChanged;
|
||||
newFile.DiagnosticsChanged += OnFileDiagnosticsChanged;
|
||||
_previousFile = newFile;
|
||||
|
||||
// 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 && File != null)
|
||||
{
|
||||
await UpdateBadgesVisibility();
|
||||
}
|
||||
|
||||
// Radio button state is managed via CheckRadioById and UncheckRadioByName
|
||||
}
|
||||
|
||||
private async Task UpdateBadgesVisibility()
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileId = File.Id.ToString();
|
||||
await JSRuntime.InvokeVoidAsync("fileExplorer.updateFileBadges",
|
||||
fileId,
|
||||
File.WarningCount > 0,
|
||||
File.ErrorCount > 0,
|
||||
File.IsModified,
|
||||
File.WarningCount.ToString(),
|
||||
File.ErrorCount.ToString(),
|
||||
File.Name);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore JS errors
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async Task HandleClick(MouseEventArgs e)
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("fileExplorer.UncheckRadioByName", RadioName);
|
||||
await JSRuntime.InvokeVoidAsync("fileExplorer.CheckRadioById", ItemId);
|
||||
Workspace.SelectedFile = File;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OnFileModified()
|
||||
{
|
||||
InvokeAsync(async () =>
|
||||
{
|
||||
await UpdateBadgesVisibility();
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
private void OnFileNameChanged()
|
||||
{
|
||||
InvokeAsync(async () =>
|
||||
{
|
||||
await UpdateBadgesVisibility();
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
private void OnFileDiagnosticsChanged(int warningCount, int errorCount)
|
||||
{
|
||||
InvokeAsync(async () =>
|
||||
{
|
||||
await UpdateBadgesVisibility();
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
private async Task HandleRename()
|
||||
{
|
||||
ShowContextMenu = false;
|
||||
|
||||
var parameters = new DialogParameters<RenameDialog>
|
||||
{
|
||||
{ x => x.CurrentName, File.Name },
|
||||
{ x => x.ItemType, "File" },
|
||||
{ x => x.RequireCsExtension, true }
|
||||
};
|
||||
|
||||
var options = new DialogOptions
|
||||
{
|
||||
CloseOnEscapeKey = true,
|
||||
MaxWidth = MaxWidth.Small,
|
||||
FullWidth = true
|
||||
};
|
||||
|
||||
var dialog = await DialogService.ShowAsync<RenameDialog>("Rename File", parameters, options);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result is not null && !result.Canceled && result.Data is string newName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parentPath = File.Parent?.Path ?? "";
|
||||
var newPath = string.IsNullOrEmpty(parentPath) ? newName : System.IO.Path.Combine(parentPath, newName);
|
||||
|
||||
await FileManagerClient.CreateFileAsync(newPath, File.Code);
|
||||
await FileManagerClient.DeleteFileAsync(File.Path);
|
||||
|
||||
File.Name = newName;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to rename file: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleSave()
|
||||
{
|
||||
if (!File.IsModified) return;
|
||||
|
||||
ShowContextMenu = false;
|
||||
try
|
||||
{
|
||||
await FileManagerClient.SaveFileAsync(File.Path, File.Code);
|
||||
File.Saved();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to save file: {ex.Message}", Severity.Error);
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task HandleDelete()
|
||||
{
|
||||
ShowContextMenu = false;
|
||||
|
||||
var parameters = new DialogParameters<ConfirmDialog>
|
||||
{
|
||||
{ x => x.Title, "Delete File" },
|
||||
{ x => x.Message, $"Are you sure you want to delete '{File.Name}'?" },
|
||||
{ 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 File", parameters, options);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result is not null && !result.Canceled && result.Data is bool confirmed && confirmed)
|
||||
{
|
||||
try
|
||||
{
|
||||
await FileManagerClient.DeleteFileAsync(File.Path);
|
||||
|
||||
// Update workspace immediately after successful deletion
|
||||
// (Server may not send FileDeleted event to the client that performed the deletion)
|
||||
Workspace.RemoveFile(File);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to delete file: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (File != null)
|
||||
{
|
||||
File.Modified -= OnFileModified;
|
||||
File.NameChanged -= OnFileNameChanged;
|
||||
File.DiagnosticsChanged -= OnFileDiagnosticsChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/* ============================================
|
||||
File Explorer Item Styles
|
||||
============================================ */
|
||||
|
||||
.file-explorer-item {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Radio button is hidden via hidden attribute, no need for CSS */
|
||||
|
||||
/* Selected state using :has(:checked) */
|
||||
.file-explorer-item:has(input[type="radio"]:checked) .file-item-content {
|
||||
background-color: #37373d;
|
||||
}
|
||||
|
||||
.file-explorer-item:has(input[type="radio"]:checked):hover .file-item-content {
|
||||
background-color: #37373d;
|
||||
}
|
||||
|
||||
.file-explorer-item:has(input[type="radio"]:checked) .file-name {
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-explorer-item:has(input[type="radio"]:checked) .file-icon {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.file-item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
gap: 6px;
|
||||
min-height: 22px;
|
||||
transition: background-color 0.15s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Indent Guide */
|
||||
.file-indent-guide {
|
||||
width: 16px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.file-indent-guide::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background-color: #3e3e42;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* File Icon Spacer - aligns with folder-expand-icon */
|
||||
.file-icon-spacer {
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-explorer-item:hover .file-item-content {
|
||||
background-color: #2a2d2e;
|
||||
}
|
||||
|
||||
/* File Icon */
|
||||
.file-icon {
|
||||
font-size: 16px;
|
||||
color: #858585;
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.file-explorer-item:hover .file-icon {
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
|
||||
/* File Name */
|
||||
.file-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #cccccc;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.file-explorer-item:hover .file-name {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
|
||||
/* File Badges */
|
||||
.file-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-badge.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.file-badge.warning {
|
||||
background-color: #d19a66;
|
||||
color: #1e1e1e;
|
||||
}
|
||||
|
||||
.file-badge.error {
|
||||
background-color: #f48771;
|
||||
color: #1e1e1e;
|
||||
}
|
||||
|
||||
.file-badge.modified {
|
||||
color: #4ec9b0;
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
margin-top: -10px;
|
||||
}
|
||||
|
||||
.file-badge.modified.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Context menu is handled by MudMenu, no custom button needed */
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/* ============================================
|
||||
Folder Explorer Item Styles
|
||||
============================================ */
|
||||
|
||||
.folder-explorer-item {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Radio button is hidden via hidden attribute, no need for CSS */
|
||||
|
||||
/* Selected state using :has(:checked) */
|
||||
.folder-explorer-item:has(input[type="radio"]:checked) .folder-item-content {
|
||||
background-color: #37373d;
|
||||
}
|
||||
|
||||
.folder-explorer-item:has(input[type="radio"]:checked):hover .folder-item-content {
|
||||
background-color: #37373d;
|
||||
}
|
||||
|
||||
.folder-explorer-item:has(input[type="radio"]:checked) .folder-name {
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.folder-explorer-item:has(input[type="radio"]:checked) .folder-icon {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.folder-explorer-item:has(input[type="radio"]:checked) .folder-expand-icon {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Folder Expand Icon */
|
||||
.folder-expand-icon {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
font-size: 9px;
|
||||
color: #858585;
|
||||
transition: transform 0.15s ease, color 0.15s ease;
|
||||
margin-right: 2px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
min-width: 10px; /* Ensure consistent width even when empty */
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.folder-expand-icon.expanded {
|
||||
transform: rotate(90deg);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.folder-explorer-item:hover .folder-expand-icon {
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.folder-item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
gap: 6px;
|
||||
min-height: 22px;
|
||||
transition: background-color 0.15s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Indent Guide */
|
||||
.folder-indent-guide {
|
||||
width: 16px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.folder-indent-guide::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background-color: #3e3e42;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.folder-explorer-item:hover .folder-item-content {
|
||||
background-color: #2a2d2e;
|
||||
}
|
||||
|
||||
|
||||
/* Folder Icon */
|
||||
.folder-icon {
|
||||
font-size: 16px;
|
||||
color: #858585;
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
transition: color 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.folder-explorer-item:hover .folder-icon {
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
|
||||
/* Folder Name */
|
||||
.folder-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #cccccc;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.folder-explorer-item:hover .folder-name {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
|
||||
/* Folder Badges */
|
||||
.folder-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.folder-badge.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.folder-badge.warning {
|
||||
background-color: #d19a66;
|
||||
color: #1e1e1e;
|
||||
}
|
||||
|
||||
.folder-badge.error {
|
||||
background-color: #f48771;
|
||||
color: #1e1e1e;
|
||||
}
|
||||
|
||||
.folder-badge.modified {
|
||||
color: #4ec9b0;
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
margin-top: -10px;
|
||||
}
|
||||
|
||||
.folder-badge.modified.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Folder Children */
|
||||
.folder-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Context menu is handled by MudMenu, no custom button needed */
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
@implements IDisposable
|
||||
|
||||
@using RobotNet10.ScriptEditor.Clients
|
||||
@using RobotNet10.ScriptEditor.Dialogs
|
||||
@using RobotNet10.Components
|
||||
@using RobotNet10.ScriptEngine.Shared
|
||||
@using RobotNet10.Shared
|
||||
@using MudBlazor
|
||||
|
||||
@inject ScriptManagerHubClient ScriptManagerClient
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<div class="mission-item">
|
||||
<div class="mission-info">
|
||||
<div class="mission-name">@Mission.Name</div>
|
||||
<div class="mission-parameter-count">
|
||||
@(Mission.Parameters?.Length ?? 0) @((Mission.Parameters?.Length ?? 0) == 1 ? "parameter" : "parameters")
|
||||
</div>
|
||||
</div>
|
||||
<IconButton Icon="play" Title="Create Mission Instance" Disabled="@CreateDisabled" OnClick="HandleCreateMission" />
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public ScriptMissionDto Mission { get; set; } = null!;
|
||||
|
||||
private bool CreateDisabled => ScriptManagerClient.State != ScriptEngineState.Running;
|
||||
|
||||
protected override void OnAfterRender(bool firstRender)
|
||||
{
|
||||
base.OnAfterRender(firstRender);
|
||||
if(firstRender)
|
||||
{
|
||||
ScriptManagerClient.StateChanged += OnEngineStateChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEngineStateChanged(ScriptEngineState _)
|
||||
{
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task HandleCreateMission()
|
||||
{
|
||||
if (!ScriptManagerClient.IsConnected)
|
||||
{
|
||||
Snackbar.Add($"Cannot create mission: Not connected to server", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var parameters = new DialogParameters<InstantiateMissionDialog>
|
||||
{
|
||||
{ x => x.Model, Mission }
|
||||
};
|
||||
|
||||
var options = new DialogOptions
|
||||
{
|
||||
CloseOnEscapeKey = true,
|
||||
MaxWidth = MaxWidth.Small,
|
||||
FullWidth = true
|
||||
};
|
||||
|
||||
var dialog = await DialogService.ShowAsync<InstantiateMissionDialog>("Create Mission", parameters, options);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result is not null && !result.Canceled && result.Data is Dictionary<string, string> missionParams)
|
||||
{
|
||||
try
|
||||
{
|
||||
var createResult = await ScriptManagerClient.CreateMissionAsync(Mission.Name, missionParams);
|
||||
|
||||
if (createResult != null && createResult.IsSuccess)
|
||||
{
|
||||
Snackbar.Add($"Mission '{Mission.Name}' created successfully (ID: {createResult.Data})", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
var errorMessage = createResult?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Failed to create mission: {errorMessage}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error creating mission: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ScriptManagerClient.StateChanged -= OnEngineStateChanged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/* ============================================
|
||||
MissionItem Component Styles
|
||||
============================================ */
|
||||
|
||||
.mission-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #2d2d30;
|
||||
transition: background-color 0.15s ease;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mission-item:hover {
|
||||
background-color: #252526;
|
||||
}
|
||||
|
||||
.mission-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mission-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mission-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #cccccc;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mission-parameter-count {
|
||||
font-size: 10px;
|
||||
color: #858585;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
@using Microsoft.AspNetCore.Components
|
||||
|
||||
<div class="sidebar-accordion-item" data-tab="@TabName">
|
||||
<div class="sidebar-accordion-header">
|
||||
<span class="accordion-arrow">▶</span>
|
||||
<span class="accordion-label">@Label</span>
|
||||
<div class="accordion-header-actions">
|
||||
@if (HeaderActions != null)
|
||||
{
|
||||
@HeaderActions
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-accordion-content collapsed">
|
||||
<div class="sidebar-panel">
|
||||
@ChildContent
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string TabName { get; set; } = string.Empty;
|
||||
[Parameter] public string Label { get; set; } = string.Empty;
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
[Parameter] public RenderFragment? HeaderActions { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/* ============================================
|
||||
SidebarAccordionItem Component Styles
|
||||
============================================ */
|
||||
|
||||
/* Accordion Item Container */
|
||||
.sidebar-accordion-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid #2d2d30;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-accordion-item:has(.sidebar-accordion-content.expanded) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Accordion Header */
|
||||
.sidebar-accordion-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px; /* Giảm padding để có thêm không gian */
|
||||
cursor: pointer;
|
||||
color: #cccccc;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
user-select: none;
|
||||
background-color: #252526;
|
||||
gap: 2px; /* Giảm gap giữa các phần tử */
|
||||
min-height: 32px; /* Đảm bảo chiều cao tối thiểu */
|
||||
}
|
||||
|
||||
.sidebar-accordion-header:hover {
|
||||
background-color: #2a2d2e;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.sidebar-accordion-header.active {
|
||||
background-color: #1e1e1e;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Accordion Arrow */
|
||||
.accordion-arrow {
|
||||
display: inline-block;
|
||||
width: 14px; /* Giảm width */
|
||||
font-size: 9px; /* Giảm font-size một chút */
|
||||
color: #858585;
|
||||
transition: transform 0.15s ease, color 0.15s ease;
|
||||
margin-right: 2px; /* Giảm margin */
|
||||
text-align: center;
|
||||
flex-shrink: 0; /* Không cho phép shrink */
|
||||
}
|
||||
|
||||
.sidebar-accordion-header.active .accordion-arrow {
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
/* Accordion Label */
|
||||
.accordion-label {
|
||||
flex: 1;
|
||||
font-size: 9px; /* Giảm thêm font-size */
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.3px; /* Giảm letter-spacing */
|
||||
min-width: 0; /* Cho phép text truncate nếu cần */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Accordion Header Actions */
|
||||
.accordion-header-actions {
|
||||
display: none;
|
||||
margin-left: auto;
|
||||
margin-right: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
flex-shrink: 0; /* Không cho phép shrink */
|
||||
align-items: center;
|
||||
gap: 1px; /* Giảm gap giữa các buttons */
|
||||
padding-left: 4px; /* Thêm padding trái để tách biệt với label */
|
||||
}
|
||||
|
||||
.sidebar-accordion-header.active .accordion-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Accordion Content */
|
||||
.sidebar-accordion-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background-color: #1e1e1e;
|
||||
transition: max-height 0.2s ease-out, opacity 0.2s ease-out, flex 0.2s ease-out;
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sidebar-accordion-content.expanded {
|
||||
max-height: 9999px; /* Giá trị lớn để không giới hạn nhưng vẫn có transition */
|
||||
opacity: 1;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar-accordion-content.collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
flex: 0 0 auto;
|
||||
/* Không dùng display: none để giữ animation và tránh re-render */
|
||||
/* Element vẫn trong DOM nhưng không chiếm không gian nhờ max-height: 0 */
|
||||
}
|
||||
|
||||
/* Sidebar Panel (content inside accordion) */
|
||||
.sidebar-accordion-content .sidebar-panel {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
color: #cccccc;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-panel h6 {
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #3e3e42;
|
||||
}
|
||||
|
||||
/* Scrollbar styling cho sidebar-panel */
|
||||
.sidebar-panel::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.sidebar-panel::-webkit-scrollbar-track {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.sidebar-panel::-webkit-scrollbar-thumb {
|
||||
background: #424242;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.sidebar-panel::-webkit-scrollbar-thumb:hover {
|
||||
background: #4e4e4e;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
@implements IDisposable
|
||||
|
||||
@using RobotNet10.ScriptEditor.Clients
|
||||
@using RobotNet10.ScriptEngine.Shared
|
||||
@using RobotNet10.Shared
|
||||
@using MudBlazor
|
||||
|
||||
@inject ScriptManagerHubClient ScriptManagerClient
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<div class="task-item">
|
||||
<div class="task-info">
|
||||
<div class="task-name">@Task.Name</div>
|
||||
<div class="task-interval">@Task.Interval ms</div>
|
||||
<div class="task-executions">@Task.ExecutionCount</div>
|
||||
</div>
|
||||
<MudSwitch T="bool"
|
||||
@bind-Value="@_isChecked"
|
||||
@bind-Value:after="@HandleToggle"
|
||||
Disabled="@IsDisabled"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small" />
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public ScriptTaskDto Task { get; set; } = null!;
|
||||
|
||||
private bool _isChecked;
|
||||
private bool _isToggling = false;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_isChecked = Task.Enabled;
|
||||
}
|
||||
|
||||
private bool IsDisabled => ScriptManagerClient.State != ScriptEngineState.Running || _isToggling;
|
||||
|
||||
protected override void OnAfterRender(bool firstRender)
|
||||
{
|
||||
base.OnAfterRender(firstRender);
|
||||
if (firstRender)
|
||||
{
|
||||
ScriptManagerClient.StateChanged += OnEngineStateChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEngineStateChanged(ScriptEngineState _)
|
||||
{
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task HandleToggle()
|
||||
{
|
||||
if (!ScriptManagerClient.IsConnected)
|
||||
{
|
||||
Snackbar.Add($"Cannot toggle task: Not connected to server", Severity.Warning);
|
||||
// Revert switch state
|
||||
_isChecked = Task.Enabled;
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isToggling)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_isToggling = true;
|
||||
StateHasChanged();
|
||||
|
||||
var isEnabled = _isChecked;
|
||||
MessageResult result;
|
||||
if (isEnabled)
|
||||
{
|
||||
result = await ScriptManagerClient.EnableTaskAsync(Task.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await ScriptManagerClient.DisableTaskAsync(Task.Name);
|
||||
}
|
||||
|
||||
if (result != null && result.IsSuccess)
|
||||
{
|
||||
Snackbar.Add($"Task '{Task.Name}' {(isEnabled ? "enabled" : "disabled")} successfully", Severity.Success);
|
||||
// Update local state - no need to reload
|
||||
// The state will be updated when TaskManager reloads on state change
|
||||
}
|
||||
else
|
||||
{
|
||||
var errorMessage = result?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Failed to {(isEnabled ? "enable" : "disable")} task: {errorMessage}", Severity.Error);
|
||||
// Revert the switch state
|
||||
_isChecked = Task.Enabled;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var wasEnabled = _isChecked;
|
||||
Snackbar.Add($"Error {(wasEnabled ? "enabling" : "disabling")} task '{Task.Name}': {ex.Message}", Severity.Error);
|
||||
// Revert the switch state
|
||||
_isChecked = Task.Enabled;
|
||||
StateHasChanged();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isToggling = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ScriptManagerClient.StateChanged -= OnEngineStateChanged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/* ============================================
|
||||
TaskItem Component Styles
|
||||
============================================ */
|
||||
|
||||
.task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #2d2d30;
|
||||
transition: background-color 0.15s ease;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
background-color: #252526;
|
||||
}
|
||||
|
||||
.task-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.task-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0; /* Allow text truncation */
|
||||
}
|
||||
|
||||
.task-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #cccccc;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-interval {
|
||||
font-size: 10px;
|
||||
color: #858585;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-executions {
|
||||
font-size: 10px;
|
||||
color: #4ec9b0;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
@using RobotNet10.ScriptEditor.Clients
|
||||
@using Microsoft.JSInterop
|
||||
@inject ConsoleHubClient HubClient
|
||||
@inject IJSRuntime JSRuntime
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<div class="console-container">
|
||||
<div class="console-header">
|
||||
<div class="console-header-left">
|
||||
<button class="btn btn-sm btn-outline-secondary btn-toggle" onclick="robotnet.console.toggleCollapse()" title="Toggle Console">
|
||||
<span class="mdi mdi-chevron-down"></span>
|
||||
</button>
|
||||
<span>Console</span>
|
||||
</div>
|
||||
<div class="console-actions">
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="ToggleAutoScroll" title="@(_autoScroll ? "Disable" : "Enable") Auto Scroll">
|
||||
<span class="@(_autoScroll ? "mdi mdi-arrow-down-bold" : "mdi mdi-arrow-down-bold-outline")"></span>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="ClearLogs" title="Clear Console">
|
||||
<span class="mdi mdi-delete"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="console-content" id="console-content">
|
||||
<!-- Messages will be added via JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool _autoScroll = true;
|
||||
private DotNetObjectReference<Console>? _objRef;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Đăng ký events từ HubClient
|
||||
HubClient.ErrorReceived += OnErrorReceived;
|
||||
HubClient.InfoReceived += OnInfoReceived;
|
||||
HubClient.WarningReceived += OnWarningReceived;
|
||||
|
||||
// Kết nối đến hub nếu chưa connected
|
||||
if (!HubClient.IsConnected)
|
||||
{
|
||||
await HubClient.StartAsync();
|
||||
}
|
||||
|
||||
// Đảm bảo đã connected trước khi đăng ký
|
||||
if (HubClient.IsConnected)
|
||||
{
|
||||
// Đăng ký nhận tất cả console messages
|
||||
await HubClient.RegisterAllAsync();
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
_objRef = DotNetObjectReference.Create(this);
|
||||
await JSRuntime.InvokeVoidAsync("robotnet.console.init", _objRef);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnErrorReceived(string message)
|
||||
{
|
||||
_ = JSRuntime.InvokeVoidAsync("robotnet.console.addMessage", "ERROR", message, _autoScroll);
|
||||
}
|
||||
|
||||
private void OnInfoReceived(string message)
|
||||
{
|
||||
_ = JSRuntime.InvokeVoidAsync("robotnet.console.addMessage", "INFO", message, _autoScroll);
|
||||
}
|
||||
|
||||
private void OnWarningReceived(string message)
|
||||
{
|
||||
_ = JSRuntime.InvokeVoidAsync("robotnet.console.addMessage", "WARN", message, _autoScroll);
|
||||
}
|
||||
|
||||
private async Task ClearLogs()
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("robotnet.console.clear");
|
||||
}
|
||||
|
||||
private void ToggleAutoScroll()
|
||||
{
|
||||
_autoScroll = !_autoScroll;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
HubClient.ErrorReceived -= OnErrorReceived;
|
||||
HubClient.InfoReceived -= OnInfoReceived;
|
||||
HubClient.WarningReceived -= OnWarningReceived;
|
||||
|
||||
_objRef?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/* Console Container */
|
||||
.console-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: #1e1e1e;
|
||||
}
|
||||
|
||||
/* Console Header */
|
||||
.console-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background-color: #252526;
|
||||
border-bottom: 1px solid #3e3e42;
|
||||
color: #cccccc;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.console-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-toggle {
|
||||
padding: 2px 6px;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.console-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.console-actions .btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
border-color: #3e3e42;
|
||||
color: #cccccc;
|
||||
background-color: transparent;
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.console-actions .btn:hover {
|
||||
background-color: #2a2d2e;
|
||||
border-color: #007acc;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.console-actions .btn:focus {
|
||||
box-shadow: 0 0 0 0.2rem rgba(0, 122, 204, 0.25);
|
||||
}
|
||||
|
||||
/* Console Content */
|
||||
.console-content {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
background-color: #1e1e1e;
|
||||
transition: flex-basis 0.3s ease-in-out, max-height 0.3s ease-in-out, padding 0.3s ease-in-out, opacity 0.2s ease-in-out;
|
||||
min-height: 0;
|
||||
opacity: 1;
|
||||
max-height: 9999px;
|
||||
}
|
||||
|
||||
.console-content.collapsed {
|
||||
flex: 0 0 0;
|
||||
flex-basis: 0;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.console-content::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.console-content::-webkit-scrollbar-track {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.console-content::-webkit-scrollbar-thumb {
|
||||
background: #424242;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.console-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #4e4e4e;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
@using MudBlazor
|
||||
|
||||
<MudDialog>
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Cancel Mission</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudText Typo="Typo.body1" Class="mb-3">
|
||||
Are you sure you want to cancel mission <strong>@MissionName</strong>?
|
||||
</MudText>
|
||||
<MudTextField @bind-Value="_reason"
|
||||
Label="Reason (optional)"
|
||||
Placeholder="Enter reason for cancellation..."
|
||||
Variant="Variant.Outlined"
|
||||
Lines="3"
|
||||
Counter="200"
|
||||
MaxLength="200" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">Cancel</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Error" OnClick="Confirm">Cancel Mission</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] IMudDialogInstance Dialog { get; set; } = null!;
|
||||
[Parameter] public string MissionName { get; set; } = "";
|
||||
|
||||
private string _reason = "";
|
||||
|
||||
private void Cancel() => Dialog.Cancel();
|
||||
|
||||
private void Confirm() => Dialog.Close(DialogResult.Ok(_reason?.Trim() ?? ""));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
@using MudBlazor
|
||||
|
||||
<MudDialog>
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@Title</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudText>@Message</MudText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">Cancel</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="@ConfirmColor" OnClick="Confirm">@ConfirmText</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] IMudDialogInstance Dialog { get; set; } = null!;
|
||||
[Parameter] public string Title { get; set; } = "Confirm";
|
||||
[Parameter] public string Message { get; set; } = "Are you sure?";
|
||||
[Parameter] public string ConfirmText { get; set; } = "Confirm";
|
||||
[Parameter] public Color ConfirmColor { get; set; } = Color.Primary;
|
||||
|
||||
private void Cancel() => Dialog.Cancel();
|
||||
|
||||
private void Confirm() => Dialog.Close(DialogResult.Ok(true));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
@using MudBlazor
|
||||
|
||||
<MudDialog>
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Create Backup</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudTextField @bind-Value="BackupName"
|
||||
Label="Backup Name"
|
||||
Placeholder="Enter backup name"
|
||||
Required="true"
|
||||
RequiredError="Backup name is required"
|
||||
HelperText="Leave empty to use default timestamp name"
|
||||
Variant="Variant.Outlined"
|
||||
FullWidth="true"
|
||||
@onkeydown="HandleKeyDown" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">Cancel</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Submit">Create</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] IMudDialogInstance Dialog { get; set; } = null!;
|
||||
|
||||
private string BackupName { get; set; } = string.Empty;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// Default name based on current date/time: yyyy-MM-dd_HHmm
|
||||
BackupName = DateTime.Now.ToString("yyyy-MM-dd_HHmm");
|
||||
}
|
||||
|
||||
private void Cancel() => Dialog.Cancel();
|
||||
|
||||
private void Submit()
|
||||
{
|
||||
var name = BackupName?.Trim() ?? string.Empty;
|
||||
|
||||
// If empty, use default timestamp
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
name = DateTime.Now.ToString("yyyy-MM-dd_HHmm");
|
||||
}
|
||||
|
||||
Dialog.Close(DialogResult.Ok(name));
|
||||
}
|
||||
|
||||
private void HandleKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter")
|
||||
{
|
||||
Submit();
|
||||
}
|
||||
else if (e.Key == "Escape")
|
||||
{
|
||||
Cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
@using MudBlazor
|
||||
|
||||
<MudDialog>
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Create New File</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudTextField @bind-Value="FileName"
|
||||
Label="File Name"
|
||||
Placeholder="Enter file name (e.g., MyFile.cs)"
|
||||
Required="true"
|
||||
RequiredError="File name is required"
|
||||
HelperText="File must have .cs extension"
|
||||
Variant="Variant.Outlined"
|
||||
FullWidth="true"
|
||||
@onkeydown="HandleKeyDown" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">Cancel</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Submit">Create</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] IMudDialogInstance Dialog { get; set; } = null!;
|
||||
|
||||
private string FileName { get; set; } = string.Empty;
|
||||
|
||||
private void Cancel() => Dialog.Cancel();
|
||||
|
||||
private void Submit()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(FileName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure .cs extension
|
||||
var name = FileName.Trim();
|
||||
if (!name.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
name += ".cs";
|
||||
}
|
||||
|
||||
Dialog.Close(DialogResult.Ok(name));
|
||||
}
|
||||
|
||||
private void HandleKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter")
|
||||
{
|
||||
Submit();
|
||||
}
|
||||
else if (e.Key == "Escape")
|
||||
{
|
||||
Cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
@using MudBlazor
|
||||
|
||||
<MudDialog>
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Create New Folder</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudTextField @bind-Value="FolderName"
|
||||
Label="Folder Name"
|
||||
Placeholder="Enter folder name"
|
||||
Required="true"
|
||||
RequiredError="Folder name is required"
|
||||
Variant="Variant.Outlined"
|
||||
FullWidth="true"
|
||||
@onkeydown="HandleKeyDown" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">Cancel</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Submit">Create</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] IMudDialogInstance Dialog { get; set; } = null!;
|
||||
|
||||
private string FolderName { get; set; } = string.Empty;
|
||||
|
||||
private void Cancel() => Dialog.Cancel();
|
||||
|
||||
private void Submit()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(FolderName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dialog.Close(DialogResult.Ok(FolderName.Trim()));
|
||||
}
|
||||
|
||||
private void HandleKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter")
|
||||
{
|
||||
Submit();
|
||||
}
|
||||
else if (e.Key == "Escape")
|
||||
{
|
||||
Cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
@using MudBlazor
|
||||
@using RobotNet10.ScriptEngine.Shared
|
||||
|
||||
<MudDialog>
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Edit Variable: @VariableName</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudText Typo="Typo.body2" Class="mb-3">Type: <strong>@TypeName</strong></MudText>
|
||||
<MudTextField @bind-Value="NewValue"
|
||||
Label="Value"
|
||||
Placeholder="Enter new value"
|
||||
Required="true"
|
||||
RequiredError="Value is required"
|
||||
HelperText="@HelperText"
|
||||
Variant="Variant.Outlined"
|
||||
FullWidth="true"
|
||||
Error="@(!string.IsNullOrEmpty(ErrorMessage))"
|
||||
ErrorText="@ErrorMessage"
|
||||
@onkeydown="HandleKeyDown"
|
||||
@bind-Value:after="ValidateValue" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">Cancel</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Submit" Disabled="@(!IsValid)">Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] IMudDialogInstance Dialog { get; set; } = null!;
|
||||
[Parameter] public string VariableName { get; set; } = string.Empty;
|
||||
[Parameter] public string TypeName { get; set; } = string.Empty;
|
||||
[Parameter] public string CurrentValue { get; set; } = string.Empty;
|
||||
|
||||
private string NewValue { get; set; } = string.Empty;
|
||||
private string ErrorMessage { get; set; } = string.Empty;
|
||||
private bool IsValid => string.IsNullOrEmpty(ErrorMessage) && !string.IsNullOrWhiteSpace(NewValue);
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
NewValue = CurrentValue;
|
||||
ValidateValue();
|
||||
}
|
||||
|
||||
private string HelperText => GetHelperText();
|
||||
|
||||
private string GetHelperText()
|
||||
{
|
||||
var type = ScriptHelpers.ResolveTypeFromString(TypeName);
|
||||
if (type == null) return "";
|
||||
|
||||
return type switch
|
||||
{
|
||||
_ when type == typeof(bool) => "Enter 'true' or 'false'",
|
||||
_ when type == typeof(int) => "Enter an integer value",
|
||||
_ when type == typeof(long) => "Enter a long integer value",
|
||||
_ when type == typeof(float) => "Enter a float value (e.g., 3.14)",
|
||||
_ when type == typeof(double) => "Enter a double value (e.g., 3.14)",
|
||||
_ when type == typeof(decimal) => "Enter a decimal value (e.g., 3.14)",
|
||||
_ when type == typeof(char) => "Enter a single character",
|
||||
_ when type == typeof(string) => "Enter a string value",
|
||||
_ when type.IsEnum => $"Enter one of: {string.Join(", ", Enum.GetNames(type))}",
|
||||
_ => "Enter a valid value"
|
||||
};
|
||||
}
|
||||
|
||||
private void ValidateValue()
|
||||
{
|
||||
ErrorMessage = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(NewValue))
|
||||
{
|
||||
return; // Will be handled by Required validation
|
||||
}
|
||||
|
||||
var type = ScriptHelpers.ResolveTypeFromString(TypeName);
|
||||
if (type == null)
|
||||
{
|
||||
ErrorMessage = "Unknown type";
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if type is supported
|
||||
if (!ScriptHelpers.SupportedTypes.Values.Contains(type) && !type.IsEnum)
|
||||
{
|
||||
ErrorMessage = "Type not supported for editing";
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate value can be converted to the type
|
||||
try
|
||||
{
|
||||
if (type.IsEnum)
|
||||
{
|
||||
// Handle enum validation separately since ResolveValueFromString doesn't handle enums correctly
|
||||
Enum.Parse(type, NewValue.Trim(), ignoreCase: true);
|
||||
}
|
||||
else if (!ScriptHelpers.ResolveValueFromString(NewValue.Trim(), type, out _))
|
||||
{
|
||||
ErrorMessage = $"Invalid value for type {TypeName}";
|
||||
}
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
ErrorMessage = $"Invalid value for type {TypeName}";
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
ErrorMessage = $"Failed to validate value for type {TypeName}";
|
||||
}
|
||||
}
|
||||
|
||||
private void Cancel() => Dialog.Cancel();
|
||||
|
||||
private void Submit()
|
||||
{
|
||||
if (!IsValid)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dialog.Close(DialogResult.Ok(NewValue.Trim()));
|
||||
}
|
||||
|
||||
private void HandleKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter" && IsValid)
|
||||
{
|
||||
Submit();
|
||||
}
|
||||
else if (e.Key == "Escape")
|
||||
{
|
||||
Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
@using MudBlazor
|
||||
@using RobotNet10.ScriptEditor.Models
|
||||
@using RobotNet10.ScriptEngine.Shared
|
||||
|
||||
<MudDialog>
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Create Mission: @MissionName</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
@if (Parameters == null || Parameters.Length == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2">This mission has no parameters.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var param in Parameters)
|
||||
{
|
||||
@switch (param.Type)
|
||||
{
|
||||
case "System.Boolean":
|
||||
<MudSwitch T="bool" Color="Color.Primary" @bind-Value="@param.BoolValue" />
|
||||
break;
|
||||
case "System.Byte":
|
||||
<MudNumericField T="byte"
|
||||
@bind-Value="@param.ByteValue"
|
||||
Min="@System.Byte.MinValue"
|
||||
Max="@System.Byte.MaxValue"
|
||||
Label="@param.Type"
|
||||
Error="@(!string.IsNullOrEmpty(param.Errors))"
|
||||
ErrorText="@param.Errors"
|
||||
ShrinkLabel="true"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense" />
|
||||
break;
|
||||
case "System.SByte":
|
||||
<MudNumericField T="sbyte"
|
||||
@bind-Value="@param.SByteValue"
|
||||
Min="@System.SByte.MinValue"
|
||||
Max="@System.SByte.MaxValue"
|
||||
Label="@param.Type"
|
||||
Error="@(!string.IsNullOrEmpty(param.Errors))"
|
||||
ErrorText="@param.Errors"
|
||||
ShrinkLabel="true"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense" />
|
||||
break;
|
||||
case "System.Int16":
|
||||
<MudNumericField T="short"
|
||||
@bind-Value="@param.ShortValue"
|
||||
Min="@System.Int16.MinValue"
|
||||
Max="@System.Int16.MaxValue"
|
||||
Label="@param.Type"
|
||||
Error="@(!string.IsNullOrEmpty(param.Errors))"
|
||||
ErrorText="@param.Errors"
|
||||
ShrinkLabel="true"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense" />
|
||||
break;
|
||||
case "System.UInt16":
|
||||
<MudNumericField T="ushort"
|
||||
@bind-Value="@param.UShortValue"
|
||||
Min="@System.UInt16.MinValue"
|
||||
Max="@System.UInt16.MaxValue"
|
||||
Label="@param.Type"
|
||||
Error="@(!string.IsNullOrEmpty(param.Errors))"
|
||||
ErrorText="@param.Errors"
|
||||
ShrinkLabel="true"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense" />
|
||||
break;
|
||||
case "System.Int32":
|
||||
<MudNumericField T="int"
|
||||
@bind-Value="@param.IntValue"
|
||||
Min="@System.Int32.MinValue"
|
||||
Max="@System.Int32.MaxValue"
|
||||
Label="@param.Type"
|
||||
Error="@(!string.IsNullOrEmpty(param.Errors))"
|
||||
ErrorText="@param.Errors"
|
||||
ShrinkLabel="true"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense" />
|
||||
break;
|
||||
case "System.UInt32":
|
||||
<MudNumericField T="uint"
|
||||
@bind-Value="@param.UIntValue"
|
||||
Min="@System.UInt32.MinValue"
|
||||
Max="@System.UInt32.MaxValue"
|
||||
Label="@param.Type"
|
||||
Error="@(!string.IsNullOrEmpty(param.Errors))"
|
||||
ErrorText="@param.Errors"
|
||||
ShrinkLabel="true"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense" />
|
||||
break;
|
||||
case "System.Int64":
|
||||
<MudNumericField T="long"
|
||||
@bind-Value="@param.LongValue"
|
||||
Min="@System.Int64.MinValue"
|
||||
Max="@System.Int64.MaxValue"
|
||||
Label="@param.Type"
|
||||
Error="@(!string.IsNullOrEmpty(param.Errors))"
|
||||
ErrorText="@param.Errors"
|
||||
ShrinkLabel="true"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense" />
|
||||
break;
|
||||
case "System.UInt64":
|
||||
<MudNumericField T="ulong"
|
||||
@bind-Value="@param.ULongValue"
|
||||
Min="@System.UInt64.MinValue"
|
||||
Max="@System.UInt64.MaxValue"
|
||||
Label="@param.Type"
|
||||
Error="@(!string.IsNullOrEmpty(param.Errors))"
|
||||
ErrorText="@param.Errors"
|
||||
ShrinkLabel="true"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense" />
|
||||
break;
|
||||
case "System.Single":
|
||||
<MudNumericField T="float"
|
||||
@bind-Value="@param.FloatValue"
|
||||
Min="@System.Single.MinValue"
|
||||
Max="@System.Single.MaxValue"
|
||||
Label="@param.Type"
|
||||
Error="@(!string.IsNullOrEmpty(param.Errors))"
|
||||
ErrorText="@param.Errors"
|
||||
ShrinkLabel="true"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense" />
|
||||
break;
|
||||
case "System.Double":
|
||||
<MudNumericField T="double"
|
||||
@bind-Value="@param.DoubleValue"
|
||||
Min="@System.Double.MinValue"
|
||||
Max="@System.Double.MaxValue"
|
||||
Label="@param.Type"
|
||||
Error="@(!string.IsNullOrEmpty(param.Errors))"
|
||||
ErrorText="@param.Errors"
|
||||
ShrinkLabel="true"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense" />
|
||||
break;
|
||||
case "System.Decimal":
|
||||
<MudNumericField T="double"
|
||||
@bind-Value="@param.DecimalValue"
|
||||
Label="@param.Type"
|
||||
Error="@(!string.IsNullOrEmpty(param.Errors))"
|
||||
ErrorText="@param.Errors"
|
||||
ShrinkLabel="true"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense" />
|
||||
break;
|
||||
case "System.String":
|
||||
<MudTextField @bind-Value="@param.StringValue"
|
||||
Label="@param.Type"
|
||||
Error="@(!string.IsNullOrEmpty(param.Errors))"
|
||||
ErrorText="@param.Errors"
|
||||
ShrinkLabel="true"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense" />
|
||||
break;
|
||||
case "System.Char":
|
||||
<MudTextField @bind-Value="@param.CharValue"
|
||||
MaxLength="1"
|
||||
Label="@param.Type"
|
||||
Error="@(!string.IsNullOrEmpty(param.Errors))"
|
||||
ErrorText="@param.Errors"
|
||||
ShrinkLabel="true"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense" />
|
||||
break;
|
||||
case "System.Threading.CancellationToken":
|
||||
<span>
|
||||
<CancellationToken>
|
||||
</span>
|
||||
break;
|
||||
default:
|
||||
<MudAlert Severity="Severity.Error" Dense>Unsupport parameter with type @param.Type</MudAlert>
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">Cancel</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Submit" Disabled="@(!IsValid)">Create</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] IMudDialogInstance Dialog { get; set; } = null!;
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public ScriptMissionDto Model { get; set; } = default!;
|
||||
|
||||
public string MissionName => Model.Name;
|
||||
private ScriptMissionParameterValueModel[] Parameters = [];
|
||||
private bool IsValid => Parameters.All(p => string.IsNullOrEmpty(p.Errors));
|
||||
|
||||
protected override void OnAfterRender(bool firstRender)
|
||||
{
|
||||
base.OnAfterRender(firstRender);
|
||||
if (firstRender)
|
||||
{
|
||||
Parameters = [.. Model.Parameters.Select(p => new ScriptMissionParameterValueModel(p.Name, p.Type, p.Default ?? ""))];
|
||||
|
||||
for (int i = 0; i < Parameters.Length; i++)
|
||||
{
|
||||
ValidateParameter(i);
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasDefaultValue(int index)
|
||||
{
|
||||
if (index < 0 || index >= Parameters.Length) return false;
|
||||
return !string.IsNullOrEmpty(Parameters[index].Default);
|
||||
}
|
||||
|
||||
private string GetHelperText(string typeName)
|
||||
{
|
||||
var type = ScriptHelpers.ResolveTypeFromString(typeName);
|
||||
if (type == null) return "";
|
||||
|
||||
return type switch
|
||||
{
|
||||
_ when type == typeof(bool) => "Enter 'true' or 'false'",
|
||||
_ when type == typeof(int) => "Enter an integer value",
|
||||
_ when type == typeof(long) => "Enter a long integer value",
|
||||
_ when type == typeof(float) => "Enter a float value (e.g., 3.14f)",
|
||||
_ when type == typeof(double) => "Enter a double value (e.g., 3.14)",
|
||||
_ when type == typeof(decimal) => "Enter a decimal value (e.g., 3.14)",
|
||||
_ when type == typeof(char) => "Enter a single character",
|
||||
_ when type == typeof(string) => "Enter a string value",
|
||||
_ when type.IsEnum => $"Enter one of: {string.Join(", ", Enum.GetNames(type))}",
|
||||
_ => "Enter a valid value"
|
||||
};
|
||||
}
|
||||
|
||||
private void ValidateParameter(int index)
|
||||
{
|
||||
if (index < 0 || index >= Parameters.Length) return;
|
||||
if(Parameters[index].Type == "System.Threading.CancellationToken")
|
||||
{
|
||||
// No validation needed
|
||||
return;
|
||||
}
|
||||
|
||||
Parameters[index].Errors = string.Empty;
|
||||
var param = Parameters[index];
|
||||
var value = Parameters[index].ToString();
|
||||
|
||||
// Allow empty if has default value
|
||||
if (string.IsNullOrWhiteSpace(value) && !string.IsNullOrEmpty(param.Default))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value) && string.IsNullOrEmpty(param.Default))
|
||||
{
|
||||
Parameters[index].Errors = "Value is required";
|
||||
return;
|
||||
}
|
||||
|
||||
var type = ScriptHelpers.ResolveTypeFromString(param.Type);
|
||||
if (type == null)
|
||||
{
|
||||
Parameters[index].Errors = "Unknown type";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (type.IsEnum)
|
||||
{
|
||||
Enum.Parse(type, value.Trim(), ignoreCase: true);
|
||||
}
|
||||
else if (!ScriptHelpers.ResolveValueFromString(value.Trim(), type, out _))
|
||||
{
|
||||
Parameters[index].Errors = $"Invalid value for type {param.Type}";
|
||||
}
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
Parameters[index].Errors = $"Invalid value for type {param.Type}";
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
Parameters[index].Errors = $"Failed to validate value for type {param.Type}";
|
||||
}
|
||||
}
|
||||
|
||||
private void Cancel() => Dialog.Cancel();
|
||||
|
||||
private void Submit()
|
||||
{
|
||||
if (!IsValid) return;
|
||||
|
||||
var parameters = new Dictionary<string, string>();
|
||||
for (int i = 0; i < Parameters.Length; i++)
|
||||
{
|
||||
var value = Parameters[i].ToString();
|
||||
// Use default if empty and default exists
|
||||
if (string.IsNullOrWhiteSpace(value) && !string.IsNullOrEmpty(Parameters[i].Default))
|
||||
{
|
||||
value = Parameters[i].Default;
|
||||
}
|
||||
parameters[Parameters[i].Name] = value ?? string.Empty;
|
||||
}
|
||||
|
||||
Dialog.Close(DialogResult.Ok(parameters));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
@using MudBlazor
|
||||
@using RobotNet10.ScriptEditor.Clients
|
||||
@using RobotNet10.ScriptEngine.Shared
|
||||
@using RobotNet10.Shared
|
||||
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<MudDialog>
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Mission Log: @MissionName</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudPaper Elevation="0" Class="pa-4" Style="max-height: 60vh; overflow-y: auto; font-family: 'Courier New', monospace; font-size: 0.875rem;">
|
||||
@if (string.IsNullOrWhiteSpace(_logText))
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">No logs available</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Style="white-space: pre-wrap; word-break: break-word;">@_logText</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton Variant="Variant.Text" OnClick="Close">Close</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] IMudDialogInstance Dialog { get; set; } = null!;
|
||||
[Parameter] public Guid MissionId { get; set; }
|
||||
[Parameter] public string MissionName { get; set; } = "";
|
||||
[Parameter] public ScriptMissionState State { get; set; }
|
||||
[Parameter] public string? InitialLog { get; set; }
|
||||
|
||||
[Inject] private InstanceMissionHubClient InstanceMissionClient { get; set; } = null!;
|
||||
[Inject] private ConsoleHubClient ConsoleClient { get; set; } = null!;
|
||||
|
||||
private string _logText = "";
|
||||
private bool _isListening = false;
|
||||
private bool _isStopped => State == ScriptMissionState.Completed ||
|
||||
State == ScriptMissionState.Canceled ||
|
||||
State == ScriptMissionState.Error;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
// Load initial log
|
||||
if (!string.IsNullOrWhiteSpace(InitialLog))
|
||||
{
|
||||
_logText = InitialLog;
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
var log = await InstanceMissionClient.GetInstanceMissionLogAsync(MissionId);
|
||||
if (!string.IsNullOrWhiteSpace(log))
|
||||
{
|
||||
_logText = log;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logText = $"Error loading log: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
// Start listening to realtime logs if mission is not stopped
|
||||
if (!_isStopped)
|
||||
{
|
||||
_isListening = true;
|
||||
|
||||
// Subscribe to log events with proper level formatting
|
||||
ConsoleClient.ErrorReceived += OnErrorReceived;
|
||||
ConsoleClient.InfoReceived += OnInfoReceived;
|
||||
ConsoleClient.WarningReceived += OnWarningReceived;
|
||||
await ConsoleClient.StartAsync();
|
||||
await ConsoleClient.RegisterMissionAsync(MissionId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void OnErrorReceived(string message)
|
||||
{
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ");
|
||||
_logText += $"[ERROR] {timestamp} | {message}{Environment.NewLine}";
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void OnInfoReceived(string message)
|
||||
{
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ");
|
||||
_logText += $"[INFO] {timestamp} | {message}{Environment.NewLine}";
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void OnWarningReceived(string message)
|
||||
{
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ");
|
||||
_logText += $"[WARN] {timestamp} | {message}{Environment.NewLine}";
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void Close()
|
||||
{
|
||||
Dialog.Close();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_isListening && ConsoleClient.IsConnected)
|
||||
{
|
||||
try
|
||||
{
|
||||
ConsoleClient.ErrorReceived -= OnErrorReceived;
|
||||
ConsoleClient.InfoReceived -= OnInfoReceived;
|
||||
ConsoleClient.WarningReceived -= OnWarningReceived;
|
||||
await ConsoleClient.UnregisterMissionAsync(MissionId);
|
||||
await ConsoleClient.StopAsync();
|
||||
}
|
||||
catch { /* Ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
@using MudBlazor
|
||||
|
||||
<MudDialog >
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Edit Permission Revoked</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudText Typo="Typo.body1">
|
||||
Another user has taken edit permission. Your changes may not be saved.
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mt-3">
|
||||
Please reload the page to request edit permission again.
|
||||
</MudText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="HandleReloadPage">Reload Page</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] IMudDialogInstance Dialog { get; set; } = null!;
|
||||
[Parameter] public Action? OnReload { get; set; }
|
||||
|
||||
private void HandleReloadPage()
|
||||
{
|
||||
Dialog.Close();
|
||||
OnReload?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
@using MudBlazor
|
||||
|
||||
<MudDialog>
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Rename @ItemType</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudTextField @bind-Value="NewName"
|
||||
Label="@LabelText"
|
||||
Placeholder="Enter new name"
|
||||
Required="true"
|
||||
RequiredError="Name is required"
|
||||
HelperText="@HelperText"
|
||||
Variant="Variant.Outlined"
|
||||
FullWidth="true"
|
||||
@onkeydown="HandleKeyDown" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">Cancel</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Submit">Rename</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] IMudDialogInstance Dialog { get; set; } = null!;
|
||||
[Parameter] public string CurrentName { get; set; } = string.Empty;
|
||||
[Parameter] public string ItemType { get; set; } = "Item";
|
||||
[Parameter] public bool RequireCsExtension { get; set; } = false;
|
||||
|
||||
private string NewName { get; set; } = string.Empty;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
NewName = CurrentName;
|
||||
}
|
||||
|
||||
private string LabelText => $"{ItemType} Name";
|
||||
private string HelperText => RequireCsExtension ? "File must have .cs extension" : "";
|
||||
|
||||
private void Cancel() => Dialog.Cancel();
|
||||
|
||||
private void Submit()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(NewName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var name = NewName.Trim();
|
||||
|
||||
if (RequireCsExtension && !name.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
name += ".cs";
|
||||
}
|
||||
|
||||
if (name == CurrentName)
|
||||
{
|
||||
Cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
Dialog.Close(DialogResult.Ok(name));
|
||||
}
|
||||
|
||||
private void HandleKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter")
|
||||
{
|
||||
Submit();
|
||||
}
|
||||
else if (e.Key == "Escape")
|
||||
{
|
||||
Cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
@using MudBlazor
|
||||
@using RobotNet10.ScriptEngine.Shared
|
||||
|
||||
<MudDialog>
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Restore Backup</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
@if (Backups == null || Backups.Length == 0)
|
||||
{
|
||||
<MudText>No backups available.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSelect @bind-Value="SelectedBackup"
|
||||
Label="Select Backup"
|
||||
Variant="Variant.Outlined"
|
||||
FullWidth="true">
|
||||
@foreach (var backup in Backups)
|
||||
{
|
||||
<MudSelectItem Value="@backup.FileName">
|
||||
@backup.FileName - @backup.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss") (@FormatSize(backup.Size))
|
||||
</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">Cancel</MudButton>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="Submit"
|
||||
Disabled="@(Backups == null || Backups.Length == 0 || string.IsNullOrWhiteSpace(SelectedBackup))">
|
||||
Restore
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] IMudDialogInstance Dialog { get; set; } = null!;
|
||||
[Parameter] public ScriptBackupInfo[] Backups { get; set; } = Array.Empty<ScriptBackupInfo>();
|
||||
|
||||
private string? SelectedBackup { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// Select first backup by default (newest)
|
||||
if (Backups != null && Backups.Length > 0)
|
||||
{
|
||||
SelectedBackup = Backups[0].FileName;
|
||||
}
|
||||
}
|
||||
|
||||
private string FormatSize(long size)
|
||||
{
|
||||
if (size < 1024) return $"{size} B";
|
||||
if (size < 1024 * 1024) return $"{size / 1024.0:F2} KB";
|
||||
return $"{size / (1024.0 * 1024.0):F2} MB";
|
||||
}
|
||||
|
||||
private void Cancel() => Dialog.Cancel();
|
||||
|
||||
private void Submit()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(SelectedBackup))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dialog.Close(DialogResult.Ok(SelectedBackup));
|
||||
}
|
||||
}
|
||||
|
||||
438
srcs/RobotNet10/Components/RobotNet10.ScriptEditor/Editor.razor
Normal file
438
srcs/RobotNet10/Components/RobotNet10.ScriptEditor/Editor.razor
Normal file
@@ -0,0 +1,438 @@
|
||||
@implements IDisposable
|
||||
|
||||
@using System.Timers
|
||||
@using BlazorMonaco
|
||||
@using BlazorMonaco.Editor
|
||||
@using BlazorMonaco.Languages
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using Microsoft.JSInterop
|
||||
@using RobotNet10.Components.Clients
|
||||
@using RobotNet10.ScriptEditor.Clients
|
||||
@using RobotNet10.ScriptEditor.Helpers.Monaco.Languages
|
||||
@using RobotNet10.ScriptEditor.Models
|
||||
@using RobotNet10.ScriptEditor.Services
|
||||
@using RobotNet10.ScriptEngine.Shared
|
||||
|
||||
@inject IJSRuntime jsRuntime
|
||||
@inject ScriptWorkspace Workspace
|
||||
@inject FileManagerHubClient FileManagerClient
|
||||
@inject ScriptManagerHubClient ScriptManagerClient
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<div class="editor-container">
|
||||
<div class="editor-header">
|
||||
<div class="editor-header-left">
|
||||
<span>@(Workspace.CurrentFile?.Name ?? "No file selected")</span>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
title="Reset Engine"
|
||||
OnClick="HandleReset"
|
||||
Disabled="@IsResetDisabled"
|
||||
Size="@Size.Small"
|
||||
Variant="@Variant.Text" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Build"
|
||||
title="Build Scripts"
|
||||
OnClick="HandleBuild"
|
||||
Disabled="@IsBuildDisabled"
|
||||
Size="@Size.Small"
|
||||
Variant="@Variant.Text"
|
||||
Color="@Color.Primary"/>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.PlayArrow"
|
||||
title="Start Engine"
|
||||
OnClick="HandleStart"
|
||||
Disabled="@IsStartDisabled"
|
||||
Size="@Size.Small"
|
||||
Variant="@Variant.Text"
|
||||
Color="@Color.Success" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Stop"
|
||||
title="Stop Engine"
|
||||
OnClick="HandleStop"
|
||||
Disabled="@IsStopDisabled"
|
||||
Size="@Size.Small"
|
||||
Variant="@Variant.Text"
|
||||
Color="@Color.Secondary" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-content">
|
||||
<StandaloneCodeEditor @ref="_editor" Id="script-code-editor"
|
||||
ConstructionOptions="EditorConstructionOptions"
|
||||
OnDidInit="EditorOnDidInit"
|
||||
OnDidChangeModelContent="DidChangeModelContent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private object dotNetHelper = default!;
|
||||
private IJSInProcessObjectReference? disposableSignatureHelpProvider;
|
||||
|
||||
private StandaloneCodeEditor? _editor = null;
|
||||
private TextModel? _editorTextModel = null;
|
||||
private readonly SemaphoreSlim _syncLock = new(1, 1);
|
||||
private string _lastSyncedCode = "";
|
||||
private System.Threading.Timer? _debounceTimer;
|
||||
private readonly object _debounceLock = new();
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// Subscribe to events early to catch state changes
|
||||
Workspace.CurrentFileChanged += OnCurrentFileChanged;
|
||||
Workspace.DiagnoticChanged += OnDiagnoticChanged;
|
||||
ScriptManagerClient.StateChanged += OnScriptManagerStateChanged;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
if (!firstRender) return;
|
||||
|
||||
// Trigger state change handler to update UI with current state
|
||||
// This ensures UI is updated even if state was loaded before subscription
|
||||
OnScriptManagerStateChanged(ScriptManagerClient.State);
|
||||
}
|
||||
|
||||
private void OnScriptManagerStateChanged(ScriptEngineState state)
|
||||
{
|
||||
if (ScriptManagerClient.State == ScriptEngineState.Idle && Workspace.CurrentFile is not null)
|
||||
{
|
||||
_editor?.UpdateOptions(new EditorUpdateOptions { ReadOnly = false }).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
_editor?.UpdateOptions(new EditorUpdateOptions { ReadOnly = true }).ConfigureAwait(false);
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private bool IsResetDisabled => ScriptManagerClient.State == ScriptEngineState.Initializing ||
|
||||
ScriptManagerClient.State == ScriptEngineState.Resetting ||
|
||||
ScriptManagerClient.State == ScriptEngineState.Building ||
|
||||
ScriptManagerClient.State == ScriptEngineState.Starting ||
|
||||
ScriptManagerClient.State == ScriptEngineState.Stopping;
|
||||
|
||||
private bool IsBuildDisabled => ScriptManagerClient.State != ScriptEngineState.Idle &&
|
||||
ScriptManagerClient.State != ScriptEngineState.BuildError;
|
||||
|
||||
private bool IsStartDisabled => ScriptManagerClient.State != ScriptEngineState.Ready;
|
||||
|
||||
private bool IsStopDisabled => ScriptManagerClient.State != ScriptEngineState.Running;
|
||||
|
||||
private async Task HandleReset()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await ScriptManagerClient.ResetAsync();
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
Snackbar.Add($"Failed to reset engine: {result.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error resetting engine: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleBuild()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await ScriptManagerClient.BuildAsync();
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
Snackbar.Add($"Failed to build: {result.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error building scripts: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleStart()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await ScriptManagerClient.StartEingineAsync();
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
Snackbar.Add($"Failed to start engine: {result.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error starting engine: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleStop()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await ScriptManagerClient.StopEingineAsync();
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
Snackbar.Add($"Failed to stop engine: {result.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error stopping engine: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static StandaloneEditorConstructionOptions EditorConstructionOptions(StandaloneCodeEditor editor)
|
||||
{
|
||||
return new StandaloneEditorConstructionOptions
|
||||
{
|
||||
Language = "csharp",
|
||||
Theme = "vs-dark",
|
||||
GlyphMargin = true,
|
||||
AutomaticLayout = true,
|
||||
ReadOnly = true,
|
||||
Value = "",
|
||||
};
|
||||
}
|
||||
|
||||
private void OnCurrentFileChanged(ScriptFile? file)
|
||||
{
|
||||
_ = InvokeAsync(() => OnCurrentFileChangedAsync(file));
|
||||
}
|
||||
|
||||
private async Task OnCurrentFileChangedAsync(ScriptFile? file)
|
||||
{
|
||||
if (_editor == null) return;
|
||||
|
||||
if (file is null)
|
||||
{
|
||||
await _editor.SetValue("");
|
||||
await _editor.UpdateOptions(new EditorUpdateOptions { ReadOnly = true });
|
||||
}
|
||||
else
|
||||
{
|
||||
await _editor.SetValue(file.Code);
|
||||
if (ScriptManagerClient.State == ScriptEngineState.Idle)
|
||||
{
|
||||
await _editor.UpdateOptions(new EditorUpdateOptions { ReadOnly = false });
|
||||
}
|
||||
await OnDiagnoticChangedAsync(file.Diagnostics);
|
||||
}
|
||||
|
||||
// Update header to show current file name
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task EditorOnDidInit()
|
||||
{
|
||||
if (_editor == null) return;
|
||||
|
||||
dotNetHelper = DotNetObjectReference.Create(this);
|
||||
|
||||
_editorTextModel = await _editor.GetModel();
|
||||
await _editor.AddCommand((int)KeyMod.CtrlCmd | (int)KeyCode.KeyS, args =>
|
||||
{
|
||||
InvokeAsync(SaveCurrentFile).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
var triggerCharacters = new List<string>() { "." };
|
||||
await BlazorMonaco.Languages.Global.RegisterDocumentFormattingEditProvider(jsRuntime, "csharp", OnFormatDocumentAsync);
|
||||
await BlazorMonaco.Languages.Global.RegisterHoverProviderAsync(jsRuntime, "csharp", OnHoverAsync);
|
||||
await BlazorMonaco.Languages.Global.RegisterCompletionItemProvider(jsRuntime, "csharp", new CompletionItemProvider(triggerCharacters, CompleteItemAsync, ResolveCompletionItemAsync));
|
||||
|
||||
disposableSignatureHelpProvider = await jsRuntime.InvokeAsync<IJSInProcessObjectReference>("robotnet.monaco.CSharpLanguageRegisterSignatureHelpProvider", dotNetHelper, nameof(GetSignatureHelp));
|
||||
}
|
||||
|
||||
private async Task<TextEdit[]> OnFormatDocumentAsync(string modelUri, FormattingOptions options)
|
||||
{
|
||||
if (_editor is null || _editorTextModel is null || _editorTextModel.Uri != modelUri) return [];
|
||||
|
||||
var lines = await _editorTextModel.GetLineCount();
|
||||
var columns = await _editorTextModel.GetLineMaxColumn(lines);
|
||||
|
||||
var value = await _editor.GetValue();
|
||||
var result = Workspace.FormatCode(value);
|
||||
|
||||
return [
|
||||
new TextEdit {
|
||||
Range = new BlazorMonaco.Range(1, 1, lines, columns),
|
||||
Text = result
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
private async Task<Hover> OnHoverAsync(string modelUri, BlazorMonaco.Position position, HoverContext context)
|
||||
{
|
||||
if (_editor is null || _editorTextModel is null || _editorTextModel.Uri != modelUri) return new();
|
||||
|
||||
var word = await _editorTextModel.GetWordAtPosition(position);
|
||||
if (word is null) return new();
|
||||
|
||||
var info = await Workspace.GetQuickInfoCurrentFile(position.LineNumber - 1, position.Column - 1);
|
||||
|
||||
var contents = new List<MarkdownString>();
|
||||
if (!string.IsNullOrWhiteSpace(info))
|
||||
{
|
||||
contents.Add(new MarkdownString { Value = info, SupportThemeIcons = false });
|
||||
}
|
||||
contents.Add(new MarkdownString { Value = word.Word, SupportThemeIcons = false });
|
||||
|
||||
return new Hover
|
||||
{
|
||||
Contents = [..contents],
|
||||
Range = new BlazorMonaco.Range
|
||||
{
|
||||
StartLineNumber = position.LineNumber,
|
||||
EndLineNumber = position.LineNumber,
|
||||
StartColumn = word.StartColumn,
|
||||
EndColumn = word.EndColumn
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<CompletionList> CompleteItemAsync(string modelUri, BlazorMonaco.Position position, CompletionContext context)
|
||||
{
|
||||
var completions = new CompletionList() { Suggestions = [] };
|
||||
|
||||
if (_editor is null || _editorTextModel is null || _editorTextModel.Uri != modelUri)
|
||||
return completions;
|
||||
|
||||
await _syncLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var currentCode = await _editor.GetValue();
|
||||
if (currentCode != _lastSyncedCode)
|
||||
{
|
||||
Workspace.WriteDocument(currentCode);
|
||||
_lastSyncedCode = currentCode;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_syncLock.Release();
|
||||
}
|
||||
|
||||
var word = await _editorTextModel.GetWordAtPosition(position);
|
||||
|
||||
if (context.TriggerKind == CompletionTriggerKind.Invoke
|
||||
&& string.IsNullOrEmpty(context.TriggerCharacter)
|
||||
&& string.IsNullOrEmpty(word?.Word))
|
||||
{
|
||||
return completions;
|
||||
}
|
||||
|
||||
char? triggerCharacter = null;
|
||||
if (context.TriggerCharacter is not null && context.TriggerCharacter.Length > 0)
|
||||
{
|
||||
triggerCharacter = context.TriggerCharacter[0];
|
||||
}
|
||||
|
||||
var completionItems = await Workspace.GetCompletionsCurrentFile(
|
||||
position.LineNumber - 1,
|
||||
position.Column - 1,
|
||||
(int)(context.TriggerKind ?? 0),
|
||||
triggerCharacter);
|
||||
|
||||
completions.Suggestions.AddRange(completionItems);
|
||||
return completions;
|
||||
}
|
||||
|
||||
private Task<CompletionItem> ResolveCompletionItemAsync(CompletionItem item)
|
||||
{
|
||||
return Task.FromResult(item);
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task<SignatureHelpResult?> GetSignatureHelp(string modelUri, int line, int column)
|
||||
{
|
||||
if (_editor is null || _editorTextModel is null || _editorTextModel.Uri != modelUri) return null;
|
||||
|
||||
return await Workspace.GetSignatureHelpCurrentFile(line - 1, column - 1);
|
||||
}
|
||||
|
||||
private async Task SaveCurrentFile()
|
||||
{
|
||||
if (Workspace.CurrentFile is null || !Workspace.CurrentFile.IsModified) return;
|
||||
|
||||
try
|
||||
{
|
||||
await FileManagerClient.SaveFileAsync(Workspace.CurrentFile.Path, Workspace.CurrentFile.Code);
|
||||
// Update IsModified status after successful save
|
||||
Workspace.CurrentFile.Saved();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Error handling - could show snackbar notification here if needed
|
||||
// For now, just let the exception propagate
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DidChangeModelContent(ModelContentChangedEvent e)
|
||||
{
|
||||
if (_editor == null) return;
|
||||
if (e.IsFlush || e.Changes.Count == 0) return;
|
||||
|
||||
await _syncLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var newCode = await _editor.GetValue();
|
||||
if (newCode != _lastSyncedCode)
|
||||
{
|
||||
// Run WriteDocument on background thread to avoid blocking UI
|
||||
Workspace.WriteDocument(newCode);
|
||||
_lastSyncedCode = newCode;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_syncLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDiagnoticChanged(IEnumerable<Microsoft.CodeAnalysis.Diagnostic> diagnostics)
|
||||
{
|
||||
Task.Run(() => OnDiagnoticChangedAsync(diagnostics)).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
private async Task OnDiagnoticChangedAsync(IEnumerable<Microsoft.CodeAnalysis.Diagnostic> diagnostics)
|
||||
{
|
||||
if (_editor == null) return;
|
||||
var model = await _editor.GetModel();
|
||||
await BlazorMonaco.Editor.Global.SetModelMarkers(jsRuntime, model, "default", diagnostics.Select(ToMonacoDiagnostic).ToList());
|
||||
}
|
||||
|
||||
private static MarkerData ToMonacoDiagnostic(Microsoft.CodeAnalysis.Diagnostic diagnostic)
|
||||
{
|
||||
var lineSpan = diagnostic.Location.GetLineSpan();
|
||||
return new()
|
||||
{
|
||||
StartLineNumber = lineSpan.StartLinePosition.Line + 1,
|
||||
StartColumn = lineSpan.StartLinePosition.Character + 1,
|
||||
EndLineNumber = lineSpan.EndLinePosition.Line + 1,
|
||||
EndColumn = lineSpan.EndLinePosition.Character + 1,
|
||||
Message = diagnostic.GetMessage(),
|
||||
Severity = diagnostic.Severity switch
|
||||
{
|
||||
Microsoft.CodeAnalysis.DiagnosticSeverity.Info => MarkerSeverity.Info,
|
||||
Microsoft.CodeAnalysis.DiagnosticSeverity.Warning => MarkerSeverity.Warning,
|
||||
Microsoft.CodeAnalysis.DiagnosticSeverity.Error => MarkerSeverity.Error,
|
||||
_ => MarkerSeverity.Hint,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_debounceLock)
|
||||
{
|
||||
_debounceTimer?.Dispose();
|
||||
_debounceTimer = null;
|
||||
}
|
||||
|
||||
_syncLock?.Dispose();
|
||||
Workspace.CurrentFileChanged -= OnCurrentFileChanged;
|
||||
Workspace.DiagnoticChanged -= OnDiagnoticChanged;
|
||||
ScriptManagerClient.StateChanged -= OnScriptManagerStateChanged;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/* Editor Container */
|
||||
.editor-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: #1e1e1e;
|
||||
}
|
||||
|
||||
/* Editor Header */
|
||||
.editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background-color: #252526;
|
||||
border-bottom: 1px solid #3e3e42;
|
||||
color: #cccccc;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editor-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.editor-header-left span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Editor Content */
|
||||
.editor-content {
|
||||
flex: 1 1 auto;
|
||||
background-color: #1e1e1e;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.JSInterop;
|
||||
using RobotNet10.ScriptEditor.Clients;
|
||||
using RobotNet10.ScriptEditor.Services;
|
||||
using RobotNet10.ScriptEngine.Shared;
|
||||
using System.Reflection;
|
||||
|
||||
namespace RobotNet10.ScriptEditor;
|
||||
|
||||
public static class Extensions
|
||||
{
|
||||
public static void AddScriptEditor<TScriptEngineResource>(this IServiceCollection services)
|
||||
where TScriptEngineResource : class, IScriptEngineResource
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = typeof(Microsoft.CodeAnalysis.Host.HostServices).Assembly.CreateInstance("Microsoft.CodeAnalysis.Host.DefaultPersistentStorageConfiguration");
|
||||
}
|
||||
catch (TargetInvocationException)
|
||||
{
|
||||
GC.Collect();
|
||||
}
|
||||
|
||||
services.AddSingleton<IScriptEngineResource, TScriptEngineResource>();
|
||||
|
||||
// Register HubClients as Scoped services
|
||||
services.AddScoped<ConsoleHubClient>();
|
||||
services.AddScoped<FileManagerHubClient>();
|
||||
services.AddScoped<ScriptManagerHubClient>();
|
||||
services.AddScoped<InstanceMissionHubClient>();
|
||||
services.AddScoped<ScriptWorkspace>();
|
||||
services.AddScoped<ScriptResourceResolver>(sp =>
|
||||
{
|
||||
var httpClient = sp.GetRequiredService<HttpClient>();
|
||||
var jsRuntime = sp.GetService<IJSRuntime>();
|
||||
return new ScriptResourceResolver(httpClient, jsRuntime);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
# FileExplorer Test Cases
|
||||
|
||||
## Tổng quan
|
||||
FileExplorer là component chính để hiển thị và quản lý cây thư mục và file trong ScriptEditor. Tài liệu này mô tả các test case cần thiết để đảm bảo component hoạt động đúng.
|
||||
|
||||
---
|
||||
|
||||
## 1. Test Cases - Hiển thị và Navigation
|
||||
|
||||
### TC-001: Hiển thị cây thư mục ban đầu
|
||||
**Mô tả:** Kiểm tra FileExplorer hiển thị đúng cấu trúc thư mục và file khi khởi tạo.
|
||||
|
||||
**Các bước:**
|
||||
1. Khởi động ứng dụng
|
||||
2. Quan sát FileExplorer trong sidebar
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- Tất cả folders và files ở root level được hiển thị
|
||||
- Folders được sắp xếp theo tên (alphabetical)
|
||||
- Files được sắp xếp theo tên (alphabetical)
|
||||
- Badge warning/error/modified hiển thị đúng cho từng item
|
||||
|
||||
---
|
||||
|
||||
### TC-002: Expand/Collapse folder
|
||||
**Mô tả:** Kiểm tra chức năng mở/đóng folder.
|
||||
|
||||
**Các bước:**
|
||||
1. Click vào icon expand (▶) của một folder có children
|
||||
2. Quan sát folder được expand
|
||||
3. Click lại vào icon expand
|
||||
4. Quan sát folder được collapse
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- Icon expand xoay và đổi thành ▼ khi expanded
|
||||
- Icon folder đổi thành mdi-folder-open khi expanded
|
||||
- Children (subfolders và files) được hiển thị khi expanded
|
||||
- Children được ẩn khi collapsed
|
||||
- State expanded/collapsed được giữ nguyên khi folder được re-render
|
||||
|
||||
---
|
||||
|
||||
### TC-003: Expand/Collapse folder bằng click chuột trái
|
||||
**Mô tả:** Kiểm tra logic expand/collapse khi click vào folder.
|
||||
|
||||
**Các bước:**
|
||||
1. Click chuột trái vào một folder chưa được selected
|
||||
2. Click chuột trái lại vào cùng folder đó (đã selected)
|
||||
3. Quan sát hành vi expand/collapse
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- Click lần 1: Folder được select, không expand/collapse
|
||||
- Click lần 2: Folder đã selected, toggle expand/collapse (nếu có children)
|
||||
|
||||
---
|
||||
|
||||
### TC-004: Click chuột phải vào folder
|
||||
**Mô tả:** Kiểm tra context menu khi click chuột phải vào folder.
|
||||
|
||||
**Các bước:**
|
||||
1. Click chuột phải vào một folder
|
||||
2. Quan sát context menu
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- Context menu hiển thị với các options:
|
||||
- Create Folder
|
||||
- Create File
|
||||
- Rename
|
||||
- Delete
|
||||
- Folder được select
|
||||
- Không toggle expand/collapse
|
||||
|
||||
---
|
||||
|
||||
## 2. Test Cases - Selection
|
||||
|
||||
### TC-005: Select folder
|
||||
**Mô tả:** Kiểm tra chức năng select folder.
|
||||
|
||||
**Các bước:**
|
||||
1. Click chuột trái vào một folder
|
||||
2. Quan sát UI
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- Radio button của folder được checked
|
||||
- `Workspace.SelectedFolder` được set đúng
|
||||
- Radio button của các folder khác được uncheck
|
||||
|
||||
---
|
||||
|
||||
### TC-006: Select file
|
||||
**Mô tả:** Kiểm tra chức năng select file.
|
||||
|
||||
**Các bước:**
|
||||
1. Click chuột trái vào một file
|
||||
2. Quan sát UI
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- Radio button của file được checked
|
||||
- `Workspace.SelectedFile` được set đúng
|
||||
- Radio button của các file/folder khác được uncheck
|
||||
|
||||
---
|
||||
|
||||
### TC-007: Clear selection khi click vào phần trống
|
||||
**Mô tả:** Kiểm tra clear selection khi click vào phần trống của FileExplorer.
|
||||
|
||||
**Các bước:**
|
||||
1. Select một folder hoặc file
|
||||
2. Click chuột trái vào phần trống trong FileExplorer (không phải folder/file)
|
||||
3. Quan sát UI
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- `Workspace.SelectedFolder` = null
|
||||
- `Workspace.SelectedFile` = null
|
||||
- Tất cả radio buttons được uncheck
|
||||
|
||||
---
|
||||
|
||||
### TC-008: Reset selection bằng Esc key
|
||||
**Mô tả:** Kiểm tra reset selection khi nhấn Esc.
|
||||
|
||||
**Các bước:**
|
||||
1. Select một folder hoặc file
|
||||
2. Nhấn phím Esc
|
||||
3. Quan sát UI
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- `Workspace.SelectedFolder` = null
|
||||
- `Workspace.SelectedFile` = null
|
||||
- Tất cả radio buttons được uncheck
|
||||
- Hoạt động từ mọi nơi trong ứng dụng (không chỉ trong FileExplorer)
|
||||
|
||||
---
|
||||
|
||||
## 3. Test Cases - Tạo mới Folder/File
|
||||
|
||||
### TC-009: Tạo folder từ header button
|
||||
**Mô tả:** Kiểm tra tạo folder từ button "Add Folder" ở header.
|
||||
|
||||
**Các bước:**
|
||||
1. Click vào button "Add Folder" (folder-plus icon) ở header
|
||||
2. Nhập tên folder trong dialog
|
||||
3. Click "Create"
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- Folder được tạo trong `SelectedFolder` nếu có, nếu không thì ở root level
|
||||
- Folder xuất hiện trong FileExplorer ngay lập tức
|
||||
- Folder được sắp xếp đúng vị trí (alphabetical)
|
||||
- Nếu folder cha đang collapsed, tự động expand để hiển thị folder mới
|
||||
|
||||
---
|
||||
|
||||
### TC-010: Tạo file từ header button
|
||||
**Mô tả:** Kiểm tra tạo file từ button "Add File" ở header.
|
||||
|
||||
**Các bước:**
|
||||
1. Click vào button "Add File" (file-plus icon) ở header
|
||||
2. Nhập tên file trong dialog (phải có extension .cs)
|
||||
3. Click "Create"
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- File được tạo trong `SelectedFolder` nếu có, nếu không thì ở root level
|
||||
- File xuất hiện trong FileExplorer ngay lập tức
|
||||
- File được sắp xếp đúng vị trí (alphabetical)
|
||||
- Nếu folder cha đang collapsed, tự động expand để hiển thị file mới
|
||||
|
||||
---
|
||||
|
||||
### TC-011: Tạo folder từ context menu của folder
|
||||
**Mô tả:** Kiểm tra tạo folder từ context menu khi click chuột phải vào folder.
|
||||
|
||||
**Các bước:**
|
||||
1. Click chuột phải vào một folder
|
||||
2. Chọn "Create Folder" từ context menu
|
||||
3. Nhập tên folder trong dialog
|
||||
4. Click "Create"
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- Folder được tạo trong folder được click chuột phải
|
||||
- Folder xuất hiện trong FileExplorer ngay lập tức
|
||||
- Folder cha tự động expand nếu đang collapsed
|
||||
|
||||
---
|
||||
|
||||
### TC-012: Tạo file từ context menu của folder
|
||||
**Mô tả:** Kiểm tra tạo file từ context menu khi click chuột phải vào folder.
|
||||
|
||||
**Các bước:**
|
||||
1. Click chuột phải vào một folder
|
||||
2. Chọn "Create File" từ context menu
|
||||
3. Nhập tên file trong dialog
|
||||
4. Click "Create"
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- File được tạo trong folder được click chuột phải
|
||||
- File xuất hiện trong FileExplorer ngay lập tức
|
||||
- Folder cha tự động expand nếu đang collapsed
|
||||
|
||||
---
|
||||
|
||||
### TC-013: Tạo folder từ context menu phần trống
|
||||
**Mô tả:** Kiểm tra tạo folder ở root level từ context menu khi click chuột phải vào phần trống.
|
||||
|
||||
**Các bước:**
|
||||
1. Click chuột phải vào phần trống trong FileExplorer (không phải folder/file)
|
||||
2. Chọn "Create Folder" từ context menu
|
||||
3. Nhập tên folder trong dialog
|
||||
4. Click "Create"
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- Folder được tạo ở root level (`Workspace.Folders`)
|
||||
- Folder xuất hiện trong FileExplorer ngay lập tức
|
||||
- Không phụ thuộc vào `SelectedFolder`
|
||||
|
||||
---
|
||||
|
||||
### TC-014: Tạo file từ context menu phần trống
|
||||
**Mô tả:** Kiểm tra tạo file ở root level từ context menu khi click chuột phải vào phần trống.
|
||||
|
||||
**Các bước:**
|
||||
1. Click chuột phải vào phần trống trong FileExplorer (không phải folder/file)
|
||||
2. Chọn "Create File" từ context menu
|
||||
3. Nhập tên file trong dialog
|
||||
4. Click "Create"
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- File được tạo ở root level (`Workspace.Files`)
|
||||
- File xuất hiện trong FileExplorer ngay lập tức
|
||||
- Không phụ thuộc vào `SelectedFolder`
|
||||
|
||||
---
|
||||
|
||||
## 4. Test Cases - Xóa Folder/File
|
||||
|
||||
### TC-015: Xóa file
|
||||
**Mô tả:** Kiểm tra xóa file từ context menu.
|
||||
|
||||
**Các bước:**
|
||||
1. Click chuột phải vào một file
|
||||
2. Chọn "Delete" từ context menu
|
||||
3. Xác nhận xóa trong dialog
|
||||
4. Quan sát UI
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- File được xóa khỏi server
|
||||
- File biến mất khỏi FileExplorer ngay lập tức (không cần refresh)
|
||||
- Parent folder cập nhật `IsModified`, `WarningCount`, `ErrorCount` đúng
|
||||
- Nếu file đang được mở trong editor, editor được clear
|
||||
|
||||
---
|
||||
|
||||
### TC-016: Xóa folder
|
||||
**Mô tả:** Kiểm tra xóa folder từ context menu.
|
||||
|
||||
**Các bước:**
|
||||
1. Click chuột phải vào một folder
|
||||
2. Chọn "Delete" từ context menu
|
||||
3. Xác nhận xóa trong dialog
|
||||
4. Quan sát UI
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- Folder và tất cả nội dung bên trong được xóa khỏi server
|
||||
- Folder biến mất khỏi FileExplorer ngay lập tức
|
||||
- Parent folder cập nhật `IsModified`, `WarningCount`, `ErrorCount` đúng
|
||||
|
||||
---
|
||||
|
||||
### TC-017: Xóa file từ client khác
|
||||
**Mô tả:** Kiểm tra UI cập nhật khi file được xóa từ client khác (SignalR event).
|
||||
|
||||
**Các bước:**
|
||||
1. Mở ứng dụng trên 2 clients
|
||||
2. Trên client 1, xóa một file
|
||||
3. Quan sát UI trên client 2
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- File biến mất khỏi FileExplorer trên client 2
|
||||
- Parent folder cập nhật đúng
|
||||
|
||||
---
|
||||
|
||||
### TC-018: Xóa folder từ client khác
|
||||
**Mô tả:** Kiểm tra UI cập nhật khi folder được xóa từ client khác (SignalR event).
|
||||
|
||||
**Các bước:**
|
||||
1. Mở ứng dụng trên 2 clients
|
||||
2. Trên client 1, xóa một folder
|
||||
3. Quan sát UI trên client 2
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- Folder biến mất khỏi FileExplorer trên client 2
|
||||
- Parent folder cập nhật đúng
|
||||
|
||||
---
|
||||
|
||||
## 5. Test Cases - Rename
|
||||
|
||||
### TC-019: Rename folder
|
||||
**Mô tả:** Kiểm tra đổi tên folder.
|
||||
|
||||
**Các bước:**
|
||||
1. Click chuột phải vào một folder
|
||||
2. Chọn "Rename" từ context menu
|
||||
3. Nhập tên mới trong dialog
|
||||
4. Click "Rename"
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- Folder được rename trên server
|
||||
- Tất cả files và subfolders bên trong được di chuyển đến folder mới
|
||||
- Folder cũ được xóa
|
||||
- UI cập nhật với tên mới
|
||||
- Path của tất cả children được cập nhật đúng
|
||||
|
||||
---
|
||||
|
||||
### TC-020: Rename file
|
||||
**Mô tả:** Kiểm tra đổi tên file.
|
||||
|
||||
**Các bước:**
|
||||
1. Click chuột phải vào một file
|
||||
2. Chọn "Rename" từ context menu
|
||||
3. Nhập tên mới trong dialog (phải có extension .cs)
|
||||
4. Click "Rename"
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- File được rename trên server
|
||||
- UI cập nhật với tên mới
|
||||
- Path của file được cập nhật đúng
|
||||
- Nếu file đang được mở trong editor, editor cập nhật đúng
|
||||
|
||||
---
|
||||
|
||||
## 6. Test Cases - Badge và Status
|
||||
|
||||
### TC-021: Hiển thị warning badge
|
||||
**Mô tả:** Kiểm tra badge warning hiển thị đúng.
|
||||
|
||||
**Các bước:**
|
||||
1. Tạo một file có warning (ví dụ: unused variable)
|
||||
2. Quan sát badge warning
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- Badge warning hiển thị trên file có warning
|
||||
- Số lượng warning hiển thị đúng
|
||||
- Badge warning hiển thị trên tất cả parent folders với tổng số warning từ children
|
||||
|
||||
---
|
||||
|
||||
### TC-022: Hiển thị error badge
|
||||
**Mô tả:** Kiểm tra badge error hiển thị đúng.
|
||||
|
||||
**Các bước:**
|
||||
1. Tạo một file có error (ví dụ: syntax error)
|
||||
2. Quan sát badge error
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- Badge error hiển thị trên file có error
|
||||
- Số lượng error hiển thị đúng
|
||||
- Badge error hiển thị trên tất cả parent folders với tổng số error từ children
|
||||
|
||||
---
|
||||
|
||||
### TC-023: Hiển thị modified badge
|
||||
**Mô tả:** Kiểm tra badge modified hiển thị đúng.
|
||||
|
||||
**Các bước:**
|
||||
1. Mở một file và sửa nội dung
|
||||
2. Quan sát badge modified
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- Badge modified (●) hiển thị trên file đã modified
|
||||
- Badge modified hiển thị trên tất cả parent folders nếu có file/folder con đã modified
|
||||
- Badge modified biến mất sau khi save file
|
||||
|
||||
---
|
||||
|
||||
### TC-024: Cập nhật badge khi file được sửa
|
||||
**Mô tả:** Kiểm tra badge được cập nhật khi file được sửa.
|
||||
|
||||
**Các bước:**
|
||||
1. Mở một file trong editor
|
||||
2. Sửa nội dung file (thêm warning hoặc error)
|
||||
3. Quan sát badge trên file và parent folders
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- Badge warning/error được cập nhật ngay lập tức trên file
|
||||
- Badge trên parent folders được cập nhật ngay lập tức
|
||||
- Tất cả grandparent folders cũng được cập nhật
|
||||
|
||||
---
|
||||
|
||||
### TC-025: Cập nhật badge khi file được save
|
||||
**Mô tả:** Kiểm tra badge modified biến mất sau khi save.
|
||||
|
||||
**Các bước:**
|
||||
1. Sửa một file
|
||||
2. Save file (Ctrl+S)
|
||||
3. Quan sát badge modified
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- Badge modified biến mất trên file
|
||||
- Badge modified biến mất trên parent folders nếu không còn file/folder con nào modified
|
||||
|
||||
---
|
||||
|
||||
## 7. Test Cases - Event Handling
|
||||
|
||||
### TC-026: FileCreated event từ SignalR
|
||||
**Mô tả:** Kiểm tra UI cập nhật khi file được tạo từ client khác.
|
||||
|
||||
**Các bước:**
|
||||
1. Mở ứng dụng trên 2 clients
|
||||
2. Trên client 1, tạo một file mới
|
||||
3. Quan sát UI trên client 2
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- File xuất hiện trong FileExplorer trên client 2
|
||||
- Parent folder tự động expand nếu đang collapsed
|
||||
|
||||
---
|
||||
|
||||
### TC-027: FolderCreated event từ SignalR
|
||||
**Mô tả:** Kiểm tra UI cập nhật khi folder được tạo từ client khác.
|
||||
|
||||
**Các bước:**
|
||||
1. Mở ứng dụng trên 2 clients
|
||||
2. Trên client 1, tạo một folder mới
|
||||
3. Quan sát UI trên client 2
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- Folder xuất hiện trong FileExplorer trên client 2
|
||||
- Parent folder tự động expand nếu đang collapsed
|
||||
|
||||
---
|
||||
|
||||
### TC-028: FileDeleted event từ SignalR
|
||||
**Mô tả:** Kiểm tra UI cập nhật khi file được xóa từ client khác.
|
||||
|
||||
**Các bước:**
|
||||
1. Mở ứng dụng trên 2 clients
|
||||
2. Trên client 1, xóa một file
|
||||
3. Quan sát UI trên client 2
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- File biến mất khỏi FileExplorer trên client 2
|
||||
- Parent folder cập nhật badge đúng
|
||||
|
||||
---
|
||||
|
||||
### TC-029: FolderDeleted event từ SignalR
|
||||
**Mô tả:** Kiểm tra UI cập nhật khi folder được xóa từ client khác.
|
||||
|
||||
**Các bước:**
|
||||
1. Mở ứng dụng trên 2 clients
|
||||
2. Trên client 1, xóa một folder
|
||||
3. Quan sát UI trên client 2
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- Folder biến mất khỏi FileExplorer trên client 2
|
||||
- Parent folder cập nhật badge đúng
|
||||
|
||||
---
|
||||
|
||||
## 8. Test Cases - Edge Cases và Bug Fixes
|
||||
|
||||
### TC-030: IsExpanded state được giữ khi folder được re-render
|
||||
**Mô tả:** Kiểm tra state expanded/collapsed được giữ đúng khi folder được tái sử dụng.
|
||||
|
||||
**Các bước:**
|
||||
1. Expand một folder
|
||||
2. Tạo một file mới trong folder khác
|
||||
3. Quan sát folder đã expand vẫn giữ nguyên state
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- Folder đã expand vẫn giữ nguyên state expanded
|
||||
- State không bị nhầm lẫn với folder khác
|
||||
|
||||
---
|
||||
|
||||
### TC-031: Aggregation của badges khi load ban đầu
|
||||
**Mô tả:** Kiểm tra badges được tổng hợp đúng khi load workspace ban đầu.
|
||||
|
||||
**Các bước:**
|
||||
1. Khởi động ứng dụng với workspace có nhiều folders và files
|
||||
2. Quan sát badges trên các folders
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- Tất cả folders hiển thị đúng tổng số warning/error từ children
|
||||
- Tất cả folders hiển thị đúng trạng thái modified nếu có children modified
|
||||
|
||||
---
|
||||
|
||||
### TC-032: Propagation của IsModified lên parent folders
|
||||
**Mô tả:** Kiểm tra IsModified được propagate đúng lên tất cả parent folders.
|
||||
|
||||
**Các bước:**
|
||||
1. Mở một file trong subfolder (nested nhiều level)
|
||||
2. Sửa file
|
||||
3. Save file
|
||||
4. Quan sát badges trên tất cả parent folders
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- File hiển thị modified badge khi sửa
|
||||
- Tất cả parent folders hiển thị modified badge khi file được sửa
|
||||
- Tất cả parent folders biến mất modified badge khi file được save
|
||||
|
||||
---
|
||||
|
||||
### TC-033: Propagation của WarningCount và ErrorCount lên parent folders
|
||||
**Mô tả:** Kiểm tra WarningCount và ErrorCount được propagate đúng lên tất cả parent folders.
|
||||
|
||||
**Các bước:**
|
||||
1. Tạo một file có warning/error trong subfolder (nested nhiều level)
|
||||
2. Quan sát badges trên tất cả parent folders
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- File hiển thị đúng số lượng warning/error
|
||||
- Tất cả parent folders hiển thị tổng số warning/error từ tất cả children
|
||||
- Grandparent folders cũng được cập nhật đúng
|
||||
|
||||
---
|
||||
|
||||
### TC-034: Context menu không hiển thị context menu của browser
|
||||
**Mô tả:** Kiểm tra context menu của browser không hiển thị khi click chuột phải vào phần trống.
|
||||
|
||||
**Các bước:**
|
||||
1. Click chuột phải vào phần trống trong FileExplorer
|
||||
2. Quan sát
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- Chỉ hiển thị context menu của ứng dụng
|
||||
- Không hiển thị context menu của browser
|
||||
|
||||
---
|
||||
|
||||
### TC-035: Context menu hiển thị đúng vị trí click chuột phải
|
||||
**Mô tả:** Kiểm tra context menu hiển thị tại vị trí click chuột phải.
|
||||
|
||||
**Các bước:**
|
||||
1. Click chuột phải vào phần trống ở các vị trí khác nhau
|
||||
2. Quan sát vị trí context menu
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- Context menu hiển thị tại vị trí con trỏ chuột
|
||||
- Vị trí chính xác, không bị lệch
|
||||
|
||||
---
|
||||
|
||||
### TC-036: Esc key hoạt động từ mọi nơi
|
||||
**Mô tả:** Kiểm tra Esc key reset selection từ mọi nơi trong ứng dụng.
|
||||
|
||||
**Các bước:**
|
||||
1. Select một file hoặc folder
|
||||
2. Click vào editor hoặc console
|
||||
3. Nhấn Esc
|
||||
4. Quan sát FileExplorer
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- Selection được reset
|
||||
- Radio buttons được uncheck
|
||||
- Hoạt động từ mọi nơi, không chỉ trong FileExplorer
|
||||
|
||||
---
|
||||
|
||||
## 9. Test Cases - Performance
|
||||
|
||||
### TC-037: UI không bị lag khi gõ text trong editor
|
||||
**Mô tả:** Kiểm tra UI không bị lag khi gõ text.
|
||||
|
||||
**Các bước:**
|
||||
1. Mở một file trong editor
|
||||
2. Gõ text liên tục
|
||||
3. Quan sát UI
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- UI không bị lag hoặc freeze
|
||||
- Text hiển thị ngay lập tức khi gõ
|
||||
- Badges được cập nhật mượt mà
|
||||
|
||||
---
|
||||
|
||||
### TC-038: Performance khi có nhiều files và folders
|
||||
**Mô tả:** Kiểm tra performance khi workspace có nhiều items.
|
||||
|
||||
**Các bước:**
|
||||
1. Tạo nhiều folders và files (100+ items)
|
||||
2. Expand/collapse folders
|
||||
3. Scroll trong FileExplorer
|
||||
4. Quan sát performance
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- UI vẫn responsive
|
||||
- Scroll mượt mà
|
||||
- Expand/collapse không bị lag
|
||||
|
||||
---
|
||||
|
||||
## 10. Test Cases - Integration
|
||||
|
||||
### TC-039: Tích hợp với Editor
|
||||
**Mô tả:** Kiểm tra FileExplorer tích hợp đúng với Editor.
|
||||
|
||||
**Các bước:**
|
||||
1. Click vào một file trong FileExplorer
|
||||
2. Quan sát Editor
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- File được mở trong Editor
|
||||
- Editor hiển thị đúng nội dung file
|
||||
- CurrentFile được set đúng
|
||||
|
||||
---
|
||||
|
||||
### TC-040: Tích hợp với Workspace
|
||||
**Mô tả:** Kiểm tra FileExplorer đồng bộ đúng với Workspace state.
|
||||
|
||||
**Các bước:**
|
||||
1. Thực hiện các thao tác (tạo, xóa, rename) trong FileExplorer
|
||||
2. Kiểm tra Workspace state
|
||||
|
||||
**Kết quả mong đợi:**
|
||||
- Workspace.Folders và Workspace.Files được cập nhật đúng
|
||||
- Workspace.SelectedFolder và Workspace.SelectedFile được set đúng
|
||||
- Workspace.RootChanged event được trigger đúng
|
||||
|
||||
---
|
||||
|
||||
## Checklist Test Execution
|
||||
|
||||
### Pre-conditions
|
||||
- [ ] Ứng dụng đã được build thành công
|
||||
- [ ] Server đang chạy
|
||||
- [ ] Database có dữ liệu test (folders và files)
|
||||
|
||||
### Test Environment
|
||||
- [ ] Browser: Chrome/Firefox/Edge (latest version)
|
||||
- [ ] Screen resolution: 1920x1080 hoặc tương đương
|
||||
- [ ] Network: Stable connection
|
||||
|
||||
### Test Execution Notes
|
||||
- Ghi chú các bug phát hiện trong quá trình test
|
||||
- Ghi lại screenshots cho các test case failed
|
||||
- Ghi lại performance metrics nếu có vấn đề
|
||||
|
||||
---
|
||||
|
||||
## Known Issues và Limitations
|
||||
|
||||
### Đã Fix
|
||||
- ✅ IsModified không propagate lên parent folders khi save file
|
||||
- ✅ WarningCount và ErrorCount không được tổng hợp đúng khi load ban đầu
|
||||
- ✅ UI không cập nhật khi xóa file/folder
|
||||
- ✅ IsExpanded state bị nhầm lẫn giữa các folder instances
|
||||
- ✅ Context menu hiển thị sai vị trí và hiển thị cả browser context menu
|
||||
- ✅ Esc key không uncheck radio buttons
|
||||
|
||||
### Cần theo dõi
|
||||
- Performance khi có quá nhiều files/folders (>1000 items)
|
||||
- Memory leak khi tạo/xóa nhiều items liên tục
|
||||
|
||||
---
|
||||
|
||||
## Test Priority
|
||||
|
||||
### High Priority (P0)
|
||||
- TC-001, TC-002, TC-005, TC-006, TC-009, TC-010, TC-015, TC-016, TC-031, TC-032, TC-033
|
||||
|
||||
### Medium Priority (P1)
|
||||
- TC-003, TC-004, TC-007, TC-008, TC-011, TC-012, TC-013, TC-014, TC-019, TC-020, TC-021, TC-022, TC-023, TC-024, TC-025
|
||||
|
||||
### Low Priority (P2)
|
||||
- TC-017, TC-018, TC-026, TC-027, TC-028, TC-029, TC-030, TC-034, TC-035, TC-036, TC-037, TC-038, TC-039, TC-040
|
||||
|
||||
---
|
||||
|
||||
## Test Results Template
|
||||
|
||||
```
|
||||
Test Case ID: TC-XXX
|
||||
Test Date: YYYY-MM-DD
|
||||
Tester: [Name]
|
||||
Status: Pass/Fail/Blocked
|
||||
Notes: [Any additional notes]
|
||||
Screenshots: [If applicable]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Tài liệu này được tạo tự động và cần được cập nhật khi có thay đổi trong FileExplorer component.*
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
@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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/* ============================================
|
||||
File Explorer Styles
|
||||
============================================ */
|
||||
|
||||
.file-explorer-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.file-explorer-tree {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 4px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.file-explorer-tree::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.file-explorer-tree::-webkit-scrollbar-track {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.file-explorer-tree::-webkit-scrollbar-thumb {
|
||||
background: #424242;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.file-explorer-tree::-webkit-scrollbar-thumb:hover {
|
||||
background: #4e4e4e;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
using BlazorMonaco.Languages;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.Completion;
|
||||
using Microsoft.CodeAnalysis.Options;
|
||||
using Microsoft.CodeAnalysis.QuickInfo;
|
||||
using Microsoft.CodeAnalysis.Tags;
|
||||
using Microsoft.CodeAnalysis.Text;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace RobotNet10.ScriptEditor.Helpers;
|
||||
|
||||
public static partial class AdhocWorkspaceHelper
|
||||
{
|
||||
[GeneratedRegex(@"<summary>\s*(.+?)\s*</summary>", RegexOptions.Singleline)]
|
||||
private static partial Regex SummaryRegex();
|
||||
|
||||
[GeneratedRegex(@"\s+")]
|
||||
private static partial Regex WhitespaceRegex();
|
||||
|
||||
private const int TriggerKind_Invoke = 1;
|
||||
private const int TriggerKind_TriggerCharacter = 2;
|
||||
private const int TriggerKind_TriggerForIncompleteCompletions = 3;
|
||||
|
||||
private static readonly Dictionary<string, CompletionItemKind> s_roslynTagToCompletionItemKind = new()
|
||||
{
|
||||
{ WellKnownTags.Public, CompletionItemKind.Keyword },
|
||||
{ WellKnownTags.Protected, CompletionItemKind.Keyword },
|
||||
{ WellKnownTags.Private, CompletionItemKind.Keyword },
|
||||
{ WellKnownTags.Internal, CompletionItemKind.Keyword },
|
||||
{ WellKnownTags.File, CompletionItemKind.File },
|
||||
{ WellKnownTags.Project, CompletionItemKind.File },
|
||||
{ WellKnownTags.Folder, CompletionItemKind.Folder },
|
||||
{ WellKnownTags.Assembly, CompletionItemKind.File },
|
||||
{ WellKnownTags.Class, CompletionItemKind.Class },
|
||||
{ WellKnownTags.Constant, CompletionItemKind.Constant },
|
||||
{ WellKnownTags.Delegate, CompletionItemKind.Function },
|
||||
{ WellKnownTags.Enum, CompletionItemKind.Enum },
|
||||
{ WellKnownTags.EnumMember, CompletionItemKind.EnumMember },
|
||||
{ WellKnownTags.Event, CompletionItemKind.Event },
|
||||
{ WellKnownTags.ExtensionMethod, CompletionItemKind.Method },
|
||||
{ WellKnownTags.Field, CompletionItemKind.Field },
|
||||
{ WellKnownTags.Interface, CompletionItemKind.Interface },
|
||||
{ WellKnownTags.Intrinsic, CompletionItemKind.Text },
|
||||
{ WellKnownTags.Keyword, CompletionItemKind.Keyword },
|
||||
{ WellKnownTags.Label, CompletionItemKind.Text },
|
||||
{ WellKnownTags.Local, CompletionItemKind.Variable },
|
||||
{ WellKnownTags.Namespace, CompletionItemKind.Module },
|
||||
{ WellKnownTags.Method, CompletionItemKind.Method },
|
||||
{ WellKnownTags.Module, CompletionItemKind.Module },
|
||||
{ WellKnownTags.Operator, CompletionItemKind.Operator },
|
||||
{ WellKnownTags.Parameter, CompletionItemKind.Value },
|
||||
{ WellKnownTags.Property, CompletionItemKind.Property },
|
||||
{ WellKnownTags.RangeVariable, CompletionItemKind.Variable },
|
||||
{ WellKnownTags.Reference, CompletionItemKind.Reference },
|
||||
{ WellKnownTags.Structure, CompletionItemKind.Struct },
|
||||
{ WellKnownTags.TypeParameter, CompletionItemKind.TypeParameter },
|
||||
{ WellKnownTags.Snippet, CompletionItemKind.Snippet },
|
||||
{ WellKnownTags.Error, CompletionItemKind.Text },
|
||||
{ WellKnownTags.Warning, CompletionItemKind.Text },
|
||||
};
|
||||
|
||||
private static CompletionTrigger GetCompletionTrigger(int kind, char? triggerCharacter, bool includeTriggerCharacter)
|
||||
=> kind switch
|
||||
{
|
||||
TriggerKind_Invoke => CompletionTrigger.Invoke,
|
||||
TriggerKind_TriggerCharacter when includeTriggerCharacter && triggerCharacter.HasValue
|
||||
=> CompletionTrigger.CreateInsertionTrigger(triggerCharacter.Value),
|
||||
_ => CompletionTrigger.Invoke,
|
||||
};
|
||||
|
||||
private static ImmutableArray<char> BuildCommitCharacters(
|
||||
Microsoft.CodeAnalysis.Completion.CompletionList completions,
|
||||
ImmutableArray<CharacterSetModificationRule> characterRules,
|
||||
ImmutableArray<char>.Builder triggerCharactersBuilder)
|
||||
{
|
||||
if (completions is null) return [];
|
||||
|
||||
triggerCharactersBuilder.Clear();
|
||||
triggerCharactersBuilder.AddRange(completions.Rules.DefaultCommitCharacters);
|
||||
|
||||
foreach (var modifiedRule in characterRules)
|
||||
{
|
||||
switch (modifiedRule.Kind)
|
||||
{
|
||||
case CharacterSetModificationKind.Add:
|
||||
triggerCharactersBuilder.AddRange(modifiedRule.Characters);
|
||||
break;
|
||||
|
||||
case CharacterSetModificationKind.Remove:
|
||||
for (int i = triggerCharactersBuilder.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (modifiedRule.Characters.Contains(triggerCharactersBuilder[i]))
|
||||
{
|
||||
triggerCharactersBuilder.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case CharacterSetModificationKind.Replace:
|
||||
triggerCharactersBuilder.Clear();
|
||||
triggerCharactersBuilder.AddRange(modifiedRule.Characters);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (completions.SuggestionModeItem is not null)
|
||||
{
|
||||
triggerCharactersBuilder.Remove(' ');
|
||||
}
|
||||
|
||||
return triggerCharactersBuilder.ToImmutable();
|
||||
}
|
||||
|
||||
private static CompletionItemKind GetCompletionItemKind(ImmutableArray<string> tags)
|
||||
{
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
if (s_roslynTagToCompletionItemKind.TryGetValue(tag, out var itemKind))
|
||||
{
|
||||
return itemKind;
|
||||
}
|
||||
}
|
||||
|
||||
return CompletionItemKind.Text;
|
||||
}
|
||||
|
||||
public static async Task<IEnumerable<BlazorMonaco.Languages.CompletionItem>> GetCompletionAsync(
|
||||
this AdhocWorkspace workspace,
|
||||
DocumentId documentId,
|
||||
int line,
|
||||
int column,
|
||||
int kind,
|
||||
char? triggerCharacter)
|
||||
{
|
||||
if (triggerCharacter == ' ') return [];
|
||||
|
||||
var document = workspace.CurrentSolution.GetDocument(documentId);
|
||||
if (document is null) return [];
|
||||
|
||||
var sourceText = await document.GetTextAsync();
|
||||
|
||||
if (line < 0 || line >= sourceText.Lines.Count)
|
||||
return [];
|
||||
|
||||
var lineObj = sourceText.Lines[line];
|
||||
int maxColumn = lineObj.End - lineObj.Start;
|
||||
|
||||
if (column < 0)
|
||||
return [];
|
||||
|
||||
if (column > maxColumn)
|
||||
column = maxColumn;
|
||||
|
||||
var position = sourceText.Lines.GetPosition(new LinePosition(line, column));
|
||||
|
||||
if (position < 0 || position > sourceText.Length)
|
||||
return [];
|
||||
|
||||
var completionService = CompletionService.GetService(document);
|
||||
if (completionService == null) return [];
|
||||
|
||||
if (kind == TriggerKind_TriggerForIncompleteCompletions
|
||||
&& !completionService.ShouldTriggerCompletion(
|
||||
sourceText,
|
||||
position,
|
||||
GetCompletionTrigger(TriggerKind_TriggerCharacter, triggerCharacter, includeTriggerCharacter: true)))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
Microsoft.CodeAnalysis.Completion.CompletionList? completionList = null;
|
||||
try
|
||||
{
|
||||
completionList = await completionService.GetCompletionsAsync(
|
||||
document,
|
||||
position,
|
||||
GetCompletionTrigger(kind - 1, triggerCharacter, includeTriggerCharacter: false));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (completionList is null || completionList.ItemsList.Count <= 0)
|
||||
return [];
|
||||
|
||||
var typedSpan = completionService.GetDefaultCompletionListSpan(sourceText, position);
|
||||
|
||||
if (typedSpan.Start < 0 || typedSpan.End > sourceText.Length)
|
||||
return [];
|
||||
|
||||
var typedText = sourceText.GetSubText(typedSpan).ToString();
|
||||
|
||||
LinePosition replacingSpanStart;
|
||||
LinePosition replacingSpanEnd;
|
||||
|
||||
try
|
||||
{
|
||||
replacingSpanStart = sourceText.Lines.GetLinePosition(typedSpan.Start);
|
||||
replacingSpanEnd = sourceText.Lines.GetLinePosition(typedSpan.End);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!typedSpan.IsEmpty || triggerCharacter != '.')
|
||||
{
|
||||
if (replacingSpanStart.Line != replacingSpanEnd.Line
|
||||
|| replacingSpanStart.Character > replacingSpanEnd.Character)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
ImmutableArray<string> filteredItems = typedText != string.Empty
|
||||
? [.. completionService.FilterItems(document, [.. completionList.ItemsList], typedText)
|
||||
.Select(i => i.DisplayText)]
|
||||
: [];
|
||||
|
||||
bool expectingImportedItems = workspace.Options.GetOption(
|
||||
new PerLanguageOption<bool?>("CompletionOptions", "ShowItemsFromUnimportedNamespaces", defaultValue: null),
|
||||
LanguageNames.CSharp) == true;
|
||||
|
||||
var completionsBuilder = new List<BlazorMonaco.Languages.CompletionItem>(completionList.ItemsList.Count);
|
||||
var commitCharactersCache = new Dictionary<int, string[]>();
|
||||
|
||||
var range = new BlazorMonaco.Range
|
||||
{
|
||||
StartLineNumber = replacingSpanStart.Line + 1,
|
||||
EndLineNumber = replacingSpanEnd.Line + 1,
|
||||
StartColumn = replacingSpanStart.Character + 1,
|
||||
EndColumn = replacingSpanEnd.Character + 1,
|
||||
};
|
||||
|
||||
foreach (var completion in completionList.ItemsList)
|
||||
{
|
||||
string? insertText = completion.Properties.TryGetValue("InsertionText", out var propInsertText)
|
||||
? propInsertText
|
||||
: completion.DisplayText;
|
||||
|
||||
if (string.IsNullOrEmpty(insertText))
|
||||
continue;
|
||||
|
||||
string documentation = "";
|
||||
|
||||
int rulesHash = GetRulesHash(completion.Rules.CommitCharacterRules);
|
||||
if (!commitCharactersCache.TryGetValue(rulesHash, out var commitCharacters))
|
||||
{
|
||||
var localBuilder = ImmutableArray.CreateBuilder<char>(
|
||||
completionList.Rules.DefaultCommitCharacters.Length);
|
||||
|
||||
var chars = BuildCommitCharacters(
|
||||
completionList,
|
||||
completion.Rules.CommitCharacterRules,
|
||||
localBuilder);
|
||||
|
||||
commitCharacters = [.. chars.Select(c => c.ToString())];
|
||||
commitCharactersCache[rulesHash] = commitCharacters;
|
||||
}
|
||||
|
||||
char sortTextPrepend = '0';
|
||||
CompletionItemInsertTextRule? insertTextRules = null;
|
||||
|
||||
if (completion.IsComplexTextEdit ||
|
||||
(completion.Properties.ContainsKey("Provider") &&
|
||||
completion.Properties["Provider"] == "SnippetCompletionProvider"))
|
||||
{
|
||||
insertTextRules = CompletionItemInsertTextRule.InsertAsSnippet;
|
||||
}
|
||||
|
||||
completionsBuilder.Add(new BlazorMonaco.Languages.CompletionItem
|
||||
{
|
||||
LabelAsString = completion.DisplayTextPrefix + completion.DisplayText + completion.DisplayTextSuffix,
|
||||
Kind = GetCompletionItemKind(completion.Tags),
|
||||
DocumentationAsString = documentation,
|
||||
InsertText = insertText,
|
||||
RangeAsObject = range,
|
||||
AdditionalTextEdits = [],
|
||||
SortText = expectingImportedItems ? sortTextPrepend + completion.SortText : completion.SortText,
|
||||
FilterText = completion.FilterText,
|
||||
Detail = completion.InlineDescription,
|
||||
Preselect = completion.Rules.MatchPriority == MatchPriority.Preselect
|
||||
|| filteredItems.Contains(completion.DisplayText),
|
||||
CommitCharacters = [.. commitCharacters],
|
||||
Tags = [],
|
||||
Command = null,
|
||||
InsertTextRules = insertTextRules,
|
||||
});
|
||||
}
|
||||
|
||||
return completionsBuilder;
|
||||
}
|
||||
|
||||
private static int GetRulesHash(ImmutableArray<CharacterSetModificationRule> rules)
|
||||
{
|
||||
if (rules.IsEmpty) return 0;
|
||||
|
||||
var hash = new HashCode();
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
hash.Add(rule.Kind);
|
||||
hash.Add(rule.Characters.Length);
|
||||
foreach (var c in rule.Characters)
|
||||
{
|
||||
hash.Add(c);
|
||||
}
|
||||
}
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
|
||||
public static async Task<string?> GetQuickInfoAsync(
|
||||
this AdhocWorkspace workspace,
|
||||
DocumentId documentId,
|
||||
int line,
|
||||
int column)
|
||||
{
|
||||
var document = workspace.CurrentSolution.GetDocument(documentId);
|
||||
if (document is null) return null;
|
||||
|
||||
var sourceText = await document.GetTextAsync();
|
||||
var position = sourceText.Lines.GetPosition(new LinePosition(line, column));
|
||||
|
||||
var quickInfoService = QuickInfoService.GetService(document);
|
||||
if (quickInfoService is null) return string.Empty;
|
||||
|
||||
var quickInfo = await quickInfoService.GetQuickInfoAsync(document, position);
|
||||
if (quickInfo is null) return string.Empty;
|
||||
|
||||
var finalTextBuilder = new StringBuilder();
|
||||
|
||||
bool lastSectionHadLineBreak = true;
|
||||
var description = quickInfo.Sections.FirstOrDefault(s => s.Kind == QuickInfoSectionKinds.Description);
|
||||
if (description is not null)
|
||||
{
|
||||
finalTextBuilder.AppendSection(description, MarkdownFormat.AllTextAsCSharp, ref lastSectionHadLineBreak);
|
||||
}
|
||||
|
||||
var summary = quickInfo.Sections.FirstOrDefault(s => s.Kind == QuickInfoSectionKinds.DocumentationComments);
|
||||
if (summary is not null)
|
||||
{
|
||||
finalTextBuilder.AppendSection(summary, MarkdownFormat.Default, ref lastSectionHadLineBreak);
|
||||
}
|
||||
|
||||
foreach (var section in quickInfo.Sections)
|
||||
{
|
||||
switch (section.Kind)
|
||||
{
|
||||
case QuickInfoSectionKinds.Description:
|
||||
case QuickInfoSectionKinds.DocumentationComments:
|
||||
continue;
|
||||
|
||||
case QuickInfoSectionKinds.TypeParameters:
|
||||
finalTextBuilder.AppendSection(section, MarkdownFormat.AllTextAsCSharp, ref lastSectionHadLineBreak);
|
||||
break;
|
||||
|
||||
case QuickInfoSectionKinds.AnonymousTypes:
|
||||
finalTextBuilder.AppendSection(section, MarkdownFormat.FirstLineDefaultRestCSharp, ref lastSectionHadLineBreak);
|
||||
break;
|
||||
|
||||
case "NullabilityAnalysis":
|
||||
finalTextBuilder.AppendSection(section, MarkdownFormat.Italicize, ref lastSectionHadLineBreak);
|
||||
break;
|
||||
|
||||
default:
|
||||
finalTextBuilder.AppendSection(section, MarkdownFormat.Default, ref lastSectionHadLineBreak);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var semanticModel = await document.GetSemanticModelAsync();
|
||||
var syntaxTree = await document.GetSyntaxTreeAsync();
|
||||
|
||||
if (semanticModel is not null && syntaxTree is not null)
|
||||
{
|
||||
var root = await syntaxTree.GetRootAsync();
|
||||
var node = root.FindToken(position).Parent;
|
||||
|
||||
while (node is not null)
|
||||
{
|
||||
var symbolInfo = semanticModel.GetSymbolInfo(node);
|
||||
var symbol = symbolInfo.Symbol ?? semanticModel.GetDeclaredSymbol(node);
|
||||
|
||||
if (symbol is IMethodSymbol methodSymbol)
|
||||
{
|
||||
var containingType = methodSymbol.ContainingType;
|
||||
if (containingType is not null)
|
||||
{
|
||||
var overloads = containingType.GetMembers(methodSymbol.Name)
|
||||
.OfType<IMethodSymbol>()
|
||||
.Where(m => m.MethodKind == methodSymbol.MethodKind)
|
||||
.ToList();
|
||||
|
||||
if (overloads.Count > 1)
|
||||
{
|
||||
finalTextBuilder.AppendLine();
|
||||
finalTextBuilder.AppendLine();
|
||||
finalTextBuilder.AppendLine("---");
|
||||
finalTextBuilder.AppendLine($"**Overloads ({overloads.Count}):**");
|
||||
finalTextBuilder.AppendLine();
|
||||
|
||||
foreach (var overload in overloads)
|
||||
{
|
||||
finalTextBuilder.AppendLine("```csharp");
|
||||
finalTextBuilder.AppendLine(overload.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat));
|
||||
finalTextBuilder.AppendLine("```");
|
||||
|
||||
var xmlDoc = overload.GetDocumentationCommentXml();
|
||||
if (!string.IsNullOrEmpty(xmlDoc))
|
||||
{
|
||||
var summaryMatch = SummaryRegex().Match(xmlDoc);
|
||||
if (summaryMatch.Success)
|
||||
{
|
||||
var summaryText = summaryMatch.Groups[1].Value.Trim();
|
||||
summaryText = WhitespaceRegex().Replace(summaryText, " ");
|
||||
finalTextBuilder.AppendLine(summaryText);
|
||||
}
|
||||
}
|
||||
|
||||
finalTextBuilder.AppendLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
node = node.Parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silently handle errors
|
||||
}
|
||||
|
||||
return finalTextBuilder.ToString().Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace RobotNet10.ScriptEditor.Helpers.Code;
|
||||
|
||||
public class BlazorBootJson
|
||||
{
|
||||
public string MainAssemblyName { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("resources")]
|
||||
public BlazorResources Resources { get; set; } = new();
|
||||
public bool CacheBootResources { get; set; }
|
||||
public int DebugLevel { get; set; }
|
||||
public string GlobalizationMode { get; set; } = "";
|
||||
public Dictionary<string, object> Extensions { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace RobotNet10.ScriptEditor.Helpers.Code;
|
||||
|
||||
public class BlazorResources
|
||||
{
|
||||
public string Hash { get; set; } = "";
|
||||
public Dictionary<string, string> Assembly { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("fingerprinting")]
|
||||
public Dictionary<string, string> Fingerprinting { get; set; } = [];
|
||||
public Dictionary<string, string> WasmNative { get; set; } = [];
|
||||
public Dictionary<string, string> CoreAssembly { get; set; } = [];
|
||||
public Dictionary<string, string> Pdb { get; set; } = [];
|
||||
public Dictionary<string, Dictionary<string, string>> SatelliteResources { get; set; } = [];
|
||||
public Dictionary<string, string> JsModuleNative { get; set; } = [];
|
||||
public Dictionary<string, string> JsModuleRuntime { get; set; } = [];
|
||||
public Dictionary<string, string> LibraryInitializers { get; set; } = [];
|
||||
public Dictionary<string, string> ModulesAfterConfigLoaded { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.QuickInfo;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace RobotNet10.ScriptEditor.Helpers;
|
||||
|
||||
public enum MarkdownFormat
|
||||
{
|
||||
Default,
|
||||
Italicize,
|
||||
FirstLineAsCSharp,
|
||||
FirstLineDefaultRestCSharp,
|
||||
AllTextAsCSharp
|
||||
}
|
||||
|
||||
public static class MarkdownHelpers
|
||||
{
|
||||
private static readonly Regex EscapeRegex = new("([\\\\`\\*_\\{\\}\\[\\]\\(\\)#+\\-\\.!])", RegexOptions.Compiled);
|
||||
|
||||
private const string ContainerStart = "ContainerStart";
|
||||
|
||||
private const string ContainerEnd = "ContainerEnd";
|
||||
|
||||
public static string Escape(string markdown) => string.IsNullOrEmpty(markdown) ? string.Empty : EscapeRegex.Replace(markdown, "\\$1");
|
||||
|
||||
public static void AppendSection(this StringBuilder builder, QuickInfoSection section, MarkdownFormat format, ref bool lastLineBreak)
|
||||
{
|
||||
if (!lastLineBreak && section.TaggedParts.Length > 0 && section.TaggedParts[0].Tag != "LineBreak")
|
||||
{
|
||||
builder.Append("\n\n");
|
||||
}
|
||||
MarkdownHelpers.TaggedTextToMarkdown(section.TaggedParts, builder, "\n", format, out lastLineBreak);
|
||||
}
|
||||
|
||||
public static void TaggedTextToMarkdown(ImmutableArray<TaggedText> taggedParts, StringBuilder stringBuilder, string newLine, MarkdownFormat markdownFormat, out bool endedWithLineBreak)
|
||||
{
|
||||
bool isInCodeBlock = false;
|
||||
bool brokeLine = true;
|
||||
bool afterFirstLine = false;
|
||||
if (markdownFormat == MarkdownFormat.Italicize)
|
||||
{
|
||||
stringBuilder.Append('_');
|
||||
}
|
||||
|
||||
int num = 0;
|
||||
while (num < taggedParts.Length)
|
||||
{
|
||||
TaggedText taggedText = taggedParts[num];
|
||||
bool flag;
|
||||
if (brokeLine && markdownFormat != MarkdownFormat.Italicize)
|
||||
{
|
||||
brokeLine = false;
|
||||
if (!afterFirstLine)
|
||||
{
|
||||
if (markdownFormat != MarkdownFormat.FirstLineAsCSharp)
|
||||
{
|
||||
goto IL_00a2;
|
||||
}
|
||||
|
||||
flag = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (markdownFormat != MarkdownFormat.FirstLineDefaultRestCSharp)
|
||||
{
|
||||
goto IL_00a2;
|
||||
}
|
||||
|
||||
flag = true;
|
||||
}
|
||||
|
||||
goto IL_00bf;
|
||||
}
|
||||
|
||||
goto IL_0279;
|
||||
IL_00a2:
|
||||
flag = markdownFormat == MarkdownFormat.AllTextAsCSharp;
|
||||
goto IL_00bf;
|
||||
IL_0279:
|
||||
switch (taggedText.Tag)
|
||||
{
|
||||
case "Text":
|
||||
if (!isInCodeBlock)
|
||||
{
|
||||
addText(taggedText.Text);
|
||||
break;
|
||||
}
|
||||
|
||||
endBlock();
|
||||
addText(taggedText.Text);
|
||||
break;
|
||||
case "Space":
|
||||
if (isInCodeBlock)
|
||||
{
|
||||
if (indexIsTag(num + 1, ["Text"]))
|
||||
{
|
||||
endBlock();
|
||||
}
|
||||
|
||||
addText(taggedText.Text);
|
||||
break;
|
||||
}
|
||||
|
||||
goto case "Punctuation";
|
||||
case "Punctuation":
|
||||
addText(taggedText.Text);
|
||||
break;
|
||||
case ContainerStart:
|
||||
addNewline();
|
||||
addText(taggedText.Text);
|
||||
break;
|
||||
case ContainerEnd:
|
||||
addNewline();
|
||||
break;
|
||||
case "LineBreak":
|
||||
if (stringBuilder.Length != 0 && !indexIsTag(num + 1, [ContainerStart, ContainerEnd]) && num + 1 != taggedParts.Length)
|
||||
{
|
||||
addNewline();
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
if (!isInCodeBlock)
|
||||
{
|
||||
isInCodeBlock = true;
|
||||
stringBuilder.Append('`');
|
||||
}
|
||||
|
||||
stringBuilder.Append(taggedText.Text);
|
||||
brokeLine = false;
|
||||
break;
|
||||
}
|
||||
|
||||
num++;
|
||||
continue;
|
||||
IL_00bf:
|
||||
bool flag2 = flag;
|
||||
if (!flag2)
|
||||
{
|
||||
for (int j = num; j < taggedParts.Length; flag2 = true, j++)
|
||||
{
|
||||
switch (taggedParts[j].Tag)
|
||||
{
|
||||
case "Text":
|
||||
flag2 = false;
|
||||
break;
|
||||
default:
|
||||
continue;
|
||||
case ContainerStart:
|
||||
case ContainerEnd:
|
||||
case "LineBreak":
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
flag2 = !indexIsTag(num,
|
||||
[
|
||||
ContainerStart,
|
||||
ContainerEnd,
|
||||
"LineBreak"
|
||||
]);
|
||||
}
|
||||
|
||||
if (flag2)
|
||||
{
|
||||
afterFirstLine = true;
|
||||
stringBuilder.Append("```csharp");
|
||||
stringBuilder.Append(newLine);
|
||||
while (true)
|
||||
{
|
||||
if (num < taggedParts.Length)
|
||||
{
|
||||
taggedText = taggedParts[num];
|
||||
if (taggedText.Tag == ContainerStart || taggedText.Tag == ContainerEnd || taggedText.Tag == "LineBreak")
|
||||
{
|
||||
stringBuilder.Append(newLine);
|
||||
if (markdownFormat != MarkdownFormat.AllTextAsCSharp && markdownFormat != MarkdownFormat.FirstLineDefaultRestCSharp)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
stringBuilder.Append(taggedText.Text);
|
||||
}
|
||||
|
||||
num++;
|
||||
continue;
|
||||
}
|
||||
|
||||
stringBuilder.Append(newLine);
|
||||
stringBuilder.Append("```");
|
||||
endedWithLineBreak = false;
|
||||
return;
|
||||
}
|
||||
|
||||
stringBuilder.Append("```");
|
||||
}
|
||||
|
||||
goto IL_0279;
|
||||
}
|
||||
|
||||
if (isInCodeBlock)
|
||||
{
|
||||
endBlock();
|
||||
}
|
||||
|
||||
if (!brokeLine && markdownFormat == MarkdownFormat.Italicize)
|
||||
{
|
||||
stringBuilder.Append('_');
|
||||
}
|
||||
|
||||
endedWithLineBreak = brokeLine;
|
||||
void addNewline()
|
||||
{
|
||||
if (isInCodeBlock)
|
||||
{
|
||||
endBlock();
|
||||
}
|
||||
|
||||
if (markdownFormat == MarkdownFormat.Italicize)
|
||||
{
|
||||
stringBuilder.Append('_');
|
||||
}
|
||||
|
||||
stringBuilder.Append(newLine);
|
||||
stringBuilder.Append(newLine);
|
||||
brokeLine = true;
|
||||
if (markdownFormat == MarkdownFormat.Italicize)
|
||||
{
|
||||
stringBuilder.Append('_');
|
||||
}
|
||||
}
|
||||
|
||||
void addText(string text)
|
||||
{
|
||||
brokeLine = false;
|
||||
afterFirstLine = true;
|
||||
if (!isInCodeBlock)
|
||||
{
|
||||
text = Escape(text);
|
||||
}
|
||||
|
||||
stringBuilder.Append(text);
|
||||
}
|
||||
|
||||
void endBlock()
|
||||
{
|
||||
stringBuilder.Append('`');
|
||||
isInCodeBlock = false;
|
||||
}
|
||||
|
||||
bool indexIsTag(int i, string[] tags)
|
||||
{
|
||||
if (i < taggedParts.Length)
|
||||
{
|
||||
return tags.Contains(taggedParts[i].Tag);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
|
||||
namespace RobotNet10.ScriptEditor.Helpers.Monaco.Documentation;
|
||||
|
||||
public class DocumentationComment(
|
||||
string summaryText = "",
|
||||
DocumentationItem[]? typeParamElements = null,
|
||||
DocumentationItem[]? paramElements = null,
|
||||
string returnsText = "",
|
||||
string remarksText = "",
|
||||
string exampleText = "",
|
||||
string valueText = "",
|
||||
DocumentationItem[]? exception = null)
|
||||
{
|
||||
public string SummaryText { get; } = summaryText;
|
||||
public DocumentationItem[] TypeParamElements { get; } = typeParamElements ?? [];
|
||||
public DocumentationItem[] ParamElements { get; } = paramElements ?? [];
|
||||
public string ReturnsText { get; } = returnsText;
|
||||
public string RemarksText { get; } = remarksText;
|
||||
public string ExampleText { get; } = exampleText;
|
||||
public string ValueText { get; } = valueText;
|
||||
public DocumentationItem[] Exception { get; } = exception ?? [];
|
||||
|
||||
public static DocumentationComment? From(string xmlDocumentation, string lineEnding)
|
||||
{
|
||||
if (string.IsNullOrEmpty(xmlDocumentation))
|
||||
return Empty;
|
||||
|
||||
var reader = new StringReader("<docroot>" + xmlDocumentation + "</docroot>");
|
||||
var summaryText = new StringBuilder();
|
||||
var typeParamElements = new List<DocumentationItemBuilder>();
|
||||
var paramElements = new List<DocumentationItemBuilder>();
|
||||
var returnsText = new StringBuilder();
|
||||
var remarksText = new StringBuilder();
|
||||
var exampleText = new StringBuilder();
|
||||
var valueText = new StringBuilder();
|
||||
var exception = new List<DocumentationItemBuilder>();
|
||||
|
||||
using (var xml = XmlReader.Create(reader))
|
||||
{
|
||||
try
|
||||
{
|
||||
xml.Read();
|
||||
string? elementName = null;
|
||||
StringBuilder? currentSectionBuilder = null;
|
||||
do
|
||||
{
|
||||
if (xml.NodeType == XmlNodeType.Element)
|
||||
{
|
||||
elementName = xml.Name.ToLowerInvariant();
|
||||
switch (elementName)
|
||||
{
|
||||
case "filterpriority":
|
||||
xml.Skip();
|
||||
break;
|
||||
case "remarks":
|
||||
currentSectionBuilder = remarksText;
|
||||
break;
|
||||
case "example":
|
||||
currentSectionBuilder = exampleText;
|
||||
break;
|
||||
case "exception":
|
||||
DocumentationItemBuilder exceptionInstance = new(GetCref(xml["cref"]).TrimEnd());
|
||||
currentSectionBuilder = exceptionInstance.Documentation;
|
||||
exception.Add(exceptionInstance);
|
||||
break;
|
||||
case "returns":
|
||||
currentSectionBuilder = returnsText;
|
||||
break;
|
||||
case "summary":
|
||||
currentSectionBuilder = summaryText;
|
||||
break;
|
||||
case "see":
|
||||
if (currentSectionBuilder is null) continue;
|
||||
currentSectionBuilder.Append(GetCref(xml["cref"]));
|
||||
currentSectionBuilder.Append(xml["langword"]);
|
||||
break;
|
||||
case "seealso":
|
||||
if (currentSectionBuilder is null) continue;
|
||||
currentSectionBuilder.Append("See also: ");
|
||||
currentSectionBuilder.Append(GetCref(xml["cref"]));
|
||||
break;
|
||||
case "paramref":
|
||||
if (currentSectionBuilder is null) continue;
|
||||
currentSectionBuilder.Append(xml["name"]);
|
||||
currentSectionBuilder.Append(' ');
|
||||
break;
|
||||
case "param":
|
||||
|
||||
DocumentationItemBuilder paramInstance = new(TrimMultiLineString(xml["name"] ?? "", lineEnding));
|
||||
currentSectionBuilder = paramInstance.Documentation;
|
||||
paramElements.Add(paramInstance);
|
||||
break;
|
||||
case "typeparamref":
|
||||
if (currentSectionBuilder is null) continue;
|
||||
currentSectionBuilder.Append(xml["name"]);
|
||||
currentSectionBuilder.Append(' ');
|
||||
break;
|
||||
case "typeparam":
|
||||
DocumentationItemBuilder typeParamInstance = new(TrimMultiLineString(xml["name"] ?? "", lineEnding));
|
||||
currentSectionBuilder = typeParamInstance.Documentation;
|
||||
typeParamElements.Add(typeParamInstance);
|
||||
break;
|
||||
case "value":
|
||||
currentSectionBuilder = valueText;
|
||||
break;
|
||||
case "br":
|
||||
case "para":
|
||||
if (currentSectionBuilder is null) continue;
|
||||
currentSectionBuilder.Append(lineEnding);
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (xml.NodeType == XmlNodeType.Text && currentSectionBuilder != null)
|
||||
{
|
||||
if (elementName == "code")
|
||||
{
|
||||
currentSectionBuilder.Append(xml.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
currentSectionBuilder.Append(TrimMultiLineString(xml.Value, lineEnding));
|
||||
}
|
||||
}
|
||||
} while (xml.Read());
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return new DocumentationComment(
|
||||
summaryText.ToString(),
|
||||
[.. typeParamElements.Select(s => s.ConvertToDocumentedObject())],
|
||||
[.. paramElements.Select(s => s.ConvertToDocumentedObject())],
|
||||
returnsText.ToString(),
|
||||
remarksText.ToString(),
|
||||
exampleText.ToString(),
|
||||
valueText.ToString(),
|
||||
[.. exception.Select(s => s.ConvertToDocumentedObject())]);
|
||||
}
|
||||
|
||||
private static string TrimMultiLineString(string input, string lineEnding)
|
||||
{
|
||||
var lines = input.Split(separator, StringSplitOptions.RemoveEmptyEntries);
|
||||
return string.Join(lineEnding, lines.Select(l => TrimStartRetainingSingleLeadingSpace(l)));
|
||||
}
|
||||
|
||||
private static string GetCref(string? cref)
|
||||
{
|
||||
if (cref == null || cref.Trim().Length == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
if (cref.Length < 2)
|
||||
{
|
||||
return cref;
|
||||
}
|
||||
if (cref.Substring(1, 1) == ":")
|
||||
{
|
||||
return string.Concat(cref.AsSpan(2, cref.Length - 2), " ");
|
||||
}
|
||||
return cref + " ";
|
||||
}
|
||||
|
||||
private static string TrimStartRetainingSingleLeadingSpace(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return string.Empty;
|
||||
if (!char.IsWhiteSpace(input[0]))
|
||||
return input;
|
||||
return $" {input.TrimStart()}";
|
||||
}
|
||||
|
||||
public string GetParameterText(string name)
|
||||
=> Array.Find(ParamElements, parameter => parameter.Name == name)?.Documentation ?? string.Empty;
|
||||
|
||||
public string GetTypeParameterText(string name)
|
||||
=> Array.Find(TypeParamElements, typeParam => typeParam.Name == name)?.Documentation ?? string.Empty;
|
||||
|
||||
public static readonly DocumentationComment Empty = new();
|
||||
private static readonly string[] separator = ["\n", "\r\n"];
|
||||
}
|
||||
|
||||
class DocumentationItemBuilder(string name)
|
||||
{
|
||||
public string Name { get; set; } = name;
|
||||
public StringBuilder Documentation { get; set; } = new StringBuilder();
|
||||
|
||||
public DocumentationItem ConvertToDocumentedObject()
|
||||
{
|
||||
return new DocumentationItem(Name, Documentation.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace RobotNet10.ScriptEditor.Helpers.Monaco.Documentation;
|
||||
|
||||
public class DocumentationItem(string name, string documentation)
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
public string Documentation { get; } = documentation;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using RobotNet10.ScriptEditor.Helpers.Monaco.Documentation;
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
|
||||
namespace RobotNet10.ScriptEditor.Helpers.Monaco;
|
||||
|
||||
public class DocumentationConverter
|
||||
{/// <summary>
|
||||
/// Converts the xml documentation string into a plain text string.
|
||||
/// </summary>
|
||||
public static string ConvertDocumentation(string xmlDocumentation, string lineEnding)
|
||||
{
|
||||
if (string.IsNullOrEmpty(xmlDocumentation))
|
||||
return string.Empty;
|
||||
|
||||
var reader = new StringReader("<docroot>" + xmlDocumentation + "</docroot>");
|
||||
using var xml = XmlReader.Create(reader);
|
||||
var ret = new StringBuilder();
|
||||
|
||||
try
|
||||
{
|
||||
xml.Read();
|
||||
string? elementName = null;
|
||||
do
|
||||
{
|
||||
if (xml.NodeType == XmlNodeType.Element)
|
||||
{
|
||||
elementName = xml.Name.ToLowerInvariant();
|
||||
switch (elementName)
|
||||
{
|
||||
case "filterpriority":
|
||||
xml.Skip();
|
||||
break;
|
||||
case "remarks":
|
||||
ret.Append(lineEnding);
|
||||
ret.Append("Remarks:");
|
||||
ret.Append(lineEnding);
|
||||
break;
|
||||
case "example":
|
||||
ret.Append(lineEnding);
|
||||
ret.Append("Example:");
|
||||
ret.Append(lineEnding);
|
||||
break;
|
||||
case "exception":
|
||||
ret.Append(lineEnding);
|
||||
ret.Append(GetCref(xml["cref"]).TrimEnd());
|
||||
ret.Append(": ");
|
||||
break;
|
||||
case "returns":
|
||||
ret.Append(lineEnding);
|
||||
ret.Append("Returns: ");
|
||||
break;
|
||||
case "see":
|
||||
ret.Append(GetCref(xml["cref"]));
|
||||
ret.Append(xml["langword"]);
|
||||
break;
|
||||
case "seealso":
|
||||
ret.Append(lineEnding);
|
||||
ret.Append("See also: ");
|
||||
ret.Append(GetCref(xml["cref"]));
|
||||
break;
|
||||
case "paramref":
|
||||
ret.Append(xml["name"]);
|
||||
ret.Append(' ');
|
||||
break;
|
||||
case "typeparam":
|
||||
ret.Append(lineEnding);
|
||||
ret.Append('<');
|
||||
ret.Append(TrimMultiLineString(xml["name"], lineEnding));
|
||||
ret.Append(">: ");
|
||||
break;
|
||||
case "param":
|
||||
ret.Append(lineEnding);
|
||||
ret.Append(TrimMultiLineString(xml["name"], lineEnding));
|
||||
ret.Append(": ");
|
||||
break;
|
||||
case "value":
|
||||
ret.Append(lineEnding);
|
||||
ret.Append("Value: ");
|
||||
ret.Append(lineEnding);
|
||||
break;
|
||||
case "br":
|
||||
case "para":
|
||||
ret.Append(lineEnding);
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (xml.NodeType == XmlNodeType.Text)
|
||||
{
|
||||
if (elementName == "code")
|
||||
{
|
||||
ret.Append(xml.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
ret.Append(TrimMultiLineString(xml.Value, lineEnding));
|
||||
}
|
||||
}
|
||||
} while (xml.Read());
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return xmlDocumentation;
|
||||
}
|
||||
return ret.ToString();
|
||||
}
|
||||
|
||||
private static readonly string[] separator = ["\n", "\r\n"];
|
||||
|
||||
private static string TrimMultiLineString(string? input, string lineEnding)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input)) return "";
|
||||
var lines = input.Split(separator, StringSplitOptions.RemoveEmptyEntries);
|
||||
return string.Join(lineEnding, lines.Select(l => l.TrimStart()));
|
||||
}
|
||||
|
||||
private static string GetCref(string? cref)
|
||||
{
|
||||
if (cref == null || cref.Trim().Length == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
if (cref.Length < 2)
|
||||
{
|
||||
return cref;
|
||||
}
|
||||
if (cref.Substring(1, 1) == ":")
|
||||
{
|
||||
return cref[2..] + " ";
|
||||
}
|
||||
return cref + " ";
|
||||
}
|
||||
|
||||
public static DocumentationComment? GetStructuredDocumentation(string? xmlDocumentation, string lineEnding)
|
||||
{
|
||||
if (string.IsNullOrEmpty(xmlDocumentation)) return null;
|
||||
return DocumentationComment.From(xmlDocumentation, lineEnding);
|
||||
}
|
||||
|
||||
public static DocumentationComment? GetStructuredDocumentation(ISymbol symbol, string lineEnding = "\n")
|
||||
{
|
||||
return symbol switch
|
||||
{
|
||||
IParameterSymbol parameter => new DocumentationComment(summaryText: GetParameterDocumentation(parameter, lineEnding) ?? ""),
|
||||
ITypeParameterSymbol typeParam => new DocumentationComment(summaryText: GetTypeParameterDocumentation(typeParam, lineEnding) ?? ""),
|
||||
IAliasSymbol alias => new DocumentationComment(summaryText: GetAliasDocumentation(alias, lineEnding) ?? ""),
|
||||
_ => GetStructuredDocumentation(symbol.GetDocumentationCommentXml(), lineEnding),
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetParameterDocumentation(IParameterSymbol parameter, string lineEnding = "\n")
|
||||
{
|
||||
var contaningSymbolDef = parameter.ContainingSymbol.OriginalDefinition;
|
||||
return GetStructuredDocumentation(contaningSymbolDef.GetDocumentationCommentXml(), lineEnding)
|
||||
?.GetParameterText(parameter.Name);
|
||||
}
|
||||
|
||||
private static string? GetTypeParameterDocumentation(ITypeParameterSymbol typeParam, string lineEnding = "\n")
|
||||
{
|
||||
var contaningSymbol = typeParam.ContainingSymbol;
|
||||
return GetStructuredDocumentation(contaningSymbol.GetDocumentationCommentXml(), lineEnding)
|
||||
?.GetTypeParameterText(typeParam.Name);
|
||||
}
|
||||
|
||||
private static string? GetAliasDocumentation(IAliasSymbol alias, string lineEnding = "\n")
|
||||
{
|
||||
return GetStructuredDocumentation(alias.Target.GetDocumentationCommentXml(), lineEnding)?.SummaryText;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
|
||||
namespace RobotNet10.ScriptEditor.Helpers.Monaco;
|
||||
|
||||
public class InvocationContext
|
||||
{
|
||||
public SemanticModel SemanticModel { get; }
|
||||
public int Position { get; }
|
||||
public SyntaxNode Receiver { get; }
|
||||
public IEnumerable<TypeInfo> ArgumentTypes { get; }
|
||||
public IEnumerable<SyntaxToken> Separators { get; }
|
||||
public bool IsInStaticContext { get; }
|
||||
|
||||
public InvocationContext(SemanticModel semModel, int position, SyntaxNode receiver, ArgumentListSyntax argList, bool isStatic)
|
||||
{
|
||||
SemanticModel = semModel;
|
||||
Position = position;
|
||||
Receiver = receiver;
|
||||
ArgumentTypes = argList.Arguments.Select(argument => semModel.GetTypeInfo(argument.Expression));
|
||||
Separators = argList.Arguments.GetSeparators();
|
||||
IsInStaticContext = isStatic;
|
||||
}
|
||||
|
||||
public InvocationContext(SemanticModel semModel, int position, SyntaxNode receiver, AttributeArgumentListSyntax argList, bool isStatic)
|
||||
{
|
||||
SemanticModel = semModel;
|
||||
Position = position;
|
||||
Receiver = receiver;
|
||||
ArgumentTypes = argList.Arguments.Select(argument => semModel.GetTypeInfo(argument.Expression));
|
||||
Separators = argList.Arguments.GetSeparators();
|
||||
IsInStaticContext = isStatic;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using BlazorMonaco;
|
||||
|
||||
namespace RobotNet10.ScriptEditor.Helpers.Monaco.Languages;
|
||||
|
||||
public class ParameterInformation
|
||||
{
|
||||
public string Label { get; set; } = "";
|
||||
public MarkdownString? Documentation { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace RobotNet10.ScriptEditor.Helpers.Monaco.Languages;
|
||||
|
||||
public class SignatureHelp
|
||||
{
|
||||
public int ActiveParameter { get; set; }
|
||||
public int ActiveSignature { get; set; }
|
||||
public SignatureInformation[] Signatures { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace RobotNet10.ScriptEditor.Helpers.Monaco.Languages;
|
||||
|
||||
public class SignatureHelpResult
|
||||
{
|
||||
public SignatureHelp Value { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using BlazorMonaco;
|
||||
|
||||
namespace RobotNet10.ScriptEditor.Helpers.Monaco.Languages;
|
||||
|
||||
public class SignatureInformation
|
||||
{
|
||||
public int? ActiveParameter { get; set; }
|
||||
public MarkdownString? Documentation { get; set; }
|
||||
public string Label { get; set; } = "";
|
||||
public ParameterInformation[] Parameters { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
using BlazorMonaco;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Text;
|
||||
using RobotNet10.ScriptEditor.Helpers.Monaco;
|
||||
using RobotNet10.ScriptEditor.Helpers.Monaco.Languages;
|
||||
|
||||
namespace RobotNet10.ScriptEditor.Helpers;
|
||||
|
||||
public static class SignatureHelpExtensions
|
||||
{
|
||||
public static async Task<SignatureHelpResult?> GetSignatureHelpAsync(this Document document, int line, int column)
|
||||
{
|
||||
var invocation = await GetInvocation(document, line, column);
|
||||
if (invocation is null) return null;
|
||||
|
||||
var response = new SignatureHelp();
|
||||
foreach (var comma in invocation.Separators)
|
||||
{
|
||||
if (comma.Span.Start > invocation.Position)
|
||||
{
|
||||
break;
|
||||
}
|
||||
response.ActiveParameter += 1;
|
||||
}
|
||||
|
||||
var signaturesSet = new HashSet<SignatureInformation>();
|
||||
var bestScore = int.MinValue;
|
||||
SignatureInformation? bestScoredItem = null;
|
||||
|
||||
var types = invocation.ArgumentTypes;
|
||||
ISymbol? throughSymbol = null;
|
||||
ISymbol? throughType = null;
|
||||
var methodGroup = invocation.SemanticModel.GetMemberGroup(invocation.Receiver).OfType<IMethodSymbol>();
|
||||
if (invocation.Receiver is MemberAccessExpressionSyntax syntax)
|
||||
{
|
||||
var throughExpression = syntax.Expression;
|
||||
throughSymbol = invocation.SemanticModel.GetSpeculativeSymbolInfo(invocation.Position, throughExpression, SpeculativeBindingOption.BindAsExpression).Symbol;
|
||||
throughType = invocation.SemanticModel.GetSpeculativeTypeInfo(invocation.Position, throughExpression, SpeculativeBindingOption.BindAsTypeOrNamespace).Type;
|
||||
var includeInstance = throughSymbol != null && throughSymbol is not ITypeSymbol ||
|
||||
throughExpression is LiteralExpressionSyntax ||
|
||||
throughExpression is TypeOfExpressionSyntax;
|
||||
var includeStatic = throughSymbol is INamedTypeSymbol || throughType != null;
|
||||
methodGroup = methodGroup.Where(m => m.IsStatic && includeStatic || !m.IsStatic && includeInstance);
|
||||
}
|
||||
else if (invocation.Receiver is SimpleNameSyntax && invocation.IsInStaticContext)
|
||||
{
|
||||
methodGroup = methodGroup.Where(m => m.IsStatic || m.MethodKind == MethodKind.LocalFunction);
|
||||
}
|
||||
|
||||
foreach (var methodOverload in methodGroup)
|
||||
{
|
||||
var signature = BuildSignature(methodOverload);
|
||||
signaturesSet.Add(signature);
|
||||
|
||||
var score = InvocationScore(methodOverload, types);
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
bestScoredItem = signature;
|
||||
}
|
||||
}
|
||||
|
||||
var signaturesList = signaturesSet.ToList();
|
||||
response.Signatures = [.. signaturesList];
|
||||
if (bestScoredItem == null)
|
||||
{
|
||||
response.ActiveSignature = -1;
|
||||
}
|
||||
else
|
||||
{
|
||||
response.ActiveSignature = signaturesList.IndexOf((SignatureInformation)bestScoredItem);
|
||||
}
|
||||
|
||||
return new SignatureHelpResult()
|
||||
{
|
||||
Value = response,
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<InvocationContext?> GetInvocation(Document document, int line, int column)
|
||||
{
|
||||
var sourceText = await document.GetTextAsync();
|
||||
var position = sourceText.Lines.GetPosition(new LinePosition(line, column));
|
||||
var tree = await document.GetSyntaxTreeAsync();
|
||||
|
||||
if (tree is null) return null;
|
||||
|
||||
var root = await tree.GetRootAsync();
|
||||
if (root is null) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var node = root.FindToken(position).Parent;
|
||||
|
||||
// Walk up until we find a node that we're interested in.
|
||||
while (node != null)
|
||||
{
|
||||
if (node is InvocationExpressionSyntax invocation && invocation.ArgumentList.Span.Contains(position))
|
||||
{
|
||||
var semanticModel = await document.GetSemanticModelAsync();
|
||||
return semanticModel is null ? null : new InvocationContext(semanticModel, position, invocation.Expression, invocation.ArgumentList, invocation.IsInStaticContext());
|
||||
}
|
||||
|
||||
if (node is BaseObjectCreationExpressionSyntax objectCreation && (objectCreation.ArgumentList?.Span.Contains(position) ?? false))
|
||||
{
|
||||
var semanticModel = await document.GetSemanticModelAsync();
|
||||
return semanticModel is null ? null : new InvocationContext(semanticModel, position, objectCreation, objectCreation.ArgumentList, objectCreation.IsInStaticContext());
|
||||
}
|
||||
|
||||
if (node is AttributeSyntax attributeSyntax && (attributeSyntax.ArgumentList?.Span.Contains(position) ?? false))
|
||||
{
|
||||
var semanticModel = await document.GetSemanticModelAsync();
|
||||
return semanticModel is null ? null : new InvocationContext(semanticModel, position, attributeSyntax, attributeSyntax.ArgumentList, attributeSyntax.IsInStaticContext());
|
||||
}
|
||||
|
||||
node = node.Parent;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int InvocationScore(IMethodSymbol symbol, IEnumerable<TypeInfo> types)
|
||||
{
|
||||
var parameters = symbol.Parameters;
|
||||
if (parameters.Length < types.Count())
|
||||
{
|
||||
return int.MinValue;
|
||||
}
|
||||
|
||||
var score = 0;
|
||||
var invocationEnum = types.GetEnumerator();
|
||||
var definitionEnum = parameters.GetEnumerator();
|
||||
while (invocationEnum.MoveNext() && definitionEnum.MoveNext())
|
||||
{
|
||||
if (invocationEnum.Current.ConvertedType == null)
|
||||
{
|
||||
// 1 point for having a parameter
|
||||
score += 1;
|
||||
}
|
||||
else if (SymbolEqualityComparer.Default.Equals(invocationEnum.Current.ConvertedType, definitionEnum.Current.Type))
|
||||
{
|
||||
// 2 points for having a parameter and being
|
||||
// the same type
|
||||
score += 2;
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static SignatureInformation BuildSignature(IMethodSymbol symbol)
|
||||
{
|
||||
var StructuredDocumentation = DocumentationConverter.GetStructuredDocumentation(symbol);
|
||||
|
||||
return new SignatureInformation
|
||||
{
|
||||
Documentation = new MarkdownString()
|
||||
{
|
||||
Value = StructuredDocumentation?.SummaryText ?? "",
|
||||
},
|
||||
Label = symbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat),
|
||||
Parameters = [..symbol.Parameters.Select(parameter => new ParameterInformation()
|
||||
{
|
||||
Label = parameter.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat),
|
||||
Documentation = new MarkdownString()
|
||||
{
|
||||
Value = StructuredDocumentation?.GetParameterText(parameter.Name) ?? string.Empty,
|
||||
},
|
||||
})],
|
||||
ActiveParameter = null,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsInStaticContext(this SyntaxNode node)
|
||||
{
|
||||
// this/base calls are always static.
|
||||
if (node.FirstAncestorOrSelf<ConstructorInitializerSyntax>() != null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var memberDeclaration = node.FirstAncestorOrSelf<MemberDeclarationSyntax>();
|
||||
if (memberDeclaration == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (memberDeclaration.Kind())
|
||||
{
|
||||
case SyntaxKind.MethodDeclaration:
|
||||
case SyntaxKind.ConstructorDeclaration:
|
||||
case SyntaxKind.EventDeclaration:
|
||||
case SyntaxKind.IndexerDeclaration:
|
||||
return GetModifiers(memberDeclaration).Any(SyntaxKind.StaticKeyword);
|
||||
|
||||
case SyntaxKind.PropertyDeclaration:
|
||||
return GetModifiers(memberDeclaration).Any(SyntaxKind.StaticKeyword) ||
|
||||
node.IsFoundUnder((PropertyDeclarationSyntax p) => p.Initializer);
|
||||
|
||||
case SyntaxKind.FieldDeclaration:
|
||||
case SyntaxKind.EventFieldDeclaration:
|
||||
// Inside a field one can only access static members of a type (unless it's top-level).
|
||||
return !memberDeclaration.Parent.IsKind(SyntaxKind.CompilationUnit);
|
||||
|
||||
case SyntaxKind.DestructorDeclaration:
|
||||
return false;
|
||||
}
|
||||
|
||||
// Global statements are not a static context.
|
||||
if (node.FirstAncestorOrSelf<GlobalStatementSyntax>() != null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// any other location is considered static
|
||||
return true;
|
||||
}
|
||||
|
||||
private static SyntaxTokenList GetModifiers(SyntaxNode member)
|
||||
{
|
||||
if (member != null)
|
||||
{
|
||||
switch (member.Kind())
|
||||
{
|
||||
case SyntaxKind.EnumDeclaration:
|
||||
return ((EnumDeclarationSyntax)member).Modifiers;
|
||||
case SyntaxKind.ClassDeclaration:
|
||||
case SyntaxKind.InterfaceDeclaration:
|
||||
case SyntaxKind.StructDeclaration:
|
||||
return ((TypeDeclarationSyntax)member).Modifiers;
|
||||
case SyntaxKind.DelegateDeclaration:
|
||||
return ((DelegateDeclarationSyntax)member).Modifiers;
|
||||
case SyntaxKind.FieldDeclaration:
|
||||
return ((FieldDeclarationSyntax)member).Modifiers;
|
||||
case SyntaxKind.EventFieldDeclaration:
|
||||
return ((EventFieldDeclarationSyntax)member).Modifiers;
|
||||
case SyntaxKind.ConstructorDeclaration:
|
||||
return ((ConstructorDeclarationSyntax)member).Modifiers;
|
||||
case SyntaxKind.DestructorDeclaration:
|
||||
return ((DestructorDeclarationSyntax)member).Modifiers;
|
||||
case SyntaxKind.PropertyDeclaration:
|
||||
return ((PropertyDeclarationSyntax)member).Modifiers;
|
||||
case SyntaxKind.EventDeclaration:
|
||||
return ((EventDeclarationSyntax)member).Modifiers;
|
||||
case SyntaxKind.IndexerDeclaration:
|
||||
return ((IndexerDeclarationSyntax)member).Modifiers;
|
||||
case SyntaxKind.OperatorDeclaration:
|
||||
return ((OperatorDeclarationSyntax)member).Modifiers;
|
||||
case SyntaxKind.ConversionOperatorDeclaration:
|
||||
return ((ConversionOperatorDeclarationSyntax)member).Modifiers;
|
||||
case SyntaxKind.MethodDeclaration:
|
||||
return ((MethodDeclarationSyntax)member).Modifiers;
|
||||
case SyntaxKind.GetAccessorDeclaration:
|
||||
case SyntaxKind.SetAccessorDeclaration:
|
||||
case SyntaxKind.AddAccessorDeclaration:
|
||||
case SyntaxKind.RemoveAccessorDeclaration:
|
||||
return ((AccessorDeclarationSyntax)member).Modifiers;
|
||||
}
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
private static bool IsFoundUnder<TParent>(this SyntaxNode node, Func<TParent, SyntaxNode?> childGetter)
|
||||
where TParent : SyntaxNode
|
||||
{
|
||||
var ancestor = node.GetAncestor<TParent>();
|
||||
if (ancestor == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var child = childGetter(ancestor);
|
||||
|
||||
// See if node passes through child on the way up to ancestor.
|
||||
return node.GetAncestorsOrThis<SyntaxNode>().Contains(child);
|
||||
}
|
||||
|
||||
private static TNode? GetAncestor<TNode>(this SyntaxNode node)
|
||||
where TNode : SyntaxNode
|
||||
{
|
||||
var current = node.Parent;
|
||||
while (current != null)
|
||||
{
|
||||
if (current is TNode tNode)
|
||||
{
|
||||
return tNode;
|
||||
}
|
||||
|
||||
current = current.GetParent();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<TNode> GetAncestorsOrThis<TNode>(this SyntaxNode node)
|
||||
where TNode : SyntaxNode
|
||||
{
|
||||
var current = node;
|
||||
while (current != null)
|
||||
{
|
||||
if (current is TNode tNode)
|
||||
{
|
||||
yield return tNode;
|
||||
}
|
||||
|
||||
current = current.GetParent();
|
||||
}
|
||||
}
|
||||
|
||||
private static SyntaxNode? GetParent(this SyntaxNode node)
|
||||
{
|
||||
return node is IStructuredTriviaSyntax trivia ? trivia.ParentTrivia.Token.Parent : node.Parent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace RobotNet10.ScriptEditor.Helpers.Webcil;
|
||||
|
||||
internal static class Constants
|
||||
{
|
||||
internal const ushort IMAGE_FILE_MACHINE_I386 = 0x014c;
|
||||
internal const ushort IMAGE_FILE_MACHINE_IA64 = 0x0200;
|
||||
internal const ushort IMAGE_FILE_MACHINE_AMD64 = 0x8664;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_data_directory
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
internal struct IMAGE_DATA_DIRECTORY
|
||||
{
|
||||
public uint VirtualAddress; // DWORD VirtualAddress
|
||||
public uint Size; // DWORD Size
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// https://www.nirsoft.net/kernel_struct/vista/IMAGE_DOS_HEADER.html
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
internal struct IMAGE_DOS_HEADER
|
||||
{
|
||||
public ushort MagicNumber; // e_magic - Magic number (The value “MZ” are the initials of the PE designer Mark Zbikowski)
|
||||
public ushort BytesOnLastPageOfFile; // e_cblp - Bytes on last page of file
|
||||
public ushort PagesInFile; // e_cp - Pages in file
|
||||
public ushort Relocations; // e_crlc - Relocations
|
||||
public ushort SizeOfHeaderInParagraphs; // e_cparhdr - Size of header in paragraphs
|
||||
public ushort MinimumExtraParagraphs; // e_minalloc - Minimum extra paragraphs needed
|
||||
public ushort MaximumExtraParagraphs; // e_maxalloc - Maximum extra paragraphs needed
|
||||
public ushort InitialSS; // e_ss - Initial (relative) SS value
|
||||
public ushort InitialSP; // e_sp - Initial SP value
|
||||
public ushort Checksum; // e_csum - Checksum
|
||||
public ushort InitialIP; // e_ip - Initial IP value
|
||||
public ushort InitialCS; // e_cs - Initial (relative) CS value
|
||||
public ushort AddressOfRelocationTable; // e_lfarlc - File address of relocation table
|
||||
public ushort OverlayNumber; // e_ovno - Overlay number
|
||||
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)]
|
||||
public ushort[] ReservedWords1; // e_res - Reserved words
|
||||
|
||||
public ushort OEMIdentifier; // e_oemid - OEM identifier (for e_oeminfo)
|
||||
public ushort OEMInformation; // e_oeminfo - OEM information; e_oemid specific
|
||||
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)]
|
||||
public ushort[] ReservedWords2; // e_res2 - Reserved words
|
||||
|
||||
public int FileAddressOfNewExeHeader; // e_lfanew - File address of new exe header
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_file_header
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
internal struct IMAGE_FILE_HEADER
|
||||
{
|
||||
/// <summary>
|
||||
/// The architecture type of the computer.
|
||||
/// An image file can only be run on the specified computer or a system that emulates the specified computer.
|
||||
/// </summary>
|
||||
public ushort Machine;
|
||||
|
||||
/// <summary>
|
||||
/// The number of sections.
|
||||
/// This indicates the size of the section table, which immediately follows the headers.
|
||||
/// Note that the Windows loader limits the number of sections to 96.
|
||||
/// </summary>
|
||||
public ushort NumberOfSections;
|
||||
|
||||
/// <summary>
|
||||
/// The low 32 bits of the time stamp of the image.
|
||||
/// This represents the date and time the image was created by the linker.
|
||||
/// The value is represented in the number of seconds elapsed since midnight (00:00:00), January 1, 1970, Universal Coordinated Time, according to the system clock.
|
||||
/// </summary>
|
||||
public uint TimeDateStamp;
|
||||
|
||||
public uint PointerToSymbolTable;
|
||||
|
||||
public uint NumberOfSymbols;
|
||||
|
||||
public ushort SizeOfOptionalHeader;
|
||||
|
||||
public ushort Characteristics;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_nt_headers32
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
internal struct IMAGE_NT_HEADERS32
|
||||
{
|
||||
public uint Signature; // DWORD Signature
|
||||
public IMAGE_FILE_HEADER FileHeader; // IMAGE_FILE_HEADER FileHeader
|
||||
public IMAGE_OPTIONAL_HEADER32 OptionalHeader; // IMAGE_OPTIONAL_HEADER32 OptionalHeader
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_nt_headers64
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
internal struct IMAGE_NT_HEADERS64
|
||||
{
|
||||
public uint Signature; // DWORD Signature
|
||||
public IMAGE_FILE_HEADER FileHeader; // IMAGE_FILE_HEADER FileHeader
|
||||
public IMAGE_OPTIONAL_HEADER64 OptionalHeader; // IMAGE_OPTIONAL_HEADER32 OptionalHeader
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_optional_header32
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
internal struct IMAGE_OPTIONAL_HEADER32
|
||||
{
|
||||
public ushort Magic;
|
||||
public byte MajorLinkerVersion;
|
||||
public byte MinorLinkerVersion;
|
||||
public uint SizeOfCode;
|
||||
public uint SizeOfInitializedData;
|
||||
public uint SizeOfUninitializedData;
|
||||
|
||||
/// <summary>
|
||||
/// A pointer to the entry point function, relative to the image base address.
|
||||
/// For executable files, this is the starting address.
|
||||
/// For device drivers, this is the address of the initialization function.
|
||||
/// The entry point function is optional for DLLs.
|
||||
/// When no entry point is present, this member is zero.
|
||||
/// </summary>
|
||||
public uint AddressOfEntryPoint;
|
||||
|
||||
/// <summary>
|
||||
/// A pointer to the beginning of the code section, relative to the image base.
|
||||
/// </summary>
|
||||
public uint BaseOfCode;
|
||||
|
||||
/// <summary>
|
||||
/// A pointer to the beginning of the data section, relative to the image base.
|
||||
/// </summary>
|
||||
public uint BaseOfData;
|
||||
|
||||
/// <summary>
|
||||
/// The preferred address of the first byte of the image when it is loaded in memory.
|
||||
/// This value is a multiple of 64K bytes.
|
||||
/// The default value for DLLs is 0x10000000.
|
||||
/// The default value for applications is 0x00400000, except on Windows CE where it is 0x00010000.
|
||||
/// </summary>
|
||||
public uint ImageBase;
|
||||
|
||||
public uint SectionAlignment;
|
||||
|
||||
public uint FileAlignment;
|
||||
|
||||
public ushort MajorOperatingSystemVersion;
|
||||
public ushort MinorOperatingSystemVersion;
|
||||
public ushort MajorImageVersion;
|
||||
public ushort MinorImageVersion;
|
||||
public ushort MajorSubsystemVersion;
|
||||
public ushort MinorSubsystemVersion;
|
||||
public uint Win32VersionValue;
|
||||
|
||||
/// <summary>
|
||||
/// The size of the image, in bytes, including all headers. Must be a multiple of SectionAlignment.
|
||||
/// </summary>
|
||||
public uint SizeOfImage;
|
||||
|
||||
/// <summary>
|
||||
/// The combined size of the following items, rounded to a multiple of the value specified in the FileAlignment member.
|
||||
/// - e_lfanew member of IMAGE_DOS_HEADER
|
||||
/// - 4 byte signature
|
||||
/// - size of IMAGE_FILE_HEADER
|
||||
/// - size of optional header
|
||||
/// - size of all section headers
|
||||
/// </summary>
|
||||
public uint SizeOfHeaders;
|
||||
public uint CheckSum;
|
||||
public ushort Subsystem;
|
||||
public ushort DllCharacteristics;
|
||||
public uint SizeOfStackReserve;
|
||||
public uint SizeOfStackCommit;
|
||||
public uint SizeOfHeapReserve;
|
||||
public uint SizeOfHeapCommit;
|
||||
public uint LoaderFlags;
|
||||
|
||||
/// <summary>
|
||||
/// The number of directory entries in the remainder of the optional header. Each entry describes a location and size.
|
||||
/// </summary>
|
||||
public uint NumberOfRvaAndSizes;
|
||||
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 0x10)]
|
||||
public IMAGE_DATA_DIRECTORY[] DataDirectory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_optional_header64
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
internal struct IMAGE_OPTIONAL_HEADER64
|
||||
{
|
||||
public ushort Magic;
|
||||
public byte MajorLinkerVersion;
|
||||
public byte MinorLinkerVersion;
|
||||
public uint SizeOfCode;
|
||||
public uint SizeOfInitializedData;
|
||||
public uint SizeOfUninitializedData;
|
||||
public uint AddressOfEntryPoint;
|
||||
public uint BaseOfCode;
|
||||
public ulong ImageBase;
|
||||
public uint SectionAlignment;
|
||||
public uint FileAlignment;
|
||||
public ushort MajorOperatingSystemVersion;
|
||||
public ushort MinorOperatingSystemVersion;
|
||||
public ushort MajorImageVersion;
|
||||
public ushort MinorImageVersion;
|
||||
public ushort MajorSubsystemVersion;
|
||||
public ushort MinorSubsystemVersion;
|
||||
public uint Win32VersionValue;
|
||||
public uint SizeOfImage;
|
||||
public uint SizeOfHeaders;
|
||||
public uint CheckSum;
|
||||
public ushort Subsystem;
|
||||
public ushort DllCharacteristics;
|
||||
public ulong SizeOfStackReserve;
|
||||
public ulong SizeOfStackCommit;
|
||||
public ulong SizeOfHeapReserve;
|
||||
public ulong SizeOfHeapCommit;
|
||||
public uint LoaderFlags;
|
||||
public uint NumberOfRvaAndSizes;
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 0x10)]
|
||||
public IMAGE_DATA_DIRECTORY[] DataDirectory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_section_header
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
internal struct IMAGE_SECTION_HEADER
|
||||
{
|
||||
/// <summary>
|
||||
/// An 8-byte, null-padded UTF-8 string.
|
||||
/// There is no terminating null character if the string is exactly eight characters long.
|
||||
/// For longer names, this member contains a forward slash (/) followed by an ASCII representation of a double number that is an offset into the string table.
|
||||
/// Executable images do not use a string table and do not support section names longer than eight characters.
|
||||
/// </summary>
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 8)]
|
||||
public byte[] Name;
|
||||
|
||||
public UnionType Misc;
|
||||
|
||||
/// <summary>
|
||||
/// The address of the first byte of the section when loaded into memory, relative to the image base.
|
||||
/// For object files, this is the address of the first byte before relocation is applied.
|
||||
/// </summary>
|
||||
public uint VirtualAddress;
|
||||
|
||||
/// <summary>
|
||||
/// The size of the initialized data on disk, in bytes.
|
||||
/// This value must be a multiple of the FileAlignment member of the IMAGE_OPTIONAL_HEADER structure.
|
||||
/// If this value is less than the VirtualSize member, the remainder of the section is filled with zeroes.
|
||||
/// If the section contains only uninitialized data, the member is zero.
|
||||
/// </summary>
|
||||
public uint SizeOfRawData;
|
||||
|
||||
/// <summary>
|
||||
/// A file pointer to the first page within the COFF file.
|
||||
/// This value must be a multiple of the FileAlignment member of the IMAGE_OPTIONAL_HEADER structure.
|
||||
/// If a section contains only uninitialized data, set this member is zero.
|
||||
/// </summary>
|
||||
public uint PointerToRawData;
|
||||
|
||||
public uint PointerToRelocations;
|
||||
|
||||
public uint PointerToLinenumbers;
|
||||
|
||||
public ushort NumberOfRelocations;
|
||||
|
||||
public ushort NumberOfLinenumbers;
|
||||
|
||||
public uint Characteristics;
|
||||
|
||||
[StructLayout(LayoutKind.Explicit)]
|
||||
public struct UnionType
|
||||
{
|
||||
[FieldOffset(0)]
|
||||
public uint PhysicalAddress;
|
||||
|
||||
/// <summary>
|
||||
/// The total size of the section when loaded into memory, in bytes. If this value is greater than the SizeOfRawData member, the section is filled with zeroes.
|
||||
/// This field is valid only for executable images and should be set to 0 for object files.
|
||||
/// </summary>
|
||||
[FieldOffset(0)]
|
||||
public uint VirtualSize;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace RobotNet10.ScriptEditor.Helpers.Webcil;
|
||||
|
||||
internal static class StreamExtensions
|
||||
{
|
||||
internal static void WriteStruct<T>(this Stream stream, T structData) where T : struct
|
||||
{
|
||||
var bytes = StructToBytes(structData);
|
||||
stream.Write(bytes);
|
||||
}
|
||||
|
||||
private static byte[] StructToBytes<T>(T structData) where T : struct
|
||||
{
|
||||
int size = Marshal.SizeOf(structData);
|
||||
byte[] byteArray = new byte[size];
|
||||
nint ptr = Marshal.AllocHGlobal(size);
|
||||
|
||||
try
|
||||
{
|
||||
Marshal.StructureToPtr(structData, ptr, false);
|
||||
Marshal.Copy(ptr, byteArray, 0, size);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(ptr);
|
||||
}
|
||||
|
||||
return byteArray;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
namespace RobotNet10.ScriptEditor.Helpers.Webcil;
|
||||
|
||||
internal class WasmWebcilUnwrapper : IAsyncDisposable
|
||||
{
|
||||
private readonly Stream _wasmStream;
|
||||
private MemoryStream? _cachedStream;
|
||||
|
||||
public WasmWebcilUnwrapper(Stream wasmStream)
|
||||
{
|
||||
_wasmStream = wasmStream;
|
||||
}
|
||||
|
||||
public async Task WriteUnwrappedAsync(Stream outputStream)
|
||||
{
|
||||
// Cache the stream content to MemoryStream for synchronous BinaryReader operations
|
||||
if (_cachedStream == null)
|
||||
{
|
||||
_cachedStream = new MemoryStream();
|
||||
await _wasmStream.CopyToAsync(_cachedStream);
|
||||
_cachedStream.Position = 0; // Reset to beginning for validation
|
||||
}
|
||||
|
||||
// Validate prefix from cached stream
|
||||
ValidateWasmPrefix(_cachedStream);
|
||||
|
||||
// Skip prefix and read data section
|
||||
var prefix = WasmWebcilWrapper.GetPrefix();
|
||||
_cachedStream.Position = prefix.Length;
|
||||
|
||||
using var reader = new BinaryReader(_cachedStream, System.Text.Encoding.UTF8, leaveOpen: true);
|
||||
var bytes = ReadDataSection(reader);
|
||||
await outputStream.WriteAsync(bytes);
|
||||
}
|
||||
|
||||
private void ValidateWasmPrefix(Stream stream)
|
||||
{
|
||||
var originalPosition = stream.Position;
|
||||
try
|
||||
{
|
||||
// Create a byte array matching the length of the prefix.
|
||||
var prefix = WasmWebcilWrapper.GetPrefix();
|
||||
var buffer = new byte[prefix.Length];
|
||||
stream.Position = 0;
|
||||
int bytesRead = stream.Read(buffer, 0, buffer.Length);
|
||||
if (bytesRead < buffer.Length)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to read Wasm prefix.");
|
||||
}
|
||||
|
||||
// Compare the read prefix with the expected one.
|
||||
if (!buffer.SequenceEqual(prefix))
|
||||
{
|
||||
throw new InvalidOperationException("Invalid Wasm prefix.");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
stream.Position = originalPosition;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SkipSection(BinaryReader reader)
|
||||
{
|
||||
var size = ULEB128Decode(reader);
|
||||
reader.BaseStream.Seek(size, SeekOrigin.Current);
|
||||
}
|
||||
|
||||
private static byte[] ReadDataSection(BinaryReader reader)
|
||||
{
|
||||
// Skip until we find the data section, which contains the Webcil payload.
|
||||
byte[] buffer = new byte[1];
|
||||
while (true)
|
||||
{
|
||||
// Read the Data section
|
||||
var dataRead = reader.Read(buffer, 0, 1);
|
||||
if (dataRead == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to read Data Section.");
|
||||
}
|
||||
|
||||
// Check the Data section (ID = 11)
|
||||
if (buffer[0] == 11)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Skip other sections by reading and ignoring their content.
|
||||
SkipSection(reader);
|
||||
}
|
||||
|
||||
// Read and ignore the size of the data section.
|
||||
ULEB128Decode(reader);
|
||||
|
||||
// Read the number of segments.
|
||||
int segmentsCount = (int)ULEB128Decode(reader);
|
||||
int lastSegment = segmentsCount - 1;
|
||||
for (int segmentIndex = 0; segmentIndex < segmentsCount; segmentIndex++)
|
||||
{
|
||||
// Ignore segmentType (1 = passive segment)
|
||||
var segmentType = reader.Read(buffer, 0, 1);
|
||||
if (segmentType != 1)
|
||||
{
|
||||
throw new InvalidOperationException($"Unexpected segment code for segment {segmentIndex}.");
|
||||
}
|
||||
|
||||
// Read the segment size.
|
||||
var segmentSize = ULEB128Decode(reader);
|
||||
|
||||
// The actual Webcil payload is expected to be in the last segment.
|
||||
if (segmentIndex == lastSegment)
|
||||
{
|
||||
return reader.ReadBytes((int)segmentSize);
|
||||
}
|
||||
|
||||
// Skip other segments.
|
||||
reader.BaseStream.Seek(segmentSize, SeekOrigin.Current);
|
||||
}
|
||||
|
||||
throw new Exception("Unable to read DataSection.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a variable-length quantity (VLQ) encoded as unsigned LEB128.
|
||||
/// LEB128 (Little Endian Base 128) is used to encode integers in a variable number of bytes.
|
||||
/// The method reads bytes from the provided binary reader and decodes them into an unsigned integer.
|
||||
/// </summary>
|
||||
/// <param name="reader">The binary reader from which to read the ULEB128 encoded data.</param>
|
||||
/// <returns>The decoded unsigned integer from the ULEB128 encoded data.</returns>
|
||||
private static uint ULEB128Decode(BinaryReader reader)
|
||||
{
|
||||
uint result = 0;
|
||||
int shift = 0;
|
||||
byte byteValue;
|
||||
|
||||
do
|
||||
{
|
||||
byteValue = reader.ReadByte();
|
||||
uint byteAsUInt = byteValue & 0x7Fu;
|
||||
result |= byteAsUInt << shift;
|
||||
shift += 7;
|
||||
} while ((byteValue & 0x80) != 0);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_cachedStream != null)
|
||||
{
|
||||
await _cachedStream.DisposeAsync();
|
||||
}
|
||||
await _wasmStream.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace RobotNet10.ScriptEditor.Helpers.Webcil;
|
||||
|
||||
public class WasmWebcilWrapper
|
||||
{
|
||||
private static readonly FieldInfo FieldInfoPrefix = typeof(WebcilWasmWrapper).GetField("s_wasmWrapperPrefix", BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.GetField)!;
|
||||
|
||||
public static byte[] GetPrefix()
|
||||
{
|
||||
#if NET7_0_OR_GREATER
|
||||
return GetPrefixValue<ReadOnlyMemory<byte>>().ToArray();
|
||||
#else
|
||||
return GetPrefixValue<byte[]>();
|
||||
#endif
|
||||
}
|
||||
|
||||
private static T GetPrefixValue<T>()
|
||||
{
|
||||
return (T)FieldInfoPrefix.GetValue(null)!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace RobotNet10.ScriptEditor.Helpers.Webcil;
|
||||
|
||||
public class Webcil
|
||||
{
|
||||
/// <summary>
|
||||
/// The header of a WebCIL file.
|
||||
/// </summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// The header is a subset of the PE, COFF and CLI headers that are needed by the mono runtime to load managed assemblies.
|
||||
/// </remarks>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public unsafe struct WebcilHeader
|
||||
{
|
||||
public fixed byte id[4]; // 'W' 'b' 'I' 'L'
|
||||
// 4 bytes
|
||||
public ushort version_major; // 0
|
||||
public ushort version_minor; // 0
|
||||
// 8 bytes
|
||||
|
||||
public ushort coff_sections;
|
||||
public ushort reserved0; // 0
|
||||
// 12 bytes
|
||||
public uint pe_cli_header_rva;
|
||||
public uint pe_cli_header_size;
|
||||
// 20 bytes
|
||||
public uint pe_debug_rva;
|
||||
public uint pe_debug_size;
|
||||
// 28 bytes
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is the Webcil analog of System.Reflection.PortableExecutable.SectionHeader, but with fewer fields
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public readonly struct WebcilSectionHeader
|
||||
{
|
||||
public readonly int VirtualSize;
|
||||
public readonly int VirtualAddress;
|
||||
public readonly int SizeOfRawData;
|
||||
public readonly int PointerToRawData;
|
||||
|
||||
public WebcilSectionHeader(int virtualSize, int virtualAddress, int sizeOfRawData, int pointerToRawData)
|
||||
{
|
||||
VirtualSize = virtualSize;
|
||||
VirtualAddress = virtualAddress;
|
||||
SizeOfRawData = sizeOfRawData;
|
||||
PointerToRawData = pointerToRawData;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace RobotNet10.ScriptEditor.Helpers.Webcil;
|
||||
|
||||
internal static unsafe class WebcilConstants
|
||||
{
|
||||
public const int WC_VERSION_MAJOR = 0;
|
||||
public const int WC_VERSION_MINOR = 0;
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Immutable;
|
||||
using System.Reflection.PortableExecutable;
|
||||
|
||||
namespace RobotNet10.ScriptEditor.Helpers.Webcil;
|
||||
|
||||
/// <summary>
|
||||
/// Reads a .NET assembly in a normal PE COFF file and writes it out as a Webcil file
|
||||
/// </summary>
|
||||
public class WebcilConverter
|
||||
{
|
||||
|
||||
// Interesting stuff we've learned about the input PE file
|
||||
public record PEFileInfo(
|
||||
// The sections in the PE file
|
||||
ImmutableArray<SectionHeader> SectionHeaders,
|
||||
// The location of the debug directory entries
|
||||
DirectoryEntry DebugTableDirectory,
|
||||
// The file offset of the sections, following the section directory
|
||||
FilePosition SectionStart,
|
||||
// The debug directory entries
|
||||
ImmutableArray<DebugDirectoryEntry> DebugDirectoryEntries
|
||||
);
|
||||
|
||||
// Intersting stuff we know about the webcil file we're writing
|
||||
public record WCFileInfo(
|
||||
// The header of the webcil file
|
||||
Webcil.WebcilHeader Header,
|
||||
// The section directory of the webcil file
|
||||
ImmutableArray<Webcil.WebcilSectionHeader> SectionHeaders,
|
||||
// The file offset of the sections, following the section directory
|
||||
FilePosition SectionStart
|
||||
);
|
||||
|
||||
private readonly string _inputPath;
|
||||
private readonly string _outputPath;
|
||||
|
||||
private string InputPath => _inputPath;
|
||||
|
||||
public bool WrapInWebAssembly { get; set; } = true;
|
||||
|
||||
private WebcilConverter(string inputPath, string outputPath)
|
||||
{
|
||||
_inputPath = inputPath;
|
||||
_outputPath = outputPath;
|
||||
}
|
||||
|
||||
public static WebcilConverter FromPortableExecutable(string inputPath, string outputPath)
|
||||
=> new WebcilConverter(inputPath, outputPath);
|
||||
|
||||
public void ConvertToWebcil()
|
||||
{
|
||||
using var inputStream = File.Open(_inputPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
PEFileInfo peInfo;
|
||||
WCFileInfo wcInfo;
|
||||
using (var peReader = new PEReader(inputStream, PEStreamOptions.LeaveOpen))
|
||||
{
|
||||
GatherInfo(peReader, out wcInfo, out peInfo);
|
||||
}
|
||||
|
||||
using var outputStream = File.Open(_outputPath, FileMode.Create, FileAccess.Write);
|
||||
if (!WrapInWebAssembly)
|
||||
{
|
||||
WriteConversionTo(outputStream, inputStream, peInfo, wcInfo);
|
||||
}
|
||||
else
|
||||
{
|
||||
// if wrapping in WASM, write the webcil payload to memory because we need to discover the length
|
||||
|
||||
// webcil is about the same size as the PE file
|
||||
using var memoryStream = new MemoryStream(checked((int)inputStream.Length));
|
||||
WriteConversionTo(memoryStream, inputStream, peInfo, wcInfo);
|
||||
memoryStream.Flush();
|
||||
var wrapper = new WebcilWasmWrapper(memoryStream);
|
||||
memoryStream.Seek(0, SeekOrigin.Begin);
|
||||
wrapper.WriteWasmWrappedWebcil(outputStream);
|
||||
}
|
||||
}
|
||||
|
||||
public void WriteConversionTo(Stream outputStream, FileStream inputStream, PEFileInfo peInfo, WCFileInfo wcInfo)
|
||||
{
|
||||
WriteHeader(outputStream, wcInfo.Header);
|
||||
WriteSectionHeaders(outputStream, wcInfo.SectionHeaders);
|
||||
CopySections(outputStream, inputStream, peInfo.SectionHeaders);
|
||||
if (wcInfo.Header.pe_debug_size != 0 && wcInfo.Header.pe_debug_rva != 0)
|
||||
{
|
||||
var wcDebugDirectoryEntries = FixupDebugDirectoryEntries(peInfo, wcInfo);
|
||||
OverwriteDebugDirectoryEntries(outputStream, wcInfo, wcDebugDirectoryEntries);
|
||||
}
|
||||
}
|
||||
|
||||
public record struct FilePosition(int Position)
|
||||
{
|
||||
public static implicit operator FilePosition(int position) => new(position);
|
||||
|
||||
public static FilePosition operator +(FilePosition left, int right) => new(left.Position + right);
|
||||
}
|
||||
|
||||
private static unsafe int SizeOfHeader()
|
||||
{
|
||||
return sizeof(Webcil.WebcilHeader);
|
||||
}
|
||||
|
||||
public unsafe void GatherInfo(PEReader peReader, out WCFileInfo wcInfo, out PEFileInfo peInfo)
|
||||
{
|
||||
var headers = peReader.PEHeaders;
|
||||
var peHeader = headers.PEHeader!;
|
||||
var coffHeader = headers.CoffHeader!;
|
||||
var sections = headers.SectionHeaders;
|
||||
Webcil.WebcilHeader header;
|
||||
header.id[0] = (byte)'W';
|
||||
header.id[1] = (byte)'b';
|
||||
header.id[2] = (byte)'I';
|
||||
header.id[3] = (byte)'L';
|
||||
header.version_major = WebcilConstants.WC_VERSION_MAJOR;
|
||||
header.version_minor = WebcilConstants.WC_VERSION_MINOR;
|
||||
header.coff_sections = (ushort)coffHeader.NumberOfSections;
|
||||
header.reserved0 = 0;
|
||||
header.pe_cli_header_rva = (uint)peHeader.CorHeaderTableDirectory.RelativeVirtualAddress;
|
||||
header.pe_cli_header_size = (uint)peHeader.CorHeaderTableDirectory.Size;
|
||||
header.pe_debug_rva = (uint)peHeader.DebugTableDirectory.RelativeVirtualAddress;
|
||||
header.pe_debug_size = (uint)peHeader.DebugTableDirectory.Size;
|
||||
|
||||
// current logical position in the output file
|
||||
FilePosition pos = SizeOfHeader();
|
||||
// position of the current section in the output file
|
||||
// initially it's after all the section headers
|
||||
FilePosition curSectionPos = pos + sizeof(Webcil.WebcilSectionHeader) * coffHeader.NumberOfSections;
|
||||
// The first WC section is immediately after the section directory
|
||||
FilePosition firstWCSection = curSectionPos;
|
||||
|
||||
FilePosition firstPESection = 0;
|
||||
|
||||
ImmutableArray<Webcil.WebcilSectionHeader>.Builder headerBuilder = ImmutableArray.CreateBuilder<Webcil.WebcilSectionHeader>(coffHeader.NumberOfSections);
|
||||
foreach (var sectionHeader in sections)
|
||||
{
|
||||
// The first section is the one with the lowest file offset
|
||||
if (firstPESection.Position == 0)
|
||||
{
|
||||
firstPESection = sectionHeader.PointerToRawData;
|
||||
}
|
||||
else
|
||||
{
|
||||
firstPESection = Math.Min(firstPESection.Position, sectionHeader.PointerToRawData);
|
||||
}
|
||||
|
||||
var newHeader = new Webcil.WebcilSectionHeader
|
||||
(
|
||||
virtualSize: sectionHeader.VirtualSize,
|
||||
virtualAddress: sectionHeader.VirtualAddress,
|
||||
sizeOfRawData: sectionHeader.SizeOfRawData,
|
||||
pointerToRawData: curSectionPos.Position
|
||||
);
|
||||
|
||||
pos += sizeof(Webcil.WebcilSectionHeader);
|
||||
curSectionPos += sectionHeader.SizeOfRawData;
|
||||
headerBuilder.Add(newHeader);
|
||||
}
|
||||
|
||||
ImmutableArray<DebugDirectoryEntry> debugDirectoryEntries = peReader.ReadDebugDirectory();
|
||||
|
||||
peInfo = new PEFileInfo(SectionHeaders: sections,
|
||||
DebugTableDirectory: peHeader.DebugTableDirectory,
|
||||
SectionStart: firstPESection,
|
||||
DebugDirectoryEntries: debugDirectoryEntries);
|
||||
|
||||
wcInfo = new WCFileInfo(Header: header,
|
||||
SectionHeaders: headerBuilder.MoveToImmutable(),
|
||||
SectionStart: firstWCSection);
|
||||
}
|
||||
|
||||
private static void WriteHeader(Stream s, Webcil.WebcilHeader webcilHeader)
|
||||
{
|
||||
if (!BitConverter.IsLittleEndian)
|
||||
{
|
||||
webcilHeader.version_major = BinaryPrimitives.ReverseEndianness(webcilHeader.version_major);
|
||||
webcilHeader.version_minor = BinaryPrimitives.ReverseEndianness(webcilHeader.version_minor);
|
||||
webcilHeader.coff_sections = BinaryPrimitives.ReverseEndianness(webcilHeader.coff_sections);
|
||||
webcilHeader.pe_cli_header_rva = BinaryPrimitives.ReverseEndianness(webcilHeader.pe_cli_header_rva);
|
||||
webcilHeader.pe_cli_header_size = BinaryPrimitives.ReverseEndianness(webcilHeader.pe_cli_header_size);
|
||||
webcilHeader.pe_debug_rva = BinaryPrimitives.ReverseEndianness(webcilHeader.pe_debug_rva);
|
||||
webcilHeader.pe_debug_size = BinaryPrimitives.ReverseEndianness(webcilHeader.pe_debug_size);
|
||||
}
|
||||
WriteStructure(s, webcilHeader);
|
||||
}
|
||||
|
||||
private static void WriteSectionHeaders(Stream s, ImmutableArray<Webcil.WebcilSectionHeader> sectionsHeaders)
|
||||
{
|
||||
foreach (var sectionHeader in sectionsHeaders)
|
||||
{
|
||||
WriteSectionHeader(s, sectionHeader);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteSectionHeader(Stream s, Webcil.WebcilSectionHeader sectionHeader)
|
||||
{
|
||||
if (!BitConverter.IsLittleEndian)
|
||||
{
|
||||
sectionHeader = new Webcil.WebcilSectionHeader
|
||||
(
|
||||
virtualSize: BinaryPrimitives.ReverseEndianness(sectionHeader.VirtualSize),
|
||||
virtualAddress: BinaryPrimitives.ReverseEndianness(sectionHeader.VirtualAddress),
|
||||
sizeOfRawData: BinaryPrimitives.ReverseEndianness(sectionHeader.SizeOfRawData),
|
||||
pointerToRawData: BinaryPrimitives.ReverseEndianness(sectionHeader.PointerToRawData)
|
||||
);
|
||||
}
|
||||
WriteStructure(s, sectionHeader);
|
||||
}
|
||||
|
||||
#if NETCOREAPP2_1_OR_GREATER
|
||||
private static void WriteStructure<T>(Stream s, T structure)
|
||||
where T : unmanaged
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
byte* p = (byte*)&structure;
|
||||
s.Write(new ReadOnlySpan<byte>(p, sizeof(T)));
|
||||
}
|
||||
}
|
||||
#else
|
||||
private static void WriteStructure<T>(Stream s, T structure)
|
||||
where T : unmanaged
|
||||
{
|
||||
int size = Marshal.SizeOf<T>();
|
||||
byte[] buffer = new byte[size];
|
||||
IntPtr ptr = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
ptr = Marshal.AllocHGlobal(size);
|
||||
Marshal.StructureToPtr(structure, ptr, false);
|
||||
Marshal.Copy(ptr, buffer, 0, size);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(ptr);
|
||||
}
|
||||
s.Write(buffer, 0, size);
|
||||
}
|
||||
#endif
|
||||
|
||||
private static void CopySections(Stream outStream, FileStream inputStream, ImmutableArray<SectionHeader> peSections)
|
||||
{
|
||||
// endianness: ok, we're just copying from one stream to another
|
||||
foreach (var peHeader in peSections)
|
||||
{
|
||||
var buffer = new byte[peHeader.SizeOfRawData];
|
||||
inputStream.Seek(peHeader.PointerToRawData, SeekOrigin.Begin);
|
||||
ReadExactly(inputStream, buffer);
|
||||
outStream.Write(buffer, 0, buffer.Length);
|
||||
}
|
||||
}
|
||||
|
||||
#if NETCOREAPP2_1_OR_GREATER && !NET6_0
|
||||
private static void ReadExactly(FileStream s, Span<byte> buffer)
|
||||
{
|
||||
s.ReadExactly(buffer);
|
||||
}
|
||||
#else
|
||||
private static void ReadExactly(FileStream s, byte[] buffer)
|
||||
{
|
||||
int offset = 0;
|
||||
while (offset < buffer.Length)
|
||||
{
|
||||
int read = s.Read(buffer, offset, buffer.Length - offset);
|
||||
if (read == 0)
|
||||
throw new EndOfStreamException();
|
||||
offset += read;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private static FilePosition GetPositionOfRelativeVirtualAddress(ImmutableArray<Webcil.WebcilSectionHeader> wcSections, uint relativeVirtualAddress)
|
||||
{
|
||||
foreach (var section in wcSections)
|
||||
{
|
||||
if (relativeVirtualAddress >= section.VirtualAddress && relativeVirtualAddress < section.VirtualAddress + section.VirtualSize)
|
||||
{
|
||||
FilePosition pos = section.PointerToRawData + ((int)relativeVirtualAddress - section.VirtualAddress);
|
||||
return pos;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("relative virtual address not in any section");
|
||||
}
|
||||
|
||||
// Given a physical file offset, return the section and the offset within the section.
|
||||
private (Webcil.WebcilSectionHeader section, int offset) GetSectionFromFileOffset(ImmutableArray<Webcil.WebcilSectionHeader> peSections, FilePosition fileOffset)
|
||||
{
|
||||
foreach (var section in peSections)
|
||||
{
|
||||
if (fileOffset.Position >= section.PointerToRawData && fileOffset.Position < section.PointerToRawData + section.SizeOfRawData)
|
||||
{
|
||||
return (section, fileOffset.Position - section.PointerToRawData);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"file offset not in any section (Webcil) for {InputPath}");
|
||||
}
|
||||
|
||||
private void GetSectionFromFileOffset(ImmutableArray<SectionHeader> sections, FilePosition fileOffset)
|
||||
{
|
||||
foreach (var section in sections)
|
||||
{
|
||||
if (fileOffset.Position >= section.PointerToRawData && fileOffset.Position < section.PointerToRawData + section.SizeOfRawData)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"file offset {fileOffset.Position} not in any section (PE) for {InputPath}");
|
||||
}
|
||||
|
||||
// Make a new set of debug directory entries that
|
||||
// have their data pointers adjusted to be relative to the start of the webcil file.
|
||||
// This is necessary because the debug directory entires in the PE file are relative to the start of the PE file,
|
||||
// and a PE header is bigger than a webcil header.
|
||||
private ImmutableArray<DebugDirectoryEntry> FixupDebugDirectoryEntries(PEFileInfo peInfo, WCFileInfo wcInfo)
|
||||
{
|
||||
int dataPointerAdjustment = peInfo.SectionStart.Position - wcInfo.SectionStart.Position;
|
||||
ImmutableArray<DebugDirectoryEntry> entries = peInfo.DebugDirectoryEntries;
|
||||
ImmutableArray<DebugDirectoryEntry>.Builder newEntries = ImmutableArray.CreateBuilder<DebugDirectoryEntry>(entries.Length);
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
DebugDirectoryEntry newEntry;
|
||||
if (entry.Type == DebugDirectoryEntryType.Reproducible || entry.DataPointer == 0 || entry.DataSize == 0)
|
||||
{
|
||||
// this entry doesn't have an associated data pointer, so just copy it
|
||||
newEntry = entry;
|
||||
}
|
||||
else
|
||||
{
|
||||
// the "DataPointer" field is a file offset in the PE file, adjust the entry wit the corresponding offset in the Webcil file
|
||||
var newDataPointer = entry.DataPointer - dataPointerAdjustment;
|
||||
newEntry = new DebugDirectoryEntry(entry.Stamp, entry.MajorVersion, entry.MinorVersion, entry.Type, entry.DataSize, entry.DataRelativeVirtualAddress, newDataPointer);
|
||||
GetSectionFromFileOffset(peInfo.SectionHeaders, entry.DataPointer);
|
||||
// validate that the new entry is in some section
|
||||
GetSectionFromFileOffset(wcInfo.SectionHeaders, newDataPointer);
|
||||
}
|
||||
newEntries.Add(newEntry);
|
||||
}
|
||||
return newEntries.MoveToImmutable();
|
||||
}
|
||||
|
||||
private static void OverwriteDebugDirectoryEntries(Stream s, WCFileInfo wcInfo, ImmutableArray<DebugDirectoryEntry> entries)
|
||||
{
|
||||
FilePosition debugDirectoryPos = GetPositionOfRelativeVirtualAddress(wcInfo.SectionHeaders, wcInfo.Header.pe_debug_rva);
|
||||
using var writer = new BinaryWriter(s, System.Text.Encoding.UTF8, leaveOpen: true);
|
||||
writer.Seek(debugDirectoryPos.Position, SeekOrigin.Begin);
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
WriteDebugDirectoryEntry(writer, entry);
|
||||
}
|
||||
// TODO check that we overwrite with the same size as the original
|
||||
|
||||
// restore the stream position
|
||||
writer.Seek(0, SeekOrigin.End);
|
||||
}
|
||||
|
||||
private static void WriteDebugDirectoryEntry(BinaryWriter writer, DebugDirectoryEntry entry)
|
||||
{
|
||||
writer.Write((uint)0); // Characteristics
|
||||
writer.Write(entry.Stamp);
|
||||
writer.Write(entry.MajorVersion);
|
||||
writer.Write(entry.MinorVersion);
|
||||
writer.Write((uint)entry.Type);
|
||||
writer.Write(entry.DataSize);
|
||||
writer.Write(entry.DataRelativeVirtualAddress);
|
||||
writer.Write(entry.DataPointer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Immutable;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.CodeAnalysis;
|
||||
|
||||
namespace RobotNet10.ScriptEditor.Helpers.Webcil;
|
||||
|
||||
internal static class WebcilConverterUtil
|
||||
{
|
||||
private static readonly byte[] SectionHeaderText = { 0x2E, 0x74, 0x65, 0x78, 0x74, 0x00, 0x00, 0x00 }; // .text
|
||||
private static readonly byte[] SectionHeaderRsRc = { 0x2E, 0x72, 0x73, 0x72, 0x63, 0x00, 0x00, 0x00 }; // .rsrc
|
||||
private static readonly byte[] SectionHeaderReloc = { 0x2E, 0x72, 0x65, 0x6C, 0x6F, 0x63, 0x00, 0x00 }; // .reloc
|
||||
private static readonly byte[] MSDOS =
|
||||
{
|
||||
0x0E, 0x1F, 0xBA, 0x0E, 0x00, 0xB4, 0x09, 0xCD, 0x21, 0xB8, 0x01, 0x4C, 0xCD, 0x21, 0x54, 0x68,
|
||||
0x69, 0x73, 0x20, 0x70, 0x72, 0x6F, 0x67, 0x72, 0x61, 0x6D, 0x20, 0x63, 0x61, 0x6E, 0x6E, 0x6F,
|
||||
0x74, 0x20, 0x62, 0x65, 0x20, 0x72, 0x75, 0x6E, 0x20, 0x69, 0x6E, 0x20, 0x44, 0x4F, 0x53, 0x20,
|
||||
0x6D, 0x6F, 0x64, 0x65, 0x2E, 0x0D, 0x0D, 0x0A, 0x24, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
|
||||
};
|
||||
private static readonly ushort[] DOSReservedWords1 = { 0, 0, 0, 0 };
|
||||
private static readonly ushort[] DOSReservedWords2 = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 };
|
||||
private static readonly DateTime Epoch = new(1970, 1, 1);
|
||||
private static readonly int SizeofDOSHeader = Marshal.SizeOf<IMAGE_DOS_HEADER>(); // 64
|
||||
private static readonly int SizeofFileHeader = Marshal.SizeOf<IMAGE_FILE_HEADER>();
|
||||
private static readonly int SizeofMSDOS = MSDOS.Length; // 64
|
||||
private static readonly int SizeofNTHeaders = Marshal.SizeOf<IMAGE_NT_HEADERS32>(); // 248
|
||||
private static readonly int SizeofOptionalHeader = Marshal.SizeOf<IMAGE_OPTIONAL_HEADER32>();
|
||||
private static readonly int SizeofSectionHeader = Marshal.SizeOf<IMAGE_SECTION_HEADER>(); // 40
|
||||
|
||||
private const uint FileAlignment = 0x0200;
|
||||
private const uint SectionAlignment = 0x2000;
|
||||
|
||||
/// <summary>
|
||||
/// Convert a Portable Executable file into a Webcil file.
|
||||
/// </summary>
|
||||
/// <param name="inputPath">The input path for the PE file.</param>
|
||||
/// <param name="outputPath">The output path for the Webcil file.</param>
|
||||
/// <param name="wrapInWebAssembly">The Webcil should be wrapped in Wasm [default value is <c>true</c>].</param>
|
||||
public static void ConvertToWebcil(string inputPath, string outputPath, bool wrapInWebAssembly = true)
|
||||
{
|
||||
var webcilConverter = WebcilConverter.FromPortableExecutable(inputPath, outputPath);
|
||||
webcilConverter.WrapInWebAssembly = wrapInWebAssembly;
|
||||
|
||||
webcilConverter.ConvertToWebcil();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a Webcil stream into a Portable Executable which can be used to create a valid <see cref="MetadataReference"/>.
|
||||
/// </summary>
|
||||
/// <param name="inputStream">The input sStream.</param>
|
||||
/// <param name="wrappedInWebAssembly">The Webcil is wrapped in Wasm [default value is <c>true</c>].</param>
|
||||
/// <returns>A byte[] Portable Executable</returns>
|
||||
public static async Task<byte[]> ConvertFromWebcilAsync(Stream inputStream, bool wrappedInWebAssembly = true)
|
||||
{
|
||||
Stream webcilStream;
|
||||
if (wrappedInWebAssembly)
|
||||
{
|
||||
await using var unwrapper = new WasmWebcilUnwrapper(inputStream);
|
||||
webcilStream = new MemoryStream();
|
||||
await unwrapper.WriteUnwrappedAsync(webcilStream);
|
||||
|
||||
webcilStream.Flush();
|
||||
webcilStream.Seek(0, SeekOrigin.Begin);
|
||||
}
|
||||
else
|
||||
{
|
||||
webcilStream = inputStream;
|
||||
}
|
||||
|
||||
// These are Webcil variables
|
||||
var webcilHeader = ReadHeader(webcilStream);
|
||||
var webcilSectionHeaders = ReadSectionHeaders(webcilStream, webcilHeader.coff_sections);
|
||||
var webcilSectionHeadersCount = webcilSectionHeaders.Length;
|
||||
var webcilSectionHeadersSizeOfRawData = (uint)webcilSectionHeaders.Sum(x => x.SizeOfRawData);
|
||||
|
||||
// These are PE (Portable Executable) variables
|
||||
int sectionStart = SizeofDOSHeader + SizeofMSDOS + SizeofNTHeaders + webcilSectionHeadersCount * SizeofSectionHeader; // 496
|
||||
int sectionStartRounded = sectionStart.RoundToNearest();
|
||||
var extraBytesAfterSections = new byte[sectionStartRounded - sectionStart];
|
||||
var pointerToRawDataFirstSectionHeader = webcilSectionHeaders[0].PointerToRawData;
|
||||
var pointerToRawDataOffsetBetweenWebcilAndPE = sectionStartRounded - pointerToRawDataFirstSectionHeader;
|
||||
|
||||
using var peStream = new MemoryStream();
|
||||
|
||||
var DOSHeader = new IMAGE_DOS_HEADER
|
||||
{
|
||||
MagicNumber = 0x5A4D,
|
||||
BytesOnLastPageOfFile = 0x90,
|
||||
PagesInFile = 3,
|
||||
Relocations = 0,
|
||||
SizeOfHeaderInParagraphs = 4,
|
||||
MinimumExtraParagraphs = 0,
|
||||
MaximumExtraParagraphs = 0xFFFF,
|
||||
InitialSS = 0,
|
||||
InitialSP = 0xB8,
|
||||
Checksum = 0,
|
||||
InitialIP = 0,
|
||||
InitialCS = 0,
|
||||
AddressOfRelocationTable = 0x40,
|
||||
OverlayNumber = 0,
|
||||
ReservedWords1 = DOSReservedWords1,
|
||||
OEMIdentifier = 0,
|
||||
OEMInformation = 0,
|
||||
ReservedWords2 = DOSReservedWords2,
|
||||
FileAddressOfNewExeHeader = 0x80
|
||||
};
|
||||
peStream.WriteStruct(DOSHeader);
|
||||
|
||||
peStream.Write(MSDOS);
|
||||
|
||||
var IMAGE_NT_HEADERS32 = new IMAGE_NT_HEADERS32
|
||||
{
|
||||
Signature = 0x4550, // 'PE'
|
||||
FileHeader = new IMAGE_FILE_HEADER
|
||||
{
|
||||
Machine = Constants.IMAGE_FILE_MACHINE_I386,
|
||||
NumberOfSections = 3,
|
||||
TimeDateStamp = GetImageTimestamp(),
|
||||
PointerToSymbolTable = 0,
|
||||
NumberOfSymbols = 0,
|
||||
SizeOfOptionalHeader = 0x00E0,
|
||||
Characteristics = 0x0022
|
||||
},
|
||||
OptionalHeader = new IMAGE_OPTIONAL_HEADER32
|
||||
{
|
||||
Magic = 0x010B, // Signature/Magic - Represents PE32 for 32-bit (0x10b) and PE32+ for 64-bit (0x20B)
|
||||
MajorLinkerVersion = 0x30,
|
||||
MinorLinkerVersion = 0,
|
||||
SizeOfCode = (uint)webcilSectionHeaders[0].SizeOfRawData,
|
||||
SizeOfInitializedData = (uint)(webcilSectionHeaders[1].SizeOfRawData + webcilSectionHeaders[2].SizeOfRawData),
|
||||
SizeOfUninitializedData = 0,
|
||||
AddressOfEntryPoint = 0, // This can be set to 0
|
||||
BaseOfCode = 0x2000,
|
||||
BaseOfData = 0xA000,
|
||||
ImageBase = 0x400000, // The default value for applications is 0x00400000
|
||||
SectionAlignment = SectionAlignment,
|
||||
FileAlignment = FileAlignment,
|
||||
MajorOperatingSystemVersion = 4,
|
||||
MinorOperatingSystemVersion = 0,
|
||||
MajorImageVersion = 0,
|
||||
MinorImageVersion = 0,
|
||||
MajorSubsystemVersion = 4,
|
||||
MinorSubsystemVersion = 0,
|
||||
Win32VersionValue = 0,
|
||||
SizeOfImage = webcilSectionHeadersSizeOfRawData.RoundToNearest(SectionAlignment),
|
||||
SizeOfHeaders = GetSizeOfHeaders(DOSHeader, webcilSectionHeadersCount),
|
||||
CheckSum = 0,
|
||||
Subsystem = 3, // IMAGE_SUBSYSTEM_WINDOWS_CUI
|
||||
DllCharacteristics = 0x8560,
|
||||
SizeOfStackReserve = 0x100000,
|
||||
SizeOfStackCommit = 0x1000,
|
||||
SizeOfHeapReserve = 0x100000,
|
||||
SizeOfHeapCommit = 0x1000,
|
||||
LoaderFlags = 0,
|
||||
NumberOfRvaAndSizes = 0x10,
|
||||
DataDirectory = new IMAGE_DATA_DIRECTORY[]
|
||||
{
|
||||
new() { Size = 0x0000, VirtualAddress = 0x0000 }, // IMAGE_DIRECTORY_ENTRY_EXPORT
|
||||
new() { Size = 0x0000, VirtualAddress = 0x0000 }, // IMAGE_DIRECTORY_ENTRY_IMPORT (can be 0)
|
||||
new() { Size = (uint) webcilSectionHeaders[1].VirtualSize, VirtualAddress = (uint) webcilSectionHeaders[1].VirtualAddress }, // IMAGE_DIRECTORY_ENTRY_RESOURCE
|
||||
new() { Size = 0x0000, VirtualAddress = 0x0000 }, // IMAGE_DIRECTORY_ENTRY_EXCEPTION
|
||||
new() { Size = 0x0000, VirtualAddress = 0x0000 }, // IMAGE_DIRECTORY_ENTRY_SECURITY
|
||||
new() { Size = (uint) webcilSectionHeaders[2].VirtualSize, VirtualAddress = (uint) webcilSectionHeaders[2].VirtualAddress }, // IMAGE_DIRECTORY_ENTRY_BASERELOC
|
||||
new() { Size = 0x0000, VirtualAddress = 0x0000 }, // IMAGE_DIRECTORY_ENTRY_DEBUG (can be 0)
|
||||
new() { Size = 0x0000, VirtualAddress = 0x0000 }, // IMAGE_DIRECTORY_ENTRY_ARCHITECTURE
|
||||
new() { Size = 0x0000, VirtualAddress = 0x0000 }, // IMAGE_DIRECTORY_ENTRY_GLOBALPTR
|
||||
new() { Size = 0x0000, VirtualAddress = 0x0000 }, // IMAGE_DIRECTORY_ENTRY_TLS
|
||||
new() { Size = 0x0000, VirtualAddress = 0x0000 }, // IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG
|
||||
new() { Size = 0x0000, VirtualAddress = 0x0000 }, // IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT
|
||||
new() { Size = 0x0008, VirtualAddress = (uint) webcilSectionHeaders[0].VirtualAddress }, // IMAGE_DIRECTORY_ENTRY_IAT
|
||||
new() { Size = 0x0000, VirtualAddress = 0x0000 }, // IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT
|
||||
new() { Size = 0x0048, VirtualAddress = (uint) webcilSectionHeaders[0].VirtualAddress + 8 }, // TODO ??? IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR
|
||||
new() { Size = 0x0000, VirtualAddress = 0x0000 } // ?
|
||||
}
|
||||
}
|
||||
};
|
||||
peStream.WriteStruct(IMAGE_NT_HEADERS32);
|
||||
|
||||
var textSectionHeader = new IMAGE_SECTION_HEADER
|
||||
{
|
||||
Name = SectionHeaderText,
|
||||
Misc = new IMAGE_SECTION_HEADER.UnionType { VirtualSize = (uint)webcilSectionHeaders[0].VirtualSize },
|
||||
VirtualAddress = (uint)webcilSectionHeaders[0].VirtualAddress,
|
||||
SizeOfRawData = (uint)webcilSectionHeaders[0].SizeOfRawData,
|
||||
PointerToRawData = webcilSectionHeaders[0].GetCorrectedPointerToRawData(pointerToRawDataOffsetBetweenWebcilAndPE),
|
||||
Characteristics = 0x60000020
|
||||
};
|
||||
peStream.WriteStruct(textSectionHeader);
|
||||
|
||||
var rsrcSectionHeader = new IMAGE_SECTION_HEADER
|
||||
{
|
||||
Name = SectionHeaderRsRc,
|
||||
Misc = new IMAGE_SECTION_HEADER.UnionType { VirtualSize = (uint)webcilSectionHeaders[1].VirtualSize },
|
||||
VirtualAddress = (uint)webcilSectionHeaders[1].VirtualAddress,
|
||||
SizeOfRawData = (uint)webcilSectionHeaders[1].SizeOfRawData,
|
||||
PointerToRawData = webcilSectionHeaders[1].GetCorrectedPointerToRawData(pointerToRawDataOffsetBetweenWebcilAndPE),
|
||||
Characteristics = 0x40000040
|
||||
};
|
||||
peStream.WriteStruct(rsrcSectionHeader);
|
||||
|
||||
var relocSectionHeader = new IMAGE_SECTION_HEADER
|
||||
{
|
||||
Name = SectionHeaderReloc,
|
||||
Misc = new IMAGE_SECTION_HEADER.UnionType { VirtualSize = (uint)webcilSectionHeaders[2].VirtualSize },
|
||||
VirtualAddress = (uint)webcilSectionHeaders[2].VirtualAddress,
|
||||
SizeOfRawData = (uint)webcilSectionHeaders[2].SizeOfRawData,
|
||||
PointerToRawData = webcilSectionHeaders[2].GetCorrectedPointerToRawData(pointerToRawDataOffsetBetweenWebcilAndPE),
|
||||
Characteristics = 0x42000040
|
||||
};
|
||||
peStream.WriteStruct(relocSectionHeader);
|
||||
|
||||
if (extraBytesAfterSections.Length > 0)
|
||||
{
|
||||
peStream.Write(extraBytesAfterSections);
|
||||
}
|
||||
|
||||
// Just copy all data
|
||||
foreach (var webcilSectionHeader in webcilSectionHeaders)
|
||||
{
|
||||
var buffer = new byte[webcilSectionHeader.SizeOfRawData];
|
||||
webcilStream.Seek(webcilSectionHeader.PointerToRawData, SeekOrigin.Begin);
|
||||
ReadExactly(webcilStream, buffer);
|
||||
|
||||
peStream.Write(buffer, 0, buffer.Length);
|
||||
}
|
||||
|
||||
peStream.Flush();
|
||||
peStream.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
return peStream.ToArray();
|
||||
}
|
||||
|
||||
private static Webcil.WebcilHeader ReadHeader(Stream webcilStream)
|
||||
{
|
||||
var webcilHeader = ReadStructure<Webcil.WebcilHeader>(webcilStream);
|
||||
|
||||
if (!BitConverter.IsLittleEndian)
|
||||
{
|
||||
webcilHeader.version_major = BinaryPrimitives.ReverseEndianness(webcilHeader.version_major);
|
||||
webcilHeader.version_minor = BinaryPrimitives.ReverseEndianness(webcilHeader.version_minor);
|
||||
webcilHeader.coff_sections = BinaryPrimitives.ReverseEndianness(webcilHeader.coff_sections);
|
||||
webcilHeader.pe_cli_header_rva = BinaryPrimitives.ReverseEndianness(webcilHeader.pe_cli_header_rva);
|
||||
webcilHeader.pe_cli_header_size = BinaryPrimitives.ReverseEndianness(webcilHeader.pe_cli_header_size);
|
||||
webcilHeader.pe_debug_rva = BinaryPrimitives.ReverseEndianness(webcilHeader.pe_debug_rva);
|
||||
webcilHeader.pe_debug_size = BinaryPrimitives.ReverseEndianness(webcilHeader.pe_debug_size);
|
||||
}
|
||||
|
||||
return webcilHeader;
|
||||
}
|
||||
|
||||
private static ImmutableArray<Webcil.WebcilSectionHeader> ReadSectionHeaders(Stream webcilStream, int sectionsHeaders)
|
||||
{
|
||||
var result = new List<Webcil.WebcilSectionHeader>();
|
||||
for (int i = 0; i < sectionsHeaders; i++)
|
||||
{
|
||||
result.Add(ReadSectionHeader(webcilStream));
|
||||
}
|
||||
|
||||
return ImmutableArray.Create(result.ToArray());
|
||||
}
|
||||
|
||||
private static Webcil.WebcilSectionHeader ReadSectionHeader(Stream webcilStream)
|
||||
{
|
||||
var sectionHeader = ReadStructure<Webcil.WebcilSectionHeader>(webcilStream);
|
||||
|
||||
if (!BitConverter.IsLittleEndian)
|
||||
{
|
||||
sectionHeader = new Webcil.WebcilSectionHeader
|
||||
(
|
||||
virtualSize: BinaryPrimitives.ReverseEndianness(sectionHeader.VirtualSize),
|
||||
virtualAddress: BinaryPrimitives.ReverseEndianness(sectionHeader.VirtualAddress),
|
||||
sizeOfRawData: BinaryPrimitives.ReverseEndianness(sectionHeader.SizeOfRawData),
|
||||
pointerToRawData: BinaryPrimitives.ReverseEndianness(sectionHeader.PointerToRawData)
|
||||
);
|
||||
}
|
||||
|
||||
return sectionHeader;
|
||||
}
|
||||
|
||||
private static uint GetSizeOfHeaders(IMAGE_DOS_HEADER IMAGE_DOS_HEADER, int numSectionHeaders)
|
||||
{
|
||||
var soh = IMAGE_DOS_HEADER.FileAddressOfNewExeHeader + // e_lfanew member of IMAGE_DOS_HEADER
|
||||
sizeof(uint) + // 4 byte signature
|
||||
SizeofFileHeader +
|
||||
SizeofOptionalHeader + // size of optional header
|
||||
numSectionHeaders * SizeofSectionHeader // size of all section headers
|
||||
;
|
||||
|
||||
return (uint)soh.RoundToNearest();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The low 32 bits of the time stamp of the image.
|
||||
/// This represents the date and time the image was created by the linker.
|
||||
/// The value is represented in the number of seconds elapsed since midnight (00:00:00), January 1, 1970, Universal Coordinated Time, according to the system clock.
|
||||
/// </summary>
|
||||
private static uint GetImageTimestamp()
|
||||
{
|
||||
// Calculate the total seconds since Unix epoch
|
||||
var totalSeconds = (DateTime.UtcNow - Epoch).Ticks / TimeSpan.TicksPerSecond;
|
||||
|
||||
// Convert to uint (low 32 bits)
|
||||
return (uint)totalSeconds;
|
||||
}
|
||||
|
||||
internal static int RoundToNearest(this int number, int nearest = 512)
|
||||
{
|
||||
int remainder = number % nearest;
|
||||
int halfNearest = nearest / 2;
|
||||
return remainder >= halfNearest ? number + nearest - remainder : number - remainder;
|
||||
}
|
||||
|
||||
internal static uint RoundToNearest(this uint number, uint nearest = 512)
|
||||
{
|
||||
uint remainder = number % nearest;
|
||||
uint halfNearest = nearest / 2;
|
||||
return remainder >= halfNearest ? number + nearest - remainder : number - remainder;
|
||||
}
|
||||
|
||||
#if NETCOREAPP2_1_OR_GREATER
|
||||
private static T ReadStructure<T>(Stream s) where T : unmanaged
|
||||
{
|
||||
T structure = default;
|
||||
unsafe
|
||||
{
|
||||
byte* p = (byte*)&structure;
|
||||
Span<byte> buffer = new Span<byte>(p, sizeof(T));
|
||||
int read = s.Read(buffer);
|
||||
if (read != sizeof(T))
|
||||
{
|
||||
throw new InvalidOperationException("Couldn't read the full structure from the stream.");
|
||||
}
|
||||
}
|
||||
|
||||
return structure;
|
||||
}
|
||||
#else
|
||||
private static T ReadStructure<T>(Stream s) where T : unmanaged
|
||||
{
|
||||
int size = Marshal.SizeOf<T>();
|
||||
byte[] buffer = new byte[size];
|
||||
s.Read(buffer, 0, size);
|
||||
|
||||
IntPtr ptr = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
ptr = Marshal.AllocHGlobal(size);
|
||||
Marshal.Copy(buffer, 0, ptr, size);
|
||||
return Marshal.PtrToStructure<T>(ptr);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(ptr);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if NETCOREAPP2_1_OR_GREATER && !NET6_0
|
||||
private static void ReadExactly(Stream s, Span<byte> buffer)
|
||||
{
|
||||
s.ReadExactly(buffer);
|
||||
}
|
||||
#else
|
||||
private static void ReadExactly(Stream s, byte[] buffer)
|
||||
{
|
||||
int offset = 0;
|
||||
while (offset < buffer.Length)
|
||||
{
|
||||
int read = s.Read(buffer, offset, buffer.Length - offset);
|
||||
if (read == 0)
|
||||
{
|
||||
throw new EndOfStreamException();
|
||||
}
|
||||
|
||||
offset += read;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace RobotNet10.ScriptEditor.Helpers.Webcil;
|
||||
|
||||
internal static class WebcilSectionHeaderExtensions
|
||||
{
|
||||
internal static uint GetCorrectedPointerToRawData(this Webcil.WebcilSectionHeader webcilSectionHeader, int offset)
|
||||
{
|
||||
return (uint) (webcilSectionHeader.PointerToRawData + offset);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
|
||||
|
||||
//
|
||||
// Emits a simple WebAssembly wrapper module around a given webcil payload.
|
||||
//
|
||||
// The entire wasm module is going to be unchanging, except for the data section which has 2 passive
|
||||
// segments. segment 0 is 4 bytes and contains the length of the webcil payload. segment 1 is of a
|
||||
// variable size and contains the webcil payload.
|
||||
//
|
||||
// The unchanging parts are stored as a "prefix" and "suffix" which contain the bytes for the following
|
||||
// WAT module, split into the parts that come before the data section, and the bytes that come after:
|
||||
//
|
||||
// (module
|
||||
// (data "\0\00\00\00") ;; data segment 0: payload size as a 4 byte LE uint32
|
||||
// (data "webcil Payload\cc") ;; data segment 1: webcil payload
|
||||
// (memory (import "webcil" "memory") 1)
|
||||
// (global (export "webcilVersion") i32 (i32.const 0))
|
||||
// (func (export "getWebcilSize") (param $destPtr i32) (result)
|
||||
// local.get $destPtr
|
||||
// i32.const 0
|
||||
// i32.const 4
|
||||
// memory.init 0)
|
||||
// (func (export "getWebcilPayload") (param $d i32) (param $n i32) (result)
|
||||
// local.get $d
|
||||
// i32.const 0
|
||||
// local.get $n
|
||||
// memory.init 1))
|
||||
namespace RobotNet10.ScriptEditor.Helpers.Webcil;
|
||||
|
||||
public class WebcilWasmWrapper
|
||||
{
|
||||
private readonly Stream _webcilPayloadStream;
|
||||
private readonly uint _webcilPayloadSize;
|
||||
|
||||
public WebcilWasmWrapper(Stream webcilPayloadStream)
|
||||
{
|
||||
_webcilPayloadStream = webcilPayloadStream;
|
||||
long len = webcilPayloadStream.Length;
|
||||
if (len > (long)uint.MaxValue)
|
||||
throw new InvalidOperationException("webcil payload too large");
|
||||
_webcilPayloadSize = (uint)len;
|
||||
}
|
||||
|
||||
public void WriteWasmWrappedWebcil(Stream outputStream)
|
||||
{
|
||||
WriteWasmHeader(outputStream);
|
||||
using (var writer = new BinaryWriter(outputStream, System.Text.Encoding.UTF8, leaveOpen: true))
|
||||
{
|
||||
WriteDataSection(writer);
|
||||
}
|
||||
WriteWasmSuffix(outputStream);
|
||||
}
|
||||
|
||||
//
|
||||
// Everything from the above wat module before the data section
|
||||
//
|
||||
// extracted by wasm-reader -s wrapper.wasm
|
||||
private static
|
||||
#if NET7_0_OR_GREATER
|
||||
ReadOnlyMemory<byte>
|
||||
#else
|
||||
byte[]
|
||||
#endif
|
||||
s_wasmWrapperPrefix = new byte[] {
|
||||
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x0a, 0x02, 0x60, 0x01, 0x7f, 0x00, 0x60, 0x02, 0x7f, 0x7f, 0x00, 0x02, 0x12, 0x01, 0x06, 0x77, 0x65, 0x62, 0x63, 0x69, 0x6c, 0x06, 0x6d,
|
||||
0x65, 0x6d, 0x6f, 0x72, 0x79, 0x02, 0x00, 0x01, 0x03, 0x03, 0x02, 0x00, 0x01, 0x06, 0x0b, 0x02, 0x7f, 0x00, 0x41, 0x00, 0x0b, 0x7f, 0x00, 0x41, 0x00, 0x0b, 0x07, 0x41, 0x04, 0x0d, 0x77, 0x65,
|
||||
0x62, 0x63, 0x69, 0x6c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x03, 0x00, 0x0a, 0x77, 0x65, 0x62, 0x63, 0x69, 0x6c, 0x53, 0x69, 0x7a, 0x65, 0x03, 0x01, 0x0d, 0x67, 0x65, 0x74, 0x57, 0x65,
|
||||
0x62, 0x63, 0x69, 0x6c, 0x53, 0x69, 0x7a, 0x65, 0x00, 0x00, 0x10, 0x67, 0x65, 0x74, 0x57, 0x65, 0x62, 0x63, 0x69, 0x6c, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x00, 0x01, 0x0c, 0x01, 0x02,
|
||||
0x0a, 0x1b, 0x02, 0x0c, 0x00, 0x20, 0x00, 0x41, 0x00, 0x41, 0x04, 0xfc, 0x08, 0x00, 0x00, 0x0b, 0x0c, 0x00, 0x20, 0x00, 0x41, 0x00, 0x20, 0x01, 0xfc, 0x08, 0x01, 0x00, 0x0b,
|
||||
};
|
||||
//
|
||||
// Everything from the above wat module after the data section
|
||||
//
|
||||
// extracted by wasm-reader -s wrapper.wasm
|
||||
private static
|
||||
#if NET7_0_OR_GREATER
|
||||
ReadOnlyMemory<byte>
|
||||
#else
|
||||
byte[]
|
||||
#endif
|
||||
s_wasmWrapperSuffix = new byte[] {
|
||||
0x00, 0x1b, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x02, 0x14, 0x02, 0x00, 0x01, 0x00, 0x07, 0x64, 0x65, 0x73, 0x74, 0x50, 0x74, 0x72, 0x01, 0x02, 0x00, 0x01, 0x64, 0x01, 0x01, 0x6e,
|
||||
};
|
||||
|
||||
private static void WriteWasmHeader(Stream outputStream)
|
||||
{
|
||||
#if NET7_0_OR_GREATER
|
||||
outputStream.Write(s_wasmWrapperPrefix.Span);
|
||||
#else
|
||||
outputStream.Write(s_wasmWrapperPrefix, 0, s_wasmWrapperPrefix.Length);
|
||||
#endif
|
||||
}
|
||||
|
||||
private static void WriteWasmSuffix(Stream outputStream)
|
||||
{
|
||||
#if NET7_0_OR_GREATER
|
||||
outputStream.Write(s_wasmWrapperSuffix.Span);
|
||||
#else
|
||||
outputStream.Write(s_wasmWrapperSuffix, 0, s_wasmWrapperSuffix.Length);
|
||||
#endif
|
||||
}
|
||||
|
||||
// 1 byte to encode "passive" data segment
|
||||
private const uint SegmentCodeSize = 1;
|
||||
|
||||
// Align the payload start to a 4-byte boundary within the wrapper. If the runtime reads the
|
||||
// payload directly, instead of by instantiatng the wasm module, we don't want the WebAssembly
|
||||
// prefix to push some of the values inside the image to odd byte offsets as the runtime assumes
|
||||
// the image will be aligned.
|
||||
//
|
||||
// There are requirements in ECMA-335 (Section II.25.4) that fat method headers and method data
|
||||
// sections be 4-byte aligned.
|
||||
private const uint WebcilPayloadInternalAlignment = 4;
|
||||
|
||||
private void WriteDataSection(BinaryWriter writer)
|
||||
{
|
||||
|
||||
uint dataSectionSize = 0;
|
||||
// uleb128 encoding of number of segments
|
||||
dataSectionSize += 1; // there's always 2 segments which encodes to 1 byte
|
||||
// compute the segment 0 size:
|
||||
// segment 0 has 1 byte segment code, 1 byte of size and at least 4 bytes of payload
|
||||
uint segment0MinimumSize = SegmentCodeSize + 1 + 4;
|
||||
dataSectionSize += segment0MinimumSize;
|
||||
|
||||
// encode webcil size as a uleb128
|
||||
byte[] ulebWebcilPayloadSize = ULEB128Encode(_webcilPayloadSize);
|
||||
|
||||
// compute the segment 1 size:
|
||||
// segment 1 has 1 byte segment code, a uleb128 encoding of the webcilPayloadSize, and the payload
|
||||
// don't count the size of the payload yet
|
||||
checked
|
||||
{
|
||||
dataSectionSize += SegmentCodeSize + (uint)ulebWebcilPayloadSize.Length;
|
||||
}
|
||||
|
||||
// at this point the data section size includes everything except the data section code, the data section size and the webcil payload itself
|
||||
// and any extra padding that we may want to add to segment 0.
|
||||
// So we can compute the offset of the payload within the wasm module.
|
||||
byte[] putativeULEBDataSectionSize = ULEB128Encode(dataSectionSize + _webcilPayloadSize);
|
||||
uint payloadOffset = (uint)s_wasmWrapperPrefix.Length + 1 + (uint)putativeULEBDataSectionSize.Length + dataSectionSize ;
|
||||
|
||||
uint paddingSize = PadTo(payloadOffset, WebcilPayloadInternalAlignment);
|
||||
|
||||
if (paddingSize > 0)
|
||||
{
|
||||
checked
|
||||
{
|
||||
dataSectionSize += paddingSize;
|
||||
}
|
||||
}
|
||||
|
||||
checked
|
||||
{
|
||||
dataSectionSize += _webcilPayloadSize;
|
||||
}
|
||||
|
||||
byte[] ulebSectionSize = ULEB128Encode(dataSectionSize);
|
||||
|
||||
if (putativeULEBDataSectionSize.Length != ulebSectionSize.Length)
|
||||
throw new InvalidOperationException ("adding padding would cause data section's encoded length to chane"); // TODO: fixme: there's upto one extra byte to encode the section length - take away a padding byte.
|
||||
writer.Write((byte)11); // section Data
|
||||
writer.Write(ulebSectionSize, 0, ulebSectionSize.Length);
|
||||
|
||||
writer.Write((byte)2); // number of segments
|
||||
|
||||
// write segment 0
|
||||
writer.Write((byte)1); // passive segment
|
||||
if (paddingSize + 4 > 127) {
|
||||
throw new InvalidOperationException ("padding would cause segment 0 to need a multi-byte ULEB128 size encoding");
|
||||
}
|
||||
writer.Write((byte)(4 + paddingSize)); // segment size: 4 plus any padding
|
||||
writer.Write((uint)_webcilPayloadSize); // payload is an unsigned 32 bit number
|
||||
for (int i = 0; i < paddingSize; i++)
|
||||
writer.Write((byte)0);
|
||||
|
||||
// write segment 1
|
||||
writer.Write((byte)1); // passive segment
|
||||
writer.Write(ulebWebcilPayloadSize, 0, ulebWebcilPayloadSize.Length); // segment size: _webcilPayloadSize
|
||||
if (writer.BaseStream.Position % WebcilPayloadInternalAlignment != 0) {
|
||||
throw new Exception ($"predited offset {payloadOffset}, actual position {writer.BaseStream.Position}");
|
||||
}
|
||||
_webcilPayloadStream.CopyTo(writer.BaseStream); // payload is the entire webcil content
|
||||
}
|
||||
|
||||
private static byte[] ULEB128Encode(uint value)
|
||||
{
|
||||
uint n = value;
|
||||
int len = 0;
|
||||
do
|
||||
{
|
||||
n >>= 7;
|
||||
len++;
|
||||
} while (n != 0);
|
||||
byte[] arr = new byte[len];
|
||||
int i = 0;
|
||||
n = value;
|
||||
do
|
||||
{
|
||||
byte b = (byte)(n & 0x7f);
|
||||
n >>= 7;
|
||||
if (n != 0)
|
||||
b |= 0x80;
|
||||
arr[i++] = b;
|
||||
} while (n != 0);
|
||||
return arr;
|
||||
}
|
||||
|
||||
private static uint PadTo (uint value, uint align)
|
||||
{
|
||||
uint newValue = AlignTo(value, align);
|
||||
return newValue - value;
|
||||
}
|
||||
|
||||
private static uint AlignTo (uint value, uint align)
|
||||
{
|
||||
return (value + (align - 1)) & ~(align - 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@using MudBlazor
|
||||
@using Microsoft.JSInterop
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using RobotNet10.ScriptEditor.Clients
|
||||
@using RobotNet10.ScriptEditor.Dialogs
|
||||
@using RobotNet10.ScriptEngine.Shared
|
||||
@using RobotNet10.Shared
|
||||
@using RobotNet10.Components
|
||||
|
||||
@inject InstanceMissionHubClient InstanceMissionClient
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
|
||||
<div class="w-100 h-100 p-3">
|
||||
<div @ref="_containerRef" class="w-100 h-100">
|
||||
<MudTable @ref="table" T="InstanceMissionDto"
|
||||
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<InstanceMissionDto>>>(LoadData))"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Dense="true"
|
||||
Height="@_tableHeight"
|
||||
Loading="@_isLoading">
|
||||
<ToolBarContent>
|
||||
<div @ref="toolbarRef" class="w-100 d-flex flex-row">
|
||||
<MudText Typo="Typo.h6">Instance Missions</MudText>
|
||||
<MudSpacer />
|
||||
<MudIconButton Class="me-2" Icon="@Icons.Material.Filled.Refresh" Color="Color.Primary" Size="Size.Small" OnClick="OnSearch" />
|
||||
<MudTextField T="string" Immediate="true" OnAdornmentClick="OnSearch" OnKeyDown="@(async (KeyboardEventArgs e) => { if (e.Key == "Enter") await OnSearch(); })"
|
||||
OnDebounceIntervalElapsed="OnSearch" DebounceInterval="1000" Value="@_searchText" Margin="Margin.Dense"
|
||||
Placeholder="Search" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search"
|
||||
IconSize="Size.Medium" Class="mt-0" Variant="Variant.Outlined" />
|
||||
</div>
|
||||
</ToolBarContent>
|
||||
<HeaderContent>
|
||||
<MudTh>Mission Name</MudTh>
|
||||
<MudTh>State</MudTh>
|
||||
<MudTh>Score</MudTh>
|
||||
<MudTh>Created At</MudTh>
|
||||
<MudTh>Stopped At</MudTh>
|
||||
<MudTh>Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Mission Name">@context.MissionName</MudTd>
|
||||
<MudTd DataLabel="State">
|
||||
<MudChip T="string" Size="Size.Small" Color="@GetStateColor(context.State)">
|
||||
@context.State
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Score">@($"{(100.0 * context.Score / @context.TotalScore):#.00}%")</MudTd>
|
||||
<MudTd DataLabel="Created At">@context.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss")</MudTd>
|
||||
<MudTd DataLabel="Created At">@(GetStoppedAtString(context))</MudTd>
|
||||
<MudTd DataLabel="Actions">
|
||||
<div style="display: flex; gap: 4px;">
|
||||
<IconButton Icon="script-text"
|
||||
Title="View Log"
|
||||
OnClick="@(() => HandleViewLog(context))" />
|
||||
@if (context.State == ScriptMissionState.Running || context.State == ScriptMissionState.Paused || context.State == ScriptMissionState.Pausing)
|
||||
{
|
||||
<IconButton Icon="cancel"
|
||||
Title="Cancel Mission"
|
||||
OnClick="@(() => HandleCancelMission(context))" />
|
||||
}
|
||||
@if (context.State == ScriptMissionState.Running)
|
||||
{
|
||||
<IconButton Icon="pause"
|
||||
Title="Pause Mission"
|
||||
OnClick="@(() => HandlePauseMission(context))" />
|
||||
}
|
||||
@if (context.State == ScriptMissionState.Paused)
|
||||
{
|
||||
<IconButton Icon="play"
|
||||
Title="Resume Mission"
|
||||
OnClick="@(() => HandleResumeMission(context))" />
|
||||
}
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<NoRecordsContent>
|
||||
<MudText>No matching records found</MudText>
|
||||
</NoRecordsContent>
|
||||
<LoadingContent>
|
||||
<MudText>Loading...</MudText>
|
||||
</LoadingContent>
|
||||
<PagerContent>
|
||||
<MudTablePager />
|
||||
</PagerContent>
|
||||
</MudTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private ElementReference _containerRef;
|
||||
private ElementReference toolbarRef;
|
||||
private MudTable<InstanceMissionDto> table = default!;
|
||||
private string _tableHeight = "400px";
|
||||
private string _searchText = "";
|
||||
private bool _isLoading = false;
|
||||
private int _totalItems = 0;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
if (firstRender)
|
||||
{
|
||||
await InstanceMissionClient.StartAsync();
|
||||
await CalculateTableHeight();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CalculateTableHeight()
|
||||
{
|
||||
var rect = await _containerRef.MudGetBoundingClientRectAsync();
|
||||
var toolbarRect = await toolbarRef.MudGetBoundingClientRectAsync();
|
||||
_tableHeight = $"{rect.Height - 70 - Math.Max(toolbarRect.Height, 64)}px";
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task<TableData<InstanceMissionDto>> LoadData(TableState state, CancellationToken cancellationToken)
|
||||
{
|
||||
_isLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var request = new SearchRequest(
|
||||
Page: state.Page + 1, // MudTable uses 0-based page, but our API uses 1-based
|
||||
Size: state.PageSize,
|
||||
TxtSearch: _searchText
|
||||
);
|
||||
|
||||
var result = await InstanceMissionClient.SearchInstanceMissionsAsync(request);
|
||||
_totalItems = result.Total;
|
||||
|
||||
return new TableData<InstanceMissionDto>
|
||||
{
|
||||
Items = result.Items,
|
||||
TotalItems = result.Total
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error loading missions: {ex.Message}", Severity.Error);
|
||||
return new TableData<InstanceMissionDto>
|
||||
{
|
||||
Items = [],
|
||||
TotalItems = 0
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnSearch()
|
||||
{
|
||||
await table.ReloadServerData();
|
||||
}
|
||||
|
||||
private Color GetStateColor(ScriptMissionState state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
ScriptMissionState.Running => Color.Success,
|
||||
ScriptMissionState.Paused => Color.Warning,
|
||||
ScriptMissionState.Pausing => Color.Warning,
|
||||
ScriptMissionState.Resuming => Color.Info,
|
||||
ScriptMissionState.Completed => Color.Success,
|
||||
ScriptMissionState.Canceled => Color.Default,
|
||||
ScriptMissionState.Error => Color.Error,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
|
||||
private async Task HandleViewLog(InstanceMissionDto mission)
|
||||
{
|
||||
var parameters = new DialogParameters<MissionLogDialog>
|
||||
{
|
||||
{ x => x.MissionId, mission.Id },
|
||||
{ x => x.MissionName, mission.MissionName },
|
||||
{ x => x.State, mission.State },
|
||||
{ x => x.InitialLog, mission.Log }
|
||||
};
|
||||
|
||||
var options = new DialogOptions
|
||||
{
|
||||
MaxWidth = MaxWidth.Large,
|
||||
FullWidth = true,
|
||||
CloseButton = true
|
||||
};
|
||||
|
||||
await DialogService.ShowAsync<MissionLogDialog>($"Mission Log: {mission.MissionName}", parameters, options);
|
||||
}
|
||||
|
||||
private async Task HandleCancelMission(InstanceMissionDto mission)
|
||||
{
|
||||
var parameters = new DialogParameters<CancelMissionDialog>
|
||||
{
|
||||
{ x => x.MissionName, mission.MissionName }
|
||||
};
|
||||
|
||||
var options = new DialogOptions
|
||||
{
|
||||
MaxWidth = MaxWidth.Small,
|
||||
FullWidth = true
|
||||
};
|
||||
|
||||
var dialog = await DialogService.ShowAsync<CancelMissionDialog>("Cancel Mission", parameters, options);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result is not null && !result.Canceled && result.Data is string userReason)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get current user information
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
var userName = authState?.User?.Identity?.Name ?? "Unknown";
|
||||
|
||||
// Combine user reason with user information
|
||||
var reason = string.IsNullOrWhiteSpace(userReason)
|
||||
? $"Canceled by {userName}"
|
||||
: $"Canceled by {userName}: {userReason.Trim()}";
|
||||
|
||||
var messageResult = await InstanceMissionClient.CancelMissionAsync(mission.Id, reason);
|
||||
if (messageResult.IsSuccess)
|
||||
{
|
||||
Snackbar.Add(messageResult.Message ?? "Mission canceled", Severity.Success);
|
||||
await table.ReloadServerData();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add(messageResult.Message ?? "Failed to cancel mission", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error canceling mission: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandlePauseMission(InstanceMissionDto mission)
|
||||
{
|
||||
try
|
||||
{
|
||||
var messageResult = await InstanceMissionClient.PauseMissionAsync(mission.Id);
|
||||
if (messageResult.IsSuccess)
|
||||
{
|
||||
Snackbar.Add(messageResult.Message ?? "Mission paused", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add(messageResult.Message ?? "Failed to pause mission", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error pausing mission: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleResumeMission(InstanceMissionDto mission)
|
||||
{
|
||||
try
|
||||
{
|
||||
var messageResult = await InstanceMissionClient.ResumeMissionAsync(mission.Id);
|
||||
if (messageResult.IsSuccess)
|
||||
{
|
||||
Snackbar.Add(messageResult.Message ?? "Mission resumed", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add(messageResult.Message ?? "Failed to resume mission", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error resuming mission: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetStoppedAtString(InstanceMissionDto mission)
|
||||
{
|
||||
if (mission.State == ScriptMissionState.Canceled || mission.State == ScriptMissionState.Completed || mission.State == ScriptMissionState.Error)
|
||||
{
|
||||
return mission.StoppedAt.ToString("yyyy-MM-dd HH:mm:ss");
|
||||
}
|
||||
else
|
||||
{
|
||||
return "--";
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await InstanceMissionClient.StopAsync();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
@using RobotNet10.ScriptEditor.Clients
|
||||
@using RobotNet10.ScriptEditor.Components
|
||||
@using RobotNet10.ScriptEngine.Shared
|
||||
@using MudBlazor
|
||||
@inject ScriptManagerHubClient ScriptManagerClient
|
||||
@inject ISnackbar Snackbar
|
||||
@implements IDisposable
|
||||
|
||||
<SidebarAccordionItem TabName="missions" Label="MISSIONS">
|
||||
<HeaderActions>
|
||||
<IconButton Icon="refresh" Title="Refresh" OnClick="HandleRefresh" />
|
||||
</HeaderActions>
|
||||
<ChildContent>
|
||||
@if (_missions == null || _missions.Count == 0)
|
||||
{
|
||||
<div class="missions-empty-state">
|
||||
<MudText Typo="Typo.body2" Class="text-secondary">
|
||||
@if (_isLoading)
|
||||
{
|
||||
<span>Loading missions...</span>
|
||||
}
|
||||
else if (_currentState == ScriptEngineState.Ready || _currentState == ScriptEngineState.Running)
|
||||
{
|
||||
<span>No missions available</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Missions available when engine is Ready or Running</span>
|
||||
}
|
||||
</MudText>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="missions-list">
|
||||
@foreach (var mission in _missions)
|
||||
{
|
||||
<MissionItem @key="@mission.Name" Mission="@mission" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ChildContent>
|
||||
</SidebarAccordionItem>
|
||||
|
||||
@code {
|
||||
private List<ScriptMissionDto> _missions = new();
|
||||
private ScriptEngineState _currentState = ScriptEngineState.Initializing;
|
||||
private bool _isLoading = false;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// Subscribe to state changes
|
||||
ScriptManagerClient.StateChanged += OnStateChanged;
|
||||
_currentState = ScriptManagerClient.State;
|
||||
|
||||
// Load missions if state is already Ready or Running
|
||||
if (_currentState == ScriptEngineState.Ready || _currentState == ScriptEngineState.Running)
|
||||
{
|
||||
_ = LoadMissionsAsync();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnAfterRender(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
// Ensure we have the latest state
|
||||
_currentState = ScriptManagerClient.State;
|
||||
if (_currentState == ScriptEngineState.Ready || _currentState == ScriptEngineState.Running)
|
||||
{
|
||||
_ = LoadMissionsAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnStateChanged(ScriptEngineState newState)
|
||||
{
|
||||
_currentState = newState;
|
||||
|
||||
if (newState == ScriptEngineState.Ready || newState == ScriptEngineState.Running)
|
||||
{
|
||||
await LoadMissionsAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Clear missions when not in Ready or Running state
|
||||
_missions.Clear();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadMissionsAsync()
|
||||
{
|
||||
if (!ScriptManagerClient.IsConnected)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_isLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
var missions = await ScriptManagerClient.GetScriptMissionsAsync();
|
||||
_missions = missions?.ToList() ?? new List<ScriptMissionDto>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to load missions: {ex.Message}", Severity.Error);
|
||||
_missions = new List<ScriptMissionDto>();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleRefresh(MouseEventArgs e)
|
||||
{
|
||||
if (_currentState == ScriptEngineState.Ready || _currentState == ScriptEngineState.Running)
|
||||
{
|
||||
await LoadMissionsAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ScriptManagerClient.StateChanged -= OnStateChanged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* ============================================
|
||||
MissionManager Component Styles
|
||||
============================================ */
|
||||
|
||||
.missions-empty-state {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: #858585;
|
||||
}
|
||||
|
||||
.missions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace RobotNet10.ScriptEditor.Models;
|
||||
|
||||
internal interface IHierarchyItem : IDisposable
|
||||
{
|
||||
string Name { get; }
|
||||
bool IsModified { get; }
|
||||
int WarningCount { get; }
|
||||
int ErrorCount { get; }
|
||||
|
||||
event Action? Modified;
|
||||
event Action? NameChanged;
|
||||
event Action<int, int>? DiagnosticsChanged;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using RobotNet10.ScriptEngine.Shared;
|
||||
|
||||
namespace RobotNet10.ScriptEditor.Models;
|
||||
|
||||
public class ScriptFile(DocumentId id, ScriptFileDto data, ScriptFolder? parent = null) : IHierarchyItem
|
||||
{
|
||||
public DocumentId Id { get; } = id;
|
||||
public ScriptFolder? Parent => parent;
|
||||
public string Path => System.IO.Path.Combine(parent?.Path ?? "", Name);
|
||||
public int Level { get; } = data.Level;
|
||||
public bool IsModified { get; private set; }
|
||||
public int WarningCount { get; private set; }
|
||||
public int ErrorCount { get; private set; }
|
||||
|
||||
public event Action? Modified
|
||||
{
|
||||
add => _modified += value;
|
||||
remove => _modified -= value;
|
||||
}
|
||||
|
||||
private event Action? _modified;
|
||||
public event Action? NameChanged;
|
||||
public event Action<int, int>? DiagnosticsChanged;
|
||||
|
||||
private string _name = data.Name;
|
||||
public string Name
|
||||
{
|
||||
get => _name;
|
||||
set
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) throw new ArgumentNullException($"Tên file {_name} không được để trống");
|
||||
|
||||
if (_name == value) return;
|
||||
|
||||
_name = value;
|
||||
NameChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
private string _code = data.Code;
|
||||
public string Code
|
||||
{
|
||||
get => _code;
|
||||
set
|
||||
{
|
||||
_code = value;
|
||||
if (IsModified)
|
||||
{
|
||||
if (SavedCode == _code)
|
||||
{
|
||||
IsModified = false;
|
||||
_modified?.Invoke();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (SavedCode != _code)
|
||||
{
|
||||
IsModified = true;
|
||||
_modified?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<Diagnostic> _diagnostics = [];
|
||||
public IEnumerable<Diagnostic> Diagnostics
|
||||
{
|
||||
get => _diagnostics;
|
||||
set
|
||||
{
|
||||
_diagnostics = value;
|
||||
var warning = _diagnostics.Count(d => d.Severity == DiagnosticSeverity.Warning);
|
||||
var error = _diagnostics.Count(d => d.Severity == DiagnosticSeverity.Error);
|
||||
|
||||
if (WarningCount != warning || ErrorCount != error)
|
||||
{
|
||||
WarningCount = warning;
|
||||
ErrorCount = error;
|
||||
// Send absolute values, not delta, for consistency with ScriptFolder.OnDiagnosticsChanged
|
||||
DiagnosticsChanged?.Invoke(warning, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string SavedCode = data.Code;
|
||||
|
||||
public void Saved()
|
||||
{
|
||||
if (!IsModified || SavedCode == Code)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SavedCode = Code;
|
||||
IsModified = false;
|
||||
_modified?.Invoke();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
using RobotNet10.ScriptEngine.Shared;
|
||||
using static System.Runtime.InteropServices.JavaScript.JSType;
|
||||
|
||||
namespace RobotNet10.ScriptEditor.Models;
|
||||
|
||||
public class ScriptFolder(ScriptFolderDto data, ScriptFolder? parent = null) : IHierarchyItem
|
||||
{
|
||||
public ScriptFolder? Parent => parent;
|
||||
public string Path => System.IO.Path.Combine(parent?.Path ?? "", Name);
|
||||
public int Level { get; } = data.Level;
|
||||
|
||||
public bool IsModified { get; private set; }
|
||||
public int WarningCount { get; private set; }
|
||||
public int ErrorCount { get; private set; }
|
||||
public bool IsExpanded { get; set; } = false;
|
||||
|
||||
public event Action? ChildrenChanged;
|
||||
public event Action? Modified;
|
||||
public event Action? NameChanged;
|
||||
public event Action<int, int>? DiagnosticsChanged;
|
||||
|
||||
public IEnumerable<ScriptFolder> Folders => WorkspaceFolders;
|
||||
public IEnumerable<ScriptFile> Files => WorkspaceFiles;
|
||||
|
||||
private string _name = data.Name;
|
||||
public string Name
|
||||
{
|
||||
get => _name;
|
||||
set
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) throw new ArgumentNullException($"Tên file {_name} không được để trống");
|
||||
|
||||
if (_name == value) return;
|
||||
|
||||
_name = value;
|
||||
|
||||
NameChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
private readonly List<ScriptFolder> WorkspaceFolders = [];
|
||||
private readonly List<ScriptFile> WorkspaceFiles = [];
|
||||
|
||||
public void AddFiles(params IEnumerable<ScriptFile> files)
|
||||
{
|
||||
WorkspaceFiles.AddRange(files);
|
||||
WorkspaceFiles.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
|
||||
|
||||
// Recalculate totals including new files
|
||||
var totalWarningCount = Files.Sum(file => file.WarningCount) + Folders.Sum(folder => folder.WarningCount);
|
||||
var totalErrorCount = Files.Sum(file => file.ErrorCount) + Folders.Sum(folder => folder.ErrorCount);
|
||||
var newIsModified = Files.Any(file => file.IsModified) || Folders.Any(folder => folder.IsModified);
|
||||
|
||||
var warningChanged = WarningCount != totalWarningCount;
|
||||
var errorChanged = ErrorCount != totalErrorCount;
|
||||
var modifiedChanged = IsModified != newIsModified;
|
||||
|
||||
WarningCount = totalWarningCount;
|
||||
ErrorCount = totalErrorCount;
|
||||
IsModified = newIsModified;
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
file.Modified += UpdateModified;
|
||||
file.DiagnosticsChanged += OnDiagnosticsChanged;
|
||||
}
|
||||
|
||||
// Notify changes
|
||||
if (warningChanged || errorChanged)
|
||||
{
|
||||
DiagnosticsChanged?.Invoke(WarningCount, ErrorCount);
|
||||
}
|
||||
if (modifiedChanged)
|
||||
{
|
||||
Modified?.Invoke();
|
||||
}
|
||||
|
||||
ChildrenChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void AddFolders(params IEnumerable<ScriptFolder> folders)
|
||||
{
|
||||
WorkspaceFolders.AddRange(folders);
|
||||
WorkspaceFolders.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
|
||||
|
||||
// Recalculate totals including new folders
|
||||
var totalWarningCount = Files.Sum(file => file.WarningCount) + Folders.Sum(folder => folder.WarningCount);
|
||||
var totalErrorCount = Files.Sum(file => file.ErrorCount) + Folders.Sum(folder => folder.ErrorCount);
|
||||
var newIsModified = Files.Any(file => file.IsModified) || Folders.Any(folder => folder.IsModified);
|
||||
|
||||
var warningChanged = WarningCount != totalWarningCount;
|
||||
var errorChanged = ErrorCount != totalErrorCount;
|
||||
var modifiedChanged = IsModified != newIsModified;
|
||||
|
||||
WarningCount = totalWarningCount;
|
||||
ErrorCount = totalErrorCount;
|
||||
IsModified = newIsModified;
|
||||
|
||||
foreach (var folder in folders)
|
||||
{
|
||||
folder.Modified += UpdateModified;
|
||||
folder.DiagnosticsChanged += OnDiagnosticsChanged;
|
||||
}
|
||||
|
||||
// Notify changes
|
||||
if (warningChanged || errorChanged)
|
||||
{
|
||||
DiagnosticsChanged?.Invoke(WarningCount, ErrorCount);
|
||||
}
|
||||
if (modifiedChanged)
|
||||
{
|
||||
Modified?.Invoke();
|
||||
}
|
||||
|
||||
ChildrenChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void RemoveFile(ScriptFile file)
|
||||
{
|
||||
if (WorkspaceFiles.Remove(file))
|
||||
{
|
||||
// Unsubscribe from file events
|
||||
file.Modified -= UpdateModified;
|
||||
file.DiagnosticsChanged -= OnDiagnosticsChanged;
|
||||
|
||||
// Recalculate totals after removal
|
||||
var totalWarningCount = Files.Sum(f => f.WarningCount) + Folders.Sum(folder => folder.WarningCount);
|
||||
var totalErrorCount = Files.Sum(f => f.ErrorCount) + Folders.Sum(folder => folder.ErrorCount);
|
||||
var newIsModified = Files.Any(f => f.IsModified) || Folders.Any(folder => folder.IsModified);
|
||||
|
||||
var warningChanged = WarningCount != totalWarningCount;
|
||||
var errorChanged = ErrorCount != totalErrorCount;
|
||||
var modifiedChanged = IsModified != newIsModified;
|
||||
|
||||
WarningCount = totalWarningCount;
|
||||
ErrorCount = totalErrorCount;
|
||||
IsModified = newIsModified;
|
||||
|
||||
// Dispose the removed file
|
||||
file.Dispose();
|
||||
|
||||
// Notify changes
|
||||
if (warningChanged || errorChanged)
|
||||
{
|
||||
DiagnosticsChanged?.Invoke(WarningCount, ErrorCount);
|
||||
}
|
||||
if (modifiedChanged)
|
||||
{
|
||||
Modified?.Invoke();
|
||||
}
|
||||
|
||||
ChildrenChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveFolder(ScriptFolder folder)
|
||||
{
|
||||
if (WorkspaceFolders.Remove(folder))
|
||||
{
|
||||
// Unsubscribe from folder events
|
||||
folder.Modified -= UpdateModified;
|
||||
folder.DiagnosticsChanged -= OnDiagnosticsChanged;
|
||||
|
||||
// Recalculate totals after removal
|
||||
var totalWarningCount = Files.Sum(file => file.WarningCount) + Folders.Sum(f => f.WarningCount);
|
||||
var totalErrorCount = Files.Sum(file => file.ErrorCount) + Folders.Sum(f => f.ErrorCount);
|
||||
var newIsModified = Files.Any(file => file.IsModified) || Folders.Any(f => f.IsModified);
|
||||
|
||||
var warningChanged = WarningCount != totalWarningCount;
|
||||
var errorChanged = ErrorCount != totalErrorCount;
|
||||
var modifiedChanged = IsModified != newIsModified;
|
||||
|
||||
WarningCount = totalWarningCount;
|
||||
ErrorCount = totalErrorCount;
|
||||
IsModified = newIsModified;
|
||||
|
||||
// Dispose the removed folder (will handle its children)
|
||||
folder.Dispose();
|
||||
|
||||
// Notify changes
|
||||
if (warningChanged || errorChanged)
|
||||
{
|
||||
DiagnosticsChanged?.Invoke(WarningCount, ErrorCount);
|
||||
}
|
||||
if (modifiedChanged)
|
||||
{
|
||||
Modified?.Invoke();
|
||||
}
|
||||
|
||||
ChildrenChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateModified()
|
||||
{
|
||||
var newIsModified = Files.Any(file => file.IsModified) || Folders.Any(folder => folder.IsModified);
|
||||
|
||||
// Only update and notify if the value actually changed
|
||||
if (IsModified != newIsModified)
|
||||
{
|
||||
IsModified = newIsModified;
|
||||
Modified?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDiagnosticsChanged(int warningCount, int errorCount)
|
||||
{
|
||||
// Recalculate totals from all children
|
||||
// Note: The parameters (warningCount, errorCount) are the absolute values from the child that changed,
|
||||
// but we recalculate from all children to ensure accuracy, especially when multiple files change simultaneously
|
||||
var totalWarningCount = Files.Sum(file => file.WarningCount) + Folders.Sum(folder => folder.WarningCount);
|
||||
var totalErrorCount = Files.Sum(file => file.ErrorCount) + Folders.Sum(folder => folder.ErrorCount);
|
||||
|
||||
if (WarningCount != totalWarningCount || ErrorCount != totalErrorCount)
|
||||
{
|
||||
WarningCount = totalWarningCount;
|
||||
ErrorCount = totalErrorCount;
|
||||
DiagnosticsChanged?.Invoke(WarningCount, ErrorCount);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recalculates WarningCount, ErrorCount, and IsModified from all children.
|
||||
/// This is useful after diagnostics have been updated for all children.
|
||||
/// </summary>
|
||||
internal void RecalculateTotals()
|
||||
{
|
||||
// First, recalculate all child folders recursively
|
||||
foreach (var subFolder in Folders)
|
||||
{
|
||||
subFolder.RecalculateTotals();
|
||||
}
|
||||
|
||||
// Then calculate totals from all children
|
||||
var totalWarningCount = Files.Sum(f => f.WarningCount) + Folders.Sum(f => f.WarningCount);
|
||||
var totalErrorCount = Files.Sum(f => f.ErrorCount) + Folders.Sum(f => f.ErrorCount);
|
||||
var isModified = Files.Any(f => f.IsModified) || Folders.Any(f => f.IsModified);
|
||||
|
||||
// Update properties if changed
|
||||
var warningChanged = WarningCount != totalWarningCount;
|
||||
var errorChanged = ErrorCount != totalErrorCount;
|
||||
var modifiedChanged = IsModified != isModified;
|
||||
|
||||
if (warningChanged || errorChanged || modifiedChanged)
|
||||
{
|
||||
WarningCount = totalWarningCount;
|
||||
ErrorCount = totalErrorCount;
|
||||
IsModified = isModified;
|
||||
|
||||
if (warningChanged || errorChanged)
|
||||
{
|
||||
DiagnosticsChanged?.Invoke(WarningCount, ErrorCount);
|
||||
}
|
||||
|
||||
if (modifiedChanged)
|
||||
{
|
||||
Modified?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var file in WorkspaceFiles)
|
||||
{
|
||||
file.Dispose();
|
||||
}
|
||||
WorkspaceFiles.Clear();
|
||||
foreach (var folder in Folders)
|
||||
{
|
||||
folder.Dispose();
|
||||
}
|
||||
WorkspaceFolders.Clear();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
namespace RobotNet10.ScriptEditor.Models;
|
||||
|
||||
public class ScriptMissionParameterValueModel(string name, string type, string valueDefault)
|
||||
{
|
||||
public static readonly Dictionary<string, Type> PredefinedTypeMap = new()
|
||||
{
|
||||
["System.Boolean"] = typeof(bool),
|
||||
["System.Byte"] = typeof(byte),
|
||||
["System.SByte"] = typeof(sbyte),
|
||||
["System.Int16"] = typeof(short),
|
||||
["System.UInt16"] = typeof(ushort),
|
||||
["System.Int32"] = typeof(int),
|
||||
["System.UInt32"] = typeof(uint),
|
||||
["System.Int64"] = typeof(long),
|
||||
["System.UInt64"] = typeof(ulong),
|
||||
["System.Single"] = typeof(float),
|
||||
["System.Double"] = typeof(double),
|
||||
["System.Decimal"] = typeof(double),
|
||||
["System.Char"] = typeof(char),
|
||||
["System.String"] = typeof(string)
|
||||
};
|
||||
|
||||
public string Name { get; } = name;
|
||||
public string Type { get; } = type;
|
||||
public string? Default { get; } = valueDefault;
|
||||
public string Errors { get; set; } = string.Empty;
|
||||
public object? Value { get; set; } = null;
|
||||
|
||||
private void EnsureType(string expectedType)
|
||||
{
|
||||
if (Type != expectedType)
|
||||
throw new InvalidOperationException($"Parameter '{Name}' is not of type '{expectedType}'. Actual type: '{Type}'.");
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return Value?.ToString() ?? "null";
|
||||
}
|
||||
|
||||
public bool BoolValue
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureType("System.Boolean");
|
||||
return Value is not null && (bool)Value;
|
||||
}
|
||||
set
|
||||
{
|
||||
EnsureType("System.Boolean");
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
public byte ByteValue
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureType("System.Byte");
|
||||
return Value is null ? default : (byte)Value;
|
||||
}
|
||||
set
|
||||
{
|
||||
EnsureType("System.Byte");
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
public sbyte SByteValue
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureType("System.SByte");
|
||||
return Value is null ? default : (sbyte)Value;
|
||||
}
|
||||
set
|
||||
{
|
||||
EnsureType("System.SByte");
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
public short ShortValue
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureType("System.Int16");
|
||||
return Value is null ? default : (short)Value;
|
||||
}
|
||||
set
|
||||
{
|
||||
EnsureType("System.Int16");
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
public ushort UShortValue
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureType("System.UInt16");
|
||||
return Value is null ? default : (ushort)Value;
|
||||
}
|
||||
set
|
||||
{
|
||||
EnsureType("System.UInt16");
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
public int IntValue
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureType("System.Int32");
|
||||
return Value is null ? default : (int)Value;
|
||||
}
|
||||
set
|
||||
{
|
||||
EnsureType("System.Int32");
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
public uint UIntValue
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureType("System.UInt32");
|
||||
return Value is null ? default : (uint)Value;
|
||||
}
|
||||
set
|
||||
{
|
||||
EnsureType("System.UInt32");
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
public long LongValue
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureType("System.Int64");
|
||||
return Value is null ? default : (long)Value;
|
||||
}
|
||||
set
|
||||
{
|
||||
EnsureType("System.Int64");
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
public ulong ULongValue
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureType("System.UInt64");
|
||||
return Value is null ? default : (ulong)Value;
|
||||
}
|
||||
set
|
||||
{
|
||||
EnsureType("System.UInt64");
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
public float FloatValue
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureType("System.Single");
|
||||
return Value is null ? default : (float)Value;
|
||||
}
|
||||
set
|
||||
{
|
||||
EnsureType("System.Single");
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
public double DoubleValue
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureType("System.Double");
|
||||
return Value is null ? default : (double)Value;
|
||||
}
|
||||
set
|
||||
{
|
||||
EnsureType("System.Double");
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
public double DecimalValue
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureType("System.Decimal");
|
||||
return Value is null ? default : (double)Value;
|
||||
}
|
||||
set
|
||||
{
|
||||
EnsureType("System.Decimal");
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
public char CharValue
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureType("System.Char");
|
||||
return Value is null ? default : (char)Value;
|
||||
}
|
||||
set
|
||||
{
|
||||
EnsureType("System.Char");
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
public string StringValue
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureType("System.String");
|
||||
return Value is null ? string.Empty : (string)Value;
|
||||
}
|
||||
set
|
||||
{
|
||||
EnsureType("System.String");
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
if (PredefinedTypeMap.TryGetValue(Type, out var type))
|
||||
{
|
||||
Value = type.IsValueType ? Activator.CreateInstance(type) : (type == typeof(string) ? string.Empty : null);
|
||||
}
|
||||
else
|
||||
{
|
||||
Value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
||||
<Optimize>false</Optimize>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<SupportedPlatform Include="browser" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BlazorMonaco" Version="3.4.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Features" Version="5.0.0" />
|
||||
<PackageReference Include="MudBlazor" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Commons\RobotNet10.Script\RobotNet10.Script.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\RobotNet10.ScriptEngine.Shared\RobotNet10.ScriptEngine.Shared.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\RobotNet10.Shared\RobotNet10.Shared.csproj" />
|
||||
<ProjectReference Include="..\RobotNet10.Components\RobotNet10.Components.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Components\" />
|
||||
<Folder Include="Clients\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,24 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.5.2.0
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RobotNet10.ScriptEditor", "RobotNet10.ScriptEditor.csproj", "{36C0C73F-8CD7-18F2-5DE8-89DBBD9E2F1D}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{36C0C73F-8CD7-18F2-5DE8-89DBBD9E2F1D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{36C0C73F-8CD7-18F2-5DE8-89DBBD9E2F1D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{36C0C73F-8CD7-18F2-5DE8-89DBBD9E2F1D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{36C0C73F-8CD7-18F2-5DE8-89DBBD9E2F1D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {AC84E36C-23DB-49A9-9DE0-E05FDE7D4B51}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,177 @@
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@using RobotNet10.ScriptEditor.Clients
|
||||
@using RobotNet10.ScriptEditor.Models
|
||||
@using RobotNet10.ScriptEditor.Dialogs
|
||||
@using Microsoft.JSInterop
|
||||
@using Microsoft.JSInterop.Implementation
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using RobotNet10.ScriptEditor.Services
|
||||
@using RobotNet10.ScriptEngine.Shared
|
||||
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject IScriptEngineResource ScriptResource
|
||||
@inject FileManagerHubClient FileManagerClient
|
||||
@inject ScriptManagerHubClient ScriptManagerClient
|
||||
@inject ScriptResourceResolver ResourceResolver
|
||||
@inject ScriptWorkspace Workspace
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IDialogService DialogService
|
||||
@using MudBlazor
|
||||
|
||||
<div class="script-editor-container">
|
||||
<!-- Phần 1: Sidebar (có thể resize ngang) -->
|
||||
<div class="sidebar-container" style="width: @(_sidebarWidth)px;">
|
||||
<div class="sidebar-content">
|
||||
<FileExplorer />
|
||||
<VariableManager />
|
||||
<TaskManager />
|
||||
<MissionManager />
|
||||
</div>
|
||||
<div class="sidebar-resizer"></div>
|
||||
</div>
|
||||
|
||||
<!-- Phần 2 và 3: Editor Area và Console Panel (sắp xếp dọc) -->
|
||||
<div class="editor-console-container">
|
||||
<!-- Phần 2: Editor Area -->
|
||||
<div class="editor-area" id="editor-area">
|
||||
<div class="editor-area-content">
|
||||
<Editor />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resizer giữa Editor và Console -->
|
||||
<div class="vertical-resizer"></div>
|
||||
|
||||
<!-- Phần 3: Console Panel (có thể resize dọc) -->
|
||||
<div class="console-panel" id="console-panel">
|
||||
<Console />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MudOverlay Visible="@(!Workspace.IsInitialized)" DarkBackground Modal Absolute AutoClose="false">
|
||||
<MudProgressCircular Color="Color.Primary" Indeterminate="true" />
|
||||
</MudOverlay>
|
||||
|
||||
@code {
|
||||
private int _sidebarWidth = 250; // Chiều rộng mặc định của sidebar (px)
|
||||
private IJSObjectReference? _jsModule;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// Subscribe to edit permission revoked event
|
||||
FileManagerClient.EditPermissionRevoked += OnEditPermissionRevoked;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
try
|
||||
{
|
||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>(
|
||||
"import", "./_content/RobotNet10.ScriptEditor/scriptEditorResize.js");
|
||||
|
||||
if (_jsModule != null)
|
||||
{
|
||||
await _jsModule.InvokeVoidAsync("initializeLayout");
|
||||
}
|
||||
|
||||
await FileManagerClient.StartAsync();
|
||||
await ScriptManagerClient.StartAsync();
|
||||
|
||||
// Request edit permission before initializing workspace
|
||||
// This will always succeed and force out any previous connection
|
||||
try
|
||||
{
|
||||
await FileManagerClient.RequestEditPermissionAsync();
|
||||
|
||||
// Check if we actually have edit permission (depends on state)
|
||||
var hasPermission = await FileManagerClient.HasEditPermissionAsync();
|
||||
Workspace.IsReadOnly = !hasPermission;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// If request fails, set to read-only
|
||||
Workspace.IsReadOnly = true;
|
||||
}
|
||||
|
||||
var references = await ResourceResolver.GetMetadataReferences(ScriptResource.Modules.ToArray(), ScriptResource.DocModules.ToArray());
|
||||
var rootFolder = await FileManagerClient.GetRootFolderAsync();
|
||||
|
||||
Workspace.Initialize(references, ScriptResource.UsingNamespaces.ToArray(), ScriptResource.AppGlobalType, rootFolder);
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (JSException)
|
||||
{
|
||||
// JavaScript module chưa được load, sẽ được xử lý sau
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Ignore other exceptions but don't crash the component
|
||||
// Mono runtime assertions (e.g., debugger-agent) are non-fatal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnEditPermissionRevoked(string? userId)
|
||||
{
|
||||
// Lock the workspace when permission is revoked
|
||||
Workspace.IsReadOnly = true;
|
||||
StateHasChanged();
|
||||
|
||||
// Show dialog to inform user
|
||||
// User can only close by clicking reload button, not by clicking outside or pressing Escape
|
||||
var options = new DialogOptions
|
||||
{
|
||||
CloseButton = false,
|
||||
CloseOnEscapeKey = false,
|
||||
BackdropClick = false,
|
||||
MaxWidth = MaxWidth.Small,
|
||||
FullWidth = true
|
||||
};
|
||||
|
||||
var parameters = new DialogParameters<PermissionRevokedDialog>
|
||||
{
|
||||
{ x => x.OnReload, new Action(() => NavigationManager.NavigateTo(NavigationManager.Uri, forceLoad: true)) }
|
||||
};
|
||||
|
||||
await DialogService.ShowAsync<PermissionRevokedDialog>("Edit Permission Revoked", parameters, options);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// Unsubscribe from events
|
||||
FileManagerClient.EditPermissionRevoked -= OnEditPermissionRevoked;
|
||||
|
||||
if (_jsModule != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _jsModule.InvokeVoidAsync("cleanupLayout");
|
||||
await _jsModule.DisposeAsync();
|
||||
|
||||
// Revoke edit permission before stopping connection
|
||||
if (FileManagerClient.IsConnected)
|
||||
{
|
||||
try
|
||||
{
|
||||
await FileManagerClient.RevokeEditPermissionAsync();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Ignore errors when revoking permission during dispose
|
||||
}
|
||||
}
|
||||
|
||||
await FileManagerClient.StopAsync();
|
||||
await ScriptManagerClient.StopAsync();
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// Ignore khi JS context đã bị disconnect
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
.script-editor-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #1e1e1e; /* Dark theme giống VSCode */
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Phần 1: Sidebar
|
||||
============================================ */
|
||||
.sidebar-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: #252526; /* Dark sidebar background */
|
||||
position: relative;
|
||||
min-width: 200px;
|
||||
max-width: 600px;
|
||||
border-right: 1px solid #3e3e42; /* Border giữa sidebar và editor+console */
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #252526;
|
||||
}
|
||||
|
||||
/* Accordion Items styles đã được di chuyển vào SidebarAccordionItem.razor.css */
|
||||
|
||||
/* Resizer cho sidebar (ngang) */
|
||||
.sidebar-resizer {
|
||||
width: 4px;
|
||||
background-color: transparent;
|
||||
cursor: col-resize;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar-resizer:hover {
|
||||
background-color: #007acc;
|
||||
}
|
||||
|
||||
.sidebar-resizer:active {
|
||||
background-color: #007acc;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Phần 2 và 3: Editor Area và Console Panel
|
||||
============================================ */
|
||||
.editor-console-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
background-color: #1e1e1e;
|
||||
}
|
||||
|
||||
/* Phần 2: Editor Area */
|
||||
.editor-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #1e1e1e;
|
||||
position: relative;
|
||||
min-height: 200px;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* Disable transition khi đang resize */
|
||||
.editor-area.resizing {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.editor-area-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: #cccccc;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.editor-area-content h6 {
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
margin: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #3e3e42;
|
||||
}
|
||||
|
||||
/* Resizer giữa Editor và Console (dọc) */
|
||||
.vertical-resizer {
|
||||
height: 4px;
|
||||
background-color: transparent;
|
||||
cursor: row-resize;
|
||||
z-index: 10;
|
||||
transition: background-color 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vertical-resizer:hover {
|
||||
background-color: #007acc;
|
||||
}
|
||||
|
||||
.vertical-resizer:active {
|
||||
background-color: #007acc;
|
||||
}
|
||||
|
||||
/* Phần 3: Console Panel */
|
||||
.console-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background-color: #1e1e1e;
|
||||
border-top: 1px solid #3e3e42;
|
||||
min-height: 200px; /* Chiều cao khởi tạo ban đầu */
|
||||
max-height: 80%;
|
||||
flex: 0 1 auto;
|
||||
height: 200px; /* Chiều cao mặc định */
|
||||
transition: min-height 0.3s ease-in-out, flex-basis 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* Disable transition khi đang resize để tránh delay */
|
||||
.console-panel.resizing {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.console-panel.collapsed {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
flex: 0 0 auto;
|
||||
flex-basis: auto;
|
||||
max-height: none;
|
||||
transition: height 0.3s ease-in-out, min-height 0.3s ease-in-out, flex-basis 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.sidebar-content::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.sidebar-content::-webkit-scrollbar-track {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.sidebar-content::-webkit-scrollbar-thumb {
|
||||
background: #424242;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.sidebar-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #4e4e4e;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar-container {
|
||||
min-width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.JSInterop;
|
||||
using RobotNet10.ScriptEditor.Helpers.Code;
|
||||
using RobotNet10.ScriptEditor.Helpers.Webcil;
|
||||
|
||||
namespace RobotNet10.ScriptEditor.Services;
|
||||
|
||||
public class ScriptResourceResolver
|
||||
{
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly IJSRuntime? jsRuntime;
|
||||
private readonly Lazy<Task<Dictionary<string, string>>> _resourceMappings;
|
||||
|
||||
public ScriptResourceResolver(HttpClient client, IJSRuntime? jsRuntime = null)
|
||||
{
|
||||
httpClient = client;
|
||||
this.jsRuntime = jsRuntime;
|
||||
_resourceMappings = new Lazy<Task<Dictionary<string, string>>>(FetchResourcesAsync);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<MetadataReference>> GetMetadataReferences(string[] wasModules, string[] docModules)
|
||||
{
|
||||
var metadataReferences = new List<MetadataReference>();
|
||||
foreach (var wasModule in wasModules)
|
||||
{
|
||||
var docModule = $"{wasModule}.xml";
|
||||
if(!docModules.Contains(docModule))
|
||||
{
|
||||
docModule = string.Empty;
|
||||
}
|
||||
metadataReferences.Add(await GetMetadataReferenceAsync(wasModule, docModule));
|
||||
}
|
||||
return metadataReferences;
|
||||
}
|
||||
|
||||
private async Task<PortableExecutableReference> GetMetadataReferenceAsync(string wasModule, string docModule)
|
||||
{
|
||||
await using var stream = await httpClient.GetStreamAsync(await ResolveResource($"{wasModule}.wasm"));
|
||||
var peBytes = await WebcilConverterUtil.ConvertFromWebcilAsync(stream);
|
||||
|
||||
using var peStream = new MemoryStream(peBytes);
|
||||
if (string.IsNullOrEmpty(docModule))
|
||||
{
|
||||
return MetadataReference.CreateFromStream(peStream, MetadataReferenceProperties.Assembly);
|
||||
}
|
||||
else
|
||||
{
|
||||
var docBuf = await httpClient.GetByteArrayAsync($"docs/{docModule}");
|
||||
return MetadataReference.CreateFromStream(peStream, MetadataReferenceProperties.Assembly, documentation: XmlDocumentationProvider.CreateFromBytes(docBuf));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> ResolveResource(string logicalName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(logicalName))
|
||||
throw new ArgumentException("Logical name cannot be null or empty.", nameof(logicalName));
|
||||
|
||||
var baseUri = httpClient.BaseAddress?.ToString().TrimEnd('/') ?? "";
|
||||
|
||||
// Strategy 1: Try JavaScript interop to get resource path from Blazor runtime (NET 10+)
|
||||
if (jsRuntime != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var jsPath = await jsRuntime.InvokeAsync<string>("robotnet.blazor.getResourcePath", logicalName);
|
||||
if (!string.IsNullOrEmpty(jsPath) && await TryResourceExists(jsPath))
|
||||
{
|
||||
return jsPath;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// JavaScript function might not be available, continue to other strategies
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Try direct path (NET 10+ common case)
|
||||
var directPath = $"{baseUri}/_framework/{logicalName}";
|
||||
if (await TryResourceExists(directPath))
|
||||
{
|
||||
return directPath;
|
||||
}
|
||||
|
||||
// Strategy 3: Try to get mapping from boot file (for NET 9 and earlier, or if direct path fails)
|
||||
var resources = await _resourceMappings.Value;
|
||||
if (resources.TryGetValue(logicalName, out var hashedName))
|
||||
{
|
||||
var hashedPath = $"{baseUri}/_framework/{hashedName}";
|
||||
if (await TryResourceExists(hashedPath))
|
||||
{
|
||||
return hashedPath;
|
||||
}
|
||||
}
|
||||
|
||||
throw new FileNotFoundException(
|
||||
$"Resource '{logicalName}' not found. " +
|
||||
$"Tried: JavaScript interop, direct path '{directPath}', " +
|
||||
$"and boot configuration mapping. " +
|
||||
$"In .NET 10, resources may be embedded in dotnet.js. " +
|
||||
$"Please ensure JavaScript function 'robotnet.blazor.getResourcePath' is available.");
|
||||
}
|
||||
|
||||
private async Task<bool> TryResourceExists(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await httpClient.SendAsync(
|
||||
new HttpRequestMessage(HttpMethod.Head, path),
|
||||
HttpCompletionOption.ResponseHeadersRead);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string>> FetchResourcesAsync()
|
||||
{
|
||||
// In NET 10+, boot files are no longer used - resources are accessed directly
|
||||
// This method is kept for backward compatibility with NET 9 and earlier
|
||||
// Return empty dictionary to indicate we should use direct paths
|
||||
var baseUri = httpClient.BaseAddress?.ToString().TrimEnd('/') ?? "";
|
||||
|
||||
// Try blazor.boot.config.json first (some NET 10 preview versions)
|
||||
var bootConfigUrl = $"{baseUri}/_framework/blazor.boot.config.json";
|
||||
try
|
||||
{
|
||||
var bootConfigContent = await httpClient.GetStringAsync(bootConfigUrl);
|
||||
return ParseBootConfigJson(bootConfigContent);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
// Fallback to blazor.boot.json (NET 9 and earlier)
|
||||
var bootJsonUrl = $"{baseUri}/_framework/blazor.boot.json";
|
||||
try
|
||||
{
|
||||
var bootJsonContent = await httpClient.GetStringAsync(bootJsonUrl);
|
||||
return ParseBootJson(bootJsonContent);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
// NET 10+: No boot file exists, use direct paths
|
||||
// Return empty dictionary - ResolveResource will use direct path
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, string> ParseBootConfigJson(string jsonContent)
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(jsonContent);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var allResources = new Dictionary<string, string>();
|
||||
|
||||
// NET 10+ uses different structure - check multiple possible locations
|
||||
if (root.TryGetProperty("resources", out var resources))
|
||||
{
|
||||
// Try to get fingerprinting resources (maps logical name -> hashed name)
|
||||
if (resources.TryGetProperty("fingerprinting", out var fingerprinting))
|
||||
{
|
||||
foreach (var prop in fingerprinting.EnumerateObject())
|
||||
{
|
||||
var logicalName = prop.Name;
|
||||
var hashedName = prop.Value.GetString();
|
||||
if (!string.IsNullOrEmpty(hashedName) && !allResources.ContainsKey(logicalName))
|
||||
{
|
||||
allResources[logicalName] = hashedName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for assembly resources directly (maps assembly name -> hashed name)
|
||||
if (resources.TryGetProperty("assembly", out var assembly))
|
||||
{
|
||||
foreach (var prop in assembly.EnumerateObject())
|
||||
{
|
||||
var logicalName = prop.Name;
|
||||
var hashedName = prop.Value.GetString();
|
||||
if (!string.IsNullOrEmpty(hashedName) && !allResources.ContainsKey(logicalName))
|
||||
{
|
||||
allResources[logicalName] = hashedName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for wasmNative resources (for .wasm files)
|
||||
if (resources.TryGetProperty("wasmNative", out var wasmNative))
|
||||
{
|
||||
foreach (var prop in wasmNative.EnumerateObject())
|
||||
{
|
||||
var logicalName = prop.Name;
|
||||
var hashedName = prop.Value.GetString();
|
||||
if (!string.IsNullOrEmpty(hashedName) && !allResources.ContainsKey(logicalName))
|
||||
{
|
||||
allResources[logicalName] = hashedName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check root level for direct mappings (some NET 10 versions might use this)
|
||||
if (root.TryGetProperty("fingerprinting", out var rootFingerprinting))
|
||||
{
|
||||
foreach (var prop in rootFingerprinting.EnumerateObject())
|
||||
{
|
||||
var logicalName = prop.Name;
|
||||
var hashedName = prop.Value.GetString();
|
||||
if (!string.IsNullOrEmpty(hashedName) && !allResources.ContainsKey(logicalName))
|
||||
{
|
||||
allResources[logicalName] = hashedName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allResources;
|
||||
}
|
||||
|
||||
private Dictionary<string, string> ParseBootJson(string jsonContent)
|
||||
{
|
||||
var bootJson = System.Text.Json.JsonSerializer.Deserialize<BlazorBootJson>(jsonContent);
|
||||
if (bootJson?.Resources?.Fingerprinting == null)
|
||||
{
|
||||
throw new InvalidOperationException("Invalid blazor.boot.json structure.");
|
||||
}
|
||||
|
||||
// Combine all relevant resources into one dictionary for easy lookup
|
||||
var allResources = new Dictionary<string, string>();
|
||||
|
||||
foreach (var resource in bootJson.Resources.Fingerprinting.Where(resource => !allResources.ContainsKey(resource.Value)))
|
||||
{
|
||||
allResources.Add(resource.Value, resource.Key);
|
||||
}
|
||||
|
||||
return allResources;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.Text;
|
||||
using RobotNet10.ScriptEditor.Helpers;
|
||||
using RobotNet10.ScriptEditor.Helpers.Monaco.Languages;
|
||||
using RobotNet10.ScriptEditor.Models;
|
||||
using RobotNet10.ScriptEngine.Shared;
|
||||
using System.Text;
|
||||
using System.Timers;
|
||||
|
||||
namespace RobotNet10.ScriptEditor.Services;
|
||||
|
||||
internal class ScriptWorkspace : IDisposable
|
||||
{
|
||||
public IEnumerable<ScriptFolder> Folders => WorkspaceFolders;
|
||||
public IEnumerable<ScriptFile> Files => WorkspaceFiles;
|
||||
|
||||
public event Action? ReadOnlyChanged;
|
||||
public event Action<IEnumerable<Diagnostic>>? DiagnoticChanged;
|
||||
public event Action? RootChanged;
|
||||
public event Action<ScriptFile?>? CurrentFileChanged;
|
||||
public event Action<ScriptFile?>? SelectedFileChanged;
|
||||
public event Action<ScriptFolder?>? SelectedFolderChanged;
|
||||
|
||||
public ScriptFile? CurrentFile { get; private set; }
|
||||
|
||||
private ScriptFile? _selectedFile;
|
||||
private ScriptFolder? _selectedFolder;
|
||||
private bool _isUpdatingSelection = false;
|
||||
|
||||
public ScriptFile? SelectedFile
|
||||
{
|
||||
get => _selectedFile;
|
||||
set
|
||||
{
|
||||
if (_selectedFile == value) return;
|
||||
if (_isUpdatingSelection) return;
|
||||
|
||||
_isUpdatingSelection = true;
|
||||
try
|
||||
{
|
||||
if (value is not null && CurrentFile != value)
|
||||
{
|
||||
CurrentFile = value;
|
||||
CurrentFileChanged?.Invoke(CurrentFile);
|
||||
}
|
||||
|
||||
var oldFolder = _selectedFile?.Parent;
|
||||
_selectedFile = value;
|
||||
|
||||
// Only update SelectedFolder if it's different
|
||||
if (_selectedFile?.Parent != oldFolder)
|
||||
{
|
||||
if (_selectedFolder != _selectedFile?.Parent)
|
||||
{
|
||||
_selectedFolder = _selectedFile?.Parent;
|
||||
SelectedFolderChanged?.Invoke(_selectedFolder);
|
||||
}
|
||||
}
|
||||
|
||||
SelectedFileChanged?.Invoke(_selectedFile);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isUpdatingSelection = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ScriptFolder? SelectedFolder
|
||||
{
|
||||
get => _selectedFolder;
|
||||
set
|
||||
{
|
||||
if (_selectedFolder == value) return;
|
||||
if (_isUpdatingSelection) return;
|
||||
|
||||
_isUpdatingSelection = true;
|
||||
try
|
||||
{
|
||||
_selectedFolder = value;
|
||||
|
||||
// Only clear SelectedFile if it's not null and not related to the new folder
|
||||
if (_selectedFile != null && _selectedFile.Parent != _selectedFolder)
|
||||
{
|
||||
_selectedFile = null;
|
||||
SelectedFileChanged?.Invoke(null);
|
||||
}
|
||||
|
||||
SelectedFolderChanged?.Invoke(_selectedFolder);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isUpdatingSelection = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsInitialized { get; private set; }
|
||||
|
||||
private bool _isReadOnly;
|
||||
|
||||
public bool IsReadOnly
|
||||
{
|
||||
get => _isReadOnly;
|
||||
set
|
||||
{
|
||||
if (_isReadOnly == value) return;
|
||||
|
||||
_isReadOnly = value;
|
||||
ReadOnlyChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
private readonly CSharpParseOptions WorkspaceParseOptions = CSharpParseOptions.Default.WithKind(SourceCodeKind.Script).WithLanguageVersion(LanguageVersion.Latest);
|
||||
private readonly List<ScriptFolder> WorkspaceFolders = [];
|
||||
private readonly List<ScriptFile> WorkspaceFiles = [];
|
||||
private readonly AdhocWorkspace adhocWorkspace = new();
|
||||
private readonly ProjectId ProjectId = ProjectId.CreateNewId();
|
||||
private readonly System.Timers.Timer DiagnosticTimer = new(1000) { AutoReset = false };
|
||||
|
||||
public ScriptWorkspace()
|
||||
{
|
||||
DiagnosticTimer.Elapsed += DiagnosticTimer_Elapsed;
|
||||
}
|
||||
|
||||
public void Initialize(IEnumerable<MetadataReference> references, string[] usingNamespaces, Type globalType, ScriptFolderDto rootFolder)
|
||||
{
|
||||
if (IsInitialized) throw new InvalidOperationException("Workspace đã được khởi tạo");
|
||||
|
||||
var preCode = $"{BuildDevelopGlobalsScript(typeof(IScriptGlobals))}\n{BuildDevelopGlobalsScript(globalType)}";
|
||||
|
||||
var csharpCompilationOptions = new CSharpCompilationOptions(
|
||||
OutputKind.DynamicallyLinkedLibrary,
|
||||
usings: usingNamespaces,
|
||||
metadataImportOptions: MetadataImportOptions.All,
|
||||
reportSuppressedDiagnostics: true);
|
||||
//.WithEmitDebugInformation(false); // Disable debug info to avoid Mono debugger agent assertions
|
||||
|
||||
var projectInfo = ProjectInfo.Create(ProjectId, VersionStamp.Create(), "ScriptEditor", "ScriptEditorAssembly", LanguageNames.CSharp)
|
||||
.WithMetadataReferences(references)
|
||||
.WithCompilationOptions(csharpCompilationOptions)
|
||||
.WithParseOptions(WorkspaceParseOptions);
|
||||
|
||||
Solution updatedSolution = adhocWorkspace.CurrentSolution.AddProject(projectInfo);
|
||||
var preScriptDocId = DocumentId.CreateNewId(ProjectId);
|
||||
updatedSolution = updatedSolution.AddDocument(preScriptDocId, "PreScript.cs", SourceText.From(preCode), ["/"], "/PreScript.cs");
|
||||
if (!adhocWorkspace.TryApplyChanges(updatedSolution))
|
||||
throw new InvalidOperationException("Add pre script document thất bại");
|
||||
|
||||
WorkspaceFiles.AddRange(rootFolder.Files.Select(file => CreateWrokspaceFile(file)));
|
||||
WorkspaceFiles.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
|
||||
|
||||
WorkspaceFolders.AddRange(rootFolder.Folders.Select(folder => CreateWorkspaceFolder(folder)));
|
||||
WorkspaceFolders.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
|
||||
|
||||
_ = Task.Run(DiagnosticProject);
|
||||
RootChanged?.Invoke();
|
||||
IsInitialized = true;
|
||||
}
|
||||
|
||||
public async Task ReinitializeAsync(ScriptFolderDto rootFolder)
|
||||
{
|
||||
if (!IsInitialized) throw new InvalidOperationException("Workspace chưa được khởi tạo");
|
||||
|
||||
// Xóa tất cả documents hiện tại (trừ PreScript.cs)
|
||||
Solution updatedSolution = adhocWorkspace.CurrentSolution;
|
||||
var project = updatedSolution.GetProject(ProjectId);
|
||||
if (project is not null)
|
||||
{
|
||||
foreach (var doc in project.Documents)
|
||||
{
|
||||
if (doc.Name != "PreScript.cs")
|
||||
{
|
||||
updatedSolution = updatedSolution.RemoveDocument(doc.Id);
|
||||
}
|
||||
}
|
||||
if (!adhocWorkspace.TryApplyChanges(updatedSolution))
|
||||
throw new InvalidOperationException("Xóa documents cũ thất bại");
|
||||
}
|
||||
|
||||
// Clear workspace files và folders
|
||||
WorkspaceFiles.Clear();
|
||||
WorkspaceFolders.Clear();
|
||||
CurrentFile = null;
|
||||
SelectedFile = null;
|
||||
SelectedFolder = null;
|
||||
|
||||
// Reload lại từ rootFolder mới
|
||||
WorkspaceFiles.AddRange(rootFolder.Files.Select(file => CreateWrokspaceFile(file)));
|
||||
WorkspaceFiles.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
|
||||
|
||||
WorkspaceFolders.AddRange(rootFolder.Folders.Select(folder => CreateWorkspaceFolder(folder)));
|
||||
WorkspaceFolders.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
|
||||
|
||||
// Chạy diagnostic ngay lập tức để phân tích lỗi của source code sau khi restore
|
||||
await DiagnosticProject();
|
||||
|
||||
RootChanged?.Invoke();
|
||||
CurrentFileChanged?.Invoke(CurrentFile);
|
||||
}
|
||||
|
||||
public void WriteDocument(string text)
|
||||
{
|
||||
if (CurrentFile is null) return;
|
||||
|
||||
Solution updatedSolution = adhocWorkspace.CurrentSolution;
|
||||
updatedSolution = updatedSolution.WithDocumentText(CurrentFile.Id, SourceText.From(text));
|
||||
if (!adhocWorkspace.TryApplyChanges(updatedSolution)) throw new InvalidOperationException("Cập nhật project ban đầu thất bại");
|
||||
|
||||
CurrentFile.Code = text;
|
||||
DiagnosticTimer.Stop();
|
||||
DiagnosticTimer.Start();
|
||||
}
|
||||
|
||||
public string FormatCode(string code)
|
||||
{
|
||||
var tree = CSharpSyntaxTree.ParseText(code, WorkspaceParseOptions);
|
||||
var root = tree.GetRoot();
|
||||
var formattedRoot = Microsoft.CodeAnalysis.Formatting.Formatter.Format(root, adhocWorkspace);
|
||||
return formattedRoot.ToFullString();
|
||||
}
|
||||
|
||||
public async Task<string?> GetQuickInfoCurrentFile(int line, int column)
|
||||
{
|
||||
if (CurrentFile is null) return null;
|
||||
|
||||
return await adhocWorkspace.GetQuickInfoAsync(CurrentFile.Id, line, column);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BlazorMonaco.Languages.CompletionItem>> GetCompletionsCurrentFile(int line, int column, int kind, char? triggerCharacter)
|
||||
{
|
||||
if (CurrentFile is null) return [];
|
||||
|
||||
return await adhocWorkspace.GetCompletionAsync(CurrentFile.Id, line, column, kind, triggerCharacter);
|
||||
}
|
||||
|
||||
public async Task<SignatureHelpResult?> GetSignatureHelpCurrentFile(int line, int column)
|
||||
{
|
||||
if (CurrentFile is null) return null;
|
||||
|
||||
var document = adhocWorkspace.CurrentSolution.GetDocument(CurrentFile.Id);
|
||||
if (document is null) return null;
|
||||
|
||||
return await document.GetSignatureHelpAsync(line, column);
|
||||
}
|
||||
|
||||
public void AddFile(ScriptFileDto fileDto, ScriptFolder? parent = null)
|
||||
{
|
||||
var workspaceFile = CreateWrokspaceFile(fileDto, parent);
|
||||
|
||||
if (parent is null)
|
||||
{
|
||||
WorkspaceFiles.Add(workspaceFile);
|
||||
WorkspaceFiles.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
|
||||
}
|
||||
else
|
||||
{
|
||||
parent.AddFiles(workspaceFile);
|
||||
}
|
||||
|
||||
RootChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void AddFolder(ScriptFolderDto folderDto, ScriptFolder? parent = null)
|
||||
{
|
||||
var workspaceFolder = CreateWorkspaceFolder(folderDto, parent);
|
||||
|
||||
if (parent is null)
|
||||
{
|
||||
WorkspaceFolders.Add(workspaceFolder);
|
||||
WorkspaceFolders.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
|
||||
}
|
||||
else
|
||||
{
|
||||
parent.AddFolders(workspaceFolder);
|
||||
}
|
||||
|
||||
RootChanged?.Invoke();
|
||||
}
|
||||
|
||||
public ScriptFile? FindFileByPath(string path)
|
||||
{
|
||||
// Search in root files
|
||||
foreach (var file in WorkspaceFiles)
|
||||
{
|
||||
if (file.Path == path) return file;
|
||||
}
|
||||
|
||||
// Search in folders recursively
|
||||
foreach (var folder in WorkspaceFolders)
|
||||
{
|
||||
var found = FindFileInFolder(folder, path);
|
||||
if (found != null) return found;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public ScriptFolder? FindFolderByPath(string path)
|
||||
{
|
||||
// Search in root folders
|
||||
foreach (var folder in WorkspaceFolders)
|
||||
{
|
||||
if (folder.Path == path) return folder;
|
||||
|
||||
var found = FindFolderInFolder(folder, path);
|
||||
if (found != null) return found;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private ScriptFile? FindFileInFolder(ScriptFolder folder, string path)
|
||||
{
|
||||
foreach (var file in folder.Files)
|
||||
{
|
||||
if (file.Path == path) return file;
|
||||
}
|
||||
|
||||
foreach (var subFolder in folder.Folders)
|
||||
{
|
||||
var found = FindFileInFolder(subFolder, path);
|
||||
if (found != null) return found;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private ScriptFolder? FindFolderInFolder(ScriptFolder folder, string path)
|
||||
{
|
||||
foreach (var subFolder in folder.Folders)
|
||||
{
|
||||
if (subFolder.Path == path) return subFolder;
|
||||
|
||||
var found = FindFolderInFolder(subFolder, path);
|
||||
if (found != null) return found;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public void RemoveFile(ScriptFile file)
|
||||
{
|
||||
if (file.Parent is null)
|
||||
{
|
||||
WorkspaceFiles.Remove(file);
|
||||
}
|
||||
else
|
||||
{
|
||||
file.Parent.RemoveFile(file);
|
||||
}
|
||||
|
||||
if (CurrentFile == file)
|
||||
{
|
||||
CurrentFile = null;
|
||||
CurrentFileChanged?.Invoke(CurrentFile);
|
||||
}
|
||||
|
||||
if (SelectedFile == file)
|
||||
{
|
||||
SelectedFile = null;
|
||||
}
|
||||
|
||||
Solution updatedSolution = adhocWorkspace.CurrentSolution;
|
||||
updatedSolution = updatedSolution.RemoveDocument(file.Id);
|
||||
|
||||
if (!adhocWorkspace.TryApplyChanges(updatedSolution))
|
||||
throw new InvalidOperationException($"Xóa file {file.Path} trong workspace thất bại");
|
||||
|
||||
DiagnosticTimer.Stop();
|
||||
DiagnosticTimer.Start();
|
||||
|
||||
RootChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void RemoveFolder(ScriptFolder folder)
|
||||
{
|
||||
if (folder.Parent is null)
|
||||
{
|
||||
WorkspaceFolders.Remove(folder);
|
||||
}
|
||||
else
|
||||
{
|
||||
folder.Parent.RemoveFolder(folder);
|
||||
}
|
||||
|
||||
// Clear selection if selected file/folder is in the deleted folder
|
||||
if (SelectedFile != null && SelectedFile.Path.StartsWith(folder.Path + System.IO.Path.DirectorySeparatorChar))
|
||||
{
|
||||
SelectedFile = null;
|
||||
}
|
||||
|
||||
if (SelectedFolder != null && (SelectedFolder.Path == folder.Path || SelectedFolder.Path.StartsWith(folder.Path + System.IO.Path.DirectorySeparatorChar)))
|
||||
{
|
||||
SelectedFolder = null;
|
||||
}
|
||||
|
||||
Solution updatedSolution = adhocWorkspace.CurrentSolution;
|
||||
RemoveFolderFromWorkspace(updatedSolution, folder);
|
||||
if (!adhocWorkspace.TryApplyChanges(updatedSolution))
|
||||
throw new InvalidOperationException($"Xóa folder {folder.Path} trong workspace thất bại");
|
||||
|
||||
DiagnosticTimer.Stop();
|
||||
DiagnosticTimer.Start();
|
||||
|
||||
RootChanged?.Invoke();
|
||||
}
|
||||
|
||||
private ScriptFile CreateWrokspaceFile(ScriptFileDto file, ScriptFolder? parent = null)
|
||||
{
|
||||
var filePath = System.IO.Path.Combine(parent?.Path ?? "", file.Name);
|
||||
|
||||
Solution updatedSolution = adhocWorkspace.CurrentSolution;
|
||||
var newId = DocumentId.CreateNewId(ProjectId);
|
||||
|
||||
updatedSolution = updatedSolution.AddDocument(newId, file.Name, SourceText.From(file.Code), parent?.Path.Split("/"), filePath);
|
||||
|
||||
if (!adhocWorkspace.TryApplyChanges(updatedSolution)) throw new InvalidOperationException("Tạo document mới thất bại");
|
||||
if (!string.IsNullOrEmpty(file.Code))
|
||||
{
|
||||
DiagnosticTimer.Stop();
|
||||
DiagnosticTimer.Start();
|
||||
}
|
||||
var scriptFile = new ScriptFile(newId, file, parent);
|
||||
return scriptFile;
|
||||
}
|
||||
|
||||
private ScriptFolder CreateWorkspaceFolder(ScriptFolderDto folder, ScriptFolder? parent = null)
|
||||
{
|
||||
var folderPath = System.IO.Path.Combine(parent?.Path ?? "", folder.Name);
|
||||
var model = new ScriptFolder(folder, parent);
|
||||
// Materialize the Select to avoid multiple enumerations
|
||||
var files = folder.Files.Select(file => CreateWrokspaceFile(file, model)).ToList();
|
||||
model.AddFiles(files);
|
||||
// Materialize the Select to avoid multiple enumerations
|
||||
var subfolders = folder.Folders.Select(dir => CreateWorkspaceFolder(dir, model)).ToList();
|
||||
model.AddFolders(subfolders);
|
||||
return model;
|
||||
}
|
||||
|
||||
private void RemoveFolderFromWorkspace(Solution updatedSolution, ScriptFolder folder)
|
||||
{
|
||||
foreach (var dir in folder.Folders)
|
||||
{
|
||||
RemoveFolderFromWorkspace(updatedSolution, dir);
|
||||
}
|
||||
foreach (var file in folder.Files)
|
||||
{
|
||||
if (CurrentFile == file)
|
||||
{
|
||||
CurrentFile = null;
|
||||
CurrentFileChanged?.Invoke(CurrentFile);
|
||||
}
|
||||
updatedSolution = updatedSolution.RemoveDocument(file.Id);
|
||||
}
|
||||
DiagnosticTimer.Stop();
|
||||
DiagnosticTimer.Start();
|
||||
}
|
||||
|
||||
private async Task DiagnosticProject()
|
||||
{
|
||||
var editorProject = adhocWorkspace.CurrentSolution.GetProject(ProjectId);
|
||||
if (editorProject == null) return;
|
||||
|
||||
var compilation = await editorProject.GetCompilationAsync();
|
||||
var diagnostics = compilation?.GetDiagnostics() ?? [];
|
||||
|
||||
foreach (var file in Files)
|
||||
{
|
||||
await GetDiagnosticsToModel(editorProject, diagnostics, file);
|
||||
}
|
||||
|
||||
foreach (var folder in Folders)
|
||||
{
|
||||
await GetDiagnosticsToModel(editorProject, diagnostics, folder);
|
||||
}
|
||||
|
||||
if (CurrentFile is not null)
|
||||
{
|
||||
DiagnoticChanged?.Invoke(CurrentFile.Diagnostics);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task GetDiagnosticsToModel(Project project, IEnumerable<Diagnostic> diagnostics, ScriptFile file)
|
||||
{
|
||||
var document = project.Solution.GetDocument(file.Id);
|
||||
if (document == null) return;
|
||||
|
||||
var syntaxTree = await document.GetSyntaxTreeAsync();
|
||||
if (syntaxTree == null) return;
|
||||
|
||||
file.Diagnostics = diagnostics.Where(d => d.Location.IsInSource && d.Location.SourceTree == syntaxTree);
|
||||
}
|
||||
|
||||
private static async Task GetDiagnosticsToModel(Project project, IEnumerable<Diagnostic> diagnostics, ScriptFolder folder)
|
||||
{
|
||||
foreach (var file in folder.Files)
|
||||
{
|
||||
await GetDiagnosticsToModel(project, diagnostics, file);
|
||||
}
|
||||
foreach (var subFolder in folder.Folders)
|
||||
{
|
||||
await GetDiagnosticsToModel(project, diagnostics, subFolder);
|
||||
}
|
||||
|
||||
// Recalculate totals for this folder after all children have been updated
|
||||
RecalculateFolderTotals(folder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively recalculates WarningCount, ErrorCount, and IsModified for a folder and all its children.
|
||||
/// </summary>
|
||||
private static void RecalculateFolderTotals(ScriptFolder folder)
|
||||
{
|
||||
folder.RecalculateTotals();
|
||||
}
|
||||
|
||||
private void DiagnosticTimer_Elapsed(object? sender, ElapsedEventArgs e)
|
||||
{
|
||||
Task.Run(DiagnosticProject).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Stop and dispose the diagnostic timer
|
||||
DiagnosticTimer.Stop();
|
||||
DiagnosticTimer.Elapsed -= DiagnosticTimer_Elapsed;
|
||||
DiagnosticTimer.Dispose();
|
||||
|
||||
// Dispose the adhoc workspace
|
||||
adhocWorkspace.Dispose();
|
||||
|
||||
// Clear event handlers to prevent memory leaks
|
||||
ReadOnlyChanged = null;
|
||||
DiagnoticChanged = null;
|
||||
RootChanged = null;
|
||||
|
||||
// Clear collections
|
||||
WorkspaceFiles.Clear();
|
||||
WorkspaceFolders.Clear();
|
||||
|
||||
// Reset state
|
||||
IsInitialized = false;
|
||||
SelectedFile = null;
|
||||
SelectedFolder = null;
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static string BuildDevelopGlobalsScript(Type glovalType)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Build properties
|
||||
foreach (var property in glovalType.GetProperties())
|
||||
{
|
||||
if (property.CanRead)
|
||||
{
|
||||
var setter = property.CanWrite ? "set; " : "";
|
||||
sb.AppendLine($"{ScriptHelpers.ToString(property.PropertyType)} {property.Name} {{ get => throw new System.NotImplementedException(); {setter}}}");
|
||||
}
|
||||
else if (property.CanWrite)
|
||||
{
|
||||
sb.AppendLine($"{ScriptHelpers.ToString(property.PropertyType)} {property.Name} {{ set => throw new System.NotImplementedException(); }}");
|
||||
}
|
||||
}
|
||||
|
||||
// Build fields
|
||||
foreach (var field in glovalType.GetFields())
|
||||
{
|
||||
sb.AppendLine($"{ScriptHelpers.ToString(field.FieldType)} {field.Name};");
|
||||
}
|
||||
|
||||
// Build methods
|
||||
foreach (var method in glovalType.GetMethods())
|
||||
{
|
||||
if (method.Name.StartsWith("get_") || method.Name.StartsWith("set_")) continue;
|
||||
|
||||
var parameters = string.Join(',', method.GetParameters().Select(parameter => ScriptHelpers.ToString(parameter)));
|
||||
sb.AppendLine($"{ScriptHelpers.ToString(method.ReturnType)} {method.Name}({parameters}) => throw new System.NotImplementedException();");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
@using RobotNet10.ScriptEditor.Clients
|
||||
@using RobotNet10.ScriptEditor.Components
|
||||
@using RobotNet10.ScriptEngine.Shared
|
||||
@using RobotNet10.Shared
|
||||
@using MudBlazor
|
||||
@inject ScriptManagerHubClient ScriptManagerClient
|
||||
@inject ISnackbar Snackbar
|
||||
@implements IDisposable
|
||||
|
||||
<SidebarAccordionItem TabName="tasks" Label="TASKS">
|
||||
<HeaderActions>
|
||||
<IconButton Icon="refresh" Title="Refresh" OnClick="HandleRefresh" />
|
||||
</HeaderActions>
|
||||
<ChildContent>
|
||||
@if (_tasks == null || _tasks.Count == 0)
|
||||
{
|
||||
<div class="tasks-empty-state">
|
||||
<MudText Typo="Typo.body2" Class="text-secondary">
|
||||
@if (_isLoading)
|
||||
{
|
||||
<span>Loading tasks...</span>
|
||||
}
|
||||
else if (_currentState == ScriptEngineState.Ready || _currentState == ScriptEngineState.Running)
|
||||
{
|
||||
<span>No tasks available</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Tasks available when engine is Ready or Running</span>
|
||||
}
|
||||
</MudText>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="tasks-list">
|
||||
@foreach (var task in _tasks)
|
||||
{
|
||||
<TaskItem @key="@task.Name"
|
||||
Task="@task"/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ChildContent>
|
||||
</SidebarAccordionItem>
|
||||
|
||||
@code {
|
||||
private List<ScriptTaskDto> _tasks = new();
|
||||
private ScriptEngineState _currentState = ScriptEngineState.Initializing;
|
||||
private bool _isLoading = false;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// Subscribe to state changes
|
||||
ScriptManagerClient.StateChanged += OnStateChanged;
|
||||
_currentState = ScriptManagerClient.State;
|
||||
|
||||
// Load tasks if state is already Ready or Running
|
||||
if (_currentState == ScriptEngineState.Ready || _currentState == ScriptEngineState.Running)
|
||||
{
|
||||
_ = LoadTasksAsync();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnAfterRender(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
// Ensure we have the latest state
|
||||
_currentState = ScriptManagerClient.State;
|
||||
if (_currentState == ScriptEngineState.Ready || _currentState == ScriptEngineState.Running)
|
||||
{
|
||||
_ = LoadTasksAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnStateChanged(ScriptEngineState newState)
|
||||
{
|
||||
_currentState = newState;
|
||||
|
||||
if (newState == ScriptEngineState.Ready || newState == ScriptEngineState.Running)
|
||||
{
|
||||
await LoadTasksAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Clear tasks when not in Ready or Running state
|
||||
_tasks.Clear();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadTasksAsync()
|
||||
{
|
||||
if (!ScriptManagerClient.IsConnected)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_isLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
var tasks = await ScriptManagerClient.GetScriptTasksAsync();
|
||||
_tasks = tasks?.ToList() ?? new List<ScriptTaskDto>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to load tasks: {ex.Message}", Severity.Error);
|
||||
_tasks = new List<ScriptTaskDto>();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleRefresh(MouseEventArgs e)
|
||||
{
|
||||
if (_currentState == ScriptEngineState.Ready || _currentState == ScriptEngineState.Running)
|
||||
{
|
||||
await LoadTasksAsync();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ScriptManagerClient.StateChanged -= OnStateChanged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* ============================================
|
||||
TaskManager Component Styles
|
||||
============================================ */
|
||||
|
||||
.tasks-empty-state {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: #858585;
|
||||
}
|
||||
|
||||
.tasks-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
@using RobotNet10.ScriptEditor.Clients
|
||||
@using RobotNet10.ScriptEditor.Dialogs
|
||||
@using RobotNet10.ScriptEngine.Shared
|
||||
@using MudBlazor
|
||||
@inject ScriptManagerHubClient ScriptManagerClient
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@implements IDisposable
|
||||
|
||||
<SidebarAccordionItem TabName="variables" Label="VARIABLES">
|
||||
<HeaderActions>
|
||||
<IconButton Icon="refresh" Title="Refresh" OnClick="HandleRefresh" />
|
||||
</HeaderActions>
|
||||
<ChildContent>
|
||||
@if (_variables == null || _variables.Count == 0)
|
||||
{
|
||||
<div class="variables-empty-state">
|
||||
<MudText Typo="Typo.body2" Class="text-secondary">
|
||||
@if (_isLoading)
|
||||
{
|
||||
<span>Loading variables...</span>
|
||||
}
|
||||
else if (_currentState == ScriptEngineState.Ready || _currentState == ScriptEngineState.Running)
|
||||
{
|
||||
<span>No variables available</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Variables available when engine is Ready or Running</span>
|
||||
}
|
||||
</MudText>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="variables-list">
|
||||
@foreach (var variable in _variables)
|
||||
{
|
||||
<div class="variable-item">
|
||||
<div class="variable-info">
|
||||
<div class="variable-name">@variable.Name</div>
|
||||
<div class="variable-type">@variable.TypeName</div>
|
||||
<div class="variable-value">@variable.Value</div>
|
||||
</div>
|
||||
@if (_currentState == ScriptEngineState.Running && variable.Writeable && IsPrimitiveType(variable.TypeName))
|
||||
{
|
||||
<IconButton Icon="pencil" Title="Edit Value" OnClick="@(e => HandleEditVariable(variable))" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ChildContent>
|
||||
</SidebarAccordionItem>
|
||||
|
||||
@code {
|
||||
private List<ScriptVariableDto> _variables = new();
|
||||
private ScriptEngineState _currentState = ScriptEngineState.Initializing;
|
||||
private bool _isLoading = false;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// Subscribe to state changes
|
||||
ScriptManagerClient.StateChanged += OnStateChanged;
|
||||
_currentState = ScriptManagerClient.State;
|
||||
|
||||
// Load variables if state is already Ready or Running
|
||||
if (_currentState == ScriptEngineState.Ready || _currentState == ScriptEngineState.Running)
|
||||
{
|
||||
_ = LoadVariablesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnAfterRender(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
// Ensure we have the latest state
|
||||
_currentState = ScriptManagerClient.State;
|
||||
if (_currentState == ScriptEngineState.Ready || _currentState == ScriptEngineState.Running)
|
||||
{
|
||||
_ = LoadVariablesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnStateChanged(ScriptEngineState newState)
|
||||
{
|
||||
_currentState = newState;
|
||||
|
||||
if (newState == ScriptEngineState.Ready || newState == ScriptEngineState.Running)
|
||||
{
|
||||
await LoadVariablesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Clear variables when not in Ready or Running state
|
||||
_variables.Clear();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadVariablesAsync()
|
||||
{
|
||||
if (!ScriptManagerClient.IsConnected)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_isLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
var variables = await ScriptManagerClient.GetScriptVariablesAsync();
|
||||
_variables = variables?.ToList() ?? new List<ScriptVariableDto>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to load variables: {ex.Message}", Severity.Error);
|
||||
_variables = new List<ScriptVariableDto>();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleRefresh(MouseEventArgs e)
|
||||
{
|
||||
if (_currentState == ScriptEngineState.Ready || _currentState == ScriptEngineState.Running)
|
||||
{
|
||||
await LoadVariablesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleEditVariable(ScriptVariableDto variable)
|
||||
{
|
||||
var parameters = new DialogParameters<EditVariableDialog>
|
||||
{
|
||||
{ x => x.VariableName, variable.Name },
|
||||
{ x => x.TypeName, variable.TypeName },
|
||||
{ x => x.CurrentValue, variable.Value }
|
||||
};
|
||||
|
||||
var options = new DialogOptions
|
||||
{
|
||||
CloseOnEscapeKey = true,
|
||||
MaxWidth = MaxWidth.Small,
|
||||
FullWidth = true
|
||||
};
|
||||
|
||||
var dialog = await DialogService.ShowAsync<EditVariableDialog>("Edit Variable", parameters, options);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result is not null && !result.Canceled && result.Data is string newValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
var messageResult = await ScriptManagerClient.SetValueAsync(variable.Name, newValue);
|
||||
|
||||
if (messageResult.IsSuccess)
|
||||
{
|
||||
Snackbar.Add($"Variable '{variable.Name}' updated successfully", Severity.Success);
|
||||
// Reload variables to get updated values
|
||||
await LoadVariablesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Failed to update variable: {messageResult.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error updating variable: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPrimitiveType(string typeName)
|
||||
{
|
||||
// Check if type is a primitive type (excluding string and object)
|
||||
var primitiveTypes = new HashSet<string>
|
||||
{
|
||||
"bool", "System.Boolean",
|
||||
"byte", "System.Byte",
|
||||
"sbyte", "System.SByte",
|
||||
"short", "System.Int16",
|
||||
"ushort", "System.UInt16",
|
||||
"int", "System.Int32",
|
||||
"uint", "System.UInt32",
|
||||
"long", "System.Int64",
|
||||
"ulong", "System.UInt64",
|
||||
"double", "System.Single",
|
||||
"double", "System.Double",
|
||||
"decimal", "System.Decimal",
|
||||
"char", "System.Char"
|
||||
};
|
||||
|
||||
// Also check if it's an enum type
|
||||
var type = ScriptHelpers.ResolveTypeFromString(typeName);
|
||||
if (type != null && type.IsEnum)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return primitiveTypes.Contains(typeName);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ScriptManagerClient.StateChanged -= OnStateChanged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/* ============================================
|
||||
VariableManager Component Styles
|
||||
============================================ */
|
||||
|
||||
.variables-empty-state {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: #858585;
|
||||
}
|
||||
|
||||
.variables-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.variable-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #2d2d30;
|
||||
transition: background-color 0.15s ease;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.variable-item:hover {
|
||||
background-color: #252526;
|
||||
}
|
||||
|
||||
.variable-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.variable-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0; /* Allow text truncation */
|
||||
}
|
||||
|
||||
.variable-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #cccccc;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.variable-type {
|
||||
font-size: 10px;
|
||||
color: #858585;
|
||||
font-style: italic;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.variable-value {
|
||||
font-size: 11px;
|
||||
color: #4ec9b0;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Scrollbar for variable value */
|
||||
.variable-value::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.variable-value::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.variable-value::-webkit-scrollbar-thumb {
|
||||
background: #424242;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.variable-value::-webkit-scrollbar-thumb:hover {
|
||||
background: #4e4e4e;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using MudBlazor
|
||||
@using RobotNet10.Components
|
||||
@using RobotNet10.ScriptEditor.Components
|
||||
@@ -0,0 +1,111 @@
|
||||
// File Explorer JavaScript functions
|
||||
|
||||
let escapeKeyHandler = null;
|
||||
|
||||
export function registerEscapeKeyHandler(dotNetHelper) {
|
||||
// Remove existing handler if any
|
||||
if (escapeKeyHandler) {
|
||||
document.removeEventListener('keydown', escapeKeyHandler);
|
||||
}
|
||||
|
||||
// Create new handler
|
||||
escapeKeyHandler = (e) => {
|
||||
if (e.key === 'Escape' || e.key === 'Esc') {
|
||||
dotNetHelper.invokeMethodAsync('HandleEscapeKey');
|
||||
}
|
||||
};
|
||||
|
||||
// Register handler
|
||||
document.addEventListener('keydown', escapeKeyHandler);
|
||||
}
|
||||
|
||||
export function unregisterEscapeKeyHandler() {
|
||||
if (escapeKeyHandler) {
|
||||
document.removeEventListener('keydown', escapeKeyHandler);
|
||||
escapeKeyHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function init(treeContainerRef) {
|
||||
// Initialize file explorer if needed
|
||||
}
|
||||
|
||||
export function updateFolderBadges(folderPath, hasWarnings, hasErrors, isModified, warningCount, errorCount, folderName) {
|
||||
// Update folder badges via DOM manipulation
|
||||
const folderElement = document.querySelector(`[data-folder-path="${folderPath}"]`);
|
||||
if (folderElement) {
|
||||
const warningBadge = folderElement.querySelector('[data-badge="warning"]');
|
||||
const errorBadge = folderElement.querySelector('[data-badge="error"]');
|
||||
const modifiedBadge = folderElement.querySelector('[data-badge="modified"]');
|
||||
|
||||
if (warningBadge) {
|
||||
warningBadge.style.display = hasWarnings ? 'inline' : 'none';
|
||||
warningBadge.textContent = warningCount;
|
||||
warningBadge.setAttribute('data-count', warningCount);
|
||||
}
|
||||
|
||||
if (errorBadge) {
|
||||
errorBadge.style.display = hasErrors ? 'inline' : 'none';
|
||||
errorBadge.textContent = errorCount;
|
||||
errorBadge.setAttribute('data-count', errorCount);
|
||||
}
|
||||
|
||||
if (modifiedBadge) {
|
||||
modifiedBadge.style.display = isModified ? 'inline' : 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function updateFileBadges(filePath, hasWarnings, hasErrors, isModified, warningCount, errorCount, fileName) {
|
||||
// Update file badges via DOM manipulation
|
||||
const fileElement = document.querySelector(`[data-file-path="${filePath}"]`);
|
||||
if (fileElement) {
|
||||
const warningBadge = fileElement.querySelector('[data-badge="warning"]');
|
||||
const errorBadge = fileElement.querySelector('[data-badge="error"]');
|
||||
const modifiedBadge = fileElement.querySelector('[data-badge="modified"]');
|
||||
|
||||
if (warningBadge) {
|
||||
warningBadge.style.display = hasWarnings ? 'inline' : 'none';
|
||||
warningBadge.textContent = warningCount;
|
||||
warningBadge.setAttribute('data-count', warningCount);
|
||||
}
|
||||
|
||||
if (errorBadge) {
|
||||
errorBadge.style.display = hasErrors ? 'inline' : 'none';
|
||||
errorBadge.textContent = errorCount;
|
||||
errorBadge.setAttribute('data-count', errorCount);
|
||||
}
|
||||
|
||||
if (modifiedBadge) {
|
||||
modifiedBadge.style.display = isModified ? 'inline' : 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function UncheckRadioByName(radioName) {
|
||||
const radios = document.querySelectorAll(`input[type="radio"][name="${radioName}"]`);
|
||||
radios.forEach(radio => {
|
||||
radio.checked = false;
|
||||
});
|
||||
}
|
||||
|
||||
export function CheckRadioById(itemId) {
|
||||
const radio = document.getElementById(itemId);
|
||||
if (radio) {
|
||||
radio.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
export function setElementPosition(elementId, x, y) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (element) {
|
||||
element.style.position = 'fixed';
|
||||
element.style.left = x + 'px';
|
||||
element.style.top = y + 'px';
|
||||
element.style.width = '1px';
|
||||
element.style.height = '1px';
|
||||
element.style.pointerEvents = 'none';
|
||||
element.style.zIndex = '-1';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
// Layout handlers cho ScriptEditor - xử lý tất cả logic layout bằng JavaScript
|
||||
|
||||
let sidebarResizer = null;
|
||||
let consoleResizer = null;
|
||||
let isResizingSidebar = false;
|
||||
let isResizingConsole = false;
|
||||
let accordionHeaders = [];
|
||||
let activeTab = null;
|
||||
|
||||
export function initializeLayout() {
|
||||
initializeResizeHandlers();
|
||||
initializeAccordion();
|
||||
}
|
||||
|
||||
function initializeAccordion() {
|
||||
// Reset state
|
||||
activeTab = null;
|
||||
|
||||
// Tìm tất cả accordion headers
|
||||
accordionHeaders = document.querySelectorAll('.sidebar-accordion-header');
|
||||
|
||||
accordionHeaders.forEach(header => {
|
||||
header.addEventListener('click', handleAccordionClick);
|
||||
});
|
||||
|
||||
// Mở tab đầu tiên mặc định (workspace)
|
||||
const firstTab = document.querySelector('[data-tab="workspace"]');
|
||||
if (firstTab) {
|
||||
toggleAccordionTab(firstTab, true);
|
||||
activeTab = 'workspace'; // Quan trọng: set activeTab để đồng bộ với UI
|
||||
}
|
||||
}
|
||||
|
||||
function handleAccordionClick(e) {
|
||||
const header = e.currentTarget;
|
||||
const accordionItem = header.closest('.sidebar-accordion-item');
|
||||
|
||||
if (!accordionItem) return;
|
||||
|
||||
// Kiểm tra nếu click vào accordion-header-actions hoặc các phần tử con của nó thì không toggle
|
||||
const actionsContainer = header.querySelector('.accordion-header-actions');
|
||||
if (actionsContainer && (actionsContainer.contains(e.target) || actionsContainer === e.target)) {
|
||||
return; // Không xử lý toggle nếu click vào actions container
|
||||
}
|
||||
|
||||
const tabName = accordionItem.getAttribute('data-tab');
|
||||
const isCurrentlyActive = activeTab === tabName;
|
||||
|
||||
// Nếu click vào tab đang active thì đóng nó, ngược lại mở tab mới
|
||||
if (isCurrentlyActive) {
|
||||
toggleAccordionTab(accordionItem, false);
|
||||
activeTab = null;
|
||||
} else {
|
||||
// Đóng tab cũ nếu có
|
||||
if (activeTab) {
|
||||
const oldTab = document.querySelector(`[data-tab="${activeTab}"]`);
|
||||
if (oldTab) {
|
||||
toggleAccordionTab(oldTab, false);
|
||||
}
|
||||
}
|
||||
// Mở tab mới
|
||||
toggleAccordionTab(accordionItem, true);
|
||||
activeTab = tabName;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAccordionTab(accordionItem, expand) {
|
||||
const header = accordionItem.querySelector('.sidebar-accordion-header');
|
||||
const content = accordionItem.querySelector('.sidebar-accordion-content');
|
||||
const arrow = accordionItem.querySelector('.accordion-arrow');
|
||||
|
||||
if (!header || !content || !arrow) return;
|
||||
|
||||
if (expand) {
|
||||
header.classList.add('active');
|
||||
content.classList.remove('collapsed');
|
||||
content.classList.add('expanded');
|
||||
arrow.textContent = '▼';
|
||||
} else {
|
||||
header.classList.remove('active');
|
||||
content.classList.remove('expanded');
|
||||
content.classList.add('collapsed');
|
||||
arrow.textContent = '▶';
|
||||
}
|
||||
}
|
||||
|
||||
function initializeResizeHandlers() {
|
||||
sidebarResizer = document.querySelector('.sidebar-resizer');
|
||||
consoleResizer = document.querySelector('.vertical-resizer');
|
||||
|
||||
// Khởi tạo chiều cao mặc định cho editor và console
|
||||
const container = document.querySelector('.editor-console-container');
|
||||
const editorArea = document.querySelector('#editor-area');
|
||||
const consolePanel = document.querySelector('#console-panel');
|
||||
|
||||
if (container && editorArea && consolePanel) {
|
||||
const containerHeight = container.clientHeight;
|
||||
const consoleHeight = 200; // Chiều cao khởi tạo ban đầu cho console
|
||||
const editorHeight = containerHeight - consoleHeight - 4; // 4px cho resizer
|
||||
|
||||
editorArea.style.height = editorHeight + 'px';
|
||||
consolePanel.style.height = consoleHeight + 'px';
|
||||
}
|
||||
|
||||
if (sidebarResizer) {
|
||||
sidebarResizer.addEventListener('mousedown', (e) => {
|
||||
isResizingSidebar = true;
|
||||
document.addEventListener('mousemove', handleSidebarResize);
|
||||
document.addEventListener('mouseup', stopSidebarResize);
|
||||
e.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
if (consoleResizer) {
|
||||
consoleResizer.addEventListener('mousedown', (e) => {
|
||||
isResizingConsole = true;
|
||||
// Thêm class resizing ngay khi bắt đầu để disable transition
|
||||
const consolePanel = document.querySelector('#console-panel');
|
||||
const editorArea = document.querySelector('#editor-area');
|
||||
if (consolePanel) consolePanel.classList.add('resizing');
|
||||
if (editorArea) editorArea.classList.add('resizing');
|
||||
|
||||
document.addEventListener('mousemove', handleConsoleResize);
|
||||
document.addEventListener('mouseup', stopConsoleResize);
|
||||
e.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
// Touch support
|
||||
if (sidebarResizer) {
|
||||
sidebarResizer.addEventListener('touchstart', (e) => {
|
||||
isResizingSidebar = true;
|
||||
document.addEventListener('touchmove', handleSidebarResizeTouch);
|
||||
document.addEventListener('touchend', stopSidebarResize);
|
||||
e.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
if (consoleResizer) {
|
||||
consoleResizer.addEventListener('touchstart', (e) => {
|
||||
isResizingConsole = true;
|
||||
// Thêm class resizing ngay khi bắt đầu để disable transition
|
||||
const consolePanel = document.querySelector('#console-panel');
|
||||
const editorArea = document.querySelector('#editor-area');
|
||||
if (consolePanel) consolePanel.classList.add('resizing');
|
||||
if (editorArea) editorArea.classList.add('resizing');
|
||||
|
||||
document.addEventListener('touchmove', handleConsoleResizeTouch);
|
||||
document.addEventListener('touchend', stopConsoleResize);
|
||||
e.preventDefault();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleSidebarResize(e) {
|
||||
if (!isResizingSidebar) return;
|
||||
|
||||
const container = document.querySelector('.script-editor-container');
|
||||
const sidebar = document.querySelector('.sidebar-container');
|
||||
|
||||
if (!container || !sidebar) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const newWidth = e.clientX - containerRect.left;
|
||||
const minWidth = 200;
|
||||
const maxWidth = Math.min(600, containerRect.width * 0.5);
|
||||
|
||||
if (newWidth >= minWidth && newWidth <= maxWidth) {
|
||||
sidebar.style.width = newWidth + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
function handleSidebarResizeTouch(e) {
|
||||
if (!isResizingSidebar || !e.touches || e.touches.length === 0) return;
|
||||
|
||||
const container = document.querySelector('.script-editor-container');
|
||||
const sidebar = document.querySelector('.sidebar-container');
|
||||
|
||||
if (!container || !sidebar) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const newWidth = e.touches[0].clientX - containerRect.left;
|
||||
const minWidth = 200;
|
||||
const maxWidth = Math.min(600, containerRect.width * 0.5);
|
||||
|
||||
if (newWidth >= minWidth && newWidth <= maxWidth) {
|
||||
sidebar.style.width = newWidth + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
function stopSidebarResize() {
|
||||
isResizingSidebar = false;
|
||||
document.removeEventListener('mousemove', handleSidebarResize);
|
||||
document.removeEventListener('mouseup', stopSidebarResize);
|
||||
document.removeEventListener('touchmove', handleSidebarResizeTouch);
|
||||
document.removeEventListener('touchend', stopSidebarResize);
|
||||
}
|
||||
|
||||
function handleConsoleResize(e) {
|
||||
if (!isResizingConsole) return;
|
||||
|
||||
const container = document.querySelector('.editor-console-container');
|
||||
const editorArea = document.querySelector('#editor-area');
|
||||
const consolePanel = document.querySelector('#console-panel');
|
||||
|
||||
if (!container || !editorArea || !consolePanel) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const containerHeight = containerRect.height;
|
||||
const newConsoleHeight = containerRect.bottom - e.clientY;
|
||||
const minConsoleHeight = 100;
|
||||
const maxConsoleHeight = containerHeight * 0.8;
|
||||
|
||||
if (newConsoleHeight >= minConsoleHeight && newConsoleHeight <= maxConsoleHeight) {
|
||||
const newEditorHeight = containerHeight - newConsoleHeight - 4; // 4px cho resizer
|
||||
editorArea.style.height = newEditorHeight + 'px';
|
||||
consolePanel.style.height = newConsoleHeight + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
function handleConsoleResizeTouch(e) {
|
||||
if (!isResizingConsole || !e.touches || e.touches.length === 0) return;
|
||||
|
||||
const container = document.querySelector('.editor-console-container');
|
||||
const editorArea = document.querySelector('#editor-area');
|
||||
const consolePanel = document.querySelector('#console-panel');
|
||||
|
||||
if (!container || !editorArea || !consolePanel) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const containerHeight = containerRect.height;
|
||||
const newConsoleHeight = containerRect.bottom - e.touches[0].clientY;
|
||||
const minConsoleHeight = 100;
|
||||
const maxConsoleHeight = containerHeight * 0.8;
|
||||
|
||||
if (newConsoleHeight >= minConsoleHeight && newConsoleHeight <= maxConsoleHeight) {
|
||||
const newEditorHeight = containerHeight - newConsoleHeight - 4; // 4px cho resizer
|
||||
editorArea.style.height = newEditorHeight + 'px';
|
||||
consolePanel.style.height = newConsoleHeight + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
function stopConsoleResize() {
|
||||
isResizingConsole = false;
|
||||
|
||||
// Remove resizing class để restore transition
|
||||
const consolePanel = document.querySelector('#console-panel');
|
||||
const editorArea = document.querySelector('#editor-area');
|
||||
if (consolePanel) {
|
||||
consolePanel.classList.remove('resizing');
|
||||
}
|
||||
if (editorArea) {
|
||||
editorArea.classList.remove('resizing');
|
||||
}
|
||||
|
||||
document.removeEventListener('mousemove', handleConsoleResize);
|
||||
document.removeEventListener('mouseup', stopConsoleResize);
|
||||
document.removeEventListener('touchmove', handleConsoleResizeTouch);
|
||||
document.removeEventListener('touchend', stopConsoleResize);
|
||||
}
|
||||
|
||||
export function cleanupLayout() {
|
||||
// Cleanup accordion handlers
|
||||
if (accordionHeaders && accordionHeaders.length > 0) {
|
||||
accordionHeaders.forEach(header => {
|
||||
header.removeEventListener('click', handleAccordionClick);
|
||||
});
|
||||
}
|
||||
accordionHeaders = [];
|
||||
|
||||
// Reset tất cả tabs về trạng thái collapsed
|
||||
const allTabs = document.querySelectorAll('.sidebar-accordion-item');
|
||||
allTabs.forEach(tab => {
|
||||
const header = tab.querySelector('.sidebar-accordion-header');
|
||||
const content = tab.querySelector('.sidebar-accordion-content');
|
||||
const arrow = tab.querySelector('.accordion-arrow');
|
||||
|
||||
if (header && content && arrow) {
|
||||
header.classList.remove('active');
|
||||
content.classList.remove('expanded');
|
||||
content.classList.add('collapsed');
|
||||
arrow.textContent = '▶';
|
||||
}
|
||||
});
|
||||
|
||||
activeTab = null;
|
||||
|
||||
// Cleanup resize handlers
|
||||
stopSidebarResize();
|
||||
stopConsoleResize();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
window.robotnet = window.robotnet || {};
|
||||
robotnet.console = {
|
||||
init: function (dotNetRef) {
|
||||
// Initialization if needed
|
||||
},
|
||||
|
||||
addMessage: function (level, message, autoScroll) {
|
||||
const container = document.getElementById('console-content');
|
||||
if (!container) return;
|
||||
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = `console-message console-message-${level.toLowerCase()}`;
|
||||
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const levelSpan = document.createElement('span');
|
||||
levelSpan.className = 'console-level';
|
||||
levelSpan.textContent = level;
|
||||
|
||||
const timeSpan = document.createElement('span');
|
||||
timeSpan.className = 'console-time';
|
||||
timeSpan.textContent = timestamp;
|
||||
|
||||
const messageSpan = document.createElement('span');
|
||||
messageSpan.className = 'console-text';
|
||||
messageSpan.textContent = message;
|
||||
|
||||
messageDiv.appendChild(levelSpan);
|
||||
messageDiv.appendChild(timeSpan);
|
||||
messageDiv.appendChild(messageSpan);
|
||||
|
||||
container.appendChild(messageDiv);
|
||||
|
||||
if (autoScroll) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
},
|
||||
|
||||
clear: function () {
|
||||
const container = document.getElementById('console-content');
|
||||
if (!container) return;
|
||||
|
||||
// Xóa tất cả messages bằng cách set innerHTML rỗng
|
||||
container.innerHTML = '';
|
||||
},
|
||||
|
||||
toggleCollapse: function () {
|
||||
const container = document.getElementById('console-content');
|
||||
const consolePanel = document.getElementById('console-panel');
|
||||
const consoleHeader = document.querySelector('.console-header');
|
||||
const verticalResizer = document.querySelector('.vertical-resizer');
|
||||
const toggleButton = document.querySelector('.console-container .btn-toggle');
|
||||
const iconSpan = toggleButton?.querySelector('span');
|
||||
|
||||
if (!container || !consolePanel || !toggleButton || !iconSpan) return;
|
||||
|
||||
// Check trạng thái hiện tại trước khi toggle
|
||||
const wasCollapsed = container.classList.contains('collapsed');
|
||||
|
||||
// Toggle collapsed class trên cả console-content và console-panel
|
||||
container.classList.toggle('collapsed');
|
||||
consolePanel.classList.toggle('collapsed');
|
||||
|
||||
// Tính toán và set height cho console-panel sau khi toggle
|
||||
if (!wasCollapsed) {
|
||||
// Đang collapse - set height về chỉ còn header
|
||||
if (consoleHeader) {
|
||||
const headerHeight = consoleHeader.offsetHeight;
|
||||
consolePanel.style.height = headerHeight + 'px';
|
||||
} else {
|
||||
consolePanel.style.height = '40px'; // Fallback height
|
||||
}
|
||||
// Ẩn vertical resizer khi collapsed
|
||||
if (verticalResizer) {
|
||||
verticalResizer.style.display = 'none';
|
||||
}
|
||||
// Update icon
|
||||
iconSpan.className = 'mdi mdi-chevron-up';
|
||||
toggleButton.title = 'Expand Console';
|
||||
} else {
|
||||
// Đang expand - restore về height mặc định
|
||||
consolePanel.style.height = '200px';
|
||||
// Hiện vertical resizer khi expanded
|
||||
if (verticalResizer) {
|
||||
verticalResizer.style.display = '';
|
||||
}
|
||||
// Update icon
|
||||
iconSpan.className = 'mdi mdi-chevron-down';
|
||||
toggleButton.title = 'Collapse Console';
|
||||
}
|
||||
}
|
||||
};
|
||||
robotnet.monaco = {
|
||||
CSharpLanguageRegisterSignatureHelpProvider: (dotnetRef, getSignatureHelpMethod) => {
|
||||
return monaco.languages.registerSignatureHelpProvider("csharp", {
|
||||
signatureHelpTriggerCharacters: ['('],
|
||||
provideSignatureHelp: async (model, position, token, context) => {
|
||||
let signature = await dotnetRef.invokeMethodAsync(getSignatureHelpMethod, model.uri.toString(), position.lineNumber, position.column);
|
||||
if (!signature) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
value: signature.value,
|
||||
dispose: () => { },
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
robotnet.blazor = {
|
||||
getResourcePath: function (logicalName) {
|
||||
// In .NET 10, try to get resource path from Blazor runtime
|
||||
// Strategy 1: Try to access via window.Blazor or Blazor._internal
|
||||
if (window.Blazor && window.Blazor._internal) {
|
||||
try {
|
||||
// Try to get resource mapping from Blazor internal APIs
|
||||
var resources = window.Blazor._internal.resources;
|
||||
if (resources && resources.assembly) {
|
||||
var assemblyName = logicalName.replace('.wasm', '');
|
||||
if (resources.assembly[assemblyName]) {
|
||||
return '/_framework/' + resources.assembly[assemblyName];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to direct path
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Try direct path (NET 10+)
|
||||
// In .NET 10, resources might be directly accessible
|
||||
return '/_framework/' + logicalName;
|
||||
}
|
||||
}
|
||||
|
||||
window.fileExplorer = {
|
||||
init: function (element) {
|
||||
// Initialize file explorer interactions
|
||||
// Currently no initialization needed as we use MudMenu for context menus
|
||||
// This function is kept for future use if needed
|
||||
},
|
||||
|
||||
updateFileBadges: function (fileId, showWarning, showError, showModified, warningCount, errorCount, fileName) {
|
||||
if (!fileId) return;
|
||||
|
||||
// Update name
|
||||
const nameElement = document.querySelector(`[data-file-id="${fileId}"][data-name="name"]`);
|
||||
if (nameElement) {
|
||||
nameElement.textContent = fileName;
|
||||
}
|
||||
|
||||
// Update warning badge
|
||||
const warningBadge = document.querySelector(`[data-file-id="${fileId}"][data-badge="warning"]`);
|
||||
if (warningBadge) {
|
||||
warningBadge.textContent = warningCount;
|
||||
warningBadge.setAttribute('data-count', warningCount);
|
||||
warningBadge.setAttribute('title', `${warningCount} warning(s)`);
|
||||
if (showWarning) {
|
||||
warningBadge.classList.remove('hidden');
|
||||
} else {
|
||||
warningBadge.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Update error badge
|
||||
const errorBadge = document.querySelector(`[data-file-id="${fileId}"][data-badge="error"]`);
|
||||
if (errorBadge) {
|
||||
errorBadge.textContent = errorCount;
|
||||
errorBadge.setAttribute('data-count', errorCount);
|
||||
errorBadge.setAttribute('title', `${errorCount} error(s)`);
|
||||
if (showError) {
|
||||
errorBadge.classList.remove('hidden');
|
||||
} else {
|
||||
errorBadge.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Update modified badge
|
||||
const modifiedBadge = document.querySelector(`[data-file-id="${fileId}"][data-badge="modified"]`);
|
||||
if (modifiedBadge) {
|
||||
if (showModified) {
|
||||
modifiedBadge.classList.remove('hidden');
|
||||
} else {
|
||||
modifiedBadge.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
CheckRadioById: function (id) {
|
||||
const radio = document.getElementById(id);
|
||||
if (radio) {
|
||||
radio.checked = true;
|
||||
// Trigger change event to ensure CSS :has(:checked) selector works
|
||||
radio.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
},
|
||||
|
||||
UncheckRadioByName: function (name) {
|
||||
const radios = document.querySelectorAll(`input[type="radio"][name="${name}"]`);
|
||||
radios.forEach(radio => radio.checked = false);
|
||||
},
|
||||
|
||||
updateFolderBadges: function (folderPath, showWarning, showError, showModified, warningCount, errorCount, folderName) {
|
||||
if (!folderPath) return;
|
||||
|
||||
// Update name
|
||||
const nameElement = document.querySelector(`[data-folder-path="${folderPath}"][data-name="name"]`);
|
||||
if (nameElement) {
|
||||
nameElement.textContent = folderName;
|
||||
}
|
||||
|
||||
// Update warning badge
|
||||
const warningBadge = document.querySelector(`[data-folder-path="${folderPath}"][data-badge="warning"]`);
|
||||
if (warningBadge) {
|
||||
warningBadge.textContent = warningCount;
|
||||
warningBadge.setAttribute('data-count', warningCount);
|
||||
warningBadge.setAttribute('title', `${warningCount} warning(s)`);
|
||||
if (showWarning) {
|
||||
warningBadge.classList.remove('hidden');
|
||||
} else {
|
||||
warningBadge.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Update error badge
|
||||
const errorBadge = document.querySelector(`[data-folder-path="${folderPath}"][data-badge="error"]`);
|
||||
if (errorBadge) {
|
||||
errorBadge.textContent = errorCount;
|
||||
errorBadge.setAttribute('data-count', errorCount);
|
||||
errorBadge.setAttribute('title', `${errorCount} error(s)`);
|
||||
if (showError) {
|
||||
errorBadge.classList.remove('hidden');
|
||||
} else {
|
||||
errorBadge.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Update modified badge
|
||||
const modifiedBadge = document.querySelector(`[data-folder-path="${folderPath}"][data-badge="modified"]`);
|
||||
if (modifiedBadge) {
|
||||
if (showModified) {
|
||||
modifiedBadge.classList.remove('hidden');
|
||||
} else {
|
||||
modifiedBadge.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
/* Console Message */
|
||||
.console-message {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
word-wrap: break-word;
|
||||
border-left: 3px solid transparent;
|
||||
padding-left: 8px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.console-message:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Console Level Badge */
|
||||
.console-level {
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Console Time */
|
||||
.console-time {
|
||||
color: #858585;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
/* Console Text */
|
||||
.console-text {
|
||||
flex: 1;
|
||||
color: #cccccc;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Message Level Styles */
|
||||
.console-message-info {
|
||||
border-left-color: #007acc;
|
||||
}
|
||||
|
||||
.console-message-info .console-level {
|
||||
background-color: rgba(0, 122, 204, 0.2);
|
||||
color: #4fc3f7;
|
||||
}
|
||||
|
||||
.console-message-warn {
|
||||
border-left-color: #ffa726;
|
||||
}
|
||||
|
||||
.console-message-warn .console-level {
|
||||
background-color: rgba(255, 167, 38, 0.2);
|
||||
color: #ffb74d;
|
||||
}
|
||||
|
||||
.console-message-error {
|
||||
border-left-color: #f44336;
|
||||
}
|
||||
|
||||
.console-message-error .console-level {
|
||||
background-color: rgba(244, 67, 54, 0.2);
|
||||
color: #ef5350;
|
||||
}
|
||||
Reference in New Issue
Block a user