This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch fix/CAMEL-25256
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 241ba3edc9a4681288a8748032002d5c1dece706
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Oct 2 08:24:05 2026 +0200

    CAMEL-25256: camel-jbang - source editor: refactorings (Ctrl+R) for Java 
and XML routes
    
    The refactoring menu of the source editor worked in YAML routes only. Now in
    Java and XML routes too:
    - Replace URI: the endpoint URI of the line (the string of from, to, toD,
      wireTap, enrich, pollEnrich, poll in Java; their uri attribute in XML,
      entities handled);
    - Extract to property: the string literal (Java) or attribute value (XML) at
      the cursor becomes a {{key}} placeholder, the value written to
      application.properties, in src/main/resources for a Maven or Gradle 
layout;
    - Extract to new file (XML): the step block at the cursor becomes a route 
from
      direct:name in name.camel.xml (added to it when it exists), a to in its 
place.
    Java has no extract to file: a step of a fluent chain is no block with clear
    edges.
    
    Docs, help and a screenshot updated.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../images/jbang/camel-tui-source-xml-refactor.png | Bin 0 -> 266917 bytes
 .../ROOT/pages/camel-jbang-tui-source-editor.adoc  |  13 +-
 .../jbang/core/commands/tui/RouteRefactorings.java | 248 +++++++++++++++++++++
 .../dsl/jbang/core/commands/tui/SourceViewer.java  | 137 +++++++++++-
 .../src/main/resources/tui/help/source.md          |   4 +-
 .../core/commands/tui/RouteRefactoringsTest.java   | 189 ++++++++++++++++
 6 files changed, 585 insertions(+), 6 deletions(-)

diff --git 
a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-xml-refactor.png 
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-xml-refactor.png
new file mode 100644
index 000000000000..6bcedc95c0ca
Binary files /dev/null and 
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-xml-refactor.png 
differ
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc
index f0b0721d5902..6996a256a66a 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc
@@ -29,10 +29,14 @@ The left panel lists the files of the integration's 
project, and the right panel
 highlighting. *Tab* switches between the two panels, and you can drag the 
border between them with the mouse.
 
 Press *Enter* to view a file, or *F4* to edit it. The editor is a plain-text 
editor with helpers for Camel routes:
-undo and redo, moving and duplicating YAML blocks, smart *Home*, and word 
navigation. In YAML routes, *Ctrl+R* opens a
-refactoring menu for the current line. *Ctrl+S* saves and keeps editing, *F5* 
saves and closes, and *Esc* cancels
+undo and redo, moving and duplicating YAML blocks, smart *Home*, and word 
navigation. *Ctrl+R* opens a refactoring menu
+for the current line: replace the endpoint URI of the line, extract the value 
at the cursor to a property (the value
+goes to `application.properties`, in `src/main/resources` for a Maven layout, 
and a `+{{key}}+` placeholder takes its
+place), and extract a step to a new route file, called with `direct:` (YAML 
and XML routes). *Ctrl+S* saves and keeps editing, *F5* saves and closes, and 
*Esc* cancels
 (and asks before it discards unsaved changes). A running integration in dev 
mode reloads the saved file.
 
+image::jbang/camel-tui-source-xml-refactor.png[Ctrl+R on a log step of an XML 
route: extract it to a new file or its message to a property]
+
 == Live Run Data
 
 While the integration runs, a column after the line numbers shows what the 
processors on each line do: the exchanges
@@ -176,7 +180,8 @@ project:
   Values are completed for options with a fixed set of values (enums, 
`true`/`false`) and `+{{placeholders}}+`.
 * *Simple expressions* -- header, property and variable names are the ones the 
file sets or reads and the headers of
   the components it uses, not the ones other routes or your own code set at 
runtime.
-* Nothing is completed from your own Java classes and beans, and there are no 
refactorings (rename, extract) or imports.
+* Nothing is completed from your own Java classes and beans, and there are no 
imports or refactorings beyond the
+  *Ctrl+R* menu (no rename, and no extract to file in Java, whose steps are no 
block with clear edges).
 
 For more than that, an AI coding agent is the more powerful companion: working 
