Skip to content

Commit 3677bbb

Browse files
committed
fix: resolve 5 TUI issues and add sorting
Fixes: 1. Editor now opens correctly using proper repo selection 2. Added column sorting (s to cycle, 1-4 for specific sort) 3. Added legend showing dirty/clean indicators + current editor 4. Strong row highlighting with contrasting colors 5. Sort mode badge in stats bar 6. Re-scan after editor closes to update status New keyboard shortcuts: - s: cycle sort modes - 1: sort by dirty first - 2: sort by name - 3: sort by branch - 4: sort by recent commits - e: show current editor
1 parent e47fc7b commit 3677bbb

File tree

3 files changed

+230
-81
lines changed

3 files changed

+230
-81
lines changed

internal/tui/model.go

Lines changed: 112 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -20,29 +20,40 @@ const (
2020
StateError
2121
)
2222

23+
// SortMode represents different sorting options
24+
type SortMode int
25+
26+
const (
27+
SortByDirty SortMode = iota
28+
SortByName
29+
SortByBranch
30+
SortByLastCommit
31+
)
32+
2333
// Model is the Bubbletea model for the TUI
2434
type Model struct {
25-
cfg *config.Config
26-
table table.Model
27-
repos []model.Repo
28-
state State
29-
err error
30-
statusMsg string
31-
width int
32-
height int
35+
cfg *config.Config
36+
table table.Model
37+
repos []model.Repo
38+
sortedRepos []model.Repo // Sorted copy for display
39+
state State
40+
err error
41+
statusMsg string
42+
width int
43+
height int
44+
sortMode SortMode
3345
}
3446

