tangled
alpha
login
or
join now
desertthunder.dev
/
noteleaf
cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐
charm
leaflet
readability
golang
29
fork
atom
overview
issues
2
pulls
pipelines
feat: interactive task viewing
desertthunder.dev
5 months ago
360ce61f
f21dea1b
+1921
-116
9 changed files
expand all
collapse all
unified
split
cmd
commands.go
main.go
go.sum
internal
handlers
seed.go
seed_test.go
tasks.go
tasks_test.go
ui
task_list.go
task_list_test.go
+118
-11
cmd/commands.go
···
13
13
)
14
14
15
15
func rootCmd() *cobra.Command {
16
16
-
return &cobra.Command{
16
16
+
root := &cobra.Command{
17
17
Use: "noteleaf",
18
18
Long: ui.Georgia.ColoredInViewport(),
19
19
Short: "A TaskWarrior-inspired CLI with notes, media queues and reading lists",
···
27
27
return nil
28
28
},
29
29
}
30
30
+
31
31
+
root.SetHelpCommand(&cobra.Command{Hidden: true})
32
32
+
cobra.EnableCommandSorting = false
33
33
+
34
34
+
root.AddGroup(&cobra.Group{ID: "core", Title: "Core Commands:"})
35
35
+
root.AddGroup(&cobra.Group{ID: "management", Title: "Management Commands:"})
36
36
+
37
37
+
return root
30
38
}
31
39
32
40
func todoCmd() *cobra.Command {
33
41
root := &cobra.Command{
34
34
-
Use: "todo",
35
35
-
Short: "task management",
42
42
+
Use: "todo",
43
43
+
Aliases: []string{"task"},
44
44
+
Short: "task management",
36
45
}
37
46
38
47
root.AddCommand(&cobra.Command{
···
45
54
},
46
55
})
47
56
48
48
-
root.AddCommand(&cobra.Command{
57
57
+
listCmd := &cobra.Command{
49
58
Use: "list",
50
59
Short: "List tasks",
51
60
Aliases: []string{"ls"},
61
61
+
Long: `List tasks with optional filtering and display modes.
62
62
+
63
63
+
By default, shows tasks in an interactive TaskWarrior-like interface.
64
64
+
Use --static to show a simple text list instead.
65
65
+
Use --all to show all tasks, otherwise only pending tasks are shown.`,
52
66
RunE: func(cmd *cobra.Command, args []string) error {
53
53
-
return handlers.ListTasks(cmd.Context(), args)
67
67
+
static, _ := cmd.Flags().GetBool("static")
68
68
+
showAll, _ := cmd.Flags().GetBool("all")
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)
54
74
},
55
55
-
})
75
75
+
}
76
76
+
listCmd.Flags().BoolP("interactive", "i", false, "Force interactive mode (default)")
77
77
+
listCmd.Flags().Bool("static", false, "Use static text output instead of interactive")
78
78
+
listCmd.Flags().BoolP("all", "a", false, "Show all tasks (default: pending only)")
79
79
+
listCmd.Flags().String("status", "", "Filter by status")
80
80
+
listCmd.Flags().String("priority", "", "Filter by priority")
81
81
+
listCmd.Flags().String("project", "", "Filter by project")
82
82
+
root.AddCommand(listCmd)
56
83
57
84
root.AddCommand(&cobra.Command{
58
85
Use: "view [task-id]",
···
124
151
return root
125
152
}
126
153
127
127
-
func movieCmd() *cobra.Command {
154
154
+
func mediaCmd() *cobra.Command {
155
155
+
root := &cobra.Command{
156
156
+
Use: "media",
157
157
+
Short: "Manage media queues (books, movies, TV shows)",
158
158
+
}
159
159
+
160
160
+
root.AddCommand(bookMediaCmd())
161
161
+
root.AddCommand(movieMediaCmd())
162
162
+
root.AddCommand(tvMediaCmd())
163
163
+
164
164
+
return root
165
165
+
}
166
166
+
167
167
+
func movieMediaCmd() *cobra.Command {
128
168
root := &cobra.Command{
129
169
Use: "movie",
130
170
Short: "Manage movie watch queue",
···
152
192
},
153
193
})
154
194
195
195
+
root.AddCommand(&cobra.Command{
196
196
+
Use: "watched [id]",
197
197
+
Short: "Mark movie as watched",
198
198
+
Aliases: []string{"seen"},
199
199
+
Args: cobra.ExactArgs(1),
200
200
+
RunE: func(cmd *cobra.Command, args []string) error {
201
201
+
fmt.Printf("Marking movie %s as watched\n", args[0])
202
202
+
// TODO: Implement movie watched status
203
203
+
return nil
204
204
+
},
205
205
+
})
206
206
+
207
207
+
root.AddCommand(&cobra.Command{
208
208
+
Use: "remove [id]",
209
209
+
Short: "Remove movie from queue",
210
210
+
Aliases: []string{"rm"},
211
211
+
Args: cobra.ExactArgs(1),
212
212
+
RunE: func(cmd *cobra.Command, args []string) error {
213
213
+
fmt.Printf("Removing movie %s from queue\n", args[0])
214
214
+
// TODO: Implement movie removal
215
215
+
return nil
216
216
+
},
217
217
+
})
218
218
+
155
219
return root
156
220
}
157
221
158
158
-
func tvCmd() *cobra.Command {
222
222
+
func tvMediaCmd() *cobra.Command {
159
223
root := &cobra.Command{
160
224
Use: "tv",
161
225
Short: "Manage TV show watch queue",
···
183
247
},
184
248
})
185
249
250
250
+
root.AddCommand(&cobra.Command{
251
251
+
Use: "watched [id]",
252
252
+
Short: "Mark TV show/episodes as watched",
253
253
+
Aliases: []string{"seen"},
254
254
+
Args: cobra.ExactArgs(1),
255
255
+
RunE: func(cmd *cobra.Command, args []string) error {
256
256
+
fmt.Printf("Marking TV show %s as watched\n", args[0])
257
257
+
// TODO: Implement TV show watched status
258
258
+
return nil
259
259
+
},
260
260
+
})
261
261
+
262
262
+
root.AddCommand(&cobra.Command{
263
263
+
Use: "remove [id]",
264
264
+
Short: "Remove TV show from queue",
265
265
+
Aliases: []string{"rm"},
266
266
+
Args: cobra.ExactArgs(1),
267
267
+
RunE: func(cmd *cobra.Command, args []string) error {
268
268
+
fmt.Printf("Removing TV show %s from queue\n", args[0])
269
269
+
// TODO: Implement TV show removal
270
270
+
return nil
271
271
+
},
272
272
+
})
273
273
+
186
274
return root
187
275
}
188
276
189
189
-
func bookCmd() *cobra.Command {
277
277
+
func bookMediaCmd() *cobra.Command {
190
278
root := &cobra.Command{
191
279
Use: "book",
192
280
Short: "Manage reading list",
···
418
506
}
419
507
420
508
func setupCmd() *cobra.Command {
421
421
-
return &cobra.Command{
509
509
+
handler, err := handlers.NewSeedHandler()
510
510
+
if err != nil {
511
511
+
log.Fatalf("failed to instantiate seed handler: %v", err)
512
512
+
}
513
513
+
514
514
+
root := &cobra.Command{
422
515
Use: "setup",
423
423
-
Short: "Initialize the application database and configuration",
516
516
+
Short: "Initialize and manage application setup",
424
517
RunE: func(cmd *cobra.Command, args []string) error {
425
518
return handlers.Setup(cmd.Context(), args)
426
519
},
427
520
}
521
521
+
522
522
+
seedCmd := &cobra.Command{
523
523
+
Use: "seed",
524
524
+
Short: "Populate database with test data",
525
525
+
Long: "Add sample tasks, books, and notes to the database for testing and demonstration purposes",
526
526
+
RunE: func(cmd *cobra.Command, args []string) error {
527
527
+
force, _ := cmd.Flags().GetBool("force")
528
528
+
return handler.Seed(cmd.Context(), force)
529
529
+
},
530
530
+
}
531
531
+
seedCmd.Flags().BoolP("force", "f", false, "Clear existing data and re-seed")
532
532
+
533
533
+
root.AddCommand(seedCmd)
534
534
+
return root
428
535
}
429
536
430
537
func confCmd() *cobra.Command {
+11
-6
cmd/main.go
···
51
51
defer app.Close()
52
52
53
53
root := rootCmd()
54
54
-
commands := []func() *cobra.Command{
55
55
-
setupCmd, resetCmd, statusCmd, todoCmd,
56
56
-
movieCmd, noteCmd, tvCmd, bookCmd, confCmd,
54
54
+
core := []func() *cobra.Command{todoCmd, mediaCmd, noteCmd}
55
55
+
mgmt := []func() *cobra.Command{statusCmd, confCmd, setupCmd, resetCmd}
56
56
+
57
57
+
for _, cmdFunc := range core {
58
58
+
cmd := cmdFunc()
59
59
+
cmd.GroupID = "core"
60
60
+
root.AddCommand(cmd)
57
61
}
58
62
59
59
-
for _, cmdFunc := range commands {
63
63
+
for _, cmdFunc := range mgmt {
60
64
cmd := cmdFunc()
65
65
+
cmd.GroupID = "management"
61
66
root.AddCommand(cmd)
62
67
}
63
68
64
64
-
options := []fang.Option{
69
69
+
opts := []fang.Option{
65
70
fang.WithVersion("0.1.0"),
66
71
fang.WithoutCompletions(),
67
72
fang.WithColorSchemeFunc(ui.NoteleafColorScheme),
68
73
}
69
74
70
70
-
if err := fang.Execute(context.Background(), root, options...); err != nil {
75
75
+
if err := fang.Execute(context.Background(), root, opts...); err != nil {
71
76
os.Exit(1)
72
77
}
73
78
}
+6
-2
go.sum
···
2
2
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
3
3
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
4
4
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
5
5
+
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
6
6
+
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
5
7
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
6
8
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
9
9
+
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
10
10
+
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
7
11
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
8
12
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
9
13
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
···
26
30
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
27
31
github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc=
28
32
github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk=
29
29
-
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
30
30
-
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
31
33
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
32
34
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
33
35
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 h1:SOylT6+BQzPHEjn15TIzawBPVD0QmhKXbcb3jY0ZIKU=
···
73
75
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
74
76
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
75
77
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
78
78
+
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
79
79
+
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
76
80
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
77
81
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
78
82
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+155
internal/handlers/seed.go
···
1
1
+
package handlers
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"fmt"
6
6
+
"math/rand"
7
7
+
"time"
8
8
+
9
9
+
"github.com/stormlightlabs/noteleaf/internal/repo"
10
10
+
"github.com/stormlightlabs/noteleaf/internal/store"
11
11
+
"github.com/stormlightlabs/noteleaf/internal/ui"
12
12
+
"github.com/stormlightlabs/noteleaf/internal/utils"
13
13
+
)
14
14
+
15
15
+
// SeedHandler handles database seeding operations
16
16
+
type SeedHandler struct {
17
17
+
db *store.Database
18
18
+
config *store.Config
19
19
+
repos *repo.Repositories
20
20
+
}
21
21
+
22
22
+
// NewSeedHandler creates a new seed handler
23
23
+
func NewSeedHandler() (*SeedHandler, error) {
24
24
+
db, err := store.NewDatabase()
25
25
+
if err != nil {
26
26
+
return nil, fmt.Errorf("failed to initialize database: %w", err)
27
27
+
}
28
28
+
29
29
+
config, err := store.LoadConfig()
30
30
+
if err != nil {
31
31
+
return nil, fmt.Errorf("failed to load configuration: %w", err)
32
32
+
}
33
33
+
34
34
+
repos := repo.NewRepositories(db.DB)
35
35
+
36
36
+
return &SeedHandler{
37
37
+
db: db,
38
38
+
config: config,
39
39
+
repos: repos,
40
40
+
}, nil
41
41
+
}
42
42
+
43
43
+
// Close cleans up resources
44
44
+
func (h *SeedHandler) Close() error {
45
45
+
if h.db != nil {
46
46
+
return h.db.Close()
47
47
+
}
48
48
+
return nil
49
49
+
}
50
50
+
51
51
+
// Seed populates the database with test data for demonstration and testing
52
52
+
func (h *SeedHandler) Seed(ctx context.Context, force bool) error {
53
53
+
logger := utils.GetLogger()
54
54
+
logger.Info("Seeding database with test data")
55
55
+
56
56
+
if force {
57
57
+
fmt.Println("Clearing existing data...")
58
58
+
if err := h.clearAllData(); err != nil {
59
59
+
return fmt.Errorf("failed to clear existing data: %w", err)
60
60
+
}
61
61
+
}
62
62
+
63
63
+
fmt.Println("Seeding database with test data...")
64
64
+
65
65
+
// Seed tasks
66
66
+
tasks := []struct {
67
67
+
description string
68
68
+
project string
69
69
+
priority string
70
70
+
status string
71
71
+
}{
72
72
+
{"Review quarterly report", "work", "high", "pending"},
73
73
+
{"Plan vacation itinerary", "personal", "medium", "pending"},
74
74
+
{"Fix bug in user authentication", "development", "high", "pending"},
75
75
+
{"Read \"Clean Code\" book", "learning", "low", "pending"},
76
76
+
{"Update project documentation", "work", "medium", "completed"},
77
77
+
}
78
78
+
79
79
+
for _, task := range tasks {
80
80
+
if err := h.seedTask(task.description, task.project, task.priority, task.status); err != nil {
81
81
+
logger.Warn("Failed to seed task", "description", task.description, "error", err)
82
82
+
}
83
83
+
}
84
84
+
85
85
+
// Seed books
86
86
+
books := []struct {
87
87
+
title string
88
88
+
author string
89
89
+
status string
90
90
+
progress int
91
91
+
}{
92
92
+
{"The Go Programming Language", "Alan Donovan", "reading", 45},
93
93
+
{"Clean Code", "Robert Martin", "queued", 0},
94
94
+
{"Design Patterns", "Gang of Four", "finished", 100},
95
95
+
{"The Pragmatic Programmer", "Andy Hunt", "queued", 0},
96
96
+
{"Effective Go", "Various", "reading", 75},
97
97
+
}
98
98
+
99
99
+
for _, book := range books {
100
100
+
if err := h.seedBook(book.title, book.author, book.status, book.progress); err != nil {
101
101
+
logger.Warn("Failed to seed book", "title", book.title, "error", err)
102
102
+
}
103
103
+
}
104
104
+
105
105
+
fmt.Printf("Successfully seeded database with %d tasks and %d books\n", len(tasks), len(books))
106
106
+
fmt.Printf("\n%s\n", ui.Info.Render("Example commands to try:"))
107
107
+
fmt.Printf(" %s\n", ui.Success.Render("noteleaf todo list"))
108
108
+
fmt.Printf(" %s\n", ui.Success.Render("noteleaf media book list"))
109
109
+
fmt.Printf(" %s\n", ui.Success.Render("noteleaf todo view 1"))
110
110
+
111
111
+
return nil
112
112
+
}
113
113
+
114
114
+
func (h *SeedHandler) clearAllData() error {
115
115
+
queries := []string{
116
116
+
"DELETE FROM tasks",
117
117
+
"DELETE FROM books",
118
118
+
"DELETE FROM notes",
119
119
+
"DELETE FROM movies",
120
120
+
"DELETE FROM tv_shows",
121
121
+
"DELETE FROM sqlite_sequence WHERE name IN ('tasks', 'books', 'notes', 'movies', 'tv_shows')",
122
122
+
}
123
123
+
124
124
+
for _, query := range queries {
125
125
+
if _, err := h.db.Exec(query); err != nil {
126
126
+
return fmt.Errorf("failed to execute %s: %w", query, err)
127
127
+
}
128
128
+
}
129
129
+
130
130
+
return nil
131
131
+
}
132
132
+
133
133
+
func (h *SeedHandler) seedTask(description, project, priority, status string) error {
134
134
+
// Generate a simple UUID for the task (required field)
135
135
+
uuid := h.generateSimpleUUID()
136
136
+
query := `INSERT INTO tasks (uuid, description, project, priority, status, entry, modified)
137
137
+
VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'))`
138
138
+
_, err := h.db.Exec(query, uuid, description, project, priority, status)
139
139
+
return err
140
140
+
}
141
141
+
142
142
+
func (h *SeedHandler) seedBook(title, author, status string, progress int) error {
143
143
+
query := `INSERT INTO books (title, author, status, progress, added)
144
144
+
VALUES (?, ?, ?, ?, datetime('now'))`
145
145
+
_, err := h.db.Exec(query, title, author, status, progress)
146
146
+
return err
147
147
+
}
148
148
+
149
149
+
// generateSimpleUUID creates a simple UUID for seeding (not cryptographically secure, but sufficient for test data)
150
150
+
func (h *SeedHandler) generateSimpleUUID() string {
151
151
+
now := time.Now()
152
152
+
// Add random component to avoid collisions during rapid seeding
153
153
+
randomNum := rand.Intn(10000)
154
154
+
return fmt.Sprintf("seed-task-%d-%d-%d", now.Unix(), now.UnixNano()%1000000, randomNum)
155
155
+
}
+250
internal/handlers/seed_test.go
···
1
1
+
package handlers
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"os"
6
6
+
"runtime"
7
7
+
"strings"
8
8
+
"testing"
9
9
+
10
10
+
"github.com/stormlightlabs/noteleaf/internal/store"
11
11
+
)
12
12
+
13
13
+
func setupSeedTest(t *testing.T) (string, func()) {
14
14
+
tempDir, err := os.MkdirTemp("", "noteleaf-seed-test-*")
15
15
+
if err != nil {
16
16
+
t.Fatalf("Failed to create temp dir: %v", err)
17
17
+
}
18
18
+
19
19
+
oldConfigHome := os.Getenv("XDG_CONFIG_HOME")
20
20
+
os.Setenv("XDG_CONFIG_HOME", tempDir)
21
21
+
22
22
+
cleanup := func() {
23
23
+
os.Setenv("XDG_CONFIG_HOME", oldConfigHome)
24
24
+
os.RemoveAll(tempDir)
25
25
+
}
26
26
+
27
27
+
ctx := context.Background()
28
28
+
err = Setup(ctx, []string{})
29
29
+
if err != nil {
30
30
+
cleanup()
31
31
+
t.Fatalf("Failed to setup database: %v", err)
32
32
+
}
33
33
+
34
34
+
return tempDir, cleanup
35
35
+
}
36
36
+
37
37
+
func countRecords(t *testing.T, db *store.Database, table string) int {
38
38
+
t.Helper()
39
39
+
40
40
+
var count int
41
41
+
query := "SELECT COUNT(*) FROM " + table
42
42
+
err := db.QueryRow(query).Scan(&count)
43
43
+
if err != nil {
44
44
+
t.Fatalf("Failed to count records in %s: %v", table, err)
45
45
+
}
46
46
+
return count
47
47
+
}
48
48
+
49
49
+
func getTaskRecord(t *testing.T, db *store.Database, id int) (description, project, priority, status string) {
50
50
+
t.Helper()
51
51
+
52
52
+
query := "SELECT description, project, priority, status FROM tasks WHERE id = ?"
53
53
+
err := db.QueryRow(query, id).Scan(&description, &project, &priority, &status)
54
54
+
if err != nil {
55
55
+
t.Fatalf("Failed to get task record: %v", err)
56
56
+
}
57
57
+
return description, project, priority, status
58
58
+
}
59
59
+
60
60
+
func getBookRecord(t *testing.T, db *store.Database, id int) (title, author, status string, progress int) {
61
61
+
t.Helper()
62
62
+
63
63
+
query := "SELECT title, author, status, progress FROM books WHERE id = ?"
64
64
+
err := db.QueryRow(query, id).Scan(&title, &author, &status, &progress)
65
65
+
if err != nil {
66
66
+
t.Fatalf("Failed to get book record: %v", err)
67
67
+
}
68
68
+
return title, author, status, progress
69
69
+
}
70
70
+
71
71
+
func TestSeedHandler(t *testing.T) {
72
72
+
_, cleanup := setupSeedTest(t)
73
73
+
defer cleanup()
74
74
+
75
75
+
handler, err := NewSeedHandler()
76
76
+
if err != nil {
77
77
+
t.Fatalf("Failed to create seed handler: %v", err)
78
78
+
}
79
79
+
defer handler.Close()
80
80
+
81
81
+
ctx := context.Background()
82
82
+
83
83
+
t.Run("New", func(t *testing.T) {
84
84
+
t.Run("creates handler successfully", func(t *testing.T) {
85
85
+
testHandler, err := NewSeedHandler()
86
86
+
if err != nil {
87
87
+
t.Fatalf("NewSeedHandler failed: %v", err)
88
88
+
}
89
89
+
if testHandler == nil {
90
90
+
t.Fatal("Handler should not be nil")
91
91
+
}
92
92
+
defer testHandler.Close()
93
93
+
94
94
+
if testHandler.db == nil {
95
95
+
t.Error("Handler database should not be nil")
96
96
+
}
97
97
+
if testHandler.config == nil {
98
98
+
t.Error("Handler config should not be nil")
99
99
+
}
100
100
+
if testHandler.repos == nil {
101
101
+
t.Error("Handler repos should not be nil")
102
102
+
}
103
103
+
})
104
104
+
105
105
+
t.Run("handles database initialization error", func(t *testing.T) {
106
106
+
originalXDG := os.Getenv("XDG_CONFIG_HOME")
107
107
+
originalHome := os.Getenv("HOME")
108
108
+
109
109
+
if runtime.GOOS == "windows" {
110
110
+
originalAppData := os.Getenv("APPDATA")
111
111
+
os.Unsetenv("APPDATA")
112
112
+
defer os.Setenv("APPDATA", originalAppData)
113
113
+
} else {
114
114
+
os.Unsetenv("XDG_CONFIG_HOME")
115
115
+
os.Unsetenv("HOME")
116
116
+
}
117
117
+
118
118
+
defer func() {
119
119
+
os.Setenv("XDG_CONFIG_HOME", originalXDG)
120
120
+
os.Setenv("HOME", originalHome)
121
121
+
}()
122
122
+
123
123
+
_, err := NewSeedHandler()
124
124
+
if err == nil {
125
125
+
t.Error("NewSeedHandler should fail when database initialization fails")
126
126
+
}
127
127
+
if !strings.Contains(err.Error(), "failed to initialize database") {
128
128
+
t.Errorf("Expected database error, got: %v", err)
129
129
+
}
130
130
+
})
131
131
+
})
132
132
+
133
133
+
t.Run("Seed", func(t *testing.T) {
134
134
+
t.Run("seeds database with test data", func(t *testing.T) {
135
135
+
err := handler.Seed(ctx, false)
136
136
+
if err != nil {
137
137
+
t.Fatalf("Seed failed: %v", err)
138
138
+
}
139
139
+
140
140
+
taskCount := countRecords(t, handler.db, "tasks")
141
141
+
if taskCount != 5 {
142
142
+
t.Errorf("Expected 5 tasks, got %d", taskCount)
143
143
+
}
144
144
+
145
145
+
bookCount := countRecords(t, handler.db, "books")
146
146
+
if bookCount != 5 {
147
147
+
t.Errorf("Expected 5 books, got %d", bookCount)
148
148
+
}
149
149
+
150
150
+
desc, proj, prio, status := getTaskRecord(t, handler.db, 1)
151
151
+
if desc != "Review quarterly report" {
152
152
+
t.Errorf("Expected 'Review quarterly report', got '%s'", desc)
153
153
+
}
154
154
+
if proj != "work" {
155
155
+
t.Errorf("Expected 'work' project, got '%s'", proj)
156
156
+
}
157
157
+
if prio != "high" {
158
158
+
t.Errorf("Expected 'high' priority, got '%s'", prio)
159
159
+
}
160
160
+
if status != "pending" {
161
161
+
t.Errorf("Expected 'pending' status, got '%s'", status)
162
162
+
}
163
163
+
164
164
+
title, author, bookStatus, progress := getBookRecord(t, handler.db, 1)
165
165
+
if title != "The Go Programming Language" {
166
166
+
t.Errorf("Expected 'The Go Programming Language', got '%s'", title)
167
167
+
}
168
168
+
if author != "Alan Donovan" {
169
169
+
t.Errorf("Expected 'Alan Donovan', got '%s'", author)
170
170
+
}
171
171
+
if bookStatus != "reading" {
172
172
+
t.Errorf("Expected 'reading' status, got '%s'", bookStatus)
173
173
+
}
174
174
+
if progress != 45 {
175
175
+
t.Errorf("Expected 45%% progress, got %d", progress)
176
176
+
}
177
177
+
})
178
178
+
179
179
+
t.Run("seeds without force flag preserves existing data", func(t *testing.T) {
180
180
+
err := handler.Seed(ctx, false)
181
181
+
if err != nil {
182
182
+
t.Fatalf("First seed failed: %v", err)
183
183
+
}
184
184
+
185
185
+
initialTaskCount := countRecords(t, handler.db, "tasks")
186
186
+
initialBookCount := countRecords(t, handler.db, "books")
187
187
+
188
188
+
err = handler.Seed(ctx, false)
189
189
+
if err != nil {
190
190
+
t.Fatalf("Second seed failed: %v", err)
191
191
+
}
192
192
+
193
193
+
finalTaskCount := countRecords(t, handler.db, "tasks")
194
194
+
finalBookCount := countRecords(t, handler.db, "books")
195
195
+
196
196
+
expectedTasks := initialTaskCount + 5
197
197
+
expectedBooks := initialBookCount + 5
198
198
+
199
199
+
if finalTaskCount != expectedTasks {
200
200
+
t.Errorf("Expected %d tasks after second seed, got %d", expectedTasks, finalTaskCount)
201
201
+
}
202
202
+
if finalBookCount != expectedBooks {
203
203
+
t.Errorf("Expected %d books after second seed, got %d", expectedBooks, finalBookCount)
204
204
+
}
205
205
+
})
206
206
+
207
207
+
t.Run("force flag clears existing data before seeding", func(t *testing.T) {
208
208
+
err := handler.Seed(ctx, false)
209
209
+
if err != nil {
210
210
+
t.Fatalf("Initial seed failed: %v", err)
211
211
+
}
212
212
+
213
213
+
if countRecords(t, handler.db, "tasks") == 0 {
214
214
+
t.Fatal("No tasks found after initial seed")
215
215
+
}
216
216
+
if countRecords(t, handler.db, "books") == 0 {
217
217
+
t.Fatal("No books found after initial seed")
218
218
+
}
219
219
+
220
220
+
err = handler.Seed(ctx, true)
221
221
+
if err != nil {
222
222
+
t.Fatalf("Force seed failed: %v", err)
223
223
+
}
224
224
+
225
225
+
taskCount := countRecords(t, handler.db, "tasks")
226
226
+
bookCount := countRecords(t, handler.db, "books")
227
227
+
228
228
+
if taskCount != 5 {
229
229
+
t.Errorf("Expected exactly 5 tasks after force seed, got %d", taskCount)
230
230
+
}
231
231
+
if bookCount != 5 {
232
232
+
t.Errorf("Expected exactly 5 books after force seed, got %d", bookCount)
233
233
+
}
234
234
+
235
235
+
_, _, _, _ = getTaskRecord(t, handler.db, 1) // Should not error
236
236
+
_, _, _, _ = getBookRecord(t, handler.db, 1) // Should not error
237
237
+
})
238
238
+
})
239
239
+
240
240
+
t.Run("Close", func(t *testing.T) {
241
241
+
testHandler, err := NewSeedHandler()
242
242
+
if err != nil {
243
243
+
t.Fatalf("Failed to create test handler: %v", err)
244
244
+
}
245
245
+
246
246
+
if err = testHandler.Close(); err != nil {
247
247
+
t.Errorf("Close should succeed: %v", err)
248
248
+
}
249
249
+
})
250
250
+
}
+46
-54
internal/handlers/tasks.go
···
3
3
import (
4
4
"context"
5
5
"fmt"
6
6
+
"slices"
6
7
"strconv"
7
8
"strings"
8
9
"time"
···
11
12
"github.com/stormlightlabs/noteleaf/internal/models"
12
13
"github.com/stormlightlabs/noteleaf/internal/repo"
13
14
"github.com/stormlightlabs/noteleaf/internal/store"
15
15
+
"github.com/stormlightlabs/noteleaf/internal/ui"
14
16
)
15
17
16
18
// TaskHandler handles all task-related commands
···
63
65
}
64
66
65
67
description := strings.Join(args, " ")
66
66
-
68
68
+
67
69
task := &models.Task{
68
70
UUID: uuid.New().String(),
69
71
Description: description,
···
80
82
}
81
83
82
84
// ListTasks lists all tasks with optional filtering
83
83
-
func ListTasks(ctx context.Context, args []string) error {
85
85
+
func ListTasks(ctx context.Context, static, showAll bool, status, priority, project string) error {
84
86
handler, err := NewTaskHandler()
85
87
if err != nil {
86
88
return fmt.Errorf("failed to initialize task handler: %w", err)
87
89
}
88
90
defer handler.Close()
89
91
90
90
-
return handler.listTasks(ctx, args)
92
92
+
if static {
93
93
+
return handler.listTasksStatic(ctx, showAll, status, priority, project)
94
94
+
}
95
95
+
96
96
+
return handler.listTasksInteractive(ctx, showAll, status, priority, project)
91
97
}
92
98
93
93
-
func (h *TaskHandler) listTasks(ctx context.Context, args []string) error {
94
94
-
opts := repo.TaskListOptions{}
95
95
-
96
96
-
// Parse arguments for filtering
97
97
-
for i, arg := range args {
98
98
-
switch {
99
99
-
case arg == "--status" && i+1 < len(args):
100
100
-
opts.Status = args[i+1]
101
101
-
case arg == "--priority" && i+1 < len(args):
102
102
-
opts.Priority = args[i+1]
103
103
-
case arg == "--project" && i+1 < len(args):
104
104
-
opts.Project = args[i+1]
105
105
-
case arg == "--search" && i+1 < len(args):
106
106
-
opts.Search = args[i+1]
107
107
-
case arg == "--limit" && i+1 < len(args):
108
108
-
if limit, err := strconv.Atoi(args[i+1]); err == nil {
109
109
-
opts.Limit = limit
110
110
-
}
111
111
-
}
99
99
+
func (h *TaskHandler) listTasksStatic(ctx context.Context, showAll bool, status, priority, project string) error {
100
100
+
opts := repo.TaskListOptions{
101
101
+
Status: status,
102
102
+
Priority: priority,
103
103
+
Project: project,
112
104
}
113
105
114
114
-
// Default to showing pending tasks only
115
115
-
if opts.Status == "" {
106
106
+
// Default to showing pending tasks only unless --all is specified
107
107
+
if !showAll && opts.Status == "" {
116
108
opts.Status = "pending"
117
109
}
118
110
···
134
126
return nil
135
127
}
136
128
129
129
+
func (h *TaskHandler) listTasksInteractive(ctx context.Context, showAll bool, status, priority, project string) error {
130
130
+
taskList := ui.NewTaskList(h.repos.Tasks, ui.TaskListOptions{
131
131
+
ShowAll: showAll,
132
132
+
Status: status,
133
133
+
Priority: priority,
134
134
+
Project: project,
135
135
+
Static: false,
136
136
+
})
137
137
+
138
138
+
return taskList.Browse(ctx)
139
139
+
}
140
140
+
137
141
// UpdateTask updates an existing task
138
142
func UpdateTask(ctx context.Context, args []string) error {
139
143
handler, err := NewTaskHandler()
···
190
194
i++
191
195
case strings.HasPrefix(arg, "--add-tag="):
192
196
tag := strings.TrimPrefix(arg, "--add-tag=")
193
193
-
if !contains(task.Tags, tag) {
197
197
+
if !slices.Contains(task.Tags, tag) {
194
198
task.Tags = append(task.Tags, tag)
195
199
}
196
200
case strings.HasPrefix(arg, "--remove-tag="):
···
229
233
var err error
230
234
231
235
if id, parseErr := strconv.ParseInt(taskID, 10, 64); parseErr == nil {
232
232
-
// Get task first to show what's being deleted
233
236
task, err = h.repos.Tasks.Get(ctx, id)
234
237
if err != nil {
235
238
return fmt.Errorf("failed to find task: %w", err)
236
239
}
237
237
-
240
240
+
238
241
err = h.repos.Tasks.Delete(ctx, id)
239
242
} else {
240
240
-
// Get by UUID first
241
243
task, err = h.repos.Tasks.GetByUUID(ctx, taskID)
242
244
if err != nil {
243
245
return fmt.Errorf("failed to find task: %w", err)
244
246
}
245
245
-
247
247
+
246
248
err = h.repos.Tasks.Delete(ctx, task.ID)
247
249
}
248
250
···
336
338
return nil
337
339
}
338
340
339
339
-
// Helper functions
340
341
func (h *TaskHandler) printTask(task *models.Task) {
341
342
fmt.Printf("[%d] %s", task.ID, task.Description)
342
342
-
343
343
+
343
344
if task.Status != "pending" {
344
345
fmt.Printf(" (%s)", task.Status)
345
346
}
346
346
-
347
347
+
347
348
if task.Priority != "" {
348
349
fmt.Printf(" [%s]", task.Priority)
349
350
}
350
350
-
351
351
+
351
352
if task.Project != "" {
352
353
fmt.Printf(" +%s", task.Project)
353
354
}
354
354
-
355
355
+
355
356
if len(task.Tags) > 0 {
356
357
fmt.Printf(" @%s", strings.Join(task.Tags, " @"))
357
358
}
358
358
-
359
359
+
359
360
if task.Due != nil {
360
361
fmt.Printf(" (due: %s)", task.Due.Format("2006-01-02"))
361
362
}
362
362
-
363
363
+
363
364
fmt.Println()
364
365
}
365
366
···
368
369
fmt.Printf("UUID: %s\n", task.UUID)
369
370
fmt.Printf("Description: %s\n", task.Description)
370
371
fmt.Printf("Status: %s\n", task.Status)
371
371
-
372
372
+
372
373
if task.Priority != "" {
373
374
fmt.Printf("Priority: %s\n", task.Priority)
374
375
}
375
375
-
376
376
+
376
377
if task.Project != "" {
377
378
fmt.Printf("Project: %s\n", task.Project)
378
379
}
379
379
-
380
380
+
380
381
if len(task.Tags) > 0 {
381
382
fmt.Printf("Tags: %s\n", strings.Join(task.Tags, ", "))
382
383
}
383
383
-
384
384
+
384
385
if task.Due != nil {
385
386
fmt.Printf("Due: %s\n", task.Due.Format("2006-01-02 15:04"))
386
387
}
387
387
-
388
388
+
388
389
fmt.Printf("Created: %s\n", task.Entry.Format("2006-01-02 15:04"))
389
390
fmt.Printf("Modified: %s\n", task.Modified.Format("2006-01-02 15:04"))
390
390
-
391
391
+
391
392
if task.Start != nil {
392
393
fmt.Printf("Started: %s\n", task.Start.Format("2006-01-02 15:04"))
393
394
}
394
394
-
395
395
+
395
396
if task.End != nil {
396
397
fmt.Printf("Completed: %s\n", task.End.Format("2006-01-02 15:04"))
397
398
}
398
398
-
399
399
+
399
400
if len(task.Annotations) > 0 {
400
401
fmt.Printf("Annotations:\n")
401
402
for _, annotation := range task.Annotations {
···
404
405
}
405
406
}
406
407
407
407
-
func contains(slice []string, item string) bool {
408
408
-
for _, s := range slice {
409
409
-
if s == item {
410
410
-
return true
411
411
-
}
412
412
-
}
413
413
-
return false
414
414
-
}
415
415
-
416
408
func removeString(slice []string, item string) []string {
417
409
var result []string
418
410
for _, s := range slice {
···
421
413
}
422
414
}
423
415
return result
424
424
-
}
416
416
+
}
+14
-43
internal/handlers/tasks_test.go
···
4
4
"context"
5
5
"os"
6
6
"runtime"
7
7
+
"slices"
7
8
"strconv"
8
9
"strings"
9
10
"testing"
···
180
181
t.Fatalf("Failed to create task2: %v", err)
181
182
}
182
183
183
183
-
t.Run("lists pending tasks by default", func(t *testing.T) {
184
184
-
args := []string{}
185
185
-
186
186
-
err := ListTasks(ctx, args)
184
184
+
t.Run("lists pending tasks by default (static mode)", func(t *testing.T) {
185
185
+
err := ListTasks(ctx, true, false, "", "", "")
187
186
if err != nil {
188
187
t.Errorf("ListTasks failed: %v", err)
189
188
}
190
189
})
191
190
192
192
-
t.Run("filters by status", func(t *testing.T) {
193
193
-
args := []string{"--status", "completed"}
194
194
-
195
195
-
err := ListTasks(ctx, args)
191
191
+
t.Run("filters by status (static mode)", func(t *testing.T) {
192
192
+
err := ListTasks(ctx, true, false, "completed", "", "")
196
193
if err != nil {
197
194
t.Errorf("ListTasks with status filter failed: %v", err)
198
195
}
199
196
})
200
197
201
201
-
t.Run("filters by priority", func(t *testing.T) {
202
202
-
args := []string{"--priority", "A"}
203
203
-
204
204
-
err := ListTasks(ctx, args)
198
198
+
t.Run("filters by priority (static mode)", func(t *testing.T) {
199
199
+
err := ListTasks(ctx, true, false, "", "A", "")
205
200
if err != nil {
206
201
t.Errorf("ListTasks with priority filter failed: %v", err)
207
202
}
208
203
})
209
204
210
210
-
t.Run("filters by project", func(t *testing.T) {
211
211
-
args := []string{"--project", "work"}
212
212
-
213
213
-
err := ListTasks(ctx, args)
205
205
+
t.Run("filters by project (static mode)", func(t *testing.T) {
206
206
+
err := ListTasks(ctx, true, false, "", "", "work")
214
207
if err != nil {
215
208
t.Errorf("ListTasks with project filter failed: %v", err)
216
209
}
217
210
})
218
211
219
219
-
t.Run("searches tasks", func(t *testing.T) {
220
220
-
args := []string{"--search", "Task"}
221
221
-
222
222
-
err := ListTasks(ctx, args)
223
223
-
if err != nil {
224
224
-
t.Errorf("ListTasks with search failed: %v", err)
225
225
-
}
226
226
-
})
227
227
-
228
228
-
t.Run("limits results", func(t *testing.T) {
229
229
-
args := []string{"--limit", "1"}
230
230
-
231
231
-
err := ListTasks(ctx, args)
212
212
+
t.Run("show all tasks (static mode)", func(t *testing.T) {
213
213
+
err := ListTasks(ctx, true, true, "", "", "")
232
214
if err != nil {
233
233
-
t.Errorf("ListTasks with limit failed: %v", err)
215
215
+
t.Errorf("ListTasks with show all failed: %v", err)
234
216
}
235
217
})
236
218
})
···
683
665
})
684
666
685
667
t.Run("Helper", func(t *testing.T) {
686
686
-
t.Run("contains function", func(t *testing.T) {
687
687
-
slice := []string{"a", "b", "c"}
688
688
-
689
689
-
if !contains(slice, "b") {
690
690
-
t.Error("Expected contains to return true for existing item")
691
691
-
}
692
692
-
693
693
-
if contains(slice, "d") {
694
694
-
t.Error("Expected contains to return false for non-existing item")
695
695
-
}
696
696
-
})
697
668
698
669
t.Run("removeString function", func(t *testing.T) {
699
670
slice := []string{"a", "b", "c", "b"}
···
703
674
t.Errorf("Expected 2 items after removing 'b', got %d", len(result))
704
675
}
705
676
706
706
-
if contains(result, "b") {
677
677
+
if slices.Contains(result, "b") {
707
678
t.Error("Expected 'b' to be removed from slice")
708
679
}
709
680
710
710
-
if !contains(result, "a") || !contains(result, "c") {
681
681
+
if !slices.Contains(result, "a") || !slices.Contains(result, "c") {
711
682
t.Error("Expected 'a' and 'c' to remain in slice")
712
683
}
713
684
})
+443
internal/ui/task_list.go
···
1
1
+
package ui
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"fmt"
6
6
+
"io"
7
7
+
"os"
8
8
+
"strings"
9
9
+
10
10
+
tea "github.com/charmbracelet/bubbletea"
11
11
+
"github.com/charmbracelet/lipgloss"
12
12
+
"github.com/stormlightlabs/noteleaf/internal/models"
13
13
+
"github.com/stormlightlabs/noteleaf/internal/repo"
14
14
+
)
15
15
+
16
16
+
// TaskRepository interface for dependency injection in tests
17
17
+
type TaskRepository interface {
18
18
+
List(ctx context.Context, opts repo.TaskListOptions) ([]*models.Task, error)
19
19
+
Update(ctx context.Context, task *models.Task) error
20
20
+
}
21
21
+
22
22
+
// TaskListOptions configures the task list UI behavior
23
23
+
type TaskListOptions struct {
24
24
+
// Output destination (stdout for interactive, buffer for testing)
25
25
+
Output io.Writer
26
26
+
// Input source (stdin for interactive, strings reader for testing)
27
27
+
Input io.Reader
28
28
+
// Enable static mode (no interactive components)
29
29
+
Static bool
30
30
+
Status string
31
31
+
Priority string
32
32
+
Project string
33
33
+
ShowAll bool
34
34
+
}
35
35
+
36
36
+
// TaskList handles task browsing and viewing UI
37
37
+
type TaskList struct {
38
38
+
repo TaskRepository
39
39
+
opts TaskListOptions
40
40
+
}
41
41
+
42
42
+
// NewTaskList creates a new task list UI component
43
43
+
func NewTaskList(repo TaskRepository, opts TaskListOptions) *TaskList {
44
44
+
if opts.Output == nil {
45
45
+
opts.Output = os.Stdout
46
46
+
}
47
47
+
if opts.Input == nil {
48
48
+
opts.Input = os.Stdin
49
49
+
}
50
50
+
return &TaskList{repo: repo, opts: opts}
51
51
+
}
52
52
+
53
53
+
type taskListModel struct {
54
54
+
tasks []*models.Task
55
55
+
selected int
56
56
+
viewing bool
57
57
+
viewContent string
58
58
+
err error
59
59
+
repo TaskRepository
60
60
+
opts TaskListOptions
61
61
+
showAll bool
62
62
+
// filter string
63
63
+
}
64
64
+
65
65
+
type tasksLoadedMsg []*models.Task
66
66
+
type taskViewMsg string
67
67
+
type errorTaskMsg error
68
68
+
69
69
+
func (m taskListModel) Init() tea.Cmd {
70
70
+
return m.loadTasks()
71
71
+
}
72
72
+
73
73
+
func (m taskListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
74
74
+
switch msg := msg.(type) {
75
75
+
case tea.KeyMsg:
76
76
+
if m.viewing {
77
77
+
switch msg.String() {
78
78
+
case "q", "esc", "backspace":
79
79
+
m.viewing = false
80
80
+
m.viewContent = ""
81
81
+
return m, nil
82
82
+
}
83
83
+
return m, nil
84
84
+
}
85
85
+
86
86
+
switch msg.String() {
87
87
+
case "ctrl+c", "q":
88
88
+
return m, tea.Quit
89
89
+
case "up", "k":
90
90
+
if m.selected > 0 {
91
91
+
m.selected--
92
92
+
}
93
93
+
case "down", "j":
94
94
+
if m.selected < len(m.tasks)-1 {
95
95
+
m.selected++
96
96
+
}
97
97
+
case "enter", "v":
98
98
+
if len(m.tasks) > 0 && m.selected < len(m.tasks) {
99
99
+
return m, m.viewTask(m.tasks[m.selected])
100
100
+
}
101
101
+
case "r":
102
102
+
return m, m.loadTasks()
103
103
+
case "a":
104
104
+
m.showAll = !m.showAll
105
105
+
return m, m.loadTasks()
106
106
+
case "d":
107
107
+
if len(m.tasks) > 0 && m.selected < len(m.tasks) {
108
108
+
return m, m.markDone(m.tasks[m.selected])
109
109
+
}
110
110
+
case "1", "2", "3", "4", "5", "6", "7", "8", "9":
111
111
+
if idx := int(msg.String()[0] - '1'); idx < len(m.tasks) {
112
112
+
m.selected = idx
113
113
+
}
114
114
+
}
115
115
+
case tasksLoadedMsg:
116
116
+
m.tasks = []*models.Task(msg)
117
117
+
if m.selected >= len(m.tasks) && len(m.tasks) > 0 {
118
118
+
m.selected = len(m.tasks) - 1
119
119
+
}
120
120
+
case taskViewMsg:
121
121
+
m.viewContent = string(msg)
122
122
+
m.viewing = true
123
123
+
case errorTaskMsg:
124
124
+
m.err = error(msg)
125
125
+
}
126
126
+
return m, nil
127
127
+
}
128
128
+
129
129
+
func (m taskListModel) View() string {
130
130
+
var s strings.Builder
131
131
+
132
132
+
style := lipgloss.NewStyle().Foreground(lipgloss.Color("86"))
133
133
+
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
134
134
+
selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true)
135
135
+
headerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true)
136
136
+
priorityHighStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true)
137
137
+
priorityMediumStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("208"))
138
138
+
priorityLowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
139
139
+
statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("28"))
140
140
+
141
141
+
if m.viewing {
142
142
+
s.WriteString(m.viewContent)
143
143
+
s.WriteString("\n\n")
144
144
+
s.WriteString(style.Render("Press q/esc/backspace to return to list"))
145
145
+
return s.String()
146
146
+
}
147
147
+
148
148
+
s.WriteString(titleStyle.Render("Tasks"))
149
149
+
if m.showAll {
150
150
+
s.WriteString(" (showing all)")
151
151
+
} else {
152
152
+
s.WriteString(" (pending only)")
153
153
+
}
154
154
+
s.WriteString("\n\n")
155
155
+
156
156
+
if m.err != nil {
157
157
+
s.WriteString(fmt.Sprintf("Error: %s", m.err))
158
158
+
return s.String()
159
159
+
}
160
160
+
161
161
+
if len(m.tasks) == 0 {
162
162
+
s.WriteString("No tasks found")
163
163
+
s.WriteString("\n\n")
164
164
+
s.WriteString(style.Render("Press r to refresh, q to quit"))
165
165
+
return s.String()
166
166
+
}
167
167
+
168
168
+
headerLine := fmt.Sprintf("%-3s %-4s %-40s %-10s %-10s %-15s", "", "ID", "Description", "Status", "Priority", "Project")
169
169
+
s.WriteString(headerStyle.Render(headerLine))
170
170
+
s.WriteString("\n")
171
171
+
s.WriteString(headerStyle.Render(strings.Repeat("โ", 80)))
172
172
+
s.WriteString("\n")
173
173
+
174
174
+
for i, task := range m.tasks {
175
175
+
prefix := " "
176
176
+
if i == m.selected {
177
177
+
prefix = " > "
178
178
+
}
179
179
+
180
180
+
description := task.Description
181
181
+
if len(description) > 38 {
182
182
+
description = description[:35] + "..."
183
183
+
}
184
184
+
185
185
+
status := task.Status
186
186
+
if len(status) > 8 {
187
187
+
status = status[:8]
188
188
+
}
189
189
+
190
190
+
priority := task.Priority
191
191
+
if priority == "" {
192
192
+
priority = "-"
193
193
+
}
194
194
+
if len(priority) > 8 {
195
195
+
priority = priority[:8]
196
196
+
}
197
197
+
198
198
+
project := task.Project
199
199
+
if project == "" {
200
200
+
project = "-"
201
201
+
}
202
202
+
if len(project) > 13 {
203
203
+
project = project[:10] + "..."
204
204
+
}
205
205
+
206
206
+
line := fmt.Sprintf("%s%-4d %-40s %-10s %-10s %-15s",
207
207
+
prefix, task.ID, description, status, priority, project)
208
208
+
209
209
+
if i == m.selected {
210
210
+
s.WriteString(selectedStyle.Render(line))
211
211
+
} else {
212
212
+
// Color based on priority
213
213
+
switch strings.ToLower(task.Priority) {
214
214
+
case "high", "urgent":
215
215
+
s.WriteString(priorityHighStyle.Render(line))
216
216
+
case "medium":
217
217
+
s.WriteString(priorityMediumStyle.Render(line))
218
218
+
case "low":
219
219
+
s.WriteString(priorityLowStyle.Render(line))
220
220
+
default:
221
221
+
if task.Status == "completed" {
222
222
+
s.WriteString(statusStyle.Render(line))
223
223
+
} else {
224
224
+
s.WriteString(style.Render(line))
225
225
+
}
226
226
+
}
227
227
+
}
228
228
+
229
229
+
// Add tags if any
230
230
+
if len(task.Tags) > 0 && i == m.selected {
231
231
+
s.WriteString(" @" + strings.Join(task.Tags, " @"))
232
232
+
}
233
233
+
234
234
+
s.WriteString("\n")
235
235
+
}
236
236
+
237
237
+
s.WriteString("\n")
238
238
+
s.WriteString(style.Render("Controls: โ/โ/k/j to navigate, Enter/v to view, d to mark done, a to toggle all/pending"))
239
239
+
s.WriteString("\n")
240
240
+
s.WriteString(style.Render("r to refresh, q to quit, 1-9 to jump to task"))
241
241
+
242
242
+
return s.String()
243
243
+
}
244
244
+
245
245
+
func (m taskListModel) loadTasks() tea.Cmd {
246
246
+
return func() tea.Msg {
247
247
+
opts := repo.TaskListOptions{}
248
248
+
249
249
+
// Set status filter
250
250
+
if m.showAll || m.opts.ShowAll {
251
251
+
// Show all tasks - no status filter
252
252
+
} else {
253
253
+
opts.Status = "pending"
254
254
+
}
255
255
+
256
256
+
// Apply other filters from options
257
257
+
if m.opts.Status != "" {
258
258
+
opts.Status = m.opts.Status
259
259
+
}
260
260
+
if m.opts.Priority != "" {
261
261
+
opts.Priority = m.opts.Priority
262
262
+
}
263
263
+
if m.opts.Project != "" {
264
264
+
opts.Project = m.opts.Project
265
265
+
}
266
266
+
267
267
+
opts.SortBy = "modified"
268
268
+
opts.SortOrder = "DESC"
269
269
+
opts.Limit = 50
270
270
+
271
271
+
tasks, err := m.repo.List(context.Background(), opts)
272
272
+
if err != nil {
273
273
+
return errorTaskMsg(err)
274
274
+
}
275
275
+
276
276
+
return tasksLoadedMsg(tasks)
277
277
+
}
278
278
+
}
279
279
+
280
280
+
func (m taskListModel) viewTask(task *models.Task) tea.Cmd {
281
281
+
return func() tea.Msg {
282
282
+
var content strings.Builder
283
283
+
284
284
+
content.WriteString(fmt.Sprintf("# Task %d\n\n", task.ID))
285
285
+
content.WriteString(fmt.Sprintf("**UUID:** %s\n", task.UUID))
286
286
+
content.WriteString(fmt.Sprintf("**Description:** %s\n", task.Description))
287
287
+
content.WriteString(fmt.Sprintf("**Status:** %s\n", task.Status))
288
288
+
289
289
+
if task.Priority != "" {
290
290
+
content.WriteString(fmt.Sprintf("**Priority:** %s\n", task.Priority))
291
291
+
}
292
292
+
293
293
+
if task.Project != "" {
294
294
+
content.WriteString(fmt.Sprintf("**Project:** %s\n", task.Project))
295
295
+
}
296
296
+
297
297
+
if len(task.Tags) > 0 {
298
298
+
content.WriteString(fmt.Sprintf("**Tags:** %s\n", strings.Join(task.Tags, ", ")))
299
299
+
}
300
300
+
301
301
+
if task.Due != nil {
302
302
+
content.WriteString(fmt.Sprintf("**Due:** %s\n", task.Due.Format("2006-01-02 15:04")))
303
303
+
}
304
304
+
305
305
+
content.WriteString(fmt.Sprintf("**Created:** %s\n", task.Entry.Format("2006-01-02 15:04")))
306
306
+
content.WriteString(fmt.Sprintf("**Modified:** %s\n", task.Modified.Format("2006-01-02 15:04")))
307
307
+
308
308
+
if task.Start != nil {
309
309
+
content.WriteString(fmt.Sprintf("**Started:** %s\n", task.Start.Format("2006-01-02 15:04")))
310
310
+
}
311
311
+
312
312
+
if task.End != nil {
313
313
+
content.WriteString(fmt.Sprintf("**Completed:** %s\n", task.End.Format("2006-01-02 15:04")))
314
314
+
}
315
315
+
316
316
+
if len(task.Annotations) > 0 {
317
317
+
content.WriteString("\n**Annotations:**\n")
318
318
+
for _, annotation := range task.Annotations {
319
319
+
content.WriteString(fmt.Sprintf("- %s\n", annotation))
320
320
+
}
321
321
+
}
322
322
+
323
323
+
return taskViewMsg(content.String())
324
324
+
}
325
325
+
}
326
326
+
327
327
+
func (m taskListModel) markDone(task *models.Task) tea.Cmd {
328
328
+
return func() tea.Msg {
329
329
+
if task.Status == "completed" {
330
330
+
return errorTaskMsg(fmt.Errorf("task already completed"))
331
331
+
}
332
332
+
333
333
+
task.Status = "completed"
334
334
+
err := m.repo.Update(context.Background(), task)
335
335
+
if err != nil {
336
336
+
return errorTaskMsg(fmt.Errorf("failed to mark task done: %w", err))
337
337
+
}
338
338
+
339
339
+
// Reload tasks after marking done
340
340
+
return m.loadTasks()()
341
341
+
}
342
342
+
}
343
343
+
344
344
+
// Browse opens an interactive TUI for navigating and viewing tasks
345
345
+
func (tl *TaskList) Browse(ctx context.Context) error {
346
346
+
if tl.opts.Static {
347
347
+
return tl.staticList(ctx)
348
348
+
}
349
349
+
350
350
+
model := taskListModel{
351
351
+
repo: tl.repo,
352
352
+
opts: tl.opts,
353
353
+
showAll: tl.opts.ShowAll,
354
354
+
}
355
355
+
356
356
+
program := tea.NewProgram(model, tea.WithInput(tl.opts.Input), tea.WithOutput(tl.opts.Output))
357
357
+
358
358
+
_, err := program.Run()
359
359
+
return err
360
360
+
}
361
361
+
362
362
+
func (tl *TaskList) staticList(ctx context.Context) error {
363
363
+
opts := repo.TaskListOptions{}
364
364
+
365
365
+
if tl.opts.ShowAll {
366
366
+
// Show all tasks - no status filter
367
367
+
} else {
368
368
+
opts.Status = "pending"
369
369
+
}
370
370
+
371
371
+
if tl.opts.Status != "" {
372
372
+
opts.Status = tl.opts.Status
373
373
+
}
374
374
+
if tl.opts.Priority != "" {
375
375
+
opts.Priority = tl.opts.Priority
376
376
+
}
377
377
+
if tl.opts.Project != "" {
378
378
+
opts.Project = tl.opts.Project
379
379
+
}
380
380
+
381
381
+
opts.SortBy = "modified"
382
382
+
opts.SortOrder = "DESC"
383
383
+
384
384
+
tasks, err := tl.repo.List(ctx, opts)
385
385
+
if err != nil {
386
386
+
fmt.Fprintf(tl.opts.Output, "Error: %s\n", err)
387
387
+
return err
388
388
+
}
389
389
+
390
390
+
fmt.Fprintf(tl.opts.Output, "Tasks")
391
391
+
if tl.opts.ShowAll {
392
392
+
fmt.Fprintf(tl.opts.Output, " (showing all)")
393
393
+
} else {
394
394
+
fmt.Fprintf(tl.opts.Output, " (pending only)")
395
395
+
}
396
396
+
fmt.Fprintf(tl.opts.Output, "\n\n")
397
397
+
398
398
+
if len(tasks) == 0 {
399
399
+
fmt.Fprintf(tl.opts.Output, "No tasks found\n")
400
400
+
return nil
401
401
+
}
402
402
+
403
403
+
fmt.Fprintf(tl.opts.Output, "%-4s %-40s %-10s %-10s %-15s\n", "ID", "Description", "Status", "Priority", "Project")
404
404
+
fmt.Fprintf(tl.opts.Output, "%s\n", strings.Repeat("โ", 80))
405
405
+
406
406
+
for _, task := range tasks {
407
407
+
description := task.Description
408
408
+
if len(description) > 38 {
409
409
+
description = description[:35] + "..."
410
410
+
}
411
411
+
412
412
+
status := task.Status
413
413
+
if len(status) > 8 {
414
414
+
status = status[:8]
415
415
+
}
416
416
+
417
417
+
priority := task.Priority
418
418
+
if priority == "" {
419
419
+
priority = "-"
420
420
+
}
421
421
+
if len(priority) > 8 {
422
422
+
priority = priority[:8]
423
423
+
}
424
424
+
425
425
+
project := task.Project
426
426
+
if project == "" {
427
427
+
project = "-"
428
428
+
}
429
429
+
if len(project) > 13 {
430
430
+
project = project[:10] + "..."
431
431
+
}
432
432
+
433
433
+
fmt.Fprintf(tl.opts.Output, "%-4d %-40s %-10s %-10s %-15s", task.ID, description, status, priority, project)
434
434
+
435
435
+
if len(task.Tags) > 0 {
436
436
+
fmt.Fprintf(tl.opts.Output, " @%s", strings.Join(task.Tags, " @"))
437
437
+
}
438
438
+
439
439
+
fmt.Fprintf(tl.opts.Output, "\n")
440
440
+
}
441
441
+
442
442
+
return nil
443
443
+
}
+878
internal/ui/task_list_test.go
···
1
1
+
package ui
2
2
+
3
3
+
import (
4
4
+
"bytes"
5
5
+
"context"
6
6
+
"errors"
7
7
+
"fmt"
8
8
+
"strings"
9
9
+
"testing"
10
10
+
"time"
11
11
+
12
12
+
tea "github.com/charmbracelet/bubbletea"
13
13
+
"github.com/stormlightlabs/noteleaf/internal/models"
14
14
+
"github.com/stormlightlabs/noteleaf/internal/repo"
15
15
+
)
16
16
+
17
17
+
// MockTaskRepository implements TaskRepository interface for testing
18
18
+
type MockTaskRepository struct {
19
19
+
tasks []*models.Task
20
20
+
listError error
21
21
+
updateError error
22
22
+
}
23
23
+
24
24
+
func (m *MockTaskRepository) List(ctx context.Context, opts repo.TaskListOptions) ([]*models.Task, error) {
25
25
+
if m.listError != nil {
26
26
+
return nil, m.listError
27
27
+
}
28
28
+
29
29
+
var filteredTasks []*models.Task
30
30
+
for _, task := range m.tasks {
31
31
+
// Apply filters
32
32
+
if opts.Status != "" && task.Status != opts.Status {
33
33
+
continue
34
34
+
}
35
35
+
if opts.Priority != "" && task.Priority != opts.Priority {
36
36
+
continue
37
37
+
}
38
38
+
if opts.Project != "" && task.Project != opts.Project {
39
39
+
continue
40
40
+
}
41
41
+
if opts.Search != "" && !strings.Contains(strings.ToLower(task.Description), strings.ToLower(opts.Search)) {
42
42
+
continue
43
43
+
}
44
44
+
filteredTasks = append(filteredTasks, task)
45
45
+
}
46
46
+
47
47
+
// Apply limit
48
48
+
if opts.Limit > 0 && len(filteredTasks) > opts.Limit {
49
49
+
filteredTasks = filteredTasks[:opts.Limit]
50
50
+
}
51
51
+
52
52
+
return filteredTasks, nil
53
53
+
}
54
54
+
55
55
+
func (m *MockTaskRepository) Update(ctx context.Context, task *models.Task) error {
56
56
+
if m.updateError != nil {
57
57
+
return m.updateError
58
58
+
}
59
59
+
// Update the task in our mock data
60
60
+
for i, t := range m.tasks {
61
61
+
if t.ID == task.ID {
62
62
+
m.tasks[i] = task
63
63
+
break
64
64
+
}
65
65
+
}
66
66
+
return nil
67
67
+
}
68
68
+
69
69
+
// Create mock tasks for testing
70
70
+
func createMockTasks() []*models.Task {
71
71
+
now := time.Now()
72
72
+
return []*models.Task{
73
73
+
{
74
74
+
ID: 1,
75
75
+
UUID: "uuid-1",
76
76
+
Description: "Review quarterly report",
77
77
+
Status: "pending",
78
78
+
Priority: "high",
79
79
+
Project: "work",
80
80
+
Tags: []string{"urgent", "business"},
81
81
+
Entry: now.Add(-24 * time.Hour),
82
82
+
Modified: now.Add(-12 * time.Hour),
83
83
+
},
84
84
+
{
85
85
+
ID: 2,
86
86
+
UUID: "uuid-2",
87
87
+
Description: "Plan vacation itinerary",
88
88
+
Status: "pending",
89
89
+
Priority: "medium",
90
90
+
Project: "personal",
91
91
+
Tags: []string{"travel"},
92
92
+
Entry: now.Add(-48 * time.Hour),
93
93
+
Modified: now.Add(-6 * time.Hour),
94
94
+
},
95
95
+
{
96
96
+
ID: 3,
97
97
+
UUID: "uuid-3",
98
98
+
Description: "Fix authentication bug",
99
99
+
Status: "completed",
100
100
+
Priority: "high",
101
101
+
Project: "development",
102
102
+
Tags: []string{"bug", "security"},
103
103
+
Entry: now.Add(-72 * time.Hour),
104
104
+
Modified: now.Add(-1 * time.Hour),
105
105
+
End: &now,
106
106
+
},
107
107
+
{
108
108
+
ID: 4,
109
109
+
UUID: "uuid-4",
110
110
+
Description: "Read Clean Code book",
111
111
+
Status: "pending",
112
112
+
Priority: "low",
113
113
+
Project: "learning",
114
114
+
Tags: []string{"books", "development"},
115
115
+
Entry: now.Add(-96 * time.Hour),
116
116
+
Modified: now.Add(-3 * time.Hour),
117
117
+
},
118
118
+
}
119
119
+
}
120
120
+
121
121
+
func TestTaskListOptions(t *testing.T) {
122
122
+
t.Run("default options", func(t *testing.T) {
123
123
+
opts := TaskListOptions{}
124
124
+
if opts.Static {
125
125
+
t.Error("Static should default to false")
126
126
+
}
127
127
+
if opts.ShowAll {
128
128
+
t.Error("ShowAll should default to false")
129
129
+
}
130
130
+
})
131
131
+
132
132
+
t.Run("custom options", func(t *testing.T) {
133
133
+
var buf bytes.Buffer
134
134
+
opts := TaskListOptions{
135
135
+
Output: &buf,
136
136
+
Static: true,
137
137
+
ShowAll: true,
138
138
+
Status: "pending",
139
139
+
Priority: "high",
140
140
+
Project: "work",
141
141
+
}
142
142
+
143
143
+
if !opts.Static {
144
144
+
t.Error("Static should be enabled")
145
145
+
}
146
146
+
if !opts.ShowAll {
147
147
+
t.Error("ShowAll should be enabled")
148
148
+
}
149
149
+
if opts.Output != &buf {
150
150
+
t.Error("Output should be set to buffer")
151
151
+
}
152
152
+
if opts.Status != "pending" {
153
153
+
t.Error("Status filter not set correctly")
154
154
+
}
155
155
+
if opts.Priority != "high" {
156
156
+
t.Error("Priority filter not set correctly")
157
157
+
}
158
158
+
if opts.Project != "work" {
159
159
+
t.Error("Project filter not set correctly")
160
160
+
}
161
161
+
})
162
162
+
}
163
163
+
164
164
+
func TestNewTaskList(t *testing.T) {
165
165
+
repo := &MockTaskRepository{tasks: createMockTasks()}
166
166
+
167
167
+
t.Run("with default options", func(t *testing.T) {
168
168
+
tl := NewTaskList(repo, TaskListOptions{})
169
169
+
if tl == nil {
170
170
+
t.Fatal("NewTaskList returned nil")
171
171
+
}
172
172
+
if tl.repo != repo {
173
173
+
t.Error("Repository not set correctly")
174
174
+
}
175
175
+
if tl.opts.Output == nil {
176
176
+
t.Error("Output should default to os.Stdout")
177
177
+
}
178
178
+
if tl.opts.Input == nil {
179
179
+
t.Error("Input should default to os.Stdin")
180
180
+
}
181
181
+
})
182
182
+
183
183
+
t.Run("with custom options", func(t *testing.T) {
184
184
+
var buf bytes.Buffer
185
185
+
opts := TaskListOptions{
186
186
+
Output: &buf,
187
187
+
Static: true,
188
188
+
ShowAll: true,
189
189
+
Priority: "high",
190
190
+
}
191
191
+
tl := NewTaskList(repo, opts)
192
192
+
if tl.opts.Output != &buf {
193
193
+
t.Error("Custom output not set")
194
194
+
}
195
195
+
if !tl.opts.Static {
196
196
+
t.Error("Static mode not set")
197
197
+
}
198
198
+
if tl.opts.Priority != "high" {
199
199
+
t.Error("Priority filter not set")
200
200
+
}
201
201
+
})
202
202
+
}
203
203
+
204
204
+
func TestTaskListStaticMode(t *testing.T) {
205
205
+
t.Run("successful static list", func(t *testing.T) {
206
206
+
repo := &MockTaskRepository{tasks: createMockTasks()}
207
207
+
var buf bytes.Buffer
208
208
+
209
209
+
tl := NewTaskList(repo, TaskListOptions{
210
210
+
Output: &buf,
211
211
+
Static: true,
212
212
+
})
213
213
+
214
214
+
err := tl.Browse(context.Background())
215
215
+
if err != nil {
216
216
+
t.Fatalf("Browse failed: %v", err)
217
217
+
}
218
218
+
219
219
+
output := buf.String()
220
220
+
if !strings.Contains(output, "Tasks (pending only)") {
221
221
+
t.Error("Title not displayed correctly")
222
222
+
}
223
223
+
if !strings.Contains(output, "Review quarterly report") {
224
224
+
t.Error("First task not displayed")
225
225
+
}
226
226
+
if !strings.Contains(output, "Plan vacation itinerary") {
227
227
+
t.Error("Second task not displayed")
228
228
+
}
229
229
+
// Should not show completed task by default
230
230
+
if strings.Contains(output, "Fix authentication bug") {
231
231
+
t.Error("Completed task should not be shown by default")
232
232
+
}
233
233
+
})
234
234
+
235
235
+
t.Run("static list with all tasks", func(t *testing.T) {
236
236
+
repo := &MockTaskRepository{tasks: createMockTasks()}
237
237
+
var buf bytes.Buffer
238
238
+
239
239
+
tl := NewTaskList(repo, TaskListOptions{
240
240
+
Output: &buf,
241
241
+
Static: true,
242
242
+
ShowAll: true,
243
243
+
})
244
244
+
245
245
+
err := tl.Browse(context.Background())
246
246
+
if err != nil {
247
247
+
t.Fatalf("Browse failed: %v", err)
248
248
+
}
249
249
+
250
250
+
output := buf.String()
251
251
+
if !strings.Contains(output, "Tasks (showing all)") {
252
252
+
t.Error("All tasks title not displayed correctly")
253
253
+
}
254
254
+
if !strings.Contains(output, "Fix authentication bug") {
255
255
+
t.Error("Completed task should be shown with --all")
256
256
+
}
257
257
+
})
258
258
+
259
259
+
t.Run("static list with filters", func(t *testing.T) {
260
260
+
repo := &MockTaskRepository{tasks: createMockTasks()}
261
261
+
var buf bytes.Buffer
262
262
+
263
263
+
tl := NewTaskList(repo, TaskListOptions{
264
264
+
Output: &buf,
265
265
+
Static: true,
266
266
+
ShowAll: true,
267
267
+
Priority: "high",
268
268
+
})
269
269
+
270
270
+
err := tl.Browse(context.Background())
271
271
+
if err != nil {
272
272
+
t.Fatalf("Browse failed: %v", err)
273
273
+
}
274
274
+
275
275
+
output := buf.String()
276
276
+
if !strings.Contains(output, "Review quarterly report") {
277
277
+
t.Error("High priority task not displayed")
278
278
+
}
279
279
+
if !strings.Contains(output, "Fix authentication bug") {
280
280
+
t.Error("High priority completed task not displayed")
281
281
+
}
282
282
+
if strings.Contains(output, "Plan vacation itinerary") {
283
283
+
t.Error("Medium priority task should not be displayed")
284
284
+
}
285
285
+
})
286
286
+
287
287
+
t.Run("static list with no results", func(t *testing.T) {
288
288
+
repo := &MockTaskRepository{tasks: []*models.Task{}}
289
289
+
var buf bytes.Buffer
290
290
+
291
291
+
tl := NewTaskList(repo, TaskListOptions{
292
292
+
Output: &buf,
293
293
+
Static: true,
294
294
+
})
295
295
+
296
296
+
err := tl.Browse(context.Background())
297
297
+
if err != nil {
298
298
+
t.Fatalf("Browse failed: %v", err)
299
299
+
}
300
300
+
301
301
+
output := buf.String()
302
302
+
if !strings.Contains(output, "No tasks found") {
303
303
+
t.Error("No tasks message not displayed")
304
304
+
}
305
305
+
})
306
306
+
307
307
+
t.Run("static list with repository error", func(t *testing.T) {
308
308
+
repo := &MockTaskRepository{
309
309
+
listError: errors.New("database error"),
310
310
+
}
311
311
+
var buf bytes.Buffer
312
312
+
313
313
+
tl := NewTaskList(repo, TaskListOptions{
314
314
+
Output: &buf,
315
315
+
Static: true,
316
316
+
})
317
317
+
318
318
+
err := tl.Browse(context.Background())
319
319
+
if err == nil {
320
320
+
t.Fatal("Expected error, got nil")
321
321
+
}
322
322
+
323
323
+
output := buf.String()
324
324
+
if !strings.Contains(output, "Error: database error") {
325
325
+
t.Error("Error message not displayed")
326
326
+
}
327
327
+
})
328
328
+
}
329
329
+
330
330
+
func TestTaskListModel(t *testing.T) {
331
331
+
repo := &MockTaskRepository{tasks: createMockTasks()}
332
332
+
333
333
+
t.Run("initial model state", func(t *testing.T) {
334
334
+
model := taskListModel{
335
335
+
opts: TaskListOptions{ShowAll: false},
336
336
+
}
337
337
+
338
338
+
if model.selected != 0 {
339
339
+
t.Error("Initial selected should be 0")
340
340
+
}
341
341
+
if model.viewing {
342
342
+
t.Error("Initial viewing should be false")
343
343
+
}
344
344
+
if model.showAll {
345
345
+
t.Error("Initial showAll should be false")
346
346
+
}
347
347
+
})
348
348
+
349
349
+
t.Run("load tasks command", func(t *testing.T) {
350
350
+
model := taskListModel{
351
351
+
repo: repo,
352
352
+
opts: TaskListOptions{ShowAll: false},
353
353
+
}
354
354
+
355
355
+
cmd := model.loadTasks()
356
356
+
if cmd == nil {
357
357
+
t.Fatal("loadTasks should return a command")
358
358
+
}
359
359
+
360
360
+
msg := cmd()
361
361
+
switch msg := msg.(type) {
362
362
+
case tasksLoadedMsg:
363
363
+
tasks := []*models.Task(msg)
364
364
+
if len(tasks) != 3 { // Only pending tasks
365
365
+
t.Errorf("Expected 3 pending tasks, got %d", len(tasks))
366
366
+
}
367
367
+
case errorTaskMsg:
368
368
+
t.Fatalf("Unexpected error: %v", error(msg))
369
369
+
default:
370
370
+
t.Fatalf("Unexpected message type: %T", msg)
371
371
+
}
372
372
+
})
373
373
+
374
374
+
t.Run("load all tasks", func(t *testing.T) {
375
375
+
model := taskListModel{
376
376
+
repo: repo,
377
377
+
opts: TaskListOptions{ShowAll: true},
378
378
+
showAll: true,
379
379
+
}
380
380
+
381
381
+
cmd := model.loadTasks()
382
382
+
msg := cmd()
383
383
+
384
384
+
switch msg := msg.(type) {
385
385
+
case tasksLoadedMsg:
386
386
+
tasks := []*models.Task(msg)
387
387
+
if len(tasks) != 4 { // All tasks
388
388
+
t.Errorf("Expected 4 tasks, got %d", len(tasks))
389
389
+
}
390
390
+
case errorTaskMsg:
391
391
+
t.Fatalf("Unexpected error: %v", error(msg))
392
392
+
}
393
393
+
})
394
394
+
395
395
+
t.Run("view task command", func(t *testing.T) {
396
396
+
model := taskListModel{
397
397
+
repo: repo,
398
398
+
opts: TaskListOptions{},
399
399
+
}
400
400
+
401
401
+
task := createMockTasks()[0]
402
402
+
cmd := model.viewTask(task)
403
403
+
if cmd == nil {
404
404
+
t.Fatal("viewTask should return a command")
405
405
+
}
406
406
+
407
407
+
msg := cmd()
408
408
+
switch msg := msg.(type) {
409
409
+
case taskViewMsg:
410
410
+
content := string(msg)
411
411
+
if !strings.Contains(content, "# Task 1") {
412
412
+
t.Error("Task title not in view content")
413
413
+
}
414
414
+
if !strings.Contains(content, "Review quarterly report") {
415
415
+
t.Error("Task description not in view content")
416
416
+
}
417
417
+
if !strings.Contains(content, "**Status:** pending") {
418
418
+
t.Error("Task status not in view content")
419
419
+
}
420
420
+
if !strings.Contains(content, "**Priority:** high") {
421
421
+
t.Error("Task priority not in view content")
422
422
+
}
423
423
+
default:
424
424
+
t.Fatalf("Unexpected message type: %T", msg)
425
425
+
}
426
426
+
})
427
427
+
428
428
+
t.Run("mark done command", func(t *testing.T) {
429
429
+
model := taskListModel{
430
430
+
repo: repo,
431
431
+
opts: TaskListOptions{},
432
432
+
}
433
433
+
434
434
+
task := createMockTasks()[0] // Pending task
435
435
+
cmd := model.markDone(task)
436
436
+
if cmd == nil {
437
437
+
t.Fatal("markDone should return a command")
438
438
+
}
439
439
+
440
440
+
msg := cmd()
441
441
+
// Should return a loadTasks command after marking done
442
442
+
switch msg := msg.(type) {
443
443
+
case tasksLoadedMsg:
444
444
+
// Success - tasks reloaded
445
445
+
case errorTaskMsg:
446
446
+
// Check if it's the expected error for already completed task
447
447
+
err := error(msg)
448
448
+
if !strings.Contains(err.Error(), "completed") {
449
449
+
t.Fatalf("Unexpected error: %v", err)
450
450
+
}
451
451
+
default:
452
452
+
t.Fatalf("Unexpected message type: %T", msg)
453
453
+
}
454
454
+
})
455
455
+
456
456
+
t.Run("mark done already completed task", func(t *testing.T) {
457
457
+
model := taskListModel{
458
458
+
repo: repo,
459
459
+
opts: TaskListOptions{},
460
460
+
}
461
461
+
462
462
+
task := createMockTasks()[2] // Already completed task
463
463
+
cmd := model.markDone(task)
464
464
+
msg := cmd()
465
465
+
466
466
+
switch msg := msg.(type) {
467
467
+
case errorTaskMsg:
468
468
+
err := error(msg)
469
469
+
if !strings.Contains(err.Error(), "already completed") {
470
470
+
t.Errorf("Expected 'already completed' error, got: %v", err)
471
471
+
}
472
472
+
default:
473
473
+
t.Fatalf("Expected errorTaskMsg for already completed task, got: %T", msg)
474
474
+
}
475
475
+
})
476
476
+
}
477
477
+
478
478
+
func TestTaskListModelKeyHandling(t *testing.T) {
479
479
+
repo := &MockTaskRepository{tasks: createMockTasks()}
480
480
+
481
481
+
t.Run("quit commands", func(t *testing.T) {
482
482
+
model := taskListModel{
483
483
+
repo: repo,
484
484
+
tasks: createMockTasks()[:2], // First 2 tasks
485
485
+
opts: TaskListOptions{},
486
486
+
}
487
487
+
488
488
+
quitKeys := []string{"ctrl+c", "q"}
489
489
+
for _, key := range quitKeys {
490
490
+
newModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)})
491
491
+
if cmd == nil {
492
492
+
t.Errorf("Key %s should return quit command", key)
493
493
+
}
494
494
+
_ = newModel // Model should be returned
495
495
+
}
496
496
+
})
497
497
+
498
498
+
t.Run("navigation keys", func(t *testing.T) {
499
499
+
model := taskListModel{
500
500
+
repo: repo,
501
501
+
tasks: createMockTasks()[:3], // First 3 tasks
502
502
+
selected: 1, // Start in middle
503
503
+
opts: TaskListOptions{},
504
504
+
}
505
505
+
506
506
+
// Test up navigation
507
507
+
upKeys := []string{"up", "k"}
508
508
+
for _, key := range upKeys {
509
509
+
testModel := model
510
510
+
newModel, _ := testModel.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)})
511
511
+
if m, ok := newModel.(taskListModel); ok {
512
512
+
if m.selected != 0 {
513
513
+
t.Errorf("Key %s should move selection up to 0, got %d", key, m.selected)
514
514
+
}
515
515
+
}
516
516
+
}
517
517
+
518
518
+
// Test down navigation
519
519
+
downKeys := []string{"down", "j"}
520
520
+
for _, key := range downKeys {
521
521
+
testModel := model
522
522
+
newModel, _ := testModel.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)})
523
523
+
if m, ok := newModel.(taskListModel); ok {
524
524
+
if m.selected != 2 {
525
525
+
t.Errorf("Key %s should move selection down to 2, got %d", key, m.selected)
526
526
+
}
527
527
+
}
528
528
+
}
529
529
+
})
530
530
+
531
531
+
t.Run("view task keys", func(t *testing.T) {
532
532
+
model := taskListModel{
533
533
+
repo: repo,
534
534
+
tasks: createMockTasks()[:2],
535
535
+
selected: 0,
536
536
+
opts: TaskListOptions{},
537
537
+
}
538
538
+
539
539
+
viewKeys := []string{"enter", "v"}
540
540
+
for _, key := range viewKeys {
541
541
+
testModel := model
542
542
+
_, cmd := testModel.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)})
543
543
+
if cmd == nil {
544
544
+
t.Errorf("Key %s should return view command", key)
545
545
+
}
546
546
+
}
547
547
+
})
548
548
+
549
549
+
t.Run("number shortcuts", func(t *testing.T) {
550
550
+
model := taskListModel{
551
551
+
repo: repo,
552
552
+
tasks: createMockTasks()[:4],
553
553
+
opts: TaskListOptions{},
554
554
+
}
555
555
+
556
556
+
for i := 1; i <= 4; i++ {
557
557
+
testModel := model
558
558
+
key := fmt.Sprintf("%d", i)
559
559
+
newModel, _ := testModel.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)})
560
560
+
if m, ok := newModel.(taskListModel); ok {
561
561
+
expectedIndex := i - 1
562
562
+
if m.selected != expectedIndex {
563
563
+
t.Errorf("Number key %s should select index %d, got %d", key, expectedIndex, m.selected)
564
564
+
}
565
565
+
}
566
566
+
}
567
567
+
})
568
568
+
569
569
+
t.Run("toggle all/pending", func(t *testing.T) {
570
570
+
model := taskListModel{
571
571
+
repo: repo,
572
572
+
tasks: createMockTasks()[:2],
573
573
+
showAll: false,
574
574
+
opts: TaskListOptions{},
575
575
+
}
576
576
+
577
577
+
newModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")})
578
578
+
if m, ok := newModel.(taskListModel); ok {
579
579
+
if !m.showAll {
580
580
+
t.Error("Key 'a' should toggle showAll to true")
581
581
+
}
582
582
+
}
583
583
+
if cmd == nil {
584
584
+
t.Error("Toggle all should trigger task reload")
585
585
+
}
586
586
+
})
587
587
+
588
588
+
t.Run("mark done key", func(t *testing.T) {
589
589
+
model := taskListModel{
590
590
+
repo: repo,
591
591
+
tasks: createMockTasks()[:2],
592
592
+
selected: 0,
593
593
+
opts: TaskListOptions{},
594
594
+
}
595
595
+
596
596
+
_, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")})
597
597
+
if cmd == nil {
598
598
+
t.Error("Key 'd' should return mark done command")
599
599
+
}
600
600
+
})
601
601
+
602
602
+
t.Run("refresh key", func(t *testing.T) {
603
603
+
model := taskListModel{
604
604
+
repo: repo,
605
605
+
tasks: createMockTasks()[:2],
606
606
+
opts: TaskListOptions{},
607
607
+
}
608
608
+
609
609
+
_, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")})
610
610
+
if cmd == nil {
611
611
+
t.Error("Key 'r' should return refresh command")
612
612
+
}
613
613
+
})
614
614
+
615
615
+
t.Run("viewing mode navigation", func(t *testing.T) {
616
616
+
model := taskListModel{
617
617
+
repo: repo,
618
618
+
tasks: createMockTasks()[:2],
619
619
+
viewing: true,
620
620
+
viewContent: "Test content",
621
621
+
opts: TaskListOptions{},
622
622
+
}
623
623
+
624
624
+
exitKeys := []string{"q", "esc", "backspace"}
625
625
+
for _, key := range exitKeys {
626
626
+
testModel := model
627
627
+
newModel, _ := testModel.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)})
628
628
+
if m, ok := newModel.(taskListModel); ok {
629
629
+
if m.viewing {
630
630
+
t.Errorf("Key %s should exit viewing mode", key)
631
631
+
}
632
632
+
if m.viewContent != "" {
633
633
+
t.Errorf("Key %s should clear view content", key)
634
634
+
}
635
635
+
}
636
636
+
}
637
637
+
})
638
638
+
}
639
639
+
640
640
+
func TestTaskListModelView(t *testing.T) {
641
641
+
repo := &MockTaskRepository{tasks: createMockTasks()}
642
642
+
643
643
+
t.Run("viewing mode", func(t *testing.T) {
644
644
+
model := taskListModel{
645
645
+
repo: repo,
646
646
+
viewing: true,
647
647
+
viewContent: "# Task Details\nTest content here",
648
648
+
opts: TaskListOptions{},
649
649
+
}
650
650
+
651
651
+
view := model.View()
652
652
+
if !strings.Contains(view, "# Task Details") {
653
653
+
t.Error("View content not displayed in viewing mode")
654
654
+
}
655
655
+
if !strings.Contains(view, "Press q/esc/backspace to return to list") {
656
656
+
t.Error("Return instructions not displayed")
657
657
+
}
658
658
+
})
659
659
+
660
660
+
t.Run("error state", func(t *testing.T) {
661
661
+
model := taskListModel{
662
662
+
repo: repo,
663
663
+
err: errors.New("test error"),
664
664
+
opts: TaskListOptions{},
665
665
+
}
666
666
+
667
667
+
view := model.View()
668
668
+
if !strings.Contains(view, "Error: test error") {
669
669
+
t.Error("Error message not displayed")
670
670
+
}
671
671
+
})
672
672
+
673
673
+
t.Run("no tasks", func(t *testing.T) {
674
674
+
model := taskListModel{
675
675
+
repo: repo,
676
676
+
tasks: []*models.Task{},
677
677
+
opts: TaskListOptions{},
678
678
+
}
679
679
+
680
680
+
view := model.View()
681
681
+
if !strings.Contains(view, "No tasks found") {
682
682
+
t.Error("No tasks message not displayed")
683
683
+
}
684
684
+
if !strings.Contains(view, "Press r to refresh, q to quit") {
685
685
+
t.Error("Help text not displayed")
686
686
+
}
687
687
+
})
688
688
+
689
689
+
t.Run("with tasks", func(t *testing.T) {
690
690
+
tasks := createMockTasks()[:2] // First 2 tasks
691
691
+
model := taskListModel{
692
692
+
repo: repo,
693
693
+
tasks: tasks,
694
694
+
selected: 0,
695
695
+
showAll: false,
696
696
+
opts: TaskListOptions{},
697
697
+
}
698
698
+
699
699
+
view := model.View()
700
700
+
if !strings.Contains(view, "Tasks (pending only)") {
701
701
+
t.Error("Title not displayed correctly")
702
702
+
}
703
703
+
if !strings.Contains(view, "Review quarterly report") {
704
704
+
t.Error("First task not displayed")
705
705
+
}
706
706
+
if !strings.Contains(view, "Plan vacation itinerary") {
707
707
+
t.Error("Second task not displayed")
708
708
+
}
709
709
+
if !strings.Contains(view, "Controls:") {
710
710
+
t.Error("Control instructions not displayed")
711
711
+
}
712
712
+
})
713
713
+
714
714
+
t.Run("show all mode", func(t *testing.T) {
715
715
+
model := taskListModel{
716
716
+
repo: repo,
717
717
+
tasks: createMockTasks(),
718
718
+
showAll: true,
719
719
+
opts: TaskListOptions{ShowAll: true},
720
720
+
}
721
721
+
722
722
+
view := model.View()
723
723
+
if !strings.Contains(view, "Tasks (showing all)") {
724
724
+
t.Error("Show all title not displayed correctly")
725
725
+
}
726
726
+
})
727
727
+
728
728
+
t.Run("selected task highlighting", func(t *testing.T) {
729
729
+
tasks := createMockTasks()[:2]
730
730
+
model := taskListModel{
731
731
+
repo: repo,
732
732
+
tasks: tasks,
733
733
+
selected: 0,
734
734
+
opts: TaskListOptions{},
735
735
+
}
736
736
+
737
737
+
view := model.View()
738
738
+
// The selected task should have a ">" prefix
739
739
+
if !strings.Contains(view, " > 1 ") {
740
740
+
t.Error("Selected task not highlighted with '>' prefix")
741
741
+
}
742
742
+
})
743
743
+
}
744
744
+
745
745
+
func TestTaskListModelUpdate(t *testing.T) {
746
746
+
repo := &MockTaskRepository{tasks: createMockTasks()}
747
747
+
748
748
+
t.Run("tasks loaded message", func(t *testing.T) {
749
749
+
model := taskListModel{
750
750
+
repo: repo,
751
751
+
opts: TaskListOptions{},
752
752
+
}
753
753
+
754
754
+
tasks := createMockTasks()[:2]
755
755
+
newModel, _ := model.Update(tasksLoadedMsg(tasks))
756
756
+
757
757
+
if m, ok := newModel.(taskListModel); ok {
758
758
+
if len(m.tasks) != 2 {
759
759
+
t.Errorf("Expected 2 tasks, got %d", len(m.tasks))
760
760
+
}
761
761
+
if m.tasks[0].Description != "Review quarterly report" {
762
762
+
t.Error("Tasks not loaded correctly")
763
763
+
}
764
764
+
}
765
765
+
})
766
766
+
767
767
+
t.Run("task view message", func(t *testing.T) {
768
768
+
model := taskListModel{
769
769
+
repo: repo,
770
770
+
opts: TaskListOptions{},
771
771
+
}
772
772
+
773
773
+
content := "# Task Details\nTest content"
774
774
+
newModel, _ := model.Update(taskViewMsg(content))
775
775
+
776
776
+
if m, ok := newModel.(taskListModel); ok {
777
777
+
if !m.viewing {
778
778
+
t.Error("Viewing mode not activated")
779
779
+
}
780
780
+
if m.viewContent != content {
781
781
+
t.Error("View content not set correctly")
782
782
+
}
783
783
+
}
784
784
+
})
785
785
+
786
786
+
t.Run("error message", func(t *testing.T) {
787
787
+
model := taskListModel{
788
788
+
repo: repo,
789
789
+
opts: TaskListOptions{},
790
790
+
}
791
791
+
792
792
+
testErr := errors.New("test error")
793
793
+
newModel, _ := model.Update(errorTaskMsg(testErr))
794
794
+
795
795
+
if m, ok := newModel.(taskListModel); ok {
796
796
+
if m.err == nil {
797
797
+
t.Error("Error not set")
798
798
+
}
799
799
+
if m.err.Error() != "test error" {
800
800
+
t.Errorf("Expected 'test error', got %v", m.err)
801
801
+
}
802
802
+
}
803
803
+
})
804
804
+
805
805
+
t.Run("selected index bounds", func(t *testing.T) {
806
806
+
model := taskListModel{
807
807
+
repo: repo,
808
808
+
tasks: createMockTasks()[:2],
809
809
+
selected: 5, // Out of bounds
810
810
+
opts: TaskListOptions{},
811
811
+
}
812
812
+
813
813
+
// Load fewer tasks
814
814
+
newTasks := createMockTasks()[:1]
815
815
+
newModel, _ := model.Update(tasksLoadedMsg(newTasks))
816
816
+
817
817
+
if m, ok := newModel.(taskListModel); ok {
818
818
+
if m.selected >= len(m.tasks) {
819
819
+
t.Error("Selected index should be adjusted to bounds")
820
820
+
}
821
821
+
if m.selected != 0 { // Should be adjusted to last valid index
822
822
+
t.Errorf("Expected selected to be 0, got %d", m.selected)
823
823
+
}
824
824
+
}
825
825
+
})
826
826
+
}
827
827
+
828
828
+
func TestTaskListRepositoryError(t *testing.T) {
829
829
+
t.Run("list error in loadTasks", func(t *testing.T) {
830
830
+
repo := &MockTaskRepository{
831
831
+
listError: errors.New("database connection failed"),
832
832
+
}
833
833
+
834
834
+
model := taskListModel{
835
835
+
repo: repo,
836
836
+
opts: TaskListOptions{},
837
837
+
}
838
838
+
839
839
+
cmd := model.loadTasks()
840
840
+
msg := cmd()
841
841
+
842
842
+
switch msg := msg.(type) {
843
843
+
case errorTaskMsg:
844
844
+
err := error(msg)
845
845
+
if !strings.Contains(err.Error(), "database connection failed") {
846
846
+
t.Errorf("Expected database error, got: %v", err)
847
847
+
}
848
848
+
default:
849
849
+
t.Fatalf("Expected errorTaskMsg, got: %T", msg)
850
850
+
}
851
851
+
})
852
852
+
853
853
+
t.Run("update error in markDone", func(t *testing.T) {
854
854
+
repo := &MockTaskRepository{
855
855
+
tasks: createMockTasks(),
856
856
+
updateError: errors.New("update failed"),
857
857
+
}
858
858
+
859
859
+
model := taskListModel{
860
860
+
repo: repo,
861
861
+
opts: TaskListOptions{},
862
862
+
}
863
863
+
864
864
+
task := createMockTasks()[0]
865
865
+
cmd := model.markDone(task)
866
866
+
msg := cmd()
867
867
+
868
868
+
switch msg := msg.(type) {
869
869
+
case errorTaskMsg:
870
870
+
err := error(msg)
871
871
+
if !strings.Contains(err.Error(), "failed to mark task done") {
872
872
+
t.Errorf("Expected mark done error, got: %v", err)
873
873
+
}
874
874
+
default:
875
875
+
t.Fatalf("Expected errorTaskMsg, got: %T", msg)
876
876
+
}
877
877
+
})
878
878
+
}