diff --git a/README.md b/README.md index f0fa808..af0e1de 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ sits below them. The mouse is enabled: **click** a pane to focus it, or a row in splits (unavailable when zoomed, scanning, on error, or with an overlay open). The Status table lists dirty files with **Worktree** and **Staged** columns (same left-to-right order as the Diff pane). The Diff pane runs `git diff` with basic colorization; -use **←** / **→** in Status or Diff to switch between **Worktree** and **Staged** views. +use **Space** in Status or Diff to toggle between **Worktree** and **Staged** views. With a file row selected, **a** runs `git add` and **r** runs `git reset` (unstage) on that path (from the Status or Diff pane), then the current repo is refreshed. **C** asks for confirmation, then runs `git checkout HEAD -- ` to restore that path to @@ -145,12 +145,12 @@ compresses each remote into a short status (`ok`, `missing`, `differs`, or | Key | Action | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | *Mouse* | Click to focus a pane; in Repositories or Status (when focused), select a row. Drag a border to resize splits (unavailable when zoomed, scanning, on error, or with an overlay open) | -| `Tab` / `Shift+Tab` | Next / previous pane; when zoomed, cycle which pane is fullscreen | +| `Tab` / `Shift+Tab` | Next / previous pane: Repositories, Status, Branches, Log (not Diff; click to focus); when zoomed, cycle fullscreen | | `Enter` | Zoom the focused pane; `Enter` again restores the split layout | | `Esc` | Exit zoom, or clear the Status file selection | | `↑` / `↓` | Move repo selection, or scroll Status / Diff / Log | | `Shift+↑` / `Shift+↓` | Same, in steps of 10 lines | -| `←` / `→` | In Status or Diff: Worktree vs Staged diff | +| `Space` | In Status or Diff: toggle Worktree vs Staged diff | | `a` / `r` | With a status file row selected (Status or Diff): `git add` / `git reset` that path | | `C` | With a status file row selected (Status or Diff): confirm, then `git checkout HEAD --` that path (restore to last commit) | | `s` | Scan or rescan | diff --git a/demo.tape b/demo.tape index a1ed8be..012fcf9 100644 --- a/demo.tape +++ b/demo.tape @@ -102,7 +102,7 @@ Sleep 200ms Tab Sleep 200ms -# tab to diff +# tab to log (Diff is not in the Tab cycle; click the Diff pane to focus it) Tab Sleep 200ms diff --git a/ui/layout_geometry.go b/ui/layout_geometry.go new file mode 100644 index 0000000..ad25e76 --- /dev/null +++ b/ui/layout_geometry.go @@ -0,0 +1,95 @@ +package ui + +// paneLayout holds inner body heights for the main stack (one pane may be +// non-zero in zoom mode; all zero means layout could not be computed). +type paneLayout struct { + repo, status, branch, diff, logBody int +} + +func (p paneLayout) isZero() bool { + return p.repo == 0 && p.status == 0 && p.branch == 0 && p.diff == 0 && p.logBody == 0 +} + +// innerVerticalBudget is total inner body lines available below the outer chrome +// (status bar, titles, etc.). +func innerVerticalBudget(termHeight int) int { + return termHeight - layoutFrameStackOuterRows +} + +// panelOuter converts an inner body height into full framed panel height. +func panelOuter(body int) int { + return body + 2 // top border (with title) + body + bottom border +} + +// diffBodyFromStackedStatusBranch returns diff inner body height so the Diff pane +// outer height matches Status and Branches stacked (each pane contributes top+bottom borders). +func diffBodyFromStackedStatusBranch(statusBody, branchBody int) int { + return statusBody + branchBody + 2 +} + +// tightenRepoAndLogForMiddleSpare shrinks log then repo until the middle row has at +// least layoutMinSpareForSplit lines, or returns ok=false. +func tightenRepoAndLogForMiddleSpare(effH, repoBody, logBody int) (repo, log int, available int, ok bool) { + repo, log = repoBody, logBody + available = effH - layoutFrameStackOuterRows - repo - log + for available < layoutMinSpareForSplit && log > layoutMinBodyLines { + log-- + available = effH - layoutFrameStackOuterRows - repo - log + } + for available < layoutMinSpareForSplit && repo > layoutMinBodyLines { + repo-- + available = effH - layoutFrameStackOuterRows - repo - log + } + if available < layoutMinSpareForSplit { + return 0, 0, 0, false + } + return repo, log, available, true +} + +// splitStatusBranchEvenly divides available inner lines between status and branch tables. +func splitStatusBranchEvenly(available int) (statusBody, branchBody int) { + statusBody = available / 2 + branchBody = available - statusBody + return statusBody, branchBody +} + +// nonZoomStackBodiesValid checks minimum inner heights and that status+branch fill +// the middle vertical budget (diff height is derived from the stack). +func nonZoomStackBodiesValid(repo, status, branch, diff, logBody, available int) bool { + if status < layoutMinBodyLines || branch < layoutMinBodyLines || diff < layoutMinBodyLines || + logBody < layoutMinBodyLines || repo < layoutMinBodyLines || + status+branch != available { + return false + } + return true +} + +// customVerticalLayoutOK validates user-resized vertical splits against the same +// invariants as layoutBodies. +func customVerticalLayoutOK(status, branch, diff, available int) bool { + if available < layoutMinSpareForSplit || diff < layoutMinBodyLines || + status+branch != available || + panelOuter(status)+panelOuter(branch) != panelOuter(diff) { + return false + } + return true +} + +// clampDiffColumnOuterWidth maps a mouse X (left column outer width) to a valid +// Diff column outer width for the middle row. +func clampDiffColumnOuterWidth(termWidth, leftOuter int) (rightOuter int, ok bool) { + if termWidth < layoutMinTermWidth { + return 0, false + } + rightOuter = termWidth - leftOuter + if rightOuter < layoutMinStatusBranchesColumn { + rightOuter = layoutMinStatusBranchesColumn + } + if rightOuter > termWidth-layoutMinStatusBranchesColumn { + rightOuter = termWidth - layoutMinStatusBranchesColumn + } + if rightOuter < layoutMinStatusBranchesColumn || rightOuter > termWidth-layoutMinStatusBranchesColumn { + return 0, false + } + return rightOuter, true +} diff --git a/ui/layout_limits.go b/ui/layout_limits.go index 7320810..e06d534 100644 --- a/ui/layout_limits.go +++ b/ui/layout_limits.go @@ -18,10 +18,14 @@ const ( layoutMinTermWidth = 20 layoutMinTermHeight = 22 - // layoutMinStatusBranchesColumn is a minimum for either status or branches column - // when splits are computed or user-resized. + // layoutMinStatusBranchesColumn is a minimum width for either middle-row column + // (status+branches stack vs diff) when splits are computed or user-resized. layoutMinStatusBranchesColumn = 10 + // layoutDefaultMiddleDiffColumnPct is the default share of terminal width for the + // framed Diff column in the middle row (remainder goes to Status+Branches). + layoutDefaultMiddleDiffColumnPct = 70 + // layoutMinInnerContentWidth is a floor for content inside borders or viewports // (also used as a placeholder size before syncViewports runs). layoutMinInnerContentWidth = 8 diff --git a/ui/model.go b/ui/model.go index a702bbe..4fd8f47 100644 --- a/ui/model.go +++ b/ui/model.go @@ -131,13 +131,14 @@ type model struct { zoomTarget pane // which pane is fullscreen when zoomed // layoutUseCustomVertical is true after the user resizes a horizontal seam; - // layoutRepoBody, layoutStatusBody, and layoutLogBody are inner body heights. + // layoutRepoBody, layoutStatusBody, layoutBranchBody, and layoutLogBody are inner body heights. layoutUseCustomVertical bool layoutRepoBody int layoutStatusBody int + layoutBranchBody int layoutLogBody int - // layoutBranchesOuter is the framed Branches column width in cells (0 = automatic). + // layoutBranchesOuter is the framed Diff (right) column outer width in the middle row (0 = default pct). layoutBranchesOuter int resizeDrag resizeSplit diff --git a/ui/mouse_line_select.go b/ui/mouse_line_select.go index 38781ff..83a6ef8 100644 --- a/ui/mouse_line_select.go +++ b/ui/mouse_line_select.go @@ -47,8 +47,8 @@ func (m *model) repoPaneOuterHeight() (outerH int, ok bool) { if m.height < layoutMinTermHeight || m.width < layoutMinTermWidth { return 0, false } - repoBody, statusBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && diffBody == 0 && logBody == 0 { + lay := m.layoutBodies() + if lay.isZero() { return 0, false } if m.zoomed { @@ -57,7 +57,7 @@ func (m *model) repoPaneOuterHeight() (outerH int, ok bool) { } return m.height, true } - return panelOuter(repoBody), true + return panelOuter(lay.repo), true } // statusPaneFrame returns the top Y of the status pane, its outer height, and @@ -66,8 +66,8 @@ func (m *model) statusPaneFrame() (topY, outerH, outerW int, ok bool) { if m.height < layoutMinTermHeight || m.width < layoutMinTermWidth { return 0, 0, 0, false } - repoBody, statusBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && diffBody == 0 && logBody == 0 { + lay := m.layoutBodies() + if lay.isZero() { return 0, 0, 0, false } if m.zoomed { @@ -76,10 +76,10 @@ func (m *model) statusPaneFrame() (topY, outerH, outerW int, ok bool) { } return 0, m.height, m.width, true } - repoOuter := panelOuter(repoBody) - statusOuter := panelOuter(statusBody) - statusW, _ := m.statusBranchesOuterWidths(m.width) - return repoOuter, statusOuter, statusW, true + repoOuter := panelOuter(lay.repo) + statusOuter := panelOuter(lay.status) + leftW, _ := m.middleRowColumnOuterWidths(m.width) + return repoOuter, statusOuter, leftW, true } // handleMousePaneLineSelect handles left-click row selection when the repo or diff --git a/ui/mouse_line_select_test.go b/ui/mouse_line_select_test.go index f39a659..672ed1c 100644 --- a/ui/mouse_line_select_test.go +++ b/ui/mouse_line_select_test.go @@ -16,8 +16,8 @@ func TestMouseRepoLineSelect(t *testing.T) { m.cursor = 0 m.focus = paneRepo - rb, _, _, _ := m.layoutBodies() - repoOuter := panelOuter(rb) + lay := m.layoutBodies() + repoOuter := panelOuter(lay.repo) if repoOuter < 4 { t.Fatalf("repoOuter=%d too small for test", repoOuter) } @@ -57,8 +57,8 @@ func TestMouseStatusLineSelect(t *testing.T) { m.focus = paneStatus m.syncViewports() - rb, _, _, _ := m.layoutBodies() - statusTop := panelOuter(rb) + lay := m.layoutBodies() + statusTop := panelOuter(lay.repo) // First data row: inner starts at statusTop+1, then header, then row 0. y := statusTop + 1 + statusTableHeaderLines(m.statusTable) msg := tea.MouseMsg{ @@ -98,8 +98,8 @@ func TestMouseStatusHeaderClickConsumed(t *testing.T) { m.focus = paneStatus m.syncViewports() - rb, _, _, _ := m.layoutBodies() - statusTop := panelOuter(rb) + lay := m.layoutBodies() + statusTop := panelOuter(lay.repo) y := statusTop + 1 // first inner line = table header prev := m.statusTable.Cursor() msg := tea.MouseMsg{X: 2, Y: y, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress} diff --git a/ui/mouse_resize.go b/ui/mouse_resize.go index b52e172..51593ed 100644 --- a/ui/mouse_resize.go +++ b/ui/mouse_resize.go @@ -10,9 +10,9 @@ type resizeSplit int const ( resizeNone resizeSplit = iota resizeRepoStatus - resizeStatusDiff - resizeDiffLog - resizeStatusBranches + resizeStatusBranch + resizeMiddleLog + resizeMiddleColumns ) // handleMousePaneResize handles click-drag on pane borders to resize splits. @@ -25,8 +25,8 @@ func (m *model) handleMousePaneResize(msg tea.MouseMsg) bool { return false } - repoBody, statusBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && diffBody == 0 && logBody == 0 { + lay := m.layoutBodies() + if lay.isZero() { return false } @@ -68,12 +68,31 @@ func (m *model) handleMousePaneResize(msg tea.MouseMsg) bool { } } +func splitSumSBPreservingRatio(statusBody, branchBody, sumSB int) (st, br int) { + pair := statusBody + branchBody + if pair <= 0 || sumSB <= 0 { + st = sumSB / 2 + br = sumSB - st + return st, br + } + st = statusBody * sumSB / pair + if st < layoutMinBodyLines { + st = layoutMinBodyLines + } + if st > sumSB-layoutMinBodyLines { + st = sumSB - layoutMinBodyLines + } + br = sumSB - st + return st, br +} + func (m *model) applyResizeDrag(x, y int) { - innerTotal := m.height - layoutFrameStackOuterRows - repoBody, statusBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && diffBody == 0 && logBody == 0 { + innerTotal := innerVerticalBudget(m.height) + lay := m.layoutBodies() + if lay.isZero() { return } + repoBody, statusBody, branchBody, diffBody, logBody := lay.repo, lay.status, lay.branch, lay.diff, lay.logBody switch m.resizeDrag { case resizeRepoStatus: @@ -93,30 +112,30 @@ func (m *model) applyResizeDrag(x, y int) { if repo < layoutMinBodyLines { return } - available := innerTotal - repo - logBody + logAdj := logBody + available := innerTotal - repo - logAdj if available < layoutMinSpareForSplit { return } - st := statusBody - di := available - st - if di < layoutMinBodyLines { - di = layoutMinBodyLines - st = available - di - } - if st < layoutMinBodyLines { - st = layoutMinBodyLines - di = available - st + sumSB := available + if sumSB < 2*layoutMinBodyLines { + return } - if di < layoutMinBodyLines || st < layoutMinBodyLines { + st, br := splitSumSBPreservingRatio(statusBody, branchBody, sumSB) + di := diffBodyFromStackedStatusBranch(st, br) + if st < layoutMinBodyLines || br < layoutMinBodyLines || di < layoutMinBodyLines { return } m.layoutUseCustomVertical = true m.layoutRepoBody = repo m.layoutStatusBody = st - m.layoutLogBody = logBody + m.layoutBranchBody = br + m.layoutLogBody = logAdj - case resizeStatusDiff: + case resizeStatusBranch: + sumSB := statusBody + branchBody repoOuter := panelOuter(repoBody) + middleOuter := panelOuter(diffBody) prevStatusOuter := panelOuter(statusBody) var statusOuter int if y < repoOuter+prevStatusOuter { @@ -124,67 +143,65 @@ func (m *model) applyResizeDrag(x, y int) { } else { statusOuter = y - repoOuter } - statusOuterMax := innerTotal - repoBody - logBody - 1 + minBranchOuter := panelOuter(layoutMinBodyLines) + statusOuterMax := repoOuter + middleOuter - minBranchOuter if statusOuterMax < layoutMinPanelOuter { return } statusOuter = max(layoutMinPanelOuter, min(statusOuter, statusOuterMax)) - status := statusOuter - 2 - if status < layoutMinBodyLines { - return - } - available := innerTotal - repoBody - logBody - diff := available - status - if diff < layoutMinBodyLines { + st := statusOuter - 2 + br := sumSB - st + if br < layoutMinBodyLines || st < layoutMinBodyLines { return } m.layoutUseCustomVertical = true m.layoutRepoBody = repoBody - m.layoutStatusBody = status + m.layoutStatusBody = st + m.layoutBranchBody = br m.layoutLogBody = logBody - case resizeDiffLog: + case resizeMiddleLog: repoOuter := panelOuter(repoBody) - y0 := repoOuter + panelOuter(statusBody) - prevDiffOuter := panelOuter(diffBody) + y0 := repoOuter + prevMiddleOuter := panelOuter(diffBody) minLogOuter := panelOuter(layoutMinBodyLines) - maxDiffOuter := m.height - repoOuter - panelOuter(statusBody) - minLogOuter - if maxDiffOuter < minLogOuter { + minMiddleOuter := panelOuter(diffBodyFromStackedStatusBranch(layoutMinBodyLines, layoutMinBodyLines)) + maxMiddleOuter := m.height - repoOuter - minLogOuter + if maxMiddleOuter < minMiddleOuter { return } - var diffOuter int - if y <= y0+prevDiffOuter-1 { - diffOuter = y - y0 + 1 + var middleOuter int + if y <= y0+prevMiddleOuter-1 { + middleOuter = y - y0 + 1 } else { - diffOuter = y - y0 + middleOuter = y - y0 } - diffOuter = max(minLogOuter, min(diffOuter, maxDiffOuter)) - diff := diffOuter - 2 - log := innerTotal - repoBody - statusBody - diff + middleOuter = max(minMiddleOuter, min(middleOuter, maxMiddleOuter)) + diff := middleOuter - 2 + sumSB := diff - 2 + if sumSB < 2*layoutMinBodyLines { + return + } + st, br := splitSumSBPreservingRatio(statusBody, branchBody, sumSB) + if diffBodyFromStackedStatusBranch(st, br) < layoutMinBodyLines { + return + } + log := innerTotal - repoBody - st - br if log < layoutMinBodyLines { return } m.layoutUseCustomVertical = true m.layoutRepoBody = repoBody - m.layoutStatusBody = statusBody + m.layoutStatusBody = st + m.layoutBranchBody = br m.layoutLogBody = log - case resizeStatusBranches: - if m.width < layoutMinTermWidth { - return - } - statusOuter := x - branches := m.width - statusOuter - if branches < layoutMinStatusBranchesColumn { - branches = layoutMinStatusBranchesColumn - } - if branches > m.width-layoutMinStatusBranchesColumn { - branches = m.width - layoutMinStatusBranchesColumn - } - if branches < layoutMinStatusBranchesColumn || branches > m.width-layoutMinStatusBranchesColumn { + case resizeMiddleColumns: + rightOuter, ok := clampDiffColumnOuterWidth(m.width, x) + if !ok { return } - m.layoutBranchesOuter = branches + m.layoutBranchesOuter = rightOuter default: return @@ -193,29 +210,31 @@ func (m *model) applyResizeDrag(x, y int) { // resizeSplitAt returns which resize handle (if any) lies at (x, y). func (m *model) resizeSplitAt(x, y int) (resizeSplit, bool) { - repoBody, statusBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && diffBody == 0 && logBody == 0 { + lay := m.layoutBodies() + if lay.isZero() { return resizeNone, false } - repoOuter := panelOuter(repoBody) - statusOuter := panelOuter(statusBody) - diffOuter := panelOuter(diffBody) - statusW, _ := m.statusBranchesOuterWidths(m.width) + repoOuter := panelOuter(lay.repo) + statusOuter := panelOuter(lay.status) + middleOuter := panelOuter(lay.diff) + leftOuter, _ := m.middleRowColumnOuterWidths(m.width) if nearInt(y, repoOuter-1) || nearInt(y, repoOuter) { return resizeRepoStatus, true } - y1 := repoOuter + statusOuter - if nearInt(y, y1-1) || nearInt(y, y1) { - return resizeStatusDiff, true + yMidLog := repoOuter + middleOuter + if nearInt(y, yMidLog-1) || nearInt(y, yMidLog) { + return resizeMiddleLog, true } - y2 := repoOuter + statusOuter + diffOuter - if nearInt(y, y2-1) || nearInt(y, y2) { - return resizeDiffLog, true + if x >= 0 && x < leftOuter && y >= repoOuter && y < repoOuter+middleOuter { + yStatusBranch := repoOuter + statusOuter + if nearInt(y, yStatusBranch-1) || nearInt(y, yStatusBranch) { + return resizeStatusBranch, true + } } - if x >= 0 && x < m.width && y >= repoOuter && y < repoOuter+statusOuter { - if nearInt(x, statusW-1) || nearInt(x, statusW) { - return resizeStatusBranches, true + if y >= repoOuter && y < repoOuter+middleOuter && x >= 0 { + if nearInt(x, leftOuter-1) || nearInt(x, leftOuter) { + return resizeMiddleColumns, true } } return resizeNone, false diff --git a/ui/mouse_resize_test.go b/ui/mouse_resize_test.go index 70f8155..b61cd3e 100644 --- a/ui/mouse_resize_test.go +++ b/ui/mouse_resize_test.go @@ -12,21 +12,16 @@ func TestResizeSplitAtHorizontalSeams(t *testing.T) { m.height = 30 m.repoList = []string{"a", "b", "c"} - repoBody, statusBody, diffBody, _ := m.layoutBodies() - repoOuter := panelOuter(repoBody) - statusOuter := panelOuter(statusBody) - diffOuter := panelOuter(diffBody) + lay := m.layoutBodies() + repoOuter := panelOuter(lay.repo) + middleOuter := panelOuter(lay.diff) if k, ok := m.resizeSplitAt(0, repoOuter); !ok || k != resizeRepoStatus { t.Fatalf("resizeSplitAt repo seam y=%d = (%v,%v), want (resizeRepoStatus,true)", repoOuter, k, ok) } - y1 := repoOuter + statusOuter - if k, ok := m.resizeSplitAt(50, y1); !ok || k != resizeStatusDiff { - t.Fatalf("resizeSplitAt status/diff y=%d = (%v,%v), want (resizeStatusDiff,true)", y1, k, ok) - } - y2 := repoOuter + statusOuter + diffOuter - if k, ok := m.resizeSplitAt(50, y2); !ok || k != resizeDiffLog { - t.Fatalf("resizeSplitAt diff/log y=%d = (%v,%v), want (resizeDiffLog,true)", y2, k, ok) + y1 := repoOuter + middleOuter + if k, ok := m.resizeSplitAt(50, y1); !ok || k != resizeMiddleLog { + t.Fatalf("resizeSplitAt middle/log y=%d = (%v,%v), want (resizeMiddleLog,true)", y1, k, ok) } } @@ -36,12 +31,28 @@ func TestResizeSplitAtVerticalBetweenStatusAndBranches(t *testing.T) { m.height = 30 m.repoList = []string{"a", "b", "c"} - repoBody, _, _, _ := m.layoutBodies() - repoOuter := panelOuter(repoBody) - statusW, _ := m.statusBranchesOuterWidths(m.width) - y := repoOuter + 2 - if k, ok := m.resizeSplitAt(statusW, y); !ok || k != resizeStatusBranches { - t.Fatalf("resizeSplitAt vertical split = (%v,%v), want (resizeStatusBranches,true)", k, ok) + l := m.layoutBodies() + repoOuter := panelOuter(l.repo) + statusOuter := panelOuter(l.status) + leftW, _ := m.middleRowColumnOuterWidths(m.width) + y := repoOuter + statusOuter - 1 + if k, ok := m.resizeSplitAt(leftW/2, y); !ok || k != resizeStatusBranch { + t.Fatalf("resizeSplitAt status/branch seam = (%v,%v), want (resizeStatusBranch,true)", k, ok) + } +} + +func TestResizeSplitAtMiddleColumnDivider(t *testing.T) { + m := newTestModel() + m.width = 100 + m.height = 30 + m.repoList = []string{"a", "b", "c"} + + l := m.layoutBodies() + repoOuter := panelOuter(l.repo) + leftW, _ := m.middleRowColumnOuterWidths(m.width) + y := repoOuter + panelOuter(l.diff)/2 + if k, ok := m.resizeSplitAt(leftW, y); !ok || k != resizeMiddleColumns { + t.Fatalf("resizeSplitAt left/diff column = (%v,%v), want (resizeMiddleColumns,true)", k, ok) } } @@ -51,8 +62,8 @@ func TestMouseDragResizesRepoPane(t *testing.T) { m.height = 30 m.repoList = []string{"a", "b", "c"} - before, _, _, _ := m.layoutBodies() - repoOuter := panelOuter(before) + before := m.layoutBodies() + repoOuter := panelOuter(before.repo) press := tea.MouseMsg{ X: 0, Y: repoOuter, @@ -72,9 +83,9 @@ func TestMouseDragResizesRepoPane(t *testing.T) { } next, _ := mm0.Update(motion) mm := next.(*model) - after, _, _, _ := mm.layoutBodies() - if after <= before { - t.Fatalf("repo body after drag = %d, before = %d, expected larger", after, before) + after := mm.layoutBodies() + if after.repo <= before.repo { + t.Fatalf("repo body after drag = %d, before = %d, expected larger", after.repo, before.repo) } if !mm.layoutUseCustomVertical { t.Fatal("expected layoutUseCustomVertical after resize") diff --git a/ui/status_layout.go b/ui/status_layout.go index 2e27f70..bc38219 100644 --- a/ui/status_layout.go +++ b/ui/status_layout.go @@ -14,9 +14,11 @@ import ( ) // autoLayoutBodies returns the default vertical split without user resize prefs. -func (m *model) autoLayoutBodies() (repoBody, statusBody, diffBody, logBody int) { +// Middle row: Status above Branches on the left, Diff on the right (same outer height as the stack). +// diffBody == statusBody+branchBody+2 so the Diff viewport fills the band; it is not added to the vertical body sum. +func (m *model) autoLayoutBodies() paneLayout { if m.height < layoutMinTermHeight || m.width < layoutMinTermWidth { - return 0, 0, 0, 0 + return paneLayout{} } if m.zoomed { body := max( @@ -24,77 +26,71 @@ func (m *model) autoLayoutBodies() (repoBody, statusBody, diffBody, logBody int) m.height-2, layoutMinBodyLines) switch m.zoomTarget { case paneRepo: - return body, 0, 0, 0 - case paneStatus, paneBranches: - return 0, body, 0, 0 + return paneLayout{repo: body} + case paneStatus: + return paneLayout{status: body} + case paneBranches: + return paneLayout{branch: body} case paneDiff: - return 0, 0, body, 0 + return paneLayout{diff: body} case paneLog: - return 0, 0, 0, body + return paneLayout{logBody: body} + default: + return paneLayout{} } } effH := m.height - logBody = layoutDefaultLogBodyLines + logBody := layoutDefaultLogBodyLines n := len(m.repoList) if n == 0 { n = 1 } - repoBody = min(n+2, effH/3) + repoBody := min(n+2, effH/3) repoBody = max(layoutMinBodyLines, repoBody) - available := effH - layoutFrameStackOuterRows - repoBody - logBody - for available < layoutMinSpareForSplit && logBody > layoutMinBodyLines { - logBody-- - available = effH - layoutFrameStackOuterRows - repoBody - logBody - } - for available < layoutMinSpareForSplit && repoBody > layoutMinBodyLines { - repoBody-- - available = effH - layoutFrameStackOuterRows - repoBody - logBody + repoBody, logBody, available, ok := tightenRepoAndLogForMiddleSpare(effH, repoBody, logBody) + if !ok { + return paneLayout{} } - if available < layoutMinSpareForSplit { - return 0, 0, 0, 0 + statusBody, branchBody := splitStatusBranchEvenly(available) + diffBody := diffBodyFromStackedStatusBranch(statusBody, branchBody) + if !nonZoomStackBodiesValid(repoBody, statusBody, branchBody, diffBody, logBody, available) { + return paneLayout{} } + return paneLayout{repo: repoBody, status: statusBody, branch: branchBody, diff: diffBody, logBody: logBody} +} - // Give Status and Diff a 1:3 height split. - // Any remainder rows go to Diff to keep it as large as possible. - statusBody = available / 4 - diffBody = available - statusBody - if statusBody < layoutMinBodyLines || diffBody < layoutMinBodyLines || logBody < layoutMinBodyLines || repoBody < layoutMinBodyLines { - return 0, 0, 0, 0 - } - return repoBody, statusBody, diffBody, logBody +func (m *model) clearCustomVerticalLayout() { + m.layoutUseCustomVertical = false + m.layoutRepoBody, m.layoutStatusBody, m.layoutBranchBody, m.layoutLogBody = 0, 0, 0, 0 } -// layoutBodies returns inner content heights: repo list, status table, diff viewport, log viewport. +// layoutBodies returns inner content heights: repo list, status table, branch table, diff viewport, log viewport. // Each framed panel's total row count is panelOuter(body) (see framedBlock). -func (m *model) layoutBodies() (repoBody, statusBody, diffBody, logBody int) { - ar, as, ad, al := m.autoLayoutBodies() - if ar == 0 && as == 0 && ad == 0 && al == 0 { - return 0, 0, 0, 0 +func (m *model) layoutBodies() paneLayout { + auto := m.autoLayoutBodies() + if auto.isZero() { + return paneLayout{} } if m.zoomed || !m.layoutUseCustomVertical { - return ar, as, ad, al + return auto } - innerTotal := m.height - layoutFrameStackOuterRows - repoBody = m.layoutRepoBody - statusBody = m.layoutStatusBody - logBody = m.layoutLogBody - if repoBody < layoutMinBodyLines || statusBody < layoutMinBodyLines || logBody < layoutMinBodyLines { - return ar, as, ad, al + innerTotal := innerVerticalBudget(m.height) + repoBody := m.layoutRepoBody + statusBody := m.layoutStatusBody + branchBody := m.layoutBranchBody + logBody := m.layoutLogBody + if repoBody < layoutMinBodyLines || statusBody < layoutMinBodyLines || branchBody < layoutMinBodyLines || logBody < layoutMinBodyLines { + m.clearCustomVerticalLayout() + return auto } available := innerTotal - repoBody - logBody - diffBody = available - statusBody - if available < layoutMinSpareForSplit || diffBody < layoutMinBodyLines || repoBody < layoutMinBodyLines || statusBody < layoutMinBodyLines || logBody < layoutMinBodyLines { - m.layoutUseCustomVertical = false - m.layoutRepoBody, m.layoutStatusBody, m.layoutLogBody = 0, 0, 0 - return ar, as, ad, al + diffBody := diffBodyFromStackedStatusBranch(statusBody, branchBody) + if !customVerticalLayoutOK(statusBody, branchBody, diffBody, available) { + m.clearCustomVerticalLayout() + return auto } - return repoBody, statusBody, diffBody, logBody -} - -// panelOuter converts an inner body height into full framed panel height. -func panelOuter(body int) int { - return body + 2 // top border (with title) + body + bottom border + return paneLayout{repo: repoBody, status: statusBody, branch: branchBody, diff: diffBody, logBody: logBody} } // paneAtTerminalCell maps a 0-based terminal cell (from Bubble Tea mouse events) @@ -106,34 +102,33 @@ func (m *model) paneAtTerminalCell(x, y int) (pane, bool) { if x < 0 || y < 0 || x >= m.width || y >= m.height { return paneRepo, false } - repoBody, statusBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && diffBody == 0 && logBody == 0 { + lay := m.layoutBodies() + if lay.isZero() { return paneRepo, false } if m.zoomed { return m.zoomTarget, true } - repoOuter := panelOuter(repoBody) - statusOuter := panelOuter(statusBody) - diffOuter := panelOuter(diffBody) - logOuter := panelOuter(logBody) + repoOuter := panelOuter(lay.repo) + statusOuter := panelOuter(lay.status) + middleOuter := panelOuter(lay.diff) + logOuter := panelOuter(lay.logBody) + leftOuter, _ := m.middleRowColumnOuterWidths(m.width) if y < repoOuter { return paneRepo, true } y -= repoOuter - if y < statusOuter { - statusW, _ := m.statusBranchesOuterWidths(m.width) - if x < statusW { - return paneStatus, true + if y < middleOuter { + if x < leftOuter { + if y < statusOuter { + return paneStatus, true + } + return paneBranches, true } - return paneBranches, true - } - y -= statusOuter - if y < diffOuter { return paneDiff, true } - y -= diffOuter + y -= middleOuter if y < logOuter { return paneLog, true } @@ -149,25 +144,25 @@ func (m *model) innerWidth() int { return w } -// statusBranchesOuterWidths splits the row width between Status and Branches panes. -func (m *model) statusBranchesOuterWidths(total int) (statusOuter, branchesOuter int) { +// middleRowColumnOuterWidths splits the middle row: left (Status+Branches stack) vs right (Diff). +func (m *model) middleRowColumnOuterWidths(total int) (leftOuter, rightOuter int) { if m.layoutBranchesOuter > 0 { - bo := m.layoutBranchesOuter - bo = max(layoutMinStatusBranchesColumn, min(bo, total-layoutMinStatusBranchesColumn)) - return total - bo, bo + right := m.layoutBranchesOuter + right = max(layoutMinStatusBranchesColumn, min(right, total-layoutMinStatusBranchesColumn)) + return total - right, right } - // Default: equal outer width (remainder column goes to Branches when odd). - statusOuter = total / 2 - branchesOuter = total - statusOuter - return statusOuter, branchesOuter + rawRight := total * layoutDefaultMiddleDiffColumnPct / 100 + rightOuter = max(layoutMinStatusBranchesColumn, min(rawRight, total-layoutMinStatusBranchesColumn)) + leftOuter = total - rightOuter + return leftOuter, rightOuter } -// statusBranchesInnerWidths returns table content widths inside each pane's own border. -func (m *model) statusBranchesInnerWidths() (statusInnerW, branchInnerW int) { - statusOuterW, branchesOuterW := m.statusBranchesOuterWidths(m.width) - statusInnerW = max(layoutMinInnerContentWidth, statusOuterW-2) - branchInnerW = max(layoutMinInnerContentWidth, branchesOuterW-2) - return statusInnerW, branchInnerW +// middleRowColumnInnerWidths returns content widths inside the left stack panes and the Diff pane. +func (m *model) middleRowColumnInnerWidths() (leftInnerW, rightInnerW int) { + leftOuterW, rightOuterW := m.middleRowColumnOuterWidths(m.width) + leftInnerW = max(layoutMinInnerContentWidth, leftOuterW-2) + rightInnerW = max(layoutMinInnerContentWidth, rightOuterW-2) + return leftInnerW, rightInnerW } // syncViewports applies layout dimensions and refreshes all pane content, @@ -193,26 +188,29 @@ func (m *model) setLogVPContent() { // a loading line until a follow-up runDiffForGen is handled. Used when // the repository selection changes so scrolling stays responsive. func (m *model) applyViewportAndPanes(syncDiff bool) { - repoBody, statusBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && diffBody == 0 && logBody == 0 { + lay := m.layoutBodies() + if lay.isZero() { return } statusInnerW := m.innerWidth() branchInnerW := statusInnerW + diffInnerW := statusInnerW if !m.zoomed { - statusInnerW, branchInnerW = m.statusBranchesInnerWidths() + statusInnerW, diffInnerW = m.middleRowColumnInnerWidths() + branchInnerW = statusInnerW } m.statusTable.SetWidth(statusInnerW) - m.statusTable.SetHeight(statusBody) + m.statusTable.SetHeight(lay.status) m.statusTable.SetColumns(statusColumns(statusInnerW)) m.logVP.Width = m.innerWidth() - m.logVP.Height = logBody - m.diffVP.Width = m.innerWidth() - m.diffVP.Height = diffBody + m.logVP.Height = lay.logBody + m.diffVP.Width = diffInnerW + m.diffVP.Height = lay.diff m.refreshStatusContent() m.refreshBranchContent(branchInnerW) + m.branchTable.SetHeight(lay.branch) if syncDiff { m.refreshDiffContent() } else if m.diffNeedsRefresh { @@ -220,7 +218,7 @@ func (m *model) applyViewportAndPanes(syncDiff bool) { } m.diffVP.SetContent(m.diffContent) m.setLogVPContent() - m.clampRepoScroll(repoBody) + m.clampRepoScroll(lay.repo) } // newStatusTable builds the status pane table with default styling. diff --git a/ui/status_layout_test.go b/ui/status_layout_test.go index b2c838b..674d6bb 100644 --- a/ui/status_layout_test.go +++ b/ui/status_layout_test.go @@ -37,12 +37,12 @@ func TestPaneAtTerminalCell(t *testing.T) { m.height = 30 m.repoList = []string{"a", "b", "c"} - repoBody, statusBody, diffBody, logBody := m.layoutBodies() - repoOuter := panelOuter(repoBody) - statusOuter := panelOuter(statusBody) - diffOuter := panelOuter(diffBody) - logOuter := panelOuter(logBody) - statusW, _ := m.statusBranchesOuterWidths(m.width) + lay := m.layoutBodies() + repoOuter := panelOuter(lay.repo) + statusOuter := panelOuter(lay.status) + middleOuter := panelOuter(lay.diff) + logOuter := panelOuter(lay.logBody) + leftW, _ := m.middleRowColumnOuterWidths(m.width) tests := []struct { x, y int @@ -52,19 +52,20 @@ func TestPaneAtTerminalCell(t *testing.T) { {0, 0, paneRepo, true}, {99, repoOuter - 1, paneRepo, true}, {0, repoOuter, paneStatus, true}, - {statusW - 1, repoOuter, paneStatus, true}, - {statusW, repoOuter, paneBranches, true}, - {0, repoOuter + statusOuter, paneDiff, true}, - {0, repoOuter + statusOuter + diffOuter, paneLog, true}, - {0, repoOuter + statusOuter + diffOuter + logOuter - 1, paneLog, true}, + {leftW - 1, repoOuter, paneStatus, true}, + {leftW, repoOuter, paneDiff, true}, + {0, repoOuter + statusOuter, paneBranches, true}, + {leftW, repoOuter + statusOuter, paneDiff, true}, + {0, repoOuter + middleOuter, paneLog, true}, + {0, repoOuter + middleOuter + logOuter - 1, paneLog, true}, {-1, 0, paneRepo, false}, {0, m.height, paneRepo, false}, } for _, tc := range tests { got, ok := m.paneAtTerminalCell(tc.x, tc.y) if ok != tc.ok || got != tc.want { - t.Fatalf("paneAtTerminalCell(%d,%d) = (%v,%v), want (%v,%v) repoOuter=%d statusOuter=%d diffOuter=%d logOuter=%d statusW=%d", - tc.x, tc.y, got, ok, tc.want, tc.ok, repoOuter, statusOuter, diffOuter, logOuter, statusW) + t.Fatalf("paneAtTerminalCell(%d,%d) = (%v,%v), want (%v,%v) repoOuter=%d statusOuter=%d middleOuter=%d logOuter=%d leftW=%d", + tc.x, tc.y, got, ok, tc.want, tc.ok, repoOuter, statusOuter, middleOuter, logOuter, leftW) } } @@ -83,10 +84,10 @@ func TestMouseFocusClickUpdatesFocus(t *testing.T) { m.repoList = []string{"a", "b", "c"} m.focus = paneRepo - rb, sb, _, _ := m.layoutBodies() + l := m.layoutBodies() click := tea.MouseMsg{ X: 0, - Y: panelOuter(rb) + panelOuter(sb)/2, + Y: panelOuter(l.repo) + panelOuter(l.status)/2, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, } @@ -129,12 +130,12 @@ func TestLayoutBodies(t *testing.T) { m.height = 30 m.repoList = []string{"a", "b", "c"} - repoBody, statusBody, diffBody, logBody := m.layoutBodies() - if repoBody < 3 || statusBody < 3 || diffBody < 3 || logBody < 3 { - t.Fatalf("layoutBodies() = (%d, %d, %d, %d), expected all >= 3", repoBody, statusBody, diffBody, logBody) + lay := m.layoutBodies() + if lay.repo < 3 || lay.status < 3 || lay.branch < 3 || lay.diff < 3 || lay.logBody < 3 { + t.Fatalf("layoutBodies() = (%d, %d, %d, %d, %d), expected all >= 3", lay.repo, lay.status, lay.branch, lay.diff, lay.logBody) } - if diffBody < statusBody*3 { - t.Fatalf("layoutBodies() expected diff to be at least 3x status, got status=%d diff=%d", statusBody, diffBody) + if lay.diff != lay.status+lay.branch+2 { + t.Fatalf("layoutBodies() diff=%d want status+branch+2=%d", lay.diff, lay.status+lay.branch+2) } } @@ -144,9 +145,9 @@ func TestLayoutBodiesReturnsZerosOnSmallScreen(t *testing.T) { m.width = 10 m.height = 10 - repoBody, statusBody, diffBody, logBody := m.layoutBodies() - if repoBody != 0 || statusBody != 0 || diffBody != 0 || logBody != 0 { - t.Fatalf("layoutBodies() = (%d, %d, %d, %d), want (0,0,0,0)", repoBody, statusBody, diffBody, logBody) + lay := m.layoutBodies() + if !lay.isZero() { + t.Fatalf("layoutBodies() = (%d, %d, %d, %d, %d), want zeros", lay.repo, lay.status, lay.branch, lay.diff, lay.logBody) } } @@ -158,9 +159,9 @@ func TestLayoutBodiesZoomedPaneOnly(t *testing.T) { m.zoomed = true m.zoomTarget = paneStatus - repoBody, statusBody, diffBody, logBody := m.layoutBodies() - if repoBody != 0 || diffBody != 0 || logBody != 0 || statusBody == 0 { - t.Fatalf("layoutBodies() = (%d, %d, %d, %d), want repo=0 status>0 diff=0 log=0", repoBody, statusBody, diffBody, logBody) + lay := m.layoutBodies() + if lay.repo != 0 || lay.branch != 0 || lay.diff != 0 || lay.logBody != 0 || lay.status == 0 { + t.Fatalf("layoutBodies() = (%d, %d, %d, %d, %d), want repo=0 status>0 branch=0 diff=0 log=0", lay.repo, lay.status, lay.branch, lay.diff, lay.logBody) } } diff --git a/ui/update.go b/ui/update.go index 5ecd60f..6f43ac2 100644 --- a/ui/update.go +++ b/ui/update.go @@ -277,24 +277,52 @@ func (m *model) toggleZoom() { m.zoomTarget = m.focus } -// cycleFocus moves focus across panes in forward or reverse order. +// tabFocusCycle is the Tab / Shift+Tab order. Diff is not included; focus it by clicking the pane. +var tabFocusCycle = []pane{paneRepo, paneStatus, paneBranches, paneLog} + +// cycleFocus moves focus across panes in forward or reverse order (skipping Diff). func (m *model) cycleFocus(forward bool) { - const paneCount = 5 + n := len(tabFocusCycle) + cur := m.focus if m.zoomed { + cur = m.zoomTarget + } + + var i int + found := false + for j, p := range tabFocusCycle { + if p == cur { + i = j + found = true + break + } + } + if !found { + if cur == paneDiff { + // Mouse-focused Diff: Tab continues the main layout order (Branches → Diff → Log). + if forward { + i = 3 // log + } else { + i = 2 // branches + } + } else { + i = 0 + } + } else { if forward { - m.zoomTarget = (m.zoomTarget + 1) % paneCount + i = (i + 1) % n } else { - m.zoomTarget = (m.zoomTarget - 1 + paneCount) % paneCount + i = (i - 1 + n) % n } - m.focus = m.zoomTarget - return } - if forward { - m.focus = (m.focus + 1) % paneCount + next := tabFocusCycle[i] + if m.zoomed { + m.zoomTarget = next + m.focus = next return } - m.focus = (m.focus - 1 + paneCount) % paneCount + m.focus = next } // handleCommandKey handles global command keys and focus controls. @@ -383,6 +411,21 @@ func (m *model) handleCommandKey(msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { m.deleteConfirmYes = false return m, nil, true } + case " ": + if m.focus != paneDiff && m.focus != paneStatus { + return m, nil, false + } + prevMode := m.diffMode + if m.diffMode == diffModeWorktree { + m.diffMode = diffModeStaged + } else { + m.diffMode = diffModeWorktree + } + if m.diffMode != prevMode { + m.diffNeedsRefresh = true + m.syncViewports() + } + return m, nil, true default: if helpKey(msg) { m.helpOpen = true @@ -395,7 +438,8 @@ func (m *model) handleCommandKey(msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { // listKeyScrollPage is the step size for Shift+↑/↓ in scrollable lists and viewports. const listKeyScrollPage = 10 -// handleArrowKey applies directional key behavior for the focused pane. +// handleArrowKey applies directional keys: scrolling / repo navigation, and +// ←/→ to move focus between Status and Diff. func (m *model) handleArrowKey(msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { switch msg.Type { case tea.KeyUp, tea.KeyDown, tea.KeyShiftUp, tea.KeyShiftDown: @@ -474,21 +518,24 @@ func (m *model) handleArrowKey(msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { m.logVP, cmd = m.logVP.Update(msg) return m, cmd, true } - case tea.KeyLeft, tea.KeyRight: - if m.focus != paneDiff && m.focus != paneStatus { - return m, nil, false - } - prevMode := m.diffMode - if msg.Type == tea.KeyLeft { - m.diffMode = diffModeWorktree - } else { - m.diffMode = diffModeStaged + case tea.KeyRight: + if m.focus == paneStatus { + m.focus = paneDiff + if m.zoomed { + m.zoomTarget = paneDiff + } + m.syncViewports() + return m, nil, true } - if m.diffMode != prevMode { - m.diffNeedsRefresh = true + case tea.KeyLeft: + if m.focus == paneDiff { + m.focus = paneStatus + if m.zoomed { + m.zoomTarget = paneStatus + } m.syncViewports() + return m, nil, true } - return m, nil, true } return m, nil, false } diff --git a/ui/update_view_test.go b/ui/update_view_test.go index aad1c5a..0b6c6c4 100644 --- a/ui/update_view_test.go +++ b/ui/update_view_test.go @@ -88,6 +88,26 @@ func TestHandleCommandKeyFocusAndZoom(t *testing.T) { t.Fatalf("shift+tab should move focus back to repo, got focus=%v handled=%v", m.focus, handled) } + // Full forward cycle: Repo → Status → Branches → Log → Repo (never Diff via Tab). + for _, want := range []pane{paneStatus, paneBranches, paneLog, paneRepo} { + _, _, handled = m.handleCommandKey(tea.KeyMsg{Type: tea.KeyTab}) + if !handled || m.focus != want { + t.Fatalf("tab full cycle want focus=%v got %v handled=%v", want, m.focus, handled) + } + } + + m.focus = paneDiff + _, _, handled = m.handleCommandKey(tea.KeyMsg{Type: tea.KeyTab}) + if !handled || m.focus != paneLog { + t.Fatalf("tab from mouse-focused Diff should go to Log, got focus=%v handled=%v", m.focus, handled) + } + m.focus = paneDiff + _, _, handled = m.handleCommandKey(tea.KeyMsg{Type: tea.KeyShiftTab}) + if !handled || m.focus != paneBranches { + t.Fatalf("shift+tab from Diff should go to Branches, got focus=%v handled=%v", m.focus, handled) + } + + m.focus = paneRepo _, _, handled = m.handleCommandKey(tea.KeyMsg{Type: tea.KeyEnter}) if !handled || !m.zoomed || m.zoomTarget != paneRepo { t.Fatalf("enter should enable zoom on focused pane, got zoomed=%v zoomTarget=%v", m.zoomed, m.zoomTarget) @@ -117,6 +137,38 @@ func TestHandleArrowKeyRepoNavigation(t *testing.T) { } } +// TestHandleArrowKeyStatusDiffFocus verifies →/← moves focus between Status and Diff. +func TestHandleArrowKeyStatusDiffFocus(t *testing.T) { + m := newTestModel() + m.width = 120 + m.height = 40 + m.focus = paneStatus + + _, _, handled := m.handleArrowKey(tea.KeyMsg{Type: tea.KeyRight}) + if !handled || m.focus != paneDiff { + t.Fatalf("right from status should focus diff, got focus=%v handled=%v", m.focus, handled) + } + + _, _, handled = m.handleArrowKey(tea.KeyMsg{Type: tea.KeyLeft}) + if !handled || m.focus != paneStatus { + t.Fatalf("left from diff should focus status, got focus=%v handled=%v", m.focus, handled) + } + + m.zoomed = true + m.zoomTarget = paneStatus + m.focus = paneStatus + _, _, handled = m.handleArrowKey(tea.KeyMsg{Type: tea.KeyRight}) + if !handled || m.focus != paneDiff || m.zoomTarget != paneDiff { + t.Fatalf("zoomed right should move focus and zoom target to diff, got focus=%v zoomTarget=%v handled=%v", + m.focus, m.zoomTarget, handled) + } + _, _, handled = m.handleArrowKey(tea.KeyMsg{Type: tea.KeyLeft}) + if !handled || m.focus != paneStatus || m.zoomTarget != paneStatus { + t.Fatalf("zoomed left should move focus and zoom target to status, got focus=%v zoomTarget=%v handled=%v", + m.focus, m.zoomTarget, handled) + } +} + // TestHandleArrowKeyRepoShiftStep verifies Shift+↓/↑ moves the repo cursor by 10 (clamped). func TestHandleArrowKeyRepoShiftStep(t *testing.T) { m := newTestModel() @@ -180,37 +232,37 @@ func TestRepoListScrollFollowsCursor(t *testing.T) { } } -// TestHandleArrowKeyDiffModeToggle verifies staged/worktree diff switching. -func TestHandleArrowKeyDiffModeToggle(t *testing.T) { +// TestHandleCommandKeySpaceDiffModeToggle verifies Space toggles staged/worktree diff in the Diff pane. +func TestHandleCommandKeySpaceDiffModeToggle(t *testing.T) { m := newTestModel() m.focus = paneDiff m.diffMode = diffModeWorktree - _, _, handled := m.handleArrowKey(tea.KeyMsg{Type: tea.KeyRight}) + _, _, handled := m.handleCommandKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) if !handled || m.diffMode != diffModeStaged { - t.Fatalf("right should switch diff mode to staged, got mode=%v handled=%v", m.diffMode, handled) + t.Fatalf("space should switch diff mode to staged, got mode=%v handled=%v", m.diffMode, handled) } - _, _, handled = m.handleArrowKey(tea.KeyMsg{Type: tea.KeyLeft}) + _, _, handled = m.handleCommandKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) if !handled || m.diffMode != diffModeWorktree { - t.Fatalf("left should switch diff mode to worktree, got mode=%v handled=%v", m.diffMode, handled) + t.Fatalf("space should switch diff mode to worktree, got mode=%v handled=%v", m.diffMode, handled) } } -// TestHandleArrowKeyStatusPaneDiffModeToggle verifies Status pane uses the same ←/→ diff mode as Diff. -func TestHandleArrowKeyStatusPaneDiffModeToggle(t *testing.T) { +// TestHandleCommandKeyStatusPaneSpaceDiffModeToggle verifies Status pane uses the same Space diff toggle as Diff. +func TestHandleCommandKeyStatusPaneSpaceDiffModeToggle(t *testing.T) { m := newTestModel() m.focus = paneStatus m.diffMode = diffModeWorktree - _, _, handled := m.handleArrowKey(tea.KeyMsg{Type: tea.KeyRight}) + _, _, handled := m.handleCommandKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) if !handled || m.diffMode != diffModeStaged { - t.Fatalf("right from status should switch diff mode to staged, got mode=%v handled=%v", m.diffMode, handled) + t.Fatalf("space from status should switch diff mode to staged, got mode=%v handled=%v", m.diffMode, handled) } - _, _, handled = m.handleArrowKey(tea.KeyMsg{Type: tea.KeyLeft}) + _, _, handled = m.handleCommandKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) if !handled || m.diffMode != diffModeWorktree { - t.Fatalf("left from status should switch diff mode to worktree, got mode=%v handled=%v", m.diffMode, handled) + t.Fatalf("space from status should switch diff mode to worktree, got mode=%v handled=%v", m.diffMode, handled) } } diff --git a/ui/view.go b/ui/view.go index e1a145d..594c17c 100644 --- a/ui/view.go +++ b/ui/view.go @@ -113,13 +113,14 @@ func (m *model) helpPanel() string { lines := []string{ "Click Focus a pane; in Repositories or Status (when focused), select a row", "Drag (border) Resize adjacent panes (unavailable when zoomed, scanning, on error, or with an overlay open)", - "Tab Next pane: Repositories → Status → Branches → Diff → Log; when zoomed, cycle which pane is fullscreen", + "Tab Next pane: Repositories → Status → Branches → Log (Diff: click or → from Status); when zoomed, cycle which pane is fullscreen", "Shift+Tab Previous pane; when zoomed, cycle backward", "Enter Zoom focused pane; Enter again restores the split layout", "Esc Exit zoom, or clear Status file selection; also closes this help", "↑ / ↓ Move repo selection or scroll Status / Diff / Log", + "← / → Status focused: → focuses Diff; Diff focused: ← focuses Status", "Shift+↑/↓ Same, in steps of 10 lines", - "← / → In Status or Diff: switch Worktree vs Staged diff", + "Space In Status or Diff: toggle Worktree vs Staged diff", "a r With a file row selected (Status or Diff): git add / git reset (unstage) that path", "C With a file row selected (Status or Diff): restore file to last commit (confirms git checkout HEAD -- path)", "s Scan / rescan", @@ -286,12 +287,18 @@ func (m *model) framedBlock(p pane, outerW, outerH int, title string, body strin return strings.Join(framed, "\n") } -// framedStatusBranchesRow draws Status and Branches as two framed panes side by side. -func (m *model) framedStatusBranchesRow(outerH int, statusBody, branchesBody string) string { - statusOuterW, branchesOuterW := m.statusBranchesOuterWidths(m.width) - statusBlock := m.framedBlock(paneStatus, statusOuterW, outerH, "Status", statusBody) - branchesBlock := m.framedBlock(paneBranches, branchesOuterW, outerH, "Branches", branchesBody) - return lipgloss.JoinHorizontal(lipgloss.Top, statusBlock, branchesBlock) +// framedMiddleRow draws the middle band: Status above Branches on the left, Diff on the right. +func (m *model) framedMiddleRow(statusBody, branchBody, diffBody int, statusView, branchView, diffView string) string { + leftW, rightW := m.middleRowColumnOuterWidths(m.width) + statusOuter := panelOuter(statusBody) + branchOuter := panelOuter(branchBody) + diffOuter := panelOuter(diffBody) + leftCol := lipgloss.JoinVertical(lipgloss.Left, + m.framedBlock(paneStatus, leftW, statusOuter, "Status", statusView), + m.framedBlock(paneBranches, leftW, branchOuter, "Branches", branchView), + ) + rightCol := m.framedBlock(paneDiff, rightW, diffOuter, "Diff", diffView) + return lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol) } // clampRepoScroll keeps repoScrollTop in range and ensures the cursor row is visible. @@ -332,11 +339,11 @@ func (m *model) clampRepoScroll(innerH int) { // syncRepoListScrollOnly updates repoScrollTop for the current cursor; it is // cheap and used while keyboard navigation debounces the rest of the UI. func (m *model) syncRepoListScrollOnly() { - repoBody, statusBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && diffBody == 0 && logBody == 0 { + lay := m.layoutBodies() + if lay.isZero() { return } - m.clampRepoScroll(repoBody) + m.clampRepoScroll(lay.repo) } // repoListView renders the repository list with current selection styling. @@ -389,10 +396,10 @@ func (m *model) renderScanOverlay() string { } // renderZoomedPane draws only the active pane in fullscreen mode. -func (m *model) renderZoomedPane(repoBody int) string { +func (m *model) renderZoomedPane(lay paneLayout) string { switch m.zoomTarget { case paneRepo: - return m.framedBlock(paneRepo, m.width, m.height, "Repositories", m.repoListView(repoBody)) + return m.framedBlock(paneRepo, m.width, m.height, "Repositories", m.repoListView(lay.repo)) case paneStatus: return m.framedBlock(paneStatus, m.width, m.height, "Status", m.statusTable.View()) case paneBranches: @@ -407,20 +414,17 @@ func (m *model) renderZoomedPane(repoBody int) string { } } -// renderMainStack composes the standard four-pane vertical layout. -func (m *model) renderMainStack(repoBody, statusBody, diffBody, logBody int) string { - repoOuter := panelOuter(repoBody) - statusOuter := panelOuter(statusBody) - diffOuter := panelOuter(diffBody) - logOuter := panelOuter(logBody) +// renderMainStack composes the standard main layout: repo, middle row (status/branch + diff), log. +func (m *model) renderMainStack(lay paneLayout) string { + repoOuter := panelOuter(lay.repo) + logOuter := panelOuter(lay.logBody) - repoBlock := m.framedBlock(paneRepo, m.width, repoOuter, "Repositories", m.repoListView(repoBody)) - statusRow := m.framedStatusBranchesRow(statusOuter, m.statusTable.View(), m.branchTable.View()) - diffBlock := m.framedBlock(paneDiff, m.width, diffOuter, "Diff", m.diffVP.View()) + repoBlock := m.framedBlock(paneRepo, m.width, repoOuter, "Repositories", m.repoListView(lay.repo)) + middleRow := m.framedMiddleRow(lay.status, lay.branch, lay.diff, m.statusTable.View(), m.branchTable.View(), m.diffVP.View()) m.setLogVPContent() logBlock := m.framedBlock(paneLog, m.width, logOuter, "Log", m.logVP.View()) - return lipgloss.JoinVertical(lipgloss.Left, repoBlock, statusRow, diffBlock, logBlock) + return lipgloss.JoinVertical(lipgloss.Left, repoBlock, middleRow, logBlock) } // renderErrorOverlay shows an error dialog with recovery hints. @@ -457,16 +461,16 @@ func (m *model) View() string { return m.renderScanOverlay() } - repoBody, statusBody, diffBody, logBody := m.layoutBodies() - if repoBody == 0 && statusBody == 0 && diffBody == 0 && logBody == 0 { + lay := m.layoutBodies() + if lay.isZero() { return "" } stack := "" if m.zoomed { - stack = m.renderZoomedPane(repoBody) + stack = m.renderZoomedPane(lay) } else { - stack = m.renderMainStack(repoBody, statusBody, diffBody, logBody) + stack = m.renderMainStack(lay) } if m.err != nil { return m.renderErrorOverlay() diff --git a/ui/view_test.go b/ui/view_test.go index d9f2487..93076a6 100644 --- a/ui/view_test.go +++ b/ui/view_test.go @@ -15,37 +15,40 @@ func init() { lipgloss.SetColorProfile(termenv.ANSI256) } -// TestStatusBranchesRowUsesFullWidth ensures outer pane widths sum to the terminal width. -func TestStatusBranchesRowUsesFullWidth(t *testing.T) { +// TestMiddleRowColumnWidthsSumToTermWidth ensures middle row columns fill the width. +func TestMiddleRowColumnWidthsSumToTermWidth(t *testing.T) { m := newTestModel() m.width = 100 - so, bo := m.statusBranchesOuterWidths(m.width) - if so+bo != m.width { - t.Fatalf("statusOuter(%d) + branchesOuter(%d) = %d, want terminal width %d", - so, bo, so+bo, m.width) + lo, ro := m.middleRowColumnOuterWidths(m.width) + if lo+ro != m.width { + t.Fatalf("leftOuter(%d) + rightOuter(%d) = %d, want terminal width %d", + lo, ro, lo+ro, m.width) } - if so != 50 || bo != 50 { - t.Fatalf("default horizontal split = (%d,%d), want (50,50)", so, bo) + if lo != 30 || ro != 70 { + t.Fatalf("default horizontal split = (%d,%d), want (30,70)", lo, ro) } } -func TestStatusBranchesOuterWidthsOddWidthPutsRemainderOnBranches(t *testing.T) { +func TestMiddleRowColumnOuterWidthsOddWidth(t *testing.T) { m := newTestModel() m.width = 81 - so, bo := m.statusBranchesOuterWidths(m.width) - if so != 40 || bo != 41 { - t.Fatalf("statusOuter, branchesOuter = (%d,%d), want (40,41)", so, bo) + lo, ro := m.middleRowColumnOuterWidths(m.width) + if lo+ro != m.width { + t.Fatalf("leftOuter(%d)+rightOuter(%d) != width %d", lo, ro, m.width) + } + if lo != 25 || ro != 56 { + t.Fatalf("leftOuter, rightOuter = (%d,%d), want (25,56)", lo, ro) } } // TestStatusTableViewFitsInnerWidth guards against table padding widening rows past the pane. func TestStatusTableViewFitsInnerWidth(t *testing.T) { m := newTestModel() - m.width = 80 + m.width = 100 m.height = 30 m.repoList = []string{"/repo"} m.syncViewports() - si, _ := m.statusBranchesInnerWidths() + si, _ := m.middleRowColumnInnerWidths() for _, line := range strings.Split(m.statusTable.View(), "\n") { if line == "" { continue @@ -73,7 +76,7 @@ func TestBranchTableViewFitsInnerWidth(t *testing.T) { }, }) m.syncViewports() - _, bi := m.statusBranchesInnerWidths() + _, bi := m.middleRowColumnInnerWidths() for _, line := range strings.Split(m.branchTable.View(), "\n") { if line == "" { continue @@ -84,11 +87,12 @@ func TestBranchTableViewFitsInnerWidth(t *testing.T) { } } -// blankStatusBranchesRow renders the Status/Branches row with empty table bodies for predictable ANSI. -func blankStatusBranchesRow(m *model, outerH int) string { +// blankMiddleRow renders the middle band with empty bodies for predictable ANSI. +func blankMiddleRow(m *model, statusBody, branchBody, diffBody int) string { m.syncViewports() - si, bi := m.statusBranchesInnerWidths() - return m.framedStatusBranchesRow(outerH, strings.Repeat(" ", si), strings.Repeat(" ", bi)) + li, ri := m.middleRowColumnInnerWidths() + return m.framedMiddleRow(statusBody, branchBody, diffBody, + strings.Repeat(" ", li), strings.Repeat(" ", li), strings.Repeat(" ", ri)) } // ansi214Count counts 256-color foreground sequences for lipgloss color 214 (focus accent). @@ -96,20 +100,21 @@ func ansi214Count(s string) int { return strings.Count(s, "38;5;214m") } -// TestFramedStatusBranchesBorderAccentIsolation checks that only the focused pane's border uses +// TestFramedMiddleRowBorderAccentIsolation checks that only the focused pane's border uses // the 214 accent when Status or Branches is focused (repo focus uses none). -func TestFramedStatusBranchesBorderAccentIsolation(t *testing.T) { +func TestFramedMiddleRowBorderAccentIsolation(t *testing.T) { m := newTestModel() m.width = 120 m.height = 28 m.repoList = []string{"/r"} + sb, bb, db := 4, 4, 10 m.focus = paneRepo - repo := blankStatusBranchesRow(m, 6) + repo := blankMiddleRow(m, sb, bb, db) m.focus = paneStatus - st := blankStatusBranchesRow(m, 6) + st := blankMiddleRow(m, sb, bb, db) m.focus = paneBranches - br := blankStatusBranchesRow(m, 6) + br := blankMiddleRow(m, sb, bb, db) if c := ansi214Count(repo); c != 0 { t.Fatalf("repo focus: expected no 214 border accents, got %d in %q", c, repo)