Skip to content

Commit 3209cc5

Browse files
authored
Merge pull request #3522 from dgageot/worktree-board-b453f86fbf35bb0e
feat(board): move cards to any column with digit keys and drag & drop
2 parents 932960c + ce6363e commit 3209cc5

5 files changed

Lines changed: 284 additions & 13 deletions

File tree

docs/features/board/index.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,12 @@ Requirements: `tmux` and `git` must be installed.
4949
| `o` | Open the card's worktree in `$DOCKER_AGENT_BOARD_EDITOR` (`code`) |
5050
| `s` | Open an interactive shell in the card's worktree |
5151
| `[` / `]` | Move the card back / forward |
52+
| `1`-`9` | Move the card to column N |
5253
| `x` | Delete the card, its session, worktree, and branch |
5354
| `p` | Manage projects (add, edit, reorder, remove) |
5455
| `e` | Edit the selected column's prompt |
5556
| `←↓↑→` `hjkl` | Navigate |
56-
| mouse | Click selects, double-click attaches, wheel scrolls |
57+
| mouse | Click selects, double-click attaches, drag moves, wheel scrolls |
5758
| `?` | Help |
5859
| `q` | Quit (agents keep running) |
5960

pkg/board/tui/dialogs.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -704,11 +704,12 @@ func (d *helpDialog) View(width, _ int) string {
704704
{keys.Editor.Help().Key, keys.Editor.Help().Desc + " ($DOCKER_AGENT_BOARD_EDITOR, default code)"},
705705
{keys.Shell.Help().Key, keys.Shell.Help().Desc},
706706
{"[ / ]", "move card (forward sends the column's prompt)"},
707+
{keys.MoveTo.Help().Key, keys.MoveTo.Help().Desc},
707708
{keys.Delete.Help().Key, "delete card, its session and worktree"},
708709
{keys.Projects.Help().Key, keys.Projects.Help().Desc},
709710
{keys.Prompt.Help().Key, "edit the selected column's prompt"},
710711
{"←↓↑→ hjkl", "navigate (g/G first/last card)"},
711-
{"mouse", "click selects · double-click attaches · wheel scrolls"},
712+
{"mouse", "click selects · double-click attaches · drag moves · wheel scrolls"},
712713
{keys.Quit.Help().Key, "quit (agents keep running in tmux)"},
713714
}
714715
// Same row styling as the main TUI's help dialog.

pkg/board/tui/tui.go

Lines changed: 117 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"image/color"
77
"os"
88
"os/exec"
9+
"slices"
910
"strings"
1011
"time"
1112

@@ -136,6 +137,7 @@ type keyMap struct {
136137
Diff key.Binding
137138
MoveFwd key.Binding
138139
MoveBack key.Binding
140+
MoveTo key.Binding
139141
Delete key.Binding
140142
Projects key.Binding
141143
Prompt key.Binding
@@ -157,6 +159,7 @@ var keys = keyMap{
157159
Diff: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "view diff")),
158160
MoveFwd: key.NewBinding(key.WithKeys("]", "shift+right", "L"), key.WithHelp("]", "move card forward")),
159161
MoveBack: key.NewBinding(key.WithKeys("[", "shift+left", "H"), key.WithHelp("[", "move card back")),
162+
MoveTo: key.NewBinding(key.WithKeys("1", "2", "3", "4", "5", "6", "7", "8", "9"), key.WithHelp("1-9", "move card to column N")),
160163
Delete: key.NewBinding(key.WithKeys("x", "backspace", "delete"), key.WithHelp("x", "delete card")),
161164
Projects: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "manage projects")),
162165
Prompt: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit column prompt")),
@@ -229,6 +232,14 @@ type model struct {
229232
// lastClick* back double-click-to-attach on cards.
230233
lastClickCard string
231234
lastClickTime time.Time
235+
236+
// drag* back drag-and-drop card moves: dragCardID is the pressed card
237+
// (a drag candidate), dragging turns true on the first motion event
238+
// (cell-motion mode only reports motion while a button is held), and
239+
// dragCol is the drop target column under the pointer (-1 when none).
240+
dragCardID string
241+
dragging bool
242+
dragCol int
232243
}
233244

