Files
superwork/vscode/src/panel/handlers/worktree.ts
T
cruldraandClaude Opus 4.7 cacc60eb0e feat(vscode): worktree 打开方式可选 VS Code/PyCharm,并修复 handleOpenWorktree 不展开 ~ 的打开报错
- worktree.ts: handleOpenWorktree/handleDeleteWorktree/dispatchImplTabPostCloseAsync 三处改用 resolveWorktreePath,修复带 ~ 的存量路径被拼成 <工作区>/~/... 导致打不开
- 新增全局设置 worktreeOpenWith('vscode' | 'pycharm',默认 vscode),贯穿 store/settings handler/messages/KanbanPanel 与 webview 的 messages/useIssues/App/SettingsModal「钩子」分组新增「工作树打开方式」下拉
- handleOpenWorktree 按设置分支:pycharm 走 JetBrains pycharm 启动器,ENOENT 时提示启用命令行启动器

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 05:20:22 +08:00

307 lines
11 KiB
TypeScript

import type { HookContext } from '../../git/worktreeHooks'
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { execFile } from 'node:child_process'
import { commands, Uri, window, workspace } from 'vscode'
import { getToken } from '../../auth/secrets'
import { detectRepo } from '../../git/remote'
import { resolveWorktreePath } from '../../git/worktree'
import {
runImplTabPostCloseHook,
runImplTabPreCreateHook,
runPostCreateHook,
runPreRemoveHook,
} from '../../git/worktreeHooks'
import { logger } from '../../logging/logger'
import { getSettings } from '../../settings/store'
import { makeNonce } from '../KanbanPanel'
/**
* Open the worktree directory in a **new** VS Code window. The Boolean
* third arg to `vscode.openFolder` is "forceNewWindow"; we always force
* a new window so the user keeps the kanban window open in parallel.
*/
export async function handleOpenWorktree(panel: KanbanWebviewPanel, relPath: string): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
void window.showErrorMessage('请先打开一个工作区文件夹')
return
}
// 存量工单可能存了带 `~` 的 worktreePath,必须经 resolveWorktreePath 展开,
// 否则 path.join(workspaceRoot, '~/...') 会拼出不存在的路径。
const abs = resolveWorktreePath(relPath, workspaceRoot)
// PyCharm 分支用 JetBrains 启动器打开目录。错误在回调里自行处理,不要落进
// 下面 openFolder 的 catch。
const { worktreeOpenWith } = getSettings(panel.context)
if (worktreeOpenWith === 'pycharm') {
execFile('pycharm', [abs], (err) => {
if (err) {
const code = (err as NodeJS.ErrnoException).code ?? 'unknown'
const message = code === 'ENOENT'
? '找不到 pycharm 命令,请在 JetBrains Toolbox 里启用命令行启动器或把 pycharm 加入 PATH'
: err.message
void window.showErrorMessage(`用 PyCharm 打开失败: ${message}`)
}
})
return
}
try {
await commands.executeCommand('vscode.openFolder', Uri.file(abs), true)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
void window.showErrorMessage(`打开 worktree 失败: ${message}`)
}
}
/**
* Confirm + run `git worktree remove <abs>` (no --force), then clear
* `worktreePath`/`branch` from the issue's state JSON and refresh the
* board. If `git` rejects due to uncommitted changes we surface stderr
* verbatim — the user can resolve manually and re-try.
*/
export async function handleDeleteWorktree(panel: KanbanWebviewPanel, issueNumber: number, relPath: string): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
void window.showErrorMessage('请先打开一个工作区文件夹')
return
}
const choice = await window.showWarningMessage(
`确认删除 worktree ${relPath}?`,
{ modal: true },
'删除',
)
if (choice !== '删除')
return
const abs = resolveWorktreePath(relPath, workspaceRoot)
// Run the pre-remove lifecycle hook so the user can tear down resources
// (close IDE windows, etc.) before the worktree dir vanishes. Best-effort
// — never blocks the removal.
const settingsForHook = getSettings(panel.context)
await dispatchWorktreeHook(panel, 'pre-remove', {
workspaceRoot,
worktreePath: abs,
branch: '',
issueNumber,
mainBranch: settingsForHook.devBranch || 'main',
customScriptPath: settingsForHook.worktreePreRemoveScript,
})
try {
await new Promise<void>((resolve, reject) => {
execFile(
'git',
['-C', workspaceRoot, 'worktree', 'remove', abs],
{ timeout: 30_000 },
(err, _stdout, stderr) => {
if (err) {
const detail = (stderr ?? '').trim() || err.message
reject(new Error(detail))
return
}
resolve()
},
)
})
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
void window.showErrorMessage(`git worktree remove 失败: ${message}`)
return
}
// Best-effort: clear worktreePath + branch from the state JSON so the
// detail panel reflects reality on the next refresh. Failures here are
// non-fatal (worktree is already gone on disk).
try {
await panel.mergeIssueState(issueNumber, { worktreePath: '', branch: '' })
}
catch (err) {
console.warn('[superpowers] failed to clear worktree state JSON:', err)
}
panel.postMessage({
type: 'issue/patch',
issueNumber,
patch: {
worktreePath: undefined,
branch: undefined,
worktreeExists: false,
},
})
void window.showInformationMessage(`已删除 worktree #${issueNumber}`)
}
/**
* Pre-merge a feature branch into the **main worktree** (workspace root) so the
* user can inspect/test the merge result locally before committing. Runs
* `git merge --no-commit --no-ff <branch>`, which never auto-commits and always
* forces a merge commit structure. Feedback is surfaced via notifications.
*/
export async function handleMergePreview(panel: KanbanWebviewPanel, issueNumber: number, branch: string): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
void window.showErrorMessage('请先打开一个工作区文件夹')
return
}
if (!branch) {
void window.showWarningMessage('该工单没有分支')
return
}
// Best-effort: figure out the branch currently checked out in the main
// worktree so the success message can warn about merging into the wrong
// place. Never blocks the merge.
const currentBranch = await new Promise<string>((resolve) => {
execFile(
'git',
['-C', workspaceRoot, 'rev-parse', '--abbrev-ref', 'HEAD'],
{ timeout: 10_000 },
(err, stdout) => {
resolve(err ? '?' : (stdout ?? '').trim() || '?')
},
)
})
// `git merge` reports conflicts on stdout ("CONFLICT ...") and the
// "Automatic merge failed" line on stderr with a non-zero exit code, so we
// judge success solely by `err == null`.
const result = await new Promise<{ err: Error | null, output: string }>((resolve) => {
execFile(
'git',
['-C', workspaceRoot, 'merge', '--no-commit', '--no-ff', branch],
{ timeout: 60_000 },
(err, stdout, stderr) => {
const output = `${stdout ?? ''}${stderr ?? ''}`.trim()
resolve({ err: err ?? null, output })
},
)
})
if (result.err == null) {
void window.showInformationMessage(`已把 ${branch} 预合并到当前分支 ${currentBranch}(未提交)。检查无误后自行 commit,或执行 git merge --abort 撤销。`)
return
}
const detail = result.output || result.err.message
logger.add({
level: 'warn',
source: 'panel',
message: `git merge --no-commit --no-ff ${branch} 未完成(issue #${issueNumber})`,
details: detail,
})
void window.showWarningMessage(`git merge 未完成(冲突或出错): ${detail}。如需撤销执行 git merge --abort`)
}
/**
* Run a user-provided shell script (post-create / pre-remove) and surface
* the outcome via logger + a toast. Always returns — failures of any
* kind never abort the calling flow, per the lifecycle-hook contract:
* worktree creation / removal is the source of truth, user scripts are
* best-effort sidecars.
*/
export async function dispatchWorktreeHook(
panel: KanbanWebviewPanel,
phase: 'post-create' | 'pre-remove' | 'impl-tab-pre-create' | 'impl-tab-post-close',
ctx: HookContext,
): Promise<void> {
let result
switch (phase) {
case 'post-create': result = await runPostCreateHook(ctx); break
case 'pre-remove': result = await runPreRemoveHook(ctx); break
case 'impl-tab-pre-create': result = await runImplTabPreCreateHook(ctx); break
case 'impl-tab-post-close': result = await runImplTabPostCloseHook(ctx); break
}
// 'skipped' = the script simply isn't on disk. That's the intended
// default state — stay silent, don't pester the user.
if (result.status === 'skipped')
return
if (result.status === 'ok') {
logger.add({
level: 'info',
source: 'panel',
message: `worktree ${phase} 钩子完成 #${ctx.issueNumber}`,
details: `path=${result.scriptPath}\nstdout=${result.stdout ?? ''}\nstderr=${result.stderr ?? ''}`,
})
return
}
// Anything else (failed / timeout / enoent) — warn-level log + a
// toast. ToastLevel only has 'info' | 'success' | 'error'; we use
// 'info' to keep it non-blocking, matching the "doesn't affect main
// flow" semantics. Detailed stdout/stderr stays in the log to avoid
// spamming the toast surface.
const label = {
'post-create': '创建后',
'pre-remove': '删除前',
'impl-tab-pre-create': '实施 tab 创建前',
'impl-tab-post-close': '实施 tab 关闭后',
}[phase]
logger.add({
level: 'warn',
source: 'panel',
message: `worktree ${phase} 钩子失败 #${ctx.issueNumber}: ${result.status}`,
details: [
`path=${result.scriptPath ?? '(unresolved)'}`,
result.exitCode !== undefined ? `exitCode=${result.exitCode}` : null,
result.errorMessage ? `err=${result.errorMessage}` : null,
result.stdout ? `stdout=${result.stdout}` : null,
result.stderr ? `stderr=${result.stderr}` : null,
].filter((line): line is string => line !== null).join('\n'),
})
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'info',
message: `worktree ${label}钩子失败 #${ctx.issueNumber}(不影响后续流程,详情见日志)`,
dismissOnTimer: 5000,
})
}
/**
* 实施 cc tab 被关闭后异步触发 impl-tab-post-close 钩子。
* 由 onDidCloseTerminal 同步回调里 fire-and-forget 调用,所以这里把整个
* 错误 catch 进 log,不向上抛。需要从 state JSON 读 worktreePath / branch。
*/
export async function dispatchImplTabPostCloseAsync(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
try {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot)
return
const remote = await detectRepo(workspaceRoot)
if (!remote)
return
const token = await getToken(panel.context, remote.host)
if (!token)
return
const stateObj = await panel.readIssueState(issueNumber)
const branch = typeof stateObj.branch === 'string' ? stateObj.branch : ''
const wt = typeof stateObj.worktreePath === 'string' ? stateObj.worktreePath : ''
if (!wt)
return
const absWorktree = resolveWorktreePath(wt, workspaceRoot)
const settings = getSettings(panel.context)
await dispatchWorktreeHook(panel, 'impl-tab-post-close', {
workspaceRoot,
worktreePath: absWorktree,
branch,
issueNumber,
mainBranch: settings.devBranch || 'main',
customScriptPath: settings.implTabPostCloseScript,
})
}
catch (err) {
logger.add({
level: 'warn',
source: 'panel',
message: `impl-tab-post-close 钩子调度失败 #${issueNumber}`,
details: err instanceof Error ? err.message : String(err),
})
}
}