mirror of
https://github.com/pgadmin-org/pgadmin4.git
synced 2025-02-25 18:55:31 -06:00
584 lines
17 KiB
JavaScript
584 lines
17 KiB
JavaScript
/////////////////////////////////////////////////////////////
|
|
//
|
|
// pgAdmin 4 - PostgreSQL Tools
|
|
//
|
|
// Copyright (C) 2013 - 2023, The pgAdmin Development Team
|
|
// This software is released under the PostgreSQL Licence
|
|
//
|
|
//////////////////////////////////////////////////////////////
|
|
|
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import OrigCodeMirror from 'bundled_codemirror';
|
|
import {useOnScreen} from 'sources/custom_hooks';
|
|
import PropTypes from 'prop-types';
|
|
import CustomPropTypes from '../custom_prop_types';
|
|
import pgWindow from 'sources/window';
|
|
import gettext from 'sources/gettext';
|
|
import { Box, InputAdornment, makeStyles } from '@material-ui/core';
|
|
import clsx from 'clsx';
|
|
import { InputText } from './FormComponents';
|
|
import { DefaultButton, PgIconButton } from './Buttons';
|
|
import CloseIcon from '@material-ui/icons/CloseRounded';
|
|
import ArrowDownwardRoundedIcon from '@material-ui/icons/ArrowDownwardRounded';
|
|
import ArrowUpwardRoundedIcon from '@material-ui/icons/ArrowUpwardRounded';
|
|
import SwapHorizRoundedIcon from '@material-ui/icons/SwapHorizRounded';
|
|
import SwapCallsRoundedIcon from '@material-ui/icons/SwapCallsRounded';
|
|
import _ from 'lodash';
|
|
import { RegexIcon, FormatCaseIcon } from './ExternalIcon';
|
|
import { isMac } from '../keyboard_shortcuts';
|
|
import { checkTrojanSource } from '../utils';
|
|
import { copyToClipboard } from '../clipboard';
|
|
import { useDelayedCaller } from '../../../static/js/custom_hooks';
|
|
|
|
const useStyles = makeStyles((theme)=>({
|
|
root: {
|
|
position: 'relative',
|
|
},
|
|
hideCursor: {
|
|
'& .CodeMirror-cursors': {
|
|
display: 'none'
|
|
}
|
|
},
|
|
findDialog: {
|
|
position: 'absolute',
|
|
zIndex: 99,
|
|
right: '4px',
|
|
...theme.mixins.panelBorder.all,
|
|
borderTop: 'none',
|
|
padding: '2px 4px',
|
|
width: '250px',
|
|
backgroundColor: theme.palette.background.default,
|
|
},
|
|
marginTop: {
|
|
marginTop: '0.25rem',
|
|
},
|
|
copyButton: {
|
|
position: 'absolute',
|
|
zIndex: 99,
|
|
right: '4px',
|
|
width: '66px',
|
|
}
|
|
}));
|
|
|
|
function parseString(string) {
|
|
return string.replace(/\\([nrt\\])/g, function(match, ch) {
|
|
if (ch == 'n') return '\n';
|
|
if (ch == 'r') return '\r';
|
|
if (ch == 't') return '\t';
|
|
if (ch == '\\') return '\\';
|
|
return match;
|
|
});
|
|
}
|
|
|
|
function parseQuery(query, useRegex=false, matchCase=false) {
|
|
try {
|
|
if (useRegex) {
|
|
query = new RegExp(query, matchCase ? 'g': 'gi');
|
|
} else {
|
|
query = parseString(query);
|
|
if(!matchCase) {
|
|
query = query.toLowerCase();
|
|
}
|
|
}
|
|
if (typeof query == 'string' ? query == '' : query.test(''))
|
|
query = /x^/;
|
|
return query;
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getRegexFinder(query) {
|
|
return (stream) => {
|
|
query.lastIndex = stream.pos;
|
|
let match = query.exec(stream.string);
|
|
if (match && match.index == stream.pos) {
|
|
stream.pos += match[0].length || 1;
|
|
return 'searching';
|
|
} else if (match) {
|
|
stream.pos = match.index;
|
|
} else {
|
|
stream.skipToEnd();
|
|
}
|
|
};
|
|
}
|
|
|
|
|
|
function getPlainStringFinder(query, matchCase) {
|
|
return (stream) => {
|
|
let matchIndex = (matchCase ? stream.string : stream.string.toLowerCase()).indexOf(query, stream.pos);
|
|
if(matchIndex == -1) {
|
|
stream.skipToEnd();
|
|
} else if(matchIndex == stream.pos) {
|
|
stream.pos += query.length;
|
|
return 'searching';
|
|
} else {
|
|
stream.pos = matchIndex;
|
|
}
|
|
};
|
|
}
|
|
|
|
function searchOverlay(query, matchCase) {
|
|
return {
|
|
token: typeof query == 'string' ?
|
|
getPlainStringFinder(query, matchCase) : getRegexFinder(query)
|
|
};
|
|
}
|
|
|
|
export const CodeMirrorInstancType = PropTypes.shape({
|
|
getCursor: PropTypes.func,
|
|
getSearchCursor: PropTypes.func,
|
|
removeOverlay: PropTypes.func,
|
|
addOverlay: PropTypes.func,
|
|
setSelection: PropTypes.func,
|
|
scrollIntoView: PropTypes.func,
|
|
getSelection: PropTypes.func,
|
|
});
|
|
|
|
export function FindDialog({editor, show, replace, onClose, selFindVal}) {
|
|
const [findVal, setFindVal] = useState(selFindVal);
|
|
const [replaceVal, setReplaceVal] = useState('');
|
|
const [useRegex, setUseRegex] = useState(false);
|
|
const [matchCase, setMatchCase] = useState(false);
|
|
const findInputRef = useRef();
|
|
const highlightsearch = useRef();
|
|
const searchCursor = useRef();
|
|
const classes = useStyles();
|
|
|
|
const search = ()=>{
|
|
if(editor) {
|
|
let query = parseQuery(findVal, useRegex, matchCase);
|
|
if(!query) return;
|
|
|
|
searchCursor.current = editor.getSearchCursor(query, 0, !matchCase);
|
|
if(findVal != '') {
|
|
editor.removeOverlay(highlightsearch.current);
|
|
highlightsearch.current = searchOverlay(query, matchCase);
|
|
editor.addOverlay(highlightsearch.current);
|
|
onFindNext();
|
|
} else {
|
|
editor.removeOverlay(highlightsearch.current);
|
|
}
|
|
}
|
|
};
|
|
|
|
useEffect(()=>{
|
|
if(show) {
|
|
// Get selected text from editor and set it to find/replace input.
|
|
let selText = editor.getSelection();
|
|
if(selText.length != 0) {
|
|
setFindVal(selText);
|
|
}
|
|
findInputRef.current && findInputRef.current.select();
|
|
search();
|
|
}
|
|
}, [show]);
|
|
|
|
useEffect(()=>{
|
|
search();
|
|
}, [findVal, useRegex, matchCase]);
|
|
|
|
const clearAndClose = ()=>{
|
|
editor.removeOverlay(highlightsearch.current);
|
|
onClose();
|
|
};
|
|
|
|
const toggle = (name)=>{
|
|
if(name == 'regex') {
|
|
setUseRegex((prev)=>!prev);
|
|
} else if(name == 'case') {
|
|
setMatchCase((prev)=>!prev);
|
|
}
|
|
};
|
|
|
|
const onFindEnter = (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
if(e.shiftKey) {
|
|
onFindPrev();
|
|
} else {
|
|
onFindNext();
|
|
}
|
|
}
|
|
};
|
|
|
|
const onReplaceEnter = (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
onReplace();
|
|
}
|
|
};
|
|
|
|
const onEscape = (e)=>{
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
clearAndClose();
|
|
}
|
|
};
|
|
|
|
const onFindNext = ()=>{
|
|
if(searchCursor.current && searchCursor.current.find()) {
|
|
editor.setSelection(searchCursor.current.from(), searchCursor.current.to());
|
|
editor.scrollIntoView({
|
|
from: searchCursor.current.from(),
|
|
to: searchCursor.current.to()
|
|
}, 20);
|
|
}
|
|
};
|
|
|
|
const onFindPrev = ()=>{
|
|
if(searchCursor.current && searchCursor.current.find(true)) {
|
|
editor.setSelection(searchCursor.current.from(), searchCursor.current.to());
|
|
editor.scrollIntoView({
|
|
from: searchCursor.current.from(),
|
|
to: searchCursor.current.to()
|
|
}, 20);
|
|
}
|
|
};
|
|
|
|
const onReplace = ()=>{
|
|
searchCursor.current.replace(replaceVal);
|
|
onFindNext();
|
|
};
|
|
|
|
const onReplaceAll = ()=>{
|
|
/* search from start */
|
|
search();
|
|
while(searchCursor.current.from()) {
|
|
onReplace();
|
|
}
|
|
};
|
|
|
|
if(!editor) {
|
|
return <></>;
|
|
}
|
|
|
|
return (
|
|
<Box className={classes.findDialog} visibility={show ? 'visible' : 'hidden'} tabIndex="0" onKeyDown={onEscape}>
|
|
<InputText value={findVal}
|
|
inputRef={(ele)=>{findInputRef.current = ele;}}
|
|
onChange={(value)=>setFindVal(value)}
|
|
onKeyPress={onFindEnter}
|
|
endAdornment={
|
|
<InputAdornment position="end">
|
|
<PgIconButton data-test="case" title="Match case" icon={<FormatCaseIcon />} size="xs" noBorder
|
|
onClick={()=>toggle('case')} color={matchCase ? 'primary' : 'default'} style={{marginRight: '2px'}}/>
|
|
<PgIconButton data-test="regex" title="Use regex" icon={<RegexIcon />} size="xs" noBorder
|
|
onClick={()=>toggle('regex')} color={useRegex ? 'primary' : 'default'}/>
|
|
</InputAdornment>
|
|
}
|
|
/>
|
|
{replace &&
|
|
<InputText value={replaceVal}
|
|
className={classes.marginTop}
|
|
onChange={(value)=>setReplaceVal(value)}
|
|
onKeyPress={onReplaceEnter}
|
|
/>}
|
|
|
|
<Box display="flex" className={classes.marginTop}>
|
|
<PgIconButton title={gettext('Previous')} icon={<ArrowUpwardRoundedIcon />} size="xs" noBorder onClick={onFindPrev}
|
|
style={{marginRight: '2px'}} />
|
|
<PgIconButton title={gettext('Next')} icon={<ArrowDownwardRoundedIcon />} size="xs" noBorder onClick={onFindNext}
|
|
style={{marginRight: '2px'}} />
|
|
{replace && <>
|
|
<PgIconButton title={gettext('Replace')} icon={<SwapHorizRoundedIcon style={{height: 'unset'}}/>} size="xs" noBorder onClick={onReplace}
|
|
style={{marginRight: '2px'}} />
|
|
<PgIconButton title={gettext('Replace All')} icon={<SwapCallsRoundedIcon />} size="xs" noBorder onClick={onReplaceAll}/>
|
|
</>}
|
|
<Box marginLeft="auto">
|
|
<PgIconButton title={gettext('Close')} icon={<CloseIcon />} size="xs" noBorder onClick={clearAndClose}/>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
FindDialog.propTypes = {
|
|
editor: CodeMirrorInstancType,
|
|
show: PropTypes.bool,
|
|
replace: PropTypes.bool,
|
|
onClose: PropTypes.func,
|
|
selFindVal: PropTypes.string,
|
|
};
|
|
|
|
export function CopyButton({show, copyText}) {
|
|
const classes = useStyles();
|
|
const [copyBtnLabel, setCopyBtnLabel] = useState(gettext('Copy'));
|
|
const revertCopiedText = useDelayedCaller(()=>{
|
|
setCopyBtnLabel(gettext('Copy'));
|
|
});
|
|
|
|
return (
|
|
<Box className={classes.copyButton} visibility={show ? 'visible' : 'hidden'}>
|
|
<DefaultButton onClick={() => {
|
|
copyToClipboard(copyText);
|
|
setCopyBtnLabel(gettext('Copied!'));
|
|
revertCopiedText(1500);
|
|
}}>{copyBtnLabel}</DefaultButton>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
CopyButton.propTypes = {
|
|
show: PropTypes.bool,
|
|
copyText: PropTypes.string
|
|
};
|
|
|
|
function handleDrop(editor, e) {
|
|
let dropDetails = null;
|
|
try {
|
|
dropDetails = JSON.parse(e.dataTransfer.getData('text'));
|
|
|
|
/* Stop firefox from redirecting */
|
|
|
|
if(e.preventDefault) {
|
|
e.preventDefault();
|
|
}
|
|
if (e.stopPropagation) {
|
|
e.stopPropagation();
|
|
}
|
|
} catch(error) {
|
|
/* if parsing fails, it must be the drag internal of codemirror text */
|
|
return;
|
|
}
|
|
|
|
let cursor = editor.coordsChar({
|
|
left: e.x,
|
|
top: e.y,
|
|
});
|
|
editor.replaceRange(dropDetails.text, cursor);
|
|
editor.focus();
|
|
editor.setSelection({
|
|
...cursor,
|
|
ch: cursor.ch + dropDetails.cur.from,
|
|
},{
|
|
...cursor,
|
|
ch: cursor.ch +dropDetails.cur.to,
|
|
});
|
|
}
|
|
|
|
function calcFontSize(fontSize) {
|
|
if(fontSize) {
|
|
fontSize = parseFloat((Math.round(parseFloat(fontSize + 'e+2')) + 'e-2'));
|
|
let rounded = Number(fontSize);
|
|
if(rounded > 0) {
|
|
return rounded + 'em';
|
|
}
|
|
}
|
|
return '1em';
|
|
}
|
|
|
|
function handlePaste(_editor, e) {
|
|
let copiedText = e.clipboardData.getData('text');
|
|
checkTrojanSource(copiedText, true);
|
|
}
|
|
|
|
/* React wrapper for CodeMirror */
|
|
export default function CodeMirror({currEditor, name, value, options, events, readonly, disabled, className, autocomplete=false, gutters=['CodeMirror-linenumbers', 'CodeMirror-foldgutter'], showCopyBtn=false}) {
|
|
const taRef = useRef();
|
|
const editor = useRef();
|
|
const cmWrapper = useRef();
|
|
const isVisibleTrack = useRef();
|
|
const classes = useStyles();
|
|
const [[showFind, isReplace], setShowFind] = useState([false, false]);
|
|
const [showCopy, setShowCopy] = useState(false);
|
|
const defaultOptions = useMemo(()=>{
|
|
let goLeftKey = 'Ctrl-Alt-Left',
|
|
goRightKey = 'Ctrl-Alt-Right',
|
|
commentKey = 'Ctrl-/';
|
|
if(isMac()) {
|
|
goLeftKey = 'Cmd-Alt-Left';
|
|
goRightKey = 'Cmd-Alt-Right';
|
|
commentKey = 'Cmd-/';
|
|
}
|
|
return {
|
|
tabindex: '0',
|
|
lineNumbers: true,
|
|
styleSelectedText: true,
|
|
mode: 'text/x-pgsql',
|
|
foldOptions: {
|
|
widget: '\u2026',
|
|
},
|
|
foldGutter: true,
|
|
gutters: gutters,
|
|
extraKeys: {
|
|
// Autocomplete sql command
|
|
...(autocomplete ? {
|
|
'Ctrl-Space': 'autocomplete',
|
|
}: {}),
|
|
'Alt-Up': 'goLineUp',
|
|
'Alt-Down': 'goLineDown',
|
|
// Move word by word left/right
|
|
[goLeftKey]: 'goGroupLeft',
|
|
[goRightKey]: 'goGroupRight',
|
|
// Allow user to delete Tab(s)
|
|
'Shift-Tab': 'indentLess',
|
|
//comment
|
|
[commentKey]: 'toggleComment',
|
|
},
|
|
dragDrop: true,
|
|
screenReaderLabel: gettext('SQL editor'),
|
|
};
|
|
});
|
|
|
|
useEffect(()=>{
|
|
const finalOptions = {...defaultOptions, ...options};
|
|
/* Create the object only once on mount */
|
|
editor.current = new OrigCodeMirror.fromTextArea(
|
|
taRef.current, finalOptions);
|
|
|
|
if(!_.isEmpty(value)) {
|
|
editor.current.setValue(value);
|
|
} else {
|
|
editor.current.setValue('');
|
|
}
|
|
currEditor && currEditor(editor.current);
|
|
if(editor.current) {
|
|
try {
|
|
cmWrapper.current = editor.current.getWrapperElement();
|
|
} catch(e) {
|
|
cmWrapper.current = null;
|
|
}
|
|
|
|
let findKey = 'Ctrl-F', replaceKey = 'Shift-Ctrl-F';
|
|
if(isMac()) {
|
|
findKey = 'Cmd-F';
|
|
replaceKey = 'Cmd-Alt-F';
|
|
}
|
|
editor.current.addKeyMap({
|
|
[findKey]: ()=>{
|
|
setShowFind([false, false]);
|
|
setShowFind([true, false]);
|
|
},
|
|
[replaceKey]: ()=>{
|
|
if(!finalOptions.readOnly) {
|
|
setShowFind([false, false]);
|
|
setShowFind([true, true]);
|
|
}
|
|
},
|
|
'Cmd-G': false,
|
|
});
|
|
}
|
|
|
|
Object.keys(events||{}).forEach((eventName)=>{
|
|
editor.current.on(eventName, events[eventName]);
|
|
});
|
|
|
|
// Register keyup event if autocomplete is true
|
|
let pref = pgWindow?.pgAdmin?.Browser?.get_preferences_for_module('sqleditor') || {};
|
|
if (autocomplete && pref.autocomplete_on_key_press) {
|
|
editor.current.on('keyup', (cm, event)=>{
|
|
if (!cm.state.completionActive && (event.key == 'Backspace' || /^[ -~]{1}$/.test(event.key))) {
|
|
OrigCodeMirror.commands.autocomplete(cm, null, {completeSingle: false});
|
|
}
|
|
});
|
|
}
|
|
|
|
editor.current.on('drop', handleDrop);
|
|
editor.current.on('paste', handlePaste);
|
|
initPreferences();
|
|
return ()=>{
|
|
editor.current?.toTextArea();
|
|
};
|
|
}, []);
|
|
|
|
|
|
|
|
const initPreferences = ()=>{
|
|
reflectPreferences();
|
|
pgWindow?.pgAdmin?.Browser?.onPreferencesChange('sqleditor', function() {
|
|
reflectPreferences();
|
|
});
|
|
};
|
|
|
|
const reflectPreferences = ()=>{
|
|
let pref = pgWindow?.pgAdmin?.Browser?.get_preferences_for_module('sqleditor') || {};
|
|
let wrapEle = editor?.current.getWrapperElement();
|
|
wrapEle && (wrapEle.style.fontSize = calcFontSize(pref.sql_font_size));
|
|
|
|
if(pref.plain_editor_mode) {
|
|
editor?.current.setOption('mode', 'text/plain');
|
|
/* Although not required, setting explicitly as codemirror will remove code folding only on next edit */
|
|
editor?.current.setOption('foldGutter', false);
|
|
} else {
|
|
editor?.current.setOption('mode', 'text/x-pgsql');
|
|
editor?.current.setOption('foldGutter', pref.code_folding);
|
|
}
|
|
|
|
editor?.current.setOption('indentWithTabs', !pref.use_spaces);
|
|
editor?.current.setOption('indentUnit', pref.tab_size);
|
|
editor?.current.setOption('tabSize', pref.tab_size);
|
|
editor?.current.setOption('lineWrapping', pref.wrap_code);
|
|
editor?.current.setOption('autoCloseBrackets', pref.insert_pair_brackets);
|
|
editor?.current.setOption('matchBrackets', pref.brace_matching);
|
|
editor?.current.refresh();
|
|
};
|
|
|
|
useEffect(()=>{
|
|
if(editor.current) {
|
|
if(readonly || disabled) {
|
|
editor.current.setOption('readOnly', true);
|
|
editor.current.addKeyMap({'Tab': false});
|
|
editor.current.addKeyMap({'Shift-Tab': false});
|
|
cmWrapper.current.classList.add(classes.hideCursor);
|
|
} else {
|
|
editor.current.setOption('readOnly', false);
|
|
editor.current.removeKeyMap('Tab');
|
|
editor.current.removeKeyMap('Shift-Tab');
|
|
cmWrapper.current.classList.remove(classes.hideCursor);
|
|
}
|
|
}
|
|
}, [readonly, disabled]);
|
|
|
|
useMemo(() => {
|
|
if(editor.current) {
|
|
if(value != editor.current.getValue()) {
|
|
if(!_.isEmpty(value)) {
|
|
editor.current.setValue(value);
|
|
} else {
|
|
editor.current.setValue('');
|
|
}
|
|
}
|
|
}
|
|
}, [value]);
|
|
|
|
const onScreenVisible = useOnScreen(cmWrapper);
|
|
if(!isVisibleTrack.current && onScreenVisible) {
|
|
isVisibleTrack.current = true;
|
|
editor.current?.refresh();
|
|
} else if(!onScreenVisible) {
|
|
isVisibleTrack.current = false;
|
|
}
|
|
|
|
const closeFind = ()=>{
|
|
setShowFind([false, false]);
|
|
editor.current?.focus();
|
|
};
|
|
|
|
return (
|
|
<div className={clsx(className, classes.root)}
|
|
onMouseEnter={() => { showCopyBtn && value.length > 0 && setShowCopy(true);}}
|
|
onMouseLeave={() => {showCopyBtn && setShowCopy(false);}}
|
|
>
|
|
<FindDialog editor={editor.current} show={showFind} replace={isReplace} onClose={closeFind} selFindVal={editor.current?.getSelection() && editor.current.getSelection().length > 0 ? editor.current.getSelection() : ''}/>
|
|
<CopyButton editor={editor.current} show={showCopy} copyText={value}></CopyButton>
|
|
<textarea ref={taRef} name={name} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
CodeMirror.propTypes = {
|
|
currEditor: PropTypes.func,
|
|
name: PropTypes.string,
|
|
value: PropTypes.string,
|
|
options: PropTypes.object,
|
|
change: PropTypes.func,
|
|
events: PropTypes.object,
|
|
readonly: PropTypes.bool,
|
|
disabled: PropTypes.bool,
|
|
className: CustomPropTypes.className,
|
|
autocomplete: PropTypes.bool,
|
|
gutters: PropTypes.array,
|
|
showCopyBtn: PropTypes.bool,
|
|
};
|