Skip to content

fix(layout): keep focus history for directional pane navigation - #2265

Open
haphamdev wants to merge 3 commits into
herdrdev:masterfrom
haphamdev:fix/improve-pane-navigation
Open

fix(layout): keep focus history for directional pane navigation#2265
haphamdev wants to merge 3 commits into
herdrdev:masterfrom
haphamdev:fix/improve-pane-navigation

Conversation

@haphamdev

Copy link
Copy Markdown

Directional focus (focus_pane_/navigate_pane_ keybinds, navigate-mode arrows, and the pane.focus_direction API) was stateless: find_in_direction re-derived the nearest pane purely from geometry, so leaving a split subtree and returning snapped to the same geometric winner instead of the pane last used there.

Add a per-pane monotonic FocusHistory on TileLayout, recorded on every real focus change, and make find_in_direction prefer the most-recently-focused pane among the nearest-column/row candidates, falling back to the existing geometric tiebreak when none has history. Swap-in-direction and the pure-geometry tests pass an empty history, preserving current behavior.

Route pane removal through a new TileLayout::close_pane primitive that records a new focus only when the removed pane was actually focused. This replaces the focus_pane/close_focused/focus_pane restore dance in detach_pane/take_pane_for_move and the split rollback path, which otherwise stamped a bystander pane as most-recently-focused and corrupted the MRU memory whenever a background pane was closed or died while focus was elsewhere.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ee4b1193-712a-4ce1-bcd7-efc8c7728545

📥 Commits

Reviewing files that changed from the base of the PR and between 039b3f2 and 8092bb0.

📒 Files selected for processing (6)
  • src/app/actions.rs
  • src/app/api/panes.rs
  • src/app/input/navigate.rs
  • src/layout.rs
  • src/workspace.rs
  • src/workspace/tab.rs
🚧 Files skipped from review as they are similar to previous changes (6)
  • src/app/input/navigate.rs
  • src/app/actions.rs
  • src/app/api/panes.rs
  • src/workspace/tab.rs
  • src/workspace.rs
  • src/layout.rs

📝 Walkthrough

Walkthrough

TileLayout now records per-pane MRU focus history. Directional navigation uses that history after nearest-edge filtering. Pane swaps use empty history, and pane cleanup closes explicit pane IDs without temporary focus changes. Split and moved-pane operations now pass explicit target and focus state.

Changes

Pane navigation and focus lifecycle

Layer / File(s) Summary
Focus history and pane lifecycle
src/layout.rs
TileLayout tracks focus timestamps during creation, focus changes, restoration, insertion, and removal. Conditional insertion and splitting preserve focus when requested. Pane cleanup closes panes by PaneId. Tests cover these focus states and lifecycle rules.
History-aware directional resolution
src/layout.rs
Directional lookup selects candidates on the nearest shared edge, prefers the most recently focused eligible pane, and uses geometric tie-breaking as fallback.
Navigation and geometry-only swaps
src/app/actions.rs, src/app/api/panes.rs, src/app/input/navigate.rs
Navigation passes focus history to directional lookup. Swaps use empty history for geometry-only targeting. Regression tests cover geometric swaps and unfocused moved panes.
Explicit split and moved-pane operations
src/workspace.rs, src/workspace/tab.rs, src/app/api/panes.rs
Split and moved-pane APIs pass explicit targets and focus flags. Pane removal closes specified IDs directly. Runtime split failures conditionally restore focus and remove the created pane by ID.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Navigation
  participant TileLayout
  participant find_in_direction
  Navigation->>TileLayout: read focus_history()
  Navigation->>find_in_direction: pass focused pane, direction, panes, and FocusHistory
  find_in_direction->>find_in_direction: filter adjacent candidates and rank by MRU or geometry
  find_in_direction-->>Navigation: return target pane
Loading

Possibly related PRs

  • herdrdev/herdr#2266: Both PRs modify TileLayout focus-history tracking and pane focus behavior.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: preserving focus history for directional pane navigation.
Description check ✅ Passed The description accurately explains the focus-history changes, navigation behavior, pane removal, and related implementation details.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@kangal-bot kangal-bot added the ai-review Trigger automated AI reviews for pull requests admitted by the PR gate label Aug 4, 2026
@haphamdev

Copy link
Copy Markdown
Author

Directional Pane Navigation with Focus History

