diff --git a/docs/en_US/images/query_status_bar.png b/docs/en_US/images/query_status_bar.png index e885920ba97..fdc2d610023 100644 Binary files a/docs/en_US/images/query_status_bar.png and b/docs/en_US/images/query_status_bar.png differ diff --git a/docs/en_US/query_tool_toolbar.rst b/docs/en_US/query_tool_toolbar.rst index 42db9874a74..ccafd633462 100644 --- a/docs/en_US/query_tool_toolbar.rst +++ b/docs/en_US/query_tool_toolbar.rst @@ -226,6 +226,8 @@ The status bar shows the following information: * **Total rows**: The total number of rows returned by the query. * **Query complete**: The time is taken by the query to complete. * **Rows selected**: The number of rows selected in the data output panel. -* **Changes staged**: This information showed the number of rows added, deleted, and updated. +* **Changes staged**: This information shows the number of rows added, deleted, and updated. +* **LF/CRLF**: It shows the end of line sequence to be used for the editor. When opening an empty editor, it will be decided based on OS. + And when opening an existing file, it will be based on file end of lines. One can change the EOL by clicking on any of the options. * **Ln**: In the Query tab, it is the line number at which the cursor is positioned. * **Col**: In the Query tab, it is the column number at which the cursor is positioned diff --git a/web/pgadmin/static/js/components/Menu.jsx b/web/pgadmin/static/js/components/Menu.jsx index 49536289634..12d0f4db152 100644 --- a/web/pgadmin/static/js/components/Menu.jsx +++ b/web/pgadmin/static/js/components/Menu.jsx @@ -91,8 +91,9 @@ export function usePgMenuGroup() { const prevMenuOpenIdRef = useRef(null); const toggleMenu = React.useCallback((e)=>{ + const name = e.currentTarget?.getAttribute('name') || e.currentTarget?.name; setOpenMenuName(()=>{ - return prevMenuOpenIdRef.current == e.currentTarget?.name ? null : e.currentTarget?.name; + return prevMenuOpenIdRef.current == name ? null : name; }); prevMenuOpenIdRef.current = null; }, []); diff --git a/web/pgadmin/static/js/components/ReactCodeMirror/CustomEditorView.js b/web/pgadmin/static/js/components/ReactCodeMirror/CustomEditorView.js index 6801b44a77c..b48f77a92d3 100644 --- a/web/pgadmin/static/js/components/ReactCodeMirror/CustomEditorView.js +++ b/web/pgadmin/static/js/components/ReactCodeMirror/CustomEditorView.js @@ -9,7 +9,7 @@ import { errorMarkerEffect } from './extensions/errorMarker'; import { currentQueryHighlighterEffect } from './extensions/currentQueryHighlighter'; import { activeLineEffect, activeLineField } from './extensions/activeLineMarker'; import { clearBreakpoints, hasBreakpoint, toggleBreakpoint } from './extensions/breakpointGutter'; -import { autoCompleteCompartment } from './extensions/extraStates'; +import { autoCompleteCompartment, eol, eolCompartment } from './extensions/extraStates'; function getAutocompLoading({ bottom, left }, dom) { @@ -30,11 +30,13 @@ export default class CustomEditorView extends EditorView { this._cleanDoc = this.state.doc; } - getValue(tillCursor=false) { + getValue(tillCursor=false, useLineSep=false) { if(tillCursor) { return this.state.sliceDoc(0, this.state.selection.main.head); + } else if (useLineSep) { + return this.state.doc.sliceString(0, this.state.doc.length, this.getEOL()); } - return this.state.doc.toString(); + return this.state.sliceDoc(); } /* Function to extract query based on position passed */ @@ -328,4 +330,14 @@ export default class CustomEditorView extends EditorView { setQueryHighlightMark(from,to) { this.dispatch({ effects: currentQueryHighlighterEffect.of({ from, to }) }); } + + getEOL(){ + return this.state.facet(eol); + } + + setEOL(val){ + this.dispatch({ + effects: eolCompartment.reconfigure(eol.of(val)) + }); + } } diff --git a/web/pgadmin/static/js/components/ReactCodeMirror/components/Editor.jsx b/web/pgadmin/static/js/components/ReactCodeMirror/components/Editor.jsx index f7c22f74484..1abd7be430b 100644 --- a/web/pgadmin/static/js/components/ReactCodeMirror/components/Editor.jsx +++ b/web/pgadmin/static/js/components/ReactCodeMirror/components/Editor.jsx @@ -48,7 +48,8 @@ import CustomEditorView from '../CustomEditorView'; import breakpointGutter, { breakpointEffect } from '../extensions/breakpointGutter'; import activeLineExtn from '../extensions/activeLineMarker'; import currentQueryHighlighterExtn from '../extensions/currentQueryHighlighter'; -import { autoCompleteCompartment, indentNewLine } from '../extensions/extraStates'; +import { autoCompleteCompartment, eolCompartment, indentNewLine, eol } from '../extensions/extraStates'; +import { OS_EOL } from '../../../../../tools/sqleditor/static/js/components/QueryToolConstants'; const arrowRightHtml = ReactDOMServer.renderToString(); const arrowDownHtml = ReactDOMServer.renderToString(); @@ -144,6 +145,10 @@ const defaultExtensions = [ return 0; }), autoCompleteCompartment.of([]), + EditorView.clipboardOutputFilter.of((text, state)=>{ + const lineSep = state.facet(eol); + return state.doc.sliceString(0, text.length, lineSep); + }) ]; export default function Editor({ @@ -167,6 +172,7 @@ export default function Editor({ useEffect(() => { const finalOptions = { ...defaultOptions, ...options }; + const osEOL = OS_EOL === 'crlf' ? '\r\n' : '\n'; const finalExtns = [ (language == 'json') ? json() : sql({dialect: PgSQL}), ...defaultExtensions, @@ -191,6 +197,7 @@ export default function Editor({ const state = EditorState.create({ extensions: [ ...finalExtns, + eolCompartment.of([eol.of(osEOL)]), shortcuts.current.of([]), configurables.current.of([]), editableConfig.current.of([ diff --git a/web/pgadmin/static/js/components/ReactCodeMirror/extensions/extraStates.js b/web/pgadmin/static/js/components/ReactCodeMirror/extensions/extraStates.js index 5e4c7b433ea..0970c4cddd4 100644 --- a/web/pgadmin/static/js/components/ReactCodeMirror/extensions/extraStates.js +++ b/web/pgadmin/static/js/components/ReactCodeMirror/extensions/extraStates.js @@ -13,4 +13,10 @@ export const indentNewLine = Facet.define({ combine: values => values.length ? values[0] : true, }); +export const eol = Facet.define({ + combine: values => values.length ? values[0] : '\n', +}); + export const autoCompleteCompartment = new Compartment(); +export const eolCompartment = new Compartment(); + diff --git a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx index 321b6e18d78..657a1f3663c 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx @@ -18,7 +18,7 @@ import { MainToolBar } from './sections/MainToolBar'; import { Messages } from './sections/Messages'; import getApiInstance, {callFetch, parseApiError} from '../../../../../static/js/api_instance'; import url_for from 'sources/url_for'; -import { PANELS, QUERY_TOOL_EVENTS, CONNECTION_STATUS, MAX_QUERY_LENGTH } from './QueryToolConstants'; +import { PANELS, QUERY_TOOL_EVENTS, CONNECTION_STATUS, MAX_QUERY_LENGTH, OS_EOL } from './QueryToolConstants'; import { useBeforeUnload, useInterval } from '../../../../../static/js/custom_hooks'; import { Box } from '@mui/material'; import { getDatabaseLabel, getTitle, setQueryToolDockerTitle } from '../sqleditor_title'; @@ -202,7 +202,8 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN database_name: _.unescape(params.database_name) || getDatabaseLabel(selectedNodeInfo), is_selected: true, }], - editor_disabled:true + editor_disabled:true, + eol:OS_EOL }); const [selectedText, setSelectedText] = useState(''); @@ -262,7 +263,7 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN { maximizable: true, tabs: [ - LayoutDocker.getPanel({id: PANELS.QUERY, title: gettext('Query'), content: setSelectedText(text)}/>}), + LayoutDocker.getPanel({id: PANELS.QUERY, title: gettext('Query'), content: setSelectedText(text)} handleEndOfLineChange={handleEndOfLineChange}/>}), LayoutDocker.getPanel({id: PANELS.HISTORY, title: gettext('Query History'), content: , cached: undefined}), ], @@ -308,6 +309,13 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN }, }; + const handleEndOfLineChange = useCallback((e)=>{ + const val = e.value || e; + const lineSep = val === 'crlf' ? '\r\n' : '\n'; + setQtStatePartial({ eol: val }); + eventBus.current.fireEvent(QUERY_TOOL_EVENTS.CHANGE_EOL, lineSep); + }, []); + const getSQLScript = () => { // Fetch the SQL for Scripts (eg: CREATE/UPDATE/DELETE/SELECT) // Call AJAX only if script type URL is present @@ -869,6 +877,7 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN preferences: qtState.preferences, mainContainerRef: containerRef, editor_disabled: qtState.editor_disabled, + eol: qtState.eol, toggleQueryTool: () => setQtStatePartial((prev)=>{ return { ...prev, @@ -899,7 +908,7 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN }; }); }, - }), [qtState.params, qtState.preferences, containerRef.current, qtState.editor_disabled]); + }), [qtState.params, qtState.preferences, containerRef.current, qtState.editor_disabled, qtState.eol]); const queryToolConnContextValue = React.useMemo(()=>({ connected: qtState.connected, @@ -940,7 +949,7 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN savedLayout={params.layout} resetToTabPanel={PANELS.MESSAGES} /> - + diff --git a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js index a1c3825679e..ddbeb91ec96 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js +++ b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js @@ -75,6 +75,7 @@ export const QUERY_TOOL_EVENTS = { RESET_GRAPH_VISUALISER: 'RESET_GRAPH_VISUALISER', GOTO_LAST_SCROLL: 'GOTO_LAST_SCROLL', + CHANGE_EOL: 'CHANGE_EOL' }; export const CONNECTION_STATUS = { @@ -105,4 +106,6 @@ export const PANELS = { GRAPH_VISUALISER: 'id-graph-visualiser', }; -export const MAX_QUERY_LENGTH = 1000000; \ No newline at end of file +export const MAX_QUERY_LENGTH = 1000000; + +export const OS_EOL = navigator.platform === 'win32' ? 'crlf' : 'lf'; \ No newline at end of file diff --git a/web/pgadmin/tools/sqleditor/static/js/components/sections/Query.jsx b/web/pgadmin/tools/sqleditor/static/js/components/sections/Query.jsx index 178c011ad99..c619a47887d 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/sections/Query.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/Query.jsx @@ -56,7 +56,7 @@ async function registerAutocomplete(editor, api, transId) { }); } -export default function Query({onTextSelect}) { +export default function Query({onTextSelect, handleEndOfLineChange}) { const editor = React.useRef(); const eventBus = useContext(QueryToolEventsContext); const queryToolCtx = useContext(QueryToolContext); @@ -65,7 +65,6 @@ export default function Query({onTextSelect}) { const pgAdmin = usePgAdmin(); const preferencesStore = usePreferences(); const queryToolPref = queryToolCtx.preferences.sqleditor; - const highlightError = (cmObj, {errormsg: result, data}, executeCursor)=>{ let errorLineNo = 0, startMarker = 0, @@ -175,7 +174,6 @@ export default function Query({onTextSelect}) { } }); - eventBus.registerListener(QUERY_TOOL_EVENTS.LOAD_FILE, (fileName, storage)=>{ queryToolCtx.api.post(url_for('sqleditor.load_file'), { 'file_name': decodeURI(fileName), @@ -191,6 +189,8 @@ export default function Query({onTextSelect}) { checkTrojanSource(res.data); editor.current.markClean(); eventBus.fireEvent(QUERY_TOOL_EVENTS.LOAD_FILE_DONE, fileName, true); + const lineSep = res.data.includes('\r\n') ? 'crlf' : 'lf'; + handleEndOfLineChange(lineSep); }).catch((err)=>{ eventBus.fireEvent(QUERY_TOOL_EVENTS.LOAD_FILE_DONE, null, false); pgAdmin.Browser.notifier.error(parseApiError(err)); @@ -200,7 +200,7 @@ export default function Query({onTextSelect}) { eventBus.registerListener(QUERY_TOOL_EVENTS.SAVE_FILE, (fileName)=>{ queryToolCtx.api.post(url_for('sqleditor.save_file'), { 'file_name': decodeURI(fileName), - 'file_content': editor.current.getValue(), + 'file_content': editor.current.getValue(false, true), }).then(()=>{ editor.current.markClean(); eventBus.fireEvent(QUERY_TOOL_EVENTS.SAVE_FILE_DONE, fileName, true); @@ -288,6 +288,12 @@ export default function Query({onTextSelect}) { editor.current.setValue(formattedSql); } }); + + eventBus.registerListener(QUERY_TOOL_EVENTS.CHANGE_EOL, (lineSep)=>{ + editor.current?.setEOL(lineSep); + eventBus.fireEvent(QUERY_TOOL_EVENTS.QUERY_CHANGED, true); + }); + eventBus.registerListener(QUERY_TOOL_EVENTS.EDITOR_TOGGLE_CASE, ()=>{ let selectedText = editor.current?.getSelection(); if (!selectedText) return; @@ -518,4 +524,5 @@ export default function Query({onTextSelect}) { Query.propTypes = { onTextSelect: PropTypes.func, + handleEndOfLineChange: PropTypes.func }; diff --git a/web/pgadmin/tools/sqleditor/static/js/components/sections/StatusBar.jsx b/web/pgadmin/tools/sqleditor/static/js/components/sections/StatusBar.jsx index c4dbf06cc7b..79a7324a3d0 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/sections/StatusBar.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/StatusBar.jsx @@ -9,12 +9,13 @@ ////////////////////////////////////////////////////////////// import React, { useEffect, useState, useContext } from 'react'; import { styled } from '@mui/material/styles'; -import { Box } from '@mui/material'; +import { Box, Tooltip } from '@mui/material'; import _ from 'lodash'; import { QUERY_TOOL_EVENTS } from '../QueryToolConstants'; import { useStopwatch } from '../../../../../../static/js/custom_hooks'; import { QueryToolEventsContext } from '../QueryToolComponent'; import gettext from 'sources/gettext'; +import { PgMenu, PgMenuItem, usePgMenuGroup } from '../../../../../../static/js/components/Menu'; const StyledBox = styled(Box)(({theme}) => ({ @@ -26,17 +27,17 @@ const StyledBox = styled(Box)(({theme}) => ({ userSelect: 'text', '& .StatusBar-padding': { padding: '2px 12px', - '& .StatusBar-mlAuto': { + '&.StatusBar-mlAuto': { marginLeft: 'auto', }, - '& .StatusBar-divider': { + '&.StatusBar-divider': { ...theme.mixins.panelBorder.right, }, }, })); -export function StatusBar() { +export function StatusBar({eol, handleEndOfLineChange}) { const eventBus = useContext(QueryToolEventsContext); const [position, setPosition] = useState([1, 1]); const [lastTaskText, setLastTaskText] = useState(null); @@ -49,6 +50,8 @@ export function StatusBar() { deleted: 0, }); const {seconds, minutes, hours, msec, start:startTimer, pause:pauseTimer, reset:resetTimer} = useStopwatch({}); + const eolMenuRef = React.useRef(null); + const {openMenuName, toggleMenu, onMenuClose} = usePgMenuGroup(); useEffect(()=>{ eventBus.registerListener(QUERY_TOOL_EVENTS.CURSOR_ACTIVITY, (newPos)=>{ @@ -109,7 +112,36 @@ export function StatusBar() { {gettext('Changes staged: %s', stagedText)} } - {gettext('Ln %s, Col %s', position[0], position[1])} + + + + + + {eol.toUpperCase()} + + + + {gettext('LF')} + {gettext('CRLF')} + + + {gettext('Ln %s, Col %s', position[0], position[1])} + ); } diff --git a/web/regression/feature_tests/view_data_dml_queries.py b/web/regression/feature_tests/view_data_dml_queries.py index f05d4256faa..1d0916638ae 100644 --- a/web/regression/feature_tests/view_data_dml_queries.py +++ b/web/regression/feature_tests/view_data_dml_queries.py @@ -329,6 +329,11 @@ def _update_boolean_cell(self, value): def _view_data_grid(self, table_name): self.page.driver.find_element(By.CSS_SELECTOR, NavMenuLocators.object_menu_css).click() + self.wait.until( + EC.visibility_of_element_located( + (By.CSS_SELECTOR, NavMenuLocators.view_data_link_css) + ), CheckForViewDataTest.TIMEOUT_STRING + ) ActionChains( self.page.driver ).move_to_element(