|
| 1 | +/*--------------------------------------------------------------------------------------------- |
| 2 | + * Copyright (c) Microsoft Corporation. All rights reserved. |
| 3 | + * Licensed under the MIT License. See License.txt in the project root for license information. |
| 4 | + *--------------------------------------------------------------------------------------------*/ |
| 5 | +import { streamToBuffer } from 'vs/base/common/buffer'; |
| 6 | +import { CancellationToken } from 'vs/base/common/cancellation'; |
| 7 | +import { IRelativePattern } from 'vs/base/common/glob'; |
| 8 | +import { ResourceSet, ResourceMap } from 'vs/base/common/map'; |
| 9 | +import { URI } from 'vs/base/common/uri'; |
| 10 | +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; |
| 11 | +import { IFileService } from 'vs/platform/files/common/files'; |
| 12 | +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; |
| 13 | +import { ILogService } from 'vs/platform/log/common/log'; |
| 14 | +import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity'; |
| 15 | +import { NotebookEditorWidget } from 'vs/workbench/contrib/notebook/browser/notebookEditorWidget'; |
| 16 | +import { INotebookEditorService } from 'vs/workbench/contrib/notebook/browser/services/notebookEditorService'; |
| 17 | +import { NotebookCellTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookCellTextModel'; |
| 18 | +import { NotebookTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookTextModel'; |
| 19 | +import { INotebookExclusiveDocumentFilter, NotebookData } from 'vs/workbench/contrib/notebook/common/notebookCommon'; |
| 20 | +import { INotebookSerializer, INotebookService, SimpleNotebookProviderInfo } from 'vs/workbench/contrib/notebook/common/notebookService'; |
| 21 | +import { INotebookSearchService } from 'vs/workbench/contrib/search/browser/notebookSearch'; |
| 22 | +import { IFileMatchWithCells, ICellMatch, CellSearchModel, contentMatchesToTextSearchMatches, webviewMatchesToTextSearchMatches } from 'vs/workbench/contrib/search/browser/searchNotebookHelpers'; |
| 23 | +import { IEditorResolverService, priorityToRank } from 'vs/workbench/services/editor/common/editorResolverService'; |
| 24 | +import { ITextQuery, IFileQuery, QueryType, ISearchProgressItem, ISearchComplete, ISearchConfigurationProperties, ISearchService } from 'vs/workbench/services/search/common/search'; |
| 25 | +import * as arrays from 'vs/base/common/arrays'; |
| 26 | +import { isNumber } from 'vs/base/common/types'; |
| 27 | + |
| 28 | +interface INotebookDataEditInfo { |
| 29 | + notebookData: NotebookData; |
| 30 | + mTime: number; |
| 31 | +} |
| 32 | + |
| 33 | +interface INotebookSearchMatchResults { |
| 34 | + results: ResourceMap<IFileMatchWithCells | null>; |
| 35 | + limitHit: boolean; |
| 36 | +} |
| 37 | + |
| 38 | +class NotebookDataCache { |
| 39 | + private _entries: ResourceMap<INotebookDataEditInfo>; |
| 40 | + // private _serializer: INotebookSerializer | undefined; |
| 41 | + |
| 42 | + constructor( |
| 43 | + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, |
| 44 | + @IFileService private readonly fileService: IFileService, |
| 45 | + @INotebookService private readonly notebookService: INotebookService, |
| 46 | + @IEditorResolverService private readonly editorResolverService: IEditorResolverService, |
| 47 | + ) { |
| 48 | + this._entries = new ResourceMap<INotebookDataEditInfo>(uri => this.uriIdentityService.extUri.getComparisonKey(uri)); |
| 49 | + } |
| 50 | + |
| 51 | + private async getSerializer(notebookUri: URI): Promise<INotebookSerializer | undefined> { |
| 52 | + const registeredEditorInfo = this.editorResolverService.getEditors(notebookUri); |
| 53 | + const priorityEditorInfo = registeredEditorInfo.reduce((acc, val) => |
| 54 | + priorityToRank(acc.priority) > priorityToRank(val.priority) ? acc : val |
| 55 | + ); |
| 56 | + const info = await this.notebookService.withNotebookDataProvider(priorityEditorInfo.id); |
| 57 | + if (!(info instanceof SimpleNotebookProviderInfo)) { |
| 58 | + return undefined; |
| 59 | + } |
| 60 | + return info.serializer; |
| 61 | + } |
| 62 | + |
| 63 | + async getNotebookData(notebookUri: URI): Promise<NotebookData> { |
| 64 | + const mTime = (await this.fileService.stat(notebookUri)).mtime; |
| 65 | + |
| 66 | + const entry = this._entries.get(notebookUri); |
| 67 | + |
| 68 | + if (entry && entry.mTime === mTime) { |
| 69 | + return entry.notebookData; |
| 70 | + } else { |
| 71 | + |
| 72 | + let _data: NotebookData = { |
| 73 | + metadata: {}, |
| 74 | + cells: [] |
| 75 | + }; |
| 76 | + |
| 77 | + const content = await this.fileService.readFileStream(notebookUri); |
| 78 | + const bytes = await streamToBuffer(content.value); |
| 79 | + const serializer = await this.getSerializer(notebookUri); |
| 80 | + if (!serializer) { |
| 81 | + //unsupported |
| 82 | + throw new Error(`serializer not initialized`); |
| 83 | + } |
| 84 | + _data = await serializer.dataToNotebook(bytes); |
| 85 | + this._entries.set(notebookUri, { notebookData: _data, mTime }); |
| 86 | + return _data; |
| 87 | + } |
| 88 | + } |
| 89 | + |
| 90 | +} |
| 91 | + |
| 92 | +export class NotebookSearchService implements INotebookSearchService { |
| 93 | + declare readonly _serviceBrand: undefined; |
| 94 | + private _notebookDataCache: NotebookDataCache; |
| 95 | + constructor( |
| 96 | + @IInstantiationService private readonly instantiationService: IInstantiationService, |
| 97 | + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, |
| 98 | + @INotebookEditorService private readonly notebookEditorService: INotebookEditorService, |
| 99 | + @ILogService private readonly logService: ILogService, |
| 100 | + @INotebookService private readonly notebookService: INotebookService, |
| 101 | + @ISearchService private readonly searchService: ISearchService, |
| 102 | + @IConfigurationService private readonly configurationService: IConfigurationService, |
| 103 | + |
| 104 | + ) { |
| 105 | + this._notebookDataCache = this.instantiationService.createInstance(NotebookDataCache); |
| 106 | + } |
| 107 | + |
| 108 | + private async runFileQueries(includes: (string)[], token: CancellationToken, textQuery: ITextQuery): Promise<URI[]> { |
| 109 | + const promises = includes.map(include => { |
| 110 | + const query: IFileQuery = { |
| 111 | + type: QueryType.File, |
| 112 | + filePattern: include, |
| 113 | + folderQueries: textQuery.folderQueries, |
| 114 | + maxResults: textQuery.maxResults, |
| 115 | + }; |
| 116 | + return this.searchService.fileSearch( |
| 117 | + query, |
| 118 | + token |
| 119 | + ); |
| 120 | + }); |
| 121 | + const result = (await Promise.all(promises)).map(sc => sc.results.map(fm => fm.resource)).flat(); |
| 122 | + const uris = new ResourceSet(result, uri => this.uriIdentityService.extUri.getComparisonKey(uri)); |
| 123 | + return Array.from(uris.keys()); |
| 124 | + } |
| 125 | + |
| 126 | + async notebookSearch(query: ITextQuery, token: CancellationToken, onProgress?: (result: ISearchProgressItem) => void): Promise<{ completeData: ISearchComplete; scannedFiles: ResourceSet }> { |
| 127 | + |
| 128 | + if (query.type !== QueryType.Text) { |
| 129 | + return { |
| 130 | + completeData: { |
| 131 | + messages: [], |
| 132 | + limitHit: false, |
| 133 | + results: [], |
| 134 | + }, |
| 135 | + scannedFiles: new ResourceSet() |
| 136 | + }; |
| 137 | + } |
| 138 | + const searchStart = Date.now(); |
| 139 | + |
| 140 | + const localNotebookWidgets = this.getLocalNotebookWidgets(); |
| 141 | + const localNotebookFiles = localNotebookWidgets.map(widget => widget.viewModel!.uri); |
| 142 | + const localResultPromise = this.getLocalNotebookResults(query, token, localNotebookWidgets); |
| 143 | + const searchLocalEnd = Date.now(); |
| 144 | + |
| 145 | + const experimentalNotebooksEnabled = this.configurationService.getValue<ISearchConfigurationProperties>('search').experimental?.closedNotebookRichContentResults ?? false; |
| 146 | + |
| 147 | + let closedResultsPromise: Promise<INotebookSearchMatchResults | undefined> = Promise.resolve(undefined); |
| 148 | + if (experimentalNotebooksEnabled) { |
| 149 | + closedResultsPromise = this.getClosedNotebookResults(query, new ResourceSet(localNotebookFiles, uri => this.uriIdentityService.extUri.getComparisonKey(uri)), token); |
| 150 | + } |
| 151 | + |
| 152 | + const resolved = (await Promise.all([localResultPromise, closedResultsPromise])).filter((result): result is INotebookSearchMatchResults => !!result); |
| 153 | + const resultArray = resolved.map(elem => elem.results); |
| 154 | + |
| 155 | + const results = arrays.coalesce(resultArray.flatMap(map => Array.from(map.values()))); |
| 156 | + const scannedFiles = new ResourceSet(resultArray.flatMap(map => Array.from(map.keys())), uri => this.uriIdentityService.extUri.getComparisonKey(uri)); |
| 157 | + if (onProgress) { |
| 158 | + results.forEach(onProgress); |
| 159 | + } |
| 160 | + this.logService.trace(`local notebook search time | ${searchLocalEnd - searchStart}ms`); |
| 161 | + return { |
| 162 | + completeData: { |
| 163 | + messages: [], |
| 164 | + limitHit: resolved.reduce((prev, cur) => prev || cur.limitHit, false), |
| 165 | + results, |
| 166 | + }, |
| 167 | + scannedFiles |
| 168 | + }; |
| 169 | + } |
| 170 | + |
| 171 | + private async getClosedNotebookResults(textQuery: ITextQuery, scannedFiles: ResourceSet, token: CancellationToken): Promise<INotebookSearchMatchResults> { |
| 172 | + const infoProviders = this.notebookService.getContributedNotebookTypes(); |
| 173 | + const includes = infoProviders.flatMap( |
| 174 | + (provider) => { |
| 175 | + return provider.selectors.map((selector) => { |
| 176 | + const globPattern = (selector as INotebookExclusiveDocumentFilter).include || selector as IRelativePattern | string; |
| 177 | + return globPattern.toString(); |
| 178 | + } |
| 179 | + ); |
| 180 | + } |
| 181 | + ); |
| 182 | + |
| 183 | + const results = new ResourceMap<IFileMatchWithCells | null>(uri => this.uriIdentityService.extUri.getComparisonKey(uri)); |
| 184 | + |
| 185 | + const start = Date.now(); |
| 186 | + |
| 187 | + const filesToScan = await this.runFileQueries(includes, token, textQuery); |
| 188 | + const deserializedNotebooks = new ResourceMap<NotebookTextModel>(); |
| 189 | + const textModels = this.notebookService.getNotebookTextModels(); |
| 190 | + for (const notebook of textModels) { |
| 191 | + deserializedNotebooks.set(notebook.uri, notebook); |
| 192 | + } |
| 193 | + |
| 194 | + const promises = filesToScan.map(async (uri) => { |
| 195 | + const cellMatches: ICellMatch[] = []; |
| 196 | + if (scannedFiles.has(uri)) { |
| 197 | + return; |
| 198 | + } |
| 199 | + |
| 200 | + try { |
| 201 | + if (token.isCancellationRequested) { |
| 202 | + return; |
| 203 | + } |
| 204 | + |
| 205 | + const cells = deserializedNotebooks.get(uri)?.cells ?? (await this._notebookDataCache.getNotebookData(uri)).cells; |
| 206 | + |
| 207 | + if (token.isCancellationRequested) { |
| 208 | + return; |
| 209 | + } |
| 210 | + |
| 211 | + cells.forEach((cell, index) => { |
| 212 | + const target = textQuery.contentPattern.pattern; |
| 213 | + const cellModel = cell instanceof NotebookCellTextModel ? new CellSearchModel('', cell.textBuffer, uri, index) : new CellSearchModel(cell.source, undefined, uri, index); |
| 214 | + |
| 215 | + const matches = cellModel.find(target); |
| 216 | + if (matches.length > 0) { |
| 217 | + const cellMatch: ICellMatch = { |
| 218 | + cell: cellModel, |
| 219 | + index: index, |
| 220 | + contentResults: contentMatchesToTextSearchMatches(matches, cellModel), |
| 221 | + webviewResults: [] |
| 222 | + }; |
| 223 | + cellMatches.push(cellMatch); |
| 224 | + } |
| 225 | + }); |
| 226 | + |
| 227 | + const fileMatch = cellMatches.length > 0 ? { |
| 228 | + resource: uri, cellResults: cellMatches |
| 229 | + } : null; |
| 230 | + results.set(uri, fileMatch); |
| 231 | + return; |
| 232 | + |
| 233 | + } catch (e) { |
| 234 | + this.logService.info('error: ' + e); |
| 235 | + return; |
| 236 | + } |
| 237 | + |
| 238 | + }); |
| 239 | + |
| 240 | + await Promise.all(promises); |
| 241 | + const end = Date.now(); |
| 242 | + |
| 243 | + this.logService.trace(`query: ${textQuery.contentPattern.pattern}`); |
| 244 | + this.logService.trace(`closed notebook search time | ${end - start}ms`); |
| 245 | + return { |
| 246 | + results: results, |
| 247 | + limitHit: false |
| 248 | + }; |
| 249 | + } |
| 250 | + |
| 251 | + private async getLocalNotebookResults(query: ITextQuery, token: CancellationToken, widgets: Array<NotebookEditorWidget>): Promise<INotebookSearchMatchResults> { |
| 252 | + const localResults = new ResourceMap<IFileMatchWithCells | null>(uri => this.uriIdentityService.extUri.getComparisonKey(uri)); |
| 253 | + let limitHit = false; |
| 254 | + |
| 255 | + for (const widget of widgets) { |
| 256 | + if (!widget.viewModel) { |
| 257 | + continue; |
| 258 | + } |
| 259 | + const askMax = isNumber(query.maxResults) ? query.maxResults + 1 : Number.MAX_SAFE_INTEGER; |
| 260 | + let matches = await widget |
| 261 | + .find(query.contentPattern.pattern, { |
| 262 | + regex: query.contentPattern.isRegExp, |
| 263 | + wholeWord: query.contentPattern.isWordMatch, |
| 264 | + caseSensitive: query.contentPattern.isCaseSensitive, |
| 265 | + includeMarkupInput: query.contentPattern.notebookInfo?.isInNotebookMarkdownInput, |
| 266 | + includeMarkupPreview: query.contentPattern.notebookInfo?.isInNotebookMarkdownPreview, |
| 267 | + includeCodeInput: query.contentPattern.notebookInfo?.isInNotebookCellInput, |
| 268 | + includeOutput: query.contentPattern.notebookInfo?.isInNotebookCellOutput, |
| 269 | + }, token, false, true); |
| 270 | + |
| 271 | + |
| 272 | + if (matches.length) { |
| 273 | + if (askMax && matches.length >= askMax) { |
| 274 | + limitHit = true; |
| 275 | + matches = matches.slice(0, askMax - 1); |
| 276 | + } |
| 277 | + const cellResults: ICellMatch[] = matches.map(match => { |
| 278 | + const contentResults = contentMatchesToTextSearchMatches(match.contentMatches, match.cell); |
| 279 | + const webviewResults = webviewMatchesToTextSearchMatches(match.webviewMatches); |
| 280 | + return { |
| 281 | + cell: match.cell, |
| 282 | + index: match.index, |
| 283 | + contentResults: contentResults, |
| 284 | + webviewResults: webviewResults, |
| 285 | + }; |
| 286 | + }); |
| 287 | + |
| 288 | + const fileMatch: IFileMatchWithCells = { |
| 289 | + resource: widget.viewModel.uri, cellResults: cellResults |
| 290 | + }; |
| 291 | + localResults.set(widget.viewModel.uri, fileMatch); |
| 292 | + } else { |
| 293 | + localResults.set(widget.viewModel.uri, null); |
| 294 | + } |
| 295 | + } |
| 296 | + |
| 297 | + return { |
| 298 | + results: localResults, |
| 299 | + limitHit |
| 300 | + }; |
| 301 | + } |
| 302 | + |
| 303 | + |
| 304 | + private getLocalNotebookWidgets(): Array<NotebookEditorWidget> { |
| 305 | + const notebookWidgets = this.notebookEditorService.retrieveAllExistingWidgets(); |
| 306 | + return notebookWidgets |
| 307 | + .map(widget => widget.value) |
| 308 | + .filter((val): val is NotebookEditorWidget => !!val && !!(val.viewModel)); |
| 309 | + } |
| 310 | +} |
0 commit comments