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
feat: update task listing with project & tag lists
desertthunder.dev
5 months ago
53dc5ebf
360ce61f
+2960
-289
17 changed files
expand all
collapse all
unified
split
ROADMAP.md
cmd
commands.go
go.mod
internal
handlers
tasks.go
tasks_test.go
repo
task_repository.go
task_repository_test.go
ui
project_list.go
project_list_test.go
tag_list.go
tag_list_test.go
task_list.go
task_list_test.go
task_view.go
task_view_test.go
utils
utils.go
utils_test.go
+131
-32
ROADMAP.md
···
2
2
3
3
## Core Task Management (TaskWarrior-inspired)
4
4
5
5
-
- [x] `list` - Display tasks with filtering and sorting options
6
6
-
- [ ] `projects` - List all project names
7
7
-
- [ ] `tags` - List all tag names
5
5
+
### Basic Operations
8
6
9
7
- [x] `create|new` - Add new task with description and optional metadata
10
10
-
8
8
+
- [x] `list` - Display tasks with filtering and sorting options
11
9
- [x] `view` - View task by ID
10
10
+
- [x] `update` - Edit task properties (description, priority, project, tags)
12
11
- [x] `done` - Mark task as completed
13
13
-
- [x] `update` - Edit task properties (description, priority, project, tags)
14
14
-
- [ ] `start/stop` - Track active time on tasks
12
12
+
- [x] `delete` - Remove task permanently
13
13
+
14
14
+
### Organization & Metadata
15
15
+
16
16
+
- [x] Project & context organization
17
17
+
- [x] `projects` - List all project names
18
18
+
- [x] Tag management system
19
19
+
- [x] `tags` - List all tag names
20
20
+
- [ ] Status tracking - todo, in-progress, blocked, done, abandoned
21
21
+
- [ ] Priority system - High/medium/low or numeric scales
22
22
+
- [ ] Due dates & scheduling - Including recurring tasks
23
23
+
- [ ] Task dependencies - Task A blocks task B relationships
24
24
+
25
25
+
### Time Management
26
26
+
27
27
+
- [ ] Time tracking functionality
28
28
+
- [ ] `start/stop` - Track active time on tasks
29
29
+
- [ ] `timesheet` - Show time tracking summaries
30
30
+
- [ ] `calendar` - Display tasks in calendar view
31
31
+
32
32
+
### Advanced Task Features
33
33
+
15
34
- [ ] `annotate` - Add notes/comments to existing tasks
35
35
+
- [ ] Recurring tasks
36
36
+
- [ ] Smart due date suggestions
37
37
+
- [ ] Completion notifications
16
38
17
17
-
- [x] `delete` - Remove task permanently
39
39
+
## Content Queue Management
18
40
19
19
-
- [ ] `calendar` - Display tasks in calendar view
20
20
-
- [ ] `timesheet` - Show time tracking summaries
41
41
+
### Reading Management
21
42
22
22
-
## Todo.txt Compatibility
43
43
+
- [x] `book add` - Add book to reading list
44
44
+
- [x] `book list` - Show reading queue with progress
45
45
+
- [x] `book reading` - Mark book as currently reading
46
46
+
- [x] `book finished|read` - Mark book as completed
47
47
+
- [x] `book remove|rm` - Remove from reading list
48
48
+
- [x] `book progress` - Update reading progress percentage
23
49
24
24
-
- [ ] `archive` - Move completed tasks to done.txt
25
25
-
- [ ] `[con]texts` - List all contexts (@context)
26
26
-
- [ ] `[proj]ects` - List all projects (+project)
27
27
-
- [ ] `[pri]ority` - Set task priority (A-Z)
28
28
-
- [ ] `[depri]oritize` - Remove priority from task
29
29
-
- [ ] `[re]place` - Replace task text entirely
30
30
-
- [ ] `prepend/append` - Add text to beginning/end of task
50
50
+
#### Enhanced Reading Features
51
51
+
52
52
+
- [ ] Articles, papers, blogs support (implement article parser)
53
53
+
- [ ] Reading status: want-to-read, currently-reading, completed, abandoned
54
54
+
- [ ] Source tracking (recommendation sources)
55
55
+
- [ ] Ratings and personal notes
56
56
+
- [ ] Genre/topic tagging
57
57
+
- [ ] Progress tracking (pages/chapters read, completion %)
31
58
32
32
-
## Media Queue Management
59
59
+
### Watching Management
33
60
34
61
- [ ] `movie add` - Add movie to watch queue
35
62
- [ ] `movie list` - Show movie queue with ratings/metadata
···
41
68
- [ ] `tv watched|seen` - Mark episodes/seasons as watched
42
69
- [ ] `tv remove|rm` - Remove from TV queue
43
70
44
44
-
## Reading List Management
71
71
+
#### Enhanced Watching Features
72
72
+
73
73
+
- [ ] Episode/season progress tracking
74
74
+
- [ ] Watch status: queued, watching, completed, dropped
75
75
+
- [ ] Platform tracking (Netflix, Amazon, etc.)
76
76
+
- [ ] Ratings and reviews
77
77
+
- [ ] Genre/mood tagging
78
78
+
79
79
+
## Organization & Discovery Features
80
80
+
81
81
+
### Smart Views & Filtering
82
82
+
83
83
+
- [ ] Custom queries and saved searches
84
84
+
- [ ] Context-aware suggestions
85
85
+
- [ ] Overdue/urgent highlighting
86
86
+
- [ ] Recently added/modified items
87
87
+
- [ ] Seasonal/mood-based filtering
88
88
+
- [ ] Full-text search across titles, notes, tags
89
89
+
90
90
+
### Analytics & Insights
91
91
+
92
92
+
- [ ] Reading/watching velocity tracking
93
93
+
- [ ] Completion rates by content type
94
94
+
- [ ] Time investment analysis
95
95
+
- [ ] Personal productivity metrics
96
96
+
- [ ] Content source analysis
97
97
+
98
98
+
## Advanced Workflow Features
99
99
+
100
100
+
### Integration & Import
101
101
+
102
102
+
- [ ] `import` - Import from various formats (CSV, JSON, todo.txt)
103
103
+
- [ ] `export` - Export to various formats
104
104
+
- [ ] Goodreads import for books
105
105
+
- [ ] IMDB/Letterboxd import for movies
106
106
+
- [ ] Todo.txt format compatibility
107
107
+
- [ ] TaskWarrior import/export
108
108
+
- [ ] URL parsing for automatic metadata
109
109
+
110
110
+
### Todo.txt Compatibility
45
111
46
46
-
- [x] `book add` - Add book to reading list
47
47
-
- [x] `book list` - Show reading queue with progress
48
48
-
- [x] `book reading` - Mark book as currently reading
49
49
-
- [x] `book finished|read` - Mark book as completed
50
50
-
- [x] `book remove|rm` - Remove from reading list
51
51
-
- [x] `book progress` - Update reading progress percentage
112
112
+
- [ ] `archive` - Move completed tasks to done.txt
113
113
+
- [ ] `[con]texts` - List all contexts (@context)
114
114
+
- [ ] `[proj]ects` - List all projects (+project)
115
115
+
- [ ] `[pri]ority` - Set task priority (A-Z)
116
116
+
- [ ] `[depri]oritize` - Remove priority from task
117
117
+
- [ ] `[re]place` - Replace task text entirely
118
118
+
- [ ] `prepend/append` - Add text to beginning/end of task
119
119
+
120
120
+
### Automation
121
121
+
122
122
+
- [ ] Auto-categorization of new items
123
123
+
- [ ] Smart due date suggestions
124
124
+
- [ ] Recurring content (weekly podcast check-ins)
125
125
+
- [ ] Completion notifications
52
126
53
127
## Data Management
128
128
+
129
129
+
### Storage & Sync
54
130
55
131
- [ ] `sync` - Synchronize with remote storage
56
132
- [ ] `sync setup` - Setup remote storage
57
57
-
133
133
+
- [ ] Local SQLite database with optional cloud sync
134
134
+
- [ ] Multiple profile support
58
135
- [ ] `backup` - Create local backup
136
136
+
- [ ] Backup/restore functionality
59
137
60
60
-
- [ ] `import` - Import from various formats (CSV, JSON, todo.txt)
61
61
-
- [ ] `export` - Export to various formats
138
138
+
### Configuration
62
139
63
140
- [ ] `config` - Manage configuration settings
64
64
-
65
141
- [ ] `undo` - Reverse last operation
142
142
+
- [ ] Themes and personalization
143
143
+
- [ ] Customizable output formats
66
144
67
67
-
## Notes
145
145
+
## Notes Management
146
146
+
147
147
+
### Basic Operations
68
148
69
149
- [x] `create|new` - Creates a new markdown note and optionally opens in configured editor
70
70
-
- Creates a note from existing markdown file content
71
150
- [x] `list` - Opens interactive TUI browser for navigating and viewing notes
72
151
- [x] `read|view` - Displays formatted note content with syntax highlighting
73
73
-
- [x] `edit|update` - Opens configured editor OR Replaces note content with new markdown file
152
152
+
- [x] `edit|update` - Opens configured editor OR replaces note content with new markdown file
74
153
- [x] `remove|rm|delete|del` - Permanently removes the note file and metadata
75
154
155
155
+
### Advanced Notes Features
156
156
+
76
157
- [ ] `search` - Search notes by content, title, or tags
77
158
- [ ] `tag` - Add/remove tags from notes
78
159
- [ ] `recent` - Show recently created/modified notes
79
160
- [ ] `templates` - Create notes from predefined templates
80
161
- [ ] `archive` - Archive old notes
81
162
- [ ] `export` - Export notes to various formats
163
163
+
- [ ] Full-text search integration
164
164
+
- [ ] Linking between notes and tasks/content
165
165
+
166
166
+
## User Experience
167
167
+
168
168
+
### Interface
169
169
+
170
170
+
- [ ] Interactive TUI mode for browsing (likely using Bubbletea)
171
171
+
- [ ] Quick-add commands for rapid entry
172
172
+
- [ ] Progress tracking UI
173
173
+
- [ ] Comprehensive help system
174
174
+
175
175
+
### Technical Infrastructure
176
176
+
177
177
+
- [ ] CI/CD pipeline -> pre-build binaries
178
178
+
- [ ] Complete README/documentation
179
179
+
- [ ] Installation instructions
180
180
+
- [ ] Usage examples
+3
-5
cmd/commands.go
···
69
69
status, _ := cmd.Flags().GetString("status")
70
70
priority, _ := cmd.Flags().GetString("priority")
71
71
project, _ := cmd.Flags().GetString("project")
72
72
-
72
72
+
73
73
return handlers.ListTasks(cmd.Context(), static, showAll, status, priority, project)
74
74
},
75
75
}
···
113
113
Short: "List projects",
114
114
Aliases: []string{"proj"},
115
115
RunE: func(cmd *cobra.Command, args []string) error {
116
116
-
fmt.Println("Listing projects...")
117
117
-
return nil
116
116
+
return handlers.ListProjects(cmd.Context(), args)
118
117
},
119
118
})
120
119
···
123
122
Short: "List tags",
124
123
Aliases: []string{"t"},
125
124
RunE: func(cmd *cobra.Command, args []string) error {
126
126
-
fmt.Println("Listing tags...")
127
127
-
return nil
125
125
+
return handlers.ListTags(cmd.Context(), args)
128
126
},
129
127
})
130
128
+1
-1
go.mod
···
67
67
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
68
68
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
69
69
golang.org/x/sys v0.33.0 // indirect
70
70
-
golang.org/x/text v0.24.0 // indirect
70
70
+
golang.org/x/text v0.24.0
71
71
)
+40
-5
internal/handlers/tasks.go
···
154
154
return fmt.Errorf("task ID required")
155
155
}
156
156
157
157
-
// Parse task ID (could be numeric ID or UUID)
158
157
taskID := args[0]
159
158
var task *models.Task
160
159
var err error
161
160
162
162
-
if id, parseErr := strconv.ParseInt(taskID, 10, 64); parseErr == nil {
163
163
-
// Numeric ID
161
161
+
if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil {
164
162
task, err = h.repos.Tasks.Get(ctx, id)
165
163
} else {
166
166
-
// Assume UUID
167
164
task, err = h.repos.Tasks.GetByUUID(ctx, taskID)
168
165
}
169
166
···
171
168
return fmt.Errorf("failed to find task: %w", err)
172
169
}
173
170
174
174
-
// Parse update arguments
175
171
for i := 1; i < len(args); i++ {
176
172
arg := args[i]
177
173
switch {
···
338
334
return nil
339
335
}
340
336
337
337
+
// ListProjects lists all projects with their task counts
338
338
+
func ListProjects(ctx context.Context, args []string) error {
339
339
+
handler, err := NewTaskHandler()
340
340
+
if err != nil {
341
341
+
return fmt.Errorf("failed to initialize task handler: %w", err)
342
342
+
}
343
343
+
defer handler.Close()
344
344
+
345
345
+
return handler.listProjects(ctx)
346
346
+
}
347
347
+
348
348
+
func (h *TaskHandler) listProjects(ctx context.Context) error {
349
349
+
projectList := ui.NewProjectList(h.repos.Tasks, ui.ProjectListOptions{})
350
350
+
return projectList.Browse(ctx)
351
351
+
}
352
352
+
353
353
+
// ListTags lists all tags with their task counts
354
354
+
func ListTags(ctx context.Context, args []string) error {
355
355
+
handler, err := NewTaskHandler()
356
356
+
if err != nil {
357
357
+
return fmt.Errorf("failed to initialize task handler: %w", err)
358
358
+
}
359
359
+
defer handler.Close()
360
360
+
361
361
+
return handler.listTags(ctx)
362
362
+
}
363
363
+
364
364
+
func (h *TaskHandler) listTags(ctx context.Context) error {
365
365
+
tagList := ui.NewTagList(h.repos.Tasks, ui.TagListOptions{})
366
366
+
return tagList.Browse(ctx)
367
367
+
}
368
368
+
341
369
func (h *TaskHandler) printTask(task *models.Task) {
342
370
fmt.Printf("[%d] %s", task.ID, task.Description)
343
371
···
414
442
}
415
443
return result
416
444
}
445
445
+
446
446
+
func pluralize(count int) string {
447
447
+
if count == 1 {
448
448
+
return ""
449
449
+
}
450
450
+
return "s"
451
451
+
}
+114
-1
internal/handlers/tasks_test.go
···
665
665
})
666
666
667
667
t.Run("Helper", func(t *testing.T) {
668
668
-
669
668
t.Run("removeString function", func(t *testing.T) {
670
669
slice := []string{"a", "b", "c", "b"}
671
670
result := removeString(slice, "b")
···
728
727
}()
729
728
730
729
handler.printTaskDetail(task)
730
730
+
})
731
731
+
})
732
732
+
733
733
+
t.Run("ListProjects", func(t *testing.T) {
734
734
+
_, cleanup := setupTaskTest(t)
735
735
+
defer cleanup()
736
736
+
737
737
+
ctx := context.Background()
738
738
+
739
739
+
handler, err := NewTaskHandler()
740
740
+
if err != nil {
741
741
+
t.Fatalf("Failed to create handler: %v", err)
742
742
+
}
743
743
+
defer handler.Close()
744
744
+
745
745
+
tasks := []*models.Task{
746
746
+
{UUID: uuid.New().String(), Description: "Task 1", Status: "pending", Project: "web-app"},
747
747
+
{UUID: uuid.New().String(), Description: "Task 2", Status: "completed", Project: "web-app"},
748
748
+
{UUID: uuid.New().String(), Description: "Task 3", Status: "pending", Project: "mobile-app"},
749
749
+
{UUID: uuid.New().String(), Description: "Task 4", Status: "pending", Project: ""},
750
750
+
}
751
751
+
752
752
+
for _, task := range tasks {
753
753
+
_, err := handler.repos.Tasks.Create(ctx, task)
754
754
+
if err != nil {
755
755
+
t.Fatalf("Failed to create task: %v", err)
756
756
+
}
757
757
+
}
758
758
+
759
759
+
t.Run("lists projects successfully", func(t *testing.T) {
760
760
+
err := ListProjects(ctx, []string{})
761
761
+
if err != nil {
762
762
+
t.Errorf("ListProjects failed: %v", err)
763
763
+
}
764
764
+
})
765
765
+
766
766
+
t.Run("returns no projects when none exist", func(t *testing.T) {
767
767
+
_, cleanup2 := setupTaskTest(t)
768
768
+
defer cleanup2()
769
769
+
770
770
+
err := ListProjects(ctx, []string{})
771
771
+
if err != nil {
772
772
+
t.Errorf("ListProjects with no projects failed: %v", err)
773
773
+
}
774
774
+
})
775
775
+
})
776
776
+
777
777
+
t.Run("ListTags", func(t *testing.T) {
778
778
+
_, cleanup := setupTaskTest(t)
779
779
+
defer cleanup()
780
780
+
781
781
+
ctx := context.Background()
782
782
+
783
783
+
handler, err := NewTaskHandler()
784
784
+
if err != nil {
785
785
+
t.Fatalf("Failed to create handler: %v", err)
786
786
+
}
787
787
+
defer handler.Close()
788
788
+
789
789
+
tasks := []*models.Task{
790
790
+
{UUID: uuid.New().String(), Description: "Task 1", Status: "pending", Tags: []string{"frontend", "urgent"}},
791
791
+
{UUID: uuid.New().String(), Description: "Task 2", Status: "completed", Tags: []string{"backend", "database"}},
792
792
+
{UUID: uuid.New().String(), Description: "Task 3", Status: "pending", Tags: []string{"frontend", "ios"}},
793
793
+
{UUID: uuid.New().String(), Description: "Task 4", Status: "pending", Tags: []string{}},
794
794
+
}
795
795
+
796
796
+
for _, task := range tasks {
797
797
+
_, err := handler.repos.Tasks.Create(ctx, task)
798
798
+
if err != nil {
799
799
+
t.Fatalf("Failed to create task: %v", err)
800
800
+
}
801
801
+
}
802
802
+
803
803
+
t.Run("lists tags successfully", func(t *testing.T) {
804
804
+
err := ListTags(ctx, []string{})
805
805
+
if err != nil {
806
806
+
t.Errorf("ListTags failed: %v", err)
807
807
+
}
808
808
+
})
809
809
+
810
810
+
t.Run("returns no tags when none exist", func(t *testing.T) {
811
811
+
_, cleanup2 := setupTaskTest(t)
812
812
+
defer cleanup2()
813
813
+
814
814
+
err := ListTags(ctx, []string{})
815
815
+
if err != nil {
816
816
+
t.Errorf("ListTags with no tags failed: %v", err)
817
817
+
}
818
818
+
})
819
819
+
})
820
820
+
821
821
+
t.Run("Pluralize", func(t *testing.T) {
822
822
+
t.Run("returns empty string for singular", func(t *testing.T) {
823
823
+
result := pluralize(1)
824
824
+
if result != "" {
825
825
+
t.Errorf("Expected empty string for 1, got '%s'", result)
826
826
+
}
827
827
+
})
828
828
+
829
829
+
t.Run("returns 's' for plural", func(t *testing.T) {
830
830
+
result := pluralize(0)
831
831
+
if result != "s" {
832
832
+
t.Errorf("Expected 's' for 0, got '%s'", result)
833
833
+
}
834
834
+
835
835
+
result = pluralize(2)
836
836
+
if result != "s" {
837
837
+
t.Errorf("Expected 's' for 2, got '%s'", result)
838
838
+
}
839
839
+
840
840
+
result = pluralize(10)
841
841
+
if result != "s" {
842
842
+
t.Errorf("Expected 's' for 10, got '%s'", result)
843
843
+
}
731
844
})
732
845
})
733
846
}
+108
-20
internal/repo/task_repository.go
···
10
10
"github.com/stormlightlabs/noteleaf/internal/models"
11
11
)
12
12
13
13
+
// TaskListOptions defines options for listing tasks
14
14
+
type TaskListOptions struct {
15
15
+
Status string
16
16
+
Priority string
17
17
+
Project string
18
18
+
DueAfter time.Time
19
19
+
DueBefore time.Time
20
20
+
Search string
21
21
+
SortBy string
22
22
+
SortOrder string
23
23
+
Limit int
24
24
+
Offset int
25
25
+
}
26
26
+
27
27
+
// ProjectSummary represents a project with its task count
28
28
+
type ProjectSummary struct {
29
29
+
Name string `json:"name"`
30
30
+
TaskCount int `json:"task_count"`
31
31
+
}
32
32
+
33
33
+
// TagSummary represents a tag with its task count
34
34
+
type TagSummary struct {
35
35
+
Name string `json:"name"`
36
36
+
TaskCount int `json:"task_count"`
37
37
+
}
38
38
+
13
39
// TaskRepository provides database operations for tasks
14
40
type TaskRepository struct {
15
41
db *sql.DB
···
222
248
args = append(args, opts.DueBefore)
223
249
}
224
250
225
225
-
// Search args
226
251
if opts.Search != "" {
227
252
searchPattern := "%" + opts.Search + "%"
228
228
-
// Add search pattern for each search field
229
253
args = append(args, searchPattern, searchPattern, searchPattern)
230
254
}
231
255
···
235
259
func (r *TaskRepository) scanTaskRow(rows *sql.Rows, task *models.Task) error {
236
260
var tags, annotations sql.NullString
237
261
238
238
-
err := rows.Scan(&task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority,
239
239
-
&task.Project, &tags, &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations)
240
240
-
if err != nil {
262
262
+
if err := rows.Scan(&task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority,
263
263
+
&task.Project, &tags, &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations); err != nil {
241
264
return fmt.Errorf("failed to scan task row: %w", err)
242
265
}
243
266
···
322
345
task := &models.Task{}
323
346
var tags, annotations sql.NullString
324
347
325
325
-
err := r.db.QueryRowContext(ctx, query, uuid).Scan(
348
348
+
if err := r.db.QueryRowContext(ctx, query, uuid).Scan(
326
349
&task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority, &task.Project,
327
327
-
&tags, &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations)
328
328
-
if err != nil {
350
350
+
&tags, &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations); err != nil {
329
351
return nil, fmt.Errorf("failed to get task by UUID: %w", err)
330
352
}
331
353
···
359
381
return r.List(ctx, TaskListOptions{Project: project})
360
382
}
361
383
362
362
-
// TaskListOptions defines options for listing tasks
363
363
-
type TaskListOptions struct {
364
364
-
Status string
365
365
-
Priority string
366
366
-
Project string
367
367
-
DueAfter time.Time
368
368
-
DueBefore time.Time
369
369
-
Search string
370
370
-
SortBy string
371
371
-
SortOrder string
372
372
-
Limit int
373
373
-
Offset int
384
384
+
// GetProjects retrieves all unique project names with their task counts
385
385
+
func (r *TaskRepository) GetProjects(ctx context.Context) ([]ProjectSummary, error) {
386
386
+
query := `
387
387
+
SELECT project, COUNT(*) as task_count
388
388
+
FROM tasks
389
389
+
WHERE project != '' AND project IS NOT NULL
390
390
+
GROUP BY project
391
391
+
ORDER BY project`
392
392
+
393
393
+
rows, err := r.db.QueryContext(ctx, query)
394
394
+
if err != nil {
395
395
+
return nil, fmt.Errorf("failed to get projects: %w", err)
396
396
+
}
397
397
+
defer rows.Close()
398
398
+
399
399
+
var projects []ProjectSummary
400
400
+
for rows.Next() {
401
401
+
var project ProjectSummary
402
402
+
if err := rows.Scan(&project.Name, &project.TaskCount); err != nil {
403
403
+
return nil, fmt.Errorf("failed to scan project row: %w", err)
404
404
+
}
405
405
+
projects = append(projects, project)
406
406
+
}
407
407
+
408
408
+
return projects, rows.Err()
409
409
+
}
410
410
+
411
411
+
// GetTags retrieves all unique tags with their task counts
412
412
+
func (r *TaskRepository) GetTags(ctx context.Context) ([]TagSummary, error) {
413
413
+
query := `
414
414
+
SELECT DISTINCT json_each.value as tag, COUNT(tasks.id) as task_count
415
415
+
FROM tasks, json_each(tasks.tags)
416
416
+
WHERE tasks.tags != '' AND tasks.tags IS NOT NULL
417
417
+
GROUP BY tag
418
418
+
ORDER BY tag`
419
419
+
420
420
+
rows, err := r.db.QueryContext(ctx, query)
421
421
+
if err != nil {
422
422
+
return nil, fmt.Errorf("failed to get tags: %w", err)
423
423
+
}
424
424
+
defer rows.Close()
425
425
+
426
426
+
var tags []TagSummary
427
427
+
for rows.Next() {
428
428
+
var tag TagSummary
429
429
+
if err := rows.Scan(&tag.Name, &tag.TaskCount); err != nil {
430
430
+
return nil, fmt.Errorf("failed to scan tag row: %w", err)
431
431
+
}
432
432
+
tags = append(tags, tag)
433
433
+
}
434
434
+
435
435
+
return tags, rows.Err()
436
436
+
}
437
437
+
438
438
+
// GetTasksByTag retrieves all tasks with a specific tag
439
439
+
func (r *TaskRepository) GetTasksByTag(ctx context.Context, tag string) ([]*models.Task, error) {
440
440
+
query := `
441
441
+
SELECT tasks.id, tasks.uuid, tasks.description, tasks.status, tasks.priority, tasks.project, tasks.tags, tasks.due, tasks.entry, tasks.modified, tasks.end, tasks.start, tasks.annotations
442
442
+
FROM tasks, json_each(tasks.tags)
443
443
+
WHERE tasks.tags != '' AND tasks.tags IS NOT NULL AND json_each.value = ?
444
444
+
ORDER BY tasks.modified DESC`
445
445
+
446
446
+
rows, err := r.db.QueryContext(ctx, query, tag)
447
447
+
if err != nil {
448
448
+
return nil, fmt.Errorf("failed to get tasks by tag: %w", err)
449
449
+
}
450
450
+
defer rows.Close()
451
451
+
452
452
+
var tasks []*models.Task
453
453
+
for rows.Next() {
454
454
+
task := &models.Task{}
455
455
+
if err := r.scanTaskRow(rows, task); err != nil {
456
456
+
return nil, err
457
457
+
}
458
458
+
tasks = append(tasks, task)
459
459
+
}
460
460
+
461
461
+
return tasks, rows.Err()
374
462
}
+271
-152
internal/repo/task_repository_test.go
···
3
3
import (
4
4
"context"
5
5
"database/sql"
6
6
+
"slices"
6
7
"testing"
7
8
"time"
8
9
···
52
53
53
54
func createSampleTask() *models.Task {
54
55
return &models.Task{
55
55
-
UUID: uuid.New().String(),
56
56
+
UUID: newUUID(),
56
57
Description: "Test task",
57
58
Status: "pending",
58
59
Priority: "H",
···
62
63
}
63
64
}
64
65
65
65
-
func TestTaskRepository_CRUD(t *testing.T) {
66
66
+
func newUUID() string {
67
67
+
return uuid.New().String()
68
68
+
}
69
69
+
70
70
+
func TestTaskRepository(t *testing.T) {
66
71
db := createTaskTestDB(t)
67
72
repo := NewTaskRepository(db)
68
73
ctx := context.Background()
···
187
192
t.Error("Expected error when getting deleted task")
188
193
}
189
194
})
190
190
-
}
191
195
192
192
-
func TestTaskRepository_List(t *testing.T) {
193
193
-
db := createTaskTestDB(t)
194
194
-
repo := NewTaskRepository(db)
195
195
-
ctx := context.Background()
196
196
-
197
197
-
tasks := []*models.Task{
198
198
-
{UUID: uuid.New().String(), Description: "Task 1", Status: "pending", Project: "proj1"},
199
199
-
{UUID: uuid.New().String(), Description: "Task 2", Status: "completed", Project: "proj1"},
200
200
-
{UUID: uuid.New().String(), Description: "Task 3", Status: "pending", Project: "proj2"},
201
201
-
}
202
202
-
203
203
-
for _, task := range tasks {
204
204
-
_, err := repo.Create(ctx, task)
205
205
-
if err != nil {
206
206
-
t.Fatalf("Failed to create task: %v", err)
196
196
+
t.Run("List", func(t *testing.T) {
197
197
+
tasks := []*models.Task{
198
198
+
{UUID: newUUID(), Description: "Task 1", Status: "pending", Project: "proj1"},
199
199
+
{UUID: newUUID(), Description: "Task 2", Status: "completed", Project: "proj1"},
200
200
+
{UUID: newUUID(), Description: "Task 3", Status: "pending", Project: "proj2"},
207
201
}
208
208
-
}
209
202
210
210
-
t.Run("List All Tasks", func(t *testing.T) {
211
211
-
results, err := repo.List(ctx, TaskListOptions{})
212
212
-
if err != nil {
213
213
-
t.Errorf("Failed to list tasks: %v", err)
203
203
+
for _, task := range tasks {
204
204
+
_, err := repo.Create(ctx, task)
205
205
+
if err != nil {
206
206
+
t.Fatalf("Failed to create task: %v", err)
207
207
+
}
214
208
}
215
209
216
216
-
if len(results) != 3 {
217
217
-
t.Errorf("Expected 3 tasks, got %d", len(results))
218
218
-
}
219
219
-
})
210
210
+
t.Run("List All Tasks", func(t *testing.T) {
211
211
+
results, err := repo.List(ctx, TaskListOptions{})
212
212
+
if err != nil {
213
213
+
t.Errorf("Failed to list tasks: %v", err)
214
214
+
}
220
215
221
221
-
t.Run("List Tasks with Filter", func(t *testing.T) {
222
222
-
results, err := repo.List(ctx, TaskListOptions{Status: "pending"})
223
223
-
if err != nil {
224
224
-
t.Errorf("Failed to list tasks: %v", err)
225
225
-
}
216
216
+
if len(results) < 3 {
217
217
+
t.Errorf("Expected at least 3 tasks, got %d", len(results))
218
218
+
}
219
219
+
})
220
220
+
221
221
+
t.Run("List Tasks with Filter", func(t *testing.T) {
222
222
+
results, err := repo.List(ctx, TaskListOptions{Status: "pending"})
223
223
+
if err != nil {
224
224
+
t.Errorf("Failed to list tasks: %v", err)
225
225
+
}
226
226
+
227
227
+
if len(results) < 2 {
228
228
+
t.Errorf("Expected at least 2 pending tasks, got %d", len(results))
229
229
+
}
230
230
+
231
231
+
for _, task := range results {
232
232
+
if task.Status != "pending" {
233
233
+
t.Errorf("Expected pending status, got %s", task.Status)
234
234
+
}
235
235
+
}
236
236
+
})
237
237
+
238
238
+
t.Run("List Tasks with Limit", func(t *testing.T) {
239
239
+
results, err := repo.List(ctx, TaskListOptions{Limit: 2})
240
240
+
if err != nil {
241
241
+
t.Errorf("Failed to list tasks: %v", err)
242
242
+
}
226
243
227
227
-
if len(results) != 2 {
228
228
-
t.Errorf("Expected 2 pending tasks, got %d", len(results))
229
229
-
}
244
244
+
if len(results) != 2 {
245
245
+
t.Errorf("Expected 2 tasks due to limit, got %d", len(results))
246
246
+
}
247
247
+
})
230
248
231
231
-
for _, task := range results {
232
232
-
if task.Status != "pending" {
233
233
-
t.Errorf("Expected pending status, got %s", task.Status)
249
249
+
t.Run("List Tasks with Search", func(t *testing.T) {
250
250
+
results, err := repo.List(ctx, TaskListOptions{Search: "Task 1"})
251
251
+
if err != nil {
252
252
+
t.Errorf("Failed to list tasks: %v", err)
234
253
}
235
235
-
}
254
254
+
255
255
+
if len(results) != 1 {
256
256
+
t.Errorf("Expected 1 task matching search, got %d", len(results))
257
257
+
}
258
258
+
259
259
+
if len(results) > 0 && results[0].Description != "Task 1" {
260
260
+
t.Errorf("Expected 'Task 1', got %s", results[0].Description)
261
261
+
}
262
262
+
})
236
263
})
237
264
238
238
-
t.Run("List Tasks with Limit", func(t *testing.T) {
239
239
-
results, err := repo.List(ctx, TaskListOptions{Limit: 2})
240
240
-
if err != nil {
241
241
-
t.Errorf("Failed to list tasks: %v", err)
265
265
+
t.Run("Special Methods", func(t *testing.T) {
266
266
+
task1 := &models.Task{UUID: newUUID(), Description: "Pending task", Status: "pending", Project: "test"}
267
267
+
task2 := &models.Task{UUID: newUUID(), Description: "Completed task", Status: "completed", Project: "test"}
268
268
+
task3 := &models.Task{UUID: newUUID(), Description: "Other project", Status: "pending", Project: "other"}
269
269
+
270
270
+
for _, task := range []*models.Task{task1, task2, task3} {
271
271
+
_, err := repo.Create(ctx, task)
272
272
+
if err != nil {
273
273
+
t.Fatalf("Failed to create task: %v", err)
274
274
+
}
242
275
}
243
276
244
244
-
if len(results) != 2 {
245
245
-
t.Errorf("Expected 2 tasks due to limit, got %d", len(results))
246
246
-
}
277
277
+
t.Run("GetPending", func(t *testing.T) {
278
278
+
results, err := repo.GetPending(ctx)
279
279
+
if err != nil {
280
280
+
t.Errorf("Failed to get pending tasks: %v", err)
281
281
+
}
282
282
+
283
283
+
if len(results) < 2 {
284
284
+
t.Errorf("Expected at least 2 pending tasks, got %d", len(results))
285
285
+
}
286
286
+
})
287
287
+
288
288
+
t.Run("GetCompleted", func(t *testing.T) {
289
289
+
results, err := repo.GetCompleted(ctx)
290
290
+
if err != nil {
291
291
+
t.Errorf("Failed to get completed tasks: %v", err)
292
292
+
}
293
293
+
294
294
+
if len(results) < 1 {
295
295
+
t.Errorf("Expected at least 1 completed task, got %d", len(results))
296
296
+
}
297
297
+
})
298
298
+
299
299
+
t.Run("GetByProject", func(t *testing.T) {
300
300
+
results, err := repo.GetByProject(ctx, "test")
301
301
+
if err != nil {
302
302
+
t.Errorf("Failed to get tasks by project: %v", err)
303
303
+
}
304
304
+
305
305
+
if len(results) < 2 {
306
306
+
t.Errorf("Expected at least 2 tasks in test project, got %d", len(results))
307
307
+
}
308
308
+
309
309
+
for _, task := range results {
310
310
+
if task.Project != "test" {
311
311
+
t.Errorf("Expected project 'test', got %s", task.Project)
312
312
+
}
313
313
+
}
314
314
+
})
315
315
+
316
316
+
t.Run("GetByUUID", func(t *testing.T) {
317
317
+
result, err := repo.GetByUUID(ctx, task1.UUID)
318
318
+
if err != nil {
319
319
+
t.Errorf("Failed to get task by UUID: %v", err)
320
320
+
}
321
321
+
322
322
+
if result.UUID != task1.UUID {
323
323
+
t.Errorf("Expected UUID %s, got %s", task1.UUID, result.UUID)
324
324
+
}
325
325
+
if result.Description != task1.Description {
326
326
+
t.Errorf("Expected description %s, got %s", task1.Description, result.Description)
327
327
+
}
328
328
+
})
247
329
})
248
330
249
249
-
t.Run("List Tasks with Search", func(t *testing.T) {
250
250
-
results, err := repo.List(ctx, TaskListOptions{Search: "Task 1"})
251
251
-
if err != nil {
252
252
-
t.Errorf("Failed to list tasks: %v", err)
331
331
+
t.Run("Count", func(t *testing.T) {
332
332
+
tasks := []*models.Task{
333
333
+
{UUID: newUUID(), Description: "Test 1", Status: "pending"},
334
334
+
{UUID: newUUID(), Description: "Test 2", Status: "pending"},
335
335
+
{UUID: newUUID(), Description: "Test 3", Status: "completed"},
253
336
}
254
337
255
255
-
if len(results) != 1 {
256
256
-
t.Errorf("Expected 1 task matching search, got %d", len(results))
338
338
+
for _, task := range tasks {
339
339
+
_, err := repo.Create(ctx, task)
340
340
+
if err != nil {
341
341
+
t.Fatalf("Failed to create task: %v", err)
342
342
+
}
257
343
}
258
344
259
259
-
if len(results) > 0 && results[0].Description != "Task 1" {
260
260
-
t.Errorf("Expected 'Task 1', got %s", results[0].Description)
261
261
-
}
262
262
-
})
263
263
-
}
345
345
+
t.Run("Count all tasks", func(t *testing.T) {
346
346
+
count, err := repo.Count(ctx, TaskListOptions{})
347
347
+
if err != nil {
348
348
+
t.Errorf("Failed to count tasks: %v", err)
349
349
+
}
264
350
265
265
-
func TestTaskRepository_SpecialMethods(t *testing.T) {
266
266
-
db := createTaskTestDB(t)
267
267
-
repo := NewTaskRepository(db)
268
268
-
ctx := context.Background()
351
351
+
if count < 3 {
352
352
+
t.Errorf("Expected at least 3 tasks, got %d", count)
353
353
+
}
354
354
+
})
269
355
270
270
-
task1 := &models.Task{UUID: uuid.New().String(), Description: "Pending task", Status: "pending", Project: "test"}
271
271
-
task2 := &models.Task{UUID: uuid.New().String(), Description: "Completed task", Status: "completed", Project: "test"}
272
272
-
task3 := &models.Task{UUID: uuid.New().String(), Description: "Other project", Status: "pending", Project: "other"}
356
356
+
t.Run("Count pending tasks", func(t *testing.T) {
357
357
+
count, err := repo.Count(ctx, TaskListOptions{Status: "pending"})
358
358
+
if err != nil {
359
359
+
t.Errorf("Failed to count pending tasks: %v", err)
360
360
+
}
273
361
274
274
-
for _, task := range []*models.Task{task1, task2, task3} {
275
275
-
_, err := repo.Create(ctx, task)
276
276
-
if err != nil {
277
277
-
t.Fatalf("Failed to create task: %v", err)
278
278
-
}
279
279
-
}
362
362
+
if count < 2 {
363
363
+
t.Errorf("Expected at least 2 pending tasks, got %d", count)
364
364
+
}
365
365
+
})
280
366
281
281
-
t.Run("GetPending", func(t *testing.T) {
282
282
-
results, err := repo.GetPending(ctx)
283
283
-
if err != nil {
284
284
-
t.Errorf("Failed to get pending tasks: %v", err)
285
285
-
}
367
367
+
t.Run("Count completed tasks", func(t *testing.T) {
368
368
+
count, err := repo.Count(ctx, TaskListOptions{Status: "completed"})
369
369
+
if err != nil {
370
370
+
t.Errorf("Failed to count completed tasks: %v", err)
371
371
+
}
286
372
287
287
-
if len(results) != 2 {
288
288
-
t.Errorf("Expected 2 pending tasks, got %d", len(results))
289
289
-
}
373
373
+
if count < 1 {
374
374
+
t.Errorf("Expected at least 1 completed task, got %d", count)
375
375
+
}
376
376
+
})
290
377
})
291
378
292
292
-
t.Run("GetCompleted", func(t *testing.T) {
293
293
-
results, err := repo.GetCompleted(ctx)
294
294
-
if err != nil {
295
295
-
t.Errorf("Failed to get completed tasks: %v", err)
379
379
+
t.Run("Projects & Tags", func(t *testing.T) {
380
380
+
tasks := []*models.Task{
381
381
+
{UUID: newUUID(), Description: "Task 1", Status: "pending", Project: "web-app", Tags: []string{"frontend", "urgent"}},
382
382
+
{UUID: newUUID(), Description: "Task 2", Status: "pending", Project: "web-app", Tags: []string{"backend", "database"}},
383
383
+
{UUID: newUUID(), Description: "Task 3", Status: "completed", Project: "mobile-app", Tags: []string{"frontend", "ios"}},
384
384
+
{UUID: newUUID(), Description: "Task 4", Status: "pending", Project: "mobile-app", Tags: []string{"android", "urgent"}},
385
385
+
{UUID: newUUID(), Description: "Task 5", Status: "pending", Project: "", Tags: []string{"documentation"}},
296
386
}
297
387
298
298
-
if len(results) != 1 {
299
299
-
t.Errorf("Expected 1 completed task, got %d", len(results))
388
388
+
for _, task := range tasks {
389
389
+
_, err := repo.Create(ctx, task)
390
390
+
if err != nil {
391
391
+
t.Fatalf("Failed to create task: %v", err)
392
392
+
}
300
393
}
301
301
-
})
302
394
303
303
-
t.Run("GetByProject", func(t *testing.T) {
304
304
-
results, err := repo.GetByProject(ctx, "test")
305
305
-
if err != nil {
306
306
-
t.Errorf("Failed to get tasks by project: %v", err)
307
307
-
}
395
395
+
t.Run("GetProjects", func(t *testing.T) {
396
396
+
projects, err := repo.GetProjects(ctx)
397
397
+
if err != nil {
398
398
+
t.Errorf("Failed to get projects: %v", err)
399
399
+
}
400
400
+
401
401
+
expectedProjectCount := 0
402
402
+
projectCounts := make(map[string]int)
403
403
+
404
404
+
for _, project := range projects {
405
405
+
if project.Name != "" {
406
406
+
expectedProjectCount++
407
407
+
projectCounts[project.Name] = project.TaskCount
408
408
+
}
409
409
+
}
410
410
+
411
411
+
if expectedProjectCount < 2 {
412
412
+
t.Errorf("Expected at least 2 projects, got %d", expectedProjectCount)
413
413
+
}
414
414
+
415
415
+
if count, exists := projectCounts["web-app"]; exists {
416
416
+
if count < 2 {
417
417
+
t.Errorf("Expected at least 2 tasks for web-app project, got %d", count)
418
418
+
}
419
419
+
} else {
420
420
+
t.Error("Expected web-app project to exist")
421
421
+
}
308
422
309
309
-
if len(results) != 2 {
310
310
-
t.Errorf("Expected 2 tasks in test project, got %d", len(results))
311
311
-
}
423
423
+
if count, exists := projectCounts["mobile-app"]; exists {
424
424
+
if count < 2 {
425
425
+
t.Errorf("Expected at least 2 tasks for mobile-app project, got %d", count)
426
426
+
}
427
427
+
} else {
428
428
+
t.Error("Expected mobile-app project to exist")
429
429
+
}
430
430
+
})
312
431
313
313
-
for _, task := range results {
314
314
-
if task.Project != "test" {
315
315
-
t.Errorf("Expected project 'test', got %s", task.Project)
432
432
+
t.Run("GetTags", func(t *testing.T) {
433
433
+
tags, err := repo.GetTags(ctx)
434
434
+
if err != nil {
435
435
+
t.Errorf("Failed to get tags: %v", err)
436
436
+
}
437
437
+
438
438
+
tagCounts := make(map[string]int)
439
439
+
for _, tag := range tags {
440
440
+
tagCounts[tag.Name] = tag.TaskCount
316
441
}
317
317
-
}
318
318
-
})
319
442
320
320
-
t.Run("GetByUUID", func(t *testing.T) {
321
321
-
result, err := repo.GetByUUID(ctx, task1.UUID)
322
322
-
if err != nil {
323
323
-
t.Errorf("Failed to get task by UUID: %v", err)
324
324
-
}
443
443
+
expectedMinCounts := map[string]int{
444
444
+
"android": 1, "backend": 1, "database": 1, "documentation": 1,
445
445
+
"frontend": 2, "ios": 1, "urgent": 2,
446
446
+
}
325
447
326
326
-
if result.UUID != task1.UUID {
327
327
-
t.Errorf("Expected UUID %s, got %s", task1.UUID, result.UUID)
328
328
-
}
329
329
-
if result.Description != task1.Description {
330
330
-
t.Errorf("Expected description %s, got %s", task1.Description, result.Description)
331
331
-
}
332
332
-
})
333
333
-
}
448
448
+
for expectedTag, minCount := range expectedMinCounts {
449
449
+
if count, exists := tagCounts[expectedTag]; exists {
450
450
+
if count < minCount {
451
451
+
t.Errorf("Expected at least %d tasks for tag %s, got %d", minCount, expectedTag, count)
452
452
+
}
453
453
+
} else {
454
454
+
t.Errorf("Expected tag %s to exist", expectedTag)
455
455
+
}
456
456
+
}
334
457
335
335
-
func TestTaskRepository_Count(t *testing.T) {
336
336
-
db := createTaskTestDB(t)
337
337
-
repo := NewTaskRepository(db)
338
338
-
ctx := context.Background()
458
458
+
if len(tags) < len(expectedMinCounts) {
459
459
+
t.Errorf("Expected at least %d tags, got %d", len(expectedMinCounts), len(tags))
460
460
+
}
461
461
+
})
339
462
340
340
-
tasks := []*models.Task{
341
341
-
{UUID: uuid.New().String(), Description: "Test 1", Status: "pending"},
342
342
-
{UUID: uuid.New().String(), Description: "Test 2", Status: "pending"},
343
343
-
{UUID: uuid.New().String(), Description: "Test 3", Status: "completed"},
344
344
-
}
463
463
+
t.Run("GetTasksByTag", func(t *testing.T) {
464
464
+
frontend, err := repo.GetTasksByTag(ctx, "frontend")
465
465
+
if err != nil {
466
466
+
t.Errorf("Failed to get tasks by tag: %v", err)
467
467
+
}
345
468
346
346
-
for _, task := range tasks {
347
347
-
_, err := repo.Create(ctx, task)
348
348
-
if err != nil {
349
349
-
t.Fatalf("Failed to create task: %v", err)
350
350
-
}
351
351
-
}
469
469
+
if len(frontend) < 2 {
470
470
+
t.Errorf("Expected at least 2 tasks with frontend tag, got %d", len(frontend))
471
471
+
}
352
472
353
353
-
t.Run("Count all tasks", func(t *testing.T) {
354
354
-
count, err := repo.Count(ctx, TaskListOptions{})
355
355
-
if err != nil {
356
356
-
t.Errorf("Failed to count tasks: %v", err)
357
357
-
}
473
473
+
for _, task := range frontend {
474
474
+
if !slices.Contains(task.Tags, "frontend") {
475
475
+
t.Errorf("Task %s should have frontend tag", task.Description)
476
476
+
}
477
477
+
}
358
478
359
359
-
if count != 3 {
360
360
-
t.Errorf("Expected 3 tasks, got %d", count)
361
361
-
}
362
362
-
})
479
479
+
urgent, err := repo.GetTasksByTag(ctx, "urgent")
480
480
+
if err != nil {
481
481
+
t.Errorf("Failed to get tasks by tag: %v", err)
482
482
+
}
363
483
364
364
-
t.Run("Count pending tasks", func(t *testing.T) {
365
365
-
count, err := repo.Count(ctx, TaskListOptions{Status: "pending"})
366
366
-
if err != nil {
367
367
-
t.Errorf("Failed to count pending tasks: %v", err)
368
368
-
}
484
484
+
if len(urgent) < 2 {
485
485
+
t.Errorf("Expected at least 2 tasks with urgent tag, got %d", len(urgent))
486
486
+
}
369
487
370
370
-
if count != 2 {
371
371
-
t.Errorf("Expected 2 pending tasks, got %d", count)
372
372
-
}
373
373
-
})
488
488
+
for _, task := range urgent {
489
489
+
if !slices.Contains(task.Tags, "urgent") {
490
490
+
t.Errorf("Task %s should have urgent tag", task.Description)
491
491
+
}
492
492
+
}
374
493
375
375
-
t.Run("Count completed tasks", func(t *testing.T) {
376
376
-
count, err := repo.Count(ctx, TaskListOptions{Status: "completed"})
377
377
-
if err != nil {
378
378
-
t.Errorf("Failed to count completed tasks: %v", err)
379
379
-
}
494
494
+
nonexistent, err := repo.GetTasksByTag(ctx, "nonexistent")
495
495
+
if err != nil {
496
496
+
t.Errorf("Failed to get tasks by nonexistent tag: %v", err)
497
497
+
}
380
498
381
381
-
if count != 1 {
382
382
-
t.Errorf("Expected 1 completed task, got %d", count)
383
383
-
}
499
499
+
if len(nonexistent) != 0 {
500
500
+
t.Errorf("Expected 0 tasks with nonexistent tag, got %d", len(nonexistent))
501
501
+
}
502
502
+
})
384
503
})
385
504
}
+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
+
}
+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
+
}
+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
+
}
+137
-62
internal/ui/task_list.go
···
7
7
"os"
8
8
"strings"
9
9
10
10
+
"github.com/charmbracelet/bubbles/help"
11
11
+
"github.com/charmbracelet/bubbles/key"
10
12
tea "github.com/charmbracelet/bubbletea"
11
13
"github.com/charmbracelet/lipgloss"
12
14
"github.com/stormlightlabs/noteleaf/internal/models"
13
15
"github.com/stormlightlabs/noteleaf/internal/repo"
16
16
+
"github.com/stormlightlabs/noteleaf/internal/utils"
14
17
)
15
18
19
19
+
var (
20
20
+
TitleColorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(Guac.Hex())).Bold(true)
21
21
+
SelectedColorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(Salt.Hex())).Bold(true)
22
22
+
HeaderColorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(Malibu.Hex())).Bold(true)
23
23
+
StatusColorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(Julep.Hex()))
24
24
+
)
25
25
+
26
26
+
// Key bindings for task list navigation
27
27
+
type keyMap struct {
28
28
+
Up key.Binding
29
29
+
Down key.Binding
30
30
+
Enter key.Binding
31
31
+
View key.Binding
32
32
+
Refresh key.Binding
33
33
+
ToggleAll key.Binding
34
34
+
MarkDone key.Binding
35
35
+
Quit key.Binding
36
36
+
Back key.Binding
37
37
+
Help key.Binding
38
38
+
Numbers []key.Binding
39
39
+
}
40
40
+
41
41
+
func (k keyMap) ShortHelp() []key.Binding {
42
42
+
return []key.Binding{k.Up, k.Down, k.Enter, k.MarkDone, k.Help, k.Quit}
43
43
+
}
44
44
+
45
45
+
func (k keyMap) FullHelp() [][]key.Binding {
46
46
+
return [][]key.Binding{
47
47
+
{k.Up, k.Down, k.Enter, k.View},
48
48
+
{k.MarkDone, k.Refresh, k.ToggleAll},
49
49
+
{k.Help, k.Quit, k.Back},
50
50
+
}
51
51
+
}
52
52
+
53
53
+
var keys = keyMap{
54
54
+
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("โ/k", "move up")),
55
55
+
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("โ/j", "move down")),
56
56
+
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "view task")),
57
57
+
View: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "view task")),
58
58
+
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
59
59
+
ToggleAll: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "toggle all/pending")),
60
60
+
MarkDone: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "mark done")),
61
61
+
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
62
62
+
Back: key.NewBinding(key.WithKeys("esc", "backspace"), key.WithHelp("esc", "back")),
63
63
+
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
64
64
+
Numbers: []key.Binding{
65
65
+
key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "jump to task 1")),
66
66
+
key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "jump to task 2")),
67
67
+
key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "jump to task 3")),
68
68
+
key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "jump to task 4")),
69
69
+
key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "jump to task 5")),
70
70
+
key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "jump to task 6")),
71
71
+
key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "jump to task 7")),
72
72
+
key.NewBinding(key.WithKeys("8"), key.WithHelp("8", "jump to task 8")),
73
73
+
key.NewBinding(key.WithKeys("9"), key.WithHelp("9", "jump to task 9")),
74
74
+
},
75
75
+
}
76
76
+
16
77
// TaskRepository interface for dependency injection in tests
17
78
type TaskRepository interface {
18
79
List(ctx context.Context, opts repo.TaskListOptions) ([]*models.Task, error)
···
50
111
return &TaskList{repo: repo, opts: opts}
51
112
}
52
113
114
114
+
type (
115
115
+
tasksLoadedMsg []*models.Task
116
116
+
taskViewMsg string
117
117
+
errorTaskMsg error
118
118
+
)
119
119
+
53
120
type taskListModel struct {
54
121
tasks []*models.Task
55
122
selected int
···
59
126
repo TaskRepository
60
127
opts TaskListOptions
61
128
showAll bool
62
62
-
// filter string
129
129
+
keys keyMap
130
130
+
help help.Model
131
131
+
showingHelp bool
63
132
}
64
64
-
65
65
-
type tasksLoadedMsg []*models.Task
66
66
-
type taskViewMsg string
67
67
-
type errorTaskMsg error
68
133
69
134
func (m taskListModel) Init() tea.Cmd {
70
135
return m.loadTasks()
···
73
138
func (m taskListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
74
139
switch msg := msg.(type) {
75
140
case tea.KeyMsg:
141
141
+
if m.showingHelp {
142
142
+
switch {
143
143
+
case key.Matches(msg, m.keys.Back) || key.Matches(msg, m.keys.Quit) || key.Matches(msg, m.keys.Help):
144
144
+
m.showingHelp = false
145
145
+
return m, nil
146
146
+
}
147
147
+
return m, nil
148
148
+
}
149
149
+
76
150
if m.viewing {
77
77
-
switch msg.String() {
78
78
-
case "q", "esc", "backspace":
151
151
+
switch {
152
152
+
case key.Matches(msg, m.keys.Back) || key.Matches(msg, m.keys.Quit):
79
153
m.viewing = false
80
154
m.viewContent = ""
81
155
return m, nil
156
156
+
case key.Matches(msg, m.keys.Help):
157
157
+
m.showingHelp = true
158
158
+
return m, nil
82
159
}
83
160
return m, nil
84
161
}
85
162
86
86
-
switch msg.String() {
87
87
-
case "ctrl+c", "q":
163
163
+
switch {
164
164
+
case key.Matches(msg, m.keys.Quit):
88
165
return m, tea.Quit
89
89
-
case "up", "k":
166
166
+
case key.Matches(msg, m.keys.Up):
90
167
if m.selected > 0 {
91
168
m.selected--
92
169
}
93
93
-
case "down", "j":
170
170
+
case key.Matches(msg, m.keys.Down):
94
171
if m.selected < len(m.tasks)-1 {
95
172
m.selected++
96
173
}
97
97
-
case "enter", "v":
174
174
+
case key.Matches(msg, m.keys.Enter) || key.Matches(msg, m.keys.View):
98
175
if len(m.tasks) > 0 && m.selected < len(m.tasks) {
99
176
return m, m.viewTask(m.tasks[m.selected])
100
177
}
101
101
-
case "r":
178
178
+
case key.Matches(msg, m.keys.Refresh):
102
179
return m, m.loadTasks()
103
103
-
case "a":
180
180
+
case key.Matches(msg, m.keys.ToggleAll):
104
181
m.showAll = !m.showAll
105
182
return m, m.loadTasks()
106
106
-
case "d":
183
183
+
case key.Matches(msg, m.keys.MarkDone):
107
184
if len(m.tasks) > 0 && m.selected < len(m.tasks) {
108
185
return m, m.markDone(m.tasks[m.selected])
109
186
}
110
110
-
case "1", "2", "3", "4", "5", "6", "7", "8", "9":
111
111
-
if idx := int(msg.String()[0] - '1'); idx < len(m.tasks) {
112
112
-
m.selected = idx
187
187
+
case key.Matches(msg, m.keys.Help):
188
188
+
m.showingHelp = true
189
189
+
return m, nil
190
190
+
default:
191
191
+
for i, numKey := range m.keys.Numbers {
192
192
+
if key.Matches(msg, numKey) && i < len(m.tasks) {
193
193
+
m.selected = i
194
194
+
break
195
195
+
}
113
196
}
114
197
}
115
198
case tasksLoadedMsg:
···
129
212
func (m taskListModel) View() string {
130
213
var s strings.Builder
131
214
132
132
-
style := lipgloss.NewStyle().Foreground(lipgloss.Color("86"))
133
133
-
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
134
134
-
selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true)
135
135
-
headerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true)
136
136
-
priorityHighStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true)
137
137
-
priorityMediumStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("208"))
138
138
-
priorityLowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
139
139
-
statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("28"))
215
215
+
style := lipgloss.NewStyle().Foreground(lipgloss.Color(Squid.Hex()))
216
216
+
217
217
+
if m.showingHelp {
218
218
+
return m.help.View(m.keys)
219
219
+
}
140
220
141
221
if m.viewing {
142
222
s.WriteString(m.viewContent)
143
223
s.WriteString("\n\n")
144
144
-
s.WriteString(style.Render("Press q/esc/backspace to return to list"))
224
224
+
s.WriteString(style.Render("Press q/esc/backspace to return to list, ? for help"))
145
225
return s.String()
146
226
}
147
227
148
148
-
s.WriteString(titleStyle.Render("Tasks"))
228
228
+
s.WriteString(TitleColorStyle.Render("Tasks"))
149
229
if m.showAll {
150
230
s.WriteString(" (showing all)")
151
231
} else {
···
165
245
return s.String()
166
246
}
167
247
168
168
-
headerLine := fmt.Sprintf("%-3s %-4s %-40s %-10s %-10s %-15s", "", "ID", "Description", "Status", "Priority", "Project")
169
169
-
s.WriteString(headerStyle.Render(headerLine))
248
248
+
headerLine := fmt.Sprintf(" %-4s %-40s %-10s %-10s %-15s", "ID", "Description", "Status", "Priority", "Project")
249
249
+
s.WriteString(HeaderColorStyle.Render(headerLine))
170
250
s.WriteString("\n")
171
171
-
s.WriteString(headerStyle.Render(strings.Repeat("โ", 80)))
251
251
+
s.WriteString(HeaderColorStyle.Render(strings.Repeat("โ", 80)))
172
252
s.WriteString("\n")
173
253
174
254
for i, task := range m.tasks {
···
203
283
project = project[:10] + "..."
204
284
}
205
285
206
206
-
line := fmt.Sprintf("%s%-4d %-40s %-10s %-10s %-15s",
207
207
-
prefix, task.ID, description, status, priority, project)
286
286
+
priority = utils.Titlecase(priority)
287
287
+
padded := fmt.Sprintf("%-10s", priority)
288
288
+
var colored string
289
289
+
switch strings.ToLower(task.Priority) {
290
290
+
case "high", "urgent":
291
291
+
colored = PriorityHigh.Render(padded)
292
292
+
case "medium":
293
293
+
colored = PriorityMedium.Render(padded)
294
294
+
case "low":
295
295
+
colored = PriorityLow.Render(padded)
296
296
+
default:
297
297
+
colored = padded
298
298
+
}
299
299
+
300
300
+
line := fmt.Sprintf("%s%-4d %-40s %-10s %s %-15s", prefix, task.ID, description, status, colored, project)
208
301
209
302
if i == m.selected {
210
210
-
s.WriteString(selectedStyle.Render(line))
303
303
+
s.WriteString(SelectedColorStyle.Render(line))
211
304
} else {
212
212
-
// Color based on priority
213
213
-
switch strings.ToLower(task.Priority) {
214
214
-
case "high", "urgent":
215
215
-
s.WriteString(priorityHighStyle.Render(line))
216
216
-
case "medium":
217
217
-
s.WriteString(priorityMediumStyle.Render(line))
218
218
-
case "low":
219
219
-
s.WriteString(priorityLowStyle.Render(line))
220
220
-
default:
221
221
-
if task.Status == "completed" {
222
222
-
s.WriteString(statusStyle.Render(line))
223
223
-
} else {
224
224
-
s.WriteString(style.Render(line))
225
225
-
}
305
305
+
if task.Status == "completed" {
306
306
+
s.WriteString(StatusColorStyle.Render(line))
307
307
+
} else {
308
308
+
s.WriteString(style.Render(line))
226
309
}
227
310
}
228
311
229
229
-
// Add tags if any
230
312
if len(task.Tags) > 0 && i == m.selected {
231
313
s.WriteString(" @" + strings.Join(task.Tags, " @"))
232
314
}
···
235
317
}
236
318
237
319
s.WriteString("\n")
238
238
-
s.WriteString(style.Render("Controls: โ/โ/k/j to navigate, Enter/v to view, d to mark done, a to toggle all/pending"))
239
239
-
s.WriteString("\n")
240
240
-
s.WriteString(style.Render("r to refresh, q to quit, 1-9 to jump to task"))
320
320
+
s.WriteString(m.help.View(m.keys))
241
321
242
322
return s.String()
243
323
}
···
245
325
func (m taskListModel) loadTasks() tea.Cmd {
246
326
return func() tea.Msg {
247
327
opts := repo.TaskListOptions{}
328
328
+
showAll := m.showAll || m.opts.ShowAll
248
329
249
249
-
// Set status filter
250
250
-
if m.showAll || m.opts.ShowAll {
251
251
-
// Show all tasks - no status filter
252
252
-
} else {
330
330
+
if !showAll {
253
331
opts.Status = "pending"
254
332
}
255
333
256
256
-
// Apply other filters from options
257
334
if m.opts.Status != "" {
258
335
opts.Status = m.opts.Status
259
336
}
···
280
357
func (m taskListModel) viewTask(task *models.Task) tea.Cmd {
281
358
return func() tea.Msg {
282
359
var content strings.Builder
283
283
-
284
360
content.WriteString(fmt.Sprintf("# Task %d\n\n", task.ID))
285
361
content.WriteString(fmt.Sprintf("**UUID:** %s\n", task.UUID))
286
362
content.WriteString(fmt.Sprintf("**Description:** %s\n", task.Description))
···
336
412
return errorTaskMsg(fmt.Errorf("failed to mark task done: %w", err))
337
413
}
338
414
339
339
-
// Reload tasks after marking done
340
415
return m.loadTasks()()
341
416
}
342
417
}
···
351
426
repo: tl.repo,
352
427
opts: tl.opts,
353
428
showAll: tl.opts.ShowAll,
429
429
+
keys: keys,
430
430
+
help: help.New(),
354
431
}
355
432
356
433
program := tea.NewProgram(model, tea.WithInput(tl.opts.Input), tea.WithOutput(tl.opts.Output))
···
362
439
func (tl *TaskList) staticList(ctx context.Context) error {
363
440
opts := repo.TaskListOptions{}
364
441
365
365
-
if tl.opts.ShowAll {
366
366
-
// Show all tasks - no status filter
367
367
-
} else {
442
442
+
if !tl.opts.ShowAll {
368
443
opts.Status = "pending"
369
444
}
370
445
+42
-11
internal/ui/task_list_test.go
···
9
9
"testing"
10
10
"time"
11
11
12
12
+
"github.com/charmbracelet/bubbles/help"
12
13
tea "github.com/charmbracelet/bubbletea"
13
14
"github.com/stormlightlabs/noteleaf/internal/models"
14
15
"github.com/stormlightlabs/noteleaf/internal/repo"
···
348
349
349
350
t.Run("load tasks command", func(t *testing.T) {
350
351
model := taskListModel{
352
352
+
keys: keys,
353
353
+
help: help.New(),
351
354
repo: repo,
352
355
opts: TaskListOptions{ShowAll: false},
353
356
}
···
373
376
374
377
t.Run("load all tasks", func(t *testing.T) {
375
378
model := taskListModel{
379
379
+
keys: keys,
376
380
repo: repo,
377
381
opts: TaskListOptions{ShowAll: true},
378
382
showAll: true,
···
394
398
395
399
t.Run("view task command", func(t *testing.T) {
396
400
model := taskListModel{
401
401
+
keys: keys,
402
402
+
help: help.New(),
397
403
repo: repo,
398
404
opts: TaskListOptions{},
399
405
}
···
427
433
428
434
t.Run("mark done command", func(t *testing.T) {
429
435
model := taskListModel{
436
436
+
keys: keys,
437
437
+
help: help.New(),
430
438
repo: repo,
431
439
opts: TaskListOptions{},
432
440
}
···
438
446
}
439
447
440
448
msg := cmd()
441
441
-
// Should return a loadTasks command after marking done
442
449
switch msg := msg.(type) {
443
450
case tasksLoadedMsg:
444
451
// Success - tasks reloaded
445
452
case errorTaskMsg:
446
446
-
// Check if it's the expected error for already completed task
447
453
err := error(msg)
448
454
if !strings.Contains(err.Error(), "completed") {
449
455
t.Fatalf("Unexpected error: %v", err)
···
455
461
456
462
t.Run("mark done already completed task", func(t *testing.T) {
457
463
model := taskListModel{
464
464
+
keys: keys,
465
465
+
help: help.New(),
458
466
repo: repo,
459
467
opts: TaskListOptions{},
460
468
}
461
469
462
462
-
task := createMockTasks()[2] // Already completed task
470
470
+
task := createMockTasks()[2]
463
471
cmd := model.markDone(task)
464
472
msg := cmd()
465
473
···
480
488
481
489
t.Run("quit commands", func(t *testing.T) {
482
490
model := taskListModel{
491
491
+
keys: keys,
483
492
repo: repo,
484
493
tasks: createMockTasks()[:2], // First 2 tasks
485
494
opts: TaskListOptions{},
···
497
506
498
507
t.Run("navigation keys", func(t *testing.T) {
499
508
model := taskListModel{
509
509
+
keys: keys,
500
510
repo: repo,
501
511
tasks: createMockTasks()[:3], // First 3 tasks
502
512
selected: 1, // Start in middle
503
513
opts: TaskListOptions{},
504
514
}
505
515
506
506
-
// Test up navigation
507
516
upKeys := []string{"up", "k"}
508
517
for _, key := range upKeys {
509
518
testModel := model
···
515
524
}
516
525
}
517
526
518
518
-
// Test down navigation
519
527
downKeys := []string{"down", "j"}
520
528
for _, key := range downKeys {
521
529
testModel := model
···
530
538
531
539
t.Run("view task keys", func(t *testing.T) {
532
540
model := taskListModel{
541
541
+
keys: keys,
533
542
repo: repo,
534
543
tasks: createMockTasks()[:2],
535
544
selected: 0,
···
548
557
549
558
t.Run("number shortcuts", func(t *testing.T) {
550
559
model := taskListModel{
560
560
+
keys: keys,
551
561
repo: repo,
552
562
tasks: createMockTasks()[:4],
553
563
opts: TaskListOptions{},
···
568
578
569
579
t.Run("toggle all/pending", func(t *testing.T) {
570
580
model := taskListModel{
581
581
+
keys: keys,
571
582
repo: repo,
572
583
tasks: createMockTasks()[:2],
573
584
showAll: false,
···
587
598
588
599
t.Run("mark done key", func(t *testing.T) {
589
600
model := taskListModel{
601
601
+
keys: keys,
590
602
repo: repo,
591
603
tasks: createMockTasks()[:2],
592
604
selected: 0,
···
601
613
602
614
t.Run("refresh key", func(t *testing.T) {
603
615
model := taskListModel{
616
616
+
keys: keys,
604
617
repo: repo,
605
618
tasks: createMockTasks()[:2],
606
619
opts: TaskListOptions{},
···
614
627
615
628
t.Run("viewing mode navigation", func(t *testing.T) {
616
629
model := taskListModel{
630
630
+
keys: keys,
617
631
repo: repo,
618
632
tasks: createMockTasks()[:2],
619
633
viewing: true,
···
642
656
643
657
t.Run("viewing mode", func(t *testing.T) {
644
658
model := taskListModel{
659
659
+
keys: keys,
645
660
repo: repo,
646
661
viewing: true,
647
662
viewContent: "# Task Details\nTest content here",
···
659
674
660
675
t.Run("error state", func(t *testing.T) {
661
676
model := taskListModel{
677
677
+
keys: keys,
678
678
+
help: help.New(),
662
679
repo: repo,
663
680
err: errors.New("test error"),
664
681
opts: TaskListOptions{},
···
672
689
673
690
t.Run("no tasks", func(t *testing.T) {
674
691
model := taskListModel{
692
692
+
keys: keys,
675
693
repo: repo,
676
694
tasks: []*models.Task{},
677
695
opts: TaskListOptions{},
···
689
707
t.Run("with tasks", func(t *testing.T) {
690
708
tasks := createMockTasks()[:2] // First 2 tasks
691
709
model := taskListModel{
710
710
+
keys: keys,
692
711
repo: repo,
693
712
tasks: tasks,
694
713
selected: 0,
···
706
725
if !strings.Contains(view, "Plan vacation itinerary") {
707
726
t.Error("Second task not displayed")
708
727
}
709
709
-
if !strings.Contains(view, "Controls:") {
710
710
-
t.Error("Control instructions not displayed")
728
728
+
if !strings.Contains(view, "help") {
729
729
+
t.Error("Help instructions not displayed")
711
730
}
712
731
})
713
732
714
733
t.Run("show all mode", func(t *testing.T) {
715
734
model := taskListModel{
735
735
+
keys: keys,
716
736
repo: repo,
717
737
tasks: createMockTasks(),
718
738
showAll: true,
···
728
748
t.Run("selected task highlighting", func(t *testing.T) {
729
749
tasks := createMockTasks()[:2]
730
750
model := taskListModel{
751
751
+
752
752
+
keys: keys,
731
753
repo: repo,
732
754
tasks: tasks,
733
755
selected: 0,
···
735
757
}
736
758
737
759
view := model.View()
738
738
-
// The selected task should have a ">" prefix
739
760
if !strings.Contains(view, " > 1 ") {
740
761
t.Error("Selected task not highlighted with '>' prefix")
741
762
}
···
747
768
748
769
t.Run("tasks loaded message", func(t *testing.T) {
749
770
model := taskListModel{
771
771
+
keys: keys,
772
772
+
help: help.New(),
750
773
repo: repo,
751
774
opts: TaskListOptions{},
752
775
}
···
766
789
767
790
t.Run("task view message", func(t *testing.T) {
768
791
model := taskListModel{
792
792
+
keys: keys,
793
793
+
help: help.New(),
769
794
repo: repo,
770
795
opts: TaskListOptions{},
771
796
}
···
785
810
786
811
t.Run("error message", func(t *testing.T) {
787
812
model := taskListModel{
813
813
+
keys: keys,
814
814
+
help: help.New(),
788
815
repo: repo,
789
816
opts: TaskListOptions{},
790
817
}
···
804
831
805
832
t.Run("selected index bounds", func(t *testing.T) {
806
833
model := taskListModel{
834
834
+
keys: keys,
807
835
repo: repo,
808
836
tasks: createMockTasks()[:2],
809
809
-
selected: 5, // Out of bounds
837
837
+
selected: 5,
810
838
opts: TaskListOptions{},
811
839
}
812
840
813
813
-
// Load fewer tasks
814
841
newTasks := createMockTasks()[:1]
815
842
newModel, _ := model.Update(tasksLoadedMsg(newTasks))
816
843
···
818
845
if m.selected >= len(m.tasks) {
819
846
t.Error("Selected index should be adjusted to bounds")
820
847
}
821
821
-
if m.selected != 0 { // Should be adjusted to last valid index
848
848
+
if m.selected != 0 {
822
849
t.Errorf("Expected selected to be 0, got %d", m.selected)
823
850
}
824
851
}
···
832
859
}
833
860
834
861
model := taskListModel{
862
862
+
keys: keys,
863
863
+
help: help.New(),
835
864
repo: repo,
836
865
opts: TaskListOptions{},
837
866
}
···
857
886
}
858
887
859
888
model := taskListModel{
889
889
+
keys: keys,
890
890
+
help: help.New(),
860
891
repo: repo,
861
892
opts: TaskListOptions{},
862
893
}
+242
internal/ui/task_view.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
+
"github.com/charmbracelet/bubbles/viewport"
13
13
+
tea "github.com/charmbracelet/bubbletea"
14
14
+
"github.com/charmbracelet/lipgloss"
15
15
+
"github.com/stormlightlabs/noteleaf/internal/models"
16
16
+
"github.com/stormlightlabs/noteleaf/internal/utils"
17
17
+
)
18
18
+
19
19
+
// TaskViewOptions configures the task view UI behavior
20
20
+
type TaskViewOptions struct {
21
21
+
// Output destination (stdout for interactive, buffer for testing)
22
22
+
Output io.Writer
23
23
+
// Input source (stdin for interactive, strings reader for testing)
24
24
+
Input io.Reader
25
25
+
// Enable static mode (no interactive components)
26
26
+
Static bool
27
27
+
// Width and height for viewport sizing
28
28
+
Width int
29
29
+
Height int
30
30
+
}
31
31
+
32
32
+
// TaskView handles task detail viewing UI
33
33
+
type TaskView struct {
34
34
+
task *models.Task
35
35
+
opts TaskViewOptions
36
36
+
}
37
37
+
38
38
+
// NewTaskView creates a new task view UI component
39
39
+
func NewTaskView(task *models.Task, opts TaskViewOptions) *TaskView {
40
40
+
if opts.Output == nil {
41
41
+
opts.Output = os.Stdout
42
42
+
}
43
43
+
if opts.Input == nil {
44
44
+
opts.Input = os.Stdin
45
45
+
}
46
46
+
// Set default dimensions if not provided
47
47
+
if opts.Width == 0 {
48
48
+
opts.Width = 80
49
49
+
}
50
50
+
if opts.Height == 0 {
51
51
+
opts.Height = 24
52
52
+
}
53
53
+
return &TaskView{task: task, opts: opts}
54
54
+
}
55
55
+
56
56
+
// Task view specific key bindings
57
57
+
type taskViewKeyMap struct {
58
58
+
Up key.Binding
59
59
+
Down key.Binding
60
60
+
PageUp key.Binding
61
61
+
PageDown key.Binding
62
62
+
Top key.Binding
63
63
+
Bottom key.Binding
64
64
+
Quit key.Binding
65
65
+
Back key.Binding
66
66
+
Help key.Binding
67
67
+
}
68
68
+
69
69
+
func (k taskViewKeyMap) ShortHelp() []key.Binding {
70
70
+
return []key.Binding{k.Up, k.Down, k.Back, k.Help, k.Quit}
71
71
+
}
72
72
+
73
73
+
func (k taskViewKeyMap) FullHelp() [][]key.Binding {
74
74
+
return [][]key.Binding{
75
75
+
{k.Up, k.Down, k.PageUp, k.PageDown},
76
76
+
{k.Top, k.Bottom},
77
77
+
{k.Help, k.Back, k.Quit},
78
78
+
}
79
79
+
}
80
80
+
81
81
+
var taskViewKeys = taskViewKeyMap{
82
82
+
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("โ/k", "scroll up")),
83
83
+
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("โ/j", "scroll down")),
84
84
+
PageUp: key.NewBinding(key.WithKeys("pgup", "b"), key.WithHelp("pgup/b", "page up")),
85
85
+
PageDown: key.NewBinding(key.WithKeys("pgdown", "f"), key.WithHelp("pgdown/f", "page down")),
86
86
+
Top: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home/g", "go to top")),
87
87
+
Bottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end/G", "go to bottom")),
88
88
+
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
89
89
+
Back: key.NewBinding(key.WithKeys("esc", "backspace"), key.WithHelp("esc", "back")),
90
90
+
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
91
91
+
}
92
92
+
93
93
+
type taskViewModel struct {
94
94
+
task *models.Task
95
95
+
viewport viewport.Model
96
96
+
keys taskViewKeyMap
97
97
+
help help.Model
98
98
+
showingHelp bool
99
99
+
opts TaskViewOptions
100
100
+
}
101
101
+
102
102
+
func (m taskViewModel) Init() tea.Cmd {
103
103
+
return nil
104
104
+
}
105
105
+
106
106
+
func (m taskViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
107
107
+
var cmd tea.Cmd
108
108
+
109
109
+
switch msg := msg.(type) {
110
110
+
case tea.KeyMsg:
111
111
+
if m.showingHelp {
112
112
+
switch {
113
113
+
case key.Matches(msg, m.keys.Back) || key.Matches(msg, m.keys.Quit) || key.Matches(msg, m.keys.Help):
114
114
+
m.showingHelp = false
115
115
+
return m, nil
116
116
+
}
117
117
+
return m, nil
118
118
+
}
119
119
+
120
120
+
switch {
121
121
+
case key.Matches(msg, m.keys.Quit) || key.Matches(msg, m.keys.Back):
122
122
+
return m, tea.Quit
123
123
+
case key.Matches(msg, m.keys.Help):
124
124
+
m.showingHelp = true
125
125
+
return m, nil
126
126
+
case key.Matches(msg, m.keys.Up):
127
127
+
m.viewport.ScrollUp(1)
128
128
+
case key.Matches(msg, m.keys.Down):
129
129
+
m.viewport.ScrollDown(1)
130
130
+
case key.Matches(msg, m.keys.PageUp):
131
131
+
m.viewport.HalfPageUp()
132
132
+
case key.Matches(msg, m.keys.PageDown):
133
133
+
m.viewport.HalfPageDown()
134
134
+
case key.Matches(msg, m.keys.Top):
135
135
+
m.viewport.GotoTop()
136
136
+
case key.Matches(msg, m.keys.Bottom):
137
137
+
m.viewport.GotoBottom()
138
138
+
}
139
139
+
140
140
+
case tea.WindowSizeMsg:
141
141
+
headerHeight := 3 // Title + spacing
142
142
+
footerHeight := 3 // Help + spacing
143
143
+
verticalMarginHeight := headerHeight + footerHeight
144
144
+
145
145
+
if !m.opts.Static {
146
146
+
m.viewport.Width = msg.Width - 2 // Account for padding
147
147
+
m.viewport.Height = msg.Height - verticalMarginHeight
148
148
+
}
149
149
+
}
150
150
+
151
151
+
m.viewport, cmd = m.viewport.Update(msg)
152
152
+
return m, cmd
153
153
+
}
154
154
+
155
155
+
func (m taskViewModel) View() string {
156
156
+
if m.showingHelp {
157
157
+
return m.help.View(m.keys)
158
158
+
}
159
159
+
160
160
+
title := TitleColorStyle.Render(fmt.Sprintf("Task %d", m.task.ID))
161
161
+
content := m.viewport.View()
162
162
+
help := lipgloss.NewStyle().Foreground(lipgloss.Color(Squid.Hex())).Render(m.help.View(m.keys))
163
163
+
164
164
+
return lipgloss.JoinVertical(lipgloss.Left, title, "", content, "", help)
165
165
+
}
166
166
+
167
167
+
func formatTaskContent(task *models.Task) string {
168
168
+
var content strings.Builder
169
169
+
170
170
+
content.WriteString(fmt.Sprintf("UUID: %s\n", task.UUID))
171
171
+
content.WriteString(fmt.Sprintf("Description: %s\n", task.Description))
172
172
+
content.WriteString(fmt.Sprintf("Status: %s\n", utils.Titlecase(task.Status)))
173
173
+
174
174
+
if task.Priority != "" {
175
175
+
content.WriteString(fmt.Sprintf("Priority: %s\n", utils.Titlecase(task.Priority)))
176
176
+
}
177
177
+
178
178
+
if task.Project != "" {
179
179
+
content.WriteString(fmt.Sprintf("Project: %s\n", task.Project))
180
180
+
}
181
181
+
182
182
+
if len(task.Tags) > 0 {
183
183
+
content.WriteString(fmt.Sprintf("Tags: %s\n", strings.Join(task.Tags, ", ")))
184
184
+
}
185
185
+
186
186
+
content.WriteString("\nDates:\n")
187
187
+
content.WriteString(fmt.Sprintf("- Created: %s\n", task.Entry.Format("2006-01-02 15:04")))
188
188
+
content.WriteString(fmt.Sprintf("- Modified: %s\n", task.Modified.Format("2006-01-02 15:04")))
189
189
+
190
190
+
if task.Due != nil {
191
191
+
content.WriteString(fmt.Sprintf("- Due: %s\n", task.Due.Format("2006-01-02 15:04")))
192
192
+
}
193
193
+
194
194
+
if task.Start != nil {
195
195
+
content.WriteString(fmt.Sprintf("- Started: %s\n", task.Start.Format("2006-01-02 15:04")))
196
196
+
}
197
197
+
198
198
+
if task.End != nil {
199
199
+
content.WriteString(fmt.Sprintf("- Completed: %s\n", task.End.Format("2006-01-02 15:04")))
200
200
+
}
201
201
+
202
202
+
if len(task.Annotations) > 0 {
203
203
+
content.WriteString("\nAnnotations:\n")
204
204
+
for i, annotation := range task.Annotations {
205
205
+
content.WriteString(fmt.Sprintf("%d. %s\n", i+1, annotation))
206
206
+
}
207
207
+
}
208
208
+
209
209
+
return content.String()
210
210
+
}
211
211
+
212
212
+
// Show displays the task in interactive mode
213
213
+
func (tv *TaskView) Show(ctx context.Context) error {
214
214
+
if tv.opts.Static {
215
215
+
return tv.staticShow(ctx)
216
216
+
}
217
217
+
218
218
+
vp := viewport.New(tv.opts.Width-2, tv.opts.Height-6)
219
219
+
vp.SetContent(formatTaskContent(tv.task))
220
220
+
221
221
+
model := taskViewModel{
222
222
+
task: tv.task,
223
223
+
viewport: vp,
224
224
+
keys: taskViewKeys,
225
225
+
help: help.New(),
226
226
+
opts: tv.opts,
227
227
+
}
228
228
+
229
229
+
program := tea.NewProgram(model, tea.WithInput(tv.opts.Input), tea.WithOutput(tv.opts.Output))
230
230
+
231
231
+
_, err := program.Run()
232
232
+
return err
233
233
+
}
234
234
+
235
235
+
func (tv *TaskView) staticShow(context.Context) error {
236
236
+
content := formatTaskContent(tv.task)
237
237
+
238
238
+
title := fmt.Sprintf("Task %d\n\n", tv.task.ID)
239
239
+
240
240
+
fmt.Fprint(tv.opts.Output, title+content)
241
241
+
return nil
242
242
+
}
+486
internal/ui/task_view_test.go
···
1
1
+
package ui
2
2
+
3
3
+
import (
4
4
+
"bytes"
5
5
+
"context"
6
6
+
"strings"
7
7
+
"testing"
8
8
+
"time"
9
9
+
10
10
+
"github.com/charmbracelet/bubbles/help"
11
11
+
"github.com/charmbracelet/bubbles/viewport"
12
12
+
tea "github.com/charmbracelet/bubbletea"
13
13
+
"github.com/stormlightlabs/noteleaf/internal/models"
14
14
+
)
15
15
+
16
16
+
func createMockTask() *models.Task {
17
17
+
now := time.Now()
18
18
+
due := now.Add(24 * time.Hour)
19
19
+
start := now.Add(-2 * time.Hour)
20
20
+
21
21
+
return &models.Task{
22
22
+
ID: 1,
23
23
+
UUID: "test-uuid-123",
24
24
+
Description: "Test task description",
25
25
+
Status: "pending",
26
26
+
Priority: "high",
27
27
+
Project: "test-project",
28
28
+
Tags: []string{"urgent", "test"},
29
29
+
Entry: now.Add(-24 * time.Hour),
30
30
+
Modified: now.Add(-1 * time.Hour),
31
31
+
Due: &due,
32
32
+
Start: &start,
33
33
+
Annotations: []string{"First annotation", "Second annotation"},
34
34
+
}
35
35
+
}
36
36
+
37
37
+
func createCompletedMockTask() *models.Task {
38
38
+
now := time.Now()
39
39
+
task := createMockTask()
40
40
+
task.Status = "completed"
41
41
+
task.End = &now
42
42
+
return task
43
43
+
}
44
44
+
45
45
+
func TestTaskView(t *testing.T) {
46
46
+
t.Run("View Options", func(t *testing.T) {
47
47
+
task := createMockTask()
48
48
+
49
49
+
t.Run("default options", func(t *testing.T) {
50
50
+
opts := TaskViewOptions{}
51
51
+
tv := NewTaskView(task, opts)
52
52
+
53
53
+
if tv.opts.Output == nil {
54
54
+
t.Error("Output should default to os.Stdout")
55
55
+
}
56
56
+
if tv.opts.Input == nil {
57
57
+
t.Error("Input should default to os.Stdin")
58
58
+
}
59
59
+
if tv.opts.Width != 80 {
60
60
+
t.Errorf("Width should default to 80, got %d", tv.opts.Width)
61
61
+
}
62
62
+
if tv.opts.Height != 24 {
63
63
+
t.Errorf("Height should default to 24, got %d", tv.opts.Height)
64
64
+
}
65
65
+
})
66
66
+
67
67
+
t.Run("custom options", func(t *testing.T) {
68
68
+
var buf bytes.Buffer
69
69
+
opts := TaskViewOptions{
70
70
+
Output: &buf,
71
71
+
Static: true,
72
72
+
Width: 100,
73
73
+
Height: 30,
74
74
+
}
75
75
+
tv := NewTaskView(task, opts)
76
76
+
77
77
+
if tv.opts.Output != &buf {
78
78
+
t.Error("Custom output not set")
79
79
+
}
80
80
+
if !tv.opts.Static {
81
81
+
t.Error("Static mode not set")
82
82
+
}
83
83
+
if tv.opts.Width != 100 {
84
84
+
t.Error("Custom width not set")
85
85
+
}
86
86
+
if tv.opts.Height != 30 {
87
87
+
t.Error("Custom height not set")
88
88
+
}
89
89
+
})
90
90
+
})
91
91
+
92
92
+
t.Run("New", func(t *testing.T) {
93
93
+
task := createMockTask()
94
94
+
95
95
+
t.Run("creates task view correctly", func(t *testing.T) {
96
96
+
opts := TaskViewOptions{Width: 60, Height: 20}
97
97
+
tv := NewTaskView(task, opts)
98
98
+
99
99
+
if tv.task != task {
100
100
+
t.Error("Task not set correctly")
101
101
+
}
102
102
+
if tv.opts.Width != 60 {
103
103
+
t.Error("Width not set correctly")
104
104
+
}
105
105
+
if tv.opts.Height != 20 {
106
106
+
t.Error("Height not set correctly")
107
107
+
}
108
108
+
})
109
109
+
})
110
110
+
111
111
+
t.Run("Static Mode", func(t *testing.T) {
112
112
+
t.Run("basic task display", func(t *testing.T) {
113
113
+
task := createMockTask()
114
114
+
var buf bytes.Buffer
115
115
+
116
116
+
tv := NewTaskView(task, TaskViewOptions{
117
117
+
Output: &buf,
118
118
+
Static: true,
119
119
+
})
120
120
+
121
121
+
err := tv.Show(context.Background())
122
122
+
if err != nil {
123
123
+
t.Fatalf("Show failed: %v", err)
124
124
+
}
125
125
+
126
126
+
output := buf.String()
127
127
+
128
128
+
if !strings.Contains(output, "Task 1") {
129
129
+
t.Error("Task title not displayed")
130
130
+
}
131
131
+
132
132
+
if !strings.Contains(output, "test-uuid-123") {
133
133
+
t.Error("UUID not displayed")
134
134
+
}
135
135
+
if !strings.Contains(output, "Test task description") {
136
136
+
t.Error("Description not displayed")
137
137
+
}
138
138
+
if !strings.Contains(output, "Pending") {
139
139
+
t.Error("Status not displayed with title case")
140
140
+
}
141
141
+
if !strings.Contains(output, "High") {
142
142
+
t.Error("Priority not displayed with title case")
143
143
+
}
144
144
+
if !strings.Contains(output, "test-project") {
145
145
+
t.Error("Project not displayed")
146
146
+
}
147
147
+
if !strings.Contains(output, "urgent, test") {
148
148
+
t.Error("Tags not displayed correctly")
149
149
+
}
150
150
+
151
151
+
if !strings.Contains(output, "Dates:") {
152
152
+
t.Error("Dates section not displayed")
153
153
+
}
154
154
+
if !strings.Contains(output, "Created:") {
155
155
+
t.Error("Created date not displayed")
156
156
+
}
157
157
+
if !strings.Contains(output, "Modified:") {
158
158
+
t.Error("Modified date not displayed")
159
159
+
}
160
160
+
if !strings.Contains(output, "Due:") {
161
161
+
t.Error("Due date not displayed")
162
162
+
}
163
163
+
if !strings.Contains(output, "Started:") {
164
164
+
t.Error("Start date not displayed")
165
165
+
}
166
166
+
167
167
+
if !strings.Contains(output, "Annotations:") {
168
168
+
t.Error("Annotations section not displayed")
169
169
+
}
170
170
+
if !strings.Contains(output, "First annotation") {
171
171
+
t.Error("First annotation not displayed")
172
172
+
}
173
173
+
if !strings.Contains(output, "Second annotation") {
174
174
+
t.Error("Second annotation not displayed")
175
175
+
}
176
176
+
})
177
177
+
178
178
+
t.Run("completed task display", func(t *testing.T) {
179
179
+
task := createCompletedMockTask()
180
180
+
var buf bytes.Buffer
181
181
+
182
182
+
tv := NewTaskView(task, TaskViewOptions{
183
183
+
Output: &buf,
184
184
+
Static: true,
185
185
+
})
186
186
+
187
187
+
err := tv.Show(context.Background())
188
188
+
if err != nil {
189
189
+
t.Fatalf("Show failed: %v", err)
190
190
+
}
191
191
+
192
192
+
output := buf.String()
193
193
+
194
194
+
if !strings.Contains(output, "Completed") {
195
195
+
t.Error("Completed status not displayed with title case")
196
196
+
}
197
197
+
if !strings.Contains(output, "Completed:") {
198
198
+
t.Error("Completion date not displayed")
199
199
+
}
200
200
+
})
201
201
+
202
202
+
t.Run("minimal task display", func(t *testing.T) {
203
203
+
now := time.Now()
204
204
+
task := &models.Task{
205
205
+
ID: 2,
206
206
+
UUID: "minimal-uuid",
207
207
+
Description: "Minimal task",
208
208
+
Status: "pending",
209
209
+
Entry: now,
210
210
+
Modified: now,
211
211
+
}
212
212
+
213
213
+
var buf bytes.Buffer
214
214
+
tv := NewTaskView(task, TaskViewOptions{
215
215
+
Output: &buf,
216
216
+
Static: true,
217
217
+
})
218
218
+
219
219
+
err := tv.Show(context.Background())
220
220
+
if err != nil {
221
221
+
t.Fatalf("Show failed: %v", err)
222
222
+
}
223
223
+
224
224
+
output := buf.String()
225
225
+
226
226
+
if !strings.Contains(output, "Task 2") {
227
227
+
t.Error("Task title not displayed")
228
228
+
}
229
229
+
if !strings.Contains(output, "minimal-uuid") {
230
230
+
t.Error("UUID not displayed")
231
231
+
}
232
232
+
if !strings.Contains(output, "Minimal task") {
233
233
+
t.Error("Description not displayed")
234
234
+
}
235
235
+
236
236
+
// Should not contain optional fields
237
237
+
if strings.Contains(output, "Priority:") {
238
238
+
t.Error("Priority should not be displayed for minimal task")
239
239
+
}
240
240
+
if strings.Contains(output, "Project:") {
241
241
+
t.Error("Project should not be displayed for minimal task")
242
242
+
}
243
243
+
if strings.Contains(output, "Tags:") {
244
244
+
t.Error("Tags should not be displayed for minimal task")
245
245
+
}
246
246
+
if strings.Contains(output, "Annotations:") {
247
247
+
t.Error("Annotations should not be displayed for minimal task")
248
248
+
}
249
249
+
})
250
250
+
})
251
251
+
252
252
+
t.Run("Format Content", func(t *testing.T) {
253
253
+
t.Run("formats task content correctly", func(t *testing.T) {
254
254
+
task := createMockTask()
255
255
+
content := formatTaskContent(task)
256
256
+
257
257
+
expectedStrings := []string{
258
258
+
"UUID: test-uuid-123",
259
259
+
"Description: Test task description",
260
260
+
"Status: Pending",
261
261
+
"Priority: High",
262
262
+
"Project: test-project",
263
263
+
"Tags: urgent, test",
264
264
+
"Dates:",
265
265
+
"Created:",
266
266
+
"Modified:",
267
267
+
"Due:",
268
268
+
"Started:",
269
269
+
"Annotations:",
270
270
+
"1. First annotation",
271
271
+
"2. Second annotation",
272
272
+
}
273
273
+
274
274
+
for _, expected := range expectedStrings {
275
275
+
if !strings.Contains(content, expected) {
276
276
+
t.Errorf("Expected content '%s' not found in formatted output", expected)
277
277
+
}
278
278
+
}
279
279
+
})
280
280
+
281
281
+
t.Run("handles empty optional fields", func(t *testing.T) {
282
282
+
now := time.Now()
283
283
+
task := &models.Task{
284
284
+
ID: 1,
285
285
+
UUID: "test-uuid",
286
286
+
Description: "Test description",
287
287
+
Status: "pending",
288
288
+
Entry: now,
289
289
+
Modified: now,
290
290
+
}
291
291
+
292
292
+
content := formatTaskContent(task)
293
293
+
294
294
+
if strings.Contains(content, "Priority:") {
295
295
+
t.Error("Priority should not appear when empty")
296
296
+
}
297
297
+
if strings.Contains(content, "Project:") {
298
298
+
t.Error("Project should not appear when empty")
299
299
+
}
300
300
+
if strings.Contains(content, "Tags:") {
301
301
+
t.Error("Tags should not appear when empty")
302
302
+
}
303
303
+
if strings.Contains(content, "Annotations:") {
304
304
+
t.Error("Annotations should not appear when empty")
305
305
+
}
306
306
+
if strings.Contains(content, "Due:") {
307
307
+
t.Error("Due date should not appear when nil")
308
308
+
}
309
309
+
if strings.Contains(content, "Started:") {
310
310
+
t.Error("Start date should not appear when nil")
311
311
+
}
312
312
+
if strings.Contains(content, "Completed:") {
313
313
+
t.Error("End date should not appear when nil")
314
314
+
}
315
315
+
})
316
316
+
})
317
317
+
318
318
+
t.Run("Model", func(t *testing.T) {
319
319
+
task := createMockTask()
320
320
+
321
321
+
t.Run("initial model state", func(t *testing.T) {
322
322
+
vp := viewport.New(80, 20)
323
323
+
vp.SetContent(formatTaskContent(task))
324
324
+
325
325
+
model := taskViewModel{task: task, opts: TaskViewOptions{Width: 80, Height: 24}}
326
326
+
327
327
+
if model.showingHelp {
328
328
+
t.Error("Initial showingHelp should be false")
329
329
+
}
330
330
+
if model.task != task {
331
331
+
t.Error("Task not set correctly")
332
332
+
}
333
333
+
})
334
334
+
335
335
+
t.Run("key handling - help toggle", func(t *testing.T) {
336
336
+
vp := viewport.New(80, 20)
337
337
+
model := taskViewModel{
338
338
+
task: task,
339
339
+
viewport: vp,
340
340
+
keys: taskViewKeys,
341
341
+
help: help.New(),
342
342
+
}
343
343
+
344
344
+
newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("?")})
345
345
+
if m, ok := newModel.(taskViewModel); ok {
346
346
+
if !m.showingHelp {
347
347
+
t.Error("Help key should show help")
348
348
+
}
349
349
+
}
350
350
+
351
351
+
model.showingHelp = true
352
352
+
newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("?")})
353
353
+
if m, ok := newModel.(taskViewModel); ok {
354
354
+
if m.showingHelp {
355
355
+
t.Error("Help key should exit help when already showing")
356
356
+
}
357
357
+
}
358
358
+
})
359
359
+
360
360
+
t.Run("key handling - quit and back", func(t *testing.T) {
361
361
+
vp := viewport.New(80, 20)
362
362
+
model := taskViewModel{
363
363
+
task: task,
364
364
+
viewport: vp,
365
365
+
keys: taskViewKeys,
366
366
+
help: help.New(),
367
367
+
}
368
368
+
369
369
+
quitKeys := []string{"q", "esc"}
370
370
+
for _, key := range quitKeys {
371
371
+
_, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)})
372
372
+
if cmd == nil {
373
373
+
t.Errorf("Key %s should return quit command", key)
374
374
+
}
375
375
+
}
376
376
+
})
377
377
+
378
378
+
t.Run("viewport navigation", func(t *testing.T) {
379
379
+
vp := viewport.New(80, 20)
380
380
+
longContent := strings.Repeat("Line of content\n", 50) // Create content longer than viewport
381
381
+
vp.SetContent(longContent)
382
382
+
383
383
+
model := taskViewModel{
384
384
+
task: task,
385
385
+
viewport: vp,
386
386
+
keys: taskViewKeys,
387
387
+
help: help.New(),
388
388
+
}
389
389
+
390
390
+
initialOffset := model.viewport.YOffset
391
391
+
392
392
+
newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
393
393
+
if m, ok := newModel.(taskViewModel); ok {
394
394
+
if m.viewport.YOffset <= initialOffset {
395
395
+
t.Error("Down key should scroll viewport down")
396
396
+
}
397
397
+
}
398
398
+
399
399
+
model.viewport.ScrollDown(5)
400
400
+
initialOffset = model.viewport.YOffset
401
401
+
newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")})
402
402
+
if m, ok := newModel.(taskViewModel); ok {
403
403
+
if m.viewport.YOffset >= initialOffset {
404
404
+
t.Error("Up key should scroll viewport up")
405
405
+
}
406
406
+
}
407
407
+
})
408
408
+
})
409
409
+
410
410
+
t.Run("View Model", func(t *testing.T) {
411
411
+
task := createMockTask()
412
412
+
413
413
+
t.Run("normal view", func(t *testing.T) {
414
414
+
vp := viewport.New(80, 20)
415
415
+
vp.SetContent(formatTaskContent(task))
416
416
+
417
417
+
model := taskViewModel{
418
418
+
task: task,
419
419
+
viewport: vp,
420
420
+
keys: taskViewKeys,
421
421
+
help: help.New(),
422
422
+
}
423
423
+
424
424
+
view := model.View()
425
425
+
426
426
+
if !strings.Contains(view, "Task 1") {
427
427
+
t.Error("Task title not displayed in view")
428
428
+
}
429
429
+
if !strings.Contains(view, "help") {
430
430
+
t.Error("Help information not displayed")
431
431
+
}
432
432
+
})
433
433
+
434
434
+
t.Run("help view", func(t *testing.T) {
435
435
+
vp := viewport.New(80, 20)
436
436
+
model := taskViewModel{
437
437
+
task: task,
438
438
+
viewport: vp,
439
439
+
keys: taskViewKeys,
440
440
+
help: help.New(),
441
441
+
showingHelp: true,
442
442
+
}
443
443
+
444
444
+
view := model.View()
445
445
+
446
446
+
if !strings.Contains(view, "scroll") {
447
447
+
t.Error("Help view should contain scroll instructions")
448
448
+
}
449
449
+
})
450
450
+
})
451
451
+
452
452
+
t.Run("Integration", func(t *testing.T) {
453
453
+
t.Run("creates and displays task view", func(t *testing.T) {
454
454
+
task := createMockTask()
455
455
+
var buf bytes.Buffer
456
456
+
457
457
+
tv := NewTaskView(task, TaskViewOptions{
458
458
+
Output: &buf,
459
459
+
Static: true,
460
460
+
Width: 80,
461
461
+
Height: 24,
462
462
+
})
463
463
+
464
464
+
if tv == nil {
465
465
+
t.Fatal("NewTaskView returned nil")
466
466
+
}
467
467
+
468
468
+
err := tv.Show(context.Background())
469
469
+
if err != nil {
470
470
+
t.Fatalf("Show failed: %v", err)
471
471
+
}
472
472
+
473
473
+
output := buf.String()
474
474
+
if len(output) == 0 {
475
475
+
t.Error("No output generated")
476
476
+
}
477
477
+
478
478
+
if !strings.Contains(output, task.Description) {
479
479
+
t.Error("Task description not displayed")
480
480
+
}
481
481
+
if !strings.Contains(output, task.UUID) {
482
482
+
t.Error("Task UUID not displayed")
483
483
+
}
484
484
+
})
485
485
+
})
486
486
+
}
+6
internal/utils/utils.go
···
5
5
"strings"
6
6
7
7
"github.com/charmbracelet/log"
8
8
+
"golang.org/x/text/cases"
9
9
+
"golang.org/x/text/language"
8
10
)
9
11
10
12
// Logger is the global application logger
···
45
47
}
46
48
return Logger
47
49
}
50
50
+
51
51
+
func Titlecase(s string) string {
52
52
+
return cases.Title(language.Und, cases.NoLower).String(s)
53
53
+
}
+73
internal/utils/utils_test.go
···
212
212
}
213
213
})
214
214
}
215
215
+
216
216
+
func TestTitlecase(t *testing.T) {
217
217
+
tests := []struct {
218
218
+
name string
219
219
+
input string
220
220
+
expected string
221
221
+
}{
222
222
+
{
223
223
+
name: "single word lowercase",
224
224
+
input: "hello",
225
225
+
expected: "Hello",
226
226
+
},
227
227
+
{
228
228
+
name: "single word uppercase",
229
229
+
input: "HELLO",
230
230
+
expected: "HELLO",
231
231
+
},
232
232
+
{
233
233
+
name: "multiple words",
234
234
+
input: "hello world",
235
235
+
expected: "Hello World",
236
236
+
},
237
237
+
{
238
238
+
name: "mixed case",
239
239
+
input: "hELLo WoRLD",
240
240
+
expected: "HELLo WoRLD",
241
241
+
},
242
242
+
{
243
243
+
name: "with punctuation",
244
244
+
input: "hello, world!",
245
245
+
expected: "Hello, World!",
246
246
+
},
247
247
+
{
248
248
+
name: "empty string",
249
249
+
input: "",
250
250
+
expected: "",
251
251
+
},
252
252
+
{
253
253
+
name: "with numbers",
254
254
+
input: "hello 123 world",
255
255
+
expected: "Hello 123 World",
256
256
+
},
257
257
+
{
258
258
+
name: "with special characters",
259
259
+
input: "hello-world_test",
260
260
+
expected: "Hello-World_test",
261
261
+
},
262
262
+
{
263
263
+
name: "already title case",
264
264
+
input: "Hello World",
265
265
+
expected: "Hello World",
266
266
+
},
267
267
+
{
268
268
+
name: "single character",
269
269
+
input: "a",
270
270
+
expected: "A",
271
271
+
},
272
272
+
{
273
273
+
name: "apostrophes",
274
274
+
input: "it's a beautiful day",
275
275
+
expected: "It's A Beautiful Day",
276
276
+
},
277
277
+
}
278
278
+
279
279
+
for _, tt := range tests {
280
280
+
t.Run(tt.name, func(t *testing.T) {
281
281
+
result := Titlecase(tt.input)
282
282
+
if result != tt.expected {
283
283
+
t.Errorf("Titlecase(%q) = %q, expected %q", tt.input, result, tt.expected)
284
284
+
}
285
285
+
})
286
286
+
}
287
287
+
}