Find the next pane to focus when moving up/down/left/right from a source
pane in a tree-based multiplexer layout (tmux/i3-style), keeping focus history as
much as possible. Mouse clicks are out of scope as an action but still update the
focus history.

Assumptions

  • H split = side by side (first = left, second = right); V split = stacked
    (first = top, second = bottom).
  • No wrap-around: moving past the screen edge is a no-op.
  • History is any focus change — directional move or mouse click both update it.

Core idea

Direction navigation is two decisions:

  1. Where can I go? Walk up the tree to the nearest split whose orientation
    matches the movement axis and lets you cross into a sibling subtree T.
  2. Which pane in T? Among the panes of T that are physically adjacent to
    the source
    (touch its crossing edge), pick the most-recently-focused one.
    Only fall back to geometry when none of them has ever been focused.

The adjacency filter is what makes Examples 2 and 3 work; the MRU pick is what
makes Examples 1 and 3 work. Two sub-filters compose:

  • Near-edge set: leaves of T lying on the edge facing the source.
    Structurally: at a split parallel to the movement, keep only the near child;
    at a split perpendicular, keep both (both touch the edge).
  • Perpendicular overlap: of those, keep only the ones whose cross-axis span
    actually overlaps the source pane (not the whole crossed boundary). This
    handles a small source facing a tall target subtree.

Then: argmax(focusedAt) over survivors; geometric nearest as tie/empty fallback.


Data model & layout

Node =
  | Pane  { id, focusedAt, rect, parent }     # focusedAt: monotonic ts, 0 = never
  | Split { orient:H|V, ratio, first, second, rect, parent }

Direction = Left | Right | Up | Down
axisOf(Left|Right)  = Horizontal
axisOf(Up|Down)     = Vertical
CLOCK = 0                                       # global monotonic counter

focus(p):            p.focusedAt = ++CLOCK      # called by BOTH keys and mouse
onMouseClick(p):     focus(p)

# One layout pass -> normalized rects in [0,1]. Only relative spans matter.
computeRects(node, r):
  node.rect = r
  if node is Pane: return
  if node.orient == H:
     a = {r.x,                r.y, r.w*node.ratio,     r.h}
     b = {r.x+r.w*node.ratio, r.y, r.w*(1-node.ratio), r.h}
  else: # V
     a = {r.x, r.y,                r.w, r.h*node.ratio}
     b = {r.x, r.y+r.h*node.ratio, r.w, r.h*(1-node.ratio)}
  computeRects(node.first, a); computeRects(node.second, b)

Algorithm

# 1) Find the subtree we cross into, or NONE at the screen edge.
findTargetSubtree(source, dir):
  node = source
  while node.parent != NULL:
    p = node.parent
    if p.orient == axisOf(dir):
      if dir in {Right, Down} and node == p.first:  return p.second
      if dir in {Left, Up}    and node == p.second: return p.first
    node = p
  return NONE

# 2a) Panes of T on the edge facing the source.
nearEdgePanes(node, dir):
  if node is Pane: return [node]
  if node.orient == axisOf(dir):                 # split PARALLEL to movement
     return nearEdgePanes(nearChild(node, dir), dir)
  else:                                          # PERPENDICULAR -> both touch edge
     return nearEdgePanes(node.first, dir) ++ nearEdgePanes(node.second, dir)

nearChild(split, dir):                           # child on the edge facing source
  Right|Down -> split.first                       # enter from left/top
  Left |Up   -> split.second                      # enter from right/bottom

# 2b) Keep only panes whose cross-axis span overlaps the source pane.
overlapPerp(a, b, dir):
  if axisOf(dir)==Horizontal:  return max(a.y,b.y) < min(a.y+a.h, b.y+b.h)
  else:                        return max(a.x,b.x) < min(a.x+a.w, b.x+b.w)

# 3) MRU among candidates; geometric fallback if none was ever focused.
selectTarget(source, cands, dir):     # cands guaranteed non-empty (see invariant)
  seen = [c for c in cands if c.focusedAt > 0]
  if seen not empty: return argmax(seen, key = c -> c.focusedAt)  # unique: CLOCK monotonic
  # no history: nearest to source center; ties broken toward first/topmost/leftmost
  return argmin(cands, key = c -> (perpDist(source.rect, c.rect, dir), docOrder(c)))

