diff --git a/apps/remix-ide-e2e/src/tests/search.test.ts b/apps/remix-ide-e2e/src/tests/search.test.ts index 19fe83da3c..f0cc787924 100644 --- a/apps/remix-ide-e2e/src/tests/search.test.ts +++ b/apps/remix-ide-e2e/src/tests/search.test.ts @@ -4,7 +4,7 @@ import { NightwatchBrowser } from 'nightwatch' import init from '../helpers/init' module.exports = { - + '@disabled': true, before: function (browser: NightwatchBrowser, done: VoidFunction) { init(browser, done, 'http://127.0.0.1:8080', true) }, @@ -67,6 +67,8 @@ module.exports = { }, 'Should replace text': function (browser: NightwatchBrowser) { browser + .waitForElementVisible('*[data-id="toggle_replace"]').click('*[data-id="toggle_replace"]') + .waitForElementVisible('*[id="search_replace"]') .setValue('*[id="search_replace"]', 'replacing').pause(1000) .waitForElementVisible('*[data-id="contracts/2_Owner.sol-30-71"]') .moveToElement('*[data-id="contracts/2_Owner.sol-30-71"]', 10, 10) @@ -90,6 +92,119 @@ module.exports = { browser.assert.ok(content.includes('replacing2 deployer for a constructor'), 'should replace text ok') }) }, + 'Should replace all & undo': function (browser: NightwatchBrowser) { + browser + .clearValue('*[id="search_input"]') + .setValue('*[id="search_input"]', 'storage') + .clearValue('*[id="search_replace"]') + .setValue('*[id="search_replace"]', '123test').pause(1000) + .waitForElementVisible('*[data-id="replace-all-contracts/1_Storage.sol"]') + .click('*[data-id="replace-all-contracts/1_Storage.sol"]').pause(2000) + .getEditorValue((content) => { + browser.assert.ok(content.includes('contract 123test'), 'should replace text ok') + browser.assert.ok(content.includes('title 123test'), 'should replace text ok') + }) + .waitForElementVisible('*[data-id="undo-replace-contracts/1_Storage.sol"]') + .click('*[data-id="undo-replace-contracts/1_Storage.sol"]').pause(2000) + .getEditorValue((content) => { + browser.assert.ok(content.includes('contract Storage'), 'should undo text ok') + browser.assert.ok(content.includes('title Storage'), 'should undo text ok') + }) + }, + 'Should replace all & undo & switch between files': function (browser: NightwatchBrowser) { + browser.waitForElementVisible('*[id="search_input"]') + .clearValue('*[id="search_input"]') + .setValue('*[id="search_input"]', 'storage') + .clearValue('*[id="search_replace"]') + .setValue('*[id="search_replace"]', '123test').pause(1000) + .waitForElementVisible('*[data-id="replace-all-contracts/1_Storage.sol"]') + .click('*[data-id="replace-all-contracts/1_Storage.sol"]').pause(2000) + .getEditorValue((content) => { + browser.assert.ok(content.includes('contract 123test'), 'should replace text ok') + browser.assert.ok(content.includes('title 123test'), 'should replace text ok') + }) + .waitForElementVisible('*[data-id="undo-replace-contracts/1_Storage.sol"]') + .openFile('README.txt') + .click('*[plugin="search"]').pause(2000) + .waitForElementNotPresent('*[data-id="undo-replace-contracts/1_Storage.sol"]') + .waitForElementVisible('*[data-id="replace-all-README.txt"]') + .click('*[data-id="replace-all-README.txt"]').pause(2000) + .getEditorValue((content) => { + browser.assert.ok(content.includes("123test' contract"), 'should replace text ok') + }) + .waitForElementVisible('*[data-id="undo-replace-README.txt"]') + .click('div[title="default_workspace/contracts/1_Storage.sol"]').pause(2000) + .waitForElementVisible('*[data-id="undo-replace-contracts/1_Storage.sol"]') + .click('*[data-id="undo-replace-contracts/1_Storage.sol"]').pause(2000) + .getEditorValue((content) => { + browser.assert.ok(content.includes('contract Storage'), 'should undo text ok') + browser.assert.ok(content.includes('title Storage'), 'should undo text ok') + }) + .click('div[title="default_workspace/README.txt"]').pause(2000) + .waitForElementVisible('*[data-id="undo-replace-README.txt"]') + .click('*[data-id="undo-replace-README.txt"]').pause(2000) + .getEditorValue((content) => { + browser.assert.ok(content.includes("Storage' contract"), 'should replace text ok') + }) + }, + 'Should hide button when edited content is the same': function (browser: NightwatchBrowser) { + browser.waitForElementVisible('*[data-id="remixIdeSidePanel"]') + .addFile('test.sol', { content: '123'}) + .click('*[plugin="search"]').waitForElementVisible('*[id="search_input"]') + .clearValue('*[id="search_input"]') + .setValue('*[id="search_input"]', '123') + .clearValue('*[id="search_replace"]') + .setValue('*[id="search_replace"]', '456').pause(1000) + .waitForElementVisible('*[data-id="replace-all-test.sol"]') + .click('*[data-id="replace-all-test.sol"]').pause(2000) + .getEditorValue((content) => { + browser.assert.ok(content.includes('456'), 'should replace text ok') + } + ) + .setEditorValue('123') + .getEditorValue((content) => { + browser.assert.ok(content.includes('123'), 'should have text ok') + } + ).pause(1000) + .waitForElementNotPresent('*[data-id="undo-replace-test.sol"]') + }, + 'Should disable/enable button when edited content changed': function (browser: NightwatchBrowser) { + browser + .waitForElementVisible('*[id="search_input"]') + .clearValue('*[id="search_input"]') + .setValue('*[id="search_input"]', '123') + .clearValue('*[id="search_replace"]') + .setValue('*[id="search_replace"]', 'replaced').pause(1000) + .waitForElementVisible('*[data-id="replace-all-test.sol"]') + .click('*[data-id="replace-all-test.sol"]').pause(2000) + .getEditorValue((content) => { + browser.assert.ok(content.includes('replaced'), 'should replace text ok') + } + ) + .setEditorValue('changed') + .getEditorValue((content) => { + browser.assert.ok(content.includes('changed'), 'should have text ok') + } + ).pause(1000) + .waitForElementVisible('*[data-id="undo-replace-test.sol"]') + .getAttribute('[data-id="undo-replace-test.sol"]', 'disabled', (result) => { + browser.assert.equal(result.value, 'true', 'should be disabled') + }) + .setEditorValue('replaced') + .getEditorValue((content) => { + browser.assert.ok(content.includes('replaced'), 'should have text ok') + } + ).pause(1000) + .waitForElementVisible('*[data-id="undo-replace-test.sol"]') + .getAttribute('[data-id="undo-replace-test.sol"]', 'disabled', (result) => { + browser.assert.equal(result.value, null, 'should not be disabled') + }) + .click('*[data-id="undo-replace-test.sol"]').pause(2000) + .getEditorValue((content) => { + browser.assert.ok(content.includes('123'), 'should have text ok') + }) + .waitForElementNotPresent('*[data-id="undo-replace-test.sol"]') + }, 'Should find text with include': function (browser: NightwatchBrowser) { browser .clearValue('*[id="search_input"]') diff --git a/apps/remix-ide/src/app.js b/apps/remix-ide/src/app.js index 732ef24c4a..8fef85bf67 100644 --- a/apps/remix-ide/src/app.js +++ b/apps/remix-ide/src/app.js @@ -30,7 +30,7 @@ const isElectron = require('is-electron') const remixLib = require('@remix-project/remix-lib') import { QueryParams } from '@remix-project/remix-lib' -import { SearchPlugin } from './app/tabs/search' + const Storage = remixLib.Storage const RemixDProvider = require('./app/files/remixDProvider') const Config = require('./config') @@ -149,7 +149,7 @@ class AppComponent { const storagePlugin = new StoragePlugin() //----- search - const search = new SearchPlugin() + // const search = new SearchPlugin() // ----------------- import content service ------------------------ const contentImport = new CompilerImports() @@ -226,7 +226,6 @@ class AppComponent { storagePlugin, hardhatProvider, this.walkthroughService, - search ]) // LAYOUT & SYSTEM VIEWS @@ -337,7 +336,7 @@ class AppComponent { await this.appManager.activatePlugin(['settings', 'config']) await this.appManager.activatePlugin(['hiddenPanel', 'pluginManager', 'contextualListener', 'terminal', 'blockchain', 'fetchAndCompile', 'contentImport', 'gistHandler']) await this.appManager.activatePlugin(['settings']) - await this.appManager.activatePlugin(['walkthrough','storage', 'search']) + await this.appManager.activatePlugin(['walkthrough','storage']) this.appManager.on( 'filePanel', diff --git a/apps/remix-ide/src/app/editor/editor.js b/apps/remix-ide/src/app/editor/editor.js index 875835d8ff..247adbf391 100644 --- a/apps/remix-ide/src/app/editor/editor.js +++ b/apps/remix-ide/src/app/editor/editor.js @@ -12,7 +12,7 @@ const profile = { name: 'editor', description: 'service - editor', version: packageJson.version, - methods: ['highlight', 'discardHighlight', 'clearAnnotations', 'addAnnotation', 'gotoLine', 'getCursorPosition'] + methods: ['highlight', 'discardHighlight', 'clearAnnotations', 'addAnnotation', 'gotoLine', 'revealRange', 'getCursorPosition'] } class Editor extends Plugin { @@ -390,6 +390,20 @@ class Editor extends Plugin { this.emit('revealLine', line + 1, col) } + /** + * Reveals the range in the editor. + * @param {number} startLineNumber + * @param {number} startColumn + * @param {number} endLineNumber + * @param {number} endColumn + */ + revealRange (startLineNumber, startColumn, endLineNumber, endColumn) { + if (!this.activated) return + this.emit('focus') + console.log(startLineNumber, startColumn, endLineNumber, endColumn) + this.emit('revealRange', startLineNumber, startColumn, endLineNumber, endColumn) + } + /** * Scrolls to a line. If center is true, it puts the line in middle of screen (or attempts to). * @param {number} line The line to scroll to diff --git a/apps/remix-ide/src/app/files/dgitProvider.js b/apps/remix-ide/src/app/files/dgitProvider.js index 9a2fcc8e80..249640a0f3 100644 --- a/apps/remix-ide/src/app/files/dgitProvider.js +++ b/apps/remix-ide/src/app/files/dgitProvider.js @@ -129,7 +129,7 @@ class DGitProvider extends Plugin { try { remotes = await git.listRemotes({ ...await this.getGitConfig() }) } catch (e) { - console.log(e) + // do nothing } return remotes } diff --git a/apps/remix-ide/src/app/tabs/search.tsx b/apps/remix-ide/src/app/tabs/search.tsx index 6914ed0150..29e6c6abe7 100644 --- a/apps/remix-ide/src/app/tabs/search.tsx +++ b/apps/remix-ide/src/app/tabs/search.tsx @@ -4,10 +4,10 @@ import React from 'react' // eslint-disable-line import { SearchTab } from '@remix-ui/search' const profile = { name: 'search', - displayName: 'Search', + displayName: 'Search in files', methods: [''], events: [], - icon: 'assets/img/Search_Icon.svg', + icon: 'assets/img/search_icon.webp', description: '', kind: '', location: 'sidePanel', diff --git a/apps/remix-ide/src/assets/img/Search_Icon.svg b/apps/remix-ide/src/assets/img/Search_Icon.svg deleted file mode 100644 index 00a6fcde04..0000000000 --- a/apps/remix-ide/src/assets/img/Search_Icon.svg +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - - diff --git a/apps/remix-ide/src/assets/img/search_icon.webp b/apps/remix-ide/src/assets/img/search_icon.webp new file mode 100644 index 0000000000..93b2695931 Binary files /dev/null and b/apps/remix-ide/src/assets/img/search_icon.webp differ diff --git a/apps/remix-ide/src/remixAppManager.js b/apps/remix-ide/src/remixAppManager.js index 7f8269201c..6a1790e3d0 100644 --- a/apps/remix-ide/src/remixAppManager.js +++ b/apps/remix-ide/src/remixAppManager.js @@ -7,7 +7,7 @@ const _paq = window._paq = window._paq || [] const requiredModules = [ // services + layout views + system views 'manager', 'config', 'compilerArtefacts', 'compilerMetadata', 'contextualListener', 'editor', 'offsetToLineColumnConverter', 'network', 'theme', 'fileManager', 'contentImport', 'blockchain', 'web3Provider', 'scriptRunner', 'fetchAndCompile', 'mainPanel', 'hiddenPanel', 'sidePanel', 'menuicons', - 'filePanel', 'terminal', 'settings', 'pluginManager', 'tabs', 'udapp', 'dGitProvider', 'solidity-logic', 'gistHandler', 'layout', 'notification', 'permissionhandler', 'walkthrough', 'storage', 'search'] + 'filePanel', 'terminal', 'settings', 'pluginManager', 'tabs', 'udapp', 'dGitProvider', 'solidity-logic', 'gistHandler', 'layout', 'notification', 'permissionhandler', 'walkthrough', 'storage'] const dependentModules = ['git', 'hardhat', 'slither'] // module which shouldn't be manually activated (e.g git is activated by remixd) diff --git a/libs/remix-ui/editor/src/lib/actions/editor.ts b/libs/remix-ui/editor/src/lib/actions/editor.ts index 0b49c0686b..b718ffca5a 100644 --- a/libs/remix-ui/editor/src/lib/actions/editor.ts +++ b/libs/remix-ui/editor/src/lib/actions/editor.ts @@ -1,3 +1,5 @@ +import { IRange } from "monaco-editor"; + export interface Action { type: string; payload: Record @@ -49,6 +51,27 @@ export const reducerActions = (models = initialState, action: Action) => { editor.setPosition({ column, lineNumber: line }) return models } + case 'REVEAL_RANGE': { + if (!editor) return models + const range: IRange = { + startLineNumber: action.payload.startLineNumber +1, + startColumn: action.payload.startColumn, + endLineNumber: action.payload.endLineNumber + 1, + endColumn: action.payload.endColumn + } + // reset to start of line + if(action.payload.startColumn < 100){ + editor.revealRange({ + startLineNumber: range.startLineNumber, + startColumn: 1, + endLineNumber: range.endLineNumber, + endColumn: 1 + }) + }else{ + editor.revealRangeInCenter(range) + } + return models + } case 'FOCUS': { if (!editor) return models editor.focus() @@ -106,6 +129,20 @@ export const reducerListener = (plugin, dispatch, monaco, editor, events) => { }) }) + plugin.on('editor', 'revealRange', (startLineNumber, startColumn, endLineNumber, endColumn) => { + dispatch({ + type: 'REVEAL_RANGE', + payload: { + startLineNumber, + startColumn, + endLineNumber, + endColumn + }, + monaco, + editor + }) + }) + plugin.on('editor', 'focus', () => { dispatch({ type: 'FOCUS', diff --git a/libs/remix-ui/search/src/lib/components/Exclude.tsx b/libs/remix-ui/search/src/lib/components/Exclude.tsx index 36d3bdd103..5385783324 100644 --- a/libs/remix-ui/search/src/lib/components/Exclude.tsx +++ b/libs/remix-ui/search/src/lib/components/Exclude.tsx @@ -17,8 +17,8 @@ export const Exclude = props => { return ( <> -
- +
+ { return ( <>
-
{ + const { setReplaceEnabled } = useContext(SearchContext) + const [expanded, setExpanded] = useState(false) + const toggleExpand = () => setExpanded(!expanded) + useEffect(() => { + setReplaceEnabled(expanded) + }, [expanded]) + return ( +
+
+
+ + {expanded ? + <> : null} +
+
+ ) +} diff --git a/libs/remix-ui/search/src/lib/components/Include.tsx b/libs/remix-ui/search/src/lib/components/Include.tsx index ab50233885..1d3f8faf96 100644 --- a/libs/remix-ui/search/src/lib/components/Include.tsx +++ b/libs/remix-ui/search/src/lib/components/Include.tsx @@ -13,8 +13,8 @@ export const Include = props => { return ( <> -
- +
+ { - const { setReplaceWithoutConfirmation } = useContext(SearchContext) + const { setReplaceWithoutConfirmation, state } = useContext(SearchContext) const change = e => { setReplaceWithoutConfirmation(e.target.checked) @@ -10,23 +10,25 @@ export const OverWriteCheck = props => { return ( <> -
-
- - + {state.replaceEnabled ? ( +
+
+ + +
-
+ ) : null} ) } diff --git a/libs/remix-ui/search/src/lib/components/Replace.tsx b/libs/remix-ui/search/src/lib/components/Replace.tsx index 7c39191e6d..a8bd50177f 100644 --- a/libs/remix-ui/search/src/lib/components/Replace.tsx +++ b/libs/remix-ui/search/src/lib/components/Replace.tsx @@ -11,8 +11,8 @@ export const Replace = props => { return ( <> -
- +
+ { @@ -14,13 +16,12 @@ const plugin = props.plugin return ( <> -
+
- - + - +
diff --git a/libs/remix-ui/search/src/lib/components/Undo.tsx b/libs/remix-ui/search/src/lib/components/Undo.tsx new file mode 100644 index 0000000000..03cbbf4550 --- /dev/null +++ b/libs/remix-ui/search/src/lib/components/Undo.tsx @@ -0,0 +1,29 @@ +import { useDialogDispatchers } from "@remix-ui/app" +import React from "react" +import { useContext } from "react" +import { SearchContext } from "../context/context" +import * as path from 'path' + +export const Undo = () => { + const { + state, + undoReplace + } = useContext(SearchContext) + const { alert } = useDialogDispatchers() + + const undo = async () => { + try{ + await undoReplace(state.undoBuffer[`${state.workspace}/${state.currentFile}`]) + }catch(e){ + alert({ id: 'undo_error', title: 'Cannot undo this change', message: e.message }) + } + } + + return (<> + {state.undoBuffer && state.undoBuffer[`${state.workspace}/${state.currentFile}`] && state.undoBuffer[`${state.workspace}/${state.currentFile}`].visible ? + : null} + ) +} diff --git a/libs/remix-ui/search/src/lib/components/results/ResultItem.tsx b/libs/remix-ui/search/src/lib/components/results/ResultItem.tsx index 91bb40b51b..91fb1c6042 100644 --- a/libs/remix-ui/search/src/lib/components/results/ResultItem.tsx +++ b/libs/remix-ui/search/src/lib/components/results/ResultItem.tsx @@ -1,3 +1,4 @@ +import { useDialogDispatchers } from '@remix-ui/app' import React, { useContext, useEffect, useRef, useState } from 'react' import { SearchContext } from '../../context/context' import { SearchResult, SearchResultLine } from '../../types' @@ -9,7 +10,7 @@ interface ResultItemProps { } export const ResultItem = (props: ResultItemProps) => { - const { state, findText, disableForceReload, updateCount } = useContext( + const { state, findText, disableForceReload, updateCount, replaceAllInFile } = useContext( SearchContext ) const [loading, setLoading] = useState(false) @@ -17,7 +18,7 @@ export const ResultItem = (props: ResultItemProps) => { const [toggleExpander, setToggleExpander] = useState(false) const reloadTimeOut = useRef(null) const subscribed = useRef(true) - + const { modal } = useDialogDispatchers() useEffect(() => { reload() @@ -41,10 +42,28 @@ export const ResultItem = (props: ResultItemProps) => { useEffect(() => { subscribed.current = true return () => { + updateCount(0, props.file.filename) subscribed.current = false } }, []) + const confirmReplace = async () => { + setLoading(true) + try { + await replaceAllInFile(props.file) + } catch (e) { + } + setLoading(false) + } + + const replace = async () => { + if(state.replaceWithOutConfirmation){ + confirmReplace() + }else{ + modal({ id: 'confirmreplace', title: 'Replace', message: `Are you sure you want to replace '${state.find}' by '${state.replace}' in ${props.file.filename}?`, okLabel: 'Yes', okFn: confirmReplace, cancelLabel: 'No', cancelFn: ()=>{}, data: null }) + } + } + const reload = () => { findText(props.file.filename).then(res => { if (subscribed.current) { @@ -84,14 +103,20 @@ export const ResultItem = (props: ResultItemProps) => {
{loading ?
Loading...
: null} {!toggleExpander && !loading ? ( -
- {lines.map((line, index) => ( +
+ {state.replaceEnabled? +
+
replace()} className='btn btn-secondary mb-2 btn-sm'>Replace all
+
+ :null} + {lines.map((line, index) => ( + index < state.maxLines ? + />: null ))}
) : null} diff --git a/libs/remix-ui/search/src/lib/components/results/ResultSummary.tsx b/libs/remix-ui/search/src/lib/components/results/ResultSummary.tsx index 157aa43006..0d4a640a04 100644 --- a/libs/remix-ui/search/src/lib/components/results/ResultSummary.tsx +++ b/libs/remix-ui/search/src/lib/components/results/ResultSummary.tsx @@ -47,15 +47,16 @@ export const ResultSummary = (props: ResultSummaryProps) => { className='search_plugin_search_line pb-1' >
{lineItem.left.substring(lineItem.left.length - 20).trimStart()}
- {lineItem.center} - {state.replace? {state.replace}:<>} + {lineItem.center} + {state.replace && state.replaceEnabled? {state.replace}:<>}
{lineItem.right.substring(0, 100)}
+ {state.replaceEnabled?
{ replace(lineItem) }} className="codicon codicon-find-replace" role="button" aria-label="Replace" aria-disabled="false">
-
+
:null}
))} diff --git a/libs/remix-ui/search/src/lib/components/results/Results.tsx b/libs/remix-ui/search/src/lib/components/results/Results.tsx index b2094d23c9..3e5472915a 100644 --- a/libs/remix-ui/search/src/lib/components/results/Results.tsx +++ b/libs/remix-ui/search/src/lib/components/results/Results.tsx @@ -5,13 +5,13 @@ import { ResultItem } from './ResultItem' export const Results = () => { const { state } = useContext(SearchContext) return ( -
- {state.find ?
{state.count} results
: null} - {state.count < state.maxResults && state.searchResults && +
+ {state.find ?
showing {state.count} results {state.fileCount} in files
: null} + {state.find && state.clipped?
The result set only shows a subset of all matches

Please narrow down your search.
: null} + {state.searchResults && state.searchResults.map((result, index) => { - return + return index : null })} - {state.find && state.count >= state.maxResults?
Too many results to display.

Please narrow your search.
: null}
) } diff --git a/libs/remix-ui/search/src/lib/components/results/SearchHelper.ts b/libs/remix-ui/search/src/lib/components/results/SearchHelper.ts index 88ae6b1de1..fb6b096df2 100644 --- a/libs/remix-ui/search/src/lib/components/results/SearchHelper.ts +++ b/libs/remix-ui/search/src/lib/components/results/SearchHelper.ts @@ -92,6 +92,9 @@ function getEOL(text) { return u > w ? '\n' : '\r\n'; } +export const replaceAllInFile = (string: string, re:RegExp, newText: string) => { + return string.replace(re, newText) +} export const replaceTextInLine = (str: string, searchResultLine: SearchResultLineLine, newText: string) => { return str diff --git a/libs/remix-ui/search/src/lib/context/context.tsx b/libs/remix-ui/search/src/lib/context/context.tsx index bbc3c72795..6d270ba1b2 100644 --- a/libs/remix-ui/search/src/lib/context/context.tsx +++ b/libs/remix-ui/search/src/lib/context/context.tsx @@ -3,6 +3,7 @@ import { createContext, useReducer } from 'react' import { findLinesInStringWithMatch, getDirectory, + replaceAllInFile, replaceTextInLine } from '../components/results/SearchHelper' import { SearchReducer } from '../reducers/Reducer' @@ -11,7 +12,8 @@ import { SearchResult, SearchResultLine, SearchResultLineLine, - SearchingInitialState + SearchingInitialState, + undoBufferRecord } from '../types' import { filePathFilter } from '@jsdevtools/file-path-filter' import { escapeRegExp } from 'lodash' @@ -20,6 +22,7 @@ export interface SearchingStateInterface { state: SearchState setFind: (value: string) => void setReplace: (value: string) => void + setReplaceEnabled: (value: boolean) => void setInclude: (value: string) => void setExclude: (value: string) => void setCaseSensitive: (value: boolean) => void @@ -28,7 +31,7 @@ export interface SearchingStateInterface { setSearchResults: (value: SearchResult[]) => void findText: (path: string) => Promise hightLightInPath: (result: SearchResult, line: SearchResultLineLine) => void - replaceText: (result: SearchResult, line: SearchResultLineLine) => void + replaceText: (result: SearchResult, line: SearchResultLineLine) => Promise reloadFile: (file: string) => void toggleCaseSensitive: () => void toggleMatchWholeWord: () => void @@ -36,6 +39,9 @@ export interface SearchingStateInterface { setReplaceWithoutConfirmation: (value: boolean) => void disableForceReload: (file: string) => void updateCount: (count: number, file: string) => void + replaceAllInFile: (result: SearchResult) => Promise + undoReplace: (buffer: undoBufferRecord) => Promise + clearUndo: () => void } export const SearchContext = createContext(null) @@ -63,6 +69,12 @@ export const SearchProvider = ({ payload: value }) }, + setReplaceEnabled: (value: boolean) => { + dispatch({ + type: 'SET_REPLACE_ENABLED', + payload: value + }) + }, setInclude: (value: string) => { dispatch({ type: 'SET_INCLUDE', @@ -135,26 +147,32 @@ export const SearchProvider = ({ payload: file }) }, + setCurrentFile: (file: string) => { + dispatch({ + type: 'SET_CURRENT_FILE', + payload: file + }) + }, + setCurrentWorkspace: (workspace: any) => { + dispatch({ + type: 'SET_CURRENT_WORKSPACE', + payload: workspace + }) + }, updateCount: (count: number, file: string) => { dispatch({ type: 'UPDATE_COUNT', - payload: {count, file} + payload: { count, file } }) }, findText: async (path: string) => { if (!plugin) return try { - if (state.find.length < 3) return + if (state.find.length < 1) return const text = await plugin.call('fileManager', 'readFile', path) - let flags = 'g' - let find = state.find - if (!state.casesensitive) flags += 'i' - if (!state.useRegExp) find = escapeRegExp(find) - if (state.matchWord) find = `\\b${find}\\b` - const re = new RegExp(find, flags) - const result: SearchResultLine[] = findLinesInStringWithMatch(text, re) + const result: SearchResultLine[] = findLinesInStringWithMatch(text, createRegExFromFind()) return result - } catch (e) {} + } catch (e) { } }, hightLightInPath: async ( result: SearchResult, @@ -162,6 +180,7 @@ export const SearchProvider = ({ ) => { await plugin.call('editor', 'discardHighlight') await plugin.call('editor', 'highlight', line.position, result.path) + await plugin.call('editor', 'revealRange', line.position.start.line, line.position.start.column, line.position.end.line, line.position.end.column) }, replaceText: async (result: SearchResult, line: SearchResultLineLine) => { try { @@ -172,30 +191,99 @@ export const SearchProvider = ({ 'readFile', result.path ) - + const replaced = replaceTextInLine(content, line, state.replace) await plugin.call( 'fileManager', 'setFile', result.path, - replaceTextInLine(content, line, state.replace) + replaced ) + setUndoState(content, replaced, result.path) } catch (e) { throw new Error(e) } + }, + replaceAllInFile: async (result: SearchResult) => { + await plugin.call('editor', 'discardHighlight') + const content = await plugin.call( + 'fileManager', + 'readFile', + result.path + ) + const replaced = replaceAllInFile(content, createRegExFromFind(), state.replace) + await plugin.call( + 'fileManager', + 'setFile', + result.path, + replaced + ) + await plugin.call( + 'fileManager', + 'open', + result.path + ) + setUndoState(content, replaced, result.path) + }, + setUndoEnabled: (path:string, workspace: string, content: string) => { + dispatch({ + type: 'SET_UNDO_ENABLED', + payload: { + path, + workspace, + content + } + }) + }, + undoReplace: async (buffer: undoBufferRecord) => { + const content = await plugin.call( + 'fileManager', + 'readFile', + buffer.path + ) + if (buffer.newContent !== content) { + throw new Error('Can not undo replace, file has been changed.') + } + await plugin.call( + 'fileManager', + 'setFile', + buffer.path, + buffer.oldContent + ) + await plugin.call( + 'fileManager', + 'open', + buffer.path + ) + }, + clearUndo: () => { + dispatch ({ + type: 'CLEAR_UNDO', + payload: undefined + }) } } + + const reloadStateForFile = async (file: string) => { - await value.reloadFile(file) + await value.reloadFile(file) } useEffect(() => { - plugin.on('filePanel', 'setWorkspace', () => { + plugin.on('filePanel', 'setWorkspace', async (workspace) => { value.setSearchResults(null) + value.clearUndo() + value.setCurrentWorkspace(workspace.name) }) plugin.on('fileManager', 'fileSaved', async file => { await reloadStateForFile(file) + await checkUndoState(file) }) + plugin.on('fileManager', 'currentFileChanged', async file => { + value.setCurrentFile(file) + await checkUndoState(file) + }) + return () => { plugin.off('fileManager', 'fileChanged') plugin.off('filePanel', 'setWorkspace') @@ -207,22 +295,61 @@ export const SearchProvider = ({ const results = [] paths.split(',').forEach(path => { path = path.trim() - if(path.startsWith('*.')) path = path.replace(/(\*\.)/g, '**/*.') - if(path.endsWith('/*') && !path.endsWith('/**/*')) path = path.replace(/(\*)/g, '**/*.*') + if (path.startsWith('*.')) path = path.replace(/(\*\.)/g, '**/*.') + if (path.endsWith('/*') && !path.endsWith('/**/*')) path = path.replace(/(\*)/g, '**/*.*') results.push(path) }) return results } + const checkUndoState = async (path: string) => { + if (!plugin) return + try { + const content = await plugin.call( + 'fileManager', + 'readFile', + path + ) + const workspace = await plugin.call('filePanel', 'getCurrentWorkspace') + value.setUndoEnabled(path, workspace.name, content) + } catch (e) { + console.log(e) + } + } + + const setUndoState = async (oldContent: string, newContent: string, path: string) => { + const workspace = await plugin.call('filePanel', 'getCurrentWorkspace') + const undo = { + oldContent, + newContent, + path, + workspace: workspace.name + } + dispatch({ + type: 'SET_UNDO', + payload: undo + }) + } + + const createRegExFromFind = () => { + let flags = 'g' + let find = state.find + if (!state.casesensitive) flags += 'i' + if (!state.useRegExp) find = escapeRegExp(find) + if (state.matchWord) find = `\\b${find}\\b` + const re = new RegExp(find, flags) + return re + } + useEffect(() => { if (state.find) { (async () => { const files = await getDirectory('/', plugin) const pathFilter: any = {} - if (state.include){ + if (state.include) { pathFilter.include = setGlobalExpression(state.include) } - if (state.exclude){ + if (state.exclude) { pathFilter.exclude = setGlobalExpression(state.exclude) } const filteredFiles = files.filter(filePathFilter(pathFilter)).map(file => { diff --git a/libs/remix-ui/search/src/lib/reducers/Reducer.ts b/libs/remix-ui/search/src/lib/reducers/Reducer.ts index 05e9efaecb..3e188f61fb 100644 --- a/libs/remix-ui/search/src/lib/reducers/Reducer.ts +++ b/libs/remix-ui/search/src/lib/reducers/Reducer.ts @@ -1,4 +1,4 @@ -import { Action, SearchingInitialState, SearchState } from "../types" +import { Action, SearchingInitialState, SearchState, undoBufferRecord } from "../types" export const SearchReducer = (state: SearchState = SearchingInitialState, action: Action) => { switch (action.type) { @@ -15,6 +15,12 @@ export const SearchReducer = (state: SearchState = SearchingInitialState, action replace: action.payload, } + case 'SET_REPLACE_ENABLED': + return { + ...state, + replaceEnabled: action.payload, + } + case 'SET_INCLUDE': return { ...state, @@ -35,21 +41,60 @@ export const SearchReducer = (state: SearchState = SearchingInitialState, action searchResults: action.payload, count: 0 } + case 'SET_UNDO_ENABLED': + if(state.undoBuffer[`${action.payload.workspace}/${action.payload.path}`]){ + state.undoBuffer[`${action.payload.workspace}/${action.payload.path}`].enabled = (action.payload.content === state.undoBuffer[`${action.payload.workspace}/${action.payload.path}`].newContent) + state.undoBuffer[`${action.payload.workspace}/${action.payload.path}`].visible = (action.payload.content !== state.undoBuffer[`${action.payload.workspace}/${action.payload.path}`].oldContent) + } + return { + ...state, + } + case 'SET_UNDO': { + const undoState = { + newContent : action.payload.newContent, + oldContent: action.payload.oldContent, + path: action.payload.path, + workspace: action.payload.workspace, + timeStamp: Date.now(), + enabled: true, + visible: true + } + state.undoBuffer[`${undoState.workspace}/${undoState.path}`] = undoState + return { + ...state, + } + } + case 'CLEAR_UNDO': { + state.undoBuffer = [] + return { + ...state, + } + } case 'UPDATE_COUNT': if (state.searchResults) { const findFile = state.searchResults.find(file => file.filename === action.payload.file) let count = 0 + let fileCount = 0 + let clipped = false if (findFile) { findFile.count = action.payload.count } state.searchResults.forEach(file => { - if (file.count) { - count += file.count + if (file.count) { + if(file.count > state.maxLines) { + clipped = true + count += state.maxLines + }else{ + count += file.count + } + fileCount++ } }) return { ...state, - count: count + count: count, + fileCount, + clipped } } else { return state @@ -85,6 +130,16 @@ export const SearchReducer = (state: SearchState = SearchingInitialState, action return { ...state, } + case 'SET_CURRENT_FILE': + return { + ...state, + currentFile: action.payload, + } + case 'SET_CURRENT_WORKSPACE': + return { + ...state, + workspace: action.payload, + } case 'RELOAD_FILE': if (state.searchResults) { const findFile = state.searchResults.find(file => file.filename === action.payload) diff --git a/libs/remix-ui/search/src/lib/search.css b/libs/remix-ui/search/src/lib/search.css index 895ce83e7f..acc789d51c 100644 --- a/libs/remix-ui/search/src/lib/search.css +++ b/libs/remix-ui/search/src/lib/search.css @@ -21,7 +21,7 @@ .search_plugin_find-part { display: flex; flex-direction: column; - padding: 2px; + padding-top: 5px; } .search_plugin_controls { @@ -64,6 +64,7 @@ .search_plugin_search_tab mark { padding: 0; + white-space: pre; } .search_plugin_search_tab .search_plugin_search_line_container .search_plugin_search_control { @@ -103,4 +104,33 @@ .search_plugin_search_tab .search_plugin_result_count_number { font-size: x-small; +} + +.search_plugin_find_container { + display: flex; + flex-direction: row; +} + +.search_plugin_find_container_internal { + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.search_plugin_find_container_arrow { + display: flex !important; + align-items: center; + cursor: pointer !important; +} + +.search_plugin_wrap_summary_replace { + display: flex; + flex-direction: row; + justify-content: flex-end; +} + +.undo-button { + white-space: pre; + text-overflow: ellipsis; + overflow: hidden; } \ No newline at end of file diff --git a/libs/remix-ui/search/src/lib/types/index.ts b/libs/remix-ui/search/src/lib/types/index.ts index af76487c51..231c4ebf93 100644 --- a/libs/remix-ui/search/src/lib/types/index.ts +++ b/libs/remix-ui/search/src/lib/types/index.ts @@ -35,10 +35,20 @@ export interface SearchResult { count: number } +export interface undoBufferRecord { + workspace: string, + path: string, + newContent: string, + timeStamp: number, + oldContent: string, + enabled: boolean, + visible: boolean +} export interface SearchState { find: string, searchResults: SearchResult[], replace: string, + replaceEnabled: boolean, include: string, exclude: string, casesensitive: boolean, @@ -47,7 +57,13 @@ export interface SearchState { useRegExp: boolean, timeStamp: number, count: number, - maxResults: number + fileCount: number, + maxFiles: number, + maxLines: number + clipped: boolean, + undoBuffer: Record[], + currentFile: string, + workspace: string } export const SearchingInitialState: SearchState = { @@ -55,6 +71,7 @@ export const SearchingInitialState: SearchState = { replace: '', include: '', exclude: '', + replaceEnabled: false, searchResults: [], casesensitive: false, matchWord: false, @@ -62,5 +79,11 @@ export const SearchingInitialState: SearchState = { replaceWithOutConfirmation: false, timeStamp: 0, count: 0, - maxResults: 500 + fileCount: 0, + maxFiles: 5000, + maxLines: 5000, + clipped: false, + undoBuffer: null, + currentFile: '', + workspace: '' } \ No newline at end of file