diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-07 13:21:44 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-07 14:16:07 +0300 |
| commit | 9e03dfa769ee54ab904f0151b8de0807e7363d3c (patch) | |
| tree | 0230073b42f017d3c819cb60c266dbaef17fa264 /internal | |
| parent | ebafe9d2348b8abc00f3ded3cc66b48fe7e8618b (diff) | |
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 <noreply@anthropic.com>
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/ui/handlers.go | 23 | ||||
| -rw-r--r-- | internal/ui/helpers.go | 23 | ||||
| -rw-r--r-- | internal/ui/helpers_test.go | 80 |
3 files changed, 123 insertions, 3 deletions
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 |
