diff --git a/docs/features/local-tool-environment/logic-design.md b/docs/features/local-tool-environment/logic-design.md index be2c9534..e06736ed 100644 --- a/docs/features/local-tool-environment/logic-design.md +++ b/docs/features/local-tool-environment/logic-design.md @@ -99,6 +99,21 @@ custom(executablePath) 这是唯一持久状态。 +候选选择还具有不持久化的界面状态: + +```text +closed +choosing +validating +failed +``` + +- 从 `closed` 进入 `choosing` 不改变当前持久来源。 +- `choosing` 中不得自动采用首个候选;只有用户明确选择候选或文件后才进入 `validating`。 +- 验证成功后一次性保存 `custom(executablePath)`;验证失败回到 `choosing` 并就地显示错误。 +- 文件选择取消回到 `choosing`,不显示错误且不改变当前来源。 +- 没有候选不是禁用“自定义环境”的理由,用户仍可刷新或选择其他可执行文件。 + ### 3.2 托管 Python 安装状态 ```text diff --git a/docs/features/local-tool-environment/prd.md b/docs/features/local-tool-environment/prd.md index c8a7e67f..f1c2a8a6 100644 --- a/docs/features/local-tool-environment/prd.md +++ b/docs/features/local-tool-environment/prd.md @@ -107,8 +107,10 @@ Python、虚拟环境或 Conda 环境时,也不能被 GoodBuddy 静默替换 ### FR-3 自定义环境 - 探测 PATH 和常见安装位置中的有效候选。 +- 探测当前虚拟环境、Conda 环境以及用户/系统常见解释器位置。 - 候选同时展示版本和规范化绝对路径。 - 用户可以选择未被探测到的可执行文件。 +- “自定义环境”入口在没有候选时仍可操作,不自动选择首个候选。 - 保存前执行真实诊断。 - 已保存路径失效时保持用户选择并报错,不静默回退。 diff --git a/docs/features/local-tool-environment/progress.md b/docs/features/local-tool-environment/progress.md index 4ea5094c..a0f757a6 100644 --- a/docs/features/local-tool-environment/progress.md +++ b/docs/features/local-tool-environment/progress.md @@ -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//`,总计 `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 的机器上优先发现活动 `\miniconda3\python.exe`,真实诊断为 + Python `3.13.13 · amd64`,刷新后持久设置仍为 `managed`。 ### 2026-09-02 菜单阶段 diff --git a/docs/features/local-tool-environment/technical-design.md b/docs/features/local-tool-environment/technical-design.md index a9c2c349..89fa6710 100644 --- a/docs/features/local-tool-environment/technical-design.md +++ b/docs/features/local-tool-environment/technical-design.md @@ -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、版本、架构和解释器绝对路径。 diff --git a/docs/features/local-tool-environment/ui-design.md b/docs/features/local-tool-environment/ui-design.md index 080aa9f8..804515bc 100644 --- a/docs/features/local-tool-environment/ui-design.md +++ b/docs/features/local-tool-environment/ui-design.md @@ -146,6 +146,14 @@ MCP 面板 不显示“自动选择”“系统安装”“指定路径”三个交叉选项。自定义环境选中后,在同一区域展示 检测候选下拉框和“选择其他可执行文件”次操作。 +“自定义环境”单选项始终可操作。尚无候选或已保存路径时,点击只展开选择区域并保持当前 +实际来源,不把入口禁用,也不自动选择第一个候选。选择区域显示“刷新候选”和“选择其他 +可执行文件”;只有用户明确选择且 Main 验证成功后才保存并切换来源。 + +文件选择取消不显示错误、不发送成功通知且保持选择区域展开。验证失败在对应 Node/Python +卡片的路径操作附近显示,保留原来源和原路径;不得只在页面顶部显示错误。刷新后出现候选 +时通过正常列表更新呈现,不改变当前来源,焦点保持在触发操作或用户随后选择的候选上。 + 候选项格式: ```text @@ -260,7 +268,8 @@ SKILL/MCP 的能力详情展示能力依赖状态: - 安装、测试、更新、删除和文件选择使用语义按钮。 - 下载进度使用可命名的 `progressbar`。 - 状态不只依赖颜色,必须包含文字。 -- 禁用控件仍提供可理解的邻近说明。 +- 尚无自定义候选时不得禁用“自定义环境”入口;真正因活动操作禁用的控件仍提供可理解的 + 邻近说明。 - 切换到自定义环境后,焦点按正常 Tab 顺序进入候选选择框。 - 对话框关闭后焦点回到触发操作。 - 窄屏时卡片操作换行,不压缩版本和路径到不可读宽度。 diff --git a/docs/features/local-tool-environment/user-stories.md b/docs/features/local-tool-environment/user-stories.md index f4be6622..7fe4a609 100644 --- a/docs/features/local-tool-environment/user-stories.md +++ b/docs/features/local-tool-environment/user-stories.md @@ -131,8 +131,12 @@ 验收: +- “自定义环境”入口始终可操作,不因尚无候选而禁用 +- 点击“自定义环境”只展开选择流程,不自动采用第一个候选 +- 候选覆盖启动时继承的用户/系统 PATH、当前虚拟环境、Conda 环境和常见安装位置 - 候选经过真实版本和架构诊断 - 相同真实路径不重复显示 +- 用户必须明确选择一个候选后才保存并切换来源 - 保存后状态显示实际生效来源 - PATH 后续变化不会自动替换选择 @@ -143,7 +147,10 @@ 验收: - 文件选择由 Main 管理 +- 没有候选时仍可从“自定义环境”流程直接打开文件选择器 +- 取消文件选择不报错、不保存,也不改变当前来源 - 保存前验证 Python 3、版本和架构 +- 验证失败在 Python 卡片的路径操作附近显示,并保留原设置 - GoodBuddy 不安装、升级或删除该环境 - 路径失效时不静默回退 diff --git a/src/main/local-tool-environment/local-tool-environment-service.test.ts b/src/main/local-tool-environment/local-tool-environment-service.test.ts index 5d2e7bba..3b851264 100644 --- a/src/main/local-tool-environment/local-tool-environment-service.test.ts +++ b/src/main/local-tool-environment/local-tool-environment-service.test.ts @@ -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 = { diff --git a/src/main/local-tool-environment/local-tool-environment-service.ts b/src/main/local-tool-environment/local-tool-environment-service.ts index bf730ed3..2247987a 100644 --- a/src/main/local-tool-environment/local-tool-environment-service.ts +++ b/src/main/local-tool-environment/local-tool-environment-service.ts @@ -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 { } } +async function childDirectories( + root: string | undefined, + suffix: readonly string[] = [] +): Promise { + 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 { + const directories: string[] = [] + const seen = new Set() + 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 { - 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> = 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)) { diff --git a/src/renderer/src/ToolEnvironmentSettingsSection.test.tsx b/src/renderer/src/ToolEnvironmentSettingsSection.test.tsx index 43fec16a..aa1d49c3 100644 --- a/src/renderer/src/ToolEnvironmentSettingsSection.test.tsx +++ b/src/renderer/src/ToolEnvironmentSettingsSection.test.tsx @@ -189,6 +189,80 @@ describe('ToolEnvironmentSettingsSection', () => { expect(onNotify).not.toHaveBeenCalled() }) + it('keeps custom Python operable without implicitly selecting the first candidate', async () => { + render() + + 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() + + 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( diff --git a/src/renderer/src/ToolEnvironmentSettingsSection.tsx b/src/renderer/src/ToolEnvironmentSettingsSection.tsx index f56c86ad..7656c08f 100644 --- a/src/renderer/src/ToolEnvironmentSettingsSection.tsx +++ b/src/renderer/src/ToolEnvironmentSettingsSection.tsx @@ -52,6 +52,12 @@ export function ToolEnvironmentSettingsSection({ const [actionError, setActionError] = useState() const [busy, setBusy] = useState() const [confirmingRemove, setConfirmingRemove] = useState(false) + const [customOpen, setCustomOpen] = useState< + Record + >({ node: false, python: false }) + const [runtimeErrors, setRuntimeErrors] = useState< + Partial> + >({}) const pythonCancellationRequested = useRef(false) const toolApi = window.goodbuddy.localToolEnvironment @@ -98,15 +104,22 @@ export function ToolEnvironmentSettingsSection({ const updateSettings = async ( nextSettings: LocalToolEnvironmentSettings, - successMessage: string - ): Promise => { + successMessage: string, + errorKind?: LocalToolKind + ): Promise => { 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 => { + ): Promise => { 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 => { + 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 => { + 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, @@ -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 (
@@ -286,12 +374,24 @@ export function ToolEnvironmentSettingsSection({ -
-
- - + {showCustom && ( +
+ {selection.source !== 'custom' && ( +

+ {t('toolEnvironment.chooseCustomPrompt', { + runtime: t(`toolEnvironment.runtimes.${kind}.title`) + })} +

+ )} +
+ + +
+ {customError && ( +

+ {customError} +

+ )} + {selectedCustomPath && ( +

+ {t('toolEnvironment.selectedPath')} + {selectedCustomPath} +

+ )} + {candidates.length > 0 ? ( +
    + {candidates.map((candidate) => { + return ( +
  • + +
  • + ) + })} +
+ ) : ( +

+ {t('toolEnvironment.noCandidates')} +

+ )}
- {selectedCustomPath && ( -

- {t('toolEnvironment.selectedPath')} - {selectedCustomPath} -

- )} - {candidates.length > 0 ? ( -
    - {candidates.map((candidate) => { - return ( -
  • - -
  • - ) - })} -
- ) : ( -

- {t('toolEnvironment.noCandidates')} -

- )} -
+ )}

{t('toolEnvironment.toolStatus')}

diff --git a/src/renderer/src/i18n/locales/en-US/settingsSections.ts b/src/renderer/src/i18n/locales/en-US/settingsSections.ts index 8be5ab0e..5d0d3104 100644 --- a/src/renderer/src/i18n/locales/en-US/settingsSections.ts +++ b/src/renderer/src/i18n/locales/en-US/settingsSections.ts @@ -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', diff --git a/src/renderer/src/i18n/locales/zh-CN/settingsSections.ts b/src/renderer/src/i18n/locales/zh-CN/settingsSections.ts index a41f221f..3202c694 100644 --- a/src/renderer/src/i18n/locales/zh-CN/settingsSections.ts +++ b/src/renderer/src/i18n/locales/zh-CN/settingsSections.ts @@ -728,8 +728,10 @@ export const settingsSections = { }, selectedPath: '当前自定义路径', candidateList: '{{runtime}} 可执行文件候选', + chooseCustomPrompt: + '请选择一个已验证的 {{runtime}} 候选,或选择其他可执行文件。完成验证前不会更改当前执行环境。', noCandidates: - '未发现候选文件。可刷新候选,或从系统文件选择器选择可执行文件。', + '未发现有效候选。可扫描用户/系统 PATH、Conda、虚拟环境和常见安装位置,或选择其他可执行文件。', toolStatus: '工具状态', companionStatus: '工具链/配套工具状态', capabilityDependencyStatus: '能力依赖状态',