The headless UI tests synchronized with the application by pumping a fixed
number of frames (39 loops of RunJobs/Delay across 19 files) and by pressing
at a point computed once from a control's bounds. Both encode how fast the
machine that wrote the test was: on the loaded Windows Debug CI agent the
frame count comes up short and the point goes stale, which is the recurring
timeout in the tree context-menu tests and the reason each such failure was
repaired one test at a time.
Waiters.WaitForIdleAsync replaces the frame loops. It observes the actual
precondition - no dispatcher job queued at Background priority or above, no
assembly still loading in the background sweep, a frame rendered - and
requires it on two consecutive polls so a thread-pool continuation about to
post back is caught as well.
Window.ClickAsync replaces element-targeted MouseDown/MouseUp pairs. It
re-resolves the target on every poll and presses only once the window's hit
test at the click point answers with that target, reporting the point and
what was hit instead on timeout. That diagnostic exposed one vacuous test:
User_Click_On_Visible_Row_Does_Not_Recentre_Viewport clicked the centre of a
row wider than the tree viewport, which lies under the decompiler text view,
so its assertion held without the row ever being clicked. It now clamps the
point to the viewport like the other tree-row clicks.
Clicks at text positions and press-only gutter clicks stay raw; they do not
target an element.
Assisted-by: Claude:claude-fable-5:Claude Code
DerivedTypesEntryNode.Filter reported Recurse, but the cascade's
Recurse handling force-loads the entry's lazy children and hides the
entry when all of them are hidden. A leaf derived type has no children,
so every entry under "Derived Types" ended up hidden, and the hiding
propagated up the whole derived chain. The WPF tree showed these
entries as matches; Match restores that and also keeps the entries'
children lazy instead of eagerly scanning the assembly list for each
level of the chain.
Assisted-by: Claude:claude-fable-5:Claude Code
These explain the code by pointing at the front-end that used to implement it.
That front-end is no longer in the tree, so the referent a reader would go
looking for does not exist: "Mirrors WPF's RefreshDecompiledView() call" names
a method nobody can open. In almost every case the sentence beside it already
carried the reason, and the reference was an appendix.
Comments citing a live platform difference are left alone, because there the
comparison is the reason rather than a memory: Avalonia genuinely has no
global RequerySuggested signal, which is why SimpleCommand exists at all.
One had gone stale rather than merely redundant. DerivedTypesEntryNode
described consulting the active search term as a missing feature to reinstate,
but SearchTermMatches is deliberately a no-op so the assembly tree stays
independent of the search pane; the comment now says so.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
A comment that justifies behaviour by pointing at the WPF front-end means
nothing to someone reading the file cold: the reason is either already stated
beside it or is not stated anywhere. Each of these now names the constraint
itself - why navigation waits for pointer-release, why a signature block wraps,
why the tree filter ignores the search term.
Comments citing an external product's documented behaviour as the source of a
rule are left alone; there the reference is the reason, not a memory of how the
code arrived.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
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
The global:: prefixes existed because the test project's namespace
ICSharpCode.ILSpy.Tests used to shadow the app's old top-level ILSpy
namespace. With the UI code back under ICSharpCode.ILSpy there is
nothing left to shadow, so plain fully qualified names resolve fine.
Assisted-by: Claude:claude-fable-5:Claude Code
The Avalonia port had placed the UI app in an ILSpy.* namespace tree,
while the csproj RootNamespace and every prior release (through 10.1)
use ICSharpCode.ILSpy.*. Restoring the historical namespace reduces the
public API diff against release/10.1 for plugin authors and removes the
shadowing that forced global:: qualifiers in the test project. The
Images class and AccessOverlayIcon enum move back into the root
namespace (as in 10.1), since an ICSharpCode.ILSpy.Images namespace
would shadow the Images class for all code inside ICSharpCode.ILSpy.
Assisted-by: Claude:claude-fable-5:Claude Code
SharpTreeView is a ListBox with AutoScrollToSelectedItem left at its default
(true), so it chased the selected row whenever its index shifted: expanding an
unrelated node pushed the selection off-screen and the ListBox yanked the
viewport back to it, fighting the expand's own reveal -- the "weird scrolling".
The rule is that a user mutating a control directly should not have the app
mutate the view too; only navigation from a *different* control (search
results, code/metadata links, analyzer nodes, Back/forward) may sync the tree.
The app already does that sync explicitly through the model
(TreeSelectionBinder -> CenterNodeInView), so the ListBox's own auto-scroll is
redundant and wrong for in-tree actions. Disable it.
Assisted-by: Claude:claude-opus-4-8:Claude Code
SharpTreeView.SelectedItems is IList?, so reading .Count on the realised grid raised CS8602. Null-forgive the dereferences in the Ctrl+A and plain-click selection tests, where the grid is always realised.
Assisted-by: Claude:claude-opus-4-8:Claude Code
Center-on-select reveals a navigated-to node centred in the viewport, but it was also yanking rows the user can already see: opening a visible row in a new tab (or any model-driven selection) pulled it to the middle. The skip cannot be decided from post-selection geometry because AutoScrollToSelectedItem first drags an off-screen row to an edge, making it look visible. Snapshot visibility in TreeSelectionBinder.SyncModelToTree before the selection changes: an already-visible primary is focused without scrolling, while a genuinely off-screen one (Back navigation, go-to-definition) is still centred.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The assembly/analyzer panes now host SharpTreeView, but ~20 headless tests
still queried the old ProDataGrid surface (DataGrid/DataGridRow/Hierarchical
Model/HierarchicalNode), so they could not exercise the live tree. Retarget
them to SharpTreeView/SharpTreeViewItem and the flattener (ItemsSource) the
control actually exposes, and centralise the row/selection lookups in
TreeNodeAssertions so the scroll assertions ride the new container type.
Driving the retargeted tests against the real control surfaced four genuine
gaps, fixed here in SharpTreeView:
- Ctrl+A selected nothing on the first press because the base ListBox only
selects all once an item inside it is focused; handle it explicitly and make
the control itself focusable so the gesture works the moment the pane gains
focus.
- Left/Right navigation focused the parent/first-child container but never
moved the selection (selection must follow the caret); add SelectAndFocus.
- The expander toggle had no stable name; name it PART_Expander so hit-target
assertions can find it, and align the test to the shipped 13px column (kept
at 13 so the +/- box centres on the connector lines).
- Tree rows stretch to content width with horizontal scroll, so a row centre
can sit past the viewport's right edge; the pointer tests now click within
the visible grid width instead of the off-screen row centre.
The UseNestedNamespaceNodes re-bind test asserted a ProDataGrid Hierarchical
Model swap that no longer exists; rewrite it to assert the live flattener
reshapes the visible rows in place when the setting toggles.
Extending a tree selection with Shift+Down then pressing Shift+Up didn't shrink the range back toward the anchor -- the dropped rows stayed selected, and in the middle of a list the anchor drifted so the range crept the wrong way. Root cause is upstream in ProDataGrid: a shift+nav key moves the grid's current row and anchor correctly, but SelectFromAnchorToCurrent calls SetRowsSelection(start,end), which only ADDS the range and never deselects rows outside it, so a shrink is a no-op. After the grid processes a shift+nav key, TreeKeyboardController prunes the selection to exactly the [anchor..current] range. It deselects out-of-range rows via the internal SetRowSelection(slot, isSelected:false, setAnchorSlot:false) rather than SelectedItems.Remove: removing an item makes the grid re-derive its current row and corrupt the anchor for the next key, whereas SetRowSelection deselects a slot in place and leaves current/anchor intact. The slot members are read reflectively and defensively (falls back to the pre-fix behaviour if a future ProDataGrid renames them). Both the assembly and analyzer trees get it via the shared controller.
Extract the standard tree keyboard gestures -- Left/Right expand-collapse + parent/child nav, numpad +/-/*, and new type-ahead incremental search -- out of AssemblyListPane into a reusable TreeKeyboardController that drives any hierarchical DataGrid. The model implements ITreeKeyboardTarget (PrimarySelectedNode + SelectNode) so each tree keeps its own select-and-reveal behaviour; expansion goes through the grid's IHierarchicalModel. Both the assembly tree and the analyzer tree now attach one, so they share consistent keyboard behaviour. Delete (unload) and Ctrl+R (analyze) stay assembly-specific in the pane.
Numpad + expands the focused node, - collapses it, and * expands it recursively. The recursive walk only descends into children whose SharpTreeNode.CanExpandRecursively is true, which is false for lazy-loading nodes -- so * stays bounded and won't try to materialise an entire assembly. Mirrors WPF SharpTreeView.
Standard tree keyboard navigation in the assembly list: Left collapses an expanded node or moves to the parent when already collapsed; Right expands a closed node or steps into its first child. Operates on the primary selection via the hierarchical model (FindNode/Expand/Collapse) and SelectNode; the parent step is gated on the parent being a visible row so it never selects the hidden root.
Pressing Delete unloaded the assembly but left the grid showing a row selected while model.SelectedItems went empty -- so a second Delete read an empty selection and no-op'd ("spamming Delete breaks"). Record the deleted node's flattened index and, once the visible tree rebuilds, re-select the node that now occupies that slot (clamped to the new end) via SelectNode, which re-syncs grid and model. Selects nothing when the list empties.
To audit what each UI test actually exercises, every step now snapshots the
live window to <TestFixtureName>/<TestName>_<NN>_<ShortDescription>.png: a
booted frame (emitted automatically by TestHarness.BootAsync), one after each
state-changing action, and one before each assertion. Flip ILSPY_TESTS_VISIBLE=1
to render the filmstrip; it lands under %TEMP%/ilspy-test-captures (overridable
via ILSPY_TEST_CAPTURES).
The step number and fixture/test name are derived automatically so inserting a
breakpoint never renumbers the rest. The identity is recorded up front from the
real ITest in an ITestAction hook rather than read live: NUnit's
TestContext.CurrentContext does not flow onto async continuations, so a capture
after an await would otherwise collide under the ad-hoc context. And when
rendering is off the whole call is a true no-op -- not even a dispatcher pump --
so instrumenting a test can never perturb the navigation/tab timing it asserts
on. Full headless suite stays green.
Nearly every headless UI test opened with the same four-line prologue
(resolve the shared MainWindow, show it, cast its DataContext, wait for the
assembly list), then repeated the corelib lookup, the EnsureLazyChildren +
Children.OfType<T>().Single() drill, the registry single-by-header lookups,
and the open-an-assembly-and-wait dance. The duplication made the intent of
each test hard to see and every signature tweak a suite-wide edit.
Collapse those into TestHarness (BootAsync, OpenAssemblyAsync, GetCommand,
GetEntry) and TreeNavigation extensions (FindCoreLib, GetChild<T>, Expand<T>),
then apply them across the suite. Net ~865 lines of boilerplate removed with
no change in behaviour; the full headless suite stays green.
The command resolves each selected assembly's references through the
per-assembly resolver, which adds the targets to the live list as
auto-loaded entries. It then finished with a full list Refresh (F5),
whose LoadList rebuilds the list from persisted state -- which never
contains on-demand auto-loaded assemblies -- so the just-resolved
dependencies were discarded the instant they were added and the command
looked like a no-op. Re-decompile the active view instead (the original
behaviour) without reloading the list. F5 still drops auto-loaded
assemblies by design; the two refresh paths must stay distinct.
Two selection-sync defects in the assembly tree. Ctrl+A only selected
the last row on the first press: SelectedItem is the last entry of
SelectedItems and every collection change re-raised it, so the grid->model
sync bounced back through SyncSelectionFromModel and assigned the singular
SelectedItem, collapsing the just-made multi-selection. The syncingSelection
guard now also covers that notification.
Clicking one row of a multi-selection left every row selected: ProDataGrid
keeps the selection on press so a row-drag can move all of them (multi-row
reorder is a real feature), but 12.0.0 has no release-side collapse for a
plain click. Mirror the usual behaviour by collapsing to the clicked row on
release when the pointer did not move far enough to be a drag.
The user reported that expanding an enum (System.DayOfWeek) with a search
term active showed only Base/Derived nodes — every FieldTreeNode child
hidden. This is NOT a recent regression. It traces back to commit
45461ddde, which made LanguageSettings.SearchTermMatches honour the
SearchTerm and wired SearchPaneModel to push its term into
LanguageSettings on every keystroke.
The PE TypeDef table includes a <Module> pseudo-type plus any user-declared
top-level types in the empty namespace. Until now AssemblyTreeNode skipped
the empty namespace when building NamespaceTreeNodes and instead hung the
global types directly off the assembly node, breaking the long-standing
tree shape (every type lives under a namespace folder).
Audit had ExitCommand listed as Missing because it grepped for a separate
ExitCommand.cs file (the WPF layout). Avalonia bundles File-menu commands
into FileCommands.cs so the audit missed it. Command was already implemented
correctly — uses IClassicDesktopStyleApplicationLifetime.Shutdown() rather
than mainWindow.Close() (cleaner since Avalonia apps can have multiple
windows).
Assisted-by: Claude:claude-opus-4-7:Claude Code
FindMemberNode's IMethod branch only checked direct MethodTreeNode children of
the type. Accessor methods (get_/set_/add_/remove_/invoke_) live as children of
their owning PropertyTreeNode / EventTreeNode, so the lookup always returned
null — and MMB on a metadata-grid accessor row silently no-opped because
OpenNodeInNewTab couldn't resolve the IMethod to a tree node.
Assisted-by: Claude:claude-opus-4-7:Claude Code
Mirrors WPF's mechanism: ILSpyTreeNode.OnChildrenChanged calls ApplyFilterToChild
on every newly-added child while the parent IsVisible, writing the Filter result
into SharpTreeNode.IsHidden. Because SharpTreeNode.IsVisible defaults to true,
the cascade fires while a node's children are added in its constructor — so a
PropertyTreeNode's get_/set_ accessors are stamped IsHidden=true under the
default ShowApiLevel before the property even attaches to its type. The base
SharpTreeNode.ShowExpander then naturally returns false ("Children.Any(!isHidden)"
sees no visible children), no per-node override needed.
Assisted-by: Claude:claude-opus-4-7:Claude Code
PropertyTreeNode now eagerly adds Getter / Setter as MethodTreeNode children
(when present); EventTreeNode does the same for Add / Remove / Invoke
accessors. The ShowExpander => false overrides drop so the base default
(Children.Any) lights up the chevron when accessors exist.
Assisted-by: Claude:claude-opus-4-7:Claude Code
Each ContentTabPage now carries the SharpTreeNode it represents (SourceNode),
stamped at construction time so the activation event fires with it already
populated. DockWorkspace subscribes to factory.ActiveDockableChanged: when a
new tab becomes active — whether by MMB carve-out, the "Decompile to new tab"
context-menu entry, or the user clicking a different tab in the strip — the
assembly-tree selection is pulled across to that tab's SourceNode. A guard
prevents the change from cascading back into ShowSelectedNode and overwriting
MainTab's content. Existing single-tab reuse path stamps MainTab.SourceNode
on every selection so flipping back to it stays a no-op.
Assisted-by: Claude:claude-opus-4-7:Claude Code
The new-tab path always built a DecompilerTabPageModel, so MMB / context-menu
on a metadata-table tree node forced the node into a decompiler tab and a second
metadata view couldn't coexist with the first. Move the carve-out into a single
DockWorkspace.OpenNodeInNewTab that asks node.CreateTab for custom content
(metadata tables, resource viewers, …) — wiring the same NavigateToCellRequested
/ RowActivated subscriptions AttachCustomContent applies on the reuse path —
and only falls back to spawning a fresh decompiler tab when the node has no
custom page-type. The assembly-tree MMB handler, the metadata-grid MMB handler,
and the "Decompile to new tab" context-menu entry now all delegate to this one
method.
Assisted-by: Claude:claude-opus-4-7:Claude Code
Wires MMB on the assembly tree and the metadata grid to the existing "open in
new tab" path, matching the WPF gesture (DecompileInNewViewCommand carries
InputGestureText = "MMB"). Plain double-click reverts to reusing the active tab;
the unreliable Shift+double-click variant is dropped.
Assisted-by: Claude:claude-opus-4-7:Claude Code
Replaces the brittle root.GetVisualDescendants().OfType<T>().Single() / .First()
pattern with a new WaitForComponent<T>() extension that polls until the requested
control is in the visual tree, then returns it. Avalonia.Headless tests routinely
queried the visual tree before lazily-templated panes (DataGrid, dock content)
had materialised, surfacing as intermittent 'Sequence contains no elements'
failures across the suite.
Assisted-by: Claude:claude-opus-4-7:Claude Code
Each tree-expansion, selection change, command execution, async wait and
key/mouse injection inside an [AvaloniaTest] now carries a short imperative
comment on the line above (e.g. "// expand typeNode", "// select methodA",
"// wait for assemblies to load", "// execute aboutCmd"). The comments are
the same scaffolding the manual debug-with-screenshot workflow uses to
follow what's happening at each breakpoint, surfaced into the committed
source so the tests are readable without the breakpoint markers attached.
Assisted-by: Claude:claude-opus-4-7:Claude Code
Adds a leading paragraph describing what each test verifies and Arrange /
Act / Assert markers (split into numbered phases for multi-step tests) so
the intent of each fixture is readable from comments alone.
Assisted-by: Claude:claude-opus-4-7:Claude Code
ProDataGrid's HierarchicalModel kept its own per-wrapper expanded state and
ignored SharpTreeNode.IsExpanded — toggling the model property had no visible
effect, and clicking the chevron in the grid didn't update the model either.
Set HierarchicalOptions.IsExpandedPropertyPath = "IsExpanded" so ProDataGrid
reads/writes the property via reflection and observes INotifyPropertyChanged
on the source. Production code stays a one-liner; the imperative
NodeExpanded/PropertyChanged plumbing first attempt would have been ~80 lines.
Test exercises the model→grid direction.
Assisted-by: Claude:claude-opus-4-7:Claude Code
Both commands have correct implementations since the WPF main-menu port; this
locks the behaviour in. Clear: with a populated list, CanExecute is true and
Execute drains the list to zero. Remove-load-errors: drops a synthetic non-PE
file into the list, waits for HasLoadError (GetLoadResultAsync rethrows the
underlying BadImageFormatException so it isn't a usable wait probe), invokes
the command, asserts the broken entry is unloaded while every previously-valid
entry is preserved. Both confirmed red by temporarily no-op'ing Execute and
green again after restoring.
Assisted-by: Claude:claude-opus-4-7:Claude Code
Mirrors the WPF SharpTreeView's ApplicationCommands.Delete handling:
KeyDown on the tree DataGrid scoops up every selected AssemblyTreeNode and
calls AssemblyList.Unload on each. Snapshots the list before mutating because
Unload triggers CollectionChanged which clears the model selection mid-loop.
Non-assembly selections (types, methods, namespaces, …) are ignored — Del is
specifically for top-level entries today; richer per-node Delete semantics
can come with the broader IPlatformDataObject port. Test pins the dispatch.
Assisted-by: Claude:claude-opus-4-7:Claude Code
Mirrors WPF SaveCodeContextMenuEntry.Execute: when a single tree node is
selected, call its Save() override first (resource nodes already self-handle
the dialog + raw/ResX choice). Otherwise prompt for a path with the active
language's extension and re-decompile the node with FullDecompilation = true,
EscapeInvalidIdentifiers = true, writing the output as plain text via
ICSharpCode.Decompiler.PlainTextOutput on a background task.
Assisted-by: Claude:claude-opus-4-7:Claude Code
Clicking an already-visible row was running ScrollIntoView+CenterRowInView via
the SelectionChanged → model PropertyChanged round-trip, yanking the viewport.
Add a clickInProgress flag separate from syncingSelection: clickInProgress is
set in OnTreeGridSelectionChanged and cleared at Background priority alongside
syncingSelection, so the model's two PropertyChanged notifications (Clear+Add
on SelectedItems) both bail out — but hyperlink / Back/Forward / search-hit
navigation, which never enters OnTreeGridSelectionChanged, still scrolls the
target into view.
Assisted-by: Claude:claude-opus-4-7:Claude Code
Five new IResourceNodeFactory implementations route .xml/.xsd/.xslt to a shared
XmlResourceEntryNode (text + XML highlighting via SyntaxExtensionOverride on the
output), .xaml to XamlResourceEntryNode (same pattern), and .png/.gif/.bmp/.jpg /
.ico / .cur to image-rendering nodes that inline the bitmap with a Save button.
SyntaxExtensionOverride is read after Decompile to switch the editor's highlighter
without changing the active language. Cursor handling flips byte 2 of the on-disk
header so Skia decodes the .cur as an icon for preview while Save preserves the
original bytes. ResourceFactoryTests pin the dispatch — including .xsd — and the
existing assembly-tree assertion is relaxed since typed entries are no longer all
ResourceTreeNodes.
Assisted-by: Claude:claude-opus-4-7:Claude Code