Files
git-editor/tui.go
T
2026-06-15 07:57:51 +01:00

1330 lines
34 KiB
Go

package main
import (
"errors"
"fmt"
"path/filepath"
"strings"
"time"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const (
formAuthorName = iota
formAuthorEmail
formAuthorDate
formCommitterName
formCommitterEmail
formCommitterDate
formMessage
formFieldCount
)
type uiFocus int
const (
focusList uiFocus = iota
focusForm
)
type uiOverlay int
const (
overlayNone uiOverlay = iota
overlaySearch
overlayPath
overlayConfirmApply
overlayConfirmForce
overlayHelp
)
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
forcePush bool
pushTags 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"
return tuiModel{
app: app,
initialPath: initialPath,
originalByHash: map[string]CommitRecord{},
draftByHash: map[string]CommitRecord{},
inputs: inputs,
message: message,
searchInput: searchInput,
pathInput: pathInput,
confirmInput: confirmInput,
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.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.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 overlayConfirmApply, overlayConfirmForce:
return m.updateConfirmation(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 "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 "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.commits, 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) 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 "esc":
m.syncFormToDraft()
m.focus = focusList
m.blurForm()
return m, nil
case "tab", "shift+tab":
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) beginApply() (tea.Model, tea.Cmd) {
if !m.repoLoaded {
m.setStatus("Load a repository before applying changes.", statusError)
return m, nil
}
m.syncFormToDraft()
count := dirtyCommitCount(m.commits, m.originalByHash, m.draftByHash)
if count == 0 && !m.forcePush {
m.setStatus("No history edits 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
}
top := m.renderTopBar(width)
bottom := m.renderBottomBar(width)
bodyHeight := height - lipgloss.Height(top) - lipgloss.Height(bottom) - 2
if bodyHeight < 12 {
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)
right := m.renderDetailPane(rightWidth, bodyHeight)
body := lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)
return appBaseStyle.Width(width).Render(strings.Join([]string{top, body, bottom}, "\n"))
}
func (m tuiModel) renderTopBar(width int) string {
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 edits:%d", onOff(m.forcePush), onOff(m.pushTags), dirtyCommitCount(m.commits, m.originalByHash, m.draftByHash))
if m.busy {
flags = "working..."
}
line := lipgloss.JoinHorizontal(
lipgloss.Center,
title,
" ",
mutedStyle.Render(truncateText(repoText, maxInt(8, width-lipgloss.Width(title)-lipgloss.Width(flags)-8))),
)
line = line + strings.Repeat(" ", maxInt(1, width-lipgloss.Width(line)-lipgloss.Width(flags)-2)) + dimStyle.Render(flags)
if m.overlay == overlaySearch {
search := focusedFieldStyle.Width(width - 4).Render(fieldLabelStyle.Render("Search") + "\n" + m.searchInput.View())
return lipgloss.NewStyle().Width(width).Render(line + "\n" + search)
}
return lipgloss.NewStyle().
Width(width).
Background(lipgloss.Color("#0C1824")).
Padding(0, 1).
Render(line)
}
func (m tuiModel) renderBottomBar(width int) string {
statusColor := colorCyan
switch m.statusKind {
case statusSuccess:
statusColor = colorGreen
case statusError:
statusColor = colorRed
}
status := lipgloss.NewStyle().Foreground(statusColor).Render(truncateText(m.status, width-4))
help := "Tab form/list / search p path r reload x reset a apply f force t tags ? help q quit"
if m.focus == focusForm {
help = "Tab next field Shift+Tab previous Esc list Enter newline in message Ctrl+C quit"
}
if m.overlay == overlayPath {
help = "Enter load repository Esc close"
}
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"
}
return lipgloss.NewStyle().
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 {
style := panelStyle
if m.focus == focusList && m.overlay == overlayNone {
style = activePanelStyle
}
innerWidth := maxInt(10, width-2)
innerHeight := maxInt(6, height-2)
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)))
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 style.Width(width).Height(height).Render(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 style.Width(width).Height(height).Render(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 selectedItemStyle.Width(width).Render(block)
}
return itemStyle.Width(width).Render(block)
}
func (m tuiModel) renderDetailPane(width int, height int) string {
style := panelStyle
if m.focus == focusForm && m.overlay == overlayNone {
style = activePanelStyle
}
innerWidth := maxInt(20, width-2)
innerHeight := maxInt(6, height-2)
var content string
switch m.overlay {
case overlayPath:
content = m.renderPathPane(innerWidth, innerHeight)
case overlayConfirmApply, overlayConfirmForce:
content = m.renderConfirmPane(innerWidth, innerHeight)
case overlayHelp:
content = m.renderHelpPane(innerWidth, innerHeight)
default:
content = m.renderFormPane(innerWidth, innerHeight)
}
return style.Width(width).Height(height).Render(content)
}
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.",
"",
focusedFieldStyle.Width(width).Render(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 := dirtyCommitCount(m.commits, m.originalByHash, 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.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.",
"",
focusedFieldStyle.Width(minInt(width, 24)).Render(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. p opens a repository path prompt. r reloads history.",
"x resets the selected commit. a applies edits. f toggles force push. t toggles tag push.",
"",
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(18, (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",
}
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))
}
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 style.Width(width).Render(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 style.Width(width).Height(height).Render(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
}
leftWidth := clamp(width*38/100, 30, 54)
if width-leftWidth-1 < 34 {
leftWidth = maxInt(24, width-35)
}
rightWidth := maxInt(30, width-leftWidth-1)
innerRight := maxInt(20, rightWidth-4)
cellWidth := maxInt(18, (innerRight-2)/2)
for i := range m.inputs {
m.inputs[i].Width = maxInt(8, cellWidth-4)
}
m.message.SetWidth(maxInt(12, innerRight-4))
m.message.SetHeight(clamp(height/4, 5, 10))
m.searchInput.Width = maxInt(16, width-10)
m.pathInput.Width = maxInt(20, innerRight-8)
m.confirmInput.Width = 12
}
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.commits, 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.commits, 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.commits, 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) 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,
}
}
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 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 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 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 _, line := range lines {
if len(out) >= height {
break
}
out = append(out, truncateText(line, width))
}
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 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.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
}