Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions src/vs/workbench/contrib/chat/browser/agentPluginActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,20 @@ export class InstallPluginAction extends Action {
}

export class UninstallPluginAction extends Action {
constructor(plugin: IAgentPlugin & { remove(): void }) {
super('agentPlugin.uninstall', localize('uninstall', "Uninstall"), 'extension-action label uninstall', true,
() => { plugin.remove(); return Promise.resolve(); });
constructor(private readonly plugin: IAgentPlugin & { remove(): Promise<boolean> }) {
super('agentPlugin.uninstall', localize('uninstall', "Uninstall"), 'extension-action label uninstall', true);
}

override async run(): Promise<void> {
await this.runAndGetResult();
}

runAndGetResult(): Promise<boolean> {
return this.plugin.remove();
}
}

function isRemovableAgentPlugin(plugin: IAgentPlugin): plugin is IAgentPlugin & { remove(): void } {
function isRemovableAgentPlugin(plugin: IAgentPlugin): plugin is IAgentPlugin & { remove(): Promise<boolean> } {
return plugin.remove !== undefined;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ registerAction2(class extends Action2 {
type: 'question',
});
if (result.confirmed) {
plugin.remove?.();
await plugin.remove?.();
}
}
return;
Expand Down Expand Up @@ -548,7 +548,7 @@ registerAction2(class extends Action2 {
type: 'question',
});
if (result.confirmed) {
plugin.remove?.();
await plugin.remove?.();
}
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,16 @@ type CustomizationEditorSectionChangedClassification = {
comment: 'Tracks section navigation within the Agent Customizations editor.';
};

export function isCurrentPluginContributionNavigation(
requestGeneration: number,
currentGeneration: number,
requestedSection: AICustomizationManagementSection,
selectedSection: AICustomizationManagementSection | undefined,
isListView: boolean,
): boolean {
return requestGeneration === currentGeneration && requestedSection === selectedSection && isListView;
}

type CustomizationEditorItemSelectedEvent = {
section: string;
promptType: string;
Expand Down Expand Up @@ -366,6 +376,7 @@ export class AICustomizationManagementEditor extends EditorPane {
private readonly sections: ISectionItem[] = [];
private readonly allSections: ISectionItem[] = [];
private selectedSection: AICustomizationManagementSection | undefined;
private contentNavigationGeneration = 0;

// Welcome page
private welcomePage: AICustomizationWelcomePage | undefined;
Expand Down Expand Up @@ -2016,6 +2027,7 @@ export class AICustomizationManagementEditor extends EditorPane {
}

private updateContentVisibility(): void {
this.contentNavigationGeneration++;
const isEditorMode = this.viewMode === 'editor';
const isMigrationMode = this.viewMode === 'migration';
const isMcpDetailMode = this.viewMode === 'mcpDetail';
Expand Down Expand Up @@ -3364,10 +3376,14 @@ export class AICustomizationManagementEditor extends EditorPane {
this.sectionContextKey.set(section);
this.storageService.store(AI_CUSTOMIZATION_MANAGEMENT_SELECTED_SECTION_KEY, section, StorageScope.PROFILE, StorageTarget.USER);
this.updateContentVisibility();
const navigationGeneration = this.contentNavigationGeneration;
await this.listWidget.setSection(section);
const modelSection = ITEMS_MODEL_SECTIONS.find(s => s === section);
if (modelSection) {
await this.itemsModel.whenSectionLoaded(modelSection);
if (!isCurrentPluginContributionNavigation(navigationGeneration, this.contentNavigationGeneration, section, this.selectedSection, this.viewMode === 'list')) {
return;
}
const item = this.itemsModel.getItems(modelSection).get().find(item => isEqual(item.uri, uri));
if (item) {
const source = item.source;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import { Button, ButtonWithDropdown } from '../../../../../base/browser/ui/butto
import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js';
import { status } from '../../../../../base/browser/ui/aria/aria.js';
import { disposableTimeout } from '../../../../../base/common/async.js';
import { CancellationToken } from '../../../../../base/common/cancellation.js';
import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js';
import { Codicon } from '../../../../../base/common/codicons.js';
import { getErrorMessage, isCancellationError } from '../../../../../base/common/errors.js';
import { Emitter } from '../../../../../base/common/event.js';
import { MarkdownString } from '../../../../../base/common/htmlContent.js';
import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js';
Expand All @@ -24,11 +25,11 @@ import { ILabelService } from '../../../../../platform/label/common/label.js';
import { defaultButtonStyles, getButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js';
import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js';
import { IOpenerService } from '../../../../../platform/opener/common/opener.js';
import { IAgentPluginService } from '../../common/plugins/agentPluginService.js';
import { IAgentPlugin, IAgentPluginService } from '../../common/plugins/agentPluginService.js';
import { createPolicyBlockedEnableAction, createUninstallPluginAction, isPluginPolicyBlocked } from '../agentPluginActions.js';
import { INotificationService } from '../../../../../platform/notification/common/notification.js';
import { URI } from '../../../../../base/common/uri.js';
import { basename, dirname, joinPath } from '../../../../../base/common/resources.js';
import { basename, dirname, isEqual, joinPath } from '../../../../../base/common/resources.js';
import { AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js';
import { FileOperationError, FileOperationResult, IFileService } from '../../../../../platform/files/common/files.js';
import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js';
Expand All @@ -39,10 +40,10 @@ import { IHoverService } from '../../../../../platform/hover/browser/hover.js';
import type { IContextMenuProvider } from '../../../../../base/browser/contextmenu.js';
import { AnchorAlignment } from '../../../../../base/browser/ui/contextview/contextview.js';
import { getPluginInclusionLabel } from './aiCustomizationPresentation.js';
import { getErrorMessage } from '../../../../../base/common/errors.js';
import { autorun } from '../../../../../base/common/observable.js';
import { autorun, waitForState } from '../../../../../base/common/observable.js';

const $ = DOM.$;
const INSTALL_REGISTRATION_TIMEOUT = 10_000;

export interface IPluginReadme {
readonly content: string;
Expand Down Expand Up @@ -96,6 +97,27 @@ export async function loadPluginReadme(
throw new Error(`Unsupported plugin README URI scheme: ${readmeUri.scheme}`);
}

export async function waitForInstalledPlugin(
agentPluginService: Pick<IAgentPluginService, 'plugins'>,
expectedUri: URI,
token: CancellationToken,
): Promise<IAgentPlugin | undefined> {
try {
const plugins = await waitForState(
agentPluginService.plugins,
plugins => plugins.some(plugin => isEqual(plugin.uri, expectedUri)),
undefined,
token,
);
return plugins.find(plugin => isEqual(plugin.uri, expectedUri));
} catch (error) {
if (isCancellationError(error)) {
return undefined;
}
throw error;
}
}

/**
* Compact detail view for an agent plugin inside the AI Customizations management editor's
* split-pane host. Renders identity, provenance, contribution summary, and description while
Expand Down Expand Up @@ -131,6 +153,7 @@ export class EmbeddedAgentPluginDetail extends Disposable {
private readonly copyStateReset = this._register(new MutableDisposable());
private readonly narrowLayoutUpdate = this._register(new MutableDisposable());
private readonly inputStateAutorun = this._register(new MutableDisposable());
private readonly installWaitDisposables = this._register(new MutableDisposable<DisposableStore>());

private current: IAgentPluginItem | undefined;
private narrowLayout = false;
Expand Down Expand Up @@ -229,6 +252,7 @@ export class EmbeddedAgentPluginDetail extends Disposable {
}

setInput(item: IAgentPluginItem): void {
this.installWaitDisposables.clear();
this.current = item;
this.renderItem();
if (item.kind === AgentPluginItemKind.Installed) {
Expand All @@ -254,6 +278,7 @@ export class EmbeddedAgentPluginDetail extends Disposable {
}

clearInput(): void {
this.installWaitDisposables.clear();
this.current = undefined;
this.inputStateAutorun.clear();
this.renderItem();
Expand Down Expand Up @@ -325,27 +350,44 @@ export class EmbeddedAgentPluginDetail extends Disposable {
this.renderDisposables.add(installButton.onDidClick(async () => {
installButton.label = localize('installing', "Installing...");
installButton.enabled = false;
const marketplacePlugin: IMarketplacePlugin = {
name: item.name,
description: item.description,
version: item.version ?? '',
source: item.source,
sourceDescriptor: item.sourceDescriptor,
marketplace: item.marketplace,
marketplaceReference: item.marketplaceReference,
marketplaceType: item.marketplaceType,
readmeUri: item.readmeUri,
};
try {
await this.pluginInstallService.installPlugin({
name: item.name,
description: item.description,
version: item.version ?? '',
source: item.source,
sourceDescriptor: item.sourceDescriptor,
marketplace: item.marketplace,
marketplaceReference: item.marketplaceReference,
marketplaceType: item.marketplaceType,
readmeUri: item.readmeUri,
});
await this.pluginInstallService.installPlugin(marketplacePlugin);
if (this._store.isDisposed || this.current !== item) {
return;
}
const waitDisposables = new DisposableStore();
this.installWaitDisposables.value = waitDisposables;
Comment thread
hawkticehurst marked this conversation as resolved.
const waitCts = new CancellationTokenSource();
waitDisposables.add({ dispose: () => waitCts.dispose(true) });
waitDisposables.add(disposableTimeout(() => waitCts.cancel(), INSTALL_REGISTRATION_TIMEOUT));
const expectedUri = this.pluginInstallService.getPluginInstallUri(marketplacePlugin);
const plugin = await waitForInstalledPlugin(this.agentPluginService, expectedUri, waitCts.token);
if (this.installWaitDisposables.value === waitDisposables) {
this.installWaitDisposables.clear();
}
if (this._store.isDisposed || this.current !== item) {
return;
}
const installed = this.getInstalledPluginForMarketplaceItem(item);
if (installed) {
if (plugin) {
installButton.label = localize('installed', "Installed");
this.setInput(installed);
this.setInput(this.toInstalledPluginItem(plugin));
} else {
installButton.label = localize('install', "Install");
installButton.enabled = true;
}
} catch (error) {
this.installWaitDisposables.clear();
if (this._store.isDisposed || this.current !== item) {
return;
}
Expand Down Expand Up @@ -375,9 +417,15 @@ export class EmbeddedAgentPluginDetail extends Disposable {
uninstallButton.label = uninstallAction.label;
uninstallButton.enabled = uninstallAction.enabled;
this.renderDisposables.add(uninstallButton.onDidClick(async () => {
await uninstallAction.run();
if (!this._store.isDisposed && this.current === item) {
this._onDidUninstall.fire();
try {
const removed = await uninstallAction.runAndGetResult();
if (removed && !this._store.isDisposed && this.current === item) {
this._onDidUninstall.fire();
}
} catch (error) {
if (!this._store.isDisposed && this.current === item) {
this.notificationService.error(localize('pluginUninstallFailed', "Unable to uninstall plugin: {0}", getErrorMessage(error)));
}
}
}));
}
Expand Down Expand Up @@ -407,6 +455,10 @@ export class EmbeddedAgentPluginDetail extends Disposable {
anchorAlignment: AnchorAlignment.RIGHT,
}),
};
const alternateAction = this.renderDisposables.add(new Action('plugin.alternateScope', '', undefined, true, async () => {
const state = getPluginEnablementActionState(item.plugin.enablement.get());
setEnablement(state.alternateState);
}));
const splitButton = this.renderDisposables.add(new ButtonWithDropdown(this.titleActionsEl, {
...defaultButtonStyles,
secondary: true,
Expand All @@ -416,9 +468,8 @@ export class EmbeddedAgentPluginDetail extends Disposable {
actions: {
getActions: () => {
const state = getPluginEnablementActionState(item.plugin.enablement.get());
return [
this.renderDisposables.add(new Action(`plugin.${state.isEnabled ? 'exclude' : 'include'}AlternateScope`, state.alternateLabel, undefined, true, async () => setEnablement(state.alternateState)))
];
alternateAction.label = state.alternateLabel;
return [alternateAction];
},
},
ariaLabel: '',
Expand All @@ -434,22 +485,7 @@ export class EmbeddedAgentPluginDetail extends Disposable {
this.renderDisposables.add(splitButton.onDidClick(() => setEnablement(getPluginEnablementActionState(item.plugin.enablement.get()).primaryState)));
}

private getInstalledPluginForMarketplaceItem(item: Extract<IAgentPluginItem, { kind: AgentPluginItemKind.Marketplace }>): IAgentPluginItem | undefined {
const expectedUri = this.pluginInstallService.getPluginInstallUri({
name: item.name,
description: item.description,
version: item.version ?? '',
source: item.source,
sourceDescriptor: item.sourceDescriptor,
marketplace: item.marketplace,
marketplaceReference: item.marketplaceReference,
marketplaceType: item.marketplaceType,
readmeUri: item.readmeUri,
});
const plugin = this.agentPluginService.plugins.get().find(plugin => plugin.uri.toString() === expectedUri.toString());
if (!plugin) {
return undefined;
}
private toInstalledPluginItem(plugin: IAgentPlugin): IAgentPluginItem {
return {
kind: AgentPluginItemKind.Installed,
name: plugin.label || basename(plugin.uri),
Expand Down
Loading
Loading