Browse Source

GAC browser rebuilt as a five-column sortable grid

WPF's Open-from-GAC dialog uses a ListView with five SortableGridViewColumns
(Reference Name / Version / Culture / Public Key Token / Location), each
click-to-sort, with the initial sort by name ascending. The Avalonia port
had reduced this to a one-column ListBox showing the raw ToString() —
all field-level information mashed into a single TextBlock, no per-field
sort, no Location column, no localised strings.

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 4 months ago
parent
commit
acce80299c
  1. 82
      ILSpy.Tests/Views/OpenFromGacDialogStructureTests.cs
  2. 71
      ILSpy/Views/GacEntry.cs
  3. 51
      ILSpy/Views/OpenFromGacDialog.axaml
  4. 126
      ILSpy/Views/OpenFromGacDialog.axaml.cs

82
ILSpy.Tests/Views/OpenFromGacDialogStructureTests.cs

@ -0,0 +1,82 @@ @@ -0,0 +1,82 @@
// 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 Avalonia.Controls;
using Avalonia.Headless.NUnit;
using Avalonia.VisualTree;
using AwesomeAssertions;
using ICSharpCode.ILSpy.Properties;
using ILSpy.Views;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Views;
/// <summary>
/// Pins the GAC browser's grid shape against the WPF dialog. WPF uses a ListView with a
/// GridView of five SortableGridViewColumns — Reference Name, Version, Culture,
/// Public Key Token, Location — each click-to-sort. The Avalonia port started with a
/// single-column ListBox that mashed every field into one TextBlock via
/// <see cref="object.ToString"/>; this fixture asserts the rebuilt dialog matches the
/// WPF column set so a future "let's go back to a simple list" doesn't quietly remove
/// the per-field sort and column widths users came to rely on.
/// </summary>
[TestFixture]
public class OpenFromGacDialogStructureTests
{
[AvaloniaTest]
public void Dialog_Window_Title_Comes_From_Localised_Resources()
{
var dialog = new OpenFromGacDialog();
dialog.Title.Should().Be(Resources.OpenFrom,
"the dialog title must match WPF's localised Resources.OpenFrom (currently 'Open From GAC')");
}
[AvaloniaTest]
public void Dialog_Uses_A_DataGrid_With_Five_Sortable_Columns_Matching_WPF()
{
var dialog = new OpenFromGacDialog();
// Force layout so the DataGrid's internal column templates are realised.
dialog.Measure(global::Avalonia.Size.Infinity);
dialog.Arrange(new global::Avalonia.Rect(dialog.DesiredSize));
var grid = dialog.GetVisualDescendants().OfType<DataGrid>().FirstOrDefault();
grid.Should().NotBeNull(
"the GAC browser must use a DataGrid (not a ListBox) so each field gets its own "
+ "sortable column — that's the whole reason WPF picked ListView+GridView");
var headers = grid!.Columns.Select(c => c.Header as string).ToList();
headers.Should().Contain(new[] {
Resources.ReferenceName,
Resources.Version,
Resources.CultureLabel,
Resources.PublicToken,
Resources.Location,
}, "the five WPF column headers must all be present and localised");
grid.Columns.Should().HaveCount(5,
"exactly five columns — no extras, no missing fields");
grid.Columns.Should().AllSatisfy(c => c.CanUserSort.Should().BeTrue(
"every column must be sortable to match WPF's SortableGridViewColumn.SortMode=Automatic"));
}
}

71
ILSpy/Views/GacEntry.cs