perpDist(s, c, dir):
  if axisOf(dir)==Horizontal: p=s.y+s.h/2; return dPI(p, c.y, c.y+c.h)
  else:                       p=s.x+s.w/2; return dPI(p, c.x, c.x+c.w)
dPI(p, lo, hi) = max(lo-p, 0) + max(p-hi, 0)     # 0 if p inside [lo,hi]

# Top level
navigate(root, source, dir):
  T = findTargetSubtree(source, dir)
  if T == NONE: return source                    # edge -> no-op
  cands = [c for c in nearEdgePanes(T, dir)
              if overlapPerp(source.rect, c.rect, dir)]
  next  = selectTarget(source, cands, dir)
  focus(next)
  return next

Complexity: O(height) to find the crossing + O(|T|) to collect candidates.
Memory: one focusedAt int per pane.


How each step works

focus / onMouseClick — recording history

Every focus change (a directional move or a mouse click) stamps the pane with the
next value of a strictly-increasing CLOCK. focusedAt is thus a per-pane
"last touched" timestamp: 0 means never focused, and a larger value means more
recent. This single integer is the entire memory the algorithm keeps about history;
"most recently focused" reduces to "largest focusedAt".

computeRects — the layout pass

A top-down walk that converts the tree's relative structure (orientation +
ratio) into absolute rectangles in normalized [0,1] coordinates, stamping
.rect on every node. Root fills {0,0,1,1}; an H split carves its box along the
width (first=left, second=right), a V split along the height (first=top,
second=bottom); ratios compose multiplicatively down the tree. Pure function of
shape + ratios, independent of history — recompute on resize/split/close, never on a
focus move. Steps 2b and 3 read these .rects; nothing else is geometric.

Step 1 — findTargetSubtree: where can I go?

Walk from the source up toward the root. At each parent p whose orientation
matches the movement axis (p.orient == axisOf(dir)), check whether the child we
came up through sits on the near side of that split:

  • moving Right/Down and we are p.first -> the sibling p.second is the target;
  • moving Left/Up and we are p.second -> the sibling p.first is the target.

The first match is returned, so we cross the nearest boundary in that direction
(the innermost enclosing split we can traverse). If the walk reaches the root with no
match, there is nothing in that direction -> NONE (screen edge, no-op). Splits of
the wrong orientation, or where we are already on the far side, are simply skipped as
we keep climbing. Cost: O(tree height).

Step 2a — nearEdgePanes: which panes touch the crossed edge?

Given the target subtree T and direction, collect the leaves lying on T's edge
that faces the source. Recurse by comparing each internal split to the movement axis:

  • parallel split (same axis as movement): its children stack along travel, so
    only one hugs the near edge — recurse into nearChild only;
  • perpendicular split: children stack across travel, so both touch the near
    edge — recurse into both and concatenate.

nearChild picks the side facing the source: first for Right/Down (enter from
left/top), second for Left/Up (enter from right/bottom). This is a purely
structural filter — no coordinates — and is what excludes, e.g., RTBR/RBBR in
Example 3 (they hang off the far child of a parallel split). Cost: O(|T|).

Step 2b — overlapPerp: keep only what lines up with the source

nearEdgePanes finds panes on T's whole edge; this narrows them to the ones
adjacent to the source pane specifically. It tests for positive overlap of the two
rects along the cross axis (vertical when moving horizontally, horizontal when
moving vertically): max(tops) < min(bottoms). The strict < rejects corner-only
touches. This is the one place a sub-pane source differs from a full-column source:
it drops near-edge panes the source doesn't actually face (e.g. RTTL in Example 5).

Step 3 — selectTarget: pick the winner

