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