From 5f1db9f7449a72f659d5b54bbfc9a154baab162d Mon Sep 17 00:00:00 2001 From: Artur Taranchiev Date: Sat, 31 Jan 2026 13:00:03 +0600 Subject: [PATCH 1/2] Show all operations in spinner --- internal/ui/account_list.go | 18 ++-- internal/ui/categories.go | 35 +++++--- internal/ui/expenses.go | 4 +- internal/ui/revenues.go | 4 +- internal/ui/summary.go | 4 +- internal/ui/transaction.go | 8 +- internal/ui/transaction_list.go | 28 +++--- internal/ui/ui.go | 81 +++++++++++++---- internal/ui/ui_test.go | 148 ++++++++++++++++++++++---------- 9 files changed, 220 insertions(+), 110 deletions(-) diff --git a/internal/ui/account_list.go b/internal/ui/account_list.go index 6d60131..0729a93 100644 --- a/internal/ui/account_list.go +++ b/internal/ui/account_list.go @@ -55,8 +55,8 @@ func (m AccountListModel[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if matchMsgType(msg, m.config.RefreshMsgType) { return m, func() tea.Msg { - startLoading("Loading accounts...") - defer stopLoading() + opID := startLoading("Loading accounts...") + defer stopLoading(opID) err := m.config.RefreshItems(m.api, m.config.AccountType) if err != nil { return notify.NotifyWarn(err.Error())() @@ -66,7 +66,10 @@ func (m AccountListModel[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if matchMsgType(msg, m.config.UpdateMsgType) { - return m, m.updateItemsCmd() + return m, tea.Batch( + m.updateItemsCmd(), + Cmd(DataLoadCompletedMsg{DataType: m.config.AccountType}), + ) } if msg, ok := msg.(UpdatePositions); ok { @@ -167,6 +170,8 @@ func (m AccountListModel[T]) createTotalEntity(primary float64) list.Item { } func (m *AccountListModel[T]) updateItemsCmd() tea.Cmd { + opID := startLoading("Updating account list...") + defer stopLoading(opID) items := m.config.GetItems(m.api, m.sorted) if m.config.HasTotalRow && m.config.GetTotalFunc != nil { @@ -178,15 +183,10 @@ func (m *AccountListModel[T]) updateItemsCmd() tea.Cmd { m.list.InsertItem(0, totalEntity), } - cmds = append(cmds, Cmd(DataLoadCompletedMsg{DataType: m.config.AccountType})) - return tea.Sequence(cmds...) } - return tea.Batch( - m.list.SetItems(items), - Cmd(DataLoadCompletedMsg{DataType: m.config.AccountType}), - ) + return m.list.SetItems(items) } func matchMsgType(msg, ty tea.Msg) bool { diff --git a/internal/ui/categories.go b/internal/ui/categories.go index 07e786b..2a931c3 100644 --- a/internal/ui/categories.go +++ b/internal/ui/categories.go @@ -92,8 +92,8 @@ func (m modelCategories) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case RefreshCategoryInsightsMsg: return m, func() tea.Msg { - startLoading("Loading category insights...") - defer stopLoading() + opID := startLoading("Loading category insights...") + defer stopLoading(opID) err := m.api.UpdateCategoriesInsights() if err != nil { return notify.NotifyWarn(err.Error())() @@ -102,8 +102,8 @@ func (m modelCategories) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case RefreshCategoriesMsg: return m, func() tea.Msg { - startLoading("Loading categories...") - defer stopLoading() + opID := startLoading("Loading categories...") + defer stopLoading(opID) err := m.api.UpdateCategories() if err != nil { return notify.NotifyWarn(err.Error())() @@ -111,19 +111,13 @@ func (m modelCategories) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return CategoriesUpdateMsg{} } case CategoriesUpdateMsg: - tSpent, tEarned := m.api.GetTotalSpentEarnedCategories() return m, tea.Batch( - m.list.SetItems(getCategoriesItems(m.api, m.sorted)), - m.list.InsertItem(0, categoryItem{ - category: totalCategory, - spent: tSpent, - earned: tEarned, - }), + m.updateItemsCmd(), Cmd(DataLoadCompletedMsg{DataType: "categories"}), ) case NewCategoryMsg: - startLoading("Creating category...") - defer stopLoading() + opID := startLoading("Creating category...") + defer stopLoading(opID) err := m.api.CreateCategory(msg.Category, "") if err != nil { return m, notify.NotifyWarn(err.Error()) @@ -258,3 +252,18 @@ func CmdPromptNewCategory(backCmd tea.Cmd) tea.Cmd { }, ) } + +func (m *modelCategories) updateItemsCmd() tea.Cmd { + opID := startLoading("Updating caterogy list...") + defer stopLoading(opID) + items := getCategoriesItems(m.api, m.sorted) + tSpent, tEarned := m.api.GetTotalSpentEarnedCategories() + return tea.Sequence( + m.list.SetItems(items), + m.list.InsertItem(0, categoryItem{ + category: totalCategory, + spent: tSpent, + earned: tEarned, + }), + ) +} diff --git a/internal/ui/expenses.go b/internal/ui/expenses.go index e553aaa..58d4776 100644 --- a/internal/ui/expenses.go +++ b/internal/ui/expenses.go @@ -93,8 +93,8 @@ func (m modelExpenses) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.(type) { case RefreshExpenseInsightsMsg: return m, func() tea.Msg { - startLoading("Loading expense insights...") - defer stopLoading() + opID := startLoading("Loading expense insights...") + defer stopLoading(opID) err := m.api.(ExpenseAPI).UpdateExpenseInsights() if err != nil { return notify.NotifyWarn(err.Error())() diff --git a/internal/ui/revenues.go b/internal/ui/revenues.go index 77c3669..7bd0c9a 100644 --- a/internal/ui/revenues.go +++ b/internal/ui/revenues.go @@ -93,8 +93,8 @@ func (m modelRevenues) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.(type) { case RefreshRevenueInsightsMsg: return m, func() tea.Msg { - startLoading("Loading revenue insights...") - defer stopLoading() + opID := startLoading("Loading revenue insights...") + defer stopLoading(opID) err := m.api.(RevenueAPI).UpdateRevenueInsights() if err != nil { return notify.NotifyWarn(err.Error())() diff --git a/internal/ui/summary.go b/internal/ui/summary.go index 4058561..80479d6 100644 --- a/internal/ui/summary.go +++ b/internal/ui/summary.go @@ -98,8 +98,8 @@ func (m modelSummary) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case RefreshSummaryMsg: return m, func() tea.Msg { - startLoading("Loading summary...") - defer stopLoading() + opID := startLoading("Loading summary...") + defer stopLoading(opID) err := m.api.UpdateSummary() if err != nil { return notify.NotifyWarn(err.Error())() diff --git a/internal/ui/transaction.go b/internal/ui/transaction.go index 25dbf5c..a1295fa 100644 --- a/internal/ui/transaction.go +++ b/internal/ui/transaction.go @@ -424,8 +424,8 @@ func (m *modelTransaction) DeleteSplit(index int) tea.Cmd { } func (m *modelTransaction) CreateTransaction() tea.Cmd { - startLoading("Creating transaction...") - defer stopLoading() + opID := startLoading("Creating transaction...") + defer stopLoading(opID) trx := []firefly.RequestTransactionSplit{} for _, s := range m.splits { trx = append(trx, firefly.RequestTransactionSplit{ @@ -470,8 +470,8 @@ func (m *modelTransaction) CreateTransaction() tea.Cmd { } func (m *modelTransaction) UpdateTransaction() tea.Cmd { - startLoading("Updating transaction...") - defer stopLoading() + opID := startLoading("Updating transaction...") + defer stopLoading(opID) trx := []firefly.RequestTransactionSplit{} for _, s := range m.splits { trx = append(trx, firefly.RequestTransactionSplit{ diff --git a/internal/ui/transaction_list.go b/internal/ui/transaction_list.go index 312b765..f9c4d47 100644 --- a/internal/ui/transaction_list.go +++ b/internal/ui/transaction_list.go @@ -8,7 +8,6 @@ import ( "fmt" "net/url" "strconv" - "strings" "time" "ffiii-tui/internal/firefly" @@ -208,14 +207,17 @@ func (m modelTransactions) Update(msg tea.Msg) (tea.Model, tea.Cmd) { continue } for _, split := range tx.Splits { - if CaseInsensitiveContains(split.Description, value) || - CaseInsensitiveContains(split.Source.Name, value) || - CaseInsensitiveContains(split.Destination.Name, value) || - CaseInsensitiveContains(split.Category.Name, value) || - CaseInsensitiveContains(split.Currency, value) || - strings.Contains(fmt.Sprintf("%.2f", split.Amount), value) || - CaseInsensitiveContains(split.ForeignCurrency, value) || - strings.Contains(fmt.Sprintf("%.2f", split.ForeignAmount), value) { + if CaseInsensitiveContains( + split.Description+ + split.Source.Name+ + split.Destination.Name+ + split.Category.Name+ + split.Currency+ + split.ForeignCurrency+ + fmt.Sprintf("%.2f", split.Amount)+ + fmt.Sprintf("%.2f", split.ForeignAmount), + value, + ) { txs = append(txs, tx) break } @@ -243,8 +245,8 @@ func (m modelTransactions) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.currentSearch != "" { searchQuery = url.QueryEscape(m.currentSearch) } - startLoading("Loading transactions...") - defer stopLoading() + opID := startLoading("Loading transactions...") + defer stopLoading(opID) transactions, err := m.api.ListTransactions(searchQuery) if err != nil { return notify.NotifyWarn(err.Error())() @@ -267,8 +269,8 @@ func (m modelTransactions) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case DeleteTransactionMsg: id := msg.Transaction.TransactionID if id != "" { - startLoading("Deleting transaction...") - defer stopLoading() + opID := startLoading("Deleting transaction...") + defer stopLoading(opID) err := m.api.DeleteTransaction(id) if err != nil { return m, tea.Batch( diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 6b82481..72edd4b 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "strings" + "sync" "sync/atomic" "time" @@ -25,8 +26,11 @@ import ( type state uint -var loading atomic.Int32 -var loadingMessage atomic.Value // stores string +var ( + loading atomic.Int32 + loadingOps sync.Map + operationIDSeq atomic.Uint64 // for generating unique operation IDs +) const ( transactionsView state = iota @@ -307,7 +311,7 @@ func (m modelUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Cmd(RefreshExpensesMsg{}), Cmd(RefreshRevenuesMsg{}), Cmd(RefreshCategoriesMsg{}), - tea.Tick(time.Second*1, func(t time.Time) tea.Msg { + tea.Tick(time.Millisecond*100, func(t time.Time) tea.Msg { return LazyLoadMsg{ t: t, c: m.api.TimeoutSeconds(), @@ -398,13 +402,7 @@ func (m modelUI) View() string { } if loading.Load() > 0 { - msgValue := loadingMessage.Load() - msg := "..." - if msgValue != nil { - if str, ok := msgValue.(string); ok && str != "" { - msg = str - } - } + msg := buildLoadingMessage() header += " | " + m.spinner.View() + msg } s.WriteString(headerRenderer.Width(m.Width).Render(header) + "\n") @@ -493,31 +491,76 @@ func SetView(state state) tea.Cmd { return Cmd(SetFocusedViewMsg{state: state}) } -func startLoading(message string) { - loadingMessage.Store(message) +func startLoading(message string) string { for { current := loading.Load() if current >= 100 { - return + return "" // Max operations reached } if loading.CompareAndSwap(current, current+1) { - return + break } } + + // Generate unique operation ID + opID := fmt.Sprintf("op_%d", operationIDSeq.Add(1)) + + loadingOps.Store(opID, message) + + return opID } -func stopLoading() { +func stopLoading(opID string) { + if opID == "" { + return // Invalid operation ID + } + + loadingOps.Delete(opID) + for { current := loading.Load() if current <= 0 { return } if loading.CompareAndSwap(current, current-1) { - // Clear message when count reaches 0 - if current-1 == 0 { - loadingMessage.Store("") - } return } } } + +func buildLoadingMessage() string { + var messages []string + + loadingOps.Range(func(key, value interface{}) bool { + if msg, ok := value.(string); ok { + abbrev := msg + + if len(abbrev) > 25 { + abbrev = abbrev[:22] + "..." + } + + messages = append(messages, abbrev) + } + return true + }) + + if len(messages) == 0 { + return "..." + } + + const maxDisplay = 5 + count := len(messages) + + if count == 1 { + return messages[0] + } + + if count <= maxDisplay { + return fmt.Sprintf("(%d) %s", count, strings.Join(messages, " ")) + } + + // Show first messages + remaining count + shown := messages[:maxDisplay] + remaining := count - maxDisplay + return fmt.Sprintf("(%d) %s | +%d more", count, strings.Join(shown, " "), remaining) +} diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index 3f27f3d..4aa6d52 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -6,6 +6,7 @@ package ui import ( "strings" + "sync" "testing" "time" @@ -1114,47 +1115,62 @@ func TestUI_HelpView_ShowAll(t *testing.T) { func TestLoading_BasicOperations(t *testing.T) { // Reset loading.Store(0) - loadingMessage.Store("") + loadingOps = sync.Map{} // Test start increments counter and stores message - startLoading("Loading test...") + opID := startLoading("Loading test...") + if opID == "" { + t.Fatal("Expected non-empty operation ID") + } if loading.Load() != 1 { t.Errorf("Expected counter to be 1, got %d", loading.Load()) } - msg := loadingMessage.Load() - if msg == nil || msg.(string) != "Loading test..." { - t.Errorf("Expected 'Loading test...', got %v", msg) + // Verify message is stored + msg := buildLoadingMessage() + if !strings.Contains(msg, "test...") { + t.Errorf("Expected message to contain 'test...', got '%s'", msg) } - // Test stop decrements counter and clears message - stopLoading() + // Test stop decrements counter and removes operation + stopLoading(opID) if loading.Load() != 0 { t.Errorf("Expected counter to be 0, got %d", loading.Load()) } - msg = loadingMessage.Load() - if msg != nil && msg.(string) != "" { - t.Errorf("Expected message to be cleared, got '%s'", msg.(string)) + // Verify message is cleared + msg = buildLoadingMessage() + if msg != "..." { + t.Errorf("Expected fallback '...', got '%s'", msg) } } func TestLoading_OverflowUnderflowProtection(t *testing.T) { // Test overflow protection loading.Store(99) - startLoading("Test") + loadingOps = sync.Map{} + + op1 := startLoading("Test") if loading.Load() != 100 { t.Errorf("Expected counter to be 100, got %d", loading.Load()) } - startLoading("Test") + op2 := startLoading("Test") + if op2 != "" { + t.Error("Expected empty operation ID when max reached") + } if loading.Load() != 100 { t.Errorf("Expected counter to stay at 100, got %d", loading.Load()) } + // Cleanup + stopLoading(op1) + // Test underflow protection loading.Store(0) - stopLoading() + loadingOps = sync.Map{} + stopLoading("") + stopLoading("invalid_id") if loading.Load() != 0 { t.Errorf("Expected counter to stay at 0, got %d", loading.Load()) } @@ -1163,52 +1179,52 @@ func TestLoading_OverflowUnderflowProtection(t *testing.T) { func TestLoading_NestedOperations(t *testing.T) { // Reset loading.Store(0) - loadingMessage.Store("") + loadingOps = sync.Map{} // Start multiple operations - startLoading("Operation 1") - startLoading("Operation 2") - startLoading("Operation 3") + op1 := startLoading("Operation 1") + op2 := startLoading("Operation 2") + op3 := startLoading("Operation 3") if loading.Load() != 3 { t.Errorf("Expected counter to be 3, got %d", loading.Load()) } - // Message should be from last operation (last-write-wins) - msg := loadingMessage.Load() - if msg == nil || msg.(string) != "Operation 3" { - t.Errorf("Expected 'Operation 3', got %v", msg) + // Message should show all operations + msg := buildLoadingMessage() + if !strings.Contains(msg, "(3)") { + t.Errorf("Expected message to show count (3), got '%s'", msg) } - // Stop one operation - message should remain - stopLoading() + // Stop one operation - should show 2 operations + stopLoading(op2) if loading.Load() != 2 { t.Errorf("Expected counter to be 2, got %d", loading.Load()) } - msg = loadingMessage.Load() - if msg == nil || msg.(string) == "" { - t.Error("Expected message to remain when counter > 0") + msg = buildLoadingMessage() + if !strings.Contains(msg, "(2)") { + t.Errorf("Expected message to show count (2), got '%s'", msg) } // Stop remaining operations - stopLoading() - stopLoading() + stopLoading(op1) + stopLoading(op3) if loading.Load() != 0 { t.Errorf("Expected counter to be 0, got %d", loading.Load()) } - msg = loadingMessage.Load() - if msg != nil && msg.(string) != "" { - t.Error("Expected message to be cleared when counter reaches 0") + msg = buildLoadingMessage() + if msg != "..." { + t.Errorf("Expected fallback '...', got '%s'", msg) } } func TestLoading_ViewIntegration(t *testing.T) { // Reset loading.Store(0) - loadingMessage.Store("") + loadingOps = sync.Map{} m := newTestModelUI() m.Width = 100 @@ -1220,36 +1236,76 @@ func TestLoading_ViewIntegration(t *testing.T) { } // Start loading - startLoading("Loading transactions...") + opID := startLoading("Loading transactions...") view = m.View() - if !strings.Contains(view, "Loading transactions...") { + if !strings.Contains(view, "transactions...") { t.Error("Expected to see loading message in view") } // Stop loading - stopLoading() + stopLoading(opID) view = m.View() - if strings.Contains(view, "Loading transactions...") { + if strings.Contains(view, "transactions...") { t.Error("Expected loading indicator to be gone when counter is 0") } } func TestLoading_FallbackMessage(t *testing.T) { - // Test with empty message - loading.Store(1) - loadingMessage.Store("") - - m := newTestModelUI() - m.Width = 100 + // Test with no operations + loading.Store(0) + loadingOps = sync.Map{} - view := m.View() - if !strings.Contains(view, "...") { - t.Error("Expected fallback '...' message when message is empty but counter > 0") + msg := buildLoadingMessage() + if msg != "..." { + t.Errorf("Expected fallback '...', got '%s'", msg) } // Cleanup loading.Store(0) - loadingMessage.Store("") + loadingOps = sync.Map{} +} + +func TestLoading_MultipleOperationsDisplay(t *testing.T) { + // Reset + loading.Store(0) + loadingOps = sync.Map{} + + // Start multiple operations + op1 := startLoading("Loading transactions...") + op2 := startLoading("Loading categories...") + op3 := startLoading("Creating category...") + + // Verify all tracked + if loading.Load() != 3 { + t.Errorf("Expected 3 operations, got %d", loading.Load()) + } + + // Check display message contains count + msg := buildLoadingMessage() + if !strings.Contains(msg, "(3)") { + t.Errorf("Expected message to contain '(3)', got '%s'", msg) + } + + // Should show abbreviated messages + if !strings.Contains(msg, " ") { + t.Error("Expected separator ' ' between messages") + } + + // Stop one operation + stopLoading(op2) + if loading.Load() != 2 { + t.Errorf("Expected 2 operations, got %d", loading.Load()) + } + + // Check message updated + msg = buildLoadingMessage() + if !strings.Contains(msg, "(2)") { + t.Errorf("Expected message to contain '(2)', got '%s'", msg) + } + + // Cleanup + stopLoading(op1) + stopLoading(op3) } // ============================================================================= From 0bf0878921b31378426151701e7154f6bc72f5ac Mon Sep 17 00:00:00 2001 From: Artur Taranchiev Date: Sat, 31 Jan 2026 16:26:46 +0600 Subject: [PATCH 2/2] spinner update view, enabled filter for lists --- internal/ui/account_list.go | 30 ++++++++--- internal/ui/assets_test.go | 7 +-- internal/ui/categories.go | 31 +++++++++--- internal/ui/categories_test.go | 24 ++++++++- internal/ui/expenses_test.go | 30 ++++++++++- internal/ui/keymap.go | 30 ++++++----- internal/ui/liabilities_test.go | 31 ++++++++++-- internal/ui/revenues_test.go | 30 ++++++++++- internal/ui/transaction.go | 64 +----------------------- internal/ui/transaction_list.go | 33 ++++++++---- internal/ui/transaction_test.go | 89 +++------------------------------ internal/ui/ui.go | 1 + 12 files changed, 207 insertions(+), 193 deletions(-) diff --git a/internal/ui/account_list.go b/internal/ui/account_list.go index 0729a93..9a46a29 100644 --- a/internal/ui/account_list.go +++ b/internal/ui/account_list.go @@ -39,7 +39,9 @@ func NewAccountListModel[T ListEntity](api any, config *AccountListConfig[T]) Ac } m.list.Title = config.Title m.list.SetShowStatusBar(false) - m.list.SetFilteringEnabled(false) + m.list.SetFilteringEnabled(true) + m.list.FilterInput.Blur() + m.list.FilterInput.Width = 20 m.list.SetShowHelp(false) m.list.DisableQuitKeybindings() @@ -83,6 +85,7 @@ func (m AccountListModel[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.list.SetSize(msg.layout.Width-h, height) } + m.list.FilterInput.Width = 20 return m, nil } @@ -90,12 +93,29 @@ func (m AccountListModel[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - // Common keys switch msg := msg.(type) { case tea.KeyMsg: switch { + case key.Matches(msg, m.keymap.Filter): + if !m.list.FilterInput.Focused() { + m.list.FilterInput.Focus() + } case key.Matches(msg, m.keymap.Quit): - return m, SetView(transactionsView) + if !m.list.FilterInput.Focused() { + return m, SetView(transactionsView) + } + m.list.FilterInput.Blur() + } + } + if m.list.FilterInput.Focused() { + m.list, cmd = m.list.Update(msg) + return m, cmd + } + + // Common keys + switch msg := msg.(type) { + case tea.KeyMsg: + switch { case key.Matches(msg, m.keymap.ViewTransactions): return m, SetView(transactionsView) case key.Matches(msg, m.keymap.ViewAssets): @@ -119,7 +139,7 @@ func (m AccountListModel[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case key.Matches(msg, m.keymap.New): return m, m.config.PromptNewFunc() - case key.Matches(msg, m.keymap.Filter): + case key.Matches(msg, m.keymap.FilterBy): i, ok := m.list.SelectedItem().(accountListItem[T]) if ok { if m.config.HasTotalRow && i.Entity.GetName() == "Total" { @@ -149,12 +169,10 @@ func (m AccountListModel[T]) View() string { } func (m *AccountListModel[T]) Focus() { - m.list.FilterInput.Focus() m.focus = true } func (m *AccountListModel[T]) Blur() { - m.list.FilterInput.Blur() m.focus = false } diff --git a/internal/ui/assets_test.go b/internal/ui/assets_test.go index 69b063e..bc13f00 100644 --- a/internal/ui/assets_test.go +++ b/internal/ui/assets_test.go @@ -461,11 +461,11 @@ func TestModelAssets_View_UsesLeftPanelStyle(t *testing.T) { func TestModelAssets_KeyQuit_SetsTransactionsView(t *testing.T) { m := newFocusedAssetsModelWithAccount(t, firefly.Account{ID: "a1", Name: "Checking", CurrencyCode: "USD", Type: "asset"}) - _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) msgs := collectMsgsFromCmd(cmd) if len(msgs) != 1 { - t.Fatalf("expected 2 messages, got %d (%T)", len(msgs), msgs) + t.Fatalf("expected 1 message, got %d (%T)", len(msgs), msgs) } focused, ok := msgs[0].(SetFocusedViewMsg) @@ -475,9 +475,6 @@ func TestModelAssets_KeyQuit_SetsTransactionsView(t *testing.T) { if focused.state != transactionsView { t.Fatalf("expected transactionsView, got %v", focused.state) } - // if _, ok := msgs[1].(UpdatePositions); !ok { - // t.Fatalf("expected UpdatePositions, got %T", msgs[1]) - // } } func TestModelAssets_KeySelect_SequencesFilterAndView(t *testing.T) { diff --git a/internal/ui/categories.go b/internal/ui/categories.go index 2a931c3..c02c9c7 100644 --- a/internal/ui/categories.go +++ b/internal/ui/categories.go @@ -76,7 +76,8 @@ func newModelCategories(api CategoryAPI) modelCategories { } m.list.Title = "Categories" m.list.Styles.HelpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) - m.list.SetFilteringEnabled(false) + m.list.SetFilteringEnabled(true) + m.list.FilterInput.Blur() m.list.SetShowStatusBar(false) m.list.SetShowHelp(false) m.list.DisableQuitKeybindings() @@ -89,6 +90,8 @@ func (m modelCategories) Init() tea.Cmd { } func (m modelCategories) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { case RefreshCategoryInsightsMsg: return m, func() tea.Msg { @@ -134,22 +137,38 @@ func (m modelCategories) Update(msg tea.Msg) (tea.Model, tea.Cmd) { msg.layout.Height-v-msg.layout.TopSize, ) } + m.list.FilterInput.Width = 20 } if !m.focus { return m, nil } - var cmd tea.Cmd - switch msg := msg.(type) { case tea.KeyMsg: switch { + case key.Matches(msg, m.keymap.Filter): + m.list.FilterInput.Focus() case key.Matches(msg, m.keymap.Quit): - return m, SetView(transactionsView) + if m.list.FilterInput.Focused() { + m.list.FilterInput.Blur() + } else { + return m, SetView(transactionsView) + } + } + } + + if m.list.FilterInput.Focused() { + m.list, cmd = m.list.Update(msg) + return m, cmd + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { case key.Matches(msg, m.keymap.New): return m, CmdPromptNewCategory(SetView(categoriesView)) - case key.Matches(msg, m.keymap.Filter): + case key.Matches(msg, m.keymap.FilterBy): i, ok := m.list.SelectedItem().(categoryItem) if ok { if i.category == totalCategory { @@ -199,12 +218,10 @@ func (m modelCategories) View() string { } func (m *modelCategories) Focus() { - m.list.FilterInput.Focus() m.focus = true } func (m *modelCategories) Blur() { - m.list.FilterInput.Blur() m.focus = false } diff --git a/internal/ui/categories_test.go b/internal/ui/categories_test.go index c47a6cb..fb80f9a 100644 --- a/internal/ui/categories_test.go +++ b/internal/ui/categories_test.go @@ -768,7 +768,6 @@ func TestKeyPresses_NavigateToCorrectViews(t *testing.T) { {"transactions", 't', transactionsView, false, 1}, {"liabilities", 'o', liabilitiesView, false, 1}, {"revenues", 'i', revenuesView, false, 1}, - {"quit to transactions", 'q', transactionsView, false, 1}, } for _, tt := range tests { @@ -807,6 +806,29 @@ func TestKeyPresses_NavigateToCorrectViews(t *testing.T) { } }) } + + // Test ESC key separately + t.Run("quit to transactions", func(t *testing.T) { + m := newFocusedCategoriesModelWithCategory(t, cat) + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + + if cmd == nil { + t.Fatal("expected cmd for esc key") + } + + msgs := collectMsgsFromCmd(cmd) + if len(msgs) != 1 { + t.Fatalf("esc key: expected 1 message, got %d (%T)", len(msgs), msgs) + } + + focused, ok := msgs[0].(SetFocusedViewMsg) + if !ok { + t.Fatalf("esc key: expected SetFocusedViewMsg, got %T", msgs[0]) + } + if focused.state != transactionsView { + t.Fatalf("esc key: expected view %v, got %v", transactionsView, focused.state) + } + }) } // Prompt callback tests diff --git a/internal/ui/expenses_test.go b/internal/ui/expenses_test.go index c911c4c..fa67088 100644 --- a/internal/ui/expenses_test.go +++ b/internal/ui/expenses_test.go @@ -696,7 +696,6 @@ func TestModelExpenses_KeyViewNavigation(t *testing.T) { {"transactions", 't', transactionsView, false, 1}, {"liabilities", 'o', liabilitiesView, false, 1}, {"expenses (self)", 'e', expensesView, false, 1}, - {"quit to transactions", 'q', transactionsView, false, 1}, } for _, tt := range tests { @@ -741,6 +740,35 @@ func TestModelExpenses_KeyViewNavigation(t *testing.T) { } }) } + + // Test ESC key separately + t.Run("quit to transactions", func(t *testing.T) { + m := newFocusedExpensesModelWithAccount(t, firefly.Account{ + ID: "e1", + Name: "Groceries", + CurrencyCode: "USD", + Type: "expense", + }) + + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + + if cmd == nil { + t.Fatal("expected cmd for esc key") + } + + msgs := collectMsgsFromCmd(cmd) + if len(msgs) != 1 { + t.Fatalf("esc key: expected 1 message, got %d (%T)", len(msgs), msgs) + } + + focused, ok := msgs[0].(SetFocusedViewMsg) + if !ok { + t.Fatalf("esc key: expected SetFocusedViewMsg, got %T", msgs[0]) + } + if focused.state != transactionsView { + t.Fatalf("esc key: expected view %v, got %v", transactionsView, focused.state) + } + }) } // Prompt callback tests diff --git a/internal/ui/keymap.go b/internal/ui/keymap.go index dcce5bd..3dc17f9 100644 --- a/internal/ui/keymap.go +++ b/internal/ui/keymap.go @@ -27,6 +27,7 @@ type AccountKeyMap struct { ViewRevenues key.Binding ViewLiabilities key.Binding Filter key.Binding + FilterBy key.Binding ResetFilter key.Binding Sort key.Binding New key.Binding @@ -37,6 +38,7 @@ type CategoryKeyMap struct { ShowFullHelp key.Binding Quit key.Binding Filter key.Binding + FilterBy key.Binding ResetFilter key.Binding New key.Binding Refresh key.Binding @@ -54,7 +56,6 @@ type TransactionFormKeyMap struct { Reset key.Binding Cancel key.Binding Submit key.Binding - NewElement key.Binding Refresh key.Binding AddSplit key.Binding DeleteSplit key.Binding @@ -109,8 +110,8 @@ func DefaultAccountKeyMap() AccountKeyMap { key.WithHelp("?", "toggle help"), ), Quit: key.NewBinding( - key.WithKeys("q", "esc"), - key.WithHelp("q/esc", "go back"), + key.WithKeys("esc"), + key.WithHelp("esc", "go back"), ), Refresh: key.NewBinding( key.WithKeys("r"), @@ -153,6 +154,10 @@ func DefaultAccountKeyMap() AccountKeyMap { key.WithHelp("n", "create new account"), ), Filter: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "filter accounts"), + ), + FilterBy: key.NewBinding( key.WithKeys("f"), key.WithHelp("f", "filter by account (press twice for exclusive)"), ), @@ -170,10 +175,14 @@ func DefaultCategoryKeyMap() CategoryKeyMap { key.WithHelp("?", "toggle help"), ), Quit: key.NewBinding( - key.WithKeys("q", "esc"), - key.WithHelp("q/esc", "go back"), + key.WithKeys("esc"), + key.WithHelp("esc", "go back"), ), Filter: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "filter category"), + ), + FilterBy: key.NewBinding( key.WithKeys("f"), key.WithHelp("f", "filter by category (press twice for exclusive)"), ), @@ -239,10 +248,6 @@ func DefaultTransactionFormKeyMap() TransactionFormKeyMap { key.WithKeys("enter"), key.WithHelp("enter", "submit"), ), - NewElement: key.NewBinding( - key.WithKeys("n"), - key.WithHelp("n", "new element"), - ), AddSplit: key.NewBinding( key.WithKeys("ctrl+a"), key.WithHelp("ctrl+a", "add split"), @@ -273,8 +278,8 @@ func DefaultTransactionsKeyMap() TransactionsKeyMap { key.WithHelp("r", "refresh data"), ), Filter: key.NewBinding( - key.WithKeys("f"), - key.WithHelp("f", "filter transactions (press twice for exclusive)"), + key.WithKeys("/"), + key.WithHelp("/", "filter transactions (press twice for exclusive)"), ), ResetFilter: key.NewBinding( key.WithKeys("ctrl+a"), @@ -341,6 +346,7 @@ func (k AccountKeyMap) ShortHelp() []key.Binding { k.ShowFullHelp, k.Quit, k.Filter, + k.FilterBy, k.ResetFilter, k.Select, k.New, @@ -353,6 +359,7 @@ func (k CategoryKeyMap) ShortHelp() []key.Binding { k.ShowFullHelp, k.Quit, k.Filter, + k.FilterBy, k.ResetFilter, k.New, k.Refresh, @@ -378,7 +385,6 @@ func (k TransactionsKeyMap) ShortHelp() []key.Binding { func (k TransactionFormKeyMap) ShortHelp() []key.Binding { return []key.Binding{ - k.NewElement, k.AddSplit, k.DeleteSplit, k.Submit, diff --git a/internal/ui/liabilities_test.go b/internal/ui/liabilities_test.go index 0dd65ec..43db4ea 100644 --- a/internal/ui/liabilities_test.go +++ b/internal/ui/liabilities_test.go @@ -127,7 +127,6 @@ func TestGetLiabilitiesItems_UsesAccountBalanceAPI(t *testing.T) { } } - func TestNewModelLiabilities_InitializesCorrectly(t *testing.T) { api := &mockLiabilityAPI{ accountsByTypeFunc: func(accountType string) []firefly.Account { @@ -372,9 +371,9 @@ func TestUpdatePositions_SetsListSize(t *testing.T) { updated, _ := m.Update(UpdatePositions{ layout: &LayoutConfig{ - Width: globalWidth, - Height: globalHeight, - TopSize: topSize, + Width: globalWidth, + Height: globalHeight, + TopSize: topSize, SummarySize: 10, }, }) @@ -523,7 +522,6 @@ func TestLiabilities_KeyPresses_NavigateToCorrectViews(t *testing.T) { {"transactions", 't', transactionsView, false, 1}, {"liabilities (self)", 'o', liabilitiesView, false, 1}, {"revenues", 'i', revenuesView, false, 1}, - {"quit to transactions", 'q', transactionsView, false, 1}, } for _, tt := range tests { @@ -562,6 +560,29 @@ func TestLiabilities_KeyPresses_NavigateToCorrectViews(t *testing.T) { } }) } + + // Test ESC key separately + t.Run("quit to transactions", func(t *testing.T) { + m := newFocusedLiabilitiesModelWithAccount(t, acc) + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + + if cmd == nil { + t.Fatal("expected cmd for esc key") + } + + msgs := collectMsgsFromCmd(cmd) + if len(msgs) != 1 { + t.Fatalf("esc key: expected 1 message, got %d (%T)", len(msgs), msgs) + } + + focused, ok := msgs[0].(SetFocusedViewMsg) + if !ok { + t.Fatalf("esc key: expected SetFocusedViewMsg, got %T", msgs[0]) + } + if focused.state != transactionsView { + t.Fatalf("esc key: expected view %v, got %v", transactionsView, focused.state) + } + }) } // Prompt callback tests diff --git a/internal/ui/revenues_test.go b/internal/ui/revenues_test.go index 4df65e7..ed0b7d4 100644 --- a/internal/ui/revenues_test.go +++ b/internal/ui/revenues_test.go @@ -698,7 +698,6 @@ func TestModelRevenues_KeyViewNavigation(t *testing.T) { {"transactions", 't', transactionsView, false, 1}, {"liabilities", 'o', liabilitiesView, false, 1}, {"revenues (self)", 'i', revenuesView, false, 1}, - {"quit to transactions", 'q', transactionsView, false, 1}, } for _, tt := range tests { @@ -743,6 +742,35 @@ func TestModelRevenues_KeyViewNavigation(t *testing.T) { } }) } + + // Test ESC key separately + t.Run("quit to transactions", func(t *testing.T) { + m := newFocusedRevenuesModelWithAccount(t, firefly.Account{ + ID: "r1", + Name: "Salary", + CurrencyCode: "USD", + Type: "revenue", + }) + + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + + if cmd == nil { + t.Fatal("expected cmd for esc key") + } + + msgs := collectMsgsFromCmd(cmd) + if len(msgs) != 1 { + t.Fatalf("esc key: expected 1 message, got %d (%T)", len(msgs), msgs) + } + + focused, ok := msgs[0].(SetFocusedViewMsg) + if !ok { + t.Fatalf("esc key: expected SetFocusedViewMsg, got %T", msgs[0]) + } + if focused.state != transactionsView { + t.Fatalf("esc key: expected view %v, got %v", transactionsView, focused.state) + } + }) } // Prompt callback tests diff --git a/internal/ui/transaction.go b/internal/ui/transaction.go index a1295fa..0bfa6da 100644 --- a/internal/ui/transaction.go +++ b/internal/ui/transaction.go @@ -30,10 +30,6 @@ var ( ) type ( - RefreshNewCategoryMsg struct{} - RefreshNewAssetMsg struct{} - RefreshNewExpenseMsg struct{} - RefreshNewRevenueMsg struct{} RedrawFormMsg struct{} DeleteSplitMsg struct{ Index int } NewTransactionMsg struct{ Transaction firefly.Transaction } @@ -98,19 +94,6 @@ func (m modelTransaction) Init() tea.Cmd { func (m modelTransaction) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case RefreshNewAssetMsg: - triggerSourceCounter++ - triggerDestinationCounter++ - return m, RedrawForm() - case RefreshNewExpenseMsg: - triggerDestinationCounter++ - return m, RedrawForm() - case RefreshNewRevenueMsg: - triggerSourceCounter++ - return m, RedrawForm() - case RefreshNewCategoryMsg: - triggerCategoryCounter++ - return m, RedrawForm() case NewTransactionMsg: if !m.created { m.SetTransaction(msg.Transaction, true) @@ -138,7 +121,7 @@ func (m modelTransaction) Update(msg tea.Msg) (tea.Model, tea.Cmd) { trx := firefly.Transaction{} m.SetTransaction(trx, true) m.created = true - return m, nil + return m, RedrawForm() case RedrawFormMsg: m.UpdateForm() return m, tea.WindowSize() @@ -155,51 +138,6 @@ func (m modelTransaction) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch { - case key.Matches(msg, m.keymap.NewElement): - field := m.form.GetFocusedField() - switch field.(type) { - case *huh.Select[firefly.Category]: - f, ok := m.form.GetFocusedField().(*huh.Select[firefly.Category]) - if ok && !f.GetFiltering() { - return m, CmdPromptNewCategory( - tea.Sequence( - SetView(newView), - Cmd(RefreshNewCategoryMsg{}))) - } - - case *huh.Select[firefly.Account]: - f, ok := m.form.GetFocusedField().(*huh.Select[firefly.Account]) - if ok && !f.GetFiltering() { - - a, ok := f.GetValue().(firefly.Account) - if ok { - switch a.Type { - case "asset": - return m, CmdPromptNewAsset( - tea.Sequence( - SetView(newView), - Cmd(RefreshNewAssetMsg{}))) - case "expense": - return m, CmdPromptNewExpense( - tea.Sequence( - SetView(newView), - Cmd(RefreshNewExpenseMsg{}))) - case "revenue": - return m, CmdPromptNewRevenue( - tea.Sequence( - SetView(newView), - Cmd(RefreshNewRevenueMsg{}))) - case "liability": - return m, CmdPromptNewLiability( - tea.Sequence( - SetView(newView), - Cmd(RefreshNewAssetMsg{}))) - } - return m, nil - } - } - } - case key.Matches(msg, m.keymap.Cancel): return m, SetView(transactionsView) case key.Matches(msg, m.keymap.Reset): diff --git a/internal/ui/transaction_list.go b/internal/ui/transaction_list.go index f9c4d47..0e99362 100644 --- a/internal/ui/transaction_list.go +++ b/internal/ui/transaction_list.go @@ -7,7 +7,6 @@ package ui import ( "fmt" "net/url" - "strconv" "time" "ffiii-tui/internal/firefly" @@ -368,11 +367,12 @@ func (m modelTransactions) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if row == nil { return m, notify.NotifyWarn("Transaction not selected.") } - id, err := strconv.Atoi(row[0]) + + trx, err := m.findTransactionByID(row[11]) if err != nil { - return m, nil + return m, notify.NotifyError("Transaction not found.") } - trx := m.transactions[id] + return m, prompt.Ask( fmt.Sprintf("Are you sure you want to delete the transaction? Type 'yes!' to confirm. Transaction: %s - %s: ", trx.TransactionID, trx.Description()), "no", @@ -532,14 +532,27 @@ func (m *modelTransactions) GetCurrentTransaction() (firefly.Transaction, error) if row == nil { return firefly.Transaction{}, fmt.Errorf("transaction not selected") } - id, err := strconv.Atoi(row[0]) - if err != nil { - return firefly.Transaction{}, fmt.Errorf("wrong transaction id: %s", row[0]) + + txID := row[11] + if txID == "" { + return firefly.Transaction{}, fmt.Errorf("invalid transaction ID") + } + + for _, tx := range m.transactions { + if tx.TransactionID == txID { + return tx, nil + } } - if id >= len(m.transactions) { - return firefly.Transaction{}, fmt.Errorf("index out of range: %d", id) + return firefly.Transaction{}, fmt.Errorf("transaction not found") +} + +func (m *modelTransactions) findTransactionByID(txID string) (firefly.Transaction, error) { + for _, tx := range m.transactions { + if tx.TransactionID == txID { + return tx, nil + } } - return m.transactions[id], nil + return firefly.Transaction{}, fmt.Errorf("transaction not found") } diff --git a/internal/ui/transaction_test.go b/internal/ui/transaction_test.go index 9104289..db10a62 100644 --- a/internal/ui/transaction_test.go +++ b/internal/ui/transaction_test.go @@ -319,85 +319,6 @@ func TestTransaction_RedrawForm(t *testing.T) { // Part 2: Message handler tests -func TestTransaction_RefreshMessages(t *testing.T) { - tests := []struct { - name string - msg tea.Msg - counterToCheck *byte - expectedCounterIncrease int - }{ - {"RefreshNewCategoryMsg", RefreshNewCategoryMsg{}, &triggerCategoryCounter, 1}, - {"RefreshNewExpenseMsg", RefreshNewExpenseMsg{}, &triggerDestinationCounter, 1}, - {"RefreshNewRevenueMsg", RefreshNewRevenueMsg{}, &triggerSourceCounter, 1}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Save and restore global counters - origCategory := triggerCategoryCounter - origSource := triggerSourceCounter - origDest := triggerDestinationCounter - defer func() { - triggerCategoryCounter = origCategory - triggerSourceCounter = origSource - triggerDestinationCounter = origDest - }() - - m := newTestTransactionModel() - initialValue := *tt.counterToCheck - - _, cmd := m.Update(tt.msg) - - // Verify counter was incremented - if *tt.counterToCheck != initialValue+byte(tt.expectedCounterIncrease) { - t.Errorf("expected counter to be %d, got %d", initialValue+byte(tt.expectedCounterIncrease), *tt.counterToCheck) - } - - // Verify RedrawForm was returned - if cmd == nil { - t.Fatal("expected cmd to be returned") - } - msg := cmd() - if _, ok := msg.(RedrawFormMsg); !ok { - t.Errorf("expected RedrawFormMsg, got %T", msg) - } - }) - } - - // Special test for RefreshNewAssetMsg incrementing both source and destination counters - t.Run("RefreshNewAssetMsg increments both counters", func(t *testing.T) { - // Save and restore global counters - origSource := triggerSourceCounter - origDest := triggerDestinationCounter - defer func() { - triggerSourceCounter = origSource - triggerDestinationCounter = origDest - }() - - m := newTestTransactionModel() - initialSource := triggerSourceCounter - initialDest := triggerDestinationCounter - - _, cmd := m.Update(RefreshNewAssetMsg{}) - - if triggerSourceCounter != initialSource+1 { - t.Errorf("expected source counter to be %d, got %d", initialSource+1, triggerSourceCounter) - } - if triggerDestinationCounter != initialDest+1 { - t.Errorf("expected destination counter to be %d, got %d", initialDest+1, triggerDestinationCounter) - } - - // Verify RedrawForm was returned - if cmd == nil { - t.Fatal("expected cmd to be returned") - } - msg := cmd() - if _, ok := msg.(RedrawFormMsg); !ok { - t.Errorf("expected RedrawFormMsg, got %T", msg) - } - }) -} - func TestTransaction_NewTransactionMsg(t *testing.T) { trx := firefly.Transaction{ TransactionID: "trx123", @@ -574,9 +495,13 @@ func TestTransaction_ResetTransactionMsg(t *testing.T) { t.Error("expected created to be true after ResetTransactionMsg") } - // Returns nil cmd - if cmd != nil { - t.Errorf("expected nil cmd, got %T", cmd) + // Returns RedrawForm cmd + if cmd == nil { + t.Fatal("expected cmd to be returned") + } + msg := cmd() + if _, ok := msg.(RedrawFormMsg); !ok { + t.Errorf("expected RedrawFormMsg, got %T", msg) } } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 72edd4b..c35cbaf 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -278,6 +278,7 @@ func (m modelUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case ViewFullTransactionViewMsg: viper.Set("ui.full_view", m.layout.ToggleFullTransactionView()) + return m, Cmd(UpdatePositions{layout: m.layout}) case DataLoadCompletedMsg: m.loadStatus[msg.DataType] = true case LazyLoadMsg: