From 9e03dfa769ee54ab904f0151b8de0807e7363d3c Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Tue, 7 Apr 2026 13:21:44 +0300 Subject: Fix filter mode to handle all taskwarrior filter expressions correctly Replace strings.Fields with shlex.Split in the filter input handler so that quoted attribute filters like description:"my task" are passed as a single argument rather than being split on whitespace. This makes proj:xxx, +tag, and any other valid taskwarrior filter expression work correctly when entered via the interactive f key in both traditional and ultra mode. Also propagate taskwarrior errors from reload() back to the user via the status bar instead of silently discarding them, so invalid filters produce visible feedback. On error the filter is rolled back to nil to avoid leaving the UI in a broken empty-list state. Extracts the filter-string parsing into a testable parseFilterInput helper in helpers.go and adds comprehensive tests in helpers_test.go. Co-Authored-By: Claude Sonnet 4.6 --- internal/ui/handlers.go | 23 +++++++++++-- internal/ui/helpers.go | 23 +++++++++++++ internal/ui/helpers_test.go | 80 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 3 deletions(-) (limited to 'internal') diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index 2d3208a..a210ffc 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -292,11 +292,28 @@ func (m *Model) handlePriorityMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, nil } -// handleFilterMode handles filter editing +// handleFilterMode handles filter editing for both traditional and ultra mode. +// The filter value is split using shell-quoting rules (via parseFilterInput) +// so that expressions with quoted values (e.g. description:"my task") are +// passed to taskwarrior as a single argument. Any taskwarrior filter expression +// that is valid on the command line (proj:xxx, +tag, description:"...", etc.) +// is therefore accepted here too. Taskwarrior errors are propagated back to +// the user via the status bar rather than being silently discarded. func (m *Model) handleFilterMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { onEnter := func(value string) error { - m.filters = strings.Fields(value) - _ = m.reload() + fields, err := parseFilterInput(value) + if err != nil { + return err + } + m.filters = fields + // Propagate taskwarrior errors so the user sees feedback when a + // filter expression is rejected by taskwarrior. + if err := m.reload(); err != nil { + // Roll back the filters to avoid leaving the UI in a broken state + // where an empty task list is shown without any explanation. + m.filters = nil + return fmt.Errorf("filter error: %w", err) + } return nil } diff --git a/internal/ui/helpers.go b/internal/ui/helpers.go index 4cdf3cc..6cefa96 100644 --- a/internal/ui/helpers.go +++ b/internal/ui/helpers.go @@ -6,6 +6,8 @@ import ( "strings" "time" + "github.com/google/shlex" + "codeberg.org/snonux/tasksamurai/internal/task" ) @@ -72,6 +74,27 @@ func compileAndCacheRegex(pattern string) (*regexp.Regexp, error) { return re, nil } +// parseFilterInput splits a raw filter string typed by the user into the +// individual filter tokens that are passed to taskwarrior. Shell-quoting +// rules are applied via shlex so that expressions like +// +// description:"my task" +// proj:dtail +urgent +// +// are handled correctly: quoted values are kept as a single argument (with the +// quotes stripped) rather than being split on whitespace. An empty input +// returns a nil slice, which clears the current filter. +func parseFilterInput(input string) ([]string, error) { + fields, err := shlex.Split(input) + if err != nil { + return nil, fmt.Errorf("invalid filter expression: %w", err) + } + if len(fields) == 0 { + return nil, nil + } + return fields, nil +} + // Validation functions // validateTagName validates a tag name diff --git a/internal/ui/helpers_test.go b/internal/ui/helpers_test.go index 9ba620c..f0c0e27 100644 --- a/internal/ui/helpers_test.go +++ b/internal/ui/helpers_test.go @@ -1,6 +1,7 @@ package ui import ( + "reflect" "testing" "time" ) @@ -360,4 +361,83 @@ func TestValidateRecurrence(t *testing.T) { } }) } +} + +// TestParseFilterInput verifies that parseFilterInput correctly handles +// taskwarrior filter expressions, including attribute filters (proj:xxx), +// tag filters (+tag), quoted values (description:"some text"), and empty input. +func TestParseFilterInput(t *testing.T) { + tests := []struct { + name string + input string + want []string + wantErr bool + }{ + { + name: "empty input clears filter", + input: "", + want: nil, + wantErr: false, + }, + { + name: "project attribute filter", + input: "proj:dtail", + want: []string{"proj:dtail"}, + wantErr: false, + }, + { + name: "tag filter", + input: "+urgent", + want: []string{"+urgent"}, + wantErr: false, + }, + { + name: "multiple filters", + input: "proj:dtail +urgent", + want: []string{"proj:dtail", "+urgent"}, + wantErr: false, + }, + { + name: "quoted description filter keeps value as single token", + input: `description:"my task"`, + // shlex strips the quotes and keeps the value as one argument + want: []string{"description:my task"}, + wantErr: false, + }, + { + name: "project filter with multiple words via quoting", + input: `project:"my project"`, + want: []string{"project:my project"}, + }, + { + name: "status filter", + input: "status:pending", + want: []string{"status:pending"}, + wantErr: false, + }, + { + name: "whitespace-only input clears filter", + input: " ", + want: nil, + wantErr: false, + }, + { + name: "unclosed quote returns error", + input: `proj:"unclosed`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseFilterInput(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("parseFilterInput(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + return + } + if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseFilterInput(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } } \ No newline at end of file -- cgit v1.2.3