diff --git a/internal/config/config.go b/internal/config/config.go index 7134761..0fabc8d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -80,3 +80,11 @@ func (c *Config) SetActiveBoardLists(projectID, listIDs string) { } c.ProjectLists[projectID] = listIDs } + +// ClearProjectLists removes the persisted board filter for a project. +func (c *Config) ClearProjectLists(projectID string) { + if c.ProjectLists == nil { + return + } + delete(c.ProjectLists, projectID) +} diff --git a/internal/project/store.go b/internal/project/store.go index 43e6edc..79f075f 100644 --- a/internal/project/store.go +++ b/internal/project/store.go @@ -11,6 +11,7 @@ import ( type Store interface { List() ([]Project, error) Ensure(id string) error + Delete(id string) error } // DirStore discovers projects as subdirectories of dataDir/projects. @@ -63,3 +64,25 @@ func (s *DirStore) Ensure(id string) error { } return nil } + +// Delete removes the project directory and all of its files. +func (s *DirStore) Delete(id string) error { + if id == "" { + return fmt.Errorf("project id is required") + } + dir := filepath.Join(s.projectsRoot(), id) + st, err := os.Stat(dir) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("project %s: not found", id) + } + return fmt.Errorf("stat project %s: %w", id, err) + } + if !st.IsDir() { + return fmt.Errorf("project %s: not a directory", id) + } + if err := os.RemoveAll(dir); err != nil { + return fmt.Errorf("delete project %s: %w", id, err) + } + return nil +} diff --git a/internal/project/store_test.go b/internal/project/store_test.go new file mode 100644 index 0000000..4e061db --- /dev/null +++ b/internal/project/store_test.go @@ -0,0 +1,46 @@ +package project_test + +import ( + "os" + "path/filepath" + "testing" + + "task/internal/project" +) + +func TestDirStoreDelete(t *testing.T) { + dir := t.TempDir() + store := project.NewDirStore(dir) + + if err := store.Ensure("inbox"); err != nil { + t.Fatal(err) + } + if err := store.Ensure("client"); err != nil { + t.Fatal(err) + } + // Drop a marker file so RemoveAll has something to wipe. + marker := filepath.Join(dir, "projects", "client", "tickets.json") + if err := os.WriteFile(marker, []byte("[]\n"), 0o644); err != nil { + t.Fatal(err) + } + + list, err := store.List() + if err != nil || len(list) != 2 { + t.Fatalf("list before: %v len=%d", err, len(list)) + } + + if err := store.Delete("client"); err != nil { + t.Fatal(err) + } + list, err = store.List() + if err != nil || len(list) != 1 || list[0].ID != "inbox" { + t.Fatalf("list after: %v %+v", err, list) + } + if _, err := os.Stat(filepath.Join(dir, "projects", "client")); !os.IsNotExist(err) { + t.Fatalf("client dir still present: %v", err) + } + + if err := store.Delete("missing"); err == nil { + t.Fatal("expected error deleting missing project") + } +} diff --git a/internal/ticket/jsontimestore.go b/internal/ticket/jsontimestore.go index b7522ab..79949bb 100644 --- a/internal/ticket/jsontimestore.go +++ b/internal/ticket/jsontimestore.go @@ -153,6 +153,28 @@ func (s *JSONTimeStore) Delete(id string) error { return s.writeUnlocked(out) } +// DeleteByProject removes all time entries for a project. +func (s *JSONTimeStore) DeleteByProject(projectID string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if projectID == "" { + return fmt.Errorf("projectId is required") + } + entries, err := s.readUnlocked() + if err != nil { + return err + } + out := make([]TimeEntry, 0, len(entries)) + for _, e := range entries { + if e.ProjectID == projectID { + continue + } + out = append(out, e) + } + return s.writeUnlocked(out) +} + // SumByTicket returns total duration for a ticket. func (s *JSONTimeStore) SumByTicket(ticketID string) (time.Duration, error) { entries, err := s.ListByTicket(ticketID) diff --git a/internal/ticket/jsontimestore_test.go b/internal/ticket/jsontimestore_test.go index 04ed661..0e846b7 100644 --- a/internal/ticket/jsontimestore_test.go +++ b/internal/ticket/jsontimestore_test.go @@ -69,3 +69,25 @@ func TestJSONTimeStoreRoundTrip(t *testing.T) { t.Fatalf("after delete: %v len=%d path=%s", err, len(list), filepath.Join(dir, "time_entries.json")) } } + +func TestJSONTimeStoreDeleteByProject(t *testing.T) { + dir := t.TempDir() + store := ticket.NewJSONTimeStore(dir) + + for _, e := range []ticket.TimeEntry{ + {TicketID: "a", ProjectID: "p1", Duration: time.Hour, Source: ticket.SourceManual}, + {TicketID: "b", ProjectID: "p1", Duration: time.Minute, Source: ticket.SourceManual}, + {TicketID: "c", ProjectID: "p2", Duration: 30 * time.Minute, Source: ticket.SourceManual}, + } { + if _, err := store.Create(e); err != nil { + t.Fatal(err) + } + } + if err := store.DeleteByProject("p1"); err != nil { + t.Fatal(err) + } + list, err := store.List() + if err != nil || len(list) != 1 || list[0].ProjectID != "p2" { + t.Fatalf("after DeleteByProject: %v %+v", err, list) + } +} diff --git a/internal/ticket/timestore.go b/internal/ticket/timestore.go index 64e8789..38418ba 100644 --- a/internal/ticket/timestore.go +++ b/internal/ticket/timestore.go @@ -9,6 +9,7 @@ type TimeStore interface { Create(e TimeEntry) (TimeEntry, error) Update(e TimeEntry) error Delete(id string) error + DeleteByProject(projectID string) error SumByTicket(ticketID string) (time.Duration, error) LoadActiveTimer() (*ActiveTimer, error) diff --git a/internal/ui/app.go b/internal/ui/app.go index 6ce5e28..3299add 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -57,32 +57,33 @@ type Model struct { projects project.Store exec command.Executor - mode Mode - tickets views.TicketList - projectList list.Model - statusMenu views.StatusMenu - boardMenu views.BoardMenu - boardFilter ticket.BoardFilter - input textinput.Model - report views.ReportView - reportGroupBy ticket.GroupBy - groupByMenu views.GroupByMenu - timeEntries views.TimeEntriesView - timeEntriesTicket string // ticket ID for ModeTimeEntries - editingTimeEntry ticket.TimeEntry - activeTimer *ticket.ActiveTimer - spentByID map[string]time.Duration - width int - height int - status string - statusIsError bool - filter string - keys KeyMap - styles Styles - version string - allTickets []ticket.Ticket - creatingProject bool - pendingDeleteTitle string + mode Mode + tickets views.TicketList + projectList list.Model + statusMenu views.StatusMenu + boardMenu views.BoardMenu + boardFilter ticket.BoardFilter + input textinput.Model + report views.ReportView + reportGroupBy ticket.GroupBy + groupByMenu views.GroupByMenu + timeEntries views.TimeEntriesView + timeEntriesTicket string // ticket ID for ModeTimeEntries + editingTimeEntry ticket.TimeEntry + activeTimer *ticket.ActiveTimer + spentByID map[string]time.Duration + width int + height int + status string + statusIsError bool + filter string + keys KeyMap + styles Styles + version string + allTickets []ticket.Ticket + creatingProject bool + pendingDeleteTitle string + pendingDeleteProject string // non-empty → confirm deletes a project, not a ticket } // New creates the root UI model. @@ -449,12 +450,31 @@ func (m Model) updateBoard(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) updateConfirmDelete(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, m.keys.Escape): + wasProject := m.pendingDeleteProject != "" m.pendingDeleteTitle = "" - m.mode = ModeList + m.pendingDeleteProject = "" m.clearStatus() + if wasProject { + m.mode = ModeProjects + } else { + m.mode = ModeList + } return m, nil case key.Matches(msg, m.keys.Enter), key.Matches(msg, m.keys.ConfirmYes): + if m.pendingDeleteProject != "" { + id := m.pendingDeleteProject + m.pendingDeleteTitle = "" + m.pendingDeleteProject = "" + if err := m.deleteProject(id); err != nil { + m.setError(err) + m.mode = ModeProjects + return m, nil + } + m.mode = ModeList + m.setStatus(fmt.Sprintf("deleted project %s", id)) + return m, nil + } m.pendingDeleteTitle = "" m.mode = ModeList if err := m.deleteSelected(); err != nil { @@ -712,6 +732,38 @@ func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.input.Focus() return m, textinput.Blink + case key.Matches(msg, m.keys.DeleteForce): + m.clearStatus() + item, ok := m.projectList.SelectedItem().(views.ProjectItem) + if !ok { + m.setError(fmt.Errorf("no project selected")) + return m, nil + } + if err := m.deleteProject(item.Project.ID); err != nil { + m.setError(err) + return m, nil + } + m.mode = ModeList + m.setStatus(fmt.Sprintf("deleted project %s", item.Project.ID)) + return m, nil + + case key.Matches(msg, m.keys.Delete): + m.clearStatus() + item, ok := m.projectList.SelectedItem().(views.ProjectItem) + if !ok { + m.setError(fmt.Errorf("no project selected")) + return m, nil + } + n, err := m.countTickets(item.Project.ID) + if err != nil { + m.setError(err) + return m, nil + } + m.pendingDeleteProject = item.Project.ID + m.pendingDeleteTitle = fmt.Sprintf("project %q (%d tickets)", item.Project.ID, n) + m.mode = ModeConfirmDelete + return m, nil + case key.Matches(msg, m.keys.Enter): item, ok := m.projectList.SelectedItem().(views.ProjectItem) if !ok { @@ -942,6 +994,80 @@ func (m *Model) switchProject(id string) error { return m.reloadTickets() } +func (m *Model) countTickets(projectID string) (int, error) { + tickets, err := m.store.List(projectID) + if err != nil { + return 0, err + } + return len(tickets), nil +} + +func (m *Model) deleteProject(id string) error { + if id == "" { + return fmt.Errorf("no project selected") + } + all, err := m.projects.List() + if err != nil { + return err + } + if len(all) <= 1 { + return fmt.Errorf("cannot delete the last project") + } + + wasActive := m.cfg.ActiveProject == id + + // Clear running timer if it belongs to this project. + if m.activeTimer != nil && m.activeTimer.ProjectID == id && m.timeStore != nil { + if err := m.timeStore.SaveActiveTimer(nil); err != nil { + return err + } + m.activeTimer = nil + } + + if m.timeStore != nil { + if err := m.timeStore.DeleteByProject(id); err != nil { + return err + } + } + if err := m.projects.Delete(id); err != nil { + return err + } + + m.cfg.ClearProjectLists(id) + if wasActive { + next := pickFallbackProject(all, id) + m.cfg.ActiveProject = next + m.boardFilter = ticket.ParseBoardLists(m.cfg.ActiveBoardLists(next)) + m.filter = "" + } + if err := config.Save(m.cfg); err != nil { + return err + } + if err := m.openProjects(); err != nil { + return err + } + return m.reloadTickets() +} + +func pickFallbackProject(all []project.Project, deletedID string) string { + var rest []string + for _, p := range all { + if p.ID == deletedID { + continue + } + rest = append(rest, p.ID) + } + for _, id := range rest { + if id == config.DefaultProject { + return id + } + } + if len(rest) > 0 { + return rest[0] + } + return config.DefaultProject +} + func (m *Model) openProjects() error { projects, err := m.projects.List() if err != nil { diff --git a/internal/ui/helpbar.go b/internal/ui/helpbar.go index 86a2b2e..acfeb71 100644 --- a/internal/ui/helpbar.go +++ b/internal/ui/helpbar.go @@ -33,6 +33,8 @@ func (m Model) helpEntries() []helpEntry { {"a-z", "jump"}, {"enter", "select"}, {"n", "new project"}, + {"x", "delete"}, + {"X", "force delete"}, {"esc", "cancel"}, } case ModeStatus: diff --git a/internal/ui/layout.go b/internal/ui/layout.go index 687496b..bd915b2 100644 --- a/internal/ui/layout.go +++ b/internal/ui/layout.go @@ -91,9 +91,18 @@ func (m Model) renderBody(height int) string { case ModeConfirmDelete: title := m.pendingDeleteTitle if title == "" { - title = "this ticket" + if m.pendingDeleteProject != "" { + title = "this project" + } else { + title = "this ticket" + } + } + var prompt string + if m.pendingDeleteProject != "" { + prompt = m.styles.ErrorMsg.Render(fmt.Sprintf("Delete %s?", title)) + } else { + prompt = m.styles.ErrorMsg.Render(fmt.Sprintf("Delete %q?", title)) } - prompt := m.styles.ErrorMsg.Render(fmt.Sprintf("Delete %q?", title)) hint := m.styles.StatusMsg.Render("y/enter confirm · esc cancel") content := prompt + "\n" + hint return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)