using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.UI.Dispatching;
using Microsoft.Web.WebView2.Core;
using XFEExtension.NetCore.WinUIHelper.Interface.Services;
using XFEExtension.NetCore.WinUIHelper.Utilities;
using XFEWorkflow.Abstractions;
using XFEWorkflow.Infrastructure.Compilation;
namespace XFEWorkflow.Controls;
public sealed class CodeEditor : UserControl, IDisposable
{
private readonly WebView2 web = new();
private readonly IWebViewService service = ServiceManager.GetService<IWebViewService>();
private readonly CodeCompiler compiler = new();
private readonly DispatcherQueueTimer analysisTimer;
private long analysisVersion;
private bool analysisEnabled, analyzing, disposed;
private Task analysisTask = Task.CompletedTask;
private string text = "";
private string documentId = Guid.NewGuid().ToString("N");
private bool ready, initialized;
private double fontSize = 14;
private readonly TaskCompletionSource loadedEditor = new(TaskCreationOptions.RunContinuationsAsynchronously);
internal Task Ready => loadedEditor.Task;
internal bool IsAnalyzing => analyzing;
internal Task AnalysisTask => analysisTask;
internal Task<string> ProbeAsync(string script) => web.ExecuteScriptAsync(script).AsTask();
internal Task CaptureAsync(string path) => WebViewCapture.SaveAsync(web.CoreWebView2, path, fromSurface: true);
public bool IsNodeBody { get; set; }
private bool readOnly;
public bool IsReadOnly { get => readOnly; set { readOnly = value; SendDocument(); } }
public string FileName { get; set; } = "workflow.cs";
public double EditorFontSize => fontSize;
private IEnumerable<CapabilityDescriptor> capabilities = [];
public IEnumerable<CapabilityDescriptor> Capabilities { get => capabilities; set { capabilities = value.ToArray(); compiler.CapabilityBindingsSource = PluginMethodBindings.Generate(capabilities); } }
public event Action<string>? TextChanged;
public event Action<string>? EditorCommandInvoked;
public event Action<IReadOnlyList<WorkflowDiagnostic>>? DiagnosticsChanged;
public bool IsAnalysisEnabled
{
get => analysisEnabled;
set
{
if (analysisEnabled == value || disposed) return;
analysisEnabled = value;
QueueAnalysis();
}
}
public CodeEditor()
{
analysisTimer = DispatcherQueue.CreateTimer();
analysisTimer.Interval = TimeSpan.FromMilliseconds(400);
analysisTimer.IsRepeating = false;
analysisTimer.Tick += (_, _) => { if (!analyzing) analysisTask = AnalyzeAsync(); };
Content = web; service.Initialize(web);
Loaded += async (_, _) =>
{
if (initialized) return; initialized = true;
try
{
var environment = await CoreWebView2Environment.CreateWithOptionsAsync(null, Path.Combine(Services.AppServices.Current.Paths.Root, "EditorProfile"),
new CoreWebView2EnvironmentOptions { AdditionalBrowserArguments = "--disable-features=CalculateNativeWinOcclusion" });
if (disposed) return;
await web.EnsureCoreWebView2Async(environment);
if (disposed) return;
web.CoreWebView2.SetVirtualHostNameToFolderMapping("editor.xfeworkflow.local", Path.Combine(AppContext.BaseDirectory, "Assets", "Editor"), CoreWebView2HostResourceAccessKind.DenyCors);
web.CoreWebView2.Settings.AreDevToolsEnabled = false;
web.CoreWebView2.WebMessageReceived += MessageReceived;
web.CoreWebView2.NavigationStarting += (_, e) => { if (!e.Uri.StartsWith("https://editor.xfeworkflow.local/", StringComparison.Ordinal)) e.Cancel = true; };
web.Source = new Uri("https://editor.xfeworkflow.local/index.html");
}
catch (Exception ex) { if (!disposed) { loadedEditor.TrySetException(ex); DiagnosticsChanged?.Invoke([new("编辑器初始化失败:" + ex.Message)]); } }
};
}
public string Text
{
get => text;
set
{
if (disposed) return;
text = value; documentId = Guid.NewGuid().ToString("N");
SendDocument(); QueueAnalysis();
}
}
private void Send(object value)
{ if (ready && !disposed) web.CoreWebView2.PostWebMessageAsJson(JsonSerializer.Serialize(value, WorkflowJson.Options)); }
private void SendDocument() => Send(new { type = "set", text, documentId, readOnly, capabilities = Capabilities.Select(x => new { x.Id, x.Name }) });
private async void MessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs e)
{
if (disposed || !e.Source.StartsWith("https://editor.xfeworkflow.local/", StringComparison.Ordinal)) return;
try
{
var message = JsonNode.Parse(e.WebMessageAsJson)!.AsObject();
switch (message.String("type"))
{
case "ready": ready = true; SendDocument(); QueueAnalysis(); loadedEditor.TrySetResult(); break;
case "change": if (readOnly || message.String("documentId") != documentId) break; text = message.String("text"); TextChanged?.Invoke(text); QueueAnalysis(); break;
case "command": if (!readOnly && message.String("documentId") == documentId && message.String("command") is "run" or "stop" or "stepOver" or "stepInto" or "breakpoint") EditorCommandInvoked?.Invoke(message.String("command")); break;
case "complete":
var id = message.String("id");
var completionDocument = message.String("documentId");
if (completionDocument != documentId || !analysisEnabled) { Send(new { type = "completion", id, items = Array.Empty<CodeCompletion>() }); break; }
var version = analysisVersion;
var items = await compiler.CompleteAsync(text, IsNodeBody, message.Integer("position"));
Send(new { type = "completion", id, items = IsCurrentAnalysis(version) ? items : [] });
break;
}
}
catch (OperationCanceledException) { }
catch (Exception ex) { if (!disposed && analysisEnabled) DiagnosticsChanged?.Invoke([new(ex.Message)]); }
}
private void QueueAnalysis()
{
analysisVersion++;
analysisTimer.Stop();
if (analysisEnabled && !disposed) analysisTimer.Start();
}
private bool IsCurrentAnalysis(long version) => !disposed && analysisEnabled && version == analysisVersion;
private async Task AnalyzeAsync()
{
if (disposed || !analysisEnabled || analyzing) return;
analyzing = true;
var version = analysisVersion;
var source = text; var body = IsNodeBody; var file = FileName; var document = documentId;
try
{
// Let the current Roslyn pass finish; switching documents only invalidates its result.
// A single worker and the timer coalesce edits without throwing from Roslyn workers.
var diagnostics = await compiler.AnalyzeAsync(source, body, file);
if (!IsCurrentAnalysis(version)) return;
DiagnosticsChanged?.Invoke(diagnostics);
Send(new { type = "diagnostics", documentId = document, items = diagnostics });
}
catch (Exception ex) { if (IsCurrentAnalysis(version)) DiagnosticsChanged?.Invoke([new(ex.Message)]); }
finally
{
analyzing = false;
if (!disposed && analysisEnabled && version != analysisVersion)
{ analysisTimer.Stop(); analysisTimer.Start(); }
}
}
public void GoTo(int line, int column = 1) { if (ready) Send(new { type = "goto", line, column }); }
public async Task<string> GetTextAsync()
{
if (!ready) return text;
var expectedDocument = documentId;
var snapshot = JsonNode.Parse(await web.ExecuteScriptAsync("({documentId, text: editor.getValue()})"))?.AsObject();
return documentId == expectedDocument && snapshot?.String("documentId") == expectedDocument ? snapshot.String("text") : text;
}
public async Task EditCommandAsync(string command)
{ if (ready && command is "undo" or "redo") await web.ExecuteScriptAsync("editor.trigger('toolbar', '" + command + "', null);"); }
public async Task ScaleFontAsync(double factor)
{
fontSize = Math.Clamp(fontSize * factor, 10, 28);
if (ready) await web.ExecuteScriptAsync("editor.updateOptions({fontSize:" + fontSize.ToString(System.Globalization.CultureInfo.InvariantCulture) + "});");
}
public void Dispose()
{
if (disposed) return;
disposed = true; analysisVersion++; analysisTimer.Stop();
compiler.Dispose(); web.Close();
}
}
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.UI.Dispatching;
using Microsoft.Web.WebView2.Core;
using XFEExtension.NetCore.WinUIHelper.Interface.Services;
using XFEExtension.NetCore.WinUIHelper.Utilities;
using XFEWorkflow.Abstractions;
using XFEWorkflow.Infrastructure.Compilation;
namespace XFEWorkflow.Controls;
public sealed class CodeEditor : UserControl, IDisposable
{
private readonly WebView2 web = new();
private readonly IWebViewService service = ServiceManager.GetService<IWebViewService>();
private readonly CodeCompiler compiler = new();
private readonly DispatcherQueueTimer analysisTimer;
private long analysisVersion;
private bool analysisEnabled, analyzing, disposed;
private Task analysisTask = Task.CompletedTask;
private string text = "";
private string documentId = Guid.NewGuid().ToString("N");
private bool ready, initialized;
private double fontSize = 14;
private readonly TaskCompletionSource loadedEditor = new(TaskCreationOptions.RunContinuationsAsynchronously);
internal Task Ready => loadedEditor.Task;
internal bool IsAnalyzing => analyzing;
internal Task AnalysisTask => analysisTask;
internal Task<string> ProbeAsync(string script) => web.ExecuteScriptAsync(script).AsTask();
internal Task CaptureAsync(string path) => WebViewCapture.SaveAsync(web.CoreWebView2, path, fromSurface: true);
public bool IsNodeBody { get; set; }
private bool readOnly;
public bool IsReadOnly { get => readOnly; set { readOnly = value; SendDocument(); } }
public string FileName { get; set; } = "workflow.cs";
public double EditorFontSize => fontSize;
private IEnumerable<CapabilityDescriptor> capabilities = [];
public IEnumerable<CapabilityDescriptor> Capabilities { get => capabilities; set { capabilities = value.ToArray(); compiler.CapabilityBindingsSource = PluginMethodBindings.Generate(capabilities); } }
public event Action<string>? TextChanged;
public event Action<string>? EditorCommandInvoked;
public event Action<IReadOnlyList<WorkflowDiagnostic>>? DiagnosticsChanged;
public bool IsAnalysisEnabled
{
get => analysisEnabled;
set
{
if (analysisEnabled == value || disposed) return;
analysisEnabled = value;
QueueAnalysis();
}
}
public CodeEditor()
{
analysisTimer = DispatcherQueue.CreateTimer();
analysisTimer.Interval = TimeSpan.FromMilliseconds(400);
analysisTimer.IsRepeating = false;
analysisTimer.Tick += (_, _) => { if (!analyzing) analysisTask = AnalyzeAsync(); };
Content = web; service.Initialize(web);
Loaded += async (_, _) =>
{
if (initialized) return; initialized = true;
try
{
var environment = await CoreWebView2Environment.CreateWithOptionsAsync(null, Path.Combine(Services.AppServices.Current.Paths.Root, "EditorProfile"),
new CoreWebView2EnvironmentOptions { AdditionalBrowserArguments = "--disable-features=CalculateNativeWinOcclusion" });
if (disposed) return;
await web.EnsureCoreWebView2Async(environment);
if (disposed) return;
web.CoreWebView2.SetVirtualHostNameToFolderMapping("editor.xfeworkflow.local", Path.Combine(AppContext.BaseDirectory, "Assets", "Editor"), CoreWebView2HostResourceAccessKind.DenyCors);
web.CoreWebView2.Settings.AreDevToolsEnabled = false;
web.CoreWebView2.WebMessageReceived += MessageReceived;
web.CoreWebView2.NavigationStarting += (_, e) => { if (!e.Uri.StartsWith("https://editor.xfeworkflow.local/", StringComparison.Ordinal)) e.Cancel = true; };
web.Source = new Uri("https://editor.xfeworkflow.local/index.html");
}
catch (Exception ex) { if (!disposed) { loadedEditor.TrySetException(ex); DiagnosticsChanged?.Invoke([new("编辑器初始化失败:" + ex.Message)]); } }
};
}
public string Text
{
get => text;
set
{
if (disposed) return;
text = value; documentId = Guid.NewGuid().ToString("N");
SendDocument(); QueueAnalysis();
}
}
private void Send(object value)
{ if (ready && !disposed) web.CoreWebView2.PostWebMessageAsJson(JsonSerializer.Serialize(value, WorkflowJson.Options)); }
private void SendDocument() => Send(new { type = "set", text, documentId, readOnly, capabilities = Capabilities.Select(x => new { x.Id, x.Name }) });
private async void MessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs e)
{
if (disposed || !e.Source.StartsWith("https://editor.xfeworkflow.local/", StringComparison.Ordinal)) return;
try
{
var message = JsonNode.Parse(e.WebMessageAsJson)!.AsObject();
switch (message.String("type"))
{
case "ready": ready = true; SendDocument(); QueueAnalysis(); loadedEditor.TrySetResult(); break;
case "change": if (readOnly || message.String("documentId") != documentId) break; text = message.String("text"); TextChanged?.Invoke(text); QueueAnalysis(); break;
case "command": if (!readOnly && message.String("documentId") == documentId && message.String("command") is "run" or "stop" or "stepOver" or "stepInto" or "breakpoint") EditorCommandInvoked?.Invoke(message.String("command")); break;
case "complete":
var id = message.String("id");
var completionDocument = message.String("documentId");
if (completionDocument != documentId || !analysisEnabled) { Send(new { type = "completion", id, items = Array.Empty<CodeCompletion>() }); break; }
var version = analysisVersion;
var items = await compiler.CompleteAsync(text, IsNodeBody, message.Integer("position"));
Send(new { type = "completion", id, items = IsCurrentAnalysis(version) ? items : [] });
break;
}
}
catch (OperationCanceledException) { }
catch (Exception ex) { if (!disposed && analysisEnabled) DiagnosticsChanged?.Invoke([new(ex.Message)]); }
}
private void QueueAnalysis()
{
analysisVersion++;
analysisTimer.Stop();
if (analysisEnabled && !disposed) analysisTimer.Start();
}
private bool IsCurrentAnalysis(long version) => !disposed && analysisEnabled && version == analysisVersion;
private async Task AnalyzeAsync()
{
if (disposed || !analysisEnabled || analyzing) return;
analyzing = true;
var version = analysisVersion;
var source = text; var body = IsNodeBody; var file = FileName; var document = documentId;
try
{
// Let the current Roslyn pass finish; switching documents only invalidates its result.
// A single worker and the timer coalesce edits without throwing from Roslyn workers.
var diagnostics = await compiler.AnalyzeAsync(source, body, file);
if (!IsCurrentAnalysis(version)) return;
DiagnosticsChanged?.Invoke(diagnostics);
Send(new { type = "diagnostics", documentId = document, items = diagnostics });
}
catch (Exception ex) { if (IsCurrentAnalysis(version)) DiagnosticsChanged?.Invoke([new(ex.Message)]); }
finally
{
analyzing = false;
if (!disposed && analysisEnabled && version != analysisVersion)
{ analysisTimer.Stop(); analysisTimer.Start(); }
}
}
public void GoTo(int line, int column = 1) { if (ready) Send(new { type = "goto", line, column }); }
public async Task<string> GetTextAsync()
{
if (!ready) return text;
var expectedDocument = documentId;
var snapshot = JsonNode.Parse(await web.ExecuteScriptAsync("({documentId, text: editor.getValue()})"))?.AsObject();
return documentId == expectedDocument && snapshot?.String("documentId") == expectedDocument ? snapshot.String("text") : text;
}
public async Task EditCommandAsync(string command)
{ if (ready && command is "undo" or "redo") await web.ExecuteScriptAsync("editor.trigger('toolbar', '" + command + "', null);"); }
public async Task ScaleFontAsync(double factor)
{
fontSize = Math.Clamp(fontSize * factor, 10, 28);
if (ready) await web.ExecuteScriptAsync("editor.updateOptions({fontSize:" + fontSize.ToString(System.Globalization.CultureInfo.InvariantCulture) + "});");
}
public void Dispose()
{
if (disposed) return;
disposed = true; analysisVersion++; analysisTimer.Stop();
compiler.Dispose(); web.Close();
}
}