mirror of https://github.com/icsharpcode/ILSpy.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1523 lines
62 KiB
1523 lines
62 KiB
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team |
|
// |
|
// Permission is hereby granted, free of charge, to any person obtaining a copy of this |
|
// software and associated documentation files (the "Software"), to deal in the Software |
|
// without restriction, including without limitation the rights to use, copy, modify, merge, |
|
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons |
|
// to whom the Software is furnished to do so, subject to the following conditions: |
|
// |
|
// The above copyright notice and this permission notice shall be included in all copies or |
|
// substantial portions of the Software. |
|
// |
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, |
|
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR |
|
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE |
|
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR |
|
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER |
|
// DEALINGS IN THE SOFTWARE. |
|
|
|
using System; |
|
using System.Collections.Generic; |
|
using System.ComponentModel; |
|
using System.Linq; |
|
using System.Reflection.Metadata.Ecma335; |
|
using System.Xml; |
|
|
|
using Avalonia; |
|
using Avalonia.Controls; |
|
using Avalonia.Controls.Documents; |
|
using Avalonia.Controls.Primitives; |
|
using Avalonia.Input; |
|
using Avalonia.Interactivity; |
|
using Avalonia.Media; |
|
using Avalonia.Threading; |
|
using Avalonia.VisualTree; |
|
|
|
using AvaloniaEdit.Document; |
|
using AvaloniaEdit.Folding; |
|
using AvaloniaEdit.Highlighting; |
|
|
|
using ICSharpCode.Decompiler.CSharp.OutputVisitor; |
|
using ICSharpCode.Decompiler.Disassembler; |
|
using ICSharpCode.Decompiler.Documentation; |
|
using ICSharpCode.Decompiler.Metadata; |
|
using ICSharpCode.Decompiler.Output; |
|
using ICSharpCode.Decompiler.TypeSystem; |
|
using ICSharpCode.ILSpyX; |
|
using ICSharpCode.ILSpyX.TreeView; |
|
|
|
using ICSharpCode.ILSpy; |
|
using ICSharpCode.ILSpy.AppEnv; |
|
using ICSharpCode.ILSpy.AssemblyTree; |
|
using ICSharpCode.ILSpy.Options; |
|
using ICSharpCode.ILSpy.TreeNodes; |
|
|
|
namespace ICSharpCode.ILSpy.TextView |
|
{ |
|
public partial class DecompilerTextView : UserControl |
|
{ |
|
// Two-track output of BuildHoverContent: rich content opens the sticky Popup with the |
|
// pointer-distance corridor; plain content uses Avalonia's ToolTip attached property. |
|
internal readonly record struct HoverContent(Control Control, bool IsRich); |
|
|
|
// Local-reference highlight colours: light-yellow "GreenYellow" for matches and a |
|
// softer green for the actual definition. |
|
static readonly Color LocalMatchBackground = Colors.GreenYellow; |
|
static readonly Color LocalDefinitionBackground = Color.FromArgb(0x80, 0xA0, 0xFF, 0xA0); |
|
|
|
// Stay-open corridor for the rich popup: as long as the pointer is closer than |
|
// `distanceToPopupLimit` to the popup edges, the popup stays. The limit shrinks toward |
|
// the pointer's last best distance (bounded by MaxMovementAwayFromPopup) so the user |
|
// has room to "reach for" the popup but can't drift away from it. |
|
const double MaxMovementAwayFromPopup = 5; |
|
|
|
// Assigned once in the ctor's Setup* helpers (so not readonly -- the compiler can't prove |
|
// single-assignment across methods); never reassigned afterwards. |
|
ReferenceElementGenerator referenceElementGenerator = null!; |
|
UIElementGenerator uiElementGenerator = null!; |
|
TextMarkerService textMarkerService = null!; |
|
BracketHighlightRenderer bracketHighlightRenderer = null!; |
|
readonly List<TextMarker> localReferenceMarks = new(); |
|
readonly List<AvaloniaEdit.Rendering.VisualLineElementGenerator> activeCustomGenerators = new(); |
|
RichTextColorizer? activeColorizer; |
|
FoldingManager? activeFoldingManager; |
|
// Outgoing model tracked so OnDataContextChanged can detach handlers and snapshot |
|
// folding state before the new DataContext takes over -- Avalonia doesn't surface |
|
// the previous DataContext to the event handler, and Dock recycles the view across |
|
// tabs (see App.axaml ControlRecyclingKey). |
|
DecompilerTabPageModel? boundModel; |
|
// The editor's template ScrollViewer. AvaloniaEdit's TextEditor.ScrollViewer is internal, |
|
// and its ScrollToVerticalOffset/ScrollToHorizontalOffset are no-ops in 12.0.0 |
|
// (AvaloniaUI/AvaloniaEdit#594) -- so we reach the ScrollViewer ourselves and set Offset. |
|
// TODO: drop this field and use Editor.ScrollToVerticalOffset once #594 ships and we bump the package. |
|
ScrollViewer? editorScrollViewer; |
|
DisplaySettings? currentDisplaySettings; |
|
ReferenceSegment? lastRightClickedSegment; |
|
// Document offset under the pointer at the last right-click, or null if the click missed the |
|
// text. Position-relative menu entries (e.g. "Toggle folding") read this so they act on the |
|
// clicked line rather than wherever the caret happens to sit. |
|
int? lastRightClickedOffset; |
|
Bookmarks.BookmarkMargin? bookmarkMargin; |
|
IReadOnlyList<IContextMenuEntryExport> contextMenuEntries = Array.Empty<IContextMenuEntryExport>(); |
|
Popup richPopup = null!; |
|
double distanceToPopupLimit; |
|
|
|
/// <summary> |
|
/// AvaloniaEdit's Ctrl+F search overlay, attached to the editor's TextArea at |
|
/// construction time. Public for tests; runtime users go through Ctrl+F. |
|
/// </summary> |
|
public AvaloniaEdit.Search.SearchPanel SearchPanel { get; } |
|
|
|
public DecompilerTextView() |
|
{ |
|
InitializeComponent(); |
|
|
|
// Ctrl+F find overlay. SearchPanel.Install registers the SearchInputHandler with |
|
// the TextArea's nested-handler chain so the gesture surfaces a search bar without |
|
// us wiring KeyBindings manually. Stored for tests; runtime path is the gesture. |
|
SearchPanel = AvaloniaEdit.Search.SearchPanel.Install(Editor); |
|
|
|
// AvaloniaEdit defaults to "Ctrl+Click to follow hyperlink" on its built-in |
|
// LinkElementGenerator and propagates that flag onto every VisualLineLinkText it |
|
// constructs (including those produced by user-added LinkElementGenerator subclasses |
|
// like the About-page resource generator). Decompiler output uses hyperlinks |
|
// extensively for in-app navigation — a plain click is the expected affordance. |
|
Editor.Options.RequireControlModifierForHyperlinkClick = false; |
|
|
|
SetupElementGenerators(); |
|
SetupBackgroundRenderers(); |
|
SetupHoverHandlers(); |
|
SetupContextMenuAndHyperlinks(); |
|
SetupRichPopup(); |
|
WireUpDisplaySettings(); |
|
|
|
// Caret-position changes drive the bracket-pair highlight. View state (caret + scroll |
|
// + foldings) is NOT pushed onto the model here -- DockWorkspace pulls it on demand via |
|
// the model's CaptureViewState delegate when it records a navigation away, so a |
|
// programmatic caret move (AvaloniaEdit jumps the caret to the end on a text replace) |
|
// is never mistaken for the user's position. |
|
Editor.TextArea.Caret.PositionChanged += OnCaretPositionChanged; |
|
|
|
SetupZoomAndCopy(); |
|
|
|
// Ctrl+L focuses the omnibar into search mode (browser address-bar gesture). Tunnel so |
|
// it wins before AvaloniaEdit's own key handling while focus is anywhere in the editor. |
|
AddHandler(KeyDownEvent, OnPreviewKeyDownForOmnibar, RoutingStrategies.Tunnel); |
|
|
|
// Ctrl+B toggles a bookmark on the caret line. Bubble so normal editor keys keep working. |
|
AddHandler(KeyDownEvent, OnBookmarkKeyDown, RoutingStrategies.Bubble); |
|
|
|
// Bookmark icon gutter, leftmost (before the line numbers). It draws nothing unless the |
|
// current document is C# and has bookmarks, so it is harmless on IL / metadata views. |
|
bookmarkMargin = new Bookmarks.BookmarkMargin(this); |
|
Editor.TextArea.LeftMargins.Insert(0, bookmarkMargin); |
|
|
|
// The text area paints the I-beam across its whole surface and the gutter margins inherit it |
|
// by walking up the visual tree. The left margins (bookmark gutter, line numbers, folding) are |
|
// click targets, not text, so force the normal arrow there. Line numbers and folding come and |
|
// go as their display settings toggle, so re-apply whenever the margin collection changes. |
|
Editor.TextArea.LeftMargins.CollectionChanged += (_, _) => ApplyArrowCursorToLeftMargins(); |
|
ApplyArrowCursorToLeftMargins(); |
|
} |
|
|
|
internal static readonly Cursor ArrowCursor = new(StandardCursorType.Arrow); |
|
|
|
void ApplyArrowCursorToLeftMargins() |
|
{ |
|
foreach (var margin in Editor.TextArea.LeftMargins) |
|
margin.Cursor = ArrowCursor; |
|
} |
|
|
|
void OnPreviewKeyDownForOmnibar(object? sender, KeyEventArgs e) |
|
{ |
|
if (e.Key == Key.L && e.KeyModifiers == KeyModifiers.Control && Omnibar.IsVisible) |
|
{ |
|
Omnibar.FocusSearch(); |
|
e.Handled = true; |
|
} |
|
} |
|
|
|
// One generator lives for the lifetime of the view; we only swap its References collection |
|
// per document. The predicate filters out anything that should not be clickable -- references |
|
// with a null target slip through unconditionally otherwise. |
|
void SetupElementGenerators() |
|
{ |
|
referenceElementGenerator = new ReferenceElementGenerator(static segment => segment.Reference != null); |
|
Editor.TextArea.TextView.ElementGenerators.Add(referenceElementGenerator); |
|
|
|
uiElementGenerator = new UIElementGenerator(); |
|
Editor.TextArea.TextView.ElementGenerators.Add(uiElementGenerator); |
|
|
|
// Reference navigation fires on pointer-RELEASE without drag (WPF parity: the WPF |
|
// view used TextArea.PreviewMouseDown/Up the same way), so a press-and-drag over a |
|
// link starts a text selection instead of navigating away. The press handler only |
|
// records the start position, so tunnel routing (before AvaloniaEdit consumes the |
|
// press) is fine. |
|
Editor.TextArea.AddHandler(InputElement.PointerPressedEvent, |
|
OnTextAreaPointerPressedForReferenceClick, |
|
RoutingStrategies.Tunnel, |
|
handledEventsToo: true); |
|
// The release handler must run AFTER AvaloniaEdit's own PointerReleased: AvaloniaEdit's |
|
// SelectionMouseHandler captures the pointer and enters selection mode on press, and |
|
// only its release handler resets that mode and releases the capture. AvaloniaEdit |
|
// subscribes in the bubble phase from the TextArea constructor (before this view adds |
|
// its handlers), so a bubble handler here is guaranteed to run after it. handledEventsToo |
|
// keeps us running even though AvaloniaEdit marks the release handled. |
|
Editor.TextArea.AddHandler(InputElement.PointerReleasedEvent, |
|
OnTextAreaPointerReleasedForReferenceClick, |
|
RoutingStrategies.Bubble, |
|
handledEventsToo: true); |
|
} |
|
|
|
// Position of the last left-button press, in this control's coordinates; null while no |
|
// press is in flight. The release compares against it to tell a click from a drag. |
|
Point? referenceClickStart; |
|
|
|
// WPF used SystemParameters.MinimumHorizontal/VerticalDragDistance, which default to 4. |
|
const double MinimumDragDistance = 4; |
|
|
|
void OnTextAreaPointerPressedForReferenceClick(object? sender, PointerPressedEventArgs e) |
|
{ |
|
referenceClickStart = e.GetCurrentPoint(this).Properties.IsLeftButtonPressed |
|
? e.GetPosition(this) |
|
: null; |
|
} |
|
|
|
void OnTextAreaPointerReleasedForReferenceClick(object? sender, PointerReleasedEventArgs e) |
|
{ |
|
var start = referenceClickStart; |
|
referenceClickStart = null; |
|
if (start == null || e.InitialPressMouseButton != MouseButton.Left) |
|
return; |
|
var delta = e.GetPosition(this) - start.Value; |
|
if (Math.Abs(delta.X) >= MinimumDragDistance || Math.Abs(delta.Y) >= MinimumDragDistance) |
|
return; |
|
|
|
var segment = GetReferenceSegmentAtPointer(e); |
|
if (segment?.Reference == null) |
|
{ |
|
// A click on empty space dismisses the local-reference highlight. |
|
ClearLocalReferenceMarks(); |
|
return; |
|
} |
|
// AvaloniaEdit's own release handler has already reset its selection mode and released |
|
// the pointer capture (it runs first in the bubble phase). Clear the empty caret-click |
|
// selection it leaves behind, then navigate; marking the release handled stops it from |
|
// bubbling further now that it has been consumed as a link click. |
|
Editor.TextArea.ClearSelection(); |
|
e.Handled = true; |
|
OnReferenceClicked(segment); |
|
} |
|
|
|
// Background renderers that live for the view's lifetime: the local-reference highlight (marks |
|
// cleared/rebuilt per click) and the bracket-pair outline (repainted on every caret move). |
|
void SetupBackgroundRenderers() |
|
{ |
|
textMarkerService = new TextMarkerService(Editor.TextArea.TextView); |
|
Editor.TextArea.TextView.BackgroundRenderers.Add(textMarkerService); |
|
bracketHighlightRenderer = new BracketHighlightRenderer(Editor.TextArea.TextView); |
|
} |
|
|
|
// Subscribe to AvaloniaEdit's built-in PointerHover routed event (fires after the cursor |
|
// settles inside a small box for ~400 ms) and resolve the segment fresh at hover-time using |
|
// the live pointer position. PointerMoved still drives the rich popup's distance corridor. |
|
void SetupHoverHandlers() |
|
{ |
|
ToolTip.SetPlacement(Editor.TextArea.TextView, PlacementMode.Pointer); |
|
ToolTip.SetBetweenShowDelay(Editor.TextArea.TextView, 0); |
|
Editor.TextArea.TextView.AddHandler( |
|
AvaloniaEdit.Rendering.TextView.PointerHoverEvent, |
|
OnTextViewPointerHover, |
|
RoutingStrategies.Bubble, |
|
handledEventsToo: true); |
|
Editor.TextArea.TextView.AddHandler( |
|
AvaloniaEdit.Rendering.TextView.PointerHoverStoppedEvent, |
|
OnTextViewPointerHoverStopped, |
|
RoutingStrategies.Bubble, |
|
handledEventsToo: true); |
|
Editor.TextArea.TextView.PointerMoved += OnTextViewPointerMoved; |
|
Editor.TextArea.TextView.PointerExited += OnTextViewPointerExited; |
|
} |
|
|
|
void SetupContextMenuAndHyperlinks() |
|
{ |
|
// Context menu -- captures the segment under the pointer at right-click time so entries |
|
// like "Show in metadata" can dispatch through the same Reference field the assembly |
|
// tree's right-click already populates. |
|
Editor.TextArea.TextView.AddHandler(InputElement.PointerPressedEvent, |
|
OnTextViewPointerPressedForContextMenu, |
|
RoutingStrategies.Tunnel, |
|
handledEventsToo: true); |
|
AttachContextMenu(TryGetContextMenuEntries()); |
|
|
|
// AvaloniaEdit's hyperlinks raise OpenUriEvent (bubbling); the default class handler on |
|
// Window passes the URI to Process.Start. Intercept first so internal schemes (the About |
|
// page's "resource:" URIs) route through the tab model instead. |
|
Editor.TextArea.TextView.AddHandler( |
|
AvaloniaEdit.Rendering.VisualLineLinkText.OpenUriEvent, |
|
OnHyperlinkOpenUri, |
|
RoutingStrategies.Bubble, |
|
handledEventsToo: false); |
|
} |
|
|
|
void SetupRichPopup() |
|
{ |
|
richPopup = RichHoverPopup; |
|
richPopup.PlacementTarget = Editor.TextArea.TextView; |
|
// Pointer placement: Avalonia anchors the popup at the current cursor location when |
|
// Open() is called, with a small offset below to avoid covering the token. |
|
richPopup.Placement = PlacementMode.Pointer; |
|
richPopup.HorizontalOffset = 0; |
|
richPopup.VerticalOffset = 16; |
|
richPopup.IsLightDismissEnabled = false; |
|
richPopup.Closed += OnRichPopupClosed; |
|
} |
|
|
|
void SetupZoomAndCopy() |
|
{ |
|
// Ctrl+Wheel zoom (matches WPF's ZoomScrollViewer.OnPreviewMouseWheel). Tunnel routing so |
|
// we see it before AvaloniaEdit's scroll handler; Handled=true on hit suppresses the |
|
// scroll. Ctrl+0/Plus/Minus are Avalonia-side extras -- keyboard zoom is a standard |
|
// modern-editor expectation that WPF ILSpy doesn't happen to ship. |
|
Editor.AddHandler(InputElement.PointerWheelChangedEvent, |
|
OnEditorPointerWheelChanged, |
|
RoutingStrategies.Tunnel, |
|
handledEventsToo: false); |
|
Editor.KeyDown += OnEditorKeyDownForZoom; |
|
|
|
// Ctrl+C copies as text + syntax-coloured HTML so a paste into an HTML target keeps the |
|
// highlighting. Tunnel so we run before AvaloniaEdit's plain-text copy keybinding and can |
|
// suppress it; falls through (Handled stays false) when there's no selection. |
|
Editor.AddHandler(InputElement.KeyDownEvent, OnEditorKeyDownForCopy, |
|
RoutingStrategies.Tunnel, handledEventsToo: false); |
|
} |
|
|
|
void OnEditorKeyDownForCopy(object? sender, KeyEventArgs e) |
|
{ |
|
if (e.Key == Key.C && e.KeyModifiers == KeyModifiers.Control |
|
&& HtmlClipboardCopy.Copy(Editor, SemanticHighlightingModel)) |
|
{ |
|
e.Handled = true; |
|
} |
|
} |
|
|
|
/// <summary>ILSpy's semantic highlighting (decompiler reference / theme colours) for the current |
|
/// document, so an HTML copy can include it on top of the xshd syntax colours.</summary> |
|
internal RichTextModel? SemanticHighlightingModel => boundModel?.HighlightingModel; |
|
|
|
// ThemeManager.Current is a process-lived singleton, so subscribing to its ThemeChanged in |
|
// the constructor and never detaching would root every DecompilerTextView for the lifetime of |
|
// the process -- one leaked view per decompiler tab. Bind the handler to the visual-tree |
|
// lifetime instead: subscribe while attached, drop it on detach. Dock hides and re-shows tab |
|
// content, so this re-subscribes on every attach. |
|
protected override void OnAttachedToVisualTree(global::Avalonia.VisualTreeAttachmentEventArgs e) |
|
{ |
|
base.OnAttachedToVisualTree(e); |
|
ICSharpCode.ILSpy.Themes.ThemeManager.Current.ThemeChanged += OnThemeChangedRebuildHighlighting; |
|
} |
|
|
|
protected override void OnDetachedFromVisualTree(global::Avalonia.VisualTreeAttachmentEventArgs e) |
|
{ |
|
ICSharpCode.ILSpy.Themes.ThemeManager.Current.ThemeChanged -= OnThemeChangedRebuildHighlighting; |
|
base.OnDetachedFromVisualTree(e); |
|
} |
|
|
|
// A theme switch re-colours the shared named HighlightingColors in place, but the |
|
// semantic RichTextModel cloned them at decompile time (RichTextModel.SetHighlighting |
|
// clones). Rebuild the model from the captured spans -- which still reference the live, |
|
// re-coloured instances -- so the already-decompiled output repaints with the new palette. |
|
void OnThemeChangedRebuildHighlighting(object? sender, System.EventArgs e) |
|
{ |
|
if (boundModel?.HighlightingSpans is not { Count: > 0 } spans) |
|
return; |
|
var model = new RichTextModel(); |
|
foreach (var (start, length, color) in spans) |
|
model.SetHighlighting(start, length, color); |
|
SetColorizer(model); |
|
Editor.TextArea.TextView.Redraw(); |
|
} |
|
|
|
// Swap the semantic-highlighting colorizer to one for <paramref name="model"/>, or remove it |
|
// when null. AvaloniaEdit only exposes Add/Remove on LineTransformers, so we hold a reference |
|
// to the live one. Shared by ApplyDocument (per-document model) and the theme-change rebuild. |
|
void SetColorizer(RichTextModel? model) |
|
{ |
|
var transformers = Editor.TextArea.TextView.LineTransformers; |
|
if (activeColorizer != null) |
|
{ |
|
transformers.Remove(activeColorizer); |
|
activeColorizer = null; |
|
} |
|
if (model != null) |
|
{ |
|
activeColorizer = new RichTextColorizer(model); |
|
transformers.Add(activeColorizer); |
|
} |
|
} |
|
|
|
void OnEditorPointerWheelChanged(object? sender, PointerWheelEventArgs e) |
|
{ |
|
if ((e.KeyModifiers & KeyModifiers.Control) != KeyModifiers.Control) |
|
return; |
|
if (currentDisplaySettings == null) |
|
return; |
|
var step = e.Delta.Y > 0 ? (System.Func<double, double>)EditorZoom.ZoomIn : EditorZoom.ZoomOut; |
|
currentDisplaySettings.SelectedFontSize = step(currentDisplaySettings.SelectedFontSize); |
|
e.Handled = true; |
|
} |
|
|
|
/// <summary>True when the active document has any code foldings (gates the folding menu entries).</summary> |
|
public bool HasFoldings => activeFoldingManager is { } mgr && mgr.AllFoldings.Any(); |
|
|
|
/// <summary>Number of currently-collapsed folds (test observation point for the folding commands).</summary> |
|
internal int FoldedFoldingCount => activeFoldingManager is { } mgr ? mgr.AllFoldings.Count(f => f.IsFolded) : 0; |
|
|
|
/// <summary>The active foldings as (Start, End, IsFolded) tuples (test observation point for |
|
/// verifying which fold a positional command acted on).</summary> |
|
internal IReadOnlyList<(int Start, int End, bool IsFolded)> GetFoldingsForTest() |
|
=> activeFoldingManager is { } mgr |
|
? mgr.AllFoldings.Select(f => (f.StartOffset, f.EndOffset, f.IsFolded)).ToArray() |
|
: Array.Empty<(int, int, bool)>(); |
|
|
|
/// <summary>True when the innermost fold containing <paramref name="offset"/> is collapsed. |
|
/// Returns false when no fold contains it (test observation point).</summary> |
|
internal bool IsFoldedAt(int offset) |
|
{ |
|
if (activeFoldingManager is not { } mgr) |
|
return false; |
|
FoldingSection? target = null; |
|
foreach (var f in mgr.AllFoldings) |
|
{ |
|
if (f.StartOffset <= offset && offset <= f.EndOffset && (target == null || f.StartOffset > target.StartOffset)) |
|
target = f; |
|
} |
|
return target?.IsFolded == true; |
|
} |
|
|
|
/// <summary>Toggles the innermost fold containing the caret (the "Toggle folding" command / Ctrl+M).</summary> |
|
public void ToggleFoldingAtCaret() => ToggleFoldingAt(Editor.TextArea.Caret.Offset); |
|
|
|
/// <summary>Toggles the innermost fold containing <paramref name="offset"/>. The right-click menu |
|
/// passes the offset under the pointer so it acts on the clicked line, not the caret line.</summary> |
|
public void ToggleFoldingAt(int offset) |
|
{ |
|
if (activeFoldingManager is not { } mgr) |
|
return; |
|
FoldingSection? target = null; |
|
foreach (var f in mgr.AllFoldings) |
|
{ |
|
if (f.StartOffset <= offset && offset <= f.EndOffset) |
|
{ |
|
if (target == null || f.StartOffset > target.StartOffset) |
|
target = f; |
|
} |
|
} |
|
if (target != null) |
|
target.IsFolded = !target.IsFolded; |
|
} |
|
|
|
/// <summary>Collapses every fold when any is open, otherwise expands them all ("Toggle all folding" |
|
/// / Ctrl+Shift+M).</summary> |
|
public void ToggleAllFoldings() |
|
{ |
|
if (activeFoldingManager is not { } mgr) |
|
return; |
|
bool anyOpen = false; |
|
foreach (var f in mgr.AllFoldings) |
|
{ |
|
if (!f.IsFolded) |
|
{ anyOpen = true; break; } |
|
} |
|
foreach (var f in mgr.AllFoldings) |
|
f.IsFolded = anyOpen; |
|
} |
|
|
|
void OnEditorKeyDownForZoom(object? sender, KeyEventArgs e) |
|
{ |
|
// Folding keyboard commands (Ctrl+M family). Mirrors typical IDE conventions: |
|
// Ctrl+M Ctrl+M-ish toggle for the fold nearest the caret; Ctrl+Shift+M for |
|
// collapse-all / expand-all by parity check. Implemented as single-key chords |
|
// (Ctrl+M / Ctrl+Shift+M) — full two-key chord support would need a key-state |
|
// machine wired through the TextArea which AvaloniaEdit doesn't ship. |
|
if ((e.KeyModifiers & KeyModifiers.Control) == KeyModifiers.Control && e.Key == Key.M) |
|
{ |
|
if (activeFoldingManager is not null) |
|
{ |
|
if ((e.KeyModifiers & KeyModifiers.Shift) == KeyModifiers.Shift) |
|
ToggleAllFoldings(); |
|
else |
|
ToggleFoldingAtCaret(); |
|
e.Handled = true; |
|
return; |
|
} |
|
} |
|
|
|
if ((e.KeyModifiers & KeyModifiers.Control) != KeyModifiers.Control) |
|
return; |
|
if (currentDisplaySettings == null) |
|
return; |
|
// OemPlus is the unmodified key on the same physical button as "+"; with Shift it |
|
// produces "+", without it produces "=". Accept both so Ctrl+Plus and Ctrl+= |
|
// (which most users actually type) both zoom in. Add (numeric keypad) covers numpad. |
|
switch (e.Key) |
|
{ |
|
case Key.OemPlus: |
|
case Key.Add: |
|
currentDisplaySettings.SelectedFontSize = EditorZoom.ZoomIn(currentDisplaySettings.SelectedFontSize); |
|
e.Handled = true; |
|
break; |
|
case Key.OemMinus: |
|
case Key.Subtract: |
|
currentDisplaySettings.SelectedFontSize = EditorZoom.ZoomOut(currentDisplaySettings.SelectedFontSize); |
|
e.Handled = true; |
|
break; |
|
case Key.D0: |
|
case Key.NumPad0: |
|
currentDisplaySettings.SelectedFontSize = EditorZoom.Reset(); |
|
e.Handled = true; |
|
break; |
|
} |
|
} |
|
|
|
void OnCaretPositionChanged(object? sender, EventArgs e) |
|
{ |
|
UpdateBracketHighlight(); |
|
} |
|
|
|
void UpdateBracketHighlight() |
|
{ |
|
// Skip when the user disabled the feature in Display Settings, and clear any |
|
// existing highlight so a previous one doesn't linger after the toggle flips. |
|
if (currentDisplaySettings is { HighlightMatchingBraces: false }) |
|
{ |
|
bracketHighlightRenderer.SetHighlight(null); |
|
return; |
|
} |
|
var language = (DataContext as DecompilerTabPageModel)?.Language; |
|
var searcher = language?.BracketSearcher ?? DefaultBracketSearcher.DefaultInstance; |
|
var result = searcher.SearchBracket(Editor.Document, Editor.TextArea.Caret.Offset); |
|
bracketHighlightRenderer.SetHighlight(result); |
|
} |
|
|
|
/// <summary> |
|
/// Mirrors the live <see cref="DisplaySettings"/> into the editor on construction and on |
|
/// every <c>PropertyChanged</c> after that. Lets the Options Display panel produce |
|
/// immediate visual feedback without a re-decompile. |
|
/// </summary> |
|
void WireUpDisplaySettings() |
|
{ |
|
var settings = TryGetDisplaySettings(); |
|
if (settings == null) |
|
return; |
|
currentDisplaySettings = settings; |
|
ZoomButtons.Bind(settings); |
|
ApplyAllDisplaySettings(settings); |
|
settings.PropertyChanged += (_, e) => ApplyDisplaySetting(settings, e.PropertyName); |
|
} |
|
|
|
static DisplaySettings? TryGetDisplaySettings() |
|
{ |
|
try |
|
{ return AppComposition.Current.GetExport<SettingsService>().DisplaySettings; } |
|
catch { return null; } |
|
} |
|
|
|
void ApplyAllDisplaySettings(DisplaySettings s) |
|
{ |
|
ApplyDisplaySetting(s, nameof(DisplaySettings.SelectedFont)); |
|
ApplyDisplaySetting(s, nameof(DisplaySettings.SelectedFontSize)); |
|
ApplyDisplaySetting(s, nameof(DisplaySettings.ShowLineNumbers)); |
|
ApplyDisplaySetting(s, nameof(DisplaySettings.EnableWordWrap)); |
|
ApplyDisplaySetting(s, nameof(DisplaySettings.HighlightCurrentLine)); |
|
ApplyDisplaySetting(s, nameof(DisplaySettings.IndentationSize)); |
|
ApplyDisplaySetting(s, nameof(DisplaySettings.IndentationUseTabs)); |
|
ApplyDisplaySetting(s, nameof(DisplaySettings.EnableOmnibar)); |
|
} |
|
|
|
void ApplyDisplaySetting(DisplaySettings s, string? propertyName) |
|
{ |
|
switch (propertyName) |
|
{ |
|
case nameof(DisplaySettings.SelectedFont): |
|
if (!string.IsNullOrEmpty(s.SelectedFont)) |
|
Editor.FontFamily = new FontFamily(s.SelectedFont); |
|
break; |
|
case nameof(DisplaySettings.SelectedFontSize): |
|
if (s.SelectedFontSize > 0) |
|
Editor.FontSize = s.SelectedFontSize; |
|
break; |
|
case nameof(DisplaySettings.ShowLineNumbers): |
|
Editor.ShowLineNumbers = s.ShowLineNumbers; |
|
break; |
|
case nameof(DisplaySettings.EnableWordWrap): |
|
Editor.WordWrap = s.EnableWordWrap; |
|
break; |
|
case nameof(DisplaySettings.HighlightCurrentLine): |
|
Editor.Options.HighlightCurrentLine = s.HighlightCurrentLine; |
|
break; |
|
case nameof(DisplaySettings.IndentationSize): |
|
if (s.IndentationSize > 0) |
|
Editor.Options.IndentationSize = s.IndentationSize; |
|
break; |
|
case nameof(DisplaySettings.IndentationUseTabs): |
|
Editor.Options.ConvertTabsToSpaces = !s.IndentationUseTabs; |
|
break; |
|
case nameof(DisplaySettings.EnableOmnibar): |
|
// Off by default; the Options Display "Tab options" toggle shows/hides the bar |
|
// live without a re-decompile, per text view. |
|
Omnibar.IsVisible = s.EnableOmnibar; |
|
break; |
|
} |
|
} |
|
|
|
static IReadOnlyList<IContextMenuEntryExport> TryGetContextMenuEntries() |
|
{ |
|
try |
|
{ return AppComposition.Current.GetExport<ContextMenuEntryRegistry>().Entries; } |
|
catch { return Array.Empty<IContextMenuEntryExport>(); } |
|
} |
|
|
|
/// <summary> |
|
/// Replaces the active context-menu entries. Tests bypass the MEF registry by |
|
/// calling this directly with stub entries. |
|
/// </summary> |
|
internal void AttachContextMenu(IReadOnlyList<IContextMenuEntryExport> entries) |
|
{ |
|
contextMenuEntries = entries; |
|
var menu = new ContextMenu(); |
|
menu.Opening += OnContextMenuOpening; |
|
Editor.TextArea.TextView.ContextMenu = menu; |
|
} |
|
|
|
void OnTextViewPointerPressedForContextMenu(object? sender, PointerPressedEventArgs e) |
|
{ |
|
if (!e.GetCurrentPoint(Editor.TextArea.TextView).Properties.IsRightButtonPressed) |
|
return; |
|
var pos = GetPositionFromPointer(e); |
|
if (pos == null) |
|
{ |
|
lastRightClickedSegment = null; |
|
lastRightClickedOffset = null; |
|
return; |
|
} |
|
var offset = Editor.Document.GetOffset(pos.Value.Line, pos.Value.Column); |
|
lastRightClickedOffset = offset; |
|
// References and foldings are independent: a document may carry foldings but no clickable |
|
// references, so the offset is recorded above before this reference lookup can bail. |
|
lastRightClickedSegment = DataContext is DecompilerTabPageModel model && model.References != null |
|
? model.References.FindSegmentsContaining(offset).FirstOrDefault() |
|
: null; |
|
} |
|
|
|
void OnContextMenuOpening(object? sender, CancelEventArgs e) |
|
{ |
|
if (sender is not ContextMenu menu) |
|
return; |
|
var ctx = new TextViewContext { |
|
TextView = this, |
|
Reference = lastRightClickedSegment, |
|
TextLocation = lastRightClickedOffset, |
|
}; |
|
menu.Items.Clear(); |
|
|
|
// Copy / Select All come from MEF-exported entries in EditorCommands.cs (Category = |
|
// "Editor", Order 100/110 so they appear at the top). The remaining |
|
// MEF-discovered entries (Show in metadata, etc.) follow. |
|
var built = ContextMenuProvider.Build(contextMenuEntries, ctx); |
|
if (built == null) |
|
{ |
|
e.Cancel = true; |
|
return; |
|
} |
|
foreach (var item in built.Items.Cast<Control>().ToArray()) |
|
{ |
|
built.Items.Remove(item); |
|
menu.Items.Add(item); |
|
} |
|
} |
|
|
|
#region Bookmarks |
|
|
|
Bookmarks.BookmarkManager? bookmarkManager; |
|
|
|
Bookmarks.BookmarkManager? BookmarkManager |
|
=> bookmarkManager ??= AppEnv.AppComposition.TryGetExport<Bookmarks.BookmarkManager>(); |
|
|
|
// Bookmarks live only on the decompiled C# view, not on IL / metadata / resource output. |
|
bool ShowsBookmarkableCode => DataContext is DecompilerTabPageModel { SyntaxExtension: ".cs" }; |
|
|
|
// Just the anchor for a line, or null when the line can't be bookmarked. Cheap enough to call |
|
// per hovered line: it does not capture the editor view state (foldings, scroll, tree path), |
|
// which only a bookmark that is actually being created needs. |
|
Bookmarks.Bookmark? CreateAnchorForLine(int line) |
|
{ |
|
if (!ShowsBookmarkableCode || DataContext is not DecompilerTabPageModel model) |
|
return null; |
|
var fallbackOwner = model.CurrentNode is IMemberTreeNode memberNode ? memberNode.Member : null; |
|
return Bookmarks.BookmarkAnchoring.CreateForLine(model.DebugInfo, model.References, Editor.Document, line, |
|
fallbackOwner, GetLocationNodeName(model.CurrentNode)); |
|
} |
|
|
|
Bookmarks.Bookmark? CreateBookmarkForLine(int line) |
|
{ |
|
var bookmark = CreateAnchorForLine(line); |
|
if (bookmark != null && DataContext is DecompilerTabPageModel model) |
|
{ |
|
var displaySettings = AppComposition.TryGetExport<SettingsService>()?.DisplaySettings; |
|
var selectedTreeNodePath = AssemblyTreeModel.GetPathForNode(model.CurrentNode); |
|
bookmark.ViewState = Bookmarks.BookmarkViewState.From(GetCurrentViewState(), displaySettings, selectedTreeNodePath); |
|
} |
|
return bookmark; |
|
} |
|
|
|
static string? GetLocationNodeName(SharpTreeNode? node) |
|
=> node is IMemberTreeNode { Member: { } member } ? member.FullName : node?.ToString(); |
|
|
|
/// <summary>Whether <paramref name="line"/> can hold a bookmark in the current document.</summary> |
|
internal bool CanToggleBookmarkAtLine(int line) => CreateAnchorForLine(line) != null; |
|
|
|
internal bool CanToggleBookmarkAtOffset(int offset) |
|
{ |
|
if (offset < 0 || offset > Editor.Document.TextLength) |
|
return false; |
|
return CanToggleBookmarkAtLine(Editor.Document.GetLineByOffset(offset).LineNumber); |
|
} |
|
|
|
/// <summary> |
|
/// Adds or removes a bookmark on <paramref name="line"/>; a no-op for non-anchorable lines. |
|
/// Returns true when a bookmark was added, false when one was removed or the line is not anchorable. |
|
/// </summary> |
|
internal bool ToggleBookmarkAtLine(int line) |
|
{ |
|
if (CreateBookmarkForLine(line) is { } candidate) |
|
return BookmarkManager?.Toggle(candidate) ?? false; |
|
return false; |
|
} |
|
|
|
internal void ToggleBookmarkAtOffset(int offset) |
|
{ |
|
if (offset < 0 || offset > Editor.Document.TextLength) |
|
return; |
|
ToggleBookmarkAtLine(Editor.Document.GetLineByOffset(offset).LineNumber); |
|
} |
|
|
|
void OnBookmarkKeyDown(object? sender, KeyEventArgs e) |
|
{ |
|
if (e.Key == Key.B && e.KeyModifiers == KeyModifiers.Control && Editor.TextArea.IsKeyboardFocusWithin) |
|
{ |
|
ToggleBookmarkAtLine(Editor.TextArea.Caret.Line); |
|
e.Handled = true; |
|
} |
|
} |
|
|
|
// Moves the caret to the next/previous bookmark in this document, ordered by line and relative |
|
// to the caret (wrapping around). A no-op when the document holds fewer than one bookmark. |
|
void NavigateBookmarkInFile(bool forward) |
|
{ |
|
if (BookmarkManager is not { } manager) |
|
return; |
|
var lines = new List<int>(); |
|
foreach (var bookmark in manager.Bookmarks) |
|
{ |
|
// Disabled bookmarks stay visible in the gutter but are skipped by next/previous. |
|
if (bookmark.Enabled && GetLineForBookmark(bookmark) is { } line) |
|
lines.Add(line); |
|
} |
|
if (lines.Count == 0) |
|
return; |
|
lines.Sort(); |
|
int caretLine = Editor.TextArea.Caret.Line; |
|
int target = forward |
|
? lines.FirstOrDefault(l => l > caretLine, lines[0]) |
|
: lines.LastOrDefault(l => l < caretLine, lines[^1]); |
|
ScrollToLine(target); |
|
} |
|
|
|
void ApplyPendingBookmark(DecompilerTabPageModel model) |
|
{ |
|
// Clear only once the line actually resolves and we scroll. A document-apply can run |
|
// against the not-yet-decompiled document (a fresh preview tab binds with empty text |
|
// before its decompile lands); leaving PendingBookmark set there lets the apply that |
|
// follows the real content position the caret instead of discarding the navigation. |
|
if (model.PendingBookmark is { } bookmark && ApplyBookmark(bookmark)) |
|
model.PendingBookmark = null; |
|
} |
|
|
|
// Computes the bookmark's current line from its saved token/anchor (resilient to reflow) and |
|
// scrolls there with the one-shot highlight. Returns false when the line cannot be resolved |
|
// yet (document not decompiled, or not C#). Shared by the document-apply consumption of |
|
// PendingBookmark and the ScrollToBookmark callback for an already-displayed node. |
|
bool ApplyBookmark(Bookmarks.Bookmark bookmark) |
|
{ |
|
if (GetLineForBookmark(bookmark) is { } line) |
|
{ |
|
ScrollToLine(line, bookmark.ViewState); |
|
return true; |
|
} |
|
return false; |
|
} |
|
|
|
void ScrollToLine(int line, Bookmarks.BookmarkViewState? viewState = null) |
|
{ |
|
var document = Editor.Document; |
|
line = Math.Clamp(line, 1, document.LineCount); |
|
Editor.TextArea.Caret.Offset = document.GetLineByNumber(line).Offset; |
|
// Centre the line and pulse its gutter icon once the layout has caught up. Posting lets a |
|
// just-applied document finish measuring, so the visual position is accurate either way -- |
|
// whether we got here after a fresh decompile or while the document was already on screen. |
|
Dispatcher.UIThread.Post(() => { |
|
if (!ReferenceEquals(Editor.Document, document) || line < 1 || line > document.LineCount) |
|
return; |
|
// Restore the captured foldings first -- collapsing/expanding shifts where lines sit -- |
|
// then centre the re-resolved line. The bookmark's saved caret/scroll are deliberately |
|
// not restored: a decompiler-setting change can reflow the text so the bookmark |
|
// re-anchors to a different line, and the stale offset would scroll it back off-screen. |
|
if (viewState != null) |
|
RestoreBookmarkFoldings(viewState); |
|
CenterLineInView(document, line); |
|
LineHighlightAdorner.DisplayLineHighlight(Editor.TextArea, line); |
|
bookmarkMargin?.PulseLine(line); |
|
}, DispatcherPriority.Background); |
|
} |
|
|
|
void RestoreBookmarkFoldings(Bookmarks.BookmarkViewState viewState) |
|
{ |
|
if (viewState.ToDecompilerTextViewState().Foldings is { } saved && activeFoldingManager is { } manager) |
|
FoldingsViewState.Restore(manager.AllFoldings, saved); |
|
} |
|
|
|
// Scrolls so <paramref name="line"/> sits in the middle of the viewport. AvaloniaEdit's |
|
// ScrollTo* are no-ops in 12.0.0 (#594), so set the ScrollViewer offset directly. |
|
void CenterLineInView(TextDocument document, int line) |
|
{ |
|
if (EditorScrollViewer is not { } scrollViewer) |
|
return; |
|
if (!ReferenceEquals(Editor.Document, document) || line < 1 || line > document.LineCount) |
|
return; |
|
var textView = Editor.TextArea.TextView; |
|
if (!ReferenceEquals(textView.Document, document)) |
|
return; |
|
double visualTop = textView.GetVisualTopByDocumentLine(line); |
|
double target = visualTop - (scrollViewer.Viewport.Height - textView.DefaultLineHeight) / 2; |
|
scrollViewer.Offset = new Vector(scrollViewer.Offset.X, Math.Max(0, target)); |
|
} |
|
|
|
// The identity of the inputs that decide where bookmarks resolve in the current C# document. |
|
// Both are replaced on every decompile, while the editor reuses one TextDocument (only its text |
|
// changes), so this -- not the document reference -- is what tells the gutter its cache is stale. |
|
internal (object? DebugInfo, object? References) BookmarkContentVersion |
|
=> DataContext is DecompilerTabPageModel { SyntaxExtension: ".cs" } model |
|
? (model.DebugInfo, model.References) |
|
: default; |
|
|
|
/// <summary> |
|
/// The document line a bookmark sits on in the currently shown C# document, or null when the |
|
/// bookmark belongs to other code. Body anchors resolve via the IL-offset map; token anchors |
|
/// via the definition's position. The module identity is verified so a token value shared |
|
/// across assemblies can't place an icon on the wrong line. |
|
/// </summary> |
|
internal int? GetLineForBookmark(Bookmarks.Bookmark bookmark, bool updateRenderedLine = true) |
|
{ |
|
if (DataContext is not DecompilerTabPageModel { SyntaxExtension: ".cs" } model) |
|
return null; |
|
|
|
if (bookmark.Kind == Bookmarks.BookmarkKind.Body) |
|
{ |
|
if (model.DebugInfo is not { } debug) |
|
return null; |
|
foreach (var method in debug.Methods) |
|
{ |
|
if (MatchesBookmark(method, bookmark) |
|
&& method.TryGetLineForOffset(bookmark.ILOffset, out var bodyLine)) |
|
{ |
|
if (updateRenderedLine) |
|
bookmark.UpdateRenderedLineNumber(bodyLine); |
|
return bodyLine; |
|
} |
|
} |
|
return null; |
|
} |
|
|
|
if (bookmark.Kind == Bookmarks.BookmarkKind.Line) |
|
{ |
|
if (bookmark.LineNumber < 1 || bookmark.LineNumber > Editor.Document.LineCount) |
|
return null; |
|
if (DocumentContainsBookmarkToken(model, bookmark)) |
|
return bookmark.LineNumber; |
|
return null; |
|
} |
|
|
|
if (model.References is { } references) |
|
{ |
|
foreach (var segment in references) |
|
{ |
|
if (segment.IsDefinition && segment.Reference is IEntity entity && MatchesBookmark(entity, bookmark)) |
|
{ |
|
var line = Editor.Document.GetLineByOffset(segment.StartOffset).LineNumber; |
|
if (updateRenderedLine) |
|
bookmark.UpdateRenderedLineNumber(line); |
|
return line; |
|
} |
|
} |
|
} |
|
return null; |
|
} |
|
|
|
// The token + assembly identity that ties a bookmark to a member in the current document. The |
|
// module identity is checked too, so a token value shared across assemblies can't match the |
|
// wrong member. Both forms (the body-anchor debug map and a reference segment's entity) are |
|
// kept in step here rather than spelled out at each call site. |
|
static bool MatchesBookmark(Bookmarks.MethodDebugInfo method, Bookmarks.Bookmark bookmark) |
|
=> method.Token == bookmark.Token && method.AssemblyFullName == bookmark.AssemblyFullName; |
|
|
|
static bool MatchesBookmark(IEntity entity, Bookmarks.Bookmark bookmark) |
|
=> (uint)MetadataTokens.GetToken(entity.MetadataToken) == bookmark.Token |
|
&& entity.ParentModule?.MetadataFile?.FullName == bookmark.AssemblyFullName; |
|
|
|
static bool DocumentContainsBookmarkToken(DecompilerTabPageModel model, Bookmarks.Bookmark bookmark) |
|
{ |
|
if (model.DebugInfo != null) |
|
{ |
|
foreach (var method in model.DebugInfo.Methods) |
|
{ |
|
if (MatchesBookmark(method, bookmark)) |
|
return true; |
|
} |
|
} |
|
|
|
if (model.References != null) |
|
{ |
|
foreach (var segment in model.References) |
|
{ |
|
if (segment.Reference is IEntity entity && MatchesBookmark(entity, bookmark)) |
|
return true; |
|
} |
|
} |
|
|
|
return false; |
|
} |
|
|
|
#endregion |
|
|
|
protected override void OnDataContextChanged(System.EventArgs e) |
|
{ |
|
base.OnDataContextChanged(e); |
|
|
|
// Detach the outgoing model's handlers before binding to the new one. Avalonia doesn't |
|
// surface the previous DataContext to this event, so we track it ourselves. |
|
if (boundModel is { } previous && !ReferenceEquals(previous, DataContext)) |
|
{ |
|
previous.PropertyChanged -= OnModelPropertyChanged; |
|
previous.CaptureViewState = null; |
|
previous.NavigateBookmarkInFile = null; |
|
previous.ScrollToBookmark = null; |
|
} |
|
|
|
boundModel = DataContext as DecompilerTabPageModel; |
|
if (boundModel is { } model) |
|
{ |
|
model.PropertyChanged += OnModelPropertyChanged; |
|
// Let DockWorkspace pull the live view state from this editor on demand when it |
|
// records a navigation away. (Re)assigning every DataContext-change handles both |
|
// the first attach and an ABA reattach. |
|
model.CaptureViewState = GetCurrentViewState; |
|
// Bookmarks-pane toolbar navigation actions that operate on the active document route through this. |
|
model.NavigateBookmarkInFile = NavigateBookmarkInFile; |
|
// Direct scroll for a bookmark activated on the already-displayed node, where no |
|
// document-apply step runs to consume PendingBookmark. |
|
model.ScrollToBookmark = bookmark => ApplyBookmark(bookmark); |
|
ApplyDocument(model); |
|
// Point the breadcrumb at this tab's node (the bar owns its own VM, so feed it the |
|
// node rather than letting it inherit the document DataContext). |
|
Omnibar.SetNode(model.CurrentNode); |
|
} |
|
else |
|
{ |
|
Omnibar.SetNode(null); |
|
} |
|
} |
|
|
|
// Reads the editor's live caret + scroll + expanded-foldings state. Invoked by |
|
// DockWorkspace via DecompilerTabPageModel.CaptureViewState when it writes a navigation |
|
// record, so the captured position reflects exactly what the user is looking at now. |
|
DecompilerTextViewState GetCurrentViewState() => new( |
|
Editor.TextArea.Caret.Offset, |
|
Editor.VerticalOffset, |
|
Editor.HorizontalOffset, |
|
activeFoldingManager is { } manager ? FoldingsViewState.Capture(manager.AllFoldings) : null); |
|
|
|
// The editor's template ScrollViewer (cached once found). ?? re-evaluates while null so it |
|
// resolves once the template is applied. |
|
ScrollViewer? EditorScrollViewer => |
|
editorScrollViewer ??= Editor.GetVisualDescendants().OfType<ScrollViewer>().FirstOrDefault(); |
|
|
|
|
|
void OnModelPropertyChanged(object? sender, PropertyChangedEventArgs e) |
|
{ |
|
// Only rebuild when Text changes — DecompileAsync sets HighlightingModel and Foldings |
|
// just before Text, so reading them at this point gets the matching state. Reacting |
|
// to each property fires several intermediate ApplyDocuments where the text and the |
|
// folding offsets disagree. |
|
if (sender is DecompilerTabPageModel model |
|
&& (e.PropertyName == nameof(DecompilerTabPageModel.Text) |
|
|| e.PropertyName == nameof(DecompilerTabPageModel.SyntaxExtension))) |
|
{ |
|
// Restore caret/scroll/foldings only on the Text change (the final content); |
|
// SyntaxExtension fires an intermediate rebuild that must not move the scroll. |
|
ApplyDocument(model, restoreViewState: e.PropertyName == nameof(DecompilerTabPageModel.Text)); |
|
} |
|
// Keep the breadcrumb in step when the tab re-targets a different node (the model |
|
// raises this when CurrentNodes changes). |
|
else if (sender is DecompilerTabPageModel nodeModel |
|
&& e.PropertyName == nameof(DecompilerTabPageModel.CurrentNode)) |
|
{ |
|
Omnibar.SetNode(nodeModel.CurrentNode); |
|
} |
|
// HighlightedReference can change independently of a re-decompile (e.g. the analyzer |
|
// pane sets it while the user is already on the right tab). Apply it directly without |
|
// rebuilding the document. |
|
else if (sender is DecompilerTabPageModel m |
|
&& e.PropertyName == nameof(DecompilerTabPageModel.HighlightedReference)) |
|
{ |
|
ApplyHighlightedReference(m); |
|
} |
|
} |
|
|
|
void ApplyHighlightedReference(DecompilerTabPageModel model) |
|
{ |
|
if (model.HighlightedReference == null) |
|
{ |
|
ClearLocalReferenceMarks(); |
|
return; |
|
} |
|
HighlightLocalReferences(model, model.HighlightedReference); |
|
} |
|
|
|
// restoreViewState: only the Text change carries the final decompiled content, so only it |
|
// should consume PendingViewState and restore (or reset) caret/scroll/foldings. The |
|
// intermediate SyntaxExtension change rebuilds the display but must NOT touch the scroll, |
|
// or it resets to the top before the Text pass restores -- a visible flicker on Back. |
|
void ApplyDocument(DecompilerTabPageModel model, bool restoreViewState = true) |
|
{ |
|
// A new decompile invalidates any tooltip resolved against the previous document — |
|
// force-close even if the popup currently wants to stay (mouseClick: true). |
|
TryCloseExistingPopup(mouseClick: true); |
|
ClearLocalReferenceMarks(); |
|
Editor.SyntaxHighlighting = HighlightingService.GetByExtension(model.SyntaxExtension); |
|
Editor.Document.Text = model.Text; |
|
|
|
// Swap the semantic-highlighting colorizer for this document's model (null clears it). |
|
SetColorizer(model.HighlightingModel); |
|
|
|
// Consume the pending view state up-front so it can't bleed into a later refresh even |
|
// when the new document has zero foldings to apply it to (e.g. a namespace summary page |
|
// that follows a method-body navigation). PendingViewState is set by Back/Forward/GoTo |
|
// navigation; null on a fresh navigation, in which case the caret/scroll reset below |
|
// puts the new document at the top. |
|
var pendingState = restoreViewState ? model.PendingViewState : null; |
|
if (restoreViewState) |
|
model.PendingViewState = null; |
|
|
|
RebuildFoldings(model, pendingState); |
|
|
|
referenceElementGenerator.References = model.References; |
|
uiElementGenerator.UIElements = model.UIElements; |
|
|
|
// Re-apply any pending reference highlight against the fresh References collection. |
|
// The analyzer-result-activation path sets HighlightedReference before the navigated |
|
// node's decompile finishes; this is where the marks finally land on the new text. |
|
if (model.HighlightedReference != null) |
|
HighlightLocalReferences(model, model.HighlightedReference); |
|
|
|
if (restoreViewState) |
|
RestoreOrResetViewState(pendingState); |
|
|
|
// Position at a navigated-to bookmark once its document (and debug map) has landed. Only on |
|
// the final content (the Text change, where restoreViewState is set), AFTER the view-state |
|
// reset above, so the bookmark scroll is the last word on position and the highlight plays |
|
// on the settled layout instead of an intermediate render a later rebuild would scroll away. |
|
if (restoreViewState) |
|
ApplyPendingBookmark(model); |
|
|
|
SwapCustomElementGenerators(model.CustomElementGenerators); |
|
|
|
Editor.TextArea.TextView.Redraw(); |
|
} |
|
|
|
// Folding markers in the gutter: uninstall the previous manager, then install fresh foldings |
|
// from the model -- clamped to the document, ordered, with the pending snapshot's open/closed |
|
// state projected on -- or fall back to the XML folding strategy for XML resources. Installing |
|
// lazily keeps editors with no foldings chrome-free. |
|
void RebuildFoldings(DecompilerTabPageModel model, DecompilerTextViewState? pendingState) |
|
{ |
|
if (activeFoldingManager != null) |
|
{ |
|
FoldingManager.Uninstall(activeFoldingManager); |
|
activeFoldingManager = null; |
|
} |
|
if (model.Foldings is { Count: > 0 } foldings) |
|
{ |
|
// Defensive clamp: drop foldings that fall outside [0, TextLength]. Without this, a |
|
// stale Foldings collection from an earlier decompile combined with a shorter (or |
|
// empty) document -- see DecompilerTabPageModel.DecompileAsync's empty-nodes |
|
// short-circuit -- would trip AvaloniaEdit's "Folding must be within document |
|
// boundary" guard inside CreateFolding. |
|
int textLength = Editor.Document.TextLength; |
|
var ordered = foldings |
|
.Where(f => f.StartOffset >= 0 && f.EndOffset <= textLength && f.StartOffset < f.EndOffset) |
|
.OrderBy(f => f.StartOffset) |
|
.ToList(); |
|
if (ordered.Count > 0) |
|
{ |
|
activeFoldingManager = FoldingManager.Install(Editor.TextArea); |
|
// Project the saved snapshot onto the freshly-built list so each NewFolding's |
|
// DefaultClosed reflects the previous state BEFORE UpdateFoldings installs them. |
|
// The checksum inside Restore guards against the document having shifted under our |
|
// feet (Refresh / settings change / etc). |
|
if (pendingState?.Foldings is { } pending) |
|
FoldingsViewState.Restore(ordered, pending); |
|
activeFoldingManager.UpdateFoldings(ordered, -1); |
|
} |
|
} |
|
else if (model.SyntaxExtension == ".xml" && !string.IsNullOrEmpty(model.Text)) |
|
{ |
|
// XML resources don't go through the C# decompiler, so the model has no |
|
// pre-collected foldings. AvaloniaEdit ships XmlFoldingStrategy that walks the |
|
// document once and emits one fold per element body. |
|
activeFoldingManager = FoldingManager.Install(Editor.TextArea); |
|
new XmlFoldingStrategy().UpdateFoldings(activeFoldingManager, Editor.Document); |
|
} |
|
} |
|
|
|
// Restore caret + scroll for Back/Forward/GoTo (pendingState carries the position captured |
|
// when the user left this node), or reset to the top on a fresh navigation: the editor is |
|
// reused across navigations and AvaloniaEdit keeps the previous document's caret/scroll when |
|
// the text is replaced. Caret is clamped to the new document length (the text may have changed |
|
// since capture, e.g. Refresh). AvaloniaEdit's ScrollTo*Offset are no-ops in 12.0.0 |
|
// (AvaloniaUI/AvaloniaEdit#594), so set the ScrollViewer's Offset directly; Avalonia re-coerces |
|
// it against the scroll extent on the next layout, so it sticks before the document is measured. |
|
void RestoreOrResetViewState(DecompilerTextViewState? pendingState) |
|
{ |
|
if (pendingState is { } state) |
|
{ |
|
Editor.TextArea.Caret.Offset = Math.Clamp(state.CaretOffset, 0, Editor.Document.TextLength); |
|
if (EditorScrollViewer is { } scrollViewer) |
|
scrollViewer.Offset = new Vector(state.HorizontalOffset, state.VerticalOffset); |
|
} |
|
else |
|
{ |
|
Editor.TextArea.Caret.Offset = 0; |
|
if (EditorScrollViewer is { } scrollViewer) |
|
scrollViewer.Offset = default; |
|
} |
|
} |
|
|
|
// Swap any tab-contributed visual-line element generators (e.g. About-page hyperlink |
|
// generators). Tracked separately from the always-on referenceElementGenerator and |
|
// uiElementGenerator so the two sets don't collide on tab change. |
|
void SwapCustomElementGenerators(IReadOnlyList<AvaloniaEdit.Rendering.VisualLineElementGenerator>? modelGenerators) |
|
{ |
|
var generators = Editor.TextArea.TextView.ElementGenerators; |
|
foreach (var g in activeCustomGenerators) |
|
generators.Remove(g); |
|
activeCustomGenerators.Clear(); |
|
if (modelGenerators is { Count: > 0 }) |
|
{ |
|
foreach (var g in modelGenerators) |
|
{ |
|
generators.Add(g); |
|
activeCustomGenerators.Add(g); |
|
} |
|
} |
|
} |
|
|
|
void OnHyperlinkOpenUri(object? sender, AvaloniaEdit.Rendering.OpenUriRoutedEventArgs e) |
|
{ |
|
if (DataContext is DecompilerTabPageModel model && model.RaiseOpenUriRequested(e.Uri)) |
|
e.Handled = true; |
|
} |
|
|
|
ReferenceSegment? GetReferenceSegmentAtPointer(PointerEventArgs e) |
|
{ |
|
if (DataContext is not DecompilerTabPageModel model || model.References == null) |
|
return null; |
|
var pos = GetPositionFromPointer(e); |
|
if (pos == null) |
|
return null; |
|
var offset = Editor.Document.GetOffset(pos.Value.Line, pos.Value.Column); |
|
return model.References.FindSegmentsContaining(offset).FirstOrDefault(); |
|
} |
|
|
|
internal void OnReferenceClicked(ReferenceSegment segment) |
|
{ |
|
if (DataContext is not DecompilerTabPageModel model || segment.Reference == null) |
|
return; |
|
|
|
// Local references stay inside this document — paint every match and let the user |
|
// scrub through them. Cross-document references clear any existing marks since the |
|
// view is about to refresh anyway. |
|
if (segment.IsLocal) |
|
{ |
|
HighlightLocalReferences(model, segment.Reference); |
|
return; |
|
} |
|
ClearLocalReferenceMarks(); |
|
|
|
// In-document jumps win when the definition is in this same view. |
|
if (model.DefinitionLookup is { } lookup) |
|
{ |
|
var definitionPos = lookup.GetDefinitionPosition(segment.Reference); |
|
if (definitionPos >= 0) |
|
{ |
|
Editor.TextArea.Caret.Offset = definitionPos; |
|
Editor.TextArea.Caret.BringCaretToView(); |
|
// Brief animated rectangle around the new caret position so the user spots |
|
// where the jump landed — particularly useful for long methods where the |
|
// scroll jump alone isn't enough to anchor attention. |
|
CaretHighlightAdorner.DisplayCaretHighlightAnimation(Editor.TextArea); |
|
Dispatcher.UIThread.Post(() => Editor.TextArea.Focus()); |
|
return; |
|
} |
|
} |
|
|
|
// Otherwise let the host route the reference (assembly-tree navigation, new tab, ...). |
|
model.RaiseNavigateRequested(segment); |
|
} |
|
|
|
void HighlightLocalReferences(DecompilerTabPageModel model, object reference) |
|
{ |
|
ClearLocalReferenceMarks(); |
|
if (model.References == null) |
|
return; |
|
foreach (var r in model.References) |
|
{ |
|
if (!ReferenceEquals(reference, r.Reference) && !reference.Equals(r.Reference)) |
|
continue; |
|
var mark = textMarkerService.Create(r.StartOffset, r.Length); |
|
mark.BackgroundColor = r.IsDefinition ? LocalDefinitionBackground : LocalMatchBackground; |
|
localReferenceMarks.Add(mark); |
|
} |
|
} |
|
|
|
void ClearLocalReferenceMarks() |
|
{ |
|
foreach (var mark in localReferenceMarks) |
|
textMarkerService.Remove(mark); |
|
localReferenceMarks.Clear(); |
|
} |
|
|
|
// Live cursor + full ScrollOffset, queried at hover-event time. Returns null if the |
|
// pointer is past the end of the line so we don't snap back to the last visual |
|
// column when the user is hovering empty trailing space. |
|
AvaloniaEdit.TextViewPosition? GetPositionFromPointer(PointerEventArgs e) |
|
{ |
|
var textView = Editor.TextArea.TextView; |
|
var pos = textView.GetPosition(e.GetPosition(textView) + textView.ScrollOffset); |
|
if (pos == null) |
|
return null; |
|
var lineLength = Editor.Document.GetLineByNumber(pos.Value.Line).Length + 1; |
|
if (pos.Value.Column >= lineLength) |
|
return null; |
|
return pos; |
|
} |
|
|
|
void OnTextViewPointerHover(object? sender, PointerEventArgs e) |
|
{ |
|
// The existing popup gets a veto (it can refuse to be replaced if it's |
|
// interacting with the user), then we resolve a fresh segment under the live |
|
// pointer. |
|
if (!TryCloseExistingPopup(mouseClick: false)) |
|
return; |
|
if (DataContext is not DecompilerTabPageModel model) |
|
return; |
|
var segment = GetReferenceSegmentAtPointer(e); |
|
if (segment?.Reference == null) |
|
return; |
|
|
|
var content = BuildHoverContent(model, segment); |
|
if (content == null) |
|
return; |
|
if (content.Value.IsRich) |
|
OpenRichPopup(content.Value.Control); |
|
else |
|
OpenPlainTooltip(content.Value.Control); |
|
} |
|
|
|
// Closes any open tooltip / rich popup before a new hover takes over. Returns false |
|
// when a rich popup wants to stay open — at which point the caller should bail |
|
// rather than steal it. <paramref name="mouseClick"/> forces the close even if the |
|
// popup is interacting with the user (used on click, on document change, etc.). |
|
bool TryCloseExistingPopup(bool mouseClick) |
|
{ |
|
if (richPopup.IsOpen) |
|
{ |
|
// While the pointer is over the popup or keyboard focus is inside it (the |
|
// user is reading, selecting text to copy, or reaching for a link), only a |
|
// forced close (document change) may take it down. |
|
if (!mouseClick && richPopup.Child is { } child |
|
&& (child.IsPointerOver || child.IsKeyboardFocusWithin)) |
|
{ |
|
return false; |
|
} |
|
CloseRichPopup(); |
|
return true; |
|
} |
|
CloseTooltip(); |
|
return true; |
|
} |
|
|
|
void OnTextViewPointerHoverStopped(object? sender, PointerEventArgs e) |
|
{ |
|
// Plain tooltips close on hover stop. The rich popup's lifetime is governed by |
|
// the distance corridor on PointerMoved. |
|
if (!richPopup.IsOpen) |
|
CloseTooltip(); |
|
} |
|
|
|
void OnTextViewPointerMoved(object? sender, PointerEventArgs e) |
|
{ |
|
if (!richPopup.IsOpen) |
|
return; |
|
// While the rich popup is open, PointerMoved drives the WPF "distance corridor" — |
|
// the popup stays as long as the pointer doesn't move further from its edges than |
|
// the shrinking limit allows. |
|
double distance = GetDistanceToPopup(e); |
|
if (distance > distanceToPopupLimit) |
|
TryCloseExistingPopup(mouseClick: false); |
|
else |
|
distanceToPopupLimit = Math.Min(distanceToPopupLimit, distance + MaxMovementAwayFromPopup); |
|
} |
|
|
|
void OnTextViewPointerExited(object? sender, PointerEventArgs e) |
|
{ |
|
// Don't close the rich popup if the pointer just moved from the editor onto the |
|
// popup itself — the user is reaching for it. The overlay popup delivers the |
|
// editor's exit BEFORE the popup child's IsPointerOver flips, so the flag alone |
|
// would close the popup right under the pointer; the distance check covers that |
|
// ordering. Plain tooltips always close. |
|
if (richPopup.IsOpen |
|
&& (richPopup.Child is { IsPointerOver: true } || GetDistanceToPopup(e) <= MaxMovementAwayFromPopup)) |
|
{ |
|
return; |
|
} |
|
TryCloseExistingPopup(mouseClick: false); |
|
} |
|
|
|
// Open* both assume the caller has already closed any existing popup / tooltip via |
|
// TryCloseExistingPopup — so they don't bother defending against a leftover popup. |
|
|
|
void OpenPlainTooltip(Control content) |
|
{ |
|
ToolTip.SetTip(Editor.TextArea.TextView, content); |
|
ToolTip.SetIsOpen(Editor.TextArea.TextView, true); |
|
} |
|
|
|
internal void OpenRichPopup(Control content) |
|
{ |
|
richPopup.Child = content; |
|
// While the pointer is over the popup, the editor no longer receives the moves |
|
// that drive the distance corridor — the popup's lifetime is governed by leaving |
|
// its own content instead. |
|
content.PointerExited += OnRichPopupContentPointerExited; |
|
richPopup.Open(); |
|
distanceToPopupLimit = double.PositiveInfinity; |
|
} |
|
|
|
void OnRichPopupContentPointerExited(object? sender, PointerEventArgs e) |
|
{ |
|
// A selection drag that sweeps past the popup edge keeps the pointer captured |
|
// inside the popup — that's a wide swipe, not a leave. The over/focus vetoes in |
|
// TryCloseExistingPopup don't see the capture, so check it here. |
|
if (e.Pointer.Captured is Visual captured && richPopup.Child is { } child |
|
&& (ReferenceEquals(captured, child) || captured.GetVisualAncestors().Contains(child))) |
|
{ |
|
return; |
|
} |
|
TryCloseExistingPopup(mouseClick: false); |
|
} |
|
|
|
double GetDistanceToPopup(PointerEventArgs e) |
|
{ |
|
if (richPopup.Child is not Control child) |
|
return 0; |
|
try |
|
{ |
|
var screen = Editor.TextArea.TextView.PointToScreen(e.GetPosition(Editor.TextArea.TextView)); |
|
var local = child.PointToClient(screen); |
|
var size = child.Bounds.Size; |
|
double x = local.X < 0 ? -local.X : (local.X > size.Width ? local.X - size.Width : 0); |
|
double y = local.Y < 0 ? -local.Y : (local.Y > size.Height ? local.Y - size.Height : 0); |
|
return Math.Sqrt(x * x + y * y); |
|
} |
|
catch (ArgumentException) |
|
{ |
|
// PointToClient throws if the popup child isn't connected — treat as far away. |
|
return double.PositiveInfinity; |
|
} |
|
} |
|
|
|
void CloseTooltip() |
|
{ |
|
ToolTip.SetIsOpen(Editor.TextArea.TextView, false); |
|
} |
|
|
|
void CloseRichPopup() |
|
{ |
|
if (richPopup.IsOpen) |
|
{ |
|
if (richPopup.Child is { } child) |
|
child.PointerExited -= OnRichPopupContentPointerExited; |
|
richPopup.Close(); |
|
} |
|
} |
|
|
|
void OnRichPopupClosed(object? sender, EventArgs e) |
|
{ |
|
distanceToPopupLimit = double.PositiveInfinity; |
|
} |
|
|
|
internal HoverContent? BuildHoverContent(DecompilerTabPageModel model, ReferenceSegment segment) |
|
{ |
|
var language = model.Language; |
|
var resolved = ResolveEntity(model, segment.Reference); |
|
switch (resolved) |
|
{ |
|
case IEntity entity when language != null: |
|
// The hover tooltip is the full signature (return type before the name, no body); |
|
// IL renders its own header form. No bold emphasis here, unlike the analyzer pane. |
|
var rich = language.GetRichText(entity, |
|
ConversionFlags.All & ~(ConversionFlags.ShowBody | ConversionFlags.PlaceReturnTypeAfterParameterList)); |
|
if (rich == null || string.IsNullOrEmpty(rich.Text)) |
|
return null; |
|
var renderer = CreateTooltipRenderer(); |
|
renderer.AddSignatureBlock(rich); |
|
AppendXmlDocumentation(renderer, entity); |
|
return new HoverContent(renderer.CreateView(), IsRich: true); |
|
case OpCodeInfo op: |
|
// Opcode docs are published as field documentation on |
|
// System.Reflection.Emit.OpCodes, so the mscorlib provider serves them. |
|
var opDocs = XmlDocLoader.MscorlibDocumentation |
|
?.GetDocumentation("F:System.Reflection.Emit.OpCodes." + op.EncodedName); |
|
if (opDocs != null) |
|
{ |
|
var opRenderer = CreateTooltipRenderer(); |
|
opRenderer.AddSignatureBlock(new RichText($"{op.Name} (0x{op.Code:x})")); |
|
opRenderer.AddXmlDocumentation(opDocs, declaringEntity: null, resolver: ResolveDocReference); |
|
return new HoverContent(opRenderer.CreateView(), IsRich: true); |
|
} |
|
var opBlock = new SelectableTextBlock { |
|
FontFamily = new FontFamily("Consolas, Menlo, Monospace"), |
|
FontSize = 12, |
|
MaxWidth = 600, |
|
TextWrapping = TextWrapping.Wrap, |
|
Inlines = new InlineCollection { new Run($"{op.Name} (0x{op.Code:x})") }, |
|
}; |
|
return new HoverContent(opBlock, IsRich: false); |
|
default: |
|
return null; |
|
} |
|
} |
|
|
|
// Hover tooltips share one renderer setup: monospace signature font, and any followed |
|
// documentation link closes the popup so the navigation is visible underneath. |
|
DocumentationRenderer CreateTooltipRenderer() |
|
{ |
|
var renderer = new DocumentationRenderer( |
|
new CSharpAmbience(), |
|
new FontFamily("Consolas, Menlo, Monospace"), |
|
12); |
|
renderer.LinkClicked += (_, _) => CloseRichPopup(); |
|
return renderer; |
|
} |
|
|
|
void AppendXmlDocumentation(DocumentationRenderer renderer, IEntity entity) |
|
{ |
|
try |
|
{ |
|
if (entity.ParentModule?.MetadataFile is not { } metadata) |
|
return; |
|
// XmlDocLoader handles every layout the decompiler library knows about: .xml |
|
// beside the .dll, .NET Framework reference-assemblies paths, and (recently) |
|
// the modern .NET ref pack at dotnet/packs/Microsoft.NETCore.App.Ref/... |
|
var documentation = XmlDocLoader.LoadDocumentation(metadata)?.GetDocumentation(entity.GetIdString()); |
|
if (documentation == null) |
|
return; |
|
renderer.AddXmlDocumentation(documentation, entity, resolver: ResolveDocReference); |
|
} |
|
catch (XmlException) |
|
{ |
|
// Malformed .xml — render signature only. |
|
} |
|
} |
|
|
|
/// <summary> |
|
/// Resolves a doc-comment cref id string (e.g. <c>T:System.String</c>) against the |
|
/// assembly list the current tab decompiled from. |
|
/// </summary> |
|
IEntity? ResolveDocReference(string idString) |
|
{ |
|
if (DataContext is not DecompilerTabPageModel model || model.CurrentNode is not { } node) |
|
return null; |
|
var list = node.AncestorsAndSelf().OfType<TreeNodes.AssemblyListTreeNode>().FirstOrDefault()?.AssemblyList; |
|
return list == null ? null : AssemblyTree.AssemblyTreeModel.FindEntityInRelevantAssemblies(idString, list.GetAssemblies()); |
|
} |
|
|
|
object? ResolveEntity(DecompilerTabPageModel model, object? reference) |
|
{ |
|
switch (reference) |
|
{ |
|
case IEntity entity: |
|
return entity; |
|
case OpCodeInfo op: |
|
return op; |
|
case EntityReference unresolved: |
|
if (DataContext is not DecompilerTabPageModel m || m.CurrentNode is not { } node) |
|
return null; |
|
var list = node.AncestorsAndSelf().OfType<TreeNodes.AssemblyListTreeNode>().FirstOrDefault()?.AssemblyList; |
|
return list == null ? null : unresolved.Resolve(list); |
|
default: |
|
return null; |
|
} |
|
} |
|
} |
|
}
|
|
|