Skip to content

Commit b4748cc

Browse files
committed
initial temporary state manager
1 parent 2331581 commit b4748cc

7 files changed

Lines changed: 281 additions & 14 deletions

File tree

package.json

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -272,12 +272,24 @@
272272
"category": "Python Envs",
273273
"icon": "$(copy)"
274274
},
275+
{
276+
"command": "python-envs.copyEnvPathCopied",
277+
"title": "%python-envs.copyEnvPathCopied.title%",
278+
"category": "Python Envs",
279+
"icon": "$(check)"
280+
},
275281
{
276282
"command": "python-envs.copyProjectPath",
277283
"title": "%python-envs.copyProjectPath.title%",
278284
"category": "Python Envs",
279285
"icon": "$(copy)"
280286
},
287+
{
288+
"command": "python-envs.copyProjectPathCopied",
289+
"title": "%python-envs.copyProjectPathCopied.title%",
290+
"category": "Python Envs",
291+
"icon": "$(check)"
292+
},
281293
{
282294
"command": "python-envs.terminal.revertStartupScriptChanges",
283295
"title": "%python-envs.terminal.revertStartupScriptChanges.title%",
@@ -381,10 +393,18 @@
381393
"command": "python-envs.copyEnvPath",
382394
"when": "false"
383395
},
396+
{
397+
"command": "python-envs.copyEnvPathCopied",
398+
"when": "false"
399+
},
384400
{
385401
"command": "python-envs.copyProjectPath",
386402
"when": "false"
387403
},
404+
{
405+
"command": "python-envs.copyProjectPathCopied",
406+
"when": "false"
407+
},
388408
{
389409
"command": "python-envs.createAny",
390410
"when": "false"
@@ -438,7 +458,12 @@
438458
{
439459
"command": "python-envs.copyEnvPath",
440460
"group": "inline",
441-
"when": "view == env-managers && viewItem =~ /.*pythonEnvironment.*/"
461+
"when": "view == env-managers && viewItem =~ /.*pythonEnvironment.*/ && viewItem =~ /^((?!copied).)*$/"
462+
},
463+
{
464+
"command": "python-envs.copyEnvPathCopied",
465+
"group": "inline",
466+
"when": "view == env-managers && viewItem =~ /.*pythonEnvironment.*/ && viewItem =~ /.*copied.*/"
442467
},
443468
{
444469
"command": "python-envs.uninstallPackage",
@@ -448,7 +473,12 @@
448473
{
449474
"command": "python-envs.copyEnvPath",
450475
"group": "inline",
451-
"when": "view == python-projects && viewItem == python-env"
476+
"when": "view == python-projects && viewItem =~ /python-env/ && viewItem =~ /^((?!copied).)*$/"
477+
},
478+
{
479+
"command": "python-envs.copyEnvPathCopied",
480+
"group": "inline",
481+
"when": "view == python-projects && viewItem =~ /python-env/ && viewItem =~ /.*copied.*/"
452482
},
453483
{
454484
"command": "python-envs.remove",
@@ -471,7 +501,12 @@
471501
{
472502
"command": "python-envs.copyProjectPath",
473503
"group": "inline",
474-
"when": "view == python-projects && viewItem =~ /.*python-workspace.*/"
504+
"when": "view == python-projects && viewItem =~ /.*python-workspace.*/ && viewItem =~ /^((?!copied).)*$/"
505+
},
506+
{
507+
"command": "python-envs.copyProjectPathCopied",
508+
"group": "inline",
509+
"when": "view == python-projects && viewItem =~ /.*python-workspace.*/ && viewItem =~ /.*copied.*/"
475510
},
476511
{
477512
"command": "python-envs.revealProjectInExplorer",

package.nls.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
"python-envs.addPythonProjectGivenResource.title": "Add as Python Project",
2222
"python-envs.removePythonProject.title": "Remove Python Project",
2323
"python-envs.copyEnvPath.title": "Copy Environment Path",
24+
"python-envs.copyEnvPathCopied.title": "Copied!",
2425
"python-envs.copyProjectPath.title": "Copy Project Path",
26+
"python-envs.copyProjectPathCopied.title": "Copied!",
2527
"python-envs.create.title": "Create Environment",
2628
"python-envs.createAny.title": "Create Environment",
2729
"python-envs.set.title": "Set Project Environment",

src/extension.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import { EnvManagerView } from './features/views/envManagersView';
6565
import { ProjectView } from './features/views/projectView';
6666
import { PythonStatusBarImpl } from './features/views/pythonStatusBar';
6767
import { updateViewsAndStatus } from './features/views/revealHandler';
68+
import { TemporaryStateManager } from './features/views/temporaryStateManager';
6869
import { ProjectItem } from './features/views/treeViewItems';
6970
import {
7071
collectEnvironmentInfo,
@@ -146,10 +147,14 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
146147
setPythonApi(envManagers, projectManager, projectCreators, terminalManager, envVarManager);
147148
const api = await getPythonApi();
148149
const sysPythonManager = createDeferred<SysPythonManager>();
149-
const managerView = new EnvManagerView(envManagers);
150+
151+
const temporaryStateManager = new TemporaryStateManager();
152+
context.subscriptions.push(temporaryStateManager);
153+
154+
const managerView = new EnvManagerView(envManagers, temporaryStateManager);
150155
context.subscriptions.push(managerView);
151156

152-
const workspaceView = new ProjectView(envManagers, projectManager);
157+
const workspaceView = new ProjectView(envManagers, projectManager, temporaryStateManager);
153158
context.subscriptions.push(workspaceView);
154159
workspaceView.initialize();
155160

@@ -283,9 +288,21 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
283288
}),
284289
commands.registerCommand('python-envs.copyEnvPath', async (item) => {
285290
await copyPathToClipboard(item);
291+
if (item?.environment?.envId) {
292+
temporaryStateManager.setState(item.environment.envId.id, 'copied');
293+
}
294+
}),
295+
commands.registerCommand('python-envs.copyEnvPathCopied', () => {
296+
// No-op: provides the checkmark icon
286297
}),
287298
commands.registerCommand('python-envs.copyProjectPath', async (item) => {
288299
await copyPathToClipboard(item);
300+
if (item?.project?.uri) {
301+
temporaryStateManager.setState(item.project.uri.fsPath, 'copied');
302+
}
303+
}),
304+
commands.registerCommand('python-envs.copyProjectPathCopied', () => {
305+
// No-op: provides the checkmark icon
289306
}),
290307
commands.registerCommand('python-envs.revealProjectInExplorer', async (item) => {
291308
await revealProjectInExplorer(item);

src/features/views/envManagersView.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { Disposable, Event, EventEmitter, ProviderResult, TreeDataProvider, TreeItem, TreeView, window } from 'vscode';
22
import { DidChangeEnvironmentEventArgs, EnvironmentGroupInfo, PythonEnvironment } from '../../api';
3+
import { ProjectViews } from '../../common/localize';
4+
import { createSimpleDebounce } from '../../common/utils/debounce';
35
import {
46
DidChangeEnvironmentManagerEventArgs,
57
DidChangePackageManagerEventArgs,
@@ -9,18 +11,19 @@ import {
911
InternalEnvironmentManager,
1012
InternalPackageManager,
1113
} from '../../internal.api';
14+
import { TemporaryStateManager } from './temporaryStateManager';
1215
import {
13-
EnvTreeItem,
16+
EnvInfoTreeItem,
1417
EnvManagerTreeItem,
15-
PythonEnvTreeItem,
16-
PackageTreeItem,
18+
EnvTreeItem,
1719
EnvTreeItemKind,
1820
NoPythonEnvTreeItem,
19-
EnvInfoTreeItem,
21+
PackageTreeItem,
22+
PythonEnvTreeItem,
2023
PythonGroupEnvTreeItem,
2124
} from './treeViewItems';
22-
import { createSimpleDebounce } from '../../common/utils/debounce';
23-
import { ProjectViews } from '../../common/localize';
25+
26+
const COPIED_STATE = 'copied';
2427

2528
export class EnvManagerView implements TreeDataProvider<EnvTreeItem>, Disposable {
2629
private treeView: TreeView<EnvTreeItem>;
@@ -32,7 +35,7 @@ export class EnvManagerView implements TreeDataProvider<EnvTreeItem>, Disposable
3235
private selected: Map<string, string> = new Map();
3336
private disposables: Disposable[] = [];
3437

35-
public constructor(public providers: EnvironmentManagers) {
38+
public constructor(public providers: EnvironmentManagers, private stateManager: TemporaryStateManager) {
3639
this.treeView = window.createTreeView<EnvTreeItem>('env-managers', {
3740
treeDataProvider: this,
3841
});
@@ -59,6 +62,15 @@ export class EnvManagerView implements TreeDataProvider<EnvTreeItem>, Disposable
5962
this.onDidChangePackageManager(p);
6063
}),
6164
);
65+
66+
this.disposables.push(
67+
this.stateManager.onDidChangeState(({ itemId }) => {
68+
const view = this.revealMap.get(itemId);
69+
if (view) {
70+
this.fireDataChanged(view);
71+
}
72+
}),
73+
);
6274
}
6375

6476
dispose() {
@@ -77,6 +89,17 @@ export class EnvManagerView implements TreeDataProvider<EnvTreeItem>, Disposable
7789
onDidChangeTreeData: Event<void | EnvTreeItem | EnvTreeItem[] | null | undefined> = this.treeDataChanged.event;
7890

7991
getTreeItem(element: EnvTreeItem): TreeItem | Thenable<TreeItem> {
92+
if (element.kind === EnvTreeItemKind.environment && element instanceof PythonEnvTreeItem) {
93+
const itemId = element.environment.envId.id;
94+
const currentContext = element.treeItem.contextValue ?? '';
95+
if (this.stateManager?.hasState(itemId, COPIED_STATE)) {
96+
if (!currentContext.includes(COPIED_STATE)) {
97+
element.treeItem.contextValue = currentContext + COPIED_STATE + ';';
98+
}
99+
} else if (currentContext.includes(COPIED_STATE)) {
100+
element.treeItem.contextValue = currentContext.replace(COPIED_STATE + ';', '');
101+
}
102+
}
80103
return element.treeItem;
81104
}
82105

@@ -202,7 +225,7 @@ export class EnvManagerView implements TreeDataProvider<EnvTreeItem>, Disposable
202225

203226
private onDidChangePackages(args: InternalDidChangePackagesEventArgs) {
204227
const view = Array.from(this.revealMap.values()).find(
205-
(v) => v.environment.envId.id === args.environment.envId.id
228+
(v) => v.environment.envId.id === args.environment.envId.id,
206229
);
207230
if (view) {
208231
this.fireDataChanged(view);

src/features/views/projectView.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { ProjectViews } from '../../common/localize';
1414
import { createSimpleDebounce } from '../../common/utils/debounce';
1515
import { onDidChangeConfiguration } from '../../common/workspace.apis';
1616
import { EnvironmentManagers, PythonProjectManager } from '../../internal.api';
17+
import { TemporaryStateManager } from './temporaryStateManager';
1718
import {
1819
GlobalProjectItem,
1920
NoProjectEnvironment,
@@ -25,6 +26,8 @@ import {
2526
ProjectTreeItemKind,
2627
} from './treeViewItems';
2728

29+
const COPIED_STATE = 'copied';
30+
2831
export class ProjectView implements TreeDataProvider<ProjectTreeItem> {
2932
private treeView: TreeView<ProjectTreeItem>;
3033
private _treeDataChanged: EventEmitter<ProjectTreeItem | ProjectTreeItem[] | null | undefined> = new EventEmitter<
@@ -35,7 +38,11 @@ export class ProjectView implements TreeDataProvider<ProjectTreeItem> {
3538
private packageRoots: Map<string, ProjectEnvironment> = new Map();
3639
private disposables: Disposable[] = [];
3740
private debouncedUpdateProject = createSimpleDebounce(500, () => this.updateProject());
38-
public constructor(private envManagers: EnvironmentManagers, private projectManager: PythonProjectManager) {
41+
public constructor(
42+
private envManagers: EnvironmentManagers,
43+
private projectManager: PythonProjectManager,
44+
private stateManager: TemporaryStateManager,
45+
) {
3946
this.treeView = window.createTreeView<ProjectTreeItem>('python-projects', {
4047
treeDataProvider: this,
4148
});
@@ -69,6 +76,20 @@ export class ProjectView implements TreeDataProvider<ProjectTreeItem> {
6976
}
7077
}),
7178
);
79+
80+
this.disposables.push(
81+
this.stateManager.onDidChangeState(({ itemId }) => {
82+
const projectView = this.projectViews.get(itemId);
83+
if (projectView) {
84+
this._treeDataChanged.fire(projectView);
85+
return;
86+
}
87+
const envView = Array.from(this.revealMap.values()).find((v) => v.environment.envId.id === itemId);
88+
if (envView) {
89+
this._treeDataChanged.fire(envView);
90+
}
91+
}),
92+
);
7293
}
7394

7495
initialize(): void {
@@ -121,6 +142,27 @@ export class ProjectView implements TreeDataProvider<ProjectTreeItem> {
121142
this._treeDataChanged.event;
122143

123144
getTreeItem(element: ProjectTreeItem): TreeItem | Thenable<TreeItem> {
145+
if (element.kind === ProjectTreeItemKind.project && element instanceof ProjectItem) {
146+
const itemId = element.project.uri.fsPath;
147+
const currentContext = element.treeItem.contextValue ?? '';
148+
if (this.stateManager?.hasState(itemId, COPIED_STATE)) {
149+
if (!currentContext.includes(COPIED_STATE)) {
150+
element.treeItem.contextValue = currentContext + ';' + COPIED_STATE;
151+
}
152+
} else if (currentContext.includes(COPIED_STATE)) {
153+
element.treeItem.contextValue = currentContext.replace(';' + COPIED_STATE, '');
154+
}
155+
} else if (element.kind === ProjectTreeItemKind.environment && element instanceof ProjectEnvironment) {
156+
const itemId = element.environment.envId.id;
157+
const currentContext = element.treeItem.contextValue ?? '';
158+
if (this.stateManager?.hasState(itemId, COPIED_STATE)) {
159+
if (!currentContext.includes(COPIED_STATE)) {
160+
element.treeItem.contextValue = currentContext + ';' + COPIED_STATE;
161+
}
162+
} else if (currentContext.includes(COPIED_STATE)) {
163+
element.treeItem.contextValue = currentContext.replace(';' + COPIED_STATE, '');
164+
}
165+
}
124166
return element.treeItem;
125167
}
126168

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { Disposable, Event, EventEmitter } from 'vscode';
2+
3+
const DEFAULT_TIMEOUT_MS = 2000;
4+
5+
/**
6+
* Manages temporary state for tree items that auto-clears after a timeout.
7+
* Useful for visual feedback like showing a checkmark after copying,
8+
* or highlighting a recently selected environment.
9+
*/
10+
export class TemporaryStateManager implements Disposable {
11+
private activeItems: Map<string, Set<string>> = new Map();
12+
private timeouts: Map<string, NodeJS.Timeout> = new Map();
13+
private readonly _onDidChangeState = new EventEmitter<{ itemId: string; stateKey: string }>();
14+
15+
public readonly onDidChangeState: Event<{ itemId: string; stateKey: string }> = this._onDidChangeState.event;
16+
17+
constructor(private readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {}
18+
19+
/**
20+
* Sets a temporary state on an item. After the timeout, the state is automatically cleared.
21+
*/
22+
public setState(itemId: string, stateKey: string): void {
23+
const timeoutKey = `${itemId}:${stateKey}`;
24+
const existingTimeout = this.timeouts.get(timeoutKey);
25+
if (existingTimeout) {
26+
clearTimeout(existingTimeout);
27+
}
28+
29+
let states = this.activeItems.get(itemId);
30+
if (!states) {
31+
states = new Set();
32+
this.activeItems.set(itemId, states);
33+
}
34+
states.add(stateKey);
35+
this._onDidChangeState.fire({ itemId, stateKey });
36+
37+
const timeout = setTimeout(() => {
38+
this.clearState(itemId, stateKey);
39+
}, this.timeoutMs);
40+
41+
this.timeouts.set(timeoutKey, timeout);
42+
}
43+
44+
/**
45+
* Clears a specific state from an item.
46+
*/
47+
public clearState(itemId: string, stateKey: string): void {
48+
const timeoutKey = `${itemId}:${stateKey}`;
49+
this.timeouts.delete(timeoutKey);
50+
51+
const states = this.activeItems.get(itemId);
52+
if (states) {
53+
states.delete(stateKey);
54+
if (states.size === 0) {
55+
this.activeItems.delete(itemId);
56+
}
57+
}
58+
this._onDidChangeState.fire({ itemId, stateKey });
59+
}
60+
61+
/**
62+
* Checks if an item has a specific state.
63+
*/
64+
public hasState(itemId: string, stateKey: string): boolean {
65+
return this.activeItems.get(itemId)?.has(stateKey) ?? false;
66+
}
67+
68+
public dispose(): void {
69+
this.timeouts.forEach((timeout) => clearTimeout(timeout));
70+
this.timeouts.clear();
71+
this.activeItems.clear();
72+
this._onDidChangeState.dispose();
73+
}
74+
}

0 commit comments

Comments
 (0)