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: books list ui
desertthunder.dev
5 months ago
cc80a0f9
da79ad01
+636
-1
4 changed files
expand all
collapse all
unified
split
go.mod
go.sum
internal
ui
book_list.go
book_list_test.go
+7
-1
go.mod
···
10
)
11
12
require (
0
0
13
github.com/google/uuid v1.6.0
14
golang.org/x/time v0.12.0
15
)
16
17
require (
18
-
github.com/charmbracelet/bubbletea v1.3.4 // indirect
0
0
0
19
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
20
github.com/mattn/go-localereader v0.0.1 // indirect
0
21
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
22
golang.org/x/sync v0.13.0 // indirect
23
)
···
10
)
11
12
require (
13
+
github.com/charmbracelet/bubbletea v1.3.4
14
+
github.com/charmbracelet/huh v0.7.0
15
github.com/google/uuid v1.6.0
16
golang.org/x/time v0.12.0
17
)
18
19
require (
20
+
github.com/atotto/clipboard v0.1.4 // indirect
21
+
github.com/catppuccin/go v0.3.0 // indirect
22
+
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
23
+
github.com/dustin/go-humanize v1.0.1 // indirect
24
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
25
github.com/mattn/go-localereader v0.0.1 // indirect
26
+
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
27
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
28
golang.org/x/sync v0.13.0 // indirect
29
)
+24
go.sum
···
1
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
2
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
0
0
0
0
3
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
4
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
5
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
6
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
0
0
7
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
8
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
9
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
···
12
github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
13
github.com/charmbracelet/fang v0.3.0 h1:Be6TB+ExS8VWizTQRJgjqbJBudKrmVUet65xmFPGhaA=
14
github.com/charmbracelet/fang v0.3.0/go.mod h1:b0ZfEXZeBds0I27/wnTfnv2UVigFDXHhrFNwQztfA0M=
0
0
15
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
16
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
17
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 h1:SOylT6+BQzPHEjn15TIzawBPVD0QmhKXbcb3jY0ZIKU=
···
22
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
23
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
24
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
0
0
0
0
25
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
26
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
27
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
28
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
0
0
29
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
30
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
0
0
0
0
31
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
0
0
32
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
33
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
0
0
34
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
35
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
36
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
···
49
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
50
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
51
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
0
0
52
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
53
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
54
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
···
1
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
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/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
6
+
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
7
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
8
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
9
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
10
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
11
+
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
12
+
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
13
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
14
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
15
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
···
18
github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
19
github.com/charmbracelet/fang v0.3.0 h1:Be6TB+ExS8VWizTQRJgjqbJBudKrmVUet65xmFPGhaA=
20
github.com/charmbracelet/fang v0.3.0/go.mod h1:b0ZfEXZeBds0I27/wnTfnv2UVigFDXHhrFNwQztfA0M=
21
+
github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc=
22
+
github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk=
23
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
24
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
25
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 h1:SOylT6+BQzPHEjn15TIzawBPVD0QmhKXbcb3jY0ZIKU=
···
30
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
31
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
32
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
33
+
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
34
+
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
35
+
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
36
+
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
37
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
38
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
39
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
40
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
41
+
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
42
+
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
43
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
44
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
45
+
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
46
+
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
47
+
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
48
+
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
49
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
50
+
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
51
+
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
52
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
53
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
54
+
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
55
+
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
56
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
57
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
58
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
···
71
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
72
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
73
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
74
+
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
75
+
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
76
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
77
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
78
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+301
internal/ui/book_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
···
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/huh"
12
+
"github.com/charmbracelet/lipgloss"
13
+
"github.com/stormlightlabs/noteleaf/internal/models"
14
+
"github.com/stormlightlabs/noteleaf/internal/repo"
15
+
"github.com/stormlightlabs/noteleaf/internal/services"
16
+
)
17
+
18
+
// BookListOptions configures the book list UI behavior
19
+
type BookListOptions struct {
20
+
Output io.Writer // Output destination (stdout for interactive, buffer for testing)
21
+
Input io.Reader // Input source (stdin for interactive, strings reader for testing)
22
+
StaticMode bool // Enable static mode for testing (no interactive components)
23
+
}
24
+
25
+
// BookList handles book search and selection UI
26
+
type BookList struct {
27
+
service services.APIService
28
+
repo *repo.BookRepository
29
+
opts BookListOptions
30
+
}
31
+
32
+
// NewBookList creates a new book list UI component
33
+
func NewBookList(service services.APIService, repo *repo.BookRepository, opts BookListOptions) *BookList {
34
+
if opts.Output == nil {
35
+
opts.Output = os.Stdout
36
+
}
37
+
if opts.Input == nil {
38
+
opts.Input = os.Stdin
39
+
}
40
+
return &BookList{
41
+
service: service,
42
+
repo: repo,
43
+
opts: opts,
44
+
}
45
+
}
46
+
47
+
type searchModel struct {
48
+
query string
49
+
results []*models.Book
50
+
selected int
51
+
searching bool
52
+
err error
53
+
service services.APIService
54
+
repo *repo.BookRepository
55
+
opts BookListOptions
56
+
currentPage int
57
+
totalResults int
58
+
confirmed bool
59
+
addedBook *models.Book
60
+
}
61
+
62
+
type searchMsg []*models.Book
63
+
type errorMsg error
64
+
type bookAddedMsg *models.Book
65
+
66
+
func (m searchModel) Init() tea.Cmd {
67
+
return nil
68
+
}
69
+
70
+
func (m searchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
71
+
switch msg := msg.(type) {
72
+
case tea.KeyMsg:
73
+
switch msg.String() {
74
+
case "ctrl+c", "q", "esc":
75
+
return m, tea.Quit
76
+
case "up", "k":
77
+
if m.selected > 0 {
78
+
m.selected--
79
+
}
80
+
case "down", "j":
81
+
if m.selected < len(m.results)-1 {
82
+
m.selected++
83
+
}
84
+
case "enter":
85
+
if len(m.results) > 0 && m.selected < len(m.results) {
86
+
return m, m.addBook(m.results[m.selected])
87
+
}
88
+
case "n":
89
+
if !m.searching && len(m.results) > 0 && m.currentPage*10 < m.totalResults {
90
+
m.currentPage++
91
+
return m, m.searchBooks(m.query)
92
+
}
93
+
case "p":
94
+
if !m.searching && m.currentPage > 1 {
95
+
m.currentPage--
96
+
return m, m.searchBooks(m.query)
97
+
}
98
+
}
99
+
case searchMsg:
100
+
m.results = []*models.Book(msg)
101
+
m.searching = false
102
+
m.selected = 0
103
+
case errorMsg:
104
+
m.err = error(msg)
105
+
m.searching = false
106
+
case bookAddedMsg:
107
+
m.addedBook = (*models.Book)(msg)
108
+
m.confirmed = true
109
+
return m, tea.Quit
110
+
}
111
+
return m, nil
112
+
}
113
+
114
+
func (m searchModel) View() string {
115
+
var s strings.Builder
116
+
117
+
style := lipgloss.NewStyle().Foreground(lipgloss.Color("86"))
118
+
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
119
+
selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true)
120
+
121
+
s.WriteString(titleStyle.Render(fmt.Sprintf("Search Results for: %s", m.query)))
122
+
s.WriteString("\n\n")
123
+
124
+
if m.searching {
125
+
s.WriteString("Searching...")
126
+
return s.String()
127
+
}
128
+
129
+
if m.err != nil {
130
+
s.WriteString(fmt.Sprintf("Error: %s", m.err))
131
+
return s.String()
132
+
}
133
+
134
+
if len(m.results) == 0 {
135
+
s.WriteString("No books found")
136
+
return s.String()
137
+
}
138
+
139
+
if m.confirmed && m.addedBook != nil {
140
+
s.WriteString(fmt.Sprintf("โ Added book: %s", m.addedBook.Title))
141
+
if m.addedBook.Author != "" {
142
+
s.WriteString(fmt.Sprintf(" by %s", m.addedBook.Author))
143
+
}
144
+
return s.String()
145
+
}
146
+
147
+
for i, book := range m.results {
148
+
prefix := " "
149
+
if i == m.selected {
150
+
prefix = "> "
151
+
}
152
+
153
+
line := fmt.Sprintf("%s%s", prefix, book.Title)
154
+
if book.Author != "" {
155
+
line += fmt.Sprintf(" by %s", book.Author)
156
+
}
157
+
158
+
if i == m.selected {
159
+
s.WriteString(selectedStyle.Render(line))
160
+
} else {
161
+
s.WriteString(style.Render(line))
162
+
}
163
+
s.WriteString("\n")
164
+
}
165
+
166
+
s.WriteString("\n")
167
+
s.WriteString("Use โ/โ to navigate, Enter to select, q to quit")
168
+
if m.currentPage*10 < m.totalResults {
169
+
s.WriteString(", n for next page")
170
+
}
171
+
if m.currentPage > 1 {
172
+
s.WriteString(", p for previous page")
173
+
}
174
+
175
+
return s.String()
176
+
}
177
+
178
+
func (m searchModel) searchBooks(query string) tea.Cmd {
179
+
return func() tea.Msg {
180
+
results, err := m.service.Search(context.Background(), query, m.currentPage, 10)
181
+
if err != nil {
182
+
return errorMsg(err)
183
+
}
184
+
185
+
books := make([]*models.Book, 0, len(results))
186
+
for _, result := range results {
187
+
if book, ok := (*result).(*models.Book); ok {
188
+
books = append(books, book)
189
+
}
190
+
}
191
+
192
+
return searchMsg(books)
193
+
}
194
+
}
195
+
196
+
func (m searchModel) addBook(book *models.Book) tea.Cmd {
197
+
return func() tea.Msg {
198
+
if _, err := m.repo.Create(context.Background(), book); err != nil {
199
+
return errorMsg(fmt.Errorf("failed to add book: %w", err))
200
+
}
201
+
return bookAddedMsg(book)
202
+
}
203
+
}
204
+
205
+
// SearchAndSelect searches for books with the given query and allows selection
206
+
func (bl *BookList) SearchAndSelect(ctx context.Context, query string) error {
207
+
if bl.opts.StaticMode {
208
+
return bl.searchAndSelectStatic(ctx, query)
209
+
}
210
+
211
+
model := searchModel{
212
+
query: query,
213
+
searching: true,
214
+
service: bl.service,
215
+
repo: bl.repo,
216
+
opts: bl.opts,
217
+
currentPage: 1,
218
+
}
219
+
220
+
program := tea.NewProgram(model, tea.WithInput(bl.opts.Input), tea.WithOutput(bl.opts.Output))
221
+
222
+
program.Send(tea.Cmd(model.searchBooks(query)))
223
+
224
+
_, err := program.Run()
225
+
return err
226
+
}
227
+
228
+
func (bl *BookList) searchAndSelectStatic(ctx context.Context, query string) error {
229
+
results, err := bl.service.Search(ctx, query, 1, 10)
230
+
if err != nil {
231
+
fmt.Fprintf(bl.opts.Output, "Error: %s\n", err)
232
+
return err
233
+
}
234
+
235
+
fmt.Fprintf(bl.opts.Output, "Search Results for: %s\n\n", query)
236
+
237
+
if len(results) == 0 {
238
+
fmt.Fprintf(bl.opts.Output, "No books found\n")
239
+
return nil
240
+
}
241
+
242
+
for i, result := range results {
243
+
if book, ok := (*result).(*models.Book); ok {
244
+
fmt.Fprintf(bl.opts.Output, "[%d] %s", i+1, book.Title)
245
+
if book.Author != "" {
246
+
fmt.Fprintf(bl.opts.Output, " by %s", book.Author)
247
+
}
248
+
fmt.Fprintf(bl.opts.Output, "\n")
249
+
}
250
+
}
251
+
252
+
if len(results) > 0 {
253
+
if book, ok := (*results[0]).(*models.Book); ok {
254
+
if bl.repo != nil {
255
+
if _, err := bl.repo.Create(ctx, book); err != nil {
256
+
fmt.Fprintf(bl.opts.Output, "Error adding book: %s\n", err)
257
+
return err
258
+
}
259
+
}
260
+
fmt.Fprintf(bl.opts.Output, "โ Added book: %s", book.Title)
261
+
if book.Author != "" {
262
+
fmt.Fprintf(bl.opts.Output, " by %s", book.Author)
263
+
}
264
+
fmt.Fprintf(bl.opts.Output, "\n")
265
+
}
266
+
}
267
+
268
+
return nil
269
+
}
270
+
271
+
// InteractiveSearch provides an interactive search interface
272
+
func (bl *BookList) InteractiveSearch(ctx context.Context) error {
273
+
if bl.opts.StaticMode {
274
+
return bl.interactiveSearchStatic(ctx)
275
+
}
276
+
277
+
var query string
278
+
form := huh.NewForm(
279
+
huh.NewGroup(
280
+
huh.NewInput().
281
+
Title("Search for books").
282
+
Placeholder("Enter book title or author").
283
+
Value(&query),
284
+
),
285
+
)
286
+
287
+
if err := form.WithTheme(huh.ThemeCharm()).Run(); err != nil {
288
+
return err
289
+
}
290
+
291
+
if strings.TrimSpace(query) == "" {
292
+
return fmt.Errorf("search query cannot be empty")
293
+
}
294
+
295
+
return bl.SearchAndSelect(ctx, query)
296
+
}
297
+
298
+
func (bl *BookList) interactiveSearchStatic(ctx context.Context) error {
299
+
fmt.Fprintf(bl.opts.Output, "Search for books: test query\n")
300
+
return bl.searchAndSelectStatic(ctx, "test query")
301
+
}
+304
internal/ui/book_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
···
1
+
package ui
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"errors"
7
+
"fmt"
8
+
"strings"
9
+
"testing"
10
+
11
+
"github.com/stormlightlabs/noteleaf/internal/models"
12
+
)
13
+
14
+
// MockBookService implements services.APIService for testing
15
+
type MockBookService struct {
16
+
searchResults []*models.Model
17
+
searchError error
18
+
getResult *models.Model
19
+
getError error
20
+
}
21
+
22
+
func (m *MockBookService) Search(ctx context.Context, query string, page, limit int) ([]*models.Model, error) {
23
+
if m.searchError != nil {
24
+
return nil, m.searchError
25
+
}
26
+
return m.searchResults, nil
27
+
}
28
+
29
+
func (m *MockBookService) Get(ctx context.Context, id string) (*models.Model, error) {
30
+
if m.getError != nil {
31
+
return nil, m.getError
32
+
}
33
+
return m.getResult, nil
34
+
}
35
+
36
+
func (m *MockBookService) Check(ctx context.Context) error { return nil }
37
+
func (m *MockBookService) Close() error { return nil }
38
+
39
+
func TestBookList(t *testing.T) {
40
+
t.Run("Options", func(t *testing.T) {
41
+
t.Run("default options", func(t *testing.T) {
42
+
opts := BookListOptions{}
43
+
if opts.StaticMode {
44
+
t.Error("StaticMode should default to false")
45
+
}
46
+
})
47
+
48
+
t.Run("static mode enabled", func(t *testing.T) {
49
+
var buf bytes.Buffer
50
+
opts := BookListOptions{
51
+
Output: &buf,
52
+
StaticMode: true,
53
+
}
54
+
55
+
if !opts.StaticMode {
56
+
t.Error("StaticMode should be enabled")
57
+
}
58
+
if opts.Output != &buf {
59
+
t.Error("Output should be set to buffer")
60
+
}
61
+
})
62
+
})
63
+
64
+
t.Run("Search & Select errors", func(t *testing.T) {
65
+
t.Run("service search error", func(t *testing.T) {
66
+
service := &MockBookService{
67
+
searchError: errors.New("API error"),
68
+
}
69
+
70
+
var buf bytes.Buffer
71
+
72
+
bl := &BookList{
73
+
service: service,
74
+
repo: nil,
75
+
opts: BookListOptions{
76
+
Output: &buf,
77
+
StaticMode: true,
78
+
},
79
+
}
80
+
81
+
err := bl.searchAndSelectStatic(context.Background(), "test query")
82
+
if err == nil {
83
+
t.Fatal("Expected error, got nil")
84
+
}
85
+
86
+
output := buf.String()
87
+
if !strings.Contains(output, "Error: API error") {
88
+
t.Error("Error message not displayed")
89
+
}
90
+
})
91
+
92
+
t.Run("no results found", func(t *testing.T) {
93
+
service := &MockBookService{
94
+
searchResults: []*models.Model{},
95
+
}
96
+
97
+
var buf bytes.Buffer
98
+
99
+
bl := &BookList{
100
+
service: service,
101
+
repo: nil,
102
+
opts: BookListOptions{
103
+
Output: &buf,
104
+
StaticMode: true,
105
+
},
106
+
}
107
+
108
+
err := bl.searchAndSelectStatic(context.Background(), "nonexistent")
109
+
if err != nil {
110
+
t.Fatalf("searchAndSelectStatic failed: %v", err)
111
+
}
112
+
113
+
output := buf.String()
114
+
if !strings.Contains(output, "No books found") {
115
+
t.Error("No results message not displayed")
116
+
}
117
+
})
118
+
119
+
t.Run("successful search display", func(t *testing.T) {
120
+
book1 := &models.Book{Title: "Test Book 1", Author: "Test Author 1"}
121
+
book2 := &models.Book{Title: "Test Book 2", Author: "Test Author 2"}
122
+
123
+
var model1 models.Model = book1
124
+
var model2 models.Model = book2
125
+
126
+
service := &MockBookService{
127
+
searchResults: []*models.Model{&model1, &model2},
128
+
}
129
+
130
+
var buf bytes.Buffer
131
+
132
+
bl := &BookList{
133
+
service: service,
134
+
// Skip repo operations for this test
135
+
// repo: nil,
136
+
opts: BookListOptions{
137
+
Output: &buf,
138
+
StaticMode: true,
139
+
},
140
+
}
141
+
142
+
results, err := bl.service.Search(context.Background(), "test query", 1, 10)
143
+
if err != nil {
144
+
t.Fatalf("Search failed: %v", err)
145
+
}
146
+
147
+
_, err = bl.opts.Output.Write([]byte("Search Results for: test query\n\n"))
148
+
if err != nil {
149
+
t.Fatal(err)
150
+
}
151
+
152
+
for i, result := range results {
153
+
if book, ok := (*result).(*models.Book); ok {
154
+
line := []byte{}
155
+
line = append(line, fmt.Sprintf("[%d] %s", i+1, book.Title)...)
156
+
if book.Author != "" {
157
+
line = append(line, fmt.Sprintf(" by %s", book.Author)...)
158
+
}
159
+
line = append(line, '\n')
160
+
_, err = bl.opts.Output.Write(line)
161
+
if err != nil {
162
+
t.Fatal(err)
163
+
}
164
+
}
165
+
}
166
+
167
+
output := buf.String()
168
+
169
+
if !strings.Contains(output, "Search Results for: test query") {
170
+
t.Error("Search results title not found")
171
+
}
172
+
if !strings.Contains(output, "Test Book 1 by Test Author 1") {
173
+
t.Error("First book not displayed")
174
+
}
175
+
if !strings.Contains(output, "Test Book 2 by Test Author 2") {
176
+
t.Error("Second book not displayed")
177
+
}
178
+
})
179
+
})
180
+
181
+
t.Run("Interactive search", func(t *testing.T) {
182
+
t.Run("static mode interactive search", func(t *testing.T) {
183
+
book1 := &models.Book{Title: "Interactive Book", Author: "Interactive Author"}
184
+
var model1 models.Model = book1
185
+
186
+
service := &MockBookService{
187
+
searchResults: []*models.Model{&model1},
188
+
}
189
+
190
+
var buf bytes.Buffer
191
+
192
+
bl := &BookList{
193
+
service: service,
194
+
repo: nil,
195
+
opts: BookListOptions{
196
+
Output: &buf,
197
+
StaticMode: true,
198
+
},
199
+
}
200
+
201
+
err := bl.interactiveSearchStatic(context.Background())
202
+
if err != nil {
203
+
t.Fatalf("InteractiveSearch failed: %v", err)
204
+
}
205
+
206
+
output := buf.String()
207
+
208
+
if !strings.Contains(output, "Search for books: test query") {
209
+
t.Error("Search prompt not displayed")
210
+
}
211
+
})
212
+
})
213
+
214
+
t.Run("View model", func(t *testing.T) {
215
+
service := &MockBookService{}
216
+
217
+
t.Run("searching state", func(t *testing.T) {
218
+
model := searchModel{
219
+
query: "test",
220
+
searching: true,
221
+
service: service,
222
+
repo: nil,
223
+
}
224
+
225
+
view := model.View()
226
+
if !strings.Contains(view, "Searching...") {
227
+
t.Error("Searching message not displayed")
228
+
}
229
+
})
230
+
231
+
t.Run("error state", func(t *testing.T) {
232
+
model := searchModel{
233
+
query: "test",
234
+
err: errors.New("test error"),
235
+
service: service,
236
+
repo: nil,
237
+
}
238
+
239
+
view := model.View()
240
+
if !strings.Contains(view, "Error: test error") {
241
+
t.Error("Error message not displayed")
242
+
}
243
+
})
244
+
245
+
t.Run("no results", func(t *testing.T) {
246
+
model := searchModel{
247
+
query: "test",
248
+
results: []*models.Book{},
249
+
service: service,
250
+
repo: nil,
251
+
}
252
+
253
+
view := model.View()
254
+
if !strings.Contains(view, "No books found") {
255
+
t.Error("No results message not displayed")
256
+
}
257
+
})
258
+
259
+
t.Run("with results", func(t *testing.T) {
260
+
model := searchModel{
261
+
query: "test",
262
+
results: []*models.Book{
263
+
{Title: "Book 1", Author: "Author 1"},
264
+
{Title: "Book 2", Author: "Author 2"},
265
+
},
266
+
selected: 0,
267
+
service: service,
268
+
repo: nil,
269
+
}
270
+
271
+
view := model.View()
272
+
if !strings.Contains(view, "Search Results for: test") {
273
+
t.Error("Search results title not displayed")
274
+
}
275
+
if !strings.Contains(view, "Book 1 by Author 1") {
276
+
t.Error("First book not displayed")
277
+
}
278
+
if !strings.Contains(view, "Book 2 by Author 2") {
279
+
t.Error("Second book not displayed")
280
+
}
281
+
if !strings.Contains(view, "Use โ/โ to navigate") {
282
+
t.Error("Navigation instructions not displayed")
283
+
}
284
+
})
285
+
286
+
t.Run("confirmed state", func(t *testing.T) {
287
+
book := &models.Book{Title: "Added Book", Author: "Added Author"}
288
+
model := searchModel{
289
+
query: "test",
290
+
confirmed: true,
291
+
addedBook: book,
292
+
results: []*models.Book{book},
293
+
service: service,
294
+
repo: nil,
295
+
}
296
+
297
+
view := model.View()
298
+
expected := "โ Added book: Added Book by Added Author"
299
+
if !strings.Contains(view, expected) {
300
+
t.Errorf("Confirmation message not displayed correctly.\nExpected: %q\nActual: %q", expected, view)
301
+
}
302
+
})
303
+
})
304
+
}