From 93c4b24b23a04a2add397dbb2af940c03f1fd44a Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Fri, 22 May 2026 00:39:56 +0200 Subject: [PATCH] Window menu lists open document tabs Adds TabPageMenuItem (mirrors ToolPaneMenuItem) and a live ObservableCollection on DockWorkspace kept in sync with factory.Documents.VisibleDockables. MainMenu appends a separator + radio-style MenuItem per tab, with Header bound to Title and IsChecked bound to IsActive. WPF parity for the previously-skipped tabs section of the Window menu. Assisted-by: Claude:claude-opus-4-7:Claude Code --- .../WindowMenuOpenDocumentsTests.cs | 90 ++++++++++++++ ILSpy/Docking/DockWorkspace.cs | 72 ++++++++++- ILSpy/ViewModels/TabPageMenuItem.cs | 78 ++++++++++++ ILSpy/Views/MainMenu.axaml.cs | 115 ++++++++++++++++-- 4 files changed, 343 insertions(+), 12 deletions(-) create mode 100644 ILSpy.Tests/MainWindow/WindowMenuOpenDocumentsTests.cs create mode 100644 ILSpy/ViewModels/TabPageMenuItem.cs diff --git a/ILSpy.Tests/MainWindow/WindowMenuOpenDocumentsTests.cs b/ILSpy.Tests/MainWindow/WindowMenuOpenDocumentsTests.cs new file mode 100644 index 000000000..df956d323 --- /dev/null +++ b/ILSpy.Tests/MainWindow/WindowMenuOpenDocumentsTests.cs @@ -0,0 +1,90 @@ +// 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.Controls; +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.Properties; + +using ILSpy.AppEnv; +using ILSpy.Docking; +using ILSpy.TreeNodes; +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +[TestFixture] +public class WindowMenuOpenDocumentsTests +{ + [AvaloniaTest] + public async Task Window_Menu_Lists_Every_Open_Document_Tab() + { + // The Window menu must surface one entry per open document tab so the user can + // jump between tabs from the keyboard. Mirrors WPF's MainMenu.InitWindowMenu + // tab-section (which composed `dockWorkspace.TabPages` into the menu's ItemsSource). + + // Arrange — boot the window, wait for assemblies, then select a node so the persistent + // MainTab carries a meaningful title. + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + + var firstAsm = vm.AssemblyTreeModel.FindNode("System.Linq"); + vm.AssemblyTreeModel.SelectNode(firstAsm); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + // Open a second tab via the production "open in new tab" path. + var secondAsm = vm.AssemblyTreeModel.FindNode("System.Private.Uri"); + vm.DockWorkspace.OpenNodeInNewTab(secondAsm); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + var menu = await window.WaitForComponent(); + var windowMenu = menu.Items.OfType() + .Single(m => (string?)m.Tag == nameof(Resources._Window)); + + // Wait for the menu to be populated — the tabs section is built lazily on Loaded / + // when the collection changes. Match on the second tab's runtime Title rather than the + // tree node's text so the assertion tracks whatever ContentTabPage actually surfaces. + var documentsDock = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; + await Waiters.WaitForAsync( + () => documentsDock.VisibleDockables!.OfType().All(t => !string.IsNullOrEmpty(t.Title)) + && documentsDock.VisibleDockables!.OfType() + .Select(t => t.Title) + .All(title => windowMenu.Items.OfType().Any(mi => string.Equals(mi.Header as string, title)))); + + // Assert — both tabs are listed by their Title in the Window menu. + var tabTitles = documentsDock.VisibleDockables!.OfType() + .Select(t => t.Title) + .ToList(); + var menuTitles = windowMenu.Items.OfType() + .Select(mi => mi.Header as string) + .ToList(); + + tabTitles.Should().HaveCount(2); + menuTitles.Should().Contain(tabTitles!); + } +} diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index 3042e6351..9da9ee438 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -18,6 +18,7 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.ComponentModel; using System.Composition; using System.Linq; @@ -130,6 +131,15 @@ namespace ILSpy.Docking public IReadOnlyList ToolPaneMenuItems { get; } + /// + /// Live list of one per open document tab. Kept in + /// sync with .VisibleDockables by the + /// handler — adds/removes mutate this in + /// place so subscribers (the Window menu) can hook + /// and patch their own items. + /// + public ObservableCollection TabPageMenuItems { get; } = new(); + [ImportingConstructor] public DockWorkspace( AssemblyTreeModel assemblyTreeModel, @@ -174,6 +184,10 @@ namespace ILSpy.Docking 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(); // 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 @@ -257,7 +271,58 @@ namespace ILSpy.Docking NavigateForwardCommand.NotifyCanExecuteChanged(); } - void OnDocumentMembershipChanged(object? sender, EventArgs e) => UpdateLastDocumentCanClose(); + 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().ToList() + ?? new List(); + + // 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. @@ -322,6 +387,11 @@ namespace ILSpy.Docking { if (e.PropertyName != nameof(IDocumentDock.ActiveDockable)) return; + // 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. diff --git a/ILSpy/ViewModels/TabPageMenuItem.cs b/ILSpy/ViewModels/TabPageMenuItem.cs new file mode 100644 index 000000000..84db1820e --- /dev/null +++ b/ILSpy/ViewModels/TabPageMenuItem.cs @@ -0,0 +1,78 @@ +// 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; + +using Dock.Model.Controls; +using Dock.Model.Core; + +namespace ILSpy.ViewModels +{ + /// + /// Window-menu adapter for a single document tab. Forwards from the + /// underlying and surfaces an bool + /// the menu can two-way-bind to so clicking the entry activates the tab. + /// + public class TabPageMenuItem : ObservableObject + { + readonly IFactory factory; + readonly IDocumentDock owner; + + public TabPageMenuItem(ContentTabPage tab, IFactory factory, IDocumentDock owner) + { + Tab = tab; + this.factory = factory; + this.owner = owner; + tab.PropertyChanged += OnTabPropertyChanged; + } + + public ContentTabPage Tab { get; } + + public string? Title => Tab.Title; + + public bool IsActive { + get => ReferenceEquals(owner.ActiveDockable, Tab); + set { + if (!value || IsActive) + return; + factory.SetActiveDockable(Tab); + factory.SetFocusedDockable(owner, Tab); + // IsActive's new value flows out via NotifyActiveChanged once the owner's + // ActiveDockable swap fires its PropertyChanged. + } + } + + /// + /// Called by when the documents dock's + /// ActiveDockable changes — re-raises so the menu + /// item's bound checkmark updates without each item having to subscribe to the + /// dock's own PropertyChanged. + /// + public void NotifyActiveChanged() => OnPropertyChanged(nameof(IsActive)); + + internal void Detach() => Tab.PropertyChanged -= OnTabPropertyChanged; + + void OnTabPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Tab.Title)) + OnPropertyChanged(nameof(Title)); + } + } +} diff --git a/ILSpy/Views/MainMenu.axaml.cs b/ILSpy/Views/MainMenu.axaml.cs index 459a8eb9d..8ca7be51f 100644 --- a/ILSpy/Views/MainMenu.axaml.cs +++ b/ILSpy/Views/MainMenu.axaml.cs @@ -192,24 +192,117 @@ public partial class MainMenu : UserControl // At this point InitMainMenu has already appended any MEF-driven Window-menu commands // (CloseAllDocuments / ResetLayout). Append tool-pane toggles after a separator so // they sit at the bottom of the Window menu. - // (Tab pages aren't yet exposed observably on DockWorkspace; revisit later.) - if (dockWorkspace.ToolPaneMenuItems.Count == 0) - return; - if (windowMenuItem.Items.Count > 0) - windowMenuItem.Items.Add(new Separator()); + if (dockWorkspace.ToolPaneMenuItems.Count > 0) + { + if (windowMenuItem.Items.Count > 0) + windowMenuItem.Items.Add(new Separator()); + + foreach (var pane in dockWorkspace.ToolPaneMenuItems) + { + var item = new MenuItem { + Header = pane.Title, + ToggleType = MenuItemToggleType.CheckBox, + DataContext = pane, + }; + item.Bind(MenuItem.IsCheckedProperty, new Binding(nameof(ToolPaneMenuItem.IsPaneVisible)) { + Mode = BindingMode.TwoWay, + }); + windowMenuItem.Items.Add(item); + } + } + + // Tab section: one MenuItem per open document. Separator only if other items already + // exist so we don't lead with a stray rule when both prior blocks were empty. + AppendTabSection(windowMenuItem, dockWorkspace); + } + + static void AppendTabSection(MenuItem windowMenuItem, DockWorkspace dockWorkspace) + { + var tabItems = dockWorkspace.TabPageMenuItems; + Separator? separator = null; + var perItem = new Dictionary(); + + void EnsureSeparator() + { + if (separator != null) + return; + if (windowMenuItem.Items.Count == 0) + return; + separator = new Separator(); + windowMenuItem.Items.Add(separator); + } - foreach (var pane in dockWorkspace.ToolPaneMenuItems) + MenuItem CreateMenuItem(TabPageMenuItem vm) { var item = new MenuItem { - Header = pane.Title, - ToggleType = MenuItemToggleType.CheckBox, - DataContext = pane, + Header = vm.Title, + ToggleType = MenuItemToggleType.Radio, + DataContext = vm, }; - item.Bind(MenuItem.IsCheckedProperty, new Binding(nameof(ToolPaneMenuItem.IsPaneVisible)) { + // Header tracks the tab's Title (e.g. swaps from "(no selection)" → "MyType" when + // the user navigates). + item.Bind(MenuItem.HeaderProperty, new Binding(nameof(TabPageMenuItem.Title))); + // IsActive: setter activates the tab, getter mirrors the dock's ActiveDockable. + item.Bind(MenuItem.IsCheckedProperty, new Binding(nameof(TabPageMenuItem.IsActive)) { Mode = BindingMode.TwoWay, }); - windowMenuItem.Items.Add(item); + return item; + } + + void AddItem(TabPageMenuItem vm) + { + EnsureSeparator(); + var menuItem = CreateMenuItem(vm); + perItem.Add(vm, menuItem); + windowMenuItem.Items.Add(menuItem); } + + void RemoveItem(TabPageMenuItem vm) + { + if (!perItem.TryGetValue(vm, out var menuItem)) + return; + windowMenuItem.Items.Remove(menuItem); + perItem.Remove(vm); + // Drop the separator if no tabs remain — the next AddItem will re-create one. + if (perItem.Count == 0 && separator != null) + { + windowMenuItem.Items.Remove(separator); + separator = null; + } + } + + foreach (var vm in tabItems) + AddItem(vm); + + tabItems.CollectionChanged += (_, args) => { + switch (args.Action) + { + case System.Collections.Specialized.NotifyCollectionChangedAction.Add: + if (args.NewItems != null) + foreach (TabPageMenuItem vm in args.NewItems) + AddItem(vm); + break; + case System.Collections.Specialized.NotifyCollectionChangedAction.Remove: + if (args.OldItems != null) + foreach (TabPageMenuItem vm in args.OldItems) + RemoveItem(vm); + break; + case System.Collections.Specialized.NotifyCollectionChangedAction.Reset: + foreach (var vm in perItem.Keys.ToList()) + RemoveItem(vm); + foreach (var vm in tabItems) + AddItem(vm); + break; + case System.Collections.Specialized.NotifyCollectionChangedAction.Move: + // Move keeps identity; the menu's relative order matters only for the tab + // listing. Rebuild the tab subrange to keep them aligned with the tab strip. + foreach (var vm in perItem.Keys.ToList()) + RemoveItem(vm); + foreach (var vm in tabItems) + AddItem(vm); + break; + } + }; } static bool TryParseGesture(string? text, out KeyGesture gesture)