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>
This commit is contained in:
2026-06-24 05:20:22 +08:00
co-authored by Claude Opus 4.7
parent 5e7a3d20d9
commit cacc60eb0e
9 changed files with 78 additions and 4 deletions
+2
View File
@@ -841,6 +841,7 @@ export class KanbanWebviewPanel {
devBranch: s.devBranch, devBranch: s.devBranch,
autoBuildBranch: s.autoBuildBranch, autoBuildBranch: s.autoBuildBranch,
worktreeDirectory: s.worktreeDirectory, worktreeDirectory: s.worktreeDirectory,
worktreeOpenWith: s.worktreeOpenWith,
worktreePostCreateScript: s.worktreePostCreateScript, worktreePostCreateScript: s.worktreePostCreateScript,
worktreePreRemoveScript: s.worktreePreRemoveScript, worktreePreRemoveScript: s.worktreePreRemoveScript,
implTabPreCreateScript: s.implTabPreCreateScript, implTabPreCreateScript: s.implTabPreCreateScript,
@@ -901,6 +902,7 @@ export class KanbanWebviewPanel {
devBranch: s.devBranch, devBranch: s.devBranch,
autoBuildBranch: s.autoBuildBranch, autoBuildBranch: s.autoBuildBranch,
worktreeDirectory: s.worktreeDirectory, worktreeDirectory: s.worktreeDirectory,
worktreeOpenWith: s.worktreeOpenWith,
worktreePostCreateScript: s.worktreePostCreateScript, worktreePostCreateScript: s.worktreePostCreateScript,
worktreePreRemoveScript: s.worktreePreRemoveScript, worktreePreRemoveScript: s.worktreePreRemoveScript,
implTabPreCreateScript: s.implTabPreCreateScript, implTabPreCreateScript: s.implTabPreCreateScript,
+4
View File
@@ -26,6 +26,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
devBranch: string devBranch: string
autoBuildBranch: string autoBuildBranch: string
worktreeDirectory: string worktreeDirectory: string
worktreeOpenWith: 'vscode' | 'pycharm'
worktreePostCreateScript: string worktreePostCreateScript: string
worktreePreRemoveScript: string worktreePreRemoveScript: string
implTabPreCreateScript: string implTabPreCreateScript: string
@@ -76,6 +77,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
devBranch: trimmedDevBranch || prev.devBranch, devBranch: trimmedDevBranch || prev.devBranch,
autoBuildBranch: trimmedAutoBuildBranch, autoBuildBranch: trimmedAutoBuildBranch,
worktreeDirectory: trimmedWorktreeDir || prev.worktreeDirectory, worktreeDirectory: trimmedWorktreeDir || prev.worktreeDirectory,
worktreeOpenWith: payload.worktreeOpenWith || prev.worktreeOpenWith,
worktreePostCreateScript: trimmedPostCreate, worktreePostCreateScript: trimmedPostCreate,
worktreePreRemoveScript: trimmedPreRemove, worktreePreRemoveScript: trimmedPreRemove,
implTabPreCreateScript: trimmedImplPre, implTabPreCreateScript: trimmedImplPre,
@@ -95,6 +97,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
devBranch: trimmedDevBranch, devBranch: trimmedDevBranch,
autoBuildBranch: trimmedAutoBuildBranch, autoBuildBranch: trimmedAutoBuildBranch,
worktreeDirectory: trimmedWorktreeDir, worktreeDirectory: trimmedWorktreeDir,
worktreeOpenWith: payload.worktreeOpenWith,
worktreePostCreateScript: trimmedPostCreate, worktreePostCreateScript: trimmedPostCreate,
worktreePreRemoveScript: trimmedPreRemove, worktreePreRemoveScript: trimmedPreRemove,
implTabPreCreateScript: trimmedImplPre, implTabPreCreateScript: trimmedImplPre,
@@ -175,6 +178,7 @@ export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Prom
devBranch: s.devBranch, devBranch: s.devBranch,
autoBuildBranch: s.autoBuildBranch, autoBuildBranch: s.autoBuildBranch,
worktreeDirectory: s.worktreeDirectory, worktreeDirectory: s.worktreeDirectory,
worktreeOpenWith: s.worktreeOpenWith,
worktreePostCreateScript: s.worktreePostCreateScript, worktreePostCreateScript: s.worktreePostCreateScript,
worktreePreRemoveScript: s.worktreePreRemoveScript, worktreePreRemoveScript: s.worktreePreRemoveScript,
implTabPreCreateScript: s.implTabPreCreateScript, implTabPreCreateScript: s.implTabPreCreateScript,
+23 -4
View File
@@ -1,10 +1,10 @@
import type { HookContext } from '../../git/worktreeHooks' import type { HookContext } from '../../git/worktreeHooks'
import type { KanbanWebviewPanel } from '../KanbanPanel' import type { KanbanWebviewPanel } from '../KanbanPanel'
import { execFile } from 'node:child_process' import { execFile } from 'node:child_process'
import * as path from 'node:path'
import { commands, Uri, window, workspace } from 'vscode' import { commands, Uri, window, workspace } from 'vscode'
import { getToken } from '../../auth/secrets' import { getToken } from '../../auth/secrets'
import { detectRepo } from '../../git/remote' import { detectRepo } from '../../git/remote'
import { resolveWorktreePath } from '../../git/worktree'
import { import {
runImplTabPostCloseHook, runImplTabPostCloseHook,
runImplTabPreCreateHook, runImplTabPreCreateHook,
@@ -26,7 +26,26 @@ export async function handleOpenWorktree(panel: KanbanWebviewPanel, relPath: str
void window.showErrorMessage('请先打开一个工作区文件夹') void window.showErrorMessage('请先打开一个工作区文件夹')
return return
} }
const abs = path.isAbsolute(relPath) ? relPath : path.join(workspaceRoot, relPath) // 存量工单可能存了带 `~` 的 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 { try {
await commands.executeCommand('vscode.openFolder', Uri.file(abs), true) await commands.executeCommand('vscode.openFolder', Uri.file(abs), true)
} }
@@ -56,7 +75,7 @@ export async function handleDeleteWorktree(panel: KanbanWebviewPanel, issueNumbe
if (choice !== '删除') if (choice !== '删除')
return return
const abs = path.isAbsolute(relPath) ? relPath : path.join(workspaceRoot, relPath) const abs = resolveWorktreePath(relPath, workspaceRoot)
// Run the pre-remove lifecycle hook so the user can tear down resources // Run the pre-remove lifecycle hook so the user can tear down resources
// (close IDE windows, etc.) before the worktree dir vanishes. Best-effort // (close IDE windows, etc.) before the worktree dir vanishes. Best-effort
@@ -265,7 +284,7 @@ export async function dispatchImplTabPostCloseAsync(panel: KanbanWebviewPanel, i
const wt = typeof stateObj.worktreePath === 'string' ? stateObj.worktreePath : '' const wt = typeof stateObj.worktreePath === 'string' ? stateObj.worktreePath : ''
if (!wt) if (!wt)
return return
const absWorktree = path.isAbsolute(wt) ? wt : path.join(workspaceRoot, wt) const absWorktree = resolveWorktreePath(wt, workspaceRoot)
const settings = getSettings(panel.context) const settings = getSettings(panel.context)
await dispatchWorktreeHook(panel, 'impl-tab-post-close', { await dispatchWorktreeHook(panel, 'impl-tab-post-close', {
workspaceRoot, workspaceRoot,
+2
View File
@@ -62,6 +62,7 @@ export type ExtensionToWebview
devBranch: string devBranch: string
autoBuildBranch: string autoBuildBranch: string
worktreeDirectory: string worktreeDirectory: string
worktreeOpenWith: 'vscode' | 'pycharm'
worktreePostCreateScript: string worktreePostCreateScript: string
worktreePreRemoveScript: string worktreePreRemoveScript: string
implTabPreCreateScript: string implTabPreCreateScript: string
@@ -117,6 +118,7 @@ export type WebviewToExtension
devBranch: string devBranch: string
autoBuildBranch: string autoBuildBranch: string
worktreeDirectory: string worktreeDirectory: string
worktreeOpenWith: 'vscode' | 'pycharm'
worktreePostCreateScript: string worktreePostCreateScript: string
worktreePreRemoveScript: string worktreePreRemoveScript: string
implTabPreCreateScript: string implTabPreCreateScript: string
+13
View File
@@ -94,6 +94,12 @@ export interface Settings {
* `~/Sources/worktree/$project_name/$feature_name`". * `~/Sources/worktree/$project_name/$feature_name`".
*/ */
worktreeDirectory: string worktreeDirectory: string
/**
* Which editor opens a worktree when the user clicks it in the detail
* panel. `'pycharm'` shells out to the JetBrains `pycharm` launcher;
* `'vscode'` (default) opens a new VS Code window.
*/
worktreeOpenWith: 'vscode' | 'pycharm'
/** /**
* Path to a user-provided shell script that runs *after* the extension * Path to a user-provided shell script that runs *after* the extension
* creates a worktree via `git worktree add`. Empty string means use the * creates a worktree via `git worktree add`. Empty string means use the
@@ -166,6 +172,7 @@ function defaults(ctx: ExtensionContext): Settings {
devBranch: 'main', devBranch: 'main',
autoBuildBranch: '', autoBuildBranch: '',
worktreeDirectory: '~/Sources/worktree/$project_name/$feature_name', worktreeDirectory: '~/Sources/worktree/$project_name/$feature_name',
worktreeOpenWith: 'vscode',
worktreePostCreateScript: '', worktreePostCreateScript: '',
worktreePreRemoveScript: '', worktreePreRemoveScript: '',
implTabPreCreateScript: '', implTabPreCreateScript: '',
@@ -214,6 +221,11 @@ export function getSettings(ctx: ExtensionContext): Settings {
const worktreeDirectory = typeof stored.worktreeDirectory === 'string' && stored.worktreeDirectory.length > 0 const worktreeDirectory = typeof stored.worktreeDirectory === 'string' && stored.worktreeDirectory.length > 0
? stored.worktreeDirectory ? stored.worktreeDirectory
: base.worktreeDirectory : base.worktreeDirectory
// worktreeOpenWith is an enum — only the two known values are valid; anything
// else (legacy installs, garbage) collapses to the default.
const worktreeOpenWith = stored.worktreeOpenWith === 'pycharm' || stored.worktreeOpenWith === 'vscode'
? stored.worktreeOpenWith
: base.worktreeOpenWith
// worktree hook script paths: '' is meaningful (= "use default // worktree hook script paths: '' is meaningful (= "use default
// .spx/worktree-*.sh"), so don't coerce. Non-string legacy values fall // .spx/worktree-*.sh"), so don't coerce. Non-string legacy values fall
// back to ''. The hook runner resolves '' to the default at execution // back to ''. The hook runner resolves '' to the default at execution
@@ -253,6 +265,7 @@ export function getSettings(ctx: ExtensionContext): Settings {
devBranch, devBranch,
autoBuildBranch, autoBuildBranch,
worktreeDirectory, worktreeDirectory,
worktreeOpenWith,
worktreePostCreateScript, worktreePostCreateScript,
worktreePreRemoveScript, worktreePreRemoveScript,
implTabPreCreateScript, implTabPreCreateScript,
+1
View File
@@ -446,6 +446,7 @@ export function App() {
initialDevBranch={settings?.devBranch ?? 'main'} initialDevBranch={settings?.devBranch ?? 'main'}
initialAutoBuildBranch={settings?.autoBuildBranch ?? ''} initialAutoBuildBranch={settings?.autoBuildBranch ?? ''}
initialWorktreeDirectory={settings?.worktreeDirectory ?? ''} initialWorktreeDirectory={settings?.worktreeDirectory ?? ''}
initialWorktreeOpenWith={settings?.worktreeOpenWith ?? 'vscode'}
initialWorktreePostCreateScript={settings?.worktreePostCreateScript ?? ''} initialWorktreePostCreateScript={settings?.worktreePostCreateScript ?? ''}
initialWorktreePreRemoveScript={settings?.worktreePreRemoveScript ?? ''} initialWorktreePreRemoveScript={settings?.worktreePreRemoveScript ?? ''}
initialImplTabPreCreateScript={settings?.implTabPreCreateScript ?? ''} initialImplTabPreCreateScript={settings?.implTabPreCreateScript ?? ''}
@@ -24,6 +24,7 @@ interface SubmitValues {
devBranch: string devBranch: string
autoBuildBranch: string autoBuildBranch: string
worktreeDirectory: string worktreeDirectory: string
worktreeOpenWith: 'vscode' | 'pycharm'
worktreePostCreateScript: string worktreePostCreateScript: string
worktreePreRemoveScript: string worktreePreRemoveScript: string
implTabPreCreateScript: string implTabPreCreateScript: string
@@ -61,6 +62,7 @@ export interface SettingsModalProps {
initialDevBranch: string initialDevBranch: string
initialAutoBuildBranch: string initialAutoBuildBranch: string
initialWorktreeDirectory: string initialWorktreeDirectory: string
initialWorktreeOpenWith: 'vscode' | 'pycharm'
initialWorktreePostCreateScript: string initialWorktreePostCreateScript: string
initialWorktreePreRemoveScript: string initialWorktreePreRemoveScript: string
initialImplTabPreCreateScript: string initialImplTabPreCreateScript: string
@@ -131,6 +133,7 @@ export function SettingsModal({
initialDevBranch, initialDevBranch,
initialAutoBuildBranch, initialAutoBuildBranch,
initialWorktreeDirectory, initialWorktreeDirectory,
initialWorktreeOpenWith,
initialWorktreePostCreateScript, initialWorktreePostCreateScript,
initialWorktreePreRemoveScript, initialWorktreePreRemoveScript,
initialImplTabPreCreateScript, initialImplTabPreCreateScript,
@@ -155,6 +158,7 @@ export function SettingsModal({
const [devBranch, setDevBranch] = useState(initialDevBranch) const [devBranch, setDevBranch] = useState(initialDevBranch)
const [autoBuildBranch, setAutoBuildBranch] = useState(initialAutoBuildBranch) const [autoBuildBranch, setAutoBuildBranch] = useState(initialAutoBuildBranch)
const [worktreeDirectory, setWorktreeDirectory] = useState(initialWorktreeDirectory) const [worktreeDirectory, setWorktreeDirectory] = useState(initialWorktreeDirectory)
const [worktreeOpenWith, setWorktreeOpenWith] = useState(initialWorktreeOpenWith)
const [worktreePostCreateScript, setWorktreePostCreateScript] = useState(initialWorktreePostCreateScript) const [worktreePostCreateScript, setWorktreePostCreateScript] = useState(initialWorktreePostCreateScript)
const [worktreePreRemoveScript, setWorktreePreRemoveScript] = useState(initialWorktreePreRemoveScript) const [worktreePreRemoveScript, setWorktreePreRemoveScript] = useState(initialWorktreePreRemoveScript)
const [implTabPreCreateScript, setImplTabPreCreateScript] = useState(initialImplTabPreCreateScript) const [implTabPreCreateScript, setImplTabPreCreateScript] = useState(initialImplTabPreCreateScript)
@@ -258,6 +262,7 @@ export function SettingsModal({
autoBuildBranch: autoBuildBranch.trim(), autoBuildBranch: autoBuildBranch.trim(),
// worktree 目录模板:trim 后留空 = 用默认模板(getSettings 读时回退) // worktree 目录模板:trim 后留空 = 用默认模板(getSettings 读时回退)
worktreeDirectory: worktreeDirectory.trim(), worktreeDirectory: worktreeDirectory.trim(),
worktreeOpenWith,
// 钩子路径保持原样 trim(前后空格无意义;空串 = 用默认 .spx/*.sh // 钩子路径保持原样 trim(前后空格无意义;空串 = 用默认 .spx/*.sh
worktreePostCreateScript: worktreePostCreateScript.trim(), worktreePostCreateScript: worktreePostCreateScript.trim(),
worktreePreRemoveScript: worktreePreRemoveScript.trim(), worktreePreRemoveScript: worktreePreRemoveScript.trim(),
@@ -642,6 +647,28 @@ export function SettingsModal({
/> />
</Field> </Field>
<Field
label="工作树打开方式"
hint={(
<>
PyCharm
{' '}
<code>pycharm</code>
{' '}
PATH JetBrains Toolbox
</>
)}
>
<select
value={worktreeOpenWith}
onChange={e => setWorktreeOpenWith(e.target.value as 'vscode' | 'pycharm')}
className={inputClass}
>
<option value="vscode">VS Code</option>
<option value="pycharm">PyCharm</option>
</select>
</Field>
<Field <Field
label="worktree 创建后脚本" label="worktree 创建后脚本"
hint={( hint={(
+4
View File
@@ -34,6 +34,7 @@ export interface SettingsValues {
devBranch: string devBranch: string
autoBuildBranch: string autoBuildBranch: string
worktreeDirectory: string worktreeDirectory: string
worktreeOpenWith: 'vscode' | 'pycharm'
worktreePostCreateScript: string worktreePostCreateScript: string
worktreePreRemoveScript: string worktreePreRemoveScript: string
implTabPreCreateScript: string implTabPreCreateScript: string
@@ -57,6 +58,7 @@ export interface SettingsOverlayState {
devBranch: string devBranch: string
autoBuildBranch: string autoBuildBranch: string
worktreeDirectory: string worktreeDirectory: string
worktreeOpenWith: 'vscode' | 'pycharm'
worktreePostCreateScript: string worktreePostCreateScript: string
worktreePreRemoveScript: string worktreePreRemoveScript: string
implTabPreCreateScript: string implTabPreCreateScript: string
@@ -268,6 +270,7 @@ export function useIssues(): UseIssuesResult {
devBranch: values.devBranch, devBranch: values.devBranch,
autoBuildBranch: values.autoBuildBranch, autoBuildBranch: values.autoBuildBranch,
worktreeDirectory: values.worktreeDirectory, worktreeDirectory: values.worktreeDirectory,
worktreeOpenWith: values.worktreeOpenWith,
worktreePostCreateScript: values.worktreePostCreateScript, worktreePostCreateScript: values.worktreePostCreateScript,
worktreePreRemoveScript: values.worktreePreRemoveScript, worktreePreRemoveScript: values.worktreePreRemoveScript,
implTabPreCreateScript: values.implTabPreCreateScript, implTabPreCreateScript: values.implTabPreCreateScript,
@@ -594,6 +597,7 @@ export function useIssues(): UseIssuesResult {
devBranch: msg.devBranch, devBranch: msg.devBranch,
autoBuildBranch: msg.autoBuildBranch, autoBuildBranch: msg.autoBuildBranch,
worktreeDirectory: msg.worktreeDirectory, worktreeDirectory: msg.worktreeDirectory,
worktreeOpenWith: msg.worktreeOpenWith,
worktreePostCreateScript: msg.worktreePostCreateScript, worktreePostCreateScript: msg.worktreePostCreateScript,
worktreePreRemoveScript: msg.worktreePreRemoveScript, worktreePreRemoveScript: msg.worktreePreRemoveScript,
implTabPreCreateScript: msg.implTabPreCreateScript, implTabPreCreateScript: msg.implTabPreCreateScript,
+2
View File
@@ -77,6 +77,7 @@ export type ExtensionToWebview
devBranch: string devBranch: string
autoBuildBranch: string autoBuildBranch: string
worktreeDirectory: string worktreeDirectory: string
worktreeOpenWith: 'vscode' | 'pycharm'
worktreePostCreateScript: string worktreePostCreateScript: string
worktreePreRemoveScript: string worktreePreRemoveScript: string
implTabPreCreateScript: string implTabPreCreateScript: string
@@ -132,6 +133,7 @@ export type WebviewToExtension
devBranch: string devBranch: string
autoBuildBranch: string autoBuildBranch: string
worktreeDirectory: string worktreeDirectory: string
worktreeOpenWith: 'vscode' | 'pycharm'
worktreePostCreateScript: string worktreePostCreateScript: string
worktreePreRemoveScript: string worktreePreRemoveScript: string
implTabPreCreateScript: string implTabPreCreateScript: string