feat(tui): refresh archive rows
This commit is contained in:
parent
5a53de1828
commit
563d50b42d
197
tui/tui.go
197
tui/tui.go
@ -45,6 +45,7 @@ var (
|
||||
const (
|
||||
wheelScrollDelay = 16 * time.Millisecond
|
||||
wheelMaxBufferedDelta = 6
|
||||
refreshInterval = 15 * time.Second
|
||||
doubleClickWindow = 450 * time.Millisecond
|
||||
rowsPaneAccent = "#5bc0eb"
|
||||
contextPaneAccent = "#9bc53d"
|
||||
@ -72,6 +73,14 @@ type wheelScrollMsg struct {
|
||||
seq int
|
||||
}
|
||||
|
||||
type refreshTickMsg struct{}
|
||||
|
||||
type refreshResultMsg struct {
|
||||
items []Item
|
||||
err error
|
||||
manual bool
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle,omitempty"`
|
||||
@ -129,6 +138,8 @@ type Options struct {
|
||||
Title string
|
||||
EmptyMessage string
|
||||
Items []Item
|
||||
Refresh func(context.Context) ([]Item, error)
|
||||
RefreshEvery time.Duration
|
||||
Layout LayoutPreset
|
||||
SourceKind string
|
||||
SourceLocation string
|
||||
@ -141,6 +152,8 @@ type BrowseOptions struct {
|
||||
Title string
|
||||
EmptyMessage string
|
||||
Rows []Row
|
||||
Refresh func(context.Context) ([]Row, error)
|
||||
RefreshEvery time.Duration
|
||||
JSON bool
|
||||
Layout LayoutPreset
|
||||
SourceKind string
|
||||
@ -163,6 +176,7 @@ func ControlsHelp() string {
|
||||
v cycle group view
|
||||
d toggle detail mode
|
||||
l toggle wide layout
|
||||
r refresh rows from the archive
|
||||
o open selected URL
|
||||
c copy selected URL
|
||||
wheel or j/k scroll focused pane
|
||||
@ -182,13 +196,35 @@ func Browse(ctx context.Context, opts BrowseOptions) error {
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(opts.Rows)
|
||||
}
|
||||
items := make([]Item, 0, len(opts.Rows))
|
||||
layout := opts.Layout
|
||||
if layout == LayoutAuto {
|
||||
layout = inferLayout(opts.Rows)
|
||||
}
|
||||
for _, row := range opts.Rows {
|
||||
items = append(items, row.ItemForLayout(layout))
|
||||
rows := opts.Rows
|
||||
if len(rows) == 0 && opts.Refresh != nil {
|
||||
refreshed, err := opts.Refresh(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows = refreshed
|
||||
if opts.Layout == LayoutAuto {
|
||||
layout = inferLayout(rows)
|
||||
}
|
||||
}
|
||||
items := rowItemsForLayout(rows, layout)
|
||||
var refreshItems func(context.Context) ([]Item, error)
|
||||
if opts.Refresh != nil {
|
||||
refreshItems = func(ctx context.Context) ([]Item, error) {
|
||||
rows, err := opts.Refresh(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nextLayout := layout
|
||||
if opts.Layout == LayoutAuto {
|
||||
nextLayout = inferLayout(rows)
|
||||
}
|
||||
return rowItemsForLayout(rows, nextLayout), nil
|
||||
}
|
||||
}
|
||||
title := strings.TrimSpace(opts.Title)
|
||||
if title == "" {
|
||||
@ -208,6 +244,8 @@ func Browse(ctx context.Context, opts BrowseOptions) error {
|
||||
Title: title,
|
||||
EmptyMessage: empty,
|
||||
Items: items,
|
||||
Refresh: refreshItems,
|
||||
RefreshEvery: opts.RefreshEvery,
|
||||
Layout: layout,
|
||||
SourceKind: opts.SourceKind,
|
||||
SourceLocation: opts.SourceLocation,
|
||||
@ -224,6 +262,14 @@ func Browse(ctx context.Context, opts BrowseOptions) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func rowItemsForLayout(rows []Row, layout LayoutPreset) []Item {
|
||||
items := make([]Item, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, row.ItemForLayout(layout))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (r Row) Item() Item {
|
||||
return r.ItemForLayout(LayoutAuto)
|
||||
}
|
||||
@ -295,6 +341,7 @@ func Run(ctx context.Context, opts Options) error {
|
||||
}
|
||||
defer restoreTerminalOutput(output)
|
||||
model := newModel(opts)
|
||||
model.ctx = ctx
|
||||
if width, height, ok := terminalSize(input, output); ok {
|
||||
model.width = width
|
||||
model.height = height
|
||||
@ -469,6 +516,9 @@ func compactTitle(value string) string {
|
||||
type model struct {
|
||||
title string
|
||||
items []Item
|
||||
refresh func(context.Context) ([]Item, error)
|
||||
refreshEvery time.Duration
|
||||
ctx context.Context
|
||||
filtered []int
|
||||
groups []itemGroup
|
||||
selected int
|
||||
@ -495,6 +545,7 @@ type model struct {
|
||||
groupMode groupMode
|
||||
compactDetail bool
|
||||
showHelp bool
|
||||
refreshing bool
|
||||
status string
|
||||
layoutMode layoutMode
|
||||
menuOpen bool
|
||||
@ -555,6 +606,7 @@ const (
|
||||
actionCopyFirstLink
|
||||
actionCopyAllLinks
|
||||
actionBackToActions
|
||||
actionRefresh
|
||||
actionQuit
|
||||
actionSortDefault
|
||||
actionSortCount
|
||||
@ -606,6 +658,9 @@ func newModel(opts Options) model {
|
||||
m := model{
|
||||
title: strings.TrimSpace(opts.Title),
|
||||
items: append([]Item(nil), opts.Items...),
|
||||
refresh: opts.Refresh,
|
||||
refreshEvery: opts.RefreshEvery,
|
||||
ctx: context.Background(),
|
||||
width: 100,
|
||||
height: 30,
|
||||
focus: focusRows,
|
||||
@ -619,6 +674,9 @@ func newModel(opts Options) model {
|
||||
if m.title == "" {
|
||||
m.title = "archive"
|
||||
}
|
||||
if m.refresh != nil && m.refreshEvery <= 0 {
|
||||
m.refreshEvery = refreshInterval
|
||||
}
|
||||
m.applyFilter()
|
||||
m.applyInitialGroupMode()
|
||||
return m
|
||||
@ -677,7 +735,7 @@ type itemGroup struct {
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return nil
|
||||
return m.refreshTickCmd()
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
@ -692,6 +750,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
m.applyQueuedWheelScroll()
|
||||
return m, nil
|
||||
case refreshTickMsg:
|
||||
return m, tea.Batch(m.startRefresh(false), m.refreshTickCmd())
|
||||
case refreshResultMsg:
|
||||
m.finishRefresh(typed)
|
||||
return m, nil
|
||||
case tea.MouseMsg:
|
||||
if typed.Action == tea.MouseActionMotion && typed.Button == tea.MouseButtonNone {
|
||||
if m.menuOpen {
|
||||
@ -843,6 +906,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.toggleLayout()
|
||||
case "d":
|
||||
m.toggleDetailMode()
|
||||
case "r":
|
||||
return m, m.startRefresh(true)
|
||||
case "v":
|
||||
m.cycleGroupMode()
|
||||
case "esc":
|
||||
@ -1124,6 +1189,7 @@ func (m *model) openActionMenuFor(context paneFocus) {
|
||||
{label: "Sort focused pane", action: actionSortMenu},
|
||||
{label: "Filter rows...", action: actionStartFilter},
|
||||
{label: "Jump to row...", action: actionStartJump},
|
||||
{label: "Refresh rows", action: actionRefresh},
|
||||
{label: "Toggle wide layout", action: actionToggleLayout},
|
||||
{label: detailModeToggleLabel(m.compactDetail), action: actionToggleDetail},
|
||||
{label: groupModeToggleLabel(m.layoutPreset, m.groupMode), action: actionCycleGroup},
|
||||
@ -1209,6 +1275,7 @@ func (m model) helpLines(width int) []string {
|
||||
" s: cycle group sort",
|
||||
" m: cycle member sort",
|
||||
" S: sort focused pane",
|
||||
" r: refresh rows from the archive",
|
||||
" v: cycle group view",
|
||||
" d: toggle compact/full detail",
|
||||
" l: toggle wide layout",
|
||||
@ -1369,6 +1436,8 @@ func (m *model) runMenuItem(item menuItem) tea.Cmd {
|
||||
case actionCopyAllLinks:
|
||||
m.copyAllReferenceLinks()
|
||||
m.closeMenu()
|
||||
case actionRefresh:
|
||||
return m.startRefresh(true)
|
||||
case actionSortDefault:
|
||||
m.setPaneSortMode(sortDefault)
|
||||
case actionSortCount:
|
||||
@ -1447,6 +1516,70 @@ func (m *model) finishJump() {
|
||||
m.jumpQuery = ""
|
||||
}
|
||||
|
||||
func (m model) refreshTickCmd() tea.Cmd {
|
||||
if m.refresh == nil || m.refreshEvery <= 0 {
|
||||
return nil
|
||||
}
|
||||
return tea.Tick(m.refreshEvery, func(time.Time) tea.Msg {
|
||||
return refreshTickMsg{}
|
||||
})
|
||||
}
|
||||
|
||||
func (m *model) startRefresh(manual bool) tea.Cmd {
|
||||
if m.refresh == nil {
|
||||
if manual {
|
||||
m.status = "Refresh unavailable"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
m.closeMenu()
|
||||
m.showHelp = false
|
||||
m.refreshing = true
|
||||
if manual {
|
||||
m.status = "Refreshing rows"
|
||||
}
|
||||
ctx := m.ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
refresh := m.refresh
|
||||
return func() tea.Msg {
|
||||
items, err := refresh(ctx)
|
||||
return refreshResultMsg{items: items, err: err, manual: manual}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *model) finishRefresh(msg refreshResultMsg) {
|
||||
m.refreshing = false
|
||||
if msg.err != nil {
|
||||
m.status = "Refresh failed: " + msg.err.Error()
|
||||
return
|
||||
}
|
||||
previousSignature := itemSignature(m.items)
|
||||
previousKey := ""
|
||||
if item, ok := m.selectedItem(); ok {
|
||||
previousKey = itemStableKey(item)
|
||||
}
|
||||
m.items = append([]Item(nil), msg.items...)
|
||||
m.applyFilter()
|
||||
if previousKey != "" {
|
||||
m.selectItemByStableKey(previousKey)
|
||||
}
|
||||
m.ensureVisible()
|
||||
nextSignature := itemSignature(m.items)
|
||||
if previousSignature == nextSignature {
|
||||
if msg.manual {
|
||||
m.status = "Rows already current"
|
||||
}
|
||||
return
|
||||
}
|
||||
if msg.manual {
|
||||
m.status = fmt.Sprintf("Refreshed %d row(s)", len(m.items))
|
||||
return
|
||||
}
|
||||
m.status = fmt.Sprintf("Auto refreshed %d row(s)", len(m.items))
|
||||
}
|
||||
|
||||
func (m *model) toggleLayout() {
|
||||
if m.layoutMode == layoutModeRightStack {
|
||||
m.layoutMode = layoutModeColumns
|
||||
@ -1768,6 +1901,9 @@ func (m model) renderFooter(width int) string {
|
||||
} else if m.jumpMode {
|
||||
line = "Jump: " + m.jumpQuery
|
||||
}
|
||||
if m.refreshing {
|
||||
line = "Refreshing rows " + line
|
||||
}
|
||||
if location := m.footerLocation(); location != "" {
|
||||
line += " " + location
|
||||
}
|
||||
@ -2018,15 +2154,15 @@ func (m *model) keepMenuVisible() {
|
||||
}
|
||||
|
||||
func footerControls(width int) string {
|
||||
full := "Tab focus click select header sort right-click menu a actions o open c copy s sort m members v group d detail l layout wheel scroll / filter # jump ? help q quit"
|
||||
full := "Tab focus click select header sort right-click menu a actions o open c copy s sort m members v group d detail l layout r refresh wheel scroll / filter # jump ? help q quit"
|
||||
if lipgloss.Width(full) <= maxInt(1, width-2) {
|
||||
return full
|
||||
}
|
||||
compact := "Tab focus click select right-click menu a actions o open c copy s sort m members v group d detail / filter # jump ? help q quit"
|
||||
compact := "Tab focus click select right-click menu a actions o open c copy s sort m members v group d detail l layout r refresh / filter # jump ? help q quit"
|
||||
if lipgloss.Width(compact) <= maxInt(1, width-2) {
|
||||
return compact
|
||||
}
|
||||
return "Tab panes click menu a actions o open c copy s sort m members v group d detail l layout / filter # jump ? help q quit"
|
||||
return "Tab panes click menu a actions o open c copy s sort m members v group d detail l layout r refresh / filter # jump ? help q quit"
|
||||
}
|
||||
|
||||
func (m model) footerLocation() string {
|
||||
@ -2640,6 +2776,25 @@ func (m *model) selectItemIndex(itemIndex int) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *model) selectItemByStableKey(key string) bool {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
for filteredIndex, itemIndex := range m.filtered {
|
||||
if itemIndex < 0 || itemIndex >= len(m.items) {
|
||||
continue
|
||||
}
|
||||
if itemStableKey(m.items[itemIndex]) == key {
|
||||
m.selected = filteredIndex
|
||||
m.contextOffset = 0
|
||||
m.detailView.GotoTop()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s sortMode) Label() string {
|
||||
switch s {
|
||||
case sortCount:
|
||||
@ -2724,6 +2879,34 @@ func compareStrings(left, right string) (bool, bool) {
|
||||
return left < right, true
|
||||
}
|
||||
|
||||
func itemStableKey(item Item) string {
|
||||
parts := []string{
|
||||
strings.TrimSpace(item.Source),
|
||||
strings.TrimSpace(item.Kind),
|
||||
strings.TrimSpace(item.ID),
|
||||
}
|
||||
if strings.Join(parts, "") != "" && strings.TrimSpace(item.ID) != "" {
|
||||
return strings.Join(parts, "\x00")
|
||||
}
|
||||
return strings.Join([]string{
|
||||
strings.TrimSpace(item.Source),
|
||||
strings.TrimSpace(item.Kind),
|
||||
strings.TrimSpace(item.Container),
|
||||
strings.TrimSpace(item.Author),
|
||||
strings.TrimSpace(item.Title),
|
||||
strings.TrimSpace(item.CreatedAt),
|
||||
strings.TrimSpace(item.UpdatedAt),
|
||||
}, "\x00")
|
||||
}
|
||||
|
||||
func itemSignature(items []Item) string {
|
||||
parts := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
parts = append(parts, itemStableKey(item)+"\x00"+strings.TrimSpace(item.Title)+"\x00"+strings.TrimSpace(item.UpdatedAt)+"\x00"+strings.TrimSpace(item.CreatedAt))
|
||||
}
|
||||
return strings.Join(parts, "\x1f")
|
||||
}
|
||||
|
||||
func (m model) positionLabel() string {
|
||||
if len(m.filtered) == 0 {
|
||||
return "0/0"
|
||||
|
||||
@ -1265,20 +1265,50 @@ func TestGitcrawlKeymapCyclesGroupAndMemberSort(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshKeyReloadsRowsLikeGitcrawl(t *testing.T) {
|
||||
m := newModel(Options{
|
||||
Title: "discrawl archive",
|
||||
Layout: LayoutChat,
|
||||
Items: []Item{
|
||||
Row{Kind: "message", ID: "m1", Container: "general", Author: "Amy", Title: "old", CreatedAt: "2026-05-01T10:00:00Z"}.ItemForLayout(LayoutChat),
|
||||
},
|
||||
Refresh: func(context.Context) ([]Item, error) {
|
||||
return []Item{
|
||||
Row{Kind: "message", ID: "m1", Container: "general", Author: "Amy", Title: "old", CreatedAt: "2026-05-01T10:00:00Z"}.ItemForLayout(LayoutChat),
|
||||
Row{Kind: "message", ID: "m2", Container: "general", Author: "Bob", Title: "new", CreatedAt: "2026-05-01T11:00:00Z"}.ItemForLayout(LayoutChat),
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}})
|
||||
m = updated.(model)
|
||||
if cmd == nil || !m.refreshing || m.status != "Refreshing rows" {
|
||||
t.Fatalf("refresh did not start, cmd=%v refreshing=%v status=%q", cmd, m.refreshing, m.status)
|
||||
}
|
||||
updated, _ = m.Update(cmd())
|
||||
m = updated.(model)
|
||||
if m.refreshing || len(m.items) != 2 || m.status != "Refreshed 2 row(s)" {
|
||||
t.Fatalf("refresh result not applied, refreshing=%v items=%d status=%q", m.refreshing, len(m.items), m.status)
|
||||
}
|
||||
item, ok := m.selectedItem()
|
||||
if !ok || item.ID != "m1" {
|
||||
t.Fatalf("refresh should preserve selected row by id, item=%#v ok=%v", item, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpPaneRendersUniversalControls(t *testing.T) {
|
||||
m := newModel(Options{
|
||||
Title: "archive",
|
||||
Items: []Item{{Title: "alpha", Tags: []string{"page"}}},
|
||||
})
|
||||
m.width = 160
|
||||
m.height = 34
|
||||
m.height = 40
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
|
||||
m = updated.(model)
|
||||
if !m.showHelp || m.menuOpen || m.focus != focusDetail {
|
||||
t.Fatalf("help should render in detail pane, showHelp=%v menu=%v focus=%v", m.showHelp, m.menuOpen, m.focus)
|
||||
}
|
||||
view := stripANSI(m.View())
|
||||
for _, want := range []string{"Crawlkit TUI", "right click: open a stable action menu", "o: open selected URL", "c: copy selected URL", "s: cycle group sort", "m: cycle member sort", "S: sort focused pane", "v: cycle group view", "#: jump to row", "left click: focus/select a pane row"} {
|
||||
for _, want := range []string{"Crawlkit TUI", "right click: open a stable action menu", "o: open selected URL", "c: copy selected URL", "r: refresh rows from the archive", "s: cycle group sort", "m: cycle member sort", "S: sort focused pane", "v: cycle group view", "#: jump to row", "left click: focus/select a pane row"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("help pane missing %q:\n%s", want, view)
|
||||
}
|
||||
@ -1531,7 +1561,7 @@ func TestRightClickPlacesFloatingMenu(t *testing.T) {
|
||||
t.Fatalf("menu rect not placed: %#v", m.menuRect)
|
||||
}
|
||||
view := m.View()
|
||||
if !strings.Contains(view, "Pane") || !strings.Contains(view, "Toggle wide layout") {
|
||||
if !strings.Contains(view, "Pane") || !strings.Contains(view, "Refresh rows") {
|
||||
t.Fatalf("floating menu missing expected sections:\n%s", view)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user