562 lines
21 KiB
C#
562 lines
21 KiB
C#
using Microsoft.CodeAnalysis;
|
|
using Microsoft.CodeAnalysis.CSharp;
|
|
using Microsoft.CodeAnalysis.QuickInfo;
|
|
using Microsoft.CodeAnalysis.Text;
|
|
using MudBlazor;
|
|
using RobotNet.Script.Shares;
|
|
using RobotNet.Shares;
|
|
using RobotNet.Clients;
|
|
using RobotNet.WebApp.Scripts.Monaco;
|
|
using RobotNet.WebApp.Scripts.Monaco.Languages;
|
|
using System.Net.Http.Json;
|
|
using System.Text;
|
|
using System.Timers;
|
|
|
|
namespace RobotNet.WebApp.Scripts.Models;
|
|
|
|
public class ScriptWorkspace(IHttpClientFactory httpFactory) : IDisposable
|
|
{
|
|
private static readonly CSharpParseOptions cSharpParse = CSharpParseOptions.Default.WithKind(SourceCodeKind.Script).WithLanguageVersion(LanguageVersion.Latest);
|
|
private readonly AdhocWorkspace adhocWorkspace = new();
|
|
private readonly ProjectId EditorProjectId = ProjectId.CreateNewId();
|
|
|
|
public readonly List<ScriptFolderModel> Folders = [];
|
|
public readonly List<ScriptFileModel> Files = [];
|
|
public ScriptFolderModel? SelectedFolder { get; private set; }
|
|
public ScriptFileModel? SelectedFile { get; private set; }
|
|
public ScriptFileModel? CurrentFile { get; private set; }
|
|
|
|
public ProcessorState ProcessorState { get; set; }
|
|
public bool IsReadOnly => ProcessorState != ProcessorState.Ready && ProcessorState != ProcessorState.Idle && ProcessorState != ProcessorState.BuildError;
|
|
|
|
public event Func<ScriptFileModel?, Task>? OnFileChanged;
|
|
public event Func<IEnumerable<Diagnostic>, Task>? OnDiagnoticChanged;
|
|
public event Action? RootChanged;
|
|
|
|
private readonly HttpClient http = httpFactory.CreateClient("ScriptManagerAPI");
|
|
|
|
private readonly System.Timers.Timer DiagnosticTimer = new()
|
|
{
|
|
AutoReset = false,
|
|
Interval = 1000,
|
|
};
|
|
|
|
private bool IsInitialized = false;
|
|
public async Task InitializeAsync()
|
|
{
|
|
if (IsInitialized) return;
|
|
|
|
IsInitialized = true;
|
|
|
|
var collectionsDllStream = await http.GetStreamAsync($"dlls/System.Collections.dll");
|
|
var runtimeDllStream = await http.GetStreamAsync($"dlls/System.Runtime.dll");
|
|
var expressionsDllStream = await http.GetStreamAsync($"dlls/System.Linq.Expressions.dll");
|
|
var robotNetDllStream = await http.GetStreamAsync($"dlls/RobotNet.Script.dll");
|
|
var robotNetDocBuf = await http.GetByteArrayAsync($"dlls/RobotNet.Script.xml");
|
|
var robotNetExpressionsDllStream = await http.GetStreamAsync($"dlls/RobotNet.Script.Expressions.dll");
|
|
var robotNetExpressionsDocBuf = await http.GetByteArrayAsync($"dlls/RobotNet.Script.Expressions.xml");
|
|
|
|
var usingNamespaces = await http.GetFromJsonAsync<IEnumerable<string>>("api/Script/UsingNamespaces") ?? [];
|
|
var preCode = await http.GetFromJsonAsync<string>("api/Script/PreCode") ?? "";
|
|
|
|
var projectInfo = ProjectInfo.Create(EditorProjectId, VersionStamp.Create(), "EditorProject", "EditorAssembly", LanguageNames.CSharp)
|
|
.WithMetadataReferences([
|
|
MetadataReference.CreateFromStream(collectionsDllStream, MetadataReferenceProperties.Assembly),
|
|
MetadataReference.CreateFromStream(runtimeDllStream, MetadataReferenceProperties.Assembly),
|
|
MetadataReference.CreateFromStream(expressionsDllStream, MetadataReferenceProperties.Assembly),
|
|
MetadataReference.CreateFromStream(robotNetDllStream, MetadataReferenceProperties.Assembly, documentation: XmlDocumentationProvider.CreateFromBytes(robotNetDocBuf)),
|
|
MetadataReference.CreateFromStream(robotNetExpressionsDllStream, MetadataReferenceProperties.Assembly, documentation: XmlDocumentationProvider.CreateFromBytes(robotNetExpressionsDocBuf)),
|
|
])
|
|
.WithCompilationOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, usings: usingNamespaces))
|
|
.WithParseOptions(cSharpParse);
|
|
|
|
Solution updatedSolution = adhocWorkspace.CurrentSolution.AddProject(projectInfo);
|
|
|
|
updatedSolution = updatedSolution.AddDocument(DocumentId.CreateNewId(EditorProjectId), "PreScript.cs", SourceText.From(preCode), ["/"], "/PreScript.cs");
|
|
|
|
if (!adhocWorkspace.TryApplyChanges(updatedSolution)) throw new InvalidOperationException("Khởi tạo project thất bại");
|
|
|
|
Folders.Clear();
|
|
Files.Clear();
|
|
|
|
var rootFolder = await http.GetFromJsonAsync<ScriptFolder>("api/Script") ?? new ScriptFolder("Script", [], []);
|
|
|
|
Files.AddRange([.. rootFolder.Files.Select(file => CreateFileModel(file, 0, null))]);
|
|
Files.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
|
|
|
|
Folders.AddRange([.. rootFolder.Folders.Select(folder => CreateFolderModel(folder, 0, null))]);
|
|
Folders.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
|
|
|
|
var editorProject = adhocWorkspace.CurrentSolution.GetProject(EditorProjectId);
|
|
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);
|
|
}
|
|
|
|
DiagnosticTimer.Elapsed += DiagnosticTimer_Elapsed;
|
|
|
|
RootChanged?.Invoke();
|
|
}
|
|
|
|
private async void DiagnosticTimer_Elapsed(object? sender, ElapsedEventArgs e)
|
|
{
|
|
var editorProject = adhocWorkspace.CurrentSolution.GetProject(EditorProjectId);
|
|
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 (OnDiagnoticChanged is not null && CurrentFile is not null)
|
|
{
|
|
await OnDiagnoticChanged.Invoke(CurrentFile.Diagnostics);
|
|
}
|
|
}
|
|
|
|
private static async Task GetDiagnosticsToModel(Project project, IEnumerable<Diagnostic> diagnostics, ScriptFileModel file)
|
|
{
|
|
var document = project.Solution.GetDocument(file.Id);
|
|
if (document == null) return;
|
|
|
|
var syntaxTree = await document.GetSyntaxTreeAsync();
|
|
if (syntaxTree == null) return;
|
|
|
|
file.SetDiagnostics(diagnostics.Where(d => d.Location.IsInSource && d.Location.SourceTree == syntaxTree));
|
|
}
|
|
|
|
private static async Task GetDiagnosticsToModel(Project project, IEnumerable<Diagnostic> diagnostics, ScriptFolderModel folders)
|
|
{
|
|
foreach (var file in folders.Files)
|
|
{
|
|
await GetDiagnosticsToModel(project, diagnostics, file);
|
|
}
|
|
foreach (var folder in folders.Folders)
|
|
{
|
|
await GetDiagnosticsToModel(project, diagnostics, folder);
|
|
}
|
|
}
|
|
|
|
private ScriptFileModel CreateFileModel(ScriptFile file, int level, ScriptFolderModel? parrent)
|
|
{
|
|
Solution updatedSolution = adhocWorkspace.CurrentSolution;
|
|
var newId = DocumentId.CreateNewId(EditorProjectId);
|
|
|
|
updatedSolution = updatedSolution.AddDocument(newId, file.Name, SourceText.From(file.Code), parrent?.Path.Split("/"), Path.Combine(parrent?.Path ?? "", file.Name));
|
|
|
|
if (!adhocWorkspace.TryApplyChanges(updatedSolution)) throw new InvalidOperationException("Tạo document mới thất bại");
|
|
|
|
var model = new ScriptFileModel(newId, file, level, parrent);
|
|
|
|
return model;
|
|
}
|
|
|
|
private ScriptFolderModel CreateFolderModel(ScriptFolder folder, int level, ScriptFolderModel? parrent)
|
|
{
|
|
var model = new ScriptFolderModel(parrent, folder.Name, level);
|
|
model.AddFiles([.. folder.Files.Select(file => CreateFileModel(file, level + 1, model))]);
|
|
model.AddFolders([.. folder.Folders.Select(dir => CreateFolderModel(dir, level + 1, model))]);
|
|
return model;
|
|
}
|
|
|
|
public bool SelectFolder(ScriptFolderModel? folder)
|
|
{
|
|
if (SelectedFolder != folder)
|
|
{
|
|
SelectedFolder = folder;
|
|
SelectedFile = null;
|
|
return true;
|
|
}
|
|
else
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public async Task SelectFile(ScriptFileModel? file)
|
|
{
|
|
CurrentFile = file;
|
|
SelectedFile = CurrentFile;
|
|
SelectedFolder = file?.Parrent;
|
|
|
|
if (OnFileChanged is not null)
|
|
{
|
|
await OnFileChanged.Invoke(file);
|
|
}
|
|
}
|
|
|
|
|
|
public async Task<MessageResult> CreateNewFile(string name)
|
|
{
|
|
var result = await http.PostFromJsonAsync<MessageResult>("api/Script/File", new CreateModel(SelectedFolder?.Path ?? "", name));
|
|
if (result is null)
|
|
{
|
|
return new(false, "Không thể tạo file mới");
|
|
}
|
|
|
|
if (result.IsSuccess)
|
|
{
|
|
var file = new ScriptFile(name, "");
|
|
if (SelectedFolder is null)
|
|
{
|
|
Files.Add(CreateFileModel(file, 0, null));
|
|
Files.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
|
|
RootChanged?.Invoke();
|
|
}
|
|
else
|
|
{
|
|
SelectedFolder.AddFiles(CreateFileModel(file, SelectedFolder.Level + 1, SelectedFolder));
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
public async Task<MessageResult> CreateNewFolder(string name)
|
|
{
|
|
var result = await http.PostFromJsonAsync<MessageResult>("api/Script/Directory", new CreateModel(SelectedFolder?.Path ?? "", name));
|
|
if (result is null)
|
|
{
|
|
return new(false, "Không thể tạo thư mục mới");
|
|
}
|
|
|
|
if (result.IsSuccess)
|
|
{
|
|
var folder = new ScriptFolder(name, [], []);
|
|
if (SelectedFolder is null)
|
|
{
|
|
Folders.Add(CreateFolderModel(folder, 0, null));
|
|
Folders.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
|
|
RootChanged?.Invoke();
|
|
}
|
|
else
|
|
{
|
|
SelectedFolder.AddFolders(CreateFolderModel(folder, SelectedFolder.Level + 1, SelectedFolder));
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
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.ChangeCode(text);
|
|
DiagnosticTimer.Stop();
|
|
DiagnosticTimer.Start();
|
|
}
|
|
|
|
public async Task<MessageResult> SaveCurrentFileAsync()
|
|
{
|
|
if (CurrentFile is null || !CurrentFile.IsModified || CurrentFile.EditCode == CurrentFile.Code) return new(true, "");
|
|
|
|
var result = await http.PatchFromJsonAsync<MessageResult>("api/Script/File", new UpdateModel(CurrentFile.Path, CurrentFile.EditCode));
|
|
if (result is null)
|
|
{
|
|
return new(false, "Không thể update được code");
|
|
}
|
|
|
|
if (result.IsSuccess)
|
|
{
|
|
CurrentFile.Saved();
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
public async Task<IEnumerable<MessageResult>> SaveSelectAsync()
|
|
{
|
|
if (SelectedFolder is not null)
|
|
{
|
|
var results = new List<MessageResult>();
|
|
foreach (var subFolder in SelectedFolder.Folders)
|
|
{
|
|
results.AddRange(await SaveAllAsync(subFolder));
|
|
}
|
|
results.AddRange(await SaveAllAsync(Files));
|
|
return results;
|
|
}
|
|
else if (SelectedFile is not null)
|
|
{
|
|
return await SaveAllAsync([SelectedFile]);
|
|
}
|
|
else
|
|
{
|
|
return [];
|
|
}
|
|
}
|
|
|
|
public async Task<IEnumerable<MessageResult>> SaveAllAsync()
|
|
{
|
|
var results = new List<MessageResult>();
|
|
foreach (var subFolder in Folders)
|
|
{
|
|
results.AddRange(await SaveAllAsync(subFolder));
|
|
}
|
|
results.AddRange(await SaveAllAsync(Files));
|
|
return results;
|
|
}
|
|
|
|
private async Task<IEnumerable<MessageResult>> SaveAllAsync(IEnumerable<ScriptFileModel> files)
|
|
{
|
|
var results = new List<MessageResult>();
|
|
foreach (var file in files)
|
|
{
|
|
if (!file.IsModified || file.EditCode == file.Code) continue;
|
|
var result = await http.PatchFromJsonAsync<MessageResult>("api/Script/File", new UpdateModel(file.Path, file.EditCode));
|
|
if (result is null)
|
|
{
|
|
results.Add(new(false, $"Không thể update được code của {file.Name}"));
|
|
continue;
|
|
}
|
|
if (result.IsSuccess)
|
|
{
|
|
file.Saved();
|
|
}
|
|
results.Add(result);
|
|
}
|
|
return results;
|
|
}
|
|
|
|
|
|
private async Task<IEnumerable<MessageResult>> SaveAllAsync(ScriptFolderModel folder)
|
|
{
|
|
var results = new List<MessageResult>();
|
|
results.AddRange(await SaveAllAsync(folder.Files));
|
|
foreach (var subFolder in folder.Folders)
|
|
{
|
|
results.AddRange(await SaveAllAsync(subFolder));
|
|
}
|
|
return results;
|
|
}
|
|
|
|
public async Task<MessageResult> DeleteFile(ScriptFileModel file)
|
|
{
|
|
var result = await http.DeleteFromJsonAsync<MessageResult>($"api/Script/File?path={file.Path}");
|
|
if (result?.IsSuccess ?? false)
|
|
{
|
|
if (Files.Remove(file))
|
|
{
|
|
RootChanged?.Invoke();
|
|
}
|
|
else
|
|
{
|
|
file.Parrent?.RemoveFile(file);
|
|
}
|
|
if (SelectedFile == file)
|
|
{
|
|
await SelectFile(null);
|
|
}
|
|
|
|
Solution updatedSolution = adhocWorkspace.CurrentSolution;
|
|
|
|
updatedSolution = updatedSolution.RemoveDocument(file.Id);
|
|
|
|
if (!adhocWorkspace.TryApplyChanges(updatedSolution))
|
|
{
|
|
return new(false, "Xóa document trong workspace mới thất bại");
|
|
}
|
|
|
|
DiagnosticTimer.Stop();
|
|
DiagnosticTimer.Start();
|
|
}
|
|
return result ?? new MessageResult(false, "Lỗi server response");
|
|
}
|
|
|
|
public async Task<MessageResult> DeleteFolder(ScriptFolderModel folder)
|
|
{
|
|
var result = await http.DeleteFromJsonAsync<MessageResult>($"api/Script/Directory?path={folder.Path}");
|
|
if (result?.IsSuccess ?? false)
|
|
{
|
|
if (Folders.Remove(folder))
|
|
{
|
|
RootChanged?.Invoke();
|
|
}
|
|
else
|
|
{
|
|
folder.Parrent?.RemoveFolder(folder);
|
|
}
|
|
Solution updatedSolution = adhocWorkspace.CurrentSolution;
|
|
await RemoveFolderFromWorkspace(updatedSolution, folder);
|
|
if (!adhocWorkspace.TryApplyChanges(updatedSolution))
|
|
{
|
|
return new(false, "Xóa document trong workspace mới thất bại");
|
|
}
|
|
|
|
DiagnosticTimer.Stop();
|
|
DiagnosticTimer.Start();
|
|
|
|
}
|
|
return result ?? new MessageResult(false, "Lỗi server response");
|
|
}
|
|
|
|
private async Task RemoveFolderFromWorkspace(Solution updatedSolution, ScriptFolderModel folder)
|
|
{
|
|
foreach (var dir in folder.Folders)
|
|
{
|
|
await RemoveFolderFromWorkspace(updatedSolution, dir);
|
|
}
|
|
foreach (var file in folder.Files)
|
|
{
|
|
if (SelectedFile == file)
|
|
{
|
|
_ = SelectFile(null);
|
|
}
|
|
updatedSolution = updatedSolution.RemoveDocument(file.Id);
|
|
}
|
|
}
|
|
|
|
public async Task<MessageResult> Rename(ScriptFolderModel folder, string newName)
|
|
{
|
|
var result = await http.PatchFromJsonAsync<MessageResult>("api/Script/DirectoryName", new RenameModel(folder.Path, newName));
|
|
if (result?.IsSuccess ?? false)
|
|
{
|
|
folder.Rename(newName);
|
|
}
|
|
|
|
return result ?? new MessageResult(false, "Lỗi server response");
|
|
}
|
|
|
|
public async Task<MessageResult> Rename(ScriptFileModel file, string newName)
|
|
{
|
|
var result = await http.PatchFromJsonAsync<MessageResult>("api/Script/FileName", new RenameModel(file.Path, newName));
|
|
if (result?.IsSuccess ?? false)
|
|
{
|
|
file.Rename(newName);
|
|
}
|
|
|
|
return result ?? new MessageResult(false, "Lỗi server response");
|
|
}
|
|
|
|
public bool AnyChanges() => AnyChanges(Files) || Folders.Any(AnyChanges);
|
|
private static bool AnyChanges(ScriptFolderModel folder) => AnyChanges(folder.Files) || folder.Folders.Any(AnyChanges);
|
|
private static bool AnyChanges(IEnumerable<ScriptFileModel> files) => files.Any(file => file.IsModified);
|
|
|
|
public async Task<IEnumerable<Diagnostic>> GetAllDiagnostics()
|
|
{
|
|
var editorProject = adhocWorkspace.CurrentSolution.GetProject(EditorProjectId);
|
|
if (editorProject == null) return [];
|
|
|
|
var compilation = await editorProject.GetCompilationAsync();
|
|
|
|
return compilation?.GetDiagnostics() ?? [];
|
|
}
|
|
|
|
public async Task<IEnumerable<Monaco.Languages.CompletionItem>> GetCompletionCurrentFileAsync(int line, int column, int kind, char? triggerCharacter)
|
|
{
|
|
return CurrentFile is null ? [] : await adhocWorkspace.GetCompletionAsync(CurrentFile.Id, line, column, kind, triggerCharacter);
|
|
}
|
|
|
|
|
|
public async Task<string?> GetQuickInfoCurrentFile(int line, int column)
|
|
{
|
|
if (CurrentFile is null) return null;
|
|
|
|
var document = adhocWorkspace.CurrentSolution.GetDocument(CurrentFile.Id);
|
|
if (document is null) return null;
|
|
|
|
var sourceText = await document.GetTextAsync();
|
|
var position = sourceText.Lines.GetPosition(new LinePosition(line - 1, column - 1));
|
|
|
|
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:
|
|
// The first line is "Anonymous Types:"
|
|
// Then we want all anonymous types to be C# highlighted
|
|
finalTextBuilder.AppendSection(section, MarkdownFormat.FirstLineDefaultRestCSharp, ref lastSectionHadLineBreak);
|
|
break;
|
|
|
|
case "NullabilityAnalysis":
|
|
// Italicize the nullable analysis for emphasis.
|
|
finalTextBuilder.AppendSection(section, MarkdownFormat.Italicize, ref lastSectionHadLineBreak);
|
|
break;
|
|
|
|
default:
|
|
finalTextBuilder.AppendSection(section, MarkdownFormat.Default, ref lastSectionHadLineBreak);
|
|
break;
|
|
}
|
|
}
|
|
|
|
return finalTextBuilder.ToString().Trim();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
private bool isDisposed = false;
|
|
public void Dispose()
|
|
{
|
|
if (isDisposed) throw new NotImplementedException();
|
|
|
|
isDisposed = true;
|
|
|
|
DiagnosticTimer.Elapsed -= DiagnosticTimer_Elapsed;
|
|
DiagnosticTimer.Stop();
|
|
|
|
adhocWorkspace.Dispose();
|
|
DiagnosticTimer.Dispose();
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
}
|