Add things
This commit is contained in:
12
.claude/settings.local.json
Normal file
12
.claude/settings.local.json
Normal 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
236
CLAUDE.md
Normal 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`)
|
||||
@ -6,32 +6,33 @@ import (
|
||||
|
||||
// Keymap is a collection of key bindings.
|
||||
type Keymap struct {
|
||||
Quit key.Binding
|
||||
Back key.Binding
|
||||
Ok key.Binding
|
||||
Delete key.Binding
|
||||
Input key.Binding
|
||||
Add key.Binding
|
||||
Edit key.Binding
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Left key.Binding
|
||||
Right key.Binding
|
||||
Next key.Binding
|
||||
Prev key.Binding
|
||||
NextPage key.Binding
|
||||
PrevPage key.Binding
|
||||
SetReport key.Binding
|
||||
SetContext key.Binding
|
||||
SetProject key.Binding
|
||||
Quit key.Binding
|
||||
Back key.Binding
|
||||
Ok key.Binding
|
||||
Delete key.Binding
|
||||
Input key.Binding
|
||||
Add key.Binding
|
||||
Edit key.Binding
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Left key.Binding
|
||||
Right key.Binding
|
||||
Next key.Binding
|
||||
Prev key.Binding
|
||||
NextPage key.Binding
|
||||
PrevPage key.Binding
|
||||
SetReport key.Binding
|
||||
SetContext key.Binding
|
||||
SetProject key.Binding
|
||||
PickProjectTask key.Binding
|
||||
Select key.Binding
|
||||
Insert key.Binding
|
||||
Tag key.Binding
|
||||
Undo key.Binding
|
||||
Fill key.Binding
|
||||
StartStop key.Binding
|
||||
Join key.Binding
|
||||
Select key.Binding
|
||||
Insert key.Binding
|
||||
Tag key.Binding
|
||||
Undo key.Binding
|
||||
Fill key.Binding
|
||||
StartStop key.Binding
|
||||
Join key.Binding
|
||||
ViewDetails key.Binding
|
||||
}
|
||||
|
||||
// TODO: use config values for key bindings
|
||||
@ -167,5 +168,10 @@ func NewKeymap() *Keymap {
|
||||
key.WithKeys("J"),
|
||||
key.WithHelp("J", "Join with previous"),
|
||||
),
|
||||
|
||||
ViewDetails: key.NewBinding(
|
||||
key.WithKeys("v"),
|
||||
key.WithHelp("v", "view details"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
174
components/detailsviewer/detailsviewer.go
Normal file
174
components/detailsviewer/detailsviewer.go
Normal 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
|
||||
}
|
||||
@ -103,6 +103,19 @@ func New(
|
||||
key.WithHelp("i", "filter"),
|
||||
)
|
||||
|
||||
// Disable the quit key binding - we don't want Esc to quit the list
|
||||
// Esc should only cancel filtering mode
|
||||
l.KeyMap.Quit = key.NewBinding(
|
||||
key.WithKeys(), // No keys bound
|
||||
key.WithHelp("", ""),
|
||||
)
|
||||
|
||||
// Also disable force quit
|
||||
l.KeyMap.ForceQuit = key.NewBinding(
|
||||
key.WithKeys(), // No keys bound
|
||||
key.WithHelp("", ""),
|
||||
)
|
||||
|
||||
p := &Picker{
|
||||
common: c,
|
||||
list: l,
|
||||
|
||||
38
go.mod
38
go.mod
@ -1,39 +1,51 @@
|
||||
module tasksquire
|
||||
|
||||
go 1.22.2
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.12
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbles v0.18.0
|
||||
github.com/charmbracelet/bubbletea v0.26.4
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/charmbracelet/huh v0.4.2
|
||||
github.com/charmbracelet/lipgloss v0.11.0
|
||||
github.com/mattn/go-runewidth v0.0.15
|
||||
golang.org/x/term v0.21.0
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||
github.com/mattn/go-runewidth v0.0.16
|
||||
github.com/sahilm/fuzzy v0.1.1
|
||||
golang.org/x/term v0.31.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/catppuccin/go v0.2.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.1.2 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a // indirect
|
||||
github.com/charmbracelet/x/exp/term v0.0.0-20240606154654-7c42867b53c7 // indirect
|
||||
github.com/charmbracelet/x/input v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/term v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.1.2 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/termenv v0.15.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sahilm/fuzzy v0.1.1 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.7.0 // indirect
|
||||
golang.org/x/sys v0.21.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
)
|
||||
|
||||
75
go.sum
75
go.sum
@ -1,33 +1,55 @@
|
||||
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
|
||||
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
|
||||
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
|
||||
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
|
||||
github.com/charmbracelet/bubbletea v0.26.4 h1:2gDkkzLZaTjMl/dQBpNVtnvcCxsh/FCkimep7FC9c40=
|
||||
github.com/charmbracelet/bubbletea v0.26.4/go.mod h1:P+r+RRA5qtI1DOHNFn0otoNwB4rn+zNAzSj/EXz6xU0=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
||||
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||
github.com/charmbracelet/huh v0.4.2 h1:5wLkwrA58XDAfEZsJzNQlfJ+K8N9+wYwvR5FOM7jXFM=
|
||||
github.com/charmbracelet/huh v0.4.2/go.mod h1:g9OXBgtY3zRV4ahnVih9bZE+1yGYN+y2C9Q6L2P+WM0=
|
||||
github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g=
|
||||
github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8=
|
||||
github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY=
|
||||
github.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a h1:lOpqe2UvPmlln41DGoii7wlSZ/q8qGIon5JJ8Biu46I=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/exp/term v0.0.0-20240606154654-7c42867b53c7 h1:6Rw/MHf+Rm7wlUr+euFyaGw1fNKeZPKVh4gkFHUjFsY=
|
||||
github.com/charmbracelet/x/exp/term v0.0.0-20240606154654-7c42867b53c7/go.mod h1:UZyEz89i6+jVx2oW3lgmIsVDNr7qzaKuvPVoSdwqDR0=
|
||||
github.com/charmbracelet/x/input v0.1.1 h1:YDOJaTUKCqtGnq9PHzx3pkkl4pXDOANUHmhH3DqMtM4=
|
||||
github.com/charmbracelet/x/input v0.1.1/go.mod h1:jvdTVUnNWj/RD6hjC4FsoB0SeZCJ2ZBkiuFP9zXvZI0=
|
||||
github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI=
|
||||
github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw=
|
||||
github.com/charmbracelet/x/windows v0.1.2 h1:Iumiwq2G+BRmgoayww/qfcvof7W/3uLoelhxojXlRWg=
|
||||
github.com/charmbracelet/x/windows v0.1.2/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
@ -37,16 +59,18 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
|
||||
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
@ -55,15 +79,22 @@ github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
||||
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
|
||||
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
|
||||
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
|
||||
@ -82,3 +82,7 @@ func doTick() tea.Cmd {
|
||||
return tickMsg(t)
|
||||
})
|
||||
}
|
||||
|
||||
type TaskPickedMsg struct {
|
||||
Task *taskwarrior.Task
|
||||
}
|
||||
|
||||
465
pages/projectTaskPicker.go
Normal file
465
pages/projectTaskPicker.go
Normal 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
|
||||
}
|
||||
135
pages/report.go
135
pages/report.go
@ -3,13 +3,13 @@ package pages
|
||||
|
||||
import (
|
||||
"tasksquire/common"
|
||||
"tasksquire/components/detailsviewer"
|
||||
"tasksquire/components/table"
|
||||
"tasksquire/taskwarrior"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
|
||||
// "github.com/charmbracelet/bubbles/table"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type ReportPage struct {
|
||||
@ -25,6 +25,10 @@ type ReportPage struct {
|
||||
|
||||
taskTable table.Model
|
||||
|
||||
// Details panel state
|
||||
detailsPanelActive bool
|
||||
detailsViewer *detailsviewer.DetailsViewer
|
||||
|
||||
subpage common.Component
|
||||
}
|
||||
|
||||
@ -38,11 +42,13 @@ func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
|
||||
// }
|
||||
|
||||
p := &ReportPage{
|
||||
common: com,
|
||||
activeReport: report,
|
||||
activeContext: com.TW.GetActiveContext(),
|
||||
activeProject: "",
|
||||
taskTable: table.New(com),
|
||||
common: com,
|
||||
activeReport: report,
|
||||
activeContext: com.TW.GetActiveContext(),
|
||||
activeProject: "",
|
||||
taskTable: table.New(com),
|
||||
detailsPanelActive: false,
|
||||
detailsViewer: detailsviewer.New(com),
|
||||
}
|
||||
|
||||
return p
|
||||
@ -51,8 +57,39 @@ func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
|
||||
func (p *ReportPage) SetSize(width int, height int) {
|
||||
p.common.SetSize(width, height)
|
||||
|
||||
p.taskTable.SetWidth(width - p.common.Styles.Base.GetHorizontalFrameSize())
|
||||
p.taskTable.SetHeight(height - p.common.Styles.Base.GetVerticalFrameSize())
|
||||
baseHeight := height - p.common.Styles.Base.GetVerticalFrameSize()
|
||||
baseWidth := width - p.common.Styles.Base.GetHorizontalFrameSize()
|
||||
|
||||
var tableHeight int
|
||||
if p.detailsPanelActive {
|
||||
// Allocate 60% for table, 40% for details panel
|
||||
// Minimum 5 lines for details, minimum 10 lines for table
|
||||
detailsHeight := max(min(baseHeight*2/5, baseHeight-10), 5)
|
||||
tableHeight = baseHeight - detailsHeight - 1 // -1 for spacing
|
||||
|
||||
// Set component size (component handles its own border/padding)
|
||||
p.detailsViewer.SetSize(baseWidth, detailsHeight)
|
||||
} else {
|
||||
tableHeight = baseHeight
|
||||
}
|
||||
|
||||
p.taskTable.SetWidth(baseWidth)
|
||||
p.taskTable.SetHeight(tableHeight)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (p *ReportPage) Init() tea.Cmd {
|
||||
@ -91,6 +128,14 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case UpdatedTasksMsg:
|
||||
cmds = append(cmds, p.getTasks())
|
||||
case tea.KeyMsg:
|
||||
// Handle ESC when details panel is active
|
||||
if p.detailsPanelActive && key.Matches(msg, p.common.Keymap.Back) {
|
||||
p.detailsPanelActive = false
|
||||
p.detailsViewer.Blur()
|
||||
p.SetSize(p.common.Width(), p.common.Height())
|
||||
return p, nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case key.Matches(msg, p.common.Keymap.Quit):
|
||||
return p, tea.Quit
|
||||
@ -155,28 +200,63 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return p, p.getTasks()
|
||||
}
|
||||
case key.Matches(msg, p.common.Keymap.ViewDetails):
|
||||
if p.selectedTask != nil {
|
||||
// Toggle details panel
|
||||
p.detailsPanelActive = !p.detailsPanelActive
|
||||
if p.detailsPanelActive {
|
||||
p.detailsViewer.SetTask(p.selectedTask)
|
||||
p.detailsViewer.Focus()
|
||||
} else {
|
||||
p.detailsViewer.Blur()
|
||||
}
|
||||
p.SetSize(p.common.Width(), p.common.Height())
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
p.taskTable, cmd = p.taskTable.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
if p.tasks != nil && len(p.tasks) > 0 {
|
||||
p.selectedTask = p.tasks[p.taskTable.Cursor()]
|
||||
// Route keyboard messages to details viewer when panel is active
|
||||
if p.detailsPanelActive {
|
||||
var viewerCmd tea.Cmd
|
||||
var viewerModel tea.Model
|
||||
viewerModel, viewerCmd = p.detailsViewer.Update(msg)
|
||||
p.detailsViewer = viewerModel.(*detailsviewer.DetailsViewer)
|
||||
cmds = append(cmds, viewerCmd)
|
||||
} else {
|
||||
p.selectedTask = nil
|
||||
// Route to table when details panel not active
|
||||
p.taskTable, cmd = p.taskTable.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
if p.tasks != nil && len(p.tasks) > 0 {
|
||||
p.selectedTask = p.tasks[p.taskTable.Cursor()]
|
||||
} else {
|
||||
p.selectedTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
return p, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (p *ReportPage) View() string {
|
||||
// return p.common.Styles.Main.Render(p.taskTable.View()) + "\n"
|
||||
if p.tasks == nil || len(p.tasks) == 0 {
|
||||
return p.common.Styles.Base.Render("No tasks found")
|
||||
}
|
||||
return p.taskTable.View()
|
||||
|
||||
tableView := p.taskTable.View()
|
||||
|
||||
if !p.detailsPanelActive {
|
||||
return tableView
|
||||
}
|
||||
|
||||
// Combine table and details panel vertically
|
||||
return lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
tableView,
|
||||
p.detailsViewer.View(),
|
||||
)
|
||||
}
|
||||
|
||||
func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) {
|
||||
@ -197,13 +277,27 @@ func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) {
|
||||
selected = len(tasks) - 1
|
||||
}
|
||||
|
||||
// Calculate proper dimensions based on whether details panel is active
|
||||
baseHeight := p.common.Height() - p.common.Styles.Base.GetVerticalFrameSize()
|
||||
baseWidth := p.common.Width() - p.common.Styles.Base.GetHorizontalFrameSize()
|
||||
|
||||
var tableHeight int
|
||||
if p.detailsPanelActive {
|
||||
// Allocate 60% for table, 40% for details panel
|
||||
// Minimum 5 lines for details, minimum 10 lines for table
|
||||
detailsHeight := max(min(baseHeight*2/5, baseHeight-10), 5)
|
||||
tableHeight = baseHeight - detailsHeight - 1 // -1 for spacing
|
||||
} else {
|
||||
tableHeight = baseHeight
|
||||
}
|
||||
|
||||
p.taskTable = table.New(
|
||||
p.common,
|
||||
table.WithReport(p.activeReport),
|
||||
table.WithTasks(tasks),
|
||||
table.WithFocused(true),
|
||||
table.WithWidth(p.common.Width()-p.common.Styles.Base.GetVerticalFrameSize()),
|
||||
table.WithHeight(p.common.Height()-p.common.Styles.Base.GetHorizontalFrameSize()-1),
|
||||
table.WithWidth(baseWidth),
|
||||
table.WithHeight(tableHeight),
|
||||
table.WithStyles(p.common.Styles.TableStyle),
|
||||
)
|
||||
|
||||
@ -215,6 +309,11 @@ func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) {
|
||||
} else {
|
||||
p.taskTable.SetCursor(len(p.tasks) - 1)
|
||||
}
|
||||
|
||||
// Refresh details content if panel is active
|
||||
if p.detailsPanelActive && p.selectedTask != nil {
|
||||
p.detailsViewer.SetTask(p.selectedTask)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ReportPage) getTasks() tea.Cmd {
|
||||
|
||||
@ -466,9 +466,15 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task, isNew bool) *taskEd
|
||||
}
|
||||
|
||||
opts := []picker.PickerOption{picker.WithOnCreate(onCreate)}
|
||||
if isNew {
|
||||
|
||||
// Check if task has a pre-filled project (e.g., from ProjectTaskPickerPage)
|
||||
hasPrefilledProject := task.Project != "" && task.Project != "(none)"
|
||||
|
||||
if isNew && !hasPrefilledProject {
|
||||
// New task with no project → start in filter mode for quick project search
|
||||
opts = append(opts, picker.WithFilterByDefault(true))
|
||||
} else {
|
||||
// Either existing task OR new task with pre-filled project → show list with project selected
|
||||
opts = append(opts, picker.WithDefaultValue(task.Project))
|
||||
}
|
||||
|
||||
|
||||
@ -30,6 +30,8 @@ type TimeEditorPage struct {
|
||||
selectedProject string
|
||||
currentField int
|
||||
totalFields int
|
||||
uuid string // Preserved UUID tag
|
||||
track string // Preserved track tag (if present)
|
||||
}
|
||||
|
||||
type timeEditorProjectSelectedMsg struct {
|
||||
@ -37,9 +39,19 @@ type timeEditorProjectSelectedMsg struct {
|
||||
}
|
||||
|
||||
func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage {
|
||||
// Extract project from tags if it exists
|
||||
projects := com.TW.GetProjects()
|
||||
selectedProject, remainingTags := extractProjectFromTags(interval.Tags, projects)
|
||||
// Extract special tags (uuid, project, track) and display tags
|
||||
uuid, selectedProject, track, displayTags := extractSpecialTags(interval.Tags)
|
||||
|
||||
// If UUID exists, fetch the task and add its title to display tags
|
||||
if uuid != "" {
|
||||
tasks := com.TW.GetTasks(nil, "uuid:"+uuid)
|
||||
if len(tasks) > 0 {
|
||||
taskTitle := tasks[0].Description
|
||||
// Add to display tags if not already present
|
||||
// Note: formatTags() will handle quoting for display, so we store the raw title
|
||||
displayTags = ensureTagPresent(displayTags, taskTitle)
|
||||
}
|
||||
}
|
||||
|
||||
// Create project picker with onCreate support for new projects
|
||||
projectItemProvider := func() []list.Item {
|
||||
@ -99,7 +111,7 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time
|
||||
|
||||
tagsInput := autocomplete.New(tagCombinations, 3, 10)
|
||||
tagsInput.SetPlaceholder("Space separated, use \"\" for tags with spaces")
|
||||
tagsInput.SetValue(formatTags(remainingTags)) // Use remaining tags (without project)
|
||||
tagsInput.SetValue(formatTags(displayTags)) // Use display tags only (no uuid, project, track)
|
||||
tagsInput.SetWidth(50)
|
||||
|
||||
p := &TimeEditorPage{
|
||||
@ -113,6 +125,8 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time
|
||||
selectedProject: selectedProject,
|
||||
currentField: 0,
|
||||
totalFields: 5, // Now 5 fields: project, tags, start, end, adjust
|
||||
uuid: uuid,
|
||||
track: track,
|
||||
}
|
||||
|
||||
return p
|
||||
@ -365,23 +379,27 @@ func (p *TimeEditorPage) saveInterval() {
|
||||
p.interval.Start = p.startEditor.GetValueString()
|
||||
p.interval.End = p.endEditor.GetValueString()
|
||||
|
||||
// Parse tags from input
|
||||
tags := parseTags(p.tagsInput.GetValue())
|
||||
// Parse display tags from input
|
||||
displayTags := parseTags(p.tagsInput.GetValue())
|
||||
|
||||
// Add project to tags if not already present
|
||||
if p.selectedProject != "" {
|
||||
projectTag := "project:" + p.selectedProject
|
||||
projectExists := false
|
||||
for _, tag := range tags {
|
||||
if tag == projectTag {
|
||||
projectExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !projectExists {
|
||||
tags = append([]string{projectTag}, tags...) // Prepend project tag
|
||||
}
|
||||
// Reconstruct full tags array by combining special tags and display tags
|
||||
var tags []string
|
||||
|
||||
// Add preserved special tags first
|
||||
if p.uuid != "" {
|
||||
tags = append(tags, "uuid:"+p.uuid)
|
||||
}
|
||||
if p.track != "" {
|
||||
tags = append(tags, p.track)
|
||||
}
|
||||
|
||||
// Add project tag
|
||||
if p.selectedProject != "" {
|
||||
tags = append(tags, "project:"+p.selectedProject)
|
||||
}
|
||||
|
||||
// Add display tags (user-entered tags from the input field)
|
||||
tags = append(tags, displayTags...)
|
||||
|
||||
p.interval.Tags = tags
|
||||
|
||||
@ -427,30 +445,39 @@ func formatTags(tags []string) string {
|
||||
return strings.Join(formatted, " ")
|
||||
}
|
||||
|
||||
// extractSpecialTags separates special tags (uuid, project, track) from display tags
|
||||
// Returns uuid, project, track as separate strings, and displayTags for user editing
|
||||
func extractSpecialTags(tags []string) (uuid, project, track string, displayTags []string) {
|
||||
for _, tag := range tags {
|
||||
if strings.HasPrefix(tag, "uuid:") {
|
||||
uuid = strings.TrimPrefix(tag, "uuid:")
|
||||
} else if strings.HasPrefix(tag, "project:") {
|
||||
project = strings.TrimPrefix(tag, "project:")
|
||||
} else if tag == "track" {
|
||||
track = tag
|
||||
} else {
|
||||
displayTags = append(displayTags, tag)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// extractProjectFromTags finds and removes the first tag that matches a known project
|
||||
// Returns the found project (or empty string) and the remaining tags
|
||||
// This is kept for backward compatibility but now uses extractSpecialTags internally
|
||||
func extractProjectFromTags(tags []string, projects []string) (string, []string) {
|
||||
projectSet := make(map[string]bool)
|
||||
for _, p := range projects {
|
||||
projectSet[p] = true
|
||||
}
|
||||
_, project, _, remaining := extractSpecialTags(tags)
|
||||
return project, remaining
|
||||
}
|
||||
|
||||
var foundProject string
|
||||
var remaining []string
|
||||
|
||||
for _, tag := range tags {
|
||||
// Check if this tag is a project tag (format: "project:projectname")
|
||||
if strings.HasPrefix(tag, "project:") {
|
||||
projectName := strings.TrimPrefix(tag, "project:")
|
||||
if foundProject == "" && projectSet[projectName] {
|
||||
foundProject = projectName // First matching project
|
||||
continue // Don't add to remaining tags
|
||||
}
|
||||
// ensureTagPresent adds a tag to the list if not already present
|
||||
func ensureTagPresent(tags []string, tag string) []string {
|
||||
for _, t := range tags {
|
||||
if t == tag {
|
||||
return tags // Already present
|
||||
}
|
||||
remaining = append(remaining, tag)
|
||||
}
|
||||
|
||||
return foundProject, remaining
|
||||
return append(tags, tag)
|
||||
}
|
||||
|
||||
// filterTagCombinationsByProject filters tag combinations to only show those
|
||||
|
||||
@ -43,12 +43,12 @@ func (a Annotation) String() string {
|
||||
type Tasks []*Task
|
||||
|
||||
type Task struct {
|
||||
Id int64 `json:"id,omitempty"`
|
||||
Uuid string `json:"uuid,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Project string `json:"project"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Id int64 `json:"id,omitempty"`
|
||||
Uuid string `json:"uuid,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Project string `json:"project"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
VirtualTags []string `json:"-"`
|
||||
Depends []string `json:"depends,omitempty"`
|
||||
|
||||
@ -58,14 +58,14 @@ func TestTask_GetDate(t *testing.T) {
|
||||
want: parsedValid,
|
||||
},
|
||||
{
|
||||
name: "Due date empty",
|
||||
task: Task{},
|
||||
name: "Due date empty",
|
||||
task: Task{},
|
||||
field: "due",
|
||||
want: time.Time{},
|
||||
},
|
||||
{
|
||||
name: "Unknown field",
|
||||
task: Task{Due: validDate},
|
||||
name: "Unknown field",
|
||||
task: Task{Due: validDate},
|
||||
field: "unknown",
|
||||
want: time.Time{},
|
||||
},
|
||||
|
||||
@ -21,6 +21,9 @@ func ExtractSpecialTags(tags []string) (uuid string, project string, displayTags
|
||||
uuid = strings.TrimPrefix(tag, UUIDPrefix)
|
||||
case strings.HasPrefix(tag, ProjectPrefix):
|
||||
project = strings.TrimPrefix(tag, ProjectPrefix)
|
||||
case tag == "track":
|
||||
// Skip the "track" tag - it's internal metadata
|
||||
continue
|
||||
default:
|
||||
// Regular tag (description or user tag)
|
||||
displayTags = append(displayTags, tag)
|
||||
|
||||
Reference in New Issue
Block a user