Compare commits
8 Commits
2b31d9bc2b
...
feat/time
| Author | SHA1 | Date | |
|---|---|---|---|
| 474bb3dc07 | |||
| 1ffcf42773 | |||
| 44ddbc0f47 | |||
| 2e33893e29 | |||
| 46ce91196a | |||
| 70b6ee9bc7 | |||
| 2baf3859fd | |||
| 2940711b26 |
@ -24,6 +24,7 @@ type Keymap struct {
|
|||||||
SetReport key.Binding
|
SetReport key.Binding
|
||||||
SetContext key.Binding
|
SetContext key.Binding
|
||||||
SetProject key.Binding
|
SetProject key.Binding
|
||||||
|
PickProjectTask key.Binding
|
||||||
Select key.Binding
|
Select key.Binding
|
||||||
Insert key.Binding
|
Insert key.Binding
|
||||||
Tag key.Binding
|
Tag key.Binding
|
||||||
@ -127,6 +128,11 @@ func NewKeymap() *Keymap {
|
|||||||
key.WithHelp("p", "Set project"),
|
key.WithHelp("p", "Set project"),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
PickProjectTask: key.NewBinding(
|
||||||
|
key.WithKeys("P"),
|
||||||
|
key.WithHelp("P", "Pick project task"),
|
||||||
|
),
|
||||||
|
|
||||||
Select: key.NewBinding(
|
Select: key.NewBinding(
|
||||||
key.WithKeys(" "),
|
key.WithKeys(" "),
|
||||||
key.WithHelp("space", "Select"),
|
key.WithHelp("space", "Select"),
|
||||||
|
|||||||
@ -27,6 +27,10 @@ type Styles struct {
|
|||||||
Form *huh.Theme
|
Form *huh.Theme
|
||||||
TableStyle TableStyle
|
TableStyle TableStyle
|
||||||
|
|
||||||
|
Tab lipgloss.Style
|
||||||
|
ActiveTab lipgloss.Style
|
||||||
|
TabBar lipgloss.Style
|
||||||
|
|
||||||
ColumnFocused lipgloss.Style
|
ColumnFocused lipgloss.Style
|
||||||
ColumnBlurred lipgloss.Style
|
ColumnBlurred lipgloss.Style
|
||||||
ColumnInsert lipgloss.Style
|
ColumnInsert lipgloss.Style
|
||||||
@ -71,6 +75,19 @@ func NewStyles(config *taskwarrior.TWConfig) *Styles {
|
|||||||
|
|
||||||
styles.Form = formTheme
|
styles.Form = formTheme
|
||||||
|
|
||||||
|
styles.Tab = lipgloss.NewStyle().
|
||||||
|
Padding(0, 1).
|
||||||
|
Foreground(lipgloss.Color("240"))
|
||||||
|
|
||||||
|
styles.ActiveTab = styles.Tab.
|
||||||
|
Foreground(lipgloss.Color("252")).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
styles.TabBar = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||||
|
BorderForeground(lipgloss.Color("240")).
|
||||||
|
MarginBottom(1)
|
||||||
|
|
||||||
styles.ColumnFocused = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)
|
styles.ColumnFocused = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)
|
||||||
styles.ColumnBlurred = lipgloss.NewStyle().Border(lipgloss.HiddenBorder(), true).Padding(1)
|
styles.ColumnBlurred = lipgloss.NewStyle().Border(lipgloss.HiddenBorder(), true).Padding(1)
|
||||||
styles.ColumnInsert = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)
|
styles.ColumnInsert = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)
|
||||||
|
|||||||
85
common/sync.go
Normal file
85
common/sync.go
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"tasksquire/taskwarrior"
|
||||||
|
"tasksquire/timewarrior"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FindTaskByUUID queries Taskwarrior for a task with the given UUID.
|
||||||
|
// Returns nil if not found.
|
||||||
|
func FindTaskByUUID(tw taskwarrior.TaskWarrior, uuid string) *taskwarrior.Task {
|
||||||
|
if uuid == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use empty report to query by UUID filter
|
||||||
|
report := &taskwarrior.Report{Name: ""}
|
||||||
|
tasks := tw.GetTasks(report, "uuid:"+uuid)
|
||||||
|
|
||||||
|
if len(tasks) > 0 {
|
||||||
|
return tasks[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncIntervalToTask synchronizes a Timewarrior interval's state to the corresponding Taskwarrior task.
|
||||||
|
// Action should be "start" or "stop".
|
||||||
|
// This function is idempotent and handles edge cases gracefully.
|
||||||
|
func SyncIntervalToTask(interval *timewarrior.Interval, tw taskwarrior.TaskWarrior, action string) {
|
||||||
|
if interval == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract UUID from interval tags
|
||||||
|
uuid := timewarrior.ExtractUUID(interval.Tags)
|
||||||
|
if uuid == "" {
|
||||||
|
slog.Debug("Interval has no UUID tag, skipping task sync",
|
||||||
|
"intervalID", interval.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find corresponding task
|
||||||
|
task := FindTaskByUUID(tw, uuid)
|
||||||
|
if task == nil {
|
||||||
|
slog.Warn("Task not found for UUID, skipping sync",
|
||||||
|
"uuid", uuid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform sync action
|
||||||
|
switch action {
|
||||||
|
case "start":
|
||||||
|
// Start task if it's pending (idempotent - taskwarrior handles already-started tasks)
|
||||||
|
if task.Status == "pending" {
|
||||||
|
slog.Info("Starting Taskwarrior task from interval",
|
||||||
|
"uuid", uuid,
|
||||||
|
"description", task.Description,
|
||||||
|
"alreadyStarted", task.Start != "")
|
||||||
|
tw.StartTask(task)
|
||||||
|
} else {
|
||||||
|
slog.Debug("Task not pending, skipping start",
|
||||||
|
"uuid", uuid,
|
||||||
|
"status", task.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "stop":
|
||||||
|
// Only stop if task is pending and currently started
|
||||||
|
if task.Status == "pending" && task.Start != "" {
|
||||||
|
slog.Info("Stopping Taskwarrior task from interval",
|
||||||
|
"uuid", uuid,
|
||||||
|
"description", task.Description)
|
||||||
|
tw.StopTask(task)
|
||||||
|
} else {
|
||||||
|
slog.Debug("Task not started or not pending, skipping stop",
|
||||||
|
"uuid", uuid,
|
||||||
|
"status", task.Status,
|
||||||
|
"hasStart", task.Start != "")
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
slog.Error("Unknown sync action", "action", action)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -89,6 +89,11 @@ func (a *Autocomplete) SetSuggestions(suggestions []string) {
|
|||||||
a.updateFilteredSuggestions()
|
a.updateFilteredSuggestions()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasSuggestions returns true if the autocomplete is currently showing suggestions
|
||||||
|
func (a *Autocomplete) HasSuggestions() bool {
|
||||||
|
return a.showSuggestions && len(a.filteredSuggestions) > 0
|
||||||
|
}
|
||||||
|
|
||||||
// Init initializes the autocomplete
|
// Init initializes the autocomplete
|
||||||
func (a *Autocomplete) Init() tea.Cmd {
|
func (a *Autocomplete) Init() tea.Cmd {
|
||||||
return textinput.Blink
|
return textinput.Blink
|
||||||
|
|||||||
@ -637,6 +637,11 @@ func (m *MultiSelect) GetValue() any {
|
|||||||
return *m.value
|
return *m.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsFiltering returns true if the multi-select is currently filtering.
|
||||||
|
func (m *MultiSelect) IsFiltering() bool {
|
||||||
|
return m.filtering
|
||||||
|
}
|
||||||
|
|
||||||
func min(a, b int) int {
|
func min(a, b int) int {
|
||||||
if a < b {
|
if a < b {
|
||||||
return a
|
return a
|
||||||
|
|||||||
@ -36,7 +36,9 @@ type Picker struct {
|
|||||||
onCreate func(string) tea.Cmd
|
onCreate func(string) tea.Cmd
|
||||||
title string
|
title string
|
||||||
filterByDefault bool
|
filterByDefault bool
|
||||||
|
defaultValue string
|
||||||
baseItems []list.Item
|
baseItems []list.Item
|
||||||
|
focused bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type PickerOption func(*Picker)
|
type PickerOption func(*Picker)
|
||||||
@ -53,6 +55,30 @@ func WithOnCreate(onCreate func(string) tea.Cmd) PickerOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithDefaultValue(value string) PickerOption {
|
||||||
|
return func(p *Picker) {
|
||||||
|
p.defaultValue = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Picker) Focus() tea.Cmd {
|
||||||
|
p.focused = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Picker) Blur() tea.Cmd {
|
||||||
|
p.focused = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Picker) GetValue() string {
|
||||||
|
item := p.list.SelectedItem()
|
||||||
|
if item == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return item.FilterValue()
|
||||||
|
}
|
||||||
|
|
||||||
func New(
|
func New(
|
||||||
c *common.Common,
|
c *common.Common,
|
||||||
title string,
|
title string,
|
||||||
@ -69,6 +95,7 @@ func New(
|
|||||||
l.SetShowHelp(false)
|
l.SetShowHelp(false)
|
||||||
l.SetShowStatusBar(false)
|
l.SetShowStatusBar(false)
|
||||||
l.SetFilteringEnabled(true)
|
l.SetFilteringEnabled(true)
|
||||||
|
l.Filter = list.UnsortedFilter // Preserve item order, don't rank by match quality
|
||||||
|
|
||||||
// Custom key for filtering (insert mode)
|
// Custom key for filtering (insert mode)
|
||||||
l.KeyMap.Filter = key.NewBinding(
|
l.KeyMap.Filter = key.NewBinding(
|
||||||
@ -82,6 +109,7 @@ func New(
|
|||||||
itemProvider: itemProvider,
|
itemProvider: itemProvider,
|
||||||
onSelect: onSelect,
|
onSelect: onSelect,
|
||||||
title: title,
|
title: title,
|
||||||
|
focused: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.TW.GetConfig().Get("uda.tasksquire.picker.filter_by_default") == "yes" {
|
if c.TW.GetConfig().Get("uda.tasksquire.picker.filter_by_default") == "yes" {
|
||||||
@ -92,8 +120,24 @@ func New(
|
|||||||
opt(p)
|
opt(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If a default value is provided, don't start in filter mode
|
||||||
|
if p.defaultValue != "" {
|
||||||
|
p.filterByDefault = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.filterByDefault {
|
||||||
|
// Manually trigger filter mode on the list so it doesn't require a global key press
|
||||||
|
p.list, _ = p.list.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh items after entering filter mode to ensure they're visible
|
||||||
p.Refresh()
|
p.Refresh()
|
||||||
|
|
||||||
|
// If a default value is provided, select the corresponding item
|
||||||
|
if p.defaultValue != "" {
|
||||||
|
p.SelectItemByFilterValue(p.defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,18 +147,22 @@ func (p *Picker) Refresh() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Picker) updateListItems() tea.Cmd {
|
func (p *Picker) updateListItems() tea.Cmd {
|
||||||
items := p.baseItems
|
return p.updateListItemsWithFilter(p.list.FilterValue())
|
||||||
filterVal := p.list.FilterValue()
|
}
|
||||||
|
|
||||||
|
func (p *Picker) updateListItemsWithFilter(filterVal string) tea.Cmd {
|
||||||
|
items := make([]list.Item, 0, len(p.baseItems)+1)
|
||||||
|
|
||||||
|
// First add all base items
|
||||||
|
items = append(items, p.baseItems...)
|
||||||
|
|
||||||
if p.onCreate != nil && filterVal != "" {
|
if p.onCreate != nil && filterVal != "" {
|
||||||
|
// Add the creation item at the end (bottom of the list)
|
||||||
newItem := creationItem{
|
newItem := creationItem{
|
||||||
text: "(new) " + filterVal,
|
text: "(new) " + filterVal,
|
||||||
filter: filterVal,
|
filter: filterVal,
|
||||||
}
|
}
|
||||||
newItems := make([]list.Item, len(items)+1)
|
items = append(items, newItem)
|
||||||
copy(newItems, items)
|
|
||||||
newItems[len(items)] = newItem
|
|
||||||
items = newItems
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.list.SetItems(items)
|
return p.list.SetItems(items)
|
||||||
@ -134,27 +182,42 @@ func (p *Picker) SetSize(width, height int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Picker) Init() tea.Cmd {
|
func (p *Picker) Init() tea.Cmd {
|
||||||
if p.filterByDefault {
|
// Trigger list item update to ensure items are properly displayed,
|
||||||
return func() tea.Msg {
|
// especially when in filter mode with an empty filter
|
||||||
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}
|
return p.updateListItems()
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if !p.focused {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
// If filtering, let the list handle keys (including Enter to stop filtering)
|
// If filtering, update items with predicted filter before list processes the key
|
||||||
if p.list.FilterState() == list.Filtering {
|
if p.list.FilterState() == list.Filtering {
|
||||||
if key.Matches(msg, p.common.Keymap.Ok) {
|
currentFilter := p.list.FilterValue()
|
||||||
items := p.list.VisibleItems()
|
predictedFilter := currentFilter
|
||||||
if len(items) == 1 {
|
|
||||||
return p, p.handleSelect(items[0])
|
// Predict what the filter will be after this key
|
||||||
|
switch msg.Type {
|
||||||
|
case tea.KeyRunes:
|
||||||
|
predictedFilter = currentFilter + string(msg.Runes)
|
||||||
|
case tea.KeyBackspace:
|
||||||
|
if len(currentFilter) > 0 {
|
||||||
|
predictedFilter = currentFilter[:len(currentFilter)-1]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update items with predicted filter before list processes the message
|
||||||
|
if predictedFilter != currentFilter {
|
||||||
|
preCmd := p.updateListItemsWithFilter(predictedFilter)
|
||||||
|
cmds = append(cmds, preCmd)
|
||||||
|
}
|
||||||
|
|
||||||
break // Pass to list.Update
|
break // Pass to list.Update
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -168,15 +231,10 @@ func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
prevFilter := p.list.FilterValue()
|
|
||||||
p.list, cmd = p.list.Update(msg)
|
p.list, cmd = p.list.Update(msg)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
if p.list.FilterValue() != prevFilter {
|
return p, tea.Batch(cmds...)
|
||||||
updateCmd := p.updateListItems()
|
|
||||||
return p, tea.Batch(cmd, updateCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
return p, cmd
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Picker) handleSelect(item list.Item) tea.Cmd {
|
func (p *Picker) handleSelect(item list.Item) tea.Cmd {
|
||||||
@ -189,7 +247,12 @@ func (p *Picker) handleSelect(item list.Item) tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Picker) View() string {
|
func (p *Picker) View() string {
|
||||||
title := p.common.Styles.Form.Focused.Title.Render(p.title)
|
var title string
|
||||||
|
if p.focused {
|
||||||
|
title = p.common.Styles.Form.Focused.Title.Render(p.title)
|
||||||
|
} else {
|
||||||
|
title = p.common.Styles.Form.Blurred.Title.Render(p.title)
|
||||||
|
}
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, title, p.list.View())
|
return lipgloss.JoinVertical(lipgloss.Left, title, p.list.View())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
118
on-modify.timewarrior
Normal file
118
on-modify.timewarrior
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
#
|
||||||
|
# Copyright 2016 - 2021, 2023, Gothenburg Bit Factory
|
||||||
|
#
|
||||||
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
# of this software and associated documentation files (the "Software"), to deal
|
||||||
|
# in the Software without restriction, including without limitation the rights
|
||||||
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
# copies of the Software, and to permit persons to whom the Software is
|
||||||
|
# furnished to do so, subject to the following conditions:
|
||||||
|
#
|
||||||
|
# The above copyright notice and this permission notice shall be included
|
||||||
|
# in all copies or substantial portions of the Software.
|
||||||
|
#
|
||||||
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||||
|
# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||||
|
# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
# SOFTWARE.
|
||||||
|
#
|
||||||
|
# https://www.opensource.org/licenses/mit-license.php
|
||||||
|
#
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Hook should extract all the following for use as Timewarrior tags:
|
||||||
|
# UUID
|
||||||
|
# Project
|
||||||
|
# Tags
|
||||||
|
# Description
|
||||||
|
# UDAs
|
||||||
|
|
||||||
|
try:
|
||||||
|
input_stream = sys.stdin.buffer
|
||||||
|
except AttributeError:
|
||||||
|
input_stream = sys.stdin
|
||||||
|
|
||||||
|
|
||||||
|
def extract_tags_from(json_obj):
|
||||||
|
# Extract attributes for use as tags.
|
||||||
|
tags = [json_obj['description']]
|
||||||
|
|
||||||
|
# Add UUID with prefix for reliable task linking
|
||||||
|
if 'uuid' in json_obj:
|
||||||
|
tags.append('uuid:' + json_obj['uuid'])
|
||||||
|
|
||||||
|
# Add project with prefix for separate column display
|
||||||
|
if 'project' in json_obj:
|
||||||
|
tags.append('project:' + json_obj['project'])
|
||||||
|
|
||||||
|
if 'tags' in json_obj:
|
||||||
|
if type(json_obj['tags']) is str:
|
||||||
|
# Usage of tasklib (e.g. in taskpirate) converts the tag list into a string
|
||||||
|
# If this is the case, convert it back into a list first
|
||||||
|
# See https://github.com/tbabej/taskpirate/issues/11
|
||||||
|
task_tags = [tag for tag in json_obj['tags'].split(',') if tag != 'next']
|
||||||
|
tags.extend(task_tags)
|
||||||
|
else:
|
||||||
|
# Filter out the 'next' tag
|
||||||
|
task_tags = [tag for tag in json_obj['tags'] if tag != 'next']
|
||||||
|
tags.extend(task_tags)
|
||||||
|
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
def extract_annotation_from(json_obj):
|
||||||
|
|
||||||
|
if 'annotations' not in json_obj:
|
||||||
|
return '\'\''
|
||||||
|
|
||||||
|
return json_obj['annotations'][0]['description']
|
||||||
|
|
||||||
|
|
||||||
|
def main(old, new):
|
||||||
|
|
||||||
|
start_or_stop = ''
|
||||||
|
|
||||||
|
# Started task.
|
||||||
|
if 'start' in new and 'start' not in old:
|
||||||
|
start_or_stop = 'start'
|
||||||
|
|
||||||
|
# Stopped task.
|
||||||
|
elif ('start' not in new or 'end' in new) and 'start' in old:
|
||||||
|
start_or_stop = 'stop'
|
||||||
|
|
||||||
|
if start_or_stop:
|
||||||
|
tags = extract_tags_from(new)
|
||||||
|
|
||||||
|
subprocess.call(['timew', start_or_stop] + tags + [':yes'])
|
||||||
|
|
||||||
|
# Modifications to task other than start/stop
|
||||||
|
elif 'start' in new and 'start' in old:
|
||||||
|
old_tags = extract_tags_from(old)
|
||||||
|
new_tags = extract_tags_from(new)
|
||||||
|
|
||||||
|
if old_tags != new_tags:
|
||||||
|
subprocess.call(['timew', 'untag', '@1'] + old_tags + [':yes'])
|
||||||
|
subprocess.call(['timew', 'tag', '@1'] + new_tags + [':yes'])
|
||||||
|
|
||||||
|
old_annotation = extract_annotation_from(old)
|
||||||
|
new_annotation = extract_annotation_from(new)
|
||||||
|
|
||||||
|
if old_annotation != new_annotation:
|
||||||
|
subprocess.call(['timew', 'annotate', '@1', new_annotation])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
old = json.loads(input_stream.readline().decode("utf-8", errors="replace"))
|
||||||
|
new = json.loads(input_stream.readline().decode("utf-8", errors="replace"))
|
||||||
|
print(json.dumps(new))
|
||||||
|
main(old, new)
|
||||||
@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MainPage struct {
|
type MainPage struct {
|
||||||
@ -13,6 +14,9 @@ type MainPage struct {
|
|||||||
|
|
||||||
taskPage common.Component
|
taskPage common.Component
|
||||||
timePage common.Component
|
timePage common.Component
|
||||||
|
currentTab int
|
||||||
|
width int
|
||||||
|
height int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMainPage(common *common.Common) *MainPage {
|
func NewMainPage(common *common.Common) *MainPage {
|
||||||
@ -24,6 +28,7 @@ func NewMainPage(common *common.Common) *MainPage {
|
|||||||
m.timePage = NewTimePage(common)
|
m.timePage = NewTimePage(common)
|
||||||
|
|
||||||
m.activePage = m.taskPage
|
m.activePage = m.taskPage
|
||||||
|
m.currentTab = 0
|
||||||
|
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
@ -37,17 +42,39 @@ func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
m.common.SetSize(msg.Width, msg.Height)
|
m.common.SetSize(msg.Width, msg.Height)
|
||||||
|
|
||||||
|
tabHeight := lipgloss.Height(m.renderTabBar())
|
||||||
|
contentHeight := msg.Height - tabHeight
|
||||||
|
if contentHeight < 0 {
|
||||||
|
contentHeight = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
newMsg := tea.WindowSizeMsg{Width: msg.Width, Height: contentHeight}
|
||||||
|
activePage, cmd := m.activePage.Update(newMsg)
|
||||||
|
m.activePage = activePage.(common.Component)
|
||||||
|
return m, cmd
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
// Only handle tab key for page switching when at the top level (no subpages active)
|
// Only handle tab key for page switching when at the top level (no subpages active)
|
||||||
if key.Matches(msg, m.common.Keymap.Next) && !m.common.HasSubpages() {
|
if key.Matches(msg, m.common.Keymap.Next) && !m.common.HasSubpages() {
|
||||||
if m.activePage == m.taskPage {
|
if m.activePage == m.taskPage {
|
||||||
m.activePage = m.timePage
|
m.activePage = m.timePage
|
||||||
|
m.currentTab = 1
|
||||||
} else {
|
} else {
|
||||||
m.activePage = m.taskPage
|
m.activePage = m.taskPage
|
||||||
|
m.currentTab = 0
|
||||||
}
|
}
|
||||||
// Re-size the new active page just in case
|
|
||||||
m.activePage.SetSize(m.common.Width(), m.common.Height())
|
tabHeight := lipgloss.Height(m.renderTabBar())
|
||||||
|
contentHeight := m.height - tabHeight
|
||||||
|
if contentHeight < 0 {
|
||||||
|
contentHeight = 0
|
||||||
|
}
|
||||||
|
m.activePage.SetSize(m.width, contentHeight)
|
||||||
|
|
||||||
// Trigger a refresh/init on switch? Maybe not needed if we keep state.
|
// Trigger a refresh/init on switch? Maybe not needed if we keep state.
|
||||||
// But we might want to refresh data.
|
// But we might want to refresh data.
|
||||||
return m, m.activePage.Init()
|
return m, m.activePage.Init()
|
||||||
@ -60,6 +87,22 @@ func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MainPage) View() string {
|
func (m *MainPage) renderTabBar() string {
|
||||||
return m.activePage.View()
|
var tabs []string
|
||||||
|
headers := []string{"Tasks", "Time"}
|
||||||
|
|
||||||
|
for i, header := range headers {
|
||||||
|
style := m.common.Styles.Tab
|
||||||
|
if m.currentTab == i {
|
||||||
|
style = m.common.Styles.ActiveTab
|
||||||
|
}
|
||||||
|
tabs = append(tabs, style.Render(header))
|
||||||
|
}
|
||||||
|
|
||||||
|
row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
|
||||||
|
return m.common.Styles.TabBar.Width(m.common.Width()).Render(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MainPage) View() string {
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, m.renderTabBar(), m.activePage.View())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -82,6 +82,12 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case UpdateProjectMsg:
|
case UpdateProjectMsg:
|
||||||
p.activeProject = string(msg)
|
p.activeProject = string(msg)
|
||||||
cmds = append(cmds, p.getTasks())
|
cmds = append(cmds, p.getTasks())
|
||||||
|
case TaskPickedMsg:
|
||||||
|
if msg.Task != nil && msg.Task.Status == "pending" {
|
||||||
|
p.common.TW.StopActiveTasks()
|
||||||
|
p.common.TW.StartTask(msg.Task)
|
||||||
|
}
|
||||||
|
cmds = append(cmds, p.getTasks())
|
||||||
case UpdatedTasksMsg:
|
case UpdatedTasksMsg:
|
||||||
cmds = append(cmds, p.getTasks())
|
cmds = append(cmds, p.getTasks())
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
@ -119,6 +125,11 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
cmd := p.subpage.Init()
|
cmd := p.subpage.Init()
|
||||||
p.common.PushPage(p)
|
p.common.PushPage(p)
|
||||||
return p.subpage, cmd
|
return p.subpage, cmd
|
||||||
|
case key.Matches(msg, p.common.Keymap.PickProjectTask):
|
||||||
|
p.subpage = NewProjectTaskPickerPage(p.common)
|
||||||
|
cmd := p.subpage.Init()
|
||||||
|
p.common.PushPage(p)
|
||||||
|
return p.subpage, cmd
|
||||||
case key.Matches(msg, p.common.Keymap.Tag):
|
case key.Matches(msg, p.common.Keymap.Tag):
|
||||||
if p.selectedTask != nil {
|
if p.selectedTask != nil {
|
||||||
tag := p.common.TW.GetConfig().Get("uda.tasksquire.tag.default")
|
tag := p.common.TW.GetConfig().Get("uda.tasksquire.tag.default")
|
||||||
@ -137,6 +148,7 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case key.Matches(msg, p.common.Keymap.StartStop):
|
case key.Matches(msg, p.common.Keymap.StartStop):
|
||||||
if p.selectedTask != nil && p.selectedTask.Status == "pending" {
|
if p.selectedTask != nil && p.selectedTask.Status == "pending" {
|
||||||
if p.selectedTask.Start == "" {
|
if p.selectedTask.Start == "" {
|
||||||
|
p.common.TW.StopActiveTasks()
|
||||||
p.common.TW.StartTask(p.selectedTask)
|
p.common.TW.StartTask(p.selectedTask)
|
||||||
} else {
|
} else {
|
||||||
p.common.TW.StopTask(p.selectedTask)
|
p.common.TW.StopTask(p.selectedTask)
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"tasksquire/components/input"
|
"tasksquire/components/input"
|
||||||
|
"tasksquire/components/picker"
|
||||||
"tasksquire/components/timestampeditor"
|
"tasksquire/components/timestampeditor"
|
||||||
"tasksquire/taskwarrior"
|
"tasksquire/taskwarrior"
|
||||||
|
|
||||||
@ -41,6 +42,8 @@ type TaskEditorPage struct {
|
|||||||
area int
|
area int
|
||||||
areaPicker *areaPicker
|
areaPicker *areaPicker
|
||||||
areas []area
|
areas []area
|
||||||
|
|
||||||
|
infoViewport viewport.Model
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPage {
|
func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPage {
|
||||||
@ -56,7 +59,7 @@ func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPag
|
|||||||
tagOptions := p.common.TW.GetTags()
|
tagOptions := p.common.TW.GetTags()
|
||||||
|
|
||||||
p.areas = []area{
|
p.areas = []area{
|
||||||
NewTaskEdit(p.common, &p.task),
|
NewTaskEdit(p.common, &p.task, p.task.Uuid == ""),
|
||||||
NewTagEdit(p.common, &p.task.Tags, tagOptions),
|
NewTagEdit(p.common, &p.task.Tags, tagOptions),
|
||||||
NewTimeEdit(p.common, &p.task.Due, &p.task.Scheduled, &p.task.Wait, &p.task.Until),
|
NewTimeEdit(p.common, &p.task.Due, &p.task.Scheduled, &p.task.Wait, &p.task.Until),
|
||||||
NewDetailsEdit(p.common, &p.task),
|
NewDetailsEdit(p.common, &p.task),
|
||||||
@ -68,6 +71,11 @@ func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPag
|
|||||||
|
|
||||||
p.areaPicker = NewAreaPicker(com, []string{"Task", "Tags", "Dates"})
|
p.areaPicker = NewAreaPicker(com, []string{"Task", "Tags", "Dates"})
|
||||||
|
|
||||||
|
p.infoViewport = viewport.New(0, 0)
|
||||||
|
if p.task.Uuid != "" {
|
||||||
|
p.infoViewport.SetContent(p.common.TW.GetInformation(&p.task))
|
||||||
|
}
|
||||||
|
|
||||||
p.columnCursor = 1
|
p.columnCursor = 1
|
||||||
if p.task.Uuid == "" {
|
if p.task.Uuid == "" {
|
||||||
p.mode = modeInsert
|
p.mode = modeInsert
|
||||||
@ -94,10 +102,20 @@ func (p *TaskEditorPage) SetSize(width, height int) {
|
|||||||
} else {
|
} else {
|
||||||
p.colHeight = height - p.common.Styles.ColumnFocused.GetVerticalFrameSize()
|
p.colHeight = height - p.common.Styles.ColumnFocused.GetVerticalFrameSize()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p.infoViewport.Width = width - p.colWidth - p.common.Styles.ColumnFocused.GetHorizontalFrameSize()*2 - 5
|
||||||
|
if p.infoViewport.Width < 0 {
|
||||||
|
p.infoViewport.Width = 0
|
||||||
|
}
|
||||||
|
p.infoViewport.Height = p.colHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *TaskEditorPage) Init() tea.Cmd {
|
func (p *TaskEditorPage) Init() tea.Cmd {
|
||||||
return nil
|
var cmds []tea.Cmd
|
||||||
|
for _, a := range p.areas {
|
||||||
|
cmds = append(cmds, a.Init())
|
||||||
|
}
|
||||||
|
return tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
@ -110,12 +128,20 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
p.mode = mode(msg)
|
p.mode = mode(msg)
|
||||||
case prevColumnMsg:
|
case prevColumnMsg:
|
||||||
p.columnCursor--
|
p.columnCursor--
|
||||||
|
maxCols := 2
|
||||||
|
if p.task.Uuid != "" {
|
||||||
|
maxCols = 3
|
||||||
|
}
|
||||||
if p.columnCursor < 0 {
|
if p.columnCursor < 0 {
|
||||||
p.columnCursor = len(p.areas) - 1
|
p.columnCursor = maxCols - 1
|
||||||
}
|
}
|
||||||
case nextColumnMsg:
|
case nextColumnMsg:
|
||||||
p.columnCursor++
|
p.columnCursor++
|
||||||
if p.columnCursor > len(p.areas)-1 {
|
maxCols := 2
|
||||||
|
if p.task.Uuid != "" {
|
||||||
|
maxCols = 3
|
||||||
|
}
|
||||||
|
if p.columnCursor >= maxCols {
|
||||||
p.columnCursor = 0
|
p.columnCursor = 0
|
||||||
}
|
}
|
||||||
case prevAreaMsg:
|
case prevAreaMsg:
|
||||||
@ -166,20 +192,26 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
picker, cmd := p.areaPicker.Update(msg)
|
picker, cmd := p.areaPicker.Update(msg)
|
||||||
p.areaPicker = picker.(*areaPicker)
|
p.areaPicker = picker.(*areaPicker)
|
||||||
return p, cmd
|
return p, cmd
|
||||||
} else {
|
} else if p.columnCursor == 1 {
|
||||||
model, cmd := p.areas[p.area].Update(prevFieldMsg{})
|
model, cmd := p.areas[p.area].Update(prevFieldMsg{})
|
||||||
p.areas[p.area] = model.(area)
|
p.areas[p.area] = model.(area)
|
||||||
return p, cmd
|
return p, cmd
|
||||||
|
} else if p.columnCursor == 2 {
|
||||||
|
p.infoViewport.LineUp(1)
|
||||||
|
return p, nil
|
||||||
}
|
}
|
||||||
case key.Matches(msg, p.common.Keymap.Down):
|
case key.Matches(msg, p.common.Keymap.Down):
|
||||||
if p.columnCursor == 0 {
|
if p.columnCursor == 0 {
|
||||||
picker, cmd := p.areaPicker.Update(msg)
|
picker, cmd := p.areaPicker.Update(msg)
|
||||||
p.areaPicker = picker.(*areaPicker)
|
p.areaPicker = picker.(*areaPicker)
|
||||||
return p, cmd
|
return p, cmd
|
||||||
} else {
|
} else if p.columnCursor == 1 {
|
||||||
model, cmd := p.areas[p.area].Update(nextFieldMsg{})
|
model, cmd := p.areas[p.area].Update(nextFieldMsg{})
|
||||||
p.areas[p.area] = model.(area)
|
p.areas[p.area] = model.(area)
|
||||||
return p, cmd
|
return p, cmd
|
||||||
|
} else if p.columnCursor == 2 {
|
||||||
|
p.infoViewport.LineDown(1)
|
||||||
|
return p, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -212,25 +244,31 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
picker, cmd := p.areaPicker.Update(msg)
|
picker, cmd := p.areaPicker.Update(msg)
|
||||||
p.areaPicker = picker.(*areaPicker)
|
p.areaPicker = picker.(*areaPicker)
|
||||||
return p, cmd
|
return p, cmd
|
||||||
} else {
|
} else if p.columnCursor == 1 {
|
||||||
model, cmd := p.areas[p.area].Update(prevFieldMsg{})
|
model, cmd := p.areas[p.area].Update(prevFieldMsg{})
|
||||||
p.areas[p.area] = model.(area)
|
p.areas[p.area] = model.(area)
|
||||||
return p, cmd
|
return p, cmd
|
||||||
}
|
}
|
||||||
|
return p, nil
|
||||||
case key.Matches(msg, p.common.Keymap.Next):
|
case key.Matches(msg, p.common.Keymap.Next):
|
||||||
if p.columnCursor == 0 {
|
if p.columnCursor == 0 {
|
||||||
picker, cmd := p.areaPicker.Update(msg)
|
picker, cmd := p.areaPicker.Update(msg)
|
||||||
p.areaPicker = picker.(*areaPicker)
|
p.areaPicker = picker.(*areaPicker)
|
||||||
return p, cmd
|
return p, cmd
|
||||||
} else {
|
} else if p.columnCursor == 1 {
|
||||||
model, cmd := p.areas[p.area].Update(nextFieldMsg{})
|
model, cmd := p.areas[p.area].Update(nextFieldMsg{})
|
||||||
p.areas[p.area] = model.(area)
|
p.areas[p.area] = model.(area)
|
||||||
return p, cmd
|
return p, cmd
|
||||||
}
|
}
|
||||||
|
return p, nil
|
||||||
case key.Matches(msg, p.common.Keymap.Ok):
|
case key.Matches(msg, p.common.Keymap.Ok):
|
||||||
|
isFiltering := p.areas[p.area].IsFiltering()
|
||||||
model, cmd := p.areas[p.area].Update(msg)
|
model, cmd := p.areas[p.area].Update(msg)
|
||||||
if p.area != 3 {
|
if p.area != 3 {
|
||||||
p.areas[p.area] = model.(area)
|
p.areas[p.area] = model.(area)
|
||||||
|
if isFiltering {
|
||||||
|
return p, cmd
|
||||||
|
}
|
||||||
return p, tea.Batch(cmd, nextField())
|
return p, tea.Batch(cmd, nextField())
|
||||||
}
|
}
|
||||||
return p, cmd
|
return p, cmd
|
||||||
@ -241,6 +279,10 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
picker, cmd := p.areaPicker.Update(msg)
|
picker, cmd := p.areaPicker.Update(msg)
|
||||||
p.areaPicker = picker.(*areaPicker)
|
p.areaPicker = picker.(*areaPicker)
|
||||||
return p, cmd
|
return p, cmd
|
||||||
|
} else if p.columnCursor == 2 {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
p.infoViewport, cmd = p.infoViewport.Update(msg)
|
||||||
|
return p, cmd
|
||||||
} else {
|
} else {
|
||||||
model, cmd := p.areas[p.area].Update(msg)
|
model, cmd := p.areas[p.area].Update(msg)
|
||||||
p.areas[p.area] = model.(area)
|
p.areas[p.area] = model.(area)
|
||||||
@ -253,29 +295,31 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
func (p *TaskEditorPage) View() string {
|
func (p *TaskEditorPage) View() string {
|
||||||
var focusedStyle, blurredStyle lipgloss.Style
|
var focusedStyle, blurredStyle lipgloss.Style
|
||||||
if p.mode == modeInsert {
|
if p.mode == modeInsert {
|
||||||
focusedStyle = p.common.Styles.ColumnInsert.Width(p.colWidth).Height(p.colHeight)
|
focusedStyle = p.common.Styles.ColumnInsert
|
||||||
} else {
|
} else {
|
||||||
focusedStyle = p.common.Styles.ColumnFocused.Width(p.colWidth).Height(p.colHeight)
|
focusedStyle = p.common.Styles.ColumnFocused
|
||||||
}
|
}
|
||||||
blurredStyle = p.common.Styles.ColumnBlurred.Width(p.colWidth).Height(p.colHeight)
|
blurredStyle = p.common.Styles.ColumnBlurred
|
||||||
// var picker, area string
|
|
||||||
var area string
|
|
||||||
if p.columnCursor == 0 {
|
|
||||||
// picker = focusedStyle.Render(p.areaPicker.View())
|
|
||||||
area = blurredStyle.Render(p.areas[p.area].View())
|
|
||||||
} else {
|
|
||||||
// picker = blurredStyle.Render(p.areaPicker.View())
|
|
||||||
area = focusedStyle.Render(p.areas[p.area].View())
|
|
||||||
|
|
||||||
|
var area string
|
||||||
|
if p.columnCursor == 1 {
|
||||||
|
area = focusedStyle.Copy().Width(p.colWidth).Height(p.colHeight).Render(p.areas[p.area].View())
|
||||||
|
} else {
|
||||||
|
area = blurredStyle.Copy().Width(p.colWidth).Height(p.colHeight).Render(p.areas[p.area].View())
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.task.Uuid != "" {
|
if p.task.Uuid != "" {
|
||||||
|
var infoView string
|
||||||
|
if p.columnCursor == 2 {
|
||||||
|
infoView = focusedStyle.Copy().Width(p.infoViewport.Width).Height(p.infoViewport.Height).Render(p.infoViewport.View())
|
||||||
|
} else {
|
||||||
|
infoView = blurredStyle.Copy().Width(p.infoViewport.Width).Height(p.infoViewport.Height).Render(p.infoViewport.View())
|
||||||
|
}
|
||||||
area = lipgloss.JoinHorizontal(
|
area = lipgloss.JoinHorizontal(
|
||||||
lipgloss.Top,
|
lipgloss.Top,
|
||||||
area,
|
area,
|
||||||
p.common.Styles.ColumnFocused.Render(p.common.TW.GetInformation(&p.task)),
|
infoView,
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tabs := ""
|
tabs := ""
|
||||||
@ -305,8 +349,11 @@ type area interface {
|
|||||||
tea.Model
|
tea.Model
|
||||||
SetCursor(c int)
|
SetCursor(c int)
|
||||||
GetName() string
|
GetName() string
|
||||||
|
IsFiltering() bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type focusMsg struct{}
|
||||||
|
|
||||||
type areaPicker struct {
|
type areaPicker struct {
|
||||||
common *common.Common
|
common *common.Common
|
||||||
list list.Model
|
list list.Model
|
||||||
@ -378,26 +425,60 @@ func (a *areaPicker) View() string {
|
|||||||
return a.list.View()
|
return a.list.View()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EditableField interface {
|
||||||
|
tea.Model
|
||||||
|
Focus() tea.Cmd
|
||||||
|
Blur() tea.Cmd
|
||||||
|
}
|
||||||
|
|
||||||
type taskEdit struct {
|
type taskEdit struct {
|
||||||
common *common.Common
|
common *common.Common
|
||||||
fields []huh.Field
|
fields []EditableField
|
||||||
cursor int
|
cursor int
|
||||||
|
|
||||||
|
projectPicker *picker.Picker
|
||||||
// newProjectName *string
|
// newProjectName *string
|
||||||
newAnnotation *string
|
newAnnotation *string
|
||||||
udaValues map[string]*string
|
udaValues map[string]*string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTaskEdit(com *common.Common, task *taskwarrior.Task) *taskEdit {
|
func NewTaskEdit(com *common.Common, task *taskwarrior.Task, isNew bool) *taskEdit {
|
||||||
// newProject := ""
|
// newProject := ""
|
||||||
projectOptions := append([]string{"(none)"}, com.TW.GetProjects()...)
|
|
||||||
if task.Project == "" {
|
if task.Project == "" {
|
||||||
task.Project = "(none)"
|
task.Project = "(none)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
itemProvider := func() []list.Item {
|
||||||
|
projects := com.TW.GetProjects()
|
||||||
|
items := []list.Item{picker.NewItem("(none)")}
|
||||||
|
for _, proj := range projects {
|
||||||
|
items = append(items, picker.NewItem(proj))
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
onSelect := func(item list.Item) tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
onCreate := func(newProject string) tea.Cmd {
|
||||||
|
// The new project name will be used as the project value
|
||||||
|
// when the task is saved
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := []picker.PickerOption{picker.WithOnCreate(onCreate)}
|
||||||
|
if isNew {
|
||||||
|
opts = append(opts, picker.WithFilterByDefault(true))
|
||||||
|
} else {
|
||||||
|
opts = append(opts, picker.WithDefaultValue(task.Project))
|
||||||
|
}
|
||||||
|
|
||||||
|
projPicker := picker.New(com, "Project", itemProvider, onSelect, opts...)
|
||||||
|
projPicker.SetSize(70, 8)
|
||||||
|
projPicker.Blur()
|
||||||
|
|
||||||
defaultKeymap := huh.NewDefaultKeyMap()
|
defaultKeymap := huh.NewDefaultKeyMap()
|
||||||
|
|
||||||
fields := []huh.Field{
|
fields := []EditableField{
|
||||||
huh.NewInput().
|
huh.NewInput().
|
||||||
Title("Task").
|
Title("Task").
|
||||||
Value(&task.Description).
|
Value(&task.Description).
|
||||||
@ -411,12 +492,7 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task) *taskEdit {
|
|||||||
Prompt(": ").
|
Prompt(": ").
|
||||||
WithTheme(com.Styles.Form),
|
WithTheme(com.Styles.Form),
|
||||||
|
|
||||||
input.NewSelect(com).
|
projPicker,
|
||||||
Options(true, input.NewOptions(projectOptions...)...).
|
|
||||||
Title("Project").
|
|
||||||
Value(&task.Project).
|
|
||||||
WithKeyMap(defaultKeymap).
|
|
||||||
WithTheme(com.Styles.Form),
|
|
||||||
|
|
||||||
// huh.NewInput().
|
// huh.NewInput().
|
||||||
// Title("New Project").
|
// Title("New Project").
|
||||||
@ -511,6 +587,7 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task) *taskEdit {
|
|||||||
t := taskEdit{
|
t := taskEdit{
|
||||||
common: com,
|
common: com,
|
||||||
fields: fields,
|
fields: fields,
|
||||||
|
projectPicker: projPicker,
|
||||||
|
|
||||||
udaValues: udaValues,
|
udaValues: udaValues,
|
||||||
|
|
||||||
@ -527,6 +604,13 @@ func (t *taskEdit) GetName() string {
|
|||||||
return "Task"
|
return "Task"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *taskEdit) IsFiltering() bool {
|
||||||
|
if f, ok := t.fields[t.cursor].(interface{ IsFiltering() bool }); ok {
|
||||||
|
return f.IsFiltering()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (t *taskEdit) SetCursor(c int) {
|
func (t *taskEdit) SetCursor(c int) {
|
||||||
t.fields[t.cursor].Blur()
|
t.fields[t.cursor].Blur()
|
||||||
if c < 0 {
|
if c < 0 {
|
||||||
@ -538,11 +622,25 @@ func (t *taskEdit) SetCursor(c int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *taskEdit) Init() tea.Cmd {
|
func (t *taskEdit) Init() tea.Cmd {
|
||||||
return nil
|
var cmds []tea.Cmd
|
||||||
|
// Ensure focus on the active field (especially for the first one)
|
||||||
|
if len(t.fields) > 0 {
|
||||||
|
cmds = append(cmds, func() tea.Msg {
|
||||||
|
return focusMsg{}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, f := range t.fields {
|
||||||
|
cmds = append(cmds, f.Init())
|
||||||
|
}
|
||||||
|
return tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *taskEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (t *taskEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.(type) {
|
switch msg.(type) {
|
||||||
|
case focusMsg:
|
||||||
|
if len(t.fields) > 0 {
|
||||||
|
return t, t.fields[t.cursor].Focus()
|
||||||
|
}
|
||||||
case nextFieldMsg:
|
case nextFieldMsg:
|
||||||
if t.cursor == len(t.fields)-1 {
|
if t.cursor == len(t.fields)-1 {
|
||||||
t.fields[t.cursor].Blur()
|
t.fields[t.cursor].Blur()
|
||||||
@ -561,7 +659,7 @@ func (t *taskEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
t.fields[t.cursor].Focus()
|
t.fields[t.cursor].Focus()
|
||||||
default:
|
default:
|
||||||
field, cmd := t.fields[t.cursor].Update(msg)
|
field, cmd := t.fields[t.cursor].Update(msg)
|
||||||
t.fields[t.cursor] = field.(huh.Field)
|
t.fields[t.cursor] = field.(EditableField)
|
||||||
return t, cmd
|
return t, cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -624,6 +722,13 @@ func (t *tagEdit) GetName() string {
|
|||||||
return "Tags"
|
return "Tags"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *tagEdit) IsFiltering() bool {
|
||||||
|
if f, ok := t.fields[t.cursor].(interface{ IsFiltering() bool }); ok {
|
||||||
|
return f.IsFiltering()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (t *tagEdit) SetCursor(c int) {
|
func (t *tagEdit) SetCursor(c int) {
|
||||||
t.fields[t.cursor].Blur()
|
t.fields[t.cursor].Blur()
|
||||||
if c < 0 {
|
if c < 0 {
|
||||||
@ -736,6 +841,10 @@ func (t *timeEdit) GetName() string {
|
|||||||
return "Dates"
|
return "Dates"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *timeEdit) IsFiltering() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (t *timeEdit) SetCursor(c int) {
|
func (t *timeEdit) SetCursor(c int) {
|
||||||
if len(t.fields) == 0 {
|
if len(t.fields) == 0 {
|
||||||
return
|
return
|
||||||
@ -868,6 +977,10 @@ func (d *detailsEdit) GetName() string {
|
|||||||
return "Details"
|
return "Details"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *detailsEdit) IsFiltering() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (d *detailsEdit) SetCursor(c int) {
|
func (d *detailsEdit) SetCursor(c int) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1035,6 +1148,8 @@ func (d *detailsEdit) View() string {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
|
func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
|
||||||
|
p.task.Project = p.areas[0].(*taskEdit).projectPicker.GetValue()
|
||||||
|
|
||||||
if p.task.Project == "(none)" {
|
if p.task.Project == "(none)" {
|
||||||
p.task.Project = ""
|
p.task.Project = ""
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,9 +40,6 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time
|
|||||||
// Extract project from tags if it exists
|
// Extract project from tags if it exists
|
||||||
projects := com.TW.GetProjects()
|
projects := com.TW.GetProjects()
|
||||||
selectedProject, remainingTags := extractProjectFromTags(interval.Tags, projects)
|
selectedProject, remainingTags := extractProjectFromTags(interval.Tags, projects)
|
||||||
if selectedProject == "" && len(projects) > 0 {
|
|
||||||
selectedProject = projects[0] // Default to first project (required)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create project picker with onCreate support for new projects
|
// Create project picker with onCreate support for new projects
|
||||||
projectItemProvider := func() []list.Item {
|
projectItemProvider := func() []list.Item {
|
||||||
@ -66,18 +63,23 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
opts := []picker.PickerOption{
|
||||||
|
picker.WithOnCreate(projectOnCreate),
|
||||||
|
}
|
||||||
|
if selectedProject != "" {
|
||||||
|
opts = append(opts, picker.WithDefaultValue(selectedProject))
|
||||||
|
} else {
|
||||||
|
opts = append(opts, picker.WithFilterByDefault(true))
|
||||||
|
}
|
||||||
|
|
||||||
projectPicker := picker.New(
|
projectPicker := picker.New(
|
||||||
com,
|
com,
|
||||||
"Project",
|
"Project",
|
||||||
projectItemProvider,
|
projectItemProvider,
|
||||||
projectOnSelect,
|
projectOnSelect,
|
||||||
picker.WithOnCreate(projectOnCreate),
|
opts...,
|
||||||
picker.WithFilterByDefault(true),
|
|
||||||
)
|
)
|
||||||
projectPicker.SetSize(50, 10) // Compact size for inline use
|
projectPicker.SetSize(50, 10) // Compact size for inline use
|
||||||
if selectedProject != "" {
|
|
||||||
projectPicker.SelectItemByFilterValue(selectedProject)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create start timestamp editor
|
// Create start timestamp editor
|
||||||
startEditor := timestampeditor.New(com).
|
startEditor := timestampeditor.New(com).
|
||||||
@ -129,8 +131,14 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case timeEditorProjectSelectedMsg:
|
case timeEditorProjectSelectedMsg:
|
||||||
// Update selected project
|
// Update selected project
|
||||||
p.selectedProject = msg.project
|
p.selectedProject = msg.project
|
||||||
|
// Blur current field (project picker)
|
||||||
|
p.blurCurrentField()
|
||||||
|
// Advance to tags field
|
||||||
|
p.currentField = 1
|
||||||
// Refresh tag autocomplete with filtered combinations
|
// Refresh tag autocomplete with filtered combinations
|
||||||
cmds = append(cmds, p.updateTagSuggestions())
|
cmds = append(cmds, p.updateTagSuggestions())
|
||||||
|
// Focus tags input
|
||||||
|
cmds = append(cmds, p.focusCurrentField())
|
||||||
return p, tea.Batch(cmds...)
|
return p, tea.Batch(cmds...)
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
@ -144,9 +152,26 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return model, BackCmd
|
return model, BackCmd
|
||||||
|
|
||||||
case key.Matches(msg, p.common.Keymap.Ok):
|
case key.Matches(msg, p.common.Keymap.Ok):
|
||||||
// Don't save if the project picker is focused - let it handle Enter
|
// Handle Enter based on current field
|
||||||
if p.currentField != 0 {
|
if p.currentField == 0 {
|
||||||
// Save and exit
|
// Project picker - let it handle Enter (will trigger projectSelectedMsg)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.currentField == 1 {
|
||||||
|
// Tags field
|
||||||
|
if p.tagsInput.HasSuggestions() {
|
||||||
|
// Let autocomplete handle suggestion selection
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Tags confirmed without suggestions - advance to start timestamp
|
||||||
|
p.blurCurrentField()
|
||||||
|
p.currentField = 2
|
||||||
|
cmds = append(cmds, p.focusCurrentField())
|
||||||
|
return p, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For all other fields (2-4: start, end, adjust), save and exit
|
||||||
p.saveInterval()
|
p.saveInterval()
|
||||||
model, err := p.common.PopPage()
|
model, err := p.common.PopPage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -154,8 +179,6 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return nil, tea.Quit
|
return nil, tea.Quit
|
||||||
}
|
}
|
||||||
return model, tea.Batch(tea.Batch(cmds...), refreshIntervals)
|
return model, tea.Batch(tea.Batch(cmds...), refreshIntervals)
|
||||||
}
|
|
||||||
// If picker is focused, let it handle the key below
|
|
||||||
|
|
||||||
case key.Matches(msg, p.common.Keymap.Next):
|
case key.Matches(msg, p.common.Keymap.Next):
|
||||||
// Move to next field
|
// Move to next field
|
||||||
@ -347,15 +370,16 @@ func (p *TimeEditorPage) saveInterval() {
|
|||||||
|
|
||||||
// Add project to tags if not already present
|
// Add project to tags if not already present
|
||||||
if p.selectedProject != "" {
|
if p.selectedProject != "" {
|
||||||
|
projectTag := "project:" + p.selectedProject
|
||||||
projectExists := false
|
projectExists := false
|
||||||
for _, tag := range tags {
|
for _, tag := range tags {
|
||||||
if tag == p.selectedProject {
|
if tag == projectTag {
|
||||||
projectExists = true
|
projectExists = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !projectExists {
|
if !projectExists {
|
||||||
tags = append([]string{p.selectedProject}, tags...) // Prepend project
|
tags = append([]string{projectTag}, tags...) // Prepend project tag
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -415,12 +439,16 @@ func extractProjectFromTags(tags []string, projects []string) (string, []string)
|
|||||||
var remaining []string
|
var remaining []string
|
||||||
|
|
||||||
for _, tag := range tags {
|
for _, tag := range tags {
|
||||||
if foundProject == "" && projectSet[tag] {
|
// Check if this tag is a project tag (format: "project:projectname")
|
||||||
foundProject = tag // First matching project
|
if strings.HasPrefix(tag, "project:") {
|
||||||
} else {
|
projectName := strings.TrimPrefix(tag, "project:")
|
||||||
remaining = append(remaining, tag)
|
if foundProject == "" && projectSet[projectName] {
|
||||||
|
foundProject = projectName // First matching project
|
||||||
|
continue // Don't add to remaining tags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
remaining = append(remaining, tag)
|
||||||
|
}
|
||||||
|
|
||||||
return foundProject, remaining
|
return foundProject, remaining
|
||||||
}
|
}
|
||||||
@ -432,6 +460,8 @@ func filterTagCombinationsByProject(combinations []string, project string) []str
|
|||||||
return combinations
|
return combinations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
projectTag := "project:" + project
|
||||||
|
|
||||||
var filtered []string
|
var filtered []string
|
||||||
for _, combo := range combinations {
|
for _, combo := range combinations {
|
||||||
// Parse the combination into individual tags
|
// Parse the combination into individual tags
|
||||||
@ -439,11 +469,11 @@ func filterTagCombinationsByProject(combinations []string, project string) []str
|
|||||||
|
|
||||||
// Check if project exists in this combination
|
// Check if project exists in this combination
|
||||||
for _, tag := range tags {
|
for _, tag := range tags {
|
||||||
if tag == project {
|
if tag == projectTag {
|
||||||
// Found the project - now remove it from display
|
// Found the project - now remove it from display
|
||||||
var displayTags []string
|
var displayTags []string
|
||||||
for _, t := range tags {
|
for _, t := range tags {
|
||||||
if t != project {
|
if t != projectTag {
|
||||||
displayTags = append(displayTags, t)
|
displayTags = append(displayTags, t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,7 @@ type TimePage struct {
|
|||||||
data timewarrior.Intervals
|
data timewarrior.Intervals
|
||||||
|
|
||||||
shouldSelectActive bool
|
shouldSelectActive bool
|
||||||
|
pendingSyncAction string // "start", "stop", or "" (empty means no pending action)
|
||||||
|
|
||||||
selectedTimespan string
|
selectedTimespan string
|
||||||
subpage common.Component
|
subpage common.Component
|
||||||
@ -163,8 +164,27 @@ func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case intervalsMsg:
|
case intervalsMsg:
|
||||||
p.data = timewarrior.Intervals(msg)
|
p.data = timewarrior.Intervals(msg)
|
||||||
p.populateTable(p.data)
|
p.populateTable(p.data)
|
||||||
|
|
||||||
|
// If we have a pending sync action (from continuing an interval),
|
||||||
|
// execute it now that intervals are refreshed
|
||||||
|
if p.pendingSyncAction != "" {
|
||||||
|
action := p.pendingSyncAction
|
||||||
|
p.pendingSyncAction = ""
|
||||||
|
cmds = append(cmds, p.syncActiveIntervalAfterRefresh(action))
|
||||||
|
}
|
||||||
|
case TaskPickedMsg:
|
||||||
|
if msg.Task != nil && msg.Task.Status == "pending" {
|
||||||
|
p.common.TW.StopActiveTasks()
|
||||||
|
p.common.TW.StartTask(msg.Task)
|
||||||
|
cmds = append(cmds, p.getIntervals())
|
||||||
|
cmds = append(cmds, doTick())
|
||||||
|
}
|
||||||
case RefreshIntervalsMsg:
|
case RefreshIntervalsMsg:
|
||||||
cmds = append(cmds, p.getIntervals())
|
cmds = append(cmds, p.getIntervals())
|
||||||
|
cmds = append(cmds, doTick())
|
||||||
|
case BackMsg:
|
||||||
|
// Restart tick loop when returning from subpage
|
||||||
|
cmds = append(cmds, doTick())
|
||||||
case tickMsg:
|
case tickMsg:
|
||||||
cmds = append(cmds, p.getIntervals())
|
cmds = append(cmds, p.getIntervals())
|
||||||
cmds = append(cmds, doTick())
|
cmds = append(cmds, doTick())
|
||||||
@ -178,17 +198,42 @@ func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
cmd := p.subpage.Init()
|
cmd := p.subpage.Init()
|
||||||
p.common.PushPage(p)
|
p.common.PushPage(p)
|
||||||
return p.subpage, cmd
|
return p.subpage, cmd
|
||||||
|
case key.Matches(msg, p.common.Keymap.PickProjectTask):
|
||||||
|
p.subpage = NewProjectTaskPickerPage(p.common)
|
||||||
|
cmd := p.subpage.Init()
|
||||||
|
p.common.PushPage(p)
|
||||||
|
return p.subpage, cmd
|
||||||
case key.Matches(msg, p.common.Keymap.StartStop):
|
case key.Matches(msg, p.common.Keymap.StartStop):
|
||||||
row := p.intervals.SelectedRow()
|
row := p.intervals.SelectedRow()
|
||||||
if row != nil {
|
if row != nil {
|
||||||
interval := (*timewarrior.Interval)(row)
|
interval := (*timewarrior.Interval)(row)
|
||||||
|
|
||||||
|
// Validate interval before proceeding
|
||||||
|
if interval.IsGap {
|
||||||
|
slog.Debug("Cannot start/stop gap interval")
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
if interval.IsActive() {
|
if interval.IsActive() {
|
||||||
|
// Stop tracking
|
||||||
p.common.TimeW.StopTracking()
|
p.common.TimeW.StopTracking()
|
||||||
|
// Sync: stop corresponding Taskwarrior task immediately (interval has UUID)
|
||||||
|
common.SyncIntervalToTask(interval, p.common.TW, "stop")
|
||||||
} else {
|
} else {
|
||||||
|
// Continue tracking - creates a NEW interval
|
||||||
|
slog.Info("Continuing interval for task sync",
|
||||||
|
"intervalID", interval.ID,
|
||||||
|
"hasUUID", timewarrior.ExtractUUID(interval.Tags) != "",
|
||||||
|
"uuid", timewarrior.ExtractUUID(interval.Tags))
|
||||||
p.common.TimeW.ContinueInterval(interval.ID)
|
p.common.TimeW.ContinueInterval(interval.ID)
|
||||||
p.shouldSelectActive = true
|
p.shouldSelectActive = true
|
||||||
|
// Set pending sync action instead of syncing immediately
|
||||||
|
// This ensures we sync AFTER intervals are refreshed
|
||||||
|
p.pendingSyncAction = "start"
|
||||||
}
|
}
|
||||||
return p, tea.Batch(p.getIntervals(), doTick())
|
cmds = append(cmds, p.getIntervals())
|
||||||
|
cmds = append(cmds, doTick())
|
||||||
|
return p, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
case key.Matches(msg, p.common.Keymap.Delete):
|
case key.Matches(msg, p.common.Keymap.Delete):
|
||||||
row := p.intervals.SelectedRow()
|
row := p.intervals.SelectedRow()
|
||||||
@ -351,6 +396,7 @@ func (p *TimePage) populateTable(intervals timewarrior.Intervals) {
|
|||||||
{Title: "Start", Name: startField, Width: startEndWidth},
|
{Title: "Start", Name: startField, Width: startEndWidth},
|
||||||
{Title: "End", Name: endField, Width: startEndWidth},
|
{Title: "End", Name: endField, Width: startEndWidth},
|
||||||
{Title: "Duration", Name: "duration", Width: 10},
|
{Title: "Duration", Name: "duration", Width: 10},
|
||||||
|
{Title: "Project", Name: "project", Width: 0}, // flexible width
|
||||||
{Title: "Tags", Name: "tags", Width: 0}, // flexible width
|
{Title: "Tags", Name: "tags", Width: 0}, // flexible width
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -419,3 +465,34 @@ func (p *TimePage) getIntervals() tea.Cmd {
|
|||||||
return intervalsMsg(intervals)
|
return intervalsMsg(intervals)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// syncActiveInterval creates a command that syncs the currently active interval to Taskwarrior
|
||||||
|
func (p *TimePage) syncActiveInterval(action string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
// Get the currently active interval
|
||||||
|
activeInterval := p.common.TimeW.GetActive()
|
||||||
|
if activeInterval != nil {
|
||||||
|
common.SyncIntervalToTask(activeInterval, p.common.TW, action)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncActiveIntervalAfterRefresh is called AFTER intervals have been refreshed
|
||||||
|
// to ensure we're working with current data
|
||||||
|
func (p *TimePage) syncActiveIntervalAfterRefresh(action string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
// At this point, intervals have been refreshed, so GetActive() will work
|
||||||
|
activeInterval := p.common.TimeW.GetActive()
|
||||||
|
if activeInterval != nil {
|
||||||
|
slog.Info("Syncing active interval to task after refresh",
|
||||||
|
"action", action,
|
||||||
|
"intervalID", activeInterval.ID,
|
||||||
|
"hasUUID", timewarrior.ExtractUUID(activeInterval.Tags) != "")
|
||||||
|
common.SyncIntervalToTask(activeInterval, p.common.TW, action)
|
||||||
|
} else {
|
||||||
|
slog.Warn("No active interval found after refresh, cannot sync to task")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -47,7 +47,7 @@ type Task struct {
|
|||||||
Uuid string `json:"uuid,omitempty"`
|
Uuid string `json:"uuid,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Project string `json:"project"`
|
Project string `json:"project"`
|
||||||
// Priority string `json:"priority"`
|
Priority string `json:"priority,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
VirtualTags []string `json:"-"`
|
VirtualTags []string `json:"-"`
|
||||||
@ -149,8 +149,8 @@ func (t *Task) GetString(fieldWFormat string) string {
|
|||||||
}
|
}
|
||||||
return t.Project
|
return t.Project
|
||||||
|
|
||||||
// case "priority":
|
case "priority":
|
||||||
// return t.Priority
|
return t.Priority
|
||||||
|
|
||||||
case "status":
|
case "status":
|
||||||
return t.Status
|
return t.Status
|
||||||
@ -233,7 +233,33 @@ func (t *Task) GetString(fieldWFormat string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Task) GetDate(dateString string) time.Time {
|
func (t *Task) GetDate(field string) time.Time {
|
||||||
|
var dateString string
|
||||||
|
switch field {
|
||||||
|
case "due":
|
||||||
|
dateString = t.Due
|
||||||
|
case "wait":
|
||||||
|
dateString = t.Wait
|
||||||
|
case "scheduled":
|
||||||
|
dateString = t.Scheduled
|
||||||
|
case "until":
|
||||||
|
dateString = t.Until
|
||||||
|
case "start":
|
||||||
|
dateString = t.Start
|
||||||
|
case "end":
|
||||||
|
dateString = t.End
|
||||||
|
case "entry":
|
||||||
|
dateString = t.Entry
|
||||||
|
case "modified":
|
||||||
|
dateString = t.Modified
|
||||||
|
default:
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if dateString == "" {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
dt, err := time.Parse(dtformat, dateString)
|
dt, err := time.Parse(dtformat, dateString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to parse time:", err)
|
slog.Error("Failed to parse time:", err)
|
||||||
|
|||||||
81
taskwarrior/models_test.go
Normal file
81
taskwarrior/models_test.go
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
package taskwarrior
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTask_GetString(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
task Task
|
||||||
|
fieldWFormat string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Priority",
|
||||||
|
task: Task{
|
||||||
|
Priority: "H",
|
||||||
|
},
|
||||||
|
fieldWFormat: "priority",
|
||||||
|
want: "H",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Description",
|
||||||
|
task: Task{
|
||||||
|
Description: "Buy milk",
|
||||||
|
},
|
||||||
|
fieldWFormat: "description.desc",
|
||||||
|
want: "Buy milk",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := tt.task.GetString(tt.fieldWFormat); got != tt.want {
|
||||||
|
t.Errorf("Task.GetString() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTask_GetDate(t *testing.T) {
|
||||||
|
validDate := "20230101T120000Z"
|
||||||
|
parsedValid, _ := time.Parse("20060102T150405Z", validDate)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
task Task
|
||||||
|
field string
|
||||||
|
want time.Time
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Due date valid",
|
||||||
|
task: Task{
|
||||||
|
Due: validDate,
|
||||||
|
},
|
||||||
|
field: "due",
|
||||||
|
want: parsedValid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Due date empty",
|
||||||
|
task: Task{},
|
||||||
|
field: "due",
|
||||||
|
want: time.Time{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Unknown field",
|
||||||
|
task: Task{Due: validDate},
|
||||||
|
field: "unknown",
|
||||||
|
want: time.Time{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := tt.task.GetDate(tt.field); !got.Equal(tt.want) {
|
||||||
|
t.Errorf("Task.GetDate() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -98,6 +98,7 @@ type TaskWarrior interface {
|
|||||||
DeleteTask(task *Task)
|
DeleteTask(task *Task)
|
||||||
StartTask(task *Task)
|
StartTask(task *Task)
|
||||||
StopTask(task *Task)
|
StopTask(task *Task)
|
||||||
|
StopActiveTasks()
|
||||||
GetInformation(task *Task) string
|
GetInformation(task *Task) string
|
||||||
AddTaskAnnotation(uuid string, annotation string)
|
AddTaskAnnotation(uuid string, annotation string)
|
||||||
|
|
||||||
@ -146,7 +147,7 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
|
|||||||
|
|
||||||
args := ts.defaultArgs
|
args := ts.defaultArgs
|
||||||
|
|
||||||
if report.Context {
|
if report != nil && report.Context {
|
||||||
for _, context := range ts.contexts {
|
for _, context := range ts.contexts {
|
||||||
if context.Active && context.Name != "none" {
|
if context.Active && context.Name != "none" {
|
||||||
args = append(args, context.ReadFilter)
|
args = append(args, context.ReadFilter)
|
||||||
@ -159,7 +160,12 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
|
|||||||
args = append(args, filter...)
|
args = append(args, filter...)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(args, []string{"export", report.Name}...)...)
|
exportArgs := []string{"export"}
|
||||||
|
if report != nil && report.Name != "" {
|
||||||
|
exportArgs = append(exportArgs, report.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, append(args, exportArgs...)...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed getting report:", err)
|
slog.Error("Failed getting report:", err)
|
||||||
@ -490,6 +496,33 @@ func (ts *TaskSquire) StopTask(task *Task) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ts *TaskSquire) StopActiveTasks() {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"+ACTIVE", "export"}...)...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed getting active tasks:", "error", err, "output", string(output))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks := make(Tasks, 0)
|
||||||
|
err = json.Unmarshal(output, &tasks)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed unmarshalling active tasks:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, task := range tasks {
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed stopping task:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) GetInformation(task *Task) string {
|
func (ts *TaskSquire) GetInformation(task *Task) string {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|||||||
Binary file not shown.
@ -83,7 +83,16 @@ func (i *Interval) GetString(field string) string {
|
|||||||
if len(i.Tags) == 0 {
|
if len(i.Tags) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return strings.Join(i.Tags, " ")
|
// Extract and filter special tags (uuid:, project:)
|
||||||
|
_, _, displayTags := ExtractSpecialTags(i.Tags)
|
||||||
|
return strings.Join(displayTags, " ")
|
||||||
|
|
||||||
|
case "project":
|
||||||
|
project := ExtractProject(i.Tags)
|
||||||
|
if project == "" {
|
||||||
|
return "(none)"
|
||||||
|
}
|
||||||
|
return project
|
||||||
|
|
||||||
case "duration":
|
case "duration":
|
||||||
return i.GetDuration()
|
return i.GetDuration()
|
||||||
|
|||||||
51
timewarrior/tags.go
Normal file
51
timewarrior/tags.go
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
package timewarrior
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Special tag prefixes for metadata
|
||||||
|
const (
|
||||||
|
UUIDPrefix = "uuid:"
|
||||||
|
ProjectPrefix = "project:"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExtractSpecialTags parses tags and separates special prefixed tags from display tags.
|
||||||
|
// Returns: uuid, project, and remaining display tags (description + user tags)
|
||||||
|
func ExtractSpecialTags(tags []string) (uuid string, project string, displayTags []string) {
|
||||||
|
displayTags = make([]string, 0, len(tags))
|
||||||
|
|
||||||
|
for _, tag := range tags {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(tag, UUIDPrefix):
|
||||||
|
uuid = strings.TrimPrefix(tag, UUIDPrefix)
|
||||||
|
case strings.HasPrefix(tag, ProjectPrefix):
|
||||||
|
project = strings.TrimPrefix(tag, ProjectPrefix)
|
||||||
|
default:
|
||||||
|
// Regular tag (description or user tag)
|
||||||
|
displayTags = append(displayTags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return uuid, project, displayTags
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractUUID extracts just the UUID from tags (for sync operations)
|
||||||
|
func ExtractUUID(tags []string) string {
|
||||||
|
for _, tag := range tags {
|
||||||
|
if strings.HasPrefix(tag, UUIDPrefix) {
|
||||||
|
return strings.TrimPrefix(tag, UUIDPrefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractProject extracts just the project name from tags
|
||||||
|
func ExtractProject(tags []string) string {
|
||||||
|
for _, tag := range tags {
|
||||||
|
if strings.HasPrefix(tag, ProjectPrefix) {
|
||||||
|
return strings.TrimPrefix(tag, ProjectPrefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@ -142,10 +142,9 @@ func formatTagsForCombination(tags []string) string {
|
|||||||
return strings.Join(formatted, " ")
|
return strings.Join(formatted, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TimeSquire) GetIntervals(filter ...string) Intervals {
|
// getIntervalsUnlocked fetches intervals without acquiring mutex (for internal use only).
|
||||||
ts.mutex.Lock()
|
// Caller must hold ts.mutex.
|
||||||
defer ts.mutex.Unlock()
|
func (ts *TimeSquire) getIntervalsUnlocked(filter ...string) Intervals {
|
||||||
|
|
||||||
args := append(ts.defaultArgs, "export")
|
args := append(ts.defaultArgs, "export")
|
||||||
if filter != nil {
|
if filter != nil {
|
||||||
args = append(args, filter...)
|
args = append(args, filter...)
|
||||||
@ -176,6 +175,14 @@ func (ts *TimeSquire) GetIntervals(filter ...string) Intervals {
|
|||||||
return intervals
|
return intervals
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetIntervals fetches intervals with the given filter, acquiring mutex lock.
|
||||||
|
func (ts *TimeSquire) GetIntervals(filter ...string) Intervals {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
return ts.getIntervalsUnlocked(filter...)
|
||||||
|
}
|
||||||
|
|
||||||
func (ts *TimeSquire) StartTracking(tags []string) error {
|
func (ts *TimeSquire) StartTracking(tags []string) error {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
@ -347,8 +354,8 @@ func (ts *TimeSquire) GetActive() *Interval {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the active interval
|
// Get the active interval using unlocked version (we already hold the mutex)
|
||||||
intervals := ts.GetIntervals()
|
intervals := ts.getIntervalsUnlocked()
|
||||||
for _, interval := range intervals {
|
for _, interval := range intervals {
|
||||||
if interval.End == "" {
|
if interval.End == "" {
|
||||||
return interval
|
return interval
|
||||||
|
|||||||
Reference in New Issue
Block a user