1613 lines
44 KiB
Go
1613 lines
44 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
|
|
overlayDatePicker
|
|
)
|
|
|
|
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
|
|
|
|
forcePush bool
|
|
pushTags bool
|
|
removeCoAuthors 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.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 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 "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 "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 "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 p path r reload x reset a apply c co-authors 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 == 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.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 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 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) 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. p opens a repository path prompt. r reloads history.",
|
|
"x resets the selected commit. a applies edits. c toggles bulk Co-authored-by cleanup.",
|
|
"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
|
|
}
|
|
|
|
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) 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 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 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
|
|
}
|