diff --git a/.github/renovate.json b/.github/renovate.json
index 90c5a18d9f6d..cfd588a66b8d 100644
--- a/.github/renovate.json
+++ b/.github/renovate.json
@@ -71,16 +71,6 @@
"org.fusesource.jansi:jansi"
]
},
- {
- "description": "Depends on commons-lang3 which is in progress for removal from core. See: https://issues.jenkins.io/browse/JENKINS-73355",
- "matchManagers": [
- "maven"
- ],
- "enabled": false,
- "matchPackageNames": [
- "org.apache.commons:commons-compress"
- ]
- },
{
"description": "Contains incompatible API changes and needs compatibility work. See: https://github.com/jenkinsci/jenkins/pull/4224",
"matchManagers": [
diff --git a/.github/workflows/publish-release-artifact.yml b/.github/workflows/publish-release-artifact.yml
index 3bd728e9632b..e7a8904e78dc 100644
--- a/.github/workflows/publish-release-artifact.yml
+++ b/.github/workflows/publish-release-artifact.yml
@@ -73,7 +73,7 @@ jobs:
wget -q https://get.jenkins.io/${REPO}/${PROJECT_VERSION}/${FILE_NAME}
- name: Upload Release Asset
id: upload-war
- uses: softprops/action-gh-release@01570a1f39cb168c169c802c3bceb9e93fb10974 # v2.1.0
+ uses: softprops/action-gh-release@7b4da11513bf3f43f9999e90eabced41ab8bb048 # v2.2.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -108,7 +108,7 @@ jobs:
- name: Upload Release Asset
id: upload-deb
if: always()
- uses: softprops/action-gh-release@01570a1f39cb168c169c802c3bceb9e93fb10974 # v2.1.0
+ uses: softprops/action-gh-release@7b4da11513bf3f43f9999e90eabced41ab8bb048 # v2.2.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -144,7 +144,7 @@ jobs:
- name: Upload Release Asset
id: upload-rpm
if: always()
- uses: softprops/action-gh-release@01570a1f39cb168c169c802c3bceb9e93fb10974 # v2.1.0
+ uses: softprops/action-gh-release@7b4da11513bf3f43f9999e90eabced41ab8bb048 # v2.2.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -180,7 +180,7 @@ jobs:
- name: Upload Release Asset
id: upload-msi
if: always()
- uses: softprops/action-gh-release@01570a1f39cb168c169c802c3bceb9e93fb10974 # v2.1.0
+ uses: softprops/action-gh-release@7b4da11513bf3f43f9999e90eabced41ab8bb048 # v2.2.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -216,7 +216,7 @@ jobs:
- name: Upload Release Asset
id: upload-suse-rpm
if: always()
- uses: softprops/action-gh-release@01570a1f39cb168c169c802c3bceb9e93fb10974 # v2.1.0
+ uses: softprops/action-gh-release@7b4da11513bf3f43f9999e90eabced41ab8bb048 # v2.2.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
diff --git a/ath.sh b/ath.sh
index e25bf4b945e8..3f6bca7f61bf 100644
--- a/ath.sh
+++ b/ath.sh
@@ -6,7 +6,7 @@ set -o xtrace
cd "$(dirname "$0")"
# https://github.com/jenkinsci/acceptance-test-harness/releases
-export ATH_VERSION=6081.v29b_ce3c2771c
+export ATH_VERSION=6107.v8c73fa_b_8f784
if [[ $# -eq 0 ]]; then
export JDK=17
diff --git a/bom/pom.xml b/bom/pom.xml
index 61de025b3e9b..a4c2dfa36e7d 100644
--- a/bom/pom.xml
+++ b/bom/pom.xml
@@ -62,7 +62,7 @@ THE SOFTWARE.
org.springframeworkspring-framework-bom
- 6.2.0
+ 6.2.1pomimport
@@ -70,7 +70,7 @@ THE SOFTWARE.
org.springframework.securityspring-security-bom
- 6.4.1
+ 6.4.2pomimport
@@ -88,7 +88,7 @@ THE SOFTWARE.
com.google.guavaguava
- 33.3.1-jre
+ 33.4.0-jre
@@ -191,11 +191,6 @@ THE SOFTWARE.
ant1.10.15
-
- org.apache.commons
- commons-compress
- 1.26.1
- org.apache.commonscommons-fileupload2
diff --git a/core/pom.xml b/core/pom.xml
index 9bff5e5ad0b2..0b9cb30d8763 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -285,17 +285,6 @@ THE SOFTWARE.
org.apache.antant
-
- org.apache.commons
- commons-compress
-
-
-
- org.apache.commons
- commons-lang3
-
-
- org.apache.commonscommons-fileupload2-core
diff --git a/core/src/main/java/hudson/Functions.java b/core/src/main/java/hudson/Functions.java
index 150b1b658c16..67801475233d 100644
--- a/core/src/main/java/hudson/Functions.java
+++ b/core/src/main/java/hudson/Functions.java
@@ -62,6 +62,7 @@
import hudson.model.View;
import hudson.scm.SCM;
import hudson.scm.SCMDescriptor;
+import hudson.search.SearchFactory;
import hudson.search.SearchableModelObject;
import hudson.security.ACL;
import hudson.security.AccessControlled;
@@ -124,6 +125,7 @@
import java.nio.charset.StandardCharsets;
import java.text.DateFormat;
import java.text.DecimalFormat;
+import java.text.MessageFormat;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
@@ -2576,4 +2578,34 @@ static String guessIcon(String iconGuess, String rootURL) {
public static String generateItemId() {
return String.valueOf(Math.floor(Math.random() * 3000));
}
+
+ @Restricted(NoExternalUse.class)
+ public static ExtensionList getSearchFactories() {
+ return SearchFactory.all();
+ }
+
+ /**
+ * @param keyboardShortcut the shortcut to be translated
+ * @return the translated shortcut, e.g. CMD+K to ⌘+K for macOS, CTRL+K for Windows
+ */
+ @Restricted(NoExternalUse.class)
+ public static String translateModifierKeysForUsersPlatform(String keyboardShortcut) {
+ StaplerRequest2 currentRequest = Stapler.getCurrentRequest2();
+ currentRequest.getWebApp().getDispatchValidator().allowDispatch(currentRequest, Stapler.getCurrentResponse2());
+ String userAgent = currentRequest.getHeader("User-Agent");
+
+ List platformsThatUseCommand = List.of("MAC", "IPHONE", "IPAD");
+ boolean useCmdKey = platformsThatUseCommand.stream().anyMatch(e -> userAgent.toUpperCase().contains(e));
+
+ return keyboardShortcut.replace("CMD", useCmdKey ? "⌘" : "CTRL");
+ }
+
+ @Restricted(NoExternalUse.class)
+ public static String formatMessage(String format, Object args) {
+ if (format == null) {
+ return args.toString();
+ }
+
+ return MessageFormat.format(format, args);
+ }
}
diff --git a/core/src/main/java/hudson/model/DirectoryBrowserSupport.java b/core/src/main/java/hudson/model/DirectoryBrowserSupport.java
index 72da0c7514c1..242975e2b13b 100644
--- a/core/src/main/java/hudson/model/DirectoryBrowserSupport.java
+++ b/core/src/main/java/hudson/model/DirectoryBrowserSupport.java
@@ -58,6 +58,8 @@
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
import jenkins.model.Jenkins;
import jenkins.security.MasterToSlaveCallable;
import jenkins.security.ResourceDomainConfiguration;
@@ -65,8 +67,6 @@
import jenkins.util.SystemProperties;
import jenkins.util.VirtualFile;
import org.apache.commons.io.IOUtils;
-import org.apache.tools.zip.ZipEntry;
-import org.apache.tools.zip.ZipOutputStream;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
import org.kohsuke.stapler.HttpResponse;
@@ -530,8 +530,8 @@ private static String createBackRef(int times) {
private static void zip(StaplerResponse2 rsp, VirtualFile root, VirtualFile dir, String glob) throws IOException, InterruptedException {
OutputStream outputStream = rsp.getOutputStream();
- try (ZipOutputStream zos = new ZipOutputStream(outputStream)) {
- zos.setEncoding(Charset.defaultCharset().displayName()); // TODO JENKINS-20663 make this overridable via query parameter
+ // TODO JENKINS-20663 make encoding overridable via query parameter
+ try (ZipOutputStream zos = new ZipOutputStream(outputStream, Charset.defaultCharset())) {
// TODO consider using run(Callable) here
if (glob.isEmpty()) {
diff --git a/core/src/main/java/hudson/model/Job.java b/core/src/main/java/hudson/model/Job.java
index d22c25e98e3d..1bbf3dad1a24 100644
--- a/core/src/main/java/hudson/model/Job.java
+++ b/core/src/main/java/hudson/model/Job.java
@@ -519,6 +519,11 @@ public boolean supportsLogRotator() {
return true;
}
+ @Override
+ public String getSearchIcon() {
+ return "symbol-status-" + this.getIconColor().getIconName();
+ }
+
@Override
protected SearchIndexBuilder makeSearchIndex() {
return super.makeSearchIndex().add(new SearchIndex() {
@@ -541,7 +546,7 @@ public void find(String token, List result) {
public void suggest(String token, List result) {
find(token, result);
}
- }).add("configure", "config", "configure");
+ });
}
@Override
@@ -853,7 +858,7 @@ public RunT getBuildForCLI(@Argument(required = true, metaVar = "BUILD#", usage
}
/**
- * Gets the youngest build #m that satisfies {@code n<=m}.
+ * Gets the oldest build #m that satisfies {@code m ≥ n}.
*
* This is useful when you'd like to fetch a build but the exact build might
* be already gone (deleted, rotated, etc.)
@@ -868,7 +873,7 @@ public RunT getNearestBuild(int n) {
}
/**
- * Gets the latest build #m that satisfies {@code m<=n}.
+ * Gets the newest build #m that satisfies {@code m ≤ n}.
*
* This is useful when you'd like to fetch a build but the exact build might
* be already gone (deleted, rotated, etc.)
@@ -977,7 +982,7 @@ public File getBuildDir() {
protected abstract void removeRun(RunT run);
/**
- * Returns the last build.
+ * Returns the newest build.
* @see LazyBuildMixIn#getLastBuild
*/
@Exported
diff --git a/core/src/main/java/hudson/model/User.java b/core/src/main/java/hudson/model/User.java
index 685a80e540a9..d40664ca2c7a 100644
--- a/core/src/main/java/hudson/model/User.java
+++ b/core/src/main/java/hudson/model/User.java
@@ -44,6 +44,7 @@
import hudson.security.AccessControlled;
import hudson.security.SecurityRealm;
import hudson.security.UserMayOrMayNotExistException2;
+import hudson.tasks.UserAvatarResolver;
import hudson.util.FormValidation;
import hudson.util.RunList;
import hudson.util.XStream2;
@@ -77,6 +78,7 @@
import jenkins.util.SystemProperties;
import org.jenkinsci.Symbol;
import org.kohsuke.accmod.Restricted;
+import org.kohsuke.accmod.restrictions.Beta;
import org.kohsuke.accmod.restrictions.NoExternalUse;
import org.kohsuke.stapler.StaplerProxy;
import org.kohsuke.stapler.StaplerRequest2;
@@ -277,6 +279,11 @@ public String getId() {
return "/user/" + Util.rawEncode(idStrategy().keyFor(id));
}
+ @Override
+ public String getSearchIcon() {
+ return UserAvatarResolver.resolve(this, "48x48");
+ }
+
/**
* The URL of the user page.
*/
@@ -673,9 +680,9 @@ public void doSubmitDescription(StaplerRequest2 req, StaplerResponse2 rsp) throw
}
/**
- * To be called from {@link Jenkins#reload} only.
+ * Called from {@link Jenkins#reload}.
*/
- @Restricted(NoExternalUse.class)
+ @Restricted(Beta.class)
public static void reload() throws IOException {
UserIdMapper.getInstance().reload();
AllUsers.reload();
diff --git a/core/src/main/java/hudson/model/View.java b/core/src/main/java/hudson/model/View.java
index b17077cde44d..722254572c0c 100644
--- a/core/src/main/java/hudson/model/View.java
+++ b/core/src/main/java/hudson/model/View.java
@@ -560,6 +560,11 @@ public String getSearchUrl() {
return getUrl();
}
+ @Override
+ public String getSearchIcon() {
+ return "symbol-jobs";
+ }
+
/**
* Returns the transient {@link Action}s associated with the top page.
*
diff --git a/core/src/main/java/hudson/search/Search.java b/core/src/main/java/hudson/search/Search.java
index 7773d9e9d696..97d15b65c2df 100644
--- a/core/src/main/java/hudson/search/Search.java
+++ b/core/src/main/java/hudson/search/Search.java
@@ -46,6 +46,8 @@
import jenkins.security.stapler.StaplerNotDispatchable;
import jenkins.util.MemoryReductionUtil;
import jenkins.util.SystemProperties;
+import org.jenkins.ui.symbol.Symbol;
+import org.jenkins.ui.symbol.SymbolRequest;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
import org.kohsuke.stapler.Ancestor;
@@ -56,6 +58,7 @@
import org.kohsuke.stapler.StaplerResponse;
import org.kohsuke.stapler.StaplerResponse2;
import org.kohsuke.stapler.export.DataWriter;
+import org.kohsuke.stapler.export.ExportConfig;
import org.kohsuke.stapler.export.Exported;
import org.kohsuke.stapler.export.ExportedBean;
import org.kohsuke.stapler.export.Flavor;
@@ -157,10 +160,23 @@ public void doSuggestOpenSearch(StaplerRequest2 req, StaplerResponse2 rsp, @Quer
*/
public void doSuggest(StaplerRequest2 req, StaplerResponse2 rsp, @QueryParameter String query) throws IOException, ServletException {
Result r = new Result();
- for (SuggestedItem item : getSuggestions(req, query))
- r.suggestions.add(new Item(item.getPath()));
+ for (SuggestedItem curItem : getSuggestions(req, query)) {
+ String iconName = curItem.item.getSearchIcon();
- rsp.serveExposedBean(req, r, Flavor.JSON);
+ if (iconName == null ||
+ (!iconName.startsWith("symbol-") && !iconName.startsWith("http"))
+ ) {
+ iconName = "symbol-search";
+ }
+
+ if (iconName.startsWith("symbol")) {
+ r.suggestions.add(new Item(curItem.getPath(), curItem.getUrl(),
+ Symbol.get(new SymbolRequest.Builder().withRaw(iconName).build())));
+ } else {
+ r.suggestions.add(new Item(curItem.getPath(), curItem.getUrl(), iconName, "image"));
+ }
+ }
+ rsp.serveExposedBean(req, r, new ExportConfig());
}
/**
@@ -252,12 +268,48 @@ public static class Result {
@ExportedBean(defaultVisibility = 999)
public static class Item {
+
@Exported
@SuppressFBWarnings(value = "URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD", justification = "read by Stapler")
public String name;
+ private final String url;
+
+ private final String type;
+
+ private final String icon;
+
public Item(String name) {
+ this(name, null, null);
+ }
+
+ public Item(String name, String url, String icon) {
+ this.name = name;
+ this.url = url;
+ this.icon = icon;
+ this.type = "symbol";
+ }
+
+ public Item(String name, String url, String icon, String type) {
this.name = name;
+ this.url = url;
+ this.icon = icon;
+ this.type = type;
+ }
+
+ @Exported
+ public String getUrl() {
+ return url;
+ }
+
+ @Exported
+ public String getIcon() {
+ return icon;
+ }
+
+ @Exported
+ public String getType() {
+ return type;
}
}
diff --git a/core/src/main/java/hudson/search/SearchItem.java b/core/src/main/java/hudson/search/SearchItem.java
index 02b2921b9741..e64efb0257ed 100644
--- a/core/src/main/java/hudson/search/SearchItem.java
+++ b/core/src/main/java/hudson/search/SearchItem.java
@@ -25,6 +25,7 @@
package hudson.search;
import hudson.model.Build;
+import org.jenkins.ui.icon.IconSpec;
/**
* Represents an item reachable from {@link SearchIndex}.
@@ -54,6 +55,14 @@ public interface SearchItem {
String getSearchUrl();
+ default String getSearchIcon() {
+ if (this instanceof IconSpec) {
+ return ((IconSpec) this).getIconClassName();
+ }
+
+ return "symbol-search";
+ }
+
/**
* Returns the {@link SearchIndex} to further search sub items inside this item.
*
diff --git a/core/src/main/java/hudson/search/SuggestedItem.java b/core/src/main/java/hudson/search/SuggestedItem.java
index 9ba455270e58..6911d002fe50 100644
--- a/core/src/main/java/hudson/search/SuggestedItem.java
+++ b/core/src/main/java/hudson/search/SuggestedItem.java
@@ -62,7 +62,7 @@ private void getPath(StringBuilder buf) {
buf.append(item.getSearchName());
else {
parent.getPath(buf);
- buf.append(' ').append(item.getSearchName());
+ buf.append(" » ").append(item.getSearchName());
}
}
diff --git a/core/src/main/java/hudson/tools/ZipExtractionInstaller.java b/core/src/main/java/hudson/tools/ZipExtractionInstaller.java
index 1e1c171f6a95..4646980ea8d6 100644
--- a/core/src/main/java/hudson/tools/ZipExtractionInstaller.java
+++ b/core/src/main/java/hudson/tools/ZipExtractionInstaller.java
@@ -43,6 +43,8 @@
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
+import java.nio.file.FileSystemNotFoundException;
+import java.nio.file.Path;
import jenkins.MasterToSlaveFileCallable;
import jenkins.model.Jenkins;
import org.jenkinsci.Symbol;
@@ -117,6 +119,14 @@ public FormValidation doCheckUrl(@QueryParameter String value) throws Interrupte
} catch (URISyntaxException e) {
return FormValidation.error(e, Messages.ZipExtractionInstaller_malformed_url());
}
+ if (uri.getScheme() != null && !uri.getScheme().startsWith("http")) {
+ try {
+ Path.of(uri);
+ return FormValidation.ok();
+ } catch (FileSystemNotFoundException | IllegalArgumentException e) {
+ return FormValidation.error(e, Messages.ZipExtractionInstaller_malformed_url());
+ }
+ }
HttpClient httpClient = ProxyConfiguration.newHttpClient();
HttpRequest httpRequest;
try {
diff --git a/core/src/main/java/jenkins/model/IComputer.java b/core/src/main/java/jenkins/model/IComputer.java
index 37fe0af98535..e00ee78bd30f 100644
--- a/core/src/main/java/jenkins/model/IComputer.java
+++ b/core/src/main/java/jenkins/model/IComputer.java
@@ -35,6 +35,7 @@
import jenkins.agents.IOfflineCause;
import org.jenkins.ui.icon.Icon;
import org.jenkins.ui.icon.IconSet;
+import org.jenkins.ui.icon.IconSpec;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.Beta;
@@ -44,7 +45,7 @@
* @since 2.480
*/
@Restricted(Beta.class)
-public interface IComputer extends AccessControlled {
+public interface IComputer extends AccessControlled, IconSpec {
/**
* Returns {@link Node#getNodeName() the name of the node}.
*/
diff --git a/core/src/main/java/jenkins/model/Jenkins.java b/core/src/main/java/jenkins/model/Jenkins.java
index bedbea0af7c4..66adc45f82e8 100644
--- a/core/src/main/java/jenkins/model/Jenkins.java
+++ b/core/src/main/java/jenkins/model/Jenkins.java
@@ -147,6 +147,7 @@
import hudson.scm.RepositoryBrowser;
import hudson.scm.SCM;
import hudson.search.CollectionSearchIndex;
+import hudson.search.SearchIndex;
import hudson.search.SearchIndexBuilder;
import hudson.search.SearchItem;
import hudson.security.ACL;
@@ -2345,11 +2346,29 @@ public String getSearchUrl() {
@Override
public SearchIndexBuilder makeSearchIndex() {
SearchIndexBuilder builder = super.makeSearchIndex();
- if (hasPermission(ADMINISTER)) {
- builder.add("configure", "config", "configure")
- .add("manage")
- .add("log");
- }
+
+ this.actions.stream().filter(e -> e.getIconFileName() != null).forEach(action -> builder.add(new SearchItem() {
+ @Override
+ public String getSearchName() {
+ return action.getDisplayName();
+ }
+
+ @Override
+ public String getSearchUrl() {
+ return action.getUrlName();
+ }
+
+ @Override
+ public String getSearchIcon() {
+ return action.getIconFileName();
+ }
+
+ @Override
+ public SearchIndex getSearchIndex() {
+ return SearchIndex.EMPTY;
+ }
+ }));
+
builder.add(new CollectionSearchIndex() {
@Override
protected SearchItem get(String key) { return getItemByFullName(key, TopLevelItem.class); }
@@ -3299,11 +3318,19 @@ public void load() throws IOException {
if (cfg.exists()) {
// reset some data that may not exist in the disk file
// so that we can take a proper compensation action later.
+ String originalPrimaryView = primaryView;
+ List originalViews = new ArrayList<>(views);
primaryView = null;
views.clear();
-
- // load from disk
- cfg.unmarshal(Jenkins.this);
+ try {
+ // load from disk
+ cfg.unmarshal(Jenkins.this);
+ } catch (IOException | RuntimeException x) {
+ primaryView = originalPrimaryView;
+ views.clear();
+ views.addAll(originalViews);
+ throw x;
+ }
}
// initialize views by inserting the default view if necessary
// this is both for clean Jenkins and for backward compatibility.
diff --git a/core/src/main/java/jenkins/model/PeepholePermalink.java b/core/src/main/java/jenkins/model/PeepholePermalink.java
index 62185f39f1ae..de6844acca55 100644
--- a/core/src/main/java/jenkins/model/PeepholePermalink.java
+++ b/core/src/main/java/jenkins/model/PeepholePermalink.java
@@ -4,6 +4,8 @@
import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import hudson.Extension;
+import hudson.ExtensionList;
+import hudson.ExtensionPoint;
import hudson.Util;
import hudson.model.Job;
import hudson.model.PermalinkProjectAction.Permalink;
@@ -14,6 +16,7 @@
import hudson.util.AtomicFileWriter;
import java.io.File;
import java.io.IOException;
+import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.HashMap;
@@ -24,6 +27,7 @@
import java.util.logging.Logger;
import java.util.stream.Stream;
import org.kohsuke.accmod.Restricted;
+import org.kohsuke.accmod.restrictions.Beta;
import org.kohsuke.accmod.restrictions.NoExternalUse;
/**
@@ -63,14 +67,6 @@
*/
public abstract class PeepholePermalink extends Permalink implements Predicate> {
- /**
- * JENKINS-22822: avoids rereading caches.
- * Top map keys are {@code builds} directories.
- * Inner maps are from permalink name to build number.
- * Synchronization is first on the outer map, then on the inner.
- */
- private static final Map> caches = new HashMap<>();
-
/**
* Checks if the given build satisfies the peep-hole criteria.
*
@@ -94,115 +90,216 @@ protected File getPermalinkFile(Job, ?> job) {
*/
@Override
public Run, ?> resolve(Job, ?> job) {
- Map cache = cacheFor(job.getBuildDir());
- int n;
- synchronized (cache) {
- n = cache.getOrDefault(getId(), 0);
+ return ExtensionList.lookupFirst(Cache.class).get(job, getId()).resolve(this, job, getId());
+ }
+
+ /**
+ * Start from the build 'b' and locate the build that matches the criteria going back in time
+ */
+ @CheckForNull
+ private Run, ?> find(@CheckForNull Run, ?> b) {
+ while (b != null && !apply(b)) {
+ b = b.getPreviousBuild();
}
- if (n == RESOLVES_TO_NONE) {
- return null;
+ return b;
+ }
+
+ /**
+ * Remembers the value 'n' in the cache for future {@link #resolve(Job)}.
+ */
+ protected void updateCache(@NonNull Job, ?> job, @CheckForNull Run, ?> b) {
+ ExtensionList.lookupFirst(Cache.class).put(job, getId(), b != null ? new Cache.Some(b.getNumber()) : Cache.NONE);
+ }
+
+ /**
+ * Persistable cache of peephole permalink targets.
+ */
+ @Restricted(Beta.class)
+ public interface Cache extends ExtensionPoint {
+
+ /** Cacheable target of a permalink. */
+ sealed interface PermalinkTarget extends Serializable {
+
+ /**
+ * Implementation of {@link #resolve(Job)}.
+ * This may update the cache if it was missing or found to be invalid.
+ */
+ @Restricted(NoExternalUse.class)
+ @CheckForNull
+ Run, ?> resolve(@NonNull PeepholePermalink pp, @NonNull Job, ?> job, @NonNull String id);
+
+ /**
+ * Partial implementation of {@link #resolve(PeepholePermalink, Job, String)} when searching.
+ * @param b if set, the newest build to even consider when searching
+ */
+ @Restricted(NoExternalUse.class)
+ @CheckForNull
+ default Run, ?> search(@NonNull PeepholePermalink pp, @NonNull Job, ?> job, @NonNull String id, @CheckForNull Run, ?> b) {
+ if (b == null) {
+ // no cache
+ b = job.getLastBuild();
+ }
+ // start from the build 'b' and locate the build that matches the criteria going back in time
+ b = pp.find(b);
+ pp.updateCache(job, b);
+ return b;
+ }
+
}
- Run, ?> b;
- if (n > 0) {
- b = job.getBuildByNumber(n);
- if (b != null && apply(b)) {
- return b; // found it (in the most efficient way possible)
+
+ /**
+ * The cache entry for this target is missing.
+ */
+ record Unknown() implements PermalinkTarget {
+ @Override
+ public Run, ?> resolve(PeepholePermalink pp, Job, ?> job, String id) {
+ return search(pp, job, id, null);
}
- } else {
- b = null;
}
- // the cache is stale. start the search
- if (b == null) {
- b = job.getNearestOldBuild(n);
+ Unknown UNKNOWN = new Unknown();
+
+ /**
+ * The cache entry for this target is present.
+ */
+ sealed interface Known extends PermalinkTarget {}
+
+ /** There is known to be no matching build. */
+ record None() implements Known {
+ @Override
+ public Run, ?> resolve(PeepholePermalink pp, Job, ?> job, String id) {
+ return null;
+ }
}
- if (b == null) {
- // no cache
- b = job.getLastBuild();
+ /** Singleton of {@link None}. */
+ None NONE = new None();
+
+ /** A matching build, indicated by {@link Run#getNumber}. */
+ record Some(int number) implements Known {
+ @Override
+ public Run, ?> resolve(PeepholePermalink pp, Job, ?> job, String id) {
+ Run, ?> b = job.getBuildByNumber(number);
+ if (b != null && pp.apply(b)) {
+ return b; // found it (in the most efficient way possible)
+ }
+ // the cache is stale. start the search
+ if (b == null) {
+ b = job.getNearestOldBuild(number);
+ }
+ return search(pp, job, id, b);
+ }
}
- // start from the build 'b' and locate the build that matches the criteria going back in time
- b = find(b);
+ /**
+ * Looks for any existing cache hit.
+ * @param id {@link #getId}
+ * @return {@link Some} or {@link #NONE} or {@link #UNKNOWN}
+ */
+ @NonNull PermalinkTarget get(@NonNull Job, ?> job, @NonNull String id);
- updateCache(job, b);
- return b;
+ /**
+ * Updates the cache.
+ * Note that this may be called not just when a build completes or is deleted
+ * (meaning that the logical value of the cache has changed),
+ * but also when {@link #resolve} has failed to find a cached value
+ * (or determined that a previously cached value is in fact invalid).
+ * @param id {@link #getId}
+ * @param target {@link Some} or {@link #NONE}
+ */
+ void put(@NonNull Job, ?> job, @NonNull String id, @NonNull Known target);
}
/**
- * Start from the build 'b' and locate the build that matches the criteria going back in time
+ * Default cache based on a {@code permalinks} file in the build directory.
+ * There is one line per cached permalink, in the format {@code lastStableBuild 123}
+ * or (for a negative cache) {@code lastFailedBuild -1}.
*/
- private Run, ?> find(Run, ?> b) {
- //noinspection StatementWithEmptyBody
- for ( ; b != null && !apply(b); b = b.getPreviousBuild())
- ;
- return b;
- }
+ @Restricted(NoExternalUse.class)
+ @Extension(ordinal = -1000)
+ public static final class DefaultCache implements Cache {
+
+ /**
+ * JENKINS-22822: avoids rereading caches.
+ * Top map keys are {@code builds} directories.
+ * Inner maps are from permalink name to target.
+ * Synchronization is first on the outer map, then on the inner.
+ */
+ private final Map> caches = new HashMap<>();
- private static @NonNull Map cacheFor(@NonNull File buildDir) {
- synchronized (caches) {
- Map cache = caches.get(buildDir);
- if (cache == null) {
- cache = load(buildDir);
- caches.put(buildDir, cache);
+ @Override
+ public PermalinkTarget get(Job, ?> job, String id) {
+ var cache = cacheFor(job.getBuildDir());
+ synchronized (cache) {
+ var cached = cache.get(id);
+ return cached != null ? cached : UNKNOWN;
}
- return cache;
}
- }
- private static @NonNull Map load(@NonNull File buildDir) {
- Map cache = new TreeMap<>();
- File storage = storageFor(buildDir);
- if (storage.isFile()) {
- try (Stream lines = Files.lines(storage.toPath(), StandardCharsets.UTF_8)) {
- lines.forEach(line -> {
- int idx = line.indexOf(' ');
- if (idx == -1) {
- return;
- }
+ @Override
+ public void put(Job, ?> job, String id, Known target) {
+ File buildDir = job.getBuildDir();
+ var cache = cacheFor(buildDir);
+ synchronized (cache) {
+ cache.put(id, target);
+ File storage = storageFor(buildDir);
+ LOGGER.fine(() -> "saving to " + storage + ": " + cache);
+ try (AtomicFileWriter cw = new AtomicFileWriter(storage)) {
try {
- cache.put(line.substring(0, idx), Integer.parseInt(line.substring(idx + 1)));
- } catch (NumberFormatException x) {
- LOGGER.log(Level.WARNING, "failed to read " + storage, x);
+ for (var entry : cache.entrySet()) {
+ cw.write(entry.getKey());
+ cw.write(' ');
+ cw.write(Integer.toString(entry.getValue() instanceof Cache.Some some ? some.number : -1));
+ cw.write('\n');
+ }
+ cw.commit();
+ } finally {
+ cw.abort();
}
- });
- } catch (IOException x) {
- LOGGER.log(Level.WARNING, "failed to read " + storage, x);
+ } catch (IOException x) {
+ LOGGER.log(Level.WARNING, "failed to update " + storage, x);
+ }
}
- LOGGER.fine(() -> "loading from " + storage + ": " + cache);
}
- return cache;
- }
- static @NonNull File storageFor(@NonNull File buildDir) {
- return new File(buildDir, "permalinks");
- }
+ private @NonNull Map cacheFor(@NonNull File buildDir) {
+ synchronized (caches) {
+ var cache = caches.get(buildDir);
+ if (cache == null) {
+ cache = load(buildDir);
+ caches.put(buildDir, cache);
+ }
+ return cache;
+ }
+ }
- /**
- * Remembers the value 'n' in the cache for future {@link #resolve(Job)}.
- */
- protected void updateCache(@NonNull Job, ?> job, @CheckForNull Run, ?> b) {
- File buildDir = job.getBuildDir();
- Map cache = cacheFor(buildDir);
- synchronized (cache) {
- cache.put(getId(), b == null ? RESOLVES_TO_NONE : b.getNumber());
+ private static @NonNull Map load(@NonNull File buildDir) {
+ Map cache = new TreeMap<>();
File storage = storageFor(buildDir);
- LOGGER.fine(() -> "saving to " + storage + ": " + cache);
- try (AtomicFileWriter cw = new AtomicFileWriter(storage)) {
- try {
- for (Map.Entry entry : cache.entrySet()) {
- cw.write(entry.getKey());
- cw.write(' ');
- cw.write(Integer.toString(entry.getValue()));
- cw.write('\n');
- }
- cw.commit();
- } finally {
- cw.abort();
+ if (storage.isFile()) {
+ try (Stream lines = Files.lines(storage.toPath(), StandardCharsets.UTF_8)) {
+ lines.forEach(line -> {
+ int idx = line.indexOf(' ');
+ if (idx == -1) {
+ return;
+ }
+ try {
+ int number = Integer.parseInt(line.substring(idx + 1));
+ cache.put(line.substring(0, idx), number == -1 ? Cache.NONE : new Cache.Some(number));
+ } catch (NumberFormatException x) {
+ LOGGER.log(Level.WARNING, "failed to read " + storage, x);
+ }
+ });
+ } catch (IOException x) {
+ LOGGER.log(Level.WARNING, "failed to read " + storage, x);
}
- } catch (IOException x) {
- LOGGER.log(Level.WARNING, "failed to update " + storage, x);
+ LOGGER.fine(() -> "loading from " + storage + ": " + cache);
}
+ return cache;
+ }
+
+ static @NonNull File storageFor(@NonNull File buildDir) {
+ return new File(buildDir, "permalinks");
}
}
@@ -380,7 +477,5 @@ public boolean apply(Run, ?> run) {
@Restricted(NoExternalUse.class)
public static void initialized() {}
- private static final int RESOLVES_TO_NONE = -1;
-
private static final Logger LOGGER = Logger.getLogger(PeepholePermalink.class.getName());
}
diff --git a/core/src/main/java/jenkins/model/experimentalflags/RemoveYuiUserExperimentalFlag.java b/core/src/main/java/jenkins/model/experimentalflags/RemoveYuiUserExperimentalFlag.java
index e8f8dcc31775..418f9c4b5406 100644
--- a/core/src/main/java/jenkins/model/experimentalflags/RemoveYuiUserExperimentalFlag.java
+++ b/core/src/main/java/jenkins/model/experimentalflags/RemoveYuiUserExperimentalFlag.java
@@ -24,8 +24,10 @@
package jenkins.model.experimentalflags;
+import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.Nullable;
import hudson.Extension;
+import jenkins.util.SystemProperties;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
@@ -46,4 +48,10 @@ public String getDisplayName() {
public String getShortDescription() {
return "Remove YUI from all Jenkins UI pages. This will break anything that depends on YUI";
}
+
+ @NonNull
+ @Override
+ public Boolean getDefaultValue() {
+ return SystemProperties.getBoolean(RemoveYuiUserExperimentalFlag.class.getName() + ".defaultValue", true);
+ }
}
diff --git a/core/src/main/java/jenkins/telemetry/Telemetry.java b/core/src/main/java/jenkins/telemetry/Telemetry.java
index 1f60e319f97d..de081c16954b 100644
--- a/core/src/main/java/jenkins/telemetry/Telemetry.java
+++ b/core/src/main/java/jenkins/telemetry/Telemetry.java
@@ -130,6 +130,11 @@ public static ExtensionList all() {
return ExtensionList.lookup(Telemetry.class);
}
+ @Restricted(NoExternalUse.class) // called by jelly
+ public static boolean isAnyTrialActive() {
+ return all().stream().anyMatch(Telemetry::isActivePeriod);
+ }
+
/**
* @since 2.147
* @return whether to collect telemetry
diff --git a/core/src/main/java/jenkins/util/VirtualFile.java b/core/src/main/java/jenkins/util/VirtualFile.java
index 6c754a50bbc4..36279a8ec6fa 100644
--- a/core/src/main/java/jenkins/util/VirtualFile.java
+++ b/core/src/main/java/jenkins/util/VirtualFile.java
@@ -60,6 +60,8 @@
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
import jenkins.MasterToSlaveFileCallable;
import jenkins.model.ArtifactManager;
import jenkins.security.MasterToSlaveCallable;
@@ -68,8 +70,6 @@
import org.apache.tools.ant.types.selectors.SelectorUtils;
import org.apache.tools.ant.types.selectors.TokenizedPath;
import org.apache.tools.ant.types.selectors.TokenizedPattern;
-import org.apache.tools.zip.ZipEntry;
-import org.apache.tools.zip.ZipOutputStream;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
@@ -375,8 +375,8 @@ public int zip(OutputStream outputStream, String includes, String excludes, bool
}
Collection files = list(includes, excludes, useDefaultExcludes, openOptions);
- try (ZipOutputStream zos = new ZipOutputStream(outputStream)) {
- zos.setEncoding(Charset.defaultCharset().displayName()); // TODO JENKINS-20663 make this overridable via query parameter
+ // TODO JENKINS-20663 make encoding overridable via query parameter
+ try (ZipOutputStream zos = new ZipOutputStream(outputStream, Charset.defaultCharset())) {
for (String relativePath : files) {
VirtualFile virtualFile = this.child(relativePath);
diff --git a/core/src/main/java/org/jenkins/ui/icon/IconSpec.java b/core/src/main/java/org/jenkins/ui/icon/IconSpec.java
index c44e577e1ce0..f1bf535b6ca6 100644
--- a/core/src/main/java/org/jenkins/ui/icon/IconSpec.java
+++ b/core/src/main/java/org/jenkins/ui/icon/IconSpec.java
@@ -27,8 +27,7 @@
/**
* Icon Specification.
*
- * Plugin extension points that implement/extend Action/ManagementLink should
- * also implement this interface.
+ * If your class provides an icon spec you should implement this interface.
*
* @author tom.fennelly@gmail.com
* @since 2.0
diff --git a/core/src/main/resources/hudson/model/UsageStatistics/global.groovy b/core/src/main/resources/hudson/model/UsageStatistics/global.groovy
index 5506a37a67d5..7254eb283a60 100644
--- a/core/src/main/resources/hudson/model/UsageStatistics/global.groovy
+++ b/core/src/main/resources/hudson/model/UsageStatistics/global.groovy
@@ -8,7 +8,7 @@ def f=namespace(lib.FormTagLib)
f.section(title: _("Usage Statistics")) {
if (UsageStatistics.DISABLED) {
- span(class: "jenkins-not-applicable") {
+ div(class: "jenkins-not-applicable jenkins-description") {
raw(_("disabledBySystemProperty"))
}
} else if (FIPS140.useCompliantAlgorithms()) {
diff --git a/core/src/main/resources/hudson/model/UsageStatistics/help-usageStatisticsCollected.jelly b/core/src/main/resources/hudson/model/UsageStatistics/help-usageStatisticsCollected.jelly
index a1bef14d4e22..2d01661a175b 100644
--- a/core/src/main/resources/hudson/model/UsageStatistics/help-usageStatisticsCollected.jelly
+++ b/core/src/main/resources/hudson/model/UsageStatistics/help-usageStatisticsCollected.jelly
@@ -1,14 +1,14 @@
-
+
For any project, it's critical to know how the software is used, but tracking usage data is inherently difficult in open-source projects.
Anonymous usage statistics address this need.
When enabled, Jenkins periodically sends information to the Jenkins project.
The Jenkins project uses this information to set development priorities.
-
+
-
General usage statistics
+
General usage statistics
Jenkins reports the following general usage statistics:
@@ -24,7 +24,7 @@
-
Telemetry collection
+
Telemetry collection
@@ -38,24 +38,34 @@
Each trial has a specific purpose and a defined end date, after which collection stops, independent of the installed versions of Jenkins or plugins.
Once a trial is complete, the trial results may be aggregated and shared with the developer community.
-
- The following trials defined on this instance are active now or in the future:
-
+
+
-
-
-
-
${collector.displayName}
-
-
-
- Start date: ${collector.start}
- End date: ${collector.end}
-
-
-
-
-
+
+
+
${%There are currently no active trials.}
+
+
+
+ The following trials defined on this instance are active now or in the future:
+
+
+
+
+
${collector.displayName}
+
+
+
+ Start date: ${collector.start}
+
+ End date: ${collector.end}
+
+
+
+
+
+
+
diff --git a/core/src/main/resources/jenkins/model/Jenkins/sidepanel.jelly b/core/src/main/resources/jenkins/model/Jenkins/sidepanel.jelly
index 9ddff73647d6..e413712d6e7c 100644
--- a/core/src/main/resources/jenkins/model/Jenkins/sidepanel.jelly
+++ b/core/src/main/resources/jenkins/model/Jenkins/sidepanel.jelly
@@ -23,4 +23,9 @@ THE SOFTWARE.
-->
-
\ No newline at end of file
+
+
+
+
+
+
diff --git a/core/src/main/resources/jenkins/views/JenkinsHeader/search-box.js b/core/src/main/resources/jenkins/views/JenkinsHeader/search-box.js
deleted file mode 100644
index 91e805c4ceaf..000000000000
--- a/core/src/main/resources/jenkins/views/JenkinsHeader/search-box.js
+++ /dev/null
@@ -1,6 +0,0 @@
-(function () {
- var element = document.getElementById("search-box-completion");
- if (element) {
- createSearchBox(element.getAttribute("data-search-url"));
- }
-})();
diff --git a/core/src/main/resources/lib/form/toggleSwitch.jelly b/core/src/main/resources/lib/form/toggleSwitch.jelly
index 2a2da3e94eb4..b0c9fcf5f835 100644
--- a/core/src/main/resources/lib/form/toggleSwitch.jelly
+++ b/core/src/main/resources/lib/form/toggleSwitch.jelly
@@ -76,19 +76,19 @@ THE SOFTWARE.
disabled="${readOnlyMode ? 'true' : null}"
checked="${value ? 'true' : null}"/>
-
- ${description}
+
+ ${attrs.description}
diff --git a/core/src/main/resources/lib/layout/command-palette.jelly b/core/src/main/resources/lib/layout/command-palette.jelly
new file mode 100644
index 000000000000..3d454f88a8c1
--- /dev/null
+++ b/core/src/main/resources/lib/layout/command-palette.jelly
@@ -0,0 +1,47 @@
+
+
+
+
+
+ The command palette overlay
+
+
+
+
+
+
diff --git a/core/src/main/resources/lib/layout/header/searchbox.jelly b/core/src/main/resources/lib/layout/header/searchbox.jelly
index 916f5ad49c22..033dc2ab0407 100644
--- a/core/src/main/resources/lib/layout/header/searchbox.jelly
+++ b/core/src/main/resources/lib/layout/header/searchbox.jelly
@@ -1,25 +1,27 @@
-
-
-
-
-
diff --git a/core/src/main/resources/lib/layout/search-bar.properties b/core/src/main/resources/lib/layout/search-bar.properties
new file mode 100644
index 000000000000..5a17184bb037
--- /dev/null
+++ b/core/src/main/resources/lib/layout/search-bar.properties
@@ -0,0 +1,23 @@
+# The MIT License
+#
+# Copyright (c) 2023, Damian Szczepanik
+#
+# 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.
+
+shortcut=Press '{0}' on your keyboard to focus
diff --git a/core/src/main/resources/lib/layout/search-bar_pl.properties b/core/src/main/resources/lib/layout/search-bar_pl.properties
index 1404a046d112..b9de372589b4 100644
--- a/core/src/main/resources/lib/layout/search-bar_pl.properties
+++ b/core/src/main/resources/lib/layout/search-bar_pl.properties
@@ -20,4 +20,4 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
-Press\ /\ on\ your\ keyboard\ to\ focus=Naciśnij / na klawiaturze, aby przesunąć tutaj kursor
+shortcut=Naciśnij '{0}' na klawiaturze, aby przesunąć tutaj kursor
diff --git a/core/src/main/resources/lib/layout/search-bar_pt_BR.properties b/core/src/main/resources/lib/layout/search-bar_pt_BR.properties
index b676df91ce65..3d3b237a71f2 100644
--- a/core/src/main/resources/lib/layout/search-bar_pt_BR.properties
+++ b/core/src/main/resources/lib/layout/search-bar_pt_BR.properties
@@ -21,4 +21,4 @@
# THE SOFTWARE.
Search=Busca
-Press\ /\ on\ your\ keyboard\ to\ focus=Digite /\ no seu teclado para realçar
+shortcut=Digite '{0}' no seu teclado para realçar
diff --git a/core/src/site/site.xml b/core/src/site/site.xml
index 52c9edf3a7f3..3e075fe497b9 100644
--- a/core/src/site/site.xml
+++ b/core/src/site/site.xml
@@ -41,7 +41,7 @@
org.apache.maven.skinsmaven-fluido-skin
- 2.0.0
+ 2.0.1
diff --git a/eslint.config.cjs b/eslint.config.cjs
index dcbd22d73060..a546c32cfa44 100644
--- a/eslint.config.cjs
+++ b/eslint.config.cjs
@@ -36,7 +36,6 @@ module.exports = [
CodeMirror: "readonly",
ComboBox: "readonly",
COMBOBOX_VERSION: "writeable",
- createSearchBox: "readonly",
crumb: "readonly",
dialog: "readonly",
ensureVisible: "readonly",
diff --git a/package.json b/package.json
index f1d27f1258ab..2fd1aa45f408 100644
--- a/package.json
+++ b/package.json
@@ -23,15 +23,15 @@
"lint": "yarn lint:js && yarn lint:css"
},
"devDependencies": {
- "@babel/cli": "7.25.9",
+ "@babel/cli": "7.26.4",
"@babel/core": "7.26.0",
"@babel/preset-env": "7.26.0",
- "@eslint/js": "9.16.0",
+ "@eslint/js": "9.17.0",
"babel-loader": "9.2.1",
"clean-webpack-plugin": "4.0.0",
"css-loader": "7.1.2",
"css-minimizer-webpack-plugin": "7.0.0",
- "eslint": "9.16.0",
+ "eslint": "9.17.0",
"eslint-config-prettier": "9.1.0",
"eslint-formatter-checkstyle": "8.40.0",
"globals": "15.13.0",
@@ -39,22 +39,22 @@
"mini-css-extract-plugin": "2.9.2",
"postcss": "8.4.49",
"postcss-loader": "8.1.1",
- "postcss-preset-env": "10.1.1",
+ "postcss-preset-env": "10.1.2",
"postcss-scss": "4.0.9",
"prettier": "3.4.2",
- "sass": "1.82.0",
+ "sass": "1.83.0",
"sass-loader": "16.0.4",
"style-loader": "4.0.0",
"stylelint": "16.11.0",
"stylelint-checkstyle-reporter": "1.0.0",
"stylelint-config-standard": "36.0.1",
- "webpack": "5.97.0",
+ "webpack": "5.97.1",
"webpack-cli": "5.1.4",
"webpack-remove-empty-scripts": "1.0.4"
},
"dependencies": {
"handlebars": "4.7.8",
- "hotkeys-js": "3.12.2",
+ "hotkeys-js": "3.13.9",
"jquery": "3.7.1",
"lodash": "4.17.21",
"sortablejs": "1.15.6",
diff --git a/pom.xml b/pom.xml
index 1f2fe1927dc7..9ffe15dacb17 100644
--- a/pom.xml
+++ b/pom.xml
@@ -73,9 +73,9 @@ THE SOFTWARE.
- 2.489
+ 2.491-SNAPSHOT
- 2024-12-03T13:54:11Z
+ 2024-12-17T13:45:57Zgithub
@@ -97,7 +97,7 @@ THE SOFTWARE.
1.30false
- 8.2
+ 8.420.18.1
@@ -281,7 +281,7 @@ THE SOFTWARE.
com.puppycrawl.toolscheckstyle
- 10.20.2
+ 10.21.0
diff --git a/src/main/js/api/search.js b/src/main/js/api/search.js
new file mode 100644
index 000000000000..ebe5f89df1bd
--- /dev/null
+++ b/src/main/js/api/search.js
@@ -0,0 +1,10 @@
+/**
+ * @param {string} searchTerm
+ */
+function search(searchTerm) {
+ const address = document.getElementById("button-open-command-palette").dataset
+ .searchUrl;
+ return fetch(`${address}?query=${encodeURIComponent(searchTerm)}`);
+}
+
+export default { search: search };
diff --git a/src/main/js/app.js b/src/main/js/app.js
index 63a35226d7d4..c59153608751 100644
--- a/src/main/js/app.js
+++ b/src/main/js/app.js
@@ -1,4 +1,5 @@
import Dropdowns from "@/components/dropdowns";
+import CommandPalette from "@/components/command-palette";
import Notifications from "@/components/notifications";
import SearchBar from "@/components/search-bar";
import Tooltips from "@/components/tooltips";
@@ -7,6 +8,7 @@ import ConfirmationLink from "@/components/confirmation-link";
import Dialogs from "@/components/dialogs";
Dropdowns.init();
+CommandPalette.init();
Notifications.init();
SearchBar.init();
Tooltips.init();
diff --git a/src/main/js/components/command-palette/datasources.js b/src/main/js/components/command-palette/datasources.js
new file mode 100644
index 000000000000..58dd5389d540
--- /dev/null
+++ b/src/main/js/components/command-palette/datasources.js
@@ -0,0 +1,29 @@
+import { LinkResult } from "./models";
+import Search from "@/api/search";
+
+export const JenkinsSearchSource = {
+ execute(query) {
+ const rootUrl = document.head.dataset.rooturl;
+
+ function correctAddress(url) {
+ if (url.startsWith("/")) {
+ url = url.substring(1);
+ }
+
+ return rootUrl + "/" + url;
+ }
+
+ return Search.search(query).then((rsp) =>
+ rsp.json().then((data) => {
+ return data["suggestions"].slice().map((e) =>
+ LinkResult({
+ icon: e.icon,
+ type: e.type,
+ label: e.name,
+ url: correctAddress(e.url),
+ }),
+ );
+ }),
+ );
+ },
+};
diff --git a/src/main/js/components/command-palette/index.js b/src/main/js/components/command-palette/index.js
new file mode 100644
index 000000000000..ca2926d3422b
--- /dev/null
+++ b/src/main/js/components/command-palette/index.js
@@ -0,0 +1,172 @@
+import { LinkResult } from "@/components/command-palette/models";
+import { JenkinsSearchSource } from "./datasources";
+import debounce from "lodash/debounce";
+import * as Symbols from "./symbols";
+import makeKeyboardNavigable from "@/util/keyboard";
+import { xmlEscape } from "@/util/security";
+import { createElementFromHtml } from "@/util/dom";
+
+const datasources = [JenkinsSearchSource];
+
+function init() {
+ const i18n = document.getElementById("command-palette-i18n");
+ const headerCommandPaletteButton = document.getElementById(
+ "button-open-command-palette",
+ );
+ const commandPalette = document.getElementById("command-palette");
+ const commandPaletteWrapper = commandPalette.querySelector(
+ ".jenkins-command-palette__wrapper",
+ );
+ const commandPaletteInput = document.getElementById("command-bar");
+ const commandPaletteSearchBarContainer = commandPalette.querySelector(
+ ".jenkins-command-palette__search",
+ );
+ const searchResults = document.getElementById("search-results");
+ const searchResultsContainer = document.getElementById(
+ "search-results-container",
+ );
+
+ const hoverClass = "jenkins-command-palette__results__item--hover";
+
+ makeKeyboardNavigable(
+ searchResultsContainer,
+ () => searchResults.querySelectorAll("a"),
+ hoverClass,
+ () => {},
+ () => commandPalette.open,
+ );
+
+ // Events
+ headerCommandPaletteButton.addEventListener("click", function () {
+ if (commandPalette.hasAttribute("open")) {
+ hideCommandPalette();
+ } else {
+ showCommandPalette();
+ }
+ });
+
+ commandPaletteWrapper.addEventListener("click", function (e) {
+ if (e.target !== e.currentTarget) {
+ return;
+ }
+
+ hideCommandPalette();
+ });
+
+ function renderResults() {
+ const query = commandPaletteInput.value;
+ let results;
+
+ if (query.length === 0) {
+ results = Promise.all([
+ LinkResult({
+ icon: Symbols.HELP,
+ type: "symbol",
+ label: i18n.dataset.getHelp,
+ url: headerCommandPaletteButton.dataset.searchHelpUrl,
+ isExternal: true,
+ }),
+ ]);
+ } else {
+ results = Promise.all(datasources.map((ds) => ds.execute(query))).then(
+ (e) => e.flat(),
+ );
+ }
+
+ results.then((results) => {
+ // Clear current search results
+ searchResults.innerHTML = "";
+
+ if (query.length === 0 || Object.keys(results).length > 0) {
+ results.forEach(function (obj) {
+ const link = createElementFromHtml(obj.render());
+ link.addEventListener("mouseenter", (e) => itemMouseEnter(e));
+ searchResults.append(link);
+ });
+
+ updateSelectedItem(0);
+ } else {
+ const label = document.createElement("p");
+ label.className = "jenkins-command-palette__info";
+ label.innerHTML =
+ "" +
+ i18n.dataset.noResultsFor +
+ " " +
+ xmlEscape(commandPaletteInput.value);
+ searchResults.append(label);
+ }
+
+ searchResultsContainer.style.height = searchResults.offsetHeight + "px";
+ debouncedSpinner.cancel();
+ commandPaletteSearchBarContainer.classList.remove(
+ "jenkins-search--loading",
+ );
+ });
+ }
+
+ const debouncedSpinner = debounce(() => {
+ commandPaletteSearchBarContainer.classList.add("jenkins-search--loading");
+ }, 150);
+
+ const debouncedLoad = debounce(() => {
+ renderResults();
+ }, 150);
+
+ commandPaletteInput.addEventListener("input", () => {
+ debouncedSpinner();
+ debouncedLoad();
+ });
+
+ // Helper methods for visibility of command palette
+ function showCommandPalette() {
+ commandPalette.showModal();
+ commandPaletteInput.focus();
+ commandPaletteInput.setSelectionRange(0, commandPaletteInput.value.length);
+
+ renderResults();
+ }
+
+ function hideCommandPalette() {
+ commandPalette.setAttribute("closing", "");
+
+ commandPalette.addEventListener(
+ "animationend",
+ () => {
+ commandPalette.removeAttribute("closing");
+ commandPalette.close();
+ },
+ { once: true },
+ );
+ }
+
+ function itemMouseEnter(item) {
+ let hoveredItems = document.querySelector("." + hoverClass);
+ if (hoveredItems) {
+ hoveredItems.classList.remove(hoverClass);
+ }
+
+ item.target.classList.add(hoverClass);
+ }
+
+ function updateSelectedItem(index, scrollIntoView = false) {
+ const maxLength = searchResults.getElementsByTagName("a").length;
+ const hoveredItem = document.querySelector("." + hoverClass);
+
+ if (hoveredItem) {
+ hoveredItem.classList.remove(hoverClass);
+ }
+
+ if (index < maxLength) {
+ const element = Array.from(searchResults.getElementsByTagName("a"))[
+ index
+ ];
+ element.classList.add(hoverClass);
+
+ if (scrollIntoView) {
+ element.scrollIntoView();
+ }
+ }
+ }
+}
+
+export default { init };
diff --git a/src/main/js/components/command-palette/models.js b/src/main/js/components/command-palette/models.js
new file mode 100644
index 000000000000..0c6a733e0d0a
--- /dev/null
+++ b/src/main/js/components/command-palette/models.js
@@ -0,0 +1,27 @@
+import * as Symbols from "./symbols";
+import { xmlEscape } from "@/util/security";
+
+/**
+ * @param {Object} params
+ * @param {string} params.icon
+ * @param {string} params.label
+ * @param {'symbol' | 'image'} params.type
+ * @param {string} params.url
+ * @param {boolean | undefined} params.isExternal
+ */
+export function LinkResult(params) {
+ return {
+ label: params.label,
+ url: params.url,
+ render: () => {
+ return `
+ ${params.type === "image" ? `` : ""}
+ ${params.type !== "image" ? `