9 Commits

Author SHA1 Message Date
02fa2e503a Add things 2026-02-04 13:13:04 +01:00
474bb3dc07 Add project tracking picker 2026-02-03 20:59:47 +01:00
1ffcf42773 Fix bugs 2026-02-03 20:13:09 +01:00
44ddbc0f47 Add syncing 2026-02-03 16:04:47 +01:00
2e33893e29 Merge branch 'feat/taskedit' into feat/time 2026-02-03 07:40:11 +01:00
46ce91196a Merge branch 'feat/task' into feat/time 2026-02-03 07:39:59 +01:00
70b6ee9bc7 Add picker to task edit 2026-02-02 20:43:08 +01:00
2baf3859fd Add tab bar 2026-02-02 19:47:18 +01:00
2940711b26 Make task details scrollable 2026-02-02 19:39:02 +01:00
26 changed files with 2087 additions and 216 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

@ -24,6 +24,7 @@ type Keymap struct {
SetReport key.Binding SetReport key.Binding
SetContext key.Binding SetContext key.Binding
SetProject key.Binding SetProject key.Binding
PickProjectTask key.Binding
Select key.Binding Select key.Binding
Insert key.Binding Insert key.Binding
Tag key.Binding Tag key.Binding
@ -31,6 +32,7 @@ type Keymap struct {
Fill key.Binding Fill key.Binding
StartStop key.Binding StartStop key.Binding
Join key.Binding Join key.Binding
ViewDetails key.Binding
} }
// TODO: use config values for key bindings // TODO: use config values for key bindings
@ -127,6 +129,11 @@ func NewKeymap() *Keymap {
key.WithHelp("p", "Set project"), key.WithHelp("p", "Set project"),
), ),
PickProjectTask: key.NewBinding(
key.WithKeys("P"),
key.WithHelp("P", "Pick project task"),
),
Select: key.NewBinding( Select: key.NewBinding(
key.WithKeys(" "), key.WithKeys(" "),
key.WithHelp("space", "Select"), key.WithHelp("space", "Select"),
@ -161,5 +168,10 @@ func NewKeymap() *Keymap {
key.WithKeys("J"), key.WithKeys("J"),
key.WithHelp("J", "Join with previous"), key.WithHelp("J", "Join with previous"),
), ),
ViewDetails: key.NewBinding(
key.WithKeys("v"),
key.WithHelp("v", "view details"),
),
} }
} }

View File

@ -27,6 +27,10 @@ type Styles struct {
Form *huh.Theme Form *huh.Theme
TableStyle TableStyle TableStyle TableStyle
Tab lipgloss.Style
ActiveTab lipgloss.Style
TabBar lipgloss.Style
ColumnFocused lipgloss.Style ColumnFocused lipgloss.Style
ColumnBlurred lipgloss.Style ColumnBlurred lipgloss.Style
ColumnInsert lipgloss.Style ColumnInsert lipgloss.Style
@ -71,6 +75,19 @@ func NewStyles(config *taskwarrior.TWConfig) *Styles {
styles.Form = formTheme styles.Form = formTheme
styles.Tab = lipgloss.NewStyle().
Padding(0, 1).
Foreground(lipgloss.Color("240"))
styles.ActiveTab = styles.Tab.
Foreground(lipgloss.Color("252")).
Bold(true)
styles.TabBar = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, true, false).
BorderForeground(lipgloss.Color("240")).
MarginBottom(1)
styles.ColumnFocused = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1) styles.ColumnFocused = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)
styles.ColumnBlurred = lipgloss.NewStyle().Border(lipgloss.HiddenBorder(), true).Padding(1) styles.ColumnBlurred = lipgloss.NewStyle().Border(lipgloss.HiddenBorder(), true).Padding(1)
styles.ColumnInsert = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1) styles.ColumnInsert = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)

85
common/sync.go Normal file
View File

@ -0,0 +1,85 @@
package common
import (
"log/slog"
"tasksquire/taskwarrior"
"tasksquire/timewarrior"
)
// FindTaskByUUID queries Taskwarrior for a task with the given UUID.
// Returns nil if not found.
func FindTaskByUUID(tw taskwarrior.TaskWarrior, uuid string) *taskwarrior.Task {
if uuid == "" {
return nil
}
// Use empty report to query by UUID filter
report := &taskwarrior.Report{Name: ""}
tasks := tw.GetTasks(report, "uuid:"+uuid)
if len(tasks) > 0 {
return tasks[0]
}
return nil
}
// SyncIntervalToTask synchronizes a Timewarrior interval's state to the corresponding Taskwarrior task.
// Action should be "start" or "stop".
// This function is idempotent and handles edge cases gracefully.
func SyncIntervalToTask(interval *timewarrior.Interval, tw taskwarrior.TaskWarrior, action string) {
if interval == nil {
return
}
// Extract UUID from interval tags
uuid := timewarrior.ExtractUUID(interval.Tags)
if uuid == "" {
slog.Debug("Interval has no UUID tag, skipping task sync",
"intervalID", interval.ID)
return
}
// Find corresponding task
task := FindTaskByUUID(tw, uuid)
if task == nil {
slog.Warn("Task not found for UUID, skipping sync",
"uuid", uuid)
return
}
// Perform sync action
switch action {
case "start":
// Start task if it's pending (idempotent - taskwarrior handles already-started tasks)
if task.Status == "pending" {
slog.Info("Starting Taskwarrior task from interval",
"uuid", uuid,
"description", task.Description,
"alreadyStarted", task.Start != "")
tw.StartTask(task)
} else {
slog.Debug("Task not pending, skipping start",
"uuid", uuid,
"status", task.Status)
}
case "stop":
// Only stop if task is pending and currently started
if task.Status == "pending" && task.Start != "" {
slog.Info("Stopping Taskwarrior task from interval",
"uuid", uuid,
"description", task.Description)
tw.StopTask(task)
} else {
slog.Debug("Task not started or not pending, skipping stop",
"uuid", uuid,
"status", task.Status,
"hasStart", task.Start != "")
}
default:
slog.Error("Unknown sync action", "action", action)
}
}

View File

