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