Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1c3ba58
Add glob pattern filter settings to snippets
dmitrivMS Dec 18, 2025
81a3fb4
Merge branch 'main' into dev/dmitriv/snippets-file-filters
dmitrivMS Dec 23, 2025
b90b67f
Merge branch 'main' into dev/dmitriv/snippets-file-filters
dmitrivMS Dec 26, 2025
37cbf83
Merge branch 'main' into dev/dmitriv/snippets-file-filters
dmitrivMS Dec 27, 2025
05c47c1
Merge branch 'main' into dev/dmitriv/snippets-file-filters
dmitrivMS Dec 30, 2025
3c64721
Merge branch 'main' into dev/dmitriv/snippets-file-filters
dmitrivMS Dec 30, 2025
6be225e
Merge branch 'main' into dev/dmitriv/snippets-file-filters
dmitrivMS Dec 31, 2025
e900a96
Merge branch 'main' into dev/dmitriv/snippets-file-filters
dmitrivMS Jan 5, 2026
c629c9f
PR feedback
dmitrivMS Jan 5, 2026
5040a04
Merge branch 'main' into dev/dmitriv/snippets-file-filters
dmitrivMS Jan 6, 2026
8a6c91d
Merge branch 'main' into dev/dmitriv/snippets-file-filters
dmitrivMS Jan 6, 2026
f262483
Merge branch 'main' into dev/dmitriv/snippets-file-filters
dmitrivMS Jan 7, 2026
c324a8d
Merge branch 'main' into dev/dmitriv/snippets-file-filters
dmitrivMS Jan 7, 2026
a6bb36d
Merge branch 'main' into dev/dmitriv/snippets-file-filters
dmitrivMS Jan 8, 2026
eb84a5b
Merge branch 'main' into dev/dmitriv/snippets-file-filters
dmitrivMS Jan 8, 2026
818b46c
Merge branch 'main' into dev/dmitriv/snippets-file-filters
dmitrivMS Jan 9, 2026
3affca7
Enable relative filename matching when / is not in the pattern
dmitrivMS Jan 9, 2026
fe7fc9f
Merge branch 'main' into dev/dmitriv/snippets-file-filters
dmitrivMS Jan 9, 2026
0b46c69
Update examples, use proper file path from URI.
dmitrivMS Jan 9, 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
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,16 @@ async function createSnippetFile(scope: string, defaultPath: URI, quickInputServ
'\t// \t],',
'\t// \t"description": "Log output to console"',
'\t// }',
'\t//',
'\t// You can also restrict snippets to specific files using include/exclude patterns:',
'\t// "Test snippet": {',
'\t// \t"scope": "javascript,typescript",',
'\t// \t"prefix": "test",',
'\t// \t"body": "test(\'$1\', () => {\\n\\t$0\\n});",',
'\t// \t"include": ["**/*.test.ts", "*.spec.ts"],',
'\t// \t"exclude": ["**/temp/*.ts"],',
'\t// \t"description": "Insert test block"',
'\t// }',
'}'
].join('\n'));

Expand All @@ -218,6 +228,15 @@ async function createLanguageSnippetFile(pick: ISnippetPick, fileService: IFileS
'\t// \t],',
'\t// \t"description": "Log output to console"',
'\t// }',
'\t//',
'\t// You can also restrict snippets to specific files using include/exclude patterns:',
'\t// "Test snippet": {',
'\t// \t"prefix": "test",',
'\t// \t"body": "test(\'$1\', () => {\\n\\t$0\\n});",',
'\t// \t"include": ["**/*.test.ts", "*.spec.ts"],',
'\t// \t"exclude": ["**/temp/*.ts"],',
'\t// \t"description": "Insert test block"',
'\t// }',
'}'
].join('\n');
await textFileService.write(pick.filepath, contents);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ export class ApplyFileSnippetAction extends SnippetsAction {
return;
}

