Allow moving tickets between projects with P.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
package command_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"task/internal/command"
|
||||||
|
"task/internal/ticket"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMoveTicketUndo(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := ticket.NewJSONStore(dir)
|
||||||
|
timeStore := ticket.NewJSONTimeStore(dir)
|
||||||
|
h := command.NewHistory()
|
||||||
|
|
||||||
|
created, err := store.Create(ticket.Ticket{
|
||||||
|
Title: "Misfiled", ProjectID: "inbox", Status: ticket.StatusTodo,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := timeStore.Create(ticket.TimeEntry{
|
||||||
|
TicketID: created.ID, ProjectID: "inbox", Duration: 15 * time.Minute, Source: ticket.SourceManual,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
after := created
|
||||||
|
after.ProjectID = "client"
|
||||||
|
if err := h.Do(&command.MoveTicket{
|
||||||
|
Store: store, TimeStore: timeStore, Before: created, After: after,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, _ := store.List("client")
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("client len=%d", len(list))
|
||||||
|
}
|
||||||
|
entries, _ := timeStore.ListByTicket(created.ID)
|
||||||
|
if len(entries) != 1 || entries[0].ProjectID != "client" {
|
||||||
|
t.Fatalf("time entries: %+v", entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.Undo(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, _ = store.List("inbox")
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("inbox after undo len=%d", len(list))
|
||||||
|
}
|
||||||
|
entries, _ = timeStore.ListByTicket(created.ID)
|
||||||
|
if len(entries) != 1 || entries[0].ProjectID != "inbox" {
|
||||||
|
t.Fatalf("time after undo: %+v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ const (
|
|||||||
typeAddTime = "add_time"
|
typeAddTime = "add_time"
|
||||||
typeRemoveTime = "remove_time"
|
typeRemoveTime = "remove_time"
|
||||||
typeUpdateTime = "update_time"
|
typeUpdateTime = "update_time"
|
||||||
|
typeMove = "move"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HistoryPath returns DataDir/history.json.
|
// HistoryPath returns DataDir/history.json.
|
||||||
@@ -73,6 +74,9 @@ func encodeCmd(cmd Command) (persistedCmd, error) {
|
|||||||
case *UpdateTimeEntry:
|
case *UpdateTimeEntry:
|
||||||
b, a := c.Before, c.After
|
b, a := c.Before, c.After
|
||||||
return persistedCmd{Type: typeUpdateTime, TimeBefore: &b, TimeAfter: &a}, nil
|
return persistedCmd{Type: typeUpdateTime, TimeBefore: &b, TimeAfter: &a}, nil
|
||||||
|
case *MoveTicket:
|
||||||
|
b, a := c.Before, c.After
|
||||||
|
return persistedCmd{Type: typeMove, Before: &b, After: &a}, nil
|
||||||
default:
|
default:
|
||||||
return persistedCmd{}, fmt.Errorf("unsupported command type %T", cmd)
|
return persistedCmd{}, fmt.Errorf("unsupported command type %T", cmd)
|
||||||
}
|
}
|
||||||
@@ -119,6 +123,11 @@ func decodeCmd(rec persistedCmd, store ticket.Store, timeStore ticket.TimeStore)
|
|||||||
return nil, fmt.Errorf("update_time: time store not configured")
|
return nil, fmt.Errorf("update_time: time store not configured")
|
||||||
}
|
}
|
||||||
return &UpdateTimeEntry{Store: timeStore, Before: *rec.TimeBefore, After: *rec.TimeAfter}, nil
|
return &UpdateTimeEntry{Store: timeStore, Before: *rec.TimeBefore, After: *rec.TimeAfter}, nil
|
||||||
|
case typeMove:
|
||||||
|
if rec.Before == nil || rec.After == nil {
|
||||||
|
return nil, fmt.Errorf("move record missing before/after")
|
||||||
|
}
|
||||||
|
return &MoveTicket{Store: store, TimeStore: timeStore, Before: *rec.Before, After: *rec.After}, nil
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown command type %q", rec.Type)
|
return nil, fmt.Errorf("unknown command type %q", rec.Type)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,3 +79,43 @@ func (c *UpdateTicket) Undo(ctx context.Context) error {
|
|||||||
_ = ctx
|
_ = ctx
|
||||||
return c.Store.Update(c.Before)
|
return c.Store.Update(c.Before)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MoveTicket relocates a ticket to another project; Undo moves it back.
|
||||||
|
type MoveTicket struct {
|
||||||
|
Store ticket.Store
|
||||||
|
TimeStore ticket.TimeStore
|
||||||
|
Before ticket.Ticket
|
||||||
|
After ticket.Ticket // ProjectID is the destination; other fields from Before
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MoveTicket) Name() string { return "move ticket" }
|
||||||
|
|
||||||
|
func (c *MoveTicket) Execute(ctx context.Context) error {
|
||||||
|
_ = ctx
|
||||||
|
moved, err := c.Store.Move(c.Before.ID, c.After.ProjectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.After = moved
|
||||||
|
if c.TimeStore != nil {
|
||||||
|
if err := c.TimeStore.RetargetTicket(moved.ID, moved.ProjectID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MoveTicket) Undo(ctx context.Context) error {
|
||||||
|
_ = ctx
|
||||||
|
moved, err := c.Store.Move(c.After.ID, c.Before.ProjectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.Before = moved
|
||||||
|
if c.TimeStore != nil {
|
||||||
|
if err := c.TimeStore.RetargetTicket(moved.ID, moved.ProjectID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -146,6 +146,73 @@ func (s *JSONStore) Update(t Ticket) error {
|
|||||||
return s.writeUnlocked(t.ProjectID, tickets)
|
return s.writeUnlocked(t.ProjectID, tickets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move relocates a ticket to another project directory.
|
||||||
|
func (s *JSONStore) Move(id, toProjectID string) (Ticket, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if id == "" {
|
||||||
|
return Ticket{}, fmt.Errorf("ticket id is required")
|
||||||
|
}
|
||||||
|
if toProjectID == "" {
|
||||||
|
return Ticket{}, fmt.Errorf("projectId is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
projects, err := os.ReadDir(filepath.Join(s.dataDir, "projects"))
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return Ticket{}, fmt.Errorf("ticket %s: not found", id)
|
||||||
|
}
|
||||||
|
return Ticket{}, fmt.Errorf("list projects: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var found Ticket
|
||||||
|
fromProject := ""
|
||||||
|
for _, p := range projects {
|
||||||
|
if !p.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tickets, err := s.readUnlocked(p.Name())
|
||||||
|
if err != nil {
|
||||||
|
return Ticket{}, err
|
||||||
|
}
|
||||||
|
for i, t := range tickets {
|
||||||
|
if t.ID != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = t
|
||||||
|
fromProject = p.Name()
|
||||||
|
tickets = append(tickets[:i], tickets[i+1:]...)
|
||||||
|
if err := s.writeUnlocked(fromProject, tickets); err != nil {
|
||||||
|
return Ticket{}, err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if fromProject != "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fromProject == "" {
|
||||||
|
return Ticket{}, fmt.Errorf("ticket %s: not found", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
found.ProjectID = toProjectID
|
||||||
|
found.UpdatedAt = time.Now().UTC()
|
||||||
|
|
||||||
|
if err := os.MkdirAll(s.projectPath(toProjectID), 0o755); err != nil {
|
||||||
|
return Ticket{}, fmt.Errorf("create project dir: %w", err)
|
||||||
|
}
|
||||||
|
dest, err := s.readUnlocked(toProjectID)
|
||||||
|
if err != nil {
|
||||||
|
return Ticket{}, err
|
||||||
|
}
|
||||||
|
dest = append(dest, found)
|
||||||
|
if err := s.writeUnlocked(toProjectID, dest); err != nil {
|
||||||
|
return Ticket{}, err
|
||||||
|
}
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Delete removes a ticket by ID from its project's file.
|
// Delete removes a ticket by ID from its project's file.
|
||||||
func (s *JSONStore) Delete(id string) error {
|
func (s *JSONStore) Delete(id string) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
|||||||
@@ -97,3 +97,33 @@ func TestJSONStoreMigratesLegacyTasksJSON(t *testing.T) {
|
|||||||
t.Fatalf("legacy tasks.json should be removed, err=%v", err)
|
t.Fatalf("legacy tasks.json should be removed, err=%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestJSONStoreMove(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := ticket.NewJSONStore(dir)
|
||||||
|
|
||||||
|
created, err := store.Create(ticket.Ticket{
|
||||||
|
Title: "Misfiled",
|
||||||
|
ProjectID: "inbox",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
moved, err := store.Move(created.ID, "client")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if moved.ProjectID != "client" {
|
||||||
|
t.Fatalf("ProjectID = %q", moved.ProjectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
from, err := store.List("inbox")
|
||||||
|
if err != nil || len(from) != 0 {
|
||||||
|
t.Fatalf("inbox after move: %v len=%d", err, len(from))
|
||||||
|
}
|
||||||
|
to, err := store.List("client")
|
||||||
|
if err != nil || len(to) != 1 || to[0].ID != created.ID {
|
||||||
|
t.Fatalf("client after move: %v %+v", err, to)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -153,6 +153,34 @@ func (s *JSONTimeStore) Delete(id string) error {
|
|||||||
return s.writeUnlocked(out)
|
return s.writeUnlocked(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RetargetTicket sets ProjectID on all entries for a ticket.
|
||||||
|
func (s *JSONTimeStore) RetargetTicket(ticketID, projectID string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if ticketID == "" {
|
||||||
|
return fmt.Errorf("ticketId is required")
|
||||||
|
}
|
||||||
|
if projectID == "" {
|
||||||
|
return fmt.Errorf("projectId is required")
|
||||||
|
}
|
||||||
|
entries, err := s.readUnlocked()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
for i := range entries {
|
||||||
|
if entries[i].TicketID == ticketID {
|
||||||
|
entries[i].ProjectID = projectID
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.writeUnlocked(entries)
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteByProject removes all time entries for a project.
|
// DeleteByProject removes all time entries for a project.
|
||||||
func (s *JSONTimeStore) DeleteByProject(projectID string) error {
|
func (s *JSONTimeStore) DeleteByProject(projectID string) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
|||||||
@@ -91,3 +91,38 @@ func TestJSONTimeStoreDeleteByProject(t *testing.T) {
|
|||||||
t.Fatalf("after DeleteByProject: %v %+v", err, list)
|
t.Fatalf("after DeleteByProject: %v %+v", err, list)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestJSONTimeStoreRetargetTicket(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := ticket.NewJSONTimeStore(dir)
|
||||||
|
|
||||||
|
if _, err := store.Create(ticket.TimeEntry{
|
||||||
|
TicketID: "t1", ProjectID: "old", Duration: time.Hour, Source: ticket.SourceManual,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := store.Create(ticket.TimeEntry{
|
||||||
|
TicketID: "t2", ProjectID: "old", Duration: time.Minute, Source: ticket.SourceManual,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.RetargetTicket("t1", "new"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, err := store.List()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, e := range list {
|
||||||
|
switch e.TicketID {
|
||||||
|
case "t1":
|
||||||
|
if e.ProjectID != "new" {
|
||||||
|
t.Fatalf("t1 project=%q", e.ProjectID)
|
||||||
|
}
|
||||||
|
case "t2":
|
||||||
|
if e.ProjectID != "old" {
|
||||||
|
t.Fatalf("t2 project=%q", e.ProjectID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ type Store interface {
|
|||||||
Get(id string) (Ticket, error)
|
Get(id string) (Ticket, error)
|
||||||
Create(t Ticket) (Ticket, error)
|
Create(t Ticket) (Ticket, error)
|
||||||
Update(t Ticket) error
|
Update(t Ticket) error
|
||||||
|
Move(id, toProjectID string) (Ticket, error)
|
||||||
Delete(id string) error
|
Delete(id string) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type TimeStore interface {
|
|||||||
Update(e TimeEntry) error
|
Update(e TimeEntry) error
|
||||||
Delete(id string) error
|
Delete(id string) error
|
||||||
DeleteByProject(projectID string) error
|
DeleteByProject(projectID string) error
|
||||||
|
RetargetTicket(ticketID, projectID string) error
|
||||||
SumByTicket(ticketID string) (time.Duration, error)
|
SumByTicket(ticketID string) (time.Duration, error)
|
||||||
|
|
||||||
LoadActiveTimer() (*ActiveTimer, error)
|
LoadActiveTimer() (*ActiveTimer, error)
|
||||||
|
|||||||
+84
-2
@@ -39,6 +39,7 @@ const (
|
|||||||
ModeCreate
|
ModeCreate
|
||||||
ModeSearch
|
ModeSearch
|
||||||
ModeProjects
|
ModeProjects
|
||||||
|
ModeMoveTicket
|
||||||
ModeStatus
|
ModeStatus
|
||||||
ModeBoard
|
ModeBoard
|
||||||
ModeConfirmDelete
|
ModeConfirmDelete
|
||||||
@@ -166,6 +167,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m.updateInput(msg)
|
return m.updateInput(msg)
|
||||||
case ModeProjects:
|
case ModeProjects:
|
||||||
return m.updateProjects(msg)
|
return m.updateProjects(msg)
|
||||||
|
case ModeMoveTicket:
|
||||||
|
return m.updateMoveTicket(msg)
|
||||||
case ModeStatus:
|
case ModeStatus:
|
||||||
return m.updateStatus(msg)
|
return m.updateStatus(msg)
|
||||||
case ModeBoard:
|
case ModeBoard:
|
||||||
@@ -185,7 +188,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
switch m.mode {
|
switch m.mode {
|
||||||
case ModeProjects:
|
case ModeProjects, ModeMoveTicket:
|
||||||
m.projectList, cmd = m.projectList.Update(msg)
|
m.projectList, cmd = m.projectList.Update(msg)
|
||||||
case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit:
|
case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit:
|
||||||
m.input, cmd = m.input.Update(msg)
|
m.input, cmd = m.input.Update(msg)
|
||||||
@@ -216,7 +219,7 @@ func (m *Model) resizeLists() {
|
|||||||
bodyH = 1
|
bodyH = 1
|
||||||
}
|
}
|
||||||
m.tickets.SetSize(m.width, bodyH)
|
m.tickets.SetSize(m.width, bodyH)
|
||||||
if m.mode == ModeProjects {
|
if m.mode == ModeProjects || m.mode == ModeMoveTicket {
|
||||||
m.projectList.SetSize(m.width, bodyH)
|
m.projectList.SetSize(m.width, bodyH)
|
||||||
}
|
}
|
||||||
m.input.Width = max(10, m.width-4)
|
m.input.Width = max(10, m.width-4)
|
||||||
@@ -258,6 +261,19 @@ func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.mode = ModeProjects
|
m.mode = ModeProjects
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.MoveTicket):
|
||||||
|
m.clearStatus()
|
||||||
|
if _, ok := m.tickets.Selected(); !ok {
|
||||||
|
m.setError(fmt.Errorf("no ticket selected"))
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if err := m.openProjects(); err != nil {
|
||||||
|
m.setError(err)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.mode = ModeMoveTicket
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case key.Matches(msg, m.keys.Board):
|
case key.Matches(msg, m.keys.Board):
|
||||||
m.clearStatus()
|
m.clearStatus()
|
||||||
m.boardMenu = views.NewBoardMenu(m.boardFilter, m.width, views.StatusMenuStyles{
|
m.boardMenu = views.NewBoardMenu(m.boardFilter, m.width, views.StatusMenuStyles{
|
||||||
@@ -716,6 +732,37 @@ func (m Model) updateReportGroup(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Model) updateMoveTicket(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, m.keys.Escape):
|
||||||
|
m.mode = ModeList
|
||||||
|
m.clearStatus()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Enter):
|
||||||
|
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
|
||||||
|
if !ok {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if err := m.moveSelectedToProject(item.Project.ID); err != nil {
|
||||||
|
m.setError(err)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.mode = ModeList
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 {
|
||||||
|
if views.JumpProjectByLetter(&m.projectList, msg.Runes[0]) {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.projectList, cmd = m.projectList.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, m.keys.Escape):
|
case key.Matches(msg, m.keys.Escape):
|
||||||
@@ -994,6 +1041,41 @@ func (m *Model) switchProject(id string) error {
|
|||||||
return m.reloadTickets()
|
return m.reloadTickets()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) moveSelectedToProject(toProjectID string) error {
|
||||||
|
t, ok := m.tickets.Selected()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no ticket selected")
|
||||||
|
}
|
||||||
|
if toProjectID == t.ProjectID {
|
||||||
|
m.setStatus("already there")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := m.projects.Ensure(toProjectID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
after := t
|
||||||
|
after.ProjectID = toProjectID
|
||||||
|
if err := m.exec.Do(&command.MoveTicket{
|
||||||
|
Store: m.store,
|
||||||
|
TimeStore: m.timeStore,
|
||||||
|
Before: t,
|
||||||
|
After: after,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if m.activeTimer != nil && m.activeTimer.TicketID == t.ID && m.timeStore != nil {
|
||||||
|
m.activeTimer.ProjectID = toProjectID
|
||||||
|
if err := m.timeStore.SaveActiveTimer(m.activeTimer); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := m.reloadTickets(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.setStatus(fmt.Sprintf("moved → %s", toProjectID))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) countTickets(projectID string) (int, error) {
|
func (m *Model) countTickets(projectID string) (int, error) {
|
||||||
tickets, err := m.store.List(projectID)
|
tickets, err := m.store.List(projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ func (m Model) helpEntries() []helpEntry {
|
|||||||
{"X", "force delete"},
|
{"X", "force delete"},
|
||||||
{"esc", "cancel"},
|
{"esc", "cancel"},
|
||||||
}
|
}
|
||||||
|
case ModeMoveTicket:
|
||||||
|
return []helpEntry{
|
||||||
|
{"j/k", "navigate"},
|
||||||
|
{"a-z", "jump"},
|
||||||
|
{"enter", "move here"},
|
||||||
|
{"esc", "cancel"},
|
||||||
|
}
|
||||||
case ModeStatus:
|
case ModeStatus:
|
||||||
return []helpEntry{
|
return []helpEntry{
|
||||||
{"j/k", "select"},
|
{"j/k", "select"},
|
||||||
@@ -99,6 +106,7 @@ func (m Model) helpEntries() []helpEntry {
|
|||||||
{"u/U", "undo/redo"},
|
{"u/U", "undo/redo"},
|
||||||
{"/", "search"},
|
{"/", "search"},
|
||||||
{"p", "project"},
|
{"p", "project"},
|
||||||
|
{"P", "move"},
|
||||||
{"l", "list"},
|
{"l", "list"},
|
||||||
{"s", "status"},
|
{"s", "status"},
|
||||||
{"x", "delete"},
|
{"x", "delete"},
|
||||||
|
|||||||
+6
-1
@@ -9,6 +9,7 @@ type KeyMap struct {
|
|||||||
New key.Binding
|
New key.Binding
|
||||||
Search key.Binding
|
Search key.Binding
|
||||||
Project key.Binding
|
Project key.Binding
|
||||||
|
MoveTicket key.Binding // P — move selected ticket to another project
|
||||||
Board key.Binding
|
Board key.Binding
|
||||||
Status key.Binding
|
Status key.Binding
|
||||||
Edit key.Binding
|
Edit key.Binding
|
||||||
@@ -51,6 +52,10 @@ func DefaultKeyMap() KeyMap {
|
|||||||
key.WithKeys("p"),
|
key.WithKeys("p"),
|
||||||
key.WithHelp("p", "project"),
|
key.WithHelp("p", "project"),
|
||||||
),
|
),
|
||||||
|
MoveTicket: key.NewBinding(
|
||||||
|
key.WithKeys("P"),
|
||||||
|
key.WithHelp("P", "move ticket"),
|
||||||
|
),
|
||||||
Board: key.NewBinding(
|
Board: key.NewBinding(
|
||||||
key.WithKeys("l", "tab"),
|
key.WithKeys("l", "tab"),
|
||||||
key.WithHelp("l", "list"),
|
key.WithHelp("l", "list"),
|
||||||
@@ -124,7 +129,7 @@ func DefaultKeyMap() KeyMap {
|
|||||||
|
|
||||||
// ShortHelp implements help.KeyMap.
|
// ShortHelp implements help.KeyMap.
|
||||||
func (k KeyMap) ShortHelp() []key.Binding {
|
func (k KeyMap) ShortHelp() []key.Binding {
|
||||||
return []key.Binding{k.Up, k.New, k.Search, k.Project, k.Board, k.Status, k.Edit, k.Timer, k.TimeManual, k.TimeEntries, k.Report, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit}
|
return []key.Binding{k.Up, k.New, k.Search, k.Project, k.MoveTicket, k.Board, k.Status, k.Edit, k.Timer, k.TimeManual, k.TimeEntries, k.Report, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FullHelp implements help.KeyMap.
|
// FullHelp implements help.KeyMap.
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func (m Model) renderBody(height int) string {
|
|||||||
menu := m.groupByMenu.View()
|
menu := m.groupByMenu.View()
|
||||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu)
|
return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu)
|
||||||
|
|
||||||
case ModeProjects:
|
case ModeProjects, ModeMoveTicket:
|
||||||
m.projectList.SetSize(m.width, height)
|
m.projectList.SetSize(m.width, height)
|
||||||
return m.projectList.View()
|
return m.projectList.View()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user