Initial commit
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user