Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
12 changes: 10 additions & 2 deletions tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"strings"
"syscall"
"time"
"unicode/utf8"

"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
63 changes: 60 additions & 3 deletions tui/tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"regexp"
"strings"
"testing"
"unicode/utf8"

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
Expand Down Expand Up @@ -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)
Expand All @@ -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))
}
}

Expand Down Expand Up @@ -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"}}})
Expand Down