Path: blob/main/src/vs/editor/contrib/find/browser/findModel.ts
3296 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);3233export const ToggleCaseSensitiveKeybinding: IKeybindings = {34primary: KeyMod.Alt | KeyCode.KeyC,35mac: { primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyC }36};37export const ToggleWholeWordKeybinding: IKeybindings = {38primary: KeyMod.Alt | KeyCode.KeyW,39mac: { primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyW }40};41export const ToggleRegexKeybinding: IKeybindings = {42primary: KeyMod.Alt | KeyCode.KeyR,43mac: { primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyR }44};45export const ToggleSearchScopeKeybinding: IKeybindings = {46primary: KeyMod.Alt | KeyCode.KeyL,47mac: { primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyL }48};49export const TogglePreserveCaseKeybinding: IKeybindings = {50primary: KeyMod.Alt | KeyCode.KeyP,51mac: { primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyP }52};5354export const FIND_IDS = {55StartFindAction: 'actions.find',56StartFindWithSelection: 'actions.findWithSelection',57StartFindWithArgs: 'editor.actions.findWithArgs',58NextMatchFindAction: 'editor.action.nextMatchFindAction',59PreviousMatchFindAction: 'editor.action.previousMatchFindAction',60GoToMatchFindAction: 'editor.action.goToMatchFindAction',61NextSelectionMatchFindAction: 'editor.action.nextSelectionMatchFindAction',62PreviousSelectionMatchFindAction: 'editor.action.previousSelectionMatchFindAction',63StartFindReplaceAction: 'editor.action.startFindReplaceAction',64CloseFindWidgetCommand: 'closeFindWidget',65ToggleCaseSensitiveCommand: 'toggleFindCaseSensitive',66ToggleWholeWordCommand: 'toggleFindWholeWord',67ToggleRegexCommand: 'toggleFindRegex',68ToggleSearchScopeCommand: 'toggleFindInSelection',69TogglePreserveCaseCommand: 'togglePreserveCase',70ReplaceOneAction: 'editor.action.replaceOne',71ReplaceAllAction: 'editor.action.replaceAll',72SelectAllMatchesAction: 'editor.action.selectAllMatches'73};7475export const MATCHES_LIMIT = 19999;76const RESEARCH_DELAY = 240;7778export class FindModelBoundToEditorModel {7980private readonly _editor: IActiveCodeEditor;81private readonly _state: FindReplaceState;82private readonly _toDispose = new DisposableStore();83private readonly _decorations: FindDecorations;84private _ignoreModelContentChanged: boolean;85private readonly _startSearchingTimer: TimeoutTimer;8687private readonly _updateDecorationsScheduler: RunOnceScheduler;88private _isDisposed: boolean;8990constructor(editor: IActiveCodeEditor, state: FindReplaceState) {91this._editor = editor;92this._state = state;93this._isDisposed = false;94this._startSearchingTimer = new TimeoutTimer();9596this._decorations = new FindDecorations(editor);97this._toDispose.add(this._decorations);9899this._updateDecorationsScheduler = new RunOnceScheduler(() => {100if (!this._editor.hasModel()) {101return;102}103return this.research(false);104}, 100);105this._toDispose.add(this._updateDecorationsScheduler);106107this._toDispose.add(this._editor.onDidChangeCursorPosition((e: ICursorPositionChangedEvent) => {108if (109e.reason === CursorChangeReason.Explicit110|| e.reason === CursorChangeReason.Undo111|| e.reason === CursorChangeReason.Redo112) {113this._decorations.setStartPosition(this._editor.getPosition());114}115}));116117this._ignoreModelContentChanged = false;118this._toDispose.add(this._editor.onDidChangeModelContent((e) => {119if (this._ignoreModelContentChanged) {120return;121}122if (e.isFlush) {123// a model.setValue() was called124this._decorations.reset();125}126this._decorations.setStartPosition(this._editor.getPosition());127this._updateDecorationsScheduler.schedule();128}));129130this._toDispose.add(this._state.onFindReplaceStateChange((e) => this._onStateChanged(e)));131132this.research(false, this._state.searchScope);133}134135public dispose(): void {136this._isDisposed = true;137dispose(this._startSearchingTimer);138this._toDispose.dispose();139}140141private _onStateChanged(e: FindReplaceStateChangedEvent): void {142if (this._isDisposed) {143// The find model is disposed during a find state changed event144return;145}146if (!this._editor.hasModel()) {147// The find model will be disposed momentarily148return;149}150if (e.searchString || e.isReplaceRevealed || e.isRegex || e.wholeWord || e.matchCase || e.searchScope) {151const model = this._editor.getModel();152153if (model.isTooLargeForSyncing()) {154this._startSearchingTimer.cancel();155156this._startSearchingTimer.setIfNotSet(() => {157if (e.searchScope) {158this.research(e.moveCursor, this._state.searchScope);159} else {160this.research(e.moveCursor);161}162}, RESEARCH_DELAY);163} else {164if (e.searchScope) {165this.research(e.moveCursor, this._state.searchScope);166} else {167this.research(e.moveCursor);168}169}170}171}172173private static _getSearchRange(model: ITextModel, findScope: Range | null): Range {174// If we have set now or before a find scope, use it for computing the search range175if (findScope) {176return findScope;177}178179return model.getFullModelRange();180}181182private research(moveCursor: boolean, newFindScope?: Range | Range[] | null): void {183let findScopes: Range[] | null = null;184if (typeof newFindScope !== 'undefined') {185if (newFindScope !== null) {186if (!Array.isArray(newFindScope)) {187findScopes = [newFindScope as Range];188} else {189findScopes = newFindScope;190}191}192} else {193findScopes = this._decorations.getFindScopes();194}195if (findScopes !== null) {196findScopes = findScopes.map(findScope => {197if (findScope.startLineNumber !== findScope.endLineNumber) {198let endLineNumber = findScope.endLineNumber;199200if (findScope.endColumn === 1) {201endLineNumber = endLineNumber - 1;202}203204return new Range(findScope.startLineNumber, 1, endLineNumber, this._editor.getModel().getLineMaxColumn(endLineNumber));205}206return findScope;207});208}209210const findMatches = this._findMatches(findScopes, false, MATCHES_LIMIT);211this._decorations.set(findMatches, findScopes);212213const editorSelection = this._editor.getSelection();214let currentMatchesPosition = this._decorations.getCurrentMatchesPosition(editorSelection);215if (currentMatchesPosition === 0 && findMatches.length > 0) {216// current selection is not on top of a match217// try to find its nearest result from the top of the document218const matchAfterSelection = findFirstIdxMonotonousOrArrLen(findMatches.map(match => match.range), range => Range.compareRangesUsingStarts(range, editorSelection) >= 0);219currentMatchesPosition = matchAfterSelection > 0 ? matchAfterSelection - 1 + 1 /** match position is one based */ : currentMatchesPosition;220}221222this._state.changeMatchInfo(223currentMatchesPosition,224this._decorations.getCount(),225undefined226);227228if (moveCursor && this._editor.getOption(EditorOption.find).cursorMoveOnType) {229this._moveToNextMatch(this._decorations.getStartPosition());230}231}232233private _hasMatches(): boolean {234return (this._state.matchesCount > 0);235}236237private _cannotFind(): boolean {238if (!this._hasMatches()) {239const findScope = this._decorations.getFindScope();240if (findScope) {241// Reveal the selection so user is reminded that 'selection find' is on.242this._editor.revealRangeInCenterIfOutsideViewport(findScope, ScrollType.Smooth);243}244return true;245}246return false;247}248249private _setCurrentFindMatch(match: Range): void {250const matchesPosition = this._decorations.setCurrentFindMatch(match);251this._state.changeMatchInfo(252matchesPosition,253this._decorations.getCount(),254match255);256257this._editor.setSelection(match);258this._editor.revealRangeInCenterIfOutsideViewport(match, ScrollType.Smooth);259}260261private _prevSearchPosition(before: Position) {262const isUsingLineStops = this._state.isRegex && (263this._state.searchString.indexOf('^') >= 0264|| this._state.searchString.indexOf('$') >= 0265);266let { lineNumber, column } = before;267const model = this._editor.getModel();268269if (isUsingLineStops || column === 1) {270if (lineNumber === 1) {271lineNumber = model.getLineCount();272} else {273lineNumber--;274}275column = model.getLineMaxColumn(lineNumber);276} else {277column--;278}279280return new Position(lineNumber, column);281}282283private _moveToPrevMatch(before: Position, isRecursed: boolean = false): void {284if (!this._state.canNavigateBack()) {285// we are beyond the first matched find result286// instead of doing nothing, we should refocus the first item287const nextMatchRange = this._decorations.matchAfterPosition(before);288289if (nextMatchRange) {290this._setCurrentFindMatch(nextMatchRange);291}292return;293}294if (this._decorations.getCount() < MATCHES_LIMIT) {295let prevMatchRange = this._decorations.matchBeforePosition(before);296297if (prevMatchRange && prevMatchRange.isEmpty() && prevMatchRange.getStartPosition().equals(before)) {298before = this._prevSearchPosition(before);299prevMatchRange = this._decorations.matchBeforePosition(before);300}301302if (prevMatchRange) {303this._setCurrentFindMatch(prevMatchRange);304}305306return;307}308309if (this._cannotFind()) {310return;311}312313const findScope = this._decorations.getFindScope();314const searchRange = FindModelBoundToEditorModel._getSearchRange(this._editor.getModel(), findScope);315316// ...(----)...|...317if (searchRange.getEndPosition().isBefore(before)) {318before = searchRange.getEndPosition();319}320321// ...|...(----)...322if (before.isBefore(searchRange.getStartPosition())) {323before = searchRange.getEndPosition();324}325326const { lineNumber, column } = before;327const model = this._editor.getModel();328329let position = new Position(lineNumber, column);330331let prevMatch = model.findPreviousMatch(this._state.searchString, position, this._state.isRegex, this._state.matchCase, this._state.wholeWord ? this._editor.getOption(EditorOption.wordSeparators) : null, false);332333if (prevMatch && prevMatch.range.isEmpty() && prevMatch.range.getStartPosition().equals(position)) {334// Looks like we're stuck at this position, unacceptable!335position = this._prevSearchPosition(position);336prevMatch = model.findPreviousMatch(this._state.searchString, position, this._state.isRegex, this._state.matchCase, this._state.wholeWord ? this._editor.getOption(EditorOption.wordSeparators) : null, false);337}338339if (!prevMatch) {340// there is precisely one match and selection is on top of it341return;342}343344if (!isRecursed && !searchRange.containsRange(prevMatch.range)) {345return this._moveToPrevMatch(prevMatch.range.getStartPosition(), true);346}347348this._setCurrentFindMatch(prevMatch.range);349}350351public moveToPrevMatch(): void {352this._moveToPrevMatch(this._editor.getSelection().getStartPosition());353}354355private _nextSearchPosition(after: Position) {356const isUsingLineStops = this._state.isRegex && (357this._state.searchString.indexOf('^') >= 0358|| this._state.searchString.indexOf('$') >= 0359);360361let { lineNumber, column } = after;362const model = this._editor.getModel();363364if (isUsingLineStops || column === model.getLineMaxColumn(lineNumber)) {365if (lineNumber === model.getLineCount()) {366lineNumber = 1;367} else {368lineNumber++;369}370column = 1;371} else {372column++;373}374375return new Position(lineNumber, column);376}377378private _moveToNextMatch(after: Position): void {379if (!this._state.canNavigateForward()) {380// we are beyond the last matched find result381// instead of doing nothing, we should refocus the last item382const prevMatchRange = this._decorations.matchBeforePosition(after);383384if (prevMatchRange) {385this._setCurrentFindMatch(prevMatchRange);386}387return;388}389if (this._decorations.getCount() < MATCHES_LIMIT) {390let nextMatchRange = this._decorations.matchAfterPosition(after);391392if (nextMatchRange && nextMatchRange.isEmpty() && nextMatchRange.getStartPosition().equals(after)) {393// Looks like we're stuck at this position, unacceptable!394after = this._nextSearchPosition(after);395nextMatchRange = this._decorations.matchAfterPosition(after);396}397if (nextMatchRange) {398this._setCurrentFindMatch(nextMatchRange);399}400401return;402}403404const nextMatch = this._getNextMatch(after, false, true);405if (nextMatch) {406this._setCurrentFindMatch(nextMatch.range);407}408}409410private _getNextMatch(after: Position, captureMatches: boolean, forceMove: boolean, isRecursed: boolean = false): FindMatch | null {411if (this._cannotFind()) {412return null;413}414415const findScope = this._decorations.getFindScope();416const searchRange = FindModelBoundToEditorModel._getSearchRange(this._editor.getModel(), findScope);417418// ...(----)...|...419if (searchRange.getEndPosition().isBefore(after)) {420after = searchRange.getStartPosition();421}422423// ...|...(----)...424if (after.isBefore(searchRange.getStartPosition())) {425after = searchRange.getStartPosition();426}427428const { lineNumber, column } = after;429const model = this._editor.getModel();430431let position = new Position(lineNumber, column);432433let nextMatch = model.findNextMatch(this._state.searchString, position, this._state.isRegex, this._state.matchCase, this._state.wholeWord ? this._editor.getOption(EditorOption.wordSeparators) : null, captureMatches);434435if (forceMove && nextMatch && nextMatch.range.isEmpty() && nextMatch.range.getStartPosition().equals(position)) {436// Looks like we're stuck at this position, unacceptable!437position = this._nextSearchPosition(position);438nextMatch = model.findNextMatch(this._state.searchString, position, this._state.isRegex, this._state.matchCase, this._state.wholeWord ? this._editor.getOption(EditorOption.wordSeparators) : null, captureMatches);439}440441if (!nextMatch) {442// there is precisely one match and selection is on top of it443return null;444}445446if (!isRecursed && !searchRange.containsRange(nextMatch.range)) {447return this._getNextMatch(nextMatch.range.getEndPosition(), captureMatches, forceMove, true);448}449450return nextMatch;451}452453public moveToNextMatch(): void {454this._moveToNextMatch(this._editor.getSelection().getEndPosition());455}456457private _moveToMatch(index: number): void {458const decorationRange = this._decorations.getDecorationRangeAt(index);459if (decorationRange) {460this._setCurrentFindMatch(decorationRange);461}462}463464public moveToMatch(index: number): void {465this._moveToMatch(index);466}467468private _getReplacePattern(): ReplacePattern {469if (this._state.isRegex) {470return parseReplaceString(this._state.replaceString);471}472return ReplacePattern.fromStaticValue(this._state.replaceString);473}474475public replace(): void {476if (!this._hasMatches()) {477return;478}479480const replacePattern = this._getReplacePattern();481const selection = this._editor.getSelection();482const nextMatch = this._getNextMatch(selection.getStartPosition(), true, false);483if (nextMatch) {484if (selection.equalsRange(nextMatch.range)) {485// selection sits on a find match => replace it!486const replaceString = replacePattern.buildReplaceString(nextMatch.matches, this._state.preserveCase);487488const command = new ReplaceCommand(selection, replaceString);489490this._executeEditorCommand('replace', command);491492this._decorations.setStartPosition(new Position(selection.startLineNumber, selection.startColumn + replaceString.length));493this.research(true);494} else {495this._decorations.setStartPosition(this._editor.getPosition());496this._setCurrentFindMatch(nextMatch.range);497}498}499}500501private _findMatches(findScopes: Range[] | null, captureMatches: boolean, limitResultCount: number): FindMatch[] {502const searchRanges = (findScopes as [] || [null]).map((scope: Range | null) =>503FindModelBoundToEditorModel._getSearchRange(this._editor.getModel(), scope)504);505506return 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);507}508509public replaceAll(): void {510if (!this._hasMatches()) {511return;512}513514const findScopes = this._decorations.getFindScopes();515516if (findScopes === null && this._state.matchesCount >= MATCHES_LIMIT) {517// Doing a replace on the entire file that is over ${MATCHES_LIMIT} matches518this._largeReplaceAll();519} else {520this._regularReplaceAll(findScopes);521}522523this.research(false);524}525526private _largeReplaceAll(): void {527const searchParams = new SearchParams(this._state.searchString, this._state.isRegex, this._state.matchCase, this._state.wholeWord ? this._editor.getOption(EditorOption.wordSeparators) : null);528const searchData = searchParams.parseSearchRequest();529if (!searchData) {530return;531}532533let searchRegex = searchData.regex;534if (!searchRegex.multiline) {535let mod = 'mu';536if (searchRegex.ignoreCase) {537mod += 'i';538}539if (searchRegex.global) {540mod += 'g';541}542searchRegex = new RegExp(searchRegex.source, mod);543}544545const model = this._editor.getModel();546const modelText = model.getValue(EndOfLinePreference.LF);547const fullModelRange = model.getFullModelRange();548549const replacePattern = this._getReplacePattern();550let resultText: string;551const preserveCase = this._state.preserveCase;552553if (replacePattern.hasReplacementPatterns || preserveCase) {554resultText = modelText.replace(searchRegex, function () {555return replacePattern.buildReplaceString(<string[]><any>arguments, preserveCase);556});557} else {558resultText = modelText.replace(searchRegex, replacePattern.buildReplaceString(null, preserveCase));559}560561const command = new ReplaceCommandThatPreservesSelection(fullModelRange, resultText, this._editor.getSelection());562this._executeEditorCommand('replaceAll', command);563}564565private _regularReplaceAll(findScopes: Range[] | null): void {566const replacePattern = this._getReplacePattern();567// Get all the ranges (even more than the highlighted ones)568const matches = this._findMatches(findScopes, replacePattern.hasReplacementPatterns || this._state.preserveCase, Constants.MAX_SAFE_SMALL_INTEGER);569570const replaceStrings: string[] = [];571for (let i = 0, len = matches.length; i < len; i++) {572replaceStrings[i] = replacePattern.buildReplaceString(matches[i].matches, this._state.preserveCase);573}574575const command = new ReplaceAllCommand(this._editor.getSelection(), matches.map(m => m.range), replaceStrings);576this._executeEditorCommand('replaceAll', command);577}578579public selectAllMatches(): void {580if (!this._hasMatches()) {581return;582}583584const findScopes = this._decorations.getFindScopes();585586// Get all the ranges (even more than the highlighted ones)587const matches = this._findMatches(findScopes, false, Constants.MAX_SAFE_SMALL_INTEGER);588let selections = matches.map(m => new Selection(m.range.startLineNumber, m.range.startColumn, m.range.endLineNumber, m.range.endColumn));589590// If one of the ranges is the editor selection, then maintain it as primary591const editorSelection = this._editor.getSelection();592for (let i = 0, len = selections.length; i < len; i++) {593const sel = selections[i];594if (sel.equalsRange(editorSelection)) {595selections = [editorSelection].concat(selections.slice(0, i)).concat(selections.slice(i + 1));596break;597}598}599600this._editor.setSelections(selections);601}602603private _executeEditorCommand(source: string, command: ICommand): void {604try {605this._ignoreModelContentChanged = true;606this._editor.pushUndoStop();607this._editor.executeCommand(source, command);608this._editor.pushUndoStop();609} finally {610this._ignoreModelContentChanged = false;611}612}613}614615616