From 914beb22f416e1df281cea154280d881dd71b9e6 Mon Sep 17 00:00:00 2001 From: safi Date: Thu, 11 Jun 2026 14:14:25 +0300 Subject: [PATCH] fix(plugin-ext): match custom editor filenamePattern against full path CustomEditorOpener only tested the basename, so a filenamePattern containing a path separator (e.g. **/components/*/config.json) never matched. Mirror VS Code's editorResolverService#globMatchesResource: a pattern with a / is matched against scheme:path, a plain pattern against the basename only, and the internal schemes it excludes (extension, webview-panel, vscode-workspace-trust, vscode-settings) match no pattern. Add unit tests for selectorMatches. Fixes #16492 --- .../custom-editor-opener.spec.ts | 104 ++++++++++++++++++ .../custom-editors/custom-editor-opener.tsx | 27 ++++- 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 packages/plugin-ext/src/main/browser/custom-editors/custom-editor-opener.spec.ts diff --git a/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-opener.spec.ts b/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-opener.spec.ts new file mode 100644 index 0000000000000..2388947668808 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-opener.spec.ts @@ -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; + }); +}); diff --git a/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-opener.tsx b/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-opener.tsx index 1a82dd81fa3ac..1729d01738626 100644 --- a/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-opener.tsx +++ b/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-opener.tsx @@ -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([ + 'extension', + Schemes.webviewPanel, + 'vscode-workspace-trust', + Schemes.vscodeSettings +]); export class CustomEditorOpener implements OpenHandler { @@ -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): + // 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; } }