From 6b25dfc93811e4972becedef89109228603a3339 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 29 Jul 2026 08:09:04 +0200 Subject: [PATCH 1/6] Stretch metadata row details across the full row width The details content was pinned left and the text blob capped at 800px, leaving dead space to the right of embedded-source text and the flags/typed sub-grids. Let all three detail shapes stretch and give the last sub-grid column the leftover width so the details area fills its host row. Assisted-by: Claude:claude-fable-5:Claude Code --- .../Metadata/MetadataRowDetailsTests.cs | 24 +++++++++++++++++++ ILSpy/Metadata/MetadataRowDetails.cs | 9 +++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs b/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs index e21ae845de..61523803b4 100644 --- a/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs +++ b/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs @@ -23,6 +23,7 @@ using Avalonia.Controls; using Avalonia.Headless.NUnit; +using Avalonia.Layout; using Avalonia.VisualTree; using AwesomeAssertions; @@ -147,6 +148,29 @@ public async Task Optional_Header_Expands_Details_For_The_Dll_Characteristics_Ro .OnlyContain(e => !tab.IsRowDetailsVisible!(e)); } + [AvaloniaTest] + public void Details_Content_Stretches_Across_The_Full_Row_Width() + { + // The details area spans the host row, and its content fills it: a capped or + // left-pinned control would leave dead space to the right of the blob text or + // sub-grid columns. + var text = MetadataRowDetails.BuildTextBlob("blob text"); + text.HorizontalAlignment.Should().Be(HorizontalAlignment.Stretch); + text.MaxWidth.Should().Be(double.PositiveInfinity, "the text blob must not cap its width"); + + var flagsGrid = (DataGrid)MetadataRowDetails.BuildFlagsGrid(new List { new(true, "<0001> bit") }); + flagsGrid.HorizontalAlignment.Should().Be(HorizontalAlignment.Stretch); + flagsGrid.Columns[^1].Width.UnitType.Should().Be(DataGridLengthUnitType.Star, + "the meaning column takes the leftover width"); + + var detailsGrid = (DataGrid)MetadataRowDetails.BuildDetailsGrid( + new List { new(true, "<0001> bit") }, + ("Value", nameof(BitEntry.Value)), ("Meaning", nameof(BitEntry.Meaning))); + detailsGrid.HorizontalAlignment.Should().Be(HorizontalAlignment.Stretch); + detailsGrid.Columns[^1].Width.UnitType.Should().Be(DataGridLengthUnitType.Star, + "the last column takes the leftover width"); + } + [AvaloniaTest] public async Task Double_Tap_Inside_The_Details_Area_Does_Not_Resolve_To_An_Activatable_Row() { diff --git a/ILSpy/Metadata/MetadataRowDetails.cs b/ILSpy/Metadata/MetadataRowDetails.cs index d90fb7d80e..a9a42c3180 100644 --- a/ILSpy/Metadata/MetadataRowDetails.cs +++ b/ILSpy/Metadata/MetadataRowDetails.cs @@ -24,7 +24,6 @@ using Avalonia.Controls; using Avalonia.Controls.Templates; using Avalonia.Data; -using Avalonia.Layout; using Avalonia.Media; using ICSharpCode.ILSpy.ViewModels; @@ -105,6 +104,7 @@ public static Control BuildFlagsGrid(IList bits) grid.Columns.Add(new DataGridTextColumn { Binding = new Binding(nameof(BitEntry.Meaning)), IsReadOnly = true, + Width = new DataGridLength(1, DataGridLengthUnitType.Star), }); return grid; } @@ -117,9 +117,7 @@ public static Control BuildTextBlob(string text) Text = text, IsReadOnly = true, TextWrapping = TextWrapping.Wrap, - MaxWidth = 800, MaxHeight = 400, - HorizontalAlignment = HorizontalAlignment.Left, }; } @@ -143,6 +141,10 @@ public static Control BuildDetailsGrid(IEnumerable rows, params (string Header, IsReadOnly = true, }); } + // The last column absorbs the leftover width so the sub-grid fills the host row + // instead of ending in dead space after its auto-sized columns. + if (grid.Columns.Count > 0) + grid.Columns[^1].Width = new DataGridLength(1, DataGridLengthUnitType.Star); return grid; } @@ -154,7 +156,6 @@ public static Control BuildDetailsGrid(IEnumerable rows, params (string Header, CanUserReorderColumns = false, CanUserSortColumns = false, SelectionMode = DataGridSelectionMode.Single, - HorizontalAlignment = HorizontalAlignment.Left, }; } } From c4edb6e05aab6e508001924d17ed36744bfddd56 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 29 Jul 2026 10:13:49 +0200 Subject: [PATCH 2/6] Show metadata text-blob row details in a syntax-highlighting editor The CustomDebugInformation details area rendered decoded text payloads (embedded source, source-link JSON, hex dumps) in a plain TextBox, which shows code without any highlighting and materializes the whole formatted text up front, so large embedded-source documents were expensive. Text payloads now travel as a TextBlobDetail tagged with a file extension -- ".json" for source link, the parent document's extension for embedded source, none for hex -- and render in the theme-aware AvaloniaEdit editor, which colours them via the existing highlighting registry and virtualizes long documents. The editor's ThemeChanged subscription moves from the constructor to OnAttachedToVisualTree so it stays paired with the detach-time unsubscribe now that editors can leave and re-enter the visual tree inside recycled row-details containers. Assisted-by: Claude:claude-fable-5:Claude Code --- .../CustomDebugInformationRowDetailsTests.cs | 113 ++++++++++++------ .../Metadata/MetadataRowDetailsTests.cs | 29 ++++- .../CustomDebugInformationTableTreeNode.cs | 38 ++++-- ILSpy/Metadata/MetadataRowDetails.cs | 30 ++++- ILSpy/TextView/DecompilerTextEditor.cs | 12 +- 5 files changed, 165 insertions(+), 57 deletions(-) diff --git a/ILSpy.Tests/Metadata/CustomDebugInformationRowDetailsTests.cs b/ILSpy.Tests/Metadata/CustomDebugInformationRowDetailsTests.cs index 22bad80fd5..56d28692e6 100644 --- a/ILSpy.Tests/Metadata/CustomDebugInformationRowDetailsTests.cs +++ b/ILSpy.Tests/Metadata/CustomDebugInformationRowDetailsTests.cs @@ -37,6 +37,7 @@ using ICSharpCode.ILSpy.Metadata; using ICSharpCode.ILSpy.Metadata.DebugTables; +using ICSharpCode.ILSpy.TextView; using ICSharpCode.ILSpy.ViewModels; using NUnit.Framework; @@ -58,23 +59,28 @@ static MetadataFile BuildPdbFixture() { // Synthesize a standalone portable PDB whose CustomDebugInformation table holds one // row per row-details scenario: a text kind (Source Link), an unrecognized kind - // GUID, embedded source in both formats (raw and DEFLATE), and the four structured - // kinds that parse into typed rows. Parents use ascending MethodDef tokens so the - // (parent-sorted) table preserves this row order. + // GUID, the four structured kinds that parse into typed rows, and embedded source in + // both formats (raw and DEFLATE), each parented to a document whose name carries the + // source-language extension. MetadataBuilder sorts the table by its + // HasCustomDebugInformation coded index on serialize, interleaving method- and + // document-parented rows, so tests locate rows by kind GUID rather than by position. var builder = new MetadataBuilder(); - AddRow(1, KnownGuids.SourceLink, Encoding.UTF8.GetBytes(SourceLinkJson)); - AddRow(2, UnknownKindGuid, new byte[] { 0x01, 0x02, 0x03 }); - AddRow(3, KnownGuids.EmbeddedSource, new byte[] { 0x00, 0x00, 0x00, 0x00, 0x41 }); - AddRow(4, KnownGuids.StateMachineHoistedLocalScopes, HoistedScopesBlob((0, 10), (16, 32))); - AddRow(5, KnownGuids.CompilationOptions, NullTerminatedStrings("language", "C#", "version", "2")); - AddRow(6, KnownGuids.CompilationMetadataReferences, MetadataReferenceBlob( + var csharpDocument = AddDocument("/src/Example.cs", KnownGuids.CSharpLanguageGuid); + var vbDocument = AddDocument("/src/Module1.vb", KnownGuids.VBLanguageGuid); + + AddRow(MethodRow(1), KnownGuids.SourceLink, Encoding.UTF8.GetBytes(SourceLinkJson)); + AddRow(MethodRow(2), UnknownKindGuid, new byte[] { 0x01, 0x02, 0x03 }); + AddRow(MethodRow(3), KnownGuids.StateMachineHoistedLocalScopes, HoistedScopesBlob((0, 10), (16, 32))); + AddRow(MethodRow(4), KnownGuids.CompilationOptions, NullTerminatedStrings("language", "C#", "version", "2")); + AddRow(MethodRow(5), KnownGuids.CompilationMetadataReferences, MetadataReferenceBlob( "System.Runtime.dll", "global", flags: 1, timestamp: 0x12345678, fileSize: 1024, ReferenceMvid)); - AddRow(7, KnownGuids.TupleElementNames, NullTerminatedStrings("Item1", "Name")); - AddRow(8, KnownGuids.EmbeddedSource, EmbeddedSourceDeflateBlob); + AddRow(MethodRow(6), KnownGuids.TupleElementNames, NullTerminatedStrings("Item1", "Name")); + AddRow(csharpDocument, KnownGuids.EmbeddedSource, new byte[] { 0x00, 0x00, 0x00, 0x00, 0x41 }); + AddRow(vbDocument, KnownGuids.EmbeddedSource, EmbeddedSourceDeflateBlob); var rowCounts = new int[MetadataTokens.TableCount]; - rowCounts[(int)TableIndex.MethodDef] = 8; + rowCounts[(int)TableIndex.MethodDef] = 6; var pdbBuilder = new PortablePdbBuilder(builder, rowCounts.ToImmutableArray(), entryPoint: default); var blob = new BlobBuilder(); pdbBuilder.Serialize(blob); @@ -82,13 +88,25 @@ static MetadataFile BuildPdbFixture() var provider = MetadataReaderProvider.FromPortablePdbImage(blob.ToImmutableArray()); return new MetadataFile(MetadataFile.MetadataFileKind.ProgramDebugDatabase, "synthetic.pdb", provider); - void AddRow(int methodRow, Guid kind, byte[] value) + static EntityHandle MethodRow(int methodRow) + => MetadataTokens.EntityHandle(TableIndex.MethodDef, methodRow); + + void AddRow(EntityHandle parent, Guid kind, byte[] value) { builder.AddCustomDebugInformation( - MetadataTokens.EntityHandle(TableIndex.MethodDef, methodRow), + parent, builder.GetOrAddGuid(kind), builder.GetOrAddBlob(value)); } + + DocumentHandle AddDocument(string name, Guid language) + { + return builder.AddDocument( + builder.GetOrAddDocumentName(name), + builder.GetOrAddGuid(KnownGuids.HashAlgorithmSHA256), + builder.GetOrAddBlob(new byte[32]), + builder.GetOrAddGuid(language)); + } } static byte[] BuildEmbeddedSourceDeflateBlob(string text) @@ -156,6 +174,13 @@ static List LoadEntries(MetadataFile metadataFile) .Select(h => new CustomDebugInformationEntry(metadataFile, h)) .ToList(); + static CustomDebugInformationEntry ByKind(IEnumerable entries, Guid kind) + => entries.Single(e => e.KindGUID == kind); + + /// The embedded-source rows in table order: raw (".cs" document) first, DEFLATE (".vb" document) second. + static List EmbeddedSourceRows(IEnumerable entries) + => entries.Where(e => e.KindGUID == KnownGuids.EmbeddedSource).ToList(); + [Test] public void Offset_And_Split_Kind_Columns_Match_The_Tables_Conventions() { @@ -166,12 +191,10 @@ public void Offset_And_Split_Kind_Columns_Match_The_Tables_Conventions() var metadataFile = BuildPdbFixture(); var entries = LoadEntries(metadataFile); - entries[0].Kind.Should().BePositive("the Kind column is the 1-based GUID heap offset"); - entries[0].KindGUID.Should().Be(KnownGuids.SourceLink); - entries[0].KindString.Should().Be("Source Link (C# / VB)"); - entries[1].KindGUID.Should().Be(UnknownKindGuid); - entries[1].KindString.Should().Be("Unknown"); - entries[3].KindString.Should().Be("State Machine Hoisted Local Scopes (C# / VB)"); + ByKind(entries, KnownGuids.SourceLink).Kind.Should().BePositive("the Kind column is the 1-based GUID heap offset"); + ByKind(entries, KnownGuids.SourceLink).KindString.Should().Be("Source Link (C# / VB)"); + ByKind(entries, UnknownKindGuid).KindString.Should().Be("Unknown"); + ByKind(entries, KnownGuids.StateMachineHoistedLocalScopes).KindString.Should().Be("State Machine Hoisted Local Scopes (C# / VB)"); int rowSize = metadataFile.Metadata.GetTableRowSize(TableIndex.CustomDebugInformation); entries[0].Offset.Should().BePositive("the table lives at a real offset inside the PDB metadata"); @@ -186,21 +209,25 @@ public void RowDetails_Parses_The_Structured_Kinds_Into_Typed_Rows() var metadataFile = BuildPdbFixture(); var entries = LoadEntries(metadataFile); - entries[3].RowDetails.Should().BeAssignableTo>() + ByKind(entries, KnownGuids.StateMachineHoistedLocalScopes).RowDetails + .Should().BeAssignableTo>() .Which.Should().Equal( new HoistedLocalScopeDetail(0, 10), new HoistedLocalScopeDetail(16, 32)); - entries[4].RowDetails.Should().BeAssignableTo>() + ByKind(entries, KnownGuids.CompilationOptions).RowDetails + .Should().BeAssignableTo>() .Which.Should().Equal( new CompilationOptionDetail("language", "C#"), new CompilationOptionDetail("version", "2")); - entries[5].RowDetails.Should().BeAssignableTo>() + ByKind(entries, KnownGuids.CompilationMetadataReferences).RowDetails + .Should().BeAssignableTo>() .Which.Should().Equal( new MetadataReferenceDetail("System.Runtime.dll", "global", 1, 0x12345678, 1024, ReferenceMvid)); - entries[6].RowDetails.Should().BeAssignableTo>() + ByKind(entries, KnownGuids.TupleElementNames).RowDetails + .Should().BeAssignableTo>() .Which.Should().Equal( new TupleElementNameDetail("Item1"), new TupleElementNameDetail("Name")); @@ -212,18 +239,25 @@ public void RowDetails_Shows_Text_For_Source_Link_And_Hex_For_Opaque_Blobs() var metadataFile = BuildPdbFixture(); var entries = LoadEntries(metadataFile); - entries[0].RowDetails.Should().Be(SourceLinkJson, "source link blobs are UTF-8 JSON"); - entries[1].RowDetails.Should().Be("01-02-03", "unrecognized kinds degrade to a hex dump"); + ByKind(entries, KnownGuids.SourceLink).RowDetails.Should().Be(new TextBlobDetail(SourceLinkJson, ".json"), + "source link blobs are UTF-8 JSON"); + ByKind(entries, UnknownKindGuid).RowDetails.Should().Be(new TextBlobDetail("01-02-03"), + "unrecognized kinds degrade to a plain hex dump"); } [Test] public void RowDetails_Decodes_Embedded_Source_To_The_Document_Text() { + // The parent document's file extension travels with the decoded text so the details + // area can highlight the source in its own language. var metadataFile = BuildPdbFixture(); var entries = LoadEntries(metadataFile); - entries[2].RowDetails.Should().Be("A", "a zero format header means the document bytes follow uncompressed"); - entries[7].RowDetails.Should().Be(EmbeddedSourceText, "a positive format header means the document is DEFLATE-compressed"); + var embedded = EmbeddedSourceRows(entries); + embedded[0].RowDetails.Should().Be(new TextBlobDetail("A", ".cs"), + "a zero format header means the document bytes follow uncompressed"); + embedded[1].RowDetails.Should().Be(new TextBlobDetail(EmbeddedSourceText, ".vb"), + "a positive format header means the document is DEFLATE-compressed"); } [Test] @@ -232,9 +266,11 @@ public void Info_Summarizes_The_Embedded_Source_Format() var metadataFile = BuildPdbFixture(); var entries = LoadEntries(metadataFile); - entries[0].Info.Should().BeNull("only embedded source carries a format header to summarize"); - entries[2].Info.Should().Be("Raw, 5 bytes"); - entries[7].Info.Should().Be( + ByKind(entries, KnownGuids.SourceLink).Info.Should().BeNull( + "only embedded source carries a format header to summarize"); + var embedded = EmbeddedSourceRows(entries); + embedded[0].Info.Should().Be("Raw, 5 bytes"); + embedded[1].Info.Should().Be( $"DEFLATE, {EmbeddedSourceDeflateBlob.Length} bytes, {Encoding.UTF8.GetByteCount(EmbeddedSourceText)} uncompressed"); } @@ -242,8 +278,8 @@ public void Info_Summarizes_The_Embedded_Source_Format() public void Tab_Configures_Selection_Driven_Row_Details_That_Route_By_Blob_Shape() { // The details area swaps presentation per row: text blobs land in a read-only - // TextBox, structured kinds in a sub-grid. Selection drives visibility, so scanning - // the table with the arrow keys previews each blob. + // syntax-highlighting editor, structured kinds in a sub-grid. Selection drives + // visibility, so scanning the table with the arrow keys previews each blob. var metadataFile = BuildPdbFixture(); var node = new CustomDebugInformationTableTreeNode(metadataFile); var tab = (MetadataTablePageModel)node.CreateTab(); @@ -252,13 +288,16 @@ public void Tab_Configures_Selection_Driven_Row_Details_That_Route_By_Blob_Shape tab.RowDetailsTemplate.Should().NotBeNull(); var entries = tab.Items.Cast().ToList(); - var shell = tab.RowDetailsTemplate!.Build(entries[0]) + var sourceLink = ByKind(entries, KnownGuids.SourceLink); + var shell = tab.RowDetailsTemplate!.Build(sourceLink) .Should().BeOfType().Subject; - shell.DataContext = entries[0]; - shell.Content.Should().BeOfType().Which.Text.Should().Be(SourceLinkJson); + shell.DataContext = sourceLink; + var editor = shell.Content.Should().BeOfType().Subject; + editor.Text.Should().Be(SourceLinkJson); + editor.SyntaxHighlighting.Should().NotBeNull("source-link JSON gets JSON highlighting"); - shell.DataContext = entries[4]; + shell.DataContext = ByKind(entries, KnownGuids.CompilationOptions); var optionsGrid = shell.Content.Should().BeOfType().Subject; ((IEnumerable)optionsGrid.ItemsSource!).Cast().Should().HaveCount(2); } diff --git a/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs b/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs index 61523803b4..6228bf626d 100644 --- a/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs +++ b/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs @@ -29,6 +29,7 @@ using AwesomeAssertions; using ICSharpCode.ILSpy.Metadata; +using ICSharpCode.ILSpy.TextView; using ICSharpCode.ILSpy.ViewModels; using ICSharpCode.ILSpy.Views; @@ -171,11 +172,37 @@ public void Details_Content_Stretches_Across_The_Full_Row_Width() "the last column takes the leftover width"); } + [AvaloniaTest] + public void Text_Blob_Is_A_Read_Only_Editor_With_Extension_Driven_Highlighting() + { + // Text payloads are code (embedded source, source-link JSON): they render in the + // theme-aware AvaloniaEdit editor, which adds syntax colours and virtualizes long + // documents, while staying read-only but selectable. The optional extension picks + // the highlighting; without one (hex dumps) the text stays plain. + var editor = MetadataRowDetails.BuildTextBlob("class C { }", ".cs") + .Should().BeOfType().Subject; + editor.Text.Should().Be("class C { }"); + editor.IsReadOnly.Should().BeTrue(); + editor.WordWrap.Should().BeTrue(); + editor.MaxHeight.Should().Be(400, "the host row must stay bounded; the editor scrolls internally"); + editor.SyntaxHighlighting.Should().NotBeNull(); + editor.SyntaxHighlighting!.Name.Should().Be("C#"); + + var json = (DecompilerTextEditor)MetadataRowDetails.BuildTextBlob("{ }", ".json"); + json.SyntaxHighlighting.Should().NotBeNull("AvaloniaEdit ships a built-in JSON definition"); + + var plain = (DecompilerTextEditor)MetadataRowDetails.BuildTextBlob("01-02-03"); + plain.SyntaxHighlighting.Should().BeNull(); + + var unknown = (DecompilerTextEditor)MetadataRowDetails.BuildTextBlob("text", ".xyz"); + unknown.SyntaxHighlighting.Should().BeNull("an unrecognized extension degrades to plain text"); + } + [AvaloniaTest] public async Task Double_Tap_Inside_The_Details_Area_Does_Not_Resolve_To_An_Activatable_Row() { // Row activation navigates away from the metadata view. A double-click inside the - // details area (e.g. word-selection in an embedded-source TextBox, or a click in the + // details area (e.g. word-selection in an embedded-source editor, or a click in the // flags sub-grid) is interacting with the details content, not requesting navigation, // so the row-resolution walk must reject sources under the details presenter. var (window, vm) = await TestHarness.BootAsync(); diff --git a/ILSpy/Metadata/DebugTables/CustomDebugInformationTableTreeNode.cs b/ILSpy/Metadata/DebugTables/CustomDebugInformationTableTreeNode.cs index e7cc6cff21..fed0a7d7f7 100644 --- a/ILSpy/Metadata/DebugTables/CustomDebugInformationTableTreeNode.cs +++ b/ILSpy/Metadata/DebugTables/CustomDebugInformationTableTreeNode.cs @@ -76,7 +76,7 @@ protected override void ConfigurePage(MetadataTablePageModel page) static Control? BuildRowDetailsContent(object? item) { return (item as CustomDebugInformationEntry)?.RowDetails switch { - string text => MetadataRowDetails.BuildTextBlob(text), + TextBlobDetail blob => MetadataRowDetails.BuildTextBlob(blob.Text, blob.HighlightExtension), IReadOnlyList rows => MetadataRowDetails.BuildDetailsGrid(rows, ("Start Offset", nameof(HoistedLocalScopeDetail.StartOffset)), ("Length", nameof(HoistedLocalScopeDetail.Length))), @@ -197,9 +197,11 @@ public string ValueTooltip { /// /// Parsed view of the Value blob for the row-details area. Structured kinds become - /// typed row lists, source-link blobs the decoded JSON text, embedded source the - /// (decompressed) document text, everything else (including malformed blobs) a hex - /// dump. Cached — the details area re-requests it on every selection change. + /// typed row lists; text payloads become a — source-link + /// blobs the decoded JSON, embedded source the (decompressed) document text tagged + /// with its document's file extension, everything else (including malformed blobs) + /// a plain hex dump. Cached — the details area re-requests it on every selection + /// change. /// public object? RowDetails { get { @@ -215,7 +217,7 @@ public object? RowDetails { } catch (Exception ex) when (ex is BadImageFormatException or InvalidDataException) { - return rowDetails = metadataFile.Metadata.GetBlobReader(debugInfo.Value).ToHexString(); + return rowDetails = new TextBlobDetail(metadataFile.Metadata.GetBlobReader(debugInfo.Value).ToHexString()); } } } @@ -231,13 +233,13 @@ object ParseRowDetails(ref BlobReader reader) return list; } if (kind == KnownGuids.SourceLink) - return reader.ReadUTF8(reader.RemainingBytes); + return new TextBlobDetail(reader.ReadUTF8(reader.RemainingBytes), ".json"); if (kind == KnownGuids.EmbeddedSource) { var embeddedSourceFormat = reader.ReadInt32(); if (embeddedSourceFormat < 0) // unknown format, show raw data as hex - return reader.ToHexString(); + return new TextBlobDetail(reader.ToHexString()); var embeddedSourceBytes = reader.ReadBytes(reader.RemainingBytes); Stream embeddedSourceByteStream = new MemoryStream(embeddedSourceBytes); @@ -245,8 +247,10 @@ object ParseRowDetails(ref BlobReader reader) if (embeddedSourceFormat > 0) // positive length means the data is compressed using DEFLATE embeddedSourceByteStream = new System.IO.Compression.DeflateStream(embeddedSourceByteStream, System.IO.Compression.CompressionMode.Decompress); - var textReader = new StreamReader(embeddedSourceByteStream, detectEncodingFromByteOrderMarks: true); - return textReader.ReadToEnd(); + // Disposing the reader disposes the whole wrapped chain (DeflateStream's + // inflater would otherwise wait for its finalizer). + using var textReader = new StreamReader(embeddedSourceByteStream, detectEncodingFromByteOrderMarks: true); + return new TextBlobDetail(textReader.ReadToEnd(), GetEmbeddedSourceExtension()); } if (kind == KnownGuids.CompilationOptions) { @@ -281,7 +285,21 @@ object ParseRowDetails(ref BlobReader reader) list.Add(new TupleElementNameDetail(reader.ReadUTF8StringNullTerminated())); return list; } - return reader.ToHexString(); + return new TextBlobDetail(reader.ToHexString()); + } + + string? GetEmbeddedSourceExtension() + { + // The parent of an embedded-source row is the Document whose text the blob + // holds; the document name's file extension selects the syntax highlighting + // for the decoded source. + if (debugInfo.Parent.Kind != HandleKind.Document) + return null; + var document = metadataFile.Metadata.GetDocument((DocumentHandle)debugInfo.Parent); + if (document.Name.IsNil) + return null; + string extension = Path.GetExtension(metadataFile.Metadata.GetString(document.Name)); + return string.IsNullOrEmpty(extension) ? null : extension; } public CustomDebugInformationEntry(MetadataFile metadataFile, CustomDebugInformationHandle handle) diff --git a/ILSpy/Metadata/MetadataRowDetails.cs b/ILSpy/Metadata/MetadataRowDetails.cs index a9a42c3180..2d50d8e114 100644 --- a/ILSpy/Metadata/MetadataRowDetails.cs +++ b/ILSpy/Metadata/MetadataRowDetails.cs @@ -22,10 +22,12 @@ using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Data; using Avalonia.Media; +using ICSharpCode.ILSpy.TextView; using ICSharpCode.ILSpy.ViewModels; namespace ICSharpCode.ILSpy.Metadata @@ -56,6 +58,13 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang } } + /// + /// Decoded text payload of a row (embedded source, source-link JSON, hex dump), tagged + /// with the file extension (".cs", ".json") that selects the syntax highlighting for its + /// display, or for plain text. + /// + public sealed record TextBlobDetail(string Text, string? HighlightExtension = null); + /// /// Factories for the row-details area of metadata grids: the DataContext-tracking shell /// template plus the content shapes shared across tables (flag-bit breakdown, decoded @@ -109,16 +118,29 @@ public static Control BuildFlagsGrid(IList bits) return grid; } - /// Read-only, word-wrapped view of a decoded text blob (source, JSON, hex). - public static Control BuildTextBlob(string text) + /// + /// Read-only, word-wrapped view of a decoded text blob (source, JSON, hex), rendered + /// in the theme-aware AvaloniaEdit editor: text payloads are mostly code, so they get + /// syntax colours (selected by , e.g. ".cs" or + /// ".json"; stays plain) and line virtualization keeps large + /// embedded-source documents cheap. Height is bounded so the host row cannot grow + /// unbounded; the editor scrolls internally. + /// + public static Control BuildTextBlob(string text, string? highlightExtension = null) { ArgumentNullException.ThrowIfNull(text); - return new TextBox { + var editor = new DecompilerTextEditor { Text = text, IsReadOnly = true, - TextWrapping = TextWrapping.Wrap, + WordWrap = true, MaxHeight = 400, + FontFamily = new FontFamily("Consolas, Menlo, Monospace"), + FontSize = 13, }; + if (highlightExtension != null) + editor.SyntaxHighlighting = HighlightingService.GetByExtension(highlightExtension); + editor.Bind(TemplatedControl.BackgroundProperty, editor.GetResourceObservable("ILSpy.EditorBackground")); + return editor; } /// diff --git a/ILSpy/TextView/DecompilerTextEditor.cs b/ILSpy/TextView/DecompilerTextEditor.cs index 8dac10e4cc..b0e5a89f88 100644 --- a/ILSpy/TextView/DecompilerTextEditor.cs +++ b/ILSpy/TextView/DecompilerTextEditor.cs @@ -36,11 +36,6 @@ namespace ICSharpCode.ILSpy.TextView /// public class DecompilerTextEditor : TextEditor { - public DecompilerTextEditor() - { - ThemeManager.Current.ThemeChanged += OnThemeChanged; - } - // Avalonia resolves the control template via the runtime type; subclasses of a // templated control inherit the base template only when StyleKeyOverride is // pointed at the base. Without this override AvaloniaEdit's template doesn't @@ -61,6 +56,13 @@ void OnThemeChanged(object? sender, EventArgs e) TextArea?.TextView?.Redraw(); } + protected override void OnAttachedToVisualTree(global::Avalonia.VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + ThemeManager.Current.ThemeChanged += OnThemeChanged; + TextArea?.TextView?.Redraw(); + } + protected override void OnDetachedFromVisualTree(global::Avalonia.VisualTreeAttachmentEventArgs e) { ThemeManager.Current.ThemeChanged -= OnThemeChanged; From b83216454d4b5c058eee33938a6dc27fbcca4de2 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 29 Jul 2026 17:56:49 +0200 Subject: [PATCH 3/6] Style metadata row-details editors like the decompiler view The text-blob editor in the metadata row-details area hardcoded its font and lacked the text view's flat selection highlight, so embedded source looked different from the decompiled code right next to it and ignored the user's font choice in the Options page. The decompiler-view look (user-selected font applied live while attached, themed background, square-cornered translucent selection) now lives in DecompilerTextEditor itself, giving every surface hosting the editor the same appearance by construction; DecompilerTextView and BuildTextBlob drop their now redundant per-site styling. Assisted-by: Claude:claude-fable-5:Claude Code --- .../Metadata/MetadataRowDetailsTests.cs | 48 ++++++++++++ ILSpy/Metadata/MetadataRowDetails.cs | 15 ++-- ILSpy/TextView/DecompilerTextEditor.cs | 75 +++++++++++++++++-- ILSpy/TextView/DecompilerTextView.axaml | 19 +---- ILSpy/TextView/DecompilerTextView.axaml.cs | 13 +--- 5 files changed, 129 insertions(+), 41 deletions(-) diff --git a/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs b/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs index 6228bf626d..2f0ec9282b 100644 --- a/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs +++ b/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs @@ -28,6 +28,7 @@ using AwesomeAssertions; +using ICSharpCode.ILSpy.AppEnv; using ICSharpCode.ILSpy.Metadata; using ICSharpCode.ILSpy.TextView; using ICSharpCode.ILSpy.ViewModels; @@ -198,6 +199,53 @@ public void Text_Blob_Is_A_Read_Only_Editor_With_Extension_Driven_Highlighting() unknown.SyntaxHighlighting.Should().BeNull("an unrecognized extension degrades to plain text"); } + [AvaloniaTest] + public void Text_Blob_Editor_Uses_The_Decompiler_View_Styling() + { + // The details editor is a second surface showing code, so it must look like the main + // decompiler view: the user-selected editor font (applied live, the same way the text + // view reacts to the Options page), the flat square-cornered selection highlight, and + // the themed editor background. + var settings = AppComposition.Current.GetExport().DisplaySettings; + var originalFont = settings.SelectedFont; + var originalSize = settings.SelectedFontSize; + var editor = (DecompilerTextEditor)MetadataRowDetails.BuildTextBlob("class C { }", ".cs"); + var window = new Window { Content = editor }; + try + { + settings.SelectedFont = "Liberation Mono"; + settings.SelectedFontSize = 17; + window.Show(); + + editor.FontFamily.Name.Should().Be("Liberation Mono", + "the details editor renders in the user-selected editor font"); + editor.FontSize.Should().Be(17); + + settings.SelectedFontSize = 21; + editor.FontSize.Should().Be(21, "font settings apply live while the details row is open"); + + editor.TextArea.SelectionCornerRadius.Should().Be(0, + "selection styling matches the decompiler view (flat, square corners)"); + window.TryFindResource("ILSpy.EditorSelectionBrush", window.ActualThemeVariant, out var selectionBrush) + .Should().BeTrue(); + editor.TextArea.SelectionBrush.Should().Be(selectionBrush); + window.TryFindResource("ILSpy.EditorBackground", window.ActualThemeVariant, out var background) + .Should().BeTrue(); + editor.Background.Should().Be(background); + + window.Close(); + settings.SelectedFontSize = 13; + editor.FontSize.Should().Be(21, + "an editor detached by row recycling must stop tracking the settings instance"); + } + finally + { + window.Close(); + settings.SelectedFont = originalFont; + settings.SelectedFontSize = originalSize; + } + } + [AvaloniaTest] public async Task Double_Tap_Inside_The_Details_Area_Does_Not_Resolve_To_An_Activatable_Row() { diff --git a/ILSpy/Metadata/MetadataRowDetails.cs b/ILSpy/Metadata/MetadataRowDetails.cs index 2d50d8e114..a0bc9607a0 100644 --- a/ILSpy/Metadata/MetadataRowDetails.cs +++ b/ILSpy/Metadata/MetadataRowDetails.cs @@ -22,10 +22,8 @@ using Avalonia; using Avalonia.Controls; -using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Data; -using Avalonia.Media; using ICSharpCode.ILSpy.TextView; using ICSharpCode.ILSpy.ViewModels; @@ -120,11 +118,11 @@ public static Control BuildFlagsGrid(IList bits) /// /// Read-only, word-wrapped view of a decoded text blob (source, JSON, hex), rendered - /// in the theme-aware AvaloniaEdit editor: text payloads are mostly code, so they get - /// syntax colours (selected by , e.g. ".cs" or - /// ".json"; stays plain) and line virtualization keeps large - /// embedded-source documents cheap. Height is bounded so the host row cannot grow - /// unbounded; the editor scrolls internally. + /// in the decompiler-view-styled AvaloniaEdit editor: text payloads are mostly code, + /// so they get syntax colours (selected by , + /// e.g. ".cs" or ".json"; stays plain) and line virtualization + /// keeps large embedded-source documents cheap. Height is bounded so the host row + /// cannot grow unbounded; the editor scrolls internally. /// public static Control BuildTextBlob(string text, string? highlightExtension = null) { @@ -134,12 +132,9 @@ public static Control BuildTextBlob(string text, string? highlightExtension = nu IsReadOnly = true, WordWrap = true, MaxHeight = 400, - FontFamily = new FontFamily("Consolas, Menlo, Monospace"), - FontSize = 13, }; if (highlightExtension != null) editor.SyntaxHighlighting = HighlightingService.GetByExtension(highlightExtension); - editor.Bind(TemplatedControl.BackgroundProperty, editor.GetResourceObservable("ILSpy.EditorBackground")); return editor; } diff --git a/ILSpy/TextView/DecompilerTextEditor.cs b/ILSpy/TextView/DecompilerTextEditor.cs index b0e5a89f88..5004c0fe7c 100644 --- a/ILSpy/TextView/DecompilerTextEditor.cs +++ b/ILSpy/TextView/DecompilerTextEditor.cs @@ -17,22 +17,34 @@ // DEALINGS IN THE SOFTWARE. using System; +using System.ComponentModel; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; using AvaloniaEdit; +using AvaloniaEdit.Editing; using AvaloniaEdit.Highlighting; using AvaloniaEdit.Rendering; +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.Options; using ICSharpCode.ILSpy.Themes; namespace ICSharpCode.ILSpy.TextView { /// - /// subclass that hooks two theme concerns: + /// subclass carrying the decompiler-view look, so every surface + /// showing code (the main text view, metadata row details) renders identically: /// (a) overrides so syntax highlighting goes /// through and adapts to the active theme; /// (b) listens for and forces a TextView redraw /// so an already-rendered editor picks up the new palette without needing the user - /// to scroll or reselect. + /// to scroll or reselect; + /// (c) follows the user-selected editor font ( / + /// ) live while attached; + /// (d) uses the themed editor background and selection highlight. /// public class DecompilerTextEditor : TextEditor { @@ -41,7 +53,23 @@ public class DecompilerTextEditor : TextEditor // pointed at the base. Without this override AvaloniaEdit's template doesn't // apply to us — meaning no ScrollViewer is installed, scroll offsets stay 0, // and Copy can't reach the editor's TextArea via the template lookup chain. - protected override System.Type StyleKeyOverride => typeof(TextEditor); + protected override Type StyleKeyOverride => typeof(TextEditor); + + DisplaySettings? displaySettings; + + public DecompilerTextEditor() + { + // Fallback font for hosts without display settings (e.g. bare test compositions); + // overwritten from DisplaySettings on attach. + FontFamily = new FontFamily("Consolas, Menlo, Monospace"); + FontSize = 13; + // Selected text keeps its syntax colours (ports icsharpcode/ILSpy#2938): + // SelectionForeground stays unset, and the selection is a flat, translucent + // highlight (square corners, no border) instead of a recoloured run. + TextArea.SelectionCornerRadius = 0; + TextArea.Bind(TextArea.SelectionBrushProperty, this.GetResourceObservable("ILSpy.EditorSelectionBrush")); + this.Bind(BackgroundProperty, this.GetResourceObservable("ILSpy.EditorBackground")); + } protected override IVisualLineTransformer CreateColorizer(IHighlightingDefinition highlightingDefinition) { @@ -56,15 +84,52 @@ void OnThemeChanged(object? sender, EventArgs e) TextArea?.TextView?.Redraw(); } - protected override void OnAttachedToVisualTree(global::Avalonia.VisualTreeAttachmentEventArgs e) + void OnDisplaySettingsChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(DisplaySettings.SelectedFont) or nameof(DisplaySettings.SelectedFontSize)) + ApplyFontSettings(); + } + + void ApplyFontSettings() + { + if (displaySettings == null) + return; + if (!string.IsNullOrEmpty(displaySettings.SelectedFont)) + FontFamily = new FontFamily(displaySettings.SelectedFont); + if (displaySettings.SelectedFontSize > 0) + FontSize = displaySettings.SelectedFontSize; + } + + static DisplaySettings? TryGetDisplaySettings() + { + try + { return AppComposition.Current.GetExport().DisplaySettings; } + catch { return null; } + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); ThemeManager.Current.ThemeChanged += OnThemeChanged; + // (Re-)apply the font on every attach: editors inside recycled containers + // (metadata row details) detach and re-attach, and settings may have changed + // while the editor was off the tree. + displaySettings = TryGetDisplaySettings(); + if (displaySettings != null) + { + ApplyFontSettings(); + displaySettings.PropertyChanged += OnDisplaySettingsChanged; + } TextArea?.TextView?.Redraw(); } - protected override void OnDetachedFromVisualTree(global::Avalonia.VisualTreeAttachmentEventArgs e) + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { + if (displaySettings != null) + { + displaySettings.PropertyChanged -= OnDisplaySettingsChanged; + displaySettings = null; + } ThemeManager.Current.ThemeChanged -= OnThemeChanged; base.OnDetachedFromVisualTree(e); } diff --git a/ILSpy/TextView/DecompilerTextView.axaml b/ILSpy/TextView/DecompilerTextView.axaml index 71c2462f0b..e3d1191171 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml +++ b/ILSpy/TextView/DecompilerTextView.axaml @@ -3,7 +3,6 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ae="using:AvaloniaEdit" - xmlns:aeediting="using:AvaloniaEdit.Editing" xmlns:textView="using:ICSharpCode.ILSpy.TextView" xmlns:omnibar="using:ICSharpCode.ILSpy.Controls.Omnibar" mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="400" @@ -11,16 +10,6 @@ x:Class="ICSharpCode.ILSpy.TextView.DecompilerTextView" x:DataType="textView:DecompilerTabPageModel"> - - - - - @@ -30,13 +19,11 @@ progress bar + cancel button while a decompilation is in flight. --> + and carries the shared decompiler-view styling: themed background and selection + highlight, user-selected editor font, redraw on theme-variant changes. --> + ShowLineNumbers="True" /> diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index c0b7700859..4bac40bff1 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -617,8 +617,9 @@ void WireUpDisplaySettings() void ApplyAllDisplaySettings(DisplaySettings s) { - ApplyDisplaySetting(s, nameof(DisplaySettings.SelectedFont)); - ApplyDisplaySetting(s, nameof(DisplaySettings.SelectedFontSize)); + // Font family/size and the themed background/selection are not handled here: + // DecompilerTextEditor itself follows those settings, shared with every other + // surface hosting the editor (metadata row details). ApplyDisplaySetting(s, nameof(DisplaySettings.ShowLineNumbers)); ApplyDisplaySetting(s, nameof(DisplaySettings.EnableWordWrap)); ApplyDisplaySetting(s, nameof(DisplaySettings.HighlightCurrentLine)); @@ -631,14 +632,6 @@ void ApplyDisplaySetting(DisplaySettings s, string? propertyName) { switch (propertyName) { - case nameof(DisplaySettings.SelectedFont): - if (!string.IsNullOrEmpty(s.SelectedFont)) - Editor.FontFamily = new FontFamily(s.SelectedFont); - break; - case nameof(DisplaySettings.SelectedFontSize): - if (s.SelectedFontSize > 0) - Editor.FontSize = s.SelectedFontSize; - break; case nameof(DisplaySettings.ShowLineNumbers): Editor.ShowLineNumbers = s.ShowLineNumbers; break; From 0ff41fb7c190dad6d35037646d08d6a886d8d203 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 29 Jul 2026 18:00:04 +0200 Subject: [PATCH 4/6] Give the row-details text blob a minimum height A one-line payload (short hex dump, tiny source-link document) rendered as a squeezed strip barely taller than the row itself, which does not read as an expandable details area. Floor the editor at 100px so the details region stays visually recognisable regardless of payload size. Assisted-by: Claude:claude-fable-5:Claude Code --- ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs | 1 + ILSpy/Metadata/MetadataRowDetails.cs | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs b/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs index 2f0ec9282b..061058f4e1 100644 --- a/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs +++ b/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs @@ -186,6 +186,7 @@ public void Text_Blob_Is_A_Read_Only_Editor_With_Extension_Driven_Highlighting() editor.IsReadOnly.Should().BeTrue(); editor.WordWrap.Should().BeTrue(); editor.MaxHeight.Should().Be(400, "the host row must stay bounded; the editor scrolls internally"); + editor.MinHeight.Should().Be(100, "a short payload must still get a recognisable details area, not a squeezed strip"); editor.SyntaxHighlighting.Should().NotBeNull(); editor.SyntaxHighlighting!.Name.Should().Be("C#"); diff --git a/ILSpy/Metadata/MetadataRowDetails.cs b/ILSpy/Metadata/MetadataRowDetails.cs index a0bc9607a0..a9c584e0d1 100644 --- a/ILSpy/Metadata/MetadataRowDetails.cs +++ b/ILSpy/Metadata/MetadataRowDetails.cs @@ -121,8 +121,10 @@ public static Control BuildFlagsGrid(IList bits) /// in the decompiler-view-styled AvaloniaEdit editor: text payloads are mostly code, /// so they get syntax colours (selected by , /// e.g. ".cs" or ".json"; stays plain) and line virtualization - /// keeps large embedded-source documents cheap. Height is bounded so the host row - /// cannot grow unbounded; the editor scrolls internally. + /// keeps large embedded-source documents cheap. Height is bounded in both directions: + /// capped so the host row cannot grow unbounded (the editor scrolls internally), and + /// floored so a short payload still reads as a details area rather than a squeezed + /// one-line strip. /// public static Control BuildTextBlob(string text, string? highlightExtension = null) { @@ -131,6 +133,7 @@ public static Control BuildTextBlob(string text, string? highlightExtension = nu Text = text, IsReadOnly = true, WordWrap = true, + MinHeight = 100, MaxHeight = 400, }; if (highlightExtension != null) From 96c7df3b461d0077a49fc48dfffa71c6d65181d4 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 29 Jul 2026 18:29:40 +0200 Subject: [PATCH 5/6] Give the row-details text blob its own Copy/Select All menu Right-clicking inside the details editor inherited the metadata grid's cell-oriented context menu, whose Copy entries are enabled only for a hovered DataGridCell -- inside the details area there is none, so the menu showed permanently disabled entries and selected text could not be copied by mouse. The editor now carries the decompiler view's editor menu shape: Copy (rich HTML copy with plain fallback, enabled while a selection exists) and Select All. Enablement is decided in ContextMenu.Opening, which only the context-request gesture raises; the test therefore raises ContextRequested instead of calling Open(). Assisted-by: Claude:claude-fable-5:Claude Code --- .../Metadata/MetadataRowDetailsTests.cs | 46 +++++++++++++++++++ ILSpy/Metadata/MetadataRowDetails.cs | 31 +++++++++++++ 2 files changed, 77 insertions(+) diff --git a/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs b/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs index 061058f4e1..a731c5758b 100644 --- a/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs +++ b/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs @@ -23,6 +23,8 @@ using Avalonia.Controls; using Avalonia.Headless.NUnit; +using Avalonia.Input; +using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.VisualTree; @@ -247,6 +249,50 @@ public void Text_Blob_Editor_Uses_The_Decompiler_View_Styling() } } + [AvaloniaTest] + public void Text_Blob_Editor_Carries_Its_Own_Copy_And_Select_All_Context_Menu() + { + // Without a menu of its own, a right-click inside the details editor inherits the + // metadata grid's cell-oriented context menu, whose Copy entries are bound to the + // hovered DataGridCell and therefore permanently disabled inside the details area. + // The editor must instead offer the decompiler-view editor menu shape: Copy + // following the selection, plus Select All. + var editor = (DecompilerTextEditor)MetadataRowDetails.BuildTextBlob("class C { }", ".cs"); + var window = new Window { Content = editor }; + window.Show(); + try + { + var menu = editor.ContextMenu; + menu.Should().NotBeNull("the editor must not inherit the metadata grid's cell menu"); + + // Raise the context-request gesture (what a right-click produces) rather than + // calling menu.Open(): only the gesture path raises ContextMenu.Opening, where + // the enablement of Copy is decided. + editor.RaiseEvent(new ContextRequestedEventArgs()); + menu!.IsOpen.Should().BeTrue(); + var items = menu.Items.OfType().ToList(); + items.Should().HaveCount(2); + var copy = items[0]; + var selectAll = items[1]; + copy.Header.Should().Be("Copy"); + selectAll.Header.Should().Be("Select All"); + copy.IsEnabled.Should().BeFalse("nothing is selected yet"); + menu.Close(); + + editor.Select(0, 5); + editor.RaiseEvent(new ContextRequestedEventArgs()); + copy.IsEnabled.Should().BeTrue("a non-empty selection makes Copy actionable"); + menu.Close(); + + selectAll.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent)); + editor.SelectionLength.Should().Be(editor.Text.Length); + } + finally + { + window.Close(); + } + } + [AvaloniaTest] public async Task Double_Tap_Inside_The_Details_Area_Does_Not_Resolve_To_An_Activatable_Row() { diff --git a/ILSpy/Metadata/MetadataRowDetails.cs b/ILSpy/Metadata/MetadataRowDetails.cs index a9c584e0d1..ac6b287e1a 100644 --- a/ILSpy/Metadata/MetadataRowDetails.cs +++ b/ILSpy/Metadata/MetadataRowDetails.cs @@ -24,6 +24,7 @@ using Avalonia.Controls; using Avalonia.Controls.Templates; using Avalonia.Data; +using Avalonia.Input; using ICSharpCode.ILSpy.TextView; using ICSharpCode.ILSpy.ViewModels; @@ -138,9 +139,39 @@ public static Control BuildTextBlob(string text, string? highlightExtension = nu }; if (highlightExtension != null) editor.SyntaxHighlighting = HighlightingService.GetByExtension(highlightExtension); + editor.ContextMenu = BuildEditorContextMenu(editor); return editor; } + /// + /// Editor context menu matching the decompiler view's editor entries (Copy / Select + /// All). The metadata grid's own context menu copies the hovered cell, which never + /// exists inside the details area — without a menu of its own the editor would + /// inherit those permanently disabled entries. + /// + static ContextMenu BuildEditorContextMenu(DecompilerTextEditor editor) + { + var copy = new MenuItem { + Header = Properties.Resources.Copy, + InputGesture = new KeyGesture(Key.C, KeyModifiers.Control), + }; + copy.Click += (_, _) => { + // Copy as text + syntax-coloured HTML; fall back to plain copy. + if (!HtmlClipboardCopy.Copy(editor)) + editor.Copy(); + }; + var selectAll = new MenuItem { + Header = Properties.Resources.Select, + InputGesture = new KeyGesture(Key.A, KeyModifiers.Control), + }; + selectAll.Click += (_, _) => editor.SelectAll(); + var menu = new ContextMenu(); + menu.Opening += (_, _) => copy.IsEnabled = editor.SelectionLength > 0; + menu.Items.Add(copy); + menu.Items.Add(selectAll); + return menu; + } + /// /// Sub-grid over parsed blob rows; one text column per (header, property) pair. /// From 5fe65f4a54a3b673e26d288b3db19ab60ef5e582 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Thu, 30 Jul 2026 21:17:39 +0200 Subject: [PATCH 6/6] Use a small fixture for tab-opening UI test The tab-opening test only verifies document tab and selection wiring. Using a tiny in-assembly fixture avoids cold framework decompilation work in CI and lowers the chance of unrelated timeout noise. Assisted-by: OpenCode:openai/gpt-5.5:OpenCode --- ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs b/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs index 48230d467e..01f2273b8c 100644 --- a/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs +++ b/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs @@ -1252,18 +1252,19 @@ public async Task Pane_OpenNodeInNewTab_Spawns_A_Fresh_Decompiler_Tab_And_Select // tab opens with the supplied node decompiled, the existing tab keeps its content, // and the assembly-tree selection is pulled across to the new tab's source node // (the active tab and the tree are kept in lockstep). - var (window, vm) = await TestHarness.BootAsync(3); + var (window, vm) = await TestHarness.BootAsync(); + var testAssembly = await vm.OpenAssemblyAsync(typeof(TabOpeningFixture).Assembly.Location); var typeNode = vm.AssemblyTreeModel.FindNode( - "System.Linq", "System.Linq", "System.Linq.Enumerable"); + testAssembly.ShortName, "ICSharpCode.ILSpy.Tests", "ICSharpCode.ILSpy.Tests.TabOpeningFixture"); typeNode.IsExpanded = true; var pinned = typeNode.Children.OfType() - .Single(m => m.MethodDefinition.Name == "AsEnumerable"); + .Single(m => m.MethodDefinition.Name == nameof(TabOpeningFixture.PinnedMethod)); var newTabTarget = typeNode.Children.OfType() - .First(m => m.MethodDefinition.Name == "Empty"); + .Single(m => m.MethodDefinition.Name == nameof(TabOpeningFixture.NewTabMethod)); vm.AssemblyTreeModel.SelectNode(pinned); var firstTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); - TestCapture.Step("asenumerable-in-first-tab"); + TestCapture.Step("fixture-pinned-method-in-first-tab"); await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType().Any()); var pane = await window.WaitForComponent(); @@ -1276,11 +1277,11 @@ public async Task Pane_OpenNodeInNewTab_Spawns_A_Fresh_Decompiler_Tab_And_Select await Waiters.WaitForAsync( () => (documents.VisibleDockables?.Count ?? 0) > initialCount); var newTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); - TestCapture.Step("empty-spawned-in-new-tab"); + TestCapture.Step("fixture-new-tab-method-spawned-in-new-tab"); ReferenceEquals(newTab, firstTab).Should().BeFalse( "a fresh decompiler tab must be created instead of reusing the existing one"); - newTab.Text.Should().Contain("Empty"); - firstTab.Text.Should().Contain("AsEnumerable"); + newTab.Text.Should().Contain(nameof(TabOpeningFixture.NewTabMethod)); + firstTab.Text.Should().Contain(nameof(TabOpeningFixture.PinnedMethod)); // Selection has moved to the new tab's source node — the active tab and the // assembly-tree selection stay in lockstep. ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, newTabTarget).Should().BeTrue( @@ -1810,3 +1811,16 @@ await Waiters.WaitForAsync(() => "freshly resolved dependencies are auto-loaded"); } } + +sealed class TabOpeningFixture +{ + public int PinnedMethod() + { + return 1; + } + + public int NewTabMethod() + { + return 2; + } +}