Improved: Open FTL File from browser (OFBIZ-12018)

Add function to open the corresponding FTL file with IDE
when the named border is clicked from browser.

Thanks Rishi and Jacques for review
diff --git a/framework/base/src/main/java/org/apache/ofbiz/base/util/UtilHtml.java b/framework/base/src/main/java/org/apache/ofbiz/base/util/UtilHtml.java
index 5265304..4ca2e17 100644
--- a/framework/base/src/main/java/org/apache/ofbiz/base/util/UtilHtml.java
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/util/UtilHtml.java
@@ -125,11 +125,13 @@
                 String pluginPathKey = File.separator + "plugins" + File.separator;
                 for (File xmlTheme : xmlThemes) {
                     String path = xmlTheme.toURI().toURL().toString();
+                    // get the path after themes or plugins folders
                     if (path.indexOf(themePathKey) > 0) {
                         path = path.substring(path.indexOf(themePathKey) + 8);
                     } else if (path.indexOf(pluginPathKey) > 0) {
                         path = path.substring(path.indexOf(pluginPathKey) + 9);
                     }
+                    // get folder name
                     path = path.substring(0, path.indexOf(File.separator));
                     if (!path.contains("common-theme") && !path.contains("ecommerce")) {
                         visualThemeBasePathsName.add(File.separator + path + File.separator);
diff --git a/framework/common/src/main/java/org/apache/ofbiz/common/CommonEvents.java b/framework/common/src/main/java/org/apache/ofbiz/common/CommonEvents.java
index 3ecf80b..6313fbf 100644
--- a/framework/common/src/main/java/org/apache/ofbiz/common/CommonEvents.java
+++ b/framework/common/src/main/java/org/apache/ofbiz/common/CommonEvents.java
@@ -24,9 +24,12 @@
 import java.awt.Graphics2D;
 import java.awt.geom.AffineTransform;
 import java.awt.image.BufferedImage;
+import java.io.BufferedReader;
 import java.io.IOException;
+import java.io.InputStreamReader;
 import java.io.UnsupportedEncodingException;
 import java.io.Writer;
+import java.net.URL;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -42,17 +45,20 @@
 import org.apache.commons.lang.RandomStringUtils;
 import org.apache.commons.lang.StringUtils;
 import org.apache.ofbiz.base.lang.JSON;
+import org.apache.ofbiz.base.location.FlexibleLocation;
 import org.apache.ofbiz.base.util.Debug;
 import org.apache.ofbiz.base.util.UtilGenerics;
 import org.apache.ofbiz.base.util.UtilHttp;
 import org.apache.ofbiz.base.util.UtilProperties;
 import org.apache.ofbiz.base.util.UtilValidate;
+import org.apache.ofbiz.base.util.string.FlexibleStringExpander;
 import org.apache.ofbiz.entity.Delegator;
 import org.apache.ofbiz.entity.GenericEntityException;
 import org.apache.ofbiz.entity.GenericValue;
 import org.apache.ofbiz.entity.util.EntityUtilProperties;
 import org.apache.ofbiz.webapp.control.JWTManager;
 import org.apache.ofbiz.webapp.control.LoginWorker;
+import org.apache.ofbiz.widget.model.ModelWidget;
 import org.apache.ofbiz.widget.model.MultiBlockHtmlTemplateUtil;
 import org.apache.ofbiz.widget.model.ThemeFactory;
 import org.apache.ofbiz.widget.renderer.VisualTheme;
@@ -444,4 +450,36 @@
         return "success";
     }
 
+    public static String openSourceFile(HttpServletRequest request, HttpServletResponse response) {
+        ModelWidget.NamedBorderType namedBorderType = ModelWidget.widgetNamedBorderEnabled();
+        if (namedBorderType == ModelWidget.NamedBorderType.SOURCE) {
+            String sourceLocation = request.getParameter("sourceLocation");
+            if (UtilValidate.isNotEmpty(sourceLocation) && sourceLocation.startsWith("component:")) {
+                try {
+                    // find absolute path of file
+                    URL sourceFileUrl = FlexibleLocation.resolveLocation(sourceLocation);
+                    String location = sourceFileUrl.getFile();
+                    // prepare content map for string expansion
+                    Map<String, Object> sourceMap = new HashMap<>();
+                    sourceMap.put("sourceLocation", location);
+                    // get command to run
+                    String cmdTemplate = UtilProperties.getPropertyValue("widget", "widget.dev.cmd.openSourceFile");
+                    String cmd = (String) FlexibleStringExpander.getInstance(cmdTemplate).expand(sourceMap);
+                    // run command
+                    Process process = Runtime.getRuntime().exec(String.format(cmd, location));
+                    // print result
+                    BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
+                    String line = "";
+                    while ((line = reader.readLine()) != null) {
+                        Debug.logInfo(line, MODULE);
+                    }
+                    return "success";
+                } catch (IOException e) {
+                    Debug.logError(e, MODULE);
+                }
+            }
+        }
+        return "error";
+    }
+
 }
