diff --git a/web/pgadmin/static/js/Dialogs/ConfirmFileDropContent.jsx b/web/pgadmin/static/js/Dialogs/ConfirmFileDropContent.jsx new file mode 100644 index 00000000000..82a5282565a --- /dev/null +++ b/web/pgadmin/static/js/Dialogs/ConfirmFileDropContent.jsx @@ -0,0 +1,46 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import { ModalContent, ModalFooter } from '../components/ModalContent'; +import gettext from 'sources/gettext'; +import { Box } from '@mui/material'; +import { DefaultButton, PrimaryButton } from '../components/Buttons'; +import CloseIcon from '@mui/icons-material/CloseRounded'; +import MergeTypeRoundedIcon from '@mui/icons-material/MergeTypeRounded'; +import FindReplaceRoundedIcon from '@mui/icons-material/FindReplaceRounded'; +import PropTypes from 'prop-types'; + +export default function ConfirmFileDropContent({closeModal, onMerge, onReplace}) { + return ( + + + {gettext('The editor already has content. Do you want to replace it with the dropped file, or merge the file content at the current cursor position?')} + + + } onClick={()=>{ + closeModal(); + }} >{gettext('Cancel')} + } onClick={()=>{ + onMerge?.(); + closeModal(); + }} >{gettext('Merge')} + } onClick={()=>{ + onReplace?.(); + closeModal(); + }} autoFocus={true} >{gettext('Replace')} + + + ); +} + +ConfirmFileDropContent.propTypes = { + closeModal: PropTypes.func, + onMerge: PropTypes.func, + onReplace: PropTypes.func, +}; diff --git a/web/pgadmin/static/js/components/ReactCodeMirror/components/Editor.jsx b/web/pgadmin/static/js/components/ReactCodeMirror/components/Editor.jsx index cc9b54448f6..e73954a919a 100644 --- a/web/pgadmin/static/js/components/ReactCodeMirror/components/Editor.jsx +++ b/web/pgadmin/static/js/components/ReactCodeMirror/components/Editor.jsx @@ -17,6 +17,14 @@ import { checkTrojanSource } from 'sources/utils'; import usePreferences from '../../../../../preferences/static/js/store'; import KeyboardArrowRightRoundedIcon from '@mui/icons-material/KeyboardArrowRightRounded'; import ExpandMoreRoundedIcon from '@mui/icons-material/ExpandMoreRounded'; +import { + handleOsFileDrop, + handleOsFileDragOver, + handleEditorBlur, + handleEditorMouseUp, + handleEditorKeyUp, + rememberCaretPos, +} from '../sqlFileDrop'; // Codemirror packages import { @@ -29,7 +37,7 @@ import { EditorView, keymap, } from '@codemirror/view'; -import { EditorState, Compartment } from '@codemirror/state'; +import { EditorState, Compartment, Prec } from '@codemirror/state'; import { history, defaultKeymap, historyKeymap, indentLess, indentMore, deleteCharBackwardStrict, copyLineDown } from '@codemirror/commands'; import { closeBrackets, autocompletion, closeBracketsKeymap, completionKeymap, acceptCompletion } from '@codemirror/autocomplete'; import { @@ -58,13 +66,30 @@ import plpgsqlFoldService from '../extensions/plpgsqlFoldService'; const arrowRightHtml = ReactDOMServer.renderToString(); const arrowDownHtml = ReactDOMServer.renderToString(); -function handleDrop(e, editor) { +/** Resolve drop insert position from mouse coords (fallback: document end). */ +function getDropPos(editor, event) { + const x = event?.clientX ?? event?.x; + const y = event?.clientY ?? event?.y; + if (typeof x === 'number' && typeof y === 'number') { + const pos = editor.posAtCoords({ x, y }, false); + if (typeof pos === 'number') { + return pos; + } + } + return editor.state.doc.length; +} + +/** Handle OS file drops or tree/object-explorer text drops on the editor. */ +export function handleDrop(e, editor) { + if (handleOsFileDrop(e, editor)) { + return true; + } + let dropDetails = null; try { dropDetails = JSON.parse(e.dataTransfer.getData('text')); /* Stop firefox from redirecting */ - if (e.preventDefault) { e.preventDefault(); } @@ -76,13 +101,20 @@ function handleDrop(e, editor) { return false; } - const dropPos = editor.posAtCoords({ x: e.x, y: e.y }); + const dropPos = getDropPos(editor, e); + const insert = dropDetails.text || ''; + const changes = editor.state.changes({ from: dropPos, to: dropPos, insert }); + const insertAt = changes.mapPos(dropPos, -1); + const anchor = Math.min(insertAt + (dropDetails.cur?.from ?? 0), changes.newLength); + const head = Math.min(insertAt + (dropDetails.cur?.to ?? 0), changes.newLength); editor.dispatch({ - changes: { from: dropPos, to: dropPos, insert: dropDetails.text || '' }, - selection: { anchor: dropPos + dropDetails.cur.from, head: dropPos + dropDetails.cur.to } + changes, + selection: { anchor, head }, + scrollIntoView: true, }); editor.focus(); + return true; } function calcFontSize(fontSize) { @@ -150,10 +182,14 @@ const defaultExtensions = [ PgSQL.language.data.of({ autocomplete: false, }), - EditorView.domEventHandlers({ + Prec.highest(EditorView.domEventHandlers({ drop: handleDrop, paste: handlePaste, - }), + dragover: handleOsFileDragOver, + blur: handleEditorBlur, + mouseup: handleEditorMouseUp, + keyup: handleEditorKeyUp, + })), errorMarkerExtn(), indentService.of((context, pos) => { if(context.state.facet(indentNewLine)) { @@ -215,9 +251,11 @@ export default function Editor({ ].concat(keepHistory ? [history()] : [])), [EditorView.updateListener.of(function(update) { if(update.selectionSet) { + rememberCaretPos(update.view); onCursorActivity?.(update.view.getCursor(), update.view); } if(update.docChanged) { + rememberCaretPos(update.view); onChange?.(update.view.getValue(), update.view); } if(breakpoint) { diff --git a/web/pgadmin/static/js/components/ReactCodeMirror/sqlFileDrop.jsx b/web/pgadmin/static/js/components/ReactCodeMirror/sqlFileDrop.jsx new file mode 100644 index 00000000000..045dadfc08a --- /dev/null +++ b/web/pgadmin/static/js/components/ReactCodeMirror/sqlFileDrop.jsx @@ -0,0 +1,261 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import { checkTrojanSource } from 'sources/utils'; +import gettext from 'sources/gettext'; +import pgAdmin from 'sources/pgadmin'; +import usePreferences from '../../../../preferences/static/js/store'; +import ConfirmFileDropContent from '../../Dialogs/ConfirmFileDropContent'; + +/* Chromium moves the contenteditable caret under the mouse during an OS file + * drag. Track the last user caret per editor and freeze it while dragging. */ +const caretByView = new WeakMap(); +const dragActiveByView = new WeakMap(); + +/** Return whether an OS file drag is active for the view. */ +function isDragActive(view) { + return Boolean(view && dragActiveByView.get(view)); +} + +/** Mark or clear OS file-drag active state for the view. */ +function setDragActive(view, active) { + if (!view) { + return; + } + if (active) { + dragActiveByView.set(view, true); + } else { + dragActiveByView.delete(view); + } +} + +/** Store the view's caret unless an OS file drag is active. */ +export function rememberCaretPos(view) { + if (!view || isDragActive(view)) { + return; + } + caretByView.set(view, view.state.selection.main.head); +} + +/** Resolve a clamped merge insert position from the remembered caret. */ +function getMergePos(editor) { + const docLen = editor.state.doc.length; + const pos = caretByView.has(editor) + ? caretByView.get(editor) + : editor.state.selection.main.head; + if (typeof pos !== 'number' || pos < 0) { + return 0; + } + if (pos > docLen) { + return docLen; + } + return pos; +} + +/** Prevent default and stop propagation for an OS file drag/drop event. */ +function stopOsFileEvent(e) { + if (e.preventDefault) { + e.preventDefault(); + } + if (e.stopPropagation) { + e.stopPropagation(); + } +} + +/** Detect whether a DataTransfer carries OS file drop data. */ +export function hasOsFileDrop(dataTransfer) { + if (!dataTransfer) { + return false; + } + if (dataTransfer.files?.length > 0) { + return true; + } + const items = dataTransfer.items; + if (items?.length) { + for (let i = 0; i < items.length; i++) { + if (items[i].kind === 'file') { + return true; + } + } + } + const types = dataTransfer.types; + if (!types) { + return false; + } + if (typeof types.includes === 'function') { + return types.includes('Files'); + } + if (typeof types.contains === 'function') { + return types.contains('Files'); + } + return Array.from(types).includes('Files'); +} + +/** Return the first File from a DataTransfer, if any. */ +function getDroppedFile(dataTransfer) { + if (dataTransfer.files?.length > 0) { + return dataTransfer.files[0]; + } + const items = dataTransfer.items; + if (items?.length) { + for (let i = 0; i < items.length; i++) { + if (items[i].kind === 'file') { + return items[i].getAsFile(); + } + } + } + return null; +} + +/** Read plain-text payload from a DataTransfer. */ +function getDroppedText(dataTransfer) { + if (!dataTransfer?.getData) { + return ''; + } + return dataTransfer.getData('text/plain') + || dataTransfer.getData('text') + || dataTransfer.getData('Text') + || ''; +} + +/** Replace the editor contents with dropped file text. */ +function applyDroppedFileText(editor, text) { + const value = text ?? ''; + editor.setValue(value); + caretByView.set(editor, editor.state.selection.main.head); + checkTrojanSource(value, true); + editor.focus(); +} + +/** Insert dropped file text at the remembered merge position. */ +function mergeDroppedFileText(editor, text, mergePos) { + const value = text ?? ''; + const docLen = editor.state.doc.length; + const pos = Math.max( + 0, + Math.min(typeof mergePos === 'number' ? mergePos : getMergePos(editor), docLen) + ); + /* Windows CRLF: JS string length > CodeMirror Text length. Use mapPos. */ + const changes = editor.state.changes({ from: pos, to: pos, insert: value }); + const newPos = changes.mapPos(pos, 1); + editor.dispatch({ + changes, + selection: { anchor: newPos, head: newPos }, + scrollIntoView: true, + }); + caretByView.set(editor, newPos); + checkTrojanSource(value, true); + editor.focus(); +} + +/** Read the Query Tool SQL file-drop preference (replace/merge/ask). */ +function getSqlFileDropAction() { + const prefs = usePreferences.getState().getPreferencesForModule('sqleditor'); + return prefs?.sql_file_drop_action || 'replace'; +} + +/** Apply a dropped SQL file using replace, merge, or ask preference. */ +function applyOsFileDrop(editor, text, mergePos) { + const value = text ?? ''; + const editorEmpty = !editor.getValue(); + const action = editorEmpty ? 'replace' : getSqlFileDropAction(); + + if (action === 'merge') { + mergeDroppedFileText(editor, value, mergePos); + return; + } + if (action === 'ask') { + pgAdmin.Browser.notifier.showModal(gettext('Drop SQL file'), (closeModal)=>( + { + mergeDroppedFileText(editor, value, mergePos); + }} + onReplace={()=>{ + applyDroppedFileText(editor, value); + }} + /> + )); + return; + } + applyDroppedFileText(editor, value); +} + +/** Remember caret position when the editor loses focus. */ +export function handleEditorBlur(_e, view) { + rememberCaretPos(view); + return false; +} + +/** Clear drag-active state and remember caret after mouseup. */ +export function handleEditorMouseUp(_e, view) { + setDragActive(view, false); + rememberCaretPos(view); + return false; +} + +/** Clear drag-active state and remember caret after keyup. */ +export function handleEditorKeyUp(_e, view) { + setDragActive(view, false); + rememberCaretPos(view); + return false; +} + +/** Accept OS file dragover and freeze caret updates while dragging. */ +export function handleOsFileDragOver(e, view) { + if (!hasOsFileDrop(e.dataTransfer)) { + return false; + } + if (e.preventDefault) { + e.preventDefault(); + } + try { + e.dataTransfer.dropEffect = 'copy'; + } catch { + /* dropEffect is not always writable */ + } + setDragActive(view, true); + return true; +} + +/** + * Handle an OS file drop on the editor. + * @returns {boolean} true when the event was an OS file drop (handled or ignored). + */ +export function handleOsFileDrop(e, editor) { + if (!hasOsFileDrop(e.dataTransfer)) { + return false; + } + stopOsFileEvent(e); + if (editor.state.readOnly) { + setDragActive(editor, false); + return true; + } + const mergePos = getMergePos(editor); + setDragActive(editor, false); + const file = getDroppedFile(e.dataTransfer); + if (file) { + const reader = new FileReader(); + reader.onload = () => { + applyOsFileDrop(editor, reader.result ?? '', mergePos); + }; + reader.onerror = () => { + setDragActive(editor, false); + }; + reader.readAsText(file); + } else { + const text = getDroppedText(e.dataTransfer); + if (!text) { + /* File-only transfer with no readable File/text — leave SQL unchanged. */ + return true; + } + applyOsFileDrop(editor, text, mergePos); + } + return true; +} diff --git a/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py b/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py index 8521c94a649..e3f20531d06 100644 --- a/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py +++ b/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py @@ -219,6 +219,27 @@ def register_query_tool_preferences(self): ) ) + self.sql_file_drop_action = self.preference.register( + 'Options', 'sql_file_drop_action', + gettext("On SQL file drop"), 'options', 'replace', + category_label=PREF_LABEL_OPTIONS, + options=[{'label': gettext('Always replace content'), + 'value': 'replace'}, + {'label': gettext('Always merge content'), + 'value': 'merge'}, + {'label': gettext('Ask'), 'value': 'ask'}], + control_props={ + 'allowClear': False, + 'tags': False + }, + help_str=gettext( + 'Controls what happens when a SQL file is dropped onto the ' + 'Query Tool editor. Replace clears the editor and loads the ' + 'file. Merge inserts the file content at the current cursor ' + 'position. Ask prompts when the editor already has content.' + ) + ) + self.view_edit_promotion_warning = self.preference.register( 'Options', 'view_edit_promotion_warning', gettext("Show View/Edit Data Promotion Warning?"), diff --git a/web/regression/javascript/components/CodeMirrorCustomEditor.spec.js b/web/regression/javascript/components/CodeMirrorCustomEditor.spec.js index 7444055ef88..bf8dedeb079 100755 --- a/web/regression/javascript/components/CodeMirrorCustomEditor.spec.js +++ b/web/regression/javascript/components/CodeMirrorCustomEditor.spec.js @@ -11,9 +11,17 @@ import { withTheme } from '../fake_theme'; import CodeMirror from 'sources/components/ReactCodeMirror'; import { syntaxTree } from '@codemirror/language'; +import { handleDrop } from 'sources/components/ReactCodeMirror/components/Editor'; +import { + handleOsFileDragOver, + handleEditorMouseUp, +} from 'sources/components/ReactCodeMirror/sqlFileDrop'; import { runScopeHandlers } from '@codemirror/view'; +import usePreferences from '../../../pgadmin/preferences/static/js/store'; +import pgAdmin from 'sources/pgadmin'; +import fakePgAdmin from '../fake_pgadmin'; -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; describe('CodeMirrorCustomEditorView', ()=>{ const ThemedCM = withTheme(CodeMirror); @@ -384,4 +392,286 @@ describe('CodeMirrorCustomEditorView', ()=>{ expect(editor.state.selection.main.head).toBe(3); }); }); + describe('SQL file drop preferences', ()=>{ + let getPrefsSpy; + + beforeEach(()=>{ + getPrefsSpy = jest.spyOn(usePreferences.getState(), 'getPreferencesForModule'); + getPrefsSpy.mockReturnValue({ sql_file_drop_action: 'replace' }); + pgAdmin.Browser = fakePgAdmin.Browser; + pgAdmin.Browser.notifier.showModal = jest.fn(); + }); + + afterEach(()=>{ + getPrefsSpy?.mockRestore(); + }); + + it('replaces editor content when a file is dropped', async ()=>{ + cmRerender({value: 'select * from old_query;'}); + const fileContent = 'select * from dropped_file;'; + const file = new File([fileContent], 'test.sql', { type: 'text/plain' }); + const event = { + dataTransfer: { + files: [file], + getData: jest.fn().mockReturnValue(''), + }, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + + expect(handleDrop(event, editor)).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + + await waitFor(() => { + expect(editor.getValue()).toBe(fileContent); + }); + }); + + it('replaces editor content when Files type is present without a FileList', ()=>{ + cmRerender({value: 'select * from old_query;'}); + const fileContent = 'select * from dropped_file;'; + const event = { + dataTransfer: { + files: [], + types: ['Files', 'text/plain'], + getData: jest.fn().mockReturnValue(fileContent), + }, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + + expect(handleDrop(event, editor)).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect(editor.getValue()).toBe(fileContent); + }); + + it('leaves editor unchanged when Files type has no File and no text payload', ()=>{ + const existing = 'select * from old_query;'; + cmRerender({value: existing}); + const event = { + dataTransfer: { + files: [], + types: ['Files'], + getData: jest.fn().mockReturnValue(''), + }, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + + expect(handleDrop(event, editor)).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect(editor.getValue()).toBe(existing); + }); + + it('merges file content at the active caret when preference is merge', ()=>{ + getPrefsSpy.mockReturnValue({ sql_file_drop_action: 'merge' }); + cmRerender({value: 'select from t;'}); + editor.dispatch({ selection: { anchor: 7, head: 7 } }); + handleEditorMouseUp({}, editor); + const fileContent = 'public.actor'; + const event = { + dataTransfer: { + files: [], + types: ['Files', 'text/plain'], + getData: jest.fn().mockReturnValue(fileContent), + }, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + + expect(handleDrop(event, editor)).toBe(true); + expect(editor.getValue()).toBe('select public.actor from t;'); + expect(pgAdmin.Browser.notifier.showModal).not.toHaveBeenCalled(); + }); + + it('merges at the clicked caret even if drag moved the selection to the start', ()=>{ + getPrefsSpy.mockReturnValue({ sql_file_drop_action: 'merge' }); + cmRerender({value: 'select from t;'}); + editor.dispatch({ selection: { anchor: 7, head: 7 } }); + handleEditorMouseUp({}, editor); + /* Chromium moves the contenteditable caret under the mouse while dragging. */ + handleOsFileDragOver({ + dataTransfer: { types: ['Files'], files: [] }, + preventDefault: jest.fn(), + }, editor); + editor.dispatch({ selection: { anchor: 0, head: 0 } }); + const fileContent = 'public.actor'; + expect(handleDrop({ + dataTransfer: { + files: [], + types: ['Files', 'text/plain'], + getData: jest.fn().mockReturnValue(fileContent), + }, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }, editor)).toBe(true); + expect(editor.getValue()).toBe('select public.actor from t;'); + }); + + it('merges file content at caret near the end of the document', ()=>{ + getPrefsSpy.mockReturnValue({ sql_file_drop_action: 'merge' }); + cmRerender({value: 'select 1;'}); + const endPos = editor.state.doc.length; + editor.dispatch({ selection: { anchor: endPos, head: endPos } }); + handleEditorMouseUp({}, editor); + const fileContent = '\nselect 2;'; + const event = { + dataTransfer: { + files: [], + types: ['Files', 'text/plain'], + getData: jest.fn().mockReturnValue(fileContent), + }, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + + expect(handleDrop(event, editor)).toBe(true); + expect(editor.getValue()).toBe('select 1;' + fileContent); + + editor.dispatch({ selection: { anchor: 7, head: 7 } }); + handleEditorMouseUp({}, editor); + const secondEvent = { + dataTransfer: { + files: [], + types: ['Files', 'text/plain'], + getData: jest.fn().mockReturnValue('x'), + }, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + expect(handleDrop(secondEvent, editor)).toBe(true); + expect(editor.getValue()).toBe('select x1;' + fileContent); + }); + + it('merges Windows CRLF file content at end without selection RangeError', ()=>{ + getPrefsSpy.mockReturnValue({ sql_file_drop_action: 'merge' }); + cmRerender({value: 'select 1;'}); + const endPos = editor.state.doc.length; + editor.dispatch({ selection: { anchor: endPos, head: endPos } }); + handleEditorMouseUp({}, editor); + /* CRLF string length > CodeMirror Text length — old code used + * pos + value.length and threw "Selection points outside of document". */ + const fileContent = '\r\nselect 2;\r\nselect 3;'; + expect(handleDrop({ + dataTransfer: { + files: [], + types: ['Files', 'text/plain'], + getData: jest.fn().mockReturnValue(fileContent), + }, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }, editor)).toBe(true); + expect(editor.getValue()).toContain('select 1;'); + expect(editor.getValue()).toContain('select 2;'); + expect(editor.getValue()).toContain('select 3;'); + expect(editor.state.selection.main.head).toBe(editor.state.doc.length); + }); + + it('asks what to do when preference is ask and editor has content', ()=>{ + getPrefsSpy.mockReturnValue({ sql_file_drop_action: 'ask' }); + cmRerender({value: 'select * from old_query;'}); + const fileContent = 'select * from dropped_file;'; + const event = { + dataTransfer: { + files: [], + types: ['Files', 'text/plain'], + getData: jest.fn().mockReturnValue(fileContent), + }, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + + expect(handleDrop(event, editor)).toBe(true); + expect(editor.getValue()).toBe('select * from old_query;'); + expect(pgAdmin.Browser.notifier.showModal).toHaveBeenCalled(); + + const modalContent = pgAdmin.Browser.notifier.showModal.mock.calls[0][1]; + const closeModal = jest.fn(); + const content = modalContent(closeModal); + content.props.onReplace(); + expect(editor.getValue()).toBe(fileContent); + }); + + it('applies merge when ask dialog merge is chosen', ()=>{ + getPrefsSpy.mockReturnValue({ sql_file_drop_action: 'ask' }); + cmRerender({value: 'select from t;'}); + editor.dispatch({ selection: { anchor: 7, head: 7 } }); + handleEditorMouseUp({}, editor); + const fileContent = 'public.actor'; + const event = { + dataTransfer: { + files: [], + types: ['Files', 'text/plain'], + getData: jest.fn().mockReturnValue(fileContent), + }, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + + handleDrop(event, editor); + /* Modal focus must not change the merge position captured at drop. */ + editor.dispatch({ selection: { anchor: 0, head: 0 } }); + const modalContent = pgAdmin.Browser.notifier.showModal.mock.calls[0][1]; + modalContent(jest.fn()).props.onMerge(); + expect(editor.getValue()).toBe('select public.actor from t;'); + }); + + it('replaces without asking when editor is empty even if preference is ask', ()=>{ + getPrefsSpy.mockReturnValue({ sql_file_drop_action: 'ask' }); + cmRerender({value: ''}); + const fileContent = 'select * from dropped_file;'; + const event = { + dataTransfer: { + files: [], + types: ['Files', 'text/plain'], + getData: jest.fn().mockReturnValue(fileContent), + }, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + + expect(handleDrop(event, editor)).toBe(true); + expect(pgAdmin.Browser.notifier.showModal).not.toHaveBeenCalled(); + expect(editor.getValue()).toBe(fileContent); + }); + }); + + it('inserts tree node text at drop position', ()=>{ + cmRerender({value: 'select from t;'}); + const dropPos = 7; + const dropDetails = { + text: 'public.actor', + cur: { from: 12, to: 12 }, + }; + jest.spyOn(editor, 'posAtCoords').mockReturnValue(dropPos); + const event = { + dataTransfer: { + files: [], + getData: jest.fn().mockReturnValue(JSON.stringify(dropDetails)), + }, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + clientX: 40, + clientY: 40, + }; + + handleDrop(event, editor); + + expect(editor.getValue()).toBe('select public.actor from t;'); + }); + + it('returns false for internal text drag so CodeMirror can handle it', ()=>{ + const event = { + dataTransfer: { + files: [], + getData: jest.fn().mockReturnValue('plain selected text'), + }, + preventDefault: jest.fn(), + }; + + expect(handleDrop(event, editor)).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + });