✨ feat(vscode): 支持重置回待办并修好待办依赖拖拽
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
|||||||
closeIssue,
|
closeIssue,
|
||||||
deleteBranch,
|
deleteBranch,
|
||||||
deleteIssue,
|
deleteIssue,
|
||||||
|
getDependencies,
|
||||||
getPullRequest,
|
getPullRequest,
|
||||||
GiteaApiError,
|
GiteaApiError,
|
||||||
mergePullRequest,
|
mergePullRequest,
|
||||||
@@ -35,6 +36,7 @@ import { pickRandomIssueColor, themeColorIdToIconUri } from '../issueColor'
|
|||||||
import { makeNonce } from '../KanbanPanel'
|
import { makeNonce } from '../KanbanPanel'
|
||||||
import { cleanupFeatureBranch } from './branchCleanup'
|
import { cleanupFeatureBranch } from './branchCleanup'
|
||||||
import * as sessions from './sessions'
|
import * as sessions from './sessions'
|
||||||
|
import { canResetToTodo, todoResetStateExtra, todoResetUiPatch } from './todoReset'
|
||||||
|
|
||||||
export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: number, toColumn: IssueColumn): Promise<void> {
|
export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: number, toColumn: IssueColumn): Promise<void> {
|
||||||
if (toColumn === 'in-progress') {
|
if (toColumn === 'in-progress') {
|
||||||
@@ -42,6 +44,11 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (toColumn === 'todo') {
|
||||||
|
await handleResetToTodo(panel, issueNumber)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (toColumn !== 'done') {
|
if (toColumn !== 'done') {
|
||||||
logger.add({
|
logger.add({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
@@ -554,6 +561,290 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function handleResetToTodo(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
|
||||||
|
const rollback = (fromColumn: IssueColumn | undefined): void => {
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'issue/patch',
|
||||||
|
issueNumber,
|
||||||
|
patch: { column: fromColumn ?? 'in-progress' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||||
|
if (!workspaceRoot) {
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'toast/show',
|
||||||
|
id: makeNonce(),
|
||||||
|
level: 'error',
|
||||||
|
message: '请先打开一个工作区文件夹',
|
||||||
|
dismissOnTimer: 5000,
|
||||||
|
})
|
||||||
|
rollback(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const remote = await detectRepo(workspaceRoot)
|
||||||
|
if (!remote) {
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'toast/show',
|
||||||
|
id: makeNonce(),
|
||||||
|
level: 'error',
|
||||||
|
message: '当前工作区没有 Gitea 远程仓库',
|
||||||
|
dismissOnTimer: 5000,
|
||||||
|
})
|
||||||
|
rollback(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getToken(panel.context, remote.host)
|
||||||
|
if (!token) {
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'toast/show',
|
||||||
|
id: makeNonce(),
|
||||||
|
level: 'error',
|
||||||
|
message: '请先完成 Gitea 配置',
|
||||||
|
dismissOnTimer: 5000,
|
||||||
|
})
|
||||||
|
rollback(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ① 读 state → 守卫(todo no-op / done 拒绝)→ 确认;取消 rollback 必须先有 fromColumn
|
||||||
|
let prStr: string | undefined
|
||||||
|
let worktreePath: string | undefined
|
||||||
|
let fromColumn: IssueColumn | undefined
|
||||||
|
let featureBranchFromState: string | undefined
|
||||||
|
try {
|
||||||
|
const stateObj = await panel.readIssueState(issueNumber)
|
||||||
|
if (typeof stateObj.pr === 'string' && stateObj.pr.length > 0)
|
||||||
|
prStr = stateObj.pr
|
||||||
|
if (typeof stateObj.worktreePath === 'string' && stateObj.worktreePath.length > 0)
|
||||||
|
worktreePath = stateObj.worktreePath
|
||||||
|
if (
|
||||||
|
typeof stateObj.column === 'string'
|
||||||
|
&& ['todo', 'in-progress', 'review', 'done'].includes(stateObj.column)
|
||||||
|
) {
|
||||||
|
fromColumn = stateObj.column as IssueColumn
|
||||||
|
}
|
||||||
|
if (typeof stateObj.branch === 'string' && stateObj.branch.length > 0)
|
||||||
|
featureBranchFromState = stateObj.branch
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
logger.add({
|
||||||
|
level: 'error',
|
||||||
|
source: 'panel',
|
||||||
|
message: `读取工单 #${issueNumber} 状态失败`,
|
||||||
|
details: message,
|
||||||
|
})
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'toast/show',
|
||||||
|
id: makeNonce(),
|
||||||
|
level: 'error',
|
||||||
|
message: `读取工单 #${issueNumber} 状态失败: ${message}`,
|
||||||
|
dismissOnTimer: 6000,
|
||||||
|
})
|
||||||
|
rollback(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromColumn === 'todo')
|
||||||
|
return
|
||||||
|
|
||||||
|
if (!canResetToTodo(fromColumn ?? 'in-progress')) {
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'toast/show',
|
||||||
|
id: makeNonce(),
|
||||||
|
level: 'error',
|
||||||
|
message: `已完成的工单 #${issueNumber} 不能重置为待办`,
|
||||||
|
dismissOnTimer: 5000,
|
||||||
|
})
|
||||||
|
rollback(fromColumn)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const choice = await window.showWarningMessage(
|
||||||
|
`确定将工单 #${issueNumber} 重置为待办?将停止会话、删除 worktree / 未合并 PR / feature 分支,实施进度不可恢复。`,
|
||||||
|
{ modal: true },
|
||||||
|
'重置为待办',
|
||||||
|
)
|
||||||
|
if (choice !== '重置为待办') {
|
||||||
|
rollback(fromColumn)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 停会话 → 删 worktree(失败即停)→ 未合并 PR 关掉 → 清 feature 分支
|
||||||
|
for (const [terminal, origin] of panel.terminalOrigin) {
|
||||||
|
if (origin.issueNumber === issueNumber) {
|
||||||
|
try {
|
||||||
|
terminal.dispose()
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// dispose 失败也无所谓,VS Code 自己会清掉关闭事件
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (worktreePath) {
|
||||||
|
const abs = resolveWorktreePath(worktreePath, workspaceRoot)
|
||||||
|
if (fs.existsSync(abs)) {
|
||||||
|
const settingsForHook = getSettings(panel.context)
|
||||||
|
await panel.dispatchWorktreeHook('pre-remove', {
|
||||||
|
workspaceRoot,
|
||||||
|
worktreePath: abs,
|
||||||
|
branch: resolveFeatureBranch({ stateBranch: featureBranchFromState }),
|
||||||
|
issueNumber,
|
||||||
|
mainBranch: settingsForHook.devBranch || 'main',
|
||||||
|
customScriptPath: settingsForHook.worktreePreRemoveScript,
|
||||||
|
})
|
||||||
|
const killed = killProcessesUsingWorktree(abs)
|
||||||
|
if (killed.length > 0) {
|
||||||
|
const list = killed
|
||||||
|
.map(h => ` pid ${h.pid}: ${h.cmd.length > 80 ? `${h.cmd.slice(0, 80)}…` : h.cmd}`)
|
||||||
|
.join('\n')
|
||||||
|
logger.add({
|
||||||
|
level: 'warn',
|
||||||
|
source: 'panel',
|
||||||
|
message: `重置待办前已杀掉占用进程 (issue #${issueNumber})`,
|
||||||
|
details: list,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await removeWorktreeDir(workspaceRoot, abs)
|
||||||
|
logger.add({
|
||||||
|
level: 'info',
|
||||||
|
source: 'panel',
|
||||||
|
message: `已清理 worktree ${worktreePath} (issue #${issueNumber})`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
logger.add({
|
||||||
|
level: 'error',
|
||||||
|
source: 'panel',
|
||||||
|
message: `清理 worktree 失败 (issue #${issueNumber})`,
|
||||||
|
details: message,
|
||||||
|
})
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'toast/show',
|
||||||
|
id: makeNonce(),
|
||||||
|
level: 'error',
|
||||||
|
message: `删除 worktree 失败 #${issueNumber}: ${message}`,
|
||||||
|
dismissOnTimer: 6000,
|
||||||
|
})
|
||||||
|
rollback(fromColumn)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let prHeadRef: string | undefined
|
||||||
|
if (prStr) {
|
||||||
|
const prIndex = Number.parseInt(prStr, 10)
|
||||||
|
if (!Number.isFinite(prIndex)) {
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'toast/show',
|
||||||
|
id: makeNonce(),
|
||||||
|
level: 'error',
|
||||||
|
message: `工单 #${issueNumber} 的 PR 字段无效: ${prStr}`,
|
||||||
|
dismissOnTimer: 6000,
|
||||||
|
})
|
||||||
|
rollback(fromColumn)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const pullRequest = await getPullRequest({
|
||||||
|
host: remote.host,
|
||||||
|
token,
|
||||||
|
owner: remote.owner,
|
||||||
|
repo: remote.repo,
|
||||||
|
index: prIndex,
|
||||||
|
})
|
||||||
|
prHeadRef = pullRequest.headRef
|
||||||
|
if (!pullRequest.merged) {
|
||||||
|
await closeIssue({
|
||||||
|
host: remote.host,
|
||||||
|
token,
|
||||||
|
owner: remote.owner,
|
||||||
|
repo: remote.repo,
|
||||||
|
issueNumber: prIndex,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
logger.add({
|
||||||
|
level: 'error',
|
||||||
|
source: 'panel',
|
||||||
|
message: `处理 PR #${prIndex} 失败 (issue #${issueNumber})`,
|
||||||
|
details: message,
|
||||||
|
})
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'toast/show',
|
||||||
|
id: makeNonce(),
|
||||||
|
level: 'error',
|
||||||
|
message: `处理 PR #${prIndex} 失败 (issue #${issueNumber}): ${message}`,
|
||||||
|
dismissOnTimer: 6000,
|
||||||
|
})
|
||||||
|
rollback(fromColumn)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const featureBranch = resolveFeatureBranch({
|
||||||
|
stateBranch: featureBranchFromState,
|
||||||
|
prHeadRef,
|
||||||
|
})
|
||||||
|
if (featureBranch) {
|
||||||
|
const settingsForBranchCleanup = getSettings(panel.context)
|
||||||
|
await cleanupFeatureBranch({
|
||||||
|
workspaceRoot,
|
||||||
|
branch: featureBranch,
|
||||||
|
issueNumber,
|
||||||
|
devBranch: settingsForBranchCleanup.devBranch,
|
||||||
|
autoBuildBranch: settingsForBranchCleanup.autoBuildBranch,
|
||||||
|
remote,
|
||||||
|
token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 落盘清空实施痕迹 → UI patch 用 null(postMessage 会丢 undefined)
|
||||||
|
try {
|
||||||
|
await panel.mergeIssueState(issueNumber, todoResetStateExtra())
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
logger.add({
|
||||||
|
level: 'error',
|
||||||
|
source: 'panel',
|
||||||
|
message: `持久化 column=todo 失败 (issue #${issueNumber})`,
|
||||||
|
details: message,
|
||||||
|
})
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'toast/show',
|
||||||
|
id: makeNonce(),
|
||||||
|
level: 'error',
|
||||||
|
message: `保存工单 #${issueNumber} 状态失败: ${message}`,
|
||||||
|
dismissOnTimer: 6000,
|
||||||
|
})
|
||||||
|
rollback(fromColumn)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'issue/patch',
|
||||||
|
issueNumber,
|
||||||
|
patch: todoResetUiPatch(),
|
||||||
|
})
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'toast/show',
|
||||||
|
id: makeNonce(),
|
||||||
|
level: 'success',
|
||||||
|
message: `工单 #${issueNumber} 已重置为待办`,
|
||||||
|
dismissOnTimer: 5000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle a "drag to in-progress" kanban move.
|
* Handle a "drag to in-progress" kanban move.
|
||||||
*
|
*
|
||||||
@@ -1401,14 +1692,36 @@ export async function handleSetDependency(panel: KanbanWebviewPanel, issueNumber
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await addDependency({
|
// Gitea 允许多依赖,loader 只取第一个;拖到另一张卡片换父时必须先清掉旧前置。
|
||||||
|
const deps = await getDependencies({
|
||||||
host: remote.host,
|
host: remote.host,
|
||||||
token,
|
token,
|
||||||
owner: remote.owner,
|
owner: remote.owner,
|
||||||
repo: remote.repo,
|
repo: remote.repo,
|
||||||
index: issueNumber,
|
index: issueNumber,
|
||||||
dependencyIndex: prerequisiteNumber,
|
|
||||||
})
|
})
|
||||||
|
for (const dep of deps) {
|
||||||
|
if (dep.number !== prerequisiteNumber) {
|
||||||
|
await removeDependency({
|
||||||
|
host: remote.host,
|
||||||
|
token,
|
||||||
|
owner: remote.owner,
|
||||||
|
repo: remote.repo,
|
||||||
|
index: issueNumber,
|
||||||
|
dependencyIndex: dep.number,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!deps.some(d => d.number === prerequisiteNumber)) {
|
||||||
|
await addDependency({
|
||||||
|
host: remote.host,
|
||||||
|
token,
|
||||||
|
owner: remote.owner,
|
||||||
|
repo: remote.repo,
|
||||||
|
index: issueNumber,
|
||||||
|
dependencyIndex: prerequisiteNumber,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { canResetToTodo, todoResetStateExtra, todoResetUiPatch } from './todoReset'
|
||||||
|
|
||||||
|
describe('canResetToTodo', () => {
|
||||||
|
it('允许从 in-progress 重置', () => {
|
||||||
|
expect(canResetToTodo('in-progress')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('允许从 review 重置', () => {
|
||||||
|
expect(canResetToTodo('review')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('已在 todo 不允许(handler 走 no-op)', () => {
|
||||||
|
expect(canResetToTodo('todo')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('done 不允许(handler 拒绝并 rollback)', () => {
|
||||||
|
expect(canResetToTodo('done')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('todoResetStateExtra', () => {
|
||||||
|
it('清空实施痕迹并回到 todo,保留规划侧字段不在 extra 里', () => {
|
||||||
|
const extra = todoResetStateExtra()
|
||||||
|
expect(extra).toEqual({
|
||||||
|
column: 'todo',
|
||||||
|
branch: '',
|
||||||
|
implementStatus: '',
|
||||||
|
pr: '',
|
||||||
|
prMerged: false,
|
||||||
|
prMergedAt: '',
|
||||||
|
worktreePath: '',
|
||||||
|
implementSessionId: '',
|
||||||
|
reviewSessionId: '',
|
||||||
|
})
|
||||||
|
expect(extra).not.toHaveProperty('sessionId')
|
||||||
|
expect(extra).not.toHaveProperty('planFile')
|
||||||
|
expect(extra).not.toHaveProperty('specFile')
|
||||||
|
expect(extra).not.toHaveProperty('profilePath')
|
||||||
|
expect(extra).not.toHaveProperty('brainstormProfilePath')
|
||||||
|
expect(extra).not.toHaveProperty('testProfilePath')
|
||||||
|
expect(extra).not.toHaveProperty('color')
|
||||||
|
expect(extra).not.toHaveProperty('autoReview')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('todoResetUiPatch', () => {
|
||||||
|
it('用 null 清空 UI 字段,避免 postMessage 丢掉 undefined', () => {
|
||||||
|
expect(todoResetUiPatch()).toEqual({
|
||||||
|
column: 'todo',
|
||||||
|
branch: null,
|
||||||
|
implementStatus: null,
|
||||||
|
pr: null,
|
||||||
|
prMerged: false,
|
||||||
|
prMergedAt: null,
|
||||||
|
worktreePath: null,
|
||||||
|
implementSessionId: null,
|
||||||
|
reviewSessionId: null,
|
||||||
|
worktreeExists: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { IssueColumn } from '../../gitea/types'
|
||||||
|
|
||||||
|
export function canResetToTodo(fromColumn: IssueColumn | undefined): boolean {
|
||||||
|
return fromColumn === 'in-progress' || fromColumn === 'review'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function todoResetStateExtra(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
column: 'todo',
|
||||||
|
branch: '',
|
||||||
|
implementStatus: '',
|
||||||
|
pr: '',
|
||||||
|
prMerged: false,
|
||||||
|
prMergedAt: '',
|
||||||
|
worktreePath: '',
|
||||||
|
implementSessionId: '',
|
||||||
|
reviewSessionId: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function todoResetUiPatch(): {
|
||||||
|
column: 'todo'
|
||||||
|
branch: null
|
||||||
|
implementStatus: null
|
||||||
|
pr: null
|
||||||
|
prMerged: false
|
||||||
|
prMergedAt: null
|
||||||
|
worktreePath: null
|
||||||
|
implementSessionId: null
|
||||||
|
reviewSessionId: null
|
||||||
|
worktreeExists: false
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
column: 'todo',
|
||||||
|
branch: null,
|
||||||
|
implementStatus: null,
|
||||||
|
pr: null,
|
||||||
|
prMerged: false,
|
||||||
|
prMergedAt: null,
|
||||||
|
worktreePath: null,
|
||||||
|
implementSessionId: null,
|
||||||
|
reviewSessionId: null,
|
||||||
|
worktreeExists: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ export type ExtensionToWebview
|
|||||||
= | { type: 'issues/loading' }
|
= | { type: 'issues/loading' }
|
||||||
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean }
|
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean }
|
||||||
| { type: 'issues/error', message: string }
|
| { 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/pr-diff-summary-done', issueNumber: number }
|
||||||
| { type: 'issue/append', issue: Issue, select?: boolean }
|
| { type: 'issue/append', issue: Issue, select?: boolean }
|
||||||
| { type: 'issue/select-by-number', issueNumber: number }
|
| { type: 'issue/select-by-number', issueNumber: number }
|
||||||
|
|||||||
@@ -366,6 +366,7 @@ export function App() {
|
|||||||
onMergeBranch={mergeBranch}
|
onMergeBranch={mergeBranch}
|
||||||
onDeleteIssue={deleteIssue}
|
onDeleteIssue={deleteIssue}
|
||||||
onCloseIssue={closeIssue}
|
onCloseIssue={closeIssue}
|
||||||
|
onResetToTodo={n => changeColumn(n, 'todo', selectedIssue?.source, selectedIssue?.externalId)}
|
||||||
onCloseSessionTab={closeSessionTab}
|
onCloseSessionTab={closeSessionTab}
|
||||||
onStartBrainstormSession={startBrainstormSession}
|
onStartBrainstormSession={startBrainstormSession}
|
||||||
onUpdateAutoReview={updateIssueAutoReview}
|
onUpdateAutoReview={updateIssueAutoReview}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ interface BottomTabsProps {
|
|||||||
onMergeBranch: (issueNumber: number, branch: string) => void
|
onMergeBranch: (issueNumber: number, branch: string) => void
|
||||||
onDeleteIssue?: (issueNumber: number) => void
|
onDeleteIssue?: (issueNumber: number) => void
|
||||||
onCloseIssue: (issueNumber: number) => void
|
onCloseIssue: (issueNumber: number) => void
|
||||||
|
onResetToTodo: (issueNumber: number) => void
|
||||||
onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void
|
onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void
|
||||||
onStartBrainstormSession: (issueNumber: number) => void
|
onStartBrainstormSession: (issueNumber: number) => void
|
||||||
onUpdateAutoReview: (issueNumber: number, value: boolean) => void
|
onUpdateAutoReview: (issueNumber: number, value: boolean) => void
|
||||||
@@ -128,6 +129,7 @@ export function BottomTabs(props: BottomTabsProps) {
|
|||||||
onMergeBranch={props.onMergeBranch}
|
onMergeBranch={props.onMergeBranch}
|
||||||
onDeleteIssue={props.onDeleteIssue}
|
onDeleteIssue={props.onDeleteIssue}
|
||||||
onCloseIssue={props.onCloseIssue}
|
onCloseIssue={props.onCloseIssue}
|
||||||
|
onResetToTodo={props.onResetToTodo}
|
||||||
onCloseSessionTab={props.onCloseSessionTab}
|
onCloseSessionTab={props.onCloseSessionTab}
|
||||||
onStartBrainstormSession={props.onStartBrainstormSession}
|
onStartBrainstormSession={props.onStartBrainstormSession}
|
||||||
onUpdateAutoReview={props.onUpdateAutoReview}
|
onUpdateAutoReview={props.onUpdateAutoReview}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { useMemo, useRef } 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 { ClaudeProfile } from '../hooks/useIssues'
|
||||||
import type { Issue, IssueColumn } from '../types'
|
import type { Issue, IssueColumn } from '../types'
|
||||||
import { COLUMN_LABELS, COLUMN_ORDER } from '../types'
|
import { COLUMN_LABELS, COLUMN_ORDER } from '../types'
|
||||||
@@ -68,6 +68,8 @@ interface IssueDetailPanelProps {
|
|||||||
onDeleteIssue?: (issueNumber: number) => void
|
onDeleteIssue?: (issueNumber: number) => void
|
||||||
/** 关闭 Gitea 工单,不清理本地会话、worktree、PR 或分支。 */
|
/** 关闭 Gitea 工单,不清理本地会话、worktree、PR 或分支。 */
|
||||||
onCloseIssue: (issueNumber: number) => void
|
onCloseIssue: (issueNumber: number) => void
|
||||||
|
/** 将实施中 / 审查中的工单重置回待办(清 worktree / 未合并 PR / feature 分支)。 */
|
||||||
|
onResetToTodo: (issueNumber: number) => void
|
||||||
/** Close the matching session terminal tab. The extension watches
|
/** Close the matching session terminal tab. The extension watches
|
||||||
* onDidCloseTerminal and clears the corresponding `*TabOpen` flag, which
|
* onDidCloseTerminal and clears the corresponding `*TabOpen` flag, which
|
||||||
* makes the X button vanish on its own. */
|
* makes the X button vanish on its own. */
|
||||||
@@ -117,6 +119,7 @@ export function IssueDetailPanel({
|
|||||||
onMergeBranch,
|
onMergeBranch,
|
||||||
onDeleteIssue,
|
onDeleteIssue,
|
||||||
onCloseIssue,
|
onCloseIssue,
|
||||||
|
onResetToTodo,
|
||||||
onCloseSessionTab,
|
onCloseSessionTab,
|
||||||
onStartBrainstormSession,
|
onStartBrainstormSession,
|
||||||
onUpdateAutoReview,
|
onUpdateAutoReview,
|
||||||
@@ -572,6 +575,17 @@ export function IssueDetailPanel({
|
|||||||
avoid acting on a gitea issue of the same number. */}
|
avoid acting on a gitea issue of the same number. */}
|
||||||
{issue.source !== 'youtrack' && (
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onCloseIssue(issue.number)}
|
onClick={() => onCloseIssue(issue.number)}
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
|
closestCenter,
|
||||||
DndContext,
|
DndContext,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
|
pointerWithin,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
} from '@dnd-kit/core'
|
} from '@dnd-kit/core'
|
||||||
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
|
|
||||||
import {
|
import {
|
||||||
SortableContext,
|
|
||||||
arrayMove,
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
sortableKeyboardCoordinates,
|
sortableKeyboardCoordinates,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable'
|
||||||
import { IssueCard } from './IssueCard'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { KanbanColumn } from './KanbanColumn'
|
|
||||||
import { isIssueLocked } from '../lib/dependencies'
|
import { isIssueLocked } from '../lib/dependencies'
|
||||||
import { compareIssuesInColumn } from '../lib/issueSort'
|
import { compareIssuesInColumn } from '../lib/issueSort'
|
||||||
import type { Issue, IssueColumn } from '../types'
|
import { isColumnId, resolveKanbanDrag } from '../lib/kanbanDrag'
|
||||||
import { COLUMN_ORDER } from '../types'
|
import { COLUMN_ORDER } from '../types'
|
||||||
|
import { IssueCard } from './IssueCard'
|
||||||
|
import { KanbanColumn } from './KanbanColumn'
|
||||||
|
|
||||||
interface KanbanBoardProps {
|
interface KanbanBoardProps {
|
||||||
issues: Issue[]
|
issues: Issue[]
|
||||||
@@ -28,27 +31,18 @@ interface KanbanBoardProps {
|
|||||||
onCreateIssue?: () => void
|
onCreateIssue?: () => void
|
||||||
selectedId?: string | null
|
selectedId?: string | null
|
||||||
onSelectIssue?: (id: string | null) => void
|
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
|
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
|
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
|
onDependencyClear?: (issueNumber: number, prerequisiteNumber: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function isColumnId(id: string): id is IssueColumn {
|
const kanbanCollisionDetection: CollisionDetection = (args) => {
|
||||||
return (COLUMN_ORDER as string[]).includes(id)
|
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))
|
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 {
|
function handleDragEnd(event: DragEndEvent): void {
|
||||||
const { active, over } = event
|
const { active, over } = event
|
||||||
setActiveId(null)
|
setActiveId(null)
|
||||||
if (!over)
|
if (!over)
|
||||||
return
|
return
|
||||||
|
|
||||||
const activeIdStr = String(active.id)
|
const result = resolveKanbanDrag({
|
||||||
const overIdStr = String(over.id)
|
issues,
|
||||||
if (activeIdStr === overIdStr)
|
activeId: String(active.id),
|
||||||
|
overId: String(over.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.type === 'none')
|
||||||
return
|
return
|
||||||
|
|
||||||
const activeIndex = issues.findIndex(i => i.id === activeIdStr)
|
if (result.type === 'reorder') {
|
||||||
if (activeIndex < 0)
|
onIssuesChange(arrayMove(issues, result.fromIndex, result.toIndex))
|
||||||
return
|
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 (result.type === 'column') {
|
||||||
if (isColumnId(overIdStr)) {
|
const idx = issues.findIndex(i => i.number === result.issueNumber)
|
||||||
// Drop onto the todo column's empty area while the card is already in
|
if (idx < 0)
|
||||||
// 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)
|
|
||||||
return
|
return
|
||||||
|
const item = issues[idx]
|
||||||
const next = [...issues]
|
const next = [...issues]
|
||||||
next.splice(activeIndex, 1)
|
next.splice(idx, 1)
|
||||||
next.push({ ...activeItem, column: overIdStr })
|
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)
|
onIssuesChange(next)
|
||||||
onColumnChange?.(activeItem.number, overIdStr, activeItem.source, activeItem.externalId)
|
onColumnChange?.(result.issueNumber, result.toColumn, result.source, result.externalId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dropped onto another card
|
if (result.type === 'dependency-set') {
|
||||||
const overIndex = issues.findIndex(i => i.id === overIdStr)
|
const idx = issues.findIndex(i => i.number === result.issueNumber)
|
||||||
if (overIndex < 0)
|
if (idx < 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))
|
|
||||||
return
|
return
|
||||||
}
|
const item = issues[idx]
|
||||||
// Cross-column: drop activeItem at overItem's position in the new column
|
|
||||||
const next = [...issues]
|
const next = [...issues]
|
||||||
next.splice(activeIndex, 1)
|
next.splice(idx, 1)
|
||||||
const newOverIndex = next.findIndex(i => i.id === overIdStr)
|
const overIdx = next.findIndex(i => i.number === result.prerequisiteNumber)
|
||||||
const insertAt = newOverIndex < 0 ? next.length : newOverIndex
|
const insertAt = overIdx < 0 ? next.length : overIdx + 1
|
||||||
next.splice(insertAt, 0, { ...activeItem, column: overItem.column })
|
next.splice(insertAt, 0, { ...item, prerequisite: result.prerequisiteNumber })
|
||||||
onIssuesChange(next)
|
onIssuesChange(next)
|
||||||
onColumnChange?.(activeItem.number, overItem.column, activeItem.source, activeItem.externalId)
|
onDependencySet?.(result.issueNumber, result.prerequisiteNumber)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both in todo: distinguish nest (middle 1/3) vs sibling reorder (edge).
|
const idx = issues.findIndex(i => i.number === result.issueNumber)
|
||||||
const zone = detectDropZone(event)
|
if (idx < 0)
|
||||||
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)
|
|
||||||
return
|
return
|
||||||
}
|
const item = issues[idx]
|
||||||
|
|
||||||
// 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 next = [...issues]
|
const next = [...issues]
|
||||||
next.splice(activeIndex, 1)
|
next.splice(idx, 1)
|
||||||
const newOverIndex = next.findIndex(i => i.id === overIdStr)
|
next.push({ ...item, prerequisite: undefined })
|
||||||
const insertAt = newOverIndex < 0 ? next.length : newOverIndex
|
|
||||||
next.splice(insertAt, 0, { ...activeItem, prerequisite: newParent })
|
|
||||||
onIssuesChange(next)
|
onIssuesChange(next)
|
||||||
if (newParent == null)
|
onDependencyClear?.(result.issueNumber, result.prerequisiteNumber)
|
||||||
onDependencyClear?.(activeItem.number, activeItem.prerequisite!)
|
|
||||||
else
|
|
||||||
onDependencySet?.(activeItem.number, newParent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
|
collisionDetection={kanbanCollisionDetection}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onDragCancel={() => setActiveId(null)}
|
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('丢到另一张待办卡片 = set(over 为前置)', () => {
|
||||||
|
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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@ export type ExtensionToWebview
|
|||||||
= | { type: 'issues/loading' }
|
= | { type: 'issues/loading' }
|
||||||
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean }
|
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean }
|
||||||
| { type: 'issues/error', message: string }
|
| { 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/pr-diff-summary-done', issueNumber: number }
|
||||||
| { type: 'issue/append', issue: Issue, select?: boolean }
|
| { type: 'issue/append', issue: Issue, select?: boolean }
|
||||||
| { type: 'issue/select-by-number', issueNumber: number }
|
| { type: 'issue/select-by-number', issueNumber: number }
|
||||||
|
|||||||
@@ -20,5 +20,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user