Files
git-editor/tui.go
T
2026-09-26 17:19:12 +01:00

2035 lines
58 KiB
Go

package main
import (
"errors"
"fmt"
"math/rand"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"golang.org/x/text/cases"
)
const (
formAuthorName = iota
formAuthorEmail
formAuthorDate
formCommitterName
formCommitterEmail
formCommitterDate
formMessage
formFieldCount
)
const (
bulkAuthorSearch = iota
bulkAuthorName
bulkAuthorEmail
bulkAuthorFieldCount
)
const (
dateSpreadStartDay = iota
dateSpreadEndDay
dateSpreadFromTime
dateSpreadToTime
dateSpreadFieldCount
)
type uiFocus int
const (
focusList uiFocus = iota
focusForm
)
type uiOverlay int
const (
overlayNone uiOverlay = iota
overlaySearch
overlayPath
overlayBulkAuthor
overlayConfirmApply
overlayConfirmForce
overlayHelp
overlayDatePicker
overlayDateSpread
)
type statusKind string
const (
statusInfo statusKind = "info"
statusSuccess statusKind = "success"
statusError statusKind = "error"
)
var (
colorInk = lipgloss.Color("#DCE7F7")
colorMuted = lipgloss.Color("#7E8DA3")
colorDim = lipgloss.Color("#506075")
colorPanel = lipgloss.Color("#111B27")
colorPanelLift = lipgloss.Color("#172536")
colorSelected = lipgloss.Color("#20364B")
colorCyan = lipgloss.Color("#55D6FF")
colorGreen = lipgloss.Color("#78F0B4")
colorMagenta = lipgloss.Color("#D68DFF")
colorAmber = lipgloss.Color("#FFD166")
colorRed = lipgloss.Color("#FF6B7A")
colorBorder = lipgloss.Color("#2B435A")
appBaseStyle = lipgloss.NewStyle().
Foreground(colorInk).
Background(lipgloss.Color("#08111B"))
panelStyle = lipgloss.NewStyle().
Background(colorPanel).
Border(lipgloss.RoundedBorder()).
BorderForeground(colorBorder)
activePanelStyle = panelStyle.Copy().
BorderForeground(colorCyan)
headerStyle = lipgloss.NewStyle().
Bold(true).
Foreground(colorCyan)
mutedStyle = lipgloss.NewStyle().
Foreground(colorMuted)
dimStyle = lipgloss.NewStyle().
Foreground(colorDim)
selectedItemStyle = lipgloss.NewStyle().
Background(colorSelected).
Foreground(colorInk).
Bold(true).
Padding(0, 1)
itemStyle = lipgloss.NewStyle().
Foreground(colorInk).
Padding(0, 1)
fieldLabelStyle = lipgloss.NewStyle().
Foreground(colorMuted).
Bold(true)
focusedFieldStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(colorCyan).
Background(colorPanelLift).
Padding(0, 1)
blurredFieldStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(colorBorder).
Background(colorPanel).
Padding(0, 1)
)
type repoLoadedMsg struct {
repo RepositoryState
commits []CommitRecord
path string
err error
}
type applyFinishedMsg struct {
result ApplyResult
repo RepositoryState
commits []CommitRecord
err error
}
type tuiModel struct {
app *App
initialPath string
repository RepositoryState
repoLoaded bool
commits []CommitRecord
originalByHash map[string]CommitRecord
draftByHash map[string]CommitRecord
selectedHash string
selectedIndex int
listOffset int
width int
height int
focus uiFocus
overlay uiOverlay
formFocus int
inputs []textinput.Model
message textarea.Model
searchInput textinput.Model
pathInput textinput.Model
confirmInput textinput.Model
datePickerTime time.Time
datePickerTarget int
bulkAuthorFocus int
bulkAuthorInputs []textinput.Model
bulkAuthorExact bool
dateSpreadFocus int
dateSpreadInputs []textinput.Model
forcePush bool
pushTags bool
removeCoAuthors bool
aiOnly bool
aiHashes map[string]bool
busy bool
status string
statusKind statusKind
}
func initialRepositoryPath(args []string, getwd func() (string, error)) (string, error) {
if len(args) > 1 {
return "", errors.New("usage: nexus [repo-path]")
}
if len(args) == 1 {
return normalizePath(args[0])
}
wd, err := getwd()
if err != nil {
return "", err
}
return normalizePath(wd)
}
func newTUIModel(app *App, initialPath string) tuiModel {
inputs := make([]textinput.Model, formFieldCount-1)
for i := range inputs {
inputs[i] = newTextInput()
}
message := textarea.New()
message.Prompt = ""
message.Placeholder = "Commit message"
message.ShowLineNumbers = false
message.CharLimit = 0
message.Blur()
searchInput := newTextInput()
searchInput.Placeholder = "Search hash, author, email, or message"
pathInput := newTextInput()
pathInput.Placeholder = "/path/to/repository"
pathInput.SetValue(initialPath)
confirmInput := newTextInput()
confirmInput.Placeholder = "FORCE"
bulkAuthorInputs := make([]textinput.Model, bulkAuthorFieldCount)
for i := range bulkAuthorInputs {
bulkAuthorInputs[i] = newTextInput()
}
bulkAuthorInputs[bulkAuthorSearch].Placeholder = "Existing author name or email"
bulkAuthorInputs[bulkAuthorName].Placeholder = "Replacement author name"
bulkAuthorInputs[bulkAuthorEmail].Placeholder = "Replacement author email"
dateSpreadInputs := make([]textinput.Model, dateSpreadFieldCount)
for i := range dateSpreadInputs {
dateSpreadInputs[i] = newTextInput()
}
dateSpreadInputs[dateSpreadStartDay].Placeholder = "YYYY-MM-DD"
dateSpreadInputs[dateSpreadEndDay].Placeholder = "YYYY-MM-DD"
dateSpreadInputs[dateSpreadFromTime].Placeholder = "08:00"
dateSpreadInputs[dateSpreadToTime].Placeholder = "12:00"
return tuiModel{
dateSpreadInputs: dateSpreadInputs,
app: app,
initialPath: initialPath,
originalByHash: map[string]CommitRecord{},
draftByHash: map[string]CommitRecord{},
inputs: inputs,
message: message,
searchInput: searchInput,
pathInput: pathInput,
confirmInput: confirmInput,
bulkAuthorInputs: bulkAuthorInputs,
pushTags: true,
status: "Loading repository...",
statusKind: statusInfo,
}
}
func newTextInput() textinput.Model {
input := textinput.New()
input.Prompt = ""
input.PlaceholderStyle = lipgloss.NewStyle().Foreground(colorDim)
input.TextStyle = lipgloss.NewStyle().Foreground(colorInk)
input.Cursor.Style = lipgloss.NewStyle().Foreground(colorCyan)
return input
}
func (m tuiModel) Init() tea.Cmd {
return loadRepositoryCmd(m.app, m.initialPath)
}
func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.updateComponentSizes()
return m, nil
case repoLoadedMsg:
m.busy = false
m.handleRepoLoaded(msg)
return m, nil
case applyFinishedMsg:
m.busy = false
m.handleApplyFinished(msg)
return m, nil
case tea.KeyMsg:
return m.handleKey(msg)
default:
return m, nil
}
}
func (m *tuiModel) handleRepoLoaded(msg repoLoadedMsg) {
if msg.err != nil {
m.repoLoaded = false
m.repository = RepositoryState{}
m.commits = nil
m.originalByHash = map[string]CommitRecord{}
m.draftByHash = map[string]CommitRecord{}
m.selectedHash = ""
m.selectedIndex = 0
m.listOffset = 0
m.overlay = overlayPath
m.focus = focusList
m.pathInput.SetValue(msg.path)
m.focusPathInput()
m.setStatus(fmt.Sprintf("Repository unavailable: %v", msg.err), statusError)
return
}
m.repoLoaded = true
m.repository = msg.repo
m.commits = newestFirst(msg.commits)
m.originalByHash = mapByHash(m.commits)
m.draftByHash = cloneMapByHash(m.commits)
m.aiHashes = aiCommitHashes(m.commits)
m.pathInput.SetValue(msg.repo.Path)
m.reconcileSelection()
m.loadSelectedCommitIntoForm()
m.overlay = overlayNone
m.setStatus(fmt.Sprintf("Loaded %d commits from %s", len(m.commits), filepath.Base(msg.repo.Path)), statusSuccess)
}
func (m *tuiModel) handleApplyFinished(msg applyFinishedMsg) {
if msg.err != nil {
m.setStatus(msg.err.Error(), statusError)
return
}
if msg.repo.Path != "" {
m.repository = msg.repo
m.repoLoaded = true
}
if msg.commits != nil {
m.removeCoAuthors = false
m.commits = newestFirst(msg.commits)
m.originalByHash = mapByHash(m.commits)
m.draftByHash = cloneMapByHash(m.commits)
m.reconcileSelection()
m.loadSelectedCommitIntoForm()
}
m.setStatus(applyResultSummary(msg.result), statusSuccess)
}
func (m tuiModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
if key == "ctrl+c" {
return m, tea.Quit
}
if m.busy {
return m, nil
}
switch m.overlay {
case overlayHelp:
if key == "esc" || key == "?" || key == "q" {
m.overlay = overlayNone
}
return m, nil
case overlaySearch:
return m.updateSearch(msg)
case overlayPath:
return m.updatePathEntry(msg)
case overlayBulkAuthor:
return m.updateBulkAuthor(msg)
case overlayDateSpread:
return m.updateDateSpread(msg)
case overlayConfirmApply, overlayConfirmForce:
return m.updateConfirmation(msg)
case overlayDatePicker:
return m.updateDatePicker(msg)
}
if m.focus == focusForm {
return m.updateForm(msg)
}
switch key {
case "q":
return m, tea.Quit
case "?":
m.overlay = overlayHelp
case "/":
m.overlay = overlaySearch
m.searchInput.Focus()
m.searchInput.CursorEnd()
case "p", "o":
m.overlay = overlayPath
m.focusPathInput()
case "b":
if m.repoLoaded {
m.openBulkAuthor()
} else {
m.setStatus("Load a repository before replacing authors.", statusError)
}
case "d":
if m.repoLoaded {
m.openDateSpread()
} else {
m.setStatus("Load a repository before rewriting dates.", statusError)
}
case "r":
if m.repository.Path != "" {
m.busy = true
m.setStatus("Reloading history...", statusInfo)
return m, loadRepositoryCmd(m.app, m.repository.Path)
}
m.overlay = overlayPath
m.focusPathInput()
case "f":
m.forcePush = !m.forcePush
m.setStatus(fmt.Sprintf("Force push %s", onOff(m.forcePush)), statusInfo)
case "t":
m.pushTags = !m.pushTags
m.setStatus(fmt.Sprintf("Push tags %s", onOff(m.pushTags)), statusInfo)
case "c":
m.removeCoAuthors = !m.removeCoAuthors
count, lines := coAuthorCleanupImpact(m.commits, m.draftByHash)
m.setStatus(fmt.Sprintf("Co-author cleanup %s: %d line%s across %d commit%s", onOff(m.removeCoAuthors), lines, plural(lines), count, plural(count)), statusInfo)
case "i":
m.aiOnly = !m.aiOnly
m.reconcileSelection()
m.loadSelectedCommitIntoForm()
m.setStatus(fmt.Sprintf("Claude/Codex filter %s: %d commit%s", onOff(m.aiOnly), len(m.aiHashes), plural(len(m.aiHashes))), statusInfo)
case "a":
return m.beginApply()
case "x":
m.resetSelectedCommit()
case "tab":
if m.selectedHash != "" {
m.focus = focusForm
m.focusFormField(0)
}
case "up", "k":
m.moveSelection(-1)
case "down", "j":
m.moveSelection(1)
case "pgup":
m.moveSelection(-5)
case "pgdown":
m.moveSelection(5)
case "home":
m.moveSelectionTo(0)
case "end":
filtered, _, _ := selectionAfterFilter(m.listCommits(), m.draftByHash, m.selectedHash, m.searchInput.Value())
m.moveSelectionTo(len(filtered) - 1)
}
return m, nil
}
func (m tuiModel) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc", "enter":
m.overlay = overlayNone
m.searchInput.Blur()
m.reconcileSelection()
return m, nil
}
var cmd tea.Cmd
m.searchInput, cmd = m.searchInput.Update(msg)
m.reconcileSelection()
return m, cmd
}
func (m tuiModel) updatePathEntry(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
if m.repoLoaded {
m.overlay = overlayNone
m.pathInput.Blur()
}
return m, nil
case "enter":
path := strings.TrimSpace(m.pathInput.Value())
if path == "" {
m.setStatus("Repository path is required.", statusError)
return m, nil
}
normalized, err := normalizePath(path)
if err != nil {
m.setStatus(err.Error(), statusError)
return m, nil
}
m.busy = true
m.pathInput.Blur()
m.setStatus("Loading repository...", statusInfo)
return m, loadRepositoryCmd(m.app, normalized)
}
var cmd tea.Cmd
m.pathInput, cmd = m.pathInput.Update(msg)
return m, cmd
}
func (m tuiModel) updateBulkAuthor(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.overlay = overlayNone
m.blurBulkAuthorInputs()
m.setStatus("Bulk author replacement cancelled.", statusInfo)
return m, nil
case "tab", "shift+tab":
delta := 1
if msg.String() == "shift+tab" {
delta = -1
}
next := (m.bulkAuthorFocus + delta + bulkAuthorFieldCount) % bulkAuthorFieldCount
m.focusBulkAuthorField(next)
return m, nil
case "ctrl+t":
m.bulkAuthorExact = !m.bulkAuthorExact
m.setStatus(fmt.Sprintf("Author match mode: %s.", authorMatchMode(m.bulkAuthorExact)), statusInfo)
return m, nil
case "enter":
m.syncFormToDraft()
matched, err := stageAuthorReplacement(
m.commits,
m.draftByHash,
m.bulkAuthorInputs[bulkAuthorSearch].Value(),
m.bulkAuthorInputs[bulkAuthorName].Value(),
m.bulkAuthorInputs[bulkAuthorEmail].Value(),
m.bulkAuthorExact,
)
if err != nil {
m.setStatus(err.Error(), statusError)
return m, nil
}
if matched == 0 {
m.setStatus("No author names or emails matched the search.", statusInfo)
return m, nil
}
m.overlay = overlayNone
m.blurBulkAuthorInputs()
m.reconcileSelection()
m.loadSelectedCommitIntoForm()
m.setStatus(fmt.Sprintf("Staged author replacement for %d commit%s.", matched, plural(matched)), statusSuccess)
return m, nil
}
var cmd tea.Cmd
m.bulkAuthorInputs[m.bulkAuthorFocus], cmd = m.bulkAuthorInputs[m.bulkAuthorFocus].Update(msg)
return m, cmd
}
func (m tuiModel) updateDateSpread(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.overlay = overlayNone
m.blurInputs(m.dateSpreadInputs)
m.setStatus("Date rewrite cancelled.", statusInfo)
return m, nil
case "tab", "shift+tab":
delta := 1
if msg.String() == "shift+tab" {
delta = -1
}
m.focusDateSpreadField((m.dateSpreadFocus + delta + dateSpreadFieldCount) % dateSpreadFieldCount)
return m, nil
case "enter":
m.syncFormToDraft()
v := func(i int) string { return m.dateSpreadInputs[i].Value() }
count, err := stageDateSpread(m.commits, m.draftByHash, v(dateSpreadStartDay), v(dateSpreadEndDay), v(dateSpreadFromTime), v(dateSpreadToTime), rand.Int63n)
if err != nil {
m.setStatus(err.Error(), statusError)
return m, nil
}
m.overlay = overlayNone
m.blurInputs(m.dateSpreadInputs)
m.loadSelectedCommitIntoForm()
m.setStatus(fmt.Sprintf("Staged new timestamps for %d commit%s.", count, plural(count)), statusSuccess)
return m, nil
}
var cmd tea.Cmd
m.dateSpreadInputs[m.dateSpreadFocus], cmd = m.dateSpreadInputs[m.dateSpreadFocus].Update(msg)
return m, cmd
}
func (m tuiModel) updateConfirmation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
if key == "esc" || key == "n" {
m.overlay = overlayNone
m.confirmInput.Blur()
m.setStatus("Apply cancelled.", statusInfo)
return m, nil
}
if m.overlay == overlayConfirmApply {
if key == "y" {
return m.applyNow()
}
return m, nil
}
if key == "enter" {
if m.confirmInput.Value() != "FORCE" {
m.setStatus("Type FORCE to confirm rewriting and force pushing.", statusError)
return m, nil
}
return m.applyNow()
}
var cmd tea.Cmd
m.confirmInput, cmd = m.confirmInput.Update(msg)
return m, cmd
}
func (m tuiModel) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
switch key {
case "enter":
if m.formFocus == formAuthorDate || m.formFocus == formCommitterDate {
selected, err := parseGitDate(m.inputs[m.formFocus].Value())
if err != nil {
m.setStatus(fmt.Sprintf("Cannot open date picker: %v", err), statusError)
return m, nil
}
m.datePickerTime = selected
m.datePickerTarget = m.formFocus
m.overlay = overlayDatePicker
m.blurForm()
m.setStatus("Date/time picker opened.", statusInfo)
return m, nil
}
case "ctrl+t":
if m.formFocus == formAuthorDate || m.formFocus == formCommitterDate {
m.inputs[m.formFocus].SetValue(time.Now().Format("2006-01-02T15:04:05-07:00"))
m.syncFormToDraft()
m.setStatus("Updated the focused date/time to now.", statusInfo)
}
return m, nil
case "esc":
m.normalizeFocusedDateInput()
m.syncFormToDraft()
m.focus = focusList
m.blurForm()
return m, nil
case "tab", "shift+tab":
m.normalizeFocusedDateInput()
m.syncFormToDraft()
next, returnToList := nextFormFocus(m.formFocus, key == "shift+tab")
if returnToList {
m.focus = focusList
m.blurForm()
return m, nil
}
m.focusFormField(next)
return m, nil
}
var cmd tea.Cmd
if m.formFocus == formMessage {
m.message, cmd = m.message.Update(msg)
} else if m.formFocus >= 0 && m.formFocus < len(m.inputs) {
m.inputs[m.formFocus], cmd = m.inputs[m.formFocus].Update(msg)
}
m.syncFormToDraft()
return m, cmd
}
func (m tuiModel) updateDatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.overlay = overlayNone
m.focusFormField(m.datePickerTarget)
m.setStatus("Date/time change cancelled.", statusInfo)
case "enter":
m.inputs[m.datePickerTarget].SetValue(formatGitDate(m.datePickerTime))
m.overlay = overlayNone
m.focusFormField(m.datePickerTarget)
m.syncFormToDraft()
m.setStatus("Date/time updated.", statusInfo)
case "left", "h":
m.datePickerTime = m.datePickerTime.AddDate(0, 0, -1)
case "right", "l":
m.datePickerTime = m.datePickerTime.AddDate(0, 0, 1)
case "up", "k":
m.datePickerTime = m.datePickerTime.Add(time.Hour)
case "down", "j":
m.datePickerTime = m.datePickerTime.Add(-time.Hour)
case "pgup", "[":
m.datePickerTime = shiftCalendarMonth(m.datePickerTime, -1)
case "pgdown", "]":
m.datePickerTime = shiftCalendarMonth(m.datePickerTime, 1)
case "shift+left", "H":
m.datePickerTime = shiftCalendarYear(m.datePickerTime, -1)
case "shift+right", "L":
m.datePickerTime = shiftCalendarYear(m.datePickerTime, 1)
case "-", "_":
m.datePickerTime = m.datePickerTime.Add(-5 * time.Minute)
case "+", "=":
m.datePickerTime = m.datePickerTime.Add(5 * time.Minute)
case "t":
m.datePickerTime = time.Now()
case "u":
m.datePickerTime = sameWallClockIn(m.datePickerTime, time.UTC)
case "z":
m.datePickerTime = sameWallClockIn(m.datePickerTime, time.Local)
case "0":
m.datePickerTime = m.datePickerTime.Add(-time.Duration(m.datePickerTime.Second()) * time.Second)
}
return m, nil
}
func (m tuiModel) beginApply() (tea.Model, tea.Cmd) {
if !m.repoLoaded {
m.setStatus("Load a repository before applying changes.", statusError)
return m, nil
}
m.syncFormToDraft()
if err := validateDraftDates(m.commits, m.draftByHash); err != nil {
m.setStatus(err.Error(), statusError)
return m, nil
}
count := plannedRewriteCount(m.commits, m.originalByHash, m.draftByHash, m.removeCoAuthors)
if count == 0 && !m.forcePush {
m.setStatus("No history edits or co-author lines to apply.", statusInfo)
return m, nil
}
if m.forcePush {
m.overlay = overlayConfirmForce
m.confirmInput.SetValue("")
m.confirmInput.Focus()
m.setStatus("Force push confirmation required.", statusInfo)
return m, nil
}
m.overlay = overlayConfirmApply
m.setStatus(fmt.Sprintf("Confirm rewriting %d edited commit%s.", count, plural(count)), statusInfo)
return m, nil
}
func (m tuiModel) applyNow() (tea.Model, tea.Cmd) {
m.syncFormToDraft()
m.overlay = overlayNone
m.confirmInput.Blur()
m.busy = true
m.setStatus("Applying commit metadata changes...", statusInfo)
return m, applyChangesCmd(m.app, m.applyRequest())
}
func (m tuiModel) View() string {
m.updateComponentSizes()
width := m.width
height := m.height
if width <= 0 {
width = 120
}
if height <= 0 {
height = 36
}
width = maxInt(1, width-1)
top := m.renderTopBar(width)
bottom := m.renderBottomBar(width)
bodyHeight := maxInt(1, height-lipgloss.Height(top)-lipgloss.Height(bottom))
leftWidth, rightWidth, separator := tuiColumnWidths(width)
left := m.renderCommitPane(leftWidth, bodyHeight)
right := m.renderDetailPane(rightWidth, bodyHeight)
body := lipgloss.JoinHorizontal(lipgloss.Top, left, strings.Repeat(" ", separator), right)
return appBaseStyle.Width(width).Render(strings.Join([]string{top, body, bottom}, "\n"))
}
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().
Bold(true).
Foreground(colorCyan).
Render("NEXUS")
repoText := "No repository loaded"
if m.repoLoaded {
clean := "clean"
if !m.repository.Clean {
clean = "dirty"
}
branch := "detached HEAD"
if !m.repository.DetachedHead && m.repository.CurrentBranch != "" {
branch = m.repository.CurrentBranch
}
repoText = fmt.Sprintf("%s %s %s", filepath.Base(m.repository.Path), branch, clean)
}
flags := fmt.Sprintf("force:%s tags:%s coauthors:%s edits:%d", onOff(m.forcePush), onOff(m.pushTags), onOff(m.removeCoAuthors), plannedRewriteCount(m.commits, m.originalByHash, m.draftByHash, m.removeCoAuthors))
if m.busy {
flags = "working..."
}
repoWidth := maxInt(4, contentWidth-lipgloss.Width(title)-lipgloss.Width(flags)-6)
line := lipgloss.JoinHorizontal(
lipgloss.Center,
title,
" ",
mutedStyle.Render(truncateText(repoText, repoWidth)),
)
line = line + strings.Repeat(" ", maxInt(1, contentWidth-lipgloss.Width(line)-lipgloss.Width(flags))) + dimStyle.Render(flags)
line = truncateText(line, contentWidth)
if m.overlay == overlaySearch {
search := renderBox(focusedFieldStyle, width, 0, fieldLabelStyle.Render("Search")+"\n"+m.searchInput.View())
return lipgloss.NewStyle().Width(width).Render(line + "\n" + search)
}
return barStyle.Render(line)
}
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
switch m.statusKind {
case statusSuccess:
statusColor = colorGreen
case statusError:
statusColor = colorRed
}
status := lipgloss.NewStyle().Foreground(statusColor).Render(truncateText(m.status, contentWidth))
help := "Tab form/list / search b bulk author d dates p path r reload x reset a apply c co-authors i claude/codex f force t tags ? help q quit"
if m.focus == focusForm {
help = "Tab next field Shift+Tab previous Ctrl+T set focused date to now Esc list Ctrl+C quit"
}
if m.overlay == overlayPath {
help = "Enter load repository Esc close"
}
if m.overlay == overlayBulkAuthor {
help = "Tab next field Shift+Tab previous Ctrl+T partial/exact Enter stage Esc cancel"
}
if m.overlay == overlayDateSpread {
help = "Tab next field Shift+Tab previous Enter stage Esc cancel"
}
if m.overlay == overlayConfirmApply {
help = "Press y to rewrite history, n/Esc to cancel"
}
if m.overlay == overlayConfirmForce {
help = "Type FORCE then Enter to rewrite and force push, Esc to cancel"
}
if m.overlay == overlayDatePicker {
help = "←/→ day ↑/↓ hour PgUp/PgDn month +/- 5 min H/L year t now z local u UTC Enter save Esc cancel"
}
return barStyle.Render(status + "\n" + dimStyle.Render(truncateText(help, contentWidth)))
}
func (m tuiModel) renderCommitPane(width int, height int) string {
style := panelStyle
if m.focus == focusList && m.overlay == overlayNone {
style = activePanelStyle
}
innerWidth := maxInt(1, contentWidthFor(style, width))
innerHeight := maxInt(1, contentHeightFor(style, height))
filtered, selectedIndex, _ := selectionAfterFilter(m.listCommits(), m.draftByHash, m.selectedHash, m.searchInput.Value())
header := headerStyle.Render(fmt.Sprintf("Commits %d/%d", len(filtered), len(m.commits)))
if m.aiOnly {
header += " " + dimStyle.Render("[AI]")
}
if m.searchInput.Value() != "" {
header += " " + dimStyle.Render("/"+truncateText(m.searchInput.Value(), innerWidth-12))
}
lines := []string{truncateText(header, innerWidth)}
if len(filtered) == 0 {
lines = append(lines, "", mutedStyle.Render("No commits match this filter."))
return renderBox(style, width, height, fitLines(lines, innerHeight, innerWidth))
}
visibleSlots := maxInt(1, (innerHeight-2)/5)
offset := clamp(m.listOffset, 0, maxInt(0, len(filtered)-visibleSlots))
if selectedIndex < offset {
offset = selectedIndex
}
if selectedIndex >= offset+visibleSlots {
offset = selectedIndex - visibleSlots + 1
}
for i := offset; i < len(filtered) && len(lines) < innerHeight-1; i++ {
item := m.renderCommitItem(filtered[i], i == selectedIndex, innerWidth)
lines = append(lines, strings.Split(item, "\n")...)
if len(lines) < innerHeight {
lines = append(lines, "")
}
}
return renderBox(style, width, height, fitLines(lines, innerHeight, innerWidth))
}
func (m tuiModel) renderCommitItem(commit CommitRecord, selected bool, width int) string {
draft := m.draftByHash[commit.Hash]
if draft.Hash == "" {
draft = commit
}
marker := " "
if commitDirty(commit.Hash, m.originalByHash, m.draftByHash) {
marker = "*"
}
top := fmt.Sprintf("%s %s %s", marker, draft.ShortHash, formatCommitTime(draft.AuthorDate))
author := draft.AuthorName
if author == "" {
author = "(no author)"
}
preview := commitPreviewLines(draft.Message, maxInt(8, width-2), 2)
for len(preview) < 2 {
preview = append(preview, "")
}
block := strings.Join([]string{
truncateText(top, width-2),
truncateText(author, width-2),
truncateText(preview[0], width-2),
truncateText(preview[1], width-2),
}, "\n")
if selected {
return renderBox(selectedItemStyle, width, 0, block)
}
return renderBox(itemStyle, width, 0, block)
}
func (m tuiModel) renderDetailPane(width int, height int) string {
style := panelStyle
if m.focus == focusForm && m.overlay == overlayNone {
style = activePanelStyle
}
innerWidth := maxInt(1, contentWidthFor(style, width))
innerHeight := maxInt(1, contentHeightFor(style, height))
var content string
switch m.overlay {
case overlayPath:
content = m.renderPathPane(innerWidth, innerHeight)
case overlayBulkAuthor:
content = m.renderBulkAuthorPane(innerWidth, innerHeight)
case overlayDateSpread:
content = m.renderDateSpreadPane(innerWidth, innerHeight)
case overlayConfirmApply, overlayConfirmForce:
content = m.renderConfirmPane(innerWidth, innerHeight)
case overlayHelp:
content = m.renderHelpPane(innerWidth, innerHeight)
case overlayDatePicker:
content = m.renderDatePickerPane(innerWidth, innerHeight)
default:
content = m.renderFormPane(innerWidth, innerHeight)
}
return renderBox(style, width, height, content)
}
func (m tuiModel) renderDatePickerPane(width int, height int) string {
target := "Author date"
if m.datePickerTarget == formCommitterDate {
target = "Committer date"
}
zoneName, offset := m.datePickerTime.Zone()
if zoneName == "" {
zoneName = formatZoneOffset(offset)
} else {
zoneName = fmt.Sprintf("%s (%s)", zoneName, formatZoneOffset(offset))
}
lines := []string{
headerStyle.Render("Date & Time Picker"),
mutedStyle.Render(target),
"",
lipgloss.NewStyle().Bold(true).Foreground(colorGreen).Render(m.datePickerTime.Format("Monday, 02 January 2006")),
lipgloss.NewStyle().Bold(true).Foreground(colorCyan).Render(m.datePickerTime.Format("15:04:05") + " " + zoneName),
"",
}
lines = append(lines, renderMonthCalendar(m.datePickerTime)...)
lines = append(lines,
"",
"←/→ or h/l day ↑/↓ or k/j hour",
"PgUp/PgDn or [/] month H/L year +/- five minutes",
"t now z local timezone u UTC 0 clear seconds",
"",
dimStyle.Render("Enter saves the selection. Esc keeps the original timestamp."),
)
return fitLines(lines, height, width)
}
func (m tuiModel) renderBulkAuthorPane(width int, height int) string {
lines := []string{
headerStyle.Render("Bulk replace author"),
"Search current draft author names and emails, then stage a new author identity on every matching commit.",
"",
m.renderBulkAuthorField(bulkAuthorSearch, "Search", width),
m.renderBulkAuthorField(bulkAuthorName, "Replacement author name", width),
m.renderBulkAuthorField(bulkAuthorEmail, "Replacement author email", width),
"",
fmt.Sprintf("Match mode: %s %s", authorMatchMode(m.bulkAuthorExact), dimStyle.Render("(Ctrl+T to toggle)")),
dimStyle.Render("Press Enter to stage these changes. Use a to apply the history rewrite."),
}
return fitLines(wrapBlockLines(lines, width), height, width)
}
func (m tuiModel) renderDateSpreadPane(width int, height int) string {
field := func(index int, label string) string {
style := blurredFieldStyle
if m.dateSpreadFocus == index {
style = focusedFieldStyle
}
return renderBox(style, width, 0, fieldLabelStyle.Render(label)+"\n"+m.dateSpreadInputs[index].View())
}
lines := []string{
headerStyle.Render("Rewrite all timestamps"),
"Give every commit, oldest first, a random author and committer timestamp between the start and end dates, within the daily time window. Each timestamp is never earlier than the one before it.",
"",
field(dateSpreadStartDay, "Start date"),
field(dateSpreadEndDay, "End date"),
field(dateSpreadFromTime, "Daily window from (HH:MM)"),
field(dateSpreadToTime, "Daily window to (HH:MM)"),
"",
dimStyle.Render("Press Enter to stage these changes. Use a to apply the history rewrite."),
}
return fitLines(wrapBlockLines(lines, width), height, width)
}
func (m tuiModel) renderBulkAuthorField(index int, label string, width int) string {
style := blurredFieldStyle
if m.overlay == overlayBulkAuthor && m.bulkAuthorFocus == index {
style = focusedFieldStyle
}
return renderBox(style, width, 0, fieldLabelStyle.Render(label)+"\n"+m.bulkAuthorInputs[index].View())
}
func (m tuiModel) renderPathPane(width int, height int) string {
lines := []string{
headerStyle.Render("Open Repository"),
"",
"Enter a Git worktree path. Nexus starts with the current directory, but you can switch repositories here at any time.",
"",
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."),
}
return fitLines(wrapBlockLines(lines, width), height, width)
}
func (m tuiModel) renderConfirmPane(width int, height int) string {
count := plannedRewriteCount(m.commits, m.originalByHash, m.draftByHash, m.removeCoAuthors)
coAuthorCommits, coAuthorLines := coAuthorCleanupImpact(m.commits, m.draftByHash)
lines := []string{
headerStyle.Render("Confirm Rewrite"),
"",
fmt.Sprintf("This will rewrite Git history for %d edited commit%s. A backup tag will be created first.", count, plural(count)),
}
if m.removeCoAuthors {
lines = append(lines, "", fmt.Sprintf("Co-author cleanup will remove %d matching line%s from %d commit%s.", coAuthorLines, plural(coAuthorLines), coAuthorCommits, plural(coAuthorCommits)))
}
if m.forcePush {
lines = append(lines,
"",
lipgloss.NewStyle().Foreground(colorRed).Bold(true).Render("Force push is enabled."),
"Type FORCE to confirm rewriting history and pushing with --force-with-lease.",
"",
renderBox(focusedFieldStyle, minInt(width, 24), 0, fieldLabelStyle.Render("Confirmation")+"\n"+m.confirmInput.View()),
)
} else {
lines = append(lines, "", "Press y to continue, or n/Esc to cancel.")
}
return fitLines(wrapBlockLines(lines, width), height, width)
}
func (m tuiModel) renderHelpPane(width int, height int) string {
lines := []string{
headerStyle.Render("Keyboard"),
"",
"List: Up/Down, PgUp/PgDn, Home/End select commits.",
"Tab moves from the list into the commit form.",
"Form: Tab and Shift+Tab move through every editable value. Esc returns to the list.",
"/ searches commits. b bulk-replaces author identities. d spreads all timestamps across a date range. p opens a repository path prompt. r reloads history.",
"x resets the selected commit. a applies edits. c toggles bulk Co-authored-by cleanup. i shows only Claude/Codex commits and merges that brought them in.",
"f toggles force push. t toggles tag push. Ctrl+T stamps the focused date field with the current local time.",
"",
headerStyle.Render("Safety"),
"",
"Apply creates a backup tag before rewriting history. Force push uses --force-with-lease.",
"",
dimStyle.Render("Press ? or Esc to close help."),
}
return fitLines(wrapBlockLines(lines, width), height, width)
}
func (m tuiModel) renderFormPane(width int, height int) string {
if !m.repoLoaded {
return fitLines([]string{
headerStyle.Render("No Repository"),
"",
"Enter a repository path to begin.",
"",
dimStyle.Render("Press p to open the path prompt."),
}, height, width)
}
if m.selectedHash == "" {
return fitLines([]string{
headerStyle.Render("No Commits"),
"",
"This repository has no commits to edit.",
}, height, width)
}
draft := m.draftByHash[m.selectedHash]
title := fmt.Sprintf("%s %s", draft.ShortHash, firstLine(draft.Message))
lines := []string{headerStyle.Render(truncateText(title, width)), ""}
cellGap := 2
cellWidth := maxInt(1, (width-cellGap)/2)
rows := [][2]int{
{formAuthorName, formAuthorEmail},
{formAuthorDate, formCommitterName},
{formCommitterEmail, formCommitterDate},
}
labels := []string{
"Author name",
"Author email",
"Author date",
"Committer name",
"Committer email",
"Committer date",
}
if width >= 44 {
for _, row := range rows {
left := m.renderTextField(row[0], labels[row[0]], cellWidth)
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)
lines = append(lines, m.renderMessageField(width, messageHeight))
meta := []string{
fmt.Sprintf("Hash: %s", draft.Hash),
fmt.Sprintf("Parents: %s", strings.Join(draft.Parents, ", ")),
fmt.Sprintf("Tree: %s", draft.Tree),
fmt.Sprintf("Refs: %s", strings.Join(draft.Refs, ", ")),
}
if len(draft.Parents) == 0 {
meta[1] = "Parents: (root commit)"
}
if len(draft.Refs) == 0 {
meta[3] = "Refs: (none)"
}
lines = append(lines, dimStyle.Render(truncateText(strings.Join(meta, " "), width)))
return fitLines(lines, height, width)
}
func (m tuiModel) renderTextField(index int, label string, width int) string {
style := blurredFieldStyle
if m.focus == focusForm && m.overlay == overlayNone && m.formFocus == index {
style = focusedFieldStyle
}
return renderBox(style, width, 0, fieldLabelStyle.Render(label)+"\n"+m.inputs[index].View())
}
func (m tuiModel) renderMessageField(width int, height int) string {
style := blurredFieldStyle
if m.focus == focusForm && m.overlay == overlayNone && m.formFocus == formMessage {
style = focusedFieldStyle
}
return renderBox(style, width, height, fieldLabelStyle.Render("Commit message")+"\n"+m.message.View())
}
func (m *tuiModel) updateComponentSizes() {
width := m.width
height := m.height
if width <= 0 {
width = 120
}
if height <= 0 {
height = 36
}
_, rightWidth, _ := tuiColumnWidths(width)
innerRight := maxInt(1, contentWidthFor(panelStyle, rightWidth))
cellWidth := maxInt(18, (innerRight-2)/2)
for i := range m.inputs {
m.inputs[i].Width = maxInt(4, contentWidthFor(blurredFieldStyle, cellWidth))
}
m.message.SetWidth(maxInt(4, contentWidthFor(blurredFieldStyle, innerRight)))
m.message.SetHeight(clamp(height/4, 5, 10))
m.searchInput.Width = maxInt(16, width-10)
m.pathInput.Width = maxInt(4, contentWidthFor(focusedFieldStyle, innerRight))
m.confirmInput.Width = 12
for i := range m.bulkAuthorInputs {
m.bulkAuthorInputs[i].Width = maxInt(4, contentWidthFor(blurredFieldStyle, innerRight))
}
}
func (m *tuiModel) openBulkAuthor() {
m.blurForm()
m.searchInput.Blur()
m.pathInput.Blur()
m.confirmInput.Blur()
for i := range m.bulkAuthorInputs {
m.bulkAuthorInputs[i].SetValue("")
}
m.bulkAuthorExact = false
m.overlay = overlayBulkAuthor
m.focusBulkAuthorField(bulkAuthorSearch)
m.setStatus("Enter an author search and replacement identity.", statusInfo)
}
func (m *tuiModel) openDateSpread() {
m.blurForm()
m.searchInput.Blur()
m.pathInput.Blur()
m.confirmInput.Blur()
m.overlay = overlayDateSpread
m.focusDateSpreadField(dateSpreadStartDay)
m.setStatus("Enter a date range and daily time window.", statusInfo)
}
func (m *tuiModel) focusDateSpreadField(index int) {
m.blurInputs(m.dateSpreadInputs)
m.dateSpreadFocus = clamp(index, 0, dateSpreadFieldCount-1)
m.dateSpreadInputs[m.dateSpreadFocus].Focus()
m.dateSpreadInputs[m.dateSpreadFocus].CursorEnd()
}
func (m *tuiModel) blurInputs(inputs []textinput.Model) {
for i := range inputs {
inputs[i].Blur()
}
}
func (m *tuiModel) focusBulkAuthorField(index int) {
m.blurBulkAuthorInputs()
m.bulkAuthorFocus = clamp(index, 0, bulkAuthorFieldCount-1)
m.bulkAuthorInputs[m.bulkAuthorFocus].Focus()
m.bulkAuthorInputs[m.bulkAuthorFocus].CursorEnd()
}
func (m *tuiModel) blurBulkAuthorInputs() {
for i := range m.bulkAuthorInputs {
m.bulkAuthorInputs[i].Blur()
}
}
func (m *tuiModel) focusPathInput() {
m.blurForm()
m.searchInput.Blur()
m.confirmInput.Blur()
m.pathInput.Focus()
m.pathInput.CursorEnd()
}
func (m *tuiModel) focusFormField(index int) {
m.blurForm()
m.formFocus = clamp(index, 0, formFieldCount-1)
if m.formFocus == formMessage {
m.message.Focus()
return
}
m.inputs[m.formFocus].Focus()
m.inputs[m.formFocus].CursorEnd()
}
func (m *tuiModel) blurForm() {
for i := range m.inputs {
m.inputs[i].Blur()
}
m.message.Blur()
}
func (m *tuiModel) reconcileSelection() {
previousHash := m.selectedHash
filtered, index, hash := selectionAfterFilter(m.listCommits(), m.draftByHash, m.selectedHash, m.searchInput.Value())
if len(filtered) == 0 {
m.selectedHash = ""
m.selectedIndex = 0
m.listOffset = 0
m.blurForm()
m.focus = focusList
return
}
m.selectedHash = hash
m.selectedIndex = index
m.ensureSelectionVisible(len(filtered))
if previousHash != m.selectedHash {
m.loadSelectedCommitIntoForm()
}
}
func (m *tuiModel) ensureSelectionVisible(total int) {
visibleSlots := 5
if m.height > 0 {
visibleSlots = maxInt(1, (m.height-8)/5)
}
if m.selectedIndex < m.listOffset {
m.listOffset = m.selectedIndex
}
if m.selectedIndex >= m.listOffset+visibleSlots {
m.listOffset = m.selectedIndex - visibleSlots + 1
}
m.listOffset = clamp(m.listOffset, 0, maxInt(0, total-visibleSlots))
}
func (m *tuiModel) moveSelection(delta int) {
filtered, selectedIndex, _ := selectionAfterFilter(m.listCommits(), m.draftByHash, m.selectedHash, m.searchInput.Value())
if len(filtered) == 0 {
return
}
m.syncFormToDraft()
next := clamp(selectedIndex+delta, 0, len(filtered)-1)
m.selectedHash = filtered[next].Hash
m.selectedIndex = next
m.ensureSelectionVisible(len(filtered))
m.loadSelectedCommitIntoForm()
}
func (m *tuiModel) moveSelectionTo(index int) {
filtered, _, _ := selectionAfterFilter(m.listCommits(), m.draftByHash, m.selectedHash, m.searchInput.Value())
if len(filtered) == 0 {
return
}
m.syncFormToDraft()
next := clamp(index, 0, len(filtered)-1)
m.selectedHash = filtered[next].Hash
m.selectedIndex = next
m.ensureSelectionVisible(len(filtered))
m.loadSelectedCommitIntoForm()
}
func (m *tuiModel) loadSelectedCommitIntoForm() {
if m.selectedHash == "" {
return
}
draft, ok := m.draftByHash[m.selectedHash]
if !ok {
return
}
values := []string{
draft.AuthorName,
draft.AuthorEmail,
draft.AuthorDate,
draft.CommitterName,
draft.CommitterEmail,
draft.CommitterDate,
}
for i, value := range values {
m.inputs[i].SetValue(value)
}
m.message.SetValue(draft.Message)
}
func (m *tuiModel) syncFormToDraft() {
if m.selectedHash == "" {
return
}
draft, ok := m.draftByHash[m.selectedHash]
if !ok {
return
}
draft.AuthorName = m.inputs[formAuthorName].Value()
draft.AuthorEmail = m.inputs[formAuthorEmail].Value()
draft.AuthorDate = m.inputs[formAuthorDate].Value()
draft.CommitterName = m.inputs[formCommitterName].Value()
draft.CommitterEmail = m.inputs[formCommitterEmail].Value()
draft.CommitterDate = m.inputs[formCommitterDate].Value()
draft.Message = m.message.Value()
draft.Subject = firstLine(draft.Message)
m.draftByHash[m.selectedHash] = draft
}
func (m *tuiModel) normalizeFocusedDateInput() {
if m.formFocus != formAuthorDate && m.formFocus != formCommitterDate {
return
}
if normalized, err := normalizeGitDate(m.inputs[m.formFocus].Value()); err == nil {
m.inputs[m.formFocus].SetValue(normalized)
}
}
func (m *tuiModel) resetSelectedCommit() {
if m.selectedHash == "" {
return
}
original, ok := m.originalByHash[m.selectedHash]
if !ok {
return
}
m.draftByHash[m.selectedHash] = cloneCommitRecord(original)
m.loadSelectedCommitIntoForm()
m.setStatus(fmt.Sprintf("Reset %s", original.ShortHash), statusInfo)
}
func (m tuiModel) applyRequest() ApplyRequest {
commits := make([]CommitRecord, 0, len(m.commits))
for _, commit := range m.commits {
draft, ok := m.draftByHash[commit.Hash]
if !ok {
draft = commit
}
commits = append(commits, cloneCommitRecord(draft))
}
return ApplyRequest{
Commits: commits,
ForcePush: m.forcePush,
PushTags: m.pushTags,
RemoveCoAuthors: m.removeCoAuthors,
}
}
func (m *tuiModel) setStatus(message string, kind statusKind) {
m.status = message
m.statusKind = kind
}
func loadRepositoryCmd(app *App, path string) tea.Cmd {
return func() tea.Msg {
repo, err := app.SetRepository(path)
if err != nil {
return repoLoadedMsg{path: path, err: err}
}
commits, err := app.LoadHistory()
if err != nil {
return repoLoadedMsg{path: path, err: err}
}
return repoLoadedMsg{repo: repo, commits: commits, path: path}
}
}
func applyChangesCmd(app *App, req ApplyRequest) tea.Cmd {
return func() tea.Msg {
result, err := app.ApplyChanges(req)
if err != nil {
return applyFinishedMsg{err: err}
}
repo, repoErr := app.GetRepositoryState()
if repoErr != nil {
return applyFinishedMsg{result: result, err: repoErr}
}
commits, historyErr := app.LoadHistory()
if historyErr != nil {
return applyFinishedMsg{result: result, repo: repo, err: historyErr}
}
return applyFinishedMsg{result: result, repo: repo, commits: commits}
}
}
func newestFirst(commits []CommitRecord) []CommitRecord {
out := make([]CommitRecord, 0, len(commits))
for i := len(commits) - 1; i >= 0; i-- {
out = append(out, cloneCommitRecord(commits[i]))
}
return out
}
func mapByHash(commits []CommitRecord) map[string]CommitRecord {
result := make(map[string]CommitRecord, len(commits))
for _, commit := range commits {
result[commit.Hash] = cloneCommitRecord(commit)
}
return result
}
func cloneMapByHash(commits []CommitRecord) map[string]CommitRecord {
return mapByHash(commits)
}
func cloneCommitRecord(commit CommitRecord) CommitRecord {
clone := commit
clone.Refs = append([]string(nil), commit.Refs...)
clone.Parents = append([]string(nil), commit.Parents...)
return clone
}
func stageAuthorReplacement(commits []CommitRecord, draftByHash map[string]CommitRecord, query string, replacementName string, replacementEmail string, exact bool) (int, error) {
query = strings.TrimSpace(query)
replacementName = strings.TrimSpace(replacementName)
replacementEmail = strings.TrimSpace(replacementEmail)
if query == "" {
return 0, errors.New("author search is required")
}
if replacementName == "" {
return 0, errors.New("replacement author name is required")
}
if replacementEmail == "" {
return 0, errors.New("replacement author email is required")
}
fold := cases.Fold()
foldedQuery := fold.String(query)
matched := 0
for _, commit := range commits {
draft, ok := draftByHash[commit.Hash]
if !ok {
draft = cloneCommitRecord(commit)
}
nameMatches := strings.EqualFold(draft.AuthorName, query)
emailMatches := strings.EqualFold(draft.AuthorEmail, query)
if !exact {
nameMatches = strings.Contains(fold.String(draft.AuthorName), foldedQuery)
emailMatches = strings.Contains(fold.String(draft.AuthorEmail), foldedQuery)
}
if !nameMatches && !emailMatches {
continue
}
draft.AuthorName = replacementName
draft.AuthorEmail = replacementEmail
draftByHash[commit.Hash] = draft
matched++
}
return matched, nil
}
// stageDateSpread gives every commit (oldest first) the same author and
// committer timestamp: a random day in [startDay, endDay] at a random time in
// [fromTime, toTime), in local time. Sorting the random instants keeps them
// non-decreasing. randN returns a value in [0, n).
func stageDateSpread(commits []CommitRecord, draftByHash map[string]CommitRecord, startDay, endDay, fromTime, toTime string, randN func(int64) int64) (int, error) {
start, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(startDay), time.Local)
if err != nil {
return 0, errors.New("start date must be YYYY-MM-DD")
}
end, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(endDay), time.Local)
if err != nil {
return 0, errors.New("end date must be YYYY-MM-DD")
}
if end.Before(start) {
return 0, errors.New("end date must not be before start date")
}
from, err := time.Parse("15:04", strings.TrimSpace(fromTime))
if err != nil {
return 0, errors.New("window start must be HH:MM")
}
to, err := time.Parse("15:04", strings.TrimSpace(toTime))
if err != nil {
return 0, errors.New("window end must be HH:MM")
}
window := int64(to.Sub(from) / time.Second)
if window <= 0 {
return 0, errors.New("window end must be after window start")
}
days := int64(0)
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
days++
}
stamps := make([]time.Time, len(commits))
for i := range stamps {
day := start.AddDate(0, 0, int(randN(days)))
base := time.Date(day.Year(), day.Month(), day.Day(), from.Hour(), from.Minute(), 0, 0, time.Local)
stamps[i] = base.Add(time.Duration(randN(window)) * time.Second)
}
sort.Slice(stamps, func(i, j int) bool { return stamps[i].Before(stamps[j]) })
// commits is newest first; walk it oldest first.
for i := range commits {
commit := commits[len(commits)-1-i]
draft, ok := draftByHash[commit.Hash]
if !ok {
draft = cloneCommitRecord(commit)
}
draft.AuthorDate = formatGitDate(stamps[i])
draft.CommitterDate = draft.AuthorDate
draftByHash[commit.Hash] = draft
}
return len(commits), nil
}
func authorMatchMode(exact bool) string {
if exact {
return "Exact"
}
return "Partial"
}
func selectionAfterFilter(commits []CommitRecord, draftByHash map[string]CommitRecord, selectedHash string, query string) ([]CommitRecord, int, string) {
filtered := filterCommits(commits, draftByHash, query)
if len(filtered) == 0 {
return filtered, 0, ""
}
for index, commit := range filtered {
if commit.Hash == selectedHash {
return filtered, index, selectedHash
}
}
return filtered, 0, filtered[0].Hash
}
func (m tuiModel) listCommits() []CommitRecord {
if !m.aiOnly {
return m.commits
}
filtered := make([]CommitRecord, 0, len(m.aiHashes))
for _, commit := range m.commits {
if m.aiHashes[commit.Hash] {
filtered = append(filtered, commit)
}
}
return filtered
}
var aiIdentityPattern = regexp.MustCompile(`(?i)\b(claude|codex|anthropic)\b`)
// aiCommitHashes marks commits whose author, committer, or message mentions
// Claude/Codex, plus merge commits whose merged-in side contains such a commit.
func aiCommitHashes(commits []CommitRecord) map[string]bool {
byHash := mapByHash(commits)
ai := map[string]bool{}
for _, c := range commits {
if aiIdentityPattern.MatchString(strings.Join([]string{c.AuthorName, c.AuthorEmail, c.CommitterName, c.CommitterEmail, c.Message}, "\n")) {
ai[c.Hash] = true
}
}
// ponytail: full ancestor walk per merge, O(commits x merges); memoise if huge repos crawl.
walk := func(start []string, skip map[string]bool, visit func(string) bool) map[string]bool {
seen := map[string]bool{}
stack := append([]string(nil), start...)
for len(stack) > 0 {
h := stack[len(stack)-1]
stack = stack[:len(stack)-1]
if seen[h] || skip[h] {
continue
}
seen[h] = true
if visit(h) {
return seen
}
stack = append(stack, byHash[h].Parents...)
}
return seen
}
for _, c := range commits {
if len(c.Parents) < 2 || ai[c.Hash] {
continue
}
base := walk(c.Parents[:1], nil, func(string) bool { return false })
walk(c.Parents[1:], base, func(h string) bool {
if ai[h] {
ai[c.Hash] = true
}
return ai[c.Hash]
})
}
return ai
}
func filterCommits(commits []CommitRecord, draftByHash map[string]CommitRecord, query string) []CommitRecord {
query = strings.ToLower(strings.TrimSpace(query))
if query == "" {
return append([]CommitRecord(nil), commits...)
}
filtered := make([]CommitRecord, 0, len(commits))
for _, commit := range commits {
draft := commit
if item, ok := draftByHash[commit.Hash]; ok {
draft = item
}
haystack := strings.ToLower(strings.Join([]string{
commit.Hash,
commit.ShortHash,
draft.AuthorName,
draft.AuthorEmail,
draft.Subject,
draft.Message,
}, "\n"))
if strings.Contains(haystack, query) {
filtered = append(filtered, commit)
}
}
return filtered
}
func dirtyCommitCount(commits []CommitRecord, originalByHash map[string]CommitRecord, draftByHash map[string]CommitRecord) int {
count := 0
for _, commit := range commits {
if commitDirty(commit.Hash, originalByHash, draftByHash) {
count++
}
}
return count
}
func plannedRewriteCount(commits []CommitRecord, originalByHash map[string]CommitRecord, draftByHash map[string]CommitRecord, removeCoAuthors bool) int {
count := 0
for _, commit := range commits {
dirty := commitDirty(commit.Hash, originalByHash, draftByHash)
draft := draftByHash[commit.Hash]
_, coAuthorLines := removeCoAuthorLines(draft.Message)
if dirty || (removeCoAuthors && coAuthorLines > 0) {
count++
}
}
return count
}
func coAuthorCleanupImpact(commits []CommitRecord, draftByHash map[string]CommitRecord) (int, int) {
commitCount := 0
lineCount := 0
for _, commit := range commits {
draft := draftByHash[commit.Hash]
_, removed := removeCoAuthorLines(draft.Message)
if removed > 0 {
commitCount++
lineCount += removed
}
}
return commitCount, lineCount
}
func validateDraftDates(commits []CommitRecord, draftByHash map[string]CommitRecord) error {
for _, commit := range commits {
draft := draftByHash[commit.Hash]
if _, err := normalizeGitDate(draft.AuthorDate); err != nil {
return fmt.Errorf("commit %s has an invalid author date: %v", draft.ShortHash, err)
}
if _, err := normalizeGitDate(draft.CommitterDate); err != nil {
return fmt.Errorf("commit %s has an invalid committer date: %v", draft.ShortHash, err)
}
}
return nil
}
func commitDirty(hash string, originalByHash map[string]CommitRecord, draftByHash map[string]CommitRecord) bool {
original, hasOriginal := originalByHash[hash]
draft, hasDraft := draftByHash[hash]
if !hasOriginal || !hasDraft {
return false
}
return editableFieldsChanged(original, draft)
}
func nextFormFocus(current int, backwards bool) (int, bool) {
if backwards {
if current <= 0 {
return 0, true
}
return current - 1, false
}
if current >= formFieldCount-1 {
return current, true
}
return current + 1, false
}
func commitPreviewLines(message string, width int, maxLines int) []string {
summary := strings.Join(strings.Fields(normalizeMessage(message)), " ")
if summary == "" {
summary = "(no message)"
}
return wrapText(summary, maxInt(4, width), maxInt(1, maxLines))
}
func wrapText(value string, width int, maxLines int) []string {
words := strings.Fields(value)
if len(words) == 0 {
return []string{""}
}
lines := make([]string, 0, maxLines)
current := ""
for _, word := range words {
if current == "" {
current = word
continue
}
if lipgloss.Width(current)+1+lipgloss.Width(word) <= width {
current += " " + word
continue
}
lines = append(lines, truncateText(current, width))
current = word
if len(lines) == maxLines {
break
}
}
if len(lines) < maxLines && current != "" {
lines = append(lines, truncateText(current, width))
}
if len(lines) == maxLines && len(words) > 0 {
last := strings.Join(lines, " ")
if lipgloss.Width(last) < lipgloss.Width(value) {
lines[len(lines)-1] = truncateText(lines[len(lines)-1], width)
}
}
return lines
}
func wrapBlockLines(lines []string, width int) []string {
var out []string
for _, line := range lines {
if strings.TrimSpace(line) == "" || strings.Contains(line, "\x1b[") {
out = append(out, line)
continue
}
wrapped := wrapText(line, width, 99)
out = append(out, wrapped...)
}
return out
}
func fitLines(lines []string, height int, width int) string {
out := make([]string, 0, height)
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 {
break
}
}
for len(out) < height {
out = append(out, "")
}
return strings.Join(out, "\n")
}
func truncateText(value string, width int) string {
if width <= 0 {
return ""
}
if lipgloss.Width(value) <= width {
return value
}
if width <= 3 {
return strings.Repeat(".", width)
}
var builder strings.Builder
for _, r := range value {
if lipgloss.Width(builder.String()+string(r)+"...") > width {
break
}
builder.WriteRune(r)
}
return builder.String() + "..."
}
func formatCommitTime(value string) string {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
parsed, err = time.Parse(time.RFC3339Nano, value)
}
if err != nil {
return value
}
return parsed.Local().Format("2006-01-02 15:04")
}
func sameWallClockIn(value time.Time, location *time.Location) time.Time {
return time.Date(value.Year(), value.Month(), value.Day(), value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), location)
}
func shiftCalendarMonth(value time.Time, months int) time.Time {
targetMonth := time.Date(value.Year(), value.Month()+time.Month(months), 1, value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), value.Location())
day := minInt(value.Day(), targetMonth.AddDate(0, 1, -1).Day())
return time.Date(targetMonth.Year(), targetMonth.Month(), day, value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), value.Location())
}
func shiftCalendarYear(value time.Time, years int) time.Time {
targetYear := value.Year() + years
lastDay := time.Date(targetYear, value.Month()+1, 0, value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), value.Location()).Day()
return time.Date(targetYear, value.Month(), minInt(value.Day(), lastDay), value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), value.Location())
}
func formatZoneOffset(offsetSeconds int) string {
sign := "+"
if offsetSeconds < 0 {
sign = "-"
offsetSeconds = -offsetSeconds
}
return fmt.Sprintf("%s%02d:%02d", sign, offsetSeconds/3600, (offsetSeconds%3600)/60)
}
func renderMonthCalendar(selected time.Time) []string {
first := time.Date(selected.Year(), selected.Month(), 1, 0, 0, 0, 0, selected.Location())
leading := (int(first.Weekday()) + 6) % 7
daysInMonth := first.AddDate(0, 1, -1).Day()
lines := []string{
lipgloss.NewStyle().Bold(true).Foreground(colorMagenta).Render(selected.Format("January 2006")),
dimStyle.Render("Mo Tu We Th Fr Sa Su"),
}
week := make([]string, 7)
for day := 1; day <= daysInMonth; day++ {
position := leading + day - 1
cell := fmt.Sprintf("%2d", day)
if day == selected.Day() {
cell = lipgloss.NewStyle().Bold(true).Foreground(colorPanel).Background(colorCyan).Render(cell)
}
week[position%7] = cell
if position%7 == 6 || day == daysInMonth {
for index := range week {
if week[index] == "" {
week[index] = " "
}
}
lines = append(lines, strings.Join(week, " "))
week = make([]string, 7)
}
}
return lines
}
func applyResultSummary(result ApplyResult) string {
parts := []string{}
if result.RewrittenCommits == 0 {
parts = append(parts, "No commits required rewriting.")
} else {
parts = append(parts, fmt.Sprintf("Rewrote %d commit%s.", result.RewrittenCommits, plural(result.RewrittenCommits)))
}
if result.BackupReference != "" {
parts = append(parts, "Backup "+result.BackupReference+".")
}
if result.RemovedCoAuthors > 0 {
parts = append(parts, fmt.Sprintf("Removed %d co-author line%s.", result.RemovedCoAuthors, plural(result.RemovedCoAuthors)))
}
if result.ForcePushed {
parts = append(parts, "Force push completed.")
}
if len(result.Warnings) > 0 {
parts = append(parts, "Warning: "+strings.Join(result.Warnings, " "))
}
return strings.Join(parts, " ")
}
func onOff(value bool) string {
if value {
return "on"
}
return "off"
}
func plural(count int) string {
if count == 1 {
return ""
}
return "s"
}
func clamp(value int, low int, high int) int {
if high < low {
return low
}
if value < low {
return low
}
if value > high {
return high
}
return value
}
func minInt(a int, b int) int {
if a < b {
return a
}
return b
}
func maxInt(a int, b int) int {
if a > b {
return a
}
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
}