// 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.Linq; using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.DataGridHierarchical; using Avalonia.Headless; using Avalonia.Headless.NUnit; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Threading; using Avalonia.VisualTree; using AwesomeAssertions; using ICSharpCode.ILSpy.Properties; using ICSharpCode.ILSpyX.TreeView; using ILSpy; using ILSpy.AppEnv; using ILSpy.AssemblyTree; using ILSpy.Commands; using ILSpy.Docking; using ILSpy.TextView; using ILSpy.TreeNodes; using ILSpy.ViewModels; using ILSpy.Views; using NUnit.Framework; namespace ICSharpCode.ILSpy.Tests; [TestFixture] public class DecompileInNewViewTests { [AvaloniaTest] public async Task DecompileInNewView_Entry_Is_Registered() { // "Decompile in new tab" is a context-menu entry sitting alongside Remove on every // node that decompiles. Verifies the [ExportContextMenuEntry] is discovered. // Arrange โ€” boot. var window = AppComposition.Current.GetExport(); window.Show(); var registry = AppComposition.Current.GetExport(); // Assert โ€” registry contains an entry whose header is the DecompileToNewPanel resource. registry.Entries.Should().Contain( e => e.Metadata.Header == nameof(Resources.DecompileToNewPanel)); } // audit (2026-05-12): converted ๐Ÿ“ฆ โ†’ ๐Ÿงช. Was calling `entry.IsVisible(synthetic ctx)` // directly with hand-built TextViewContexts; now drives the live `BuildContextMenuForCurrentState` // path with real selection states. The entry's presence/absence in the built menu IS the // integration-level expression of IsVisible (the builder drops hidden entries before // creating their MenuItems). [AvaloniaTest] public async Task DecompileInNewView_Appears_In_The_Right_Click_Menu_Only_When_Tree_Nodes_Are_Selected() { var window = AppComposition.Current.GetExport(); window.Show(); var vm = (MainWindowViewModel)window.DataContext!; await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); var pane = await window.WaitForComponent(); var registry = AppComposition.Current.GetExport(); var asm = vm.AssemblyTreeModel.FindNode("System.Linq"); var header = ICSharpCode.ILSpy.Properties.Resources.DecompileToNewPanel; window.Capture("initial-no-selection"); // With an assembly node selected โ†’ the menu surfaces "Decompile in new tab". vm.AssemblyTreeModel.SelectNode(asm); window.Capture("assembly-selected"); var withSelection = pane.BuildContextMenuForCurrentState(registry.Entries); withSelection.Should().NotBeNull(); withSelection!.Items.OfType().Select(i => (string?)i.Header) .Should().Contain(header); // With no selection โ†’ the entry is filtered out of the built menu. vm.AssemblyTreeModel.SelectedItems.Clear(); window.Capture("selection-cleared"); var withoutSelection = pane.BuildContextMenuForCurrentState(registry.Entries); // `Build` returns null when no entry would be visible; otherwise it may still build a // menu (other entries don't gate on selection). Either way, the header must not appear. (withoutSelection?.Items.OfType().Select(i => (string?)i.Header) ?? System.Linq.Enumerable.Empty()) .Should().NotContain(header); } // audit (2026-06-02): rewritten for the Thunderbird-style context target. Previously this // test selected the second method via the model first โ€” encoding the OLD bug where // right-clicking a row moved the selection there before the menu opened, so you ended up // with the right-clicked node twice (the preview reused for it + the new tab) instead of // "keep A, open B". Now A stays the active document and the menu targets B directly, so the // result is A + B: the new tab shows B and A is untouched. [AvaloniaTest] public async Task Clicking_DecompileInNewView_Opens_The_Right_Clicked_Method_While_Keeping_The_Active_One() { var window = AppComposition.Current.GetExport(); window.Show(); var vm = (MainWindowViewModel)window.DataContext!; await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); var typeNode = vm.AssemblyTreeModel.FindNode( "System.Linq", "System.Linq", "System.Linq.Enumerable"); typeNode.IsExpanded = true; var firstMethod = typeNode.Children.OfType() .Single(m => m.MethodDefinition.Name == "AsEnumerable"); var secondMethod = typeNode.Children.OfType() .First(m => m.MethodDefinition.Name == "Empty"); // A is the active document: select firstMethod and decompile it into the preview tab. vm.AssemblyTreeModel.SelectNode(firstMethod); var firstTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); firstTab.Text.Should().Contain("AsEnumerable"); window.Capture("first-method-active"); var pane = await window.WaitForComponent(); var registry = AppComposition.Current.GetExport(); var documents = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; var initialCount = documents.VisibleDockables?.Count ?? 0; // Right-click B (secondMethod) WITHOUT moving the selection, and click "Decompile to // new tab" from the menu the right-click would produce. var menu = pane.BuildContextMenuForCurrentState(registry.Entries, rightClickedNode: secondMethod); menu.Should().NotBeNull(); menu!.ClickItem(Resources.DecompileToNewPanel); await Waiters.WaitForAsync( () => (documents.VisibleDockables?.Count ?? 0) > initialCount); var newTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); // The new tab shows B... ReferenceEquals(newTab, firstTab).Should().BeFalse( "a fresh decompiler tab must be created instead of reusing the existing one"); newTab.Text.Should().Contain("Empty"); // ...the original tab still shows A (it was NOT yanked to B by the right-click โ€” this is // the whole point: you keep A and gain B, instead of ending up with B twice)... firstTab.Text.Should().Contain("AsEnumerable"); // ...and exactly one tab was added (A + B), not two copies of B. documents.VisibleDockables!.Count.Should().Be(initialCount + 1); window.Capture("a-kept-b-opened"); } [AvaloniaTest] public async Task Right_Clicking_An_Unselected_Row_Does_Not_Move_The_Selection() { // Thunderbird-style context target: right-clicking a row the user has NOT selected must // leave the real selection (and therefore the preview document) untouched. Today // ProDataGrid's default selects the right-clicked row on press, which yanks the preview // to B before the menu opens โ€” so "Decompile to new tab" ends up with B twice instead of // the intended A + B. This probe asserts the selection stays put under a real right-click. var window = AppComposition.Current.GetExport(); window.Show(); var vm = (MainWindowViewModel)window.DataContext!; await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); var pane = await window.WaitForComponent(); var grid = await pane.WaitForComponent(); var nodeA = vm.AssemblyTreeModel.FindNode("System.Linq"); var nodeB = vm.AssemblyTreeModel.FindNode(TreeNavigation.CoreLibName); // Make A the active document/selection. vm.AssemblyTreeModel.SelectNode(nodeA); // Let the top-level rows realise and layout settle. for (int i = 0; i < 8; i++) { Dispatcher.UIThread.RunJobs(); grid.UpdateLayout(); await Task.Delay(25); } var rowB = grid.GetVisualDescendants().OfType() .FirstOrDefault(r => RowNodeEquals(r, nodeB)); rowB.Should().NotBeNull("the top-level CoreLib row must be realised"); // Suppressing the right-press must not also suppress the context menu โ€” it still opens // from ContextRequested (raised on release). Watch for it. var menuRequested = false; grid.AddHandler(Control.ContextRequestedEvent, (_, _) => menuRequested = true, handledEventsToo: true); // Right-click the centre of B's row (clear of the far-left expander glyph). var point = rowB!.TranslatePoint(new Point(rowB.Bounds.Width / 2, rowB.Bounds.Height / 2), window); point.Should().NotBeNull(); HeadlessWindowExtensions.MouseDown(window, point!.Value, MouseButton.Right); HeadlessWindowExtensions.MouseUp(window, point.Value, MouseButton.Right); for (int i = 0; i < 4; i++) { Dispatcher.UIThread.RunJobs(); await Task.Delay(20); } ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, nodeA).Should().BeTrue( "right-clicking an unselected row must not change the selection (Thunderbird-style context target)"); menuRequested.Should().BeTrue( "the context menu must still be requested after the suppressed right-press"); rowB.Classes.Should().Contain("contextTarget", "the right-clicked row must carry the focus-box style class while it is the menu target"); } [AvaloniaTest] public async Task Right_Clicking_A_Second_Row_Moves_The_Context_Highlight_To_It() { // Regression: the focus box must follow every right-click, not stick after the first one // and then never appear again. Reproduces "right-click B (highlight), dismiss, right-click // C (no highlight)". var window = AppComposition.Current.GetExport(); window.Show(); var vm = (MainWindowViewModel)window.DataContext!; await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); var pane = await window.WaitForComponent(); var grid = await pane.WaitForComponent(); var assemblies = vm.AssemblyTreeModel.Root!.Children.OfType().Take(3).ToArray(); assemblies.Length.Should().BeGreaterThanOrEqualTo(3, "need three top-level rows to click"); var nodeA = assemblies[0]; var nodeB = assemblies[1]; var nodeC = assemblies[2]; vm.AssemblyTreeModel.SelectNode(nodeA); for (int i = 0; i < 8; i++) { Dispatcher.UIThread.RunJobs(); grid.UpdateLayout(); await Task.Delay(25); } DataGridRow Row(SharpTreeNode node) => grid.GetVisualDescendants().OfType() .First(r => RowNodeEquals(r, node)); async Task RightClick(SharpTreeNode node) { var row = Row(node); var pt = row.TranslatePoint(new Point(row.Bounds.Width / 2, row.Bounds.Height / 2), window); HeadlessWindowExtensions.MouseDown(window, pt!.Value, MouseButton.Right); HeadlessWindowExtensions.MouseUp(window, pt.Value, MouseButton.Right); for (int i = 0; i < 4; i++) { Dispatcher.UIThread.RunJobs(); await Task.Delay(20); } } async Task Dismiss() { window.KeyPress(Key.Escape, RawInputModifiers.None, PhysicalKey.Escape, keySymbol: null); for (int i = 0; i < 4; i++) { Dispatcher.UIThread.RunJobs(); await Task.Delay(20); } } await RightClick(nodeB); Row(nodeB).Classes.Should().Contain("contextTarget", "the first right-click highlights B"); // Dismiss B's menu, then right-click C โ€” the user's exact sequence. await Dismiss(); await RightClick(nodeC); Row(nodeC).Classes.Should().Contain("contextTarget", "a second right-click (after dismissing the first menu) must highlight C"); Row(nodeB).Classes.Should().NotContain("contextTarget", "B's highlight must be gone"); } [AvaloniaTest] public async Task Opening_A_Node_In_A_New_Tab_Syncs_The_Tree_Selection_To_It() { // Activating the new tab must pull the tree selection over to its node -- both the model // selection AND the grid's visual selection. Previously masked because the right-click // already moved the selection; now that right-click leaves it put, the tab-activation // sync has to do the work. var window = AppComposition.Current.GetExport(); window.Show(); var vm = (MainWindowViewModel)window.DataContext!; await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); var pane = await window.WaitForComponent(); var grid = await pane.WaitForComponent(); var nodeA = vm.AssemblyTreeModel.FindNode("System.Linq"); var nodeB = vm.AssemblyTreeModel.FindNode(TreeNavigation.CoreLibName); vm.AssemblyTreeModel.SelectNode(nodeA); await vm.DockWorkspace.WaitForDecompiledTextAsync(); for (int i = 0; i < 6; i++) { Dispatcher.UIThread.RunJobs(); grid.UpdateLayout(); await Task.Delay(20); } var registry = AppComposition.Current.GetExport(); var menu = pane.BuildContextMenuForCurrentState(registry.Entries, rightClickedNode: nodeB); menu!.ClickItem(Resources.DecompileToNewPanel); await vm.DockWorkspace.WaitForDecompiledTextAsync(); for (int i = 0; i < 6; i++) { Dispatcher.UIThread.RunJobs(); grid.UpdateLayout(); await Task.Delay(20); } // The model selection follows the new active tab... ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, nodeB).Should().BeTrue( "the tree model selection must follow the newly-activated tab"); // ...and so does the grid's visual selection (the grid stores either the raw node or its // HierarchicalNode wrapper depending on the path that set it). ReferenceEquals(GridSelectedNode(grid), nodeB).Should().BeTrue( "the grid's visual selection must follow the newly-activated tab"); } [AvaloniaTest] public async Task Activating_A_Single_Node_Tab_Syncs_The_Tree_After_A_Multi_Node_Selection() { // Regression: once the tree has held a multi-selection (e.g. a tab decompiled from // several nodes), activating a single-node tab must still pull the tree selection over. // The SelectedItem setter replaces the collection via Clear()+Add(); for the count>1 case // that exposed a transient empty selection, whose grid sync deferred its done-flag and // then suppressed the sync for the real value -- so the tree stopped following the tab. var window = AppComposition.Current.GetExport(); window.Show(); var vm = (MainWindowViewModel)window.DataContext!; await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); var pane = await window.WaitForComponent(); var grid = await pane.WaitForComponent(); var assemblies = vm.AssemblyTreeModel.Root!.Children.OfType().Take(3).ToArray(); assemblies.Length.Should().BeGreaterThanOrEqualTo(3, "need three top-level rows"); var nodeA = assemblies[0]; var nodeB = assemblies[1]; var nodeC = assemblies[2]; // Hold a multi-selection (A + B), as a multi-node decompile tab would. vm.AssemblyTreeModel.SelectedItems.Clear(); vm.AssemblyTreeModel.SelectedItems.Add(nodeA); vm.AssemblyTreeModel.SelectedItems.Add(nodeB); await vm.DockWorkspace.WaitForDecompiledTextAsync(); for (int i = 0; i < 6; i++) { Dispatcher.UIThread.RunJobs(); grid.UpdateLayout(); await Task.Delay(20); } vm.AssemblyTreeModel.SelectedItems.Count.Should().Be(2, "precondition: a multi-selection is held"); // Open C in a new tab -> activates it -> the tree must follow to C. var registry = AppComposition.Current.GetExport(); var menu = pane.BuildContextMenuForCurrentState(registry.Entries, rightClickedNode: nodeC); menu!.ClickItem(Resources.DecompileToNewPanel); await vm.DockWorkspace.WaitForDecompiledTextAsync(); for (int i = 0; i < 6; i++) { Dispatcher.UIThread.RunJobs(); grid.UpdateLayout(); await Task.Delay(20); } ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, nodeC).Should().BeTrue( "the tree model selection must follow the newly-activated single-node tab"); vm.AssemblyTreeModel.SelectedItems.Count.Should().Be(1, "activating the single-node tab must collapse the multi-selection to that one node"); ReferenceEquals(GridSelectedNode(grid), nodeC).Should().BeTrue( "the grid's visual selection must follow even after a prior multi-selection"); } [AvaloniaTest] public async Task Activating_A_Multi_Node_Tab_Restores_Its_Multi_Selection_In_The_Tree() { // A tab decompiled from several nodes carries no single SourceNode, so activating it has // to restore the WHOLE set in the tree -- not nothing, and not just one. Scenario: select // two nodes (multi-node preview tab), open a third in a new tab, then re-activate the // multi-node tab; the tree selection must come back to both original nodes. var window = AppComposition.Current.GetExport(); window.Show(); var vm = (MainWindowViewModel)window.DataContext!; await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); var pane = await window.WaitForComponent(); var grid = await pane.WaitForComponent(); var documents = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; var assemblies = vm.AssemblyTreeModel.Root!.Children.OfType().Take(3).ToArray(); assemblies.Length.Should().BeGreaterThanOrEqualTo(3, "need three top-level rows"); var nodeA = assemblies[0]; var nodeB = assemblies[1]; var nodeC = assemblies[2]; // 1) Multi-select A + B -> the active (preview) tab decompiles both. vm.AssemblyTreeModel.SelectedItems.Clear(); vm.AssemblyTreeModel.SelectedItems.Add(nodeA); vm.AssemblyTreeModel.SelectedItems.Add(nodeB); await vm.DockWorkspace.WaitForDecompiledTextAsync(); var multiTab = (ContentTabPage)documents.ActiveDockable!; // 2) Open C in a new (single-node) tab -> it becomes active, tree -> C. var registry = AppComposition.Current.GetExport(); var menu = pane.BuildContextMenuForCurrentState(registry.Entries, rightClickedNode: nodeC); menu!.ClickItem(Resources.DecompileToNewPanel); await vm.DockWorkspace.WaitForDecompiledTextAsync(); ReferenceEquals(multiTab, documents.ActiveDockable).Should().BeFalse( "precondition: the new single-node tab is active, not the multi-node one"); // 3) Re-activate the multi-node tab. vm.DockWorkspace.Factory.SetActiveDockable(multiTab); for (int i = 0; i < 8; i++) { Dispatcher.UIThread.RunJobs(); grid.UpdateLayout(); await Task.Delay(20); } // 4) The tree selection must contain BOTH original nodes again -- in the model... vm.AssemblyTreeModel.SelectedItems.Should().Contain(nodeA) .And.Contain(nodeB); vm.AssemblyTreeModel.SelectedItems.Count.Should().Be(2, "re-activating the multi-node tab restores exactly its two nodes"); // ...and visually in the grid (both rows highlighted, not just the primary). GridSelectedNodes(grid).Should().Contain(nodeA).And.Contain(nodeB); GridSelectedNodes(grid).Should().HaveCount(2, "the grid must visually highlight every restored node"); } static bool RowNodeEquals(DataGridRow row, SharpTreeNode node) => row.DataContext is HierarchicalNode hn && ReferenceEquals(hn.Item, node); static SharpTreeNode? GridSelectedNode(DataGrid grid) => grid.SelectedItem is HierarchicalNode hn ? hn.Item as SharpTreeNode : grid.SelectedItem as SharpTreeNode; static System.Collections.Generic.List GridSelectedNodes(DataGrid grid) => grid.SelectedItems.Cast() .Select(o => o is HierarchicalNode hn ? hn.Item as SharpTreeNode : o as SharpTreeNode) .Where(n => n != null) .Select(n => n!) .ToList(); }