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"
|
||||
"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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user