@ -0,0 +1,71 @@ @@ -0,0 +1,71 @@
// 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.Text;
using ICSharpCode.Decompiler.Metadata;
namespace ILSpy.Views
{
/// <summary>
/// One row in the GAC browser. Wraps an <see cref="AssemblyNameReference"/> + the
/// resolved on-disk path, exposing each WPF column's bound property (ShortName,
/// Version, Culture, PublicKeyToken, FileName) so the DataGrid can sort by any of
/// them independently. <see cref="FullName"/> + <see cref="FormattedVersion"/> back
/// the filter, mirroring the WPF dialog.
/// </summary>
public sealed class GacEntry
{
readonly AssemblyNameReference reference;
string? formattedVersion;
string? publicKeyToken;
public GacEntry(AssemblyNameReference reference, string fileName)
{
this.reference = reference;
FileName = fileName;
}
public string FileName { get; }
public string FullName => reference.FullName;
public string ShortName => reference.Name;
public System.Version? Version => reference.Version;
public string FormattedVersion => formattedVersion ??= (Version?.ToString() ?? string.Empty);
public string Culture
=> string.IsNullOrEmpty(reference.Culture) ? "neutral" : reference.Culture!;
public string PublicKeyToken => publicKeyToken ??= FormatPublicKeyToken(reference.PublicKeyToken);
static string FormatPublicKeyToken(byte[]? token)
{
if (token == null || token.Length == 0)
return "null";
var sb = new StringBuilder(token.Length * 2);
foreach (var b in token)
sb.AppendFormat("{0:x2}", b);
return sb.ToString();
}
public override string ToString() => FullName;
}
}

51
ILSpy/Views/OpenFromGacDialog.axaml

@ -1,25 +1,46 @@ @@ -1,25 +1,46 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ILSpy.Views.OpenFromGacDialog"
Width="600" Height="450" MinWidth="500" MinHeight="300"
Width="750" Height="350" MinWidth="200" MinHeight="150"
WindowStartupLocation="CenterOwner"
Title="Open from GAC">
<Grid Margin="12,8" RowDefinitions="Auto,*,Auto,Auto" RowSpacing="6">
CanResize="True">
<Grid Margin="12,8" RowDefinitions="Auto,*,Auto" RowSpacing="6">
<Grid Grid.Row="0" ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Text="Filter:" VerticalAlignment="Center" Margin="0,0,8,0" />
<Label Grid.Column="0" Name="FilterLabel" Target="{Binding #FilterBox}"
VerticalAlignment="Center" Margin="0,0,8,0" />
<TextBox Grid.Column="1" Name="FilterBox" />
</Grid>
<ListBox Grid.Row="1" Name="EntriesList" SelectionMode="Multiple">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<ProgressBar Grid.Row="2" Name="LoadingBar" Height="6" IsIndeterminate="True" />
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6">
<Button Name="OkButton" Content="Open" IsDefault="True" IsEnabled="False" MinWidth="72" />
<Button Name="CancelButton" Content="Cancel" IsCancel="True" MinWidth="72" />
<DataGrid Grid.Row="1" Name="EntriesGrid"
AutoGenerateColumns="False"
CanUserResizeColumns="True"
CanUserSortColumns="True"
GridLinesVisibility="None"
HeadersVisibility="Column"
IsReadOnly="True"
SelectionMode="Extended">
<DataGrid.Columns>
<DataGridTextColumn Width="300" CanUserSort="True"
x:CompileBindings="False"
Binding="{Binding ShortName}" />
<DataGridTextColumn Width="75" CanUserSort="True"
x:CompileBindings="False"
Binding="{Binding FormattedVersion}" />
<DataGridTextColumn Width="65" CanUserSort="True"
x:CompileBindings="False"
Binding="{Binding Culture}" />
<DataGridTextColumn Width="115" CanUserSort="True"
x:CompileBindings="False"
Binding="{Binding PublicKeyToken}" />
<DataGridTextColumn Width="*" CanUserSort="True"
x:CompileBindings="False"
Binding="{Binding FileName}" />
</DataGrid.Columns>
</DataGrid>
<ProgressBar Grid.Row="1" Name="LoadingBar" Height="6"
VerticalAlignment="Bottom" Margin="0,0,0,0" />
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6">
<Button Name="OkButton" IsDefault="True" IsEnabled="False" MinWidth="72" />
<Button Name="CancelButton" IsCancel="True" MinWidth="72" />
</StackPanel>
</Grid>
</Window>

126
ILSpy/Views/OpenFromGacDialog.axaml.cs

