Avoid listener leak warnings (#295402)

* Fix leak in ChatProgressContentPart

* Optimize inline anchor widget

* And thsi
This commit is contained in:
Rob Lourens
2026-02-15 07:31:28 +00:00
committed by GitHub
parent 2f1297db89
commit eecfc4932d
5 changed files with 104 additions and 41 deletions
@@ -614,8 +614,7 @@ export class DefaultChatAttachmentWidget extends AbstractChatAttachmentWidget {
}
if (attachment.kind === 'symbol') {
const scopedContextKeyService = this._register(this.contextKeyService.createScoped(this.element));
this._register(this.instantiationService.invokeFunction(hookUpSymbolAttachmentDragAndContextMenu, this.element, scopedContextKeyService, { ...attachment, kind: attachment.symbolKind }, MenuId.ChatInputSymbolAttachmentContext));
this._register(this.instantiationService.invokeFunction(hookUpSymbolAttachmentDragAndContextMenu, this.element, this.contextKeyService, { ...attachment, kind: attachment.symbolKind }, MenuId.ChatInputSymbolAttachmentContext));
}
// Handle click for string context attachments with context commands
@@ -1115,19 +1114,15 @@ export function hookUpResourceAttachmentDragAndContextMenu(accessor: ServicesAcc
return store;
}
export function hookUpSymbolAttachmentDragAndContextMenu(accessor: ServicesAccessor, widget: HTMLElement, scopedContextKeyService: IScopedContextKeyService, attachment: { name: string; value: Location; kind: SymbolKind }, contextMenuId: MenuId): IDisposable {
export function hookUpSymbolAttachmentDragAndContextMenu(accessor: ServicesAccessor, widget: HTMLElement, parentContextKeyService: IContextKeyService, attachment: { name: string; value: Location; kind: SymbolKind }, contextMenuId: MenuId): IDisposable {
const instantiationService = accessor.get(IInstantiationService);
const languageFeaturesService = accessor.get(ILanguageFeaturesService);
const textModelService = accessor.get(ITextModelService);
const contextMenuService = accessor.get(IContextMenuService);
const menuService = accessor.get(IMenuService);
const store = new DisposableStore();
// Context
store.add(setResourceContext(accessor, scopedContextKeyService, attachment.value.uri));
const chatResourceContext = chatAttachmentResourceContextKey.bindTo(scopedContextKeyService);
chatResourceContext.set(attachment.value.uri.toString());
// Drag and drop
widget.draggable = true;
store.add(dom.addDisposableListener(widget, 'dragstart', e => {
@@ -1143,26 +1138,57 @@ export function hookUpSymbolAttachmentDragAndContextMenu(accessor: ServicesAcces
e.dataTransfer?.setDragImage(widget, 0, 0);
}));
// Context menu
const providerContexts: ReadonlyArray<[IContextKey<boolean>, LanguageFeatureRegistry<unknown>]> = [
[EditorContextKeys.hasDefinitionProvider.bindTo(scopedContextKeyService), languageFeaturesService.definitionProvider],
[EditorContextKeys.hasReferenceProvider.bindTo(scopedContextKeyService), languageFeaturesService.referenceProvider],
[EditorContextKeys.hasImplementationProvider.bindTo(scopedContextKeyService), languageFeaturesService.implementationProvider],
[EditorContextKeys.hasTypeDefinitionProvider.bindTo(scopedContextKeyService), languageFeaturesService.typeDefinitionProvider],
];
// Context menu (context key service created eagerly for keybinding preconditions,
// but resource context and provider contexts are initialized lazily on first use)
const scopedContextKeyService = store.add(parentContextKeyService.createScoped(widget));
chatAttachmentResourceContextKey.bindTo(scopedContextKeyService).set(attachment.value.uri.toString());
store.add(setResourceContext(accessor, scopedContextKeyService, attachment.value.uri));
let providerContexts: ReadonlyArray<[IContextKey<boolean>, LanguageFeatureRegistry<unknown>]> | undefined;
const ensureProviderContexts = () => {
if (!providerContexts) {
providerContexts = [
[EditorContextKeys.hasDefinitionProvider.bindTo(scopedContextKeyService), languageFeaturesService.definitionProvider],
[EditorContextKeys.hasReferenceProvider.bindTo(scopedContextKeyService), languageFeaturesService.referenceProvider],
[EditorContextKeys.hasImplementationProvider.bindTo(scopedContextKeyService), languageFeaturesService.implementationProvider],
[EditorContextKeys.hasTypeDefinitionProvider.bindTo(scopedContextKeyService), languageFeaturesService.typeDefinitionProvider],
];
}
};
const updateContextKeys = async () => {
ensureProviderContexts();
const modelRef = await textModelService.createModelReference(attachment.value.uri);
try {
const model = modelRef.object.textEditorModel;
for (const [contextKey, registry] of providerContexts) {
for (const [contextKey, registry] of providerContexts!) {
contextKey.set(registry.has(model));
}
} finally {
modelRef.dispose();
}
};
store.add(addBasicContextMenu(accessor, widget, scopedContextKeyService, contextMenuId, attachment.value, updateContextKeys));
store.add(dom.addDisposableListener(widget, dom.EventType.CONTEXT_MENU, async domEvent => {
const event = new StandardMouseEvent(dom.getWindow(domEvent), domEvent);
dom.EventHelper.stop(domEvent, true);
try {
await updateContextKeys();
} catch (e) {
console.error(e);
}
contextMenuService.showContextMenu({
contextKeyService: scopedContextKeyService,
getAnchor: () => event,
getActions: () => {
const menu = menuService.getMenuActions(contextMenuId, scopedContextKeyService, { arg: attachment.value });
return getFlatContextMenuActions(menu);
},
});
}));
return store;
}
@@ -23,7 +23,7 @@ import { getFlatContextMenuActions } from '../../../../../../platform/actions/br
import { Action2, IMenuService, MenuId, registerAction2 } from '../../../../../../platform/actions/common/actions.js';
import { IClipboardService } from '../../../../../../platform/clipboard/common/clipboardService.js';
import { ICommandService } from '../../../../../../platform/commands/common/commands.js';
import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js';
import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js';
import { IContextMenuService } from '../../../../../../platform/contextview/browser/contextView.js';
import { IResourceStat } from '../../../../../../platform/dnd/browser/dnd.js';
import { ITextResourceEditorInput } from '../../../../../../platform/editor/common/editor.js';
@@ -125,8 +125,6 @@ export class InlineAnchorWidget extends Disposable {
public static readonly className = 'chat-inline-anchor-widget';
private readonly _chatResourceContext: IContextKey<string>;
readonly data: ContentRefData;
constructor(
@@ -158,9 +156,6 @@ export class InlineAnchorWidget extends Disposable {
? { kind: 'symbol', symbol: inlineReference.inlineReference }
: { uri: inlineReference.inlineReference };
const contextKeyService = this._register(originalContextKeyService.createScoped(element));
this._chatResourceContext = chatAttachmentResourceContextKey.bindTo(contextKeyService);
element.classList.add(InlineAnchorWidget.className, 'show-file-icons');
let iconText: Array<string | HTMLElement>;
@@ -168,7 +163,6 @@ export class InlineAnchorWidget extends Disposable {
let location: { readonly uri: URI; readonly range?: IRange };
let updateContextKeys: (() => Promise<void>) | undefined;
if (this.data.kind === 'symbol') {
const symbol = this.data.symbol;
@@ -176,7 +170,7 @@ export class InlineAnchorWidget extends Disposable {
iconText = [this.data.symbol.name];
iconClasses = ['codicon', ...getIconClasses(modelService, languageService, undefined, undefined, SymbolKinds.toIcon(symbol.kind))];
this._store.add(instantiationService.invokeFunction(accessor => hookUpSymbolAttachmentDragAndContextMenu(accessor, element, contextKeyService, { value: symbol.location, name: symbol.name, kind: symbol.kind }, MenuId.ChatInlineSymbolAnchorContext)));
this._store.add(instantiationService.invokeFunction(accessor => hookUpSymbolAttachmentDragAndContextMenu(accessor, element, originalContextKeyService, { value: symbol.location, name: symbol.name, kind: symbol.kind }, MenuId.ChatInlineSymbolAnchorContext)));
} else {
location = this.data;
@@ -209,10 +203,10 @@ export class InlineAnchorWidget extends Disposable {
refreshIconClasses();
}));
const isFolderContext = ExplorerFolderContext.bindTo(contextKeyService);
let isDirectory = false;
fileService.stat(location.uri)
.then(stat => {
isFolderContext.set(stat.isDirectory);
isDirectory = stat.isDirectory;
if (stat.isDirectory) {
fileKind = FileKind.FOLDER;
refreshIconClasses();
@@ -221,15 +215,20 @@ export class InlineAnchorWidget extends Disposable {
.catch(() => { });
// Context menu
const contextKeyService = this._register(originalContextKeyService.createScoped(element));
chatAttachmentResourceContextKey.bindTo(contextKeyService).set(location.uri.toString());
const isFolderContext = ExplorerFolderContext.bindTo(contextKeyService);
let contextMenuInitialized = false;
this._register(dom.addDisposableListener(element, dom.EventType.CONTEXT_MENU, async domEvent => {
const event = new StandardMouseEvent(dom.getWindow(domEvent), domEvent);
dom.EventHelper.stop(domEvent, true);
try {
await updateContextKeys?.();
} catch (e) {
console.error(e);
if (!contextMenuInitialized) {
contextMenuInitialized = true;
const resourceContextKey = new StaticResourceContextKey(contextKeyService, fileService, languageService, modelService);
resourceContextKey.set(location.uri);
}
isFolderContext.set(isDirectory);
if (this._store.isDisposed) {
return;
@@ -255,10 +254,6 @@ export class InlineAnchorWidget extends Disposable {
}
}
const resourceContextKey = new StaticResourceContextKey(contextKeyService, fileService, languageService, modelService);
resourceContextKey.set(location.uri);
this._chatResourceContext.set(location.uri.toString());
const iconEl = dom.$('span.icon');
iconEl.classList.add(...iconClasses);
element.replaceChildren(iconEl, dom.$('span.icon-label', {}, ...iconText));
@@ -260,6 +260,44 @@ export class ChatMarkdownDecorationsRenderer {
return container;
}
/**
* Renders a parsed chat request as plain text DOM elements with inline decoration spans
* for agents, slash commands, and other special parts. No markdown rendering is used.
*/
renderParsedRequestToPlainText(parsedRequest: IParsedChatRequest, store: DisposableStore): HTMLElement {
const container = dom.$('span');
container.style.whiteSpace = 'pre-wrap';
for (const part of parsedRequest.parts) {
if (part instanceof ChatRequestTextPart) {
container.appendChild(document.createTextNode(part.text));
} else if (part instanceof ChatRequestAgentPart) {
const widget = this.renderResourceWidget(`${chatAgentLeader}${part.agent.name}`, undefined, store);
const hover: Lazy<ChatAgentHover> = new Lazy(() => store.add(this.instantiationService.createInstance(ChatAgentHover)));
store.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), widget, () => {
hover.value.setAgent(part.agent.id);
return hover.value.domNode;
}, getChatAgentHoverOptions(() => part.agent, this.commandService)));
container.appendChild(widget);
} else {
const title = this.getDecorationTitle(part);
const widget = this.renderResourceWidget(part.text, title ? { title } : undefined, store);
container.appendChild(widget);
}
}
return container;
}
private getDecorationTitle(part: IParsedChatRequestPart): string | undefined {
const uri = part instanceof ChatRequestDynamicVariablePart && part.data instanceof URI ?
part.data :
undefined;
return uri ? this.labelService.getUriLabel(uri, { relative: true }) :
part instanceof ChatRequestSlashCommandPart ? part.slashCommand.detail :
part instanceof ChatRequestAgentSubcommandPart ? part.command.description :
part instanceof ChatRequestSlashPromptPart ? part.name :
part instanceof ChatRequestToolPart ? (this.toolsService.getTool(part.toolId)?.userDescription) :
undefined;
}
private injectKeybindingHint(a: HTMLAnchorElement, href: string, keybindingService: IKeybindingService): void {
const command = href.match(/command:([^\)]+)/)?.[1];
@@ -7,7 +7,7 @@ import { $, append } from '../../../../../../base/browser/dom.js';
import { alert } from '../../../../../../base/browser/ui/aria/aria.js';
import { Codicon } from '../../../../../../base/common/codicons.js';
import { MarkdownString, type IMarkdownString } from '../../../../../../base/common/htmlContent.js';
import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js';
import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js';
import { ThemeIcon } from '../../../../../../base/common/themables.js';
import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js';
import { IRenderedMarkdown } from '../../../../../../base/browser/markdownRenderer.js';
@@ -33,6 +33,7 @@ export class ChatProgressContentPart extends Disposable implements IChatContentP
private readonly showSpinner: boolean;
private readonly isHidden: boolean;
private readonly renderedMessage = this._register(new MutableDisposable<IRenderedMarkdown>());
private readonly _fileWidgetStore = this._register(new DisposableStore());
private currentContent: IMarkdownString;
constructor(
@@ -67,7 +68,7 @@ export class ChatProgressContentPart extends Disposable implements IChatContentP
const codicon = icon ? icon : this.showSpinner ? ThemeIcon.modify(Codicon.loading, 'spin') : Codicon.check;
const result = this.chatContentMarkdownRenderer.render(progress.content);
result.element.classList.add('progress-step');
renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._store);
renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._fileWidgetStore);
const tooltip: IMarkdownString | undefined = this.createApprovalMessage();
const progressPart = this._register(instantiationService.createInstance(ChatProgressSubPart, result.element, codicon, tooltip));
@@ -83,7 +84,8 @@ export class ChatProgressContentPart extends Disposable implements IChatContentP
// Render the new message
const result = this._register(this.chatContentMarkdownRenderer.render(content));
result.element.classList.add('progress-step');
renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._store);
this._fileWidgetStore.clear();
renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._fileWidgetStore);
// Replace the old message container with the new one
if (this.renderedMessage.value) {
@@ -457,12 +457,14 @@
}
/* not ideal but I cannot query the last div with this class... */
.rendered-markdown:last-of-type > P > SPAN:empty {
.rendered-markdown:last-of-type > P > SPAN:empty,
.rendered-markdown:last-of-type > SPAN:empty {
display: inline-block;
width: 11px;
}
.rendered-markdown:last-of-type > P > SPAN:empty::after {
.rendered-markdown:last-of-type > P > SPAN:empty::after,
.rendered-markdown:last-of-type > SPAN:empty::after {
content: '';
white-space: nowrap;
overflow: hidden;