diff --git a/java/api.debugger.jpda/apichanges.xml b/java/api.debugger.jpda/apichanges.xml index aa0dc431f1ed..8c685ec6b434 100644 --- a/java/api.debugger.jpda/apichanges.xml +++ b/java/api.debugger.jpda/apichanges.xml @@ -51,6 +51,19 @@ + + + Snapshot a value with Variable.asValue + + + + + + New asValue method added to request + a snapshot of the current value of the variable. + + + ObjectInfoProvider, JPDABreakpointEvent, JPDADebugger enhancements diff --git a/java/api.debugger.jpda/manifest.mf b/java/api.debugger.jpda/manifest.mf index 52daef17d393..e733b72a644d 100644 --- a/java/api.debugger.jpda/manifest.mf +++ b/java/api.debugger.jpda/manifest.mf @@ -1,6 +1,6 @@ Manifest-Version: 1.0 OpenIDE-Module: org.netbeans.api.debugger.jpda/2 OpenIDE-Module-Localizing-Bundle: org/netbeans/api/debugger/jpda/Bundle.properties -OpenIDE-Module-Specification-Version: 3.46 +OpenIDE-Module-Specification-Version: 3.47 OpenIDE-Module-Package-Dependencies: com.sun.jdi[VirtualMachineManager] diff --git a/java/api.debugger.jpda/src/org/netbeans/api/debugger/jpda/Variable.java b/java/api.debugger.jpda/src/org/netbeans/api/debugger/jpda/Variable.java index befe6b7dc1e8..2c6ededfbb5b 100644 --- a/java/api.debugger.jpda/src/org/netbeans/api/debugger/jpda/Variable.java +++ b/java/api.debugger.jpda/src/org/netbeans/api/debugger/jpda/Variable.java @@ -21,7 +21,7 @@ /** - * Represents some variable in debugged JVM. + * Represents some variable in the debuggee. * *
  * Since JDI interfaces evolve from one version to another, it's strongly recommended
@@ -56,10 +56,25 @@ public interface Variable {
     /**
      * Create an object in this JVM, which mirrors the value of this variable in
      * the target JVM.
-     * 
+     *
      * @return The mirror object, or null when a mirror object can not be created.
      * @since 2.44
      */
     Object createMirrorObject();
-    
+
+    /**
+     * Creates a snapshot of the variable value. It clones the variable
+     * object to assure it sticks to its current debugee value
+     * regardless if the variable state gets re-assigned in the future.
+     *
+     * This is an optional operation. Variables that have no notion of
+     * snapshotting may return {@code this} as the default
+     * implementation of this method does.
+     *
+     * @return a snapshot of the current variable value
+     * @since 3.47
+     */
+    default Variable asValue() {
+        return this;
+    }
 }
diff --git a/java/debugger.jpda.ui/nbproject/project.xml b/java/debugger.jpda.ui/nbproject/project.xml
index 15bf0d2efcc3..a9769778ebeb 100644
--- a/java/debugger.jpda.ui/nbproject/project.xml
+++ b/java/debugger.jpda.ui/nbproject/project.xml
@@ -40,7 +40,7 @@
                     
                     
                         2
-                        3.45
+                        3.47
                     
                 
                 
diff --git a/java/debugger.jpda.ui/src/org/netbeans/modules/debugger/jpda/ui/BreakpointOutput.java b/java/debugger.jpda.ui/src/org/netbeans/modules/debugger/jpda/ui/BreakpointOutput.java
index 002797a361e7..91cbbff5f589 100644
--- a/java/debugger.jpda.ui/src/org/netbeans/modules/debugger/jpda/ui/BreakpointOutput.java
+++ b/java/debugger.jpda.ui/src/org/netbeans/modules/debugger/jpda/ui/BreakpointOutput.java
@@ -25,8 +25,6 @@
 import java.lang.ref.WeakReference;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.function.BiConsumer;
-import java.util.function.Consumer;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import org.netbeans.api.debugger.ActionsManagerListener;
@@ -53,6 +51,7 @@
 import org.netbeans.modules.debugger.jpda.JPDADebuggerImpl;
 import org.netbeans.modules.debugger.jpda.ui.models.BreakpointsActionsProvider;
 import org.netbeans.modules.debugger.jpda.ui.models.BreakpointsNodeModel;
+import org.netbeans.modules.debugger.jpda.ui.views.BreakpointOutputWriter;
 import org.netbeans.spi.debugger.ContextProvider;
 import org.netbeans.spi.debugger.jpda.ObjectInfoProvider;
 import org.netbeans.spi.viewmodel.NodeModel;
