tangled
alpha
login
or
join now
desertthunder.dev
/
noteleaf
cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐
charm
leaflet
readability
golang
29
fork
atom
overview
issues
2
pulls
pipelines
refactor: project & tag adapters
desertthunder.dev
5 months ago
e00fe004
3fed73b3
+658
-1343
13 changed files
expand all
collapse all
unified
split
internal
handlers
tasks.go
ui
data_list.go
data_list_test.go
data_table.go
data_table_test.go
project_list.go
project_list_adapter.go
project_list_adapter_test.go
project_list_test.go
tag_list.go
tag_list_adapter.go
tag_list_adapter_test.go
tag_list_test.go
+4
-5
internal/handlers/tasks.go
···
552
552
}
553
553
554
554
func (h *TaskHandler) listProjectsInteractive(ctx context.Context) error {
555
555
-
projectList := ui.NewProjectList(h.repos.Tasks, ui.ProjectListOptions{})
556
556
-
return projectList.Browse(ctx)
555
555
+
projectTable := ui.NewProjectListFromTable(h.repos.Tasks, nil, nil, false)
556
556
+
return projectTable.Browse(ctx)
557
557
}
558
558
559
559
// ListTags lists all tags with their task counts
···
645
645
}
646
646
647
647
func (h *TaskHandler) listTagsInteractive(ctx context.Context) error {
648
648
-
tagList := ui.NewTagList(h.repos.Tasks, ui.TagListOptions{})
649
649
-
return tagList.Browse(ctx)
648
648
+
tagTable := ui.NewTagListFromTable(h.repos.Tasks, nil, nil, false)
649
649
+
return tagTable.Browse(ctx)
650
650
}
651
651
652
652
func (h *TaskHandler) printTask(task *models.Task) {
···
755
755
}
756
756
}
757
757
758
758
-
// formatDuration formats a duration in a human-readable format
759
758
func formatDuration(d time.Duration) string {
760
759
if d < time.Minute {
761
760
return fmt.Sprintf("%.0fs", d.Seconds())
-2
internal/ui/data_list.go
···
154
154
listViewMsg string
155
155
listErrorMsg error
156
156
listCountMsg int
157
157
-
searchModeMsg bool
158
157
)
159
158
160
159
type dataListModel struct {
···
172
171
help help.Model
173
172
showingHelp bool
174
173
totalCount int
175
175
-
currentPage int
176
174
listOpts ListOptions
177
175
}
178
176
+6
-13
internal/ui/data_list_test.go
···
15
15
)
16
16
17
17
type MockListItem struct {
18
18
-
id int64
19
18
title string
20
19
description string
21
20
filterValue string
22
22
-
created time.Time
23
23
-
modified time.Time
24
21
}
25
22
26
23
func (m MockListItem) GetID() int64 {
27
27
-
return m.id
24
24
+
return 1 // Mock ID
28
25
}
29
26
30
27
func (m MockListItem) SetID(id int64) {
31
31
-
m.id = id
28
28
+
// Mock - no-op
32
29
}
33
30
34
31
func (m MockListItem) GetTableName() string {
···
36
33
}
37
34
38
35
func (m MockListItem) GetCreatedAt() time.Time {
39
39
-
return m.created
36
36
+
return time.Time{} // Mock - zero time
40
37
}
41
38
42
39
func (m MockListItem) SetCreatedAt(t time.Time) {
43
43
-
m.created = t
40
40
+
// Mock - no-op
44
41
}
45
42
46
43
func (m MockListItem) GetUpdatedAt() time.Time {
47
47
-
return m.modified
44
44
+
return time.Time{} // Mock - zero time
48
45
}
49
46
50
47
func (m MockListItem) SetUpdatedAt(t time.Time) {
51
51
-
m.modified = t
48
48
+
// Mock - no-op
52
49
}
53
50
54
51
func (m MockListItem) GetTitle() string {
···
64
61
}
65
62
66
63
func NewMockItem(id int64, title, description, filterValue string) MockListItem {
67
67
-
now := time.Now()
68
64
return MockListItem{
69
69
-
id: id,
70
65
title: title,
71
66
description: description,
72
67
filterValue: filterValue,
73
73
-
created: now,
74
74
-
modified: now,
75
68
}
76
69
}
77
70
-1
internal/ui/data_table.go
···
166
166
help help.Model
167
167
showingHelp bool
168
168
totalCount int
169
169
-
currentPage int
170
169
dataOpts DataOptions
171
170
}
172
171
+8
-16
internal/ui/data_table_test.go
···
15
15
)
16
16
17
17
type MockDataRecord struct {
18
18
-
id int64
19
19
-
fields map[string]any
20
20
-
created time.Time
21
21
-
modified time.Time
18
18
+
fields map[string]any
22
19
}
23
20
24
21
func (m MockDataRecord) GetID() int64 {
25
25
-
return m.id
22
22
+
return 1 // Mock ID
26
23
}
27
24
28
25
func (m MockDataRecord) SetID(id int64) {
29
29
-
m.id = id
26
26
+
// Mock - no-op
30
27
}
31
28
32
29
func (m MockDataRecord) GetTableName() string {
···
34
31
}
35
32
36
33
func (m MockDataRecord) GetCreatedAt() time.Time {
37
37
-
return m.created
34
34
+
return time.Time{} // Mock - zero time
38
35
}
39
36
40
37
func (m MockDataRecord) SetCreatedAt(t time.Time) {
41
41
-
m.created = t
38
38
+
// Mock - no-op
42
39
}
43
40
44
41
func (m MockDataRecord) GetUpdatedAt() time.Time {
45
45
-
return m.modified
42
42
+
return time.Time{} // Mock - zero time
46
43
}
47
44
48
45
func (m MockDataRecord) SetUpdatedAt(t time.Time) {
49
49
-
m.modified = t
46
46
+
// Mock - no-op
50
47
}
51
48
52
49
func (m MockDataRecord) GetField(name string) any {
···
54
51
}
55
52
56
53
func NewMockRecord(id int64, fields map[string]any) MockDataRecord {
57
57
-
now := time.Now()
58
54
return MockDataRecord{
59
59
-
id: id,
60
60
-
fields: fields,
61
61
-
created: now,
62
62
-
modified: now,
55
55
+
fields: fields,
63
56
}
64
57
}
65
58
···
67
60
records []DataRecord
68
61
loadError error
69
62
countError error
70
70
-
loadDelay bool
71
63
}
72
64
73
65
func (m *MockDataSource) Load(ctx context.Context, opts DataOptions) ([]DataRecord, error) {
-290
internal/ui/project_list.go
···
1
1
-
package ui
2
2
-
3
3
-
import (
4
4
-
"context"
5
5
-
"fmt"
6
6
-
"io"
7
7
-
"os"
8
8
-
"strings"
9
9
-
10
10
-
"github.com/charmbracelet/bubbles/help"
11
11
-
"github.com/charmbracelet/bubbles/key"
12
12
-
tea "github.com/charmbracelet/bubbletea"
13
13
-
"github.com/charmbracelet/lipgloss"
14
14
-
"github.com/stormlightlabs/noteleaf/internal/repo"
15
15
-
)
16
16
-
17
17
-
// Project list key bindings
18
18
-
type projectKeyMap struct {
19
19
-
Up key.Binding
20
20
-
Down key.Binding
21
21
-
Enter key.Binding
22
22
-
Refresh key.Binding
23
23
-
Quit key.Binding
24
24
-
Back key.Binding
25
25
-
Help key.Binding
26
26
-
Numbers []key.Binding
27
27
-
}
28
28
-
29
29
-
func (k projectKeyMap) ShortHelp() []key.Binding {
30
30
-
return []key.Binding{k.Up, k.Down, k.Enter, k.Help, k.Quit}
31
31
-
}
32
32
-
33
33
-
func (k projectKeyMap) FullHelp() [][]key.Binding {
34
34
-
return [][]key.Binding{
35
35
-
{k.Up, k.Down, k.Enter, k.Refresh},
36
36
-
{k.Help, k.Quit, k.Back},
37
37
-
}
38
38
-
}
39
39
-
40
40
-
var projectKeys = projectKeyMap{
41
41
-
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("โ/k", "move up")),
42
42
-
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("โ/j", "move down")),
43
43
-
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select project")),
44
44
-
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
45
45
-
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
46
46
-
Back: key.NewBinding(key.WithKeys("esc", "backspace"), key.WithHelp("esc", "back")),
47
47
-
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
48
48
-
Numbers: []key.Binding{
49
49
-
key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "jump to project 1")),
50
50
-
key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "jump to project 2")),
51
51
-
key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "jump to project 3")),
52
52
-
key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "jump to project 4")),
53
53
-
key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "jump to project 5")),
54
54
-
key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "jump to project 6")),
55
55
-
key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "jump to project 7")),
56
56
-
key.NewBinding(key.WithKeys("8"), key.WithHelp("8", "jump to project 8")),
57
57
-
key.NewBinding(key.WithKeys("9"), key.WithHelp("9", "jump to project 9")),
58
58
-
},
59
59
-
}
60
60
-
61
61
-
// ProjectRepository interface for dependency injection in tests
62
62
-
type ProjectRepository interface {
63
63
-
GetProjects(ctx context.Context) ([]repo.ProjectSummary, error)
64
64
-
}
65
65
-
66
66
-
// ProjectListOptions configures the project list UI behavior
67
67
-
type ProjectListOptions struct {
68
68
-
// Output destination (stdout for interactive, buffer for testing)
69
69
-
Output io.Writer
70
70
-
// Input source (stdin for interactive, strings reader for testing)
71
71
-
Input io.Reader
72
72
-
// Enable static mode (no interactive components)
73
73
-
Static bool
74
74
-
}
75
75
-
76
76
-
// ProjectList handles project browsing UI
77
77
-
type ProjectList struct {
78
78
-
repo ProjectRepository
79
79
-
opts ProjectListOptions
80
80
-
}
81
81
-
82
82
-
// NewProjectList creates a new project list UI component
83
83
-
func NewProjectList(repo ProjectRepository, opts ProjectListOptions) *ProjectList {
84
84
-
if opts.Output == nil {
85
85
-
opts.Output = os.Stdout
86
86
-
}
87
87
-
if opts.Input == nil {
88
88
-
opts.Input = os.Stdin
89
89
-
}
90
90
-
return &ProjectList{repo: repo, opts: opts}
91
91
-
}
92
92
-
93
93
-
type (
94
94
-
projectsLoadedMsg []repo.ProjectSummary
95
95
-
errorProjectMsg error
96
96
-
projectListModel struct {
97
97
-
projects []repo.ProjectSummary
98
98
-
selected int
99
99
-
err error
100
100
-
repo ProjectRepository
101
101
-
opts ProjectListOptions
102
102
-
keys projectKeyMap
103
103
-
help help.Model
104
104
-
showingHelp bool
105
105
-
}
106
106
-
)
107
107
-
108
108
-
func (m projectListModel) Init() tea.Cmd {
109
109
-
return m.loadProjects()
110
110
-
}
111
111
-
112
112
-
func (m projectListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
113
113
-
switch msg := msg.(type) {
114
114
-
case tea.KeyMsg:
115
115
-
if m.showingHelp {
116
116
-
switch {
117
117
-
case key.Matches(msg, m.keys.Back) || key.Matches(msg, m.keys.Quit) || key.Matches(msg, m.keys.Help):
118
118
-
m.showingHelp = false
119
119
-
return m, nil
120
120
-
}
121
121
-
return m, nil
122
122
-
}
123
123
-
124
124
-
switch {
125
125
-
case key.Matches(msg, m.keys.Quit):
126
126
-
return m, tea.Quit
127
127
-
case key.Matches(msg, m.keys.Up):
128
128
-
if m.selected > 0 {
129
129
-
m.selected--
130
130
-
}
131
131
-
case key.Matches(msg, m.keys.Down):
132
132
-
if m.selected < len(m.projects)-1 {
133
133
-
m.selected++
134
134
-
}
135
135
-
case key.Matches(msg, m.keys.Enter):
136
136
-
if len(m.projects) > 0 && m.selected < len(m.projects) {
137
137
-
// TODO: navigate to tasks for that project
138
138
-
return m, tea.Quit
139
139
-
}
140
140
-
case key.Matches(msg, m.keys.Refresh):
141
141
-
return m, m.loadProjects()
142
142
-
case key.Matches(msg, m.keys.Help):
143
143
-
m.showingHelp = true
144
144
-
return m, nil
145
145
-
default:
146
146
-
for i, numKey := range m.keys.Numbers {
147
147
-
if key.Matches(msg, numKey) && i < len(m.projects) {
148
148
-
m.selected = i
149
149
-
break
150
150
-
}
151
151
-
}
152
152
-
}
153
153
-
case projectsLoadedMsg:
154
154
-
m.projects = []repo.ProjectSummary(msg)
155
155
-
if m.selected >= len(m.projects) && len(m.projects) > 0 {
156
156
-
m.selected = len(m.projects) - 1
157
157
-
}
158
158
-
case errorProjectMsg:
159
159
-
m.err = error(msg)
160
160
-
}
161
161
-
return m, nil
162
162
-
}
163
163
-
164
164
-
func (m projectListModel) View() string {
165
165
-
var s strings.Builder
166
166
-
167
167
-
style := lipgloss.NewStyle().Foreground(lipgloss.Color(Squid.Hex()))
168
168
-
169
169
-
if m.showingHelp {
170
170
-
return m.help.View(m.keys)
171
171
-
}
172
172
-
173
173
-
s.WriteString(TitleColorStyle.Render("Projects"))
174
174
-
s.WriteString("\n\n")
175
175
-
176
176
-
if m.err != nil {
177
177
-
s.WriteString(fmt.Sprintf("Error: %s", m.err))
178
178
-
return s.String()
179
179
-
}
180
180
-
181
181
-
if len(m.projects) == 0 {
182
182
-
s.WriteString("No projects found")
183
183
-
s.WriteString("\n\n")
184
184
-
s.WriteString(style.Render("Press r to refresh, q to quit"))
185
185
-
return s.String()
186
186
-
}
187
187
-
188
188
-
headerLine := fmt.Sprintf(" %-30s %-15s", "Project Name", "Task Count")
189
189
-
s.WriteString(HeaderColorStyle.Render(headerLine))
190
190
-
s.WriteString("\n")
191
191
-
s.WriteString(HeaderColorStyle.Render(strings.Repeat("โ", 50)))
192
192
-
s.WriteString("\n")
193
193
-
194
194
-
for i, project := range m.projects {
195
195
-
prefix := " "
196
196
-
if i == m.selected {
197
197
-
prefix = " > "
198
198
-
}
199
199
-
200
200
-
projectName := project.Name
201
201
-
if len(projectName) > 28 {
202
202
-
projectName = projectName[:25] + "..."
203
203
-
}
204
204
-
205
205
-
taskCountStr := fmt.Sprintf("%d task%s", project.TaskCount, pluralizeCount(project.TaskCount))
206
206
-
207
207
-
line := fmt.Sprintf("%s%-30s %-15s", prefix, projectName, taskCountStr)
208
208
-
209
209
-
if i == m.selected {
210
210
-
s.WriteString(SelectedColorStyle.Render(line))
211
211
-
} else {
212
212
-
s.WriteString(style.Render(line))
213
213
-
}
214
214
-
215
215
-
s.WriteString("\n")
216
216
-
}
217
217
-
218
218
-
s.WriteString("\n")
219
219
-
s.WriteString(m.help.View(m.keys))
220
220
-
221
221
-
return s.String()
222
222
-
}
223
223
-
224
224
-
func (m projectListModel) loadProjects() tea.Cmd {
225
225
-
return func() tea.Msg {
226
226
-
projects, err := m.repo.GetProjects(context.Background())
227
227
-
if err != nil {
228
228
-
return errorProjectMsg(err)
229
229
-
}
230
230
-
231
231
-
return projectsLoadedMsg(projects)
232
232
-
}
233
233
-
}
234
234
-
235
235
-
// Browse opens an interactive TUI for navigating projects
236
236
-
func (pl *ProjectList) Browse(ctx context.Context) error {
237
237
-
if pl.opts.Static {
238
238
-
return pl.staticList(ctx)
239
239
-
}
240
240
-
241
241
-
model := projectListModel{
242
242
-
repo: pl.repo,
243
243
-
opts: pl.opts,
244
244
-
keys: projectKeys,
245
245
-
help: help.New(),
246
246
-
}
247
247
-
248
248
-
program := tea.NewProgram(model, tea.WithInput(pl.opts.Input), tea.WithOutput(pl.opts.Output))
249
249
-
250
250
-
_, err := program.Run()
251
251
-
return err
252
252
-
}
253
253
-
254
254
-
func (pl *ProjectList) staticList(ctx context.Context) error {
255
255
-
projects, err := pl.repo.GetProjects(ctx)
256
256
-
if err != nil {
257
257
-
fmt.Fprintf(pl.opts.Output, "Error: %s\n", err)
258
258
-
return err
259
259
-
}
260
260
-
261
261
-
fmt.Fprintf(pl.opts.Output, "Projects\n\n")
262
262
-
263
263
-
if len(projects) == 0 {
264
264
-
fmt.Fprintf(pl.opts.Output, "No projects found\n")
265
265
-
return nil
266
266
-
}
267
267
-
268
268
-
fmt.Fprintf(pl.opts.Output, "%-30s %-15s\n", "Project Name", "Task Count")
269
269
-
fmt.Fprintf(pl.opts.Output, "%s\n", strings.Repeat("โ", 50))
270
270
-
271
271
-
for _, project := range projects {
272
272
-
projectName := project.Name
273
273
-
if len(projectName) > 28 {
274
274
-
projectName = projectName[:25] + "..."
275
275
-
}
276
276
-
277
277
-
taskCountStr := fmt.Sprintf("%d task%s", project.TaskCount, pluralizeCount(project.TaskCount))
278
278
-
279
279
-
fmt.Fprintf(pl.opts.Output, "%-30s %-15s\n", projectName, taskCountStr)
280
280
-
}
281
281
-
282
282
-
return nil
283
283
-
}
284
284
-
285
285
-
func pluralizeCount(count int) string {
286
286
-
if count == 1 {
287
287
-
return ""
288
288
-
}
289
289
-
return "s"
290
290
-
}
+120
internal/ui/project_list_adapter.go
···
1
1
+
package ui
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"fmt"
6
6
+
"io"
7
7
+
"time"
8
8
+
9
9
+
"github.com/stormlightlabs/noteleaf/internal/repo"
10
10
+
)
11
11
+
12
12
+
// ProjectRepository interface for dependency injection in tests
13
13
+
type ProjectRepository interface {
14
14
+
GetProjects(ctx context.Context) ([]repo.ProjectSummary, error)
15
15
+
}
16
16
+
17
17
+
18
18
+
func pluralizeCount(count int) string {
19
19
+
if count == 1 {
20
20
+
return ""
21
21
+
}
22
22
+
return "s"
23
23
+
}
24
24
+
25
25
+
// ProjectSummaryRecord adapts repo.ProjectSummary to work with DataTable
26
26
+
type ProjectSummaryRecord struct {
27
27
+
summary repo.ProjectSummary
28
28
+
}
29
29
+
30
30
+
func (p *ProjectSummaryRecord) GetField(name string) any {
31
31
+
switch name {
32
32
+
case "name":
33
33
+
return p.summary.Name
34
34
+
case "task_count":
35
35
+
return p.summary.TaskCount
36
36
+
default:
37
37
+
return ""
38
38
+
}
39
39
+
}
40
40
+
41
41
+
func (p *ProjectSummaryRecord) GetTableName() string {
42
42
+
return "projects"
43
43
+
}
44
44
+
45
45
+
// Use task count as pseudo-ID since projects don't have IDs
46
46
+
func (p *ProjectSummaryRecord) GetID() int64 { return int64(p.summary.TaskCount) }
47
47
+
func (p *ProjectSummaryRecord) SetID(id int64) {}
48
48
+
func (p *ProjectSummaryRecord) GetCreatedAt() time.Time { return time.Time{} }
49
49
+
func (p *ProjectSummaryRecord) SetCreatedAt(t time.Time) {}
50
50
+
func (p *ProjectSummaryRecord) GetUpdatedAt() time.Time { return time.Time{} }
51
51
+
func (p *ProjectSummaryRecord) SetUpdatedAt(t time.Time) {}
52
52
+
53
53
+
// ProjectDataSource adapts ProjectRepository to work with DataTable
54
54
+
type ProjectDataSource struct {
55
55
+
repo ProjectRepository
56
56
+
}
57
57
+
58
58
+
func (p *ProjectDataSource) Load(ctx context.Context, opts DataOptions) ([]DataRecord, error) {
59
59
+
projects, err := p.repo.GetProjects(ctx)
60
60
+
if err != nil {
61
61
+
return nil, err
62
62
+
}
63
63
+
64
64
+
records := make([]DataRecord, len(projects))
65
65
+
for i, project := range projects {
66
66
+
records[i] = &ProjectSummaryRecord{summary: project}
67
67
+
}
68
68
+
69
69
+
return records, nil
70
70
+
}
71
71
+
72
72
+
func (p *ProjectDataSource) Count(ctx context.Context, opts DataOptions) (int, error) {
73
73
+
projects, err := p.repo.GetProjects(ctx)
74
74
+
if err != nil {
75
75
+
return 0, err
76
76
+
}
77
77
+
return len(projects), nil
78
78
+
}
79
79
+
80
80
+
// NewProjectDataTable creates a new DataTable for browsing projects
81
81
+
func NewProjectDataTable(repo ProjectRepository, opts DataTableOptions) *DataTable {
82
82
+
if opts.Title == "" {
83
83
+
opts.Title = "Projects"
84
84
+
}
85
85
+
86
86
+
if len(opts.Fields) == 0 {
87
87
+
opts.Fields = []Field{
88
88
+
{
89
89
+
Name: "name",
90
90
+
Title: "Project Name",
91
91
+
Width: 30,
92
92
+
},
93
93
+
{
94
94
+
Name: "task_count",
95
95
+
Title: "Task Count",
96
96
+
Width: 15,
97
97
+
Formatter: func(value any) string {
98
98
+
if count, ok := value.(int); ok {
99
99
+
return fmt.Sprintf("%d task%s", count, pluralizeCount(count))
100
100
+
}
101
101
+
return fmt.Sprintf("%v", value)
102
102
+
},
103
103
+
},
104
104
+
}
105
105
+
}
106
106
+
107
107
+
source := &ProjectDataSource{repo: repo}
108
108
+
return NewDataTable(source, opts)
109
109
+
}
110
110
+
111
111
+
// NewProjectListFromTable creates a ProjectList-compatible interface using DataTable
112
112
+
func NewProjectListFromTable(repo ProjectRepository, output io.Writer, input io.Reader, static bool) *DataTable {
113
113
+
opts := DataTableOptions{
114
114
+
Output: output,
115
115
+
Input: input,
116
116
+
Static: static,
117
117
+
Title: "Projects",
118
118
+
}
119
119
+
return NewProjectDataTable(repo, opts)
120
120
+
}
+209
internal/ui/project_list_adapter_test.go
···
1
1
+
package ui
2
2
+
3
3
+
import (
4
4
+
"bytes"
5
5
+
"context"
6
6
+
"fmt"
7
7
+
"strings"
8
8
+
"testing"
9
9
+
10
10
+
"github.com/stormlightlabs/noteleaf/internal/repo"
11
11
+
)
12
12
+
13
13
+
// mockProjectRepository implements ProjectRepository for testing
14
14
+
type mockProjectRepository struct {
15
15
+
projects []repo.ProjectSummary
16
16
+
err error
17
17
+
}
18
18
+
19
19
+
func (m *mockProjectRepository) GetProjects(ctx context.Context) ([]repo.ProjectSummary, error) {
20
20
+
if m.err != nil {
21
21
+
return nil, m.err
22
22
+
}
23
23
+
return m.projects, nil
24
24
+
}
25
25
+
26
26
+
func TestProjectAdapter(t *testing.T) {
27
27
+
t.Run("ProjectSummaryRecord", func(t *testing.T) {
28
28
+
summary := repo.ProjectSummary{
29
29
+
Name: "Test Project",
30
30
+
TaskCount: 5,
31
31
+
}
32
32
+
record := &ProjectSummaryRecord{summary: summary}
33
33
+
34
34
+
t.Run("GetField", func(t *testing.T) {
35
35
+
tests := []struct {
36
36
+
field string
37
37
+
expected any
38
38
+
name string
39
39
+
}{
40
40
+
{"name", "Test Project", "should return project name"},
41
41
+
{"task_count", 5, "should return task count"},
42
42
+
{"unknown", "", "should return empty string for unknown field"},
43
43
+
}
44
44
+
45
45
+
for _, tt := range tests {
46
46
+
t.Run(tt.name, func(t *testing.T) {
47
47
+
result := record.GetField(tt.field)
48
48
+
if result != tt.expected {
49
49
+
t.Errorf("GetField(%q) = %v, want %v", tt.field, result, tt.expected)
50
50
+
}
51
51
+
})
52
52
+
}
53
53
+
})
54
54
+
55
55
+
t.Run("ModelInterface", func(t *testing.T) {
56
56
+
if record.GetID() != 5 {
57
57
+
t.Errorf("GetID() = %d, want 5", record.GetID())
58
58
+
}
59
59
+
60
60
+
if record.GetTableName() != "projects" {
61
61
+
t.Errorf("GetTableName() = %q, want 'projects'", record.GetTableName())
62
62
+
}
63
63
+
64
64
+
if !record.GetCreatedAt().IsZero() {
65
65
+
t.Error("GetCreatedAt() should return zero time")
66
66
+
}
67
67
+
68
68
+
if !record.GetUpdatedAt().IsZero() {
69
69
+
t.Error("GetUpdatedAt() should return zero time")
70
70
+
}
71
71
+
})
72
72
+
})
73
73
+
74
74
+
t.Run("ProjectDataSource", func(t *testing.T) {
75
75
+
t.Run("Load", func(t *testing.T) {
76
76
+
projects := []repo.ProjectSummary{
77
77
+
{Name: "Project 1", TaskCount: 5},
78
78
+
{Name: "Project 2", TaskCount: 3},
79
79
+
}
80
80
+
repo := &mockProjectRepository{projects: projects}
81
81
+
source := &ProjectDataSource{repo: repo}
82
82
+
83
83
+
records, err := source.Load(context.Background(), DataOptions{})
84
84
+
if err != nil {
85
85
+
t.Fatalf("Load() failed: %v", err)
86
86
+
}
87
87
+
88
88
+
if len(records) != 2 {
89
89
+
t.Errorf("Load() returned %d records, want 2", len(records))
90
90
+
}
91
91
+
92
92
+
if records[0].GetField("name") != "Project 1" {
93
93
+
t.Errorf("First record name = %v, want 'Project 1'", records[0].GetField("name"))
94
94
+
}
95
95
+
96
96
+
if records[0].GetField("task_count") != 5 {
97
97
+
t.Errorf("First record task_count = %v, want 5", records[0].GetField("task_count"))
98
98
+
}
99
99
+
})
100
100
+
101
101
+
t.Run("Load_Error", func(t *testing.T) {
102
102
+
testErr := fmt.Errorf("test error")
103
103
+
repo := &mockProjectRepository{err: testErr}
104
104
+
source := &ProjectDataSource{repo: repo}
105
105
+
106
106
+
_, err := source.Load(context.Background(), DataOptions{})
107
107
+
if err != testErr {
108
108
+
t.Errorf("Load() error = %v, want %v", err, testErr)
109
109
+
}
110
110
+
})
111
111
+
112
112
+
t.Run("Count", func(t *testing.T) {
113
113
+
projects := []repo.ProjectSummary{
114
114
+
{Name: "Project 1", TaskCount: 5},
115
115
+
{Name: "Project 2", TaskCount: 3},
116
116
+
{Name: "Project 3", TaskCount: 1},
117
117
+
}
118
118
+
repo := &mockProjectRepository{projects: projects}
119
119
+
source := &ProjectDataSource{repo: repo}
120
120
+
121
121
+
count, err := source.Count(context.Background(), DataOptions{})
122
122
+
if err != nil {
123
123
+
t.Fatalf("Count() failed: %v", err)
124
124
+
}
125
125
+
126
126
+
if count != 3 {
127
127
+
t.Errorf("Count() = %d, want 3", count)
128
128
+
}
129
129
+
})
130
130
+
131
131
+
t.Run("Count_Error", func(t *testing.T) {
132
132
+
testErr := fmt.Errorf("test error")
133
133
+
repo := &mockProjectRepository{err: testErr}
134
134
+
source := &ProjectDataSource{repo: repo}
135
135
+
136
136
+
_, err := source.Count(context.Background(), DataOptions{})
137
137
+
if err != testErr {
138
138
+
t.Errorf("Count() error = %v, want %v", err, testErr)
139
139
+
}
140
140
+
})
141
141
+
})
142
142
+
143
143
+
t.Run("NewProjectDataTable", func(t *testing.T) {
144
144
+
repo := &mockProjectRepository{
145
145
+
projects: []repo.ProjectSummary{
146
146
+
{Name: "Test Project", TaskCount: 2},
147
147
+
},
148
148
+
}
149
149
+
150
150
+
opts := DataTableOptions{
151
151
+
Output: &bytes.Buffer{},
152
152
+
Input: strings.NewReader("q\n"),
153
153
+
Static: true,
154
154
+
}
155
155
+
156
156
+
table := NewProjectDataTable(repo, opts)
157
157
+
if table == nil {
158
158
+
t.Fatal("NewProjectDataTable() returned nil")
159
159
+
}
160
160
+
161
161
+
emptyOpts := DataTableOptions{
162
162
+
Output: &bytes.Buffer{},
163
163
+
Input: strings.NewReader("q\n"),
164
164
+
Static: true,
165
165
+
}
166
166
+
167
167
+
table2 := NewProjectDataTable(repo, emptyOpts)
168
168
+
if table2 == nil {
169
169
+
t.Fatal("NewProjectDataTable() with empty opts returned nil")
170
170
+
}
171
171
+
172
172
+
err := table.Browse(context.Background())
173
173
+
if err != nil {
174
174
+
t.Errorf("Browse() failed: %v", err)
175
175
+
}
176
176
+
})
177
177
+
178
178
+
t.Run("NewProjectListFromTable", func(t *testing.T) {
179
179
+
repo := &mockProjectRepository{
180
180
+
projects: []repo.ProjectSummary{
181
181
+
{Name: "Test Project", TaskCount: 1},
182
182
+
},
183
183
+
}
184
184
+
185
185
+
output := &bytes.Buffer{}
186
186
+
input := strings.NewReader("q\n")
187
187
+
188
188
+
table := NewProjectListFromTable(repo, output, input, true)
189
189
+
if table == nil {
190
190
+
t.Fatal("NewProjectListFromTable() returned nil")
191
191
+
}
192
192
+
193
193
+
err := table.Browse(context.Background())
194
194
+
if err != nil {
195
195
+
t.Errorf("Browse() failed: %v", err)
196
196
+
}
197
197
+
198
198
+
outputStr := output.String()
199
199
+
if !strings.Contains(outputStr, "Projects") {
200
200
+
t.Error("Output should contain 'Projects' title")
201
201
+
}
202
202
+
if !strings.Contains(outputStr, "Test Project") {
203
203
+
t.Error("Output should contain project name")
204
204
+
}
205
205
+
if !strings.Contains(outputStr, "1 task") {
206
206
+
t.Error("Output should contain formatted task count")
207
207
+
}
208
208
+
})
209
209
+
}
-375
internal/ui/project_list_test.go
···
1
1
-
package ui
2
2
-
3
3
-
import (
4
4
-
"bytes"
5
5
-
"context"
6
6
-
"fmt"
7
7
-
"strings"
8
8
-
"testing"
9
9
-
10
10
-
tea "github.com/charmbracelet/bubbletea"
11
11
-
"github.com/stormlightlabs/noteleaf/internal/repo"
12
12
-
)
13
13
-
14
14
-
// Mock project repository for testing
15
15
-
type mockProjectRepository struct {
16
16
-
projects []repo.ProjectSummary
17
17
-
err error
18
18
-
}
19
19
-
20
20
-
func (m *mockProjectRepository) GetProjects(ctx context.Context) ([]repo.ProjectSummary, error) {
21
21
-
if m.err != nil {
22
22
-
return nil, m.err
23
23
-
}
24
24
-
return m.projects, nil
25
25
-
}
26
26
-
27
27
-
func TestProjectList(t *testing.T) {
28
28
-
t.Run("NewProjectList", func(t *testing.T) {
29
29
-
t.Run("creates project list successfully", func(t *testing.T) {
30
30
-
mockRepo := &mockProjectRepository{}
31
31
-
opts := ProjectListOptions{}
32
32
-
33
33
-
projectList := NewProjectList(mockRepo, opts)
34
34
-
35
35
-
if projectList == nil {
36
36
-
t.Fatal("ProjectList should not be nil")
37
37
-
}
38
38
-
if projectList.repo != mockRepo {
39
39
-
t.Error("ProjectList repo should be set correctly")
40
40
-
}
41
41
-
})
42
42
-
43
43
-
t.Run("sets default options", func(t *testing.T) {
44
44
-
mockRepo := &mockProjectRepository{}
45
45
-
opts := ProjectListOptions{}
46
46
-
47
47
-
projectList := NewProjectList(mockRepo, opts)
48
48
-
49
49
-
if projectList.opts.Output == nil {
50
50
-
t.Error("Default output should be set")
51
51
-
}
52
52
-
if projectList.opts.Input == nil {
53
53
-
t.Error("Default input should be set")
54
54
-
}
55
55
-
})
56
56
-
57
57
-
t.Run("preserves custom options", func(t *testing.T) {
58
58
-
mockRepo := &mockProjectRepository{}
59
59
-
output := &bytes.Buffer{}
60
60
-
input := strings.NewReader("")
61
61
-
opts := ProjectListOptions{
62
62
-
Output: output,
63
63
-
Input: input,
64
64
-
Static: true,
65
65
-
}
66
66
-
67
67
-
projectList := NewProjectList(mockRepo, opts)
68
68
-
69
69
-
if projectList.opts.Output != output {
70
70
-
t.Error("Custom output should be preserved")
71
71
-
}
72
72
-
if projectList.opts.Input != input {
73
73
-
t.Error("Custom input should be preserved")
74
74
-
}
75
75
-
if !projectList.opts.Static {
76
76
-
t.Error("Static option should be preserved")
77
77
-
}
78
78
-
})
79
79
-
})
80
80
-
81
81
-
t.Run("StaticList", func(t *testing.T) {
82
82
-
t.Run("displays projects correctly", func(t *testing.T) {
83
83
-
projects := []repo.ProjectSummary{
84
84
-
{Name: "web-app", TaskCount: 5},
85
85
-
{Name: "mobile-app", TaskCount: 3},
86
86
-
{Name: "documentation", TaskCount: 1},
87
87
-
}
88
88
-
mockRepo := &mockProjectRepository{projects: projects}
89
89
-
output := &bytes.Buffer{}
90
90
-
opts := ProjectListOptions{Output: output, Static: true}
91
91
-
92
92
-
projectList := NewProjectList(mockRepo, opts)
93
93
-
err := projectList.Browse(context.Background())
94
94
-
95
95
-
if err != nil {
96
96
-
t.Errorf("Browse should not return error: %v", err)
97
97
-
}
98
98
-
99
99
-
result := output.String()
100
100
-
if !strings.Contains(result, "Projects") {
101
101
-
t.Error("Output should contain title")
102
102
-
}
103
103
-
if !strings.Contains(result, "web-app") {
104
104
-
t.Error("Output should contain web-app project")
105
105
-
}
106
106
-
if !strings.Contains(result, "mobile-app") {
107
107
-
t.Error("Output should contain mobile-app project")
108
108
-
}
109
109
-
if !strings.Contains(result, "5 tasks") {
110
110
-
t.Error("Output should show correct task count for web-app")
111
111
-
}
112
112
-
if !strings.Contains(result, "1 task") {
113
113
-
t.Error("Output should show singular task for documentation")
114
114
-
}
115
115
-
})
116
116
-
117
117
-
t.Run("handles empty project list", func(t *testing.T) {
118
118
-
mockRepo := &mockProjectRepository{projects: []repo.ProjectSummary{}}
119
119
-
output := &bytes.Buffer{}
120
120
-
opts := ProjectListOptions{Output: output, Static: true}
121
121
-
122
122
-
projectList := NewProjectList(mockRepo, opts)
123
123
-
err := projectList.Browse(context.Background())
124
124
-
125
125
-
if err != nil {
126
126
-
t.Errorf("Browse should not return error: %v", err)
127
127
-
}
128
128
-
129
129
-
result := output.String()
130
130
-
if !strings.Contains(result, "No projects found") {
131
131
-
t.Error("Output should indicate no projects found")
132
132
-
}
133
133
-
})
134
134
-
135
135
-
t.Run("handles repository errors", func(t *testing.T) {
136
136
-
mockRepo := &mockProjectRepository{err: fmt.Errorf("database error")}
137
137
-
output := &bytes.Buffer{}
138
138
-
opts := ProjectListOptions{Output: output, Static: true}
139
139
-
140
140
-
projectList := NewProjectList(mockRepo, opts)
141
141
-
err := projectList.Browse(context.Background())
142
142
-
143
143
-
if err == nil {
144
144
-
t.Error("Browse should return error when repository fails")
145
145
-
}
146
146
-
147
147
-
result := output.String()
148
148
-
if !strings.Contains(result, "Error:") {
149
149
-
t.Error("Output should contain error message")
150
150
-
}
151
151
-
})
152
152
-
153
153
-
t.Run("truncates long project names", func(t *testing.T) {
154
154
-
projects := []repo.ProjectSummary{
155
155
-
{Name: "this-is-a-very-long-project-name-that-should-be-truncated", TaskCount: 2},
156
156
-
}
157
157
-
mockRepo := &mockProjectRepository{projects: projects}
158
158
-
output := &bytes.Buffer{}
159
159
-
opts := ProjectListOptions{Output: output, Static: true}
160
160
-
161
161
-
projectList := NewProjectList(mockRepo, opts)
162
162
-
err := projectList.Browse(context.Background())
163
163
-
164
164
-
if err != nil {
165
165
-
t.Errorf("Browse should not return error: %v", err)
166
166
-
}
167
167
-
168
168
-
result := output.String()
169
169
-
if !strings.Contains(result, "...") {
170
170
-
t.Error("Output should truncate long project names")
171
171
-
}
172
172
-
})
173
173
-
})
174
174
-
175
175
-
t.Run("ProjectListModel", func(t *testing.T) {
176
176
-
t.Run("initializes correctly", func(t *testing.T) {
177
177
-
model := projectListModel{
178
178
-
selected: 0,
179
179
-
showingHelp: false,
180
180
-
}
181
181
-
182
182
-
if model.selected != 0 {
183
183
-
t.Error("Initial selection should be 0")
184
184
-
}
185
185
-
if model.showingHelp {
186
186
-
t.Error("Should not be showing help initially")
187
187
-
}
188
188
-
})
189
189
-
190
190
-
t.Run("handles key navigation", func(t *testing.T) {
191
191
-
projects := []repo.ProjectSummary{
192
192
-
{Name: "project1", TaskCount: 1},
193
193
-
{Name: "project2", TaskCount: 2},
194
194
-
{Name: "project3", TaskCount: 3},
195
195
-
}
196
196
-
197
197
-
model := projectListModel{
198
198
-
projects: projects,
199
199
-
selected: 1,
200
200
-
keys: projectKeys,
201
201
-
}
202
202
-
203
203
-
// Test down key
204
204
-
downMsg := tea.KeyMsg{Type: tea.KeyDown}
205
205
-
updatedModel, _ := model.Update(downMsg)
206
206
-
if updatedModel.(projectListModel).selected != 2 {
207
207
-
t.Error("Down key should move selection down")
208
208
-
}
209
209
-
210
210
-
// Test up key
211
211
-
upMsg := tea.KeyMsg{Type: tea.KeyUp}
212
212
-
updatedModel, _ = updatedModel.Update(upMsg)
213
213
-
if updatedModel.(projectListModel).selected != 1 {
214
214
-
t.Error("Up key should move selection up")
215
215
-
}
216
216
-
217
217
-
// Test boundary conditions
218
218
-
model.selected = 0
219
219
-
updatedModel, _ = model.Update(upMsg)
220
220
-
if updatedModel.(projectListModel).selected != 0 {
221
221
-
t.Error("Up key should not move selection below 0")
222
222
-
}
223
223
-
224
224
-
model.selected = len(projects) - 1
225
225
-
updatedModel, _ = model.Update(downMsg)
226
226
-
if updatedModel.(projectListModel).selected != len(projects)-1 {
227
227
-
t.Error("Down key should not move selection beyond list length")
228
228
-
}
229
229
-
})
230
230
-
231
231
-
t.Run("handles number key selection", func(t *testing.T) {
232
232
-
projects := []repo.ProjectSummary{
233
233
-
{Name: "project1", TaskCount: 1},
234
234
-
{Name: "project2", TaskCount: 2},
235
235
-
{Name: "project3", TaskCount: 3},
236
236
-
}
237
237
-
238
238
-
model := projectListModel{
239
239
-
projects: projects,
240
240
-
selected: 0,
241
241
-
keys: projectKeys,
242
242
-
}
243
243
-
244
244
-
// Test number key 3 (index 2)
245
245
-
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'3'}}
246
246
-
updatedModel, _ := model.Update(keyMsg)
247
247
-
if updatedModel.(projectListModel).selected != 2 {
248
248
-
t.Error("Number key 3 should select index 2")
249
249
-
}
250
250
-
})
251
251
-
252
252
-
t.Run("handles help toggle", func(t *testing.T) {
253
253
-
model := projectListModel{
254
254
-
keys: projectKeys,
255
255
-
}
256
256
-
257
257
-
// Toggle help on
258
258
-
helpMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}
259
259
-
updatedModel, _ := model.Update(helpMsg)
260
260
-
if !updatedModel.(projectListModel).showingHelp {
261
261
-
t.Error("Help key should show help")
262
262
-
}
263
263
-
264
264
-
// Toggle help off
265
265
-
updatedModel, _ = updatedModel.Update(helpMsg)
266
266
-
if updatedModel.(projectListModel).showingHelp {
267
267
-
t.Error("Help key should hide help when already showing")
268
268
-
}
269
269
-
})
270
270
-
271
271
-
t.Run("handles projects loaded message", func(t *testing.T) {
272
272
-
projects := []repo.ProjectSummary{
273
273
-
{Name: "new-project", TaskCount: 5},
274
274
-
}
275
275
-
276
276
-
model := projectListModel{
277
277
-
selected: 5, // Invalid selection
278
278
-
}
279
279
-
280
280
-
msg := projectsLoadedMsg(projects)
281
281
-
updatedModel, _ := model.Update(msg)
282
282
-
resultModel := updatedModel.(projectListModel)
283
283
-
284
284
-
if len(resultModel.projects) != 1 {
285
285
-
t.Error("Projects should be loaded correctly")
286
286
-
}
287
287
-
if resultModel.selected != 0 {
288
288
-
t.Error("Selection should be reset to valid range")
289
289
-
}
290
290
-
})
291
291
-
292
292
-
t.Run("handles error message", func(t *testing.T) {
293
293
-
model := projectListModel{}
294
294
-
295
295
-
errorMsg := errorProjectMsg(fmt.Errorf("test error"))
296
296
-
updatedModel, _ := model.Update(errorMsg)
297
297
-
resultModel := updatedModel.(projectListModel)
298
298
-
299
299
-
if resultModel.err == nil {
300
300
-
t.Error("Error should be set")
301
301
-
}
302
302
-
if resultModel.err.Error() != "test error" {
303
303
-
t.Errorf("Expected 'test error', got '%s'", resultModel.err.Error())
304
304
-
}
305
305
-
})
306
306
-
307
307
-
t.Run("view renders correctly", func(t *testing.T) {
308
308
-
projects := []repo.ProjectSummary{
309
309
-
{Name: "web-app", TaskCount: 5},
310
310
-
{Name: "mobile-app", TaskCount: 1},
311
311
-
}
312
312
-
313
313
-
model := projectListModel{
314
314
-
projects: projects,
315
315
-
selected: 0,
316
316
-
keys: projectKeys,
317
317
-
}
318
318
-
319
319
-
view := model.View()
320
320
-
if !strings.Contains(view, "Projects") {
321
321
-
t.Error("View should contain title")
322
322
-
}
323
323
-
if !strings.Contains(view, "web-app") {
324
324
-
t.Error("View should contain project names")
325
325
-
}
326
326
-
if !strings.Contains(view, "5 tasks") {
327
327
-
t.Error("View should show task counts")
328
328
-
}
329
329
-
if !strings.Contains(view, "1 task") {
330
330
-
t.Error("View should show singular task count")
331
331
-
}
332
332
-
})
333
333
-
334
334
-
t.Run("view handles empty state", func(t *testing.T) {
335
335
-
model := projectListModel{
336
336
-
projects: []repo.ProjectSummary{},
337
337
-
}
338
338
-
339
339
-
view := model.View()
340
340
-
if !strings.Contains(view, "No projects found") {
341
341
-
t.Error("View should show empty state message")
342
342
-
}
343
343
-
})
344
344
-
345
345
-
t.Run("view handles error state", func(t *testing.T) {
346
346
-
model := projectListModel{
347
347
-
err: fmt.Errorf("test error"),
348
348
-
}
349
349
-
350
350
-
view := model.View()
351
351
-
if !strings.Contains(view, "Error:") {
352
352
-
t.Error("View should show error message")
353
353
-
}
354
354
-
})
355
355
-
})
356
356
-
357
357
-
t.Run("PluralizeCount", func(t *testing.T) {
358
358
-
t.Run("returns empty string for singular", func(t *testing.T) {
359
359
-
result := pluralizeCount(1)
360
360
-
if result != "" {
361
361
-
t.Errorf("Expected empty string for 1, got '%s'", result)
362
362
-
}
363
363
-
})
364
364
-
365
365
-
t.Run("returns 's' for plural", func(t *testing.T) {
366
366
-
testCases := []int{0, 2, 10, 100}
367
367
-
for _, count := range testCases {
368
368
-
result := pluralizeCount(count)
369
369
-
if result != "s" {
370
370
-
t.Errorf("Expected 's' for %d, got '%s'", count, result)
371
371
-
}
372
372
-
}
373
373
-
})
374
374
-
})
375
375
-
}
-287
internal/ui/tag_list.go
···
1
1
-
package ui
2
2
-
3
3
-
import (
4
4
-
"context"
5
5
-
"fmt"
6
6
-
"io"
7
7
-
"os"
8
8
-
"strings"
9
9
-
10
10
-
"github.com/charmbracelet/bubbles/help"
11
11
-
"github.com/charmbracelet/bubbles/key"
12
12
-
tea "github.com/charmbracelet/bubbletea"
13
13
-
"github.com/charmbracelet/lipgloss"
14
14
-
"github.com/stormlightlabs/noteleaf/internal/repo"
15
15
-
)
16
16
-
17
17
-
// Tag list key bindings
18
18
-
type tagKeyMap struct {
19
19
-
Up key.Binding
20
20
-
Down key.Binding
21
21
-
Enter key.Binding
22
22
-
Refresh key.Binding
23
23
-
Quit key.Binding
24
24
-
Back key.Binding
25
25
-
Help key.Binding
26
26
-
Numbers []key.Binding
27
27
-
}
28
28
-
29
29
-
func (k tagKeyMap) ShortHelp() []key.Binding {
30
30
-
return []key.Binding{k.Up, k.Down, k.Enter, k.Help, k.Quit}
31
31
-
}
32
32
-
33
33
-
func (k tagKeyMap) FullHelp() [][]key.Binding {
34
34
-
return [][]key.Binding{
35
35
-
{k.Up, k.Down, k.Enter, k.Refresh},
36
36
-
{k.Help, k.Quit, k.Back},
37
37
-
}
38
38
-
}
39
39
-
40
40
-
var tagKeys = tagKeyMap{
41
41
-
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("โ/k", "move up")),
42
42
-
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("โ/j", "move down")),
43
43
-
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select tag")),
44
44
-
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
45
45
-
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
46
46
-
Back: key.NewBinding(key.WithKeys("esc", "backspace"), key.WithHelp("esc", "back")),
47
47
-
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
48
48
-
Numbers: []key.Binding{
49
49
-
key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "jump to tag 1")),
50
50
-
key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "jump to tag 2")),
51
51
-
key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "jump to tag 3")),
52
52
-
key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "jump to tag 4")),
53
53
-
key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "jump to tag 5")),
54
54
-
key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "jump to tag 6")),
55
55
-
key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "jump to tag 7")),
56
56
-
key.NewBinding(key.WithKeys("8"), key.WithHelp("8", "jump to tag 8")),
57
57
-
key.NewBinding(key.WithKeys("9"), key.WithHelp("9", "jump to tag 9")),
58
58
-
},
59
59
-
}
60
60
-
61
61
-
type (
62
62
-
// TagRepository interface for dependency injection in tests
63
63
-
TagRepository interface {
64
64
-
GetTags(ctx context.Context) ([]repo.TagSummary, error)
65
65
-
}
66
66
-
67
67
-
// TagListOptions configures the tag list UI behavior
68
68
-
TagListOptions struct {
69
69
-
// Output destination (stdout for interactive, buffer for testing)
70
70
-
Output io.Writer
71
71
-
// Input source (stdin for interactive, strings reader for testing)
72
72
-
Input io.Reader
73
73
-
// Enable static mode (no interactive components)
74
74
-
Static bool
75
75
-
}
76
76
-
77
77
-
// TagList handles tag browsing UI
78
78
-
TagList struct {
79
79
-
repo TagRepository
80
80
-
opts TagListOptions
81
81
-
}
82
82
-
)
83
83
-
84
84
-
// NewTagList creates a new tag list UI component
85
85
-
func NewTagList(repo TagRepository, opts TagListOptions) *TagList {
86
86
-
if opts.Output == nil {
87
87
-
opts.Output = os.Stdout
88
88
-
}
89
89
-
if opts.Input == nil {
90
90
-
opts.Input = os.Stdin
91
91
-
}
92
92
-
return &TagList{repo: repo, opts: opts}
93
93
-
}
94
94
-
95
95
-
type (
96
96
-
tagsLoadedMsg []repo.TagSummary
97
97
-
errorTagMsg error
98
98
-
tagListModel struct {
99
99
-
tags []repo.TagSummary
100
100
-
selected int
101
101
-
err error
102
102
-
repo TagRepository
103
103
-
opts TagListOptions
104
104
-
keys tagKeyMap
105
105
-
help help.Model
106
106
-
showingHelp bool
107
107
-
}
108
108
-
)
109
109
-
110
110
-
func (m tagListModel) Init() tea.Cmd {
111
111
-
return m.loadTags()
112
112
-
}
113
113
-
114
114
-
func (m tagListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
115
115
-
switch msg := msg.(type) {
116
116
-
case tea.KeyMsg:
117
117
-
if m.showingHelp {
118
118
-
switch {
119
119
-
case key.Matches(msg, m.keys.Back) || key.Matches(msg, m.keys.Quit) || key.Matches(msg, m.keys.Help):
120
120
-
m.showingHelp = false
121
121
-
return m, nil
122
122
-
}
123
123
-
return m, nil
124
124
-
}
125
125
-
126
126
-
switch {
127
127
-
case key.Matches(msg, m.keys.Quit):
128
128
-
return m, tea.Quit
129
129
-
case key.Matches(msg, m.keys.Up):
130
130
-
if m.selected > 0 {
131
131
-
m.selected--
132
132
-
}
133
133
-
case key.Matches(msg, m.keys.Down):
134
134
-
if m.selected < len(m.tags)-1 {
135
135
-
m.selected++
136
136
-
}
137
137
-
case key.Matches(msg, m.keys.Enter):
138
138
-
if len(m.tags) > 0 && m.selected < len(m.tags) {
139
139
-
// For now, just show the selected tag name
140
140
-
// In a real implementation, this might navigate to tasks with that tag
141
141
-
return m, tea.Quit
142
142
-
}
143
143
-
case key.Matches(msg, m.keys.Refresh):
144
144
-
return m, m.loadTags()
145
145
-
case key.Matches(msg, m.keys.Help):
146
146
-
m.showingHelp = true
147
147
-
return m, nil
148
148
-
default:
149
149
-
// Handle number keys for quick selection
150
150
-
for i, numKey := range m.keys.Numbers {
151
151
-
if key.Matches(msg, numKey) && i < len(m.tags) {
152
152
-
m.selected = i
153
153
-
break
154
154
-
}
155
155
-
}
156
156
-
}
157
157
-
case tagsLoadedMsg:
158
158
-
m.tags = []repo.TagSummary(msg)
159
159
-
if m.selected >= len(m.tags) && len(m.tags) > 0 {
160
160
-
m.selected = len(m.tags) - 1
161
161
-
}
162
162
-
case errorTagMsg:
163
163
-
m.err = error(msg)
164
164
-
}
165
165
-
return m, nil
166
166
-
}
167
167
-
168
168
-
func (m tagListModel) View() string {
169
169
-
var s strings.Builder
170
170
-
171
171
-
style := lipgloss.NewStyle().Foreground(lipgloss.Color(Squid.Hex()))
172
172
-
173
173
-
if m.showingHelp {
174
174
-
return m.help.View(m.keys)
175
175
-
}
176
176
-
177
177
-
s.WriteString(TitleColorStyle.Render("Tags"))
178
178
-
s.WriteString("\n\n")
179
179
-
180
180
-
if m.err != nil {
181
181
-
s.WriteString(fmt.Sprintf("Error: %s", m.err))
182
182
-
return s.String()
183
183
-
}
184
184
-
185
185
-
if len(m.tags) == 0 {
186
186
-
s.WriteString("No tags found")
187
187
-
s.WriteString("\n\n")
188
188
-
s.WriteString(style.Render("Press r to refresh, q to quit"))
189
189
-
return s.String()
190
190
-
}
191
191
-
192
192
-
headerLine := fmt.Sprintf(" %-25s %-15s", "Tag Name", "Task Count")
193
193
-
s.WriteString(HeaderColorStyle.Render(headerLine))
194
194
-
s.WriteString("\n")
195
195
-
s.WriteString(HeaderColorStyle.Render(strings.Repeat("โ", 45)))
196
196
-
s.WriteString("\n")
197
197
-
198
198
-
for i, tag := range m.tags {
199
199
-
prefix := " "
200
200
-
if i == m.selected {
201
201
-
prefix = " > "
202
202
-
}
203
203
-
204
204
-
tagName := tag.Name
205
205
-
if len(tagName) > 23 {
206
206
-
tagName = tagName[:20] + "..."
207
207
-
}
208
208
-
209
209
-
taskCountStr := fmt.Sprintf("%d task%s", tag.TaskCount, pluralizeCount(tag.TaskCount))
210
210
-
211
211
-
line := fmt.Sprintf("%s%-25s %-15s", prefix, tagName, taskCountStr)
212
212
-
213
213
-
if i == m.selected {
214
214
-
s.WriteString(SelectedColorStyle.Render(line))
215
215
-
} else {
216
216
-
s.WriteString(style.Render(line))
217
217
-
}
218
218
-
219
219
-
s.WriteString("\n")
220
220
-
}
221
221
-
222
222
-
s.WriteString("\n")
223
223
-
s.WriteString(m.help.View(m.keys))
224
224
-
225
225
-
return s.String()
226
226
-
}
227
227
-
228
228
-
func (m tagListModel) loadTags() tea.Cmd {
229
229
-
return func() tea.Msg {
230
230
-
tags, err := m.repo.GetTags(context.Background())
231
231
-
if err != nil {
232
232
-
return errorTagMsg(err)
233
233
-
}
234
234
-
235
235
-
return tagsLoadedMsg(tags)
236
236
-
}
237
237
-
}
238
238
-
239
239
-
// Browse opens an interactive TUI for navigating tags
240
240
-
func (tl *TagList) Browse(ctx context.Context) error {
241
241
-
if tl.opts.Static {
242
242
-
return tl.staticList(ctx)
243
243
-
}
244
244
-
245
245
-
model := tagListModel{
246
246
-
repo: tl.repo,
247
247
-
opts: tl.opts,
248
248
-
keys: tagKeys,
249
249
-
help: help.New(),
250
250
-
}
251
251
-
252
252
-
program := tea.NewProgram(model, tea.WithInput(tl.opts.Input), tea.WithOutput(tl.opts.Output))
253
253
-
254
254
-
_, err := program.Run()
255
255
-
return err
256
256
-
}
257
257
-
258
258
-
func (tl *TagList) staticList(ctx context.Context) error {
259
259
-
tags, err := tl.repo.GetTags(ctx)
260
260
-
if err != nil {
261
261
-
fmt.Fprintf(tl.opts.Output, "Error: %s\n", err)
262
262
-
return err
263
263
-
}
264
264
-
265
265
-
fmt.Fprintf(tl.opts.Output, "Tags\n\n")
266
266
-
267
267
-
if len(tags) == 0 {
268
268
-
fmt.Fprintf(tl.opts.Output, "No tags found\n")
269
269
-
return nil
270
270
-
}
271
271
-
272
272
-
fmt.Fprintf(tl.opts.Output, "%-25s %-15s\n", "Tag Name", "Task Count")
273
273
-
fmt.Fprintf(tl.opts.Output, "%s\n", strings.Repeat("โ", 45))
274
274
-
275
275
-
for _, tag := range tags {
276
276
-
tagName := tag.Name
277
277
-
if len(tagName) > 23 {
278
278
-
tagName = tagName[:20] + "..."
279
279
-
}
280
280
-
281
281
-
taskCountStr := fmt.Sprintf("%d task%s", tag.TaskCount, pluralizeCount(tag.TaskCount))
282
282
-
283
283
-
fmt.Fprintf(tl.opts.Output, "%-25s %-15s\n", tagName, taskCountStr)
284
284
-
}
285
285
-
286
286
-
return nil
287
287
-
}
+113
internal/ui/tag_list_adapter.go
···
1
1
+
package ui
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"fmt"
6
6
+
"io"
7
7
+
"time"
8
8
+
9
9
+
"github.com/stormlightlabs/noteleaf/internal/repo"
10
10
+
)
11
11
+
12
12
+
// TagRepository interface for dependency injection in tests
13
13
+
type TagRepository interface {
14
14
+
GetTags(ctx context.Context) ([]repo.TagSummary, error)
15
15
+
}
16
16
+
17
17
+
18
18
+
// TagSummaryRecord adapts repo.TagSummary to work with DataTable
19
19
+
type TagSummaryRecord struct {
20
20
+
summary repo.TagSummary
21
21
+
}
22
22
+
23
23
+
func (t *TagSummaryRecord) GetField(name string) any {
24
24
+
switch name {
25
25
+
case "name":
26
26
+
return t.summary.Name
27
27
+
case "task_count":
28
28
+
return t.summary.TaskCount
29
29
+
default:
30
30
+
return ""
31
31
+
}
32
32
+
}
33
33
+
34
34
+
func (t *TagSummaryRecord) GetTableName() string {
35
35
+
return "tags"
36
36
+
}
37
37
+
38
38
+
// Use task count as pseudo-ID since tags don't have IDs
39
39
+
func (t *TagSummaryRecord) GetID() int64 { return int64(t.summary.TaskCount) }
40
40
+
func (t *TagSummaryRecord) SetID(id int64) {}
41
41
+
func (t *TagSummaryRecord) GetCreatedAt() time.Time { return time.Time{} }
42
42
+
func (t *TagSummaryRecord) SetCreatedAt(time time.Time) {}
43
43
+
func (t *TagSummaryRecord) GetUpdatedAt() time.Time { return time.Time{} }
44
44
+
func (t *TagSummaryRecord) SetUpdatedAt(time time.Time) {}
45
45
+
46
46
+
// TagDataSource adapts TagRepository to work with DataTable
47
47
+
type TagDataSource struct {
48
48
+
repo TagRepository
49
49
+
}
50
50
+
51
51
+
func (t *TagDataSource) Load(ctx context.Context, opts DataOptions) ([]DataRecord, error) {
52
52
+
tags, err := t.repo.GetTags(ctx)
53
53
+
if err != nil {
54
54
+
return nil, err
55
55
+
}
56
56
+
57
57
+
records := make([]DataRecord, len(tags))
58
58
+
for i, tag := range tags {
59
59
+
records[i] = &TagSummaryRecord{summary: tag}
60
60
+
}
61
61
+
62
62
+
return records, nil
63
63
+
}
64
64
+
65
65
+
func (t *TagDataSource) Count(ctx context.Context, opts DataOptions) (int, error) {
66
66
+
tags, err := t.repo.GetTags(ctx)
67
67
+
if err != nil {
68
68
+
return 0, err
69
69
+
}
70
70
+
return len(tags), nil
71
71
+
}
72
72
+
73
73
+
// NewTagDataTable creates a new DataTable for browsing tags
74
74
+
func NewTagDataTable(repo TagRepository, opts DataTableOptions) *DataTable {
75
75
+
if opts.Title == "" {
76
76
+
opts.Title = "Tags"
77
77
+
}
78
78
+
79
79
+
if len(opts.Fields) == 0 {
80
80
+
opts.Fields = []Field{
81
81
+
{
82
82
+
Name: "name",
83
83
+
Title: "Tag Name",
84
84
+
Width: 25,
85
85
+
},
86
86
+
{
87
87
+
Name: "task_count",
88
88
+
Title: "Task Count",
89
89
+
Width: 15,
90
90
+
Formatter: func(value any) string {
91
91
+
if count, ok := value.(int); ok {
92
92
+
return fmt.Sprintf("%d task%s", count, pluralizeCount(count))
93
93
+
}
94
94
+
return fmt.Sprintf("%v", value)
95
95
+
},
96
96
+
},
97
97
+
}
98
98
+
}
99
99
+
100
100
+
source := &TagDataSource{repo: repo}
101
101
+
return NewDataTable(source, opts)
102
102
+
}
103
103
+
104
104
+
// NewTagListFromTable creates a TagList-compatible interface using DataTable
105
105
+
func NewTagListFromTable(repo TagRepository, output io.Writer, input io.Reader, static bool) *DataTable {
106
106
+
opts := DataTableOptions{
107
107
+
Output: output,
108
108
+
Input: input,
109
109
+
Static: static,
110
110
+
Title: "Tags",
111
111
+
}
112
112
+
return NewTagDataTable(repo, opts)
113
113
+
}
+198
internal/ui/tag_list_adapter_test.go
···
1
1
+
package ui
2
2
+
3
3
+
import (
4
4
+
"bytes"
5
5
+
"context"
6
6
+
"fmt"
7
7
+
"strings"
8
8
+
"testing"
9
9
+
10
10
+
"github.com/stormlightlabs/noteleaf/internal/repo"
11
11
+
)
12
12
+
13
13
+
// mockTagRepository implements TagRepository for testing
14
14
+
type mockTagRepository struct {
15
15
+
tags []repo.TagSummary
16
16
+
err error
17
17
+
}
18
18
+
19
19
+
func (m *mockTagRepository) GetTags(ctx context.Context) ([]repo.TagSummary, error) {
20
20
+
if m.err != nil {
21
21
+
return nil, m.err
22
22
+
}
23
23
+
return m.tags, nil
24
24
+
}
25
25
+
26
26
+
func TestTagAdapter(t *testing.T) {
27
27
+
t.Run("TagSummaryRecord", func(t *testing.T) {
28
28
+
summary := repo.TagSummary{
29
29
+
Name: "work",
30
30
+
TaskCount: 8,
31
31
+
}
32
32
+
record := &TagSummaryRecord{summary: summary}
33
33
+
34
34
+
t.Run("GetField", func(t *testing.T) {
35
35
+
tests := []struct {
36
36
+
field string
37
37
+
expected any
38
38
+
name string
39
39
+
}{
40
40
+
{"name", "work", "should return tag name"},
41
41
+
{"task_count", 8, "should return task count"},
42
42
+
{"unknown", "", "should return empty string for unknown field"},
43
43
+
}
44
44
+
45
45
+
for _, tt := range tests {
46
46
+
t.Run(tt.name, func(t *testing.T) {
47
47
+
result := record.GetField(tt.field)
48
48
+
if result != tt.expected {
49
49
+
t.Errorf("GetField(%q) = %v, want %v", tt.field, result, tt.expected)
50
50
+
}
51
51
+
})
52
52
+
}
53
53
+
})
54
54
+
55
55
+
t.Run("ModelInterface", func(t *testing.T) {
56
56
+
if record.GetID() != 8 {
57
57
+
t.Errorf("GetID() = %d, want 8", record.GetID())
58
58
+
}
59
59
+
60
60
+
if record.GetTableName() != "tags" {
61
61
+
t.Errorf("GetTableName() = %q, want 'tags'", record.GetTableName())
62
62
+
}
63
63
+
64
64
+
if !record.GetCreatedAt().IsZero() {
65
65
+
t.Error("GetCreatedAt() should return zero time")
66
66
+
}
67
67
+
68
68
+
if !record.GetUpdatedAt().IsZero() {
69
69
+
t.Error("GetUpdatedAt() should return zero time")
70
70
+
}
71
71
+
})
72
72
+
})
73
73
+
74
74
+
t.Run("TagDataSource", func(t *testing.T) {
75
75
+
t.Run("Load", func(t *testing.T) {
76
76
+
tags := []repo.TagSummary{
77
77
+
{Name: "work", TaskCount: 5},
78
78
+
{Name: "personal", TaskCount: 3},
79
79
+
}
80
80
+
repo := &mockTagRepository{tags: tags}
81
81
+
source := &TagDataSource{repo: repo}
82
82
+
83
83
+
records, err := source.Load(context.Background(), DataOptions{})
84
84
+
if err != nil {
85
85
+
t.Fatalf("Load() failed: %v", err)
86
86
+
}
87
87
+
88
88
+
if len(records) != 2 {
89
89
+
t.Errorf("Load() returned %d records, want 2", len(records))
90
90
+
}
91
91
+
92
92
+
if records[0].GetField("name") != "work" {
93
93
+
t.Errorf("First record name = %v, want 'work'", records[0].GetField("name"))
94
94
+
}
95
95
+
96
96
+
if records[0].GetField("task_count") != 5 {
97
97
+
t.Errorf("First record task_count = %v, want 5", records[0].GetField("task_count"))
98
98
+
}
99
99
+
})
100
100
+
101
101
+
t.Run("Load_Error", func(t *testing.T) {
102
102
+
testErr := fmt.Errorf("test error")
103
103
+
repo := &mockTagRepository{err: testErr}
104
104
+
source := &TagDataSource{repo: repo}
105
105
+
106
106
+
_, err := source.Load(context.Background(), DataOptions{})
107
107
+
if err != testErr {
108
108
+
t.Errorf("Load() error = %v, want %v", err, testErr)
109
109
+
}
110
110
+
})
111
111
+
112
112
+
t.Run("Count", func(t *testing.T) {
113
113
+
tags := []repo.TagSummary{
114
114
+
{Name: "work", TaskCount: 5},
115
115
+
{Name: "personal", TaskCount: 3},
116
116
+
{Name: "urgent", TaskCount: 1},
117
117
+
}
118
118
+
repo := &mockTagRepository{tags: tags}
119
119
+
source := &TagDataSource{repo: repo}
120
120
+
121
121
+
count, err := source.Count(context.Background(), DataOptions{})
122
122
+
if err != nil {
123
123
+
t.Fatalf("Count() failed: %v", err)
124
124
+
}
125
125
+
126
126
+
if count != 3 {
127
127
+
t.Errorf("Count() = %d, want 3", count)
128
128
+
}
129
129
+
})
130
130
+
131
131
+
t.Run("Count_Error", func(t *testing.T) {
132
132
+
testErr := fmt.Errorf("test error")
133
133
+
repo := &mockTagRepository{err: testErr}
134
134
+
source := &TagDataSource{repo: repo}
135
135
+
136
136
+
_, err := source.Count(context.Background(), DataOptions{})
137
137
+
if err != testErr {
138
138
+
t.Errorf("Count() error = %v, want %v", err, testErr)
139
139
+
}
140
140
+
})
141
141
+
})
142
142
+
143
143
+
t.Run("NewTagDataTable", func(t *testing.T) {
144
144
+
repo := &mockTagRepository{
145
145
+
tags: []repo.TagSummary{
146
146
+
{Name: "work", TaskCount: 4},
147
147
+
},
148
148
+
}
149
149
+
150
150
+
opts := DataTableOptions{
151
151
+
Output: &bytes.Buffer{},
152
152
+
Input: strings.NewReader("q\n"),
153
153
+
Static: true,
154
154
+
}
155
155
+
156
156
+
table := NewTagDataTable(repo, opts)
157
157
+
if table == nil {
158
158
+
t.Fatal("NewTagDataTable() returned nil")
159
159
+
}
160
160
+
161
161
+
err := table.Browse(context.Background())
162
162
+
if err != nil {
163
163
+
t.Errorf("Browse() failed: %v", err)
164
164
+
}
165
165
+
})
166
166
+
167
167
+
t.Run("NewTagListFromTable", func(t *testing.T) {
168
168
+
repo := &mockTagRepository{
169
169
+
tags: []repo.TagSummary{
170
170
+
{Name: "urgent", TaskCount: 1},
171
171
+
},
172
172
+
}
173
173
+
174
174
+
output := &bytes.Buffer{}
175
175
+
input := strings.NewReader("q\n")
176
176
+
177
177
+
table := NewTagListFromTable(repo, output, input, true)
178
178
+
if table == nil {
179
179
+
t.Fatal("NewTagListFromTable() returned nil")
180
180
+
}
181
181
+
182
182
+
err := table.Browse(context.Background())
183
183
+
if err != nil {
184
184
+
t.Errorf("Browse() failed: %v", err)
185
185
+
}
186
186
+
187
187
+
outputStr := output.String()
188
188
+
if !strings.Contains(outputStr, "Tags") {
189
189
+
t.Error("Output should contain 'Tags' title")
190
190
+
}
191
191
+
if !strings.Contains(outputStr, "urgent") {
192
192
+
t.Error("Output should contain tag name")
193
193
+
}
194
194
+
if !strings.Contains(outputStr, "1 task") {
195
195
+
t.Error("Output should contain formatted task count")
196
196
+
}
197
197
+
})
198
198
+
}
-354
internal/ui/tag_list_test.go
···
1
1
-
package ui
2
2
-
3
3
-
import (
4
4
-
"bytes"
5
5
-
"context"
6
6
-
"fmt"
7
7
-
"strings"
8
8
-
"testing"
9
9
-
10
10
-
tea "github.com/charmbracelet/bubbletea"
11
11
-
"github.com/stormlightlabs/noteleaf/internal/repo"
12
12
-
)
13
13
-
14
14
-
// Mock tag repository for testing
15
15
-
type mockTagRepository struct {
16
16
-
tags []repo.TagSummary
17
17
-
err error
18
18
-
}
19
19
-
20
20
-
func (m *mockTagRepository) GetTags(ctx context.Context) ([]repo.TagSummary, error) {
21
21
-
if m.err != nil {
22
22
-
return nil, m.err
23
23
-
}
24
24
-
return m.tags, nil
25
25
-
}
26
26
-
27
27
-
func TestTagList(t *testing.T) {
28
28
-
t.Run("NewTagList", func(t *testing.T) {
29
29
-
t.Run("creates tag list successfully", func(t *testing.T) {
30
30
-
mockRepo := &mockTagRepository{}
31
31
-
opts := TagListOptions{}
32
32
-
33
33
-
tagList := NewTagList(mockRepo, opts)
34
34
-
35
35
-
if tagList == nil {
36
36
-
t.Fatal("TagList should not be nil")
37
37
-
}
38
38
-
if tagList.repo != mockRepo {
39
39
-
t.Error("TagList repo should be set correctly")
40
40
-
}
41
41
-
})
42
42
-
43
43
-
t.Run("sets default options", func(t *testing.T) {
44
44
-
mockRepo := &mockTagRepository{}
45
45
-
opts := TagListOptions{}
46
46
-
47
47
-
tagList := NewTagList(mockRepo, opts)
48
48
-
49
49
-
if tagList.opts.Output == nil {
50
50
-
t.Error("Default output should be set")
51
51
-
}
52
52
-
if tagList.opts.Input == nil {
53
53
-
t.Error("Default input should be set")
54
54
-
}
55
55
-
})
56
56
-
57
57
-
t.Run("preserves custom options", func(t *testing.T) {
58
58
-
mockRepo := &mockTagRepository{}
59
59
-
output := &bytes.Buffer{}
60
60
-
input := strings.NewReader("")
61
61
-
opts := TagListOptions{
62
62
-
Output: output,
63
63
-
Input: input,
64
64
-
Static: true,
65
65
-
}
66
66
-
67
67
-
tagList := NewTagList(mockRepo, opts)
68
68
-
69
69
-
if tagList.opts.Output != output {
70
70
-
t.Error("Custom output should be preserved")
71
71
-
}
72
72
-
if tagList.opts.Input != input {
73
73
-
t.Error("Custom input should be preserved")
74
74
-
}
75
75
-
if !tagList.opts.Static {
76
76
-
t.Error("Static option should be preserved")
77
77
-
}
78
78
-
})
79
79
-
})
80
80
-
81
81
-
t.Run("StaticList", func(t *testing.T) {
82
82
-
t.Run("displays tags correctly", func(t *testing.T) {
83
83
-
tags := []repo.TagSummary{
84
84
-
{Name: "frontend", TaskCount: 5},
85
85
-
{Name: "backend", TaskCount: 3},
86
86
-
{Name: "urgent", TaskCount: 1},
87
87
-
}
88
88
-
mockRepo := &mockTagRepository{tags: tags}
89
89
-
output := &bytes.Buffer{}
90
90
-
opts := TagListOptions{Output: output, Static: true}
91
91
-
92
92
-
tagList := NewTagList(mockRepo, opts)
93
93
-
err := tagList.Browse(context.Background())
94
94
-
95
95
-
if err != nil {
96
96
-
t.Errorf("Browse should not return error: %v", err)
97
97
-
}
98
98
-
99
99
-
result := output.String()
100
100
-
if !strings.Contains(result, "Tags") {
101
101
-
t.Error("Output should contain title")
102
102
-
}
103
103
-
if !strings.Contains(result, "frontend") {
104
104
-
t.Error("Output should contain frontend tag")
105
105
-
}
106
106
-
if !strings.Contains(result, "backend") {
107
107
-
t.Error("Output should contain backend tag")
108
108
-
}
109
109
-
if !strings.Contains(result, "5 tasks") {
110
110
-
t.Error("Output should show correct task count for frontend")
111
111
-
}
112
112
-
if !strings.Contains(result, "1 task") {
113
113
-
t.Error("Output should show singular task for urgent")
114
114
-
}
115
115
-
})
116
116
-
117
117
-
t.Run("handles empty tag list", func(t *testing.T) {
118
118
-
mockRepo := &mockTagRepository{tags: []repo.TagSummary{}}
119
119
-
output := &bytes.Buffer{}
120
120
-
opts := TagListOptions{Output: output, Static: true}
121
121
-
122
122
-
tagList := NewTagList(mockRepo, opts)
123
123
-
err := tagList.Browse(context.Background())
124
124
-
125
125
-
if err != nil {
126
126
-
t.Errorf("Browse should not return error: %v", err)
127
127
-
}
128
128
-
129
129
-
result := output.String()
130
130
-
if !strings.Contains(result, "No tags found") {
131
131
-
t.Error("Output should indicate no tags found")
132
132
-
}
133
133
-
})
134
134
-
135
135
-
t.Run("handles repository errors", func(t *testing.T) {
136
136
-
mockRepo := &mockTagRepository{err: fmt.Errorf("database error")}
137
137
-
output := &bytes.Buffer{}
138
138
-
opts := TagListOptions{Output: output, Static: true}
139
139
-
140
140
-
tagList := NewTagList(mockRepo, opts)
141
141
-
err := tagList.Browse(context.Background())
142
142
-
143
143
-
if err == nil {
144
144
-
t.Error("Browse should return error when repository fails")
145
145
-
}
146
146
-
147
147
-
result := output.String()
148
148
-
if !strings.Contains(result, "Error:") {
149
149
-
t.Error("Output should contain error message")
150
150
-
}
151
151
-
})
152
152
-
153
153
-
t.Run("truncates long tag names", func(t *testing.T) {
154
154
-
tags := []repo.TagSummary{
155
155
-
{Name: "this-is-a-very-long-tag-name-that-should-be-truncated", TaskCount: 2},
156
156
-
}
157
157
-
mockRepo := &mockTagRepository{tags: tags}
158
158
-
output := &bytes.Buffer{}
159
159
-
opts := TagListOptions{Output: output, Static: true}
160
160
-
161
161
-
tagList := NewTagList(mockRepo, opts)
162
162
-
err := tagList.Browse(context.Background())
163
163
-
164
164
-
if err != nil {
165
165
-
t.Errorf("Browse should not return error: %v", err)
166
166
-
}
167
167
-
168
168
-
result := output.String()
169
169
-
if !strings.Contains(result, "...") {
170
170
-
t.Error("Output should truncate long tag names")
171
171
-
}
172
172
-
})
173
173
-
})
174
174
-
175
175
-
t.Run("TagListModel", func(t *testing.T) {
176
176
-
t.Run("initializes correctly", func(t *testing.T) {
177
177
-
model := tagListModel{
178
178
-
selected: 0,
179
179
-
showingHelp: false,
180
180
-
}
181
181
-
182
182
-
if model.selected != 0 {
183
183
-
t.Error("Initial selection should be 0")
184
184
-
}
185
185
-
if model.showingHelp {
186
186
-
t.Error("Should not be showing help initially")
187
187
-
}
188
188
-
})
189
189
-
190
190
-
t.Run("handles key navigation", func(t *testing.T) {
191
191
-
tags := []repo.TagSummary{
192
192
-
{Name: "tag1", TaskCount: 1},
193
193
-
{Name: "tag2", TaskCount: 2},
194
194
-
{Name: "tag3", TaskCount: 3},
195
195
-
}
196
196
-
197
197
-
model := tagListModel{
198
198
-
tags: tags,
199
199
-
selected: 1,
200
200
-
keys: tagKeys,
201
201
-
}
202
202
-
203
203
-
// Test down key
204
204
-
downMsg := tea.KeyMsg{Type: tea.KeyDown}
205
205
-
updatedModel, _ := model.Update(downMsg)
206
206
-
if updatedModel.(tagListModel).selected != 2 {
207
207
-
t.Error("Down key should move selection down")
208
208
-
}
209
209
-
210
210
-
// Test up key
211
211
-
upMsg := tea.KeyMsg{Type: tea.KeyUp}
212
212
-
updatedModel, _ = updatedModel.Update(upMsg)
213
213
-
if updatedModel.(tagListModel).selected != 1 {
214
214
-
t.Error("Up key should move selection up")
215
215
-
}
216
216
-
217
217
-
// Test boundary conditions
218
218
-
model.selected = 0
219
219
-
updatedModel, _ = model.Update(upMsg)
220
220
-
if updatedModel.(tagListModel).selected != 0 {
221
221
-
t.Error("Up key should not move selection below 0")
222
222
-
}
223
223
-
224
224
-
model.selected = len(tags) - 1
225
225
-
updatedModel, _ = model.Update(downMsg)
226
226
-
if updatedModel.(tagListModel).selected != len(tags)-1 {
227
227
-
t.Error("Down key should not move selection beyond list length")
228
228
-
}
229
229
-
})
230
230
-
231
231
-
t.Run("handles number key selection", func(t *testing.T) {
232
232
-
tags := []repo.TagSummary{
233
233
-
{Name: "tag1", TaskCount: 1},
234
234
-
{Name: "tag2", TaskCount: 2},
235
235
-
{Name: "tag3", TaskCount: 3},
236
236
-
}
237
237
-
238
238
-
model := tagListModel{
239
239
-
tags: tags,
240
240
-
selected: 0,
241
241
-
keys: tagKeys,
242
242
-
}
243
243
-
244
244
-
// Test number key 3 (index 2)
245
245
-
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'3'}}
246
246
-
updatedModel, _ := model.Update(keyMsg)
247
247
-
if updatedModel.(tagListModel).selected != 2 {
248
248
-
t.Error("Number key 3 should select index 2")
249
249
-
}
250
250
-
})
251
251
-
252
252
-
t.Run("handles help toggle", func(t *testing.T) {
253
253
-
model := tagListModel{
254
254
-
keys: tagKeys,
255
255
-
}
256
256
-
257
257
-
helpMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}
258
258
-
updatedModel, _ := model.Update(helpMsg)
259
259
-
if !updatedModel.(tagListModel).showingHelp {
260
260
-
t.Error("Help key should show help")
261
261
-
}
262
262
-
263
263
-
updatedModel, _ = updatedModel.Update(helpMsg)
264
264
-
if updatedModel.(tagListModel).showingHelp {
265
265
-
t.Error("Help key should hide help when already showing")
266
266
-
}
267
267
-
})
268
268
-
269
269
-
t.Run("handles tags loaded message", func(t *testing.T) {
270
270
-
tags := []repo.TagSummary{
271
271
-
{Name: "new-tag", TaskCount: 5},
272
272
-
}
273
273
-
274
274
-
model := tagListModel{
275
275
-
selected: 5, // Invalid selection
276
276
-
}
277
277
-
278
278
-
msg := tagsLoadedMsg(tags)
279
279
-
updatedModel, _ := model.Update(msg)
280
280
-
resultModel := updatedModel.(tagListModel)
281
281
-
282
282
-
if len(resultModel.tags) != 1 {
283
283
-
t.Error("Tags should be loaded correctly")
284
284
-
}
285
285
-
if resultModel.selected != 0 {
286
286
-
t.Error("Selection should be reset to valid range")
287
287
-
}
288
288
-
})
289
289
-
290
290
-
t.Run("handles error message", func(t *testing.T) {
291
291
-
model := tagListModel{}
292
292
-
293
293
-
errorMsg := errorTagMsg(fmt.Errorf("test error"))
294
294
-
updatedModel, _ := model.Update(errorMsg)
295
295
-
resultModel := updatedModel.(tagListModel)
296
296
-
297
297
-
if resultModel.err == nil {
298
298
-
t.Error("Error should be set")
299
299
-
}
300
300
-
if resultModel.err.Error() != "test error" {
301
301
-
t.Errorf("Expected 'test error', got '%s'", resultModel.err.Error())
302
302
-
}
303
303
-
})
304
304
-
305
305
-
t.Run("view renders correctly", func(t *testing.T) {
306
306
-
tags := []repo.TagSummary{
307
307
-
{Name: "frontend", TaskCount: 5},
308
308
-
{Name: "urgent", TaskCount: 1},
309
309
-
}
310
310
-
311
311
-
model := tagListModel{
312
312
-
tags: tags,
313
313
-
selected: 0,
314
314
-
keys: tagKeys,
315
315
-
}
316
316
-
317
317
-
view := model.View()
318
318
-
if !strings.Contains(view, "Tags") {
319
319
-
t.Error("View should contain title")
320
320
-
}
321
321
-
if !strings.Contains(view, "frontend") {
322
322
-
t.Error("View should contain tag names")
323
323
-
}
324
324
-
if !strings.Contains(view, "5 tasks") {
325
325
-
t.Error("View should show task counts")
326
326
-
}
327
327
-
if !strings.Contains(view, "1 task") {
328
328
-
t.Error("View should show singular task count")
329
329
-
}
330
330
-
})
331
331
-
332
332
-
t.Run("view handles empty state", func(t *testing.T) {
333
333
-
model := tagListModel{
334
334
-
tags: []repo.TagSummary{},
335
335
-
}
336
336
-
337
337
-
view := model.View()
338
338
-
if !strings.Contains(view, "No tags found") {
339
339
-
t.Error("View should show empty state message")
340
340
-
}
341
341
-
})
342
342
-
343
343
-
t.Run("view handles error state", func(t *testing.T) {
344
344
-
model := tagListModel{
345
345
-
err: fmt.Errorf("test error"),
346
346
-
}
347
347
-
348
348
-
view := model.View()
349
349
-
if !strings.Contains(view, "Error:") {
350
350
-
t.Error("View should show error message")
351
351
-
}
352
352
-
})
353
353
-
})
354
354
-
}