diff options
| author | Paul Buetow <paul@buetow.org> | 2025-06-27 23:07:14 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-06-27 23:07:14 +0300 |
| commit | 3654835ece8616c4aa65dc68a4ac7918c0d38d3c (patch) | |
| tree | 3e17deab4f7732dffe1556b97ff12b2bb4b462e5 | |
| parent | cfc9e9a45cbf517a833c1fbffda6ed5068d08454 (diff) | |
Fix multiple bugs and improve error handling
- Fix file handle leak in SetDebugLog by properly closing previous debug files
- Capture and display stderr from all taskwarrior commands for better error messages
- Handle browser launch errors with status bar notifications
- Add validation for task IDs to prevent negative/zero IDs
- Add mutual exclusion for editing modes to prevent UI state conflicts
- Add bounds checking for array access in expandedCellView
- Cache compiled regular expressions for search performance
- Add CLAUDE.md file with project documentation for AI assistance
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
| -rw-r--r-- | CLAUDE.md | 100 | ||||
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | internal/task/task.go | 89 | ||||
| -rw-r--r-- | internal/ui/table.go | 84 |
4 files changed, 259 insertions, 16 deletions
diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3514455 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,100 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +TaskSamurai is a fast Terminal User Interface (TUI) for Taskwarrior written in Go. It provides a keyboard-driven table interface for task management with extensive hotkey support and real-time integration with the Taskwarrior CLI. + +## Development Commands + +```bash +# Build the application +go-task + +# Run the application +go-task run + +# Run all tests +go-task test + +# Install to $GOPATH/bin +go-task install + +# Run with debug logging +./tasksamurai --debug-log debug.log + +# Run with custom browser command +./tasksamurai --browser-cmd chromium + +# Run with disco mode (random theme changes) +./tasksamurai --disco +``` + +## Architecture + +### Core Components + +1. **cmd/tasksamurai/main.go**: Entry point that initializes flags, creates the UI model, and starts the Bubble Tea program. + +2. **internal/task/**: Taskwarrior integration layer + - Executes `task` commands for all CRUD operations + - Parses JSON export format from Taskwarrior + - Handles task filtering, sorting, and statistics calculation + - All operations require the `task` command to be available in PATH + +3. **internal/ui/**: Terminal UI implementation using Bubble Tea framework + - Main model in `model.go` handles application state and message processing + - `table.go` manages the task table display + - `input.go` processes keyboard input and executes commands + - `theme.go` handles color themes and disco mode + - Search functionality with regex support in search methods + +4. **internal/atable/**: Custom table widget implementation + - Provides flexible table rendering for the task display + - Handles column widths, scrolling, and cell formatting + +### Key Design Patterns + +- **Message-Driven Architecture**: Uses Bubble Tea's message passing for all UI updates +- **Command Pattern**: All Taskwarrior operations go through the `internal/task` package +- **MVC-like Structure**: Clear separation between data (task), view (table), and control (input handling) + +### Integration Points + +- **Taskwarrior CLI**: All task operations execute `task` commands via `exec.Command` +- **Terminal**: Uses ANSI escape sequences and terminal control through Bubble Tea +- **External Browser**: Opens URLs via configurable browser command + +## Testing Approach + +Tests are located alongside implementation files (*_test.go pattern). Key test areas: + +- Task operations and JSON parsing in `internal/task/` +- UI component behavior in `internal/ui/` +- Tests create temporary directories for isolated Taskwarrior environments +- Tests check for `task` command availability and skip if not present + +Run a single test file: +```bash +go test ./internal/task/task_test.go +``` + +Run tests for a specific package: +```bash +go test ./internal/ui/ +``` + +## Important Implementation Notes + +1. **Taskwarrior Dependency**: The application requires Taskwarrior to be installed and available in PATH. All operations fail gracefully if `task` is not available. + +2. **State Management**: The UI state is managed through the main model in `internal/ui/model.go`. State updates happen through Bubble Tea messages. + +3. **Error Handling**: Errors from Taskwarrior commands are displayed in the status bar at the bottom of the UI. + +4. **Performance**: The table widget uses efficient rendering to handle large task lists. Only visible rows are rendered. + +5. **Configuration**: User preferences like browser command and debug logging are passed via command-line flags, not configuration files. + +6. **Hotkeys**: Extensive hotkey system defined in `internal/ui/input.go`. See README.md for complete reference.
\ No newline at end of file @@ -6,7 +6,7 @@ Task Samurai is a fast terminal interface for [Taskwarrior](https://taskwarrior. ## Why does this exist? -- I wanted to tinker with agentic coding. This project was entirely implemented using OpenAI Codex. +- I wanted to tinker with agentic coding. - I wanted a faster UI for Taskwarrior than other options like vit which is Python based. - I wanted something built with Bubble Tea but never had time to deep dive into it. diff --git a/internal/task/task.go b/internal/task/task.go index b20eef0..be3b6ce 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -38,18 +38,27 @@ type Task struct { } var debugWriter io.Writer +var debugFile *os.File // Track the file handle to close it properly // SetDebugLog enables logging of executed commands to the given file. // Passing an empty path disables logging. func SetDebugLog(path string) error { - if path == "" { + // Close existing debug file if open + if debugFile != nil { + debugFile.Close() + debugFile = nil debugWriter = nil + } + + if path == "" { return nil } + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return err } + debugFile = f debugWriter = f return nil } @@ -71,8 +80,7 @@ func Add(description string, tags []string) error { // is passed as a separate command-line argument, allowing the caller to // specify additional modifiers like due dates or tags. func AddArgs(args []string) error { - cmd := exec.Command("task", append([]string{"add"}, args...)...) - return cmd.Run() + return run(append([]string{"add"}, args...)...) } // AddLine splits the given line into shell words and runs "task add" with the @@ -94,8 +102,16 @@ func AddLine(line string) error { func Export(filters ...string) ([]Task, error) { args := append(filters, "export", "rc.json.array=off") cmd := exec.Command("task", args...) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() if err != nil { + // Include stderr output in the error message + if stderr.Len() > 0 { + return nil, fmt.Errorf("%v: %s", err, strings.TrimSpace(stderr.String())) + } return nil, err } @@ -124,11 +140,26 @@ func run(args ...string) error { fmt.Fprintln(debugWriter, "task "+strings.Join(args, " ")) } cmd := exec.Command("task", args...) - return cmd.Run() + + // Capture stderr to provide better error messages + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + // Include stderr output in the error message + if stderr.Len() > 0 { + return fmt.Errorf("%v: %s", err, strings.TrimSpace(stderr.String())) + } + return err + } + return nil } // SetStatus changes the status of the task with the given id. func SetStatus(id int, status string) error { + if id <= 0 { + return fmt.Errorf("invalid task ID: %d", id) + } return run(strconv.Itoa(id), "modify", "status:"+status) } @@ -139,31 +170,49 @@ func SetStatusUUID(uuid, status string) error { // Start begins the task with the given id. func Start(id int) error { + if id <= 0 { + return fmt.Errorf("invalid task ID: %d", id) + } return run(strconv.Itoa(id), "start") } // Stop stops the task with the given id. func Stop(id int) error { + if id <= 0 { + return fmt.Errorf("invalid task ID: %d", id) + } return run(strconv.Itoa(id), "stop") } // Done marks the task with the given id as completed. func Done(id int) error { + if id <= 0 { + return fmt.Errorf("invalid task ID: %d", id) + } return run(strconv.Itoa(id), "done") } // Delete removes the task with the given id. func Delete(id int) error { + if id <= 0 { + return fmt.Errorf("invalid task ID: %d", id) + } return run(strconv.Itoa(id), "delete") } // SetPriority changes the priority of the task with the given id. func SetPriority(id int, priority string) error { + if id <= 0 { + return fmt.Errorf("invalid task ID: %d", id) + } return run(strconv.Itoa(id), "modify", "priority:"+priority) } // AddTags adds tags to the task with the given id. func AddTags(id int, tags []string) error { + if id <= 0 { + return fmt.Errorf("invalid task ID: %d", id) + } args := []string{strconv.Itoa(id), "modify"} for _, t := range tags { if len(t) > 0 && t[0] != '+' { @@ -176,6 +225,9 @@ func AddTags(id int, tags []string) error { // RemoveTags removes tags from the task with the given id. func RemoveTags(id int, tags []string) error { + if id <= 0 { + return fmt.Errorf("invalid task ID: %d", id) + } args := []string{strconv.Itoa(id), "modify"} for _, t := range tags { if len(t) > 0 && t[0] != '-' { @@ -189,6 +241,9 @@ func RemoveTags(id int, tags []string) error { // SetTags sets the tags of the task with the given id to exactly the provided set. // Tags not present will be removed and new tags added as needed. func SetTags(id int, tags []string) error { + if id <= 0 { + return fmt.Errorf("invalid task ID: %d", id) + } tasks, err := Export(strconv.Itoa(id)) if err != nil { return err @@ -232,21 +287,33 @@ func SetTags(id int, tags []string) error { // SetRecurrence sets the recurrence for the task with the given id. func SetRecurrence(id int, rec string) error { + if id <= 0 { + return fmt.Errorf("invalid task ID: %d", id) + } return run(strconv.Itoa(id), "modify", "recur:"+rec) } // SetDueDate sets the due date for the task with the given id. func SetDueDate(id int, due string) error { + if id <= 0 { + return fmt.Errorf("invalid task ID: %d", id) + } return run(strconv.Itoa(id), "modify", "due:"+due) } // SetDescription changes the description of the task with the given id. func SetDescription(id int, desc string) error { + if id <= 0 { + return fmt.Errorf("invalid task ID: %d", id) + } return run(strconv.Itoa(id), "modify", "description:"+desc) } // Annotate adds an annotation to the task with the given id. func Annotate(id int, text string) error { + if id <= 0 { + return fmt.Errorf("invalid task ID: %d", id) + } return run(strconv.Itoa(id), "annotate", text) } @@ -255,6 +322,9 @@ func Annotate(id int, text string) error { // annotation text is matched exactly when provided. If text is empty, the // oldest annotation is removed. func Denotate(id int, text string) error { + if id <= 0 { + return fmt.Errorf("invalid task ID: %d", id) + } args := []string{strconv.Itoa(id), "denotate"} if text != "" { args = append(args, text) @@ -266,6 +336,9 @@ func Denotate(id int, text string) error { // given id and sets a single annotation with the provided text. If text is // empty, all annotations are simply removed. func ReplaceAnnotations(id int, text string) error { + if id <= 0 { + return fmt.Errorf("invalid task ID: %d", id) + } tasks, err := Export(strconv.Itoa(id)) if err != nil { return err @@ -290,6 +363,11 @@ func ReplaceAnnotations(id int, text string) error { // The caller is responsible for running the command, typically via // tea.ExecProcess so that the terminal state is properly managed. func EditCmd(id int) *exec.Cmd { + if id <= 0 { + // Return a command that will fail with an appropriate error + cmd := exec.Command("sh", "-c", fmt.Sprintf("echo 'invalid task ID: %d' >&2; exit 1", id)) + return cmd + } cmd := exec.Command("task", strconv.Itoa(id), "edit") cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout @@ -300,6 +378,9 @@ func EditCmd(id int) *exec.Cmd { // Edit opens the task in an editor for manual modification. // This is a convenience wrapper around EditCmd. func Edit(id int) error { + if id <= 0 { + return fmt.Errorf("invalid task ID: %d", id) + } return EditCmd(id).Run() } diff --git a/internal/ui/table.go b/internal/ui/table.go index d75f350..de1776a 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -22,7 +22,10 @@ import ( var priorityOptions = []string{"H", "M", "L", ""} -var urlRegex = regexp.MustCompile(`https?://\S+`) +var ( + urlRegex = regexp.MustCompile(`https?://\S+`) + searchRegexCache = make(map[string]*regexp.Regexp) +) func init() { rand.Seed(time.Now().UnixNano()) @@ -113,6 +116,8 @@ type Model struct { theme Theme defaultTheme Theme disco bool + + statusMsg string } // editDoneMsg is emitted when the external editor process finishes. @@ -136,6 +141,19 @@ func blinkCmd() tea.Cmd { return tea.Tick(blinkInterval, func(time.Time) tea.Msg { return blinkMsg{} }) } +// clearEditingModes ensures only one editing mode is active at a time +func (m *Model) clearEditingModes() { + m.annotating = false + m.descEditing = false + m.tagsEditing = false + m.dueEditing = false + m.recurEditing = false + m.filterEditing = false + m.addingTask = false + m.searching = false + m.prioritySelecting = false +} + func (m *Model) startBlink(id int, markDone bool) tea.Cmd { m.blinkID = id m.blinkMarkDone = markDone @@ -314,6 +332,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, blinkCmd() } return m, nil + case struct{ clearStatus bool }: + m.statusMsg = "" + return m, nil case tea.KeyMsg: // Only allow navigation while a task row is blinking. This // prevents accidental modifications to other tasks but still @@ -545,9 +566,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.searching { switch msg.Type { case tea.KeyEnter: - re, err := regexp.Compile(m.searchInput.Value()) - if err == nil { - m.searchRegex = re + pattern := m.searchInput.Value() + if pattern != "" { + // Check cache first + if cached, ok := searchRegexCache[pattern]; ok { + m.searchRegex = cached + } else { + // Compile and cache if not found + re, err := regexp.Compile(pattern) + if err == nil { + m.searchRegex = re + // Limit cache size to prevent memory leak + if len(searchRegexCache) > 100 { + // Clear cache when it gets too large + searchRegexCache = make(map[string]*regexp.Regexp) + } + searchRegexCache[pattern] = re + } else { + m.searchRegex = nil + } + } } else { m.searchRegex = nil } @@ -638,13 +676,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "o": if row := m.tbl.SelectedRow(); row != nil { desc := m.tasks[m.tbl.Cursor()].Description - re := regexp.MustCompile(`https?://\S+`) - url := re.FindString(desc) + url := urlRegex.FindString(desc) if url != "" { - _ = exec.Command(m.browserCmd, url).Run() - idStr := ansi.Strip(row[1]) - if id, err := strconv.Atoi(idStr); err == nil { - return m, m.startBlink(id, false) + if err := exec.Command(m.browserCmd, url).Run(); err != nil { + // Show error in status bar + m.statusMsg = fmt.Sprintf("Error opening browser: %v", err) + // Clear status message after delay + cmd := tea.Tick(3*time.Second, func(time.Time) tea.Msg { + return struct{ clearStatus bool }{true} + }) + return m, cmd + } else { + idStr := ansi.Strip(row[1]) + if id, err := strconv.Atoi(idStr); err == nil { + return m, m.startBlink(id, false) + } } } } @@ -668,6 +714,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if row := m.tbl.SelectedRow(); row != nil { idStr := ansi.Strip(row[1]) if id, err := strconv.Atoi(idStr); err == nil { + m.clearEditingModes() m.dueID = id m.dueEditing = true m.dueDate = time.Now() @@ -691,6 +738,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if row := m.tbl.SelectedRow(); row != nil { idStr := ansi.Strip(row[1]) if id, err := strconv.Atoi(idStr); err == nil { + m.clearEditingModes() m.recurID = id m.recurEditing = true m.recurInput.SetValue(m.tasks[m.tbl.Cursor()].Recur) @@ -703,6 +751,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if row := m.tbl.SelectedRow(); row != nil { idStr := ansi.Strip(row[1]) if id, err := strconv.Atoi(idStr); err == nil { + m.clearEditingModes() m.priorityID = id m.prioritySelecting = true m.priorityIndex = 0 @@ -714,6 +763,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if row := m.tbl.SelectedRow(); row != nil { idStr := ansi.Strip(row[1]) if id, err := strconv.Atoi(idStr); err == nil { + m.clearEditingModes() m.annotateID = id m.annotating = true m.replaceAnnotations = false @@ -727,6 +777,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if row := m.tbl.SelectedRow(); row != nil { idStr := ansi.Strip(row[1]) if id, err := strconv.Atoi(idStr); err == nil { + m.clearEditingModes() m.annotateID = id m.annotating = true m.replaceAnnotations = true @@ -737,12 +788,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } case "f": + m.clearEditingModes() m.filterEditing = true m.filterInput.SetValue(strings.Join(m.filters, " ")) m.filterInput.Focus() m.updateTableHeight() return m, nil case "+": + m.clearEditingModes() m.addingTask = true m.addInput.SetValue("") m.addInput.Focus() @@ -752,6 +805,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if row := m.tbl.SelectedRow(); row != nil { idStr := ansi.Strip(row[1]) if id, err := strconv.Atoi(idStr); err == nil { + m.clearEditingModes() m.tagsID = id m.tagsEditing = true m.tagsInput.SetValue("") @@ -776,6 +830,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.reload() return m, nil case "/", "?": + m.clearEditingModes() m.searching = true m.searchInput.SetValue("") m.searchInput.Focus() @@ -810,6 +865,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { col := m.tbl.ColumnCursor() switch col { case 0: + m.clearEditingModes() m.priorityID = id m.prioritySelecting = true switch m.tasks[m.tbl.Cursor()].Priority { @@ -831,10 +887,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.dueDate = time.Now() } + m.clearEditingModes() m.dueEditing = true m.updateTableHeight() return m, nil case 4: + m.clearEditingModes() m.recurID = id m.recurEditing = true m.recurInput.SetValue(m.tasks[m.tbl.Cursor()].Recur) @@ -861,6 +919,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateTableHeight() return m, nil case 7: + m.clearEditingModes() m.descID = id m.descEditing = true m.descInput.SetValue(m.tasks[m.tbl.Cursor()].Description) @@ -996,6 +1055,9 @@ func (m Model) View() string { func (m Model) statusLine() string { status := fmt.Sprintf("Total:%d InProgress:%d Due:%d | press H for help", m.total, m.inProgress, m.due) + if m.statusMsg != "" { + status = m.statusMsg + } return lipgloss.NewStyle(). Foreground(lipgloss.Color(m.theme.StatusFG)). Background(lipgloss.Color(m.theme.StatusBG)). @@ -1221,7 +1283,7 @@ func (m Model) taskToRowSearch(t task.Task, re *regexp.Regexp, styles atable.Sty func (m Model) expandedCellView() string { row := m.tbl.Cursor() col := m.tbl.ColumnCursor() - if row < 0 || row >= len(m.tasks) || col < 0 { + if row < 0 || row >= len(m.tasks) || col < 0 || col > 8 { return "" } t := m.tasks[row] |
