feat(vscode): 支持重置回待办并修好待办依赖拖拽

This commit is contained in:
2026-08-25 18:53:55 +08:00
parent 75dec8fa90
commit 6c05b7f7e3
12 changed files with 717 additions and 164 deletions
+1
View File
@@ -366,6 +366,7 @@ export function App() {
onMergeBranch={mergeBranch}
onDeleteIssue={deleteIssue}
onCloseIssue={closeIssue}
onResetToTodo={n => changeColumn(n, 'todo', selectedIssue?.source, selectedIssue?.externalId)}
onCloseSessionTab={closeSessionTab}
onStartBrainstormSession={startBrainstormSession}
onUpdateAutoReview={updateIssueAutoReview}
@@ -42,6 +42,7 @@ interface BottomTabsProps {
onMergeBranch: (issueNumber: number, branch: string) => void
onDeleteIssue?: (issueNumber: number) => void
onCloseIssue: (issueNumber: number) => void
onResetToTodo: (issueNumber: number) => void
onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void
onStartBrainstormSession: (issueNumber: number) => void
onUpdateAutoReview: (issueNumber: number, value: boolean) => void
@@ -128,6 +129,7 @@ export function BottomTabs(props: BottomTabsProps) {
onMergeBranch={props.onMergeBranch}
onDeleteIssue={props.onDeleteIssue}
onCloseIssue={props.onCloseIssue}
onResetToTodo={props.onResetToTodo}
onCloseSessionTab={props.onCloseSessionTab}
onStartBrainstormSession={props.onStartBrainstormSession}
onUpdateAutoReview={props.onUpdateAutoReview}
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import { useMemo, useRef } from 'react'
import { CircleSlash, ExternalLink, GitMerge, Play, Terminal, Trash2, X } from 'lucide-react'
import { CircleSlash, ExternalLink, GitMerge, Play, RotateCcw, Terminal, Trash2, X } from 'lucide-react'
import type { ClaudeProfile } from '../hooks/useIssues'
import type { Issue, IssueColumn } from '../types'
import { COLUMN_LABELS, COLUMN_ORDER } from '../types'
@@ -68,6 +68,8 @@ interface IssueDetailPanelProps {
onDeleteIssue?: (issueNumber: number) => void
/** 关闭 Gitea 工单,不清理本地会话、worktree、PR 或分支。 */
onCloseIssue: (issueNumber: number) => void
/** 将实施中 / 审查中的工单重置回待办(清 worktree / 未合并 PR / feature 分支)。 */
onResetToTodo: (issueNumber: number) => void
/** Close the matching session terminal tab. The extension watches
* onDidCloseTerminal and clears the corresponding `*TabOpen` flag, which
* makes the X button vanish on its own. */
@@ -117,6 +119,7 @@ export function IssueDetailPanel({
onMergeBranch,
onDeleteIssue,
onCloseIssue,
onResetToTodo,
onCloseSessionTab,
onStartBrainstormSession,
onUpdateAutoReview,
@@ -572,6 +575,17 @@ export function IssueDetailPanel({
avoid acting on a gitea issue of the same number. */}
{issue.source !== 'youtrack' && (
<>
{(issue.column === 'in-progress' || issue.column === 'review') && (
<button
type="button"
onClick={() => onResetToTodo(issue.number)}
title="重置为待办"
aria-label="重置为待办"
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-yellow-500/60 hover:bg-yellow-500/10 hover:text-yellow-500"
>
<RotateCcw className="size-3.5" />
</button>
)}
<button
type="button"
onClick={() => onCloseIssue(issue.number)}
+52 -159
View File
@@ -1,25 +1,28 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { CollisionDetection, DragEndEvent, DragStartEvent } from '@dnd-kit/core'
import type { Issue, IssueColumn } from '../types'
import {
closestCenter,
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
pointerWithin,
useSensor,
useSensors,
} from '@dnd-kit/core'
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
import {
SortableContext,
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { IssueCard } from './IssueCard'
import { KanbanColumn } from './KanbanColumn'
import { useEffect, useMemo, useRef, useState } from 'react'
import { isIssueLocked } from '../lib/dependencies'
import { compareIssuesInColumn } from '../lib/issueSort'
import type { Issue, IssueColumn } from '../types'
import { isColumnId, resolveKanbanDrag } from '../lib/kanbanDrag'
import { COLUMN_ORDER } from '../types'
import { IssueCard } from './IssueCard'
import { KanbanColumn } from './KanbanColumn'
interface KanbanBoardProps {
issues: Issue[]
@@ -28,27 +31,18 @@ interface KanbanBoardProps {
onCreateIssue?: () => void
selectedId?: string | null
onSelectIssue?: (id: string | null) => void
/**
* Called when a card moves to a new column via drag-and-drop, in addition
* to the optimistic `onIssuesChange`. Currently only invoked for moves
* targeting `done`; other column changes remain client-visual-only.
*/
onColumnChange?: (issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string) => void
/**
* Called when a drag gesture establishes a new prerequisite relationship
* (drop onto another todo card's middle 1/3) or moves to a sibling whose
* parent differs from the active card's current parent.
*/
onDependencySet?: (issueNumber: number, prerequisiteNumber: number) => void
/**
* Called when a drag gesture clears an existing prerequisite (e.g. drop
* onto the todo column's empty area, or onto a sibling at root level).
*/
onDependencyClear?: (issueNumber: number, prerequisiteNumber: number) => void
}
function isColumnId(id: string): id is IssueColumn {
return (COLUMN_ORDER as string[]).includes(id)
const kanbanCollisionDetection: CollisionDetection = (args) => {
const pointerHits = pointerWithin(args)
if (pointerHits.length > 0) {
const cardHits = pointerHits.filter(c => !isColumnId(String(c.id)))
return cardHits.length > 0 ? cardHits : pointerHits
}
return closestCenter(args)
}
/**
@@ -152,174 +146,73 @@ export function KanbanBoard({
setActiveId(String(event.active.id))
}
/**
* Classify the drop position relative to the `over` card. Middle 1/3
* means nest (set prerequisite); the upper/lower 2/3 means sibling reorder.
* The 1/3-2/3 split is intentionally wider than 1/4-1/2-1/4 because the
* sortable strategy reflows during drag, so the user needs more room.
*/
function detectDropZone(event: DragEndEvent): 'middle' | 'edge' {
const activeRect = event.active.rect.current.translated
const overRect = event.over?.rect
if (!activeRect || !overRect)
return 'edge'
const activeCenterY = (activeRect.top + activeRect.bottom) / 2
const overHeight = overRect.height
// Widen the nest zone to the middle 60% of the over card so user doesn't
// need pixel-precision. Edge zone is just the top/bottom 20% slivers,
// which are still enough for sibling-reorder gestures.
const middleStart = overRect.top + overHeight * 0.2
const middleEnd = overRect.top + overHeight * 0.8
return activeCenterY >= middleStart && activeCenterY <= middleEnd ? 'middle' : 'edge'
}
/**
* Walks up the prerequisite chain from `target`, returning true if
* `maybeAncestor` appears anywhere on the way. Uses a visited set so a
* pre-existing cycle in the data doesn't spin forever.
*/
function isDescendant(maybeAncestor: number, target: number, all: Issue[]): boolean {
const visited = new Set<number>()
let current: Issue | undefined = all.find(i => i.number === target)
while (current && current.prerequisite != null) {
if (visited.has(current.number))
return false
visited.add(current.number)
if (current.prerequisite === maybeAncestor)
return true
current = all.find(i => i.number === current!.prerequisite)
}
return false
}
function handleDragEnd(event: DragEndEvent): void {
const { active, over } = event
setActiveId(null)
if (!over)
return
const activeIdStr = String(active.id)
const overIdStr = String(over.id)
if (activeIdStr === overIdStr)
const result = resolveKanbanDrag({
issues,
activeId: String(active.id),
overId: String(over.id),
})
if (result.type === 'none')
return
const activeIndex = issues.findIndex(i => i.id === activeIdStr)
if (activeIndex < 0)
if (result.type === 'reorder') {
onIssuesChange(arrayMove(issues, result.fromIndex, result.toIndex))
return
const activeItem = issues[activeIndex]
// Lock check: a todo card whose prerequisite is still unfinished must
// stay in the todo column. Resolve target column from either the over
// column id or the over card's column.
const prereqIssue = activeItem.prerequisite != null
? issues.find(i => i.number === activeItem.prerequisite)
: undefined
const isLocked = !!prereqIssue && prereqIssue.column !== 'done'
if (isLocked && activeItem.column === 'todo') {
const overItemForLock = isColumnId(overIdStr)
? undefined
: issues.find(i => i.id === overIdStr)
const targetColumn: IssueColumn | undefined = isColumnId(overIdStr)
? overIdStr
: overItemForLock?.column
if (targetColumn && targetColumn !== 'todo')
return
}
// Dropped onto a column container
if (isColumnId(overIdStr)) {
// Drop onto the todo column's empty area while the card is already in
// todo → clear prerequisite (move to root level, append at column end).
if (overIdStr === 'todo' && activeItem.column === 'todo' && activeItem.prerequisite != null) {
const prevPrereq = activeItem.prerequisite
const next = [...issues]
next.splice(activeIndex, 1)
next.push({ ...activeItem, prerequisite: undefined })
onIssuesChange(next)
onDependencyClear?.(activeItem.number, prevPrereq)
return
}
if (activeItem.column === overIdStr)
if (result.type === 'column') {
const idx = issues.findIndex(i => i.number === result.issueNumber)
if (idx < 0)
return
const item = issues[idx]
const next = [...issues]
next.splice(activeIndex, 1)
next.push({ ...activeItem, column: overIdStr })
next.splice(idx, 1)
const overIdx = isColumnId(String(over.id))
? -1
: next.findIndex(i => i.id === String(over.id))
const insertAt = overIdx < 0 ? next.length : overIdx
next.splice(insertAt, 0, { ...item, column: result.toColumn })
onIssuesChange(next)
onColumnChange?.(activeItem.number, overIdStr, activeItem.source, activeItem.externalId)
onColumnChange?.(result.issueNumber, result.toColumn, result.source, result.externalId)
return
}
// Dropped onto another card
const overIndex = issues.findIndex(i => i.id === overIdStr)
if (overIndex < 0)
return
const overItem = issues[overIndex]
// Cross-column or non-todo-to-non-todo: keep the existing semantics.
if (activeItem.column !== 'todo' || overItem.column !== 'todo') {
if (activeItem.column === overItem.column) {
onIssuesChange(arrayMove(issues, activeIndex, overIndex))
if (result.type === 'dependency-set') {
const idx = issues.findIndex(i => i.number === result.issueNumber)
if (idx < 0)
return
}
// Cross-column: drop activeItem at overItem's position in the new column
const item = issues[idx]
const next = [...issues]
next.splice(activeIndex, 1)
const newOverIndex = next.findIndex(i => i.id === overIdStr)
const insertAt = newOverIndex < 0 ? next.length : newOverIndex
next.splice(insertAt, 0, { ...activeItem, column: overItem.column })
next.splice(idx, 1)
const overIdx = next.findIndex(i => i.number === result.prerequisiteNumber)
const insertAt = overIdx < 0 ? next.length : overIdx + 1
next.splice(insertAt, 0, { ...item, prerequisite: result.prerequisiteNumber })
onIssuesChange(next)
onColumnChange?.(activeItem.number, overItem.column, activeItem.source, activeItem.externalId)
onDependencySet?.(result.issueNumber, result.prerequisiteNumber)
return
}
// Both in todo: distinguish nest (middle 1/3) vs sibling reorder (edge).
const zone = detectDropZone(event)
if (zone === 'middle') {
// Don't allow nesting onto our own descendant (would form a cycle).
if (isDescendant(activeItem.number, overItem.number, issues))
return
// Already nested under this same parent → nothing to do.
if (activeItem.prerequisite === overItem.number)
return
const next = [...issues]
next.splice(activeIndex, 1)
const newOverIndex = next.findIndex(i => i.id === overIdStr)
const insertAt = newOverIndex < 0 ? next.length : newOverIndex + 1
next.splice(insertAt, 0, { ...activeItem, prerequisite: overItem.number })
onIssuesChange(next)
onDependencySet?.(activeItem.number, overItem.number)
const idx = issues.findIndex(i => i.number === result.issueNumber)
if (idx < 0)
return
}
// edge: sibling reorder. Inherit the over card's prerequisite as parent.
const newParent = overItem.prerequisite
if (newParent === activeItem.prerequisite) {
// Same parent (including both undefined): pure reorder.
onIssuesChange(arrayMove(issues, activeIndex, overIndex))
return
}
// Cycle guard: refuse to set the new parent if it would put active under
// its own descendant. Still reorder visually.
const cycle = newParent != null && isDescendant(activeItem.number, newParent, issues)
if (cycle) {
onIssuesChange(arrayMove(issues, activeIndex, overIndex))
return
}
const item = issues[idx]
const next = [...issues]
next.splice(activeIndex, 1)
const newOverIndex = next.findIndex(i => i.id === overIdStr)
const insertAt = newOverIndex < 0 ? next.length : newOverIndex
next.splice(insertAt, 0, { ...activeItem, prerequisite: newParent })
next.splice(idx, 1)
next.push({ ...item, prerequisite: undefined })
onIssuesChange(next)
if (newParent == null)
onDependencyClear?.(activeItem.number, activeItem.prerequisite!)
else
onDependencySet?.(activeItem.number, newParent)
onDependencyClear?.(result.issueNumber, result.prerequisiteNumber)
}
return (
<DndContext
sensors={sensors}
collisionDetection={kanbanCollisionDetection}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={() => setActiveId(null)}
@@ -0,0 +1,120 @@
import type { Issue, IssueColumn } from '../types'
import { describe, expect, it } from 'vitest'
import { isColumnId, resolveKanbanDrag } from './kanbanDrag'
function issue(
number: number,
column: IssueColumn,
extra: Partial<Issue> = {},
): Issue {
return {
id: `repo#${number}`,
number,
title: `issue ${number}`,
column,
htmlUrl: '',
...extra,
}
}
describe('isColumnId', () => {
it('识别四列 id', () => {
expect(isColumnId('todo')).toBe(true)
expect(isColumnId('in-progress')).toBe(true)
expect(isColumnId('review')).toBe(true)
expect(isColumnId('done')).toBe(true)
})
it('拒绝卡片 id', () => {
expect(isColumnId('repo#1')).toBe(false)
})
})
describe('resolveKanbanDrag', () => {
it('active===over → none', () => {
const issues = [issue(1, 'todo')]
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'repo#1' }))
.toEqual({ type: 'none' })
})
it('丢到另一张待办卡片 = setover 为前置)', () => {
const issues = [issue(1, 'todo'), issue(2, 'todo')]
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'repo#2' }))
.toEqual({ type: 'dependency-set', issueNumber: 1, prerequisiteNumber: 2 })
})
it('丢到待办列空白且已有依赖 = clear', () => {
const issues = [issue(1, 'todo', { prerequisite: 2 }), issue(2, 'todo')]
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'todo' }))
.toEqual({ type: 'dependency-clear', issueNumber: 1, prerequisiteNumber: 2 })
})
it('over 是列且已在 todo 且无依赖 = none', () => {
const issues = [issue(1, 'todo')]
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'todo' }))
.toEqual({ type: 'none' })
})
it('跨列仍是 column', () => {
const issues = [issue(1, 'in-progress'), issue(2, 'todo')]
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'todo' }))
.toEqual({ type: 'column', issueNumber: 1, toColumn: 'todo' })
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'repo#2' }))
.toEqual({ type: 'column', issueNumber: 1, toColumn: 'todo' })
})
it('跨列带上 source / externalId', () => {
const issues = [issue(1, 'review', { source: 'youtrack', externalId: 'LXF-1' })]
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'todo' }))
.toEqual({
type: 'column',
issueNumber: 1,
toColumn: 'todo',
source: 'youtrack',
externalId: 'LXF-1',
})
})
it('环拒绝:不能把祖先挂到自己的后代下', () => {
const issues = [
issue(1, 'todo'),
issue(2, 'todo', { prerequisite: 1 }),
issue(3, 'todo', { prerequisite: 2 }),
]
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'repo#3' }))
.toEqual({ type: 'none' })
})
it('已经是这个 parent → none', () => {
const issues = [issue(1, 'todo', { prerequisite: 2 }), issue(2, 'todo')]
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'repo#2' }))
.toEqual({ type: 'none' })
})
it('换 parent 也是 dependency-set,不先 clear', () => {
const issues = [
issue(1, 'todo', { prerequisite: 2 }),
issue(2, 'todo'),
issue(3, 'todo'),
]
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'repo#3' }))
.toEqual({ type: 'dependency-set', issueNumber: 1, prerequisiteNumber: 3 })
})
it('同列非 todo → reorder', () => {
const issues = [issue(1, 'in-progress'), issue(2, 'in-progress')]
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'repo#2' }))
.toEqual({ type: 'reorder', fromIndex: 0, toIndex: 1 })
})
it('锁定:todo 且前置未完成,目标列不是 todo → none', () => {
const issues = [
issue(1, 'todo', { prerequisite: 2 }),
issue(2, 'in-progress'),
]
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'in-progress' }))
.toEqual({ type: 'none' })
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'repo#2' }))
.toEqual({ type: 'none' })
})
})
+102
View File
@@ -0,0 +1,102 @@
import type { Issue, IssueColumn } from '../types'
import { COLUMN_ORDER } from '../types'
import { isIssueLocked } from './dependencies'
export function isColumnId(id: string): id is IssueColumn {
return (COLUMN_ORDER as string[]).includes(id)
}
export type KanbanDragResult
= | { type: 'none' }
| { type: 'column', issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string }
| { type: 'dependency-set', issueNumber: number, prerequisiteNumber: number }
| { type: 'dependency-clear', issueNumber: number, prerequisiteNumber: number }
| { type: 'reorder', fromIndex: number, toIndex: number }
function isDescendant(maybeAncestor: number, target: number, all: Issue[]): boolean {
const visited = new Set<number>()
let current: Issue | undefined = all.find(i => i.number === target)
while (current && current.prerequisite != null) {
if (visited.has(current.number))
return false
visited.add(current.number)
if (current.prerequisite === maybeAncestor)
return true
current = all.find(i => i.number === current!.prerequisite)
}
return false
}
export function resolveKanbanDrag(opts: {
issues: Issue[]
activeId: string
overId: string
}): KanbanDragResult {
const { issues, activeId, overId } = opts
if (activeId === overId)
return { type: 'none' }
const activeItem = issues.find(i => i.id === activeId)
if (!activeItem)
return { type: 'none' }
// ① 锁:待办且前置未完成,只能在 todo 列内操作
const locked = isIssueLocked(activeItem, issues).locked && activeItem.column === 'todo'
const overIsColumn = isColumnId(overId)
const overItem = overIsColumn ? undefined : issues.find(i => i.id === overId)
const targetColumn: IssueColumn | undefined = overIsColumn ? overId : overItem?.column
if (locked && targetColumn && targetColumn !== 'todo')
return { type: 'none' }
// ② over 是列:已在 todo 且有依赖 → 清依赖;已在该列 → none;否则换列
if (overIsColumn) {
if (overId === 'todo' && activeItem.column === 'todo' && activeItem.prerequisite != null) {
return {
type: 'dependency-clear',
issueNumber: activeItem.number,
prerequisiteNumber: activeItem.prerequisite,
}
}
if (activeItem.column === overId)
return { type: 'none' }
return {
type: 'column',
issueNumber: activeItem.number,
toColumn: overId,
...(activeItem.source ? { source: activeItem.source } : {}),
...(activeItem.externalId ? { externalId: activeItem.externalId } : {}),
}
}
if (!overItem)
return { type: 'none' }
// ③ over 是卡片:双方 todo → 设依赖;同列非 todo → 重排;跨列 → 换列
if (activeItem.column === 'todo' && overItem.column === 'todo') {
if (isDescendant(activeItem.number, overItem.number, issues))
return { type: 'none' }
if (activeItem.prerequisite === overItem.number)
return { type: 'none' }
return {
type: 'dependency-set',
issueNumber: activeItem.number,
prerequisiteNumber: overItem.number,
}
}
if (activeItem.column === overItem.column) {
return {
type: 'reorder',
fromIndex: issues.findIndex(i => i.id === activeId),
toIndex: issues.findIndex(i => i.id === overId),
}
}
return {
type: 'column',
issueNumber: activeItem.number,
toColumn: overItem.column,
...(activeItem.source ? { source: activeItem.source } : {}),
...(activeItem.externalId ? { externalId: activeItem.externalId } : {}),
}
}
+1 -1
View File
@@ -57,7 +57,7 @@ export type ExtensionToWebview
= | { type: 'issues/loading' }
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean }
| { type: 'issues/error', message: string }
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string, implementSessionId?: string, reviewSessionId?: string, reviewSessionFileExists?: boolean, testSessionId?: string, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed', column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string } }
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string } }
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
| { type: 'issue/append', issue: Issue, select?: boolean }
| { type: 'issue/select-by-number', issueNumber: number }