Allow deleting projects from the project picker with x/X.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Skruecken
2026-10-19 13:59:20 +02:00
co-authored by Cursor
parent f3a592f3ea
commit c5920ffd54
9 changed files with 288 additions and 29 deletions
+8
View File
@@ -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)
}
+23
View File
@@ -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
}
+46
View File
@@ -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")
}
}
+22
View File
@@ -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)
+22
View File
@@ -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)
}
}
+1
View File
@@ -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)
+153 -27
View File
@@ -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 {
+2
View File
@@ -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:
+11 -2
View File
@@ -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)