diff --git a/framework/common/webcommon/WEB-INF/common-controller.xml b/framework/common/webcommon/WEB-INF/common-controller.xml
index a2dd12a..36b10c9 100644
--- a/framework/common/webcommon/WEB-INF/common-controller.xml
+++ b/framework/common/webcommon/WEB-INF/common-controller.xml
@@ -336,6 +336,13 @@
         <response name="error" type="request" value="json"/>
     </request-map>
 
+    <request-map uri="openSourceFile">
+        <security https="false" auth="false"/>
+        <event type="java" path="org.apache.ofbiz.common.CommonEvents" invoke="openSourceFile"/>
+        <response name="success" type="none" />
+        <response name="error" type="none" />
+    </request-map>
+
     <!--========================== AJAX events =====================-->
 
     <!-- View Mappings -->
diff --git a/framework/widget/config/widget.properties b/framework/widget/config/widget.properties
index 306fb31..8d7a446 100644
--- a/framework/widget/config/widget.properties
+++ b/framework/widget/config/widget.properties
@@ -28,7 +28,19 @@
 widget.verbose=true
 
 # Enable widget named border for development
-widget.dev.namedBorder=true
+# NONE - For production where no named border will be shown.
+# LABEL - Show named border
+# SOURCE - Show named border with link to open the source code
+widget.dev.namedBorder=NONE
+
+# Command template to open file with editor
+# Linux:
+# idea ${sourceLocation}
+# eclipse --launcher.openFile ${sourceLocation}
+# Windows:
+# idea.exe ${sourceLocation}
+# eclipse.exe --launcher.openFile ${sourceLocation}
+widget.dev.cmd.openSourceFile=idea ${sourceLocation}
 
 # Default number of items to be displayed per page in a list form
 widget.form.defaultViewSize=20
diff --git a/framework/widget/src/main/java/org/apache/ofbiz/widget/model/HtmlWidget.java b/framework/widget/src/main/java/org/apache/ofbiz/widget/model/HtmlWidget.java
index 3cd8e5f..c75c06f 100644
--- a/framework/widget/src/main/java/org/apache/ofbiz/widget/model/HtmlWidget.java
+++ b/framework/widget/src/main/java/org/apache/ofbiz/widget/model/HtmlWidget.java
@@ -167,9 +167,16 @@
                 if (insertWidgetBoundaryComments) {
                     writer.append(HtmlWidgetRenderer.buildBoundaryComment("Begin", "Template", location));
                 }
-                boolean insertWidgetNamedBorder = !location.endsWith(".fo.ftl") && ModelWidget.widgetNamedBorderEnabled();
+                boolean insertWidgetNamedBorder = false;
+                NamedBorderType namedBorderType = null;
+                if (!location.endsWith(".fo.ftl")) {
+                    namedBorderType = ModelWidget.widgetNamedBorderEnabled();
+                    if (namedBorderType != NamedBorderType.NONE) {
+                        insertWidgetNamedBorder = true;
+                    }
+                }
                 if (insertWidgetNamedBorder) {
-                    writer.append(HtmlWidgetRenderer.buildNamedBorder("Begin", "Template", location));
+                    writer.append(HtmlWidgetRenderer.buildNamedBorder("Begin", "Template", location, namedBorderType));
                 }
 
                 Template template = null;