3547
// NewModel creates a new TUI model
3648
func NewModel(cfg *config.Config) Model {
3749
columns := []table.Column{
38-
{Title: "⬤", Width: 2}, // Status indicator
39-
{Title: "Repository", Width: 20},
40-
{Title: "Path", Width: 30},
41-
{Title: "Branch", Width: 15},
50+
{Title: "Status", Width: 6},
51+
{Title: "Repository", Width: 18},
52+
{Title: "Branch", Width: 14},
4253
{Title: "Staged", Width: 6},
4354
{Title: "Modified", Width: 8},
4455
{Title: "Untracked", Width: 9},
45-
{Title: "Last Commit", Width: 16},
56+
{Title: "Last Commit", Width: 14},
4657
}
4758

4859
t := table.New(
@@ -52,30 +63,33 @@ func NewModel(cfg *config.Config) Model {
5263
table.WithHeight(12),
5364
)
5465

55-
// Apply modern table styles
66+
// Apply modern table styles with strong highlighting
5667
s := table.DefaultStyles()
5768
s.Header = s.Header.
58-
BorderStyle(lipgloss.ThickBorder()).
69+
BorderStyle(lipgloss.NormalBorder()).
5970
BorderForeground(lipgloss.Color("#7C3AED")).
6071
BorderBottom(true).
6172
Bold(true).
62-
Foreground(lipgloss.Color("#F9FAFB")).
63-
Background(lipgloss.Color("#374151"))
64-
65-
s.Selected = s.Selected.
6673
Foreground(lipgloss.Color("#FFFFFF")).
6774
Background(lipgloss.Color("#7C3AED")).
75+
Padding(0, 1)
76+
77+
// Strong row highlighting
78+
s.Selected = s.Selected.
79+
Foreground(lipgloss.Color("#000000")).
80+
Background(lipgloss.Color("#A78BFA")).
6881
Bold(true)
6982

7083
s.Cell = s.Cell.
71-
Foreground(lipgloss.Color("#F9FAFB"))
84+
Padding(0, 1)
7285

7386
t.SetStyles(s)
7487

7588
return Model{
76-
cfg: cfg,
77-
table: t,
78-
state: StateLoading,
89+
cfg: cfg,
90+
table: t,
91+
state: StateLoading,
92+
sortMode: SortByDirty,
7993
}
8094
}
8195

@@ -84,55 +98,96 @@ func (m Model) Init() tea.Cmd {
8498
return scanReposCmd(m.cfg)
8599
}
86100

101+
// GetSelectedRepo returns the currently selected repo
102+
func (m Model) GetSelectedRepo() *model.Repo {
103+
if m.state != StateReady || len(m.sortedRepos) == 0 {
104+
return nil
105+
}
106+
107+
cursor := m.table.Cursor()
108+
if cursor >= 0 && cursor < len(m.sortedRepos) {
109+
return &m.sortedRepos[cursor]
110+
}
111+
return nil
112+
}
113+
114+
// sortRepos sorts repos based on current sort mode
115+
func (m *Model) sortRepos() {
116+
m.sortedRepos = make([]model.Repo, len(m.repos))
117+
copy(m.sortedRepos, m.repos)
118+
119+
switch m.sortMode {
120+
case SortByDirty:
121+
sort.Slice(m.sortedRepos, func(i, j int) bool {
122+
if m.sortedRepos[i].Status.IsDirty != m.sortedRepos[j].Status.IsDirty {
123+
return m.sortedRepos[i].Status.IsDirty
124+
}
125+
return m.sortedRepos[i].Name < m.sortedRepos[j].Name
126+
})
127+
case SortByName:
128+
sort.Slice(m.sortedRepos, func(i, j int) bool {
129+
return m.sortedRepos[i].Name < m.sortedRepos[j].Name
130+
})
131+
case SortByBranch:
132+
sort.Slice(m.sortedRepos, func(i, j int) bool {
133+
return m.sortedRepos[i].Status.Branch < m.sortedRepos[j].Status.Branch
134+
})
135+
case SortByLastCommit:
136+
sort.Slice(m.sortedRepos, func(i, j int) bool {
137+
return m.sortedRepos[i].Status.LastCommit.After(m.sortedRepos[j].Status.LastCommit)
138+
})
139+
}
140+
}
141+
142+
// updateTable refreshes the table with current sorted repos
143+
func (m *Model) updateTable() {
144+
m.sortRepos()
145+
m.table.SetRows(reposToRows(m.sortedRepos))
146+
}
147+
148+
// GetSortModeName returns the display name of current sort mode
149+
func (m Model) GetSortModeName() string {
150+
switch m.sortMode {
151+
case SortByDirty:
152+
return "Dirty First"
153+
case SortByName:
154+
return "Name"
155+
case SortByBranch:
156+
return "Branch"
157+
case SortByLastCommit:
158+
return "Recent"
159+
}
160+
return "Unknown"
161+
}
162+
87163
// reposToRows converts repos to table rows with status indicators
88164
func reposToRows(repos []model.Repo) []table.Row {
89-
// Sort by dirty first, then by name
90-
sorted := make([]model.Repo, len(repos))
91-
copy(sorted, repos)
92-
sort.Slice(sorted, func(i, j int) bool {
93-
// Dirty repos first
94-
if sorted[i].Status.IsDirty != sorted[j].Status.IsDirty {
95-
return sorted[i].Status.IsDirty
96-
}
97-
// Then by name
98-
return sorted[i].Name < sorted[j].Name
99-
})
100-
101-
rows := make([]table.Row, 0, len(sorted))
102-
for _, r := range sorted {
165+
rows := make([]table.Row, 0, len(repos))
166+
for _, r := range repos {
103167
lastCommit := "N/A"
104168
if !r.Status.LastCommit.IsZero() {
105169
lastCommit = r.Status.LastCommit.Format("Jan 02 15:04")
106170
}
107171

108-
// Status indicator
109-
indicator := "○" // Clean
172+
// Status indicator with text
173+
status := "Clean"
110174
if r.Status.IsDirty {
111-
indicator = "●" // Dirty
175+
status = "● Dirty"
112176
}
113177

114178
rows = append(rows, table.Row{
115-
indicator,
116-
truncateString(r.Name, 20),
117-
truncatePath(r.Path, 30),
118-
truncateString(r.Status.Branch, 15),
119-
colorNumber(r.Status.Staged, "#10B981"), // Green
120-
colorNumber(r.Status.Unstaged, "#F59E0B"), // Amber
121-
colorNumber(r.Status.Untracked, "#9CA3AF"), // Gray
179+
status,
180+
truncateString(r.Name, 18),
181+
truncateString(r.Status.Branch, 14),
182+
formatNumber(r.Status.Staged),
183+
formatNumber(r.Status.Unstaged),
184+
formatNumber(r.Status.Untracked),
122185
lastCommit,
123186
})
124187
}
125188
return rows
126189
}
127190

128-
// truncatePath shortens a path to fit in the given width
129-
func truncatePath(path string, maxLen int) string {
130-
if len(path) <= maxLen {
131-
return path
132-
}
133-
return "…" + path[len(path)-maxLen+1:]
134-
}
135-
136191
// truncateString shortens a string with ellipsis
137192
func truncateString(s string, maxLen int) string {
138193
if len(s) <= maxLen {
@@ -141,8 +196,8 @@ func truncateString(s string, maxLen int) string {
141196
return s[:maxLen-1] + "…"
142197
}
143198

144-
// colorNumber returns a string representation of a number
145-
func colorNumber(n int, _ string) string {
199+
// formatNumber formats a number for display
200+
func formatNumber(n int) string {
146201
if n == 0 {
147202
return "—"
148203
}

internal/tui/update.go

Lines changed: 77 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1414
case tea.WindowSizeMsg:
1515
m.width = msg.Width
1616
m.height = msg.Height
17-
// Adjust table height based on window size
18-
m.table.SetHeight(m.height - 6) // Leave room for header and footer
17+
// Adjust table height based on window size (leave room for header, stats, help, legend)
18+
tableHeight := m.height - 10
19+
if tableHeight < 5 {
20+
tableHeight = 5
21+
}
22+
m.table.SetHeight(tableHeight)
1923

2024
case scanCompleteMsg:
2125
m.repos = msg.repos
2226
m.state = StateReady
23-
m.table.SetRows(reposToRows(m.repos))
27+
m.updateTable()
2428
m.statusMsg = ""
2529
return m, nil
2630

@@ -30,31 +34,36 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3034
return m, nil
3135

3236
case openEditorMsg:
33-
// Open the editor asynchronously
34-
return m, tea.ExecProcess(exec.Command(m.cfg.Editor, msg.path), nil)
37+
// Open the editor and wait for it to close
38+
c := exec.Command(m.cfg.Editor, msg.path)
39+
return m, tea.ExecProcess(c, func(err error) tea.Msg {
40+
if err != nil {
41+
return editorClosedMsg{err: err}
42+
}
43+
return editorClosedMsg{}
44+
})
45+
46+
case editorClosedMsg:
47+
if msg.err != nil {
48+
m.statusMsg = "Error: " + msg.err.Error()
49+
} else {
50+
m.statusMsg = ""
51+
}
52+
// Rescan after editor closes to update status
53+
return m, scanReposCmd(m.cfg)
3554

3655
case tea.KeyMsg:
3756
switch msg.String() {
3857
case "ctrl+c", "q":
3958
return m, tea.Quit
4059

4160
case "enter":
42-
if m.state == StateReady && len(m.repos) > 0 {
43-
// Get the selected repo
44-
selectedRow := m.table.Cursor()
45-
if selectedRow >= 0 && selectedRow < len(m.repos) {
46-
// Find the actual repo (repos are sorted in reposToRows)
47-
row := m.table.SelectedRow()
48-
if len(row) > 0 {
49-
// Find repo by name (first column)
50-
for _, repo := range m.repos {
51-
if repo.Name == row[0] {
52-
m.statusMsg = "Opening in " + m.cfg.Editor + "..."
53-
return m, func() tea.Msg {
54-
return openEditorMsg{path: repo.Path}
55-
}
56-
}
57-
}
61+
if m.state == StateReady {
62+
repo := m.GetSelectedRepo()
63+
if repo != nil {
64+
m.statusMsg = "Opening " + repo.Name + " in " + m.cfg.Editor + "..."
65+
return m, func() tea.Msg {
66+
return openEditorMsg{path: repo.Path}
5867
}
5968
}
6069
}
@@ -64,10 +73,57 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
6473
m.state = StateLoading
6574
m.statusMsg = "Rescanning..."
6675
return m, scanReposCmd(m.cfg)
76+
77+
case "s":
78+
// Cycle through sort modes
79+
if m.state == StateReady {
80+
m.sortMode = (m.sortMode + 1) % 4
81+
m.updateTable()
82+
m.statusMsg = "Sorted by: " + m.GetSortModeName()
83+
}
84+
85+
case "1":
86+
if m.state == StateReady {
87+
m.sortMode = SortByDirty
88+
m.updateTable()
89+
m.statusMsg = "Sorted by: Dirty First"
90+
}
91+
92+
case "2":
93+
if m.state == StateReady {
94+
m.sortMode = SortByName
95+
m.updateTable()
96+
m.statusMsg = "Sorted by: Name"
97+
}
98+
99+
case "3":
100+
if m.state == StateReady {
101+
m.sortMode = SortByBranch
102+
m.updateTable()
103+
m.statusMsg = "Sorted by: Branch"
104+
}
105+
106+
case "4":
107+
if m.state == StateReady {
108+
m.sortMode = SortByLastCommit
109+
m.updateTable()
110+
m.statusMsg = "Sorted by: Recent"
111+
}
112+
113+
case "e":
114+
// Show editor config hint
115+
if m.state == StateReady {
116+
m.statusMsg = "Editor: " + m.cfg.Editor + " (change in ~/.config/git-scope/config.yml)"
117+
}
67118
}
68119
}
69120

70121
// Update the table
71122
m.table, cmd = m.table.Update(msg)
72123
return m, cmd
73124
}
125+
126+
// editorClosedMsg is sent when the editor process closes
127+
type editorClosedMsg struct {
128+
err error
129+
}

0 commit comments

Comments
 (0)