/********************************************************************************
 * Copyright (C) 2017 TypeFox 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 WITH Classpath-exception-2.0
 ********************************************************************************/ import { inject , injectable , optional , postConstruct } from '@theia/core/shared/inversify' ; import { OpenerService , KeybindingRegistry , QuickAccessRegistry , QuickAccessProvider , CommonCommands } from '@theia/core/lib/browser' ; import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service' ; import URI from '@theia/core/lib/common/uri' ; import { FileSearchService , WHITESPACE_QUERY_SEPARATOR } from '../common/file-search-service' ; import { CancellationToken , Command } from '@theia/core/lib/common' ; import { LabelProvider } from '@theia/core/lib/browser/label-provider' ; import { NavigationLocationService } from '@theia/editor/lib/browser/navigation/navigation-location-service' ; import * as fuzzy from '@theia/core/shared/fuzzy' ; import { MessageService } from '@theia/core/lib/common/message-service' ; import { FileSystemPreferences } from '@theia/filesystem/lib/browser' ; import { EditorOpenerOptions , EditorWidget , Position , Range } from '@theia/editor/lib/browser' ; import { findMatches , QuickInputService , QuickPickItem , QuickPicks } from '@theia/core/lib/browser/quick-input/quick-input-service' ; export const quickFileOpen = Command . toDefaultLocalizedCommand ( { id : 'file-search.openFile' , category : CommonCommands . FILE_CATEGORY , label : 'Open File...' } ) ; export interface FilterAndRange { filter : string ; range ? : Range ; } const LINE_COLON_PATTERN = /\s?[#:\(](?:line )?(\d*)(?:[#:,](\d*))?\)?\s*$/ ; export type FileQuickPickItem = QuickPickItem & { uri : URI ; } ; @ injectable ( ) export class QuickFileOpenService implements QuickAccessProvider { static readonly PREFIX = '' ; @ inject ( KeybindingRegistry ) protected readonly keybindingRegistry : KeybindingRegistry ; @ inject ( WorkspaceService ) protected readonly workspaceService : WorkspaceService ; @ inject ( OpenerService ) protected readonly openerService : OpenerService ; @ inject ( QuickInputService ) @ optional ( ) protected readonly quickInputService : QuickInputService ; @ inject ( QuickAccessRegistry ) protected readonly quickAccessRegistry : QuickAccessRegistry ; @ inject ( FileSearchService ) protected readonly fileSearchService : FileSearchService ; @ inject ( LabelProvider ) protected readonly labelProvider : LabelProvider ; @ inject ( NavigationLocationService ) protected readonly navigationLocationService : NavigationLocationService ; @ inject ( MessageService ) protected readonly messageService : MessageService ; @ inject ( FileSystemPreferences ) protected readonly fsPreferences : FileSystemPreferences ; registerQuickAccessProvider ( ) { this . quickAccessRegistry . registerQuickAccessProvider ( { getInstance : ( ) => this , prefix : QuickFileOpenService . PREFIX , placeholder : this . getPlaceHolder ( ) , helpEntries : [ { description : 'Open File' , needsEditor : false } ] } ) ; } /**
     * Whether to hide .gitignored (and other ignored) files.
     */ protected hideIgnoredFiles = true ; /**
     * Whether the dialog is currently open.
     */ protected isOpen = false ; private updateIsOpen = true ; protected filterAndRangeDefault = { filter : '' , range : undefined } ; /**
     * Tracks the user file search filter and location range e.g. fileFilter:line:column or fileFilter:line,column
     */ protected filterAndRange : FilterAndRange = this . filterAndRangeDefault ; /**
     * The score constants when comparing file search results.
     */ private static readonly Scores = { max : 1000 , exact : 500 , partial : 250 } ; @ postConstruct ( ) protected init ( ) { this . quickInputService ?. onHide ( ( ) => { if ( this . updateIsOpen ) { this . isOpen = false ; } else { this . updateIsOpen = true ; } } ) ; } isEnabled ( ) { return this . workspaceService . opened ; } open ( ) { if ( this . isOpen ) { this . hideIgnoredFiles = ! this . hideIgnoredFiles ; this . hideQuickPick ( ) ; } else { this . hideIgnoredFiles = true ; this . filterAndRange = this . filterAndRangeDefault ; this . isOpen = true ; } this . quickInputService ?. open ( this . filterAndRange . filter ) ; } protected hideQuickPick ( ) { this . updateIsOpen = false ; this . quickInputService ?. hide ( ) ; } /**
     * Get a string (suitable to show to the user) representing the keyboard
     * shortcut used to open the quick file open menu.
     */ protected getKeyCommand ( ) { const keyCommand = this . keybindingRegistry . getKeybindingsForCommand ( quickFileOpen . id ) ; if ( keyCommand ) { const accel = this . keybindingRegistry . acceleratorFor ( keyCommand [ 0 ] , '+' ) ; return accel . join ( ' ' ) ; } return undefined ; } async getPicks ( filter , token ) { const roots = this . workspaceService . tryGetRoots ( ) ; this . filterAndRange = this . splitFilterAndRange ( filter ) ; const fileFilter = this . filterAndRange . filter ; const alreadyCollected = new Set < string > ( ) ; const recentlyUsedItems = [ ] ; const locations = [ ... this . navigationLocationService . locations ( ) ] . reverse ( ) ; for ( const location of locations ) { const uriString = location . uri . toString ( ) ; if ( location . uri . scheme === 'file' && ! alreadyCollected . has ( uriString ) && fuzzy . test ( fileFilter , uriString ) ) { if ( recentlyUsedItems . length === 0 ) { recentlyUsedItems . push ( { type : 'separator' , label : 'recently opened' } ) ; } const item = this . toItem ( fileFilter , location . uri ) ; recentlyUsedItems . push ( item ) ; alreadyCollected . add ( uriString ) ; } } if ( fileFilter . length > 0 ) { const handler = async ( results ) => { if ( token . isCancellationRequested || results . length <= 0 ) { return [ ] ; } const result = [ ... recentlyUsedItems ] ; const fileSearchResultItems = [ ] ; for ( const fileUri of results ) { if ( ! alreadyCollected . has ( fileUri ) ) { const item = this . toItem ( fileFilter , fileUri ) ; fileSearchResultItems . push ( item ) ; alreadyCollected . add ( fileUri ) ; } } const sortedResults = fileSearchResultItems . slice ( ) ; sortedResults . sort ( ( a , b ) => this . compareItems ( a , b ) ) ; if ( sortedResults . length > 0 ) { result . unshift ( { type : 'separator' , label : 'file results' } ) ; result . push ( ... sortedResults ) ; } return result ; } ; return this . fileSearchService . find ( fileFilter , { rootUris : roots . map ( r => r . resource . toString ( ) ) , fuzzyMatch : true , limit : 200 , useGitIgnore : this . hideIgnoredFiles , excludePatterns : this . hideIgnoredFiles ? Object . keys ( this . fsPreferences [ 'files.exclude' ] ) : undefined , } , token ) . then ( handler ) ; } else { return roots . length !== 0 ? recentlyUsedItems : [ ] ; } } protected compareItems ( left , right ) { param str returns function score ( str ) { if ( ! str ) { return 0 ; } const querySplit = query . split ( WHITESPACE_QUERY_SEPARATOR ) ; const queryJoin = querySplit . join ( '' ) ; let exactMatch = true ; let partialMatch = false ; querySplit . forEach ( part => { const partMatches = str . includes ( part ) ; exactMatch = exactMatch && partMatches ; partialMatch = partialMatch || partMatches ; } ) ; const fuzzyMatch = fuzzy . match ( queryJoin , str ) ; let matchScore = 0 ; if ( ! ! fuzzyMatch && matchScore !== null ) { matchScore = ( fuzzyMatch . score === Infinity ) ? QuickFileOpenService . Scores . max : fuzzyMatch . score ; } if ( exactMatch ) { return matchScore + QuickFileOpenService . Scores . exact ; } else if ( partialMatch ) { return matchScore + QuickFileOpenService . Scores . partial ; } else { return ( fuzzyMatch === null ) ? 0 : matchScore ; } } const query = normalize ( this . filterAndRange . filter ) ; const compareByLabelScore = ( l , r ) => score ( r . label ) - score ( l . label ) ; const compareByLabelIndex = ( l , r ) => r . label . indexOf ( query ) - l . label . indexOf ( query ) ; const compareByLabel = ( l , r ) => r . label . localeCompare ( l . label ) ; const compareByPathScore = ( l , r ) => score ( r . uri . path . toString ( ) ) - score ( l . uri . path . toString ( ) ) ; const compareByPathIndex = ( l , r ) => r . uri . path . toString ( ) . indexOf ( query ) - l . uri . path . toString ( ) . indexOf ( query ) ; const compareByPathLabel = ( l , r ) => r . uri . path . toString ( ) . localeCompare ( l . uri . path . toString ( ) ) ; return compareWithDiscriminators ( left , right , compareByLabelScore , compareByLabelIndex , compareByLabel , compareByPathScore , compareByPathIndex , compareByPathLabel ) ; } openFile ( uri ) { const options = this . buildOpenerOptions ( ) ; const closedEditor = this . navigationLocationService . closedEditorsStack . find ( editor => editor . uri . path . toString ( ) === uri . path . toString ( ) ) ; this . openerService . getOpener ( uri , options ) . then ( opener => opener . open ( uri , options ) ) . then ( widget => { if ( widget instanceof EditorWidget && closedEditor && ! options . selection ) { widget . editor . restoreViewState ( closedEditor . viewState ) ; } } ) . catch ( error => this . messageService . error ( error ) ) ; } protected buildOpenerOptions ( ) { return { selection : this . filterAndRange . range } ; } private toItem ( lookFor , uriOrString ) { const uri = uriOrString instanceof URI ? uriOrString : new URI ( uriOrString ) ; const label = this . labelProvider . getName ( uri ) ; const description = this . getItemDescription ( uri ) ; const iconClasses = this . getItemIconClasses ( uri ) ; return { label , description , highlights : { label : findMatches ( label , lookFor ) , description : findMatches ( description , lookFor ) } , iconClasses , uri , execute : ( ) => this . openFile ( uri ) } ; } private getItemIconClasses ( uri ) { const icon = this . labelProvider . getIcon ( uri ) ; return icon !== '' ? [ icon + ' file-icon' ] : [ ] ; } private getItemDescription ( uri ) { const resourcePathLabel = this . labelProvider . getLongName ( uri . parent ) ; const rootUri = this . workspaceService . getWorkspaceRootUri ( uri ) ; if ( ! rootUri ) { return resourcePathLabel ; } if ( this . workspaceService . isMultiRootWorkspaceOpened ) { const rootLabel = this . labelProvider . getName ( rootUri ) ; return rootUri . path . toString ( ) === uri . parent . path . toString ( ) ? `${ rootLabel }` : `${ rootLabel } • ${ resourcePathLabel }` ; } else { return rootUri . toString ( ) === uri . parent . toString ( ) ? '' : resourcePathLabel ; } } private getPlaceHolder ( ) { let placeholder = 'File name to search (append : to go to line).' ; const keybinding = this . getKeyCommand ( ) ; if ( keybinding ) { placeholder += ` (Press ${ keybinding } to show/hide ignored files)` ; } return placeholder ; } param expression protected splitFilterAndRange ( expression ) { let filter = expression ; let range = undefined ; const patternMatch = LINE_COLON_PATTERN . exec ( expression ) ; if ( patternMatch ) { const line = parseInt ( patternMatch [ 1 ] ?? '' , 10 ) ; if ( Number . isFinite ( line ) ) { const lineNumber = line > 0 ? line - 1 : 0 ; const column = parseInt ( patternMatch [ 2 ] ?? '' , 10 ) ; const startColumn = Number . isFinite ( column ) && column > 0 ? column - 1 : 0 ; const position = Position . create ( lineNumber , startColumn ) ; filter = expression . substr ( 0 , patternMatch . index ) ; range = Range . create ( position , position ) ; } } return { filter , range } ; } } param str returns function normalize ( str ) { return str . trim ( ) . toLowerCase ( ) ; } function compareWithDiscriminators < T > ( left , right , ... discriminators ) { let comparisonValue = 0 ; let i = 0 ; while ( comparisonValue === 0 && i < discriminators . length ) { comparisonValue = discriminators [ i ] ( left , right ) ; i ++ ; } return comparisonValue ; }