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 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 { type Store interface {
List() ([]Project, error) List() ([]Project, error)
Ensure(id string) error Ensure(id string) error
Delete(id string) error
} }
// DirStore discovers projects as subdirectories of dataDir/projects. // DirStore discovers projects as subdirectories of dataDir/projects.
@@ -63,3 +64,25 @@ func (s *DirStore) Ensure(id string) error {
} }
return nil 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) 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. // SumByTicket returns total duration for a ticket.
func (s *JSONTimeStore) SumByTicket(ticketID string) (time.Duration, error) { func (s *JSONTimeStore) SumByTicket(ticketID string) (time.Duration, error) {
entries, err := s.ListByTicket(ticketID) 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")) 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) Create(e TimeEntry) (TimeEntry, error)
Update(e TimeEntry) error Update(e TimeEntry) error
Delete(id string) error Delete(id string) error
DeleteByProject(projectID string) error
SumByTicket(ticketID string) (time.Duration, error) SumByTicket(ticketID string) (time.Duration, error)
LoadActiveTimer() (*ActiveTimer, error) LoadActiveTimer() (*ActiveTimer, error)
+153 -27
View File
@@ -57,32 +57,33 @@ type Model struct {
projects project.Store projects project.Store
exec command.Executor exec command.Executor
mode Mode mode Mode
tickets views.TicketList tickets views.TicketList
projectList list.Model projectList list.Model
statusMenu views.StatusMenu statusMenu views.StatusMenu
boardMenu views.BoardMenu boardMenu views.BoardMenu
boardFilter ticket.BoardFilter boardFilter ticket.BoardFilter
input textinput.Model input textinput.Model
report views.ReportView report views.ReportView
reportGroupBy ticket.GroupBy reportGroupBy ticket.GroupBy
groupByMenu views.GroupByMenu groupByMenu views.GroupByMenu
timeEntries views.TimeEntriesView timeEntries views.TimeEntriesView
timeEntriesTicket string // ticket ID for ModeTimeEntries timeEntriesTicket string // ticket ID for ModeTimeEntries
editingTimeEntry ticket.TimeEntry editingTimeEntry ticket.TimeEntry
activeTimer *ticket.ActiveTimer activeTimer *ticket.ActiveTimer
spentByID map[string]time.Duration spentByID map[string]time.Duration
width int width int
height int height int
status string status string
statusIsError bool statusIsError bool
filter string filter string
keys KeyMap keys KeyMap
styles Styles styles Styles
version string version string
allTickets []ticket.Ticket allTickets []ticket.Ticket
creatingProject bool creatingProject bool
pendingDeleteTitle string pendingDeleteTitle string
pendingDeleteProject string // non-empty → confirm deletes a project, not a ticket
} }
// New creates the root UI model. // 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) { func (m Model) updateConfirmDelete(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch { switch {
case key.Matches(msg, m.keys.Escape): case key.Matches(msg, m.keys.Escape):
wasProject := m.pendingDeleteProject != ""
m.pendingDeleteTitle = "" m.pendingDeleteTitle = ""
m.mode = ModeList m.pendingDeleteProject = ""
m.clearStatus() m.clearStatus()
if wasProject {
m.mode = ModeProjects
} else {
m.mode = ModeList
}
return m, nil return m, nil
case key.Matches(msg, m.keys.Enter), key.Matches(msg, m.keys.ConfirmYes): 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.pendingDeleteTitle = ""
m.mode = ModeList m.mode = ModeList
if err := m.deleteSelected(); err != nil { if err := m.deleteSelected(); err != nil {
@@ -712,6 +732,38 @@ func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.input.Focus() m.input.Focus()
return m, textinput.Blink 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): case key.Matches(msg, m.keys.Enter):
item, ok := m.projectList.SelectedItem().(views.ProjectItem) item, ok := m.projectList.SelectedItem().(views.ProjectItem)
if !ok { if !ok {
@@ -942,6 +994,80 @@ func (m *Model) switchProject(id string) error {
return m.reloadTickets() 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 { func (m *Model) openProjects() error {
projects, err := m.projects.List() projects, err := m.projects.List()
if err != nil { if err != nil {
+2
View File
@@ -33,6 +33,8 @@ func (m Model) helpEntries() []helpEntry {
{"a-z", "jump"}, {"a-z", "jump"},
{"enter", "select"}, {"enter", "select"},
{"n", "new project"}, {"n", "new project"},
{"x", "delete"},
{"X", "force delete"},
{"esc", "cancel"}, {"esc", "cancel"},
} }
case ModeStatus: case ModeStatus:
+11 -2
View File
@@ -91,9 +91,18 @@ func (m Model) renderBody(height int) string {
case ModeConfirmDelete: case ModeConfirmDelete:
title := m.pendingDeleteTitle title := m.pendingDeleteTitle
if title == "" { 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") hint := m.styles.StatusMsg.Render("y/enter confirm · esc cancel")
content := prompt + "\n" + hint content := prompt + "\n" + hint
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content) return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)