Files
I150/srcs/RobotNet10/Components/RobotNet10.ScriptEditor/Services/ScriptWorkspace.cs
2026-07-03 16:37:12 +07:00

588 lines
20 KiB
C#

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Text;
using RobotNet10.ScriptEditor.Helpers;
using RobotNet10.ScriptEditor.Helpers.Monaco.Languages;
using RobotNet10.ScriptEditor.Models;
using RobotNet10.ScriptEngine.Shared;
using System.Text;
using System.Timers;
namespace RobotNet10.ScriptEditor.Services;
internal class ScriptWorkspace : IDisposable
{
public IEnumerable<ScriptFolder> Folders => WorkspaceFolders;
public IEnumerable<ScriptFile> Files => WorkspaceFiles;
public event Action? ReadOnlyChanged;
public event Action<IEnumerable<Diagnostic>>? DiagnoticChanged;
public event Action? RootChanged;
public event Action<ScriptFile?>? CurrentFileChanged;
public event Action<ScriptFile?>? SelectedFileChanged;
public event Action<ScriptFolder?>? SelectedFolderChanged;
public ScriptFile? CurrentFile { get; private set; }
private ScriptFile? _selectedFile;
private ScriptFolder? _selectedFolder;
private bool _isUpdatingSelection = false;
public ScriptFile? SelectedFile
{
get => _selectedFile;
set
{
if (_selectedFile == value) return;
if (_isUpdatingSelection) return;
_isUpdatingSelection = true;
try
{
if (value is not null && CurrentFile != value)
{
CurrentFile = value;
CurrentFileChanged?.Invoke(CurrentFile);
}
var oldFolder = _selectedFile?.Parent;
_selectedFile = value;
// Only update SelectedFolder if it's different
if (_selectedFile?.Parent != oldFolder)
{
if (_selectedFolder != _selectedFile?.Parent)
{
_selectedFolder = _selectedFile?.Parent;
SelectedFolderChanged?.Invoke(_selectedFolder);
}
}
SelectedFileChanged?.Invoke(_selectedFile);
}
finally
{
_isUpdatingSelection = false;
}
}
}
public ScriptFolder? SelectedFolder
{
get => _selectedFolder;
set
{
if (_selectedFolder == value) return;
if (_isUpdatingSelection) return;
_isUpdatingSelection = true;
try
{
_selectedFolder = value;
// Only clear SelectedFile if it's not null and not related to the new folder
if (_selectedFile != null && _selectedFile.Parent != _selectedFolder)
{
_selectedFile = null;
SelectedFileChanged?.Invoke(null);
}
SelectedFolderChanged?.Invoke(_selectedFolder);
}
finally
{
_isUpdatingSelection = false;
}
}
}
public bool IsInitialized { get; private set; }
private bool _isReadOnly;
public bool IsReadOnly
{
get => _isReadOnly;
set
{
if (_isReadOnly == value) return;
_isReadOnly = value;
ReadOnlyChanged?.Invoke();
}
}
private readonly CSharpParseOptions WorkspaceParseOptions = CSharpParseOptions.Default.WithKind(SourceCodeKind.Script).WithLanguageVersion(LanguageVersion.Latest);
private readonly List<ScriptFolder> WorkspaceFolders = [];
private readonly List<ScriptFile> WorkspaceFiles = [];
private readonly AdhocWorkspace adhocWorkspace = new();
private readonly ProjectId ProjectId = ProjectId.CreateNewId();
private readonly System.Timers.Timer DiagnosticTimer = new(1000) { AutoReset = false };
public ScriptWorkspace()
{
DiagnosticTimer.Elapsed += DiagnosticTimer_Elapsed;
}
public void Initialize(IEnumerable<MetadataReference> references, string[] usingNamespaces, Type globalType, ScriptFolderDto rootFolder)
{
if (IsInitialized) throw new InvalidOperationException("Workspace đã được khởi tạo");
var preCode = $"{BuildDevelopGlobalsScript(typeof(IScriptGlobals))}\n{BuildDevelopGlobalsScript(globalType)}";
var csharpCompilationOptions = new CSharpCompilationOptions(
OutputKind.DynamicallyLinkedLibrary,
usings: usingNamespaces,
metadataImportOptions: MetadataImportOptions.All,
reportSuppressedDiagnostics: true);
//.WithEmitDebugInformation(false); // Disable debug info to avoid Mono debugger agent assertions
var projectInfo = ProjectInfo.Create(ProjectId, VersionStamp.Create(), "ScriptEditor", "ScriptEditorAssembly", LanguageNames.CSharp)
.WithMetadataReferences(references)
.WithCompilationOptions(csharpCompilationOptions)
.WithParseOptions(WorkspaceParseOptions);
Solution updatedSolution = adhocWorkspace.CurrentSolution.AddProject(projectInfo);
var preScriptDocId = DocumentId.CreateNewId(ProjectId);
updatedSolution = updatedSolution.AddDocument(preScriptDocId, "PreScript.cs", SourceText.From(preCode), ["/"], "/PreScript.cs");
if (!adhocWorkspace.TryApplyChanges(updatedSolution))
throw new InvalidOperationException("Add pre script document thất bại");
WorkspaceFiles.AddRange(rootFolder.Files.Select(file => CreateWrokspaceFile(file)));
WorkspaceFiles.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
WorkspaceFolders.AddRange(rootFolder.Folders.Select(folder => CreateWorkspaceFolder(folder)));
WorkspaceFolders.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
_ = Task.Run(DiagnosticProject);
RootChanged?.Invoke();
IsInitialized = true;
}
public async Task ReinitializeAsync(ScriptFolderDto rootFolder)
{
if (!IsInitialized) throw new InvalidOperationException("Workspace chưa được khởi tạo");
// Xóa tất cả documents hiện tại (trừ PreScript.cs)
Solution updatedSolution = adhocWorkspace.CurrentSolution;
var project = updatedSolution.GetProject(ProjectId);
if (project is not null)
{
foreach (var doc in project.Documents)
{
if (doc.Name != "PreScript.cs")
{
updatedSolution = updatedSolution.RemoveDocument(doc.Id);
}
}
if (!adhocWorkspace.TryApplyChanges(updatedSolution))
throw new InvalidOperationException("Xóa documents cũ thất bại");
}
// Clear workspace files và folders
WorkspaceFiles.Clear();
WorkspaceFolders.Clear();
CurrentFile = null;
SelectedFile = null;
SelectedFolder = null;
// Reload lại từ rootFolder mới
WorkspaceFiles.AddRange(rootFolder.Files.Select(file => CreateWrokspaceFile(file)));
WorkspaceFiles.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
WorkspaceFolders.AddRange(rootFolder.Folders.Select(folder => CreateWorkspaceFolder(folder)));
WorkspaceFolders.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
// Chạy diagnostic ngay lập tức để phân tích lỗi của source code sau khi restore
await DiagnosticProject();
RootChanged?.Invoke();
CurrentFileChanged?.Invoke(CurrentFile);
}
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.Code = text;
DiagnosticTimer.Stop();
DiagnosticTimer.Start();
}
public string FormatCode(string code)
{
var tree = CSharpSyntaxTree.ParseText(code, WorkspaceParseOptions);
var root = tree.GetRoot();
var formattedRoot = Microsoft.CodeAnalysis.Formatting.Formatter.Format(root, adhocWorkspace);
return formattedRoot.ToFullString();
}
public async Task<string?> GetQuickInfoCurrentFile(int line, int column)
{
if (CurrentFile is null) return null;
return await adhocWorkspace.GetQuickInfoAsync(CurrentFile.Id, line, column);
}
public async Task<IEnumerable<BlazorMonaco.Languages.CompletionItem>> GetCompletionsCurrentFile(int line, int column, int kind, char? triggerCharacter)
{
if (CurrentFile is null) return [];
return await adhocWorkspace.GetCompletionAsync(CurrentFile.Id, line, column, kind, triggerCharacter);
}
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);
}
public void AddFile(ScriptFileDto fileDto, ScriptFolder? parent = null)
{
var workspaceFile = CreateWrokspaceFile(fileDto, parent);
if (parent is null)
{
WorkspaceFiles.Add(workspaceFile);
WorkspaceFiles.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
}
else
{
parent.AddFiles(workspaceFile);
}
RootChanged?.Invoke();
}
public void AddFolder(ScriptFolderDto folderDto, ScriptFolder? parent = null)
{
var workspaceFolder = CreateWorkspaceFolder(folderDto, parent);
if (parent is null)
{
WorkspaceFolders.Add(workspaceFolder);
WorkspaceFolders.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
}
else
{
parent.AddFolders(workspaceFolder);
}
RootChanged?.Invoke();
}
public ScriptFile? FindFileByPath(string path)
{
// Search in root files
foreach (var file in WorkspaceFiles)
{
if (file.Path == path) return file;
}
// Search in folders recursively
foreach (var folder in WorkspaceFolders)
{
var found = FindFileInFolder(folder, path);
if (found != null) return found;
}
return null;
}
public ScriptFolder? FindFolderByPath(string path)
{
// Search in root folders
foreach (var folder in WorkspaceFolders)
{
if (folder.Path == path) return folder;
var found = FindFolderInFolder(folder, path);
if (found != null) return found;
}
return null;
}
private ScriptFile? FindFileInFolder(ScriptFolder folder, string path)
{
foreach (var file in folder.Files)
{
if (file.Path == path) return file;
}
foreach (var subFolder in folder.Folders)
{
var found = FindFileInFolder(subFolder, path);
if (found != null) return found;
}
return null;
}
private ScriptFolder? FindFolderInFolder(ScriptFolder folder, string path)
{
foreach (var subFolder in folder.Folders)
{
if (subFolder.Path == path) return subFolder;
var found = FindFolderInFolder(subFolder, path);
if (found != null) return found;
}
return null;
}
public void RemoveFile(ScriptFile file)
{
if (file.Parent is null)
{
WorkspaceFiles.Remove(file);
}
else
{
file.Parent.RemoveFile(file);
}
if (CurrentFile == file)
{
CurrentFile = null;
CurrentFileChanged?.Invoke(CurrentFile);
}
if (SelectedFile == file)
{
SelectedFile = null;
}
Solution updatedSolution = adhocWorkspace.CurrentSolution;
updatedSolution = updatedSolution.RemoveDocument(file.Id);
if (!adhocWorkspace.TryApplyChanges(updatedSolution))
throw new InvalidOperationException($"Xóa file {file.Path} trong workspace thất bại");
DiagnosticTimer.Stop();
DiagnosticTimer.Start();
RootChanged?.Invoke();
}
public void RemoveFolder(ScriptFolder folder)
{
if (folder.Parent is null)
{
WorkspaceFolders.Remove(folder);
}
else
{
folder.Parent.RemoveFolder(folder);
}
// Clear selection if selected file/folder is in the deleted folder
if (SelectedFile != null && SelectedFile.Path.StartsWith(folder.Path + System.IO.Path.DirectorySeparatorChar))
{
SelectedFile = null;
}
if (SelectedFolder != null && (SelectedFolder.Path == folder.Path || SelectedFolder.Path.StartsWith(folder.Path + System.IO.Path.DirectorySeparatorChar)))
{
SelectedFolder = null;
}
Solution updatedSolution = adhocWorkspace.CurrentSolution;
RemoveFolderFromWorkspace(updatedSolution, folder);
if (!adhocWorkspace.TryApplyChanges(updatedSolution))
throw new InvalidOperationException($"Xóa folder {folder.Path} trong workspace thất bại");
DiagnosticTimer.Stop();
DiagnosticTimer.Start();
RootChanged?.Invoke();
}
private ScriptFile CreateWrokspaceFile(ScriptFileDto file, ScriptFolder? parent = null)
{
var filePath = System.IO.Path.Combine(parent?.Path ?? "", file.Name);
Solution updatedSolution = adhocWorkspace.CurrentSolution;
var newId = DocumentId.CreateNewId(ProjectId);
updatedSolution = updatedSolution.AddDocument(newId, file.Name, SourceText.From(file.Code), parent?.Path.Split("/"), filePath);
if (!adhocWorkspace.TryApplyChanges(updatedSolution)) throw new InvalidOperationException("Tạo document mới thất bại");
if (!string.IsNullOrEmpty(file.Code))
{
DiagnosticTimer.Stop();
DiagnosticTimer.Start();
}
var scriptFile = new ScriptFile(newId, file, parent);
return scriptFile;
}
private ScriptFolder CreateWorkspaceFolder(ScriptFolderDto folder, ScriptFolder? parent = null)
{
var folderPath = System.IO.Path.Combine(parent?.Path ?? "", folder.Name);
var model = new ScriptFolder(folder, parent);
// Materialize the Select to avoid multiple enumerations
var files = folder.Files.Select(file => CreateWrokspaceFile(file, model)).ToList();
model.AddFiles(files);
// Materialize the Select to avoid multiple enumerations
var subfolders = folder.Folders.Select(dir => CreateWorkspaceFolder(dir, model)).ToList();
model.AddFolders(subfolders);
return model;
}
private void RemoveFolderFromWorkspace(Solution updatedSolution, ScriptFolder folder)
{
foreach (var dir in folder.Folders)
{
RemoveFolderFromWorkspace(updatedSolution, dir);
}
foreach (var file in folder.Files)
{
if (CurrentFile == file)
{
CurrentFile = null;
CurrentFileChanged?.Invoke(CurrentFile);
}
updatedSolution = updatedSolution.RemoveDocument(file.Id);
}
DiagnosticTimer.Stop();
DiagnosticTimer.Start();
}
private async Task DiagnosticProject()
{
var editorProject = adhocWorkspace.CurrentSolution.GetProject(ProjectId);
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 (CurrentFile is not null)
{
DiagnoticChanged?.Invoke(CurrentFile.Diagnostics);
}
}
private static async Task GetDiagnosticsToModel(Project project, IEnumerable<Diagnostic> diagnostics, ScriptFile file)
{
var document = project.Solution.GetDocument(file.Id);
if (document == null) return;
var syntaxTree = await document.GetSyntaxTreeAsync();
if (syntaxTree == null) return;
file.Diagnostics = diagnostics.Where(d => d.Location.IsInSource && d.Location.SourceTree == syntaxTree);
}
private static async Task GetDiagnosticsToModel(Project project, IEnumerable<Diagnostic> diagnostics, ScriptFolder folder)
{
foreach (var file in folder.Files)
{
await GetDiagnosticsToModel(project, diagnostics, file);
}
foreach (var subFolder in folder.Folders)
{
await GetDiagnosticsToModel(project, diagnostics, subFolder);
}
// Recalculate totals for this folder after all children have been updated
RecalculateFolderTotals(folder);
}
/// <summary>
/// Recursively recalculates WarningCount, ErrorCount, and IsModified for a folder and all its children.
/// </summary>
private static void RecalculateFolderTotals(ScriptFolder folder)
{
folder.RecalculateTotals();
}
private void DiagnosticTimer_Elapsed(object? sender, ElapsedEventArgs e)
{
Task.Run(DiagnosticProject).GetAwaiter().GetResult();
}
public void Dispose()
{
// Stop and dispose the diagnostic timer
DiagnosticTimer.Stop();
DiagnosticTimer.Elapsed -= DiagnosticTimer_Elapsed;
DiagnosticTimer.Dispose();
// Dispose the adhoc workspace
adhocWorkspace.Dispose();
// Clear event handlers to prevent memory leaks
ReadOnlyChanged = null;
DiagnoticChanged = null;
RootChanged = null;
// Clear collections
WorkspaceFiles.Clear();
WorkspaceFolders.Clear();
// Reset state
IsInitialized = false;
SelectedFile = null;
SelectedFolder = null;
GC.SuppressFinalize(this);
}
private static string BuildDevelopGlobalsScript(Type glovalType)
{
var sb = new StringBuilder();
// Build properties
foreach (var property in glovalType.GetProperties())
{
if (property.CanRead)
{
var setter = property.CanWrite ? "set; " : "";
sb.AppendLine($"{ScriptHelpers.ToString(property.PropertyType)} {property.Name} {{ get => throw new System.NotImplementedException(); {setter}}}");
}
else if (property.CanWrite)
{
sb.AppendLine($"{ScriptHelpers.ToString(property.PropertyType)} {property.Name} {{ set => throw new System.NotImplementedException(); }}");
}
}
// Build fields
foreach (var field in glovalType.GetFields())
{
sb.AppendLine($"{ScriptHelpers.ToString(field.FieldType)} {field.Name};");
}
// Build methods
foreach (var method in glovalType.GetMethods())
{
if (method.Name.StartsWith("get_") || method.Name.StartsWith("set_")) continue;
var parameters = string.Join(',', method.GetParameters().Select(parameter => ScriptHelpers.ToString(parameter)));
sb.AppendLine($"{ScriptHelpers.ToString(method.ReturnType)} {method.Name}({parameters}) => throw new System.NotImplementedException();");
}
return sb.ToString();
}
}