Code sync

This commit is contained in:
Andy Dixon
2026-07-08 20:07:38 +01:00
parent 092f1f9a45
commit b9b8635c33
3 changed files with 152 additions and 56 deletions
+2 -2
View File
@@ -4,7 +4,7 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
mkdir -p "$ROOT_DIR/bin" mkdir -p "$ROOT_DIR/bin"
go build -o "$ROOT_DIR/bin/nexus" "$ROOT_DIR" go build -o "$ROOT_DIR/bin/giteditor" "$ROOT_DIR"
echo "Built $ROOT_DIR/bin/nexus" echo "Built $ROOT_DIR/bin/giteditor"
+105 -54
View File
@@ -523,28 +523,26 @@ func (m tuiModel) View() string {
if height <= 0 { if height <= 0 {
height = 36 height = 36
} }
width = maxInt(1, width-1)
top := m.renderTopBar(width) top := m.renderTopBar(width)
bottom := m.renderBottomBar(width) bottom := m.renderBottomBar(width)
bodyHeight := height - lipgloss.Height(top) - lipgloss.Height(bottom) - 2 bodyHeight := maxInt(1, height-lipgloss.Height(top)-lipgloss.Height(bottom))
if bodyHeight < 12 { leftWidth, rightWidth, separator := tuiColumnWidths(width)
bodyHeight = 12
}
leftWidth := clamp(width*38/100, 30, 54)
if width-leftWidth-1 < 34 {
leftWidth = maxInt(24, width-35)
}
rightWidth := maxInt(30, width-leftWidth-1)
left := m.renderCommitPane(leftWidth, bodyHeight) left := m.renderCommitPane(leftWidth, bodyHeight)
right := m.renderDetailPane(rightWidth, bodyHeight) right := m.renderDetailPane(rightWidth, bodyHeight)
body := lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right) body := lipgloss.JoinHorizontal(lipgloss.Top, left, strings.Repeat(" ", separator), right)
return appBaseStyle.Width(width).Render(strings.Join([]string{top, body, bottom}, "\n")) return appBaseStyle.Width(width).Render(strings.Join([]string{top, body, bottom}, "\n"))
} }
func (m tuiModel) renderTopBar(width int) string { func (m tuiModel) renderTopBar(width int) string {
barStyle := lipgloss.NewStyle().
Width(contentWidthFor(lipgloss.NewStyle().Padding(0, 1), width)).
Background(lipgloss.Color("#0C1824")).
Padding(0, 1)
contentWidth := contentWidthFor(barStyle, width)
title := lipgloss.NewStyle(). title := lipgloss.NewStyle().
Bold(true). Bold(true).
Foreground(colorCyan). Foreground(colorCyan).
@@ -568,27 +566,30 @@ func (m tuiModel) renderTopBar(width int) string {
flags = "working..." flags = "working..."
} }
repoWidth := maxInt(4, contentWidth-lipgloss.Width(title)-lipgloss.Width(flags)-6)
line := lipgloss.JoinHorizontal( line := lipgloss.JoinHorizontal(
lipgloss.Center, lipgloss.Center,
title, title,
" ", " ",
mutedStyle.Render(truncateText(repoText, maxInt(8, width-lipgloss.Width(title)-lipgloss.Width(flags)-8))), mutedStyle.Render(truncateText(repoText, repoWidth)),
) )
line = line + strings.Repeat(" ", maxInt(1, width-lipgloss.Width(line)-lipgloss.Width(flags)-2)) + dimStyle.Render(flags) line = line + strings.Repeat(" ", maxInt(1, contentWidth-lipgloss.Width(line)-lipgloss.Width(flags))) + dimStyle.Render(flags)
line = truncateText(line, contentWidth)
if m.overlay == overlaySearch { if m.overlay == overlaySearch {
search := focusedFieldStyle.Width(width - 4).Render(fieldLabelStyle.Render("Search") + "\n" + m.searchInput.View()) search := renderBox(focusedFieldStyle, width, 0, fieldLabelStyle.Render("Search")+"\n"+m.searchInput.View())
return lipgloss.NewStyle().Width(width).Render(line + "\n" + search) return lipgloss.NewStyle().Width(width).Render(line + "\n" + search)
} }
return lipgloss.NewStyle(). return barStyle.Render(line)
Width(width).
Background(lipgloss.Color("#0C1824")).
Padding(0, 1).
Render(line)
} }
func (m tuiModel) renderBottomBar(width int) string { func (m tuiModel) renderBottomBar(width int) string {
barStyle := lipgloss.NewStyle().
Width(contentWidthFor(lipgloss.NewStyle().Padding(0, 1), width)).
Background(lipgloss.Color("#0C1824")).
Padding(0, 1)
contentWidth := contentWidthFor(barStyle, width)
statusColor := colorCyan statusColor := colorCyan
switch m.statusKind { switch m.statusKind {
case statusSuccess: case statusSuccess:
@@ -597,7 +598,7 @@ func (m tuiModel) renderBottomBar(width int) string {
statusColor = colorRed statusColor = colorRed
} }
status := lipgloss.NewStyle().Foreground(statusColor).Render(truncateText(m.status, width-4)) status := lipgloss.NewStyle().Foreground(statusColor).Render(truncateText(m.status, contentWidth))
help := "Tab form/list / search p path r reload x reset a apply f force t tags ? help q quit" help := "Tab form/list / search p path r reload x reset a apply f force t tags ? help q quit"
if m.focus == focusForm { if m.focus == focusForm {
help = "Tab next field Shift+Tab previous Esc list Enter newline in message Ctrl+C quit" help = "Tab next field Shift+Tab previous Esc list Enter newline in message Ctrl+C quit"
@@ -612,11 +613,7 @@ func (m tuiModel) renderBottomBar(width int) string {
help = "Type FORCE then Enter to rewrite and force push, Esc to cancel" help = "Type FORCE then Enter to rewrite and force push, Esc to cancel"
} }
return lipgloss.NewStyle(). return barStyle.Render(status + "\n" + dimStyle.Render(truncateText(help, contentWidth)))
Width(width).
Background(lipgloss.Color("#0C1824")).
Padding(0, 1).
Render(status + "\n" + dimStyle.Render(truncateText(help, width-4)))
} }
func (m tuiModel) renderCommitPane(width int, height int) string { func (m tuiModel) renderCommitPane(width int, height int) string {
@@ -624,8 +621,8 @@ func (m tuiModel) renderCommitPane(width int, height int) string {
if m.focus == focusList && m.overlay == overlayNone { if m.focus == focusList && m.overlay == overlayNone {
style = activePanelStyle style = activePanelStyle
} }
innerWidth := maxInt(10, width-2) innerWidth := maxInt(1, contentWidthFor(style, width))
innerHeight := maxInt(6, height-2) innerHeight := maxInt(1, contentHeightFor(style, height))
filtered, selectedIndex, _ := selectionAfterFilter(m.commits, m.draftByHash, m.selectedHash, m.searchInput.Value()) filtered, selectedIndex, _ := selectionAfterFilter(m.commits, m.draftByHash, m.selectedHash, m.searchInput.Value())
header := headerStyle.Render(fmt.Sprintf("Commits %d/%d", len(filtered), len(m.commits))) header := headerStyle.Render(fmt.Sprintf("Commits %d/%d", len(filtered), len(m.commits)))
@@ -636,7 +633,7 @@ func (m tuiModel) renderCommitPane(width int, height int) string {
lines := []string{truncateText(header, innerWidth)} lines := []string{truncateText(header, innerWidth)}
if len(filtered) == 0 { if len(filtered) == 0 {
lines = append(lines, "", mutedStyle.Render("No commits match this filter.")) lines = append(lines, "", mutedStyle.Render("No commits match this filter."))
return style.Width(width).Height(height).Render(fitLines(lines, innerHeight, innerWidth)) return renderBox(style, width, height, fitLines(lines, innerHeight, innerWidth))
} }
visibleSlots := maxInt(1, (innerHeight-2)/5) visibleSlots := maxInt(1, (innerHeight-2)/5)
@@ -656,7 +653,7 @@ func (m tuiModel) renderCommitPane(width int, height int) string {
} }
} }
return style.Width(width).Height(height).Render(fitLines(lines, innerHeight, innerWidth)) return renderBox(style, width, height, fitLines(lines, innerHeight, innerWidth))
} }
func (m tuiModel) renderCommitItem(commit CommitRecord, selected bool, width int) string { func (m tuiModel) renderCommitItem(commit CommitRecord, selected bool, width int) string {
@@ -688,9 +685,9 @@ func (m tuiModel) renderCommitItem(commit CommitRecord, selected bool, width int
}, "\n") }, "\n")
if selected { if selected {
return selectedItemStyle.Width(width).Render(block) return renderBox(selectedItemStyle, width, 0, block)
} }
return itemStyle.Width(width).Render(block) return renderBox(itemStyle, width, 0, block)
} }
func (m tuiModel) renderDetailPane(width int, height int) string { func (m tuiModel) renderDetailPane(width int, height int) string {
@@ -698,8 +695,8 @@ func (m tuiModel) renderDetailPane(width int, height int) string {
if m.focus == focusForm && m.overlay == overlayNone { if m.focus == focusForm && m.overlay == overlayNone {
style = activePanelStyle style = activePanelStyle
} }
innerWidth := maxInt(20, width-2) innerWidth := maxInt(1, contentWidthFor(style, width))
innerHeight := maxInt(6, height-2) innerHeight := maxInt(1, contentHeightFor(style, height))
var content string var content string
switch m.overlay { switch m.overlay {
@@ -713,7 +710,7 @@ func (m tuiModel) renderDetailPane(width int, height int) string {
content = m.renderFormPane(innerWidth, innerHeight) content = m.renderFormPane(innerWidth, innerHeight)
} }
return style.Width(width).Height(height).Render(content) return renderBox(style, width, height, content)
} }
func (m tuiModel) renderPathPane(width int, height int) string { func (m tuiModel) renderPathPane(width int, height int) string {
@@ -722,7 +719,7 @@ func (m tuiModel) renderPathPane(width int, height int) string {
"", "",
"Enter a Git worktree path. Nexus starts with the current directory, but you can switch repositories here at any time.", "Enter a Git worktree path. Nexus starts with the current directory, but you can switch repositories here at any time.",
"", "",
focusedFieldStyle.Width(width).Render(fieldLabelStyle.Render("Path") + "\n" + m.pathInput.View()), renderBox(focusedFieldStyle, width, 0, fieldLabelStyle.Render("Path")+"\n"+m.pathInput.View()),
"", "",
dimStyle.Render("Press Enter to load. Press Esc to return to the current repository."), dimStyle.Render("Press Enter to load. Press Esc to return to the current repository."),
} }
@@ -743,7 +740,7 @@ func (m tuiModel) renderConfirmPane(width int, height int) string {
lipgloss.NewStyle().Foreground(colorRed).Bold(true).Render("Force push is enabled."), lipgloss.NewStyle().Foreground(colorRed).Bold(true).Render("Force push is enabled."),
"Type FORCE to confirm rewriting history and pushing with --force-with-lease.", "Type FORCE to confirm rewriting history and pushing with --force-with-lease.",
"", "",
focusedFieldStyle.Width(minInt(width, 24)).Render(fieldLabelStyle.Render("Confirmation")+"\n"+m.confirmInput.View()), renderBox(focusedFieldStyle, minInt(width, 24), 0, fieldLabelStyle.Render("Confirmation")+"\n"+m.confirmInput.View()),
) )
} else { } else {
lines = append(lines, "", "Press y to continue, or n/Esc to cancel.") lines = append(lines, "", "Press y to continue, or n/Esc to cancel.")
@@ -794,7 +791,7 @@ func (m tuiModel) renderFormPane(width int, height int) string {
lines := []string{headerStyle.Render(truncateText(title, width)), ""} lines := []string{headerStyle.Render(truncateText(title, width)), ""}
cellGap := 2 cellGap := 2
cellWidth := maxInt(18, (width-cellGap)/2) cellWidth := maxInt(1, (width-cellGap)/2)
rows := [][2]int{ rows := [][2]int{
{formAuthorName, formAuthorEmail}, {formAuthorName, formAuthorEmail},
{formAuthorDate, formCommitterName}, {formAuthorDate, formCommitterName},
@@ -809,10 +806,17 @@ func (m tuiModel) renderFormPane(width int, height int) string {
"Committer date", "Committer date",
} }
for _, row := range rows { if width >= 44 {
left := m.renderTextField(row[0], labels[row[0]], cellWidth) for _, row := range rows {
right := m.renderTextField(row[1], labels[row[1]], cellWidth) left := m.renderTextField(row[0], labels[row[0]], cellWidth)
lines = append(lines, lipgloss.JoinHorizontal(lipgloss.Top, left, strings.Repeat(" ", cellGap), right)) right := m.renderTextField(row[1], labels[row[1]], cellWidth)
lines = append(lines, lipgloss.JoinHorizontal(lipgloss.Top, left, strings.Repeat(" ", cellGap), right))
}
} else {
for _, row := range rows {
lines = append(lines, m.renderTextField(row[0], labels[row[0]], width))
lines = append(lines, m.renderTextField(row[1], labels[row[1]], width))
}
} }
messageHeight := clamp(height-lipgloss.Height(strings.Join(lines, "\n"))-8, 5, 10) messageHeight := clamp(height-lipgloss.Height(strings.Join(lines, "\n"))-8, 5, 10)
@@ -840,7 +844,7 @@ func (m tuiModel) renderTextField(index int, label string, width int) string {
if m.focus == focusForm && m.overlay == overlayNone && m.formFocus == index { if m.focus == focusForm && m.overlay == overlayNone && m.formFocus == index {
style = focusedFieldStyle style = focusedFieldStyle
} }
return style.Width(width).Render(fieldLabelStyle.Render(label) + "\n" + m.inputs[index].View()) return renderBox(style, width, 0, fieldLabelStyle.Render(label)+"\n"+m.inputs[index].View())
} }
func (m tuiModel) renderMessageField(width int, height int) string { func (m tuiModel) renderMessageField(width int, height int) string {
@@ -848,7 +852,7 @@ func (m tuiModel) renderMessageField(width int, height int) string {
if m.focus == focusForm && m.overlay == overlayNone && m.formFocus == formMessage { if m.focus == focusForm && m.overlay == overlayNone && m.formFocus == formMessage {
style = focusedFieldStyle style = focusedFieldStyle
} }
return style.Width(width).Height(height).Render(fieldLabelStyle.Render("Commit message") + "\n" + m.message.View()) return renderBox(style, width, height, fieldLabelStyle.Render("Commit message")+"\n"+m.message.View())
} }
func (m *tuiModel) updateComponentSizes() { func (m *tuiModel) updateComponentSizes() {
@@ -860,21 +864,17 @@ func (m *tuiModel) updateComponentSizes() {
if height <= 0 { if height <= 0 {
height = 36 height = 36
} }
leftWidth := clamp(width*38/100, 30, 54) _, rightWidth, _ := tuiColumnWidths(width)
if width-leftWidth-1 < 34 { innerRight := maxInt(1, contentWidthFor(panelStyle, rightWidth))
leftWidth = maxInt(24, width-35)
}
rightWidth := maxInt(30, width-leftWidth-1)
innerRight := maxInt(20, rightWidth-4)
cellWidth := maxInt(18, (innerRight-2)/2) cellWidth := maxInt(18, (innerRight-2)/2)
for i := range m.inputs { for i := range m.inputs {
m.inputs[i].Width = maxInt(8, cellWidth-4) m.inputs[i].Width = maxInt(4, contentWidthFor(blurredFieldStyle, cellWidth))
} }
m.message.SetWidth(maxInt(12, innerRight-4)) m.message.SetWidth(maxInt(4, contentWidthFor(blurredFieldStyle, innerRight)))
m.message.SetHeight(clamp(height/4, 5, 10)) m.message.SetHeight(clamp(height/4, 5, 10))
m.searchInput.Width = maxInt(16, width-10) m.searchInput.Width = maxInt(16, width-10)
m.pathInput.Width = maxInt(20, innerRight-8) m.pathInput.Width = maxInt(4, contentWidthFor(focusedFieldStyle, innerRight))
m.confirmInput.Width = 12 m.confirmInput.Width = 12
} }
@@ -1225,11 +1225,16 @@ func wrapBlockLines(lines []string, width int) []string {
func fitLines(lines []string, height int, width int) string { func fitLines(lines []string, height int, width int) string {
out := make([]string, 0, height) out := make([]string, 0, height)
for _, line := range lines { for _, block := range lines {
for _, line := range strings.Split(block, "\n") {
if len(out) >= height {
break
}
out = append(out, truncateText(line, width))
}
if len(out) >= height { if len(out) >= height {
break break
} }
out = append(out, truncateText(line, width))
} }
for len(out) < height { for len(out) < height {
out = append(out, "") out = append(out, "")
@@ -1327,3 +1332,49 @@ func maxInt(a int, b int) int {
} }
return b return b
} }
func contentWidthFor(style lipgloss.Style, outerWidth int) int {
return maxInt(1, outerWidth-style.GetHorizontalFrameSize())
}
func contentHeightFor(style lipgloss.Style, outerHeight int) int {
return maxInt(1, outerHeight-style.GetVerticalFrameSize())
}
func renderBox(style lipgloss.Style, outerWidth int, outerHeight int, content string) string {
if outerWidth > 0 {
style = style.Width(contentWidthFor(style, outerWidth))
}
if outerHeight > 0 {
style = style.Height(contentHeightFor(style, outerHeight))
}
return style.Render(content)
}
func tuiColumnWidths(totalWidth int) (int, int, int) {
if totalWidth <= 2 {
return maxInt(1, totalWidth), 1, 0
}
separator := 1
if totalWidth < 50 {
separator = 0
}
left := clamp(totalWidth*38/100, 24, 54)
minRight := 24
if totalWidth < 70 {
minRight = maxInt(10, totalWidth/2)
}
maxLeft := totalWidth - separator - minRight
if maxLeft < 10 {
maxLeft = maxInt(1, totalWidth-separator-1)
}
left = clamp(left, 1, maxLeft)
right := totalWidth - separator - left
if right < 1 {
right = 1
left = maxInt(1, totalWidth-separator-right)
}
return left, right, separator
}
+45
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"errors" "errors"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -111,3 +112,47 @@ func TestRepositoryLoadErrorOpensPathEntryState(t *testing.T) {
t.Fatalf("expected path input to keep attempted path %q, got %q", path, model.pathInput.Value()) t.Fatalf("expected path input to keep attempted path %q, got %q", path, model.pathInput.Value())
} }
} }
func TestViewFitsTerminalDimensions(t *testing.T) {
model := newTUIModel(NewApp(), "/tmp/repo")
model.width = 80
model.height = 24
initialView := model.View()
if height := lipgloss.Height(initialView); height > model.height {
t.Fatalf("expected initial view height <= %d, got %d", model.height, height)
}
if width := maxRenderedLineWidth(initialView); width > model.width-1 {
t.Fatalf("expected initial max line width <= %d, got %d", model.width-1, width)
}
model.handleRepoLoaded(repoLoadedMsg{
repo: RepositoryState{
Path: "/tmp/repo",
Clean: true,
CurrentBranch: "main",
},
commits: []CommitRecord{
{Hash: "1111111111111111111111111111111111111111", ShortHash: "1111111", AuthorName: "Ada Lovelace", AuthorEmail: "ada@example.com", AuthorDate: "2024-01-01T10:00:00Z", CommitterName: "Ada Lovelace", CommitterEmail: "ada@example.com", CommitterDate: "2024-01-01T10:00:00Z", Message: "Add a compact terminal interface that should not wrap every line"},
{Hash: "2222222222222222222222222222222222222222", ShortHash: "2222222", AuthorName: "Grace Hopper", AuthorEmail: "grace@example.com", AuthorDate: "2024-01-02T10:00:00Z", CommitterName: "Grace Hopper", CommitterEmail: "grace@example.com", CommitterDate: "2024-01-02T10:00:00Z", Message: "Fix rendering dimensions"},
},
})
view := model.View()
if height := lipgloss.Height(view); height > model.height {
t.Fatalf("expected view height <= %d, got %d", model.height, height)
}
if width := maxRenderedLineWidth(view); width > model.width-1 {
t.Fatalf("expected max line width <= %d, got %d", model.width-1, width)
}
}
func maxRenderedLineWidth(value string) int {
width := 0
for _, line := range strings.Split(value, "\n") {
if lineWidth := lipgloss.Width(line); lineWidth > width {
width = lineWidth
}
}
return width
}