From d33fa6624ad6c2643d36ad9e2f9df5972a95e212 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Fri, 10 Apr 2026 19:48:31 +0300 Subject: [PATCH] Fixed drag-and-drop into text fields: files are now inserted at the text caret position instead of where the mouse was dropped Replaced Swing's SwingDropTarget with a custom DropTargetAdapter that never calls setDropLocation(), so the caret stays at the user's original position throughout the drag. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + pom.xml | 2 +- .../ui/common/FileDropHandler.java | 37 ++++++++--- .../ui/common/TextComponentDecorator.java | 25 ++++++++ .../ui/common/FileDropHandlerTest.java | 63 ++++++++++++++++++- 5 files changed, 114 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3fbce39..2f773e98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Fixed drag-and-drop into text fields: files are now inserted at the text caret position instead of where the mouse was dropped - Fixed Accept/Decline and Yes/No button colors to respect the IDE Look and Feel theme (dark themes no longer show clashing hardcoded colors) - Added session management when opening a session from the toolbar and when closing a tab via the Close button - Added configurable session mode: choose to resume the last session or start a new one diff --git a/pom.xml b/pom.xml index c6e262f8..aa9791eb 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbclaudecodegui netbeans-claude-code-gui - 0.21.14-SNAPSHOT + 0.21.18-SNAPSHOT nbm Netbeans Claude Code GUI NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE. diff --git a/src/main/java/io/github/nbclaudecodegui/ui/common/FileDropHandler.java b/src/main/java/io/github/nbclaudecodegui/ui/common/FileDropHandler.java index 983c1d6b..07fcdf76 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/common/FileDropHandler.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/common/FileDropHandler.java @@ -198,7 +198,20 @@ public boolean importData(TransferSupport support) { LOG.fine("importData: component=" + support.getComponent().getClass().getSimpleName()); Transferable t = support.getTransferable(); JTextComponent textComponent = findTextComponent(support.getComponent()); - return doImport(t, textComponent); + int insertPos = textComponent != null ? textComponent.getCaretPosition() : 0; + return doImport(t, textComponent, insertPos); + } + + /** + * Called by our custom {@link java.awt.dnd.DropTargetAdapter} in place of the standard + * Swing drop pipeline. Because our adapter never calls {@code setDropLocation}, the caret + * stays at the position the user left it — so we can read it here and insert there. + */ + void handleDrop(java.awt.dnd.DropTargetDropEvent dtde, JTextComponent tc) { + dtde.acceptDrop(java.awt.dnd.DnDConstants.ACTION_COPY_OR_MOVE); + int insertPos = tc.getCaretPosition(); + boolean ok = doImport(dtde.getTransferable(), tc, insertPos); + dtde.dropComplete(ok); } // ------------------------------------------------------------------------- @@ -223,19 +236,19 @@ public boolean importFromClipboard(JTextComponent textComponent) { boolean hasText = t.isDataFlavorSupported(DataFlavor.stringFlavor); LOG.fine("importFromClipboard: hasFiles=" + hasFiles + " hasImage=" + hasImage + " hasText=" + hasText); if (!hasFiles && !hasImage && !hasText) return false; - return doImport(t, textComponent); + return doImport(t, textComponent, textComponent.getCaretPosition()); } // ------------------------------------------------------------------------- // Shared import logic // ------------------------------------------------------------------------- - private boolean doImport(Transferable t, JTextComponent textComponent) { + private boolean doImport(Transferable t, JTextComponent textComponent, int insertPos) { try { Node[] nodes = extractNodes(t); if (nodes != null && nodes.length > 0) { LOG.fine("doImport: nodes branch, count=" + nodes.length); - return importNodes(nodes, textComponent); + return importNodes(nodes, textComponent, insertPos); } if (t.isDataFlavorSupported(DataFlavor.imageFlavor)) { Image img = (Image) t.getTransferData(DataFlavor.imageFlavor); @@ -243,7 +256,7 @@ private boolean doImport(Transferable t, JTextComponent textComponent) { LOG.fine("doImport: image branch"); File tmp = writeTempPng(img); if (textComponent != null) { - insertAtCaret(textComponent, "@" + tmp.getAbsolutePath()); + insertAtCaret(textComponent, "@" + tmp.getAbsolutePath(), insertPos); } return true; } @@ -265,14 +278,14 @@ private boolean doImport(Transferable t, JTextComponent textComponent) { } if (paths.length() > 0 && textComponent != null) { - insertAtCaret(textComponent, paths.toString()); + insertAtCaret(textComponent, paths.toString(), insertPos); } return true; } if (t.isDataFlavorSupported(DataFlavor.stringFlavor)) { LOG.fine("doImport: string branch"); String s = (String) t.getTransferData(DataFlavor.stringFlavor); - if (s != null && textComponent != null) { insertAtCaret(textComponent, s); return true; } + if (s != null && textComponent != null) { insertAtCaret(textComponent, s, insertPos); return true; } } } catch (Exception ex) { // Import failed; fall through to return false @@ -297,6 +310,11 @@ private boolean doImport(Transferable t, JTextComponent textComponent) { * */ boolean importNodes(Node[] nodes, JTextComponent textComponent) { + int pos = textComponent != null ? textComponent.getCaretPosition() : 0; + return importNodes(nodes, textComponent, pos); + } + + private boolean importNodes(Node[] nodes, JTextComponent textComponent, int insertPos) { LOG.fine("importNodes: entry, count=" + (nodes == null ? 0 : nodes.length)); if (nodes == null || nodes.length == 0) return false; String wdStr = workingDirSupplier.get(); @@ -393,7 +411,7 @@ boolean importNodes(Node[] nodes, JTextComponent textComponent) { } if (inlineText.length() > 0 && textComponent != null) { - insertAtCaret(textComponent, inlineText.toString()); + insertAtCaret(textComponent, inlineText.toString(), insertPos); } LOG.fine("importNodes: returning " + (inlineText.length() > 0) + ", text='" + inlineText + "'"); return inlineText.length() > 0; @@ -438,9 +456,8 @@ private File writeTempPng(Image img) throws IOException { return tmp; } - private static void insertAtCaret(JTextComponent textComponent, String text) { + private static void insertAtCaret(JTextComponent textComponent, String text, int pos) { javax.swing.SwingUtilities.invokeLater(() -> { - int pos = textComponent.getCaretPosition(); try { String before = textComponent.getText(0, pos); String toInsert = (!before.isEmpty() && !before.endsWith(" ") && !before.endsWith("\n")) diff --git a/src/main/java/io/github/nbclaudecodegui/ui/common/TextComponentDecorator.java b/src/main/java/io/github/nbclaudecodegui/ui/common/TextComponentDecorator.java index ba196623..d0c303b6 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/common/TextComponentDecorator.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/common/TextComponentDecorator.java @@ -54,6 +54,31 @@ public void setup() { dropHandler = new FileDropHandler(wdSupplier, tc.getTransferHandler()); tc.setTransferHandler(dropHandler); + // Replace Swing's SwingDropTarget with our own that never calls setDropLocation, + // so the caret stays frozen at its original position during the entire drag. + tc.setDropTarget(new java.awt.dnd.DropTarget(tc, + java.awt.dnd.DnDConstants.ACTION_COPY_OR_MOVE, + new java.awt.dnd.DropTargetAdapter() { + @Override + public void dragEnter(java.awt.dnd.DropTargetDragEvent e) { + if (dropHandler.canImportTransferable(e.getTransferable())) + e.acceptDrag(java.awt.dnd.DnDConstants.ACTION_COPY_OR_MOVE); + else + e.rejectDrag(); + } + @Override + public void dragOver(java.awt.dnd.DropTargetDragEvent e) { + if (dropHandler.canImportTransferable(e.getTransferable())) + e.acceptDrag(java.awt.dnd.DnDConstants.ACTION_COPY_OR_MOVE); + else + e.rejectDrag(); + } + @Override + public void drop(java.awt.dnd.DropTargetDropEvent e) { + dropHandler.handleDrop(e, tc); + } + }, true)); + // 2. @-completion AtCompletionPopup.install(tc, wdSupplier); diff --git a/src/test/java/io/github/nbclaudecodegui/ui/common/FileDropHandlerTest.java b/src/test/java/io/github/nbclaudecodegui/ui/common/FileDropHandlerTest.java index 28c65f55..b70467ab 100644 --- a/src/test/java/io/github/nbclaudecodegui/ui/common/FileDropHandlerTest.java +++ b/src/test/java/io/github/nbclaudecodegui/ui/common/FileDropHandlerTest.java @@ -782,6 +782,61 @@ void importNodesFilesWindowNodeInsideWorkDirInsertsRelativePath() throws Excepti }); } + // ------------------------------------------------------------------------- + // Tests: insert at explicit position (DnD caret-save fix) + // ------------------------------------------------------------------------- + + /** + * Verifies that doImport(Transferable, JTextComponent, int) inserts at the + * given position, NOT at the text component's current caret position. + * This is the core of the DnD caret-save fix: the drag-hover caret movement + * must be ignored; insertion must happen where the caret was before the drag. + */ + @Test + void dropFileInsertsAtExplicitPositionIgnoringCurrentCaret() throws Exception { + File inside = new File(workDir, "file.txt"); + inside.createNewFile(); + + SwingUtilities.invokeAndWait(() -> { + area.setText("hello world"); + // Simulate Swing moving the caret to the drop location (end of text) + area.setCaretPosition(11); + // Call doImport with insertPos=5 (the saved pre-drag caret position) + java.awt.datatransfer.Transferable t = fileListTransferable(List.of(inside)); + callDoImportAt(handler, t, area, 5); + }); + + SwingUtilities.invokeAndWait(() -> { + String text = area.getText(); + assertTrue(text.contains("file.txt"), "path must appear in text, got: '" + text + "'"); + // The path must be inserted inside "hello world", not appended at the end + assertTrue(text.startsWith("hello"), + "text must start with 'hello' (insertion at pos 5), got: '" + text + "'"); + int pathIdx = text.indexOf("file.txt"); + assertTrue(pathIdx > 0 && pathIdx < text.indexOf("world"), + "path must be inserted between 'hello' and 'world', got: '" + text + "'"); + }); + } + + /** + * Calls the package-private {@code doImport(Transferable, JTextComponent, int)} via reflection. + */ + private static boolean callDoImportAt(FileDropHandler handler, + java.awt.datatransfer.Transferable t, + JTextArea area, int insertPos) { + try { + java.lang.reflect.Method m = FileDropHandler.class.getDeclaredMethod( + "doImport", + java.awt.datatransfer.Transferable.class, + javax.swing.text.JTextComponent.class, + int.class); + m.setAccessible(true); + return (boolean) m.invoke(handler, t, area, insertPos); + } catch (Exception ex) { + throw new RuntimeException("Could not invoke FileDropHandler.doImport(t, tc, pos)", ex); + } + } + // ------------------------------------------------------------------------- // Tests: Cut/Copy — export delegation to original TransferHandler // ------------------------------------------------------------------------- @@ -845,7 +900,8 @@ void canImportBlankAbstractNodeReturnsFalse() throws Exception { } /** - * Calls the package-private {@code doImport(Transferable, JTextComponent)} via reflection. + * Calls the package-private {@code doImport(Transferable, JTextComponent, int)} via reflection, + * using the current caret position as the insert position. */ private static boolean callDoImport(FileDropHandler handler, java.awt.datatransfer.Transferable t, @@ -854,9 +910,10 @@ private static boolean callDoImport(FileDropHandler handler, java.lang.reflect.Method m = FileDropHandler.class.getDeclaredMethod( "doImport", java.awt.datatransfer.Transferable.class, - javax.swing.text.JTextComponent.class); + javax.swing.text.JTextComponent.class, + int.class); m.setAccessible(true); - return (boolean) m.invoke(handler, t, area); + return (boolean) m.invoke(handler, t, area, area.getCaretPosition()); } catch (Exception ex) { throw new RuntimeException("Could not invoke FileDropHandler.doImport", ex); }