feat(board): preview the dragged card at its insertion point#3532
Conversation
…target column Renders a faded ghost clone of the dragged card at the tail of the drop-target column so users can see where the card will land before releasing. Assisted-By: claude-opus-4-5
Assisted-By: docker-agent
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟢 APPROVE
The ghost-preview feature is well-structured: the containsCard guard prevents double-rendering, the scroll-to-tail logic is correct for the common case, and the resetDrag refactor in tui.go is a clean improvement. Two minor edge cases in view.go are worth a quick look before merging.
| if dropTarget { | ||
| if card := m.cardByID(m.dragCardID); card != nil { | ||
| ghost = m.renderGhost(card, innerWidth) | ||
| slots-- |
There was a problem hiding this comment.
[LOW] Ghost preview hides all real cards when terminal fits exactly one card slot
When visibleSlots() returns 1 (the minimum, enforced by the max(..., 1) guard), slots-- drives it to 0. The subsequent computation then becomes:
scroll = max(len(cards)-0, 0) // = len(cards)
end = min(len(cards)+0, len(cards)) // = len(cards)The render loop (for i := scroll; i < end) is empty, so every real card disappears and the column shows only header + rule + "… N more" + ghost. On a tall-enough terminal this never triggers (slots ≥ 2), but on a constrained terminal where only one card row fits, a drag-over causes the entire card list to vanish except the ghost.
A simple guard avoids the underflow:
if slots > 1 {
slots--
scroll = max(len(cards)-slots, 0)
}That way a one-slot terminal still renders its single real card and skips the ghost, matching the PR description's intent ("terminals too small to show even one real card get a … N more line").
| borderColor := darken(styles.BorderSecondary, 0.35) | ||
| switch { | ||
| case m.dragging && idx == m.dragCol && idx != m.selCol: | ||
| case dropTarget: |
There was a problem hiding this comment.
[LOW] Dropping idx != m.selCol lets dropTarget override the selected column's white border with green
The old condition was:
case m.dragging && idx == m.dragCol && idx != m.selCol:The new dropTarget is:
m.dragging && idx == m.dragCol && !containsCard(cards, m.dragCardID)There is no longer a guard preventing dropTarget from being true when the destination column is also the selected column (m.dragCol == m.selCol). Because case dropTarget: appears first in the border-color switch, a drag toward a column the user previously selected will show a green border instead of the white one they'd normally see.
This may well be intentional — the green ghost+border is more informative during a drag — but since the removal is silent and the old guard was explicit, it's worth a quick confirmation that this is the desired UX before merging.
When dragging a card in the board TUI, there was no visual indication of where the card would land. Drop targets highlight the destination column with a green border, but the exact insertion point — always the tail of the column — was invisible.
While a drag is in progress and a column is highlighted as the drop target, the column now renders a faded "ghost" clone of the dragged card at the tail position, exactly where the card will be appended on drop. The column's scroll window shifts temporarily to reveal that tail slot, giving the user a clear preview of the outcome. If the drag is cancelled the scroll is restored, leaving the column untouched. The ghost uses faded title and project text with a border in faded success green, matching the existing green column-highlight treatment.
A second commit hardens the feature against edge cases: terminals too small to show even one real card get a
… N moreline instead of a ghost-only view, the ghost is suppressed when the dragged card is already in the target column to handle mid-drag refresh races, and stale drag state is cleared at the top ofhandleClickto recover from lost release events. Tests cover tail-scroll behaviour, the tiny-terminal fallback, and duplicate-card suppression.