summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Bütow <1224732+snonux@users.noreply.github.com>2025-06-22 16:50:46 +0300
committerPaul Bütow <1224732+snonux@users.noreply.github.com>2025-06-22 16:50:46 +0300
commitb140c36cd8bb3d20c7211fe672b9d5674c9f0d3b (patch)
tree1f33e866c2a4fdb4df2c41a8acd15c2be81c14b5
parentdc1a6e83669dd22be4542833970d3251741ba1f3 (diff)
Add generic task command hotkey
-rw-r--r--README.md2
-rw-r--r--internal/task/task.go18
-rw-r--r--internal/ui/table.go61
-rw-r--r--internal/ui/table_test.go51
4 files changed, 131 insertions, 1 deletions
diff --git a/README.md b/README.md
index bc8c7d6..8899bb9 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,7 @@ Task Samurai invokes the `task` command to read and modify tasks. The tasks are
- `d`: mark task done
- `U`: undo last done
- `D`: set due date
+- `T`: run task command
- `+`: add task
- `r`: random due date
- `R`: edit recurrence
@@ -57,4 +58,5 @@ Task Samurai invokes the `task` command to read and modify tasks. The tasks are
- `q` or `esc`: close search/help or quit (press `q` when nothing is open)
Example: press `+`, type `Buy milk` and hit Enter to add a new task called "Buy milk".
+Example: press `T`, type `+bg modify +huhu` to tag all `+bg` tasks with `+huhu`.
diff --git a/internal/task/task.go b/internal/task/task.go
index b20eef0..5f19482 100644
--- a/internal/task/task.go
+++ b/internal/task/task.go
@@ -86,6 +86,24 @@ func AddLine(line string) error {
return AddArgs(fields)
}
+// RunArgs executes "task" with the given arguments. Each item in args is
+// passed as a separate command-line argument.
+func RunArgs(args []string) error {
+ cmd := exec.Command("task", args...)
+ return cmd.Run()
+}
+
+// RunLine splits the provided line into shell words and executes "task" with
+// the resulting arguments. This allows callers to run arbitrary Taskwarrior
+// commands directly.
+func RunLine(line string) error {
+ fields, err := shlex.Split(line)
+ if err != nil {
+ return err
+ }
+ return RunArgs(fields)
+}
+
// Export retrieves all tasks using `task export rc.json.array=off` and parses
// the JSON output into a slice of Task structs.
// Export retrieves tasks using `task <filter> export rc.json.array=off` and parses
diff --git a/internal/ui/table.go b/internal/ui/table.go
index c3e1748..480936c 100644
--- a/internal/ui/table.go
+++ b/internal/ui/table.go
@@ -64,6 +64,9 @@ type Model struct {
addingTask bool
addInput textinput.Model
+ runningTask bool
+ taskInput textinput.Model
+
searching bool
searchInput textinput.Model
searchRegex *regexp.Regexp
@@ -167,6 +170,9 @@ func New(filters []string) (Model, error) {
m.addInput = textinput.New()
m.addInput.Prompt = "add: "
+ m.taskInput = textinput.New()
+ m.taskInput.Prompt = "task: "
+
m.defaultTheme = DefaultTheme()
m.theme = m.defaultTheme
@@ -517,6 +523,46 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.addInput, cmd = m.addInput.Update(msg)
return m, cmd
}
+ if m.runningTask {
+ switch msg.Type {
+ case tea.KeyEnter:
+ oldIDs := make(map[int]struct{})
+ for _, tsk := range m.tasks {
+ oldIDs[tsk.ID] = struct{}{}
+ }
+ task.RunLine(m.taskInput.Value())
+ m.runningTask = false
+ m.taskInput.Blur()
+ m.reload()
+ var newID int
+ row := -1
+ for i, tsk := range m.tasks {
+ if _, ok := oldIDs[tsk.ID]; !ok {
+ newID = tsk.ID
+ row = i
+ break
+ }
+ }
+ m.updateTableHeight()
+ if row >= 0 {
+ prevRow := m.tbl.Cursor()
+ prevCol := m.tbl.ColumnCursor()
+ m.tbl.SetCursor(row)
+ m.tbl.SetColumnCursor(7)
+ m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor())
+ return m, m.startBlink(newID, false)
+ }
+ return m, nil
+ case tea.KeyEsc:
+ m.runningTask = false
+ m.taskInput.Blur()
+ m.updateTableHeight()
+ return m, nil
+ }
+ var cmd tea.Cmd
+ m.taskInput, cmd = m.taskInput.Update(msg)
+ return m, cmd
+ }
if m.searching {
switch msg.Type {
case tea.KeyEnter:
@@ -710,6 +756,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.addInput.Focus()
m.updateTableHeight()
return m, nil
+ case "T":
+ m.runningTask = true
+ m.taskInput.SetValue("")
+ m.taskInput.Focus()
+ m.updateTableHeight()
+ return m, nil
case "t":
if row := m.tbl.SelectedRow(); row != nil {
idStr := ansi.Strip(row[1])
@@ -853,6 +905,7 @@ func (m Model) View() string {
m.tbl.HelpView(),
"enter/i: edit or expand cell",
"E: edit task",
+ "T: run task command",
"+: add task",
"s: toggle start/stop",
"d: mark task done",
@@ -934,6 +987,12 @@ func (m Model) View() string {
m.addInput.View(),
)
}
+ if m.runningTask {
+ view = lipgloss.JoinVertical(lipgloss.Left,
+ view,
+ m.taskInput.View(),
+ )
+ }
if m.searching {
view = lipgloss.JoinVertical(lipgloss.Left,
view,
@@ -1247,7 +1306,7 @@ func (m *Model) updateTableHeight() {
if m.cellExpanded {
h--
}
- if m.annotating || m.dueEditing || m.prioritySelecting || m.searching || m.descEditing || m.tagsEditing || m.recurEditing || m.filterEditing || m.addingTask {
+ if m.annotating || m.dueEditing || m.prioritySelecting || m.searching || m.descEditing || m.tagsEditing || m.recurEditing || m.filterEditing || m.addingTask || m.runningTask {
h--
}
if h < 1 {
diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go
index 365180c..d94243b 100644
--- a/internal/ui/table_test.go
+++ b/internal/ui/table_test.go
@@ -493,6 +493,57 @@ func TestAddHotkey(t *testing.T) {
}
}
+func TestTaskHotkey(t *testing.T) {
+ tmp := t.TempDir()
+ taskPath := filepath.Join(tmp, "task")
+ cmdFile := filepath.Join(tmp, "cmd.txt")
+
+ script := "#!/bin/sh\n" +
+ "if echo \"$@\" | grep -q export; then\n" +
+ " echo '{\"id\":1,\"uuid\":\"x\",\"description\":\"d\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0}'\n" +
+ " exit 0\n" +
+ "fi\n" +
+ "echo \"$@\" > " + cmdFile + "\n"
+
+ if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ origPath := os.Getenv("PATH")
+ os.Setenv("PATH", tmp+":"+origPath)
+ t.Cleanup(func() { os.Setenv("PATH", origPath) })
+
+ os.Setenv("TASKDATA", tmp)
+ os.Setenv("TASKRC", "/dev/null")
+ t.Cleanup(func() {
+ os.Unsetenv("TASKDATA")
+ os.Unsetenv("TASKRC")
+ })
+
+ m, err := New(nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ mv, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'T'}})
+ m = mv.(Model)
+ for _, r := range "+bg modify +huhu" {
+ mv, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
+ m = mv.(Model)
+ }
+ mv, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ m = mv.(Model)
+
+ data, err := os.ReadFile(cmdFile)
+ if err != nil {
+ t.Fatalf("read cmd: %v", err)
+ }
+
+ if strings.TrimSpace(string(data)) != "+bg modify +huhu" {
+ t.Fatalf("task not called: %q", data)
+ }
+}
+
func TestNavigationHotkeys(t *testing.T) {
tmp := t.TempDir()
taskPath := filepath.Join(tmp, "task")