@@ -75,6 +74,7 @@ public class BreakpointOutput extends LazyActionsManagerListener
         ("\\\\");
     private static final String threadNamePattern = "{threadName}";
     private static final String classNamePattern = "{className}";
+    private static final String localsPattern = "{locals}";
     private static final Pattern methodNamePattern = Pattern.compile
         ("\\{methodName\\}");
     private static final Pattern lineNumberPattern = Pattern.compile
@@ -375,13 +375,23 @@ private void printOutEvent(DebuggerConsoleIO io, String format, JPDABreakpointEv
                 printText = selectCondition(printText, threadStartedCondition, false);
             }
         }
+
+        boolean printLocals = false;
+        for (;;) {
+            int localsAt = printText.indexOf(localsPattern);
+            if (localsAt == -1) {
+                break;
+            }
+            printText = printText.substring(0, localsAt) + printText.substring(localsAt + localsPattern.length());
+            printLocals = true;
+        }
              
         // 5) resolve all expressions {=expression}
-        printExpressions(event, printText, (msg, action) -> {
-            io.printWithAction(msg, action, false);
-        }, (msg) -> {
-            io.println(msg, null);
-        });
+        printExpressions(event, printText, io::printWithAction);
+
+        if (printLocals) {
+            printLocals(event, io::printWithAction);
+        }
     }
 
     /* Processing all expressions. Expressions take form of {=expression}.
@@ -390,8 +400,7 @@ private void printOutEvent(DebuggerConsoleIO io, String format, JPDABreakpointEv
     final void printExpressions(
         JPDABreakpointEvent event,
         String message,
-        BiConsumer print,
-        Consumer println
+        BreakpointOutputWriter io
     ) {
         String printText = message;
         Session session = event.getDebugger().getSession();
@@ -413,31 +422,22 @@ final void printExpressions(
                 }
 
                 String beforeExpression = printText.substring(0, m.start());
-                print.accept(beforeExpression, null);
-
-                CallStackFrame csf = null;
-                JPDAThread t = event.getThread();
-                if (t != null && !event.isThreadDeath()) {
-                    try {
-                        CallStackFrame[] topFramePtr = t.getCallStack(0, 1);
-                        if (topFramePtr.length > 0) {
-                            csf = topFramePtr[0];
-                        }
-                    } catch (AbsentInformationException aiex) {}
-                }
+                io.print(beforeExpression, null, false);
+
+                CallStackFrame csf = findTopFrame(event);
                 Variable refValue = theDebugger.evaluate(expression, csf);
                 if (refValue instanceof ObjectVariable obj && !isNull(obj)) {
                     var sessionRef = new WeakReference<>(session);
-                    print.accept(obj.getValue(), () -> {
+                    io.print(obj.getValue(), () -> {
                         var s = sessionRef.get();
                         var ok = s != null && ObjectInfoProvider.showObjectInfo(s, obj);
                         if (!ok) {
-                            println.accept("Cannot display details of " + obj.getValue());
+                            io.print("Cannot display details of " + obj.getValue(), null, true);
                         }
-                    });
+                    }, false);
                 } else {
                     String textualValue = refValue == null ? "null" : refValue.getValue();
-                    print.accept(textualValue, null);
+                    io.print(textualValue, null, false);
                 }
             } catch (Exception e) {
                 // expression is invalid or cannot be evaluated
@@ -448,11 +448,10 @@ final void printExpressions(
                     BreakpointsActionsProvider.customize(bp);
                 };
                 dumpErrors.add(() -> {
-                    print.accept("  ", null);
-                    print.accept(msg, configure);
-                    println.accept("");
+                    io.print("  ", null, false);
+                    io.print(msg, configure, true);
                 });
-                print.accept("{=" + expression + "}", configure);
+                io.print("{=" + expression + "}", configure, false);
             }
             // process the rest of the text
             printText = printText.substring(m.end());
@@ -462,12 +461,53 @@ final void printExpressions(
             printText = printText + "\n***\n"+ thr.getLocalizedMessage()+"\n***\n";
         }
         // remaining of the text with a new line
-        println.accept(printText);
+        io.print(printText, null, true);
         for (var errors : dumpErrors) {
             errors.run();
         }
     }
 
+    final void printLocals(JPDABreakpointEvent event, BreakpointOutputWriter io) {
+        CallStackFrame csf = findTopFrame(event);
+        if (csf != null) {
+            try {
+                for (var refValue : csf.getLocalVariables()) {
+                    io.print("  ", null, false);
+                    io.print(refValue.getName(), null, false);
+                    io.print(" = ", null, false);
+                    if (refValue instanceof ObjectVariable ov && !isNull(ov) && ov.asValue() instanceof ObjectVariable obj) {
+                        var sessionRef = new WeakReference<>(event.getDebugger().getSession());
+                        io.print(obj.getValue(), () -> {
+                            var s = sessionRef.get();
+                            var ok = s != null && ObjectInfoProvider.showObjectInfo(s, obj);
+                            if (!ok) {
+                                io.print("Cannot display details of " + obj.getValue(), null, true);
+                            }
+                        }, true);
+                    } else {
+                        io.print(refValue.getValue(), null, true);
+                    }
+                }
+            } catch (AbsentInformationException ex) {
+                // no locals info
+            }
+        }
+    }
+
+
+    private CallStackFrame findTopFrame(JPDABreakpointEvent event) {
+        if (event.getThread() != null && !event.isThreadDeath()) {
+            try {
+                CallStackFrame[] topFramePtr = event.getThread().getCallStack(0, 1);
+                if (topFramePtr.length > 0) {
+                    return topFramePtr[0];
+                }
+            } catch (AbsentInformationException aiex) {
+            }
+        }
+        return null;
+    }
+
     private static boolean isNull(ObjectVariable obj) {
         return obj.getUniqueID() == 0;
     }
diff --git a/java/debugger.jpda.ui/src/org/netbeans/modules/debugger/jpda/ui/EditorContextBridge.java b/java/debugger.jpda.ui/src/org/netbeans/modules/debugger/jpda/ui/EditorContextBridge.java
index a28784be4440..530c3451402f 100644
--- a/java/debugger.jpda.ui/src/org/netbeans/modules/debugger/jpda/ui/EditorContextBridge.java
+++ b/java/debugger.jpda.ui/src/org/netbeans/modules/debugger/jpda/ui/EditorContextBridge.java
@@ -23,19 +23,12 @@
 import java.io.File;
 import java.net.MalformedURLException;
 import java.net.URL;
-import java.util.ArrayList;
 import java.util.Iterator;
 import java.util.List;
 
-import javax.swing.JEditorPane;
-import org.netbeans.api.debugger.Breakpoint.VALIDITY;
 import org.netbeans.api.debugger.DebuggerManager;
-import org.netbeans.api.debugger.jpda.FieldBreakpoint;
-import org.netbeans.api.debugger.jpda.JPDABreakpoint;
 import org.netbeans.api.debugger.jpda.LineBreakpoint;
-import org.netbeans.api.debugger.jpda.MethodBreakpoint;
 import org.netbeans.api.debugger.jpda.CallStackFrame;
-import org.netbeans.api.debugger.jpda.JPDADebugger;
 import org.netbeans.api.debugger.jpda.JPDAThread;
 import org.netbeans.spi.debugger.jpda.EditorContext;
 import org.netbeans.spi.debugger.jpda.SourcePathProvider;
@@ -44,7 +37,6 @@
 import org.openide.ErrorManager;
 import org.openide.filesystems.FileObject;
 import org.openide.util.Exceptions;
-import org.openide.util.Utilities;
 
 
 /**
diff --git a/java/debugger.jpda.ui/src/org/netbeans/modules/debugger/jpda/ui/FixedWatchesManager.java b/java/debugger.jpda.ui/src/org/netbeans/modules/debugger/jpda/ui/FixedWatchesManager.java
index 6f1f86a54f9a..39689bc4f24a 100644
--- a/java/debugger.jpda.ui/src/org/netbeans/modules/debugger/jpda/ui/FixedWatchesManager.java
+++ b/java/debugger.jpda.ui/src/org/netbeans/modules/debugger/jpda/ui/FixedWatchesManager.java
@@ -138,9 +138,9 @@ private boolean isPrimitive (Object node) {
     );
         
         
-    private Map             fixedWatches = new LinkedHashMap();
-    private HashSet         listeners;
-    private ContextProvider contextProvider;
+    private Map fixedWatches = new LinkedHashMap<>();
+    private HashSet listeners;
+    private final ContextProvider contextProvider;
 
     
     public FixedWatchesManager (ContextProvider contextProvider) {
@@ -148,8 +148,8 @@ public FixedWatchesManager (ContextProvider contextProvider) {
     }
 
     public void deleteAllFixedWatches() {
-        Collection nodes = new ArrayList(fixedWatches.keySet());
-        for (Iterator iter = nodes.iterator(); iter.hasNext();) {
+        Collection nodes = new ArrayList<>(fixedWatches.keySet());
+        for (Iterator iter = nodes.iterator(); iter.hasNext();) {
             fixedWatches.remove(iter.next());
             fireModelChanged(new ModelEvent.NodeChanged(FixedWatchesManager.this,
                 TreeModel.ROOT,
@@ -361,18 +361,9 @@ private void createFixedWatch (Object node) {
         }
     }
 
-    public void addFixedWatch (String name, Variable variable) {
+    public void addFixedWatch (String name, Variable orig) {
         // Clone the variable to assure that it's unique and sticks to the JDI value.
-        if (variable instanceof Cloneable) {
-            try { // terrible code to invoke the clone() method
-                java.lang.reflect.Method cloneMethod = variable.getClass().getMethod("clone", new Class[] {});
-                cloneMethod.setAccessible(true);
-                Object newVar = cloneMethod.invoke(variable, new Object[] {});
-                if (newVar instanceof Variable) {
-                    variable = (Variable) newVar;
-                }
-            } catch (Exception ex) {} // Ignore any exceptions
-        }
+        Variable variable = orig.asValue();
         fixedWatches.put (new KeyWrapper(variable), name);
         fireModelChanged (new ModelEvent.NodeChanged(
                 this,
diff --git a/java/debugger.jpda.ui/src/org/netbeans/modules/debugger/jpda/ui/actions/AddLogpointAction.java b/java/debugger.jpda.ui/src/org/netbeans/modules/debugger/jpda/ui/actions/AddLogpointAction.java
new file mode 100644
index 000000000000..9289e66fbe0d
--- /dev/null
+++ b/java/debugger.jpda.ui/src/org/netbeans/modules/debugger/jpda/ui/actions/AddLogpointAction.java
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.netbeans.modules.debugger.jpda.ui.actions;
+
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import javax.swing.AbstractAction;
+import javax.swing.Action;
+import org.netbeans.api.debugger.DebuggerManager;
+import org.netbeans.api.debugger.jpda.JPDABreakpoint;
+import org.netbeans.api.debugger.jpda.LineBreakpoint;
+import org.netbeans.modules.debugger.jpda.ui.EditorContextBridge;
+import org.openide.awt.ActionID;
+import org.openide.awt.ActionReference;
+import org.openide.awt.ActionReferences;
+import org.openide.awt.ActionRegistration;
+import org.openide.awt.ActionState;
+import org.openide.loaders.DataObject;
+import org.openide.util.NbBundle.Messages;
+
+@ActionID(
+        category = "Debug",
+        id = "org.netbeans.modules.debugger.jpda.ui.actions.AddLogpointAction"
+)
+@ActionRegistration(
+        displayName = "#CTL_AddLogpointAction",
+        enabledOn = @ActionState( property = "hasJavaSource", type = Action.class )
+)
+@ActionReferences({
+    @ActionReference(path = "Editors/text/x-java/Popup", position = 1900),
+    @ActionReference(path = "Editors/AnnotationTypes/BreakpointActionsWithToggleNew", position = 400)
+})
+@Messages({
+        "CTL_AddLogpointAction=Add &Logpoint",
+        "# {0} - clazz name",
+        "# {1} - method name",
+        "# {2} - line number",
+        "# {3} - suffix",
+        "MSG_LogpointAt=Logpoint at {0}.{1}:{2} {3}"
+})
+public final class AddLogpointAction extends AbstractAction {
+    private final DataObject context;
+
+    public AddLogpointAction(DataObject context) {
+        this.context = context;
+    }
+
+    @Override
+    public Object getValue(String key) {
+        if ("hasJavaSource".equals(key)) {
+            String mime = context.getPrimaryFile().getMIMEType("text/x-java");
+            return "text/x-java".equals(mime);
+        }
+        return super.getValue(key);
+    }
+
+
+    @Override
+    public void actionPerformed(ActionEvent ev) {
+        String where = EditorContextBridge.getContext().getCurrentURL();
+        int at = EditorContextBridge.getContext().getCurrentLineNumber();
+
+        LineBreakpoint lb = LineBreakpoint.create(where, at);
+        lb.setSuspend(JPDABreakpoint.SUSPEND_NONE);
+        lb.setPrintText(Bundle.MSG_LogpointAt("{className}", "{methodName}", "{lineNumber}", "{locals}"));
+
+        DebuggerManager.getDebuggerManager().addBreakpoint(lb);
+    }
+}
diff --git a/java/debugger.jpda.ui/src/org/netbeans/modules/debugger/jpda/ui/breakpoints/Bundle.properties b/java/debugger.jpda.ui/src/org/netbeans/modules/debugger/jpda/ui/breakpoints/Bundle.properties
index f5102fb01e63..599cab68cf13 100644
--- a/java/debugger.jpda.ui/src/org/netbeans/modules/debugger/jpda/ui/breakpoints/Bundle.properties
+++ b/java/debugger.jpda.ui/src/org/netbeans/modules/debugger/jpda/ui/breakpoints/Bundle.properties
@@ -47,7 +47,9 @@
     L_Actions_Panel_Print_Text=Print &Text\:
 
     ACSD_TF_Actions_Panel_Print_Text=
-    TTT_TF_Actions_Panel_Print_Text=Text to be printed when breakpoint occurs. Use {=} to print expressions/variables.
+    TTT_TF_Actions_Panel_Print_Text=Text to be printed when breakpoint occurs.\n\
+         Use {=} to print expressions/variables.\n\
+        Use {className}, {methodName}, {lineNumber} or {locals} to print additional info.
 
 #ClassBreakpointPanel
     CTL_Class_Breakpoint_Print_Text=Class breakpoint hit for class {className}.
diff --git a/java/debugger.jpda.ui/src/org/netbeans/modules/debugger/jpda/ui/views/BreakpointOutputWriter.java b/java/debugger.jpda.ui/src/org/netbeans/modules/debugger/jpda/ui/views/BreakpointOutputWriter.java
new file mode 100644
index 000000000000..abab0d01e29f
--- /dev/null
+++ b/java/debugger.jpda.ui/src/org/netbeans/modules/debugger/jpda/ui/views/BreakpointOutputWriter.java
@@ -0,0 +1,26 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.netbeans.modules.debugger.jpda.ui.views;
+
+/**
+ * Abstraction of console output writer for easier mocking while testing.
+ */
+public interface BreakpointOutputWriter {
+    void print(String txt, Runnable action, boolean newLine);
+}
diff --git a/java/debugger.jpda.ui/test/unit/src/org/netbeans/modules/debugger/jpda/ui/BreakpointOutputTest.java b/java/debugger.jpda.ui/test/unit/src/org/netbeans/modules/debugger/jpda/ui/BreakpointOutputTest.java
index 693fea79b38a..9a36d1ef43c6 100644
--- a/java/debugger.jpda.ui/test/unit/src/org/netbeans/modules/debugger/jpda/ui/BreakpointOutputTest.java
+++ b/java/debugger.jpda.ui/test/unit/src/org/netbeans/modules/debugger/jpda/ui/BreakpointOutputTest.java
@@ -24,8 +24,6 @@
 import java.io.StringWriter;
 import java.lang.reflect.Method;
 import java.net.URI;
