feat(convoy): Add numbered shortcuts and interactive TUI (gt-fo0qa)
- Add numbered prefixes to `gt convoy list` output (1. 2. 3. ...) - Support numeric shortcuts in `gt convoy status <n>` - Add `-i/--interactive` flag for expandable tree view TUI - New internal/tui/convoy package with bubbletea-based UI - j/k navigation, enter to expand/collapse - 1-9 to jump directly to convoy 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
1c82d51408
commit
7d6ea09efe
+72
-10
@@ -9,10 +9,13 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
|
"github.com/steveyegge/gastown/internal/tui/convoy"
|
||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,19 +28,25 @@ func generateShortID() string {
|
|||||||
|
|
||||||
// Convoy command flags
|
// Convoy command flags
|
||||||
var (
|
var (
|
||||||
convoyMolecule string
|
convoyMolecule string
|
||||||
convoyNotify string
|
convoyNotify string
|
||||||
convoyStatusJSON bool
|
convoyStatusJSON bool
|
||||||
convoyListJSON bool
|
convoyListJSON bool
|
||||||
convoyListStatus string
|
convoyListStatus string
|
||||||
convoyListAll bool
|
convoyListAll bool
|
||||||
|
convoyInteractive bool
|
||||||
)
|
)
|
||||||
|
|
||||||
var convoyCmd = &cobra.Command{
|
var convoyCmd = &cobra.Command{
|
||||||
Use: "convoy",
|
Use: "convoy",
|
||||||
GroupID: GroupWork,
|
GroupID: GroupWork,
|
||||||
Short: "Track batches of work across rigs",
|
Short: "Track batches of work across rigs",
|
||||||
RunE: requireSubcommand,
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if convoyInteractive {
|
||||||
|
return runConvoyTUI()
|
||||||
|
}
|
||||||
|
return requireSubcommand(cmd, args)
|
||||||
|
},
|
||||||
Long: `Manage convoys - the primary unit for tracking batched work.
|
Long: `Manage convoys - the primary unit for tracking batched work.
|
||||||
|
|
||||||
A convoy is a persistent tracking unit that monitors related issues across
|
A convoy is a persistent tracking unit that monitors related issues across
|
||||||
@@ -134,6 +143,9 @@ func init() {
|
|||||||
convoyListCmd.Flags().StringVar(&convoyListStatus, "status", "", "Filter by status (open, closed)")
|
convoyListCmd.Flags().StringVar(&convoyListStatus, "status", "", "Filter by status (open, closed)")
|
||||||
convoyListCmd.Flags().BoolVar(&convoyListAll, "all", false, "Show all convoys (open and closed)")
|
convoyListCmd.Flags().BoolVar(&convoyListAll, "all", false, "Show all convoys (open and closed)")
|
||||||
|
|
||||||
|
// Interactive TUI flag (on parent command)
|
||||||
|
convoyCmd.Flags().BoolVarP(&convoyInteractive, "interactive", "i", false, "Interactive tree view")
|
||||||
|
|
||||||
// Add subcommands
|
// Add subcommands
|
||||||
convoyCmd.AddCommand(convoyCreateCmd)
|
convoyCmd.AddCommand(convoyCreateCmd)
|
||||||
convoyCmd.AddCommand(convoyStatusCmd)
|
convoyCmd.AddCommand(convoyStatusCmd)
|
||||||
@@ -329,6 +341,15 @@ func runConvoyStatus(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
convoyID := args[0]
|
convoyID := args[0]
|
||||||
|
|
||||||
|
// Check if it's a numeric shortcut (e.g., "1" instead of "hq-cv-xyz")
|
||||||
|
if n, err := strconv.Atoi(convoyID); err == nil && n > 0 {
|
||||||
|
resolved, err := resolveConvoyNumber(townBeads, n)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
convoyID = resolved
|
||||||
|
}
|
||||||
|
|
||||||
// Get convoy details
|
// Get convoy details
|
||||||
showArgs := []string{"show", convoyID, "--json"}
|
showArgs := []string{"show", convoyID, "--json"}
|
||||||
showCmd := exec.Command("bd", showArgs...)
|
showCmd := exec.Command("bd", showArgs...)
|
||||||
@@ -518,11 +539,11 @@ func runConvoyList(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("%s\n\n", style.Bold.Render("Convoys"))
|
fmt.Printf("%s\n\n", style.Bold.Render("Convoys"))
|
||||||
for _, c := range convoys {
|
for i, c := range convoys {
|
||||||
status := formatConvoyStatus(c.Status)
|
status := formatConvoyStatus(c.Status)
|
||||||
fmt.Printf(" 🚚 %s: %s %s\n", c.ID, c.Title, status)
|
fmt.Printf(" %d. 🚚 %s: %s %s\n", i+1, c.ID, c.Title, status)
|
||||||
}
|
}
|
||||||
fmt.Printf("\nUse 'gt convoy status <id>' for detailed view.\n")
|
fmt.Printf("\nUse 'gt convoy status <id>' or 'gt convoy status <n>' for detailed view.\n")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -703,3 +724,44 @@ func getIssueDetails(issueID string) *issueDetails {
|
|||||||
IssueType: issues[0].IssueType,
|
IssueType: issues[0].IssueType,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runConvoyTUI launches the interactive convoy TUI.
|
||||||
|
func runConvoyTUI() error {
|
||||||
|
townBeads, err := getTownBeadsDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m := convoy.New(townBeads)
|
||||||
|
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||||
|
_, err = p.Run()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveConvoyNumber converts a numeric shortcut (1, 2, 3...) to a convoy ID.
|
||||||
|
// Numbers correspond to the order shown in 'gt convoy list'.
|
||||||
|
func resolveConvoyNumber(townBeads string, n int) (string, error) {
|
||||||
|
// Get convoy list (same query as runConvoyList)
|
||||||
|
listArgs := []string{"list", "--type=convoy", "--json"}
|
||||||
|
listCmd := exec.Command("bd", listArgs...)
|
||||||
|
listCmd.Dir = townBeads
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
listCmd.Stdout = &stdout
|
||||||
|
|
||||||
|
if err := listCmd.Run(); err != nil {
|
||||||
|
return "", fmt.Errorf("listing convoys: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var convoys []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &convoys); err != nil {
|
||||||
|
return "", fmt.Errorf("parsing convoy list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n < 1 || n > len(convoys) {
|
||||||
|
return "", fmt.Errorf("convoy %d not found (have %d convoys)", n, len(convoys))
|
||||||
|
}
|
||||||
|
|
||||||
|
return convoys[n-1].ID, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package convoy
|
||||||
|
|
||||||
|
import "github.com/charmbracelet/bubbles/key"
|
||||||
|
|
||||||
|
// KeyMap defines the key bindings for the convoy TUI.
|
||||||
|
type KeyMap struct {
|
||||||
|
Up key.Binding
|
||||||
|
Down key.Binding
|
||||||
|
PageUp key.Binding
|
||||||
|
PageDown key.Binding
|
||||||
|
Top key.Binding
|
||||||
|
Bottom key.Binding
|
||||||
|
Toggle key.Binding // expand/collapse
|
||||||
|
Help key.Binding
|
||||||
|
Quit key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultKeyMap returns the default key bindings.
|
||||||
|
func DefaultKeyMap() KeyMap {
|
||||||
|
return KeyMap{
|
||||||
|
Up: key.NewBinding(
|
||||||
|
key.WithKeys("up", "k"),
|
||||||
|
key.WithHelp("↑/k", "up"),
|
||||||
|
),
|
||||||
|
Down: key.NewBinding(
|
||||||
|
key.WithKeys("down", "j"),
|
||||||
|
key.WithHelp("↓/j", "down"),
|
||||||
|
),
|
||||||
|
PageUp: key.NewBinding(
|
||||||
|
key.WithKeys("pgup", "ctrl+u"),
|
||||||
|
key.WithHelp("pgup", "page up"),
|
||||||
|
),
|
||||||
|
PageDown: key.NewBinding(
|
||||||
|
key.WithKeys("pgdown", "ctrl+d"),
|
||||||
|
key.WithHelp("pgdn", "page down"),
|
||||||
|
),
|
||||||
|
Top: key.NewBinding(
|
||||||
|
key.WithKeys("home", "g"),
|
||||||
|
key.WithHelp("g", "top"),
|
||||||
|
),
|
||||||
|
Bottom: key.NewBinding(
|
||||||
|
key.WithKeys("end", "G"),
|
||||||
|
key.WithHelp("G", "bottom"),
|
||||||
|
),
|
||||||
|
Toggle: key.NewBinding(
|
||||||
|
key.WithKeys("enter", " "),
|
||||||
|
key.WithHelp("enter/space", "expand/collapse"),
|
||||||
|
),
|
||||||
|
Help: key.NewBinding(
|
||||||
|
key.WithKeys("?"),
|
||||||
|
key.WithHelp("?", "help"),
|
||||||
|
),
|
||||||
|
Quit: key.NewBinding(
|
||||||
|
key.WithKeys("q", "esc", "ctrl+c"),
|
||||||
|
key.WithHelp("q", "quit"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortHelp returns keybindings to show in the help view.
|
||||||
|
func (k KeyMap) ShortHelp() []key.Binding {
|
||||||
|
return []key.Binding{k.Up, k.Down, k.Toggle, k.Quit, k.Help}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullHelp returns keybindings for the expanded help view.
|
||||||
|
func (k KeyMap) FullHelp() [][]key.Binding {
|
||||||
|
return [][]key.Binding{
|
||||||
|
{k.Up, k.Down, k.PageUp, k.PageDown},
|
||||||
|
{k.Top, k.Bottom, k.Toggle},
|
||||||
|
{k.Help, k.Quit},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
package convoy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/help"
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IssueItem represents a tracked issue within a convoy.
|
||||||
|
type IssueItem struct {
|
||||||
|
ID string
|
||||||
|
Title string
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvoyItem represents a convoy with its tracked issues.
|
||||||
|
type ConvoyItem struct {
|
||||||
|
ID string
|
||||||
|
Title string
|
||||||
|
Status string
|
||||||
|
Issues []IssueItem
|
||||||
|
Progress string // e.g., "2/5"
|
||||||
|
Expanded bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model is the bubbletea model for the convoy TUI.
|
||||||
|
type Model struct {
|
||||||
|
convoys []ConvoyItem
|
||||||
|
cursor int // Current selection index in flattened view
|
||||||
|
townBeads string // Path to town beads directory
|
||||||
|
err error
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
keys KeyMap
|
||||||
|
help help.Model
|
||||||
|
showHelp bool
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new convoy TUI model.
|
||||||
|
func New(townBeads string) Model {
|
||||||
|
return Model{
|
||||||
|
townBeads: townBeads,
|
||||||
|
keys: DefaultKeyMap(),
|
||||||
|
help: help.New(),
|
||||||
|
convoys: make([]ConvoyItem, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the model.
|
||||||
|
func (m Model) Init() tea.Cmd {
|
||||||
|
return m.fetchConvoys
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchConvoysMsg is the result of fetching convoys.
|
||||||
|
type fetchConvoysMsg struct {
|
||||||
|
convoys []ConvoyItem
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchConvoys fetches convoy data from beads.
|
||||||
|
func (m Model) fetchConvoys() tea.Msg {
|
||||||
|
convoys, err := loadConvoys(m.townBeads)
|
||||||
|
return fetchConvoysMsg{convoys: convoys, err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConvoys loads convoy data from the beads directory.
|
||||||
|
func loadConvoys(townBeads string) ([]ConvoyItem, error) {
|
||||||
|
// Get list of open convoys
|
||||||
|
listArgs := []string{"list", "--type=convoy", "--json"}
|
||||||
|
listCmd := exec.Command("bd", listArgs...)
|
||||||
|
listCmd.Dir = townBeads
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
listCmd.Stdout = &stdout
|
||||||
|
|
||||||
|
if err := listCmd.Run(); err != nil {
|
||||||
|
return nil, fmt.Errorf("listing convoys: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawConvoys []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &rawConvoys); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing convoy list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
convoys := make([]ConvoyItem, 0, len(rawConvoys))
|
||||||
|
for _, rc := range rawConvoys {
|
||||||
|
issues, completed, total := loadTrackedIssues(townBeads, rc.ID)
|
||||||
|
convoys = append(convoys, ConvoyItem{
|
||||||
|
ID: rc.ID,
|
||||||
|
Title: rc.Title,
|
||||||
|
Status: rc.Status,
|
||||||
|
Issues: issues,
|
||||||
|
Progress: fmt.Sprintf("%d/%d", completed, total),
|
||||||
|
Expanded: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return convoys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadTrackedIssues loads issues tracked by a convoy.
|
||||||
|
func loadTrackedIssues(townBeads, convoyID string) ([]IssueItem, int, int) {
|
||||||
|
dbPath := filepath.Join(townBeads, "beads.db")
|
||||||
|
|
||||||
|
// Query tracked issues from SQLite
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT d.depends_on_id
|
||||||
|
FROM dependencies d
|
||||||
|
WHERE d.issue_id = '%s' AND d.dependency_type = 'tracks'
|
||||||
|
`, convoyID)
|
||||||
|
|
||||||
|
cmd := exec.Command("sqlite3", "-json", dbPath, query)
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil, 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var deps []struct {
|
||||||
|
DependsOnID string `json:"depends_on_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &deps); err != nil {
|
||||||
|
return nil, 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
issues := make([]IssueItem, 0, len(deps))
|
||||||
|
completed := 0
|
||||||
|
|
||||||
|
for _, dep := range deps {
|
||||||
|
issueID := dep.DependsOnID
|
||||||
|
|
||||||
|
// Handle external references
|
||||||
|
if strings.HasPrefix(issueID, "external:") {
|
||||||
|
parts := strings.SplitN(issueID, ":", 3)
|
||||||
|
if len(parts) == 3 {
|
||||||
|
issueID = parts[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get issue details
|
||||||
|
issue := getIssueDetails(townBeads, issueID)
|
||||||
|
if issue != nil {
|
||||||
|
issues = append(issues, *issue)
|
||||||
|
if issue.Status == "closed" {
|
||||||
|
completed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by status (open first, then closed)
|
||||||
|
sort.Slice(issues, func(i, j int) bool {
|
||||||
|
if issues[i].Status == issues[j].Status {
|
||||||
|
return issues[i].ID < issues[j].ID
|
||||||
|
}
|
||||||
|
return issues[i].Status != "closed" // open comes first
|
||||||
|
})
|
||||||
|
|
||||||
|
return issues, completed, len(issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getIssueDetails fetches details for a single issue.
|
||||||
|
func getIssueDetails(townBeads, issueID string) *IssueItem {
|
||||||
|
cmd := exec.Command("bd", "show", issueID, "--json")
|
||||||
|
cmd.Dir = townBeads
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var issues []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil || len(issues) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &IssueItem{
|
||||||
|
ID: issues[0].ID,
|
||||||
|
Title: issues[0].Title,
|
||||||
|
Status: issues[0].Status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles messages.
|
||||||
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
|
m.help.Width = msg.Width
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case fetchConvoysMsg:
|
||||||
|
m.err = msg.err
|
||||||
|
m.convoys = msg.convoys
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, m.keys.Quit):
|
||||||
|
return m, tea.Quit
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Help):
|
||||||
|
m.showHelp = !m.showHelp
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Up):
|
||||||
|
if m.cursor > 0 {
|
||||||
|
m.cursor--
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Down):
|
||||||
|
max := m.maxCursor()
|
||||||
|
if m.cursor < max {
|
||||||
|
m.cursor++
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Top):
|
||||||
|
m.cursor = 0
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Bottom):
|
||||||
|
m.cursor = m.maxCursor()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Toggle):
|
||||||
|
m.toggleExpand()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
// Number keys for direct convoy access
|
||||||
|
case msg.String() >= "1" && msg.String() <= "9":
|
||||||
|
n := int(msg.String()[0] - '0')
|
||||||
|
if n <= len(m.convoys) {
|
||||||
|
m.jumpToConvoy(n - 1)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxCursor returns the maximum valid cursor position.
|
||||||
|
func (m Model) maxCursor() int {
|
||||||
|
count := 0
|
||||||
|
for _, c := range m.convoys {
|
||||||
|
count++ // convoy itself
|
||||||
|
if c.Expanded {
|
||||||
|
count += len(c.Issues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return count - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// cursorToConvoyIndex returns the convoy index and issue index for the current cursor.
|
||||||
|
// Returns (convoyIdx, issueIdx) where issueIdx is -1 if on a convoy row.
|
||||||
|
func (m Model) cursorToConvoyIndex() (int, int) {
|
||||||
|
pos := 0
|
||||||
|
for ci, c := range m.convoys {
|
||||||
|
if pos == m.cursor {
|
||||||
|
return ci, -1
|
||||||
|
}
|
||||||
|
pos++
|
||||||
|
if c.Expanded {
|
||||||
|
for ii := range c.Issues {
|
||||||
|
if pos == m.cursor {
|
||||||
|
return ci, ii
|
||||||
|
}
|
||||||
|
pos++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1, -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// toggleExpand toggles expansion of the convoy at the current cursor.
|
||||||
|
func (m *Model) toggleExpand() {
|
||||||
|
ci, ii := m.cursorToConvoyIndex()
|
||||||
|
if ci >= 0 && ii == -1 {
|
||||||
|
// On a convoy row, toggle it
|
||||||
|
m.convoys[ci].Expanded = !m.convoys[ci].Expanded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jumpToConvoy moves the cursor to a specific convoy by index.
|
||||||
|
func (m *Model) jumpToConvoy(convoyIdx int) {
|
||||||
|
if convoyIdx < 0 || convoyIdx >= len(m.convoys) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pos := 0
|
||||||
|
for ci, c := range m.convoys {
|
||||||
|
if ci == convoyIdx {
|
||||||
|
m.cursor = pos
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pos++
|
||||||
|
if c.Expanded {
|
||||||
|
pos += len(c.Issues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the model.
|
||||||
|
func (m Model) View() string {
|
||||||
|
return m.renderView()
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package convoy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Styles for the convoy TUI
|
||||||
|
var (
|
||||||
|
titleStyle = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color("12"))
|
||||||
|
|
||||||
|
selectedStyle = lipgloss.NewStyle().
|
||||||
|
Background(lipgloss.Color("236")).
|
||||||
|
Foreground(lipgloss.Color("15"))
|
||||||
|
|
||||||
|
convoyStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("15"))
|
||||||
|
|
||||||
|
issueOpenStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("11")) // yellow
|
||||||
|
|
||||||
|
issueClosedStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("10")) // green
|
||||||
|
|
||||||
|
progressStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("8")) // gray
|
||||||
|
|
||||||
|
helpStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("8"))
|
||||||
|
|
||||||
|
errorStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("9")) // red
|
||||||
|
)
|
||||||
|
|
||||||
|
// renderView renders the entire view.
|
||||||
|
func (m Model) renderView() string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
// Title
|
||||||
|
b.WriteString(titleStyle.Render("Convoys"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
if m.err != nil {
|
||||||
|
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
if len(m.convoys) == 0 && m.err == nil {
|
||||||
|
b.WriteString("No convoys found.\n")
|
||||||
|
b.WriteString("Create a convoy with: gt convoy create <name> [issues...]\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render convoys
|
||||||
|
pos := 0
|
||||||
|
for ci, c := range m.convoys {
|
||||||
|
isSelected := pos == m.cursor
|
||||||
|
|
||||||
|
// Convoy row
|
||||||
|
expandIcon := "▶"
|
||||||
|
if c.Expanded {
|
||||||
|
expandIcon = "▼"
|
||||||
|
}
|
||||||
|
|
||||||
|
statusIcon := statusToIcon(c.Status)
|
||||||
|
line := fmt.Sprintf("%s %d. %s %s: %s %s",
|
||||||
|
expandIcon,
|
||||||
|
ci+1,
|
||||||
|
statusIcon,
|
||||||
|
c.ID,
|
||||||
|
c.Title,
|
||||||
|
progressStyle.Render(fmt.Sprintf("(%s)", c.Progress)),
|
||||||
|
)
|
||||||
|
|
||||||
|
if isSelected {
|
||||||
|
b.WriteString(selectedStyle.Render(line))
|
||||||
|
} else {
|
||||||
|
b.WriteString(convoyStyle.Render(line))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
pos++
|
||||||
|
|
||||||
|
// Render issues if expanded
|
||||||
|
if c.Expanded {
|
||||||
|
for ii, issue := range c.Issues {
|
||||||
|
isIssueSelected := pos == m.cursor
|
||||||
|
|
||||||
|
// Tree connector
|
||||||
|
connector := "├─"
|
||||||
|
if ii == len(c.Issues)-1 {
|
||||||
|
connector = "└─"
|
||||||
|
}
|
||||||
|
|
||||||
|
issueIcon := "○"
|
||||||
|
style := issueOpenStyle
|
||||||
|
if issue.Status == "closed" {
|
||||||
|
issueIcon = "✓"
|
||||||
|
style = issueClosedStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
issueLine := fmt.Sprintf(" %s %s %s: %s",
|
||||||
|
connector,
|
||||||
|
issueIcon,
|
||||||
|
issue.ID,
|
||||||
|
truncate(issue.Title, 50),
|
||||||
|
)
|
||||||
|
|
||||||
|
if isIssueSelected {
|
||||||
|
b.WriteString(selectedStyle.Render(issueLine))
|
||||||
|
} else {
|
||||||
|
b.WriteString(style.Render(issueLine))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
pos++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Help footer
|
||||||
|
b.WriteString("\n")
|
||||||
|
if m.showHelp {
|
||||||
|
b.WriteString(m.help.View(m.keys))
|
||||||
|
} else {
|
||||||
|
b.WriteString(helpStyle.Render("j/k:navigate enter:expand 1-9:jump q:quit ?:help"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusToIcon converts a status string to an icon.
|
||||||
|
func statusToIcon(status string) string {
|
||||||
|
switch status {
|
||||||
|
case "open":
|
||||||
|
return "🚚"
|
||||||
|
case "closed":
|
||||||
|
return "✓"
|
||||||
|
case "in_progress":
|
||||||
|
return "→"
|
||||||
|
default:
|
||||||
|
return "●"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncate shortens a string to the given length.
|
||||||
|
func truncate(s string, maxLen int) string {
|
||||||
|
if len(s) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxLen-3] + "..."
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user