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