Files
SharpIDE/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit.cs
2025-10-11 12:46:50 +10:00

426 lines
15 KiB
C#

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<CodeAction> _currentCodeActionsInPopup = [];
private bool _fileChangingSuppressBreakpointToggleEvent;
public override void _Ready()
{
SyntaxHighlighter = _syntaxHighlighter;
_popupMenu = GetNode<PopupMenu>("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<SharpIdeRazorClassifiedSpan> 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);
}
}