From 02fa2e503a9cf55b0718bc01760b618c6fadf0e4 Mon Sep 17 00:00:00 2001 From: Martin Pander Date: Wed, 4 Feb 2026 13:13:04 +0100 Subject: [PATCH] Add things --- .claude/settings.local.json | 12 + CLAUDE.md | 236 +++++++++++ common/keymap.go | 56 +-- components/detailsviewer/detailsviewer.go | 174 ++++++++ components/picker/picker.go | 13 + go.mod | 38 +- go.sum | 75 +++- pages/messaging.go | 4 + pages/projectTaskPicker.go | 465 ++++++++++++++++++++++ pages/report.go | 135 ++++++- pages/taskEditor.go | 8 +- pages/timeEditor.go | 101 +++-- taskwarrior/models.go | 12 +- taskwarrior/models_test.go | 8 +- timewarrior/tags.go | 3 + 15 files changed, 1214 insertions(+), 126 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 CLAUDE.md create mode 100644 components/detailsviewer/detailsviewer.go create mode 100644 pages/projectTaskPicker.go diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..89cb8f1 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(find:*)", + "Bash(ls:*)", + "Bash(go fmt:*)", + "Bash(go build:*)", + "Bash(go vet:*)", + "Bash(timew export:*)" + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..db25a93 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,236 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +TaskSquire is a Go-based Terminal User Interface (TUI) for Taskwarrior and Timewarrior. It uses the Bubble Tea framework (Model-View-Update pattern) from the Charm ecosystem. + +**Key Technologies:** +- Go 1.22.2 +- Bubble Tea (MVU pattern) +- Lip Gloss (styling) +- Huh (forms) +- Bubbles (components) + +## Build and Development Commands + +### Running and Building +```bash +# Run directly +go run main.go + +# Build binary +go build -o tasksquire main.go + +# Format code (always run before committing) +go fmt ./... + +# Vet code +go vet ./... + +# Tidy dependencies +go mod tidy +``` + +### Testing +```bash +# Run all tests +go test ./... + +# Run tests for specific package +go test ./taskwarrior + +# Run single test +go test ./taskwarrior -run TestTaskSquire_GetContext + +# Run with verbose output +go test -v ./... + +# Run with coverage +go test -cover ./... +``` + +### Linting +```bash +# Lint with golangci-lint (via nix-shell) +golangci-lint run +``` + +### Development Environment +The project uses Nix for development environment setup: +```bash +# Enter Nix development shell +nix develop + +# Or use direnv (automatically loads .envrc) +direnv allow +``` + +## Architecture + +### High-Level Structure + +TaskSquire follows the MVU (Model-View-Update) pattern with a component-based architecture: + +1. **Entry Point (`main.go`)**: Initializes TaskSquire and TimeSquire, creates Common state container, and starts Bubble Tea program +2. **Common State (`common/`)**: Shared state, components interface, keymaps, styles, and utilities +3. **Pages (`pages/`)**: Top-level UI views (report, taskEditor, timePage, pickers) +4. **Components (`components/`)**: Reusable UI widgets (input, table, timetable, picker) +5. **Business Logic**: + - `taskwarrior/`: Wraps Taskwarrior CLI, models, and config parsing + - `timewarrior/`: Wraps Timewarrior CLI, models, and config parsing + +### Component System + +All UI elements implement the `Component` interface: +```go +type Component interface { + tea.Model // Init(), Update(tea.Msg), View() + SetSize(width int, height int) +} +``` + +Components can be composed hierarchically. The `Common` struct manages a page stack for navigation. + +### Page Navigation Pattern + +- **Main Page** (`pages/main.go`): Root page with tab switching between Tasks and Time views +- **Page Stack**: Managed by `common.Common`, allows pushing/popping subpages + - `common.PushPage(page)` - push a new page on top + - `common.PopPage()` - return to previous page + - `common.HasSubpages()` - check if subpages are active +- **Tab Switching**: Only works at top level (when no subpages active) + +### State Management + +The `common.Common` struct acts as a shared state container: +- `TW`: TaskWarrior interface for task operations +- `TimeW`: TimeWarrior interface for time tracking +- `Keymap`: Centralized key bindings +- `Styles`: Centralized styling (parsed from Taskwarrior config) +- `Udas`: User Defined Attributes from Taskwarrior config +- `pageStack`: Stack-based page navigation + +### Taskwarrior Integration + +The `TaskWarrior` interface provides all task operations: +- Task CRUD: `GetTasks()`, `ImportTask()`, `SetTaskDone()` +- Task control: `StartTask()`, `StopTask()`, `DeleteTask()` +- Context management: `GetContext()`, `GetContexts()`, `SetContext()` +- Reports: `GetReport()`, `GetReports()` +- Config parsing: Manual parsing of Taskwarrior config format + +All Taskwarrior operations use `exec.Command()` to call the `task` CLI binary. Results are parsed from JSON output. + +### Timewarrior Integration + +The `TimeWarrior` interface provides time tracking operations: +- Interval management: `GetIntervals()`, `ModifyInterval()`, `DeleteInterval()` +- Tracking control: `StartTracking()`, `StopTracking()`, `ContinueTracking()` +- Tag management: `GetTags()`, `GetTagCombinations()` +- Utility: `FillInterval()`, `JoinInterval()`, `Undo()` + +Similar to TaskWarrior, uses `exec.Command()` to call the `timew` CLI binary. + +### Custom JSON Marshaling + +The `Task` struct uses custom `MarshalJSON()` and `UnmarshalJSON()` to handle: +- User Defined Attributes (UDAs) stored in `Udas map[string]any` +- Dynamic field handling via `json.RawMessage` +- Virtual tags (filtered from regular tags) + +### Configuration and Environment + +- **Taskwarrior Config**: Located via `TASKRC` env var, or fallback to `~/.taskrc` or `~/.config/task/taskrc` +- **Timewarrior Config**: Located via `TIMEWARRIORDB` env var, or fallback to `~/.timewarrior/timewarrior.cfg` +- **Config Parsing**: Custom parser in `taskwarrior/config.go` handles Taskwarrior's config format +- **Theme Colors**: Extracted from Taskwarrior config and used in Lip Gloss styles + +### Concurrency + +- Both `TaskSquire` and `TimeSquire` use `sync.Mutex` to protect shared state +- Lock pattern: `ts.mu.Lock()` followed by `defer ts.mu.Unlock()` +- Operations are synchronous (no goroutines in typical flows) + +### Logging + +- Uses `log/slog` for structured logging +- Logs written to `app.log` in current directory +- Errors logged but execution typically continues (graceful degradation) +- Log pattern: `slog.Error("message", "key", value)` + +## Code Style and Patterns + +### Import Organization +Standard library first, then third-party, then local: +```go +import ( + "context" + "fmt" + + tea "github.com/charmbracelet/bubbletea" + + "tasksquire/common" + "tasksquire/taskwarrior" +) +``` + +### Naming Conventions +- Exported types: `PascalCase` (e.g., `TaskSquire`, `ReportPage`) +- Unexported fields: `camelCase` (e.g., `configLocation`, `activeReport`) +- Interfaces: Often end in 'er' or describe capability (e.g., `TaskWarrior`, `TimeWarrior`, `Component`) + +### Error Handling +- Log errors with `slog.Error()` and continue execution +- Don't panic unless fatal initialization error +- Return errors from functions, don't log and return + +### MVU Pattern in Bubble Tea +Components follow the MVU pattern: +- `Init() tea.Cmd`: Initialize and return commands for side effects +- `Update(tea.Msg) (tea.Model, tea.Cmd)`: Handle messages, update state, return commands +- `View() string`: Render UI as string + +Custom messages for inter-component communication: +```go +type MyCustomMsg struct { + data string +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case MyCustomMsg: + // Handle custom message + } + return m, nil +} +``` + +### Styling with Lip Gloss +- Centralized styles in `common/styles.go` +- Theme colors parsed from Taskwarrior config +- Create reusable style functions, not inline styles + +### Testing Patterns +- Table-driven tests with struct slices +- Helper functions like `TaskWarriorTestSetup()` +- Use `t.TempDir()` for isolated test environments +- Include `prep func()` in test cases for setup + +## Important Implementation Details + +### Virtual Tags +Taskwarrior has virtual tags (ACTIVE, BLOCKED, etc.) that are filtered out from regular tags. See the `virtualTags` map in `taskwarrior/taskwarrior.go`. + +### Non-Standard Reports +Some Taskwarrior reports require special handling (burndown, calendar, etc.). See `nonStandardReports` map. + +### Timestamp Format +Taskwarrior uses ISO 8601 format: `20060102T150405Z` (defined as `dtformat` constant) + +### Color Parsing +Custom color parsing from Taskwarrior config format in `common/styles.go` + +### VSCode Debugging +Launch configuration available for remote debugging on port 43000 (see `.vscode/launch.json`) diff --git a/common/keymap.go b/common/keymap.go index 531bfc4..6b896a3 100644 --- a/common/keymap.go +++ b/common/keymap.go @@ -6,32 +6,33 @@ import ( // Keymap is a collection of key bindings. type Keymap struct { - Quit key.Binding - Back key.Binding - Ok key.Binding - Delete key.Binding - Input key.Binding - Add key.Binding - Edit key.Binding - Up key.Binding - Down key.Binding - Left key.Binding - Right key.Binding - Next key.Binding - Prev key.Binding - NextPage key.Binding - PrevPage key.Binding - SetReport key.Binding - SetContext key.Binding - SetProject key.Binding + Quit key.Binding + Back key.Binding + Ok key.Binding + Delete key.Binding + Input key.Binding + Add key.Binding + Edit key.Binding + Up key.Binding + Down key.Binding + Left key.Binding + Right key.Binding + Next key.Binding + Prev key.Binding + NextPage key.Binding + PrevPage key.Binding + SetReport key.Binding + SetContext key.Binding + SetProject key.Binding PickProjectTask key.Binding - Select key.Binding - Insert key.Binding - Tag key.Binding - Undo key.Binding - Fill key.Binding - StartStop key.Binding - Join key.Binding + Select key.Binding + Insert key.Binding + Tag key.Binding + Undo key.Binding + Fill key.Binding + StartStop key.Binding + Join key.Binding + ViewDetails key.Binding } // TODO: use config values for key bindings @@ -167,5 +168,10 @@ func NewKeymap() *Keymap { key.WithKeys("J"), key.WithHelp("J", "Join with previous"), ), + + ViewDetails: key.NewBinding( + key.WithKeys("v"), + key.WithHelp("v", "view details"), + ), } } diff --git a/components/detailsviewer/detailsviewer.go b/components/detailsviewer/detailsviewer.go new file mode 100644 index 0000000..a6e080b --- /dev/null +++ b/components/detailsviewer/detailsviewer.go @@ -0,0 +1,174 @@ +package detailsviewer + +import ( + "log/slog" + "tasksquire/common" + "tasksquire/taskwarrior" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss" +) + +// DetailsViewer is a reusable component for displaying task details +type DetailsViewer struct { + common *common.Common + viewport viewport.Model + task *taskwarrior.Task + focused bool + width int + height int +} + +// New creates a new DetailsViewer component +func New(com *common.Common) *DetailsViewer { + return &DetailsViewer{ + common: com, + viewport: viewport.New(0, 0), + focused: false, + } +} + +// SetTask updates the task to display +func (d *DetailsViewer) SetTask(task *taskwarrior.Task) { + d.task = task + d.updateContent() +} + +// Focus sets the component to focused state (for future interactivity) +func (d *DetailsViewer) Focus() { + d.focused = true +} + +// Blur sets the component to blurred state +func (d *DetailsViewer) Blur() { + d.focused = false +} + +// IsFocused returns whether the component is focused +func (d *DetailsViewer) IsFocused() bool { + return d.focused +} + +// SetSize implements common.Component +func (d *DetailsViewer) SetSize(width, height int) { + d.width = width + d.height = height + + // Account for border and padding (4 chars horizontal, 4 lines vertical) + d.viewport.Width = max(width-4, 0) + d.viewport.Height = max(height-4, 0) + + // Refresh content with new width + d.updateContent() +} + +// Init implements tea.Model +func (d *DetailsViewer) Init() tea.Cmd { + return nil +} + +// Update implements tea.Model +func (d *DetailsViewer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + d.viewport, cmd = d.viewport.Update(msg) + return d, cmd +} + +// View implements tea.Model +func (d *DetailsViewer) View() string { + // Title bar + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("252")) + + helpStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")) + + header := lipgloss.JoinHorizontal( + lipgloss.Left, + titleStyle.Render("Details"), + " ", + helpStyle.Render("(↑/↓ scroll, v/ESC close)"), + ) + + // Container style + containerStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("240")). + Padding(0, 1). + Width(d.width). + Height(d.height) + + // Optional: highlight border when focused (for future interactivity) + if d.focused { + containerStyle = containerStyle. + BorderForeground(lipgloss.Color("86")) + } + + content := lipgloss.JoinVertical( + lipgloss.Left, + header, + d.viewport.View(), + ) + + return containerStyle.Render(content) +} + +// updateContent refreshes the viewport content based on current task +func (d *DetailsViewer) updateContent() { + if d.task == nil { + d.viewport.SetContent("(No task selected)") + return + } + + detailsValue := "" + if details, ok := d.task.Udas["details"]; ok && details != nil { + detailsValue = details.(string) + } + + if detailsValue == "" { + d.viewport.SetContent("(No details for this task)") + return + } + + // Render markdown with glamour + renderer, err := glamour.NewTermRenderer( + glamour.WithAutoStyle(), + glamour.WithWordWrap(d.viewport.Width), + ) + if err != nil { + slog.Error("failed to create markdown renderer", "error", err) + // Fallback to plain text + wrapped := lipgloss.NewStyle(). + Width(d.viewport.Width). + Render(detailsValue) + d.viewport.SetContent(wrapped) + d.viewport.GotoTop() + return + } + + rendered, err := renderer.Render(detailsValue) + if err != nil { + slog.Error("failed to render markdown", "error", err) + // Fallback to plain text + wrapped := lipgloss.NewStyle(). + Width(d.viewport.Width). + Render(detailsValue) + d.viewport.SetContent(wrapped) + d.viewport.GotoTop() + return + } + + d.viewport.SetContent(rendered) + d.viewport.GotoTop() +} + +// Helper function +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/components/picker/picker.go b/components/picker/picker.go index a28827f..ae69a11 100644 --- a/components/picker/picker.go +++ b/components/picker/picker.go @@ -103,6 +103,19 @@ func New( key.WithHelp("i", "filter"), ) + // Disable the quit key binding - we don't want Esc to quit the list + // Esc should only cancel filtering mode + l.KeyMap.Quit = key.NewBinding( + key.WithKeys(), // No keys bound + key.WithHelp("", ""), + ) + + // Also disable force quit + l.KeyMap.ForceQuit = key.NewBinding( + key.WithKeys(), // No keys bound + key.WithHelp("", ""), + ) + p := &Picker{ common: c, list: l, diff --git a/go.mod b/go.mod index 434e77c..4b533d6 100644 --- a/go.mod +++ b/go.mod @@ -1,39 +1,51 @@ module tasksquire -go 1.22.2 +go 1.23.0 + +toolchain go1.24.12 require ( github.com/charmbracelet/bubbles v0.18.0 github.com/charmbracelet/bubbletea v0.26.4 + github.com/charmbracelet/glamour v0.10.0 github.com/charmbracelet/huh v0.4.2 - github.com/charmbracelet/lipgloss v0.11.0 - github.com/mattn/go-runewidth v0.0.15 - golang.org/x/term v0.21.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/mattn/go-runewidth v0.0.16 + github.com/sahilm/fuzzy v0.1.1 + golang.org/x/term v0.31.0 ) require ( + github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/catppuccin/go v0.2.0 // indirect - github.com/charmbracelet/x/ansi v0.1.2 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a // indirect github.com/charmbracelet/x/exp/term v0.0.0-20240606154654-7c42867b53c7 // indirect - github.com/charmbracelet/x/input v0.1.1 // indirect - github.com/charmbracelet/x/term v0.1.1 // indirect - github.com/charmbracelet/x/windows v0.1.2 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.15.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/sahilm/fuzzy v0.1.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.7.0 // indirect - golang.org/x/sys v0.21.0 // indirect - golang.org/x/text v0.16.0 // indirect + github.com/yuin/goldmark v1.7.8 // indirect + github.com/yuin/goldmark-emoji v1.0.5 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect ) diff --git a/go.sum b/go.sum index b80ee42..de7290b 100644 --- a/go.sum +++ b/go.sum @@ -1,33 +1,55 @@ +github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= +github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= github.com/charmbracelet/bubbletea v0.26.4 h1:2gDkkzLZaTjMl/dQBpNVtnvcCxsh/FCkimep7FC9c40= github.com/charmbracelet/bubbletea v0.26.4/go.mod h1:P+r+RRA5qtI1DOHNFn0otoNwB4rn+zNAzSj/EXz6xU0= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= +github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= github.com/charmbracelet/huh v0.4.2 h1:5wLkwrA58XDAfEZsJzNQlfJ+K8N9+wYwvR5FOM7jXFM= github.com/charmbracelet/huh v0.4.2/go.mod h1:g9OXBgtY3zRV4ahnVih9bZE+1yGYN+y2C9Q6L2P+WM0= -github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g= -github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8= -github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY= -github.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a h1:lOpqe2UvPmlln41DGoii7wlSZ/q8qGIon5JJ8Biu46I= github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/exp/term v0.0.0-20240606154654-7c42867b53c7 h1:6Rw/MHf+Rm7wlUr+euFyaGw1fNKeZPKVh4gkFHUjFsY= github.com/charmbracelet/x/exp/term v0.0.0-20240606154654-7c42867b53c7/go.mod h1:UZyEz89i6+jVx2oW3lgmIsVDNr7qzaKuvPVoSdwqDR0= -github.com/charmbracelet/x/input v0.1.1 h1:YDOJaTUKCqtGnq9PHzx3pkkl4pXDOANUHmhH3DqMtM4= -github.com/charmbracelet/x/input v0.1.1/go.mod h1:jvdTVUnNWj/RD6hjC4FsoB0SeZCJ2ZBkiuFP9zXvZI0= -github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI= -github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw= -github.com/charmbracelet/x/windows v0.1.2 h1:Iumiwq2G+BRmgoayww/qfcvof7W/3uLoelhxojXlRWg= -github.com/charmbracelet/x/windows v0.1.2/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -37,16 +59,18 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= @@ -55,15 +79,22 @@ github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= +github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= -golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= diff --git a/pages/messaging.go b/pages/messaging.go index 289c0e5..e02b582 100644 --- a/pages/messaging.go +++ b/pages/messaging.go @@ -82,3 +82,7 @@ func doTick() tea.Cmd { return tickMsg(t) }) } + +type TaskPickedMsg struct { + Task *taskwarrior.Task +} diff --git a/pages/projectTaskPicker.go b/pages/projectTaskPicker.go new file mode 100644 index 0000000..90381fc --- /dev/null +++ b/pages/projectTaskPicker.go @@ -0,0 +1,465 @@ +package pages + +import ( + "fmt" + "tasksquire/common" + "tasksquire/components/picker" + "tasksquire/taskwarrior" + "tasksquire/timewarrior" + "time" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type ProjectTaskPickerPage struct { + common *common.Common + + // Both pickers visible simultaneously + projectPicker *picker.Picker + taskPicker *picker.Picker + selectedProject string + selectedTask *taskwarrior.Task + + // Focus tracking: 0 = project picker, 1 = task picker + focusedPicker int +} + +type projectTaskPickerProjectSelectedMsg struct { + project string +} + +type projectTaskPickerTaskSelectedMsg struct { + task *taskwarrior.Task +} + +func NewProjectTaskPickerPage(com *common.Common) *ProjectTaskPickerPage { + p := &ProjectTaskPickerPage{ + common: com, + focusedPicker: 0, + } + + // Create project picker + projectItemProvider := func() []list.Item { + projects := com.TW.GetProjects() + items := make([]list.Item, 0, len(projects)) + + for _, proj := range projects { + items = append(items, picker.NewItem(proj)) + } + return items + } + + projectOnSelect := func(item list.Item) tea.Cmd { + return func() tea.Msg { + return projectTaskPickerProjectSelectedMsg{project: item.FilterValue()} + } + } + + p.projectPicker = picker.New( + com, + "Projects", + projectItemProvider, + projectOnSelect, + ) + + // Initialize with the first project's tasks + projects := com.TW.GetProjects() + if len(projects) > 0 { + p.selectedProject = projects[0] + p.createTaskPicker(projects[0]) + } else { + // No projects - create empty task picker + p.createTaskPicker("") + } + + p.SetSize(com.Width(), com.Height()) + + return p +} + +func (p *ProjectTaskPickerPage) createTaskPicker(project string) { + // Build filters for tasks + filters := []string{"+track", "status:pending"} + + if project != "" { + // Tasks in the selected project + filters = append(filters, "project:"+project) + } + + taskItemProvider := func() []list.Item { + tasks := p.common.TW.GetTasks(nil, filters...) + items := make([]list.Item, 0, len(tasks)) + + for i := range tasks { + // Just use the description as the item text + // picker.NewItem creates a simple item with title and filter value + items = append(items, picker.NewItem(tasks[i].Description)) + } + return items + } + + taskOnSelect := func(item list.Item) tea.Cmd { + return func() tea.Msg { + // Find the task by description + tasks := p.common.TW.GetTasks(nil, filters...) + for _, task := range tasks { + if task.Description == item.FilterValue() { + // tasks is already []*Task, so task is already *Task + return projectTaskPickerTaskSelectedMsg{task: task} + } + } + return nil + } + } + + title := "Tasks with +track" + if project != "" { + title = fmt.Sprintf("Tasks: %s", project) + } + + p.taskPicker = picker.New( + p.common, + title, + taskItemProvider, + taskOnSelect, + picker.WithFilterByDefault(false), // Start in list mode, not filter mode + ) +} + +func (p *ProjectTaskPickerPage) Init() tea.Cmd { + // Focus the project picker initially + p.projectPicker.Focus() + return tea.Batch(p.projectPicker.Init(), p.taskPicker.Init()) +} + +func (p *ProjectTaskPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + p.SetSize(msg.Width, msg.Height) + + case projectTaskPickerProjectSelectedMsg: + // Project selected - update task picker + p.selectedProject = msg.project + p.createTaskPicker(msg.project) + // Move focus to task picker + p.projectPicker.Blur() + p.taskPicker.Focus() + p.focusedPicker = 1 + p.SetSize(p.common.Width(), p.common.Height()) + return p, p.taskPicker.Init() + + case projectTaskPickerTaskSelectedMsg: + // Task selected - emit TaskPickedMsg and return to parent + p.selectedTask = msg.task + model, err := p.common.PopPage() + if err != nil { + return p, tea.Quit + } + return model, func() tea.Msg { + return TaskPickedMsg{Task: p.selectedTask} + } + + case UpdatedTasksMsg: + // Task was edited - refresh the task list and recreate the task picker + if p.selectedProject != "" { + p.createTaskPicker(p.selectedProject) + p.SetSize(p.common.Width(), p.common.Height()) + // Keep the task picker focused + p.taskPicker.Focus() + p.focusedPicker = 1 + return p, p.taskPicker.Init() + } + return p, nil + + case tea.KeyMsg: + // Check if the focused picker is in filtering mode BEFORE handling any keys + var focusedPickerFiltering bool + if p.focusedPicker == 0 { + focusedPickerFiltering = p.projectPicker.IsFiltering() + } else { + focusedPickerFiltering = p.taskPicker.IsFiltering() + } + + switch { + case key.Matches(msg, p.common.Keymap.Back): + // If the focused picker is filtering, let it handle the escape key to dismiss the filter + // and don't exit the page + if focusedPickerFiltering { + // Don't handle the Back key - let it fall through to the picker + break + } + // Exit picker completely + model, err := p.common.PopPage() + if err != nil { + return p, tea.Quit + } + return model, BackCmd + + case key.Matches(msg, p.common.Keymap.Add): + // Don't handle 'a' if focused picker is filtering - let the picker handle it for typing + if focusedPickerFiltering { + break + } + // Create new task with selected project and track tag pre-filled + newTask := taskwarrior.NewTask() + newTask.Project = p.selectedProject + newTask.Tags = []string{"track"} + + // Open task editor with pre-populated task + taskEditor := NewTaskEditorPage(p.common, newTask) + p.common.PushPage(p) + return taskEditor, taskEditor.Init() + + case key.Matches(msg, p.common.Keymap.Edit): + // Don't handle 'e' if focused picker is filtering - let the picker handle it for typing + if focusedPickerFiltering { + break + } + // Edit task when task picker is focused and a task is selected + if p.focusedPicker == 1 && p.selectedProject != "" { + // Get the currently highlighted task + selectedItemText := p.taskPicker.GetValue() + if selectedItemText != "" { + // Find the task by description + filters := []string{"+track", "status:pending"} + filters = append(filters, "project:"+p.selectedProject) + + tasks := p.common.TW.GetTasks(nil, filters...) + for _, task := range tasks { + if task.Description == selectedItemText { + // Found the task - open editor + p.selectedTask = task + taskEditor := NewTaskEditorPage(p.common, *task) + p.common.PushPage(p) + return taskEditor, taskEditor.Init() + } + } + } + } + return p, nil + + case key.Matches(msg, p.common.Keymap.Tag): + // Don't handle 't' if focused picker is filtering - let the picker handle it for typing + if focusedPickerFiltering { + break + } + // Open time editor with task pre-filled when task picker is focused + if p.focusedPicker == 1 && p.selectedProject != "" { + // Get the currently highlighted task + selectedItemText := p.taskPicker.GetValue() + if selectedItemText != "" { + // Find the task by description + filters := []string{"+track", "status:pending"} + filters = append(filters, "project:"+p.selectedProject) + + tasks := p.common.TW.GetTasks(nil, filters...) + for _, task := range tasks { + if task.Description == selectedItemText { + // Found the task - create new interval with task pre-filled + interval := createIntervalFromTask(task) + + // Open time editor with pre-populated interval + timeEditor := NewTimeEditorPage(p.common, interval) + p.common.PushPage(p) + return timeEditor, timeEditor.Init() + } + } + } + } + return p, nil + + case key.Matches(msg, p.common.Keymap.Next): + // Tab: switch focus between pickers + if p.focusedPicker == 0 { + p.projectPicker.Blur() + p.taskPicker.Focus() + p.focusedPicker = 1 + } else { + p.taskPicker.Blur() + p.projectPicker.Focus() + p.focusedPicker = 0 + } + return p, nil + + case key.Matches(msg, p.common.Keymap.Prev): + // Shift+Tab: switch focus between pickers (reverse) + if p.focusedPicker == 1 { + p.taskPicker.Blur() + p.projectPicker.Focus() + p.focusedPicker = 0 + } else { + p.projectPicker.Blur() + p.taskPicker.Focus() + p.focusedPicker = 1 + } + return p, nil + } + } + + // Update the focused picker + var cmd tea.Cmd + if p.focusedPicker == 0 { + // Track the previous project selection + previousProject := p.selectedProject + + _, cmd = p.projectPicker.Update(msg) + cmds = append(cmds, cmd) + + // Check if the highlighted project changed + currentProject := p.projectPicker.GetValue() + if currentProject != previousProject && currentProject != "" { + // Update the selected project and refresh task picker + p.selectedProject = currentProject + p.createTaskPicker(currentProject) + p.SetSize(p.common.Width(), p.common.Height()) + cmds = append(cmds, p.taskPicker.Init()) + } + } else { + _, cmd = p.taskPicker.Update(msg) + cmds = append(cmds, cmd) + } + + return p, tea.Batch(cmds...) +} + +func (p *ProjectTaskPickerPage) View() string { + // Render both pickers (they handle their own focused/blurred styling) + projectView := p.projectPicker.View() + taskView := p.taskPicker.View() + + // Create distinct styling for focused vs blurred pickers + var projectStyled, taskStyled string + + if p.focusedPicker == 0 { + // Project picker is focused + projectStyled = lipgloss.NewStyle(). + Border(lipgloss.ThickBorder()). + BorderForeground(lipgloss.Color("6")). // Cyan for focused + Padding(0, 1). + Render(projectView) + + taskStyled = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). // Gray for blurred + Padding(0, 1). + Render(taskView) + } else { + // Task picker is focused + projectStyled = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). // Gray for blurred + Padding(0, 1). + Render(projectView) + + taskStyled = lipgloss.NewStyle(). + Border(lipgloss.ThickBorder()). + BorderForeground(lipgloss.Color("6")). // Cyan for focused + Padding(0, 1). + Render(taskView) + } + + // Layout side by side if width permits, otherwise stack vertically + var content string + if p.common.Width() >= 100 { + // Side by side layout + content = lipgloss.JoinHorizontal(lipgloss.Top, projectStyled, " ", taskStyled) + } else { + // Vertical stack layout + content = lipgloss.JoinVertical(lipgloss.Left, projectStyled, "", taskStyled) + } + + // Add help text + helpText := "Tab/Shift+Tab: switch focus • Enter: select • a: add task • e: edit • t: track time • Esc: cancel" + helpStyled := lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")). + Italic(true). + Render(helpText) + + fullContent := lipgloss.JoinVertical(lipgloss.Left, content, "", helpStyled) + + return lipgloss.Place( + p.common.Width(), + p.common.Height(), + lipgloss.Center, + lipgloss.Center, + fullContent, + ) +} + +func (p *ProjectTaskPickerPage) SetSize(width, height int) { + p.common.SetSize(width, height) + + // Calculate sizes based on layout + var projectWidth, taskWidth, listHeight int + + if width >= 100 { + // Side by side layout + projectWidth = 30 + taskWidth = width - projectWidth - 10 // Account for margins and padding + if taskWidth > 60 { + taskWidth = 60 + } + } else { + // Vertical stack layout + projectWidth = width - 8 + taskWidth = width - 8 + if projectWidth > 60 { + projectWidth = 60 + } + if taskWidth > 60 { + taskWidth = 60 + } + } + + // Height for each picker + listHeight = height - 10 // Account for help text and padding + if listHeight > 25 { + listHeight = 25 + } + if listHeight < 10 { + listHeight = 10 + } + + if p.projectPicker != nil { + p.projectPicker.SetSize(projectWidth, listHeight) + } + + if p.taskPicker != nil { + p.taskPicker.SetSize(taskWidth, listHeight) + } +} + +// createIntervalFromTask creates a new time interval pre-filled with task metadata +func createIntervalFromTask(task *taskwarrior.Task) *timewarrior.Interval { + interval := timewarrior.NewInterval() + + // Set start time to now (UTC format) + interval.Start = time.Now().UTC().Format("20060102T150405Z") + // Leave End empty for active tracking + interval.End = "" + + // Build tags from task metadata + tags := []string{} + + // Add UUID tag for task linking + if task.Uuid != "" { + tags = append(tags, "uuid:"+task.Uuid) + } + + // Add project tag + if task.Project != "" { + tags = append(tags, "project:"+task.Project) + } + + // Add existing task tags (excluding virtual tags) + tags = append(tags, task.Tags...) + + interval.Tags = tags + + return interval +} diff --git a/pages/report.go b/pages/report.go index 2893b54..c2266dc 100644 --- a/pages/report.go +++ b/pages/report.go @@ -3,13 +3,13 @@ package pages import ( "tasksquire/common" + "tasksquire/components/detailsviewer" "tasksquire/components/table" "tasksquire/taskwarrior" "github.com/charmbracelet/bubbles/key" - - // "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) type ReportPage struct { @@ -25,6 +25,10 @@ type ReportPage struct { taskTable table.Model + // Details panel state + detailsPanelActive bool + detailsViewer *detailsviewer.DetailsViewer + subpage common.Component } @@ -38,11 +42,13 @@ func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage { // } p := &ReportPage{ - common: com, - activeReport: report, - activeContext: com.TW.GetActiveContext(), - activeProject: "", - taskTable: table.New(com), + common: com, + activeReport: report, + activeContext: com.TW.GetActiveContext(), + activeProject: "", + taskTable: table.New(com), + detailsPanelActive: false, + detailsViewer: detailsviewer.New(com), } return p @@ -51,8 +57,39 @@ func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage { func (p *ReportPage) SetSize(width int, height int) { p.common.SetSize(width, height) - p.taskTable.SetWidth(width - p.common.Styles.Base.GetHorizontalFrameSize()) - p.taskTable.SetHeight(height - p.common.Styles.Base.GetVerticalFrameSize()) + baseHeight := height - p.common.Styles.Base.GetVerticalFrameSize() + baseWidth := width - p.common.Styles.Base.GetHorizontalFrameSize() + + var tableHeight int + if p.detailsPanelActive { + // Allocate 60% for table, 40% for details panel + // Minimum 5 lines for details, minimum 10 lines for table + detailsHeight := max(min(baseHeight*2/5, baseHeight-10), 5) + tableHeight = baseHeight - detailsHeight - 1 // -1 for spacing + + // Set component size (component handles its own border/padding) + p.detailsViewer.SetSize(baseWidth, detailsHeight) + } else { + tableHeight = baseHeight + } + + p.taskTable.SetWidth(baseWidth) + p.taskTable.SetHeight(tableHeight) +} + +// Helper functions +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func min(a, b int) int { + if a < b { + return a + } + return b } func (p *ReportPage) Init() tea.Cmd { @@ -91,6 +128,14 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case UpdatedTasksMsg: cmds = append(cmds, p.getTasks()) case tea.KeyMsg: + // Handle ESC when details panel is active + if p.detailsPanelActive && key.Matches(msg, p.common.Keymap.Back) { + p.detailsPanelActive = false + p.detailsViewer.Blur() + p.SetSize(p.common.Width(), p.common.Height()) + return p, nil + } + switch { case key.Matches(msg, p.common.Keymap.Quit): return p, tea.Quit @@ -155,28 +200,63 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return p, p.getTasks() } + case key.Matches(msg, p.common.Keymap.ViewDetails): + if p.selectedTask != nil { + // Toggle details panel + p.detailsPanelActive = !p.detailsPanelActive + if p.detailsPanelActive { + p.detailsViewer.SetTask(p.selectedTask) + p.detailsViewer.Focus() + } else { + p.detailsViewer.Blur() + } + p.SetSize(p.common.Width(), p.common.Height()) + return p, nil + } } } var cmd tea.Cmd - p.taskTable, cmd = p.taskTable.Update(msg) - cmds = append(cmds, cmd) - if p.tasks != nil && len(p.tasks) > 0 { - p.selectedTask = p.tasks[p.taskTable.Cursor()] + // Route keyboard messages to details viewer when panel is active + if p.detailsPanelActive { + var viewerCmd tea.Cmd + var viewerModel tea.Model + viewerModel, viewerCmd = p.detailsViewer.Update(msg) + p.detailsViewer = viewerModel.(*detailsviewer.DetailsViewer) + cmds = append(cmds, viewerCmd) } else { - p.selectedTask = nil + // Route to table when details panel not active + p.taskTable, cmd = p.taskTable.Update(msg) + cmds = append(cmds, cmd) + + if p.tasks != nil && len(p.tasks) > 0 { + p.selectedTask = p.tasks[p.taskTable.Cursor()] + } else { + p.selectedTask = nil + } } return p, tea.Batch(cmds...) } func (p *ReportPage) View() string { - // return p.common.Styles.Main.Render(p.taskTable.View()) + "\n" if p.tasks == nil || len(p.tasks) == 0 { return p.common.Styles.Base.Render("No tasks found") } - return p.taskTable.View() + + tableView := p.taskTable.View() + + if !p.detailsPanelActive { + return tableView + } + + // Combine table and details panel vertically + return lipgloss.JoinVertical( + lipgloss.Left, + tableView, + p.detailsViewer.View(), + ) } func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) { @@ -197,13 +277,27 @@ func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) { selected = len(tasks) - 1 } + // Calculate proper dimensions based on whether details panel is active + baseHeight := p.common.Height() - p.common.Styles.Base.GetVerticalFrameSize() + baseWidth := p.common.Width() - p.common.Styles.Base.GetHorizontalFrameSize() + + var tableHeight int + if p.detailsPanelActive { + // Allocate 60% for table, 40% for details panel + // Minimum 5 lines for details, minimum 10 lines for table + detailsHeight := max(min(baseHeight*2/5, baseHeight-10), 5) + tableHeight = baseHeight - detailsHeight - 1 // -1 for spacing + } else { + tableHeight = baseHeight + } + p.taskTable = table.New( p.common, table.WithReport(p.activeReport), table.WithTasks(tasks), table.WithFocused(true), - table.WithWidth(p.common.Width()-p.common.Styles.Base.GetVerticalFrameSize()), - table.WithHeight(p.common.Height()-p.common.Styles.Base.GetHorizontalFrameSize()-1), + table.WithWidth(baseWidth), + table.WithHeight(tableHeight), table.WithStyles(p.common.Styles.TableStyle), ) @@ -215,6 +309,11 @@ func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) { } else { p.taskTable.SetCursor(len(p.tasks) - 1) } + + // Refresh details content if panel is active + if p.detailsPanelActive && p.selectedTask != nil { + p.detailsViewer.SetTask(p.selectedTask) + } } func (p *ReportPage) getTasks() tea.Cmd { diff --git a/pages/taskEditor.go b/pages/taskEditor.go index 19012ce..351e738 100644 --- a/pages/taskEditor.go +++ b/pages/taskEditor.go @@ -466,9 +466,15 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task, isNew bool) *taskEd } opts := []picker.PickerOption{picker.WithOnCreate(onCreate)} - if isNew { + + // Check if task has a pre-filled project (e.g., from ProjectTaskPickerPage) + hasPrefilledProject := task.Project != "" && task.Project != "(none)" + + if isNew && !hasPrefilledProject { + // New task with no project → start in filter mode for quick project search opts = append(opts, picker.WithFilterByDefault(true)) } else { + // Either existing task OR new task with pre-filled project → show list with project selected opts = append(opts, picker.WithDefaultValue(task.Project)) } diff --git a/pages/timeEditor.go b/pages/timeEditor.go index 4c9ef8f..7d0c7ce 100644 --- a/pages/timeEditor.go +++ b/pages/timeEditor.go @@ -30,6 +30,8 @@ type TimeEditorPage struct { selectedProject string currentField int totalFields int + uuid string // Preserved UUID tag + track string // Preserved track tag (if present) } type timeEditorProjectSelectedMsg struct { @@ -37,9 +39,19 @@ type timeEditorProjectSelectedMsg struct { } func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage { - // Extract project from tags if it exists - projects := com.TW.GetProjects() - selectedProject, remainingTags := extractProjectFromTags(interval.Tags, projects) + // Extract special tags (uuid, project, track) and display tags + uuid, selectedProject, track, displayTags := extractSpecialTags(interval.Tags) + + // 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 + // 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) + } + } // Create project picker with onCreate support for new projects projectItemProvider := func() []list.Item { @@ -99,7 +111,7 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time tagsInput := autocomplete.New(tagCombinations, 3, 10) tagsInput.SetPlaceholder("Space separated, use \"\" for tags with spaces") - tagsInput.SetValue(formatTags(remainingTags)) // Use remaining tags (without project) + tagsInput.SetValue(formatTags(displayTags)) // Use display tags only (no uuid, project, track) tagsInput.SetWidth(50) p := &TimeEditorPage{ @@ -113,6 +125,8 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time selectedProject: selectedProject, currentField: 0, totalFields: 5, // Now 5 fields: project, tags, start, end, adjust + uuid: uuid, + track: track, } return p @@ -365,23 +379,27 @@ func (p *TimeEditorPage) saveInterval() { p.interval.Start = p.startEditor.GetValueString() p.interval.End = p.endEditor.GetValueString() - // Parse tags from input - tags := parseTags(p.tagsInput.GetValue()) + // Parse display tags from input + displayTags := parseTags(p.tagsInput.GetValue()) - // Add project to tags if not already present - if p.selectedProject != "" { - projectTag := "project:" + p.selectedProject - projectExists := false - for _, tag := range tags { - if tag == projectTag { - projectExists = true - break - } - } - if !projectExists { - tags = append([]string{projectTag}, tags...) // Prepend project tag - } + // Reconstruct full tags array by combining special tags and display tags + var tags []string + + // Add preserved special tags first + if p.uuid != "" { + tags = append(tags, "uuid:"+p.uuid) } + if p.track != "" { + tags = append(tags, p.track) + } + + // Add project tag + if p.selectedProject != "" { + tags = append(tags, "project:"+p.selectedProject) + } + + // Add display tags (user-entered tags from the input field) + tags = append(tags, displayTags...) p.interval.Tags = tags @@ -427,30 +445,39 @@ func formatTags(tags []string) string { return strings.Join(formatted, " ") } +// extractSpecialTags separates special tags (uuid, project, track) from display tags +// Returns uuid, project, track as separate strings, and displayTags for user editing +func extractSpecialTags(tags []string) (uuid, project, track string, displayTags []string) { + for _, tag := range tags { + if strings.HasPrefix(tag, "uuid:") { + uuid = strings.TrimPrefix(tag, "uuid:") + } else if strings.HasPrefix(tag, "project:") { + project = strings.TrimPrefix(tag, "project:") + } else if tag == "track" { + track = tag + } else { + displayTags = append(displayTags, tag) + } + } + return +} + // extractProjectFromTags finds and removes the first tag that matches a known project // Returns the found project (or empty string) and the remaining tags +// This is kept for backward compatibility but now uses extractSpecialTags internally func extractProjectFromTags(tags []string, projects []string) (string, []string) { - projectSet := make(map[string]bool) - for _, p := range projects { - projectSet[p] = true - } + _, project, _, remaining := extractSpecialTags(tags) + return project, remaining +} - var foundProject string - var remaining []string - - for _, tag := range tags { - // Check if this tag is a project tag (format: "project:projectname") - if strings.HasPrefix(tag, "project:") { - projectName := strings.TrimPrefix(tag, "project:") - if foundProject == "" && projectSet[projectName] { - foundProject = projectName // First matching project - continue // Don't add to remaining tags - } +// ensureTagPresent adds a tag to the list if not already present +func ensureTagPresent(tags []string, tag string) []string { + for _, t := range tags { + if t == tag { + return tags // Already present } - remaining = append(remaining, tag) } - - return foundProject, remaining + return append(tags, tag) } // filterTagCombinationsByProject filters tag combinations to only show those diff --git a/taskwarrior/models.go b/taskwarrior/models.go index dc9dabe..c3b6553 100644 --- a/taskwarrior/models.go +++ b/taskwarrior/models.go @@ -43,12 +43,12 @@ func (a Annotation) String() string { type Tasks []*Task type Task struct { - Id int64 `json:"id,omitempty"` - Uuid string `json:"uuid,omitempty"` - Description string `json:"description,omitempty"` - Project string `json:"project"` - Priority string `json:"priority,omitempty"` - Status string `json:"status,omitempty"` + Id int64 `json:"id,omitempty"` + Uuid string `json:"uuid,omitempty"` + Description string `json:"description,omitempty"` + Project string `json:"project"` + Priority string `json:"priority,omitempty"` + Status string `json:"status,omitempty"` Tags []string `json:"tags"` VirtualTags []string `json:"-"` Depends []string `json:"depends,omitempty"` diff --git a/taskwarrior/models_test.go b/taskwarrior/models_test.go index 8862ef2..f20368b 100644 --- a/taskwarrior/models_test.go +++ b/taskwarrior/models_test.go @@ -58,14 +58,14 @@ func TestTask_GetDate(t *testing.T) { want: parsedValid, }, { - name: "Due date empty", - task: Task{}, + name: "Due date empty", + task: Task{}, field: "due", want: time.Time{}, }, { - name: "Unknown field", - task: Task{Due: validDate}, + name: "Unknown field", + task: Task{Due: validDate}, field: "unknown", want: time.Time{}, }, diff --git a/timewarrior/tags.go b/timewarrior/tags.go index 0250981..c00de37 100644 --- a/timewarrior/tags.go +++ b/timewarrior/tags.go @@ -21,6 +21,9 @@ func ExtractSpecialTags(tags []string) (uuid string, project string, displayTags uuid = strings.TrimPrefix(tag, UUIDPrefix) case strings.HasPrefix(tag, ProjectPrefix): project = strings.TrimPrefix(tag, ProjectPrefix) + case tag == "track": + // Skip the "track" tag - it's internal metadata + continue default: // Regular tag (description or user tag) displayTags = append(displayTags, tag)