Skip to content

Avalonia 12 Port and Removal of the WPF UI#3755

Merged
christophwille merged 530 commits into
icsharpcode:masterfrom
siegfriedpammer:avalonia
Jun 10, 2026
Merged

Avalonia 12 Port and Removal of the WPF UI#3755
christophwille merged 530 commits into
icsharpcode:masterfrom
siegfriedpammer:avalonia

Conversation

@siegfriedpammer

@siegfriedpammer siegfriedpammer commented Jun 10, 2026

Copy link
Copy Markdown
Member

NOTE: The below summary/overview of the changes in this PR was generated by an LLM and reviewed by me.
Please report any issues or missing features you might encounter.

NOTE: Currently, our CI only provides pre-built artifacts for Windows.

Migrate the ILSpy desktop UI to cross-platform Avalonia 12

Summary

  • Replaces ILSpy's WPF desktop application with a cross-platform UI built on Avalonia 12,
    running on Windows, Linux, and macOS.
  • The decompiler engine (ICSharpCode.Decompiler, ICSharpCode.ILSpyX) was already cross-platform
    and is carried over largely unchanged; the work here is the UI host, its supporting
    infrastructure, and the build/CI changes needed to ship a cross-platform app.
  • Scope: 527 commits, 1023 files, +87.7k / -33.2k. master is fully merged in (0 commits
    behind). Forked at e0954474a; opens with "Remove WPF ILSpy host (prep for Avalonia rewrite)".

Tech stack

  • Avalonia 12 UI (Simple theme), AvaloniaEdit code view, Dock (@wieslawsoltes) layout,
    ProDataGrid hierarchical grids, Avalonia.Xaml.Behaviors.
  • Microsoft.Extensions.DependencyInjection + System.Composition MEF, with a small bridge.
  • Main app targets net10.0 (cross-platform); test projects target net11.0 to match the
    preview SDK.

WPF -> Avalonia: what changed

The headline change is the UI host itself: the WPF ILSpy.exe (AvalonDock, WPF SharpTreeView,
FlowDocument tooltips, WPF theming) is replaced by an Avalonia 12 app (Dock library, AvaloniaEdit,
Avalonia-native tree + tooltips). The decompiler core is unchanged, so the comparison below is about
how the app surface stacks up against the WPF original (master).

At parity

