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:
@@ -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