@ -20,6 +20,7 @@ using System; @@ -20,6 +20,7 @@ using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
@ -28,55 +29,124 @@ using Avalonia.Threading; @@ -28,55 +29,124 @@ using Avalonia.Threading;
using ICSharpCode.Decompiler.Metadata;
// Alias the WPF-shared Resources class — Window inherits an IResourceDictionary Resources
// property that would otherwise shadow ICSharpCode.ILSpy.Properties.Resources, turning every
// `Resources.X` into an IResourceDictionary indexer lookup that doesn't compile.
using Loc = ICSharpCode.ILSpy.Properties.Resources;
namespace ILSpy.Views
{
/// <summary>
/// "Open from GAC" dialog: enumerates the Global Assembly Cache via the shared
/// <see cref="UniversalAssemblyResolver.EnumerateGac"/> + <see cref="UniversalAssemblyResolver.GetAssemblyInGac"/>
/// pair, surfaces the assemblies in a filterable list, returns the selected
/// absolute file paths. Windows-only — the GAC doesn't exist on Linux/macOS, and
/// the command's CanExecute gates on <c>OperatingSystem.IsWindows()</c>.
/// pair and surfaces each entry in a five-column DataGrid (Reference Name / Version /
/// Culture / Public Key Token / Location) — each header click-to-sort, initial sort
/// by name ascending. Multi-select; returns the selected absolute file paths via
/// <see cref="SelectedFileNames"/>. Windows-only — the GAC doesn't exist on Linux/macOS,
/// and the command's <c>CanExecute</c> gates on <see cref="OperatingSystem.IsWindows"/>.
/// </summary>
public partial class OpenFromGacDialog : Window
{
readonly ObservableCollection<GacEntry> allEntries = new();
readonly ObservableCollection<GacEntry> filteredEntries = new();
readonly System.Threading.CancellationTokenSource fetchCts = new();
readonly CancellationTokenSource fetchCts = new();
ListBox entriesList = null!;
DataGrid entriesGrid = null!;
TextBox filterBox = null!;
Label filterLabel = null!;
ProgressBar loadingBar = null!;
Button okButton = null!;
Button cancelButton = null!;
DataGridColumn nameColumn = null!;
public OpenFromGacDialog()
{
InitializeComponent();
entriesList = this.FindControl<ListBox>("EntriesList")!;
entriesGrid = this.FindControl<DataGrid>("EntriesGrid")!;
filterBox = this.FindControl<TextBox>("FilterBox")!;
filterLabel = this.FindControl<Label>("FilterLabel")!;
loadingBar = this.FindControl<ProgressBar>("LoadingBar")!;
okButton = this.FindControl<Button>("OkButton")!;
entriesList.ItemsSource = filteredEntries;
entriesList.SelectionChanged += (_, _) => okButton.IsEnabled = entriesList.SelectedItems!.Count > 0;
cancelButton = this.FindControl<Button>("CancelButton")!;
nameColumn = this.FindControl<DataGrid>("EntriesGrid")!.Columns[0];
Title = Loc.OpenFrom;
// OpenListDialog__Open and _Search carry leading "_" Win32-mnemonic markers
// (e.g. "_Open"). Avalonia's Label/Button use "_" for the same access-key purpose,
// so the strings drop in unchanged; the Label.Target binding wires Alt+S to the
// filter box exactly as WPF's <Label Target=...> did.
filterLabel.Content = Loc._Search;
okButton.Content = Loc.OpenListDialog__Open;
cancelButton.Content = Loc.Cancel;
// Column headers — set in code-behind rather than AXAML because the localised
// strings live in the linked WPF Resources.resx; binding `{x:Static ...}` from
// AXAML would need a namespace mapping per Window and is more boilerplate than
// just five assignments here.
entriesGrid.Columns[0].Header = Loc.ReferenceName;
entriesGrid.Columns[1].Header = Loc.Version;
entriesGrid.Columns[2].Header = Loc.CultureLabel;
entriesGrid.Columns[3].Header = Loc.PublicToken;
entriesGrid.Columns[4].Header = Loc.Location;
entriesGrid.ItemsSource = filteredEntries;
entriesGrid.SelectionChanged += (_, _) => okButton.IsEnabled = entriesGrid.SelectedItems!.Count > 0;
filterBox.TextChanged += (_, _) => Refilter();
okButton.Click += (_, _) => Close(SelectedFileNames);
this.FindControl<Button>("CancelButton")!.Click += (_, _) => Close(System.Array.Empty<string>());
cancelButton.Click += (_, _) => Close(System.Array.Empty<string>());
Closed += (_, _) => fetchCts.Cancel();
// Initial sort: name ascending — matches WPF's
// SortableGridViewColumn.SetCurrentSortColumn(listView, nameColumn) +
// SetSortDirection(Ascending). Avalonia's DataGridColumn.Sort(direction) takes
// System.ComponentModel.ListSortDirection; the call mutates the underlying view
// and updates the header glyph.
Opened += (_, _) => nameColumn.Sort(System.ComponentModel.ListSortDirection.Ascending);
// Auto-focus the filter so typing starts narrowing immediately — WPF achieves the
// same via FocusManager.FocusedElement on the Window.
Opened += (_, _) => filterBox.Focus();
_ = FetchAsync();
}
void InitializeComponent() => AvaloniaXamlLoader.Load(this);
public string[] SelectedFileNames
=> entriesList.SelectedItems!.OfType<GacEntry>().Select(e => e.FileName).ToArray();
=> entriesGrid.SelectedItems!.OfType<GacEntry>().Select(e => e.FileName).ToArray();
async Task FetchAsync()
{
var token = fetchCts.Token;
var seen = new HashSet<string>();
loadingBar.IsIndeterminate = true;
try
{
await Task.Run(() => {
// Two-phase progress, like WPF: indeterminate during EnumerateGac (we don't know
// the count yet), then determinate while resolving each reference's on-disk path.
// EnumerateGac is materialised to a list off-thread so the count is known before
// the resolve pass starts.
var references = await Task.Run(() => {
var list = new List<AssemblyNameReference>();
foreach (var reference in UniversalAssemblyResolver.EnumerateGac())
{
if (token.IsCancellationRequested)
break;
list.Add(reference);
}
return list;
}, token);
if (token.IsCancellationRequested)
return;
loadingBar.IsIndeterminate = false;
loadingBar.Minimum = 0;
loadingBar.Maximum = references.Count;
loadingBar.Value = 0;
await Task.Run(() => {
foreach (var reference in references)
{
if (token.IsCancellationRequested)
return;
@ -86,7 +156,10 @@ namespace ILSpy.Views @@ -86,7 +156,10 @@ namespace ILSpy.Views
if (path == null)
continue;
var entry = new GacEntry(reference, path);
Dispatcher.UIThread.Post(() => AddEntry(entry));
Dispatcher.UIThread.Post(() => {
AddEntry(entry);
loadingBar.Value++;
});
}
}, token);
}
@ -119,31 +192,18 @@ namespace ILSpy.Views @@ -119,31 +192,18 @@ namespace ILSpy.Views
var text = filterBox.Text?.Trim();
if (string.IsNullOrEmpty(text))
return true;
var label = entry.ToString();
foreach (var token in text.Split(' ', System.StringSplitOptions.RemoveEmptyEntries))
// Match every space-separated token against the full assembly name OR the version
// string — mirrors WPF's filter (FullName || FormattedVersion). All tokens must
// match: "system 4.0" finds entries whose FullName contains "system" AND
// FormattedVersion contains "4.0".
foreach (var token in text.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
if (label.IndexOf(token, System.StringComparison.OrdinalIgnoreCase) < 0)
bool inFullName = entry.FullName.IndexOf(token, StringComparison.OrdinalIgnoreCase) >= 0;
bool inVersion = entry.FormattedVersion.IndexOf(token, StringComparison.OrdinalIgnoreCase) >= 0;
if (!inFullName && !inVersion)
return false;
}
return true;
}
sealed class GacEntry(AssemblyNameReference reference, string fileName)
{
public string FileName { get; } = fileName;
public override string ToString()
=> $"{reference.Name}, Version={reference.Version}, Culture={(reference.Culture is { Length: > 0 } c ? c : "neutral")}, PublicKeyToken={FormatPublicKey(reference)}";
static string FormatPublicKey(AssemblyNameReference reference)
{
var token = reference.PublicKeyToken;
if (token == null || token.Length == 0)
return "null";
var sb = new System.Text.StringBuilder(token.Length * 2);
foreach (var b in token)
sb.AppendFormat("{0:x2}", b);
return sb.ToString();
}
}
}
}

Loading…
Cancel
Save