The day-to-day feature surface is ported and working: the assembly tree and all node types,
drag-and-drop and assembly-list management, the decompiler languages (C#, IL, "IL with C#") with
C#-version selection, hyperlink navigation, hover tooltips, code folding, in-document search,
copy-with-syntax-highlighting (HTML clipboard), the output-length guard, Search (all modes, mode
hotkeys, middle-click open-in-new-tab), Analyze, the Metadata explorer, navigation history
(back/forward), Save Code, Generate Portable PDB, Open-from-GAC, command-line handling, plugin
loading, and the per-node / text-view context menus.

Where Avalonia is ahead of WPF

  • Cross-platform. Runs on Windows, Linux, and macOS; the WPF app was Windows-only.
  • Richer Metadata explorer — per-column filters, [Flags] value popups, persisted filter
    state, token hyperlinks with Ctrl+G go-to-token, and copy-cell / copy-row-as-TSV.
  • First-class project/solution export — dedicated File-menu and context entries with live
    progress tabs (determinate bar showing the current file + remaining count); WPF folded export
    into "Save Code".
  • C# AST-transform stepping in the Debug Steps pane (one step per transform via a shared
    IDebugStepProvider), replacing the old per-transform dropdown "languages"; the ILAst /
    BlockIL views are now visible in Release.
  • Keyboard ergonomics — zoom chords (Ctrl+0/+/-), folding chords (Ctrl+M / Ctrl+Shift+M),
    and menu gesture hints.
  • Multi-row document tab strip (resolves Allow mutiline tabs #3753)
  • Frozen tabs and Preview tab semantics (resolves [Feature request] Ability to freeze/lock decompiler tabs #2474)

Known gaps vs WPF

  • UI-language menu — WPF's View -> UI Language culture switcher now removed completely.
  • Theme set reduced to Light / Dark — WPF also shipped VS Code Light+/Dark+ and R# Light/Dark;
    those variants are not ported.
  • Options page is live-apply with a Reset button only — no modal OK / Cancel snapshot, so edits
    cannot be previewed and discarded as a unit.
  • XML-doc cref resolution dropped — hover tooltips show the raw cref text instead of a
    resolved, navigable entity link (cross-assembly resolution is wired to null).
  • Local-reference highlight colors are hardcoded and do not follow dark mode.
  • "Style window title bar" setting persists but has no checkbox and no consumer (dead).
  • "Open with ILSpy" shell integration (Misc page) is deferred — Windows-only, pending file-
    association registration.
  • Minor behavioral differences remain (e.g. the folding-toggle context entry acts on the caret line
    rather than the right-clicked line; no Pdb2Xml DEBUG context entry).

Build & CI (supporting the above)

  • Warnings-as-errors repo-wide; vulnerable transitive packages overridden (the System.Net.Http /
    System.Private.Uri / System.Text.RegularExpressions 4.3.0 trio from the DiaSymReader chain).
  • RID-complete lock files so dotnet publish -r <rid> works in locked mode on any host.
  • Cross-platform pre-commit format hook; Windows-CI fixes (7z exclusion quoting, net10.0
    publish-path correction). VS add-ins and WiX installer remain Windows-only net472.

Issues addressed

Testing

  • 204 changed/added files under ILSpy.Tests -- headless Avalonia NUnit coverage
    (TestHarness.BootAsync, Waiters) across docking, tree, metadata, search, options reactions,
    and export.

Notes / follow-ups

  • Windows-only frontends (VS add-ins, WiX installer, BAML-decompiler tests) stay net472 /
    net11.0-windows and build on Windows only.
  • README and CLAUDE.md updated for the cross-platform reality.

Screenshots (past versions, might not reflect the current state 100%)

image image (source: https://hachyderm.io/@dodowithcap@mastodon.social/116701799621871742) image

Assisted-by: Claude:claude-opus-4-7:Claude Code

Assisted-by: Claude:claude-opus-4-7:Claude Code
Assisted-by: Claude:claude-opus-4-7:Claude Code

Assisted-by: Claude:claude-opus-4-7:Claude Code
Cherry-picked from master commits 4c8e606 + 4c347ef. Refresh (F5) on a
selection inside a lazy-loaded resource tree (e.g. a .baml entry inside an
embedded .resources file) was collapsing the selection back to the resources
folder because FindNodeByPath ran before the assembly's metadata-file
finished loading and the resource children hadn't materialised yet.

Assisted-by: Claude:claude-opus-4-7:Claude Code
Three coupled wires that all hang off the assembly-list-changed signal:

Assisted-by: Claude:claude-opus-4-7:Claude Code
1) post-refresh-redecompile: AssemblyTreeModel.RefreshInternalAsync now calls
   DockWorkspace.ForceRefreshActiveTab() after SelectNode. F5 on the same
   assembly list doesn't rebuild the tree → FindNodeByPath returns the same
   tree-node reference → SelectedItem setter early-outs → ShowSelectedNode's
   lastShownNodes dedup short-circuits, leaving stale decompiled text. The
   force path resets lastShownNodes and re-runs the decompile pipeline.
   Mirrors WPF's RefreshDecompiledView() call.

Assisted-by: Claude:claude-opus-4-7:Claude Code
Persists the live dock layout to ILSpy.Layout.json next to ILSpy.xml on
MainWindow.OnClosing; loads it on DockWorkspace ctor; falls back to
factory.CreateLayout when the file is absent or fails to deserialize.
WPF stays XML in ILSpy.xml — this is Avalonia-side only.

Assisted-by: Claude:claude-opus-4-7:Claude Code
Two missing toolbar buttons (Manage Assembly Lists, Show Search) and an
incorrect button order; the layout now mirrors WPF MainToolBar.xaml +
InitToolbar exactly. Also fixes an InitializeButtons no-op under
Avalonia.Headless (Loaded never fired; switched to AttachedToVisualTree).

Assisted-by: Claude:claude-opus-4-7:Claude Code
Imports the three SVG assets that were missing from `ILSpy.Avalonia/Assets/Icons/`
and registers them in `Images/Images.cs`, so the MEF-injected toolbar buttons
for "Reload all assemblies", "Sort assembly list by name", and "Collapse all
tree nodes" now render their icons instead of falling back to the tooltip text
(`MainToolBar.axaml.cs:226` `ResolveIcon` returned null for these). Adds a
regression test asserting every `[ExportToolbarCommand]`'s `ToolbarIcon`
resolves to a real `IImage` field on the registry.
LMB double-click is WPF's expand/collapse gesture, already wired through
`OnTreeGridDoubleTapped`. An earlier patch also added it as a third "open in
new tab" gesture in `OnTreeGridPointerPressed`, so users got BOTH effects on
every double-click (the row toggled *and* a new tab opened). Restore parity
by removing the `isDoubleClick` branch from `OnTreeGridPointerPressed`; MMB
single-click and Ctrl+LMB single-click remain the two new-tab gestures.
Regression test in `HeadlessMmbPointerTests`.
The user-reported "Debug Steps pane is empty after clicking Show Steps" bug
was a View-lifecycle issue: `DebugStepsPaneModel` declares
`IsVisibleByDefault = false`, so the `DebugSteps` UserControl was realised by
the dock factory only when its tab was first activated. By then
`BlockILLanguage.DecompileMethod` had already fired `OnStepperUpdated` into
the void — no subscriber existed yet.
The three editor-command tests (Copy_Entry_Reflects, Copy_Execute, SelectAll_Execute)
selected `System.Linq.Enumerable` (the whole type, 300+ LINQ methods) and waited
on `view.Editor.Document.TextLength > 0` — a 15s `WaitForAsync` poll. In suite
context this was sloppy: it could latch on stale editor content from a previous
test, and in isolation it occasionally raced past 15s on a slow system,
producing the intermittent `failed: 1` we saw earlier.

Assisted-by: Claude:claude-opus-4-7:Claude Code
Without per-dockable view resolution the dock chrome caches one view per
slot and reuses it for every dockable that lands there, so a second pane
sharing an Alignment (Analyzer + Debug Steps both Bottom) renders the
first pane's content under the second pane's tab. Each dockable instead
owns its view (IDockableViewOwner) and DockableViewRecycling hands that
instance back keyed by dockable identity -- there is no app-lifetime
global view cache to leak every transient document tab's view subtree.

Resolution runs through the single application-wide ViewLocator (its
dispatch map is introduced here rather than in the later DataTemplate
cleanup) so the dock never re-resolves an owned view through the template
machinery on a cache hit.

Assisted-by: Claude:claude-opus-4-8:Claude Code
Replaces the reflection-driven Dock.Serializer.SystemTextJson._options
mutation with a from-scratch JsonSerializerOptions in ILSpyDockJson.cs.
Mirrors Newtonsoft's ListContractResolver semantics — polymorphism on
the six IDockable interfaces, IList<T> -> ObservableCollection<T>
substitution at deserialise time, writable-only property persistence,
the standard ignore set (ICommand, [IgnoreDataMember]) — and keeps the
existing back-reference / Task-shape strip plus the singleton-dockable
CreateObject hook.

Assisted-by: Claude:claude-opus-4-7:Claude Code
Generalises the existing internal StartupLog into a public AppLog with
named categories. The Startup category (default-on) keeps the existing
[startup +Nms] format and Mark/Phase API, so all 44 call sites get a
mechanical StartupLog. -> AppLog. rename without behavioural change.

Assisted-by: Claude:claude-opus-4-7:Claude Code
Sweep-up commit. The dotnet-format pre-commit hook keeps re-ordering
these usings on every other commit; landing them once stops the hook
from grumbling at unrelated diffs going forward.

Assisted-by: Claude:claude-opus-4-7:Claude Code
Ports the WPF assemblyList_CollectionChanged history-prune from
ILSpy/AssemblyTree/AssemblyTreeModel.cs to the Avalonia
DockWorkspace.OnAssemblyListChanged handler. NavigationHistory<T>
already exposed the RemoveAll(Predicate<T>) primitive; the caller
wiring was the missing piece. Without it, a tree-row click after
removing an assembly walked through NavigationEntry.DisplayText
on a stale TreeNodeEntry, which hit MemberReferenceTreeNode.Signature
-> Language.EntityToString -> ILAmbience.ConvertSymbol and NRE'd on
a now-null ParentModule.

Assisted-by: Claude:claude-opus-4-7:Claude Code
DockWorkspace previously subscribed to IFactory.ActiveDockableChanged
hoping it would fire on tab-strip clicks. It doesn't — Dock's
FactoryBase only raises that event from InitActiveDockable (layout
structural init at startup/load). User-driven tab clicks set
dock.ActiveDockable = X directly on the dock-model, which raises the
model's own INotifyPropertyChanged but bypasses the factory event.

Assisted-by: Claude:claude-opus-4-7:Claude Code
Stepper.Step's break-at-step-limit relies on Debugger.Break(), which
is a silent no-op when no debugger is attached. The wiring (command ->
RestartDecompileWithStepLimit -> DecompilationOptions.IsDebug ->
context.Stepper.IsDebug -> Stepper.Step) was all correct, but the
common-case "user runs from a terminal" left the gesture invisible.

Assisted-by: Claude:claude-opus-4-7:Claude Code
UpdateLastDocumentCanClose was counting only factory.Documents.VisibleDockables
to decide CanClose. After the user drags a tab out into a side-by-side
split, Dock creates a second IDocumentDock that holds the dragged tab.
factory.Documents still points at the ORIGINAL dock (now with one tab),
so the handler updates CanClose=false on its remaining tab — but the
new sibling dock's tab keeps the CanClose=true it had before the drag.
Result: one tab closable, the other not, even though the user has two
documents open.

Assisted-by: Claude:claude-opus-4-7:Claude Code
The default ApiVisibility=PublicOnly setting filters search results
through CheckVisibility, which rejects private entities. Compiler-
generated names — async/iterator state machines (<Method>d__N),
display classes (<>c__DisplayClass), anonymous-method closures (<>c)
— are all private, so they were unfindable even when the user
typed the exact full name.

Assisted-by: Claude:claude-opus-4-7:Claude Code
Language.BracketSearcher defaulted to DefaultBracketSearcher
(no-op); only CSharpLanguage overrode it. Switching the active
language to ILAst or IL left caret-on-bracket inert.

Assisted-by: Claude:claude-opus-4-7:Claude Code
Build the layout and call InitLayout up front in DockWorkspace instead of
letting DockControl run it post-template-apply, and populate the locators
explicitly: tool panes map their id to the [Shared] singleton pane, the
Documents dock and main tab get entries for navigate-by-id, and
HostWindowLocator is registered so panes can float into their own windows.
Doing it before the chrome attaches keeps the layout's owner/factory
wiring ahead of the first content realisation.

Also drops the now-redundant per-VM DataTemplate blocks from App.axaml --
the single ViewLocator already resolves them -- and the unused
MainWindowViewModel.DockFactory property and DockControl init flags.

Assisted-by: Claude:claude-opus-4-8:Claude Code
Two halves to the fix:

Assisted-by: Claude:claude-opus-4-7:Claude Code
The "Compare..." right-click flow opened a tab that came up blank because
two stacked bugs masked each other.

Assisted-by: Claude:claude-opus-4-7:Claude Code
User-reported: opening multiple document tabs and restarting brings them
all back broken except the first one.

Assisted-by: Claude:claude-opus-4-7:Claude Code
User-reported: Ctrl+LMB on an assembly-tree row both toggled the row in
the multi-selection AND opened a new decompiler tab. ProDataGrid's
Extended-selection mode treats Ctrl+LMB as a toggle, and
OnTreeGridPointerPressed also treated it as "open in new tab", so every
Ctrl+click fired both effects regardless of the e.Handled=true on our
handler — selection lives in the DataGrid template's own pre-bubble
pipeline.

Assisted-by: Claude:claude-opus-4-7:Claude Code
WPF parity: under DEBUG, the language dropdown gains one extra entry per
C# AST transform — "C# - no transforms", "C# - after FirstTransformName",
… "C# - after LastTransformName" — so a developer can pick a step and
see the decompiler's mid-pipeline AST output.

Assisted-by: Claude:claude-opus-4-7:Claude Code
User-reported: editor hover tooltips were not wide enough to show full
method signatures. Root cause was a three-way layout interaction —
DocumentationRenderer.AddSignatureBlock created the signature
SelectableTextBlock with TextWrapping.NoWrap, the outer Border capped
MaxWidth at 600px, and the wrapping ScrollViewer disabled horizontal
scrolling. So anything past 600px was simply clipped, with no way to
reveal the rest.

Assisted-by: Claude:claude-opus-4-7:Claude Code
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
Adds TabPageMenuItem (mirrors ToolPaneMenuItem) and a live ObservableCollection
on DockWorkspace kept in sync with factory.Documents.VisibleDockables. MainMenu
appends a separator + radio-style MenuItem per tab, with Header bound to Title
and IsChecked bound to IsActive. WPF parity for the previously-skipped tabs
section of the Window menu.

Assisted-by: Claude:claude-opus-4-7:Claude Code
DecompileAsync and ShowText both pushed the same eight properties (syntax
extension, highlighting model + spans, foldings, references, definition
lookup, UI elements, text) onto the page. Fold them into ApplyOutput,
which reads the collateral straight off the now-immutable output and takes
the syntax extension and text as parameters -- so the decompile path keeps
computing GetText off the UI thread and supplies it, while ShowText passes
its own. Drops the six hand-captured locals in the decompile apply step.

Assisted-by: Claude:claude-opus-4-8:Claude Code
The eight DecompileX overloads each opened with the same five lines:
resolve the module, build the decompiler, emit both reference warnings and
the assembly-name comment. Fold that into BeginDecompile(IEntity, ...),
which returns the decompiler; each overload then adds only its own (varying)
type-header comment. The three DecompileExtension overloads, identical bar
the comment target, now share a DecompileExtensionCore.

Assisted-by: Claude:claude-opus-4-8:Claude Code
The drag-drop / file-drop callback selected the dropped assemblies with a
raw SelectedItems.Clear() + Add() loop, which flashes a transient empty
selection. The model has SelectNodes precisely to avoid that -- it brackets
the swap so the selection-changed fan-out fires once, with the final set,
never empty. A transient empty poisons the grid-sync deferred guard, after
which the tree stops following tab activation. Route the callback through
SelectNodes; a regression test asserts no empty SelectedItem is observed
mid-drop.

Assisted-by: Claude:claude-opus-4-8:Claude Code
The UseNestedNamespaceNodes and HideEmptyMetadataTables handlers both
force a materialised node to re-create its children (clear, re-arm
LazyLoading, EnsureLazyChildren), guarded on it not already being lazy.
That's a general tree-node operation -- a soft, in-place rebuild that
re-runs LoadChildren without reloading the assembly -- so it belongs on
SharpTreeNode next to the lazy contract it builds on, not as a private
helper in the model. Both call sites now use node.ReloadChildren().

Assisted-by: Claude:claude-opus-4-8:Claude Code
The Options page is non-modal and live-apply, so -- unlike the WPF host,
which ran a full assembly-list Refresh() when its modal Options dialog
closed -- a setting that changes the decompiler/disassembler output (brace
folding, member/using expansion, debug info, IL detail, indentation) had
nothing to make it take effect; toggling it did nothing until the user
re-navigated.

Classify every DisplaySettings property in one table (DisplaySettingReactions:
editor-live / tree-text / tree-shape / re-decompile / none), grounded in the
actual consumers (ApplyDisplaySettings, GetIndentationString, the IL/mixed
languages). OnSettingsChanged dispatches on it, and a coverage test asserts
the table spans every settable property so a newly-added one can't silently
fall through.

Fixing the re-decompile case exposed that ForceRefreshActiveTab was itself a
no-op for an unchanged node -- ShowSelectedNode re-sets CurrentNodes, whose
setter dedups -- so RefreshDecompiledView (also used after dependency
resolution) never actually re-ran. Add DecompilerTabPageModel.Redecompile()
to force past the dedup and call it from ForceRefreshActiveTab.

Also drop the EnableSmoothScrolling setting: it drove TomsToolbox's
AdvancedScrollWheelBehavior in WPF, which the Avalonia port doesn't use and
never replaced, so the checkbox persisted a value nothing read.

Assisted-by: Claude:claude-opus-4-8:Claude Code
WriteKeyword/WritePrimitiveType/WriteIdentifier/WritePrimitiveValue each
ended with the same begin-span / base-write / end-span guard. Replace it
with `using (Colored(color)) base.WriteX(...)`, backed by an allocation-free
ref-struct scope so the per-token hot path takes no closure.

Assisted-by: Claude:claude-opus-4-8:Claude Code
WriteKeyword coloured every modifier the decompiler emits except
'required' (C# 11 required members), which fell through and rendered in
the default text colour. Add it to the modifiers group.

Assisted-by: Claude:claude-opus-4-8:Claude Code
The C# 13 'allows ref struct' anti-constraint is emitted as a single
PrimitiveType token (TypeSystemAstBuilder), so it reached WritePrimitiveType
rather than WriteKeyword and fell through uncoloured. Add it to the
value-type keyword group alongside the other constraint, 'unmanaged'.

Assisted-by: Claude:claude-opus-4-8:Claude Code
ApplyDocument was a 116-line god-method and the constructor a 112-line wall
of unrelated wiring. Both are linear sequences with no shared control flow,
so split them into intention-named steps:

ApplyDocument (now ~40 lines) orchestrates RebuildFoldings,
RestoreOrResetViewState and SwapCustomElementGenerators; the constructor
(now ~30 lines) calls SetupElementGenerators / SetupBackgroundRenderers /
SetupHoverHandlers / SetupContextMenuAndHyperlinks / SetupRichPopup /
SetupZoomAndCopy. Order is preserved exactly. Pure refactor, no behaviour
change.

The five renderer/generator/popup fields lose `readonly` since they are now
assigned in the ctor-only Setup helpers rather than the ctor body; they are
still single-assignment in practice.

Assisted-by: Claude:claude-opus-4-8:Claude Code
AssemblyTreeModel carried a ~180-line node-finding subsystem -- resolve a
reference (entity / namespace / resource / assembly / metadata file) or a
saved path to its tree node -- that has nothing to do with the class's
selection and list-lifecycle responsibilities. Lift the implementation into
a TreeNodeLocator, with the locator taking the tree root and assembly list
as parameters so it stays stateless.

The model keeps its public FindTreeNode / FindNodeByPath / GetPathForNode as
thin delegators (they're called from DockWorkspace, SearchPaneModel,
AssemblyTreeNode and tests), so no external call site changes -- the god
class just sheds the implementation.

Assisted-by: Claude:claude-opus-4-8:Claude Code
DockWorkspace carried the reflection-heavy metadata-grid resolution: read a
clicked token cell, resolve a double-clicked row to its tree node, and find
the metadata-table node a token points at -- with the "read the row's
metadataFile private field + int token" reflection block copy-pasted between
the cell-click and row-activation paths. As the WPF host kept this metadata
logic out of the main workspace, lift it into a MetadataNavigator service.

DockWorkspace keeps the dock-side actions (select, scroll-to-row, history
stamping, open-in-new-tab) and now delegates resolution; the public
NavigateToToken / TryResolveRowToTreeNode surface is unchanged, so the
external callers (ShowInMetadata, DecompileMetadataRowInNewTab) don't move.
The duplicated reflection collapses into one TryReadRow.

Assisted-by: Claude:claude-opus-4-8:Claude Code
The 23 typed table-row viewmodels each inlined the same
MetadataOffset + GetTableMetadataOffset + GetTableRowSize * (rid - 1)
arithmetic, and the four heap tree nodes each repeated an identical
lazy-cache / "{name} Heap (count)" header / CreateTab shell that differed
only in the heap name and how rows are walked. A single mistyped
TableIndex or a divergent cache shell would have been invisible across
that many copies.

Add MetadataTableTreeNode.GetRowOffset (with a MetadataReader+offset
overload for the one entry that lacks a MetadataFile) and a generic
MetadataHeapTreeNode<TEntry> base carrying the cache, header, and tab,
leaving subclasses to supply only the name and row materialiser.

Assisted-by: Claude:claude-opus-4-8:Claude Code
The Microsoft.DiaSymReader / .PortablePdb packages pull NETStandard.Library
1.6.1, which drags in 4.3.0 builds of System.Net.Http, System.Private.Uri
and System.Text.RegularExpressions, all carrying known advisories (NU1902/
NU1903). They are framework-provided at runtime on net10.0; pin the patched
4.3.4 / 4.3.2 / 4.3.1 via central transitive pinning so NuGet audit passes.
Enabling transitive pinning also aligns a handful of other transitive
packages to their declared central versions.

Assisted-by: Claude:claude-opus-4-8:Claude Code
A clean build currently has zero warnings; enforce that so new ones can't
accumulate unnoticed. Set TreatWarningsAsErrors in the root
Directory.Build.props rather than per project. ICSharpCode.ILSpyCmd keeps
its existing TreatWarningsAsErrors=false (project-level wins over the
imported default), and the three legacy net472 projects (the VS add-ins
and the installer builder) opt out explicitly since they only build on
Windows and aren't covered by the cross-platform CI that gates this.

Assisted-by: Claude:claude-opus-4-8:Claude Code
Pull in the submodule's new root Directory.Build.props so the decompiler
test fixtures aren't subject to this repo's TreatWarningsAsErrors=true.

Assisted-by: Claude:claude-opus-4-8:Claude Code
ICSharpCode.Decompiler / ILSpyX / BamlDecompiler set RestoreLockedMode but
declared no RuntimeIdentifiers, so their lock files were RID-agnostic. A
RID-specific 'dotnet publish -r <rid>' of ILSpy (which declares all four
distribution RIDs) propagated the RID to these locked project references and
failed restore with NU1004 unless --no-restore or --force-evaluate was used.
Declare the same win-x64;win-arm64;linux-x64;osx-arm64 set on the three
libraries and regenerate the locks so a locked-mode publish works for every
platform out of the box.

Assisted-by: Claude:claude-opus-4-8:Claude Code
In pwsh the bare '-xr!*.pdb' 7z argument is split into two tokens, '-xr!*'
(which excludes everything) and a bogus '.pdb' source, so the Release
binaries zip added 0 files and the step failed. Quote the exclusion so it
reaches 7z as one token, and pin the job's run shell to pwsh via defaults
so no step silently inherits a different default shell.

Assisted-by: Claude:claude-opus-4-8:Claude Code
ILSpy targets net10.0, not net10.0-windows; the VS add-in build paths, the
installer output dir, the local-dev publish script, and the VS Code launch
config still referenced the old net10.0-windows layout. Align them with the
actual TFM, matching publish.ps1 and the build workflow.

Assisted-by: Claude:claude-opus-4-8:Claude Code
The Debug Steps pane previously only served the ILAst language (one node per
IL transform). The C# decompiler's intermediate AST states were instead
exposed as a row of extra "C# - after TRANSFORM" dropdown languages, which
cluttered the language list and only let you jump to one fixed step at a time.

Generalise the pane to an IDebugStepProvider interface: ILAst keeps its IL
steps, and the C# language now contributes one step per AST transform, mapping
a selected step onto options.StepLimit (already honoured by CreateDecompiler).
Each language also supplies its own options object, so the writing-options
checkboxes show for ILAst and nothing for C#. The per-transform dropdown
languages are removed in favour of this.

Assisted-by: Claude:claude-opus-4-8:Claude Code
Make the repo-root pwsh scripts the mandated path for restore/build/update-deps/
format/clean/publish, and explain why: the bare dotnet commands prune the
packages.lock.json files (the recurring spurious lock-file diff). Add two code
conventions -- comments must read without any knowledge of the session that
wrote them, and edits made outside the session must not be reverted without
confirmation. Drop the "Framework-specific gotchas" section, which only covered
a few narrow UI control quirks.

Assisted-by: Claude:claude-opus-4-8:Claude Code
The README still described ILSpy as a WPF app. Reflect the current reality:
the desktop UI is cross-platform Avalonia (Windows/Linux/macOS), the solution
filters are ILSpy.Desktop.slnf / ILSpy.XPlat.slnf (no ILSpy.Wpf.slnf), Unix/Mac
can build and run the UI, and the build requires the .NET 11 SDK. In CLAUDE.md,
the net472 add-ins / installer / Windows-bound tests are Windows-only frontends
and extra tests, not a legacy porting backlog.

Assisted-by: Claude:claude-opus-4-8:Claude Code
Project/solution export (and other RunInNewTabAsync work) opened a tab with a
static title and a permanently indeterminate progress bar, even though the
whole-project decompiler already produces per-file DecompilationProgress that
was being dropped.

Wire that progress through: DecompilationOptions carries an
IProgress<DecompilationProgress> that DecompileAsProject sets on the
WholeProjectDecompiler; a new RunInNewTabAsync overload hands the work a
UI-thread-marshaled Progress<T> feeding the tab. The tab now animates the
braille spinner over its title (the operation name) and shows a determinate bar
plus the file currently being written and an "N of M" count. Solution export
reports per-assembly rather than per-file, since its assemblies decompile in
parallel and per-file reports would race. The determinate bar is pinned to 75%
of the overlay width so it doesn't jitter as the status text changes.

Assisted-by: Claude:claude-opus-4-8:Claude Code
Toggling a display setting that affects decompiler output (folding, member or
using expansion, debug info, IL detail, indentation) routed through
ForceRefreshActiveTab, whose ShowSelectedNode re-projects the tree selection
and so activates the preview tab -- yanking the user off whatever tab they were
on (e.g. the live Options page they were editing).

Add RefreshDecompilerOutputInPlace, which re-decompiles the cached decompiler
content directly without re-projecting or activating anything, and route the
Redecompile display-setting reaction to it. F5 reload and dependency-load keep
using ForceRefreshActiveTab, where re-projecting the selection is intended.

Assisted-by: Claude:claude-opus-4-8:Claude Code
Debug.Assert failures were routed straight to the single-button unhandled-
exception dialog and deduplicated by call site, so the only available action
was effectively "ignore". Restore the developer dialog the WPF host offered:
Throw raises the assertion as an exception, Debug breaks into the debugger,
Ignore continues, and Ignore All suppresses that call site for the session.
Asserts fire on the background decompiler thread, so the dialog marshals to the
UI thread and blocks the caller on a nested dispatcher frame until a choice is
made, matching how the WPF version stayed responsive.

Separately, cancelling a long-running operation tab left it blank; it now shows
"Operation was cancelled." so the frozen tab explains why it stopped.

Assisted-by: Claude:claude-opus-4-8:Claude Code
Generate-PDB and assembly-node decompile failures reported only the exception
Message, which is too thin to act on: the runtime-async PDB-generation crash
surfaces as a bare "Object reference not set to an instance of an object" with
no indication of where it came from. Append the whole exception (type, message,
stack trace, inner exceptions) inside a default-collapsed "Exception details"
fold so the report stays scannable while the trace is one click away.

The fold span is counted in WriteLine() calls rather than embedded newline
characters, so the trace is emitted one line per WriteLine() -- a single
Write() of the multi-line string would collapse to one counted line and be
dropped as a single-line fold.

Also fixes the PDB tab title showing a literal "{0}": the GeneratingPortablePDB
resource is a format string whose placeholder was never filled.

Assisted-by: Claude:claude-opus-4-8:Claude Code
This was linked to issues Jun 10, 2026
@christophwille
christophwille merged commit 0a5d412 into icsharpcode:master Jun 10, 2026
5 checks passed
@ahdung

ahdung commented Jun 11, 2026

Copy link
Copy Markdown

Great

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow mutiline tabs Announce a new cross platform port [Feature request] Ability to freeze/lock decompiler tabs Docking: Feature List (RFC)

3 participants