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
|
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 {
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user