diff options
| -rw-r--r-- | .thicket/tickets.jsonl | 4 | ||||
| -rw-r--r-- | test_feeds.txt | 72 | ||||
| -rw-r--r-- | tui/tui.go | 44 | ||||
| -rw-r--r-- | tui/tui_test.go | 34 |
4 files changed, 131 insertions, 23 deletions
diff --git a/.thicket/tickets.jsonl b/.thicket/tickets.jsonl index 6f62686..8df7045 100644 --- a/.thicket/tickets.jsonl +++ b/.thicket/tickets.jsonl @@ -1,5 +1,7 @@ {"id":"NK-1phdpf","title":"refactor backend to have a clean API","description":"create a nice clean API for the backend GO code that is more independent of the frontend\n\nensure that it is working with good tests","type":"task","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-13T01:52:49.8322638Z","updated":"2026-02-13T04:26:47.517515371Z"} +{"id":"NK-27or4b","title":"Increase Test Coverage to \u003e80%","description":"Project-wide test coverage is currently ~63%. Key gaps are in the new and packages, as well as some core model logic. Increase coverage to at least 80% to ensure stability.","type":"task","status":"open","priority":2,"labels":null,"assignee":"","created":"2026-02-13T05:03:09.677147894Z","updated":"2026-02-13T05:03:09.677147894Z"} {"id":"NK-6q9nyg","title":"Refactor HTTP-dependent functions for testability","description":"Several functions use http.Get or external libraries directly (GetFullContent uses goose, ResolveFeedURL uses http.Get + goquery, imageProxyHandler uses http.Client). Refactor these to accept interfaces for HTTP fetching so they can be unit tested with mocks. This is the primary blocker for reaching 90% coverage.","type":"task","status":"open","priority":3,"labels":null,"assignee":"","created":"2026-02-13T03:54:37.630148644Z","updated":"2026-02-13T03:54:37.630148644Z"} +{"id":"NK-7tzbql","title":"Fix TUI Content View Navigation and Interaction","description":"The TUI content view (reading a single item) is currently non-functional or severely limited. Users cannot easily navigate back, scroll, or interact with the content. This task involves improving the 'viewContent' state in the TUI.","type":"bug","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-13T05:02:57.382793121Z","updated":"2026-02-13T05:06:15.144485446Z"} {"id":"NK-bsdwqz","title":"terminal UI","description":"once there is good test coverage and a clean backend API, work on a nice efficient TUI with https://github.com/charmbracelet/bubbletea","type":"task","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-13T01:54:02.285738454Z","updated":"2026-02-13T04:42:09.824268427Z"} {"id":"NK-ed1iah","title":"Make feed crawling async in API","description":"Currently, POST /api/feed triggers an immediate crawl which blocks the response (or at least keeps the goroutine alive). Refactor the crawling architecture to be truly async with a job queue or status updates, improving API responsiveness and reliability.","type":"task","status":"open","priority":2,"labels":null,"assignee":"","created":"2026-02-13T04:26:55.908243985Z","updated":"2026-02-13T04:26:55.908243985Z"} {"id":"NK-gqkh96","title":"Remaining test coverage gaps","description":"Cross-package test coverage is at 81.2%. The remaining untested functions are: GetFullContent (goose HTTP extraction), indexHandler/serveBoxedFile (rice.MustFindBox), Serve (starts HTTP server), main, util.init. To reach 90%, consider: (1) refactoring GetFullContent to accept an interface for HTTP fetching, (2) refactoring Serve to extract route setup into a testable function, (3) mocking rice.MustFindBox, (4) using feeds from https://trenchant.org/feeds.txt as static test fixtures for integration tests.","type":"task","status":"open","priority":3,"labels":null,"assignee":"","created":"2026-02-13T03:54:30.298141982Z","updated":"2026-02-13T03:54:30.298141982Z"} @@ -7,6 +9,8 @@ {"id":"NK-t0nmbj","title":"new web frontend","description":"The current frontend uses an old version of backbone and jquery. Let's \"deprecate\" it -- keep it arouond so we can test against it and use it, but let's be able to also serve and use a nice shiny new frontend written in either simiple, highly efficient vanilla javascript, or put together something in react or similar. Needs to feel fast and low latency!","type":"feature","status":"open","priority":2,"labels":null,"assignee":"","created":"2026-02-13T02:01:37.2107893Z","updated":"2026-02-13T02:01:37.2107893Z"} {"id":"NK-x924bu","title":"test coverage","description":"assume the code works properly (it mostly does)\nget to 90% test coverage on the go code","type":"task","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-13T01:52:01.042476226Z","updated":"2026-02-13T03:54:21.526519915Z"} {"id":"NK-d0ghccy","from_ticket_id":"NK-ric1zs","to_ticket_id":"NK-1phdpf","type":"created_from","created":"2026-02-13T04:26:55.875394997Z"} +{"id":"NK-d1uyy71","from_ticket_id":"NK-27or4b","to_ticket_id":"NK-bsdwqz","type":"created_from","created":"2026-02-13T05:03:09.689282214Z"} {"id":"NK-d86tgcs","from_ticket_id":"NK-ed1iah","to_ticket_id":"NK-1phdpf","type":"created_from","created":"2026-02-13T04:26:55.917754798Z"} {"id":"NK-dgfppki","from_ticket_id":"NK-gqkh96","to_ticket_id":"NK-x924bu","type":"created_from","created":"2026-02-13T03:54:30.303602703Z"} +{"id":"NK-dlvmiyc","from_ticket_id":"NK-7tzbql","to_ticket_id":"NK-bsdwqz","type":"created_from","created":"2026-02-13T05:02:57.392616851Z"} {"id":"NK-dwav3hh","from_ticket_id":"NK-6q9nyg","to_ticket_id":"NK-x924bu","type":"created_from","created":"2026-02-13T03:54:37.639569082Z"} diff --git a/test_feeds.txt b/test_feeds.txt new file mode 100644 index 0000000..942a12c --- /dev/null +++ b/test_feeds.txt @@ -0,0 +1,72 @@ +ttps://a.wholelottanothing.org/feed/ +http://feeds.feedburner.com/eod_full +https://www.analogue.co/feed/all +http://torrez.org/feed.xml +https://annas-blog.org/rss.xml +http://ascii.textfiles.com/feed/atom +https://begriffs.com/atom.xml +http://ilovebenbrown.com/rss +http://bitcannon.net/index.xml +http://feeds.feedburner.com/bricoleur +https://www.bump.net/feed/ +http://www.bytecellar.com/feed/ +http://www.antipope.org/charlie/blog-static/atom.xml +https://computer.rip/rss.xml +http://www.coryarcangel.com/feed/atom/ +http://forums.court-records.net/news/rss.php +https://craigmod.com/index.xml +https://danluu.com/atom.xml +https://lemire.me/blog/feed/ +http://www.spinellis.gr/blog/dds-blog-rss.xml +http://dreamandfriends.com/feed/ +http://sircmpwn.github.io/feed.xml +http://everybodyslibraries.com/feed/ +https://benbrown.com/feed.xml +http://www.tedunangst.com/flak/rss +https://fronkonstin.com/feed/ +http://www.ftrain.com/xml/feed/rss.xml +http://www.hardcoregaming101.net/feed/ +https://ln.hixie.ch/rss/html +http://www.cringely.com/feed/ +https://writing.markchristian.org/feed.xml +http://idlewords.com/index.xml +http://imperialviolet.org/iv-rss.xml +http://insertcredit.com/feed/ +http://interconnected.org/home/;rss2 +http://www.jessyoko.com/blog/feed/ +http://www.jnd.org/index.xml +http://www.jonas-kyratzes.net/feed/ +https://jcs.org/rss/notaweblog/ +https://www.jwz.org/blog/feed/ +http://feeds.kottke.org/main +http://www.timemachinego.com/linkmachinego/feed/ +http://lostlevels.org/wordpress/?feed=rss2 +http://motd.co/feed/ +https://nadia.xyz/feed.xml +http://nerdlypleasures.blogspot.com/feeds/posts/default +http://www.wadjeteyegames.com/category/news/feed +http://nullprogram.com/feed/ +https://www.tbray.org/ongoing/ongoing.atom +http://undeadly.org/cgi?action=rss +http://blogs.law.harvard.edu/philg/xml/rss.xml +https://ploum.net/atom_en.xml +https://randomfoo.net/feed +https://www.raphkoster.com/feed/ +http://ribbonblack.blogspot.com/rss.xml +https://www.schneier.com/blog/atom.xml +http://joeyh.name/blog/index.rss +http://blog.fogus.me/feed/ +https://simonwillison.net/atom/everything/ +https://www.smoothterminal.com/feed +https://www.sonyaellenmann.com/feed +http://feeds2.feedburner.com/stevelosh +https://www.filfre.net/feed/rss/ +https://obscuritory.com/feed/ +http://dtrace.org/blogs/bmc/feed/rss/ +http://www.trenchant.org/rss.xml +http://chneukirchen.org/trivium/index.atom +https://gamehistory.org/feed/ +https://virtuallyfun.com/feed/ +http://waxy.org/links/index.xml +http://negativesmart.com/wp-rss2.php +https://zed.dev/blog.rss @@ -7,6 +7,7 @@ import ( "adammathes.com/neko/models/feed" "adammathes.com/neko/models/item" "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" ) @@ -18,24 +19,6 @@ const ( viewContent ) -type itemDelegate struct{} - -func (d itemDelegate) Height() int { return 1 } -func (d itemDelegate) Spacing() int { return 0 } -func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } -func (d itemDelegate) Render(w strings.Builder, m list.Model, index int, listItem list.Item) { - str, ok := listItem.(itemString) - if !ok { - return - } - - if index == m.Index() { - fmt.Fprint(&w, SelectedItemStyle.Render(string(str))) - } else { - fmt.Fprint(&w, ItemStyle.Render(string(str))) - } -} - type itemString string func (i itemString) FilterValue() string { return string(i) } @@ -44,6 +27,7 @@ type Model struct { state viewState feedList list.Model itemList list.Model + contentView viewport.Model feeds []*feed.Feed items []*item.Item selectedFeed *feed.Feed @@ -57,9 +41,10 @@ func NewModel() Model { m := Model{ state: viewFeeds, } - // Initialize lists with empty items to avoid nil dereference in SetSize + // Initialize lists and viewport with empty items to avoid nil dereference m.feedList = list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0) m.itemList = list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0) + m.contentView = viewport.New(0, 0) return m } @@ -100,6 +85,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height m.feedList.SetSize(msg.Width, msg.Height-4) m.itemList.SetSize(msg.Width, msg.Height-4) + m.contentView.Width = msg.Width - 4 + m.contentView.Height = msg.Height - 8 case feedsMsg: m.feeds = msg @@ -130,7 +117,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { - case "ctrl+c", "q": + case "ctrl+c": + return m, tea.Quit + + case "q", "esc": if m.state == viewFeeds { return m, tea.Quit } @@ -151,6 +141,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { idx := m.itemList.Index() if idx >= 0 && idx < len(m.items) { m.selectedItem = m.items[idx] + + content := fmt.Sprintf("%s\n\n%s", + HeaderStyle.Render(m.selectedItem.Title), + m.selectedItem.Description) + + m.contentView.SetContent(content) + m.contentView.YPosition = 0 m.state = viewContent } } @@ -161,6 +158,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.feedList, cmd = m.feedList.Update(msg) } else if m.state == viewItems { m.itemList, cmd = m.itemList.Update(msg) + } else if m.state == viewContent { + m.contentView, cmd = m.contentView.Update(msg) } return m, cmd @@ -181,9 +180,8 @@ func (m Model) View() string { s.WriteString(m.itemList.View()) case viewContent: if m.selectedItem != nil { - s.WriteString(HeaderStyle.Render(m.selectedItem.Title) + "\n") - s.WriteString(ContentStyle.Render(m.selectedItem.Description) + "\n\n") - s.WriteString(StatusStyle.Render("Press 'q' or 'esc' to go back")) + s.WriteString(m.contentView.View() + "\n") + s.WriteString(StatusStyle.Render("Press 'q' or 'esc' to go back | j/k or arrows to scroll")) } } diff --git a/tui/tui_test.go b/tui/tui_test.go index 6ae8d73..de9caf0 100644 --- a/tui/tui_test.go +++ b/tui/tui_test.go @@ -1,6 +1,7 @@ package tui import ( + "strings" "testing" "adammathes.com/neko/models/feed" @@ -28,6 +29,8 @@ func TestUpdateWindowSizeNoPanic(t *testing.T) { func TestStateTransitions(t *testing.T) { m := NewModel() + m1, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + m = m1.(Model) // Feed loaded feeds := []*feed.Feed{{Id: 1, Title: "Test Feed"}} @@ -55,4 +58,35 @@ func TestStateTransitions(t *testing.T) { if tm4.state != viewFeeds { t.Errorf("Expected back to viewFeeds, got %v", tm4.state) } + + // Test entering content view + tm5, _ := tm3.Update(tea.KeyMsg{Type: tea.KeyEnter}) + tm5m := tm5.(Model) + if tm5m.state != viewContent { + t.Errorf("Expected state viewContent, got %v", tm5m.state) + } + if !strings.Contains(tm5m.contentView.View(), "Test Item") { + t.Error("Expected content view to show item title") + } + + // Back from content to items + tm6, _ := tm5.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) + tm6m := tm6.(Model) + if tm6m.state != viewItems { + t.Errorf("Expected back to viewItems, got %v", tm6m.state) + } + + // Test View for all states + if v := m.View(); !strings.Contains(v, "NEKO TUI") { + t.Error("View should contain title") + } + if v := tm2.View(); !strings.Contains(v, "Feeds") { + t.Error("View should contain Feeds list") + } + if v := tm3.View(); !strings.Contains(v, "Items") && !strings.Contains(v, "Test Feed") { + t.Error("View should contain Items list or Feed title") + } + if v := tm5m.View(); !strings.Contains(v, "Test Item") { + t.Error("View should contain Item title in content view") + } } |
