todo editor
This commit is contained in:
@@ -5,3 +5,5 @@
|
||||
"status": "open",
|
||||
"created_at": "2026-11-04T15:25:41.786Z"
|
||||
}
|
||||
|
||||
Send some :tada: emojis to the user in the pi agent interface
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# TodoApp
|
||||
|
||||
A terminal app for inspecting Pi project todos. The Bubble Tea UI currently shows the selected project's `.pi/todos` directory and a file count; browsing and editing in the UI are planned in later tickets. The `internal/todos` storage package already reads and writes Pi-compatible Markdown files, using `.lock` files and atomic replacements for edits.
|
||||
A terminal app for Pi project todos. The Bubble Tea UI browses and searches a project's `.pi/todos` directory, shows full Markdown bodies, and creates, edits, closes, and reopens todos. The `internal/todos` storage package uses Pi-compatible Markdown files, `.lock` files, and atomic replacements for edits.
|
||||
|
||||
## Run
|
||||
|
||||
@@ -12,4 +12,14 @@ go run . --project /path/to/repo # inspect another project
|
||||
go test ./...
|
||||
```
|
||||
|
||||
If `.pi/todos` does not exist, the app shows an empty state without creating files. If the project path is invalid or the todo directory cannot be read, it exits with an error. Press `q` or `Ctrl+C` to exit the TUI.
|
||||
If `.pi/todos` does not exist, the app shows an empty state without creating files. Bad todo files are reported as warnings without hiding valid todos. If the project path is invalid or the todo directory cannot be read, it exits with an error.
|
||||
|
||||
## Controls
|
||||
|
||||
- `↑`/`↓` or `j`/`k`: select a todo; `Enter`: view its full Markdown body.
|
||||
- `/`: search titles, IDs, tags, statuses, and body text. Type to filter, then `Enter` or `Esc` to finish. In the list, `Esc` clears the filter.
|
||||
- In detail view, use `↑`/`↓`, `PgUp`/`PgDn`, `Home`/`End` to scroll; `Esc` returns to the list.
|
||||
- `n`: create a todo; `e`: edit the selected todo (also works in detail view).
|
||||
- In the editor, `Tab`/`Shift+Tab` moves between title, comma-separated tags, and Markdown body. `Enter` adds a line in the body. `←`/`→` moves the cursor; `Ctrl+S` saves. `Esc` cancels, with confirmation if there are unsaved changes.
|
||||
- `x`: close the selected todo; `o`: reopen a closed todo. Both ask for confirmation (`y`/`n`).
|
||||
- `r`: reload todos from disk; `q` or `Ctrl+C`: quit.
|
||||
|
||||
@@ -1,19 +1,34 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/mattn/go-runewidth"
|
||||
|
||||
"todoapp/internal/todos"
|
||||
)
|
||||
|
||||
type model struct {
|
||||
project string
|
||||
todosDir string
|
||||
count int
|
||||
project string
|
||||
todosDir string
|
||||
items []todos.Todo
|
||||
warnings []error
|
||||
query string
|
||||
search bool
|
||||
selected int // index into the filtered list
|
||||
detail bool
|
||||
scroll int
|
||||
width int
|
||||
height int
|
||||
editor *editor
|
||||
confirm string
|
||||
confirmID string
|
||||
notice string
|
||||
}
|
||||
|
||||
func newModel(project string) (model, error) {
|
||||
@@ -28,42 +43,223 @@ func newModel(project string) (model, error) {
|
||||
if !info.IsDir() {
|
||||
return model{}, fmt.Errorf("project path %q is not a directory", absolute)
|
||||
}
|
||||
|
||||
dir := filepath.Join(absolute, ".pi", "todos")
|
||||
entries, err := os.ReadDir(dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return model{project: absolute, todosDir: dir}, nil
|
||||
}
|
||||
dir := todos.Dir(absolute)
|
||||
items, warnings, err := todos.Scan(dir)
|
||||
if err != nil {
|
||||
return model{}, fmt.Errorf("read todo directory %q: %w", dir, err)
|
||||
return model{}, fmt.Errorf("load todos: %w", err)
|
||||
}
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".md") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return model{project: absolute, todosDir: dir, count: count}, nil
|
||||
return model{project: absolute, todosDir: dir, items: items, warnings: warnings, width: 80, height: 24}, nil
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m model) filtered() []int {
|
||||
query := strings.ToLower(strings.TrimSpace(m.query))
|
||||
var matches []int
|
||||
for i, todo := range m.items {
|
||||
haystack := strings.ToLower(strings.Join([]string{todo.ID, todo.Title, todo.Status, strings.Join(todo.Tags, " "), todo.Body}, " "))
|
||||
if query == "" || strings.Contains(haystack, query) {
|
||||
matches = append(matches, i)
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
func (m *model) clampSelection() {
|
||||
matches := m.filtered()
|
||||
if len(matches) == 0 {
|
||||
m.selected = 0
|
||||
} else if m.selected >= len(matches) {
|
||||
m.selected = len(matches) - 1
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := message.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "q", "ctrl+c":
|
||||
switch msg := message.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
return m, nil
|
||||
case tea.KeyMsg:
|
||||
if msg.String() == "ctrl+c" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
if m.confirm != "" {
|
||||
m.handleConfirm(msg)
|
||||
return m, nil
|
||||
}
|
||||
if m.editor != nil {
|
||||
m.handleEditor(msg)
|
||||
return m, nil
|
||||
}
|
||||
if m.search {
|
||||
switch msg.String() {
|
||||
case "esc", "enter":
|
||||
m.search = false
|
||||
case "backspace", "ctrl+h":
|
||||
runes := []rune(m.query)
|
||||
if len(runes) > 0 {
|
||||
m.query = string(runes[:len(runes)-1])
|
||||
}
|
||||
m.selected = 0
|
||||
case "ctrl+u":
|
||||
m.query = ""
|
||||
m.selected = 0
|
||||
default:
|
||||
if msg.Type == tea.KeySpace {
|
||||
m.query += " "
|
||||
m.selected = 0
|
||||
} else if msg.Type == tea.KeyRunes {
|
||||
m.query += string(msg.Runes)
|
||||
m.selected = 0
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if msg.String() == "q" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
if m.detail {
|
||||
if msg.String() == "e" {
|
||||
m.openEditor(false)
|
||||
return m, nil
|
||||
}
|
||||
if msg.String() == "x" || msg.String() == "o" {
|
||||
m.askStatusChange(msg.String())
|
||||
return m, nil
|
||||
}
|
||||
lines := m.detailLines()
|
||||
page := max(1, m.height-9)
|
||||
last := max(0, len(lines)-page)
|
||||
switch msg.String() {
|
||||
case "esc", "left":
|
||||
m.detail, m.scroll = false, 0
|
||||
case "up", "k":
|
||||
m.scroll = max(0, m.scroll-1)
|
||||
case "down", "j":
|
||||
m.scroll = min(last, m.scroll+1)
|
||||
case "pgup":
|
||||
m.scroll = max(0, m.scroll-page)
|
||||
case "pgdown", " ":
|
||||
m.scroll = min(last, m.scroll+page)
|
||||
case "home", "g":
|
||||
m.scroll = 0
|
||||
case "end", "G":
|
||||
m.scroll = last
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
matches := m.filtered()
|
||||
switch msg.String() {
|
||||
case "up", "k":
|
||||
m.selected = max(0, m.selected-1)
|
||||
case "down", "j":
|
||||
m.selected = min(max(0, len(matches)-1), m.selected+1)
|
||||
case "pgup":
|
||||
m.selected = max(0, m.selected-max(1, m.height-8))
|
||||
case "pgdown":
|
||||
m.selected = min(max(0, len(matches)-1), m.selected+max(1, m.height-8))
|
||||
case "home", "g":
|
||||
m.selected = 0
|
||||
case "end", "G":
|
||||
m.selected = max(0, len(matches)-1)
|
||||
case "enter":
|
||||
if len(matches) > 0 {
|
||||
m.detail, m.scroll = true, 0
|
||||
}
|
||||
case "/":
|
||||
m.search = true
|
||||
case "esc":
|
||||
m.query = ""
|
||||
m.selected = 0
|
||||
case "r":
|
||||
m.reload("")
|
||||
case "n":
|
||||
m.openEditor(true)
|
||||
case "e":
|
||||
m.openEditor(false)
|
||||
case "x", "o":
|
||||
m.askStatusChange(msg.String())
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Remove terminal control characters from content read from Markdown files.
|
||||
func safe(text string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r == '\n' || r == '\t' || !unicode.IsControl(r) {
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, text)
|
||||
}
|
||||
|
||||
func (m model) detailLines() []string {
|
||||
matches := m.filtered()
|
||||
if len(matches) == 0 || m.selected >= len(matches) {
|
||||
return nil
|
||||
}
|
||||
body := safe(m.items[matches[m.selected]].Body)
|
||||
if strings.TrimSpace(body) == "" {
|
||||
return []string{"(No description)"}
|
||||
}
|
||||
var lines []string
|
||||
for _, line := range strings.Split(strings.TrimRight(body, "\n"), "\n") {
|
||||
lines = append(lines, strings.Split(runewidth.Wrap(line, max(1, m.width-2)), "\n")...)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
var state string
|
||||
if m.count == 0 {
|
||||
state = "No todos yet. Pi stores todo items in this directory."
|
||||
if m.confirm != "" {
|
||||
return m.confirmView()
|
||||
}
|
||||
if m.editor != nil {
|
||||
return m.editorView()
|
||||
}
|
||||
width := max(1, m.width)
|
||||
matches := m.filtered()
|
||||
if m.detail && len(matches) > 0 {
|
||||
todo := m.items[matches[m.selected]]
|
||||
lines := m.detailLines()
|
||||
page := max(1, m.height-9)
|
||||
start := min(m.scroll, max(0, len(lines)-page))
|
||||
end := min(start+page, len(lines))
|
||||
header := fmt.Sprintf("TodoApp — %s [%s]\n%s • %s • %s\n\n", safe(todo.Title), todo.ID, safe(todo.Status), safe(strings.Join(todo.Tags, ", ")), todo.CreatedAt)
|
||||
return header + strings.Join(lines[start:end], "\n") + fmt.Sprintf("\n\n%d-%d/%d ↑↓/PgUp/PgDn scroll • e edit • x/o close/reopen • Esc back • q quit\n%s", start+1, end, len(lines), noticeLine(m.notice))
|
||||
}
|
||||
var out strings.Builder
|
||||
fmt.Fprintf(&out, "TodoApp — %s\nTodos: %s\n", m.project, m.todosDir)
|
||||
if len(m.warnings) > 0 {
|
||||
fmt.Fprintf(&out, "Warning: %d unreadable todo(s): %s\n", len(m.warnings), safe(m.warnings[0].Error()))
|
||||
}
|
||||
fmt.Fprintf(&out, "\n%d of %d todo(s)\n", len(matches), len(m.items))
|
||||
if len(matches) == 0 {
|
||||
if len(m.items) == 0 {
|
||||
out.WriteString("No todos yet. Pi stores todo items in this directory.\n")
|
||||
} else {
|
||||
out.WriteString("No matching todos.\n")
|
||||
}
|
||||
} else {
|
||||
page := max(1, m.height-9)
|
||||
start := max(0, m.selected-page+1)
|
||||
end := min(start+page, len(matches))
|
||||
for i := start; i < end; i++ {
|
||||
todo := m.items[matches[i]]
|
||||
prefix := " "
|
||||
if i == m.selected {
|
||||
prefix = "> "
|
||||
}
|
||||
line := fmt.Sprintf("%s%s %s [%s] %s", prefix, todo.ID, safe(todo.Title), safe(todo.Status), safe(strings.Join(todo.Tags, ", ")))
|
||||
out.WriteString(runewidth.Truncate(line, width, "…") + "\n")
|
||||
}
|
||||
}
|
||||
if m.search {
|
||||
fmt.Fprintf(&out, "\nSearch: %s_ (Enter/Esc finish, Ctrl+U clear)\n", safe(m.query))
|
||||
} else {
|
||||
state = fmt.Sprintf("Found %d todo file(s). Browsing is coming in the next ticket.", m.count)
|
||||
fmt.Fprintf(&out, "\nSearch: %s (/ to edit, Esc clear)\n", safe(m.query))
|
||||
}
|
||||
return fmt.Sprintf("TodoApp\n\nProject: %s\nTodos: %s\n\n%s\n\nPress q or Ctrl+C to quit.\n", m.project, m.todosDir, state)
|
||||
out.WriteString("↑↓ navigate • Enter view • n new • e edit • x/o close/reopen • r refresh • q quit\n")
|
||||
out.WriteString(noticeLine(m.notice))
|
||||
return out.String()
|
||||
}
|
||||
|
||||
+188
-11
@@ -7,15 +7,30 @@ import (
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"todoapp/internal/todos"
|
||||
)
|
||||
|
||||
func testKey(m model, key tea.KeyMsg) model {
|
||||
updated, _ := m.Update(key)
|
||||
return updated.(model)
|
||||
}
|
||||
|
||||
func runeKey(r rune) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} }
|
||||
func typedKey(r rune) tea.KeyMsg {
|
||||
if r == ' ' {
|
||||
return tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}}
|
||||
}
|
||||
return runeKey(r)
|
||||
}
|
||||
|
||||
func TestNewModelWithoutTodos(t *testing.T) {
|
||||
project := t.TempDir()
|
||||
app, err := newModel(project)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if app.todosDir != filepath.Join(project, ".pi", "todos") || app.count != 0 {
|
||||
if app.todosDir != filepath.Join(project, ".pi", "todos") || len(app.items) != 0 {
|
||||
t.Fatalf("unexpected model: %+v", app)
|
||||
}
|
||||
if !strings.Contains(app.View(), "No todos yet") {
|
||||
@@ -23,21 +38,183 @@ func TestNewModelWithoutTodos(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewModelWithTodos(t *testing.T) {
|
||||
func TestNewModelWithTodosAndSearch(t *testing.T) {
|
||||
project := t.TempDir()
|
||||
dir := filepath.Join(project, ".pi", "todos")
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
dir := todos.Dir(project)
|
||||
if _, err := todos.Create(dir, todos.Todo{Title: "Implement feature", Tags: []string{"go"}, Body: "# Details\n\nBody text."}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"a.md", "b.md", "settings.json"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), nil, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := todos.Create(dir, todos.Todo{Title: "Celebrate", Status: "closed"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := newModel(project)
|
||||
if err != nil || app.count != 2 {
|
||||
if err != nil || len(app.items) != 2 {
|
||||
t.Fatalf("unexpected result: %+v, %v", app, err)
|
||||
}
|
||||
if !strings.Contains(app.View(), "Implement feature") || !strings.Contains(app.View(), "Celebrate") {
|
||||
t.Fatalf("list missing titles: %s", app.View())
|
||||
}
|
||||
app = testKey(app, runeKey('/'))
|
||||
for _, r := range "celebrate" {
|
||||
app = testKey(app, runeKey(r))
|
||||
}
|
||||
if len(app.filtered()) != 1 || !strings.Contains(app.View(), "Celebrate") || strings.Contains(app.View(), "Implement feature") {
|
||||
t.Fatalf("search failed: %s", app.View())
|
||||
}
|
||||
app = testKey(app, tea.KeyMsg{Type: tea.KeyEsc})
|
||||
app = testKey(app, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if !app.detail || !strings.Contains(app.View(), "No description") {
|
||||
t.Fatalf("detail missing: %s", app.View())
|
||||
}
|
||||
app = testKey(app, tea.KeyMsg{Type: tea.KeyEsc})
|
||||
app = testKey(app, tea.KeyMsg{Type: tea.KeyEsc})
|
||||
if app.detail || app.query != "" {
|
||||
t.Fatalf("could not return and clear search: %+v", app)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailScrollingAndNavigation(t *testing.T) {
|
||||
app := model{width: 40, height: 12, items: []todos.Todo{
|
||||
{ID: "deadbeef", Title: "First", Body: "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\n"},
|
||||
{ID: "cafebabe", Title: "Second", Body: "# Another body"},
|
||||
}}
|
||||
app = testKey(app, tea.KeyMsg{Type: tea.KeyDown})
|
||||
if app.selected != 1 {
|
||||
t.Fatalf("selection did not move: %d", app.selected)
|
||||
}
|
||||
app = testKey(app, tea.KeyMsg{Type: tea.KeyUp})
|
||||
app = testKey(app, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if !strings.Contains(app.View(), "line 1") || strings.Contains(app.View(), "line 8") {
|
||||
t.Fatalf("expected paged detail: %s", app.View())
|
||||
}
|
||||
app = testKey(app, tea.KeyMsg{Type: tea.KeyPgDown})
|
||||
if app.scroll == 0 || !strings.Contains(app.View(), "line 4") {
|
||||
t.Fatalf("detail did not scroll: %s", app.View())
|
||||
}
|
||||
app = testKey(app, tea.KeyMsg{Type: tea.KeyEnd})
|
||||
if !strings.Contains(app.View(), "line 8") {
|
||||
t.Fatalf("detail cannot reach final line: %s", app.View())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshReadsNewTodos(t *testing.T) {
|
||||
project := t.TempDir()
|
||||
app, err := newModel(project)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := todos.Create(todos.Dir(project), todos.Todo{Title: "New"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app = testKey(app, runeKey('r'))
|
||||
if len(app.items) != 1 || !strings.Contains(app.View(), "New") {
|
||||
t.Fatalf("refresh missed todo: %s", app.View())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateEditAndStatusFlows(t *testing.T) {
|
||||
project := t.TempDir()
|
||||
app, err := newModel(project)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app = testKey(app, runeKey('n'))
|
||||
for _, r := range "New todo" {
|
||||
app = testKey(app, typedKey(r))
|
||||
}
|
||||
app = testKey(app, tea.KeyMsg{Type: tea.KeyTab})
|
||||
for _, r := range "qa, go" {
|
||||
app = testKey(app, typedKey(r))
|
||||
}
|
||||
app = testKey(app, tea.KeyMsg{Type: tea.KeyTab})
|
||||
for _, r := range "First line" {
|
||||
app = testKey(app, typedKey(r))
|
||||
}
|
||||
app = testKey(app, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
for _, r := range "Second" {
|
||||
app = testKey(app, runeKey(r))
|
||||
}
|
||||
app = testKey(app, tea.KeyMsg{Type: tea.KeyCtrlS})
|
||||
if app.editor != nil || len(app.items) != 1 || app.items[0].Title != "New todo" || app.items[0].Body != "First line\nSecond\n" || len(app.items[0].Tags) != 2 {
|
||||
t.Fatalf("create failed: %+v", app)
|
||||
}
|
||||
id := app.items[0].ID
|
||||
app = testKey(app, runeKey('/'))
|
||||
for _, r := range "New todo" {
|
||||
app = testKey(app, typedKey(r))
|
||||
}
|
||||
if app.query != "New todo" || len(app.filtered()) != 1 {
|
||||
t.Fatalf("search with spaces failed: %q", app.query)
|
||||
}
|
||||
app = testKey(app, tea.KeyMsg{Type: tea.KeyEsc})
|
||||
app = testKey(app, runeKey('e'))
|
||||
app = testKey(app, tea.KeyMsg{Type: tea.KeyCtrlU})
|
||||
for _, r := range "Edited" {
|
||||
app = testKey(app, runeKey(r))
|
||||
}
|
||||
app = testKey(app, tea.KeyMsg{Type: tea.KeyCtrlS})
|
||||
if app.items[0].Title != "Edited" || app.items[0].ID != id {
|
||||
t.Fatalf("edit failed: %+v", app.items)
|
||||
}
|
||||
app = testKey(app, runeKey('o'))
|
||||
if app.confirm != "" || app.items[0].Status != "open" {
|
||||
t.Fatal("reopen on open todo should be a no-op")
|
||||
}
|
||||
app = testKey(app, runeKey('x'))
|
||||
if app.confirm != "close" || app.items[0].Status != "open" {
|
||||
t.Fatal("close should require confirmation")
|
||||
}
|
||||
app = testKey(app, runeKey('n'))
|
||||
if app.confirm != "" || app.items[0].Status != "open" {
|
||||
t.Fatal("cancel changed status")
|
||||
}
|
||||
app = testKey(app, runeKey('x'))
|
||||
app = testKey(app, runeKey('y'))
|
||||
if app.items[0].Status != "closed" {
|
||||
t.Fatalf("close failed: %+v", app.items)
|
||||
}
|
||||
app = testKey(app, runeKey('o'))
|
||||
app = testKey(app, runeKey('y'))
|
||||
if app.items[0].Status != "open" {
|
||||
t.Fatalf("reopen failed: %+v", app.items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscardNeedsConfirmation(t *testing.T) {
|
||||
app, err := newModel(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app = testKey(app, runeKey('n'))
|
||||
app = testKey(app, runeKey('a'))
|
||||
app = testKey(app, tea.KeyMsg{Type: tea.KeyEsc})
|
||||
if app.confirm != "discard" || app.editor == nil {
|
||||
t.Fatal("unsaved changes were discarded without confirmation")
|
||||
}
|
||||
app = testKey(app, runeKey('n'))
|
||||
if app.editor == nil || app.confirm != "" {
|
||||
t.Fatal("cancel should return to editor")
|
||||
}
|
||||
app = testKey(app, tea.KeyMsg{Type: tea.KeyEsc})
|
||||
app = testKey(app, runeKey('y'))
|
||||
if app.editor != nil || len(app.items) != 0 {
|
||||
t.Fatal("discard wrote a todo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedFileDoesNotHideGoodTodos(t *testing.T) {
|
||||
project := t.TempDir()
|
||||
dir := todos.Dir(project)
|
||||
if _, err := todos.Create(dir, todos.Todo{Title: "Good"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "deadbeef.md"), []byte("not JSON"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := newModel(project)
|
||||
if err != nil || len(app.items) != 1 || len(app.warnings) != 1 || !strings.Contains(app.View(), "Good") || !strings.Contains(app.View(), "Warning") {
|
||||
t.Fatalf("unexpected malformed handling: %+v, %v", app, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidProjectAndTodoPath(t *testing.T) {
|
||||
@@ -51,14 +228,14 @@ func TestInvalidProjectAndTodoPath(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(project, ".pi", "todos"), nil, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := newModel(project); err == nil || !strings.Contains(err.Error(), "read todo directory") {
|
||||
if _, err := newModel(project); err == nil || !strings.Contains(err.Error(), "load todos") {
|
||||
t.Fatalf("expected todo directory error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuitKey(t *testing.T) {
|
||||
app := model{}
|
||||
_, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
|
||||
_, cmd := app.Update(runeKey('q'))
|
||||
if cmd == nil || cmd() != tea.Quit() {
|
||||
t.Fatal("q should quit")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/mattn/go-runewidth"
|
||||
|
||||
"todoapp/internal/todos"
|
||||
)
|
||||
|
||||
type editor struct {
|
||||
original todos.Todo
|
||||
creating bool
|
||||
fields [3]string // title, comma-separated tags, Markdown body
|
||||
field int
|
||||
cursor int // rune offset in active field
|
||||
dirty bool
|
||||
}
|
||||
|
||||
func (e *editor) selectField(field int) {
|
||||
e.field = (field + len(e.fields)) % len(e.fields)
|
||||
e.cursor = len([]rune(e.fields[e.field]))
|
||||
}
|
||||
|
||||
func (m *model) selectedTodo() (todos.Todo, bool) {
|
||||
matches := m.filtered()
|
||||
if len(matches) == 0 || m.selected >= len(matches) {
|
||||
return todos.Todo{}, false
|
||||
}
|
||||
return m.items[matches[m.selected]], true
|
||||
}
|
||||
|
||||
func (m *model) openEditor(create bool) {
|
||||
e := &editor{creating: create}
|
||||
if !create {
|
||||
selected, ok := m.selectedTodo()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
current, err := todos.Read(m.todosDir, selected.ID)
|
||||
if err != nil {
|
||||
m.notice = err.Error()
|
||||
return
|
||||
}
|
||||
e.original = current
|
||||
e.fields = [3]string{current.Title, strings.Join(current.Tags, ", "), current.Body}
|
||||
e.cursor = len([]rune(current.Title))
|
||||
}
|
||||
m.notice = ""
|
||||
m.editor = e
|
||||
}
|
||||
|
||||
func parseTags(text string) []string {
|
||||
var tags []string
|
||||
for _, tag := range strings.Split(text, ",") {
|
||||
if trimmed := strings.TrimSpace(tag); trimmed != "" {
|
||||
tags = append(tags, trimmed)
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func (m *model) reload(id string) {
|
||||
items, warnings, err := todos.Scan(m.todosDir)
|
||||
if err != nil {
|
||||
m.notice = err.Error()
|
||||
return
|
||||
}
|
||||
m.items, m.warnings = items, warnings
|
||||
if id != "" {
|
||||
for i, index := range m.filtered() {
|
||||
if m.items[index].ID == id {
|
||||
m.selected = i
|
||||
return
|
||||
}
|
||||
}
|
||||
m.query = "" // a changed item may no longer match the old filter
|
||||
for i, index := range m.filtered() {
|
||||
if m.items[index].ID == id {
|
||||
m.selected = i
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
m.clampSelection()
|
||||
}
|
||||
|
||||
func (m *model) saveEditor() {
|
||||
e := m.editor
|
||||
title := strings.TrimSpace(e.fields[0])
|
||||
if title == "" {
|
||||
m.notice = "Title cannot be empty"
|
||||
return
|
||||
}
|
||||
todo := e.original
|
||||
todo.Title = title
|
||||
todo.Tags = parseTags(e.fields[1])
|
||||
todo.Body = e.fields[2]
|
||||
var err error
|
||||
if e.creating {
|
||||
todo, err = todos.Create(m.todosDir, todo)
|
||||
} else {
|
||||
err = todos.Update(m.todosDir, todo)
|
||||
}
|
||||
if err != nil {
|
||||
m.notice = err.Error()
|
||||
return
|
||||
}
|
||||
m.editor = nil
|
||||
m.notice = "Todo saved"
|
||||
m.reload(todo.ID)
|
||||
}
|
||||
|
||||
func (m *model) handleEditor(key tea.KeyMsg) {
|
||||
e := m.editor
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
if e.dirty {
|
||||
m.confirm = "discard"
|
||||
} else {
|
||||
m.editor = nil
|
||||
}
|
||||
return
|
||||
case "ctrl+s":
|
||||
m.saveEditor()
|
||||
return
|
||||
case "tab":
|
||||
e.selectField(e.field + 1)
|
||||
return
|
||||
case "shift+tab":
|
||||
e.selectField(e.field - 1)
|
||||
return
|
||||
case "enter":
|
||||
if e.field < 2 {
|
||||
e.selectField(e.field + 1)
|
||||
return
|
||||
}
|
||||
key = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'\n'}}
|
||||
case "left", "ctrl+b":
|
||||
e.cursor = max(0, e.cursor-1)
|
||||
return
|
||||
case "right", "ctrl+f":
|
||||
e.cursor = min(len([]rune(e.fields[e.field])), e.cursor+1)
|
||||
return
|
||||
case "home", "ctrl+a":
|
||||
e.cursor = 0
|
||||
return
|
||||
case "end", "ctrl+e":
|
||||
e.cursor = len([]rune(e.fields[e.field]))
|
||||
return
|
||||
case "ctrl+u":
|
||||
e.fields[e.field], e.cursor, e.dirty = "", 0, true
|
||||
return
|
||||
case "backspace", "ctrl+h":
|
||||
if e.cursor > 0 {
|
||||
chars := []rune(e.fields[e.field])
|
||||
e.fields[e.field] = string(append(chars[:e.cursor-1], chars[e.cursor:]...))
|
||||
e.cursor--
|
||||
e.dirty = true
|
||||
}
|
||||
return
|
||||
case "delete":
|
||||
chars := []rune(e.fields[e.field])
|
||||
if e.cursor < len(chars) {
|
||||
e.fields[e.field] = string(append(chars[:e.cursor], chars[e.cursor+1:]...))
|
||||
e.dirty = true
|
||||
}
|
||||
return
|
||||
case " ":
|
||||
// Bubble Tea reports space as KeySpace, not KeyRunes.
|
||||
key = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}
|
||||
}
|
||||
if key.Type == tea.KeyRunes {
|
||||
chars := []rune(e.fields[e.field])
|
||||
added := key.Runes
|
||||
if e.field != 2 {
|
||||
for _, r := range added {
|
||||
if r == '\n' {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
e.fields[e.field] = string(append(append([]rune{}, chars[:e.cursor]...), append(added, chars[e.cursor:]...)...))
|
||||
e.cursor += len(added)
|
||||
e.dirty = true
|
||||
m.notice = ""
|
||||
}
|
||||
}
|
||||
|
||||
func (m *model) askStatusChange(key string) {
|
||||
selected, ok := m.selectedTodo()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
closed := selected.Status == "closed" || selected.Status == "done"
|
||||
if key == "o" && !closed {
|
||||
m.notice = "Todo is already open"
|
||||
return
|
||||
}
|
||||
if key == "x" && closed {
|
||||
m.notice = "Todo is already closed"
|
||||
return
|
||||
}
|
||||
if key == "o" {
|
||||
m.confirm = "reopen"
|
||||
} else {
|
||||
m.confirm = "close"
|
||||
}
|
||||
m.confirmID = selected.ID
|
||||
}
|
||||
|
||||
func (m *model) handleConfirm(key tea.KeyMsg) {
|
||||
switch key.String() {
|
||||
case "n", "esc":
|
||||
m.confirm = ""
|
||||
case "y":
|
||||
action, id := m.confirm, m.confirmID
|
||||
m.confirm = ""
|
||||
if action == "discard" {
|
||||
m.editor = nil
|
||||
m.notice = "Changes discarded"
|
||||
return
|
||||
}
|
||||
todo, err := todos.Read(m.todosDir, id)
|
||||
if err != nil {
|
||||
m.notice = err.Error()
|
||||
return
|
||||
}
|
||||
if action == "close" {
|
||||
todo.Status = "closed"
|
||||
} else {
|
||||
todo.Status = "open"
|
||||
}
|
||||
if err := todos.Update(m.todosDir, todo); err != nil {
|
||||
m.notice = err.Error()
|
||||
return
|
||||
}
|
||||
if action == "close" {
|
||||
m.notice = "Todo closed"
|
||||
} else {
|
||||
m.notice = "Todo reopened"
|
||||
}
|
||||
m.reload(id)
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) editorView() string {
|
||||
e := m.editor
|
||||
mode := "New todo"
|
||||
if !e.creating {
|
||||
mode = "Edit " + e.original.ID
|
||||
}
|
||||
var out strings.Builder
|
||||
fmt.Fprintf(&out, "TodoApp — %s\n\n", mode)
|
||||
for i, label := range [2]string{"Title", "Tags (comma-separated)"} {
|
||||
prefix := " "
|
||||
if i == e.field {
|
||||
prefix = "> "
|
||||
}
|
||||
value := e.fields[i]
|
||||
if i == e.field {
|
||||
chars := []rune(value)
|
||||
value = string(chars[:e.cursor]) + "▏" + string(chars[e.cursor:])
|
||||
}
|
||||
fmt.Fprintln(&out, runewidth.Truncate(prefix+label+": "+safe(value), max(1, m.width), "…"))
|
||||
}
|
||||
out.WriteString("\nBody (Markdown; Enter adds a line):\n")
|
||||
body := e.fields[2]
|
||||
if e.field == 2 {
|
||||
chars := []rune(body)
|
||||
body = string(chars[:e.cursor]) + "▏" + string(chars[e.cursor:])
|
||||
}
|
||||
lines := strings.Split(safe(body), "\n")
|
||||
page := max(1, m.height-11)
|
||||
cursorLine := 0
|
||||
if e.field == 2 {
|
||||
cursorLine = strings.Count(string([]rune(e.fields[2])[:e.cursor]), "\n")
|
||||
}
|
||||
start := max(0, cursorLine-page+1)
|
||||
if start+page > len(lines) {
|
||||
start = max(0, len(lines)-page)
|
||||
}
|
||||
for _, line := range lines[start:min(start+page, len(lines))] {
|
||||
fmt.Fprintln(&out, runewidth.Truncate(line, max(1, m.width), "…"))
|
||||
}
|
||||
if len(lines) > page {
|
||||
fmt.Fprintf(&out, "(%d-%d of %d body lines)\n", start+1, min(start+page, len(lines)), len(lines))
|
||||
}
|
||||
out.WriteString(noticeLine(m.notice))
|
||||
out.WriteString("\nTab/Shift+Tab fields • ←→ cursor • Ctrl+S save • Esc cancel\n")
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func (m model) confirmView() string {
|
||||
question := "Discard unsaved edits?"
|
||||
if m.confirm == "close" {
|
||||
question = "Close this todo?"
|
||||
} else if m.confirm == "reopen" {
|
||||
question = "Reopen this todo?"
|
||||
}
|
||||
return fmt.Sprintf("TodoApp\n\n%s\n\ny confirm • n/Esc cancel\n", question)
|
||||
}
|
||||
|
||||
func noticeLine(text string) string {
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
return "\n" + safe(text) + "\n"
|
||||
}
|
||||
@@ -2,7 +2,10 @@ module todoapp
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require github.com/charmbracelet/bubbletea v1.3.10
|
||||
require (
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/mattn/go-runewidth v0.0.16
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
@@ -15,7 +18,6 @@ require (
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
|
||||
+20
-5
@@ -114,16 +114,18 @@ func Read(dir, id string) (Todo, error) {
|
||||
return Parse(id, data)
|
||||
}
|
||||
|
||||
// List reads todos in filename order. Malformed files are reported, not overwritten.
|
||||
func List(dir string) ([]Todo, error) {
|
||||
// Scan reads valid todos in filename order and reports malformed files separately.
|
||||
// A missing directory is empty; other directory errors are fatal.
|
||||
func Scan(dir string) ([]Todo, []error, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []Todo{}, nil
|
||||
return []Todo{}, nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read todo directory: %w", err)
|
||||
return nil, nil, fmt.Errorf("read todo directory: %w", err)
|
||||
}
|
||||
result := make([]Todo, 0)
|
||||
var warnings []error
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
||||
continue
|
||||
@@ -134,10 +136,23 @@ func List(dir string) ([]Todo, error) {
|
||||
}
|
||||
todo, err := Read(dir, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
warnings = append(warnings, err)
|
||||
continue
|
||||
}
|
||||
result = append(result, todo)
|
||||
}
|
||||
return result, warnings, nil
|
||||
}
|
||||
|
||||
// List reports malformed files as errors rather than silently omitting them.
|
||||
func List(dir string) ([]Todo, error) {
|
||||
result, warnings, err := Scan(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(warnings) > 0 {
|
||||
return nil, errors.Join(warnings...)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -126,4 +126,8 @@ func TestMalformedAndUnsafeIDs(t *testing.T) {
|
||||
if _, err := List(dir); err == nil {
|
||||
t.Fatal("malformed file silently ignored")
|
||||
}
|
||||
items, warnings, err := Scan(dir)
|
||||
if err != nil || len(items) != 0 || len(warnings) != 1 {
|
||||
t.Fatalf("scan should report malformed file: %v, %v, %v", items, warnings, err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user