From b4748ccdf2fa9458332d36e394befd5d48f34c4c Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Fri, 28 Nov 2025 09:26:40 -0800 Subject: [PATCH 1/5] initial temporary state manager --- package.json | 41 +++++++++- package.nls.json | 2 + src/extension.ts | 21 +++++- src/features/views/envManagersView.ts | 39 ++++++++-- src/features/views/projectView.ts | 44 ++++++++++- src/features/views/temporaryStateManager.ts | 74 +++++++++++++++++++ .../views/temporaryStateManager.unit.test.ts | 74 +++++++++++++++++++ 7 files changed, 281 insertions(+), 14 deletions(-) create mode 100644 src/features/views/temporaryStateManager.ts create mode 100644 src/test/features/views/temporaryStateManager.unit.test.ts diff --git a/package.json b/package.json index 63251ab5..3924edbd 100644 --- a/package.json +++ b/package.json @@ -272,12 +272,24 @@ "category": "Python Envs", "icon": "$(copy)" }, + { + "command": "python-envs.copyEnvPathCopied", + "title": "%python-envs.copyEnvPathCopied.title%", + "category": "Python Envs", + "icon": "$(check)" + }, { "command": "python-envs.copyProjectPath", "title": "%python-envs.copyProjectPath.title%", "category": "Python Envs", "icon": "$(copy)" }, + { + "command": "python-envs.copyProjectPathCopied", + "title": "%python-envs.copyProjectPathCopied.title%", + "category": "Python Envs", + "icon": "$(check)" + }, { "command": "python-envs.terminal.revertStartupScriptChanges", "title": "%python-envs.terminal.revertStartupScriptChanges.title%", @@ -381,10 +393,18 @@ "command": "python-envs.copyEnvPath", "when": "false" }, + { + "command": "python-envs.copyEnvPathCopied", + "when": "false" + }, { "command": "python-envs.copyProjectPath", "when": "false" }, + { + "command": "python-envs.copyProjectPathCopied", + "when": "false" + }, { "command": "python-envs.createAny", "when": "false" @@ -438,7 +458,12 @@ { "command": "python-envs.copyEnvPath", "group": "inline", - "when": "view == env-managers && viewItem =~ /.*pythonEnvironment.*/" + "when": "view == env-managers && viewItem =~ /.*pythonEnvironment.*/ && viewItem =~ /^((?!copied).)*$/" + }, + { + "command": "python-envs.copyEnvPathCopied", + "group": "inline", + "when": "view == env-managers && viewItem =~ /.*pythonEnvironment.*/ && viewItem =~ /.*copied.*/" }, { "command": "python-envs.uninstallPackage", @@ -448,7 +473,12 @@ { "command": "python-envs.copyEnvPath", "group": "inline", - "when": "view == python-projects && viewItem == python-env" + "when": "view == python-projects && viewItem =~ /python-env/ && viewItem =~ /^((?!copied).)*$/" + }, + { + "command": "python-envs.copyEnvPathCopied", + "group": "inline", + "when": "view == python-projects && viewItem =~ /python-env/ && viewItem =~ /.*copied.*/" }, { "command": "python-envs.remove", @@ -471,7 +501,12 @@ { "command": "python-envs.copyProjectPath", "group": "inline", - "when": "view == python-projects && viewItem =~ /.*python-workspace.*/" + "when": "view == python-projects && viewItem =~ /.*python-workspace.*/ && viewItem =~ /^((?!copied).)*$/" + }, + { + "command": "python-envs.copyProjectPathCopied", + "group": "inline", + "when": "view == python-projects && viewItem =~ /.*python-workspace.*/ && viewItem =~ /.*copied.*/" }, { "command": "python-envs.revealProjectInExplorer", diff --git a/package.nls.json b/package.nls.json index 33a270de..f840a3f0 100644 --- a/package.nls.json +++ b/package.nls.json @@ -21,7 +21,9 @@ "python-envs.addPythonProjectGivenResource.title": "Add as Python Project", "python-envs.removePythonProject.title": "Remove Python Project", "python-envs.copyEnvPath.title": "Copy Environment Path", + "python-envs.copyEnvPathCopied.title": "Copied!", "python-envs.copyProjectPath.title": "Copy Project Path", + "python-envs.copyProjectPathCopied.title": "Copied!", "python-envs.create.title": "Create Environment", "python-envs.createAny.title": "Create Environment", "python-envs.set.title": "Set Project Environment", diff --git a/src/extension.ts b/src/extension.ts index 80240241..eb87c167 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -65,6 +65,7 @@ import { EnvManagerView } from './features/views/envManagersView'; import { ProjectView } from './features/views/projectView'; import { PythonStatusBarImpl } from './features/views/pythonStatusBar'; import { updateViewsAndStatus } from './features/views/revealHandler'; +import { TemporaryStateManager } from './features/views/temporaryStateManager'; import { ProjectItem } from './features/views/treeViewItems'; import { collectEnvironmentInfo, @@ -146,10 +147,14 @@ export async function activate(context: ExtensionContext): Promise(); - const managerView = new EnvManagerView(envManagers); + + const temporaryStateManager = new TemporaryStateManager(); + context.subscriptions.push(temporaryStateManager); + + const managerView = new EnvManagerView(envManagers, temporaryStateManager); context.subscriptions.push(managerView); - const workspaceView = new ProjectView(envManagers, projectManager); + const workspaceView = new ProjectView(envManagers, projectManager, temporaryStateManager); context.subscriptions.push(workspaceView); workspaceView.initialize(); @@ -283,9 +288,21 @@ export async function activate(context: ExtensionContext): Promise { await copyPathToClipboard(item); + if (item?.environment?.envId) { + temporaryStateManager.setState(item.environment.envId.id, 'copied'); + } + }), + commands.registerCommand('python-envs.copyEnvPathCopied', () => { + // No-op: provides the checkmark icon }), commands.registerCommand('python-envs.copyProjectPath', async (item) => { await copyPathToClipboard(item); + if (item?.project?.uri) { + temporaryStateManager.setState(item.project.uri.fsPath, 'copied'); + } + }), + commands.registerCommand('python-envs.copyProjectPathCopied', () => { + // No-op: provides the checkmark icon }), commands.registerCommand('python-envs.revealProjectInExplorer', async (item) => { await revealProjectInExplorer(item); diff --git a/src/features/views/envManagersView.ts b/src/features/views/envManagersView.ts index c418fed7..3b217cb6 100644 --- a/src/features/views/envManagersView.ts +++ b/src/features/views/envManagersView.ts @@ -1,5 +1,7 @@ import { Disposable, Event, EventEmitter, ProviderResult, TreeDataProvider, TreeItem, TreeView, window } from 'vscode'; import { DidChangeEnvironmentEventArgs, EnvironmentGroupInfo, PythonEnvironment } from '../../api'; +import { ProjectViews } from '../../common/localize'; +import { createSimpleDebounce } from '../../common/utils/debounce'; import { DidChangeEnvironmentManagerEventArgs, DidChangePackageManagerEventArgs, @@ -9,18 +11,19 @@ import { InternalEnvironmentManager, InternalPackageManager, } from '../../internal.api'; +import { TemporaryStateManager } from './temporaryStateManager'; import { - EnvTreeItem, + EnvInfoTreeItem, EnvManagerTreeItem, - PythonEnvTreeItem, - PackageTreeItem, + EnvTreeItem, EnvTreeItemKind, NoPythonEnvTreeItem, - EnvInfoTreeItem, + PackageTreeItem, + PythonEnvTreeItem, PythonGroupEnvTreeItem, } from './treeViewItems'; -import { createSimpleDebounce } from '../../common/utils/debounce'; -import { ProjectViews } from '../../common/localize'; + +const COPIED_STATE = 'copied'; export class EnvManagerView implements TreeDataProvider, Disposable { private treeView: TreeView; @@ -32,7 +35,7 @@ export class EnvManagerView implements TreeDataProvider, Disposable private selected: Map = new Map(); private disposables: Disposable[] = []; - public constructor(public providers: EnvironmentManagers) { + public constructor(public providers: EnvironmentManagers, private stateManager: TemporaryStateManager) { this.treeView = window.createTreeView('env-managers', { treeDataProvider: this, }); @@ -59,6 +62,15 @@ export class EnvManagerView implements TreeDataProvider, Disposable this.onDidChangePackageManager(p); }), ); + + this.disposables.push( + this.stateManager.onDidChangeState(({ itemId }) => { + const view = this.revealMap.get(itemId); + if (view) { + this.fireDataChanged(view); + } + }), + ); } dispose() { @@ -77,6 +89,17 @@ export class EnvManagerView implements TreeDataProvider, Disposable onDidChangeTreeData: Event = this.treeDataChanged.event; getTreeItem(element: EnvTreeItem): TreeItem | Thenable { + if (element.kind === EnvTreeItemKind.environment && element instanceof PythonEnvTreeItem) { + const itemId = element.environment.envId.id; + const currentContext = element.treeItem.contextValue ?? ''; + if (this.stateManager?.hasState(itemId, COPIED_STATE)) { + if (!currentContext.includes(COPIED_STATE)) { + element.treeItem.contextValue = currentContext + COPIED_STATE + ';'; + } + } else if (currentContext.includes(COPIED_STATE)) { + element.treeItem.contextValue = currentContext.replace(COPIED_STATE + ';', ''); + } + } return element.treeItem; } @@ -202,7 +225,7 @@ export class EnvManagerView implements TreeDataProvider, Disposable private onDidChangePackages(args: InternalDidChangePackagesEventArgs) { const view = Array.from(this.revealMap.values()).find( - (v) => v.environment.envId.id === args.environment.envId.id + (v) => v.environment.envId.id === args.environment.envId.id, ); if (view) { this.fireDataChanged(view); diff --git a/src/features/views/projectView.ts b/src/features/views/projectView.ts index 2c405a07..82f916a5 100644 --- a/src/features/views/projectView.ts +++ b/src/features/views/projectView.ts @@ -14,6 +14,7 @@ import { ProjectViews } from '../../common/localize'; import { createSimpleDebounce } from '../../common/utils/debounce'; import { onDidChangeConfiguration } from '../../common/workspace.apis'; import { EnvironmentManagers, PythonProjectManager } from '../../internal.api'; +import { TemporaryStateManager } from './temporaryStateManager'; import { GlobalProjectItem, NoProjectEnvironment, @@ -25,6 +26,8 @@ import { ProjectTreeItemKind, } from './treeViewItems'; +const COPIED_STATE = 'copied'; + export class ProjectView implements TreeDataProvider { private treeView: TreeView; private _treeDataChanged: EventEmitter = new EventEmitter< @@ -35,7 +38,11 @@ export class ProjectView implements TreeDataProvider { private packageRoots: Map = new Map(); private disposables: Disposable[] = []; private debouncedUpdateProject = createSimpleDebounce(500, () => this.updateProject()); - public constructor(private envManagers: EnvironmentManagers, private projectManager: PythonProjectManager) { + public constructor( + private envManagers: EnvironmentManagers, + private projectManager: PythonProjectManager, + private stateManager: TemporaryStateManager, + ) { this.treeView = window.createTreeView('python-projects', { treeDataProvider: this, }); @@ -69,6 +76,20 @@ export class ProjectView implements TreeDataProvider { } }), ); + + this.disposables.push( + this.stateManager.onDidChangeState(({ itemId }) => { + const projectView = this.projectViews.get(itemId); + if (projectView) { + this._treeDataChanged.fire(projectView); + return; + } + const envView = Array.from(this.revealMap.values()).find((v) => v.environment.envId.id === itemId); + if (envView) { + this._treeDataChanged.fire(envView); + } + }), + ); } initialize(): void { @@ -121,6 +142,27 @@ export class ProjectView implements TreeDataProvider { this._treeDataChanged.event; getTreeItem(element: ProjectTreeItem): TreeItem | Thenable { + if (element.kind === ProjectTreeItemKind.project && element instanceof ProjectItem) { + const itemId = element.project.uri.fsPath; + const currentContext = element.treeItem.contextValue ?? ''; + if (this.stateManager?.hasState(itemId, COPIED_STATE)) { + if (!currentContext.includes(COPIED_STATE)) { + element.treeItem.contextValue = currentContext + ';' + COPIED_STATE; + } + } else if (currentContext.includes(COPIED_STATE)) { + element.treeItem.contextValue = currentContext.replace(';' + COPIED_STATE, ''); + } + } else if (element.kind === ProjectTreeItemKind.environment && element instanceof ProjectEnvironment) { + const itemId = element.environment.envId.id; + const currentContext = element.treeItem.contextValue ?? ''; + if (this.stateManager?.hasState(itemId, COPIED_STATE)) { + if (!currentContext.includes(COPIED_STATE)) { + element.treeItem.contextValue = currentContext + ';' + COPIED_STATE; + } + } else if (currentContext.includes(COPIED_STATE)) { + element.treeItem.contextValue = currentContext.replace(';' + COPIED_STATE, ''); + } + } return element.treeItem; } diff --git a/src/features/views/temporaryStateManager.ts b/src/features/views/temporaryStateManager.ts new file mode 100644 index 00000000..64ec529c --- /dev/null +++ b/src/features/views/temporaryStateManager.ts @@ -0,0 +1,74 @@ +import { Disposable, Event, EventEmitter } from 'vscode'; + +const DEFAULT_TIMEOUT_MS = 2000; + +/** + * Manages temporary state for tree items that auto-clears after a timeout. + * Useful for visual feedback like showing a checkmark after copying, + * or highlighting a recently selected environment. + */ +export class TemporaryStateManager implements Disposable { + private activeItems: Map> = new Map(); + private timeouts: Map = new Map(); + private readonly _onDidChangeState = new EventEmitter<{ itemId: string; stateKey: string }>(); + + public readonly onDidChangeState: Event<{ itemId: string; stateKey: string }> = this._onDidChangeState.event; + + constructor(private readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {} + + /** + * Sets a temporary state on an item. After the timeout, the state is automatically cleared. + */ + public setState(itemId: string, stateKey: string): void { + const timeoutKey = `${itemId}:${stateKey}`; + const existingTimeout = this.timeouts.get(timeoutKey); + if (existingTimeout) { + clearTimeout(existingTimeout); + } + + let states = this.activeItems.get(itemId); + if (!states) { + states = new Set(); + this.activeItems.set(itemId, states); + } + states.add(stateKey); + this._onDidChangeState.fire({ itemId, stateKey }); + + const timeout = setTimeout(() => { + this.clearState(itemId, stateKey); + }, this.timeoutMs); + + this.timeouts.set(timeoutKey, timeout); + } + + /** + * Clears a specific state from an item. + */ + public clearState(itemId: string, stateKey: string): void { + const timeoutKey = `${itemId}:${stateKey}`; + this.timeouts.delete(timeoutKey); + + const states = this.activeItems.get(itemId); + if (states) { + states.delete(stateKey); + if (states.size === 0) { + this.activeItems.delete(itemId); + } + } + this._onDidChangeState.fire({ itemId, stateKey }); + } + + /** + * Checks if an item has a specific state. + */ + public hasState(itemId: string, stateKey: string): boolean { + return this.activeItems.get(itemId)?.has(stateKey) ?? false; + } + + public dispose(): void { + this.timeouts.forEach((timeout) => clearTimeout(timeout)); + this.timeouts.clear(); + this.activeItems.clear(); + this._onDidChangeState.dispose(); + } +} diff --git a/src/test/features/views/temporaryStateManager.unit.test.ts b/src/test/features/views/temporaryStateManager.unit.test.ts new file mode 100644 index 00000000..e9e47f55 --- /dev/null +++ b/src/test/features/views/temporaryStateManager.unit.test.ts @@ -0,0 +1,74 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { TemporaryStateManager } from '../../../features/views/temporaryStateManager'; + +suite('TemporaryStateManager', () => { + let manager: TemporaryStateManager; + + setup(() => { + manager = new TemporaryStateManager(); + }); + + teardown(() => { + manager.dispose(); + sinon.restore(); + }); + + test('hasState returns false for items without state', () => { + assert.strictEqual(manager.hasState('item-1', 'copied'), false); + assert.strictEqual(manager.hasState('item-2', 'selected'), false); + }); + + test('setState sets the state on an item', () => { + manager.setState('item-1', 'copied'); + assert.strictEqual(manager.hasState('item-1', 'copied'), true); + assert.strictEqual(manager.hasState('item-1', 'selected'), false); + assert.strictEqual(manager.hasState('item-2', 'copied'), false); + }); + + test('setState fires onDidChangeState event', () => { + const spy = sinon.spy(); + manager.onDidChangeState(spy); + + manager.setState('item-1', 'copied'); + + assert.strictEqual(spy.calledOnce, true); + assert.deepStrictEqual(spy.firstCall.args[0], { itemId: 'item-1', stateKey: 'copied' }); + }); + + test('multiple states can be set on the same item', () => { + manager.setState('item-1', 'copied'); + manager.setState('item-1', 'selected'); + + assert.strictEqual(manager.hasState('item-1', 'copied'), true); + assert.strictEqual(manager.hasState('item-1', 'selected'), true); + }); + + test('clearState removes specific state', () => { + manager.setState('item-1', 'copied'); + manager.setState('item-1', 'selected'); + + manager.clearState('item-1', 'copied'); + + assert.strictEqual(manager.hasState('item-1', 'copied'), false); + assert.strictEqual(manager.hasState('item-1', 'selected'), true); + }); + + test('setting same state again resets timeout', () => { + const spy = sinon.spy(); + manager.onDidChangeState(spy); + + manager.setState('item-1', 'copied'); + assert.strictEqual(spy.callCount, 1); + + manager.setState('item-1', 'copied'); + assert.strictEqual(spy.callCount, 2); + assert.strictEqual(manager.hasState('item-1', 'copied'), true); + }); + + test('dispose clears all state without errors', () => { + manager.setState('item-1', 'copied'); + manager.setState('item-2', 'selected'); + manager.dispose(); + }); +}); From 3374772fa96e1c724e480f2884c4c4b006a4be71 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Fri, 28 Nov 2025 09:34:00 -0800 Subject: [PATCH 2/5] add select and selected feedback --- package.json | 17 ++++++++++++++++- package.nls.json | 1 + src/extension.ts | 8 +++++++- src/features/views/envManagersView.ts | 22 ++++++++++++++++++---- src/features/views/projectView.ts | 24 +++++++++++++++++++----- 5 files changed, 61 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 3924edbd..9f4798ec 100644 --- a/package.json +++ b/package.json @@ -193,6 +193,12 @@ "category": "Python", "icon": "$(check)" }, + { + "command": "python-envs.setEnvSelected", + "title": "%python-envs.setEnvSelected.title%", + "category": "Python", + "icon": "$(pass-filled)" + }, { "command": "python-envs.remove", "title": "%python-envs.remove.title%", @@ -333,6 +339,10 @@ "command": "python-envs.setEnv", "when": "false" }, + { + "command": "python-envs.setEnvSelected", + "when": "false" + }, { "command": "python-envs.remove", "when": "false" @@ -439,7 +449,12 @@ { "command": "python-envs.setEnv", "group": "inline", - "when": "view == env-managers && viewItem =~ /.*pythonEnvironment.*/" + "when": "view == env-managers && viewItem =~ /.*pythonEnvironment.*/ && viewItem =~ /^((?!selected).)*$/" + }, + { + "command": "python-envs.setEnvSelected", + "group": "inline", + "when": "view == env-managers && viewItem =~ /.*pythonEnvironment.*/ && viewItem =~ /.*selected.*/" }, { "command": "python-envs.createTerminal", diff --git a/package.nls.json b/package.nls.json index f840a3f0..3a0c25ce 100644 --- a/package.nls.json +++ b/package.nls.json @@ -28,6 +28,7 @@ "python-envs.createAny.title": "Create Environment", "python-envs.set.title": "Set Project Environment", "python-envs.setEnv.title": "Set As Project Environment", + "python-envs.setEnvSelected.title": "Set!", "python-envs.remove.title": "Delete Environment", "python-envs.refreshAllManagers.title": "Refresh All Environment Managers", "python-envs.refreshPackages.title": "Refresh Packages List", diff --git a/src/extension.ts b/src/extension.ts index eb87c167..817effb0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -66,7 +66,7 @@ import { ProjectView } from './features/views/projectView'; import { PythonStatusBarImpl } from './features/views/pythonStatusBar'; import { updateViewsAndStatus } from './features/views/revealHandler'; import { TemporaryStateManager } from './features/views/temporaryStateManager'; -import { ProjectItem } from './features/views/treeViewItems'; +import { ProjectItem, PythonEnvTreeItem } from './features/views/treeViewItems'; import { collectEnvironmentInfo, getEnvManagerAndPackageManagerConfigLevels, @@ -229,6 +229,12 @@ export async function activate(context: ExtensionContext): Promise { await setEnvironmentCommand(item, envManagers, projectManager); + if (item instanceof PythonEnvTreeItem) { + temporaryStateManager.setState(item.environment.envId.id, 'selected'); + } + }), + commands.registerCommand('python-envs.setEnvSelected', async () => { + // No-op: This command is just for showing the feedback icon }), commands.registerCommand('python-envs.setEnvManager', async () => { await setEnvManagerCommand(envManagers, projectManager); diff --git a/src/features/views/envManagersView.ts b/src/features/views/envManagersView.ts index 3b217cb6..4ffc71d6 100644 --- a/src/features/views/envManagersView.ts +++ b/src/features/views/envManagersView.ts @@ -24,6 +24,7 @@ import { } from './treeViewItems'; const COPIED_STATE = 'copied'; +const SELECTED_STATE = 'selected'; export class EnvManagerView implements TreeDataProvider, Disposable { private treeView: TreeView; @@ -91,14 +92,27 @@ export class EnvManagerView implements TreeDataProvider, Disposable getTreeItem(element: EnvTreeItem): TreeItem | Thenable { if (element.kind === EnvTreeItemKind.environment && element instanceof PythonEnvTreeItem) { const itemId = element.environment.envId.id; - const currentContext = element.treeItem.contextValue ?? ''; - if (this.stateManager?.hasState(itemId, COPIED_STATE)) { + let currentContext = element.treeItem.contextValue ?? ''; + + // Handle copied state + if (this.stateManager.hasState(itemId, COPIED_STATE)) { if (!currentContext.includes(COPIED_STATE)) { - element.treeItem.contextValue = currentContext + COPIED_STATE + ';'; + currentContext = currentContext + COPIED_STATE + ';'; } } else if (currentContext.includes(COPIED_STATE)) { - element.treeItem.contextValue = currentContext.replace(COPIED_STATE + ';', ''); + currentContext = currentContext.replace(COPIED_STATE + ';', ''); } + + // Handle selected state + if (this.stateManager.hasState(itemId, SELECTED_STATE)) { + if (!currentContext.includes(SELECTED_STATE)) { + currentContext = currentContext + SELECTED_STATE + ';'; + } + } else if (currentContext.includes(SELECTED_STATE)) { + currentContext = currentContext.replace(SELECTED_STATE + ';', ''); + } + + element.treeItem.contextValue = currentContext; } return element.treeItem; } diff --git a/src/features/views/projectView.ts b/src/features/views/projectView.ts index 82f916a5..8fb9b308 100644 --- a/src/features/views/projectView.ts +++ b/src/features/views/projectView.ts @@ -27,6 +27,7 @@ import { } from './treeViewItems'; const COPIED_STATE = 'copied'; +const SELECTED_STATE = 'selected'; export class ProjectView implements TreeDataProvider { private treeView: TreeView; @@ -145,7 +146,7 @@ export class ProjectView implements TreeDataProvider { if (element.kind === ProjectTreeItemKind.project && element instanceof ProjectItem) { const itemId = element.project.uri.fsPath; const currentContext = element.treeItem.contextValue ?? ''; - if (this.stateManager?.hasState(itemId, COPIED_STATE)) { + if (this.stateManager.hasState(itemId, COPIED_STATE)) { if (!currentContext.includes(COPIED_STATE)) { element.treeItem.contextValue = currentContext + ';' + COPIED_STATE; } @@ -154,14 +155,27 @@ export class ProjectView implements TreeDataProvider { } } else if (element.kind === ProjectTreeItemKind.environment && element instanceof ProjectEnvironment) { const itemId = element.environment.envId.id; - const currentContext = element.treeItem.contextValue ?? ''; - if (this.stateManager?.hasState(itemId, COPIED_STATE)) { + let currentContext = element.treeItem.contextValue ?? ''; + + // Handle copied state + if (this.stateManager.hasState(itemId, COPIED_STATE)) { if (!currentContext.includes(COPIED_STATE)) { - element.treeItem.contextValue = currentContext + ';' + COPIED_STATE; + currentContext = currentContext + ';' + COPIED_STATE; } } else if (currentContext.includes(COPIED_STATE)) { - element.treeItem.contextValue = currentContext.replace(';' + COPIED_STATE, ''); + currentContext = currentContext.replace(';' + COPIED_STATE, ''); } + + // Handle selected state + if (this.stateManager.hasState(itemId, SELECTED_STATE)) { + if (!currentContext.includes(SELECTED_STATE)) { + currentContext = currentContext + ';' + SELECTED_STATE; + } + } else if (currentContext.includes(SELECTED_STATE)) { + currentContext = currentContext.replace(';' + SELECTED_STATE, ''); + } + + element.treeItem.contextValue = currentContext; } return element.treeItem; } From 064e02dd1897293b0cfd3dff2c26a8b028af71df Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Fri, 28 Nov 2025 09:38:32 -0800 Subject: [PATCH 3/5] refactor to remove code duplication when setting context --- src/features/views/envManagersView.ts | 28 ++++---------- src/features/views/projectView.ts | 38 +++++-------------- src/features/views/temporaryStateManager.ts | 28 ++++++++++++++ .../views/temporaryStateManager.unit.test.ts | 32 ++++++++++++++++ 4 files changed, 77 insertions(+), 49 deletions(-) diff --git a/src/features/views/envManagersView.ts b/src/features/views/envManagersView.ts index 4ffc71d6..f1a29767 100644 --- a/src/features/views/envManagersView.ts +++ b/src/features/views/envManagersView.ts @@ -25,6 +25,7 @@ import { const COPIED_STATE = 'copied'; const SELECTED_STATE = 'selected'; +const ENV_STATE_KEYS = [COPIED_STATE, SELECTED_STATE]; export class EnvManagerView implements TreeDataProvider, Disposable { private treeView: TreeView; @@ -92,27 +93,12 @@ export class EnvManagerView implements TreeDataProvider, Disposable getTreeItem(element: EnvTreeItem): TreeItem | Thenable { if (element.kind === EnvTreeItemKind.environment && element instanceof PythonEnvTreeItem) { const itemId = element.environment.envId.id; - let currentContext = element.treeItem.contextValue ?? ''; - - // Handle copied state - if (this.stateManager.hasState(itemId, COPIED_STATE)) { - if (!currentContext.includes(COPIED_STATE)) { - currentContext = currentContext + COPIED_STATE + ';'; - } - } else if (currentContext.includes(COPIED_STATE)) { - currentContext = currentContext.replace(COPIED_STATE + ';', ''); - } - - // Handle selected state - if (this.stateManager.hasState(itemId, SELECTED_STATE)) { - if (!currentContext.includes(SELECTED_STATE)) { - currentContext = currentContext + SELECTED_STATE + ';'; - } - } else if (currentContext.includes(SELECTED_STATE)) { - currentContext = currentContext.replace(SELECTED_STATE + ';', ''); - } - - element.treeItem.contextValue = currentContext; + const currentContext = element.treeItem.contextValue ?? ''; + element.treeItem.contextValue = this.stateManager.updateContextValue( + itemId, + currentContext, + ENV_STATE_KEYS, + ); } return element.treeItem; } diff --git a/src/features/views/projectView.ts b/src/features/views/projectView.ts index 8fb9b308..858877c8 100644 --- a/src/features/views/projectView.ts +++ b/src/features/views/projectView.ts @@ -28,6 +28,7 @@ import { const COPIED_STATE = 'copied'; const SELECTED_STATE = 'selected'; +const ENV_STATE_KEYS = [COPIED_STATE, SELECTED_STATE]; export class ProjectView implements TreeDataProvider { private treeView: TreeView; @@ -146,36 +147,17 @@ export class ProjectView implements TreeDataProvider { if (element.kind === ProjectTreeItemKind.project && element instanceof ProjectItem) { const itemId = element.project.uri.fsPath; const currentContext = element.treeItem.contextValue ?? ''; - if (this.stateManager.hasState(itemId, COPIED_STATE)) { - if (!currentContext.includes(COPIED_STATE)) { - element.treeItem.contextValue = currentContext + ';' + COPIED_STATE; - } - } else if (currentContext.includes(COPIED_STATE)) { - element.treeItem.contextValue = currentContext.replace(';' + COPIED_STATE, ''); - } + element.treeItem.contextValue = this.stateManager.updateContextValue(itemId, currentContext, [ + COPIED_STATE, + ]); } else if (element.kind === ProjectTreeItemKind.environment && element instanceof ProjectEnvironment) { const itemId = element.environment.envId.id; - let currentContext = element.treeItem.contextValue ?? ''; - - // Handle copied state - if (this.stateManager.hasState(itemId, COPIED_STATE)) { - if (!currentContext.includes(COPIED_STATE)) { - currentContext = currentContext + ';' + COPIED_STATE; - } - } else if (currentContext.includes(COPIED_STATE)) { - currentContext = currentContext.replace(';' + COPIED_STATE, ''); - } - - // Handle selected state - if (this.stateManager.hasState(itemId, SELECTED_STATE)) { - if (!currentContext.includes(SELECTED_STATE)) { - currentContext = currentContext + ';' + SELECTED_STATE; - } - } else if (currentContext.includes(SELECTED_STATE)) { - currentContext = currentContext.replace(';' + SELECTED_STATE, ''); - } - - element.treeItem.contextValue = currentContext; + const currentContext = element.treeItem.contextValue ?? ''; + element.treeItem.contextValue = this.stateManager.updateContextValue( + itemId, + currentContext, + ENV_STATE_KEYS, + ); } return element.treeItem; } diff --git a/src/features/views/temporaryStateManager.ts b/src/features/views/temporaryStateManager.ts index 64ec529c..b836cc19 100644 --- a/src/features/views/temporaryStateManager.ts +++ b/src/features/views/temporaryStateManager.ts @@ -65,6 +65,34 @@ export class TemporaryStateManager implements Disposable { return this.activeItems.get(itemId)?.has(stateKey) ?? false; } + /** + * Updates a contextValue string by adding or removing state keys based on current state. + * @param itemId The item ID to check states for + * @param currentContext The current contextValue string + * @param stateKeys The state keys to check and update + * @param separator The separator to use when adding states (default: ';') + * @returns The updated contextValue string + */ + public updateContextValue( + itemId: string, + currentContext: string, + stateKeys: string[], + separator: string = ';', + ): string { + let result = currentContext; + for (const stateKey of stateKeys) { + const stateWithSeparator = separator + stateKey; + if (this.hasState(itemId, stateKey)) { + if (!result.includes(stateKey)) { + result = result + stateWithSeparator; + } + } else if (result.includes(stateKey)) { + result = result.replace(stateWithSeparator, ''); + } + } + return result; + } + public dispose(): void { this.timeouts.forEach((timeout) => clearTimeout(timeout)); this.timeouts.clear(); diff --git a/src/test/features/views/temporaryStateManager.unit.test.ts b/src/test/features/views/temporaryStateManager.unit.test.ts index e9e47f55..7ed95246 100644 --- a/src/test/features/views/temporaryStateManager.unit.test.ts +++ b/src/test/features/views/temporaryStateManager.unit.test.ts @@ -71,4 +71,36 @@ suite('TemporaryStateManager', () => { manager.setState('item-2', 'selected'); manager.dispose(); }); + + suite('updateContextValue', () => { + test('adds state key when state is set', () => { + manager.setState('item-1', 'copied'); + const result = manager.updateContextValue('item-1', 'pythonEnvironment', ['copied']); + assert.strictEqual(result, 'pythonEnvironment;copied'); + }); + + test('removes state key when state is not set', () => { + const result = manager.updateContextValue('item-1', 'pythonEnvironment;copied', ['copied']); + assert.strictEqual(result, 'pythonEnvironment'); + }); + + test('handles multiple state keys', () => { + manager.setState('item-1', 'copied'); + manager.setState('item-1', 'selected'); + const result = manager.updateContextValue('item-1', 'pythonEnvironment', ['copied', 'selected']); + assert.strictEqual(result, 'pythonEnvironment;copied;selected'); + }); + + test('only adds states that are set', () => { + manager.setState('item-1', 'selected'); + const result = manager.updateContextValue('item-1', 'pythonEnvironment', ['copied', 'selected']); + assert.strictEqual(result, 'pythonEnvironment;selected'); + }); + + test('does not duplicate existing state', () => { + manager.setState('item-1', 'copied'); + const result = manager.updateContextValue('item-1', 'pythonEnvironment;copied', ['copied']); + assert.strictEqual(result, 'pythonEnvironment;copied'); + }); + }); }); From bee7fbeca585004f641b759bf27515c688f64b70 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Fri, 28 Nov 2025 09:48:35 -0800 Subject: [PATCH 4/5] refactor TemporaryStateManager with interface --- src/features/views/envManagersView.ts | 4 ++-- src/features/views/projectView.ts | 4 ++-- src/features/views/temporaryStateManager.ts | 13 ++++++++++++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/features/views/envManagersView.ts b/src/features/views/envManagersView.ts index f1a29767..5cc11dd8 100644 --- a/src/features/views/envManagersView.ts +++ b/src/features/views/envManagersView.ts @@ -11,7 +11,7 @@ import { InternalEnvironmentManager, InternalPackageManager, } from '../../internal.api'; -import { TemporaryStateManager } from './temporaryStateManager'; +import { ITemporaryStateManager } from './temporaryStateManager'; import { EnvInfoTreeItem, EnvManagerTreeItem, @@ -37,7 +37,7 @@ export class EnvManagerView implements TreeDataProvider, Disposable private selected: Map = new Map(); private disposables: Disposable[] = []; - public constructor(public providers: EnvironmentManagers, private stateManager: TemporaryStateManager) { + public constructor(public providers: EnvironmentManagers, private stateManager: ITemporaryStateManager) { this.treeView = window.createTreeView('env-managers', { treeDataProvider: this, }); diff --git a/src/features/views/projectView.ts b/src/features/views/projectView.ts index 858877c8..2c10b472 100644 --- a/src/features/views/projectView.ts +++ b/src/features/views/projectView.ts @@ -14,7 +14,7 @@ import { ProjectViews } from '../../common/localize'; import { createSimpleDebounce } from '../../common/utils/debounce'; import { onDidChangeConfiguration } from '../../common/workspace.apis'; import { EnvironmentManagers, PythonProjectManager } from '../../internal.api'; -import { TemporaryStateManager } from './temporaryStateManager'; +import { ITemporaryStateManager } from './temporaryStateManager'; import { GlobalProjectItem, NoProjectEnvironment, @@ -43,7 +43,7 @@ export class ProjectView implements TreeDataProvider { public constructor( private envManagers: EnvironmentManagers, private projectManager: PythonProjectManager, - private stateManager: TemporaryStateManager, + private stateManager: ITemporaryStateManager, ) { this.treeView = window.createTreeView('python-projects', { treeDataProvider: this, diff --git a/src/features/views/temporaryStateManager.ts b/src/features/views/temporaryStateManager.ts index b836cc19..d459c299 100644 --- a/src/features/views/temporaryStateManager.ts +++ b/src/features/views/temporaryStateManager.ts @@ -2,12 +2,23 @@ import { Disposable, Event, EventEmitter } from 'vscode'; const DEFAULT_TIMEOUT_MS = 2000; +/** + * Interface for managing temporary state on tree items. + */ +export interface ITemporaryStateManager { + readonly onDidChangeState: Event<{ itemId: string; stateKey: string }>; + setState(itemId: string, stateKey: string): void; + clearState(itemId: string, stateKey: string): void; + hasState(itemId: string, stateKey: string): boolean; + updateContextValue(itemId: string, currentContext: string, stateKeys: string[], separator?: string): string; +} + /** * Manages temporary state for tree items that auto-clears after a timeout. * Useful for visual feedback like showing a checkmark after copying, * or highlighting a recently selected environment. */ -export class TemporaryStateManager implements Disposable { +export class TemporaryStateManager implements ITemporaryStateManager, Disposable { private activeItems: Map> = new Map(); private timeouts: Map = new Map(); private readonly _onDidChangeState = new EventEmitter<{ itemId: string; stateKey: string }>(); From 2d976dcf35660c8596c874ff2d96c831bb073589 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Fri, 28 Nov 2025 09:50:55 -0800 Subject: [PATCH 5/5] change icon from check to clippy for copy feedback --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 9f4798ec..427cd93d 100644 --- a/package.json +++ b/package.json @@ -282,7 +282,7 @@ "command": "python-envs.copyEnvPathCopied", "title": "%python-envs.copyEnvPathCopied.title%", "category": "Python Envs", - "icon": "$(check)" + "icon": "$(clippy)" }, { "command": "python-envs.copyProjectPath", @@ -294,7 +294,7 @@ "command": "python-envs.copyProjectPathCopied", "title": "%python-envs.copyProjectPathCopied.title%", "category": "Python Envs", - "icon": "$(check)" + "icon": "$(clippy)" }, { "command": "python-envs.terminal.revertStartupScriptChanges",