Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- <path>` to restore that path to
Expand All @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion demo.tape
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
95 changes: 95 additions & 0 deletions ui/layout_geometry.go
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 6 additions & 2 deletions ui/layout_limits.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions ui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 9 additions & 9 deletions ui/mouse_line_select.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand Down
12 changes: 6 additions & 6 deletions ui/mouse_line_select_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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}
Expand Down
Loading
Loading