From 703ed981ac46bd7c7bbd7a1bffe450cc1c84b7c6 Mon Sep 17 00:00:00 2001 From: Martin Pander Date: Tue, 10 Feb 2026 15:54:08 +0100 Subject: [PATCH] Add things --- common/keymap.go | 6 + common/task.go | 4 +- components/table/table.go | 76 +++- components/timestampeditor/timestampeditor.go | 6 +- flake.nix | 3 +- main.go | 3 + opencode_sandbox.sh | 40 ++ pages/report.go | 56 ++- pages/timeEditor.go | 271 ++++++++++++-- taskwarrior/models.go | 6 +- taskwarrior/taskwarrior.go | 8 +- taskwarrior/tree.go | 127 +++++++ taskwarrior/tree_test.go | 345 ++++++++++++++++++ 13 files changed, 901 insertions(+), 50 deletions(-) create mode 100644 opencode_sandbox.sh create mode 100644 taskwarrior/tree.go create mode 100644 taskwarrior/tree_test.go diff --git a/common/keymap.go b/common/keymap.go index 6b896a3..bca6068 100644 --- a/common/keymap.go +++ b/common/keymap.go @@ -33,6 +33,7 @@ type Keymap struct { StartStop key.Binding Join key.Binding ViewDetails key.Binding + Subtask key.Binding } // TODO: use config values for key bindings @@ -173,5 +174,10 @@ func NewKeymap() *Keymap { key.WithKeys("v"), key.WithHelp("v", "view details"), ), + + Subtask: key.NewBinding( + key.WithKeys("S"), + key.WithHelp("S", "Create subtask"), + ), } } diff --git a/common/task.go b/common/task.go index 8c38d4b..c97c4a7 100644 --- a/common/task.go +++ b/common/task.go @@ -29,7 +29,7 @@ type Task struct { Depends []string `json:"depends,omitempty"` DependsIds string `json:"-"` Urgency float32 `json:"urgency,omitempty"` - Parent string `json:"parent,omitempty"` + Parent string `json:"parenttask,omitempty"` Due string `json:"due,omitempty"` Wait string `json:"wait,omitempty"` Scheduled string `json:"scheduled,omitempty"` @@ -125,7 +125,7 @@ func (t *Task) GetString(fieldWFormat string) string { } return strings.Join(t.Tags, " ") - case "parent": + case "parenttask": if format == "short" { return t.Parent[:8] } diff --git a/components/table/table.go b/components/table/table.go index 17a54a6..f08a824 100644 --- a/components/table/table.go +++ b/components/table/table.go @@ -27,6 +27,7 @@ type Model struct { focus bool styles common.TableStyle styleFunc StyleFunc + taskTree *taskwarrior.TaskTree viewport viewport.Model start int @@ -242,9 +243,31 @@ func (m *Model) parseColumns(cols []Column) []Column { return cols } + // Calculate max tree depth for indentation + maxTreeWidth := 0 + if m.taskTree != nil { + for _, node := range m.taskTree.FlatList { + // Calculate indentation: depth * 2 spaces + tree characters (3 chars for "└─ ") + treeWidth := 0 + if node.Depth > 0 { + treeWidth = node.Depth*2 + 3 + } + // Add progress indicator width for parent tasks (e.g., " (3/5)" = 6 chars max) + if node.HasChildren() { + treeWidth += 7 + } + maxTreeWidth = max(maxTreeWidth, treeWidth) + } + } + for i, col := range cols { for _, task := range m.rows { - col.ContentWidth = max(col.ContentWidth, lipgloss.Width(task.GetString(col.Name))) + contentWidth := lipgloss.Width(task.GetString(col.Name)) + // Add tree width to description column + if strings.Contains(col.Name, "description") { + contentWidth += maxTreeWidth + } + col.ContentWidth = max(col.ContentWidth, contentWidth) } cols[i] = col } @@ -351,6 +374,13 @@ func WithKeyMap(km KeyMap) Option { } } +// WithTaskTree sets the task tree for hierarchical display. +func WithTaskTree(tree *taskwarrior.TaskTree) Option { + return func(m *Model) { + m.taskTree = tree + } +} + // Update is the Bubble Tea update loop. func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { if !m.focus { @@ -571,6 +601,21 @@ func (m Model) headersView() string { func (m *Model) renderRow(r int) string { var s = make([]string, 0, len(m.cols)) + + // Extract tree metadata for this row + var depth int + var hasChildren bool + var progress string + if m.taskTree != nil && r < len(m.taskTree.FlatList) { + node := m.taskTree.FlatList[r] + depth = node.Depth + hasChildren = node.HasChildren() + if hasChildren { + completed, total := node.GetChildrenStatus() + progress = fmt.Sprintf(" (%d/%d)", completed, total) + } + } + for i, col := range m.cols { // for i, task := range m.rows[r] { if m.cols[i].Width <= 0 { @@ -589,8 +634,16 @@ func (m *Model) renderRow(r int) string { cellStyle = cellStyle.Inherit(m.styles.Selected) } + // Render cell content with tree formatting for description column + var cellContent string + if strings.Contains(col.Name, "description") && m.taskTree != nil { + cellContent = m.renderTreeDescription(r, depth, hasChildren, progress) + } else { + cellContent = m.rows[r].GetString(col.Name) + } + style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true) - renderedCell := cellStyle.Render(style.Render(runewidth.Truncate(m.rows[r].GetString(col.Name), m.cols[i].Width, "…"))) + renderedCell := cellStyle.Render(style.Render(runewidth.Truncate(cellContent, m.cols[i].Width, "…"))) s = append(s, renderedCell) } @@ -603,6 +656,25 @@ func (m *Model) renderRow(r int) string { return row } +// renderTreeDescription renders the description column with tree indentation and progress +func (m *Model) renderTreeDescription(rowIdx int, depth int, hasChildren bool, progress string) string { + task := m.rows[rowIdx] + desc := task.Description + + // Build indentation and tree characters + prefix := "" + if depth > 0 { + prefix = strings.Repeat(" ", depth) + "└─ " + } + + // Add progress indicator for parent tasks + if hasChildren { + desc = desc + progress + } + + return prefix + desc +} + func max(a, b int) int { if a > b { return a diff --git a/components/timestampeditor/timestampeditor.go b/components/timestampeditor/timestampeditor.go index 4b74361..1e6e436 100644 --- a/components/timestampeditor/timestampeditor.go +++ b/components/timestampeditor/timestampeditor.go @@ -187,14 +187,14 @@ func (t *TimestampEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) { t.adjustDate(1) } - // Time field adjustments (uppercase - 30 minutes) or date adjustments (week) + // Time field adjustments (uppercase - 60 minutes) or date adjustments (week) case "J": // Set current time on first edit if empty if t.isEmpty { t.setCurrentTime() } if t.currentField == TimeField { - t.adjustTime(-30) + t.adjustTime(-60) } else { t.adjustDate(-7) } @@ -204,7 +204,7 @@ func (t *TimestampEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) { t.setCurrentTime() } if t.currentField == TimeField { - t.adjustTime(30) + t.adjustTime(60) } else { t.adjustDate(7) } diff --git a/flake.nix b/flake.nix index 5a34ff1..2848139 100644 --- a/flake.nix +++ b/flake.nix @@ -18,8 +18,6 @@ version = "0.1.0"; src = ./.; - # Update this hash when dependencies change. - # You can get the correct hash by running `nix build`. vendorHash = "sha256-fDzQuKBZPkOATMMnYcFv/aJP62XDhL9LjM/UYre9JQ4="; ldflags = [ "-s" "-w" ]; @@ -62,3 +60,4 @@ devShell = self.devShells.${system}.default; }); } + diff --git a/main.go b/main.go index a456cb5..fc923a2 100644 --- a/main.go +++ b/main.go @@ -41,6 +41,9 @@ func main() { } ts := taskwarrior.NewTaskSquire(taskrcPath) + if ts == nil { + log.Fatal("Failed to initialize TaskSquire. Please check your Taskwarrior installation and taskrc file.") + } tws := timewarrior.NewTimeSquire(timewConfigPath) ctx := context.Background() common := common.NewCommon(ctx, ts, tws) diff --git a/opencode_sandbox.sh b/opencode_sandbox.sh new file mode 100644 index 0000000..bdbc98c --- /dev/null +++ b/opencode_sandbox.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +# 1. Resolve the absolute path of opencode from your Nix environment +OPENCODE_PATH=$(command -v opencode) + +if [ -z "$OPENCODE_PATH" ]; then + echo "❌ Error: 'opencode' not found in your PATH." + exit 1 +fi + +echo "🛡️ Engaging Bubblewrap Sandbox..." +echo "📍 Using binary: $OPENCODE_PATH" + +# 2. Run bwrap using the absolute path +bwrap \ + --ro-bind /bin /bin \ + --ro-bind /usr /usr \ + --ro-bind /lib /lib \ + --ro-bind /lib64 /lib64 \ + --ro-bind /nix /nix \ + --ro-bind /home/pan/.nix-profile/bin /home/pan/.nix-profile/bin \ + --ro-bind /home/pan/.config/opencode /home/pan/.config/opencode \ + --ro-bind /etc/resolv.conf /etc/resolv.conf \ + --ro-bind /etc/hosts /etc/hosts \ + --ro-bind-try /etc/ssl/certs /etc/ssl/certs \ + --ro-bind-try /etc/static/ssl/certs /etc/static/ssl/certs \ + --bind /home/pan/.local/share/opencode /home/pan/.local/share/opencode \ + --proc /proc \ + --dev-bind /dev /dev \ + --tmpfs /tmp \ + --unshare-all \ + --share-net \ + --die-with-parent \ + --bind "$(pwd)" "$(pwd)" \ + --chdir "$(pwd)" \ + --setenv PATH "$PATH" \ + --setenv HOME "$HOME" \ + --setenv TASKRC "$TASKRC" \ + --setenv TASKDATA "$TASKDATA" \ + "$OPENCODE_PATH" "$@" diff --git a/pages/report.go b/pages/report.go index c2266dc..19124b2 100644 --- a/pages/report.go +++ b/pages/report.go @@ -159,6 +159,40 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd := p.subpage.Init() p.common.PushPage(p) return p.subpage, cmd + case key.Matches(msg, p.common.Keymap.Subtask): + if p.selectedTask != nil { + // Create new task inheriting parent's attributes + newTask := taskwarrior.NewTask() + + // Set parent relationship + newTask.Parent = p.selectedTask.Uuid + + // Copy parent's attributes + newTask.Project = p.selectedTask.Project + newTask.Priority = p.selectedTask.Priority + newTask.Tags = make([]string, len(p.selectedTask.Tags)) + copy(newTask.Tags, p.selectedTask.Tags) + + // Copy UDAs (except "details" which is task-specific) + if p.selectedTask.Udas != nil { + newTask.Udas = make(map[string]any) + for k, v := range p.selectedTask.Udas { + // Skip "details" UDA - it's specific to parent task + if k == "details" { + continue + } + // Deep copy other UDA values + newTask.Udas[k] = v + } + } + + // Open task editor with pre-populated task + p.subpage = NewTaskEditorPage(p.common, newTask) + cmd := p.subpage.Init() + p.common.PushPage(p) + return p.subpage, cmd + } + return p, nil case key.Matches(msg, p.common.Keymap.Ok): p.common.TW.SetTaskDone(p.selectedTask) return p, p.getTasks() @@ -264,17 +298,28 @@ func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) { return } + // Build task tree for hierarchical display + taskTree := taskwarrior.BuildTaskTree(tasks) + + // Use flattened tree list for display order + orderedTasks := make(taskwarrior.Tasks, len(taskTree.FlatList)) + for i, node := range taskTree.FlatList { + orderedTasks[i] = node.Task + } + selected := p.taskTable.Cursor() + // Adjust cursor for tree ordering if p.selectedTask != nil { - for i, task := range tasks { + for i, task := range orderedTasks { if task.Uuid == p.selectedTask.Uuid { selected = i + break } } } - if selected > len(tasks)-1 { - selected = len(tasks) - 1 + if selected > len(orderedTasks)-1 { + selected = len(orderedTasks) - 1 } // Calculate proper dimensions based on whether details panel is active @@ -294,7 +339,8 @@ func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) { p.taskTable = table.New( p.common, table.WithReport(p.activeReport), - table.WithTasks(tasks), + table.WithTasks(orderedTasks), + table.WithTaskTree(taskTree), table.WithFocused(true), table.WithWidth(baseWidth), table.WithHeight(tableHeight), @@ -304,7 +350,7 @@ func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) { if selected == 0 { selected = p.taskTable.Cursor() } - if selected < len(tasks) { + if selected < len(orderedTasks) { p.taskTable.SetCursor(selected) } else { p.taskTable.SetCursor(len(p.tasks) - 1) diff --git a/pages/timeEditor.go b/pages/timeEditor.go index 7d0c7ce..40e86fb 100644 --- a/pages/timeEditor.go +++ b/pages/timeEditor.go @@ -1,12 +1,14 @@ package pages import ( + "fmt" "log/slog" "strings" "tasksquire/common" "tasksquire/components/autocomplete" "tasksquire/components/picker" "tasksquire/components/timestampeditor" + "tasksquire/taskwarrior" "tasksquire/timewarrior" "github.com/charmbracelet/bubbles/key" @@ -21,6 +23,7 @@ type TimeEditorPage struct { // Fields projectPicker *picker.Picker + taskPicker *picker.Picker startEditor *timestampeditor.TimestampEditor endEditor *timestampeditor.TimestampEditor tagsInput *autocomplete.Autocomplete @@ -28,6 +31,7 @@ type TimeEditorPage struct { // State selectedProject string + selectedTask *taskwarrior.Task currentField int totalFields int uuid string // Preserved UUID tag @@ -38,18 +42,91 @@ type timeEditorProjectSelectedMsg struct { project string } +type timeEditorTaskSelectedMsg struct { + task *taskwarrior.Task +} + +// createTaskPickerForProject creates a picker showing tasks with +track tag for the given project +func createTaskPickerForProject(com *common.Common, project string, defaultTask string) *picker.Picker { + // Build filters for tasks with +track tag + filters := []string{"+track", "status:pending"} + if project != "" { + filters = append(filters, "project:"+project) + } + + taskItemProvider := func() []list.Item { + tasks := com.TW.GetTasks(nil, filters...) + // Add "(none)" as first option, then all tasks + items := make([]list.Item, 0, len(tasks)+1) + items = append(items, picker.NewItem("(none)")) + for i := range tasks { + items = append(items, picker.NewItem(tasks[i].Description)) + } + return items + } + + taskOnSelect := func(item list.Item) tea.Cmd { + return func() tea.Msg { + // Handle "(none)" selection + if item.FilterValue() == "(none)" { + return timeEditorTaskSelectedMsg{task: nil} + } + // Find the task by description + tasks := com.TW.GetTasks(nil, filters...) + for _, task := range tasks { + if task.Description == item.FilterValue() { + return timeEditorTaskSelectedMsg{task: task} + } + } + return nil + } + } + + title := "Task" + if project != "" { + title = fmt.Sprintf("Task (%s)", project) + } + + opts := []picker.PickerOption{ + picker.WithFilterByDefault(false), // Start in list mode, not filter mode + } + + // Pre-select task if provided, otherwise default to "(none)" + if defaultTask != "" { + opts = append(opts, picker.WithDefaultValue(defaultTask)) + } else { + opts = append(opts, picker.WithDefaultValue("(none)")) + } + + taskPicker := picker.New( + com, + title, + taskItemProvider, + taskOnSelect, + opts..., + ) + taskPicker.SetSize(50, 10) + + return taskPicker +} + func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage { // Extract special tags (uuid, project, track) and display tags uuid, selectedProject, track, displayTags := extractSpecialTags(interval.Tags) + // Track selected task for pre-selection + var selectedTask *taskwarrior.Task + var defaultTaskDescription string + // If UUID exists, fetch the task and add its title to display tags if uuid != "" { tasks := com.TW.GetTasks(nil, "uuid:"+uuid) if len(tasks) > 0 { - taskTitle := tasks[0].Description + selectedTask = tasks[0] + defaultTaskDescription = selectedTask.Description // Add to display tags if not already present // Note: formatTags() will handle quoting for display, so we store the raw title - displayTags = ensureTagPresent(displayTags, taskTitle) + displayTags = ensureTagPresent(displayTags, defaultTaskDescription) } } @@ -93,6 +170,12 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time ) projectPicker.SetSize(50, 10) // Compact size for inline use + // Create task picker (only if project is selected) + var taskPicker *picker.Picker + if selectedProject != "" { + taskPicker = createTaskPickerForProject(com, selectedProject, defaultTaskDescription) + } + // Create start timestamp editor startEditor := timestampeditor.New(com). Title("Start"). @@ -118,13 +201,15 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time common: com, interval: interval, projectPicker: projectPicker, + taskPicker: taskPicker, startEditor: startEditor, endEditor: endEditor, tagsInput: tagsInput, adjust: true, // Enable :adjust by default selectedProject: selectedProject, + selectedTask: selectedTask, currentField: 0, - totalFields: 5, // Now 5 fields: project, tags, start, end, adjust + totalFields: 6, // 6 fields: project, task, tags, start, end, adjust uuid: uuid, track: track, } @@ -143,15 +228,18 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case timeEditorProjectSelectedMsg: - // Update selected project - p.selectedProject = msg.project - // Blur current field (project picker) + // Project selection happens on Enter - advance to task picker + // (Auto-selection of project already happened in Update() switch) p.blurCurrentField() - // Advance to tags field p.currentField = 1 - // Refresh tag autocomplete with filtered combinations - cmds = append(cmds, p.updateTagSuggestions()) - // Focus tags input + cmds = append(cmds, p.focusCurrentField()) + return p, tea.Batch(cmds...) + + case timeEditorTaskSelectedMsg: + // Task selection happens on Enter - advance to tags field + // (Auto-selection of task already happened in Update() switch) + p.blurCurrentField() + p.currentField = 2 cmds = append(cmds, p.focusCurrentField()) return p, tea.Batch(cmds...) @@ -173,6 +261,11 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if p.currentField == 1 { + // Task picker - let it handle Enter (will trigger taskSelectedMsg) + break + } + + if p.currentField == 2 { // Tags field if p.tagsInput.HasSuggestions() { // Let autocomplete handle suggestion selection @@ -180,12 +273,12 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Tags confirmed without suggestions - advance to start timestamp p.blurCurrentField() - p.currentField = 2 + p.currentField = 3 cmds = append(cmds, p.focusCurrentField()) return p, tea.Batch(cmds...) } - // For all other fields (2-4: start, end, adjust), save and exit + // For all other fields (3-5: start, end, adjust), save and exit p.saveInterval() model, err := p.common.PopPage() if err != nil { @@ -215,30 +308,116 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch p.currentField { case 0: // Project picker + // Track the previous project selection + previousProject := p.selectedProject + var model tea.Model model, cmd = p.projectPicker.Update(msg) if pk, ok := model.(*picker.Picker); ok { p.projectPicker = pk } - case 1: // Tags (was 0) + + // Check if the highlighted project changed (auto-selection) + currentProject := p.projectPicker.GetValue() + if currentProject != previousProject && currentProject != "" { + // Update the selected project and refresh task picker + p.selectedProject = currentProject + // Clear task selection when project changes + p.selectedTask = nil + p.uuid = "" + // Create/update task picker for the new project + p.taskPicker = createTaskPickerForProject(p.common, currentProject, "") + // Refresh tag autocomplete with filtered combinations + cmds = append(cmds, p.updateTagSuggestions()) + } + case 1: // Task picker + if p.taskPicker != nil { + // Track the previous task selection + var previousTaskDesc string + if p.selectedTask != nil { + previousTaskDesc = p.selectedTask.Description + } + + var model tea.Model + model, cmd = p.taskPicker.Update(msg) + if pk, ok := model.(*picker.Picker); ok { + p.taskPicker = pk + } + + // Check if the highlighted task changed (auto-selection) + currentTaskDesc := p.taskPicker.GetValue() + if currentTaskDesc != previousTaskDesc && currentTaskDesc != "" { + // Handle "(none)" selection - clear task state + if currentTaskDesc == "(none)" { + p.selectedTask = nil + p.uuid = "" + p.track = "" + // Don't clear tags - user might still want manual tags + // Refresh tag suggestions + cmds = append(cmds, p.updateTagSuggestions()) + } else { + // Find and update the selected task + filters := []string{"+track", "status:pending"} + if p.selectedProject != "" { + filters = append(filters, "project:"+p.selectedProject) + } + tasks := p.common.TW.GetTasks(nil, filters...) + for _, task := range tasks { + if task.Description == currentTaskDesc { + // Update selected task + p.selectedTask = task + p.uuid = task.Uuid + + // Build tags from task + tags := []string{} + + // Add task description + if task.Description != "" { + tags = append(tags, task.Description) + } + + // Add task tags (excluding "track" tag since it's preserved separately) + for _, tag := range task.Tags { + if tag != "track" { + tags = append(tags, tag) + } + } + + // Store track tag if present + if task.HasTag("track") { + p.track = "track" + } + + // Update tags input + p.tagsInput.SetValue(formatTags(tags)) + + // Refresh tag suggestions + cmds = append(cmds, p.updateTagSuggestions()) + break + } + } + } + } + } + case 2: // Tags var model tea.Model model, cmd = p.tagsInput.Update(msg) if ac, ok := model.(*autocomplete.Autocomplete); ok { p.tagsInput = ac } - case 2: // Start (was 1) + case 3: // Start var model tea.Model model, cmd = p.startEditor.Update(msg) if editor, ok := model.(*timestampeditor.TimestampEditor); ok { p.startEditor = editor } - case 3: // End (was 2) + case 4: // End var model tea.Model model, cmd = p.endEditor.Update(msg) if editor, ok := model.(*timestampeditor.TimestampEditor); ok { p.endEditor = editor } - case 4: // Adjust (was 3) + case 5: // Adjust // Handle adjust toggle with space/enter if msg, ok := msg.(tea.KeyMsg); ok { if msg.String() == " " || msg.String() == "enter" { @@ -256,13 +435,18 @@ func (p *TimeEditorPage) focusCurrentField() tea.Cmd { case 0: return p.projectPicker.Init() // Picker doesn't have explicit Focus() case 1: + if p.taskPicker != nil { + return p.taskPicker.Init() + } + return nil + case 2: p.tagsInput.Focus() return p.tagsInput.Init() - case 2: - return p.startEditor.Focus() case 3: - return p.endEditor.Focus() + return p.startEditor.Focus() case 4: + return p.endEditor.Focus() + case 5: // Adjust checkbox doesn't need focus action return nil } @@ -272,14 +456,16 @@ func (p *TimeEditorPage) focusCurrentField() tea.Cmd { func (p *TimeEditorPage) blurCurrentField() { switch p.currentField { case 0: - // Picker doesn't have explicit Blur(), state handled by Update + // Project picker doesn't have explicit Blur(), state handled by Update case 1: - p.tagsInput.Blur() + // Task picker doesn't have explicit Blur(), state handled by Update case 2: - p.startEditor.Blur() + p.tagsInput.Blur() case 3: - p.endEditor.Blur() + p.startEditor.Blur() case 4: + p.endEditor.Blur() + case 5: // Adjust checkbox doesn't need blur action } } @@ -304,10 +490,33 @@ func (p *TimeEditorPage) View() string { sections = append(sections, "") sections = append(sections, "") - // Tags input (now field 1, was first) + // Task picker (field 1) + if p.currentField == 1 { + if p.taskPicker != nil { + sections = append(sections, p.taskPicker.View()) + } else { + // No project selected yet + blurredLabelStyle := p.common.Styles.Form.Blurred.Title + sections = append(sections, blurredLabelStyle.Render("Task")) + sections = append(sections, lipgloss.NewStyle().Faint(true).Render("(select a project first)")) + } + } else { + blurredLabelStyle := p.common.Styles.Form.Blurred.Title + sections = append(sections, blurredLabelStyle.Render("Task")) + if p.selectedTask != nil { + sections = append(sections, lipgloss.NewStyle().Faint(true).Render(p.selectedTask.Description)) + } else { + sections = append(sections, lipgloss.NewStyle().Faint(true).Render("(none)")) + } + } + + sections = append(sections, "") + sections = append(sections, "") + + // Tags input (field 2) tagsLabelStyle := p.common.Styles.Form.Focused.Title tagsLabel := tagsLabelStyle.Render("Tags") - if p.currentField == 1 { // Changed from 0 + if p.currentField == 2 { sections = append(sections, tagsLabel) sections = append(sections, p.tagsInput.View()) descStyle := p.common.Styles.Form.Focused.Description @@ -321,15 +530,15 @@ func (p *TimeEditorPage) View() string { sections = append(sections, "") sections = append(sections, "") - // Start editor + // Start editor (field 3) sections = append(sections, p.startEditor.View()) sections = append(sections, "") - // End editor + // End editor (field 4) sections = append(sections, p.endEditor.View()) sections = append(sections, "") - // Adjust checkbox (now field 4, was 3) + // Adjust checkbox (field 5) adjustLabelStyle := p.common.Styles.Form.Focused.Title adjustLabel := adjustLabelStyle.Render("Adjust overlaps") @@ -340,7 +549,7 @@ func (p *TimeEditorPage) View() string { checkbox = "[ ]" } - if p.currentField == 4 { // Changed from 3 + if p.currentField == 5 { focusedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) sections = append(sections, adjustLabel) sections = append(sections, focusedStyle.Render(checkbox+" Auto-adjust overlapping intervals")) @@ -357,7 +566,7 @@ func (p *TimeEditorPage) View() string { // Help text helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - sections = append(sections, helpStyle.Render("tab/shift+tab: navigate • enter: save • esc: cancel")) + sections = append(sections, helpStyle.Render("tab/shift+tab: navigate • enter: select/save • esc: cancel")) return lipgloss.JoinVertical(lipgloss.Left, sections...) } @@ -528,7 +737,7 @@ func (p *TimeEditorPage) updateTagSuggestions() tea.Cmd { p.tagsInput.SetValue(currentValue) // If tags field is focused, refocus it - if p.currentField == 1 { + if p.currentField == 2 { p.tagsInput.Focus() return p.tagsInput.Init() } diff --git a/taskwarrior/models.go b/taskwarrior/models.go index c3b6553..573b095 100644 --- a/taskwarrior/models.go +++ b/taskwarrior/models.go @@ -54,7 +54,7 @@ type Task struct { Depends []string `json:"depends,omitempty"` DependsIds string `json:"-"` Urgency float32 `json:"urgency,omitempty"` - Parent string `json:"parent,omitempty"` + Parent string `json:"parenttask,omitempty"` Due string `json:"due,omitempty"` Wait string `json:"wait,omitempty"` Scheduled string `json:"scheduled,omitempty"` @@ -168,7 +168,7 @@ func (t *Task) GetString(fieldWFormat string) string { } return strings.Join(t.Tags, " ") - case "parent": + case "parenttask": if format == "short" { return t.Parent[:8] } @@ -317,7 +317,7 @@ func (t *Task) UnmarshalJSON(data []byte) error { delete(m, "tags") delete(m, "depends") delete(m, "urgency") - delete(m, "parent") + delete(m, "parenttask") delete(m, "due") delete(m, "wait") delete(m, "scheduled") diff --git a/taskwarrior/taskwarrior.go b/taskwarrior/taskwarrior.go index 670107c..0e695d2 100644 --- a/taskwarrior/taskwarrior.go +++ b/taskwarrior/taskwarrior.go @@ -128,6 +128,10 @@ func NewTaskSquire(configLocation string) *TaskSquire { mutex: sync.Mutex{}, } ts.config = ts.extractConfig() + if ts.config == nil { + slog.Error("Failed to extract config - taskwarrior commands are failing. Check your taskrc file for syntax errors.") + return nil + } ts.reports = ts.extractReports() ts.contexts = ts.extractContexts() @@ -552,7 +556,7 @@ func (ts *TaskSquire) extractConfig() *TWConfig { cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_show"}...)...) output, err := cmd.CombinedOutput() if err != nil { - slog.Error("Failed getting config:", err) + slog.Error("Failed getting config", "error", err, "output", string(output)) return nil } @@ -610,7 +614,7 @@ func (ts *TaskSquire) extractContexts() Contexts { output, err := cmd.CombinedOutput() if err != nil { - slog.Error("Failed getting contexts:", err) + slog.Error("Failed getting contexts", "error", err, "output", string(output)) return nil } diff --git a/taskwarrior/tree.go b/taskwarrior/tree.go new file mode 100644 index 0000000..6f83c6e --- /dev/null +++ b/taskwarrior/tree.go @@ -0,0 +1,127 @@ +package taskwarrior + +import ( + "log/slog" +) + +// TaskNode represents a task in the tree structure +type TaskNode struct { + Task *Task + Children []*TaskNode + Parent *TaskNode + Depth int +} + +// TaskTree manages the hierarchical task structure +type TaskTree struct { + Nodes map[string]*TaskNode // UUID -> TaskNode + Roots []*TaskNode // Top-level tasks (no parent) + FlatList []*TaskNode // Flattened tree in display order +} + +// BuildTaskTree constructs a tree from a flat list of tasks +// Three-pass algorithm: +// 1. Create all nodes +// 2. Establish parent-child relationships +// 3. Calculate depths and flatten tree +func BuildTaskTree(tasks Tasks) *TaskTree { + tree := &TaskTree{ + Nodes: make(map[string]*TaskNode), + Roots: make([]*TaskNode, 0), + FlatList: make([]*TaskNode, 0), + } + + // Pass 1: Create all nodes + for _, task := range tasks { + node := &TaskNode{ + Task: task, + Children: make([]*TaskNode, 0), + Parent: nil, + Depth: 0, + } + tree.Nodes[task.Uuid] = node + } + + // Pass 2: Establish parent-child relationships + // Iterate over original tasks slice to preserve order + for _, task := range tasks { + node := tree.Nodes[task.Uuid] + parentUUID := getParentUUID(node.Task) + if parentUUID == "" { + // No parent, this is a root task + tree.Roots = append(tree.Roots, node) + } else { + // Find parent node + parentNode, exists := tree.Nodes[parentUUID] + if !exists { + // Orphaned task - missing parent + slog.Warn("Task has missing parent", + "task_uuid", node.Task.Uuid, + "parent_uuid", parentUUID, + "task_desc", node.Task.Description) + // Treat as root (graceful degradation) + tree.Roots = append(tree.Roots, node) + } else { + // Establish relationship + node.Parent = parentNode + parentNode.Children = append(parentNode.Children, node) + } + } + } + + // Pass 3: Calculate depths and flatten tree + for _, root := range tree.Roots { + flattenNode(root, 0, &tree.FlatList) + } + + return tree +} + +// getParentUUID extracts the parent UUID from a task's UDAs +func getParentUUID(task *Task) string { + if task.Udas == nil { + return "" + } + + parentVal, exists := task.Udas["parenttask"] + if !exists { + return "" + } + + // Parent UDA is stored as a string + if parentStr, ok := parentVal.(string); ok { + return parentStr + } + + return "" +} + +// flattenNode recursively flattens the tree in depth-first order +func flattenNode(node *TaskNode, depth int, flatList *[]*TaskNode) { + node.Depth = depth + *flatList = append(*flatList, node) + + // Recursively flatten children + for _, child := range node.Children { + flattenNode(child, depth+1, flatList) + } +} + +// GetChildrenStatus returns completed/total counts for a parent task +func (tn *TaskNode) GetChildrenStatus() (completed int, total int) { + total = len(tn.Children) + completed = 0 + + for _, child := range tn.Children { + if child.Task.Status == "completed" { + completed++ + } + } + + return completed, total +} + +// HasChildren returns true if the node has any children +func (tn *TaskNode) HasChildren() bool { + return len(tn.Children) > 0 +} diff --git a/taskwarrior/tree_test.go b/taskwarrior/tree_test.go new file mode 100644 index 0000000..9a349d4 --- /dev/null +++ b/taskwarrior/tree_test.go @@ -0,0 +1,345 @@ +package taskwarrior + +import ( + "testing" +) + +func TestBuildTaskTree_EmptyList(t *testing.T) { + tasks := Tasks{} + tree := BuildTaskTree(tasks) + + if tree == nil { + t.Fatal("Expected tree to be non-nil") + } + + if len(tree.Nodes) != 0 { + t.Errorf("Expected 0 nodes, got %d", len(tree.Nodes)) + } + + if len(tree.Roots) != 0 { + t.Errorf("Expected 0 roots, got %d", len(tree.Roots)) + } + + if len(tree.FlatList) != 0 { + t.Errorf("Expected 0 items in flat list, got %d", len(tree.FlatList)) + } +} + +func TestBuildTaskTree_NoParents(t *testing.T) { + tasks := Tasks{ + {Uuid: "task1", Description: "Task 1", Status: "pending"}, + {Uuid: "task2", Description: "Task 2", Status: "pending"}, + {Uuid: "task3", Description: "Task 3", Status: "completed"}, + } + + tree := BuildTaskTree(tasks) + + if len(tree.Nodes) != 3 { + t.Errorf("Expected 3 nodes, got %d", len(tree.Nodes)) + } + + if len(tree.Roots) != 3 { + t.Errorf("Expected 3 roots (all tasks have no parent), got %d", len(tree.Roots)) + } + + if len(tree.FlatList) != 3 { + t.Errorf("Expected 3 items in flat list, got %d", len(tree.FlatList)) + } + + // All tasks should have depth 0 + for i, node := range tree.FlatList { + if node.Depth != 0 { + t.Errorf("Task %d expected depth 0, got %d", i, node.Depth) + } + if node.HasChildren() { + t.Errorf("Task %d should not have children", i) + } + } +} + +func TestBuildTaskTree_SimpleParentChild(t *testing.T) { + tasks := Tasks{ + {Uuid: "parent1", Description: "Parent Task", Status: "pending"}, + { + Uuid: "child1", + Description: "Child Task 1", + Status: "pending", + Udas: map[string]any{"parenttask": "parent1"}, + }, + { + Uuid: "child2", + Description: "Child Task 2", + Status: "completed", + Udas: map[string]any{"parenttask": "parent1"}, + }, + } + + tree := BuildTaskTree(tasks) + + if len(tree.Nodes) != 3 { + t.Fatalf("Expected 3 nodes, got %d", len(tree.Nodes)) + } + + if len(tree.Roots) != 1 { + t.Fatalf("Expected 1 root, got %d", len(tree.Roots)) + } + + // Check root is the parent + root := tree.Roots[0] + if root.Task.Uuid != "parent1" { + t.Errorf("Expected root to be parent1, got %s", root.Task.Uuid) + } + + // Check parent has 2 children + if len(root.Children) != 2 { + t.Fatalf("Expected parent to have 2 children, got %d", len(root.Children)) + } + + // Check children status + completed, total := root.GetChildrenStatus() + if total != 2 { + t.Errorf("Expected total children = 2, got %d", total) + } + if completed != 1 { + t.Errorf("Expected completed children = 1, got %d", completed) + } + + // Check flat list order (parent first, then children) + if len(tree.FlatList) != 3 { + t.Fatalf("Expected 3 items in flat list, got %d", len(tree.FlatList)) + } + + if tree.FlatList[0].Task.Uuid != "parent1" { + t.Errorf("Expected first item to be parent, got %s", tree.FlatList[0].Task.Uuid) + } + + if tree.FlatList[0].Depth != 0 { + t.Errorf("Expected parent depth = 0, got %d", tree.FlatList[0].Depth) + } + + // Children should be at depth 1 + for i := 1; i < 3; i++ { + if tree.FlatList[i].Depth != 1 { + t.Errorf("Expected child %d depth = 1, got %d", i, tree.FlatList[i].Depth) + } + if tree.FlatList[i].Parent == nil { + t.Errorf("Child %d should have a parent", i) + } else if tree.FlatList[i].Parent.Task.Uuid != "parent1" { + t.Errorf("Child %d parent should be parent1, got %s", i, tree.FlatList[i].Parent.Task.Uuid) + } + } +} + +func TestBuildTaskTree_MultiLevel(t *testing.T) { + tasks := Tasks{ + {Uuid: "grandparent", Description: "Grandparent", Status: "pending"}, + { + Uuid: "parent1", + Description: "Parent 1", + Status: "pending", + Udas: map[string]any{"parenttask": "grandparent"}, + }, + { + Uuid: "parent2", + Description: "Parent 2", + Status: "pending", + Udas: map[string]any{"parenttask": "grandparent"}, + }, + { + Uuid: "child1", + Description: "Child 1", + Status: "pending", + Udas: map[string]any{"parenttask": "parent1"}, + }, + { + Uuid: "grandchild1", + Description: "Grandchild 1", + Status: "completed", + Udas: map[string]any{"parenttask": "child1"}, + }, + } + + tree := BuildTaskTree(tasks) + + if len(tree.Nodes) != 5 { + t.Fatalf("Expected 5 nodes, got %d", len(tree.Nodes)) + } + + if len(tree.Roots) != 1 { + t.Fatalf("Expected 1 root, got %d", len(tree.Roots)) + } + + // Find nodes by UUID + grandparentNode := tree.Nodes["grandparent"] + parent1Node := tree.Nodes["parent1"] + child1Node := tree.Nodes["child1"] + grandchildNode := tree.Nodes["grandchild1"] + + // Check depths + if grandparentNode.Depth != 0 { + t.Errorf("Expected grandparent depth = 0, got %d", grandparentNode.Depth) + } + if parent1Node.Depth != 1 { + t.Errorf("Expected parent1 depth = 1, got %d", parent1Node.Depth) + } + if child1Node.Depth != 2 { + t.Errorf("Expected child1 depth = 2, got %d", child1Node.Depth) + } + if grandchildNode.Depth != 3 { + t.Errorf("Expected grandchild depth = 3, got %d", grandchildNode.Depth) + } + + // Check parent-child relationships + if len(grandparentNode.Children) != 2 { + t.Errorf("Expected grandparent to have 2 children, got %d", len(grandparentNode.Children)) + } + + if len(parent1Node.Children) != 1 { + t.Errorf("Expected parent1 to have 1 child, got %d", len(parent1Node.Children)) + } + + if len(child1Node.Children) != 1 { + t.Errorf("Expected child1 to have 1 child, got %d", len(child1Node.Children)) + } + + if grandchildNode.HasChildren() { + t.Error("Expected grandchild to have no children") + } + + // Check flat list maintains tree order + if len(tree.FlatList) != 5 { + t.Fatalf("Expected 5 items in flat list, got %d", len(tree.FlatList)) + } + + // Grandparent should be first + if tree.FlatList[0].Task.Uuid != "grandparent" { + t.Errorf("Expected first item to be grandparent, got %s", tree.FlatList[0].Task.Uuid) + } +} + +func TestBuildTaskTree_OrphanedTask(t *testing.T) { + tasks := Tasks{ + {Uuid: "task1", Description: "Normal Task", Status: "pending"}, + { + Uuid: "orphan", + Description: "Orphaned Task", + Status: "pending", + Udas: map[string]any{"parenttask": "nonexistent"}, + }, + } + + tree := BuildTaskTree(tasks) + + if len(tree.Nodes) != 2 { + t.Fatalf("Expected 2 nodes, got %d", len(tree.Nodes)) + } + + // Orphaned task should be treated as root + if len(tree.Roots) != 2 { + t.Errorf("Expected 2 roots (orphan should be treated as root), got %d", len(tree.Roots)) + } + + // Both should have depth 0 + for _, node := range tree.FlatList { + if node.Depth != 0 { + t.Errorf("Expected all tasks to have depth 0, got %d for %s", node.Depth, node.Task.Description) + } + } +} + +func TestTaskNode_GetChildrenStatus(t *testing.T) { + tests := []struct { + name string + children []*TaskNode + wantComp int + wantTotal int + }{ + { + name: "no children", + children: []*TaskNode{}, + wantComp: 0, + wantTotal: 0, + }, + { + name: "all pending", + children: []*TaskNode{ + {Task: &Task{Status: "pending"}}, + {Task: &Task{Status: "pending"}}, + }, + wantComp: 0, + wantTotal: 2, + }, + { + name: "all completed", + children: []*TaskNode{ + {Task: &Task{Status: "completed"}}, + {Task: &Task{Status: "completed"}}, + {Task: &Task{Status: "completed"}}, + }, + wantComp: 3, + wantTotal: 3, + }, + { + name: "mixed status", + children: []*TaskNode{ + {Task: &Task{Status: "completed"}}, + {Task: &Task{Status: "pending"}}, + {Task: &Task{Status: "completed"}}, + {Task: &Task{Status: "pending"}}, + {Task: &Task{Status: "completed"}}, + }, + wantComp: 3, + wantTotal: 5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + node := &TaskNode{ + Task: &Task{}, + Children: tt.children, + } + + gotComp, gotTotal := node.GetChildrenStatus() + + if gotComp != tt.wantComp { + t.Errorf("GetChildrenStatus() completed = %d, want %d", gotComp, tt.wantComp) + } + if gotTotal != tt.wantTotal { + t.Errorf("GetChildrenStatus() total = %d, want %d", gotTotal, tt.wantTotal) + } + }) + } +} + +func TestTaskNode_HasChildren(t *testing.T) { + tests := []struct { + name string + children []*TaskNode + want bool + }{ + { + name: "no children", + children: []*TaskNode{}, + want: false, + }, + { + name: "has children", + children: []*TaskNode{{Task: &Task{}}}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + node := &TaskNode{ + Task: &Task{}, + Children: tt.children, + } + + if got := node.HasChildren(); got != tt.want { + t.Errorf("HasChildren() = %v, want %v", got, tt.want) + } + }) + } +}