Browse Source

Single document with swappable inner content

Tree-node selection now updates the inner Content of one persistent
ContentTabPage instead of swapping the dockable in the dock. The
wrapper view (ContentTabPageView) keeps both inner views — the
decompiler text editor and the metadata grid — pre-realised in the
visual tree from construction time and toggles which is visible based
on Content's runtime type.

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
56183e8176
  1. 54
      ILSpy.Tests/Commands/HelpCommandTests.cs
  2. 8
      ILSpy.Tests/Editor/DecompilerViewTests.cs
  3. 42
      ILSpy.Tests/Metadata/PEHeaderTreeTests.cs
  4. 8
      ILSpy.Tests/Waiters.cs
  5. 3
      ILSpy/App.axaml
  6. 17
      ILSpy/Commands/AboutCommand.cs
  7. 6
      ILSpy/Commands/DecompileInNewViewCommand.cs
  8. 174
      ILSpy/Docking/DockWorkspace.cs
  9. 12
      ILSpy/Docking/ILSpyDockFactory.cs
  10. 64
      ILSpy/ViewModels/ContentTabPage.cs
  11. 22
      ILSpy/Views/ContentTabPageView.axaml
  12. 94
      ILSpy/Views/ContentTabPageView.axaml.cs

54
ILSpy.Tests/Commands/HelpCommandTests.cs

