Skip to content

Commit fa3d60d

Browse files
authored
Support uninstalling extensions from Tools section of Chat Customizations (#323981)
* Support uninstalling extensions from Tools section of Chat Customizations * PR feedback
1 parent 282e6f3 commit fa3d60d

1 file changed

Lines changed: 82 additions & 4 deletions

File tree

src/vs/workbench/contrib/chat/browser/aiCustomization/toolsListWidget.ts

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,26 @@ import { IKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js';
88
import { Button } from '../../../../../base/browser/ui/button/button.js';
99
import { HighlightedLabel } from '../../../../../base/browser/ui/highlightedlabel/highlightedLabel.js';
1010
import { InputBox } from '../../../../../base/browser/ui/inputbox/inputBox.js';
11-
import { IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js';
11+
import { IListContextMenuEvent, IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js';
1212
import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js';
1313
import { Checkbox, TriStateCheckbox } from '../../../../../base/browser/ui/toggle/toggle.js';
14+
import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js';
15+
import { IAnchor } from '../../../../../base/browser/ui/contextview/contextview.js';
16+
import { Action } from '../../../../../base/common/actions.js';
1417
import { Delayer } from '../../../../../base/common/async.js';
1518
import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js';
1619
import { Codicon } from '../../../../../base/common/codicons.js';
1720
import { Emitter } from '../../../../../base/common/event.js';
1821
import { IMatch, matchesContiguousSubString } from '../../../../../base/common/filters.js';
1922
import { KeyCode } from '../../../../../base/common/keyCodes.js';
2023
import { Disposable, DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js';
21-
import { autorun, derived, IObservable, IReader, observableValue } from '../../../../../base/common/observable.js';
24+
import { autorun, derived, IObservable, IReader, observableSignalFromEvent, observableValue } from '../../../../../base/common/observable.js';
2225
import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js';
2326
import { URI } from '../../../../../base/common/uri.js';
2427
import { localize } from '../../../../../nls.js';
2528
import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js';
26-
import { IContextViewService } from '../../../../../platform/contextview/browser/contextView.js';
29+
import { IContextMenuService, IContextViewService } from '../../../../../platform/contextview/browser/contextView.js';
30+
import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
2731
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
2832
import { WorkbenchList } from '../../../../../platform/list/browser/listService.js';
2933
import { IOpenerService } from '../../../../../platform/opener/common/opener.js';
@@ -167,6 +171,8 @@ export class ToolsListWidget extends Disposable {
167171
@ILanguageModelToolsService private readonly _toolsService: ILanguageModelToolsService,
168172
@IAgentHostToolSetEnablementService private readonly _enablementService: IAgentHostToolSetEnablementService,
169173
@IContextViewService private readonly _contextViewService: IContextViewService,
174+
@IContextMenuService private readonly _contextMenuService: IContextMenuService,
175+
@IDialogService private readonly _dialogService: IDialogService,
170176
@IOpenerService private readonly _openerService: IOpenerService,
171177
@IInstantiationService private readonly _instantiationService: IInstantiationService,
172178
@IExtensionsWorkbenchService private readonly _extensionsWorkbenchService: IExtensionsWorkbenchService,
@@ -298,6 +304,8 @@ export class ToolsListWidget extends Disposable {
298304
this._onDidSelectExtension.fire(e.element);
299305
}
300306
}));
307+
308+
this._register(this._galleryList.onContextMenu(e => this._onGalleryContextMenu(e)));
301309
}
302310

303311
private _readState(reader: IReader): IToolEnablementState {
@@ -325,7 +333,10 @@ export class ToolsListWidget extends Disposable {
325333
}
326334

327335
private _createViewModel(): IObservable<readonly IToolSetViewModel[]> {
336+
// Refresh when extensions change so tool sets from an uninstalled extension drop out immediately (their tools linger in the extension host until reload).
337+
const extensionsChanged = observableSignalFromEvent(this, this._extensionsWorkbenchService.onChange);
328338
return derived(reader => {
339+
extensionsChanged.read(reader);
329340
const query = this._searchQuery.read(reader).trim();
330341

331342
const result: IToolSetViewModel[] = [];
@@ -344,6 +355,14 @@ export class ToolsListWidget extends Disposable {
344355
if (ts.deprecated) {
345356
return undefined;
346357
}
358+
// Hide extension-provided sets whose extension is gone or being removed.
359+
if (ts.source.type === 'extension') {
360+
const extensionId = ts.source.extensionId;
361+
const installed = this._extensionsWorkbenchService.local.find(e => ExtensionIdentifier.equals(e.identifier.id, extensionId));
362+
if (!installed || installed.state === ExtensionState.Uninstalling || installed.state === ExtensionState.Uninstalled) {
363+
return undefined;
364+
}
365+
}
347366
const memberTools = Array.from(ts.getTools(reader));
348367
if (memberTools.length === 0) {
349368
return undefined;
@@ -604,6 +623,17 @@ export class ToolsListWidget extends Disposable {
604623
toggleExpand();
605624
}));
606625

626+
// Extension-provided tool sets can be uninstalled via the context menu.
627+
this._rowStore.add(DOM.addDisposableListener(row, 'contextmenu', e => {
628+
const extension = this._resolveExtensionForToolSet(ts);
629+
if (!extension) {
630+
return;
631+
}
632+
DOM.EventHelper.stop(e, true);
633+
const anchor: HTMLElement | StandardMouseEvent = e.button === 2 ? new StandardMouseEvent(DOM.getWindow(row), e) : row;
634+
this._showExtensionContextMenu(anchor, extension);
635+
}));
636+
607637
const group = DOM.append(this._treeContainer, $('.tools-list-children'));
608638
group.id = `tools-group-${ts.id}`;
609639
group.setAttribute('role', 'group');
@@ -888,8 +918,56 @@ export class ToolsListWidget extends Disposable {
888918
private _currentState(): IToolEnablementState {
889919
return this._enablementService.getState(this._sessionType);
890920
}
891-
}
892921

922+
/** Resolve the installed, non-builtin extension backing an extension-provided tool set. */
923+
private _resolveExtensionForToolSet(ts: IToolSet): IExtension | undefined {
924+
if (ts.source.type !== 'extension') {
925+
return undefined;
926+
}
927+
const source = ts.source;
928+
const extension = this._extensionsWorkbenchService.local.find(e => ExtensionIdentifier.equals(e.identifier.id, source.extensionId));
929+
if (!extension || extension.local?.isBuiltin) {
930+
return undefined;
931+
}
932+
return extension;
933+
}
934+
935+
private _onGalleryContextMenu(e: IListContextMenuEvent<IExtension>): void {
936+
const extension = e.element;
937+
if (!extension || extension.state !== ExtensionState.Installed || extension.local?.isBuiltin) {
938+
return;
939+
}
940+
this._showExtensionContextMenu(e.anchor, extension);
941+
}
942+
943+
private _showExtensionContextMenu(anchor: HTMLElement | StandardMouseEvent | IAnchor, extension: IExtension): void {
944+
const disposables = new DisposableStore();
945+
const uninstallAction = disposables.add(new Action(
946+
'toolsList.uninstallExtension',
947+
localize('uninstallExtension', "Uninstall Extension"),
948+
undefined,
949+
true,
950+
() => this._uninstallExtension(extension),
951+
));
952+
this._contextMenuService.showContextMenu({
953+
getAnchor: () => anchor,
954+
getActions: () => [uninstallAction],
955+
onHide: () => disposables.dispose(),
956+
});
957+
}
958+
959+
private async _uninstallExtension(extension: IExtension): Promise<void> {
960+
const result = await this._dialogService.confirm({
961+
message: localize('confirmUninstallToolExtension', "Do you want to uninstall the extension '{0}'?", extension.displayName),
962+
detail: localize('confirmUninstallToolExtensionDetail', "This extension may contribute more than tools. Uninstalling it removes all of its contributions."),
963+
primaryButton: localize('uninstallExtensionBtn', "Uninstall Extension"),
964+
type: 'question',
965+
});
966+
if (result.confirmed) {
967+
await this._extensionsWorkbenchService.uninstall(extension);
968+
}
969+
}
970+
}
893971

894972
/**
895973
* The Copilot CLI's built-in tools, surfaced read-only for reference. Mirrored from the published

0 commit comments

Comments
 (0)