🐛 fix(vscode): 修好单个会话 tab 从看板点回时不反向选中所属工单
Claude-Session: https://claude.ai/code/session_01FAA5YEyR7fzWj7gYQiLSqy
This commit is contained in:
1 parent
eee0170a63
commit
06fb5ecec2
4 files changed
+157
-77
No files matched your search
@@ -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).
|
||||
*/
|
||||
export function handleSessionFocus(panel: KanbanWebviewPanel, issueNumber: number): void {
|
||||
// 如果这次 session/focus 是 onDidChangeActiveTerminal 反选触发的回路
|
||||
// 如果这次 session/focus 是终端 tab 切换反选触发的回路
|
||||
// (而不是用户主动点卡片切换工单),跳过优先级跳转,否则会把用户刚刚
|
||||
// 点的"审查 tab"弹回到优先级更高的"实施 tab"。
|
||||
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.
|
||||
const newIssueTerm = panel.newIssueTerminals.get(issueNumber)
|
||||
if (newIssueTerm && newIssueTerm.exitStatus === undefined) {
|
||||
if (newIssueTerm !== window.activeTerminal) {
|
||||
panel.programmaticTabReveal = newIssueTerm
|
||||
if (newIssueTerm !== window.activeTerminal)
|
||||
newIssueTerm.show(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
// 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) {
|
||||
const term = findExistingTerminal(panel, name)
|
||||
if (term) {
|
||||
// 已经是 active terminal 就不重复 show(避免无谓的回声事件);
|
||||
// 否则记下程序化 reveal,让随后的 active 变更被吞掉,不回环。
|
||||
if (term !== window.activeTerminal) {
|
||||
panel.programmaticTabReveal = term
|
||||
// preserveFocus 只在分组内切 tab、不激活分组,因此不会被当成
|
||||
// 用户切到终端而反向选中;已是 active terminal 则无需重复 show。
|
||||
if (term !== window.activeTerminal)
|
||||
term.show(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -165,27 +161,14 @@ export function handleSessionFocus(panel: KanbanWebviewPanel, issueNumber: numbe
|
||||
}
|
||||
|
||||
/**
|
||||
* 反向选中:用户在 column 2 切换 terminal tab 时,从 terminal.name 解析
|
||||
* issueNumber 并通知 webview 选中对应工单。
|
||||
* 反向选中:用户把焦点切到 column 2 的某个 terminal tab 时,从 terminal.name
|
||||
* 解析 issueNumber 并通知 webview 选中对应工单。去重与回声抑制由调用方的
|
||||
* `FocusedTerminalTracker` 负责,这里每次调用都视为一次真实切换。
|
||||
*
|
||||
* 终端名按 `issue-${N}-(规划|实施|审查)` 命名;`issue-new-${nonce}-...`
|
||||
* 终端名按 `issue-${N}-(规划|实施|审查|测试)` 命名;`issue-new-${nonce}-...`
|
||||
* 是新建工单流程的占位 tab,没有 issue number,跳过。
|
||||
*/
|
||||
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.
|
||||
const m = terminal.name.match(/^issue-(\d+)-(规划|实施|审查|测试)/)
|
||||
if (m) {
|
||||
|
||||
Reference in new issue
Block a user