diff --git a/src/concurrencyLimiter.ts b/src/concurrencyLimiter.ts new file mode 100644 index 0000000..5a63651 --- /dev/null +++ b/src/concurrencyLimiter.ts @@ -0,0 +1,34 @@ + +type Task = () => any; +export class ConcurrencyLimiter { + protected tasks: Task[] = []; + protected workers: Promise[] = []; + constructor(protected maxWorkers = 10) { } + public addTask(task: Task) { + this.tasks.push(task); + if (this.workers.length < this.maxWorkers) { + this.createWorker(); + } + } + public clear() { + this.tasks = []; + } + public toPromise() { + if (!this.workers.length) return Promise.resolve(); + return Promise.all(this.workers).then(() => this.toPromise()); + } + protected async createWorker() { + const worker = this.taskRunner(); + this.workers.push(worker); + worker.catch(() => { }).then(() => this.workers.splice(this.workers.indexOf(worker))); + } + protected async taskRunner() { + let task: Task | undefined = this.tasks.pop(); + while (task) { + try { + await task(); + } catch (e) { } + task = this.tasks.pop(); + } + } +} diff --git a/src/extension.ts b/src/extension.ts index 013b94e..e8a2ef0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -53,7 +53,9 @@ export function activate(context: vscode.ExtensionContext) { subscribe(vscode.commands.registerCommand(command, callback, thisArg)); subscribe(vscode.workspace.registerFileSystemProvider('ssh', manager, { isCaseSensitive: true })); - subscribe(vscode.workspace.registerFileSearchProvider('ssh', new SearchProvider(manager))); + const searchProvider = new SearchProvider(manager); + subscribe(vscode.workspace.registerFileSearchProvider('ssh', searchProvider)); + subscribe(vscode.workspace.registerTextSearchProvider('ssh', searchProvider)); async function pickAndClick(func: (name: string) => void, name?: string, activeOrNot?: boolean) { name = name || await pickConfig(manager, activeOrNot); diff --git a/src/searchProvider.ts b/src/searchProvider.ts index d44cfca..89a4c23 100644 --- a/src/searchProvider.ts +++ b/src/searchProvider.ts @@ -2,9 +2,66 @@ import * as minimatch from 'minimatch'; import { posix as path } from 'path'; import * as vscode from 'vscode'; +import { ConcurrencyLimiter } from './concurrencyLimiter'; import { Manager } from './manager'; -export class SearchProvider implements vscode.FileSearchProvider { +function textSearchQueryToRegExp(query: vscode.TextSearchQuery): RegExp { + const flags = `${query.isCaseSensitive ? '' : 'i'}mug`; + let pattern = query.pattern; + if (query.isRegExp) { + if (query.isWordMatch) pattern = `\\b${query.pattern}\\b`; + return new RegExp(pattern, flags); + } + pattern = pattern.replace(/\\/g, '\\\\'); + if (query.isWordMatch) pattern = `\\b${query.pattern}\\b`; + return new RegExp(pattern, flags); +} + +type RegExpMatchHandler = (lastIndex: number, mach: RegExpExecArray) => true | null | undefined | void; +function forEachMatch(content: string, regex: RegExp, handler: RegExpMatchHandler) { + let mat = regex.exec(content); + while (mat) { + const { lastIndex } = regex; + if (handler(lastIndex, mat)) break; + mat = regex.exec(content); + } +} + +type SimpleRange = [number, number]; +function getSearchResultRanges(content: string, query: vscode.TextSearchQuery, options: vscode.TextSearchOptions, regex: RegExp): SimpleRange[] { + const results: SimpleRange[] = []; + if (query.isMultiline) { + forEachMatch(content, /[^\r\n]+/g, (index, mat) => { + const res = getSearchResultRanges(mat[0], query, options, regex); + res.forEach(range => (range[0] += index, range[1] += index)); + results.push(...res); + }); + return results; + } + forEachMatch(content, regex, (index, mat) => { + results.push([index - mat[0].length, index]); + }); + return results; +} + +function getSearchResults(content: string, query: vscode.TextSearchQuery, options: vscode.TextSearchOptions, regexp: RegExp): vscode.Range[] { + const indexes = getSearchResultRanges(content, query, options, regexp); + const indexForLine: number[] = [0]; + for (let i = 0; i < content.length; i += 1) { + if (content[i] === '\n') { + indexForLine.push(i + 1); + } + } + return indexes.map((range) => { + const startLine = indexForLine.findIndex(v => v >= range[0]) - 1; + const endLine = indexForLine.findIndex(v => v >= range[1]) - 1; + const startChar = range[0] - indexForLine[startLine]; + const endChar = range[1] - indexForLine[endLine]; + return new vscode.Range(startLine, startChar, endLine, endChar); + }); +} + +export class SearchProvider implements vscode.FileSearchProvider, vscode.TextSearchProvider { protected cache: [vscode.CancellationToken, Promise][] = []; constructor(protected manager: Manager) { } public async provideFileSearchResults(query: vscode.FileSearchQuery, options: vscode.FileSearchOptions, token: vscode.CancellationToken): Promise { @@ -12,12 +69,43 @@ export class SearchProvider implements vscode.FileSearchProvider { const paths = await this.getTree(options, session || token, !session); if (token.isCancellationRequested) return []; const pattern = query.pattern.toLowerCase(); - return paths.map((relative) => { - if (!relative.path.toLowerCase().includes(pattern)) return null; - return folder.with({ path: path.join(folder.path, relative.path) }); + return paths.map((relative) => { + return relative.path.toLowerCase().includes(pattern) && relative; }).filter(s => !!s) as vscode.Uri[]; } - protected async getTree(options: vscode.FileSearchOptions, session: vscode.CancellationToken, singleton = false): Promise { + public async provideTextSearchResults(query: vscode.TextSearchQuery, options: vscode.TextSearchOptions, progress: vscode.Progress, token: vscode.CancellationToken): Promise { + const paths = await this.getTree(options, token, true); + const regexp = textSearchQueryToRegExp(query); + const limiter = new ConcurrencyLimiter(20); + token.onCancellationRequested(limiter.clear, limiter); + let found = 0; + const fs = (await this.manager.getFs(options.folder))!; + async function handleFile(uri: vscode.Uri) { + if (token.isCancellationRequested) return; + if (options.maxFileSize) { + const stats = await fs.stat(uri); + if (stats.size > options.maxFileSize) return; + } + const buffer = Buffer.from(await fs.readFile(uri)); + const content = Buffer.from(buffer.toString(), options.encoding); + const ranges = getSearchResults(content.toString(), query, options, regexp); + found += getSearchResults.length; + progress.report({ + ranges, uri, + preview: { + matches: ranges, + text: content.toString(), + }, + } as vscode.TextSearchMatch); + if (found >= options.maxResults) limiter.clear(); + } + for (const filepath of paths) { + limiter.addTask(() => handleFile(filepath)); + } + await limiter.toPromise(); + return { limitHit: found > options.maxResults }; + } + protected async getTree(options: vscode.SearchOptions, session: vscode.CancellationToken, singleton = false): Promise { let cached = this.cache.find(([t]) => session === t); if (cached) return await cached[1]; const singletonSource = singleton && new vscode.CancellationTokenSource(); @@ -36,6 +124,7 @@ export class SearchProvider implements vscode.FileSearchProvider { return res; } protected async internal_buildTree(options: vscode.FileSearchOptions, token: vscode.CancellationToken): Promise { + // TODO: For the options, actually use the following: includes, useIgnoreFiles and useGlobalIgnoreFiles const { folder } = options; const fs = await this.manager.getFs(folder); if (!fs || token.isCancellationRequested) return []; @@ -52,6 +141,8 @@ export class SearchProvider implements vscode.FileSearchProvider { if (exclude(joined)) return; // tslint:disable-next-line:no-bitwise if (type & vscode.FileType.Directory) { + // tslint:disable-next-line:no-bitwise + if ((type & vscode.FileType.SymbolicLink) && !options.followSymlinks) return; return readDirectory(uri.with({ path: joined })); } else { res.push(uri.with({ path: joined })); @@ -59,6 +150,11 @@ export class SearchProvider implements vscode.FileSearchProvider { })); } await readDirectory(folder); + if (options.includes.length) { + const includes = options.includes.map(e => minimatch.makeRe(e, { nocase: true })); + const include = (p: string) => includes.some(reg => reg.test(p)); + return res.filter(uri => include(uri.path) || uri.path[0] === '/' && include(uri.path.slice(1))); + } return res; } }