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
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// *****************************************************************************
// Copyright (C) 2026 Safi Seid-Ahmad, K2view and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************

import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
let disableJSDOM = enableJSDOM();

import * as chai from 'chai';
import URI from '@theia/core/lib/common/uri';
import { CustomEditorOpener } from './custom-editor-opener';
import { CustomEditor, CustomEditorPriority } from '../../../common';

disableJSDOM();

const expect = chai.expect;

describe('CustomEditorOpener#selectorMatches', () => {

before(() => {
disableJSDOM = enableJSDOM();
});

after(() => {
disableJSDOM();
});

// Only selector matching is exercised — none of the collaborators are touched.
function createOpener(filenamePattern: string): CustomEditorOpener {
const editor: CustomEditor = {
viewType: 'test.editor',
displayName: 'Test Editor',
selector: [{ filenamePattern }],
priority: CustomEditorPriority.default
};
return new CustomEditorOpener(editor, undefined!, undefined!, undefined!, undefined!);
}

function matches(filenamePattern: string, path: string): boolean {
const opener = createOpener(filenamePattern);
return opener.matches([{ filenamePattern }], new URI(`file://${path}`));
}

function matchesUri(filenamePattern: string, uri: string): boolean {
const opener = createOpener(filenamePattern);
return opener.matches([{ filenamePattern }], new URI(uri));
}

it('matches a plain extension pattern against the basename', () => {
expect(matches('*.custom', '/project/src/file.custom')).true;
expect(matches('*.custom', '/project/src/file.other')).false;
});

it('matches a plain filename pattern against the basename anywhere', () => {
expect(matches('config.json', '/anywhere/at/all/config.json')).true;
expect(matches('config.json', '/anywhere/at/all/other.json')).false;
});

it('matches case-insensitively', () => {
expect(matches('*.CUSTOM', '/project/file.custom')).true;
expect(matches('*.custom', '/project/FILE.CUSTOM')).true;
});

it('matches a pattern containing a path separator against the full path (VS Code parity)', () => {
const pattern = '**/components/*/config.json';
expect(matches(pattern, '/project/src/components/button/config.json')).true;
expect(matches(pattern, '/project/elsewhere/config.json')).false;
expect(matches(pattern, '/project/src/components/button/nested/config.json')).false;
});

it('matches nested path patterns', () => {
const pattern = '**/components/*/styles/*.css';
expect(matches(pattern, '/project/src/components/button/styles/theme.css')).true;
expect(matches(pattern, '/project/src/components/button/config.json')).false;
expect(matches(pattern, '/project/src/components/button/styles/dark/theme.css')).false;
});

it('does not let a path pattern match a bare basename', () => {
expect(matches('**/components/*/config.json', '/config.json')).false;
});

it('never matches the excluded schemes, mirroring VS Code (globMatchesResource)', () => {
// Patterns that would match the basename or full path on a `file:` resource...
expect(matchesUri('*.json', 'file:///project/settings.json')).true;
expect(matchesUri('**/settings.json', 'file:///project/settings.json')).true;
// ...must not match on VS Code's internal schemes.
expect(matchesUri('*.json', 'vscode-settings:/settings.json')).false;
expect(matchesUri('**/settings.json', 'vscode-settings:/project/settings.json')).false;
expect(matchesUri('*.json', 'webview-panel:/panel/config.json')).false;
expect(matchesUri('*', 'extension:/some/resource')).false;
expect(matchesUri('*', 'vscode-workspace-trust:/trust')).false;
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ import { PluginCustomEditorRegistry } from './plugin-custom-editor-registry';
import { generateUuid } from '@theia/core/lib/common/uuid';
import { DisposableCollection, Emitter, PreferenceService } from '@theia/core';
import { match } from '@theia/core/lib/common/glob';
import { Schemes } from '../../../common/uri-components';

/**
* Schemes that never match a custom editor's `filenamePattern`, mirroring the
* excluded schemes in VS Code's `editorResolverService#globMatchesResource`.
* Theia has no scheme constants for `extension` and `vscode-workspace-trust`,
* so those are referenced by their literal values.
*/
const nonMatchingSchemes = new Set<string>([
'extension',
Schemes.webviewPanel,
'vscode-workspace-trust',
Schemes.vscodeSettings
]);

export class CustomEditorOpener implements OpenHandler {

Expand Down Expand Up @@ -200,8 +214,19 @@ export class CustomEditorOpener implements OpenHandler {
}

selectorMatches(selector: CustomEditorSelector, resource: URI): boolean {
if (nonMatchingSchemes.has(resource.scheme)) {
// These schemes match no glob pattern, mirroring VS Code's `globMatchesResource`.
return false;
}
if (selector.filenamePattern) {
if (match(selector.filenamePattern.toLowerCase(), resource.path.name.toLowerCase() + resource.path.ext.toLowerCase())) {
const filenamePattern = selector.filenamePattern.toLowerCase();
// Mirror VS Code's editor-association matching (editorResolverService#globMatchesResource):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The VSCode implementation never matches patterns that use schemes for extensions, settings, etc as defined in the following. Was it on purpose to deviate from the VSCode behavior?

https://github.com/microsoft/vscode/blob/190016f57611359bf4bafe2395ce27c074a6317d/src/vs/workbench/services/editor/common/editorResolverService.ts#L272-L276

@safisa safisa Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, not intentional, thanks. I've added the excluded-schemes short-circuit to mirror globMatchesResource, so extension, webview-panel, vscode-workspace-trust, and vscode-settings resources match no pattern. Theia only defines Schemes constants for vscode-settings and webview-panel, so the other two are referenced by their literal scheme strings. Added a test covering it.

// a pattern containing a path separator is matched against the full `scheme:path`,
// a plain pattern is matched against the basename only.
const target = filenamePattern.includes('/')
? `${resource.scheme}:${resource.path.toString()}`.toLowerCase()
: resource.path.base.toLowerCase();
if (match(filenamePattern, target)) {
return true;
}
}
Expand Down
Loading