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

feat: books list ui

+636 -1
+7 -1
go.mod
··· 10 10 ) 11 11 12 12 require ( 13 + github.com/charmbracelet/bubbletea v1.3.4 14 + github.com/charmbracelet/huh v0.7.0 13 15 github.com/google/uuid v1.6.0 14 16 golang.org/x/time v0.12.0 15 17 ) 16 18 17 19 require ( 18 - github.com/charmbracelet/bubbletea v1.3.4 // indirect 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 19 24 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 20 25 github.com/mattn/go-localereader v0.0.1 // indirect 26 + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect 21 27 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 22 28 golang.org/x/sync v0.13.0 // indirect 23 29 )
+24
go.sum
··· 1 1 github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= 2 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= 3 7 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 4 8 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 9 github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 6 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= 7 13 github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 8 14 github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 9 15 github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= ··· 12 18 github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= 13 19 github.com/charmbracelet/fang v0.3.0 h1:Be6TB+ExS8VWizTQRJgjqbJBudKrmVUet65xmFPGhaA= 14 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= 15 23 github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 16 24 github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 17 25 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 h1:SOylT6+BQzPHEjn15TIzawBPVD0QmhKXbcb3jY0ZIKU= ··· 22 30 github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 23 31 github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 24 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= 25 37 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= 26 38 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= 27 39 github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 28 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= 29 43 github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 30 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= 31 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= 32 52 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 33 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= 34 56 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 35 57 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 36 58 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= ··· 49 71 github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 50 72 github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= 51 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= 52 76 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 53 77 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 54 78 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+301
internal/ui/book_list.go
··· 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
··· 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 + }