538 lines
15 KiB
Go
538 lines
15 KiB
Go
package pages
|
|
|
|
import (
|
|
"log/slog"
|
|
"strings"
|
|
"tasksquire/common"
|
|
"tasksquire/components/autocomplete"
|
|
"tasksquire/components/picker"
|
|
"tasksquire/components/timestampeditor"
|
|
"tasksquire/timewarrior"
|
|
|
|
"github.com/charmbracelet/bubbles/key"
|
|
"github.com/charmbracelet/bubbles/list"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
type TimeEditorPage struct {
|
|
common *common.Common
|
|
interval *timewarrior.Interval
|
|
|
|
// Fields
|
|
projectPicker *picker.Picker
|
|
startEditor *timestampeditor.TimestampEditor
|
|
endEditor *timestampeditor.TimestampEditor
|
|
tagsInput *autocomplete.Autocomplete
|
|
adjust bool
|
|
|
|
// State
|
|
selectedProject string
|
|
currentField int
|
|
totalFields int
|
|
uuid string // Preserved UUID tag
|
|
track string // Preserved track tag (if present)
|
|
}
|
|
|
|
type timeEditorProjectSelectedMsg struct {
|
|
project string
|
|
}
|
|
|
|
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)
|
|
|
|
// If UUID exists, fetch the task and add its title to display tags
|
|
if uuid != "" {
|
|
tasks := com.TW.GetTasks(nil, "uuid:"+uuid)
|
|
if len(tasks) > 0 {
|
|
taskTitle := tasks[0].Description
|
|
// Add to display tags if not already present
|
|
// Note: formatTags() will handle quoting for display, so we store the raw title
|
|
displayTags = ensureTagPresent(displayTags, taskTitle)
|
|
}
|
|
}
|
|
|
|
// Create project picker with onCreate support for new projects
|
|
projectItemProvider := func() []list.Item {
|
|
projects := com.TW.GetProjects()
|
|
items := make([]list.Item, len(projects))
|
|
for i, proj := range projects {
|
|
items[i] = picker.NewItem(proj)
|
|
}
|
|
return items
|
|
}
|
|
|
|
projectOnSelect := func(item list.Item) tea.Cmd {
|
|
return func() tea.Msg {
|
|
return timeEditorProjectSelectedMsg{project: item.FilterValue()}
|
|
}
|
|
}
|
|
|
|
projectOnCreate := func(name string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
return timeEditorProjectSelectedMsg{project: name}
|
|
}
|
|
}
|
|
|
|
opts := []picker.PickerOption{
|
|
picker.WithOnCreate(projectOnCreate),
|
|
}
|
|
if selectedProject != "" {
|
|
opts = append(opts, picker.WithDefaultValue(selectedProject))
|
|
} else {
|
|
opts = append(opts, picker.WithFilterByDefault(true))
|
|
}
|
|
|
|
projectPicker := picker.New(
|
|
com,
|
|
"Project",
|
|
projectItemProvider,
|
|
projectOnSelect,
|
|
opts...,
|
|
)
|
|
projectPicker.SetSize(50, 10) // Compact size for inline use
|
|
|
|
// Create start timestamp editor
|
|
startEditor := timestampeditor.New(com).
|
|
Title("Start").
|
|
ValueFromString(interval.Start)
|
|
|
|
// Create end timestamp editor
|
|
endEditor := timestampeditor.New(com).
|
|
Title("End").
|
|
ValueFromString(interval.End)
|
|
|
|
// Get tag combinations filtered by selected project
|
|
tagCombinations := filterTagCombinationsByProject(
|
|
com.TimeW.GetTagCombinations(),
|
|
selectedProject,
|
|
)
|
|
|
|
tagsInput := autocomplete.New(tagCombinations, 3, 10)
|
|
tagsInput.SetPlaceholder("Space separated, use \"\" for tags with spaces")
|
|
tagsInput.SetValue(formatTags(displayTags)) // Use display tags only (no uuid, project, track)
|
|
tagsInput.SetWidth(50)
|
|
|
|
p := &TimeEditorPage{
|
|
common: com,
|
|
interval: interval,
|
|
projectPicker: projectPicker,
|
|
startEditor: startEditor,
|
|
endEditor: endEditor,
|
|
tagsInput: tagsInput,
|
|
adjust: true, // Enable :adjust by default
|
|
selectedProject: selectedProject,
|
|
currentField: 0,
|
|
totalFields: 5, // Now 5 fields: project, tags, start, end, adjust
|
|
uuid: uuid,
|
|
track: track,
|
|
}
|
|
|
|
return p
|
|
}
|
|
|
|
func (p *TimeEditorPage) Init() tea.Cmd {
|
|
// Focus the first field (project picker)
|
|
p.currentField = 0
|
|
return p.projectPicker.Init()
|
|
}
|
|
|
|
func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
var cmds []tea.Cmd
|
|
|
|
switch msg := msg.(type) {
|
|
case timeEditorProjectSelectedMsg:
|
|
// Update selected 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
|
|
cmds = append(cmds, p.updateTagSuggestions())
|
|
// Focus tags input
|
|
cmds = append(cmds, p.focusCurrentField())
|
|
return p, tea.Batch(cmds...)
|
|
|
|
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.Ok):
|
|
// Handle Enter based on current field
|
|
if p.currentField == 0 {
|
|
// 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()
|
|
model, err := p.common.PopPage()
|
|
if err != nil {
|
|
slog.Error("page stack empty")
|
|
return nil, tea.Quit
|
|
}
|
|
return model, tea.Batch(tea.Batch(cmds...), refreshIntervals)
|
|
|
|
case key.Matches(msg, p.common.Keymap.Next):
|
|
// Move to next field
|
|
p.blurCurrentField()
|
|
p.currentField = (p.currentField + 1) % p.totalFields
|
|
cmds = append(cmds, p.focusCurrentField())
|
|
|
|
case key.Matches(msg, p.common.Keymap.Prev):
|
|
// Move to previous field
|
|
p.blurCurrentField()
|
|
p.currentField = (p.currentField - 1 + p.totalFields) % p.totalFields
|
|
cmds = append(cmds, p.focusCurrentField())
|
|
}
|
|
|
|
case tea.WindowSizeMsg:
|
|
p.SetSize(msg.Width, msg.Height)
|
|
}
|
|
|
|
// Update the currently focused field
|
|
var cmd tea.Cmd
|
|
switch p.currentField {
|
|
case 0: // Project picker
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
// Handle adjust toggle with space/enter
|
|
if msg, ok := msg.(tea.KeyMsg); ok {
|
|
if msg.String() == " " || msg.String() == "enter" {
|
|
p.adjust = !p.adjust
|
|
}
|
|
}
|
|
}
|
|
cmds = append(cmds, cmd)
|
|
|
|
return p, tea.Batch(cmds...)
|
|
}
|
|
|
|
func (p *TimeEditorPage) focusCurrentField() tea.Cmd {
|
|
switch p.currentField {
|
|
case 0:
|
|
return p.projectPicker.Init() // Picker doesn't have explicit Focus()
|
|
case 1:
|
|
p.tagsInput.Focus()
|
|
return p.tagsInput.Init()
|
|
case 2:
|
|
return p.startEditor.Focus()
|
|
case 3:
|
|
return p.endEditor.Focus()
|
|
case 4:
|
|
// Adjust checkbox doesn't need focus action
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *TimeEditorPage) blurCurrentField() {
|
|
switch p.currentField {
|
|
case 0:
|
|
// Picker doesn't have explicit Blur(), state handled by Update
|
|
case 1:
|
|
p.tagsInput.Blur()
|
|
case 2:
|
|
p.startEditor.Blur()
|
|
case 3:
|
|
p.endEditor.Blur()
|
|
case 4:
|
|
// Adjust checkbox doesn't need blur action
|
|
}
|
|
}
|
|
|
|
func (p *TimeEditorPage) View() string {
|
|
var sections []string
|
|
|
|
// Title
|
|
titleStyle := p.common.Styles.Form.Focused.Title
|
|
sections = append(sections, titleStyle.Render("Edit Time Interval"))
|
|
sections = append(sections, "")
|
|
|
|
// Project picker (field 0)
|
|
if p.currentField == 0 {
|
|
sections = append(sections, p.projectPicker.View())
|
|
} else {
|
|
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
|
|
sections = append(sections, blurredLabelStyle.Render("Project"))
|
|
sections = append(sections, lipgloss.NewStyle().Faint(true).Render(p.selectedProject))
|
|
}
|
|
|
|
sections = append(sections, "")
|
|
sections = append(sections, "")
|
|
|
|
// Tags input (now field 1, was first)
|
|
tagsLabelStyle := p.common.Styles.Form.Focused.Title
|
|
tagsLabel := tagsLabelStyle.Render("Tags")
|
|
if p.currentField == 1 { // Changed from 0
|
|
sections = append(sections, tagsLabel)
|
|
sections = append(sections, p.tagsInput.View())
|
|
descStyle := p.common.Styles.Form.Focused.Description
|
|
sections = append(sections, descStyle.Render("Space separated, use \"\" for tags with spaces"))
|
|
} else {
|
|
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
|
|
sections = append(sections, blurredLabelStyle.Render("Tags"))
|
|
sections = append(sections, lipgloss.NewStyle().Faint(true).Render(p.tagsInput.GetValue()))
|
|
}
|
|
|
|
sections = append(sections, "")
|
|
sections = append(sections, "")
|
|
|
|
// Start editor
|
|
sections = append(sections, p.startEditor.View())
|
|
sections = append(sections, "")
|
|
|
|
// End editor
|
|
sections = append(sections, p.endEditor.View())
|
|
sections = append(sections, "")
|
|
|
|
// Adjust checkbox (now field 4, was 3)
|
|
adjustLabelStyle := p.common.Styles.Form.Focused.Title
|
|
adjustLabel := adjustLabelStyle.Render("Adjust overlaps")
|
|
|
|
var checkbox string
|
|
if p.adjust {
|
|
checkbox = "[X]"
|
|
} else {
|
|
checkbox = "[ ]"
|
|
}
|
|
|
|
if p.currentField == 4 { // Changed from 3
|
|
focusedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
|
|
sections = append(sections, adjustLabel)
|
|
sections = append(sections, focusedStyle.Render(checkbox+" Auto-adjust overlapping intervals"))
|
|
descStyle := p.common.Styles.Form.Focused.Description
|
|
sections = append(sections, descStyle.Render("Press space to toggle"))
|
|
} else {
|
|
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
|
|
sections = append(sections, blurredLabelStyle.Render("Adjust overlaps"))
|
|
sections = append(sections, lipgloss.NewStyle().Faint(true).Render(checkbox+" Auto-adjust overlapping intervals"))
|
|
}
|
|
|
|
sections = append(sections, "")
|
|
sections = append(sections, "")
|
|
|
|
// Help text
|
|
helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
|
sections = append(sections, helpStyle.Render("tab/shift+tab: navigate • enter: save • esc: cancel"))
|
|
|
|
return lipgloss.JoinVertical(lipgloss.Left, sections...)
|
|
}
|
|
|
|
func (p *TimeEditorPage) SetSize(width, height int) {
|
|
p.common.SetSize(width, height)
|
|
}
|
|
|
|
func (p *TimeEditorPage) saveInterval() {
|
|
// If it's an existing interval (has ID), delete it first so we can replace it with the modified version
|
|
if p.interval.ID != 0 {
|
|
err := p.common.TimeW.DeleteInterval(p.interval.ID)
|
|
if err != nil {
|
|
slog.Error("Failed to delete old interval during edit", "err", err)
|
|
// Proceeding to import anyway, attempting to save user data
|
|
}
|
|
}
|
|
|
|
p.interval.Start = p.startEditor.GetValueString()
|
|
p.interval.End = p.endEditor.GetValueString()
|
|
|
|
// Parse display tags from input
|
|
displayTags := parseTags(p.tagsInput.GetValue())
|
|
|
|
// Reconstruct full tags array by combining special tags and display tags
|
|
var tags []string
|
|
|
|
// Add preserved special tags first
|
|
if p.uuid != "" {
|
|
tags = append(tags, "uuid:"+p.uuid)
|
|
}
|
|
if p.track != "" {
|
|
tags = append(tags, p.track)
|
|
}
|
|
|
|
// Add project tag
|
|
if p.selectedProject != "" {
|
|
tags = append(tags, "project:"+p.selectedProject)
|
|
}
|
|
|
|
// Add display tags (user-entered tags from the input field)
|
|
tags = append(tags, displayTags...)
|
|
|
|
p.interval.Tags = tags
|
|
|
|
err := p.common.TimeW.ModifyInterval(p.interval, p.adjust)
|
|
if err != nil {
|
|
slog.Error("Failed to modify interval", "err", err)
|
|
}
|
|
}
|
|
|
|
func parseTags(tagsStr string) []string {
|
|
var tags []string
|
|
var current strings.Builder
|
|
inQuotes := false
|
|
|
|
for _, r := range tagsStr {
|
|
switch {
|
|
case r == '"':
|
|
inQuotes = !inQuotes
|
|
case r == ' ' && !inQuotes:
|
|
if current.Len() > 0 {
|
|
tags = append(tags, current.String())
|
|
current.Reset()
|
|
}
|
|
default:
|
|
current.WriteRune(r)
|
|
}
|
|
}
|
|
if current.Len() > 0 {
|
|
tags = append(tags, current.String())
|
|
}
|
|
return tags
|
|
}
|
|
|
|
func formatTags(tags []string) string {
|
|
var formatted []string
|
|
for _, t := range tags {
|
|
if strings.Contains(t, " ") {
|
|
formatted = append(formatted, "\""+t+"\"")
|
|
} else {
|
|
formatted = append(formatted, t)
|
|
}
|
|
}
|
|
return strings.Join(formatted, " ")
|
|
}
|
|
|
|
// extractSpecialTags separates special tags (uuid, project, track) from display tags
|
|
// Returns uuid, project, track as separate strings, and displayTags for user editing
|
|
func extractSpecialTags(tags []string) (uuid, project, track string, displayTags []string) {
|
|
for _, tag := range tags {
|
|
if strings.HasPrefix(tag, "uuid:") {
|
|
uuid = strings.TrimPrefix(tag, "uuid:")
|
|
} else if strings.HasPrefix(tag, "project:") {
|
|
project = strings.TrimPrefix(tag, "project:")
|
|
} else if tag == "track" {
|
|
track = tag
|
|
} else {
|
|
displayTags = append(displayTags, tag)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// extractProjectFromTags finds and removes the first tag that matches a known project
|
|
// Returns the found project (or empty string) and the remaining tags
|
|
// This is kept for backward compatibility but now uses extractSpecialTags internally
|
|
func extractProjectFromTags(tags []string, projects []string) (string, []string) {
|
|
_, project, _, remaining := extractSpecialTags(tags)
|
|
return project, remaining
|
|
}
|
|
|
|
// ensureTagPresent adds a tag to the list if not already present
|
|
func ensureTagPresent(tags []string, tag string) []string {
|
|
for _, t := range tags {
|
|
if t == tag {
|
|
return tags // Already present
|
|
}
|
|
}
|
|
return append(tags, tag)
|
|
}
|
|
|
|
// filterTagCombinationsByProject filters tag combinations to only show those
|
|
// containing the exact project tag, and removes the project from the displayed combination
|
|
func filterTagCombinationsByProject(combinations []string, project string) []string {
|
|
if project == "" {
|
|
return combinations
|
|
}
|
|
|
|
projectTag := "project:" + project
|
|
|
|
var filtered []string
|
|
for _, combo := range combinations {
|
|
// Parse the combination into individual tags
|
|
tags := parseTags(combo)
|
|
|
|
// Check if project exists in this combination
|
|
for _, tag := range tags {
|
|
if tag == projectTag {
|
|
// Found the project - now remove it from display
|
|
var displayTags []string
|
|
for _, t := range tags {
|
|
if t != projectTag {
|
|
displayTags = append(displayTags, t)
|
|
}
|
|
}
|
|
if len(displayTags) > 0 {
|
|
filtered = append(filtered, formatTags(displayTags))
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return filtered
|
|
}
|
|
|
|
// updateTagSuggestions refreshes the tag autocomplete with filtered combinations
|
|
func (p *TimeEditorPage) updateTagSuggestions() tea.Cmd {
|
|
combinations := filterTagCombinationsByProject(
|
|
p.common.TimeW.GetTagCombinations(),
|
|
p.selectedProject,
|
|
)
|
|
|
|
// Update autocomplete suggestions
|
|
currentValue := p.tagsInput.GetValue()
|
|
p.tagsInput.SetSuggestions(combinations)
|
|
p.tagsInput.SetValue(currentValue)
|
|
|
|
// If tags field is focused, refocus it
|
|
if p.currentField == 1 {
|
|
p.tagsInput.Focus()
|
|
return p.tagsInput.Init()
|
|
}
|
|
|
|
return nil
|
|
}
|