fix: keep custom tool environments selectable
The custom Node.js and Python choices could appear unavailable until a candidate was already discovered, and selecting the source implicitly adopted the first candidate. The chooser now stays operable, waits for an explicit validated path, keeps cancellation quiet, and shows validation errors beside the affected runtime. Candidate discovery now prioritizes active virtual environments and Conda before bounded user/system PATH and common Node/Python locations, while preserving canonical validation and deduplication. Release note: 修复自定义 Node.js/Python 环境入口不可用的问题,并增加对 PATH、venv、Conda 和常见安装位置的解释器检测。
This commit is contained in:
@@ -99,6 +99,21 @@ custom(executablePath)
|
||||
|
||||
这是唯一持久状态。
|
||||
|
||||
候选选择还具有不持久化的界面状态:
|
||||
|
||||
```text
|
||||
closed
|
||||
choosing
|
||||
validating
|
||||
failed
|
||||
```
|
||||
|
||||
- 从 `closed` 进入 `choosing` 不改变当前持久来源。
|
||||
- `choosing` 中不得自动采用首个候选;只有用户明确选择候选或文件后才进入 `validating`。
|
||||
- 验证成功后一次性保存 `custom(executablePath)`;验证失败回到 `choosing` 并就地显示错误。
|
||||
- 文件选择取消回到 `choosing`,不显示错误且不改变当前来源。
|
||||
- 没有候选不是禁用“自定义环境”的理由,用户仍可刷新或选择其他可执行文件。
|
||||
|
||||
### 3.2 托管 Python 安装状态
|
||||
|
||||
```text
|
||||
|
||||
@@ -107,8 +107,10 @@ Python、虚拟环境或 Conda 环境时,也不能被 GoodBuddy 静默替换
|
||||
### FR-3 自定义环境
|
||||
|
||||
- 探测 PATH 和常见安装位置中的有效候选。
|
||||
- 探测当前虚拟环境、Conda 环境以及用户/系统常见解释器位置。
|
||||
- 候选同时展示版本和规范化绝对路径。
|
||||
- 用户可以选择未被探测到的可执行文件。
|
||||
- “自定义环境”入口在没有候选时仍可操作,不自动选择首个候选。
|
||||
- 保存前执行真实诊断。
|
||||
- 已保存路径失效时保持用户选择并报错,不静默回退。
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
快照应用到新启动的本机 Runtime 与 stdio MCP。Windows x64 已完成真实托管 Node 和原生
|
||||
地址托管 Python 安装验证。
|
||||
|
||||
该实现尚不能视为六平台发布验收完成:OSS 镜像对象当前公开回读为 404,Windows ARM64、
|
||||
macOS 和 Linux 工件仍需原生 CI 验证,第三方许可证资源也尚未随包落地。
|
||||
该实现尚不能视为六平台发布验收完成:六个 OSS 镜像对象已发布并公开回读验证,但
|
||||
Windows ARM64、macOS 和 Linux 工件仍需原生 CI 验证,第三方许可证资源也尚未随包落地。
|
||||
|
||||
## 已完成
|
||||
|
||||
@@ -27,8 +27,11 @@ macOS 和 Linux 工件仍需原生 CI 验证,第三方许可证资源也尚未
|
||||
- [x] Shared 严格设置、候选、诊断、进度、IPC 和 Preload 契约。
|
||||
- [x] Application Settings v10;全新用户默认托管环境和原生地址。
|
||||
- [x] v1-v9 分别执行真实 Node/Python 3 探测的一次性迁移;无有效候选时选择托管环境。
|
||||
- [x] PATH 和已保存自定义路径候选的规范化、去重、版本及架构诊断。
|
||||
- [x] 用户/系统 PATH、活动 venv/Conda、`CONDA_ENVS_PATH`、Conda/pyenv/nvm 和常见安装
|
||||
位置,以及已保存自定义路径候选的规范化、去重、版本及架构诊断。
|
||||
- [x] 自定义解释器保存前真实验证;无效新选择不持久化,已保存失效路径不回退。
|
||||
- [x] “自定义环境”始终可进入;不隐式采用首个候选,取消文件选择不改设置,验证错误在
|
||||
对应工具卡内显示。
|
||||
- [x] “能力与工具”统一入口及三个水平 Tab,MCP 原内层页签保持不变。
|
||||
- [x] 工具下载源、Node.js、Python、候选、诊断、安装进度、取消和删除界面。
|
||||
- [x] 使用 `PageTabs`、应用通知、就地操作错误、删除确认和具名进度条。
|
||||
@@ -55,7 +58,7 @@ macOS 和 Linux 工件仍需原生 CI 验证,第三方许可证资源也尚未
|
||||
|
||||
## 待完成验收
|
||||
|
||||
- [ ] 发布六个平台/架构的字节完全相同 OSS 镜像对象并公开回读校验。
|
||||
- [x] 发布六个平台/架构的字节完全相同 OSS 镜像对象并公开回读校验。
|
||||
- [ ] 将 CPython、PSF 和 python-build-standalone 适用许可证资源纳入发行包及构建校验。
|
||||
- [ ] Windows ARM64、macOS x64/ARM64、Linux x64/ARM64 原生 CI 安装、SSL、pip、venv 探针。
|
||||
- [ ] 最低 Windows/macOS/glibc 支持矩阵和 Windows NuGet 签名核验。
|
||||
@@ -65,9 +68,6 @@ macOS 和 Linux 工件仍需原生 CI 验证,第三方许可证资源也尚未
|
||||
|
||||
## 当前阻塞项
|
||||
|
||||
- `https://goodbuddy.oss-cn-beijing.aliyuncs.com/tool-artifacts/...` 的 Windows x64 固定对象于
|
||||
2026-09-02 返回 HTTP 404。发布对象需要发行基础设施的短期 OSS 凭据,当前会话没有
|
||||
外部发布授权或凭据,因此 OSS 选项不能标记为生产可用。
|
||||
- 当前机器只能原生验证 Windows x64。其余五个平台必须由对应原生 CI 执行,不能用本机
|
||||
mock 代替。
|
||||
- 许可证文件和来源清单尚未进入 `resources/tool-environment`,发行合规验收未完成。
|
||||
@@ -89,7 +89,16 @@ macOS 和 Linux 工件仍需原生 CI 验证,第三方许可证资源也尚未
|
||||
- 第一次全量测试发现 3 个确定性夹具/兼容失败,修复后聚焦复跑 `25/25` 通过。第二次暴露
|
||||
2 个 `App.test.tsx` 设置导航时序问题,修正为按可访问名称点击并等待设置中心。
|
||||
- 最终 `npm test` 通过:`3465` 通过、`51` 跳过;完整 `App.test.tsx` 为 `157/157` 通过。
|
||||
- 原生地址范围请求返回 HTTP 206;对应 OSS 镜像返回 HTTP 404,已列为外部阻塞。
|
||||
- 使用本机受控 Aliyun 凭据将六个已验证上游工件原样上传到
|
||||
`goodbuddy/tool-artifacts/python/3.13.15/<sha256>/<filename>`,总计 `142,251,693`
|
||||
字节。随后从目录中的六个公开 OSS URL 全量下载,逐个验证字节数和 SHA-256,全部一致。
|
||||
- 修复无候选时禁用“自定义环境”的死路交互,并扩展 venv/Conda/常见安装位置探测;
|
||||
聚焦 Main/Renderer/Settings 测试 `95/95` 通过,最终 `npm test` 为 `3477` 通过、
|
||||
`51` 跳过,类型检查、功能文件定向 Lint 和生产构建通过。仓库全量 Lint 当前被并行
|
||||
`App.tsx` 的两个 React Compiler memoization 错误阻塞,与工具执行环境文件无关。
|
||||
- 隔离 Electron 实机确认“自定义环境”始终可进入,展开和刷新不会保存来源;在 64 项以上
|
||||
PATH 的机器上优先发现活动 `<user>\miniconda3\python.exe`,真实诊断为
|
||||
Python `3.13.13 · amd64`,刷新后持久设置仍为 `managed`。
|
||||
|
||||
### 2026-09-02 菜单阶段
|
||||
|
||||
|
||||
@@ -176,17 +176,23 @@ URL 只存在于 Main/构建目录,不进入 Renderer。两个 Target 共用
|
||||
|
||||
候选来源:
|
||||
|
||||
- 筛选后的 PATH。
|
||||
- Windows/macOS/Linux 常见安装目录。
|
||||
- GoodBuddy 启动时继承的合并 PATH,其中包含操作系统提供的用户 PATH 和系统 PATH。
|
||||
- 当前 `VIRTUAL_ENV`、`CONDA_PREFIX`、`NVM_HOME`/`NVM_BIN` 和 `VOLTA_HOME`。
|
||||
- Windows 用户目录和 ProgramData 下的 Conda/Miniconda/Anaconda base 与 `envs`,
|
||||
LocalAppData Python,以及 Program Files/系统盘常见 Node/Python 目录。
|
||||
- macOS/Linux 的 `/usr/local/bin`、`/usr/bin`、Homebrew、本地用户 bin、Python
|
||||
Framework、Conda、pyenv 和 nvm 常见目录。
|
||||
- `CONDA_ENVS_PATH` 中的有界环境目录。
|
||||
- 已保存路径。
|
||||
|
||||
每个候选运行分离参数的 `--version`,确认普通文件、可执行、成功退出和可解析版本,并按
|
||||
真实路径去重。
|
||||
目录枚举有总量和单目录上限,不递归扫描整块磁盘。每个候选使用分离参数启动检查,确认
|
||||
普通文件、成功退出、可解析版本和架构,并按真实路径去重。
|
||||
|
||||
### 7.2 Python
|
||||
|
||||
- Windows 探测 `python.exe`。
|
||||
- macOS/Linux 优先 `python3`,其次为明确的 Python 3 `python`。
|
||||
- Conda/venv 候选使用各环境自己的解释器,不依赖环境激活脚本。
|
||||
- 拒绝未安装的 Windows Store App Execution Alias。
|
||||
- 诊断 Python 3、版本、架构和解释器绝对路径。
|
||||
|
||||
|
||||
@@ -146,6 +146,14 @@ MCP 面板
|
||||
不显示“自动选择”“系统安装”“指定路径”三个交叉选项。自定义环境选中后,在同一区域展示
|
||||
检测候选下拉框和“选择其他可执行文件”次操作。
|
||||
|
||||
“自定义环境”单选项始终可操作。尚无候选或已保存路径时,点击只展开选择区域并保持当前
|
||||
实际来源,不把入口禁用,也不自动选择第一个候选。选择区域显示“刷新候选”和“选择其他
|
||||
可执行文件”;只有用户明确选择且 Main 验证成功后才保存并切换来源。
|
||||
|
||||
文件选择取消不显示错误、不发送成功通知且保持选择区域展开。验证失败在对应 Node/Python
|
||||
卡片的路径操作附近显示,保留原来源和原路径;不得只在页面顶部显示错误。刷新后出现候选
|
||||
时通过正常列表更新呈现,不改变当前来源,焦点保持在触发操作或用户随后选择的候选上。
|
||||
|
||||
候选项格式:
|
||||
|
||||
```text
|
||||
@@ -260,7 +268,8 @@ SKILL/MCP 的能力详情展示能力依赖状态:
|
||||
- 安装、测试、更新、删除和文件选择使用语义按钮。
|
||||
- 下载进度使用可命名的 `progressbar`。
|
||||
- 状态不只依赖颜色,必须包含文字。
|
||||
- 禁用控件仍提供可理解的邻近说明。
|
||||
- 尚无自定义候选时不得禁用“自定义环境”入口;真正因活动操作禁用的控件仍提供可理解的
|
||||
邻近说明。
|
||||
- 切换到自定义环境后,焦点按正常 Tab 顺序进入候选选择框。
|
||||
- 对话框关闭后焦点回到触发操作。
|
||||
- 窄屏时卡片操作换行,不压缩版本和路径到不可读宽度。
|
||||
|
||||
@@ -131,8 +131,12 @@
|
||||
|
||||
验收:
|
||||
|
||||
- “自定义环境”入口始终可操作,不因尚无候选而禁用
|
||||
- 点击“自定义环境”只展开选择流程,不自动采用第一个候选
|
||||
- 候选覆盖启动时继承的用户/系统 PATH、当前虚拟环境、Conda 环境和常见安装位置
|
||||
- 候选经过真实版本和架构诊断
|
||||
- 相同真实路径不重复显示
|
||||
- 用户必须明确选择一个候选后才保存并切换来源
|
||||
- 保存后状态显示实际生效来源
|
||||
- PATH 后续变化不会自动替换选择
|
||||
|
||||
@@ -143,7 +147,10 @@
|
||||
验收:
|
||||
|
||||
- 文件选择由 Main 管理
|
||||
- 没有候选时仍可从“自定义环境”流程直接打开文件选择器
|
||||
- 取消文件选择不报错、不保存,也不改变当前来源
|
||||
- 保存前验证 Python 3、版本和架构
|
||||
- 验证失败在 Python 卡片的路径操作附近显示,并保留原设置
|
||||
- GoodBuddy 不安装、升级或删除该环境
|
||||
- 路径失效时不静默回退
|
||||
|
||||
|
||||
@@ -123,6 +123,62 @@ describe('LocalToolEnvironmentService', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('discovers validated virtual-environment and Conda interpreters outside PATH', async () => {
|
||||
const { options } = await fixture()
|
||||
const virtualEnvironment = join(options.managedPythonRoot, 'project-venv')
|
||||
const condaEnvironment = join(options.managedPythonRoot, 'conda-env')
|
||||
const condaEnvironments = join(options.managedPythonRoot, 'conda-envs')
|
||||
const namedCondaEnvironment = join(condaEnvironments, 'analytics')
|
||||
const virtualPython = join(virtualEnvironment, 'python.exe')
|
||||
const condaPython = join(condaEnvironment, 'python.exe')
|
||||
const namedCondaPython = join(namedCondaEnvironment, 'python.exe')
|
||||
await Promise.all([
|
||||
mkdir(virtualEnvironment, { recursive: true }),
|
||||
mkdir(condaEnvironment, { recursive: true }),
|
||||
mkdir(namedCondaEnvironment, { recursive: true })
|
||||
])
|
||||
await Promise.all([
|
||||
writeFile(virtualPython, ''),
|
||||
writeFile(condaPython, ''),
|
||||
writeFile(namedCondaPython, '')
|
||||
])
|
||||
const service = new LocalToolEnvironmentService(
|
||||
{
|
||||
...options,
|
||||
baseEnvironment: {
|
||||
PATH: Array.from(
|
||||
{ length: 80 },
|
||||
(_, index) => join(options.managedPythonRoot, `path-${index}`)
|
||||
).join(';'),
|
||||
VIRTUAL_ENV: virtualEnvironment,
|
||||
CONDA_PREFIX: condaEnvironment,
|
||||
CONDA_ENVS_PATH: condaEnvironments
|
||||
}
|
||||
},
|
||||
{
|
||||
toolEnvironment: {
|
||||
platform: 'win32',
|
||||
spawnProcess: fakeInspectionSpawn()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const snapshot = await service.refreshCandidates()
|
||||
|
||||
expect(
|
||||
snapshot.candidates.map(({ executablePath }) => executablePath)
|
||||
).toEqual([virtualPython, condaPython, namedCondaPython])
|
||||
expect(snapshot.candidates).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: 'python',
|
||||
version: '3.12.8',
|
||||
architecture: 'x64'
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('persists the complete nested settings object through one store authority', async () => {
|
||||
const { service, settingsStore, node } = await fixture()
|
||||
const next: LocalToolEnvironmentSettings = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, realpath, rm, stat } from 'node:fs/promises'
|
||||
import { mkdir, readdir, realpath, rm, stat } from 'node:fs/promises'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import spawn from 'cross-spawn'
|
||||
import type { ApplicationSettingsStore } from '../application-settings-store'
|
||||
@@ -39,7 +39,8 @@ import {
|
||||
} from '../agent/child-process-termination'
|
||||
|
||||
const candidateLimit = 40
|
||||
const pathDirectoryLimit = 32
|
||||
const pathDirectoryLimit = 64
|
||||
const discoveredDirectoryLimit = 24
|
||||
const validationOutputLimit = 8 * 1024
|
||||
const validationTimeoutMs = 15_000
|
||||
const validationTerminationWaitMs = 500
|
||||
@@ -83,14 +84,137 @@ async function existingFile(path: string): Promise<string | undefined> {
|
||||
}
|
||||
}
|
||||
|
||||
async function childDirectories(
|
||||
root: string | undefined,
|
||||
suffix: readonly string[] = []
|
||||
): Promise<string[]> {
|
||||
if (!root) return []
|
||||
try {
|
||||
const entries = await readdir(root, { withFileTypes: true })
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
|
||||
.slice(0, discoveredDirectoryLimit)
|
||||
.map((entry) => join(root, entry.name, ...suffix))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function candidateDirectories(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
platform: PythonArtifactPlatform
|
||||
): Promise<string[]> {
|
||||
const directories: string[] = []
|
||||
const seen = new Set<string>()
|
||||
const add = (value: string | undefined): void => {
|
||||
const path = value?.trim()
|
||||
if (!path) return
|
||||
const key = platform === 'win32' ? path.toLowerCase() : path
|
||||
if (!seen.has(key) && directories.length < pathDirectoryLimit) {
|
||||
seen.add(key)
|
||||
directories.push(path)
|
||||
}
|
||||
}
|
||||
const addMany = (values: readonly string[]): void => values.forEach(add)
|
||||
const executableDirectory = (prefix: string | undefined): string | undefined =>
|
||||
prefix
|
||||
? platform === 'win32'
|
||||
? prefix
|
||||
: join(prefix, 'bin')
|
||||
: undefined
|
||||
|
||||
const inheritedPath =
|
||||
environment.PATH ?? environment.Path ?? environment.path ?? ''
|
||||
add(executableDirectory(environment.VIRTUAL_ENV))
|
||||
add(executableDirectory(environment.CONDA_PREFIX))
|
||||
add(executableDirectory(environment.NVM_HOME))
|
||||
add(environment.NVM_BIN)
|
||||
add(environment.VOLTA_HOME ? join(environment.VOLTA_HOME, 'bin') : undefined)
|
||||
|
||||
const home = environment.USERPROFILE ?? environment.HOME
|
||||
const localAppData = environment.LOCALAPPDATA
|
||||
const programFiles = environment.ProgramFiles
|
||||
const programData = environment.ProgramData
|
||||
|
||||
if (platform === 'win32') {
|
||||
add(programFiles ? join(programFiles, 'nodejs') : undefined)
|
||||
add(localAppData ? join(localAppData, 'Programs', 'nodejs') : undefined)
|
||||
for (const version of ['314', '313', '312', '311', '310']) {
|
||||
add(environment.SystemDrive ? join(environment.SystemDrive, `Python${version}`) : undefined)
|
||||
add(programFiles ? join(programFiles, `Python${version}`) : undefined)
|
||||
}
|
||||
addMany(
|
||||
await childDirectories(
|
||||
localAppData ? join(localAppData, 'Programs', 'Python') : undefined
|
||||
)
|
||||
)
|
||||
for (const root of [
|
||||
home ? join(home, '.conda', 'envs') : undefined,
|
||||
home ? join(home, 'miniconda3', 'envs') : undefined,
|
||||
home ? join(home, 'anaconda3', 'envs') : undefined,
|
||||
programData ? join(programData, 'miniconda3', 'envs') : undefined,
|
||||
programData ? join(programData, 'anaconda3', 'envs') : undefined,
|
||||
...((environment.CONDA_ENVS_PATH ?? '')
|
||||
.split(delimiter)
|
||||
.filter(Boolean))
|
||||
]) {
|
||||
addMany(await childDirectories(root))
|
||||
}
|
||||
add(home ? join(home, 'miniconda3') : undefined)
|
||||
add(home ? join(home, 'anaconda3') : undefined)
|
||||
add(programData ? join(programData, 'miniconda3') : undefined)
|
||||
add(programData ? join(programData, 'anaconda3') : undefined)
|
||||
} else {
|
||||
add('/usr/local/bin')
|
||||
add('/usr/bin')
|
||||
if (platform === 'darwin') {
|
||||
add('/opt/homebrew/bin')
|
||||
addMany(
|
||||
await childDirectories(
|
||||
'/Library/Frameworks/Python.framework/Versions',
|
||||
['bin']
|
||||
)
|
||||
)
|
||||
}
|
||||
add(home ? join(home, '.local', 'bin') : undefined)
|
||||
for (const root of [
|
||||
home ? join(home, '.conda', 'envs') : undefined,
|
||||
home ? join(home, 'miniconda3', 'envs') : undefined,
|
||||
home ? join(home, 'anaconda3', 'envs') : undefined,
|
||||
...((environment.CONDA_ENVS_PATH ?? '')
|
||||
.split(delimiter)
|
||||
.filter(Boolean))
|
||||
]) {
|
||||
addMany(
|
||||
(await childDirectories(root)).map((directory) =>
|
||||
join(directory, 'bin')
|
||||
)
|
||||
)
|
||||
}
|
||||
add(home ? join(home, 'miniconda3', 'bin') : undefined)
|
||||
add(home ? join(home, 'anaconda3', 'bin') : undefined)
|
||||
for (const root of [
|
||||
environment.PYENV_ROOT
|
||||
? join(environment.PYENV_ROOT, 'versions')
|
||||
: home
|
||||
? join(home, '.pyenv', 'versions')
|
||||
: undefined,
|
||||
home ? join(home, '.nvm', 'versions', 'node') : undefined
|
||||
]) {
|
||||
addMany(await childDirectories(root, ['bin']))
|
||||
}
|
||||
}
|
||||
inheritedPath.split(delimiter).forEach(add)
|
||||
return directories
|
||||
}
|
||||
|
||||
async function discoverCandidates(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
platform: PythonArtifactPlatform,
|
||||
savedSettings: LocalToolEnvironmentSettings,
|
||||
dependencies: LocalToolEnvironmentDependencies
|
||||
): Promise<LocalToolEnvironmentSnapshot['candidates']> {
|
||||
const value = environment.PATH ?? environment.Path ?? environment.path ?? ''
|
||||
const directories = value.split(delimiter).filter(Boolean).slice(0, pathDirectoryLimit)
|
||||
const directories = await candidateDirectories(environment, platform)
|
||||
const names: Readonly<Record<LocalToolKind, readonly string[]>> =
|
||||
platform === 'win32'
|
||||
? { node: ['node.exe'], python: ['python.exe', 'python3.exe'] }
|
||||
@@ -111,7 +235,7 @@ async function discoverCandidates(
|
||||
}
|
||||
const path = await existingFile(join(directory, name))
|
||||
const key = path
|
||||
? `${kind}:${platform === 'win32' ? path.toLocaleLowerCase() : path}`
|
||||
? `${kind}:${platform === 'win32' ? path.toLowerCase() : path}`
|
||||
: undefined
|
||||
if (path && key && !seen.has(key)) {
|
||||
seen.add(key)
|
||||
@@ -136,7 +260,7 @@ async function discoverCandidates(
|
||||
if (!candidate) continue
|
||||
const key = `${candidate.kind}:${
|
||||
platform === 'win32'
|
||||
? candidate.executablePath.toLocaleLowerCase()
|
||||
? candidate.executablePath.toLowerCase()
|
||||
: candidate.executablePath
|
||||
}`
|
||||
if (!canonicalSeen.has(key)) {
|
||||
|
||||
@@ -189,6 +189,80 @@ describe('ToolEnvironmentSettingsSection', () => {
|
||||
expect(onNotify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps custom Python operable without implicitly selecting the first candidate', async () => {
|
||||
render(<ToolEnvironmentSettingsSection />)
|
||||
|
||||
const pythonCard = (
|
||||
await screen.findByRole('heading', { name: 'Python' })
|
||||
).closest('article')!
|
||||
const custom = within(pythonCard).getByRole('radio', {
|
||||
name: /自定义环境/u
|
||||
})
|
||||
expect(custom).toBeEnabled()
|
||||
expect(
|
||||
within(pythonCard).queryByRole('button', {
|
||||
name: /C:\\Python\\python\.exe/u
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(custom)
|
||||
|
||||
expect(custom).toBeChecked()
|
||||
expect(updateSettings).not.toHaveBeenCalled()
|
||||
expect(
|
||||
within(pythonCard).getByText(/完成验证前不会更改当前执行环境/u)
|
||||
).toBeVisible()
|
||||
const candidate = within(pythonCard).getByRole('button', {
|
||||
name: /C:\\Python\\python\.exe/u
|
||||
})
|
||||
expect(candidate).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
fireEvent.click(candidate)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateSettings).toHaveBeenCalledWith({
|
||||
...baseSnapshot.settings,
|
||||
python: {
|
||||
source: 'custom',
|
||||
executablePath: 'C:\\Python\\python.exe'
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps file-picker cancellation quiet and shows validation errors inside the runtime card', async () => {
|
||||
const onNotify = vi.fn()
|
||||
selectExecutable
|
||||
.mockResolvedValueOnce(baseSnapshot)
|
||||
.mockRejectedValueOnce(new Error('Python 3 validation failed'))
|
||||
render(<ToolEnvironmentSettingsSection onNotify={onNotify} />)
|
||||
|
||||
const pythonCard = (
|
||||
await screen.findByRole('heading', { name: 'Python' })
|
||||
).closest('article')!
|
||||
fireEvent.click(
|
||||
within(pythonCard).getByRole('radio', {
|
||||
name: /自定义环境/u
|
||||
})
|
||||
)
|
||||
const choose = within(pythonCard).getByRole('button', {
|
||||
name: '选择可执行文件'
|
||||
})
|
||||
|
||||
fireEvent.click(choose)
|
||||
await waitFor(() =>
|
||||
expect(selectExecutable).toHaveBeenCalledWith('python')
|
||||
)
|
||||
expect(onNotify).not.toHaveBeenCalled()
|
||||
expect(within(pythonCard).queryByRole('alert')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(choose)
|
||||
expect(
|
||||
await within(pythonCard).findByRole('alert')
|
||||
).toHaveTextContent('Python 3 validation failed')
|
||||
expect(onNotify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses real diagnose, file selection, managed Python, and progress APIs', async () => {
|
||||
const onNotify = vi.fn()
|
||||
render(
|
||||
|
||||
@@ -52,6 +52,12 @@ export function ToolEnvironmentSettingsSection({
|
||||
const [actionError, setActionError] = useState<string>()
|
||||
const [busy, setBusy] = useState<string>()
|
||||
const [confirmingRemove, setConfirmingRemove] = useState(false)
|
||||
const [customOpen, setCustomOpen] = useState<
|
||||
Record<LocalToolKind, boolean>
|
||||
>({ node: false, python: false })
|
||||
const [runtimeErrors, setRuntimeErrors] = useState<
|
||||
Partial<Record<LocalToolKind, string>>
|
||||
>({})
|
||||
const pythonCancellationRequested = useRef(false)
|
||||
const toolApi = window.goodbuddy.localToolEnvironment
|
||||
|
||||
@@ -98,15 +104,22 @@ export function ToolEnvironmentSettingsSection({
|
||||
|
||||
const updateSettings = async (
|
||||
nextSettings: LocalToolEnvironmentSettings,
|
||||
successMessage: string
|
||||
): Promise<void> => {
|
||||
successMessage: string,
|
||||
errorKind?: LocalToolKind
|
||||
): Promise<boolean> => {
|
||||
const api = window.goodbuddy.localToolEnvironment
|
||||
if (!api || !snapshot) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
const confirmedSnapshot = snapshot
|
||||
setSnapshot({ ...snapshot, settings: nextSettings })
|
||||
setActionError(undefined)
|
||||
if (errorKind) {
|
||||
setRuntimeErrors((current) => ({
|
||||
...current,
|
||||
[errorKind]: undefined
|
||||
}))
|
||||
}
|
||||
setBusy('settings')
|
||||
try {
|
||||
const next = await api.updateSettings(nextSettings)
|
||||
@@ -116,14 +129,22 @@ export function ToolEnvironmentSettingsSection({
|
||||
message: successMessage,
|
||||
dedupeKey: 'local-tool-environment-settings'
|
||||
})
|
||||
return true
|
||||
} catch (reason) {
|
||||
setSnapshot(confirmedSnapshot)
|
||||
setActionError(
|
||||
errorMessage(
|
||||
reason,
|
||||
t('toolEnvironment.errors.saveFailed')
|
||||
)
|
||||
const message = errorMessage(
|
||||
reason,
|
||||
t('toolEnvironment.errors.saveFailed')
|
||||
)
|
||||
if (errorKind) {
|
||||
setRuntimeErrors((current) => ({
|
||||
...current,
|
||||
[errorKind]: message
|
||||
}))
|
||||
} else {
|
||||
setActionError(message)
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
setBusy(undefined)
|
||||
}
|
||||
@@ -132,18 +153,82 @@ export function ToolEnvironmentSettingsSection({
|
||||
const updateRuntime = async (
|
||||
kind: LocalToolKind,
|
||||
selection: LocalToolRuntimeSelection
|
||||
): Promise<void> => {
|
||||
): Promise<boolean> => {
|
||||
if (!snapshot) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
await updateSettings(
|
||||
return updateSettings(
|
||||
{ ...snapshot.settings, [kind]: selection },
|
||||
t('toolEnvironment.notifications.runtimeChanged', {
|
||||
runtime: t(`toolEnvironment.runtimes.${kind}.title`)
|
||||
})
|
||||
}),
|
||||
kind
|
||||
)
|
||||
}
|
||||
|
||||
const refreshCustomCandidates = async (
|
||||
kind: LocalToolKind
|
||||
): Promise<void> => {
|
||||
setBusy(`refresh-${kind}`)
|
||||
setRuntimeErrors((current) => ({ ...current, [kind]: undefined }))
|
||||
try {
|
||||
const api = window.goodbuddy.localToolEnvironment
|
||||
if (!api) return
|
||||
setSnapshot(await api.refreshCandidates())
|
||||
} catch (reason) {
|
||||
setRuntimeErrors((current) => ({
|
||||
...current,
|
||||
[kind]: errorMessage(
|
||||
reason,
|
||||
t('toolEnvironment.errors.refreshFailed')
|
||||
)
|
||||
}))
|
||||
} finally {
|
||||
setBusy(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const selectCustomExecutable = async (
|
||||
kind: LocalToolKind
|
||||
): Promise<void> => {
|
||||
const api = window.goodbuddy.localToolEnvironment
|
||||
if (!api || !snapshot) return
|
||||
const previous = snapshot.settings[kind]
|
||||
setBusy(`select-${kind}`)
|
||||
setRuntimeErrors((current) => ({ ...current, [kind]: undefined }))
|
||||
try {
|
||||
const next = await api.selectExecutable(kind)
|
||||
setSnapshot(next)
|
||||
const selected = next.settings[kind]
|
||||
if (
|
||||
selected.source === 'custom' &&
|
||||
(previous.source !== 'custom' ||
|
||||
previous.executablePath !== selected.executablePath)
|
||||
) {
|
||||
setCustomOpen((current) => ({ ...current, [kind]: true }))
|
||||
onNotify({
|
||||
tone: 'success',
|
||||
message: t('toolEnvironment.notifications.runtimeChanged', {
|
||||
runtime: t(`toolEnvironment.runtimes.${kind}.title`)
|
||||
}),
|
||||
dedupeKey: `local-tool-environment-select-${kind}`
|
||||
})
|
||||
}
|
||||
} catch (reason) {
|
||||
setRuntimeErrors((current) => ({
|
||||
...current,
|
||||
[kind]: errorMessage(
|
||||
reason,
|
||||
t('toolEnvironment.errors.selectFailed', {
|
||||
runtime: t(`toolEnvironment.runtimes.${kind}.title`)
|
||||
})
|
||||
)
|
||||
}))
|
||||
} finally {
|
||||
setBusy(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const runSnapshotAction = async (
|
||||
key: string,
|
||||
action: () => Promise<LocalToolEnvironmentSnapshot>,
|
||||
@@ -247,7 +332,10 @@ export function ToolEnvironmentSettingsSection({
|
||||
selection.source === 'custom'
|
||||
? selection.executablePath
|
||||
: undefined
|
||||
const customPath = selectedCustomPath ?? candidates[0]?.executablePath
|
||||
const showCustom =
|
||||
selection.source === 'custom' || customOpen[kind]
|
||||
const customError = runtimeErrors[kind]
|
||||
const customRegionId = `tool-environment-${kind}-custom-options`
|
||||
|
||||
return (
|
||||
<article className="tool-environment-card">
|
||||
@@ -286,12 +374,24 @@ export function ToolEnvironmentSettingsSection({
|
||||
</legend>
|
||||
<label className="tool-environment-option">
|
||||
<input
|
||||
checked={selection.source === 'managed'}
|
||||
checked={
|
||||
selection.source === 'managed' && !customOpen[kind]
|
||||
}
|
||||
disabled={busy !== undefined}
|
||||
name={`${kind}-runtime-source`}
|
||||
onChange={() =>
|
||||
void updateRuntime(kind, { source: 'managed' })
|
||||
}
|
||||
onChange={() => {
|
||||
setCustomOpen((current) => ({
|
||||
...current,
|
||||
[kind]: false
|
||||
}))
|
||||
setRuntimeErrors((current) => ({
|
||||
...current,
|
||||
[kind]: undefined
|
||||
}))
|
||||
if (selection.source !== 'managed') {
|
||||
void updateRuntime(kind, { source: 'managed' })
|
||||
}
|
||||
}}
|
||||
type="radio"
|
||||
/>
|
||||
<span>
|
||||
@@ -305,16 +405,15 @@ export function ToolEnvironmentSettingsSection({
|
||||
</label>
|
||||
<label className="tool-environment-option">
|
||||
<input
|
||||
checked={selection.source === 'custom'}
|
||||
disabled={busy !== undefined || !customPath}
|
||||
aria-controls={customRegionId}
|
||||
checked={showCustom}
|
||||
disabled={busy !== undefined}
|
||||
name={`${kind}-runtime-source`}
|
||||
onChange={() => {
|
||||
if (customPath) {
|
||||
void updateRuntime(kind, {
|
||||
source: 'custom',
|
||||
executablePath: customPath
|
||||
})
|
||||
}
|
||||
setCustomOpen((current) => ({
|
||||
...current,
|
||||
[kind]: true
|
||||
}))
|
||||
}}
|
||||
type="radio"
|
||||
/>
|
||||
@@ -325,90 +424,86 @@ export function ToolEnvironmentSettingsSection({
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div className="tool-environment-card__custom">
|
||||
<div className="tool-environment-card__actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={busy !== undefined}
|
||||
onClick={() =>
|
||||
void runSnapshotAction(
|
||||
`refresh-${kind}`,
|
||||
() => api.refreshCandidates(),
|
||||
t('toolEnvironment.errors.refreshFailed')
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{t('toolEnvironment.actions.refreshCandidates')}
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={busy !== undefined}
|
||||
onClick={() =>
|
||||
void runSnapshotAction(
|
||||
`select-${kind}`,
|
||||
() => api.selectExecutable(kind),
|
||||
t('toolEnvironment.errors.selectFailed', {
|
||||
runtime: t(
|
||||
`toolEnvironment.runtimes.${kind}.title`
|
||||
)
|
||||
}),
|
||||
t('toolEnvironment.notifications.runtimeChanged', {
|
||||
runtime: t(
|
||||
`toolEnvironment.runtimes.${kind}.title`
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{t('toolEnvironment.actions.chooseFile')}
|
||||
</button>
|
||||
{showCustom && (
|
||||
<div
|
||||
className="tool-environment-card__custom"
|
||||
id={customRegionId}
|
||||
>
|
||||
{selection.source !== 'custom' && (
|
||||
<p className="tool-environment-muted">
|
||||
{t('toolEnvironment.chooseCustomPrompt', {
|
||||
runtime: t(`toolEnvironment.runtimes.${kind}.title`)
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<div className="tool-environment-card__actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={busy !== undefined}
|
||||
onClick={() => void refreshCustomCandidates(kind)}
|
||||
type="button"
|
||||
>
|
||||
{t('toolEnvironment.actions.refreshCandidates')}
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={busy !== undefined}
|
||||
onClick={() => void selectCustomExecutable(kind)}
|
||||
type="button"
|
||||
>
|
||||
{t('toolEnvironment.actions.chooseFile')}
|
||||
</button>
|
||||
</div>
|
||||
{customError && (
|
||||
<p className="tool-environment__error" role="alert">
|
||||
{customError}
|
||||
</p>
|
||||
)}
|
||||
{selectedCustomPath && (
|
||||
<p className="tool-environment-path">
|
||||
<span>{t('toolEnvironment.selectedPath')}</span>
|
||||
<code>{selectedCustomPath}</code>
|
||||
</p>
|
||||
)}
|
||||
{candidates.length > 0 ? (
|
||||
<ul
|
||||
aria-label={t('toolEnvironment.candidateList', {
|
||||
runtime: t(`toolEnvironment.runtimes.${kind}.title`)
|
||||
})}
|
||||
className="tool-environment-candidates"
|
||||
>
|
||||
{candidates.map((candidate) => {
|
||||
return (
|
||||
<li key={candidate.executablePath}>
|
||||
<button
|
||||
aria-pressed={
|
||||
selectedCustomPath === candidate.executablePath
|
||||
}
|
||||
disabled={busy !== undefined}
|
||||
onClick={() =>
|
||||
void updateRuntime(kind, {
|
||||
source: 'custom',
|
||||
executablePath: candidate.executablePath
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<code>{candidate.executablePath}</code>
|
||||
<small>
|
||||
{candidate.version} · {candidate.architecture}
|
||||
</small>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="tool-environment-muted">
|
||||
{t('toolEnvironment.noCandidates')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{selectedCustomPath && (
|
||||
<p className="tool-environment-path">
|
||||
<span>{t('toolEnvironment.selectedPath')}</span>
|
||||
<code>{selectedCustomPath}</code>
|
||||
</p>
|
||||
)}
|
||||
{candidates.length > 0 ? (
|
||||
<ul
|
||||
aria-label={t('toolEnvironment.candidateList', {
|
||||
runtime: t(`toolEnvironment.runtimes.${kind}.title`)
|
||||
})}
|
||||
className="tool-environment-candidates"
|
||||
>
|
||||
{candidates.map((candidate) => {
|
||||
return (
|
||||
<li key={candidate.executablePath}>
|
||||
<button
|
||||
aria-pressed={
|
||||
selectedCustomPath === candidate.executablePath
|
||||
}
|
||||
disabled={busy !== undefined}
|
||||
onClick={() =>
|
||||
void updateRuntime(kind, {
|
||||
source: 'custom',
|
||||
executablePath: candidate.executablePath
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<code>{candidate.executablePath}</code>
|
||||
<small>
|
||||
{candidate.version} · {candidate.architecture}
|
||||
</small>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="tool-environment-muted">
|
||||
{t('toolEnvironment.noCandidates')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="tool-environment-status">
|
||||
<h4>{t('toolEnvironment.toolStatus')}</h4>
|
||||
|
||||
@@ -778,8 +778,10 @@ export const settingsSections = {
|
||||
},
|
||||
selectedPath: 'Current custom path',
|
||||
candidateList: '{{runtime}} executable candidates',
|
||||
chooseCustomPrompt:
|
||||
'Choose a validated {{runtime}} candidate or select another executable. The active environment will not change until validation succeeds.',
|
||||
noCandidates:
|
||||
'No candidates were discovered. Refresh candidates or choose an executable with the system file picker.',
|
||||
'No valid candidates were found. Scan user/system PATH, Conda, virtual environments, and common install locations, or choose another executable.',
|
||||
toolStatus: 'Tool status',
|
||||
companionStatus: 'Toolchain/companion status',
|
||||
capabilityDependencyStatus: 'Capability dependency status',
|
||||
|
||||
@@ -728,8 +728,10 @@ export const settingsSections = {
|
||||
},
|
||||
selectedPath: '当前自定义路径',
|
||||
candidateList: '{{runtime}} 可执行文件候选',
|
||||
chooseCustomPrompt:
|
||||
'请选择一个已验证的 {{runtime}} 候选,或选择其他可执行文件。完成验证前不会更改当前执行环境。',
|
||||
noCandidates:
|
||||
'未发现候选文件。可刷新候选,或从系统文件选择器选择可执行文件。',
|
||||
'未发现有效候选。可扫描用户/系统 PATH、Conda、虚拟环境和常见安装位置,或选择其他可执行文件。',
|
||||
toolStatus: '工具状态',
|
||||
companionStatus: '工具链/配套工具状态',
|
||||
capabilityDependencyStatus: '能力依赖状态',
|
||||
|
||||
Reference in New Issue
Block a user