diff --git a/package.json b/package.json index 63251ab5..427cd93d 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%", @@ -272,12 +278,24 @@ "category": "Python Envs", "icon": "$(copy)" }, + { + "command": "python-envs.copyEnvPathCopied", + "title": "%python-envs.copyEnvPathCopied.title%", + "category": "Python Envs", + "icon": "$(clippy)" + }, { "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": "$(clippy)" + }, { "command": "python-envs.terminal.revertStartupScriptChanges", "title": "%python-envs.terminal.revertStartupScriptChanges.title%", @@ -321,6 +339,10 @@ "command": "python-envs.setEnv", "when": "false" }, + { + "command": "python-envs.setEnvSelected", + "when": "false" + }, { "command": "python-envs.remove", "when": "false" @@ -381,10 +403,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" @@ -419,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", @@ -438,7 +473,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 +488,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 +516,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..3a0c25ce 100644 --- a/package.nls.json +++ b/package.nls.json @@ -21,11 +21,14 @@ "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", "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 80240241..817effb0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -65,7 +65,8 @@ 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 { ProjectItem } from './features/views/treeViewItems'; +import { TemporaryStateManager } from './features/views/temporaryStateManager'; +import { ProjectItem, PythonEnvTreeItem } from './features/views/treeViewItems'; import { collectEnvironmentInfo, getEnvManagerAndPackageManagerConfigLevels, @@ -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(); @@ -224,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); @@ -283,9 +294,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..5cc11dd8 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,21 @@ import { InternalEnvironmentManager, InternalPackageManager, } from '../../internal.api'; +import { ITemporaryStateManager } 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'; +const SELECTED_STATE = 'selected'; +const ENV_STATE_KEYS = [COPIED_STATE, SELECTED_STATE]; export class EnvManagerView implements TreeDataProvider, Disposable { private treeView: TreeView; @@ -32,7 +37,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: ITemporaryStateManager) { this.treeView = window.createTreeView('env-managers', { treeDataProvider: this, }); @@ -59,6 +64,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 +91,15 @@ 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 ?? ''; + element.treeItem.contextValue = this.stateManager.updateContextValue( + itemId, + currentContext, + ENV_STATE_KEYS, + ); + } 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..2c10b472 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 { ITemporaryStateManager } from './temporaryStateManager'; import { GlobalProjectItem, NoProjectEnvironment, @@ -25,6 +26,10 @@ import { ProjectTreeItemKind, } from './treeViewItems'; +const COPIED_STATE = 'copied'; +const SELECTED_STATE = 'selected'; +const ENV_STATE_KEYS = [COPIED_STATE, SELECTED_STATE]; + export class ProjectView implements TreeDataProvider { private treeView: TreeView; private _treeDataChanged: EventEmitter = new EventEmitter< @@ -35,7 +40,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: ITemporaryStateManager, + ) { this.treeView = window.createTreeView('python-projects', { treeDataProvider: this, }); @@ -69,6 +78,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 +144,21 @@ 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 ?? ''; + 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; + 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 new file mode 100644 index 00000000..d459c299 --- /dev/null +++ b/src/features/views/temporaryStateManager.ts @@ -0,0 +1,113 @@ +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 ITemporaryStateManager, 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; + } + + /** + * 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(); + 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..7ed95246 --- /dev/null +++ b/src/test/features/views/temporaryStateManager.unit.test.ts @@ -0,0 +1,106 @@ +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(); + }); + + 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'); + }); + }); +});