Charm-based CLI with JSON persistence, status/list filters, external editor editing, command-history undo/redo, and glamour markdown without AutoStyle OSC hangs. Co-authored-by: Cursor <cursoragent@cursor.com>
106 lines
2.7 KiB
Go
106 lines
2.7 KiB
Go
package ticket
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
)
|
|
|
|
// FormatEditDocument is the markdown representation used for external editing.
|
|
// Format:
|
|
//
|
|
// # Title
|
|
//
|
|
// optional description body…
|
|
func FormatEditDocument(t Ticket) string {
|
|
title := strings.TrimSpace(t.Title)
|
|
title = strings.TrimLeft(title, "#")
|
|
title = strings.TrimSpace(title)
|
|
|
|
var b strings.Builder
|
|
b.WriteString("# ")
|
|
b.WriteString(title)
|
|
b.WriteString("\n")
|
|
if desc := strings.TrimSpace(t.Description); desc != "" {
|
|
b.WriteString("\n")
|
|
b.WriteString(desc)
|
|
if !strings.HasSuffix(desc, "\n") {
|
|
b.WriteString("\n")
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// ParseEditDocument extracts title and description from an edited markdown file.
|
|
func ParseEditDocument(content string) (title, description string, err error) {
|
|
content = strings.ReplaceAll(content, "\r\n", "\n")
|
|
content = strings.TrimSpace(content)
|
|
if content == "" {
|
|
return "", "", fmt.Errorf("ticket content is empty")
|
|
}
|
|
|
|
lines := strings.Split(content, "\n")
|
|
first := strings.TrimSpace(lines[0])
|
|
if strings.HasPrefix(first, "#") {
|
|
title = strings.TrimSpace(strings.TrimLeft(first, "#"))
|
|
rest := strings.Join(lines[1:], "\n")
|
|
description = strings.TrimSpace(rest)
|
|
} else {
|
|
title = first
|
|
rest := strings.Join(lines[1:], "\n")
|
|
description = strings.TrimSpace(rest)
|
|
}
|
|
if title == "" {
|
|
return "", "", fmt.Errorf("ticket title is required")
|
|
}
|
|
return title, description, nil
|
|
}
|
|
|
|
// WriteEditFile writes the ticket to a temp .md file and returns its path.
|
|
func WriteEditFile(t Ticket) (string, error) {
|
|
f, err := os.CreateTemp("", "task-master-ticket-*.md")
|
|
if err != nil {
|
|
return "", fmt.Errorf("create temp file: %w", err)
|
|
}
|
|
path := f.Name()
|
|
if _, err := f.WriteString(FormatEditDocument(t)); err != nil {
|
|
_ = f.Close()
|
|
_ = os.Remove(path)
|
|
return "", fmt.Errorf("write temp file: %w", err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
_ = os.Remove(path)
|
|
return "", fmt.Errorf("close temp file: %w", err)
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
// ResolveEditor returns the user's preferred editor command string.
|
|
// Order: $EDITOR → $VISUAL → nvim → vim → vi.
|
|
func ResolveEditor() string {
|
|
if ed := strings.TrimSpace(os.Getenv("EDITOR")); ed != "" {
|
|
return ed
|
|
}
|
|
if ed := strings.TrimSpace(os.Getenv("VISUAL")); ed != "" {
|
|
return ed
|
|
}
|
|
for _, cand := range []string{"nvim", "vim", "vi"} {
|
|
if _, err := exec.LookPath(cand); err == nil {
|
|
return cand
|
|
}
|
|
}
|
|
return "vi"
|
|
}
|
|
|
|
// EditorCmd builds a command that opens path in the default terminal editor.
|
|
// Supports multi-word EDITOR values like "code --wait".
|
|
func EditorCmd(path string) *exec.Cmd {
|
|
ed := ResolveEditor()
|
|
cmd := exec.Command("sh", "-c", ed+` "$1"`, "--", path)
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return cmd
|
|
}
|