diff --git a/.vscode/tasks.json b/.vscode/tasks.json index e6bf967ddf0..4e7499e663a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -403,6 +403,18 @@ "endsPattern": "Redirection server listening on.*" } } + }, + { + "label": "Install & Compile", + "type": "shell", + "command": "npm install && npm run compile", + "windows": { + "command": "cmd /d /c \"npm install && npm run compile\"" + }, + "inSessions": true, + "runOptions": { + "runOn": "worktreeCreated" + } } ] } diff --git a/extensions/git/package.json b/extensions/git/package.json index 1fbac49569f..3eac301dfbe 100644 --- a/extensions/git/package.json +++ b/extensions/git/package.json @@ -38,6 +38,7 @@ "scmTextDocument", "scmValidation", "statusBarItemTooltip", + "taskRunOptions", "tabInputMultiDiff", "tabInputTextMerge", "textEditorDiffInformation", diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index bd6b6a5c7ff..ac0b2bff402 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -9,7 +9,7 @@ import * as fs from 'fs'; import * as fsPromises from 'fs/promises'; import * as path from 'path'; import picomatch from 'picomatch'; -import { CancellationError, CancellationToken, CancellationTokenSource, Command, commands, Disposable, Event, EventEmitter, ExcludeSettingOptions, FileDecoration, l10n, LogLevel, LogOutputChannel, Memento, ProgressLocation, ProgressOptions, RelativePattern, scm, SourceControl, SourceControlInputBox, SourceControlInputBoxValidation, SourceControlInputBoxValidationType, SourceControlResourceDecorations, SourceControlResourceGroup, SourceControlResourceState, TabInputNotebookDiff, TabInputTextDiff, TabInputTextMultiDiff, ThemeColor, ThemeIcon, Uri, window, workspace, WorkspaceEdit } from 'vscode'; +import { CancellationError, CancellationToken, CancellationTokenSource, Command, commands, CustomExecution, Disposable, Event, EventEmitter, ExcludeSettingOptions, FileDecoration, l10n, LogLevel, LogOutputChannel, Memento, ProcessExecution, ProgressLocation, ProgressOptions, RelativePattern, scm, ShellExecution, SourceControl, SourceControlInputBox, SourceControlInputBoxValidation, SourceControlInputBoxValidationType, SourceControlResourceDecorations, SourceControlResourceGroup, SourceControlResourceState, TabInputNotebookDiff, TabInputTextDiff, TabInputTextMultiDiff, Task, TaskRunOn, tasks, ThemeColor, ThemeIcon, Uri, window, workspace, WorkspaceEdit, WorkspaceFolder } from 'vscode'; import { ActionButton } from './actionButton'; import { ApiRepository } from './api/api1'; import type { Branch, BranchQuery, Change, CommitOptions, DiffChange, FetchOptions, LogOptions, Ref, Remote, RepositoryKind } from './api/git'; @@ -1892,15 +1892,42 @@ export class Repository implements Disposable { this.globalState.update(`${Repository.WORKTREE_ROOT_STORAGE_KEY}:${this.root}`, newWorktreeRoot); } - // Copy worktree include files. We explicitly do not await this - // since we don't want to block the worktree creation on the - // copy operation. - this._copyWorktreeIncludeFiles(worktreePath!); + this._setupWorktree(worktreePath!); return worktreePath!; }); } + private async _setupWorktree(worktreePath: string): Promise { + // Copy worktree include files. We explicitly do not await this + // since we don't want to block the worktree creation on the + // copy operation. + await this._copyWorktreeIncludeFiles(worktreePath); + + await this._runWorktreeCreatedTasks(worktreePath); + } + + private async _runWorktreeCreatedTasks(worktreePath: string): Promise { + try { + const allTasks = await tasks.fetchTasks(); + const worktreeTasks = allTasks.filter(task => task.runOptions.runOn === TaskRunOn.WorktreeCreated); + + for (const task of worktreeTasks) { + const worktreeTask = retargetTaskToWorktree(task, worktreePath); + if (!worktreeTask) { + this.logger.warn(`[Repository][_runWorktreeCreatedTasks] Skipped task '${task.name}' because it could not be retargeted to worktree '${worktreePath}'.`); + continue; + } + + tasks.executeTask(worktreeTask).then(undefined, err => { + this.logger.warn(`[Repository][_runWorktreeCreatedTasks] Failed to execute worktree-created task '${task.name}' for '${worktreePath}': ${err}`); + }); + } + } catch (err) { + this.logger.warn(`[Repository][_runWorktreeCreatedTasks] Failed to execute worktree-created tasks for '${worktreePath}': ${err}`); + } + } + private async _getWorktreeIncludePaths(): Promise> { const config = workspace.getConfiguration('git', Uri.file(this.root)); const worktreeIncludeFiles = config.get('worktreeIncludeFiles', []); @@ -3349,3 +3376,56 @@ export class Repository implements Disposable { this.disposables = dispose(this.disposables); } } + +function retargetTaskToWorktree(task: Task, worktreePath: string): Task | undefined { + const execution = retargetTaskExecution(task.execution, worktreePath); + if (!execution) { + return undefined; + } + + const worktreeFolder: WorkspaceFolder = { + uri: Uri.file(worktreePath), + name: path.basename(worktreePath), + index: workspace.workspaceFolders?.length ?? 0 + }; + + const worktreeTask = new Task({ ...task.definition }, worktreeFolder, task.name, task.source, execution, task.problemMatchers); + worktreeTask.detail = task.detail; + worktreeTask.group = task.group; + worktreeTask.isBackground = task.isBackground; + worktreeTask.presentationOptions = { ...task.presentationOptions }; + worktreeTask.runOptions = { ...task.runOptions }; + + return worktreeTask; +} + +function retargetTaskExecution(execution: ProcessExecution | ShellExecution | CustomExecution | undefined, worktreePath: string): ProcessExecution | ShellExecution | CustomExecution | undefined { + if (!execution) { + return undefined; + } + + if (execution instanceof ProcessExecution) { + return new ProcessExecution(execution.process, execution.args, { + ...execution.options, + cwd: worktreePath + }); + } + + if (execution instanceof ShellExecution) { + if (execution.commandLine !== undefined) { + return new ShellExecution(execution.commandLine, { + ...execution.options, + cwd: worktreePath + }); + } + + if (execution.command !== undefined) { + return new ShellExecution(execution.command, execution.args ?? [], { + ...execution.options, + cwd: worktreePath + }); + } + } + + return execution; +} diff --git a/extensions/git/tsconfig.json b/extensions/git/tsconfig.json index 2a7ad5259ac..a34d12aaa48 100644 --- a/extensions/git/tsconfig.json +++ b/extensions/git/tsconfig.json @@ -27,6 +27,7 @@ "../../src/vscode-dts/vscode.proposed.scmMultiDiffEditor.d.ts", "../../src/vscode-dts/vscode.proposed.scmTextDocument.d.ts", "../../src/vscode-dts/vscode.proposed.statusBarItemTooltip.d.ts", + "../../src/vscode-dts/vscode.proposed.taskRunOptions.d.ts", "../../src/vscode-dts/vscode.proposed.tabInputMultiDiff.d.ts", "../../src/vscode-dts/vscode.proposed.tabInputTextMerge.d.ts", "../../src/vscode-dts/vscode.proposed.textEditorDiffInformation.d.ts", diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 00e09a016ac..65fd42574b0 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -426,6 +426,9 @@ const _allApiProposals = { taskProblemMatcherStatus: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.taskProblemMatcherStatus.d.ts', }, + taskRunOptions: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.taskRunOptions.d.ts', + }, telemetry: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.telemetry.d.ts', }, diff --git a/src/vs/sessions/contrib/chat/browser/media/runScriptAction.css b/src/vs/sessions/contrib/chat/browser/media/runScriptAction.css new file mode 100644 index 00000000000..0837bc7b8c0 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/runScriptAction.css @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.run-script-action-widget { + display: flex; + flex-direction: column; + gap: 12px; + background-color: var(--vscode-quickInput-background); + padding: 8px 8px 12px; +} + +.run-script-action-section { + display: flex; + flex-direction: column; + gap: 6px; +} + +.run-script-action-label { + font-size: 12px; + font-weight: 600; +} + +.run-script-action-input .monaco-inputbox { + width: 100%; +} + +.run-script-action-option-row { + display: flex; + align-items: center; + min-height: 22px; + gap: 8px; +} + +.run-script-action-option-text { + cursor: pointer; + -webkit-user-select: none; + user-select: none; +} + +.run-script-action-section .monaco-custom-radio { + width: fit-content; + max-width: 100%; +} + +.run-script-action-hint { + font-size: 12px; + opacity: 0.8; +} + +.run-script-action-buttons { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 4px; +} diff --git a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts index a1cd803058a..72f9d4c771a 100644 --- a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts +++ b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts @@ -6,7 +6,7 @@ import { equals } from '../../../../base/common/arrays.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { autorun, derivedOpts, IObservable } from '../../../../base/common/observable.js'; import { localize, localize2 } from '../../../../nls.js'; import { MenuId, registerAction2, Action2, MenuRegistry } from '../../../../platform/actions/common/actions.js'; @@ -18,9 +18,10 @@ import { IWorkbenchContribution } from '../../../../workbench/common/contributio import { SessionsCategories } from '../../../common/categories.js'; import { IActiveSessionItem, IsActiveSessionBackgroundProviderContext, ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { Menus } from '../../../browser/menus.js'; -import { ISessionsConfigurationService, ITaskEntry, TaskStorageTarget } from './sessionsConfigurationService.js'; +import { INonSessionTaskEntry, ISessionsConfigurationService, ITaskEntry, TaskStorageTarget } from './sessionsConfigurationService.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { IRunScriptCustomTaskWidgetResult, RunScriptCustomTaskWidget } from './runScriptCustomTaskWidget.js'; @@ -31,7 +32,6 @@ export const RunScriptDropdownMenuId = MenuId.for('AgentSessionsRunScriptDropdow const RUN_SCRIPT_ACTION_ID = 'workbench.action.agentSessions.runScript'; const RUN_SCRIPT_ACTION_PRIMARY_ID = 'workbench.action.agentSessions.runScriptPrimary'; const CONFIGURE_DEFAULT_RUN_ACTION_ID = 'workbench.action.agentSessions.configureDefaultRunAction'; - function getTaskDisplayLabel(task: ITaskEntry): string { if (task.label && task.label.length > 0) { return task.label; @@ -48,6 +48,19 @@ function getTaskDisplayLabel(task: ITaskEntry): string { return ''; } +function getTaskCommandPreview(task: ITaskEntry): string { + if (task.command && task.command.length > 0) { + return task.command; + } + if (task.script && task.script.length > 0) { + return localize('npmTaskCommandPreview', "npm run {0}", task.script); + } + if (task.task && task.task.toString().length > 0) { + return task.task.toString(); + } + return getTaskDisplayLabel(task); +} + interface IRunScriptActionContext { readonly session: IActiveSessionItem; readonly tasks: readonly ITaskEntry[]; @@ -103,7 +116,7 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr constructor() { super({ id: RUN_SCRIPT_ACTION_PRIMARY_ID, - title: { value: localize('runPrimaryScript', 'Run Primary Script'), original: 'Run Primary Script' }, + title: { value: localize('runPrimaryTask', 'Run Primary Task'), original: 'Run Primary Task' }, icon: Codicon.play, category: SessionsCategories.Sessions, f1: true, @@ -182,7 +195,7 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr constructor() { super({ id: CONFIGURE_DEFAULT_RUN_ACTION_ID, - title: localize2('configureDefaultRunAction', "Add Run Action..."), + title: localize2('configureDefaultRunAction', "Add Action..."), category: SessionsCategories.Sessions, icon: Codicon.play, precondition: configureScriptPrecondition, @@ -226,12 +239,12 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr if (nonSessionTasks.length > 0) { items.push({ type: 'separator', label: localize('existingTasks', "Existing Tasks") }); - for (const task of nonSessionTasks) { + for (const { task, target } of nonSessionTasks) { items.push({ label: getTaskDisplayLabel(task), description: task.command, task, - source: 'workspace', + source: target, }); } } @@ -246,81 +259,86 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr const pickedItem = picked as ITaskPickItem; if (pickedItem.task) { - // Existing task — set inSessions: true - await this._sessionsConfigService.addTaskToSessions(pickedItem.task, session, pickedItem.source ?? 'workspace'); - return pickedItem.task; + return this._showCustomCommandInput(session, { task: pickedItem.task, target: pickedItem.source ?? 'workspace' }); } else { // Custom command path return this._showCustomCommandInput(session); } } - private async _showCustomCommandInput(session: IActiveSessionItem): Promise { - const command = await this._quickInputService.input({ - placeHolder: localize('enterCommandPlaceholder', "Enter command (e.g., npm run dev)"), - prompt: localize('enterCommandPrompt', "This command will be run as a task in the integrated terminal") - }); - - if (!command) { + private async _showCustomCommandInput(session: IActiveSessionItem, existingTask?: INonSessionTaskEntry): Promise { + const taskConfiguration = await this._showCustomCommandWidget(session, existingTask); + if (!taskConfiguration) { return undefined; } - const target = await this._pickStorageTarget(session); - if (!target) { - return undefined; + if (existingTask) { + await this._sessionsConfigService.addTaskToSessions(existingTask.task, session, existingTask.target, { runOn: taskConfiguration.runOn ?? 'default' }); + return { + ...existingTask.task, + inSessions: true, + ...(taskConfiguration.runOn ? { runOptions: { runOn: taskConfiguration.runOn } } : {}), + }; } - return this._sessionsConfigService.createAndAddTask(command, session, target); + return this._sessionsConfigService.createAndAddTask( + taskConfiguration.label, + taskConfiguration.command, + session, + taskConfiguration.target, + taskConfiguration.runOn ? { runOn: taskConfiguration.runOn } : undefined + ); } - private async _pickStorageTarget(session: IActiveSessionItem): Promise { - const hasWorktree = !!session.worktree; - const hasRepository = !!session.repository; + private _showCustomCommandWidget(session: IActiveSessionItem, existingTask?: INonSessionTaskEntry): Promise { + const workspaceTargetDisabledReason = !(session.worktree ?? session.repository) + ? localize('workspaceStorageUnavailableTooltip', "Workspace storage is unavailable for this session") + : undefined; - interface IStorageTargetItem extends IQuickPickItem { - target: TaskStorageTarget; - } + return new Promise(resolve => { + const disposables = new DisposableStore(); + let settled = false; - const items: IStorageTargetItem[] = [ - { - target: 'user', - label: localize('storeInUserSettings', "User Settings"), - description: localize('storeInUserSettingsDesc', "Available in all sessions"), - }, - hasWorktree ? { - target: 'workspace', - label: localize('storeInWorkspaceWorktreeSettings', "Workspace (Worktree)"), - description: localize('storeInWorkspaceWorktreeSettingsDesc', "Stored in session worktree"), - } : hasRepository ? { - target: 'workspace', - label: localize('storeInWorkspaceSettings', "Workspace"), - description: localize('storeInWorkspace', "Stored in the workspace"), - } : { - target: 'workspace', - label: localize('storeInWorkspaceSettingsDisable', "Workspace Unavailable"), - description: localize('storeInWorkspaceDisabled', "Stored in the workspace Unavailable"), - disabled: true, - italic: true, - } - ]; + const quickWidget = disposables.add(this._quickInputService.createQuickWidget()); + quickWidget.title = existingTask + ? localize('addExistingActionWidgetTitle', "Add Existing Action...") + : localize('addActionWidgetTitle', "Add Action..."); + quickWidget.description = existingTask + ? localize('addExistingActionWidgetDescription', "Enable an existing task for sessions and configure when it should run") + : localize('addActionWidgetDescription', "Create a shell task and configure how it should be saved and run"); + quickWidget.ignoreFocusOut = true; + const widget = disposables.add(new RunScriptCustomTaskWidget({ + label: existingTask?.task.label, + labelDisabledReason: existingTask ? localize('existingTaskLabelLocked', "This name comes from an existing task and cannot be changed here.") : undefined, + command: existingTask ? getTaskCommandPreview(existingTask.task) : undefined, + commandDisabledReason: existingTask ? localize('existingTaskCommandLocked', "This command comes from an existing task and cannot be changed here.") : undefined, + target: existingTask?.target, + targetDisabledReason: existingTask ? localize('existingTaskTargetLocked', "This existing task cannot be moved between workspace and user storage.") : workspaceTargetDisabledReason, + runOn: existingTask?.task.runOptions?.runOn === 'worktreeCreated' ? 'worktreeCreated' : undefined, + })); + quickWidget.widget = widget.domNode; - return new Promise(resolve => { - const picker = this._quickInputService.createQuickPick({ useSeparators: true }); - picker.placeholder = localize('pickStorageTarget', "Where should this action be saved?"); - picker.items = items; - - picker.onDidAccept(() => { - const selected = picker.activeItems[0]; - if (selected && (selected.target !== 'workspace' || hasWorktree)) { - resolve(selected.target); - picker.dispose(); + const complete = (result: IRunScriptCustomTaskWidgetResult | undefined) => { + if (settled) { + return; } - }); - picker.onDidHide(() => { - resolve(undefined); - picker.dispose(); - }); - picker.show(); + settled = true; + resolve(result); + quickWidget.hide(); + }; + + disposables.add(widget.onDidSubmit(result => complete(result))); + disposables.add(widget.onDidCancel(() => complete(undefined))); + disposables.add(quickWidget.onDidHide(() => { + if (!settled) { + settled = true; + resolve(undefined); + } + disposables.dispose(); + })); + + quickWidget.show(); + widget.focus(); }); } } diff --git a/src/vs/sessions/contrib/chat/browser/runScriptCustomTaskWidget.ts b/src/vs/sessions/contrib/chat/browser/runScriptCustomTaskWidget.ts new file mode 100644 index 00000000000..32259cd6446 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/runScriptCustomTaskWidget.ts @@ -0,0 +1,204 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/runScriptAction.css'; + +import * as dom from '../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { InputBox } from '../../../../base/browser/ui/inputbox/inputBox.js'; +import { Radio } from '../../../../base/browser/ui/radio/radio.js'; +import { Checkbox } from '../../../../base/browser/ui/toggle/toggle.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { localize } from '../../../../nls.js'; +import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { TaskStorageTarget } from './sessionsConfigurationService.js'; + +export const WORKTREE_CREATED_RUN_ON = 'worktreeCreated' as const; + +export interface IRunScriptCustomTaskWidgetState { + readonly label?: string; + readonly labelDisabledReason?: string; + readonly command?: string; + readonly commandDisabledReason?: string; + readonly target?: TaskStorageTarget; + readonly targetDisabledReason?: string; + readonly runOn?: typeof WORKTREE_CREATED_RUN_ON; +} + +export interface IRunScriptCustomTaskWidgetResult { + readonly label?: string; + readonly command: string; + readonly target: TaskStorageTarget; + readonly runOn?: typeof WORKTREE_CREATED_RUN_ON; +} + +export class RunScriptCustomTaskWidget extends Disposable { + + readonly domNode: HTMLElement; + + private readonly _labelInput: InputBox; + private readonly _commandInput: InputBox; + private readonly _runOnCheckbox: Checkbox; + private readonly _storageOptions: Radio; + private readonly _submitButton: Button; + private readonly _cancelButton: Button; + private readonly _labelLocked: boolean; + private readonly _commandLocked: boolean; + private readonly _targetLocked: boolean; + private _selectedTarget: TaskStorageTarget; + + private readonly _onDidSubmit = this._register(new Emitter()); + readonly onDidSubmit: Event = this._onDidSubmit.event; + + private readonly _onDidCancel = this._register(new Emitter()); + readonly onDidCancel: Event = this._onDidCancel.event; + + constructor(state: IRunScriptCustomTaskWidgetState) { + super(); + + this._labelLocked = !!state.labelDisabledReason; + this._commandLocked = !!state.commandDisabledReason; + this._targetLocked = !!state.targetDisabledReason && state.target !== undefined; + this._selectedTarget = state.target ?? (state.targetDisabledReason ? 'user' : 'workspace'); + + this.domNode = dom.$('.run-script-action-widget'); + + const labelSection = dom.append(this.domNode, dom.$('.run-script-action-section')); + dom.append(labelSection, dom.$('label.run-script-action-label', undefined, localize('labelFieldLabel', "Name"))); + const labelInputContainer = dom.append(labelSection, dom.$('.run-script-action-input')); + this._labelInput = this._register(new InputBox(labelInputContainer, undefined, { + placeholder: localize('enterLabelPlaceholder', "Enter a name for this action (optional)"), + tooltip: state.labelDisabledReason, + ariaLabel: localize('enterLabelAriaLabel', "Task name"), + inputBoxStyles: defaultInputBoxStyles, + })); + this._labelInput.value = state.label ?? ''; + if (state.labelDisabledReason) { + this._labelInput.disable(); + } + + const commandSection = dom.append(this.domNode, dom.$('.run-script-action-section')); + dom.append(commandSection, dom.$('label.run-script-action-label', undefined, localize('commandFieldLabel', "Command"))); + const commandInputContainer = dom.append(commandSection, dom.$('.run-script-action-input')); + this._commandInput = this._register(new InputBox(commandInputContainer, undefined, { + placeholder: localize('enterCommandPlaceholder', "Enter command (for example, npm run dev)"), + tooltip: state.commandDisabledReason, + ariaLabel: localize('enterCommandAriaLabel', "Task command"), + inputBoxStyles: defaultInputBoxStyles, + })); + this._commandInput.value = state.command ?? ''; + if (state.commandDisabledReason) { + this._commandInput.disable(); + } + + const runOnSection = dom.append(this.domNode, dom.$('.run-script-action-section')); + dom.append(runOnSection, dom.$('div.run-script-action-label', undefined, localize('runOptionsLabel', "Run Options"))); + const runOnRow = dom.append(runOnSection, dom.$('.run-script-action-option-row')); + this._runOnCheckbox = this._register(new Checkbox(localize('runOnWorktreeCreated', "Run When Worktree Is Created"), state.runOn === WORKTREE_CREATED_RUN_ON, defaultCheckboxStyles)); + runOnRow.appendChild(this._runOnCheckbox.domNode); + const runOnText = dom.append(runOnRow, dom.$('span.run-script-action-option-text', undefined, localize('runOnWorktreeCreatedDescription', "Automatically run this action when the session worktree is created"))); + this._register(dom.addDisposableListener(runOnText, dom.EventType.CLICK, () => this._runOnCheckbox.checked = !this._runOnCheckbox.checked)); + + const storageSection = dom.append(this.domNode, dom.$('.run-script-action-section')); + dom.append(storageSection, dom.$('div.run-script-action-label', undefined, localize('storageLabel', "Save In"))); + const storageDisabledReason = state.targetDisabledReason; + const workspaceTargetDisabled = !!storageDisabledReason; + this._storageOptions = this._register(new Radio({ + items: [ + { + text: localize('workspaceStorageLabel', "Workspace"), + tooltip: storageDisabledReason ?? localize('workspaceStorageTooltip', "Save this action in the current workspace"), + isActive: this._selectedTarget === 'workspace', + disabled: workspaceTargetDisabled, + }, + { + text: localize('userStorageLabel', "User"), + tooltip: this._targetLocked ? storageDisabledReason : localize('userStorageTooltip', "Save this action in your user tasks and make it available in all sessions"), + isActive: this._selectedTarget === 'user', + disabled: this._targetLocked, + } + ] + })); + this._storageOptions.domNode.setAttribute('aria-label', localize('storageAriaLabel', "Task storage target")); + storageSection.appendChild(this._storageOptions.domNode); + if (storageDisabledReason && !this._targetLocked) { + dom.append(storageSection, dom.$('div.run-script-action-hint', undefined, storageDisabledReason)); + } + + const buttonRow = dom.append(this.domNode, dom.$('.run-script-action-buttons')); + this._cancelButton = this._register(new Button(buttonRow, { ...defaultButtonStyles, secondary: true })); + this._cancelButton.label = localize('cancelAddAction', "Cancel"); + this._submitButton = this._register(new Button(buttonRow, defaultButtonStyles)); + this._submitButton.label = localize('confirmAddAction', "Add Action"); + + this._register(this._labelInput.onDidChange(() => this._updateButtonEnablement())); + this._register(this._commandInput.onDidChange(() => this._updateButtonEnablement())); + this._register(this._storageOptions.onDidSelect(index => { + this._selectedTarget = index === 0 ? 'workspace' : 'user'; + })); + this._register(this._submitButton.onDidClick(() => this._submit())); + this._register(this._cancelButton.onDidClick(() => this._onDidCancel.fire())); + this._register(dom.addDisposableListener(this._labelInput.inputElement, dom.EventType.KEY_DOWN, event => { + const keyboardEvent = new StandardKeyboardEvent(event); + if (keyboardEvent.equals(KeyCode.Enter)) { + keyboardEvent.preventDefault(); + keyboardEvent.stopPropagation(); + this._submit(); + } + })); + this._register(dom.addDisposableListener(this._commandInput.inputElement, dom.EventType.KEY_DOWN, event => { + const keyboardEvent = new StandardKeyboardEvent(event); + if (keyboardEvent.equals(KeyCode.Enter)) { + keyboardEvent.preventDefault(); + keyboardEvent.stopPropagation(); + this._submit(); + } + })); + this._register(dom.addDisposableListener(this.domNode, dom.EventType.KEY_DOWN, event => { + const keyboardEvent = new StandardKeyboardEvent(event); + if (keyboardEvent.equals(KeyCode.Escape)) { + keyboardEvent.preventDefault(); + keyboardEvent.stopPropagation(); + this._onDidCancel.fire(); + } + })); + + this._updateButtonEnablement(); + } + + focus(): void { + if (!this._labelLocked) { + this._labelInput.focus(); + return; + } + if (this._commandLocked) { + this._runOnCheckbox.focus(); + return; + } + this._commandInput.focus(); + } + + private _submit(): void { + const label = this._labelInput.value.trim(); + const command = this._commandInput.value.trim(); + if (!command) { + return; + } + + this._onDidSubmit.fire({ + label: label.length > 0 ? label : undefined, + command, + target: this._selectedTarget, + runOn: this._runOnCheckbox.checked ? WORKTREE_CREATED_RUN_ON : undefined, + }); + } + + private _updateButtonEnablement(): void { + this._submitButton.enabled = this._commandInput.value.trim().length > 0; + } +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts b/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts index a36bed73da5..5ba62f2c0a0 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; +import { autorun, IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; import { joinPath, dirname, isEqual } from '../../../../base/common/resources.js'; import { parse } from '../../../../base/common/jsonc.js'; import { isMacintosh, isWindows } from '../../../../base/common/platform.js'; @@ -20,6 +20,11 @@ import { ITerminalInstance, ITerminalService } from '../../../../workbench/contr import { CommandString } from '../../../../workbench/contrib/tasks/common/taskConfiguration.js'; export type TaskStorageTarget = 'user' | 'workspace'; +type TaskRunOnOption = 'default' | 'folderOpen' | 'worktreeCreated'; + +interface ITaskRunOptions { + readonly runOn?: TaskRunOnOption; +} /** * Shape of a single task entry inside tasks.json. @@ -32,12 +37,18 @@ export interface ITaskEntry { readonly command?: string; readonly args?: CommandString[]; readonly inSessions?: boolean; + readonly runOptions?: ITaskRunOptions; readonly windows?: { command?: string; args?: CommandString[] }; readonly osx?: { command?: string; args?: CommandString[] }; readonly linux?: { command?: string; args?: CommandString[] }; readonly [key: string]: unknown; } +export interface INonSessionTaskEntry { + readonly task: ITaskEntry; + readonly target: TaskStorageTarget; +} + interface ITasksJson { version?: string; tasks?: ITaskEntry[]; @@ -56,19 +67,19 @@ export interface ISessionsConfigurationService { * Returns tasks that do NOT have `inSessions: true` — used as * suggestions in the "Add Run Action" picker. */ - getNonSessionTasks(session: IActiveSessionItem): Promise; + getNonSessionTasks(session: IActiveSessionItem): Promise; /** * Sets `inSessions: true` on an existing task (identified by label), * updating it in place in its tasks.json. */ - addTaskToSessions(task: ITaskEntry, session: IActiveSessionItem, target: TaskStorageTarget): Promise; + addTaskToSessions(task: ITaskEntry, session: IActiveSessionItem, target: TaskStorageTarget, options?: ITaskRunOptions): Promise; /** * Creates a new shell task with `inSessions: true` and writes it to * the appropriate tasks.json (user or workspace). */ - createAndAddTask(command: string, session: IActiveSessionItem, target: TaskStorageTarget): Promise; + createAndAddTask(label: string | undefined, command: string, session: IActiveSessionItem, target: TaskStorageTarget, options?: ITaskRunOptions): Promise; /** * Runs a task entry in a terminal, resolving the correct platform @@ -95,6 +106,7 @@ export class SessionsConfigurationService extends Disposable implements ISession private readonly _fileWatcher = this._register(new MutableDisposable()); /** Maps `cwd.toString() + command` to the terminal `instanceId`. */ private readonly _taskTerminals = new Map(); + private readonly _knownSessionWorktrees = new Map(); private readonly _lastRunTaskLabels: Map; private readonly _lastRunTaskObservables = new Map>>(); @@ -111,6 +123,11 @@ export class SessionsConfigurationService extends Disposable implements ISession ) { super(); this._lastRunTaskLabels = this._loadLastRunTaskLabels(); + + this._register(autorun(reader => { + const activeSession = this._sessionsManagementService.activeSession.read(reader); + this._handleActiveSessionChange(activeSession); + })); } getSessionTasks(session: IActiveSessionItem): IObservable { @@ -126,12 +143,33 @@ export class SessionsConfigurationService extends Disposable implements ISession return this._sessionTasks; } - async getNonSessionTasks(session: IActiveSessionItem): Promise { - const allTasks = await this._readAllTasks(session); - return allTasks.filter(t => !t.inSessions); + async getNonSessionTasks(session: IActiveSessionItem): Promise { + const result: INonSessionTaskEntry[] = []; + + const workspaceUri = this._getTasksJsonUri(session, 'workspace'); + if (workspaceUri) { + const workspaceJson = await this._readTasksJson(workspaceUri); + for (const task of workspaceJson.tasks ?? []) { + if (!task.inSessions && this._isSupportedTask(task)) { + result.push({ task, target: 'workspace' }); + } + } + } + + const userUri = this._getTasksJsonUri(session, 'user'); + if (userUri) { + const userJson = await this._readTasksJson(userUri); + for (const task of userJson.tasks ?? []) { + if (!task.inSessions && this._isSupportedTask(task)) { + result.push({ task, target: 'user' }); + } + } + } + + return result; } - async addTaskToSessions(task: ITaskEntry, session: IActiveSessionItem, target: TaskStorageTarget): Promise { + async addTaskToSessions(task: ITaskEntry, session: IActiveSessionItem, target: TaskStorageTarget, options?: ITaskRunOptions): Promise { const tasksJsonUri = this._getTasksJsonUri(session, target); if (!tasksJsonUri) { return; @@ -144,16 +182,25 @@ export class SessionsConfigurationService extends Disposable implements ISession return; } - await this._jsonEditingService.write(tasksJsonUri, [ - { path: ['tasks', index, 'inSessions'], value: true } - ], true); + const edits: { path: (string | number)[]; value: unknown }[] = [ + { path: ['tasks', index, 'inSessions'], value: true }, + ]; + + if (options) { + edits.push({ + path: ['tasks', index, 'runOptions'], + value: options.runOn && options.runOn !== 'default' ? { runOn: options.runOn } : undefined, + }); + } + + await this._jsonEditingService.write(tasksJsonUri, edits, true); if (target === 'workspace') { await this._commitTasksFile(session); } } - async createAndAddTask(command: string, session: IActiveSessionItem, target: TaskStorageTarget): Promise { + async createAndAddTask(label: string | undefined, command: string, session: IActiveSessionItem, target: TaskStorageTarget, options?: ITaskRunOptions): Promise { const tasksJsonUri = this._getTasksJsonUri(session, target); if (!tasksJsonUri) { return undefined; @@ -161,11 +208,13 @@ export class SessionsConfigurationService extends Disposable implements ISession const tasksJson = await this._readTasksJson(tasksJsonUri); const tasks = tasksJson.tasks ?? []; + const resolvedLabel = label?.trim() || command; const newTask: ITaskEntry = { - label: command, + label: resolvedLabel, type: 'shell', command, inSessions: true, + ...(options?.runOn && options.runOn !== 'default' ? { runOptions: { runOn: options.runOn } } : {}), }; await this._jsonEditingService.write(tasksJsonUri, [ @@ -332,6 +381,37 @@ export class SessionsConfigurationService extends Disposable implements ISession return `${command} ${resolvedArgs}`; } + private _handleActiveSessionChange(session: IActiveSessionItem | undefined): void { + if (!session) { + return; + } + + const sessionKey = session.resource.toString(); + const currentWorktree = session.worktree?.toString(); + if (!this._knownSessionWorktrees.has(sessionKey)) { + this._knownSessionWorktrees.set(sessionKey, currentWorktree); + return; + } + + const previousWorktree = this._knownSessionWorktrees.get(sessionKey); + this._knownSessionWorktrees.set(sessionKey, currentWorktree); + if (!currentWorktree || previousWorktree === currentWorktree) { + return; + } + + void this._runWorktreeCreatedTasks(session); + } + + private async _runWorktreeCreatedTasks(session: IActiveSessionItem): Promise { + const tasks = await this._readAllTasks(session); + for (const task of tasks) { + if (!task.inSessions || task.runOptions?.runOn !== 'worktreeCreated') { + continue; + } + await this.runTask(task, session); + } + } + private _ensureFileWatch(folder: URI): void { const tasksUri = joinPath(folder, '.vscode', 'tasks.json'); if (this._watchedResource && this._watchedResource.toString() === tasksUri.toString()) { diff --git a/src/vs/sessions/contrib/chat/test/browser/runScriptCustomTaskWidget.fixture.ts b/src/vs/sessions/contrib/chat/test/browser/runScriptCustomTaskWidget.fixture.ts new file mode 100644 index 00000000000..e20913a9a98 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/runScriptCustomTaskWidget.fixture.ts @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { IRunScriptCustomTaskWidgetState, RunScriptCustomTaskWidget, WORKTREE_CREATED_RUN_ON } from '../../browser/runScriptCustomTaskWidget.js'; + +// Ensure color registrations are loaded +import '../../../../common/theme.js'; +import '../../../../../platform/theme/common/colors/inputColors.js'; + +const filledLabel = 'Start Dev Server'; +const filledCommand = 'npm run dev'; +const workspaceUnavailableReason = 'Workspace storage is unavailable for this session'; + +function renderWidget(ctx: ComponentFixtureContext, state: IRunScriptCustomTaskWidgetState): void { + ctx.container.style.width = '600px'; + ctx.container.style.padding = '0'; + ctx.container.style.borderRadius = 'var(--vscode-cornerRadius-xLarge)'; + ctx.container.style.backgroundColor = 'var(--vscode-quickInput-background)'; + ctx.container.style.overflow = 'hidden'; + + const widget = ctx.disposableStore.add(new RunScriptCustomTaskWidget(state)); + ctx.container.appendChild(widget.domNode); +} + +function defineFixture(state: IRunScriptCustomTaskWidgetState) { + return defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderWidget(ctx, state), + }); +} + +export default defineThemedFixtureGroup({ path: 'sessions/' }, { + WorkspaceSelectedEmpty: defineFixture({ + target: 'workspace', + }), + + WorkspaceSelectedCheckedEmpty: defineFixture({ + target: 'workspace', + runOn: WORKTREE_CREATED_RUN_ON, + }), + + WorkspaceSelectedFilled: defineFixture({ + label: filledLabel, + target: 'workspace', + command: filledCommand, + }), + + WorkspaceSelectedCheckedFilled: defineFixture({ + label: filledLabel, + target: 'workspace', + command: filledCommand, + runOn: WORKTREE_CREATED_RUN_ON, + }), + + UserSelectedEmpty: defineFixture({ + target: 'user', + }), + + UserSelectedCheckedEmpty: defineFixture({ + target: 'user', + runOn: WORKTREE_CREATED_RUN_ON, + }), + + UserSelectedFilled: defineFixture({ + label: filledLabel, + target: 'user', + command: filledCommand, + }), + + UserSelectedCheckedFilled: defineFixture({ + label: filledLabel, + target: 'user', + command: filledCommand, + runOn: WORKTREE_CREATED_RUN_ON, + }), + + WorkspaceUnavailableEmpty: defineFixture({ + target: 'user', + targetDisabledReason: workspaceUnavailableReason, + }), + + WorkspaceUnavailableCheckedEmpty: defineFixture({ + target: 'user', + targetDisabledReason: workspaceUnavailableReason, + runOn: WORKTREE_CREATED_RUN_ON, + }), + + WorkspaceUnavailableFilled: defineFixture({ + label: filledLabel, + target: 'user', + command: filledCommand, + targetDisabledReason: workspaceUnavailableReason, + }), + + WorkspaceUnavailableCheckedFilled: defineFixture({ + label: filledLabel, + target: 'user', + command: filledCommand, + targetDisabledReason: workspaceUnavailableReason, + runOn: WORKTREE_CREATED_RUN_ON, + }), + + ExistingWorkspaceTaskLocked: defineFixture({ + label: filledLabel, + labelDisabledReason: 'This name comes from an existing task and cannot be changed here.', + command: filledCommand, + commandDisabledReason: 'This command comes from an existing task and cannot be changed here.', + target: 'workspace', + targetDisabledReason: 'This existing task cannot be moved between workspace and user storage.', + }), + + ExistingUserTaskLockedChecked: defineFixture({ + label: filledLabel, + labelDisabledReason: 'This name comes from an existing task and cannot be changed here.', + command: filledCommand, + commandDisabledReason: 'This command comes from an existing task and cannot be changed here.', + target: 'user', + targetDisabledReason: 'This existing task cannot be moved between workspace and user storage.', + runOn: WORKTREE_CREATED_RUN_ON, + }), +}); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts index d127cff33e9..073e6c42d02 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts @@ -15,14 +15,19 @@ import { IJSONEditingService, IJSONValue } from '../../../../../workbench/servic import { IPreferencesService } from '../../../../../workbench/services/preferences/common/preferences.js'; import { ITerminalInstance, ITerminalService } from '../../../../../workbench/contrib/terminal/browser/terminal.js'; import { IActiveSessionItem, ISessionsManagementService } from '../../../sessions/browser/sessionsManagementService.js'; -import { ISessionsConfigurationService, SessionsConfigurationService, ITaskEntry } from '../../browser/sessionsConfigurationService.js'; +import { INonSessionTaskEntry, ISessionsConfigurationService, SessionsConfigurationService, ITaskEntry } from '../../browser/sessionsConfigurationService.js'; import { VSBuffer } from '../../../../../base/common/buffer.js'; import { observableValue } from '../../../../../base/common/observable.js'; function makeSession(opts: { repository?: URI; worktree?: URI } = {}): IActiveSessionItem { return { + resource: URI.parse('file:///session'), + isUntitled: false, + label: 'session', repository: opts.repository, worktree: opts.worktree, + worktreeBranchName: undefined, + providerType: 'background', } as IActiveSessionItem; } @@ -53,6 +58,7 @@ suite('SessionsConfigurationService', () => { let committedFiles: { session: IActiveSessionItem; fileUris: URI[] }[]; let storageService: InMemoryStorageService; let readFileCalls: URI[]; + let activeSessionObs: ReturnType>; const userSettingsUri = URI.parse('file:///user/settings.json'); const repoUri = URI.parse('file:///repo'); @@ -67,6 +73,7 @@ suite('SessionsConfigurationService', () => { readFileCalls = []; const instantiationService = store.add(new TestInstantiationService()); + activeSessionObs = observableValue('activeSession', undefined); instantiationService.stub(IFileService, new class extends mock() { override async readFile(resource: URI) { @@ -115,7 +122,7 @@ suite('SessionsConfigurationService', () => { instantiationService.stub(ITerminalService, terminalServiceMock); instantiationService.stub(ISessionsManagementService, new class extends mock() { - override activeSession = observableValue('activeSession', undefined); + override activeSession = activeSessionObs; override async commitWorktreeFiles(session: IActiveSessionItem, fileUris: URI[]) { committedFiles.push({ session, fileUris }); } }); @@ -219,7 +226,7 @@ suite('SessionsConfigurationService', () => { const session = makeSession({ worktree: worktreeUri, repository: repoUri }); const nonSessionTasks = await service.getNonSessionTasks(session); - assert.deepStrictEqual(nonSessionTasks.map(t => t.label), ['lint', 'test', 'watch']); + assert.deepStrictEqual(nonSessionTasks.map(t => t.task.label), ['lint', 'test', 'watch']); }); test('getNonSessionTasks reads from repository when no worktree', async () => { @@ -234,7 +241,26 @@ suite('SessionsConfigurationService', () => { const session = makeSession({ repository: repoUri }); const nonSessionTasks = await service.getNonSessionTasks(session); - assert.deepStrictEqual(nonSessionTasks.map(t => t.label), ['lint']); + assert.deepStrictEqual(nonSessionTasks.map(t => t.task.label), ['lint']); + }); + + test('getNonSessionTasks preserves the source target for workspace and user tasks', async () => { + const worktreeTasksUri = URI.parse('file:///worktree/.vscode/tasks.json'); + const userTasksUri = URI.from({ scheme: userSettingsUri.scheme, path: '/user/tasks.json' }); + fileContents.set(worktreeTasksUri.toString(), tasksJsonContent([ + makeTask('workspaceTask', 'npm run workspace'), + ])); + fileContents.set(userTasksUri.toString(), tasksJsonContent([ + makeTask('userTask', 'npm run user'), + ])); + + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + const nonSessionTasks = await service.getNonSessionTasks(session); + + assert.deepStrictEqual(nonSessionTasks, [ + { task: { label: 'workspaceTask', type: 'shell', command: 'npm run workspace' }, target: 'workspace' }, + { task: { label: 'userTask', type: 'shell', command: 'npm run user' }, target: 'user' }, + ] satisfies INonSessionTaskEntry[]); }); // --- addTaskToSessions --- @@ -284,6 +310,36 @@ suite('SessionsConfigurationService', () => { assert.strictEqual(committedFiles.length, 0, 'should not commit when there is no worktree'); }); + test('addTaskToSessions updates runOptions when provided', async () => { + const worktreeTasksUri = URI.parse('file:///worktree/.vscode/tasks.json'); + fileContents.set(worktreeTasksUri.toString(), tasksJsonContent([ + makeTask('build', 'npm run build'), + ])); + + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + await service.addTaskToSessions(makeTask('build', 'npm run build'), session, 'workspace', { runOn: 'worktreeCreated' }); + + assert.deepStrictEqual(jsonEdits[0].values, [ + { path: ['tasks', 0, 'inSessions'], value: true }, + { path: ['tasks', 0, 'runOptions'], value: { runOn: 'worktreeCreated' } }, + ]); + }); + + test('addTaskToSessions clears runOptions when default is requested', async () => { + const worktreeTasksUri = URI.parse('file:///worktree/.vscode/tasks.json'); + fileContents.set(worktreeTasksUri.toString(), tasksJsonContent([ + { ...makeTask('build', 'npm run build'), runOptions: { runOn: 'worktreeCreated' } }, + ])); + + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + await service.addTaskToSessions(makeTask('build', 'npm run build'), session, 'workspace', { runOn: 'default' }); + + assert.deepStrictEqual(jsonEdits[0].values, [ + { path: ['tasks', 0, 'inSessions'], value: true }, + { path: ['tasks', 0, 'runOptions'], value: undefined }, + ]); + }); + // --- createAndAddTask --- test('createAndAddTask writes new task with inSessions: true', async () => { @@ -293,7 +349,7 @@ suite('SessionsConfigurationService', () => { ])); const session = makeSession({ worktree: worktreeUri, repository: repoUri }); - await service.createAndAddTask('npm run dev', session, 'workspace'); + await service.createAndAddTask(undefined, 'npm run dev', session, 'workspace'); assert.strictEqual(jsonEdits.length, 1); const edit = jsonEdits[0]; @@ -315,7 +371,7 @@ suite('SessionsConfigurationService', () => { ])); const session = makeSession({ repository: repoUri }); - await service.createAndAddTask('npm run dev', session, 'workspace'); + await service.createAndAddTask(undefined, 'npm run dev', session, 'workspace'); assert.strictEqual(jsonEdits.length, 1); assert.strictEqual(jsonEdits[0].uri.toString(), repoTasksUri.toString()); @@ -328,6 +384,35 @@ suite('SessionsConfigurationService', () => { assert.strictEqual(committedFiles.length, 0, 'should not commit when there is no worktree'); }); + test('createAndAddTask writes worktreeCreated run option when requested', async () => { + const worktreeTasksUri = URI.parse('file:///worktree/.vscode/tasks.json'); + fileContents.set(worktreeTasksUri.toString(), tasksJsonContent([])); + + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + await service.createAndAddTask(undefined, 'npm run dev', session, 'workspace', { runOn: 'worktreeCreated' }); + + assert.strictEqual(jsonEdits.length, 1); + const tasksValue = jsonEdits[0].values.find(v => v.path[0] === 'tasks'); + assert.ok(tasksValue); + const tasks = tasksValue!.value as ITaskEntry[]; + assert.deepStrictEqual(tasks[0].runOptions, { runOn: 'worktreeCreated' }); + }); + + test('createAndAddTask writes a custom label when provided', async () => { + const worktreeTasksUri = URI.parse('file:///worktree/.vscode/tasks.json'); + fileContents.set(worktreeTasksUri.toString(), tasksJsonContent([])); + + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + await service.createAndAddTask('Start Dev Server', 'npm run dev', session, 'workspace'); + + assert.strictEqual(jsonEdits.length, 1); + const tasksValue = jsonEdits[0].values.find(v => v.path[0] === 'tasks'); + assert.ok(tasksValue); + const tasks = tasksValue!.value as ITaskEntry[]; + assert.strictEqual(tasks[0].label, 'Start Dev Server'); + assert.strictEqual(tasks[0].command, 'npm run dev'); + }); + // --- runTask --- test('runTask creates terminal and sends command', async () => { @@ -456,6 +541,26 @@ suite('SessionsConfigurationService', () => { assert.strictEqual(createdTerminals.length, 2, 'should create two terminals for different worktrees'); }); + test('runs worktreeCreated session tasks when a session gains a worktree', async () => { + const sessionResource = URI.parse('file:///session-worktree-created'); + const worktreeTasksUri = URI.parse('file:///worktree/.vscode/tasks.json'); + const userTasksUri = URI.from({ scheme: userSettingsUri.scheme, path: '/user/tasks.json' }); + fileContents.set(worktreeTasksUri.toString(), tasksJsonContent([ + { label: 'build', type: 'shell', command: 'npm run build', inSessions: true, runOptions: { runOn: 'worktreeCreated' } }, + makeTask('manual', 'npm test', true), + ])); + fileContents.set(userTasksUri.toString(), tasksJsonContent([])); + + activeSessionObs.set({ ...makeSession({ repository: repoUri }), resource: sessionResource }, undefined); + await new Promise(r => setTimeout(r, 10)); + + activeSessionObs.set({ ...makeSession({ repository: repoUri, worktree: worktreeUri }), resource: sessionResource }, undefined); + await new Promise(r => setTimeout(r, 10)); + + assert.strictEqual(sentCommands.length, 1); + assert.strictEqual(sentCommands[0].command, 'npm run build'); + }); + // --- getLastRunTaskLabel (MRU) --- test('getLastRunTaskLabel returns undefined when no task has been run', () => { diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 5e575c728d0..ee4a9fe5a2d 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1945,6 +1945,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I TaskGroup: extHostTypes.TaskGroup, TaskPanelKind: extHostTypes.TaskPanelKind, TaskRevealKind: extHostTypes.TaskRevealKind, + TaskRunOn: extHostTypes.TaskRunOn, TaskScope: extHostTypes.TaskScope, TerminalLink: extHostTypes.TerminalLink, TerminalQuickFixTerminalCommand: extHostTypes.TerminalQuickFixCommand, diff --git a/src/vs/workbench/api/common/extHostTask.ts b/src/vs/workbench/api/common/extHostTask.ts index 5bf476c980a..7191d7b5ac3 100644 --- a/src/vs/workbench/api/common/extHostTask.ts +++ b/src/vs/workbench/api/common/extHostTask.ts @@ -332,6 +332,9 @@ export namespace TaskDTO { if (value.presentationOptions) { result.presentationOptions = TaskPresentationOptionsDTO.to(value.presentationOptions)!; } + if (value.runOptions) { + result.runOptions = value.runOptions; + } if (value._id) { result._id = value._id; } diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index bd97ee3ffbb..e5b07617802 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -1275,6 +1275,12 @@ export enum TaskScope { Workspace = 2 } +export enum TaskRunOn { + Default = 1, + FolderOpen = 2, + WorktreeCreated = 3, +} + export class CustomExecution implements vscode.CustomExecution { private _callback: (resolvedDefinition: vscode.TaskDefinition) => Thenable; constructor(callback: (resolvedDefinition: vscode.TaskDefinition) => Thenable) { diff --git a/src/vs/workbench/api/common/shared/tasks.ts b/src/vs/workbench/api/common/shared/tasks.ts index a7e46e70ff8..e6ec0ec0987 100644 --- a/src/vs/workbench/api/common/shared/tasks.ts +++ b/src/vs/workbench/api/common/shared/tasks.ts @@ -26,6 +26,7 @@ export interface ITaskPresentationOptionsDTO { export interface IRunOptionsDTO { reevaluateOnRerun?: boolean; + runOn?: number; } export interface IExecutionOptionsDTO { diff --git a/src/vs/workbench/contrib/tasks/common/jsonSchema_v2.ts b/src/vs/workbench/contrib/tasks/common/jsonSchema_v2.ts index 8d937a1e5b5..56eb132182a 100644 --- a/src/vs/workbench/contrib/tasks/common/jsonSchema_v2.ts +++ b/src/vs/workbench/contrib/tasks/common/jsonSchema_v2.ts @@ -384,8 +384,8 @@ const runOptions: IJSONSchema = { }, runOn: { type: 'string', - enum: ['default', 'folderOpen'], - description: nls.localize('JsonSchema.tasks.runOn', 'Configures when the task should be run. If set to folderOpen, then the task will be run automatically when the folder is opened.'), + enum: ['default', 'folderOpen', 'worktreeCreated'], + description: nls.localize('JsonSchema.tasks.runOn', 'Configures when the task should be run. If set to folderOpen, then the task will be run automatically when the folder is opened. If set to worktreeCreated, then the task will be run automatically when an Agent Session worktree is created.'), default: 'default' }, instanceLimit: { diff --git a/src/vs/workbench/contrib/tasks/common/taskConfiguration.ts b/src/vs/workbench/contrib/tasks/common/taskConfiguration.ts index e90a90ea3b9..a6f14097d8f 100644 --- a/src/vs/workbench/contrib/tasks/common/taskConfiguration.ts +++ b/src/vs/workbench/contrib/tasks/common/taskConfiguration.ts @@ -716,6 +716,8 @@ export namespace RunOnOptions { switch (value.toLowerCase()) { case 'folderopen': return Tasks.RunOnOptions.folderOpen; + case 'worktreecreated': + return Tasks.RunOnOptions.worktreeCreated; case 'default': default: return Tasks.RunOnOptions.default; diff --git a/src/vs/workbench/contrib/tasks/common/tasks.ts b/src/vs/workbench/contrib/tasks/common/tasks.ts index c7b6427e551..5e6ab552216 100644 --- a/src/vs/workbench/contrib/tasks/common/tasks.ts +++ b/src/vs/workbench/contrib/tasks/common/tasks.ts @@ -586,7 +586,8 @@ export interface IConfigurationProperties { export enum RunOnOptions { default = 1, - folderOpen = 2 + folderOpen = 2, + worktreeCreated = 3 } export const enum InstancePolicy { diff --git a/src/vscode-dts/vscode.proposed.taskRunOptions.d.ts b/src/vscode-dts/vscode.proposed.taskRunOptions.d.ts new file mode 100644 index 00000000000..4f874a3354f --- /dev/null +++ b/src/vscode-dts/vscode.proposed.taskRunOptions.d.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + /** + * Controls when a task should be run. + */ + export enum TaskRunOn { + /** + * The task is not run automatically. + */ + Default = 1, + + /** + * The task runs when a folder is opened. + */ + FolderOpen = 2, + + /** + * The task runs when an Agent Session worktree is created. + */ + WorktreeCreated = 3, + } + + export interface RunOptions { + /** + * Controls when a task is run automatically. + */ + runOn?: TaskRunOn; + } +} \ No newline at end of file