Skip to content
Merged
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
24 changes: 19 additions & 5 deletions packages/core/src/browser/widgets/select-component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,20 @@ export class SelectComponent extends React.Component<SelectComponentProps, Selec
return selected;
}

override componentDidUpdate(prevProps: SelectComponentProps): void {
override componentDidUpdate(prevProps: SelectComponentProps, prevState: SelectComponentState): void {
if (prevState.selected !== this.state.selected && this.state.dimensions) {
const dropdownContainer = this.dropdownRef?.current;
const selectedOption = dropdownContainer?.querySelector(`[data-option-index="${this.state.selected}"]`) as HTMLElement | undefined;
if (dropdownContainer && selectedOption) {
const optionBottom = selectedOption.offsetTop + selectedOption.offsetHeight;
const visibleBottom = dropdownContainer.scrollTop + dropdownContainer.clientHeight;
if (optionBottom > visibleBottom) {
dropdownContainer.scrollTop = optionBottom - dropdownContainer.clientHeight;
} else if (selectedOption.offsetTop < dropdownContainer.scrollTop) {
dropdownContainer.scrollTop = selectedOption.offsetTop;
}
}
}
if (prevProps.defaultValue !== this.props.defaultValue || prevProps.options !== this.props.options) {
const selected = this.getInitialSelectedIndex(this.props);
this.setState({
Expand Down Expand Up @@ -254,11 +267,11 @@ export class SelectComponent extends React.Component<SelectComponentProps, Selec
</>;
}

protected nextNotSeparator(direction: 'forwards' | 'backwards'): number {
protected nextNotSeparator(direction: 'forwards' | 'backwards', startFrom?: number): number {
const { options } = this.props;
const step = direction === 'forwards' ? 1 : -1;
const length = this.props.options.length;
let selected = this.state.selected;
let selected = startFrom ?? this.state.selected;
let count = 0;
do {
selected = (selected + step) % length;
Expand All @@ -276,14 +289,14 @@ export class SelectComponent extends React.Component<SelectComponentProps, Selec
return;
}
if (ev.key === 'ArrowUp') {
const selected = this.nextNotSeparator('backwards');
const selected = this.nextNotSeparator('backwards', this.state.hover);
this.setState({
selected,
hover: selected
});
} else if (ev.key === 'ArrowDown') {
if (this.state.dimensions) {
const selected = this.nextNotSeparator('forwards');
const selected = this.nextNotSeparator('forwards', this.state.hover);
this.setState({
selected,
hover: selected
Expand Down Expand Up @@ -404,6 +417,7 @@ export class SelectComponent extends React.Component<SelectComponentProps, Selec
return (
<div
key={index}
data-option-index={index}
className={`theia-select-component-option${index === selected ? ' selected' : ''}`}
onMouseOver={() => {
this.setState({
Expand Down
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):
// 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