Skip to content

feat(tui): aggregated layer view (A keybind)#57

Merged
deveshctl merged 3 commits into
mainfrom
feat/layer-aggregation-view
Jun 24, 2026
Merged

feat(tui): aggregated layer view (A keybind)#57
deveshctl merged 3 commits into
mainfrom
feat/layer-aggregation-view

Conversation

@deveshctl

@deveshctl deveshctl commented Jun 17, 2026

Copy link
Copy Markdown
Owner

Summary

Adds an aggregated layer view to the TUI as a togglable alternate to the default per-layer view. Default behaviour is unchanged; users who never press A see only a cosmetic relabel of the file-tree panel title.

What's new

  • A toggle in the TUI flips the file tree between two views:
    • Current Layer Contents (default): per-layer changes — what this layer modified.
    • Aggregated Layer Contents (A): cumulative provenance from the base layer — DiffType labels carry forward across iterations (a file Modified in L1 stays Modified at L7 even when L2..L7 didn't touch it).
  • Status badge [agg] next to [diff]; bottom-bar hint in both focus modes; help row under "Layers".

Implementation

Layer Change
image/stack.go New BuildAggregatedTrees, aggregateMerge, cloneWithDiffType. mergeLayer refactored to delegate to mergeLayerWith(carry)Stack's observable behaviour is bit-identical (regression-tested).
image/analysis.go Analysis.AggregatedTrees field; populated alongside StackedTrees in AnalyzeWithOptions.
tui/{keymap,model,filetree,help}.go Aggregate binding, aggregated field, toggle handler after Sort, currentTreeRoot routing, [agg] badge, bottom-bar hints, help row, title strings.

No cache-schema bumpAggregatedTrees is a pure function of Layers and re-derives on cache load. No changes to ci/, compare.go, efficiency.go, JSON export, or the cache envelope.

Testing

  • 7 new image/stack_test.go tests, including:
    • TestBuildAggregatedTrees_PreservesPriorDiffTypeAcrossUntouchedLayers — central correctness test for label carry-forward.
    • TestBuildAggregatedTrees_OpaqueWhiteoutAtMidLayer — verifies that opaque whiteouts propagate Removed children through untouched directory merges.
    • TestStack_StillCompareSingleLayer — regression guard locking Stack's existing semantics.
  • 6 new tui/model_test.go tests covering toggle reset, root routing, viewer/filter precedence, state survival across toggle, and nil-fallback.
  • 2 new title assertions in tui/filetree_test.go.
  • Existing test suite unchanged — all Analysis{...} literals continue to compile via named-field syntax.
  • Verified end-to-end on a real multi-stage image: title flip on A, [agg] badge, label carry-forward, filter-input swallowing, filter/diff/sort survival across toggle, Enter extractor, archive-mode parity, cache cold/hot parity, and byte-identical output from layerx ci and layerx --json (those subcommands consume StackedTrees only).

Risks addressed

  • Bit-drift in StackTestStack_StillCompareSingleLayer plus the existing Stack test suite.
  • Recursion-propagation bugTestBuildAggregatedTrees_OpaqueWhiteoutAtMidLayer is designed to fail in exactly that scenario.
  • MemoryBuildAggregatedTrees peak ≈ 2× final tree + N snapshots, comparable to Stack. Doc-comment states the bound.
  • Test fixtures with Analysis{...} literals → new field zero-values to nil; currentTreeRoot handles nil gracefully.

@deveshctl deveshctl changed the title feat(tui): aggregated layer view (A keybind, Dive Ctrl+A parity) feat(tui): aggregated layer view (A keybind) Jun 19, 2026
Implements Dive's CompareAllLayers parity — the most-cited migration gap
from Dive (P1 / Tier 1.3). Pressing A in the file-tree pane switches
between two views:

- Default (Current Layer Contents): unchanged, today's CompareSingleLayer
  view. result[i] shows only what layer i changed against the cumulative
  state of layers 0..i-1.
- Aggregated (Aggregated Layer Contents): cumulative provenance from L0
  baseline. result[i] overlays every layer 1..i in order, preserving
  DiffType labels across iterations — a file Modified in L1 stays
  Modified at L7 even when L2..L7 didn't touch it.

Implementation:

- image/stack.go: factor mergeLayer into mergeLayerWith(carry) so
  Stack and the new BuildAggregatedTrees walker share the bulk of the
  logic. Stack's observable behaviour is bit-identical
  (TestStack_StillCompareSingleLayer locks this). The aggregated walker
  uses cloneWithDiffType as its carry-forward, propagating DiffType
  labels and Removed tombstones through untouched-by-this-layer paths,
  including recursively into directory subtrees.
- image/analysis.go: Analysis gains AggregatedTrees []*FileTree;
  AnalyzeWithOptions populates it via BuildAggregatedTrees(layers).
  Pure addition — ci/, compare.go, efficiency.go, JSON export, and the
  cache schema are all untouched.
- tui/keymap.go, tui/model.go, tui/filetree.go, tui/help.go: A toggle
  with reset-tree-cursor semantics; currentTreeRoot routes on the bool;
  [agg] status badge alongside [diff]; bottom-bar hint in both focus
  modes; help row under Layers; panel title flips between Dive's exact
  strings.

Tests:

- 7 new tests in image/stack_test.go covering length, L0 equivalence,
  the spec's Worked Example (carry-forward across untouched layers),
  Removed propagation, opaque-whiteout-at-mid-layer (the recursion
  fix), empty-layer snapshotting, and a Stack regression guard.
- 6 new tests in tui/model_test.go covering toggle reset, root
  routing, viewer/filter precedence, filter+diff+sort survival across
  toggle, and nil-AggregatedTrees fallback.
- 2 new title assertions in tui/filetree_test.go.
- analysis_test.go gains a wiring assertion (length matches Layers).

No cache schema bump (AggregatedTrees is derived). No public API
removed. ci/json/compare subcommands unchanged.
@deveshctl deveshctl force-pushed the feat/layer-aggregation-view branch from 00ac958 to 593740c Compare June 20, 2026 07:01
…-side

The previous A toggle swapped which tree the file panel pointed at and
relabelled the title — useful but forced flip-flopping to compare what
the current layer changed vs. the cumulative carried-forward state.

Replace the toggle with a real split: A halves the file-tree panel into
two stacked sub-panels at the same layer cursor. Top shows per-layer Δ
(StackedTrees); bottom shows cumulative provenance (AggregatedTrees)
with Modified/Removed labels carried forward across untouched layers.
A labelled divider (▾ Cumulative N/M) sits between them and accents when
the bottom pane has focus.

Each sub-pane has its own cursor, scroll offset, and collapse state, so
inspecting one does not lose your place in the other — the whole point
of seeing both at once. Tab cycles layers → top → bottom → layers only
when the split is on; with it off, Tab is the original two-state cycle.
Filter, sort, diff-only, and Enter act on the focused pane. Status bar
shows [split]; help and bottom-bar hints describe the new flow.

Refactor: extract renderTreeBody from renderFileTree so the same body
assembly serves both single-pane and split-pane layouts. Add focus-aware
helpers (rootFor / displayTreeFor / treeCursorFor / treeOffsetFor /
adjustTreeScrollFor / clampTreeCursor) and a third focus state
focusTreeAgg. Navigation, file open, extract, and copy-path all operate
on the active pane.

Tests: updated currentTreeRoot/displayTree expectations to follow
active-pane semantics; added coverage for the three-state Tab cycle,
focus snap-back when toggling out, independent pane cursors,
focus-driven displayTree, filter applying to both panes, and split
renderer titles/divider.
Two CI failures on the previous commit:

1. TestSplitMode_DisplayTreeFollowsFocus — expected the per-layer Δ
   slice and the cumulative slice to differ in length. They don't:
   Stack also produces a cumulative tree, just with DiffType labels
   stripped to Unchanged for paths the current layer didn't touch.
   Switch the assertion to compare DiffType labels at a probe path
   (/etc/passwd: Unchanged in the Δ view, Added in the cumulative
   view) — that is the actual observable difference between the two
   panes and what the user reads off the screen.

2. TestOverlayHelp_WideTerminal_UsesHorizontalLayout — the new help
   footer line about the split view was wide enough to dominate
   lipgloss.Width(content), clamping boxWidth to maxBox and forcing
   renderPanel to truncate the three-column header row, which dropped
   the third column's 'Wasted Files' title from the rendered output.
   The bottom-bar hint and the panel title already advertise the
   split, so drop the extra footer line. Also collapse the new
   Tab+A help rows into a single A row so column 0 width stays at
   the prior maximum.
@deveshctl deveshctl merged commit 7ecee62 into main Jun 24, 2026
22 checks passed
@deveshctl deveshctl deleted the feat/layer-aggregation-view branch June 24, 2026 07:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant