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:
2026-08-09 01:12:48 +02:00
co-authored by Claude Opus 5
parent 9ba3fe8fbe
commit 6d4266c224
3 changed files with 227 additions and 11 deletions
+104 -11
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
@@ -51,6 +52,11 @@ type Model struct {
txnTable table.Model
reportTable table.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
@@ -87,6 +93,10 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En
ti.Prompt = ""
ti.CharLimit = 64
sp := spinner.New()
sp.Spinner = spinner.Dot
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("62"))
styles := table.DefaultStyles()
styles.Header = styles.Header.Bold(true)
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,
view: viewAccounts,
text: ti,
spinner: sp,
accountTable: newTable([]table.Column{
{Title: "Account", Width: 20},
{Title: "Balance", Width: 14},
@@ -234,6 +245,22 @@ func (m *Model) selected() (model.Transaction, bool) {
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.
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
@@ -242,6 +269,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.resize()
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:
if m.input != inputNone {
return m.updateInput(msg)
@@ -251,6 +289,48 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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() {
h := m.height - 6 // title, status, help, padding
if h < 3 {
@@ -438,6 +518,10 @@ func (m *Model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
case "r":
if m.importing {
m.status = "import in progress…"
return m, nil
}
n, err := m.engine.Retag(m.db)
if err != nil {
m.err = err
@@ -448,18 +532,13 @@ func (m *Model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
case "i":
res, err := importer.Run(m.root, m.db, m.accounts, m.engine, importer.Options{})
if err != nil {
m.err = err
return m, nil
if m.importing {
return m, nil // already running; ignore the repeat press
}
_, added, skipped := res.Total()
m.status = fmt.Sprintf("imported: %d new, %d duplicate", added, skipped)
if failures := res.Errs(); len(failures) > 0 {
m.err = fmt.Errorf("%s: %w", failures[0].Path, failures[0].Err)
}
m.err = m.reload()
return m, nil
m.importing = true
m.err = nil
m.status = ""
return m, tea.Batch(m.spinner.Tick, m.importCmd())
}
var cmd tea.Cmd
@@ -498,6 +577,8 @@ func (m *Model) View() string {
b.WriteString("\n")
switch {
case m.importing:
b.WriteString(statusStyle.Render(m.spinner.View() + m.importingLabel()))
case m.input == inputTag:
b.WriteString(statusStyle.Render("tag: ") + m.text.View())
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 {
if m.importing {
return "importing… · q quit"
}
if m.input != inputNone {
return "enter confirm · esc cancel"
}