-import java.util.function.BiConsumer;
-import java.util.function.Consumer;
 import junit.framework.Test;
 import org.netbeans.api.debugger.DebuggerManager;
 import org.netbeans.api.debugger.Session;
@@ -35,6 +33,7 @@
 import org.netbeans.api.debugger.jpda.event.JPDABreakpointEvent;
 import org.netbeans.api.debugger.jpda.event.JPDABreakpointListener;
 import org.netbeans.junit.NbTestCase;
+import org.netbeans.modules.debugger.jpda.ui.views.BreakpointOutputWriter;
 import org.openide.util.Utilities;
 
 /** Testing {@link BreakpointOutput#printExpressions}
@@ -83,7 +82,7 @@ public void testBreakpointOutputLinks() throws Throwable {
         assertOnBreakpoint((event) -> {
             writer.append("on ").append(event.getThread().getName()).append("\n");
             try {
-                printExpressions(out, event, format, (msg, action) -> {
+                printExpressions(out, event, format, (msg, action, newLine) -> {
                     if (action != null) {
                         writer.append("");
                     }
-                }, (msg) -> {
-                    writer.append(msg).append("\n");
+                    if (newLine) {
+                        writer.append("\n");
+                    }
                 });
             } catch (ReflectiveOperationException ex) {
                 ex.printStackTrace(writer);
@@ -108,6 +108,49 @@ public void testBreakpointOutputLinks() throws Throwable {
         """, collected.toString());
     }
 
+    public void testLocalVariablesOutput() throws Throwable {
+        final Session session = support.getDebugger().getSession();
+
+        //
+        // now the real test and collect the printed messages
+        //
+
+        var collected = new StringWriter();
+        var writer = new PrintWriter(collected);
+        BreakpointOutput out = new BreakpointOutput(session);
+        assertOnBreakpoint((event) -> {
+            writer.append("on ").append(event.getThread().getName()).append("\n");
+            try {
+                printLocals(out, event, (msg, action, newLine) -> {
+                    if (action != null) {
+                        writer.append("");
+                    }
+                    if (newLine) {
+                        writer.append("\n");
+                    }
+                });
+            } catch (ReflectiveOperationException ex) {
+                ex.printStackTrace(writer);
+            }
+        });
+
+        //
+        // verify result
+        //
+        assertMatches("""
+        on main
+          args = 
+          i = 42
+          when = 
+          arr = 
+          obj = 
+        """, collected.toString());
+    }
+
     public void testArithmeticException() throws Exception {
         //
         // now the real test and collect the printed messages
@@ -120,7 +163,7 @@ public void testArithmeticException() throws Exception {
         assertOnBreakpoint((event) -> {
             writer.append("on ").append(event.getThread().getName()).append("\n");
             try {
-                printExpressions(out, event, format, (msg, action) -> {
+                printExpressions(out, event, format, (msg, action, newLine) -> {
                     if (action != null) {
                         writer.append("");
                     }
-                }, (msg) -> {
-                    writer.append(msg).append("\n");
+                    if (newLine) {
+                        writer.append("\n");
+                    }
                 });
             } catch (ReflectiveOperationException ex) {
                 ex.printStackTrace(writer);
@@ -192,15 +236,32 @@ private void printExpressions(
         BreakpointOutput out,
         JPDABreakpointEvent event,
         String message,
-        BiConsumer print,
-        Consumer println
+        BreakpointOutputWriter writer
     ) throws ReflectiveOperationException {
         Method m = out.getClass().getDeclaredMethod(
                 "printExpressions",
-                JPDABreakpointEvent.class, String.class, BiConsumer.class, Consumer.class
+                JPDABreakpointEvent.class, String.class, BreakpointOutputWriter.class
+        );
+        m.setAccessible(true);
+        m.invoke(out, event, message, writer);
+    }
+
+    /**
+     * Calls {@link BreakpointOutput#printLocals}.
+     * Have to use reflection even the class and its tests are in the
+     * same package. They are loaded by different classloaders.
+     */
+    private void printLocals(
+        BreakpointOutput out,
+        JPDABreakpointEvent event,
+        BreakpointOutputWriter writer
+    ) throws ReflectiveOperationException {
+        Method m = out.getClass().getDeclaredMethod(
+                "printLocals",
+                JPDABreakpointEvent.class, BreakpointOutputWriter.class
         );
         m.setAccessible(true);
-        m.invoke(out, event, message, print, println);
+        m.invoke(out, event, writer);
     }
 
     private void assertMatches(String expected, String actual) {
diff --git a/java/debugger.jpda.ui/test/unit/src/org/netbeans/modules/debugger/jpda/ui/LogpointReviewTest.java b/java/debugger.jpda.ui/test/unit/src/org/netbeans/modules/debugger/jpda/ui/LogpointReviewTest.java
new file mode 100644
index 000000000000..a24764167579
--- /dev/null
+++ b/java/debugger.jpda.ui/test/unit/src/org/netbeans/modules/debugger/jpda/ui/LogpointReviewTest.java
@@ -0,0 +1,117 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.netbeans.modules.debugger.jpda.ui;
+
+import java.io.File;
+import java.lang.reflect.Field;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import junit.framework.Test;
+import org.netbeans.api.debugger.DebuggerManager;
+import org.netbeans.api.debugger.jpda.ExceptionBreakpoint;
+import org.netbeans.api.debugger.jpda.JPDADebugger;
+import org.netbeans.api.debugger.jpda.JPDASupport;
+import org.netbeans.api.debugger.jpda.ObjectVariable;
+import org.netbeans.junit.NbTestCase;
+import org.openide.util.Utilities;
+
+public class LogpointReviewTest extends NbTestCase {
+
+    public LogpointReviewTest(String name) {
+        super(name);
+    }
+
+    public static Test suite() {
+        return JPDASupport.createTestSuite(LogpointReviewTest.class);
+    }
+
+    public void testEarlierLocalHyperlinkKeepsObjectIdentity() throws Exception, Throwable {
+        var bp = ExceptionBreakpoint.create("java.lang.IllegalStateException", ExceptionBreakpoint.TYPE_EXCEPTION_CAUGHT_UNCAUGHT);
+        bp.setPrintText("");
+        DebuggerManager.getDebuggerManager().addBreakpoint(bp);
+        System.setProperty("test.dir.src", new File("test/unit/src").getAbsolutePath());
+        AtomicReference error = new AtomicReference<>();
+        ObjectVariable[] captured = {null};
+        long[] originalId = {0};
+        int[] hits = {0};
+        bp.addJPDABreakpointListener(event -> {
+            try {
+                var output = new BreakpointOutput(event.getDebugger().getSession());
+                Object io = event.getDebugger().getClass().getMethod("getConsoleIO").invoke(event.getDebugger());
+                Object manager = field(field(io, "output"), "ioManager");
+                List buffer = (List) field(manager, "buffer");
+                synchronized (buffer) {
+                    buffer.clear();
+                    output.substituteAndPrintText("REVIEW {locals}", event);
+                    hits[0]++;
+                    if (hits[0] == 1) {
+                        boolean item = false;
+                        for (Object text : buffer) {
+                            if ("item".equals(field(text, "text"))) {
+                                item = true;
+                            }
+                            Object action = field(text, "action");
+                            if (item && action != null) {
+                                for (Field f : action.getClass().getDeclaredFields()) {
+                                    f.setAccessible(true);
+                                    if (f.get(action) instanceof ObjectVariable obj) {
+                                        captured[0] = obj;
+                                    }
+                                }
+                                assertNotNull("Hyperlink captures object", captured[0]);
+                                originalId[0] = captured[0].getUniqueID();
+                                break;
+                            }
+                        }
+                    } else {
+                        assertNotNull("First item hyperlink captured", captured[0]);
+                        System.out.println("REVIEW hyperlink original=" + originalId[0] + " current=" + captured[0].getUniqueID());
+                        assertEquals("Earlier hyperlink should still target the first object", originalId[0], captured[0].getUniqueID());
+                    }
+                    buffer.clear();
+                }
+            } catch (Throwable ex) {
+                error.set(ex);
+            } finally {
+                event.resume();
+            }
+        });
+        var support = JPDASupport.attach(new String[0], "test.LogpointReviewMain", new String[0], new File[] {
+            Utilities.toFile(test.LogpointReviewMain.class.getProtectionDomain().getCodeSource().getLocation().toURI())
+        });
+        for (;;) {
+            support.waitState(JPDADebugger.STATE_STOPPED);
+            if (support.getDebugger().getState() == JPDADebugger.STATE_DISCONNECTED) {
+                break;
+            }
+            support.doContinue();
+        }
+        DebuggerManager.getDebuggerManager().removeBreakpoint(bp);
+        if (error.get() instanceof Throwable t) {
+            throw t;
+        }
+        assertEquals("Two loop iterations", 2, hits[0]);
+    }
+
+    private static Object field(Object target, String name) throws Exception {
+        Field f = target.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        return f.get(target);
+    }
+}
diff --git a/java/debugger.jpda.ui/test/unit/src/test/LogpointReviewMain.java b/java/debugger.jpda.ui/test/unit/src/test/LogpointReviewMain.java
new file mode 100644
index 000000000000..8588d21f761e
--- /dev/null
+++ b/java/debugger.jpda.ui/test/unit/src/test/LogpointReviewMain.java
@@ -0,0 +1,32 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package test;
+
+public class LogpointReviewMain {
+    public static void main(String... args) {
+        Object[] keepAlive = {new Object(), new Object()};
+        for (Object item : keepAlive) {
+            try {
+                throw new IllegalStateException(item.toString());
+            } catch (IllegalStateException ex) {
+                System.out.println(ex.getMessage());
+            }
+        }
+    }
+}
diff --git a/java/debugger.jpda/nbproject/project.xml b/java/debugger.jpda/nbproject/project.xml
index 72d9cfe650b7..2b3afaf60d71 100644
--- a/java/debugger.jpda/nbproject/project.xml
+++ b/java/debugger.jpda/nbproject/project.xml
@@ -49,7 +49,7 @@
                     
                     
                         2
-                        3.18
+                        3.47
                     
                 
                 
diff --git a/java/debugger.jpda/src/org/netbeans/modules/debugger/jpda/models/AbstractObjectVariable.java b/java/debugger.jpda/src/org/netbeans/modules/debugger/jpda/models/AbstractObjectVariable.java
index cd00586e5494..480ba5762d02 100644
--- a/java/debugger.jpda/src/org/netbeans/modules/debugger/jpda/models/AbstractObjectVariable.java
+++ b/java/debugger.jpda/src/org/netbeans/modules/debugger/jpda/models/AbstractObjectVariable.java
@@ -1109,84 +1109,6 @@ public String toString () {
         return "ObjectVariable ";
     }
 
-    /*@Override
-    protected void finalize() throws Throwable {
-        Value v = getInnerValue();
-        if (v instanceof ObjectReference) {
-            try {
-                System.err.println("ENABLING collection for "+v);
-                ObjectReferenceWrapper.enableCollection((ObjectReference) v);
-            } catch (Exception ex) {}
-        }
-        super.finalize();
-    }*/
-
-    /* Uncomment when needed. Was used to create "readable" String and Char values.
-    private static String convertToStringInitializer (String s) {
-        StringBuffer sb = new StringBuffer ();
-        int i, k = s.length ();
-        for (i = 0; i < k; i++)
-            switch (s.charAt (i)) {
-                case '\b':
-                    sb.append ("\\b");
-                    break;
-                case '\f':
-                    sb.append ("\\f");
-                    break;
-                case '\\':
-                    sb.append ("\\\\");
-                    break;
-                case '\t':
-                    sb.append ("\\t");
-                    break;
-                case '\r':
-                    sb.append ("\\r");
-                    break;
-                case '\n':
-                    sb.append ("\\n");
-                    break;
-                case '\"':
-                    sb.append ("\\\"");
-                    break;
-                default:
-                    sb.append (s.charAt (i));
-            }
-        return sb.toString();
-    }
-
-    private static String convertToCharInitializer (String s) {
-        StringBuffer sb = new StringBuffer ();
-        int i, k = s.length ();
-        for (i = 0; i < k; i++)
-            switch (s.charAt (i)) {
-                case '\b':
-                    sb.append ("\\b");
-                    break;
-                case '\f':
-                    sb.append ("\\f");
-                    break;
-                case '\\':
-                    sb.append ("\\\\");
-                    break;
-                case '\t':
-                    sb.append ("\\t");
-                    break;
-                case '\r':
-                    sb.append ("\\r");
-                    break;
-                case '\n':
-                    sb.append ("\\n");
-                    break;
-                case '\'':
-                    sb.append ("\\\'");
-                    break;
-                default:
-                    sb.append (s.charAt (i));
-            }
-        return sb.toString();
-    }
-     */
-
     private class DebuggetStateListener extends Object implements PropertyChangeListener {
 
         @Override
diff --git a/java/debugger.jpda/src/org/netbeans/modules/debugger/jpda/models/AbstractVariable.java b/java/debugger.jpda/src/org/netbeans/modules/debugger/jpda/models/AbstractVariable.java
index 4dcc9a31b25c..87439530c150 100644
--- a/java/debugger.jpda/src/org/netbeans/modules/debugger/jpda/models/AbstractVariable.java
+++ b/java/debugger.jpda/src/org/netbeans/modules/debugger/jpda/models/AbstractVariable.java
@@ -455,6 +455,11 @@ public Variable clone() {
         return clon;
     }
 
+    @Override
+    public Variable asValue() {
+        return clone();
+    }
+
     @Override
     public Object createMirrorObject() {
         Value v = getJDIValue();
diff --git a/java/debugger.jpda/src/org/netbeans/modules/debugger/jpda/models/ObjectLocalVariable.java b/java/debugger.jpda/src/org/netbeans/modules/debugger/jpda/models/ObjectLocalVariable.java
index 7db474f56dcd..53ed1b4e9d85 100644
--- a/java/debugger.jpda/src/org/netbeans/modules/debugger/jpda/models/ObjectLocalVariable.java
+++ b/java/debugger.jpda/src/org/netbeans/modules/debugger/jpda/models/ObjectLocalVariable.java
@@ -21,7 +21,6 @@
 
 import com.sun.jdi.AbsentInformationException;
 import com.sun.jdi.ClassNotLoadedException;
-import com.sun.jdi.InvalidStackFrameException;
 import com.sun.jdi.InvalidTypeException;
 import com.sun.jdi.LocalVariable;
 import com.sun.jdi.ObjectReference;