234245
func newModel(app *board.App, refresh chan struct{}) *model {
@@ -244,6 +255,7 @@ func newModel(app *board.App, refresh chan struct{}) *model {
244255

245256
// openDialog installs a dialog and runs its init command.
246257
func (m *model) openDialog(d dialog) tea.Cmd {
258+
m.resetDrag() // the dialog captures the release: the drag cannot finish
247259
m.dialog = d
248260
return d.Init()
249261
}
@@ -259,6 +271,15 @@ func (m *model) reload() {
259271
m.projectColors[p.Name] = projectColorAt(i)
260272
}
261273
m.clampSelection()
274+
// A refresh can remove the dragged card or shrink the column list;
275+
// re-validate so a drop cannot act on stale state.
276+
if m.dragCardID != "" {
277+
if m.cardByID(m.dragCardID) == nil {
278+
m.resetDrag()
279+
} else if m.dragCol >= len(m.columns) {
280+
m.dragCol = -1
281+
}
282+
}
262283
}
263284

264285
// groupCards buckets cards by column, in board order. A card whose column
@@ -303,6 +324,18 @@ func (m *model) selectedCard() *board.Card {
303324
return cards[m.selRow]
304325
}
305326

327+
// cardByID finds a card anywhere on the board.
328+
func (m *model) cardByID(id string) *board.Card {
329+
for _, cards := range m.cards {
330+
for _, c := range cards {
331+
if c.ID == id {
332+
return c
333+
}
334+
}
335+
}
336+
return nil
337+
}
338+
306339
func (m *model) Init() tea.Cmd {
307340
return tea.Batch(m.waitRefresh(), m.scheduleTick())
308341
}
@@ -499,6 +532,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
499532
return m.handleKey(msg)
500533
case tea.MouseClickMsg:
501534
return m.handleClick(msg)
535+
case tea.MouseMotionMsg:
536+
m.handleMotion(msg)
537+
return m, nil
538+
case tea.MouseReleaseMsg:
539+
return m.handleRelease(msg)
502540
case tea.MouseWheelMsg:
503541
m.handleWheel(msg)
504542
return m, nil
@@ -507,6 +545,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
507545
}
508546

