diff --git a/CHANGELOG.md b/CHANGELOG.md index 815fbd0..ec3fe60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,10 @@ # Changelog -## v0.14.6 - Unreleased +## v0.14.6 - 2026-08-06 +- Fix the TUI filter so typing `q` appends to the query instead of quitting; `ctrl+c`/`ctrl+d` still quit while filtering. +- Fix TUI filter backspace to remove one rune instead of one byte, keeping CJK and emoji queries valid UTF-8. +- Guard `startRefresh` against overlapping runs so slow refreshes no longer stack goroutines; a manual refresh reports "Refresh already in progress". - Rewrite the README to the shared project standard and move the full package inventory to `docs/packages.md`. - Prepare the unified release pipeline for future Developer ID-signed and notarized `crawlctl` macOS archives and static Linux archives. diff --git a/tui/tui.go b/tui/tui.go index 349ac68..b9d2fea 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -16,6 +16,7 @@ import ( "strings" "syscall" "time" + "unicode/utf8" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -840,7 +841,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.filterMode { switch typed.String() { - case "ctrl+c", "ctrl+d", "q": + case "ctrl+c", "ctrl+d": return m, tea.Quit case "enter": m.filterMode = false @@ -852,7 +853,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.filterMode = false case "backspace": if len(m.query) > 0 { - m.query = m.query[:len(m.query)-1] + _, size := utf8.DecodeLastRuneInString(m.query) + m.query = m.query[:len(m.query)-size] m.applyFilter() } default: @@ -1600,6 +1602,12 @@ func (m *model) startRefresh(manual bool) tea.Cmd { } return nil } + if m.refreshing { + if manual { + m.status = "Refresh already in progress" + } + return nil + } m.closeMenu() m.showHelp = false m.refreshing = true diff --git a/tui/tui_test.go b/tui/tui_test.go index e787265..1928299 100644 --- a/tui/tui_test.go +++ b/tui/tui_test.go @@ -8,6 +8,7 @@ import ( "regexp" "strings" "testing" + "unicode/utf8" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -444,7 +445,7 @@ func TestVeryNarrowPanesStillShowCompactColumns(t *testing.T) { } } -func TestQClosesMenuAndQuitsFromFilterModes(t *testing.T) { +func TestQClosesMenuAndTypesInFilterMode(t *testing.T) { m := newModel(Options{Title: "archive", Items: []Item{{Title: "alpha"}}}) updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) m = updated.(model) @@ -463,9 +464,46 @@ func TestQClosesMenuAndQuitsFromFilterModes(t *testing.T) { if !m.filterMode { t.Fatal("filter did not start") } - _, cmd = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + m = updated.(model) + if cmd != nil || !m.filterMode || m.query != "q" { + t.Fatalf("q in filter should be typed, mode=%v query=%q cmd=%v", m.filterMode, m.query, cmd) + } +} + +func TestFilterModeAcceptsQueryAndControlCQuits(t *testing.T) { + m := newModel(Options{Title: "archive", Items: []Item{{Title: "query"}}}) + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + m = updated.(model) + for _, r := range "query" { + var cmd tea.Cmd + updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + m = updated.(model) + if cmd != nil { + t.Fatalf("typing %q returned a command", r) + } + } + if !m.filterMode || m.query != "query" { + t.Fatalf("typed filter = mode %v query %q, want mode true query %q", m.filterMode, m.query, "query") + } + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) if cmd == nil { - t.Fatal("q in filter should quit") + t.Fatal("ctrl+c in filter should quit") + } +} + +func TestFilterBackspaceRemovesLastRune(t *testing.T) { + m := newModel(Options{Title: "archive", Items: []Item{{Title: "日本語"}}}) + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + m = updated.(model) + for _, r := range "日本語" { + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + m = updated.(model) + } + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + m = updated.(model) + if m.query != "日本" || !utf8.ValidString(m.query) { + t.Fatalf("query after backspace = %q, valid UTF-8=%v", m.query, utf8.ValidString(m.query)) } } @@ -1516,6 +1554,25 @@ func TestRefreshCurrentStatusUsesGitcrawlSourceLanguage(t *testing.T) { } } +func TestStartRefreshDoesNotStack(t *testing.T) { + m := newModel(Options{ + Title: "archive", + Items: []Item{{Title: "alpha"}}, + Refresh: func(context.Context) ([]Item, error) { + return []Item{{Title: "alpha"}}, nil + }, + }) + if cmd := m.startRefresh(false); cmd == nil || !m.refreshing { + t.Fatalf("initial refresh did not start, cmd=%v refreshing=%v", cmd, m.refreshing) + } + if cmd := m.startRefresh(false); cmd != nil { + t.Fatalf("automatic refresh stacked while refreshing: cmd=%v", cmd) + } + if cmd := m.startRefresh(true); cmd != nil || m.status != "Refresh already in progress" { + t.Fatalf("manual refresh while refreshing = cmd %v status %q", cmd, m.status) + } +} + func TestContextDoneQuitsModelForSignalCleanup(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) m := newModel(Options{Title: "archive", Items: []Item{{Title: "alpha"}}})