Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
f6b799b
statusbar options
benibenj Feb 23, 2026
fe69cce
Removing Experimental mode for terminal sandboxing (#297077)
dileepyavan Feb 23, 2026
e63e21f
Merge pull request #297120 from microsoft/benibenj/tiny-kangaroo
benibenj Feb 23, 2026
d944e1d
remove toolpicker from mode dropdown
benibenj Feb 23, 2026
a3894ed
Chat input: move + button, show attachments bar via implicitContext s…
daviddossett Feb 23, 2026
346fbf3
Add telemetry for command center chat toggles (#296071)
Copilot Feb 23, 2026
846ffcb
show checkpoint on response hover (#297121)
justschen Feb 23, 2026
8452d33
Add agent-mode tip for enabling agentic browser integration (#297026)
Copilot Feb 23, 2026
5fa47db
ai customizations: mcp marketplace (#297087)
joshspicer Feb 23, 2026
d701d42
Respect explicit editor.accessibilitySupport setting in accessibility…
meganrogge Feb 23, 2026
07e3030
dont fail ci for external PRs (#297124)
hediet Feb 23, 2026
bb98d2e
Merge pull request #297127 from microsoft/benibenj/criminal-trout
benibenj Feb 23, 2026
e87d386
Tweak tip (#297132)
roblourens Feb 23, 2026
bb4b084
Adapt sessions filtering to be modified by other implementors (#297129)
joshspicer Feb 23, 2026
3e137f4
Fix agent props in handoff event (#295675)
digitarald Feb 23, 2026
92ca3ed
Chat customizations: Hover hints for each section (#297139)
joshspicer Feb 24, 2026
05cd5b7
Add memory leak audit skill for AI coding agents (#293292)
digitarald Feb 24, 2026
1365070
'ai customizations' -> 'chat customizations' (#297137)
joshspicer Feb 24, 2026
deb2d4a
chat: mark streaming tool calls as skipped when request completes (#2…
connor4312 Feb 24, 2026
dc4cd84
plugins: enable installing plugins from marketplaces
connor4312 Feb 24, 2026
f6b1688
pr comments
connor4312 Feb 24, 2026
5b33ee8
Merge pull request #297148 from microsoft/connor4312/installing-plugins
connor4312 Feb 24, 2026
f7f0962
fix chat customizations search (#297155)
joshspicer Feb 24, 2026
a3bffa0
surface /autoApprove and /yolo as commands for yolo mode (#297158)
justschen Feb 24, 2026
aabb1c7
Browser tools feedback (#297163)
kycutler Feb 24, 2026
48cc125
Chat Customizations Window: Organize MCP servers by Workspace/User gr…
joshspicer Feb 24, 2026
b34cc40
Send telemetry with Windows OS release information during update init…
dmitrivMS Feb 24, 2026
cd524e0
Add `accessibility.chat.showCheckmarks` setting (#297133)
daviddossett Feb 24, 2026
3cb9e46
Sandbox: Updating output for terminal command errors due to domain mi…
dileepyavan Feb 24, 2026
b7dae0b
Fix sessions list: show description with badge, remove bold, add elli…
daviddossett Feb 24, 2026
3e620f7
fix issue where dynamic variables are checked properly when editing (…
justschen Feb 24, 2026
e942254
Add `github.permissions` syntax to custom agent lint (#297126)
pwang347 Feb 24, 2026
b015d50
Move update status bar entry to the leftmost position (#297185)
dmitrivMS Feb 24, 2026
c4c215b
Feature: Chat Debug Panel (#296417)
pwang347 Feb 24, 2026
0fb7719
feat: add AI co-author support for commits (#296435)
dmitrivMS Feb 24, 2026
b97ea3d
checkpoint hover and model info hover at the same time (#297202)
justschen Feb 24, 2026
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
147 changes: 147 additions & 0 deletions .github/skills/memory-leak-audit/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
---
name: memory-leak-audit
description: 'Audit code for memory leaks and disposable issues. Use when reviewing event listeners, DOM handlers, lifecycle callbacks, or fixing leak reports. Covers addDisposableListener, Event.once, MutableDisposable, DisposableStore, and onWillDispose patterns.'
---

# Memory Leak Audit

The #1 bug category in VS Code. This skill encodes the patterns that prevent and fix leaks.

## When to Use

- Reviewing code that registers event listeners or DOM handlers
- Fixing reported memory leaks (listener counts growing over time)
- Creating objects in methods that are called repeatedly
- Working with model lifecycle events (onWillDispose, onDidClose)
- Adding event subscriptions in constructors or setup methods

## Audit Checklist

Work through each check in order. A single missed pattern can cause thousands of leaked objects.

### Step 1: DOM Event Listeners

**Rule**: Never use raw `.onload`, `.onclick`, or `addEventListener()` directly. Always use `addDisposableListener()`.

```typescript
// BAD — leaks a listener every call
this.iconElement.onload = () => { ... };

// GOOD — tracked and disposable
this._register(addDisposableListener(this.iconElement, 'load', () => { ... }));
```

**Validated by**: PR #280566 — Extension icon widget leaked 185 listeners after 37 toggles.

### Step 2: One-Time Events

**Rule**: Use `Event.once()` for events that should only fire once (lifecycle events, close events, first-change events).

```typescript
// BAD — listener stays registered forever after first fire
model.onDidDispose(() => store.dispose());

// GOOD — auto-removes after first invocation
Event.once(model.onDidDispose)(() => store.dispose());
```

**Validated by**: PRs #285657, #285661 — Terminal lifecycle hacks replaced with `Event.once()`.

### Step 3: Repeated Method Calls

**Rule**: Objects created in methods called multiple times must NOT be registered to the class `this._register()`. Use `MutableDisposable` or return `IDisposable` to the caller.

```typescript
// BAD — every call adds another listener to the class store
startSearch() {
this._register(this.model.onResults(() => { ... }));
}

// GOOD — MutableDisposable ensures max 1 listener
private readonly _searchListener = this._register(new MutableDisposable());

startSearch() {
this._searchListener.value = this.model.onResults(() => { ... });
}
```

When the event should only fire once per method call, combine `Event.once()` with `MutableDisposable` — this auto-removes the listener after the first invocation while still guarding against repeated calls:

```typescript
private readonly _searchListener = this._register(new MutableDisposable());

startSearch() {
this._searchListener.value = Event.once(this.model.onResults)(() => { ... });
}
```

**Validated by**: PR #283466 — Terminal find widget leaked 1 listener per search.

### Step 4: Model-Tied DisposableStores

**Rule**: When creating a `DisposableStore` tied to a model's lifetime, register `model.onWillDispose(() => store.dispose())` to the store itself.

```typescript
const store = new DisposableStore();
store.add(model.onWillDispose(() => store.dispose()));
store.add(model.onDidChange(() => { ... }));
```

**Validated by**: Pattern used in `chatEditingSession.ts`, `fileBasedRecommendations.ts`, `testingContentProvider.ts`.

### Step 5: Resource Pool Patterns

**Rule**: When using factory methods that create pooled objects (lists, trees), disposables must be registered to the individual item, not the pool class.

```typescript
// BAD — registers to pool, never cleaned per item
createItem() {
const item = new Item();
this._register(item.onEvent(() => { ... }));
return item;
}

// GOOD — wrap with item-scoped disposal
createItem(): IDisposable & Item {
const store = new DisposableStore();
const item = new Item();
store.add(item.onEvent(() => { ... }));
return { ...item, dispose: () => store.dispose() };
}
```

**Validated by**: PR #290505 — Chat content parts CollapsibleListPool and TreePool leaked disposables.

### Step 6: Test Validation

**Rule**: Every test suite that creates disposable objects must call `ensureNoDisposablesAreLeakedInTestSuite()`.

```typescript
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';

suite('MyFeature', () => {
ensureNoDisposablesAreLeakedInTestSuite();

test('does something', () => {
// test disposables are tracked automatically
});
});
```

## Quick Reference

| Scenario | Pattern | Anti-Pattern |
|----------|---------|-------------|
| DOM events | `addDisposableListener()` | `.onclick =`, `addEventListener()` |
| One-time events | `Event.once(event)(handler)` | `event(handler)` for lifecycle |
| Repeated methods | `MutableDisposable` or return `IDisposable` | `this._register()` in non-constructor |
| Model lifecycle | `store.add(model.onWillDispose(...))` | Forgetting cleanup |
| Pooled objects | Item-scoped `DisposableStore` | Pool-scoped `this._register()` |
| Tests | `ensureNoDisposablesAreLeakedInTestSuite()` | No leak checking |

## Verification

After fixing leaks, verify by:
1. Checking listener counts before/after repeated operations
2. Running `ensureNoDisposablesAreLeakedInTestSuite()` in tests
3. Confirming object counts stabilize (don't grow linearly with usage)
4 changes: 2 additions & 2 deletions .github/workflows/screenshot-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,14 +108,14 @@ jobs:

if [ -n "$CHECK_RUN_ID" ]; then
gh api "repos/${{ github.repository }}/check-runs/$CHECK_RUN_ID" \
-X PATCH --input - <<EOF
-X PATCH --input - <<EOF || echo "::warning::Could not update check run (expected for fork PRs)"
{"details_url":"$DETAILS_URL","output":{"title":"$TITLE","summary":"$TITLE"}}
EOF
fi

DETAILS_URL="https://hediet-ghartifactpreview.azurewebsites.net/${{ github.repository }}/run/${{ github.run_id }}/component-explorer/___explorer.html?report=./screenshot-report/report.json"
gh api "repos/${{ github.repository }}/statuses/$SHA" \
--input - <<EOF
--input - <<EOF || echo "::warning::Could not create commit status (expected for fork PRs)"
{"state":"success","target_url":"$DETAILS_URL","description":"$TITLE","context":"screenshots / explorer"}
EOF

Expand Down
16 changes: 16 additions & 0 deletions extensions/git/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3614,6 +3614,22 @@
"default": false,
"description": "%config.alwaysSignOff%"
},
"git.addAICoAuthor": {
"type": "string",
"enum": [
"off",
"chatAndAgent",
"all"
],
"enumDescriptions": [
"%config.addAICoAuthor.off%",
"%config.addAICoAuthor.chatAndAgent%",
"%config.addAICoAuthor.all%"
],
"scope": "resource",
"default": "off",
"description": "%config.addAICoAuthor%"
},
"git.ignoreSubmodules": {
"type": "boolean",
"scope": "resource",
Expand Down
4 changes: 4 additions & 0 deletions extensions/git/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,10 @@
"config.worktreeIncludeFiles": "Configure [glob patterns](https://aka.ms/vscode-glob-patterns) for files and folders that are included when creating a new worktree. Only files and folders that match the patterns and are listed in `.gitignore` will be copied to the newly created worktree.",
"config.alwaysShowStagedChangesResourceGroup": "Always show the Staged Changes resource group.",
"config.alwaysSignOff": "Controls the signoff flag for all commits.",
"config.addAICoAuthor": "Controls whether a 'Co-authored-by' trailer is automatically added to the commit message when AI-generated code is included in the commit.",
"config.addAICoAuthor.off": "Never add the AI co-author trailer.",
"config.addAICoAuthor.chatAndAgent": "Add the AI co-author trailer when code from chat or agent edits is included.",
"config.addAICoAuthor.all": "Add the AI co-author trailer when any AI-generated code is included, such as inline completions, chat, or agent edits.",
"config.ignoreSubmodules": "Ignore modifications to submodules in the file tree.",
"config.ignoredRepositories": "List of Git repositories to ignore.",
"config.scanRepositories": "List of paths to search for Git repositories in.",
Expand Down
2 changes: 1 addition & 1 deletion extensions/git/src/blame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ export class GitBlameController {
arguments: ['git.blame']
}] satisfies Command[]);

return getCommitHover(commitAvatar, authorName, authorEmail, authorDate, message, commitInformation?.shortStat, commands);
return getCommitHover(commitAvatar, authorName, authorEmail, authorDate, message, commitInformation?.shortStat, commands, commitInformation?.coAuthors);
}

private _onDidChangeConfiguration(e?: ConfigurationChangeEvent): void {
Expand Down
25 changes: 23 additions & 2 deletions extensions/git/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@

import * as os from 'os';
import * as path from 'path';
import { Command, commands, Disposable, MessageOptions, Position, QuickPickItem, Range, SourceControlResourceState, TextDocumentShowOptions, TextEditor, Uri, ViewColumn, window, workspace, WorkspaceEdit, WorkspaceFolder, TimelineItem, env, Selection, TextDocumentContentProvider, InputBoxValidationSeverity, TabInputText, TabInputTextMerge, QuickPickItemKind, TextDocument, LogOutputChannel, l10n, Memento, UIKind, QuickInputButton, ThemeIcon, SourceControlHistoryItem, SourceControl, InputBoxValidationMessage, Tab, TabInputNotebook, QuickInputButtonLocation, languages, SourceControlArtifact } from 'vscode';
import { Command, commands, Disposable, MessageOptions, Position, QuickPickItem, Range, SourceControlResourceState, TextDocumentShowOptions, TextEditor, Uri, ViewColumn, window, workspace, WorkspaceEdit, WorkspaceFolder, TimelineItem, env, Selection, TextDocumentContentProvider, InputBoxValidationSeverity, TabInputText, TabInputTextMerge, QuickPickItemKind, TextDocument, LogOutputChannel, l10n, Memento, UIKind, QuickInputButton, ThemeIcon, SourceControlHistoryItem, SourceControl, InputBoxValidationMessage, Tab, TabInputNotebook, QuickInputButtonLocation, languages, SourceControlArtifact, ProgressLocation } from 'vscode';
import TelemetryReporter from '@vscode/extension-telemetry';
import { uniqueNamesGenerator, adjectives, animals, colors, NumberDictionary } from '@joaomoreno/unique-names-generator';
import { ForcePushMode, GitErrorCodes, RefType, Status, CommitOptions, RemoteSourcePublisher, Remote, Branch, Ref } from './api/git';
import { Git, GitError, Stash, Worktree } from './git';
import { Git, GitError, Repository as GitRepository, Stash, Worktree } from './git';
import { Model } from './model';
import { GitResourceGroup, Repository, Resource, ResourceGroupType } from './repository';
import { DiffEditorSelectionHunkToolbarContext, LineChange, applyLineChanges, getIndexDiffInformation, getModifiedRange, getWorkingTreeDiffInformation, intersectDiffWithRange, invertLineChange, toLineChanges, toLineRanges, compareLineChanges } from './staging';
Expand Down Expand Up @@ -1037,6 +1037,27 @@ export class CommandCenter {
await this.cloneManager.clone(url, { parentPath, recursive: true });
}

@command('_git.cloneRepository')
async cloneRepository(url: string, parentPath: string): Promise<void> {
const opts = {
location: ProgressLocation.Notification,
title: l10n.t('Cloning git repository "{0}"...', url),
cancellable: true
};

await window.withProgress(
opts,
(progress, token) => this.model.git.clone(url, { parentPath, progress }, token)
);
}

@command('_git.pull')
async pullRepository(repositoryPath: string): Promise<void> {
const dotGit = await this.git.getRepositoryDotGit(repositoryPath);
const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger);
await repo.pull();
}

@command('git.init')
async init(skipFolderPrompt = false): Promise<void> {
let repositoryPath: string | undefined = undefined;
Expand Down
27 changes: 26 additions & 1 deletion extensions/git/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,11 @@ export interface CommitShortStat {
readonly deletions: number;
}

export interface CoAuthor {
readonly name: string;
readonly email: string;
}

export interface Commit {
hash: string;
message: string;
Expand All @@ -758,6 +763,7 @@ export interface Commit {
commitDate?: Date;
refNames: string[];
shortStat?: CommitShortStat;
coAuthors?: CoAuthor[];
}

export interface RefQuery extends ApiRefQuery {
Expand Down Expand Up @@ -951,13 +957,32 @@ export function parseGitCommits(data: string): Commit[] {
authorEmail: ` ${authorEmail}`.substr(1),
commitDate: new Date(Number(commitDate) * 1000),
refNames: refNames.split(',').map(s => s.trim()),
shortStat: shortStat ? parseGitDiffShortStat(shortStat) : undefined
shortStat: shortStat ? parseGitDiffShortStat(shortStat) : undefined,
coAuthors: parseCoAuthors(message)
});
} while (true);

return commits;
}

const coAuthorRegex = /^Co-authored-by:\s*(.+?)\s*<([^>]+)>\s*$/gim;

export function parseCoAuthors(message: string): CoAuthor[] {
const coAuthors: CoAuthor[] = [];
let match;

coAuthorRegex.lastIndex = 0;
while ((match = coAuthorRegex.exec(message)) !== null) {
const name = match[1].trim();
const email = match[2].trim();
if (name && email) {
coAuthors.push({ name, email });
}
}

return coAuthors;
}

const diffShortStatRegex = /(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/;

function parseGitDiffShortStat(data: string): CommitShortStat {
Expand Down
2 changes: 1 addition & 1 deletion extensions/git/src/historyProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec
processHoverRemoteCommands(remoteHoverCommands, commit.hash)
];

const tooltip = getHistoryItemHover(avatarUrl, commit.authorName, commit.authorEmail, commit.authorDate ?? commit.commitDate, messageWithLinks, commit.shortStat, commands);
const tooltip = getHistoryItemHover(avatarUrl, commit.authorName, commit.authorEmail, commit.authorDate ?? commit.commitDate, messageWithLinks, commit.shortStat, commands, commit.coAuthors);

historyItems.push({
id: commit.hash,
Expand Down
32 changes: 26 additions & 6 deletions extensions/git/src/hover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@
import { Command, l10n, MarkdownString, Uri } from 'vscode';
import { fromNow, getCommitShortHash } from './util';
import { emojify } from './emoji';
import { CommitShortStat } from './git';
import { CoAuthor, CommitShortStat } from './git';

export const AVATAR_SIZE = 20;

export function getCommitHover(authorAvatar: string | undefined, authorName: string | undefined, authorEmail: string | undefined, authorDate: Date | number | undefined, message: string, shortStats: CommitShortStat | undefined, commands: Command[][] | undefined): MarkdownString {
export function getCommitHover(authorAvatar: string | undefined, authorName: string | undefined, authorEmail: string | undefined, authorDate: Date | number | undefined, message: string, shortStats: CommitShortStat | undefined, commands: Command[][] | undefined, coAuthors?: CoAuthor[]): MarkdownString {
const markdownString = new MarkdownString('', true);
markdownString.isTrusted = {
enabledCommands: commands?.flat().map(c => c.command) ?? []
};

// Author, Subject | Message (escape image syntax)
appendContent(markdownString, authorAvatar, authorName, authorEmail, authorDate, message);
appendContent(markdownString, authorAvatar, authorName, authorEmail, authorDate, message, coAuthors);

// Short stats
if (shortStats) {
Expand All @@ -32,12 +32,12 @@ export function getCommitHover(authorAvatar: string | undefined, authorName: str
return markdownString;
}

export function getHistoryItemHover(authorAvatar: string | undefined, authorName: string | undefined, authorEmail: string | undefined, authorDate: Date | number | undefined, message: string, shortStats: CommitShortStat | undefined, commands: Command[][] | undefined): MarkdownString[] {
export function getHistoryItemHover(authorAvatar: string | undefined, authorName: string | undefined, authorEmail: string | undefined, authorDate: Date | number | undefined, message: string, shortStats: CommitShortStat | undefined, commands: Command[][] | undefined, coAuthors?: CoAuthor[]): MarkdownString[] {
const hoverContent: MarkdownString[] = [];

// Author, Subject | Message (escape image syntax)
const authorMarkdownString = new MarkdownString('', true);
appendContent(authorMarkdownString, authorAvatar, authorName, authorEmail, authorDate, message);
appendContent(authorMarkdownString, authorAvatar, authorName, authorEmail, authorDate, message, coAuthors);
hoverContent.push(authorMarkdownString);

// Short stats
Expand All @@ -61,7 +61,7 @@ export function getHistoryItemHover(authorAvatar: string | undefined, authorName
return hoverContent;
}

function appendContent(markdownString: MarkdownString, authorAvatar: string | undefined, authorName: string | undefined, authorEmail: string | undefined, authorDate: Date | number | undefined, message: string): void {
function appendContent(markdownString: MarkdownString, authorAvatar: string | undefined, authorName: string | undefined, authorEmail: string | undefined, authorDate: Date | number | undefined, message: string, coAuthors?: CoAuthor[]): void {
// Author
if (authorName) {
// Avatar
Expand Down Expand Up @@ -101,6 +101,26 @@ function appendContent(markdownString: MarkdownString, authorAvatar: string | un
markdownString.appendMarkdown('\n\n');
}

// Co-authors
if (coAuthors && coAuthors.length > 0) {
for (const coAuthor of coAuthors) {
markdownString.appendMarkdown('$(account) ');
if (coAuthor.email) {
markdownString.appendMarkdown('[**');
markdownString.appendText(coAuthor.name);
markdownString.appendMarkdown('**](mailto:');
markdownString.appendText(coAuthor.email);
markdownString.appendMarkdown(')');
} else {
markdownString.appendMarkdown('**');
markdownString.appendText(coAuthor.name);
markdownString.appendMarkdown('**');
}
markdownString.appendMarkdown(` _(${l10n.t('Co-author')})_`);
markdownString.appendMarkdown('\n\n');
}
}

// Subject | Message (escape image syntax)
markdownString.appendMarkdown(`${emojify(message.replace(/!\[/g, '&#33;&#91;').replace(/\r\n|\r|\n/g, '\n\n'))}`);
markdownString.appendMarkdown(`\n\n---\n\n`);
Expand Down
Loading
Loading