@ -20,6 +20,7 @@ using System;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Headless.NUnit; using Avalonia.Headless.NUnit;
using Avalonia.VisualTree; using Avalonia.VisualTree;
@ -72,9 +73,9 @@ public class HelpCommandTests
// containing the version line and the MIT License mention from the embedded blurb. // containing the version line and the MIT License mention from the embedded blurb.
await Waiters.WaitForAsync( await Waiters.WaitForAsync(
() => (documents.VisibleDockables?.Count ?? 0) > initialTabCount () => (documents.VisibleDockables?.Count ?? 0) > initialTabCount
&& documents.ActiveDockable is DecompilerTabPageModel { Text.Length: > 0 }); && documents.ActiveDockable is ContentTabPage { Content: DecompilerTabPageModel { Text.Length: > 0 } });
var aboutTab = (DecompilerTabPageModel)documents.ActiveDockable!; var aboutTab = (DecompilerTabPageModel)((ContentTabPage)documents.ActiveDockable!).Content!;
aboutTab.Title.Should().Be(Resources.About); aboutTab.Title.Should().Be(Resources.About);
aboutTab.Text.Should().Contain(Resources.ILSpyVersion); aboutTab.Text.Should().Contain(Resources.ILSpyVersion);
aboutTab.Text.Should().Contain("MIT License"); aboutTab.Text.Should().Contain("MIT License");
@ -102,8 +103,8 @@ public class HelpCommandTests
var documents = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; var documents = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!;
aboutCmd.Execute(null); aboutCmd.Execute(null);
await Waiters.WaitForAsync( await Waiters.WaitForAsync(
() => documents.ActiveDockable is DecompilerTabPageModel { Text.Length: > 0 }); () => documents.ActiveDockable is ContentTabPage { Content: DecompilerTabPageModel { Text.Length: > 0 } });
var aboutTab = (DecompilerTabPageModel)documents.ActiveDockable!; var aboutTab = (DecompilerTabPageModel)((ContentTabPage)documents.ActiveDockable!).Content!;
// Assert (mid-test) — the tab carries the custom hyperlink generators that // Assert (mid-test) — the tab carries the custom hyperlink generators that
// DecompilerTextView installs alongside the document. // DecompilerTextView installs alongside the document.
@ -120,10 +121,10 @@ public class HelpCommandTests
// Assert — a new tab opens with the license body. // Assert — a new tab opens with the license body.
await Waiters.WaitForAsync( await Waiters.WaitForAsync(
() => (documents.VisibleDockables?.Count ?? 0) > beforeCount () => (documents.VisibleDockables?.Count ?? 0) > beforeCount
&& documents.ActiveDockable is DecompilerTabPageModel licenseTab && documents.ActiveDockable is ContentTabPage { Content: DecompilerTabPageModel licenseInner }
&& !ReferenceEquals(licenseTab, aboutTab) && !ReferenceEquals(licenseInner, aboutTab)
&& licenseTab.Text.Length > 0); && licenseInner.Text.Length > 0);
var licenseTab = (DecompilerTabPageModel)documents.ActiveDockable!; var licenseTab = (DecompilerTabPageModel)((ContentTabPage)documents.ActiveDockable!).Content!;
licenseTab.Text.Should().Contain("Permission is hereby granted"); licenseTab.Text.Should().Contain("Permission is hereby granted");
} }
@ -150,20 +151,24 @@ public class HelpCommandTests
// realised editor — the value AvaloniaEdit's LinkElementGenerator (and our own // realised editor — the value AvaloniaEdit's LinkElementGenerator (and our own
// ResourceLinkGenerator) both consult when constructing VisualLineLinkText. // ResourceLinkGenerator) both consult when constructing VisualLineLinkText.
// Arrange — boot, select a node so the decompiler tab is realised + the editor inside // Arrange — boot, select a node so a decompiler viewmodel is alive on the active
// it is actually constructed (the view is data-templated lazily on first activation). // tab. Construct the view explicitly (the dock's deferred content presenter doesn't
// materialise its templated children in Avalonia.Headless mode, so walking the
// window's visual tree wouldn't find the editor — building one from the live
// viewmodel mirrors what the live app's DataTemplate produces).
var window = AppComposition.Current.GetExport<MainWindow>(); var window = AppComposition.Current.GetExport<MainWindow>();
window.Show(); window.Show();
var vm = (MainWindowViewModel)window.DataContext!; var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var node = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq"); var node = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
vm.AssemblyTreeModel.SelectNode(node); vm.AssemblyTreeModel.SelectNode(node);
await vm.DockWorkspace.WaitForDecompiledTextAsync(); var content = await vm.DockWorkspace.WaitForDecompiledTextAsync();
await Waiters.WaitForAsync(
() => window.GetVisualDescendants().OfType<DecompilerTextView>().Any());
// Act — locate the editor inside the realised DecompilerTextView. var view = new DecompilerTextView { DataContext = content };
var view = await window.WaitForComponent<DecompilerTextView>(); // Force layout so the templated TextEditor child is realised — the view's
// DataContextChanged-driven setup runs synchronously on assignment.
var host = new Window { Content = view, Width = 600, Height = 400 };
host.Show();
var editor = await view.WaitForComponent<AvaloniaEdit.TextEditor>(); var editor = await view.WaitForComponent<AvaloniaEdit.TextEditor>();
// Assert — the editor's hyperlink-click option is off. // Assert — the editor's hyperlink-click option is off.
@ -206,10 +211,12 @@ public class HelpCommandTests
.Single(c => c.Metadata.Header == nameof(Resources._About)) .Single(c => c.Metadata.Header == nameof(Resources._About))
.CreateExport().Value; .CreateExport().Value;
var documents = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; var documents = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!;
var mainTab = ((ILSpyDockFactory)vm.DockWorkspace.Factory).MainTab!;
aboutCmd.Execute(null); aboutCmd.Execute(null);
await Waiters.WaitForAsync( await Waiters.WaitForAsync(
() => documents.ActiveDockable is DecompilerTabPageModel { IsStaticContent: true }); () => documents.ActiveDockable is ContentTabPage { Content: DecompilerTabPageModel { IsStaticContent: true } });
var aboutTab = (DecompilerTabPageModel)documents.ActiveDockable!; var aboutWrapper = (ContentTabPage)documents.ActiveDockable!;
var aboutTab = (DecompilerTabPageModel)aboutWrapper.Content!;
var aboutText = aboutTab.Text; var aboutText = aboutTab.Text;
// Assert (mid-test) — the back stack now ends with the tree-node entry; the About // Assert (mid-test) — the back stack now ends with the tree-node entry; the About
@ -221,24 +228,23 @@ public class HelpCommandTests
// Act 2 — press Back from the About page. // Act 2 — press Back from the About page.
vm.DockWorkspace.NavigateBackCommand.CanExecute(null).Should().BeTrue(); vm.DockWorkspace.NavigateBackCommand.CanExecute(null).Should().BeTrue();
// execute vm.DockWorkspace.NavigateBackCommand
vm.DockWorkspace.NavigateBackCommand.Execute(null); vm.DockWorkspace.NavigateBackCommand.Execute(null);
await Waiters.WaitForAsync( await Waiters.WaitForAsync(
() => ReferenceEquals(documents.ActiveDockable, decompilerTab)); () => ReferenceEquals(documents.ActiveDockable, mainTab));
// Assert — the decompiler tab is active again; About tab still exists with content // Assert — the main tab (with the decompiler content) is active again; the About
// preserved. // sibling still exists with content preserved.
ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, method).Should().BeTrue(); ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, method).Should().BeTrue();
decompilerTab.Text.Should().Be(methodText); decompilerTab.Text.Should().Be(methodText);
documents.VisibleDockables!.Should().Contain(aboutTab); mainTab.Content.Should().BeSameAs(decompilerTab);
documents.VisibleDockables!.Should().Contain(aboutWrapper);
aboutTab.Text.Should().Be(aboutText, "the About tab content must survive a Back navigation"); aboutTab.Text.Should().Be(aboutText, "the About tab content must survive a Back navigation");
// Act 3 — press Forward to return to the About page. // Act 3 — press Forward to return to the About page.
vm.DockWorkspace.NavigateForwardCommand.CanExecute(null).Should().BeTrue(); vm.DockWorkspace.NavigateForwardCommand.CanExecute(null).Should().BeTrue();
// execute vm.DockWorkspace.NavigateForwardCommand
vm.DockWorkspace.NavigateForwardCommand.Execute(null); vm.DockWorkspace.NavigateForwardCommand.Execute(null);
await Waiters.WaitForAsync( await Waiters.WaitForAsync(
() => ReferenceEquals(documents.ActiveDockable, aboutTab)); () => ReferenceEquals(documents.ActiveDockable, aboutWrapper));
// Assert — About is active again, content intact. // Assert — About is active again, content intact.
aboutTab.Text.Should().Be(aboutText); aboutTab.Text.Should().Be(aboutText);

8
ILSpy.Tests/Editor/DecompilerViewTests.cs

