From bf219ecbfe9824ff38f0b1c60ce86d420ad3081c Mon Sep 17 00:00:00 2001 From: Jan Bicker Date: Tue, 20 Mar 2018 17:47:05 +0100 Subject: [PATCH] Search and Replace Widget implemented Signed-off-by: Jan Bicker --- .../core/src/browser/icons/CollapseAll.svg | 7 + .../src/browser/icons/CollapseAll_inverse.svg | 7 + packages/core/src/browser/icons/Refresh.svg | 7 + .../src/browser/icons/Refresh_inverse.svg | 7 + .../src/browser/icons/case-sensitive-dark.svg | 16 + .../core/src/browser/icons/case-sensitive.svg | 16 + .../icons/clear-search-results-dark.svg | 8 + .../browser/icons/clear-search-results.svg | 8 + .../core/src/browser/icons/regex-dark.svg | 10 + packages/core/src/browser/icons/regex.svg | 10 + .../src/browser/icons/replace-all-inverse.svg | 13 + .../core/src/browser/icons/replace-all.svg | 13 + .../src/browser/icons/replace-inverse.svg | 15 + packages/core/src/browser/icons/replace.svg | 15 + .../src/browser/icons/whole-word-dark.svg | 19 + .../core/src/browser/icons/whole-word.svg | 19 + .../src/browser/shell/application-shell.ts | 7 + .../style/variables-bright.useable.css | 14 + .../browser/style/variables-dark.useable.css | 14 + packages/editor/src/browser/editor.ts | 28 ++ packages/monaco/src/browser/monaco-editor.ts | 30 ++ .../src/browser/in-memory-text-resource.ts | 33 ++ .../src/browser/quick-search-in-workspace.ts | 12 +- ...arch-in-workspace-frontend-contribution.ts | 32 ++ .../search-in-workspace-frontend-module.ts | 36 +- .../search-in-workspace-result-tree-widget.ts | 466 ++++++++++++++++++ .../browser/search-in-workspace-service.ts | 17 +- .../src/browser/search-in-workspace-widget.ts | 278 +++++++++++ .../src/browser/styles/index.css | 315 ++++++++++++ .../common/search-in-workspace-interface.ts | 20 + ...ep-search-in-workspace-server.slow-spec.ts | 102 +++- .../ripgrep-search-in-workspace-server.ts | 42 +- 32 files changed, 1597 insertions(+), 39 deletions(-) create mode 100644 packages/core/src/browser/icons/CollapseAll.svg create mode 100644 packages/core/src/browser/icons/CollapseAll_inverse.svg create mode 100644 packages/core/src/browser/icons/Refresh.svg create mode 100644 packages/core/src/browser/icons/Refresh_inverse.svg create mode 100644 packages/core/src/browser/icons/case-sensitive-dark.svg create mode 100644 packages/core/src/browser/icons/case-sensitive.svg create mode 100644 packages/core/src/browser/icons/clear-search-results-dark.svg create mode 100644 packages/core/src/browser/icons/clear-search-results.svg create mode 100644 packages/core/src/browser/icons/regex-dark.svg create mode 100644 packages/core/src/browser/icons/regex.svg create mode 100644 packages/core/src/browser/icons/replace-all-inverse.svg create mode 100644 packages/core/src/browser/icons/replace-all.svg create mode 100644 packages/core/src/browser/icons/replace-inverse.svg create mode 100644 packages/core/src/browser/icons/replace.svg create mode 100644 packages/core/src/browser/icons/whole-word-dark.svg create mode 100644 packages/core/src/browser/icons/whole-word.svg create mode 100644 packages/search-in-workspace/src/browser/in-memory-text-resource.ts create mode 100644 packages/search-in-workspace/src/browser/search-in-workspace-frontend-contribution.ts create mode 100644 packages/search-in-workspace/src/browser/search-in-workspace-result-tree-widget.ts create mode 100644 packages/search-in-workspace/src/browser/search-in-workspace-widget.ts create mode 100644 packages/search-in-workspace/src/browser/styles/index.css diff --git a/packages/core/src/browser/icons/CollapseAll.svg b/packages/core/src/browser/icons/CollapseAll.svg new file mode 100644 index 0000000000000..f44a3b03142ad --- /dev/null +++ b/packages/core/src/browser/icons/CollapseAll.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/core/src/browser/icons/CollapseAll_inverse.svg b/packages/core/src/browser/icons/CollapseAll_inverse.svg new file mode 100644 index 0000000000000..0d65cd8ba6095 --- /dev/null +++ b/packages/core/src/browser/icons/CollapseAll_inverse.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/core/src/browser/icons/Refresh.svg b/packages/core/src/browser/icons/Refresh.svg new file mode 100644 index 0000000000000..82ac7408b2a74 --- /dev/null +++ b/packages/core/src/browser/icons/Refresh.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/core/src/browser/icons/Refresh_inverse.svg b/packages/core/src/browser/icons/Refresh_inverse.svg new file mode 100644 index 0000000000000..b0f180ce1563b --- /dev/null +++ b/packages/core/src/browser/icons/Refresh_inverse.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/core/src/browser/icons/case-sensitive-dark.svg b/packages/core/src/browser/icons/case-sensitive-dark.svg new file mode 100644 index 0000000000000..f4b6fcd6d81d1 --- /dev/null +++ b/packages/core/src/browser/icons/case-sensitive-dark.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/packages/core/src/browser/icons/case-sensitive.svg b/packages/core/src/browser/icons/case-sensitive.svg new file mode 100644 index 0000000000000..94f9b32503fa5 --- /dev/null +++ b/packages/core/src/browser/icons/case-sensitive.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/packages/core/src/browser/icons/clear-search-results-dark.svg b/packages/core/src/browser/icons/clear-search-results-dark.svg new file mode 100644 index 0000000000000..ae0e6809a8c85 --- /dev/null +++ b/packages/core/src/browser/icons/clear-search-results-dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/core/src/browser/icons/clear-search-results.svg b/packages/core/src/browser/icons/clear-search-results.svg new file mode 100644 index 0000000000000..24a8a8b1cc6dc --- /dev/null +++ b/packages/core/src/browser/icons/clear-search-results.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/core/src/browser/icons/regex-dark.svg b/packages/core/src/browser/icons/regex-dark.svg new file mode 100644 index 0000000000000..5b0a7c7a8908c --- /dev/null +++ b/packages/core/src/browser/icons/regex-dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/core/src/browser/icons/regex.svg b/packages/core/src/browser/icons/regex.svg new file mode 100644 index 0000000000000..b83e6965ec6e2 --- /dev/null +++ b/packages/core/src/browser/icons/regex.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/core/src/browser/icons/replace-all-inverse.svg b/packages/core/src/browser/icons/replace-all-inverse.svg new file mode 100644 index 0000000000000..cb1b7fca836fd --- /dev/null +++ b/packages/core/src/browser/icons/replace-all-inverse.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/packages/core/src/browser/icons/replace-all.svg b/packages/core/src/browser/icons/replace-all.svg new file mode 100644 index 0000000000000..edd6cc8ef7049 --- /dev/null +++ b/packages/core/src/browser/icons/replace-all.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/packages/core/src/browser/icons/replace-inverse.svg b/packages/core/src/browser/icons/replace-inverse.svg new file mode 100644 index 0000000000000..ec99722f487f3 --- /dev/null +++ b/packages/core/src/browser/icons/replace-inverse.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + diff --git a/packages/core/src/browser/icons/replace.svg b/packages/core/src/browser/icons/replace.svg new file mode 100644 index 0000000000000..82b26f264bfa4 --- /dev/null +++ b/packages/core/src/browser/icons/replace.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + diff --git a/packages/core/src/browser/icons/whole-word-dark.svg b/packages/core/src/browser/icons/whole-word-dark.svg new file mode 100644 index 0000000000000..ae27a3eb68b61 --- /dev/null +++ b/packages/core/src/browser/icons/whole-word-dark.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + diff --git a/packages/core/src/browser/icons/whole-word.svg b/packages/core/src/browser/icons/whole-word.svg new file mode 100644 index 0000000000000..10878ec28a6bb --- /dev/null +++ b/packages/core/src/browser/icons/whole-word.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + diff --git a/packages/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts index 201fdbb5afd37..c2db6248dbdf8 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -1094,6 +1094,13 @@ export class ApplicationShell extends Widget { return toArray(this.bottomPanel.tabBars()); } + /** + * The tab bars contained in all shell areas. + */ + get allTabBars(): TabBar[] { + return [...this.mainAreaTabBars, ...this.bottomAreaTabBars, this.leftPanelHandler.tabBar, this.rightPanelHandler.tabBar]; + } + /* * Activate the next tab in the current tab bar. */ diff --git a/packages/core/src/browser/style/variables-bright.useable.css b/packages/core/src/browser/style/variables-bright.useable.css index 738e46a243482..0dcfdfb39d4e1 100644 --- a/packages/core/src/browser/style/variables-bright.useable.css +++ b/packages/core/src/browser/style/variables-bright.useable.css @@ -142,6 +142,12 @@ is not optimized for dense, information rich UIs. --theia-removed-color0: rgba(230, 0, 0, 0.8); --theia-modified-color0: rgba(0, 100, 150, 0.8); + --theia-search-match-color0: rgba(234, 92, 0, 0.33); + --theia-search-match-color1: rgba(234, 92, 0, 0.5); + --theia-current-search-match-color: rgb(168, 172, 148, 0.7); + --theia-range-highlight: rgba(253, 255, 0, 0.2); + --theia-match-replace-color: rgba(155, 185, 85, 0.2); + --theia-highlight-background-color: var(--md-purple-A100); --theia-highlight-color: var(--theia-content-font-color0); @@ -150,6 +156,14 @@ is not optimized for dense, information rich UIs. --theia-sprite-y-offset: 0px; --theia-icon-circle: url(../icons/circle-bright.svg); --theia-preloader: url(../icons/spinner.gif); + --theia-icon-case-sensitive: url(../icons/case-sensitive.svg); + --theia-icon-regex: url(../icons/regex.svg); + --theia-icon-whole-word: url(../icons/whole-word.svg); + --theia-icon-refresh: url(../icons/Refresh.svg); + --theia-icon-collapse-all: url(../icons/CollapseAll.svg); + --theia-icon-clear: url(../icons/clear-search-results.svg); + --theia-icon-replace: url(../icons/replace.svg); + --theia-icon-replace-all: url(../icons/replace-all.svg); /* Scrollbars */ --theia-scrollbar-width: 6px; diff --git a/packages/core/src/browser/style/variables-dark.useable.css b/packages/core/src/browser/style/variables-dark.useable.css index 210c33ccc3225..ef969ecf9285e 100644 --- a/packages/core/src/browser/style/variables-dark.useable.css +++ b/packages/core/src/browser/style/variables-dark.useable.css @@ -142,6 +142,12 @@ is not optimized for dense, information rich UIs. --theia-removed-color0: rgba(230, 0, 0, 0.8); --theia-modified-color0: rgba(0, 100, 150, 0.8); + --theia-search-match-color0: rgba(234, 92, 0, 0.33); + --theia-search-match-color1: rgba(234, 92, 0, 0.5); + --theia-current-search-match-color: rgba(81,92,106, 0.7); + --theia-range-highlight: rgba(255, 255, 255, 0.04); + --theia-match-replace-color: rgba(155, 185, 85, 0.2); + --theia-highlight-background-color: var(--md-purple-A400); --theia-highlight-color: var(--theia-content-font-color0); @@ -150,6 +156,14 @@ is not optimized for dense, information rich UIs. --theia-sprite-y-offset: -20px; --theia-icon-circle: url(../icons/circle-dark.svg); --theia-preloader: url(../icons/spinner.gif); + --theia-icon-case-sensitive: url(../icons/case-sensitive-dark.svg); + --theia-icon-regex: url(../icons/regex-dark.svg); + --theia-icon-whole-word: url(../icons/whole-word-dark.svg); + --theia-icon-refresh: url(../icons/Refresh_inverse.svg); + --theia-icon-collapse-all: url(../icons/CollapseAll_inverse.svg); + --theia-icon-clear: url(../icons/clear-search-results-dark.svg); + --theia-icon-replace: url(../icons/replace-inverse.svg); + --theia-icon-replace-all: url(../icons/replace-all-inverse.svg); /* Scrollbars */ --theia-scrollbar-width: 6px; diff --git a/packages/editor/src/browser/editor.ts b/packages/editor/src/browser/editor.ts index d991551d5abbe..b8073c94c2bbf 100644 --- a/packages/editor/src/browser/editor.ts +++ b/packages/editor/src/browser/editor.ts @@ -81,6 +81,12 @@ export interface TextEditor extends Disposable, TextEditorSelection, Navigatable deltaDecorations(params: DeltaDecorationParams): string[]; getVisibleColumn(position: Position): number; + + /** + * Replaces the text of source given in ReplacetextParams. + * @param params: ReplaceTextParams + */ + replaceText(params: ReplaceTextParams): Promise; } export interface Dimension { @@ -115,6 +121,28 @@ export interface DeltaDecorationParams { newDecorations: EditorDecoration[]; } +export interface ReplaceTextParams { + /** + * the source to edit + */ + source: string; + /** + * the replace operations + */ + replaceOperations: ReplaceOperation[]; +} + +export interface ReplaceOperation { + /** + * the position that shall be replaced + */ + range: Range; + /** + * the text to replace with + */ + text: string; +} + export namespace TextEditorSelection { // tslint:disable-next-line:no-any export function is(e: any): e is TextEditorSelection { diff --git a/packages/monaco/src/browser/monaco-editor.ts b/packages/monaco/src/browser/monaco-editor.ts index b3d89e159223b..42ee6ab8f035b 100644 --- a/packages/monaco/src/browser/monaco-editor.ts +++ b/packages/monaco/src/browser/monaco-editor.ts @@ -22,6 +22,7 @@ import { RevealPositionOptions, EditorDecorationsService, DeltaDecorationParams, + ReplaceTextParams, } from '@theia/editor/lib/browser'; import { MonacoEditorModel } from "./monaco-editor-model"; @@ -29,6 +30,7 @@ import IEditorConstructionOptions = monaco.editor.IEditorConstructionOptions; import IModelDeltaDecoration = monaco.editor.IModelDeltaDecoration; import IEditorOverrideServices = monaco.editor.IEditorOverrideServices; import IStandaloneCodeEditor = monaco.editor.IStandaloneCodeEditor; +import IIdentifiedSingleEditOperation = monaco.editor.IIdentifiedSingleEditOperation; import IBoxSizing = ElementExt.IBoxSizing; import IEditorReference = monaco.editor.IEditorReference; import SuggestController = monaco.suggestController.SuggestController; @@ -336,6 +338,34 @@ export class MonacoEditor implements TextEditor, IEditorReference { lineNumber: position.line + 1 }); } + + async replaceText(params: ReplaceTextParams): Promise { + const edits: IIdentifiedSingleEditOperation[] = params.replaceOperations.map(param => { + const startPos = param.range.start; + const endPos = param.range.end; + const range = this.p2m.asRange({ + start: { + line: startPos.line - 1, + character: startPos.character - 1 + }, + end: { + line: endPos.line - 1, + character: endPos.character - 1 + } + }); + return { + forceMoveMarkers: true, + identifier: { + major: range.startLineNumber, + minor: range.startColumn + }, + range, + text: param.text + }; + }); + return this.editor.executeEdits(params.source, edits); + } + } export namespace MonacoEditor { diff --git a/packages/search-in-workspace/src/browser/in-memory-text-resource.ts b/packages/search-in-workspace/src/browser/in-memory-text-resource.ts new file mode 100644 index 0000000000000..7f519979cbbe2 --- /dev/null +++ b/packages/search-in-workspace/src/browser/in-memory-text-resource.ts @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2018 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +import { injectable } from "inversify"; +import { ResourceResolver, Resource } from "@theia/core"; +import URI from "@theia/core/lib/common/uri"; + +export const MEMORY_TEXT = "mem-txt"; + +export class InMemoryTextResource implements Resource { + + constructor(readonly uri: URI) { } + + async readContents(options?: { encoding?: string | undefined; } | undefined): Promise { + return this.uri.query; + } + + dispose(): void { } +} + +@injectable() +export class InMemoryTextResourceResolver implements ResourceResolver { + resolve(uri: URI): Resource | Promise { + if (uri.scheme !== MEMORY_TEXT) { + throw new Error(`Expected a URI with ${MEMORY_TEXT} scheme. Was: ${uri}.`); + } + return new InMemoryTextResource(uri); + } +} diff --git a/packages/search-in-workspace/src/browser/quick-search-in-workspace.ts b/packages/search-in-workspace/src/browser/quick-search-in-workspace.ts index 57407d6101963..c7d4125066349 100644 --- a/packages/search-in-workspace/src/browser/quick-search-in-workspace.ts +++ b/packages/search-in-workspace/src/browser/quick-search-in-workspace.ts @@ -23,13 +23,11 @@ export class QuickSearchInWorkspace implements QuickOpenModel { private currentSearchId: number = -1; protected MAX_RESULTS = 100; - constructor( - @inject(QuickOpenService) protected readonly quickOpenService: QuickOpenService, - @inject(SearchInWorkspaceService) protected readonly searchInWorkspaceService: SearchInWorkspaceService, - @inject(OpenerService) protected readonly openerService: OpenerService, - @inject(LabelProvider) protected readonly labelProvider: LabelProvider, - @inject(ILogger) protected readonly logger: ILogger, - ) { } + @inject(QuickOpenService) protected readonly quickOpenService: QuickOpenService; + @inject(SearchInWorkspaceService) protected readonly searchInWorkspaceService: SearchInWorkspaceService; + @inject(OpenerService) protected readonly openerService: OpenerService; + @inject(LabelProvider) protected readonly labelProvider: LabelProvider; + @inject(ILogger) protected readonly logger: ILogger; isEnabled(): boolean { return this.searchInWorkspaceService.isEnabled(); diff --git a/packages/search-in-workspace/src/browser/search-in-workspace-frontend-contribution.ts b/packages/search-in-workspace/src/browser/search-in-workspace-frontend-contribution.ts new file mode 100644 index 0000000000000..085a958ded68f --- /dev/null +++ b/packages/search-in-workspace/src/browser/search-in-workspace-frontend-contribution.ts @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2018 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +import { AbstractViewContribution } from "@theia/core/lib/browser"; +import { SearchInWorkspaceWidget } from "./search-in-workspace-widget"; +import { injectable } from "inversify"; + +export namespace SearchInWorkspaceCommands { + export const OPEN_SIW_WIDGET = { + id: "search-in-workspace.open" + }; +} + +@injectable() +export class SearchInWorkspaceFrontendContribution extends AbstractViewContribution { + + constructor() { + super({ + widgetId: SearchInWorkspaceWidget.ID, + widgetName: SearchInWorkspaceWidget.LABEL, + defaultWidgetOptions: { + area: "left" + }, + toggleCommandId: SearchInWorkspaceCommands.OPEN_SIW_WIDGET.id, + toggleKeybinding: "ctrlcmd+shift+f" + }); + } +} diff --git a/packages/search-in-workspace/src/browser/search-in-workspace-frontend-module.ts b/packages/search-in-workspace/src/browser/search-in-workspace-frontend-module.ts index c770202854c3b..a1973a392500e 100644 --- a/packages/search-in-workspace/src/browser/search-in-workspace-frontend-module.ts +++ b/packages/search-in-workspace/src/browser/search-in-workspace-frontend-module.ts @@ -5,14 +5,32 @@ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 */ -import { ContainerModule } from "inversify"; +import { ContainerModule, interfaces } from "inversify"; import { SearchInWorkspaceService, SearchInWorkspaceClientImpl } from './search-in-workspace-service'; import { SearchInWorkspaceServer } from '../common/search-in-workspace-interface'; -import { WebSocketConnectionProvider, KeybindingContribution } from '@theia/core/lib/browser'; +import { WebSocketConnectionProvider, KeybindingContribution, WidgetFactory, createTreeContainer, TreeWidget } from '@theia/core/lib/browser'; import { QuickSearchInWorkspace, SearchInWorkspaceContributions } from './quick-search-in-workspace'; -import { CommandContribution, MenuContribution } from "@theia/core"; +import { CommandContribution, MenuContribution, ResourceResolver } from "@theia/core"; +import { SearchInWorkspaceWidget } from "./search-in-workspace-widget"; +import { SearchInWorkspaceResultTreeWidget } from "./search-in-workspace-result-tree-widget"; +import { SearchInWorkspaceFrontendContribution } from "./search-in-workspace-frontend-contribution"; +import { InMemoryTextResourceResolver } from "./in-memory-text-resource"; + +import "../../src/browser/styles/index.css"; export default new ContainerModule(bind => { + bind(SearchInWorkspaceWidget).toSelf(); + bind(WidgetFactory).toDynamicValue(ctx => ({ + id: SearchInWorkspaceWidget.ID, + createWidget: () => ctx.container.get(SearchInWorkspaceWidget) + })); + bind(SearchInWorkspaceResultTreeWidget).toDynamicValue(ctx => createSearchTreeWidget(ctx.container)); + + bind(SearchInWorkspaceFrontendContribution).toSelf().inSingletonScope(); + for (const identifier of [CommandContribution, MenuContribution, KeybindingContribution]) { + bind(identifier).toService(SearchInWorkspaceFrontendContribution); + } + bind(QuickSearchInWorkspace).toSelf().inSingletonScope(); bind(CommandContribution).to(SearchInWorkspaceContributions).inSingletonScope(); @@ -29,4 +47,16 @@ export default new ContainerModule(bind => { const client = ctx.container.get(SearchInWorkspaceClientImpl); return WebSocketConnectionProvider.createProxy(ctx.container, '/search-in-workspace', client); }).inSingletonScope(); + + bind(InMemoryTextResourceResolver).toSelf().inSingletonScope(); + bind(ResourceResolver).toService(InMemoryTextResourceResolver); }); + +export function createSearchTreeWidget(parent: interfaces.Container): SearchInWorkspaceResultTreeWidget { + const child = createTreeContainer(parent); + + child.unbind(TreeWidget); + child.bind(SearchInWorkspaceResultTreeWidget).toSelf(); + + return child.get(SearchInWorkspaceResultTreeWidget); +} diff --git a/packages/search-in-workspace/src/browser/search-in-workspace-result-tree-widget.ts b/packages/search-in-workspace/src/browser/search-in-workspace-result-tree-widget.ts new file mode 100644 index 0000000000000..82994c2f52f59 --- /dev/null +++ b/packages/search-in-workspace/src/browser/search-in-workspace-result-tree-widget.ts @@ -0,0 +1,466 @@ +/* + * Copyright (C) 2018 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +import { + TreeWidget, + ContextMenuRenderer, + CompositeTreeNode, + ExpandableTreeNode, + SelectableTreeNode, + TreeModel, + TreeNode, + NodeProps, + LabelProvider, + TreeExpansionService, + ApplicationShell, + DiffUris +} from "@theia/core/lib/browser"; +import { SearchInWorkspaceResult, SearchInWorkspaceOptions } from "../common/search-in-workspace-interface"; +import { SearchInWorkspaceService } from "./search-in-workspace-service"; +import { TreeProps } from "@theia/core/lib/browser"; +import { EditorManager, EditorDecoration, TrackedRangeStickiness, OverviewRulerLane, EditorWidget, ReplaceOperation } from "@theia/editor/lib/browser"; +import { inject, injectable, postConstruct } from "inversify"; +import URI from "@theia/core/lib/common/uri"; +import { Path, CancellationTokenSource, Emitter, Event } from "@theia/core"; +import { WorkspaceService } from "@theia/workspace/lib/browser"; +import { h } from "@phosphor/virtualdom"; +import { MEMORY_TEXT } from "./in-memory-text-resource"; +import { FileResourceResolver } from "@theia/filesystem/lib/browser"; + +export interface SearchInWorkspaceResultNode extends ExpandableTreeNode, SelectableTreeNode { + children: SearchInWorkspaceResultLineNode[]; + path: string; + file: string; +} +export namespace SearchInWorkspaceResultNode { + export function is(node: any): node is SearchInWorkspaceResultNode { + return ExpandableTreeNode.is(node) && SelectableTreeNode.is(node) && "path" in node; + } +} + +export type SearchInWorkspaceResultLineNode = SelectableTreeNode & SearchInWorkspaceResult; +export namespace SearchInWorkspaceResultLineNode { + export function is(node: any): node is SearchInWorkspaceResultLineNode { + return SelectableTreeNode.is(node) && "line" in node && "character" in node && "lineText" in node; + } +} + +@injectable() +export class SearchInWorkspaceResultTreeWidget extends TreeWidget { + + protected resultTree: Map; + protected workspaceRoot: string = ""; + + protected _showReplaceButtons = false; + protected _replaceTerm = ""; + protected searchTerm = ""; + + protected appliedDecorations = new Map(); + + private cancelIndicator = new CancellationTokenSource(); + + protected changeEmitter: Emitter>; + + @inject(SearchInWorkspaceService) protected readonly searchService: SearchInWorkspaceService; + @inject(EditorManager) protected readonly editorManager: EditorManager; + @inject(FileResourceResolver) protected readonly fileResourceResolver: FileResourceResolver; + @inject(ApplicationShell) protected readonly shell: ApplicationShell; + @inject(LabelProvider) protected readonly labelProvider: LabelProvider; + @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; + @inject(TreeExpansionService) protected readonly expansionService: TreeExpansionService; + + constructor( + @inject(TreeProps) readonly props: TreeProps, + @inject(TreeModel) readonly model: TreeModel, + @inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer + ) { + super(props, model, contextMenuRenderer); + + model.root = { + id: "ResultTree", + name: "ResultTree", + parent: undefined, + visible: false, + children: [] + }; + + model.onSelectionChanged(nodes => { + const node = nodes[0]; + if (SearchInWorkspaceResultLineNode.is(node)) { + this.doOpen(node, true); + } + }); + + model.onNodeRefreshed(() => this.changeEmitter.fire(this.resultTree)); + } + + @postConstruct() + protected init() { + this.addClass("resultContainer"); + + this.workspaceService.root.then(rootFileStat => { + if (rootFileStat) { + const uri = new URI(rootFileStat.uri); + this.workspaceRoot = uri.withoutScheme().toString(); + } + }); + + this.changeEmitter = new Emitter(); + + this.editorManager.onActiveEditorChanged(() => { + this.updateCurrentEditorDecorations(); + }); + } + + set showReplaceButtons(srb: boolean) { + this._showReplaceButtons = srb; + this.update(); + } + + set replaceTerm(rt: string) { + this._replaceTerm = rt; + this.update(); + } + + get onChange(): Event> { + return this.changeEmitter.event; + } + + collapseAll() { + this.resultTree.forEach(v => this.expansionService.collapseNode(v)); + } + + async search(searchTerm: string, searchOptions: SearchInWorkspaceOptions): Promise { + this.searchTerm = searchTerm; + this.resultTree = new Map(); + if (searchTerm === "") { + this.refreshModelChildren(); + return; + } + this.cancelIndicator.cancel(); + this.cancelIndicator = new CancellationTokenSource(); + const token = this.cancelIndicator.token; + const searchId = await this.searchService.search(searchTerm, { + onResult: async (searchId: number, result: SearchInWorkspaceResult) => { + if (token.isCancellationRequested) { + return; + } + const { name, path } = this.filenameAndPath(result.file); + const resultElement = this.resultTree.get(result.file); + + if (resultElement) { + const resultLine = this.createResultLineNode(result, resultElement); + resultElement.children.push(resultLine); + } else { + const children: SearchInWorkspaceResultLineNode[] = []; + const icon = await this.labelProvider.getIcon(new URI(result.file)); + if (CompositeTreeNode.is(this.model.root)) { + const resultElement: SearchInWorkspaceResultNode = { + selected: false, + name, + path, + children, + expanded: true, + id: path + "-" + name, + parent: this.model.root, + icon, + file: result.file + }; + resultElement.children.push(this.createResultLineNode(result, resultElement)); + this.resultTree.set(result.file, resultElement); + } + } + }, + onDone: () => { + if (token.isCancellationRequested) { + return; + } + this.refreshModelChildren(); + } + }, searchOptions); + token.onCancellationRequested(() => this.searchService.cancel(searchId)); + } + + protected refreshModelChildren() { + if (CompositeTreeNode.is(this.model.root)) { + this.model.root.children = Array.from(this.resultTree.values()); + this.model.refresh(); + this.updateCurrentEditorDecorations(); + } + } + + protected updateCurrentEditorDecorations() { + this.shell.allTabBars.map(tb => { + const currentTitle = tb.currentTitle; + if (currentTitle && currentTitle.owner instanceof EditorWidget) { + const widget = currentTitle.owner; + const result = this.resultTree.get(widget.editor.uri.withoutScheme().toString()); + if (result) { + this.decorateEditor(result, widget); + } + } + }); + + const widget = this.editorManager.currentEditor; + if (widget) { + const result = this.resultTree.get(widget.editor.uri.withoutScheme().toString()); + if (result) { + this.decorateEditor(result, widget); + } + } + } + + protected createResultLineNode(result: SearchInWorkspaceResult, resultNode: SearchInWorkspaceResultNode): SearchInWorkspaceResultLineNode { + return { + ...result, + selected: false, + id: result.file + "-" + result.line + "-" + result.character + "-" + result.length, + name: result.lineText, + parent: resultNode + }; + } + + protected filenameAndPath(uriStr: string): { name: string, path: string } { + const uri: URI = new URI(uriStr); + const name = uri.displayName; + const path = new Path(uri.toString().substr(this.workspaceRoot.length + 1)).dir.toString(); + return { name, path }; + } + + protected renderCaption(node: TreeNode, props: NodeProps): h.Child { + if (SearchInWorkspaceResultNode.is(node)) { + return this.renderResultNode(node); + } else if (SearchInWorkspaceResultLineNode.is(node)) { + return this.renderResultLineNode(node); + } + return ""; + } + + protected renderTailDecorations(node: TreeNode, props: NodeProps): h.Child[] { + const btns: h.Child[] = []; + if (this._showReplaceButtons) { + btns.push(this.renderReplaceButton(node)); + } + btns.push(this.renderRemoveButton(node)); + return [h.div({ className: "result-node-buttons" }, ...btns)]; + } + + protected renderReplaceButton(node: TreeNode): h.Child { + return h.span({ + className: "replace-result", onclick: async e => { + this.replaceResult(node); + this.removeNode(node); + e.stopPropagation(); + } + }); + } + + replaceAll(): void { + this.resultTree.forEach(async resultNode => { + await this.replaceResult(resultNode); + }); + this.resultTree.clear(); + this.refreshModelChildren(); + } + + protected updateRightResults(node: SearchInWorkspaceResultLineNode) { + const result = this.resultTree.get(node.file); + if (result) { + const rightPositionedNodes = result.children.filter(rl => rl.line === node.line && rl.character > node.character); + const diff = this._replaceTerm.length - this.searchTerm.length; + rightPositionedNodes.map(r => r.character += diff); + } + } + + protected async replaceResult(node: TreeNode) { + const toReplace: SearchInWorkspaceResultLineNode[] = []; + if (SearchInWorkspaceResultNode.is(node)) { + toReplace.push(...node.children); + } else if (SearchInWorkspaceResultLineNode.is(node)) { + toReplace.push(node); + this.updateRightResults(node); + } + + if (toReplace.length > 0) { + const widget = await this.doOpen(toReplace[0]); + const source = widget.editor.document.getText(); + const replaceOperations = toReplace.map(resultLineNode => { + text: this._replaceTerm, + range: { + start: { + line: resultLineNode.line, + character: resultLineNode.character + }, + end: { + line: resultLineNode.line, + character: resultLineNode.character + resultLineNode.length + } + } + }); + await widget.editor.replaceText({ + source, + replaceOperations + }); + } + } + + protected renderRemoveButton(node: TreeNode): h.Child { + return h.span({ + className: "remove-node", onclick: e => { + this.removeNode(node); + e.stopPropagation(); + } + }); + } + + protected removeNode(node: TreeNode) { + if (SearchInWorkspaceResultNode.is(node)) { + this.resultTree.delete(node.file); + } else if (SearchInWorkspaceResultLineNode.is(node)) { + const result = this.resultTree.get(node.file); + if (result) { + const index = result.children.findIndex(n => n.file === node.file && n.line === node.line && n.character === node.character); + if (index > -1) { + result.children.splice(index, 1); + if (result.children.length === index) { + const keyArr = Array.from(this.resultTree.keys()); + const currentResultIndex = keyArr.findIndex(k => result.file === k); + if (currentResultIndex + 1 <= keyArr.length) { + const newNode = this.resultTree.get(keyArr[currentResultIndex + 1]); + if (newNode) { + this.model.selectNode(newNode); + } + } + } else { + this.model.selectNode(result.children[index]); + } + if (result.children.length === 0) { + this.resultTree.delete(result.file); + } + } + } + } + this.refreshModelChildren(); + } + + protected renderResultNode(node: SearchInWorkspaceResultNode): h.Child { + const icon = node.icon; + const fileIcon = h.span({ className: `file-icon ${icon || ""}` }); + const fileName = h.span({ className: "file-name" }, node.name); + const filePath = h.span({ className: "file-path" }, node.path); + const resultNumber = h.span({ className: "result-number" }, node.children.length.toString()); + const resultHeadInfo = h.div( + { className: `result-head-info noWrapInfo noselect ${node.selected ? 'selected' : ''}` }, + fileIcon, fileName, filePath); + const resultHead = h.div({ className: "result-head" }, resultHeadInfo, resultNumber); + return h.div({ className: "result" }, resultHead); + } + + protected renderResultLineNode(node: SearchInWorkspaceResultLineNode): h.Child { + const start = h.span(node.lineText.substr(0, node.character - 1)); + const match = this.renderMatchLinePart(node); + const end = h.span(node.lineText.substr(node.character - 1 + node.length)); + return h.div( + { + className: `resultLine noWrapInfo ${node.selected ? 'selected' : ''}`, + onclick: () => this.model.selectNode(node) + }, start, ...match, end); + } + + protected renderMatchLinePart(node: SearchInWorkspaceResultLineNode): h.Child[] { + const replaceTerm = this._replaceTerm !== "" && this._showReplaceButtons ? h.span({ className: "replace-term" }, this._replaceTerm) : ""; + const className = `match${this._showReplaceButtons ? " strike-through" : ""}`; + return [h.span({ className }, node.lineText.substr(node.character - 1, node.length)), replaceTerm]; + } + + protected async doOpen(node: SearchInWorkspaceResultLineNode, preview: boolean = false): Promise { + let fileUri: URI; + const resultNode = this.resultTree.get(node.file); + if (resultNode && this._showReplaceButtons && preview) { + const leftUri = new URI(node.file).withScheme("file"); + const rightUri = await this.createReplacePreview(resultNode); + fileUri = DiffUris.encode(leftUri, rightUri); + } else { + fileUri = new URI(node.file).withScheme("file"); + } + const editorWidget = await this.editorManager.open(fileUri, { + selection: { + start: { + line: node.line - 1, + character: node.character - 1 + }, + end: { + line: node.line - 1, + character: node.character - 1 + node.length + } + }, + mode: "reveal" + }); + + if (resultNode) { + this.decorateEditor(resultNode, editorWidget); + } + + return editorWidget; + } + + protected async createReplacePreview(node: SearchInWorkspaceResultNode): Promise { + const fileUri = new URI(node.file).withScheme("file"); + const uri = fileUri.withoutScheme().toString(); + const resource = await this.fileResourceResolver.resolve(fileUri); + const content = await resource.readContents(); + + const lines = content.split("\n"); + node.children.map(l => { + const leftPositionedNodes = node.children.filter(rl => rl.line === l.line && rl.character < l.character); + const diff = (this._replaceTerm.length - this.searchTerm.length) * leftPositionedNodes.length; + const start = lines[l.line - 1].substr(0, l.character - 1 + diff); + const end = lines[l.line - 1].substr(l.character - 1 + diff + l.length); + lines[l.line - 1] = start + this._replaceTerm + end; + }); + + return new URI(uri).withScheme(MEMORY_TEXT).withQuery(lines.join("\n")); + } + + protected decorateEditor(node: SearchInWorkspaceResultNode | undefined, editorWidget: EditorWidget) { + const key = `${node.file}#search-in-workspace-matches`; + const oldDecorations = this.appliedDecorations.get(key) || []; + const appliedDecorations = editorWidget.editor.deltaDecorations({ + newDecorations: this.createEditorDecorations(node), + oldDecorations, + uri: node.file + }); + this.appliedDecorations.set(key, appliedDecorations); + } + + protected createEditorDecorations(resultNode: SearchInWorkspaceResultNode): EditorDecoration[] { + const decorations: EditorDecoration[] = []; + resultNode.children.map(res => { + decorations.push({ + range: { + start: { + line: res.line - 1, + character: res.character - 1 + }, + end: { + line: res.line - 1, + character: res.character - 1 + res.length + } + }, + options: { + overviewRuler: { + color: "rgba(230, 0, 0, 1)", + position: OverviewRulerLane.Full + }, + className: res.selected ? "current-search-in-workspace-editor-match" : "search-in-workspace-editor-match", + stickiness: TrackedRangeStickiness.GrowsOnlyWhenTypingBefore + } + }); + }); + return decorations; + } +} diff --git a/packages/search-in-workspace/src/browser/search-in-workspace-service.ts b/packages/search-in-workspace/src/browser/search-in-workspace-service.ts index d6d6d20fac612..0ce9f06a2b494 100644 --- a/packages/search-in-workspace/src/browser/search-in-workspace-service.ts +++ b/packages/search-in-workspace/src/browser/search-in-workspace-service.ts @@ -5,7 +5,7 @@ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 */ -import { injectable, inject } from "inversify"; +import { injectable, inject, postConstruct } from "inversify"; import { SearchInWorkspaceServer, SearchInWorkspaceClient, SearchInWorkspaceResult, SearchInWorkspaceOptions } from "../common/search-in-workspace-interface"; import { WorkspaceService } from "@theia/workspace/lib/browser"; import URI from "@theia/core/lib/common/uri"; @@ -56,13 +56,14 @@ export class SearchInWorkspaceService implements SearchInWorkspaceClient { private lastKnownSearchId: number = -1; - constructor( - @inject(SearchInWorkspaceServer) protected readonly searchServer: SearchInWorkspaceServer, - @inject(SearchInWorkspaceClientImpl) protected readonly client: SearchInWorkspaceClientImpl, - @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService, - @inject(ILogger) protected readonly logger: ILogger, - ) { - client.setService(this); + @inject(SearchInWorkspaceServer) protected readonly searchServer: SearchInWorkspaceServer; + @inject(SearchInWorkspaceClientImpl) protected readonly client: SearchInWorkspaceClientImpl; + @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; + @inject(ILogger) protected readonly logger: ILogger; + + @postConstruct() + protected init() { + this.client.setService(this); } isEnabled(): boolean { diff --git a/packages/search-in-workspace/src/browser/search-in-workspace-widget.ts b/packages/search-in-workspace/src/browser/search-in-workspace-widget.ts new file mode 100644 index 0000000000000..aeafb58b0de98 --- /dev/null +++ b/packages/search-in-workspace/src/browser/search-in-workspace-widget.ts @@ -0,0 +1,278 @@ +/* + * Copyright (C) 2018 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +import { Widget, Message, BaseWidget, VirtualRenderer, Key } from "@theia/core/lib/browser"; +import { inject, injectable, postConstruct } from "inversify"; +import { SearchInWorkspaceResultTreeWidget } from "./search-in-workspace-result-tree-widget"; +import { h } from "@phosphor/virtualdom"; +import { SearchInWorkspaceOptions } from "../common/search-in-workspace-interface"; + +export interface SearchFieldState { + className: string; + enabled: boolean; + title: string; +} + +@injectable() +export class SearchInWorkspaceWidget extends BaseWidget { + static ID = "search-in-workspace"; + static LABEL = "Search"; + + protected matchCaseState: SearchFieldState; + protected wholeWordState: SearchFieldState; + protected regExpState: SearchFieldState; + + protected showSearchDetails = false; + protected hasResults = false; + + protected searchInWorkspaceOptions: SearchInWorkspaceOptions; + + protected searchTerm = ""; + protected replaceTerm = ""; + + protected showReplaceField = false; + + protected contentNode: HTMLElement; + protected searchFormContainer: HTMLElement; + protected resultContainer: HTMLElement; + + @inject(SearchInWorkspaceResultTreeWidget) protected readonly resultTreeWidget: SearchInWorkspaceResultTreeWidget; + + @postConstruct() + init() { + this.id = SearchInWorkspaceWidget.ID; + this.title.label = SearchInWorkspaceWidget.LABEL; + + this.contentNode = document.createElement('div'); + this.contentNode.classList.add("searchContainer"); + this.searchFormContainer = document.createElement('div'); + this.searchFormContainer.classList.add("searchHeader"); + this.contentNode.appendChild(this.searchFormContainer); + this.node.appendChild(this.contentNode); + + this.matchCaseState = { + className: "match-case", + enabled: false, + title: "Match Case" + }; + this.wholeWordState = { + className: "whole-word", + enabled: false, + title: "Match Whole Word" + }; + this.regExpState = { + className: "use-regexp", + enabled: false, + title: "Use Regular Expression" + }; + this.searchInWorkspaceOptions = { + matchCase: false, + matchWholeWord: false, + useRegExp: false, + include: "", + exclude: "", + maxResults: 500 + }; + this.resultTreeWidget.onChange(r => { + this.hasResults = r.size > 0; + this.update(); + }); + } + + onAfterAttach(msg: Message) { + super.onAfterAttach(msg); + VirtualRenderer.render(this.renderSearchHeader(), this.searchFormContainer); + Widget.attach(this.resultTreeWidget, this.contentNode); + } + + onUpdateRequest(msg: Message) { + super.onUpdateRequest(msg); + VirtualRenderer.render(this.renderSearchHeader(), this.searchFormContainer); + // we have to be sure that the value attribute is set programmatically. At least for clearing the search field. + // Since setAttribute doesn't work for the value (as phosphor renderer does) we have to do it directly in DOM. + const f = document.getElementById("search-input-field"); + if (f) { + (f as HTMLInputElement).value = this.searchTerm; + } + } + + protected renderSearchHeader(): h.Child { + const controlButtons = this.renderControlButtons(); + const searchAndReplaceContainer = this.renderSearchAndReplace(); + const searchDetails = this.renderSearchDetails(); + return h.div(controlButtons, searchAndReplaceContainer, searchDetails); + } + + protected refresh = () => { + this.resultTreeWidget.search(this.searchTerm, this.searchInWorkspaceOptions); + this.update(); + } + + protected collapseAll = () => { + this.resultTreeWidget.collapseAll(); + this.update(); + } + + protected clear = () => { + this.searchTerm = ""; + this.resultTreeWidget.search(this.searchTerm, this.searchInWorkspaceOptions); + this.update(); + } + + protected renderControlButtons(): h.Child { + const refreshButton = this.renderControlButton(`refresh${this.hasResults || this.searchTerm !== "" ? " enabled" : ""}`, this.refresh); + const collapseAllButton = this.renderControlButton(`collapse-all${this.hasResults ? " enabled" : ""}`, this.collapseAll); + const clearButton = this.renderControlButton(`clear${this.hasResults ? " enabled" : ""}`, this.clear); + return h.div({ className: "controls button-container" }, refreshButton, collapseAllButton, clearButton); + } + + protected renderControlButton(btnClass: string, clickHandler: () => void): h.Child { + return h.span({ className: `btn ${btnClass}`, onclick: clickHandler }); + } + + protected renderSearchAndReplace(): h.Child { + const toggleContainer = this.renderReplaceFieldToggle(); + const searchField = this.renderSearchField(); + const replaceField = this.renderReplaceField(); + const searchAndReplaceFields = h.div({ className: "search-and-replace-fields" }, searchField, replaceField); + return h.div({ className: "search-and-replace-container" }, toggleContainer, searchAndReplaceFields); + } + + protected renderReplaceFieldToggle(): h.Child { + const toggle = h.span({ className: `fa fa-caret-${this.showReplaceField ? "down" : "right"}` }); + return h.div({ + className: "replace-toggle", + onclick: () => { + this.showReplaceField = !this.showReplaceField; + this.resultTreeWidget.showReplaceButtons = this.showReplaceField; + this.update(); + } + }, toggle); + } + + protected renderSearchField(): h.Child { + const input = h.input({ + id: "search-input-field", + type: "text", + placeholder: "Search", + value: this.searchTerm, + onkeyup: e => { + if (e.target) { + if (Key.ENTER.keyCode === e.keyCode) { + this.resultTreeWidget.search((e.target as HTMLInputElement).value, (this.searchInWorkspaceOptions || {})); + this.update(); + } else { + this.searchTerm = (e.target as HTMLInputElement).value; + } + } + } + }); + const optionContainer = this.renderOptionContainer(); + return h.div({ className: "search-field" }, input, optionContainer); + } + + protected renderReplaceField(): h.Child { + const input = h.input({ + id: "replace-input-field", + type: "text", + placeholder: "Replace", + onkeyup: e => { + if (e.target) { + if (Key.ENTER.keyCode === e.keyCode) { + this.resultTreeWidget.search(this.searchTerm, (this.searchInWorkspaceOptions || {})); + this.update(); + } else { + this.replaceTerm = (e.target as HTMLInputElement).value; + this.resultTreeWidget.replaceTerm = this.replaceTerm; + } + } + } + }); + const replaceAllButtonContainer = this.renderReplaceAllButtonContainer(); + return h.div({ className: `replace-field${this.showReplaceField ? "" : " hidden"}` }, input, replaceAllButtonContainer); + } + + protected renderReplaceAllButtonContainer(): h.Child { + const replaceButton = h.span({ + className: `replace-all-button${this.searchTerm === "" ? " disabled" : ""}`, + onclick: () => { + this.resultTreeWidget.replaceAll(); + } + }); + return h.div({ + className: "replace-all-button-container" + }, replaceButton); + } + + protected renderOptionContainer(): h.Child { + const matchCaseOption = this.renderOptionElement(this.matchCaseState); + const wholeWordOption = this.renderOptionElement(this.wholeWordState); + const regexOption = this.renderOptionElement(this.regExpState); + return h.div({ className: "option-buttons" }, matchCaseOption, wholeWordOption, regexOption); + } + + protected renderOptionElement(opt: SearchFieldState): h.Child { + return h.span({ + className: `${opt.className} option ${opt.enabled ? "enabled" : ""}`, + title: opt.title, + onclick: () => this.handleOptionClick(opt) + }); + } + + protected handleOptionClick(option: SearchFieldState): void { + option.enabled = !option.enabled; + this.updateSearchOptions(); + this.resultTreeWidget.search(this.searchTerm, this.searchInWorkspaceOptions); + this.update(); + } + + protected updateSearchOptions() { + this.searchInWorkspaceOptions.matchCase = this.matchCaseState.enabled; + this.searchInWorkspaceOptions.matchWholeWord = this.wholeWordState.enabled; + this.searchInWorkspaceOptions.useRegExp = this.regExpState.enabled; + } + + protected renderSearchDetails(): h.Child { + const expandButton = this.renderExpandGlobFieldsButton(); + const globFieldContainer = this.renderGlobFieldContainer(); + return h.div({ className: "search-details" }, expandButton, globFieldContainer); + } + + protected renderGlobFieldContainer(): h.Child { + const includeField = this.renderGlobField("include"); + const excludeField = this.renderGlobField("exclude"); + return h.div({ className: `glob-field-container${!this.showSearchDetails ? " hidden" : ""}` }, includeField, excludeField); + } + + protected renderExpandGlobFieldsButton(): h.Child { + const button = h.span({ className: "fa fa-ellipsis-h btn" }); + return h.div({ + className: "button-container", onclick: () => { + this.showSearchDetails = !this.showSearchDetails; + this.update(); + } + }, button); + } + + protected renderGlobField(kind: "include" | "exclude"): h.Child { + const label = h.div({ className: "label" }, "files to " + kind); + const input = h.input({ + type: "text", + value: "", + onkeyup: e => { + if (e.target) { + if (Key.ENTER.keyCode === e.keyCode) { + this.resultTreeWidget.search(this.searchTerm, this.searchInWorkspaceOptions); + } else { + this.searchInWorkspaceOptions[kind] = (e.target as HTMLInputElement).value; + } + } + } + }); + return h.div({ className: "glob-field" }, label, input); + } +} diff --git a/packages/search-in-workspace/src/browser/styles/index.css b/packages/search-in-workspace/src/browser/styles/index.css new file mode 100644 index 0000000000000..ce8ff3a1e50d8 --- /dev/null +++ b/packages/search-in-workspace/src/browser/styles/index.css @@ -0,0 +1,315 @@ +.searchContainer { + color: var(--theia-ui-font-color1); + padding: 5px; + display: flex; + flex-direction: column; + height: 100%; + box-sizing: border-box; +} + +.searchContainer input[type="text"] { + flex: 1; + line-height: var(--theia-content-line-height); + font-size: var(--theia-ui-font-size1); + padding-left: 8px; + border-width: 1px; + border-style: solid; + border-color: var(--theia-border-color1); + background: var(--theia-layout-color0); + color: var(--theia-ui-font-color1); +} + +.searchContainer input[type="text"]:focus { + outline: none; +} + +.searchContainer .searchHeader { + width: 100%; + margin-bottom: 10px; +} + +.searchContainer .searchHeader .controls.button-container { + height:30px; + margin-bottom: 5px; +} + +.searchContainer .searchHeader .controls .refresh { + background: var(--theia-icon-refresh); +} + +.searchContainer .searchHeader .controls .collapse-all { + background: var(--theia-icon-collapse-all); +} + +.searchContainer .searchHeader .controls .clear { + background: var(--theia-icon-clear); +} + +.searchContainer .searchHeader .search-field { + display: flex; + align-items: center; +} + +.searchContainer .searchHeader .search-field .option { + cursor: pointer; + opacity: 0.7; +} + +.searchContainer .searchHeader .search-field .option.enabled { + border: var(--theia-border-width) var(--theia-accent-color3) solid; +} + +.searchContainer .searchHeader .search-field .option:hover { + opacity: 1; +} + +.searchContainer .searchHeader .search-field .option.match-case { + background-image: var(--theia-icon-case-sensitive); +} + +.searchContainer .searchHeader .search-field .option.whole-word { + background-image: var(--theia-icon-whole-word); +} + +.searchContainer .searchHeader .search-field .option.use-regexp { + background-image: var(--theia-icon-regex); +} + +.searchContainer .searchHeader .search-field .option-buttons { + background: var(--theia-layout-color0); + height: 23px; + display: flex; + align-items: center; +} + +.searchContainer .searchHeader .search-field:focus { + outline: none; + border: var(--theia-border-width) var(--theia-accent-color3) solid; +} + +.searchContainer .searchHeader .button-container { + text-align: right; + padding-right: 5px; + padding-top: 5px; + display: flex; + justify-content: flex-end; +} + +.searchContainer .searchHeader .search-field .option, +.searchContainer .searchHeader .button-container .btn { + width: 21px; + height: 21px; + margin: 0 1px; + display: inline-block; + box-sizing: border-box; + align-items: center; + user-select: none; + background-size: 100%; + background-repeat: no-repeat; + background-position: center; + border: var(--theia-border-width) solid transparent; +} + +.searchContainer .searchHeader .controls .btn{ + margin-left: 10px; + opacity: 0.3; +} + +.searchContainer .searchHeader .controls .btn.enabled{ + margin-left: 10px; + opacity: 1; +} + +.searchContainer .searchHeader .search-details .button-container { + height: 0px; +} + + +.searchContainer .searchHeader .search-details .button-container .btn{ + cursor: pointer; +} + +.searchContainer .searchHeader .glob-field-container.hidden { + display: none; +} + +.searchContainer .searchHeader .glob-field-container .glob-field { + margin-bottom: 8px; + display: flex; + flex-direction: column; +} + +.searchContainer .searchHeader .glob-field-container .glob-field .label { + margin-bottom: 3px; + user-select: none; + font-size: var(--theia-ui-font-size0); +} + +.searchContainer .result { + overflow: hidden; + width: 100%; +} + +.searchContainer .result .result-head { + display:flex; +} + +.searchContainer .result .result-head .result-no { + background: var(--theia-ui-expand-button-color); + padding: 3px 8px; + border-radius: 7px; + font-size: var(--theia-ui-font-size0); +} + +.searchContainer .result .result-head .expand-icon { + margin: 0 3px; + width: 7px; +} + +.searchContainer .result .result-head .file-icon { + margin: 0 3px; +} + +.searchContainer .result .result-head .file-name { + margin-right: 5px; +} + +.searchContainer .result .result-head .file-path { + color: var(--theia-ui-font-color2); + font-size: var(--theia-ui-font-size0); + margin-left: 3px; +} + +.searchContainer .resultLine .match { + background: var(--theia-search-match-color1); +} + +.searchContainer .resultLine .match.strike-through { + text-decoration: line-through; +} + +.searchContainer .resultLine.selected .match { + background: var(--theia-search-match-color1); +} +.searchContainer .resultLine .replace-term { + background: var(--theia-match-replace-color); +} + +.noWrapInfo { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; +} + +.result-head-info { + align-items: center; +} + +.search-in-workspace-editor-match { + background: var(--theia-search-match-color0); +} + +.current-search-in-workspace-editor-match { + background: var(--theia-current-search-match-color) +} + +.current-match-range-highlight { + background: var(--theia-range-highlight); +} + +.result-node-buttons { + display: none; +} + +.theia-TreeNode:hover .result-node-buttons { + display: flex; + justify-content: flex-end; + flex: 1; + align-items: center; + align-self: center; +} + +.theia-TreeNode:hover .result-number { + display: none; +} + +.theia-TreeNode .result-number { + background-color: var(--theia-ui-font-color2); + border-radius: 10px; + padding: 0 5px; + text-align: center; + font-size: calc(var(--theia-ui-font-size0) * 0.8); + color: var(--theia-ui-icon-font-color); + font-weight: 400; + min-width: 7px; + height: 16px; + line-height: calc(var(--theia-private-horizontal-tab-height) * 0.8); + align-self: center; +} + +.result-node-buttons .remove-node { + background-image: var(--theia-icon-close); +} + +.result-node-buttons > span { + width: 15px; + height: 15px; + margin-right: 3px; + background-repeat: no-repeat; + background-position: center; + background-size: contain; +} + +.search-and-replace-container { + display: flex; +} + +.replace-toggle { + display: flex; + align-items: center; + width: 15px; + justify-content: center; + margin-right: 2px; +} + +.replace-toggle:hover { + background: var(--theia-layout-color0); +} + +.search-and-replace-fields { + display: flex; + flex-direction: column; + flex: 1; +} + +.replace-field { + display: flex; + margin-top: 5px; +} + +.replace-field.hidden { + display: none; +} + +.replace-all-button-container { + width: 25px; + display: flex; + align-items: center; + justify-content: center; +} + +.replace-all-button { + width: 100%; + height: 100%; + display: inline-block; + background: var(--theia-icon-replace-all) no-repeat center; +} + +.result-node-buttons .replace-result { + background-image: var(--theia-icon-replace); +} + +.replace-all-button.disabled { + opacity: 0.5; +} \ No newline at end of file diff --git a/packages/search-in-workspace/src/common/search-in-workspace-interface.ts b/packages/search-in-workspace/src/common/search-in-workspace-interface.ts index 87fc47afef64a..5cc5264590da1 100644 --- a/packages/search-in-workspace/src/common/search-in-workspace-interface.ts +++ b/packages/search-in-workspace/src/common/search-in-workspace-interface.ts @@ -12,6 +12,26 @@ export interface SearchInWorkspaceOptions { * Maximum number of results to return. Defaults to unlimited. */ maxResults?: number; + /** + * Search case sensitively if true. + */ + matchCase?: boolean; + /** + * Search whole words only if true. + */ + matchWholeWord?: boolean; + /** + * Use regular expressions for search if true. + */ + useRegExp?: boolean; + /** + * Glob pattern for matching files and directories to include the search. + */ + include?: string; + /** + * Glob pattern for matching files and directories to exclude the search. + */ + exclude?: string } export interface SearchInWorkspaceResult { diff --git a/packages/search-in-workspace/src/node/ripgrep-search-in-workspace-server.slow-spec.ts b/packages/search-in-workspace/src/node/ripgrep-search-in-workspace-server.slow-spec.ts index c97238b0d04ab..6ef2573fa3b45 100644 --- a/packages/search-in-workspace/src/node/ripgrep-search-in-workspace-server.slow-spec.ts +++ b/packages/search-in-workspace/src/node/ripgrep-search-in-workspace-server.slow-spec.ts @@ -71,7 +71,7 @@ it's very confusing. `); createTestFile('regexes', `\ -aaa hello x h3lo y hell0h3lllo +aaa hello. x h3lo y hell0h3lllo hello1 `); @@ -177,7 +177,7 @@ describe('ripgrep-search-in-workspace-server', function () { { file: 'carrots', line: 3, character: 28, length: pattern.length, lineText: '' }, { file: 'carrots', line: 3, character: 52, length: pattern.length, lineText: '' }, { file: 'carrots', line: 4, character: 1, length: pattern.length, lineText: '' }, - { file: 'potatoes', line: 1, character: 18, length: pattern.length, lineText: '' }, + { file: 'potatoes', line: 1, character: 18, length: pattern.length, lineText: '' } ]; compareSearchResults(expected, client.results); @@ -187,6 +187,66 @@ describe('ripgrep-search-in-workspace-server', function () { ripgrepServer.search(pattern, rootDir); }); + it('returns 5 results when searching for "carrot" case sensitive', function (done) { + const pattern = 'carrot'; + + const client = new ResultAccumulator(() => { + const expected: SearchInWorkspaceResult[] = [ + { file: 'carrots', line: 1, character: 11, length: pattern.length, lineText: '' }, + { file: 'carrots', line: 2, character: 6, length: pattern.length, lineText: '' }, + { file: 'carrots', line: 2, character: 35, length: pattern.length, lineText: '' }, + { file: 'carrots', line: 3, character: 28, length: pattern.length, lineText: '' }, + { file: 'potatoes', line: 1, character: 18, length: pattern.length, lineText: '' } + ]; + + compareSearchResults(expected, client.results); + done(); + }); + ripgrepServer.setClient(client); + ripgrepServer.search(pattern, rootDir, { + matchCase: true + }); + }); + + it('returns 4 results when searching for "carrot" matching whole words, case insensitive', function (done) { + const pattern = 'carrot'; + + const client = new ResultAccumulator(() => { + const expected: SearchInWorkspaceResult[] = [ + { file: 'carrots', line: 1, character: 11, length: pattern.length, lineText: '' }, + { file: 'carrots', line: 3, character: 28, length: pattern.length, lineText: '' }, + { file: 'carrots', line: 3, character: 52, length: pattern.length, lineText: '' }, + { file: 'carrots', line: 4, character: 1, length: pattern.length, lineText: '' } + ]; + + compareSearchResults(expected, client.results); + done(); + }); + ripgrepServer.setClient(client); + ripgrepServer.search(pattern, rootDir, { + matchWholeWord: true + }); + }); + + it('returns 4 results when searching for "carrot" matching whole words, case sensitive', function (done) { + const pattern = 'carrot'; + + const client = new ResultAccumulator(() => { + const expected: SearchInWorkspaceResult[] = [ + { file: 'carrots', line: 1, character: 11, length: pattern.length, lineText: '' }, + { file: 'carrots', line: 3, character: 28, length: pattern.length, lineText: '' } + ]; + + compareSearchResults(expected, client.results); + done(); + }); + ripgrepServer.setClient(client); + ripgrepServer.search(pattern, rootDir, { + matchWholeWord: true, + matchCase: true + }); + }); + it('returns 1 result when searching for "Carrot"', function (done) { const client = new ResultAccumulator(() => { const expected: SearchInWorkspaceResult[] = [ @@ -197,7 +257,7 @@ describe('ripgrep-search-in-workspace-server', function () { done(); }); ripgrepServer.setClient(client); - ripgrepServer.search('Carrot', rootDir); + ripgrepServer.search('Carrot', rootDir, { matchCase: true }); }); it('returns 0 result when searching for "CarroT"', function (done) { @@ -208,7 +268,7 @@ describe('ripgrep-search-in-workspace-server', function () { done(); }); ripgrepServer.setClient(client); - ripgrepServer.search(pattern, rootDir); + ripgrepServer.search(pattern, rootDir, { matchCase: true }); }); // Try something that we know isn't there. @@ -301,9 +361,9 @@ describe('ripgrep-search-in-workspace-server', function () { const client = new ResultAccumulator(() => { const expected: SearchInWorkspaceResult[] = [ { file: 'regexes', line: 1, character: 5, length: 5, lineText: '' }, - { file: 'regexes', line: 1, character: 13, length: 4, lineText: '' }, - { file: 'regexes', line: 1, character: 20, length: 5, lineText: '' }, - { file: 'regexes', line: 1, character: 25, length: 6, lineText: '' }, + { file: 'regexes', line: 1, character: 14, length: 4, lineText: '' }, + { file: 'regexes', line: 1, character: 21, length: 5, lineText: '' }, + { file: 'regexes', line: 1, character: 26, length: 6, lineText: '' }, { file: 'regexes', line: 2, character: 1, length: 5, lineText: '' }, ]; @@ -311,7 +371,27 @@ describe('ripgrep-search-in-workspace-server', function () { done(); }); ripgrepServer.setClient(client); - ripgrepServer.search(pattern, rootDir); + ripgrepServer.search(pattern, rootDir, { + useRegExp: true + }); + }); + + // Try without regex + it('searches for fixed string', function (done) { + const pattern = 'hello.'; + + const client = new ResultAccumulator(() => { + const expected: SearchInWorkspaceResult[] = [ + { file: 'regexes', line: 1, character: 5, length: 6, lineText: '' } + ]; + + compareSearchResults(expected, client.results); + done(); + }); + ripgrepServer.setClient(client); + ripgrepServer.search(pattern, rootDir, { + useRegExp: false + }); }); // Try with a pattern starting with -, and in filenames containing colons and spaces. @@ -333,7 +413,7 @@ describe('ripgrep-search-in-workspace-server', function () { done(); }); ripgrepServer.setClient(client); - ripgrepServer.search(pattern, rootDir); + ripgrepServer.search(pattern, rootDir, { useRegExp: true }); }); // Try with a pattern starting with --, and in filenames containing colons and spaces. @@ -355,7 +435,7 @@ describe('ripgrep-search-in-workspace-server', function () { done(); }); ripgrepServer.setClient(client); - ripgrepServer.search(pattern, rootDir); + ripgrepServer.search(pattern, rootDir, { useRegExp: true }); }); // Try searching in an UTF-8 file. @@ -390,7 +470,7 @@ describe('ripgrep-search-in-workspace-server', function () { done(); }); ripgrepServer.setClient(client); - ripgrepServer.search(pattern, rootDir); + ripgrepServer.search(pattern, rootDir, { useRegExp: true }); }); // A regex that may match an empty string should not return zero-length diff --git a/packages/search-in-workspace/src/node/ripgrep-search-in-workspace-server.ts b/packages/search-in-workspace/src/node/ripgrep-search-in-workspace-server.ts index 7dae101e05cf1..db43c5f55a7ce 100644 --- a/packages/search-in-workspace/src/node/ripgrep-search-in-workspace-server.ts +++ b/packages/search-in-workspace/src/node/ripgrep-search-in-workspace-server.ts @@ -44,24 +44,46 @@ export class RipgrepSearchInWorkspaceServer implements SearchInWorkspaceServer { this.client = client; } + protected getArgs(options?: SearchInWorkspaceOptions): string[] { + const args = ["--vimgrep", "--color=always", + "--colors=path:none", + "--colors=line:none", + "--colors=column:none", + "--colors=match:none", + "--colors=path:fg:red", + "--colors=line:fg:green", + "--colors=column:fg:yellow", + "--colors=match:fg:blue", + "--sort-files"]; + args.push(options && options.matchCase ? "--case-sensitive" : "--ignore-case"); + if (options && options.matchWholeWord) { + args.push("--word-regexp"); + } + args.push(options && options.useRegExp ? "--regexp" : "--fixed-strings"); + + return args; + } + // Search for the string WHAT in directory ROOT. Return the assigned search id. search(what: string, root: string, opts?: SearchInWorkspaceOptions): Promise { // Start the rg process. Use --vimgrep to get one result per // line, --color=always to get color control characters that // we'll use to parse the lines. const searchId = this.nextSearchId++; + const args = this.getArgs(opts); + const globs = []; + if (opts && opts.include) { + globs.push("--glob='" + opts.include + "'"); + } + if (opts && opts.exclude) { + globs.push("--glob='!" + opts.exclude + "'"); + } const processOptions: RawProcessOptions = { command: rgPath, - args: ["--vimgrep", "-S", "--color=always", - "--colors=path:none", - "--colors=line:none", - "--colors=column:none", - "--colors=match:none", - "--colors=path:fg:red", - "--colors=line:fg:green", - "--colors=column:fg:yellow", - "--colors=match:fg:blue", - "-e", what, root], + args: [...args, what, ...globs, root], + options: { + shell: true + } }; const process: RawProcess = this.rawProcessFactory(processOptions); this.ongoingSearches.set(searchId, process);