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
+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)