4 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
24 changed files with 1847 additions and 182 deletions

View File

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

236
CLAUDE.md Normal file
View File

@ -0,0 +1,236 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
TaskSquire is a Go-based Terminal User Interface (TUI) for Taskwarrior and Timewarrior. It uses the Bubble Tea framework (Model-View-Update pattern) from the Charm ecosystem.
**Key Technologies:**
- Go 1.22.2
- Bubble Tea (MVU pattern)
- Lip Gloss (styling)
- Huh (forms)
- Bubbles (components)
## Build and Development Commands
### Running and Building
```bash
# Run directly
go run main.go
# Build binary
go build -o tasksquire main.go
# Format code (always run before committing)
go fmt ./...
# Vet code
go vet ./...
# Tidy dependencies
go mod tidy
```
### Testing
```bash
# Run all tests
go test ./...
# Run tests for specific package
go test ./taskwarrior
# Run single test
go test ./taskwarrior -run TestTaskSquire_GetContext
# Run with verbose output
go test -v ./...
# Run with coverage
go test -cover ./...
```
### Linting
```bash
# Lint with golangci-lint (via nix-shell)
golangci-lint run
```
### Development Environment
The project uses Nix for development environment setup:
```bash
# Enter Nix development shell
nix develop
# Or use direnv (automatically loads .envrc)
direnv allow
```
## Architecture
### High-Level Structure
TaskSquire follows the MVU (Model-View-Update) pattern with a component-based architecture:
1. **Entry Point (`main.go`)**: Initializes TaskSquire and TimeSquire, creates Common state container, and starts Bubble Tea program
2. **Common State (`common/`)**: Shared state, components interface, keymaps, styles, and utilities
3. **Pages (`pages/`)**: Top-level UI views (report, taskEditor, timePage, pickers)
4. **Components (`components/`)**: Reusable UI widgets (input, table, timetable, picker)
5. **Business Logic**:
- `taskwarrior/`: Wraps Taskwarrior CLI, models, and config parsing
- `timewarrior/`: Wraps Timewarrior CLI, models, and config parsing
### Component System
All UI elements implement the `Component` interface:
```go
type Component interface {
tea.Model // Init(), Update(tea.Msg), View()
SetSize(width int, height int)
}
```
Components can be composed hierarchically. The `Common` struct manages a page stack for navigation.
### Page Navigation Pattern
- **Main Page** (`pages/main.go`): Root page with tab switching between Tasks and Time views
- **Page Stack**: Managed by `common.Common`, allows pushing/popping subpages
- `common.PushPage(page)` - push a new page on top
- `common.PopPage()` - return to previous page
- `common.HasSubpages()` - check if subpages are active
- **Tab Switching**: Only works at top level (when no subpages active)
### State Management
The `common.Common` struct acts as a shared state container:
- `TW`: TaskWarrior interface for task operations
- `TimeW`: TimeWarrior interface for time tracking
- `Keymap`: Centralized key bindings
- `Styles`: Centralized styling (parsed from Taskwarrior config)
- `Udas`: User Defined Attributes from Taskwarrior config
- `pageStack`: Stack-based page navigation
### Taskwarrior Integration
The `TaskWarrior` interface provides all task operations:
- Task CRUD: `GetTasks()`, `ImportTask()`, `SetTaskDone()`
- Task control: `StartTask()`, `StopTask()`, `DeleteTask()`
- Context management: `GetContext()`, `GetContexts()`, `SetContext()`
- Reports: `GetReport()`, `GetReports()`
- Config parsing: Manual parsing of Taskwarrior config format
All Taskwarrior operations use `exec.Command()` to call the `task` CLI binary. Results are parsed from JSON output.
### Timewarrior Integration
The `TimeWarrior` interface provides time tracking operations:
- Interval management: `GetIntervals()`, `ModifyInterval()`, `DeleteInterval()`
- Tracking control: `StartTracking()`, `StopTracking()`, `ContinueTracking()`
- Tag management: `GetTags()`, `GetTagCombinations()`
- Utility: `FillInterval()`, `JoinInterval()`, `Undo()`
Similar to TaskWarrior, uses `exec.Command()` to call the `timew` CLI binary.
### Custom JSON Marshaling
The `Task` struct uses custom `MarshalJSON()` and `UnmarshalJSON()` to handle:
- User Defined Attributes (UDAs) stored in `Udas map[string]any`
- Dynamic field handling via `json.RawMessage`
- Virtual tags (filtered from regular tags)
### Configuration and Environment
- **Taskwarrior Config**: Located via `TASKRC` env var, or fallback to `~/.taskrc` or `~/.config/task/taskrc`
- **Timewarrior Config**: Located via `TIMEWARRIORDB` env var, or fallback to `~/.timewarrior/timewarrior.cfg`
- **Config Parsing**: Custom parser in `taskwarrior/config.go` handles Taskwarrior's config format
- **Theme Colors**: Extracted from Taskwarrior config and used in Lip Gloss styles
### Concurrency
- Both `TaskSquire` and `TimeSquire` use `sync.Mutex` to protect shared state
- Lock pattern: `ts.mu.Lock()` followed by `defer ts.mu.Unlock()`
- Operations are synchronous (no goroutines in typical flows)
### Logging
- Uses `log/slog` for structured logging
- Logs written to `app.log` in current directory
- Errors logged but execution typically continues (graceful degradation)
- Log pattern: `slog.Error("message", "key", value)`
## Code Style and Patterns
### Import Organization
Standard library first, then third-party, then local:
```go
import (
"context"
"fmt"
tea "github.com/charmbracelet/bubbletea"
"tasksquire/common"
"tasksquire/taskwarrior"
)
```
### Naming Conventions
- Exported types: `PascalCase` (e.g., `TaskSquire`, `ReportPage`)
- Unexported fields: `camelCase` (e.g., `configLocation`, `activeReport`)
- Interfaces: Often end in 'er' or describe capability (e.g., `TaskWarrior`, `TimeWarrior`, `Component`)
### Error Handling
- Log errors with `slog.Error()` and continue execution
- Don't panic unless fatal initialization error
- Return errors from functions, don't log and return
### MVU Pattern in Bubble Tea
Components follow the MVU pattern:
- `Init() tea.Cmd`: Initialize and return commands for side effects
- `Update(tea.Msg) (tea.Model, tea.Cmd)`: Handle messages, update state, return commands
- `View() string`: Render UI as string
Custom messages for inter-component communication:
```go
type MyCustomMsg struct {
data string
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case MyCustomMsg:
// Handle custom message
}
return m, nil
}
```
### Styling with Lip Gloss
- Centralized styles in `common/styles.go`
- Theme colors parsed from Taskwarrior config
- Create reusable style functions, not inline styles
### Testing Patterns
- Table-driven tests with struct slices
- Helper functions like `TaskWarriorTestSetup()`
- Use `t.TempDir()` for isolated test environments
- Include `prep func()` in test cases for setup
## Important Implementation Details
### Virtual Tags
Taskwarrior has virtual tags (ACTIVE, BLOCKED, etc.) that are filtered out from regular tags. See the `virtualTags` map in `taskwarrior/taskwarrior.go`.
### Non-Standard Reports
Some Taskwarrior reports require special handling (burndown, calendar, etc.). See `nonStandardReports` map.
### Timestamp Format
Taskwarrior uses ISO 8601 format: `20060102T150405Z` (defined as `dtformat` constant)
### Color Parsing
Custom color parsing from Taskwarrior config format in `common/styles.go`
### VSCode Debugging
Launch configuration available for remote debugging on port 43000 (see `.vscode/launch.json`)

