From 2cc6cf6fc8f297f64076cca8a0033e6d8bf0a4e0 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 23 Jun 2016 16:16:52 +0200 Subject: [PATCH] Search and Replace implementation --- src/vs/base/browser/ui/inputbox/inputBox.css | 12 +- .../browser/media/expando-collapsed-dark.svg | 1 + .../browser/media/expando-collapsed.svg | 1 + .../browser/media/expando-expanded-dark.svg | 1 + .../search/browser/media/expando-expanded.svg | 1 + .../browser/media/replace-all-inverse.svg | 11 + .../search/browser/media/replace-all.svg | 11 + .../search/browser/media/replace-inverse.svg | 13 + .../parts/search/browser/media/replace.svg | 13 + .../search/browser/media/searchviewlet.css | 108 +++++- .../parts/search/browser/replaceService.ts | 53 +++ .../search/browser/search.contribution.ts | 7 +- .../parts/search/browser/searchActions.ts | 101 ++--- .../parts/search/browser/searchResultsView.ts | 54 ++- .../parts/search/browser/searchViewlet.ts | 352 +++++++++++------- .../parts/search/browser/searchWidget.ts | 257 +++++++++++++ .../workbench/parts/search/common/replace.ts | 27 ++ .../parts/search/common/searchModel.ts | 31 ++ 18 files changed, 830 insertions(+), 224 deletions(-) create mode 100644 src/vs/workbench/parts/search/browser/media/expando-collapsed-dark.svg create mode 100644 src/vs/workbench/parts/search/browser/media/expando-collapsed.svg create mode 100644 src/vs/workbench/parts/search/browser/media/expando-expanded-dark.svg create mode 100644 src/vs/workbench/parts/search/browser/media/expando-expanded.svg create mode 100644 src/vs/workbench/parts/search/browser/media/replace-all-inverse.svg create mode 100644 src/vs/workbench/parts/search/browser/media/replace-all.svg create mode 100644 src/vs/workbench/parts/search/browser/media/replace-inverse.svg create mode 100644 src/vs/workbench/parts/search/browser/media/replace.svg create mode 100644 src/vs/workbench/parts/search/browser/replaceService.ts create mode 100644 src/vs/workbench/parts/search/browser/searchWidget.ts create mode 100644 src/vs/workbench/parts/search/common/replace.ts diff --git a/src/vs/base/browser/ui/inputbox/inputBox.css b/src/vs/base/browser/ui/inputbox/inputBox.css index 4edda336bb2..13fe2cd1d0f 100644 --- a/src/vs/base/browser/ui/inputbox/inputBox.css +++ b/src/vs/base/browser/ui/inputbox/inputBox.css @@ -106,10 +106,20 @@ /* Action bar support */ .monaco-inputbox .monaco-action-bar { position: absolute; - right: 0px; + right: 2px; top: 4px; } +.monaco-inputbox .monaco-action-bar .action-item { + margin-left: 2px; +} + +.monaco-inputbox .monaco-action-bar .action-item .icon { + background-repeat: no-repeat; + width: 16px; + height: 16px; +} + /* Theming */ .monaco-inputbox.idle { diff --git a/src/vs/workbench/parts/search/browser/media/expando-collapsed-dark.svg b/src/vs/workbench/parts/search/browser/media/expando-collapsed-dark.svg new file mode 100644 index 00000000000..6f3abfce784 --- /dev/null +++ b/src/vs/workbench/parts/search/browser/media/expando-collapsed-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/vs/workbench/parts/search/browser/media/expando-collapsed.svg b/src/vs/workbench/parts/search/browser/media/expando-collapsed.svg new file mode 100644 index 00000000000..5dcb87c772c --- /dev/null +++ b/src/vs/workbench/parts/search/browser/media/expando-collapsed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/vs/workbench/parts/search/browser/media/expando-expanded-dark.svg b/src/vs/workbench/parts/search/browser/media/expando-expanded-dark.svg new file mode 100644 index 00000000000..22dfac04f15 --- /dev/null +++ b/src/vs/workbench/parts/search/browser/media/expando-expanded-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/vs/workbench/parts/search/browser/media/expando-expanded.svg b/src/vs/workbench/parts/search/browser/media/expando-expanded.svg new file mode 100644 index 00000000000..e55ccd923e5 --- /dev/null +++ b/src/vs/workbench/parts/search/browser/media/expando-expanded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/vs/workbench/parts/search/browser/media/replace-all-inverse.svg b/src/vs/workbench/parts/search/browser/media/replace-all-inverse.svg new file mode 100644 index 00000000000..2744a6a4e81 --- /dev/null +++ b/src/vs/workbench/parts/search/browser/media/replace-all-inverse.svg @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/src/vs/workbench/parts/search/browser/media/replace-all.svg b/src/vs/workbench/parts/search/browser/media/replace-all.svg new file mode 100644 index 00000000000..4c55b91d0e9 --- /dev/null +++ b/src/vs/workbench/parts/search/browser/media/replace-all.svg @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/src/vs/workbench/parts/search/browser/media/replace-inverse.svg b/src/vs/workbench/parts/search/browser/media/replace-inverse.svg new file mode 100644 index 00000000000..a15ad9b4af1 --- /dev/null +++ b/src/vs/workbench/parts/search/browser/media/replace-inverse.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/src/vs/workbench/parts/search/browser/media/replace.svg b/src/vs/workbench/parts/search/browser/media/replace.svg new file mode 100644 index 00000000000..14da77d2198 --- /dev/null +++ b/src/vs/workbench/parts/search/browser/media/replace.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/src/vs/workbench/parts/search/browser/media/searchviewlet.css b/src/vs/workbench/parts/search/browser/media/searchviewlet.css index 5b827f09cd6..fca60f57ff7 100644 --- a/src/vs/workbench/parts/search/browser/media/searchviewlet.css +++ b/src/vs/workbench/parts/search/browser/media/searchviewlet.css @@ -3,20 +3,45 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -.search-viewlet .highlight { - color: black; - background-color: rgba(234, 92, 0, 0.3); +.search-viewlet .search-widgets-container { + margin: 6px 17px 0 2px; } -.search-viewlet .query-box { - margin: 6px 17px 0 17px; +.search-viewlet .search-widget .toggle-replace-button { + position: absolute; + top: 0; + left: 0; + width: 16px; + height: 100%; + + -webkit-box-sizing: border-box; + -o-box-sizing: border-box; + -moz-box-sizing: border-box; + -ms-box-sizing: border-box; + box-sizing: border-box; + + background-position: center center; + background-repeat: no-repeat; + cursor: pointer; } -.search-viewlet .query-box .monaco-findInput { +.search-viewlet .search-widget .input-box { + margin-left: 17px; +} + +.search-viewlet .search-widget .monaco-findInput { display: inline-block; vertical-align: middle; } +.search-viewlet .search-widget .replace-box { + margin-top: 6px; +} + +.search-viewlet .search-widget .replace-box.disabled { + display: none; +} + .search-viewlet .query-clear { width: 20px; height: 20px; @@ -29,14 +54,13 @@ .search-viewlet .query-details { min-height: 1em; position: relative; - margin: 0 17px 6px 17px; - padding: 0 4px; + margin: 0 0 6px 17px; } .search-viewlet .query-details .more { position: absolute; - right: 0; margin-right: 0.5em; + right: 0; cursor: pointer; width: 16px; height: 13px; @@ -163,6 +187,24 @@ background: url("action-remove.svg") center center no-repeat; } +.search-viewlet .action-replace { + background-image: url('replace.svg'); +} + +.search-viewlet .action-replace-all { + background-image: url('replace-all.svg'); +} + +.monaco-editor.hc-black .search-viewlet .action-replace, +.monaco-editor.vs-dark .search-viewlet .action-replace { + background-image: url('replace-inverse.svg'); +} + +.monaco-editor.hc-black .search-viewlet .action-replace-all, +.monaco-editor.vs-dark .search-viewlet .action-replace-all { + background-image: url('replace-all-inverse.svg'); +} + .search-viewlet .label { font-style: italic; } @@ -248,8 +290,28 @@ box-sizing: border-box; } + +.search-viewlet .replaceMatch { + background-color: rgba(234, 92, 0, 0.45); +} + +.search-viewlet .removeMatch { + text-decoration: line-through; +} + +.hc-black .monaco-workbench .search-viewlet .replaceMatch, +.monaco-editor.hc-black .replaceMatch { + border-color: #F44336; +} + /* Theming */ -.vs .search-viewlet .query-box, + +.search-viewlet .highlight { + color: black; + background-color: rgba(234, 92, 0, 0.3); +} + +.vs .search-viewlet .input-box, .vs .search-viewlet .file-types .monaco-inputbox { background-color: white; } @@ -262,7 +324,15 @@ color: #FFF; } -.vs-dark .search-viewlet .query-box, +.vs .search-viewlet .search-widget .toggle-replace-button.collapse { + background-image: url('expando-collapsed.svg'); +} + +.vs .search-viewlet .search-widget .toggle-replace-button.expand { + background-image: url('expando-expanded.svg'); +} + +.vs-dark .search-viewlet .input-box, .vs-dark .search-viewlet .file-types .monaco-inputbox { background-color: #3C3C3C; } @@ -287,8 +357,18 @@ padding: 0; } +.vs-dark .search-viewlet .search-widget .toggle-replace-button.expand, +.hc-black .search-viewlet .search-widget .toggle-replace-button.expand { + background-image: url('expando-expanded-dark.svg'); +} + +.vs-dark .search-viewlet .search-widget .toggle-replace-button.collapse, +.hc-black .search-viewlet .search-widget .toggle-replace-button.collapse { + background-image: url('expando-collapsed-dark.svg'); +} + /* High Contrast Theming */ -.hc-black .monaco-workbench .search-viewlet .query-box, +.hc-black .monaco-workbench .search-viewlet .input-box, .hc-black .monaco-workbench .search-viewlet .file-types .monaco-inputbox { background-color: #000; } @@ -331,11 +411,11 @@ width: 16px; } -.hc-black .monaco-workbench .query-box { +.hc-black .monaco-workbench .input-box { border: 1px solid #6FC3DF; } -.hc-black .monaco-workbench .query-box .monaco-inputbox.idle { +.hc-black .monaco-workbench .input-box .monaco-inputbox.idle { border: none; } diff --git a/src/vs/workbench/parts/search/browser/replaceService.ts b/src/vs/workbench/parts/search/browser/replaceService.ts new file mode 100644 index 00000000000..863240ef981 --- /dev/null +++ b/src/vs/workbench/parts/search/browser/replaceService.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { TPromise } from 'vs/base/common/winjs.base'; +import { IReplaceService } from 'vs/workbench/parts/search/common/replace'; +import { IEditorService } from 'vs/platform/editor/common/editor'; +import { IEventService } from 'vs/platform/event/common/event'; +import { Match, FileMatch } from 'vs/workbench/parts/search/common/searchModel'; +import { BulkEdit, IResourceEdit, createBulkEdit } from 'vs/editor/common/services/bulkEdit'; +import { IProgressRunner } from 'vs/platform/progress/common/progress'; + +export class ReplaceService implements IReplaceService { + + public serviceId= IReplaceService; + + constructor(@IEventService private eventService: IEventService, @IEditorService private editorService) { + } + + public replace(match: Match, text: string): TPromise + public replace(files: FileMatch[], text: string, progress?: IProgressRunner): TPromise + public replace(arg: any, text: string, progress: IProgressRunner= null): TPromise { + + let bulkEdit: BulkEdit = createBulkEdit(this.eventService, this.editorService, null); + bulkEdit.progress(progress); + + if (arg instanceof Match) { + bulkEdit.add([this.createEdit(arg, text)]); + } + + if (arg instanceof Array) { + arg.forEach(element => { + let fileMatch = element; + fileMatch.matches().forEach(match => { + bulkEdit.add([this.createEdit(match, text)]); + }); + }); + } + + return bulkEdit.finish(); + } + + private createEdit(match: Match, text: string): IResourceEdit { + let fileMatch: FileMatch= match.parent(); + let resourceEdit: IResourceEdit= { + resource: fileMatch.resource(), + range: match.range(), + newText: text + }; + return resourceEdit; + } +} \ No newline at end of file diff --git a/src/vs/workbench/parts/search/browser/search.contribution.ts b/src/vs/workbench/parts/search/browser/search.contribution.ts index 2753d6131ed..942248d1ecd 100644 --- a/src/vs/workbench/parts/search/browser/search.contribution.ts +++ b/src/vs/workbench/parts/search/browser/search.contribution.ts @@ -27,6 +27,11 @@ import {IViewletService} from 'vs/workbench/services/viewlet/common/viewletServi import {KeyMod, KeyCode} from 'vs/base/common/keyCodes'; import {OpenSearchViewletAction} from 'vs/workbench/parts/search/browser/searchActions'; import {VIEWLET_ID} from 'vs/workbench/parts/search/common/constants'; +import {registerSingleton} from 'vs/platform/instantiation/common/extensions'; +import { IReplaceService } from 'vs/workbench/parts/search/common/replace'; +import { ReplaceService } from 'vs/workbench/parts/search/browser/replaceService'; + +registerSingleton(IReplaceService, ReplaceService); KeybindingsRegistry.registerCommandDesc({ id: 'workbench.action.search.toggleQueryDetails', @@ -185,4 +190,4 @@ configurationRegistry.registerConfiguration({ } } } -}); +}); \ No newline at end of file diff --git a/src/vs/workbench/parts/search/browser/searchActions.ts b/src/vs/workbench/parts/search/browser/searchActions.ts index 2615d928b08..96108ac7a68 100644 --- a/src/vs/workbench/parts/search/browser/searchActions.ts +++ b/src/vs/workbench/parts/search/browser/searchActions.ts @@ -12,12 +12,13 @@ import { ToggleViewletAction } from 'vs/workbench/browser/viewlet'; import { IViewletService } from 'vs/workbench/services/viewlet/common/viewletService'; import { ITree } from 'vs/base/parts/tree/browser/tree'; import { SearchViewlet } from 'vs/workbench/parts/search/browser/searchViewlet'; -import { Match, FileMatch } from 'vs/workbench/parts/search/common/searchModel'; +import { SearchResult, Match, FileMatch, FileMatchOrMatch } from 'vs/workbench/parts/search/common/searchModel'; +import { IReplaceService } from 'vs/workbench/parts/search/common/replace'; import * as Constants from 'vs/workbench/parts/search/common/constants'; import { CollapseAllAction as TreeCollapseAction } from 'vs/base/parts/tree/browser/treeDefaults'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { OpenGlobalSettingsAction } from 'vs/workbench/browser/actions/openSettings'; -import {IWorkbenchEditorService} from 'vs/workbench/services/editor/common/editorService'; +import { IWorkbenchEditorService } from 'vs/workbench/services/editor/common/editorService'; export class OpenSearchViewletAction extends ToggleViewletAction { @@ -27,6 +28,7 @@ export class OpenSearchViewletAction extends ToggleViewletAction { constructor(id: string, label: string, @IViewletService viewletService: IViewletService, @IWorkbenchEditorService editorService: IWorkbenchEditorService) { super(id, label, Constants.VIEWLET_ID, viewletService, editorService); } + } export class FindInFolderAction extends Action { @@ -88,82 +90,53 @@ export class ClearSearchResultsAction extends Action { } } -export class SelectOrRemoveAction extends Action { - private selectMode: boolean; - private viewlet: SearchViewlet; +export class RemoveAction extends Action { - constructor(viewlet: SearchViewlet) { - super('selectOrRemove'); - - this.label = nls.localize('SelectOrRemoveAction.selectLabel', "Select"); - this.enabled = false; - this.selectMode = true; - this.viewlet = viewlet; + constructor(private viewer: ITree, private element: FileMatchOrMatch) { + super('remove', nls.localize('RemoveAction.label', "Remove"), 'action-remove'); } public run(): TPromise { - let result: TPromise; - - if (this.selectMode) { - result = this.runAsSelect(); + if (this.element instanceof FileMatch) { + let parent:SearchResult = this.element.parent(); + parent.remove(this.element); } else { - result = this.runAsRemove(); + let parent:FileMatch = this.element.parent(); + parent.remove(this.element); } - - this.selectMode = !this.selectMode; - this.label = this.selectMode ? nls.localize('SelectOrRemoveAction.selectLabel', "Select") : nls.localize('SelectOrRemoveAction.removeLabel', "Remove"); - - return result; - } - - private runAsSelect(): TPromise { - this.viewlet.getResults().addClass('select'); - - return TPromise.as(null); - } - - private runAsRemove(): TPromise { - let elements: any[] = []; - let tree: ITree = this.viewlet.getControl(); - - tree.getInput().matches().forEach((fileMatch: FileMatch) => { - fileMatch.matches().filter((lineMatch: Match) => { - return (lineMatch).$checked; - }).forEach((lineMatch: Match) => { - lineMatch.parent().remove(lineMatch); - elements.push(lineMatch.parent()); - }); - }); - - this.viewlet.getResults().removeClass('select'); - - if (elements.length > 0) { - return tree.refreshAll(elements).then(() => { - return tree.refresh(); - }); - } - - return TPromise.as(null); + return this.viewer.refresh(parent); } } -export class RemoveAction extends Action { +export class ReplaceAllAction extends Action { - private viewer: ITree; - private fileMatch: FileMatch; - - constructor(viewer: ITree, element: FileMatch) { - super('remove', nls.localize('RemoveAction.label', "Remove"), 'action-remove'); - - this.viewer = viewer; - this.fileMatch = element; + constructor(private viewer: ITree, private fileMatch: FileMatch, private viewlet: SearchViewlet, + @IReplaceService private replaceService: IReplaceService) { + super('file-action-replace-all', nls.localize('file.replaceAll.label', "Replace All"), 'action-replace-all'); } public run(): TPromise { - let parent = this.fileMatch.parent(); - parent.remove(this.fileMatch); + return this.replaceService.replace([this.fileMatch], this.fileMatch.parent().replaceText).then(() => { + this.viewlet.open(this.fileMatch).done(() => { + new RemoveAction(this.viewer, this.fileMatch).run(); + }, errors.onUnexpectedError); + }); + } +} - return this.viewer.refresh(parent); +export class ReplaceAction extends Action { + + constructor(private viewer: ITree, private element: Match, private viewlet: SearchViewlet, + @IReplaceService private replaceService: IReplaceService) { + super('action-replace', nls.localize('match.replace.label', "Replace"), 'action-replace'); + } + + public run(): TPromise { + return this.replaceService.replace(this.element, this.element.parent().parent().replaceText).then(() => { + this.viewlet.open(this.element).done(() => { + new RemoveAction(this.viewer, this.element).run(); + }, errors.onUnexpectedError); + }); } } diff --git a/src/vs/workbench/parts/search/browser/searchResultsView.ts b/src/vs/workbench/parts/search/browser/searchResultsView.ts index 89be7678771..b617bc384e9 100644 --- a/src/vs/workbench/parts/search/browser/searchResultsView.ts +++ b/src/vs/workbench/parts/search/browser/searchResultsView.ts @@ -19,15 +19,14 @@ import { LeftRightWidget, IRenderer } from 'vs/base/browser/ui/leftRightWidget/l import { ITree, IElementCallback, IDataSource, ISorter, IAccessibilityProvider, IFilter } from 'vs/base/parts/tree/browser/tree'; import {ClickBehavior, DefaultController} from 'vs/base/parts/tree/browser/treeDefaults'; import { ContributableActionProvider } from 'vs/workbench/browser/actionBarRegistry'; -import { Match, EmptyMatch, SearchResult, FileMatch} from 'vs/workbench/parts/search/common/searchModel'; +import { Match, EmptyMatch, SearchResult, FileMatch, FileMatchOrMatch } from 'vs/workbench/parts/search/common/searchModel'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { Range } from 'vs/editor/common/core/range'; import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { CommonKeybindings} from 'vs/base/common/keyCodes'; import { SearchViewlet } from 'vs/workbench/parts/search/browser/searchViewlet'; -import { RemoveAction } from 'vs/workbench/parts/search/browser/searchActions'; - -export type FileMatchOrMatch = FileMatch | Match; +import { RemoveAction, ReplaceAllAction, ReplaceAction } from 'vs/workbench/parts/search/browser/searchActions'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; export class SearchDataSource implements IDataSource { @@ -87,14 +86,26 @@ export class SearchSorter implements ISorter { class SearchActionProvider extends ContributableActionProvider { + constructor(private viewlet: SearchViewlet, @IInstantiationService private instantiationService: IInstantiationService) { + super(); + } + public hasActions(tree: ITree, element: any): boolean { - return element instanceof FileMatch || super.hasActions(tree, element); + return element instanceof FileMatch || (tree.getInput().isReplaceActive() || element instanceof Match) || super.hasActions(tree, element); } public getActions(tree: ITree, element: any): TPromise { return super.getActions(tree, element).then(actions => { if (element instanceof FileMatch) { actions.unshift(new RemoveAction(tree, element)); + if (tree.getInput().isReplaceActive() && element.count() > 0) { + actions.unshift(this.instantiationService.createInstance(ReplaceAllAction, tree, element, this.viewlet)); + } + } + if (element instanceof Match && !(element instanceof EmptyMatch)) { + if (tree.getInput().isReplaceActive()) { + actions.unshift(this.instantiationService.createInstance(ReplaceAction, tree, element, this.viewlet), new RemoveAction(tree, element)); + } } return actions; @@ -104,9 +115,10 @@ class SearchActionProvider extends ContributableActionProvider { export class SearchRenderer extends ActionsRenderer { - constructor(actionRunner: IActionRunner, @IWorkspaceContextService private contextService: IWorkspaceContextService) { + constructor(actionRunner: IActionRunner, viewlet: SearchViewlet, @IWorkspaceContextService private contextService: IWorkspaceContextService, + @IInstantiationService private instantiationService: IInstantiationService) { super({ - actionProvider: new SearchActionProvider(), + actionProvider: instantiationService.createInstance(SearchActionProvider, viewlet), actionRunner: actionRunner }); } @@ -159,8 +171,21 @@ export class SearchRenderer extends ActionsRenderer { elements.push(''); elements.push(strings.escape(preview.before)); - elements.push(''); - elements.push(strings.escape(preview.inside)); + + let input= tree.getInput(); + if (input.isReplaceActive()) { + let replaceValue= input.replaceText; + if (replaceValue) { + elements.push(''); + elements.push(strings.escape(replaceValue)); + } else { + elements.push(''); + elements.push(strings.escape(preview.inside)); + } + } else { + elements.push(''); + elements.push(strings.escape(preview.inside)); + } elements.push(''); elements.push(strings.escape(preview.after)); elements.push(''); @@ -220,13 +245,22 @@ export class SearchController extends DefaultController { private onDelete(tree: ITree, event: IKeyboardEvent): boolean { let result = false; let element = tree.getFocus(); - if (element instanceof FileMatch) { + if (element instanceof FileMatch || + (element instanceof Match && tree.getInput().isReplaceActive() && !(element instanceof EmptyMatch))) { new RemoveAction(tree, element).run().done(null, errors.onUnexpectedError); result = true; } return result; } + + protected onUp(tree: ITree, event: IKeyboardEvent): boolean { + if (tree.getNavigator().first() === tree.getFocus()) { + this.viewlet.moveFocusFromResults(); + return true; + } + return super.onUp(tree, event); + } } export class SearchFilter implements IFilter { diff --git a/src/vs/workbench/parts/search/browser/searchViewlet.ts b/src/vs/workbench/parts/search/browser/searchViewlet.ts index 2ca4ef03896..6665f91abed 100644 --- a/src/vs/workbench/parts/search/browser/searchViewlet.ts +++ b/src/vs/workbench/parts/search/browser/searchViewlet.ts @@ -8,6 +8,7 @@ import 'vs/css!./media/searchviewlet'; import nls = require('vs/nls'); import {TPromise, PPromise} from 'vs/base/common/winjs.base'; +import {Delayer} from 'vs/base/common/async'; import {EditorType} from 'vs/editor/common/editorCommon'; import lifecycle = require('vs/base/common/lifecycle'); import errors = require('vs/base/common/errors'); @@ -18,10 +19,10 @@ import URI from 'vs/base/common/uri'; import strings = require('vs/base/common/strings'); import dom = require('vs/base/browser/dom'); import {IAction, Action} from 'vs/base/common/actions'; -import {StandardKeyboardEvent, IKeyboardEvent} from 'vs/base/browser/keyboardEvent'; +import {StandardKeyboardEvent} from 'vs/base/browser/keyboardEvent'; import timer = require('vs/base/common/timer'); import {Dimension, Builder, $} from 'vs/base/browser/builder'; -import {FindInput, IFindInputOptions} from 'vs/base/browser/ui/findinput/findInput'; +import { FindInput } from 'vs/base/browser/ui/findinput/findInput'; import {ITree} from 'vs/base/parts/tree/browser/tree'; import {Tree} from 'vs/base/parts/tree/browser/treeImpl'; import {Scope} from 'vs/workbench/common/memento'; @@ -31,7 +32,7 @@ import {IEditorGroupService} from 'vs/workbench/services/group/common/groupServi import {getOutOfWorkspaceEditorResources} from 'vs/workbench/common/editor'; import {FileChangeType, FileChangesEvent, EventType as FileEventType} from 'vs/platform/files/common/files'; import {Viewlet} from 'vs/workbench/browser/viewlet'; -import {Match, EmptyMatch, SearchResult} from 'vs/workbench/parts/search/common/searchModel'; +import {Match, EmptyMatch, FileMatch, SearchResult, FileMatchOrMatch} from 'vs/workbench/parts/search/common/searchModel'; import {getExcludes, QueryBuilder} from 'vs/workbench/parts/search/common/searchQuery'; import {VIEWLET_ID} from 'vs/workbench/parts/search/common/constants'; import {MessageType, InputBox } from 'vs/base/browser/ui/inputbox/inputBox'; @@ -51,27 +52,31 @@ import {ITelemetryService} from 'vs/platform/telemetry/common/telemetry'; import {KeyCode, CommonKeybindings} from 'vs/base/common/keyCodes'; import { PatternInputWidget } from 'vs/workbench/parts/search/browser/patternInputWidget'; import { SearchRenderer, SearchDataSource, SearchSorter, SearchController, SearchAccessibilityProvider, SearchFilter } from 'vs/workbench/parts/search/browser/searchResultsView'; -import { RefreshAction, SelectOrRemoveAction, CollapseAllAction, ClearSearchResultsAction, ConfigureGlobalExclusionsAction } from 'vs/workbench/parts/search/browser/searchActions'; +import { SearchWidget } from 'vs/workbench/parts/search/browser/searchWidget'; +import { RefreshAction, CollapseAllAction, ClearSearchResultsAction, ConfigureGlobalExclusionsAction } from 'vs/workbench/parts/search/browser/searchActions'; +import { IReplaceService } from 'vs/workbench/parts/search/common/replace'; export class SearchViewlet extends Viewlet { - private static MAX_TEXT_RESULTS = 2048; - private isDisposed: boolean; + private toDispose: lifecycle.IDisposable[]; + private currentRequest: PPromise; + private delayedRefresh: Delayer; private loading: boolean; private queryBuilder: QueryBuilder; private viewModel: SearchResult; private callOnModelChange: lifecycle.IDisposable[]; + private replacingAll:boolean= false; private viewletVisible: IKeybindingContextKey; private actionRegistry: { [key: string]: Action; }; private tree: ITree; private viewletSettings: any; private domNode: Builder; - private queryBox: HTMLElement; private messages: Builder; - private findInput: FindInput; + private searchWidgetsContainer: Builder; + private searchWidget: SearchWidget; private size: Dimension; private queryDetails: HTMLElement; private inputPatternExclusions: PatternInputWidget; @@ -93,10 +98,13 @@ export class SearchViewlet extends Viewlet { @IConfigurationService private configurationService: IConfigurationService, @IWorkspaceContextService private contextService: IWorkspaceContextService, @ISearchService private searchService: ISearchService, - @IKeybindingService keybindingService: IKeybindingService + @IKeybindingService keybindingService: IKeybindingService, + @IReplaceService private replaceService: IReplaceService ) { super(VIEWLET_ID, telemetryService); + this.toDispose = []; + this.delayedRefresh = new Delayer(200); this.viewletVisible = keybindingService.createKey('searchViewletVisible', true); this.callOnModelChange = []; @@ -119,16 +127,6 @@ export class SearchViewlet extends Viewlet { public create(parent: Builder): TPromise { super.create(parent); - let filePatterns = this.viewletSettings['query.filePatterns'] || ''; - let patternExclusions = this.viewletSettings['query.folderExclusions'] || ''; - let exclusionsUsePattern = this.viewletSettings['query.exclusionsUsePattern']; - let includesUsePattern = this.viewletSettings['query.includesUsePattern']; - let patternIncludes = this.viewletSettings['query.folderIncludes'] || ''; - let contentPattern = this.viewletSettings['query.contentPattern'] || ''; - let isRegex = this.viewletSettings['query.regex'] === true; - let isWholeWords = this.viewletSettings['query.wholeWords'] === true; - let isCaseSensitive = this.viewletSettings['query.caseSensitive'] === true; - let builder: Builder; this.domNode = parent.div({ 'class': 'search-viewlet' @@ -136,63 +134,26 @@ export class SearchViewlet extends Viewlet { builder = div; }); - let onStandardKeyUp = (keyboardEvent: IKeyboardEvent) => { - if (keyboardEvent.keyCode === KeyCode.Enter) { + builder.div({'class': ['search-widgets-container']}, (div) => { + this.searchWidgetsContainer= div; + }); + this.createSearchWidget(this.searchWidgetsContainer); + + let filePatterns = this.viewletSettings['query.filePatterns'] || ''; + let patternExclusions = this.viewletSettings['query.folderExclusions'] || ''; + let exclusionsUsePattern = this.viewletSettings['query.exclusionsUsePattern']; + let includesUsePattern = this.viewletSettings['query.includesUsePattern']; + let patternIncludes = this.viewletSettings['query.folderIncludes'] || ''; + + let onKeyUp = (e: KeyboardEvent) => { + if (e.keyCode === KeyCode.Enter) { this.onQueryChanged(true); - } else if (keyboardEvent.keyCode === KeyCode.Escape) { + } else if (e.keyCode === KeyCode.Escape) { this.cancelSearch(); } }; - let onKeyUp = (e: KeyboardEvent) => { - onStandardKeyUp(new StandardKeyboardEvent(e)); - }; - - this.queryBox = builder.div({ 'class': 'query-box' }, (div) => { - let options: IFindInputOptions = { - label: nls.localize('label.Search', 'Search: Type Search Term and press Enter to search or Escape to cancel'), - validation: (value: string) => { - if (value.length === 0) { - return null; - } - if (!this.findInput.getRegex()) { - return null; - } - let regExp: RegExp; - try { - regExp = new RegExp(value); - } catch (e) { - return { content: e.message }; - } - if (strings.regExpLeadsToEndlessLoop(regExp)) { - return { content: nls.localize('regexp.validationFailure', "Expression matches everything") }; - } - }, - placeholder: nls.localize('findPlaceHolder', "Press Enter to Search, ESC to Cancel") - }; - - this.findInput = new FindInput(div.getHTMLElement(), this.contextViewService, options); - this.findInput.onKeyUp(onStandardKeyUp); - this.findInput.onKeyDown((keyboardEvent: IKeyboardEvent) => { - if (keyboardEvent.keyCode === KeyCode.DownArrow) { - dom.EventHelper.stop(keyboardEvent); - if (this.showsFileTypes()) { - this.toggleFileTypes(true, true); - } else { - this.selectTreeIfNotSelected(keyboardEvent); - } - } - }); - this.findInput.onDidOptionChange((viaKeyboard) => { - this.onQueryChanged(true, viaKeyboard); - }); - this.findInput.setValue(contentPattern); - this.findInput.setRegex(isRegex); - this.findInput.setCaseSensitive(isCaseSensitive); - this.findInput.setWholeWords(isWholeWords); - }).style({ position: 'relative' }).getHTMLElement(); - - this.queryDetails = builder.div({ 'class': ['query-details', 'separator'] }, (builder) => { + this.queryDetails = this.searchWidgetsContainer.div({ 'class': ['query-details'] }, (builder) => { builder.div({ 'class': 'more', 'tabindex': 0, 'role': 'button', 'title': nls.localize('moreSearch', "Toggle Search Details") }) .on(dom.EventType.CLICK, (e) => { dom.EventHelper.stop(e); @@ -224,8 +185,7 @@ export class SearchViewlet extends Viewlet { let keyboardEvent = new StandardKeyboardEvent(e); if (keyboardEvent.equals(CommonKeybindings.UP_ARROW)) { dom.EventHelper.stop(e); - this.findInput.focus(); - this.findInput.select(); + this.searchWidget.focus(true, true); } else if (keyboardEvent.equals(CommonKeybindings.DOWN_ARROW)) { dom.EventHelper.stop(e); this.inputPatternExclusions.focus(); @@ -258,7 +218,7 @@ export class SearchViewlet extends Viewlet { this.inputPatternIncludes.select(); } else if (keyboardEvent.equals(CommonKeybindings.DOWN_ARROW)) { dom.EventHelper.stop(e); - this.selectTreeIfNotSelected(keyboardEvent); + this.selectTreeIfNotSelected(); } }).on(FindInput.OPTION_CHANGE, (e) => { this.onQueryChanged(false); @@ -280,13 +240,104 @@ export class SearchViewlet extends Viewlet { }).hide(); }).getHTMLElement(); - this.messages = builder.div({ 'class': 'messages' }).hide().clone(); + this.messages = this.searchWidgetsContainer.div({ 'class': 'messages' }).hide().clone(); + this.createSearchResultsView(builder); + + this.actionRegistry = {}; + let actions: Action[] = [new CollapseAllAction(this), new RefreshAction(this), new ClearSearchResultsAction(this)]; + actions.forEach((action) => { + this.actionRegistry[action.id] = action; + }); + + if (filePatterns !== '' || patternExclusions !== '' || patternIncludes !== '') { + this.toggleFileTypes(true, true, true); + } + + this.updateGlobalPatternExclusions(this.configurationService.getConfiguration()); + + return TPromise.as(null); + } + + private createSearchWidget(builder: Builder): void { + let contentPattern = this.viewletSettings['query.contentPattern'] || ''; + let isRegex = this.viewletSettings['query.regex'] === true; + let isWholeWords = this.viewletSettings['query.wholeWords'] === true; + let isCaseSensitive = this.viewletSettings['query.caseSensitive'] === true; + + this.searchWidget= new SearchWidget(builder, this.contextViewService, { + value: contentPattern, + isRegex: isRegex, + isCaseSensitive: isCaseSensitive, + isWholeWords: isWholeWords + }, this.instantiationService); + + this.searchWidget.onSearchSubmit((refresh) => this.onQueryChanged(refresh)); + this.searchWidget.onSearchCancel(() => this.cancelSearch()); + this.searchWidget.searchInput.onDidOptionChange((viaKeyboard) => this.onQueryChanged(true, viaKeyboard)); + + this.searchWidget.onReplaceToggled((state) => this.layout(this.size)); + this.searchWidget.onReplaceStateChange((state) => { + if (this.viewModel) { + this.viewModel.replaceText= this.searchWidget.getReplaceValue(); + } + this.tree.refresh(); + }); + this.searchWidget.onReplaceValueChanged((value) => { + if (this.viewModel) { + this.viewModel.replaceText= this.searchWidget.getReplaceValue(); + } + this.tree.refresh(); + }); + + this.searchWidget.onKeyDownArrow(() => { + if (this.showsFileTypes()) { + this.toggleFileTypes(true, this.showsFileTypes()); + } else { + this.selectTreeIfNotSelected(); + } + }); + this.searchWidget.onReplaceAll(() => this.replaceAll()); + } + + private replaceAll(): void { + let progressRunner= this.progressService.show(100); + + let occurrences= this.viewModel.count(); + let fileCount= this.viewModel.fileCount(); + let afterReplaceAllMessage= nls.localize('replaceAll.message', "Replaced {0} occurrences across {1} files.", occurrences, fileCount); + let isDone:boolean= false; + + let confirmation= { + title: nls.localize('replaceAll.confirmation.title', "Replace All"), + message: nls.localize('replaceAll.confirmation.message', "Replace {0} occurrences across {1} files?", occurrences, fileCount), + primaryButton: nls.localize('replaceAll.confirm.button', "Replace") + }; + + if (this.messageService.confirm(confirmation)) { + this.replacingAll= true; + this.replaceService.replace(this.viewModel.matches(), this.viewModel.replaceText, progressRunner).then(() => { + isDone= true; + this.replacingAll= false; + setTimeout(() => { + progressRunner.done(); + this.showEmptyStage(); + this.showMessage(afterReplaceAllMessage); + }, 200); + }, () => this.replacingAll= false); + } + } + + private showMessage(text: string): Builder { + return this.messages.empty().show().asContainer().div({ 'class': 'message', text: text }); + } + + private createSearchResultsView(builder: Builder): void { builder.div({ 'class': 'results' }, (div) => { this.results = div; let dataSource = new SearchDataSource(); - let renderer = this.instantiationService.createInstance(SearchRenderer, this.getActionRunner()); + let renderer = this.instantiationService.createInstance(SearchRenderer, this.getActionRunner(), this); this.tree = new Tree(div.getHTMLElement(), { dataSource: dataSource, @@ -323,20 +374,6 @@ export class SearchViewlet extends Viewlet { this.onFocus(element, !focusEditor, sideBySide, doubleClick); })); }); - - this.actionRegistry = {}; - let actions: Action[] = [new CollapseAllAction(this), new RefreshAction(this), new ClearSearchResultsAction(this), new SelectOrRemoveAction(this)]; - actions.forEach((action) => { - this.actionRegistry[action.id] = action; - }); - - if (filePatterns !== '' || patternExclusions !== '' || patternIncludes !== '') { - this.toggleFileTypes(true, true, true); - } - - this.updateGlobalPatternExclusions(this.configurationService.getConfiguration()); - - return TPromise.as(null); } private updateGlobalPatternExclusions(configuration: ISearchConfiguration): void { @@ -393,13 +430,19 @@ export class SearchViewlet extends Viewlet { public focus(): void { super.focus(); - let selectedText = this.getSelectionFromEditor(); + let selectedText = this.getSearchTextFromEditor(); if (selectedText) { - this.findInput.setValue(selectedText); + this.searchWidget.searchInput.setValue(selectedText); } + this.searchWidget.focus(); + } - this.findInput.focus(); - this.findInput.select(); + public moveFocusFromResults(): void { + if (this.showsFileTypes()) { + this.toggleFileTypes(true, true, false, true); + } else { + this.searchWidget.focus(true, true); + } } private reLayout(): void { @@ -407,15 +450,13 @@ export class SearchViewlet extends Viewlet { return; } - this.findInput.setWidth(this.size.width - 34 /* container margin */); + this.searchWidget.setWidth(this.size.width - 34 /* container margin */); - this.inputPatternExclusions.setWidth(this.size.width - 42 /* container margin */); - this.inputPatternIncludes.setWidth(this.size.width - 42 /* container margin */); - this.inputPatternGlobalExclusions.width = this.size.width - 42 /* container margin */ - 24 /* actions */; + this.inputPatternExclusions.setWidth(this.size.width - 36 /* container margin */); + this.inputPatternIncludes.setWidth(this.size.width - 36 /* container margin */); + this.inputPatternGlobalExclusions.width = this.size.width - 36 /* container margin */ - 24 /* actions */; - let queryBoxHeight = dom.getTotalHeight(this.queryBox); - let queryDetailsHeight = dom.getTotalHeight(this.queryDetails); - let searchResultContainerSize = this.size.height - queryBoxHeight - queryDetailsHeight; + let searchResultContainerSize = this.size.height - dom.getTotalHeight(this.searchWidgetsContainer.getContainer()) - 6 /** container margin top */; this.results.style({ height: searchResultContainerSize + 'px' }); this.tree.layout(searchResultContainerSize); @@ -433,7 +474,7 @@ export class SearchViewlet extends Viewlet { public clearSearchResults(): void { this.disposeModel(); this.showEmptyStage(); - this.findInput.clear(); + this.searchWidget.clear(); if (this.currentRequest) { this.currentRequest.cancel(); this.currentRequest = null; @@ -442,8 +483,7 @@ export class SearchViewlet extends Viewlet { public cancelSearch(): boolean { if (this.currentRequest) { - this.findInput.focus(); - this.findInput.select(); + this.searchWidget.focus(); this.currentRequest.cancel(); this.currentRequest = null; @@ -454,7 +494,7 @@ export class SearchViewlet extends Viewlet { return false; } - private selectTreeIfNotSelected(keyboardEvent: IKeyboardEvent): void { + private selectTreeIfNotSelected(): void { if (this.tree.getInput()) { this.tree.DOMFocus(); let selection = this.tree.getSelection(); @@ -464,21 +504,21 @@ export class SearchViewlet extends Viewlet { } } - private getSelectionFromEditor(): string { + private getSearchTextFromEditor(): string { if (!this.editorService.getActiveEditor()) { return null; } - let editor: any = this.editorService.getActiveEditor().getControl(); - if (!editor || !isFunction(editor.getEditorType) || editor.getEditorType() !== EditorType.ICodeEditor) { // Substitute for (editor instanceof ICodeEditor) + let editorControl: any = this.editorService.getActiveEditor().getControl(); + if (!editorControl || !isFunction(editorControl.getEditorType) || editorControl.getEditorType() !== EditorType.ICodeEditor) { // Substitute for (editor instanceof ICodeEditor) return null; } - let range = editor.getSelection(); + let range = editorControl.getSelection(); if (range && !range.isEmpty() && range.startLineNumber === range.endLineNumber) { - let r = editor.getModel().getLineContent(range.startLineNumber); - r = r.substring(range.startColumn - 1, range.endColumn - 1); - return r; + let searchText = editorControl.getModel().getLineContent(range.startLineNumber); + searchText = searchText.substring(range.startColumn - 1, range.endColumn - 1); + return searchText; } return null; @@ -488,7 +528,7 @@ export class SearchViewlet extends Viewlet { return dom.hasClass(this.queryDetails, 'more'); } - public toggleFileTypes(moveFocus?: boolean, show?: boolean, skipLayout?: boolean): void { + public toggleFileTypes(moveFocus?: boolean, show?: boolean, skipLayout?: boolean, reverse?: boolean): void { let cls = 'more'; show = typeof show === 'undefined' ? !dom.hasClass(this.queryDetails, cls) : Boolean(show); skipLayout = Boolean(skipLayout); @@ -496,14 +536,18 @@ export class SearchViewlet extends Viewlet { if (show) { dom.addClass(this.queryDetails, cls); if (moveFocus) { - this.inputPatternIncludes.focus(); - this.inputPatternIncludes.select(); + if (reverse) { + this.inputPatternExclusions.focus(); + this.inputPatternExclusions.select(); + } else { + this.inputPatternIncludes.focus(); + this.inputPatternIncludes.select(); + } } } else { dom.removeClass(this.queryDetails, cls); if (moveFocus) { - this.findInput.focus(); - this.findInput.select(); + this.searchWidget.focus(); } } @@ -521,15 +565,15 @@ export class SearchViewlet extends Viewlet { if (workspaceRelativePath) { this.inputPatternIncludes.setIsGlobPattern(false); this.inputPatternIncludes.setValue(workspaceRelativePath); - this.findInput.focus(); + this.searchWidget.focus(false); } } public onQueryChanged(rerunQuery: boolean, preserveFocus?: boolean): void { - let isRegex = this.findInput.getRegex(); - let isWholeWords = this.findInput.getWholeWords(); - let isCaseSensitive = this.findInput.getCaseSensitive(); - let contentPattern = this.findInput.getValue(); + let isRegex = this.searchWidget.searchInput.getRegex(); + let isWholeWords = this.searchWidget.searchInput.getWholeWords(); + let isCaseSensitive = this.searchWidget.searchInput.getCaseSensitive(); + let contentPattern = this.searchWidget.searchInput.getValue(); let patternExcludes = this.inputPatternExclusions.getValue().trim(); let exclusionsUsePattern = this.inputPatternExclusions.isGlobPattern(); let patternIncludes = this.inputPatternIncludes.getValue().trim(); @@ -581,14 +625,14 @@ export class SearchViewlet extends Viewlet { folderResources: this.contextService.getWorkspace() ? [this.contextService.getWorkspace().resource] : [], extraFileResources: getOutOfWorkspaceEditorResources(this.editorGroupService, this.contextService), excludePattern: excludes, - includePattern: includes, - maxResults: SearchViewlet.MAX_TEXT_RESULTS, + includePattern: includes + // maxResults: SearchViewlet.MAX_TEXT_RESULTS, }; this.onQueryTriggered(this.queryBuilder.text(content, options), patternExcludes, patternIncludes); if (!preserveFocus) { - this.findInput.focus(); // focus back to input field + this.searchWidget.focus(false); // focus back to input field } } @@ -607,7 +651,7 @@ export class SearchViewlet extends Viewlet { let progressWorked = 0; this.loading = true; - this.findInput.clearMessage(); + this.searchWidget.searchInput.clearMessage(); this.disposeModel(); this.showEmptyStage(); @@ -657,6 +701,7 @@ export class SearchViewlet extends Viewlet { this.viewModel.append(completed.results); } } + this.viewModel.replaceText= this.searchWidget.getReplaceValue(); this.tree.refresh().then(() => { autoExpand(true); @@ -667,12 +712,11 @@ export class SearchViewlet extends Viewlet { this.telemetryService.publicLog('searchResultsShown', { count: this.viewModel.count(), fileCount: this.viewModel.fileCount() }); this.actionRegistry['refresh'].enabled = true; - this.actionRegistry['selectOrRemove'].enabled = hasResults; this.actionRegistry['vs.tree.collapse'].enabled = hasResults; this.actionRegistry['clearSearchResults'].enabled = hasResults; if (completed && completed.limitHit) { - this.findInput.showMessage({ + this.searchWidget.searchInput.showMessage({ content: nls.localize('searchMaxResultsWarning', "The result set only contains a subset of all matches. Please be more specific in your search to narrow down the results."), type: MessageType.WARNING }); @@ -700,7 +744,7 @@ export class SearchViewlet extends Viewlet { this.tree.onHidden(); this.results.hide(); - let div = this.messages.empty().show().asContainer().div({ 'class': 'message', text: message }); + let div = this.showMessage(message); if (!completed) { $(div).a({ @@ -784,7 +828,11 @@ export class SearchViewlet extends Viewlet { this.viewModel = this.instantiationService.createInstance(SearchResult, query.contentPattern); this.tree.setInput(this.viewModel).then(() => { autoExpand(false); - this.callOnModelChange.push(this.viewModel.addListener2('changed', (e: any) => this.tree.refresh(e, true))); + this.callOnModelChange.push(this.viewModel.addListener2('changed', (e: any) => { + if (this.replacingAll) { + this.delayedRefresh.trigger(() => this.tree.refresh(e, true)); + } + })); }).done(null, errors.onUnexpectedError); } @@ -840,7 +888,6 @@ export class SearchViewlet extends Viewlet { // disable 'result'-actions this.actionRegistry['refresh'].enabled = false; - this.actionRegistry['selectOrRemove'].enabled = false; this.actionRegistry['vs.tree.collapse'].enabled = false; this.actionRegistry['clearSearchResults'].enabled = false; @@ -858,16 +905,50 @@ export class SearchViewlet extends Viewlet { this.telemetryService.publicLog('searchResultChosen'); + return this.open(lineMatch, preserveFocus, sideBySide, pinned); + } + + public open(element: FileMatchOrMatch, preserveFocus?: boolean, sideBySide?: boolean, pinned?: boolean): TPromise { + let selection= this.getSelectionFrom(element); + let resource= element instanceof Match ? element.parent().resource() : (element).resource(); return this.editorService.openEditor({ - resource: lineMatch.parent().resource(), + resource: resource, options: { preserveFocus, pinned, - selection: lineMatch instanceof EmptyMatch ? void 0 : lineMatch.range() + selection: selection } }, sideBySide); } + private getSelectionFrom(element: FileMatchOrMatch): any { + if (element instanceof EmptyMatch) { + return void 0; + } + + let match: Match= null; + if (element instanceof Match) { + match= element; + } + if (element instanceof FileMatch && element.count() > 0) { + match= element.matches()[element.matches.length - 1]; + } + if (match) { + let range= match.range(); + if (this.viewModel.isReplaceActive()) { + let replaceText= this.viewModel.replaceText; + return { + startLineNumber: range.startLineNumber, + startColumn: range.startColumn + replaceText.length, + endLineNumber: range.startLineNumber, + endColumn: range.startColumn + replaceText.length + }; + } + return range; + } + return void 0; + } + private onUntitledFileSaved(e: UntitledEditorEvent): void { if (!this.viewModel) { return; @@ -906,10 +987,13 @@ export class SearchViewlet extends Viewlet { public dispose(): void { this.isDisposed = true; + this.toDispose = lifecycle.dispose(this.toDispose); + if (this.tree) { this.tree.dispose(); } + this.searchWidget.dispose(); this.inputPatternIncludes.dispose(); this.inputPatternExclusions.dispose(); @@ -926,4 +1010,4 @@ export class SearchViewlet extends Viewlet { } this.callOnModelChange = lifecycle.dispose(this.callOnModelChange); } -} +} \ No newline at end of file diff --git a/src/vs/workbench/parts/search/browser/searchWidget.ts b/src/vs/workbench/parts/search/browser/searchWidget.ts new file mode 100644 index 00000000000..6f4f6045bbc --- /dev/null +++ b/src/vs/workbench/parts/search/browser/searchWidget.ts @@ -0,0 +1,257 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import nls = require('vs/nls'); +import strings = require('vs/base/common/strings'); +import dom = require('vs/base/browser/dom'); +import { TPromise } from 'vs/base/common/winjs.base'; +import { Widget } from 'vs/base/browser/ui/widget'; +import { Action } from 'vs/base/common/actions'; +import { FindInput, IFindInputOptions } from 'vs/base/browser/ui/findinput/findInput'; +import { InputBox } from 'vs/base/browser/ui/inputbox/inputBox'; +import { Button } from 'vs/base/browser/ui/button/button'; +import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent'; +import { IContextViewService } from 'vs/platform/contextview/browser/contextView'; +import { KeyCode } from 'vs/base/common/keyCodes'; +import Event, { Emitter } from 'vs/base/common/event'; +import { Builder } from 'vs/base/browser/builder'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; + +export interface ISearchWidgetOptions { + value?:string; + isRegex?:boolean; + isCaseSensitive?:boolean; + isWholeWords?:boolean; +} + +export class SearchWidget extends Widget { + + public static REPLACE_PLACE_HOLD= nls.localize('search.replace.placeHolder', "Replace"); + + public domNode: HTMLElement; + public searchInput: FindInput; + private replaceInput: InputBox; + + private replaceInputContainer: HTMLElement; + private toggleReplaceButton: Button; + private replaceAllAction: Action; + + private _onSearchSubmit = this._register(new Emitter()); + public onSearchSubmit: Event = this._onSearchSubmit.event; + + private _onSearchCancel = this._register(new Emitter()); + public onSearchCancel: Event = this._onSearchCancel.event; + + private _onReplaceToggled = this._register(new Emitter()); + public onReplaceToggled: Event = this._onReplaceToggled.event; + + private _onReplaceState = this._register(new Emitter()); + public onReplaceStateChange: Event = this._onReplaceState.event; + + private _onReplaceValueChanged = this._register(new Emitter()); + public onReplaceValueChanged: Event = this._onReplaceValueChanged.event; + + private _onKeyDownArrow = this._register(new Emitter()); + public onKeyDownArrow: Event = this._onKeyDownArrow.event; + + private _onReplaceAll = this._register(new Emitter()); + public onReplaceAll: Event = this._onReplaceAll.event; + + constructor(container: Builder, private contextViewService: IContextViewService, options: ISearchWidgetOptions= Object.create(null), + @IInstantiationService private instantiationService: IInstantiationService) { + super(); + this.render(container, options); + } + + public focus(select:boolean= true, focusReplace: boolean= false):void { + if (this.searchInput.inputBox.hasFocus() || this.replaceInput.hasFocus()) { + return; + } + + if (focusReplace && this.isReplaceShown()) { + this.replaceInput.focus(); + if (select) { + this.replaceInput.select(); + } + } else { + this.searchInput.focus(); + if (select) { + this.searchInput.select(); + } + } + } + + public setWidth(width: number) { + this.searchInput.setWidth(width - 2); + this.replaceInput.width= width - 28; + } + + public clear() { + this.searchInput.clear(); + this.replaceInput.value= ''; + } + + public isReplaceActive(): boolean { + return this.isReplaceShown() && this.replaceAllAction.enabled; + } + + public isReplaceShown(): boolean { + return !dom.hasClass(this.replaceInputContainer, 'disabled'); + } + + public getReplaceValue():string { + return this.isReplaceActive() ? this.replaceInput.value : null; + } + + private render(container: Builder, options: ISearchWidgetOptions): void { + this.domNode = container.div({ 'class': 'search-widget' }).style({ position: 'relative' }).getHTMLElement(); + this.renderToggleReplaceButton(this.domNode); + + this.renderSearchInput(this.domNode, options); + this.renderReplaceInput(this.domNode); + } + + private renderToggleReplaceButton(parent: HTMLElement): void { + this.toggleReplaceButton= this._register(new Button(parent)); + this.toggleReplaceButton.icon= 'toggle-replace-button collapse'; + this.toggleReplaceButton.addListener2('click', () => this.onToggleReplaceButton()); + this.toggleReplaceButton.getElement().title= nls.localize('search.replace.toggle.button.title', "Toggle Replace"); + } + + private renderSearchInput(parent: HTMLElement, options: ISearchWidgetOptions): void { + let inputOptions: IFindInputOptions = { + label: nls.localize('label.Search', 'Search: Type Search Term and press Enter to search or Escape to cancel'), + validation: (value: string) => this.validatSearchInput(value), + placeholder: nls.localize('search.placeHolder', "Search") + }; + + let searchInputContainer= dom.append(parent, dom.emmet('.search-box.input-box')); + this.searchInput = this._register(new FindInput(searchInputContainer, this.contextViewService, inputOptions)); + this.searchInput.onKeyUp((keyboardEvent: IKeyboardEvent) => this.onSearchInputKeyUp(keyboardEvent)); + this.searchInput.onKeyDown((keyboardEvent: IKeyboardEvent) => this.onSearchInputKeyDown(keyboardEvent)); + this.searchInput.setValue(options.value || ''); + this.searchInput.setRegex(!!options.isRegex); + this.searchInput.setCaseSensitive(!!options.isCaseSensitive); + this.searchInput.setWholeWords(!!options.isWholeWords); + this._register(dom.addDisposableListener(this.searchInput.inputBox.inputElement, dom.EventType.FOCUS, () => this.updateReplaceActionState())); + this._register(dom.addDisposableListener(this.searchInput.inputBox.inputElement, dom.EventType.BLUR, () => this.updateReplaceActionState())); + } + + private renderReplaceInput(parent: HTMLElement): void { + this.replaceAllAction = new Action('action-replace-all', nls.localize('file.replaceAll.label', "Replace All"), 'action-replace-all', false, () => { + this._onReplaceAll.fire(); + return TPromise.as(null); + }); + this.replaceInputContainer= dom.append(parent, dom.emmet('.replace-box.input-box.disabled')); + this.replaceInput = this._register(new InputBox(this.replaceInputContainer, this.contextViewService, { + placeholder: SearchWidget.REPLACE_PLACE_HOLD, + actions: [this.replaceAllAction] + })); + this.onkeydown(this.replaceInput.inputElement, (keyboardEvent) => this.onReplaceInputKeyDown(keyboardEvent)); + this.onkeyup(this.replaceInput.inputElement, (keyboardEvent) => this.onReplaceInputKeyUp(keyboardEvent)); + this.replaceInput.onDidChange(() => this._onReplaceValueChanged.fire()); + } + + private onToggleReplaceButton():void { + dom.toggleClass(this.replaceInputContainer, 'disabled'); + dom.toggleClass(this.toggleReplaceButton.getElement(), 'collapse'); + dom.toggleClass(this.toggleReplaceButton.getElement(), 'expand'); + this._onReplaceToggled.fire(); + this.updateReplaceActionState(); + } + + private updateReplaceActionState():boolean { + let enabled= this.isReplaceShown() && !this.searchInput.inputBox.hasFocus(); + if (this.replaceAllAction.enabled !== enabled) { + this.replaceAllAction.enabled= enabled; + this._onReplaceState.fire(); + return true; + } + return false; + } + + private validatSearchInput(value: string): any { + if (value.length === 0) { + return null; + } + if (!this.searchInput.getRegex()) { + return null; + } + let regExp: RegExp; + try { + regExp = new RegExp(value); + } catch (e) { + return { content: e.message }; + } + if (strings.regExpLeadsToEndlessLoop(regExp)) { + return { content: nls.localize('regexp.validationFailure', "Expression matches everything") }; + } + } + + private onSearchInputKeyUp(keyboardEvent: IKeyboardEvent) { + switch (keyboardEvent.keyCode) { + case KeyCode.Enter: + this.submitSearch(); + return; + case KeyCode.Escape: + this._onSearchCancel.fire(); + return; + default: + return; + } + } + + private onSearchInputKeyDown(keyboardEvent: IKeyboardEvent) { + switch (keyboardEvent.keyCode) { + case KeyCode.DownArrow: + if (this.isReplaceShown()) { + this.replaceInput.focus(); + keyboardEvent.stopPropagation(); + } else { + this._onKeyDownArrow.fire(); + } + return; + default: + return; + } + } + + private onReplaceInputKeyUp(keyboardEvent: IKeyboardEvent) { + switch (keyboardEvent.keyCode) { + case KeyCode.Enter: + this.submitSearch(); + return; + case KeyCode.Escape: + this.onToggleReplaceButton(); + this.searchInput.focus(); + return; + default: + return; + } + } + + private onReplaceInputKeyDown(keyboardEvent: IKeyboardEvent) { + switch (keyboardEvent.keyCode) { + case KeyCode.UpArrow: + this.searchInput.focus(); + return; + case KeyCode.DownArrow: + this._onKeyDownArrow.fire(); + return; + default: + return; + } + } + + private submitSearch(refresh: boolean= true): void { + if (this.searchInput.getValue()) { + this._onSearchSubmit.fire(refresh); + } + } + + public dispose(): void { + super.dispose(); + } +} \ No newline at end of file diff --git a/src/vs/workbench/parts/search/common/replace.ts b/src/vs/workbench/parts/search/common/replace.ts new file mode 100644 index 00000000000..e757d0150f4 --- /dev/null +++ b/src/vs/workbench/parts/search/common/replace.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { TPromise } from 'vs/base/common/winjs.base'; +import { Match, FileMatch } from 'vs/workbench/parts/search/common/searchModel'; +import { createDecorator, ServiceIdentifier } from 'vs/platform/instantiation/common/instantiation'; +import { IProgressRunner } from 'vs/platform/progress/common/progress'; + +export var IReplaceService = createDecorator('replaceService'); + +export interface IReplaceService { + + serviceId : ServiceIdentifier; + + /** + * Replace the match with the given text. + */ + replace(match: Match, text: string): TPromise; + + /** + * Replace all the matches in the given file matches with provided text. + * You can also pass the progress runner to update the progress of replacing. + */ + replace(files: FileMatch[], text: string, progress?: IProgressRunner): TPromise; +} diff --git a/src/vs/workbench/parts/search/common/searchModel.ts b/src/vs/workbench/parts/search/common/searchModel.ts index f246e33c4be..995094d2cd9 100644 --- a/src/vs/workbench/parts/search/common/searchModel.ts +++ b/src/vs/workbench/parts/search/common/searchModel.ts @@ -123,6 +123,8 @@ export class FileMatch implements lifecycle.IDisposable { } } +export type FileMatchOrMatch = FileMatch | Match; + export class LiveFileMatch extends FileMatch implements lifecycle.IDisposable { private static DecorationOption: IModelDecorationOptions = { @@ -199,12 +201,21 @@ export class LiveFileMatch extends FileMatch implements lifecycle.IDisposable { private _isTextModelDisposed(): boolean { return !this._model || (this._model).isDisposed(); } + + public remove(match: Match): void { + super.remove(match); + if (this.count() === 0) { + this.add(new EmptyMatch(this)); + } + } + } export class SearchResult extends EventEmitter { private _modelService: IModelService; private _query: Search.IPatternInfo; + private _replace: string= null; private _disposables: lifecycle.IDisposable[] = []; private _matches: { [key: string]: FileMatch; } = Object.create(null); @@ -221,6 +232,26 @@ export class SearchResult extends EventEmitter { } } + /** + * Return true if replace is enabled otherwise false + */ + public isReplaceActive():boolean { + return this.replaceText !== null && this.replaceText !== void 0; + } + + /** + * Returns the text to replace. + * Can be null if replace is not enabled. Use replace() before. + * Can be empty. + */ + public get replaceText(): string { + return this._replace; + } + + public set replaceText(replace: string) { + this._replace= replace; + } + private _onModelAdded(model: IModel): void { let resource = model.uri, fileMatch = this._matches[resource.toString()];