diff --git a/core/src/main/java/hudson/Functions.java b/core/src/main/java/hudson/Functions.java index 13a51432af..e525dd6c4e 100644 --- a/core/src/main/java/hudson/Functions.java +++ b/core/src/main/java/hudson/Functions.java @@ -55,6 +55,7 @@ import hudson.model.PaneStatusProperties; import hudson.model.ParameterDefinition; import hudson.model.ParameterDefinition.ParameterDescriptor; import hudson.model.PasswordParameterDefinition; +import hudson.model.RootAction; import hudson.model.Run; import hudson.model.Slave; import hudson.model.TimeZoneProperty; @@ -2097,6 +2098,46 @@ public class Functions { return url.endsWith(href); } + /** + * If the given {@code Action} is a {@link RootAction#isPrimaryAction() primary} {code RootAction}, or a parent of the current page, return {@code true}. + * Used in {@code actions.jelly} to decide if the action should shown in the main header or the hamburger. + */ + @Restricted(NoExternalUse.class) + public static boolean showInPrimaryHeader(Action action) { + // regular Actions can be injected into Jenkins via a factory. + if (action instanceof RootAction ra) { + if (ra.isPrimaryAction()) { + return true; + } + } + String path = Stapler.getCurrentRequest2().getPathInfo(); + if (path == null || path.equals("/")) { + // we are in the root page so there is nothing current + return false; + } + + String actionPath = action.getUrlName(); + if (actionPath == null) { + // we are not a primary action and can not ever be current when we have no URL + return false; + } + + // RootActions are not expected to start with a `/` but some do and some do, and not all Actions will be RootActions + // but path will always start with a "/" + if (!actionPath.startsWith("/")) { + actionPath = '/' + actionPath; + } + + // the action /foo should not match /foobar but should match /foo/bar + if (!actionPath.endsWith("/")) { + actionPath = actionPath + '/'; + } + if (!path.endsWith("/")) { + path = path + '/'; + } + return path.startsWith(actionPath); + } + /** * @deprecated From JEXL expressions ({@code ${…}}) in {@code *.jelly} files * you can use {@code [obj]} syntax to construct an {@code Object[]} diff --git a/core/src/main/java/jenkins/views/Header.java b/core/src/main/java/jenkins/views/Header.java index bbdfaeb85c..ad5ef4d5a8 100644 --- a/core/src/main/java/jenkins/views/Header.java +++ b/core/src/main/java/jenkins/views/Header.java @@ -3,8 +3,10 @@ package jenkins.views; import hudson.ExtensionComponent; import hudson.ExtensionList; import hudson.ExtensionPoint; +import hudson.Functions; import hudson.model.Action; import hudson.model.RootAction; +import java.io.IOException; import java.util.Comparator; import java.util.List; import java.util.Map; @@ -82,10 +84,24 @@ public abstract class Header implements ExtensionPoint { return Jenkins.get() .getActions() .stream() - .filter(e -> e.getIconFileName() != null || (e instanceof IconSpec is && is.getIconClassName() != null)) + .filter(e -> e.getIconFileName() != null || (e instanceof IconSpec is && is.getIconClassName() != null) || hasLegacyView(e)) .sorted(Comparator.comparingDouble( a -> rootActionsOrdinal.getOrDefault(a.getClass().getName(), Double.MAX_VALUE) ).reversed()) .toList(); } + + /** + * Jenkins will show actions with a custom action.jelly even if its getIconFileName returned {@code null}. + * @param action the action to check if it has a custom view. + * @return {@code true} iff the action has an {@code action.jelly} + */ + private static boolean hasLegacyView(Action action) { + try { + return Functions.hasView(action, "action"); + } catch (IOException ignored) { + // can not load the view so ignore for the header + return false; + } + } } diff --git a/core/src/main/resources/lib/layout/header/actions.jelly b/core/src/main/resources/lib/layout/header/actions.jelly index a7a4ae939e..8ce61d3c99 100644 --- a/core/src/main/resources/lib/layout/header/actions.jelly +++ b/core/src/main/resources/lib/layout/header/actions.jelly @@ -8,7 +8,7 @@ - + @@ -23,7 +23,7 @@ - + diff --git a/core/src/main/resources/lib/layout/header/primaryAction.jelly b/core/src/main/resources/lib/layout/header/primaryAction.jelly index a199bf00ee..bc91147f0b 100644 --- a/core/src/main/resources/lib/layout/header/primaryAction.jelly +++ b/core/src/main/resources/lib/layout/header/primaryAction.jelly @@ -14,59 +14,73 @@ - - - - rendering primary action: ${action.class.name} - - - - - + + + + + + + + + + + + + + + + rendering primary action: ${action.class.name} + + + + + - - - -
${action.displayName}
- -
${badge.tooltip}
+ + + +
${action.displayName}
+ +
${badge.tooltip}
+
+
+
+ + + + ${interactive} + root-action-${action.class.simpleName} + ${h.getActionUrl(app.url, action)} + + [0, 10] + + + + + ${interactive} + tooltip + ${interactive ? 'dropdown' : 'tooltip'} + mouseenter focus + ${interactive} + header-action + false + jenkins-button ${isCurrent ? '' : 'jenkins-button--tertiary'} + + ${action.displayName} + + + + + + + +
-
-
- - - - - ${interactive} - root-action-${action.class.simpleName} - ${h.getActionUrl(app.url, action)} - - [0, 10] - - - - - ${interactive} - tooltip - ${interactive ? 'dropdown' : 'tooltip'} - mouseenter focus - ${interactive} - header-action - false - jenkins-button ${isCurrent ? '' : 'jenkins-button--tertiary'} - - ${action.displayName} - - - - - - - - +
+
diff --git a/core/src/main/resources/lib/layout/header/secondaryAction.jelly b/core/src/main/resources/lib/layout/header/secondaryAction.jelly index 508fb3f794..5c8123de01 100644 --- a/core/src/main/resources/lib/layout/header/secondaryAction.jelly +++ b/core/src/main/resources/lib/layout/header/secondaryAction.jelly @@ -10,13 +10,27 @@ - - - - - + + + + + + + + + + + + + + + + + + + diff --git a/core/src/main/resources/lib/layout/task.jelly b/core/src/main/resources/lib/layout/task.jelly index 550d9282e5..c4bb01dcea 100644 --- a/core/src/main/resources/lib/layout/task.jelly +++ b/core/src/main/resources/lib/layout/task.jelly @@ -23,7 +23,7 @@ THE SOFTWARE. --> - + tag renders the left navigation bar of Jenkins. Each tag gets an icon and link. @@ -133,9 +133,11 @@ THE SOFTWARE. + + + + + + + + + + + + + + + + + + + + + + + + +
${title}
+ +
${badge.tooltip}
+
+
+
+ + + + + ${interactive} + root-action-${action.class.simpleName} + + # + ${href} + ${post} + ${confirmationMessage} + ${title} + ${destructive} + + + ${href} + + + [0, 10] + + + + + ${interactive} + tooltip + ${interactive ? 'dropdown' : 'tooltip'} + mouseenter focus + ${interactive} + header-action + false + jenkins-button ${isCurrent ? '' : 'jenkins-button--tertiary'} ${requiresConfirmation ? 'confirmation-link' : ''} + + ${title} + + + + + + + + +
+
+
+
+ + + + + + +
+ +
+ ${title} + +
+
+
+ + + + + +
+
+
diff --git a/core/src/test/java/hudson/FunctionsTest.java b/core/src/test/java/hudson/FunctionsTest.java index bcaa51300c..32fd49c433 100644 --- a/core/src/test/java/hudson/FunctionsTest.java +++ b/core/src/test/java/hudson/FunctionsTest.java @@ -31,12 +31,14 @@ import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.nullValue; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; +import hudson.console.ConsoleAnnotatorFactory.RootAction; import hudson.model.Action; import hudson.model.Computer; import hudson.model.Item; @@ -58,6 +60,9 @@ import java.util.regex.Pattern; import jenkins.model.Jenkins; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; import org.jvnet.hudson.test.Issue; import org.kohsuke.stapler.Ancestor; import org.kohsuke.stapler.Stapler; @@ -309,6 +314,13 @@ class FunctionsTest { return action; } + private static RootAction createMockRootAction(String uri, boolean primary) { + RootAction action = mock(RootAction.class); + when(action.getUrlName()).thenReturn(uri); + when(action.isPrimaryAction()).thenReturn(primary); + return action; + } + private static StaplerRequest2 createMockRequest(String contextPath) { StaplerRequest2 req = mock(StaplerRequest2.class); when(req.getContextPath()).thenReturn(contextPath); @@ -741,4 +753,47 @@ class FunctionsTest { assertEquals("http://acme.com/icon.svg", Functions.guessIcon("http://acme.com/icon.svg", "/jenkins")); assertEquals("https://acme.com/icon.svg", Functions.guessIcon("https://acme.com/icon.svg", "/jenkins")); } + + @ParameterizedTest + @ValueSource(strings = {"/myAction", "/myAction/", "/myAction/subPath"}) + void showInPrimaryHeader_shouldReturnTrueForCurrentAction(String path) { + StaplerRequest2 req = mock(StaplerRequest2.class); + when(req.getPathInfo()).thenReturn(path); + + Action action = createMockAction("myAction"); + try (MockedStatic mocked = mockStatic(Stapler.class)) { + mocked.when(Stapler::getCurrentRequest2).thenReturn(req); + assertTrue(Functions.showInPrimaryHeader(action)); + } + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"/myAction", "/myAction/", "/myAction/subPath"}) + void showInPrimaryHeader_shouldReturnTrueForPrimaryActions(String path) { + StaplerRequest2 req = mock(StaplerRequest2.class); + when(req.getPathInfo()).thenReturn(path); + + RootAction action = createMockRootAction("anything", true); + + try (MockedStatic mocked = mockStatic(Stapler.class)) { + mocked.when(Stapler::getCurrentRequest2).thenReturn(req); + assertTrue(Functions.showInPrimaryHeader(action)); + } + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"/myAction", "/myAction/", "/myAction/subPath"}) + void showInPrimaryHeader_shouldReturnFalseForNonPrimaryNonCurrentAction(String path) { + StaplerRequest2 req = mock(StaplerRequest2.class); + when(req.getPathInfo()).thenReturn(path); + + Action action = createMockRootAction("myAct", false); + try (MockedStatic mocked = mockStatic(Stapler.class)) { + mocked.when(Stapler::getCurrentRequest2).thenReturn(req); + assertFalse(Functions.showInPrimaryHeader(action)); + } + } + } diff --git a/src/main/scss/components/_side-panel-tasks.scss b/src/main/scss/components/_side-panel-tasks.scss index 65edd8dc13..1e94cdc56d 100644 --- a/src/main/scss/components/_side-panel-tasks.scss +++ b/src/main/scss/components/_side-panel-tasks.scss @@ -4,6 +4,7 @@ $background-outset: 0.7rem; #tasks, +.tasks, .subtasks { display: flex; flex-direction: column; @@ -44,68 +45,71 @@ $background-outset: 0.7rem; } } -#tasks .task { - margin: 0 calc($background-outset * -1); -} +#tasks, +.tasks { + .task { + margin: 0 calc($background-outset * -1); -#tasks .task .task-link { - @include mixins.item; + .task-link { + @include mixins.item; - display: flex; - align-items: center; - justify-content: flex-start; - padding: 0.5rem $background-outset; - gap: 0.65rem; - width: 100%; - font-size: var(--font-size-sm); - color: var(--text-color); - margin: 0; - transition: opacity var(--standard-transition); - - .task-icon-link { - display: inline-flex; - - svg, - img { - width: 1.375rem !important; - height: 1.375rem !important; + display: flex; + align-items: center; + justify-content: flex-start; + padding: 0.5rem $background-outset; + gap: 0.65rem; + width: 100%; + font-size: var(--font-size-sm); color: var(--text-color); + margin: 0; + transition: opacity var(--standard-transition); - * { - transition: stroke-width var(--standard-transition); + .task-icon-link { + display: inline-flex; + + svg, + img { + width: 1.375rem !important; + height: 1.375rem !important; + color: var(--text-color); + + * { + transition: stroke-width var(--standard-transition); + } + } + } + + .task-icon-badge { + margin-left: auto; + } + + .task-link-text { + display: contents; + word-break: word-break; + } + + &--active { + font-weight: 450; + cursor: default; + + svg * { + stroke-width: 35px; + } + + &::before { + background-color: var(--item-background--active) !important; + } + + &::after { + box-shadow: none !important; + } + } + + &:not(:hover, &:active, &:focus, &--active) { + &::before { + border-color: transparent; + } } } } - - .task-icon-badge { - margin-left: auto; - } - - .task-link-text { - display: contents; - word-break: word-break; - } - - &--active { - font-weight: 450; - cursor: default; - - svg * { - stroke-width: 35px; - } - - &::before { - background-color: var(--item-background--active) !important; - } - - &::after { - box-shadow: none !important; - } - } - - &:not(:hover, &:active, &:focus, &--active) { - &::before { - border-color: transparent; - } - } }