From db697ec3b933bba36ac42a158ed481b7a328ff75 Mon Sep 17 00:00:00 2001 From: Jan Faracik <43062514+janfaracik@users.noreply.github.com> Date: Wed, 15 Oct 2025 15:07:39 +0100 Subject: [PATCH 01/47] Init --- core/src/main/java/hudson/Functions.java | 11 ++ core/src/main/java/hudson/model/Action.java | 27 ++- .../main/java/hudson/model/Actionable.java | 29 +++ .../model/ModelObjectWithContextMenu.java | 185 +++++++++--------- .../main/java/jenkins/model/menu/Group.java | 39 ++++ .../java/jenkins/model/menu/Semantic.java | 9 + .../model/menu/event/ConfirmationEvent.java | 39 ++++ .../java/jenkins/model/menu/event/Event.java | 7 + .../model/menu/event/JavaScriptEvent.java | 34 ++++ .../jenkins/model/menu/event/LinkEvent.java | 40 ++++ .../jenkins/model/run/DeleteRunAction.java | 55 ++++++ .../java/jenkins/model/run/EditRunAction.java | 49 +++++ .../java/jenkins/model/run/KeepRunAction.java | 78 ++++++++ .../jenkins/model/run/Messages.properties | 3 + .../lib/layout/app-bar-controls.jelly | 50 +++++ .../resources/lib/layout/run-subpage.jelly | 11 +- src/main/js/app.js | 2 + src/main/js/components/app-bar/index.js | 37 ++++ .../js/components/dropdowns/hetero-list.js | 2 +- src/main/js/components/dropdowns/jumplists.js | 6 +- src/main/js/components/dropdowns/templates.js | 133 +++++++++++-- src/main/js/components/dropdowns/types.js | 70 +++++++ src/main/js/components/dropdowns/utils.js | 181 +++++++++++------ src/main/js/util/security.js | 4 + .../resources/images/symbols/lock-open.svg | 1 + 25 files changed, 922 insertions(+), 180 deletions(-) create mode 100644 core/src/main/java/jenkins/model/menu/Group.java create mode 100644 core/src/main/java/jenkins/model/menu/Semantic.java create mode 100644 core/src/main/java/jenkins/model/menu/event/ConfirmationEvent.java create mode 100644 core/src/main/java/jenkins/model/menu/event/Event.java create mode 100644 core/src/main/java/jenkins/model/menu/event/JavaScriptEvent.java create mode 100644 core/src/main/java/jenkins/model/menu/event/LinkEvent.java create mode 100644 core/src/main/java/jenkins/model/run/DeleteRunAction.java create mode 100644 core/src/main/java/jenkins/model/run/EditRunAction.java create mode 100644 core/src/main/java/jenkins/model/run/KeepRunAction.java create mode 100644 core/src/main/resources/jenkins/model/run/Messages.properties create mode 100644 core/src/main/resources/lib/layout/app-bar-controls.jelly create mode 100644 src/main/js/components/app-bar/index.js create mode 100644 src/main/js/components/dropdowns/types.js create mode 100644 war/src/main/resources/images/symbols/lock-open.svg diff --git a/core/src/main/java/hudson/Functions.java b/core/src/main/java/hudson/Functions.java index 3d2f25b535d5..1371bfd85bab 100644 --- a/core/src/main/java/hudson/Functions.java +++ b/core/src/main/java/hudson/Functions.java @@ -170,6 +170,7 @@ import jenkins.model.details.DetailFactory; import jenkins.model.details.DetailGroup; import jenkins.util.SystemProperties; +import net.sf.json.JSONObject; import org.apache.commons.jelly.JellyContext; import org.apache.commons.jelly.JellyTagException; import org.apache.commons.jelly.Script; @@ -2599,6 +2600,16 @@ public static String generateItemId() { return String.valueOf(Math.floor(Math.random() * 3000)); } + /** + * Converts the given actions to a JSON object + */ + @Restricted(NoExternalUse.class) + public static String convertActionsToJson(List actions) { + ModelObjectWithContextMenu.ContextMenu contextMenu = new ModelObjectWithContextMenu.ContextMenu(); + contextMenu.addAll(actions); + return JSONObject.fromObject(contextMenu).toString(); + } + /** * Returns a grouped list of Detail objects for the given Actionable object */ diff --git a/core/src/main/java/hudson/model/Action.java b/core/src/main/java/hudson/model/Action.java index 17b21a2ca7c9..a3b5057455ba 100644 --- a/core/src/main/java/hudson/model/Action.java +++ b/core/src/main/java/hudson/model/Action.java @@ -26,6 +26,10 @@ import edu.umd.cs.findbugs.annotations.CheckForNull; import hudson.Functions; +import jenkins.model.menu.Group; +import jenkins.model.menu.Semantic; +import jenkins.model.menu.event.Event; +import jenkins.model.menu.event.LinkEvent; /** * Object that contributes additional information, behaviors, and UIs to {@link ModelObject} @@ -137,6 +141,27 @@ public interface Action extends ModelObject { * null if this action object doesn't need to be bound to web * (when you do that, be sure to also return null from {@link #getIconFileName()}. * @see Functions#getActionUrl(String, Action) + * + * @deprecated + * Override {@link #getEvent()} instead */ - @CheckForNull String getUrlName(); + @CheckForNull default String getUrlName() { + return null; + } + + default Group getGroup() { + return Group.IN_MENU; + } + + default Event getEvent() { + return LinkEvent.of(getUrlName()); + } + + default Semantic getSemantic() { + return null; + } + + default boolean isVisibleInContextMenu() { + return true; + } } diff --git a/core/src/main/java/hudson/model/Actionable.java b/core/src/main/java/hudson/model/Actionable.java index 45a8978a0388..949211ef9d31 100644 --- a/core/src/main/java/hudson/model/Actionable.java +++ b/core/src/main/java/hudson/model/Actionable.java @@ -30,14 +30,20 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.Comparator; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.concurrent.CopyOnWriteArrayList; import java.util.logging.Level; import java.util.logging.Logger; +import java.util.stream.Collectors; import jenkins.model.ModelObjectWithContextMenu; +import jenkins.model.Tab; import jenkins.model.TransientActionFactory; import jenkins.security.stapler.StaplerNotDispatchable; +import org.apache.commons.lang.StringUtils; +import org.jenkins.ui.icon.IconSpec; import org.kohsuke.stapler.StaplerRequest; import org.kohsuke.stapler.StaplerRequest2; import org.kohsuke.stapler.StaplerResponse; @@ -113,6 +119,29 @@ public final List getAllActions() { return Collections.unmodifiableList(_actions); } + /** + * TODO + * @since TODO + */ + public List getAppBarActions() { + return getAllActions().stream() + .filter(e -> !(e instanceof Tab)) + .filter(e -> { + String icon = e.getIconFileName(); + + if (e instanceof IconSpec) { + if (((IconSpec) e).getIconClassName() != null) { + icon = ((IconSpec) e).getIconClassName(); + } + } + + return !StringUtils.isBlank(e.getDisplayName()) && !StringUtils.isBlank(icon); + }) + .sorted(Comparator.comparingInt((Action e) -> e.getGroup().getOrder()) + .thenComparing(e -> Objects.requireNonNullElse(e.getDisplayName(), ""))) + .collect(Collectors.toUnmodifiableList()); + } + private Collection createFor(TransientActionFactory taf) { try { Collection result = taf.createFor(taf.type().cast(this)); diff --git a/core/src/main/java/jenkins/model/ModelObjectWithContextMenu.java b/core/src/main/java/jenkins/model/ModelObjectWithContextMenu.java index 11eea481106a..9e787de70179 100644 --- a/core/src/main/java/jenkins/model/ModelObjectWithContextMenu.java +++ b/core/src/main/java/jenkins/model/ModelObjectWithContextMenu.java @@ -5,11 +5,11 @@ import hudson.Util; import hudson.model.Action; import hudson.model.Actionable; -import hudson.model.BallColor; import hudson.model.Computer; import hudson.model.Job; import hudson.model.ModelObject; import hudson.model.Node; +import hudson.model.Run; import hudson.slaves.Cloud; import jakarta.servlet.ServletException; import java.io.IOException; @@ -19,6 +19,9 @@ import java.util.Collection; import java.util.List; import jenkins.management.Badge; +import jenkins.model.menu.Group; +import jenkins.model.menu.Semantic; +import jenkins.model.menu.event.Event; import jenkins.security.stapler.StaplerNotDispatchable; import org.apache.commons.jelly.JellyContext; import org.apache.commons.jelly.JellyException; @@ -27,6 +30,7 @@ import org.apache.commons.jelly.XMLOutput; import org.jenkins.ui.icon.Icon; import org.jenkins.ui.icon.IconSet; +import org.jenkins.ui.icon.IconSpec; import org.jenkins.ui.symbol.Symbol; import org.jenkins.ui.symbol.SymbolRequest; import org.kohsuke.accmod.Restricted; @@ -106,102 +110,68 @@ public void generateResponse(StaplerRequest2 req, StaplerResponse2 rsp, Object o } public ContextMenu add(String url, String text) { - items.add(new MenuItem(url, null, text)); + items.add(new MenuItem().withUrl(url).withIcon(null).withDisplayName(text)); return this; } public ContextMenu addAll(Collection actions) { - for (Action a : actions) - add(a); + for (Action a : actions) { + if (a.isVisibleInContextMenu()) { + add(a); + } + } + return this; } /** * @see ContextMenuVisibility */ - public ContextMenu add(Action a) { - if (!Functions.isContextMenuVisible(a)) { + public ContextMenu add(Action action) { + MenuItem menuItem = new MenuItem() + .withDisplayName(action.getDisplayName()); + + menuItem.semantic = action.getSemantic(); + menuItem.group = action.getGroup(); + menuItem.event = action.getEvent(); + if (!Functions.isContextMenuVisible(action)) { return this; } StaplerRequest2 req = Stapler.getCurrentRequest2(); - String text = a.getDisplayName(); - String base = Functions.getIconFilePath(a); + String text = action.getDisplayName(); + String base = Functions.getIconFilePath(action); if (base == null) return this; - String url = Functions.getActionUrl(req.findAncestor(ModelObject.class).getUrl(), a); + String url = Functions.getActionUrl(req.findAncestor(ModelObject.class).getUrl(), action); if (base.startsWith("symbol-")) { Icon icon = Functions.tryGetIcon(base); - return add(url, icon.getClassSpec(), text); +// return add(url, icon.getClassSpec(), text); } else { String icon = Stapler.getCurrentRequest2().getContextPath() + (base.startsWith("images/") ? Functions.getResourcePath() : "") + '/' + base; - return add(url, icon, text); +// return add(url, icon, text); } - } +// } - public ContextMenu add(String url, String icon, String text) { - if (text != null && icon != null && url != null) - items.add(new MenuItem(url, icon, text)); - return this; - } +// if (action.getEvent().getClass() == LinkEvent.class) { +// menuItem.url = ((LinkEvent)action.getEvent()).getUrl(); +// } - /** @since 1.504 */ - public ContextMenu add(String url, String icon, String text, boolean post) { - if (text != null && icon != null && url != null) { - MenuItem item = new MenuItem(url, icon, text); - item.post = post; - items.add(item); + String icon = action.getIconFileName(); + if (action instanceof IconSpec) { + if (((IconSpec) action).getIconClassName() != null) { + icon = ((IconSpec) action).getIconClassName(); + } } - return this; - } + menuItem.icon = icon; - /** @since 1.512 */ - public ContextMenu add(String url, String icon, String text, boolean post, boolean requiresConfirmation) { - if (text != null && icon != null && url != null) { - MenuItem item = new MenuItem(url, icon, text); - item.post = post; - item.requiresConfirmation = requiresConfirmation; - items.add(item); + if (icon != null && icon.startsWith("symbol-")) { + menuItem.iconXml = Symbol.get(new SymbolRequest.Builder() + .withName(icon.split(" ")[0].substring(7)) + .withPluginName(Functions.extractPluginNameFromIconSrc(icon)) + .build()); } - return this; - } - /** @since 2.335 */ - public ContextMenu add(String url, String icon, String iconXml, String text, boolean post, boolean requiresConfirmation) { - if (text != null && icon != null && url != null) { - MenuItem item = new MenuItem(url, icon, text); - item.iconXml = iconXml; - item.post = post; - item.requiresConfirmation = requiresConfirmation; - items.add(item); - } - return this; - } - - /** @since 2.401 */ - public ContextMenu add(String url, String icon, String iconXml, String text, boolean post, boolean requiresConfirmation, Badge badge) { - if (text != null && icon != null && url != null) { - MenuItem item = new MenuItem(url, icon, text); - item.iconXml = iconXml; - item.post = post; - item.requiresConfirmation = requiresConfirmation; - item.badge = badge; - items.add(item); - } - return this; - } - - /** @since 2.415 */ - public ContextMenu add(String url, String icon, String iconXml, String text, boolean post, boolean requiresConfirmation, Badge badge, String message) { - if (text != null && icon != null && url != null) { - MenuItem item = new MenuItem(url, icon, text); - item.iconXml = iconXml; - item.post = post; - item.requiresConfirmation = requiresConfirmation; - item.badge = badge; - item.message = message; - items.add(item); - } - return this; + return add(menuItem); } /** @@ -245,9 +215,9 @@ public ContextMenu add(MenuItem item) { public ContextMenu add(Node n) { Computer c = n.toComputer(); return add(new MenuItem() - .withDisplayName(n.getDisplayName()) - .withStockIcon(c == null ? "computer.svg" : c.getIcon()) - .withContextRelativeUrl(n.getSearchUrl())); + .withDisplayName(n.getDisplayName()) + .withStockIcon(c == null ? "computer.svg" : c.getIcon()) + .withContextRelativeUrl(n.getSearchUrl())); } /** @@ -269,9 +239,9 @@ public ContextMenu add(Computer c) { */ public ContextMenu add(IComputer c) { return add(new MenuItem() - .withDisplayName(c.getDisplayName()) - .withIconClass(c.getIconClassName()) - .withContextRelativeUrl(c.getUrl())); + .withDisplayName(c.getDisplayName()) + .withIconClass(c.getIconClassName()) + .withContextRelativeUrl(c.getUrl())); } public ContextMenu add(Cloud c) { @@ -288,9 +258,23 @@ public ContextMenu add(Cloud c) { */ public ContextMenu add(Job job) { return add(new MenuItem() - .withDisplayName(job.getDisplayName()) - .withIcon(job.getIconColor()) - .withUrl(job.getSearchUrl())); + .withDisplayName(job.getDisplayName()) + .withIcon(job.getIconColor().getImage()) + .withUrl(job.getSearchUrl())); + } + + // Used in Jelly! - task.jelly + public ContextMenu add(String url, String icon, String iconXml, String text, boolean post, boolean requiresConfirmation, Badge badge, String message) { + if (text != null && icon != null && url != null) { + MenuItem item = new MenuItem().withUrl(url).withIcon(icon).withDisplayName(text); + item.iconXml = iconXml; + item.post = post; + item.requiresConfirmation = requiresConfirmation; + item.badge = badge; + item.message = message; + items.add(item); + } + return this; } /** @@ -311,11 +295,13 @@ public ContextMenu from(ModelObjectWithContextMenu self, StaplerRequest2 request return from(self, request, response, "sidepanel"); } - public ContextMenu from(ModelObjectWithContextMenu self, StaplerRequest request, StaplerResponse response) throws JellyException, IOException { - return from(self, StaplerRequest.toStaplerRequest2(request), StaplerResponse.toStaplerResponse2(response), "sidepanel"); - } - public ContextMenu from(ModelObjectWithContextMenu self, StaplerRequest2 request, StaplerResponse2 response, String view) throws JellyException, IOException { + // TODO - refactor this + if (self instanceof Run) { + this.addAll(((Actionable) self).getAppBarActions()); + return this; + } + WebApp webApp = WebApp.getCurrent(); final Script s = webApp.getMetaClass(self).getTearOff(JellyClassTearOff.class).findScript(view); if (s != null) { @@ -394,9 +380,14 @@ class MenuItem { @SuppressFBWarnings(value = "URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD", justification = "read by Stapler") public boolean requiresConfirmation; - private Badge badge; + private Group group; + + private Event event; + + private Semantic semantic; + private String message; /** @@ -428,13 +419,24 @@ public Badge getBadge() { return badge; } + @Exported(inline = true) + public Group getGroup() { + return group; + } + + @Exported(inline = true) + public Event getEvent() { + return event; + } + @Exported - public String getMessage() { - return message; + public Semantic getSemantic() { + return semantic; } - public MenuItem(String url, String icon, String displayName) { - withUrl(url).withIcon(icon).withDisplayName(displayName); + @Exported + public String getMessage() { + return message; } public MenuItem() { @@ -463,8 +465,9 @@ public MenuItem withIcon(String icon) { return this; } - public MenuItem withIcon(BallColor color) { - return withStockIcon(color.getImage()); + public MenuItem withIconXml(String icon) { + this.iconXml = icon; + return this; } /** diff --git a/core/src/main/java/jenkins/model/menu/Group.java b/core/src/main/java/jenkins/model/menu/Group.java new file mode 100644 index 000000000000..15a463ffab5b --- /dev/null +++ b/core/src/main/java/jenkins/model/menu/Group.java @@ -0,0 +1,39 @@ +package jenkins.model.menu; + +import org.kohsuke.stapler.export.Exported; +import org.kohsuke.stapler.export.ExportedBean; + +@ExportedBean +public class Group { + + private final int order; + + private Group(int order) { + if (order < 0) { + throw new RuntimeException("Action orders cannot be less than 0"); + } + + this.order = order; + } + + public static Group FIRST_IN_APP_BAR = of(0); + + public static Group IN_APP_BAR = of(1); + + public static Group LAST_IN_APP_BAR = of(2); + + public static Group FIRST_IN_MENU = of(3); + + public static Group IN_MENU = of(100); + + public static Group LAST_IN_MENU = of(Integer.MAX_VALUE); + + public static Group of(int customOrder) { + return new Group(customOrder); + } + + @Exported + public int getOrder() { + return order; + } +} diff --git a/core/src/main/java/jenkins/model/menu/Semantic.java b/core/src/main/java/jenkins/model/menu/Semantic.java new file mode 100644 index 000000000000..5ecc7057cde3 --- /dev/null +++ b/core/src/main/java/jenkins/model/menu/Semantic.java @@ -0,0 +1,9 @@ +package jenkins.model.menu; + +/** + * Enum of semantic colors available in Jenkins + */ +public enum Semantic { + BUILD, + DESTRUCTIVE +} diff --git a/core/src/main/java/jenkins/model/menu/event/ConfirmationEvent.java b/core/src/main/java/jenkins/model/menu/event/ConfirmationEvent.java new file mode 100644 index 000000000000..7933ff823af7 --- /dev/null +++ b/core/src/main/java/jenkins/model/menu/event/ConfirmationEvent.java @@ -0,0 +1,39 @@ +package jenkins.model.menu.event; + +import org.kohsuke.stapler.export.Exported; +import org.kohsuke.stapler.export.ExportedBean; + +@ExportedBean +public final class ConfirmationEvent implements Event { + + private final String title; + + private final String description; + + private final String postTo; + + private ConfirmationEvent(String title, String description, String postTo) { + this.title = title; + this.description = description; + this.postTo = postTo; + } + + public static ConfirmationEvent of(String title, String description, String postTo) { + return new ConfirmationEvent(title, description, postTo); + } + + @Exported + public String getTitle() { + return title; + } + + @Exported + public String getDescription() { + return description; + } + + @Exported + public String getPostTo() { + return postTo; + } +} diff --git a/core/src/main/java/jenkins/model/menu/event/Event.java b/core/src/main/java/jenkins/model/menu/event/Event.java new file mode 100644 index 000000000000..47244dd650e1 --- /dev/null +++ b/core/src/main/java/jenkins/model/menu/event/Event.java @@ -0,0 +1,7 @@ +package jenkins.model.menu.event; + +import org.kohsuke.stapler.export.ExportedBean; + +@ExportedBean +public interface Event { +} diff --git a/core/src/main/java/jenkins/model/menu/event/JavaScriptEvent.java b/core/src/main/java/jenkins/model/menu/event/JavaScriptEvent.java new file mode 100644 index 000000000000..7bd18481316c --- /dev/null +++ b/core/src/main/java/jenkins/model/menu/event/JavaScriptEvent.java @@ -0,0 +1,34 @@ +package jenkins.model.menu.event; + +import java.util.Map; +import jenkins.model.Jenkins; +import org.kohsuke.stapler.export.Exported; +import org.kohsuke.stapler.export.ExportedBean; + +@ExportedBean +public final class JavaScriptEvent implements Event { + + private final Map attributes; + + private final String javascriptUrl; + + private JavaScriptEvent(Map attributes, String javascriptUrl) { + this.attributes = attributes; + this.javascriptUrl = javascriptUrl; + } + + public static JavaScriptEvent of(Map attributes, String javascriptUrl) { + Jenkins jenkins = Jenkins.get(); + return new JavaScriptEvent(attributes, jenkins.getRootUrl() + javascriptUrl); + } + + @Exported + public Map getAttributes() { + return attributes; + } + + @Exported + public String getJavascriptUrl() { + return javascriptUrl; + } +} diff --git a/core/src/main/java/jenkins/model/menu/event/LinkEvent.java b/core/src/main/java/jenkins/model/menu/event/LinkEvent.java new file mode 100644 index 000000000000..515be0dc359b --- /dev/null +++ b/core/src/main/java/jenkins/model/menu/event/LinkEvent.java @@ -0,0 +1,40 @@ +package jenkins.model.menu.event; + +import org.kohsuke.stapler.export.Exported; +import org.kohsuke.stapler.export.ExportedBean; + +@ExportedBean +public final class LinkEvent implements Event { + + private final String url; + + private final LinkEventType type; + + private LinkEvent(String url, LinkEventType type) { + this.url = url; + this.type = type; + } + + public static LinkEvent of(String url) { + return new LinkEvent(url, LinkEventType.GET); + } + + public static LinkEvent of(String url, LinkEventType type) { + return new LinkEvent(url, type); + } + + @Exported + public String getUrl() { + return url; + } + + @Exported + public LinkEventType getType() { + return type; + } + + public enum LinkEventType { + GET, + POST + } +} diff --git a/core/src/main/java/jenkins/model/run/DeleteRunAction.java b/core/src/main/java/jenkins/model/run/DeleteRunAction.java new file mode 100644 index 000000000000..7e9de5634f02 --- /dev/null +++ b/core/src/main/java/jenkins/model/run/DeleteRunAction.java @@ -0,0 +1,55 @@ +package jenkins.model.run; + +import hudson.Extension; +import hudson.model.Action; +import hudson.model.Run; +import java.util.Collection; +import java.util.Set; +import jenkins.model.TransientActionFactory; +import jenkins.model.menu.Group; +import jenkins.model.menu.Semantic; +import jenkins.model.menu.event.ConfirmationEvent; +import jenkins.model.menu.event.Event; + +@Extension +public class DeleteRunAction extends TransientActionFactory { + + @Override + public Class type() { + return Run.class; + } + + @Override + public Collection createFor(Run target) { + if (!target.hasPermission(Run.DELETE)) { + return Set.of(); + } + + return Set.of(new Action() { + @Override + public String getDisplayName() { + return Messages.DeleteRunFactory_Delete(); + } + + @Override + public String getIconFileName() { + return "symbol-trash"; + } + + @Override + public Group getGroup() { + return Group.LAST_IN_MENU; + } + + @Override + public Event getEvent() { + return ConfirmationEvent.of(Messages.DeleteRunFactory_DeleteDialog_Title(), Messages.DeleteRunFactory_DeleteDialog_Description(), "doDelete"); + } + + @Override + public Semantic getSemantic() { + return Semantic.DESTRUCTIVE; + } + }); + } +} diff --git a/core/src/main/java/jenkins/model/run/EditRunAction.java b/core/src/main/java/jenkins/model/run/EditRunAction.java new file mode 100644 index 000000000000..1ecbb3e67907 --- /dev/null +++ b/core/src/main/java/jenkins/model/run/EditRunAction.java @@ -0,0 +1,49 @@ +package jenkins.model.run; + +import hudson.Extension; +import hudson.model.Action; +import hudson.model.Run; +import java.util.Collection; +import java.util.Set; +import jenkins.model.TransientActionFactory; +import jenkins.model.menu.Group; +import jenkins.model.menu.event.Event; +import jenkins.model.menu.event.LinkEvent; + +@Extension +public class EditRunAction extends TransientActionFactory { + + @Override + public Class type() { + return Run.class; + } + + @Override + public Collection createFor(Run target) { + if (!target.hasPermission(Run.UPDATE)) { + return Set.of(); + } + + return Set.of(new Action() { + @Override + public String getDisplayName() { + return target.hasPermission(Run.UPDATE) ? "Edit build information" : "View build information"; + } + + @Override + public String getIconFileName() { + return "symbol-edit"; + } + + @Override + public Group getGroup() { + return Group.FIRST_IN_MENU; + } + + @Override + public Event getEvent() { + return LinkEvent.of("configure"); + } + }); + } +} diff --git a/core/src/main/java/jenkins/model/run/KeepRunAction.java b/core/src/main/java/jenkins/model/run/KeepRunAction.java new file mode 100644 index 000000000000..2fe44c5df7ba --- /dev/null +++ b/core/src/main/java/jenkins/model/run/KeepRunAction.java @@ -0,0 +1,78 @@ +package jenkins.model.run; + +import hudson.Extension; +import hudson.model.Action; +import hudson.model.Run; +import hudson.security.Permission; +import java.util.Collection; +import java.util.Set; +import jenkins.model.TransientActionFactory; +import jenkins.model.menu.Group; +import jenkins.model.menu.event.Event; +import jenkins.model.menu.event.LinkEvent; + +@Extension +public class KeepRunAction extends TransientActionFactory { + + @Override + public Class type() { + return Run.class; + } + + @Override + public Collection createFor(Run target) { + if (!target.canToggleLogKeep()) { + return Set.of(); + } + + if (target.isKeepLog() && target.hasPermission(Permission.DELETE)) { + return Set.of(new Action() { + @Override + public String getDisplayName() { + return "Don't keep this build forever"; + } + + @Override + public String getIconFileName() { + return "symbol-lock-closed"; + } + + @Override + public Group getGroup() { + return Group.FIRST_IN_MENU; + } + + @Override + public Event getEvent() { + return LinkEvent.of("toggleLogKeep", LinkEvent.LinkEventType.POST); + } + }); + } + + if (!target.isKeepLog() && target.hasPermission(Permission.UPDATE)) { + return Set.of(new Action() { + @Override + public String getDisplayName() { + return "Keep this build forever"; + } + + @Override + public String getIconFileName() { + return "symbol-lock-open"; + } + + @Override + public Group getGroup() { + return Group.FIRST_IN_MENU; + } + + @Override + public Event getEvent() { + return LinkEvent.of("toggleLogKeep", LinkEvent.LinkEventType.POST); + } + }); + } + + return Set.of(); + } +} diff --git a/core/src/main/resources/jenkins/model/run/Messages.properties b/core/src/main/resources/jenkins/model/run/Messages.properties new file mode 100644 index 000000000000..70b95d40f0e5 --- /dev/null +++ b/core/src/main/resources/jenkins/model/run/Messages.properties @@ -0,0 +1,3 @@ +DeleteRunFactory.Delete=Delete build +DeleteRunFactory.DeleteDialog.Title=Delete build +DeleteRunFactory.DeleteDialog.Description=Are you sure you want to delete this build? diff --git a/core/src/main/resources/lib/layout/app-bar-controls.jelly b/core/src/main/resources/lib/layout/app-bar-controls.jelly new file mode 100644 index 000000000000..3b0f93155150 --- /dev/null +++ b/core/src/main/resources/lib/layout/app-bar-controls.jelly @@ -0,0 +1,50 @@ + + + + + + Generates a strip of controls based on the given actions parameter. + + + The actions to show. + + + + + + + + diff --git a/core/src/main/resources/lib/layout/run-subpage.jelly b/core/src/main/resources/lib/layout/run-subpage.jelly index 0da77a917243..9378dd395595 100644 --- a/core/src/main/resources/lib/layout/run-subpage.jelly +++ b/core/src/main/resources/lib/layout/run-subpage.jelly @@ -68,17 +68,8 @@ THE SOFTWARE.
- - -
- - -
-
-
+
- -
diff --git a/src/main/js/app.js b/src/main/js/app.js index c59153608751..44b0688538d2 100644 --- a/src/main/js/app.js +++ b/src/main/js/app.js @@ -1,3 +1,4 @@ +import AppBar from "@/components/app-bar"; import Dropdowns from "@/components/dropdowns"; import CommandPalette from "@/components/command-palette"; import Notifications from "@/components/notifications"; @@ -7,6 +8,7 @@ import StopButtonLink from "@/components/stop-button-link"; import ConfirmationLink from "@/components/confirmation-link"; import Dialogs from "@/components/dialogs"; +AppBar.init(); Dropdowns.init(); CommandPalette.init(); Notifications.init(); diff --git a/src/main/js/components/app-bar/index.js b/src/main/js/components/app-bar/index.js new file mode 100644 index 000000000000..7e11e0c6f430 --- /dev/null +++ b/src/main/js/components/app-bar/index.js @@ -0,0 +1,37 @@ +import behaviorShim from "@/util/behavior-shim"; +import Utils from "@/components/dropdowns/utils"; +import Templates from "@/components/dropdowns/templates"; + +function init() { + behaviorShim.specify("#auto-overflow", "-dropdowns-", 1000, (element) => { + const template = JSON.parse(element.nextSibling.content.textContent); + const appBarItems = Utils.mapChildrenItemsToDropdownItems( + template.items.filter((e) => e.group.order <= 2), + ); + const overflowItems = Utils.mapChildrenItemsToDropdownItems( + template.items.filter((e) => e.group.order > 2), + ); + + // Append top level items to the app bar + appBarItems.forEach((item, index) => { + // Only the first button in an app bar should have an icon + if (index > 0) { + item.icon = null; + item.iconXml = null; + } + element.parentNode.insertBefore( + Templates.menuItem(item, "jenkins-button"), + element, + ); + }); + + // Add any additional items as an overflow menu + if (overflowItems.length > 0) { + Utils.generateDropdown(element, (instance) => { + instance.setContent(Utils.generateDropdownItems(overflowItems)); + }); + } + }); +} + +export default { init }; diff --git a/src/main/js/components/dropdowns/hetero-list.js b/src/main/js/components/dropdowns/hetero-list.js index a685669cef2a..4384adb2f729 100644 --- a/src/main/js/components/dropdowns/hetero-list.js +++ b/src/main/js/components/dropdowns/hetero-list.js @@ -191,7 +191,7 @@ function generateButtons() { let disabled = oneEach && has(n.descriptorId); let type = disabled ? "DISABLED" : "button"; let item = { - label: n.title, + displayName: n.title, onClick: (event) => { event.preventDefault(); event.stopPropagation(); diff --git a/src/main/js/components/dropdowns/jumplists.js b/src/main/js/components/dropdowns/jumplists.js index 7d0324fba34c..70714025e2b9 100644 --- a/src/main/js/components/dropdowns/jumplists.js +++ b/src/main/js/components/dropdowns/jumplists.js @@ -100,11 +100,13 @@ function generateDropdowns() { .then((json) => instance.setContent( Utils.generateDropdownItems( - mapChildrenItemsToDropdownItems(json.items), + Utils.mapChildrenItemsToDropdownItems(json.items), + false, + href, ), ), ) - .catch((error) => console.log(`Jumplist request failed: ${error}`)) + .catch((error) => console.error(`Jumplist request failed:`, error)) .finally(() => (instance.loaded = true)); }), ); diff --git a/src/main/js/components/dropdowns/templates.js b/src/main/js/components/dropdowns/templates.js index 13694535b94a..e92433f45d67 100644 --- a/src/main/js/components/dropdowns/templates.js +++ b/src/main/js/components/dropdowns/templates.js @@ -1,5 +1,6 @@ import { createElementFromHtml } from "@/util/dom"; import { xmlEscape } from "@/util/security"; +import behaviorShim from "@/util/behavior-shim"; const hideOnPopperBlur = { name: "hideOnPopperBlur", @@ -60,15 +61,48 @@ function dropdown() { }; } -function menuItem(options) { +function kebabToCamelCase(str) { + return str.replace(/-([a-z])/g, function (match, char) { + return char.toUpperCase(); + }); +} + +function loadScriptIfNotLoaded(url, item) { + // Check if the script element with the given URL already exists + const existingScript = document.querySelector(`script[src="${url}"]`); + + if (!existingScript) { + const script = document.createElement("script"); + script.src = url; + + script.onload = () => { + // TODO - This is hacky + behaviorShim.applySubtree(item, true); + }; + + document.body.appendChild(script); + } +} + +/** + * Generates the contents for the dropdown + * @param {DropdownItem} menuItem + * @param {'jenkins-dropdown__item' | 'jenkins-button'} type + * @param {string} context + * @return {Element} TODO + */ +function menuItem(menuItem, type = "jenkins-dropdown__item", context = "") { + /** + * @type {DropdownItem} + */ const itemOptions = Object.assign( { type: "link", }, - options, + menuItem, ); - const label = xmlEscape(itemOptions.label); + const label = xmlEscape(itemOptions.displayName); let badgeText; let badgeTooltip; let badgeSeverity; @@ -77,12 +111,39 @@ function menuItem(options) { badgeTooltip = xmlEscape(itemOptions.badge.tooltip); badgeSeverity = xmlEscape(itemOptions.badge.severity); } - const tag = itemOptions.type === "link" ? "a" : "button"; + + // TODO - improve this + let clazz = + itemOptions.clazz + + (itemOptions.semantic + ? " jenkins-!-" + itemOptions.semantic.toLowerCase() + "-color" + : ""); + + // TODO - make this better + const tag = + itemOptions.event && itemOptions.event.type === "GET" ? "a" : "button"; + const url = tag === "a" ? context + xmlEscape(itemOptions.event.url) : ""; + + function optionalVal(key, val) { + if (val) { + return `${key}="${val}"` + } + + return ""; + } + + function optionalVals(keyVals) { + return Object.keys(keyVals).map(key => optionalVal(key, keyVals[key])).join(' '); + } const item = createElementFromHtml(` - <${tag} class="jenkins-dropdown__item ${itemOptions.clazz ? xmlEscape(itemOptions.clazz) : ""}" - ${itemOptions.url ? `href="${xmlEscape(itemOptions.url)}"` : ""} ${itemOptions.id ? `id="${xmlEscape(itemOptions.id)}"` : ""} - ${itemOptions.tooltip ? `data-html-tooltip="${xmlEscape(itemOptions.tooltip)}"` : ""}> + <${tag} + ${optionalVals({ + "class": type + " " + clazz, + "href": url, + "id": xmlEscape(itemOptions.id), + "data-html-tooltip": xmlEscape(itemOptions.tooltip) + })}> ${ itemOptions.icon ? `
${ @@ -99,19 +160,67 @@ function menuItem(options) { : `` } ${ - itemOptions.subMenu != null + itemOptions.event && itemOptions.event.actions ? `` : `` } `); - if (options.onClick) { - item.addEventListener("click", (event) => options.onClick(event)); + // Load script if needed + if (menuItem.event && menuItem.event.attributes) { + for (const key in menuItem.event.attributes) { + item.dataset[kebabToCamelCase(key)] = + menuItem.event.attributes[key].toString(); + } + + loadScriptIfNotLoaded(menuItem.event.javascriptUrl, item); } - if (options.onKeyPress) { - item.onkeypress = options.onKeyPress; + + // If generic onClick event + if (menuItem.onClick) { + item.addEventListener("click", menuItem.onClick); + } + + // If its a link + if (menuItem.event && menuItem.event.url && menuItem.event.type === "POST") { + item.addEventListener("click", () => { + const form = document.createElement("form"); + form.setAttribute("method", "POST"); + form.setAttribute("action", context + xmlEscape(itemOptions.event.url)); + crumb.appendToForm(form); + document.body.appendChild(form); + form.submit(); + }); + } + + // If its a confirmation dialog + if (menuItem.event && menuItem.event.postTo) { + item.addEventListener("click", () => { + dialog + .confirm(menuItem.event.title, { + message: menuItem.event.description, + type: menuItem.semantic.toLowerCase() ?? "default", + }) + .then( + () => { + const form = document.createElement("form"); + form.setAttribute("method", "POST"); + form.setAttribute( + "action", + context + xmlEscape(itemOptions.event.postTo), + ); + crumb.appendToForm(form); + document.body.appendChild(form); + form.submit(); + }, + () => {}, + ); + }); } + // if (options.onKeyPress) { + // item.onkeypress = options.onKeyPress; + // } return item; } diff --git a/src/main/js/components/dropdowns/types.js b/src/main/js/components/dropdowns/types.js new file mode 100644 index 000000000000..2d52236dc9cd --- /dev/null +++ b/src/main/js/components/dropdowns/types.js @@ -0,0 +1,70 @@ +/** + * @typedef MenuItemDropdownItem + * @type {object} + * @property {{order: number}} group + * + * @property {"ITEM"} type + * @property {string} [id] + * @property {string} displayName + * @property {string} [icon] + * @property {string} [iconXml] + * @property {{text: string, tooltip: string, severity: string}} badge + * @property {{ + * url: string, type?: 'GET' | 'POST' + * } | { + * title: string, description: string, postTo: string + * } | { + * attributes: {[key: string]: string}, javascriptUrl: string + * } | { + * actions: DropdownItem[] + * }} event + * @property {string} semantic + * @property {string} contents - TODO + * @property {string} clazz - TODO ??? not sure if this is staying + * @property {() => {}} onClick - TODO ??? not sure if this is staying + */ + +/** + * @typedef SubmenuDropdownItem + * @type {{ + * type: "SUBMENU"; + * }} + */ + +/** + * @typedef SeparatorDropdownItem + * @type {{ + * type: "SEPARATOR"; + * }} + */ + +/** + * @typedef HeaderDropdownItem + * @type {{ + * type: "HEADER"; + * displayName: string; + * }} + */ + +/** + * @typedef CustomDropdownItem + * @type {{ + * type: "CUSTOM"; + * displayName: string; + * }} + */ + +/** + * @typedef DropdownItem + * @type { + * MenuItemDropdownItem | + * SubmenuDropdownItem | + * SeparatorDropdownItem | + * HeaderDropdownItem | + * CustomDropdownItem + * } + */ + +/** + * @typedef {"ITEM" | "SUBMENU" | "SEPARATOR" | "HEADER" | "CUSTOM"} DropdownItemType + * */ diff --git a/src/main/js/components/dropdowns/utils.js b/src/main/js/components/dropdowns/utils.js index fd1cb05aab4c..365bcaba10c1 100644 --- a/src/main/js/components/dropdowns/utils.js +++ b/src/main/js/components/dropdowns/utils.js @@ -75,10 +75,13 @@ function generateDropdown(element, callback, immediate, options = {}) { ); } -/* +/** * Generates the contents for the dropdown + * @param {DropdownItem[]} items + * @param {boolean} compact + * @param {string} context */ -function generateDropdownItems(items, compact) { +function generateDropdownItems(items, compact = false, context = "") { const menuItems = document.createElement("div"); menuItems.classList.add("jenkins-dropdown"); if (compact === true) { @@ -92,7 +95,7 @@ function generateDropdownItems(items, compact) { } if (item.type === "HEADER") { - return Templates.heading(item.label); + return Templates.heading(item.displayName); } if (item.type === "SEPARATOR") { @@ -100,16 +103,20 @@ function generateDropdownItems(items, compact) { } if (item.type === "DISABLED") { - return Templates.disabled(item.label); + return Templates.disabled(item.displayName); } - const menuItem = Templates.menuItem(item); + const menuItem = Templates.menuItem( + item, + "jenkins-dropdown__item", + context, + ); - if (item.subMenu != null) { + if (item.event && item.event.actions != null) { tippy( menuItem, Object.assign({}, Templates.dropdown(), { - content: generateDropdownItems(item.subMenu()), + content: generateDropdownItems(item.event.actions), trigger: "mouseenter", placement: "right-start", offset: [-8, 0], @@ -190,95 +197,143 @@ function generateDropdownItems(items, compact) { return menuItems; } +function validateDropdown(e) { + if (e.targetUrl) { + const method = e.getAttribute("checkMethod") || "post"; + try { + FormChecker.delayedCheck(e.targetUrl(), method, e.targetElement); + } catch (x) { + console.warn(x); + } + } +} + +function getMaxSuggestionCount(e, defaultValue) { + return parseInt(e.dataset["maxsuggestions"]) || defaultValue; +} + +function debounce(callback) { + callback.running = false; + return () => { + if (!callback.running) { + callback.running = true; + setTimeout(() => { + callback(); + callback.running = false; + }, 300); + } + }; +} + +/** + * Generates the contents for the dropdown + * @param {DropdownItem[]} items + * @return {DropdownItem[]} + */ +function mapChildrenItemsToDropdownItems(items) { + /** @type {number | null} */ + let initialGroup = null; + + return items.flatMap((item) => { + if (item.type === "HEADER") { + return { + type: "HEADER", + label: item.displayName, + }; + } + + if (item.type === "SEPARATOR") { + return { + type: "SEPARATOR", + }; + } + + const response = []; + + if ( + initialGroup != null && + item.group?.order !== initialGroup && + item.group.order > 2 + ) { + response.push({ + type: "SEPARATOR", + }); + } + initialGroup = item.group?.order; + + response.push(item); + return response; + }); +} + +/** + * @param {HTMLElement[]} children + * @return {DropdownItem[]} + */ function convertHtmlToItems(children) { - const items = []; - Array.from(children).forEach((child) => { + return [...children].map((child) => { const attributes = child.dataset; + + /** @type {DropdownItemType} */ const type = child.dataset.dropdownType; switch (type) { case "ITEM": { + /** @type {MenuItemDropdownItem} */ const item = { - label: attributes.dropdownText, + type: "ITEM", + displayName: attributes.dropdownText, id: attributes.dropdownId, icon: attributes.dropdownIcon, iconXml: attributes.dropdownIcon, clazz: attributes.dropdownClazz, + semantic: attributes.dropdownSemantic, }; - if (attributes.dropdownHref) { - item.url = attributes.dropdownHref; - item.type = "link"; - } else { - item.type = "button"; + if (attributes.dropdownConfirmationTitle) { + item.event = { + title: attributes.dropdownConfirmationTitle, + description: attributes.dropdownConfirmationDescription, + postTo: attributes.dropdownConfirmationUrl, + }; } - if (attributes.dropdownBadgeSeverity) { - item.badge = { - text: attributes.dropdownBadgeText, - tooltip: attributes.dropdownBadgeTooltip, - severity: attributes.dropdownBadgeSeverity, + + if (attributes.dropdownHref) { + item.event = { + url: attributes.dropdownHref, + type: "GET", }; } - items.push(item); - break; + return item; } case "SUBMENU": - items.push({ + /** @type {MenuItemDropdownItem} */ + return { type: "ITEM", - label: attributes.dropdownText, + displayName: attributes.dropdownText, icon: attributes.dropdownIcon, iconXml: attributes.dropdownIcon, - subMenu: () => convertHtmlToItems(child.content.children), - }); - break; + event: { + actions: convertHtmlToItems(child.content.children), + }, + }; case "SEPARATOR": - items.push({ type: type }); - break; + return { type: type }; case "HEADER": - items.push({ type: type, label: attributes.dropdownText }); - break; + return { type: type, displayName: attributes.dropdownText }; case "CUSTOM": - items.push({ type: type, contents: child.content.cloneNode(true) }); - break; + return { type: type, contents: child.content.cloneNode(true) }; } }); - return items; -} - -function validateDropdown(e) { - if (e.targetUrl) { - const method = e.getAttribute("checkMethod") || "post"; - try { - FormChecker.delayedCheck(e.targetUrl(), method, e.targetElement); - } catch (x) { - console.warn(x); - } - } -} - -function getMaxSuggestionCount(e, defaultValue) { - return parseInt(e.dataset["maxsuggestions"]) || defaultValue; -} - -function debounce(callback) { - callback.running = false; - return () => { - if (!callback.running) { - callback.running = true; - setTimeout(() => { - callback(); - callback.running = false; - }, 300); - } - }; } export default { - convertHtmlToItems, generateDropdown, generateDropdownItems, validateDropdown, getMaxSuggestionCount, debounce, + mapChildrenItemsToDropdownItems, + convertHtmlToItems }; diff --git a/src/main/js/util/security.js b/src/main/js/util/security.js index 34de0031026d..f716fef0c534 100644 --- a/src/main/js/util/security.js +++ b/src/main/js/util/security.js @@ -1,4 +1,8 @@ function xmlEscape(str) { + if (str === null || str === undefined) { + return ""; + } + return str.replace(/[<>&'"]/g, (match) => { switch (match) { case "<": diff --git a/war/src/main/resources/images/symbols/lock-open.svg b/war/src/main/resources/images/symbols/lock-open.svg new file mode 100644 index 000000000000..c990a6045c1f --- /dev/null +++ b/war/src/main/resources/images/symbols/lock-open.svg @@ -0,0 +1 @@ + \ No newline at end of file From c09f2271e476b78641a4d0c6c2ed7799f62ad3f2 Mon Sep 17 00:00:00 2001 From: Jan Faracik <43062514+janfaracik@users.noreply.github.com> Date: Wed, 15 Oct 2025 15:52:45 +0100 Subject: [PATCH 02/47] Init --- .../resources/lib/layout/defer.children.jelly | 33 ++++++++++++++ .../src/main/resources/lib/layout/defer.jelly | 43 +++++++++++++++++++ .../lib/layout/defer.placeholder.jelly | 33 ++++++++++++++ .../main/resources/lib/layout/skeleton.jelly | 10 +++++ .../lib/layout/skeleton/skeleton.css | 18 ++++++++ src/main/js/app.js | 2 + src/main/js/components/defer/index.js | 12 ++++++ 7 files changed, 151 insertions(+) create mode 100644 core/src/main/resources/lib/layout/defer.children.jelly create mode 100644 core/src/main/resources/lib/layout/defer.jelly create mode 100644 core/src/main/resources/lib/layout/defer.placeholder.jelly create mode 100644 src/main/js/components/defer/index.js diff --git a/core/src/main/resources/lib/layout/defer.children.jelly b/core/src/main/resources/lib/layout/defer.children.jelly new file mode 100644 index 000000000000..4f9ee3ad8fb6 --- /dev/null +++ b/core/src/main/resources/lib/layout/defer.children.jelly @@ -0,0 +1,33 @@ + + + + + A lazy-loaded element. + + + + + + diff --git a/core/src/main/resources/lib/layout/defer.jelly b/core/src/main/resources/lib/layout/defer.jelly new file mode 100644 index 000000000000..d50204f996ac --- /dev/null +++ b/core/src/main/resources/lib/layout/defer.jelly @@ -0,0 +1,43 @@ + + + + + A lazy-loaded element. + + + +
+ +
+ + + + +
+ +
+
+
+
diff --git a/core/src/main/resources/lib/layout/defer.placeholder.jelly b/core/src/main/resources/lib/layout/defer.placeholder.jelly new file mode 100644 index 000000000000..4c6e11506f0f --- /dev/null +++ b/core/src/main/resources/lib/layout/defer.placeholder.jelly @@ -0,0 +1,33 @@ + + + + + A lazy-loaded element. + + + + + + diff --git a/core/src/main/resources/lib/layout/skeleton.jelly b/core/src/main/resources/lib/layout/skeleton.jelly index 1a4cdf1c25fd..20d6655f23bc 100644 --- a/core/src/main/resources/lib/layout/skeleton.jelly +++ b/core/src/main/resources/lib/layout/skeleton.jelly @@ -43,6 +43,16 @@ THE SOFTWARE.
+ +
+
+
+ + +
+
+
+
diff --git a/core/src/main/resources/lib/layout/skeleton/skeleton.css b/core/src/main/resources/lib/layout/skeleton/skeleton.css index cb65a6861ce3..bff078f36e21 100644 --- a/core/src/main/resources/lib/layout/skeleton/skeleton.css +++ b/core/src/main/resources/lib/layout/skeleton/skeleton.css @@ -1,4 +1,5 @@ .jenkins-side-panel-skeleton, +.jenkins-button-skeleton, .jenkins-form-skeleton { position: relative; display: flex; @@ -65,6 +66,23 @@ } } +.jenkins-button-skeleton { + display: contents; + + & > div { + width: 120px; + height: 38px !important; + border-radius: var(--form-input-border-radius); + margin-bottom: 0; + } +} + +.jenkins-small-button-skeleton { + & > div { + width: 52px; + } +} + .jenkins-side-panel-skeleton { gap: 0.125rem; diff --git a/src/main/js/app.js b/src/main/js/app.js index 44b0688538d2..c21991f6e826 100644 --- a/src/main/js/app.js +++ b/src/main/js/app.js @@ -7,10 +7,12 @@ import Tooltips from "@/components/tooltips"; import StopButtonLink from "@/components/stop-button-link"; import ConfirmationLink from "@/components/confirmation-link"; import Dialogs from "@/components/dialogs"; +import Defer from "@/components/defer"; AppBar.init(); Dropdowns.init(); CommandPalette.init(); +Defer.init(); Notifications.init(); SearchBar.init(); Tooltips.init(); diff --git a/src/main/js/components/defer/index.js b/src/main/js/components/defer/index.js new file mode 100644 index 000000000000..1bc7192be6c8 --- /dev/null +++ b/src/main/js/components/defer/index.js @@ -0,0 +1,12 @@ +import behaviorShim from "@/util/behavior-shim"; + +function init() { + behaviorShim.specify(".defer-element", "-defer-", 1000, (element) => { + const placeholder = element.previousElementSibling; + renderOnDemand(element, () => { + placeholder.remove(); + }); + }); +} + +export default { init }; From 370220d24e6cd8eeec53e7f79d7a1a39e79d67a8 Mon Sep 17 00:00:00 2001 From: Jan Faracik <43062514+janfaracik@users.noreply.github.com> Date: Fri, 17 Oct 2025 16:37:48 +0100 Subject: [PATCH 03/47] Init --- .../NewManageJenkinsUserExperimentalFlag.java | 49 +++++++++++++++++ .../jenkins/model/Jenkins/configure.jelly | 14 ++--- .../lib/layout/settings-subpage.jelly | 52 +++++++++++++++++++ 3 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 core/src/main/java/jenkins/model/experimentalflags/NewManageJenkinsUserExperimentalFlag.java create mode 100644 core/src/main/resources/lib/layout/settings-subpage.jelly diff --git a/core/src/main/java/jenkins/model/experimentalflags/NewManageJenkinsUserExperimentalFlag.java b/core/src/main/java/jenkins/model/experimentalflags/NewManageJenkinsUserExperimentalFlag.java new file mode 100644 index 000000000000..e04f7c60314a --- /dev/null +++ b/core/src/main/java/jenkins/model/experimentalflags/NewManageJenkinsUserExperimentalFlag.java @@ -0,0 +1,49 @@ +/* + * The MIT License + * + * Copyright (c) 2025, Jan Faracik + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.model.experimentalflags; + +import edu.umd.cs.findbugs.annotations.Nullable; +import hudson.Extension; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +@Extension +@Restricted(NoExternalUse.class) +public class NewManageJenkinsUserExperimentalFlag extends BooleanUserExperimentalFlag { + public NewManageJenkinsUserExperimentalFlag() { + super("new-manage-jenkins.flag"); + } + + @Override + public String getDisplayName() { + return "New Manage Jenkins UI"; + } + + @Nullable + @Override + public String getShortDescription() { + return "Enables a sidebar for the Manage Jenkins pages for easier navigation."; + } +} diff --git a/core/src/main/resources/jenkins/model/Jenkins/configure.jelly b/core/src/main/resources/jenkins/model/Jenkins/configure.jelly index c5644dd192ce..6774d0fb9996 100644 --- a/core/src/main/resources/jenkins/model/Jenkins/configure.jelly +++ b/core/src/main/resources/jenkins/model/Jenkins/configure.jelly @@ -26,12 +26,13 @@ THE SOFTWARE. Config page --> - - - - + + - + + + + @@ -63,6 +64,5 @@ THE SOFTWARE. - - + diff --git a/core/src/main/resources/lib/layout/settings-subpage.jelly b/core/src/main/resources/lib/layout/settings-subpage.jelly new file mode 100644 index 000000000000..567d479f8f0f --- /dev/null +++ b/core/src/main/resources/lib/layout/settings-subpage.jelly @@ -0,0 +1,52 @@ + + + + + + A reusable container for subpages relating under Manage Jenkins. + + + + + + + + + Experimental! + + + + + + + + + + + + + + + From f840a0003deed699d1308a27959e6505d764525b Mon Sep 17 00:00:00 2001 From: Jan Faracik <43062514+janfaracik@users.noreply.github.com> Date: Fri, 17 Oct 2025 16:58:57 +0100 Subject: [PATCH 04/47] Push --- .../resources/hudson/AboutJenkins/index.jelly | 17 +-- .../hudson/cli/CLIAction/index.jelly | 9 +- .../diagnosis/OldDataMonitor/manage.jelly | 6 +- .../logging/LogRecorderManager/index.jelly | 8 +- .../hudson/model/ComputerSet/index.jelly | 8 +- .../HudsonPrivateSecurityRealm/index.jelly | 110 +++++++++--------- .../jenkins/agents/CloudSet/index.jelly | 12 +- .../AppearanceGlobalConfiguration/index.jelly | 72 ++++++------ .../management/ShutdownLink/index.groovy | 44 ++++--- .../jenkins/model/Jenkins/configure.jelly | 4 +- .../model/Jenkins/load-statistics.jelly | 10 +- .../jenkins/model/Jenkins/systemInfo.jelly | 8 +- .../GlobalToolConfiguration/index.groovy | 6 +- .../resources/lib/hudson/scriptConsole.jelly | 8 +- .../main/resources/lib/layout/app-bar.jelly | 32 +++-- .../lib/layout/settings-subpage.jelly | 40 ++++++- 16 files changed, 205 insertions(+), 189 deletions(-) diff --git a/core/src/main/resources/hudson/AboutJenkins/index.jelly b/core/src/main/resources/hudson/AboutJenkins/index.jelly index 6d589663f576..ea5cac8073b3 100644 --- a/core/src/main/resources/hudson/AboutJenkins/index.jelly +++ b/core/src/main/resources/hudson/AboutJenkins/index.jelly @@ -25,12 +25,13 @@ THE SOFTWARE. - - -