diff --git a/extensions/vscode-account/src/AADHelper.ts b/extensions/vscode-account/src/AADHelper.ts index 9a8b98e4ea9ff8d155864cafa3eb7660643df9a4..26bca9f861432cd7297851e112dbf6d475e43d8c 100644 --- a/extensions/vscode-account/src/AADHelper.ts +++ b/extensions/vscode-account/src/AADHelper.ts @@ -4,18 +4,18 @@ *--------------------------------------------------------------------------------------------*/ import * as crypto from 'crypto'; -import * as vscode from 'vscode'; import * as https from 'https'; import * as querystring from 'querystring'; -import { keychain } from './keychain'; -import { toBase64UrlEncoding } from './utils'; +import * as vscode from 'vscode'; import { createServer, startServer } from './authServer'; +import { keychain } from './keychain'; import Logger from './logger'; +import { toBase64UrlEncoding } from './utils'; const redirectUrl = 'https://vscode-redirect.azurewebsites.net/'; const loginEndpointUrl = 'https://login.microsoftonline.com/'; const clientId = 'aebc6443-996d-45c2-90f0-388ff96faa56'; -const scope = 'https://management.core.windows.net/.default offline_access'; +const resourceId = 'https://management.core.windows.net/'; const tenant = 'common'; interface IToken { @@ -24,6 +24,13 @@ interface IToken { refreshToken: string; } +interface ITokenClaims { + email?: string; + unique_name?: string; + oid?: string; + altsecid?: string; +} + export const onDidChangeSessions = new vscode.EventEmitter(); export class AzureActiveDirectoryService { @@ -59,23 +66,20 @@ export class AzureActiveDirectoryService { } private tokenToAccount(token: IToken): vscode.Session { + const claims = this.getTokenClaims(token.accessToken); return { - id: '', + id: claims?.oid || claims?.altsecid || '', accessToken: token.accessToken, - displayName: this.getDisplayNameFromToken(token.accessToken) + displayName: claims?.email || claims?.unique_name || 'user@example.com' }; } - private getDisplayNameFromToken(accessToken: string): string { - let displayName = 'user@example.com'; + private getTokenClaims(accessToken: string): ITokenClaims | undefined { try { - // TODO fixme - displayName = JSON.parse(atob(accessToken.split('.')[1])); + return JSON.parse(Buffer.from(accessToken.split('.')[1], 'base64').toString()); } catch (e) { - // Fall back to example display name + Logger.error(e.message); } - - return displayName; } get sessions(): vscode.Session[] { @@ -108,7 +112,7 @@ export class AzureActiveDirectoryService { const codeVerifier = toBase64UrlEncoding(crypto.randomBytes(32).toString('base64')); const codeChallenge = toBase64UrlEncoding(crypto.createHash('sha256').update(codeVerifier).digest('base64')); - const loginUrl = `${loginEndpointUrl}${tenant}/oauth2/v2.0/authorize?response_type=code&response_mode=query&client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(redirectUrl)}&state=${state}&scope=${encodeURIComponent(scope)}&prompt=select_account&code_challenge_method=S256&code_challenge=${codeChallenge}`; + const loginUrl = `${loginEndpointUrl}${tenant}/oauth2/authorize?response_type=code&response_mode=query&client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(redirectUrl)}&state=${state}&resource=${encodeURIComponent(resourceId)}&prompt=select_account&code_challenge_method=S256&code_challenge=${codeChallenge}`; await redirectReq.res.writeHead(302, { Location: loginUrl }); redirectReq.res.end(); @@ -165,12 +169,12 @@ export class AzureActiveDirectoryService { grant_type: 'authorization_code', code: code, client_id: clientId, - scope: scope, + resource: resourceId, code_verifier: codeVerifier, redirect_uri: redirectUrl }); - const tokenUrl = vscode.Uri.parse(`${loginEndpointUrl}${tenant}/oauth2/v2.0/token`); + const tokenUrl = vscode.Uri.parse(`${loginEndpointUrl}${tenant}/oauth2/token`); const post = https.request({ host: tokenUrl.authority, @@ -220,12 +224,12 @@ export class AzureActiveDirectoryService { refresh_token: refreshToken, client_id: clientId, grant_type: 'refresh_token', - scope: scope + resource: resourceId }); const post = https.request({ host: 'login.microsoftonline.com', - path: `/${tenant}/oauth2/v2.0/token`, + path: `/${tenant}/oauth2/token`, method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', diff --git a/src/vs/code/electron-browser/sharedProcess/sharedProcessMain.ts b/src/vs/code/electron-browser/sharedProcess/sharedProcessMain.ts index af483787e1dabc5408b436b92b89bf7fd73ec78a..a28a1ea8d6c3f1d83d592fd4825269f924433604 100644 --- a/src/vs/code/electron-browser/sharedProcess/sharedProcessMain.ts +++ b/src/vs/code/electron-browser/sharedProcess/sharedProcessMain.ts @@ -12,8 +12,8 @@ import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; import { InstantiationService } from 'vs/platform/instantiation/common/instantiationService'; import { IEnvironmentService, ParsedArgs } from 'vs/platform/environment/common/environment'; import { EnvironmentService } from 'vs/platform/environment/node/environmentService'; -import { ExtensionManagementChannel } from 'vs/platform/extensionManagement/common/extensionManagementIpc'; -import { IExtensionManagementService, IExtensionGalleryService } from 'vs/platform/extensionManagement/common/extensionManagement'; +import { ExtensionManagementChannel, GlobalExtensionEnablementServiceClient } from 'vs/platform/extensionManagement/common/extensionManagementIpc'; +import { IExtensionManagementService, IExtensionGalleryService, IGlobalExtensionEnablementService } from 'vs/platform/extensionManagement/common/extensionManagement'; import { ExtensionManagementService } from 'vs/platform/extensionManagement/node/extensionManagementService'; import { ExtensionGalleryService } from 'vs/platform/extensionManagement/common/extensionGalleryService'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; @@ -185,6 +185,7 @@ async function main(server: Server, initData: ISharedProcessInitData, configurat services.set(IUserDataAuthTokenService, new SyncDescriptor(UserDataAuthTokenService)); services.set(IUserDataSyncLogService, new SyncDescriptor(UserDataSyncLogService)); services.set(IUserDataSyncUtilService, new UserDataSyncUtilServiceClient(server.getChannel('userDataSyncUtil', activeWindowRouter))); + services.set(IGlobalExtensionEnablementService, new GlobalExtensionEnablementServiceClient(server.getChannel('globalExtensionEnablement', activeWindowRouter))); services.set(IUserDataSyncStoreService, new SyncDescriptor(UserDataSyncStoreService)); services.set(ISettingsSyncService, new SyncDescriptor(SettingsSynchroniser)); services.set(IUserDataSyncService, new SyncDescriptor(UserDataSyncService)); diff --git a/src/vs/platform/environment/common/environment.ts b/src/vs/platform/environment/common/environment.ts index 033cdc575f4db66744d457c9f8a34ac27322ffe0..c3a94942b8210dff61055fe8ad3c616d69a243b7 100644 --- a/src/vs/platform/environment/common/environment.ts +++ b/src/vs/platform/environment/common/environment.ts @@ -87,6 +87,8 @@ export interface ParsedArgs { 'nolazy'?: boolean; 'force-device-scale-factor'?: string; 'force-renderer-accessibility'?: boolean; + 'ignore-certificate-error'?: boolean; + 'allow-insecure-localhost'?: boolean; } export const IEnvironmentService = createDecorator('environmentService'); diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts index 6832b93c5cb86a6579500d52980ea6320e43337e..e68e0647c32624d35b7f77cca8098d323b8be552 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -119,6 +119,8 @@ export const OPTIONS: OptionDescriptions> = { 'nolazy': { type: 'boolean' }, // node inspect 'force-device-scale-factor': { type: 'string' }, 'force-renderer-accessibility': { type: 'boolean' }, + 'ignore-certificate-error': { type: 'boolean' }, + 'allow-insecure-localhost': { type: 'boolean' }, '_urls': { type: 'string[]' }, _: { type: 'string[]' } // main arguments diff --git a/src/vs/platform/environment/node/argvHelper.ts b/src/vs/platform/environment/node/argvHelper.ts index 2aef6dff05ca62e947a531b1a5fc0066d7a840ca..55af70aeda50f92ae22e24f9c2430c3cd2e1e018 100644 --- a/src/vs/platform/environment/node/argvHelper.ts +++ b/src/vs/platform/environment/node/argvHelper.ts @@ -13,7 +13,7 @@ import { parseArgs, ErrorReporter, OPTIONS } from 'vs/platform/environment/node/ function parseAndValidate(cmdLineArgs: string[], reportWarnings: boolean): ParsedArgs { const errorReporter: ErrorReporter = { onUnknownOption: (id) => { - console.warn(localize('unknownOption', "Option '{0}' is unknown. Ignoring.", id)); + console.warn(localize('unknownOption', "Warning: '{0}' is not in the list of known options, but still passed to Electron/Chromium.", id)); }, onMultipleValues: (id, val) => { console.warn(localize('multipleValues', "Option '{0}' is defined more than once. Using value '{1}.'", id, val)); diff --git a/src/vs/platform/extensionManagement/common/extensionEnablementService.ts b/src/vs/platform/extensionManagement/common/extensionEnablementService.ts index f4d9b1ee806873b70309d108369b81f6e8149283..a8fe676d7cf19a73cecffaa35c697328785fb9d8 100644 --- a/src/vs/platform/extensionManagement/common/extensionEnablementService.ts +++ b/src/vs/platform/extensionManagement/common/extensionEnablementService.ts @@ -44,6 +44,10 @@ export class GlobalExtensionEnablementService extends Disposable implements IGlo return this._getExtensions(DISABLED_EXTENSIONS_STORAGE_PATH); } + async getDisabledExtensionsAsync(): Promise { + return this.getDisabledExtensions(); + } + private _addToDisabledExtensions(identifier: IExtensionIdentifier): boolean { let disabledExtensions = this.getDisabledExtensions(); if (disabledExtensions.every(e => !areSameExtensions(e, identifier))) { diff --git a/src/vs/platform/extensionManagement/common/extensionManagement.ts b/src/vs/platform/extensionManagement/common/extensionManagement.ts index d0450f544efeb6ca4b35e9cbf810d5845ca7e328..1c73c5ad34603e2d60df5e7dd127cc4adf743135 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagement.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagement.ts @@ -214,9 +214,13 @@ export const IGlobalExtensionEnablementService = createDecorator; + getDisabledExtensions(): IExtensionIdentifier[]; enableExtension(extension: IExtensionIdentifier): Promise; disableExtension(extension: IExtensionIdentifier): Promise; + + // Async method until storage service is available in shared process + getDisabledExtensionsAsync(): Promise; } export const ExtensionsLabel = localize('extensions', "Extensions"); diff --git a/src/vs/platform/extensionManagement/common/extensionManagementIpc.ts b/src/vs/platform/extensionManagement/common/extensionManagementIpc.ts index 035559f7ec6f1d1bcac5c5fb9f2ce764a9aef615..b5f8114efe0be7d087fcdfa27d0098aff8506509 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagementIpc.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagementIpc.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IChannel, IServerChannel } from 'vs/base/parts/ipc/common/ipc'; -import { IExtensionManagementService, ILocalExtension, InstallExtensionEvent, DidInstallExtensionEvent, IGalleryExtension, DidUninstallExtensionEvent, IExtensionIdentifier, IGalleryMetadata, IReportedExtension } from 'vs/platform/extensionManagement/common/extensionManagement'; +import { IExtensionManagementService, ILocalExtension, InstallExtensionEvent, DidInstallExtensionEvent, IGalleryExtension, DidUninstallExtensionEvent, IExtensionIdentifier, IGalleryMetadata, IReportedExtension, IGlobalExtensionEnablementService } from 'vs/platform/extensionManagement/common/extensionManagement'; import { Event } from 'vs/base/common/event'; import { URI, UriComponents } from 'vs/base/common/uri'; import { IURITransformer, DefaultURITransformer, transformAndReviveIncomingURIs } from 'vs/base/common/uriIpc'; @@ -130,3 +130,53 @@ export class ExtensionManagementChannelClient implements IExtensionManagementSer return Promise.resolve(this.channel.call('getExtensionsReport')); } } + +export class GlobalExtensionEnablementServiceChannel implements IServerChannel { + + constructor(private readonly service: IGlobalExtensionEnablementService) { } + + listen(_: unknown, event: string): Event { + switch (event) { + case 'onDidChangeEnablement': return this.service.onDidChangeEnablement; + } + throw new Error(`Event not found: ${event}`); + } + + call(context: any, command: string, args?: any): Promise { + switch (command) { + case 'getDisabledExtensionsAsync': return Promise.resolve(this.service.getDisabledExtensions()); + case 'enableExtension': return this.service.enableExtension(args[0]); + case 'disableExtension': return this.service.disableExtension(args[0]); + } + throw new Error('Invalid call'); + } +} + +export class GlobalExtensionEnablementServiceClient implements IGlobalExtensionEnablementService { + + _serviceBrand: undefined; + + get onDidChangeEnablement(): Event { return this.channel.listen('onDidChangeEnablement'); } + + constructor(private readonly channel: IChannel) { + } + + getDisabledExtensionsAsync(): Promise { + return this.channel.call('getDisabledExtensionsAsync'); + } + + enableExtension(extension: IExtensionIdentifier): Promise { + return this.channel.call('enableExtension', [extension]); + } + + disableExtension(extension: IExtensionIdentifier): Promise { + return this.channel.call('disableExtension', [extension]); + } + + getDisabledExtensions(): IExtensionIdentifier[] { + throw new Error('not supported'); + } + +} + + diff --git a/src/vs/platform/userDataSync/common/extensionsMerge.ts b/src/vs/platform/userDataSync/common/extensionsMerge.ts index 364c7513ecd4732a551712296c1727b3fbed5f3f..5d02314b264a6e97277505681b200badf4992940 100644 --- a/src/vs/platform/userDataSync/common/extensionsMerge.ts +++ b/src/vs/platform/userDataSync/common/extensionsMerge.ts @@ -100,14 +100,8 @@ export function merge(localExtensions: ISyncExtension[], remoteExtensions: ISync // Remotely updated extensions for (const key of values(baseToRemote.updated)) { - // If updated in local - if (baseToLocal.updated.has(key)) { - // Is different from local to remote - if (localToRemote.updated.has(key)) { - // update it in local - updated.push(massageSyncExtension(remoteExtensionsMap.get(key)!, key)); - } - } + // Update in local always + updated.push(massageSyncExtension(remoteExtensionsMap.get(key)!, key)); } // Locally added extensions diff --git a/src/vs/platform/userDataSync/common/extensionsSync.ts b/src/vs/platform/userDataSync/common/extensionsSync.ts index 4d0c3595fa0bd958d04efba35f10a53628db6490..d00d62def184c9d1f261176f427be8504500e8f8 100644 --- a/src/vs/platform/userDataSync/common/extensionsSync.ts +++ b/src/vs/platform/userDataSync/common/extensionsSync.ts @@ -9,7 +9,7 @@ import { Emitter, Event } from 'vs/base/common/event'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; import { URI } from 'vs/base/common/uri'; import { joinPath } from 'vs/base/common/resources'; -import { IExtensionManagementService, IExtensionGalleryService } from 'vs/platform/extensionManagement/common/extensionManagement'; +import { IExtensionManagementService, IExtensionGalleryService, IGlobalExtensionEnablementService } from 'vs/platform/extensionManagement/common/extensionManagement'; import { ExtensionType, IExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; import { areSameExtensions } from 'vs/platform/extensionManagement/common/extensionManagementUtil'; import { IFileService } from 'vs/platform/files/common/files'; @@ -53,6 +53,7 @@ export class ExtensionsSynchroniser extends AbstractSynchroniser implements IUse @IFileService fileService: IFileService, @IUserDataSyncStoreService private readonly userDataSyncStoreService: IUserDataSyncStoreService, @IExtensionManagementService private readonly extensionManagementService: IExtensionManagementService, + @IGlobalExtensionEnablementService private readonly extensionEnablementService: IGlobalExtensionEnablementService, @IUserDataSyncLogService private readonly logService: IUserDataSyncLogService, @IExtensionGalleryService private readonly extensionGalleryService: IExtensionGalleryService, @IConfigurationService private readonly configurationService: IConfigurationService, @@ -285,6 +286,11 @@ export class ExtensionsSynchroniser extends AbstractSynchroniser implements IUse try { await this.extensionManagementService.installFromGallery(extension); removeFromSkipped.push(extension.identifier); + if (e.enabled) { + await this.extensionEnablementService.enableExtension(extension.identifier); + } else { + await this.extensionEnablementService.disableExtension(extension.identifier); + } } catch (error) { addToSkipped.push(e); this.logService.error(error); @@ -312,8 +318,9 @@ export class ExtensionsSynchroniser extends AbstractSynchroniser implements IUse private async getLocalExtensions(): Promise { const installedExtensions = await this.extensionManagementService.getInstalled(ExtensionType.User); + const disabledExtensions = await this.extensionEnablementService.getDisabledExtensionsAsync(); return installedExtensions - .map(({ identifier }) => ({ identifier, enabled: true })); + .map(({ identifier }) => ({ identifier, enabled: !disabledExtensions.some(disabledExtension => areSameExtensions(disabledExtension, identifier)) })); } private async getLastSyncUserData(): Promise { diff --git a/src/vs/workbench/browser/parts/editor/editorGroupView.ts b/src/vs/workbench/browser/parts/editor/editorGroupView.ts index d7f2cedfeb83a6f4837bbb467eb2395ec2ba7469..6de26a8919b65435481ee177e64b0b843acd9a6a 100644 --- a/src/vs/workbench/browser/parts/editor/editorGroupView.ts +++ b/src/vs/workbench/browser/parts/editor/editorGroupView.ts @@ -835,7 +835,7 @@ export class EditorGroupView extends Themable implements IEditorGroupView { // Determine options const openEditorOptions: IEditorOpenOptions = { index: options ? options.index : undefined, - pinned: !this.accessor.partOptions.enablePreview || editor.isDirty() || options?.pinned || typeof options?.index === 'number', + pinned: !this.accessor.partOptions.enablePreview || editor.isDirty() || (options?.pinned ?? typeof options?.index === 'number'), // unless specified, prefer to pin when opening with index active: this._group.count === 0 || !options || !options.inactive }; @@ -1497,7 +1497,7 @@ export class EditorGroupView extends Themable implements IEditorGroupView { } options.inactive = !isActiveEditor; - options.pinned = true; + options.pinned = options.pinned ?? true; // unless specified, prefer to pin upon replace const editorToReplace = { editor, replacement, options }; if (isActiveEditor) { diff --git a/src/vs/workbench/browser/parts/editor/textDiffEditor.ts b/src/vs/workbench/browser/parts/editor/textDiffEditor.ts index aaa2387c55bc7e00c413cd7a856b5b058b43a777..036cb7b0ea03ff1c7c9bc24dfefad0d9165cd948 100644 --- a/src/vs/workbench/browser/parts/editor/textDiffEditor.ts +++ b/src/vs/workbench/browser/parts/editor/textDiffEditor.ts @@ -175,7 +175,7 @@ export class TextDiffEditor extends BaseTextEditor implements ITextDiffEditor { // because we are triggering another openEditor() call // and do not control the initial intent that resulted // in us now opening as binary. - const preservingOptions: IEditorOptions = { activation: EditorActivation.PRESERVE }; + const preservingOptions: IEditorOptions = { activation: EditorActivation.PRESERVE, pinned: this.group?.isPinned(input) }; if (options) { options.overwrite(preservingOptions); } else { diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index f2a68f351d3d0dfe91caec9247ca4ab9c209c734..60b31ed492af7921d02401a76eaa1cffb5954286 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -133,8 +133,7 @@ import { URI } from 'vs/base/common/uri'; 'workbench.editor.mouseBackForwardToNavigate': { 'type': 'boolean', 'description': nls.localize('mouseBackForwardToNavigate', "Navigate between open files using mouse buttons four and five if provided."), - 'default': true, - 'included': !isMacintosh + 'default': true }, 'workbench.editor.restoreViewState': { 'type': 'boolean', diff --git a/src/vs/workbench/contrib/backup/electron-browser/backupTracker.ts b/src/vs/workbench/contrib/backup/electron-browser/backupTracker.ts index feabf4e152c5651da05226780a4e8c7f21f1e70b..0fa5598ed2ea43a74db1366bfdb00aeb6ec47b61 100644 --- a/src/vs/workbench/contrib/backup/electron-browser/backupTracker.ts +++ b/src/vs/workbench/contrib/backup/electron-browser/backupTracker.ts @@ -70,20 +70,17 @@ export class NativeBackupTracker extends BackupTracker implements IWorkbenchCont private async handleDirtyBeforeShutdown(workingCopies: IWorkingCopy[], reason: ShutdownReason): Promise { // Trigger backup if configured - let didBackup: boolean | undefined = undefined; let backupError: Error | undefined = undefined; if (this.filesConfigurationService.isHotExitEnabled) { try { - didBackup = await this.backupBeforeShutdown(workingCopies, reason); + if (await this.backupBeforeShutdown(workingCopies, reason)) { + return this.noVeto({ discardAllBackups: false }); // no veto (backup was successful) + } } catch (error) { backupError = error; } } - if (didBackup) { - return this.noVeto({ discardAllBackups: false }); // no veto (backup was successful) - } - // we ran a backup but received an error that we show to the user if (backupError) { this.showErrorDialog(localize('backupTrackerBackupFailed', "One or many editors that are dirty could not be saved to the backup location."), backupError); @@ -169,7 +166,7 @@ export class NativeBackupTracker extends BackupTracker implements IWorkbenchCont await this.doSaveAllBeforeShutdown(true /* includeUntitled */, SaveReason.EXPLICIT); if (this.workingCopyService.hasDirty) { - return true; // veto if any save failed or was canceled + return true; // veto (save failed or was canceled) } return this.noVeto({ discardAllBackups: true }); // no veto (dirty saved) @@ -194,10 +191,11 @@ export class NativeBackupTracker extends BackupTracker implements IWorkbenchCont // First save through the editor service to benefit // from some extras like switching to untitled dirty // editors before saving. - await this.editorService.saveAll({ includeUntitled, ...saveOptions }); + const result = await this.editorService.saveAll({ includeUntitled, ...saveOptions }); // If we still have dirty working copies, save those directly - if (this.workingCopyService.hasDirty) { + // unless the save was not successful (e.g. cancelled) + if (result) { await Promise.all(this.workingCopyService.dirtyWorkingCopies.map(async workingCopy => { if (!includeUntitled && (workingCopy.capabilities & WorkingCopyCapabilities.Untitled)) { return; // skip untitled unless explicitly included @@ -214,10 +212,11 @@ export class NativeBackupTracker extends BackupTracker implements IWorkbenchCont const revertOptions = { soft: true }; // First revert through the editor service - await this.editorService.revertAll(revertOptions); + const result = await this.editorService.revertAll(revertOptions); // If we still have dirty working copies, revert those directly - if (this.workingCopyService.hasDirty) { + // unless the revert operation was not successful (e.g. cancelled) + if (result) { await Promise.all(this.workingCopyService.dirtyWorkingCopies.map(workingCopy => workingCopy.revert(revertOptions))); } } diff --git a/src/vs/workbench/contrib/customEditor/browser/customEditors.ts b/src/vs/workbench/contrib/customEditor/browser/customEditors.ts index bc76a68485d57831ab49c2ae06f312f6247e0be9..2afd16f5f631c9b2a84184b19c06fa77a280d276 100644 --- a/src/vs/workbench/contrib/customEditor/browser/customEditors.ts +++ b/src/vs/workbench/contrib/customEditor/browser/customEditors.ts @@ -441,7 +441,7 @@ export class CustomEditorContribution implements IWorkbenchContribution { if (modifiedOverride || originalOverride) { return { override: (async () => { - const input = new DiffEditorInput(editor.getName(), editor.getDescription(), originalOverride || editor.originalInput, modifiedOverride || editor.modifiedInput); + const input = new DiffEditorInput(editor.getName(), editor.getDescription(), originalOverride || editor.originalInput, modifiedOverride || editor.modifiedInput, true); return this.editorService.openEditor(input, { ...options, ignoreOverrides: true }, group); })(), }; diff --git a/src/vs/workbench/contrib/search/browser/search.contribution.ts b/src/vs/workbench/contrib/search/browser/search.contribution.ts index e54aa07912ad0c77c0433ddf5483814610126651..30d72f8111313c64fbaae9cec4a1dec6f2a17453 100644 --- a/src/vs/workbench/contrib/search/browser/search.contribution.ts +++ b/src/vs/workbench/contrib/search/browser/search.contribution.ts @@ -56,10 +56,11 @@ import { ExplorerViewPaneContainer } from 'vs/workbench/contrib/files/browser/ex import { assertType } from 'vs/base/common/types'; import { SearchViewPaneContainer } from 'vs/workbench/contrib/search/browser/searchViewlet'; import { EditorDescriptor, Extensions as EditorExtensions, IEditorRegistry } from 'vs/workbench/browser/editor'; -import { SearchEditorInput, SearchEditorInputFactory, SearchEditorContribution } from 'vs/workbench/contrib/search/browser/searchEditorCommands'; +import { SearchEditorInput, SearchEditorInputFactory, SearchEditorContribution } from 'vs/workbench/contrib/search/browser/searchEditorInput'; import { SearchEditor } from 'vs/workbench/contrib/search/browser/searchEditor'; import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; import { Extensions as EditorInputExtensions, IEditorInputFactoryRegistry } from 'vs/workbench/common/editor'; +import product from 'vs/platform/product/common/product'; registerSingleton(ISearchWorkbenchService, SearchWorkbenchService, true); registerSingleton(ISearchHistoryService, SearchHistoryService, true); @@ -854,7 +855,7 @@ configurationRegistry.registerConfiguration({ }, 'search.enableSearchEditorPreview': { type: 'boolean', - default: false, + default: product.quality !== 'stable', description: nls.localize('search.enableSearchEditorPreview', "Experimental: When enabled, allows opening workspace search results in an editor.") }, 'search.searchEditorPreview.doubleClickBehaviour': { diff --git a/src/vs/workbench/contrib/search/browser/searchActions.ts b/src/vs/workbench/contrib/search/browser/searchActions.ts index b8f13ef18326660f72131752c707f3d53408f787..b0021171811c037edd283527f54204664dd10124 100644 --- a/src/vs/workbench/contrib/search/browser/searchActions.ts +++ b/src/vs/workbench/contrib/search/browser/searchActions.ts @@ -29,10 +29,9 @@ import { IViewletService } from 'vs/workbench/services/viewlet/browser/viewlet'; import { SearchViewPaneContainer } from 'vs/workbench/contrib/search/browser/searchViewlet'; import { SearchPanel } from 'vs/workbench/contrib/search/browser/searchPanel'; import { ITreeNavigator } from 'vs/base/browser/ui/tree/tree'; -import { createEditorFromSearchResult, openNewSearchEditor, SearchEditorInput } from 'vs/workbench/contrib/search/browser/searchEditorCommands'; - +import { createEditorFromSearchResult, openNewSearchEditor } from 'vs/workbench/contrib/search/browser/searchEditorActions'; import type { SearchEditor } from 'vs/workbench/contrib/search/browser/searchEditor'; -import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; +import { SearchEditorInput } from 'vs/workbench/contrib/search/browser/searchEditorInput'; export function isSearchViewFocused(viewletService: IViewletService, panelService: IPanelService): boolean { const searchView = getSearchView(viewletService, panelService); @@ -561,7 +560,11 @@ export class OpenSearchEditorAction extends Action { @IConfigurationService private configurationService: IConfigurationService, @IInstantiationService private readonly instantiationService: IInstantiationService, ) { - super(id, label); + super(id, label, 'codicon-new-file'); + } + + update() { + // pass } get enabled(): boolean { @@ -587,7 +590,6 @@ export class OpenResultsInEditorAction extends Action { @IEditorService private editorService: IEditorService, @IConfigurationService private configurationService: IConfigurationService, @IInstantiationService private readonly instantiationService: IInstantiationService, - @ITextFileService private readonly textFileService: ITextFileService ) { super(id, label, 'codicon-go-to-file'); } @@ -604,7 +606,7 @@ export class OpenResultsInEditorAction extends Action { async run() { const searchView = getSearchView(this.viewletService, this.panelService); if (searchView && this.configurationService.getValue('search').enableSearchEditorPreview) { - await createEditorFromSearchResult(searchView.searchResult, searchView.searchIncludePattern.getValue(), searchView.searchExcludePattern.getValue(), this.labelService, this.editorService, this.textFileService, this.instantiationService); + await createEditorFromSearchResult(searchView.searchResult, searchView.searchIncludePattern.getValue(), searchView.searchExcludePattern.getValue(), this.labelService, this.editorService, this.instantiationService); } } } diff --git a/src/vs/workbench/contrib/search/browser/searchEditor.ts b/src/vs/workbench/contrib/search/browser/searchEditor.ts index 5a6064f11b30d6917c27f2307f92873fad0048ec..be086a6a09540c6a39368ffa52b70541900cc0b4 100644 --- a/src/vs/workbench/contrib/search/browser/searchEditor.ts +++ b/src/vs/workbench/contrib/search/browser/searchEditor.ts @@ -23,10 +23,10 @@ import { IInstantiationService } from 'vs/platform/instantiation/common/instanti import { ILabelService } from 'vs/platform/label/common/label'; import { IStorageService } from 'vs/platform/storage/common/storage'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; -import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { IThemeService, registerThemingParticipant } from 'vs/platform/theme/common/themeService'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { BaseEditor } from 'vs/workbench/browser/parts/editor/baseEditor'; -import { EditorInput, EditorOptions } from 'vs/workbench/common/editor'; +import { EditorOptions } from 'vs/workbench/common/editor'; import { ExcludePatternInputWidget, PatternInputWidget } from 'vs/workbench/contrib/search/browser/patternInputWidget'; import { SearchWidget } from 'vs/workbench/contrib/search/browser/searchWidget'; import { ITextQueryBuilderOptions, QueryBuilder } from 'vs/workbench/contrib/search/common/queryBuilder'; @@ -34,19 +34,15 @@ import { getOutOfWorkspaceEditorResources } from 'vs/workbench/contrib/search/co import { SearchModel } from 'vs/workbench/contrib/search/common/searchModel'; import { IPatternInfo, ISearchConfigurationProperties, ITextQuery } from 'vs/workbench/services/search/common/search'; import { Delayer } from 'vs/base/common/async'; -import { serializeSearchResultForEditor, SearchConfiguration, SearchEditorInput } from 'vs/workbench/contrib/search/browser/searchEditorCommands'; -import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; +import { serializeSearchResultForEditor } from 'vs/workbench/contrib/search/browser/searchEditorSerialization'; import { IContextKeyService, IContextKey } from 'vs/platform/contextkey/common/contextkey'; import { InSearchEditor, InputBoxFocusedKey } from 'vs/workbench/contrib/search/common/constants'; import { IEditorProgressService, LongRunningOperation } from 'vs/platform/progress/common/progress'; +import type { SearchEditorInput, SearchConfiguration } from 'vs/workbench/contrib/search/browser/searchEditorInput'; +import { searchEditorFindMatchBorder, searchEditorFindMatch } from 'vs/platform/theme/common/colorRegistry'; const RESULT_LINE_REGEX = /^(\s+)(\d+)(:| )(\s+)(.*)$/; -// Using \r\n on Windows inserts an extra newline between results. -const lineDelimiter = '\n'; - - - export class SearchEditor extends BaseEditor { static readonly ID: string = 'workbench.editor.searchEditor'; @@ -78,7 +74,6 @@ export class SearchEditor extends BaseEditor { @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextViewService private readonly contextViewService: IContextViewService, @ICommandService private readonly commandService: ICommandService, - @ITextFileService private readonly textFileService: ITextFileService, @IContextKeyService readonly contextKeyService: IContextKeyService, @IEditorProgressService readonly progressService: IEditorProgressService, ) { @@ -304,16 +299,17 @@ export class SearchEditor extends BaseEditor { return; } - (assertIsDefined(this._input) as SearchEditorInput).setConfig(config); const labelFormatter = (uri: URI): string => this.labelService.getUriLabel(uri, { relative: true }); const results = serializeSearchResultForEditor(searchModel.searchResult, config.includes, config.excludes, config.contextLines, labelFormatter, true); const textModel = assertIsDefined(this.searchResultEditor.getModel()); - this.modelService.updateModel(textModel, results.text.join(lineDelimiter)); + this.modelService.updateModel(textModel, results.text); this.getInput()?.setDirty(this.getInput()?.resource.scheme !== 'search-editor'); this.hideHeader(); textModel.deltaDecorations([], results.matchRanges.map(range => ({ range, options: { className: 'searchEditorFindMatch', stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges } }))); + (assertIsDefined(this._input) as SearchEditorInput).reloadModel(); + searchModel.dispose(); } @@ -327,7 +323,8 @@ export class SearchEditor extends BaseEditor { .length ?? 0; - this.searchResultEditor.setHiddenAreas([new Range(1, 1, headerLines + 1, 1)]); + // const length = this.searchResultEditor.getModel()?.getLineLength(headerLines); + this.searchResultEditor.setHiddenAreas([new Range(1, 1, headerLines, 1)]); } layout(dimension: DOM.Dimension) { @@ -339,6 +336,14 @@ export class SearchEditor extends BaseEditor { this.queryEditorWidget.focus(); } + getSelected() { + const selection = this.searchResultEditor.getSelection(); + if (selection) { + return this.searchResultEditor.getModel()?.getValueInRange(selection) ?? ''; + } + return ''; + } + private reLayout() { if (this.dimension) { this.queryEditorWidget.setWidth(this.dimension.width - 28 /* container margin */); @@ -352,38 +357,26 @@ export class SearchEditor extends BaseEditor { return this._input as SearchEditorInput; } - async setInput(newInput: EditorInput, options: EditorOptions | undefined, token: CancellationToken): Promise { + async setInput(newInput: SearchEditorInput, options: EditorOptions | undefined, token: CancellationToken): Promise { await super.setInput(newInput, options, token); this.inSearchEditorContextKey.set(true); - if (!(newInput instanceof SearchEditorInput)) { return; } - - const model = assertIsDefined(this.modelService.getModel(newInput.resource)); + const { model } = await newInput.reloadModel(); this.searchResultEditor.setModel(model); - - const backup = await newInput.resolveBackup(); - if (backup) { - model.setValueFromTextBuffer(backup); - } else { - if (newInput.resource.scheme !== 'search-editor') { - if (model.getValue() === '') { - model.setValue((await this.textFileService.read(newInput.resource)).value); - } - } - } - this.hideHeader(); this.pauseSearching = true; - this.queryEditorWidget.setValue(newInput.config.query, true); - this.queryEditorWidget.searchInput.setCaseSensitive(newInput.config.caseSensitive); - this.queryEditorWidget.searchInput.setRegex(newInput.config.regexp); - this.queryEditorWidget.searchInput.setWholeWords(newInput.config.wholeWord); - this.queryEditorWidget.setContextLines(newInput.config.contextLines); - this.inputPatternExcludes.setValue(newInput.config.excludes); - this.inputPatternIncludes.setValue(newInput.config.includes); - this.inputPatternExcludes.setUseExcludesAndIgnoreFiles(newInput.config.useIgnores); - this.toggleIncludesExcludes(newInput.config.showIncludesExcludes); + const { query } = await newInput.reloadModel(); + + this.queryEditorWidget.setValue(query.query, true); + this.queryEditorWidget.searchInput.setCaseSensitive(query.caseSensitive); + this.queryEditorWidget.searchInput.setRegex(query.regexp); + this.queryEditorWidget.searchInput.setWholeWords(query.wholeWord); + this.queryEditorWidget.setContextLines(query.contextLines); + this.inputPatternExcludes.setValue(query.excludes); + this.inputPatternIncludes.setValue(query.includes); + this.inputPatternExcludes.setUseExcludesAndIgnoreFiles(query.useIgnores); + this.toggleIncludesExcludes(query.showIncludesExcludes); this.focusInput(); this.pauseSearching = false; @@ -415,3 +408,12 @@ export class SearchEditor extends BaseEditor { this.inSearchEditorContextKey.set(false); } } + +registerThemingParticipant((theme, collector) => { + collector.addRule(`.monaco-editor .searchEditorFindMatch { background-color: ${theme.getColor(searchEditorFindMatch)}; }`); + + const findMatchHighlightBorder = theme.getColor(searchEditorFindMatchBorder); + if (findMatchHighlightBorder) { + collector.addRule(`.monaco-editor .searchEditorFindMatch { border: 1px ${theme.type === 'hc' ? 'dotted' : 'solid'} ${findMatchHighlightBorder}; box-sizing: border-box; }`); + } +}); diff --git a/src/vs/workbench/contrib/search/browser/searchEditorActions.ts b/src/vs/workbench/contrib/search/browser/searchEditorActions.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5865ede77e8d90fd59cebe016a967a7ad4846c6 --- /dev/null +++ b/src/vs/workbench/contrib/search/browser/searchEditorActions.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { assertIsDefined } from 'vs/base/common/types'; +import { URI } from 'vs/base/common/uri'; +import 'vs/css!./media/searchEditor'; +import { isDiffEditor, ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { TrackedRangeStickiness } from 'vs/editor/common/model'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { ILabelService } from 'vs/platform/label/common/label'; +import { SearchResult } from 'vs/workbench/contrib/search/common/searchModel'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { SearchEditor } from 'vs/workbench/contrib/search/browser/searchEditor'; +import { getOrMakeSearchEditorInput, SearchEditorInput } from 'vs/workbench/contrib/search/browser/searchEditorInput'; +import { serializeSearchResultForEditor, serializeSearchConfiguration } from 'vs/workbench/contrib/search/browser/searchEditorSerialization'; + + +export const openNewSearchEditor = + async (editorService: IEditorService, instantiationService: IInstantiationService) => { + const activeEditor = editorService.activeTextEditorWidget; + let activeModel: ICodeEditor | undefined; + let selected = ''; + if (activeEditor) { + if (isDiffEditor(activeEditor)) { + if (activeEditor.getOriginalEditor().hasTextFocus()) { + activeModel = activeEditor.getOriginalEditor(); + } else { + activeModel = activeEditor.getModifiedEditor(); + } + } else { + activeModel = activeEditor as ICodeEditor; + } + const selection = activeModel?.getSelection(); + selected = (selection && activeModel?.getModel()?.getValueInRange(selection)) ?? ''; + } else { + if (editorService.activeEditor instanceof SearchEditorInput) { + const active = editorService.activeControl as SearchEditor; + selected = active.getSelected(); + } + } + + const input = instantiationService.invokeFunction(getOrMakeSearchEditorInput, { text: serializeSearchConfiguration({ query: selected }) }); + await editorService.openEditor(input, { pinned: true }); + }; + +export const createEditorFromSearchResult = + async (searchResult: SearchResult, rawIncludePattern: string, rawExcludePattern: string, labelService: ILabelService, editorService: IEditorService, instantiationService: IInstantiationService) => { + if (!searchResult.query) { + console.error('Expected searchResult.query to be defined. Got', searchResult); + return; + } + + const labelFormatter = (uri: URI): string => labelService.getUriLabel(uri, { relative: true }); + + const { text, matchRanges } = serializeSearchResultForEditor(searchResult, rawIncludePattern, rawExcludePattern, 0, labelFormatter, true); + + const input = instantiationService.invokeFunction(getOrMakeSearchEditorInput, { text }); + const editor = await editorService.openEditor(input, { pinned: true }) as SearchEditor; + const model = assertIsDefined(editor.getModel()); + model.deltaDecorations([], matchRanges.map(range => ({ range, options: { className: 'searchEditorFindMatch', stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges } }))); + }; diff --git a/src/vs/workbench/contrib/search/browser/searchEditorCommands.ts b/src/vs/workbench/contrib/search/browser/searchEditorCommands.ts deleted file mode 100644 index 554c806632bdd1b6fd53f613a6ae871002e3c368..0000000000000000000000000000000000000000 --- a/src/vs/workbench/contrib/search/browser/searchEditorCommands.ts +++ /dev/null @@ -1,586 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { coalesce, flatten } from 'vs/base/common/arrays'; -import * as network from 'vs/base/common/network'; -import { repeat, endsWith } from 'vs/base/common/strings'; -import { assertIsDefined } from 'vs/base/common/types'; -import { URI } from 'vs/base/common/uri'; -import 'vs/css!./media/searchEditor'; -import { isDiffEditor, ICodeEditor } from 'vs/editor/browser/editorBrowser'; -import { Range } from 'vs/editor/common/core/range'; -import { EndOfLinePreference, TrackedRangeStickiness, ITextModel, ITextBuffer, DefaultEndOfLine } from 'vs/editor/common/model'; -import { localize } from 'vs/nls'; -import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { ILabelService } from 'vs/platform/label/common/label'; -import { searchEditorFindMatch, searchEditorFindMatchBorder } from 'vs/platform/theme/common/colorRegistry'; -import { registerThemingParticipant } from 'vs/platform/theme/common/themeService'; -import { UntitledTextEditorInput } from 'vs/workbench/common/editor/untitledTextEditorInput'; -import { FileMatch, Match, searchMatchComparer, SearchResult } from 'vs/workbench/contrib/search/common/searchModel'; -import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; -import { ITextQuery } from 'vs/workbench/services/search/common/search'; -import { IEditorInputFactory, GroupIdentifier, EditorInput, SaveContext, IRevertOptions } from 'vs/workbench/common/editor'; -import { IModelService } from 'vs/editor/common/services/modelService'; -import { IModeService } from 'vs/editor/common/services/modeService'; -import { SearchEditor } from 'vs/workbench/contrib/search/browser/searchEditor'; -import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; -import { ITextFileSaveOptions, ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; -import type { IWorkbenchContribution } from 'vs/workbench/common/contributions'; -import { FileEditorInput } from 'vs/workbench/contrib/files/common/editors/fileEditorInput'; -import { dirname, joinPath, isEqual } from 'vs/base/common/resources'; -import { IHistoryService } from 'vs/workbench/services/history/common/history'; -import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; -import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; -import { basename } from 'vs/base/common/path'; -import { IWorkingCopyService, WorkingCopyCapabilities, IWorkingCopy, IWorkingCopyBackup } from 'vs/workbench/services/workingCopy/common/workingCopyService'; -import { IBackupFileService } from 'vs/workbench/services/backup/common/backup'; - - - -export type SearchConfiguration = { - query: string, - includes: string, - excludes: string - contextLines: number, - wholeWord: boolean, - caseSensitive: boolean, - regexp: boolean, - useIgnores: boolean, - showIncludesExcludes: boolean, -}; - -export class SearchEditorContribution implements IWorkbenchContribution { - constructor( - @IEditorService private readonly editorService: IEditorService, - @ITextFileService protected readonly textFileService: ITextFileService, - @IInstantiationService protected readonly instantiationService: IInstantiationService, - @IModelService protected readonly modelService: IModelService, - ) { - - this.editorService.overrideOpenEditor((editor, options, group) => { - const resource = editor.getResource(); - if (!resource || - !(endsWith(resource.path, '.code-search') || resource.scheme === 'search-editor') || - !(editor instanceof FileEditorInput || (resource.scheme === 'search-editor'))) { - return undefined; - } - - if (group.isOpened(editor)) { - return undefined; - } - - return { - override: (async () => { - const contents = resource.scheme === 'search-editor' ? this.modelService.getModel(resource)?.getValue() ?? '' : (await this.textFileService.read(resource)).value; - const header = searchHeaderToContentPattern(contents.split('\n').slice(0, 5)); - - const input = instantiationService.createInstance( - SearchEditorInput, - { - query: header.pattern, - regexp: header.flags.regex, - caseSensitive: header.flags.caseSensitive, - wholeWord: header.flags.wholeWord, - includes: header.includes, - excludes: header.excludes, - contextLines: header.context ?? 0, - useIgnores: !header.flags.ignoreExcludes, - showIncludesExcludes: !!(header.includes || header.excludes || header.flags.ignoreExcludes) - }, contents, resource); - - return editorService.openEditor(input, { ...options, pinned: resource.scheme === 'search-editor', ignoreOverrides: true }, group); - })() - }; - }); - } -} - -export class SearchEditorInputFactory implements IEditorInputFactory { - - canSerialize() { return true; } - - serialize(input: SearchEditorInput) { - let resource = undefined; - if (input.resource.path || input.resource.fragment) { - resource = input.resource.toString(); - } - - return JSON.stringify({ ...input.config, resource, dirty: input.isDirty() }); - } - - deserialize(instantiationService: IInstantiationService, serializedEditorInput: string): SearchEditorInput | undefined { - const { resource, dirty, ...config } = JSON.parse(serializedEditorInput); - const input = instantiationService.createInstance(SearchEditorInput, config, undefined, resource && URI.parse(resource)); - input.setDirty(dirty); - return input; - } -} - -export class SearchEditorInput extends EditorInput { - static readonly ID: string = 'workbench.editorinputs.searchEditorInput'; - - private _config: SearchConfiguration; - public get config(): Readonly { - return this._config; - } - - private model: ITextModel; - public readonly resource: URI; - - private dirty: boolean = false; - private hasRestoredFromBackup = false; - - constructor( - config: Partial | undefined, - initialContents: string | undefined, - resource: URI | undefined, - @IModelService private readonly modelService: IModelService, - @IModeService private readonly modeService: IModeService, - @IEditorService protected readonly editorService: IEditorService, - @IEditorGroupsService protected readonly editorGroupService: IEditorGroupsService, - @ITextFileService protected readonly textFileService: ITextFileService, - @IHistoryService private readonly historyService: IHistoryService, - @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, - @IFileDialogService private readonly fileDialogService: IFileDialogService, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @IWorkingCopyService private readonly workingCopyService: IWorkingCopyService, - @IBackupFileService private readonly backupService: IBackupFileService, - ) { - super(); - this.resource = resource ?? URI.from({ scheme: 'search-editor', fragment: `${Math.random()}` }); - this._config = { ...{ query: '', includes: '', excludes: '', contextLines: 0, wholeWord: false, caseSensitive: false, regexp: false, useIgnores: true, showIncludesExcludes: false }, ...config }; - - const searchResultMode = this.modeService.create('search-result'); - - this.model = this.modelService.getModel(this.resource) ?? this.modelService.createModel(initialContents ?? '', searchResultMode, this.resource); - - const workingCopyAdapter: IWorkingCopy = { - resource: this.resource, - capabilities: this.resource.scheme === 'search-editor' ? WorkingCopyCapabilities.Untitled : 0, - onDidChangeDirty: this.onDidChangeDirty, - onDidChangeContent: this.onDidChangeDirty, - isDirty: () => this.isDirty(), - backup: () => this.backup(), - save: (options) => this.save(0, options), - revert: () => this.revert(), - }; - - - this.workingCopyService.registerWorkingCopy(workingCopyAdapter); - } - - async save(group: GroupIdentifier, options?: ITextFileSaveOptions): Promise { - if (this.resource.scheme === 'search-editor') { - const path = await this.promptForPath(this.resource, this.suggestFileName(), options?.availableFileSystems); - if (path) { - if (await this.textFileService.saveAs(this.resource, path, options)) { - this.setDirty(false); - if (options?.context !== SaveContext.EDITOR_CLOSE && !isEqual(path, this.resource)) { - const replacement = this.instantiationService.createInstance(SearchEditorInput, this.config, undefined, path); - await this.editorService.replaceEditors([{ editor: this, replacement, options: { pinned: true } }], group); - return true; - } else if (options?.context === SaveContext.EDITOR_CLOSE) { - return true; - } - } - } - return false; - } else { - this.setDirty(false); - return !!this.textFileService.write(this.resource, this.model.getValue(), options); - } - } - - // Brining this over from textFileService because it only suggests for untitled scheme. - // In the future I may just use the untitled scheme. I dont get particular benefit from using search-editor... - private async promptForPath(resource: URI, defaultUri: URI, availableFileSystems?: string[]): Promise { - // Help user to find a name for the file by opening it first - await this.editorService.openEditor({ resource, options: { revealIfOpened: true, preserveFocus: true } }); - return this.fileDialogService.pickFileToSave(defaultUri, availableFileSystems); - } - - getTypeId(): string { - return SearchEditorInput.ID; - } - - getName(): string { - if (this.resource.scheme === 'search-editor') { - return this.config.query ? localize('searchTitle.withQuery', "Search: {0}", this.config.query) : localize('searchTitle', "Search"); - } - return localize('searchTitle.withQuery', "Search: {0}", basename(this.resource.path, '.code-search')); - } - - setConfig(config: SearchConfiguration) { - this._config = config; - this._onDidChangeLabel.fire(); - } - - async resolve() { - return null; - } - - async resolveBackup(): Promise { - if (this.hasRestoredFromBackup === true) { return undefined; } - this.hasRestoredFromBackup = true; - return (await this.backupService.resolve(this.resource))?.value.create(DefaultEndOfLine.LF); - } - - setDirty(dirty: boolean) { - this.dirty = dirty; - this._onDidChangeDirty.fire(); - } - - isDirty() { - return this.dirty; - } - - dispose() { - this.modelService.destroyModel(this.resource); - super.dispose(); - } - - matches(other: unknown) { - if (this === other) { return true; } - - if (other instanceof SearchEditorInput) { - if ( - (other.resource.path && other.resource.path === this.resource.path) || - (other.resource.fragment && other.resource.fragment === this.resource.fragment) - ) { - return true; - } - } - return false; - } - - async revert(options?: IRevertOptions) { - // TODO: this should actually revert the contents. But it needs to set dirty false. - super.revert(options); - this.setDirty(false); - return true; - } - - private async backup(): Promise { - if (this.model.isDisposed() || this.model.getValueLength() === 0) { - // FIXME: this is clearly not good, but `backup` is sometimes getting called after the - // model disposes, so we cant reliably grab the snapshot from the model. Instead fall back to the existing snapshot, if one exists. - // Ideally we'd return undefined and thus signal we dont want to overwrite any existing backup. - return { content: (await this.backupService.resolve(this.resource))?.value.create(DefaultEndOfLine.LF).createSnapshot(true) }; - } - - return { content: this.model.createSnapshot() }; - } - - // Bringing this over from textFileService because it only suggests for untitled scheme. - // In the future I may just use the untitled scheme. I dont get particular benefit from using search-editor... - private suggestFileName(): URI { - const searchFileName = (this.config.query.replace(/[^\w \-_]+/g, '_') || 'Search') + '.code-search'; - - const remoteAuthority = this.environmentService.configuration.remoteAuthority; - const schemeFilter = remoteAuthority ? network.Schemas.vscodeRemote : network.Schemas.file; - - const lastActiveFile = this.historyService.getLastActiveFile(schemeFilter); - if (lastActiveFile) { - const lastDir = dirname(lastActiveFile); - return joinPath(lastDir, searchFileName); - } - - const lastActiveFolder = this.historyService.getLastActiveWorkspaceRoot(schemeFilter); - if (lastActiveFolder) { - return joinPath(lastActiveFolder, searchFileName); - } - - return URI.from({ scheme: schemeFilter, path: searchFileName }); - } -} - -// Using \r\n on Windows inserts an extra newline between results. -const lineDelimiter = '\n'; - -const translateRangeLines = - (n: number) => - (range: Range) => - new Range(range.startLineNumber + n, range.startColumn, range.endLineNumber + n, range.endColumn); - -const matchToSearchResultFormat = (match: Match): { line: string, ranges: Range[], lineNumber: string }[] => { - const getLinePrefix = (i: number) => `${match.range().startLineNumber + i}`; - - const fullMatchLines = match.fullPreviewLines(); - const largestPrefixSize = fullMatchLines.reduce((largest, _, i) => Math.max(getLinePrefix(i).length, largest), 0); - - - const results: { line: string, ranges: Range[], lineNumber: string }[] = []; - - fullMatchLines - .forEach((sourceLine, i) => { - const lineNumber = getLinePrefix(i); - const paddingStr = repeat(' ', largestPrefixSize - lineNumber.length); - const prefix = ` ${lineNumber}: ${paddingStr}`; - const prefixOffset = prefix.length; - - const line = (prefix + sourceLine).replace(/\r?\n?$/, ''); - - const rangeOnThisLine = ({ start, end }: { start?: number; end?: number; }) => new Range(1, (start ?? 1) + prefixOffset, 1, (end ?? sourceLine.length + 1) + prefixOffset); - - const matchRange = match.range(); - const matchIsSingleLine = matchRange.startLineNumber === matchRange.endLineNumber; - - let lineRange; - if (matchIsSingleLine) { lineRange = (rangeOnThisLine({ start: matchRange.startColumn, end: matchRange.endColumn })); } - else if (i === 0) { lineRange = (rangeOnThisLine({ start: matchRange.startColumn })); } - else if (i === fullMatchLines.length - 1) { lineRange = (rangeOnThisLine({ end: matchRange.endColumn })); } - else { lineRange = (rangeOnThisLine({})); } - - results.push({ lineNumber: lineNumber, line, ranges: [lineRange] }); - }); - - return results; -}; - -type SearchResultSerialization = { text: string[], matchRanges: Range[] }; - -function fileMatchToSearchResultFormat(fileMatch: FileMatch, labelFormatter: (x: URI) => string): SearchResultSerialization { - const serializedMatches = flatten(fileMatch.matches() - .sort(searchMatchComparer) - .map(match => matchToSearchResultFormat(match))); - - const uriString = labelFormatter(fileMatch.resource); - let text: string[] = [`${uriString}:`]; - let matchRanges: Range[] = []; - - const targetLineNumberToOffset: Record = {}; - - const context: { line: string, lineNumber: number }[] = []; - fileMatch.context.forEach((line, lineNumber) => context.push({ line, lineNumber })); - context.sort((a, b) => a.lineNumber - b.lineNumber); - - let lastLine: number | undefined = undefined; - - const seenLines = new Set(); - serializedMatches.forEach(match => { - if (!seenLines.has(match.line)) { - while (context.length && context[0].lineNumber < +match.lineNumber) { - const { line, lineNumber } = context.shift()!; - if (lastLine !== undefined && lineNumber !== lastLine + 1) { - text.push(''); - } - text.push(` ${lineNumber} ${line}`); - lastLine = lineNumber; - } - - targetLineNumberToOffset[match.lineNumber] = text.length; - seenLines.add(match.line); - text.push(match.line); - lastLine = +match.lineNumber; - } - - matchRanges.push(...match.ranges.map(translateRangeLines(targetLineNumberToOffset[match.lineNumber]))); - }); - - while (context.length) { - const { line, lineNumber } = context.shift()!; - text.push(` ${lineNumber} ${line}`); - } - - return { text, matchRanges }; -} - -const flattenSearchResultSerializations = (serializations: SearchResultSerialization[]): SearchResultSerialization => { - let text: string[] = []; - let matchRanges: Range[] = []; - - serializations.forEach(serialized => { - serialized.matchRanges.map(translateRangeLines(text.length)).forEach(range => matchRanges.push(range)); - serialized.text.forEach(line => text.push(line)); - text.push(''); // new line - }); - - return { text, matchRanges }; -}; - -const contentPatternToSearchResultHeader = (pattern: ITextQuery | null, includes: string, excludes: string, contextLines: number): string[] => { - if (!pattern) { return []; } - - const removeNullFalseAndUndefined = (a: (T | null | false | undefined)[]) => a.filter(a => a !== false && a !== null && a !== undefined) as T[]; - - const escapeNewlines = (str: string) => str.replace(/\\/g, '\\\\').replace(/\n/g, '\\n'); - - return removeNullFalseAndUndefined([ - `# Query: ${escapeNewlines(pattern.contentPattern.pattern)}`, - - (pattern.contentPattern.isCaseSensitive || pattern.contentPattern.isWordMatch || pattern.contentPattern.isRegExp || pattern.userDisabledExcludesAndIgnoreFiles) - && `# Flags: ${coalesce([ - pattern.contentPattern.isCaseSensitive && 'CaseSensitive', - pattern.contentPattern.isWordMatch && 'WordMatch', - pattern.contentPattern.isRegExp && 'RegExp', - pattern.userDisabledExcludesAndIgnoreFiles && 'IgnoreExcludeSettings' - ]).join(' ')}`, - includes ? `# Including: ${includes}` : undefined, - excludes ? `# Excluding: ${excludes}` : undefined, - contextLines ? `# ContextLines: ${contextLines}` : undefined, - '' - ]); -}; - - -type SearchHeader = { - pattern: string; - flags: { - regex: boolean; - wholeWord: boolean; - caseSensitive: boolean; - ignoreExcludes: boolean; - }; - includes: string; - excludes: string; - context: number | undefined; -}; - -const searchHeaderToContentPattern = (header: string[]): SearchHeader => { - const query: SearchHeader = { - pattern: '', - flags: { regex: false, caseSensitive: false, ignoreExcludes: false, wholeWord: false }, - includes: '', - excludes: '', - context: undefined - }; - - const unescapeNewlines = (str: string) => { - let out = ''; - for (let i = 0; i < str.length; i++) { - if (str[i] === '\\') { - i++; - const escaped = str[i]; - - if (escaped === 'n') { - out += '\n'; - } - else if (escaped === '\\') { - out += '\\'; - } - else { - throw Error(localize('invalidQueryStringError', "All backslashes in Query string must be escaped (\\\\)")); - } - } else { - out += str[i]; - } - } - return out; - }; - const parseYML = /^# ([^:]*): (.*)$/; - for (const line of header) { - const parsed = parseYML.exec(line); - if (!parsed) { continue; } - const [, key, value] = parsed; - switch (key) { - case 'Query': query.pattern = unescapeNewlines(value); break; - case 'Including': query.includes = value; break; - case 'Excluding': query.excludes = value; break; - case 'ContextLines': query.context = +value; break; - case 'Flags': { - query.flags = { - regex: value.indexOf('RegExp') !== -1, - caseSensitive: value.indexOf('CaseSensitive') !== -1, - ignoreExcludes: value.indexOf('IgnoreExcludeSettings') !== -1, - wholeWord: value.indexOf('WordMatch') !== -1 - }; - } - } - } - - return query; -}; - -export const serializeSearchResultForEditor = (searchResult: SearchResult, rawIncludePattern: string, rawExcludePattern: string, contextLines: number, labelFormatter: (x: URI) => string, includeHeader: boolean): SearchResultSerialization => { - const header = includeHeader ? contentPatternToSearchResultHeader(searchResult.query, rawIncludePattern, rawExcludePattern, contextLines) : []; - const allResults = - flattenSearchResultSerializations( - flatten( - searchResult.folderMatches().sort(searchMatchComparer) - .map(folderMatch => folderMatch.matches().sort(searchMatchComparer) - .map(fileMatch => fileMatchToSearchResultFormat(fileMatch, labelFormatter))))); - - return { matchRanges: allResults.matchRanges.map(translateRangeLines(header.length)), text: header.concat(allResults.text.length ? allResults.text : ['No Results']) }; -}; - -export const openNewSearchEditor = - async (editorService: IEditorService, instantiationService: IInstantiationService) => { - const activeEditor = editorService.activeTextEditorWidget; - let activeModel: ICodeEditor | undefined; - if (isDiffEditor(activeEditor)) { - if (activeEditor.getOriginalEditor().hasTextFocus()) { - activeModel = activeEditor.getOriginalEditor(); - } else { - activeModel = activeEditor.getModifiedEditor(); - } - } else { - activeModel = activeEditor as ICodeEditor | undefined; - } - const selection = activeModel?.getSelection(); - let selected = (selection && activeModel?.getModel()?.getValueInRange(selection)) ?? ''; - await editorService.openEditor(instantiationService.createInstance(SearchEditorInput, { query: selected }, undefined, undefined), { pinned: true }); - }; - -export const createEditorFromSearchResult = - async (searchResult: SearchResult, rawIncludePattern: string, rawExcludePattern: string, labelService: ILabelService, editorService: IEditorService, textFileService: ITextFileService, instantiationService: IInstantiationService) => { - if (!searchResult.query) { - console.error('Expected searchResult.query to be defined. Got', searchResult); - return; - } - - const searchTerm = searchResult.query.contentPattern.pattern.replace(/[^\w-_. ]/g, '') || 'Search'; - - const labelFormatter = (uri: URI): string => labelService.getUriLabel(uri, { relative: true }); - - const results = serializeSearchResultForEditor(searchResult, rawIncludePattern, rawExcludePattern, 0, labelFormatter, true); - const contents = results.text.join(lineDelimiter); - let possible = { - contents, - mode: 'search-result', - resource: URI.from({ scheme: network.Schemas.untitled, path: searchTerm }) - }; - - let id = 0; - - let existing = textFileService.untitled.get(possible.resource); - while (existing) { - if (existing instanceof UntitledTextEditorInput) { - const model = await existing.resolve(); - const existingContents = model.textEditorModel.getValue(EndOfLinePreference.LF); - if (existingContents === contents) { - break; - } - } - possible.resource = possible.resource.with({ path: searchTerm + '-' + ++id }); - existing = textFileService.untitled.get(possible.resource); - } - - const input = instantiationService.createInstance( - SearchEditorInput, - { - query: searchResult.query.contentPattern.pattern, - regexp: !!searchResult.query.contentPattern.isRegExp, - caseSensitive: !!searchResult.query.contentPattern.isCaseSensitive, - wholeWord: !!searchResult.query.contentPattern.isWordMatch, - includes: rawIncludePattern, - excludes: rawExcludePattern, - contextLines: 0, - useIgnores: !searchResult.query.userDisabledExcludesAndIgnoreFiles, - showIncludesExcludes: !!(rawExcludePattern || rawExcludePattern || searchResult.query.userDisabledExcludesAndIgnoreFiles) - }, contents, undefined); - - const editor = await editorService.openEditor(input, { pinned: true }) as SearchEditor; - const model = assertIsDefined(editor.getModel()); - model.deltaDecorations([], results.matchRanges.map(range => ({ range, options: { className: 'searchEditorFindMatch', stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges } }))); - }; - -registerThemingParticipant((theme, collector) => { - collector.addRule(`.monaco-editor .searchEditorFindMatch { background-color: ${theme.getColor(searchEditorFindMatch)}; }`); - - const findMatchHighlightBorder = theme.getColor(searchEditorFindMatchBorder); - if (findMatchHighlightBorder) { - collector.addRule(`.monaco-editor .searchEditorFindMatch { border: 1px ${theme.type === 'hc' ? 'dotted' : 'solid'} ${findMatchHighlightBorder}; box-sizing: border-box; }`); - } -}); diff --git a/src/vs/workbench/contrib/search/browser/searchEditorInput.ts b/src/vs/workbench/contrib/search/browser/searchEditorInput.ts new file mode 100644 index 0000000000000000000000000000000000000000..59fab059cafb25652f38848c8c17382a67301bfe --- /dev/null +++ b/src/vs/workbench/contrib/search/browser/searchEditorInput.ts @@ -0,0 +1,309 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as network from 'vs/base/common/network'; +import { endsWith } from 'vs/base/common/strings'; +import { URI } from 'vs/base/common/uri'; +import 'vs/css!./media/searchEditor'; +import { ITextModel, ITextBufferFactory } from 'vs/editor/common/model'; +import { localize } from 'vs/nls'; +import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { IEditorInputFactory, GroupIdentifier, EditorInput, SaveContext, IRevertOptions } from 'vs/workbench/common/editor'; +import { IModelService } from 'vs/editor/common/services/modelService'; +import { IModeService } from 'vs/editor/common/services/modeService'; +import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; +import { ITextFileSaveOptions, ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; +import type { IWorkbenchContribution } from 'vs/workbench/common/contributions'; +import { FileEditorInput } from 'vs/workbench/contrib/files/common/editors/fileEditorInput'; +import { dirname, joinPath, isEqual } from 'vs/base/common/resources'; +import { IHistoryService } from 'vs/workbench/services/history/common/history'; +import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; +import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; +import { basename } from 'vs/base/common/path'; +import { IWorkingCopyService, WorkingCopyCapabilities, IWorkingCopy, IWorkingCopyBackup } from 'vs/workbench/services/workingCopy/common/workingCopyService'; +import { IBackupFileService } from 'vs/workbench/services/backup/common/backup'; +import { assertIsDefined } from 'vs/base/common/types'; +import { extractSearchQuery, serializeSearchConfiguration } from 'vs/workbench/contrib/search/browser/searchEditorSerialization'; + +export type SearchConfiguration = { + query: string, + includes: string, + excludes: string + contextLines: number, + wholeWord: boolean, + caseSensitive: boolean, + regexp: boolean, + useIgnores: boolean, + showIncludesExcludes: boolean, +}; + +export class SearchEditorInput extends EditorInput { + static readonly ID: string = 'workbench.editorinputs.searchEditorInput'; + + private dirty: boolean = false; + private readonly model: Promise; + private resolvedModel?: { model: ITextModel, query: SearchConfiguration }; + + constructor( + public readonly resource: URI, + getModel: () => Promise, + @IModelService private readonly modelService: IModelService, + @IEditorService protected readonly editorService: IEditorService, + @IEditorGroupsService protected readonly editorGroupService: IEditorGroupsService, + @ITextFileService protected readonly textFileService: ITextFileService, + @IHistoryService private readonly historyService: IHistoryService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, + @IFileDialogService private readonly fileDialogService: IFileDialogService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IWorkingCopyService private readonly workingCopyService: IWorkingCopyService, + ) { + super(); + + this.model = getModel(); + + const workingCopyAdapter: IWorkingCopy = { + resource: this.resource, + capabilities: this.resource.scheme === 'search-editor' ? WorkingCopyCapabilities.Untitled : 0, + onDidChangeDirty: this.onDidChangeDirty, + onDidChangeContent: this.onDidChangeDirty, + isDirty: () => this.isDirty(), + backup: () => this.backup(), + save: (options) => this.save(0, options), + revert: () => this.revert(), + }; + + this.workingCopyService.registerWorkingCopy(workingCopyAdapter); + } + + async save(group: GroupIdentifier, options?: ITextFileSaveOptions): Promise { + if (this.resource.scheme === 'search-editor') { + const path = await this.promptForPath(this.resource, await this.suggestFileName(), options?.availableFileSystems); + if (path) { + if (await this.textFileService.saveAs(this.resource, path, options)) { + this.setDirty(false); + if (options?.context !== SaveContext.EDITOR_CLOSE && !isEqual(path, this.resource)) { + const replacement = this.instantiationService.invokeFunction(getOrMakeSearchEditorInput, { uri: path }); + await this.editorService.replaceEditors([{ editor: this, replacement, options: { pinned: true } }], group); + return true; + } else if (options?.context === SaveContext.EDITOR_CLOSE) { + return true; + } + } + } + return false; + } else { + this.setDirty(false); + return !!this.textFileService.write(this.resource, (await this.model).getValue(), options); + } + } + + // Brining this over from textFileService because it only suggests for untitled scheme. + // In the future I may just use the untitled scheme. I dont get particular benefit from using search-editor... + private async promptForPath(resource: URI, defaultUri: URI, availableFileSystems?: string[]): Promise { + // Help user to find a name for the file by opening it first + await this.editorService.openEditor({ resource, options: { revealIfOpened: true, preserveFocus: true } }); + return this.fileDialogService.pickFileToSave(defaultUri, availableFileSystems); + } + + getTypeId(): string { + return SearchEditorInput.ID; + } + + getName(): string { + if (this.resource.scheme === 'search-editor') { + return this.resolvedModel?.query.query + ? localize('searchTitle.withQuery', "Search: {0}", this.resolvedModel?.query.query) + : localize('searchTitle', "Search"); + } + + return localize('searchTitle.withQuery', "Search: {0}", basename(this.resource.path, '.code-search')); + } + + async reloadModel() { + const model = await this.model; + const query = extractSearchQuery(model); + this.resolvedModel = { model, query }; + this._onDidChangeLabel.fire(); + return { model, query }; + } + + getConfigSync() { + if (!this.resolvedModel) { + console.error('Requested config for Search Editor before initalization'); + } + + return this.resolvedModel?.query; + } + + async resolve() { + return null; + } + + setDirty(dirty: boolean) { + this.dirty = dirty; + this._onDidChangeDirty.fire(); + } + + isDirty() { + return this.dirty; + } + + dispose() { + this.modelService.destroyModel(this.resource); + super.dispose(); + } + + matches(other: unknown) { + if (this === other) { return true; } + + if (other instanceof SearchEditorInput) { + if ( + (other.resource.path && other.resource.path === this.resource.path) || + (other.resource.fragment && other.resource.fragment === this.resource.fragment) + ) { + return true; + } + } + return false; + } + + async revert(options?: IRevertOptions) { + // TODO: this should actually revert the contents. But it needs to set dirty false. + super.revert(options); + this.setDirty(false); + return true; + } + + private async backup(): Promise { + const content = (await this.model).createSnapshot(); + return { content }; + } + + // Bringing this over from textFileService because it only suggests for untitled scheme. + // In the future I may just use the untitled scheme. I dont get particular benefit from using search-editor... + private async suggestFileName(): Promise { + const query = (await this.reloadModel()).query.query; + + const searchFileName = (query.replace(/[^\w \-_]+/g, '_') || 'Search') + '.code-search'; + + const remoteAuthority = this.environmentService.configuration.remoteAuthority; + const schemeFilter = remoteAuthority ? network.Schemas.vscodeRemote : network.Schemas.file; + + const lastActiveFile = this.historyService.getLastActiveFile(schemeFilter); + if (lastActiveFile) { + const lastDir = dirname(lastActiveFile); + return joinPath(lastDir, searchFileName); + } + + const lastActiveFolder = this.historyService.getLastActiveWorkspaceRoot(schemeFilter); + if (lastActiveFolder) { + return joinPath(lastActiveFolder, searchFileName); + } + + return URI.from({ scheme: schemeFilter, path: searchFileName }); + } +} + + + +export class SearchEditorContribution implements IWorkbenchContribution { + constructor( + @IEditorService private readonly editorService: IEditorService, + @ITextFileService protected readonly textFileService: ITextFileService, + @IInstantiationService protected readonly instantiationService: IInstantiationService, + @IModelService protected readonly modelService: IModelService, + ) { + + this.editorService.overrideOpenEditor((editor, options, group) => { + const resource = editor.getResource(); + if (!resource || + !(endsWith(resource.path, '.code-search') || resource.scheme === 'search-editor') || + !(editor instanceof FileEditorInput || (resource.scheme === 'search-editor'))) { + return undefined; + } + + if (group.isOpened(editor)) { + return undefined; + } + + const input = instantiationService.invokeFunction(getOrMakeSearchEditorInput, { uri: resource }); + const opened = editorService.openEditor(input, { ...options, pinned: resource.scheme === 'search-editor', ignoreOverrides: true }, group); + return { override: Promise.resolve(opened) }; + }); + } +} + +export class SearchEditorInputFactory implements IEditorInputFactory { + + canSerialize() { return true; } + + serialize(input: SearchEditorInput) { + let resource = undefined; + if (input.resource.path || input.resource.fragment) { + resource = input.resource.toString(); + } + + const config = input.getConfigSync(); + + return JSON.stringify({ resource, dirty: input.isDirty(), config }); + } + + deserialize(instantiationService: IInstantiationService, serializedEditorInput: string): SearchEditorInput | undefined { + const { resource, dirty, config } = JSON.parse(serializedEditorInput); + + const input = instantiationService.invokeFunction(getOrMakeSearchEditorInput, { text: serializeSearchConfiguration(config), uri: URI.parse(resource) }); + input.setDirty(dirty); + return input; + } +} + + +const inputs = new Map(); +export const getOrMakeSearchEditorInput = ( + accessor: ServicesAccessor, + existingData: { uri: URI, text?: string } | { text: string, uri?: URI } +): SearchEditorInput => { + + const uri = existingData.uri ?? URI.from({ scheme: 'search-editor', fragment: `${Math.random()}` }); + + const instantiationService = accessor.get(IInstantiationService); + const modelService = accessor.get(IModelService); + const textFileService = accessor.get(ITextFileService); + const backupService = accessor.get(IBackupFileService); + const modeService = accessor.get(IModeService); + + const existing = inputs.get(uri.toString()); + if (existing) { + return existing; + } + + + const getModel = async () => { + const existing = modelService.getModel(uri); + if (existing) { return existing; } + + // must be called before `hasBackupSync` to ensure the backup service is initalized. + await backupService.getBackups(); + + let contents: string | ITextBufferFactory; + if (backupService.hasBackupSync(uri)) { + contents = assertIsDefined((await backupService.resolve(uri))?.value); + // backupService.discardBackup(uri); + } else if (uri.scheme !== 'search-editor') { + contents = (await textFileService.read(uri)).value; + } else { + contents = existingData.text ?? ''; + } + return modelService.createModel(contents, modeService.create('search-result'), uri); + }; + + const input = instantiationService.createInstance(SearchEditorInput, uri, getModel); + + inputs.set(uri.toString(), input); + input.onDispose(() => inputs.delete(uri.toString())); + + return input; +}; diff --git a/src/vs/workbench/contrib/search/browser/searchEditorSerialization.ts b/src/vs/workbench/contrib/search/browser/searchEditorSerialization.ts new file mode 100644 index 0000000000000000000000000000000000000000..e813406532e10afc1fd387355d1a351b1758e34d --- /dev/null +++ b/src/vs/workbench/contrib/search/browser/searchEditorSerialization.ts @@ -0,0 +1,248 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import 'vs/css!./media/searchEditor'; +import { coalesce, flatten } from 'vs/base/common/arrays'; +import { repeat } from 'vs/base/common/strings'; +import { URI } from 'vs/base/common/uri'; +import { Range } from 'vs/editor/common/core/range'; +import { FileMatch, Match, searchMatchComparer, SearchResult } from 'vs/workbench/contrib/search/common/searchModel'; +import { ITextQuery } from 'vs/workbench/services/search/common/search'; +import { localize } from 'vs/nls'; +import type { ITextModel } from 'vs/editor/common/model'; +import type { SearchConfiguration } from 'vs/workbench/contrib/search/browser/searchEditorInput'; + +// Using \r\n on Windows inserts an extra newline between results. +const lineDelimiter = '\n'; + +const translateRangeLines = + (n: number) => + (range: Range) => + new Range(range.startLineNumber + n, range.startColumn, range.endLineNumber + n, range.endColumn); + +const matchToSearchResultFormat = (match: Match): { line: string, ranges: Range[], lineNumber: string }[] => { + const getLinePrefix = (i: number) => `${match.range().startLineNumber + i}`; + + const fullMatchLines = match.fullPreviewLines(); + const largestPrefixSize = fullMatchLines.reduce((largest, _, i) => Math.max(getLinePrefix(i).length, largest), 0); + + + const results: { line: string, ranges: Range[], lineNumber: string }[] = []; + + fullMatchLines + .forEach((sourceLine, i) => { + const lineNumber = getLinePrefix(i); + const paddingStr = repeat(' ', largestPrefixSize - lineNumber.length); + const prefix = ` ${lineNumber}: ${paddingStr}`; + const prefixOffset = prefix.length; + + const line = (prefix + sourceLine).replace(/\r?\n?$/, ''); + + const rangeOnThisLine = ({ start, end }: { start?: number; end?: number; }) => new Range(1, (start ?? 1) + prefixOffset, 1, (end ?? sourceLine.length + 1) + prefixOffset); + + const matchRange = match.range(); + const matchIsSingleLine = matchRange.startLineNumber === matchRange.endLineNumber; + + let lineRange; + if (matchIsSingleLine) { lineRange = (rangeOnThisLine({ start: matchRange.startColumn, end: matchRange.endColumn })); } + else if (i === 0) { lineRange = (rangeOnThisLine({ start: matchRange.startColumn })); } + else if (i === fullMatchLines.length - 1) { lineRange = (rangeOnThisLine({ end: matchRange.endColumn })); } + else { lineRange = (rangeOnThisLine({})); } + + results.push({ lineNumber: lineNumber, line, ranges: [lineRange] }); + }); + + return results; +}; + +type SearchResultSerialization = { text: string[], matchRanges: Range[] }; + +function fileMatchToSearchResultFormat(fileMatch: FileMatch, labelFormatter: (x: URI) => string): SearchResultSerialization { + const serializedMatches = flatten(fileMatch.matches() + .sort(searchMatchComparer) + .map(match => matchToSearchResultFormat(match))); + + const uriString = labelFormatter(fileMatch.resource); + let text: string[] = [`${uriString}:`]; + let matchRanges: Range[] = []; + + const targetLineNumberToOffset: Record = {}; + + const context: { line: string, lineNumber: number }[] = []; + fileMatch.context.forEach((line, lineNumber) => context.push({ line, lineNumber })); + context.sort((a, b) => a.lineNumber - b.lineNumber); + + let lastLine: number | undefined = undefined; + + const seenLines = new Set(); + serializedMatches.forEach(match => { + if (!seenLines.has(match.line)) { + while (context.length && context[0].lineNumber < +match.lineNumber) { + const { line, lineNumber } = context.shift()!; + if (lastLine !== undefined && lineNumber !== lastLine + 1) { + text.push(''); + } + text.push(` ${lineNumber} ${line}`); + lastLine = lineNumber; + } + + targetLineNumberToOffset[match.lineNumber] = text.length; + seenLines.add(match.line); + text.push(match.line); + lastLine = +match.lineNumber; + } + + matchRanges.push(...match.ranges.map(translateRangeLines(targetLineNumberToOffset[match.lineNumber]))); + }); + + while (context.length) { + const { line, lineNumber } = context.shift()!; + text.push(` ${lineNumber} ${line}`); + } + + return { text, matchRanges }; +} + +const contentPatternToSearchResultHeader = (pattern: ITextQuery | null, includes: string, excludes: string, contextLines: number): string[] => { + if (!pattern) { return []; } + + const removeNullFalseAndUndefined = (a: (T | null | false | undefined)[]) => a.filter(a => a !== false && a !== null && a !== undefined) as T[]; + + const escapeNewlines = (str: string) => str.replace(/\\/g, '\\\\').replace(/\n/g, '\\n'); + + return removeNullFalseAndUndefined([ + `# Query: ${escapeNewlines(pattern.contentPattern.pattern)}`, + + (pattern.contentPattern.isCaseSensitive || pattern.contentPattern.isWordMatch || pattern.contentPattern.isRegExp || pattern.userDisabledExcludesAndIgnoreFiles) + && `# Flags: ${coalesce([ + pattern.contentPattern.isCaseSensitive && 'CaseSensitive', + pattern.contentPattern.isWordMatch && 'WordMatch', + pattern.contentPattern.isRegExp && 'RegExp', + pattern.userDisabledExcludesAndIgnoreFiles && 'IgnoreExcludeSettings' + ]).join(' ')}`, + includes ? `# Including: ${includes}` : undefined, + excludes ? `# Excluding: ${excludes}` : undefined, + contextLines ? `# ContextLines: ${contextLines}` : undefined + ]); +}; + +export const serializeSearchConfiguration = (config: Partial): string => { + const removeNullFalseAndUndefined = (a: (T | null | false | undefined)[]) => a.filter(a => a !== false && a !== null && a !== undefined) as T[]; + + const escapeNewlines = (str: string) => str.replace(/\\/g, '\\\\').replace(/\n/g, '\\n'); + + return removeNullFalseAndUndefined([ + `# Query: ${escapeNewlines(config.query ?? '')}`, + + (config.caseSensitive || config.wholeWord || config.regexp || config.useIgnores === false) + && `# Flags: ${coalesce([ + config.caseSensitive && 'CaseSensitive', + config.wholeWord && 'WordMatch', + config.regexp && 'RegExp', + (config.useIgnores === false) && 'IgnoreExcludeSettings' + ]).join(' ')}`, + config.includes ? `# Including: ${config.includes}` : undefined, + config.excludes ? `# Excluding: ${config.excludes}` : undefined, + config.contextLines ? `# ContextLines: ${config.contextLines}` : undefined, + '' + ]).join(lineDelimiter); +}; + + +export const extractSearchQuery = (model: ITextModel): SearchConfiguration => { + const header = model.getValueInRange(new Range(1, 1, 6, 1)).split(lineDelimiter); + + const query: SearchConfiguration = { + query: '', + includes: '', + excludes: '', + regexp: false, + caseSensitive: false, + useIgnores: true, + wholeWord: false, + contextLines: 0, + showIncludesExcludes: false, + }; + + const unescapeNewlines = (str: string) => { + let out = ''; + for (let i = 0; i < str.length; i++) { + if (str[i] === '\\') { + i++; + const escaped = str[i]; + + if (escaped === 'n') { + out += '\n'; + } + else if (escaped === '\\') { + out += '\\'; + } + else { + throw Error(localize('invalidQueryStringError', "All backslashes in Query string must be escaped (\\\\)")); + } + } else { + out += str[i]; + } + } + return out; + }; + + const parseYML = /^# ([^:]*): (.*)$/; + for (const line of header) { + const parsed = parseYML.exec(line); + if (!parsed) { continue; } + const [, key, value] = parsed; + switch (key) { + case 'Query': query.query = unescapeNewlines(value); break; + case 'Including': query.includes = value; break; + case 'Excluding': query.excludes = value; break; + case 'ContextLines': query.contextLines = +value; break; + case 'Flags': { + query.regexp = value.indexOf('RegExp') !== -1; + query.caseSensitive = value.indexOf('CaseSensitive') !== -1; + query.useIgnores = value.indexOf('IgnoreExcludeSettings') === -1; + query.wholeWord = value.indexOf('WordMatch') !== -1; + } + } + } + + query.showIncludesExcludes = !!(query.includes || query.excludes || !query.useIgnores); + + return query; +}; + +export const serializeSearchResultForEditor = + (searchResult: SearchResult, rawIncludePattern: string, rawExcludePattern: string, contextLines: number, labelFormatter: (x: URI) => string, includeHeader: boolean): { matchRanges: Range[], text: string } => { + const header = includeHeader + ? contentPatternToSearchResultHeader(searchResult.query, rawIncludePattern, rawExcludePattern, contextLines) + : []; + + const allResults = + flattenSearchResultSerializations( + flatten( + searchResult.folderMatches().sort(searchMatchComparer) + .map(folderMatch => folderMatch.matches().sort(searchMatchComparer) + .map(fileMatch => fileMatchToSearchResultFormat(fileMatch, labelFormatter))))); + + return { + matchRanges: allResults.matchRanges.map(translateRangeLines(header.length)), + text: header + .concat(allResults.text.length ? allResults.text : ['No Results']) + .join(lineDelimiter) + }; + }; + +const flattenSearchResultSerializations = (serializations: SearchResultSerialization[]): SearchResultSerialization => { + let text: string[] = []; + let matchRanges: Range[] = []; + + serializations.forEach(serialized => { + serialized.matchRanges.map(translateRangeLines(text.length)).forEach(range => matchRanges.push(range)); + serialized.text.forEach(line => text.push(line)); + text.push(''); // new line + }); + + return { text, matchRanges }; +}; diff --git a/src/vs/workbench/contrib/search/browser/searchView.ts b/src/vs/workbench/contrib/search/browser/searchView.ts index 4cd26d8bd5fabe2c7dc868238b1fdf466a98751e..0cf4ab44d7049909144cd93225a7d58b3bff9e67 100644 --- a/src/vs/workbench/contrib/search/browser/searchView.ts +++ b/src/vs/workbench/contrib/search/browser/searchView.ts @@ -43,7 +43,7 @@ import { OpenFileFolderAction, OpenFolderAction } from 'vs/workbench/browser/act import { ResourceLabels } from 'vs/workbench/browser/labels'; import { IEditor } from 'vs/workbench/common/editor'; import { ExcludePatternInputWidget, PatternInputWidget } from 'vs/workbench/contrib/search/browser/patternInputWidget'; -import { CancelSearchAction, ClearSearchResultsAction, CollapseDeepestExpandedLevelAction, RefreshAction, IFindInFilesArgs, OpenResultsInEditorAction, appendKeyBindingLabel, ExpandAllAction, ToggleCollapseAndExpandAction } from 'vs/workbench/contrib/search/browser/searchActions'; +import { CancelSearchAction, ClearSearchResultsAction, CollapseDeepestExpandedLevelAction, RefreshAction, IFindInFilesArgs, OpenSearchEditorAction, appendKeyBindingLabel, ExpandAllAction, ToggleCollapseAndExpandAction } from 'vs/workbench/contrib/search/browser/searchActions'; import { FileMatchRenderer, FolderMatchRenderer, MatchRenderer, SearchAccessibilityProvider, SearchDelegate, SearchDND } from 'vs/workbench/contrib/search/browser/searchResultsView'; import { ISearchWidgetOptions, SearchWidget } from 'vs/workbench/contrib/search/browser/searchWidget'; import * as Constants from 'vs/workbench/contrib/search/common/constants'; @@ -64,7 +64,7 @@ import { IOpenerService } from 'vs/platform/opener/common/opener'; import { MultiCursorSelectionController } from 'vs/editor/contrib/multicursor/multicursor'; import { Selection } from 'vs/editor/common/core/selection'; import { SIDE_BAR_BACKGROUND, PANEL_BACKGROUND } from 'vs/workbench/common/theme'; -import { createEditorFromSearchResult } from 'vs/workbench/contrib/search/browser/searchEditorCommands'; +import { createEditorFromSearchResult } from 'vs/workbench/contrib/search/browser/searchEditorActions'; import { ILabelService } from 'vs/platform/label/common/label'; import { Color, RGBA } from 'vs/base/common/color'; @@ -114,7 +114,7 @@ export class SearchView extends ViewPane { private state: SearchUIState = SearchUIState.Idle; - private actions: Array = []; + private actions: Array = []; private toggleCollapseAction: ToggleCollapseAndExpandAction; private cancelAction: CancelSearchAction; private refreshAction: RefreshAction; @@ -232,7 +232,7 @@ export class SearchView extends ViewPane { if (this.searchConfig.enableSearchEditorPreview) { this.actions.push( - this._register(this.instantiationService.createInstance(OpenResultsInEditorAction, OpenResultsInEditorAction.ID, OpenResultsInEditorAction.LABEL)) + this._register(this.instantiationService.createInstance(OpenSearchEditorAction, OpenSearchEditorAction.ID, OpenSearchEditorAction.LABEL)) ); } @@ -1559,7 +1559,7 @@ export class SearchView extends ViewPane { this.messageDisposables.push(dom.addDisposableListener(openInEditorLink, dom.EventType.CLICK, (e: MouseEvent) => { dom.EventHelper.stop(e, false); - createEditorFromSearchResult(this.searchResult, this.searchIncludePattern.getValue(), this.searchExcludePattern.getValue(), this.labelService, this.editorService, this.textFileService, this.instantiationService); + createEditorFromSearchResult(this.searchResult, this.searchIncludePattern.getValue(), this.searchExcludePattern.getValue(), this.labelService, this.editorService, this.instantiationService); })); } else { diff --git a/src/vs/workbench/contrib/userDataSync/electron-browser/userDataSync.contribution.ts b/src/vs/workbench/contrib/userDataSync/electron-browser/userDataSync.contribution.ts index 926a6d2e7a2c44cd7a878e419f9ac28bf6d09531..42e83a301df7edf42bc4007a961c04e018bb7bc7 100644 --- a/src/vs/workbench/contrib/userDataSync/electron-browser/userDataSync.contribution.ts +++ b/src/vs/workbench/contrib/userDataSync/electron-browser/userDataSync.contribution.ts @@ -9,14 +9,18 @@ import { Registry } from 'vs/platform/registry/common/platform'; import { LifecyclePhase } from 'vs/platform/lifecycle/common/lifecycle'; import { ISharedProcessService } from 'vs/platform/ipc/electron-browser/sharedProcessService'; import { UserDataSycnUtilServiceChannel } from 'vs/platform/userDataSync/common/userDataSyncIpc'; +import { GlobalExtensionEnablementServiceChannel } from 'vs/platform/extensionManagement/common/extensionManagementIpc'; +import { IGlobalExtensionEnablementService } from 'vs/platform/extensionManagement/common/extensionManagement'; class UserDataSyncServicesContribution implements IWorkbenchContribution { constructor( @IUserDataSyncUtilService userDataSyncUtilService: IUserDataSyncUtilService, @ISharedProcessService sharedProcessService: ISharedProcessService, + @IGlobalExtensionEnablementService globalExtensionEnablementService: IGlobalExtensionEnablementService, ) { sharedProcessService.registerChannel('userDataSyncUtil', new UserDataSycnUtilServiceChannel(userDataSyncUtilService)); + sharedProcessService.registerChannel('globalExtensionEnablement', new GlobalExtensionEnablementServiceChannel(globalExtensionEnablementService)); } } diff --git a/src/vs/workbench/services/backup/common/backup.ts b/src/vs/workbench/services/backup/common/backup.ts index 2b31bd1e104d213afe43e26bae38f5f6d3ca713e..25fae6a15caa70fdd348d9f69c951046d84a04ce 100644 --- a/src/vs/workbench/services/backup/common/backup.ts +++ b/src/vs/workbench/services/backup/common/backup.ts @@ -28,6 +28,10 @@ export interface IBackupFileService { /** * Finds out if the provided resource with the given version is backed up. + * + * Note: if the backup service has not been initialized yet, this may return + * the wrong result. Always use `resolve()` if you can do a long running + * operation. */ hasBackupSync(resource: URI, versionId?: number): boolean;