together with you in the TUI, it knows
 Camel, reads and writes the whole project, runs the routes and checks them, 
and explains what it does as it goes. See
@@ -299,7 +304,7 @@ mode, which hides the line numbers and borders for easy 
copying.
 | *Ctrl+K* | Delete current line
 | *Ctrl+Left/Right* | Word navigation
 | *Home* | Smart home (content indent / column 0)
-| *Ctrl+R* | Refactoring menu (YAML)
+| *Ctrl+R* | Refactoring menu: replace URI, extract to property, extract a 
step to a new file (YAML, XML)
 | *F9* | Next problem
 | *Shift+F9* | Apply the quick fix of the problem on the line
 | *Shift+F8* | Ask the AI to fix the problem on the line
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteRefactorings.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteRefactorings.java
new file mode 100644
index 000000000000..66cea3eed012
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteRefactorings.java
@@ -0,0 +1,248 @@
+/*
+ * 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.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * The refactorings of the source editor (Ctrl+R) for Java and XML routes 
(CAMEL-25256), as {@link SourceRefactorings}
+ * has them for YAML: the endpoint URI of a line, the string literal (Java) or 
attribute value (XML) at the cursor, and
+ * the element block of an XML step, with what replaces them.
+ */
+final class RouteRefactorings {
+
+    /**
+     * A quoted value on a line: where its text starts and ends (inside the 
quotes), and the text as the route means it
+     * (escapes and entities undone).
+     */
+    record Value(int start, int end, String text) {
+    }
+
+    /** The DSL methods (Java) and elements (XML) whose argument or uri 
attribute is an endpoint URI. */
+    private static final String ENDPOINT_CALLS = 
"from|to|toD|wireTap|enrich|pollEnrich|poll";
+    private static final Pattern JAVA_URI = Pattern.compile(
+            "\\b(?:" + ENDPOINT_CALLS + 
")\\s*\\(\\s*\"((?:[^\"\\\\]|\\\\.)*)\"");
+    private static final Pattern XML_URI = Pattern.compile(
+            "<(?:[\\w-]+:)?(?:" + ENDPOINT_CALLS + 
")\\b[^>]*?\\suri\\s*=\\s*(\"([^\"]*)\"|'([^']*)')");
+    private static final Pattern XML_ATTRIBUTE = 
Pattern.compile("[\\w:.-]+\\s*=\\s*(\"([^\"]*)\"|'([^']*)')");
+
+    /** Elements that are no step to extract: the route, its input, and the 
parts of the steps that hold them. */
+    private static final Set<String> NOT_EXTRACTABLE = Set.of(
+            "routes", "route", "camel", "beans", "from", "when", "otherwise", 
"doCatch", "doFinally", "onFallback",
+            "expression", "simple", "constant", "header", "exchangeProperty", 
"variable", "jsonpath", "xpath",
+            "groovy", "language", "method", "tokenize", "jq", "ref", 
"datasonnet", "spel", "mvel", "ognl", "js",
+            "python", "java", "wasm", "xquery", "xtokenize", "hl7terser", 
"jactl", "csimple", "joor");
+
+    private RouteRefactorings() {
+    }
+
+    // ---- the endpoint URI of a line ----
+
+    /** The endpoint URI on the line: the string of from, to, toD... in Java, 
the uri attribute of those in XML. */
+    static Value uri(String dsl, String line) {
+        if (line == null) {
+            return null;
+        }
+        Matcher m = ("xml".equals(dsl) ? XML_URI : JAVA_URI).matcher(line);
+        if (!m.find()) {
+            return null;
+        }
+        if ("xml".equals(dsl)) {
+            int group = m.group(2) != null ? 2 : 3;
+            return new Value(m.start(group), m.end(group), 
xmlDecode(m.group(group)));
+        }
+        return new Value(m.start(1), m.end(1), javaUnescape(m.group(1)));
+    }
+
+    // ---- the value at the cursor ----
+
+    /** The string literal (Java) or attribute value (XML) the cursor is on; 
null when it is on none. */
+    static Value valueAt(String dsl, String line, int col) {
+        if (line == null || col < 0) {
+            return null;
+        }
+        if ("xml".equals(dsl)) {
+            Matcher m = XML_ATTRIBUTE.matcher(line);
+            while (m.find()) {
+                int group = m.group(2) != null ? 2 : 3;
+                // on the attribute name or in its value, the quotes included
+                if (col >= m.start() && col <= m.end()) {
+                    return new Value(m.start(group), m.end(group), 
xmlDecode(m.group(group)));
+                }
+            }
+            return null;
+        }
+        int open = -1;
+        for (int i = 0; i < line.length(); i++) {
+            char c = line.charAt(i);
+            if (open < 0 && c == '/' && i + 1 < line.length() && line.charAt(i 
+ 1) == '/') {
+                return null;
+            }
+            if (open >= 0 && c == '\\') {
+                i++;
+            } else if (c == '"') {
+                if (open < 0) {
+                    open = i;
+                } else {
+                    if (col >= open && col <= i + 1) {
+                        return new Value(open + 1, i, 
javaUnescape(line.substring(open + 1, i)));
+                    }
+                    open = -1;
+                }
+            }
+        }
+        return null;
+    }
+
+    /** Whether a value can be extracted to a property: something, and no 
placeholder already. */
+    static boolean isExtractable(Value v) {
+        return v != null && !v.text().isBlank() && !(v.text().startsWith("{{") 
&& v.text().endsWith("}}"));
+    }
+
+    /** The line with the value replaced, written as the DSL quotes it 
(escaped in Java, entities in XML). */
+    static String replace(String dsl, String line, Value v, String text) {
+        String written = "xml".equals(dsl) ? xmlEncode(text) : 
javaEscape(text);
+        return line.substring(0, v.start()) + written + 
line.substring(v.end());
+    }
+
+    /**
+     * The application.properties a property of the route file goes to: 
src/main/resources of a Maven or Gradle layout,
+     * else the file's own folder, as camel run reads it.
+     */
+    static Path propertiesFile(Path routeFile) {
+        Path dir = routeFile.toAbsolutePath().getParent();
+        for (Path p = dir; p != null; p = p.getParent()) {
+            if (p.getFileName() != null && 
"src".equals(p.getFileName().toString())) {
+                return p.resolve("main/resources/application.properties");
+            }
+        }
+        return dir.resolve("application.properties");
+    }
+
+    // ---- an XML step block ----
+
+    /** The rows of an XML element block, its start tag on the first. */
+    record Block(int start, int end, String element, int indent) {
+    }
+
+    private static final Pattern START_TAG = 
Pattern.compile("^(\\s*)<(?:[\\w-]+:)?([\\w-]+)");
+
+    /** The step block whose start tag is on the row; null when the row holds 
none, or no step to extract. */
+    static Block xmlStep(List<String> lines, int row) {
+        if (row < 0 || row >= lines.size()) {
+            return null;
+        }
+        Matcher m = START_TAG.matcher(lines.get(row));
+        if (!m.find() || NOT_EXTRACTABLE.contains(m.group(2))) {
+            return null;
+        }
+        // must be inside a route
+        XmlCompletionContext c = XmlCompletionContext.at(lines, row, 
m.start(2) - 1);
+        if (c == null || !c.path().contains("route")) {
+            return null;
+        }
+        int depth = 0;
+        for (int r = row; r < lines.size(); r++) {
+            depth += tagBalance(lines.get(r));
+            if (depth <= 0) {
+                return new Block(row, r, m.group(2), m.group(1).length());
+            }
+        }
+        return null;
+    }
+
+    /**
+     * The opening minus the closing tags of a line: self-closing tags, 
comments and processing instructions count 0.
+     */
+    static int tagBalance(String line) {
+        int balance = 0;
+        String s = line.replaceAll("<!--.*?-->", "").replaceAll("<\\?.*?\\?>", 
"");
+        Matcher m = Pattern.compile("<(/?)[\\w:-]+[^>]*?(/?)>").matcher(s);
+        while (m.find()) {
+            if (!m.group(1).isEmpty()) {
+                balance--;
+            } else if (m.group(2).isEmpty()) {
+                balance++;
+            }
+        }
+        return balance;
+    }
+
+    /** A new XML routes file with the block as a route consuming from 
direct:name. */
+    static String xmlRouteFile(String name, List<String> block, int indent) {
+        StringBuilder sb = new StringBuilder();
+        sb.append("<routes 
xmlns=\"http://camel.apache.org/schema/xml-io\";>\n");
+        sb.append(xmlRoute(name, block, indent));
+        sb.append("</routes>\n");
+        return sb.toString();
+    }
+
+    /** A route of the block consuming from direct:name, indented as a route 
of a routes file. */
+    static String xmlRoute(String name, List<String> block, int indent) {
+        StringBuilder sb = new StringBuilder();
+        sb.append("    <route id=\"").append(xmlEncode(name)).append("\">\n");
+        sb.append("        <from 
uri=\"direct:").append(xmlEncode(name)).append("\"/>\n");
+        for (String line : block) {
+            String body
+                    = line.length() >= indent && line.substring(0, 
indent).isBlank() ? line.substring(indent) : line.strip();
+            sb.append(body.isBlank() ? "" : "        " + body).append('\n');
+        }
+        sb.append("    </route>\n");
+        return sb.toString();
+    }
+
+    /** An existing XML routes file with the route added before its end tag; 
null when it has no routes end tag. */
+    static String addXmlRoute(String existing, String route) {
+        int end = existing.lastIndexOf("</routes>");
+        if (end < 0) {
+            return null;
+        }
+        return existing.substring(0, end) + route + existing.substring(end);
+    }
+
+    /** The lines with the block replaced by a to of direct:name, at the 
block's indent. */
+    static List<String> replaceWithTo(List<String> lines, Block block, String 
name) {
+        List<String> answer = new ArrayList<>(lines.subList(0, block.start()));
+        answer.add(" ".repeat(block.indent()) + "<to uri=\"direct:" + 
xmlEncode(name) + "\"/>");
+        answer.addAll(lines.subList(block.end() + 1, lines.size()));
+        return answer;
+    }
+
+    // ---- quoting ----
+
+    static String xmlDecode(String s) {
+        return s.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", 
"\"").replace("&apos;", "'")
+                .replace("&amp;", "&");
+    }
+
+    static String xmlEncode(String s) {
+        return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", 
"&gt;").replace("\"", "&quot;");
+    }
+
+    static String javaUnescape(String s) {
+        return s.replace("\\\"", "\"").replace("\\\\", "\\");
+    }
+
+    static String javaEscape(String s) {
+        return s.replace("\\", "\\\\").replace("\"", "\\\"");
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
index 692fa4bee868..337d19245337 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
@@ -988,7 +988,7 @@ class SourceViewer {
             applyBlockEdit(YamlBlockEditor.deleteLine(editLines(), 
editState.cursorRow()));
             return true;
         }
-        if (ke.hasCtrl() && ke.isCharIgnoreCase('r') && isCamelYamlFile()) {
+        if (ke.hasCtrl() && ke.isCharIgnoreCase('r') && (isCamelYamlFile() || 
uriCompletion != null)) {
             openRefactorPopup();
             return true;
         }
@@ -2998,6 +2998,11 @@ class SourceViewer {
             return;
         }
         String rawLine = editState.getLine(row);
+        if (!isCamelYamlFile()) {
+            // Java and XML routes (CAMEL-25256)
+            openRouteRefactorPopup(row, rawLine);
+            return;
+        }
         List<RefactorPopup.Action> actions = new ArrayList<>();
         // Extract to new file: available on any EIP step block in a YAML route
         if (isCamelYamlFile()) {
@@ -3021,12 +3026,142 @@ class SourceViewer {
         refactorPopup.open(actions, currentUri);
     }
 
+    /**
+     * The refactorings of a Java or XML route: replace the endpoint URI of 
the line, extract the value at the cursor to
+     * a property, and in XML extract the step block of the line to a new 
route file.
+     */
+    private void openRouteRefactorPopup(int row, String rawLine) {
+        String dsl = uriCompletion;
+        List<RefactorPopup.Action> actions = new ArrayList<>();
+        if ("xml".equals(dsl) && RouteRefactorings.xmlStep(editLines(), row) 
!= null) {
+            actions.add(RefactorPopup.Action.EXTRACT_TO_FILE);
+        }
+        RouteRefactorings.Value uri = RouteRefactorings.uri(dsl, rawLine);
+        if (uri != null) {
+            actions.add(RefactorPopup.Action.REPLACE_URI);
+        }
+        if (RouteRefactorings.isExtractable(RouteRefactorings.valueAt(dsl, 
rawLine, editState.cursorCol()))) {
+            actions.add(RefactorPopup.Action.EXTRACT_TO_PROPERTY);
+        }
+        if (actions.isEmpty()) {
+            notifySave("Nothing to refactor here: put the cursor on an 
endpoint URI, a value"
+                       + ("xml".equals(dsl) ? " or the start tag of a step" : 
""),
+                    false);
+            return;
+        }
+        refactorPopup = new RefactorPopup();
+        refactorPopup.open(actions, uri != null ? uri.text() : null);
+    }
+
+    private void applyRouteRefactoring(RefactorPopup.Request req, int row, 
String rawLine) {
+        String dsl = uriCompletion;
+        switch (req.action()) {
+            case REPLACE_URI -> {
+                RouteRefactorings.Value uri = RouteRefactorings.uri(dsl, 
rawLine);
+                if (uri != null) {
+                    setLine(row, RouteRefactorings.replace(dsl, rawLine, uri, 
req.value()));
+                    notifySave("Replaced URI with: " + req.value(), false);
+                }
+            }
+            case EXTRACT_TO_PROPERTY -> {
+                RouteRefactorings.Value v = RouteRefactorings.valueAt(dsl, 
rawLine, editState.cursorCol());
+                if (!RouteRefactorings.isExtractable(v) || req.value() == null 
|| req.value().isBlank()) {
+                    return;
+                }
+                String key = req.value().strip();
+                setLine(row, RouteRefactorings.replace(dsl, rawLine, v, "{{" + 
key + "}}"));
+                if (editableFile != null) {
+                    Path props = 
RouteRefactorings.propertiesFile(editableFile);
+                    try {
+                        Files.createDirectories(props.getParent());
+                        Files.writeString(props, key + "=" + v.text() + "\n", 
StandardCharsets.UTF_8,
+                                StandardOpenOption.CREATE, 
StandardOpenOption.APPEND);
+                    } catch (IOException e) {
+                        notifySave("Warning: could not write " + 
props.getFileName() + ": " + e.getMessage(), true);
+                        return;
+                    }
+                }
+                notifySave("Extracted to property: " + key, false);
+            }
+            case EXTRACT_TO_FILE -> applyXmlExtractToFile(row, req.value());
+        }
+    }
+
+    /** The line replaced in the edited text, the cursor kept on it. */
+    private void setLine(int row, String newLine) {
+        recordEditChange();
+        List<String> lines = editLines();
+        lines.set(row, newLine);
+        editState.setText(YamlBlockEditor.fromLines(lines));
+        SourceEditorNavigation.positionCursor(editState, row, 
countLeadingSpaces(newLine));
+    }
+
+    /** The XML step block of the row moved to a new route (from direct:name) 
in name.camel.xml, a to in its place. */
+    private void applyXmlExtractToFile(int row, String name) {
+        if (editableFile == null) {
+            notifySave("Cannot extract: file is not writable", true);
+            return;
+        }
+        name = sanitizeFileName(name);
+        if (name.isEmpty()) {
+            notifySave("Cannot extract: invalid file name", true);
+            return;
+        }
+        List<String> lines = editLines();
+        RouteRefactorings.Block block = RouteRefactorings.xmlStep(lines, row);
+        if (block == null) {
+            return;
+        }
+        List<String> blockLines = new ArrayList<>(lines.subList(block.start(), 
block.end() + 1));
+        String newFileName = name + ".camel.xml";
+        Path newFile = editableFile.getParent().resolve(newFileName);
+        String route = RouteRefactorings.xmlRoute(name, blockLines, 
block.indent());
+        String newContent;
+        boolean existed = Files.exists(newFile);
+        try {
+            if (existed) {
+                newContent = 
RouteRefactorings.addXmlRoute(Files.readString(newFile, 
StandardCharsets.UTF_8), route);
+                if (newContent == null) {
+                    notifySave(newFileName + " exists and has no </routes> to 
add the route to", true);
+                    return;
+                }
+            } else {
+                newContent = RouteRefactorings.xmlRouteFile(name, blockLines, 
block.indent());
+            }
+        } catch (IOException e) {
+            notifySave("Failed to read " + newFileName + ": " + 
e.getMessage(), true);
+            return;
+        }
+        recordEditChange();
+        
editState.setText(YamlBlockEditor.fromLines(RouteRefactorings.replaceWithTo(lines,
 block, name)));
+        SourceEditorNavigation.positionCursor(editState, block.start(), 
block.indent());
+        // saved at once, so the route index of the jump links sees both files 
(as the YAML extraction does)
+        try {
+            Files.writeString(editableFile, editState.text(), 
StandardCharsets.UTF_8);
+            dirty = false;
+            originalEditText = editState.text();
+            lineStatuses = null;
+            Files.writeString(newFile, newContent, StandardCharsets.UTF_8);
+        } catch (IOException e) {
+            notifySave("Failed to write " + newFileName + ": " + 
e.getMessage(), true);
+            return;
+        }
+        if (!existed && onFileCreated != null) {
+            onFileCreated.run();
+        }
+        notifySave(existed ? "Added route to " + newFileName : "Extracted to " 
+ newFileName, false);
+    }
+
     private void applyRefactoring(RefactorPopup.Request req) {
         int row = editState.cursorRow();
         if (row < 0 || row >= editState.lineCount()) {
             return;
         }
         String rawLine = editState.getLine(row);
+        if (!isCamelYamlFile()) {
+            applyRouteRefactoring(req, row, rawLine);
+            return;
+        }
         switch (req.action()) {
             case EXTRACT_TO_FILE -> applyExtractToFile(row, req.value());
             case REPLACE_URI -> applyReplaceUri(row, rawLine, req.value());
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md
index d47e8417cb24..2427517cebfb 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md
@@ -17,7 +17,9 @@ explorer on the left and a source viewer on the right.
 - **Esc** — cancel edit (in edit mode) or close viewer
 - **Ctrl+S** — save file and continue editing (Camel dev mode auto-reloads)
 - **F5** — save file and close editor (in edit mode)
-- **Ctrl+R** — open refactoring menu in edit mode (YAML files only; choose an 
action for the current line)
+- **Ctrl+R** — open refactoring menu in edit mode, for the current line: 
replace
+  the endpoint URI, extract the value at the cursor to a property, extract a 
step
+  to a new route file (YAML and XML)
 - **Space** — cycle format (YAML/Java/XML) for Camel routes
 - Quick documentation panel is shown at the bottom for Camel source files
   (YAML, XML and Java DSL routes: the component and options of an endpoint,
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteRefactoringsTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteRefactoringsTest.java
new file mode 100644
index 000000000000..993df7227ddd
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteRefactoringsTest.java
@@ -0,0 +1,189 @@
+/*
+ * 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.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The refactorings of the source editor (Ctrl+R) for Java and XML routes 
(CAMEL-25256).
+ */
+class RouteRefactoringsTest {
+
+    @TempDir
+    Path tempDir;
+
+    @BeforeEach
+    void setUp() {
+        Theme.resetForTesting();
+    }
+
+    @Test
+    void theEndpointUriOfALine() {
+        String java = "            .to(\"kafka:orders?brokers=a:9092\")";
+        RouteRefactorings.Value uri = RouteRefactorings.uri("java", java);
+        assertThat(uri.text()).isEqualTo("kafka:orders?brokers=a:9092");
+        assertThat(RouteRefactorings.replace("java", java, uri, "seda:orders"))
+                .isEqualTo("            .to(\"seda:orders\")");
+
+        String xml = "        <to id=\"x\" uri=\"kafka:orders?a=1&amp;b=2\"/>";
+        uri = RouteRefactorings.uri("xml", xml);
+        assertThat(uri.text()).isEqualTo("kafka:orders?a=1&b=2");
+        assertThat(RouteRefactorings.replace("xml", xml, uri, 
"seda:x?a=1&b=2"))
+                .isEqualTo("        <to id=\"x\" 
uri=\"seda:x?a=1&amp;b=2\"/>");
+
+        assertThat(RouteRefactorings.uri("java", "            
.log(\"Hello\")")).isNull();
+        assertThat(RouteRefactorings.uri("xml", "        <log 
message=\"uri\"/>")).isNull();
+    }
+
+    @Test
+    void theValueAtTheCursor() {
+        String java = "            .setHeader(\"region\", constant(\"EU 
\\\"west\\\"\"))";
+        RouteRefactorings.Value v = RouteRefactorings.valueAt("java", java, 
java.indexOf("EU"));
+        assertThat(v.text()).isEqualTo("EU \"west\"");
+        assertThat(RouteRefactorings.valueAt("java", java, 
java.indexOf("region")).text()).isEqualTo("region");
+        // not in a string, or in a comment
+        assertThat(RouteRefactorings.valueAt("java", java, 
java.indexOf("constant"))).isNull();
+        assertThat(RouteRefactorings.valueAt("java", "  // .log(\"x\")", 
12)).isNull();
+
+        String xml = "        <log message=\"Got ${body}\" 
loggingLevel=\"WARN\"/>";
+        assertThat(RouteRefactorings.valueAt("xml", xml, 
xml.indexOf("Got")).text()).isEqualTo("Got ${body}");
+        // on the attribute name too
+        assertThat(RouteRefactorings.valueAt("xml", xml, 
xml.indexOf("loggingLevel")).text()).isEqualTo("WARN");
+
+        assertThat(RouteRefactorings.isExtractable(new 
RouteRefactorings.Value(0, 0, "{{x}}"))).isFalse();
+        assertThat(RouteRefactorings.isExtractable(new 
RouteRefactorings.Value(0, 0, " "))).isFalse();
+    }
+
+    @Test
+    void thePropertiesFileOfARouteFile() {
+        
assertThat(RouteRefactorings.propertiesFile(Path.of("/p/src/main/java/com/acme/MyRoute.java")))
+                
.isEqualTo(Path.of("/p/src/main/resources/application.properties"));
+        
assertThat(RouteRefactorings.propertiesFile(Path.of("/p/routes/orders.camel.xml")))
+                .isEqualTo(Path.of("/p/routes/application.properties"));
+    }
+
+    private static final List<String> XML = List.of(
+            "<routes xmlns=\"http://camel.apache.org/schema/xml-io\";>",
+            "    <route id=\"orders\">",
+            "        <from uri=\"kafka:orders\"/>",
+            "        <split>",
+            "            <simple>${body}</simple>",
+            "            <to uri=\"direct:item\"/>",
+            "        </split>",
+            "        <log message=\"done\"/>",
+            "    </route>",
+            "</routes>");
+
+    @Test
+    void anXmlStepBlock() {
+        RouteRefactorings.Block split = RouteRefactorings.xmlStep(XML, 3);
+        assertThat(split).isEqualTo(new RouteRefactorings.Block(3, 6, "split", 
8));
+        assertThat(RouteRefactorings.xmlStep(XML, 7)).isEqualTo(new 
RouteRefactorings.Block(7, 7, "log", 8));
+        // the route, its from, an expression, and outside a route are no 
steps to extract
+        assertThat(RouteRefactorings.xmlStep(XML, 1)).isNull();
+        assertThat(RouteRefactorings.xmlStep(XML, 2)).isNull();
+        assertThat(RouteRefactorings.xmlStep(XML, 4)).isNull();
+        assertThat(RouteRefactorings.xmlStep(XML, 0)).isNull();
+
+        List<String> replaced = RouteRefactorings.replaceWithTo(XML, split, 
"items");
+        assertThat(replaced.get(3)).isEqualTo("        <to 
uri=\"direct:items\"/>");
+        assertThat(replaced.get(4)).isEqualTo("        <log 
message=\"done\"/>");
+
+        String file = RouteRefactorings.xmlRouteFile("items", XML.subList(3, 
7), 8);
+        assertThat(file).contains("<route id=\"items\">", "        <from 
uri=\"direct:items\"/>",
+                "        <split>", "            <simple>${body}</simple>", "   
     </split>");
+        assertThat(RouteRefactorings.addXmlRoute(file, "    <route 
id=\"more\"/>\n")).endsWith(
+                "    <route id=\"more\"/>\n</routes>\n");
+    }
+
+    @Test
+    void extractAJavaValueToAProperty() throws Exception {
+        Path dir = 
Files.createDirectories(tempDir.resolve("src/main/java/com/acme"));
+        SourceViewer viewer = viewer(dir.resolve("MyRoute.java"), """
+                public class MyRoute extends RouteBuilder {
+                    public void configure() {
+                        from("timer:tick")
+                            .log("Hello Camel");
+                    }
+                }
+                """, "java");
+        cursorAt(viewer, 3, "Camel\");");
+        refactor(viewer, "app.greeting");
+        assertThat(line(viewer, 3)).isEqualTo("            
.log(\"{{app.greeting}}\");");
+        
assertThat(Files.readString(tempDir.resolve("src/main/resources/application.properties")))
+                .isEqualTo("app.greeting=Hello Camel\n");
+    }
+
+    @Test
+    void extractAnXmlStepToANewFile() throws Exception {
+        Path file = tempDir.resolve("orders.camel.xml");
+        SourceViewer viewer = viewer(file, String.join("\n", XML) + "\n", 
"xml");
+        cursorAt(viewer, 3, "<split>");
+        // the menu: extract to new file first
+        refactor(viewer, "items");
+        assertThat(line(viewer, 3)).isEqualTo("        <to 
uri=\"direct:items\"/>");
+        assertThat(Files.readString(file)).contains("<to 
uri=\"direct:items\"/>").doesNotContain("<split>");
+        
assertThat(Files.readString(tempDir.resolve("items.camel.xml"))).contains("<from
 uri=\"direct:items\"/>",
+                "<split>", "<to uri=\"direct:item\"/>");
+    }
+
+    private SourceViewer viewer(Path file, String src, String dsl) throws 
Exception {
+        Files.writeString(file, src, StandardCharsets.UTF_8);
+        SourceViewer viewer = new SourceViewer();
+        viewer.setUriCompletion(dsl);
+        viewer.loadFile(file);
+        viewer.enterEditMode();
+        return viewer;
+    }
+
+    /** Ctrl+R, Enter on the first refactoring, the value typed, Enter. */
+    private static void refactor(SourceViewer viewer, String value) {
+        viewer.handleKeyEvent(KeyEvent.ofChar('r', KeyModifiers.CTRL));
+        viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, 
KeyModifiers.NONE));
+        for (char ch : value.toCharArray()) {
+            viewer.handleKeyEvent(KeyEvent.ofChar(ch, KeyModifiers.NONE));
+        }
+        viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, 
KeyModifiers.NONE));
+    }
+
+    /** Puts the cursor on the line, before the given text at its end. */
+    private static void cursorAt(SourceViewer viewer, int row, String tail) {
+        for (int i = 0; i < row; i++) {
+            viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, 
KeyModifiers.NONE));
+        }
+        viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.END, KeyModifiers.NONE));
+        for (int i = 0; i < tail.length(); i++) {
+            viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.LEFT, 
KeyModifiers.NONE));
+        }
+    }
+
+    private static String line(SourceViewer viewer, int row) {
+        return viewer.editText().split("\n")[row];
+    }
+}

Reply via email to