553 lines
14 KiB
Go
553 lines
14 KiB
Go
package ui
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/list"
|
|
"github.com/charmbracelet/bubbles/spinner"
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"github.com/your-username/todo/src/ai"
|
|
"github.com/your-username/todo/src/database"
|
|
"github.com/your-username/todo/src/models"
|
|
)
|
|
|
|
var (
|
|
titleStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("62"))
|
|
|
|
completedStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("42")) // Green color
|
|
|
|
taskStyle = lipgloss.NewStyle().
|
|
BorderStyle(lipgloss.NormalBorder()).
|
|
BorderForeground(lipgloss.Color("240")).
|
|
Padding(1, 2)
|
|
|
|
statusStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("34")) // Blue color
|
|
|
|
dimmedStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("240")) // Dimmed gray color
|
|
)
|
|
|
|
type Model struct {
|
|
list list.Model // Changed from *list.Model to list.Model
|
|
titleInput textinput.Model
|
|
bodyInput textinput.Model
|
|
searchInput textinput.Model
|
|
spinner spinner.Model
|
|
db database.Database
|
|
aiService *ai.AIService
|
|
state State
|
|
selected *database.Task
|
|
errorMsg string
|
|
filter string
|
|
currentPage int
|
|
itemsPerPage int
|
|
showCompleted bool
|
|
analysisResults map[int64]*database.Analysis
|
|
isLoading bool // Track loading state
|
|
analyzingTasks map[int]bool
|
|
analysisTimeout time.Duration
|
|
}
|
|
|
|
type State int
|
|
|
|
const (
|
|
StateNormal State = iota
|
|
StateCreating
|
|
StateEditing
|
|
StateSearching
|
|
StateViewing
|
|
StateAnalyzing
|
|
)
|
|
|
|
func New(db database.Database, aiService *ai.AIService) Model {
|
|
ti := textinput.New()
|
|
ti.Placeholder = "Task title"
|
|
ti.Focus()
|
|
|
|
bi := textinput.New()
|
|
bi.Placeholder = "Task description (optional)"
|
|
|
|
si := textinput.New()
|
|
si.Placeholder = "Search tasks..."
|
|
|
|
delegate := list.NewDefaultDelegate()
|
|
delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.Foreground(lipgloss.Color("62"))
|
|
delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.Foreground(lipgloss.Color("241"))
|
|
|
|
taskList := list.New(nil, delegate, 0, 0)
|
|
taskList.Title = "Todo Tasks"
|
|
taskList.SetShowTitle(true)
|
|
taskList.SetFilteringEnabled(true)
|
|
|
|
s := spinner.New()
|
|
s.Spinner = spinner.Dot
|
|
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
|
|
|
|
m := Model{
|
|
list: taskList,
|
|
titleInput: ti,
|
|
bodyInput: bi,
|
|
searchInput: si,
|
|
spinner: s,
|
|
db: db,
|
|
aiService: aiService,
|
|
state: StateNormal,
|
|
itemsPerPage: 10,
|
|
analysisResults: make(map[int64]*database.Analysis),
|
|
isLoading: true,
|
|
analyzingTasks: make(map[int]bool),
|
|
showCompleted: true, // Show all tasks by default
|
|
analysisTimeout: 5 * time.Second,
|
|
}
|
|
|
|
return m
|
|
}
|
|
|
|
func (m Model) Init() tea.Cmd {
|
|
m.isLoading = true // Set initial loading state
|
|
return tea.Batch(m.loadTasks, m.spinner.Tick)
|
|
}
|
|
|
|
func (m Model) loadTasks() tea.Msg {
|
|
dbTasks, err := m.db.GetTasks(context.Background(), m.showCompleted)
|
|
if err != nil {
|
|
return errMsg{err}
|
|
}
|
|
|
|
// Convert database.Task to models.Task
|
|
var items []list.Item
|
|
for _, t := range dbTasks {
|
|
items = append(items, taskItem{
|
|
task: models.Task{
|
|
ID: t.ID,
|
|
Title: t.Title,
|
|
Completed: t.Completed,
|
|
CreatedAt: t.CreatedAt,
|
|
},
|
|
analyzing: m.analyzingTasks[int(t.ID)],
|
|
})
|
|
}
|
|
|
|
return tasksLoadedMsg{items: items}
|
|
}
|
|
|
|
func (m Model) updateTaskList(tasks []models.Task) {
|
|
items := make([]list.Item, len(tasks))
|
|
for i, task := range tasks {
|
|
items[i] = taskItem{
|
|
task: task,
|
|
analyzing: m.analyzingTasks[int(task.ID)],
|
|
}
|
|
}
|
|
m.list.SetItems(items)
|
|
}
|
|
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
var cmds []tea.Cmd
|
|
|
|
switch msg := msg.(type) {
|
|
case nil:
|
|
// Initial update, load tasks
|
|
m.isLoading = true
|
|
return m, m.loadTasks
|
|
|
|
case spinner.TickMsg:
|
|
var cmd tea.Cmd
|
|
m.spinner, cmd = m.spinner.Update(msg)
|
|
return m, cmd
|
|
|
|
case tasksLoadedMsg:
|
|
m.isLoading = false
|
|
m.list.SetItems(msg.items)
|
|
if len(msg.items) > 0 {
|
|
m.list.Select(0)
|
|
}
|
|
return m, nil
|
|
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "q", "ctrl+c":
|
|
return m, tea.Quit
|
|
case "n":
|
|
if m.state == StateNormal {
|
|
m.state = StateCreating
|
|
m.titleInput.Focus()
|
|
return m, textinput.Blink
|
|
}
|
|
case "/":
|
|
if m.state == StateNormal {
|
|
m.state = StateSearching
|
|
m.searchInput.Focus()
|
|
return m, textinput.Blink
|
|
}
|
|
case "e":
|
|
if m.state == StateNormal {
|
|
if i, ok := m.list.SelectedItem().(taskItem); ok {
|
|
m.state = StateEditing
|
|
m.selected = &database.Task{ID: i.task.ID, Title: i.task.Title, Completed: i.task.Completed}
|
|
m.titleInput.SetValue(i.task.Title)
|
|
m.titleInput.Focus()
|
|
return m, textinput.Blink
|
|
}
|
|
}
|
|
case "tab":
|
|
if m.state == StateCreating || m.state == StateEditing {
|
|
if m.titleInput.Focused() {
|
|
m.titleInput.Blur()
|
|
m.bodyInput.Focus()
|
|
return m, textinput.Blink
|
|
} else if m.bodyInput.Focused() {
|
|
m.bodyInput.Blur()
|
|
m.titleInput.Focus()
|
|
return m, textinput.Blink
|
|
}
|
|
}
|
|
m.showCompleted = !m.showCompleted
|
|
m.filter = "" // Clear filter when toggling completed tasks
|
|
m.isLoading = true
|
|
return m, m.loadTasks
|
|
case "shift+tab":
|
|
if m.state == StateCreating || m.state == StateEditing {
|
|
if m.titleInput.Focused() {
|
|
m.titleInput.Blur()
|
|
m.bodyInput.Focus()
|
|
return m, textinput.Blink
|
|
} else if m.bodyInput.Focused() {
|
|
m.bodyInput.Blur()
|
|
m.titleInput.Focus()
|
|
return m, textinput.Blink
|
|
}
|
|
}
|
|
case "c":
|
|
if m.state == StateNormal {
|
|
if i, ok := m.list.SelectedItem().(taskItem); ok {
|
|
err := m.db.CompleteTask(context.Background(), int(i.task.ID))
|
|
if err != nil {
|
|
m.errorMsg = fmt.Sprintf("Failed to complete task: %v", err)
|
|
}
|
|
m.showCompleted = true // Show completed tasks after completing one
|
|
m.filter = "" // Clear filter when showing completed tasks
|
|
m.isLoading = true
|
|
return m, m.loadTasks
|
|
}
|
|
}
|
|
case "a":
|
|
if m.state == StateNormal || m.state == StateViewing {
|
|
if i, ok := m.list.SelectedItem().(taskItem); ok {
|
|
m.state = StateAnalyzing
|
|
m.selected = &database.Task{ID: i.task.ID, Title: i.task.Title, Completed: i.task.Completed}
|
|
m.analyzingTasks[int(i.task.ID)] = true
|
|
return m, m.analyzeTask(i.task)
|
|
}
|
|
}
|
|
return m, nil
|
|
case "enter":
|
|
switch m.state {
|
|
case StateCreating:
|
|
if m.titleInput.Focused() && m.titleInput.Value() != "" {
|
|
m.titleInput.Blur()
|
|
m.bodyInput.Focus()
|
|
return m, textinput.Blink
|
|
} else if m.bodyInput.Focused() {
|
|
if m.titleInput.Value() != "" {
|
|
_, err := m.db.AddTask(context.Background(), m.titleInput.Value(), m.bodyInput.Value())
|
|
if err != nil {
|
|
m.errorMsg = fmt.Sprintf("Failed to add task: %v", err)
|
|
}
|
|
m.titleInput.Reset()
|
|
m.bodyInput.Reset()
|
|
m.state = StateNormal
|
|
m.isLoading = true
|
|
return m, m.loadTasks
|
|
}
|
|
}
|
|
case StateEditing:
|
|
if m.titleInput.Focused() && m.titleInput.Value() != "" {
|
|
m.titleInput.Blur()
|
|
m.bodyInput.Focus()
|
|
return m, textinput.Blink
|
|
} else if m.bodyInput.Focused() && m.selected != nil {
|
|
err := m.db.UpdateTask(context.Background(), m.selected.ID, m.titleInput.Value(), m.bodyInput.Value())
|
|
if err != nil {
|
|
m.errorMsg = fmt.Sprintf("Failed to update task: %v", err)
|
|
}
|
|
m.titleInput.Reset()
|
|
m.bodyInput.Reset()
|
|
m.selected = nil
|
|
m.state = StateNormal
|
|
m.isLoading = true
|
|
return m, m.loadTasks
|
|
}
|
|
case StateSearching:
|
|
m.filter = m.searchInput.Value()
|
|
m.searchInput.Reset()
|
|
m.state = StateNormal
|
|
m.isLoading = true
|
|
return m, m.loadTasks
|
|
case StateNormal:
|
|
if i, ok := m.list.SelectedItem().(taskItem); ok {
|
|
m.selected = &database.Task{ID: i.task.ID, Title: i.task.Title, Completed: i.task.Completed}
|
|
m.state = StateViewing
|
|
|
|
// Load existing analysis when viewing task
|
|
analysis, err := m.db.GetTaskAnalysis(context.Background(), int(i.task.ID))
|
|
if err == nil && analysis != nil {
|
|
if m.analysisResults == nil {
|
|
m.analysisResults = make(map[int64]*database.Analysis)
|
|
}
|
|
m.analysisResults[i.task.ID] = analysis
|
|
return m, nil
|
|
}
|
|
|
|
// If no analysis exists, run the analysis
|
|
return m, m.analyzeTask(i.task)
|
|
}
|
|
}
|
|
case "esc":
|
|
if m.state != StateNormal {
|
|
m.state = StateNormal
|
|
m.errorMsg = ""
|
|
m.selected = nil
|
|
m.titleInput.Reset()
|
|
m.bodyInput.Reset()
|
|
if m.state == StateSearching {
|
|
m.filter = ""
|
|
}
|
|
m.isLoading = true
|
|
return m, m.loadTasks
|
|
}
|
|
}
|
|
|
|
case tea.WindowSizeMsg:
|
|
m.list.SetWidth(msg.Width)
|
|
m.list.SetHeight(msg.Height)
|
|
return m, nil
|
|
|
|
case analysisMsg:
|
|
if m.state == StateAnalyzing {
|
|
// Get the currently selected task
|
|
if i, ok := m.list.SelectedItem().(taskItem); ok {
|
|
// Create clean, properly formatted JSON
|
|
analysis := &database.Analysis{
|
|
TaskID: i.task.ID,
|
|
Result: fmt.Sprintf(`{"priority":%d,"category":"%s","estimate":%d,"related":"%s","summary":"%s"}`,
|
|
msg.result.Priority,
|
|
msg.result.Category,
|
|
msg.result.TimeEstimate,
|
|
msg.result.RelatedTasks,
|
|
msg.result.Summary),
|
|
AnalyzedAt: time.Now(),
|
|
}
|
|
|
|
if err := m.db.StoreAnalysis(context.Background(), analysis); err != nil {
|
|
m.errorMsg = fmt.Sprintf("Failed to store analysis: %v", err)
|
|
} else {
|
|
if m.analysisResults == nil {
|
|
m.analysisResults = make(map[int64]*database.Analysis)
|
|
}
|
|
m.analysisResults[i.task.ID] = analysis
|
|
}
|
|
delete(m.analyzingTasks, int(i.task.ID))
|
|
}
|
|
m.state = StateViewing
|
|
return m, nil
|
|
}
|
|
|
|
case errMsg:
|
|
m.errorMsg = msg.Error()
|
|
return m, nil
|
|
}
|
|
|
|
// Update the appropriate input based on current state
|
|
var cmd tea.Cmd
|
|
switch m.state {
|
|
case StateAnalyzing:
|
|
var spinnerCmd tea.Cmd
|
|
m.spinner, spinnerCmd = m.spinner.Update(msg)
|
|
cmds = append(cmds, spinnerCmd)
|
|
case StateCreating, StateEditing:
|
|
m.titleInput, cmd = m.titleInput.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
m.bodyInput, cmd = m.bodyInput.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
case StateSearching:
|
|
m.searchInput, cmd = m.searchInput.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
case StateNormal:
|
|
if !m.isLoading {
|
|
m.list, cmd = m.list.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
var spinnerCmd tea.Cmd
|
|
m.spinner, spinnerCmd = m.spinner.Update(msg)
|
|
cmds = append(cmds, spinnerCmd)
|
|
}
|
|
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
func (m Model) View() string {
|
|
var b strings.Builder
|
|
|
|
if m.errorMsg != "" {
|
|
b.WriteString(fmt.Sprintf("Error: %s\n\n", m.errorMsg))
|
|
}
|
|
|
|
switch m.state {
|
|
case StateCreating:
|
|
b.WriteString("Add new task:\n\n")
|
|
b.WriteString("Title:\n")
|
|
b.WriteString(m.titleInput.View())
|
|
b.WriteString("\n\nDescription:\n")
|
|
b.WriteString(m.bodyInput.View())
|
|
b.WriteString("\n\n(Tab/Shift+Tab to switch fields, Enter to move to description or save, ESC to cancel)")
|
|
|
|
case StateEditing:
|
|
b.WriteString("Edit task:\n\n")
|
|
b.WriteString("Title:\n")
|
|
b.WriteString(m.titleInput.View())
|
|
b.WriteString("\n\nDescription:\n")
|
|
b.WriteString(m.bodyInput.View())
|
|
b.WriteString("\n\n(Tab/Shift+Tab to switch fields, Enter to move to description or save, ESC to cancel)")
|
|
|
|
case StateSearching:
|
|
b.WriteString("Search tasks:\n\n")
|
|
b.WriteString(m.searchInput.View())
|
|
b.WriteString("\n\n(ESC to cancel)")
|
|
|
|
case StateViewing:
|
|
if m.selected != nil {
|
|
b.WriteString(m.renderTaskDetail())
|
|
return b.String()
|
|
}
|
|
|
|
case StateAnalyzing:
|
|
b.WriteString(fmt.Sprintf("\n\n%s Analyzing task...\n", m.spinner.View()))
|
|
return taskStyle.Render(b.String())
|
|
|
|
default:
|
|
header := fmt.Sprintf(
|
|
"%s\n\nPress 'n' for new task, 'e' to edit task, '/' to search, TAB to toggle completed, 'c' to complete task\n",
|
|
titleStyle.Render("Your Tasks"),
|
|
)
|
|
b.WriteString(header)
|
|
if m.isLoading {
|
|
b.WriteString(fmt.Sprintf("\n%s Loading tasks...\n", m.spinner.View()))
|
|
} else {
|
|
b.WriteString(m.list.View())
|
|
}
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func (m Model) renderTaskDetail() string {
|
|
var b strings.Builder
|
|
|
|
if m.selected == nil {
|
|
return ""
|
|
}
|
|
|
|
title := m.selected.Title
|
|
if m.selected.Completed {
|
|
title = completedStyle.Render(title)
|
|
}
|
|
|
|
b.WriteString(fmt.Sprintf("Title: %s\n\n", title))
|
|
|
|
// Add analysis results if available
|
|
if analysis, ok := m.analysisResults[m.selected.ID]; ok {
|
|
var result ai.AnalysisResult
|
|
if err := json.Unmarshal([]byte(analysis.Result), &result); err == nil {
|
|
b.WriteString("\nAnalysis Results:\n")
|
|
b.WriteString(fmt.Sprintf("Priority: %d/5\n", result.Priority))
|
|
b.WriteString(fmt.Sprintf("Category: %s\n", result.Category))
|
|
b.WriteString(fmt.Sprintf("Time Estimate: %d minutes\n", result.TimeEstimate))
|
|
if result.RelatedTasks != "" {
|
|
b.WriteString(fmt.Sprintf("Related Tasks: %s\n", result.RelatedTasks))
|
|
}
|
|
if result.Summary != "" && result.Summary != title {
|
|
b.WriteString(fmt.Sprintf("Summary: %s\n", result.Summary))
|
|
}
|
|
b.WriteString(fmt.Sprintf("\nAnalyzed at: %s", analysis.AnalyzedAt.Format("2006-01-02 15:04:05")))
|
|
}
|
|
}
|
|
|
|
b.WriteString("\n\nPress 'a' to analyze task, ESC to return")
|
|
return taskStyle.Render(b.String())
|
|
}
|
|
|
|
func (m Model) analyzeTask(task models.Task) tea.Cmd {
|
|
return func() tea.Msg {
|
|
// Create a timeout channel
|
|
done := make(chan struct{})
|
|
var result *ai.AnalysisResult
|
|
var err error
|
|
|
|
// Run analysis in a goroutine
|
|
go func() {
|
|
result, err = m.aiService.AnalyzeTask(context.Background(), task.Title)
|
|
close(done)
|
|
}()
|
|
|
|
// Wait for either completion or timeout
|
|
select {
|
|
case <-done:
|
|
if err != nil {
|
|
return errMsg{err}
|
|
}
|
|
return analysisMsg{result}
|
|
case <-time.After(m.analysisTimeout):
|
|
return analysisMsg{&ai.AnalysisResult{
|
|
Priority: 3,
|
|
Category: "task",
|
|
TimeEstimate: 30,
|
|
RelatedTasks: "",
|
|
Summary: "Analysis timed out for: " + task.Title,
|
|
}}
|
|
}
|
|
}
|
|
}
|
|
|
|
type taskItem struct {
|
|
task models.Task
|
|
analyzing bool
|
|
}
|
|
|
|
func (i taskItem) Title() string {
|
|
status := "[ ]"
|
|
if i.task.Completed {
|
|
status = "[x]"
|
|
}
|
|
|
|
aiStatus := ""
|
|
if i.analyzing {
|
|
aiStatus = " (analyzing...)"
|
|
}
|
|
|
|
return fmt.Sprintf("%s %s%s", status, i.task.Title, aiStatus)
|
|
}
|
|
|
|
func (i taskItem) Description() string { return "" }
|
|
func (i taskItem) FilterValue() string { return i.task.Title }
|
|
|
|
type tasksLoadedMsg struct {
|
|
items []list.Item
|
|
}
|
|
|
|
type analysisMsg struct {
|
|
result *ai.AnalysisResult
|
|
}
|
|
|
|
type errMsg struct{ error }
|