@ -89,6 +89,11 @@ func (a *Autocomplete) SetSuggestions(suggestions []string) {
a.updateFilteredSuggestions() a.updateFilteredSuggestions()
} }
// HasSuggestions returns true if the autocomplete is currently showing suggestions
func (a *Autocomplete) HasSuggestions() bool {
return a.showSuggestions && len(a.filteredSuggestions) > 0
}
// Init initializes the autocomplete // Init initializes the autocomplete
func (a *Autocomplete) Init() tea.Cmd { func (a *Autocomplete) Init() tea.Cmd {
return textinput.Blink return textinput.Blink

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

@ -637,6 +637,11 @@ func (m *MultiSelect) GetValue() any {
return *m.value return *m.value
} }
// IsFiltering returns true if the multi-select is currently filtering.
func (m *MultiSelect) IsFiltering() bool {
return m.filtering
}
func min(a, b int) int { func min(a, b int) int {
if a < b { if a < b {
return a return a

View File

@ -36,7 +36,9 @@ type Picker struct {
onCreate func(string) tea.Cmd onCreate func(string) tea.Cmd
title string title string
filterByDefault bool filterByDefault bool
defaultValue string
baseItems []list.Item baseItems []list.Item
focused bool
} }
type PickerOption func(*Picker) type PickerOption func(*Picker)
@ -53,6 +55,30 @@ func WithOnCreate(onCreate func(string) tea.Cmd) PickerOption {
} }
} }
func WithDefaultValue(value string) PickerOption {
return func(p *Picker) {
p.defaultValue = value
}
}
func (p *Picker) Focus() tea.Cmd {
p.focused = true
return nil
}
func (p *Picker) Blur() tea.Cmd {
p.focused = false
return nil
}
func (p *Picker) GetValue() string {
item := p.list.SelectedItem()
if item == nil {
return ""
}
return item.FilterValue()
}
func New( func New(
c *common.Common, c *common.Common,
title string, title string,
@ -69,6 +95,7 @@ func New(
l.SetShowHelp(false) l.SetShowHelp(false)
l.SetShowStatusBar(false) l.SetShowStatusBar(false)
l.SetFilteringEnabled(true) l.SetFilteringEnabled(true)
l.Filter = list.UnsortedFilter // Preserve item order, don't rank by match quality
// Custom key for filtering (insert mode) // Custom key for filtering (insert mode)
l.KeyMap.Filter = key.NewBinding( l.KeyMap.Filter = key.NewBinding(
@ -76,12 +103,26 @@ func New(
key.WithHelp("i", "filter"), 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{ p := &Picker{
common: c, common: c,
list: l, list: l,
itemProvider: itemProvider, itemProvider: itemProvider,
onSelect: onSelect, onSelect: onSelect,
title: title, title: title,
focused: true,
} }
if c.TW.GetConfig().Get("uda.tasksquire.picker.filter_by_default") == "yes" { if c.TW.GetConfig().Get("uda.tasksquire.picker.filter_by_default") == "yes" {
@ -92,8 +133,24 @@ func New(
opt(p) opt(p)
} }
// If a default value is provided, don't start in filter mode
if p.defaultValue != "" {
p.filterByDefault = false
}
if p.filterByDefault {
// Manually trigger filter mode on the list so it doesn't require a global key press
p.list, _ = p.list.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
}
// Refresh items after entering filter mode to ensure they're visible
p.Refresh() p.Refresh()
// If a default value is provided, select the corresponding item
if p.defaultValue != "" {
p.SelectItemByFilterValue(p.defaultValue)
}
return p return p
} }
@ -103,18 +160,22 @@ func (p *Picker) Refresh() tea.Cmd {
} }
func (p *Picker) updateListItems() tea.Cmd { func (p *Picker) updateListItems() tea.Cmd {
items := p.baseItems return p.updateListItemsWithFilter(p.list.FilterValue())
filterVal := p.list.FilterValue() }
func (p *Picker) updateListItemsWithFilter(filterVal string) tea.Cmd {
items := make([]list.Item, 0, len(p.baseItems)+1)
// First add all base items
items = append(items, p.baseItems...)
if p.onCreate != nil && filterVal != "" { if p.onCreate != nil && filterVal != "" {
// Add the creation item at the end (bottom of the list)
newItem := creationItem{ newItem := creationItem{
text: "(new) " + filterVal, text: "(new) " + filterVal,
filter: filterVal, filter: filterVal,
} }
newItems := make([]list.Item, len(items)+1) items = append(items, newItem)
copy(newItems, items)
newItems[len(items)] = newItem
items = newItems
} }
return p.list.SetItems(items) return p.list.SetItems(items)
@ -134,27 +195,42 @@ func (p *Picker) SetSize(width, height int) {
} }
func (p *Picker) Init() tea.Cmd { func (p *Picker) Init() tea.Cmd {
if p.filterByDefault { // Trigger list item update to ensure items are properly displayed,
return func() tea.Msg { // especially when in filter mode with an empty filter
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}} return p.updateListItems()
}
}
return nil
} }
func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !p.focused {
return p, nil
}
var cmd tea.Cmd var cmd tea.Cmd
var cmds []tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
// If filtering, let the list handle keys (including Enter to stop filtering) // If filtering, update items with predicted filter before list processes the key
if p.list.FilterState() == list.Filtering { if p.list.FilterState() == list.Filtering {
if key.Matches(msg, p.common.Keymap.Ok) { currentFilter := p.list.FilterValue()
items := p.list.VisibleItems() predictedFilter := currentFilter
if len(items) == 1 {
return p, p.handleSelect(items[0]) // Predict what the filter will be after this key
switch msg.Type {
case tea.KeyRunes:
predictedFilter = currentFilter + string(msg.Runes)
case tea.KeyBackspace:
if len(currentFilter) > 0 {
predictedFilter = currentFilter[:len(currentFilter)-1]
} }
} }
// Update items with predicted filter before list processes the message
if predictedFilter != currentFilter {
preCmd := p.updateListItemsWithFilter(predictedFilter)
cmds = append(cmds, preCmd)
}
break // Pass to list.Update break // Pass to list.Update
} }
@ -168,15 +244,10 @@ func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
} }
prevFilter := p.list.FilterValue()
p.list, cmd = p.list.Update(msg) p.list, cmd = p.list.Update(msg)
cmds = append(cmds, cmd)
if p.list.FilterValue() != prevFilter { return p, tea.Batch(cmds...)
updateCmd := p.updateListItems()
return p, tea.Batch(cmd, updateCmd)
}
return p, cmd
} }
func (p *Picker) handleSelect(item list.Item) tea.Cmd { func (p *Picker) handleSelect(item list.Item) tea.Cmd {
@ -189,7 +260,12 @@ func (p *Picker) handleSelect(item list.Item) tea.Cmd {
} }
func (p *Picker) View() string { func (p *Picker) View() string {
title := p.common.Styles.Form.Focused.Title.Render(p.title) var title string
if p.focused {
title = p.common.Styles.Form.Focused.Title.Render(p.title)
} else {
title = p.common.Styles.Form.Blurred.Title.Render(p.title)
}
return lipgloss.JoinVertical(lipgloss.Left, title, p.list.View()) return lipgloss.JoinVertical(lipgloss.Left, title, p.list.View())
} }

38
go.mod
View File

@ -1,39 +1,51 @@
module tasksquire module tasksquire
go 1.22.2 go 1.23.0
toolchain go1.24.12
require ( require (
github.com/charmbracelet/bubbles v0.18.0 github.com/charmbracelet/bubbles v0.18.0
github.com/charmbracelet/bubbletea v0.26.4 github.com/charmbracelet/bubbletea v0.26.4
github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/huh v0.4.2 github.com/charmbracelet/huh v0.4.2
github.com/charmbracelet/lipgloss v0.11.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/mattn/go-runewidth v0.0.15 github.com/mattn/go-runewidth v0.0.16
golang.org/x/term v0.21.0 github.com/sahilm/fuzzy v0.1.1
golang.org/x/term v0.31.0
) )
require ( require (
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // 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/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/strings v0.0.0-20240524151031-ff83003bf67a // indirect
github.com/charmbracelet/x/exp/term v0.0.0-20240606154654-7c42867b53c7 // 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.2.1 // indirect
github.com/charmbracelet/x/term v0.1.1 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/charmbracelet/x/windows v0.1.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 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/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // 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/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // 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/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.7.0 // indirect github.com/yuin/goldmark v1.7.8 // indirect
golang.org/x/sys v0.21.0 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect
golang.org/x/text v0.16.0 // 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 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 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 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 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 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= 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 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= 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 h1:2gDkkzLZaTjMl/dQBpNVtnvcCxsh/FCkimep7FC9c40=
github.com/charmbracelet/bubbletea v0.26.4/go.mod h1:P+r+RRA5qtI1DOHNFn0otoNwB4rn+zNAzSj/EXz6xU0= 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 h1:5wLkwrA58XDAfEZsJzNQlfJ+K8N9+wYwvR5FOM7jXFM=
github.com/charmbracelet/huh v0.4.2/go.mod h1:g9OXBgtY3zRV4ahnVih9bZE+1yGYN+y2C9Q6L2P+WM0= 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 v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY= github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= 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 h1:lOpqe2UvPmlln41DGoii7wlSZ/q8qGIon5JJ8Biu46I=
github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= 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 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/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/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/input v0.1.1/go.mod h1:jvdTVUnNWj/RD6hjC4FsoB0SeZCJ2ZBkiuFP9zXvZI0= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 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/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 h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 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 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 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= 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 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 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.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 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 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 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 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 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 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 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.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 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.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.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= 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/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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 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 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 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/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=

118
on-modify.timewarrior Normal file
View File

@ -0,0 +1,118 @@
#!/usr/bin/env python3
###############################################################################
#
# Copyright 2016 - 2021, 2023, Gothenburg Bit Factory
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included
# in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# https://www.opensource.org/licenses/mit-license.php
#
###############################################################################
import json
import subprocess
import sys
# Hook should extract all the following for use as Timewarrior tags:
# UUID
# Project
# Tags
# Description
# UDAs
try:
input_stream = sys.stdin.buffer
except AttributeError:
input_stream = sys.stdin
def extract_tags_from(json_obj):
# Extract attributes for use as tags.
tags = [json_obj['description']]
# Add UUID with prefix for reliable task linking
if 'uuid' in json_obj:
tags.append('uuid:' + json_obj['uuid'])
# Add project with prefix for separate column display
if 'project' in json_obj:
tags.append('project:' + json_obj['project'])
if 'tags' in json_obj:
if type(json_obj['tags']) is str:
# Usage of tasklib (e.g. in taskpirate) converts the tag list into a string
# If this is the case, convert it back into a list first
# See https://github.com/tbabej/taskpirate/issues/11
task_tags = [tag for tag in json_obj['tags'].split(',') if tag != 'next']
tags.extend(task_tags)
else:
# Filter out the 'next' tag
task_tags = [tag for tag in json_obj['tags'] if tag != 'next']
tags.extend(task_tags)
return tags
def extract_annotation_from(json_obj):
if 'annotations' not in json_obj:
return '\'\''
return json_obj['annotations'][0]['description']
def main(old, new):
start_or_stop = ''
# Started task.
if 'start' in new and 'start' not in old:
start_or_stop = 'start'
# Stopped task.
elif ('start' not in new or 'end' in new) and 'start' in old:
start_or_stop = 'stop'
if start_or_stop:
tags = extract_tags_from(new)
subprocess.call(['timew', start_or_stop] + tags + [':yes'])
# Modifications to task other than start/stop
elif 'start' in new and 'start' in old:
old_tags = extract_tags_from(old)
new_tags = extract_tags_from(new)
if old_tags != new_tags:
subprocess.call(['timew', 'untag', '@1'] + old_tags + [':yes'])
subprocess.call(['timew', 'tag', '@1'] + new_tags + [':yes'])
old_annotation = extract_annotation_from(old)
new_annotation = extract_annotation_from(new)
if old_annotation != new_annotation:
subprocess.call(['timew', 'annotate', '@1', new_annotation])
if __name__ == "__main__":
old = json.loads(input_stream.readline().decode("utf-8", errors="replace"))
new = json.loads(input_stream.readline().decode("utf-8", errors="replace"))
print(json.dumps(new))
main(old, new)

View File

@ -5,6 +5,7 @@ import (
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
) )
type MainPage struct { type MainPage struct {
@ -13,6 +14,9 @@ type MainPage struct {
taskPage common.Component taskPage common.Component
timePage common.Component timePage common.Component
currentTab int
width int
height int
} }
func NewMainPage(common *common.Common) *MainPage { func NewMainPage(common *common.Common) *MainPage {
@ -24,6 +28,7 @@ func NewMainPage(common *common.Common) *MainPage {
m.timePage = NewTimePage(common) m.timePage = NewTimePage(common)
m.activePage = m.taskPage m.activePage = m.taskPage
m.currentTab = 0
return m return m
} }
@ -37,17 +42,39 @@ func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.common.SetSize(msg.Width, msg.Height) m.common.SetSize(msg.Width, msg.Height)
tabHeight := lipgloss.Height(m.renderTabBar())
contentHeight := msg.Height - tabHeight
if contentHeight < 0 {
contentHeight = 0
}
newMsg := tea.WindowSizeMsg{Width: msg.Width, Height: contentHeight}
activePage, cmd := m.activePage.Update(newMsg)
m.activePage = activePage.(common.Component)
return m, cmd
case tea.KeyMsg: case tea.KeyMsg:
// Only handle tab key for page switching when at the top level (no subpages active) // Only handle tab key for page switching when at the top level (no subpages active)
if key.Matches(msg, m.common.Keymap.Next) && !m.common.HasSubpages() { if key.Matches(msg, m.common.Keymap.Next) && !m.common.HasSubpages() {
if m.activePage == m.taskPage { if m.activePage == m.taskPage {
m.activePage = m.timePage m.activePage = m.timePage
m.currentTab = 1
} else { } else {
m.activePage = m.taskPage m.activePage = m.taskPage
m.currentTab = 0
} }
// Re-size the new active page just in case
m.activePage.SetSize(m.common.Width(), m.common.Height()) tabHeight := lipgloss.Height(m.renderTabBar())
contentHeight := m.height - tabHeight
if contentHeight < 0 {
contentHeight = 0
}
m.activePage.SetSize(m.width, contentHeight)
// Trigger a refresh/init on switch? Maybe not needed if we keep state. // Trigger a refresh/init on switch? Maybe not needed if we keep state.
// But we might want to refresh data. // But we might want to refresh data.
return m, m.activePage.Init() return m, m.activePage.Init()
@ -60,6 +87,22 @@ func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd return m, cmd
} }
func (m *MainPage) View() string { func (m *MainPage) renderTabBar() string {
return m.activePage.View() var tabs []string
headers := []string{"Tasks", "Time"}
for i, header := range headers {
style := m.common.Styles.Tab
if m.currentTab == i {
style = m.common.Styles.ActiveTab
}
tabs = append(tabs, style.Render(header))
}
row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
return m.common.Styles.TabBar.Width(m.common.Width()).Render(row)
}
func (m *MainPage) View() string {
return lipgloss.JoinVertical(lipgloss.Left, m.renderTabBar(), m.activePage.View())
} }

View File

@ -82,3 +82,7 @@ func doTick() tea.Cmd {
return tickMsg(t) 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 ( import (
"tasksquire/common" "tasksquire/common"
"tasksquire/components/detailsviewer"
"tasksquire/components/table" "tasksquire/components/table"
"tasksquire/taskwarrior" "tasksquire/taskwarrior"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
// "github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
) )
type ReportPage struct { type ReportPage struct {
@ -25,6 +25,10 @@ type ReportPage struct {
taskTable table.Model taskTable table.Model
// Details panel state
detailsPanelActive bool
detailsViewer *detailsviewer.DetailsViewer
subpage common.Component subpage common.Component
} }
@ -43,6 +47,8 @@ func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
activeContext: com.TW.GetActiveContext(), activeContext: com.TW.GetActiveContext(),
activeProject: "", activeProject: "",
taskTable: table.New(com), taskTable: table.New(com),
detailsPanelActive: false,
detailsViewer: detailsviewer.New(com),
} }
return p return p
@ -51,8 +57,39 @@ func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
func (p *ReportPage) SetSize(width int, height int) { func (p *ReportPage) SetSize(width int, height int) {
p.common.SetSize(width, height) p.common.SetSize(width, height)
p.taskTable.SetWidth(width - p.common.Styles.Base.GetHorizontalFrameSize()) baseHeight := height - p.common.Styles.Base.GetVerticalFrameSize()
p.taskTable.SetHeight(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 { func (p *ReportPage) Init() tea.Cmd {
@ -82,9 +119,23 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case UpdateProjectMsg: case UpdateProjectMsg:
p.activeProject = string(msg) p.activeProject = string(msg)
cmds = append(cmds, p.getTasks()) cmds = append(cmds, p.getTasks())
case TaskPickedMsg:
if msg.Task != nil && msg.Task.Status == "pending" {
p.common.TW.StopActiveTasks()
p.common.TW.StartTask(msg.Task)
}
cmds = append(cmds, p.getTasks())
case UpdatedTasksMsg: case UpdatedTasksMsg:
cmds = append(cmds, p.getTasks()) cmds = append(cmds, p.getTasks())
case tea.KeyMsg: 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 { switch {
case key.Matches(msg, p.common.Keymap.Quit): case key.Matches(msg, p.common.Keymap.Quit):
return p, tea.Quit return p, tea.Quit
@ -119,6 +170,11 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmd := p.subpage.Init() cmd := p.subpage.Init()
p.common.PushPage(p) p.common.PushPage(p)
return p.subpage, cmd return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.PickProjectTask):
p.subpage = NewProjectTaskPickerPage(p.common)
cmd := p.subpage.Init()
p.common.PushPage(p)
return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.Tag): case key.Matches(msg, p.common.Keymap.Tag):
if p.selectedTask != nil { if p.selectedTask != nil {
tag := p.common.TW.GetConfig().Get("uda.tasksquire.tag.default") tag := p.common.TW.GetConfig().Get("uda.tasksquire.tag.default")
@ -137,16 +193,40 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, p.common.Keymap.StartStop): case key.Matches(msg, p.common.Keymap.StartStop):
if p.selectedTask != nil && p.selectedTask.Status == "pending" { if p.selectedTask != nil && p.selectedTask.Status == "pending" {
if p.selectedTask.Start == "" { if p.selectedTask.Start == "" {
p.common.TW.StopActiveTasks()
p.common.TW.StartTask(p.selectedTask) p.common.TW.StartTask(p.selectedTask)
} else { } else {
p.common.TW.StopTask(p.selectedTask) p.common.TW.StopTask(p.selectedTask)
} }
return p, p.getTasks() 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 var cmd tea.Cmd
// 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 {
// Route to table when details panel not active
p.taskTable, cmd = p.taskTable.Update(msg) p.taskTable, cmd = p.taskTable.Update(msg)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
@ -155,16 +235,28 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else { } else {
p.selectedTask = nil p.selectedTask = nil
} }
}
return p, tea.Batch(cmds...) return p, tea.Batch(cmds...)
} }
func (p *ReportPage) View() string { func (p *ReportPage) View() string {
// return p.common.Styles.Main.Render(p.taskTable.View()) + "\n"
if p.tasks == nil || len(p.tasks) == 0 { if p.tasks == nil || len(p.tasks) == 0 {
return p.common.Styles.Base.Render("No tasks found") 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) { func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) {
@ -185,13 +277,27 @@ func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) {
selected = len(tasks) - 1 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.taskTable = table.New(
p.common, p.common,
table.WithReport(p.activeReport), table.WithReport(p.activeReport),
table.WithTasks(tasks), table.WithTasks(tasks),
table.WithFocused(true), table.WithFocused(true),
table.WithWidth(p.common.Width()-p.common.Styles.Base.GetVerticalFrameSize()), table.WithWidth(baseWidth),
table.WithHeight(p.common.Height()-p.common.Styles.Base.GetHorizontalFrameSize()-1), table.WithHeight(tableHeight),
table.WithStyles(p.common.Styles.TableStyle), table.WithStyles(p.common.Styles.TableStyle),
) )
@ -203,6 +309,11 @@ func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) {
} else { } else {
p.taskTable.SetCursor(len(p.tasks) - 1) 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 { func (p *ReportPage) getTasks() tea.Cmd {

View File

@ -8,6 +8,7 @@ import (
"time" "time"
"tasksquire/components/input" "tasksquire/components/input"
"tasksquire/components/picker"
"tasksquire/components/timestampeditor" "tasksquire/components/timestampeditor"
"tasksquire/taskwarrior" "tasksquire/taskwarrior"
@ -41,6 +42,8 @@ type TaskEditorPage struct {
area int area int
areaPicker *areaPicker areaPicker *areaPicker
areas []area areas []area
infoViewport viewport.Model
} }
func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPage { func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPage {
@ -56,7 +59,7 @@ func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPag
tagOptions := p.common.TW.GetTags() tagOptions := p.common.TW.GetTags()
p.areas = []area{ p.areas = []area{
NewTaskEdit(p.common, &p.task), NewTaskEdit(p.common, &p.task, p.task.Uuid == ""),
NewTagEdit(p.common, &p.task.Tags, tagOptions), NewTagEdit(p.common, &p.task.Tags, tagOptions),
NewTimeEdit(p.common, &p.task.Due, &p.task.Scheduled, &p.task.Wait, &p.task.Until), NewTimeEdit(p.common, &p.task.Due, &p.task.Scheduled, &p.task.Wait, &p.task.Until),
NewDetailsEdit(p.common, &p.task), NewDetailsEdit(p.common, &p.task),
@ -68,6 +71,11 @@ func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPag
p.areaPicker = NewAreaPicker(com, []string{"Task", "Tags", "Dates"}) p.areaPicker = NewAreaPicker(com, []string{"Task", "Tags", "Dates"})
p.infoViewport = viewport.New(0, 0)
if p.task.Uuid != "" {
p.infoViewport.SetContent(p.common.TW.GetInformation(&p.task))
}
p.columnCursor = 1 p.columnCursor = 1
if p.task.Uuid == "" { if p.task.Uuid == "" {
p.mode = modeInsert p.mode = modeInsert
@ -94,10 +102,20 @@ func (p *TaskEditorPage) SetSize(width, height int) {
} else { } else {
p.colHeight = height - p.common.Styles.ColumnFocused.GetVerticalFrameSize() p.colHeight = height - p.common.Styles.ColumnFocused.GetVerticalFrameSize()
} }
p.infoViewport.Width = width - p.colWidth - p.common.Styles.ColumnFocused.GetHorizontalFrameSize()*2 - 5
if p.infoViewport.Width < 0 {
p.infoViewport.Width = 0
}
p.infoViewport.Height = p.colHeight
} }
func (p *TaskEditorPage) Init() tea.Cmd { func (p *TaskEditorPage) Init() tea.Cmd {
return nil var cmds []tea.Cmd
for _, a := range p.areas {
cmds = append(cmds, a.Init())
}
return tea.Batch(cmds...)
} }
func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@ -110,12 +128,20 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
p.mode = mode(msg) p.mode = mode(msg)
case prevColumnMsg: case prevColumnMsg:
p.columnCursor-- p.columnCursor--
maxCols := 2
if p.task.Uuid != "" {
maxCols = 3
}
if p.columnCursor < 0 { if p.columnCursor < 0 {
p.columnCursor = len(p.areas) - 1 p.columnCursor = maxCols - 1
} }
case nextColumnMsg: case nextColumnMsg:
p.columnCursor++ p.columnCursor++
if p.columnCursor > len(p.areas)-1 { maxCols := 2
if p.task.Uuid != "" {
maxCols = 3
}
if p.columnCursor >= maxCols {
p.columnCursor = 0 p.columnCursor = 0
} }
case prevAreaMsg: case prevAreaMsg:
@ -166,20 +192,26 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
picker, cmd := p.areaPicker.Update(msg) picker, cmd := p.areaPicker.Update(msg)
p.areaPicker = picker.(*areaPicker) p.areaPicker = picker.(*areaPicker)
return p, cmd return p, cmd
} else { } else if p.columnCursor == 1 {
model, cmd := p.areas[p.area].Update(prevFieldMsg{}) model, cmd := p.areas[p.area].Update(prevFieldMsg{})
p.areas[p.area] = model.(area) p.areas[p.area] = model.(area)
return p, cmd return p, cmd
} else if p.columnCursor == 2 {
p.infoViewport.LineUp(1)
return p, nil
} }
case key.Matches(msg, p.common.Keymap.Down): case key.Matches(msg, p.common.Keymap.Down):
if p.columnCursor == 0 { if p.columnCursor == 0 {
picker, cmd := p.areaPicker.Update(msg) picker, cmd := p.areaPicker.Update(msg)
p.areaPicker = picker.(*areaPicker) p.areaPicker = picker.(*areaPicker)
return p, cmd return p, cmd
} else { } else if p.columnCursor == 1 {
model, cmd := p.areas[p.area].Update(nextFieldMsg{}) model, cmd := p.areas[p.area].Update(nextFieldMsg{})
p.areas[p.area] = model.(area) p.areas[p.area] = model.(area)
return p, cmd return p, cmd
} else if p.columnCursor == 2 {
p.infoViewport.LineDown(1)
return p, nil
} }
} }
} }
@ -212,25 +244,31 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
picker, cmd := p.areaPicker.Update(msg) picker, cmd := p.areaPicker.Update(msg)
p.areaPicker = picker.(*areaPicker) p.areaPicker = picker.(*areaPicker)
return p, cmd return p, cmd
} else { } else if p.columnCursor == 1 {
model, cmd := p.areas[p.area].Update(prevFieldMsg{}) model, cmd := p.areas[p.area].Update(prevFieldMsg{})
p.areas[p.area] = model.(area) p.areas[p.area] = model.(area)
return p, cmd return p, cmd
} }
return p, nil
case key.Matches(msg, p.common.Keymap.Next): case key.Matches(msg, p.common.Keymap.Next):
if p.columnCursor == 0 { if p.columnCursor == 0 {
picker, cmd := p.areaPicker.Update(msg) picker, cmd := p.areaPicker.Update(msg)
p.areaPicker = picker.(*areaPicker) p.areaPicker = picker.(*areaPicker)
return p, cmd return p, cmd
} else { } else if p.columnCursor == 1 {
model, cmd := p.areas[p.area].Update(nextFieldMsg{}) model, cmd := p.areas[p.area].Update(nextFieldMsg{})
p.areas[p.area] = model.(area) p.areas[p.area] = model.(area)
return p, cmd return p, cmd
} }
return p, nil
case key.Matches(msg, p.common.Keymap.Ok): case key.Matches(msg, p.common.Keymap.Ok):
isFiltering := p.areas[p.area].IsFiltering()
model, cmd := p.areas[p.area].Update(msg) model, cmd := p.areas[p.area].Update(msg)
if p.area != 3 { if p.area != 3 {
p.areas[p.area] = model.(area) p.areas[p.area] = model.(area)
if isFiltering {
return p, cmd
}
return p, tea.Batch(cmd, nextField()) return p, tea.Batch(cmd, nextField())
} }
return p, cmd return p, cmd
@ -241,6 +279,10 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
picker, cmd := p.areaPicker.Update(msg) picker, cmd := p.areaPicker.Update(msg)
p.areaPicker = picker.(*areaPicker) p.areaPicker = picker.(*areaPicker)
return p, cmd return p, cmd
} else if p.columnCursor == 2 {
var cmd tea.Cmd
p.infoViewport, cmd = p.infoViewport.Update(msg)
return p, cmd
} else { } else {
model, cmd := p.areas[p.area].Update(msg) model, cmd := p.areas[p.area].Update(msg)
p.areas[p.area] = model.(area) p.areas[p.area] = model.(area)
@ -253,29 +295,31 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (p *TaskEditorPage) View() string { func (p *TaskEditorPage) View() string {
var focusedStyle, blurredStyle lipgloss.Style var focusedStyle, blurredStyle lipgloss.Style
if p.mode == modeInsert { if p.mode == modeInsert {
focusedStyle = p.common.Styles.ColumnInsert.Width(p.colWidth).Height(p.colHeight) focusedStyle = p.common.Styles.ColumnInsert
} else { } else {
focusedStyle = p.common.Styles.ColumnFocused.Width(p.colWidth).Height(p.colHeight) focusedStyle = p.common.Styles.ColumnFocused
} }
blurredStyle = p.common.Styles.ColumnBlurred.Width(p.colWidth).Height(p.colHeight) blurredStyle = p.common.Styles.ColumnBlurred
// var picker, area string
var area string
if p.columnCursor == 0 {
// picker = focusedStyle.Render(p.areaPicker.View())
area = blurredStyle.Render(p.areas[p.area].View())
} else {
// picker = blurredStyle.Render(p.areaPicker.View())
area = focusedStyle.Render(p.areas[p.area].View())
var area string
if p.columnCursor == 1 {
area = focusedStyle.Copy().Width(p.colWidth).Height(p.colHeight).Render(p.areas[p.area].View())
} else {
area = blurredStyle.Copy().Width(p.colWidth).Height(p.colHeight).Render(p.areas[p.area].View())
} }
if p.task.Uuid != "" { if p.task.Uuid != "" {
var infoView string
if p.columnCursor == 2 {
infoView = focusedStyle.Copy().Width(p.infoViewport.Width).Height(p.infoViewport.Height).Render(p.infoViewport.View())
} else {
infoView = blurredStyle.Copy().Width(p.infoViewport.Width).Height(p.infoViewport.Height).Render(p.infoViewport.View())
}
area = lipgloss.JoinHorizontal( area = lipgloss.JoinHorizontal(
lipgloss.Top, lipgloss.Top,
area, area,
p.common.Styles.ColumnFocused.Render(p.common.TW.GetInformation(&p.task)), infoView,
) )
} }
tabs := "" tabs := ""
@ -305,8 +349,11 @@ type area interface {
tea.Model tea.Model
SetCursor(c int) SetCursor(c int)
GetName() string GetName() string
IsFiltering() bool
} }
type focusMsg struct{}
type areaPicker struct { type areaPicker struct {
common *common.Common common *common.Common
list list.Model list list.Model
@ -378,26 +425,66 @@ func (a *areaPicker) View() string {
return a.list.View() return a.list.View()
} }
type EditableField interface {
tea.Model
Focus() tea.Cmd
Blur() tea.Cmd
}
type taskEdit struct { type taskEdit struct {
common *common.Common common *common.Common
fields []huh.Field fields []EditableField
cursor int cursor int
projectPicker *picker.Picker
// newProjectName *string // newProjectName *string
newAnnotation *string newAnnotation *string
udaValues map[string]*string udaValues map[string]*string
} }
func NewTaskEdit(com *common.Common, task *taskwarrior.Task) *taskEdit { func NewTaskEdit(com *common.Common, task *taskwarrior.Task, isNew bool) *taskEdit {
// newProject := "" // newProject := ""
projectOptions := append([]string{"(none)"}, com.TW.GetProjects()...)
if task.Project == "" { if task.Project == "" {
task.Project = "(none)" task.Project = "(none)"
} }
itemProvider := func() []list.Item {
projects := com.TW.GetProjects()
items := []list.Item{picker.NewItem("(none)")}
for _, proj := range projects {
items = append(items, picker.NewItem(proj))
}
return items
}
onSelect := func(item list.Item) tea.Cmd {
return nil
}
onCreate := func(newProject string) tea.Cmd {
// The new project name will be used as the project value
// when the task is saved
return nil
}
opts := []picker.PickerOption{picker.WithOnCreate(onCreate)}
// 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))
}
projPicker := picker.New(com, "Project", itemProvider, onSelect, opts...)
projPicker.SetSize(70, 8)
projPicker.Blur()
defaultKeymap := huh.NewDefaultKeyMap() defaultKeymap := huh.NewDefaultKeyMap()
fields := []huh.Field{ fields := []EditableField{
huh.NewInput(). huh.NewInput().
Title("Task"). Title("Task").
Value(&task.Description). Value(&task.Description).
@ -411,12 +498,7 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task) *taskEdit {
Prompt(": "). Prompt(": ").
WithTheme(com.Styles.Form), WithTheme(com.Styles.Form),
input.NewSelect(com). projPicker,
Options(true, input.NewOptions(projectOptions...)...).
Title("Project").
Value(&task.Project).
WithKeyMap(defaultKeymap).
WithTheme(com.Styles.Form),
// huh.NewInput(). // huh.NewInput().
// Title("New Project"). // Title("New Project").
@ -511,6 +593,7 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task) *taskEdit {
t := taskEdit{ t := taskEdit{
common: com, common: com,
fields: fields, fields: fields,
projectPicker: projPicker,
udaValues: udaValues, udaValues: udaValues,
@ -527,6 +610,13 @@ func (t *taskEdit) GetName() string {
return "Task" return "Task"
} }
func (t *taskEdit) IsFiltering() bool {
if f, ok := t.fields[t.cursor].(interface{ IsFiltering() bool }); ok {
return f.IsFiltering()
}
return false
}
func (t *taskEdit) SetCursor(c int) { func (t *taskEdit) SetCursor(c int) {
t.fields[t.cursor].Blur() t.fields[t.cursor].Blur()
if c < 0 { if c < 0 {
@ -538,11 +628,25 @@ func (t *taskEdit) SetCursor(c int) {
} }
func (t *taskEdit) Init() tea.Cmd { func (t *taskEdit) Init() tea.Cmd {
return nil var cmds []tea.Cmd
// Ensure focus on the active field (especially for the first one)
if len(t.fields) > 0 {
cmds = append(cmds, func() tea.Msg {
return focusMsg{}
})
}
for _, f := range t.fields {
cmds = append(cmds, f.Init())
}
return tea.Batch(cmds...)
} }
func (t *taskEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (t *taskEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) { switch msg.(type) {
case focusMsg:
if len(t.fields) > 0 {
return t, t.fields[t.cursor].Focus()
}
case nextFieldMsg: case nextFieldMsg:
if t.cursor == len(t.fields)-1 { if t.cursor == len(t.fields)-1 {
t.fields[t.cursor].Blur() t.fields[t.cursor].Blur()
@ -561,7 +665,7 @@ func (t *taskEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
t.fields[t.cursor].Focus() t.fields[t.cursor].Focus()
default: default:
field, cmd := t.fields[t.cursor].Update(msg) field, cmd := t.fields[t.cursor].Update(msg)
t.fields[t.cursor] = field.(huh.Field) t.fields[t.cursor] = field.(EditableField)
return t, cmd return t, cmd
} }
@ -624,6 +728,13 @@ func (t *tagEdit) GetName() string {
return "Tags" return "Tags"
} }
func (t *tagEdit) IsFiltering() bool {
if f, ok := t.fields[t.cursor].(interface{ IsFiltering() bool }); ok {
return f.IsFiltering()
}
return false
}
func (t *tagEdit) SetCursor(c int) { func (t *tagEdit) SetCursor(c int) {
t.fields[t.cursor].Blur() t.fields[t.cursor].Blur()
if c < 0 { if c < 0 {
@ -736,6 +847,10 @@ func (t *timeEdit) GetName() string {
return "Dates" return "Dates"
} }
func (t *timeEdit) IsFiltering() bool {
return false
}
func (t *timeEdit) SetCursor(c int) { func (t *timeEdit) SetCursor(c int) {
if len(t.fields) == 0 { if len(t.fields) == 0 {
return return
@ -868,6 +983,10 @@ func (d *detailsEdit) GetName() string {
return "Details" return "Details"
} }
func (d *detailsEdit) IsFiltering() bool {
return false
}
func (d *detailsEdit) SetCursor(c int) { func (d *detailsEdit) SetCursor(c int) {
} }
@ -1035,6 +1154,8 @@ func (d *detailsEdit) View() string {
// } // }
func (p *TaskEditorPage) updateTasksCmd() tea.Msg { func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
p.task.Project = p.areas[0].(*taskEdit).projectPicker.GetValue()
if p.task.Project == "(none)" { if p.task.Project == "(none)" {
p.task.Project = "" p.task.Project = ""
} }

View File

@ -30,6 +30,8 @@ type TimeEditorPage struct {
selectedProject string selectedProject string
currentField int currentField int
totalFields int totalFields int
uuid string // Preserved UUID tag
track string // Preserved track tag (if present)
} }
type timeEditorProjectSelectedMsg struct { type timeEditorProjectSelectedMsg struct {
@ -37,11 +39,18 @@ type timeEditorProjectSelectedMsg struct {
} }
func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage { func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage {
// Extract project from tags if it exists // Extract special tags (uuid, project, track) and display tags
projects := com.TW.GetProjects() uuid, selectedProject, track, displayTags := extractSpecialTags(interval.Tags)
selectedProject, remainingTags := extractProjectFromTags(interval.Tags, projects)
if selectedProject == "" && len(projects) > 0 { // If UUID exists, fetch the task and add its title to display tags
selectedProject = projects[0] // Default to first project (required) 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 // Create project picker with onCreate support for new projects
@ -66,18 +75,23 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time
} }
} }
opts := []picker.PickerOption{
picker.WithOnCreate(projectOnCreate),
}
if selectedProject != "" {
opts = append(opts, picker.WithDefaultValue(selectedProject))
} else {
opts = append(opts, picker.WithFilterByDefault(true))
}
projectPicker := picker.New( projectPicker := picker.New(
com, com,
"Project", "Project",
projectItemProvider, projectItemProvider,
projectOnSelect, projectOnSelect,
picker.WithOnCreate(projectOnCreate), opts...,
picker.WithFilterByDefault(true),
) )
projectPicker.SetSize(50, 10) // Compact size for inline use projectPicker.SetSize(50, 10) // Compact size for inline use
if selectedProject != "" {
projectPicker.SelectItemByFilterValue(selectedProject)
}
// Create start timestamp editor // Create start timestamp editor
startEditor := timestampeditor.New(com). startEditor := timestampeditor.New(com).
@ -97,7 +111,7 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time
tagsInput := autocomplete.New(tagCombinations, 3, 10) tagsInput := autocomplete.New(tagCombinations, 3, 10)
tagsInput.SetPlaceholder("Space separated, use \"\" for tags with spaces") 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) tagsInput.SetWidth(50)
p := &TimeEditorPage{ p := &TimeEditorPage{
@ -111,6 +125,8 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time
selectedProject: selectedProject, selectedProject: selectedProject,
currentField: 0, currentField: 0,
totalFields: 5, // Now 5 fields: project, tags, start, end, adjust totalFields: 5, // Now 5 fields: project, tags, start, end, adjust
uuid: uuid,
track: track,
} }
return p return p
@ -129,8 +145,14 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case timeEditorProjectSelectedMsg: case timeEditorProjectSelectedMsg:
// Update selected project // Update selected project
p.selectedProject = msg.project p.selectedProject = msg.project
// Blur current field (project picker)
p.blurCurrentField()
// Advance to tags field
p.currentField = 1
// Refresh tag autocomplete with filtered combinations // Refresh tag autocomplete with filtered combinations
cmds = append(cmds, p.updateTagSuggestions()) cmds = append(cmds, p.updateTagSuggestions())
// Focus tags input
cmds = append(cmds, p.focusCurrentField())
return p, tea.Batch(cmds...) return p, tea.Batch(cmds...)
case tea.KeyMsg: case tea.KeyMsg:
@ -144,9 +166,26 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return model, BackCmd return model, BackCmd
case key.Matches(msg, p.common.Keymap.Ok): case key.Matches(msg, p.common.Keymap.Ok):
// Don't save if the project picker is focused - let it handle Enter // Handle Enter based on current field
if p.currentField != 0 { if p.currentField == 0 {
// Save and exit // Project picker - let it handle Enter (will trigger projectSelectedMsg)
break
}
if p.currentField == 1 {
// Tags field
if p.tagsInput.HasSuggestions() {
// Let autocomplete handle suggestion selection
break
}
// Tags confirmed without suggestions - advance to start timestamp
p.blurCurrentField()
p.currentField = 2
cmds = append(cmds, p.focusCurrentField())
return p, tea.Batch(cmds...)
}
// For all other fields (2-4: start, end, adjust), save and exit
p.saveInterval() p.saveInterval()
model, err := p.common.PopPage() model, err := p.common.PopPage()
if err != nil { if err != nil {
@ -154,8 +193,6 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return nil, tea.Quit return nil, tea.Quit
} }
return model, tea.Batch(tea.Batch(cmds...), refreshIntervals) return model, tea.Batch(tea.Batch(cmds...), refreshIntervals)
}
// If picker is focused, let it handle the key below
case key.Matches(msg, p.common.Keymap.Next): case key.Matches(msg, p.common.Keymap.Next):
// Move to next field // Move to next field
@ -342,23 +379,28 @@ func (p *TimeEditorPage) saveInterval() {
p.interval.Start = p.startEditor.GetValueString() p.interval.Start = p.startEditor.GetValueString()
p.interval.End = p.endEditor.GetValueString() p.interval.End = p.endEditor.GetValueString()
// Parse tags from input // Parse display tags from input
tags := parseTags(p.tagsInput.GetValue()) displayTags := parseTags(p.tagsInput.GetValue())
// Add project to tags if not already present // 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 != "" { if p.selectedProject != "" {
projectExists := false tags = append(tags, "project:"+p.selectedProject)
for _, tag := range tags {
if tag == p.selectedProject {
projectExists = true
break
}
}
if !projectExists {
tags = append([]string{p.selectedProject}, tags...) // Prepend project
}
} }
// Add display tags (user-entered tags from the input field)
tags = append(tags, displayTags...)
p.interval.Tags = tags p.interval.Tags = tags
err := p.common.TimeW.ModifyInterval(p.interval, p.adjust) err := p.common.TimeW.ModifyInterval(p.interval, p.adjust)
@ -403,26 +445,39 @@ func formatTags(tags []string) string {
return strings.Join(formatted, " ") 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 // extractProjectFromTags finds and removes the first tag that matches a known project
// Returns the found project (or empty string) and the remaining tags // 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) { func extractProjectFromTags(tags []string, projects []string) (string, []string) {
projectSet := make(map[string]bool) _, project, _, remaining := extractSpecialTags(tags)
for _, p := range projects { return project, remaining
projectSet[p] = true
} }
var foundProject string // ensureTagPresent adds a tag to the list if not already present
var remaining []string func ensureTagPresent(tags []string, tag string) []string {
for _, t := range tags {
for _, tag := range tags { if t == tag {
if foundProject == "" && projectSet[tag] { return tags // Already present
foundProject = tag // First matching project
} else {
remaining = append(remaining, tag)
} }
} }
return append(tags, tag)
return foundProject, remaining
} }
// filterTagCombinationsByProject filters tag combinations to only show those // filterTagCombinationsByProject filters tag combinations to only show those
@ -432,6 +487,8 @@ func filterTagCombinationsByProject(combinations []string, project string) []str
return combinations return combinations
} }
projectTag := "project:" + project
var filtered []string var filtered []string
for _, combo := range combinations { for _, combo := range combinations {
// Parse the combination into individual tags // Parse the combination into individual tags
@ -439,11 +496,11 @@ func filterTagCombinationsByProject(combinations []string, project string) []str
// Check if project exists in this combination // Check if project exists in this combination
for _, tag := range tags { for _, tag := range tags {
if tag == project { if tag == projectTag {
// Found the project - now remove it from display // Found the project - now remove it from display
var displayTags []string var displayTags []string
for _, t := range tags { for _, t := range tags {
if t != project { if t != projectTag {
displayTags = append(displayTags, t) displayTags = append(displayTags, t)
} }
} }

View File

@ -21,6 +21,7 @@ type TimePage struct {
data timewarrior.Intervals data timewarrior.Intervals
shouldSelectActive bool shouldSelectActive bool
pendingSyncAction string // "start", "stop", or "" (empty means no pending action)
selectedTimespan string selectedTimespan string
subpage common.Component subpage common.Component
@ -163,8 +164,27 @@ func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case intervalsMsg: case intervalsMsg:
p.data = timewarrior.Intervals(msg) p.data = timewarrior.Intervals(msg)
p.populateTable(p.data) p.populateTable(p.data)
// If we have a pending sync action (from continuing an interval),
// execute it now that intervals are refreshed
if p.pendingSyncAction != "" {
action := p.pendingSyncAction
p.pendingSyncAction = ""
cmds = append(cmds, p.syncActiveIntervalAfterRefresh(action))
}
case TaskPickedMsg:
if msg.Task != nil && msg.Task.Status == "pending" {
p.common.TW.StopActiveTasks()
p.common.TW.StartTask(msg.Task)
cmds = append(cmds, p.getIntervals())
cmds = append(cmds, doTick())
}
case RefreshIntervalsMsg: case RefreshIntervalsMsg:
cmds = append(cmds, p.getIntervals()) cmds = append(cmds, p.getIntervals())
cmds = append(cmds, doTick())
case BackMsg:
// Restart tick loop when returning from subpage
cmds = append(cmds, doTick())
case tickMsg: case tickMsg:
cmds = append(cmds, p.getIntervals()) cmds = append(cmds, p.getIntervals())
cmds = append(cmds, doTick()) cmds = append(cmds, doTick())
@ -178,17 +198,42 @@ func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmd := p.subpage.Init() cmd := p.subpage.Init()
p.common.PushPage(p) p.common.PushPage(p)
return p.subpage, cmd return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.PickProjectTask):
p.subpage = NewProjectTaskPickerPage(p.common)
cmd := p.subpage.Init()
p.common.PushPage(p)
return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.StartStop): case key.Matches(msg, p.common.Keymap.StartStop):
row := p.intervals.SelectedRow() row := p.intervals.SelectedRow()
if row != nil { if row != nil {
interval := (*timewarrior.Interval)(row) interval := (*timewarrior.Interval)(row)
// Validate interval before proceeding
if interval.IsGap {
slog.Debug("Cannot start/stop gap interval")
return p, nil
}
if interval.IsActive() { if interval.IsActive() {
// Stop tracking
p.common.TimeW.StopTracking() p.common.TimeW.StopTracking()
// Sync: stop corresponding Taskwarrior task immediately (interval has UUID)
common.SyncIntervalToTask(interval, p.common.TW, "stop")
} else { } else {
// Continue tracking - creates a NEW interval
slog.Info("Continuing interval for task sync",
"intervalID", interval.ID,
"hasUUID", timewarrior.ExtractUUID(interval.Tags) != "",
"uuid", timewarrior.ExtractUUID(interval.Tags))
p.common.TimeW.ContinueInterval(interval.ID) p.common.TimeW.ContinueInterval(interval.ID)
p.shouldSelectActive = true p.shouldSelectActive = true
// Set pending sync action instead of syncing immediately
// This ensures we sync AFTER intervals are refreshed
p.pendingSyncAction = "start"
} }
return p, tea.Batch(p.getIntervals(), doTick()) cmds = append(cmds, p.getIntervals())
cmds = append(cmds, doTick())
return p, tea.Batch(cmds...)
} }
case key.Matches(msg, p.common.Keymap.Delete): case key.Matches(msg, p.common.Keymap.Delete):
row := p.intervals.SelectedRow() row := p.intervals.SelectedRow()
@ -351,6 +396,7 @@ func (p *TimePage) populateTable(intervals timewarrior.Intervals) {
{Title: "Start", Name: startField, Width: startEndWidth}, {Title: "Start", Name: startField, Width: startEndWidth},
{Title: "End", Name: endField, Width: startEndWidth}, {Title: "End", Name: endField, Width: startEndWidth},
{Title: "Duration", Name: "duration", Width: 10}, {Title: "Duration", Name: "duration", Width: 10},
{Title: "Project", Name: "project", Width: 0}, // flexible width
{Title: "Tags", Name: "tags", Width: 0}, // flexible width {Title: "Tags", Name: "tags", Width: 0}, // flexible width
} }
@ -419,3 +465,34 @@ func (p *TimePage) getIntervals() tea.Cmd {
return intervalsMsg(intervals) return intervalsMsg(intervals)
} }
} }
// syncActiveInterval creates a command that syncs the currently active interval to Taskwarrior
func (p *TimePage) syncActiveInterval(action string) tea.Cmd {
return func() tea.Msg {
// Get the currently active interval
activeInterval := p.common.TimeW.GetActive()
if activeInterval != nil {
common.SyncIntervalToTask(activeInterval, p.common.TW, action)
}
return nil
}
}
// syncActiveIntervalAfterRefresh is called AFTER intervals have been refreshed
// to ensure we're working with current data
func (p *TimePage) syncActiveIntervalAfterRefresh(action string) tea.Cmd {
return func() tea.Msg {
// At this point, intervals have been refreshed, so GetActive() will work
activeInterval := p.common.TimeW.GetActive()
if activeInterval != nil {
slog.Info("Syncing active interval to task after refresh",
"action", action,
"intervalID", activeInterval.ID,
"hasUUID", timewarrior.ExtractUUID(activeInterval.Tags) != "")
common.SyncIntervalToTask(activeInterval, p.common.TW, action)
} else {
slog.Warn("No active interval found after refresh, cannot sync to task")
}
return nil
}
}

View File

@ -47,7 +47,7 @@ type Task struct {
Uuid string `json:"uuid,omitempty"` Uuid string `json:"uuid,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Project string `json:"project"` Project string `json:"project"`
// Priority string `json:"priority"` Priority string `json:"priority,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
VirtualTags []string `json:"-"` VirtualTags []string `json:"-"`
@ -149,8 +149,8 @@ func (t *Task) GetString(fieldWFormat string) string {
} }
return t.Project return t.Project
// case "priority": case "priority":
// return t.Priority return t.Priority
case "status": case "status":
return t.Status return t.Status
@ -233,7 +233,33 @@ func (t *Task) GetString(fieldWFormat string) string {
return "" return ""
} }
func (t *Task) GetDate(dateString string) time.Time { func (t *Task) GetDate(field string) time.Time {
var dateString string
switch field {
case "due":
dateString = t.Due
case "wait":
dateString = t.Wait
case "scheduled":
dateString = t.Scheduled
case "until":
dateString = t.Until
case "start":
dateString = t.Start
case "end":
dateString = t.End
case "entry":
dateString = t.Entry
case "modified":
dateString = t.Modified
default:
return time.Time{}
}
if dateString == "" {
return time.Time{}
}
dt, err := time.Parse(dtformat, dateString) dt, err := time.Parse(dtformat, dateString)
if err != nil { if err != nil {
slog.Error("Failed to parse time:", err) slog.Error("Failed to parse time:", err)

View File

@ -0,0 +1,81 @@
package taskwarrior
import (
"testing"
"time"
)
func TestTask_GetString(t *testing.T) {
tests := []struct {
name string
task Task
fieldWFormat string
want string
}{
{
name: "Priority",
task: Task{
Priority: "H",
},
fieldWFormat: "priority",
want: "H",
},
{
name: "Description",
task: Task{
Description: "Buy milk",
},
fieldWFormat: "description.desc",
want: "Buy milk",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.task.GetString(tt.fieldWFormat); got != tt.want {
t.Errorf("Task.GetString() = %v, want %v", got, tt.want)
}
})
}
}
func TestTask_GetDate(t *testing.T) {
validDate := "20230101T120000Z"
parsedValid, _ := time.Parse("20060102T150405Z", validDate)
tests := []struct {
name string
task Task
field string
want time.Time
}{
{
name: "Due date valid",
task: Task{
Due: validDate,
},
field: "due",
want: parsedValid,
},
{
name: "Due date empty",
task: Task{},
field: "due",
want: time.Time{},
},
{
name: "Unknown field",
task: Task{Due: validDate},
field: "unknown",
want: time.Time{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.task.GetDate(tt.field); !got.Equal(tt.want) {
t.Errorf("Task.GetDate() = %v, want %v", got, tt.want)
}
})
}
}

View File

@ -98,6 +98,7 @@ type TaskWarrior interface {
DeleteTask(task *Task) DeleteTask(task *Task)
StartTask(task *Task) StartTask(task *Task)
StopTask(task *Task) StopTask(task *Task)
StopActiveTasks()
GetInformation(task *Task) string GetInformation(task *Task) string
AddTaskAnnotation(uuid string, annotation string) AddTaskAnnotation(uuid string, annotation string)
@ -146,7 +147,7 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
args := ts.defaultArgs args := ts.defaultArgs
if report.Context { if report != nil && report.Context {
for _, context := range ts.contexts { for _, context := range ts.contexts {
if context.Active && context.Name != "none" { if context.Active && context.Name != "none" {
args = append(args, context.ReadFilter) args = append(args, context.ReadFilter)
@ -159,7 +160,12 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
args = append(args, filter...) args = append(args, filter...)
} }
cmd := exec.Command(twBinary, append(args, []string{"export", report.Name}...)...) exportArgs := []string{"export"}
if report != nil && report.Name != "" {
exportArgs = append(exportArgs, report.Name)
}
cmd := exec.Command(twBinary, append(args, exportArgs...)...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed getting report:", err) slog.Error("Failed getting report:", err)
@ -490,6 +496,33 @@ func (ts *TaskSquire) StopTask(task *Task) {
} }
} }
func (ts *TaskSquire) StopActiveTasks() {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"+ACTIVE", "export"}...)...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting active tasks:", "error", err, "output", string(output))
return
}
tasks := make(Tasks, 0)
err = json.Unmarshal(output, &tasks)
if err != nil {
slog.Error("Failed unmarshalling active tasks:", err)
return
}
for _, task := range tasks {
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
err := cmd.Run()
if err != nil {
slog.Error("Failed stopping task:", err)
}
}
}
func (ts *TaskSquire) GetInformation(task *Task) string { func (ts *TaskSquire) GetInformation(task *Task) string {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()

Binary file not shown.

View File

@ -83,7 +83,16 @@ func (i *Interval) GetString(field string) string {
if len(i.Tags) == 0 { if len(i.Tags) == 0 {
return "" return ""
} }
return strings.Join(i.Tags, " ") // Extract and filter special tags (uuid:, project:)
_, _, displayTags := ExtractSpecialTags(i.Tags)
return strings.Join(displayTags, " ")
case "project":
project := ExtractProject(i.Tags)
if project == "" {
return "(none)"
}
return project
case "duration": case "duration":
return i.GetDuration() return i.GetDuration()

54
timewarrior/tags.go Normal file
View File

@ -0,0 +1,54 @@
package timewarrior
import (
"strings"
)
// Special tag prefixes for metadata
const (
UUIDPrefix = "uuid:"
ProjectPrefix = "project:"
)
// ExtractSpecialTags parses tags and separates special prefixed tags from display tags.
// Returns: uuid, project, and remaining display tags (description + user tags)
func ExtractSpecialTags(tags []string) (uuid string, project string, displayTags []string) {
displayTags = make([]string, 0, len(tags))
for _, tag := range tags {
switch {
case strings.HasPrefix(tag, UUIDPrefix):
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)
}
}
return uuid, project, displayTags
}
// ExtractUUID extracts just the UUID from tags (for sync operations)
func ExtractUUID(tags []string) string {
for _, tag := range tags {
if strings.HasPrefix(tag, UUIDPrefix) {
return strings.TrimPrefix(tag, UUIDPrefix)
}
}
return ""
}
// ExtractProject extracts just the project name from tags
func ExtractProject(tags []string) string {
for _, tag := range tags {
if strings.HasPrefix(tag, ProjectPrefix) {
return strings.TrimPrefix(tag, ProjectPrefix)
}
}
return ""
}

View File

@ -142,10 +142,9 @@ func formatTagsForCombination(tags []string) string {
return strings.Join(formatted, " ") return strings.Join(formatted, " ")
} }
func (ts *TimeSquire) GetIntervals(filter ...string) Intervals { // getIntervalsUnlocked fetches intervals without acquiring mutex (for internal use only).
ts.mutex.Lock() // Caller must hold ts.mutex.
defer ts.mutex.Unlock() func (ts *TimeSquire) getIntervalsUnlocked(filter ...string) Intervals {
args := append(ts.defaultArgs, "export") args := append(ts.defaultArgs, "export")
if filter != nil { if filter != nil {
args = append(args, filter...) args = append(args, filter...)
@ -176,6 +175,14 @@ func (ts *TimeSquire) GetIntervals(filter ...string) Intervals {
return intervals return intervals
} }
// GetIntervals fetches intervals with the given filter, acquiring mutex lock.
func (ts *TimeSquire) GetIntervals(filter ...string) Intervals {
ts.mutex.Lock()
defer ts.mutex.Unlock()
return ts.getIntervalsUnlocked(filter...)
}
func (ts *TimeSquire) StartTracking(tags []string) error { func (ts *TimeSquire) StartTracking(tags []string) error {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
@ -347,8 +354,8 @@ func (ts *TimeSquire) GetActive() *Interval {
return nil return nil
} }
// Get the active interval // Get the active interval using unlocked version (we already hold the mutex)
intervals := ts.GetIntervals() intervals := ts.getIntervalsUnlocked()
for _, interval := range intervals { for _, interval := range intervals {
if interval.End == "" { if interval.End == "" {
return interval return interval