cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐Ÿƒ
charm leaflet readability golang

feat: update task listing with project & tag lists

+2960 -289
+131 -32
ROADMAP.md
··· 2 2 3 3 ## Core Task Management (TaskWarrior-inspired) 4 4 5 - - [x] `list` - Display tasks with filtering and sorting options 6 - - [ ] `projects` - List all project names 7 - - [ ] `tags` - List all tag names 5 + ### Basic Operations 8 6 9 7 - [x] `create|new` - Add new task with description and optional metadata 10 - 8 + - [x] `list` - Display tasks with filtering and sorting options 11 9 - [x] `view` - View task by ID 10 + - [x] `update` - Edit task properties (description, priority, project, tags) 12 11 - [x] `done` - Mark task as completed 13 - - [x] `update` - Edit task properties (description, priority, project, tags) 14 - - [ ] `start/stop` - Track active time on tasks 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 + 15 34 - [ ] `annotate` - Add notes/comments to existing tasks 35 + - [ ] Recurring tasks 36 + - [ ] Smart due date suggestions 37 + - [ ] Completion notifications 16 38 17 - - [x] `delete` - Remove task permanently 39 + ## Content Queue Management 18 40 19 - - [ ] `calendar` - Display tasks in calendar view 20 - - [ ] `timesheet` - Show time tracking summaries 41 + ### Reading Management 21 42 22 - ## Todo.txt Compatibility 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 23 49 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 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 %) 31 58 32 - ## Media Queue Management 59 + ### Watching Management 33 60 34 61 - [ ] `movie add` - Add movie to watch queue 35 62 - [ ] `movie list` - Show movie queue with ratings/metadata ··· 41 68 - [ ] `tv watched|seen` - Mark episodes/seasons as watched 42 69 - [ ] `tv remove|rm` - Remove from TV queue 43 70 44 - ## Reading List Management 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 45 111 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 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 52 126 53 127 ## Data Management 128 + 129 + ### Storage & Sync 54 130 55 131 - [ ] `sync` - Synchronize with remote storage 56 132 - [ ] `sync setup` - Setup remote storage 57 - 133 + - [ ] Local SQLite database with optional cloud sync 134 + - [ ] Multiple profile support 58 135 - [ ] `backup` - Create local backup 136 + - [ ] Backup/restore functionality 59 137 60 - - [ ] `import` - Import from various formats (CSV, JSON, todo.txt) 61 - - [ ] `export` - Export to various formats 138 + ### Configuration 62 139 63 140 - [ ] `config` - Manage configuration settings 64 - 65 141 - [ ] `undo` - Reverse last operation 142 + - [ ] Themes and personalization 143 + - [ ] Customizable output formats 66 144 67 - ## Notes 145 + ## Notes Management 146 + 147 + ### Basic Operations 68 148 69 149 - [x] `create|new` - Creates a new markdown note and optionally opens in configured editor 70 - - Creates a note from existing markdown file content 71 150 - [x] `list` - Opens interactive TUI browser for navigating and viewing notes 72 151 - [x] `read|view` - Displays formatted note content with syntax highlighting 73 - - [x] `edit|update` - Opens configured editor OR Replaces note content with new markdown file 152 + - [x] `edit|update` - Opens configured editor OR replaces note content with new markdown file 74 153 - [x] `remove|rm|delete|del` - Permanently removes the note file and metadata 75 154 155 + ### Advanced Notes Features 156 + 76 157 - [ ] `search` - Search notes by content, title, or tags 77 158 - [ ] `tag` - Add/remove tags from notes 78 159 - [ ] `recent` - Show recently created/modified notes 79 160 - [ ] `templates` - Create notes from predefined templates 80 161 - [ ] `archive` - Archive old notes 81 162 - [ ] `export` - Export notes to various formats 163 + - [ ] 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 69 status, _ := cmd.Flags().GetString("status") 70 70 priority, _ := cmd.Flags().GetString("priority") 71 71 project, _ := cmd.Flags().GetString("project") 72 - 72 + 73 73 return handlers.ListTasks(cmd.Context(), static, showAll, status, priority, project) 74 74 }, 75 75 } ··· 113 113 Short: "List projects", 114 114 Aliases: []string{"proj"}, 115 115 RunE: func(cmd *cobra.Command, args []string) error { 116 - fmt.Println("Listing projects...") 117 - return nil 116 + return handlers.ListProjects(cmd.Context(), args) 118 117 }, 119 118 }) 120 119 ··· 123 122 Short: "List tags", 124 123 Aliases: []string{"t"}, 125 124 RunE: func(cmd *cobra.Command, args []string) error { 126 - fmt.Println("Listing tags...") 127 - return nil 125 + return handlers.ListTags(cmd.Context(), args) 128 126 }, 129 127 }) 130 128
+1 -1
go.mod
··· 67 67 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 68 68 golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect 69 69 golang.org/x/sys v0.33.0 // indirect 70 - golang.org/x/text v0.24.0 // indirect 70 + golang.org/x/text v0.24.0 71 71 )
+40 -5
internal/handlers/tasks.go
··· 154 154 return fmt.Errorf("task ID required") 155 155 } 156 156 157 - // Parse task ID (could be numeric ID or UUID) 158 157 taskID := args[0] 159 158 var task *models.Task 160 159 var err error 161 160 162 - if id, parseErr := strconv.ParseInt(taskID, 10, 64); parseErr == nil { 163 - // Numeric ID 161 + if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { 164 162 task, err = h.repos.Tasks.Get(ctx, id) 165 163 } else { 166 - // Assume UUID 167 164 task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 168 165 } 169 166 ··· 171 168 return fmt.Errorf("failed to find task: %w", err) 172 169 } 173 170 174 - // Parse update arguments 175 171 for i := 1; i < len(args); i++ { 176 172 arg := args[i] 177 173 switch { ··· 338 334 return nil 339 335 } 340 336 337 + // 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 + 341 369 func (h *TaskHandler) printTask(task *models.Task) { 342 370 fmt.Printf("[%d] %s", task.ID, task.Description) 343 371 ··· 414 442 } 415 443 return result 416 444 } 445 + 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 665 }) 666 666 667 667 t.Run("Helper", func(t *testing.T) { 668 - 669 668 t.Run("removeString function", func(t *testing.T) { 670 669 slice := []string{"a", "b", "c", "b"} 671 670 result := removeString(slice, "b") ··· 728 727 }() 729 728 730 729 handler.printTaskDetail(task) 730 + }) 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 + } 731 844 }) 732 845 }) 733 846 }
+108 -20
internal/repo/task_repository.go
··· 10 10 "github.com/stormlightlabs/noteleaf/internal/models" 11 11 ) 12 12 13 + // 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 + 13 39 // TaskRepository provides database operations for tasks 14 40 type TaskRepository struct { 15 41 db *sql.DB ··· 222 248 args = append(args, opts.DueBefore) 223 249 } 224 250 225 - // Search args 226 251 if opts.Search != "" { 227 252 searchPattern := "%" + opts.Search + "%" 228 - // Add search pattern for each search field 229 253 args = append(args, searchPattern, searchPattern, searchPattern) 230 254 } 231 255 ··· 235 259 func (r *TaskRepository) scanTaskRow(rows *sql.Rows, task *models.Task) error { 236 260 var tags, annotations sql.NullString 237 261 238 - 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 { 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 { 241 264 return fmt.Errorf("failed to scan task row: %w", err) 242 265 } 243 266 ··· 322 345 task := &models.Task{} 323 346 var tags, annotations sql.NullString 324 347 325 - err := r.db.QueryRowContext(ctx, query, uuid).Scan( 348 + if err := r.db.QueryRowContext(ctx, query, uuid).Scan( 326 349 &task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority, &task.Project, 327 - &tags, &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations) 328 - if err != nil { 350 + &tags, &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations); err != nil { 329 351 return nil, fmt.Errorf("failed to get task by UUID: %w", err) 330 352 } 331 353 ··· 359 381 return r.List(ctx, TaskListOptions{Project: project}) 360 382 } 361 383 362 - // 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 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() 374 462 }
+271 -152
internal/repo/task_repository_test.go
··· 3 3 import ( 4 4 "context" 5 5 "database/sql" 6 + "slices" 6 7 "testing" 7 8 "time" 8 9 ··· 52 53 53 54 func createSampleTask() *models.Task { 54 55 return &models.Task{ 55 - UUID: uuid.New().String(), 56 + UUID: newUUID(), 56 57 Description: "Test task", 57 58 Status: "pending", 58 59 Priority: "H", ··· 62 63 } 63 64 } 64 65 65 - func TestTaskRepository_CRUD(t *testing.T) { 66 + func newUUID() string { 67 + return uuid.New().String() 68 + } 69 + 70 + func TestTaskRepository(t *testing.T) { 66 71 db := createTaskTestDB(t) 67 72 repo := NewTaskRepository(db) 68 73 ctx := context.Background() ··· 187 192 t.Error("Expected error when getting deleted task") 188 193 } 189 194 }) 190 - } 191 195 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) 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"}, 207 201 } 208 - } 209 202 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) 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 + } 214 208 } 215 209 216 - if len(results) != 3 { 217 - t.Errorf("Expected 3 tasks, got %d", len(results)) 218 - } 219 - }) 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 + } 220 215 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 - } 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 + } 226 243 227 - if len(results) != 2 { 228 - t.Errorf("Expected 2 pending tasks, got %d", len(results)) 229 - } 244 + if len(results) != 2 { 245 + t.Errorf("Expected 2 tasks due to limit, got %d", len(results)) 246 + } 247 + }) 230 248 231 - for _, task := range results { 232 - if task.Status != "pending" { 233 - t.Errorf("Expected pending status, got %s", task.Status) 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) 234 253 } 235 - } 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 + }) 236 263 }) 237 264 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) 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 + } 242 275 } 243 276 244 - if len(results) != 2 { 245 - t.Errorf("Expected 2 tasks due to limit, got %d", len(results)) 246 - } 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 + }) 247 329 }) 248 330 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) 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"}, 253 336 } 254 337 255 - if len(results) != 1 { 256 - t.Errorf("Expected 1 task matching search, got %d", len(results)) 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 + } 257 343 } 258 344 259 - if len(results) > 0 && results[0].Description != "Task 1" { 260 - t.Errorf("Expected 'Task 1', got %s", results[0].Description) 261 - } 262 - }) 263 - } 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 + } 264 350 265 - func TestTaskRepository_SpecialMethods(t *testing.T) { 266 - db := createTaskTestDB(t) 267 - repo := NewTaskRepository(db) 268 - ctx := context.Background() 351 + if count < 3 { 352 + t.Errorf("Expected at least 3 tasks, got %d", count) 353 + } 354 + }) 269 355 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"} 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 + } 273 361 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 - } 362 + if count < 2 { 363 + t.Errorf("Expected at least 2 pending tasks, got %d", count) 364 + } 365 + }) 280 366 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 - } 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 + } 286 372 287 - if len(results) != 2 { 288 - t.Errorf("Expected 2 pending tasks, got %d", len(results)) 289 - } 373 + if count < 1 { 374 + t.Errorf("Expected at least 1 completed task, got %d", count) 375 + } 376 + }) 290 377 }) 291 378 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) 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"}}, 296 386 } 297 387 298 - if len(results) != 1 { 299 - t.Errorf("Expected 1 completed task, got %d", len(results)) 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 + } 300 393 } 301 - }) 302 394 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 - } 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 + } 308 422 309 - if len(results) != 2 { 310 - t.Errorf("Expected 2 tasks in test project, got %d", len(results)) 311 - } 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 + }) 312 431 313 - for _, task := range results { 314 - if task.Project != "test" { 315 - t.Errorf("Expected project 'test', got %s", task.Project) 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 316 441 } 317 - } 318 - }) 319 442 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 - } 443 + expectedMinCounts := map[string]int{ 444 + "android": 1, "backend": 1, "database": 1, "documentation": 1, 445 + "frontend": 2, "ios": 1, "urgent": 2, 446 + } 325 447 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 - } 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 + } 334 457 335 - func TestTaskRepository_Count(t *testing.T) { 336 - db := createTaskTestDB(t) 337 - repo := NewTaskRepository(db) 338 - ctx := context.Background() 458 + if len(tags) < len(expectedMinCounts) { 459 + t.Errorf("Expected at least %d tags, got %d", len(expectedMinCounts), len(tags)) 460 + } 461 + }) 339 462 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 - } 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 + } 345 468 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 - } 469 + if len(frontend) < 2 { 470 + t.Errorf("Expected at least 2 tasks with frontend tag, got %d", len(frontend)) 471 + } 352 472 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 - } 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 + } 358 478 359 - if count != 3 { 360 - t.Errorf("Expected 3 tasks, got %d", count) 361 - } 362 - }) 479 + urgent, err := repo.GetTasksByTag(ctx, "urgent") 480 + if err != nil { 481 + t.Errorf("Failed to get tasks by tag: %v", err) 482 + } 363 483 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 - } 484 + if len(urgent) < 2 { 485 + t.Errorf("Expected at least 2 tasks with urgent tag, got %d", len(urgent)) 486 + } 369 487 370 - if count != 2 { 371 - t.Errorf("Expected 2 pending tasks, got %d", count) 372 - } 373 - }) 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 + } 374 493 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 - } 494 + nonexistent, err := repo.GetTasksByTag(ctx, "nonexistent") 495 + if err != nil { 496 + t.Errorf("Failed to get tasks by nonexistent tag: %v", err) 497 + } 380 498 381 - if count != 1 { 382 - t.Errorf("Expected 1 completed task, got %d", count) 383 - } 499 + if len(nonexistent) != 0 { 500 + t.Errorf("Expected 0 tasks with nonexistent tag, got %d", len(nonexistent)) 501 + } 502 + }) 384 503 }) 385 504 }
+290
internal/ui/project_list.go
··· 1 + package ui 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "os" 8 + "strings" 9 + 10 + "github.com/charmbracelet/bubbles/help" 11 + "github.com/charmbracelet/bubbles/key" 12 + tea "github.com/charmbracelet/bubbletea" 13 + "github.com/charmbracelet/lipgloss" 14 + "github.com/stormlightlabs/noteleaf/internal/repo" 15 + ) 16 + 17 + // Project list key bindings 18 + type projectKeyMap struct { 19 + Up key.Binding 20 + Down key.Binding 21 + Enter key.Binding 22 + Refresh key.Binding 23 + Quit key.Binding 24 + Back key.Binding 25 + Help key.Binding 26 + Numbers []key.Binding 27 + } 28 + 29 + func (k projectKeyMap) ShortHelp() []key.Binding { 30 + return []key.Binding{k.Up, k.Down, k.Enter, k.Help, k.Quit} 31 + } 32 + 33 + func (k projectKeyMap) FullHelp() [][]key.Binding { 34 + return [][]key.Binding{ 35 + {k.Up, k.Down, k.Enter, k.Refresh}, 36 + {k.Help, k.Quit, k.Back}, 37 + } 38 + } 39 + 40 + var projectKeys = projectKeyMap{ 41 + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("โ†‘/k", "move up")), 42 + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("โ†“/j", "move down")), 43 + Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select project")), 44 + Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), 45 + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), 46 + Back: key.NewBinding(key.WithKeys("esc", "backspace"), key.WithHelp("esc", "back")), 47 + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), 48 + Numbers: []key.Binding{ 49 + key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "jump to project 1")), 50 + key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "jump to project 2")), 51 + key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "jump to project 3")), 52 + key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "jump to project 4")), 53 + key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "jump to project 5")), 54 + key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "jump to project 6")), 55 + key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "jump to project 7")), 56 + key.NewBinding(key.WithKeys("8"), key.WithHelp("8", "jump to project 8")), 57 + key.NewBinding(key.WithKeys("9"), key.WithHelp("9", "jump to project 9")), 58 + }, 59 + } 60 + 61 + // ProjectRepository interface for dependency injection in tests 62 + type ProjectRepository interface { 63 + GetProjects(ctx context.Context) ([]repo.ProjectSummary, error) 64 + } 65 + 66 + // ProjectListOptions configures the project list UI behavior 67 + type ProjectListOptions struct { 68 + // Output destination (stdout for interactive, buffer for testing) 69 + Output io.Writer 70 + // Input source (stdin for interactive, strings reader for testing) 71 + Input io.Reader 72 + // Enable static mode (no interactive components) 73 + Static bool 74 + } 75 + 76 + // ProjectList handles project browsing UI 77 + type ProjectList struct { 78 + repo ProjectRepository 79 + opts ProjectListOptions 80 + } 81 + 82 + // NewProjectList creates a new project list UI component 83 + func NewProjectList(repo ProjectRepository, opts ProjectListOptions) *ProjectList { 84 + if opts.Output == nil { 85 + opts.Output = os.Stdout 86 + } 87 + if opts.Input == nil { 88 + opts.Input = os.Stdin 89 + } 90 + return &ProjectList{repo: repo, opts: opts} 91 + } 92 + 93 + type ( 94 + projectsLoadedMsg []repo.ProjectSummary 95 + errorProjectMsg error 96 + projectListModel struct { 97 + projects []repo.ProjectSummary 98 + selected int 99 + err error 100 + repo ProjectRepository 101 + opts ProjectListOptions 102 + keys projectKeyMap 103 + help help.Model 104 + showingHelp bool 105 + } 106 + ) 107 + 108 + func (m projectListModel) Init() tea.Cmd { 109 + return m.loadProjects() 110 + } 111 + 112 + func (m projectListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 113 + switch msg := msg.(type) { 114 + case tea.KeyMsg: 115 + if m.showingHelp { 116 + switch { 117 + case key.Matches(msg, m.keys.Back) || key.Matches(msg, m.keys.Quit) || key.Matches(msg, m.keys.Help): 118 + m.showingHelp = false 119 + return m, nil 120 + } 121 + return m, nil 122 + } 123 + 124 + switch { 125 + case key.Matches(msg, m.keys.Quit): 126 + return m, tea.Quit 127 + case key.Matches(msg, m.keys.Up): 128 + if m.selected > 0 { 129 + m.selected-- 130 + } 131 + case key.Matches(msg, m.keys.Down): 132 + if m.selected < len(m.projects)-1 { 133 + m.selected++ 134 + } 135 + case key.Matches(msg, m.keys.Enter): 136 + if len(m.projects) > 0 && m.selected < len(m.projects) { 137 + // TODO: navigate to tasks for that project 138 + return m, tea.Quit 139 + } 140 + case key.Matches(msg, m.keys.Refresh): 141 + return m, m.loadProjects() 142 + case key.Matches(msg, m.keys.Help): 143 + m.showingHelp = true 144 + return m, nil 145 + default: 146 + for i, numKey := range m.keys.Numbers { 147 + if key.Matches(msg, numKey) && i < len(m.projects) { 148 + m.selected = i 149 + break 150 + } 151 + } 152 + } 153 + case projectsLoadedMsg: 154 + m.projects = []repo.ProjectSummary(msg) 155 + if m.selected >= len(m.projects) && len(m.projects) > 0 { 156 + m.selected = len(m.projects) - 1 157 + } 158 + case errorProjectMsg: 159 + m.err = error(msg) 160 + } 161 + return m, nil 162 + } 163 + 164 + func (m projectListModel) View() string { 165 + var s strings.Builder 166 + 167 + style := lipgloss.NewStyle().Foreground(lipgloss.Color(Squid.Hex())) 168 + 169 + if m.showingHelp { 170 + return m.help.View(m.keys) 171 + } 172 + 173 + s.WriteString(TitleColorStyle.Render("Projects")) 174 + s.WriteString("\n\n") 175 + 176 + if m.err != nil { 177 + s.WriteString(fmt.Sprintf("Error: %s", m.err)) 178 + return s.String() 179 + } 180 + 181 + if len(m.projects) == 0 { 182 + s.WriteString("No projects found") 183 + s.WriteString("\n\n") 184 + s.WriteString(style.Render("Press r to refresh, q to quit")) 185 + return s.String() 186 + } 187 + 188 + headerLine := fmt.Sprintf(" %-30s %-15s", "Project Name", "Task Count") 189 + s.WriteString(HeaderColorStyle.Render(headerLine)) 190 + s.WriteString("\n") 191 + s.WriteString(HeaderColorStyle.Render(strings.Repeat("โ”€", 50))) 192 + s.WriteString("\n") 193 + 194 + for i, project := range m.projects { 195 + prefix := " " 196 + if i == m.selected { 197 + prefix = " > " 198 + } 199 + 200 + projectName := project.Name 201 + if len(projectName) > 28 { 202 + projectName = projectName[:25] + "..." 203 + } 204 + 205 + taskCountStr := fmt.Sprintf("%d task%s", project.TaskCount, pluralizeCount(project.TaskCount)) 206 + 207 + line := fmt.Sprintf("%s%-30s %-15s", prefix, projectName, taskCountStr) 208 + 209 + if i == m.selected { 210 + s.WriteString(SelectedColorStyle.Render(line)) 211 + } else { 212 + s.WriteString(style.Render(line)) 213 + } 214 + 215 + s.WriteString("\n") 216 + } 217 + 218 + s.WriteString("\n") 219 + s.WriteString(m.help.View(m.keys)) 220 + 221 + return s.String() 222 + } 223 + 224 + func (m projectListModel) loadProjects() tea.Cmd { 225 + return func() tea.Msg { 226 + projects, err := m.repo.GetProjects(context.Background()) 227 + if err != nil { 228 + return errorProjectMsg(err) 229 + } 230 + 231 + return projectsLoadedMsg(projects) 232 + } 233 + } 234 + 235 + // Browse opens an interactive TUI for navigating projects 236 + func (pl *ProjectList) Browse(ctx context.Context) error { 237 + if pl.opts.Static { 238 + return pl.staticList(ctx) 239 + } 240 + 241 + model := projectListModel{ 242 + repo: pl.repo, 243 + opts: pl.opts, 244 + keys: projectKeys, 245 + help: help.New(), 246 + } 247 + 248 + program := tea.NewProgram(model, tea.WithInput(pl.opts.Input), tea.WithOutput(pl.opts.Output)) 249 + 250 + _, err := program.Run() 251 + return err 252 + } 253 + 254 + func (pl *ProjectList) staticList(ctx context.Context) error { 255 + projects, err := pl.repo.GetProjects(ctx) 256 + if err != nil { 257 + fmt.Fprintf(pl.opts.Output, "Error: %s\n", err) 258 + return err 259 + } 260 + 261 + fmt.Fprintf(pl.opts.Output, "Projects\n\n") 262 + 263 + if len(projects) == 0 { 264 + fmt.Fprintf(pl.opts.Output, "No projects found\n") 265 + return nil 266 + } 267 + 268 + fmt.Fprintf(pl.opts.Output, "%-30s %-15s\n", "Project Name", "Task Count") 269 + fmt.Fprintf(pl.opts.Output, "%s\n", strings.Repeat("โ”€", 50)) 270 + 271 + for _, project := range projects { 272 + projectName := project.Name 273 + if len(projectName) > 28 { 274 + projectName = projectName[:25] + "..." 275 + } 276 + 277 + taskCountStr := fmt.Sprintf("%d task%s", project.TaskCount, pluralizeCount(project.TaskCount)) 278 + 279 + fmt.Fprintf(pl.opts.Output, "%-30s %-15s\n", projectName, taskCountStr) 280 + } 281 + 282 + return nil 283 + } 284 + 285 + func pluralizeCount(count int) string { 286 + if count == 1 { 287 + return "" 288 + } 289 + return "s" 290 + }
+375
internal/ui/project_list_test.go
··· 1 + package ui 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "strings" 8 + "testing" 9 + 10 + tea "github.com/charmbracelet/bubbletea" 11 + "github.com/stormlightlabs/noteleaf/internal/repo" 12 + ) 13 + 14 + // Mock project repository for testing 15 + type mockProjectRepository struct { 16 + projects []repo.ProjectSummary 17 + err error 18 + } 19 + 20 + func (m *mockProjectRepository) GetProjects(ctx context.Context) ([]repo.ProjectSummary, error) { 21 + if m.err != nil { 22 + return nil, m.err 23 + } 24 + return m.projects, nil 25 + } 26 + 27 + func TestProjectList(t *testing.T) { 28 + t.Run("NewProjectList", func(t *testing.T) { 29 + t.Run("creates project list successfully", func(t *testing.T) { 30 + mockRepo := &mockProjectRepository{} 31 + opts := ProjectListOptions{} 32 + 33 + projectList := NewProjectList(mockRepo, opts) 34 + 35 + if projectList == nil { 36 + t.Fatal("ProjectList should not be nil") 37 + } 38 + if projectList.repo != mockRepo { 39 + t.Error("ProjectList repo should be set correctly") 40 + } 41 + }) 42 + 43 + t.Run("sets default options", func(t *testing.T) { 44 + mockRepo := &mockProjectRepository{} 45 + opts := ProjectListOptions{} 46 + 47 + projectList := NewProjectList(mockRepo, opts) 48 + 49 + if projectList.opts.Output == nil { 50 + t.Error("Default output should be set") 51 + } 52 + if projectList.opts.Input == nil { 53 + t.Error("Default input should be set") 54 + } 55 + }) 56 + 57 + t.Run("preserves custom options", func(t *testing.T) { 58 + mockRepo := &mockProjectRepository{} 59 + output := &bytes.Buffer{} 60 + input := strings.NewReader("") 61 + opts := ProjectListOptions{ 62 + Output: output, 63 + Input: input, 64 + Static: true, 65 + } 66 + 67 + projectList := NewProjectList(mockRepo, opts) 68 + 69 + if projectList.opts.Output != output { 70 + t.Error("Custom output should be preserved") 71 + } 72 + if projectList.opts.Input != input { 73 + t.Error("Custom input should be preserved") 74 + } 75 + if !projectList.opts.Static { 76 + t.Error("Static option should be preserved") 77 + } 78 + }) 79 + }) 80 + 81 + t.Run("StaticList", func(t *testing.T) { 82 + t.Run("displays projects correctly", func(t *testing.T) { 83 + projects := []repo.ProjectSummary{ 84 + {Name: "web-app", TaskCount: 5}, 85 + {Name: "mobile-app", TaskCount: 3}, 86 + {Name: "documentation", TaskCount: 1}, 87 + } 88 + mockRepo := &mockProjectRepository{projects: projects} 89 + output := &bytes.Buffer{} 90 + opts := ProjectListOptions{Output: output, Static: true} 91 + 92 + projectList := NewProjectList(mockRepo, opts) 93 + err := projectList.Browse(context.Background()) 94 + 95 + if err != nil { 96 + t.Errorf("Browse should not return error: %v", err) 97 + } 98 + 99 + result := output.String() 100 + if !strings.Contains(result, "Projects") { 101 + t.Error("Output should contain title") 102 + } 103 + if !strings.Contains(result, "web-app") { 104 + t.Error("Output should contain web-app project") 105 + } 106 + if !strings.Contains(result, "mobile-app") { 107 + t.Error("Output should contain mobile-app project") 108 + } 109 + if !strings.Contains(result, "5 tasks") { 110 + t.Error("Output should show correct task count for web-app") 111 + } 112 + if !strings.Contains(result, "1 task") { 113 + t.Error("Output should show singular task for documentation") 114 + } 115 + }) 116 + 117 + t.Run("handles empty project list", func(t *testing.T) { 118 + mockRepo := &mockProjectRepository{projects: []repo.ProjectSummary{}} 119 + output := &bytes.Buffer{} 120 + opts := ProjectListOptions{Output: output, Static: true} 121 + 122 + projectList := NewProjectList(mockRepo, opts) 123 + err := projectList.Browse(context.Background()) 124 + 125 + if err != nil { 126 + t.Errorf("Browse should not return error: %v", err) 127 + } 128 + 129 + result := output.String() 130 + if !strings.Contains(result, "No projects found") { 131 + t.Error("Output should indicate no projects found") 132 + } 133 + }) 134 + 135 + t.Run("handles repository errors", func(t *testing.T) { 136 + mockRepo := &mockProjectRepository{err: fmt.Errorf("database error")} 137 + output := &bytes.Buffer{} 138 + opts := ProjectListOptions{Output: output, Static: true} 139 + 140 + projectList := NewProjectList(mockRepo, opts) 141 + err := projectList.Browse(context.Background()) 142 + 143 + if err == nil { 144 + t.Error("Browse should return error when repository fails") 145 + } 146 + 147 + result := output.String() 148 + if !strings.Contains(result, "Error:") { 149 + t.Error("Output should contain error message") 150 + } 151 + }) 152 + 153 + t.Run("truncates long project names", func(t *testing.T) { 154 + projects := []repo.ProjectSummary{ 155 + {Name: "this-is-a-very-long-project-name-that-should-be-truncated", TaskCount: 2}, 156 + } 157 + mockRepo := &mockProjectRepository{projects: projects} 158 + output := &bytes.Buffer{} 159 + opts := ProjectListOptions{Output: output, Static: true} 160 + 161 + projectList := NewProjectList(mockRepo, opts) 162 + err := projectList.Browse(context.Background()) 163 + 164 + if err != nil { 165 + t.Errorf("Browse should not return error: %v", err) 166 + } 167 + 168 + result := output.String() 169 + if !strings.Contains(result, "...") { 170 + t.Error("Output should truncate long project names") 171 + } 172 + }) 173 + }) 174 + 175 + t.Run("ProjectListModel", func(t *testing.T) { 176 + t.Run("initializes correctly", func(t *testing.T) { 177 + model := projectListModel{ 178 + selected: 0, 179 + showingHelp: false, 180 + } 181 + 182 + if model.selected != 0 { 183 + t.Error("Initial selection should be 0") 184 + } 185 + if model.showingHelp { 186 + t.Error("Should not be showing help initially") 187 + } 188 + }) 189 + 190 + t.Run("handles key navigation", func(t *testing.T) { 191 + projects := []repo.ProjectSummary{ 192 + {Name: "project1", TaskCount: 1}, 193 + {Name: "project2", TaskCount: 2}, 194 + {Name: "project3", TaskCount: 3}, 195 + } 196 + 197 + model := projectListModel{ 198 + projects: projects, 199 + selected: 1, 200 + keys: projectKeys, 201 + } 202 + 203 + // Test down key 204 + downMsg := tea.KeyMsg{Type: tea.KeyDown} 205 + updatedModel, _ := model.Update(downMsg) 206 + if updatedModel.(projectListModel).selected != 2 { 207 + t.Error("Down key should move selection down") 208 + } 209 + 210 + // Test up key 211 + upMsg := tea.KeyMsg{Type: tea.KeyUp} 212 + updatedModel, _ = updatedModel.Update(upMsg) 213 + if updatedModel.(projectListModel).selected != 1 { 214 + t.Error("Up key should move selection up") 215 + } 216 + 217 + // Test boundary conditions 218 + model.selected = 0 219 + updatedModel, _ = model.Update(upMsg) 220 + if updatedModel.(projectListModel).selected != 0 { 221 + t.Error("Up key should not move selection below 0") 222 + } 223 + 224 + model.selected = len(projects) - 1 225 + updatedModel, _ = model.Update(downMsg) 226 + if updatedModel.(projectListModel).selected != len(projects)-1 { 227 + t.Error("Down key should not move selection beyond list length") 228 + } 229 + }) 230 + 231 + t.Run("handles number key selection", func(t *testing.T) { 232 + projects := []repo.ProjectSummary{ 233 + {Name: "project1", TaskCount: 1}, 234 + {Name: "project2", TaskCount: 2}, 235 + {Name: "project3", TaskCount: 3}, 236 + } 237 + 238 + model := projectListModel{ 239 + projects: projects, 240 + selected: 0, 241 + keys: projectKeys, 242 + } 243 + 244 + // Test number key 3 (index 2) 245 + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'3'}} 246 + updatedModel, _ := model.Update(keyMsg) 247 + if updatedModel.(projectListModel).selected != 2 { 248 + t.Error("Number key 3 should select index 2") 249 + } 250 + }) 251 + 252 + t.Run("handles help toggle", func(t *testing.T) { 253 + model := projectListModel{ 254 + keys: projectKeys, 255 + } 256 + 257 + // Toggle help on 258 + helpMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}} 259 + updatedModel, _ := model.Update(helpMsg) 260 + if !updatedModel.(projectListModel).showingHelp { 261 + t.Error("Help key should show help") 262 + } 263 + 264 + // Toggle help off 265 + updatedModel, _ = updatedModel.Update(helpMsg) 266 + if updatedModel.(projectListModel).showingHelp { 267 + t.Error("Help key should hide help when already showing") 268 + } 269 + }) 270 + 271 + t.Run("handles projects loaded message", func(t *testing.T) { 272 + projects := []repo.ProjectSummary{ 273 + {Name: "new-project", TaskCount: 5}, 274 + } 275 + 276 + model := projectListModel{ 277 + selected: 5, // Invalid selection 278 + } 279 + 280 + msg := projectsLoadedMsg(projects) 281 + updatedModel, _ := model.Update(msg) 282 + resultModel := updatedModel.(projectListModel) 283 + 284 + if len(resultModel.projects) != 1 { 285 + t.Error("Projects should be loaded correctly") 286 + } 287 + if resultModel.selected != 0 { 288 + t.Error("Selection should be reset to valid range") 289 + } 290 + }) 291 + 292 + t.Run("handles error message", func(t *testing.T) { 293 + model := projectListModel{} 294 + 295 + errorMsg := errorProjectMsg(fmt.Errorf("test error")) 296 + updatedModel, _ := model.Update(errorMsg) 297 + resultModel := updatedModel.(projectListModel) 298 + 299 + if resultModel.err == nil { 300 + t.Error("Error should be set") 301 + } 302 + if resultModel.err.Error() != "test error" { 303 + t.Errorf("Expected 'test error', got '%s'", resultModel.err.Error()) 304 + } 305 + }) 306 + 307 + t.Run("view renders correctly", func(t *testing.T) { 308 + projects := []repo.ProjectSummary{ 309 + {Name: "web-app", TaskCount: 5}, 310 + {Name: "mobile-app", TaskCount: 1}, 311 + } 312 + 313 + model := projectListModel{ 314 + projects: projects, 315 + selected: 0, 316 + keys: projectKeys, 317 + } 318 + 319 + view := model.View() 320 + if !strings.Contains(view, "Projects") { 321 + t.Error("View should contain title") 322 + } 323 + if !strings.Contains(view, "web-app") { 324 + t.Error("View should contain project names") 325 + } 326 + if !strings.Contains(view, "5 tasks") { 327 + t.Error("View should show task counts") 328 + } 329 + if !strings.Contains(view, "1 task") { 330 + t.Error("View should show singular task count") 331 + } 332 + }) 333 + 334 + t.Run("view handles empty state", func(t *testing.T) { 335 + model := projectListModel{ 336 + projects: []repo.ProjectSummary{}, 337 + } 338 + 339 + view := model.View() 340 + if !strings.Contains(view, "No projects found") { 341 + t.Error("View should show empty state message") 342 + } 343 + }) 344 + 345 + t.Run("view handles error state", func(t *testing.T) { 346 + model := projectListModel{ 347 + err: fmt.Errorf("test error"), 348 + } 349 + 350 + view := model.View() 351 + if !strings.Contains(view, "Error:") { 352 + t.Error("View should show error message") 353 + } 354 + }) 355 + }) 356 + 357 + t.Run("PluralizeCount", func(t *testing.T) { 358 + t.Run("returns empty string for singular", func(t *testing.T) { 359 + result := pluralizeCount(1) 360 + if result != "" { 361 + t.Errorf("Expected empty string for 1, got '%s'", result) 362 + } 363 + }) 364 + 365 + t.Run("returns 's' for plural", func(t *testing.T) { 366 + testCases := []int{0, 2, 10, 100} 367 + for _, count := range testCases { 368 + result := pluralizeCount(count) 369 + if result != "s" { 370 + t.Errorf("Expected 's' for %d, got '%s'", count, result) 371 + } 372 + } 373 + }) 374 + }) 375 + }
+287
internal/ui/tag_list.go
··· 1 + package ui 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "os" 8 + "strings" 9 + 10 + "github.com/charmbracelet/bubbles/help" 11 + "github.com/charmbracelet/bubbles/key" 12 + tea "github.com/charmbracelet/bubbletea" 13 + "github.com/charmbracelet/lipgloss" 14 + "github.com/stormlightlabs/noteleaf/internal/repo" 15 + ) 16 + 17 + // Tag list key bindings 18 + type tagKeyMap struct { 19 + Up key.Binding 20 + Down key.Binding 21 + Enter key.Binding 22 + Refresh key.Binding 23 + Quit key.Binding 24 + Back key.Binding 25 + Help key.Binding 26 + Numbers []key.Binding 27 + } 28 + 29 + func (k tagKeyMap) ShortHelp() []key.Binding { 30 + return []key.Binding{k.Up, k.Down, k.Enter, k.Help, k.Quit} 31 + } 32 + 33 + func (k tagKeyMap) FullHelp() [][]key.Binding { 34 + return [][]key.Binding{ 35 + {k.Up, k.Down, k.Enter, k.Refresh}, 36 + {k.Help, k.Quit, k.Back}, 37 + } 38 + } 39 + 40 + var tagKeys = tagKeyMap{ 41 + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("โ†‘/k", "move up")), 42 + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("โ†“/j", "move down")), 43 + Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select tag")), 44 + Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), 45 + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), 46 + Back: key.NewBinding(key.WithKeys("esc", "backspace"), key.WithHelp("esc", "back")), 47 + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), 48 + Numbers: []key.Binding{ 49 + key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "jump to tag 1")), 50 + key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "jump to tag 2")), 51 + key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "jump to tag 3")), 52 + key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "jump to tag 4")), 53 + key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "jump to tag 5")), 54 + key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "jump to tag 6")), 55 + key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "jump to tag 7")), 56 + key.NewBinding(key.WithKeys("8"), key.WithHelp("8", "jump to tag 8")), 57 + key.NewBinding(key.WithKeys("9"), key.WithHelp("9", "jump to tag 9")), 58 + }, 59 + } 60 + 61 + type ( 62 + // TagRepository interface for dependency injection in tests 63 + TagRepository interface { 64 + GetTags(ctx context.Context) ([]repo.TagSummary, error) 65 + } 66 + 67 + // TagListOptions configures the tag list UI behavior 68 + TagListOptions struct { 69 + // Output destination (stdout for interactive, buffer for testing) 70 + Output io.Writer 71 + // Input source (stdin for interactive, strings reader for testing) 72 + Input io.Reader 73 + // Enable static mode (no interactive components) 74 + Static bool 75 + } 76 + 77 + // TagList handles tag browsing UI 78 + TagList struct { 79 + repo TagRepository 80 + opts TagListOptions 81 + } 82 + ) 83 + 84 + // NewTagList creates a new tag list UI component 85 + func NewTagList(repo TagRepository, opts TagListOptions) *TagList { 86 + if opts.Output == nil { 87 + opts.Output = os.Stdout 88 + } 89 + if opts.Input == nil { 90 + opts.Input = os.Stdin 91 + } 92 + return &TagList{repo: repo, opts: opts} 93 + } 94 + 95 + type ( 96 + tagsLoadedMsg []repo.TagSummary 97 + errorTagMsg error 98 + tagListModel struct { 99 + tags []repo.TagSummary 100 + selected int 101 + err error 102 + repo TagRepository 103 + opts TagListOptions 104 + keys tagKeyMap 105 + help help.Model 106 + showingHelp bool 107 + } 108 + ) 109 + 110 + func (m tagListModel) Init() tea.Cmd { 111 + return m.loadTags() 112 + } 113 + 114 + func (m tagListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 115 + switch msg := msg.(type) { 116 + case tea.KeyMsg: 117 + if m.showingHelp { 118 + switch { 119 + case key.Matches(msg, m.keys.Back) || key.Matches(msg, m.keys.Quit) || key.Matches(msg, m.keys.Help): 120 + m.showingHelp = false 121 + return m, nil 122 + } 123 + return m, nil 124 + } 125 + 126 + switch { 127 + case key.Matches(msg, m.keys.Quit): 128 + return m, tea.Quit 129 + case key.Matches(msg, m.keys.Up): 130 + if m.selected > 0 { 131 + m.selected-- 132 + } 133 + case key.Matches(msg, m.keys.Down): 134 + if m.selected < len(m.tags)-1 { 135 + m.selected++ 136 + } 137 + case key.Matches(msg, m.keys.Enter): 138 + if len(m.tags) > 0 && m.selected < len(m.tags) { 139 + // For now, just show the selected tag name 140 + // In a real implementation, this might navigate to tasks with that tag 141 + return m, tea.Quit 142 + } 143 + case key.Matches(msg, m.keys.Refresh): 144 + return m, m.loadTags() 145 + case key.Matches(msg, m.keys.Help): 146 + m.showingHelp = true 147 + return m, nil 148 + default: 149 + // Handle number keys for quick selection 150 + for i, numKey := range m.keys.Numbers { 151 + if key.Matches(msg, numKey) && i < len(m.tags) { 152 + m.selected = i 153 + break 154 + } 155 + } 156 + } 157 + case tagsLoadedMsg: 158 + m.tags = []repo.TagSummary(msg) 159 + if m.selected >= len(m.tags) && len(m.tags) > 0 { 160 + m.selected = len(m.tags) - 1 161 + } 162 + case errorTagMsg: 163 + m.err = error(msg) 164 + } 165 + return m, nil 166 + } 167 + 168 + func (m tagListModel) View() string { 169 + var s strings.Builder 170 + 171 + style := lipgloss.NewStyle().Foreground(lipgloss.Color(Squid.Hex())) 172 + 173 + if m.showingHelp { 174 + return m.help.View(m.keys) 175 + } 176 + 177 + s.WriteString(TitleColorStyle.Render("Tags")) 178 + s.WriteString("\n\n") 179 + 180 + if m.err != nil { 181 + s.WriteString(fmt.Sprintf("Error: %s", m.err)) 182 + return s.String() 183 + } 184 + 185 + if len(m.tags) == 0 { 186 + s.WriteString("No tags found") 187 + s.WriteString("\n\n") 188 + s.WriteString(style.Render("Press r to refresh, q to quit")) 189 + return s.String() 190 + } 191 + 192 + headerLine := fmt.Sprintf(" %-25s %-15s", "Tag Name", "Task Count") 193 + s.WriteString(HeaderColorStyle.Render(headerLine)) 194 + s.WriteString("\n") 195 + s.WriteString(HeaderColorStyle.Render(strings.Repeat("โ”€", 45))) 196 + s.WriteString("\n") 197 + 198 + for i, tag := range m.tags { 199 + prefix := " " 200 + if i == m.selected { 201 + prefix = " > " 202 + } 203 + 204 + tagName := tag.Name 205 + if len(tagName) > 23 { 206 + tagName = tagName[:20] + "..." 207 + } 208 + 209 + taskCountStr := fmt.Sprintf("%d task%s", tag.TaskCount, pluralizeCount(tag.TaskCount)) 210 + 211 + line := fmt.Sprintf("%s%-25s %-15s", prefix, tagName, taskCountStr) 212 + 213 + if i == m.selected { 214 + s.WriteString(SelectedColorStyle.Render(line)) 215 + } else { 216 + s.WriteString(style.Render(line)) 217 + } 218 + 219 + s.WriteString("\n") 220 + } 221 + 222 + s.WriteString("\n") 223 + s.WriteString(m.help.View(m.keys)) 224 + 225 + return s.String() 226 + } 227 + 228 + func (m tagListModel) loadTags() tea.Cmd { 229 + return func() tea.Msg { 230 + tags, err := m.repo.GetTags(context.Background()) 231 + if err != nil { 232 + return errorTagMsg(err) 233 + } 234 + 235 + return tagsLoadedMsg(tags) 236 + } 237 + } 238 + 239 + // Browse opens an interactive TUI for navigating tags 240 + func (tl *TagList) Browse(ctx context.Context) error { 241 + if tl.opts.Static { 242 + return tl.staticList(ctx) 243 + } 244 + 245 + model := tagListModel{ 246 + repo: tl.repo, 247 + opts: tl.opts, 248 + keys: tagKeys, 249 + help: help.New(), 250 + } 251 + 252 + program := tea.NewProgram(model, tea.WithInput(tl.opts.Input), tea.WithOutput(tl.opts.Output)) 253 + 254 + _, err := program.Run() 255 + return err 256 + } 257 + 258 + func (tl *TagList) staticList(ctx context.Context) error { 259 + tags, err := tl.repo.GetTags(ctx) 260 + if err != nil { 261 + fmt.Fprintf(tl.opts.Output, "Error: %s\n", err) 262 + return err 263 + } 264 + 265 + fmt.Fprintf(tl.opts.Output, "Tags\n\n") 266 + 267 + if len(tags) == 0 { 268 + fmt.Fprintf(tl.opts.Output, "No tags found\n") 269 + return nil 270 + } 271 + 272 + fmt.Fprintf(tl.opts.Output, "%-25s %-15s\n", "Tag Name", "Task Count") 273 + fmt.Fprintf(tl.opts.Output, "%s\n", strings.Repeat("โ”€", 45)) 274 + 275 + for _, tag := range tags { 276 + tagName := tag.Name 277 + if len(tagName) > 23 { 278 + tagName = tagName[:20] + "..." 279 + } 280 + 281 + taskCountStr := fmt.Sprintf("%d task%s", tag.TaskCount, pluralizeCount(tag.TaskCount)) 282 + 283 + fmt.Fprintf(tl.opts.Output, "%-25s %-15s\n", tagName, taskCountStr) 284 + } 285 + 286 + return nil 287 + }
+354
internal/ui/tag_list_test.go
··· 1 + package ui 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "strings" 8 + "testing" 9 + 10 + tea "github.com/charmbracelet/bubbletea" 11 + "github.com/stormlightlabs/noteleaf/internal/repo" 12 + ) 13 + 14 + // Mock tag repository for testing 15 + type mockTagRepository struct { 16 + tags []repo.TagSummary 17 + err error 18 + } 19 + 20 + func (m *mockTagRepository) GetTags(ctx context.Context) ([]repo.TagSummary, error) { 21 + if m.err != nil { 22 + return nil, m.err 23 + } 24 + return m.tags, nil 25 + } 26 + 27 + func TestTagList(t *testing.T) { 28 + t.Run("NewTagList", func(t *testing.T) { 29 + t.Run("creates tag list successfully", func(t *testing.T) { 30 + mockRepo := &mockTagRepository{} 31 + opts := TagListOptions{} 32 + 33 + tagList := NewTagList(mockRepo, opts) 34 + 35 + if tagList == nil { 36 + t.Fatal("TagList should not be nil") 37 + } 38 + if tagList.repo != mockRepo { 39 + t.Error("TagList repo should be set correctly") 40 + } 41 + }) 42 + 43 + t.Run("sets default options", func(t *testing.T) { 44 + mockRepo := &mockTagRepository{} 45 + opts := TagListOptions{} 46 + 47 + tagList := NewTagList(mockRepo, opts) 48 + 49 + if tagList.opts.Output == nil { 50 + t.Error("Default output should be set") 51 + } 52 + if tagList.opts.Input == nil { 53 + t.Error("Default input should be set") 54 + } 55 + }) 56 + 57 + t.Run("preserves custom options", func(t *testing.T) { 58 + mockRepo := &mockTagRepository{} 59 + output := &bytes.Buffer{} 60 + input := strings.NewReader("") 61 + opts := TagListOptions{ 62 + Output: output, 63 + Input: input, 64 + Static: true, 65 + } 66 + 67 + tagList := NewTagList(mockRepo, opts) 68 + 69 + if tagList.opts.Output != output { 70 + t.Error("Custom output should be preserved") 71 + } 72 + if tagList.opts.Input != input { 73 + t.Error("Custom input should be preserved") 74 + } 75 + if !tagList.opts.Static { 76 + t.Error("Static option should be preserved") 77 + } 78 + }) 79 + }) 80 + 81 + t.Run("StaticList", func(t *testing.T) { 82 + t.Run("displays tags correctly", func(t *testing.T) { 83 + tags := []repo.TagSummary{ 84 + {Name: "frontend", TaskCount: 5}, 85 + {Name: "backend", TaskCount: 3}, 86 + {Name: "urgent", TaskCount: 1}, 87 + } 88 + mockRepo := &mockTagRepository{tags: tags} 89 + output := &bytes.Buffer{} 90 + opts := TagListOptions{Output: output, Static: true} 91 + 92 + tagList := NewTagList(mockRepo, opts) 93 + err := tagList.Browse(context.Background()) 94 + 95 + if err != nil { 96 + t.Errorf("Browse should not return error: %v", err) 97 + } 98 + 99 + result := output.String() 100 + if !strings.Contains(result, "Tags") { 101 + t.Error("Output should contain title") 102 + } 103 + if !strings.Contains(result, "frontend") { 104 + t.Error("Output should contain frontend tag") 105 + } 106 + if !strings.Contains(result, "backend") { 107 + t.Error("Output should contain backend tag") 108 + } 109 + if !strings.Contains(result, "5 tasks") { 110 + t.Error("Output should show correct task count for frontend") 111 + } 112 + if !strings.Contains(result, "1 task") { 113 + t.Error("Output should show singular task for urgent") 114 + } 115 + }) 116 + 117 + t.Run("handles empty tag list", func(t *testing.T) { 118 + mockRepo := &mockTagRepository{tags: []repo.TagSummary{}} 119 + output := &bytes.Buffer{} 120 + opts := TagListOptions{Output: output, Static: true} 121 + 122 + tagList := NewTagList(mockRepo, opts) 123 + err := tagList.Browse(context.Background()) 124 + 125 + if err != nil { 126 + t.Errorf("Browse should not return error: %v", err) 127 + } 128 + 129 + result := output.String() 130 + if !strings.Contains(result, "No tags found") { 131 + t.Error("Output should indicate no tags found") 132 + } 133 + }) 134 + 135 + t.Run("handles repository errors", func(t *testing.T) { 136 + mockRepo := &mockTagRepository{err: fmt.Errorf("database error")} 137 + output := &bytes.Buffer{} 138 + opts := TagListOptions{Output: output, Static: true} 139 + 140 + tagList := NewTagList(mockRepo, opts) 141 + err := tagList.Browse(context.Background()) 142 + 143 + if err == nil { 144 + t.Error("Browse should return error when repository fails") 145 + } 146 + 147 + result := output.String() 148 + if !strings.Contains(result, "Error:") { 149 + t.Error("Output should contain error message") 150 + } 151 + }) 152 + 153 + t.Run("truncates long tag names", func(t *testing.T) { 154 + tags := []repo.TagSummary{ 155 + {Name: "this-is-a-very-long-tag-name-that-should-be-truncated", TaskCount: 2}, 156 + } 157 + mockRepo := &mockTagRepository{tags: tags} 158 + output := &bytes.Buffer{} 159 + opts := TagListOptions{Output: output, Static: true} 160 + 161 + tagList := NewTagList(mockRepo, opts) 162 + err := tagList.Browse(context.Background()) 163 + 164 + if err != nil { 165 + t.Errorf("Browse should not return error: %v", err) 166 + } 167 + 168 + result := output.String() 169 + if !strings.Contains(result, "...") { 170 + t.Error("Output should truncate long tag names") 171 + } 172 + }) 173 + }) 174 + 175 + t.Run("TagListModel", func(t *testing.T) { 176 + t.Run("initializes correctly", func(t *testing.T) { 177 + model := tagListModel{ 178 + selected: 0, 179 + showingHelp: false, 180 + } 181 + 182 + if model.selected != 0 { 183 + t.Error("Initial selection should be 0") 184 + } 185 + if model.showingHelp { 186 + t.Error("Should not be showing help initially") 187 + } 188 + }) 189 + 190 + t.Run("handles key navigation", func(t *testing.T) { 191 + tags := []repo.TagSummary{ 192 + {Name: "tag1", TaskCount: 1}, 193 + {Name: "tag2", TaskCount: 2}, 194 + {Name: "tag3", TaskCount: 3}, 195 + } 196 + 197 + model := tagListModel{ 198 + tags: tags, 199 + selected: 1, 200 + keys: tagKeys, 201 + } 202 + 203 + // Test down key 204 + downMsg := tea.KeyMsg{Type: tea.KeyDown} 205 + updatedModel, _ := model.Update(downMsg) 206 + if updatedModel.(tagListModel).selected != 2 { 207 + t.Error("Down key should move selection down") 208 + } 209 + 210 + // Test up key 211 + upMsg := tea.KeyMsg{Type: tea.KeyUp} 212 + updatedModel, _ = updatedModel.Update(upMsg) 213 + if updatedModel.(tagListModel).selected != 1 { 214 + t.Error("Up key should move selection up") 215 + } 216 + 217 + // Test boundary conditions 218 + model.selected = 0 219 + updatedModel, _ = model.Update(upMsg) 220 + if updatedModel.(tagListModel).selected != 0 { 221 + t.Error("Up key should not move selection below 0") 222 + } 223 + 224 + model.selected = len(tags) - 1 225 + updatedModel, _ = model.Update(downMsg) 226 + if updatedModel.(tagListModel).selected != len(tags)-1 { 227 + t.Error("Down key should not move selection beyond list length") 228 + } 229 + }) 230 + 231 + t.Run("handles number key selection", func(t *testing.T) { 232 + tags := []repo.TagSummary{ 233 + {Name: "tag1", TaskCount: 1}, 234 + {Name: "tag2", TaskCount: 2}, 235 + {Name: "tag3", TaskCount: 3}, 236 + } 237 + 238 + model := tagListModel{ 239 + tags: tags, 240 + selected: 0, 241 + keys: tagKeys, 242 + } 243 + 244 + // Test number key 3 (index 2) 245 + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'3'}} 246 + updatedModel, _ := model.Update(keyMsg) 247 + if updatedModel.(tagListModel).selected != 2 { 248 + t.Error("Number key 3 should select index 2") 249 + } 250 + }) 251 + 252 + t.Run("handles help toggle", func(t *testing.T) { 253 + model := tagListModel{ 254 + keys: tagKeys, 255 + } 256 + 257 + helpMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}} 258 + updatedModel, _ := model.Update(helpMsg) 259 + if !updatedModel.(tagListModel).showingHelp { 260 + t.Error("Help key should show help") 261 + } 262 + 263 + updatedModel, _ = updatedModel.Update(helpMsg) 264 + if updatedModel.(tagListModel).showingHelp { 265 + t.Error("Help key should hide help when already showing") 266 + } 267 + }) 268 + 269 + t.Run("handles tags loaded message", func(t *testing.T) { 270 + tags := []repo.TagSummary{ 271 + {Name: "new-tag", TaskCount: 5}, 272 + } 273 + 274 + model := tagListModel{ 275 + selected: 5, // Invalid selection 276 + } 277 + 278 + msg := tagsLoadedMsg(tags) 279 + updatedModel, _ := model.Update(msg) 280 + resultModel := updatedModel.(tagListModel) 281 + 282 + if len(resultModel.tags) != 1 { 283 + t.Error("Tags should be loaded correctly") 284 + } 285 + if resultModel.selected != 0 { 286 + t.Error("Selection should be reset to valid range") 287 + } 288 + }) 289 + 290 + t.Run("handles error message", func(t *testing.T) { 291 + model := tagListModel{} 292 + 293 + errorMsg := errorTagMsg(fmt.Errorf("test error")) 294 + updatedModel, _ := model.Update(errorMsg) 295 + resultModel := updatedModel.(tagListModel) 296 + 297 + if resultModel.err == nil { 298 + t.Error("Error should be set") 299 + } 300 + if resultModel.err.Error() != "test error" { 301 + t.Errorf("Expected 'test error', got '%s'", resultModel.err.Error()) 302 + } 303 + }) 304 + 305 + t.Run("view renders correctly", func(t *testing.T) { 306 + tags := []repo.TagSummary{ 307 + {Name: "frontend", TaskCount: 5}, 308 + {Name: "urgent", TaskCount: 1}, 309 + } 310 + 311 + model := tagListModel{ 312 + tags: tags, 313 + selected: 0, 314 + keys: tagKeys, 315 + } 316 + 317 + view := model.View() 318 + if !strings.Contains(view, "Tags") { 319 + t.Error("View should contain title") 320 + } 321 + if !strings.Contains(view, "frontend") { 322 + t.Error("View should contain tag names") 323 + } 324 + if !strings.Contains(view, "5 tasks") { 325 + t.Error("View should show task counts") 326 + } 327 + if !strings.Contains(view, "1 task") { 328 + t.Error("View should show singular task count") 329 + } 330 + }) 331 + 332 + t.Run("view handles empty state", func(t *testing.T) { 333 + model := tagListModel{ 334 + tags: []repo.TagSummary{}, 335 + } 336 + 337 + view := model.View() 338 + if !strings.Contains(view, "No tags found") { 339 + t.Error("View should show empty state message") 340 + } 341 + }) 342 + 343 + t.Run("view handles error state", func(t *testing.T) { 344 + model := tagListModel{ 345 + err: fmt.Errorf("test error"), 346 + } 347 + 348 + view := model.View() 349 + if !strings.Contains(view, "Error:") { 350 + t.Error("View should show error message") 351 + } 352 + }) 353 + }) 354 + }
+137 -62
internal/ui/task_list.go
··· 7 7 "os" 8 8 "strings" 9 9 10 + "github.com/charmbracelet/bubbles/help" 11 + "github.com/charmbracelet/bubbles/key" 10 12 tea "github.com/charmbracelet/bubbletea" 11 13 "github.com/charmbracelet/lipgloss" 12 14 "github.com/stormlightlabs/noteleaf/internal/models" 13 15 "github.com/stormlightlabs/noteleaf/internal/repo" 16 + "github.com/stormlightlabs/noteleaf/internal/utils" 14 17 ) 15 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 + 16 77 // TaskRepository interface for dependency injection in tests 17 78 type TaskRepository interface { 18 79 List(ctx context.Context, opts repo.TaskListOptions) ([]*models.Task, error) ··· 50 111 return &TaskList{repo: repo, opts: opts} 51 112 } 52 113 114 + type ( 115 + tasksLoadedMsg []*models.Task 116 + taskViewMsg string 117 + errorTaskMsg error 118 + ) 119 + 53 120 type taskListModel struct { 54 121 tasks []*models.Task 55 122 selected int ··· 59 126 repo TaskRepository 60 127 opts TaskListOptions 61 128 showAll bool 62 - // filter string 129 + keys keyMap 130 + help help.Model 131 + showingHelp bool 63 132 } 64 - 65 - type tasksLoadedMsg []*models.Task 66 - type taskViewMsg string 67 - type errorTaskMsg error 68 133 69 134 func (m taskListModel) Init() tea.Cmd { 70 135 return m.loadTasks() ··· 73 138 func (m taskListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 74 139 switch msg := msg.(type) { 75 140 case tea.KeyMsg: 141 + 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 + 76 150 if m.viewing { 77 - switch msg.String() { 78 - case "q", "esc", "backspace": 151 + switch { 152 + case key.Matches(msg, m.keys.Back) || key.Matches(msg, m.keys.Quit): 79 153 m.viewing = false 80 154 m.viewContent = "" 81 155 return m, nil 156 + case key.Matches(msg, m.keys.Help): 157 + m.showingHelp = true 158 + return m, nil 82 159 } 83 160 return m, nil 84 161 } 85 162 86 - switch msg.String() { 87 - case "ctrl+c", "q": 163 + switch { 164 + case key.Matches(msg, m.keys.Quit): 88 165 return m, tea.Quit 89 - case "up", "k": 166 + case key.Matches(msg, m.keys.Up): 90 167 if m.selected > 0 { 91 168 m.selected-- 92 169 } 93 - case "down", "j": 170 + case key.Matches(msg, m.keys.Down): 94 171 if m.selected < len(m.tasks)-1 { 95 172 m.selected++ 96 173 } 97 - case "enter", "v": 174 + case key.Matches(msg, m.keys.Enter) || key.Matches(msg, m.keys.View): 98 175 if len(m.tasks) > 0 && m.selected < len(m.tasks) { 99 176 return m, m.viewTask(m.tasks[m.selected]) 100 177 } 101 - case "r": 178 + case key.Matches(msg, m.keys.Refresh): 102 179 return m, m.loadTasks() 103 - case "a": 180 + case key.Matches(msg, m.keys.ToggleAll): 104 181 m.showAll = !m.showAll 105 182 return m, m.loadTasks() 106 - case "d": 183 + case key.Matches(msg, m.keys.MarkDone): 107 184 if len(m.tasks) > 0 && m.selected < len(m.tasks) { 108 185 return m, m.markDone(m.tasks[m.selected]) 109 186 } 110 - 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 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 + } 113 196 } 114 197 } 115 198 case tasksLoadedMsg: ··· 129 212 func (m taskListModel) View() string { 130 213 var s strings.Builder 131 214 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")) 215 + style := lipgloss.NewStyle().Foreground(lipgloss.Color(Squid.Hex())) 216 + 217 + if m.showingHelp { 218 + return m.help.View(m.keys) 219 + } 140 220 141 221 if m.viewing { 142 222 s.WriteString(m.viewContent) 143 223 s.WriteString("\n\n") 144 - s.WriteString(style.Render("Press q/esc/backspace to return to list")) 224 + s.WriteString(style.Render("Press q/esc/backspace to return to list, ? for help")) 145 225 return s.String() 146 226 } 147 227 148 - s.WriteString(titleStyle.Render("Tasks")) 228 + s.WriteString(TitleColorStyle.Render("Tasks")) 149 229 if m.showAll { 150 230 s.WriteString(" (showing all)") 151 231 } else { ··· 165 245 return s.String() 166 246 } 167 247 168 - headerLine := fmt.Sprintf("%-3s %-4s %-40s %-10s %-10s %-15s", "", "ID", "Description", "Status", "Priority", "Project") 169 - s.WriteString(headerStyle.Render(headerLine)) 248 + headerLine := fmt.Sprintf(" %-4s %-40s %-10s %-10s %-15s", "ID", "Description", "Status", "Priority", "Project") 249 + s.WriteString(HeaderColorStyle.Render(headerLine)) 170 250 s.WriteString("\n") 171 - s.WriteString(headerStyle.Render(strings.Repeat("โ”€", 80))) 251 + s.WriteString(HeaderColorStyle.Render(strings.Repeat("โ”€", 80))) 172 252 s.WriteString("\n") 173 253 174 254 for i, task := range m.tasks { ··· 203 283 project = project[:10] + "..." 204 284 } 205 285 206 - line := fmt.Sprintf("%s%-4d %-40s %-10s %-10s %-15s", 207 - prefix, task.ID, description, status, priority, project) 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) 208 301 209 302 if i == m.selected { 210 - s.WriteString(selectedStyle.Render(line)) 303 + s.WriteString(SelectedColorStyle.Render(line)) 211 304 } 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 - } 305 + if task.Status == "completed" { 306 + s.WriteString(StatusColorStyle.Render(line)) 307 + } else { 308 + s.WriteString(style.Render(line)) 226 309 } 227 310 } 228 311 229 - // Add tags if any 230 312 if len(task.Tags) > 0 && i == m.selected { 231 313 s.WriteString(" @" + strings.Join(task.Tags, " @")) 232 314 } ··· 235 317 } 236 318 237 319 s.WriteString("\n") 238 - 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")) 320 + s.WriteString(m.help.View(m.keys)) 241 321 242 322 return s.String() 243 323 } ··· 245 325 func (m taskListModel) loadTasks() tea.Cmd { 246 326 return func() tea.Msg { 247 327 opts := repo.TaskListOptions{} 328 + showAll := m.showAll || m.opts.ShowAll 248 329 249 - // Set status filter 250 - if m.showAll || m.opts.ShowAll { 251 - // Show all tasks - no status filter 252 - } else { 330 + if !showAll { 253 331 opts.Status = "pending" 254 332 } 255 333 256 - // Apply other filters from options 257 334 if m.opts.Status != "" { 258 335 opts.Status = m.opts.Status 259 336 } ··· 280 357 func (m taskListModel) viewTask(task *models.Task) tea.Cmd { 281 358 return func() tea.Msg { 282 359 var content strings.Builder 283 - 284 360 content.WriteString(fmt.Sprintf("# Task %d\n\n", task.ID)) 285 361 content.WriteString(fmt.Sprintf("**UUID:** %s\n", task.UUID)) 286 362 content.WriteString(fmt.Sprintf("**Description:** %s\n", task.Description)) ··· 336 412 return errorTaskMsg(fmt.Errorf("failed to mark task done: %w", err)) 337 413 } 338 414 339 - // Reload tasks after marking done 340 415 return m.loadTasks()() 341 416 } 342 417 } ··· 351 426 repo: tl.repo, 352 427 opts: tl.opts, 353 428 showAll: tl.opts.ShowAll, 429 + keys: keys, 430 + help: help.New(), 354 431 } 355 432 356 433 program := tea.NewProgram(model, tea.WithInput(tl.opts.Input), tea.WithOutput(tl.opts.Output)) ··· 362 439 func (tl *TaskList) staticList(ctx context.Context) error { 363 440 opts := repo.TaskListOptions{} 364 441 365 - if tl.opts.ShowAll { 366 - // Show all tasks - no status filter 367 - } else { 442 + if !tl.opts.ShowAll { 368 443 opts.Status = "pending" 369 444 } 370 445
+42 -11
internal/ui/task_list_test.go
··· 9 9 "testing" 10 10 "time" 11 11 12 + "github.com/charmbracelet/bubbles/help" 12 13 tea "github.com/charmbracelet/bubbletea" 13 14 "github.com/stormlightlabs/noteleaf/internal/models" 14 15 "github.com/stormlightlabs/noteleaf/internal/repo" ··· 348 349 349 350 t.Run("load tasks command", func(t *testing.T) { 350 351 model := taskListModel{ 352 + keys: keys, 353 + help: help.New(), 351 354 repo: repo, 352 355 opts: TaskListOptions{ShowAll: false}, 353 356 } ··· 373 376 374 377 t.Run("load all tasks", func(t *testing.T) { 375 378 model := taskListModel{ 379 + keys: keys, 376 380 repo: repo, 377 381 opts: TaskListOptions{ShowAll: true}, 378 382 showAll: true, ··· 394 398 395 399 t.Run("view task command", func(t *testing.T) { 396 400 model := taskListModel{ 401 + keys: keys, 402 + help: help.New(), 397 403 repo: repo, 398 404 opts: TaskListOptions{}, 399 405 } ··· 427 433 428 434 t.Run("mark done command", func(t *testing.T) { 429 435 model := taskListModel{ 436 + keys: keys, 437 + help: help.New(), 430 438 repo: repo, 431 439 opts: TaskListOptions{}, 432 440 } ··· 438 446 } 439 447 440 448 msg := cmd() 441 - // Should return a loadTasks command after marking done 442 449 switch msg := msg.(type) { 443 450 case tasksLoadedMsg: 444 451 // Success - tasks reloaded 445 452 case errorTaskMsg: 446 - // Check if it's the expected error for already completed task 447 453 err := error(msg) 448 454 if !strings.Contains(err.Error(), "completed") { 449 455 t.Fatalf("Unexpected error: %v", err) ··· 455 461 456 462 t.Run("mark done already completed task", func(t *testing.T) { 457 463 model := taskListModel{ 464 + keys: keys, 465 + help: help.New(), 458 466 repo: repo, 459 467 opts: TaskListOptions{}, 460 468 } 461 469 462 - task := createMockTasks()[2] // Already completed task 470 + task := createMockTasks()[2] 463 471 cmd := model.markDone(task) 464 472 msg := cmd() 465 473 ··· 480 488 481 489 t.Run("quit commands", func(t *testing.T) { 482 490 model := taskListModel{ 491 + keys: keys, 483 492 repo: repo, 484 493 tasks: createMockTasks()[:2], // First 2 tasks 485 494 opts: TaskListOptions{}, ··· 497 506 498 507 t.Run("navigation keys", func(t *testing.T) { 499 508 model := taskListModel{ 509 + keys: keys, 500 510 repo: repo, 501 511 tasks: createMockTasks()[:3], // First 3 tasks 502 512 selected: 1, // Start in middle 503 513 opts: TaskListOptions{}, 504 514 } 505 515 506 - // Test up navigation 507 516 upKeys := []string{"up", "k"} 508 517 for _, key := range upKeys { 509 518 testModel := model ··· 515 524 } 516 525 } 517 526 518 - // Test down navigation 519 527 downKeys := []string{"down", "j"} 520 528 for _, key := range downKeys { 521 529 testModel := model ··· 530 538 531 539 t.Run("view task keys", func(t *testing.T) { 532 540 model := taskListModel{ 541 + keys: keys, 533 542 repo: repo, 534 543 tasks: createMockTasks()[:2], 535 544 selected: 0, ··· 548 557 549 558 t.Run("number shortcuts", func(t *testing.T) { 550 559 model := taskListModel{ 560 + keys: keys, 551 561 repo: repo, 552 562 tasks: createMockTasks()[:4], 553 563 opts: TaskListOptions{}, ··· 568 578 569 579 t.Run("toggle all/pending", func(t *testing.T) { 570 580 model := taskListModel{ 581 + keys: keys, 571 582 repo: repo, 572 583 tasks: createMockTasks()[:2], 573 584 showAll: false, ··· 587 598 588 599 t.Run("mark done key", func(t *testing.T) { 589 600 model := taskListModel{ 601 + keys: keys, 590 602 repo: repo, 591 603 tasks: createMockTasks()[:2], 592 604 selected: 0, ··· 601 613 602 614 t.Run("refresh key", func(t *testing.T) { 603 615 model := taskListModel{ 616 + keys: keys, 604 617 repo: repo, 605 618 tasks: createMockTasks()[:2], 606 619 opts: TaskListOptions{}, ··· 614 627 615 628 t.Run("viewing mode navigation", func(t *testing.T) { 616 629 model := taskListModel{ 630 + keys: keys, 617 631 repo: repo, 618 632 tasks: createMockTasks()[:2], 619 633 viewing: true, ··· 642 656 643 657 t.Run("viewing mode", func(t *testing.T) { 644 658 model := taskListModel{ 659 + keys: keys, 645 660 repo: repo, 646 661 viewing: true, 647 662 viewContent: "# Task Details\nTest content here", ··· 659 674 660 675 t.Run("error state", func(t *testing.T) { 661 676 model := taskListModel{ 677 + keys: keys, 678 + help: help.New(), 662 679 repo: repo, 663 680 err: errors.New("test error"), 664 681 opts: TaskListOptions{}, ··· 672 689 673 690 t.Run("no tasks", func(t *testing.T) { 674 691 model := taskListModel{ 692 + keys: keys, 675 693 repo: repo, 676 694 tasks: []*models.Task{}, 677 695 opts: TaskListOptions{}, ··· 689 707 t.Run("with tasks", func(t *testing.T) { 690 708 tasks := createMockTasks()[:2] // First 2 tasks 691 709 model := taskListModel{ 710 + keys: keys, 692 711 repo: repo, 693 712 tasks: tasks, 694 713 selected: 0, ··· 706 725 if !strings.Contains(view, "Plan vacation itinerary") { 707 726 t.Error("Second task not displayed") 708 727 } 709 - if !strings.Contains(view, "Controls:") { 710 - t.Error("Control instructions not displayed") 728 + if !strings.Contains(view, "help") { 729 + t.Error("Help instructions not displayed") 711 730 } 712 731 }) 713 732 714 733 t.Run("show all mode", func(t *testing.T) { 715 734 model := taskListModel{ 735 + keys: keys, 716 736 repo: repo, 717 737 tasks: createMockTasks(), 718 738 showAll: true, ··· 728 748 t.Run("selected task highlighting", func(t *testing.T) { 729 749 tasks := createMockTasks()[:2] 730 750 model := taskListModel{ 751 + 752 + keys: keys, 731 753 repo: repo, 732 754 tasks: tasks, 733 755 selected: 0, ··· 735 757 } 736 758 737 759 view := model.View() 738 - // The selected task should have a ">" prefix 739 760 if !strings.Contains(view, " > 1 ") { 740 761 t.Error("Selected task not highlighted with '>' prefix") 741 762 } ··· 747 768 748 769 t.Run("tasks loaded message", func(t *testing.T) { 749 770 model := taskListModel{ 771 + keys: keys, 772 + help: help.New(), 750 773 repo: repo, 751 774 opts: TaskListOptions{}, 752 775 } ··· 766 789 767 790 t.Run("task view message", func(t *testing.T) { 768 791 model := taskListModel{ 792 + keys: keys, 793 + help: help.New(), 769 794 repo: repo, 770 795 opts: TaskListOptions{}, 771 796 } ··· 785 810 786 811 t.Run("error message", func(t *testing.T) { 787 812 model := taskListModel{ 813 + keys: keys, 814 + help: help.New(), 788 815 repo: repo, 789 816 opts: TaskListOptions{}, 790 817 } ··· 804 831 805 832 t.Run("selected index bounds", func(t *testing.T) { 806 833 model := taskListModel{ 834 + keys: keys, 807 835 repo: repo, 808 836 tasks: createMockTasks()[:2], 809 - selected: 5, // Out of bounds 837 + selected: 5, 810 838 opts: TaskListOptions{}, 811 839 } 812 840 813 - // Load fewer tasks 814 841 newTasks := createMockTasks()[:1] 815 842 newModel, _ := model.Update(tasksLoadedMsg(newTasks)) 816 843 ··· 818 845 if m.selected >= len(m.tasks) { 819 846 t.Error("Selected index should be adjusted to bounds") 820 847 } 821 - if m.selected != 0 { // Should be adjusted to last valid index 848 + if m.selected != 0 { 822 849 t.Errorf("Expected selected to be 0, got %d", m.selected) 823 850 } 824 851 } ··· 832 859 } 833 860 834 861 model := taskListModel{ 862 + keys: keys, 863 + help: help.New(), 835 864 repo: repo, 836 865 opts: TaskListOptions{}, 837 866 } ··· 857 886 } 858 887 859 888 model := taskListModel{ 889 + keys: keys, 890 + help: help.New(), 860 891 repo: repo, 861 892 opts: TaskListOptions{}, 862 893 }
+242
internal/ui/task_view.go
··· 1 + package ui 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "os" 8 + "strings" 9 + 10 + "github.com/charmbracelet/bubbles/help" 11 + "github.com/charmbracelet/bubbles/key" 12 + "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
··· 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 5 "strings" 6 6 7 7 "github.com/charmbracelet/log" 8 + "golang.org/x/text/cases" 9 + "golang.org/x/text/language" 8 10 ) 9 11 10 12 // Logger is the global application logger ··· 45 47 } 46 48 return Logger 47 49 } 50 + 51 + func Titlecase(s string) string { 52 + return cases.Title(language.Und, cases.NoLower).String(s) 53 + }
+73
internal/utils/utils_test.go
··· 212 212 } 213 213 }) 214 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 + }