Allow deleting projects from the project picker with x/X.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+127
-1
@@ -83,6 +83,7 @@ type Model struct {
|
||||
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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
+10
-1
@@ -91,9 +91,18 @@ func (m Model) renderBody(height int) string {
|
||||
case ModeConfirmDelete:
|
||||
title := m.pendingDeleteTitle
|
||||
if title == "" {
|
||||
if m.pendingDeleteProject != "" {
|
||||
title = "this project"
|
||||
} else {
|
||||
title = "this ticket"
|
||||
}
|
||||
prompt := m.styles.ErrorMsg.Render(fmt.Sprintf("Delete %q?", title))
|
||||
}
|
||||
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))
|
||||
}
|
||||
hint := m.styles.StatusMsg.Render("y/enter confirm · esc cancel")
|
||||
content := prompt + "\n" + hint
|
||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
|
||||
|
||||
Reference in New Issue
Block a user