@ -434,6 +434,13 @@ public class DecompilerViewTests
vm.AssemblyTreeModel.SelectNode(assemblyNode); vm.AssemblyTreeModel.SelectNode(assemblyNode);
var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
// The dock's deferred content presenter doesn't materialise its templated children
// in Avalonia.Headless mode, so we construct the view explicitly from the live
// viewmodel — same shape the live app's DataTemplate produces.
var view = new DecompilerTextView { DataContext = tab };
var host = new global::Avalonia.Controls.Window { Content = view, Width = 600, Height = 400 };
host.Show();
// Act — drop a synthetic XML resource into the tab. XmlResourceEntryNode triggers // Act — drop a synthetic XML resource into the tab. XmlResourceEntryNode triggers
// SyntaxExtension=".xml" and the XmlFoldingStrategy install. // SyntaxExtension=".xml" and the XmlFoldingStrategy install.
var xml = "<root>\n <child attr=\"v\">\n text\n </child>\n <other>\n </other>\n</root>"; var xml = "<root>\n <child attr=\"v\">\n text\n </child>\n <other>\n </other>\n</root>";
@ -441,7 +448,6 @@ public class DecompilerViewTests
tab.CurrentNode = new XmlResourceEntryNode("test.xml", () => new System.IO.MemoryStream(bytes)); tab.CurrentNode = new XmlResourceEntryNode("test.xml", () => new System.IO.MemoryStream(bytes));
await Waiters.WaitForAsync(() => tab.SyntaxExtension == ".xml" && tab.Text.Contains("<root>")); await Waiters.WaitForAsync(() => tab.SyntaxExtension == ".xml" && tab.Text.Contains("<root>"));
var view = await window.WaitForComponent<DecompilerTextView>();
// Drain the layout so ApplyDocument's PropertyChanged handler has executed. // Drain the layout so ApplyDocument's PropertyChanged handler has executed.
for (int i = 0; i < 5; i++) for (int i = 0; i < 5; i++)
{ {

42
ILSpy.Tests/Metadata/PEHeaderTreeTests.cs

@ -139,8 +139,8 @@ public class PEHeaderTreeTests
var decompilerTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); var decompilerTab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
decompilerTab.Should().NotBeNull(); decompilerTab.Should().NotBeNull();
var documents = ((global::ILSpy.Docking.ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; var mainTab = ((global::ILSpy.Docking.ILSpyDockFactory)vm.DockWorkspace.Factory).MainTab!;
documents.ActiveDockable.Should().BeOfType<global::ILSpy.TextView.DecompilerTabPageModel>(); mainTab.Content.Should().BeOfType<global::ILSpy.TextView.DecompilerTabPageModel>();
} }
[AvaloniaTest] [AvaloniaTest]
@ -169,18 +169,19 @@ public class PEHeaderTreeTests
vm.AssemblyTreeModel.SelectNode(assemblyNode); vm.AssemblyTreeModel.SelectNode(assemblyNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
var documents = ((global::ILSpy.Docking.ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; var mainTab = ((global::ILSpy.Docking.ILSpyDockFactory)vm.DockWorkspace.Factory).MainTab!;
documents.ActiveDockable.Should().BeOfType<global::ILSpy.TextView.DecompilerTabPageModel>(); mainTab.Content.Should().BeOfType<global::ILSpy.TextView.DecompilerTabPageModel>();
} }
[AvaloniaTest] [AvaloniaTest]
public async Task Round_Tripping_Metadata_To_Entity_Keeps_The_Dock_Single_Tab() public async Task Round_Tripping_Metadata_To_Entity_Reuses_The_Same_Document_Instance()
{ {
// The dock holds at most one document tab at a time: switching from metadata to an // The document area holds a single persistent ContentTabPage for the lifetime of
// entity closes the grid and surfaces a decompiler text view, switching back // the app; only its inner Content swaps between decompiler / metadata viewmodels.
// closes the decompiler tab and surfaces a fresh grid. Mirrors WPF's // Keeping the Document instance stable is what makes the visual swap actually
// single-tab swap-content UX so the user never sees both views compete for the // happen on content-type changes — replacing the dockable in place leaves the
// document area. // previous inner view rendered (the user would see a "Decompiling" spinner that
// never goes away because the prior decompiler tab is still on screen).
var window = AppComposition.Current.GetExport<MainWindow>(); var window = AppComposition.Current.GetExport<MainWindow>();
window.Show(); window.Show();
@ -194,24 +195,29 @@ public class PEHeaderTreeTests
metadataNode.EnsureLazyChildren(); metadataNode.EnsureLazyChildren();
var dosNode = metadataNode.Children.OfType<DosHeaderTreeNode>().Single(); var dosNode = metadataNode.Children.OfType<DosHeaderTreeNode>().Single();
var coffNode = metadataNode.Children.OfType<CoffHeaderTreeNode>().Single(); var coffNode = metadataNode.Children.OfType<CoffHeaderTreeNode>().Single();
var documents = ((global::ILSpy.Docking.ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; var factoryFactory = (global::ILSpy.Docking.ILSpyDockFactory)vm.DockWorkspace.Factory;
var documents = factoryFactory.Documents!;
var mainTab = factoryFactory.MainTab!;
vm.AssemblyTreeModel.SelectNode(dosNode); vm.AssemblyTreeModel.SelectNode(dosNode);
await vm.DockWorkspace.WaitForMetadataTabAsync(); await vm.DockWorkspace.WaitForMetadataTabAsync();
documents.VisibleDockables!.Should().HaveCount(1); documents.VisibleDockables!.Should().HaveCount(1).And.Contain(mainTab);
mainTab.Content.Should().BeOfType<global::ILSpy.ViewModels.MetadataTablePageModel>();
vm.AssemblyTreeModel.SelectNode(assemblyNode); vm.AssemblyTreeModel.SelectNode(assemblyNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
documents.VisibleDockables!.Should().HaveCount(1); documents.VisibleDockables!.Should().HaveCount(1).And.Contain(mainTab);
mainTab.Content.Should().BeOfType<global::ILSpy.TextView.DecompilerTabPageModel>();
vm.AssemblyTreeModel.SelectNode(coffNode); vm.AssemblyTreeModel.SelectNode(coffNode);
await vm.DockWorkspace.WaitForMetadataTabAsync(); await vm.DockWorkspace.WaitForMetadataTabAsync();
documents.VisibleDockables!.Should().HaveCount(1); documents.VisibleDockables!.Should().HaveCount(1).And.Contain(mainTab);
mainTab.Content.Should().BeOfType<global::ILSpy.ViewModels.MetadataTablePageModel>();
vm.AssemblyTreeModel.SelectNode(assemblyNode); vm.AssemblyTreeModel.SelectNode(assemblyNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
documents.VisibleDockables!.Should().HaveCount(1); documents.VisibleDockables!.Should().HaveCount(1).And.Contain(mainTab);
documents.ActiveDockable.Should().BeOfType<global::ILSpy.TextView.DecompilerTabPageModel>(); mainTab.Content.Should().BeOfType<global::ILSpy.TextView.DecompilerTabPageModel>();
} }
[AvaloniaTest] [AvaloniaTest]
@ -250,8 +256,8 @@ public class PEHeaderTreeTests
thirdTab.Should().BeSameAs(firstTab); thirdTab.Should().BeSameAs(firstTab);
thirdTab.Title.Should().Be("DOS Header"); thirdTab.Title.Should().Be("DOS Header");
var documents = ((global::ILSpy.Docking.ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; var mainTab = ((global::ILSpy.Docking.ILSpyDockFactory)vm.DockWorkspace.Factory).MainTab!;
documents.VisibleDockables!.OfType<global::ILSpy.ViewModels.MetadataTablePageModel>().Should().ContainSingle(); mainTab.Content.Should().BeSameAs(firstTab, "the inner metadata viewmodel is reused in place");
} }
[AvaloniaTest] [AvaloniaTest]

8
ILSpy.Tests/Waiters.cs

@ -96,12 +96,12 @@ public static class Waiters
?? throw new InvalidOperationException("DockWorkspace has no document dock yet."); ?? throw new InvalidOperationException("DockWorkspace has no document dock yet.");
await WaitForAsync( await WaitForAsync(
() => documents.ActiveDockable is DecompilerTabPageModel { IsDecompiling: false } tab () => documents.ActiveDockable is ContentTabPage { Content: DecompilerTabPageModel { IsDecompiling: false } tab }
&& !string.IsNullOrEmpty(tab.Text), && !string.IsNullOrEmpty(tab.Text),
timeout, timeout,
"active decompiler tab to finish decompiling and produce text"); "active decompiler tab to finish decompiling and produce text");
return (DecompilerTabPageModel)documents.ActiveDockable!; return (DecompilerTabPageModel)((ContentTabPage)documents.ActiveDockable!).Content!;
} }
public static async Task<MetadataTablePageModel> WaitForMetadataTabAsync( public static async Task<MetadataTablePageModel> WaitForMetadataTabAsync(
@ -113,10 +113,10 @@ public static class Waiters
?? throw new InvalidOperationException("DockWorkspace has no document dock yet."); ?? throw new InvalidOperationException("DockWorkspace has no document dock yet.");
await WaitForAsync( await WaitForAsync(
() => documents.ActiveDockable is MetadataTablePageModel { Items.Count: > 0 }, () => documents.ActiveDockable is ContentTabPage { Content: MetadataTablePageModel { Items.Count: > 0 } },
timeout, timeout,
"active dockable to be a metadata table tab with populated rows"); "active dockable to be a metadata table tab with populated rows");
return (MetadataTablePageModel)documents.ActiveDockable!; return (MetadataTablePageModel)((ContentTabPage)documents.ActiveDockable!).Content!;
} }
} }

3
ILSpy/App.axaml

@ -38,6 +38,9 @@
<DataTemplate DataType="analyzers:AnalyzerTreeViewModel"> <DataTemplate DataType="analyzers:AnalyzerTreeViewModel">
<analyzers:AnalyzerTreeView /> <analyzers:AnalyzerTreeView />
</DataTemplate> </DataTemplate>
<DataTemplate DataType="vm:ContentTabPage">
<metaViews:ContentTabPageView />
</DataTemplate>
<DataTemplate DataType="textView:DecompilerTabPageModel"> <DataTemplate DataType="textView:DecompilerTabPageModel">
<textView:DecompilerTextView /> <textView:DecompilerTextView />
</DataTemplate> </DataTemplate>

17
ILSpy/Commands/AboutCommand.cs

@ -30,6 +30,7 @@ using ICSharpCode.ILSpy.Properties;
using ILSpy.Docking; using ILSpy.Docking;
using ILSpy.TextView; using ILSpy.TextView;
using ILSpy.ViewModels;
namespace ILSpy.Commands namespace ILSpy.Commands
{ {
@ -80,14 +81,13 @@ namespace ILSpy.Commands
foreach (var (phrase, uri) in Links) foreach (var (phrase, uri) in Links)
output.AddVisualLineElementGenerator(new ResourceLinkGenerator(phrase, uri)); output.AddVisualLineElementGenerator(new ResourceLinkGenerator(phrase, uri));
var tab = OpenInNewTab(Resources.About, output, ".txt"); var (tab, _) = OpenInNewTab(Resources.About, output, ".txt");
tab.IsStaticContent = true;
dockWorkspace.RecordStaticPage(tab, new Uri("resource:aboutpage")); dockWorkspace.RecordStaticPage(tab, new Uri("resource:aboutpage"));
} }
DecompilerTabPageModel OpenInNewTab(string title, AvaloniaEditTextOutput output, string syntaxExtension) (ContentTabPage Tab, DecompilerTabPageModel Content) OpenInNewTab(string title, AvaloniaEditTextOutput output, string syntaxExtension)
{ {
var tab = new DecompilerTabPageModel { var content = new DecompilerTabPageModel {
Title = title, Title = title,
SyntaxExtension = syntaxExtension, SyntaxExtension = syntaxExtension,
Text = output.GetText(), Text = output.GetText(),
@ -96,13 +96,13 @@ namespace ILSpy.Commands
DefinitionLookup = output.DefinitionLookup, DefinitionLookup = output.DefinitionLookup,
UIElements = output.UIElements, UIElements = output.UIElements,
Foldings = output.Foldings, Foldings = output.Foldings,
IsStaticContent = true,
CustomElementGenerators = output.ElementGenerators.Count > 0 CustomElementGenerators = output.ElementGenerators.Count > 0
? new List<VisualLineElementGenerator>(output.ElementGenerators) ? new List<VisualLineElementGenerator>(output.ElementGenerators)
: null, : null,
}; };
tab.OpenUriRequested += OnOpenUri; content.OpenUriRequested += OnOpenUri;
dockWorkspace.OpenNewTab(tab); return (dockWorkspace.OpenNewTab(content), content);
return tab;
} }
bool OnOpenUri(Uri uri) bool OnOpenUri(Uri uri)
@ -122,8 +122,7 @@ namespace ILSpy.Commands
using var reader = new StreamReader(stream); using var reader = new StreamReader(stream);
var output = new AvaloniaEditTextOutput { Title = resourceName }; var output = new AvaloniaEditTextOutput { Title = resourceName };
output.Write(reader.ReadToEnd()); output.Write(reader.ReadToEnd());
var tab = OpenInNewTab(resourceName, output, ".txt"); var (tab, _) = OpenInNewTab(resourceName, output, ".txt");
tab.IsStaticContent = true;
dockWorkspace.RecordStaticPage(tab, new Uri("resource:" + resourceName)); dockWorkspace.RecordStaticPage(tab, new Uri("resource:" + resourceName));
} }

6
ILSpy/Commands/DecompileInNewViewCommand.cs

@ -62,9 +62,9 @@ namespace ILSpy.Commands
var nodes = SelectedNodes(context).ToArray(); var nodes = SelectedNodes(context).ToArray();
if (nodes.Length == 0) if (nodes.Length == 0)
return; return;
var tab = new DecompilerTabPageModel { Language = languageService.CurrentLanguage }; var content = new DecompilerTabPageModel { Language = languageService.CurrentLanguage };
dockWorkspace.OpenNewTab(tab); dockWorkspace.OpenNewTab(content);
tab.CurrentNodes = nodes; content.CurrentNodes = nodes;
} }
static System.Collections.Generic.IEnumerable<ILSpyTreeNode> SelectedNodes(TextViewContext context) static System.Collections.Generic.IEnumerable<ILSpyTreeNode> SelectedNodes(TextViewContext context)

174
ILSpy/Docking/DockWorkspace.cs

@ -82,11 +82,6 @@ namespace ILSpy.Docking
entry => entry != null && (history.BackEntries.Contains(entry) || history.ForwardEntries.Contains(entry))); entry => entry != null && (history.BackEntries.Contains(entry) || history.ForwardEntries.Contains(entry)));
factory = new ILSpyDockFactory(toolPaneRegistry); factory = new ILSpyDockFactory(toolPaneRegistry);
Layout = factory.CreateLayout(); Layout = factory.CreateLayout();
if (factory.InitialDecompilerTab is { } initialTab)
{
initialTab.Language = languageService.CurrentLanguage;
initialTab.NavigateRequested += OnNavigateRequested;
}
assemblyTreeModel.PropertyChanged += OnAssemblyTreePropertyChanged; assemblyTreeModel.PropertyChanged += OnAssemblyTreePropertyChanged;
assemblyTreeModel.SelectedItems.CollectionChanged += (_, _) => ShowSelectedNode(); assemblyTreeModel.SelectedItems.CollectionChanged += (_, _) => ShowSelectedNode();
@ -228,7 +223,7 @@ namespace ILSpy.Docking
{ {
if (e.PropertyName == nameof(LanguageService.CurrentLanguage)) if (e.PropertyName == nameof(LanguageService.CurrentLanguage))
{ {
if (GetDecompilerContentTab() is { } tab) if (ActiveDecompilerTab is { } tab)
{ {
tab.Language = languageService.CurrentLanguage; tab.Language = languageService.CurrentLanguage;
// Re-decompile by re-assigning the same node so the tab refreshes for the new language. // Re-decompile by re-assigning the same node so the tab refreshes for the new language.
@ -251,119 +246,55 @@ namespace ILSpy.Docking
assemblyTreeModel.SelectedItem = node; assemblyTreeModel.SelectedItem = node;
} }
// Long-lived decompiler viewmodel — kept alive across metadata interludes so going
// back to text doesn't lose the previous decompile until a fresh one supersedes it.
// Created lazily on first need.
DecompilerTabPageModel? decompilerContent;
void ShowSelectedNode() void ShowSelectedNode()
{ {
var nodes = assemblyTreeModel.SelectedItems.OfType<ILSpyTreeNode>().ToArray(); var nodes = assemblyTreeModel.SelectedItems.OfType<ILSpyTreeNode>().ToArray();
if (nodes.Length == 0) if (nodes.Length == 0)
return; return;
if (factory.MainTab is not { } main)
// Tree-node selections always reuse the user's active document slot — custom-tab
// nodes (metadata grids) and the regular decompiler path both swap state into
// whatever's currently active. If the active dockable is the wrong concrete type
// it gets replaced in place; no sibling tabs are created.
// Carve-outs ("Open in new tab", "Freeze tab") aren't implemented yet and would
// branch off this path before the active-slot lookup.
var customTab = nodes.Length == 1 ? nodes[0].CreateTab() : null;
if (customTab != null)
{
PutInActiveSlot(customTab);
return; return;
}
if (AcquireActiveDecompilerTab() is { } decTab) // Tree-node selections always reuse the single document slot. The Document
{ // instance never changes; its inner Content swaps between viewmodels. The
decTab.Language = languageService.CurrentLanguage; // wrapper view (ContentTabPageView) holds pre-realised inner views and toggles
decTab.CurrentNodes = nodes; // which is visible based on Content's runtime type — keeps the visual swap
} // deterministic without going through Dock's add+close lifecycle.
} // Carve-outs ("Open in new tab", "Freeze tab") aren't implemented yet and would
// branch off this path before the Content swap.
// Drops <paramref name="prototype"/> into the active document slot. Same concrete var customContent = nodes.Length == 1 ? nodes[0].CreateTab() : null;
// type as what's already there → copy state in place. Different type → swap in the
// VisibleDockables list (single mutation fires INotifyCollectionChanged.Replace,
// which Dock translates into a clean visual swap; close-then-add in the same tick
// leaves the old view rendered until the next layout pass).
void PutInActiveSlot(TabPageModel prototype)
{
if (factory.Documents is not { } docs)
return;
if (docs.ActiveDockable is TabPageModel active && active.GetType() == prototype.GetType()) if (customContent != null)
{ {
CopyTabState(prototype, active); // Copy state into the existing inner viewmodel when types match — keeps
// scroll position / sort order across the navigation. Only swap to a new
// instance when the content type changes.
if (main.Content?.GetType() == customContent.GetType())
CopyContentState(customContent, main.Content);
else
main.Content = customContent;
return; return;
} }
var oldActive = docs.ActiveDockable; var decTab = decompilerContent ??= CreateDecompilerContent();
if (oldActive != null && docs.VisibleDockables is { } list) main.Content = decTab;
{ decTab.Language = languageService.CurrentLanguage;
int idx = list.IndexOf(oldActive); decTab.CurrentNodes = nodes;
if (idx >= 0)
{
prototype.Owner = docs;
prototype.Factory = factory;
list[idx] = prototype;
docs.ActiveDockable = prototype;
factory.SetFocusedDockable(docs, prototype);
return;
}
}
// Fallback (no current active): just add.
factory.AddDockable(docs, prototype);
factory.SetActiveDockable(prototype);
factory.SetFocusedDockable(docs, prototype);
} }
// Once consumed and later replaced, the factory's InitialDecompilerTab can't be DecompilerTabPageModel CreateDecompilerContent()
// re-added to the dock — Dock treats it as a known closed dockable. Track whether
// it's still available so the first selection uses it and later selections build a
// fresh instance instead.
bool initialDecompilerTabConsumed;
DecompilerTabPageModel? AcquireActiveDecompilerTab()
{ {
if (factory.Documents is not { } docs) var tab = new DecompilerTabPageModel { Title = "(no selection)" };
return null; tab.Language = languageService.CurrentLanguage;
if (docs.ActiveDockable is DecompilerTabPageModel active) tab.NavigateRequested += OnNavigateRequested;
return active; return tab;
DecompilerTabPageModel fresh;
if (!initialDecompilerTabConsumed && factory.InitialDecompilerTab is { } initial)
{
fresh = initial;
initialDecompilerTabConsumed = true;
}
else
{
fresh = new DecompilerTabPageModel { Title = "(no selection)" };
fresh.NavigateRequested += OnNavigateRequested;
}
// Replace the active dockable in place (see PutInActiveSlot for why an in-place
// list mutation beats close-then-add for the visual swap).
var oldActive = docs.ActiveDockable;
if (oldActive != null && docs.VisibleDockables is { } list)
{
int idx = list.IndexOf(oldActive);
if (idx >= 0)
{
fresh.Owner = docs;
fresh.Factory = factory;
list[idx] = fresh;
docs.ActiveDockable = fresh;
factory.SetFocusedDockable(docs, fresh);
return fresh;
}
}
factory.AddDockable(docs, fresh);
factory.SetActiveDockable(fresh);
factory.SetFocusedDockable(docs, fresh);
return fresh;
} }
static void CopyTabState(TabPageModel source, TabPageModel target) static void CopyContentState(object source, object target)
{ {
if (source is MetadataTablePageModel newMeta && target is MetadataTablePageModel oldMeta) if (source is MetadataTablePageModel newMeta && target is MetadataTablePageModel oldMeta)
{ {
@ -374,25 +305,17 @@ namespace ILSpy.Docking
} }
} }
public DecompilerTabPageModel? ActiveDecompilerTab => GetDecompilerContentTab(); public DecompilerTabPageModel? ActiveDecompilerTab
=> factory.MainTab?.Content as DecompilerTabPageModel is { IsStaticContent: false } d ? d : null;
// Decompiler-content tabs only — static-content tabs (About / License) are excluded so
// tree-node selections never overwrite them.
DecompilerTabPageModel? GetDecompilerContentTab()
{
if (factory.Documents?.ActiveDockable is DecompilerTabPageModel { IsStaticContent: false } active)
return active;
if (factory.Documents?.VisibleDockables != null)
{
foreach (var d in factory.Documents.VisibleDockables)
if (d is DecompilerTabPageModel { IsStaticContent: false } m)
return m;
}
return null;
}
public TabPageModel OpenNewTab(TabPageModel tab) /// <summary>
/// Opens a fresh sibling tab whose <see cref="ContentTabPage.Content"/> is the
/// supplied viewmodel. Used by explicit "Open in new tab" gestures and by static
/// pages (About, License) that must not be overwritten by tree-node selections.
/// </summary>
public ContentTabPage OpenNewTab(object content)
{ {
var tab = new ContentTabPage { Content = content };
if (factory.Documents != null) if (factory.Documents != null)
{ {
factory.AddDockable(factory.Documents, tab); factory.AddDockable(factory.Documents, tab);
@ -415,7 +338,7 @@ namespace ILSpy.Docking
public void ResetLayout() public void ResetLayout()
{ {
// Rebuild from the factory's default layout. The active tree node, if any, will be // Rebuild from the factory's default layout. The active tree node, if any, will be
// re-projected onto the freshly-created decompiler tab through the existing // re-projected onto the fresh MainTab through the existing
// SelectedItem -> ShowSelectedNode plumbing. // SelectedItem -> ShowSelectedNode plumbing.
var newLayout = factory.CreateLayout(); var newLayout = factory.CreateLayout();
factory.InitLayout(newLayout); factory.InitLayout(newLayout);
@ -425,14 +348,9 @@ namespace ILSpy.Docking
currentRoot.ActiveDockable = root.ActiveDockable; currentRoot.ActiveDockable = root.ActiveDockable;
currentRoot.FocusedDockable = root.FocusedDockable; currentRoot.FocusedDockable = root.FocusedDockable;
} }
// Fresh layout means the factory minted a new InitialDecompilerTab; AcquireActive // Fresh MainTab means the cached decompiler content (with handlers wired to the
// can use it again. // old tab) is gone; rebuild on the next selection.
initialDecompilerTabConsumed = false; decompilerContent = null;
if (factory.InitialDecompilerTab is { } initialTab)
{
initialTab.Language = languageService.CurrentLanguage;
initialTab.NavigateRequested += OnNavigateRequested;
}
ShowSelectedNode(); ShowSelectedNode();
} }
} }

12
ILSpy/Docking/ILSpyDockFactory.cs

@ -36,7 +36,12 @@ namespace ILSpy.Docking
public IDocumentDock? Documents { get; private set; } public IDocumentDock? Documents { get; private set; }
public DecompilerTabPageModel? InitialDecompilerTab { get; private set; } /// <summary>
/// The single persistent Document the host puts in <see cref="Documents"/>. Its
/// <see cref="ContentTabPage.Content"/> swaps between viewmodels (decompiler text /
/// metadata grid) on tree-node selection — DockWorkspace owns the population.
/// </summary>
public ContentTabPage? MainTab { get; private set; }
public ILSpyDockFactory(ToolPaneRegistry registry) public ILSpyDockFactory(ToolPaneRegistry registry)
{ {
@ -53,8 +58,9 @@ namespace ILSpy.Docking
}; };
Documents = documents; Documents = documents;
// Initial decompiler tab is added lazily on first selection (DockWorkspace.ShowSelectedNode). MainTab = new ContentTabPage { Title = "(no selection)" };
InitialDecompilerTab = new DecompilerTabPageModel { Title = "(no selection)" }; documents.VisibleDockables = CreateList<IDockable>(MainTab);
documents.ActiveDockable = MainTab;
ToolDock? leftToolDock = BuildToolDock("LeftTools", ToolPaneAlignment.Left, 0.25); ToolDock? leftToolDock = BuildToolDock("LeftTools", ToolPaneAlignment.Left, 0.25);
ToolDock? topToolDock = BuildToolDock("TopTools", ToolPaneAlignment.Top, 0.2); ToolDock? topToolDock = BuildToolDock("TopTools", ToolPaneAlignment.Top, 0.2);

64
ILSpy/ViewModels/ContentTabPage.cs

@ -0,0 +1,64 @@
// 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.ComponentModel;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ILSpy.ViewModels
{
/// <summary>
/// The single Document the docking host puts in its document area. <see cref="Content"/>
/// holds the active inner viewmodel (decompiler text or metadata grid). The wrapper
/// view (<c>ContentTabPageView</c>) keeps both possible inner views pre-realised and
/// toggles which is visible — Dock.Avalonia's add+close-in-the-same-tick semantics
/// otherwise leave the previous view rendered when the tab type changes.
/// </summary>
public sealed partial class ContentTabPage : TabPageModel
{
[ObservableProperty]
private object? content;
// Bubble the inner content's Title up to the Document's Title so the tab strip
// reflects whatever the active page chose (e.g. the decompiler tab's spinner glyph
// or "DOS Header" for a metadata grid).
partial void OnContentChanged(object? oldValue, object? newValue)
{
if (oldValue is INotifyPropertyChanged oldNotify)
oldNotify.PropertyChanged -= OnContentPropertyChanged;
if (newValue is INotifyPropertyChanged newNotify)
newNotify.PropertyChanged += OnContentPropertyChanged;
SyncTitleFromContent();
}
void OnContentPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(Title))
SyncTitleFromContent();
}
void SyncTitleFromContent()
{
if (Content is null)
return;
var titleProp = Content.GetType().GetProperty(nameof(Title), typeof(string));
if (titleProp?.GetValue(Content) is string s)
Title = s;
}
}
}

22
ILSpy/Views/ContentTabPageView.axaml

@ -0,0 +1,22 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ILSpy.ViewModels"
xmlns:textView="using:ILSpy.TextView"
xmlns:metaViews="using:ILSpy.Views"
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="400"
x:Class="ILSpy.Views.ContentTabPageView"
x:DataType="vm:ContentTabPage">
<!-- Both inner views are pre-realised; only the one matching Content's runtime type
is visible. Code-behind toggles IsVisible and DataContext on Content change.
Avoids Dock's add+close-in-the-same-tick visual-staleness when the tab type
changes (the previous view would otherwise stay rendered until the next layout
pass). -->
<Panel>
<textView:DecompilerTextView Name="DecompilerView" />
<metaViews:MetadataTablePage Name="MetadataView" />
</Panel>
</UserControl>

94
ILSpy/Views/ContentTabPageView.axaml.cs

@ -0,0 +1,94 @@
// 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.ComponentModel;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using ILSpy.TextView;
using ILSpy.ViewModels;
namespace ILSpy.Views
{
/// <summary>
/// View for <see cref="ContentTabPage"/>. Toggles which pre-realised inner view shows
/// (decompiler text or metadata grid) based on <see cref="ContentTabPage.Content"/>'s
/// runtime type — both inner views live in the visual tree from construction time so
/// the dock's visual swap is just a visibility flip.
/// </summary>
public partial class ContentTabPageView : UserControl
{
ContentTabPage? boundPage;
DecompilerTextView decompilerView = null!;
MetadataTablePage metadataView = null!;
public ContentTabPageView()
{
InitializeComponent();
decompilerView = this.FindControl<DecompilerTextView>("DecompilerView")!;
metadataView = this.FindControl<MetadataTablePage>("MetadataView")!;
DataContextChanged += (_, _) => RebindPage();
}
void InitializeComponent() => AvaloniaXamlLoader.Load(this);
void RebindPage()
{
if (boundPage != null)
boundPage.PropertyChanged -= OnPagePropertyChanged;
boundPage = DataContext as ContentTabPage;
if (boundPage != null)
boundPage.PropertyChanged += OnPagePropertyChanged;
ApplyContent();
}
void OnPagePropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(ContentTabPage.Content))
ApplyContent();
}
void ApplyContent()
{
var content = boundPage?.Content;
if (content is DecompilerTabPageModel decompiler)
{
decompilerView.DataContext = decompiler;
decompilerView.IsVisible = true;
}
else
{
decompilerView.IsVisible = false;
decompilerView.DataContext = null;
}
if (content is MetadataTablePageModel metadata)
{
metadataView.DataContext = metadata;
metadataView.IsVisible = true;
}
else
{
metadataView.IsVisible = false;
metadataView.DataContext = null;
}
}
}
}
Loading…
Cancel
Save