509547
func (m *model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
548+
// A key press cancels any drag in progress (esc cancels a drag) and
549+
// keeps the state clean across paths that lose the release event, like
550+
// tea.ExecProcess (attach, shell) leaving mouse mode.
551+
m.resetDrag()
510552
switch {
511553
case key.Matches(msg, keys.Quit):
512554
return m, tea.Quit
@@ -544,10 +586,13 @@ func (m *model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
544586
}
545587

546588
case key.Matches(msg, keys.MoveFwd):
547-
cmd := m.moveCard(1)
589+
cmd := m.moveCardTo(m.selCol + 1)
548590
return m, cmd
549591
case key.Matches(msg, keys.MoveBack):
550-
cmd := m.moveCard(-1)
592+
cmd := m.moveCardTo(m.selCol - 1)
593+
return m, cmd
594+
case key.Matches(msg, keys.MoveTo):
595+
cmd := m.moveCardTo(int(msg.String()[0] - '1'))
551596
return m, cmd
552597

553598
case key.Matches(msg, keys.Delete):
@@ -686,9 +731,64 @@ func (m *model) handleClick(msg tea.MouseClickMsg) (tea.Model, tea.Cmd) {
686731
}
687732
m.lastClickCard = card.ID
688733
m.lastClickTime = time.Now()
734+
// The pressed card is a drag candidate: motion before the release turns
735+
// the click into a drag (see handleMotion/handleRelease).
736+
m.dragCardID = card.ID
737+
m.dragCol = col
689738
return m, nil
690739
}
691740

741+
// handleMotion tracks a pressed card being dragged. Cell-motion mode only
742+
// reports motion while a button is held, so motion after a card press
743+
// means a drag; the column under the pointer becomes the drop target.
744+
func (m *model) handleMotion(msg tea.MouseMotionMsg) {
745+
if m.dragCardID == "" || msg.Button != tea.MouseLeft {
746+
return
747+
}
748+
// Jitter within the pressed card stays a click (double-click keeps
749+
// working): the drag starts once the pointer leaves the card.
750+
if !m.dragging {
751+
if col, row, ok := m.cardAt(msg.X, msg.Y); ok && col == m.selCol && row == m.selRow {
752+
return
753+
}
754+
}
755+
m.dragging = true
756+
if col, ok := m.columnAt(msg.X, msg.Y); ok {
757+
m.dragCol = col
758+
} else {
759+
m.dragCol = -1
760+
}
761+
}
762+
763+
// handleRelease completes a drag-and-drop: dropping a card on another
764+
// column moves it there. A release without prior motion is a plain click,
765+
// already handled by handleClick.
766+
func (m *model) handleRelease(msg tea.MouseReleaseMsg) (tea.Model, tea.Cmd) {
767+
if msg.Button != tea.MouseLeft {
768+
return m, nil
769+
}
770+
cardID, wasDragging, dst := m.dragCardID, m.dragging, m.dragCol
771+
m.resetDrag()
772+
if !wasDragging {
773+
return m, nil
774+
}
775+
m.lastClickCard = "" // a completed drag must not arm double-click
776+
if col, ok := m.columnAt(msg.X, msg.Y); ok {
777+
dst = col
778+
}
779+
cmd := m.moveCard(cardID, dst)
780+
return m, cmd
781+
}
782+
783+
// resetDrag abandons any drag in progress. Called whenever a drag can no
784+
// longer complete cleanly: a dialog opening (it captures the release), a
785+
// key press, or the dragged card disappearing on a refresh.
786+
func (m *model) resetDrag() {
787+
m.dragCardID = ""
788+
m.dragging = false
789+
m.dragCol = -1
790+
}
791+
692792
// handleWheel moves the selection through the column under the cursor, so
693793
// scrolling anywhere on a column walks its cards (the scroll window follows
694794
// the selection). Wheel events outside the columns area are ignored.
@@ -731,18 +831,27 @@ func (m *model) createCard(project board.Project, prompt string) tea.Cmd {
731831
}
732832
}
733833

734-
func (m *model) moveCard(direction int) tea.Cmd {
735-
card := m.selectedCard()
736-
if card == nil {
737-
return nil
834+
// moveCardTo moves the selected card to the column at index dst.
835+
func (m *model) moveCardTo(dst int) tea.Cmd {
836+
if card := m.selectedCard(); card != nil {
837+
return m.moveCard(card.ID, dst)
738838
}
739-
dst := m.selCol + direction
839+
return nil
840+
}
841+
842+
// moveCard moves a card — by ID, so a board refresh mid-drag cannot
843+
// redirect the move to another card — to the column at index dst. Moving a
844+
// card to the column it already occupies is a no-op.
845+
func (m *model) moveCard(cardID string, dst int) tea.Cmd {
740846
if dst < 0 || dst >= len(m.columns) {
741847
return nil
742848
}
743849
colID := m.columns[dst].ID
850+
if slices.ContainsFunc(m.cards[colID], func(c *board.Card) bool { return c.ID == cardID }) {
851+
return nil
852+
}
744853
return func() tea.Msg {
745-
if err := m.app.MoveCard(card.ID, colID); err != nil {
854+
if err := m.app.MoveCard(cardID, colID); err != nil {
746855
return flashMsg{text: err.Error(), isErr: true}
747856
}
748857
return cardMovedMsg{colIdx: dst}

0 commit comments

Comments
 (0)