Add things

This commit is contained in:
Martin Pander
2026-02-04 13:13:04 +01:00
parent 474bb3dc07
commit 02fa2e503a
15 changed files with 1214 additions and 126 deletions

View File

@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(ls:*)",
"Bash(go fmt:*)",
"Bash(go build:*)",
"Bash(go vet:*)",
"Bash(timew export:*)"
]
}
}

236
CLAUDE.md Normal file
View File

@ -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`)

View File

@ -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"),
),
}
}

View File

@ -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
}

View File

@ -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,

38
go.mod
View File

@ -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
)

75
go.sum
View File

@ -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=

View File

@ -82,3 +82,7 @@ func doTick() tea.Cmd {
return tickMsg(t)
})
}
type TaskPickedMsg struct {
Task *taskwarrior.Task
}

465
pages/projectTaskPicker.go Normal file
View File

@ -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
}

View File

@ -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 {

View File

@ -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))
}

View File

@ -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

View File

@ -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"`

View File

@ -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{},
},

View File

@ -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)