diff options
| -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 |
