import * as ui from "../../ui"; import * as csx from "../../base/csx"; import * as React from "react"; var ReactDOM = require("react-dom"); import * as tab from "./tab"; import { server, cast } from "../../../socket/socketClient"; import * as commands from "../../commands/commands"; import * as utils from "../../../common/utils"; import * as d3 from "d3"; import * as $ from "jquery"; import * as styles from "../../styles/styles"; import * as onresize from "onresize"; import { Clipboard } from "../../components/clipboard"; import { Types } from "../../../socket/socketContract"; import { Icon } from "../../components/icon"; import * as Mousetrap from "mousetrap"; import { Robocop } from "../../components/robocop"; import * as pure from "../../../common/pure"; import * as buttons from "../../components/buttons"; import * as types from "../../../common/types"; import * as gls from "../../base/gls"; import * as typestyle from "typestyle"; type NodeDisplay = Types.NodeDisplay; let EOL = '\n'; /** * The styles */ import { inputCodeStyle, searchOptionsLabelStyle } from "../../findAndReplace"; let {inputBlackStyleBase} = styles.Input; const inputBlackClassName = typestyle.style(inputBlackStyleBase); namespace ResultsStyles { export const rootClassName = typestyle.style( csx.flex, csx.scroll as any, styles.padded1, { border: '1px solid grey', $nest: { '&:focus': { outline: 'none', border: '1px solid ' + styles.highlightColor, } } } ); export const header = csx.extend( styles.padded1, { cursor: 'default', fontSize: '1.5em', fontWeight: 'bold', color: styles.textColor, background: 'black', border: '2px solid grey', } ); export let preview = { padding: '3px', background: 'black', cursor: 'pointer', userSelect: 'text', } export let selected = { backgroundColor: styles.selectedBackgroundColor }; export const noFocusClassName = typestyle.style( { $nest: { '&:focus': { outline: 'none' } } } ) } /** * Props / State */ export interface Props extends tab.TabProps { } export interface State { completed?: boolean; results?: Types.FarmResultDetails[]; config?: Types.FarmConfig; farmResultByFilePath?: Types.FarmResultsByFilePath; /** * Results view state */ collapsedState?: { [filePath: string]: boolean }; selected?: { filePath?: string; /* If we have a filePath selected (instead of a search result) we set this to -1*/ line?: number; }; queryRegex?: RegExp; /** * Search state */ findQuery?: string; replaceQuery?: string; isRegex?: boolean; isCaseSensitive?: boolean; isFullWord?: boolean; } export class FindAndReplaceView extends ui.BaseComponent { constructor(props: Props) { super(props); let {protocol, filePath} = utils.getFilePathAndProtocolFromUrl(props.url); this.filePath = filePath; this.state = { completed: true, farmResultByFilePath: {}, collapsedState: {}, selected: {}, }; } filePath: string; mode: Types.ASTMode; componentDidMount() { /** * Keyboard: Focus */ this.focusOnInput(); // On initial mount as well :) this.disposible.add(commands.findAndReplace.on(this.focusOnInput)); this.disposible.add(commands.findAndReplaceMulti.on(this.focusOnInput)); /** * Keyboard: Stop */ this.disposible.add(commands.esc.on(() => { // Disabled as esc is used to focus search results as well // this.cancelAnyRunningSearch(); })); /** * Initial load && keeping it updated */ server.farmResults({}).then(res => { this.parseResults(res); }); this.disposible.add(cast.farmResultsUpdated.on(res => { this.parseResults(res); })); /** * Handle the keyboard in the search results */ let treeRoot = this.refs.results; let handlers = new Mousetrap(treeRoot); let selectFirst = () => { if (this.state.results && this.state.results.length) { this.setSelected(this.state.results[0].filePath, -1); } } handlers.bind('up', () => { // initial state if (!this.state.results || !this.state.results.length) return false; if (!this.state.selected.filePath) { selectFirst(); return false; } /** If we have an actual line go to previous or filePath */ if (this.state.selected.line !== -1) { const relevantResults = this.state.farmResultByFilePath[this.state.selected.filePath]; const indexInResults = relevantResults .map(x => x.line) .indexOf(this.state.selected.line); if (indexInResults === 0) { this.setSelected(this.state.selected.filePath, -1) } else { this.setSelected(this.state.selected.filePath, relevantResults[indexInResults - 1].line); } } /** Else go to the previous filePath (if any) (collapsed) the filePath (expanded) last child */ else { let filePaths = Object.keys(this.state.farmResultByFilePath); let filePathIndex = filePaths.indexOf(this.state.selected.filePath); if (filePathIndex === 0) return false; let previousFilePath = filePaths[filePathIndex - 1]; if (this.state.collapsedState[previousFilePath]) { this.setSelected(previousFilePath, -1); } else { let results = this.state.farmResultByFilePath[previousFilePath]; this.setSelected(previousFilePath, results[results.length - 1].line); } } return false; }); handlers.bind('down', () => { if (!this.state.results || !this.state.results.length) return false; if (!this.state.selected.filePath) { selectFirst(); return false; } /** If we are on a filePath (collaped) go to next filePath if any (expanded) go to first child */ if (this.state.selected.line === -1) { if (this.state.collapsedState[this.state.selected.filePath]) { let filePaths = Object.keys(this.state.farmResultByFilePath); let filePathIndex = filePaths.indexOf(this.state.selected.filePath); if (filePathIndex === filePaths.length - 1) return false; let nextFilePath = filePaths[filePathIndex + 1]; this.setSelected(nextFilePath, -1); } else { let results = this.state.farmResultByFilePath[this.state.selected.filePath]; this.setSelected(results[0].filePath, results[0].line); } } /** Else if last in the group go to next filePath if any, otherwise goto next sibling */ else { let results = this.state.farmResultByFilePath[this.state.selected.filePath]; let indexIntoResults = results.map(x => x.line).indexOf(this.state.selected.line); if (indexIntoResults === results.length - 1) { // Goto next filePath if any let filePaths = Object.keys(this.state.farmResultByFilePath); let filePathIndex = filePaths.indexOf(this.state.selected.filePath); if (filePathIndex === filePaths.length - 1) return false; let nextFilePath = filePaths[filePathIndex + 1]; this.setSelected(nextFilePath, -1); } else { let nextResult = results[indexIntoResults + 1]; this.setSelected(nextResult.filePath, nextResult.line); } } return false; }); handlers.bind('left', () => { /** Just select and collapse the folder irrespective of our current state */ if (!this.state.results || !this.state.results.length) return false; if (!this.state.selected.filePath) return false; this.state.collapsedState[this.state.selected.filePath] = true; this.setState({ collapsedState: this.state.collapsedState }); this.setSelected(this.state.selected.filePath, -1); return false; }); handlers.bind('right', () => { /** Expand only if a filePath root is currently selected */ if (!this.state.results || !this.state.results.length) return false; this.state.collapsedState[this.state.selected.filePath] = false; this.setState({ collapsedState: this.state.collapsedState }); return false; }); handlers.bind('enter', () => { /** Enter always takes you into the filePath */ if (!this.state.results || !this.state.results.length) return false; if (!this.state.selected.filePath) { selectFirst(); } this.openSearchResult(this.state.selected.filePath, this.state.selected.line); return false; }); // Listen to tab events const api = this.props.api; this.disposible.add(api.resize.on(this.resize)); this.disposible.add(api.focus.on(this.focus)); this.disposible.add(api.save.on(this.save)); this.disposible.add(api.close.on(this.close)); this.disposible.add(api.gotoPosition.on(this.gotoPosition)); // Listen to search tab events this.disposible.add(api.search.doSearch.on(this.search.doSearch)); this.disposible.add(api.search.hideSearch.on(this.search.hideSearch)); this.disposible.add(api.search.findNext.on(this.search.findNext)); this.disposible.add(api.search.findPrevious.on(this.search.findPrevious)); this.disposible.add(api.search.replaceNext.on(this.search.replaceNext)); this.disposible.add(api.search.replacePrevious.on(this.search.replacePrevious)); this.disposible.add(api.search.replaceAll.on(this.search.replaceAll)); } refs: { [string: string]: any; results: HTMLDivElement; find: JSX.Element; replace: JSX.Element; regex: { refs: { input: JSX.Element } }; caseInsensitive: { refs: { input: JSX.Element } }; fullWord: { refs: { input: JSX.Element } }; } findInput = (): HTMLInputElement => ReactDOM.findDOMNode(this.refs.find); replaceInput = (): HTMLInputElement => ReactDOM.findDOMNode(this.refs.replace); regexInput = (): HTMLInputElement => ReactDOM.findDOMNode(this.refs.regex.refs.input); caseInsensitiveInput = (): HTMLInputElement => ReactDOM.findDOMNode(this.refs.caseInsensitive.refs.input); fullWordInput = (): HTMLInputElement => ReactDOM.findDOMNode(this.refs.fullWord.refs.input); replaceWith = () => this.replaceInput().value; render() { let hasSearch = !!this.state.config; let rendered = (
{ hasSearch ? this.renderSearchResults() :
No Search
}
{ this.renderSearchControls() }
); return rendered; } renderSearchControls() { return (
Controls: {' '}Esc to focus on results {' '}Enter to start/restart search {' '}Toggle 🔘 switches start/restart search
Results: {' '}Up/Down to go through results {' '}Enter to open a search result
{ /* Disabled Replace as that is not the core focus of my work at the moment {' '}{commands.modName} + Enter to replace {' '}{commands.modName} + Shift + Enter to replace all */ }
); } renderSearchResults() { let filePaths = Object.keys(this.state.farmResultByFilePath); let queryRegex = this.state.queryRegex; let queryRegexStr = (this.state.queryRegex && this.state.queryRegex.toString()) || ''; queryRegexStr = queryRegexStr && ` (Query : ${queryRegexStr}) ` return (
Total Results ({this.state.results.length}) { (this.state.results.length >= types.maxCountFindAndReplaceMultiResults) && (+) } { !this.state.completed && } {queryRegexStr}
{ filePaths.map((filePath, i) => { let results = this.state.farmResultByFilePath[filePath]; let selectedRoot = filePath === this.state.selected.filePath && this.state.selected.line == -1 let selectedResultLine = filePath === this.state.selected.filePath ? this.state.selected.line : -2 /* -2 means not selected */; return ( ); }) }
); } toggleFilePathExpansion = (filePath: string) => { this.state.collapsedState[filePath] = !this.state.collapsedState[filePath]; this.setState({ collapsedState: this.state.collapsedState, selected: { filePath, line: -1 } }); } /** * Scrolling through results */ resultRef(filePath: string) { const ref = filePath; return this.refs[ref] as FileResults.FileResults; } /** * Input Change handlers */ fullWordKeyDownHandler = (e: React.SyntheticEvent) => { let {tab, shift, enter} = ui.getKeyStates(e); if (tab && !shift) { this.findInput().focus(); e.preventDefault(); return; } }; findKeyDownHandler = (e: React.SyntheticEvent) => { let {tab, shift, enter, mod} = ui.getKeyStates(e); if (shift && tab) { this.fullWordInput() && this.fullWordInput().focus(); e.preventDefault(); return; } /** * Handling commit */ if (!this.state.findQuery) { return; } if (enter) { this.startSearch(); } }; findChanged = () => { let val = this.findInput().value.trim(); this.setState({ findQuery: val }); }; handleRegexChange = (e) => { let val: boolean = e.target.checked; this.setState({ isRegex: val }); this.startSearch(); } handleCaseSensitiveChange = (e) => { let val: boolean = e.target.checked; this.setState({ isCaseSensitive: val }); this.startSearch(); } handleFullWordChange = (e) => { let val: boolean = e.target.checked; this.setState({ isFullWord: val }); this.startSearch(); } /** * Sends the search query * debounced as state needs to be set before this execs */ startSearch = utils.debounce(() => { const config: Types.FarmConfig = { query: this.state.findQuery, isRegex: this.state.isRegex, isFullWord: this.state.isFullWord, isCaseSensitive: this.state.isCaseSensitive, globs: [] }; server.startFarming(config); this.setState({ // Clear previous search collapsedState: {}, selected: {}, // Set new results preemptively results: [], config, queryRegex: utils.findOptionsToQueryRegex({ query: config.query, isRegex: config.isRegex, isFullWord: config.isFullWord, isCaseSensitive: config.isCaseSensitive, }), completed: false, farmResultByFilePath: {}, }); }, 100); cancelAnyRunningSearch = () => { server.stopFarmingIfRunning({}); }; /** Parses results as they come and puts them into the state */ parseResults(response: Types.FarmNotification) { // Convert as needed // console.log(response); // DEBUG let results = response.results; let loaded: Types.FarmResultsByFilePath = utils.createMapByKey(results, result => result.filePath); let queryRegex = response.config ? utils.findOptionsToQueryRegex({ query: response.config.query, isRegex: response.config.isRegex, isFullWord: response.config.isFullWord, isCaseSensitive: response.config.isCaseSensitive, }) : null; // Finally rerender this.setState({ results: results, config: response.config, queryRegex, completed: response.completed, farmResultByFilePath: loaded }); } openSearchResult = (filePath: string, line: number) => { commands.doOpenOrFocusFile.emit({ filePath, position: { line: line - 1, ch: 0 } }); this.setSelected(filePath, line); } setSelected = (filePath: string, line: number) => { this.setState({ selected: { filePath, line } }); this.focusFilePath(filePath, line); } focusFilePath = (filePath: string, line: number) => { this.resultRef(filePath).focus(filePath, line); }; /** * TAB implementation */ resize = () => { // This layout doesn't need it } /** Allows us to focus on input on certain keystrokes instead of search results */ focusOnInput = () => setTimeout(() => this.findInput().focus(), 500); focus = () => { this.refs.results.focus(); } save = () => { } close = () => { } gotoPosition = (position: EditorPosition) => { } search = { doSearch: (options: FindOptions) => { // not needed }, hideSearch: () => { // not needed }, findNext: (options: FindOptions) => { }, findPrevious: (options: FindOptions) => { }, replaceNext: ({newText}: { newText: string }) => { }, replacePrevious: ({newText}: { newText: string }) => { }, replaceAll: ({newText}: { newText: string }) => { } } } namespace FileResults { export interface Props { filePath: string; results: Types.FarmResultDetails[]; expanded: boolean; onClickFilePath: (filePath: string) => any; openSearchResult: (filePath: string, line: number) => any; selectedRoot: boolean; selectedResultLine: number; queryRegex: RegExp; } export interface State { } export class FileResults extends React.PureComponent{ render() { let selectedStyle = this.props.selectedRoot ? ResultsStyles.selected : {}; return (
this.props.onClickFilePath(this.props.filePath)}>
{!this.props.expanded ? "+" : "-"} {this.props.filePath} ({this.props.results.length})
{!this.props.expanded ?
); } renderResultsForFilePath = (results: Types.FarmResultDetails[]) => { return results.map((result, i) => { let selectedStyle = this.props.selectedResultLine === result.line ? ResultsStyles.selected : {}; return (
{ e.stopPropagation(); this.props.openSearchResult(result.filePath, result.line) } }> {utils.padLeft((result.line).toString(), 6)} : {this.renderMatched(result.preview, this.props.queryRegex)}
); }) } focus(filePath: string, line: number) { let dom = this.refs[this.props.filePath + ':' + line] as HTMLDivElement; dom.scrollIntoViewIfNeeded(false); } renderMatched(preview: string, queryRegex: RegExp) { let matched = this.getMatchedSegments(preview, queryRegex); let matchedStyle = { fontWeight: 'bold' as 'bold', color: '#66d9ef' }; return matched.map((item, i) => { return {item.str}; }); } getMatchedSegments(preview: string, queryRegex: RegExp) { // A data structure which is efficient to render type MatchedSegments = { str: string, matched: boolean }[]; let result: MatchedSegments = []; var match; let previewCollectedIndex = 0; let collectUnmatched = (matchStart: number, matchLength: number) => { if (previewCollectedIndex < matchStart) { result.push({ str: preview.substring(previewCollectedIndex, matchStart), matched: false }); previewCollectedIndex = (matchStart + matchLength); } }; while (match = queryRegex.exec(preview)) { let matchStart = match.index; let matchLength = match[0].length; // let nextMatchStart = queryRegex.lastIndex; // Since we don't need it // If we have an unmatched string portion that is still not collected // Collect it :) collectUnmatched(matchStart, matchLength); result.push({ str: preview.substr(matchStart, matchLength), matched: true }); } // If we still have some string portion uncollected, collect it if (previewCollectedIndex !== preview.length) { collectUnmatched(preview.length, 0); } return result; } } }