@@ -181,7 +188,7 @@
                 FreeMarkerWorker.renderTemplate(template, context, writer);
 
                 if (insertWidgetNamedBorder) {
-                    writer.append(HtmlWidgetRenderer.buildNamedBorder("End", "Template", location));
+                    writer.append(HtmlWidgetRenderer.buildNamedBorder("End", "Template", location, namedBorderType));
                 }
                 if (insertWidgetBoundaryComments) {
                     writer.append(HtmlWidgetRenderer.buildBoundaryComment("End", "Template", location));
diff --git a/framework/widget/src/main/java/org/apache/ofbiz/widget/model/ModelWidget.java b/framework/widget/src/main/java/org/apache/ofbiz/widget/model/ModelWidget.java
index 8630fd1..9393d7a 100644
--- a/framework/widget/src/main/java/org/apache/ofbiz/widget/model/ModelWidget.java
+++ b/framework/widget/src/main/java/org/apache/ofbiz/widget/model/ModelWidget.java
@@ -39,6 +39,7 @@
      * set to "widgetVerbose".
      */
     public static final String ENABLE_BOUNDARY_COMMENTS_PARAM = "widgetVerbose";
+    public enum NamedBorderType { NONE, LABEL, SOURCE }
 
     private final String name;
     private final String systemId;
@@ -152,10 +153,10 @@
     }
 
     /**
-     * Returns <code>true</code> if showing filename and border on the rendered part of the template.
-     * @return true if <code>widget.dev.namedBorder</code> is set to <code>true</code>
+     * determine how to display named border for development
+     * @return NamedBorderType from <code>widget.dev.namedBorder</code> property
      */
-    public static boolean widgetNamedBorderEnabled() {
-        return "true".equals(UtilProperties.getPropertyValue("widget", "widget.dev.namedBorder"));
+    public static NamedBorderType widgetNamedBorderEnabled() {
+        return NamedBorderType.valueOf(UtilProperties.getPropertyValue("widget", "widget.dev.namedBorder", NamedBorderType.NONE.toString()));
     }
 }
diff --git a/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/html/HtmlWidgetRenderer.java b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/html/HtmlWidgetRenderer.java
index 0b89029..e79a718 100644
--- a/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/html/HtmlWidgetRenderer.java
+++ b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/html/HtmlWidgetRenderer.java
@@ -75,16 +75,26 @@
         return "<!-- " + boundaryType + " " + widgetType + " " + widgetName + " -->" + WHITE_SPACE;
     }
 
-    public static String buildNamedBorder(String boundaryType, String widgetType, String widgetName) {
+    public static String buildNamedBorder(String boundaryType, String widgetType, String widgetName, ModelWidget.NamedBorderType namedBorderType) {
         List<String> themeBasePathsToExempt = UtilHtml.getVisualThemeFolderNamesToExempt();
         if (!themeBasePathsToExempt.stream().anyMatch(widgetName::contains)) {
             // add additional visual label for non-theme ftl
             switch (boundaryType) {
             case "End":
                 String fileName = widgetName.substring(widgetName.lastIndexOf(File.separator) + 1);
-                return "</div><div class='info-overlay'><span class='info-overlay-item'>"
-                        + fileName
-                        + "</span></div></div>";
+                switch (namedBorderType) {
+                case SOURCE:
+                    return "</div><div class='info-overlay'><span class='info-overlay-item'><a href='#' data-source='"
+                            + widgetName
+                            + "'>"
+                            + fileName
+                            + "</a></span></div></div>";
+                case LABEL:
+                    return "</div><div class='info-overlay'><span class='info-overlay-item'>"
+                            + fileName
+                            + "</span></div></div>";
+                default: return "";
+                }
             default:
                 return "<div class='info-container'><div class='info-content'>";
             }
diff --git a/themes/common-theme/webapp/common/js/util/OfbizUtil.js b/themes/common-theme/webapp/common/js/util/OfbizUtil.js
index c59fc35..11b4ab3 100644
--- a/themes/common-theme/webapp/common/js/util/OfbizUtil.js
+++ b/themes/common-theme/webapp/common/js/util/OfbizUtil.js
@@ -44,14 +44,28 @@
     bindObservers("body");
 
     function initNamedBorder() {
+        jQuery("[data-source]").off();
         // fadeout info-overlay labels
         setTimeout(function(){
             $('.info-overlay').fadeOut(1000, function(){
+                jQuery("[data-source]").off();
                 $('.info-container').contents().unwrap();
                 $('.info-content').contents().unwrap();
                 $('.info-overlay').delay(1000).remove();
             });
         }, 3000);
+        // clickable link in named border to open source file
+        jQuery("[data-source]").click(function(){
+            var sourceLocaton = jQuery(this).data("source");
+            jQuery.ajax({
+                url: 'openSourceFile',
+                type: "POST",
+                data: {sourceLocation:sourceLocaton},
+                success: function(data) {
+                    alert("Command is sent to open source file with your IDE");
+                }
+            });
+        });
     }
     initNamedBorder();
 });