From db72051deb86fa4c6843f0774a9dcb5c503e91f1 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Mon, 28 Oct 2024 10:30:16 -0700 Subject: [PATCH 1/5] partial --- src/common/window.apis.ts | 9 ++ src/features/execution/terminal.ts | 7 +- src/features/execution/terminalManager.ts | 146 ++++++++++++++++++++++ 3 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 src/features/execution/terminalManager.ts diff --git a/src/common/window.apis.ts b/src/common/window.apis.ts index 55961bfb..64ac88b0 100644 --- a/src/common/window.apis.ts +++ b/src/common/window.apis.ts @@ -18,6 +18,7 @@ import { Terminal, TerminalOptions, TerminalShellExecutionEndEvent, + TerminalShellExecutionStartEvent, TerminalShellIntegrationChangeEvent, TextEditor, Uri, @@ -53,6 +54,14 @@ export function activeTerminal(): Terminal | undefined { return window.activeTerminal; } +export function onDidStartTerminalShellExecution( + listener: (e: TerminalShellExecutionStartEvent) => any, + thisArgs?: any, + disposables?: Disposable[], +): Disposable { + return window.onDidStartTerminalShellExecution(listener, thisArgs, disposables); +} + export function onDidEndTerminalShellExecution( listener: (e: TerminalShellExecutionEndEvent) => any, thisArgs?: any, diff --git a/src/features/execution/terminal.ts b/src/features/execution/terminal.ts index ad1dbeb5..05446a0b 100644 --- a/src/features/execution/terminal.ts +++ b/src/features/execution/terminal.ts @@ -6,7 +6,6 @@ import { TerminalShellExecutionEndEvent, TerminalShellIntegration, Uri, - window, } from 'vscode'; import { IconPath, PythonEnvironment, PythonProject } from '../../api'; import * as path from 'path'; @@ -17,11 +16,13 @@ import { onDidCloseTerminal, onDidEndTerminalShellExecution, onDidOpenTerminal, + withProgress, } from '../../common/window.apis'; import { getActivationCommand, isActivatableEnvironment } from './activation'; import { createDeferred } from '../../common/utils/deferred'; import { getConfiguration } from '../../common/workspace.apis'; import { quoteArgs } from './execUtils'; +import { showErrorMessage } from '../../common/errors/utils'; const SHELL_INTEGRATION_TIMEOUT = 5; @@ -152,7 +153,7 @@ export async function createPythonTerminal(environment: PythonEnvironment, cwd?: if (activatable) { try { - await window.withProgress( + await withProgress( { location: ProgressLocation.Window, title: `Activating ${environment.displayName}`, @@ -162,7 +163,7 @@ export async function createPythonTerminal(environment: PythonEnvironment, cwd?: }, ); } catch (e) { - window.showErrorMessage(`Failed to activate ${environment.displayName}`); + showErrorMessage(`Failed to activate ${environment.displayName}`); } } diff --git a/src/features/execution/terminalManager.ts b/src/features/execution/terminalManager.ts new file mode 100644 index 00000000..162c4283 --- /dev/null +++ b/src/features/execution/terminalManager.ts @@ -0,0 +1,146 @@ +import { + Disposable, + EventEmitter, + ProgressLocation, + Terminal, + TerminalShellExecutionEndEvent, + TerminalShellExecutionStartEvent, + TerminalShellIntegrationChangeEvent, + Uri, +} from 'vscode'; +import { + createTerminal, + onDidChangeTerminalShellIntegration, + onDidCloseTerminal, + onDidEndTerminalShellExecution, + onDidOpenTerminal, + onDidStartTerminalShellExecution, + withProgress, +} from '../../common/window.apis'; +import { IconPath, PythonEnvironment } from '../../api'; +import { isActivatableEnvironment } from './activation'; +import { showErrorMessage } from '../../common/errors/utils'; + +function getIconPath(i: IconPath | undefined): IconPath | undefined { + if (i instanceof Uri) { + return i.fsPath.endsWith('__icon__.py') ? undefined : i; + } + return i; +} + +export class TerminalManager implements Disposable { + private disposables: Disposable[] = []; + private onTerminalOpenedEmitter = new EventEmitter(); + private onTerminalOpened = this.onTerminalOpenedEmitter.event; + + private onTerminalClosedEmitter = new EventEmitter(); + private onTerminalClosed = this.onTerminalClosedEmitter.event; + + private onTerminalShellIntegrationChangedEmitter = new EventEmitter(); + private onTerminalShellIntegrationChanged = this.onTerminalShellIntegrationChangedEmitter.event; + + private onTerminalShellExecutionStartEmitter = new EventEmitter(); + private onTerminalShellExecutionStart = this.onTerminalShellExecutionStartEmitter.event; + + private onTerminalShellExecutionEndEmitter = new EventEmitter(); + private onTerminalShellExecutionEnd = this.onTerminalShellExecutionEndEmitter.event; + + constructor() { + this.disposables.push( + onDidOpenTerminal((t: Terminal) => { + this.onTerminalOpenedEmitter.fire(t); + }), + onDidCloseTerminal((t: Terminal) => { + this.onTerminalClosedEmitter.fire(t); + }), + onDidChangeTerminalShellIntegration((e: TerminalShellIntegrationChangeEvent) => { + this.onTerminalShellIntegrationChangedEmitter.fire(e); + }), + onDidStartTerminalShellExecution((e: TerminalShellExecutionStartEvent) => { + this.onTerminalShellExecutionStartEmitter.fire(e); + }), + onDidEndTerminalShellExecution((e: TerminalShellExecutionEndEvent) => { + this.onTerminalShellExecutionEndEmitter.fire(e); + }), + this.onTerminalOpenedEmitter, + this.onTerminalClosedEmitter, + this.onTerminalShellIntegrationChangedEmitter, + this.onTerminalShellExecutionStartEmitter, + this.onTerminalShellExecutionEndEmitter, + ); + } + + private async activateUsingShellIntegration( + terminal: Terminal, + environment: PythonEnvironment, + progress: Progress<{ + message?: string; + increment?: number; + }>, + ): Promise {} + + private async activateEnvironmentOnCreation( + terminal: Terminal, + environment: PythonEnvironment, + progress: Progress<{ + message?: string; + increment?: number; + }>, + ): Promise { + const deferred = createDeferred(); + const disposables: Disposable[] = []; + let disposeTimer: Disposable | undefined; + let activated = false; + + try { + disposables.push( + this.onTerminalOpened(async (t: Terminal) => { + if (t === terminal) { + if (terminal.shellIntegration && !activated) { + await this.activateUsingShellIntegration(terminal, environment, progress); + deferred.resolve(); + } else { + const timer = setInterval(() => {}); + } + } + }), + ); + } finally { + disposables.forEach((d) => d.dispose()); + } + } + + public async create( + environment: PythonEnvironment, + cwd?: string | Uri, + env?: { [key: string]: string | null | undefined }, + ): Promise { + const activatable = isActivatableEnvironment(environment); + const newTerminal = createTerminal({ + // name: `Python: ${environment.displayName}`, + iconPath: getIconPath(environment.iconPath), + cwd, + env, + }); + if (activatable) { + try { + await withProgress( + { + location: ProgressLocation.Window, + title: `Activating ${environment.displayName}`, + }, + async (progress) => { + await activateEnvironmentOnCreation(newTerminal, environment, progress); + }, + ); + } catch (e) { + showErrorMessage(`Failed to activate ${environment.displayName}`); + } + } + return newTerminal; + } + + dispose(): void { + this.disposables.forEach((d) => d.dispose()); + } +} From 6152354eb1925b84ce07dffe46e31adcc07a06af Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Tue, 29 Oct 2024 22:52:56 -0700 Subject: [PATCH 2/5] Partial terminal features --- src/api.ts | 68 ++++++- src/extension.ts | 11 +- src/features/envCommands.ts | 54 ++++- src/features/execution/runInTerminal.ts | 53 ++--- src/features/execution/terminal.ts | 235 ---------------------- src/features/execution/terminalManager.ts | 192 ++++++++++++++++-- src/internal.api.ts | 2 +- 7 files changed, 317 insertions(+), 298 deletions(-) delete mode 100644 src/features/execution/terminal.ts diff --git a/src/api.ts b/src/api.ts index 58fc990b..60512b79 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,4 +1,4 @@ -import { Uri, Disposable, MarkdownString, Event, LogOutputChannel, ThemeIcon } from 'vscode'; +import { Uri, Disposable, MarkdownString, Event, LogOutputChannel, ThemeIcon, Terminal } from 'vscode'; /** * The path to an icon, or a theme-specific configuration of icons. @@ -767,6 +767,38 @@ export interface Installable { readonly uri?: Uri; } +export interface PythonProcess { + /** + * The process ID of the Python process. + */ + readonly pid: number; + + /** + * The standard input of the Python process. + */ + readonly stdin: NodeJS.WritableStream; + + /** + * The standard output of the Python process. + */ + readonly stdout: NodeJS.ReadableStream; + + /** + * The standard error of the Python process. + */ + readonly stderr: NodeJS.ReadableStream; + + /** + * Kills the Python process. + */ + kill(): void; + + /** + * Event that is fired when the Python process exits. + */ + onExit: Event; +} + export interface PythonEnvironmentManagerApi { /** * Register an environment manager implementation. @@ -963,6 +995,40 @@ export interface PythonProjectApi { registerPythonProjectCreator(creator: PythonProjectCreator): Disposable; } +export interface PythonExecutionApi { + createTerminal( + cwd: string | Uri | PythonProject, + environment?: PythonEnvironment, + envVars?: { [key: string]: string }, + ): Promise; + runInTerminal( + environment: PythonEnvironment, + cwd: string | Uri | PythonProject, + command: string, + args?: string[], + ): Promise; + runInDedicatedTerminal( + terminalKey: string | Uri, + environment: PythonEnvironment, + cwd: string | Uri | PythonProject, + command: string, + args?: string[], + ): Promise; + runAsTask( + environment: PythonEnvironment, + cwd: string | Uri | PythonProject, + command: string, + args?: string[], + envVars?: { [key: string]: string }, + ): Promise; + runInBackground( + environment: PythonEnvironment, + cwd: string | Uri | PythonProject, + command: string, + args?: string[], + envVars?: { [key: string]: string }, + ): Promise; +} /** * The API for interacting with Python environments, package managers, and projects. */ diff --git a/src/extension.ts b/src/extension.ts index fb2f6d9a..9b2f3a20 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -20,6 +20,7 @@ import { resetEnvironmentCommand, refreshPackagesCommand, createAnyEnvironmentCommand, + runInDedicatedTerminalCommand, } from './features/envCommands'; import { registerCondaFeatures } from './managers/conda/main'; import { registerSystemPythonFeatures } from './managers/sysPython/main'; @@ -37,6 +38,7 @@ import { } from './features/projectCreators'; import { WorkspaceView } from './features/views/projectView'; import { registerCompletionProvider } from './features/settings/settingCompletions'; +import { TerminalManager, TerminalManagerImpl } from './features/execution/terminalManager'; export async function activate(context: ExtensionContext): Promise { // Logging should be set up before anything else. @@ -46,6 +48,8 @@ export async function activate(context: ExtensionContext): Promise { - return runInTerminalCommand(item, api); + return runInTerminalCommand(item, api, terminalManager); + }), + commands.registerCommand('python-envs.runInDedicatedTerminal', (item) => { + return runInDedicatedTerminalCommand(item, api, terminalManager); }), commands.registerCommand('python-envs.runAsTask', (item) => { return runAsTaskCommand(item, api); }), commands.registerCommand('python-envs.createTerminal', (item) => { - return createTerminalCommand(item, api); + return createTerminalCommand(item, api, terminalManager); }), window.onDidChangeActiveTextEditor(async (e: TextEditor | undefined) => { if (e && !e.document.isUntitled && e.document.uri.scheme === 'file') { diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index 60063d91..590b57c5 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -24,7 +24,6 @@ import { } from './settings/settingHelpers'; import { getAbsolutePath } from '../common/utils/fileNameUtils'; -import { createPythonTerminal } from './execution/terminal'; import { runInTerminal } from './execution/runInTerminal'; import { runAsTask } from './execution/runAsTask'; import { @@ -40,6 +39,7 @@ import { pickEnvironment } from '../common/pickers/environments'; import { pickEnvironmentManager, pickPackageManager, pickCreator } from '../common/pickers/managers'; import { pickPackageOptions, getPackagesToInstall, getPackagesToUninstall } from '../common/pickers/packages'; import { pickProject, pickProjectMany } from '../common/pickers/projects'; +import { TerminalManager } from './execution/terminalManager'; export async function refreshManagerCommand(context: unknown): Promise { if (context instanceof EnvManagerTreeItem) { @@ -417,19 +417,20 @@ export async function getPackageCommandOptions( export async function createTerminalCommand( context: unknown, api: PythonEnvironmentApi, + tm: TerminalManager, ): Promise { if (context instanceof Uri) { const uri = context as Uri; const env = await api.getEnvironment(uri); const pw = api.getPythonProject(uri); if (env && pw) { - return await createPythonTerminal(env, pw.uri); + return await tm.create(env, pw.uri); } } else if (context instanceof ProjectItem) { const view = context as ProjectItem; const env = await api.getEnvironment(view.project.uri); if (env) { - const terminal = await createPythonTerminal(env, view.project.uri); + const terminal = await tm.create(env, view.project.uri); terminal.show(); return terminal; } @@ -437,26 +438,32 @@ export async function createTerminalCommand( const view = context as PythonEnvTreeItem; const pw = await pickProject(api.getPythonProjects()); if (pw) { - const terminal = await createPythonTerminal(view.environment, pw.uri); + const terminal = await tm.create(view.environment, pw.uri); terminal.show(); return terminal; } } } -export async function runInTerminalCommand(item: unknown, api: PythonEnvironmentApi): Promise { +export async function runInTerminalCommand( + item: unknown, + api: PythonEnvironmentApi, + tm: TerminalManager, +): Promise { const keys = Object.keys(item ?? {}); if (item instanceof Uri) { const uri = item as Uri; const project = api.getPythonProject(uri); const environment = await api.getEnvironment(uri); if (environment && project) { + const terminal = await tm.getProjectTerminal(project, environment); await runInTerminal( + environment, + terminal, { project, args: [item.fsPath], }, - environment, { show: true }, ); } @@ -464,7 +471,40 @@ export async function runInTerminalCommand(item: unknown, api: PythonEnvironment const options = item as PythonTerminalExecutionOptions; const environment = await api.getEnvironment(options.project.uri); if (environment) { - await runInTerminal(options, environment, { show: true }); + const terminal = await tm.getProjectTerminal(options.project, environment); + await runInTerminal(environment, terminal, options, { show: true }); + } + } +} + +export async function runInDedicatedTerminalCommand( + item: unknown, + api: PythonEnvironmentApi, + tm: TerminalManager, +): Promise { + const keys = Object.keys(item ?? {}); + if (item instanceof Uri) { + const uri = item as Uri; + const project = api.getPythonProject(uri); + const environment = await api.getEnvironment(uri); + if (environment && project) { + const terminal = await tm.getDedicatedTerminal(item, project, environment); + await runInTerminal( + environment, + terminal, + { + project, + args: [item.fsPath], + }, + { show: true }, + ); + } + } else if (keys.includes('project') && keys.includes('args')) { + const options = item as PythonTerminalExecutionOptions; + const environment = await api.getEnvironment(options.project.uri); + if (environment && options.uri) { + const terminal = await tm.getDedicatedTerminal(options.uri, options.project, environment); + await runInTerminal(environment, terminal, options, { show: true }); } } } diff --git a/src/features/execution/runInTerminal.ts b/src/features/execution/runInTerminal.ts index 7f247d96..a29edc2d 100644 --- a/src/features/execution/runInTerminal.ts +++ b/src/features/execution/runInTerminal.ts @@ -1,47 +1,38 @@ import { Terminal, TerminalShellExecution } from 'vscode'; import { PythonEnvironment } from '../../api'; import { PythonTerminalExecutionOptions } from '../../internal.api'; -import { getDedicatedTerminal, getProjectTerminal } from './terminal'; import { onDidEndTerminalShellExecution } from '../../common/window.apis'; import { createDeferred } from '../../common/utils/deferred'; import { quoteArgs } from './execUtils'; export async function runInTerminal( - options: PythonTerminalExecutionOptions, environment: PythonEnvironment, + terminal: Terminal, + options: PythonTerminalExecutionOptions, extra?: { show?: boolean }, ): Promise { - let terminal: Terminal | undefined; - if (options.useDedicatedTerminal) { - terminal = await getDedicatedTerminal(options.useDedicatedTerminal, environment, options.project); - } else { - terminal = await getProjectTerminal(options.project, environment); + if (extra?.show) { + terminal.show(); } - if (terminal) { - if (extra?.show) { - terminal.show(); - } + const executable = + environment.execInfo?.activatedRun?.executable ?? environment.execInfo?.run.executable ?? 'python'; + const args = environment.execInfo?.activatedRun?.args ?? environment.execInfo?.run.args ?? []; + const allArgs = [...args, ...options.args]; - const executable = - environment.execInfo?.activatedRun?.executable ?? environment.execInfo?.run.executable ?? 'python'; - const args = environment.execInfo?.activatedRun?.args ?? environment.execInfo?.run.args ?? []; - const allArgs = [...args, ...options.args]; - - if (terminal.shellIntegration) { - let execution: TerminalShellExecution | undefined; - const deferred = createDeferred(); - const disposable = onDidEndTerminalShellExecution((e) => { - if (e.execution === execution) { - disposable.dispose(); - deferred.resolve(); - } - }); - execution = terminal.shellIntegration.executeCommand(executable, allArgs); - return deferred.promise; - } else { - const text = quoteArgs([executable, ...allArgs]).join(' '); - terminal.sendText(`${text}\n`); - } + if (terminal.shellIntegration) { + let execution: TerminalShellExecution | undefined; + const deferred = createDeferred(); + const disposable = onDidEndTerminalShellExecution((e) => { + if (e.execution === execution) { + disposable.dispose(); + deferred.resolve(); + } + }); + execution = terminal.shellIntegration.executeCommand(executable, allArgs); + return deferred.promise; + } else { + const text = quoteArgs([executable, ...allArgs]).join(' '); + terminal.sendText(`${text}\n`); } } diff --git a/src/features/execution/terminal.ts b/src/features/execution/terminal.ts deleted file mode 100644 index 05446a0b..00000000 --- a/src/features/execution/terminal.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { - Disposable, - Progress, - ProgressLocation, - Terminal, - TerminalShellExecutionEndEvent, - TerminalShellIntegration, - Uri, -} from 'vscode'; -import { IconPath, PythonEnvironment, PythonProject } from '../../api'; -import * as path from 'path'; -import * as fsapi from 'fs-extra'; -import { - createTerminal, - onDidChangeTerminalShellIntegration, - onDidCloseTerminal, - onDidEndTerminalShellExecution, - onDidOpenTerminal, - withProgress, -} from '../../common/window.apis'; -import { getActivationCommand, isActivatableEnvironment } from './activation'; -import { createDeferred } from '../../common/utils/deferred'; -import { getConfiguration } from '../../common/workspace.apis'; -import { quoteArgs } from './execUtils'; -import { showErrorMessage } from '../../common/errors/utils'; - -const SHELL_INTEGRATION_TIMEOUT = 5; - -async function runActivationCommands( - shellIntegration: TerminalShellIntegration, - terminal: Terminal, - environment: PythonEnvironment, - progress: Progress<{ - message?: string; - increment?: number; - }>, -) { - const activationCommands = getActivationCommand(terminal, environment); - if (activationCommands) { - for (const command of activationCommands) { - const text = command.args ? `${command.executable} ${command.args.join(' ')}` : command.executable; - progress.report({ message: `Activating ${environment.displayName}: running ${text}` }); - const execPromise = createDeferred(); - const execution = command.args - ? shellIntegration.executeCommand(command.executable, command.args) - : shellIntegration.executeCommand(command.executable); - - const disposable = onDidEndTerminalShellExecution((e: TerminalShellExecutionEndEvent) => { - if (e.execution === execution) { - execPromise.resolve(); - disposable.dispose(); - } - }); - - await execPromise.promise; - } - } -} - -function runActivationCommandsLegacy(terminal: Terminal, environment: PythonEnvironment) { - const activationCommands = getActivationCommand(terminal, environment); - if (activationCommands) { - for (const command of activationCommands) { - const args = command.args ?? []; - const text = quoteArgs([command.executable, ...args]).join(' '); - terminal.sendText(text); - } - } -} - -async function activateEnvironmentOnCreation( - newTerminal: Terminal, - environment: PythonEnvironment, - progress: Progress<{ - message?: string; - increment?: number; - }>, -) { - const deferred = createDeferred(); - const disposables: Disposable[] = []; - let disposeTimer: Disposable | undefined; - - try { - let activated = false; - progress.report({ message: `Activating ${environment.displayName}: waiting for Shell Integration` }); - disposables.push( - onDidChangeTerminalShellIntegration(async ({ terminal, shellIntegration }) => { - if (terminal === newTerminal && !activated) { - disposeTimer?.dispose(); - activated = true; - await runActivationCommands(shellIntegration, terminal, environment, progress); - deferred.resolve(); - } - }), - onDidOpenTerminal((terminal) => { - if (terminal === newTerminal) { - let seconds = 0; - const timer = setInterval(() => { - if (newTerminal.shellIntegration || activated) { - return; - } - if (seconds >= SHELL_INTEGRATION_TIMEOUT) { - disposeTimer?.dispose(); - activated = true; - progress.report({ message: `Activating ${environment.displayName}: using legacy method` }); - runActivationCommandsLegacy(terminal, environment); - deferred.resolve(); - } else { - progress.report({ - message: `Activating ${environment.displayName}: waiting for Shell Integration ${ - SHELL_INTEGRATION_TIMEOUT - seconds - }s`, - }); - } - seconds++; - }, 1000); - - disposeTimer = new Disposable(() => { - clearInterval(timer); - disposeTimer = undefined; - }); - } - }), - onDidCloseTerminal((terminal) => { - if (terminal === newTerminal && !deferred.completed) { - deferred.reject(new Error('Terminal closed before activation')); - } - }), - new Disposable(() => { - disposeTimer?.dispose(); - }), - ); - await deferred.promise; - } finally { - disposables.forEach((d) => d.dispose()); - } -} - -function getIconPath(i: IconPath | undefined): IconPath | undefined { - if (i instanceof Uri) { - return i.fsPath.endsWith('__icon__.py') ? undefined : i; - } - return i; -} - -export async function createPythonTerminal(environment: PythonEnvironment, cwd?: string | Uri): Promise { - const activatable = isActivatableEnvironment(environment); - const newTerminal = createTerminal({ - // name: `Python: ${environment.displayName}`, - iconPath: getIconPath(environment.iconPath), - cwd, - }); - - if (activatable) { - try { - await withProgress( - { - location: ProgressLocation.Window, - title: `Activating ${environment.displayName}`, - }, - async (progress) => { - await activateEnvironmentOnCreation(newTerminal, environment, progress); - }, - ); - } catch (e) { - showErrorMessage(`Failed to activate ${environment.displayName}`); - } - } - - return newTerminal; -} - -const dedicatedTerminals = new Map(); -export async function getDedicatedTerminal( - uri: Uri, - environment: PythonEnvironment, - project: PythonProject, - createNew: boolean = false, -): Promise { - const key = `${environment.envId.id}:${path.normalize(uri.fsPath)}`; - if (!createNew) { - const terminal = dedicatedTerminals.get(key); - if (terminal) { - return terminal; - } - } - - const config = getConfiguration('python', uri); - const projectStat = await fsapi.stat(project.uri.fsPath); - const projectDir = projectStat.isDirectory() ? project.uri.fsPath : path.dirname(project.uri.fsPath); - - const uriStat = await fsapi.stat(uri.fsPath); - const uriDir = uriStat.isDirectory() ? uri.fsPath : path.dirname(uri.fsPath); - const cwd = config.get('terminal.executeInFileDir', false) ? uriDir : projectDir; - - const newTerminal = await createPythonTerminal(environment, cwd); - dedicatedTerminals.set(key, newTerminal); - - const disable = onDidCloseTerminal((terminal) => { - if (terminal === newTerminal) { - dedicatedTerminals.delete(key); - disable.dispose(); - } - }); - - return newTerminal; -} - -const projectTerminals = new Map(); -export async function getProjectTerminal( - project: PythonProject, - environment: PythonEnvironment, - createNew: boolean = false, -): Promise { - const key = `${environment.envId.id}:${path.normalize(project.uri.fsPath)}`; - if (!createNew) { - const terminal = projectTerminals.get(key); - if (terminal) { - return terminal; - } - } - const stat = await fsapi.stat(project.uri.fsPath); - const cwd = stat.isDirectory() ? project.uri.fsPath : path.dirname(project.uri.fsPath); - const newTerminal = await createPythonTerminal(environment, cwd); - projectTerminals.set(key, newTerminal); - - const disable = onDidCloseTerminal((terminal) => { - if (terminal === newTerminal) { - projectTerminals.delete(key); - disable.dispose(); - } - }); - - return newTerminal; -} diff --git a/src/features/execution/terminalManager.ts b/src/features/execution/terminalManager.ts index 162c4283..cd744471 100644 --- a/src/features/execution/terminalManager.ts +++ b/src/features/execution/terminalManager.ts @@ -1,7 +1,10 @@ +import * as path from 'path'; +import * as fsapi from 'fs-extra'; import { Disposable, EventEmitter, ProgressLocation, + TerminalShellIntegration, Terminal, TerminalShellExecutionEndEvent, TerminalShellExecutionStartEvent, @@ -17,9 +20,13 @@ import { onDidStartTerminalShellExecution, withProgress, } from '../../common/window.apis'; -import { IconPath, PythonEnvironment } from '../../api'; -import { isActivatableEnvironment } from './activation'; +import { IconPath, PythonEnvironment, PythonProject } from '../../api'; +import { getActivationCommand, isActivatableEnvironment } from './activation'; import { showErrorMessage } from '../../common/errors/utils'; +import { quoteArgs } from './execUtils'; +import { createDeferred } from '../../common/utils/deferred'; +import { traceVerbose } from '../../common/logging'; +import { getConfiguration } from '../../common/workspace.apis'; function getIconPath(i: IconPath | undefined): IconPath | undefined { if (i instanceof Uri) { @@ -28,7 +35,25 @@ function getIconPath(i: IconPath | undefined): IconPath | undefined { return i; } -export class TerminalManager implements Disposable { +const SHELL_INTEGRATION_TIMEOUT = 500; // 0.5 seconds +const SHELL_INTEGRATION_POLL_INTERVAL = 100; // 0.1 seconds + +export interface TerminalManager extends Disposable { + getProjectTerminal(project: PythonProject, environment: PythonEnvironment, createNew?: boolean): Promise; + getDedicatedTerminal( + uri: Uri, + project: PythonProject, + environment: PythonEnvironment, + createNew?: boolean, + ): Promise; + create( + environment: PythonEnvironment, + cwd?: string | Uri | PythonProject, + env?: { [key: string]: string | null | undefined }, + ): Promise; +} + +export class TerminalManagerImpl implements Disposable { private disposables: Disposable[] = []; private onTerminalOpenedEmitter = new EventEmitter(); private onTerminalOpened = this.onTerminalOpenedEmitter.event; @@ -70,23 +95,47 @@ export class TerminalManager implements Disposable { ); } - private async activateUsingShellIntegration( - terminal: Terminal, - environment: PythonEnvironment, - progress: Progress<{ - message?: string; - increment?: number; - }>, - ): Promise {} + private activateLegacy(terminal: Terminal, environment: PythonEnvironment) { + const activationCommands = getActivationCommand(terminal, environment); + if (activationCommands) { + for (const command of activationCommands) { + const args = command.args ?? []; + const text = quoteArgs([command.executable, ...args]).join(' '); + terminal.sendText(text); + } + } + } - private async activateEnvironmentOnCreation( + private async activateUsingShellIntegration( + shellIntegration: TerminalShellIntegration, terminal: Terminal, environment: PythonEnvironment, - progress: Progress<{ - message?: string; - increment?: number; - }>, ): Promise { + const activationCommands = getActivationCommand(terminal, environment); + if (activationCommands) { + for (const command of activationCommands) { + const execPromise = createDeferred(); + const execution = shellIntegration.executeCommand(command.executable, command.args ?? []); + const disposables: Disposable[] = []; + disposables.push( + this.onTerminalShellExecutionEnd((e: TerminalShellExecutionEndEvent) => { + if (e.execution === execution) { + execPromise.resolve(); + } + }), + this.onTerminalShellExecutionStart((e: TerminalShellExecutionStartEvent) => { + if (e.execution === execution) { + traceVerbose(`Shell execution started: ${command.executable} ${command.args?.join(' ')}`); + } + }), + ); + + await execPromise.promise; + } + } + } + + private async activateEnvironmentOnCreation(terminal: Terminal, environment: PythonEnvironment): Promise { const deferred = createDeferred(); const disposables: Disposable[] = []; let disposeTimer: Disposable | undefined; @@ -96,14 +145,51 @@ export class TerminalManager implements Disposable { disposables.push( this.onTerminalOpened(async (t: Terminal) => { if (t === terminal) { - if (terminal.shellIntegration && !activated) { - await this.activateUsingShellIntegration(terminal, environment, progress); + if (terminal.shellIntegration) { + // Shell integration is available when the terminal is opened. + activated = true; + await this.activateUsingShellIntegration(terminal.shellIntegration, terminal, environment); deferred.resolve(); } else { - const timer = setInterval(() => {}); + let seconds = 0; + const timer = setInterval(() => { + seconds += SHELL_INTEGRATION_POLL_INTERVAL; + if (terminal.shellIntegration || activated) { + disposeTimer?.dispose(); + return; + } + + if (seconds >= SHELL_INTEGRATION_TIMEOUT) { + disposeTimer?.dispose(); + activated = true; + this.activateLegacy(terminal, environment); + deferred.resolve(); + } + }, 100); + + disposeTimer = new Disposable(() => { + clearInterval(timer); + disposeTimer = undefined; + }); } } }), + this.onTerminalShellIntegrationChanged(async (e: TerminalShellIntegrationChangeEvent) => { + if (terminal === e.terminal && !activated) { + disposeTimer?.dispose(); + activated = true; + await this.activateUsingShellIntegration(e.shellIntegration, terminal, environment); + deferred.resolve(); + } + }), + this.onTerminalClosed((t) => { + if (terminal === t && !deferred.completed) { + deferred.reject(new Error('Terminal closed before activation')); + } + }), + new Disposable(() => { + disposeTimer?.dispose(); + }), ); } finally { disposables.forEach((d) => d.dispose()); @@ -129,8 +215,8 @@ export class TerminalManager implements Disposable { location: ProgressLocation.Window, title: `Activating ${environment.displayName}`, }, - async (progress) => { - await activateEnvironmentOnCreation(newTerminal, environment, progress); + async () => { + await this.activateEnvironmentOnCreation(newTerminal, environment); }, ); } catch (e) { @@ -140,6 +226,70 @@ export class TerminalManager implements Disposable { return newTerminal; } + private dedicatedTerminals = new Map(); + async getDedicatedTerminal( + uri: Uri, + project: PythonProject, + environment: PythonEnvironment, + createNew: boolean = false, + ): Promise { + const key = `${environment.envId.id}:${path.normalize(uri.fsPath)}`; + if (!createNew) { + const terminal = this.dedicatedTerminals.get(key); + if (terminal) { + return terminal; + } + } + + const config = getConfiguration('python', uri); + const projectStat = await fsapi.stat(project.uri.fsPath); + const projectDir = projectStat.isDirectory() ? project.uri.fsPath : path.dirname(project.uri.fsPath); + + const uriStat = await fsapi.stat(uri.fsPath); + const uriDir = uriStat.isDirectory() ? uri.fsPath : path.dirname(uri.fsPath); + const cwd = config.get('terminal.executeInFileDir', false) ? uriDir : projectDir; + + const newTerminal = await this.create(environment, cwd); + this.dedicatedTerminals.set(key, newTerminal); + + const disable = onDidCloseTerminal((terminal) => { + if (terminal === newTerminal) { + this.dedicatedTerminals.delete(key); + disable.dispose(); + } + }); + + return newTerminal; + } + + private projectTerminals = new Map(); + async getProjectTerminal( + project: PythonProject, + environment: PythonEnvironment, + createNew: boolean = false, + ): Promise { + const key = `${environment.envId.id}:${path.normalize(project.uri.fsPath)}`; + if (!createNew) { + const terminal = this.projectTerminals.get(key); + if (terminal) { + return terminal; + } + } + const stat = await fsapi.stat(project.uri.fsPath); + const cwd = stat.isDirectory() ? project.uri.fsPath : path.dirname(project.uri.fsPath); + const newTerminal = await this.create(environment, cwd); + this.projectTerminals.set(key, newTerminal); + + const disable = onDidCloseTerminal((terminal) => { + if (terminal === newTerminal) { + this.projectTerminals.delete(key); + disable.dispose(); + } + }); + + return newTerminal; + } + dispose(): void { this.disposables.forEach((d) => d.dispose()); } diff --git a/src/internal.api.ts b/src/internal.api.ts index af0af4a2..37f36a21 100644 --- a/src/internal.api.ts +++ b/src/internal.api.ts @@ -325,7 +325,7 @@ export interface ProjectCreators extends Disposable { export interface PythonTerminalExecutionOptions { project: PythonProject; args: string[]; - useDedicatedTerminal?: Uri; + uri?: Uri; } export interface PythonTaskExecutionOptions { From 0cf98051f0a3f6a4d6f134932651611207135456 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Tue, 29 Oct 2024 22:54:08 -0700 Subject: [PATCH 3/5] . --- src/api.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/api.ts b/src/api.ts index 60512b79..5038a1d8 100644 --- a/src/api.ts +++ b/src/api.ts @@ -767,6 +767,8 @@ export interface Installable { readonly uri?: Uri; } +export interface PythonTaskResult {} + export interface PythonProcess { /** * The process ID of the Python process. @@ -1020,7 +1022,7 @@ export interface PythonExecutionApi { command: string, args?: string[], envVars?: { [key: string]: string }, - ): Promise; + ): Promise; runInBackground( environment: PythonEnvironment, cwd: string | Uri | PythonProject, From 81b698594d9f37f6747d0b641bdf89ee711e384a Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Tue, 5 Nov 2024 09:23:53 -0800 Subject: [PATCH 4/5] Add activate feature --- src/common/window.apis.ts | 20 +++ src/extension.ts | 19 ++- src/features/execution/activateButton.ts | 78 +++++++++ src/features/execution/activation.ts | 21 +++ src/features/execution/terminalManager.ts | 191 ++++++++++++++++++++-- 5 files changed, 318 insertions(+), 11 deletions(-) create mode 100644 src/features/execution/activateButton.ts diff --git a/src/common/window.apis.ts b/src/common/window.apis.ts index 64ac88b0..1f706de9 100644 --- a/src/common/window.apis.ts +++ b/src/common/window.apis.ts @@ -54,6 +54,26 @@ export function activeTerminal(): Terminal | undefined { return window.activeTerminal; } +export function activeTextEditor(): TextEditor | undefined { + return window.activeTextEditor; +} + +export function onDidChangeActiveTerminal( + listener: (e: Terminal | undefined) => any, + thisArgs?: any, + disposables?: Disposable[], +): Disposable { + return window.onDidChangeActiveTerminal(listener, thisArgs, disposables); +} + +export function onDidChangeActiveTextEditor( + listener: (e: TextEditor | undefined) => any, + thisArgs?: any, + disposables?: Disposable[], +): Disposable { + return window.onDidChangeActiveTextEditor(listener, thisArgs, disposables); +} + export function onDidStartTerminalShellExecution( listener: (e: TerminalShellExecutionStartEvent) => any, thisArgs?: any, diff --git a/src/extension.ts b/src/extension.ts index 9b2f3a20..1fecea5e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -39,6 +39,8 @@ import { import { WorkspaceView } from './features/views/projectView'; import { registerCompletionProvider } from './features/settings/settingCompletions'; import { TerminalManager, TerminalManagerImpl } from './features/execution/terminalManager'; +import { ActivateStatusButton } from './features/execution/activateButton'; +import { activeTerminal, onDidChangeActiveTextEditor } from './common/window.apis'; export async function activate(context: ExtensionContext): Promise { // Logging should be set up before anything else. @@ -49,6 +51,7 @@ export async function activate(context: ExtensionContext): Promise { return createTerminalCommand(item, api, terminalManager); }), - window.onDidChangeActiveTextEditor(async (e: TextEditor | undefined) => { + commands.registerCommand('python-envs.terminal.activate', async (terminal, env) => { + await terminalManager.activate(terminal, env); + await activateStatusButton.update(terminal); + }), + commands.registerCommand('python-envs.terminal.deactivate', async (terminal) => { + await terminalManager.deactivate(terminal); + await activateStatusButton.update(terminal); + }), + envManagers.onDidChangeEnvironmentManager(async () => { + await activateStatusButton.update(activeTerminal()); + }), + onDidChangeActiveTextEditor(async (e: TextEditor | undefined) => { if (e && !e.document.isUntitled && e.document.uri.scheme === 'file') { if ( e.document.languageId === 'python' || diff --git a/src/features/execution/activateButton.ts b/src/features/execution/activateButton.ts new file mode 100644 index 00000000..9e560f43 --- /dev/null +++ b/src/features/execution/activateButton.ts @@ -0,0 +1,78 @@ +import { StatusBarAlignment, StatusBarItem, Terminal } from 'vscode'; +import { Disposable } from 'vscode-jsonrpc'; +import { activeTextEditor, createStatusBarItem, onDidChangeActiveTerminal } from '../../common/window.apis'; +import { TerminalActivation } from './terminalManager'; +import { EnvironmentManagers, PythonProjectManager } from '../../internal.api'; +import { isActivatableEnvironment } from './activation'; +import { PythonEnvironment } from '../../api'; + +export class ActivateStatusButton implements Disposable { + private readonly statusBarItem: StatusBarItem; + private disposables: Disposable[] = []; + + constructor( + private readonly tm: TerminalActivation, + private readonly em: EnvironmentManagers, + private readonly pm: PythonProjectManager, + ) { + this.statusBarItem = createStatusBarItem('python-envs.terminal.activate', StatusBarAlignment.Right, 100); + this.disposables.push( + this.statusBarItem, + onDidChangeActiveTerminal(async (terminal) => { + await this.update(terminal); + }), + ); + } + + public dispose() { + this.disposables.forEach((d) => d.dispose()); + } + + public async update(terminal?: Terminal) { + if (!terminal) { + this.statusBarItem.hide(); + return; + } + + const projects = this.pm.getProjects(); + if (projects.length === 0) { + this.statusBarItem.hide(); + return; + } + + const projectUri = projects.length === 1 ? projects[0].uri : activeTextEditor()?.document.uri; + if (!projectUri) { + this.statusBarItem.hide(); + return; + } + + const manager = this.em.getEnvironmentManager(projectUri); + const env = await manager?.get(projectUri); + if (env && isActivatableEnvironment(env)) { + this.updateStatusBarItem(terminal, env); + } else { + this.statusBarItem.hide(); + } + } + + private updateStatusBarItem(terminal: Terminal, env: PythonEnvironment) { + if (this.tm.isActivated(terminal, env)) { + this.statusBarItem.text = '$(terminal) Deactivate'; + this.statusBarItem.tooltip = 'Deactivate the terminal'; + this.statusBarItem.command = { + command: 'python-envs.terminal.deactivate', + title: 'Deactivate the terminal', + arguments: [terminal, env], + }; + } else { + this.statusBarItem.text = '$(terminal) Activate'; + this.statusBarItem.tooltip = 'Activate the terminal'; + this.statusBarItem.command = { + command: 'python-envs.terminal.activate', + title: 'Activate the terminal', + arguments: [terminal, env], + }; + } + this.statusBarItem.show(); + } +} diff --git a/src/features/execution/activation.ts b/src/features/execution/activation.ts index b141ee59..34d359a9 100644 --- a/src/features/execution/activation.ts +++ b/src/features/execution/activation.ts @@ -30,3 +30,24 @@ export function getActivationCommand( return activation; } + +export function getDeactivationCommand( + terminal: Terminal, + environment: PythonEnvironment, +): PythonCommandRunConfiguration[] | undefined { + const shell = identifyTerminalShell(terminal); + + let deactivation: PythonCommandRunConfiguration[] | undefined; + if (environment.execInfo?.shellDeactivation) { + deactivation = environment.execInfo.shellDeactivation.get(shell); + if (!deactivation) { + deactivation = environment.execInfo.shellDeactivation.get(TerminalShellType.unknown); + } + } + + if (!deactivation) { + deactivation = environment.execInfo?.deactivation; + } + + return deactivation; +} diff --git a/src/features/execution/terminalManager.ts b/src/features/execution/terminalManager.ts index cd744471..db332513 100644 --- a/src/features/execution/terminalManager.ts +++ b/src/features/execution/terminalManager.ts @@ -18,15 +18,17 @@ import { onDidEndTerminalShellExecution, onDidOpenTerminal, onDidStartTerminalShellExecution, + terminals, withProgress, } from '../../common/window.apis'; import { IconPath, PythonEnvironment, PythonProject } from '../../api'; -import { getActivationCommand, isActivatableEnvironment } from './activation'; +import { getActivationCommand, getDeactivationCommand, isActivatableEnvironment } from './activation'; import { showErrorMessage } from '../../common/errors/utils'; import { quoteArgs } from './execUtils'; import { createDeferred } from '../../common/utils/deferred'; -import { traceVerbose } from '../../common/logging'; +import { traceError, traceVerbose } from '../../common/logging'; import { getConfiguration } from '../../common/workspace.apis'; +import { EnvironmentManagers } from '../../internal.api'; function getIconPath(i: IconPath | undefined): IconPath | undefined { if (i instanceof Uri) { @@ -38,7 +40,21 @@ function getIconPath(i: IconPath | undefined): IconPath | undefined { const SHELL_INTEGRATION_TIMEOUT = 500; // 0.5 seconds const SHELL_INTEGRATION_POLL_INTERVAL = 100; // 0.1 seconds -export interface TerminalManager extends Disposable { +export interface TerminalActivation { + isActivated(terminal: Terminal, environment?: PythonEnvironment): boolean; + activate(terminal: Terminal, environment: PythonEnvironment): Promise; + deactivate(terminal: Terminal): Promise; +} + +export interface TerminalCreation { + create( + environment: PythonEnvironment, + cwd?: string | Uri, + env?: { [key: string]: string | null | undefined }, + ): Promise; +} + +export interface TerminalGetters { getProjectTerminal(project: PythonProject, environment: PythonEnvironment, createNew?: boolean): Promise; getDedicatedTerminal( uri: Uri, @@ -46,15 +62,18 @@ export interface TerminalManager extends Disposable { environment: PythonEnvironment, createNew?: boolean, ): Promise; - create( - environment: PythonEnvironment, - cwd?: string | Uri | PythonProject, - env?: { [key: string]: string | null | undefined }, - ): Promise; } -export class TerminalManagerImpl implements Disposable { +export interface TerminalManager extends TerminalActivation, TerminalCreation, TerminalGetters, Disposable { + initialize(projects: PythonProject[], em: EnvironmentManagers): Promise; +} + +export class TerminalManagerImpl implements TerminalManager { private disposables: Disposable[] = []; + private activatedTerminals = new Map(); + private activatingTerminals = new Map>(); + private deactivatingTerminals = new Map>(); + private onTerminalOpenedEmitter = new EventEmitter(); private onTerminalOpened = this.onTerminalOpenedEmitter.event; @@ -106,6 +125,17 @@ export class TerminalManagerImpl implements Disposable { } } + private deactivateLegacy(terminal: Terminal, environment: PythonEnvironment) { + const deactivationCommands = getDeactivationCommand(terminal, environment); + if (deactivationCommands) { + for (const command of deactivationCommands) { + const args = command.args ?? []; + const text = quoteArgs([command.executable, ...args]).join(' '); + terminal.sendText(text); + } + } + } + private async activateUsingShellIntegration( shellIntegration: TerminalShellIntegration, terminal: Terminal, @@ -135,14 +165,47 @@ export class TerminalManagerImpl implements Disposable { } } + private async deactivateUsingShellIntegration( + shellIntegration: TerminalShellIntegration, + terminal: Terminal, + environment: PythonEnvironment, + ): Promise { + const deactivationCommands = getDeactivationCommand(terminal, environment); + if (deactivationCommands) { + for (const command of deactivationCommands) { + const execPromise = createDeferred(); + const execution = shellIntegration.executeCommand(command.executable, command.args ?? []); + const disposables: Disposable[] = []; + disposables.push( + this.onTerminalShellExecutionEnd((e: TerminalShellExecutionEndEvent) => { + if (e.execution === execution) { + execPromise.resolve(); + } + }), + this.onTerminalShellExecutionStart((e: TerminalShellExecutionStartEvent) => { + if (e.execution === execution) { + traceVerbose(`Shell execution started: ${command.executable} ${command.args?.join(' ')}`); + } + }), + ); + + await execPromise.promise; + } + } + } + private async activateEnvironmentOnCreation(terminal: Terminal, environment: PythonEnvironment): Promise { const deferred = createDeferred(); const disposables: Disposable[] = []; let disposeTimer: Disposable | undefined; let activated = false; + this.activatingTerminals.set(terminal, deferred.promise); try { disposables.push( + new Disposable(() => { + this.activatingTerminals.delete(terminal); + }), this.onTerminalOpened(async (t: Terminal) => { if (t === terminal) { if (terminal.shellIntegration) { @@ -191,6 +254,10 @@ export class TerminalManagerImpl implements Disposable { disposeTimer?.dispose(); }), ); + await deferred.promise; + this.activatedTerminals.set(terminal, environment); + } catch (ex) { + traceError('Failed to activate environment:\r\n', ex); } finally { disposables.forEach((d) => d.dispose()); } @@ -198,7 +265,7 @@ export class TerminalManagerImpl implements Disposable { public async create( environment: PythonEnvironment, - cwd?: string | Uri, + cwd?: string | Uri | undefined, env?: { [key: string]: string | null | undefined }, ): Promise { const activatable = isActivatableEnvironment(environment); @@ -290,6 +357,110 @@ export class TerminalManagerImpl implements Disposable { return newTerminal; } + public isActivated(terminal: Terminal, environment?: PythonEnvironment): boolean { + if (!environment) { + return this.activatedTerminals.has(terminal); + } + const env = this.activatedTerminals.get(terminal); + return env?.envId.id === environment?.envId.id; + } + + private async activateInternal(terminal: Terminal, environment: PythonEnvironment): Promise { + if (terminal.shellIntegration) { + await this.activateUsingShellIntegration(terminal.shellIntegration, terminal, environment); + } else { + this.activateLegacy(terminal, environment); + } + } + + public async activate(terminal: Terminal, environment: PythonEnvironment): Promise { + if (this.isActivated(terminal, environment)) { + return; + } + + if (this.deactivatingTerminals.has(terminal)) { + traceVerbose('Terminal is being deactivated, cannot activate. Waiting...'); + return this.deactivatingTerminals.get(terminal); + } + + if (this.activatingTerminals.has(terminal)) { + return this.activatingTerminals.get(terminal); + } + + try { + traceVerbose(`Activating terminal for environment: ${environment.displayName}`); + const promise = this.activateInternal(terminal, environment); + this.activatingTerminals.set(terminal, promise); + await promise; + this.activatedTerminals.set(terminal, environment); + } catch (ex) { + traceError('Failed to activate environment:\r\n', ex); + } finally { + this.activatingTerminals.delete(terminal); + } + } + + private async deactivateInternal(terminal: Terminal, environment: PythonEnvironment): Promise { + if (terminal.shellIntegration) { + await this.deactivateUsingShellIntegration(terminal.shellIntegration, terminal, environment); + } else { + this.deactivateLegacy(terminal, environment); + } + } + + public async deactivate(terminal: Terminal): Promise { + if (this.activatingTerminals.has(terminal)) { + traceVerbose('Terminal is being activated, cannot deactivate. Waiting...'); + await this.activatingTerminals.get(terminal); + } + + if (this.deactivatingTerminals.has(terminal)) { + return this.deactivatingTerminals.get(terminal); + } + + const environment = this.activatedTerminals.get(terminal); + if (!environment) { + return; + } + + try { + traceVerbose(`Deactivating terminal for environment: ${environment.displayName}`); + const promise = this.deactivateInternal(terminal, environment); + this.deactivatingTerminals.set(terminal, promise); + await promise; + this.activatedTerminals.delete(terminal); + } catch (ex) { + traceError('Failed to activate environment:\r\n', ex); + } finally { + this.deactivatingTerminals.delete(terminal); + } + } + + public async initialize(projects: PythonProject[], em: EnvironmentManagers): Promise { + const config = getConfiguration('python'); + if (config.get('terminal.activateEnvInCurrentTerminal', false)) { + await Promise.all( + terminals().map(async (t) => { + if (projects.length === 0) { + const manager = em.getEnvironmentManager(undefined); + const env = await manager?.get(undefined); + if (env) { + return this.activate(t, env); + } + } else if (projects.length === 1) { + const manager = em.getEnvironmentManager(projects[0].uri); + const env = await manager?.get(projects[0].uri); + if (env) { + return this.activate(t, env); + } + } else { + // TODO: handle multi project case + } + }), + ); + } + } + dispose(): void { this.disposables.forEach((d) => d.dispose()); } From 066b8f8b7e6f1347f3f17d95c7b085d8d331cb9d Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Tue, 5 Nov 2024 14:39:38 -0800 Subject: [PATCH 5/5] More terminal features --- .vscode/settings.json | 6 +- package.json | 40 ++++++ src/common/command.api.ts | 5 + src/extension.ts | 52 +++++--- .../{execution => common}/activation.ts | 0 .../{execution => common}/shellDetector.ts | 0 src/features/envCommands.ts | 8 +- src/features/execution/activateButton.ts | 78 ----------- src/features/terminal/activateMenuButton.ts | 64 +++++++++ .../terminalManager.ts | 122 ++++++++++++------ 10 files changed, 230 insertions(+), 145 deletions(-) create mode 100644 src/common/command.api.ts rename src/features/{execution => common}/activation.ts (100%) rename src/features/{execution => common}/shellDetector.ts (100%) delete mode 100644 src/features/execution/activateButton.ts create mode 100644 src/features/terminal/activateMenuButton.ts rename src/features/{execution => terminal}/terminalManager.ts (83%) diff --git a/.vscode/settings.json b/.vscode/settings.json index 8d4822a3..43d5bdc1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,9 +10,9 @@ }, // Turn off tsc task auto detection since we have the necessary tasks as npm scripts "typescript.tsc.autoDetect": "off", + "editor.formatOnSave": true, "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true + "editor.defaultFormatter": "esbenp.prettier-vscode" }, "prettier.tabWidth": 4 -} \ No newline at end of file +} diff --git a/package.json b/package.json index c378e8e2..422b159d 100644 --- a/package.json +++ b/package.json @@ -179,6 +179,18 @@ "title": "Run as Task", "category": "Python Envs", "icon": "$(play)" + }, + { + "command": "python-envs.terminal.activate", + "title": "Activate Environment in Current Terminal", + "category": "Python Envs", + "icon": "$(zap)" + }, + { + "command": "python-envs.terminal.deactivate", + "title": "Deactivate Environment in Current Terminal", + "category": "Python Envs", + "icon": "$(circle-slash)" } ], "menus": { @@ -234,6 +246,14 @@ { "command": "python-envs.runAsTask", "when": "true" + }, + { + "command": "python-envs.terminal.activate", + "when": "false" + }, + { + "command": "python-envs.terminal.deactivate", + "when": "false" } ], "view/item/context": [ @@ -315,6 +335,16 @@ "command": "python-envs.refreshAllManagers", "group": "navigation", "when": "view == env-managers" + }, + { + "command": "python-envs.terminal.activate", + "group": "navigation", + "when": "view == terminal && pythonTerminalActivation && !pythonTerminalActivated" + }, + { + "command": "python-envs.terminal.deactivate", + "group": "navigation", + "when": "view == terminal && pythonTerminalActivation && pythonTerminalActivated" } ], "explorer/context": [ @@ -335,6 +365,16 @@ "group": "Python", "when": "editorLangId == python" } + ], + "terminal/title/context": [ + { + "command": "python-envs.terminal.activate", + "when": "pythonTerminalActivation && !pythonTerminalActivated" + }, + { + "command": "python-envs.terminal.deactivate", + "when": "pythonTerminalActivation && pythonTerminalActivated" + } ] }, "viewsContainers": { diff --git a/src/common/command.api.ts b/src/common/command.api.ts new file mode 100644 index 00000000..375cc007 --- /dev/null +++ b/src/common/command.api.ts @@ -0,0 +1,5 @@ +import { commands } from 'vscode'; + +export function executeCommand(command: string, ...rest: any[]): Thenable { + return commands.executeCommand(command, ...rest); +} diff --git a/src/extension.ts b/src/extension.ts index 1fecea5e..41456d27 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -38,9 +38,13 @@ import { } from './features/projectCreators'; import { WorkspaceView } from './features/views/projectView'; import { registerCompletionProvider } from './features/settings/settingCompletions'; -import { TerminalManager, TerminalManagerImpl } from './features/execution/terminalManager'; -import { ActivateStatusButton } from './features/execution/activateButton'; -import { activeTerminal, onDidChangeActiveTextEditor } from './common/window.apis'; +import { TerminalManager, TerminalManagerImpl } from './features/terminal/terminalManager'; +import { activeTerminal, onDidChangeActiveTerminal, onDidChangeActiveTextEditor } from './common/window.apis'; +import { + getEnvironmentForTerminal, + setActivateMenuButtonContext, + updateActivateMenuButtonContext, +} from './features/terminal/activateMenuButton'; export async function activate(context: ExtensionContext): Promise { // Logging should be set up before anything else. @@ -59,9 +63,6 @@ export async function activate(context: ExtensionContext): Promise { - if (r.projects) { - projects.push(r.projects); + if (r.project) { + projects.push(r.project); } }); workspaceView.updateProject(projects); + await updateActivateMenuButtonContext(terminalManager, projectManager, envManagers); } }), commands.registerCommand('python-envs.setEnv', async (item) => { @@ -126,11 +128,12 @@ export async function activate(context: ExtensionContext): Promise { - if (r.projects) { - projects.push(r.projects); + if (r.project) { + projects.push(r.project); } }); workspaceView.updateProject(projects); + await updateActivateMenuButtonContext(terminalManager, projectManager, envManagers); } }), commands.registerCommand('python-envs.reset', async (item) => { @@ -164,16 +167,31 @@ export async function activate(context: ExtensionContext): Promise { return createTerminalCommand(item, api, terminalManager); }), - commands.registerCommand('python-envs.terminal.activate', async (terminal, env) => { - await terminalManager.activate(terminal, env); - await activateStatusButton.update(terminal); + commands.registerCommand('python-envs.terminal.activate', async () => { + const terminal = activeTerminal(); + if (terminal) { + const env = await getEnvironmentForTerminal(terminalManager, projectManager, envManagers, terminal); + if (env) { + await terminalManager.activate(terminal, env); + await setActivateMenuButtonContext(terminalManager, terminal, env); + } + } }), - commands.registerCommand('python-envs.terminal.deactivate', async (terminal) => { - await terminalManager.deactivate(terminal); - await activateStatusButton.update(terminal); + commands.registerCommand('python-envs.terminal.deactivate', async () => { + const terminal = activeTerminal(); + if (terminal) { + await terminalManager.deactivate(terminal); + const env = await getEnvironmentForTerminal(terminalManager, projectManager, envManagers, terminal); + if (env) { + await setActivateMenuButtonContext(terminalManager, terminal, env); + } + } }), envManagers.onDidChangeEnvironmentManager(async () => { - await activateStatusButton.update(activeTerminal()); + await updateActivateMenuButtonContext(terminalManager, projectManager, envManagers); + }), + onDidChangeActiveTerminal(async (t) => { + await updateActivateMenuButtonContext(terminalManager, projectManager, envManagers, t); }), onDidChangeActiveTextEditor(async (e: TextEditor | undefined) => { if (e && !e.document.isUntitled && e.document.uri.scheme === 'file') { diff --git a/src/features/execution/activation.ts b/src/features/common/activation.ts similarity index 100% rename from src/features/execution/activation.ts rename to src/features/common/activation.ts diff --git a/src/features/execution/shellDetector.ts b/src/features/common/shellDetector.ts similarity index 100% rename from src/features/execution/shellDetector.ts rename to src/features/common/shellDetector.ts diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index 590b57c5..86921936 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -39,7 +39,7 @@ import { pickEnvironment } from '../common/pickers/environments'; import { pickEnvironmentManager, pickPackageManager, pickCreator } from '../common/pickers/managers'; import { pickPackageOptions, getPackagesToInstall, getPackagesToUninstall } from '../common/pickers/packages'; import { pickProject, pickProjectMany } from '../common/pickers/projects'; -import { TerminalManager } from './execution/terminalManager'; +import { TerminalManager } from './terminal/terminalManager'; export async function refreshManagerCommand(context: unknown): Promise { if (context instanceof EnvManagerTreeItem) { @@ -158,7 +158,7 @@ export async function handlePackagesCommand( } export interface EnvironmentSetResult { - projects?: PythonProject; + project?: PythonProject; environment: PythonEnvironment; } @@ -180,7 +180,7 @@ export async function setEnvironmentCommand( packageManager: manager.preferredPackageManagerId, })), ); - return projects.map((p) => ({ project: [p], environment: view.environment })); + return projects.map((p) => ({ project: p, environment: view.environment })); } return; } else if (context instanceof ProjectItem) { @@ -240,7 +240,7 @@ export async function setEnvironmentCommand( }); await Promise.all(promises); await setAllManagerSettings(settings); - return [...projects.map((p) => ({ project: p, environment: selected }))]; + return projects.map((p) => ({ project: p, environment: selected })); } return; } diff --git a/src/features/execution/activateButton.ts b/src/features/execution/activateButton.ts deleted file mode 100644 index 9e560f43..00000000 --- a/src/features/execution/activateButton.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { StatusBarAlignment, StatusBarItem, Terminal } from 'vscode'; -import { Disposable } from 'vscode-jsonrpc'; -import { activeTextEditor, createStatusBarItem, onDidChangeActiveTerminal } from '../../common/window.apis'; -import { TerminalActivation } from './terminalManager'; -import { EnvironmentManagers, PythonProjectManager } from '../../internal.api'; -import { isActivatableEnvironment } from './activation'; -import { PythonEnvironment } from '../../api'; - -export class ActivateStatusButton implements Disposable { - private readonly statusBarItem: StatusBarItem; - private disposables: Disposable[] = []; - - constructor( - private readonly tm: TerminalActivation, - private readonly em: EnvironmentManagers, - private readonly pm: PythonProjectManager, - ) { - this.statusBarItem = createStatusBarItem('python-envs.terminal.activate', StatusBarAlignment.Right, 100); - this.disposables.push( - this.statusBarItem, - onDidChangeActiveTerminal(async (terminal) => { - await this.update(terminal); - }), - ); - } - - public dispose() { - this.disposables.forEach((d) => d.dispose()); - } - - public async update(terminal?: Terminal) { - if (!terminal) { - this.statusBarItem.hide(); - return; - } - - const projects = this.pm.getProjects(); - if (projects.length === 0) { - this.statusBarItem.hide(); - return; - } - - const projectUri = projects.length === 1 ? projects[0].uri : activeTextEditor()?.document.uri; - if (!projectUri) { - this.statusBarItem.hide(); - return; - } - - const manager = this.em.getEnvironmentManager(projectUri); - const env = await manager?.get(projectUri); - if (env && isActivatableEnvironment(env)) { - this.updateStatusBarItem(terminal, env); - } else { - this.statusBarItem.hide(); - } - } - - private updateStatusBarItem(terminal: Terminal, env: PythonEnvironment) { - if (this.tm.isActivated(terminal, env)) { - this.statusBarItem.text = '$(terminal) Deactivate'; - this.statusBarItem.tooltip = 'Deactivate the terminal'; - this.statusBarItem.command = { - command: 'python-envs.terminal.deactivate', - title: 'Deactivate the terminal', - arguments: [terminal, env], - }; - } else { - this.statusBarItem.text = '$(terminal) Activate'; - this.statusBarItem.tooltip = 'Activate the terminal'; - this.statusBarItem.command = { - command: 'python-envs.terminal.activate', - title: 'Activate the terminal', - arguments: [terminal, env], - }; - } - this.statusBarItem.show(); - } -} diff --git a/src/features/terminal/activateMenuButton.ts b/src/features/terminal/activateMenuButton.ts new file mode 100644 index 00000000..968269ce --- /dev/null +++ b/src/features/terminal/activateMenuButton.ts @@ -0,0 +1,64 @@ +import { Terminal } from 'vscode'; +import { activeTerminal } from '../../common/window.apis'; +import { TerminalActivation, TerminalEnvironment } from './terminalManager'; +import { EnvironmentManagers, PythonProjectManager } from '../../internal.api'; +import { PythonEnvironment } from '../../api'; +import { isActivatableEnvironment } from '../common/activation'; +import { executeCommand } from '../../common/command.api'; + +export async function getEnvironmentForTerminal( + tm: TerminalEnvironment, + pm: PythonProjectManager, + em: EnvironmentManagers, + t: Terminal, +): Promise { + let env = await tm.getEnvironment(t); + + if (!env) { + const projects = pm.getProjects(); + if (projects.length === 0) { + const manager = em.getEnvironmentManager(undefined); + env = await manager?.get(undefined); + } else if (projects.length === 1) { + const manager = em.getEnvironmentManager(projects[0].uri); + env = await manager?.get(projects[0].uri); + } + } + + return env; +} + +export async function updateActivateMenuButtonContext( + tm: TerminalEnvironment & TerminalActivation, + pm: PythonProjectManager, + em: EnvironmentManagers, + terminal?: Terminal, +): Promise { + const selected = terminal ?? activeTerminal(); + + if (!selected) { + return; + } + + const env = await getEnvironmentForTerminal(tm, pm, em, selected); + if (!env) { + return; + } + + await setActivateMenuButtonContext(tm, selected, env); +} + +export async function setActivateMenuButtonContext( + tm: TerminalActivation, + terminal: Terminal, + env: PythonEnvironment, +): Promise { + const activatable = isActivatableEnvironment(env); + await executeCommand('setContext', 'pythonTerminalActivation', activatable); + + if (tm.isActivated(terminal)) { + await executeCommand('setContext', 'pythonTerminalActivated', true); + } else { + await executeCommand('setContext', 'pythonTerminalActivated', false); + } +} diff --git a/src/features/execution/terminalManager.ts b/src/features/terminal/terminalManager.ts similarity index 83% rename from src/features/execution/terminalManager.ts rename to src/features/terminal/terminalManager.ts index db332513..8cdb9b18 100644 --- a/src/features/execution/terminalManager.ts +++ b/src/features/terminal/terminalManager.ts @@ -22,9 +22,9 @@ import { withProgress, } from '../../common/window.apis'; import { IconPath, PythonEnvironment, PythonProject } from '../../api'; -import { getActivationCommand, getDeactivationCommand, isActivatableEnvironment } from './activation'; +import { getActivationCommand, getDeactivationCommand, isActivatableEnvironment } from '../common/activation'; import { showErrorMessage } from '../../common/errors/utils'; -import { quoteArgs } from './execUtils'; +import { quoteArgs } from '../execution/execUtils'; import { createDeferred } from '../../common/utils/deferred'; import { traceError, traceVerbose } from '../../common/logging'; import { getConfiguration } from '../../common/workspace.apis'; @@ -64,10 +64,22 @@ export interface TerminalGetters { ): Promise; } -export interface TerminalManager extends TerminalActivation, TerminalCreation, TerminalGetters, Disposable { +export interface TerminalEnvironment { + getEnvironment(terminal: Terminal): Promise; +} + +export interface TerminalInit { initialize(projects: PythonProject[], em: EnvironmentManagers): Promise; } +export interface TerminalManager + extends TerminalEnvironment, + TerminalInit, + TerminalActivation, + TerminalCreation, + TerminalGetters, + Disposable {} + export class TerminalManagerImpl implements TerminalManager { private disposables: Disposable[] = []; private activatedTerminals = new Map(); @@ -122,6 +134,7 @@ export class TerminalManagerImpl implements TerminalManager { const text = quoteArgs([command.executable, ...args]).join(' '); terminal.sendText(text); } + this.activatedTerminals.set(terminal, environment); } } @@ -133,6 +146,7 @@ export class TerminalManagerImpl implements TerminalManager { const text = quoteArgs([command.executable, ...args]).join(' '); terminal.sendText(text); } + this.activatedTerminals.delete(terminal); } } @@ -143,24 +157,29 @@ export class TerminalManagerImpl implements TerminalManager { ): Promise { const activationCommands = getActivationCommand(terminal, environment); if (activationCommands) { - for (const command of activationCommands) { - const execPromise = createDeferred(); - const execution = shellIntegration.executeCommand(command.executable, command.args ?? []); - const disposables: Disposable[] = []; - disposables.push( - this.onTerminalShellExecutionEnd((e: TerminalShellExecutionEndEvent) => { - if (e.execution === execution) { - execPromise.resolve(); - } - }), - this.onTerminalShellExecutionStart((e: TerminalShellExecutionStartEvent) => { - if (e.execution === execution) { - traceVerbose(`Shell execution started: ${command.executable} ${command.args?.join(' ')}`); - } - }), - ); - - await execPromise.promise; + try { + for (const command of activationCommands) { + const execPromise = createDeferred(); + const execution = shellIntegration.executeCommand(command.executable, command.args ?? []); + const disposables: Disposable[] = []; + disposables.push( + this.onTerminalShellExecutionEnd((e: TerminalShellExecutionEndEvent) => { + if (e.execution === execution) { + execPromise.resolve(); + } + }), + this.onTerminalShellExecutionStart((e: TerminalShellExecutionStartEvent) => { + if (e.execution === execution) { + traceVerbose( + `Shell execution started: ${command.executable} ${command.args?.join(' ')}`, + ); + } + }), + ); + await execPromise.promise; + } + } finally { + this.activatedTerminals.set(terminal, environment); } } } @@ -172,24 +191,30 @@ export class TerminalManagerImpl implements TerminalManager { ): Promise { const deactivationCommands = getDeactivationCommand(terminal, environment); if (deactivationCommands) { - for (const command of deactivationCommands) { - const execPromise = createDeferred(); - const execution = shellIntegration.executeCommand(command.executable, command.args ?? []); - const disposables: Disposable[] = []; - disposables.push( - this.onTerminalShellExecutionEnd((e: TerminalShellExecutionEndEvent) => { - if (e.execution === execution) { - execPromise.resolve(); - } - }), - this.onTerminalShellExecutionStart((e: TerminalShellExecutionStartEvent) => { - if (e.execution === execution) { - traceVerbose(`Shell execution started: ${command.executable} ${command.args?.join(' ')}`); - } - }), - ); - - await execPromise.promise; + try { + for (const command of deactivationCommands) { + const execPromise = createDeferred(); + const execution = shellIntegration.executeCommand(command.executable, command.args ?? []); + const disposables: Disposable[] = []; + disposables.push( + this.onTerminalShellExecutionEnd((e: TerminalShellExecutionEndEvent) => { + if (e.execution === execution) { + execPromise.resolve(); + } + }), + this.onTerminalShellExecutionStart((e: TerminalShellExecutionStartEvent) => { + if (e.execution === execution) { + traceVerbose( + `Shell execution started: ${command.executable} ${command.args?.join(' ')}`, + ); + } + }), + ); + + await execPromise.promise; + } + } finally { + this.activatedTerminals.delete(terminal); } } } @@ -255,7 +280,6 @@ export class TerminalManagerImpl implements TerminalManager { }), ); await deferred.promise; - this.activatedTerminals.set(terminal, environment); } catch (ex) { traceError('Failed to activate environment:\r\n', ex); } finally { @@ -392,7 +416,6 @@ export class TerminalManagerImpl implements TerminalManager { const promise = this.activateInternal(terminal, environment); this.activatingTerminals.set(terminal, promise); await promise; - this.activatedTerminals.set(terminal, environment); } catch (ex) { traceError('Failed to activate environment:\r\n', ex); } finally { @@ -428,9 +451,8 @@ export class TerminalManagerImpl implements TerminalManager { const promise = this.deactivateInternal(terminal, environment); this.deactivatingTerminals.set(terminal, promise); await promise; - this.activatedTerminals.delete(terminal); } catch (ex) { - traceError('Failed to activate environment:\r\n', ex); + traceError('Failed to deactivate environment:\r\n', ex); } finally { this.deactivatingTerminals.delete(terminal); } @@ -461,6 +483,20 @@ export class TerminalManagerImpl implements TerminalManager { } } + public async getEnvironment(terminal: Terminal): Promise { + if (this.deactivatingTerminals.has(terminal)) { + return undefined; + } + + if (this.activatingTerminals.has(terminal)) { + await this.activatingTerminals.get(terminal); + } + + if (this.activatedTerminals.has(terminal)) { + return Promise.resolve(this.activatedTerminals.get(terminal)); + } + } + dispose(): void { this.disposables.forEach((d) => d.dispose()); }