RobotNet/RobotNet.WebApp/Scripts/Models/ScriptWorkspace.cs
2025-10-15 15:15:53 +07:00

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);
}
}