From 8aa08cbf6e774119d92eba0ecb3608c0be03e53c Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Wed, 22 Jul 2020 11:33:28 +0200 Subject: [PATCH] fixed fuzzy. added proper boost. Signed-off-by: Akos Kitta --- .../boards/boards-data-menu-updater.ts | 50 +-- .../boards/boards-service-client-impl.ts | 35 +- .../contributions/edit-contributions.ts | 2 +- .../src/browser/theia/monaco/comparers.ts | 367 ++++++++++++++++++ .../src/common/protocol/boards-service.ts | 11 +- .../src/node/core-service-impl.ts | 6 +- .../src/node/sketches-service-impl.ts | 2 +- 7 files changed, 436 insertions(+), 37 deletions(-) create mode 100644 arduino-ide-extension/src/browser/theia/monaco/comparers.ts diff --git a/arduino-ide-extension/src/browser/boards/boards-data-menu-updater.ts b/arduino-ide-extension/src/browser/boards/boards-data-menu-updater.ts index 24eba9b2..64767fc3 100644 --- a/arduino-ide-extension/src/browser/boards/boards-data-menu-updater.ts +++ b/arduino-ide-extension/src/browser/boards/boards-data-menu-updater.ts @@ -42,34 +42,36 @@ export class BoardsDataMenuUpdater implements FrontendApplicationContribution { const { fqbn } = selectedBoard; if (fqbn) { const { configOptions, programmers, selectedProgrammer } = await this.boardsDataStore.getData(fqbn); - const boardsConfigMenuPath = [...ArduinoMenus.TOOLS, 'z01_boardsConfig']; // `z_` is for ordering. - for (const { label, option, values } of configOptions.sort(ConfigOption.LABEL_COMPARATOR)) { - const menuPath = [...boardsConfigMenuPath, `${option}`]; - const commands = new Map() - for (const value of values) { - const id = `${fqbn}-${option}--${value.value}`; - const command = { id }; - const selectedValue = value.value; - const handler = { - execute: () => this.boardsDataStore.selectConfigOption({ fqbn, option, selectedValue }), - isToggled: () => value.selected - }; - commands.set(id, Object.assign(this.commandRegistry.registerCommand(command, handler), { label: value.label })); + if (configOptions.length) { + const boardsConfigMenuPath = [...ArduinoMenus.TOOLS, 'z01_boardsConfig']; // `z_` is for ordering. + for (const { label, option, values } of configOptions.sort(ConfigOption.LABEL_COMPARATOR)) { + const menuPath = [...boardsConfigMenuPath, `${option}`]; + const commands = new Map() + for (const value of values) { + const id = `${fqbn}-${option}--${value.value}`; + const command = { id }; + const selectedValue = value.value; + const handler = { + execute: () => this.boardsDataStore.selectConfigOption({ fqbn, option, selectedValue }), + isToggled: () => value.selected + }; + commands.set(id, Object.assign(this.commandRegistry.registerCommand(command, handler), { label: value.label })); + } + this.menuRegistry.registerSubmenu(menuPath, label); + this.toDisposeOnBoardChange.pushAll([ + ...commands.values(), + Disposable.create(() => this.unregisterSubmenu(menuPath)), // We cannot dispose submenu entries: https://github.com/eclipse-theia/theia/issues/7299 + ...Array.from(commands.keys()).map((commandId, i) => { + const { label } = commands.get(commandId)!; + this.menuRegistry.registerMenuAction(menuPath, { commandId, order: `${i}`, label }); + return Disposable.create(() => this.menuRegistry.unregisterMenuAction(commandId)); + }) + ]); } - this.menuRegistry.registerSubmenu(menuPath, label); - this.toDisposeOnBoardChange.pushAll([ - ...commands.values(), - Disposable.create(() => this.unregisterSubmenu(menuPath)), // We cannot dispose submenu entries: https://github.com/eclipse-theia/theia/issues/7299 - ...Array.from(commands.keys()).map((commandId, i) => { - const { label } = commands.get(commandId)!; - this.menuRegistry.registerMenuAction(menuPath, { commandId, order: `${i}`, label }); - return Disposable.create(() => this.menuRegistry.unregisterMenuAction(commandId)); - }) - ]); } if (programmers.length) { const programmersMenuPath = [...ArduinoMenus.TOOLS, 'z02_programmers']; - const label = selectedProgrammer ? `Programmer: ${selectedProgrammer.name}` : 'Programmer' + const label = selectedProgrammer ? `Programmer: "${selectedProgrammer.name}"` : 'Programmer' this.menuRegistry.registerSubmenu(programmersMenuPath, label); for (const programmer of programmers) { const { id, name } = programmer; diff --git a/arduino-ide-extension/src/browser/boards/boards-service-client-impl.ts b/arduino-ide-extension/src/browser/boards/boards-service-client-impl.ts index f720f832..638beafa 100644 --- a/arduino-ide-extension/src/browser/boards/boards-service-client-impl.ts +++ b/arduino-ide-extension/src/browser/boards/boards-service-client-impl.ts @@ -8,6 +8,12 @@ import { RecursiveRequired } from '../../common/types'; import { BoardsServiceClient, AttachedBoardsChangeEvent, BoardInstalledEvent, Board, Port, BoardUninstalledEvent, BoardsService } from '../../common/protocol'; import { BoardsConfig } from './boards-config'; import { naturalCompare } from '../../common/utils'; +import { compareAnything } from '../theia/monaco/comparers'; + +interface BoardMatch { + readonly board: Board & Readonly<{ packageName: string }>; + readonly matches: monaco.filters.IMatch[] | undefined; +} @injectable() export class BoardsServiceClientImpl implements BoardsServiceClient, FrontendApplicationContribution { @@ -168,13 +174,28 @@ export class BoardsServiceClientImpl implements BoardsServiceClient, FrontendApp const coresFilter = !!cores && cores.length ? ((toFilter: { packageName: string }) => cores.some(core => core === toFilter.packageName)) : () => true; - const fuzzyFilter = !!query - ? ((toFilter: Board) => !!monaco.filters.matchesFuzzy(query, toFilter.name, true)) - : () => true - return boards - .filter(coresFilter) - .filter(fuzzyFilter) - .sort(Board.compare) + if (!query) { + return boards.filter(coresFilter).sort(Board.compare); + } + const toMatch = ((toFilter: Board & { packageName: string }) => (({ board: toFilter, matches: monaco.filters.matchesFuzzy(query, toFilter.name, true) }))); + const compareEntries = (left: BoardMatch, right: BoardMatch, lookFor: string) => { + const leftMatches = left.matches || []; + const rightMatches = right.matches || []; + if (leftMatches.length && !rightMatches.length) { + return -1; + } + if (!leftMatches.length && rightMatches.length) { + return 1; + } + if (leftMatches.length === 0 && rightMatches.length === 0) { + return 0; + } + const leftLabel = left.board.name.replace(/\r?\n/g, ' '); + const rightLabel = right.board.name.replace(/\r?\n/g, ' '); + return compareAnything(leftLabel, rightLabel, lookFor); + } + const normalizedQuery = query.toLowerCase(); + return boards.filter(coresFilter).map(toMatch).sort((left, right) => compareEntries(left, right, normalizedQuery)).map(({ board }) => board); } get boardsConfig(): BoardsConfig.Config { diff --git a/arduino-ide-extension/src/browser/contributions/edit-contributions.ts b/arduino-ide-extension/src/browser/contributions/edit-contributions.ts index 66188a56..957bd8aa 100644 --- a/arduino-ide-extension/src/browser/contributions/edit-contributions.ts +++ b/arduino-ide-extension/src/browser/contributions/edit-contributions.ts @@ -219,7 +219,7 @@ ${value} } protected async run(commandId: string): Promise { - const editor = await this.current(); + const editor = await this.current(); // TODO: this should be the active monaco editor and not Theia editor. e.g: Output if (editor) { const action = editor.getControl().getAction(commandId); if (action) { diff --git a/arduino-ide-extension/src/browser/theia/monaco/comparers.ts b/arduino-ide-extension/src/browser/theia/monaco/comparers.ts new file mode 100644 index 00000000..a563bd2a --- /dev/null +++ b/arduino-ide-extension/src/browser/theia/monaco/comparers.ts @@ -0,0 +1,367 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// Copied from https://github.com/microsoft/vscode/blob/724c307bf35646ac549a8533a255c51b63fea5c7/src/vs/base/common/comparers.ts +// We cannot customize the monaco loader for Theia: https://github.com/eclipse-theia/theia/issues/8220 + +import { isWindows } from '@theia/core/lib/common/os'; + +const sep = (isWindows ? '\\' : '/'); +interface IDisposable { + dispose(): void; +} +interface IdleDeadline { + readonly didTimeout: boolean; + timeRemaining(): number; +} +let runWhenIdle: (callback: (idle: IdleDeadline) => void, timeout?: number) => IDisposable; +declare function requestIdleCallback(callback: (args: IdleDeadline) => void, options?: { timeout: number }): number; +declare function cancelIdleCallback(handle: number): void; + +(function () { + if (typeof requestIdleCallback !== 'function' || typeof cancelIdleCallback !== 'function') { + const dummyIdle: IdleDeadline = Object.freeze({ + didTimeout: true, + timeRemaining() { return 15; } + }); + runWhenIdle = (runner) => { + const handle = setTimeout(() => runner(dummyIdle)); + let disposed = false; + return { + dispose() { + if (disposed) { + return; + } + disposed = true; + clearTimeout(handle); + } + }; + }; + } else { + runWhenIdle = (runner, timeout?) => { + const handle: number = requestIdleCallback(runner, typeof timeout === 'number' ? { timeout } : undefined); + let disposed = false; + return { + dispose() { + if (disposed) { + return; + } + disposed = true; + cancelIdleCallback(handle); + } + }; + }; + } +})(); + +/** + * An implementation of the "idle-until-urgent"-strategy as introduced + * here: https://philipwalton.com/articles/idle-until-urgent/ + */ +class IdleValue { + + private readonly _executor: () => void; + private readonly _handle: IDisposable; + + private _didRun: boolean = false; + private _value?: T; + private _error: any; + + constructor(executor: () => T) { + this._executor = () => { + try { + this._value = executor(); + } catch (err) { + this._error = err; + } finally { + this._didRun = true; + } + }; + this._handle = runWhenIdle(() => this._executor()); + } + + dispose(): void { + this._handle.dispose(); + } + + get value(): T { + if (!this._didRun) { + this._handle.dispose(); + this._executor(); + } + if (this._error) { + throw this._error; + } + return this._value!; + } +} + +// When comparing large numbers of strings, such as in sorting large arrays, is better for +// performance to create an Intl.Collator object and use the function provided by its compare +// property than it is to use String.prototype.localeCompare() + +// A collator with numeric sorting enabled, and no sensitivity to case or to accents +const intlFileNameCollatorBaseNumeric: IdleValue<{ collator: Intl.Collator, collatorIsNumeric: boolean }> = new IdleValue(() => { + const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); + return { + collator: collator, + collatorIsNumeric: collator.resolvedOptions().numeric + }; +}); + +// A collator with numeric sorting enabled. +const intlFileNameCollatorNumeric: IdleValue<{ collator: Intl.Collator }> = new IdleValue(() => { + const collator = new Intl.Collator(undefined, { numeric: true }); + return { + collator: collator + }; +}); + +// A collator with numeric sorting enabled, and sensitivity to accents and diacritics but not case. +const intlFileNameCollatorNumericCaseInsenstive: IdleValue<{ collator: Intl.Collator }> = new IdleValue(() => { + const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'accent' }); + return { + collator: collator + }; +}); + +export function compareFileNames(one: string | null, other: string | null, caseSensitive = false): number { + const a = one || ''; + const b = other || ''; + const result = intlFileNameCollatorBaseNumeric.value.collator.compare(a, b); + + // Using the numeric option in the collator will + // make compare(`foo1`, `foo01`) === 0. We must disambiguate. + if (intlFileNameCollatorBaseNumeric.value.collatorIsNumeric && result === 0 && a !== b) { + return a < b ? -1 : 1; + } + + return result; +} + +/** Compares filenames by name then extension, sorting numbers numerically instead of alphabetically. */ +export function compareFileNamesNumeric(one: string | null, other: string | null): number { + const [oneName, oneExtension] = extractNameAndExtension(one, true); + const [otherName, otherExtension] = extractNameAndExtension(other, true); + const collatorNumeric = intlFileNameCollatorNumeric.value.collator; + const collatorNumericCaseInsensitive = intlFileNameCollatorNumericCaseInsenstive.value.collator; + let result; + + // Check for name differences, comparing numbers numerically instead of alphabetically. + result = compareAndDisambiguateByLength(collatorNumeric, oneName, otherName); + if (result !== 0) { + return result; + } + + // Check for case insensitive extension differences, comparing numbers numerically instead of alphabetically. + result = compareAndDisambiguateByLength(collatorNumericCaseInsensitive, oneExtension, otherExtension); + if (result !== 0) { + return result; + } + + // Disambiguate the extension case if needed. + if (oneExtension !== otherExtension) { + return collatorNumeric.compare(oneExtension, otherExtension); + } + + return 0; +} + +const FileNameMatch = /^(.*?)(\.([^.]*))?$/; + +export function noIntlCompareFileNames(one: string | null, other: string | null, caseSensitive = false): number { + if (!caseSensitive) { + one = one && one.toLowerCase(); + other = other && other.toLowerCase(); + } + + const [oneName, oneExtension] = extractNameAndExtension(one); + const [otherName, otherExtension] = extractNameAndExtension(other); + + if (oneName !== otherName) { + return oneName < otherName ? -1 : 1; + } + + if (oneExtension === otherExtension) { + return 0; + } + + return oneExtension < otherExtension ? -1 : 1; +} + +export function compareFileExtensions(one: string | null, other: string | null): number { + const [oneName, oneExtension] = extractNameAndExtension(one); + const [otherName, otherExtension] = extractNameAndExtension(other); + + let result = intlFileNameCollatorBaseNumeric.value.collator.compare(oneExtension, otherExtension); + + if (result === 0) { + // Using the numeric option in the collator will + // make compare(`foo1`, `foo01`) === 0. We must disambiguate. + if (intlFileNameCollatorBaseNumeric.value.collatorIsNumeric && oneExtension !== otherExtension) { + return oneExtension < otherExtension ? -1 : 1; + } + + // Extensions are equal, compare filenames + result = intlFileNameCollatorBaseNumeric.value.collator.compare(oneName, otherName); + + if (intlFileNameCollatorBaseNumeric.value.collatorIsNumeric && result === 0 && oneName !== otherName) { + return oneName < otherName ? -1 : 1; + } + } + + return result; +} + +/** Compares filenames by extenson, then by name. Sorts numbers numerically, not alphabetically. */ +export function compareFileExtensionsNumeric(one: string | null, other: string | null): number { + const [oneName, oneExtension] = extractNameAndExtension(one, true); + const [otherName, otherExtension] = extractNameAndExtension(other, true); + const collatorNumeric = intlFileNameCollatorNumeric.value.collator; + const collatorNumericCaseInsensitive = intlFileNameCollatorNumericCaseInsenstive.value.collator; + let result; + + // Check for extension differences, ignoring differences in case and comparing numbers numerically. + result = compareAndDisambiguateByLength(collatorNumericCaseInsensitive, oneExtension, otherExtension); + if (result !== 0) { + return result; + } + + // Compare names. + result = compareAndDisambiguateByLength(collatorNumeric, oneName, otherName); + if (result !== 0) { + return result; + } + + // Disambiguate extension case if needed. + if (oneExtension !== otherExtension) { + return collatorNumeric.compare(oneExtension, otherExtension); + } + + return 0; +} + +/** Extracts the name and extension from a full filename, with optional special handling for dotfiles */ +function extractNameAndExtension(str?: string | null, dotfilesAsNames = false): [string, string] { + const match = str ? FileNameMatch.exec(str) as Array : ([] as Array); + + let result: [string, string] = [(match && match[1]) || '', (match && match[3]) || '']; + + // if the dotfilesAsNames option is selected, treat an empty filename with an extension, + // or a filename that starts with a dot, as a dotfile name + if (dotfilesAsNames && (!result[0] && result[1] || result[0] && result[0].charAt(0) === '.')) { + result = [result[0] + '.' + result[1], '']; + } + + return result; +} + +function compareAndDisambiguateByLength(collator: Intl.Collator, one: string, other: string) { + // Check for differences + let result = collator.compare(one, other); + if (result !== 0) { + return result; + } + + // In a numeric comparison, `foo1` and `foo01` will compare as equivalent. + // Disambiguate by sorting the shorter string first. + if (one.length !== other.length) { + return one.length < other.length ? -1 : 1; + } + + return 0; +} + +function comparePathComponents(one: string, other: string, caseSensitive = false): number { + if (!caseSensitive) { + one = one && one.toLowerCase(); + other = other && other.toLowerCase(); + } + + if (one === other) { + return 0; + } + + return one < other ? -1 : 1; +} + +export function comparePaths(one: string, other: string, caseSensitive = false): number { + const oneParts = one.split(sep); + const otherParts = other.split(sep); + + const lastOne = oneParts.length - 1; + const lastOther = otherParts.length - 1; + let endOne: boolean, endOther: boolean; + + for (let i = 0; ; i++) { + endOne = lastOne === i; + endOther = lastOther === i; + + if (endOne && endOther) { + return compareFileNames(oneParts[i], otherParts[i], caseSensitive); + } else if (endOne) { + return -1; + } else if (endOther) { + return 1; + } + + const result = comparePathComponents(oneParts[i], otherParts[i], caseSensitive); + + if (result !== 0) { + return result; + } + } +} + +export function compareAnything(one: string, other: string, lookFor: string): number { + const elementAName = one.toLowerCase(); + const elementBName = other.toLowerCase(); + + // Sort prefix matches over non prefix matches + const prefixCompare = compareByPrefix(one, other, lookFor); + if (prefixCompare) { + return prefixCompare; + } + + // Sort suffix matches over non suffix matches + const elementASuffixMatch = elementAName.endsWith(lookFor); + const elementBSuffixMatch = elementBName.endsWith(lookFor); + if (elementASuffixMatch !== elementBSuffixMatch) { + return elementASuffixMatch ? -1 : 1; + } + + // Understand file names + const r = compareFileNames(elementAName, elementBName); + if (r !== 0) { + return r; + } + + // Compare by name + return elementAName.localeCompare(elementBName); +} + +export function compareByPrefix(one: string, other: string, lookFor: string): number { + const elementAName = one.toLowerCase(); + const elementBName = other.toLowerCase(); + + // Sort prefix matches over non prefix matches + const elementAPrefixMatch = elementAName.startsWith(lookFor); + const elementBPrefixMatch = elementBName.startsWith(lookFor); + if (elementAPrefixMatch !== elementBPrefixMatch) { + return elementAPrefixMatch ? -1 : 1; + } + + // Same prefix: Sort shorter matches to the top to have those on top that match more precisely + else if (elementAPrefixMatch && elementBPrefixMatch) { + if (elementAName.length < elementBName.length) { + return -1; + } + + if (elementAName.length > elementBName.length) { + return 1; + } + } + + return 0; +} diff --git a/arduino-ide-extension/src/common/protocol/boards-service.ts b/arduino-ide-extension/src/common/protocol/boards-service.ts index 8c6c7dc0..b16e6a59 100644 --- a/arduino-ide-extension/src/common/protocol/boards-service.ts +++ b/arduino-ide-extension/src/common/protocol/boards-service.ts @@ -276,6 +276,7 @@ export interface Programmer { readonly id: string; } export namespace Programmer { + export function equals(left: Programmer | undefined, right: Programmer | undefined): boolean { if (!left) { return !right; @@ -285,6 +286,15 @@ export namespace Programmer { } return left.id === right.id && left.name === right.name && left.platform === right.platform; } + + export function toString({ id, platform }: Programmer): string { + const [vendor,] = platform.split('@'); + if (!vendor) { + throw new Error(`Could not extract vendor from platform: '${platform}'.`); + } + return `${vendor}:${id}`; + } + } export namespace Board { @@ -357,5 +367,4 @@ export namespace Board { })); } - } diff --git a/arduino-ide-extension/src/node/core-service-impl.ts b/arduino-ide-extension/src/node/core-service-impl.ts index c19da8d6..0e4ace21 100644 --- a/arduino-ide-extension/src/node/core-service-impl.ts +++ b/arduino-ide-extension/src/node/core-service-impl.ts @@ -2,7 +2,7 @@ import { inject, injectable, postConstruct } from 'inversify'; import { FileSystem } from '@theia/filesystem/lib/common/filesystem'; import { CoreService, CoreServiceClient } from '../common/protocol/core-service'; import { CompileReq, CompileResp } from './cli-protocol/commands/compile_pb'; -import { BoardsService } from '../common/protocol/boards-service'; +import { BoardsService, Programmer } from '../common/protocol/boards-service'; import { CoreClientProvider } from './core-client-provider'; import * as path from 'path'; import { ToolOutputServiceServer } from '../common/protocol/tool-output-service'; @@ -62,7 +62,7 @@ export class CoreServiceImpl implements CoreService { compilerReq.setVerbose(true); compilerReq.setQuiet(false); if (options.programmer) { - compilerReq.setProgrammer(options.programmer.id); + compilerReq.setProgrammer(Programmer.toString(options.programmer)); } const result = client.compile(compilerReq); @@ -108,7 +108,7 @@ export class CoreServiceImpl implements CoreService { uploadReq.setFqbn(fqbn); uploadReq.setPort(options.port); if (options.programmer) { - uploadReq.setProgrammer(options.programmer.id); + uploadReq.setProgrammer(Programmer.toString(options.programmer)); } const result = client.upload(uploadReq); diff --git a/arduino-ide-extension/src/node/sketches-service-impl.ts b/arduino-ide-extension/src/node/sketches-service-impl.ts index 31ce5761..500f4067 100644 --- a/arduino-ide-extension/src/node/sketches-service-impl.ts +++ b/arduino-ide-extension/src/node/sketches-service-impl.ts @@ -72,7 +72,7 @@ export class SketchesServiceImpl implements SketchesService, BackendApplicationC && fs.lstatSync(filePath).isFile()) { const uri = FileUri.create(filePath).toString(); if (fileName === basename + '.ino') { - uris.unshift(uri); + uris.unshift(uri); // The sketch file is the first. } else { uris.push(uri); }