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; + } +} 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 e21ae845de..a731c5758b 100644 --- a/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs +++ b/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs @@ -23,11 +23,16 @@ using Avalonia.Controls; using Avalonia.Headless.NUnit; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; using Avalonia.VisualTree; using AwesomeAssertions; +using ICSharpCode.ILSpy.AppEnv; using ICSharpCode.ILSpy.Metadata; +using ICSharpCode.ILSpy.TextView; using ICSharpCode.ILSpy.ViewModels; using ICSharpCode.ILSpy.Views; @@ -147,11 +152,152 @@ 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 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.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#"); + + 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 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 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() { // 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 d90fb7d80e..ac6b287e1a 100644 --- a/ILSpy/Metadata/MetadataRowDetails.cs +++ b/ILSpy/Metadata/MetadataRowDetails.cs @@ -24,9 +24,9 @@ using Avalonia.Controls; using Avalonia.Controls.Templates; using Avalonia.Data; -using Avalonia.Layout; -using Avalonia.Media; +using Avalonia.Input; +using ICSharpCode.ILSpy.TextView; using ICSharpCode.ILSpy.ViewModels; namespace ICSharpCode.ILSpy.Metadata @@ -57,6 +57,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 @@ -105,22 +112,64 @@ 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; } - /// 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 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 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) { ArgumentNullException.ThrowIfNull(text); - return new TextBox { + var editor = new DecompilerTextEditor { Text = text, IsReadOnly = true, - TextWrapping = TextWrapping.Wrap, - MaxWidth = 800, + WordWrap = true, + MinHeight = 100, MaxHeight = 400, - HorizontalAlignment = HorizontalAlignment.Left, }; + 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; } /// @@ -143,6 +192,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 +207,6 @@ public static Control BuildDetailsGrid(IEnumerable rows, params (string Header, CanUserReorderColumns = false, CanUserSortColumns = false, SelectionMode = DataGridSelectionMode.Single, - HorizontalAlignment = HorizontalAlignment.Left, }; } } diff --git a/ILSpy/TextView/DecompilerTextEditor.cs b/ILSpy/TextView/DecompilerTextEditor.cs index 8dac10e4cc..5004c0fe7c 100644 --- a/ILSpy/TextView/DecompilerTextEditor.cs +++ b/ILSpy/TextView/DecompilerTextEditor.cs @@ -17,36 +17,59 @@ // 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 { - 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 // 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) { @@ -61,8 +84,52 @@ void OnThemeChanged(object? sender, EventArgs e) TextArea?.TextView?.Redraw(); } - protected override void OnDetachedFromVisualTree(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(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;