diff --git a/cmd/add.go b/cmd/add.go index 82bff3f..c82a07c 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -59,7 +59,7 @@ func runAdd(cmd *cobra.Command, args []string) error { } } - id, err := s.NextID() + id, err := store.NewID() if err != nil { return err } @@ -72,12 +72,7 @@ func runAdd(cmd *cobra.Command, args []string) error { CreatedAt: time.Now().UTC(), } - tickets, err := s.ReadTickets() - if err != nil { - return err - } - tickets = append(tickets, ticket) - if err := s.WriteTickets(tickets); err != nil { + if err := s.WriteTicket(ticket); err != nil { return err } @@ -101,7 +96,7 @@ func runAdd(cmd *cobra.Command, args []string) error { } } - fmt.Printf("#%d %s\n", id, title) + fmt.Printf("#%s %s\n", id, title) return nil } diff --git a/cmd/delete.go b/cmd/delete.go index 07e73ca..6697a28 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "os" - "strconv" "github.com/frankcruz/tasklin/internal/store" "github.com/spf13/cobra" @@ -17,10 +16,7 @@ var deleteCmd = &cobra.Command{ } func runDelete(cmd *cobra.Command, args []string) error { - ticketID, err := strconv.Atoi(args[0]) - if err != nil { - return fmt.Errorf("invalid ticket id: %s", args[0]) - } + ticketID := args[0] cwd, err := os.Getwd() if err != nil { @@ -44,26 +40,19 @@ func runDelete(cmd *cobra.Command, args []string) error { } } if idx == -1 { - return fmt.Errorf("ticket %d not found", ticketID) - } - - deleted, err := s.ReadDeleted() - if err != nil { - return err + return fmt.Errorf("ticket %s not found", ticketID) } title := tickets[idx].Title - deleted = append(deleted, tickets[idx]) - tickets = append(tickets[:idx], tickets[idx+1:]...) - if err := s.WriteTickets(tickets); err != nil { + if err := s.WriteDeletedTicket(tickets[idx]); err != nil { return err } - if err := s.WriteDeleted(deleted); err != nil { + if err := s.DeleteTicketFile(ticketID); err != nil { return err } - fmt.Printf("#%d %s deleted\n", ticketID, title) + fmt.Printf("#%s %s deleted\n", ticketID, title) return nil } diff --git a/cmd/move.go b/cmd/move.go index 00a0899..64506a2 100644 --- a/cmd/move.go +++ b/cmd/move.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "os" - "strconv" "strings" "time" @@ -20,10 +19,7 @@ var moveCmd = &cobra.Command{ } func runMove(cmd *cobra.Command, args []string) error { - ticketID, err := strconv.Atoi(args[0]) - if err != nil { - return fmt.Errorf("invalid ticket id: %s", args[0]) - } + ticketID := args[0] targetStatus := args[1] cwd, err := os.Getwd() @@ -68,17 +64,16 @@ func runMove(cmd *cobra.Command, args []string) error { tr := model.Transition{From: t.Status, To: resolved, At: time.Now().UTC()} tickets[i].Status = resolved tickets[i].Transitions = append(tickets[i].Transitions, tr) + if err := s.WriteTicket(tickets[i]); err != nil { + return err + } break } if !found { - return fmt.Errorf("ticket %d not found", ticketID) - } - - if err := s.WriteTickets(tickets); err != nil { - return err + return fmt.Errorf("ticket %s not found", ticketID) } - fmt.Printf("#%d → %s\n", ticketID, resolved) + fmt.Printf("#%s → %s\n", ticketID, resolved) return nil } diff --git a/cmd/show.go b/cmd/show.go index a4981de..6b0618c 100644 --- a/cmd/show.go +++ b/cmd/show.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "os" - "strconv" "strings" "github.com/charmbracelet/lipgloss" @@ -53,10 +52,7 @@ func statusColor(name string) lipgloss.Color { } func runShow(cmd *cobra.Command, args []string) error { - ticketID, err := strconv.Atoi(args[0]) - if err != nil { - return fmt.Errorf("invalid ticket id: %s", args[0]) - } + ticketID := args[0] cwd, err := os.Getwd() if err != nil { @@ -85,11 +81,10 @@ func runShow(cmd *cobra.Command, args []string) error { } } if idx == -1 { - return fmt.Errorf("ticket %d not found", ticketID) + return fmt.Errorf("ticket %s not found", ticketID) } t := tickets[idx] - // Resolve status color from config. dotColor := lipgloss.Color("252") for _, st := range cfg.Statuses { if st.Name == t.Status { @@ -102,7 +97,7 @@ func runShow(cmd *cobra.Command, args []string) error { fmt.Println() fmt.Printf(" %s %s\n", - showIDStyle.Render(fmt.Sprintf("#%d", t.ID)), + showIDStyle.Render(fmt.Sprintf("#%s", t.ID)), showTitleStyle.Render(t.Title), ) fmt.Println(" " + showSepStyle.Render(sep)) diff --git a/cmd/update.go b/cmd/update.go index 83f04ed..75ac3cc 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "os" - "strconv" "strings" "github.com/frankcruz/tasklin/internal/store" @@ -22,10 +21,7 @@ var updateCmd = &cobra.Command{ } func runUpdate(cmd *cobra.Command, args []string) error { - ticketID, err := strconv.Atoi(args[0]) - if err != nil { - return fmt.Errorf("invalid ticket id: %s", args[0]) - } + ticketID := args[0] if !cmd.Flags().Changed("title") && !cmd.Flags().Changed("add-label") && !cmd.Flags().Changed("remove-label") { return fmt.Errorf("nothing to update: specify --title, --add-label, or --remove-label") @@ -53,7 +49,7 @@ func runUpdate(cmd *cobra.Command, args []string) error { } } if idx == -1 { - return fmt.Errorf("ticket %d not found", ticketID) + return fmt.Errorf("ticket %s not found", ticketID) } t := &tickets[idx] @@ -109,11 +105,11 @@ func runUpdate(cmd *cobra.Command, args []string) error { } if len(changes) == 0 { - fmt.Printf("#%d no changes\n", ticketID) + fmt.Printf("#%s no changes\n", ticketID) return nil } - if err := s.WriteTickets(tickets); err != nil { + if err := s.WriteTicket(*t); err != nil { return err } @@ -137,7 +133,7 @@ func runUpdate(cmd *cobra.Command, args []string) error { } } - fmt.Printf("#%d %s\n", t.ID, t.Title) + fmt.Printf("#%s %s\n", t.ID, t.Title) for _, c := range changes { fmt.Println(c) } diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 9a4b180..7e23501 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "strconv" "time" "github.com/frankcruz/tasklin/internal/model" @@ -49,8 +48,12 @@ func (s *Store) migrateTickets() (bool, error) { return false, err } for _, lt := range ltf.Tickets { + id, err := NewID() + if err != nil { + return false, fmt.Errorf("migrate tickets: generate id: %w", err) + } t := model.Ticket{ - ID: strconv.Itoa(lt.ID), + ID: id, Title: lt.Title, Status: lt.Status, Labels: lt.Labels, @@ -78,8 +81,12 @@ func (s *Store) migrateDeleted() (bool, error) { return false, err } for _, lt := range ltf.Tickets { + id, err := NewID() + if err != nil { + return false, fmt.Errorf("migrate deleted: generate id: %w", err) + } t := model.Ticket{ - ID: strconv.Itoa(lt.ID), + ID: id, Title: lt.Title, Status: lt.Status, Labels: lt.Labels, diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 5e76da7..57f9e3d 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -149,20 +149,102 @@ func TestReadTickets_EmptyDir(t *testing.T) { func TestNewID(t *testing.T) { hexRe := regexp.MustCompile(`^[0-9a-f]{8}$`) - id, err := store.NewID() + seen := make(map[string]bool, 200) + for i := 0; i < 200; i++ { + id, err := store.NewID() + if err != nil { + t.Fatalf("NewID call %d: %v", i, err) + } + if !hexRe.MatchString(id) { + t.Errorf("NewID returned %q, want 8 lowercase hex chars", id) + } + if seen[id] { + t.Errorf("NewID produced duplicate id %q after %d calls", id, i) + } + seen[id] = true + } +} + +func TestWriteReadDeletedTicket(t *testing.T) { + s := newTempStore(t) + if err := s.Init(model.DefaultConfig()); err != nil { + t.Fatal(err) + } + tk := model.Ticket{ID: "dead0001", Title: "archived task", Status: "Done", CreatedAt: time.Now().UTC()} + if err := s.WriteDeletedTicket(tk); err != nil { + t.Fatalf("WriteDeletedTicket: %v", err) + } + got, err := s.ReadDeleted() + if err != nil { + t.Fatalf("ReadDeleted: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 deleted ticket, got %d", len(got)) + } + if got[0].ID != tk.ID { + t.Errorf("ID mismatch: want %q, got %q", tk.ID, got[0].ID) + } + if got[0].Title != tk.Title { + t.Errorf("Title mismatch: want %q, got %q", tk.Title, got[0].Title) + } +} + +func TestMigrateIfNeeded_Tickets(t *testing.T) { + dir := t.TempDir() + s := store.New(dir) + if err := s.Init(model.DefaultConfig()); err != nil { + t.Fatal(err) + } + + // Write a legacy tickets.yaml with integer-ID tickets. + legacyYAML := `tickets: + - id: 1 + title: First + status: To Do + created_at: 2024-01-01T00:00:00Z + - id: 2 + title: Second + status: In Progress + created_at: 2024-01-02T00:00:00Z +` + if err := os.WriteFile(filepath.Join(s.TodoPath(), "tickets.yaml"), []byte(legacyYAML), 0644); err != nil { + t.Fatal(err) + } + + migrated, err := s.MigrateIfNeeded() if err != nil { - t.Fatalf("NewID: %v", err) + t.Fatalf("MigrateIfNeeded: %v", err) } - if !hexRe.MatchString(id) { - t.Errorf("NewID returned %q, want 8 lowercase hex chars", id) + if !migrated { + t.Error("expected migration to have occurred") } - // Two calls should produce different IDs. - id2, err := store.NewID() + + tickets, err := s.ReadTickets() if err != nil { - t.Fatalf("NewID (second call): %v", err) + t.Fatalf("ReadTickets after migration: %v", err) + } + if len(tickets) != 2 { + t.Fatalf("expected 2 tickets after migration, got %d", len(tickets)) + } + + hexRe := regexp.MustCompile(`^[0-9a-f]{8}$`) + titles := map[string]bool{} + for _, tk := range tickets { + if !hexRe.MatchString(tk.ID) { + t.Errorf("migrated ticket has non-hex ID %q; git hooks require 8-char hex IDs", tk.ID) + } + titles[tk.Title] = true + } + if !titles["First"] || !titles["Second"] { + t.Error("migrated tickets are missing expected titles") + } + + // Legacy file must have been renamed to .bak. + if _, err := os.Stat(filepath.Join(s.TodoPath(), "tickets.yaml")); !os.IsNotExist(err) { + t.Error("expected tickets.yaml to be removed after migration") } - if id == id2 { - t.Error("two NewID calls returned the same value") + if _, err := os.Stat(filepath.Join(s.TodoPath(), "tickets.yaml.bak")); err != nil { + t.Error("expected tickets.yaml.bak to exist after migration") } } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 31e0518..6dc1f14 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -399,6 +399,12 @@ func (m Model) handleStatuses(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.mode == viewStatusEdit { switch msg.String() { case "esc": + // If we already applied the name change (step 1, existing status), + // tickets on disk are updated but config.yaml hasn't been written yet. + // Persist it now so disk stays consistent with in-memory state. + if !m.statusEditNew && m.statusEditStep == 1 { + _ = m.store.WriteConfig(m.cfg) + } m.mode = viewStatuses m.inputBuf = "" m.inputCursor = 0 @@ -652,6 +658,7 @@ func (m Model) handleMove(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.moveSelected(targetStatus) m.mode = viewBoard m.rowIdx = 0 + m.clampScroll() return m, m.scheduleCommit(ticket, targetStatus) } return m, nil @@ -909,6 +916,7 @@ func (m *Model) deleteSelected() { if m.rowIdx > 0 { m.rowIdx-- } + m.clampScroll() } func (m *Model) addLabelToTicket(ticketID string, label string) { @@ -1846,7 +1854,7 @@ func (m Model) viewHelpOverlay() string { type sc struct{ keys, desc string } shortcuts := []sc{ - {"← → / h l", "move between columns"}, + {"← → / h", "move between columns"}, {"↑ ↓ / k j", "move between tickets"}, {"Shift+← →", "move ticket to adjacent column"}, {"Enter", "view ticket detail"}, @@ -2141,20 +2149,6 @@ func max(a, b int) int { return b } -// filepath shim — avoid full import just for Base. -var filepath = pathHelper{} - -type pathHelper struct{} - -func (pathHelper) Base(p string) string { - // simple last-segment extraction - p = strings.TrimRight(p, "/") - if idx := strings.LastIndex(p, "/"); idx >= 0 { - return p[idx+1:] - } - return p -} - // ColIdx returns the currently focused column index (exported for testing). func (m Model) ColIdx() int { return m.colIdx } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 1ab2801..9ef94e4 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -45,7 +45,6 @@ func sendKey(m tui.Model, key string) tui.Model { case "left": msg = tea.KeyMsg{Type: tea.KeyLeft} case "shift+right": - msg = tea.KeyMsg{Type: tea.KeyRight, Alt: false, Runes: nil} msg = tea.KeyMsg{Type: tea.KeyShiftRight} case "shift+left": msg = tea.KeyMsg{Type: tea.KeyShiftLeft}