Among the surviving candidates (guaranteed non-empty):

  1. If any were ever focused (focusedAt > 0), return the one with the largest
    focusedAt — the most-recently-focused reachable pane. CLOCK monotonicity makes
    this unique. This is the history-preserving path.
  2. Otherwise fall back to geometry: the candidate nearest the source's center along
    the cross axis (perpDist, which is 0 when the center lies inside the
    candidate's span, else the gap to it). Equidistant ties break deterministically
    toward the first/topmost/leftmost via docOrder.

History dominates over geometry whenever ≥1 candidate has history; geometry only
decides when none do.

Top level — navigate

Compose the steps: find the target subtree (NONE -> no-op), collect its near-edge
panes, filter by cross-axis overlap with the source, select by MRU-then-geometry,
then focus the result (updating history for the next move) and return it.


Worked examples

Example 1 — MRU across a stacked target.

+-----------+-----------+
|           |    RT     |    root=H(L, R=V(RT,RB))
|     L     +-----------+    seq: focus RB -> Left->L -> Right->?
|           |    RB     |
+-----------+-----------+

Right from L -> T=R. nearEdge(R,Right)=[RT,RB] (V perp -> both). Both overlap L's
full height. focusedAt: RB=1, RT=0 => RB.

Example 2 — adjacency beats recency.

+---------+---------+---------+
|    L    |   RL    |   RR    |    root=H(L, R=H(RL,RR)); focus RR -> click L -> Right->?
+---------+---------+---------+

Right from L -> T=R. nearEdge(R,Right): R is H parallel, nearChild=first=RL =>
[RL]. RR is excluded structurally (not on R's left edge). Only candidate RL,
even though RR is more recent.

Example 3 — recency within the adjacent set.

+-----------+-----------------------+
|           |          RTT          |    clicks: RBBL -> RTBR -> L, then Right->?
|           +-----------+-----------+
|           |   RTBL    |   RTBR    |    focusedAt: RBBL=1, RTBR=2, L=3
|     L     +-----------+-----------+
|           |          RBT          |
|           +-----------+-----------+
|           |   RBBL    |   RBBR    |
+-----------+-----------+-----------+

Right from L -> T=R. nearEdge(R,Right) descends: R(V,perp)->both;
RT(V,perp)->RTT + RTB(H,parallel->first)=RTBL; RB(V,perp)->RBT +
RBB(H,parallel->first)=RBBL => {RTT, RTBL, RBT, RBBL}. All overlap full-height
L. RTBR/RBBR dropped (not on left edge). Of the four, only RBBL has focusedAt>0
=> RBBL. (RTBR excluded by adjacency though most recent; RTT/RBT excluded by
never-focused.)

Example 4 — history carried through a multi-hop path (same layout & history as
Ex. 3). Start focus = L (last click). Do Right, Up, Up.

x: 0        .5         .75         1.0
+-----------+-----------------------+ y=0
|           |          RTT          |   history: RBBL=1, RTBR=2, L=3  (CLOCK=3)
|           +-----------+-----------+ y=.25
|           |   RTBL    |   RTBR    |
|     L     +-----------+-----------+ y=.5
|           |          RBT          |
|           +-----------+-----------+ y=.75
|           |   RBBL    |   RBBR    |
+-----------+-----------+-----------+ y=1.0
  • Right from L -> T=R, cands {RTTn=RTT, RTBL, RBT, RBBL}; only RBBL focused
    => RBBL (RBBL=4).
  • Up from RBBL -> walk up RBB(H, wrong axis) to RB(V): RBB is second ->
    T=first=RBT. nearEdge=[RBT]; never focused, single cand => RBT (RBT=5).
  • Up from RBT -> RBT is RB.first, walk to R(V): RB is second -> T=first=RT.
    nearEdge(RT,Up): RT(V,parallel)->second=RTB; RTB(H,perp)->both => [RTBL, RTBR].
    RBT spans R's full width, so both overlap. focusedAt RTBL=0, RTBR=2 =>
    RTBR (RTBR=6).

Path: L -> RBBL -> RBT -> RTBR. The Up,Up faithfully returns to RTBR
because it still carries the highest focusedAt among the reachable near-edge set.

Example 5 — the overlap filter picks the column on a wider target row. Take
Ex. 3's layout but split RTT horizontally into RTTL | RTTR, with RTTR wider than
RTBR
(so RTTR's x-span covers RTBR's). Same history. Do Right, then Up x3.

x: 0        .5    .7            1.0
+-----------+-----+-------------+ y=0
|           |RTTL |    RTTR     |   RTTL x[.5,.7]   RTTR x[.7,1.0]
|           +-----+--+----------+ y=.25
|           |   RTBL |   RTBR   |   RTBL x[.5,.75]  RTBR x[.75,1.0]
|     L     +--------+----------+ y=.5
|           |        RBT        |
|           +--------+----------+ y=.75
|           |  RBBL  |   RBBR   |
+-----------+--------+----------+ y=1.0
  • Right from L -> cands {RTTL, RTBL, RBT, RBBL} (RTT's near/left child is now
    RTTL); only RBBL focused => RBBL (=4).
  • Up from RBBL -> RBT (=5), as in Ex. 4.
  • Up from RBT -> RTBR (=6), as in Ex. 4.
  • Up from RTBR -> walk up RTB(H, wrong axis) to RT(V): RTB is second ->
    T=first=RTT. nearEdge(RTT,Up): RTT(H,perp)->both => [RTTL, RTTR]. Now the
    overlap filter vs source RTBR x[.75,1.0]: RTTL x[.5,.7] has no x-overlap
    (dropped); RTTR x[.7,1.0] overlaps => cands=[RTTR]. Never focused, single cand
    => RTTR (=7).

Path: L -> RBBL -> RBT -> RTBR -> RTTR. RTTL is filtered out purely by
geometry: had RTTR been narrower than RTBR, RTTL would also overlap and the
fallback would decide by nearest center.

Example 6 — a two-candidate overlap where history overrides geometry. Drop L;
the whole screen is Ex. 3's R subtree (rooted at the V split RT/RB), with RTT
split into RTTL | RTTR and RTTR narrower than RTBR (so RTBR's column now
covers both RTTL and RTTR). History: RTTL -> RTBR -> RBBL. Focus = RBBL,
then Up x3.

x: 0            .5      .7          1.0
+-----------------------+-----------+ y=0    history:  RTTL=1  RTBR=2  RBBL=3
|         RTTL          |   RTTR    |        RTTL x[0,.7]   RTTR x[.7,1.0]
+---------------+-------+-----------+ y=.25
|     RTBL      |      RTBR         |        RTBL x[0,.5]   RTBR x[.5,1.0]
+---------------+------------------+  y=.5
|              RBT                  |        RBT  x[0,1.0]
+---------------+------------------+  y=.75
|     RBBL      |      RBBR         |        RBBL x[0,.5]   RBBR x[.5,1.0]
+---------------+------------------+  y=1.0
  • Up from RBBL -> walk up RBB(H) to RB(V): RBB is second -> T=first=RBT.
    nearEdge=[RBT], never focused, single cand => RBT (=4).
  • Up from RBT -> RBT is RB.first, walk to root R(V): RB is second ->
    T=first=RT. nearEdge(RT,Up)=[RTBL, RTBR]; RBT is full width so both overlap.
    RTBL=0, RTBR=2 => RTBR (=5).
  • Up from RTBR (x[.5,1.0]) -> RTB is RT.second -> T=first=RTT.
    nearEdge(RTT,Up): RTT(H,perp)->both => [RTTL, RTTR]. Overlap vs [.5,1.0]:
    RTTL x[0,.7] overlaps ([.5,.7]); RTTR x[.7,1.0] overlaps ([.7,1.0]) =>
    two candidates {RTTL, RTTR}. focusedAt RTTL=1, RTTR=0 => RTTL (=6).

Path: RBBL -> RBT -> RTBR -> RTTL. Contrast Ex. 5: there RTTR was wider, the
overlap set was the single {RTTR}, and geometry alone decided. Here the wider
source column yields {RTTL, RTTR}, and history breaks the tie for RTTL — even
though the geometric fallback (perpDist from RTBR's center x=.75) would have
preferred RTTR. Two survivors => history wins; one survivor => geometry is moot.


Properties & invariants

  • Candidate set is never empty when T != NONE. The shared boundary between a
    node and its sibling T spans the full cross-axis extent of their common parent.
    The source lies inside that node, so source's cross-axis span is a subset of
    T's near edge, which the near-edge panes tile exactly. A positive-extent source
    therefore strictly overlaps at least one near-edge pane. So navigate always
    returns a pane when a move is possible — no empty-selection case to guard.
  • MRU pick is unambiguous. CLOCK is strictly monotonic, so no two panes share
    a focusedAt; argmax over seen is unique. Ties are only possible in the
    geometric fallback, resolved deterministically by docOrder (first/topmost/
    leftmost).
  • Immediate reciprocity. Right after moving S -> T, moving the opposite
    direction returns to S: nothing in S's subtree was focused after S (it was
    the current focus until the move), so S is the MRU among that subtree's
    near-edge candidates. Back-and-forth is stable. (An intervening mouse click
    inside S's subtree can legitimately redirect the return — by design.)

Cases not covered / deliberately out of scope

  • Sticky column across repeated moves. The fallback keys off the source
    center
    , which drifts when you traverse panes of differing widths (e.g.
    down-down through a narrow then wide pane, then up). Real multiplexers keep a
    remembered cross-axis coordinate. This is the "Open choice" below; the history
    path is unaffected.
  • Pane close / open. History (focusedAt) survives resize since it is
    independent of rect, but closing the focused pane needs a separate rule to pick
    its successor (e.g. MRU among the closed pane's former near-edge neighbors). Not
    part of directional navigation.
  • Wrap-around. T == NONE is a no-op here. Wrapping to the opposite edge would
    be a variant of findTargetSubtree (descend from the root's far side) — omitted
    by the no-wrap assumption.
  • N-ary splits / gutters / borders. The model is strictly binary; an N-pane row
    is a right-nested chain of H splits, which the walk steps through one boundary
    at a time — correct, but callers must build the tree that way. Gutter pixels
    don't affect the result: selection uses structural near-edge descent plus span
    overlap, never exact edge-coordinate equality.
  • Degenerate geometry. Assumes every pane has positive width and height and
    ratios in (0,1); zero-area panes would make overlapPerp's strict < reject
    everything.

Why this decomposition

  • Adjacency = a structural filter, not geometry-first. The near-edge descent
    (both on perpendicular splits, near child on parallel ones) is exactly
    "which leaves touch the crossing edge," O(subtree) with no pixel math.
  • focusedAt timestamp beats a global history list. MRU-among-a-set is a
    single argmax; a list would need scanning/filtering per move for identical
    results.
  • The perpendicular-overlap filter is the one place geometry is load-bearing —
    it distinguishes "source is the whole column" (Ex. 1/3, all near-edge panes
    qualify) from "source is one sub-pane" (only the aligned near-edge panes
    qualify), which pure tree-walking can't see.
  • Fallback is isolated in selectTarget: swap perpDist for a "sticky
    desired coordinate" (tmux-style column memory) without touching history logic.

Open choice

The no-history fallback. This uses nearest to source center. Alternatives:
topmost/leftmost (first-most), or a remembered cross-axis cursor that survives
repeated moves. Adjust selectTarget to change it.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ac961af3-8d8f-4da9-b75e-647321d9ade3

📥 Commits

Reviewing files that changed from the base of the PR and between adb50cb and c8394f9.

📒 Files selected for processing (5)
  • src/app/actions.rs
  • src/app/api/panes.rs
  • src/app/input/navigate.rs
  • src/layout.rs
  • src/workspace/tab.rs

Comment thread src/app/api/panes.rs
@greptile-apps

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds per-layout focus history so directional pane navigation can return to the most recently focused adjacent pane while preserving geometry-only behavior for swaps.

  • Records monotonic per-pane focus timestamps on actual focus changes.
  • Uses focus history for keyboard and API directional navigation.
  • Adds focus-aware split, move, close, and rollback primitives to avoid stamping background panes.
  • Keeps directional swaps on the existing geometric selection path.

Confidence Score: 5/5

The PR appears safe to merge within the scope of this follow-up review.

No blocking failure remains.

Important Files Changed

Filename Overview
src/layout.rs Adds focus-history storage and integrates it into directional target selection and pane lifecycle operations.
src/workspace/tab.rs Routes targeted splits, pane insertion, removal, and spawn rollback through the new focus-aware layout primitives.
src/workspace.rs Passes explicit split targets and focus intent through workspace-level pane creation and move operations.
src/app/api/panes.rs Applies history-aware navigation to pane APIs while retaining geometry-only directional swaps.
src/app/input/navigate.rs Uses active-tab focus history for navigation and an empty history for swaps.
src/app/actions.rs Updates action-driven directional focus to consult the active layout history while leaving swaps geometric.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  FocusChange[Pane focus changes] --> History[TileLayout FocusHistory]
  Navigate[Directional navigation] --> Candidates[Nearest row or column candidates]
  History --> Candidates
  Candidates --> MRU[Most recently focused candidate]
  Swap[Directional swap] --> Geometry[Geometry-only selection]
Loading

Reviews (5): Last reviewed commit: "fix(layout): don't record focus history ..." | Re-trigger Greptile

@haphamdev

Copy link
Copy Markdown
Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 57dbf564-61df-463d-9e8e-5f37c3426568

📥 Commits

Reviewing files that changed from the base of the PR and between adb50cb and 17616d6.

📒 Files selected for processing (5)
  • src/app/actions.rs
  • src/app/api/panes.rs
  • src/app/input/navigate.rs
  • src/layout.rs
  • src/workspace/tab.rs

Comment thread src/layout.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/layout.rs (1)

163-165: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Do not record temporary focus during a no-focus split.

With PaneSplitParams.focus == false, src/workspace.rs Line 862 temporarily calls focus_pane(pane_id), this split records new_id, and src/workspace.rs Line 919 restores the previous pane through focus_pane. These transitions are not visible focus changes.

Both panes receive MRU stamps. Directional navigation can then select the new pane although the request did not focus it.

Thread the focus intent through the split path. Record history only after a real focus change. Add a regression test for pane.split with focus: false.

Also applies to: 236-239


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3f0d348b-aca0-4031-838a-3af0f0f6e272

📥 Commits

Reviewing files that changed from the base of the PR and between 17616d6 and 3ba3023.

📒 Files selected for processing (4)
  • src/app/api/panes.rs
  • src/layout.rs
  • src/workspace.rs
  • src/workspace/tab.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/workspace/tab.rs

Directional focus (focus_pane_*/navigate_pane_* keybinds, navigate-mode
arrows, and the pane.focus_direction API) was stateless: find_in_direction
re-derived the nearest pane purely from geometry, so leaving a split
subtree and returning snapped to the same geometric winner instead of the
pane last used there.

Add a per-pane monotonic FocusHistory on TileLayout, recorded on every real
focus change, and make find_in_direction prefer the most-recently-focused
pane among the nearest-column/row candidates, falling back to the existing
geometric tiebreak when none has history. Focus navigation and the
pane.neighbor query use live history; directional swaps stay geometry-only,
matching the TUI swap path, via a dedicated directional_swap_target helper
that passes an empty history so swap targets never depend on unrelated focus
movements.

Route pane removal through a new TileLayout::close_pane primitive that
records a new focus only when the removed pane was actually focused. This
replaces the focus_pane/close_focused/focus_pane restore dance in
detach_pane/take_pane_for_move and the split rollback path, which otherwise
stamped a bystander pane as most-recently-focused and corrupted the MRU
memory whenever a background pane was closed or died while focus was
elsewhere.
insert_pane_near unconditionally stamped the moved pane as focused, so a
pane relocated via pane.move with focus:false became eligible for MRU
directional navigation despite never being visibly focused. The move path
restored the prior focus afterward, leaving a phantom history stamp that
could hijack directional focus/neighbor selection toward the moved pane.

Thread the focus intent through insert_pane_near / insert_existing_pane /
insert_moved_pane_into_tab so the moved pane is focused and recorded only
when the move requests focus; a focus:false move leaves focus and history
untouched. This also removes the focus/restore dance in handle_pane_move.
pane.split with focus:false temporarily focused the split target and the
new pane, then restored focus, leaving phantom MRU stamps on both. A
focus:true split of a non-focused pane likewise stamped the target it was
never resting on. Directional navigation could then jump to a pane the
split never focused.

Add TileLayout::split_pane(target, direction, ratio, focus) that focuses and
records the new pane only when focus is requested, and thread the focus
intent through split_focused_with_runtime and the workspace split path,
dropping the focus_pane targeting/restore dance. The focused-split
convenience wrappers become test-only.
@haphamdev
haphamdev force-pushed the fix/improve-pane-navigation branch from 039b3f2 to 8092bb0 Compare August 4, 2026 06:50
@haphamdev

Copy link
Copy Markdown
Author

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)

src/layout.rs (1)> 163-165: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Do not record temporary focus during a no-focus split.
With PaneSplitParams.focus == false, src/workspace.rs Line 862 temporarily calls focus_pane(pane_id), this split records new_id, and src/workspace.rs Line 919 restores the previous pane through focus_pane. These transitions are not visible focus changes.
Both panes receive MRU stamps. Directional navigation can then select the new pane although the request did not focus it.
Thread the focus intent through the split path. Record history only after a real focus change. Add a regression test for pane.split with focus: false.
Also applies to: 236-239

ℹ️ Review info

Fixed

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

Labels

ai-review Trigger automated AI reviews for pull requests admitted by the PR gate rabbit-says-slop

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants