arche / internal/tui/hunks.go

commit 154431fd
  1package tui
  2
  3import (
  4	"fmt"
  5	"strings"
  6
  7	"arche/internal/diff"
  8
  9	tea "github.com/charmbracelet/bubbletea"
 10	"github.com/charmbracelet/lipgloss"
 11)
 12
 13type HunkItem struct {
 14	FilePath         string
 15	HunkIdx          int
 16	TotalHunksInFile int
 17	Hunk             diff.Hunk
 18}
 19
 20type HunkSelection struct {
 21	Selected  []bool
 22	Cancelled bool
 23}
 24
 25func RunHunkSelector(items []HunkItem, verb string) (HunkSelection, error) {
 26	if len(items) == 0 {
 27		return HunkSelection{}, nil
 28	}
 29	m := hunkModel{
 30		items:    items,
 31		selected: make([]bool, len(items)),
 32		verb:     verb,
 33	}
 34	prog := tea.NewProgram(m, tea.WithAltScreen())
 35	final, err := prog.Run()
 36	if err != nil {
 37		return HunkSelection{Cancelled: true}, err
 38	}
 39	fm := final.(hunkModel)
 40	return HunkSelection{Selected: fm.selected, Cancelled: fm.quit}, nil
 41}
 42
 43var (
 44	styleAdd     = lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
 45	styleRemove  = lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
 46	styleEqual   = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
 47	styleHeader  = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
 48	styleBar     = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
 49	styleKey     = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11"))
 50	styleInclude = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("2"))
 51	styleSkip    = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("1"))
 52)
 53
 54type hunkModel struct {
 55	items    []HunkItem
 56	selected []bool
 57	cursor   int
 58	verb     string
 59	quit     bool
 60	done     bool
 61	msg      string
 62}
 63
 64func (m hunkModel) Init() tea.Cmd { return nil }
 65
 66func (m hunkModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 67	switch msg := msg.(type) {
 68	case tea.KeyMsg:
 69		switch msg.String() {
 70		case "y", "Y":
 71			m.selected[m.cursor] = true
 72			return m.advance(), nil
 73
 74		case "n", "N":
 75			m.selected[m.cursor] = false
 76			return m.advance(), nil
 77
 78		case "a":
 79			for i := m.cursor; i < len(m.items); i++ {
 80				m.selected[i] = true
 81			}
 82			m.msg = "All remaining hunks included."
 83			m.done = true
 84			return m, tea.Quit
 85
 86		case "d":
 87			for i := m.cursor; i < len(m.items); i++ {
 88				m.selected[i] = false
 89			}
 90			m.msg = "All remaining hunks skipped."
 91			m.done = true
 92			return m, tea.Quit
 93
 94		case "left", "h", "p", "backspace":
 95			if m.cursor > 0 {
 96				m.cursor--
 97			}
 98
 99		case "right", "l", "tab":
100			if m.cursor < len(m.items)-1 {
101				m.cursor++
102			}
103
104		case "enter", "q":
105			m.done = true
106			return m, tea.Quit
107
108		case "ctrl+c", "Q":
109			m.quit = true
110			m.done = true
111			return m, tea.Quit
112		}
113	}
114	return m, nil
115}
116
117func (m hunkModel) advance() hunkModel {
118	if m.cursor < len(m.items)-1 {
119		m.cursor++
120	} else {
121		m.done = true
122	}
123	return m
124}
125
126func (m hunkModel) View() string {
127	if m.done {
128		if m.quit {
129			return styleRemove.Render("Aborted.") + "\n"
130		}
131		if m.msg != "" {
132			return m.msg + "\n"
133		}
134		return styleInclude.Render("Selection confirmed.") + "\n"
135	}
136
137	item := m.items[m.cursor]
138	var sb strings.Builder
139
140	progress := fmt.Sprintf("[%d/%d]", m.cursor+1, len(m.items))
141	fileInfo := fmt.Sprintf("%s  hunk %d/%d",
142		item.FilePath, item.HunkIdx+1, item.TotalHunksInFile)
143	sb.WriteString(styleHeader.Render("arche interactive"))
144	sb.WriteString("  ")
145	sb.WriteString(styleBar.Render(progress))
146	sb.WriteString("  ")
147	sb.WriteString(fileInfo)
148	sb.WriteString("\n")
149	sb.WriteString(styleBar.Render(strings.Repeat("─", 70)))
150	sb.WriteString("\n")
151
152	sb.WriteString(styleBar.Render(item.Hunk.Header()))
153	sb.WriteString("\n")
154
155	for _, l := range item.Hunk.Lines {
156		line := string(l.Kind) + l.Content
157		if !strings.HasSuffix(line, "\n") {
158			line += "\n"
159		}
160		switch l.Kind {
161		case diff.LineAdd:
162			sb.WriteString(styleAdd.Render(line))
163		case diff.LineRemove:
164			sb.WriteString(styleRemove.Render(line))
165		default:
166			sb.WriteString(styleEqual.Render(line))
167		}
168	}
169
170	sb.WriteString(styleBar.Render(strings.Repeat("─", 70)))
171	sb.WriteString("\n")
172
173	switch {
174	case m.selected[m.cursor]:
175		sb.WriteString(styleInclude.Render("✓ included"))
176	default:
177		sb.WriteString(styleSkip.Render("✗ skipped"))
178	}
179	sb.WriteString("\n\n")
180
181	keys := []string{
182		styleKey.Render("y") + " " + m.verb,
183		styleKey.Render("n") + " skip",
184		styleKey.Render("a") + " all",
185		styleKey.Render("d") + " none",
186		styleKey.Render("←/→") + " navigate",
187		styleKey.Render("enter") + " confirm",
188		styleKey.Render("Q") + " abort",
189	}
190	sb.WriteString(strings.Join(keys, "  "))
191	sb.WriteString("\n")
192
193	return sb.String()
194}