const snippets = await snippetService.getSnippets(undefined, { fileTemplateSnippets: true, noRecencySort: true, includeNoPrefixSnippets: true });
const resourceUri = editor.getModel().uri;
const snippets = await snippetService.getSnippets(undefined, resourceUri, { fileTemplateSnippets: true, noRecencySort: true, includeNoPrefixSnippets: true });
if (snippets.length === 0) {
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,13 @@ export class InsertSnippetAction extends SnippetEditorAction {

if (name) {
// take selected snippet
snippetService.getSnippets(languageId, { includeNoPrefixSnippets: true })
snippetService.getSnippets(languageId, undefined, { includeNoPrefixSnippets: true })
.then(snippets => snippets.find(snippet => snippet.name === name))
.then(resolve, reject);

} else {
// let user pick a snippet
resolve(instaService.invokeFunction(pickSnippet, languageId));
resolve(instaService.invokeFunction(pickSnippet, languageId, editor.getModel().uri));
}
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export async function getSurroundableSnippets(snippetsService: ISnippetsService,
model.tokenization.tokenizeIfCheap(lineNumber);
const languageId = model.getLanguageIdAtPosition(lineNumber, column);

const allSnippets = await snippetsService.getSnippets(languageId, { includeNoPrefixSnippets: true, includeDisabledSnippets });
const allSnippets = await snippetsService.getSnippets(languageId, model.uri, { includeNoPrefixSnippets: true, includeDisabledSnippets });
return allSnippets.filter(snippet => snippet.usesSelection);
}

Expand Down Expand Up @@ -54,12 +54,13 @@ export class SurroundWithSnippetEditorAction extends SnippetEditorAction {
const snippetsService = accessor.get(ISnippetsService);
const clipboardService = accessor.get(IClipboardService);

const snippets = await getSurroundableSnippets(snippetsService, editor.getModel(), editor.getPosition(), true);
const model = editor.getModel();
const snippets = await getSurroundableSnippets(snippetsService, model, editor.getPosition(), true);
if (!snippets.length) {
return;
}

const snippet = await instaService.invokeFunction(pickSnippet, snippets);
const snippet = await instaService.invokeFunction(pickSnippet, snippets, model.uri);
if (!snippet) {
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ class FileTemplateCodeActionProvider implements CodeActionProvider {
return undefined;
}

const snippets = await this._snippetService.getSnippets(model.getLanguageId(), { fileTemplateSnippets: true, includeNoPrefixSnippets: true });
const snippets = await this._snippetService.getSnippets(model.getLanguageId(), model.uri, { fileTemplateSnippets: true, includeNoPrefixSnippets: true });
const actions: CodeAction[] = [];
for (const snippet of snippets) {
if (actions.length >= FileTemplateCodeActionProvider._MAX_CODE_ACTIONS) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export class SnippetCompletionProvider implements CompletionItemProvider {
const triggerCharacterLow = context.triggerCharacter?.toLowerCase() ?? '';
const languageId = this._getLanguageIdAtPosition(model, position);
const languageConfig = this._languageConfigurationService.getLanguageConfiguration(languageId);
const snippets = new Set(await this._snippets.getSnippets(languageId));
const snippets = new Set(await this._snippets.getSnippets(languageId, model.uri));
const suggestions: SnippetCompletion[] = [];

for (const snippet of snippets) {
Expand Down
5 changes: 3 additions & 2 deletions src/vs/workbench/contrib/snippets/browser/snippetPicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ import { ThemeIcon } from '../../../../base/common/themables.js';
import { Event } from '../../../../base/common/event.js';
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
import { DisposableStore } from '../../../../base/common/lifecycle.js';
import { URI } from '../../../../base/common/uri.js';

export async function pickSnippet(accessor: ServicesAccessor, languageIdOrSnippets: string | Snippet[]): Promise<Snippet | undefined> {
export async function pickSnippet(accessor: ServicesAccessor, languageIdOrSnippets: string | Snippet[], resourceUri?: URI): Promise<Snippet | undefined> {

const snippetService = accessor.get(ISnippetsService);
const quickInputService = accessor.get(IQuickInputService);
Expand All @@ -26,7 +27,7 @@ export async function pickSnippet(accessor: ServicesAccessor, languageIdOrSnippe
if (Array.isArray(languageIdOrSnippets)) {
snippets = languageIdOrSnippets;
} else {
snippets = (await snippetService.getSnippets(languageIdOrSnippets, { includeDisabledSnippets: true, includeNoPrefixSnippets: true }));
snippets = (await snippetService.getSnippets(languageIdOrSnippets, resourceUri, { includeDisabledSnippets: true, includeNoPrefixSnippets: true }));
}

snippets.sort((a, b) => a.snippetSource - b.snippetSource);
Expand Down
14 changes: 14 additions & 0 deletions src/vs/workbench/contrib/snippets/browser/snippets.contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,20 @@ const snippetSchemaProperties: IJSONSchemaMap = {
description: {
description: nls.localize('snippetSchema.json.description', 'The snippet description.'),
type: ['string', 'array']
},
include: {
markdownDescription: nls.localize('snippetSchema.json.include', 'A list of glob patterns to include the snippet for specific files, e.g. `["**/*.test.ts", "*.spec.ts"]` or `"**/*.spec.ts"`.'),
type: ['string', 'array'],
items: {
type: 'string'
}
},
exclude: {
markdownDescription: nls.localize('snippetSchema.json.exclude', 'A list of glob patterns to exclude the snippet from specific files, e.g. `["**/*.min.js"]` or `"*.min.js"`.'),
type: ['string', 'array'],
items: {
type: 'string'
}
}
};

Expand Down
5 changes: 3 additions & 2 deletions src/vs/workbench/contrib/snippets/browser/snippets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { SnippetFile, Snippet } from './snippetsFile.js';
import { URI } from '../../../../base/common/uri.js';

export const ISnippetsService = createDecorator<ISnippetsService>('snippetService');

Expand All @@ -27,7 +28,7 @@ export interface ISnippetsService {

updateUsageTimestamp(snippet: Snippet): void;

getSnippets(languageId: string | undefined, opt?: ISnippetGetOptions): Promise<Snippet[]>;
getSnippets(languageId: string | undefined, resourceUri?: URI, opt?: ISnippetGetOptions): Promise<Snippet[]>;

getSnippetsSync(languageId: string, opt?: ISnippetGetOptions): Snippet[];
getSnippetsSync(languageId: string, resourceUri?: URI, opt?: ISnippetGetOptions): Snippet[];
}
56 changes: 55 additions & 1 deletion src/vs/workbench/contrib/snippets/browser/snippetsFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { relativePath } from '../../../../base/common/resources.js';
import { isObject } from '../../../../base/common/types.js';
import { Iterable } from '../../../../base/common/iterator.js';
import { WindowIdleValue, getActiveWindow } from '../../../../base/browser/dom.js';
import { match as matchGlob } from '../../../../base/common/glob.js';
import { Schemas } from '../../../../base/common/network.js';

class SnippetBodyInsights {

Expand Down Expand Up @@ -113,6 +115,8 @@ export class Snippet {
readonly source: string,
readonly snippetSource: SnippetSource,
readonly snippetIdentifier: string,
readonly include?: string[],
readonly exclude?: string[],
readonly extensionId?: ExtensionIdentifier,
) {
this.prefixLow = prefix.toLowerCase();
Expand All @@ -138,6 +142,34 @@ export class Snippet {
get usesSelection(): boolean {
return this._bodyInsights.value.usesSelectionVariable;
}

isFileIncluded(resourceUri: URI): boolean {
const uriPath = resourceUri.scheme === Schemas.file ? resourceUri.fsPath : resourceUri.path;
const fileName = basename(uriPath);

const getMatchTarget = (pattern: string): string => {
return pattern.includes('/') ? uriPath : fileName;
};

if (this.exclude) {
for (const pattern of this.exclude.filter(Boolean)) {
if (matchGlob(pattern, getMatchTarget(pattern), { ignoreCase: true })) {
return false;
}
}
}

if (this.include) {
for (const pattern of this.include.filter(Boolean)) {
if (matchGlob(pattern, getMatchTarget(pattern), { ignoreCase: true })) {
return true;
}
}
return false;
}

return true;
}
}


Expand All @@ -147,6 +179,8 @@ interface JsonSerializedSnippet {
scope?: string;
prefix: string | string[] | undefined;
description: string;
include?: string | string[];
exclude?: string | string[];
}

function isJsonSerializedSnippet(thing: unknown): thing is JsonSerializedSnippet {
Expand Down Expand Up @@ -192,7 +226,7 @@ export class SnippetFile {
}

private _filepathSelect(selector: string, bucket: Snippet[]): void {
// for `fooLang.json` files all snippets are accepted
// for `fooLang.json` files apply inclusion/exclusion rules only
if (selector + '.json' === basename(this.location.path)) {
bucket.push(...this.data);
}
Expand Down Expand Up @@ -286,6 +320,24 @@ export class SnippetFile {
scopes = [];
}

let include: string[] | undefined;
if (snippet.include) {
if (Array.isArray(snippet.include)) {
include = snippet.include;
} else if (typeof snippet.include === 'string') {
include = [snippet.include];
}
}

let exclude: string[] | undefined;
if (snippet.exclude) {
if (Array.isArray(snippet.exclude)) {
exclude = snippet.exclude;
} else if (typeof snippet.exclude === 'string') {
exclude = [snippet.exclude];
}
}

let source: string;
if (this._extension) {
// extension snippet -> show the name of the extension
Expand Down Expand Up @@ -314,6 +366,8 @@ export class SnippetFile {
source,
this.source,
this._extension ? `${relativePath(this._extension.extensionLocation, this.location)}/${name}` : `${basename(this.location.path)}/${name}`,
include,
exclude,
this._extension?.identifier,
));
}
Expand Down
14 changes: 9 additions & 5 deletions src/vs/workbench/contrib/snippets/browser/snippetsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ export class SnippetsService implements ISnippetsService {
return this._files.values();
}

async getSnippets(languageId: string | undefined, opts?: ISnippetGetOptions): Promise<Snippet[]> {
async getSnippets(languageId: string | undefined, resourceUri?: URI, opts?: ISnippetGetOptions): Promise<Snippet[]> {
await this._joinSnippets();

const result: Snippet[] = [];
Expand All @@ -289,10 +289,10 @@ export class SnippetsService implements ISnippetsService {
}
}
await Promise.all(promises);
return this._filterAndSortSnippets(result, opts);
return this._filterAndSortSnippets(result, resourceUri, opts);
}

getSnippetsSync(languageId: string, opts?: ISnippetGetOptions): Snippet[] {
getSnippetsSync(languageId: string, resourceUri?: URI, opts?: ISnippetGetOptions): Snippet[] {
const result: Snippet[] = [];
if (this._languageService.isRegisteredLanguageId(languageId)) {
for (const file of this._files.values()) {
Expand All @@ -302,10 +302,10 @@ export class SnippetsService implements ISnippetsService {
file.select(languageId, result);
}
}
return this._filterAndSortSnippets(result, opts);
return this._filterAndSortSnippets(result, resourceUri, opts);
}

private _filterAndSortSnippets(snippets: Snippet[], opts?: ISnippetGetOptions): Snippet[] {
private _filterAndSortSnippets(snippets: Snippet[], resourceUri?: URI, opts?: ISnippetGetOptions): Snippet[] {

const result: Snippet[] = [];

Expand All @@ -322,6 +322,10 @@ export class SnippetsService implements ISnippetsService {
// isTopLevel requested but mismatching
continue;
}
if (resourceUri && !snippet.isFileIncluded(resourceUri)) {
// include/exclude settings don't match
continue;
}
result.push(snippet);
}

Expand Down
2 changes: 1 addition & 1 deletion src/vs/workbench/contrib/snippets/browser/tabCompletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export class TabCompletionController implements IEditorContribution {
const model = this._editor.getModel();
model.tokenization.tokenizeIfCheap(selection.positionLineNumber);
const id = model.getLanguageIdAtPosition(selection.positionLineNumber, selection.positionColumn);
const snippets = this._snippetService.getSnippetsSync(id);
const snippets = this._snippetService.getSnippetsSync(id, model.uri);

if (!snippets) {
// nothing for this language
Expand Down
Loading
Loading