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}