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();
+ });
+
});