Compare commits
43 Commits
035d09900e
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0a3e0a568 | ||
|
|
9eda92503e | ||
|
|
418bcd96a8 | ||
|
|
6b1418fc71 | ||
|
|
b46aced2c7 | ||
|
|
3ab26f658d | ||
|
|
1a9fd9b4b0 | ||
|
|
6e60698526 | ||
|
|
703ed981ac | ||
|
|
e3effe8b25 | ||
|
|
980c8eb309 | ||
|
|
e35f480248 | ||
|
|
02fa2e503a | ||
|
|
474bb3dc07 | ||
|
|
1ffcf42773 | ||
|
|
44ddbc0f47 | ||
|
|
2e33893e29 | ||
|
|
46ce91196a | ||
|
|
2b31d9bc2b | ||
|
|
70b6ee9bc7 | ||
|
|
2baf3859fd | ||
|
|
2940711b26 | ||
|
|
f5d297e6ab | ||
|
|
938ed177f1 | ||
|
|
81b9d87935 | ||
|
|
9940316ace | ||
|
|
fc8e9481c3 | ||
|
|
7032d0fa54 | ||
|
|
681ed7e635 | ||
|
|
effd95f6c1 | ||
|
|
4767a6cd91 | ||
|
|
ce193c336c | ||
|
|
f19767fb10 | ||
|
|
82c41a22d2 | ||
|
|
73d51b956a | ||
|
|
fac7ff81dd | ||
|
|
0d55a3b119 | ||
|
|
c660b6cbb1 | ||
|
|
98d2d041d6 | ||
|
|
bafd8958d4 | ||
|
|
3e1cb9d1bc | ||
|
|
0572763e31 | ||
|
|
9aa7b04b98 |
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:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
app.log
|
|
||||||
test/taskchampion.sqlite3
|
test/taskchampion.sqlite3
|
||||||
|
tasksquire
|
||||||
|
test/*.sqlite3*
|
||||||
|
result
|
||||||
|
main
|
||||||
|
|||||||
207
AGENTS.md
Normal file
207
AGENTS.md
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
# Agent Development Guide for TaskSquire
|
||||||
|
|
||||||
|
This guide is for AI coding agents working on TaskSquire, a Go-based TUI (Terminal User Interface) for Taskwarrior.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
- **Language**: Go 1.22.2
|
||||||
|
- **Architecture**: Model-View-Update (MVU) pattern using Bubble Tea framework
|
||||||
|
- **Module**: `tasksquire`
|
||||||
|
- **Main Dependencies**: Bubble Tea, Lip Gloss, Huh, Bubbles (Charm ecosystem)
|
||||||
|
|
||||||
|
## Build, Test, and Lint Commands
|
||||||
|
|
||||||
|
### Building and Running
|
||||||
|
```bash
|
||||||
|
# Run directly
|
||||||
|
go run main.go
|
||||||
|
|
||||||
|
# Build binary
|
||||||
|
go build -o tasksquire main.go
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Run tests for a specific package
|
||||||
|
go test ./taskwarrior
|
||||||
|
|
||||||
|
# Run a single test
|
||||||
|
go test ./taskwarrior -run TestTaskSquire_GetContext
|
||||||
|
|
||||||
|
# Run tests with verbose output
|
||||||
|
go test -v ./taskwarrior
|
||||||
|
|
||||||
|
# Run tests with coverage
|
||||||
|
go test -cover ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linting and Formatting
|
||||||
|
```bash
|
||||||
|
# Format code (always run before committing)
|
||||||
|
go fmt ./...
|
||||||
|
|
||||||
|
# Lint with golangci-lint (available via nix-shell)
|
||||||
|
golangci-lint run
|
||||||
|
|
||||||
|
# Vet code for suspicious constructs
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
# Tidy dependencies
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Environment
|
||||||
|
```bash
|
||||||
|
# Enter Nix development shell (provides all tools)
|
||||||
|
nix develop
|
||||||
|
|
||||||
|
# Or use direnv (automatically loads .envrc)
|
||||||
|
direnv allow
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tasksquire/
|
||||||
|
├── main.go # Entry point: initializes TaskSquire, TimeSquire, and Bubble Tea
|
||||||
|
├── common/ # Shared state, components interface, keymaps, styles, utilities
|
||||||
|
├── pages/ # UI pages/views (report, taskEditor, timePage, pickers, etc.)
|
||||||
|
├── components/ # Reusable UI components (input, table, timetable, picker)
|
||||||
|
├── taskwarrior/ # Taskwarrior CLI wrapper, models, config
|
||||||
|
├── timewarrior/ # Timewarrior integration, models, config
|
||||||
|
└── test/ # Test fixtures and data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
### Imports
|
||||||
|
- **Standard Library First**: Group standard library imports, then third-party, then local
|
||||||
|
- **Local Import Pattern**: Use `tasksquire/<package>` for internal imports
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
"tasksquire/common"
|
||||||
|
"tasksquire/taskwarrior"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
- **Exported Types**: PascalCase (e.g., `TaskSquire`, `ReportPage`, `Common`)
|
||||||
|
- **Unexported Fields**: camelCase (e.g., `configLocation`, `activeReport`, `pageStack`)
|
||||||
|
- **Interfaces**: Follow Go convention, often ending in 'er' (e.g., `TaskWarrior`, `TimeWarrior`, `Component`)
|
||||||
|
- **Constants**: PascalCase or SCREAMING_SNAKE_CASE for exported constants
|
||||||
|
- **Test Functions**: `TestFunctionName` or `TestType_Method`
|
||||||
|
|
||||||
|
### Types and Interfaces
|
||||||
|
- **Interface-Based Design**: Use interfaces for main abstractions (see `TaskWarrior`, `TimeWarrior`, `Component`)
|
||||||
|
- **Struct Composition**: Embed common state (e.g., pages embed or reference `*common.Common`)
|
||||||
|
- **Pointer Receivers**: Use pointer receivers for methods that modify state or for consistency
|
||||||
|
- **Generic Types**: Use generics where appropriate (e.g., `Stack[T]` in `common/stack.go`)
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- **Logging Over Panicking**: Use `log/slog` for structured logging, typically continue execution
|
||||||
|
- **Error Returns**: Return errors from functions, don't log and return
|
||||||
|
- **Context**: Errors are often logged with `slog.Error()` or `slog.Warn()` and execution continues
|
||||||
|
```go
|
||||||
|
// Typical pattern
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to get tasks", "error", err)
|
||||||
|
return nil // or continue with default behavior
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Concurrency and Thread Safety
|
||||||
|
- **Mutex Protection**: Use `sync.Mutex` to protect shared state (see `TaskSquire.mu`)
|
||||||
|
- **Lock Pattern**: Lock before operations, defer unlock
|
||||||
|
```go
|
||||||
|
ts.mu.Lock()
|
||||||
|
defer ts.mu.Unlock()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration and Environment
|
||||||
|
- **Environment Variables**: Respect `TASKRC` and `TIMEWARRIORDB`
|
||||||
|
- **Fallback Paths**: Check standard locations (`~/.taskrc`, `~/.config/task/taskrc`)
|
||||||
|
- **Config Parsing**: Parse Taskwarrior config format manually (see `taskwarrior/config.go`)
|
||||||
|
|
||||||
|
### MVU Pattern (Bubble Tea)
|
||||||
|
- **Components Implement**: `Init() tea.Cmd`, `Update(tea.Msg) (tea.Model, tea.Cmd)`, `View() string`
|
||||||
|
- **Custom Messages**: Define custom message types for inter-component communication
|
||||||
|
- **Cmd Chaining**: Return commands from Init/Update to trigger async operations
|
||||||
|
```go
|
||||||
|
type MyMsg struct {
|
||||||
|
data string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case MyMsg:
|
||||||
|
// Handle custom message
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Styling with Lip Gloss
|
||||||
|
- **Centralized Styles**: Define styles in `common/styles.go`
|
||||||
|
- **Theme Colors**: Parse colors from Taskwarrior config
|
||||||
|
- **Reusable Styles**: Create style functions, not inline styles
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- **Table-Driven Tests**: Use struct slices for test cases
|
||||||
|
- **Test Setup**: Create helper functions like `TaskWarriorTestSetup()`
|
||||||
|
- **Temp Directories**: Use `t.TempDir()` for isolated test environments
|
||||||
|
- **Prep Functions**: Include `prep func()` in test cases for setup
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- **TODO Comments**: Mark future improvements with `// TODO: description`
|
||||||
|
- **Package Comments**: Document package purpose at the top of main files
|
||||||
|
- **Exported Functions**: Document exported functions, types, and methods
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Page Navigation
|
||||||
|
- Pages pushed onto stack via `common.PushPage()`
|
||||||
|
- Pop pages with `common.PopPage()`
|
||||||
|
- Check for subpages with `common.HasSubpages()`
|
||||||
|
|
||||||
|
### Task Operations
|
||||||
|
```go
|
||||||
|
// Get tasks for a report
|
||||||
|
tasks := ts.GetTasks(report, "filter", "args")
|
||||||
|
|
||||||
|
// Import/create task
|
||||||
|
ts.ImportTask(&task)
|
||||||
|
|
||||||
|
// Mark task done
|
||||||
|
ts.SetTaskDone(&task)
|
||||||
|
|
||||||
|
// Start/stop task
|
||||||
|
ts.StartTask(&task)
|
||||||
|
ts.StopTask(&task)
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON Handling
|
||||||
|
- Custom Marshal/Unmarshal for Task struct to handle UDAs (User Defined Attributes)
|
||||||
|
- Use `json.RawMessage` for flexible field handling
|
||||||
|
|
||||||
|
## Key Files to Reference
|
||||||
|
|
||||||
|
- `common/component.go` - Component interface definition
|
||||||
|
- `common/common.go` - Shared state container
|
||||||
|
- `taskwarrior/taskwarrior.go` - TaskWarrior interface and implementation
|
||||||
|
- `pages/main.go` - Main page router pattern
|
||||||
|
- `taskwarrior/models.go` - Data model examples
|
||||||
|
|
||||||
|
## Development Notes
|
||||||
|
|
||||||
|
- **Logging**: Application logs to `/tmp/tasksquire.log`
|
||||||
|
- **Virtual Tags**: Filter out Taskwarrior virtual tags (see `virtualTags` map)
|
||||||
|
- **Color Parsing**: Custom color parsing from Taskwarrior config format
|
||||||
|
- **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`)
|
||||||
71
GEMINI.md
Normal file
71
GEMINI.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Tasksquire
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Tasksquire is a Terminal User Interface (TUI) for [Taskwarrior](https://taskwarrior.org/), built using Go and the [Charm](https://charm.sh/) ecosystem (Bubble Tea, Lip Gloss, Huh). It provides a visual and interactive way to manage your tasks, contexts, and reports directly from the terminal.
|
||||||
|
|
||||||
|
The application functions as a wrapper around the `task` command-line tool, parsing its output (JSON, config) and executing commands to read and modify task data.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The project follows the standard [Bubble Tea](https://github.com/charmbracelet/bubbletea) Model-View-Update (MVU) architecture.
|
||||||
|
|
||||||
|
### Key Directories & Files
|
||||||
|
|
||||||
|
* **`main.go`**: The entry point of the application. It initializes the `TaskSquire` wrapper, sets up logging, and starts the Bubble Tea program with the `MainPage`.
|
||||||
|
* **`taskwarrior/`**: Contains the logic for interacting with the Taskwarrior CLI.
|
||||||
|
* `taskwarrior.go`: The core wrapper (`TaskSquire` struct) that executes `task` commands (`export`, `add`, `modify`, etc.) and parses results.
|
||||||
|
* `models.go`: Defines the Go structs matching Taskwarrior's data model (Tasks, Reports, Config).
|
||||||
|
* **`pages/`**: Contains the different views of the application.
|
||||||
|
* `main.go`: The top-level component (`MainPage`) that manages routing/switching between different pages.
|
||||||
|
* `report.go`: Displays lists of tasks (Taskwarrior reports).
|
||||||
|
* `taskEditor.go`: UI for creating or editing tasks.
|
||||||
|
* **`common/`**: Shared utilities, global state, and data structures used across the application.
|
||||||
|
* **`components/`**: Reusable UI components (e.g., inputs, tables).
|
||||||
|
* **`timewarrior/`**: Contains logic for integration with Timewarrior (likely in progress or planned).
|
||||||
|
|
||||||
|
## Building and Running
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
* **Go**: Version 1.22 or higher.
|
||||||
|
* **Taskwarrior**: The `task` binary must be installed and available in your system's `PATH`.
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
To run the application directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
To build a binary:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o tasksquire main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nix Support
|
||||||
|
|
||||||
|
This project includes a `flake.nix` for users of the Nix package manager. You can enter a development shell with all dependencies (Go, tools) by running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix develop
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Tasksquire respects your existing Taskwarrior configuration (`.taskrc`). It looks for the configuration file in the following order:
|
||||||
|
|
||||||
|
1. `TASKRC` environment variable.
|
||||||
|
2. `$HOME/.taskrc`
|
||||||
|
3. `$HOME/.config/task/taskrc`
|
||||||
|
|
||||||
|
Logging is written to `/tmp/tasksquire.log`.
|
||||||
|
|
||||||
|
## Development Conventions
|
||||||
|
|
||||||
|
* **UI Framework**: Uses [Bubble Tea](https://github.com/charmbracelet/bubbletea) for the TUI loop.
|
||||||
|
* **Styling**: Uses [Lip Gloss](https://github.com/charmbracelet/lipgloss) for terminal styling.
|
||||||
|
* **Forms**: Uses [Huh](https://github.com/charmbracelet/huh) for form inputs.
|
||||||
|
* **Logging**: Uses `log/slog` for structured logging.
|
||||||
20
README.md
Normal file
20
README.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# TODO
|
||||||
|
- [>] Add default tags
|
||||||
|
- Default tags should be defined in the config and always displayed in the tag picker
|
||||||
|
- [ ] Add project manager
|
||||||
|
- [ ] Add projects that are always displayed in the project picker
|
||||||
|
- Saved in config
|
||||||
|
- [ ] Remove/archive projects
|
||||||
|
- [ ] Integrate timewarrior
|
||||||
|
- [ ] Add default timetracking items when addind projects
|
||||||
|
- [ ] Create interface for timewarrior input
|
||||||
|
- [ ] Create daily/weekly reports for HRM input
|
||||||
|
- Combine by project
|
||||||
|
- Combine by task names
|
||||||
|
- Combine by tags
|
||||||
|
- [ ] Add tag manager
|
||||||
|
- [ ] Edit default tags
|
||||||
|
- [ ] Update to bubbletea 2.0
|
||||||
|
|
||||||
|
# Done
|
||||||
|
- [x] Use JJ
|
||||||
315
common/styles.go
315
common/styles.go
@@ -1,315 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
// "github.com/charmbracelet/bubbles/table"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
|
|
||||||
"tasksquire/taskwarrior"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TableStyle struct {
|
|
||||||
Header lipgloss.Style
|
|
||||||
Cell lipgloss.Style
|
|
||||||
Selected lipgloss.Style
|
|
||||||
}
|
|
||||||
|
|
||||||
type Styles struct {
|
|
||||||
Base lipgloss.Style
|
|
||||||
|
|
||||||
Form *huh.Theme
|
|
||||||
TableStyle TableStyle
|
|
||||||
|
|
||||||
ColumnFocused lipgloss.Style
|
|
||||||
ColumnBlurred lipgloss.Style
|
|
||||||
ColumnInsert lipgloss.Style
|
|
||||||
|
|
||||||
// TODO: make color config completely dynamic to account for keyword., project., tag. and uda. colors
|
|
||||||
Active lipgloss.Style
|
|
||||||
Alternate lipgloss.Style
|
|
||||||
Blocked lipgloss.Style
|
|
||||||
Blocking lipgloss.Style
|
|
||||||
BurndownDone lipgloss.Style
|
|
||||||
BurndownPending lipgloss.Style
|
|
||||||
BurndownStarted lipgloss.Style
|
|
||||||
CalendarDue lipgloss.Style
|
|
||||||
CalendarDueToday lipgloss.Style
|
|
||||||
CalendarHoliday lipgloss.Style
|
|
||||||
CalendarOverdue lipgloss.Style
|
|
||||||
CalendarScheduled lipgloss.Style
|
|
||||||
CalendarToday lipgloss.Style
|
|
||||||
CalendarWeekend lipgloss.Style
|
|
||||||
CalendarWeeknumber lipgloss.Style
|
|
||||||
Completed lipgloss.Style
|
|
||||||
Debug lipgloss.Style
|
|
||||||
Deleted lipgloss.Style
|
|
||||||
Due lipgloss.Style
|
|
||||||
DueToday lipgloss.Style
|
|
||||||
Error lipgloss.Style
|
|
||||||
Footnote lipgloss.Style
|
|
||||||
Header lipgloss.Style
|
|
||||||
HistoryAdd lipgloss.Style
|
|
||||||
HistoryDelete lipgloss.Style
|
|
||||||
HistoryDone lipgloss.Style
|
|
||||||
Label lipgloss.Style
|
|
||||||
LabelSort lipgloss.Style
|
|
||||||
Overdue lipgloss.Style
|
|
||||||
ProjectNone lipgloss.Style
|
|
||||||
Recurring lipgloss.Style
|
|
||||||
Scheduled lipgloss.Style
|
|
||||||
SummaryBackground lipgloss.Style
|
|
||||||
SummaryBar lipgloss.Style
|
|
||||||
SyncAdded lipgloss.Style
|
|
||||||
SyncChanged lipgloss.Style
|
|
||||||
SyncRejected lipgloss.Style
|
|
||||||
TagNext lipgloss.Style
|
|
||||||
TagNone lipgloss.Style
|
|
||||||
Tagged lipgloss.Style
|
|
||||||
UdaPriorityH lipgloss.Style
|
|
||||||
UdaPriorityL lipgloss.Style
|
|
||||||
UdaPriorityM lipgloss.Style
|
|
||||||
UndoAfter lipgloss.Style
|
|
||||||
UndoBefore lipgloss.Style
|
|
||||||
Until lipgloss.Style
|
|
||||||
Warning lipgloss.Style
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStyles(config *taskwarrior.TWConfig) *Styles {
|
|
||||||
styles := parseColors(config.GetConfig())
|
|
||||||
styles.Base = lipgloss.NewStyle()
|
|
||||||
|
|
||||||
styles.TableStyle = TableStyle{
|
|
||||||
// Header: lipgloss.NewStyle().Bold(true).Padding(0, 1).BorderBottom(true),
|
|
||||||
Cell: lipgloss.NewStyle().Padding(0, 1, 0, 0),
|
|
||||||
Header: lipgloss.NewStyle().Bold(true).Padding(0, 1, 0, 0).Underline(true),
|
|
||||||
Selected: lipgloss.NewStyle().Bold(true).Reverse(true),
|
|
||||||
}
|
|
||||||
|
|
||||||
formTheme := huh.ThemeBase()
|
|
||||||
formTheme.Focused.Title = formTheme.Focused.Title.Bold(true)
|
|
||||||
formTheme.Focused.SelectSelector = formTheme.Focused.SelectSelector.SetString("> ")
|
|
||||||
formTheme.Focused.SelectedOption = formTheme.Focused.SelectedOption.Bold(true)
|
|
||||||
formTheme.Focused.MultiSelectSelector = formTheme.Focused.MultiSelectSelector.SetString("> ")
|
|
||||||
formTheme.Focused.SelectedPrefix = formTheme.Focused.SelectedPrefix.SetString("✓ ")
|
|
||||||
formTheme.Focused.UnselectedPrefix = formTheme.Focused.SelectedPrefix.SetString("• ")
|
|
||||||
formTheme.Blurred.SelectSelector = formTheme.Blurred.SelectSelector.SetString(" ")
|
|
||||||
formTheme.Blurred.SelectedOption = formTheme.Blurred.SelectedOption.Bold(true)
|
|
||||||
formTheme.Blurred.MultiSelectSelector = formTheme.Blurred.MultiSelectSelector.SetString(" ")
|
|
||||||
formTheme.Blurred.SelectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("✓ ")
|
|
||||||
formTheme.Blurred.UnselectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("• ")
|
|
||||||
|
|
||||||
styles.Form = formTheme
|
|
||||||
|
|
||||||
styles.ColumnFocused = lipgloss.NewStyle().Width(30).Height(50).Border(lipgloss.DoubleBorder(), true)
|
|
||||||
styles.ColumnBlurred = lipgloss.NewStyle().Width(30).Height(50).Border(lipgloss.HiddenBorder(), true)
|
|
||||||
styles.ColumnInsert = lipgloss.NewStyle().Width(30).Height(50).Border(lipgloss.DoubleBorder(), true).BorderForeground(styles.Active.GetForeground())
|
|
||||||
|
|
||||||
return styles
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseColors(config map[string]string) *Styles {
|
|
||||||
styles := Styles{}
|
|
||||||
|
|
||||||
for key, value := range config {
|
|
||||||
if strings.HasPrefix(key, "color.") {
|
|
||||||
_, colorValue, _ := strings.Cut(key, ".")
|
|
||||||
switch colorValue {
|
|
||||||
case "active":
|
|
||||||
styles.Active = parseColorString(value)
|
|
||||||
case "alternate":
|
|
||||||
styles.Alternate = parseColorString(value)
|
|
||||||
case "blocked":
|
|
||||||
styles.Blocked = parseColorString(value)
|
|
||||||
case "blocking":
|
|
||||||
styles.Blocking = parseColorString(value)
|
|
||||||
case "burndown.done":
|
|
||||||
styles.BurndownDone = parseColorString(value)
|
|
||||||
case "burndown.pending":
|
|
||||||
styles.BurndownPending = parseColorString(value)
|
|
||||||
case "burndown.started":
|
|
||||||
styles.BurndownStarted = parseColorString(value)
|
|
||||||
case "calendar.due":
|
|
||||||
styles.CalendarDue = parseColorString(value)
|
|
||||||
case "calendar.due.today":
|
|
||||||
styles.CalendarDueToday = parseColorString(value)
|
|
||||||
case "calendar.holiday":
|
|
||||||
styles.CalendarHoliday = parseColorString(value)
|
|
||||||
case "calendar.overdue":
|
|
||||||
styles.CalendarOverdue = parseColorString(value)
|
|
||||||
case "calendar.scheduled":
|
|
||||||
styles.CalendarScheduled = parseColorString(value)
|
|
||||||
case "calendar.today":
|
|
||||||
styles.CalendarToday = parseColorString(value)
|
|
||||||
case "calendar.weekend":
|
|
||||||
styles.CalendarWeekend = parseColorString(value)
|
|
||||||
case "calendar.weeknumber":
|
|
||||||
styles.CalendarWeeknumber = parseColorString(value)
|
|
||||||
case "completed":
|
|
||||||
styles.Completed = parseColorString(value)
|
|
||||||
case "debug":
|
|
||||||
styles.Debug = parseColorString(value)
|
|
||||||
case "deleted":
|
|
||||||
styles.Deleted = parseColorString(value)
|
|
||||||
case "due":
|
|
||||||
styles.Due = parseColorString(value)
|
|
||||||
case "due.today":
|
|
||||||
styles.DueToday = parseColorString(value)
|
|
||||||
case "error":
|
|
||||||
styles.Error = parseColorString(value)
|
|
||||||
case "footnote":
|
|
||||||
styles.Footnote = parseColorString(value)
|
|
||||||
case "header":
|
|
||||||
styles.Header = parseColorString(value)
|
|
||||||
case "history.add":
|
|
||||||
styles.HistoryAdd = parseColorString(value)
|
|
||||||
case "history.delete":
|
|
||||||
styles.HistoryDelete = parseColorString(value)
|
|
||||||
case "history.done":
|
|
||||||
styles.HistoryDone = parseColorString(value)
|
|
||||||
case "label":
|
|
||||||
styles.Label = parseColorString(value)
|
|
||||||
case "label.sort":
|
|
||||||
styles.LabelSort = parseColorString(value)
|
|
||||||
case "overdue":
|
|
||||||
styles.Overdue = parseColorString(value)
|
|
||||||
case "project.none":
|
|
||||||
styles.ProjectNone = parseColorString(value)
|
|
||||||
case "recurring":
|
|
||||||
styles.Recurring = parseColorString(value)
|
|
||||||
case "scheduled":
|
|
||||||
styles.Scheduled = parseColorString(value)
|
|
||||||
case "summary.background":
|
|
||||||
styles.SummaryBackground = parseColorString(value)
|
|
||||||
case "summary.bar":
|
|
||||||
styles.SummaryBar = parseColorString(value)
|
|
||||||
case "sync.added":
|
|
||||||
styles.SyncAdded = parseColorString(value)
|
|
||||||
case "sync.changed":
|
|
||||||
styles.SyncChanged = parseColorString(value)
|
|
||||||
case "sync.rejected":
|
|
||||||
styles.SyncRejected = parseColorString(value)
|
|
||||||
case "tag.next":
|
|
||||||
styles.TagNext = parseColorString(value)
|
|
||||||
case "tag.none":
|
|
||||||
styles.TagNone = parseColorString(value)
|
|
||||||
case "tagged":
|
|
||||||
styles.Tagged = parseColorString(value)
|
|
||||||
case "uda.priority.H":
|
|
||||||
styles.UdaPriorityH = parseColorString(value)
|
|
||||||
case "uda.priority.L":
|
|
||||||
styles.UdaPriorityL = parseColorString(value)
|
|
||||||
case "uda.priority.M":
|
|
||||||
styles.UdaPriorityM = parseColorString(value)
|
|
||||||
case "undo.after":
|
|
||||||
styles.UndoAfter = parseColorString(value)
|
|
||||||
case "undo.before":
|
|
||||||
styles.UndoBefore = parseColorString(value)
|
|
||||||
case "until":
|
|
||||||
styles.Until = parseColorString(value)
|
|
||||||
case "warning":
|
|
||||||
styles.Warning = parseColorString(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &styles
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseColorString(color string) lipgloss.Style {
|
|
||||||
style := lipgloss.NewStyle()
|
|
||||||
if color == "" {
|
|
||||||
return style
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(color, "on") {
|
|
||||||
fgbg := strings.Split(color, "on")
|
|
||||||
fg := strings.TrimSpace(fgbg[0])
|
|
||||||
bg := strings.TrimSpace(fgbg[1])
|
|
||||||
if fg != "" {
|
|
||||||
style = style.Foreground(parseColor(fg))
|
|
||||||
}
|
|
||||||
if bg != "" {
|
|
||||||
style = style.Background(parseColor(bg))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
style = style.Foreground(parseColor(strings.TrimSpace(color)))
|
|
||||||
}
|
|
||||||
|
|
||||||
return style
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseColor(color string) lipgloss.Color {
|
|
||||||
if strings.HasPrefix(color, "rgb") {
|
|
||||||
return lipgloss.Color(convertRgbToAnsi(strings.TrimPrefix(color, "rgb")))
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(color, "color") {
|
|
||||||
return lipgloss.Color(strings.TrimPrefix(color, "color"))
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(color, "gray") {
|
|
||||||
gray, err := strconv.Atoi(strings.TrimPrefix(color, "gray"))
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Invalid gray color format")
|
|
||||||
return lipgloss.Color("0")
|
|
||||||
}
|
|
||||||
return lipgloss.Color(strconv.Itoa(gray + 232))
|
|
||||||
}
|
|
||||||
if ansi, okcolor := colorStrings[color]; okcolor {
|
|
||||||
return lipgloss.Color(strconv.Itoa(ansi))
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Error("Invalid color format")
|
|
||||||
return lipgloss.Color("0")
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertRgbToAnsi(rgbString string) string {
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if len(rgbString) != 3 {
|
|
||||||
slog.Error("Invalid RGB color format")
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
r, err := strconv.Atoi(string(rgbString[0]))
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Invalid value for R")
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
g, err := strconv.Atoi(string(rgbString[1]))
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Invalid value for G")
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
b, err := strconv.Atoi(string(rgbString[2]))
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Invalid value for B")
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return strconv.Itoa(16 + (36 * r) + (6 * g) + b)
|
|
||||||
}
|
|
||||||
|
|
||||||
var colorStrings = map[string]int{
|
|
||||||
"black": 0,
|
|
||||||
"red": 1,
|
|
||||||
"green": 2,
|
|
||||||
"yellow": 3,
|
|
||||||
"blue": 4,
|
|
||||||
"magenta": 5,
|
|
||||||
"cyan": 6,
|
|
||||||
"white": 7,
|
|
||||||
"bright black": 8,
|
|
||||||
"bright red": 9,
|
|
||||||
"bright green": 10,
|
|
||||||
"bright yellow": 11,
|
|
||||||
"bright blue": 12,
|
|
||||||
"bright magenta": 13,
|
|
||||||
"bright cyan": 14,
|
|
||||||
"bright white": 15,
|
|
||||||
}
|
|
||||||
@@ -1,593 +0,0 @@
|
|||||||
package table
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
"github.com/mattn/go-runewidth"
|
|
||||||
|
|
||||||
"tasksquire/common"
|
|
||||||
"tasksquire/taskwarrior"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Model defines a state for the table widget.
|
|
||||||
type Model struct {
|
|
||||||
common *common.Common
|
|
||||||
KeyMap KeyMap
|
|
||||||
|
|
||||||
cols []Column
|
|
||||||
rows taskwarrior.Tasks
|
|
||||||
rowStyles []lipgloss.Style
|
|
||||||
cursor int
|
|
||||||
focus bool
|
|
||||||
styles common.TableStyle
|
|
||||||
styleFunc StyleFunc
|
|
||||||
|
|
||||||
viewport viewport.Model
|
|
||||||
start int
|
|
||||||
end int
|
|
||||||
}
|
|
||||||
|
|
||||||
// Row represents one line in the table.
|
|
||||||
type Row *taskwarrior.Task
|
|
||||||
|
|
||||||
// Column defines the table structure.
|
|
||||||
type Column struct {
|
|
||||||
Title string
|
|
||||||
Name string
|
|
||||||
Width int
|
|
||||||
MaxWidth int
|
|
||||||
ContentWidth int
|
|
||||||
}
|
|
||||||
|
|
||||||
// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which
|
|
||||||
// is used to render the menu.
|
|
||||||
type KeyMap struct {
|
|
||||||
LineUp key.Binding
|
|
||||||
LineDown key.Binding
|
|
||||||
PageUp key.Binding
|
|
||||||
PageDown key.Binding
|
|
||||||
HalfPageUp key.Binding
|
|
||||||
HalfPageDown key.Binding
|
|
||||||
GotoTop key.Binding
|
|
||||||
GotoBottom key.Binding
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShortHelp implements the KeyMap interface.
|
|
||||||
func (km KeyMap) ShortHelp() []key.Binding {
|
|
||||||
return []key.Binding{km.LineUp, km.LineDown}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FullHelp implements the KeyMap interface.
|
|
||||||
func (km KeyMap) FullHelp() [][]key.Binding {
|
|
||||||
return [][]key.Binding{
|
|
||||||
{km.LineUp, km.LineDown, km.GotoTop, km.GotoBottom},
|
|
||||||
{km.PageUp, km.PageDown, km.HalfPageUp, km.HalfPageDown},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultKeyMap returns a default set of keybindings.
|
|
||||||
func DefaultKeyMap() KeyMap {
|
|
||||||
const spacebar = " "
|
|
||||||
return KeyMap{
|
|
||||||
LineUp: key.NewBinding(
|
|
||||||
key.WithKeys("up", "k"),
|
|
||||||
key.WithHelp("↑/k", "up"),
|
|
||||||
),
|
|
||||||
LineDown: key.NewBinding(
|
|
||||||
key.WithKeys("down", "j"),
|
|
||||||
key.WithHelp("↓/j", "down"),
|
|
||||||
),
|
|
||||||
PageUp: key.NewBinding(
|
|
||||||
key.WithKeys("b", "pgup"),
|
|
||||||
key.WithHelp("b/pgup", "page up"),
|
|
||||||
),
|
|
||||||
PageDown: key.NewBinding(
|
|
||||||
key.WithKeys("f", "pgdown", spacebar),
|
|
||||||
key.WithHelp("f/pgdn", "page down"),
|
|
||||||
),
|
|
||||||
HalfPageUp: key.NewBinding(
|
|
||||||
key.WithKeys("u", "ctrl+u"),
|
|
||||||
key.WithHelp("u", "½ page up"),
|
|
||||||
),
|
|
||||||
HalfPageDown: key.NewBinding(
|
|
||||||
key.WithKeys("d", "ctrl+d"),
|
|
||||||
key.WithHelp("d", "½ page down"),
|
|
||||||
),
|
|
||||||
GotoTop: key.NewBinding(
|
|
||||||
key.WithKeys("home", "g"),
|
|
||||||
key.WithHelp("g/home", "go to start"),
|
|
||||||
),
|
|
||||||
GotoBottom: key.NewBinding(
|
|
||||||
key.WithKeys("end", "G"),
|
|
||||||
key.WithHelp("G/end", "go to end"),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetStyles sets the table styles.
|
|
||||||
func (m *Model) SetStyles(s common.TableStyle) {
|
|
||||||
m.styles = s
|
|
||||||
m.UpdateViewport()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Option is used to set options in New. For example:
|
|
||||||
//
|
|
||||||
// table := New(WithColumns([]Column{{Title: "ID", Width: 10}}))
|
|
||||||
type Option func(*Model)
|
|
||||||
|
|
||||||
// New creates a new model for the table widget.
|
|
||||||
func New(com *common.Common, opts ...Option) Model {
|
|
||||||
m := Model{
|
|
||||||
common: com,
|
|
||||||
cursor: 0,
|
|
||||||
viewport: viewport.New(0, 20),
|
|
||||||
|
|
||||||
KeyMap: DefaultKeyMap(),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(&m)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.cols = m.parseColumns(m.cols)
|
|
||||||
m.rowStyles = m.parseRowStyles(m.rows)
|
|
||||||
|
|
||||||
m.UpdateViewport()
|
|
||||||
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: dynamically read rule.precedence.color
|
|
||||||
func (m *Model) parseRowStyles(rows taskwarrior.Tasks) []lipgloss.Style {
|
|
||||||
styles := make([]lipgloss.Style, len(rows))
|
|
||||||
if len(rows) == 0 {
|
|
||||||
return styles
|
|
||||||
}
|
|
||||||
for i, task := range rows {
|
|
||||||
if task.Status == "deleted" {
|
|
||||||
styles[i] = m.common.Styles.Deleted.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if task.Status == "completed" {
|
|
||||||
styles[i] = m.common.Styles.Completed.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if task.Status == "pending" && task.Start != "" {
|
|
||||||
styles[i] = m.common.Styles.Active.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// TODO: implement keyword
|
|
||||||
// TODO: implement tag
|
|
||||||
// TODO: implement project
|
|
||||||
if !task.GetDate("due").IsZero() && task.GetDate("due").Before(time.Now()) {
|
|
||||||
styles[i] = m.common.Styles.Overdue.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if task.Scheduled != "" {
|
|
||||||
styles[i] = m.common.Styles.Scheduled.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !task.GetDate("due").IsZero() && task.GetDate("due").Truncate(24*time.Hour).Equal(time.Now().Truncate(24*time.Hour)) {
|
|
||||||
styles[i] = m.common.Styles.DueToday.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if task.Due != "" {
|
|
||||||
styles[i] = m.common.Styles.Due.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(task.Depends) > 0 {
|
|
||||||
styles[i] = m.common.Styles.Blocked.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// TODO implement blocking
|
|
||||||
if task.Recur != "" {
|
|
||||||
styles[i] = m.common.Styles.Recurring.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(task.Tags) > 0 {
|
|
||||||
styles[i] = m.common.Styles.Tagged.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
taskIteration:
|
|
||||||
for _, tag := range task.Tags {
|
|
||||||
if tag == "next" {
|
|
||||||
styles[i] = m.common.Styles.TagNext.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
break taskIteration
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// TODO implement uda
|
|
||||||
styles[i] = m.common.Styles.Base.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
|
||||||
}
|
|
||||||
return styles
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) parseColumns(cols []Column) []Column {
|
|
||||||
if len(cols) == 0 {
|
|
||||||
return cols
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, col := range cols {
|
|
||||||
for _, task := range m.rows {
|
|
||||||
col.ContentWidth = max(col.ContentWidth, lipgloss.Width(task.GetString(col.Name)))
|
|
||||||
}
|
|
||||||
cols[i] = col
|
|
||||||
}
|
|
||||||
|
|
||||||
combinedSize := 0
|
|
||||||
nonZeroWidths := 0
|
|
||||||
descIndex := -1
|
|
||||||
for i, col := range cols {
|
|
||||||
if col.ContentWidth > 0 {
|
|
||||||
col.Width = max(col.ContentWidth, lipgloss.Width(col.Title))
|
|
||||||
nonZeroWidths++
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(col.Name, "description") {
|
|
||||||
combinedSize += col.Width
|
|
||||||
} else {
|
|
||||||
descIndex = i
|
|
||||||
}
|
|
||||||
|
|
||||||
cols[i] = col
|
|
||||||
}
|
|
||||||
|
|
||||||
if descIndex >= 0 {
|
|
||||||
cols[descIndex].Width = m.Width() - combinedSize - nonZeroWidths
|
|
||||||
}
|
|
||||||
|
|
||||||
return cols
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithColumns sets the table columns (headers).
|
|
||||||
func WithColumns(cols []Column) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
m.cols = cols
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithReport(report *taskwarrior.Report) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
columns := make([]Column, len(report.Columns))
|
|
||||||
for i, col := range report.Columns {
|
|
||||||
columns[i] = Column{
|
|
||||||
Title: report.Labels[i],
|
|
||||||
Name: col,
|
|
||||||
Width: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.cols = columns
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithRows sets the table rows (data).
|
|
||||||
func WithRows(rows taskwarrior.Tasks) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
m.rows = rows
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithRows sets the table rows (data).
|
|
||||||
func WithTasks(rows taskwarrior.Tasks) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
m.rows = rows
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithHeight sets the height of the table.
|
|
||||||
func WithHeight(h int) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
m.viewport.Height = h - lipgloss.Height(m.headersView())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithWidth sets the width of the table.
|
|
||||||
func WithWidth(w int) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
m.viewport.Width = w
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithFocused sets the focus state of the table.
|
|
||||||
func WithFocused(f bool) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
m.focus = f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithStyles sets the table styles.
|
|
||||||
func WithStyles(s common.TableStyle) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
m.styles = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithStyleFunc sets the table style func which can determine a cell style per column, row, and selected state.
|
|
||||||
func WithStyleFunc(f StyleFunc) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
m.styleFunc = f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithKeyMap sets the key map.
|
|
||||||
func WithKeyMap(km KeyMap) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
m.KeyMap = km
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update is the Bubble Tea update loop.
|
|
||||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
|
||||||
if !m.focus {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.KeyMsg:
|
|
||||||
switch {
|
|
||||||
case key.Matches(msg, m.KeyMap.LineUp):
|
|
||||||
m.MoveUp(1)
|
|
||||||
case key.Matches(msg, m.KeyMap.LineDown):
|
|
||||||
m.MoveDown(1)
|
|
||||||
case key.Matches(msg, m.KeyMap.PageUp):
|
|
||||||
m.MoveUp(m.viewport.Height)
|
|
||||||
case key.Matches(msg, m.KeyMap.PageDown):
|
|
||||||
m.MoveDown(m.viewport.Height)
|
|
||||||
case key.Matches(msg, m.KeyMap.HalfPageUp):
|
|
||||||
m.MoveUp(m.viewport.Height / 2)
|
|
||||||
case key.Matches(msg, m.KeyMap.HalfPageDown):
|
|
||||||
m.MoveDown(m.viewport.Height / 2)
|
|
||||||
case key.Matches(msg, m.KeyMap.LineDown):
|
|
||||||
m.MoveDown(1)
|
|
||||||
case key.Matches(msg, m.KeyMap.GotoTop):
|
|
||||||
m.GotoTop()
|
|
||||||
case key.Matches(msg, m.KeyMap.GotoBottom):
|
|
||||||
m.GotoBottom()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Focused returns the focus state of the table.
|
|
||||||
func (m Model) Focused() bool {
|
|
||||||
return m.focus
|
|
||||||
}
|
|
||||||
|
|
||||||
// Focus focuses the table, allowing the user to move around the rows and
|
|
||||||
// interact.
|
|
||||||
func (m *Model) Focus() {
|
|
||||||
m.focus = true
|
|
||||||
m.UpdateViewport()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blur blurs the table, preventing selection or movement.
|
|
||||||
func (m *Model) Blur() {
|
|
||||||
m.focus = false
|
|
||||||
m.UpdateViewport()
|
|
||||||
}
|
|
||||||
|
|
||||||
// View renders the component.
|
|
||||||
func (m Model) View() string {
|
|
||||||
return m.headersView() + "\n" + m.viewport.View()
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateViewport updates the list content based on the previously defined
|
|
||||||
// columns and rows.
|
|
||||||
func (m *Model) UpdateViewport() {
|
|
||||||
renderedRows := make([]string, 0, len(m.rows))
|
|
||||||
|
|
||||||
// Render only rows from: m.cursor-m.viewport.Height to: m.cursor+m.viewport.Height
|
|
||||||
// Constant runtime, independent of number of rows in a table.
|
|
||||||
// Limits the number of renderedRows to a maximum of 2*m.viewport.Height
|
|
||||||
if m.cursor >= 0 {
|
|
||||||
m.start = clamp(m.cursor-m.viewport.Height, 0, m.cursor)
|
|
||||||
} else {
|
|
||||||
m.start = 0
|
|
||||||
}
|
|
||||||
m.end = clamp(m.cursor+m.viewport.Height, m.cursor, len(m.rows))
|
|
||||||
for i := m.start; i < m.end; i++ {
|
|
||||||
renderedRows = append(renderedRows, m.renderRow(i))
|
|
||||||
}
|
|
||||||
|
|
||||||
m.viewport.SetContent(
|
|
||||||
lipgloss.JoinVertical(lipgloss.Left, renderedRows...),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SelectedRow returns the selected row.
|
|
||||||
// You can cast it to your own implementation.
|
|
||||||
func (m Model) SelectedRow() Row {
|
|
||||||
if m.cursor < 0 || m.cursor >= len(m.rows) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return m.rows[m.cursor]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rows returns the current rows.
|
|
||||||
func (m Model) Rows() taskwarrior.Tasks {
|
|
||||||
return m.rows
|
|
||||||
}
|
|
||||||
|
|
||||||
// Columns returns the current columns.
|
|
||||||
func (m Model) Columns() []Column {
|
|
||||||
return m.cols
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetRows sets a new rows state.
|
|
||||||
func (m *Model) SetRows(r taskwarrior.Tasks) {
|
|
||||||
m.rows = r
|
|
||||||
m.UpdateViewport()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetColumns sets a new columns state.
|
|
||||||
func (m *Model) SetColumns(c []Column) {
|
|
||||||
m.cols = c
|
|
||||||
m.UpdateViewport()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetWidth sets the width of the viewport of the table.
|
|
||||||
func (m *Model) SetWidth(w int) {
|
|
||||||
m.viewport.Width = w
|
|
||||||
m.UpdateViewport()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetHeight sets the height of the viewport of the table.
|
|
||||||
func (m *Model) SetHeight(h int) {
|
|
||||||
m.viewport.Height = h - lipgloss.Height(m.headersView())
|
|
||||||
m.UpdateViewport()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Height returns the viewport height of the table.
|
|
||||||
func (m Model) Height() int {
|
|
||||||
return m.viewport.Height
|
|
||||||
}
|
|
||||||
|
|
||||||
// Width returns the viewport width of the table.
|
|
||||||
func (m Model) Width() int {
|
|
||||||
return m.viewport.Width
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cursor returns the index of the selected row.
|
|
||||||
func (m Model) Cursor() int {
|
|
||||||
return m.cursor
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetCursor sets the cursor position in the table.
|
|
||||||
func (m *Model) SetCursor(n int) {
|
|
||||||
m.cursor = clamp(n, 0, len(m.rows)-1)
|
|
||||||
m.UpdateViewport()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MoveUp moves the selection up by any number of rows.
|
|
||||||
// It can not go above the first row.
|
|
||||||
func (m *Model) MoveUp(n int) {
|
|
||||||
m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1)
|
|
||||||
switch {
|
|
||||||
case m.start == 0:
|
|
||||||
m.viewport.SetYOffset(clamp(m.viewport.YOffset, 0, m.cursor))
|
|
||||||
case m.start < m.viewport.Height:
|
|
||||||
m.viewport.YOffset = (clamp(clamp(m.viewport.YOffset+n, 0, m.cursor), 0, m.viewport.Height))
|
|
||||||
case m.viewport.YOffset >= 1:
|
|
||||||
m.viewport.YOffset = clamp(m.viewport.YOffset+n, 1, m.viewport.Height)
|
|
||||||
}
|
|
||||||
m.UpdateViewport()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MoveDown moves the selection down by any number of rows.
|
|
||||||
// It can not go below the last row.
|
|
||||||
func (m *Model) MoveDown(n int) {
|
|
||||||
m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1)
|
|
||||||
m.UpdateViewport()
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case m.end == len(m.rows) && m.viewport.YOffset > 0:
|
|
||||||
m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.viewport.Height))
|
|
||||||
case m.cursor > (m.end-m.start)/2 && m.viewport.YOffset > 0:
|
|
||||||
m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.cursor))
|
|
||||||
case m.viewport.YOffset > 1:
|
|
||||||
case m.cursor > m.viewport.YOffset+m.viewport.Height-1:
|
|
||||||
m.viewport.SetYOffset(clamp(m.viewport.YOffset+1, 0, 1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GotoTop moves the selection to the first row.
|
|
||||||
func (m *Model) GotoTop() {
|
|
||||||
m.MoveUp(m.cursor)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GotoBottom moves the selection to the last row.
|
|
||||||
func (m *Model) GotoBottom() {
|
|
||||||
m.MoveDown(len(m.rows))
|
|
||||||
}
|
|
||||||
|
|
||||||
// FromValues create the table rows from a simple string. It uses `\n` by
|
|
||||||
// default for getting all the rows and the given separator for the fields on
|
|
||||||
// each row.
|
|
||||||
// func (m *Model) FromValues(value, separator string) {
|
|
||||||
// rows := []Row{}
|
|
||||||
// for _, line := range strings.Split(value, "\n") {
|
|
||||||
// r := Row{}
|
|
||||||
// for _, field := range strings.Split(line, separator) {
|
|
||||||
// r = append(r, field)
|
|
||||||
// }
|
|
||||||
// rows = append(rows, r)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// m.SetRows(rows)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// StyleFunc is a function that can be used to customize the style of a table cell based on the row and column index.
|
|
||||||
type StyleFunc func(row, col int, value string) lipgloss.Style
|
|
||||||
|
|
||||||
func (m Model) headersView() string {
|
|
||||||
var s = make([]string, 0, len(m.cols))
|
|
||||||
for _, col := range m.cols {
|
|
||||||
if col.Width <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true)
|
|
||||||
renderedCell := style.Render(runewidth.Truncate(col.Title, col.Width, "…"))
|
|
||||||
s = append(s, m.styles.Header.Render(renderedCell))
|
|
||||||
}
|
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Left, s...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) renderRow(r int) string {
|
|
||||||
var s = make([]string, 0, len(m.cols))
|
|
||||||
for i, col := range m.cols {
|
|
||||||
// for i, task := range m.rows[r] {
|
|
||||||
if m.cols[i].Width <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var cellStyle lipgloss.Style
|
|
||||||
// if m.styleFunc != nil {
|
|
||||||
// cellStyle = m.styleFunc(r, i, m.rows[r].GetString(col.Name))
|
|
||||||
// if r == m.cursor {
|
|
||||||
// cellStyle.Inherit(m.styles.Selected)
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
cellStyle = m.rowStyles[r]
|
|
||||||
// }
|
|
||||||
if r == m.cursor {
|
|
||||||
cellStyle = cellStyle.Inherit(m.styles.Selected)
|
|
||||||
}
|
|
||||||
|
|
||||||
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, "…")))
|
|
||||||
s = append(s, renderedCell)
|
|
||||||
}
|
|
||||||
|
|
||||||
row := lipgloss.JoinHorizontal(lipgloss.Left, s...)
|
|
||||||
|
|
||||||
if r == m.cursor {
|
|
||||||
return m.styles.Selected.Render(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
return row
|
|
||||||
}
|
|
||||||
|
|
||||||
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 clamp(v, low, high int) int {
|
|
||||||
return min(max(v, low), high)
|
|
||||||
}
|
|
||||||
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": 1771848320,
|
||||||
"narHash": "sha256-cYApT0NXJfqBkKcci7D9Kr4CBYZKOQKDYA23q8XNuWg=",
|
"narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=",
|
||||||
"owner": "NixOS",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "33d1e753c82ffc557b4a585c77de43d4c922ebb5",
|
"rev": "2fc6539b481e1d2569f25f8799236694180c0993",
|
||||||
"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
|
||||||
|
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";
|
|
||||||
};
|
};
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
46
go.mod
46
go.mod
@@ -1,32 +1,28 @@
|
|||||||
module tasksquire
|
module tasksquire
|
||||||
|
|
||||||
go 1.22.2
|
go 1.25
|
||||||
|
|
||||||
|
toolchain go1.25.7
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/charmbracelet/bubbles v0.18.0
|
charm.land/bubbles/v2 v2.0.0 // indirect
|
||||||
github.com/charmbracelet/bubbletea v0.26.1
|
charm.land/bubbletea/v2 v2.0.0 // indirect
|
||||||
github.com/charmbracelet/huh v0.3.0
|
charm.land/lipgloss/v2 v2.0.0 // indirect
|
||||||
github.com/charmbracelet/lipgloss v0.10.1-0.20240506202754-3ee5dcab73cb
|
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.15
|
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
|
||||||
golang.org/x/term v0.20.0
|
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||||
)
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||||
require (
|
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
github.com/catppuccin/go v0.2.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/charmbracelet/x/exp/term v0.0.0-20240506152644-8135bef4e495 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/mattn/go-runewidth v0.0.20 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
|
||||||
github.com/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/termenv v0.15.2 // indirect
|
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sync v0.7.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.20.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.15.0 // indirect
|
golang.org/x/term v0.40.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
95
go.sum
95
go.sum
@@ -1,53 +1,50 @@
|
|||||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
charm.land/bubbletea/v2 v2.0.0 h1:p0d6CtWyJXJ9GfzMpUUqbP/XUUhhlk06+vCKWmox1wQ=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
charm.land/bubbletea/v2 v2.0.0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
|
||||||
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
|
charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
|
||||||
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
|
||||||
github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
|
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||||
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
|
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||||
github.com/charmbracelet/bubbletea v0.26.1 h1:xujcQeF73rh4jwu3+zhfQsvV18x+7zIjlw7/CYbzGJ0=
|
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
||||||
github.com/charmbracelet/bubbletea v0.26.1/go.mod h1:FzKr7sKoO8iFVcdIBM9J0sJOcQv5nDQaYwsee3kpbgo=
|
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
||||||
github.com/charmbracelet/huh v0.3.0 h1:CxPplWkgW2yUTDDG0Z4S5HH8SJOosWHd4LxCvi0XsKE=
|
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
|
||||||
github.com/charmbracelet/huh v0.3.0/go.mod h1:fujUdKX8tC45CCSaRQdw789O6uaCRwx8l2NDyKfC4jA=
|
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
|
||||||
github.com/charmbracelet/lipgloss v0.10.1-0.20240506202754-3ee5dcab73cb h1:Hs3xzxHuruNT2Iuo87iS40c0PhLqpnUKBI6Xw6Ad3wQ=
|
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||||
github.com/charmbracelet/lipgloss v0.10.1-0.20240506202754-3ee5dcab73cb/go.mod h1:EPP2QJ0ectp3zo6gx9f8oJGq8keirqPJ3XpYEI8wrrs=
|
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||||
github.com/charmbracelet/x/exp/term v0.0.0-20240506152644-8135bef4e495 h1:+0U9qX8Pv8KiYgRxfBvORRjgBzLgHMjtElP4O0PyKYA=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/exp/term v0.0.0-20240506152644-8135bef4e495/go.mod h1:qeR6w1zITbkF7vEhcx0CqX5GfnIiQloJWQghN6HfP+c=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
||||||
|
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
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/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
|
||||||
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
|
|
||||||
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
|
|
||||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"tasksquire/taskwarrior"
|
"tasksquire/internal/taskwarrior"
|
||||||
|
"tasksquire/internal/timewarrior"
|
||||||
|
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
@@ -13,20 +14,24 @@ import (
|
|||||||
type Common struct {
|
type Common struct {
|
||||||
Ctx context.Context
|
Ctx context.Context
|
||||||
TW taskwarrior.TaskWarrior
|
TW taskwarrior.TaskWarrior
|
||||||
|
TimeW timewarrior.TimeWarrior
|
||||||
Keymap *Keymap
|
Keymap *Keymap
|
||||||
Styles *Styles
|
Styles *Styles
|
||||||
|
Udas []taskwarrior.Uda
|
||||||
|
|
||||||
pageStack *Stack[Component]
|
pageStack *Stack[Component]
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCommon(ctx context.Context, tw taskwarrior.TaskWarrior) *Common {
|
func NewCommon(ctx context.Context, tw taskwarrior.TaskWarrior, timeW timewarrior.TimeWarrior) *Common {
|
||||||
return &Common{
|
return &Common{
|
||||||
Ctx: ctx,
|
Ctx: ctx,
|
||||||
TW: tw,
|
TW: tw,
|
||||||
|
TimeW: timeW,
|
||||||
Keymap: NewKeymap(),
|
Keymap: NewKeymap(),
|
||||||
Styles: NewStyles(tw.GetConfig()),
|
Styles: NewStyles(tw.GetConfig()),
|
||||||
|
Udas: tw.GetUdas(),
|
||||||
|
|
||||||
pageStack: NewStack[Component](),
|
pageStack: NewStack[Component](),
|
||||||
}
|
}
|
||||||
@@ -52,5 +57,15 @@ func (c *Common) PushPage(page Component) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Common) PopPage() (Component, error) {
|
func (c *Common) PopPage() (Component, error) {
|
||||||
return c.pageStack.Pop()
|
component, err := c.pageStack.Pop()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
component.SetSize(c.width, c.height)
|
||||||
|
return component, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Common) HasSubpages() bool {
|
||||||
|
return !c.pageStack.IsEmpty()
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import tea "github.com/charmbracelet/bubbletea"
|
import tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
type Component interface {
|
type Component interface {
|
||||||
tea.Model
|
tea.Model
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/charmbracelet/bubbles/key"
|
"charm.land/bubbles/v2/key"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Keymap is a collection of key bindings.
|
// Keymap is a collection of key bindings.
|
||||||
@@ -17,14 +17,23 @@ type Keymap struct {
|
|||||||
Down key.Binding
|
Down key.Binding
|
||||||
Left key.Binding
|
Left key.Binding
|
||||||
Right key.Binding
|
Right key.Binding
|
||||||
|
Next key.Binding
|
||||||
|
Prev key.Binding
|
||||||
|
NextPage key.Binding
|
||||||
|
PrevPage key.Binding
|
||||||
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
|
||||||
Undo key.Binding
|
Undo key.Binding
|
||||||
|
Fill key.Binding
|
||||||
StartStop key.Binding
|
StartStop 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
|
||||||
@@ -86,6 +95,26 @@ func NewKeymap() *Keymap {
|
|||||||
key.WithHelp("→/l", "Right"),
|
key.WithHelp("→/l", "Right"),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
Next: key.NewBinding(
|
||||||
|
key.WithKeys("tab"),
|
||||||
|
key.WithHelp("tab", "Next"),
|
||||||
|
),
|
||||||
|
|
||||||
|
Prev: key.NewBinding(
|
||||||
|
key.WithKeys("shift+tab"),
|
||||||
|
key.WithHelp("shift+tab", "Previous"),
|
||||||
|
),
|
||||||
|
|
||||||
|
NextPage: key.NewBinding(
|
||||||
|
key.WithKeys("]", "L"),
|
||||||
|
key.WithHelp("]/L", "Next page"),
|
||||||
|
),
|
||||||
|
|
||||||
|
PrevPage: key.NewBinding(
|
||||||
|
key.WithKeys("[", "H"),
|
||||||
|
key.WithHelp("[/H", "Previous page"),
|
||||||
|
),
|
||||||
|
|
||||||
SetReport: key.NewBinding(
|
SetReport: key.NewBinding(
|
||||||
key.WithKeys("r"),
|
key.WithKeys("r"),
|
||||||
key.WithHelp("r", "Set report"),
|
key.WithHelp("r", "Set report"),
|
||||||
@@ -101,9 +130,14 @@ 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("enter"),
|
key.WithKeys(" "),
|
||||||
key.WithHelp("enter", "Select"),
|
key.WithHelp("space", "Select"),
|
||||||
),
|
),
|
||||||
|
|
||||||
Insert: key.NewBinding(
|
Insert: key.NewBinding(
|
||||||
@@ -121,9 +155,29 @@ func NewKeymap() *Keymap {
|
|||||||
key.WithHelp("undo", "Undo"),
|
key.WithHelp("undo", "Undo"),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
Fill: key.NewBinding(
|
||||||
|
key.WithKeys("f"),
|
||||||
|
key.WithHelp("fill", "Fill gaps"),
|
||||||
|
),
|
||||||
|
|
||||||
StartStop: key.NewBinding(
|
StartStop: key.NewBinding(
|
||||||
key.WithKeys("s"),
|
key.WithKeys("s"),
|
||||||
key.WithHelp("start/stop", "Start/Stop"),
|
key.WithHelp("start/stop", "Start/Stop"),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
Join: key.NewBinding(
|
||||||
|
key.WithKeys("J"),
|
||||||
|
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"),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
18
internal/common/messages.go
Normal file
18
internal/common/messages.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"tasksquire/internal/taskwarrior"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TaskMsg taskwarrior.Tasks
|
||||||
|
|
||||||
|
type TickMsg time.Time
|
||||||
|
|
||||||
|
func DoTick() tea.Cmd {
|
||||||
|
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
||||||
|
return TickMsg(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -38,3 +38,10 @@ func (s *Stack[T]) Pop() (T, error) {
|
|||||||
|
|
||||||
return item, nil
|
return item, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Stack[T]) IsEmpty() bool {
|
||||||
|
s.mutex.Lock()
|
||||||
|
defer s.mutex.Unlock()
|
||||||
|
|
||||||
|
return len(s.items) == 0
|
||||||
|
}
|
||||||
234
internal/common/styles.go
Normal file
234
internal/common/styles.go
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"image/color"
|
||||||
|
|
||||||
|
"tasksquire/internal/taskwarrior"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TableStyle struct {
|
||||||
|
Header lipgloss.Style
|
||||||
|
Cell 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 {
|
||||||
|
Colors map[string]*lipgloss.Style
|
||||||
|
Palette Palette
|
||||||
|
|
||||||
|
Base lipgloss.Style
|
||||||
|
|
||||||
|
// Form *huh.Theme
|
||||||
|
TableStyle TableStyle
|
||||||
|
|
||||||
|
Tab lipgloss.Style
|
||||||
|
ActiveTab lipgloss.Style
|
||||||
|
TabBar lipgloss.Style
|
||||||
|
|
||||||
|
ColumnFocused lipgloss.Style
|
||||||
|
ColumnBlurred lipgloss.Style
|
||||||
|
ColumnInsert lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStyles(config *taskwarrior.TWConfig) *Styles {
|
||||||
|
styles := Styles{}
|
||||||
|
|
||||||
|
colors := make(map[string]*lipgloss.Style)
|
||||||
|
|
||||||
|
for key, value := range config.GetConfig() {
|
||||||
|
if strings.HasPrefix(key, "color.") {
|
||||||
|
_, color, _ := strings.Cut(key, ".")
|
||||||
|
colors[color] = parseColorString(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
styles.Colors = colors
|
||||||
|
|
||||||
|
// 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{
|
||||||
|
// Header: lipgloss.NewStyle().Bold(true).Padding(0, 1).BorderBottom(true),
|
||||||
|
Cell: lipgloss.NewStyle().Padding(0, 1, 0, 0),
|
||||||
|
Header: lipgloss.NewStyle().Bold(true).Padding(0, 1, 0, 0).Underline(true).Foreground(styles.Palette.Primary.GetForeground()),
|
||||||
|
Selected: lipgloss.NewStyle().Bold(true).Reverse(true).Foreground(styles.Palette.Accent.GetForeground()),
|
||||||
|
}
|
||||||
|
|
||||||
|
// formTheme := huh.ThemeBase()
|
||||||
|
// formTheme.Focused.Title = formTheme.Focused.Title.Bold(true).Foreground(styles.Palette.Primary.GetForeground())
|
||||||
|
// formTheme.Focused.SelectSelector = formTheme.Focused.SelectSelector.SetString("→ ").Foreground(styles.Palette.Accent.GetForeground())
|
||||||
|
// formTheme.Focused.SelectedOption = formTheme.Focused.SelectedOption.Bold(true).Foreground(styles.Palette.Accent.GetForeground())
|
||||||
|
// formTheme.Focused.MultiSelectSelector = formTheme.Focused.MultiSelectSelector.SetString("→ ").Foreground(styles.Palette.Accent.GetForeground())
|
||||||
|
// formTheme.Focused.SelectedPrefix = formTheme.Focused.SelectedPrefix.SetString("✓ ").Foreground(styles.Palette.Accent.GetForeground())
|
||||||
|
// formTheme.Focused.UnselectedPrefix = formTheme.Focused.SelectedPrefix.SetString("• ")
|
||||||
|
// formTheme.Blurred.Title = formTheme.Blurred.Title.Bold(true).Foreground(styles.Palette.Muted.GetForeground())
|
||||||
|
// formTheme.Blurred.SelectSelector = formTheme.Blurred.SelectSelector.SetString(" ")
|
||||||
|
// formTheme.Blurred.SelectedOption = formTheme.Blurred.SelectedOption.Bold(true)
|
||||||
|
// formTheme.Blurred.MultiSelectSelector = formTheme.Blurred.MultiSelectSelector.SetString(" ")
|
||||||
|
// formTheme.Blurred.SelectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("✓ ")
|
||||||
|
// formTheme.Blurred.UnselectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("• ")
|
||||||
|
|
||||||
|
// styles.Form = formTheme
|
||||||
|
|
||||||
|
styles.Tab = lipgloss.NewStyle().
|
||||||
|
Padding(0, 1).
|
||||||
|
Foreground(styles.Palette.Muted.GetForeground())
|
||||||
|
|
||||||
|
styles.ActiveTab = styles.Tab.
|
||||||
|
Foreground(styles.Palette.Primary.GetForeground()).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
styles.TabBar = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||||
|
BorderForeground(styles.Palette.Border.GetForeground()).
|
||||||
|
MarginBottom(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).BorderForeground(styles.Palette.Border.GetForeground())
|
||||||
|
styles.ColumnInsert = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1).BorderForeground(styles.Palette.Accent.GetForeground())
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if color == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
style := lipgloss.NewStyle()
|
||||||
|
|
||||||
|
if strings.Contains(color, "on") {
|
||||||
|
fgbg := strings.Split(color, "on")
|
||||||
|
fg := strings.TrimSpace(fgbg[0])
|
||||||
|
bg := strings.TrimSpace(fgbg[1])
|
||||||
|
if fg != "" {
|
||||||
|
style = style.Foreground(parseColor(fg))
|
||||||
|
}
|
||||||
|
if bg != "" {
|
||||||
|
style = style.Background(parseColor(bg))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
style = style.Foreground(parseColor(strings.TrimSpace(color)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &style
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseColor(color string) color.Color {
|
||||||
|
if strings.HasPrefix(color, "rgb") {
|
||||||
|
return lipgloss.Color(convertRgbToAnsi(strings.TrimPrefix(color, "rgb")))
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(color, "color") {
|
||||||
|
return lipgloss.Color(strings.TrimPrefix(color, "color"))
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(color, "gray") {
|
||||||
|
gray, err := strconv.Atoi(strings.TrimPrefix(color, "gray"))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Invalid gray color format")
|
||||||
|
return lipgloss.Color("0")
|
||||||
|
}
|
||||||
|
return lipgloss.Color(strconv.Itoa(gray + 232))
|
||||||
|
}
|
||||||
|
if ansi, okcolor := colorStrings[color]; okcolor {
|
||||||
|
return lipgloss.Color(strconv.Itoa(ansi))
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Error("Invalid color format")
|
||||||
|
return lipgloss.Color("0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertRgbToAnsi(rgbString string) string {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if len(rgbString) != 3 {
|
||||||
|
slog.Error("Invalid RGB color format")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := strconv.Atoi(string(rgbString[0]))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Invalid value for R")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
g, err := strconv.Atoi(string(rgbString[1]))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Invalid value for G")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := strconv.Atoi(string(rgbString[2]))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Invalid value for B")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return strconv.Itoa(16 + (36 * r) + (6 * g) + b)
|
||||||
|
}
|
||||||
|
|
||||||
|
var colorStrings = map[string]int{
|
||||||
|
"black": 0,
|
||||||
|
"red": 1,
|
||||||
|
"green": 2,
|
||||||
|
"yellow": 3,
|
||||||
|
"blue": 4,
|
||||||
|
"magenta": 5,
|
||||||
|
"cyan": 6,
|
||||||
|
"white": 7,
|
||||||
|
"bright black": 8,
|
||||||
|
"bright red": 9,
|
||||||
|
"bright green": 10,
|
||||||
|
"bright yellow": 11,
|
||||||
|
"bright blue": 12,
|
||||||
|
"bright magenta": 13,
|
||||||
|
"bright cyan": 14,
|
||||||
|
"bright white": 15,
|
||||||
|
}
|
||||||
85
internal/common/sync.go
Normal file
85
internal/common/sync.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"tasksquire/internal/taskwarrior"
|
||||||
|
"tasksquire/internal/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 ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
445
internal/components/tasktable/tasktable.go
Normal file
445
internal/components/tasktable/tasktable.go
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
package tasktable
|
||||||
|
|
||||||
|
import (
|
||||||
|
taskw "tasksquire/internal/taskwarrior"
|
||||||
|
|
||||||
|
"charm.land/bubbles/v2/help"
|
||||||
|
"charm.land/bubbles/v2/key"
|
||||||
|
"charm.land/bubbles/v2/viewport"
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Model defines a state for the table widget.
|
||||||
|
type Model struct {
|
||||||
|
KeyMap KeyMap
|
||||||
|
Help help.Model
|
||||||
|
|
||||||
|
cols []Column
|
||||||
|
rows []Row
|
||||||
|
cursor int
|
||||||
|
focus bool
|
||||||
|
styles Styles
|
||||||
|
|
||||||
|
viewport viewport.Model
|
||||||
|
start int
|
||||||
|
end int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row represents one line in the table.
|
||||||
|
type Row struct {
|
||||||
|
task taskw.Task
|
||||||
|
style lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column defines the table structure.
|
||||||
|
type Column struct {
|
||||||
|
Title string
|
||||||
|
Name string
|
||||||
|
Width int
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which
|
||||||
|
// is used to render the help menu.
|
||||||
|
type KeyMap struct {
|
||||||
|
LineUp key.Binding
|
||||||
|
LineDown key.Binding
|
||||||
|
PageUp key.Binding
|
||||||
|
PageDown key.Binding
|
||||||
|
HalfPageUp key.Binding
|
||||||
|
HalfPageDown key.Binding
|
||||||
|
GotoTop key.Binding
|
||||||
|
GotoBottom key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortHelp implements the KeyMap interface.
|
||||||
|
func (km KeyMap) ShortHelp() []key.Binding {
|
||||||
|
return []key.Binding{km.LineUp, km.LineDown}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullHelp implements the KeyMap interface.
|
||||||
|
func (km KeyMap) FullHelp() [][]key.Binding {
|
||||||
|
return [][]key.Binding{
|
||||||
|
{km.LineUp, km.LineDown, km.GotoTop, km.GotoBottom},
|
||||||
|
{km.PageUp, km.PageDown, km.HalfPageUp, km.HalfPageDown},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultKeyMap returns a default set of keybindings.
|
||||||
|
func DefaultKeyMap() KeyMap {
|
||||||
|
return KeyMap{
|
||||||
|
LineUp: key.NewBinding(
|
||||||
|
key.WithKeys("up", "k"),
|
||||||
|
key.WithHelp("↑/k", "up"),
|
||||||
|
),
|
||||||
|
LineDown: key.NewBinding(
|
||||||
|
key.WithKeys("down", "j"),
|
||||||
|
key.WithHelp("↓/j", "down"),
|
||||||
|
),
|
||||||
|
PageUp: key.NewBinding(
|
||||||
|
key.WithKeys("b", "pgup"),
|
||||||
|
key.WithHelp("b/pgup", "page up"),
|
||||||
|
),
|
||||||
|
PageDown: key.NewBinding(
|
||||||
|
key.WithKeys("f", "pgdown", "space"),
|
||||||
|
key.WithHelp("f/pgdn", "page down"),
|
||||||
|
),
|
||||||
|
HalfPageUp: key.NewBinding(
|
||||||
|
key.WithKeys("u", "ctrl+u"),
|
||||||
|
key.WithHelp("u", "½ page up"),
|
||||||
|
),
|
||||||
|
HalfPageDown: key.NewBinding(
|
||||||
|
key.WithKeys("d", "ctrl+d"),
|
||||||
|
key.WithHelp("d", "½ page down"),
|
||||||
|
),
|
||||||
|
GotoTop: key.NewBinding(
|
||||||
|
key.WithKeys("home", "g"),
|
||||||
|
key.WithHelp("g/home", "go to start"),
|
||||||
|
),
|
||||||
|
GotoBottom: key.NewBinding(
|
||||||
|
key.WithKeys("end", "G"),
|
||||||
|
key.WithHelp("G/end", "go to end"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styles contains style definitions for this list component. By default, these
|
||||||
|
// values are generated by DefaultStyles.
|
||||||
|
type Styles struct {
|
||||||
|
Header lipgloss.Style
|
||||||
|
Cell lipgloss.Style
|
||||||
|
Selected lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultStyles returns a set of default style definitions for this table.
|
||||||
|
func DefaultStyles() Styles {
|
||||||
|
return Styles{
|
||||||
|
Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")),
|
||||||
|
Header: lipgloss.NewStyle().Bold(true).Padding(0, 1),
|
||||||
|
Cell: lipgloss.NewStyle().Padding(0, 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStyles sets the table styles.
|
||||||
|
func (m *Model) SetStyles(s Styles) {
|
||||||
|
m.styles = s
|
||||||
|
m.UpdateViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option is used to set options in New. For example:
|
||||||
|
//
|
||||||
|
// table := New(WithColumns([]Column{{Title: "ID", Width: 10}}))
|
||||||
|
type Option func(*Model)
|
||||||
|
|
||||||
|
// New creates a new model for the table widget.
|
||||||
|
func New(opts ...Option) Model {
|
||||||
|
m := Model{
|
||||||
|
cursor: 0,
|
||||||
|
viewport: viewport.New(viewport.WithHeight(20)), //nolint:mnd
|
||||||
|
|
||||||
|
KeyMap: DefaultKeyMap(),
|
||||||
|
Help: help.New(),
|
||||||
|
styles: DefaultStyles(),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(&m)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.UpdateViewport()
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithColumns sets the table columns (headers).
|
||||||
|
func WithColumns(cols []Column) Option {
|
||||||
|
return func(m *Model) {
|
||||||
|
m.cols = cols
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithRows sets the table rows (data).
|
||||||
|
func WithRows(rows []Row) Option {
|
||||||
|
return func(m *Model) {
|
||||||
|
m.rows = rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithHeight sets the height of the table.
|
||||||
|
func WithHeight(h int) Option {
|
||||||
|
return func(m *Model) {
|
||||||
|
m.viewport.SetHeight(h - lipgloss.Height(m.headersView()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithWidth sets the width of the table.
|
||||||
|
func WithWidth(w int) Option {
|
||||||
|
return func(m *Model) {
|
||||||
|
m.viewport.SetWidth(w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithFocused sets the focus state of the table.
|
||||||
|
func WithFocused(f bool) Option {
|
||||||
|
return func(m *Model) {
|
||||||
|
m.focus = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithStyles sets the table styles.
|
||||||
|
func WithStyles(s Styles) Option {
|
||||||
|
return func(m *Model) {
|
||||||
|
m.styles = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithKeyMap sets the key map.
|
||||||
|
func WithKeyMap(km KeyMap) Option {
|
||||||
|
return func(m *Model) {
|
||||||
|
m.KeyMap = km
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update is the Bubble Tea update loop.
|
||||||
|
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||||
|
if !m.focus {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyPressMsg:
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, m.KeyMap.LineUp):
|
||||||
|
m.MoveUp(1)
|
||||||
|
case key.Matches(msg, m.KeyMap.LineDown):
|
||||||
|
m.MoveDown(1)
|
||||||
|
case key.Matches(msg, m.KeyMap.PageUp):
|
||||||
|
m.MoveUp(m.viewport.Height())
|
||||||
|
case key.Matches(msg, m.KeyMap.PageDown):
|
||||||
|
m.MoveDown(m.viewport.Height())
|
||||||
|
case key.Matches(msg, m.KeyMap.HalfPageUp):
|
||||||
|
m.MoveUp(m.viewport.Height() / 2) //nolint:mnd
|
||||||
|
case key.Matches(msg, m.KeyMap.HalfPageDown):
|
||||||
|
m.MoveDown(m.viewport.Height() / 2) //nolint:mnd
|
||||||
|
case key.Matches(msg, m.KeyMap.GotoTop):
|
||||||
|
m.GotoTop()
|
||||||
|
case key.Matches(msg, m.KeyMap.GotoBottom):
|
||||||
|
m.GotoBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focused returns the focus state of the table.
|
||||||
|
func (m Model) Focused() bool {
|
||||||
|
return m.focus
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus focuses the table, allowing the user to move around the rows and
|
||||||
|
// interact.
|
||||||
|
func (m *Model) Focus() {
|
||||||
|
m.focus = true
|
||||||
|
m.UpdateViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blur blurs the table, preventing selection or movement.
|
||||||
|
func (m *Model) Blur() {
|
||||||
|
m.focus = false
|
||||||
|
m.UpdateViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the component.
|
||||||
|
func (m Model) View() string {
|
||||||
|
return m.headersView() + "\n" + m.viewport.View()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HelpView is a helper method for rendering the help menu from the keymap.
|
||||||
|
// Note that this view is not rendered by default and you must call it
|
||||||
|
// manually in your application, where applicable.
|
||||||
|
func (m Model) HelpView() string {
|
||||||
|
return m.Help.View(m.KeyMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateViewport updates the list content based on the previously defined
|
||||||
|
// columns and rows.
|
||||||
|
func (m *Model) UpdateViewport() {
|
||||||
|
renderedRows := make([]string, 0, len(m.rows))
|
||||||
|
|
||||||
|
// Render only rows from: m.cursor-m.viewport.Height to: m.cursor+m.viewport.Height
|
||||||
|
// Constant runtime, independent of number of rows in a table.
|
||||||
|
// Limits the number of renderedRows to a maximum of 2*m.viewport.Height
|
||||||
|
if m.cursor >= 0 {
|
||||||
|
m.start = clamp(m.cursor-m.viewport.Height(), 0, m.cursor)
|
||||||
|
} else {
|
||||||
|
m.start = 0
|
||||||
|
}
|
||||||
|
m.end = clamp(m.cursor+m.viewport.Height(), m.cursor, len(m.rows))
|
||||||
|
for i := m.start; i < m.end; i++ {
|
||||||
|
renderedRows = append(renderedRows, m.renderRow(i))
|
||||||
|
}
|
||||||
|
|
||||||
|
m.viewport.SetContent(
|
||||||
|
lipgloss.JoinVertical(lipgloss.Left, renderedRows...),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectedRow returns the selected row.
|
||||||
|
// You can cast it to your own implementation.
|
||||||
|
func (m Model) SelectedRow() Row {
|
||||||
|
if m.cursor < 0 || m.cursor >= len(m.rows) {
|
||||||
|
return Row{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.rows[m.cursor]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rows returns the current rows.
|
||||||
|
func (m Model) Rows() []Row {
|
||||||
|
return m.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns returns the current columns.
|
||||||
|
func (m Model) Columns() []Column {
|
||||||
|
return m.cols
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRows sets a new rows state.
|
||||||
|
func (m *Model) SetRows(r []Row) {
|
||||||
|
m.rows = r
|
||||||
|
|
||||||
|
if m.cursor > len(m.rows)-1 {
|
||||||
|
m.cursor = len(m.rows) - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
m.UpdateViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetColumns sets a new columns state.
|
||||||
|
func (m *Model) SetColumns(c []Column) {
|
||||||
|
m.cols = c
|
||||||
|
m.UpdateViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWidth sets the width of the viewport of the table.
|
||||||
|
func (m *Model) SetWidth(w int) {
|
||||||
|
m.viewport.SetWidth(w)
|
||||||
|
m.UpdateViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHeight sets the height of the viewport of the table.
|
||||||
|
func (m *Model) SetHeight(h int) {
|
||||||
|
m.viewport.SetHeight(h - lipgloss.Height(m.headersView()))
|
||||||
|
m.UpdateViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Height returns the viewport height of the table.
|
||||||
|
func (m Model) Height() int {
|
||||||
|
return m.viewport.Height()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Width returns the viewport width of the table.
|
||||||
|
func (m Model) Width() int {
|
||||||
|
return m.viewport.Width()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor returns the index of the selected row.
|
||||||
|
func (m Model) Cursor() int {
|
||||||
|
return m.cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCursor sets the cursor position in the table.
|
||||||
|
func (m *Model) SetCursor(n int) {
|
||||||
|
m.cursor = clamp(n, 0, len(m.rows)-1)
|
||||||
|
m.UpdateViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveUp moves the selection up by any number of rows.
|
||||||
|
// It can not go above the first row.
|
||||||
|
func (m *Model) MoveUp(n int) {
|
||||||
|
m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1)
|
||||||
|
|
||||||
|
offset := m.viewport.YOffset()
|
||||||
|
switch {
|
||||||
|
case m.start == 0:
|
||||||
|
offset = clamp(offset, 0, m.cursor)
|
||||||
|
case m.start < m.viewport.Height():
|
||||||
|
offset = clamp(clamp(offset+n, 0, m.cursor), 0, m.viewport.Height())
|
||||||
|
case offset >= 1:
|
||||||
|
offset = clamp(offset+n, 1, m.viewport.Height())
|
||||||
|
}
|
||||||
|
m.viewport.SetYOffset(offset)
|
||||||
|
m.UpdateViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveDown moves the selection down by any number of rows.
|
||||||
|
// It can not go below the last row.
|
||||||
|
func (m *Model) MoveDown(n int) {
|
||||||
|
m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1)
|
||||||
|
m.UpdateViewport()
|
||||||
|
|
||||||
|
offset := m.viewport.YOffset()
|
||||||
|
switch {
|
||||||
|
case m.end == len(m.rows) && offset > 0:
|
||||||
|
offset = clamp(offset-n, 1, m.viewport.Height())
|
||||||
|
case m.cursor > (m.end-m.start)/2 && offset > 0:
|
||||||
|
offset = clamp(offset-n, 1, m.cursor)
|
||||||
|
case offset > 1:
|
||||||
|
case m.cursor > offset+m.viewport.Height()-1:
|
||||||
|
offset = clamp(offset+1, 0, 1)
|
||||||
|
}
|
||||||
|
m.viewport.SetYOffset(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GotoTop moves the selection to the first row.
|
||||||
|
func (m *Model) GotoTop() {
|
||||||
|
m.MoveUp(m.cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GotoBottom moves the selection to the last row.
|
||||||
|
func (m *Model) GotoBottom() {
|
||||||
|
m.MoveDown(len(m.rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) headersView() string {
|
||||||
|
s := make([]string, 0, len(m.cols))
|
||||||
|
for _, col := range m.cols {
|
||||||
|
if col.Width <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true)
|
||||||
|
renderedCell := style.Render(ansi.Truncate(col.Title, col.Width, "…"))
|
||||||
|
s = append(s, m.styles.Header.Render(renderedCell))
|
||||||
|
}
|
||||||
|
return lipgloss.JoinHorizontal(lipgloss.Top, s...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) renderRow(r int) string {
|
||||||
|
s := make([]string, 0, len(m.cols))
|
||||||
|
for i, col := range m.cols {
|
||||||
|
if m.cols[i].Width <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cellStyle := m.rows[r].style
|
||||||
|
if r == m.cursor {
|
||||||
|
cellStyle = cellStyle.Inherit(m.styles.Selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true)
|
||||||
|
renderedCell := cellStyle.Render(style.Render(ansi.Truncate(m.rows[r].task.GetString(col.Name), m.cols[i].Width, "…")))
|
||||||
|
s = append(s, renderedCell)
|
||||||
|
}
|
||||||
|
|
||||||
|
row := lipgloss.JoinHorizontal(lipgloss.Top, s...)
|
||||||
|
|
||||||
|
if r == m.cursor {
|
||||||
|
return m.styles.Selected.Render(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
func clamp(v, low, high int) int {
|
||||||
|
return min(max(v, low), high)
|
||||||
|
}
|
||||||
112
internal/pages/main.go
Normal file
112
internal/pages/main.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"tasksquire/internal/common"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
// "charm.land/bubbles/v2/key"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MainPage struct {
|
||||||
|
common *common.Common
|
||||||
|
activePage common.Component
|
||||||
|
|
||||||
|
taskPage common.Component
|
||||||
|
timePage common.Component
|
||||||
|
currentTab int
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMainPage(common *common.Common) *MainPage {
|
||||||
|
m := &MainPage{
|
||||||
|
common: common,
|
||||||
|
}
|
||||||
|
|
||||||
|
m.taskPage = NewTaskPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default")))
|
||||||
|
// m.timePage = NewTimePage(common)
|
||||||
|
//
|
||||||
|
m.activePage = m.taskPage
|
||||||
|
m.currentTab = 0
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MainPage) Init() tea.Cmd {
|
||||||
|
// return tea.Batch(m.taskPage.Init(), m.timePage.Init())
|
||||||
|
return tea.Batch()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
|
||||||
|
// switch msg := msg.(type) {
|
||||||
|
// case tea.WindowSizeMsg:
|
||||||
|
// m.width = msg.Width
|
||||||
|
// m.height = msg.Height
|
||||||
|
// m.common.SetSize(msg.Width, msg.Height)
|
||||||
|
//
|
||||||
|
// tabHeight := lipgloss.Height(m.renderTabBar())
|
||||||
|
// contentHeight := msg.Height - tabHeight
|
||||||
|
// if contentHeight < 0 {
|
||||||
|
// contentHeight = 0
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// newMsg := tea.WindowSizeMsg{Width: msg.Width, Height: contentHeight}
|
||||||
|
// activePage, cmd := m.activePage.Update(newMsg)
|
||||||
|
// m.activePage = activePage.(common.Component)
|
||||||
|
// return m, cmd
|
||||||
|
//
|
||||||
|
// case tea.KeyMsg:
|
||||||
|
// // Only handle tab key for page switching when at the top level (no subpages active)
|
||||||
|
// if key.Matches(msg, m.common.Keymap.Next) && !m.common.HasSubpages() {
|
||||||
|
// if m.activePage == m.taskPage {
|
||||||
|
// m.activePage = m.timePage
|
||||||
|
// m.currentTab = 1
|
||||||
|
// } else {
|
||||||
|
// m.activePage = m.taskPage
|
||||||
|
// m.currentTab = 0
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// tabHeight := lipgloss.Height(m.renderTabBar())
|
||||||
|
// contentHeight := m.height - tabHeight
|
||||||
|
// if contentHeight < 0 {
|
||||||
|
// contentHeight = 0
|
||||||
|
// }
|
||||||
|
// m.activePage.SetSize(m.width, contentHeight)
|
||||||
|
//
|
||||||
|
// // Trigger a refresh/init on switch? Maybe not needed if we keep state.
|
||||||
|
// // But we might want to refresh data.
|
||||||
|
// return m, m.activePage.Init()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
activePage, cmd := m.activePage.Update(msg)
|
||||||
|
m.activePage = activePage.(common.Component)
|
||||||
|
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MainPage) renderTabBar() string {
|
||||||
|
var tabs []string
|
||||||
|
headers := []string{"Tasks", "Time"}
|
||||||
|
|
||||||
|
for i, header := range headers {
|
||||||
|
style := m.common.Styles.Tab
|
||||||
|
if m.currentTab == i {
|
||||||
|
style = m.common.Styles.ActiveTab
|
||||||
|
}
|
||||||
|
tabs = append(tabs, style.Render(header))
|
||||||
|
}
|
||||||
|
|
||||||
|
row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
|
||||||
|
return m.common.Styles.TabBar.Width(m.common.Width()).Render(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MainPage) View() tea.View {
|
||||||
|
v := tea.NewView(lipgloss.JoinVertical(lipgloss.Left, m.renderTabBar(), m.activePage.View().Content))
|
||||||
|
v.AltScreen = true
|
||||||
|
return v
|
||||||
|
// return lipgloss.JoinVertical(lipgloss.Left, m.renderTabBar(), m.activePage.View())
|
||||||
|
}
|
||||||
355
internal/pages/tasks.go
Normal file
355
internal/pages/tasks.go
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
// TODO: update table every second (to show correct relative time)
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"tasksquire/internal/common"
|
||||||
|
"tasksquire/internal/components/tasktable"
|
||||||
|
"tasksquire/internal/taskwarrior"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
// "charm.land/lipgloss/v2"
|
||||||
|
"charm.land/bubbles/v2/key"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TaskPage struct {
|
||||||
|
common *common.Common
|
||||||
|
|
||||||
|
activeReport *taskwarrior.Report
|
||||||
|
activeContext *taskwarrior.Context
|
||||||
|
activeProject string
|
||||||
|
selectedTask *taskwarrior.Task
|
||||||
|
taskCursor int
|
||||||
|
|
||||||
|
tasks taskwarrior.Tasks
|
||||||
|
|
||||||
|
taskTable tasktable.Model
|
||||||
|
|
||||||
|
// Details panel state
|
||||||
|
// detailsPanelActive bool
|
||||||
|
// detailsViewer *detailsviewer.DetailsViewer
|
||||||
|
|
||||||
|
subpage common.Component
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTaskPage(com *common.Common, report *taskwarrior.Report) *TaskPage {
|
||||||
|
p := &TaskPage{
|
||||||
|
common: com,
|
||||||
|
activeReport: report,
|
||||||
|
activeContext: com.TW.GetActiveContext(),
|
||||||
|
activeProject: "",
|
||||||
|
taskTable: tasktable.New(),
|
||||||
|
// detailsPanelActive: false,
|
||||||
|
// detailsViewer: detailsviewer.New(com),
|
||||||
|
}
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TaskPage) SetSize(width int, height int) {
|
||||||
|
p.common.SetSize(width, height)
|
||||||
|
|
||||||
|
baseHeight := height - p.common.Styles.Base.GetVerticalFrameSize()
|
||||||
|
baseWidth := width - p.common.Styles.Base.GetHorizontalFrameSize()
|
||||||
|
|
||||||
|
var tableHeight int
|
||||||
|
// if p.detailsPanelActive {
|
||||||
|
// // Allocate 60% for table, 40% for details panel
|
||||||
|
// // Minimum 5 lines for details, minimum 10 lines for table
|
||||||
|
// detailsHeight := max(min(baseHeight*2/5, baseHeight-10), 5)
|
||||||
|
// tableHeight = baseHeight - detailsHeight - 1 // -1 for spacing
|
||||||
|
//
|
||||||
|
// // Set component size (component handles its own border/padding)
|
||||||
|
// // p.detailsViewer.SetSize(baseWidth, detailsHeight)
|
||||||
|
// } else {
|
||||||
|
tableHeight = baseHeight
|
||||||
|
// }
|
||||||
|
|
||||||
|
p.taskTable.SetWidth(baseWidth)
|
||||||
|
p.taskTable.SetHeight(tableHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TaskPage) Init() tea.Cmd {
|
||||||
|
return tea.Batch(p.getTasks(), common.DoTick())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TaskPage) 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 BackMsg:
|
||||||
|
case common.TickMsg:
|
||||||
|
cmds = append(cmds, p.getTasks())
|
||||||
|
cmds = append(cmds, common.DoTick())
|
||||||
|
return p, tea.Batch(cmds...)
|
||||||
|
case common.TaskMsg:
|
||||||
|
p.tasks = taskwarrior.Tasks(msg)
|
||||||
|
// case UpdateReportMsg:
|
||||||
|
// p.activeReport = msg
|
||||||
|
// cmds = append(cmds, p.getTasks())
|
||||||
|
// case UpdateContextMsg:
|
||||||
|
// p.activeContext = msg
|
||||||
|
// p.common.TW.SetContext(msg)
|
||||||
|
// p.populateTaskTable(p.tasks)
|
||||||
|
// cmds = append(cmds, p.getTasks())
|
||||||
|
// case UpdateProjectMsg:
|
||||||
|
// p.activeProject = string(msg)
|
||||||
|
// 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:
|
||||||
|
// cmds = append(cmds, p.getTasks())
|
||||||
|
case tea.KeyPressMsg:
|
||||||
|
// Handle ESC when details panel is active
|
||||||
|
// if p.detailsPanelActive && key.Matches(msg, p.common.Keymap.Back) {
|
||||||
|
// p.detailsPanelActive = false
|
||||||
|
// p.detailsViewer.Blur()
|
||||||
|
// p.SetSize(p.common.Width(), p.common.Height())
|
||||||
|
// return p, nil
|
||||||
|
// }
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, p.common.Keymap.Quit):
|
||||||
|
return p, tea.Quit
|
||||||
|
}
|
||||||
|
// case key.Matches(msg, p.common.Keymap.SetReport):
|
||||||
|
// p.subpage = NewReportPickerPage(p.common, p.activeReport)
|
||||||
|
// cmd := p.subpage.Init()
|
||||||
|
// p.common.PushPage(p)
|
||||||
|
// return p.subpage, cmd
|
||||||
|
// case key.Matches(msg, p.common.Keymap.SetContext):
|
||||||
|
// p.subpage = NewContextPickerPage(p.common)
|
||||||
|
// cmd := p.subpage.Init()
|
||||||
|
// p.common.PushPage(p)
|
||||||
|
// return p.subpage, cmd
|
||||||
|
// case key.Matches(msg, p.common.Keymap.Add):
|
||||||
|
// p.subpage = NewTaskEditorPage(p.common, taskwarrior.NewTask())
|
||||||
|
// cmd := p.subpage.Init()
|
||||||
|
// p.common.PushPage(p)
|
||||||
|
// return p.subpage, cmd
|
||||||
|
// case key.Matches(msg, p.common.Keymap.Edit):
|
||||||
|
// p.subpage = NewTaskEditorPage(p.common, *p.selectedTask)
|
||||||
|
// cmd := p.subpage.Init()
|
||||||
|
// p.common.PushPage(p)
|
||||||
|
// 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):
|
||||||
|
// p.common.TW.SetTaskDone(p.selectedTask)
|
||||||
|
// return p, p.getTasks()
|
||||||
|
// case key.Matches(msg, p.common.Keymap.Delete):
|
||||||
|
// p.common.TW.DeleteTask(p.selectedTask)
|
||||||
|
// return p, p.getTasks()
|
||||||
|
// case key.Matches(msg, p.common.Keymap.SetProject):
|
||||||
|
// p.subpage = NewProjectPickerPage(p.common, p.activeProject)
|
||||||
|
// cmd := p.subpage.Init()
|
||||||
|
// p.common.PushPage(p)
|
||||||
|
// 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):
|
||||||
|
// if p.selectedTask != nil {
|
||||||
|
// tag := p.common.TW.GetConfig().Get("uda.tasksquire.tag.default")
|
||||||
|
// if p.selectedTask.HasTag(tag) {
|
||||||
|
// p.selectedTask.RemoveTag(tag)
|
||||||
|
// } else {
|
||||||
|
// p.selectedTask.AddTag(tag)
|
||||||
|
// }
|
||||||
|
// p.common.TW.ImportTask(p.selectedTask)
|
||||||
|
// return p, p.getTasks()
|
||||||
|
// }
|
||||||
|
// return p, nil
|
||||||
|
// case key.Matches(msg, p.common.Keymap.Undo):
|
||||||
|
// p.common.TW.Undo()
|
||||||
|
// return p, p.getTasks()
|
||||||
|
// case key.Matches(msg, p.common.Keymap.StartStop):
|
||||||
|
// if p.selectedTask != nil && p.selectedTask.Status == "pending" {
|
||||||
|
// if p.selectedTask.Start == "" {
|
||||||
|
// p.common.TW.StopActiveTasks()
|
||||||
|
// p.common.TW.StartTask(p.selectedTask)
|
||||||
|
// } else {
|
||||||
|
// p.common.TW.StopTask(p.selectedTask)
|
||||||
|
// }
|
||||||
|
// 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
|
||||||
|
//
|
||||||
|
// // 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)
|
||||||
|
// cmds = append(cmds, cmd)
|
||||||
|
//
|
||||||
|
// if p.tasks != nil && len(p.tasks) > 0 {
|
||||||
|
// p.selectedTask = p.tasks[p.taskTable.Cursor()]
|
||||||
|
// } else {
|
||||||
|
// p.selectedTask = nil
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
return p, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TaskPage) View() tea.View {
|
||||||
|
if len(p.tasks) == 0 {
|
||||||
|
return tea.NewView(p.common.Styles.Base.Render("No tasks found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
tableView := p.taskTable.View()
|
||||||
|
|
||||||
|
return tea.NewView(tableView)
|
||||||
|
}
|
||||||
|
//
|
||||||
|
// if !p.detailsPanelActive {
|
||||||
|
// return tableView
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Combine table and details panel vertically
|
||||||
|
// return lipgloss.JoinVertical(
|
||||||
|
// lipgloss.Left,
|
||||||
|
// tableView,
|
||||||
|
// p.detailsViewer.View(),
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func (p *TaskPage) populateTaskTable(tasks taskwarrior.Tasks) {
|
||||||
|
// if len(tasks) == 0 {
|
||||||
|
// 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()
|
||||||
|
//
|
||||||
|
// // Adjust cursor for tree ordering
|
||||||
|
// if p.selectedTask != nil {
|
||||||
|
// for i, task := range orderedTasks {
|
||||||
|
// if task.Uuid == p.selectedTask.Uuid {
|
||||||
|
// selected = i
|
||||||
|
// break
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// if selected > len(orderedTasks)-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.common,
|
||||||
|
// able.WithReport(p.activeReport),
|
||||||
|
// table.WithTasks(orderedTasks),
|
||||||
|
// table.WithTaskTree(taskTree),
|
||||||
|
// table.WithFocused(true),
|
||||||
|
// table.WithWidth(baseWidth),
|
||||||
|
// table.WithHeight(tableHeight),
|
||||||
|
// table.WithStyles(p.common.Styles.TableStyle),
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// if selected == 0 {
|
||||||
|
// selected = p.taskTable.Cursor()
|
||||||
|
// }
|
||||||
|
// if selected < len(orderedTasks) {
|
||||||
|
// p.taskTable.SetCursor(selected)
|
||||||
|
// } else {
|
||||||
|
// p.taskTable.SetCursor(len(p.tasks) - 1)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Refresh details content if panel is active
|
||||||
|
// if p.detailsPanelActive && p.selectedTask != nil {
|
||||||
|
// p.detailsViewer.SetTask(p.selectedTask)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
func (p *TaskPage) getTasks() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
filters := []string{}
|
||||||
|
if p.activeProject != "" {
|
||||||
|
filters = append(filters, "project:"+p.activeProject)
|
||||||
|
}
|
||||||
|
tasks := p.common.TW.GetTasks(p.activeReport, filters...)
|
||||||
|
return common.TaskMsg(tasks)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,8 @@ var (
|
|||||||
defaultConfig = map[string]string{
|
defaultConfig = map[string]string{
|
||||||
"uda.tasksquire.report.default": "next",
|
"uda.tasksquire.report.default": "next",
|
||||||
"uda.tasksquire.tag.default": "next",
|
"uda.tasksquire.tag.default": "next",
|
||||||
"uda.tasksquire.tags.default": "low_energy,customer,delegate,code,communication,research",
|
"uda.tasksquire.tags.default": "mngmnt,ops,low_energy,cust,delegate,code,comm,research",
|
||||||
|
"uda.tasksquire.picker.filter_by_default": "yes",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ func (tc *TWConfig) GetConfig() map[string]string {
|
|||||||
|
|
||||||
func (tc *TWConfig) Get(key string) string {
|
func (tc *TWConfig) Get(key string) string {
|
||||||
if _, ok := tc.config[key]; !ok {
|
if _, ok := tc.config[key]; !ok {
|
||||||
slog.Debug(fmt.Sprintf("Key not found in config: %s", key))
|
slog.Debug(fmt.Sprintf("Key not found in Taskwarrior config: %s", key))
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package taskwarrior
|
package taskwarrior
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
@@ -13,6 +14,23 @@ const (
|
|||||||
dtformat = "20060102T150405Z"
|
dtformat = "20060102T150405Z"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type UdaType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
UdaTypeString UdaType = "string"
|
||||||
|
UdaTypeDate UdaType = "date"
|
||||||
|
UdaTypeNumeric UdaType = "numeric"
|
||||||
|
UdaTypeDuration UdaType = "duration"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Uda struct {
|
||||||
|
Name string
|
||||||
|
Type UdaType
|
||||||
|
Label string
|
||||||
|
Values []string
|
||||||
|
Default string
|
||||||
|
}
|
||||||
|
|
||||||
type Annotation struct {
|
type Annotation struct {
|
||||||
Entry string `json:"entry,omitempty"`
|
Entry string `json:"entry,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
@@ -22,19 +40,21 @@ func (a Annotation) String() string {
|
|||||||
return fmt.Sprintf("%s %s", a.Entry, a.Description)
|
return fmt.Sprintf("%s %s", a.Entry, a.Description)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Tasks []*Task
|
||||||
|
|
||||||
type Task struct {
|
type Task struct {
|
||||||
Id int64 `json:"id,omitempty"`
|
Id int64 `json:"id,omitempty"`
|
||||||
Uuid string `json:"uuid,omitempty"`
|
Uuid string `json:"uuid,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Project string `json:"project"`
|
Project string `json:"project"`
|
||||||
Priority string `json:"priority"`
|
Priority string `json:"priority,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
VirtualTags []string `json:"-"`
|
VirtualTags []string `json:"-"`
|
||||||
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"`
|
||||||
@@ -45,6 +65,16 @@ type Task struct {
|
|||||||
Modified string `json:"modified,omitempty"`
|
Modified string `json:"modified,omitempty"`
|
||||||
Recur string `json:"recur,omitempty"`
|
Recur string `json:"recur,omitempty"`
|
||||||
Annotations []Annotation `json:"annotations,omitempty"`
|
Annotations []Annotation `json:"annotations,omitempty"`
|
||||||
|
Udas map[string]any `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: fix pointer receiver
|
||||||
|
func NewTask() Task {
|
||||||
|
return Task{
|
||||||
|
Tags: make([]string, 0),
|
||||||
|
Depends: make([]string, 0),
|
||||||
|
Udas: make(map[string]any),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Task) GetString(fieldWFormat string) string {
|
func (t *Task) GetString(fieldWFormat string) string {
|
||||||
@@ -90,19 +120,25 @@ func (t *Task) GetString(fieldWFormat string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(t.Annotations) == 0 {
|
if t.Udas["details"] != nil && t.Udas["details"] != "" {
|
||||||
return t.Description
|
return fmt.Sprintf("%s [D]", t.Description)
|
||||||
} else {
|
} else {
|
||||||
// var annotations []string
|
return t.Description
|
||||||
// for _, a := range t.Annotations {
|
|
||||||
// annotations = append(annotations, a.String())
|
|
||||||
// }
|
|
||||||
// return fmt.Sprintf("%s\n%s", t.Description, strings.Join(annotations, "\n"))
|
|
||||||
|
|
||||||
// TODO enable support for multiline in table
|
|
||||||
return fmt.Sprintf("%s [%d]", t.Description, len(t.Annotations))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if len(t.Annotations) == 0 {
|
||||||
|
// return t.Description
|
||||||
|
// } else {
|
||||||
|
// // var annotations []string
|
||||||
|
// // for _, a := range t.Annotations {
|
||||||
|
// // annotations = append(annotations, a.String())
|
||||||
|
// // }
|
||||||
|
// // return fmt.Sprintf("%s\n%s", t.Description, strings.Join(annotations, "\n"))
|
||||||
|
|
||||||
|
// // TODO enable support for multiline in table
|
||||||
|
// return fmt.Sprintf("%s [%d]", t.Description, len(t.Annotations))
|
||||||
|
// }
|
||||||
|
|
||||||
case "project":
|
case "project":
|
||||||
switch format {
|
switch format {
|
||||||
case "parent":
|
case "parent":
|
||||||
@@ -132,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]
|
||||||
}
|
}
|
||||||
@@ -185,15 +221,48 @@ func (t *Task) GetString(fieldWFormat string) string {
|
|||||||
return t.Recur
|
return t.Recur
|
||||||
|
|
||||||
default:
|
default:
|
||||||
slog.Error(fmt.Sprintf("Field not implemented: %s", field))
|
// TODO: format according to UDA type
|
||||||
return ""
|
if val, ok := t.Udas[field]; ok {
|
||||||
|
if strVal, ok := val.(string); ok {
|
||||||
|
return strVal
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Error("Field not implemented", "field", field)
|
||||||
|
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
|
||||||
@@ -223,7 +292,69 @@ func (t *Task) RemoveTag(tag string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tasks []*Task
|
func (t *Task) UnmarshalJSON(data []byte) error {
|
||||||
|
type Alias Task
|
||||||
|
task := Alias{}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &task); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
*t = Task(task)
|
||||||
|
|
||||||
|
m := make(map[string]any)
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(m, "id")
|
||||||
|
delete(m, "uuid")
|
||||||
|
delete(m, "description")
|
||||||
|
delete(m, "project")
|
||||||
|
// delete(m, "priority")
|
||||||
|
delete(m, "status")
|
||||||
|
delete(m, "tags")
|
||||||
|
delete(m, "depends")
|
||||||
|
delete(m, "urgency")
|
||||||
|
delete(m, "parenttask")
|
||||||
|
delete(m, "due")
|
||||||
|
delete(m, "wait")
|
||||||
|
delete(m, "scheduled")
|
||||||
|
delete(m, "until")
|
||||||
|
delete(m, "start")
|
||||||
|
delete(m, "end")
|
||||||
|
delete(m, "entry")
|
||||||
|
delete(m, "modified")
|
||||||
|
delete(m, "recur")
|
||||||
|
delete(m, "annotations")
|
||||||
|
t.Udas = m
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Task) MarshalJSON() ([]byte, error) {
|
||||||
|
type Alias Task
|
||||||
|
task := Alias(*t)
|
||||||
|
|
||||||
|
knownFields, err := json.Marshal(task)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var knownMap map[string]any
|
||||||
|
if err := json.Unmarshal(knownFields, &knownMap); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range t.Udas {
|
||||||
|
if value != nil && value != "" {
|
||||||
|
knownMap[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(knownMap)
|
||||||
|
}
|
||||||
|
|
||||||
type Context struct {
|
type Context struct {
|
||||||
Name string
|
Name string
|
||||||
@@ -253,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 ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,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 ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -411,3 +542,15 @@ func ValidateDate(s string) error {
|
|||||||
|
|
||||||
return fmt.Errorf("invalid date")
|
return fmt.Errorf("invalid date")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ValidateNumeric(s string) error {
|
||||||
|
if _, err := strconv.ParseFloat(s, 64); err != nil {
|
||||||
|
return fmt.Errorf("invalid number")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateDuration(s string) error {
|
||||||
|
// TODO: implement duration validation
|
||||||
|
return nil
|
||||||
|
}
|
||||||
81
internal/taskwarrior/models_test.go
Normal file
81
internal/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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
670
internal/taskwarrior/taskwarrior.go
Normal file
670
internal/taskwarrior/taskwarrior.go
Normal file
@@ -0,0 +1,670 @@
|
|||||||
|
// TODO: error handling
|
||||||
|
// TODO: split combinedOutput and handle stderr differently
|
||||||
|
// TODO: reorder functions
|
||||||
|
package taskwarrior
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
twBinary = "task"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
nonStandardReports = map[string]struct{}{
|
||||||
|
"burndown.daily": {},
|
||||||
|
"burndown.monthly": {},
|
||||||
|
"burndown.weekly": {},
|
||||||
|
"calendar": {},
|
||||||
|
"colors": {},
|
||||||
|
"export": {},
|
||||||
|
"ghistory.annual": {},
|
||||||
|
"ghistory.monthly": {},
|
||||||
|
"history.annual": {},
|
||||||
|
"history.monthly": {},
|
||||||
|
"information": {},
|
||||||
|
"summary": {},
|
||||||
|
"timesheet": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
virtualTags = map[string]struct{}{
|
||||||
|
"ACTIVE": {},
|
||||||
|
"ANNOTATED": {},
|
||||||
|
"BLOCKED": {},
|
||||||
|
"BLOCKING": {},
|
||||||
|
"CHILD": {},
|
||||||
|
"COMPLETED": {},
|
||||||
|
"DELETED": {},
|
||||||
|
"DUE": {},
|
||||||
|
"DUETODAY": {},
|
||||||
|
"INSTANCE": {},
|
||||||
|
"LATEST": {},
|
||||||
|
"MONTH": {},
|
||||||
|
"ORPHAN": {},
|
||||||
|
"OVERDUE": {},
|
||||||
|
"PARENT": {},
|
||||||
|
"PENDING": {},
|
||||||
|
"PRIORITY": {},
|
||||||
|
"PROJECT": {},
|
||||||
|
"QUARTER": {},
|
||||||
|
"READY": {},
|
||||||
|
"SCHEDULED": {},
|
||||||
|
"TAGGED": {},
|
||||||
|
"TEMPLATE": {},
|
||||||
|
"TODAY": {},
|
||||||
|
"TOMORROW": {},
|
||||||
|
"UDA": {},
|
||||||
|
"UNBLOCKED": {},
|
||||||
|
"UNTIL": {},
|
||||||
|
"WAITING": {},
|
||||||
|
"WEEK": {},
|
||||||
|
"YEAR": {},
|
||||||
|
"YESTERDAY": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
type TaskWarrior interface {
|
||||||
|
GetConfig() *TWConfig
|
||||||
|
|
||||||
|
GetActiveContext() *Context
|
||||||
|
GetContext(context string) *Context
|
||||||
|
GetContexts() Contexts
|
||||||
|
SetContext(context *Context) error
|
||||||
|
|
||||||
|
GetProjects() []string
|
||||||
|
|
||||||
|
GetPriorities() []string
|
||||||
|
|
||||||
|
GetTags() []string
|
||||||
|
|
||||||
|
GetReport(report string) *Report
|
||||||
|
GetReports() Reports
|
||||||
|
|
||||||
|
GetUdas() []Uda
|
||||||
|
|
||||||
|
GetTasks(report *Report, filter ...string) Tasks
|
||||||
|
// AddTask(task *Task) error
|
||||||
|
ImportTask(task *Task)
|
||||||
|
SetTaskDone(task *Task)
|
||||||
|
DeleteTask(task *Task)
|
||||||
|
StartTask(task *Task)
|
||||||
|
StopTask(task *Task)
|
||||||
|
StopActiveTasks()
|
||||||
|
GetInformation(task *Task) string
|
||||||
|
AddTaskAnnotation(uuid string, annotation string)
|
||||||
|
|
||||||
|
Undo()
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskwarriorInterop struct {
|
||||||
|
configLocation string
|
||||||
|
defaultArgs []string
|
||||||
|
config *TWConfig
|
||||||
|
reports Reports
|
||||||
|
contexts Contexts
|
||||||
|
ctx context.Context
|
||||||
|
|
||||||
|
mutex sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTaskwarriorInterop(ctx context.Context, configLocation string) *TaskwarriorInterop {
|
||||||
|
if _, err := exec.LookPath(twBinary); err != nil {
|
||||||
|
slog.Error("Taskwarrior not found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defaultArgs := []string{fmt.Sprintf("rc=%s", configLocation), "rc.verbose=nothing", "rc.dependency.confirmation=no", "rc.recurrence.confirmation=no", "rc.confirmation=no", "rc.json.array=1"}
|
||||||
|
|
||||||
|
ts := &TaskwarriorInterop{
|
||||||
|
configLocation: configLocation,
|
||||||
|
defaultArgs: defaultArgs,
|
||||||
|
ctx: ctx,
|
||||||
|
mutex: sync.Mutex{},
|
||||||
|
}
|
||||||
|
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.contexts = ts.extractContexts()
|
||||||
|
|
||||||
|
return ts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskwarriorInterop) GetConfig() *TWConfig {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
return ts.config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskwarriorInterop) GetTasks(report *Report, filter ...string) Tasks {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
args := ts.defaultArgs
|
||||||
|
|
||||||
|
if report != nil && report.Context {
|
||||||
|
for _, context := range ts.contexts {
|
||||||
|
if context.Active && context.Name != "none" {
|
||||||
|
args = append(args, context.ReadFilter)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter != nil {
|
||||||
|
args = append(args, filter...)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
if err != nil {
|
||||||
|
if ts.ctx.Err() == context.Canceled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
slog.Error("Failed getting report", "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks := make(Tasks, 0)
|
||||||
|
err = json.Unmarshal(output, &tasks)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed unmarshalling tasks", "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, task := range tasks {
|
||||||
|
if len(task.Depends) > 0 {
|
||||||
|
ids := make([]string, len(task.Depends))
|
||||||
|
for i, dependUuid := range task.Depends {
|
||||||
|
ids[i] = ts.getIds([]string{fmt.Sprintf("uuid:%s", dependUuid)})
|
||||||
|
}
|
||||||
|
|
||||||
|
task.DependsIds = strings.Join(ids, " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskwarriorInterop) getIds(filter []string) string {
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(append(ts.defaultArgs, filter...), "_ids")...)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed getting field", "error", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskwarriorInterop) GetContext(context string) *Context {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
if context == "" {
|
||||||
|
context = "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
if context, ok := ts.contexts[context]; ok {
|
||||||
|
return context
|
||||||
|
} else {
|
||||||
|
slog.Error("Context not found", "name", context)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskwarriorInterop) GetActiveContext() *Context {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
for _, context := range ts.contexts {
|
||||||
|
if context.Active {
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ts.contexts["none"]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskwarriorInterop) GetContexts() Contexts {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
return ts.contexts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskwarriorInterop) GetProjects() []string {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_projects"}...)...)
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed getting projects", "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
projects := make([]string, 0)
|
||||||
|
for _, project := range strings.Split(string(output), "\n") {
|
||||||
|
if project != "" {
|
||||||
|
projects = append(projects, project)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(projects)
|
||||||
|
|
||||||
|
return projects
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskwarriorInterop) GetPriorities() []string {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
priorities := make([]string, 0)
|
||||||
|
for _, priority := range strings.Split(ts.config.Get("uda.priority.values"), ",") {
|
||||||
|
if priority != "" {
|
||||||
|
priorities = append(priorities, priority)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return priorities
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskwarriorInterop) GetTags() []string {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_tags"}...)...)
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed getting tags", "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tags := make([]string, 0)
|
||||||
|
tagSet := make(map[string]struct{})
|
||||||
|
|
||||||
|
for _, tag := range strings.Split(string(output), "\n") {
|
||||||
|
if _, ok := virtualTags[tag]; !ok && tag != "" {
|
||||||
|
tags = append(tags, tag)
|
||||||
|
tagSet[tag] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tag := range strings.Split(ts.config.Get("uda.TaskwarriorInterop.tags.default"), ",") {
|
||||||
|
if _, ok := tagSet[tag]; !ok {
|
||||||
|
tags = append(tags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(tags)
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskwarriorInterop) GetReport(report string) *Report {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
return ts.reports[report]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskwarriorInterop) GetReports() Reports {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
return ts.reports
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskwarriorInterop) GetUdas() []Uda {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "_udas")...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
if ts.ctx.Err() == context.Canceled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
slog.Error("Failed getting UDAs", "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
udas := make([]Uda, 0)
|
||||||
|
for _, uda := range strings.Split(string(output), "\n") {
|
||||||
|
if uda != "" {
|
||||||
|
udatype := UdaType(ts.config.Get(fmt.Sprintf("uda.%s.type", uda)))
|
||||||
|
if udatype == "" {
|
||||||
|
slog.Error("UDA type not found", "uda", uda)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
label := ts.config.Get(fmt.Sprintf("uda.%s.label", uda))
|
||||||
|
values := strings.Split(ts.config.Get(fmt.Sprintf("uda.%s.values", uda)), ",")
|
||||||
|
def := ts.config.Get(fmt.Sprintf("uda.%s.default", uda))
|
||||||
|
|
||||||
|
uda := Uda{
|
||||||
|
Name: uda,
|
||||||
|
Label: label,
|
||||||
|
Type: udatype,
|
||||||
|
Values: values,
|
||||||
|
Default: def,
|
||||||
|
}
|
||||||
|
|
||||||
|
udas = append(udas, uda)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return udas
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskwarriorInterop) SetContext(context *Context) error {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
if context.Name == "none" && ts.contexts["none"].Active {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, []string{"context", context.Name}...)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Error("Failed setting context", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: optimize this; there should be no need to re-extract everything
|
||||||
|
ts.config = ts.extractConfig()
|
||||||
|
ts.contexts = ts.extractContexts()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// func (ts *TaskwarriorInterop) AddTask(task *Task) error {
|
||||||
|
// ts.mutex.Lock()
|
||||||
|
// defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
// addArgs := []string{"add"}
|
||||||
|
|
||||||
|
// if task.Description == "" {
|
||||||
|
// slog.Error("Task description is required")
|
||||||
|
// return nil
|
||||||
|
// } else {
|
||||||
|
// addArgs = append(addArgs, task.Description)
|
||||||
|
// }
|
||||||
|
// if task.Priority != "" && task.Priority != "(none)" {
|
||||||
|
// addArgs = append(addArgs, fmt.Sprintf("priority:%s", task.Priority))
|
||||||
|
// }
|
||||||
|
// if task.Project != "" && task.Project != "(none)" {
|
||||||
|
// addArgs = append(addArgs, fmt.Sprintf("project:%s", task.Project))
|
||||||
|
// }
|
||||||
|
// if task.Tags != nil {
|
||||||
|
// for _, tag := range task.Tags {
|
||||||
|
// addArgs = append(addArgs, fmt.Sprintf("+%s", tag))
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// if task.Due != "" {
|
||||||
|
// addArgs = append(addArgs, fmt.Sprintf("due:%s", task.Due))
|
||||||
|
// }
|
||||||
|
|
||||||
|
// cmd := exec.Command(twBinary, append(ts.defaultArgs, addArgs...)...)
|
||||||
|
// err := cmd.Run()
|
||||||
|
// if err != nil {
|
||||||
|
// slog.Error("Failed adding task:", err)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // TODO remove error?
|
||||||
|
// return nil
|
||||||
|
// }
|
||||||
|
|
||||||
|
// TODO error handling
|
||||||
|
func (ts *TaskwarriorInterop) ImportTask(task *Task) {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
tasks, err := json.Marshal(Tasks{task})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed marshalling task", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"import", "-"}...)...)
|
||||||
|
cmd.Stdin = bytes.NewBuffer(tasks)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
if ts.ctx.Err() == context.Canceled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Error("Failed modifying task", "error", err, "output", string(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskwarriorInterop) SetTaskDone(task *Task) {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"done", task.Uuid}...)...)
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed setting task done", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskwarriorInterop) DeleteTask(task *Task) {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{task.Uuid, "delete"}...)...)
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed deleting task", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskwarriorInterop) Undo() {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed undoing task", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskwarriorInterop) StartTask(task *Task) {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"start", task.Uuid}...)...)
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed starting task", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskwarriorInterop) StopTask(task *Task) {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskwarriorInterop) 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskwarriorInterop) GetInformation(task *Task) string {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{fmt.Sprintf("uuid:%s", task.Uuid), "information"}...)...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
if ts.ctx.Err() == context.Canceled {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
slog.Error("Failed getting task information", "error", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskwarriorInterop) AddTaskAnnotation(uuid string, annotation string) {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{uuid, "annotate", annotation}...)...)
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed adding annotation", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskwarriorInterop) extractConfig() *TWConfig {
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_show"}...)...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
if ts.ctx.Err() == context.Canceled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
slog.Error("Failed getting config", "error", err, "output", string(output))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewConfig(strings.Split(string(output), "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskwarriorInterop) extractReports() Reports {
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_config"}...)...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
availableReports := extractReports(string(output))
|
||||||
|
|
||||||
|
reports := make(Reports)
|
||||||
|
|
||||||
|
for _, report := range availableReports {
|
||||||
|
if _, ok := nonStandardReports[report]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
reports[report] = &Report{
|
||||||
|
Name: report,
|
||||||
|
Description: ts.config.Get(fmt.Sprintf("report.%s.description", report)),
|
||||||
|
Labels: strings.Split(ts.config.Get(fmt.Sprintf("report.%s.labels", report)), ","),
|
||||||
|
Filter: ts.config.Get(fmt.Sprintf("report.%s.filter", report)),
|
||||||
|
Sort: ts.config.Get(fmt.Sprintf("report.%s.sort", report)),
|
||||||
|
Columns: strings.Split(ts.config.Get(fmt.Sprintf("report.%s.columns", report)), ","),
|
||||||
|
Context: ts.config.Get(fmt.Sprintf("report.%s.context", report)) == "1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reports
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractReports(config string) []string {
|
||||||
|
re := regexp.MustCompile(`report\.([^.]+)\.[^.]+`)
|
||||||
|
matches := re.FindAllStringSubmatch(config, -1)
|
||||||
|
uniques := make(map[string]struct{})
|
||||||
|
for _, match := range matches {
|
||||||
|
uniques[match[1]] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var reports []string
|
||||||
|
for part := range uniques {
|
||||||
|
reports = append(reports, part)
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(reports)
|
||||||
|
return reports
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskwarriorInterop) extractContexts() Contexts {
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_context"}...)...)
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
if ts.ctx.Err() == context.Canceled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
slog.Error("Failed getting contexts", "error", err, "output", string(output))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
activeContext := ts.config.Get("context")
|
||||||
|
if activeContext == "" {
|
||||||
|
activeContext = "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
contexts := make(Contexts)
|
||||||
|
contexts["none"] = &Context{
|
||||||
|
Name: "none",
|
||||||
|
Active: activeContext == "none",
|
||||||
|
ReadFilter: "",
|
||||||
|
WriteFilter: "",
|
||||||
|
}
|
||||||
|
for _, context := range strings.Split(string(output), "\n") {
|
||||||
|
if context == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
contexts[context] = &Context{
|
||||||
|
Name: context,
|
||||||
|
Active: activeContext == context,
|
||||||
|
ReadFilter: ts.config.Get(fmt.Sprintf("context.%s.read", context)),
|
||||||
|
WriteFilter: ts.config.Get(fmt.Sprintf("context.%s.write", context)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return contexts
|
||||||
|
}
|
||||||
@@ -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
internal/taskwarrior/tree.go
Normal file
127
internal/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
internal/taskwarrior/tree_test.go
Normal file
345
internal/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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
78
internal/timewarrior/config.go
Normal file
78
internal/timewarrior/config.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package timewarrior
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TWConfig struct {
|
||||||
|
config map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
defaultConfig = map[string]string{
|
||||||
|
"uda.timesquire.default.tag": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewConfig(config []string) *TWConfig {
|
||||||
|
cfg := parseConfig(config)
|
||||||
|
|
||||||
|
for key, value := range defaultConfig {
|
||||||
|
if _, ok := cfg[key]; !ok {
|
||||||
|
cfg[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TWConfig{
|
||||||
|
config: cfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TWConfig) GetConfig() map[string]string {
|
||||||
|
return tc.config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TWConfig) Get(key string) string {
|
||||||
|
if _, ok := tc.config[key]; !ok {
|
||||||
|
slog.Debug(fmt.Sprintf("Key not found in config: %s", key))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return tc.config[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseConfig(config []string) map[string]string {
|
||||||
|
configMap := make(map[string]string)
|
||||||
|
|
||||||
|
for _, line := range config {
|
||||||
|
// Skip empty lines and comments
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timewarrior config format: key = value or key: value
|
||||||
|
var key, value string
|
||||||
|
if strings.Contains(line, "=") {
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
key = strings.TrimSpace(parts[0])
|
||||||
|
value = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
} else if strings.Contains(line, ":") {
|
||||||
|
parts := strings.SplitN(line, ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
key = strings.TrimSpace(parts[0])
|
||||||
|
value = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if key != "" {
|
||||||
|
configMap[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return configMap
|
||||||
|
}
|
||||||
323
internal/timewarrior/models.go
Normal file
323
internal/timewarrior/models.go
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
package timewarrior
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dtformat = "20060102T150405Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Intervals []*Interval
|
||||||
|
|
||||||
|
type Interval struct {
|
||||||
|
ID int `json:"-"`
|
||||||
|
Start string `json:"start,omitempty"`
|
||||||
|
End string `json:"end,omitempty"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
IsGap bool `json:"-"` // True if this represents an untracked time gap
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInterval() *Interval {
|
||||||
|
return &Interval{
|
||||||
|
Tags: make([]string, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGapInterval creates a new gap interval representing untracked time.
|
||||||
|
// start and end are the times between which the gap occurred.
|
||||||
|
func NewGapInterval(start, end time.Time) *Interval {
|
||||||
|
return &Interval{
|
||||||
|
ID: -1, // Gap intervals have no real ID
|
||||||
|
Start: start.UTC().Format(dtformat),
|
||||||
|
End: end.UTC().Format(dtformat),
|
||||||
|
Tags: make([]string, 0),
|
||||||
|
IsGap: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Interval) GetString(field string) string {
|
||||||
|
// Special handling for gap intervals
|
||||||
|
if i.IsGap {
|
||||||
|
switch field {
|
||||||
|
case "duration":
|
||||||
|
return i.GetDuration()
|
||||||
|
case "gap_display":
|
||||||
|
return fmt.Sprintf("--- Untracked: %s ---", i.GetDuration())
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch field {
|
||||||
|
case "id":
|
||||||
|
return strconv.Itoa(i.ID)
|
||||||
|
|
||||||
|
case "start":
|
||||||
|
return formatDate(i.Start, "formatted")
|
||||||
|
|
||||||
|
case "start_time":
|
||||||
|
return formatDate(i.Start, "time")
|
||||||
|
|
||||||
|
case "end":
|
||||||
|
if i.End == "" {
|
||||||
|
return "now"
|
||||||
|
}
|
||||||
|
return formatDate(i.End, "formatted")
|
||||||
|
|
||||||
|
case "end_time":
|
||||||
|
if i.End == "" {
|
||||||
|
return "now"
|
||||||
|
}
|
||||||
|
return formatDate(i.End, "time")
|
||||||
|
|
||||||
|
case "weekday":
|
||||||
|
return formatDate(i.Start, "weekday")
|
||||||
|
|
||||||
|
case "tags":
|
||||||
|
if len(i.Tags) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// 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":
|
||||||
|
return i.GetDuration()
|
||||||
|
|
||||||
|
case "active":
|
||||||
|
if i.End == "" {
|
||||||
|
return "●"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
|
||||||
|
default:
|
||||||
|
slog.Error("Field not implemented", "field", field)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Interval) GetDuration() string {
|
||||||
|
start, err := time.Parse(dtformat, i.Start)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to parse start time", "error", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var end time.Time
|
||||||
|
if i.End == "" {
|
||||||
|
end = time.Now()
|
||||||
|
} else {
|
||||||
|
end, err = time.Parse(dtformat, i.End)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to parse end time", "error", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
duration := end.Sub(start)
|
||||||
|
return formatDuration(duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Interval) GetStartTime() time.Time {
|
||||||
|
dt, err := time.Parse(dtformat, i.Start)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to parse time", "error", err)
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return dt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Interval) GetEndTime() time.Time {
|
||||||
|
if i.End == "" {
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
dt, err := time.Parse(dtformat, i.End)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to parse time", "error", err)
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return dt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Interval) HasTag(tag string) bool {
|
||||||
|
for _, t := range i.Tags {
|
||||||
|
if t == tag {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Interval) AddTag(tag string) {
|
||||||
|
if !i.HasTag(tag) {
|
||||||
|
i.Tags = append(i.Tags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Interval) RemoveTag(tag string) {
|
||||||
|
for idx, t := range i.Tags {
|
||||||
|
if t == tag {
|
||||||
|
i.Tags = append(i.Tags[:idx], i.Tags[idx+1:]...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Interval) IsActive() bool {
|
||||||
|
return i.End == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDate(date string, format string) string {
|
||||||
|
if date == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
dt, err := time.Parse(dtformat, date)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to parse time", "error", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
dt = dt.Local()
|
||||||
|
|
||||||
|
switch format {
|
||||||
|
case "formatted", "":
|
||||||
|
return dt.Format("2006-01-02 15:04")
|
||||||
|
case "time":
|
||||||
|
return dt.Format("15:04")
|
||||||
|
case "date":
|
||||||
|
return dt.Format("2006-01-02")
|
||||||
|
case "weekday":
|
||||||
|
return dt.Format("Mon")
|
||||||
|
case "iso":
|
||||||
|
return dt.Format("2006-01-02T150405Z")
|
||||||
|
case "epoch":
|
||||||
|
return strconv.FormatInt(dt.Unix(), 10)
|
||||||
|
case "age":
|
||||||
|
return parseDurationVague(time.Since(dt))
|
||||||
|
case "relative":
|
||||||
|
return parseDurationVague(time.Until(dt))
|
||||||
|
default:
|
||||||
|
slog.Error("Date format not implemented", "format", format)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDuration(d time.Duration) string {
|
||||||
|
hours := int(d.Hours())
|
||||||
|
minutes := int(d.Minutes()) % 60
|
||||||
|
seconds := int(d.Seconds()) % 60
|
||||||
|
|
||||||
|
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDurationVague(d time.Duration) string {
|
||||||
|
dur := d.Round(time.Second).Abs()
|
||||||
|
days := dur.Hours() / 24
|
||||||
|
|
||||||
|
var formatted string
|
||||||
|
if dur >= time.Hour*24*365 {
|
||||||
|
formatted = fmt.Sprintf("%.1fy", days/365)
|
||||||
|
} else if dur >= time.Hour*24*90 {
|
||||||
|
formatted = strconv.Itoa(int(math.Round(days/30))) + "mo"
|
||||||
|
} else if dur >= time.Hour*24*7 {
|
||||||
|
formatted = strconv.Itoa(int(math.Round(days/7))) + "w"
|
||||||
|
} else if dur >= time.Hour*24 {
|
||||||
|
formatted = strconv.Itoa(int(days)) + "d"
|
||||||
|
} else if dur >= time.Hour {
|
||||||
|
formatted = strconv.Itoa(int(dur.Round(time.Hour).Hours())) + "h"
|
||||||
|
} else if dur >= time.Minute {
|
||||||
|
formatted = strconv.Itoa(int(dur.Round(time.Minute).Minutes())) + "min"
|
||||||
|
} else if dur >= time.Second {
|
||||||
|
formatted = strconv.Itoa(int(dur.Round(time.Second).Seconds())) + "s"
|
||||||
|
}
|
||||||
|
|
||||||
|
if d < 0 {
|
||||||
|
formatted = "-" + formatted
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatted
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
dateFormats = []string{
|
||||||
|
"2006-01-02",
|
||||||
|
"2006-01-02T15:04",
|
||||||
|
"20060102T150405Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
specialDateFormats = []string{
|
||||||
|
"",
|
||||||
|
"now",
|
||||||
|
"today",
|
||||||
|
"yesterday",
|
||||||
|
"tomorrow",
|
||||||
|
"monday",
|
||||||
|
"tuesday",
|
||||||
|
"wednesday",
|
||||||
|
"thursday",
|
||||||
|
"friday",
|
||||||
|
"saturday",
|
||||||
|
"sunday",
|
||||||
|
"mon",
|
||||||
|
"tue",
|
||||||
|
"wed",
|
||||||
|
"thu",
|
||||||
|
"fri",
|
||||||
|
"sat",
|
||||||
|
"sun",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func ValidateDate(s string) error {
|
||||||
|
for _, f := range dateFormats {
|
||||||
|
if _, err := time.Parse(f, s); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range specialDateFormats {
|
||||||
|
if s == f {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("invalid date")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateDuration(s string) error {
|
||||||
|
// TODO: implement proper duration validation
|
||||||
|
// Should accept formats like: 1h, 30m, 1h30m, etc.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary represents time tracking summary data
|
||||||
|
type Summary struct {
|
||||||
|
Range string
|
||||||
|
TotalTime time.Duration
|
||||||
|
ByTag map[string]time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Summary) GetTotalString() string {
|
||||||
|
return formatDuration(s.TotalTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Summary) GetTagTime(tag string) string {
|
||||||
|
if duration, ok := s.ByTag[tag]; ok {
|
||||||
|
return formatDuration(duration)
|
||||||
|
}
|
||||||
|
return "0:00"
|
||||||
|
}
|
||||||
54
internal/timewarrior/tags.go
Normal file
54
internal/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 ""
|
||||||
|
}
|
||||||
411
internal/timewarrior/timewarrior.go
Normal file
411
internal/timewarrior/timewarrior.go
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
// TODO: error handling
|
||||||
|
// TODO: split combinedOutput and handle stderr differently
|
||||||
|
package timewarrior
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
twBinary = "timew"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TimeWarrior interface {
|
||||||
|
GetConfig() *TWConfig
|
||||||
|
|
||||||
|
GetTags() []string
|
||||||
|
GetTagCombinations() []string
|
||||||
|
|
||||||
|
GetIntervals(filter ...string) Intervals
|
||||||
|
StartTracking(tags []string) error
|
||||||
|
StopTracking() error
|
||||||
|
ContinueTracking() error
|
||||||
|
ContinueInterval(id int) error
|
||||||
|
CancelTracking() error
|
||||||
|
DeleteInterval(id int) error
|
||||||
|
FillInterval(id int) error
|
||||||
|
JoinInterval(id int) error
|
||||||
|
ModifyInterval(interval *Interval, adjust bool) error
|
||||||
|
GetSummary(filter ...string) string
|
||||||
|
GetActive() *Interval
|
||||||
|
|
||||||
|
Undo()
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimewarriorInterop struct {
|
||||||
|
configLocation string
|
||||||
|
defaultArgs []string
|
||||||
|
config *TWConfig
|
||||||
|
ctx context.Context
|
||||||
|
|
||||||
|
mutex sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTimewarriorInterop(ctx context.Context, configLocation string) *TimewarriorInterop {
|
||||||
|
if _, err := exec.LookPath(twBinary); err != nil {
|
||||||
|
slog.Error("Timewarrior not found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := &TimewarriorInterop{
|
||||||
|
configLocation: configLocation,
|
||||||
|
defaultArgs: []string{},
|
||||||
|
ctx: ctx,
|
||||||
|
mutex: sync.Mutex{},
|
||||||
|
}
|
||||||
|
ts.config = ts.extractConfig()
|
||||||
|
|
||||||
|
return ts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimewarriorInterop) GetConfig() *TWConfig {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
return ts.config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimewarriorInterop) GetTags() []string {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"tags"}...)...)
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed getting tags", "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tags := make([]string, 0)
|
||||||
|
lines := strings.Split(string(output), "\n")
|
||||||
|
|
||||||
|
// Skip header lines and parse tag names
|
||||||
|
for i, line := range lines {
|
||||||
|
if i < 3 || line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Tags are space-separated, first column is the tag name
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) > 0 {
|
||||||
|
tags = append(tags, fields[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(tags)
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTagCombinations returns unique tag combinations from intervals,
|
||||||
|
// ordered newest first (most recent intervals' tags appear first).
|
||||||
|
// Returns formatted strings like "dev client-work meeting".
|
||||||
|
func (ts *TimewarriorInterop) GetTagCombinations() []string {
|
||||||
|
intervals := ts.GetIntervals() // Already sorted newest first
|
||||||
|
|
||||||
|
// Track unique combinations while preserving order
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var combinations []string
|
||||||
|
|
||||||
|
for _, interval := range intervals {
|
||||||
|
if len(interval.Tags) == 0 {
|
||||||
|
continue // Skip intervals with no tags
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format tags (handles spaces with quotes)
|
||||||
|
combo := formatTagsForCombination(interval.Tags)
|
||||||
|
|
||||||
|
if !seen[combo] {
|
||||||
|
seen[combo] = true
|
||||||
|
combinations = append(combinations, combo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return combinations
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatTagsForCombination formats tags consistently for display
|
||||||
|
func formatTagsForCombination(tags []string) string {
|
||||||
|
var formatted []string
|
||||||
|
for _, t := range tags {
|
||||||
|
if strings.Contains(t, " ") {
|
||||||
|
formatted = append(formatted, "\""+t+"\"")
|
||||||
|
} else {
|
||||||
|
formatted = append(formatted, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(formatted, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// getIntervalsUnlocked fetches intervals without acquiring mutex (for internal use only).
|
||||||
|
// Caller must hold ts.mutex.
|
||||||
|
func (ts *TimewarriorInterop) getIntervalsUnlocked(filter ...string) Intervals {
|
||||||
|
args := append(ts.defaultArgs, "export")
|
||||||
|
if filter != nil {
|
||||||
|
args = append(args, filter...)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
if ts.ctx.Err() == context.Canceled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
slog.Error("Failed getting intervals", "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
intervals := make(Intervals, 0)
|
||||||
|
err = json.Unmarshal(output, &intervals)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed unmarshalling intervals", "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse the intervals to show newest first
|
||||||
|
slices.Reverse(intervals)
|
||||||
|
|
||||||
|
// Assign IDs based on new order (newest is @1)
|
||||||
|
for i := range intervals {
|
||||||
|
intervals[i].ID = i + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return intervals
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIntervals fetches intervals with the given filter, acquiring mutex lock.
|
||||||
|
func (ts *TimewarriorInterop) GetIntervals(filter ...string) Intervals {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
return ts.getIntervalsUnlocked(filter...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimewarriorInterop) StartTracking(tags []string) error {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
if len(tags) == 0 {
|
||||||
|
return fmt.Errorf("at least one tag is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
args := append(ts.defaultArgs, "start")
|
||||||
|
args = append(args, tags...)
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Error("Failed starting tracking", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimewarriorInterop) StopTracking() error {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "stop")...)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Error("Failed stopping tracking", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimewarriorInterop) ContinueTracking() error {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "continue")...)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Error("Failed continuing tracking", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimewarriorInterop) ContinueInterval(id int) error {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"continue", fmt.Sprintf("@%d", id)}...)...)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Error("Failed continuing interval", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimewarriorInterop) CancelTracking() error {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "cancel")...)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Error("Failed canceling tracking", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimewarriorInterop) DeleteInterval(id int) error {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"delete", fmt.Sprintf("@%d", id)}...)...)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Error("Failed deleting interval", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimewarriorInterop) FillInterval(id int) error {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"fill", fmt.Sprintf("@%d", id)}...)...)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Error("Failed filling interval", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimewarriorInterop) JoinInterval(id int) error {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
// Join the current interval with the previous one
|
||||||
|
// The previous interval has id+1 (since intervals are ordered newest first)
|
||||||
|
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 {
|
||||||
|
slog.Error("Failed joining interval", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimewarriorInterop) ModifyInterval(interval *Interval, adjust bool) error {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
// Export the modified interval
|
||||||
|
intervals, err := json.Marshal(Intervals{interval})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed marshalling interval", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build import command with optional :adjust hint
|
||||||
|
args := append(ts.defaultArgs, "import")
|
||||||
|
if adjust {
|
||||||
|
args = append(args, ":adjust")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import the modified interval
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
|
||||||
|
cmd.Stdin = bytes.NewBuffer(intervals)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed modifying interval", "error", err, "output", string(out))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimewarriorInterop) GetSummary(filter ...string) string {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
args := append(ts.defaultArgs, "summary")
|
||||||
|
if filter != nil {
|
||||||
|
args = append(args, filter...)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed getting summary", "error", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimewarriorInterop) GetActive() *Interval {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"get", "dom.active"}...)...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil || string(output) == "0\n" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the active interval using unlocked version (we already hold the mutex)
|
||||||
|
intervals := ts.getIntervalsUnlocked()
|
||||||
|
for _, interval := range intervals {
|
||||||
|
if interval.End == "" {
|
||||||
|
return interval
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimewarriorInterop) Undo() {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed undoing", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimewarriorInterop) extractConfig() *TWConfig {
|
||||||
|
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"show"}...)...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed getting config", "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewConfig(strings.Split(string(output), "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractTags(config string) []string {
|
||||||
|
re := regexp.MustCompile(`tag\.([^.]+)\.[^.]+`)
|
||||||
|
matches := re.FindAllStringSubmatch(config, -1)
|
||||||
|
uniques := make(map[string]struct{})
|
||||||
|
for _, match := range matches {
|
||||||
|
uniques[match[1]] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tags []string
|
||||||
|
for tag := range uniques {
|
||||||
|
tags = append(tags, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(tags)
|
||||||
|
return tags
|
||||||
|
}
|
||||||
56
main.go
56
main.go
@@ -1,56 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"tasksquire/common"
|
|
||||||
"tasksquire/pages"
|
|
||||||
"tasksquire/taskwarrior"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
ts := taskwarrior.NewTaskSquire("./test/taskrc")
|
|
||||||
ctx := context.Background()
|
|
||||||
common := common.NewCommon(ctx, ts)
|
|
||||||
|
|
||||||
file, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("failed to open log file: %v", err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
// Create a new slog handler for the file
|
|
||||||
handler := slog.NewTextHandler(file, &slog.HandlerOptions{})
|
|
||||||
|
|
||||||
// Set the default logger to use the file handler
|
|
||||||
slog.SetDefault(slog.New(handler))
|
|
||||||
|
|
||||||
// form := huh.NewForm(
|
|
||||||
// huh.NewGroup(
|
|
||||||
// huh.NewSelect[string]().
|
|
||||||
// Options(huh.NewOptions(config.Reports...)...).
|
|
||||||
// Title("Report").
|
|
||||||
// Description("Choose the report to display").
|
|
||||||
// Value(&report),
|
|
||||||
// ),
|
|
||||||
// )
|
|
||||||
|
|
||||||
// err = form.Run()
|
|
||||||
// if err != nil {
|
|
||||||
// slog.Error("Uh oh:", err)
|
|
||||||
// os.Exit(1)
|
|
||||||
// }
|
|
||||||
m := pages.NewMainPage(common)
|
|
||||||
|
|
||||||
if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil {
|
|
||||||
fmt.Println("Error running program:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
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" "$@"
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"slices"
|
|
||||||
"tasksquire/common"
|
|
||||||
"tasksquire/taskwarrior"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ContextPickerPage struct {
|
|
||||||
common *common.Common
|
|
||||||
contexts taskwarrior.Contexts
|
|
||||||
form *huh.Form
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewContextPickerPage(common *common.Common) *ContextPickerPage {
|
|
||||||
p := &ContextPickerPage{
|
|
||||||
common: common,
|
|
||||||
contexts: common.TW.GetContexts(),
|
|
||||||
}
|
|
||||||
|
|
||||||
selected := common.TW.GetActiveContext().Name
|
|
||||||
options := make([]string, 0)
|
|
||||||
for _, c := range p.contexts {
|
|
||||||
if c.Name != "none" {
|
|
||||||
options = append(options, c.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slices.Sort(options)
|
|
||||||
options = append([]string{"(none)"}, options...)
|
|
||||||
|
|
||||||
p.form = huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewSelect[string]().
|
|
||||||
Key("context").
|
|
||||||
Options(huh.NewOptions(options...)...).
|
|
||||||
Title("Contexts").
|
|
||||||
Description("Choose a context").
|
|
||||||
Value(&selected),
|
|
||||||
),
|
|
||||||
).
|
|
||||||
WithShowHelp(false).
|
|
||||||
WithShowErrors(true).
|
|
||||||
WithTheme(p.common.Styles.Form)
|
|
||||||
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ContextPickerPage) SetSize(width, height int) {
|
|
||||||
p.common.SetSize(width, height)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ContextPickerPage) Init() tea.Cmd {
|
|
||||||
return p.form.Init()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ContextPickerPage) 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 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
f, cmd := p.form.Update(msg)
|
|
||||||
if f, ok := f.(*huh.Form); ok {
|
|
||||||
p.form = f
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.form.State == huh.StateCompleted {
|
|
||||||
cmds = append(cmds, p.updateContextCmd)
|
|
||||||
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 *ContextPickerPage) View() string {
|
|
||||||
return p.common.Styles.Base.Render(p.form.View())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ContextPickerPage) updateContextCmd() tea.Msg {
|
|
||||||
context := p.form.GetString("context")
|
|
||||||
if context == "(none)" {
|
|
||||||
context = ""
|
|
||||||
}
|
|
||||||
return UpdateContextMsg(p.common.TW.GetContext(context))
|
|
||||||
}
|
|
||||||
|
|
||||||
type UpdateContextMsg *taskwarrior.Context
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
// import (
|
|
||||||
// "tasksquire/common"
|
|
||||||
|
|
||||||
// "github.com/charmbracelet/bubbles/textinput"
|
|
||||||
// tea "github.com/charmbracelet/bubbletea"
|
|
||||||
// "github.com/charmbracelet/lipgloss"
|
|
||||||
// datepicker "github.com/ethanefung/bubble-datepicker"
|
|
||||||
// )
|
|
||||||
|
|
||||||
// type Model struct {
|
|
||||||
// focus focus
|
|
||||||
// input textinput.Model
|
|
||||||
// datepicker datepicker.Model
|
|
||||||
// }
|
|
||||||
|
|
||||||
// var inputStyles = lipgloss.NewStyle().Padding(1, 1, 0)
|
|
||||||
|
|
||||||
// func initializeModel() tea.Model {
|
|
||||||
// dp := datepicker.New(time.Now())
|
|
||||||
|
|
||||||
// input := textinput.New()
|
|
||||||
// input.Placeholder = "YYYY-MM-DD (enter date)"
|
|
||||||
// input.Focus()
|
|
||||||
// input.Width = 20
|
|
||||||
|
|
||||||
// return Model{
|
|
||||||
// focus: FocusInput,
|
|
||||||
// input: input,
|
|
||||||
// datepicker: dp,
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (m Model) Init() tea.Cmd {
|
|
||||||
// return textinput.Blink
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
// var cmd tea.Cmd
|
|
||||||
|
|
||||||
// switch msg := msg.(type) {
|
|
||||||
// case tea.WindowSizeMsg:
|
|
||||||
// // TODO figure out how we want to size things
|
|
||||||
// // we'll probably want both bubbles to be vertically stacked
|
|
||||||
// // and to take as much room as the can
|
|
||||||
// return m, nil
|
|
||||||
// case tea.KeyMsg:
|
|
||||||
// switch msg.String() {
|
|
||||||
// case "ctrl+c", "q":
|
|
||||||
// return m, tea.Quit
|
|
||||||
// case "tab":
|
|
||||||
// if m.focus == FocusInput {
|
|
||||||
// m.focus = FocusDatePicker
|
|
||||||
// m.input.Blur()
|
|
||||||
// m.input.SetValue(m.datepicker.Time.Format(time.DateOnly))
|
|
||||||
|
|
||||||
// m.datepicker.SelectDate()
|
|
||||||
// m.datepicker.SetFocus(datepicker.FocusHeaderMonth)
|
|
||||||
// m.datepicker = m.datepicker
|
|
||||||
// return m, nil
|
|
||||||
|
|
||||||
// }
|
|
||||||
// case "shift+tab":
|
|
||||||
// if m.focus == FocusDatePicker && m.datepicker.Focused == datepicker.FocusHeaderMonth {
|
|
||||||
// m.focus = FocusInput
|
|
||||||
// m.datepicker.Blur()
|
|
||||||
|
|
||||||
// m.input.Focus()
|
|
||||||
// return m, nil
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// switch m.focus {
|
|
||||||
// case FocusInput:
|
|
||||||
// m.input, cmd = m.UpdateInput(msg)
|
|
||||||
// case FocusDatePicker:
|
|
||||||
// m.datepicker, cmd = m.UpdateDatepicker(msg)
|
|
||||||
// case FocusNone:
|
|
||||||
// // do nothing
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return m, cmd
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (m Model) View() string {
|
|
||||||
// return lipgloss.JoinVertical(lipgloss.Left, inputStyles.Render(m.input.View()), m.datepicker.View())
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (m *Model) UpdateInput(msg tea.Msg) (textinput.Model, tea.Cmd) {
|
|
||||||
// var cmd tea.Cmd
|
|
||||||
|
|
||||||
// m.input, cmd = m.input.Update(msg)
|
|
||||||
|
|
||||||
// val := m.input.Value()
|
|
||||||
// t, err := time.Parse(time.DateOnly, strings.TrimSpace(val))
|
|
||||||
// if err == nil {
|
|
||||||
// m.datepicker.SetTime(t)
|
|
||||||
// m.datepicker.SelectDate()
|
|
||||||
// m.datepicker.Blur()
|
|
||||||
// }
|
|
||||||
// if err != nil && m.datepicker.Selected {
|
|
||||||
// m.datepicker.UnselectDate()
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return m.input, cmd
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (m *Model) UpdateDatepicker(msg tea.Msg) (datepicker.Model, tea.Cmd) {
|
|
||||||
// var cmd tea.Cmd
|
|
||||||
|
|
||||||
// prev := m.datepicker.Time
|
|
||||||
|
|
||||||
// m.datepicker, cmd = m.datepicker.Update(msg)
|
|
||||||
|
|
||||||
// if prev != m.datepicker.Time {
|
|
||||||
// m.input.SetValue(m.datepicker.Time.Format(time.DateOnly))
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return m.datepicker, cmd
|
|
||||||
// }
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
|
|
||||||
"tasksquire/common"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MainPage struct {
|
|
||||||
common *common.Common
|
|
||||||
activePage common.Component
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewMainPage(common *common.Common) *MainPage {
|
|
||||||
m := &MainPage{
|
|
||||||
common: common,
|
|
||||||
}
|
|
||||||
|
|
||||||
m.activePage = NewReportPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default")))
|
|
||||||
// m.activePage = NewTaskEditorPage(common, taskwarrior.Task{})
|
|
||||||
|
|
||||||
return m
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MainPage) Init() tea.Cmd {
|
|
||||||
return m.activePage.Init()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
var cmd tea.Cmd
|
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
m.common.SetSize(msg.Width, msg.Height)
|
|
||||||
}
|
|
||||||
|
|
||||||
activePage, cmd := m.activePage.Update(msg)
|
|
||||||
m.activePage = activePage.(common.Component)
|
|
||||||
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MainPage) View() string {
|
|
||||||
return m.activePage.View()
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import tea "github.com/charmbracelet/bubbletea"
|
|
||||||
|
|
||||||
type UpdatedTasksMsg struct{}
|
|
||||||
|
|
||||||
type nextColumnMsg struct{}
|
|
||||||
|
|
||||||
func nextColumn() tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
return nextColumnMsg{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type prevColumnMsg struct{}
|
|
||||||
|
|
||||||
func prevColumn() tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
return prevColumnMsg{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type nextFieldMsg struct{}
|
|
||||||
|
|
||||||
func nextField() tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
return nextFieldMsg{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type prevFieldMsg struct{}
|
|
||||||
|
|
||||||
func prevField() tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
return prevFieldMsg{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type nextAreaMsg struct{}
|
|
||||||
|
|
||||||
func nextArea() tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
return nextAreaMsg{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type prevAreaMsg struct{}
|
|
||||||
|
|
||||||
func prevArea() tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
return prevAreaMsg{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type changeAreaMsg area
|
|
||||||
|
|
||||||
func changeArea(a area) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
return changeAreaMsg(a)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func changeMode(mode mode) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
return changeModeMsg(mode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type changeModeMsg mode
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
func BackCmd() tea.Msg {
|
|
||||||
return BackMsg{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type BackMsg struct{}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"tasksquire/common"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ProjectPickerPage struct {
|
|
||||||
common *common.Common
|
|
||||||
form *huh.Form
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectPickerPage {
|
|
||||||
p := &ProjectPickerPage{
|
|
||||||
common: common,
|
|
||||||
}
|
|
||||||
|
|
||||||
var selected string
|
|
||||||
if activeProject == "" {
|
|
||||||
selected = "(none)"
|
|
||||||
} else {
|
|
||||||
selected = activeProject
|
|
||||||
}
|
|
||||||
|
|
||||||
projects := common.TW.GetProjects()
|
|
||||||
options := []string{"(none)"}
|
|
||||||
options = append(options, projects...)
|
|
||||||
|
|
||||||
p.form = huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewSelect[string]().
|
|
||||||
Key("project").
|
|
||||||
Options(huh.NewOptions(options...)...).
|
|
||||||
Title("Projects").
|
|
||||||
Description("Choose a project").
|
|
||||||
Value(&selected),
|
|
||||||
),
|
|
||||||
).
|
|
||||||
WithShowHelp(false).
|
|
||||||
WithShowErrors(false)
|
|
||||||
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProjectPickerPage) SetSize(width, height int) {
|
|
||||||
p.common.SetSize(width, height)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProjectPickerPage) Init() tea.Cmd {
|
|
||||||
return p.form.Init()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProjectPickerPage) 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 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
f, cmd := p.form.Update(msg)
|
|
||||||
if f, ok := f.(*huh.Form); ok {
|
|
||||||
p.form = f
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.form.State == huh.StateCompleted {
|
|
||||||
cmds = append(cmds, p.updateProjectCmd)
|
|
||||||
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 *ProjectPickerPage) View() string {
|
|
||||||
return p.common.Styles.Base.Render(p.form.View())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProjectPickerPage) updateProjectCmd() tea.Msg {
|
|
||||||
project := p.form.GetString("project")
|
|
||||||
if project == "(none)" {
|
|
||||||
project = ""
|
|
||||||
}
|
|
||||||
return UpdateProjectMsg(project)
|
|
||||||
}
|
|
||||||
|
|
||||||
type UpdateProjectMsg string
|
|
||||||
218
pages/report.go
218
pages/report.go
@@ -1,218 +0,0 @@
|
|||||||
// TODO: update table every second (to show correct relative time)
|
|
||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"tasksquire/common"
|
|
||||||
"tasksquire/components/table"
|
|
||||||
"tasksquire/taskwarrior"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
|
||||||
// "github.com/charmbracelet/bubbles/table"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ReportPage struct {
|
|
||||||
common *common.Common
|
|
||||||
|
|
||||||
activeReport *taskwarrior.Report
|
|
||||||
activeContext *taskwarrior.Context
|
|
||||||
activeProject string
|
|
||||||
selectedTask *taskwarrior.Task
|
|
||||||
taskCursor int
|
|
||||||
|
|
||||||
tasks taskwarrior.Tasks
|
|
||||||
|
|
||||||
taskTable table.Model
|
|
||||||
|
|
||||||
subpage tea.Model
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
|
|
||||||
// return &ReportPage{
|
|
||||||
// common: com,
|
|
||||||
// activeReport: report,
|
|
||||||
// activeContext: com.TW.GetActiveContext(),
|
|
||||||
// activeProject: "",
|
|
||||||
// taskTable: table.New(com),
|
|
||||||
// }
|
|
||||||
|
|
||||||
p := &ReportPage{
|
|
||||||
common: com,
|
|
||||||
activeReport: report,
|
|
||||||
activeContext: com.TW.GetActiveContext(),
|
|
||||||
activeProject: "",
|
|
||||||
taskTable: table.New(com),
|
|
||||||
}
|
|
||||||
|
|
||||||
p.subpage = NewTaskEditorPage(p.common, taskwarrior.Task{})
|
|
||||||
p.subpage.Init()
|
|
||||||
p.common.PushPage(p)
|
|
||||||
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ReportPage) SetSize(width int, height int) {
|
|
||||||
p.common.SetSize(width, height)
|
|
||||||
|
|
||||||
p.taskTable.SetWidth(width - p.common.Styles.Base.GetVerticalFrameSize())
|
|
||||||
p.taskTable.SetHeight(height - p.common.Styles.Base.GetHorizontalFrameSize())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ReportPage) Init() tea.Cmd {
|
|
||||||
return p.getTasks()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ReportPage) 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 BackMsg:
|
|
||||||
case TaskMsg:
|
|
||||||
p.tasks = taskwarrior.Tasks(msg)
|
|
||||||
p.populateTaskTable(p.tasks)
|
|
||||||
case UpdateReportMsg:
|
|
||||||
p.activeReport = msg
|
|
||||||
cmds = append(cmds, p.getTasks())
|
|
||||||
case UpdateContextMsg:
|
|
||||||
p.activeContext = msg
|
|
||||||
p.common.TW.SetContext(msg)
|
|
||||||
cmds = append(cmds, p.getTasks())
|
|
||||||
case UpdateProjectMsg:
|
|
||||||
p.activeProject = string(msg)
|
|
||||||
cmds = append(cmds, p.getTasks())
|
|
||||||
case UpdatedTasksMsg:
|
|
||||||
cmds = append(cmds, p.getTasks())
|
|
||||||
case tea.KeyMsg:
|
|
||||||
switch {
|
|
||||||
case key.Matches(msg, p.common.Keymap.Quit):
|
|
||||||
return p, tea.Quit
|
|
||||||
case key.Matches(msg, p.common.Keymap.SetReport):
|
|
||||||
p.subpage = NewReportPickerPage(p.common, p.activeReport)
|
|
||||||
p.subpage.Init()
|
|
||||||
p.common.PushPage(p)
|
|
||||||
return p.subpage, nil
|
|
||||||
case key.Matches(msg, p.common.Keymap.SetContext):
|
|
||||||
p.subpage = NewContextPickerPage(p.common)
|
|
||||||
p.subpage.Init()
|
|
||||||
p.common.PushPage(p)
|
|
||||||
return p.subpage, nil
|
|
||||||
case key.Matches(msg, p.common.Keymap.Add):
|
|
||||||
p.subpage = NewTaskEditorPage(p.common, taskwarrior.Task{})
|
|
||||||
p.subpage.Init()
|
|
||||||
p.common.PushPage(p)
|
|
||||||
return p.subpage, nil
|
|
||||||
case key.Matches(msg, p.common.Keymap.Edit):
|
|
||||||
p.subpage = NewTaskEditorPage(p.common, *p.selectedTask)
|
|
||||||
p.subpage.Init()
|
|
||||||
p.common.PushPage(p)
|
|
||||||
return p.subpage, nil
|
|
||||||
case key.Matches(msg, p.common.Keymap.Ok):
|
|
||||||
p.common.TW.SetTaskDone(p.selectedTask)
|
|
||||||
return p, p.getTasks()
|
|
||||||
case key.Matches(msg, p.common.Keymap.Delete):
|
|
||||||
p.common.TW.DeleteTask(p.selectedTask)
|
|
||||||
return p, p.getTasks()
|
|
||||||
case key.Matches(msg, p.common.Keymap.SetProject):
|
|
||||||
p.subpage = NewProjectPickerPage(p.common, p.activeProject)
|
|
||||||
p.subpage.Init()
|
|
||||||
p.common.PushPage(p)
|
|
||||||
return p.subpage, nil
|
|
||||||
case key.Matches(msg, p.common.Keymap.Tag):
|
|
||||||
if p.selectedTask != nil {
|
|
||||||
tag := p.common.TW.GetConfig().Get("uda.tasksquire.tag.default")
|
|
||||||
if p.selectedTask.HasTag(tag) {
|
|
||||||
p.selectedTask.RemoveTag(tag)
|
|
||||||
} else {
|
|
||||||
p.selectedTask.AddTag(tag)
|
|
||||||
}
|
|
||||||
p.common.TW.ImportTask(p.selectedTask)
|
|
||||||
return p, p.getTasks()
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
case key.Matches(msg, p.common.Keymap.Undo):
|
|
||||||
p.common.TW.Undo()
|
|
||||||
return p, p.getTasks()
|
|
||||||
case key.Matches(msg, p.common.Keymap.StartStop):
|
|
||||||
if p.selectedTask != nil && p.selectedTask.Status == "pending" {
|
|
||||||
if p.selectedTask.Start == "" {
|
|
||||||
p.common.TW.StartTask(p.selectedTask)
|
|
||||||
} else {
|
|
||||||
p.common.TW.StopTask(p.selectedTask)
|
|
||||||
}
|
|
||||||
return p, p.getTasks()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var cmd tea.Cmd
|
|
||||||
p.taskTable, cmd = p.taskTable.Update(msg)
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
|
|
||||||
if p.tasks != nil && len(p.tasks) > 0 {
|
|
||||||
p.selectedTask = p.tasks[p.taskTable.Cursor()]
|
|
||||||
} else {
|
|
||||||
p.selectedTask = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return p, tea.Batch(cmds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ReportPage) View() string {
|
|
||||||
// return p.common.Styles.Main.Render(p.taskTable.View()) + "\n"
|
|
||||||
if p.tasks == nil || len(p.tasks) == 0 {
|
|
||||||
return p.common.Styles.Base.Render("No tasks found")
|
|
||||||
}
|
|
||||||
return p.taskTable.View()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) {
|
|
||||||
if len(tasks) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
selected := p.taskTable.Cursor()
|
|
||||||
|
|
||||||
if p.selectedTask != nil {
|
|
||||||
for i, task := range tasks {
|
|
||||||
if task.Uuid == p.selectedTask.Uuid {
|
|
||||||
selected = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if selected > len(tasks)-1 {
|
|
||||||
selected = len(tasks) - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
p.taskTable = table.New(
|
|
||||||
p.common,
|
|
||||||
table.WithReport(p.activeReport),
|
|
||||||
table.WithTasks(tasks),
|
|
||||||
table.WithFocused(true),
|
|
||||||
table.WithWidth(p.common.Width()-p.common.Styles.Base.GetVerticalFrameSize()),
|
|
||||||
table.WithHeight(p.common.Height()-p.common.Styles.Base.GetHorizontalFrameSize()-1),
|
|
||||||
table.WithStyles(p.common.Styles.TableStyle),
|
|
||||||
)
|
|
||||||
|
|
||||||
if selected == 0 {
|
|
||||||
selected = p.taskTable.Cursor()
|
|
||||||
}
|
|
||||||
if selected < len(tasks) {
|
|
||||||
p.taskTable.SetCursor(selected)
|
|
||||||
} else {
|
|
||||||
p.taskTable.SetCursor(len(p.tasks) - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ReportPage) getTasks() tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
filters := []string{}
|
|
||||||
if p.activeProject != "" {
|
|
||||||
filters = append(filters, "project:"+p.activeProject)
|
|
||||||
}
|
|
||||||
tasks := p.common.TW.GetTasks(p.activeReport, filters...)
|
|
||||||
return TaskMsg(tasks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type TaskMsg taskwarrior.Tasks
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"slices"
|
|
||||||
"tasksquire/common"
|
|
||||||
"tasksquire/taskwarrior"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ReportPickerPage struct {
|
|
||||||
common *common.Common
|
|
||||||
reports taskwarrior.Reports
|
|
||||||
form *huh.Form
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report) *ReportPickerPage {
|
|
||||||
p := &ReportPickerPage{
|
|
||||||
common: common,
|
|
||||||
reports: common.TW.GetReports(),
|
|
||||||
}
|
|
||||||
|
|
||||||
selected := activeReport.Name
|
|
||||||
|
|
||||||
options := make([]string, 0)
|
|
||||||
for _, r := range p.reports {
|
|
||||||
options = append(options, r.Name)
|
|
||||||
}
|
|
||||||
slices.Sort(options)
|
|
||||||
|
|
||||||
p.form = huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewSelect[string]().
|
|
||||||
Key("report").
|
|
||||||
Options(huh.NewOptions(options...)...).
|
|
||||||
Title("Reports").
|
|
||||||
Description("Choose a report").
|
|
||||||
Value(&selected),
|
|
||||||
),
|
|
||||||
).
|
|
||||||
WithShowHelp(false).
|
|
||||||
WithShowErrors(false)
|
|
||||||
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ReportPickerPage) SetSize(width, height int) {
|
|
||||||
p.common.SetSize(width, height)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ReportPickerPage) Init() tea.Cmd {
|
|
||||||
return p.form.Init()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ReportPickerPage) 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 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
f, cmd := p.form.Update(msg)
|
|
||||||
if f, ok := f.(*huh.Form); ok {
|
|
||||||
p.form = f
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.form.State == huh.StateCompleted {
|
|
||||||
cmds = append(cmds, []tea.Cmd{BackCmd, p.updateReportCmd}...)
|
|
||||||
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 *ReportPickerPage) View() string {
|
|
||||||
return p.common.Styles.Base.Render(p.form.View())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ReportPickerPage) updateReportCmd() tea.Msg {
|
|
||||||
return UpdateReportMsg(p.common.TW.GetReport(p.form.GetString("report")))
|
|
||||||
}
|
|
||||||
|
|
||||||
type UpdateReportMsg *taskwarrior.Report
|
|
||||||
@@ -1,912 +0,0 @@
|
|||||||
package pages
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"tasksquire/common"
|
|
||||||
|
|
||||||
"tasksquire/taskwarrior"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
|
||||||
"github.com/charmbracelet/bubbles/list"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
|
||||||
|
|
||||||
type mode int
|
|
||||||
|
|
||||||
const (
|
|
||||||
modeNormal mode = iota
|
|
||||||
modeInsert
|
|
||||||
)
|
|
||||||
|
|
||||||
type TaskEditorPage struct {
|
|
||||||
common *common.Common
|
|
||||||
task taskwarrior.Task
|
|
||||||
|
|
||||||
mode mode
|
|
||||||
|
|
||||||
columnCursor int
|
|
||||||
|
|
||||||
area area
|
|
||||||
areaPicker *areaPicker
|
|
||||||
areas map[area]tea.Model
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPage {
|
|
||||||
p := TaskEditorPage{
|
|
||||||
common: com,
|
|
||||||
task: task,
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.task.Priority == "" {
|
|
||||||
p.task.Priority = "(none)"
|
|
||||||
}
|
|
||||||
if p.task.Project == "" {
|
|
||||||
p.task.Project = "(none)"
|
|
||||||
}
|
|
||||||
|
|
||||||
priorityOptions := append([]string{"(none)"}, p.common.TW.GetPriorities()...)
|
|
||||||
projectOptions := append([]string{"(none)"}, p.common.TW.GetProjects()...)
|
|
||||||
tagOptions := p.common.TW.GetTags()
|
|
||||||
tagOptions = append(tagOptions, strings.Split(p.common.TW.GetConfig().Get("uda.tasksquire.tags.default"), ",")...)
|
|
||||||
slices.Sort(tagOptions)
|
|
||||||
|
|
||||||
p.areas = map[area]tea.Model{
|
|
||||||
areaTask: NewTaskEdit(p.common, &p.task.Description, &p.task.Priority, &p.task.Project, priorityOptions, projectOptions),
|
|
||||||
areaTags: NewTagEdit(p.common, &p.task.Tags, tagOptions),
|
|
||||||
areaTime: NewTimeEdit(p.common, &p.task.Due, &p.task.Scheduled, &p.task.Wait, &p.task.Until),
|
|
||||||
}
|
|
||||||
|
|
||||||
// p.areaList = NewAreaList(common, areaItems)
|
|
||||||
// p.selectedArea = areaTask
|
|
||||||
// p.columns = append([]tea.Model{p.areaList}, p.areas[p.selectedArea]...)
|
|
||||||
|
|
||||||
p.areaPicker = NewAreaPicker(com, []string{"Task", "Tags", "Dates"})
|
|
||||||
|
|
||||||
p.columnCursor = 1
|
|
||||||
if p.task.Uuid == "" {
|
|
||||||
// p.mode = modeInsert
|
|
||||||
p.mode = modeInsert
|
|
||||||
} else {
|
|
||||||
p.mode = modeNormal
|
|
||||||
}
|
|
||||||
|
|
||||||
return &p
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TaskEditorPage) SetSize(width, height int) {
|
|
||||||
p.common.SetSize(width, height)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TaskEditorPage) Init() tea.Cmd {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case changeAreaMsg:
|
|
||||||
p.area = area(msg)
|
|
||||||
case changeModeMsg:
|
|
||||||
p.mode = mode(msg)
|
|
||||||
case prevColumnMsg:
|
|
||||||
p.columnCursor--
|
|
||||||
if p.columnCursor < 0 {
|
|
||||||
p.columnCursor = len(p.areas) - 1
|
|
||||||
}
|
|
||||||
case nextColumnMsg:
|
|
||||||
p.columnCursor++
|
|
||||||
if p.columnCursor > len(p.areas)-1 {
|
|
||||||
p.columnCursor = 0
|
|
||||||
}
|
|
||||||
case prevAreaMsg:
|
|
||||||
p.area--
|
|
||||||
if p.area < 0 {
|
|
||||||
p.area = 2
|
|
||||||
}
|
|
||||||
case nextAreaMsg:
|
|
||||||
p.area++
|
|
||||||
if p.area > 2 {
|
|
||||||
p.area = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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, changeMode(modeInsert)
|
|
||||||
case key.Matches(msg, p.common.Keymap.Ok):
|
|
||||||
model, err := p.common.PopPage()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("page stack empty")
|
|
||||||
return nil, tea.Quit
|
|
||||||
}
|
|
||||||
return model, p.updateTasksCmd
|
|
||||||
case key.Matches(msg, p.common.Keymap.Left):
|
|
||||||
return p, prevColumn()
|
|
||||||
case key.Matches(msg, p.common.Keymap.Right):
|
|
||||||
return p, nextColumn()
|
|
||||||
case key.Matches(msg, p.common.Keymap.Up):
|
|
||||||
var cmd tea.Cmd
|
|
||||||
if p.columnCursor == 0 {
|
|
||||||
picker, cmd := p.areaPicker.Update(msg)
|
|
||||||
p.areaPicker = picker.(*areaPicker)
|
|
||||||
return p, cmd
|
|
||||||
} else {
|
|
||||||
p.areas[p.area], cmd = p.areas[p.area].Update(prevFieldMsg{})
|
|
||||||
return p, cmd
|
|
||||||
}
|
|
||||||
case key.Matches(msg, p.common.Keymap.Down):
|
|
||||||
var cmd tea.Cmd
|
|
||||||
if p.columnCursor == 0 {
|
|
||||||
picker, cmd := p.areaPicker.Update(msg)
|
|
||||||
p.areaPicker = picker.(*areaPicker)
|
|
||||||
return p, cmd
|
|
||||||
} else {
|
|
||||||
p.areas[p.area], cmd = p.areas[p.area].Update(nextFieldMsg{})
|
|
||||||
return p, cmd
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// var cmd tea.Cmd
|
|
||||||
// if p.columnCursor == 0 {
|
|
||||||
// p., 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)
|
|
||||||
// }
|
|
||||||
case modeInsert:
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.KeyMsg:
|
|
||||||
switch {
|
|
||||||
case key.Matches(msg, p.common.Keymap.Back):
|
|
||||||
return p, changeMode(modeNormal)
|
|
||||||
case key.Matches(msg, p.common.Keymap.Ok):
|
|
||||||
area, cmd := p.areas[p.area].Update(msg)
|
|
||||||
p.areas[p.area] = area
|
|
||||||
return p, tea.Batch(cmd, nextField())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var cmd tea.Cmd
|
|
||||||
if p.columnCursor == 0 {
|
|
||||||
picker, cmd := p.areaPicker.Update(msg)
|
|
||||||
p.areaPicker = picker.(*areaPicker)
|
|
||||||
return p, cmd
|
|
||||||
} else {
|
|
||||||
p.areas[p.area], cmd = p.areas[p.area].Update(msg)
|
|
||||||
return p, cmd
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TaskEditorPage) View() string {
|
|
||||||
var focusStyle lipgloss.Style
|
|
||||||
if p.mode == modeInsert {
|
|
||||||
focusStyle = p.common.Styles.ColumnInsert
|
|
||||||
} else {
|
|
||||||
focusStyle = p.common.Styles.ColumnFocused
|
|
||||||
}
|
|
||||||
var picker, area string
|
|
||||||
if p.columnCursor == 0 {
|
|
||||||
picker = focusStyle.Render(p.areaPicker.View())
|
|
||||||
area = p.common.Styles.ColumnBlurred.Render(p.areas[p.area].View())
|
|
||||||
} else {
|
|
||||||
picker = p.common.Styles.ColumnBlurred.Render(p.areaPicker.View())
|
|
||||||
area = focusStyle.Render(p.areas[p.area].View())
|
|
||||||
|
|
||||||
}
|
|
||||||
return lipgloss.JoinHorizontal(
|
|
||||||
lipgloss.Left,
|
|
||||||
picker,
|
|
||||||
area,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// import (
|
|
||||||
// "fmt"
|
|
||||||
// "io"
|
|
||||||
// "log/slog"
|
|
||||||
// "strings"
|
|
||||||
// "tasksquire/common"
|
|
||||||
// "tasksquire/taskwarrior"
|
|
||||||
// "time"
|
|
||||||
|
|
||||||
// "github.com/charmbracelet/bubbles/list"
|
|
||||||
// "github.com/charmbracelet/bubbles/textinput"
|
|
||||||
// tea "github.com/charmbracelet/bubbletea"
|
|
||||||
// "github.com/charmbracelet/huh"
|
|
||||||
// "github.com/charmbracelet/lipgloss"
|
|
||||||
// )
|
|
||||||
|
|
||||||
// type Field int
|
|
||||||
|
|
||||||
// const (
|
|
||||||
// FieldDescription Field = iota
|
|
||||||
// FieldPriority
|
|
||||||
// FieldProject
|
|
||||||
// FieldNewProject
|
|
||||||
// FieldTags
|
|
||||||
// FieldNewTags
|
|
||||||
// FieldDue
|
|
||||||
// FieldScheduled
|
|
||||||
// FieldWait
|
|
||||||
// FieldUntil
|
|
||||||
// )
|
|
||||||
|
|
||||||
// type column int
|
|
||||||
|
|
||||||
// const (
|
|
||||||
// column1 column = iota
|
|
||||||
// column2
|
|
||||||
// column3
|
|
||||||
// )
|
|
||||||
|
|
||||||
// func changeColumn(c column) tea.Cmd {
|
|
||||||
// return func() tea.Msg {
|
|
||||||
// return changeColumnMsg(c)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// type changeColumnMsg column
|
|
||||||
|
|
||||||
// type mode int
|
|
||||||
|
|
||||||
// const (
|
|
||||||
// modeNormal mode = iota
|
|
||||||
// modeInsert
|
|
||||||
// modeAddTag
|
|
||||||
// modeAddProject
|
|
||||||
// )
|
|
||||||
|
|
||||||
// type TaskEditorPage struct {
|
|
||||||
// common *common.Common
|
|
||||||
// task taskwarrior.Task
|
|
||||||
// areaList tea.Model
|
|
||||||
// mode mode
|
|
||||||
// statusline tea.Model
|
|
||||||
|
|
||||||
// // TODO: rework support for adding tags and projects
|
|
||||||
// additionalTags string
|
|
||||||
// additionalProject string
|
|
||||||
|
|
||||||
// columnCursor int
|
|
||||||
// columns []tea.Model
|
|
||||||
|
|
||||||
// areas map[area][]tea.Model
|
|
||||||
// selectedArea area
|
|
||||||
// }
|
|
||||||
|
|
||||||
// type TaskEditorKeys struct {
|
|
||||||
// Quit key.Binding
|
|
||||||
// Up key.Binding
|
|
||||||
// Down key.Binding
|
|
||||||
// Select key.Binding
|
|
||||||
// ToggleFocus key.Binding
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func NewTaskEditorPage(common *common.Common, task taskwarrior.Task) *TaskEditorPage {
|
|
||||||
// p := &TaskEditorPage{
|
|
||||||
// common: common,
|
|
||||||
// task: task,
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if p.task.Uuid == "" {
|
|
||||||
// p.mode = modeInsert
|
|
||||||
// } else {
|
|
||||||
// p.mode = modeNormal
|
|
||||||
// }
|
|
||||||
|
|
||||||
// areaItems := []list.Item{
|
|
||||||
// item("Task"),
|
|
||||||
// item("Tags"),
|
|
||||||
// item("Time"),
|
|
||||||
// }
|
|
||||||
|
|
||||||
// }
|
|
||||||
|
|
||||||
// p.statusline = NewStatusLine(common, p.mode)
|
|
||||||
|
|
||||||
// return p
|
|
||||||
// }
|
|
||||||
|
|
||||||
type area int
|
|
||||||
|
|
||||||
const (
|
|
||||||
areaTask area = iota
|
|
||||||
areaTags
|
|
||||||
areaTime
|
|
||||||
)
|
|
||||||
|
|
||||||
type areaPicker struct {
|
|
||||||
common *common.Common
|
|
||||||
list list.Model
|
|
||||||
}
|
|
||||||
|
|
||||||
type item string
|
|
||||||
|
|
||||||
func (i item) Title() string { return string(i) }
|
|
||||||
func (i item) Description() string { return "test" }
|
|
||||||
func (i item) FilterValue() string { return "" }
|
|
||||||
|
|
||||||
func NewAreaPicker(common *common.Common, items []string) *areaPicker {
|
|
||||||
listItems := make([]list.Item, len(items))
|
|
||||||
for i, itm := range items {
|
|
||||||
listItems[i] = item(itm)
|
|
||||||
}
|
|
||||||
|
|
||||||
list := list.New(listItems, list.DefaultDelegate{}, 20, 50)
|
|
||||||
list.SetFilteringEnabled(false)
|
|
||||||
list.SetShowStatusBar(false)
|
|
||||||
|
|
||||||
return &areaPicker{
|
|
||||||
common: common,
|
|
||||||
list: list,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *areaPicker) Area() area {
|
|
||||||
switch a.list.SelectedItem() {
|
|
||||||
case item("Task"):
|
|
||||||
return areaTask
|
|
||||||
case item("Tags"):
|
|
||||||
return areaTags
|
|
||||||
case item("Dates"):
|
|
||||||
return areaTime
|
|
||||||
default:
|
|
||||||
return areaTask
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *areaPicker) Init() tea.Cmd {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *areaPicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
var cmds []tea.Cmd
|
|
||||||
var cmd tea.Cmd
|
|
||||||
cursor := a.list.Cursor()
|
|
||||||
// switch msg.(type) {
|
|
||||||
// case nextFieldMsg:
|
|
||||||
// a.list, cmd = a.list.Update(a.list.KeyMap.CursorDown)
|
|
||||||
// case prevFieldMsg:
|
|
||||||
// a.list, cmd = a.list.Update(a.list.KeyMap.CursorUp)
|
|
||||||
// }
|
|
||||||
a.list, cmd = a.list.Update(msg)
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
if cursor != a.list.Cursor() {
|
|
||||||
cmds = append(cmds, changeArea(a.Area()))
|
|
||||||
}
|
|
||||||
|
|
||||||
return a, tea.Batch(cmds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *areaPicker) View() string {
|
|
||||||
return a.list.View()
|
|
||||||
}
|
|
||||||
|
|
||||||
type taskEdit struct {
|
|
||||||
common *common.Common
|
|
||||||
fields []huh.Field
|
|
||||||
cursor int
|
|
||||||
|
|
||||||
newProjectName *string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTaskEdit(common *common.Common, description *string, priority *string, project *string, priorityOptions []string, projectOptions []string) *taskEdit {
|
|
||||||
newProject := ""
|
|
||||||
|
|
||||||
defaultKeymap := huh.NewDefaultKeyMap()
|
|
||||||
|
|
||||||
t := taskEdit{
|
|
||||||
common: common,
|
|
||||||
fields: []huh.Field{
|
|
||||||
huh.NewInput().
|
|
||||||
Title("Task").
|
|
||||||
Value(description).
|
|
||||||
Validate(func(desc string) error {
|
|
||||||
if desc == "" {
|
|
||||||
return fmt.Errorf("task description is required")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}).
|
|
||||||
Inline(true).
|
|
||||||
WithTheme(common.Styles.Form),
|
|
||||||
|
|
||||||
huh.NewSelect[string]().
|
|
||||||
Options(huh.NewOptions(priorityOptions...)...).
|
|
||||||
Title("Priority").
|
|
||||||
Key("priority").
|
|
||||||
Value(priority).
|
|
||||||
WithKeyMap(defaultKeymap).
|
|
||||||
WithTheme(common.Styles.Form),
|
|
||||||
|
|
||||||
huh.NewSelect[string]().
|
|
||||||
Options(huh.NewOptions(projectOptions...)...).
|
|
||||||
Title("Project").
|
|
||||||
Value(project).
|
|
||||||
WithKeyMap(defaultKeymap).
|
|
||||||
WithTheme(common.Styles.Form),
|
|
||||||
|
|
||||||
huh.NewInput().
|
|
||||||
Title("New Project").
|
|
||||||
Value(&newProject).
|
|
||||||
WithTheme(common.Styles.Form),
|
|
||||||
},
|
|
||||||
|
|
||||||
newProjectName: &newProject,
|
|
||||||
}
|
|
||||||
|
|
||||||
t.fields[0].Focus()
|
|
||||||
|
|
||||||
return &t
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t taskEdit) Init() tea.Cmd {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t taskEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
switch msg.(type) {
|
|
||||||
case nextFieldMsg:
|
|
||||||
if t.cursor == len(t.fields)-1 {
|
|
||||||
t.fields[t.cursor].Blur()
|
|
||||||
return t, nextArea()
|
|
||||||
}
|
|
||||||
t.fields[t.cursor].Blur()
|
|
||||||
t.cursor++
|
|
||||||
t.fields[t.cursor].Focus()
|
|
||||||
case prevFieldMsg:
|
|
||||||
if t.cursor == 0 {
|
|
||||||
t.fields[t.cursor].Blur()
|
|
||||||
return t, prevArea()
|
|
||||||
}
|
|
||||||
t.fields[t.cursor].Blur()
|
|
||||||
t.cursor--
|
|
||||||
t.fields[t.cursor].Focus()
|
|
||||||
default:
|
|
||||||
field, cmd := t.fields[t.cursor].Update(msg)
|
|
||||||
t.fields[t.cursor] = field.(huh.Field)
|
|
||||||
return t, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t taskEdit) View() string {
|
|
||||||
views := make([]string, len(t.fields))
|
|
||||||
for i, field := range t.fields {
|
|
||||||
views[i] = field.View()
|
|
||||||
}
|
|
||||||
return lipgloss.JoinVertical(
|
|
||||||
lipgloss.Left,
|
|
||||||
views...,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type tagEdit struct {
|
|
||||||
common *common.Common
|
|
||||||
fields []huh.Field
|
|
||||||
|
|
||||||
cursor int
|
|
||||||
|
|
||||||
newTagsValue *string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTagEdit(common *common.Common, selected *[]string, options []string) *tagEdit {
|
|
||||||
newTags := ""
|
|
||||||
|
|
||||||
defaultKeymap := huh.NewDefaultKeyMap()
|
|
||||||
|
|
||||||
t := tagEdit{
|
|
||||||
common: common,
|
|
||||||
fields: []huh.Field{
|
|
||||||
huh.NewMultiSelect[string]().
|
|
||||||
Options(huh.NewOptions(options...)...).
|
|
||||||
// Key("tags").
|
|
||||||
Title("Tags").
|
|
||||||
Value(selected).
|
|
||||||
Filterable(true).
|
|
||||||
WithKeyMap(defaultKeymap).
|
|
||||||
WithTheme(common.Styles.Form),
|
|
||||||
huh.NewInput().
|
|
||||||
Title("New Tags").
|
|
||||||
Value(&newTags).
|
|
||||||
Inline(true).
|
|
||||||
WithTheme(common.Styles.Form),
|
|
||||||
},
|
|
||||||
newTagsValue: &newTags,
|
|
||||||
}
|
|
||||||
|
|
||||||
t.fields[0].Focus()
|
|
||||||
|
|
||||||
return &t
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t tagEdit) Init() tea.Cmd {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t tagEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
switch msg.(type) {
|
|
||||||
case nextFieldMsg:
|
|
||||||
if t.cursor == len(t.fields)-1 {
|
|
||||||
t.fields[t.cursor].Blur()
|
|
||||||
return t, nextArea()
|
|
||||||
}
|
|
||||||
t.fields[t.cursor].Blur()
|
|
||||||
t.cursor++
|
|
||||||
t.fields[t.cursor].Focus()
|
|
||||||
case prevFieldMsg:
|
|
||||||
if t.cursor == 0 {
|
|
||||||
t.fields[t.cursor].Blur()
|
|
||||||
return t, prevArea()
|
|
||||||
}
|
|
||||||
t.fields[t.cursor].Blur()
|
|
||||||
t.cursor--
|
|
||||||
t.fields[t.cursor].Focus()
|
|
||||||
default:
|
|
||||||
field, cmd := t.fields[t.cursor].Update(msg)
|
|
||||||
t.fields[t.cursor] = field.(huh.Field)
|
|
||||||
return t, cmd
|
|
||||||
}
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t tagEdit) View() string {
|
|
||||||
views := make([]string, len(t.fields))
|
|
||||||
for i, field := range t.fields {
|
|
||||||
views[i] = field.View()
|
|
||||||
}
|
|
||||||
return lipgloss.JoinVertical(
|
|
||||||
lipgloss.Left,
|
|
||||||
views...,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type timeEdit struct {
|
|
||||||
common *common.Common
|
|
||||||
fields []huh.Field
|
|
||||||
|
|
||||||
cursor int
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTimeEdit(common *common.Common, due *string, scheduled *string, wait *string, until *string) *timeEdit {
|
|
||||||
// defaultKeymap := huh.NewDefaultKeyMap()
|
|
||||||
t := timeEdit{
|
|
||||||
common: common,
|
|
||||||
fields: []huh.Field{
|
|
||||||
huh.NewInput().
|
|
||||||
Title("Due").
|
|
||||||
Value(due).
|
|
||||||
Validate(taskwarrior.ValidateDate).
|
|
||||||
Inline(true).
|
|
||||||
WithTheme(common.Styles.Form),
|
|
||||||
huh.NewInput().
|
|
||||||
Title("Scheduled").
|
|
||||||
Value(scheduled).
|
|
||||||
Validate(taskwarrior.ValidateDate).
|
|
||||||
Inline(true).
|
|
||||||
WithTheme(common.Styles.Form),
|
|
||||||
huh.NewInput().
|
|
||||||
Title("Wait").
|
|
||||||
Value(wait).
|
|
||||||
Validate(taskwarrior.ValidateDate).
|
|
||||||
Inline(true).
|
|
||||||
WithTheme(common.Styles.Form),
|
|
||||||
huh.NewInput().
|
|
||||||
Title("Until").
|
|
||||||
Value(until).
|
|
||||||
Validate(taskwarrior.ValidateDate).
|
|
||||||
Inline(true).
|
|
||||||
WithTheme(common.Styles.Form),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
t.fields[0].Focus()
|
|
||||||
|
|
||||||
return &t
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t timeEdit) Init() tea.Cmd {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t timeEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
switch msg.(type) {
|
|
||||||
case nextFieldMsg:
|
|
||||||
if t.cursor == len(t.fields)-1 {
|
|
||||||
t.fields[t.cursor].Blur()
|
|
||||||
return t, nextArea()
|
|
||||||
}
|
|
||||||
t.fields[t.cursor].Blur()
|
|
||||||
t.cursor++
|
|
||||||
t.fields[t.cursor].Focus()
|
|
||||||
case prevFieldMsg:
|
|
||||||
if t.cursor == 0 {
|
|
||||||
t.fields[t.cursor].Blur()
|
|
||||||
return t, prevArea()
|
|
||||||
}
|
|
||||||
t.fields[t.cursor].Blur()
|
|
||||||
t.cursor--
|
|
||||||
t.fields[t.cursor].Focus()
|
|
||||||
default:
|
|
||||||
field, cmd := t.fields[t.cursor].Update(msg)
|
|
||||||
t.fields[t.cursor] = field.(huh.Field)
|
|
||||||
return t, cmd
|
|
||||||
}
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t timeEdit) View() string {
|
|
||||||
views := make([]string, len(t.fields))
|
|
||||||
for i, field := range t.fields {
|
|
||||||
views[i] = field.View()
|
|
||||||
}
|
|
||||||
return lipgloss.JoinVertical(
|
|
||||||
lipgloss.Left,
|
|
||||||
views...,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
if p.task.Project == "(none)" {
|
|
||||||
p.task.Project = ""
|
|
||||||
}
|
|
||||||
if p.task.Priority == "(none)" {
|
|
||||||
p.task.Priority = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if *p.areas[areaTask].(taskEdit).newProjectName != "" {
|
|
||||||
p.task.Project = *p.areas[areaTask].(taskEdit).newProjectName
|
|
||||||
}
|
|
||||||
|
|
||||||
if *p.areas[areaTags].(tagEdit).newTagsValue != "" {
|
|
||||||
p.task.Tags = append(p.task.Tags, strings.Split(*p.areas[areaTags].(tagEdit).newTagsValue, " ")...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// if p.additionalProject != "" {
|
|
||||||
// p.task.Project = p.additionalProject
|
|
||||||
// }
|
|
||||||
// tags := p.form.Get("tags").([]string)
|
|
||||||
// p.task.Tags = tags
|
|
||||||
p.common.TW.ImportTask(&p.task)
|
|
||||||
return UpdatedTasksMsg{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,534 +0,0 @@
|
|||||||
// TODO: error handling
|
|
||||||
// TODO: split combinedOutput and handle stderr differently
|
|
||||||
// TODO: reorder functions
|
|
||||||
package taskwarrior
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"os/exec"
|
|
||||||
"regexp"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
twBinary = "task"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
nonStandardReports = map[string]struct{}{
|
|
||||||
"burndown.daily": {},
|
|
||||||
"burndown.monthly": {},
|
|
||||||
"burndown.weekly": {},
|
|
||||||
"calendar": {},
|
|
||||||
"colors": {},
|
|
||||||
"export": {},
|
|
||||||
"ghistory.annual": {},
|
|
||||||
"ghistory.monthly": {},
|
|
||||||
"history.annual": {},
|
|
||||||
"history.monthly": {},
|
|
||||||
"information": {},
|
|
||||||
"summary": {},
|
|
||||||
"timesheet": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
virtualTags = map[string]struct{}{
|
|
||||||
"ACTIVE": {},
|
|
||||||
"ANNOTATED": {},
|
|
||||||
"BLOCKED": {},
|
|
||||||
"BLOCKING": {},
|
|
||||||
"CHILD": {},
|
|
||||||
"COMPLETED": {},
|
|
||||||
"DELETED": {},
|
|
||||||
"DUE": {},
|
|
||||||
"DUETODAY": {},
|
|
||||||
"INSTANCE": {},
|
|
||||||
"LATEST": {},
|
|
||||||
"MONTH": {},
|
|
||||||
"ORPHAN": {},
|
|
||||||
"OVERDUE": {},
|
|
||||||
"PARENT": {},
|
|
||||||
"PENDING": {},
|
|
||||||
"PRIORITY": {},
|
|
||||||
"PROJECT": {},
|
|
||||||
"QUARTER": {},
|
|
||||||
"READY": {},
|
|
||||||
"SCHEDULED": {},
|
|
||||||
"TAGGED": {},
|
|
||||||
"TEMPLATE": {},
|
|
||||||
"TODAY": {},
|
|
||||||
"TOMORROW": {},
|
|
||||||
"UDA": {},
|
|
||||||
"UNBLOCKED": {},
|
|
||||||
"UNTIL": {},
|
|
||||||
"WAITING": {},
|
|
||||||
"WEEK": {},
|
|
||||||
"YEAR": {},
|
|
||||||
"YESTERDAY": {},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
type TaskWarrior interface {
|
|
||||||
GetConfig() *TWConfig
|
|
||||||
|
|
||||||
GetActiveContext() *Context
|
|
||||||
GetContext(context string) *Context
|
|
||||||
GetContexts() Contexts
|
|
||||||
SetContext(context *Context) error
|
|
||||||
|
|
||||||
GetProjects() []string
|
|
||||||
|
|
||||||
GetPriorities() []string
|
|
||||||
|
|
||||||
GetTags() []string
|
|
||||||
|
|
||||||
GetReport(report string) *Report
|
|
||||||
GetReports() Reports
|
|
||||||
|
|
||||||
GetTasks(report *Report, filter ...string) Tasks
|
|
||||||
AddTask(task *Task) error
|
|
||||||
ImportTask(task *Task)
|
|
||||||
SetTaskDone(task *Task)
|
|
||||||
DeleteTask(task *Task)
|
|
||||||
StartTask(task *Task)
|
|
||||||
StopTask(task *Task)
|
|
||||||
|
|
||||||
Undo()
|
|
||||||
}
|
|
||||||
|
|
||||||
type TaskSquire struct {
|
|
||||||
configLocation string
|
|
||||||
defaultArgs []string
|
|
||||||
config *TWConfig
|
|
||||||
reports Reports
|
|
||||||
contexts Contexts
|
|
||||||
|
|
||||||
mutex sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTaskSquire(configLocation string) *TaskSquire {
|
|
||||||
if _, err := exec.LookPath(twBinary); err != nil {
|
|
||||||
slog.Error("Taskwarrior not found")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
defaultArgs := []string{fmt.Sprintf("rc=%s", configLocation), "rc.verbose=nothing", "rc.dependency.confirmation=no", "rc.recurrence.confirmation=no", "rc.confirmation=no", "rc.json.array=1"}
|
|
||||||
|
|
||||||
ts := &TaskSquire{
|
|
||||||
configLocation: configLocation,
|
|
||||||
defaultArgs: defaultArgs,
|
|
||||||
mutex: sync.Mutex{},
|
|
||||||
}
|
|
||||||
ts.config = ts.extractConfig()
|
|
||||||
ts.reports = ts.extractReports()
|
|
||||||
ts.contexts = ts.extractContexts()
|
|
||||||
|
|
||||||
return ts
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TaskSquire) GetConfig() *TWConfig {
|
|
||||||
ts.mutex.Lock()
|
|
||||||
defer ts.mutex.Unlock()
|
|
||||||
|
|
||||||
return ts.config
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
|
|
||||||
ts.mutex.Lock()
|
|
||||||
defer ts.mutex.Unlock()
|
|
||||||
|
|
||||||
args := ts.defaultArgs
|
|
||||||
|
|
||||||
if report.Context {
|
|
||||||
for _, context := range ts.contexts {
|
|
||||||
if context.Active && context.Name != "none" {
|
|
||||||
args = append(args, context.ReadFilter)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if filter != nil {
|
|
||||||
args = append(args, filter...)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(args, []string{"export", report.Name}...)...)
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed getting report:", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks := make(Tasks, 0)
|
|
||||||
err = json.Unmarshal(output, &tasks)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed unmarshalling tasks:", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, task := range tasks {
|
|
||||||
if task.Depends != nil && len(task.Depends) > 0 {
|
|
||||||
ids := make([]string, len(task.Depends))
|
|
||||||
for i, dependUuid := range task.Depends {
|
|
||||||
ids[i] = ts.getIds([]string{fmt.Sprintf("uuid:%s", dependUuid)})
|
|
||||||
}
|
|
||||||
|
|
||||||
task.DependsIds = strings.Join(ids, " ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tasks
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TaskSquire) getIds(filter []string) string {
|
|
||||||
cmd := exec.Command(twBinary, append(append(ts.defaultArgs, filter...), "_ids")...)
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed getting field:", err)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSpace(string(out))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TaskSquire) GetContext(context string) *Context {
|
|
||||||
ts.mutex.Lock()
|
|
||||||
defer ts.mutex.Unlock()
|
|
||||||
|
|
||||||
if context == "" {
|
|
||||||
context = "none"
|
|
||||||
}
|
|
||||||
|
|
||||||
if context, ok := ts.contexts[context]; ok {
|
|
||||||
return context
|
|
||||||
} else {
|
|
||||||
slog.Error(fmt.Sprintf("Context not found: %s", context.Name))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TaskSquire) GetActiveContext() *Context {
|
|
||||||
ts.mutex.Lock()
|
|
||||||
defer ts.mutex.Unlock()
|
|
||||||
|
|
||||||
for _, context := range ts.contexts {
|
|
||||||
if context.Active {
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ts.contexts["none"]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TaskSquire) GetContexts() Contexts {
|
|
||||||
ts.mutex.Lock()
|
|
||||||
defer ts.mutex.Unlock()
|
|
||||||
|
|
||||||
return ts.contexts
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TaskSquire) GetProjects() []string {
|
|
||||||
ts.mutex.Lock()
|
|
||||||
defer ts.mutex.Unlock()
|
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_projects"}...)...)
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed getting projects:", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
projects := make([]string, 0)
|
|
||||||
for _, project := range strings.Split(string(output), "\n") {
|
|
||||||
if project != "" {
|
|
||||||
projects = append(projects, project)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
slices.Sort(projects)
|
|
||||||
|
|
||||||
return projects
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TaskSquire) GetPriorities() []string {
|
|
||||||
ts.mutex.Lock()
|
|
||||||
defer ts.mutex.Unlock()
|
|
||||||
|
|
||||||
priorities := make([]string, 0)
|
|
||||||
for _, priority := range strings.Split(ts.config.Get("uda.priority.values"), ",") {
|
|
||||||
if priority != "" {
|
|
||||||
priorities = append(priorities, priority)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return priorities
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TaskSquire) GetTags() []string {
|
|
||||||
ts.mutex.Lock()
|
|
||||||
defer ts.mutex.Unlock()
|
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_tags"}...)...)
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed getting tags:", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tags := make([]string, 0)
|
|
||||||
|
|
||||||
for _, tag := range strings.Split(string(output), "\n") {
|
|
||||||
if _, ok := virtualTags[tag]; !ok && tag != "" {
|
|
||||||
tags = append(tags, tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
slices.Sort(tags)
|
|
||||||
|
|
||||||
return tags
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TaskSquire) GetReport(report string) *Report {
|
|
||||||
ts.mutex.Lock()
|
|
||||||
defer ts.mutex.Unlock()
|
|
||||||
|
|
||||||
return ts.reports[report]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TaskSquire) GetReports() Reports {
|
|
||||||
ts.mutex.Lock()
|
|
||||||
defer ts.mutex.Unlock()
|
|
||||||
|
|
||||||
return ts.reports
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TaskSquire) SetContext(context *Context) error {
|
|
||||||
ts.mutex.Lock()
|
|
||||||
defer ts.mutex.Unlock()
|
|
||||||
|
|
||||||
if context.Name == "none" && ts.contexts["none"].Active {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, []string{"context", context.Name}...)
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
slog.Error("Failed setting context:", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: optimize this; there should be no need to re-extract everything
|
|
||||||
ts.config = ts.extractConfig()
|
|
||||||
ts.contexts = ts.extractContexts()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TaskSquire) AddTask(task *Task) error {
|
|
||||||
ts.mutex.Lock()
|
|
||||||
defer ts.mutex.Unlock()
|
|
||||||
|
|
||||||
addArgs := []string{"add"}
|
|
||||||
|
|
||||||
if task.Description == "" {
|
|
||||||
slog.Error("Task description is required")
|
|
||||||
return nil
|
|
||||||
} else {
|
|
||||||
addArgs = append(addArgs, task.Description)
|
|
||||||
}
|
|
||||||
if task.Priority != "" && task.Priority != "(none)" {
|
|
||||||
addArgs = append(addArgs, fmt.Sprintf("priority:%s", task.Priority))
|
|
||||||
}
|
|
||||||
if task.Project != "" && task.Project != "(none)" {
|
|
||||||
addArgs = append(addArgs, fmt.Sprintf("project:%s", task.Project))
|
|
||||||
}
|
|
||||||
if task.Tags != nil {
|
|
||||||
for _, tag := range task.Tags {
|
|
||||||
addArgs = append(addArgs, fmt.Sprintf("+%s", tag))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if task.Due != "" {
|
|
||||||
addArgs = append(addArgs, fmt.Sprintf("due:%s", task.Due))
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, addArgs...)...)
|
|
||||||
err := cmd.Run()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed adding task:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO remove error?
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO error handling
|
|
||||||
func (ts *TaskSquire) ImportTask(task *Task) {
|
|
||||||
ts.mutex.Lock()
|
|
||||||
defer ts.mutex.Unlock()
|
|
||||||
|
|
||||||
tasks, err := json.Marshal(Tasks{task})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed marshalling task:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"import", "-"}...)...)
|
|
||||||
cmd.Stdin = bytes.NewBuffer(tasks)
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed modifying task:", err, string(out))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TaskSquire) SetTaskDone(task *Task) {
|
|
||||||
ts.mutex.Lock()
|
|
||||||
defer ts.mutex.Unlock()
|
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"done", task.Uuid}...)...)
|
|
||||||
err := cmd.Run()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed setting task done:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TaskSquire) DeleteTask(task *Task) {
|
|
||||||
ts.mutex.Lock()
|
|
||||||
defer ts.mutex.Unlock()
|
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{task.Uuid, "delete"}...)...)
|
|
||||||
err := cmd.Run()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed deleting task:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TaskSquire) Undo() {
|
|
||||||
ts.mutex.Lock()
|
|
||||||
defer ts.mutex.Unlock()
|
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
|
|
||||||
err := cmd.Run()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed undoing task:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TaskSquire) StartTask(task *Task) {
|
|
||||||
ts.mutex.Lock()
|
|
||||||
defer ts.mutex.Unlock()
|
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"start", task.Uuid}...)...)
|
|
||||||
err := cmd.Run()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed starting task:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TaskSquire) StopTask(task *Task) {
|
|
||||||
ts.mutex.Lock()
|
|
||||||
defer ts.mutex.Unlock()
|
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
|
|
||||||
err := cmd.Run()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed stopping task:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TaskSquire) extractConfig() *TWConfig {
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_show"}...)...)
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed getting config:", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return NewConfig(strings.Split(string(output), "\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TaskSquire) extractReports() Reports {
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_config"}...)...)
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
availableReports := extractReports(string(output))
|
|
||||||
|
|
||||||
reports := make(Reports)
|
|
||||||
|
|
||||||
for _, report := range availableReports {
|
|
||||||
if _, ok := nonStandardReports[report]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
reports[report] = &Report{
|
|
||||||
Name: report,
|
|
||||||
Description: ts.config.Get(fmt.Sprintf("report.%s.description", report)),
|
|
||||||
Labels: strings.Split(ts.config.Get(fmt.Sprintf("report.%s.labels", report)), ","),
|
|
||||||
Filter: ts.config.Get(fmt.Sprintf("report.%s.filter", report)),
|
|
||||||
Sort: ts.config.Get(fmt.Sprintf("report.%s.sort", report)),
|
|
||||||
Columns: strings.Split(ts.config.Get(fmt.Sprintf("report.%s.columns", report)), ","),
|
|
||||||
Context: ts.config.Get(fmt.Sprintf("report.%s.context", report)) == "1",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return reports
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractReports(config string) []string {
|
|
||||||
re := regexp.MustCompile(`report\.([^.]+)\.[^.]+`)
|
|
||||||
matches := re.FindAllStringSubmatch(config, -1)
|
|
||||||
uniques := make(map[string]struct{})
|
|
||||||
for _, match := range matches {
|
|
||||||
uniques[match[1]] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
var reports []string
|
|
||||||
for part := range uniques {
|
|
||||||
reports = append(reports, part)
|
|
||||||
}
|
|
||||||
|
|
||||||
slices.Sort(reports)
|
|
||||||
return reports
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TaskSquire) extractContexts() Contexts {
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_context"}...)...)
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed getting contexts:", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
activeContext := ts.config.Get("context")
|
|
||||||
if activeContext == "" {
|
|
||||||
activeContext = "none"
|
|
||||||
}
|
|
||||||
|
|
||||||
contexts := make(Contexts)
|
|
||||||
contexts["none"] = &Context{
|
|
||||||
Name: "none",
|
|
||||||
Active: activeContext == "none",
|
|
||||||
ReadFilter: "",
|
|
||||||
WriteFilter: "",
|
|
||||||
}
|
|
||||||
for _, context := range strings.Split(string(output), "\n") {
|
|
||||||
if context == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
contexts[context] = &Context{
|
|
||||||
Name: context,
|
|
||||||
Active: activeContext == context,
|
|
||||||
ReadFilter: ts.config.Get(fmt.Sprintf("context.%s.read", context)),
|
|
||||||
WriteFilter: ts.config.Get(fmt.Sprintf("context.%s.write", context)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return contexts
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -1,6 +0,0 @@
|
|||||||
include light-256.theme
|
|
||||||
|
|
||||||
context.test.read=+test
|
|
||||||
context.test.write=+test
|
|
||||||
context.home.read=+home
|
|
||||||
context.home.write=+home
|
|
||||||
Reference in New Issue
Block a user