Run imports in the background behind a spinner
Import ran inline in the update loop, so the whole interface froze for as long as pdftotext took over a stack of statements, with no way to tell work in progress apart from a hang. It now runs as a command off the event loop, with a spinner and a note that PDFs take a while. A repeat i press is ignored and retag is refused while an import is running, so nothing writes to the index concurrently. The result arrives as a message carrying the counts, the first failure, and the first warning with a count of any others. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+104
-11
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
"github.com/charmbracelet/bubbles/table"
|
"github.com/charmbracelet/bubbles/table"
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
@@ -51,6 +52,11 @@ type Model struct {
|
|||||||
txnTable table.Model
|
txnTable table.Model
|
||||||
reportTable table.Model
|
reportTable table.Model
|
||||||
text textinput.Model
|
text textinput.Model
|
||||||
|
spinner spinner.Model
|
||||||
|
|
||||||
|
// importing is set while an import runs in the background, so the UI keeps
|
||||||
|
// redrawing instead of freezing on a slow PDF.
|
||||||
|
importing bool
|
||||||
|
|
||||||
txns []model.Transaction // rows currently shown in txnTable
|
txns []model.Transaction // rows currently shown in txnTable
|
||||||
|
|
||||||
@@ -87,6 +93,10 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En
|
|||||||
ti.Prompt = ""
|
ti.Prompt = ""
|
||||||
ti.CharLimit = 64
|
ti.CharLimit = 64
|
||||||
|
|
||||||
|
sp := spinner.New()
|
||||||
|
sp.Spinner = spinner.Dot
|
||||||
|
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("62"))
|
||||||
|
|
||||||
styles := table.DefaultStyles()
|
styles := table.DefaultStyles()
|
||||||
styles.Header = styles.Header.Bold(true)
|
styles.Header = styles.Header.Bold(true)
|
||||||
styles.Selected = styles.Selected.Bold(true).Foreground(lipgloss.Color("15")).Background(lipgloss.Color("62"))
|
styles.Selected = styles.Selected.Bold(true).Foreground(lipgloss.Color("15")).Background(lipgloss.Color("62"))
|
||||||
@@ -103,6 +113,7 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En
|
|||||||
engine: engine,
|
engine: engine,
|
||||||
view: viewAccounts,
|
view: viewAccounts,
|
||||||
text: ti,
|
text: ti,
|
||||||
|
spinner: sp,
|
||||||
accountTable: newTable([]table.Column{
|
accountTable: newTable([]table.Column{
|
||||||
{Title: "Account", Width: 20},
|
{Title: "Account", Width: 20},
|
||||||
{Title: "Balance", Width: 14},
|
{Title: "Balance", Width: 14},
|
||||||
@@ -234,6 +245,22 @@ func (m *Model) selected() (model.Transaction, bool) {
|
|||||||
return m.txns[i], true
|
return m.txns[i], true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// importDoneMsg carries the outcome of a background import back to the model.
|
||||||
|
type importDoneMsg struct {
|
||||||
|
res importer.Result
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// importCmd runs the import off the event loop. Only values are captured, and
|
||||||
|
// the model is left untouched until the result comes back as a message.
|
||||||
|
func (m *Model) importCmd() tea.Cmd {
|
||||||
|
root, db, accounts, engine := m.root, m.db, m.accounts, m.engine
|
||||||
|
return func() tea.Msg {
|
||||||
|
res, err := importer.Run(root, db, accounts, engine, importer.Options{})
|
||||||
|
return importDoneMsg{res: res, err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update implements tea.Model.
|
// Update implements tea.Model.
|
||||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
@@ -242,6 +269,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.resize()
|
m.resize()
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case spinner.TickMsg:
|
||||||
|
if !m.importing {
|
||||||
|
return m, nil // a stale tick from a finished import
|
||||||
|
}
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.spinner, cmd = m.spinner.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
|
||||||
|
case importDoneMsg:
|
||||||
|
return m.finishImport(msg)
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
if m.input != inputNone {
|
if m.input != inputNone {
|
||||||
return m.updateInput(msg)
|
return m.updateInput(msg)
|
||||||
@@ -251,6 +289,48 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) finishImport(msg importDoneMsg) (tea.Model, tea.Cmd) {
|
||||||
|
m.importing = false
|
||||||
|
if msg.err != nil {
|
||||||
|
m.err = msg.err
|
||||||
|
m.status = "import failed"
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, added, skipped := msg.res.Total()
|
||||||
|
m.status = fmt.Sprintf("imported: %d new, %d duplicate", added, skipped)
|
||||||
|
|
||||||
|
// A failed file and a warning are both worth surfacing, but the status
|
||||||
|
// line only has room for the first thing that went wrong.
|
||||||
|
if failures := msg.res.Errs(); len(failures) > 0 {
|
||||||
|
m.err = fmt.Errorf("%s: %w", failures[0].Path, failures[0].Err)
|
||||||
|
} else if warnings := firstWarning(msg.res); warnings != "" {
|
||||||
|
m.status += " · " + warnings
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.reload(); err != nil {
|
||||||
|
m.err = err
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstWarning(res importer.Result) string {
|
||||||
|
total := 0
|
||||||
|
first := ""
|
||||||
|
for _, f := range res.Files {
|
||||||
|
for _, w := range f.Warnings {
|
||||||
|
if first == "" {
|
||||||
|
first = fmt.Sprintf("%s: %s", f.Path, w)
|
||||||
|
}
|
||||||
|
total++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total > 1 {
|
||||||
|
return fmt.Sprintf("%s (+%d more warnings)", first, total-1)
|
||||||
|
}
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) resize() {
|
func (m *Model) resize() {
|
||||||
h := m.height - 6 // title, status, help, padding
|
h := m.height - 6 // title, status, help, padding
|
||||||
if h < 3 {
|
if h < 3 {
|
||||||
@@ -438,6 +518,10 @@ func (m *Model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case "r":
|
case "r":
|
||||||
|
if m.importing {
|
||||||
|
m.status = "import in progress…"
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
n, err := m.engine.Retag(m.db)
|
n, err := m.engine.Retag(m.db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.err = err
|
m.err = err
|
||||||
@@ -448,18 +532,13 @@ func (m *Model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case "i":
|
case "i":
|
||||||
res, err := importer.Run(m.root, m.db, m.accounts, m.engine, importer.Options{})
|
if m.importing {
|
||||||
if err != nil {
|
return m, nil // already running; ignore the repeat press
|
||||||
m.err = err
|
|
||||||
return m, nil
|
|
||||||
}
|
}
|
||||||
_, added, skipped := res.Total()
|
m.importing = true
|
||||||
m.status = fmt.Sprintf("imported: %d new, %d duplicate", added, skipped)
|
m.err = nil
|
||||||
if failures := res.Errs(); len(failures) > 0 {
|
m.status = ""
|
||||||
m.err = fmt.Errorf("%s: %w", failures[0].Path, failures[0].Err)
|
return m, tea.Batch(m.spinner.Tick, m.importCmd())
|
||||||
}
|
|
||||||
m.err = m.reload()
|
|
||||||
return m, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
@@ -498,6 +577,8 @@ func (m *Model) View() string {
|
|||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
|
case m.importing:
|
||||||
|
b.WriteString(statusStyle.Render(m.spinner.View() + m.importingLabel()))
|
||||||
case m.input == inputTag:
|
case m.input == inputTag:
|
||||||
b.WriteString(statusStyle.Render("tag: ") + m.text.View())
|
b.WriteString(statusStyle.Render("tag: ") + m.text.View())
|
||||||
case m.input == inputSearch:
|
case m.input == inputSearch:
|
||||||
@@ -582,7 +663,19 @@ func (m *Model) title() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// importingLabel names what the import is working through, since extracting
|
||||||
|
// text from PDFs is where the wait actually comes from.
|
||||||
|
func (m *Model) importingLabel() string {
|
||||||
|
if n := len(m.accounts); n > 0 {
|
||||||
|
return fmt.Sprintf("importing %d account(s)… parsing statements can take a while for PDFs", n)
|
||||||
|
}
|
||||||
|
return "importing…"
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) help() string {
|
func (m *Model) help() string {
|
||||||
|
if m.importing {
|
||||||
|
return "importing… · q quit"
|
||||||
|
}
|
||||||
if m.input != inputNone {
|
if m.input != inputNone {
|
||||||
return "enter confirm · esc cancel"
|
return "enter confirm · esc cancel"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
"git.petrovv.com/nikola/money/internal/config"
|
"git.petrovv.com/nikola/money/internal/config"
|
||||||
|
"git.petrovv.com/nikola/money/internal/importer"
|
||||||
"git.petrovv.com/nikola/money/internal/model"
|
"git.petrovv.com/nikola/money/internal/model"
|
||||||
"git.petrovv.com/nikola/money/internal/rules"
|
"git.petrovv.com/nikola/money/internal/rules"
|
||||||
"git.petrovv.com/nikola/money/internal/store"
|
"git.petrovv.com/nikola/money/internal/store"
|
||||||
@@ -257,6 +261,125 @@ func TestReportViewExcludesTransfers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Import must not block the event loop: pressing i starts a background command
|
||||||
|
// and puts the view into a spinning state that keeps redrawing.
|
||||||
|
func TestImportShowsSpinnerAndDoesNotBlock(t *testing.T) {
|
||||||
|
m, _ := newTestModel(t)
|
||||||
|
|
||||||
|
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
|
||||||
|
if !m.importing {
|
||||||
|
t.Fatal("expected the model to be in the importing state")
|
||||||
|
}
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatal("expected a command to run the import in the background")
|
||||||
|
}
|
||||||
|
if view := m.View(); !strings.Contains(view, "importing") {
|
||||||
|
t.Errorf("expected the status line to say it is importing:\n%s", view)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tick advances the spinner and schedules the next frame.
|
||||||
|
_, tickCmd := m.Update(spinner.TickMsg{Time: time.Now()})
|
||||||
|
if tickCmd == nil {
|
||||||
|
t.Error("expected the spinner to schedule another tick while importing")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pressing i again must not start a second concurrent import.
|
||||||
|
if _, again := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}); again != nil {
|
||||||
|
t.Error("expected a repeat i press to be ignored while importing")
|
||||||
|
}
|
||||||
|
// Nor may retag run against the database mid-import.
|
||||||
|
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}})
|
||||||
|
if !strings.Contains(m.status, "import in progress") {
|
||||||
|
t.Errorf("status = %q, want retag to be refused during an import", m.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The result arriving clears the spinner and reports what happened.
|
||||||
|
m.Update(importDoneMsg{res: importer.Result{
|
||||||
|
Files: []importer.FileResult{{Path: "checking/st.csv", New: 3, Skipped: 1}},
|
||||||
|
}})
|
||||||
|
if m.importing {
|
||||||
|
t.Error("expected the importing state to clear")
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.status, "3 new") || !strings.Contains(m.status, "1 duplicate") {
|
||||||
|
t.Errorf("status = %q, want the import counts", m.status)
|
||||||
|
}
|
||||||
|
// A stale tick after the import finished must not restart the spinner.
|
||||||
|
if _, cmd := m.Update(spinner.TickMsg{Time: time.Now()}); cmd != nil {
|
||||||
|
t.Error("expected a tick after the import to be ignored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The spinner must keep ticking for as long as the import runs: one frame and
|
||||||
|
// then silence looks exactly like the freeze it is meant to rule out.
|
||||||
|
func TestSpinnerKeepsTicking(t *testing.T) {
|
||||||
|
m, _ := newTestModel(t)
|
||||||
|
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
|
||||||
|
|
||||||
|
// Drive the loop the way Bubble Tea does: run the command, feed the
|
||||||
|
// message it produces back into Update, and repeat.
|
||||||
|
var frames []string
|
||||||
|
cmd := tea.Cmd(m.spinner.Tick)
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatalf("tick %d: no command scheduled, the spinner stopped", i)
|
||||||
|
}
|
||||||
|
msg := cmd()
|
||||||
|
if msg == nil {
|
||||||
|
t.Fatalf("tick %d: command produced no message", i)
|
||||||
|
}
|
||||||
|
_, cmd = m.Update(msg)
|
||||||
|
frames = append(frames, m.spinner.View())
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(unique(frames)) < 2 {
|
||||||
|
t.Errorf("spinner rendered %v; want the frame to advance between ticks", frames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unique(in []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
for _, s := range in {
|
||||||
|
if !seen[s] {
|
||||||
|
seen[s] = true
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportFailureIsReported(t *testing.T) {
|
||||||
|
m, _ := newTestModel(t)
|
||||||
|
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
|
||||||
|
|
||||||
|
m.Update(importDoneMsg{err: errors.New("index is locked")})
|
||||||
|
if m.importing {
|
||||||
|
t.Error("expected the importing state to clear on failure")
|
||||||
|
}
|
||||||
|
if m.err == nil || !strings.Contains(m.err.Error(), "index is locked") {
|
||||||
|
t.Errorf("err = %v, want the failure surfaced", m.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A per-file warning is worth seeing without leaving the TUI.
|
||||||
|
func TestImportWarningsSurface(t *testing.T) {
|
||||||
|
m, _ := newTestModel(t)
|
||||||
|
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
|
||||||
|
|
||||||
|
m.Update(importDoneMsg{res: importer.Result{Files: []importer.FileResult{{
|
||||||
|
Path: "revolut/statement.csv",
|
||||||
|
New: 2,
|
||||||
|
Warnings: []string{"skipped 1 PENDING transactions", "skipped 3 rows in JPY"},
|
||||||
|
}}}})
|
||||||
|
|
||||||
|
if !strings.Contains(m.status, "skipped 1 PENDING") {
|
||||||
|
t.Errorf("status = %q, want the first warning", m.status)
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.status, "+1 more") {
|
||||||
|
t.Errorf("status = %q, want a count of the remaining warnings", m.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// newEmptyModel builds a model over an index with nothing in it.
|
// newEmptyModel builds a model over an index with nothing in it.
|
||||||
func newEmptyModel(t *testing.T, accounts []*config.Account) *Model {
|
func newEmptyModel(t *testing.T, accounts []*config.Account) *Model {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
Reference in New Issue
Block a user