using System.Collections.Immutable; using Godot; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Classification; using Microsoft.CodeAnalysis.CodeActions; using Microsoft.CodeAnalysis.Text; using SharpIDE.Application.Features.Analysis; using SharpIDE.Application.Features.Debugging; using SharpIDE.Application.Features.SolutionDiscovery; using SharpIDE.Application.Features.SolutionDiscovery.VsPersistence; using SharpIDE.RazorAccess; using Task = System.Threading.Tasks.Task; namespace SharpIDE.Godot.Features.CodeEditor; public partial class SharpIdeCodeEdit : CodeEdit { [Signal] public delegate void CodeFixesRequestedEventHandler(); private int _currentLine; private int _selectionStartCol; private int _selectionEndCol; public SharpIdeSolutionModel? Solution { get; set; } public SharpIdeFile SharpIdeFile => _currentFile; private SharpIdeFile _currentFile = null!; private CustomHighlighter _syntaxHighlighter = new(); private PopupMenu _popupMenu = null!; private ImmutableArray<(FileLinePositionSpan fileSpan, Diagnostic diagnostic)> _diagnostics = []; private ImmutableArray _currentCodeActionsInPopup = []; private bool _fileChangingSuppressBreakpointToggleEvent; public override void _Ready() { SyntaxHighlighter = _syntaxHighlighter; _popupMenu = GetNode("CodeFixesMenu"); _popupMenu.IdPressed += OnCodeFixSelected; CodeCompletionRequested += OnCodeCompletionRequested; CodeFixesRequested += OnCodeFixesRequested; BreakpointToggled += OnBreakpointToggled; CaretChanged += OnCaretChanged; TextChanged += OnTextChanged; SymbolHovered += OnSymbolHovered; SymbolValidate += OnSymbolValidate; SymbolLookup += OnSymbolLookup; } public override void _ExitTree() { _currentFile?.FileContentsChangedExternallyFromDisk.Unsubscribe(OnFileChangedExternallyFromDisk); _currentFile?.FileContentsChangedExternally.Unsubscribe(OnFileChangedExternallyInMemory); } private void OnBreakpointToggled(long line) { if (_fileChangingSuppressBreakpointToggleEvent) return; var lineInt = (int)line; var breakpointAdded = IsLineBreakpointed(lineInt); var lineForDebugger = lineInt + 1; // Godot is 0-indexed, Debugging is 1-indexed var breakpoints = Singletons.RunService.Breakpoints.GetOrAdd(_currentFile, []); if (breakpointAdded) { breakpoints.Add(new Breakpoint { Line = lineForDebugger } ); } else { var breakpoint = breakpoints.Single(b => b.Line == lineForDebugger); breakpoints.Remove(breakpoint); } SetLineColour(lineInt); GD.Print($"Breakpoint {(breakpointAdded ? "added" : "removed")} at line {lineForDebugger}"); } private void OnSymbolLookup(string symbol, long line, long column) { GD.Print($"Symbol lookup requested: {symbol} at line {line}, column {column}"); } private void OnSymbolValidate(string symbol) { GD.Print($"Symbol validating: {symbol}"); //var valid = symbol.Contains(' ') is false; //SetSymbolLookupWordAsValid(valid); SetSymbolLookupWordAsValid(true); } private async void OnSymbolHovered(string symbol, long line, long column) { if (HasFocus() is false) return; // only show if we have focus, every tab is currently listening for this event, maybe find a better way GD.Print($"Symbol hovered: {symbol} at line {line}, column {column}"); var roslynSymbol = await RoslynAnalysis.LookupSymbol(_currentFile, new LinePosition((int)line, (int)column)); if (roslynSymbol is null) { return; } var popupPanel = new PopupPanel(); popupPanel.Unfocusable = true; // may need to change eventually for navigating to other symbols popupPanel.MouseExited += () => popupPanel.QueueFree(); // set background color var styleBox = new StyleBoxFlat { BgColor = new Color("2b2d30"), BorderColor = new Color("3e4045"), BorderWidthTop = 1, BorderWidthBottom = 1, BorderWidthLeft = 1, BorderWidthRight = 1, CornerRadiusBottomLeft = 4, CornerRadiusBottomRight = 4, CornerRadiusTopLeft = 4, CornerRadiusTopRight = 4, ShadowSize = 2, ShadowColor = new Color(0, 0, 0, 0.5f), ContentMarginTop = 10, ContentMarginBottom = 10, ContentMarginLeft = 10, ContentMarginRight = 10 }; popupPanel.AddThemeStyleboxOverride("panel", styleBox); var symbolInfoNode = roslynSymbol switch { IMethodSymbol methodSymbol => SymbolInfoComponents.GetMethodSymbolInfo(methodSymbol), _ => new Control() }; popupPanel.AddChild(symbolInfoNode); AddChild(popupPanel); var globalSymbolPosition = GetRectAtLineColumn((int)line, (int)column).Position + GetGlobalPosition(); globalSymbolPosition.Y += GetLineHeight(); var globalMousePosition = GetGlobalMousePosition(); popupPanel.Position = new Vector2I((int)globalMousePosition.X, (int)globalSymbolPosition.Y); popupPanel.Popup(); } private void OnCaretChanged() { _selectionStartCol = GetSelectionFromColumn(); _selectionEndCol = GetSelectionToColumn(); _currentLine = GetCaretLine(); // GD.Print($"Selection changed to line {_currentLine}, start {_selectionStartCol}, end {_selectionEndCol}"); } private async void OnTextChanged() { await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); _currentFile.IsDirty.Value = true; Singletons.FileManager.UpdateFileTextInMemory(_currentFile, Text); _ = Task.GodotRun(async () => { var syntaxHighlighting = RoslynAnalysis.GetDocumentSyntaxHighlighting(_currentFile); var razorSyntaxHighlighting = RoslynAnalysis.GetRazorDocumentSyntaxHighlighting(_currentFile); var diagnostics = RoslynAnalysis.GetDocumentDiagnostics(_currentFile); var slnDiagnostics = RoslynAnalysis.UpdateSolutionDiagnostics(); await Task.WhenAll(syntaxHighlighting, razorSyntaxHighlighting, diagnostics); Callable.From(() => { SetSyntaxHighlightingModel(syntaxHighlighting.Result, razorSyntaxHighlighting.Result); SetDiagnosticsModel(diagnostics.Result); }).CallDeferred(); await slnDiagnostics; }); } // TODO: This is now significantly slower, invoke -> text updated in editor private void OnCodeFixSelected(long id) { GD.Print($"Code fix selected: {id}"); var codeAction = _currentCodeActionsInPopup[(int)id]; if (codeAction is null) return; _ = Task.GodotRun(async () => { var affectedFiles = await RoslynAnalysis.ApplyCodeActionAsync(codeAction); // TODO: This can be more efficient - we can just update in memory and proceed with highlighting etc. Save to disk in background. foreach (var (affectedFile, updatedText) in affectedFiles) { await Singletons.FileManager.UpdateInMemoryIfOpenAndSaveAsync(affectedFile, updatedText); affectedFile.FileContentsChangedExternally.InvokeParallelFireAndForget(); } }); } private async Task OnFileChangedExternallyInMemory() { var fileContents = await Singletons.FileManager.GetFileTextAsync(_currentFile); var syntaxHighlighting = RoslynAnalysis.GetDocumentSyntaxHighlighting(_currentFile); var razorSyntaxHighlighting = RoslynAnalysis.GetRazorDocumentSyntaxHighlighting(_currentFile); var diagnostics = RoslynAnalysis.GetDocumentDiagnostics(_currentFile); var slnDiagnostics = RoslynAnalysis.UpdateSolutionDiagnostics(); await Task.WhenAll(syntaxHighlighting, razorSyntaxHighlighting, diagnostics); Callable.From(() => { var currentCaretPosition = GetCaretPosition(); var vScroll = GetVScroll(); BeginComplexOperation(); SetText(fileContents); SetSyntaxHighlightingModel(syntaxHighlighting.Result, razorSyntaxHighlighting.Result); SetDiagnosticsModel(diagnostics.Result); SetCaretLine(currentCaretPosition.line); SetCaretColumn(currentCaretPosition.col); SetVScroll(vScroll); EndComplexOperation(); }).CallDeferred(); await slnDiagnostics; } public void SetFileLinePosition(SharpIdeFileLinePosition fileLinePosition) { var line = fileLinePosition.Line - 1; var column = fileLinePosition.Column - 1; SetCaretLine(line); SetCaretColumn(column); CenterViewportToCaret(); GrabFocus(); } // TODO: Ensure not running on UI thread public async Task SetSharpIdeFile(SharpIdeFile file) { await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); // get off the UI thread _currentFile = file; var readFileTask = Singletons.FileManager.GetFileTextAsync(file); _currentFile.FileContentsChangedExternally.Subscribe(OnFileChangedExternallyInMemory); _currentFile.FileContentsChangedExternallyFromDisk.Subscribe(OnFileChangedExternallyFromDisk); var syntaxHighlighting = RoslynAnalysis.GetDocumentSyntaxHighlighting(_currentFile); var razorSyntaxHighlighting = RoslynAnalysis.GetRazorDocumentSyntaxHighlighting(_currentFile); var diagnostics = RoslynAnalysis.GetDocumentDiagnostics(_currentFile); var setTextTask = this.InvokeAsync(async () => { _fileChangingSuppressBreakpointToggleEvent = true; SetText(await readFileTask); _fileChangingSuppressBreakpointToggleEvent = false; }); await Task.WhenAll(syntaxHighlighting, razorSyntaxHighlighting, setTextTask); // Text must be set before setting syntax highlighting SetSyntaxHighlightingModel(await syntaxHighlighting, await razorSyntaxHighlighting); SetDiagnosticsModel(await diagnostics); } private async Task OnFileChangedExternallyFromDisk() { await Singletons.FileManager.ReloadFileFromDisk(_currentFile); await OnFileChangedExternallyInMemory(); } public void UnderlineRange(int line, int caretStartCol, int caretEndCol, Color color, float thickness = 1.5f) { if (line < 0 || line >= GetLineCount()) return; if (caretStartCol > caretEndCol) // something went wrong return; // Clamp columns to line length int lineLength = GetLine(line).Length; caretStartCol = Mathf.Clamp(caretStartCol, 0, lineLength); caretEndCol = Mathf.Clamp(caretEndCol, 0, lineLength); // GetRectAtLineColumn returns the rectangle for the character before the column passed in, or the first character if the column is 0. var startRect = GetRectAtLineColumn(line, caretStartCol); var endRect = GetRectAtLineColumn(line, caretEndCol); //DrawLine(startRect.Position, startRect.End, color); //DrawLine(endRect.Position, endRect.End, color); var startPos = startRect.End; if (caretStartCol is 0) { startPos.X -= startRect.Size.X; } var endPos = endRect.End; startPos.Y -= 3; endPos.Y -= 3; if (caretStartCol == caretEndCol) { endPos.X += 10; } DrawDashedLine(startPos, endPos, color, thickness); //DrawLine(startPos, endPos, color, thickness); } public override void _Draw() { //UnderlineRange(_currentLine, _selectionStartCol, _selectionEndCol, new Color(1, 0, 0)); foreach (var (fileSpan, diagnostic) in _diagnostics) { if (diagnostic.Location.IsInSource) { var line = fileSpan.StartLinePosition.Line; var startCol = fileSpan.StartLinePosition.Character; var endCol = fileSpan.EndLinePosition.Character; var color = diagnostic.Severity switch { DiagnosticSeverity.Error => new Color(1, 0, 0), DiagnosticSeverity.Warning => new Color("ffb700"), _ => new Color(0, 1, 0) // Info or other }; UnderlineRange(line, startCol, endCol, color); } } } public override void _UnhandledKeyInput(InputEvent @event) { if (HasFocus() is false) return; // every tab is currently listening for this input. Only respond if we have focus. Consider refactoring this _UnhandledKeyInput to CodeEditorPanel if (@event.IsActionPressed(InputStringNames.CodeFixes)) { 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); }); } } private readonly Color _breakpointLineColor = new Color("3a2323"); private readonly Color _executingLineColor = new Color("665001"); public void SetLineColour(int line) { var breakpointed = IsLineBreakpointed(line); var executing = IsLineExecuting(line); var lineColour = (breakpointed, executing) switch { (_, true) => _executingLineColor, (true, false) => _breakpointLineColor, (false, false) => Colors.Transparent }; SetLineBackgroundColor(line, lineColour); } private void SetDiagnosticsModel(ImmutableArray<(FileLinePositionSpan fileSpan, Diagnostic diagnostic)> diagnostics) { _diagnostics = diagnostics; } private void SetSyntaxHighlightingModel(IEnumerable<(FileLinePositionSpan fileSpan, ClassifiedSpan classifiedSpan)> classifiedSpans, IEnumerable razorClassifiedSpans) { _syntaxHighlighter.ClassifiedSpans = classifiedSpans.ToHashSet(); _syntaxHighlighter.RazorClassifiedSpans = razorClassifiedSpans.ToHashSet(); Callable.From(() => { _syntaxHighlighter.ClearHighlightingCache(); //_syntaxHighlighter.UpdateCache(); SyntaxHighlighter = null; SyntaxHighlighter = _syntaxHighlighter; // Reassign to trigger redraw }).CallDeferred(); } private void OnCodeFixesRequested() { var (caretLine, caretColumn) = GetCaretPosition(); var popupMenuPosition = GetCaretDrawPos() with { X = 0 } + GetGlobalPosition(); _popupMenu.Position = new Vector2I((int)popupMenuPosition.X, (int)popupMenuPosition.Y); _popupMenu.Clear(); _popupMenu.AddItem("Getting Context Actions...", 0); _popupMenu.Popup(); GD.Print($"Code fixes requested at line {caretLine}, column {caretColumn}"); _ = Task.GodotRun(async () => { var linePos = new LinePosition(caretLine, caretColumn); var codeActions = await RoslynAnalysis.GetCodeFixesForDocumentAtPosition(_currentFile, linePos); await this.InvokeAsync(() => { _popupMenu.Clear(); foreach (var (index, codeAction) in codeActions.Index()) { _currentCodeActionsInPopup = codeActions; _popupMenu.AddItem(codeAction.Title, index); //_popupMenu.SetItemMetadata(menuItem, codeAction); } if (codeActions.Length is not 0) _popupMenu.SetFocusedItem(0); GD.Print($"Code fixes found: {codeActions.Length}, displaying menu"); }); }); } private void OnCodeCompletionRequested() { var (caretLine, caretColumn) = GetCaretPosition(); GD.Print($"Code completion requested at line {caretLine}, column {caretColumn}"); _ = Task.GodotRun(async () => { var linePos = new LinePosition(caretLine, caretColumn); var completions = await RoslynAnalysis.GetCodeCompletionsForDocumentAtPosition(_currentFile, linePos); await this.InvokeAsync(() => { foreach (var completionItem in completions.ItemsList) { AddCodeCompletionOption(CodeCompletionKind.Class, completionItem.DisplayText, completionItem.DisplayText); } // partially working - displays menu only when caret is what CodeEdit determines as valid UpdateCodeCompletionOptions(true); //RequestCodeCompletion(true); GD.Print($"Found {completions.ItemsList.Count} completions, displaying menu"); }); }); } private (int line, int col) GetCaretPosition() { var caretColumn = GetCaretColumn(); var caretLine = GetCaretLine(); return (caretLine, caretColumn); } }