From 9f6b241c909bf9f971f5bae22816501a9db81067 Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Tue, 15 Sep 2026 16:39:05 -0700 Subject: [PATCH 1/2] feat: confirm inline-script env setup with a short-lived CodeLens Setting up a PEP 723 inline-script environment previously signalled success only by the setup CodeLens disappearing, which is indistinguishable from the lens never having been offered. It also left the chosen base interpreter invisible, which matters when `requires-python` matches several installed Pythons or when one was installed on demand. For five seconds after setup succeeds the hidden setup lens is replaced by a passive "Script environment ready (Python X.Y.Z)" confirmation anchored at the `# /// script` block, which then expires on its own. That line is already occupied by the setup lens at that point, so this adds no extra reflow. Every other CodeLens path is unchanged: the dirty guard, the metadata check, shouldRoute semantics, and the setup lens title, command and anchor are all byte-identical. Both setup surfaces (the CodeLens and the unresolved-import quick fix) show the confirmation; the bulk command does not, since it already reports its own summary. Confirmations are in-memory and per-window, and their timers are cleared on disposal. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/managing-python-projects.md | 2 + src/common/localize.ts | 7 ++ src/features/inlineScript/codeLens.ts | 74 ++++++++++++++++--- src/features/inlineScript/setupEnvironment.ts | 13 +++- .../inlineScript/codeLens.unit.test.ts | 64 +++++++++++++++- .../setupEnvironment.unit.test.ts | 32 +++++++- 6 files changed, 175 insertions(+), 17 deletions(-) diff --git a/docs/managing-python-projects.md b/docs/managing-python-projects.md index eedca05f..487e8d80 100644 --- a/docs/managing-python-projects.md +++ b/docs/managing-python-projects.md @@ -99,6 +99,8 @@ When you create a script, the extension generates a single `.py` file with PEP 7 An inline-script environment is built from the script's `# /// script` block and stored in the extension's cache, where it is shared by every script with the same dependencies and base interpreter. Because editing one would silently change the others, these environments are not user-managed: the Python Environments views do not offer install, uninstall, or version-change actions for them. Their package list remains visible. +A CodeLens above the `# /// script` block offers **Set up environment for this script**, and the same action is available as a quick fix on an unresolved import. For a few seconds after setup succeeds it is replaced by a **Script environment ready (Python X.Y.Z)** confirmation naming the Python that was selected — useful when `requires-python` matches several installed versions, or when one was installed on demand. The confirmation is plain text rather than a clickable action, and it expires on its own; at every other time the setup CodeLens behaves exactly as before. + Setup records which distributions it installed. If that record and the environment's contents later disagree — for example after installing a package into it from a terminal — every script sharing the environment needs setup again. Saving or reopening a script does not repair it; use the script's setup action to rebuild from its declared dependencies. Once a mismatch is confirmed during an environment lookup, the affected scripts' setup actions return without requiring a save. diff --git a/src/common/localize.ts b/src/common/localize.ts index b528b7fc..cec38487 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -44,6 +44,13 @@ export namespace InlineScriptStrings { export const diagnosticSource = l10n.t('Python Environments'); + export function environmentReady(version: string | undefined): string { + const shown = version?.trim(); + return shown + ? l10n.t('Script environment ready (Python {0})', shown) + : l10n.t('Script environment ready'); + } + export const unterminatedBlock = l10n.t( "This '# /// script' block is missing its closing '# ///' marker, so its inline script metadata is ignored.", ); diff --git a/src/features/inlineScript/codeLens.ts b/src/features/inlineScript/codeLens.ts index f605134c..e0b374d9 100644 --- a/src/features/inlineScript/codeLens.ts +++ b/src/features/inlineScript/codeLens.ts @@ -11,8 +11,12 @@ import { languages, Range, TextDocument, + Uri, } from 'vscode'; -import { InlineScriptRoutingRegistry } from '../../common/inlineScript/routingRegistry'; +import { getInlineScriptRoutingKey, InlineScriptRoutingRegistry } from '../../common/inlineScript/routingRegistry'; +import { InlineScriptStrings } from '../../common/localize'; + +export const READY_CONFIRMATION_TIMEOUT_MS = 5000; /** * Shows a single "Set up environment for this script" CodeLens above a `.py` file's PEP 723 @@ -29,10 +33,16 @@ export class InlineScriptCodeLensProvider implements CodeLensProvider, Disposabl private readonly _onDidChangeCodeLenses = new EventEmitter(); public readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event; private readonly subscriptions: Disposable[] = []; + private readonly readyConfirmations = new Map< + string, + { readonly version: string | undefined; readonly timer: ReturnType } + >(); + private disposed = false; constructor( private readonly routing: InlineScriptRoutingRegistry, private readonly setupCommand: string, + private readonly confirmationTimeoutMs: number = READY_CONFIRMATION_TIMEOUT_MS, ) { this.subscriptions.push( this.routing.onDidChangeRouteability(() => this._onDidChangeCodeLenses.fire()), @@ -47,6 +57,22 @@ export class InlineScriptCodeLensProvider implements CodeLensProvider, Disposabl ); } + public noteEnvironmentReady(uri: Uri, version: string | undefined): void { + const key = getInlineScriptRoutingKey(uri); + if (this.disposed || !key) { + return; + } + this.clearConfirmation(key); + this.readyConfirmations.set(key, { + version, + timer: setTimeout(() => { + this.readyConfirmations.delete(key); + this._onDidChangeCodeLenses.fire(); + }, this.confirmationTimeoutMs), + }); + this._onDidChangeCodeLenses.fire(); + } + public provideCodeLenses(document: TextDocument, _token: CancellationToken): CodeLens[] { if (document.isDirty) { // The association is validated against the saved file (the manager refuses to validate a @@ -60,13 +86,24 @@ export class InlineScriptCodeLensProvider implements CodeLensProvider, Disposabl // No saved PEP 723 metadata (or it is currently being edited). return []; } - if (this.routing.shouldRoute(uri)) { - // A validated inline-script environment matching the current metadata already exists. - return []; - } const offset = metadata.sourceRange?.start ?? metadata.range.start; const position = document.positionAt(offset); const range = new Range(position, position); + if (this.routing.shouldRoute(uri)) { + // A validated inline-script environment matching the current metadata already exists. + const key = getInlineScriptRoutingKey(uri); + const confirmation = key ? this.readyConfirmations.get(key) : undefined; + if (!confirmation) { + return []; + } + // An empty command id renders the title as plain, non-clickable text. + return [ + new CodeLens(range, { + title: InlineScriptStrings.environmentReady(confirmation.version), + command: '', + }), + ]; + } return [ new CodeLens(range, { title: l10n.t('Set up environment for this script'), @@ -77,21 +114,38 @@ export class InlineScriptCodeLensProvider implements CodeLensProvider, Disposabl } public dispose(): void { + this.disposed = true; this.subscriptions.forEach((s) => s.dispose()); this.subscriptions.length = 0; + this.readyConfirmations.forEach((confirmation) => clearTimeout(confirmation.timer)); + this.readyConfirmations.clear(); this._onDidChangeCodeLenses.dispose(); } + + private clearConfirmation(key: string): void { + const existing = this.readyConfirmations.get(key); + if (existing) { + clearTimeout(existing.timer); + this.readyConfirmations.delete(key); + } + } } /** * Register the inline-script CodeLens provider for local `.py` files. Only called when the PEP 723 * inline-script feature flag is enabled, so it is a no-op for everyone else. */ -export function registerInlineScriptCodeLens(routing: InlineScriptRoutingRegistry, setupCommand: string): Disposable { +export function registerInlineScriptCodeLens( + routing: InlineScriptRoutingRegistry, + setupCommand: string, +): { readonly disposable: Disposable; readonly provider: InlineScriptCodeLensProvider } { const provider = new InlineScriptCodeLensProvider(routing, setupCommand); const registration = languages.registerCodeLensProvider({ scheme: 'file', language: 'python' }, provider); - return new Disposable(() => { - registration.dispose(); - provider.dispose(); - }); + return { + provider, + disposable: new Disposable(() => { + registration.dispose(); + provider.dispose(); + }), + }; } diff --git a/src/features/inlineScript/setupEnvironment.ts b/src/features/inlineScript/setupEnvironment.ts index 488893fc..8923018b 100644 --- a/src/features/inlineScript/setupEnvironment.ts +++ b/src/features/inlineScript/setupEnvironment.ts @@ -17,6 +17,7 @@ import { } from '../../common/window.apis'; import { asRelativePath, findFiles, getOpenTextDocuments } from '../../common/workspace.apis'; import { EnvironmentManagers } from '../../internal.api'; +import { shortenVersionString } from '../../managers/common/utils'; import { registerInlineScriptCodeLens } from './codeLens'; import { promptUpdateExtensionsForInlineScripts } from './extensionVersionCheck'; import { registerInlineScriptSetupCodeAction } from './setupCodeAction'; @@ -134,6 +135,7 @@ async function saveScriptBeforeSetup(scriptUri: Uri, routing: InlineScriptRoutin export function setupInlineScriptEnvironmentHandler( em: EnvironmentManagers, routing: InlineScriptRoutingRegistry, + onEnvironmentReady?: (scriptUri: Uri, version: string | undefined) => void, ): (scriptUri?: Uri) => Promise { return async (scriptUri?: Uri): Promise => { const uri = scriptUri ?? window.activeTextEditor?.document.uri; @@ -164,6 +166,7 @@ export function setupInlineScriptEnvironmentHandler( notifyInlineScriptSetupOutcome(uri, routing); return; } + onEnvironmentReady?.(uri, shortenVersionString(environment.version)); // Kept out of the try: the environment is already set up, so a failure in this follow-up // must not be reported to the user as a setup failure. await promptUpdateExtensionsForInlineScripts().catch((error) => @@ -362,10 +365,16 @@ async function filterInlineScriptFiles(files: readonly Uri[]): Promise { * palette-gated behind the flag. */ export function registerInlineScriptUx(em: EnvironmentManagers, routing: InlineScriptRoutingRegistry): Disposable[] { + const codeLens = registerInlineScriptCodeLens(routing, SETUP_INLINE_SCRIPT_ENV_COMMAND); return [ - registerInlineScriptCodeLens(routing, SETUP_INLINE_SCRIPT_ENV_COMMAND), + codeLens.disposable, registerInlineScriptSetupCodeAction(routing, SETUP_INLINE_SCRIPT_ENV_COMMAND), - commands.registerCommand(SETUP_INLINE_SCRIPT_ENV_COMMAND, setupInlineScriptEnvironmentHandler(em, routing)), + commands.registerCommand( + SETUP_INLINE_SCRIPT_ENV_COMMAND, + setupInlineScriptEnvironmentHandler(em, routing, (uri, version) => + codeLens.provider.noteEnvironmentReady(uri, version), + ), + ), commands.registerCommand(SETUP_INLINE_SCRIPT_ENVS_COMMAND, () => setUpInlineScriptEnvironmentsInWorkspace(em, routing), ), diff --git a/src/test/features/inlineScript/codeLens.unit.test.ts b/src/test/features/inlineScript/codeLens.unit.test.ts index 935af095..fcbc619a 100644 --- a/src/test/features/inlineScript/codeLens.unit.test.ts +++ b/src/test/features/inlineScript/codeLens.unit.test.ts @@ -2,10 +2,11 @@ // Licensed under the MIT License. import assert from 'assert'; +import * as sinon from 'sinon'; import { Position, TextDocument, Uri } from 'vscode'; import { InlineScriptMetadata } from '../../../common/inlineScript/metadata'; import { InlineScriptRoutingRegistry } from '../../../common/inlineScript/routingRegistry'; -import { InlineScriptCodeLensProvider } from '../../../features/inlineScript/codeLens'; +import { InlineScriptCodeLensProvider, READY_CONFIRMATION_TIMEOUT_MS } from '../../../features/inlineScript/codeLens'; const SETUP_COMMAND = 'python-envs.setupInlineScriptEnv'; @@ -83,4 +84,65 @@ suite('Inline script CodeLens provider', () => { sub.dispose(); assert.ok(fireCount >= 1, 'onDidChangeCodeLenses should fire when routing state changes'); }); + + suite('post-setup confirmation', () => { + let clock: sinon.SinonFakeTimers; + + setup(() => { + clock = sinon.useFakeTimers(); + routing.setMetadata(scriptUri, makeMetadata()); + routing.setValidatedAssociation(scriptUri, true); + }); + + teardown(() => clock.restore()); + + test('replaces the hidden setup lens with a non-clickable confirmation naming the version', () => { + provider.noteEnvironmentReady(scriptUri, '3.12.4'); + + const lenses = provider.provideCodeLenses(makeDocument(scriptUri), {} as never); + + assert.strictEqual(lenses.length, 1); + assert.ok(lenses[0].command?.title.includes('3.12.4'), lenses[0].command?.title); + assert.strictEqual(lenses[0].command?.command, '', 'the confirmation must not be clickable'); + }); + + test('omits the version when none was resolved', () => { + provider.noteEnvironmentReady(scriptUri, undefined); + + const lenses = provider.provideCodeLenses(makeDocument(scriptUri), {} as never); + + assert.strictEqual(lenses.length, 1); + assert.ok(!lenses[0].command?.title.includes('('), lenses[0].command?.title); + }); + + test('expires on its own and refreshes so the lens disappears', () => { + provider.noteEnvironmentReady(scriptUri, '3.12.4'); + let fireCount = 0; + const sub = provider.onDidChangeCodeLenses(() => (fireCount += 1)); + + clock.tick(READY_CONFIRMATION_TIMEOUT_MS + 1); + sub.dispose(); + + assert.strictEqual(fireCount, 1, 'expiry must refresh the lenses'); + assert.strictEqual(provider.provideCodeLenses(makeDocument(scriptUri), {} as never).length, 0); + }); + + test('shows nothing for a routed script that was not just set up', () => { + assert.strictEqual(provider.provideCodeLenses(makeDocument(scriptUri), {} as never).length, 0); + }); + + test('stays hidden while the document is dirty', () => { + provider.noteEnvironmentReady(scriptUri, '3.12.4'); + + assert.strictEqual(provider.provideCodeLenses(makeDocument(scriptUri, true), {} as never).length, 0); + }); + + test('does not leak timers past disposal', () => { + provider.noteEnvironmentReady(scriptUri, '3.12.4'); + + provider.dispose(); + + assert.doesNotThrow(() => clock.tick(READY_CONFIRMATION_TIMEOUT_MS + 1)); + }); + }); }); diff --git a/src/test/features/inlineScript/setupEnvironment.unit.test.ts b/src/test/features/inlineScript/setupEnvironment.unit.test.ts index 6d184cbc..1fb85967 100644 --- a/src/test/features/inlineScript/setupEnvironment.unit.test.ts +++ b/src/test/features/inlineScript/setupEnvironment.unit.test.ts @@ -327,6 +327,7 @@ suite('setupInlineScriptEnvironmentHandler', () => { let errorStub: sinon.SinonStub; let saveStub: sinon.SinonStub; let promptStub: sinon.SinonStub; + let readySpy: sinon.SinonStub; setup(() => { em = typemoq.Mock.ofType(); @@ -338,6 +339,7 @@ suite('setupInlineScriptEnvironmentHandler', () => { errorStub = sinon.stub(winapi, 'showErrorMessage').resolves(undefined); sinon.stub(winapi, 'showInformationMessage').resolves(undefined); sinon.stub(winapi, 'showWarningMessage').resolves(undefined); + readySpy = sinon.stub(); promptStub = sinon.stub(extensionVersionCheck, 'promptUpdateExtensionsForInlineScripts').resolves(); saveStub = sinon.stub().resolves(true); }); @@ -358,8 +360,7 @@ suite('setupInlineScriptEnvironmentHandler', () => { return env; } - test('saves a dirty document before setup, because setup reads the block from disk', async () => { - openDirtyDocument(); + test('saves a dirty document before setup, because setup reads the block from disk', async () => { openDirtyDocument(); expectEnvironmentCreated(); await setupInlineScriptEnvironmentHandler(em.object, routing)(scriptUri); @@ -417,12 +418,35 @@ suite('setupInlineScriptEnvironmentHandler', () => { sinon.assert.calledOnce(errorStub); }); - test('does not report a failing companion-extension prompt as a setup failure', async () => { - expectEnvironmentCreated(); + test('does not report a failing companion-extension prompt as a setup failure', async () => { expectEnvironmentCreated(); promptStub.rejects(new Error('boom')); await setupInlineScriptEnvironmentHandler(em.object, routing)(scriptUri); sinon.assert.notCalled(errorStub); }); + + test('reports the ready environment and its Python version once setup succeeds', async () => { + expectEnvironmentCreated(); + + await setupInlineScriptEnvironmentHandler(em.object, routing, readySpy)(scriptUri); + + sinon.assert.calledOnceWithExactly(readySpy, scriptUri, '3.12.0'); + }); + + test('reports no ready environment when setup produced none', async () => { + manager.setup((m) => m.create(scriptUri, undefined)).returns(() => Promise.resolve(undefined)); + + await setupInlineScriptEnvironmentHandler(em.object, routing, readySpy)(scriptUri); + + sinon.assert.notCalled(readySpy); + }); + + test('reports no ready environment when setup throws', async () => { + manager.setup((m) => m.create(scriptUri, undefined)).returns(() => Promise.reject(new Error('boom'))); + + await setupInlineScriptEnvironmentHandler(em.object, routing, readySpy)(scriptUri); + + sinon.assert.notCalled(readySpy); + }); }); From c8fc337fea2d69c208fd73d7c50952fa89d6d644 Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Tue, 15 Sep 2026 17:20:07 -0700 Subject: [PATCH 2/2] test: assert the full confirmation titles instead of substrings The two CodeLens confirmation tests used partial assertions: `includes('3.12.4')` and `!includes('(')`. Both are weak -- the first passes on any message that merely contains the version, and the second only asserts the absence of a character, so it verified nothing about the versionless text at all. Both now assert the complete localized title with assert.strictEqual. Confirmed load-bearing by mutation: renaming the message to "Script env ready" leaves both old assertions green while both new ones fail. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/test/features/inlineScript/codeLens.unit.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/features/inlineScript/codeLens.unit.test.ts b/src/test/features/inlineScript/codeLens.unit.test.ts index fcbc619a..46fb1bdc 100644 --- a/src/test/features/inlineScript/codeLens.unit.test.ts +++ b/src/test/features/inlineScript/codeLens.unit.test.ts @@ -102,7 +102,7 @@ suite('Inline script CodeLens provider', () => { const lenses = provider.provideCodeLenses(makeDocument(scriptUri), {} as never); assert.strictEqual(lenses.length, 1); - assert.ok(lenses[0].command?.title.includes('3.12.4'), lenses[0].command?.title); + assert.strictEqual(lenses[0].command?.title, 'Script environment ready (Python 3.12.4)'); assert.strictEqual(lenses[0].command?.command, '', 'the confirmation must not be clickable'); }); @@ -112,7 +112,7 @@ suite('Inline script CodeLens provider', () => { const lenses = provider.provideCodeLenses(makeDocument(scriptUri), {} as never); assert.strictEqual(lenses.length, 1); - assert.ok(!lenses[0].command?.title.includes('('), lenses[0].command?.title); + assert.strictEqual(lenses[0].command?.title, 'Script environment ready'); }); test('expires on its own and refreshes so the lens disappears', () => {