From c5c4298283f489532970d47401b87d50c1a42b01 Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Fri, 7 Aug 2026 13:54:31 -0700 Subject: [PATCH 1/2] Add engagement-based marketplace review prompt Prompt established users after successful environment selection or creation across distinct days, with a once-only Marketplace action. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f449e51d-c3f9-40cc-9e9d-1a24773e7712 --- src/common/localize.ts | 7 + src/extension.ts | 34 ++- src/features/envCommands.ts | 18 +- .../feedback/feedbackPromptService.ts | 257 ++++++++++++++++++ .../feedbackPromptService.unit.test.ts | 209 ++++++++++++++ 5 files changed, 514 insertions(+), 11 deletions(-) create mode 100644 src/features/feedback/feedbackPromptService.ts create mode 100644 src/test/features/feedback/feedbackPromptService.unit.test.ts diff --git a/src/common/localize.ts b/src/common/localize.ts index c18e7340..22dd34b9 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -23,6 +23,13 @@ export namespace WorkbenchStrings { export const installExtension = l10n.t('Install Extension'); } +export namespace FeedbackStrings { + export const prompt = l10n.t( + 'Would you like to share an honest review of Python Environments on the Visual Studio Marketplace?', + ); + export const reviewMarketplace = l10n.t('Review on Marketplace'); +} + export namespace Interpreter { export const statusBarSelect = l10n.t('Select Interpreter'); export const browsePath = l10n.t('Browse...'); diff --git a/src/extension.ts b/src/extension.ts index c4c2e2a9..6389733b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -33,6 +33,7 @@ import { createLogOutputChannel, onDidChangeActiveTerminal, onDidChangeTerminalShellIntegration, + onDidChangeWindowState, withProgress, } from './common/window.apis'; import { getConfiguration, getWorkspaceFolders } from './common/workspace.apis'; @@ -65,6 +66,7 @@ import { } from './features/envCommands'; import { PythonEnvironmentManagers } from './features/envManagers'; import { EnvVarManager, PythonEnvVariableManager } from './features/execution/envVariableManager'; +import { FeedbackPromptService } from './features/feedback/feedbackPromptService'; import { InlineScriptLazyDetector } from './features/inlineScript/lazyDetector'; import { applyInitialEnvironmentSelection, @@ -165,6 +167,16 @@ export async function activate(context: ExtensionContext): Promise { + if (state.focused) { + feedbackPrompt.notifyWindowFocused(); + } + }), + ); // One-time migration: remove `system` defaultEnvManager from User settings if a previous // version wrote it there (bug #1468). Awaited so the migration deterministically affects @@ -274,7 +286,7 @@ export async function activate(context: ExtensionContext): Promise { // Telemetry: record environment creation attempt with no specific manager @@ -290,7 +306,7 @@ export async function activate(context: ExtensionContext): Promise { await removeEnvironmentCommand(item, envManagers); @@ -326,10 +346,16 @@ export async function activate(context: ExtensionContext): Promise { - await setEnvironmentCommand(item, envManagers, projectManager); + const environment = await setEnvironmentCommand(item, envManagers, projectManager); + if (environment) { + await feedbackPrompt.recordSuccessfulAction(); + } }), commands.registerCommand('python-envs.setEnv', async (item) => { - await setEnvironmentCommand(item, envManagers, projectManager); + const environment = await setEnvironmentCommand(item, envManagers, projectManager); + if (environment) { + await feedbackPrompt.recordSuccessfulAction(); + } if (item instanceof PythonEnvTreeItem) { temporaryStateManager.setState(item.environment.envId.id, 'selected'); } diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index a9ff8a0e..e3b82839 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -417,7 +417,7 @@ export async function setEnvironmentCommand( context: unknown, em: EnvironmentManagers, wm: PythonProjectManager, -): Promise { +): Promise { if (context instanceof PythonEnvTreeItem) { try { const view = context as PythonEnvTreeItem; @@ -427,23 +427,25 @@ export async function setEnvironmentCommand( if (selected && selected.length > 0) { // Check if the selected environment is already the current one for each project await setEnvironmentForProjects(selected, context.environment, em); + return view.environment; } } else { await em.setEnvironments('global', view.environment); + return view.environment; } } catch (ex) { if (ex === QuickInputButtons.Back) { - await setEnvironmentCommand(context, em, wm); + return await setEnvironmentCommand(context, em, wm); } throw ex; } } else if (context instanceof ProjectItem) { const view = context as ProjectItem; - await setEnvironmentCommand([view.project.uri], em, wm); + return await setEnvironmentCommand([view.project.uri], em, wm); } else if (context instanceof GlobalProjectItem) { - await setEnvironmentCommand(undefined, em, wm); + return await setEnvironmentCommand(undefined, em, wm); } else if (context instanceof Uri) { - await setEnvironmentCommand([context], em, wm); + return await setEnvironmentCommand([context], em, wm); } else if (context === undefined) { try { const projects = wm.getProjects(); @@ -451,7 +453,7 @@ export async function setEnvironmentCommand( const selected = await pickProjectMany(projects); if (selected && selected.length > 0) { const uris = selected.map((p) => p.uri); - await setEnvironmentCommand(uris, em, wm); + return await setEnvironmentCommand(uris, em, wm); } } else { const globalEnvManager = em.getEnvironmentManager(undefined); @@ -463,11 +465,12 @@ export async function setEnvironmentCommand( }); if (selected) { await em.setEnvironments('global', selected); + return selected; } } } catch (ex) { if (ex === QuickInputButtons.Back) { - await setEnvironmentCommand(context, em, wm); + return await setEnvironmentCommand(context, em, wm); } throw ex; } @@ -486,6 +489,7 @@ export async function setEnvironmentCommand( if (selected) { // Use the same logic for checking already set environments await setEnvironmentForProjects(projects, selected, em); + return selected; } } else { traceError(`Invalid context for setting environment command: ${context}`); diff --git a/src/features/feedback/feedbackPromptService.ts b/src/features/feedback/feedbackPromptService.ts new file mode 100644 index 00000000..c04721d6 --- /dev/null +++ b/src/features/feedback/feedbackPromptService.ts @@ -0,0 +1,257 @@ +import { promises as fs } from 'fs'; +import * as path from 'path'; +import { Disposable, env, Memento, UIKind, window } from 'vscode'; +import { launchBrowser } from '../../common/env.apis'; +import { FeedbackStrings } from '../../common/localize'; +import { traceError } from '../../common/logging'; +import { showInformationMessage } from '../../common/window.apis'; +import { getConfiguration } from '../../common/workspace.apis'; + +export const FEEDBACK_PROMPT_STATE_KEY = 'python-envs:feedbackPrompt:v2'; + +const MARKETPLACE_REVIEWS_URL = + 'https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-python-envs&tab=RatingsAndReviews'; +const PROMPT_CLAIM_FILE = 'feedback-prompt-shown'; +const MINIMUM_AGE_MS = 14 * 24 * 60 * 60 * 1000; +const MINIMUM_ACTIVE_DAYS = 9; +const MINIMUM_SUCCESSFUL_ACTION_DAYS = 2; +const QUIET_PERIOD_MS = 60 * 1000; +const MAX_TRACKED_DAYS = 30; + +export interface FeedbackPromptState { + firstSeenAt: number; + activeDays: string[]; + successfulActionDays: string[]; +} + +interface FeedbackPromptDependencies { + now: () => number; + schedule: (callback: () => void, delayMs: number) => ReturnType; + cancelSchedule: (timer: ReturnType) => void; + showInformationMessage: typeof showInformationMessage; + launchBrowser: typeof launchBrowser; + isWindowFocused: () => boolean; + isWeb: () => boolean; + isFeedbackEnabled: () => boolean; + claimPrompt: () => Promise; + releasePromptClaim: () => Promise; +} + +const defaultDependencies: Omit = { + now: () => Date.now(), + schedule: (callback, delayMs) => setTimeout(callback, delayMs), + cancelSchedule: (timer) => clearTimeout(timer), + showInformationMessage, + launchBrowser, + isWindowFocused: () => window.state.focused, + isWeb: () => env.uiKind === UIKind.Web, + isFeedbackEnabled: () => getConfiguration('telemetry').get('feedback.enabled', true), +}; + +function utcDay(timestamp: number): string { + return new Date(timestamp).toISOString().slice(0, 10); +} + +function boundedDays(days: string[]): string[] { + return [...new Set(days)].slice(-MAX_TRACKED_DAYS); +} + +function normalizeState(value: FeedbackPromptState | undefined, now: number): FeedbackPromptState { + return { + firstSeenAt: typeof value?.firstSeenAt === 'number' ? value.firstSeenAt : now, + activeDays: boundedDays(Array.isArray(value?.activeDays) ? value.activeDays : []), + successfulActionDays: boundedDays( + Array.isArray(value?.successfulActionDays) ? value.successfulActionDays : [], + ), + }; +} + +function mergeStates(first: FeedbackPromptState, second: FeedbackPromptState): FeedbackPromptState { + return { + firstSeenAt: Math.min(first.firstSeenAt, second.firstSeenAt), + activeDays: boundedDays([...first.activeDays, ...second.activeDays]), + successfulActionDays: boundedDays([ + ...first.successfulActionDays, + ...second.successfulActionDays, + ]), + }; +} + +export function isFeedbackPromptEligible(state: FeedbackPromptState, now: number): boolean { + return ( + now - state.firstSeenAt >= MINIMUM_AGE_MS && + new Set(state.activeDays).size >= MINIMUM_ACTIVE_DAYS && + new Set(state.successfulActionDays).size >= MINIMUM_SUCCESSFUL_ACTION_DAYS + ); +} + +export class FeedbackPromptService implements Disposable { + private state: FeedbackPromptState; + private stateWrite = Promise.resolve(); + private promptShown: boolean; + private successfulActionThisSession = false; + private promptInProgress = false; + private disposed = false; + private timer: ReturnType | undefined; + private readonly dependencies: FeedbackPromptDependencies; + + constructor( + private readonly globalState: Memento, + globalStoragePath: string, + dependencies: Partial = {}, + private readonly quietPeriodMs = QUIET_PERIOD_MS, + ) { + this.dependencies = { + ...defaultDependencies, + claimPrompt: () => claimPrompt(globalStoragePath), + releasePromptClaim: () => releasePromptClaim(globalStoragePath), + ...dependencies, + }; + this.promptShown = false; + this.state = normalizeState( + globalState.get(FEEDBACK_PROMPT_STATE_KEY), + this.dependencies.now(), + ); + } + + async initialize(): Promise { + const day = utcDay(this.dependencies.now()); + await this.updateState((state) => { + if (state.activeDays.includes(day)) { + return false; + } + state.activeDays = boundedDays([...state.activeDays, day]); + return true; + }); + } + + async recordSuccessfulAction(): Promise { + const day = utcDay(this.dependencies.now()); + await this.updateState((state) => { + if (state.successfulActionDays.includes(day)) { + return false; + } + state.successfulActionDays = boundedDays([...state.successfulActionDays, day]); + return true; + }); + this.successfulActionThisSession = true; + this.scheduleEvaluation(); + } + + notifyWindowFocused(): void { + this.scheduleEvaluation(); + } + + async showPromptIfEligible(): Promise { + await this.stateWrite; + if (!this.canShowPrompt()) { + return; + } + + this.cancelEvaluation(); + this.promptInProgress = true; + try { + if (!(await this.dependencies.claimPrompt())) { + this.promptShown = true; + return; + } + if (!this.canShowPrompt(true)) { + await this.dependencies.releasePromptClaim(); + this.scheduleEvaluation(); + return; + } + + this.promptShown = true; + const selection = await this.dependencies.showInformationMessage( + FeedbackStrings.prompt, + FeedbackStrings.reviewMarketplace, + ); + if (selection === FeedbackStrings.reviewMarketplace) { + await this.dependencies.launchBrowser(MARKETPLACE_REVIEWS_URL); + } + } catch (error) { + traceError('Failed to show or handle the feedback prompt:', error); + } finally { + this.promptInProgress = false; + } + } + + dispose(): void { + this.disposed = true; + this.cancelEvaluation(); + } + + private canShowPrompt(ignorePromptInProgress = false): boolean { + return ( + !this.disposed && + (ignorePromptInProgress || !this.promptInProgress) && + !this.promptShown && + this.successfulActionThisSession && + !this.dependencies.isWeb() && + this.dependencies.isWindowFocused() && + this.dependencies.isFeedbackEnabled() && + isFeedbackPromptEligible(this.state, this.dependencies.now()) + ); + } + + private async updateState(mutator: (state: FeedbackPromptState) => boolean): Promise { + this.stateWrite = this.stateWrite + .then(async () => { + this.state = mergeStates( + this.state, + normalizeState( + this.globalState.get(FEEDBACK_PROMPT_STATE_KEY), + this.dependencies.now(), + ), + ); + if (!mutator(this.state)) { + return; + } + await this.globalState.update(FEEDBACK_PROMPT_STATE_KEY, this.state); + }) + .catch((error) => traceError('Failed to persist feedback prompt state:', error)); + await this.stateWrite; + } + + private scheduleEvaluation(): void { + if (this.disposed || this.promptShown || !this.successfulActionThisSession) { + return; + } + this.cancelEvaluation(); + this.timer = this.dependencies.schedule(() => { + this.timer = undefined; + void this.showPromptIfEligible(); + }, this.quietPeriodMs); + } + + private cancelEvaluation(): void { + if (this.timer !== undefined) { + this.dependencies.cancelSchedule(this.timer); + this.timer = undefined; + } + } +} + +async function claimPrompt(globalStoragePath: string): Promise { + await fs.mkdir(globalStoragePath, { recursive: true }); + try { + const handle = await fs.open(path.join(globalStoragePath, PROMPT_CLAIM_FILE), 'wx'); + await handle.close(); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + return false; + } + throw error; + } +} + +async function releasePromptClaim(globalStoragePath: string): Promise { + try { + await fs.unlink(path.join(globalStoragePath, PROMPT_CLAIM_FILE)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } +} diff --git a/src/test/features/feedback/feedbackPromptService.unit.test.ts b/src/test/features/feedback/feedbackPromptService.unit.test.ts new file mode 100644 index 00000000..09b99fac --- /dev/null +++ b/src/test/features/feedback/feedbackPromptService.unit.test.ts @@ -0,0 +1,209 @@ +import assert from 'assert'; +import * as fs from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { + FEEDBACK_PROMPT_STATE_KEY, + FeedbackPromptService, + FeedbackPromptState, + isFeedbackPromptEligible, +} from '../../../features/feedback/feedbackPromptService'; +import { FeedbackStrings } from '../../../common/localize'; +import { MockMemento } from '../../mocks/mementos'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const NOW = Date.UTC(2026, 7, 4, 12); + +function day(offset: number): string { + return new Date(NOW + offset * DAY_MS).toISOString().slice(0, 10); +} + +function eligibleState(overrides: Partial = {}): FeedbackPromptState { + return { + firstSeenAt: NOW - 20 * DAY_MS, + activeDays: Array.from({ length: 9 }, (_, index) => day(index - 8)), + successfulActionDays: [day(-1), day(0)], + ...overrides, + }; +} + +function dependencies( + now: () => number, + showInformationMessage = sinon.stub().resolves(undefined), + launchBrowser = sinon.stub().resolves(true), +) { + return { + now, + showInformationMessage, + launchBrowser, + isWindowFocused: () => true, + isWeb: () => false, + isFeedbackEnabled: () => true, + claimPrompt: async () => true, + releasePromptClaim: async () => undefined, + }; +} + +suite('FeedbackPromptService', () => { + teardown(() => { + sinon.restore(); + }); + + test('requires age, active days, and successful actions on two days', () => { + assert.strictEqual(isFeedbackPromptEligible(eligibleState(), NOW), true); + assert.strictEqual( + isFeedbackPromptEligible(eligibleState({ firstSeenAt: NOW - 13 * DAY_MS }), NOW), + false, + ); + assert.strictEqual( + isFeedbackPromptEligible( + eligibleState({ activeDays: eligibleState().activeDays.slice(1) }), + NOW, + ), + false, + ); + assert.strictEqual( + isFeedbackPromptEligible(eligibleState({ successfulActionDays: [day(0)] }), NOW), + false, + ); + }); + + test('records active and successful-action days without same-day inflation', async () => { + const memento = new MockMemento(); + let now = NOW; + const service = new FeedbackPromptService(memento, 'unused', dependencies(() => now)); + + await service.initialize(); + await service.initialize(); + await service.recordSuccessfulAction(); + await service.recordSuccessfulAction(); + now += DAY_MS; + await service.recordSuccessfulAction(); + + const state = memento.get(FEEDBACK_PROMPT_STATE_KEY) as FeedbackPromptState; + assert.deepStrictEqual(state.activeDays, [day(0)]); + assert.deepStrictEqual(state.successfulActionDays, [day(0), day(1)]); + service.dispose(); + }); + + test('prompts only after a successful action in the current session', async () => { + const memento = new MockMemento(); + await memento.update(FEEDBACK_PROMPT_STATE_KEY, eligibleState()); + const showInformationMessage = sinon.stub().resolves(FeedbackStrings.reviewMarketplace); + const launchBrowser = sinon.stub().resolves(true); + const service = new FeedbackPromptService( + memento, + 'unused', + dependencies(() => NOW, showInformationMessage, launchBrowser), + ); + + await service.initialize(); + await service.showPromptIfEligible(); + assert.strictEqual(showInformationMessage.callCount, 0); + + await service.recordSuccessfulAction(); + await service.showPromptIfEligible(); + + assert.strictEqual(showInformationMessage.callCount, 1); + assert.deepStrictEqual(showInformationMessage.firstCall.args.slice(1), [ + FeedbackStrings.reviewMarketplace, + ]); + assert.strictEqual(launchBrowser.callCount, 1); + assert.match(launchBrowser.firstCall.args[0].toString(), /tab=RatingsAndReviews$/); + service.dispose(); + }); + + test('never prompts a second time, including after dismissal', async () => { + const memento = new MockMemento(); + await memento.update(FEEDBACK_PROMPT_STATE_KEY, eligibleState()); + const showInformationMessage = sinon.stub().resolves(undefined); + const service = new FeedbackPromptService( + memento, + 'unused', + dependencies(() => NOW, showInformationMessage), + ); + + await service.initialize(); + await service.recordSuccessfulAction(); + await service.showPromptIfEligible(); + await service.showPromptIfEligible(); + + assert.strictEqual(showInformationMessage.callCount, 1); + service.dispose(); + }); + + test('claims the prompt atomically across extension hosts', async () => { + const storagePath = path.join(os.tmpdir(), `python-envs-feedback-${process.pid}-${Date.now()}`); + const firstMemento = new MockMemento(); + const secondMemento = new MockMemento(); + await firstMemento.update(FEEDBACK_PROMPT_STATE_KEY, eligibleState()); + await secondMemento.update(FEEDBACK_PROMPT_STATE_KEY, eligibleState()); + const showInformationMessage = sinon.stub().resolves(undefined); + const sharedDependencies = { + now: () => NOW, + showInformationMessage, + launchBrowser: sinon.stub(), + isWindowFocused: () => true, + isWeb: () => false, + isFeedbackEnabled: () => true, + }; + const first = new FeedbackPromptService(firstMemento, storagePath, sharedDependencies); + const second = new FeedbackPromptService(secondMemento, storagePath, sharedDependencies); + + try { + await Promise.all([first.initialize(), second.initialize()]); + await Promise.all([first.recordSuccessfulAction(), second.recordSuccessfulAction()]); + await Promise.all([first.showPromptIfEligible(), second.showPromptIfEligible()]); + + assert.strictEqual(showInformationMessage.callCount, 1); + } finally { + first.dispose(); + second.dispose(); + await fs.remove(storagePath); + } + }); + + test('suppresses web, unfocused, and feedback-disabled sessions', async () => { + const cases = [ + { isWeb: () => true }, + { isWindowFocused: () => false }, + { isFeedbackEnabled: () => false }, + ]; + for (const overrides of cases) { + const memento = new MockMemento(); + await memento.update(FEEDBACK_PROMPT_STATE_KEY, eligibleState()); + const showInformationMessage = sinon.stub(); + const service = new FeedbackPromptService(memento, 'unused', { + ...dependencies(() => NOW, showInformationMessage), + ...overrides, + }); + + await service.initialize(); + await service.recordSuccessfulAction(); + await service.showPromptIfEligible(); + + assert.strictEqual(showInformationMessage.callCount, 0); + service.dispose(); + } + }); + + test('uses a one-minute quiet delay and cancels it on disposal', async () => { + const memento = new MockMemento(); + const schedule = sinon.stub().returns(123 as unknown as ReturnType); + const cancelSchedule = sinon.stub(); + const service = new FeedbackPromptService(memento, 'unused', { + ...dependencies(() => NOW), + schedule, + cancelSchedule, + }); + + await service.initialize(); + await service.recordSuccessfulAction(); + service.dispose(); + + assert.strictEqual(schedule.callCount, 1); + assert.strictEqual(schedule.firstCall.args[1], 60_000); + assert.strictEqual(cancelSchedule.callCount, 1); + }); +}); From 2e4b7cdaa05969e75caa0982bf16cb5f57c40527 Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Fri, 7 Aug 2026 14:06:14 -0700 Subject: [PATCH 2/2] Refine marketplace review prompt wording Use concise, neutral language for the feedback notification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f449e51d-c3f9-40cc-9e9d-1a24773e7712 --- src/common/localize.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/common/localize.ts b/src/common/localize.ts index 22dd34b9..7cb19e96 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -24,9 +24,7 @@ export namespace WorkbenchStrings { } export namespace FeedbackStrings { - export const prompt = l10n.t( - 'Would you like to share an honest review of Python Environments on the Visual Studio Marketplace?', - ); + export const prompt = l10n.t('Would you like to leave a review for Python Environments?'); export const reviewMarketplace = l10n.t('Review on Marketplace'); }