Skip to content

Commit cdd96ee

Browse files
committed
ui: Add commit selection widget
This widget presents the branches and commits in a tree-like structure similar to `gs ll`, allowing users to select a particular commit.
1 parent b1b70b2 commit cdd96ee

File tree

6 files changed

+616
-0
lines changed

6 files changed

+616
-0
lines changed

internal/ui/widget/commit.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ func (s CommitSummaryStyle) Faint(f bool) CommitSummaryStyle {
3232
return s
3333
}
3434

35+
// Bold returns a copy of the style with bold set to true on all fields.
36+
func (s CommitSummaryStyle) Bold(b bool) CommitSummaryStyle {
37+
s.Hash = s.Hash.Bold(b)
38+
s.Subject = s.Subject.Bold(b)
39+
s.Time = s.Time.Bold(b)
40+
return s
41+
}
42+
3543
// DefaultCommitSummaryStyle is the default style
3644
// for rendering a CommitSummary.
3745
var DefaultCommitSummaryStyle = CommitSummaryStyle{

internal/ui/widget/commit_pick.go

Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
package widget
2+
3+
import (
4+
"errors"
5+
"maps"
6+
"slices"
7+
"strings"
8+
9+
"github.com/charmbracelet/bubbles/key"
10+
tea "github.com/charmbracelet/bubbletea"
11+
"github.com/charmbracelet/lipgloss"
12+
"go.abhg.dev/gs/internal/git"
13+
"go.abhg.dev/gs/internal/ui"
14+
"go.abhg.dev/gs/internal/ui/fliptree"
15+
)
16+
17+
// TODO: support multi-select
18+
19+
// CommitPickKeyMap defines the key mappings for the commit pick widget.
20+
type CommitPickKeyMap struct {
21+
Up key.Binding
22+
Down key.Binding
23+
Accept key.Binding
24+
}
25+
26+
// DefaultCommitPickKeyMap is the default key map for the commit pick widget.
27+
var DefaultCommitPickKeyMap = CommitPickKeyMap{
28+
Up: key.NewBinding(
29+
key.WithKeys("up"),
30+
key.WithHelp("up", "go up"),
31+
),
32+
Down: key.NewBinding(
33+
key.WithKeys("down"),
34+
key.WithHelp("down", "go down"),
35+
),
36+
Accept: key.NewBinding(
37+
key.WithKeys("enter", "tab"),
38+
key.WithHelp("enter/tab", "accept"),
39+
),
40+
}
41+
42+
// CommitPickStyle defines the visual style of the commit pick widget.
43+
type CommitPickStyle struct {
44+
Branch lipgloss.Style
45+
CursorStyle lipgloss.Style
46+
47+
LogCommitStyle CommitSummaryStyle
48+
}
49+
50+
// DefaultCommitPickStyle is the default style for the commit pick widget.
51+
var DefaultCommitPickStyle = CommitPickStyle{
52+
Branch: ui.NewStyle().Bold(true),
53+
CursorStyle: ui.NewStyle().
54+
Foreground(ui.Yellow).
55+
Bold(true).
56+
SetString("▶"),
57+
LogCommitStyle: DefaultCommitSummaryStyle,
58+
}
59+
60+
// CommitPickBranch is a single branch shown in the commit pick widget.
61+
type CommitPickBranch struct {
62+
// Branch is the name of the branch.
63+
Branch string
64+
65+
// Base is the base branch that this branch is based on.
66+
// This will be used to create a tree view.
67+
// If no base is specified, the branch is shown as a root.
68+
Base string
69+
70+
// Commits in the branch that we can select from.
71+
Commits []CommitSummary
72+
}
73+
74+
type commitPickBranch struct {
75+
Name string
76+
Base int // index in CommitPick.branches or -1
77+
Aboves []int // index in CommitPick.branches
78+
Commits []int // index in CommitPick.commits
79+
}
80+
81+
type commitPickCommit struct {
82+
Summary CommitSummary
83+
Branch int // index in CommitPick.branches
84+
}
85+
86+
// CommitPick is a widget that allows users to pick out a commit
87+
// from a list of branches and commits.
88+
type CommitPick struct {
89+
KeyMap CommitPickKeyMap
90+
Style CommitPickStyle
91+
92+
title string
93+
desc string
94+
95+
// Original list of branches provided to WithBranches.
96+
// Is turned into branches, commits, and commitOrder at Init() time.
97+
input []CommitPickBranch
98+
99+
branches []commitPickBranch
100+
commits []commitPickCommit
101+
roots []int // indexes in branches of root branches (no base)
102+
103+
// Indexes in commits, ordered by how they're presented.
104+
// This is depth-first by branch, and then in-order per-branch.
105+
order []int
106+
cursor int // index of cursor in order
107+
108+
accepted bool
109+
value *git.Hash
110+
err error
111+
}
112+
113+
var _ ui.Field = (*CommitPick)(nil)
114+
115+
// NewCommitPick initializes a new CommitPick widget.
116+
// Use WithBranches to add branch information.
117+
func NewCommitPick() *CommitPick {
118+
return &CommitPick{
119+
KeyMap: DefaultCommitPickKeyMap,
120+
Style: DefaultCommitPickStyle,
121+
value: new(git.Hash),
122+
}
123+
}
124+
125+
// Title returns the title of the field.
126+
func (c *CommitPick) Title() string { return c.title }
127+
128+
// Description provides an optional description for the field.
129+
func (c *CommitPick) Description() string { return c.desc }
130+
131+
// Err returns an error if the widget has already failed.
132+
func (c *CommitPick) Err() error { return c.err }
133+
134+
// WithBranches adds branches with commits for a user to select from.
135+
func (c *CommitPick) WithBranches(branches ...CommitPickBranch) *CommitPick {
136+
c.input = branches
137+
return c
138+
}
139+
140+
// WithTitle changes the title of the widget.
141+
func (c *CommitPick) WithTitle(title string) *CommitPick {
142+
c.title = title
143+
return c
144+
}
145+
146+
// WithDescription changes the description of the widget.
147+
func (c *CommitPick) WithDescription(desc string) *CommitPick {
148+
c.desc = desc
149+
return c
150+
}
151+
152+
// WithValue specifies the variable to which the selected commit hash
153+
// will be written.
154+
func (c *CommitPick) WithValue(value *git.Hash) *CommitPick {
155+
c.value = value
156+
return c
157+
}
158+
159+
// UnmarshalValue unmarshals a commit hash from an external source.
160+
// This is used by [ui.RobotView] to supply the value in tests.
161+
func (c *CommitPick) UnmarshalValue(unmarshal func(any) error) error {
162+
var hash git.Hash
163+
if err := unmarshal(&hash); err != nil {
164+
return err
165+
}
166+
*c.value = hash
167+
return nil
168+
}
169+
170+
// Init initializes the widget. This is called by Bubble Tea.
171+
// With* functions may not be used once this is called.
172+
func (c *CommitPick) Init() tea.Cmd {
173+
if len(c.input) == 0 {
174+
c.err = errors.New("no branches provided")
175+
return tea.Quit
176+
}
177+
178+
// First pass: initialize objects.
179+
branches := make([]commitPickBranch, 0, len(c.input))
180+
branchIdxByName := make(map[string]int, len(c.input))
181+
var commits []commitPickCommit
182+
for _, b := range c.input {
183+
idx := len(branches)
184+
branch := commitPickBranch{
185+
Name: b.Branch,
186+
Base: -1,
187+
}
188+
branchIdxByName[b.Branch] = idx
189+
for _, commit := range b.Commits {
190+
branch.Commits = append(
191+
branch.Commits, len(commits),
192+
)
193+
commits = append(commits, commitPickCommit{
194+
Summary: commit,
195+
Branch: idx,
196+
})
197+
}
198+
branches = append(branches, branch)
199+
}
200+
201+
if len(commits) == 0 {
202+
c.err = errors.New("no commits found")
203+
return tea.Quit
204+
}
205+
206+
// Second pass: connect Bases and Aboves.
207+
rootSet := make(map[int]struct{})
208+
for idx, b := range c.input {
209+
if b.Base == "" {
210+
rootSet[idx] = struct{}{}
211+
continue
212+
}
213+
214+
baseIdx, ok := branchIdxByName[b.Base]
215+
if !ok {
216+
// Base is not a known branch.
217+
// That's fine, add an empty entry for it.
218+
baseIdx = len(branches)
219+
branches = append(branches, commitPickBranch{
220+
Name: b.Base,
221+
Base: -1,
222+
})
223+
branchIdxByName[b.Base] = baseIdx
224+
rootSet[baseIdx] = struct{}{}
225+
}
226+
227+
branches[idx].Base = baseIdx
228+
branches[baseIdx].Aboves = append(branches[baseIdx].Aboves, idx)
229+
}
230+
231+
// Finally, using this information,
232+
// traverse the branches in depth-first order
233+
// to match the order in which the tree will render them.
234+
// This will be used for the commit ordering.
235+
roots := slices.Sorted(maps.Keys(rootSet))
236+
237+
commitOrder := make([]int, 0, len(commits))
238+
var visitBranch func(int)
239+
visitBranch = func(idx int) {
240+
for _, aboveIdx := range branches[idx].Aboves {
241+
visitBranch(aboveIdx)
242+
}
243+
244+
for _, commitIdx := range branches[idx].Commits {
245+
// If the current (default) value matches the hash,
246+
// move the cursor to it.
247+
if commits[commitIdx].Summary.ShortHash == *c.value {
248+
c.cursor = len(commitOrder)
249+
}
250+
251+
commitOrder = append(commitOrder, commitIdx)
252+
}
253+
}
254+
for _, root := range roots {
255+
visitBranch(root)
256+
}
257+
258+
c.branches = branches
259+
c.commits = commits
260+
c.order = commitOrder
261+
c.roots = roots
262+
return nil
263+
}
264+
265+
// Update receives a UI message and updates the widget's internal state.
266+
func (c *CommitPick) Update(msg tea.Msg) tea.Cmd {
267+
keyMsg, ok := msg.(tea.KeyMsg)
268+
if !ok {
269+
return nil
270+
}
271+
272+
// TODO: do we want to support filtering?
273+
274+
switch {
275+
case key.Matches(keyMsg, c.KeyMap.Up):
276+
c.moveCursor(false /* backwards */)
277+
case key.Matches(keyMsg, c.KeyMap.Down):
278+
c.moveCursor(true /* forwards */)
279+
case key.Matches(keyMsg, c.KeyMap.Accept):
280+
c.accepted = true
281+
commitIdx := c.order[c.cursor]
282+
*c.value = c.commits[commitIdx].Summary.ShortHash
283+
return ui.AcceptField
284+
}
285+
286+
return nil
287+
}
288+
289+
func (c *CommitPick) moveCursor(forwards bool) {
290+
delta := 1
291+
if !forwards {
292+
delta = -1
293+
}
294+
295+
c.cursor += delta
296+
if c.cursor < 0 {
297+
c.cursor = len(c.order) - 1
298+
} else if c.cursor >= len(c.order) {
299+
c.cursor = 0
300+
}
301+
}
302+
303+
// Render renders the widget to a writer.
304+
func (c *CommitPick) Render(w ui.Writer) {
305+
if c.accepted {
306+
w.WriteString(c.value.String())
307+
return
308+
}
309+
310+
if c.title != "" {
311+
w.WriteString("\n")
312+
}
313+
314+
_ = fliptree.Write(w, fliptree.Graph[commitPickBranch]{
315+
Values: c.branches,
316+
Roots: c.roots,
317+
Edges: func(b commitPickBranch) []int { return b.Aboves },
318+
View: func(b commitPickBranch) string {
319+
var o strings.Builder
320+
o.WriteString(c.Style.Branch.Render(b.Name))
321+
322+
focusedCommitIdx := c.order[c.cursor]
323+
focusedBranchIdx := c.commits[focusedCommitIdx].Branch
324+
325+
for _, commitIdx := range b.Commits {
326+
commit := c.commits[commitIdx]
327+
328+
o.WriteString(" ")
329+
o.WriteString("\n")
330+
331+
cursor := " "
332+
summaryStyle := c.Style.LogCommitStyle
333+
// Three levels of visibility for commits:
334+
// 1. focused on commit
335+
// 2. not focused on commit,
336+
// but focused on commit in same branch
337+
// 3. focused on a different branch
338+
switch {
339+
case focusedCommitIdx == commitIdx:
340+
summaryStyle = summaryStyle.Bold(true)
341+
cursor = c.Style.CursorStyle.String()
342+
case focusedBranchIdx == commit.Branch:
343+
// default style is good enough
344+
default:
345+
summaryStyle = summaryStyle.Faint(true)
346+
}
347+
348+
o.WriteString(cursor)
349+
o.WriteString(" ")
350+
commit.Summary.Render(&o, summaryStyle)
351+
}
352+
353+
return o.String()
354+
},
355+
}, fliptree.Options[commitPickBranch]{})
356+
}

0 commit comments

Comments
 (0)