Path: blob/main/src/vs/editor/contrib/find/browser/findModel.ts
5270 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { findFirstIdxMonotonousOrArrLen } from '../../../../base/common/arraysFind.js';6import { RunOnceScheduler, TimeoutTimer } from '../../../../base/common/async.js';7import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js';8import { DisposableStore, dispose } from '../../../../base/common/lifecycle.js';9import { Constants } from '../../../../base/common/uint.js';10import { IActiveCodeEditor } from '../../../browser/editorBrowser.js';11import { ReplaceCommand, ReplaceCommandThatPreservesSelection } from '../../../common/commands/replaceCommand.js';12import { EditorOption } from '../../../common/config/editorOptions.js';13import { CursorChangeReason, ICursorPositionChangedEvent } from '../../../common/cursorEvents.js';14import { Position } from '../../../common/core/position.js';15import { Range } from '../../../common/core/range.js';16import { Selection } from '../../../common/core/selection.js';17import { ICommand, ScrollType } from '../../../common/editorCommon.js';18import { EndOfLinePreference, FindMatch, ITextModel } from '../../../common/model.js';19import { SearchParams } from '../../../common/model/textModelSearch.js';20import { FindDecorations } from './findDecorations.js';21import { FindReplaceState, FindReplaceStateChangedEvent } from './findState.js';22import { ReplaceAllCommand } from './replaceAllCommand.js';23import { parseReplaceString, ReplacePattern } from './replacePattern.js';24import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';25import { IKeybindings } from '../../../../platform/keybinding/common/keybindingsRegistry.js';2627export const CONTEXT_FIND_WIDGET_VISIBLE = new RawContextKey<boolean>('findWidgetVisible', false);28export const CONTEXT_FIND_WIDGET_NOT_VISIBLE = CONTEXT_FIND_WIDGET_VISIBLE.toNegated();29// Keep ContextKey use of 'Focussed' to not break when clauses30export const CONTEXT_FIND_INPUT_FOCUSED = new RawContextKey<boolean>('findInputFocussed', false);31export const CONTEXT_REPLACE_INPUT_FOCUSED = new RawContextKey<boolean>('replaceInputFocussed', false);32/**33* Context key that is true when any element within the Find widget has focus.34* This includes the Find input, Replace input, checkboxes, buttons, etc.35*/36export const CONTEXT_FIND_WIDGET_FOCUSED = new RawContextKey<boolean>('findWidgetFocused', false);3738export const ToggleCaseSensitiveKeybinding: IKeybindings = {39primary: KeyMod.Alt | KeyCode.KeyC,40mac: { primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyC }41};42export const ToggleWholeWordKeybinding: IKeybindings = {43primary: KeyMod.Alt | KeyCode.KeyW,44mac: { primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyW }45};46export const ToggleRegexKeybinding: IKeybindings = {47primary: KeyMod.Alt | KeyCode.KeyR,48mac: { primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyR }49};50export const ToggleSearchScopeKeybinding: IKeybindings = {51primary: KeyMod.Alt | KeyCode.KeyL,52mac: { primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyL }53};54export const TogglePreserveCaseKeybinding: IKeybindings = {55primary: KeyMod.Alt | KeyCode.KeyP,56mac: { primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyP }57};5859export const FIND_IDS = {60StartFindAction: 'actions.find',61StartFindWithSelection: 'actions.findWithSelection',62StartFindWithArgs: 'editor.actions.findWithArgs',63NextMatchFindAction: 'editor.action.nextMatchFindAction',64PreviousMatchFindAction: 'editor.action.previousMatchFindAction',65GoToMatchFindAction: 'editor.action.goToMatchFindAction',66NextSelectionMatchFindAction: 'editor.action.nextSelectionMatchFindAction',67PreviousSelectionMatchFindAction: 'editor.action.previousSelectionMatchFindAction',68StartFindReplaceAction: 'editor.action.startFindReplaceAction',69CloseFindWidgetCommand: 'closeFindWidget',70ToggleCaseSensitiveCommand: 'toggleFindCaseSensitive',71ToggleWholeWordCommand: 'toggleFindWholeWord',72ToggleRegexCommand: 'toggleFindRegex',73ToggleSearchScopeCommand: 'toggleFindInSelection',74TogglePreserveCaseCommand: 'togglePreserveCase',75ReplaceOneAction: 'editor.action.replaceOne',76ReplaceAllAction: 'editor.action.replaceAll',77SelectAllMatchesAction: 'editor.action.selectAllMatches'78};7980export const MATCHES_LIMIT = 19999;81const RESEARCH_DELAY = 240;8283export class FindModelBoundToEditorModel {8485private readonly _editor: IActiveCodeEditor;86private readonly _state: FindReplaceState;87private readonly _toDispose = new DisposableStore();88private readonly _decorations: FindDecorations;89private _ignoreModelContentChanged: boolean;90private readonly _startSearchingTimer: TimeoutTimer;9192private readonly _updateDecorationsScheduler: RunOnceScheduler;93private _isDisposed: boolean;9495constructor(editor: IActiveCodeEditor, state: FindReplaceState) {96this._editor = editor;97this._state = state;98this._isDisposed = false;99this._startSearchingTimer = new TimeoutTimer();100101this._decorations = new FindDecorations(editor);102this._toDispose.add(this._decorations);103104this._updateDecorationsScheduler = new RunOnceScheduler(() => {105if (!this._editor.hasModel()) {106return;107}108return this.research(false);109}, 100);110this._toDispose.add(this._updateDecorationsScheduler);111112this._toDispose.add(this._editor.onDidChangeCursorPosition((e: ICursorPositionChangedEvent) => {113if (114e.reason === CursorChangeReason.Explicit115|| e.reason === CursorChangeReason.Undo116|| e.reason === CursorChangeReason.Redo117) {118this._decorations.setStartPosition(this._editor.getPosition());119}120}));121122this._ignoreModelContentChanged = false;123this._toDispose.add(this._editor.onDidChangeModelContent((e) => {124if (this._ignoreModelContentChanged) {125return;126}127if (e.isFlush) {128// a model.setValue() was called129this._decorations.reset();130}131this._decorations.setStartPosition(this._editor.getPosition());132this._updateDecorationsScheduler.schedule();133}));134135this._toDispose.add(this._state.onFindReplaceStateChange((e) => this._onStateChanged(e)));136137this.research(false, this._state.searchScope);138}139140public dispose(): void {141this._isDisposed = true;142dispose(this._startSearchingTimer);143this._toDispose.dispose();144}145146private _onStateChanged(e: FindReplaceStateChangedEvent): void {147if (this._isDisposed) {148// The find model is disposed during a find state changed event149return;150}151if (!this._editor.hasModel()) {152// The find model will be disposed momentarily153return;154}155if (e.searchString || e.isReplaceRevealed || e.isRegex || e.wholeWord || e.matchCase || e.searchScope) {156const model = this._editor.getModel();157158if (model.isTooLargeForSyncing()) {159this._startSearchingTimer.cancel();160161this._startSearchingTimer.setIfNotSet(() => {162if (e.searchScope) {163this.research(e.moveCursor, this._state.searchScope);164} else {165this.research(e.moveCursor);166}167}, RESEARCH_DELAY);168} else {169if (e.searchScope) {170this.research(e.moveCursor, this._state.searchScope);171} else {172this.research(e.moveCursor);173}174}175}176}177178private static _getSearchRange(model: ITextModel, findScope: Range | null): Range {179// If we have set now or before a find scope, use it for computing the search range180if (findScope) {181return findScope;182}183184return model.getFullModelRange();185}186187private research(moveCursor: boolean, newFindScope?: Range | Range[] | null): void {188let findScopes: Range[] | null = null;189if (typeof newFindScope !== 'undefined') {190if (newFindScope !== null) {191if (!Array.isArray(newFindScope)) {192findScopes = [newFindScope];193} else {194findScopes = newFindScope;195}196}197} else {198findScopes = this._decorations.getFindScopes();199}200if (findScopes !== null) {201findScopes = findScopes.map(findScope => {202if (findScope.startLineNumber !== findScope.endLineNumber) {203let endLineNumber = findScope.endLineNumber;204205if (findScope.endColumn === 1) {206endLineNumber = endLineNumber - 1;207}208209return new Range(findScope.startLineNumber, 1, endLineNumber, this._editor.getModel().getLineMaxColumn(endLineNumber));210}211return findScope;212});213}214215const findMatches = this._findMatches(findScopes, false, MATCHES_LIMIT);216this._decorations.set(findMatches, findScopes);217218const editorSelection = this._editor.getSelection();219let currentMatchesPosition = this._decorations.getCurrentMatchesPosition(editorSelection);220if (currentMatchesPosition === 0 && findMatches.length > 0) {221// current selection is not on top of a match222// try to find its nearest result from the top of the document223const matchAfterSelection = findFirstIdxMonotonousOrArrLen(findMatches.map(match => match.range), range => Range.compareRangesUsingStarts(range, editorSelection) >= 0);224currentMatchesPosition = matchAfterSelection > 0 ? matchAfterSelection - 1 + 1 /** match position is one based */ : currentMatchesPosition;225}226227this._state.changeMatchInfo(228currentMatchesPosition,229this._decorations.getCount(),230undefined231);232233if (moveCursor && this._editor.getOption(EditorOption.find).cursorMoveOnType) {234this._moveToNextMatch(this._decorations.getStartPosition());235}236}237238private _hasMatches(): boolean {239return (this._state.matchesCount > 0);240}241242private _cannotFind(): boolean {243if (!this._hasMatches()) {244const findScope = this._decorations.getFindScope();245if (findScope) {246// Reveal the selection so user is reminded that 'selection find' is on.247this._editor.revealRangeInCenterIfOutsideViewport(findScope, ScrollType.Smooth);248}249return true;250}251return false;252}253254private _setCurrentFindMatch(match: Range): void {255const matchesPosition = this._decorations.setCurrentFindMatch(match);256this._state.changeMatchInfo(257matchesPosition,258this._decorations.getCount(),259match260);261262this._editor.setSelection(match);263this._editor.revealRangeInCenterIfOutsideViewport(match, ScrollType.Smooth);264}265266private _prevSearchPosition(before: Position) {267const isUsingLineStops = this._state.isRegex && (268this._state.searchString.indexOf('^') >= 0269|| this._state.searchString.indexOf('$') >= 0270);271let { lineNumber, column } = before;272const model = this._editor.getModel();273274if (isUsingLineStops || column === 1) {275if (lineNumber === 1) {276lineNumber = model.getLineCount();277} else {278lineNumber--;279}280column = model.getLineMaxColumn(lineNumber);281} else {282column--;283}284285return new Position(lineNumber, column);286}287288private _moveToPrevMatch(before: Position, isRecursed: boolean = false): void {289if (!this._state.canNavigateBack()) {290// we are beyond the first matched find result291// instead of doing nothing, we should refocus the first item292const nextMatchRange = this._decorations.matchAfterPosition(before);293294if (nextMatchRange) {295this._setCurrentFindMatch(nextMatchRange);296}297return;298}299if (this._decorations.getCount() < MATCHES_LIMIT) {300let prevMatchRange = this._decorations.matchBeforePosition(before);301302if (prevMatchRange && prevMatchRange.isEmpty() && prevMatchRange.getStartPosition().equals(before)) {303before = this._prevSearchPosition(before);304prevMatchRange = this._decorations.matchBeforePosition(before);305}306307if (prevMatchRange) {308this._setCurrentFindMatch(prevMatchRange);309}310311return;312}313314if (this._cannotFind()) {315return;316}317318const findScope = this._decorations.getFindScope();319const searchRange = FindModelBoundToEditorModel._getSearchRange(this._editor.getModel(), findScope);320321// ...(----)...|...322if (searchRange.getEndPosition().isBefore(before)) {323before = searchRange.getEndPosition();324}325326// ...|...(----)...327if (before.isBefore(searchRange.getStartPosition())) {328before = searchRange.getEndPosition();329}330331const { lineNumber, column } = before;332const model = this._editor.getModel();333334let position = new Position(lineNumber, column);335336let prevMatch = model.findPreviousMatch(this._state.searchString, position, this._state.isRegex, this._state.matchCase, this._state.wholeWord ? this._editor.getOption(EditorOption.wordSeparators) : null, false);337338if (prevMatch && prevMatch.range.isEmpty() && prevMatch.range.getStartPosition().equals(position)) {339// Looks like we're stuck at this position, unacceptable!340position = this._prevSearchPosition(position);341prevMatch = model.findPreviousMatch(this._state.searchString, position, this._state.isRegex, this._state.matchCase, this._state.wholeWord ? this._editor.getOption(EditorOption.wordSeparators) : null, false);342}343344if (!prevMatch) {345// there is precisely one match and selection is on top of it346return;347}348349if (!isRecursed && !searchRange.containsRange(prevMatch.range)) {350return this._moveToPrevMatch(prevMatch.range.getStartPosition(), true);351}352353this._setCurrentFindMatch(prevMatch.range);354}355356public moveToPrevMatch(): void {357this._moveToPrevMatch(this._editor.getSelection().getStartPosition());358}359360private _nextSearchPosition(after: Position) {361const isUsingLineStops = this._state.isRegex && (362this._state.searchString.indexOf('^') >= 0363|| this._state.searchString.indexOf('$') >= 0364);365366let { lineNumber, column } = after;367const model = this._editor.getModel();368369if (isUsingLineStops || column === model.getLineMaxColumn(lineNumber)) {370if (lineNumber === model.getLineCount()) {371lineNumber = 1;372} else {373lineNumber++;374}375column = 1;376} else {377column++;378}379380return new Position(lineNumber, column);381}382383private _moveToNextMatch(after: Position): void {384if (!this._state.canNavigateForward()) {385// we are beyond the last matched find result386// instead of doing nothing, we should refocus the last item387const prevMatchRange = this._decorations.matchBeforePosition(after);388389if (prevMatchRange) {390this._setCurrentFindMatch(prevMatchRange);391}392return;393}394if (this._decorations.getCount() < MATCHES_LIMIT) {395let nextMatchRange = this._decorations.matchAfterPosition(after);396397if (nextMatchRange && nextMatchRange.isEmpty() && nextMatchRange.getStartPosition().equals(after)) {398// Looks like we're stuck at this position, unacceptable!399after = this._nextSearchPosition(after);400nextMatchRange = this._decorations.matchAfterPosition(after);401}402if (nextMatchRange) {403this._setCurrentFindMatch(nextMatchRange);404}405406return;407}408409const nextMatch = this._getNextMatch(after, false, true);410if (nextMatch) {411this._setCurrentFindMatch(nextMatch.range);412}413}414415private _getNextMatch(after: Position, captureMatches: boolean, forceMove: boolean, isRecursed: boolean = false): FindMatch | null {416if (this._cannotFind()) {417return null;418}419420const findScope = this._decorations.getFindScope();421const searchRange = FindModelBoundToEditorModel._getSearchRange(this._editor.getModel(), findScope);422423// ...(----)...|...424if (searchRange.getEndPosition().isBefore(after)) {425after = searchRange.getStartPosition();426}427428// ...|...(----)...429if (after.isBefore(searchRange.getStartPosition())) {430after = searchRange.getStartPosition();431}432433const { lineNumber, column } = after;434const model = this._editor.getModel();435436let position = new Position(lineNumber, column);437438let nextMatch = model.findNextMatch(this._state.searchString, position, this._state.isRegex, this._state.matchCase, this._state.wholeWord ? this._editor.getOption(EditorOption.wordSeparators) : null, captureMatches);439440if (forceMove && nextMatch && nextMatch.range.isEmpty() && nextMatch.range.getStartPosition().equals(position)) {441// Looks like we're stuck at this position, unacceptable!442position = this._nextSearchPosition(position);443nextMatch = model.findNextMatch(this._state.searchString, position, this._state.isRegex, this._state.matchCase, this._state.wholeWord ? this._editor.getOption(EditorOption.wordSeparators) : null, captureMatches);444}445446if (!nextMatch) {447// there is precisely one match and selection is on top of it448return null;449}450451if (!isRecursed && !searchRange.containsRange(nextMatch.range)) {452return this._getNextMatch(nextMatch.range.getEndPosition(), captureMatches, forceMove, true);453}454455return nextMatch;456}457458public moveToNextMatch(): void {459this._moveToNextMatch(this._editor.getSelection().getEndPosition());460}461462private _moveToMatch(index: number): void {463const decorationRange = this._decorations.getDecorationRangeAt(index);464if (decorationRange) {465this._setCurrentFindMatch(decorationRange);466}467}468469public moveToMatch(index: number): void {470this._moveToMatch(index);471}472473private _getReplacePattern(): ReplacePattern {474if (this._state.isRegex) {475return parseReplaceString(this._state.replaceString);476}477return ReplacePattern.fromStaticValue(this._state.replaceString);478}479480public replace(): void {481if (!this._hasMatches()) {482return;483}484485const replacePattern = this._getReplacePattern();486const selection = this._editor.getSelection();487const nextMatch = this._getNextMatch(selection.getStartPosition(), true, false);488if (nextMatch) {489if (selection.equalsRange(nextMatch.range)) {490// selection sits on a find match => replace it!491const replaceString = replacePattern.buildReplaceString(nextMatch.matches, this._state.preserveCase);492493const command = new ReplaceCommand(selection, replaceString);494495this._executeEditorCommand('replace', command);496497this._decorations.setStartPosition(new Position(selection.startLineNumber, selection.startColumn + replaceString.length));498this.research(true);499} else {500this._decorations.setStartPosition(this._editor.getPosition());501this._setCurrentFindMatch(nextMatch.range);502}503}504}505506private _findMatches(findScopes: Range[] | null, captureMatches: boolean, limitResultCount: number): FindMatch[] {507const searchRanges = (findScopes as [] || [null]).map((scope: Range | null) =>508FindModelBoundToEditorModel._getSearchRange(this._editor.getModel(), scope)509);510511return this._editor.getModel().findMatches(this._state.searchString, searchRanges, this._state.isRegex, this._state.matchCase, this._state.wholeWord ? this._editor.getOption(EditorOption.wordSeparators) : null, captureMatches, limitResultCount);512}513514public replaceAll(): void {515if (!this._hasMatches()) {516return;517}518519const findScopes = this._decorations.getFindScopes();520521if (findScopes === null && this._state.matchesCount >= MATCHES_LIMIT) {522// Doing a replace on the entire file that is over ${MATCHES_LIMIT} matches523this._largeReplaceAll();524} else {525this._regularReplaceAll(findScopes);526}527528this.research(false);529}530531private _largeReplaceAll(): void {532const searchParams = new SearchParams(this._state.searchString, this._state.isRegex, this._state.matchCase, this._state.wholeWord ? this._editor.getOption(EditorOption.wordSeparators) : null);533const searchData = searchParams.parseSearchRequest();534if (!searchData) {535return;536}537538let searchRegex = searchData.regex;539if (!searchRegex.multiline) {540let mod = 'mu';541if (searchRegex.ignoreCase) {542mod += 'i';543}544if (searchRegex.global) {545mod += 'g';546}547searchRegex = new RegExp(searchRegex.source, mod);548}549550const model = this._editor.getModel();551const modelText = model.getValue(EndOfLinePreference.LF);552const fullModelRange = model.getFullModelRange();553554const replacePattern = this._getReplacePattern();555let resultText: string;556const preserveCase = this._state.preserveCase;557558if (replacePattern.hasReplacementPatterns || preserveCase) {559resultText = modelText.replace(searchRegex, function () {560// eslint-disable-next-line local/code-no-any-casts561return replacePattern.buildReplaceString(<string[]><any>arguments, preserveCase);562});563} else {564resultText = modelText.replace(searchRegex, replacePattern.buildReplaceString(null, preserveCase));565}566567const command = new ReplaceCommandThatPreservesSelection(fullModelRange, resultText, this._editor.getSelection());568this._executeEditorCommand('replaceAll', command);569}570571private _regularReplaceAll(findScopes: Range[] | null): void {572const replacePattern = this._getReplacePattern();573// Get all the ranges (even more than the highlighted ones)574const matches = this._findMatches(findScopes, replacePattern.hasReplacementPatterns || this._state.preserveCase, Constants.MAX_SAFE_SMALL_INTEGER);575576const replaceStrings: string[] = [];577for (let i = 0, len = matches.length; i < len; i++) {578replaceStrings[i] = replacePattern.buildReplaceString(matches[i].matches, this._state.preserveCase);579}580581const command = new ReplaceAllCommand(this._editor.getSelection(), matches.map(m => m.range), replaceStrings);582this._executeEditorCommand('replaceAll', command);583}584585public selectAllMatches(): void {586if (!this._hasMatches()) {587return;588}589590const findScopes = this._decorations.getFindScopes();591592// Get all the ranges (even more than the highlighted ones)593const matches = this._findMatches(findScopes, false, Constants.MAX_SAFE_SMALL_INTEGER);594let selections = matches.map(m => new Selection(m.range.startLineNumber, m.range.startColumn, m.range.endLineNumber, m.range.endColumn));595596// If one of the ranges is the editor selection, then maintain it as primary597const editorSelection = this._editor.getSelection();598for (let i = 0, len = selections.length; i < len; i++) {599const sel = selections[i];600if (sel.equalsRange(editorSelection)) {601selections = [editorSelection].concat(selections.slice(0, i)).concat(selections.slice(i + 1));602break;603}604}605606this._editor.setSelections(selections);607}608609private _executeEditorCommand(source: string, command: ICommand): void {610try {611this._ignoreModelContentChanged = true;612this._editor.pushUndoStop();613this._editor.executeCommand(source, command);614this._editor.pushUndoStop();615} finally {616this._ignoreModelContentChanged = false;617}618}619}620621622