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
27 changes: 27 additions & 0 deletions src/vs/base/browser/ui/iconLabel/iconLabel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface IIconLabelValueOptions {
suffix?: string;
hideIcon?: boolean;
extraClasses?: readonly string[];
extraAttributes?: Readonly<Record<string, string>>;
bold?: boolean;
italic?: boolean;
strikethrough?: boolean;
Expand All @@ -50,6 +51,7 @@ class FastLabelNode {
private _textContent: string | undefined;
private _classNames: string[] | undefined;
private _empty: boolean | undefined;
private _attributes: Readonly<Record<string, string>> | undefined;

constructor(private _element: HTMLElement) {
}
Expand Down Expand Up @@ -86,6 +88,30 @@ class FastLabelNode {
this._element.style.marginLeft = empty ? '0' : '';
}

setAttributes(attributes: Readonly<Record<string, string>> | undefined): void {
if (this.disposed || equals(attributes, this._attributes)) {
return;
}

// Remove previous attributes that are no longer present
if (this._attributes) {
for (const key of Object.keys(this._attributes)) {
if (!attributes || !Object.prototype.hasOwnProperty.call(attributes, key)) {
this._element.removeAttribute(key);
}
}
}

// Set new attributes
if (attributes) {
for (const [key, value] of Object.entries(attributes)) {
this._element.setAttribute(key, value);
}
}

this._attributes = attributes;
}

dispose(): void {
this.disposed = true;
}
Expand Down Expand Up @@ -190,6 +216,7 @@ export class IconLabel extends Disposable {

this.domNode.classNames = labelClasses;
this.domNode.element.setAttribute('aria-label', ariaLabel);
this.domNode.setAttributes(options?.extraAttributes);
this.labelContainer.classList.value = '';
this.labelContainer.classList.add(...containerClasses);
this.setupHover(options?.descriptionTitle ? this.labelContainer : this.element, options?.title);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,27 @@ import { ThemeIcon } from '../../../base/common/themables.js';

const fileIconDirectoryRegex = /(?:\/|^)(?:([^\/]+)\/)?([^\/]+)$/;

export function getIconClasses(modelService: IModelService, languageService: ILanguageService, resource: uri | undefined, fileKind?: FileKind, icon?: ThemeIcon | URI): string[] {
/**
* Structured result from icon class resolution, carrying both CSS classes
* and optional data-attributes for CSS attribute selector matching (used by glob patterns in icon themes).
*/
export interface FileIconInfo {
readonly classes: string[];
readonly attributes?: Readonly<Record<string, string>>;
}

export function getFileIconInfo(modelService: IModelService, languageService: ILanguageService, resource: uri | undefined, fileKind?: FileKind, icon?: ThemeIcon | URI): FileIconInfo {
if (ThemeIcon.isThemeIcon(icon)) {
return [`codicon-${icon.id}`, 'predefined-file-icon'];
return { classes: [`codicon-${icon.id}`, 'predefined-file-icon'] };
}

if (URI.isUri(icon)) {
return [];
return { classes: [] };
}

// we always set these base classes even if we do not have a path
const classes = fileKind === FileKind.ROOT_FOLDER ? ['rootfolder-icon'] : fileKind === FileKind.FOLDER ? ['folder-icon'] : ['file-icon'];
const attributes: Record<string, string> = {};
if (resource) {

// Get the path and name of the resource. For data-URIs, we need to parse specially
Expand All @@ -48,11 +58,17 @@ export function getIconClasses(modelService: IModelService, languageService: ILa
// Root Folders
if (fileKind === FileKind.ROOT_FOLDER) {
classes.push(`${name}-root-name-folder-icon`);
if (name) {
attributes['data-folder-name'] = name;
}
}

// Folders
else if (fileKind === FileKind.FOLDER) {
classes.push(`${name}-name-folder-icon`);
if (name) {
attributes['data-folder-name'] = name;
}
}

// Files
Expand All @@ -72,6 +88,13 @@ export function getIconClasses(modelService: IModelService, languageService: ILa
}
}
classes.push(`ext-file-icon`); // extra segment to increase file-ext score

// data-attributes for glob-based icon theme matching
attributes['data-file-name'] = name;
const lastDot = name.lastIndexOf('.');
if (lastDot > 0) {
attributes['data-file-ext'] = name.substring(lastDot + 1);
}
}

// Detected Mode
Expand All @@ -81,11 +104,11 @@ export function getIconClasses(modelService: IModelService, languageService: ILa
}
}
}
return classes;
return { classes, attributes: Object.keys(attributes).length > 0 ? attributes : undefined };
}

export function getIconClassesForLanguageId(languageId: string): string[] {
return ['file-icon', `${fileIconSelectorEscape(languageId)}-lang-file-icon`];
export function getFileIconInfoForLanguageId(languageId: string): FileIconInfo {
return { classes: ['file-icon', `${fileIconSelectorEscape(languageId)}-lang-file-icon`] };
}

function detectLanguageId(modelService: IModelService, languageService: ILanguageService, resource: uri): string | null {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import { InlineCompletionEditorType } from '../../../../model/provideInlineCompl
import { basename } from '../../../../../../../../base/common/resources.js';
import { IModelService } from '../../../../../../../common/services/model.js';
import { ILanguageService } from '../../../../../../../common/languages/language.js';
import { getIconClasses } from '../../../../../../../common/services/getIconClasses.js';
import { getFileIconInfo } from '../../../../../../../common/services/getFileIconInfo.js';
import { FileKind } from '../../../../../../../../platform/files/common/files.js';
import { TextModelValueReference } from '../../../../model/textModelValueReference.js';

Expand Down Expand Up @@ -420,7 +420,7 @@ export class InlineEditsLongDistanceHint extends Disposable implements IInlineEd
if (isCrossFileEdit) {
// For cross-file edits, show target filename instead of outline
const fileName = basename(targetUri);
const iconClasses = getIconClasses(this._modelService, this._languageService, targetUri, FileKind.FILE);
const iconClasses = getFileIconInfo(this._modelService, this._languageService, targetUri, FileKind.FILE).classes;
children.push(n.div({
class: 'target-file',
style: { display: 'flex', alignItems: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
Expand Down
10 changes: 5 additions & 5 deletions src/vs/editor/contrib/suggest/browser/suggestWidgetRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { URI } from '../../../../base/common/uri.js';
import { ICodeEditor } from '../../../browser/editorBrowser.js';
import { EditorOption } from '../../../common/config/editorOptions.js';
import { CompletionItemKind, CompletionItemKinds, CompletionItemTag } from '../../../common/languages.js';
import { getIconClasses } from '../../../common/services/getIconClasses.js';
import { getFileIconInfo } from '../../../common/services/getFileIconInfo.js';
import { IModelService } from '../../../common/services/model.js';
import { ILanguageService } from '../../../common/languages/language.js';
import * as nls from '../../../../nls.js';
Expand Down Expand Up @@ -183,17 +183,17 @@ export class ItemRenderer implements IListRenderer<CompletionItem, ISuggestionTe
// special logic for 'file' completion items
data.icon.className = 'icon hide';
data.iconContainer.className = 'icon hide';
const labelClasses = getIconClasses(this._modelService, this._languageService, URI.from({ scheme: 'fake', path: element.textLabel }), FileKind.FILE);
const detailClasses = getIconClasses(this._modelService, this._languageService, URI.from({ scheme: 'fake', path: completion.detail }), FileKind.FILE);
const labelClasses = getFileIconInfo(this._modelService, this._languageService, URI.from({ scheme: 'fake', path: element.textLabel }), FileKind.FILE).classes;
const detailClasses = getFileIconInfo(this._modelService, this._languageService, URI.from({ scheme: 'fake', path: completion.detail }), FileKind.FILE).classes;
labelOptions.extraClasses = labelClasses.length > detailClasses.length ? labelClasses : detailClasses;

} else if (completion.kind === CompletionItemKind.Folder && this._themeService.getFileIconTheme().hasFolderIcons) {
// special logic for 'folder' completion items
data.icon.className = 'icon hide';
data.iconContainer.className = 'icon hide';
labelOptions.extraClasses = [
getIconClasses(this._modelService, this._languageService, URI.from({ scheme: 'fake', path: element.textLabel }), FileKind.FOLDER),
getIconClasses(this._modelService, this._languageService, URI.from({ scheme: 'fake', path: completion.detail }), FileKind.FOLDER)
getFileIconInfo(this._modelService, this._languageService, URI.from({ scheme: 'fake', path: element.textLabel }), FileKind.FOLDER).classes,
getFileIconInfo(this._modelService, this._languageService, URI.from({ scheme: 'fake', path: completion.detail }), FileKind.FOLDER).classes
].flat();
} else {
// normal icon
Expand Down
127 changes: 127 additions & 0 deletions src/vs/editor/test/common/services/getFileIconInfo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import assert from 'assert';
import { URI } from '../../../../base/common/uri.js';
import { mock } from '../../../../base/test/common/mock.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { getFileIconInfo, getFileIconInfoForLanguageId } from '../../../common/services/getFileIconInfo.js';
import { FileKind } from '../../../../platform/files/common/files.js';
import { ILanguageService } from '../../../common/languages/language.js';
import { IModelService } from '../../../common/services/model.js';

suite('getFileIconInfo', () => {

ensureNoDisposablesAreLeakedInTestSuite();

const modelService = new class extends mock<IModelService>() {
override getModel() { return null; }
};
const languageService = new class extends mock<ILanguageService>() {
override getLanguageIdByMimeType(): null { return null; }
override guessLanguageIdByFilepathOrFirstLine(): null { return null; }
};

suite('FileIconInfo shape', () => {

test('returns classes and attributes for a file resource', () => {
const result = getFileIconInfo(modelService, languageService, URI.file('/project/src/app.test.ts'), FileKind.FILE);
assert.ok(Array.isArray(result.classes));
assert.ok(result.classes.includes('file-icon'));
assert.ok(result.attributes);
});

test('returns no attributes when resource is undefined', () => {
const result = getFileIconInfo(modelService, languageService, undefined, FileKind.FILE);
assert.ok(result.classes.includes('file-icon'));
assert.strictEqual(result.attributes, undefined);
});
});

suite('data-file-name attribute', () => {

test('sets data-file-name for regular files', () => {
const result = getFileIconInfo(modelService, languageService, URI.file('/project/app.ts'), FileKind.FILE);
assert.strictEqual(result.attributes?.['data-file-name'], 'app.ts');
});

test('lowercases data-file-name', () => {
const result = getFileIconInfo(modelService, languageService, URI.file('/project/MyComponent.TSX'), FileKind.FILE);
assert.strictEqual(result.attributes?.['data-file-name'], 'mycomponent.tsx');
});

test('sets data-file-name for dotfiles', () => {
const result = getFileIconInfo(modelService, languageService, URI.file('/project/.env.development'), FileKind.FILE);
assert.strictEqual(result.attributes?.['data-file-name'], '.env.development');
});

test('sets data-file-name for files without extension', () => {
const result = getFileIconInfo(modelService, languageService, URI.file('/project/Makefile'), FileKind.FILE);
assert.strictEqual(result.attributes?.['data-file-name'], 'makefile');
});
});

suite('data-file-ext attribute', () => {

test('sets data-file-ext to last extension segment', () => {
const result = getFileIconInfo(modelService, languageService, URI.file('/project/app.test.ts'), FileKind.FILE);
assert.strictEqual(result.attributes?.['data-file-ext'], 'ts');
});

test('omits data-file-ext for files without extension', () => {
const result = getFileIconInfo(modelService, languageService, URI.file('/project/Makefile'), FileKind.FILE);
assert.strictEqual(result.attributes?.['data-file-ext'], undefined);
});

test('omits data-file-ext for bare dotfiles', () => {
const result = getFileIconInfo(modelService, languageService, URI.file('/project/.env'), FileKind.FILE);
assert.strictEqual(result.attributes?.['data-file-ext'], undefined);
});

test('sets data-file-ext for dotfiles with extension', () => {
const result = getFileIconInfo(modelService, languageService, URI.file('/project/.env.local'), FileKind.FILE);
assert.strictEqual(result.attributes?.['data-file-ext'], 'local');
});
});

suite('data-folder-name attribute', () => {

test('sets data-folder-name for folders', () => {
const result = getFileIconInfo(modelService, languageService, URI.file('/project/src'), FileKind.FOLDER);
assert.ok(result.classes.includes('folder-icon'));
assert.strictEqual(result.attributes?.['data-folder-name'], 'src');
});

test('sets data-folder-name for root folders', () => {
const result = getFileIconInfo(modelService, languageService, URI.file('/project'), FileKind.ROOT_FOLDER);
assert.ok(result.classes.includes('rootfolder-icon'));
assert.strictEqual(result.attributes?.['data-folder-name'], 'project');
});

test('omits data-file-name for folders', () => {
const result = getFileIconInfo(modelService, languageService, URI.file('/project/src'), FileKind.FOLDER);
assert.strictEqual(result.attributes?.['data-file-name'], undefined);
});

test('omits data-folder-name for files', () => {
const result = getFileIconInfo(modelService, languageService, URI.file('/project/app.ts'), FileKind.FILE);
assert.strictEqual(result.attributes?.['data-folder-name'], undefined);
});
});

suite('getFileIconInfoForLanguageId', () => {

test('returns FileIconInfo with classes', () => {
const result = getFileIconInfoForLanguageId('typescript');
assert.ok(result.classes.includes('file-icon'));
assert.ok(result.classes.includes('typescript-lang-file-icon'));
});

test('returns no attributes', () => {
const result = getFileIconInfoForLanguageId('typescript');
assert.strictEqual(result.attributes, undefined);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { IModelService } from '../../../../editor/common/services/model.js';
import { ILanguageService } from '../../../../editor/common/languages/language.js';
import { getIconClasses } from '../../../../editor/common/services/getIconClasses.js';
import { getFileIconInfo } from '../../../../editor/common/services/getFileIconInfo.js';
import { basename } from '../../../../base/common/resources.js';
import { Schemas } from '../../../../base/common/network.js';
import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../../../workbench/browser/labels.js';
Expand Down Expand Up @@ -430,7 +430,7 @@ export class NewChatContextAttachments extends Disposable {
return searchResult.results.map(result => ({
label: basename(result.resource),
description: this.labelService.getUriLabel(result.resource, { relative: true }),
iconClasses: getIconClasses(this.modelService, this.languageService, result.resource, FileKind.FILE),
iconClasses: getFileIconInfo(this.modelService, this.languageService, result.resource, FileKind.FILE).classes,
id: result.resource.toString(),
} satisfies IQuickPickItem));
} catch {
Expand Down Expand Up @@ -474,7 +474,7 @@ export class NewChatContextAttachments extends Disposable {
picks.push({
label: child.name,
description: this.labelService.getUriLabel(child.resource, { relative: true }),
iconClasses: getIconClasses(this.modelService, this.languageService, child.resource, FileKind.FILE),
iconClasses: getFileIconInfo(this.modelService, this.languageService, child.resource, FileKind.FILE).classes,
id: child.resource.toString(),
});
}
Expand Down
4 changes: 2 additions & 2 deletions src/vs/workbench/api/browser/mainThreadQuickOpen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { basenameOrAuthority, dirname, hasTrailingPathSeparator } from '../../..
import { ThemeIcon } from '../../../base/common/themables.js';
import { isUriComponents, URI } from '../../../base/common/uri.js';
import { ILanguageService } from '../../../editor/common/languages/language.js';
import { getIconClasses } from '../../../editor/common/services/getIconClasses.js';
import { getFileIconInfo } from '../../../editor/common/services/getFileIconInfo.js';
import { IModelService } from '../../../editor/common/services/model.js';
import { FileKind } from '../../../platform/files/common/files.js';
import { ILabelService } from '../../../platform/label/common/label.js';
Expand Down Expand Up @@ -277,7 +277,7 @@ export class MainThreadQuickOpen implements MainThreadQuickOpenShape {
const icon = item.iconPathDto;
if (ThemeIcon.isThemeIcon(icon) && (ThemeIcon.isFile(icon) || ThemeIcon.isFolder(icon))) {
const fileKind = ThemeIcon.isFolder(icon) || hasTrailingPathSeparator(resourceUri) ? FileKind.FOLDER : FileKind.FILE;
const iconClasses = new Lazy(() => getIconClasses(this.modelService, this.languageService, resourceUri, fileKind));
const iconClasses = new Lazy(() => getFileIconInfo(this.modelService, this.languageService, resourceUri, fileKind).classes);
Object.defineProperty(item, 'iconClasses', { get: () => iconClasses.value });
} else {
this.expandIconPath(item);
Expand Down
8 changes: 4 additions & 4 deletions src/vs/workbench/browser/actions/windowActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { IModelService } from '../../../editor/common/services/model.js';
import { ILanguageService } from '../../../editor/common/languages/language.js';
import { IRecent, isRecentFolder, isRecentWorkspace, IWorkspacesService } from '../../../platform/workspaces/common/workspaces.js';
import { URI } from '../../../base/common/uri.js';
import { getIconClasses } from '../../../editor/common/services/getIconClasses.js';
import { getFileIconInfo } from '../../../editor/common/services/getFileIconInfo.js';
import { FileKind } from '../../../platform/files/common/files.js';
import { splitRecentLabel } from '../../../base/common/labels.js';
import { isMacintosh, isWeb, isWindows } from '../../../base/common/platform.js';
Expand Down Expand Up @@ -227,15 +227,15 @@ abstract class BaseOpenRecentAction extends Action2 {
// Folder
if (isRecentFolder(recent)) {
resource = recent.folderUri;
iconClasses = getIconClasses(modelService, languageService, resource, FileKind.FOLDER);
iconClasses = getFileIconInfo(modelService, languageService, resource, FileKind.FOLDER).classes;
openable = { folderUri: resource };
fullLabel = recent.label || labelService.getWorkspaceLabel(resource, { verbose: Verbosity.LONG });
}

// Workspace
else if (isRecentWorkspace(recent)) {
resource = recent.workspace.configPath;
iconClasses = getIconClasses(modelService, languageService, resource, FileKind.ROOT_FOLDER);
iconClasses = getFileIconInfo(modelService, languageService, resource, FileKind.ROOT_FOLDER).classes;
openable = { workspaceUri: resource };
fullLabel = recent.label || labelService.getWorkspaceLabel(recent.workspace, { verbose: Verbosity.LONG });
isWorkspace = true;
Expand All @@ -244,7 +244,7 @@ abstract class BaseOpenRecentAction extends Action2 {
// File
else {
resource = recent.fileUri;
iconClasses = getIconClasses(modelService, languageService, resource, FileKind.FILE);
iconClasses = getFileIconInfo(modelService, languageService, resource, FileKind.FILE).classes;
openable = { fileUri: resource };
fullLabel = recent.label || labelService.getUriLabel(resource, { appendWorkspaceSuffix: true });
}
Expand Down
Loading