From e6ca4aef8f5891238563e5a2a6513f27efd1e8d6 Mon Sep 17 00:00:00 2001 From: Gyuri Horak Date: Wed, 4 Nov 2026 16:40:52 +0100 Subject: [PATCH] todo editor --- .pi/todos/f2ddb00d.md | 2 + README.md | 14 +- app.go | 248 +++++++++++++++++++++++++--- app_test.go | 199 ++++++++++++++++++++-- editor.go | 311 +++++++++++++++++++++++++++++++++++ go.mod | 6 +- internal/todos/store.go | 25 ++- internal/todos/store_test.go | 4 + 8 files changed, 763 insertions(+), 46 deletions(-) create mode 100644 editor.go diff --git a/.pi/todos/f2ddb00d.md b/.pi/todos/f2ddb00d.md index 455f821..053cddd 100644 --- a/.pi/todos/f2ddb00d.md +++ b/.pi/todos/f2ddb00d.md @@ -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 diff --git a/README.md b/README.md index 71f3508..540e539 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app.go b/app.go index 63ad188..a550f3d 100644 --- a/app.go +++ b/app.go @@ -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() } diff --git a/app_test.go b/app_test.go index 461167b..77ea463 100644 --- a/app_test.go +++ b/app_test.go @@ -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") } diff --git a/editor.go b/editor.go new file mode 100644 index 0000000..490724f --- /dev/null +++ b/editor.go @@ -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" +} diff --git a/go.mod b/go.mod index 1885efb..e386cf4 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/internal/todos/store.go b/internal/todos/store.go index b3ca690..677c002 100644 --- a/internal/todos/store.go +++ b/internal/todos/store.go @@ -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 } diff --git a/internal/todos/store_test.go b/internal/todos/store_test.go index 93137fa..e8fdad7 100644 --- a/internal/todos/store_test.go +++ b/internal/todos/store_test.go @@ -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) + } }