Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions src/htmlLanguageTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export interface HtmlAttributeValueContext {
attribute: string;
value: string;
range: Range;
attributes?: { [name: string]: string | null };
}

export interface HtmlContentContext {
Expand Down
19 changes: 9 additions & 10 deletions src/services/htmlCompletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,18 +304,17 @@ export class HTMLCompletion {
addQuotes = true;
}

if (completionParticipants.length > 0) {
const tag = currentTag.toLowerCase();
const attribute = currentAttributeName.toLowerCase();
const fullRange = getReplaceRange(valueStart, valueEnd);
for (const participant of completionParticipants) {
if (participant.onHtmlAttributeValue) {
participant.onHtmlAttributeValue({ document, position, tag, attribute, value: valuePrefix, range: fullRange });
}
if (completionParticipants.length > 0) {
const tag = currentTag.toLowerCase();
const attribute = currentAttributeName.toLowerCase();
const fullRange = getReplaceRange(valueStart, valueEnd);
for (const participant of completionParticipants) {
if (participant.onHtmlAttributeValue) {
participant.onHtmlAttributeValue({ document, position, tag, attribute, value: valuePrefix, range: fullRange, attributes: node.attributes });
}
}

dataProviders.forEach(provider => {
}
dataProviders.forEach(provider => {
provider.provideValues(currentTag, currentAttributeName).forEach(value => {
const insertText = addQuotes ? '"' + value.name + '"' : value.name;

Expand Down
78 changes: 75 additions & 3 deletions src/services/pathCompletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export class PathCompletionParticipant implements ICompletionParticipant {
result.isIncomplete = true;
} else {
const replaceRange = pathToReplaceRange(attributeCompletion.value, fullValue, attributeCompletion.range);
const suggestions = await this.providePathSuggestions(attributeCompletion.value, replaceRange, document, documentContext);
const suggestions = await this.providePathSuggestions(attributeCompletion.value, replaceRange, document, documentContext, attributeCompletion);
for (const item of suggestions) {
result.items.push(item);
}
Expand All @@ -38,18 +38,45 @@ export class PathCompletionParticipant implements ICompletionParticipant {
return result;
}

private async providePathSuggestions(valueBeforeCursor: string, replaceRange: Range, document: TextDocument, documentContext: DocumentContext) {
private async providePathSuggestions(valueBeforeCursor: string, replaceRange: Range, document: TextDocument, documentContext: DocumentContext, context?: HtmlAttributeValueContext) {
const valueBeforeLastSlash = valueBeforeCursor.substring(0, valueBeforeCursor.lastIndexOf('/') + 1); // keep the last slash

let parentDir = documentContext.resolveReference(valueBeforeLastSlash || '.', document.uri);
if (parentDir) {
try {
const result: CompletionItem[] = [];
const infos = await this.readDirectory(parentDir);

// Determine file extensions to prioritize/filter based on tag and attributes
const extensionFilter = this.getExtensionFilter(context);

for (const [name, type] of infos) {
// Exclude paths that start with `.`
if (name.charCodeAt(0) !== CharCode_dot) {
result.push(createCompletionItem(name, type === FileType.Directory, replaceRange));
const item = createCompletionItem(name, type === FileType.Directory, replaceRange);

// Apply filtering/sorting based on file extension
if (extensionFilter) {
if (type === FileType.Directory) {
// Always include directories
result.push(item);
} else {
// For files, check if they match the filter
const matchesFilter = extensionFilter.extensions.some(ext => name.toLowerCase().endsWith(ext));
if (matchesFilter) {
// Add matching files with higher sort priority
item.sortText = '0_' + name;
result.push(item);
} else if (!extensionFilter.exclusive) {
// Add non-matching files with lower sort priority if not exclusive
item.sortText = '1_' + name;
result.push(item);
}
// If exclusive and doesn't match, don't add the file
}
} else {
result.push(item);
}
}
}
return result;
Expand All @@ -60,6 +87,51 @@ export class PathCompletionParticipant implements ICompletionParticipant {
return [];
}

/**
* Determines which file extensions to filter/prioritize based on the HTML tag and attributes
*/
private getExtensionFilter(context?: HtmlAttributeValueContext): { extensions: string[], exclusive: boolean } | undefined {
if (!context) {
return undefined;
}

// Handle <link> tag with rel="stylesheet"
if (context.tag === 'link' && context.attribute === 'href' && context.attributes) {
const rel = context.attributes['rel'];
if (rel === 'stylesheet' || rel === '"stylesheet"' || rel === "'stylesheet'") {
// Filter to CSS files for stylesheets
return { extensions: ['.css', '.scss', '.sass', '.less'], exclusive: false };
}
if (rel === 'icon' || rel === '"icon"' || rel === "'icon'" ||
rel === 'apple-touch-icon' || rel === '"apple-touch-icon"' || rel === "'apple-touch-icon'") {
// Filter to image files for icons
return { extensions: ['.ico', '.png', '.svg', '.jpg', '.jpeg', '.gif', '.webp'], exclusive: false };
}
}

// Handle <script> tag with src attribute - prioritize JS files
if (context.tag === 'script' && context.attribute === 'src') {
return { extensions: ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'], exclusive: false };
}

// Handle <img> tag with src attribute - prioritize image files
if (context.tag === 'img' && context.attribute === 'src') {
return { extensions: ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico'], exclusive: false };
}

// Handle <video> tag with src attribute - prioritize video files
if (context.tag === 'video' && context.attribute === 'src') {
return { extensions: ['.mp4', '.webm', '.ogg', '.mov', '.avi'], exclusive: false };
}

// Handle <audio> tag with src attribute - prioritize audio files
if (context.tag === 'audio' && context.attribute === 'src') {
return { extensions: ['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac'], exclusive: false };
}

return undefined;
}

}

const CharCode_dot = '.'.charCodeAt(0);
Expand Down
4 changes: 4 additions & 0 deletions src/test/pathCompletionFixtures/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/* Main stylesheet */
body {
font-family: Arial, sans-serif;
}
74 changes: 73 additions & 1 deletion src/test/pathCompletions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ suite('HTML Path Completion', () => {

test('Completion should ignore files/folders starting with dot', async () => {
await testCompletion2For('<script src="./|"', {
count: 3
count: 4 // about/, index.html, src/, styles.css (excludes .foo.js)
}, indexHtmlUri, workspaceFolderUri);
});

Expand Down Expand Up @@ -289,4 +289,76 @@ suite('HTML Path Completion', () => {
]
}, aboutHtmlUri);
});

test('Filter CSS files for <link rel="stylesheet"> tag', async () => {
// Test filtering for stylesheet in about/ directory - CSS files should be prioritized
await testCompletion2For('<link rel="stylesheet" href="|">', {
items: [
{ label: 'about.css', kind: CompletionItemKind.File, resultText: '<link rel="stylesheet" href="about.css">' },
{ label: 'about.html', kind: CompletionItemKind.File, resultText: '<link rel="stylesheet" href="about.html">' },
{ label: 'media/', kind: CompletionItemKind.Folder, resultText: '<link rel="stylesheet" href="media/">', command: triggerSuggestCommand }
]
}, aboutHtmlUri);

// Test with double quotes in root directory
await testCompletion2For('<link rel="stylesheet" href="./|">', {
items: [
{ label: 'about/', kind: CompletionItemKind.Folder, resultText: '<link rel="stylesheet" href="./about/">', command: triggerSuggestCommand },
{ label: 'styles.css', kind: CompletionItemKind.File, resultText: '<link rel="stylesheet" href="./styles.css">' },
{ label: 'index.html', kind: CompletionItemKind.File, resultText: '<link rel="stylesheet" href="./index.html">' },
{ label: 'src/', kind: CompletionItemKind.Folder, resultText: '<link rel="stylesheet" href="./src/">', command: triggerSuggestCommand }
]
}, indexHtmlUri);

// Test with single quotes in rel attribute
await testCompletion2For(`<link rel='stylesheet' href="./|">`, {
items: [
{ label: 'about/', kind: CompletionItemKind.Folder, resultText: `<link rel='stylesheet' href="./about/">`, command: triggerSuggestCommand },
{ label: 'styles.css', kind: CompletionItemKind.File, resultText: `<link rel='stylesheet' href="./styles.css">` },
{ label: 'index.html', kind: CompletionItemKind.File, resultText: `<link rel='stylesheet' href="./index.html">` },
{ label: 'src/', kind: CompletionItemKind.Folder, resultText: `<link rel='stylesheet' href="./src/">`, command: triggerSuggestCommand }
]
}, indexHtmlUri);
});

test('Prioritize script files for <script> tag', async () => {
// JavaScript files should be prioritized for script tags
await testCompletion2For('<script src="./|">', {
items: [
{ label: 'about/', kind: CompletionItemKind.Folder, resultText: '<script src="./about/">', command: triggerSuggestCommand },
{ label: 'index.html', kind: CompletionItemKind.File, resultText: '<script src="./index.html">' },
{ label: 'src/', kind: CompletionItemKind.Folder, resultText: '<script src="./src/">', command: triggerSuggestCommand }
]
}, indexHtmlUri);

await testCompletion2For('<script src="./src/|">', {
items: [
{ label: 'feature.js', kind: CompletionItemKind.File, resultText: '<script src="./src/feature.js">' },
{ label: 'test.js', kind: CompletionItemKind.File, resultText: '<script src="./src/test.js">' }
]
}, indexHtmlUri);
});

test('No filtering for <link> tag without rel attribute', async () => {
// Without rel attribute, no filtering should occur
await testCompletion2For('<link href="./|">', {
items: [
{ label: 'about/', kind: CompletionItemKind.Folder, resultText: '<link href="./about/">', command: triggerSuggestCommand },
{ label: 'index.html', kind: CompletionItemKind.File, resultText: '<link href="./index.html">' },
{ label: 'src/', kind: CompletionItemKind.Folder, resultText: '<link href="./src/">', command: triggerSuggestCommand },
{ label: 'styles.css', kind: CompletionItemKind.File, resultText: '<link href="./styles.css">' }
]
}, indexHtmlUri);
});

test('Prioritize image files for <link rel="icon">', async () => {
// For rel="icon", we expect image file filtering (but media/ contains icon.pic which matches)
await testCompletion2For('<link rel="icon" href="|">', {
items: [
{ label: 'about.css', kind: CompletionItemKind.File, resultText: '<link rel="icon" href="about.css">' },
{ label: 'about.html', kind: CompletionItemKind.File, resultText: '<link rel="icon" href="about.html">' },
{ label: 'media/', kind: CompletionItemKind.Folder, resultText: '<link rel="icon" href="media/">', command: triggerSuggestCommand }
]
}, aboutHtmlUri);
});
});