Compare commits
17 Commits
feat/task
...
6b1418fc71
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b1418fc71 | ||
|
|
b46aced2c7 | ||
|
|
3ab26f658d | ||
|
|
1a9fd9b4b0 | ||
|
|
6e60698526 | ||
|
|
703ed981ac | ||
|
|
e3effe8b25 | ||
|
|
980c8eb309 | ||
|
|
e35f480248 | ||
|
|
02fa2e503a | ||
|
|
474bb3dc07 | ||
|
|
1ffcf42773 | ||
|
|
44ddbc0f47 | ||
|
|
2e33893e29 | ||
|
|
46ce91196a | ||
|
|
2b31d9bc2b | ||
|
|
70b6ee9bc7 |
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:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,5 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
app.log
|
|
||||||
test/taskchampion.sqlite3
|
test/taskchampion.sqlite3
|
||||||
tasksquire
|
tasksquire
|
||||||
test/*.sqlite3*
|
test/*.sqlite3*
|
||||||
|
result
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ ts.StopTask(&task)
|
|||||||
|
|
||||||
## Development Notes
|
## Development Notes
|
||||||
|
|
||||||
- **Logging**: Application logs to `app.log` in current directory
|
- **Logging**: Application logs to `/tmp/tasksquire.log`
|
||||||
- **Virtual Tags**: Filter out Taskwarrior virtual tags (see `virtualTags` map)
|
- **Virtual Tags**: Filter out Taskwarrior virtual tags (see `virtualTags` map)
|
||||||
- **Color Parsing**: Custom color parsing from Taskwarrior config format
|
- **Color Parsing**: Custom color parsing from Taskwarrior config format
|
||||||
- **Debugging**: VSCode launch.json configured for remote debugging on port 43000
|
- **Debugging**: VSCode launch.json configured for remote debugging on port 43000
|
||||||
|
|||||||
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 `/tmp/tasksquire.log`
|
||||||
|
- 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`)
|
||||||
@@ -61,7 +61,7 @@ Tasksquire respects your existing Taskwarrior configuration (`.taskrc`). It look
|
|||||||
2. `$HOME/.taskrc`
|
2. `$HOME/.taskrc`
|
||||||
3. `$HOME/.config/task/taskrc`
|
3. `$HOME/.config/task/taskrc`
|
||||||
|
|
||||||
Logging is written to `app.log` in the current working directory.
|
Logging is written to `/tmp/tasksquire.log`.
|
||||||
|
|
||||||
## Development Conventions
|
## Development Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type Keymap struct {
|
|||||||
SetReport key.Binding
|
SetReport key.Binding
|
||||||
SetContext key.Binding
|
SetContext key.Binding
|
||||||
SetProject key.Binding
|
SetProject key.Binding
|
||||||
|
PickProjectTask key.Binding
|
||||||
Select key.Binding
|
Select key.Binding
|
||||||
Insert key.Binding
|
Insert key.Binding
|
||||||
Tag key.Binding
|
Tag key.Binding
|
||||||
@@ -31,6 +32,8 @@ type Keymap struct {
|
|||||||
Fill key.Binding
|
Fill key.Binding
|
||||||
StartStop key.Binding
|
StartStop key.Binding
|
||||||
Join key.Binding
|
Join key.Binding
|
||||||
|
ViewDetails key.Binding
|
||||||
|
Subtask key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: use config values for key bindings
|
// TODO: use config values for key bindings
|
||||||
@@ -103,13 +106,13 @@ func NewKeymap() *Keymap {
|
|||||||
),
|
),
|
||||||
|
|
||||||
NextPage: key.NewBinding(
|
NextPage: key.NewBinding(
|
||||||
key.WithKeys("]"),
|
key.WithKeys("]", "L"),
|
||||||
key.WithHelp("[", "Next page"),
|
key.WithHelp("]/L", "Next page"),
|
||||||
),
|
),
|
||||||
|
|
||||||
PrevPage: key.NewBinding(
|
PrevPage: key.NewBinding(
|
||||||
key.WithKeys("["),
|
key.WithKeys("[", "H"),
|
||||||
key.WithHelp("]", "Previous page"),
|
key.WithHelp("[/H", "Previous page"),
|
||||||
),
|
),
|
||||||
|
|
||||||
SetReport: key.NewBinding(
|
SetReport: key.NewBinding(
|
||||||
@@ -127,6 +130,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 +169,15 @@ 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"),
|
||||||
|
),
|
||||||
|
|
||||||
|
Subtask: key.NewBinding(
|
||||||
|
key.WithKeys("S"),
|
||||||
|
key.WithHelp("S", "Create subtask"),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,19 @@ type TableStyle struct {
|
|||||||
Selected lipgloss.Style
|
Selected lipgloss.Style
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Palette struct {
|
||||||
|
Primary lipgloss.Style
|
||||||
|
Secondary lipgloss.Style
|
||||||
|
Accent lipgloss.Style
|
||||||
|
Muted lipgloss.Style
|
||||||
|
Border lipgloss.Style
|
||||||
|
Background lipgloss.Style
|
||||||
|
Text lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
type Styles struct {
|
type Styles struct {
|
||||||
Colors map[string]*lipgloss.Style
|
Colors map[string]*lipgloss.Style
|
||||||
|
Palette Palette
|
||||||
|
|
||||||
Base lipgloss.Style
|
Base lipgloss.Style
|
||||||
|
|
||||||
@@ -50,23 +61,43 @@ func NewStyles(config *taskwarrior.TWConfig) *Styles {
|
|||||||
|
|
||||||
styles.Colors = colors
|
styles.Colors = colors
|
||||||
|
|
||||||
styles.Base = lipgloss.NewStyle()
|
// Initialize Palette (Iceberg Light)
|
||||||
|
styles.Palette.Primary = lipgloss.NewStyle().Foreground(lipgloss.Color("#2d539e")) // Blue
|
||||||
|
styles.Palette.Secondary = lipgloss.NewStyle().Foreground(lipgloss.Color("#7759b4")) // Purple
|
||||||
|
styles.Palette.Accent = lipgloss.NewStyle().Foreground(lipgloss.Color("#c57339")) // Orange
|
||||||
|
styles.Palette.Muted = lipgloss.NewStyle().Foreground(lipgloss.Color("#8389a3")) // Grey
|
||||||
|
styles.Palette.Border = lipgloss.NewStyle().Foreground(lipgloss.Color("#cad0de")) // Light Grey Border
|
||||||
|
styles.Palette.Background = lipgloss.NewStyle().Background(lipgloss.Color("#e8e9ec")) // Light Background
|
||||||
|
styles.Palette.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("#33374c")) // Dark Text
|
||||||
|
|
||||||
|
// Override from config if available (example mapping)
|
||||||
|
if s, ok := styles.Colors["primary"]; ok {
|
||||||
|
styles.Palette.Primary = *s
|
||||||
|
}
|
||||||
|
if s, ok := styles.Colors["secondary"]; ok {
|
||||||
|
styles.Palette.Secondary = *s
|
||||||
|
}
|
||||||
|
if s, ok := styles.Colors["active"]; ok {
|
||||||
|
styles.Palette.Accent = *s
|
||||||
|
}
|
||||||
|
|
||||||
|
styles.Base = lipgloss.NewStyle().Foreground(styles.Palette.Text.GetForeground())
|
||||||
|
|
||||||
styles.TableStyle = TableStyle{
|
styles.TableStyle = TableStyle{
|
||||||
// Header: lipgloss.NewStyle().Bold(true).Padding(0, 1).BorderBottom(true),
|
// Header: lipgloss.NewStyle().Bold(true).Padding(0, 1).BorderBottom(true),
|
||||||
Cell: lipgloss.NewStyle().Padding(0, 1, 0, 0),
|
Cell: lipgloss.NewStyle().Padding(0, 1, 0, 0),
|
||||||
Header: lipgloss.NewStyle().Bold(true).Padding(0, 1, 0, 0).Underline(true),
|
Header: lipgloss.NewStyle().Bold(true).Padding(0, 1, 0, 0).Underline(true).Foreground(styles.Palette.Primary.GetForeground()),
|
||||||
Selected: lipgloss.NewStyle().Bold(true).Reverse(true),
|
Selected: lipgloss.NewStyle().Bold(true).Reverse(true).Foreground(styles.Palette.Accent.GetForeground()),
|
||||||
}
|
}
|
||||||
|
|
||||||
formTheme := huh.ThemeBase()
|
formTheme := huh.ThemeBase()
|
||||||
formTheme.Focused.Title = formTheme.Focused.Title.Bold(true)
|
formTheme.Focused.Title = formTheme.Focused.Title.Bold(true).Foreground(styles.Palette.Primary.GetForeground())
|
||||||
formTheme.Focused.SelectSelector = formTheme.Focused.SelectSelector.SetString("→ ")
|
formTheme.Focused.SelectSelector = formTheme.Focused.SelectSelector.SetString("→ ").Foreground(styles.Palette.Accent.GetForeground())
|
||||||
formTheme.Focused.SelectedOption = formTheme.Focused.SelectedOption.Bold(true)
|
formTheme.Focused.SelectedOption = formTheme.Focused.SelectedOption.Bold(true).Foreground(styles.Palette.Accent.GetForeground())
|
||||||
formTheme.Focused.MultiSelectSelector = formTheme.Focused.MultiSelectSelector.SetString("→ ")
|
formTheme.Focused.MultiSelectSelector = formTheme.Focused.MultiSelectSelector.SetString("→ ").Foreground(styles.Palette.Accent.GetForeground())
|
||||||
formTheme.Focused.SelectedPrefix = formTheme.Focused.SelectedPrefix.SetString("✓ ")
|
formTheme.Focused.SelectedPrefix = formTheme.Focused.SelectedPrefix.SetString("✓ ").Foreground(styles.Palette.Accent.GetForeground())
|
||||||
formTheme.Focused.UnselectedPrefix = formTheme.Focused.SelectedPrefix.SetString("• ")
|
formTheme.Focused.UnselectedPrefix = formTheme.Focused.SelectedPrefix.SetString("• ")
|
||||||
formTheme.Blurred.Title = formTheme.Blurred.Title.Bold(true)
|
formTheme.Blurred.Title = formTheme.Blurred.Title.Bold(true).Foreground(styles.Palette.Muted.GetForeground())
|
||||||
formTheme.Blurred.SelectSelector = formTheme.Blurred.SelectSelector.SetString(" ")
|
formTheme.Blurred.SelectSelector = formTheme.Blurred.SelectSelector.SetString(" ")
|
||||||
formTheme.Blurred.SelectedOption = formTheme.Blurred.SelectedOption.Bold(true)
|
formTheme.Blurred.SelectedOption = formTheme.Blurred.SelectedOption.Bold(true)
|
||||||
formTheme.Blurred.MultiSelectSelector = formTheme.Blurred.MultiSelectSelector.SetString(" ")
|
formTheme.Blurred.MultiSelectSelector = formTheme.Blurred.MultiSelectSelector.SetString(" ")
|
||||||
@@ -77,27 +108,38 @@ func NewStyles(config *taskwarrior.TWConfig) *Styles {
|
|||||||
|
|
||||||
styles.Tab = lipgloss.NewStyle().
|
styles.Tab = lipgloss.NewStyle().
|
||||||
Padding(0, 1).
|
Padding(0, 1).
|
||||||
Foreground(lipgloss.Color("240"))
|
Foreground(styles.Palette.Muted.GetForeground())
|
||||||
|
|
||||||
styles.ActiveTab = styles.Tab.
|
styles.ActiveTab = styles.Tab.
|
||||||
Foreground(lipgloss.Color("252")).
|
Foreground(styles.Palette.Primary.GetForeground()).
|
||||||
Bold(true)
|
Bold(true)
|
||||||
|
|
||||||
styles.TabBar = lipgloss.NewStyle().
|
styles.TabBar = lipgloss.NewStyle().
|
||||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||||
BorderForeground(lipgloss.Color("240")).
|
BorderForeground(styles.Palette.Border.GetForeground()).
|
||||||
MarginBottom(1)
|
MarginBottom(1)
|
||||||
|
|
||||||
styles.ColumnFocused = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)
|
styles.ColumnFocused = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1).BorderForeground(styles.Palette.Primary.GetForeground())
|
||||||
styles.ColumnBlurred = lipgloss.NewStyle().Border(lipgloss.HiddenBorder(), true).Padding(1)
|
styles.ColumnBlurred = lipgloss.NewStyle().Border(lipgloss.HiddenBorder(), true).Padding(1).BorderForeground(styles.Palette.Border.GetForeground())
|
||||||
styles.ColumnInsert = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)
|
styles.ColumnInsert = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1).BorderForeground(styles.Palette.Accent.GetForeground())
|
||||||
if styles.Colors["active"] != nil {
|
|
||||||
styles.ColumnInsert = styles.ColumnInsert.BorderForeground(styles.Colors["active"].GetForeground())
|
|
||||||
}
|
|
||||||
|
|
||||||
return &styles
|
return &styles
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Styles) GetModalSize(width, height int) (int, int) {
|
||||||
|
modalWidth := 60
|
||||||
|
if width < 64 {
|
||||||
|
modalWidth = width - 4
|
||||||
|
}
|
||||||
|
|
||||||
|
modalHeight := 20
|
||||||
|
if height < 24 {
|
||||||
|
modalHeight = height - 4
|
||||||
|
}
|
||||||
|
|
||||||
|
return modalWidth, modalHeight
|
||||||
|
}
|
||||||
|
|
||||||
func parseColorString(color string) *lipgloss.Style {
|
func parseColorString(color string) *lipgloss.Style {
|
||||||
if color == "" {
|
if color == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
85
common/sync.go
Normal file
85
common/sync.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ type Task struct {
|
|||||||
Depends []string `json:"depends,omitempty"`
|
Depends []string `json:"depends,omitempty"`
|
||||||
DependsIds string `json:"-"`
|
DependsIds string `json:"-"`
|
||||||
Urgency float32 `json:"urgency,omitempty"`
|
Urgency float32 `json:"urgency,omitempty"`
|
||||||
Parent string `json:"parent,omitempty"`
|
Parent string `json:"parenttask,omitempty"`
|
||||||
Due string `json:"due,omitempty"`
|
Due string `json:"due,omitempty"`
|
||||||
Wait string `json:"wait,omitempty"`
|
Wait string `json:"wait,omitempty"`
|
||||||
Scheduled string `json:"scheduled,omitempty"`
|
Scheduled string `json:"scheduled,omitempty"`
|
||||||
@@ -125,7 +125,7 @@ func (t *Task) GetString(fieldWFormat string) string {
|
|||||||
}
|
}
|
||||||
return strings.Join(t.Tags, " ")
|
return strings.Join(t.Tags, " ")
|
||||||
|
|
||||||
case "parent":
|
case "parenttask":
|
||||||
if format == "short" {
|
if format == "short" {
|
||||||
return t.Parent[:8]
|
return t.Parent[:8]
|
||||||
}
|
}
|
||||||
@@ -178,7 +178,7 @@ func (t *Task) GetString(fieldWFormat string) string {
|
|||||||
return t.Recur
|
return t.Recur
|
||||||
|
|
||||||
default:
|
default:
|
||||||
slog.Error(fmt.Sprintf("Field not implemented: %s", field))
|
slog.Error("Field not implemented", "field", field)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,7 +214,7 @@ func formatDate(date string, format string) string {
|
|||||||
dtformat := "20060102T150405Z"
|
dtformat := "20060102T150405Z"
|
||||||
dt, err := time.Parse(dtformat, date)
|
dt, err := time.Parse(dtformat, date)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to parse time:", err)
|
slog.Error("Failed to parse time", "error", err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +238,7 @@ func formatDate(date string, format string) string {
|
|||||||
case "countdown":
|
case "countdown":
|
||||||
return parseCountdown(time.Since(dt))
|
return parseCountdown(time.Since(dt))
|
||||||
default:
|
default:
|
||||||
slog.Error(fmt.Sprintf("Date format not implemented: %s", format))
|
slog.Error("Date format not implemented", "format", format)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,17 @@ func (a *Autocomplete) SetMinChars(min int) {
|
|||||||
a.minChars = min
|
a.minChars = min
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSuggestions updates the available suggestions
|
||||||
|
func (a *Autocomplete) SetSuggestions(suggestions []string) {
|
||||||
|
a.allSuggestions = suggestions
|
||||||
|
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
|
||||||
|
|||||||
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(d.common.Styles.Palette.Text.GetForeground())
|
||||||
|
|
||||||
|
helpStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(d.common.Styles.Palette.Muted.GetForeground())
|
||||||
|
|
||||||
|
header := lipgloss.JoinHorizontal(
|
||||||
|
lipgloss.Left,
|
||||||
|
titleStyle.Render("Details"),
|
||||||
|
" ",
|
||||||
|
helpStyle.Render("(↑/↓ scroll, v/ESC close)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Container style
|
||||||
|
containerStyle := lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(d.common.Styles.Palette.Border.GetForeground()).
|
||||||
|
Padding(0, 1).
|
||||||
|
Width(d.width).
|
||||||
|
Height(d.height)
|
||||||
|
|
||||||
|
// Optional: highlight border when focused (for future interactivity)
|
||||||
|
if d.focused {
|
||||||
|
containerStyle = containerStyle.
|
||||||
|
BorderForeground(d.common.Styles.Palette.Accent.GetForeground())
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -637,6 +637,11 @@ func (m *MultiSelect) GetValue() any {
|
|||||||
return *m.value
|
return *m.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsFiltering returns true if the multi-select is currently filtering.
|
||||||
|
func (m *MultiSelect) IsFiltering() bool {
|
||||||
|
return m.filtering
|
||||||
|
}
|
||||||
|
|
||||||
func min(a, b int) int {
|
func min(a, b int) int {
|
||||||
if a < b {
|
if a < b {
|
||||||
return a
|
return a
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ type Picker struct {
|
|||||||
onCreate func(string) tea.Cmd
|
onCreate func(string) tea.Cmd
|
||||||
title string
|
title string
|
||||||
filterByDefault bool
|
filterByDefault bool
|
||||||
|
defaultValue string
|
||||||
baseItems []list.Item
|
baseItems []list.Item
|
||||||
|
focused bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type PickerOption func(*Picker)
|
type PickerOption func(*Picker)
|
||||||
@@ -53,6 +55,30 @@ func WithOnCreate(onCreate func(string) tea.Cmd) PickerOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithDefaultValue(value string) PickerOption {
|
||||||
|
return func(p *Picker) {
|
||||||
|
p.defaultValue = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Picker) Focus() tea.Cmd {
|
||||||
|
p.focused = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Picker) Blur() tea.Cmd {
|
||||||
|
p.focused = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Picker) GetValue() string {
|
||||||
|
item := p.list.SelectedItem()
|
||||||
|
if item == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return item.FilterValue()
|
||||||
|
}
|
||||||
|
|
||||||
func New(
|
func New(
|
||||||
c *common.Common,
|
c *common.Common,
|
||||||
title string,
|
title string,
|
||||||
@@ -64,11 +90,27 @@ func New(
|
|||||||
delegate.ShowDescription = false
|
delegate.ShowDescription = false
|
||||||
delegate.SetSpacing(0)
|
delegate.SetSpacing(0)
|
||||||
|
|
||||||
|
// Update Styles
|
||||||
|
delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(c.Styles.Palette.Text.GetForeground())
|
||||||
|
delegate.Styles.SelectedTitle = lipgloss.NewStyle().Foreground(c.Styles.Palette.Accent.GetForeground()).Bold(true).PaddingLeft(2)
|
||||||
|
delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(c.Styles.Palette.Muted.GetForeground())
|
||||||
|
delegate.Styles.SelectedDesc = lipgloss.NewStyle().Foreground(c.Styles.Palette.Accent.GetForeground()).PaddingLeft(2)
|
||||||
|
delegate.Styles.FilterMatch = lipgloss.NewStyle().Foreground(c.Styles.Palette.Secondary.GetForeground()).Underline(true)
|
||||||
|
|
||||||
l := list.New([]list.Item{}, delegate, 0, 0)
|
l := list.New([]list.Item{}, delegate, 0, 0)
|
||||||
|
l.Styles.FilterPrompt = lipgloss.NewStyle().Foreground(c.Styles.Palette.Accent.GetForeground())
|
||||||
|
l.Styles.FilterCursor = lipgloss.NewStyle().Foreground(c.Styles.Palette.Accent.GetForeground())
|
||||||
|
|
||||||
|
// Ensure the filter input text is readable (using Text color instead of potentially inheriting something else)
|
||||||
|
l.FilterInput.TextStyle = lipgloss.NewStyle().Foreground(c.Styles.Palette.Text.GetForeground())
|
||||||
|
l.FilterInput.PromptStyle = l.Styles.FilterPrompt
|
||||||
|
l.FilterInput.CursorStyle = l.Styles.FilterCursor
|
||||||
|
|
||||||
l.SetShowTitle(false)
|
l.SetShowTitle(false)
|
||||||
l.SetShowHelp(false)
|
l.SetShowHelp(false)
|
||||||
l.SetShowStatusBar(false)
|
l.SetShowStatusBar(false)
|
||||||
l.SetFilteringEnabled(true)
|
l.SetFilteringEnabled(true)
|
||||||
|
l.Filter = list.UnsortedFilter // Preserve item order, don't rank by match quality
|
||||||
|
|
||||||
// Custom key for filtering (insert mode)
|
// Custom key for filtering (insert mode)
|
||||||
l.KeyMap.Filter = key.NewBinding(
|
l.KeyMap.Filter = key.NewBinding(
|
||||||
@@ -76,12 +118,26 @@ func New(
|
|||||||
key.WithHelp("i", "filter"),
|
key.WithHelp("i", "filter"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Disable the quit key binding - we don't want Esc to quit the list
|
||||||
|
// Esc should only cancel filtering mode
|
||||||
|
l.KeyMap.Quit = key.NewBinding(
|
||||||
|
key.WithKeys(), // No keys bound
|
||||||
|
key.WithHelp("", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Also disable force quit
|
||||||
|
l.KeyMap.ForceQuit = key.NewBinding(
|
||||||
|
key.WithKeys(), // No keys bound
|
||||||
|
key.WithHelp("", ""),
|
||||||
|
)
|
||||||
|
|
||||||
p := &Picker{
|
p := &Picker{
|
||||||
common: c,
|
common: c,
|
||||||
list: l,
|
list: l,
|
||||||
itemProvider: itemProvider,
|
itemProvider: itemProvider,
|
||||||
onSelect: onSelect,
|
onSelect: onSelect,
|
||||||
title: title,
|
title: title,
|
||||||
|
focused: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.TW.GetConfig().Get("uda.tasksquire.picker.filter_by_default") == "yes" {
|
if c.TW.GetConfig().Get("uda.tasksquire.picker.filter_by_default") == "yes" {
|
||||||
@@ -92,8 +148,24 @@ func New(
|
|||||||
opt(p)
|
opt(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If a default value is provided, don't start in filter mode
|
||||||
|
if p.defaultValue != "" {
|
||||||
|
p.filterByDefault = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.filterByDefault {
|
||||||
|
// Manually trigger filter mode on the list so it doesn't require a global key press
|
||||||
|
p.list, _ = p.list.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh items after entering filter mode to ensure they're visible
|
||||||
p.Refresh()
|
p.Refresh()
|
||||||
|
|
||||||
|
// If a default value is provided, select the corresponding item
|
||||||
|
if p.defaultValue != "" {
|
||||||
|
p.SelectItemByFilterValue(p.defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,18 +175,22 @@ func (p *Picker) Refresh() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Picker) updateListItems() tea.Cmd {
|
func (p *Picker) updateListItems() tea.Cmd {
|
||||||
items := p.baseItems
|
return p.updateListItemsWithFilter(p.list.FilterValue())
|
||||||
filterVal := p.list.FilterValue()
|
}
|
||||||
|
|
||||||
|
func (p *Picker) updateListItemsWithFilter(filterVal string) tea.Cmd {
|
||||||
|
items := make([]list.Item, 0, len(p.baseItems)+1)
|
||||||
|
|
||||||
|
// First add all base items
|
||||||
|
items = append(items, p.baseItems...)
|
||||||
|
|
||||||
if p.onCreate != nil && filterVal != "" {
|
if p.onCreate != nil && filterVal != "" {
|
||||||
|
// Add the creation item at the end (bottom of the list)
|
||||||
newItem := creationItem{
|
newItem := creationItem{
|
||||||
text: "(new) " + filterVal,
|
text: "(new) " + filterVal,
|
||||||
filter: filterVal,
|
filter: filterVal,
|
||||||
}
|
}
|
||||||
newItems := make([]list.Item, len(items)+1)
|
items = append(items, newItem)
|
||||||
copy(newItems, items)
|
|
||||||
newItems[len(items)] = newItem
|
|
||||||
items = newItems
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.list.SetItems(items)
|
return p.list.SetItems(items)
|
||||||
@@ -134,27 +210,42 @@ func (p *Picker) SetSize(width, height int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Picker) Init() tea.Cmd {
|
func (p *Picker) Init() tea.Cmd {
|
||||||
if p.filterByDefault {
|
// Trigger list item update to ensure items are properly displayed,
|
||||||
return func() tea.Msg {
|
// especially when in filter mode with an empty filter
|
||||||
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}
|
return p.updateListItems()
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if !p.focused {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
// If filtering, let the list handle keys (including Enter to stop filtering)
|
// If filtering, update items with predicted filter before list processes the key
|
||||||
if p.list.FilterState() == list.Filtering {
|
if p.list.FilterState() == list.Filtering {
|
||||||
if key.Matches(msg, p.common.Keymap.Ok) {
|
currentFilter := p.list.FilterValue()
|
||||||
items := p.list.VisibleItems()
|
predictedFilter := currentFilter
|
||||||
if len(items) == 1 {
|
|
||||||
return p, p.handleSelect(items[0])
|
// Predict what the filter will be after this key
|
||||||
|
switch msg.Type {
|
||||||
|
case tea.KeyRunes:
|
||||||
|
predictedFilter = currentFilter + string(msg.Runes)
|
||||||
|
case tea.KeyBackspace:
|
||||||
|
if len(currentFilter) > 0 {
|
||||||
|
predictedFilter = currentFilter[:len(currentFilter)-1]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update items with predicted filter before list processes the message
|
||||||
|
if predictedFilter != currentFilter {
|
||||||
|
preCmd := p.updateListItemsWithFilter(predictedFilter)
|
||||||
|
cmds = append(cmds, preCmd)
|
||||||
|
}
|
||||||
|
|
||||||
break // Pass to list.Update
|
break // Pass to list.Update
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,15 +259,10 @@ func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
prevFilter := p.list.FilterValue()
|
|
||||||
p.list, cmd = p.list.Update(msg)
|
p.list, cmd = p.list.Update(msg)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
if p.list.FilterValue() != prevFilter {
|
return p, tea.Batch(cmds...)
|
||||||
updateCmd := p.updateListItems()
|
|
||||||
return p, tea.Batch(cmd, updateCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
return p, cmd
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Picker) handleSelect(item list.Item) tea.Cmd {
|
func (p *Picker) handleSelect(item list.Item) tea.Cmd {
|
||||||
@@ -189,7 +275,12 @@ func (p *Picker) handleSelect(item list.Item) tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Picker) View() string {
|
func (p *Picker) View() string {
|
||||||
title := p.common.Styles.Form.Focused.Title.Render(p.title)
|
var title string
|
||||||
|
if p.focused {
|
||||||
|
title = p.common.Styles.Form.Focused.Title.Render(p.title)
|
||||||
|
} else {
|
||||||
|
title = p.common.Styles.Form.Blurred.Title.Render(p.title)
|
||||||
|
}
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, title, p.list.View())
|
return lipgloss.JoinVertical(lipgloss.Left, title, p.list.View())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type Model struct {
|
|||||||
focus bool
|
focus bool
|
||||||
styles common.TableStyle
|
styles common.TableStyle
|
||||||
styleFunc StyleFunc
|
styleFunc StyleFunc
|
||||||
|
taskTree *taskwarrior.TaskTree
|
||||||
|
|
||||||
viewport viewport.Model
|
viewport viewport.Model
|
||||||
start int
|
start int
|
||||||
@@ -242,9 +243,31 @@ func (m *Model) parseColumns(cols []Column) []Column {
|
|||||||
return cols
|
return cols
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate max tree depth for indentation
|
||||||
|
maxTreeWidth := 0
|
||||||
|
if m.taskTree != nil {
|
||||||
|
for _, node := range m.taskTree.FlatList {
|
||||||
|
// Calculate indentation: depth * 2 spaces + tree characters (3 chars for "└─ ")
|
||||||
|
treeWidth := 0
|
||||||
|
if node.Depth > 0 {
|
||||||
|
treeWidth = node.Depth*2 + 3
|
||||||
|
}
|
||||||
|
// Add progress indicator width for parent tasks (e.g., " (3/5)" = 6 chars max)
|
||||||
|
if node.HasChildren() {
|
||||||
|
treeWidth += 7
|
||||||
|
}
|
||||||
|
maxTreeWidth = max(maxTreeWidth, treeWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for i, col := range cols {
|
for i, col := range cols {
|
||||||
for _, task := range m.rows {
|
for _, task := range m.rows {
|
||||||
col.ContentWidth = max(col.ContentWidth, lipgloss.Width(task.GetString(col.Name)))
|
contentWidth := lipgloss.Width(task.GetString(col.Name))
|
||||||
|
// Add tree width to description column
|
||||||
|
if strings.Contains(col.Name, "description") {
|
||||||
|
contentWidth += maxTreeWidth
|
||||||
|
}
|
||||||
|
col.ContentWidth = max(col.ContentWidth, contentWidth)
|
||||||
}
|
}
|
||||||
cols[i] = col
|
cols[i] = col
|
||||||
}
|
}
|
||||||
@@ -351,6 +374,13 @@ func WithKeyMap(km KeyMap) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithTaskTree sets the task tree for hierarchical display.
|
||||||
|
func WithTaskTree(tree *taskwarrior.TaskTree) Option {
|
||||||
|
return func(m *Model) {
|
||||||
|
m.taskTree = tree
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update is the Bubble Tea update loop.
|
// Update is the Bubble Tea update loop.
|
||||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||||
if !m.focus {
|
if !m.focus {
|
||||||
@@ -571,6 +601,21 @@ func (m Model) headersView() string {
|
|||||||
|
|
||||||
func (m *Model) renderRow(r int) string {
|
func (m *Model) renderRow(r int) string {
|
||||||
var s = make([]string, 0, len(m.cols))
|
var s = make([]string, 0, len(m.cols))
|
||||||
|
|
||||||
|
// Extract tree metadata for this row
|
||||||
|
var depth int
|
||||||
|
var hasChildren bool
|
||||||
|
var progress string
|
||||||
|
if m.taskTree != nil && r < len(m.taskTree.FlatList) {
|
||||||
|
node := m.taskTree.FlatList[r]
|
||||||
|
depth = node.Depth
|
||||||
|
hasChildren = node.HasChildren()
|
||||||
|
if hasChildren {
|
||||||
|
completed, total := node.GetChildrenStatus()
|
||||||
|
progress = fmt.Sprintf(" (%d/%d)", completed, total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for i, col := range m.cols {
|
for i, col := range m.cols {
|
||||||
// for i, task := range m.rows[r] {
|
// for i, task := range m.rows[r] {
|
||||||
if m.cols[i].Width <= 0 {
|
if m.cols[i].Width <= 0 {
|
||||||
@@ -589,8 +634,16 @@ func (m *Model) renderRow(r int) string {
|
|||||||
cellStyle = cellStyle.Inherit(m.styles.Selected)
|
cellStyle = cellStyle.Inherit(m.styles.Selected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render cell content with tree formatting for description column
|
||||||
|
var cellContent string
|
||||||
|
if strings.Contains(col.Name, "description") && m.taskTree != nil {
|
||||||
|
cellContent = m.renderTreeDescription(r, depth, hasChildren, progress)
|
||||||
|
} else {
|
||||||
|
cellContent = m.rows[r].GetString(col.Name)
|
||||||
|
}
|
||||||
|
|
||||||
style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true)
|
style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true)
|
||||||
renderedCell := cellStyle.Render(style.Render(runewidth.Truncate(m.rows[r].GetString(col.Name), m.cols[i].Width, "…")))
|
renderedCell := cellStyle.Render(style.Render(runewidth.Truncate(cellContent, m.cols[i].Width, "…")))
|
||||||
s = append(s, renderedCell)
|
s = append(s, renderedCell)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,6 +656,25 @@ func (m *Model) renderRow(r int) string {
|
|||||||
return row
|
return row
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderTreeDescription renders the description column with tree indentation and progress
|
||||||
|
func (m *Model) renderTreeDescription(rowIdx int, depth int, hasChildren bool, progress string) string {
|
||||||
|
task := m.rows[rowIdx]
|
||||||
|
desc := task.Description
|
||||||
|
|
||||||
|
// Build indentation and tree characters
|
||||||
|
prefix := ""
|
||||||
|
if depth > 0 {
|
||||||
|
prefix = strings.Repeat(" ", depth) + "└─ "
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add progress indicator for parent tasks
|
||||||
|
if hasChildren {
|
||||||
|
desc = desc + progress
|
||||||
|
}
|
||||||
|
|
||||||
|
return prefix + desc
|
||||||
|
}
|
||||||
|
|
||||||
func max(a, b int) int {
|
func max(a, b int) int {
|
||||||
if a > b {
|
if a > b {
|
||||||
return a
|
return a
|
||||||
|
|||||||
@@ -187,14 +187,14 @@ func (t *TimestampEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
t.adjustDate(1)
|
t.adjustDate(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Time field adjustments (uppercase - 30 minutes) or date adjustments (week)
|
// Time field adjustments (uppercase - 60 minutes) or date adjustments (week)
|
||||||
case "J":
|
case "J":
|
||||||
// Set current time on first edit if empty
|
// Set current time on first edit if empty
|
||||||
if t.isEmpty {
|
if t.isEmpty {
|
||||||
t.setCurrentTime()
|
t.setCurrentTime()
|
||||||
}
|
}
|
||||||
if t.currentField == TimeField {
|
if t.currentField == TimeField {
|
||||||
t.adjustTime(-30)
|
t.adjustTime(-60)
|
||||||
} else {
|
} else {
|
||||||
t.adjustDate(-7)
|
t.adjustDate(-7)
|
||||||
}
|
}
|
||||||
@@ -204,7 +204,7 @@ func (t *TimestampEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
t.setCurrentTime()
|
t.setCurrentTime()
|
||||||
}
|
}
|
||||||
if t.currentField == TimeField {
|
if t.currentField == TimeField {
|
||||||
t.adjustTime(30)
|
t.adjustTime(60)
|
||||||
} else {
|
} else {
|
||||||
t.adjustDate(7)
|
t.adjustDate(7)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -530,7 +530,7 @@ func (m *Model) renderRow(r int) string {
|
|||||||
if m.rows[r].IsGap {
|
if m.rows[r].IsGap {
|
||||||
gapText := m.rows[r].GetString("gap_display")
|
gapText := m.rows[r].GetString("gap_display")
|
||||||
gapStyle := lipgloss.NewStyle().
|
gapStyle := lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("240")).
|
Foreground(m.common.Styles.Palette.Muted.GetForeground()).
|
||||||
Align(lipgloss.Center).
|
Align(lipgloss.Center).
|
||||||
Width(m.Width())
|
Width(m.Width())
|
||||||
return gapStyle.Render(gapText)
|
return gapStyle.Render(gapText)
|
||||||
|
|||||||
63
flake.lock
generated
63
flake.lock
generated
@@ -1,58 +1,59 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"flake-utils": {
|
"flake-parts": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"systems": "systems"
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1710146030,
|
"lastModified": 1769996383,
|
||||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
|
||||||
"owner": "numtide",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-utils",
|
"repo": "flake-parts",
|
||||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "numtide",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-utils",
|
"repo": "flake-parts",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1715787315,
|
"lastModified": 1771369470,
|
||||||
"narHash": "sha256-cYApT0NXJfqBkKcci7D9Kr4CBYZKOQKDYA23q8XNuWg=",
|
"narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
|
||||||
"owner": "NixOS",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "33d1e753c82ffc557b4a585c77de43d4c922ebb5",
|
"rev": "0182a361324364ae3f436a63005877674cf45efb",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"id": "nixpkgs",
|
"owner": "nixos",
|
||||||
"ref": "nixos-unstable",
|
"ref": "nixos-unstable",
|
||||||
"type": "indirect"
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs-lib": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1769909678,
|
||||||
|
"narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "nixpkgs.lib",
|
||||||
|
"rev": "72716169fe93074c333e8d0173151350670b824c",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "nixpkgs.lib",
|
||||||
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-utils": "flake-utils",
|
"flake-parts": "flake-parts",
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"systems": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681028828,
|
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "root",
|
"root": "root",
|
||||||
|
|||||||
55
flake.nix
55
flake.nix
@@ -2,23 +2,44 @@
|
|||||||
description = "Tasksquire";
|
description = "Tasksquire";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, flake-utils }:
|
outputs = inputs@{ self, nixpkgs, flake-parts, ... }:
|
||||||
flake-utils.lib.eachDefaultSystem (system:
|
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||||
let
|
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ];
|
||||||
pkgs = import nixpkgs {
|
|
||||||
inherit system;
|
|
||||||
};
|
|
||||||
|
|
||||||
buildDeps = with pkgs; [
|
perSystem = { config, self', inputs', pkgs, system, ... }: {
|
||||||
go_1_22
|
packages.tasksquire = pkgs.buildGoModule {
|
||||||
gcc
|
pname = "tasksquire";
|
||||||
|
version = "0.1.0";
|
||||||
|
src = ./.;
|
||||||
|
|
||||||
|
vendorHash = "sha256-fDzQuKBZPkOATMMnYcFv/aJP62XDhL9LjM/UYre9JQ4=";
|
||||||
|
|
||||||
|
ldflags = [ "-s" "-w" ];
|
||||||
|
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
taskwarrior3
|
||||||
|
timewarrior
|
||||||
];
|
];
|
||||||
|
|
||||||
devDeps = with pkgs; buildDeps ++ [
|
meta = with pkgs.lib; {
|
||||||
|
description = "A Terminal User Interface (TUI) for Taskwarrior";
|
||||||
|
mainProgram = "tasksquire";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Set the default package
|
||||||
|
packages.default = self'.packages.tasksquire;
|
||||||
|
|
||||||
|
# Development shell
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
inputsFrom = [ self'.packages.tasksquire ];
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
go_1_24
|
||||||
|
gcc
|
||||||
gotools
|
gotools
|
||||||
golangci-lint
|
golangci-lint
|
||||||
gopls
|
gopls
|
||||||
@@ -28,12 +49,8 @@
|
|||||||
gotests
|
gotests
|
||||||
delve
|
delve
|
||||||
];
|
];
|
||||||
in
|
CGO_CFLAGS = "-O";
|
||||||
{
|
};
|
||||||
devShell = pkgs.mkShell {
|
};
|
||||||
buildInputs = devDeps;
|
|
||||||
CGO_CFLAGS="-O";
|
|
||||||
};
|
};
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
38
go.mod
38
go.mod
@@ -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
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 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=
|
||||||
|
|||||||
14
main.go
14
main.go
@@ -40,12 +40,18 @@ func main() {
|
|||||||
timewConfigPath = ""
|
timewConfigPath = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
ts := taskwarrior.NewTaskSquire(taskrcPath)
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
tws := timewarrior.NewTimeSquire(timewConfigPath)
|
defer cancel()
|
||||||
ctx := context.Background()
|
|
||||||
|
ts := taskwarrior.NewTaskSquire(ctx, taskrcPath)
|
||||||
|
if ts == nil {
|
||||||
|
log.Fatal("Failed to initialize TaskSquire. Please check your Taskwarrior installation and taskrc file.")
|
||||||
|
}
|
||||||
|
|
||||||
|
tws := timewarrior.NewTimeSquire(ctx, timewConfigPath)
|
||||||
common := common.NewCommon(ctx, ts, tws)
|
common := common.NewCommon(ctx, ts, tws)
|
||||||
|
|
||||||
file, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
file, err := os.OpenFile("/tmp/tasksquire.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to open log file: %v", err)
|
log.Fatalf("failed to open log file: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
118
on-modify.timewarrior
Normal file
118
on-modify.timewarrior
Normal 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)
|
||||||
40
opencode_sandbox.sh
Normal file
40
opencode_sandbox.sh
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# 1. Resolve the absolute path of opencode from your Nix environment
|
||||||
|
OPENCODE_PATH=$(command -v opencode)
|
||||||
|
|
||||||
|
if [ -z "$OPENCODE_PATH" ]; then
|
||||||
|
echo "❌ Error: 'opencode' not found in your PATH."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🛡️ Engaging Bubblewrap Sandbox..."
|
||||||
|
echo "📍 Using binary: $OPENCODE_PATH"
|
||||||
|
|
||||||
|
# 2. Run bwrap using the absolute path
|
||||||
|
bwrap \
|
||||||
|
--ro-bind /bin /bin \
|
||||||
|
--ro-bind /usr /usr \
|
||||||
|
--ro-bind /lib /lib \
|
||||||
|
--ro-bind /lib64 /lib64 \
|
||||||
|
--ro-bind /nix /nix \
|
||||||
|
--ro-bind /home/pan/.nix-profile/bin /home/pan/.nix-profile/bin \
|
||||||
|
--ro-bind /home/pan/.config/opencode /home/pan/.config/opencode \
|
||||||
|
--ro-bind /etc/resolv.conf /etc/resolv.conf \
|
||||||
|
--ro-bind /etc/hosts /etc/hosts \
|
||||||
|
--ro-bind-try /etc/ssl/certs /etc/ssl/certs \
|
||||||
|
--ro-bind-try /etc/static/ssl/certs /etc/static/ssl/certs \
|
||||||
|
--bind /home/pan/.local/share/opencode /home/pan/.local/share/opencode \
|
||||||
|
--proc /proc \
|
||||||
|
--dev-bind /dev /dev \
|
||||||
|
--tmpfs /tmp \
|
||||||
|
--unshare-all \
|
||||||
|
--share-net \
|
||||||
|
--die-with-parent \
|
||||||
|
--bind "$(pwd)" "$(pwd)" \
|
||||||
|
--chdir "$(pwd)" \
|
||||||
|
--setenv PATH "$PATH" \
|
||||||
|
--setenv HOME "$HOME" \
|
||||||
|
--setenv TASKRC "$TASKRC" \
|
||||||
|
--setenv TASKDATA "$TASKDATA" \
|
||||||
|
"$OPENCODE_PATH" "$@"
|
||||||
@@ -65,16 +65,9 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage {
|
|||||||
func (p *ContextPickerPage) SetSize(width, height int) {
|
func (p *ContextPickerPage) SetSize(width, height int) {
|
||||||
p.common.SetSize(width, height)
|
p.common.SetSize(width, height)
|
||||||
|
|
||||||
// Set list size with some padding/limits to look like a picker
|
// Use shared modal sizing logic
|
||||||
listWidth := width - 4
|
modalWidth, modalHeight := p.common.Styles.GetModalSize(width, height)
|
||||||
if listWidth > 40 {
|
p.picker.SetSize(modalWidth-2, modalHeight-2)
|
||||||
listWidth = 40
|
|
||||||
}
|
|
||||||
listHeight := height - 6
|
|
||||||
if listHeight > 20 {
|
|
||||||
listHeight = 20
|
|
||||||
}
|
|
||||||
p.picker.SetSize(listWidth, listHeight)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ContextPickerPage) Init() tea.Cmd {
|
func (p *ContextPickerPage) Init() tea.Cmd {
|
||||||
@@ -124,20 +117,23 @@ func (p *ContextPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *ContextPickerPage) View() string {
|
func (p *ContextPickerPage) View() string {
|
||||||
width := p.common.Width() - 4
|
modalWidth, modalHeight := p.common.Styles.GetModalSize(p.common.Width(), p.common.Height())
|
||||||
if width > 40 {
|
|
||||||
width = 40
|
|
||||||
}
|
|
||||||
|
|
||||||
content := p.picker.View()
|
content := p.picker.View()
|
||||||
styledContent := lipgloss.NewStyle().Width(width).Render(content)
|
styledContent := lipgloss.NewStyle().
|
||||||
|
Width(modalWidth).
|
||||||
|
Height(modalHeight).
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(p.common.Styles.Palette.Border.GetForeground()).
|
||||||
|
Padding(0, 1).
|
||||||
|
Render(content)
|
||||||
|
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
p.common.Width(),
|
p.common.Width(),
|
||||||
p.common.Height(),
|
p.common.Height(),
|
||||||
lipgloss.Center,
|
lipgloss.Center,
|
||||||
lipgloss.Center,
|
lipgloss.Center,
|
||||||
p.common.Styles.Base.Render(styledContent),
|
styledContent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,3 +82,7 @@ func doTick() tea.Cmd {
|
|||||||
return tickMsg(t)
|
return tickMsg(t)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TaskPickedMsg struct {
|
||||||
|
Task *taskwarrior.Task
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,16 +55,9 @@ func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectP
|
|||||||
func (p *ProjectPickerPage) SetSize(width, height int) {
|
func (p *ProjectPickerPage) SetSize(width, height int) {
|
||||||
p.common.SetSize(width, height)
|
p.common.SetSize(width, height)
|
||||||
|
|
||||||
// Set list size with some padding/limits to look like a picker
|
// Use shared modal sizing logic
|
||||||
listWidth := width - 4
|
modalWidth, modalHeight := p.common.Styles.GetModalSize(width, height)
|
||||||
if listWidth > 40 {
|
p.picker.SetSize(modalWidth-2, modalHeight-2)
|
||||||
listWidth = 40
|
|
||||||
}
|
|
||||||
listHeight := height - 6
|
|
||||||
if listHeight > 20 {
|
|
||||||
listHeight = 20
|
|
||||||
}
|
|
||||||
p.picker.SetSize(listWidth, listHeight)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ProjectPickerPage) Init() tea.Cmd {
|
func (p *ProjectPickerPage) Init() tea.Cmd {
|
||||||
@@ -112,20 +105,23 @@ func (p *ProjectPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *ProjectPickerPage) View() string {
|
func (p *ProjectPickerPage) View() string {
|
||||||
width := p.common.Width() - 4
|
modalWidth, modalHeight := p.common.Styles.GetModalSize(p.common.Width(), p.common.Height())
|
||||||
if width > 40 {
|
|
||||||
width = 40
|
|
||||||
}
|
|
||||||
|
|
||||||
content := p.picker.View()
|
content := p.picker.View()
|
||||||
styledContent := lipgloss.NewStyle().Width(width).Render(content)
|
styledContent := lipgloss.NewStyle().
|
||||||
|
Width(modalWidth).
|
||||||
|
Height(modalHeight).
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(p.common.Styles.Palette.Border.GetForeground()).
|
||||||
|
Padding(0, 1).
|
||||||
|
Render(content)
|
||||||
|
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
p.common.Width(),
|
p.common.Width(),
|
||||||
p.common.Height(),
|
p.common.Height(),
|
||||||
lipgloss.Center,
|
lipgloss.Center,
|
||||||
lipgloss.Center,
|
lipgloss.Center,
|
||||||
p.common.Styles.Base.Render(styledContent),
|
styledContent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
468
pages/projectTaskPicker.go
Normal file
468
pages/projectTaskPicker.go
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
focusedBorder := p.common.Styles.Palette.Accent.GetForeground()
|
||||||
|
blurredBorder := p.common.Styles.Palette.Border.GetForeground()
|
||||||
|
|
||||||
|
if p.focusedPicker == 0 {
|
||||||
|
// Project picker is focused
|
||||||
|
projectStyled = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.ThickBorder()).
|
||||||
|
BorderForeground(focusedBorder).
|
||||||
|
Padding(0, 1).
|
||||||
|
Render(projectView)
|
||||||
|
|
||||||
|
taskStyled = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.NormalBorder()).
|
||||||
|
BorderForeground(blurredBorder).
|
||||||
|
Padding(0, 1).
|
||||||
|
Render(taskView)
|
||||||
|
} else {
|
||||||
|
// Task picker is focused
|
||||||
|
projectStyled = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.NormalBorder()).
|
||||||
|
BorderForeground(blurredBorder).
|
||||||
|
Padding(0, 1).
|
||||||
|
Render(projectView)
|
||||||
|
|
||||||
|
taskStyled = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.ThickBorder()).
|
||||||
|
BorderForeground(focusedBorder).
|
||||||
|
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(p.common.Styles.Palette.Muted.GetForeground()).
|
||||||
|
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
|
||||||
|
}
|
||||||
183
pages/report.go
183
pages/report.go
@@ -3,13 +3,13 @@ package pages
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"tasksquire/common"
|
"tasksquire/common"
|
||||||
|
"tasksquire/components/detailsviewer"
|
||||||
"tasksquire/components/table"
|
"tasksquire/components/table"
|
||||||
"tasksquire/taskwarrior"
|
"tasksquire/taskwarrior"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
|
||||||
// "github.com/charmbracelet/bubbles/table"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ReportPage struct {
|
type ReportPage struct {
|
||||||
@@ -25,6 +25,10 @@ type ReportPage struct {
|
|||||||
|
|
||||||
taskTable table.Model
|
taskTable table.Model
|
||||||
|
|
||||||
|
// Details panel state
|
||||||
|
detailsPanelActive bool
|
||||||
|
detailsViewer *detailsviewer.DetailsViewer
|
||||||
|
|
||||||
subpage common.Component
|
subpage common.Component
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +47,8 @@ func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
|
|||||||
activeContext: com.TW.GetActiveContext(),
|
activeContext: com.TW.GetActiveContext(),
|
||||||
activeProject: "",
|
activeProject: "",
|
||||||
taskTable: table.New(com),
|
taskTable: table.New(com),
|
||||||
|
detailsPanelActive: false,
|
||||||
|
detailsViewer: detailsviewer.New(com),
|
||||||
}
|
}
|
||||||
|
|
||||||
return p
|
return p
|
||||||
@@ -51,8 +57,39 @@ func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
|
|||||||
func (p *ReportPage) SetSize(width int, height int) {
|
func (p *ReportPage) SetSize(width int, height int) {
|
||||||
p.common.SetSize(width, height)
|
p.common.SetSize(width, height)
|
||||||
|
|
||||||
p.taskTable.SetWidth(width - p.common.Styles.Base.GetHorizontalFrameSize())
|
baseHeight := height - p.common.Styles.Base.GetVerticalFrameSize()
|
||||||
p.taskTable.SetHeight(height - p.common.Styles.Base.GetVerticalFrameSize())
|
baseWidth := width - p.common.Styles.Base.GetHorizontalFrameSize()
|
||||||
|
|
||||||
|
var tableHeight int
|
||||||
|
if p.detailsPanelActive {
|
||||||
|
// Allocate 60% for table, 40% for details panel
|
||||||
|
// Minimum 5 lines for details, minimum 10 lines for table
|
||||||
|
detailsHeight := max(min(baseHeight*2/5, baseHeight-10), 5)
|
||||||
|
tableHeight = baseHeight - detailsHeight - 1 // -1 for spacing
|
||||||
|
|
||||||
|
// Set component size (component handles its own border/padding)
|
||||||
|
p.detailsViewer.SetSize(baseWidth, detailsHeight)
|
||||||
|
} else {
|
||||||
|
tableHeight = baseHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
p.taskTable.SetWidth(baseWidth)
|
||||||
|
p.taskTable.SetHeight(tableHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
func max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ReportPage) Init() tea.Cmd {
|
func (p *ReportPage) Init() tea.Cmd {
|
||||||
@@ -82,9 +119,23 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case UpdateProjectMsg:
|
case UpdateProjectMsg:
|
||||||
p.activeProject = string(msg)
|
p.activeProject = string(msg)
|
||||||
cmds = append(cmds, p.getTasks())
|
cmds = append(cmds, p.getTasks())
|
||||||
|
case TaskPickedMsg:
|
||||||
|
if msg.Task != nil && msg.Task.Status == "pending" {
|
||||||
|
p.common.TW.StopActiveTasks()
|
||||||
|
p.common.TW.StartTask(msg.Task)
|
||||||
|
}
|
||||||
|
cmds = append(cmds, p.getTasks())
|
||||||
case UpdatedTasksMsg:
|
case UpdatedTasksMsg:
|
||||||
cmds = append(cmds, p.getTasks())
|
cmds = append(cmds, p.getTasks())
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
|
// Handle ESC when details panel is active
|
||||||
|
if p.detailsPanelActive && key.Matches(msg, p.common.Keymap.Back) {
|
||||||
|
p.detailsPanelActive = false
|
||||||
|
p.detailsViewer.Blur()
|
||||||
|
p.SetSize(p.common.Width(), p.common.Height())
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, p.common.Keymap.Quit):
|
case key.Matches(msg, p.common.Keymap.Quit):
|
||||||
return p, tea.Quit
|
return p, tea.Quit
|
||||||
@@ -108,6 +159,40 @@ 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.Subtask):
|
||||||
|
if p.selectedTask != nil {
|
||||||
|
// Create new task inheriting parent's attributes
|
||||||
|
newTask := taskwarrior.NewTask()
|
||||||
|
|
||||||
|
// Set parent relationship
|
||||||
|
newTask.Parent = p.selectedTask.Uuid
|
||||||
|
|
||||||
|
// Copy parent's attributes
|
||||||
|
newTask.Project = p.selectedTask.Project
|
||||||
|
newTask.Priority = p.selectedTask.Priority
|
||||||
|
newTask.Tags = make([]string, len(p.selectedTask.Tags))
|
||||||
|
copy(newTask.Tags, p.selectedTask.Tags)
|
||||||
|
|
||||||
|
// Copy UDAs (except "details" which is task-specific)
|
||||||
|
if p.selectedTask.Udas != nil {
|
||||||
|
newTask.Udas = make(map[string]any)
|
||||||
|
for k, v := range p.selectedTask.Udas {
|
||||||
|
// Skip "details" UDA - it's specific to parent task
|
||||||
|
if k == "details" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Deep copy other UDA values
|
||||||
|
newTask.Udas[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open task editor with pre-populated task
|
||||||
|
p.subpage = NewTaskEditorPage(p.common, newTask)
|
||||||
|
cmd := p.subpage.Init()
|
||||||
|
p.common.PushPage(p)
|
||||||
|
return p.subpage, cmd
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
case key.Matches(msg, p.common.Keymap.Ok):
|
case key.Matches(msg, p.common.Keymap.Ok):
|
||||||
p.common.TW.SetTaskDone(p.selectedTask)
|
p.common.TW.SetTaskDone(p.selectedTask)
|
||||||
return p, p.getTasks()
|
return p, p.getTasks()
|
||||||
@@ -119,6 +204,11 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
cmd := p.subpage.Init()
|
cmd := p.subpage.Init()
|
||||||
p.common.PushPage(p)
|
p.common.PushPage(p)
|
||||||
return p.subpage, cmd
|
return p.subpage, cmd
|
||||||
|
case key.Matches(msg, p.common.Keymap.PickProjectTask):
|
||||||
|
p.subpage = NewProjectTaskPickerPage(p.common)
|
||||||
|
cmd := p.subpage.Init()
|
||||||
|
p.common.PushPage(p)
|
||||||
|
return p.subpage, cmd
|
||||||
case key.Matches(msg, p.common.Keymap.Tag):
|
case key.Matches(msg, p.common.Keymap.Tag):
|
||||||
if p.selectedTask != nil {
|
if p.selectedTask != nil {
|
||||||
tag := p.common.TW.GetConfig().Get("uda.tasksquire.tag.default")
|
tag := p.common.TW.GetConfig().Get("uda.tasksquire.tag.default")
|
||||||
@@ -137,16 +227,40 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case key.Matches(msg, p.common.Keymap.StartStop):
|
case key.Matches(msg, p.common.Keymap.StartStop):
|
||||||
if p.selectedTask != nil && p.selectedTask.Status == "pending" {
|
if p.selectedTask != nil && p.selectedTask.Status == "pending" {
|
||||||
if p.selectedTask.Start == "" {
|
if p.selectedTask.Start == "" {
|
||||||
|
p.common.TW.StopActiveTasks()
|
||||||
p.common.TW.StartTask(p.selectedTask)
|
p.common.TW.StartTask(p.selectedTask)
|
||||||
} else {
|
} else {
|
||||||
p.common.TW.StopTask(p.selectedTask)
|
p.common.TW.StopTask(p.selectedTask)
|
||||||
}
|
}
|
||||||
return p, p.getTasks()
|
return p, p.getTasks()
|
||||||
}
|
}
|
||||||
|
case key.Matches(msg, p.common.Keymap.ViewDetails):
|
||||||
|
if p.selectedTask != nil {
|
||||||
|
// Toggle details panel
|
||||||
|
p.detailsPanelActive = !p.detailsPanelActive
|
||||||
|
if p.detailsPanelActive {
|
||||||
|
p.detailsViewer.SetTask(p.selectedTask)
|
||||||
|
p.detailsViewer.Focus()
|
||||||
|
} else {
|
||||||
|
p.detailsViewer.Blur()
|
||||||
|
}
|
||||||
|
p.SetSize(p.common.Width(), p.common.Height())
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
|
|
||||||
|
// Route keyboard messages to details viewer when panel is active
|
||||||
|
if p.detailsPanelActive {
|
||||||
|
var viewerCmd tea.Cmd
|
||||||
|
var viewerModel tea.Model
|
||||||
|
viewerModel, viewerCmd = p.detailsViewer.Update(msg)
|
||||||
|
p.detailsViewer = viewerModel.(*detailsviewer.DetailsViewer)
|
||||||
|
cmds = append(cmds, viewerCmd)
|
||||||
|
} else {
|
||||||
|
// Route to table when details panel not active
|
||||||
p.taskTable, cmd = p.taskTable.Update(msg)
|
p.taskTable, cmd = p.taskTable.Update(msg)
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
@@ -155,16 +269,28 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
} else {
|
} else {
|
||||||
p.selectedTask = nil
|
p.selectedTask = nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return p, tea.Batch(cmds...)
|
return p, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ReportPage) View() string {
|
func (p *ReportPage) View() string {
|
||||||
// return p.common.Styles.Main.Render(p.taskTable.View()) + "\n"
|
|
||||||
if p.tasks == nil || len(p.tasks) == 0 {
|
if p.tasks == nil || len(p.tasks) == 0 {
|
||||||
return p.common.Styles.Base.Render("No tasks found")
|
return p.common.Styles.Base.Render("No tasks found")
|
||||||
}
|
}
|
||||||
return p.taskTable.View()
|
|
||||||
|
tableView := p.taskTable.View()
|
||||||
|
|
||||||
|
if !p.detailsPanelActive {
|
||||||
|
return tableView
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine table and details panel vertically
|
||||||
|
return lipgloss.JoinVertical(
|
||||||
|
lipgloss.Left,
|
||||||
|
tableView,
|
||||||
|
p.detailsViewer.View(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) {
|
func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) {
|
||||||
@@ -172,37 +298,68 @@ func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build task tree for hierarchical display
|
||||||
|
taskTree := taskwarrior.BuildTaskTree(tasks)
|
||||||
|
|
||||||
|
// Use flattened tree list for display order
|
||||||
|
orderedTasks := make(taskwarrior.Tasks, len(taskTree.FlatList))
|
||||||
|
for i, node := range taskTree.FlatList {
|
||||||
|
orderedTasks[i] = node.Task
|
||||||
|
}
|
||||||
|
|
||||||
selected := p.taskTable.Cursor()
|
selected := p.taskTable.Cursor()
|
||||||
|
|
||||||
|
// Adjust cursor for tree ordering
|
||||||
if p.selectedTask != nil {
|
if p.selectedTask != nil {
|
||||||
for i, task := range tasks {
|
for i, task := range orderedTasks {
|
||||||
if task.Uuid == p.selectedTask.Uuid {
|
if task.Uuid == p.selectedTask.Uuid {
|
||||||
selected = i
|
selected = i
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if selected > len(tasks)-1 {
|
if selected > len(orderedTasks)-1 {
|
||||||
selected = len(tasks) - 1
|
selected = len(orderedTasks) - 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(orderedTasks),
|
||||||
|
table.WithTaskTree(taskTree),
|
||||||
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),
|
||||||
)
|
)
|
||||||
|
|
||||||
if selected == 0 {
|
if selected == 0 {
|
||||||
selected = p.taskTable.Cursor()
|
selected = p.taskTable.Cursor()
|
||||||
}
|
}
|
||||||
if selected < len(tasks) {
|
if selected < len(orderedTasks) {
|
||||||
p.taskTable.SetCursor(selected)
|
p.taskTable.SetCursor(selected)
|
||||||
} 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 {
|
||||||
|
|||||||
@@ -57,16 +57,9 @@ func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report
|
|||||||
func (p *ReportPickerPage) SetSize(width, height int) {
|
func (p *ReportPickerPage) SetSize(width, height int) {
|
||||||
p.common.SetSize(width, height)
|
p.common.SetSize(width, height)
|
||||||
|
|
||||||
// Set list size with some padding/limits to look like a picker
|
// Use shared modal sizing logic
|
||||||
listWidth := width - 4
|
modalWidth, modalHeight := p.common.Styles.GetModalSize(width, height)
|
||||||
if listWidth > 40 {
|
p.picker.SetSize(modalWidth-2, modalHeight-2)
|
||||||
listWidth = 40
|
|
||||||
}
|
|
||||||
listHeight := height - 6
|
|
||||||
if listHeight > 20 {
|
|
||||||
listHeight = 20
|
|
||||||
}
|
|
||||||
p.picker.SetSize(listWidth, listHeight)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ReportPickerPage) Init() tea.Cmd {
|
func (p *ReportPickerPage) Init() tea.Cmd {
|
||||||
@@ -112,20 +105,23 @@ func (p *ReportPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *ReportPickerPage) View() string {
|
func (p *ReportPickerPage) View() string {
|
||||||
width := p.common.Width() - 4
|
modalWidth, modalHeight := p.common.Styles.GetModalSize(p.common.Width(), p.common.Height())
|
||||||
if width > 40 {
|
|
||||||
width = 40
|
|
||||||
}
|
|
||||||
|
|
||||||
content := p.picker.View()
|
content := p.picker.View()
|
||||||
styledContent := lipgloss.NewStyle().Width(width).Render(content)
|
styledContent := lipgloss.NewStyle().
|
||||||
|
Width(modalWidth).
|
||||||
|
Height(modalHeight).
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(p.common.Styles.Palette.Border.GetForeground()).
|
||||||
|
Padding(0, 1).
|
||||||
|
Render(content)
|
||||||
|
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
p.common.Width(),
|
p.common.Width(),
|
||||||
p.common.Height(),
|
p.common.Height(),
|
||||||
lipgloss.Center,
|
lipgloss.Center,
|
||||||
lipgloss.Center,
|
lipgloss.Center,
|
||||||
p.common.Styles.Base.Render(styledContent),
|
styledContent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ package pages
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
|
||||||
"tasksquire/common"
|
"tasksquire/common"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"tasksquire/components/input"
|
"tasksquire/components/input"
|
||||||
|
"tasksquire/components/picker"
|
||||||
"tasksquire/components/timestampeditor"
|
"tasksquire/components/timestampeditor"
|
||||||
"tasksquire/taskwarrior"
|
"tasksquire/taskwarrior"
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPag
|
|||||||
tagOptions := p.common.TW.GetTags()
|
tagOptions := p.common.TW.GetTags()
|
||||||
|
|
||||||
p.areas = []area{
|
p.areas = []area{
|
||||||
NewTaskEdit(p.common, &p.task),
|
NewTaskEdit(p.common, &p.task, p.task.Uuid == ""),
|
||||||
NewTagEdit(p.common, &p.task.Tags, tagOptions),
|
NewTagEdit(p.common, &p.task.Tags, tagOptions),
|
||||||
NewTimeEdit(p.common, &p.task.Due, &p.task.Scheduled, &p.task.Wait, &p.task.Until),
|
NewTimeEdit(p.common, &p.task.Due, &p.task.Scheduled, &p.task.Wait, &p.task.Until),
|
||||||
NewDetailsEdit(p.common, &p.task),
|
NewDetailsEdit(p.common, &p.task),
|
||||||
@@ -110,7 +110,11 @@ func (p *TaskEditorPage) SetSize(width, height int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *TaskEditorPage) Init() tea.Cmd {
|
func (p *TaskEditorPage) Init() tea.Cmd {
|
||||||
return nil
|
var cmds []tea.Cmd
|
||||||
|
for _, a := range p.areas {
|
||||||
|
cmds = append(cmds, a.Init())
|
||||||
|
}
|
||||||
|
return tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
@@ -257,9 +261,13 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return p, nil
|
return p, nil
|
||||||
case key.Matches(msg, p.common.Keymap.Ok):
|
case key.Matches(msg, p.common.Keymap.Ok):
|
||||||
|
isFiltering := p.areas[p.area].IsFiltering()
|
||||||
model, cmd := p.areas[p.area].Update(msg)
|
model, cmd := p.areas[p.area].Update(msg)
|
||||||
if p.area != 3 {
|
if p.area != 3 {
|
||||||
p.areas[p.area] = model.(area)
|
p.areas[p.area] = model.(area)
|
||||||
|
if isFiltering {
|
||||||
|
return p, cmd
|
||||||
|
}
|
||||||
return p, tea.Batch(cmd, nextField())
|
return p, tea.Batch(cmd, nextField())
|
||||||
}
|
}
|
||||||
return p, cmd
|
return p, cmd
|
||||||
@@ -315,11 +323,13 @@ func (p *TaskEditorPage) View() string {
|
|||||||
|
|
||||||
tabs := ""
|
tabs := ""
|
||||||
for i, a := range p.areas {
|
for i, a := range p.areas {
|
||||||
|
style := p.common.Styles.Base
|
||||||
if i == p.area {
|
if i == p.area {
|
||||||
tabs += p.common.Styles.Base.Bold(true).Render(fmt.Sprintf(" %s ", a.GetName()))
|
style = style.Bold(true).Foreground(p.common.Styles.Palette.Accent.GetForeground())
|
||||||
} else {
|
} else {
|
||||||
tabs += p.common.Styles.Base.Render(fmt.Sprintf(" %s ", a.GetName()))
|
style = style.Foreground(p.common.Styles.Palette.Muted.GetForeground())
|
||||||
}
|
}
|
||||||
|
tabs += style.Render(fmt.Sprintf(" %s ", a.GetName()))
|
||||||
}
|
}
|
||||||
|
|
||||||
page := lipgloss.JoinVertical(
|
page := lipgloss.JoinVertical(
|
||||||
@@ -340,8 +350,11 @@ type area interface {
|
|||||||
tea.Model
|
tea.Model
|
||||||
SetCursor(c int)
|
SetCursor(c int)
|
||||||
GetName() string
|
GetName() string
|
||||||
|
IsFiltering() bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type focusMsg struct{}
|
||||||
|
|
||||||
type areaPicker struct {
|
type areaPicker struct {
|
||||||
common *common.Common
|
common *common.Common
|
||||||
list list.Model
|
list list.Model
|
||||||
@@ -413,26 +426,66 @@ func (a *areaPicker) View() string {
|
|||||||
return a.list.View()
|
return a.list.View()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EditableField interface {
|
||||||
|
tea.Model
|
||||||
|
Focus() tea.Cmd
|
||||||
|
Blur() tea.Cmd
|
||||||
|
}
|
||||||
|
|
||||||
type taskEdit struct {
|
type taskEdit struct {
|
||||||
common *common.Common
|
common *common.Common
|
||||||
fields []huh.Field
|
fields []EditableField
|
||||||
cursor int
|
cursor int
|
||||||
|
|
||||||
|
projectPicker *picker.Picker
|
||||||
// newProjectName *string
|
// newProjectName *string
|
||||||
newAnnotation *string
|
newAnnotation *string
|
||||||
udaValues map[string]*string
|
udaValues map[string]*string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTaskEdit(com *common.Common, task *taskwarrior.Task) *taskEdit {
|
func NewTaskEdit(com *common.Common, task *taskwarrior.Task, isNew bool) *taskEdit {
|
||||||
// newProject := ""
|
// newProject := ""
|
||||||
projectOptions := append([]string{"(none)"}, com.TW.GetProjects()...)
|
|
||||||
if task.Project == "" {
|
if task.Project == "" {
|
||||||
task.Project = "(none)"
|
task.Project = "(none)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
itemProvider := func() []list.Item {
|
||||||
|
projects := com.TW.GetProjects()
|
||||||
|
items := []list.Item{picker.NewItem("(none)")}
|
||||||
|
for _, proj := range projects {
|
||||||
|
items = append(items, picker.NewItem(proj))
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
onSelect := func(item list.Item) tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
onCreate := func(newProject string) tea.Cmd {
|
||||||
|
// The new project name will be used as the project value
|
||||||
|
// when the task is saved
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := []picker.PickerOption{picker.WithOnCreate(onCreate)}
|
||||||
|
|
||||||
|
// Check if task has a pre-filled project (e.g., from ProjectTaskPickerPage)
|
||||||
|
hasPrefilledProject := task.Project != "" && task.Project != "(none)"
|
||||||
|
|
||||||
|
if isNew && !hasPrefilledProject {
|
||||||
|
// New task with no project → start in filter mode for quick project search
|
||||||
|
opts = append(opts, picker.WithFilterByDefault(true))
|
||||||
|
} else {
|
||||||
|
// Either existing task OR new task with pre-filled project → show list with project selected
|
||||||
|
opts = append(opts, picker.WithDefaultValue(task.Project))
|
||||||
|
}
|
||||||
|
|
||||||
|
projPicker := picker.New(com, "Project", itemProvider, onSelect, opts...)
|
||||||
|
projPicker.SetSize(70, 8)
|
||||||
|
projPicker.Blur()
|
||||||
|
|
||||||
defaultKeymap := huh.NewDefaultKeyMap()
|
defaultKeymap := huh.NewDefaultKeyMap()
|
||||||
|
|
||||||
fields := []huh.Field{
|
fields := []EditableField{
|
||||||
huh.NewInput().
|
huh.NewInput().
|
||||||
Title("Task").
|
Title("Task").
|
||||||
Value(&task.Description).
|
Value(&task.Description).
|
||||||
@@ -446,12 +499,7 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task) *taskEdit {
|
|||||||
Prompt(": ").
|
Prompt(": ").
|
||||||
WithTheme(com.Styles.Form),
|
WithTheme(com.Styles.Form),
|
||||||
|
|
||||||
input.NewSelect(com).
|
projPicker,
|
||||||
Options(true, input.NewOptions(projectOptions...)...).
|
|
||||||
Title("Project").
|
|
||||||
Value(&task.Project).
|
|
||||||
WithKeyMap(defaultKeymap).
|
|
||||||
WithTheme(com.Styles.Form),
|
|
||||||
|
|
||||||
// huh.NewInput().
|
// huh.NewInput().
|
||||||
// Title("New Project").
|
// Title("New Project").
|
||||||
@@ -463,6 +511,9 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task) *taskEdit {
|
|||||||
|
|
||||||
udaValues := make(map[string]*string)
|
udaValues := make(map[string]*string)
|
||||||
for _, uda := range com.Udas {
|
for _, uda := range com.Udas {
|
||||||
|
if uda.Name == "parenttask" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
switch uda.Type {
|
switch uda.Type {
|
||||||
case taskwarrior.UdaTypeNumeric:
|
case taskwarrior.UdaTypeNumeric:
|
||||||
val := ""
|
val := ""
|
||||||
@@ -546,6 +597,7 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task) *taskEdit {
|
|||||||
t := taskEdit{
|
t := taskEdit{
|
||||||
common: com,
|
common: com,
|
||||||
fields: fields,
|
fields: fields,
|
||||||
|
projectPicker: projPicker,
|
||||||
|
|
||||||
udaValues: udaValues,
|
udaValues: udaValues,
|
||||||
|
|
||||||
@@ -562,6 +614,13 @@ func (t *taskEdit) GetName() string {
|
|||||||
return "Task"
|
return "Task"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *taskEdit) IsFiltering() bool {
|
||||||
|
if f, ok := t.fields[t.cursor].(interface{ IsFiltering() bool }); ok {
|
||||||
|
return f.IsFiltering()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (t *taskEdit) SetCursor(c int) {
|
func (t *taskEdit) SetCursor(c int) {
|
||||||
t.fields[t.cursor].Blur()
|
t.fields[t.cursor].Blur()
|
||||||
if c < 0 {
|
if c < 0 {
|
||||||
@@ -573,11 +632,25 @@ func (t *taskEdit) SetCursor(c int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *taskEdit) Init() tea.Cmd {
|
func (t *taskEdit) Init() tea.Cmd {
|
||||||
return nil
|
var cmds []tea.Cmd
|
||||||
|
// Ensure focus on the active field (especially for the first one)
|
||||||
|
if len(t.fields) > 0 {
|
||||||
|
cmds = append(cmds, func() tea.Msg {
|
||||||
|
return focusMsg{}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, f := range t.fields {
|
||||||
|
cmds = append(cmds, f.Init())
|
||||||
|
}
|
||||||
|
return tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *taskEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (t *taskEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.(type) {
|
switch msg.(type) {
|
||||||
|
case focusMsg:
|
||||||
|
if len(t.fields) > 0 {
|
||||||
|
return t, t.fields[t.cursor].Focus()
|
||||||
|
}
|
||||||
case nextFieldMsg:
|
case nextFieldMsg:
|
||||||
if t.cursor == len(t.fields)-1 {
|
if t.cursor == len(t.fields)-1 {
|
||||||
t.fields[t.cursor].Blur()
|
t.fields[t.cursor].Blur()
|
||||||
@@ -596,7 +669,7 @@ func (t *taskEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
t.fields[t.cursor].Focus()
|
t.fields[t.cursor].Focus()
|
||||||
default:
|
default:
|
||||||
field, cmd := t.fields[t.cursor].Update(msg)
|
field, cmd := t.fields[t.cursor].Update(msg)
|
||||||
t.fields[t.cursor] = field.(huh.Field)
|
t.fields[t.cursor] = field.(EditableField)
|
||||||
return t, cmd
|
return t, cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,13 +695,9 @@ type tagEdit struct {
|
|||||||
fields []huh.Field
|
fields []huh.Field
|
||||||
|
|
||||||
cursor int
|
cursor int
|
||||||
|
|
||||||
newTagsValue *string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTagEdit(common *common.Common, selected *[]string, options []string) *tagEdit {
|
func NewTagEdit(common *common.Common, selected *[]string, options []string) *tagEdit {
|
||||||
newTags := ""
|
|
||||||
|
|
||||||
defaultKeymap := huh.NewDefaultKeyMap()
|
defaultKeymap := huh.NewDefaultKeyMap()
|
||||||
|
|
||||||
t := tagEdit{
|
t := tagEdit{
|
||||||
@@ -642,14 +711,7 @@ func NewTagEdit(common *common.Common, selected *[]string, options []string) *ta
|
|||||||
Filterable(true).
|
Filterable(true).
|
||||||
WithKeyMap(defaultKeymap).
|
WithKeyMap(defaultKeymap).
|
||||||
WithTheme(common.Styles.Form),
|
WithTheme(common.Styles.Form),
|
||||||
huh.NewInput().
|
|
||||||
Title("New Tags").
|
|
||||||
Value(&newTags).
|
|
||||||
Inline(true).
|
|
||||||
Prompt(": ").
|
|
||||||
WithTheme(common.Styles.Form),
|
|
||||||
},
|
},
|
||||||
newTagsValue: &newTags,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &t
|
return &t
|
||||||
@@ -659,6 +721,13 @@ func (t *tagEdit) GetName() string {
|
|||||||
return "Tags"
|
return "Tags"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *tagEdit) IsFiltering() bool {
|
||||||
|
if f, ok := t.fields[t.cursor].(interface{ IsFiltering() bool }); ok {
|
||||||
|
return f.IsFiltering()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (t *tagEdit) SetCursor(c int) {
|
func (t *tagEdit) SetCursor(c int) {
|
||||||
t.fields[t.cursor].Blur()
|
t.fields[t.cursor].Blur()
|
||||||
if c < 0 {
|
if c < 0 {
|
||||||
@@ -771,6 +840,10 @@ func (t *timeEdit) GetName() string {
|
|||||||
return "Dates"
|
return "Dates"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *timeEdit) IsFiltering() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (t *timeEdit) SetCursor(c int) {
|
func (t *timeEdit) SetCursor(c int) {
|
||||||
if len(t.fields) == 0 {
|
if len(t.fields) == 0 {
|
||||||
return
|
return
|
||||||
@@ -903,6 +976,10 @@ func (d *detailsEdit) GetName() string {
|
|||||||
return "Details"
|
return "Details"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *detailsEdit) IsFiltering() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (d *detailsEdit) SetCursor(c int) {
|
func (d *detailsEdit) SetCursor(c int) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -926,150 +1003,11 @@ func (d *detailsEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
func (d *detailsEdit) View() string {
|
func (d *detailsEdit) View() string {
|
||||||
return d.ta.View()
|
return d.ta.View()
|
||||||
// dtls := `
|
|
||||||
// # Cool Details!
|
|
||||||
// ## Things I need
|
|
||||||
// - [ ] A thing
|
|
||||||
// - [x] Done thing
|
|
||||||
|
|
||||||
// ## People
|
|
||||||
// - pe1
|
|
||||||
// - pe2
|
|
||||||
// `
|
|
||||||
|
|
||||||
// details, err := d.renderer.Render(dtls)
|
|
||||||
// if err != nil {
|
|
||||||
// slog.Error(err.Error())
|
|
||||||
// return "Could not parse markdown"
|
|
||||||
// }
|
|
||||||
|
|
||||||
// d.vp.SetContent(details)
|
|
||||||
// return d.vp.View()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// func (p *TaskEditorPage) SetSize(width, height int) {
|
|
||||||
// p.common.SetSize(width, height)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (p *TaskEditorPage) Init() tea.Cmd {
|
|
||||||
// // return p.form.Init()
|
|
||||||
// return nil
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
// var cmds []tea.Cmd
|
|
||||||
|
|
||||||
// switch msg := msg.(type) {
|
|
||||||
// case SwitchModeMsg:
|
|
||||||
// switch mode(msg) {
|
|
||||||
// case modeNormal:
|
|
||||||
// p.mode = modeNormal
|
|
||||||
// case modeInsert:
|
|
||||||
// p.mode = modeInsert
|
|
||||||
// }
|
|
||||||
// case changeAreaMsg:
|
|
||||||
// p.selectedArea = area(msg)
|
|
||||||
// p.columns = append([]tea.Model{p.areaList}, p.areas[p.selectedArea]...)
|
|
||||||
// case nextColumnMsg:
|
|
||||||
// p.columnCursor++
|
|
||||||
// if p.columnCursor > len(p.columns)-1 {
|
|
||||||
// p.columnCursor = 0
|
|
||||||
// }
|
|
||||||
// case prevColumnMsg:
|
|
||||||
// p.columnCursor--
|
|
||||||
// if p.columnCursor < 0 {
|
|
||||||
// p.columnCursor = len(p.columns) - 1
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// switch p.mode {
|
|
||||||
// case modeNormal:
|
|
||||||
// switch msg := msg.(type) {
|
|
||||||
// case tea.KeyMsg:
|
|
||||||
// switch {
|
|
||||||
// case key.Matches(msg, p.common.Keymap.Back):
|
|
||||||
// model, err := p.common.PopPage()
|
|
||||||
// if err != nil {
|
|
||||||
// slog.Error("page stack empty")
|
|
||||||
// return nil, tea.Quit
|
|
||||||
// }
|
|
||||||
// return model, BackCmd
|
|
||||||
// case key.Matches(msg, p.common.Keymap.Insert):
|
|
||||||
// return p, p.switchModeCmd(modeInsert)
|
|
||||||
// // case key.Matches(msg, p.common.Keymap.Ok):
|
|
||||||
// // p.form.State = huh.StateCompleted
|
|
||||||
// case key.Matches(msg, p.common.Keymap.Left):
|
|
||||||
// return p, prevColumn()
|
|
||||||
// case key.Matches(msg, p.common.Keymap.Right):
|
|
||||||
// return p, nextColumn()
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// case modeInsert:
|
|
||||||
// switch msg := msg.(type) {
|
|
||||||
// case tea.KeyMsg:
|
|
||||||
// switch {
|
|
||||||
// case key.Matches(msg, p.common.Keymap.Back):
|
|
||||||
// return p, p.switchModeCmd(modeNormal)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// var cmd tea.Cmd
|
|
||||||
// if p.columnCursor == 0 {
|
|
||||||
// p.areaList, cmd = p.areaList.Update(msg)
|
|
||||||
// p.selectedArea = p.areaList.(areaList).Area()
|
|
||||||
// cmds = append(cmds, cmd)
|
|
||||||
// } else {
|
|
||||||
// p.areas[p.selectedArea][p.columnCursor-1], cmd = p.areas[p.selectedArea][p.columnCursor-1].Update(msg)
|
|
||||||
// cmds = append(cmds, cmd)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// }
|
|
||||||
|
|
||||||
// var cmd tea.Cmd
|
|
||||||
// if p.columnCursor == 0 {
|
|
||||||
// p.areaList, cmd = p.areaList.Update(msg)
|
|
||||||
// p.selectedArea = p.areaList.(areaList).Area()
|
|
||||||
// cmds = append(cmds, cmd)
|
|
||||||
// } else {
|
|
||||||
// p.areas[p.selectedArea][p.columnCursor-1], cmd = p.areas[p.selectedArea][p.columnCursor-1].Update(msg)
|
|
||||||
// cmds = append(cmds, cmd)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// p.statusline, cmd = p.statusline.Update(msg)
|
|
||||||
// cmds = append(cmds, cmd)
|
|
||||||
|
|
||||||
// // if p.form.State == huh.StateCompleted {
|
|
||||||
// // cmds = append(cmds, p.updateTasksCmd)
|
|
||||||
// // model, err := p.common.PopPage()
|
|
||||||
// // if err != nil {
|
|
||||||
// // slog.Error("page stack empty")
|
|
||||||
// // return nil, tea.Quit
|
|
||||||
// // }
|
|
||||||
// // return model, tea.Batch(cmds...)
|
|
||||||
// // }
|
|
||||||
|
|
||||||
// return p, tea.Batch(cmds...)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (p *TaskEditorPage) View() string {
|
|
||||||
// columns := make([]string, len(p.columns))
|
|
||||||
// for i, c := range p.columns {
|
|
||||||
// columns[i] = c.View()
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return lipgloss.JoinVertical(
|
|
||||||
// lipgloss.Left,
|
|
||||||
// lipgloss.JoinHorizontal(
|
|
||||||
// lipgloss.Top,
|
|
||||||
// // lipgloss.Place(p.common.Width(), p.common.Height()-1, 0.5, 0.5, p.form.View(), lipgloss.WithWhitespaceBackground(p.common.Styles.Warning.GetForeground())),
|
|
||||||
// // lipgloss.Place(p.common.Width(), p.common.Height()-1, 0.5, 0.5, p.form.View(), lipgloss.WithWhitespaceChars(" . ")),
|
|
||||||
// columns...,
|
|
||||||
// ),
|
|
||||||
// p.statusline.View(),
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
|
|
||||||
func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
|
func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
|
||||||
|
p.task.Project = p.areas[0].(*taskEdit).projectPicker.GetValue()
|
||||||
|
|
||||||
if p.task.Project == "(none)" {
|
if p.task.Project == "(none)" {
|
||||||
p.task.Project = ""
|
p.task.Project = ""
|
||||||
}
|
}
|
||||||
@@ -1083,17 +1021,6 @@ func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if *(p.areas[0].(*taskEdit).newProjectName) != "" {
|
|
||||||
// p.task.Project = *p.areas[0].(*taskEdit).newProjectName
|
|
||||||
// }
|
|
||||||
|
|
||||||
if *(p.areas[1].(*tagEdit).newTagsValue) != "" {
|
|
||||||
newTags := strings.Split(*p.areas[1].(*tagEdit).newTagsValue, " ")
|
|
||||||
if len(newTags) > 0 {
|
|
||||||
p.task.Tags = append(p.task.Tags, newTags...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync timestamp fields from the timeEdit area (area 2)
|
// Sync timestamp fields from the timeEdit area (area 2)
|
||||||
p.areas[2].(*timeEdit).syncToTaskFields()
|
p.areas[2].(*timeEdit).syncToTaskFields()
|
||||||
|
|
||||||
@@ -1102,8 +1029,6 @@ func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
|
|||||||
Entry: time.Now().Format("20060102T150405Z"),
|
Entry: time.Now().Format("20060102T150405Z"),
|
||||||
Description: *(p.areas[0].(*taskEdit).newAnnotation),
|
Description: *(p.areas[0].(*taskEdit).newAnnotation),
|
||||||
})
|
})
|
||||||
|
|
||||||
// p.common.TW.AddTaskAnnotation(p.task.Uuid, *p.areas[0].(*taskEdit).newAnnotation)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := p.task.Udas["details"]; ok || p.areas[3].(*detailsEdit).ta.Value() != "" {
|
if _, ok := p.task.Udas["details"]; ok || p.areas[3].(*detailsEdit).ta.Value() != "" {
|
||||||
@@ -1114,100 +1039,5 @@ func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
|
|||||||
return UpdatedTasksMsg{}
|
return UpdatedTasksMsg{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// type StatusLine struct {
|
// type StatusLine struct { ... }
|
||||||
// common *common.Common
|
// ...
|
||||||
// mode mode
|
|
||||||
// input textinput.Model
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func NewStatusLine(common *common.Common, mode mode) *StatusLine {
|
|
||||||
// input := textinput.New()
|
|
||||||
// input.Placeholder = ""
|
|
||||||
// input.Prompt = ""
|
|
||||||
// input.Blur()
|
|
||||||
|
|
||||||
// return &StatusLine{
|
|
||||||
// input: textinput.New(),
|
|
||||||
// common: common,
|
|
||||||
// mode: mode,
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (s *StatusLine) Init() tea.Cmd {
|
|
||||||
// s.input.Blur()
|
|
||||||
// return nil
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (s *StatusLine) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
// var cmd tea.Cmd
|
|
||||||
|
|
||||||
// switch msg := msg.(type) {
|
|
||||||
// case SwitchModeMsg:
|
|
||||||
// s.mode = mode(msg)
|
|
||||||
// switch s.mode {
|
|
||||||
// case modeNormal:
|
|
||||||
// s.input.Blur()
|
|
||||||
// case modeInsert:
|
|
||||||
// s.input.Focus()
|
|
||||||
// }
|
|
||||||
// case tea.KeyMsg:
|
|
||||||
// switch {
|
|
||||||
// case key.Matches(msg, s.common.Keymap.Back):
|
|
||||||
// s.input.Blur()
|
|
||||||
// case key.Matches(msg, s.common.Keymap.Input):
|
|
||||||
// s.input.Focus()
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// s.input, cmd = s.input.Update(msg)
|
|
||||||
// return s, cmd
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (s *StatusLine) View() string {
|
|
||||||
// var mode string
|
|
||||||
// switch s.mode {
|
|
||||||
// case modeNormal:
|
|
||||||
// mode = s.common.Styles.Base.Render("NORMAL")
|
|
||||||
// case modeInsert:
|
|
||||||
// mode = s.common.Styles.Active.Inline(true).Reverse(true).Render("INSERT")
|
|
||||||
// }
|
|
||||||
// return lipgloss.JoinHorizontal(lipgloss.Left, mode, s.input.View())
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // TODO: move this to taskwarrior; add missing date formats
|
|
||||||
|
|
||||||
// type itemDelegate struct{}
|
|
||||||
|
|
||||||
// func (d itemDelegate) Height() int { return 1 }
|
|
||||||
// func (d itemDelegate) Spacing() int { return 0 }
|
|
||||||
// func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
|
|
||||||
// func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
|
|
||||||
// i, ok := listItem.(item)
|
|
||||||
// if !ok {
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
|
|
||||||
// str := fmt.Sprintf("%s", i)
|
|
||||||
|
|
||||||
// fn := itemStyle.Render
|
|
||||||
// if index == m.Index() {
|
|
||||||
// fn = func(s ...string) string {
|
|
||||||
// return selectedItemStyle.Render("> " + strings.Join(s, " "))
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// fmt.Fprint(w, fn(str))
|
|
||||||
// }
|
|
||||||
|
|
||||||
// var (
|
|
||||||
// titleStyle = lipgloss.NewStyle().MarginLeft(2)
|
|
||||||
// itemStyle = lipgloss.NewStyle().PaddingLeft(4)
|
|
||||||
// selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170"))
|
|
||||||
// paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4)
|
|
||||||
// helpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1)
|
|
||||||
// quitTextStyle = lipgloss.NewStyle().Margin(1, 0, 2, 4)
|
|
||||||
// )
|
|
||||||
|
|
||||||
// type item string
|
|
||||||
|
|
||||||
// func (i item) FilterValue() string { return "" }
|
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
package pages
|
package pages
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"tasksquire/common"
|
"tasksquire/common"
|
||||||
"tasksquire/components/autocomplete"
|
"tasksquire/components/autocomplete"
|
||||||
|
"tasksquire/components/picker"
|
||||||
"tasksquire/components/timestampeditor"
|
"tasksquire/components/timestampeditor"
|
||||||
|
"tasksquire/taskwarrior"
|
||||||
"tasksquire/timewarrior"
|
"tasksquire/timewarrior"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
"github.com/charmbracelet/bubbles/list"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
@@ -18,17 +22,160 @@ type TimeEditorPage struct {
|
|||||||
interval *timewarrior.Interval
|
interval *timewarrior.Interval
|
||||||
|
|
||||||
// Fields
|
// Fields
|
||||||
|
projectPicker *picker.Picker
|
||||||
|
taskPicker *picker.Picker
|
||||||
startEditor *timestampeditor.TimestampEditor
|
startEditor *timestampeditor.TimestampEditor
|
||||||
endEditor *timestampeditor.TimestampEditor
|
endEditor *timestampeditor.TimestampEditor
|
||||||
tagsInput *autocomplete.Autocomplete
|
tagsInput *autocomplete.Autocomplete
|
||||||
adjust bool
|
adjust bool
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
selectedProject string
|
||||||
|
selectedTask *taskwarrior.Task
|
||||||
currentField int
|
currentField int
|
||||||
totalFields int
|
totalFields int
|
||||||
|
uuid string // Preserved UUID tag
|
||||||
|
track string // Preserved track tag (if present)
|
||||||
|
}
|
||||||
|
|
||||||
|
type timeEditorProjectSelectedMsg struct {
|
||||||
|
project string
|
||||||
|
}
|
||||||
|
|
||||||
|
type timeEditorTaskSelectedMsg struct {
|
||||||
|
task *taskwarrior.Task
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTaskPickerForProject creates a picker showing tasks with +track tag for the given project
|
||||||
|
func createTaskPickerForProject(com *common.Common, project string, defaultTask string) *picker.Picker {
|
||||||
|
// Build filters for tasks with +track tag
|
||||||
|
filters := []string{"+track", "status:pending"}
|
||||||
|
if project != "" {
|
||||||
|
filters = append(filters, "project:"+project)
|
||||||
|
}
|
||||||
|
|
||||||
|
taskItemProvider := func() []list.Item {
|
||||||
|
tasks := com.TW.GetTasks(nil, filters...)
|
||||||
|
// Add "(none)" as first option, then all tasks
|
||||||
|
items := make([]list.Item, 0, len(tasks)+1)
|
||||||
|
items = append(items, picker.NewItem("(none)"))
|
||||||
|
for i := range tasks {
|
||||||
|
items = append(items, picker.NewItem(tasks[i].Description))
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
taskOnSelect := func(item list.Item) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
// Handle "(none)" selection
|
||||||
|
if item.FilterValue() == "(none)" {
|
||||||
|
return timeEditorTaskSelectedMsg{task: nil}
|
||||||
|
}
|
||||||
|
// Find the task by description
|
||||||
|
tasks := com.TW.GetTasks(nil, filters...)
|
||||||
|
for _, task := range tasks {
|
||||||
|
if task.Description == item.FilterValue() {
|
||||||
|
return timeEditorTaskSelectedMsg{task: task}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
title := "Task"
|
||||||
|
if project != "" {
|
||||||
|
title = fmt.Sprintf("Task (%s)", project)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := []picker.PickerOption{
|
||||||
|
picker.WithFilterByDefault(false), // Start in list mode, not filter mode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-select task if provided, otherwise default to "(none)"
|
||||||
|
if defaultTask != "" {
|
||||||
|
opts = append(opts, picker.WithDefaultValue(defaultTask))
|
||||||
|
} else {
|
||||||
|
opts = append(opts, picker.WithDefaultValue("(none)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
taskPicker := picker.New(
|
||||||
|
com,
|
||||||
|
title,
|
||||||
|
taskItemProvider,
|
||||||
|
taskOnSelect,
|
||||||
|
opts...,
|
||||||
|
)
|
||||||
|
taskPicker.SetSize(50, 10)
|
||||||
|
|
||||||
|
return taskPicker
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage {
|
func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage {
|
||||||
|
// Extract special tags (uuid, project, track) and display tags
|
||||||
|
uuid, selectedProject, track, displayTags := extractSpecialTags(interval.Tags)
|
||||||
|
|
||||||
|
// Track selected task for pre-selection
|
||||||
|
var selectedTask *taskwarrior.Task
|
||||||
|
var defaultTaskDescription string
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
selectedTask = tasks[0]
|
||||||
|
defaultTaskDescription = selectedTask.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, defaultTaskDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create project picker with onCreate support for new projects
|
||||||
|
projectItemProvider := func() []list.Item {
|
||||||
|
projects := com.TW.GetProjects()
|
||||||
|
items := make([]list.Item, len(projects))
|
||||||
|
for i, proj := range projects {
|
||||||
|
items[i] = picker.NewItem(proj)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
projectOnSelect := func(item list.Item) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
return timeEditorProjectSelectedMsg{project: item.FilterValue()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
projectOnCreate := func(name string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
return timeEditorProjectSelectedMsg{project: name}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := []picker.PickerOption{
|
||||||
|
picker.WithOnCreate(projectOnCreate),
|
||||||
|
}
|
||||||
|
if selectedProject != "" {
|
||||||
|
opts = append(opts, picker.WithDefaultValue(selectedProject))
|
||||||
|
} else {
|
||||||
|
opts = append(opts, picker.WithFilterByDefault(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
projectPicker := picker.New(
|
||||||
|
com,
|
||||||
|
"Project",
|
||||||
|
projectItemProvider,
|
||||||
|
projectOnSelect,
|
||||||
|
opts...,
|
||||||
|
)
|
||||||
|
projectPicker.SetSize(50, 10) // Compact size for inline use
|
||||||
|
|
||||||
|
// Create task picker (only if project is selected)
|
||||||
|
var taskPicker *picker.Picker
|
||||||
|
if selectedProject != "" {
|
||||||
|
taskPicker = createTaskPickerForProject(com, selectedProject, defaultTaskDescription)
|
||||||
|
}
|
||||||
|
|
||||||
// Create start timestamp editor
|
// Create start timestamp editor
|
||||||
startEditor := timestampeditor.New(com).
|
startEditor := timestampeditor.New(com).
|
||||||
Title("Start").
|
Title("Start").
|
||||||
@@ -39,38 +186,63 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time
|
|||||||
Title("End").
|
Title("End").
|
||||||
ValueFromString(interval.End)
|
ValueFromString(interval.End)
|
||||||
|
|
||||||
// Create tags autocomplete with combinations from past intervals
|
// Get tag combinations filtered by selected project
|
||||||
tagCombinations := com.TimeW.GetTagCombinations()
|
tagCombinations := filterTagCombinationsByProject(
|
||||||
|
com.TimeW.GetTagCombinations(),
|
||||||
|
selectedProject,
|
||||||
|
)
|
||||||
|
|
||||||
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(interval.Tags))
|
tagsInput.SetValue(formatTags(displayTags)) // Use display tags only (no uuid, project, track)
|
||||||
tagsInput.SetWidth(50)
|
tagsInput.SetWidth(50)
|
||||||
|
|
||||||
p := &TimeEditorPage{
|
p := &TimeEditorPage{
|
||||||
common: com,
|
common: com,
|
||||||
interval: interval,
|
interval: interval,
|
||||||
|
projectPicker: projectPicker,
|
||||||
|
taskPicker: taskPicker,
|
||||||
startEditor: startEditor,
|
startEditor: startEditor,
|
||||||
endEditor: endEditor,
|
endEditor: endEditor,
|
||||||
tagsInput: tagsInput,
|
tagsInput: tagsInput,
|
||||||
adjust: true, // Enable :adjust by default
|
adjust: true, // Enable :adjust by default
|
||||||
|
selectedProject: selectedProject,
|
||||||
|
selectedTask: selectedTask,
|
||||||
currentField: 0,
|
currentField: 0,
|
||||||
totalFields: 4, // Updated to include adjust field
|
totalFields: 6, // 6 fields: project, task, tags, start, end, adjust
|
||||||
|
uuid: uuid,
|
||||||
|
track: track,
|
||||||
}
|
}
|
||||||
|
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *TimeEditorPage) Init() tea.Cmd {
|
func (p *TimeEditorPage) Init() tea.Cmd {
|
||||||
// Focus the first field (tags)
|
// Focus the first field (project picker)
|
||||||
p.currentField = 0
|
p.currentField = 0
|
||||||
p.tagsInput.Focus()
|
return p.projectPicker.Init()
|
||||||
return p.tagsInput.Init()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
case timeEditorProjectSelectedMsg:
|
||||||
|
// Project selection happens on Enter - advance to task picker
|
||||||
|
// (Auto-selection of project already happened in Update() switch)
|
||||||
|
p.blurCurrentField()
|
||||||
|
p.currentField = 1
|
||||||
|
cmds = append(cmds, p.focusCurrentField())
|
||||||
|
return p, tea.Batch(cmds...)
|
||||||
|
|
||||||
|
case timeEditorTaskSelectedMsg:
|
||||||
|
// Task selection happens on Enter - advance to tags field
|
||||||
|
// (Auto-selection of task already happened in Update() switch)
|
||||||
|
p.blurCurrentField()
|
||||||
|
p.currentField = 2
|
||||||
|
cmds = append(cmds, p.focusCurrentField())
|
||||||
|
return p, tea.Batch(cmds...)
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, p.common.Keymap.Back):
|
case key.Matches(msg, p.common.Keymap.Back):
|
||||||
@@ -82,7 +254,31 @@ 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):
|
||||||
// Save and exit
|
// Handle Enter based on current field
|
||||||
|
if p.currentField == 0 {
|
||||||
|
// Project picker - let it handle Enter (will trigger projectSelectedMsg)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.currentField == 1 {
|
||||||
|
// Task picker - let it handle Enter (will trigger taskSelectedMsg)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.currentField == 2 {
|
||||||
|
// Tags field
|
||||||
|
if p.tagsInput.HasSuggestions() {
|
||||||
|
// Let autocomplete handle suggestion selection
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Tags confirmed without suggestions - advance to start timestamp
|
||||||
|
p.blurCurrentField()
|
||||||
|
p.currentField = 3
|
||||||
|
cmds = append(cmds, p.focusCurrentField())
|
||||||
|
return p, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For all other fields (3-5: start, end, adjust), save and exit
|
||||||
p.saveInterval()
|
p.saveInterval()
|
||||||
model, err := p.common.PopPage()
|
model, err := p.common.PopPage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -111,25 +307,117 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
// Update the currently focused field
|
// Update the currently focused field
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
switch p.currentField {
|
switch p.currentField {
|
||||||
case 0:
|
case 0: // Project picker
|
||||||
|
// Track the previous project selection
|
||||||
|
previousProject := p.selectedProject
|
||||||
|
|
||||||
|
var model tea.Model
|
||||||
|
model, cmd = p.projectPicker.Update(msg)
|
||||||
|
if pk, ok := model.(*picker.Picker); ok {
|
||||||
|
p.projectPicker = pk
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the highlighted project changed (auto-selection)
|
||||||
|
currentProject := p.projectPicker.GetValue()
|
||||||
|
if currentProject != previousProject && currentProject != "" {
|
||||||
|
// Update the selected project and refresh task picker
|
||||||
|
p.selectedProject = currentProject
|
||||||
|
// Clear task selection when project changes
|
||||||
|
p.selectedTask = nil
|
||||||
|
p.uuid = ""
|
||||||
|
// Create/update task picker for the new project
|
||||||
|
p.taskPicker = createTaskPickerForProject(p.common, currentProject, "")
|
||||||
|
// Refresh tag autocomplete with filtered combinations
|
||||||
|
cmds = append(cmds, p.updateTagSuggestions())
|
||||||
|
}
|
||||||
|
case 1: // Task picker
|
||||||
|
if p.taskPicker != nil {
|
||||||
|
// Track the previous task selection
|
||||||
|
var previousTaskDesc string
|
||||||
|
if p.selectedTask != nil {
|
||||||
|
previousTaskDesc = p.selectedTask.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
var model tea.Model
|
||||||
|
model, cmd = p.taskPicker.Update(msg)
|
||||||
|
if pk, ok := model.(*picker.Picker); ok {
|
||||||
|
p.taskPicker = pk
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the highlighted task changed (auto-selection)
|
||||||
|
currentTaskDesc := p.taskPicker.GetValue()
|
||||||
|
if currentTaskDesc != previousTaskDesc && currentTaskDesc != "" {
|
||||||
|
// Handle "(none)" selection - clear task state
|
||||||
|
if currentTaskDesc == "(none)" {
|
||||||
|
p.selectedTask = nil
|
||||||
|
p.uuid = ""
|
||||||
|
p.track = ""
|
||||||
|
// Don't clear tags - user might still want manual tags
|
||||||
|
// Refresh tag suggestions
|
||||||
|
cmds = append(cmds, p.updateTagSuggestions())
|
||||||
|
} else {
|
||||||
|
// Find and update the selected task
|
||||||
|
filters := []string{"+track", "status:pending"}
|
||||||
|
if p.selectedProject != "" {
|
||||||
|
filters = append(filters, "project:"+p.selectedProject)
|
||||||
|
}
|
||||||
|
tasks := p.common.TW.GetTasks(nil, filters...)
|
||||||
|
for _, task := range tasks {
|
||||||
|
if task.Description == currentTaskDesc {
|
||||||
|
// Update selected task
|
||||||
|
p.selectedTask = task
|
||||||
|
p.uuid = task.Uuid
|
||||||
|
|
||||||
|
// Build tags from task
|
||||||
|
tags := []string{}
|
||||||
|
|
||||||
|
// Add task description
|
||||||
|
if task.Description != "" {
|
||||||
|
tags = append(tags, task.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add task tags (excluding "track" tag since it's preserved separately)
|
||||||
|
for _, tag := range task.Tags {
|
||||||
|
if tag != "track" {
|
||||||
|
tags = append(tags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store track tag if present
|
||||||
|
if task.HasTag("track") {
|
||||||
|
p.track = "track"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tags input
|
||||||
|
p.tagsInput.SetValue(formatTags(tags))
|
||||||
|
|
||||||
|
// Refresh tag suggestions
|
||||||
|
cmds = append(cmds, p.updateTagSuggestions())
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 2: // Tags
|
||||||
var model tea.Model
|
var model tea.Model
|
||||||
model, cmd = p.tagsInput.Update(msg)
|
model, cmd = p.tagsInput.Update(msg)
|
||||||
if ac, ok := model.(*autocomplete.Autocomplete); ok {
|
if ac, ok := model.(*autocomplete.Autocomplete); ok {
|
||||||
p.tagsInput = ac
|
p.tagsInput = ac
|
||||||
}
|
}
|
||||||
case 1:
|
case 3: // Start
|
||||||
var model tea.Model
|
var model tea.Model
|
||||||
model, cmd = p.startEditor.Update(msg)
|
model, cmd = p.startEditor.Update(msg)
|
||||||
if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
|
if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
|
||||||
p.startEditor = editor
|
p.startEditor = editor
|
||||||
}
|
}
|
||||||
case 2:
|
case 4: // End
|
||||||
var model tea.Model
|
var model tea.Model
|
||||||
model, cmd = p.endEditor.Update(msg)
|
model, cmd = p.endEditor.Update(msg)
|
||||||
if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
|
if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
|
||||||
p.endEditor = editor
|
p.endEditor = editor
|
||||||
}
|
}
|
||||||
case 3:
|
case 5: // Adjust
|
||||||
// Handle adjust toggle with space/enter
|
// Handle adjust toggle with space/enter
|
||||||
if msg, ok := msg.(tea.KeyMsg); ok {
|
if msg, ok := msg.(tea.KeyMsg); ok {
|
||||||
if msg.String() == " " || msg.String() == "enter" {
|
if msg.String() == " " || msg.String() == "enter" {
|
||||||
@@ -145,13 +433,20 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
func (p *TimeEditorPage) focusCurrentField() tea.Cmd {
|
func (p *TimeEditorPage) focusCurrentField() tea.Cmd {
|
||||||
switch p.currentField {
|
switch p.currentField {
|
||||||
case 0:
|
case 0:
|
||||||
|
return p.projectPicker.Init() // Picker doesn't have explicit Focus()
|
||||||
|
case 1:
|
||||||
|
if p.taskPicker != nil {
|
||||||
|
return p.taskPicker.Init()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case 2:
|
||||||
p.tagsInput.Focus()
|
p.tagsInput.Focus()
|
||||||
return p.tagsInput.Init()
|
return p.tagsInput.Init()
|
||||||
case 1:
|
|
||||||
return p.startEditor.Focus()
|
|
||||||
case 2:
|
|
||||||
return p.endEditor.Focus()
|
|
||||||
case 3:
|
case 3:
|
||||||
|
return p.startEditor.Focus()
|
||||||
|
case 4:
|
||||||
|
return p.endEditor.Focus()
|
||||||
|
case 5:
|
||||||
// Adjust checkbox doesn't need focus action
|
// Adjust checkbox doesn't need focus action
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -161,12 +456,16 @@ func (p *TimeEditorPage) focusCurrentField() tea.Cmd {
|
|||||||
func (p *TimeEditorPage) blurCurrentField() {
|
func (p *TimeEditorPage) blurCurrentField() {
|
||||||
switch p.currentField {
|
switch p.currentField {
|
||||||
case 0:
|
case 0:
|
||||||
p.tagsInput.Blur()
|
// Project picker doesn't have explicit Blur(), state handled by Update
|
||||||
case 1:
|
case 1:
|
||||||
p.startEditor.Blur()
|
// Task picker doesn't have explicit Blur(), state handled by Update
|
||||||
case 2:
|
case 2:
|
||||||
p.endEditor.Blur()
|
p.tagsInput.Blur()
|
||||||
case 3:
|
case 3:
|
||||||
|
p.startEditor.Blur()
|
||||||
|
case 4:
|
||||||
|
p.endEditor.Blur()
|
||||||
|
case 5:
|
||||||
// Adjust checkbox doesn't need blur action
|
// Adjust checkbox doesn't need blur action
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,10 +478,45 @@ func (p *TimeEditorPage) View() string {
|
|||||||
sections = append(sections, titleStyle.Render("Edit Time Interval"))
|
sections = append(sections, titleStyle.Render("Edit Time Interval"))
|
||||||
sections = append(sections, "")
|
sections = append(sections, "")
|
||||||
|
|
||||||
// Tags input (now first)
|
// Project picker (field 0)
|
||||||
|
if p.currentField == 0 {
|
||||||
|
sections = append(sections, p.projectPicker.View())
|
||||||
|
} else {
|
||||||
|
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
|
||||||
|
sections = append(sections, blurredLabelStyle.Render("Project"))
|
||||||
|
sections = append(sections, lipgloss.NewStyle().Faint(true).Render(p.selectedProject))
|
||||||
|
}
|
||||||
|
|
||||||
|
sections = append(sections, "")
|
||||||
|
sections = append(sections, "")
|
||||||
|
|
||||||
|
// Task picker (field 1)
|
||||||
|
if p.currentField == 1 {
|
||||||
|
if p.taskPicker != nil {
|
||||||
|
sections = append(sections, p.taskPicker.View())
|
||||||
|
} else {
|
||||||
|
// No project selected yet
|
||||||
|
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
|
||||||
|
sections = append(sections, blurredLabelStyle.Render("Task"))
|
||||||
|
sections = append(sections, lipgloss.NewStyle().Faint(true).Render("(select a project first)"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
|
||||||
|
sections = append(sections, blurredLabelStyle.Render("Task"))
|
||||||
|
if p.selectedTask != nil {
|
||||||
|
sections = append(sections, lipgloss.NewStyle().Faint(true).Render(p.selectedTask.Description))
|
||||||
|
} else {
|
||||||
|
sections = append(sections, lipgloss.NewStyle().Faint(true).Render("(none)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sections = append(sections, "")
|
||||||
|
sections = append(sections, "")
|
||||||
|
|
||||||
|
// Tags input (field 2)
|
||||||
tagsLabelStyle := p.common.Styles.Form.Focused.Title
|
tagsLabelStyle := p.common.Styles.Form.Focused.Title
|
||||||
tagsLabel := tagsLabelStyle.Render("Tags")
|
tagsLabel := tagsLabelStyle.Render("Tags")
|
||||||
if p.currentField == 0 {
|
if p.currentField == 2 {
|
||||||
sections = append(sections, tagsLabel)
|
sections = append(sections, tagsLabel)
|
||||||
sections = append(sections, p.tagsInput.View())
|
sections = append(sections, p.tagsInput.View())
|
||||||
descStyle := p.common.Styles.Form.Focused.Description
|
descStyle := p.common.Styles.Form.Focused.Description
|
||||||
@@ -196,15 +530,15 @@ func (p *TimeEditorPage) View() string {
|
|||||||
sections = append(sections, "")
|
sections = append(sections, "")
|
||||||
sections = append(sections, "")
|
sections = append(sections, "")
|
||||||
|
|
||||||
// Start editor
|
// Start editor (field 3)
|
||||||
sections = append(sections, p.startEditor.View())
|
sections = append(sections, p.startEditor.View())
|
||||||
sections = append(sections, "")
|
sections = append(sections, "")
|
||||||
|
|
||||||
// End editor
|
// End editor (field 4)
|
||||||
sections = append(sections, p.endEditor.View())
|
sections = append(sections, p.endEditor.View())
|
||||||
sections = append(sections, "")
|
sections = append(sections, "")
|
||||||
|
|
||||||
// Adjust checkbox
|
// Adjust checkbox (field 5)
|
||||||
adjustLabelStyle := p.common.Styles.Form.Focused.Title
|
adjustLabelStyle := p.common.Styles.Form.Focused.Title
|
||||||
adjustLabel := adjustLabelStyle.Render("Adjust overlaps")
|
adjustLabel := adjustLabelStyle.Render("Adjust overlaps")
|
||||||
|
|
||||||
@@ -215,7 +549,7 @@ func (p *TimeEditorPage) View() string {
|
|||||||
checkbox = "[ ]"
|
checkbox = "[ ]"
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.currentField == 3 {
|
if p.currentField == 5 {
|
||||||
focusedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
|
focusedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
|
||||||
sections = append(sections, adjustLabel)
|
sections = append(sections, adjustLabel)
|
||||||
sections = append(sections, focusedStyle.Render(checkbox+" Auto-adjust overlapping intervals"))
|
sections = append(sections, focusedStyle.Render(checkbox+" Auto-adjust overlapping intervals"))
|
||||||
@@ -232,9 +566,17 @@ func (p *TimeEditorPage) View() string {
|
|||||||
|
|
||||||
// Help text
|
// Help text
|
||||||
helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
||||||
sections = append(sections, helpStyle.Render("tab/shift+tab: navigate • enter: save • esc: cancel"))
|
sections = append(sections, helpStyle.Render("tab/shift+tab: navigate • enter: select/save • esc: cancel"))
|
||||||
|
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, sections...)
|
content := lipgloss.JoinVertical(lipgloss.Left, sections...)
|
||||||
|
|
||||||
|
return lipgloss.Place(
|
||||||
|
p.common.Width(),
|
||||||
|
p.common.Height(),
|
||||||
|
lipgloss.Center,
|
||||||
|
lipgloss.Center,
|
||||||
|
content,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *TimeEditorPage) SetSize(width, height int) {
|
func (p *TimeEditorPage) SetSize(width, height int) {
|
||||||
@@ -254,8 +596,29 @@ 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
|
// Parse display tags from input
|
||||||
p.interval.Tags = parseTags(p.tagsInput.GetValue())
|
displayTags := parseTags(p.tagsInput.GetValue())
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
err := p.common.TimeW.ModifyInterval(p.interval, p.adjust)
|
err := p.common.TimeW.ModifyInterval(p.interval, p.adjust)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -298,3 +661,94 @@ func formatTags(tags []string) string {
|
|||||||
}
|
}
|
||||||
return strings.Join(formatted, " ")
|
return strings.Join(formatted, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractSpecialTags separates special tags (uuid, project, track) from display tags
|
||||||
|
// Returns uuid, project, track as separate strings, and displayTags for user editing
|
||||||
|
func extractSpecialTags(tags []string) (uuid, project, track string, displayTags []string) {
|
||||||
|
for _, tag := range tags {
|
||||||
|
if strings.HasPrefix(tag, "uuid:") {
|
||||||
|
uuid = strings.TrimPrefix(tag, "uuid:")
|
||||||
|
} else if strings.HasPrefix(tag, "project:") {
|
||||||
|
project = strings.TrimPrefix(tag, "project:")
|
||||||
|
} else if tag == "track" {
|
||||||
|
track = tag
|
||||||
|
} else {
|
||||||
|
displayTags = append(displayTags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractProjectFromTags finds and removes the first tag that matches a known project
|
||||||
|
// 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
|
||||||
|
// containing the exact project tag, and removes the project from the displayed combination
|
||||||
|
func filterTagCombinationsByProject(combinations []string, project string) []string {
|
||||||
|
if project == "" {
|
||||||
|
return combinations
|
||||||
|
}
|
||||||
|
|
||||||
|
projectTag := "project:" + project
|
||||||
|
|
||||||
|
var filtered []string
|
||||||
|
for _, combo := range combinations {
|
||||||
|
// Parse the combination into individual tags
|
||||||
|
tags := parseTags(combo)
|
||||||
|
|
||||||
|
// Check if project exists in this combination
|
||||||
|
for _, tag := range tags {
|
||||||
|
if tag == projectTag {
|
||||||
|
// Found the project - now remove it from display
|
||||||
|
var displayTags []string
|
||||||
|
for _, t := range tags {
|
||||||
|
if t != projectTag {
|
||||||
|
displayTags = append(displayTags, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(displayTags) > 0 {
|
||||||
|
filtered = append(filtered, formatTags(displayTags))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTagSuggestions refreshes the tag autocomplete with filtered combinations
|
||||||
|
func (p *TimeEditorPage) updateTagSuggestions() tea.Cmd {
|
||||||
|
combinations := filterTagCombinationsByProject(
|
||||||
|
p.common.TimeW.GetTagCombinations(),
|
||||||
|
p.selectedProject,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update autocomplete suggestions
|
||||||
|
currentValue := p.tagsInput.GetValue()
|
||||||
|
p.tagsInput.SetSuggestions(combinations)
|
||||||
|
p.tagsInput.SetValue(currentValue)
|
||||||
|
|
||||||
|
// If tags field is focused, refocus it
|
||||||
|
if p.currentField == 2 {
|
||||||
|
p.tagsInput.Focus()
|
||||||
|
return p.tagsInput.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -143,7 +144,7 @@ func (p *TimePage) renderHeader() string {
|
|||||||
|
|
||||||
slog.Info("Rendering time page header", "text", headerText, "timespan", p.selectedTimespan)
|
slog.Info("Rendering time page header", "text", headerText, "timespan", p.selectedTimespan)
|
||||||
// Make header bold and prominent
|
// Make header bold and prominent
|
||||||
headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6"))
|
headerStyle := lipgloss.NewStyle().Bold(true).Foreground(p.common.Styles.Palette.Accent.GetForeground())
|
||||||
return headerStyle.Render(headerText)
|
return headerStyle.Render(headerText)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,8 +164,27 @@ func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case intervalsMsg:
|
case intervalsMsg:
|
||||||
p.data = timewarrior.Intervals(msg)
|
p.data = timewarrior.Intervals(msg)
|
||||||
p.populateTable(p.data)
|
p.populateTable(p.data)
|
||||||
|
|
||||||
|
// If we have a pending sync action (from continuing an interval),
|
||||||
|
// execute it now that intervals are refreshed
|
||||||
|
if p.pendingSyncAction != "" {
|
||||||
|
action := p.pendingSyncAction
|
||||||
|
p.pendingSyncAction = ""
|
||||||
|
cmds = append(cmds, p.syncActiveIntervalAfterRefresh(action))
|
||||||
|
}
|
||||||
|
case TaskPickedMsg:
|
||||||
|
if msg.Task != nil && msg.Task.Status == "pending" {
|
||||||
|
p.common.TW.StopActiveTasks()
|
||||||
|
p.common.TW.StartTask(msg.Task)
|
||||||
|
cmds = append(cmds, p.getIntervals())
|
||||||
|
cmds = append(cmds, doTick())
|
||||||
|
}
|
||||||
case RefreshIntervalsMsg:
|
case RefreshIntervalsMsg:
|
||||||
cmds = append(cmds, p.getIntervals())
|
cmds = append(cmds, p.getIntervals())
|
||||||
|
cmds = append(cmds, doTick())
|
||||||
|
case BackMsg:
|
||||||
|
// Restart tick loop when returning from subpage
|
||||||
|
cmds = append(cmds, doTick())
|
||||||
case tickMsg:
|
case tickMsg:
|
||||||
cmds = append(cmds, p.getIntervals())
|
cmds = append(cmds, p.getIntervals())
|
||||||
cmds = append(cmds, doTick())
|
cmds = append(cmds, doTick())
|
||||||
@@ -178,17 +198,42 @@ func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
cmd := p.subpage.Init()
|
cmd := p.subpage.Init()
|
||||||
p.common.PushPage(p)
|
p.common.PushPage(p)
|
||||||
return p.subpage, cmd
|
return p.subpage, cmd
|
||||||
|
case key.Matches(msg, p.common.Keymap.PickProjectTask):
|
||||||
|
p.subpage = NewProjectTaskPickerPage(p.common)
|
||||||
|
cmd := p.subpage.Init()
|
||||||
|
p.common.PushPage(p)
|
||||||
|
return p.subpage, cmd
|
||||||
case key.Matches(msg, p.common.Keymap.StartStop):
|
case key.Matches(msg, p.common.Keymap.StartStop):
|
||||||
row := p.intervals.SelectedRow()
|
row := p.intervals.SelectedRow()
|
||||||
if row != nil {
|
if row != nil {
|
||||||
interval := (*timewarrior.Interval)(row)
|
interval := (*timewarrior.Interval)(row)
|
||||||
|
|
||||||
|
// Validate interval before proceeding
|
||||||
|
if interval.IsGap {
|
||||||
|
slog.Debug("Cannot start/stop gap interval")
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
if interval.IsActive() {
|
if interval.IsActive() {
|
||||||
|
// Stop tracking
|
||||||
p.common.TimeW.StopTracking()
|
p.common.TimeW.StopTracking()
|
||||||
|
// Sync: stop corresponding Taskwarrior task immediately (interval has UUID)
|
||||||
|
common.SyncIntervalToTask(interval, p.common.TW, "stop")
|
||||||
} else {
|
} else {
|
||||||
|
// Continue tracking - creates a NEW interval
|
||||||
|
slog.Info("Continuing interval for task sync",
|
||||||
|
"intervalID", interval.ID,
|
||||||
|
"hasUUID", timewarrior.ExtractUUID(interval.Tags) != "",
|
||||||
|
"uuid", timewarrior.ExtractUUID(interval.Tags))
|
||||||
p.common.TimeW.ContinueInterval(interval.ID)
|
p.common.TimeW.ContinueInterval(interval.ID)
|
||||||
p.shouldSelectActive = true
|
p.shouldSelectActive = true
|
||||||
|
// Set pending sync action instead of syncing immediately
|
||||||
|
// This ensures we sync AFTER intervals are refreshed
|
||||||
|
p.pendingSyncAction = "start"
|
||||||
}
|
}
|
||||||
return p, tea.Batch(p.getIntervals(), doTick())
|
cmds = append(cmds, p.getIntervals())
|
||||||
|
cmds = append(cmds, doTick())
|
||||||
|
return p, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
case key.Matches(msg, p.common.Keymap.Delete):
|
case key.Matches(msg, p.common.Keymap.Delete):
|
||||||
row := p.intervals.SelectedRow()
|
row := p.intervals.SelectedRow()
|
||||||
@@ -351,6 +396,7 @@ func (p *TimePage) populateTable(intervals timewarrior.Intervals) {
|
|||||||
{Title: "Start", Name: startField, Width: startEndWidth},
|
{Title: "Start", Name: startField, Width: startEndWidth},
|
||||||
{Title: "End", Name: endField, Width: startEndWidth},
|
{Title: "End", Name: endField, Width: startEndWidth},
|
||||||
{Title: "Duration", Name: "duration", Width: 10},
|
{Title: "Duration", Name: "duration", Width: 10},
|
||||||
|
{Title: "Project", Name: "project", Width: 0}, // flexible width
|
||||||
{Title: "Tags", Name: "tags", Width: 0}, // flexible width
|
{Title: "Tags", Name: "tags", Width: 0}, // flexible width
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,3 +465,34 @@ func (p *TimePage) getIntervals() tea.Cmd {
|
|||||||
return intervalsMsg(intervals)
|
return intervalsMsg(intervals)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// syncActiveInterval creates a command that syncs the currently active interval to Taskwarrior
|
||||||
|
func (p *TimePage) syncActiveInterval(action string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
// Get the currently active interval
|
||||||
|
activeInterval := p.common.TimeW.GetActive()
|
||||||
|
if activeInterval != nil {
|
||||||
|
common.SyncIntervalToTask(activeInterval, p.common.TW, action)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncActiveIntervalAfterRefresh is called AFTER intervals have been refreshed
|
||||||
|
// to ensure we're working with current data
|
||||||
|
func (p *TimePage) syncActiveIntervalAfterRefresh(action string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
// At this point, intervals have been refreshed, so GetActive() will work
|
||||||
|
activeInterval := p.common.TimeW.GetActive()
|
||||||
|
if activeInterval != nil {
|
||||||
|
slog.Info("Syncing active interval to task after refresh",
|
||||||
|
"action", action,
|
||||||
|
"intervalID", activeInterval.ID,
|
||||||
|
"hasUUID", timewarrior.ExtractUUID(activeInterval.Tags) != "")
|
||||||
|
common.SyncIntervalToTask(activeInterval, p.common.TW, action)
|
||||||
|
} else {
|
||||||
|
slog.Warn("No active interval found after refresh, cannot sync to task")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,14 +47,14 @@ type Task struct {
|
|||||||
Uuid string `json:"uuid,omitempty"`
|
Uuid string `json:"uuid,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Project string `json:"project"`
|
Project string `json:"project"`
|
||||||
// Priority string `json:"priority"`
|
Priority string `json:"priority,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
VirtualTags []string `json:"-"`
|
VirtualTags []string `json:"-"`
|
||||||
Depends []string `json:"depends,omitempty"`
|
Depends []string `json:"depends,omitempty"`
|
||||||
DependsIds string `json:"-"`
|
DependsIds string `json:"-"`
|
||||||
Urgency float32 `json:"urgency,omitempty"`
|
Urgency float32 `json:"urgency,omitempty"`
|
||||||
Parent string `json:"parent,omitempty"`
|
Parent string `json:"parenttask,omitempty"`
|
||||||
Due string `json:"due,omitempty"`
|
Due string `json:"due,omitempty"`
|
||||||
Wait string `json:"wait,omitempty"`
|
Wait string `json:"wait,omitempty"`
|
||||||
Scheduled string `json:"scheduled,omitempty"`
|
Scheduled string `json:"scheduled,omitempty"`
|
||||||
@@ -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
|
||||||
@@ -168,7 +168,7 @@ func (t *Task) GetString(fieldWFormat string) string {
|
|||||||
}
|
}
|
||||||
return strings.Join(t.Tags, " ")
|
return strings.Join(t.Tags, " ")
|
||||||
|
|
||||||
case "parent":
|
case "parenttask":
|
||||||
if format == "short" {
|
if format == "short" {
|
||||||
return t.Parent[:8]
|
return t.Parent[:8]
|
||||||
}
|
}
|
||||||
@@ -229,14 +229,40 @@ func (t *Task) GetString(fieldWFormat string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Error(fmt.Sprintf("Field not implemented: %s", field))
|
slog.Error("Field not implemented", "field", field)
|
||||||
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", "error", err)
|
||||||
return time.Time{}
|
return time.Time{}
|
||||||
}
|
}
|
||||||
return dt
|
return dt
|
||||||
@@ -291,7 +317,7 @@ func (t *Task) UnmarshalJSON(data []byte) error {
|
|||||||
delete(m, "tags")
|
delete(m, "tags")
|
||||||
delete(m, "depends")
|
delete(m, "depends")
|
||||||
delete(m, "urgency")
|
delete(m, "urgency")
|
||||||
delete(m, "parent")
|
delete(m, "parenttask")
|
||||||
delete(m, "due")
|
delete(m, "due")
|
||||||
delete(m, "wait")
|
delete(m, "wait")
|
||||||
delete(m, "scheduled")
|
delete(m, "scheduled")
|
||||||
@@ -358,7 +384,7 @@ func formatDate(date string, format string) string {
|
|||||||
|
|
||||||
dt, err := time.Parse(dtformat, date)
|
dt, err := time.Parse(dtformat, date)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to parse time:", err)
|
slog.Error("Failed to parse time", "error", err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,7 +408,7 @@ func formatDate(date string, format string) string {
|
|||||||
case "countdown":
|
case "countdown":
|
||||||
return parseCountdown(time.Since(dt))
|
return parseCountdown(time.Since(dt))
|
||||||
default:
|
default:
|
||||||
slog.Error(fmt.Sprintf("Date format not implemented: %s", format))
|
slog.Error("Date format not implemented", "format", format)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
81
taskwarrior/models_test.go
Normal file
81
taskwarrior/models_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ package taskwarrior
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -98,6 +99,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)
|
||||||
|
|
||||||
@@ -110,11 +112,12 @@ type TaskSquire struct {
|
|||||||
config *TWConfig
|
config *TWConfig
|
||||||
reports Reports
|
reports Reports
|
||||||
contexts Contexts
|
contexts Contexts
|
||||||
|
ctx context.Context
|
||||||
|
|
||||||
mutex sync.Mutex
|
mutex sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTaskSquire(configLocation string) *TaskSquire {
|
func NewTaskSquire(ctx context.Context, configLocation string) *TaskSquire {
|
||||||
if _, err := exec.LookPath(twBinary); err != nil {
|
if _, err := exec.LookPath(twBinary); err != nil {
|
||||||
slog.Error("Taskwarrior not found")
|
slog.Error("Taskwarrior not found")
|
||||||
return nil
|
return nil
|
||||||
@@ -124,9 +127,14 @@ func NewTaskSquire(configLocation string) *TaskSquire {
|
|||||||
ts := &TaskSquire{
|
ts := &TaskSquire{
|
||||||
configLocation: configLocation,
|
configLocation: configLocation,
|
||||||
defaultArgs: defaultArgs,
|
defaultArgs: defaultArgs,
|
||||||
|
ctx: ctx,
|
||||||
mutex: sync.Mutex{},
|
mutex: sync.Mutex{},
|
||||||
}
|
}
|
||||||
ts.config = ts.extractConfig()
|
ts.config = ts.extractConfig()
|
||||||
|
if ts.config == nil {
|
||||||
|
slog.Error("Failed to extract config - taskwarrior commands are failing. Check your taskrc file for syntax errors.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
ts.reports = ts.extractReports()
|
ts.reports = ts.extractReports()
|
||||||
ts.contexts = ts.extractContexts()
|
ts.contexts = ts.extractContexts()
|
||||||
|
|
||||||
@@ -146,7 +154,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,17 +167,25 @@ 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.CommandContext(ts.ctx, twBinary, append(args, exportArgs...)...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed getting report:", err)
|
if ts.ctx.Err() == context.Canceled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
slog.Error("Failed getting report", "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks := make(Tasks, 0)
|
tasks := make(Tasks, 0)
|
||||||
err = json.Unmarshal(output, &tasks)
|
err = json.Unmarshal(output, &tasks)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed unmarshalling tasks:", err)
|
slog.Error("Failed unmarshalling tasks", "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,10 +204,10 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) getIds(filter []string) string {
|
func (ts *TaskSquire) getIds(filter []string) string {
|
||||||
cmd := exec.Command(twBinary, append(append(ts.defaultArgs, filter...), "_ids")...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(append(ts.defaultArgs, filter...), "_ids")...)
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed getting field:", err)
|
slog.Error("Failed getting field", "error", err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +225,7 @@ func (ts *TaskSquire) GetContext(context string) *Context {
|
|||||||
if context, ok := ts.contexts[context]; ok {
|
if context, ok := ts.contexts[context]; ok {
|
||||||
return context
|
return context
|
||||||
} else {
|
} else {
|
||||||
slog.Error(fmt.Sprintf("Context not found: %s", context.Name))
|
slog.Error("Context not found", "name", context)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,11 +254,11 @@ func (ts *TaskSquire) GetProjects() []string {
|
|||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_projects"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_projects"}...)...)
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed getting projects:", err)
|
slog.Error("Failed getting projects", "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,11 +292,11 @@ func (ts *TaskSquire) GetTags() []string {
|
|||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_tags"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_tags"}...)...)
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed getting tags:", err)
|
slog.Error("Failed getting tags", "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,10 +339,13 @@ func (ts *TaskSquire) GetUdas() []Uda {
|
|||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, "_udas")...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "_udas")...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed getting config:", err)
|
if ts.ctx.Err() == context.Canceled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
slog.Error("Failed getting UDAs", "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,7 +354,7 @@ func (ts *TaskSquire) GetUdas() []Uda {
|
|||||||
if uda != "" {
|
if uda != "" {
|
||||||
udatype := UdaType(ts.config.Get(fmt.Sprintf("uda.%s.type", uda)))
|
udatype := UdaType(ts.config.Get(fmt.Sprintf("uda.%s.type", uda)))
|
||||||
if udatype == "" {
|
if udatype == "" {
|
||||||
slog.Error(fmt.Sprintf("UDA type not found: %s", uda))
|
slog.Error("UDA type not found", "uda", uda)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,9 +385,9 @@ func (ts *TaskSquire) SetContext(context *Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, []string{"context", context.Name}...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, []string{"context", context.Name}...)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
slog.Error("Failed setting context:", err)
|
slog.Error("Failed setting context", "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,14 +442,17 @@ func (ts *TaskSquire) ImportTask(task *Task) {
|
|||||||
|
|
||||||
tasks, err := json.Marshal(Tasks{task})
|
tasks, err := json.Marshal(Tasks{task})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed marshalling task:", err)
|
slog.Error("Failed marshalling task", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"import", "-"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"import", "-"}...)...)
|
||||||
cmd.Stdin = bytes.NewBuffer(tasks)
|
cmd.Stdin = bytes.NewBuffer(tasks)
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed modifying task:", err, string(out))
|
if ts.ctx.Err() == context.Canceled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Error("Failed modifying task", "error", err, "output", string(out))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,10 +460,10 @@ func (ts *TaskSquire) SetTaskDone(task *Task) {
|
|||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"done", task.Uuid}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"done", task.Uuid}...)...)
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed setting task done:", err)
|
slog.Error("Failed setting task done", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,10 +471,10 @@ func (ts *TaskSquire) DeleteTask(task *Task) {
|
|||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{task.Uuid, "delete"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{task.Uuid, "delete"}...)...)
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed deleting task:", err)
|
slog.Error("Failed deleting task", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -461,10 +483,10 @@ func (ts *TaskSquire) Undo() {
|
|||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed undoing task:", err)
|
slog.Error("Failed undoing task", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,10 +494,10 @@ func (ts *TaskSquire) StartTask(task *Task) {
|
|||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"start", task.Uuid}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"start", task.Uuid}...)...)
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed starting task:", err)
|
slog.Error("Failed starting task", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,10 +505,40 @@ func (ts *TaskSquire) StopTask(task *Task) {
|
|||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed stopping task:", err)
|
slog.Error("Failed stopping task", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskSquire) StopActiveTasks() {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"+ACTIVE", "export"}...)...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
if ts.ctx.Err() == context.Canceled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, task := range tasks {
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed stopping task", "error", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,10 +546,13 @@ func (ts *TaskSquire) GetInformation(task *Task) string {
|
|||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{fmt.Sprintf("uuid:%s", task.Uuid), "information"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{fmt.Sprintf("uuid:%s", task.Uuid), "information"}...)...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed getting task information:", err)
|
if ts.ctx.Err() == context.Canceled {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
slog.Error("Failed getting task information", "error", err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,18 +563,21 @@ func (ts *TaskSquire) AddTaskAnnotation(uuid string, annotation string) {
|
|||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{uuid, "annotate", annotation}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{uuid, "annotate", annotation}...)...)
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed adding annotation:", err)
|
slog.Error("Failed adding annotation", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) extractConfig() *TWConfig {
|
func (ts *TaskSquire) extractConfig() *TWConfig {
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_show"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_show"}...)...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed getting config:", err)
|
if ts.ctx.Err() == context.Canceled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
slog.Error("Failed getting config", "error", err, "output", string(output))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,7 +585,7 @@ func (ts *TaskSquire) extractConfig() *TWConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) extractReports() Reports {
|
func (ts *TaskSquire) extractReports() Reports {
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_config"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_config"}...)...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -573,11 +631,14 @@ func extractReports(config string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) extractContexts() Contexts {
|
func (ts *TaskSquire) extractContexts() Contexts {
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_context"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_context"}...)...)
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed getting contexts:", err)
|
if ts.ctx.Err() == context.Canceled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
slog.Error("Failed getting contexts", "error", err, "output", string(output))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package taskwarrior
|
package taskwarrior
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -56,7 +57,7 @@ func TestTaskSquire_GetContext(t *testing.T) {
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
tt.prep()
|
tt.prep()
|
||||||
ts := NewTaskSquire(tt.fields.configLocation)
|
ts := NewTaskSquire(context.Background(), tt.fields.configLocation)
|
||||||
if got := ts.GetActiveContext(); got.Name != tt.want {
|
if got := ts.GetActiveContext(); got.Name != tt.want {
|
||||||
t.Errorf("TaskSquire.GetContext() = %v, want %v", got, tt.want)
|
t.Errorf("TaskSquire.GetContext() = %v, want %v", got, tt.want)
|
||||||
}
|
}
|
||||||
|
|||||||
127
taskwarrior/tree.go
Normal file
127
taskwarrior/tree.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package taskwarrior
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskNode represents a task in the tree structure
|
||||||
|
type TaskNode struct {
|
||||||
|
Task *Task
|
||||||
|
Children []*TaskNode
|
||||||
|
Parent *TaskNode
|
||||||
|
Depth int
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskTree manages the hierarchical task structure
|
||||||
|
type TaskTree struct {
|
||||||
|
Nodes map[string]*TaskNode // UUID -> TaskNode
|
||||||
|
Roots []*TaskNode // Top-level tasks (no parent)
|
||||||
|
FlatList []*TaskNode // Flattened tree in display order
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildTaskTree constructs a tree from a flat list of tasks
|
||||||
|
// Three-pass algorithm:
|
||||||
|
// 1. Create all nodes
|
||||||
|
// 2. Establish parent-child relationships
|
||||||
|
// 3. Calculate depths and flatten tree
|
||||||
|
func BuildTaskTree(tasks Tasks) *TaskTree {
|
||||||
|
tree := &TaskTree{
|
||||||
|
Nodes: make(map[string]*TaskNode),
|
||||||
|
Roots: make([]*TaskNode, 0),
|
||||||
|
FlatList: make([]*TaskNode, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 1: Create all nodes
|
||||||
|
for _, task := range tasks {
|
||||||
|
node := &TaskNode{
|
||||||
|
Task: task,
|
||||||
|
Children: make([]*TaskNode, 0),
|
||||||
|
Parent: nil,
|
||||||
|
Depth: 0,
|
||||||
|
}
|
||||||
|
tree.Nodes[task.Uuid] = node
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: Establish parent-child relationships
|
||||||
|
// Iterate over original tasks slice to preserve order
|
||||||
|
for _, task := range tasks {
|
||||||
|
node := tree.Nodes[task.Uuid]
|
||||||
|
parentUUID := getParentUUID(node.Task)
|
||||||
|
if parentUUID == "" {
|
||||||
|
// No parent, this is a root task
|
||||||
|
tree.Roots = append(tree.Roots, node)
|
||||||
|
} else {
|
||||||
|
// Find parent node
|
||||||
|
parentNode, exists := tree.Nodes[parentUUID]
|
||||||
|
if !exists {
|
||||||
|
// Orphaned task - missing parent
|
||||||
|
slog.Warn("Task has missing parent",
|
||||||
|
"task_uuid", node.Task.Uuid,
|
||||||
|
"parent_uuid", parentUUID,
|
||||||
|
"task_desc", node.Task.Description)
|
||||||
|
// Treat as root (graceful degradation)
|
||||||
|
tree.Roots = append(tree.Roots, node)
|
||||||
|
} else {
|
||||||
|
// Establish relationship
|
||||||
|
node.Parent = parentNode
|
||||||
|
parentNode.Children = append(parentNode.Children, node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 3: Calculate depths and flatten tree
|
||||||
|
for _, root := range tree.Roots {
|
||||||
|
flattenNode(root, 0, &tree.FlatList)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tree
|
||||||
|
}
|
||||||
|
|
||||||
|
// getParentUUID extracts the parent UUID from a task's UDAs
|
||||||
|
func getParentUUID(task *Task) string {
|
||||||
|
if task.Udas == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
parentVal, exists := task.Udas["parenttask"]
|
||||||
|
if !exists {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent UDA is stored as a string
|
||||||
|
if parentStr, ok := parentVal.(string); ok {
|
||||||
|
return parentStr
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// flattenNode recursively flattens the tree in depth-first order
|
||||||
|
func flattenNode(node *TaskNode, depth int, flatList *[]*TaskNode) {
|
||||||
|
node.Depth = depth
|
||||||
|
*flatList = append(*flatList, node)
|
||||||
|
|
||||||
|
// Recursively flatten children
|
||||||
|
for _, child := range node.Children {
|
||||||
|
flattenNode(child, depth+1, flatList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChildrenStatus returns completed/total counts for a parent task
|
||||||
|
func (tn *TaskNode) GetChildrenStatus() (completed int, total int) {
|
||||||
|
total = len(tn.Children)
|
||||||
|
completed = 0
|
||||||
|
|
||||||
|
for _, child := range tn.Children {
|
||||||
|
if child.Task.Status == "completed" {
|
||||||
|
completed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return completed, total
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasChildren returns true if the node has any children
|
||||||
|
func (tn *TaskNode) HasChildren() bool {
|
||||||
|
return len(tn.Children) > 0
|
||||||
|
}
|
||||||
345
taskwarrior/tree_test.go
Normal file
345
taskwarrior/tree_test.go
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
package taskwarrior
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildTaskTree_EmptyList(t *testing.T) {
|
||||||
|
tasks := Tasks{}
|
||||||
|
tree := BuildTaskTree(tasks)
|
||||||
|
|
||||||
|
if tree == nil {
|
||||||
|
t.Fatal("Expected tree to be non-nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tree.Nodes) != 0 {
|
||||||
|
t.Errorf("Expected 0 nodes, got %d", len(tree.Nodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tree.Roots) != 0 {
|
||||||
|
t.Errorf("Expected 0 roots, got %d", len(tree.Roots))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tree.FlatList) != 0 {
|
||||||
|
t.Errorf("Expected 0 items in flat list, got %d", len(tree.FlatList))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildTaskTree_NoParents(t *testing.T) {
|
||||||
|
tasks := Tasks{
|
||||||
|
{Uuid: "task1", Description: "Task 1", Status: "pending"},
|
||||||
|
{Uuid: "task2", Description: "Task 2", Status: "pending"},
|
||||||
|
{Uuid: "task3", Description: "Task 3", Status: "completed"},
|
||||||
|
}
|
||||||
|
|
||||||
|
tree := BuildTaskTree(tasks)
|
||||||
|
|
||||||
|
if len(tree.Nodes) != 3 {
|
||||||
|
t.Errorf("Expected 3 nodes, got %d", len(tree.Nodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tree.Roots) != 3 {
|
||||||
|
t.Errorf("Expected 3 roots (all tasks have no parent), got %d", len(tree.Roots))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tree.FlatList) != 3 {
|
||||||
|
t.Errorf("Expected 3 items in flat list, got %d", len(tree.FlatList))
|
||||||
|
}
|
||||||
|
|
||||||
|
// All tasks should have depth 0
|
||||||
|
for i, node := range tree.FlatList {
|
||||||
|
if node.Depth != 0 {
|
||||||
|
t.Errorf("Task %d expected depth 0, got %d", i, node.Depth)
|
||||||
|
}
|
||||||
|
if node.HasChildren() {
|
||||||
|
t.Errorf("Task %d should not have children", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildTaskTree_SimpleParentChild(t *testing.T) {
|
||||||
|
tasks := Tasks{
|
||||||
|
{Uuid: "parent1", Description: "Parent Task", Status: "pending"},
|
||||||
|
{
|
||||||
|
Uuid: "child1",
|
||||||
|
Description: "Child Task 1",
|
||||||
|
Status: "pending",
|
||||||
|
Udas: map[string]any{"parenttask": "parent1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Uuid: "child2",
|
||||||
|
Description: "Child Task 2",
|
||||||
|
Status: "completed",
|
||||||
|
Udas: map[string]any{"parenttask": "parent1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tree := BuildTaskTree(tasks)
|
||||||
|
|
||||||
|
if len(tree.Nodes) != 3 {
|
||||||
|
t.Fatalf("Expected 3 nodes, got %d", len(tree.Nodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tree.Roots) != 1 {
|
||||||
|
t.Fatalf("Expected 1 root, got %d", len(tree.Roots))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check root is the parent
|
||||||
|
root := tree.Roots[0]
|
||||||
|
if root.Task.Uuid != "parent1" {
|
||||||
|
t.Errorf("Expected root to be parent1, got %s", root.Task.Uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check parent has 2 children
|
||||||
|
if len(root.Children) != 2 {
|
||||||
|
t.Fatalf("Expected parent to have 2 children, got %d", len(root.Children))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check children status
|
||||||
|
completed, total := root.GetChildrenStatus()
|
||||||
|
if total != 2 {
|
||||||
|
t.Errorf("Expected total children = 2, got %d", total)
|
||||||
|
}
|
||||||
|
if completed != 1 {
|
||||||
|
t.Errorf("Expected completed children = 1, got %d", completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check flat list order (parent first, then children)
|
||||||
|
if len(tree.FlatList) != 3 {
|
||||||
|
t.Fatalf("Expected 3 items in flat list, got %d", len(tree.FlatList))
|
||||||
|
}
|
||||||
|
|
||||||
|
if tree.FlatList[0].Task.Uuid != "parent1" {
|
||||||
|
t.Errorf("Expected first item to be parent, got %s", tree.FlatList[0].Task.Uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tree.FlatList[0].Depth != 0 {
|
||||||
|
t.Errorf("Expected parent depth = 0, got %d", tree.FlatList[0].Depth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Children should be at depth 1
|
||||||
|
for i := 1; i < 3; i++ {
|
||||||
|
if tree.FlatList[i].Depth != 1 {
|
||||||
|
t.Errorf("Expected child %d depth = 1, got %d", i, tree.FlatList[i].Depth)
|
||||||
|
}
|
||||||
|
if tree.FlatList[i].Parent == nil {
|
||||||
|
t.Errorf("Child %d should have a parent", i)
|
||||||
|
} else if tree.FlatList[i].Parent.Task.Uuid != "parent1" {
|
||||||
|
t.Errorf("Child %d parent should be parent1, got %s", i, tree.FlatList[i].Parent.Task.Uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildTaskTree_MultiLevel(t *testing.T) {
|
||||||
|
tasks := Tasks{
|
||||||
|
{Uuid: "grandparent", Description: "Grandparent", Status: "pending"},
|
||||||
|
{
|
||||||
|
Uuid: "parent1",
|
||||||
|
Description: "Parent 1",
|
||||||
|
Status: "pending",
|
||||||
|
Udas: map[string]any{"parenttask": "grandparent"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Uuid: "parent2",
|
||||||
|
Description: "Parent 2",
|
||||||
|
Status: "pending",
|
||||||
|
Udas: map[string]any{"parenttask": "grandparent"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Uuid: "child1",
|
||||||
|
Description: "Child 1",
|
||||||
|
Status: "pending",
|
||||||
|
Udas: map[string]any{"parenttask": "parent1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Uuid: "grandchild1",
|
||||||
|
Description: "Grandchild 1",
|
||||||
|
Status: "completed",
|
||||||
|
Udas: map[string]any{"parenttask": "child1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tree := BuildTaskTree(tasks)
|
||||||
|
|
||||||
|
if len(tree.Nodes) != 5 {
|
||||||
|
t.Fatalf("Expected 5 nodes, got %d", len(tree.Nodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tree.Roots) != 1 {
|
||||||
|
t.Fatalf("Expected 1 root, got %d", len(tree.Roots))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find nodes by UUID
|
||||||
|
grandparentNode := tree.Nodes["grandparent"]
|
||||||
|
parent1Node := tree.Nodes["parent1"]
|
||||||
|
child1Node := tree.Nodes["child1"]
|
||||||
|
grandchildNode := tree.Nodes["grandchild1"]
|
||||||
|
|
||||||
|
// Check depths
|
||||||
|
if grandparentNode.Depth != 0 {
|
||||||
|
t.Errorf("Expected grandparent depth = 0, got %d", grandparentNode.Depth)
|
||||||
|
}
|
||||||
|
if parent1Node.Depth != 1 {
|
||||||
|
t.Errorf("Expected parent1 depth = 1, got %d", parent1Node.Depth)
|
||||||
|
}
|
||||||
|
if child1Node.Depth != 2 {
|
||||||
|
t.Errorf("Expected child1 depth = 2, got %d", child1Node.Depth)
|
||||||
|
}
|
||||||
|
if grandchildNode.Depth != 3 {
|
||||||
|
t.Errorf("Expected grandchild depth = 3, got %d", grandchildNode.Depth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check parent-child relationships
|
||||||
|
if len(grandparentNode.Children) != 2 {
|
||||||
|
t.Errorf("Expected grandparent to have 2 children, got %d", len(grandparentNode.Children))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parent1Node.Children) != 1 {
|
||||||
|
t.Errorf("Expected parent1 to have 1 child, got %d", len(parent1Node.Children))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(child1Node.Children) != 1 {
|
||||||
|
t.Errorf("Expected child1 to have 1 child, got %d", len(child1Node.Children))
|
||||||
|
}
|
||||||
|
|
||||||
|
if grandchildNode.HasChildren() {
|
||||||
|
t.Error("Expected grandchild to have no children")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check flat list maintains tree order
|
||||||
|
if len(tree.FlatList) != 5 {
|
||||||
|
t.Fatalf("Expected 5 items in flat list, got %d", len(tree.FlatList))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grandparent should be first
|
||||||
|
if tree.FlatList[0].Task.Uuid != "grandparent" {
|
||||||
|
t.Errorf("Expected first item to be grandparent, got %s", tree.FlatList[0].Task.Uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildTaskTree_OrphanedTask(t *testing.T) {
|
||||||
|
tasks := Tasks{
|
||||||
|
{Uuid: "task1", Description: "Normal Task", Status: "pending"},
|
||||||
|
{
|
||||||
|
Uuid: "orphan",
|
||||||
|
Description: "Orphaned Task",
|
||||||
|
Status: "pending",
|
||||||
|
Udas: map[string]any{"parenttask": "nonexistent"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tree := BuildTaskTree(tasks)
|
||||||
|
|
||||||
|
if len(tree.Nodes) != 2 {
|
||||||
|
t.Fatalf("Expected 2 nodes, got %d", len(tree.Nodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orphaned task should be treated as root
|
||||||
|
if len(tree.Roots) != 2 {
|
||||||
|
t.Errorf("Expected 2 roots (orphan should be treated as root), got %d", len(tree.Roots))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both should have depth 0
|
||||||
|
for _, node := range tree.FlatList {
|
||||||
|
if node.Depth != 0 {
|
||||||
|
t.Errorf("Expected all tasks to have depth 0, got %d for %s", node.Depth, node.Task.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskNode_GetChildrenStatus(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
children []*TaskNode
|
||||||
|
wantComp int
|
||||||
|
wantTotal int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no children",
|
||||||
|
children: []*TaskNode{},
|
||||||
|
wantComp: 0,
|
||||||
|
wantTotal: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all pending",
|
||||||
|
children: []*TaskNode{
|
||||||
|
{Task: &Task{Status: "pending"}},
|
||||||
|
{Task: &Task{Status: "pending"}},
|
||||||
|
},
|
||||||
|
wantComp: 0,
|
||||||
|
wantTotal: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all completed",
|
||||||
|
children: []*TaskNode{
|
||||||
|
{Task: &Task{Status: "completed"}},
|
||||||
|
{Task: &Task{Status: "completed"}},
|
||||||
|
{Task: &Task{Status: "completed"}},
|
||||||
|
},
|
||||||
|
wantComp: 3,
|
||||||
|
wantTotal: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed status",
|
||||||
|
children: []*TaskNode{
|
||||||
|
{Task: &Task{Status: "completed"}},
|
||||||
|
{Task: &Task{Status: "pending"}},
|
||||||
|
{Task: &Task{Status: "completed"}},
|
||||||
|
{Task: &Task{Status: "pending"}},
|
||||||
|
{Task: &Task{Status: "completed"}},
|
||||||
|
},
|
||||||
|
wantComp: 3,
|
||||||
|
wantTotal: 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
node := &TaskNode{
|
||||||
|
Task: &Task{},
|
||||||
|
Children: tt.children,
|
||||||
|
}
|
||||||
|
|
||||||
|
gotComp, gotTotal := node.GetChildrenStatus()
|
||||||
|
|
||||||
|
if gotComp != tt.wantComp {
|
||||||
|
t.Errorf("GetChildrenStatus() completed = %d, want %d", gotComp, tt.wantComp)
|
||||||
|
}
|
||||||
|
if gotTotal != tt.wantTotal {
|
||||||
|
t.Errorf("GetChildrenStatus() total = %d, want %d", gotTotal, tt.wantTotal)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskNode_HasChildren(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
children []*TaskNode
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no children",
|
||||||
|
children: []*TaskNode{},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "has children",
|
||||||
|
children: []*TaskNode{{Task: &Task{}}},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
node := &TaskNode{
|
||||||
|
Task: &Task{},
|
||||||
|
Children: tt.children,
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := node.HasChildren(); got != tt.want {
|
||||||
|
t.Errorf("HasChildren() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -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()
|
||||||
@@ -95,7 +104,7 @@ func (i *Interval) GetString(field string) string {
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
default:
|
default:
|
||||||
slog.Error(fmt.Sprintf("Field not implemented: %s", field))
|
slog.Error("Field not implemented", "field", field)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,7 +112,7 @@ func (i *Interval) GetString(field string) string {
|
|||||||
func (i *Interval) GetDuration() string {
|
func (i *Interval) GetDuration() string {
|
||||||
start, err := time.Parse(dtformat, i.Start)
|
start, err := time.Parse(dtformat, i.Start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to parse start time:", err)
|
slog.Error("Failed to parse start time", "error", err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +122,7 @@ func (i *Interval) GetDuration() string {
|
|||||||
} else {
|
} else {
|
||||||
end, err = time.Parse(dtformat, i.End)
|
end, err = time.Parse(dtformat, i.End)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to parse end time:", err)
|
slog.Error("Failed to parse end time", "error", err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,7 +134,7 @@ func (i *Interval) GetDuration() string {
|
|||||||
func (i *Interval) GetStartTime() time.Time {
|
func (i *Interval) GetStartTime() time.Time {
|
||||||
dt, err := time.Parse(dtformat, i.Start)
|
dt, err := time.Parse(dtformat, i.Start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to parse time:", err)
|
slog.Error("Failed to parse time", "error", err)
|
||||||
return time.Time{}
|
return time.Time{}
|
||||||
}
|
}
|
||||||
return dt
|
return dt
|
||||||
@@ -137,7 +146,7 @@ func (i *Interval) GetEndTime() time.Time {
|
|||||||
}
|
}
|
||||||
dt, err := time.Parse(dtformat, i.End)
|
dt, err := time.Parse(dtformat, i.End)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to parse time:", err)
|
slog.Error("Failed to parse time", "error", err)
|
||||||
return time.Time{}
|
return time.Time{}
|
||||||
}
|
}
|
||||||
return dt
|
return dt
|
||||||
@@ -178,7 +187,7 @@ func formatDate(date string, format string) string {
|
|||||||
|
|
||||||
dt, err := time.Parse(dtformat, date)
|
dt, err := time.Parse(dtformat, date)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to parse time:", err)
|
slog.Error("Failed to parse time", "error", err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +211,7 @@ func formatDate(date string, format string) string {
|
|||||||
case "relative":
|
case "relative":
|
||||||
return parseDurationVague(time.Until(dt))
|
return parseDurationVague(time.Until(dt))
|
||||||
default:
|
default:
|
||||||
slog.Error(fmt.Sprintf("Date format not implemented: %s", format))
|
slog.Error("Date format not implemented", "format", format)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
54
timewarrior/tags.go
Normal file
54
timewarrior/tags.go
Normal 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 ""
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ package timewarrior
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -44,11 +45,12 @@ type TimeSquire struct {
|
|||||||
configLocation string
|
configLocation string
|
||||||
defaultArgs []string
|
defaultArgs []string
|
||||||
config *TWConfig
|
config *TWConfig
|
||||||
|
ctx context.Context
|
||||||
|
|
||||||
mutex sync.Mutex
|
mutex sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTimeSquire(configLocation string) *TimeSquire {
|
func NewTimeSquire(ctx context.Context, configLocation string) *TimeSquire {
|
||||||
if _, err := exec.LookPath(twBinary); err != nil {
|
if _, err := exec.LookPath(twBinary); err != nil {
|
||||||
slog.Error("Timewarrior not found")
|
slog.Error("Timewarrior not found")
|
||||||
return nil
|
return nil
|
||||||
@@ -57,6 +59,7 @@ func NewTimeSquire(configLocation string) *TimeSquire {
|
|||||||
ts := &TimeSquire{
|
ts := &TimeSquire{
|
||||||
configLocation: configLocation,
|
configLocation: configLocation,
|
||||||
defaultArgs: []string{},
|
defaultArgs: []string{},
|
||||||
|
ctx: ctx,
|
||||||
mutex: sync.Mutex{},
|
mutex: sync.Mutex{},
|
||||||
}
|
}
|
||||||
ts.config = ts.extractConfig()
|
ts.config = ts.extractConfig()
|
||||||
@@ -75,11 +78,11 @@ func (ts *TimeSquire) GetTags() []string {
|
|||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"tags"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"tags"}...)...)
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed getting tags:", err)
|
slog.Error("Failed getting tags", "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,26 +145,28 @@ 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...)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, args...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed getting intervals:", err)
|
if ts.ctx.Err() == context.Canceled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
slog.Error("Failed getting intervals", "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
intervals := make(Intervals, 0)
|
intervals := make(Intervals, 0)
|
||||||
err = json.Unmarshal(output, &intervals)
|
err = json.Unmarshal(output, &intervals)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed unmarshalling intervals:", err)
|
slog.Error("Failed unmarshalling intervals", "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +181,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()
|
||||||
@@ -187,9 +200,9 @@ func (ts *TimeSquire) StartTracking(tags []string) error {
|
|||||||
args := append(ts.defaultArgs, "start")
|
args := append(ts.defaultArgs, "start")
|
||||||
args = append(args, tags...)
|
args = append(args, tags...)
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, args...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
slog.Error("Failed starting tracking:", err)
|
slog.Error("Failed starting tracking", "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,9 +213,9 @@ func (ts *TimeSquire) StopTracking() error {
|
|||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, "stop")...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "stop")...)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
slog.Error("Failed stopping tracking:", err)
|
slog.Error("Failed stopping tracking", "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,9 +226,9 @@ func (ts *TimeSquire) ContinueTracking() error {
|
|||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, "continue")...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "continue")...)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
slog.Error("Failed continuing tracking:", err)
|
slog.Error("Failed continuing tracking", "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,9 +239,9 @@ func (ts *TimeSquire) ContinueInterval(id int) error {
|
|||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"continue", fmt.Sprintf("@%d", id)}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"continue", fmt.Sprintf("@%d", id)}...)...)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
slog.Error("Failed continuing interval:", err)
|
slog.Error("Failed continuing interval", "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,9 +252,9 @@ func (ts *TimeSquire) CancelTracking() error {
|
|||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, "cancel")...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "cancel")...)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
slog.Error("Failed canceling tracking:", err)
|
slog.Error("Failed canceling tracking", "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,9 +265,9 @@ func (ts *TimeSquire) DeleteInterval(id int) error {
|
|||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"delete", fmt.Sprintf("@%d", id)}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"delete", fmt.Sprintf("@%d", id)}...)...)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
slog.Error("Failed deleting interval:", err)
|
slog.Error("Failed deleting interval", "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,9 +278,9 @@ func (ts *TimeSquire) FillInterval(id int) error {
|
|||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"fill", fmt.Sprintf("@%d", id)}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"fill", fmt.Sprintf("@%d", id)}...)...)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
slog.Error("Failed filling interval:", err)
|
slog.Error("Failed filling interval", "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,9 +293,9 @@ func (ts *TimeSquire) JoinInterval(id int) error {
|
|||||||
|
|
||||||
// Join the current interval with the previous one
|
// Join the current interval with the previous one
|
||||||
// The previous interval has id+1 (since intervals are ordered newest first)
|
// The previous interval has id+1 (since intervals are ordered newest first)
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"join", fmt.Sprintf("@%d", id+1), fmt.Sprintf("@%d", id)}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"join", fmt.Sprintf("@%d", id+1), fmt.Sprintf("@%d", id)}...)...)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
slog.Error("Failed joining interval:", err)
|
slog.Error("Failed joining interval", "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +309,7 @@ func (ts *TimeSquire) ModifyInterval(interval *Interval, adjust bool) error {
|
|||||||
// Export the modified interval
|
// Export the modified interval
|
||||||
intervals, err := json.Marshal(Intervals{interval})
|
intervals, err := json.Marshal(Intervals{interval})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed marshalling interval:", err)
|
slog.Error("Failed marshalling interval", "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,11 +320,11 @@ func (ts *TimeSquire) ModifyInterval(interval *Interval, adjust bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Import the modified interval
|
// Import the modified interval
|
||||||
cmd := exec.Command(twBinary, args...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
|
||||||
cmd.Stdin = bytes.NewBuffer(intervals)
|
cmd.Stdin = bytes.NewBuffer(intervals)
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed modifying interval:", err, string(out))
|
slog.Error("Failed modifying interval", "error", err, "output", string(out))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,10 +340,10 @@ func (ts *TimeSquire) GetSummary(filter ...string) string {
|
|||||||
args = append(args, filter...)
|
args = append(args, filter...)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, args...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed getting summary:", err)
|
slog.Error("Failed getting summary", "error", err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,14 +354,14 @@ func (ts *TimeSquire) GetActive() *Interval {
|
|||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"get", "dom.active"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"get", "dom.active"}...)...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil || string(output) == "0\n" {
|
if err != nil || string(output) == "0\n" {
|
||||||
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
|
||||||
@@ -362,18 +375,18 @@ func (ts *TimeSquire) Undo() {
|
|||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed undoing:", err)
|
slog.Error("Failed undoing", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TimeSquire) extractConfig() *TWConfig {
|
func (ts *TimeSquire) extractConfig() *TWConfig {
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"show"}...)...)
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"show"}...)...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed getting config:", err)
|
slog.Error("Failed getting config", "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user