View File

@ -6,31 +6,33 @@ import (
// Keymap is a collection of key bindings. // Keymap is a collection of key bindings.
type Keymap struct { type Keymap struct {
Quit key.Binding Quit key.Binding
Back key.Binding Back key.Binding
Ok key.Binding Ok key.Binding
Delete key.Binding Delete key.Binding
Input key.Binding Input key.Binding
Add key.Binding Add key.Binding
Edit key.Binding Edit key.Binding
Up key.Binding Up key.Binding
Down key.Binding Down key.Binding
Left key.Binding Left key.Binding
Right key.Binding Right key.Binding
Next key.Binding Next key.Binding
Prev key.Binding Prev key.Binding
NextPage key.Binding NextPage key.Binding
PrevPage key.Binding PrevPage key.Binding
SetReport key.Binding SetReport key.Binding
SetContext key.Binding SetContext key.Binding
SetProject key.Binding SetProject key.Binding
Select key.Binding PickProjectTask key.Binding
Insert key.Binding Select key.Binding
Tag key.Binding Insert key.Binding
Undo key.Binding Tag key.Binding
Fill key.Binding Undo key.Binding
StartStop key.Binding Fill key.Binding
Join key.Binding StartStop 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"),
),
} }
} }

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

@ -36,6 +36,7 @@ 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 focused bool
} }
@ -54,6 +55,12 @@ 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 { func (p *Picker) Focus() tea.Cmd {
p.focused = true p.focused = true
return nil return nil
@ -88,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(
@ -95,6 +103,19 @@ 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,
@ -112,16 +133,24 @@ func New(
opt(p) opt(p)
} }
if p.filterByDefault { // If a default value is provided, don't start in filter mode
// Manually trigger filter mode on the list so it doesn't require a global key press if p.defaultValue != "" {
var cmd tea.Cmd p.filterByDefault = false
p.list, cmd = p.list.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
// We can ignore the command here as it's likely just for blinking, which will happen on Init anyway
_ = cmd
} }
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
} }
@ -131,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)
@ -162,7 +195,9 @@ func (p *Picker) SetSize(width, height int) {
} }
func (p *Picker) Init() tea.Cmd { func (p *Picker) Init() tea.Cmd {
return nil // Trigger list item update to ensure items are properly displayed,
// especially when in filter mode with an empty filter
return p.updateListItems()
} }
func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@ -171,17 +206,31 @@ func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
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
} }
@ -195,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 {

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

@ -12,8 +12,8 @@ type MainPage struct {
common *common.Common common *common.Common
activePage common.Component activePage common.Component
taskPage common.Component taskPage common.Component
timePage common.Component timePage common.Component
currentTab int currentTab int
width int width int
height int height int

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
} }
@ -38,11 +42,13 @@ func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
// } // }
p := &ReportPage{ p := &ReportPage{
common: com, common: com,
activeReport: report, activeReport: report,
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,34 +193,70 @@ 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
p.taskTable, cmd = p.taskTable.Update(msg)
cmds = append(cmds, cmd)
if p.tasks != nil && len(p.tasks) > 0 { // Route keyboard messages to details viewer when panel is active
p.selectedTask = p.tasks[p.taskTable.Cursor()] 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 { } else {
p.selectedTask = nil // Route to table when details panel not active
p.taskTable, cmd = p.taskTable.Update(msg)
cmds = append(cmds, cmd)
if p.tasks != nil && len(p.tasks) > 0 {
p.selectedTask = p.tasks[p.taskTable.Cursor()]
} else {
p.selectedTask = nil
}
} }
return p, tea.Batch(cmds...) 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

@ -459,15 +459,27 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task, isNew bool) *taskEd
onSelect := func(item list.Item) tea.Cmd { onSelect := func(item list.Item) tea.Cmd {
return nil 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{} opts := []picker.PickerOption{picker.WithOnCreate(onCreate)}
if isNew {
// Check if task has a pre-filled project (e.g., from ProjectTaskPickerPage)
hasPrefilledProject := task.Project != "" && task.Project != "(none)"
if isNew && !hasPrefilledProject {
// New task with no project → start in filter mode for quick project search
opts = append(opts, picker.WithFilterByDefault(true)) 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 := picker.New(com, "Project", itemProvider, onSelect, opts...)
projPicker.SetSize(70, 8) projPicker.SetSize(70, 8)
projPicker.SelectItemByFilterValue(task.Project)
projPicker.Blur() projPicker.Blur()
defaultKeymap := huh.NewDefaultKeyMap() defaultKeymap := huh.NewDefaultKeyMap()

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,18 +166,33 @@ 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)
p.saveInterval() break
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, tea.Batch(tea.Batch(cmds...), refreshIntervals)
} }
// If picker is focused, let it handle the key below
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()
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, tea.Batch(tea.Batch(cmds...), refreshIntervals)
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,22 +379,27 @@ 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
if p.selectedProject != "" { var tags []string
projectExists := false
for _, tag := range tags { // Add preserved special tags first
if tag == p.selectedProject { if p.uuid != "" {
projectExists = true tags = append(tags, "uuid:"+p.uuid)
break
}
}
if !projectExists {
tags = append([]string{p.selectedProject}, tags...) // Prepend project
}
} }
if p.track != "" {
tags = append(tags, p.track)
}
// Add project tag
if p.selectedProject != "" {
tags = append(tags, "project:"+p.selectedProject)
}
// Add display tags (user-entered tags from the input field)
tags = append(tags, displayTags...)
p.interval.Tags = tags p.interval.Tags = tags
@ -403,26 +445,39 @@ func formatTags(tags []string) string {
return strings.Join(formatted, " ") return strings.Join(formatted, " ")
} }
// extractProjectFromTags finds and removes the first tag that matches a known project // extractSpecialTags separates special tags (uuid, project, track) from display tags
// Returns the found project (or empty string) and the remaining tags // Returns uuid, project, track as separate strings, and displayTags for user editing
func extractProjectFromTags(tags []string, projects []string) (string, []string) { func extractSpecialTags(tags []string) (uuid, project, track string, displayTags []string) {
projectSet := make(map[string]bool)
for _, p := range projects {
projectSet[p] = true
}
var foundProject string
var remaining []string
for _, tag := range tags { for _, tag := range tags {
if foundProject == "" && projectSet[tag] { if strings.HasPrefix(tag, "uuid:") {
foundProject = tag // First matching project uuid = strings.TrimPrefix(tag, "uuid:")
} else if strings.HasPrefix(tag, "project:") {
project = strings.TrimPrefix(tag, "project:")
} else if tag == "track" {
track = tag
} else { } else {
remaining = append(remaining, tag) displayTags = append(displayTags, tag)
} }
} }
return
}
return foundProject, remaining // extractProjectFromTags finds and removes the first tag that matches a known project
// Returns the found project (or empty string) and the remaining tags
// This is kept for backward compatibility but now uses extractSpecialTags internally
func extractProjectFromTags(tags []string, projects []string) (string, []string) {
_, project, _, remaining := extractSpecialTags(tags)
return project, remaining
}
// ensureTagPresent adds a tag to the list if not already present
func ensureTagPresent(tags []string, tag string) []string {
for _, t := range tags {
if t == tag {
return tags // Already present
}
}
return append(tags, tag)
} }
// 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,7 +396,8 @@ 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: "Tags", Name: "tags", Width: 0}, // flexible width {Title: "Project", Name: "project", Width: 0}, // flexible width
{Title: "Tags", Name: "tags", Width: 0}, // flexible width
} }
// Calculate table height: total height - header (1 line) - blank line (1) - safety (1) // Calculate table height: total height - header (1 line) - blank line (1) - safety (1)
@ -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

@ -43,11 +43,11 @@ func (a Annotation) String() string {
type Tasks []*Task type Tasks []*Task
type Task struct { type Task struct {
Id int64 `json:"id,omitempty"` Id int64 `json:"id,omitempty"`
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