feat(vscode): PyCharm 启动命令可配置(支持 ~/绝对路径),抽 expandTilde 复用

GUI 启动的 VS Code 扩展进程 PATH 常不含 JetBrains 启动器,裸 pycharm 易 ENOENT。
新增设置 pycharmCommand(默认 pycharm),贯穿 store/settings handler/messages/
KanbanPanel 与 webview,「钩子」分组在选 PyCharm 时显示「PyCharm 启动命令」输入框。
handleOpenWorktree 用该命令并经 expandTilde 展开 ~。expandTilde 从 resolveWorktreePath
抽出共用。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 05:29:19 +08:00
co-authored by Claude Opus 4.7
parent cacc60eb0e
commit 02b0aca891
10 changed files with 84 additions and 9 deletions
+10 -5
View File
@@ -10,16 +10,21 @@ import { execFile } from 'node:child_process'
import * as os from 'node:os'
import * as path from 'node:path'
/**
* 把开头的 `~` 展开为用户 home 目录。execFile 不走 shell,传给它的命令/路径
* 里的 `~` 不会被展开,需要我们自己处理。
*/
export function expandTilde(p: string): string {
return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p
}
/**
* 把存储的 worktreePath 解析成可用的绝对路径。三态合一:
* 开头 `~` → 展开为 homegit/execFile 不走 shell,自己不展开 `~`,
* 存量工单里可能存了带 `~` 的字面量);已是绝对 → 原样;
* 开头 `~` → 展开为 home存量工单里可能存了带 `~` 的字面量);已是绝对 → 原样;
* 相对 → 拼到 workspaceRoot 下(旧的 .claude/worktrees/<hash> 方案)。
*/
export function resolveWorktreePath(stored: string, workspaceRoot: string): string {
let p = stored
if (p.startsWith('~'))
p = path.join(os.homedir(), p.slice(1))
const p = expandTilde(stored)
return path.isAbsolute(p) ? p : path.join(workspaceRoot, p)
}
+2
View File
@@ -842,6 +842,7 @@ export class KanbanWebviewPanel {
autoBuildBranch: s.autoBuildBranch,
worktreeDirectory: s.worktreeDirectory,
worktreeOpenWith: s.worktreeOpenWith,
pycharmCommand: s.pycharmCommand,
worktreePostCreateScript: s.worktreePostCreateScript,
worktreePreRemoveScript: s.worktreePreRemoveScript,
implTabPreCreateScript: s.implTabPreCreateScript,
@@ -903,6 +904,7 @@ export class KanbanWebviewPanel {
autoBuildBranch: s.autoBuildBranch,
worktreeDirectory: s.worktreeDirectory,
worktreeOpenWith: s.worktreeOpenWith,
pycharmCommand: s.pycharmCommand,
worktreePostCreateScript: s.worktreePostCreateScript,
worktreePreRemoveScript: s.worktreePreRemoveScript,
implTabPreCreateScript: s.implTabPreCreateScript,
+7
View File
@@ -27,6 +27,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
autoBuildBranch: string
worktreeDirectory: string
worktreeOpenWith: 'vscode' | 'pycharm'
pycharmCommand: string
worktreePostCreateScript: string
worktreePreRemoveScript: string
implTabPreCreateScript: string
@@ -48,6 +49,9 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
// worktree dir template: '' = "use default", so don't preserve blanks
// specially — getSettings falls back to the default at read time.
const trimmedWorktreeDir = payload.worktreeDirectory.trim()
// pycharm launcher command: '' = "use default `pycharm`", getSettings falls
// back at read time, so just strip whitespace here.
const trimmedPycharmCmd = payload.pycharmCommand.trim()
// Hook script paths: keep '' meaningful (= "use default
// .spx/*.sh"). Just strip whitespace.
const trimmedPostCreate = payload.worktreePostCreateScript.trim()
@@ -78,6 +82,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
autoBuildBranch: trimmedAutoBuildBranch,
worktreeDirectory: trimmedWorktreeDir || prev.worktreeDirectory,
worktreeOpenWith: payload.worktreeOpenWith || prev.worktreeOpenWith,
pycharmCommand: trimmedPycharmCmd || prev.pycharmCommand,
worktreePostCreateScript: trimmedPostCreate,
worktreePreRemoveScript: trimmedPreRemove,
implTabPreCreateScript: trimmedImplPre,
@@ -98,6 +103,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
autoBuildBranch: trimmedAutoBuildBranch,
worktreeDirectory: trimmedWorktreeDir,
worktreeOpenWith: payload.worktreeOpenWith,
pycharmCommand: trimmedPycharmCmd,
worktreePostCreateScript: trimmedPostCreate,
worktreePreRemoveScript: trimmedPreRemove,
implTabPreCreateScript: trimmedImplPre,
@@ -179,6 +185,7 @@ export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Prom
autoBuildBranch: s.autoBuildBranch,
worktreeDirectory: s.worktreeDirectory,
worktreeOpenWith: s.worktreeOpenWith,
pycharmCommand: s.pycharmCommand,
worktreePostCreateScript: s.worktreePostCreateScript,
worktreePreRemoveScript: s.worktreePreRemoveScript,
implTabPreCreateScript: s.implTabPreCreateScript,
+7 -4
View File
@@ -4,7 +4,7 @@ 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 { expandTilde, resolveWorktreePath } from '../../git/worktree'
import {
runImplTabPostCloseHook,
runImplTabPreCreateHook,
@@ -32,13 +32,16 @@ export async function handleOpenWorktree(panel: KanbanWebviewPanel, relPath: str
// PyCharm 分支用 JetBrains 启动器打开目录。错误在回调里自行处理,不要落进
// 下面 openFolder 的 catch。
const { worktreeOpenWith } = getSettings(panel.context)
const { worktreeOpenWith, pycharmCommand } = getSettings(panel.context)
if (worktreeOpenWith === 'pycharm') {
execFile('pycharm', [abs], (err) => {
// GUI 启动的 VS Code 进程 PATH 常不含 JetBrains 启动器,故命令可配置;
// 允许填 `~`/绝对路径,execFile 不走 shell`~` 需自己展开。
const cmd = expandTilde(pycharmCommand)
execFile(cmd, [abs], (err) => {
if (err) {
const code = (err as NodeJS.ErrnoException).code ?? 'unknown'
const message = code === 'ENOENT'
? '找不到 pycharm 命令,请在 JetBrains Toolbox 里启用命令行启动器或把 pycharm 加入 PATH'
? `找不到 ${cmd} 命令,请在「PyCharm 启动命令」里填启动器的完整路径,或确保它在 PATH 中`
: err.message
void window.showErrorMessage(`用 PyCharm 打开失败: ${message}`)
}
+2
View File
@@ -63,6 +63,7 @@ export type ExtensionToWebview
autoBuildBranch: string
worktreeDirectory: string
worktreeOpenWith: 'vscode' | 'pycharm'
pycharmCommand: string
worktreePostCreateScript: string
worktreePreRemoveScript: string
implTabPreCreateScript: string
@@ -119,6 +120,7 @@ export type WebviewToExtension
autoBuildBranch: string
worktreeDirectory: string
worktreeOpenWith: 'vscode' | 'pycharm'
pycharmCommand: string
worktreePostCreateScript: string
worktreePreRemoveScript: string
implTabPreCreateScript: string
+14
View File
@@ -100,6 +100,13 @@ export interface Settings {
* `'vscode'` (default) opens a new VS Code window.
*/
worktreeOpenWith: 'vscode' | 'pycharm'
/**
* Command used to launch PyCharm when `worktreeOpenWith === 'pycharm'`.
* A bare name relies on PATH, which a GUI-launched VS Code often lacks, so
* an absolute path (or a `~`-prefixed one) is the reliable choice. Empty
* means "use the default `pycharm`".
*/
pycharmCommand: string
/**
* Path to a user-provided shell script that runs *after* the extension
* creates a worktree via `git worktree add`. Empty string means use the
@@ -173,6 +180,7 @@ function defaults(ctx: ExtensionContext): Settings {
autoBuildBranch: '',
worktreeDirectory: '~/Sources/worktree/$project_name/$feature_name',
worktreeOpenWith: 'vscode',
pycharmCommand: 'pycharm',
worktreePostCreateScript: '',
worktreePreRemoveScript: '',
implTabPreCreateScript: '',
@@ -226,6 +234,11 @@ export function getSettings(ctx: ExtensionContext): Settings {
const worktreeOpenWith = stored.worktreeOpenWith === 'pycharm' || stored.worktreeOpenWith === 'vscode'
? stored.worktreeOpenWith
: base.worktreeOpenWith
// pycharm launcher command: blank means "use the default `pycharm`", so fall
// back to base rather than preserving an empty string.
const pycharmCommand = typeof stored.pycharmCommand === 'string' && stored.pycharmCommand.length > 0
? stored.pycharmCommand
: base.pycharmCommand
// worktree hook script paths: '' is meaningful (= "use default
// .spx/worktree-*.sh"), so don't coerce. Non-string legacy values fall
// back to ''. The hook runner resolves '' to the default at execution
@@ -266,6 +279,7 @@ export function getSettings(ctx: ExtensionContext): Settings {
autoBuildBranch,
worktreeDirectory,
worktreeOpenWith,
pycharmCommand,
worktreePostCreateScript,
worktreePreRemoveScript,
implTabPreCreateScript,