🐛 fix(vscode): 修好单个会话 tab 从看板点回时不反向选中所属工单
Claude-Session: https://claude.ai/code/session_01FAA5YEyR7fzWj7gYQiLSqy
This commit is contained in:
@@ -33,6 +33,7 @@ import * as profileAssets from './handlers/profileAssets'
|
|||||||
import * as sessions from './handlers/sessions'
|
import * as sessions from './handlers/sessions'
|
||||||
import * as settings from './handlers/settings'
|
import * as settings from './handlers/settings'
|
||||||
import * as terminals from './handlers/terminals'
|
import * as terminals from './handlers/terminals'
|
||||||
|
import { focusedTerminal, FocusedTerminalTracker } from './handlers/terminalTabs'
|
||||||
import * as toolbar from './handlers/toolbar'
|
import * as toolbar from './handlers/toolbar'
|
||||||
import * as worktree from './handlers/worktree'
|
import * as worktree from './handlers/worktree'
|
||||||
import * as youtrackIssues from './handlers/youtrackIssues'
|
import * as youtrackIssues from './handlers/youtrackIssues'
|
||||||
@@ -195,7 +196,7 @@ export class KanbanWebviewPanel {
|
|||||||
gitStateDisposable: { dispose: () => void } | undefined
|
gitStateDisposable: { dispose: () => void } | undefined
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 最近一次 onDidChangeActiveTerminal 触发反选 webview 的时间戳(ms epoch)。
|
* 最近一次终端 tab 切换触发反选 webview 的时间戳(ms epoch)。
|
||||||
* `handleSessionFocus` 收到 webview 回发的 session/focus 时检查这个,
|
* `handleSessionFocus` 收到 webview 回发的 session/focus 时检查这个,
|
||||||
* 距离 <200ms 且工单号相同 → 跳过优先级跳转,避免点审查 tab 自动弹回实施 tab。
|
* 距离 <200ms 且工单号相同 → 跳过优先级跳转,避免点审查 tab 自动弹回实施 tab。
|
||||||
*/
|
*/
|
||||||
@@ -205,22 +206,11 @@ export class KanbanWebviewPanel {
|
|||||||
lastReverseSelectIssueNumber = -1
|
lastReverseSelectIssueNumber = -1
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上一次被 `handleActiveTerminalChanged` 处理的 terminal 引用。同一 terminal
|
* 当前有焦点的会话终端 tab。由 tab 事件推导「活动分组的活动 tab」再做 diff,
|
||||||
* 短时间内重复触发(OSC title 改写、shell prompt 重绘等导致的 onDidChangeTabs
|
* 只有焦点真正落到另一个终端 tab 时才反向选中工单:OSC title 改写不会重复
|
||||||
* 误触)直接 noop,避免 webview 反复 setPendingSelectId 造成 UI 闪烁。
|
* 触发,单 tab 分组从看板点回来(无 isActive / activeTerminal 变化)也能触发。
|
||||||
* `onDidCloseTerminal` 中清掉该 ref。
|
|
||||||
*/
|
*/
|
||||||
// internal: handler 模块访问
|
private readonly focusedTerminalTracker = new FocusedTerminalTracker<Terminal>()
|
||||||
lastActiveTerminalRef: Terminal | undefined
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 我们自己 `show()` 引发的 active terminal 变更会回声到
|
|
||||||
* `handleActiveTerminalChanged`,若再反向选中工单就会和正向(选中→聚焦)
|
|
||||||
* 形成死循环、CPU 飙升。handleSessionFocus show 前把目标终端记在这里,
|
|
||||||
* handleActiveTerminalChanged 命中即吞掉这一次回声(基于引用、不靠时间窗)。
|
|
||||||
*/
|
|
||||||
// internal: handler 模块访问
|
|
||||||
programmaticTabReveal: Terminal | undefined
|
|
||||||
|
|
||||||
// internal: context 供 handler 模块访问
|
// internal: context 供 handler 模块访问
|
||||||
private constructor(public readonly context: ExtensionContext, panel: WebviewPanel) {
|
private constructor(public readonly context: ExtensionContext, panel: WebviewPanel) {
|
||||||
@@ -285,8 +275,6 @@ export class KanbanWebviewPanel {
|
|||||||
const origin = this.terminalOrigin.get(closed)
|
const origin = this.terminalOrigin.get(closed)
|
||||||
// 反查 terminalOrigin 给详情面板推 *TabOpen: false,让关闭按钮消失。
|
// 反查 terminalOrigin 给详情面板推 *TabOpen: false,让关闭按钮消失。
|
||||||
this.untrackClosedTerminal(closed)
|
this.untrackClosedTerminal(closed)
|
||||||
if (closed === this.lastActiveTerminalRef)
|
|
||||||
this.lastActiveTerminalRef = undefined
|
|
||||||
if (origin?.kind === 'implement' || origin?.kind === 'test')
|
if (origin?.kind === 'implement' || origin?.kind === 'test')
|
||||||
void this.dispatchImplTabPostCloseAsync(origin.issueNumber)
|
void this.dispatchImplTabPostCloseAsync(origin.issueNumber)
|
||||||
// 冲突解决会话不进 terminalOrigin,按终端名触发 post-close 钩子。
|
// 冲突解决会话不进 terminalOrigin,按终端名触发 post-close 钩子。
|
||||||
@@ -294,39 +282,12 @@ export class KanbanWebviewPanel {
|
|||||||
if (conflictMatch)
|
if (conflictMatch)
|
||||||
void this.dispatchImplTabPostCloseAsync(Number(conflictMatch[1]))
|
void this.dispatchImplTabPostCloseAsync(Number(conflictMatch[1]))
|
||||||
}),
|
}),
|
||||||
// 用户在 column 2 切换终端 tab 时,反向选中看板上对应的工单卡片。
|
// 用户把焦点切到 column 2 的终端 tab 时,反向选中看板上对应的工单卡片。
|
||||||
window.onDidChangeActiveTerminal((terminal) => {
|
// 会话终端一律开在编辑区(见 resolveTerminalLocation),tabs API 即唯一真相:
|
||||||
if (!terminal)
|
// 分组内切 tab 走 onDidChangeTabs,分组激活(单 tab 分组从看板点回来)
|
||||||
return
|
// 走 onDidChangeTabGroups,两者都只是触发一次重新推导。
|
||||||
this.handleActiveTerminalChanged(terminal)
|
window.tabGroups.onDidChangeTabs(() => this.syncFocusedTerminalTab()),
|
||||||
}),
|
window.tabGroups.onDidChangeTabGroups(() => this.syncFocusedTerminalTab()),
|
||||||
// 补充监听 tab 切换事件:当 column 2 只有一个 terminal tab 时,
|
|
||||||
// 它始终是 active terminal,`onDidChangeActiveTerminal` 不会触发;
|
|
||||||
// 但用户点击该 tab 仍会让它成为 active tab,`onDidChangeTabs` 会以
|
|
||||||
// `changed` + `isActive=true` 的形式投递事件。两者并存时
|
|
||||||
// `handleActiveTerminalChanged` 内的 `lastReverseSelectAt` 时间窗
|
|
||||||
// 已能去重,不会回环放大。
|
|
||||||
window.tabGroups.onDidChangeTabs((e) => {
|
|
||||||
// e.opened 也要处理:新开的终端 tab(如双击会话 id 打开)走 opened,
|
|
||||||
// 不在 changed 里,且 show(preserveFocus) 不一定触发 onDidChangeActiveTerminal,
|
|
||||||
// 否则新 tab 不会反向选中对应工单。
|
|
||||||
for (const tab of [...e.opened, ...e.changed]) {
|
|
||||||
if (!tab.isActive)
|
|
||||||
continue
|
|
||||||
if (!(tab.input instanceof TabInputTerminal))
|
|
||||||
continue
|
|
||||||
// TabInputTerminal 只有构造器、无任何字段,只能用 tab.label
|
|
||||||
// 反查 `window.terminals` 里的实例。终端 name 常被 shell 的
|
|
||||||
// OSC title 序列改写(追加 git branch 等后缀),所以双向 startsWith
|
|
||||||
// 兜底,参考 `injectIntoImplTerminal` 里的命名匹配策略。
|
|
||||||
const label = tab.label
|
|
||||||
const term = window.terminals.find(
|
|
||||||
t => label.startsWith(t.name) || t.name.startsWith(label),
|
|
||||||
)
|
|
||||||
if (term)
|
|
||||||
this.handleActiveTerminalChanged(term)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Start observing the workspace repo so we can hide the "提交代码"
|
// Start observing the workspace repo so we can hide the "提交代码"
|
||||||
@@ -335,6 +296,17 @@ export class KanbanWebviewPanel {
|
|||||||
toolbar.setupGitWatcher(this)
|
toolbar.setupGitWatcher(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private syncFocusedTerminalTab(): void {
|
||||||
|
const term = focusedTerminal(
|
||||||
|
window.tabGroups.activeTabGroup.activeTab,
|
||||||
|
input => input instanceof TabInputTerminal,
|
||||||
|
window.terminals,
|
||||||
|
)
|
||||||
|
const activated = this.focusedTerminalTracker.sync(term)
|
||||||
|
if (activated)
|
||||||
|
this.handleActiveTerminalChanged(activated)
|
||||||
|
}
|
||||||
|
|
||||||
static createOrShow(context: ExtensionContext): void {
|
static createOrShow(context: ExtensionContext): void {
|
||||||
if (KanbanWebviewPanel.current) {
|
if (KanbanWebviewPanel.current) {
|
||||||
KanbanWebviewPanel.current.panel.reveal(ViewColumn.Active)
|
KanbanWebviewPanel.current.panel.reveal(ViewColumn.Active)
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { findTerminalByTabLabel, focusedTerminal, FocusedTerminalTracker } from './terminalTabs'
|
||||||
|
|
||||||
|
const t371 = { name: 'issue-371-测试' }
|
||||||
|
const t257 = { name: 'issue-257-实施 (feature/billing)' }
|
||||||
|
const t37 = { name: 'issue-37-测试' }
|
||||||
|
const terminals = [t37, t371, t257]
|
||||||
|
|
||||||
|
describe('findTerminalByTabLabel', () => {
|
||||||
|
it('精确匹配优先', () => {
|
||||||
|
expect(findTerminalByTabLabel(terminals, 'issue-371-测试')).toBe(t371)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tab label 被 OSC title 追加后缀时按前缀匹配', () => {
|
||||||
|
expect(findTerminalByTabLabel(terminals, 'issue-371-测试 (main)')).toBe(t371)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('terminal name 带后缀而 label 是原名时也能匹配', () => {
|
||||||
|
expect(findTerminalByTabLabel(terminals, 'issue-257-实施')).toBe(t257)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('issue-37 不会误配 issue-371', () => {
|
||||||
|
expect(findTerminalByTabLabel(terminals, 'issue-37-测试')).toBe(t37)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('找不到返回 undefined', () => {
|
||||||
|
expect(findTerminalByTabLabel(terminals, 'Project Dashboard')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('focusedTerminal', () => {
|
||||||
|
const isTerminal = (input: unknown): boolean => input === 'terminal'
|
||||||
|
|
||||||
|
it('活动 tab 是终端 → 解析出对应终端', () => {
|
||||||
|
expect(focusedTerminal({ label: 'issue-371-测试', input: 'terminal' }, isTerminal, terminals)).toBe(t371)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('活动 tab 不是终端(看板 webview)→ undefined', () => {
|
||||||
|
expect(focusedTerminal({ label: 'Superpowers Kanban', input: 'webview' }, isTerminal, terminals)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('没有活动 tab → undefined', () => {
|
||||||
|
expect(focusedTerminal(undefined, isTerminal, terminals)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('focusedTerminalTracker', () => {
|
||||||
|
it('只开一个终端 tab:点回看板再点回该 tab 要再次触发', () => {
|
||||||
|
const tracker = new FocusedTerminalTracker<{ name: string }>()
|
||||||
|
expect(tracker.sync(t371)).toBe(t371)
|
||||||
|
expect(tracker.sync(undefined)).toBeUndefined()
|
||||||
|
expect(tracker.sync(t371)).toBe(t371)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('同一终端重复上报(label 改写)不触发', () => {
|
||||||
|
const tracker = new FocusedTerminalTracker<{ name: string }>()
|
||||||
|
expect(tracker.sync(t371)).toBe(t371)
|
||||||
|
expect(tracker.sync(t371)).toBeUndefined()
|
||||||
|
expect(tracker.sync(t371)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('在终端之间切换触发新终端', () => {
|
||||||
|
const tracker = new FocusedTerminalTracker<{ name: string }>()
|
||||||
|
expect(tracker.sync(t371)).toBe(t371)
|
||||||
|
expect(tracker.sync(t257)).toBe(t257)
|
||||||
|
expect(tracker.sync(t371)).toBe(t371)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('焦点离开终端不触发', () => {
|
||||||
|
const tracker = new FocusedTerminalTracker<{ name: string }>()
|
||||||
|
tracker.sync(t371)
|
||||||
|
expect(tracker.sync(undefined)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* 「用户把焦点切到哪个会话终端 tab」的纯逻辑,与 vscode API 解耦以便单测。
|
||||||
|
*
|
||||||
|
* 反向选中工单只关心一件事:活动分组的活动 tab 是不是某个会话终端。
|
||||||
|
* 由 tab 事件推导这个状态再与上次比较,而不是逐个事件猜测用户意图——
|
||||||
|
* 这样单 tab 分组重新激活(无 isActive 变化、无 activeTerminal 变化)
|
||||||
|
* 与 OSC title 改写(同一终端反复上报)都能得到正确结果。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TerminalLike {
|
||||||
|
readonly name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabLike {
|
||||||
|
readonly label: string
|
||||||
|
readonly input: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `TabInputTerminal` 没有任何字段,只能用 tab.label 反查 `window.terminals`。
|
||||||
|
* 终端 name 常被 shell 的 OSC title 序列改写(追加 git branch 等后缀),
|
||||||
|
* 精确匹配失败后再双向 startsWith 兜底。
|
||||||
|
*/
|
||||||
|
export function findTerminalByTabLabel<T extends TerminalLike>(terminals: readonly T[], label: string): T | undefined {
|
||||||
|
return terminals.find(t => t.name === label)
|
||||||
|
?? terminals.find(t => label.startsWith(t.name) || t.name.startsWith(label))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 活动 tab 对应的会话终端;活动 tab 不是终端(如看板 webview)时为 undefined。 */
|
||||||
|
export function focusedTerminal<T extends TerminalLike>(
|
||||||
|
activeTab: TabLike | undefined,
|
||||||
|
isTerminalTab: (input: unknown) => boolean,
|
||||||
|
terminals: readonly T[],
|
||||||
|
): T | undefined {
|
||||||
|
if (!activeTab || !isTerminalTab(activeTab.input))
|
||||||
|
return undefined
|
||||||
|
return findTerminalByTabLabel(terminals, activeTab.label)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 记住上次有焦点的终端,只在焦点真正落到「另一个」终端 tab 上时报告。 */
|
||||||
|
export class FocusedTerminalTracker<T> {
|
||||||
|
private current: T | undefined
|
||||||
|
|
||||||
|
/** 返回新获得焦点的终端;焦点未变或离开终端 tab 时返回 undefined。 */
|
||||||
|
sync(next: T | undefined): T | undefined {
|
||||||
|
if (next === this.current)
|
||||||
|
return undefined
|
||||||
|
this.current = next
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,7 +118,7 @@ export function findExistingTerminal(panel: KanbanWebviewPanel, expectedName: st
|
|||||||
* no-op (user has to press Enter to spawn one).
|
* no-op (user has to press Enter to spawn one).
|
||||||
*/
|
*/
|
||||||
export function handleSessionFocus(panel: KanbanWebviewPanel, issueNumber: number): void {
|
export function handleSessionFocus(panel: KanbanWebviewPanel, issueNumber: number): void {
|
||||||
// 如果这次 session/focus 是 onDidChangeActiveTerminal 反选触发的回路
|
// 如果这次 session/focus 是终端 tab 切换反选触发的回路
|
||||||
// (而不是用户主动点卡片切换工单),跳过优先级跳转,否则会把用户刚刚
|
// (而不是用户主动点卡片切换工单),跳过优先级跳转,否则会把用户刚刚
|
||||||
// 点的"审查 tab"弹回到优先级更高的"实施 tab"。
|
// 点的"审查 tab"弹回到优先级更高的"实施 tab"。
|
||||||
const REVERSE_LOOP_WINDOW_MS = 200
|
const REVERSE_LOOP_WINDOW_MS = 200
|
||||||
@@ -135,10 +135,8 @@ export function handleSessionFocus(panel: KanbanWebviewPanel, issueNumber: numbe
|
|||||||
// side-map is the only way to find it by issueNumber.
|
// side-map is the only way to find it by issueNumber.
|
||||||
const newIssueTerm = panel.newIssueTerminals.get(issueNumber)
|
const newIssueTerm = panel.newIssueTerminals.get(issueNumber)
|
||||||
if (newIssueTerm && newIssueTerm.exitStatus === undefined) {
|
if (newIssueTerm && newIssueTerm.exitStatus === undefined) {
|
||||||
if (newIssueTerm !== window.activeTerminal) {
|
if (newIssueTerm !== window.activeTerminal)
|
||||||
panel.programmaticTabReveal = newIssueTerm
|
|
||||||
newIssueTerm.show(true)
|
newIssueTerm.show(true)
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Priority 1-4: 实施 > 规划 > 审查 > 测试. Match by terminal.name since we know
|
// Priority 1-4: 实施 > 规划 > 审查 > 测试. Match by terminal.name since we know
|
||||||
@@ -152,12 +150,10 @@ export function handleSessionFocus(panel: KanbanWebviewPanel, issueNumber: numbe
|
|||||||
for (const name of namePriority) {
|
for (const name of namePriority) {
|
||||||
const term = findExistingTerminal(panel, name)
|
const term = findExistingTerminal(panel, name)
|
||||||
if (term) {
|
if (term) {
|
||||||
// 已经是 active terminal 就不重复 show(避免无谓的回声事件);
|
// preserveFocus 只在分组内切 tab、不激活分组,因此不会被当成
|
||||||
// 否则记下程序化 reveal,让随后的 active 变更被吞掉,不回环。
|
// 用户切到终端而反向选中;已是 active terminal 则无需重复 show。
|
||||||
if (term !== window.activeTerminal) {
|
if (term !== window.activeTerminal)
|
||||||
panel.programmaticTabReveal = term
|
|
||||||
term.show(true)
|
term.show(true)
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,27 +161,14 @@ export function handleSessionFocus(panel: KanbanWebviewPanel, issueNumber: numbe
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 反向选中:用户在 column 2 切换 terminal tab 时,从 terminal.name 解析
|
* 反向选中:用户把焦点切到 column 2 的某个 terminal tab 时,从 terminal.name
|
||||||
* issueNumber 并通知 webview 选中对应工单。
|
* 解析 issueNumber 并通知 webview 选中对应工单。去重与回声抑制由调用方的
|
||||||
|
* `FocusedTerminalTracker` 负责,这里每次调用都视为一次真实切换。
|
||||||
*
|
*
|
||||||
* 终端名按 `issue-${N}-(规划|实施|审查)` 命名;`issue-new-${nonce}-...`
|
* 终端名按 `issue-${N}-(规划|实施|审查|测试)` 命名;`issue-new-${nonce}-...`
|
||||||
* 是新建工单流程的占位 tab,没有 issue number,跳过。
|
* 是新建工单流程的占位 tab,没有 issue number,跳过。
|
||||||
*/
|
*/
|
||||||
export function handleActiveTerminalChanged(panel: KanbanWebviewPanel, terminal: Terminal): void {
|
export function handleActiveTerminalChanged(panel: KanbanWebviewPanel, terminal: Terminal): void {
|
||||||
// Dedupe by reference: OSC title rewrites / shell prompt updates fire
|
|
||||||
// onDidChangeTabs repeatedly for the same terminal. Skip if it's the
|
|
||||||
// same reference we just handled.
|
|
||||||
// 吞掉「我们自己 show() 引发的」active 变更回声:handleSessionFocus 在程序化
|
|
||||||
// show 前把目标终端记到 programmaticTabReveal,这里命中就 return,不反向选中,
|
|
||||||
// 从根上打断 选中→聚焦→选中 的死循环(基于引用,不依赖脆弱的时间窗)。
|
|
||||||
// 每次调用都清空,因此只压制紧随其后的那一次回声,用户真实点 tab 不受影响。
|
|
||||||
const reveal = panel.programmaticTabReveal
|
|
||||||
panel.programmaticTabReveal = undefined
|
|
||||||
if (terminal === reveal)
|
|
||||||
return
|
|
||||||
if (terminal === panel.lastActiveTerminalRef)
|
|
||||||
return
|
|
||||||
panel.lastActiveTerminalRef = terminal
|
|
||||||
// Primary: `issue-${N}-(规划|实施|审查|测试)` — the steady-state naming.
|
// Primary: `issue-${N}-(规划|实施|审查|测试)` — the steady-state naming.
|
||||||
const m = terminal.name.match(/^issue-(\d+)-(规划|实施|审查|测试)/)
|
const m = terminal.name.match(/^issue-(\d+)-(规划|实施|审查|测试)/)
|
||||||
if (m) {
|
if (m) {
|
||||||
|
|||||||
Reference in New Issue
Block a user