From 06fb5ecec2a0a9ad9e800b0935022843f4689f91 Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 26 Aug 2026 13:42:30 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(vscode):=20=E4=BF=AE?= =?UTF-8?q?=E5=A5=BD=E5=8D=95=E4=B8=AA=E4=BC=9A=E8=AF=9D=20tab=20=E4=BB=8E?= =?UTF-8?q?=E7=9C=8B=E6=9D=BF=E7=82=B9=E5=9B=9E=E6=97=B6=E4=B8=8D=E5=8F=8D?= =?UTF-8?q?=E5=90=91=E9=80=89=E4=B8=AD=E6=89=80=E5=B1=9E=E5=B7=A5=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01FAA5YEyR7fzWj7gYQiLSqy --- vscode/src/panel/KanbanPanel.ts | 74 ++++++------------- .../src/panel/handlers/terminalTabs.test.ts | 74 +++++++++++++++++++ vscode/src/panel/handlers/terminalTabs.ts | 51 +++++++++++++ vscode/src/panel/handlers/terminals.ts | 35 +++------ 4 files changed, 157 insertions(+), 77 deletions(-) create mode 100644 vscode/src/panel/handlers/terminalTabs.test.ts create mode 100644 vscode/src/panel/handlers/terminalTabs.ts diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index 8c163d4..fed8bbc 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -33,6 +33,7 @@ import * as profileAssets from './handlers/profileAssets' import * as sessions from './handlers/sessions' import * as settings from './handlers/settings' import * as terminals from './handlers/terminals' +import { focusedTerminal, FocusedTerminalTracker } from './handlers/terminalTabs' import * as toolbar from './handlers/toolbar' import * as worktree from './handlers/worktree' import * as youtrackIssues from './handlers/youtrackIssues' @@ -195,7 +196,7 @@ export class KanbanWebviewPanel { gitStateDisposable: { dispose: () => void } | undefined /** - * 最近一次 onDidChangeActiveTerminal 触发反选 webview 的时间戳(ms epoch)。 + * 最近一次终端 tab 切换触发反选 webview 的时间戳(ms epoch)。 * `handleSessionFocus` 收到 webview 回发的 session/focus 时检查这个, * 距离 <200ms 且工单号相同 → 跳过优先级跳转,避免点审查 tab 自动弹回实施 tab。 */ @@ -205,22 +206,11 @@ export class KanbanWebviewPanel { lastReverseSelectIssueNumber = -1 /** - * 上一次被 `handleActiveTerminalChanged` 处理的 terminal 引用。同一 terminal - * 短时间内重复触发(OSC title 改写、shell prompt 重绘等导致的 onDidChangeTabs - * 误触)直接 noop,避免 webview 反复 setPendingSelectId 造成 UI 闪烁。 - * `onDidCloseTerminal` 中清掉该 ref。 + * 当前有焦点的会话终端 tab。由 tab 事件推导「活动分组的活动 tab」再做 diff, + * 只有焦点真正落到另一个终端 tab 时才反向选中工单:OSC title 改写不会重复 + * 触发,单 tab 分组从看板点回来(无 isActive / activeTerminal 变化)也能触发。 */ - // internal: handler 模块访问 - lastActiveTerminalRef: Terminal | undefined - - /** - * 我们自己 `show()` 引发的 active terminal 变更会回声到 - * `handleActiveTerminalChanged`,若再反向选中工单就会和正向(选中→聚焦) - * 形成死循环、CPU 飙升。handleSessionFocus show 前把目标终端记在这里, - * handleActiveTerminalChanged 命中即吞掉这一次回声(基于引用、不靠时间窗)。 - */ - // internal: handler 模块访问 - programmaticTabReveal: Terminal | undefined + private readonly focusedTerminalTracker = new FocusedTerminalTracker() // internal: context 供 handler 模块访问 private constructor(public readonly context: ExtensionContext, panel: WebviewPanel) { @@ -285,8 +275,6 @@ export class KanbanWebviewPanel { const origin = this.terminalOrigin.get(closed) // 反查 terminalOrigin 给详情面板推 *TabOpen: false,让关闭按钮消失。 this.untrackClosedTerminal(closed) - if (closed === this.lastActiveTerminalRef) - this.lastActiveTerminalRef = undefined if (origin?.kind === 'implement' || origin?.kind === 'test') void this.dispatchImplTabPostCloseAsync(origin.issueNumber) // 冲突解决会话不进 terminalOrigin,按终端名触发 post-close 钩子。 @@ -294,39 +282,12 @@ export class KanbanWebviewPanel { if (conflictMatch) void this.dispatchImplTabPostCloseAsync(Number(conflictMatch[1])) }), - // 用户在 column 2 切换终端 tab 时,反向选中看板上对应的工单卡片。 - window.onDidChangeActiveTerminal((terminal) => { - if (!terminal) - return - this.handleActiveTerminalChanged(terminal) - }), - // 补充监听 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) - } - }), + // 用户把焦点切到 column 2 的终端 tab 时,反向选中看板上对应的工单卡片。 + // 会话终端一律开在编辑区(见 resolveTerminalLocation),tabs API 即唯一真相: + // 分组内切 tab 走 onDidChangeTabs,分组激活(单 tab 分组从看板点回来) + // 走 onDidChangeTabGroups,两者都只是触发一次重新推导。 + window.tabGroups.onDidChangeTabs(() => this.syncFocusedTerminalTab()), + window.tabGroups.onDidChangeTabGroups(() => this.syncFocusedTerminalTab()), ) // Start observing the workspace repo so we can hide the "提交代码" @@ -335,6 +296,17 @@ export class KanbanWebviewPanel { 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 { if (KanbanWebviewPanel.current) { KanbanWebviewPanel.current.panel.reveal(ViewColumn.Active) diff --git a/vscode/src/panel/handlers/terminalTabs.test.ts b/vscode/src/panel/handlers/terminalTabs.test.ts new file mode 100644 index 0000000..029f42b --- /dev/null +++ b/vscode/src/panel/handlers/terminalTabs.test.ts @@ -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() + }) +}) diff --git a/vscode/src/panel/handlers/terminalTabs.ts b/vscode/src/panel/handlers/terminalTabs.ts new file mode 100644 index 0000000..209c040 --- /dev/null +++ b/vscode/src/panel/handlers/terminalTabs.ts @@ -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(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( + 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 { + private current: T | undefined + + /** 返回新获得焦点的终端;焦点未变或离开终端 tab 时返回 undefined。 */ + sync(next: T | undefined): T | undefined { + if (next === this.current) + return undefined + this.current = next + return next + } +} diff --git a/vscode/src/panel/handlers/terminals.ts b/vscode/src/panel/handlers/terminals.ts index 838ea92..88fcbdf 100644 --- a/vscode/src/panel/handlers/terminals.ts +++ b/vscode/src/panel/handlers/terminals.ts @@ -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) {