todo editor

This commit is contained in:
2026-11-04 16:57:28 +01:00
parent 21694908a3
commit e6ca4aef8f
8 changed files with 763 additions and 46 deletions
+2
View File
@@ -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
+12 -2
View File
@@ -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.
+220 -24
View File
@@ -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
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 {
state = fmt.Sprintf("Found %d todo file(s). Browsing is coming in the next ticket.", m.count)
out.WriteString("No matching todos.\n")
}
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)
} 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 {
fmt.Fprintf(&out, "\nSearch: %s (/ to edit, Esc clear)\n", safe(m.query))
}
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()
}
+187 -10
View File
@@ -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 {
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")
}
+311
View File
@@ -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"
}
+4 -2
View File
@@ -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
View File
@@ -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
}
+4
View File
@@ -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)
}
}