package tui import ( "context" "fmt" "strings" "time" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "git.unkin.net/unkin/forgebot/pkg/models" ) type viewMode int const ( viewBoard viewMode = iota viewDetail viewFilter ) type tasksLoadedMsg struct { tasks []models.Task err error } type taskUpdatedMsg struct { err error } type tickMsg time.Time type App struct { client *Client board board detail detailView mode viewMode width int height int err error help help.Model showHelp bool filter textinput.Model filterRepo string } func NewApp(apiURL string) App { ti := textinput.New() ti.Placeholder = "owner/repo" ti.CharLimit = 100 return App{ client: NewClient(apiURL), board: newBoard(), detail: newDetailView(), help: help.New(), filter: ti, } } func (a App) Init() tea.Cmd { return tea.Batch(fetchTasks(a.client, a.filterRepo), tickCmd()) } func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: a.width = msg.Width a.height = msg.Height a.help.Width = msg.Width return a, nil case tasksLoadedMsg: if msg.err != nil { a.err = msg.err } else { a.err = nil a.board.loadTasks(msg.tasks) } return a, nil case taskUpdatedMsg: if msg.err != nil { a.err = msg.err } return a, fetchTasks(a.client, a.filterRepo) case editorFinishedMsg: if msg.err != nil { a.err = msg.err } return a, fetchTasks(a.client, a.filterRepo) case tickMsg: return a, tea.Batch(fetchTasks(a.client, a.filterRepo), tickCmd()) case tea.KeyMsg: if a.mode == viewFilter { return a.updateFilter(msg) } if a.mode == viewDetail { return a.updateDetail(msg) } return a.updateBoard(msg) } return a, nil } func (a App) updateBoard(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, keys.Quit): return a, tea.Quit case key.Matches(msg, keys.Left): a.board.moveLeft() case key.Matches(msg, keys.Right): a.board.moveRight() case key.Matches(msg, keys.Up): a.board.moveUp() case key.Matches(msg, keys.Down): a.board.moveDown() case key.Matches(msg, keys.Enter): if t := a.board.selectedTask(); t != nil { a.mode = viewDetail a.detail.setTask(t, a.width, a.height) } case key.Matches(msg, keys.Edit): if t := a.board.selectedTask(); t != nil { return a, editTaskCmd(t, a.client) } case key.Matches(msg, keys.New): return a, newTaskEditorCmd(a.client) case key.Matches(msg, keys.Done): if t := a.board.selectedTask(); t != nil && t.Status == models.StatusInReview { return a, updateTaskStatus(a.client, t.ID, models.StatusDone) } case key.Matches(msg, keys.Wontdo): if t := a.board.selectedTask(); t != nil && t.Status == models.StatusInReview { return a, updateTaskStatus(a.client, t.ID, models.StatusWontdo) } case key.Matches(msg, keys.Refresh): return a, fetchTasks(a.client, a.filterRepo) case key.Matches(msg, keys.Filter): a.mode = viewFilter a.filter.SetValue(a.filterRepo) a.filter.Focus() return a, nil case key.Matches(msg, keys.Help): a.showHelp = !a.showHelp } return a, nil } func (a App) updateDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, keys.Back), key.Matches(msg, keys.Quit): a.mode = viewBoard return a, nil case key.Matches(msg, keys.Edit): if a.detail.task != nil { return a, editTaskCmd(a.detail.task, a.client) } } var cmd tea.Cmd a.detail.viewport, cmd = a.detail.viewport.Update(msg) return a, cmd } func (a App) updateFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "enter": a.filterRepo = a.filter.Value() a.mode = viewBoard a.filter.Blur() return a, fetchTasks(a.client, a.filterRepo) case "esc": a.mode = viewBoard a.filter.Blur() return a, nil } var cmd tea.Cmd a.filter, cmd = a.filter.Update(msg) return a, cmd } func (a App) View() string { if a.width == 0 { return "Loading..." } var content string switch a.mode { case viewDetail: content = a.detail.view() case viewFilter: content = a.board.view(a.width, a.height-4) content += "\n" + lipgloss.NewStyle().Bold(true).Render("Filter repo: ") + a.filter.View() default: content = a.board.view(a.width, a.height-3) } var statusLine string if a.err != nil { errText := a.err.Error() if len(errText) > a.width-2 { errText = errText[:a.width-5] + "..." } statusLine = errStyle.Render(errText) } else if a.filterRepo != "" { statusLine = dimStyle.Render(fmt.Sprintf("filter: %s", a.filterRepo)) } var helpView string if a.showHelp && a.mode == viewBoard { helpView = a.help.View(keys) } else if a.mode == viewBoard { helpView = helpStyle.Render("? help q quit") } parts := []string{content} if statusLine != "" { parts = append(parts, statusLine) } if helpView != "" { parts = append(parts, helpView) } return strings.Join(parts, "\n") } func fetchTasks(client *Client, repo string) tea.Cmd { return func() tea.Msg { tasks, err := client.ListTasks(context.Background(), "", repo) return tasksLoadedMsg{tasks: tasks, err: err} } } func updateTaskStatus(client *Client, id string, status models.TaskStatus) tea.Cmd { return func() tea.Msg { err := client.UpdateTask(context.Background(), id, models.UpdateTaskRequest{Status: status}) return taskUpdatedMsg{err: err} } } func tickCmd() tea.Cmd { return tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) }