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 Folders = []; public readonly List 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? OnFileChanged; public event Func, 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>("api/Script/UsingNamespaces") ?? []; var preCode = await http.GetFromJsonAsync("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("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 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 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 CreateNewFile(string name) { var result = await http.PostFromJsonAsync("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 CreateNewFolder(string name) { var result = await http.PostFromJsonAsync("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 SaveCurrentFileAsync() { if (CurrentFile is null || !CurrentFile.IsModified || CurrentFile.EditCode == CurrentFile.Code) return new(true, ""); var result = await http.PatchFromJsonAsync("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> SaveSelectAsync() { if (SelectedFolder is not null) { var results = new List(); 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> SaveAllAsync() { var results = new List(); foreach (var subFolder in Folders) { results.AddRange(await SaveAllAsync(subFolder)); } results.AddRange(await SaveAllAsync(Files)); return results; } private async Task> SaveAllAsync(IEnumerable files) { var results = new List(); foreach (var file in files) { if (!file.IsModified || file.EditCode == file.Code) continue; var result = await http.PatchFromJsonAsync("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> SaveAllAsync(ScriptFolderModel folder) { var results = new List(); results.AddRange(await SaveAllAsync(folder.Files)); foreach (var subFolder in folder.Folders) { results.AddRange(await SaveAllAsync(subFolder)); } return results; } public async Task DeleteFile(ScriptFileModel file) { var result = await http.DeleteFromJsonAsync($"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 DeleteFolder(ScriptFolderModel folder) { var result = await http.DeleteFromJsonAsync($"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 Rename(ScriptFolderModel folder, string newName) { var result = await http.PatchFromJsonAsync("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 Rename(ScriptFileModel file, string newName) { var result = await http.PatchFromJsonAsync("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 files) => files.Any(file => file.IsModified); public async Task> GetAllDiagnostics() { var editorProject = adhocWorkspace.CurrentSolution.GetProject(EditorProjectId); if (editorProject == null) return []; var compilation = await editorProject.GetCompilationAsync(); return compilation?.GetDiagnostics() ?? []; } public async Task> 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 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 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); } }