diff --git a/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs b/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs index 113577b..86fbff9 100644 --- a/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs +++ b/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs @@ -5,6 +5,7 @@ using Ardalis.GuardClauses; using Microsoft.AspNetCore.Razor.Language; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Classification; +using Microsoft.CodeAnalysis.CodeActions; using Microsoft.CodeAnalysis.CodeFixes; using Microsoft.CodeAnalysis.CodeRefactorings; using Microsoft.CodeAnalysis.Completion; @@ -29,7 +30,8 @@ namespace SharpIDE.Application.Features.Analysis; public static class RoslynAnalysis { - public static MSBuildWorkspace? _workspace; + public static AdhocWorkspace? _workspace; + private static MSBuildProjectLoader? _msBuildProjectLoader; private static RemoteSnapshotManager? _snapshotManager; private static RemoteSemanticTokensLegendService? _semanticTokensLegendService; private static SharpIdeSolutionModel? _sharpIdeSolutionModel; @@ -68,7 +70,7 @@ public static class RoslynAnalysis var container = configuration.CreateContainer(); var host = MefHostServices.Create(container); - _workspace = MSBuildWorkspace.Create(host); + _workspace = new AdhocWorkspace(host); _workspace.RegisterWorkspaceFailedHandler(o => throw new InvalidOperationException($"Workspace failed: {o.Diagnostic.Message}")); var snapshotManager = container.GetExports().FirstOrDefault(); @@ -76,10 +78,14 @@ public static class RoslynAnalysis _semanticTokensLegendService = container.GetExports().FirstOrDefault(); _semanticTokensLegendService!.SetLegend(TokenTypeProvider.ConstructTokenTypes(false), TokenTypeProvider.ConstructTokenModifiers()); + + _msBuildProjectLoader = new MSBuildProjectLoader(_workspace); } using (var ___ = SharpIdeOtel.Source.StartActivity("OpenSolution")) { - var solution = await _workspace.OpenSolutionAsync(_sharpIdeSolutionModel.FilePath, new Progress()); + var solutionInfo = await _msBuildProjectLoader!.LoadSolutionInfoAsync(_sharpIdeSolutionModel.FilePath); + _workspace.ClearSolution(); + var solution = _workspace.AddSolution(solutionInfo); } timer.Stop(); Console.WriteLine($"RoslynAnalysis: Solution loaded in {timer.ElapsedMilliseconds}ms"); @@ -427,27 +433,49 @@ public static class RoslynAnalysis return completions; } - public static async Task ApplyCodeActionAsync(CodeAction codeAction) + /// Returns the list of files modified by applying the code action + public static async Task> ApplyCodeActionAsync(CodeAction codeAction) { using var _ = SharpIdeOtel.Source.StartActivity($"{nameof(RoslynAnalysis)}.{nameof(ApplyCodeActionAsync)}"); var cancellationToken = CancellationToken.None; var operations = await codeAction.GetOperationsAsync(cancellationToken); + var changedDocumentIds = new List(); foreach (var operation in operations) { - operation.Apply(_workspace!, cancellationToken); - // if (operation is ApplyChangesOperation applyChangesOperation) - // { - // var newSolution = applyChangesOperation.ChangedSolution; - // _workspace.TryApplyChanges(newSolution); - // } - // else - // { - // throw new NotSupportedException($"Unsupported operation type: {operation.GetType().Name}"); - // } + if (operation is ApplyChangesOperation applyChangesOperation) + { + var newSolution = applyChangesOperation.ChangedSolution; + var changedDocIds = newSolution + .GetChanges(_workspace!.CurrentSolution) + .GetProjectChanges() + .SelectMany(s => s.GetChangedDocuments()); + changedDocumentIds.AddRange(changedDocIds); + + _workspace.TryApplyChanges(newSolution); + } + else + { + throw new NotSupportedException($"Unsupported operation type: {operation.GetType().Name}"); + } } + + var changedFilesWithText = await changedDocumentIds + .DistinctBy(s => s.Id) + .Select(id => _workspace!.CurrentSolution.GetDocument(id)) + .Where(d => d is not null) + .OfType() // ensures non-null + .ToAsyncEnumerable() + .Select(async (Document doc, CancellationToken ct) => + { + var text = await doc.GetTextAsync(ct); + var sharpFile = _sharpIdeSolutionModel!.AllFiles.Single(f => f.Path == doc.FilePath); + return (sharpFile, text.ToString()); + }) + .ToListAsync(cancellationToken); + + return changedFilesWithText; } - // TODO: Use AdhocWorkspace or something else, to avoid writing to disk on every change public static void UpdateDocument(SharpIdeFile fileModel, string newContent) { Guard.Against.Null(fileModel, nameof(fileModel)); diff --git a/src/SharpIDE.Application/Features/FilePersistence/IdeFileManager.cs b/src/SharpIDE.Application/Features/FilePersistence/IdeFileManager.cs new file mode 100644 index 0000000..4bc2b15 --- /dev/null +++ b/src/SharpIDE.Application/Features/FilePersistence/IdeFileManager.cs @@ -0,0 +1,71 @@ +using System.Collections.Concurrent; +using SharpIDE.Application.Features.Analysis; +using SharpIDE.Application.Features.SolutionDiscovery; + +namespace SharpIDE.Application.Features.FilePersistence; +#pragma warning disable VSTHRD011 + +/// Holds the in memory copies of files, and manages saving/loading them to/from disk. +public class IdeFileManager +{ + private ConcurrentDictionary>> _openFiles = new(); + + /// Implicitly 'opens' a file if not already open, and returns the text. + public async Task GetFileTextAsync(SharpIdeFile file) + { + var textTaskLazy = _openFiles.GetOrAdd(file, f => + { + var lazy = new Lazy>(Task () => File.ReadAllTextAsync(f.Path)); + return lazy; + }); + var textTask = textTaskLazy.Value; + var text = await textTask; + return text; + } + + // Calling this assumes that the file is already open - may need to be revisited for code fixes and refactorings. I think all files involved in a multi-file fix/refactor shall just be saved to disk immediately. + public void UpdateFileTextInMemory(SharpIdeFile file, string newText) + { + if (!_openFiles.ContainsKey(file)) throw new InvalidOperationException("File is not open in memory."); + + var newLazyTask = new Lazy>(() => Task.FromResult(newText)); + _openFiles[file] = newLazyTask; + // Potentially should be event based? + if (file.IsRoslynWorkspaceFile) + { + RoslynAnalysis.UpdateDocument(file, newText); + } + } + + public async Task SaveFileAsync(SharpIdeFile file) + { + if (!_openFiles.ContainsKey(file)) throw new InvalidOperationException("File is not open in memory."); + + var text = await GetFileTextAsync(file); + await File.WriteAllTextAsync(file.Path, text); + file.IsDirty.Value = false; + } + + public async Task UpdateInMemoryIfOpenAndSaveAsync(SharpIdeFile file, string newText) + { + if (_openFiles.ContainsKey(file)) + { + UpdateFileTextInMemory(file, newText); + await SaveFileAsync(file); + } + else + { + await File.WriteAllTextAsync(file.Path, newText); + } + } + + public async Task SaveAllOpenFilesAsync() + { + foreach (var file in _openFiles.Keys) + { + await SaveFileAsync(file); + } + } +} + +#pragma warning restore VSTHRD011 diff --git a/src/SharpIDE.Application/Features/SolutionDiscovery/SharpIdeFile.cs b/src/SharpIDE.Application/Features/SolutionDiscovery/SharpIdeFile.cs index 7aa66e2..3d3a03d 100644 --- a/src/SharpIDE.Application/Features/SolutionDiscovery/SharpIdeFile.cs +++ b/src/SharpIDE.Application/Features/SolutionDiscovery/SharpIdeFile.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; +using R3; using SharpIDE.Application.Features.SolutionDiscovery.VsPersistence; namespace SharpIDE.Application.Features.SolutionDiscovery; @@ -10,6 +11,10 @@ public class SharpIdeFile : ISharpIdeNode, IChildSharpIdeNode public required string Path { get; set; } public required string Name { get; set; } public bool IsRazorFile => Path.EndsWith(".razor", StringComparison.OrdinalIgnoreCase); + public bool IsCshtmlFile => Path.EndsWith(".cshtml", StringComparison.OrdinalIgnoreCase); + public bool IsCsharpFile => Path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase); + public bool IsRoslynWorkspaceFile => IsCsharpFile || IsRazorFile || IsCshtmlFile; + public required ReactiveProperty IsDirty { get; set; } [SetsRequiredMembers] internal SharpIdeFile(string fullPath, string name, IExpandableSharpIdeNode parent, ConcurrentBag allFiles) @@ -17,6 +22,7 @@ public class SharpIdeFile : ISharpIdeNode, IChildSharpIdeNode Path = fullPath; Name = name; Parent = parent; + IsDirty = new ReactiveProperty(false); allFiles.Add(this); } } diff --git a/src/SharpIDE.Application/SharpIDE.Application.csproj b/src/SharpIDE.Application/SharpIDE.Application.csproj index 9b3149b..2798462 100644 --- a/src/SharpIDE.Application/SharpIDE.Application.csproj +++ b/src/SharpIDE.Application/SharpIDE.Application.csproj @@ -45,6 +45,7 @@ + diff --git a/src/SharpIDE.Godot/Features/BottomPanel/BottomPanelType.cs.uid b/src/SharpIDE.Godot/Features/BottomPanel/BottomPanelType.cs.uid new file mode 100644 index 0000000..d61f8df --- /dev/null +++ b/src/SharpIDE.Godot/Features/BottomPanel/BottomPanelType.cs.uid @@ -0,0 +1 @@ +uid://dyoci88kqk2r1 diff --git a/src/SharpIDE.Godot/Features/CodeEditor/CodeEditorPanel.cs b/src/SharpIDE.Godot/Features/CodeEditor/CodeEditorPanel.cs index 4bc0850..fc55fc7 100644 --- a/src/SharpIDE.Godot/Features/CodeEditor/CodeEditorPanel.cs +++ b/src/SharpIDE.Godot/Features/CodeEditor/CodeEditorPanel.cs @@ -1,5 +1,6 @@ using Ardalis.GuardClauses; using Godot; +using R3; using SharpIDE.Application.Features.Analysis; using SharpIDE.Application.Features.Debugging; using SharpIDE.Application.Features.Events; @@ -74,6 +75,16 @@ public partial class CodeEditorPanel : MarginContainer _tabContainer.SetTabTooltip(newTabIndex, file.Path); _tabContainer.CurrentTab = newTabIndex; }); + file.IsDirty.Skip(1).SubscribeOnThreadPool().SubscribeAwait(async (isDirty, ct) => + { + //GD.Print($"File dirty state changed: {file.Path} is now {(isDirty ? "dirty" : "clean")}"); + await this.InvokeAsync(() => + { + var tabIndex = newTab.GetIndex(); + var title = file.Name + (isDirty ? " (*)" : ""); + _tabContainer.SetTabTitle(tabIndex, title); + }); + }); await newTab.SetSharpIdeFile(file); if (fileLinePosition is not null) await this.InvokeAsync(() => newTab.SetFileLinePosition(fileLinePosition.Value)); } diff --git a/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit.cs b/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit.cs index 6512a39..6ebd368 100644 --- a/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit.cs +++ b/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit.cs @@ -1,5 +1,4 @@ using System.Collections.Immutable; -using Ardalis.GuardClauses; using Godot; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Classification; @@ -93,10 +92,11 @@ public partial class SharpIdeCodeEdit : CodeEdit GD.Print($"Selection changed to line {_currentLine}, start {_selectionStartCol}, end {_selectionEndCol}"); } - private void OnTextChanged() + private async void OnTextChanged() { - // update the MSBuildWorkspace - RoslynAnalysis.UpdateDocument(_currentFile, Text); + await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + _currentFile.IsDirty.Value = true; + Singletons.FileManager.UpdateFileTextInMemory(_currentFile, Text); _ = Task.GodotRun(async () => { var syntaxHighlighting = RoslynAnalysis.GetDocumentSyntaxHighlighting(_currentFile); @@ -122,8 +122,12 @@ public partial class SharpIdeCodeEdit : CodeEdit var vScroll = GetVScroll(); _ = Task.GodotRun(async () => { - await RoslynAnalysis.ApplyCodeActionAsync(codeAction); - var fileContents = await File.ReadAllTextAsync(_currentFile.Path); + var affectedFiles = await RoslynAnalysis.ApplyCodeActionAsync(codeAction); + foreach (var (affectedFile, updatedText) in affectedFiles) + { + await Singletons.FileManager.UpdateInMemoryIfOpenAndSaveAsync(affectedFile, updatedText); + } + var fileContents = await Singletons.FileManager.GetFileTextAsync(_currentFile); var syntaxHighlighting = await RoslynAnalysis.GetDocumentSyntaxHighlighting(_currentFile); var razorSyntaxHighlighting = await RoslynAnalysis.GetRazorDocumentSyntaxHighlighting(_currentFile); var diagnostics = await RoslynAnalysis.GetDocumentDiagnostics(_currentFile); @@ -156,7 +160,7 @@ public partial class SharpIdeCodeEdit : CodeEdit { await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); // get off the UI thread _currentFile = file; - var readFileTask = File.ReadAllTextAsync(_currentFile.Path); + var readFileTask = Singletons.FileManager.GetFileTextAsync(file); var syntaxHighlighting = RoslynAnalysis.GetDocumentSyntaxHighlighting(_currentFile); var razorSyntaxHighlighting = RoslynAnalysis.GetRazorDocumentSyntaxHighlighting(_currentFile); @@ -234,6 +238,20 @@ public partial class SharpIdeCodeEdit : CodeEdit { EmitSignalCodeFixesRequested(); } + else if (@event.IsActionPressed(InputStringNames.SaveAllFiles)) + { + _ = Task.GodotRun(async () => + { + await Singletons.FileManager.SaveAllOpenFilesAsync(); + }); + } + else if (@event.IsActionPressed(InputStringNames.SaveFile)) + { + _ = Task.GodotRun(async () => + { + await Singletons.FileManager.SaveFileAsync(_currentFile); + }); + } } diff --git a/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs b/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs index e9895ef..8c82d1d 100644 --- a/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs +++ b/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs @@ -130,7 +130,7 @@ public partial class SolutionExplorerPanel : MarginContainer foreach (var sharpIdeFolder in project.Folders) { - AddFoldertoTree(projectItem, sharpIdeFolder); + AddFolderToTree(projectItem, sharpIdeFolder); } foreach (var file in project.Files) @@ -139,7 +139,7 @@ public partial class SolutionExplorerPanel : MarginContainer } } - private void AddFoldertoTree(TreeItem projectItem, SharpIdeFolder sharpIdeFolder) + private void AddFolderToTree(TreeItem projectItem, SharpIdeFolder sharpIdeFolder) { var folderItem = _tree.CreateItem(projectItem); folderItem.SetText(0, sharpIdeFolder.Name); @@ -147,7 +147,7 @@ public partial class SolutionExplorerPanel : MarginContainer foreach (var subFolder in sharpIdeFolder.Folders) { - AddFoldertoTree(folderItem, subFolder); // recursion + AddFolderToTree(folderItem, subFolder); // recursion } foreach (var file in sharpIdeFolder.Files) diff --git a/src/SharpIDE.Godot/IdeRoot.cs b/src/SharpIDE.Godot/IdeRoot.cs index 228a27d..32d6821 100644 --- a/src/SharpIDE.Godot/IdeRoot.cs +++ b/src/SharpIDE.Godot/IdeRoot.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Hosting; using SharpIDE.Application.Features.Analysis; using SharpIDE.Application.Features.Build; using SharpIDE.Application.Features.Events; +using SharpIDE.Application.Features.FilePersistence; using SharpIDE.Application.Features.FileWatching; using SharpIDE.Application.Features.Run; using SharpIDE.Application.Features.SolutionDiscovery; @@ -45,6 +46,7 @@ public partial class IdeRoot : Control Singletons.BuildService = new BuildService(); Singletons.FileWatcher?.Dispose(); Singletons.FileWatcher = new IdeFileWatcher(); + Singletons.FileManager = new IdeFileManager(); } public override void _Ready() diff --git a/src/SharpIDE.Godot/InputStringNames.cs b/src/SharpIDE.Godot/InputStringNames.cs index e7e5e17..a43c99a 100644 --- a/src/SharpIDE.Godot/InputStringNames.cs +++ b/src/SharpIDE.Godot/InputStringNames.cs @@ -7,4 +7,6 @@ public static class InputStringNames public static readonly StringName CodeFixes = "CodeFixes"; public static readonly StringName StepOver = "StepOver"; public static readonly StringName FindInFiles = nameof(FindInFiles); + public static readonly StringName SaveFile = nameof(SaveFile); + public static readonly StringName SaveAllFiles = nameof(SaveAllFiles); } \ No newline at end of file diff --git a/src/SharpIDE.Godot/Singletons.cs b/src/SharpIDE.Godot/Singletons.cs index 618cc1a..a3a6fe8 100644 --- a/src/SharpIDE.Godot/Singletons.cs +++ b/src/SharpIDE.Godot/Singletons.cs @@ -1,4 +1,5 @@ using SharpIDE.Application.Features.Build; +using SharpIDE.Application.Features.FilePersistence; using SharpIDE.Application.Features.FileWatching; using SharpIDE.Application.Features.Run; using SharpIDE.Godot.Features.IdeSettings; @@ -10,5 +11,6 @@ public static class Singletons public static RunService RunService { get; set; } = null!; public static BuildService BuildService { get; set; } = null!; public static IdeFileWatcher FileWatcher { get; set; } = null!; + public static IdeFileManager FileManager { get; set; } = null!; public static AppState AppState { get; set; } = null!; } \ No newline at end of file diff --git a/src/SharpIDE.Godot/project.godot b/src/SharpIDE.Godot/project.godot index bbba30c..4df9a49 100644 --- a/src/SharpIDE.Godot/project.godot +++ b/src/SharpIDE.Godot/project.godot @@ -51,3 +51,13 @@ FindInFiles={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":true,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":70,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) ] } +SaveFile={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":true,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +SaveAllFiles={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":true,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +}