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.
1152 lines
45 KiB
1152 lines
45 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.Collections.ObjectModel; |
|
using System.ComponentModel; |
|
using System.Composition; |
|
using System.Linq; |
|
using System.Reflection; |
|
using System.Reflection.Metadata; |
|
using System.Reflection.Metadata.Ecma335; |
|
using System.Threading; |
|
using System.Threading.Tasks; |
|
|
|
using CommunityToolkit.Mvvm.ComponentModel; |
|
using CommunityToolkit.Mvvm.Input; |
|
|
|
using Dock.Model.Controls; |
|
using Dock.Model.Core; |
|
using Dock.Model.Core.Events; |
|
|
|
using ICSharpCode.Decompiler.Metadata; |
|
using ICSharpCode.Decompiler.TypeSystem; |
|
using ICSharpCode.ILSpyX; |
|
using ICSharpCode.ILSpyX.TreeView; |
|
|
|
using ILSpy.AssemblyTree; |
|
using ILSpy.Commands; |
|
using ILSpy.Languages; |
|
using ILSpy.Metadata; |
|
using ILSpy.Navigation; |
|
using ILSpy.TextView; |
|
using ILSpy.TreeNodes; |
|
using ILSpy.ViewModels; |
|
|
|
namespace ILSpy.Docking |
|
{ |
|
[Export] |
|
[Shared] |
|
public partial class DockWorkspace : ObservableObject |
|
{ |
|
/// <summary> |
|
/// The active <see cref="ContentTabPage"/> in the documents dock, or null when no |
|
/// content tab is active (transient layout states, tool-pane focus, etc.). Updated |
|
/// whenever <c>factory.Documents.ActiveDockable</c> changes via the existing |
|
/// <see cref="OnDocumentsPropertyChanged"/> subscription. The main-toolbar pickers |
|
/// bind through this — see <c>ActiveContentTabPage.SupportsLanguageSwitching</c>. |
|
/// </summary> |
|
[ObservableProperty] |
|
private ContentTabPage? activeContentTabPage; |
|
|
|
readonly ILSpyDockFactory factory; |
|
readonly AssemblyTreeModel assemblyTreeModel; |
|
readonly LanguageService languageService; |
|
readonly NavigationHistory<NavigationEntry> history = new(); |
|
// Set true while a Back/Forward navigation is rewriting state (SelectedItem, |
|
// active dockable) so the change notifications don't push fresh entries onto the |
|
// stack and undo the navigation we just did. |
|
bool suppressHistoryRecording; |
|
|
|
public IFactory Factory => factory; |
|
|
|
/// <summary>The documents dock — direct access to the carve-out tabs collection. |
|
/// Used by commands that need to scan existing tabs for ensure-single-instance |
|
/// behaviour (e.g. ShowOptionsCommand).</summary> |
|
public IDocumentDock? Documents => factory.Documents; |
|
|
|
public IRelayCommand NavigateBackCommand { get; } |
|
public IRelayCommand NavigateForwardCommand { get; } |
|
public IRelayCommand<NavigationEntry> NavigateToHistoryCommand { get; } |
|
|
|
/// <summary> |
|
/// Brings the search tool pane to focus. Wired to Ctrl+Shift+F and Ctrl+E on the |
|
/// main window. Idempotent — re-firing on an already-active pane is a no-op. |
|
/// </summary> |
|
public IRelayCommand ShowSearchCommand { get; } |
|
|
|
// Read-only history snapshots for the Back/Forward split-button dropdowns; oldest-first. |
|
// The UI reverses these for newest-first display. |
|
public IReadOnlyList<NavigationEntry> BackHistory => history.BackEntries; |
|
public IReadOnlyList<NavigationEntry> ForwardHistory => history.ForwardEntries; |
|
|
|
/// <summary> |
|
/// Prunes navigation-history entries whose tree-node ancestor matches |
|
/// <paramref name="predicateOnNode"/>. Called from the assembly-tree model |
|
/// when the active assembly list emits a CollectionChanged with removals: |
|
/// every history entry pointing at a node that lived in one of the removed |
|
/// assemblies is dropped so Back/Forward can't surface a detached node. |
|
/// Static-page entries are left alone (they don't reference removed assemblies). |
|
/// </summary> |
|
public void PruneHistory(Predicate<SharpTreeNode> predicateOnNode) |
|
{ |
|
ArgumentNullException.ThrowIfNull(predicateOnNode); |
|
history.RemoveAll(entry => |
|
entry is TreeNodeEntry t && predicateOnNode(t.Node)); |
|
} |
|
|
|
public IRootDock Layout { get; } |
|
|
|
/// <summary> |
|
/// Persists the current dock layout to the JSON sidecar next to ILSpy.xml. |
|
/// Called from <c>MainWindow.OnClosing</c> so the user's pane positions, |
|
/// splitter ratios, and pinned panels survive a restart. Best-effort: any |
|
/// serialization failure is logged and swallowed — losing the saved layout |
|
/// is strictly less bad than blocking shutdown. |
|
/// </summary> |
|
public void SaveLayout() => ILSpyDockFactory.SaveLayout(GetLayoutFilePath(), Layout); |
|
|
|
/// <summary> |
|
/// Resolves <c>ILSpy.Layout.json</c> as a sidecar in the same directory the |
|
/// XML <c>ILSpy.xml</c> settings file lives in — local-to-binary on portable |
|
/// installs, %APPDATA%/ICSharpCode/ otherwise. Keeping it next to the XML |
|
/// makes "delete settings to reset" still work as a single-folder action. |
|
/// WPF stays XML; this is Avalonia-side only. |
|
/// </summary> |
|
static string GetLayoutFilePath() |
|
{ |
|
var xmlPath = ICSharpCode.ILSpyX.Settings.ILSpySettings.SettingsFilePathProvider?.Invoke(); |
|
if (string.IsNullOrEmpty(xmlPath)) |
|
return "ILSpy.Layout.json"; |
|
var dir = System.IO.Path.GetDirectoryName(xmlPath); |
|
return string.IsNullOrEmpty(dir) |
|
? "ILSpy.Layout.json" |
|
: System.IO.Path.Combine(dir, "ILSpy.Layout.json"); |
|
} |
|
|
|
public IReadOnlyList<ToolPaneMenuItem> ToolPaneMenuItems { get; } |
|
|
|
/// <summary> |
|
/// Live list of one <see cref="TabPageMenuItem"/> per open document tab. Kept in |
|
/// sync with <see cref="Documents"/>.<c>VisibleDockables</c> by the |
|
/// <see cref="OnDocumentMembershipChanged"/> handler — adds/removes mutate this in |
|
/// place so subscribers (the Window menu) can hook |
|
/// <see cref="ObservableCollection{T}.CollectionChanged"/> and patch their own items. |
|
/// </summary> |
|
public ObservableCollection<TabPageMenuItem> TabPageMenuItems { get; } = new(); |
|
|
|
[ImportingConstructor] |
|
public DockWorkspace( |
|
AssemblyTreeModel assemblyTreeModel, |
|
ToolPaneRegistry toolPaneRegistry, |
|
LanguageService languageService) |
|
{ |
|
ILSpy.AppEnv.AppLog.Mark("DockWorkspace ctor entered"); |
|
this.assemblyTreeModel = assemblyTreeModel; |
|
this.languageService = languageService; |
|
NavigateBackCommand = new RelayCommand(NavigateBack, () => history.CanNavigateBack); |
|
NavigateForwardCommand = new RelayCommand(NavigateForward, () => history.CanNavigateForward); |
|
NavigateToHistoryCommand = new RelayCommand<NavigationEntry>(NavigateToHistory, |
|
entry => entry != null && (history.BackEntries.Contains(entry) || history.ForwardEntries.Contains(entry))); |
|
ShowSearchCommand = new RelayCommand(ExecuteShowSearch); |
|
using (ILSpy.AppEnv.AppLog.Phase("ILSpyDockFactory ctor + CreateLayout + InitLayout")) |
|
{ |
|
using (ILSpy.AppEnv.AppLog.Phase("ILSpyDockFactory ctor")) |
|
factory = new ILSpyDockFactory(toolPaneRegistry); |
|
// Prefer the user's saved layout (ILSpy.Layout.json sidecar next to |
|
// ILSpy.xml); fall back to the default layout if there is no saved one |
|
// or it failed to deserialize. The fallback path is the same shape the |
|
// app uses on first launch. |
|
IRootDock? loaded; |
|
using (ILSpy.AppEnv.AppLog.Phase("ILSpyDockFactory.LoadLayout")) |
|
loaded = factory.LoadLayout(GetLayoutFilePath()); |
|
if (loaded != null) |
|
{ |
|
Layout = loaded; |
|
ILSpy.AppEnv.AppLog.Mark("ILSpyDockFactory: using LOADED layout"); |
|
} |
|
else |
|
{ |
|
using (ILSpy.AppEnv.AppLog.Phase("ILSpyDockFactory.CreateLayout (default)")) |
|
Layout = factory.CreateLayout(); |
|
} |
|
// Build the layout, then InitLayout BEFORE the chrome ever sees it, rather than |
|
// letting the DockControl's InitializeFactory / InitializeLayout flags run it |
|
// post-template-apply. Wiring owners/factories/locators up front means the |
|
// layout is fully resolvable before the first DeferredContentControl attaches. |
|
using (ILSpy.AppEnv.AppLog.Phase("ILSpyDockFactory.InitLayout")) |
|
factory.InitLayout(Layout); |
|
} |
|
|
|
assemblyTreeModel.PropertyChanged += OnAssemblyTreePropertyChanged; |
|
assemblyTreeModel.SelectedItems.CollectionChanged += (_, _) => { |
|
if (!syncingTreeFromActiveTab) |
|
ShowSelectedNode(); |
|
}; |
|
languageService.PropertyChanged += OnLanguagePropertyChanged; |
|
|
|
ToolPaneMenuItems = toolPaneRegistry.Panes |
|
.Select(p => new ToolPaneMenuItem(p.Pane, factory)) |
|
.ToList(); |
|
|
|
factory.DockableAdded += OnDocumentMembershipChanged; |
|
factory.DockableRemoved += OnDocumentMembershipChanged; |
|
factory.DockableClosed += OnDocumentMembershipChanged; |
|
factory.DockableClosing += OnDockableClosing; |
|
// Seed TabPageMenuItems with whatever the loaded layout already contains |
|
// (typically MainTab) — InitLayout ran before the subscriptions above were |
|
// in place, so no DockableAdded event fired for those initial members. |
|
SyncTabPageMenuItems(); |
|
// Seed ActiveContentTabPage from the freshly-initialised layout so the toolbar |
|
// pickers' IsEnabled bindings have the right value at first paint. The |
|
// subscription below will keep it in sync from here on. |
|
ActiveContentTabPage = factory.Documents?.ActiveDockable as ContentTabPage; |
|
// Dock's IFactory.ActiveDockableChanged only fires from InitActiveDockable (layout |
|
// structural init), not when the user clicks a different tab — that path sets |
|
// dock.ActiveDockable = X directly on the dock model. Subscribe to the model's own |
|
// PropertyChanged so tab clicks reach our sync-tree-to-tab handler. |
|
if (factory.Documents is INotifyPropertyChanged documentsNotify) |
|
documentsNotify.PropertyChanged += OnDocumentsPropertyChanged; |
|
// Close orphaned carve-out tabs when their assembly is removed. The persistent |
|
// MainTab slot is left alone — its content will swap to whatever the user selects |
|
// next via the assembly tree. Mirrors WPF's DockWorkspace.CurrentAssemblyList_Changed. |
|
ILSpy.Util.MessageBus<ILSpy.Util.CurrentAssemblyListChangedEventArgs>.Subscribers |
|
+= OnAssemblyListChanged; |
|
ILSpy.AppEnv.AppLog.Mark("DockWorkspace ctor exited"); |
|
} |
|
|
|
void OnAssemblyListChanged(object? sender, ILSpy.Util.CurrentAssemblyListChangedEventArgs e) |
|
{ |
|
var inner = e.Inner; |
|
|
|
// On Reset (assembly list wholesale-cleared), drop ALL history — every entry is |
|
// stale by definition. Mirrors WPF's assemblyList_CollectionChanged. |
|
if (inner.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Reset) |
|
{ |
|
PruneHistoryAfterAssemblyListChange(removed: null); |
|
return; |
|
} |
|
|
|
if (inner.OldItems is not { Count: > 0 } oldItems) |
|
return; |
|
var removed = new HashSet<ICSharpCode.ILSpyX.LoadedAssembly>( |
|
oldItems.OfType<ICSharpCode.ILSpyX.LoadedAssembly>()); |
|
if (removed.Count == 0) |
|
return; |
|
|
|
PruneHistoryAfterAssemblyListChange(removed); |
|
|
|
if (factory.Documents?.VisibleDockables is not { } visible) |
|
return; |
|
|
|
foreach (var dockable in visible.OfType<ViewModels.ContentTabPage>().ToArray()) |
|
{ |
|
if (dockable.Content is not TextView.DecompilerTabPageModel tab || tab.IsStaticContent) |
|
continue; |
|
var nodes = tab.CurrentNodes; |
|
if (nodes.Count == 0) |
|
continue; |
|
bool anyTouchesRemoved = false; |
|
bool anyAlive = false; |
|
foreach (var n in nodes) |
|
{ |
|
var owner = n.AncestorsAndSelf().OfType<TreeNodes.AssemblyTreeNode>().LastOrDefault(); |
|
if (owner is null) |
|
continue; |
|
if (removed.Contains(owner.LoadedAssembly)) |
|
anyTouchesRemoved = true; |
|
else |
|
anyAlive = true; |
|
} |
|
if (!anyTouchesRemoved) |
|
continue; |
|
// Cancel synchronously, BEFORE AssemblyList.Unload reaches assembly.Dispose(). |
|
// The decompile worker checks its CancellationToken between transforms; the |
|
// spinner exits when its Task.Delay sees the cancellation. After this returns |
|
// no managed reader should hold a live handle into the MetadataFile that's |
|
// about to be unmapped. (LoadedAssembly.Text additionally returns its cached |
|
// value once isDisposed flips, covering any residual cooperative-cancel race.) |
|
tab.CancelDecompilationCommand.Execute(null); |
|
if (anyAlive) |
|
continue; |
|
if (ReferenceEquals(dockable, factory.MainTab)) |
|
{ |
|
// Persistent slot: empty it. The CurrentNodes setter unsubscribes from each |
|
// node's PropertyChanged, so a delayed AssemblyTreeNode.RaisePropertyChanged |
|
// post-Dispose has no listener to drag into a metadata read. |
|
tab.CurrentNodes = System.Array.Empty<ILSpyTreeNode>(); |
|
dockable.SourceNode = null; |
|
} |
|
else |
|
{ |
|
factory.CloseDockable(dockable); |
|
} |
|
} |
|
} |
|
|
|
// Drops history entries whose tree node lives inside a now-removed assembly. With |
|
// <paramref name="removed"/> == null, drops everything (used for the Reset action). |
|
// Without this, Back/Forward would surface entries whose Node reference has been |
|
// detached from the live tree, and formatting their DisplayText routes through |
|
// language-specific formatters that NRE on a stale IEntity.ParentModule. |
|
void PruneHistoryAfterAssemblyListChange(HashSet<ICSharpCode.ILSpyX.LoadedAssembly>? removed) |
|
{ |
|
if (removed == null) |
|
{ |
|
history.RemoveAll(_ => true); |
|
} |
|
else |
|
{ |
|
history.RemoveAll(entry => |
|
entry is Navigation.TreeNodeEntry t |
|
&& t.Node.AncestorsAndSelf() |
|
.OfType<TreeNodes.AssemblyTreeNode>() |
|
.Any(a => removed.Contains(a.LoadedAssembly))); |
|
} |
|
NavigateBackCommand.NotifyCanExecuteChanged(); |
|
NavigateForwardCommand.NotifyCanExecuteChanged(); |
|
} |
|
|
|
void OnDocumentMembershipChanged(object? sender, EventArgs e) |
|
{ |
|
UpdateLastDocumentCanClose(); |
|
SyncTabPageMenuItems(); |
|
} |
|
|
|
// Mirror factory.Documents.VisibleDockables into TabPageMenuItems. Mutates in place so |
|
// existing items keep their PropertyChanged subscriptions and the bound Window-menu |
|
// entries don't re-bind for tabs that didn't move. |
|
void SyncTabPageMenuItems() |
|
{ |
|
if (factory.Documents is not { } docDock) |
|
return; |
|
var present = docDock.VisibleDockables?.OfType<ContentTabPage>().ToList() |
|
?? new List<ContentTabPage>(); |
|
|
|
// Drop items for tabs that have left the dock (close / re-parent). |
|
for (int i = TabPageMenuItems.Count - 1; i >= 0; i--) |
|
{ |
|
if (!present.Contains(TabPageMenuItems[i].Tab)) |
|
{ |
|
TabPageMenuItems[i].Detach(); |
|
TabPageMenuItems.RemoveAt(i); |
|
} |
|
} |
|
|
|
// Append items for newly arrived tabs, preserving the dock's order so the menu |
|
// reads left-to-right the same as the tab strip. |
|
for (int i = 0; i < present.Count; i++) |
|
{ |
|
var tab = present[i]; |
|
int existing = -1; |
|
for (int j = 0; j < TabPageMenuItems.Count; j++) |
|
{ |
|
if (ReferenceEquals(TabPageMenuItems[j].Tab, tab)) |
|
{ |
|
existing = j; |
|
break; |
|
} |
|
} |
|
if (existing == -1) |
|
{ |
|
TabPageMenuItems.Insert(i, new TabPageMenuItem(tab, factory, docDock)); |
|
} |
|
else if (existing != i) |
|
{ |
|
var item = TabPageMenuItems[existing]; |
|
TabPageMenuItems.RemoveAt(existing); |
|
TabPageMenuItems.Insert(i, item); |
|
} |
|
} |
|
} |
|
|
|
// Convert a tool pane's "close" (X button) into "hide" so the Window menu can restore it. |
|
// Documents are still closed for real — they get garbage-collected. |
|
void OnDockableClosing(object? sender, DockableClosingEventArgs e) |
|
{ |
|
if (e.Dockable is ToolPaneModel toolPane) |
|
{ |
|
e.Cancel = true; |
|
factory.HideDockable(toolPane); |
|
} |
|
} |
|
|
|
// When only one document is open across the entire workspace, make it un-closeable so |
|
// the user can't end up with an empty document area. Counts across ALL IDocumentDocks |
|
// in the layout tree — not just factory.Documents — so a split into side-by-side |
|
// document docks (each with one tab) still lets the user close either one. |
|
void UpdateLastDocumentCanClose() |
|
{ |
|
if (Layout is not IDockable root) |
|
return; |
|
var allDocs = FlattenDocumentDocks(root).ToList(); |
|
int totalTabs = allDocs.Sum(d => d.VisibleDockables?.Count ?? 0); |
|
bool canClose = totalTabs > 1; |
|
foreach (var dock in allDocs) |
|
{ |
|
if (dock.VisibleDockables is { } kids) |
|
{ |
|
foreach (var d in kids) |
|
d.CanClose = canClose; |
|
} |
|
} |
|
} |
|
|
|
static IEnumerable<IDocumentDock> FlattenDocumentDocks(IDockable root) |
|
{ |
|
if (root is IDocumentDock doc) |
|
yield return doc; |
|
if (root is IDock dock && dock.VisibleDockables is { } kids) |
|
{ |
|
foreach (var k in kids) |
|
foreach (var d in FlattenDocumentDocks(k)) |
|
yield return d; |
|
} |
|
} |
|
|
|
// Set true while syncing the tree's selection FROM the active tab so the |
|
// SelectionChanged handler doesn't bounce back into ShowSelectedNode and overwrite |
|
// MainTab.Content with the carved-out tab's node. |
|
bool syncingTreeFromActiveTab; |
|
|
|
void OnAssemblyTreePropertyChanged(object? sender, PropertyChangedEventArgs e) |
|
{ |
|
if (e.PropertyName == nameof(AssemblyTreeModel.SelectedItem)) |
|
{ |
|
if (!syncingTreeFromActiveTab) |
|
ShowSelectedNode(); |
|
RecordTreeNodeSelection(assemblyTreeModel.SelectedItem); |
|
} |
|
} |
|
|
|
void OnDocumentsPropertyChanged(object? sender, PropertyChangedEventArgs e) |
|
{ |
|
if (e.PropertyName != nameof(IDocumentDock.ActiveDockable)) |
|
return; |
|
// Mirror the dock's active document into ActiveContentTabPage. Tool-pane |
|
// dockables and non-content dockables fall through to null, which the toolbar |
|
// reads as "no language-aware tab is active" — pickers stay enabled by default. |
|
ActiveContentTabPage = factory.Documents?.ActiveDockable as ContentTabPage; |
|
// Tell each TabPageMenuItem to re-raise IsActive so the Window menu's checkmark |
|
// follows the dock's selection. Items resolve "am I active?" against the dock's |
|
// current ActiveDockable on read; this notify just kicks the binding. |
|
foreach (var item in TabPageMenuItems) |
|
item.NotifyActiveChanged(); |
|
// Carve-out tab → active: pull the tree's selection over to whatever entity the |
|
// new active tab is showing. Fires on user-driven tab clicks (via the dock model's |
|
// ActiveDockable setter) and on programmatic SetActiveDockable calls. |
|
if (factory.Documents?.ActiveDockable is not ContentTabPage tab || tab.SourceNode is not { } node) |
|
return; |
|
if (ReferenceEquals(assemblyTreeModel.SelectedItem, node)) |
|
return; |
|
syncingTreeFromActiveTab = true; |
|
try |
|
{ |
|
assemblyTreeModel.SelectedItem = node; |
|
} |
|
finally |
|
{ |
|
syncingTreeFromActiveTab = false; |
|
} |
|
} |
|
|
|
void RecordTreeNodeSelection(SharpTreeNode? node) |
|
{ |
|
if (suppressHistoryRecording || node == null) |
|
return; |
|
// ShowSelectedNode has already settled the active dockable for this selection — |
|
// record whichever tab type ended up there (decompiler or metadata grid). |
|
if (factory.Documents?.ActiveDockable is not TabPageModel tab) |
|
return; |
|
RecordHistoryEntry(new TreeNodeEntry(tab, node)); |
|
} |
|
|
|
/// <summary> |
|
/// Records a static-page entry (e.g. About) into the navigation history. The caller |
|
/// has already opened <paramref name="tab"/> via <see cref="OpenNewTab"/>. The tab |
|
/// should have <see cref="DecompilerTabPageModel.IsStaticContent"/> set so that |
|
/// subsequent tree-node selections route to a different tab and leave it intact. |
|
/// </summary> |
|
public void RecordStaticPage(TabPageModel tab, Uri uri) |
|
{ |
|
ArgumentNullException.ThrowIfNull(tab); |
|
ArgumentNullException.ThrowIfNull(uri); |
|
if (suppressHistoryRecording) |
|
return; |
|
RecordHistoryEntry(new StaticPageEntry(tab, uri)); |
|
} |
|
|
|
void RecordHistoryEntry(NavigationEntry entry) |
|
{ |
|
// Snapshot the editor's caret + scroll state into the OUTGOING entry — at this |
|
// point the editor still shows the previous selection's content, so the values |
|
// describe where the user was looking before this new entry takes over. The |
|
// captured state is then restored if/when the user navigates Back/Forward to it. |
|
CaptureCurrentViewState(); |
|
history.Record(entry); |
|
NavigateBackCommand.NotifyCanExecuteChanged(); |
|
NavigateForwardCommand.NotifyCanExecuteChanged(); |
|
} |
|
|
|
void CaptureCurrentViewState() |
|
{ |
|
if (history.Current is not TreeNodeEntry current) |
|
return; |
|
var decompTab = UnwrapDecompilerTab(current.Tab); |
|
if (decompTab == null) |
|
return; |
|
// Foldings have no model-side property-change event we can mirror onto, so the |
|
// text view's snapshot delegate has to be invoked synchronously here. Caret and |
|
// scroll values are kept fresh by per-event push from the view; foldings aren't. |
|
decompTab.CaptureFoldingsState?.Invoke(); |
|
current.CaretOffset = decompTab.LastKnownCaretOffset; |
|
current.VerticalOffset = decompTab.LastKnownVerticalOffset; |
|
current.HorizontalOffset = decompTab.LastKnownHorizontalOffset; |
|
current.Foldings = decompTab.LastKnownFoldings; |
|
} |
|
|
|
// The recorded TabPageModel in TreeNodeEntry can be either a DecompilerTabPageModel |
|
// directly OR a ContentTabPage whose Content is one — `factory.Documents.ActiveDockable` |
|
// returns the latter in the normal layout. Try both shapes. |
|
static TextView.DecompilerTabPageModel? UnwrapDecompilerTab(ViewModels.TabPageModel? tab) => tab switch { |
|
TextView.DecompilerTabPageModel d => d, |
|
ViewModels.ContentTabPage c => c.Content as TextView.DecompilerTabPageModel, |
|
_ => null, |
|
}; |
|
|
|
void NavigateBack() => NavigateHistory(forward: false); |
|
void NavigateForward() => NavigateHistory(forward: true); |
|
|
|
void NavigateHistory(bool forward) |
|
{ |
|
if (forward ? !history.CanNavigateForward : !history.CanNavigateBack) |
|
return; |
|
var target = forward ? history.GoForward() : history.GoBack(); |
|
ApplyNavigationTarget(target); |
|
} |
|
|
|
void NavigateToHistory(NavigationEntry? entry) |
|
{ |
|
if (entry == null) |
|
return; |
|
bool forward = history.ForwardEntries.Contains(entry); |
|
if (!forward && !history.BackEntries.Contains(entry)) |
|
return; |
|
var target = history.GoTo(entry, forward); |
|
if (target != null) |
|
ApplyNavigationTarget(target); |
|
} |
|
|
|
void ApplyNavigationTarget(NavigationEntry target) |
|
{ |
|
suppressHistoryRecording = true; |
|
try |
|
{ |
|
if (factory.Documents?.VisibleDockables is { } docs && docs.Contains(target.Tab)) |
|
factory.SetActiveDockable(target.Tab); |
|
if (target is TreeNodeEntry treeNode) |
|
{ |
|
// Stash the captured view state on the tab BEFORE setting SelectedItem. |
|
// SelectedItem triggers the decompile; the editor's ApplyDocument reads |
|
// the Pending* fields right after Text lands and restores caret + scroll. |
|
if (UnwrapDecompilerTab(treeNode.Tab) is { } decompTab) |
|
{ |
|
decompTab.PendingCaretOffset = treeNode.CaretOffset; |
|
decompTab.PendingVerticalOffset = treeNode.VerticalOffset; |
|
decompTab.PendingHorizontalOffset = treeNode.HorizontalOffset; |
|
decompTab.PendingFoldings = treeNode.Foldings; |
|
} |
|
assemblyTreeModel.SelectedItem = treeNode.Node; |
|
} |
|
// StaticPageEntry: just reactivating the tab is enough — its content was |
|
// preserved because IsStaticContent kept tree-node selections from targeting it. |
|
} |
|
finally |
|
{ |
|
suppressHistoryRecording = false; |
|
} |
|
NavigateBackCommand.NotifyCanExecuteChanged(); |
|
NavigateForwardCommand.NotifyCanExecuteChanged(); |
|
} |
|
|
|
void OnLanguagePropertyChanged(object? sender, PropertyChangedEventArgs e) |
|
{ |
|
if (e.PropertyName is nameof(LanguageService.CurrentLanguage) or nameof(LanguageService.CurrentVersion)) |
|
{ |
|
if (ActiveDecompilerTab is { } tab) |
|
{ |
|
tab.Language = languageService.CurrentLanguage; |
|
// Re-decompile by re-assigning the same node so the tab refreshes for the new |
|
// language or language version (the version is read inside |
|
// TryGetLiveDecompilerSettings, which builds the per-run DecompilerSettings). |
|
var node = tab.CurrentNode; |
|
tab.CurrentNode = null; |
|
tab.CurrentNode = node; |
|
} |
|
} |
|
} |
|
|
|
void OnNavigateRequested(ReferenceSegment segment) |
|
{ |
|
// Hyperlink click in the decompiler view: resolve the segment's reference to a tree |
|
// node and select it. Falls through silently when we don't know how to model the |
|
// reference (only types/members/EntityReferences are supported today). |
|
if (segment.Reference == null) |
|
return; |
|
// EntityReferences with a non-"decompile" protocol (e.g. metadata://) get a first |
|
// pass through registered IProtocolHandler exports. The first handler returning a |
|
// non-null node wins; if none match we fall through to the default resolver. |
|
if (segment.Reference is global::ILSpy.EntityReference entity |
|
&& entity.Protocol != "decompile") |
|
{ |
|
var module = entity.ResolveAssembly(assemblyTreeModel.AssemblyList!); |
|
if (module != null) |
|
{ |
|
foreach (var handler in TryGetProtocolHandlers()) |
|
{ |
|
var resolved = handler.Resolve(entity.Protocol, module, entity.Handle, out _); |
|
if (resolved != null) |
|
{ |
|
assemblyTreeModel.SelectedItem = resolved; |
|
return; |
|
} |
|
} |
|
} |
|
} |
|
var node = assemblyTreeModel.FindTreeNode(segment.Reference); |
|
if (node != null) |
|
assemblyTreeModel.SelectedItem = node; |
|
} |
|
|
|
static IEnumerable<Commands.IProtocolHandler> TryGetProtocolHandlers() |
|
{ |
|
try |
|
{ |
|
return AppEnv.AppComposition.Current.GetExports<Commands.IProtocolHandler>(); |
|
} |
|
catch |
|
{ |
|
return System.Array.Empty<Commands.IProtocolHandler>(); |
|
} |
|
} |
|
|
|
// 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; |
|
|
|
ILSpyTreeNode[]? lastShownNodes; |
|
|
|
/// <summary> |
|
/// Force a re-decompile of the active tree selection even if it equals the last |
|
/// rendered selection. Called by <see cref="AssemblyTree.AssemblyTreeModel"/> at |
|
/// the end of <c>RefreshInternalAsync</c>: when F5 reloads the same assembly list, |
|
/// the tree isn't rebuilt and the SelectedItem reference is preserved, so the |
|
/// normal selection-change cascade would no-op and the editor would keep stale |
|
/// decompiled text. Resetting <c>lastShownNodes</c> defeats the |
|
/// dedup short-circuit inside <see cref="ShowSelectedNode"/>. Mirrors WPF's |
|
/// <c>RefreshDecompiledView()</c> call. |
|
/// </summary> |
|
public void ForceRefreshActiveTab() |
|
{ |
|
lastShownNodes = null; |
|
ShowSelectedNode(); |
|
} |
|
|
|
void ShowSelectedNode() |
|
{ |
|
using var _ = ILSpy.AppEnv.AppLog.Phase("DockWorkspace.ShowSelectedNode"); |
|
var nodes = assemblyTreeModel.SelectedItems.OfType<ILSpyTreeNode>().ToArray(); |
|
if (nodes.Length == 0) |
|
{ |
|
lastShownNodes = null; |
|
return; |
|
} |
|
// SelectedItems.CollectionChanged and SelectedItem PropertyChanged both fan into |
|
// here on a single click, so dedupe to avoid creating two TabPageModels for the |
|
// same selection — the second one's columns would replace the first's, but the |
|
// first's filter wiring would be left dangling on stale ColumnFilter instances. |
|
// Dedupe is also what makes "re-clicking the same node after pin" a no-op: the |
|
// pinned tab stays active, no new preview tab spawns. |
|
if (lastShownNodes is { } prev && prev.SequenceEqual(nodes)) |
|
{ |
|
if (factory.MainTab is { } existingMain) |
|
ActivateMainTabIfNeeded(existingMain); |
|
return; |
|
} |
|
lastShownNodes = nodes; |
|
|
|
// Pick or spawn the target tab. If the currently-active document tab is a |
|
// writable preview ContentTabPage we replace its content in place. Anything |
|
// else — pinned tab, Options page, About page, no Documents dock — counts as |
|
// frozen and gets a brand-new preview tab spawned beside it. |
|
ContentTabPage? main; |
|
if (IsWritablePreview(factory.Documents?.ActiveDockable) is ContentTabPage activePreview) |
|
{ |
|
main = activePreview; |
|
factory.MainTab = main; |
|
} |
|
else |
|
{ |
|
main = factory.CreateAndAttachPreviewTab(); |
|
if (main == null) |
|
return; |
|
// Fresh tab needs a fresh DecompilerTabPageModel — the cached one (if any) |
|
// belongs to a previous tab and must not be re-assigned across tabs. |
|
decompilerContent = null; |
|
} |
|
|
|
// Tree-node selections always reuse the single document slot. The Document |
|
// instance never changes; its inner Content swaps between viewmodels. The |
|
// wrapper view (ContentTabPageView) holds pre-realised inner views and toggles |
|
// 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. |
|
TabPageModel? customContent; |
|
using (ILSpy.AppEnv.AppLog.Phase("ShowSelectedNode: node.CreateTab")) |
|
customContent = nodes.Length == 1 ? nodes[0].CreateTab() : null; |
|
|
|
if (customContent != null) |
|
{ |
|
// 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 |
|
AttachCustomContent(main, customContent); |
|
main.SourceNode = nodes[0]; |
|
ActivateMainTabIfNeeded(main); |
|
return; |
|
} |
|
|
|
DecompilerTabPageModel decTab; |
|
using (ILSpy.AppEnv.AppLog.Phase("ShowSelectedNode: get/create decompilerContent")) |
|
decTab = decompilerContent ??= CreateDecompilerContent(); |
|
using (ILSpy.AppEnv.AppLog.Phase("ShowSelectedNode: main.Content = decTab (Dock view-recycling)")) |
|
main.Content = decTab; |
|
decTab.Language = languageService.CurrentLanguage; |
|
using (ILSpy.AppEnv.AppLog.Phase("ShowSelectedNode: decTab.CurrentNodes = nodes (kicks off DecompileAsync)")) |
|
decTab.CurrentNodes = nodes; |
|
main.SourceNode = nodes.Length == 1 ? nodes[0] : null; |
|
using (ILSpy.AppEnv.AppLog.Phase("ShowSelectedNode: ActivateMainTabIfNeeded")) |
|
ActivateMainTabIfNeeded(main); |
|
} |
|
|
|
// Returns the active document tab if it's a writable preview ContentTabPage — meaning |
|
// IsPreview=true AND not hosting static-content (Options, About). Otherwise returns |
|
// null, signalling that ShowSelectedNode must spawn a fresh preview tab instead of |
|
// overwriting the active one. Options pages and About pages are born with |
|
// IsPreview=false (carve-out semantics) AND carry IsStaticContent=true on their |
|
// content viewmodel — either flag alone would suffice, both check guards against |
|
// drift. |
|
static ContentTabPage? IsWritablePreview(IDockable? dockable) |
|
{ |
|
if (dockable is not ContentTabPage tab) |
|
return null; |
|
if (!tab.IsPreview) |
|
return null; |
|
if (tab.Content is DecompilerTabPageModel { IsStaticContent: true }) |
|
return null; |
|
if (tab.Content is Options.OptionsPageModel) |
|
return null; |
|
return tab; |
|
} |
|
|
|
// Brings MainTab to the front of the documents dock so the just-updated content is |
|
// what the user sees. No-op when MainTab is already active, when there's no |
|
// documents dock, or when a Back/Forward navigation is in flight (ApplyNavigationTarget |
|
// has already chosen which tab to activate, including possibly a sibling tab — our |
|
// activation would override that intent). |
|
void ActivateMainTabIfNeeded(ContentTabPage main) |
|
{ |
|
if (suppressHistoryRecording) |
|
return; |
|
if (factory.Documents is not { } docs) |
|
return; |
|
if (ReferenceEquals(docs.ActiveDockable, main)) |
|
return; |
|
factory.SetActiveDockable(main); |
|
} |
|
|
|
void AttachCustomContent(ContentTabPage main, TabPageModel newContent) |
|
{ |
|
// Detach navigation handlers from the outgoing content; subscribe on the |
|
// incoming one so token clicks route through OnMetadataCellClicked and |
|
// row activation routes through OnMetadataRowActivated. |
|
if (main.Content is MetadataTablePageModel oldMeta) |
|
{ |
|
oldMeta.NavigateToCellRequested -= OnMetadataCellClicked; |
|
oldMeta.RowActivated -= OnMetadataRowActivated; |
|
} |
|
if (newContent is MetadataTablePageModel newMeta) |
|
{ |
|
newMeta.NavigateToCellRequested += OnMetadataCellClicked; |
|
newMeta.RowActivated += OnMetadataRowActivated; |
|
} |
|
main.Content = newContent; |
|
} |
|
|
|
void OnMetadataCellClicked(MetadataCellNavigationEventArgs e) |
|
{ |
|
// (row, columnName) → metadata token + module → matching table tree node + row index. |
|
// Resolved via reflection because each entry struct buries its MetadataFile in a |
|
// private readonly field with the same name across all 43 typed table viewmodels. |
|
var prop = e.Row.GetType().GetProperty(e.ColumnName); |
|
if (prop?.GetValue(e.Row) is not int token || token == 0) |
|
return; |
|
var fileField = e.Row.GetType().GetField("metadataFile", BindingFlags.Instance | BindingFlags.NonPublic); |
|
if (fileField?.GetValue(e.Row) is not MetadataFile metadataFile) |
|
return; |
|
NavigateToToken(new MetadataTokenReference(metadataFile, MetadataTokens.EntityHandle(token))); |
|
} |
|
|
|
internal void OnMetadataRowActivated(MetadataRowActivationEventArgs e) |
|
{ |
|
// Row double-click: resolve the row's Token + metadataFile to an IEntity, then |
|
// either select the matching tree node (single-tab reuse) or open it in a fresh |
|
// tab (MMB / context-menu carve-out). |
|
var node = TryResolveRowToTreeNode(e.Row); |
|
if (node is null) |
|
return; |
|
if (e.OpenInNewTab) |
|
OpenNodeInNewTab(node); |
|
else |
|
assemblyTreeModel.SelectedItem = node; |
|
} |
|
|
|
/// <summary> |
|
/// Opens <paramref name="node"/> in a fresh document tab. When the node carries |
|
/// custom content (metadata tables, resource viewers, …) the new tab hosts that |
|
/// custom page-type with the same nav / row-activation wiring the single-tab |
|
/// reuse path applies via <see cref="AttachCustomContent"/>; otherwise a fresh |
|
/// <see cref="DecompilerTabPageModel"/> is spawned and asked to decompile the |
|
/// node. Shared between the assembly-tree MMB handler, the metadata-grid MMB |
|
/// handler, and the "Decompile to new tab" context-menu entry. The created tab |
|
/// is pinned (born with <see cref="ContentTabPage.IsPreview"/> false) — see |
|
/// <see cref="OpenNewTab"/>. |
|
/// </summary> |
|
public void OpenNodeInNewTab(ILSpyTreeNode node) |
|
{ |
|
ArgumentNullException.ThrowIfNull(node); |
|
var customContent = node.CreateTab(); |
|
if (customContent != null) |
|
{ |
|
if (customContent is MetadataTablePageModel newMeta) |
|
{ |
|
newMeta.NavigateToCellRequested += OnMetadataCellClicked; |
|
newMeta.RowActivated += OnMetadataRowActivated; |
|
} |
|
OpenNewTab(customContent, sourceNode: node); |
|
} |
|
else |
|
{ |
|
var content = new DecompilerTabPageModel { Language = languageService.CurrentLanguage }; |
|
OpenNewTab(content, sourceNode: node); |
|
content.CurrentNodes = new[] { node }; |
|
} |
|
} |
|
|
|
/// <summary> |
|
/// Reflection-based resolution of a metadata-grid row to the matching assembly-tree |
|
/// node, sharing the path between row double-click and the "Decompile to new tab" |
|
/// context-menu entry. Returns <see langword="null"/> when the row's token doesn't |
|
/// resolve to an <see cref="IEntity"/> the tree knows how to model (heap rows, |
|
/// AssemblyRef rows pointing at unloaded assemblies, etc.). |
|
/// </summary> |
|
internal ILSpyTreeNode? TryResolveRowToTreeNode(object row) |
|
{ |
|
var fileField = row.GetType().GetField("metadataFile", BindingFlags.Instance | BindingFlags.NonPublic); |
|
if (fileField?.GetValue(row) is not MetadataFile metadataFile) |
|
return null; |
|
var tokenProp = row.GetType().GetProperty("Token"); |
|
if (tokenProp?.GetValue(row) is not int token || token == 0) |
|
return null; |
|
var handle = MetadataTokens.EntityHandle(token); |
|
if (handle.IsNil) |
|
return null; |
|
|
|
var assemblies = assemblyTreeModel.AssemblyList?.GetAssemblies(); |
|
if (assemblies is null) |
|
return null; |
|
LoadedAssembly? owningAssembly = null; |
|
foreach (var a in assemblies) |
|
{ |
|
if (ReferenceEquals(a.GetMetadataFileOrNull(), metadataFile)) |
|
{ |
|
owningAssembly = a; |
|
break; |
|
} |
|
} |
|
if (owningAssembly is null) |
|
return null; |
|
var ts = owningAssembly.GetTypeSystemOrNull(); |
|
if (ts?.MainModule is not MetadataModule metadataModule) |
|
return null; |
|
IEntity? entity; |
|
try |
|
{ entity = metadataModule.ResolveEntity(handle); } |
|
catch { return null; } |
|
if (entity is null) |
|
return null; |
|
return assemblyTreeModel.FindTreeNode(entity); |
|
} |
|
|
|
public void NavigateToToken(MetadataTokenReference reference) |
|
{ |
|
if (reference.Handle.IsNil) |
|
return; |
|
// Find the MetadataTreeNode for the referenced module, drill down to its |
|
// Tables container, and pick the table whose Kind matches the handle's runtime |
|
// kind — HandleKind values for table-backed entities double as TableIndex. |
|
var assemblies = assemblyTreeModel.AssemblyList?.GetAssemblies(); |
|
if (assemblies == null) |
|
return; |
|
AssemblyTreeNode? targetAssembly = null; |
|
if (assemblyTreeModel.Root != null) |
|
{ |
|
foreach (var child in assemblyTreeModel.Root.Children.OfType<AssemblyTreeNode>()) |
|
{ |
|
if (ReferenceEquals(child.LoadedAssembly.GetMetadataFileOrNull(), reference.MetadataFile)) |
|
{ |
|
targetAssembly = child; |
|
break; |
|
} |
|
} |
|
} |
|
if (targetAssembly == null) |
|
return; |
|
targetAssembly.EnsureLazyChildren(); |
|
var metaNode = targetAssembly.Children.OfType<MetadataTreeNode>().FirstOrDefault(); |
|
if (metaNode == null) |
|
return; |
|
metaNode.EnsureLazyChildren(); |
|
var tablesNode = metaNode.Children.OfType<MetadataTablesTreeNode>().FirstOrDefault(); |
|
if (tablesNode == null) |
|
return; |
|
tablesNode.EnsureLazyChildren(); |
|
var tableIndex = (TableIndex)(int)reference.Handle.Kind; |
|
var tableNode = tablesNode.Children.OfType<MetadataTableTreeNode>() |
|
.FirstOrDefault(t => t.Kind == tableIndex); |
|
if (tableNode == null) |
|
return; |
|
|
|
suppressHistoryRecording = true; |
|
try |
|
{ |
|
assemblyTreeModel.SelectedItem = tableNode; |
|
} |
|
finally |
|
{ |
|
suppressHistoryRecording = false; |
|
} |
|
|
|
// ShowSelectedNode just settled MainTab.Content to the table's MetadataTablePageModel. |
|
// Set ScrollToRow to the handle's row number (1-based → 0-based). |
|
if (factory.MainTab?.Content is MetadataTablePageModel pm) |
|
pm.ScrollToRow = MetadataTokens.GetRowNumber((EntityHandle)reference.Handle) - 1; |
|
} |
|
|
|
DecompilerTabPageModel CreateDecompilerContent() |
|
{ |
|
var tab = new DecompilerTabPageModel { Title = "(no selection)" }; |
|
tab.Language = languageService.CurrentLanguage; |
|
tab.NavigateRequested += OnNavigateRequested; |
|
return tab; |
|
} |
|
|
|
static void CopyContentState(object source, object target) |
|
{ |
|
if (source is MetadataTablePageModel newMeta && target is MetadataTablePageModel oldMeta) |
|
{ |
|
oldMeta.Title = newMeta.Title; |
|
oldMeta.Columns = newMeta.Columns; |
|
oldMeta.Items = newMeta.Items; |
|
oldMeta.ScrollToRow = newMeta.ScrollToRow; |
|
} |
|
} |
|
|
|
public DecompilerTabPageModel? ActiveDecompilerTab |
|
=> factory.MainTab?.Content as DecompilerTabPageModel is { IsStaticContent: false } d ? d : null; |
|
|
|
/// <summary> |
|
/// Forwards to <see cref="DecompilerTabPageModel.RunWithCancellation"/> on the active |
|
/// decompiler tab. Convenience wrapper used by long-running commands (Create Diagram, |
|
/// CFG, etc.) that need a wait UI with a custom title. Returns a faulted task if no |
|
/// active decompiler tab is available. |
|
/// </summary> |
|
public Task<T> RunWithCancellation<T>( |
|
Func<CancellationToken, Task<T>> taskCreation, |
|
string? progressTitle = null) |
|
{ |
|
var tab = ActiveDecompilerTab; |
|
if (tab == null) |
|
return Task.FromException<T>(new InvalidOperationException("No active decompiler tab")); |
|
return tab.RunWithCancellation(taskCreation, progressTitle); |
|
} |
|
|
|
/// <summary> |
|
/// Forwards to <see cref="DecompilerTabPageModel.ShowText"/> on the active decompiler |
|
/// tab. Convenience wrapper used by command-driven reports (Create Diagram, Generate |
|
/// PDB, etc.) to surface their output where the user is looking. |
|
/// </summary> |
|
public void ShowText(TextView.AvaloniaEditTextOutput output) |
|
{ |
|
ActiveDecompilerTab?.ShowText(output); |
|
} |
|
|
|
/// <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> |
|
/// <param name="sourceNode"> |
|
/// Optional assembly-tree node this tab represents. Set BEFORE the tab is activated |
|
/// so <see cref="OnActiveDockableChanged"/> can pick it up and pull the tree's |
|
/// selection across — setting it after-the-fact misses the activation event. |
|
/// </param> |
|
/// <summary> |
|
/// Brings the tool pane with the given <paramref name="contentId"/> to focus. Walks |
|
/// every <see cref="IDockable"/> in the layout via <see cref="GetAllDockables"/> and |
|
/// matches on <see cref="IDockable.Id"/>. Silently no-ops when the pane isn't in the |
|
/// layout — e.g. it was closed via its X button and hasn't been re-added. |
|
/// </summary> |
|
public void ShowToolPane(string contentId) |
|
{ |
|
foreach (var dockable in GetAllDockables(Layout)) |
|
{ |
|
if (dockable is ToolPaneModel toolPane && toolPane.Id == contentId) |
|
{ |
|
factory.SetActiveDockable(toolPane); |
|
if (toolPane.Owner is IDock owner) |
|
factory.SetFocusedDockable(owner, toolPane); |
|
return; |
|
} |
|
} |
|
} |
|
|
|
void ExecuteShowSearch() |
|
{ |
|
ShowToolPane(ILSpy.Search.SearchPaneModel.PaneContentId); |
|
// Hand keyboard focus to the search input AFTER activating the pane — the view's |
|
// code-behind subscribes to FocusRequested and posts the focus shift onto the |
|
// dispatcher so the freshly-active pane has a frame to surface in the layout |
|
// first. Resolving the pane through AppComposition (instead of injecting it) |
|
// keeps the dock-workspace decoupled from the search namespace. |
|
try |
|
{ |
|
var search = AppEnv.AppComposition.Current.GetExport<ILSpy.Search.SearchPaneModel>(); |
|
search.RequestFocus(); |
|
} |
|
catch |
|
{ |
|
// Composition isn't available in design-time previews / minimal tests; the |
|
// activation alone is enough to be useful there. |
|
} |
|
} |
|
|
|
static System.Collections.Generic.IEnumerable<IDockable> GetAllDockables(IDockable? root) |
|
{ |
|
if (root == null) |
|
yield break; |
|
yield return root; |
|
if (root is IDock dock && dock.VisibleDockables != null) |
|
{ |
|
foreach (var child in dock.VisibleDockables) |
|
{ |
|
foreach (var descendant in GetAllDockables(child)) |
|
yield return descendant; |
|
} |
|
} |
|
} |
|
|
|
public ContentTabPage OpenNewTab(object content, SharpTreeNode? sourceNode = null) |
|
{ |
|
// Carve-outs are born pinned — they survive tree-node selections instead of |
|
// being replaced like the preview MainTab. |
|
var tab = new ContentTabPage { Content = content, SourceNode = sourceNode, IsPreview = false }; |
|
if (factory.Documents != null) |
|
{ |
|
factory.AddDockable(factory.Documents, tab); |
|
factory.SetActiveDockable(tab); |
|
factory.SetFocusedDockable(factory.Documents, tab); |
|
} |
|
return tab; |
|
} |
|
|
|
/// <summary> |
|
/// VS-style "pin tab" gesture. The current <c>factory.MainTab</c> keeps its position, |
|
/// content, and active state but flips to pinned |
|
/// (<see cref="ContentTabPage.IsPreview"/> = false). No new tab spawns at pin time: |
|
/// the next tree-node selection that finds the active tab frozen will lazily spawn |
|
/// a fresh preview tab inside <see cref="ShowSelectedNode"/>. Re-clicking the same |
|
/// node after pin is a no-op (the dedupe activates the pinned tab). |
|
/// </summary> |
|
/// <summary> |
|
/// Pump dispatcher jobs so synchronously-set selection state (SourceNode, IsPreview, |
|
/// tab count, Content reference) finishes propagating, without awaiting the |
|
/// fire-and-forget DecompileAsync. Tests that assert on dock structure can use this |
|
/// instead of WaitForDecompiledTextAsync to avoid the multi-second decompile wait. |
|
/// </summary> |
|
public void SettleSelection() |
|
{ |
|
Avalonia.Threading.Dispatcher.UIThread.RunJobs(); |
|
} |
|
|
|
public void PinCurrentTab() |
|
{ |
|
if (factory.PinCurrentMainTab() is null) |
|
return; |
|
// Cached decompiler viewmodel was bound to the just-pinned tab — drop the cache |
|
// so the next selection-change-into-frozen path materialises a fresh |
|
// DecompilerTabPageModel for the newly-spawned preview tab. |
|
decompilerContent = null; |
|
} |
|
|
|
public void CloseAllTabs() |
|
{ |
|
var docs = factory.Documents?.VisibleDockables; |
|
if (docs == null) |
|
return; |
|
// Snapshot first — CloseDockable mutates VisibleDockables. |
|
foreach (var doc in System.Linq.Enumerable.ToArray(docs)) |
|
factory.CloseDockable(doc); |
|
} |
|
|
|
public void ResetLayout() |
|
{ |
|
// Rebuild from the factory's default layout. The active tree node, if any, will be |
|
// re-projected onto the fresh MainTab through the existing |
|
// SelectedItem -> ShowSelectedNode plumbing. |
|
var newLayout = factory.CreateLayout(); |
|
factory.InitLayout(newLayout); |
|
if (newLayout is IRootDock root && Layout is IRootDock currentRoot) |
|
{ |
|
currentRoot.VisibleDockables = root.VisibleDockables; |
|
currentRoot.ActiveDockable = root.ActiveDockable; |
|
currentRoot.FocusedDockable = root.FocusedDockable; |
|
} |
|
// Fresh MainTab means the cached decompiler content (with handlers wired to the |
|
// old tab) is gone; rebuild on the next selection. |
|
decompilerContent = null; |
|
ShowSelectedNode(); |
|
} |
|
} |
|
}
|
|
|