Compare commits
8 Commits
e35f480248
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b1418fc71 | ||
|
|
b46aced2c7 | ||
|
|
3ab26f658d | ||
|
|
1a9fd9b4b0 | ||
|
|
6e60698526 | ||
|
|
703ed981ac | ||
|
|
e3effe8b25 | ||
|
|
980c8eb309 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,5 @@
|
||||
.DS_Store
|
||||
app.log
|
||||
test/taskchampion.sqlite3
|
||||
tasksquire
|
||||
test/*.sqlite3*
|
||||
result
|
||||
|
||||
@@ -201,7 +201,7 @@ ts.StopTask(&task)
|
||||
|
||||
## Development Notes
|
||||
|
||||
- **Logging**: Application logs to `app.log` in current directory
|
||||
- **Logging**: Application logs to `/tmp/tasksquire.log`
|
||||
- **Virtual Tags**: Filter out Taskwarrior virtual tags (see `virtualTags` map)
|
||||
- **Color Parsing**: Custom color parsing from Taskwarrior config format
|
||||
- **Debugging**: VSCode launch.json configured for remote debugging on port 43000
|
||||
|
||||
@@ -156,7 +156,7 @@ The `Task` struct uses custom `MarshalJSON()` and `UnmarshalJSON()` to handle:
|
||||
### Logging
|
||||
|
||||
- Uses `log/slog` for structured logging
|
||||
- Logs written to `app.log` in current directory
|
||||
- Logs written to `/tmp/tasksquire.log`
|
||||
- Errors logged but execution typically continues (graceful degradation)
|
||||
- Log pattern: `slog.Error("message", "key", value)`
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ Tasksquire respects your existing Taskwarrior configuration (`.taskrc`). It look
|
||||
2. `$HOME/.taskrc`
|
||||
3. `$HOME/.config/task/taskrc`
|
||||
|
||||
Logging is written to `app.log` in the current working directory.
|
||||
Logging is written to `/tmp/tasksquire.log`.
|
||||
|
||||
## Development Conventions
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ type Keymap struct {
|
||||
StartStop key.Binding
|
||||
Join key.Binding
|
||||
ViewDetails key.Binding
|
||||
Subtask key.Binding
|
||||
}
|
||||
|
||||
// TODO: use config values for key bindings
|
||||
@@ -105,13 +106,13 @@ func NewKeymap() *Keymap {
|
||||
),
|
||||
|
||||
NextPage: key.NewBinding(
|
||||
key.WithKeys("]"),
|
||||
key.WithHelp("[", "Next page"),
|
||||
key.WithKeys("]", "L"),
|
||||
key.WithHelp("]/L", "Next page"),
|
||||
),
|
||||
|
||||
PrevPage: key.NewBinding(
|
||||
key.WithKeys("["),
|
||||
key.WithHelp("]", "Previous page"),
|
||||
key.WithKeys("[", "H"),
|
||||
key.WithHelp("[/H", "Previous page"),
|
||||
),
|
||||
|
||||
SetReport: key.NewBinding(
|
||||
@@ -173,5 +174,10 @@ func NewKeymap() *Keymap {
|
||||
key.WithKeys("v"),
|
||||
key.WithHelp("v", "view details"),
|
||||
),
|
||||
|
||||
Subtask: key.NewBinding(
|
||||
key.WithKeys("S"),
|
||||
key.WithHelp("S", "Create subtask"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,19 @@ type TableStyle struct {
|
||||
Selected lipgloss.Style
|
||||
}
|
||||
|
||||
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
|
||||
Colors map[string]*lipgloss.Style
|
||||
Palette Palette
|
||||
|
||||
Base lipgloss.Style
|
||||
|
||||
@@ -50,23 +61,43 @@ func NewStyles(config *taskwarrior.TWConfig) *Styles {
|
||||
|
||||
styles.Colors = colors
|
||||
|
||||
styles.Base = lipgloss.NewStyle()
|
||||
// Initialize Palette (Iceberg Light)
|
||||
styles.Palette.Primary = lipgloss.NewStyle().Foreground(lipgloss.Color("#2d539e")) // Blue
|
||||
styles.Palette.Secondary = lipgloss.NewStyle().Foreground(lipgloss.Color("#7759b4")) // Purple
|
||||
styles.Palette.Accent = lipgloss.NewStyle().Foreground(lipgloss.Color("#c57339")) // Orange
|
||||
styles.Palette.Muted = lipgloss.NewStyle().Foreground(lipgloss.Color("#8389a3")) // Grey
|
||||
styles.Palette.Border = lipgloss.NewStyle().Foreground(lipgloss.Color("#cad0de")) // Light Grey Border
|
||||
styles.Palette.Background = lipgloss.NewStyle().Background(lipgloss.Color("#e8e9ec")) // Light Background
|
||||
styles.Palette.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("#33374c")) // Dark Text
|
||||
|
||||
// Override from config if available (example mapping)
|
||||
if s, ok := styles.Colors["primary"]; ok {
|
||||
styles.Palette.Primary = *s
|
||||
}
|
||||
if s, ok := styles.Colors["secondary"]; ok {
|
||||
styles.Palette.Secondary = *s
|
||||
}
|
||||
if s, ok := styles.Colors["active"]; ok {
|
||||
styles.Palette.Accent = *s
|
||||
}
|
||||
|
||||
styles.Base = lipgloss.NewStyle().Foreground(styles.Palette.Text.GetForeground())
|
||||
|
||||
styles.TableStyle = TableStyle{
|
||||
// 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),
|
||||
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)
|
||||
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.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)
|
||||
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(" ")
|
||||
@@ -77,27 +108,38 @@ func NewStyles(config *taskwarrior.TWConfig) *Styles {
|
||||
|
||||
styles.Tab = lipgloss.NewStyle().
|
||||
Padding(0, 1).
|
||||
Foreground(lipgloss.Color("240"))
|
||||
Foreground(styles.Palette.Muted.GetForeground())
|
||||
|
||||
styles.ActiveTab = styles.Tab.
|
||||
Foreground(lipgloss.Color("252")).
|
||||
Foreground(styles.Palette.Primary.GetForeground()).
|
||||
Bold(true)
|
||||
|
||||
styles.TabBar = lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||
BorderForeground(lipgloss.Color("240")).
|
||||
BorderForeground(styles.Palette.Border.GetForeground()).
|
||||
MarginBottom(1)
|
||||
|
||||
styles.ColumnFocused = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)
|
||||
styles.ColumnBlurred = lipgloss.NewStyle().Border(lipgloss.HiddenBorder(), true).Padding(1)
|
||||
styles.ColumnInsert = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)
|
||||
if styles.Colors["active"] != nil {
|
||||
styles.ColumnInsert = styles.ColumnInsert.BorderForeground(styles.Colors["active"].GetForeground())
|
||||
}
|
||||
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
|
||||
|
||||
@@ -29,7 +29,7 @@ type Task struct {
|
||||
Depends []string `json:"depends,omitempty"`
|
||||
DependsIds string `json:"-"`
|
||||
Urgency float32 `json:"urgency,omitempty"`
|
||||
Parent string `json:"parent,omitempty"`
|
||||
Parent string `json:"parenttask,omitempty"`
|
||||
Due string `json:"due,omitempty"`
|
||||
Wait string `json:"wait,omitempty"`
|
||||
Scheduled string `json:"scheduled,omitempty"`
|
||||
@@ -125,7 +125,7 @@ func (t *Task) GetString(fieldWFormat string) string {
|
||||
}
|
||||
return strings.Join(t.Tags, " ")
|
||||
|
||||
case "parent":
|
||||
case "parenttask":
|
||||
if format == "short" {
|
||||
return t.Parent[:8]
|
||||
}
|
||||
@@ -178,7 +178,7 @@ func (t *Task) GetString(fieldWFormat string) string {
|
||||
return t.Recur
|
||||
|
||||
default:
|
||||
slog.Error(fmt.Sprintf("Field not implemented: %s", field))
|
||||
slog.Error("Field not implemented", "field", field)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -214,7 +214,7 @@ func formatDate(date string, format string) string {
|
||||
dtformat := "20060102T150405Z"
|
||||
dt, err := time.Parse(dtformat, date)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse time:", err)
|
||||
slog.Error("Failed to parse time", "error", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ func formatDate(date string, format string) string {
|
||||
case "countdown":
|
||||
return parseCountdown(time.Since(dt))
|
||||
default:
|
||||
slog.Error(fmt.Sprintf("Date format not implemented: %s", format))
|
||||
slog.Error("Date format not implemented", "format", format)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,10 +81,10 @@ func (d *DetailsViewer) View() string {
|
||||
// Title bar
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("252"))
|
||||
Foreground(d.common.Styles.Palette.Text.GetForeground())
|
||||
|
||||
helpStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240"))
|
||||
Foreground(d.common.Styles.Palette.Muted.GetForeground())
|
||||
|
||||
header := lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
@@ -96,7 +96,7 @@ func (d *DetailsViewer) View() string {
|
||||
// Container style
|
||||
containerStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("240")).
|
||||
BorderForeground(d.common.Styles.Palette.Border.GetForeground()).
|
||||
Padding(0, 1).
|
||||
Width(d.width).
|
||||
Height(d.height)
|
||||
@@ -104,7 +104,7 @@ func (d *DetailsViewer) View() string {
|
||||
// Optional: highlight border when focused (for future interactivity)
|
||||
if d.focused {
|
||||
containerStyle = containerStyle.
|
||||
BorderForeground(lipgloss.Color("86"))
|
||||
BorderForeground(d.common.Styles.Palette.Accent.GetForeground())
|
||||
}
|
||||
|
||||
content := lipgloss.JoinVertical(
|
||||
|
||||
@@ -90,7 +90,22 @@ func New(
|
||||
delegate.ShowDescription = false
|
||||
delegate.SetSpacing(0)
|
||||
|
||||
// Update Styles
|
||||
delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(c.Styles.Palette.Text.GetForeground())
|
||||
delegate.Styles.SelectedTitle = lipgloss.NewStyle().Foreground(c.Styles.Palette.Accent.GetForeground()).Bold(true).PaddingLeft(2)
|
||||
delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(c.Styles.Palette.Muted.GetForeground())
|
||||
delegate.Styles.SelectedDesc = lipgloss.NewStyle().Foreground(c.Styles.Palette.Accent.GetForeground()).PaddingLeft(2)
|
||||
delegate.Styles.FilterMatch = lipgloss.NewStyle().Foreground(c.Styles.Palette.Secondary.GetForeground()).Underline(true)
|
||||
|
||||
l := list.New([]list.Item{}, delegate, 0, 0)
|
||||
l.Styles.FilterPrompt = lipgloss.NewStyle().Foreground(c.Styles.Palette.Accent.GetForeground())
|
||||
l.Styles.FilterCursor = lipgloss.NewStyle().Foreground(c.Styles.Palette.Accent.GetForeground())
|
||||
|
||||
// Ensure the filter input text is readable (using Text color instead of potentially inheriting something else)
|
||||
l.FilterInput.TextStyle = lipgloss.NewStyle().Foreground(c.Styles.Palette.Text.GetForeground())
|
||||
l.FilterInput.PromptStyle = l.Styles.FilterPrompt
|
||||
l.FilterInput.CursorStyle = l.Styles.FilterCursor
|
||||
|
||||
l.SetShowTitle(false)
|
||||
l.SetShowHelp(false)
|
||||
l.SetShowStatusBar(false)
|
||||
|
||||
@@ -27,6 +27,7 @@ type Model struct {
|
||||
focus bool
|
||||
styles common.TableStyle
|
||||
styleFunc StyleFunc
|
||||
taskTree *taskwarrior.TaskTree
|
||||
|
||||
viewport viewport.Model
|
||||
start int
|
||||
@@ -242,9 +243,31 @@ func (m *Model) parseColumns(cols []Column) []Column {
|
||||
return cols
|
||||
}
|
||||
|
||||
// Calculate max tree depth for indentation
|
||||
maxTreeWidth := 0
|
||||
if m.taskTree != nil {
|
||||
for _, node := range m.taskTree.FlatList {
|
||||
// Calculate indentation: depth * 2 spaces + tree characters (3 chars for "└─ ")
|
||||
treeWidth := 0
|
||||
if node.Depth > 0 {
|
||||
treeWidth = node.Depth*2 + 3
|
||||
}
|
||||
// Add progress indicator width for parent tasks (e.g., " (3/5)" = 6 chars max)
|
||||
if node.HasChildren() {
|
||||
treeWidth += 7
|
||||
}
|
||||
maxTreeWidth = max(maxTreeWidth, treeWidth)
|
||||
}
|
||||
}
|
||||
|
||||
for i, col := range cols {
|
||||
for _, task := range m.rows {
|
||||
col.ContentWidth = max(col.ContentWidth, lipgloss.Width(task.GetString(col.Name)))
|
||||
contentWidth := lipgloss.Width(task.GetString(col.Name))
|
||||
// Add tree width to description column
|
||||
if strings.Contains(col.Name, "description") {
|
||||
contentWidth += maxTreeWidth
|
||||
}
|
||||
col.ContentWidth = max(col.ContentWidth, contentWidth)
|
||||
}
|
||||
cols[i] = col
|
||||
}
|
||||
@@ -351,6 +374,13 @@ func WithKeyMap(km KeyMap) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithTaskTree sets the task tree for hierarchical display.
|
||||
func WithTaskTree(tree *taskwarrior.TaskTree) Option {
|
||||
return func(m *Model) {
|
||||
m.taskTree = tree
|
||||
}
|
||||
}
|
||||
|
||||
// Update is the Bubble Tea update loop.
|
||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
if !m.focus {
|
||||
@@ -571,6 +601,21 @@ func (m Model) headersView() string {
|
||||
|
||||
func (m *Model) renderRow(r int) string {
|
||||
var s = make([]string, 0, len(m.cols))
|
||||
|
||||
// Extract tree metadata for this row
|
||||
var depth int
|
||||
var hasChildren bool
|
||||
var progress string
|
||||
if m.taskTree != nil && r < len(m.taskTree.FlatList) {
|
||||
node := m.taskTree.FlatList[r]
|
||||
depth = node.Depth
|
||||
hasChildren = node.HasChildren()
|
||||
if hasChildren {
|
||||
completed, total := node.GetChildrenStatus()
|
||||
progress = fmt.Sprintf(" (%d/%d)", completed, total)
|
||||
}
|
||||
}
|
||||
|
||||
for i, col := range m.cols {
|
||||
// for i, task := range m.rows[r] {
|
||||
if m.cols[i].Width <= 0 {
|
||||
@@ -589,8 +634,16 @@ func (m *Model) renderRow(r int) string {
|
||||
cellStyle = cellStyle.Inherit(m.styles.Selected)
|
||||
}
|
||||
|
||||
// Render cell content with tree formatting for description column
|
||||
var cellContent string
|
||||
if strings.Contains(col.Name, "description") && m.taskTree != nil {
|
||||
cellContent = m.renderTreeDescription(r, depth, hasChildren, progress)
|
||||
} else {
|
||||
cellContent = m.rows[r].GetString(col.Name)
|
||||
}
|
||||
|
||||
style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true)
|
||||
renderedCell := cellStyle.Render(style.Render(runewidth.Truncate(m.rows[r].GetString(col.Name), m.cols[i].Width, "…")))
|
||||
renderedCell := cellStyle.Render(style.Render(runewidth.Truncate(cellContent, m.cols[i].Width, "…")))
|
||||
s = append(s, renderedCell)
|
||||
}
|
||||
|
||||
@@ -603,6 +656,25 @@ func (m *Model) renderRow(r int) string {
|
||||
return row
|
||||
}
|
||||
|
||||
// renderTreeDescription renders the description column with tree indentation and progress
|
||||
func (m *Model) renderTreeDescription(rowIdx int, depth int, hasChildren bool, progress string) string {
|
||||
task := m.rows[rowIdx]
|
||||
desc := task.Description
|
||||
|
||||
// Build indentation and tree characters
|
||||
prefix := ""
|
||||
if depth > 0 {
|
||||
prefix = strings.Repeat(" ", depth) + "└─ "
|
||||
}
|
||||
|
||||
// Add progress indicator for parent tasks
|
||||
if hasChildren {
|
||||
desc = desc + progress
|
||||
}
|
||||
|
||||
return prefix + desc
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
|
||||
@@ -187,14 +187,14 @@ func (t *TimestampEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
t.adjustDate(1)
|
||||
}
|
||||
|
||||
// Time field adjustments (uppercase - 30 minutes) or date adjustments (week)
|
||||
// Time field adjustments (uppercase - 60 minutes) or date adjustments (week)
|
||||
case "J":
|
||||
// Set current time on first edit if empty
|
||||
if t.isEmpty {
|
||||
t.setCurrentTime()
|
||||
}
|
||||
if t.currentField == TimeField {
|
||||
t.adjustTime(-30)
|
||||
t.adjustTime(-60)
|
||||
} else {
|
||||
t.adjustDate(-7)
|
||||
}
|
||||
@@ -204,7 +204,7 @@ func (t *TimestampEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
t.setCurrentTime()
|
||||
}
|
||||
if t.currentField == TimeField {
|
||||
t.adjustTime(30)
|
||||
t.adjustTime(60)
|
||||
} else {
|
||||
t.adjustDate(7)
|
||||
}
|
||||
|
||||
@@ -530,7 +530,7 @@ func (m *Model) renderRow(r int) string {
|
||||
if m.rows[r].IsGap {
|
||||
gapText := m.rows[r].GetString("gap_display")
|
||||
gapStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240")).
|
||||
Foreground(m.common.Styles.Palette.Muted.GetForeground()).
|
||||
Align(lipgloss.Center).
|
||||
Width(m.Width())
|
||||
return gapStyle.Render(gapText)
|
||||
|
||||
63
flake.lock
generated
63
flake.lock
generated
@@ -1,58 +1,59 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"lastModified": 1769996383,
|
||||
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1770197578,
|
||||
"narHash": "sha256-AYqlWrX09+HvGs8zM6ebZ1pwUqjkfpnv8mewYwAo+iM=",
|
||||
"owner": "NixOS",
|
||||
"lastModified": 1771369470,
|
||||
"narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "00c21e4c93d963c50d4c0c89bfa84ed6e0694df2",
|
||||
"rev": "0182a361324364ae3f436a63005877674cf45efb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"id": "nixpkgs",
|
||||
"owner": "nixos",
|
||||
"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": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"flake-parts": "flake-parts",
|
||||
"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",
|
||||
|
||||
36
flake.nix
36
flake.nix
@@ -2,24 +2,20 @@
|
||||
description = "Tasksquire";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
};
|
||||
outputs = inputs@{ self, nixpkgs, flake-parts, ... }:
|
||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ];
|
||||
|
||||
tasksquire = pkgs.buildGoModule {
|
||||
perSystem = { config, self', inputs', pkgs, system, ... }: {
|
||||
packages.tasksquire = pkgs.buildGoModule {
|
||||
pname = "tasksquire";
|
||||
version = "0.1.0";
|
||||
src = ./.;
|
||||
|
||||
# Update this hash when dependencies change.
|
||||
# You can get the correct hash by running `nix build`.
|
||||
vendorHash = "sha256-fDzQuKBZPkOATMMnYcFv/aJP62XDhL9LjM/UYre9JQ4=";
|
||||
|
||||
ldflags = [ "-s" "-w" ];
|
||||
@@ -34,13 +30,13 @@
|
||||
mainProgram = "tasksquire";
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
packages.default = tasksquire;
|
||||
packages.tasksquire = tasksquire;
|
||||
apps.default = flake-utils.lib.mkApp { drv = tasksquire; };
|
||||
|
||||
# Set the default package
|
||||
packages.default = self'.packages.tasksquire;
|
||||
|
||||
# Development shell
|
||||
devShells.default = pkgs.mkShell {
|
||||
inputsFrom = [ self'.packages.tasksquire ];
|
||||
buildInputs = with pkgs; [
|
||||
go_1_24
|
||||
gcc
|
||||
@@ -52,13 +48,9 @@
|
||||
go-tools
|
||||
gotests
|
||||
delve
|
||||
taskwarrior3
|
||||
timewarrior
|
||||
];
|
||||
CGO_CFLAGS = "-O";
|
||||
};
|
||||
|
||||
# Backward compatibility
|
||||
devShell = self.devShells.${system}.default;
|
||||
});
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
14
main.go
14
main.go
@@ -40,12 +40,18 @@ func main() {
|
||||
timewConfigPath = ""
|
||||
}
|
||||
|
||||
ts := taskwarrior.NewTaskSquire(taskrcPath)
|
||||
tws := timewarrior.NewTimeSquire(timewConfigPath)
|
||||
ctx := context.Background()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
ts := taskwarrior.NewTaskSquire(ctx, taskrcPath)
|
||||
if ts == nil {
|
||||
log.Fatal("Failed to initialize TaskSquire. Please check your Taskwarrior installation and taskrc file.")
|
||||
}
|
||||
|
||||
tws := timewarrior.NewTimeSquire(ctx, timewConfigPath)
|
||||
common := common.NewCommon(ctx, ts, tws)
|
||||
|
||||
file, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
file, err := os.OpenFile("/tmp/tasksquire.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to open log file: %v", err)
|
||||
}
|
||||
|
||||
40
opencode_sandbox.sh
Normal file
40
opencode_sandbox.sh
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# 1. Resolve the absolute path of opencode from your Nix environment
|
||||
OPENCODE_PATH=$(command -v opencode)
|
||||
|
||||
if [ -z "$OPENCODE_PATH" ]; then
|
||||
echo "❌ Error: 'opencode' not found in your PATH."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🛡️ Engaging Bubblewrap Sandbox..."
|
||||
echo "📍 Using binary: $OPENCODE_PATH"
|
||||
|
||||
# 2. Run bwrap using the absolute path
|
||||
bwrap \
|
||||
--ro-bind /bin /bin \
|
||||
--ro-bind /usr /usr \
|
||||
--ro-bind /lib /lib \
|
||||
--ro-bind /lib64 /lib64 \
|
||||
--ro-bind /nix /nix \
|
||||
--ro-bind /home/pan/.nix-profile/bin /home/pan/.nix-profile/bin \
|
||||
--ro-bind /home/pan/.config/opencode /home/pan/.config/opencode \
|
||||
--ro-bind /etc/resolv.conf /etc/resolv.conf \
|
||||
--ro-bind /etc/hosts /etc/hosts \
|
||||
--ro-bind-try /etc/ssl/certs /etc/ssl/certs \
|
||||
--ro-bind-try /etc/static/ssl/certs /etc/static/ssl/certs \
|
||||
--bind /home/pan/.local/share/opencode /home/pan/.local/share/opencode \
|
||||
--proc /proc \
|
||||
--dev-bind /dev /dev \
|
||||
--tmpfs /tmp \
|
||||
--unshare-all \
|
||||
--share-net \
|
||||
--die-with-parent \
|
||||
--bind "$(pwd)" "$(pwd)" \
|
||||
--chdir "$(pwd)" \
|
||||
--setenv PATH "$PATH" \
|
||||
--setenv HOME "$HOME" \
|
||||
--setenv TASKRC "$TASKRC" \
|
||||
--setenv TASKDATA "$TASKDATA" \
|
||||
"$OPENCODE_PATH" "$@"
|
||||
@@ -65,16 +65,9 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage {
|
||||
func (p *ContextPickerPage) SetSize(width, height int) {
|
||||
p.common.SetSize(width, height)
|
||||
|
||||
// Set list size with some padding/limits to look like a picker
|
||||
listWidth := width - 4
|
||||
if listWidth > 40 {
|
||||
listWidth = 40
|
||||
}
|
||||
listHeight := height - 6
|
||||
if listHeight > 20 {
|
||||
listHeight = 20
|
||||
}
|
||||
p.picker.SetSize(listWidth, listHeight)
|
||||
// Use shared modal sizing logic
|
||||
modalWidth, modalHeight := p.common.Styles.GetModalSize(width, height)
|
||||
p.picker.SetSize(modalWidth-2, modalHeight-2)
|
||||
}
|
||||
|
||||
func (p *ContextPickerPage) Init() tea.Cmd {
|
||||
@@ -124,20 +117,23 @@ func (p *ContextPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (p *ContextPickerPage) View() string {
|
||||
width := p.common.Width() - 4
|
||||
if width > 40 {
|
||||
width = 40
|
||||
}
|
||||
modalWidth, modalHeight := p.common.Styles.GetModalSize(p.common.Width(), p.common.Height())
|
||||
|
||||
content := p.picker.View()
|
||||
styledContent := lipgloss.NewStyle().Width(width).Render(content)
|
||||
styledContent := lipgloss.NewStyle().
|
||||
Width(modalWidth).
|
||||
Height(modalHeight).
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(p.common.Styles.Palette.Border.GetForeground()).
|
||||
Padding(0, 1).
|
||||
Render(content)
|
||||
|
||||
return lipgloss.Place(
|
||||
p.common.Width(),
|
||||
p.common.Height(),
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
p.common.Styles.Base.Render(styledContent),
|
||||
styledContent,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -55,16 +55,9 @@ func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectP
|
||||
func (p *ProjectPickerPage) SetSize(width, height int) {
|
||||
p.common.SetSize(width, height)
|
||||
|
||||
// Set list size with some padding/limits to look like a picker
|
||||
listWidth := width - 4
|
||||
if listWidth > 40 {
|
||||
listWidth = 40
|
||||
}
|
||||
listHeight := height - 6
|
||||
if listHeight > 20 {
|
||||
listHeight = 20
|
||||
}
|
||||
p.picker.SetSize(listWidth, listHeight)
|
||||
// Use shared modal sizing logic
|
||||
modalWidth, modalHeight := p.common.Styles.GetModalSize(width, height)
|
||||
p.picker.SetSize(modalWidth-2, modalHeight-2)
|
||||
}
|
||||
|
||||
func (p *ProjectPickerPage) Init() tea.Cmd {
|
||||
@@ -112,20 +105,23 @@ func (p *ProjectPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (p *ProjectPickerPage) View() string {
|
||||
width := p.common.Width() - 4
|
||||
if width > 40 {
|
||||
width = 40
|
||||
}
|
||||
modalWidth, modalHeight := p.common.Styles.GetModalSize(p.common.Width(), p.common.Height())
|
||||
|
||||
content := p.picker.View()
|
||||
styledContent := lipgloss.NewStyle().Width(width).Render(content)
|
||||
styledContent := lipgloss.NewStyle().
|
||||
Width(modalWidth).
|
||||
Height(modalHeight).
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(p.common.Styles.Palette.Border.GetForeground()).
|
||||
Padding(0, 1).
|
||||
Render(content)
|
||||
|
||||
return lipgloss.Place(
|
||||
p.common.Width(),
|
||||
p.common.Height(),
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
p.common.Styles.Base.Render(styledContent),
|
||||
styledContent,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -335,30 +335,33 @@ func (p *ProjectTaskPickerPage) View() string {
|
||||
// Create distinct styling for focused vs blurred pickers
|
||||
var projectStyled, taskStyled string
|
||||
|
||||
focusedBorder := p.common.Styles.Palette.Accent.GetForeground()
|
||||
blurredBorder := p.common.Styles.Palette.Border.GetForeground()
|
||||
|
||||
if p.focusedPicker == 0 {
|
||||
// Project picker is focused
|
||||
projectStyled = lipgloss.NewStyle().
|
||||
Border(lipgloss.ThickBorder()).
|
||||
BorderForeground(lipgloss.Color("6")). // Cyan for focused
|
||||
BorderForeground(focusedBorder).
|
||||
Padding(0, 1).
|
||||
Render(projectView)
|
||||
|
||||
taskStyled = lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("240")). // Gray for blurred
|
||||
BorderForeground(blurredBorder).
|
||||
Padding(0, 1).
|
||||
Render(taskView)
|
||||
} else {
|
||||
// Task picker is focused
|
||||
projectStyled = lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("240")). // Gray for blurred
|
||||
BorderForeground(blurredBorder).
|
||||
Padding(0, 1).
|
||||
Render(projectView)
|
||||
|
||||
taskStyled = lipgloss.NewStyle().
|
||||
Border(lipgloss.ThickBorder()).
|
||||
BorderForeground(lipgloss.Color("6")). // Cyan for focused
|
||||
BorderForeground(focusedBorder).
|
||||
Padding(0, 1).
|
||||
Render(taskView)
|
||||
}
|
||||
@@ -376,7 +379,7 @@ func (p *ProjectTaskPickerPage) View() string {
|
||||
// Add help text
|
||||
helpText := "Tab/Shift+Tab: switch focus • Enter: select • a: add task • e: edit • t: track time • Esc: cancel"
|
||||
helpStyled := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
Foreground(p.common.Styles.Palette.Muted.GetForeground()).
|
||||
Italic(true).
|
||||
Render(helpText)
|
||||
|
||||
|
||||
@@ -159,6 +159,40 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
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()
|
||||
@@ -264,17 +298,28 @@ func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) {
|
||||
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 tasks {
|
||||
for i, task := range orderedTasks {
|
||||
if task.Uuid == p.selectedTask.Uuid {
|
||||
selected = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if selected > len(tasks)-1 {
|
||||
selected = len(tasks) - 1
|
||||
if selected > len(orderedTasks)-1 {
|
||||
selected = len(orderedTasks) - 1
|
||||
}
|
||||
|
||||
// Calculate proper dimensions based on whether details panel is active
|
||||
@@ -294,7 +339,8 @@ func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) {
|
||||
p.taskTable = table.New(
|
||||
p.common,
|
||||
table.WithReport(p.activeReport),
|
||||
table.WithTasks(tasks),
|
||||
table.WithTasks(orderedTasks),
|
||||
table.WithTaskTree(taskTree),
|
||||
table.WithFocused(true),
|
||||
table.WithWidth(baseWidth),
|
||||
table.WithHeight(tableHeight),
|
||||
@@ -304,7 +350,7 @@ func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) {
|
||||
if selected == 0 {
|
||||
selected = p.taskTable.Cursor()
|
||||
}
|
||||
if selected < len(tasks) {
|
||||
if selected < len(orderedTasks) {
|
||||
p.taskTable.SetCursor(selected)
|
||||
} else {
|
||||
p.taskTable.SetCursor(len(p.tasks) - 1)
|
||||
|
||||
@@ -57,16 +57,9 @@ func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report
|
||||
func (p *ReportPickerPage) SetSize(width, height int) {
|
||||
p.common.SetSize(width, height)
|
||||
|
||||
// Set list size with some padding/limits to look like a picker
|
||||
listWidth := width - 4
|
||||
if listWidth > 40 {
|
||||
listWidth = 40
|
||||
}
|
||||
listHeight := height - 6
|
||||
if listHeight > 20 {
|
||||
listHeight = 20
|
||||
}
|
||||
p.picker.SetSize(listWidth, listHeight)
|
||||
// Use shared modal sizing logic
|
||||
modalWidth, modalHeight := p.common.Styles.GetModalSize(width, height)
|
||||
p.picker.SetSize(modalWidth-2, modalHeight-2)
|
||||
}
|
||||
|
||||
func (p *ReportPickerPage) Init() tea.Cmd {
|
||||
@@ -112,20 +105,23 @@ func (p *ReportPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (p *ReportPickerPage) View() string {
|
||||
width := p.common.Width() - 4
|
||||
if width > 40 {
|
||||
width = 40
|
||||
}
|
||||
modalWidth, modalHeight := p.common.Styles.GetModalSize(p.common.Width(), p.common.Height())
|
||||
|
||||
content := p.picker.View()
|
||||
styledContent := lipgloss.NewStyle().Width(width).Render(content)
|
||||
styledContent := lipgloss.NewStyle().
|
||||
Width(modalWidth).
|
||||
Height(modalHeight).
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(p.common.Styles.Palette.Border.GetForeground()).
|
||||
Padding(0, 1).
|
||||
Render(content)
|
||||
|
||||
return lipgloss.Place(
|
||||
p.common.Width(),
|
||||
p.common.Height(),
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
p.common.Styles.Base.Render(styledContent),
|
||||
styledContent,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package pages
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"tasksquire/common"
|
||||
"time"
|
||||
|
||||
@@ -324,11 +323,13 @@ func (p *TaskEditorPage) View() string {
|
||||
|
||||
tabs := ""
|
||||
for i, a := range p.areas {
|
||||
style := p.common.Styles.Base
|
||||
if i == p.area {
|
||||
tabs += p.common.Styles.Base.Bold(true).Render(fmt.Sprintf(" %s ", a.GetName()))
|
||||
style = style.Bold(true).Foreground(p.common.Styles.Palette.Accent.GetForeground())
|
||||
} else {
|
||||
tabs += p.common.Styles.Base.Render(fmt.Sprintf(" %s ", a.GetName()))
|
||||
style = style.Foreground(p.common.Styles.Palette.Muted.GetForeground())
|
||||
}
|
||||
tabs += style.Render(fmt.Sprintf(" %s ", a.GetName()))
|
||||
}
|
||||
|
||||
page := lipgloss.JoinVertical(
|
||||
@@ -510,6 +511,9 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task, isNew bool) *taskEd
|
||||
|
||||
udaValues := make(map[string]*string)
|
||||
for _, uda := range com.Udas {
|
||||
if uda.Name == "parenttask" {
|
||||
continue
|
||||
}
|
||||
switch uda.Type {
|
||||
case taskwarrior.UdaTypeNumeric:
|
||||
val := ""
|
||||
@@ -691,13 +695,9 @@ type tagEdit struct {
|
||||
fields []huh.Field
|
||||
|
||||
cursor int
|
||||
|
||||
newTagsValue *string
|
||||
}
|
||||
|
||||
func NewTagEdit(common *common.Common, selected *[]string, options []string) *tagEdit {
|
||||
newTags := ""
|
||||
|
||||
defaultKeymap := huh.NewDefaultKeyMap()
|
||||
|
||||
t := tagEdit{
|
||||
@@ -711,14 +711,7 @@ func NewTagEdit(common *common.Common, selected *[]string, options []string) *ta
|
||||
Filterable(true).
|
||||
WithKeyMap(defaultKeymap).
|
||||
WithTheme(common.Styles.Form),
|
||||
huh.NewInput().
|
||||
Title("New Tags").
|
||||
Value(&newTags).
|
||||
Inline(true).
|
||||
Prompt(": ").
|
||||
WithTheme(common.Styles.Form),
|
||||
},
|
||||
newTagsValue: &newTags,
|
||||
}
|
||||
|
||||
return &t
|
||||
@@ -1010,149 +1003,8 @@ func (d *detailsEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
func (d *detailsEdit) View() string {
|
||||
return d.ta.View()
|
||||
// dtls := `
|
||||
// # Cool Details!
|
||||
// ## Things I need
|
||||
// - [ ] A thing
|
||||
// - [x] Done thing
|
||||
|
||||
// ## People
|
||||
// - pe1
|
||||
// - pe2
|
||||
// `
|
||||
|
||||
// details, err := d.renderer.Render(dtls)
|
||||
// if err != nil {
|
||||
// slog.Error(err.Error())
|
||||
// return "Could not parse markdown"
|
||||
// }
|
||||
|
||||
// d.vp.SetContent(details)
|
||||
// return d.vp.View()
|
||||
}
|
||||
|
||||
// func (p *TaskEditorPage) SetSize(width, height int) {
|
||||
// p.common.SetSize(width, height)
|
||||
// }
|
||||
|
||||
// func (p *TaskEditorPage) Init() tea.Cmd {
|
||||
// // return p.form.Init()
|
||||
// return nil
|
||||
// }
|
||||
|
||||
// func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// var cmds []tea.Cmd
|
||||
|
||||
// switch msg := msg.(type) {
|
||||
// case SwitchModeMsg:
|
||||
// switch mode(msg) {
|
||||
// case modeNormal:
|
||||
// p.mode = modeNormal
|
||||
// case modeInsert:
|
||||
// p.mode = modeInsert
|
||||
// }
|
||||
// case changeAreaMsg:
|
||||
// p.selectedArea = area(msg)
|
||||
// p.columns = append([]tea.Model{p.areaList}, p.areas[p.selectedArea]...)
|
||||
// case nextColumnMsg:
|
||||
// p.columnCursor++
|
||||
// if p.columnCursor > len(p.columns)-1 {
|
||||
// p.columnCursor = 0
|
||||
// }
|
||||
// case prevColumnMsg:
|
||||
// p.columnCursor--
|
||||
// if p.columnCursor < 0 {
|
||||
// p.columnCursor = len(p.columns) - 1
|
||||
// }
|
||||
// }
|
||||
|
||||
// switch p.mode {
|
||||
// case modeNormal:
|
||||
// switch msg := msg.(type) {
|
||||
// case tea.KeyMsg:
|
||||
// switch {
|
||||
// case key.Matches(msg, p.common.Keymap.Back):
|
||||
// model, err := p.common.PopPage()
|
||||
// if err != nil {
|
||||
// slog.Error("page stack empty")
|
||||
// return nil, tea.Quit
|
||||
// }
|
||||
// return model, BackCmd
|
||||
// case key.Matches(msg, p.common.Keymap.Insert):
|
||||
// return p, p.switchModeCmd(modeInsert)
|
||||
// // case key.Matches(msg, p.common.Keymap.Ok):
|
||||
// // p.form.State = huh.StateCompleted
|
||||
// case key.Matches(msg, p.common.Keymap.Left):
|
||||
// return p, prevColumn()
|
||||
// case key.Matches(msg, p.common.Keymap.Right):
|
||||
// return p, nextColumn()
|
||||
// }
|
||||
// }
|
||||
// case modeInsert:
|
||||
// switch msg := msg.(type) {
|
||||
// case tea.KeyMsg:
|
||||
// switch {
|
||||
// case key.Matches(msg, p.common.Keymap.Back):
|
||||
// return p, p.switchModeCmd(modeNormal)
|
||||
// }
|
||||
// }
|
||||
|
||||
// var cmd tea.Cmd
|
||||
// if p.columnCursor == 0 {
|
||||
// p.areaList, cmd = p.areaList.Update(msg)
|
||||
// p.selectedArea = p.areaList.(areaList).Area()
|
||||
// cmds = append(cmds, cmd)
|
||||
// } else {
|
||||
// p.areas[p.selectedArea][p.columnCursor-1], cmd = p.areas[p.selectedArea][p.columnCursor-1].Update(msg)
|
||||
// cmds = append(cmds, cmd)
|
||||
// }
|
||||
|
||||
// }
|
||||
|
||||
// var cmd tea.Cmd
|
||||
// if p.columnCursor == 0 {
|
||||
// p.areaList, cmd = p.areaList.Update(msg)
|
||||
// p.selectedArea = p.areaList.(areaList).Area()
|
||||
// cmds = append(cmds, cmd)
|
||||
// } else {
|
||||
// p.areas[p.selectedArea][p.columnCursor-1], cmd = p.areas[p.selectedArea][p.columnCursor-1].Update(msg)
|
||||
// cmds = append(cmds, cmd)
|
||||
// }
|
||||
|
||||
// p.statusline, cmd = p.statusline.Update(msg)
|
||||
// cmds = append(cmds, cmd)
|
||||
|
||||
// // if p.form.State == huh.StateCompleted {
|
||||
// // cmds = append(cmds, p.updateTasksCmd)
|
||||
// // model, err := p.common.PopPage()
|
||||
// // if err != nil {
|
||||
// // slog.Error("page stack empty")
|
||||
// // return nil, tea.Quit
|
||||
// // }
|
||||
// // return model, tea.Batch(cmds...)
|
||||
// // }
|
||||
|
||||
// return p, tea.Batch(cmds...)
|
||||
// }
|
||||
|
||||
// func (p *TaskEditorPage) View() string {
|
||||
// columns := make([]string, len(p.columns))
|
||||
// for i, c := range p.columns {
|
||||
// columns[i] = c.View()
|
||||
// }
|
||||
|
||||
// return lipgloss.JoinVertical(
|
||||
// lipgloss.Left,
|
||||
// lipgloss.JoinHorizontal(
|
||||
// lipgloss.Top,
|
||||
// // lipgloss.Place(p.common.Width(), p.common.Height()-1, 0.5, 0.5, p.form.View(), lipgloss.WithWhitespaceBackground(p.common.Styles.Warning.GetForeground())),
|
||||
// // lipgloss.Place(p.common.Width(), p.common.Height()-1, 0.5, 0.5, p.form.View(), lipgloss.WithWhitespaceChars(" . ")),
|
||||
// columns...,
|
||||
// ),
|
||||
// p.statusline.View(),
|
||||
// )
|
||||
// }
|
||||
|
||||
func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
|
||||
p.task.Project = p.areas[0].(*taskEdit).projectPicker.GetValue()
|
||||
|
||||
@@ -1169,17 +1021,6 @@ func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
|
||||
}
|
||||
}
|
||||
|
||||
// if *(p.areas[0].(*taskEdit).newProjectName) != "" {
|
||||
// p.task.Project = *p.areas[0].(*taskEdit).newProjectName
|
||||
// }
|
||||
|
||||
if *(p.areas[1].(*tagEdit).newTagsValue) != "" {
|
||||
newTags := strings.Split(*p.areas[1].(*tagEdit).newTagsValue, " ")
|
||||
if len(newTags) > 0 {
|
||||
p.task.Tags = append(p.task.Tags, newTags...)
|
||||
}
|
||||
}
|
||||
|
||||
// Sync timestamp fields from the timeEdit area (area 2)
|
||||
p.areas[2].(*timeEdit).syncToTaskFields()
|
||||
|
||||
@@ -1188,8 +1029,6 @@ func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
|
||||
Entry: time.Now().Format("20060102T150405Z"),
|
||||
Description: *(p.areas[0].(*taskEdit).newAnnotation),
|
||||
})
|
||||
|
||||
// p.common.TW.AddTaskAnnotation(p.task.Uuid, *p.areas[0].(*taskEdit).newAnnotation)
|
||||
}
|
||||
|
||||
if _, ok := p.task.Udas["details"]; ok || p.areas[3].(*detailsEdit).ta.Value() != "" {
|
||||
@@ -1200,100 +1039,5 @@ func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
|
||||
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 "" }
|
||||
// type StatusLine struct { ... }
|
||||
// ...
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"tasksquire/common"
|
||||
"tasksquire/components/autocomplete"
|
||||
"tasksquire/components/picker"
|
||||
"tasksquire/components/timestampeditor"
|
||||
"tasksquire/taskwarrior"
|
||||
"tasksquire/timewarrior"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
@@ -21,6 +23,7 @@ type TimeEditorPage struct {
|
||||
|
||||
// Fields
|
||||
projectPicker *picker.Picker
|
||||
taskPicker *picker.Picker
|
||||
startEditor *timestampeditor.TimestampEditor
|
||||
endEditor *timestampeditor.TimestampEditor
|
||||
tagsInput *autocomplete.Autocomplete
|
||||
@@ -28,6 +31,7 @@ type TimeEditorPage struct {
|
||||
|
||||
// State
|
||||
selectedProject string
|
||||
selectedTask *taskwarrior.Task
|
||||
currentField int
|
||||
totalFields int
|
||||
uuid string // Preserved UUID tag
|
||||
@@ -38,18 +42,91 @@ type timeEditorProjectSelectedMsg struct {
|
||||
project string
|
||||
}
|
||||
|
||||
type timeEditorTaskSelectedMsg struct {
|
||||
task *taskwarrior.Task
|
||||
}
|
||||
|
||||
// createTaskPickerForProject creates a picker showing tasks with +track tag for the given project
|
||||
func createTaskPickerForProject(com *common.Common, project string, defaultTask string) *picker.Picker {
|
||||
// Build filters for tasks with +track tag
|
||||
filters := []string{"+track", "status:pending"}
|
||||
if project != "" {
|
||||
filters = append(filters, "project:"+project)
|
||||
}
|
||||
|
||||
taskItemProvider := func() []list.Item {
|
||||
tasks := com.TW.GetTasks(nil, filters...)
|
||||
// Add "(none)" as first option, then all tasks
|
||||
items := make([]list.Item, 0, len(tasks)+1)
|
||||
items = append(items, picker.NewItem("(none)"))
|
||||
for i := range tasks {
|
||||
items = append(items, picker.NewItem(tasks[i].Description))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
taskOnSelect := func(item list.Item) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// Handle "(none)" selection
|
||||
if item.FilterValue() == "(none)" {
|
||||
return timeEditorTaskSelectedMsg{task: nil}
|
||||
}
|
||||
// Find the task by description
|
||||
tasks := com.TW.GetTasks(nil, filters...)
|
||||
for _, task := range tasks {
|
||||
if task.Description == item.FilterValue() {
|
||||
return timeEditorTaskSelectedMsg{task: task}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
title := "Task"
|
||||
if project != "" {
|
||||
title = fmt.Sprintf("Task (%s)", project)
|
||||
}
|
||||
|
||||
opts := []picker.PickerOption{
|
||||
picker.WithFilterByDefault(false), // Start in list mode, not filter mode
|
||||
}
|
||||
|
||||
// Pre-select task if provided, otherwise default to "(none)"
|
||||
if defaultTask != "" {
|
||||
opts = append(opts, picker.WithDefaultValue(defaultTask))
|
||||
} else {
|
||||
opts = append(opts, picker.WithDefaultValue("(none)"))
|
||||
}
|
||||
|
||||
taskPicker := picker.New(
|
||||
com,
|
||||
title,
|
||||
taskItemProvider,
|
||||
taskOnSelect,
|
||||
opts...,
|
||||
)
|
||||
taskPicker.SetSize(50, 10)
|
||||
|
||||
return taskPicker
|
||||
}
|
||||
|
||||
func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage {
|
||||
// Extract special tags (uuid, project, track) and display tags
|
||||
uuid, selectedProject, track, displayTags := extractSpecialTags(interval.Tags)
|
||||
|
||||
// Track selected task for pre-selection
|
||||
var selectedTask *taskwarrior.Task
|
||||
var defaultTaskDescription string
|
||||
|
||||
// If UUID exists, fetch the task and add its title to display tags
|
||||
if uuid != "" {
|
||||
tasks := com.TW.GetTasks(nil, "uuid:"+uuid)
|
||||
if len(tasks) > 0 {
|
||||
taskTitle := tasks[0].Description
|
||||
selectedTask = tasks[0]
|
||||
defaultTaskDescription = selectedTask.Description
|
||||
// Add to display tags if not already present
|
||||
// Note: formatTags() will handle quoting for display, so we store the raw title
|
||||
displayTags = ensureTagPresent(displayTags, taskTitle)
|
||||
displayTags = ensureTagPresent(displayTags, defaultTaskDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +170,12 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time
|
||||
)
|
||||
projectPicker.SetSize(50, 10) // Compact size for inline use
|
||||
|
||||
// Create task picker (only if project is selected)
|
||||
var taskPicker *picker.Picker
|
||||
if selectedProject != "" {
|
||||
taskPicker = createTaskPickerForProject(com, selectedProject, defaultTaskDescription)
|
||||
}
|
||||
|
||||
// Create start timestamp editor
|
||||
startEditor := timestampeditor.New(com).
|
||||
Title("Start").
|
||||
@@ -118,13 +201,15 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time
|
||||
common: com,
|
||||
interval: interval,
|
||||
projectPicker: projectPicker,
|
||||
taskPicker: taskPicker,
|
||||
startEditor: startEditor,
|
||||
endEditor: endEditor,
|
||||
tagsInput: tagsInput,
|
||||
adjust: true, // Enable :adjust by default
|
||||
selectedProject: selectedProject,
|
||||
selectedTask: selectedTask,
|
||||
currentField: 0,
|
||||
totalFields: 5, // Now 5 fields: project, tags, start, end, adjust
|
||||
totalFields: 6, // 6 fields: project, task, tags, start, end, adjust
|
||||
uuid: uuid,
|
||||
track: track,
|
||||
}
|
||||
@@ -143,15 +228,18 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case timeEditorProjectSelectedMsg:
|
||||
// Update selected project
|
||||
p.selectedProject = msg.project
|
||||
// Blur current field (project picker)
|
||||
// Project selection happens on Enter - advance to task picker
|
||||
// (Auto-selection of project already happened in Update() switch)
|
||||
p.blurCurrentField()
|
||||
// Advance to tags field
|
||||
p.currentField = 1
|
||||
// Refresh tag autocomplete with filtered combinations
|
||||
cmds = append(cmds, p.updateTagSuggestions())
|
||||
// Focus tags input
|
||||
cmds = append(cmds, p.focusCurrentField())
|
||||
return p, tea.Batch(cmds...)
|
||||
|
||||
case timeEditorTaskSelectedMsg:
|
||||
// Task selection happens on Enter - advance to tags field
|
||||
// (Auto-selection of task already happened in Update() switch)
|
||||
p.blurCurrentField()
|
||||
p.currentField = 2
|
||||
cmds = append(cmds, p.focusCurrentField())
|
||||
return p, tea.Batch(cmds...)
|
||||
|
||||
@@ -173,6 +261,11 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
if p.currentField == 1 {
|
||||
// Task picker - let it handle Enter (will trigger taskSelectedMsg)
|
||||
break
|
||||
}
|
||||
|
||||
if p.currentField == 2 {
|
||||
// Tags field
|
||||
if p.tagsInput.HasSuggestions() {
|
||||
// Let autocomplete handle suggestion selection
|
||||
@@ -180,12 +273,12 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
// Tags confirmed without suggestions - advance to start timestamp
|
||||
p.blurCurrentField()
|
||||
p.currentField = 2
|
||||
p.currentField = 3
|
||||
cmds = append(cmds, p.focusCurrentField())
|
||||
return p, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// For all other fields (2-4: start, end, adjust), save and exit
|
||||
// For all other fields (3-5: start, end, adjust), save and exit
|
||||
p.saveInterval()
|
||||
model, err := p.common.PopPage()
|
||||
if err != nil {
|
||||
@@ -215,30 +308,116 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
switch p.currentField {
|
||||
case 0: // Project picker
|
||||
// Track the previous project selection
|
||||
previousProject := p.selectedProject
|
||||
|
||||
var model tea.Model
|
||||
model, cmd = p.projectPicker.Update(msg)
|
||||
if pk, ok := model.(*picker.Picker); ok {
|
||||
p.projectPicker = pk
|
||||
}
|
||||
case 1: // Tags (was 0)
|
||||
|
||||
// Check if the highlighted project changed (auto-selection)
|
||||
currentProject := p.projectPicker.GetValue()
|
||||
if currentProject != previousProject && currentProject != "" {
|
||||
// Update the selected project and refresh task picker
|
||||
p.selectedProject = currentProject
|
||||
// Clear task selection when project changes
|
||||
p.selectedTask = nil
|
||||
p.uuid = ""
|
||||
// Create/update task picker for the new project
|
||||
p.taskPicker = createTaskPickerForProject(p.common, currentProject, "")
|
||||
// Refresh tag autocomplete with filtered combinations
|
||||
cmds = append(cmds, p.updateTagSuggestions())
|
||||
}
|
||||
case 1: // Task picker
|
||||
if p.taskPicker != nil {
|
||||
// Track the previous task selection
|
||||
var previousTaskDesc string
|
||||
if p.selectedTask != nil {
|
||||
previousTaskDesc = p.selectedTask.Description
|
||||
}
|
||||
|
||||
var model tea.Model
|
||||
model, cmd = p.taskPicker.Update(msg)
|
||||
if pk, ok := model.(*picker.Picker); ok {
|
||||
p.taskPicker = pk
|
||||
}
|
||||
|
||||
// Check if the highlighted task changed (auto-selection)
|
||||
currentTaskDesc := p.taskPicker.GetValue()
|
||||
if currentTaskDesc != previousTaskDesc && currentTaskDesc != "" {
|
||||
// Handle "(none)" selection - clear task state
|
||||
if currentTaskDesc == "(none)" {
|
||||
p.selectedTask = nil
|
||||
p.uuid = ""
|
||||
p.track = ""
|
||||
// Don't clear tags - user might still want manual tags
|
||||
// Refresh tag suggestions
|
||||
cmds = append(cmds, p.updateTagSuggestions())
|
||||
} else {
|
||||
// Find and update the selected task
|
||||
filters := []string{"+track", "status:pending"}
|
||||
if p.selectedProject != "" {
|
||||
filters = append(filters, "project:"+p.selectedProject)
|
||||
}
|
||||
tasks := p.common.TW.GetTasks(nil, filters...)
|
||||
for _, task := range tasks {
|
||||
if task.Description == currentTaskDesc {
|
||||
// Update selected task
|
||||
p.selectedTask = task
|
||||
p.uuid = task.Uuid
|
||||
|
||||
// Build tags from task
|
||||
tags := []string{}
|
||||
|
||||
// Add task description
|
||||
if task.Description != "" {
|
||||
tags = append(tags, task.Description)
|
||||
}
|
||||
|
||||
// Add task tags (excluding "track" tag since it's preserved separately)
|
||||
for _, tag := range task.Tags {
|
||||
if tag != "track" {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
// Store track tag if present
|
||||
if task.HasTag("track") {
|
||||
p.track = "track"
|
||||
}
|
||||
|
||||
// Update tags input
|
||||
p.tagsInput.SetValue(formatTags(tags))
|
||||
|
||||
// Refresh tag suggestions
|
||||
cmds = append(cmds, p.updateTagSuggestions())
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case 2: // Tags
|
||||
var model tea.Model
|
||||
model, cmd = p.tagsInput.Update(msg)
|
||||
if ac, ok := model.(*autocomplete.Autocomplete); ok {
|
||||
p.tagsInput = ac
|
||||
}
|
||||
case 2: // Start (was 1)
|
||||
case 3: // Start
|
||||
var model tea.Model
|
||||
model, cmd = p.startEditor.Update(msg)
|
||||
if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
|
||||
p.startEditor = editor
|
||||
}
|
||||
case 3: // End (was 2)
|
||||
case 4: // End
|
||||
var model tea.Model
|
||||
model, cmd = p.endEditor.Update(msg)
|
||||
if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
|
||||
p.endEditor = editor
|
||||
}
|
||||
case 4: // Adjust (was 3)
|
||||
case 5: // Adjust
|
||||
// Handle adjust toggle with space/enter
|
||||
if msg, ok := msg.(tea.KeyMsg); ok {
|
||||
if msg.String() == " " || msg.String() == "enter" {
|
||||
@@ -256,13 +435,18 @@ func (p *TimeEditorPage) focusCurrentField() tea.Cmd {
|
||||
case 0:
|
||||
return p.projectPicker.Init() // Picker doesn't have explicit Focus()
|
||||
case 1:
|
||||
if p.taskPicker != nil {
|
||||
return p.taskPicker.Init()
|
||||
}
|
||||
return nil
|
||||
case 2:
|
||||
p.tagsInput.Focus()
|
||||
return p.tagsInput.Init()
|
||||
case 2:
|
||||
return p.startEditor.Focus()
|
||||
case 3:
|
||||
return p.endEditor.Focus()
|
||||
return p.startEditor.Focus()
|
||||
case 4:
|
||||
return p.endEditor.Focus()
|
||||
case 5:
|
||||
// Adjust checkbox doesn't need focus action
|
||||
return nil
|
||||
}
|
||||
@@ -272,14 +456,16 @@ func (p *TimeEditorPage) focusCurrentField() tea.Cmd {
|
||||
func (p *TimeEditorPage) blurCurrentField() {
|
||||
switch p.currentField {
|
||||
case 0:
|
||||
// Picker doesn't have explicit Blur(), state handled by Update
|
||||
// Project picker doesn't have explicit Blur(), state handled by Update
|
||||
case 1:
|
||||
p.tagsInput.Blur()
|
||||
// Task picker doesn't have explicit Blur(), state handled by Update
|
||||
case 2:
|
||||
p.startEditor.Blur()
|
||||
p.tagsInput.Blur()
|
||||
case 3:
|
||||
p.endEditor.Blur()
|
||||
p.startEditor.Blur()
|
||||
case 4:
|
||||
p.endEditor.Blur()
|
||||
case 5:
|
||||
// Adjust checkbox doesn't need blur action
|
||||
}
|
||||
}
|
||||
@@ -304,10 +490,33 @@ func (p *TimeEditorPage) View() string {
|
||||
sections = append(sections, "")
|
||||
sections = append(sections, "")
|
||||
|
||||
// Tags input (now field 1, was first)
|
||||
// Task picker (field 1)
|
||||
if p.currentField == 1 {
|
||||
if p.taskPicker != nil {
|
||||
sections = append(sections, p.taskPicker.View())
|
||||
} else {
|
||||
// No project selected yet
|
||||
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
|
||||
sections = append(sections, blurredLabelStyle.Render("Task"))
|
||||
sections = append(sections, lipgloss.NewStyle().Faint(true).Render("(select a project first)"))
|
||||
}
|
||||
} else {
|
||||
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
|
||||
sections = append(sections, blurredLabelStyle.Render("Task"))
|
||||
if p.selectedTask != nil {
|
||||
sections = append(sections, lipgloss.NewStyle().Faint(true).Render(p.selectedTask.Description))
|
||||
} else {
|
||||
sections = append(sections, lipgloss.NewStyle().Faint(true).Render("(none)"))
|
||||
}
|
||||
}
|
||||
|
||||
sections = append(sections, "")
|
||||
sections = append(sections, "")
|
||||
|
||||
// Tags input (field 2)
|
||||
tagsLabelStyle := p.common.Styles.Form.Focused.Title
|
||||
tagsLabel := tagsLabelStyle.Render("Tags")
|
||||
if p.currentField == 1 { // Changed from 0
|
||||
if p.currentField == 2 {
|
||||
sections = append(sections, tagsLabel)
|
||||
sections = append(sections, p.tagsInput.View())
|
||||
descStyle := p.common.Styles.Form.Focused.Description
|
||||
@@ -321,15 +530,15 @@ func (p *TimeEditorPage) View() string {
|
||||
sections = append(sections, "")
|
||||
sections = append(sections, "")
|
||||
|
||||
// Start editor
|
||||
// Start editor (field 3)
|
||||
sections = append(sections, p.startEditor.View())
|
||||
sections = append(sections, "")
|
||||
|
||||
// End editor
|
||||
// End editor (field 4)
|
||||
sections = append(sections, p.endEditor.View())
|
||||
sections = append(sections, "")
|
||||
|
||||
// Adjust checkbox (now field 4, was 3)
|
||||
// Adjust checkbox (field 5)
|
||||
adjustLabelStyle := p.common.Styles.Form.Focused.Title
|
||||
adjustLabel := adjustLabelStyle.Render("Adjust overlaps")
|
||||
|
||||
@@ -340,7 +549,7 @@ func (p *TimeEditorPage) View() string {
|
||||
checkbox = "[ ]"
|
||||
}
|
||||
|
||||
if p.currentField == 4 { // Changed from 3
|
||||
if p.currentField == 5 {
|
||||
focusedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
|
||||
sections = append(sections, adjustLabel)
|
||||
sections = append(sections, focusedStyle.Render(checkbox+" Auto-adjust overlapping intervals"))
|
||||
@@ -357,9 +566,17 @@ func (p *TimeEditorPage) View() string {
|
||||
|
||||
// Help text
|
||||
helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
||||
sections = append(sections, helpStyle.Render("tab/shift+tab: navigate • enter: save • esc: cancel"))
|
||||
sections = append(sections, helpStyle.Render("tab/shift+tab: navigate • enter: select/save • esc: cancel"))
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, sections...)
|
||||
content := lipgloss.JoinVertical(lipgloss.Left, sections...)
|
||||
|
||||
return lipgloss.Place(
|
||||
p.common.Width(),
|
||||
p.common.Height(),
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
content,
|
||||
)
|
||||
}
|
||||
|
||||
func (p *TimeEditorPage) SetSize(width, height int) {
|
||||
@@ -528,7 +745,7 @@ func (p *TimeEditorPage) updateTagSuggestions() tea.Cmd {
|
||||
p.tagsInput.SetValue(currentValue)
|
||||
|
||||
// If tags field is focused, refocus it
|
||||
if p.currentField == 1 {
|
||||
if p.currentField == 2 {
|
||||
p.tagsInput.Focus()
|
||||
return p.tagsInput.Init()
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ func (p *TimePage) renderHeader() string {
|
||||
|
||||
slog.Info("Rendering time page header", "text", headerText, "timespan", p.selectedTimespan)
|
||||
// Make header bold and prominent
|
||||
headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6"))
|
||||
headerStyle := lipgloss.NewStyle().Bold(true).Foreground(p.common.Styles.Palette.Accent.GetForeground())
|
||||
return headerStyle.Render(headerText)
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ type Task struct {
|
||||
Depends []string `json:"depends,omitempty"`
|
||||
DependsIds string `json:"-"`
|
||||
Urgency float32 `json:"urgency,omitempty"`
|
||||
Parent string `json:"parent,omitempty"`
|
||||
Parent string `json:"parenttask,omitempty"`
|
||||
Due string `json:"due,omitempty"`
|
||||
Wait string `json:"wait,omitempty"`
|
||||
Scheduled string `json:"scheduled,omitempty"`
|
||||
@@ -168,7 +168,7 @@ func (t *Task) GetString(fieldWFormat string) string {
|
||||
}
|
||||
return strings.Join(t.Tags, " ")
|
||||
|
||||
case "parent":
|
||||
case "parenttask":
|
||||
if format == "short" {
|
||||
return t.Parent[:8]
|
||||
}
|
||||
@@ -229,7 +229,7 @@ func (t *Task) GetString(fieldWFormat string) string {
|
||||
}
|
||||
}
|
||||
|
||||
slog.Error(fmt.Sprintf("Field not implemented: %s", field))
|
||||
slog.Error("Field not implemented", "field", field)
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ func (t *Task) GetDate(field string) time.Time {
|
||||
|
||||
dt, err := time.Parse(dtformat, dateString)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse time:", err)
|
||||
slog.Error("Failed to parse time", "error", err)
|
||||
return time.Time{}
|
||||
}
|
||||
return dt
|
||||
@@ -317,7 +317,7 @@ func (t *Task) UnmarshalJSON(data []byte) error {
|
||||
delete(m, "tags")
|
||||
delete(m, "depends")
|
||||
delete(m, "urgency")
|
||||
delete(m, "parent")
|
||||
delete(m, "parenttask")
|
||||
delete(m, "due")
|
||||
delete(m, "wait")
|
||||
delete(m, "scheduled")
|
||||
@@ -384,7 +384,7 @@ func formatDate(date string, format string) string {
|
||||
|
||||
dt, err := time.Parse(dtformat, date)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse time:", err)
|
||||
slog.Error("Failed to parse time", "error", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -408,7 +408,7 @@ func formatDate(date string, format string) string {
|
||||
case "countdown":
|
||||
return parseCountdown(time.Since(dt))
|
||||
default:
|
||||
slog.Error(fmt.Sprintf("Date format not implemented: %s", format))
|
||||
slog.Error("Date format not implemented", "format", format)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package taskwarrior
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -111,11 +112,12 @@ type TaskSquire struct {
|
||||
config *TWConfig
|
||||
reports Reports
|
||||
contexts Contexts
|
||||
ctx context.Context
|
||||
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
func NewTaskSquire(configLocation string) *TaskSquire {
|
||||
func NewTaskSquire(ctx context.Context, configLocation string) *TaskSquire {
|
||||
if _, err := exec.LookPath(twBinary); err != nil {
|
||||
slog.Error("Taskwarrior not found")
|
||||
return nil
|
||||
@@ -125,9 +127,14 @@ func NewTaskSquire(configLocation string) *TaskSquire {
|
||||
ts := &TaskSquire{
|
||||
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()
|
||||
|
||||
@@ -165,17 +172,20 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
|
||||
exportArgs = append(exportArgs, report.Name)
|
||||
}
|
||||
|
||||
cmd := exec.Command(twBinary, append(args, exportArgs...)...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(args, exportArgs...)...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("Failed getting report:", err)
|
||||
if ts.ctx.Err() == context.Canceled {
|
||||
return nil
|
||||
}
|
||||
slog.Error("Failed getting report", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
tasks := make(Tasks, 0)
|
||||
err = json.Unmarshal(output, &tasks)
|
||||
if err != nil {
|
||||
slog.Error("Failed unmarshalling tasks:", err)
|
||||
slog.Error("Failed unmarshalling tasks", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -194,10 +204,10 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
|
||||
}
|
||||
|
||||
func (ts *TaskSquire) getIds(filter []string) string {
|
||||
cmd := exec.Command(twBinary, append(append(ts.defaultArgs, filter...), "_ids")...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(append(ts.defaultArgs, filter...), "_ids")...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("Failed getting field:", err)
|
||||
slog.Error("Failed getting field", "error", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -215,7 +225,7 @@ func (ts *TaskSquire) GetContext(context string) *Context {
|
||||
if context, ok := ts.contexts[context]; ok {
|
||||
return context
|
||||
} else {
|
||||
slog.Error(fmt.Sprintf("Context not found: %s", context.Name))
|
||||
slog.Error("Context not found", "name", context)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -244,11 +254,11 @@ func (ts *TaskSquire) GetProjects() []string {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_projects"}...)...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_projects"}...)...)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("Failed getting projects:", err)
|
||||
slog.Error("Failed getting projects", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -282,11 +292,11 @@ func (ts *TaskSquire) GetTags() []string {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_tags"}...)...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_tags"}...)...)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("Failed getting tags:", err)
|
||||
slog.Error("Failed getting tags", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -329,10 +339,13 @@ func (ts *TaskSquire) GetUdas() []Uda {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, "_udas")...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "_udas")...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("Failed getting config:", err)
|
||||
if ts.ctx.Err() == context.Canceled {
|
||||
return nil
|
||||
}
|
||||
slog.Error("Failed getting UDAs", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -341,7 +354,7 @@ func (ts *TaskSquire) GetUdas() []Uda {
|
||||
if uda != "" {
|
||||
udatype := UdaType(ts.config.Get(fmt.Sprintf("uda.%s.type", uda)))
|
||||
if udatype == "" {
|
||||
slog.Error(fmt.Sprintf("UDA type not found: %s", uda))
|
||||
slog.Error("UDA type not found", "uda", uda)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -372,9 +385,9 @@ func (ts *TaskSquire) SetContext(context *Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := exec.Command(twBinary, []string{"context", context.Name}...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, []string{"context", context.Name}...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("Failed setting context:", err)
|
||||
slog.Error("Failed setting context", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -429,14 +442,17 @@ func (ts *TaskSquire) ImportTask(task *Task) {
|
||||
|
||||
tasks, err := json.Marshal(Tasks{task})
|
||||
if err != nil {
|
||||
slog.Error("Failed marshalling task:", err)
|
||||
slog.Error("Failed marshalling task", "error", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"import", "-"}...)...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"import", "-"}...)...)
|
||||
cmd.Stdin = bytes.NewBuffer(tasks)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("Failed modifying task:", err, string(out))
|
||||
if ts.ctx.Err() == context.Canceled {
|
||||
return
|
||||
}
|
||||
slog.Error("Failed modifying task", "error", err, "output", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,10 +460,10 @@ func (ts *TaskSquire) SetTaskDone(task *Task) {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"done", task.Uuid}...)...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"done", task.Uuid}...)...)
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
slog.Error("Failed setting task done:", err)
|
||||
slog.Error("Failed setting task done", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,10 +471,10 @@ func (ts *TaskSquire) DeleteTask(task *Task) {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{task.Uuid, "delete"}...)...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{task.Uuid, "delete"}...)...)
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
slog.Error("Failed deleting task:", err)
|
||||
slog.Error("Failed deleting task", "error", err)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -467,10 +483,10 @@ func (ts *TaskSquire) Undo() {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
slog.Error("Failed undoing task:", err)
|
||||
slog.Error("Failed undoing task", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,10 +494,10 @@ func (ts *TaskSquire) StartTask(task *Task) {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"start", task.Uuid}...)...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"start", task.Uuid}...)...)
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
slog.Error("Failed starting task:", err)
|
||||
slog.Error("Failed starting task", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,10 +505,10 @@ func (ts *TaskSquire) StopTask(task *Task) {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
slog.Error("Failed stopping task:", err)
|
||||
slog.Error("Failed stopping task", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,25 +516,28 @@ func (ts *TaskSquire) StopActiveTasks() {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"+ACTIVE", "export"}...)...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"+ACTIVE", "export"}...)...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("Failed getting active tasks:", "error", err, "output", string(output))
|
||||
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:", err)
|
||||
slog.Error("Failed unmarshalling active tasks", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, task := range tasks {
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
slog.Error("Failed stopping task:", err)
|
||||
slog.Error("Failed stopping task", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -527,10 +546,13 @@ func (ts *TaskSquire) GetInformation(task *Task) string {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{fmt.Sprintf("uuid:%s", task.Uuid), "information"}...)...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{fmt.Sprintf("uuid:%s", task.Uuid), "information"}...)...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("Failed getting task information:", err)
|
||||
if ts.ctx.Err() == context.Canceled {
|
||||
return ""
|
||||
}
|
||||
slog.Error("Failed getting task information", "error", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -541,18 +563,21 @@ func (ts *TaskSquire) AddTaskAnnotation(uuid string, annotation string) {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{uuid, "annotate", annotation}...)...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{uuid, "annotate", annotation}...)...)
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
slog.Error("Failed adding annotation:", err)
|
||||
slog.Error("Failed adding annotation", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *TaskSquire) extractConfig() *TWConfig {
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_show"}...)...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_show"}...)...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("Failed getting config:", err)
|
||||
if ts.ctx.Err() == context.Canceled {
|
||||
return nil
|
||||
}
|
||||
slog.Error("Failed getting config", "error", err, "output", string(output))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -560,7 +585,7 @@ func (ts *TaskSquire) extractConfig() *TWConfig {
|
||||
}
|
||||
|
||||
func (ts *TaskSquire) extractReports() Reports {
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_config"}...)...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_config"}...)...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -606,11 +631,14 @@ func extractReports(config string) []string {
|
||||
}
|
||||
|
||||
func (ts *TaskSquire) extractContexts() Contexts {
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_context"}...)...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_context"}...)...)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("Failed getting contexts:", err)
|
||||
if ts.ctx.Err() == context.Canceled {
|
||||
return nil
|
||||
}
|
||||
slog.Error("Failed getting contexts", "error", err, "output", string(output))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package taskwarrior
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
@@ -56,7 +57,7 @@ func TestTaskSquire_GetContext(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.prep()
|
||||
ts := NewTaskSquire(tt.fields.configLocation)
|
||||
ts := NewTaskSquire(context.Background(), tt.fields.configLocation)
|
||||
if got := ts.GetActiveContext(); got.Name != tt.want {
|
||||
t.Errorf("TaskSquire.GetContext() = %v, want %v", got, tt.want)
|
||||
}
|
||||
|
||||
127
taskwarrior/tree.go
Normal file
127
taskwarrior/tree.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package taskwarrior
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// TaskNode represents a task in the tree structure
|
||||
type TaskNode struct {
|
||||
Task *Task
|
||||
Children []*TaskNode
|
||||
Parent *TaskNode
|
||||
Depth int
|
||||
}
|
||||
|
||||
// TaskTree manages the hierarchical task structure
|
||||
type TaskTree struct {
|
||||
Nodes map[string]*TaskNode // UUID -> TaskNode
|
||||
Roots []*TaskNode // Top-level tasks (no parent)
|
||||
FlatList []*TaskNode // Flattened tree in display order
|
||||
}
|
||||
|
||||
// BuildTaskTree constructs a tree from a flat list of tasks
|
||||
// Three-pass algorithm:
|
||||
// 1. Create all nodes
|
||||
// 2. Establish parent-child relationships
|
||||
// 3. Calculate depths and flatten tree
|
||||
func BuildTaskTree(tasks Tasks) *TaskTree {
|
||||
tree := &TaskTree{
|
||||
Nodes: make(map[string]*TaskNode),
|
||||
Roots: make([]*TaskNode, 0),
|
||||
FlatList: make([]*TaskNode, 0),
|
||||
}
|
||||
|
||||
// Pass 1: Create all nodes
|
||||
for _, task := range tasks {
|
||||
node := &TaskNode{
|
||||
Task: task,
|
||||
Children: make([]*TaskNode, 0),
|
||||
Parent: nil,
|
||||
Depth: 0,
|
||||
}
|
||||
tree.Nodes[task.Uuid] = node
|
||||
}
|
||||
|
||||
// Pass 2: Establish parent-child relationships
|
||||
// Iterate over original tasks slice to preserve order
|
||||
for _, task := range tasks {
|
||||
node := tree.Nodes[task.Uuid]
|
||||
parentUUID := getParentUUID(node.Task)
|
||||
if parentUUID == "" {
|
||||
// No parent, this is a root task
|
||||
tree.Roots = append(tree.Roots, node)
|
||||
} else {
|
||||
// Find parent node
|
||||
parentNode, exists := tree.Nodes[parentUUID]
|
||||
if !exists {
|
||||
// Orphaned task - missing parent
|
||||
slog.Warn("Task has missing parent",
|
||||
"task_uuid", node.Task.Uuid,
|
||||
"parent_uuid", parentUUID,
|
||||
"task_desc", node.Task.Description)
|
||||
// Treat as root (graceful degradation)
|
||||
tree.Roots = append(tree.Roots, node)
|
||||
} else {
|
||||
// Establish relationship
|
||||
node.Parent = parentNode
|
||||
parentNode.Children = append(parentNode.Children, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: Calculate depths and flatten tree
|
||||
for _, root := range tree.Roots {
|
||||
flattenNode(root, 0, &tree.FlatList)
|
||||
}
|
||||
|
||||
return tree
|
||||
}
|
||||
|
||||
// getParentUUID extracts the parent UUID from a task's UDAs
|
||||
func getParentUUID(task *Task) string {
|
||||
if task.Udas == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
parentVal, exists := task.Udas["parenttask"]
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Parent UDA is stored as a string
|
||||
if parentStr, ok := parentVal.(string); ok {
|
||||
return parentStr
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// flattenNode recursively flattens the tree in depth-first order
|
||||
func flattenNode(node *TaskNode, depth int, flatList *[]*TaskNode) {
|
||||
node.Depth = depth
|
||||
*flatList = append(*flatList, node)
|
||||
|
||||
// Recursively flatten children
|
||||
for _, child := range node.Children {
|
||||
flattenNode(child, depth+1, flatList)
|
||||
}
|
||||
}
|
||||
|
||||
// GetChildrenStatus returns completed/total counts for a parent task
|
||||
func (tn *TaskNode) GetChildrenStatus() (completed int, total int) {
|
||||
total = len(tn.Children)
|
||||
completed = 0
|
||||
|
||||
for _, child := range tn.Children {
|
||||
if child.Task.Status == "completed" {
|
||||
completed++
|
||||
}
|
||||
}
|
||||
|
||||
return completed, total
|
||||
}
|
||||
|
||||
// HasChildren returns true if the node has any children
|
||||
func (tn *TaskNode) HasChildren() bool {
|
||||
return len(tn.Children) > 0
|
||||
}
|
||||
345
taskwarrior/tree_test.go
Normal file
345
taskwarrior/tree_test.go
Normal file
@@ -0,0 +1,345 @@
|
||||
package taskwarrior
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildTaskTree_EmptyList(t *testing.T) {
|
||||
tasks := Tasks{}
|
||||
tree := BuildTaskTree(tasks)
|
||||
|
||||
if tree == nil {
|
||||
t.Fatal("Expected tree to be non-nil")
|
||||
}
|
||||
|
||||
if len(tree.Nodes) != 0 {
|
||||
t.Errorf("Expected 0 nodes, got %d", len(tree.Nodes))
|
||||
}
|
||||
|
||||
if len(tree.Roots) != 0 {
|
||||
t.Errorf("Expected 0 roots, got %d", len(tree.Roots))
|
||||
}
|
||||
|
||||
if len(tree.FlatList) != 0 {
|
||||
t.Errorf("Expected 0 items in flat list, got %d", len(tree.FlatList))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTaskTree_NoParents(t *testing.T) {
|
||||
tasks := Tasks{
|
||||
{Uuid: "task1", Description: "Task 1", Status: "pending"},
|
||||
{Uuid: "task2", Description: "Task 2", Status: "pending"},
|
||||
{Uuid: "task3", Description: "Task 3", Status: "completed"},
|
||||
}
|
||||
|
||||
tree := BuildTaskTree(tasks)
|
||||
|
||||
if len(tree.Nodes) != 3 {
|
||||
t.Errorf("Expected 3 nodes, got %d", len(tree.Nodes))
|
||||
}
|
||||
|
||||
if len(tree.Roots) != 3 {
|
||||
t.Errorf("Expected 3 roots (all tasks have no parent), got %d", len(tree.Roots))
|
||||
}
|
||||
|
||||
if len(tree.FlatList) != 3 {
|
||||
t.Errorf("Expected 3 items in flat list, got %d", len(tree.FlatList))
|
||||
}
|
||||
|
||||
// All tasks should have depth 0
|
||||
for i, node := range tree.FlatList {
|
||||
if node.Depth != 0 {
|
||||
t.Errorf("Task %d expected depth 0, got %d", i, node.Depth)
|
||||
}
|
||||
if node.HasChildren() {
|
||||
t.Errorf("Task %d should not have children", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTaskTree_SimpleParentChild(t *testing.T) {
|
||||
tasks := Tasks{
|
||||
{Uuid: "parent1", Description: "Parent Task", Status: "pending"},
|
||||
{
|
||||
Uuid: "child1",
|
||||
Description: "Child Task 1",
|
||||
Status: "pending",
|
||||
Udas: map[string]any{"parenttask": "parent1"},
|
||||
},
|
||||
{
|
||||
Uuid: "child2",
|
||||
Description: "Child Task 2",
|
||||
Status: "completed",
|
||||
Udas: map[string]any{"parenttask": "parent1"},
|
||||
},
|
||||
}
|
||||
|
||||
tree := BuildTaskTree(tasks)
|
||||
|
||||
if len(tree.Nodes) != 3 {
|
||||
t.Fatalf("Expected 3 nodes, got %d", len(tree.Nodes))
|
||||
}
|
||||
|
||||
if len(tree.Roots) != 1 {
|
||||
t.Fatalf("Expected 1 root, got %d", len(tree.Roots))
|
||||
}
|
||||
|
||||
// Check root is the parent
|
||||
root := tree.Roots[0]
|
||||
if root.Task.Uuid != "parent1" {
|
||||
t.Errorf("Expected root to be parent1, got %s", root.Task.Uuid)
|
||||
}
|
||||
|
||||
// Check parent has 2 children
|
||||
if len(root.Children) != 2 {
|
||||
t.Fatalf("Expected parent to have 2 children, got %d", len(root.Children))
|
||||
}
|
||||
|
||||
// Check children status
|
||||
completed, total := root.GetChildrenStatus()
|
||||
if total != 2 {
|
||||
t.Errorf("Expected total children = 2, got %d", total)
|
||||
}
|
||||
if completed != 1 {
|
||||
t.Errorf("Expected completed children = 1, got %d", completed)
|
||||
}
|
||||
|
||||
// Check flat list order (parent first, then children)
|
||||
if len(tree.FlatList) != 3 {
|
||||
t.Fatalf("Expected 3 items in flat list, got %d", len(tree.FlatList))
|
||||
}
|
||||
|
||||
if tree.FlatList[0].Task.Uuid != "parent1" {
|
||||
t.Errorf("Expected first item to be parent, got %s", tree.FlatList[0].Task.Uuid)
|
||||
}
|
||||
|
||||
if tree.FlatList[0].Depth != 0 {
|
||||
t.Errorf("Expected parent depth = 0, got %d", tree.FlatList[0].Depth)
|
||||
}
|
||||
|
||||
// Children should be at depth 1
|
||||
for i := 1; i < 3; i++ {
|
||||
if tree.FlatList[i].Depth != 1 {
|
||||
t.Errorf("Expected child %d depth = 1, got %d", i, tree.FlatList[i].Depth)
|
||||
}
|
||||
if tree.FlatList[i].Parent == nil {
|
||||
t.Errorf("Child %d should have a parent", i)
|
||||
} else if tree.FlatList[i].Parent.Task.Uuid != "parent1" {
|
||||
t.Errorf("Child %d parent should be parent1, got %s", i, tree.FlatList[i].Parent.Task.Uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTaskTree_MultiLevel(t *testing.T) {
|
||||
tasks := Tasks{
|
||||
{Uuid: "grandparent", Description: "Grandparent", Status: "pending"},
|
||||
{
|
||||
Uuid: "parent1",
|
||||
Description: "Parent 1",
|
||||
Status: "pending",
|
||||
Udas: map[string]any{"parenttask": "grandparent"},
|
||||
},
|
||||
{
|
||||
Uuid: "parent2",
|
||||
Description: "Parent 2",
|
||||
Status: "pending",
|
||||
Udas: map[string]any{"parenttask": "grandparent"},
|
||||
},
|
||||
{
|
||||
Uuid: "child1",
|
||||
Description: "Child 1",
|
||||
Status: "pending",
|
||||
Udas: map[string]any{"parenttask": "parent1"},
|
||||
},
|
||||
{
|
||||
Uuid: "grandchild1",
|
||||
Description: "Grandchild 1",
|
||||
Status: "completed",
|
||||
Udas: map[string]any{"parenttask": "child1"},
|
||||
},
|
||||
}
|
||||
|
||||
tree := BuildTaskTree(tasks)
|
||||
|
||||
if len(tree.Nodes) != 5 {
|
||||
t.Fatalf("Expected 5 nodes, got %d", len(tree.Nodes))
|
||||
}
|
||||
|
||||
if len(tree.Roots) != 1 {
|
||||
t.Fatalf("Expected 1 root, got %d", len(tree.Roots))
|
||||
}
|
||||
|
||||
// Find nodes by UUID
|
||||
grandparentNode := tree.Nodes["grandparent"]
|
||||
parent1Node := tree.Nodes["parent1"]
|
||||
child1Node := tree.Nodes["child1"]
|
||||
grandchildNode := tree.Nodes["grandchild1"]
|
||||
|
||||
// Check depths
|
||||
if grandparentNode.Depth != 0 {
|
||||
t.Errorf("Expected grandparent depth = 0, got %d", grandparentNode.Depth)
|
||||
}
|
||||
if parent1Node.Depth != 1 {
|
||||
t.Errorf("Expected parent1 depth = 1, got %d", parent1Node.Depth)
|
||||
}
|
||||
if child1Node.Depth != 2 {
|
||||
t.Errorf("Expected child1 depth = 2, got %d", child1Node.Depth)
|
||||
}
|
||||
if grandchildNode.Depth != 3 {
|
||||
t.Errorf("Expected grandchild depth = 3, got %d", grandchildNode.Depth)
|
||||
}
|
||||
|
||||
// Check parent-child relationships
|
||||
if len(grandparentNode.Children) != 2 {
|
||||
t.Errorf("Expected grandparent to have 2 children, got %d", len(grandparentNode.Children))
|
||||
}
|
||||
|
||||
if len(parent1Node.Children) != 1 {
|
||||
t.Errorf("Expected parent1 to have 1 child, got %d", len(parent1Node.Children))
|
||||
}
|
||||
|
||||
if len(child1Node.Children) != 1 {
|
||||
t.Errorf("Expected child1 to have 1 child, got %d", len(child1Node.Children))
|
||||
}
|
||||
|
||||
if grandchildNode.HasChildren() {
|
||||
t.Error("Expected grandchild to have no children")
|
||||
}
|
||||
|
||||
// Check flat list maintains tree order
|
||||
if len(tree.FlatList) != 5 {
|
||||
t.Fatalf("Expected 5 items in flat list, got %d", len(tree.FlatList))
|
||||
}
|
||||
|
||||
// Grandparent should be first
|
||||
if tree.FlatList[0].Task.Uuid != "grandparent" {
|
||||
t.Errorf("Expected first item to be grandparent, got %s", tree.FlatList[0].Task.Uuid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTaskTree_OrphanedTask(t *testing.T) {
|
||||
tasks := Tasks{
|
||||
{Uuid: "task1", Description: "Normal Task", Status: "pending"},
|
||||
{
|
||||
Uuid: "orphan",
|
||||
Description: "Orphaned Task",
|
||||
Status: "pending",
|
||||
Udas: map[string]any{"parenttask": "nonexistent"},
|
||||
},
|
||||
}
|
||||
|
||||
tree := BuildTaskTree(tasks)
|
||||
|
||||
if len(tree.Nodes) != 2 {
|
||||
t.Fatalf("Expected 2 nodes, got %d", len(tree.Nodes))
|
||||
}
|
||||
|
||||
// Orphaned task should be treated as root
|
||||
if len(tree.Roots) != 2 {
|
||||
t.Errorf("Expected 2 roots (orphan should be treated as root), got %d", len(tree.Roots))
|
||||
}
|
||||
|
||||
// Both should have depth 0
|
||||
for _, node := range tree.FlatList {
|
||||
if node.Depth != 0 {
|
||||
t.Errorf("Expected all tasks to have depth 0, got %d for %s", node.Depth, node.Task.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskNode_GetChildrenStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
children []*TaskNode
|
||||
wantComp int
|
||||
wantTotal int
|
||||
}{
|
||||
{
|
||||
name: "no children",
|
||||
children: []*TaskNode{},
|
||||
wantComp: 0,
|
||||
wantTotal: 0,
|
||||
},
|
||||
{
|
||||
name: "all pending",
|
||||
children: []*TaskNode{
|
||||
{Task: &Task{Status: "pending"}},
|
||||
{Task: &Task{Status: "pending"}},
|
||||
},
|
||||
wantComp: 0,
|
||||
wantTotal: 2,
|
||||
},
|
||||
{
|
||||
name: "all completed",
|
||||
children: []*TaskNode{
|
||||
{Task: &Task{Status: "completed"}},
|
||||
{Task: &Task{Status: "completed"}},
|
||||
{Task: &Task{Status: "completed"}},
|
||||
},
|
||||
wantComp: 3,
|
||||
wantTotal: 3,
|
||||
},
|
||||
{
|
||||
name: "mixed status",
|
||||
children: []*TaskNode{
|
||||
{Task: &Task{Status: "completed"}},
|
||||
{Task: &Task{Status: "pending"}},
|
||||
{Task: &Task{Status: "completed"}},
|
||||
{Task: &Task{Status: "pending"}},
|
||||
{Task: &Task{Status: "completed"}},
|
||||
},
|
||||
wantComp: 3,
|
||||
wantTotal: 5,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
node := &TaskNode{
|
||||
Task: &Task{},
|
||||
Children: tt.children,
|
||||
}
|
||||
|
||||
gotComp, gotTotal := node.GetChildrenStatus()
|
||||
|
||||
if gotComp != tt.wantComp {
|
||||
t.Errorf("GetChildrenStatus() completed = %d, want %d", gotComp, tt.wantComp)
|
||||
}
|
||||
if gotTotal != tt.wantTotal {
|
||||
t.Errorf("GetChildrenStatus() total = %d, want %d", gotTotal, tt.wantTotal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskNode_HasChildren(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
children []*TaskNode
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "no children",
|
||||
children: []*TaskNode{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "has children",
|
||||
children: []*TaskNode{{Task: &Task{}}},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
node := &TaskNode{
|
||||
Task: &Task{},
|
||||
Children: tt.children,
|
||||
}
|
||||
|
||||
if got := node.HasChildren(); got != tt.want {
|
||||
t.Errorf("HasChildren() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ func (i *Interval) GetString(field string) string {
|
||||
return ""
|
||||
|
||||
default:
|
||||
slog.Error(fmt.Sprintf("Field not implemented: %s", field))
|
||||
slog.Error("Field not implemented", "field", field)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func (i *Interval) GetString(field string) string {
|
||||
func (i *Interval) GetDuration() string {
|
||||
start, err := time.Parse(dtformat, i.Start)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse start time:", err)
|
||||
slog.Error("Failed to parse start time", "error", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ func (i *Interval) GetDuration() string {
|
||||
} else {
|
||||
end, err = time.Parse(dtformat, i.End)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse end time:", err)
|
||||
slog.Error("Failed to parse end time", "error", err)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,7 @@ func (i *Interval) GetDuration() string {
|
||||
func (i *Interval) GetStartTime() time.Time {
|
||||
dt, err := time.Parse(dtformat, i.Start)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse time:", err)
|
||||
slog.Error("Failed to parse time", "error", err)
|
||||
return time.Time{}
|
||||
}
|
||||
return dt
|
||||
@@ -146,7 +146,7 @@ func (i *Interval) GetEndTime() time.Time {
|
||||
}
|
||||
dt, err := time.Parse(dtformat, i.End)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse time:", err)
|
||||
slog.Error("Failed to parse time", "error", err)
|
||||
return time.Time{}
|
||||
}
|
||||
return dt
|
||||
@@ -187,7 +187,7 @@ func formatDate(date string, format string) string {
|
||||
|
||||
dt, err := time.Parse(dtformat, date)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse time:", err)
|
||||
slog.Error("Failed to parse time", "error", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ func formatDate(date string, format string) string {
|
||||
case "relative":
|
||||
return parseDurationVague(time.Until(dt))
|
||||
default:
|
||||
slog.Error(fmt.Sprintf("Date format not implemented: %s", format))
|
||||
slog.Error("Date format not implemented", "format", format)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ package timewarrior
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -44,11 +45,12 @@ type TimeSquire struct {
|
||||
configLocation string
|
||||
defaultArgs []string
|
||||
config *TWConfig
|
||||
ctx context.Context
|
||||
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
func NewTimeSquire(configLocation string) *TimeSquire {
|
||||
func NewTimeSquire(ctx context.Context, configLocation string) *TimeSquire {
|
||||
if _, err := exec.LookPath(twBinary); err != nil {
|
||||
slog.Error("Timewarrior not found")
|
||||
return nil
|
||||
@@ -57,6 +59,7 @@ func NewTimeSquire(configLocation string) *TimeSquire {
|
||||
ts := &TimeSquire{
|
||||
configLocation: configLocation,
|
||||
defaultArgs: []string{},
|
||||
ctx: ctx,
|
||||
mutex: sync.Mutex{},
|
||||
}
|
||||
ts.config = ts.extractConfig()
|
||||
@@ -75,11 +78,11 @@ func (ts *TimeSquire) GetTags() []string {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"tags"}...)...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"tags"}...)...)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("Failed getting tags:", err)
|
||||
slog.Error("Failed getting tags", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -150,17 +153,20 @@ func (ts *TimeSquire) getIntervalsUnlocked(filter ...string) Intervals {
|
||||
args = append(args, filter...)
|
||||
}
|
||||
|
||||
cmd := exec.Command(twBinary, args...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("Failed getting intervals:", err)
|
||||
if ts.ctx.Err() == context.Canceled {
|
||||
return nil
|
||||
}
|
||||
slog.Error("Failed getting intervals", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
intervals := make(Intervals, 0)
|
||||
err = json.Unmarshal(output, &intervals)
|
||||
if err != nil {
|
||||
slog.Error("Failed unmarshalling intervals:", err)
|
||||
slog.Error("Failed unmarshalling intervals", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -194,9 +200,9 @@ func (ts *TimeSquire) StartTracking(tags []string) error {
|
||||
args := append(ts.defaultArgs, "start")
|
||||
args = append(args, tags...)
|
||||
|
||||
cmd := exec.Command(twBinary, args...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("Failed starting tracking:", err)
|
||||
slog.Error("Failed starting tracking", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -207,9 +213,9 @@ func (ts *TimeSquire) StopTracking() error {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, "stop")...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "stop")...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("Failed stopping tracking:", err)
|
||||
slog.Error("Failed stopping tracking", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -220,9 +226,9 @@ func (ts *TimeSquire) ContinueTracking() error {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, "continue")...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "continue")...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("Failed continuing tracking:", err)
|
||||
slog.Error("Failed continuing tracking", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -233,9 +239,9 @@ func (ts *TimeSquire) ContinueInterval(id int) error {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"continue", fmt.Sprintf("@%d", id)}...)...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"continue", fmt.Sprintf("@%d", id)}...)...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("Failed continuing interval:", err)
|
||||
slog.Error("Failed continuing interval", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -246,9 +252,9 @@ func (ts *TimeSquire) CancelTracking() error {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, "cancel")...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "cancel")...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("Failed canceling tracking:", err)
|
||||
slog.Error("Failed canceling tracking", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -259,9 +265,9 @@ func (ts *TimeSquire) DeleteInterval(id int) error {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"delete", fmt.Sprintf("@%d", id)}...)...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"delete", fmt.Sprintf("@%d", id)}...)...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("Failed deleting interval:", err)
|
||||
slog.Error("Failed deleting interval", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -272,9 +278,9 @@ func (ts *TimeSquire) FillInterval(id int) error {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"fill", fmt.Sprintf("@%d", id)}...)...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"fill", fmt.Sprintf("@%d", id)}...)...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("Failed filling interval:", err)
|
||||
slog.Error("Failed filling interval", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -287,9 +293,9 @@ func (ts *TimeSquire) JoinInterval(id int) error {
|
||||
|
||||
// Join the current interval with the previous one
|
||||
// The previous interval has id+1 (since intervals are ordered newest first)
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"join", fmt.Sprintf("@%d", id+1), fmt.Sprintf("@%d", id)}...)...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"join", fmt.Sprintf("@%d", id+1), fmt.Sprintf("@%d", id)}...)...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("Failed joining interval:", err)
|
||||
slog.Error("Failed joining interval", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -303,7 +309,7 @@ func (ts *TimeSquire) ModifyInterval(interval *Interval, adjust bool) error {
|
||||
// Export the modified interval
|
||||
intervals, err := json.Marshal(Intervals{interval})
|
||||
if err != nil {
|
||||
slog.Error("Failed marshalling interval:", err)
|
||||
slog.Error("Failed marshalling interval", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -314,11 +320,11 @@ func (ts *TimeSquire) ModifyInterval(interval *Interval, adjust bool) error {
|
||||
}
|
||||
|
||||
// Import the modified interval
|
||||
cmd := exec.Command(twBinary, args...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
|
||||
cmd.Stdin = bytes.NewBuffer(intervals)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("Failed modifying interval:", err, string(out))
|
||||
slog.Error("Failed modifying interval", "error", err, "output", string(out))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -334,10 +340,10 @@ func (ts *TimeSquire) GetSummary(filter ...string) string {
|
||||
args = append(args, filter...)
|
||||
}
|
||||
|
||||
cmd := exec.Command(twBinary, args...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("Failed getting summary:", err)
|
||||
slog.Error("Failed getting summary", "error", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -348,7 +354,7 @@ func (ts *TimeSquire) GetActive() *Interval {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"get", "dom.active"}...)...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"get", "dom.active"}...)...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil || string(output) == "0\n" {
|
||||
return nil
|
||||
@@ -369,18 +375,18 @@ func (ts *TimeSquire) Undo() {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
slog.Error("Failed undoing:", err)
|
||||
slog.Error("Failed undoing", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *TimeSquire) extractConfig() *TWConfig {
|
||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"show"}...)...)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"show"}...)...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("Failed getting config:", err)
|
||||
slog.Error("Failed getting config", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user