This is an automated email from the ASF dual-hosted git repository.
hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 714cc76f19 create projects from git #6961 (#6962)
714cc76f19 is described below
commit 714cc76f19b84493f4d4ede2941aec02d167535c
Author: Bart Maertens <[email protected]>
AuthorDate: Mon Apr 6 12:36:21 2026 +0000
create projects from git #6961 (#6962)
---
docs/hop-user-manual/modules/ROOT/nav.adoc | 1 +
.../modules/ROOT/pages/technology/neo4j/index.adoc | 2 +
.../main/java/org/apache/hop/git/model/UIGit.java | 42 +-
plugins/misc/projects/pom.xml | 2 +
plugins/misc/projects/src/assembly/assembly.xml | 5 +
.../projects/git/BitbucketRepositoryBrowser.java | 189 +++++++
.../hop/projects/git/GitHubRepositoryBrowser.java | 145 ++++++
.../hop/projects/git/GitLabRepositoryBrowser.java | 147 ++++++
.../org/apache/hop/projects/git/GitRepoAuth.java | 62 +++
.../apache/hop/projects/git/GitRepoProvider.java | 87 ++++
.../hop/projects/git/GitRepositoryBrowser.java | 57 +++
.../apache/hop/projects/git/GitRepositoryInfo.java | 83 +++
.../apache/hop/projects/git/GitRepositoryPage.java | 46 ++
.../gui/CloneFromVersionControlDialog.java | 378 ++++++++++++++
.../apache/hop/projects/gui/ProjectsGuiPlugin.java | 80 ++-
.../hop/projects/gui/SelectRepositoryDialog.java | 567 +++++++++++++++++++++
.../apache/hop/projects/util/GitCloneHelper.java | 110 ++++
.../main/resources/dependencies.xml} | 18 +-
.../gui/messages/messages_en_US.properties | 83 +++
.../org/apache/hop/ui/core/gui/GuiMenuWidgets.java | 9 +-
20 files changed, 2090 insertions(+), 23 deletions(-)
diff --git a/docs/hop-user-manual/modules/ROOT/nav.adoc
b/docs/hop-user-manual/modules/ROOT/nav.adoc
index 66e920aef9..90da4a2b7c 100644
--- a/docs/hop-user-manual/modules/ROOT/nav.adoc
+++ b/docs/hop-user-manual/modules/ROOT/nav.adoc
@@ -474,6 +474,7 @@ under the License.
** xref:technology/memgraph/index.adoc[Memgraph]
** xref:technology/neo4j/index.adoc[Neo4j]
*** xref:technology/neo4j/working-with-neo4j-data.adoc[Working with Neo4j data]
+*** xref:technology/neo4j/neo4j-logging-schema.adoc[Neo4j Logging Schema]
** xref:technology/google/index.adoc[Google]
* xref:hop-server/index.adoc[Hop Server]
** xref:hop-server/rest-api.adoc[REST api]
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/technology/neo4j/index.adoc
b/docs/hop-user-manual/modules/ROOT/pages/technology/neo4j/index.adoc
index bc01293401..5319316205 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/technology/neo4j/index.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/technology/neo4j/index.adoc
@@ -68,3 +68,5 @@ The Neo4j examples included in the release downloads is
described in document:
xref:technology/neo4j/working-with-neo4j-data.adoc[Working with Neo4j data]
+xref:technology/neo4j/neo4j-logging-schema.adoc[Neo4j Logging Schema]
describes the graph schema used when logging executions to Neo4j.
+
diff --git a/plugins/misc/git/src/main/java/org/apache/hop/git/model/UIGit.java
b/plugins/misc/git/src/main/java/org/apache/hop/git/model/UIGit.java
index eef85ef318..a8941f703b 100644
--- a/plugins/misc/git/src/main/java/org/apache/hop/git/model/UIGit.java
+++ b/plugins/misc/git/src/main/java/org/apache/hop/git/model/UIGit.java
@@ -803,11 +803,44 @@ public class UIGit extends VCS {
}
}
+ /**
+ * Create a CredentialsProvider for token-based authentication (GitHub,
GitLab, etc.).
+ *
+ * @param token Personal access token
+ * @return CredentialsProvider, or null if token is null/empty
+ */
+ public static CredentialsProvider createTokenCredentialsProvider(String
token) {
+ if (token == null || token.trim().isEmpty()) {
+ return null;
+ }
+ return new UsernamePasswordCredentialsProvider("x-oauth-basic",
token.trim());
+ }
+
public boolean cloneRepo(String directory, String uri) {
+ return cloneRepo(directory, uri, null, 0);
+ }
+
+ /**
+ * Clone a repository with optional credentials and shallow clone depth.
+ *
+ * @param directory Local directory to clone into
+ * @param uri Repository URI (e.g. https://github.com/user/repo.git)
+ * @param credentialsProvider Optional credentials for authentication (e.g.
token). If null and
+ * auth fails, user will be prompted.
+ * @param depth Shallow clone depth (0 = full clone, 1+ = shallow with that
many commits)
+ * @return true if clone succeeded
+ */
+ public boolean cloneRepo(
+ String directory, String uri, CredentialsProvider credentialsProvider,
int depth) {
+ CredentialsProvider provider =
+ credentialsProvider != null ? credentialsProvider :
this.credentialsProvider;
CloneCommand cmd = Git.cloneRepository();
cmd.setDirectory(new File(directory));
cmd.setURI(uri);
- cmd.setCredentialsProvider(credentialsProvider);
+ cmd.setCredentialsProvider(provider);
+ if (depth > 0) {
+ cmd.setDepth(depth);
+ }
try {
Git gitClone = cmd.call();
gitClone.close();
@@ -818,12 +851,11 @@ public class UIGit extends VCS {
.contains(
CONST_AUTHENTICATION_IS_REQUIRED_BUT_NO_CREDENTIALS_PROVIDER_HAS_BEEN_REGISTERED)
|| e.getMessage().contains(CONST_NOT_AUTHORIZED))) {
- if (promptUsernamePassword()) {
- return cloneRepo(directory, uri);
+ if (provider == null && promptUsernamePassword()) {
+ return cloneRepo(directory, uri, null, depth);
}
- } else {
- showMessageBox(BaseMessages.getString(PKG, CONST_DIALOG_ERROR),
e.getMessage());
}
+ showMessageBox(BaseMessages.getString(PKG, CONST_DIALOG_ERROR),
e.getMessage());
}
return false;
}
diff --git a/plugins/misc/projects/pom.xml b/plugins/misc/projects/pom.xml
index 0d8d4dc90a..c51ee5fc65 100644
--- a/plugins/misc/projects/pom.xml
+++ b/plugins/misc/projects/pom.xml
@@ -29,4 +29,6 @@
<packaging>jar</packaging>
<name>Hop Plugins Miscellaneous Projects</name>
+ <!-- Git plugin (hop-misc-git) is loaded at runtime; GitCloneHelper uses
its classloader via reflection -->
+ <dependencies></dependencies>
</project>
diff --git a/plugins/misc/projects/src/assembly/assembly.xml
b/plugins/misc/projects/src/assembly/assembly.xml
index 3dec1aa567..96d8ab832c 100644
--- a/plugins/misc/projects/src/assembly/assembly.xml
+++ b/plugins/misc/projects/src/assembly/assembly.xml
@@ -31,6 +31,11 @@
<outputDirectory>plugins/misc/projects</outputDirectory>
<filtered>true</filtered>
</file>
+ <file>
+
<source>${project.basedir}/src/main/resources/dependencies.xml</source>
+ <outputDirectory>plugins/misc/projects</outputDirectory>
+ <filtered>true</filtered>
+ </file>
</files>
<fileSets>
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/git/BitbucketRepositoryBrowser.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/git/BitbucketRepositoryBrowser.java
new file mode 100644
index 0000000000..1aea2553f3
--- /dev/null
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/git/BitbucketRepositoryBrowser.java
@@ -0,0 +1,189 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.git;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+import org.apache.hop.core.exception.HopException;
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.json.simple.parser.JSONParser;
+import org.json.simple.parser.ParseException;
+
+/**
+ * Fetches repository listings from Bitbucket Cloud using the 2.0 API.
+ *
+ * <p>Bitbucket uses Basic auth with a username + app password (not an OAuth
token). The API returns
+ * all repos accessible to that user across their workspaces via {@code
/repositories}.
+ */
+class BitbucketRepositoryBrowser implements GitRepositoryBrowser {
+
+ private static final String DEFAULT_API = "https://api.bitbucket.org/2.0";
+ private static final HttpClient HTTP =
+ HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
+
+ @Override
+ public GitRepositoryPage listRepositories(
+ String apiBaseUrl, GitRepoAuth auth, String searchQuery, int page)
throws HopException {
+
+ if (auth == null || !auth.isBasicAuth()) {
+ throw new HopException("Bitbucket requires a username and app
password.");
+ }
+
+ String base =
+ (apiBaseUrl != null && !apiBaseUrl.isBlank())
+ ? apiBaseUrl.replaceAll("/+$", "")
+ : DEFAULT_API;
+
+ // /repositories/{workspace} lists repos for one workspace; /repositories
lists all the user
+ // has access to (requires "account" scope on the app password).
+ StringBuilder url =
+ new StringBuilder(base)
+ .append("/repositories/")
+ .append(encode(auth.getUsername()))
+ .append("?pagelen=")
+ .append(PAGE_SIZE)
+ .append("&page=")
+ .append(page)
+ .append("&sort=-updated_on");
+
+ if (searchQuery != null && !searchQuery.isBlank()) {
+ try {
+ url.append("&q=name+%7E+%22")
+ .append(java.net.URLEncoder.encode(searchQuery, "UTF-8"))
+ .append("%22");
+ } catch (java.io.UnsupportedEncodingException e) {
+ // UTF-8 is always supported
+ }
+ }
+
+ String credentials =
+ Base64.getEncoder()
+ .encodeToString(
+ (auth.getUsername() + ":" +
auth.getPassword()).getBytes(StandardCharsets.UTF_8));
+
+ HttpRequest request =
+ HttpRequest.newBuilder()
+ .uri(URI.create(url.toString()))
+ .timeout(Duration.ofSeconds(15))
+ .header("Accept", "application/json")
+ .header("Authorization", "Basic " + credentials)
+ .GET()
+ .build();
+
+ HttpResponse<String> response;
+ try {
+ response = HTTP.send(request, HttpResponse.BodyHandlers.ofString());
+ } catch (IOException | InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new HopException(
+ "Failed to connect to Bitbucket API at " + base + ": " +
e.getMessage(), e);
+ }
+
+ if (response.statusCode() == 401) {
+ throw new HopException(
+ "Bitbucket authentication failed. Check your username and app
password.");
+ }
+ if (response.statusCode() == 403) {
+ throw new HopException(
+ "Bitbucket access denied (HTTP 403). Your app password may lack the
'Repositories: Read' permission.");
+ }
+ if (response.statusCode() != 200) {
+ throw new HopException(
+ "Bitbucket API returned HTTP " + response.statusCode() + ". URL: " +
url);
+ }
+
+ return parseResponse(response.body(), page);
+ }
+
+ private GitRepositoryPage parseResponse(String json, int page) throws
HopException {
+ JSONParser parser = new JSONParser();
+ JSONObject root;
+ try {
+ root = (JSONObject) parser.parse(json);
+ } catch (ParseException e) {
+ throw new HopException("Failed to parse Bitbucket API response: " +
e.getMessage(), e);
+ }
+
+ JSONArray values = (JSONArray) root.get("values");
+ if (values == null) {
+ return new GitRepositoryPage(List.of(), page, false);
+ }
+
+ List<GitRepositoryInfo> result = new ArrayList<>();
+ for (Object item : values) {
+ JSONObject repo = (JSONObject) item;
+ String name = getString(repo, "name");
+ String owner = "";
+ JSONObject ws = (JSONObject) repo.get("workspace");
+ if (ws != null) {
+ owner = getString(ws, "slug");
+ }
+ String description = getString(repo, "description");
+ boolean isPrivate = Boolean.TRUE.equals(repo.get("is_private"));
+ String updatedOn = getString(repo, "updated_on");
+
+ String httpsCloneUrl = "";
+ String sshCloneUrl = "";
+ JSONObject links = (JSONObject) repo.get("links");
+ if (links != null) {
+ JSONArray cloneLinks = (JSONArray) links.get("clone");
+ if (cloneLinks != null) {
+ for (Object cl : cloneLinks) {
+ JSONObject cloneLink = (JSONObject) cl;
+ String linkName = getString(cloneLink, "name");
+ String href = getString(cloneLink, "href");
+ if ("https".equalsIgnoreCase(linkName)) {
+ httpsCloneUrl = href;
+ } else if ("ssh".equalsIgnoreCase(linkName)) {
+ sshCloneUrl = href;
+ }
+ }
+ }
+ }
+
+ result.add(
+ new GitRepositoryInfo(
+ name, owner, description, isPrivate, updatedOn, httpsCloneUrl,
sshCloneUrl));
+ }
+
+ boolean hasMore = root.get("next") != null;
+ return new GitRepositoryPage(result, page, hasMore);
+ }
+
+ private static String getString(JSONObject obj, String key) {
+ Object val = obj.get(key);
+ return val != null ? val.toString() : "";
+ }
+
+ private static String encode(String value) {
+ try {
+ return java.net.URLEncoder.encode(value, "UTF-8");
+ } catch (java.io.UnsupportedEncodingException e) {
+ return value;
+ }
+ }
+}
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/git/GitHubRepositoryBrowser.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/git/GitHubRepositoryBrowser.java
new file mode 100644
index 0000000000..42650b361a
--- /dev/null
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/git/GitHubRepositoryBrowser.java
@@ -0,0 +1,145 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.git;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.exception.HopException;
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.json.simple.parser.JSONParser;
+import org.json.simple.parser.ParseException;
+
+/**
+ * Fetches repository listings from GitHub.com or a GitHub Enterprise server.
+ *
+ * <p>Uses {@code GET /user/repos} sorted by {@code updated} to return the
most recently active
+ * repos first, matching the mental model users expect.
+ */
+class GitHubRepositoryBrowser implements GitRepositoryBrowser {
+
+ private static final String DEFAULT_API = "https://api.github.com";
+ private static final HttpClient HTTP =
+ HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
+
+ @Override
+ public GitRepositoryPage listRepositories(
+ String apiBaseUrl, GitRepoAuth auth, String searchQuery, int page)
throws HopException {
+
+ String base =
+ (apiBaseUrl != null && !apiBaseUrl.isBlank())
+ ? apiBaseUrl.replaceAll("/+$", "")
+ : DEFAULT_API;
+ String url =
+ base
+ + "/user/repos?per_page="
+ + PAGE_SIZE
+ + "&page="
+ + page
+ +
"&sort=updated&direction=desc&affiliation=owner,collaborator,organization_member";
+
+ HttpRequest.Builder requestBuilder =
+ HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .timeout(Duration.ofSeconds(15))
+ .header("Accept", "application/vnd.github+json")
+ .header("X-GitHub-Api-Version", "2022-11-28")
+ .GET();
+
+ if (auth != null && auth.isTokenBased()) {
+ requestBuilder.header("Authorization", "token " + auth.getToken());
+ }
+
+ HttpResponse<String> response;
+ try {
+ response = HTTP.send(requestBuilder.build(),
HttpResponse.BodyHandlers.ofString());
+ } catch (IOException | InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new HopException(
+ "Failed to connect to GitHub API at " + base + ": " +
e.getMessage(), e);
+ }
+
+ if (response.statusCode() == 401) {
+ throw new HopException(
+ "GitHub authentication failed. Check that your personal access token
is valid and has the 'repo' scope.");
+ }
+ if (response.statusCode() == 403) {
+ throw new HopException(
+ "GitHub access denied (HTTP 403). Your token may be missing the
'repo' scope or rate-limited.");
+ }
+ if (response.statusCode() != 200) {
+ throw new HopException("GitHub API returned HTTP " +
response.statusCode() + ". URL: " + url);
+ }
+
+ List<GitRepositoryInfo> repos = parseRepos(response.body(), searchQuery);
+
+ // GitHub uses Link header for pagination; if we got a full page there is
likely more.
+ boolean hasMore = repos.size() == PAGE_SIZE;
+ return new GitRepositoryPage(repos, page, hasMore);
+ }
+
+ private List<GitRepositoryInfo> parseRepos(String json, String searchQuery)
throws HopException {
+ JSONParser parser = new JSONParser();
+ JSONArray array;
+ try {
+ array = (JSONArray) parser.parse(json);
+ } catch (ParseException e) {
+ throw new HopException("Failed to parse GitHub API response: " +
e.getMessage(), e);
+ }
+
+ String filter = (searchQuery != null) ? searchQuery.toLowerCase() : "";
+ List<GitRepositoryInfo> result = new ArrayList<>();
+
+ for (Object item : array) {
+ JSONObject repo = (JSONObject) item;
+ String name = getString(repo, "name");
+ String owner = "";
+ JSONObject ownerObj = (JSONObject) repo.get("owner");
+ if (ownerObj != null) {
+ owner = getString(ownerObj, "login");
+ }
+ String description = getString(repo, "description");
+ boolean isPrivate = Boolean.TRUE.equals(repo.get("private"));
+ String updatedAt = getString(repo, "pushed_at");
+ String httpsCloneUrl = getString(repo, "clone_url");
+ String sshCloneUrl = getString(repo, "ssh_url");
+
+ if (!filter.isEmpty()
+ && !name.toLowerCase().contains(filter)
+ && !owner.toLowerCase().contains(filter)) {
+ continue;
+ }
+
+ result.add(
+ new GitRepositoryInfo(
+ name, owner, description, isPrivate, updatedAt, httpsCloneUrl,
sshCloneUrl));
+ }
+ return result;
+ }
+
+ private static String getString(JSONObject obj, String key) {
+ Object val = obj.get(key);
+ return val != null ? val.toString() : "";
+ }
+}
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/git/GitLabRepositoryBrowser.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/git/GitLabRepositoryBrowser.java
new file mode 100644
index 0000000000..aa49a07467
--- /dev/null
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/git/GitLabRepositoryBrowser.java
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.git;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.exception.HopException;
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.json.simple.parser.JSONParser;
+import org.json.simple.parser.ParseException;
+
+/**
+ * Fetches repository (project) listings from GitLab.com or a self-hosted
GitLab instance.
+ *
+ * <p>Uses {@code GET /projects?membership=true} so only repos accessible to
the token are returned,
+ * sorted by last activity.
+ */
+class GitLabRepositoryBrowser implements GitRepositoryBrowser {
+
+ private static final String DEFAULT_API = "https://gitlab.com/api/v4";
+ private static final HttpClient HTTP =
+ HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
+
+ @Override
+ public GitRepositoryPage listRepositories(
+ String apiBaseUrl, GitRepoAuth auth, String searchQuery, int page)
throws HopException {
+
+ String base =
+ (apiBaseUrl != null && !apiBaseUrl.isBlank())
+ ? apiBaseUrl.replaceAll("/+$", "")
+ : DEFAULT_API;
+
+ StringBuilder url =
+ new StringBuilder(base)
+
.append("/projects?membership=true&order_by=last_activity_at&sort=desc")
+ .append("&per_page=")
+ .append(PAGE_SIZE)
+ .append("&page=")
+ .append(page);
+
+ if (searchQuery != null && !searchQuery.isBlank()) {
+ try {
+ url.append("&search=").append(java.net.URLEncoder.encode(searchQuery,
"UTF-8"));
+ } catch (java.io.UnsupportedEncodingException e) {
+ // UTF-8 is always supported
+ }
+ }
+
+ HttpRequest.Builder requestBuilder =
+ HttpRequest.newBuilder()
+ .uri(URI.create(url.toString()))
+ .timeout(Duration.ofSeconds(15))
+ .header("Accept", "application/json")
+ .GET();
+
+ if (auth != null && auth.isTokenBased()) {
+ requestBuilder.header("PRIVATE-TOKEN", auth.getToken());
+ }
+
+ HttpResponse<String> response;
+ try {
+ response = HTTP.send(requestBuilder.build(),
HttpResponse.BodyHandlers.ofString());
+ } catch (IOException | InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new HopException(
+ "Failed to connect to GitLab API at " + base + ": " +
e.getMessage(), e);
+ }
+
+ if (response.statusCode() == 401) {
+ throw new HopException(
+ "GitLab authentication failed. Check that your personal access token
is valid and has the 'read_api' or 'read_repository' scope.");
+ }
+ if (response.statusCode() == 403) {
+ throw new HopException(
+ "GitLab access denied (HTTP 403). Your token may lack the required
scope.");
+ }
+ if (response.statusCode() != 200) {
+ throw new HopException("GitLab API returned HTTP " +
response.statusCode() + ". URL: " + url);
+ }
+
+ List<GitRepositoryInfo> repos = parseRepos(response.body());
+
+ // GitLab includes X-Next-Page header; if we got a full page assume there
is more.
+ boolean hasMore = repos.size() == PAGE_SIZE;
+ return new GitRepositoryPage(repos, page, hasMore);
+ }
+
+ private List<GitRepositoryInfo> parseRepos(String json) throws HopException {
+ JSONParser parser = new JSONParser();
+ JSONArray array;
+ try {
+ array = (JSONArray) parser.parse(json);
+ } catch (ParseException e) {
+ throw new HopException("Failed to parse GitLab API response: " +
e.getMessage(), e);
+ }
+
+ List<GitRepositoryInfo> result = new ArrayList<>();
+
+ for (Object item : array) {
+ JSONObject repo = (JSONObject) item;
+ String name = getString(repo, "name");
+ String owner = "";
+ JSONObject ns = (JSONObject) repo.get("namespace");
+ if (ns != null) {
+ owner = getString(ns, "full_path");
+ }
+ String description = getString(repo, "description");
+ String visibility = getString(repo, "visibility");
+ boolean isPrivate = "private".equalsIgnoreCase(visibility);
+ String updatedAt = getString(repo, "last_activity_at");
+ String httpsCloneUrl = getString(repo, "http_url_to_repo");
+ String sshCloneUrl = getString(repo, "ssh_url_to_repo");
+
+ result.add(
+ new GitRepositoryInfo(
+ name, owner, description, isPrivate, updatedAt, httpsCloneUrl,
sshCloneUrl));
+ }
+ return result;
+ }
+
+ private static String getString(JSONObject obj, String key) {
+ Object val = obj.get(key);
+ return val != null ? val.toString() : "";
+ }
+}
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/git/GitRepoAuth.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/git/GitRepoAuth.java
new file mode 100644
index 0000000000..840634c987
--- /dev/null
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/git/GitRepoAuth.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.git;
+
+/** Holds credentials for authenticating to a Git provider's API. */
+public class GitRepoAuth {
+
+ private final String token;
+ private final String username;
+ private final String password;
+
+ private GitRepoAuth(String token, String username, String password) {
+ this.token = token;
+ this.username = username;
+ this.password = password;
+ }
+
+ /** Token-based auth (GitHub PAT, GitLab PAT, GHE PAT). */
+ public static GitRepoAuth forToken(String token) {
+ return new GitRepoAuth(token, null, null);
+ }
+
+ /** Basic auth (Bitbucket username + app password). */
+ public static GitRepoAuth forBasic(String username, String password) {
+ return new GitRepoAuth(null, username, password);
+ }
+
+ public String getToken() {
+ return token;
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ public boolean isTokenBased() {
+ return token != null && !token.isBlank();
+ }
+
+ public boolean isBasicAuth() {
+ return username != null && !username.isBlank();
+ }
+}
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/git/GitRepoProvider.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/git/GitRepoProvider.java
new file mode 100644
index 0000000000..d7bca1ba2b
--- /dev/null
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/git/GitRepoProvider.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.git;
+
+public enum GitRepoProvider {
+ GITHUB_CLOUD("GitHub.com", "https://api.github.com", "https://github.com",
false),
+ GITHUB_ENTERPRISE("GitHub Enterprise", null, null, true),
+ GITLAB("GitLab", "https://gitlab.com/api/v4", "https://gitlab.com", true),
+ BITBUCKET("Bitbucket", "https://api.bitbucket.org/2.0",
"https://bitbucket.org", false);
+
+ private final String displayName;
+ private final String defaultApiBaseUrl;
+ private final String defaultWebBaseUrl;
+
+ /** Whether a custom host URL field should be offered in the UI for this
provider. */
+ private final boolean supportsCustomHost;
+
+ GitRepoProvider(
+ String displayName,
+ String defaultApiBaseUrl,
+ String defaultWebBaseUrl,
+ boolean supportsCustomHost) {
+ this.displayName = displayName;
+ this.defaultApiBaseUrl = defaultApiBaseUrl;
+ this.defaultWebBaseUrl = defaultWebBaseUrl;
+ this.supportsCustomHost = supportsCustomHost;
+ }
+
+ public String getDisplayName() {
+ return displayName;
+ }
+
+ public String getDefaultApiBaseUrl() {
+ return defaultApiBaseUrl;
+ }
+
+ public String getDefaultWebBaseUrl() {
+ return defaultWebBaseUrl;
+ }
+
+ public boolean isSupportsCustomHost() {
+ return supportsCustomHost;
+ }
+
+ /** Returns display names for all providers, in declaration order. */
+ public static String[] displayNames() {
+ GitRepoProvider[] values = values();
+ String[] names = new String[values.length];
+ for (int i = 0; i < values.length; i++) {
+ names[i] = values[i].displayName;
+ }
+ return names;
+ }
+
+ /** Best-effort detection of provider from a repository URL. */
+ public static GitRepoProvider detectFromUrl(String url) {
+ if (url == null || url.isBlank()) {
+ return GITHUB_CLOUD;
+ }
+ String lower = url.toLowerCase();
+ if (lower.contains("github.com")) {
+ return GITHUB_CLOUD;
+ }
+ if (lower.contains("gitlab.com") || lower.contains("/api/v4")) {
+ return GITLAB;
+ }
+ if (lower.contains("bitbucket.org")) {
+ return BITBUCKET;
+ }
+ return GITHUB_CLOUD;
+ }
+}
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/git/GitRepositoryBrowser.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/git/GitRepositoryBrowser.java
new file mode 100644
index 0000000000..ff08fbcba0
--- /dev/null
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/git/GitRepositoryBrowser.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.git;
+
+import org.apache.hop.core.exception.HopException;
+
+/**
+ * Fetches repository listings from a Git provider's REST API.
+ *
+ * <p>Implementations exist for GitHub (cloud + enterprise), GitLab, and
Bitbucket.
+ */
+public interface GitRepositoryBrowser {
+
+ int PAGE_SIZE = 50;
+
+ /**
+ * Returns one page of repositories accessible with the given credentials.
+ *
+ * @param apiBaseUrl Provider API root (e.g. {@code https://api.github.com}
or a custom GHE /
+ * GitLab host). May be {@code null} for providers where the default is
baked in.
+ * @param auth Authentication credentials.
+ * @param searchQuery Optional name filter (empty/null means no filter).
+ * @param page 1-based page number.
+ * @return A page of results; never {@code null}.
+ * @throws HopException on network or API errors (message is human-readable).
+ */
+ GitRepositoryPage listRepositories(
+ String apiBaseUrl, GitRepoAuth auth, String searchQuery, int page)
throws HopException;
+
+ /**
+ * Returns the correct {@link GitRepositoryBrowser} implementation for
{@code provider}.
+ *
+ * @throws IllegalArgumentException for unknown providers.
+ */
+ static GitRepositoryBrowser forProvider(GitRepoProvider provider) {
+ return switch (provider) {
+ case GITHUB_CLOUD, GITHUB_ENTERPRISE -> new GitHubRepositoryBrowser();
+ case GITLAB -> new GitLabRepositoryBrowser();
+ case BITBUCKET -> new BitbucketRepositoryBrowser();
+ };
+ }
+}
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/git/GitRepositoryInfo.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/git/GitRepositoryInfo.java
new file mode 100644
index 0000000000..5a42f5f37d
--- /dev/null
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/git/GitRepositoryInfo.java
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.git;
+
+/** Represents a single repository returned by a Git provider's API. */
+public class GitRepositoryInfo {
+
+ private final String name;
+ private final String owner;
+ private final String description;
+ private final boolean privateRepo;
+ private final String lastUpdated;
+ private final String httpsCloneUrl;
+ private final String sshCloneUrl;
+
+ public GitRepositoryInfo(
+ String name,
+ String owner,
+ String description,
+ boolean privateRepo,
+ String lastUpdated,
+ String httpsCloneUrl,
+ String sshCloneUrl) {
+ this.name = name;
+ this.owner = owner;
+ this.description = description;
+ this.privateRepo = privateRepo;
+ this.lastUpdated = lastUpdated;
+ this.httpsCloneUrl = httpsCloneUrl;
+ this.sshCloneUrl = sshCloneUrl;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getOwner() {
+ return owner;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public boolean isPrivateRepo() {
+ return privateRepo;
+ }
+
+ public String getLastUpdated() {
+ return lastUpdated;
+ }
+
+ public String getHttpsCloneUrl() {
+ return httpsCloneUrl;
+ }
+
+ public String getSshCloneUrl() {
+ return sshCloneUrl;
+ }
+
+ /** Short ISO date (first 10 chars) for display, or empty string if null. */
+ public String getLastUpdatedShort() {
+ if (lastUpdated == null || lastUpdated.length() < 10) {
+ return lastUpdated != null ? lastUpdated : "";
+ }
+ return lastUpdated.substring(0, 10);
+ }
+}
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/git/GitRepositoryPage.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/git/GitRepositoryPage.java
new file mode 100644
index 0000000000..bf524f470e
--- /dev/null
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/git/GitRepositoryPage.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.git;
+
+import java.util.List;
+
+/** A page of repository results from a Git provider's API. */
+public class GitRepositoryPage {
+
+ private final List<GitRepositoryInfo> repositories;
+ private final int currentPage;
+ private final boolean hasMore;
+
+ public GitRepositoryPage(List<GitRepositoryInfo> repositories, int
currentPage, boolean hasMore) {
+ this.repositories = repositories;
+ this.currentPage = currentPage;
+ this.hasMore = hasMore;
+ }
+
+ public List<GitRepositoryInfo> getRepositories() {
+ return repositories;
+ }
+
+ public int getCurrentPage() {
+ return currentPage;
+ }
+
+ public boolean isHasMore() {
+ return hasMore;
+ }
+}
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/CloneFromVersionControlDialog.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/CloneFromVersionControlDialog.java
new file mode 100644
index 0000000000..b619ebebe7
--- /dev/null
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/CloneFromVersionControlDialog.java
@@ -0,0 +1,378 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.gui;
+
+import java.io.File;
+import org.apache.commons.io.FilenameUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.config.HopConfig;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.projects.config.ProjectsConfig;
+import org.apache.hop.projects.config.ProjectsConfigSingleton;
+import org.apache.hop.projects.git.GitRepoProvider;
+import org.apache.hop.projects.project.Project;
+import org.apache.hop.projects.project.ProjectConfig;
+import org.apache.hop.projects.util.GitCloneHelper;
+import org.apache.hop.ui.core.ConstUi;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.gui.WindowProperty;
+import org.apache.hop.ui.core.widget.TextVar;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Dialog;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.Text;
+
+public class CloneFromVersionControlDialog extends Dialog {
+ private static final Class<?> PKG = CloneFromVersionControlDialog.class;
+
+ private String returnValue;
+ private Shell shell;
+ private final PropsUi props;
+ private final IVariables variables;
+
+ private Text wUrl;
+ private TextVar wDirectory;
+ private Text wProjectName;
+ private Text wToken;
+ private Button wShallowClone;
+ private Text wShallowDepth;
+
+ public CloneFromVersionControlDialog(Shell parent, IVariables variables) {
+ super(parent, SWT.DIALOG_TRIM | SWT.APPLICATION_MODAL | SWT.RESIZE);
+ this.variables = new Variables();
+ this.variables.initializeFrom(variables);
+ props = PropsUi.getInstance();
+ }
+
+ public String open() {
+ Shell parent = getParent();
+ shell = new Shell(parent, SWT.DIALOG_TRIM | SWT.APPLICATION_MODAL |
SWT.RESIZE);
+ shell.setImage(
+ GuiResource.getInstance()
+ .getImage(
+ "project.svg",
+ PKG.getClassLoader(),
+ ConstUi.SMALL_ICON_SIZE,
+ ConstUi.SMALL_ICON_SIZE));
+
+ PropsUi.setLook(shell);
+
+ int margin = PropsUi.getMargin() + 2;
+ int middle = props.getMiddlePct();
+
+ shell.setLayout(new FormLayout());
+ shell.setText(BaseMessages.getString(PKG,
"CloneFromVersionControlDialog.Shell.Name"));
+
+ Button wOk = new Button(shell, SWT.PUSH);
+ wOk.setText(BaseMessages.getString(PKG, "System.Button.OK"));
+ wOk.addListener(SWT.Selection, event -> ok());
+ Button wCancel = new Button(shell, SWT.PUSH);
+ wCancel.setText(BaseMessages.getString(PKG, "System.Button.Cancel"));
+ wCancel.addListener(SWT.Selection, event -> cancel());
+ BaseTransformDialog.positionBottomButtons(shell, new Button[] {wOk,
wCancel}, margin * 3, null);
+
+ Composite comp = new Composite(shell, SWT.NONE);
+ comp.setLayout(new FormLayout());
+ PropsUi.setLook(comp);
+
+ FormData fdComp = new FormData();
+ fdComp.left = new FormAttachment(0, 0);
+ fdComp.right = new FormAttachment(100, 0);
+ fdComp.top = new FormAttachment(0, 0);
+ fdComp.bottom = new FormAttachment(wOk, -margin);
+ comp.setLayoutData(fdComp);
+
+ Control lastControl = null;
+
+ // URL
+ Label wlUrl = new Label(comp, SWT.RIGHT);
+ wlUrl.setText(BaseMessages.getString(PKG,
"CloneFromVersionControlDialog.Label.Url"));
+ FormData fdlUrl = new FormData();
+ fdlUrl.left = new FormAttachment(0, 0);
+ fdlUrl.right = new FormAttachment(middle, 0);
+ fdlUrl.top = new FormAttachment(0, margin * 2);
+ wlUrl.setLayoutData(fdlUrl);
+ Button wBrowseRepo = new Button(comp, SWT.PUSH);
+ wBrowseRepo.setText(
+ BaseMessages.getString(PKG,
"CloneFromVersionControlDialog.Button.BrowseRepo"));
+ FormData fdbBrowseRepo = new FormData();
+ fdbBrowseRepo.right = new FormAttachment(99, 0);
+ fdbBrowseRepo.top = new FormAttachment(wlUrl, 0, SWT.CENTER);
+ wBrowseRepo.setLayoutData(fdbBrowseRepo);
+ wBrowseRepo.addListener(SWT.Selection, e -> browseRepository());
+ wUrl = new Text(comp, SWT.SINGLE | SWT.BORDER | SWT.LEFT);
+ FormData fdUrl = new FormData();
+ fdUrl.left = new FormAttachment(middle, margin);
+ fdUrl.right = new FormAttachment(wBrowseRepo, -margin);
+ fdUrl.top = new FormAttachment(wlUrl, 0, SWT.CENTER);
+ wUrl.setLayoutData(fdUrl);
+ lastControl = wUrl;
+
+ // Token (optional) — placed directly below the URL / Browse row
+ Label wlToken = new Label(comp, SWT.RIGHT);
+ wlToken.setText(BaseMessages.getString(PKG,
"CloneFromVersionControlDialog.Label.Token"));
+ FormData fdlToken = new FormData();
+ fdlToken.left = new FormAttachment(0, 0);
+ fdlToken.right = new FormAttachment(middle, 0);
+ fdlToken.top = new FormAttachment(lastControl, margin);
+ wlToken.setLayoutData(fdlToken);
+ wToken = new Text(comp, SWT.SINGLE | SWT.BORDER | SWT.LEFT | SWT.PASSWORD);
+ PropsUi.setLook(wToken);
+ FormData fdToken = new FormData();
+ fdToken.left = new FormAttachment(middle, margin);
+ fdToken.right = new FormAttachment(99, 0);
+ fdToken.top = new FormAttachment(wlToken, 0, SWT.CENTER);
+ wToken.setLayoutData(fdToken);
+ wToken.setToolTipText(
+ BaseMessages.getString(PKG,
"CloneFromVersionControlDialog.Label.Token.Tooltip"));
+ lastControl = wToken;
+
+ // Directory
+ Label wlDir = new Label(comp, SWT.RIGHT);
+ wlDir.setText(BaseMessages.getString(PKG,
"CloneFromVersionControlDialog.Label.Directory"));
+ FormData fdlDir = new FormData();
+ fdlDir.left = new FormAttachment(0, 0);
+ fdlDir.right = new FormAttachment(middle, 0);
+ fdlDir.top = new FormAttachment(lastControl, margin);
+ wlDir.setLayoutData(fdlDir);
+ Button wbDir = new Button(comp, SWT.PUSH);
+ wbDir.setText(BaseMessages.getString(PKG,
"CloneFromVersionControlDialog.Button.Browse"));
+ FormData fdbDir = new FormData();
+ fdbDir.right = new FormAttachment(99, 0);
+ fdbDir.top = new FormAttachment(wlDir, 0, SWT.CENTER);
+ wbDir.setLayoutData(fdbDir);
+ wbDir.addListener(SWT.Selection, e -> browseDirectory());
+ wDirectory = new TextVar(variables, comp, SWT.SINGLE | SWT.BORDER |
SWT.LEFT);
+ FormData fdDir = new FormData();
+ fdDir.left = new FormAttachment(middle, margin);
+ fdDir.right = new FormAttachment(wbDir, -margin);
+ fdDir.top = new FormAttachment(wlDir, 0, SWT.CENTER);
+ wDirectory.setLayoutData(fdDir);
+ lastControl = wDirectory;
+
+ // Project name
+ Label wlName = new Label(comp, SWT.RIGHT);
+ wlName.setText(BaseMessages.getString(PKG,
"CloneFromVersionControlDialog.Label.ProjectName"));
+ FormData fdlName = new FormData();
+ fdlName.left = new FormAttachment(0, 0);
+ fdlName.right = new FormAttachment(middle, 0);
+ fdlName.top = new FormAttachment(lastControl, margin);
+ wlName.setLayoutData(fdlName);
+ wProjectName = new Text(comp, SWT.SINGLE | SWT.BORDER | SWT.LEFT);
+ FormData fdName = new FormData();
+ fdName.left = new FormAttachment(middle, margin);
+ fdName.right = new FormAttachment(99, 0);
+ fdName.top = new FormAttachment(wlName, 0, SWT.CENTER);
+ wProjectName.setLayoutData(fdName);
+ wUrl.addListener(SWT.Modify, e -> updateProjectNameFromUrl());
+ lastControl = wProjectName;
+
+ // Shallow clone
+ Label wlShallow = new Label(comp, SWT.RIGHT);
+ wlShallow.setText(
+ BaseMessages.getString(PKG,
"CloneFromVersionControlDialog.Label.ShallowClone"));
+ FormData fdlShallow = new FormData();
+ fdlShallow.left = new FormAttachment(0, 0);
+ fdlShallow.right = new FormAttachment(middle, 0);
+ fdlShallow.top = new FormAttachment(lastControl, margin * 2);
+ wlShallow.setLayoutData(fdlShallow);
+ wShallowClone = new Button(comp, SWT.CHECK);
+ FormData fdShallow = new FormData();
+ fdShallow.left = new FormAttachment(middle, margin);
+ fdShallow.top = new FormAttachment(wlShallow, 0, SWT.CENTER);
+ wShallowClone.setLayoutData(fdShallow);
+ wShallowClone.addListener(SWT.Selection, e -> updateShallowDepthState());
+ wShallowDepth = new Text(comp, SWT.SINGLE | SWT.BORDER | SWT.LEFT);
+ PropsUi.setLook(wShallowDepth);
+ wShallowDepth.setText("1");
+ FormData fdDepth = new FormData();
+ fdDepth.left = new FormAttachment(wShallowClone, margin);
+ fdDepth.top = new FormAttachment(wlShallow, 0, SWT.CENTER);
+ fdDepth.width = 60;
+ wShallowDepth.setLayoutData(fdDepth);
+ Label wlCommits = new Label(comp, SWT.LEFT);
+ wlCommits.setText(
+ " "
+ + BaseMessages.getString(
+ PKG,
"CloneFromVersionControlDialog.Label.ShallowClone.Commits"));
+ FormData fdlCommits = new FormData();
+ fdlCommits.left = new FormAttachment(wShallowDepth, margin);
+ fdlCommits.top = new FormAttachment(wlShallow, 0, SWT.CENTER);
+ wlCommits.setLayoutData(fdlCommits);
+
+ getData();
+ updateShallowDepthState();
+
+ shell.setDefaultButton(wOk);
+ wUrl.setFocus();
+ BaseDialog.defaultShellHandling(shell, c -> ok(), c -> cancel());
+
+ return returnValue;
+ }
+
+ private void browseRepository() {
+ GitRepoProvider detectedProvider =
GitRepoProvider.detectFromUrl(wUrl.getText().trim());
+ SelectRepositoryDialog dialog =
+ new SelectRepositoryDialog(shell, detectedProvider,
wToken.getText().trim());
+ String cloneUrl = dialog.open();
+ if (cloneUrl != null) {
+ wUrl.setText(cloneUrl);
+ String repoName = dialog.getSelectedRepoName();
+ if (repoName != null && StringUtils.isEmpty(wProjectName.getText())) {
+ wProjectName.setText(repoName);
+ }
+ }
+ }
+
+ private void updateShallowDepthState() {
+ wShallowDepth.setEnabled(wShallowClone.getSelection());
+ }
+
+ private void updateProjectNameFromUrl() {
+ if (StringUtils.isEmpty(wProjectName.getText())) {
+ String url = wUrl.getText();
+ if (StringUtils.isNotEmpty(url)) {
+ String baseName =
FilenameUtils.getBaseName(url.replaceFirst("\\.git$", ""));
+ if (StringUtils.isNotEmpty(baseName)) {
+ wProjectName.setText(baseName);
+ }
+ }
+ }
+ }
+
+ private void browseDirectory() {
+ BaseDialog.presentDirectoryDialog(shell, wDirectory, variables);
+ }
+
+ private void getData() {
+ ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+ String standardFolder =
variables.resolve(config.getStandardProjectsFolder());
+ wDirectory.setText(Const.NVL(standardFolder, ""));
+ }
+
+ private void ok() {
+ try {
+ String url = wUrl.getText().trim();
+ if (StringUtils.isEmpty(url)) {
+ throw new HopException("Please specify the repository URL");
+ }
+ String directory = variables.resolve(wDirectory.getText());
+ if (StringUtils.isEmpty(directory)) {
+ throw new HopException("Please specify the directory");
+ }
+ String projectName = wProjectName.getText().trim();
+ if (StringUtils.isEmpty(projectName)) {
+ projectName = FilenameUtils.getBaseName(url.replaceFirst("\\.git$",
""));
+ }
+ if (StringUtils.isEmpty(projectName)) {
+ throw new HopException("Please specify a project name");
+ }
+
+ ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+ if (config.findProjectConfig(projectName) != null) {
+ throw new HopException("Project '" + projectName + "' already exists");
+ }
+
+ String clonePath = directory + File.separator + projectName;
+ FileObject cloneDir = HopVfs.getFileObject(clonePath);
+ if (cloneDir.exists()) {
+ throw new HopException("Directory '" + clonePath + "' already exists");
+ }
+
+ int depth = 0;
+ if (wShallowClone.getSelection()) {
+ try {
+ depth = Integer.parseInt(wShallowDepth.getText().trim());
+ if (depth < 1) {
+ depth = 1;
+ }
+ } catch (NumberFormatException e) {
+ depth = 1;
+ }
+ }
+
+ if (!GitCloneHelper.cloneRepo(clonePath, url, wToken.getText(), depth)) {
+ return;
+ }
+
+ String defaultConfigFile =
variables.resolve(config.getDefaultProjectConfigFile());
+ ProjectConfig projectConfig = new ProjectConfig(projectName, clonePath,
defaultConfigFile);
+ Project project = new Project();
+ project.setParentProjectName(config.getStandardParentProject());
+
+ String configFilename =
projectConfig.getActualProjectConfigFilename(variables);
+ FileObject configFile = HopVfs.getFileObject(configFilename);
+ if (!configFile.exists()) {
+ project.setConfigFilename(configFilename);
+ if (!configFile.getParent().exists()) {
+ configFile.getParent().createFolder();
+ }
+ project.saveToFile();
+ } else {
+ project.setConfigFilename(configFilename);
+ project.readFromFile();
+ }
+
+ config.addProjectConfig(projectConfig);
+ HopConfig.getInstance().saveToFile();
+
+ HopGui hopGui = HopGui.getInstance();
+ ProjectsGuiPlugin.updateProjectToolItem(projectName);
+ ProjectsGuiPlugin.enableHopGuiProject(projectName, project, null);
+
+ returnValue = projectName;
+ dispose();
+ } catch (Exception e) {
+ new ErrorDialog(
+ shell,
+ BaseMessages.getString(
+ ProjectsGuiPlugin.PKG,
"ProjectGuiPlugin.AddProject.Error.Dialog.Header"),
+ BaseMessages.getString(
+ ProjectsGuiPlugin.PKG,
"ProjectGuiPlugin.AddProject.Error.Dialog.Message"),
+ e);
+ }
+ }
+
+ private void cancel() {
+ returnValue = null;
+ dispose();
+ }
+
+ private void dispose() {
+ props.setScreen(new WindowProperty(shell));
+ shell.dispose();
+ }
+}
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
index 77946a9fa2..979f46066b 100644
---
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
@@ -116,6 +116,12 @@ public class ProjectsGuiPlugin {
public static final String ID_TOOLBAR_ITEM_PROJECT =
"toolbar-item-10000-project";
public static final String ID_CONTEXT_MENU_PROJECT = "context-menu-project";
public static final String ID_CONTEXT_MENU_PROJECT_ADD =
"context-menu-project-40010-add";
+ public static final String ID_CONTEXT_MENU_PROJECT_ADD_FROM_VC =
+ "context-menu-project-40011-add-from-vc";
+ public static final String ID_CONTEXT_MENU_PROJECT_ADD_FROM_EXISTING =
+ "context-menu-project-40012-add-from-existing";
+ public static final String ID_CONTEXT_MENU_PROJECT_ADD_FROM_TEMPLATE =
+ "context-menu-project-40013-add-from-template";
public static final String ID_CONTEXT_MENU_PROJECT_EDIT =
"context-menu-project-40020-edit";
public static final String ID_CONTEXT_MENU_PROJECT_DELETE =
"context-menu-project-40030-delete";
@@ -363,7 +369,7 @@ public class ProjectsGuiPlugin {
return info != null ? info.path : null;
}
- private static void updateProjectToolItem(String projectName) {
+ public static void updateProjectToolItem(String projectName) {
GuiToolbarWidgets statusWidgets =
HopGui.getInstance().getStatusToolbarWidgets();
if (projectName == null || StringUtils.isEmpty(projectName)) {
statusWidgets.setToolbarItemText(ID_TOOLBAR_ITEM_PROJECT, "");
@@ -916,7 +922,77 @@ public class ProjectsGuiPlugin {
label = "i18n::HopGui.Toolbar.Project.Add.Label",
toolTip = "i18n::HopGui.Toolbar.Project.Add.Tooltip",
image = "ui/images/add.svg")
- public void addNewProject() {
+ public void addProjectMenu() {
+ // Cascade menu - no action, submenu opens on hover
+ }
+
+ @GuiMenuElement(
+ root = ID_CONTEXT_MENU_PROJECT,
+ parentId = ID_CONTEXT_MENU_PROJECT_ADD,
+ id = ID_CONTEXT_MENU_PROJECT_ADD_FROM_VC,
+ label = "i18n::HopGui.Toolbar.Project.Add.FromVersionControl.Label",
+ toolTip = "i18n::HopGui.Toolbar.Project.Add.FromVersionControl.Tooltip",
+ image = "ui/images/add.svg")
+ public void addProjectFromVersionControl() {
+ addProjectFromVersionControlInternal();
+ }
+
+ @GuiMenuElement(
+ root = ID_CONTEXT_MENU_PROJECT,
+ parentId = ID_CONTEXT_MENU_PROJECT_ADD,
+ id = ID_CONTEXT_MENU_PROJECT_ADD_FROM_EXISTING,
+ label = "i18n::HopGui.Toolbar.Project.Add.FromExisting.Label",
+ toolTip = "i18n::HopGui.Toolbar.Project.Add.FromExisting.Tooltip",
+ image = "ui/images/add.svg")
+ public void addProjectFromExistingSources() {
+ addProjectFromExistingSourcesInternal();
+ }
+
+ private void addProjectFromVersionControlInternal() {
+ try {
+ HopGui hopGui = HopGui.getInstance();
+ if (hopGui == null) {
+ LogChannel.GENERAL.logError("HopGui is not available");
+ return;
+ }
+ Shell shell = hopGui.getActiveShell();
+ if (shell == null) {
+ shell = hopGui.getShell();
+ }
+ if (shell == null) {
+ org.eclipse.swt.widgets.Display display =
org.eclipse.swt.widgets.Display.getCurrent();
+ if (display != null) {
+ shell = display.getActiveShell();
+ }
+ }
+ if (shell == null || shell.isDisposed()) {
+ LogChannel.GENERAL.logError(
+ "No valid shell available for dialog. HopGui may not be fully
initialized.");
+ return;
+ }
+ IVariables variables = hopGui.getVariables();
+ if (variables == null) {
+ variables = Variables.getADefaultVariableSpace();
+ }
+ CloneFromVersionControlDialog dialog = new
CloneFromVersionControlDialog(shell, variables);
+ dialog.open();
+ } catch (Exception e) {
+ LogChannel.GENERAL.logError("Error opening 'Add project from version
control' dialog", e);
+ HopGui hopGui = HopGui.getInstance();
+ if (hopGui != null) {
+ Shell shell = hopGui.getShell();
+ if (shell != null && !shell.isDisposed()) {
+ new ErrorDialog(
+ shell,
+ BaseMessages.getString(PKG,
"ProjectGuiPlugin.AddProject.Error.Dialog.Header"),
+ BaseMessages.getString(PKG,
"ProjectGuiPlugin.AddProject.Error.Dialog.Message"),
+ e);
+ }
+ }
+ }
+ }
+
+ private void addProjectFromExistingSourcesInternal() {
HopGui hopGui = HopGui.getInstance();
IVariables variables = hopGui.getVariables();
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/SelectRepositoryDialog.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/SelectRepositoryDialog.java
new file mode 100644
index 0000000000..759f52adc8
--- /dev/null
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/SelectRepositoryDialog.java
@@ -0,0 +1,567 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.gui;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.projects.git.GitRepoAuth;
+import org.apache.hop.projects.git.GitRepoProvider;
+import org.apache.hop.projects.git.GitRepositoryBrowser;
+import org.apache.hop.projects.git.GitRepositoryInfo;
+import org.apache.hop.projects.git.GitRepositoryPage;
+import org.apache.hop.ui.core.ConstUi;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.gui.WindowProperty;
+import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Combo;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Dialog;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.Table;
+import org.eclipse.swt.widgets.TableColumn;
+import org.eclipse.swt.widgets.TableItem;
+import org.eclipse.swt.widgets.Text;
+
+/**
+ * Dialog that lets users browse and select a repository from GitHub, GitHub
Enterprise, GitLab, or
+ * Bitbucket. Returns the selected HTTPS clone URL (and repo name) to the
calling dialog.
+ */
+public class SelectRepositoryDialog extends Dialog {
+
+ private static final Class<?> PKG = SelectRepositoryDialog.class;
+
+ private static final int COL_NAME = 0;
+ private static final int COL_OWNER = 1;
+ private static final int COL_VISIBILITY = 2;
+ private static final int COL_UPDATED = 3;
+
+ private final PropsUi props;
+
+ /** Previously chosen provider (pre-selects the combo). */
+ private final GitRepoProvider initialProvider;
+
+ /** Previously entered token (pre-fills the token field). */
+ private final String initialToken;
+
+ private Shell shell;
+
+ private Combo wProvider;
+ private Label wlHost;
+ private Text wHost;
+ private Label wlToken;
+ private Text wToken;
+ private Label wlUsername;
+ private Text wUsername;
+ private Label wlPassword;
+ private Text wPassword;
+ private Text wFilter;
+ private Table wRepoTable;
+ private Button wLoadMore;
+ private Label wStatus;
+
+ /** The HTTPS clone URL of the selected repository, or {@code null} if
cancelled. */
+ private String selectedCloneUrl;
+
+ /** The name of the selected repository, or {@code null} if cancelled. */
+ private String selectedRepoName;
+
+ private final List<GitRepositoryInfo> loadedRepos = new ArrayList<>();
+ private int currentPage = 1;
+ private boolean hasMore = false;
+
+ public SelectRepositoryDialog(
+ Shell parent, GitRepoProvider initialProvider, String initialToken) {
+ super(parent, SWT.DIALOG_TRIM | SWT.APPLICATION_MODAL | SWT.RESIZE);
+ this.initialProvider = initialProvider != null ? initialProvider :
GitRepoProvider.GITHUB_CLOUD;
+ this.initialToken = initialToken != null ? initialToken : "";
+ this.props = PropsUi.getInstance();
+ }
+
+ /**
+ * Opens the dialog.
+ *
+ * @return the HTTPS clone URL of the selected repository, or {@code null}
if cancelled.
+ */
+ public String open() {
+ Shell parent = getParent();
+ shell = new Shell(parent, SWT.DIALOG_TRIM | SWT.APPLICATION_MODAL |
SWT.RESIZE);
+ shell.setImage(
+ GuiResource.getInstance()
+ .getImage(
+ "project.svg",
+ PKG.getClassLoader(),
+ ConstUi.SMALL_ICON_SIZE,
+ ConstUi.SMALL_ICON_SIZE));
+
+ PropsUi.setLook(shell);
+ shell.setLayout(new FormLayout());
+ shell.setText(BaseMessages.getString(PKG,
"SelectRepositoryDialog.Shell.Name"));
+
+ int margin = PropsUi.getMargin() + 2;
+ int middle = props.getMiddlePct();
+
+ // --- Bottom buttons ---
+ Button wOk = new Button(shell, SWT.PUSH);
+ wOk.setText(BaseMessages.getString(PKG, "System.Button.OK"));
+ wOk.addListener(SWT.Selection, e -> ok());
+ wOk.setEnabled(false); // enabled only when a repo is selected
+
+ Button wCancel = new Button(shell, SWT.PUSH);
+ wCancel.setText(BaseMessages.getString(PKG, "System.Button.Cancel"));
+ wCancel.addListener(SWT.Selection, e -> cancel());
+
+ BaseTransformDialog.positionBottomButtons(shell, new Button[] {wOk,
wCancel}, margin * 3, null);
+
+ // Status bar just above buttons
+ wStatus = new Label(shell, SWT.LEFT);
+ wStatus.setText("");
+ PropsUi.setLook(wStatus);
+ FormData fdStatus = new FormData();
+ fdStatus.left = new FormAttachment(0, margin);
+ fdStatus.right = new FormAttachment(100, -margin);
+ fdStatus.bottom = new FormAttachment(wOk, -margin);
+ wStatus.setLayoutData(fdStatus);
+
+ // --- Scrollable content area ---
+ Composite comp = new Composite(shell, SWT.NONE);
+ comp.setLayout(new FormLayout());
+ PropsUi.setLook(comp);
+ FormData fdComp = new FormData();
+ fdComp.left = new FormAttachment(0, 0);
+ fdComp.right = new FormAttachment(100, 0);
+ fdComp.top = new FormAttachment(0, 0);
+ fdComp.bottom = new FormAttachment(wStatus, -margin);
+ comp.setLayoutData(fdComp);
+
+ // --- Provider ---
+ Label wlProvider = new Label(comp, SWT.RIGHT);
+ wlProvider.setText(BaseMessages.getString(PKG,
"SelectRepositoryDialog.Label.Provider"));
+ PropsUi.setLook(wlProvider);
+ FormData fdlProvider = new FormData();
+ fdlProvider.left = new FormAttachment(0, 0);
+ fdlProvider.right = new FormAttachment(middle, 0);
+ fdlProvider.top = new FormAttachment(0, margin * 2);
+ wlProvider.setLayoutData(fdlProvider);
+
+ wProvider = new Combo(comp, SWT.DROP_DOWN | SWT.READ_ONLY);
+ wProvider.setItems(GitRepoProvider.displayNames());
+ PropsUi.setLook(wProvider);
+ FormData fdProvider = new FormData();
+ fdProvider.left = new FormAttachment(middle, margin);
+ fdProvider.right = new FormAttachment(99, 0);
+ fdProvider.top = new FormAttachment(wlProvider, 0, SWT.CENTER);
+ wProvider.setLayoutData(fdProvider);
+ wProvider.addListener(SWT.Selection, e -> updateProviderState());
+
+ // --- Host (GHE / self-hosted GitLab) ---
+ wlHost = new Label(comp, SWT.RIGHT);
+ wlHost.setText(BaseMessages.getString(PKG,
"SelectRepositoryDialog.Label.Host"));
+ PropsUi.setLook(wlHost);
+ FormData fdlHost = new FormData();
+ fdlHost.left = new FormAttachment(0, 0);
+ fdlHost.right = new FormAttachment(middle, 0);
+ fdlHost.top = new FormAttachment(wProvider, margin);
+ wlHost.setLayoutData(fdlHost);
+
+ wHost = new Text(comp, SWT.SINGLE | SWT.BORDER);
+ PropsUi.setLook(wHost);
+ wHost.setToolTipText(BaseMessages.getString(PKG,
"SelectRepositoryDialog.Label.Host.Tooltip"));
+ FormData fdHost = new FormData();
+ fdHost.left = new FormAttachment(middle, margin);
+ fdHost.right = new FormAttachment(99, 0);
+ fdHost.top = new FormAttachment(wlHost, 0, SWT.CENTER);
+ wHost.setLayoutData(fdHost);
+
+ // --- Token (GitHub, GitLab) ---
+ wlToken = new Label(comp, SWT.RIGHT);
+ wlToken.setText(BaseMessages.getString(PKG,
"SelectRepositoryDialog.Label.Token"));
+ PropsUi.setLook(wlToken);
+ FormData fdlToken = new FormData();
+ fdlToken.left = new FormAttachment(0, 0);
+ fdlToken.right = new FormAttachment(middle, 0);
+ fdlToken.top = new FormAttachment(wHost, margin);
+ wlToken.setLayoutData(fdlToken);
+
+ wToken = new Text(comp, SWT.SINGLE | SWT.BORDER | SWT.PASSWORD);
+ PropsUi.setLook(wToken);
+ wToken.setToolTipText(
+ BaseMessages.getString(PKG,
"SelectRepositoryDialog.Label.Token.Tooltip"));
+ FormData fdToken = new FormData();
+ fdToken.left = new FormAttachment(middle, margin);
+ fdToken.right = new FormAttachment(99, 0);
+ fdToken.top = new FormAttachment(wlToken, 0, SWT.CENTER);
+ wToken.setLayoutData(fdToken);
+
+ // --- Username (Bitbucket) ---
+ wlUsername = new Label(comp, SWT.RIGHT);
+ wlUsername.setText(BaseMessages.getString(PKG,
"SelectRepositoryDialog.Label.Username"));
+ PropsUi.setLook(wlUsername);
+ FormData fdlUsername = new FormData();
+ fdlUsername.left = new FormAttachment(0, 0);
+ fdlUsername.right = new FormAttachment(middle, 0);
+ fdlUsername.top = new FormAttachment(wHost, margin);
+ wlUsername.setLayoutData(fdlUsername);
+
+ wUsername = new Text(comp, SWT.SINGLE | SWT.BORDER);
+ PropsUi.setLook(wUsername);
+ FormData fdUsername = new FormData();
+ fdUsername.left = new FormAttachment(middle, margin);
+ fdUsername.right = new FormAttachment(99, 0);
+ fdUsername.top = new FormAttachment(wlUsername, 0, SWT.CENTER);
+ wUsername.setLayoutData(fdUsername);
+
+ // --- App password (Bitbucket) ---
+ wlPassword = new Label(comp, SWT.RIGHT);
+ wlPassword.setText(BaseMessages.getString(PKG,
"SelectRepositoryDialog.Label.AppPassword"));
+ PropsUi.setLook(wlPassword);
+ FormData fdlPassword = new FormData();
+ fdlPassword.left = new FormAttachment(0, 0);
+ fdlPassword.right = new FormAttachment(middle, 0);
+ fdlPassword.top = new FormAttachment(wUsername, margin);
+ wlPassword.setLayoutData(fdlPassword);
+
+ wPassword = new Text(comp, SWT.SINGLE | SWT.BORDER | SWT.PASSWORD);
+ PropsUi.setLook(wPassword);
+ wPassword.setToolTipText(
+ BaseMessages.getString(PKG,
"SelectRepositoryDialog.Label.AppPassword.Tooltip"));
+ FormData fdPassword = new FormData();
+ fdPassword.left = new FormAttachment(middle, margin);
+ fdPassword.right = new FormAttachment(99, 0);
+ fdPassword.top = new FormAttachment(wlPassword, 0, SWT.CENTER);
+ wPassword.setLayoutData(fdPassword);
+
+ // --- Filter + Search button ---
+ Label wlFilter = new Label(comp, SWT.RIGHT);
+ wlFilter.setText(BaseMessages.getString(PKG,
"SelectRepositoryDialog.Label.Filter"));
+ PropsUi.setLook(wlFilter);
+ FormData fdlFilter = new FormData();
+ fdlFilter.left = new FormAttachment(0, 0);
+ fdlFilter.right = new FormAttachment(middle, 0);
+ fdlFilter.top = new FormAttachment(wToken, margin * 2);
+ wlFilter.setLayoutData(fdlFilter);
+
+ Button wSearch = new Button(comp, SWT.PUSH);
+ wSearch.setText(BaseMessages.getString(PKG,
"SelectRepositoryDialog.Button.Search"));
+ FormData fdSearch = new FormData();
+ fdSearch.right = new FormAttachment(99, 0);
+ fdSearch.top = new FormAttachment(wlFilter, 0, SWT.CENTER);
+ wSearch.setLayoutData(fdSearch);
+ wSearch.addListener(SWT.Selection, e -> loadRepos(true));
+
+ wFilter = new Text(comp, SWT.SINGLE | SWT.BORDER);
+ PropsUi.setLook(wFilter);
+ wFilter.setToolTipText(
+ BaseMessages.getString(PKG,
"SelectRepositoryDialog.Label.Filter.Tooltip"));
+ FormData fdFilter = new FormData();
+ fdFilter.left = new FormAttachment(middle, margin);
+ fdFilter.right = new FormAttachment(wSearch, -margin);
+ fdFilter.top = new FormAttachment(wlFilter, 0, SWT.CENTER);
+ wFilter.setLayoutData(fdFilter);
+ // Allow pressing Enter in filter field to trigger search
+ wFilter.addListener(SWT.DefaultSelection, e -> loadRepos(true));
+
+ // --- Repository table ---
+ wRepoTable = new Table(comp, SWT.BORDER | SWT.FULL_SELECTION | SWT.SINGLE
| SWT.V_SCROLL);
+ wRepoTable.setHeaderVisible(true);
+ wRepoTable.setLinesVisible(true);
+ PropsUi.setLook(wRepoTable);
+
+ TableColumn colName = new TableColumn(wRepoTable, SWT.LEFT);
+ colName.setText(BaseMessages.getString(PKG,
"SelectRepositoryDialog.Table.Col.Name"));
+ colName.setWidth(220);
+
+ TableColumn colOwner = new TableColumn(wRepoTable, SWT.LEFT);
+ colOwner.setText(BaseMessages.getString(PKG,
"SelectRepositoryDialog.Table.Col.Owner"));
+ colOwner.setWidth(160);
+
+ TableColumn colVisibility = new TableColumn(wRepoTable, SWT.LEFT);
+ colVisibility.setText(
+ BaseMessages.getString(PKG,
"SelectRepositoryDialog.Table.Col.Visibility"));
+ colVisibility.setWidth(80);
+
+ TableColumn colUpdated = new TableColumn(wRepoTable, SWT.LEFT);
+ colUpdated.setText(BaseMessages.getString(PKG,
"SelectRepositoryDialog.Table.Col.Updated"));
+ colUpdated.setWidth(110);
+
+ FormData fdTable = new FormData();
+ fdTable.left = new FormAttachment(0, margin);
+ fdTable.right = new FormAttachment(99, 0);
+ fdTable.top = new FormAttachment(wSearch, margin);
+ fdTable.height = 300;
+ wRepoTable.setLayoutData(fdTable);
+
+ // Single click selects; double click selects and closes
+ wRepoTable.addListener(
+ SWT.Selection,
+ e -> {
+ wOk.setEnabled(wRepoTable.getSelectionCount() > 0);
+ });
+ wRepoTable.addListener(SWT.DefaultSelection, e -> ok());
+
+ // --- Load more button ---
+ wLoadMore = new Button(comp, SWT.PUSH);
+ wLoadMore.setText(BaseMessages.getString(PKG,
"SelectRepositoryDialog.Button.LoadMore"));
+ wLoadMore.setEnabled(false);
+ FormData fdLoadMore = new FormData();
+ fdLoadMore.left = new FormAttachment(middle, margin);
+ fdLoadMore.top = new FormAttachment(wRepoTable, margin);
+ wLoadMore.setLayoutData(fdLoadMore);
+ wLoadMore.addListener(SWT.Selection, e -> loadRepos(false));
+
+ // --- Initial data ---
+ selectProvider(initialProvider);
+ wToken.setText(initialToken);
+
+ shell.setSize(720, 580);
+ shell.open();
+
+ while (!shell.isDisposed()) {
+ if (!shell.getDisplay().readAndDispatch()) {
+ shell.getDisplay().sleep();
+ }
+ }
+ return selectedCloneUrl;
+ }
+
+ /** Returns the repo name of the last selection. */
+ public String getSelectedRepoName() {
+ return selectedRepoName;
+ }
+
+ //
---------------------------------------------------------------------------
+ // UI helpers
+ //
---------------------------------------------------------------------------
+
+ private void selectProvider(GitRepoProvider provider) {
+ GitRepoProvider[] values = GitRepoProvider.values();
+ for (int i = 0; i < values.length; i++) {
+ if (values[i] == provider) {
+ wProvider.select(i);
+ break;
+ }
+ }
+ updateProviderState();
+ }
+
+ private GitRepoProvider selectedProvider() {
+ int idx = wProvider.getSelectionIndex();
+ if (idx < 0) {
+ return GitRepoProvider.GITHUB_CLOUD;
+ }
+ return GitRepoProvider.values()[idx];
+ }
+
+ private void updateProviderState() {
+ GitRepoProvider provider = selectedProvider();
+ boolean isBitbucket = provider == GitRepoProvider.BITBUCKET;
+ boolean needsHost = provider.isSupportsCustomHost();
+
+ // Host field: shown for GHE and GitLab (self-hosted)
+ wlHost.setVisible(needsHost);
+ wHost.setVisible(needsHost);
+
+ // Token vs username+password
+ wlToken.setVisible(!isBitbucket);
+ wToken.setVisible(!isBitbucket);
+ wlUsername.setVisible(isBitbucket);
+ wUsername.setVisible(isBitbucket);
+ wlPassword.setVisible(isBitbucket);
+ wPassword.setVisible(isBitbucket);
+
+ // Update host placeholder text
+ if (provider == GitRepoProvider.GITHUB_ENTERPRISE) {
+ wHost.setMessage("https://git.mycorp.com");
+ } else if (provider == GitRepoProvider.GITLAB) {
+ wHost.setMessage("https://gitlab.com (leave blank for GitLab.com)");
+ }
+
+ shell.layout(true, true);
+ }
+
+ //
---------------------------------------------------------------------------
+ // Repository loading
+ //
---------------------------------------------------------------------------
+
+ private void loadRepos(boolean resetPage) {
+ GitRepoProvider provider = selectedProvider();
+
+ GitRepoAuth auth;
+ if (provider == GitRepoProvider.BITBUCKET) {
+ String username = wUsername.getText().trim();
+ String password = wPassword.getText().trim();
+ if (username.isEmpty() || password.isEmpty()) {
+ wStatus.setText(
+ BaseMessages.getString(PKG,
"SelectRepositoryDialog.Status.NeedUsernamePassword"));
+ return;
+ }
+ auth = GitRepoAuth.forBasic(username, password);
+ } else {
+ String token = wToken.getText().trim();
+ if (token.isEmpty()) {
+ wStatus.setText(BaseMessages.getString(PKG,
"SelectRepositoryDialog.Status.NeedToken"));
+ return;
+ }
+ auth = GitRepoAuth.forToken(token);
+ }
+
+ String apiBaseUrl = resolveApiBaseUrl(provider);
+ String filter = wFilter.getText().trim();
+
+ if (resetPage) {
+ currentPage = 1;
+ loadedRepos.clear();
+ wRepoTable.removeAll();
+ }
+
+ wStatus.setText(BaseMessages.getString(PKG,
"SelectRepositoryDialog.Status.Loading"));
+ shell.setCursor(shell.getDisplay().getSystemCursor(SWT.CURSOR_WAIT));
+
+ final int pageToLoad = currentPage;
+ final GitRepoAuth finalAuth = auth;
+ final String finalApiBaseUrl = apiBaseUrl;
+ final String finalFilter = filter;
+
+ // Run the network call in a background thread and update UI on the SWT
thread.
+ Thread worker =
+ new Thread(
+ () -> {
+ GitRepositoryPage result = null;
+ Exception error = null;
+ try {
+ GitRepositoryBrowser browser =
GitRepositoryBrowser.forProvider(provider);
+ result =
+ browser.listRepositories(finalApiBaseUrl, finalAuth,
finalFilter, pageToLoad);
+ } catch (Exception e) {
+ error = e;
+ }
+
+ final GitRepositoryPage finalResult = result;
+ final Exception finalError = error;
+
+ shell
+ .getDisplay()
+ .asyncExec(
+ () -> {
+ if (shell.isDisposed()) {
+ return;
+ }
+ shell.setCursor(null);
+ if (finalError != null) {
+ wStatus.setText(
+ BaseMessages.getString(PKG,
"SelectRepositoryDialog.Status.Error"));
+ new ErrorDialog(
+ shell,
+ BaseMessages.getString(PKG,
"SelectRepositoryDialog.Error.Header"),
+ BaseMessages.getString(PKG,
"SelectRepositoryDialog.Error.Message"),
+ finalError);
+ return;
+ }
+ if (finalResult != null) {
+ loadedRepos.addAll(finalResult.getRepositories());
+ hasMore = finalResult.isHasMore();
+ currentPage = pageToLoad + 1;
+ populateTable(loadedRepos);
+ wLoadMore.setEnabled(hasMore);
+ wStatus.setText(
+ String.format(
+ BaseMessages.getString(
+ PKG,
"SelectRepositoryDialog.Status.Loaded"),
+ loadedRepos.size()));
+ }
+ });
+ });
+ worker.setDaemon(true);
+ worker.start();
+ }
+
+ private String resolveApiBaseUrl(GitRepoProvider provider) {
+ String hostValue = wHost.getText().trim();
+ return switch (provider) {
+ case GITHUB_ENTERPRISE -> {
+ if (!hostValue.isEmpty()) {
+ String base = hostValue.replaceAll("/+$", "");
+ yield base + "/api/v3";
+ }
+ yield null;
+ }
+ case GITLAB -> {
+ if (!hostValue.isEmpty()) {
+ String base = hostValue.replaceAll("/+$", "");
+ yield base + "/api/v4";
+ }
+ yield null; // default to gitlab.com
+ }
+ default -> null; // implementations use their own defaults
+ };
+ }
+
+ private void populateTable(List<GitRepositoryInfo> repos) {
+ wRepoTable.removeAll();
+ for (GitRepositoryInfo repo : repos) {
+ TableItem item = new TableItem(wRepoTable, SWT.NONE);
+ item.setText(COL_NAME, repo.getName());
+ item.setText(COL_OWNER, repo.getOwner());
+ item.setText(
+ COL_VISIBILITY,
+ repo.isPrivateRepo()
+ ? BaseMessages.getString(PKG,
"SelectRepositoryDialog.Visibility.Private")
+ : BaseMessages.getString(PKG,
"SelectRepositoryDialog.Visibility.Public"));
+ item.setText(COL_UPDATED, repo.getLastUpdatedShort());
+ item.setData(repo);
+ }
+ }
+
+ //
---------------------------------------------------------------------------
+ // OK / Cancel
+ //
---------------------------------------------------------------------------
+
+ private void ok() {
+ int idx = wRepoTable.getSelectionIndex();
+ if (idx < 0) {
+ return;
+ }
+ TableItem item = wRepoTable.getItem(idx);
+ GitRepositoryInfo repo = (GitRepositoryInfo) item.getData();
+ if (repo == null) {
+ return;
+ }
+ selectedCloneUrl = repo.getHttpsCloneUrl();
+ selectedRepoName = repo.getName();
+ dispose();
+ }
+
+ private void cancel() {
+ selectedCloneUrl = null;
+ selectedRepoName = null;
+ dispose();
+ }
+
+ private void dispose() {
+ props.setScreen(new WindowProperty(shell));
+ shell.dispose();
+ }
+}
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/GitCloneHelper.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/GitCloneHelper.java
new file mode 100644
index 0000000000..a8cb432a13
--- /dev/null
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/GitCloneHelper.java
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.util;
+
+import java.lang.reflect.Method;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.gui.plugin.GuiPluginType;
+import org.apache.hop.core.plugins.IPlugin;
+import org.apache.hop.core.plugins.PluginRegistry;
+
+/**
+ * Helper to clone Git repositories using UIGit from the hop-misc-git plugin.
Uses the git plugin's
+ * classloader to avoid ClassNotFoundException when plugins are loaded in
isolation.
+ */
+public class GitCloneHelper {
+
+ private GitCloneHelper() {}
+
+ /**
+ * Clone a repository (full clone, no credentials).
+ *
+ * @param directory Local directory to clone into
+ * @param uri Repository URI (e.g. https://github.com/user/repo.git)
+ * @return true if clone succeeded
+ * @throws HopException if clone fails or git plugin is not installed
+ */
+ public static boolean cloneRepo(String directory, String uri) throws
HopException {
+ return cloneRepo(directory, uri, null, 0);
+ }
+
+ /**
+ * Clone a repository with optional credentials and shallow clone depth.
+ *
+ * @param directory Local directory to clone into
+ * @param uri Repository URI (e.g. https://github.com/user/repo.git)
+ * @param token Optional token for authentication (null for none)
+ * @param depth Shallow clone depth (0 = full clone, 1+ = shallow with that
many commits)
+ * @return true if clone succeeded
+ * @throws HopException if clone fails or git plugin is not installed
+ */
+ public static boolean cloneRepo(String directory, String uri, String token,
int depth)
+ throws HopException {
+ try {
+ IPlugin gitPlugin = findGitPlugin();
+ if (gitPlugin == null) {
+ throw new HopException(
+ "Git plugin is not installed. Please add the hop-misc-git plugin
to your plugins folder.");
+ }
+ ClassLoader gitClassLoader =
PluginRegistry.getInstance().getClassLoader(gitPlugin);
+ Class<?> uiGitClass =
gitClassLoader.loadClass("org.apache.hop.git.model.UIGit");
+ Object uiGit = uiGitClass.getConstructor().newInstance();
+
+ Object credentialsProvider = null;
+ if (StringUtils.isNotEmpty(token)) {
+ Method createTokenMethod =
+ uiGitClass.getMethod("createTokenCredentialsProvider",
String.class);
+ credentialsProvider = createTokenMethod.invoke(null, token);
+ }
+
+ // Find cloneRepo(String, String, CredentialsProvider, int) by name and
param count
+ // to avoid loading CredentialsProvider in our classloader (causes
NoClassDefFoundError
+ // when projects plugin has dependencies.xml referencing git plugin)
+ Method cloneRepoMethod = null;
+ for (Method m : uiGitClass.getMethods()) {
+ if ("cloneRepo".equals(m.getName()) && m.getParameterCount() == 4) {
+ cloneRepoMethod = m;
+ break;
+ }
+ }
+ if (cloneRepoMethod == null) {
+ throw new
HopException("UIGit.cloneRepo(String,String,CredentialsProvider,int) not
found");
+ }
+ Boolean success =
+ (Boolean) cloneRepoMethod.invoke(uiGit, directory, uri,
credentialsProvider, depth);
+ return Boolean.TRUE.equals(success);
+ } catch (Exception e) {
+ if (e.getCause() != null) {
+ throw new HopException("Clone failed: " + e.getCause().getMessage(),
e.getCause());
+ }
+ throw new HopException("Clone failed: " + e.getMessage(), e);
+ }
+ }
+
+ private static IPlugin findGitPlugin() {
+ for (IPlugin plugin :
PluginRegistry.getInstance().getPlugins(GuiPluginType.class)) {
+ for (String className : plugin.getClassMap().values()) {
+ if (className != null && className.startsWith("org.apache.hop.git.")) {
+ return plugin;
+ }
+ }
+ }
+ return null;
+ }
+}
diff --git a/plugins/misc/projects/pom.xml
b/plugins/misc/projects/src/main/resources/dependencies.xml
similarity index 58%
copy from plugins/misc/projects/pom.xml
copy to plugins/misc/projects/src/main/resources/dependencies.xml
index 0d8d4dc90a..f1cedcfee7 100644
--- a/plugins/misc/projects/pom.xml
+++ b/plugins/misc/projects/src/main/resources/dependencies.xml
@@ -1,4 +1,3 @@
-<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
@@ -16,17 +15,8 @@
~ limitations under the License.
~
-->
-<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
- <modelVersion>4.0.0</modelVersion>
- <parent>
- <groupId>org.apache.hop</groupId>
- <artifactId>hop-plugins-misc</artifactId>
- <version>2.18.0-SNAPSHOT</version>
- </parent>
-
- <artifactId>hop-misc-projects</artifactId>
- <packaging>jar</packaging>
- <name>Hop Plugins Miscellaneous Projects</name>
-
-</project>
+<dependencies>
+ <!-- Git plugin folder: adds UIGit and JGit libs for "Add project from
version control" -->
+ <folder>../git</folder>
+</dependencies>
diff --git
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/gui/messages/messages_en_US.properties
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/gui/messages/messages_en_US.properties
index 7545f18d5c..9205198991 100644
---
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/gui/messages/messages_en_US.properties
+++
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/gui/messages/messages_en_US.properties
@@ -32,6 +32,12 @@ HopGui.Toolbar.Environment.Edit.Tooltip=Edit the selected
environment
HopGui.Toolbar.Project.Tooltip=Project {0} in ''{1}'' configured in ''{2}''
HopGui.Toolbar.Project.Add.Label=Add project...
HopGui.Toolbar.Project.Add.Tooltip=Add a new project
+HopGui.Toolbar.Project.Add.FromVersionControl.Label=From version control
+HopGui.Toolbar.Project.Add.FromVersionControl.Tooltip=Check out a project from
a Git repository
+HopGui.Toolbar.Project.Add.FromExisting.Label=In a folder
+HopGui.Toolbar.Project.Add.FromExisting.Tooltip=Add a Hop project from an
existing folder
+HopGui.Toolbar.Project.Add.FromTemplate.Label=From a template
+HopGui.Toolbar.Project.Add.FromTemplate.Tooltip=Create a new project from an
existing template
HopGui.Toolbar.Project.Delete.Label=Delete project...
HopGui.Toolbar.Project.Delete.Tooltip=Delete the selected project
HopGui.Toolbar.Project.Edit.Label=Edit project...
@@ -85,3 +91,80 @@ ProjectGuiPlugin.ZipDirectory.Error.Dialog.Message=Error
zipping project
ProjectGuiPlugin.ZipDirectory.Taskname.Text=Zipping project directory...
ProjectGuiPlugin.IncludeVariables.Label=Include variables in export
ProjectGuiPlugin.IncludeMetadata.Label=Include metadata in export
+EmptyProjectDialog.Shell.Name=Create empty project
+EmptyProjectDialog.Label.ProjectName=Project name
+EmptyProjectDialog.Label.HomeFolder=Home folder
+EmptyProjectDialog.Button.Browse=Browse...
+EmptyProjectDialog.Label.ConfigFile=Configuration file
+EmptyProjectDialog.Label.ConfigFile.Tooltip=Relative path within the home
folder to the project configuration JSON file (e.g. config/project-config.json)
+EmptyProjectDialog.Label.ParentProject=Parent project
+EmptyProjectDialog.Label.Description=Description
+EmptyProjectDialog.Label.Company=Company
+EmptyProjectDialog.Label.Department=Department
+EmptyProjectDialog.Label.MetadataBaseFolder=Metadata base folder
+EmptyProjectDialog.Label.UnitTestsBasePath=Unit tests base path
+EmptyProjectDialog.Label.DataSetCsvFolder=Data set CSV folder
+EmptyProjectDialog.Label.EnforceHomeExecution=Enforce execution in home
+EmptyProjectDialog.Label.ProjectVariables=Project variables
+EmptyProjectDialog.Table.Col.VariableName=Variable name
+EmptyProjectDialog.Table.Col.VariableValue=Value
+EmptyProjectDialog.Table.Col.VariableDescription=Description
+EmptyProjectDialog.Label.ScaffoldOptions=Scaffold options
+EmptyProjectDialog.Label.CreatePipeline=Create pipeline
+EmptyProjectDialog.Label.PipelineName=Pipeline name
+EmptyProjectDialog.Label.CreateWorkflow=Create workflow
+EmptyProjectDialog.Label.WorkflowName=Workflow name
+EmptyProjectDialog.Label.CreateRunConfigs=Create run configurations
+EmptyProjectDialog.Label.CreateEnvironment=Create environment
+EmptyProjectDialog.Default.PipelineName=main.hpl
+EmptyProjectDialog.Default.WorkflowName=main.hwf
+CloneFromVersionControlDialog.Shell.Name=Clone project from Git
+CloneFromVersionControlDialog.Label.Url=Repository URL
+CloneFromVersionControlDialog.Label.Directory=Directory
+CloneFromVersionControlDialog.Label.ProjectName=Project name
+CloneFromVersionControlDialog.Label.Token=Authentication token (optional)
+CloneFromVersionControlDialog.Label.Token.Tooltip=Personal access token for
GitHub, GitLab, etc. Leave empty for public repos or to be prompted.
+CloneFromVersionControlDialog.Label.ShallowClone=Shallow clone (history
truncated to)
+CloneFromVersionControlDialog.Label.ShallowClone.Commits=commits
+CloneFromVersionControlDialog.Button.Browse=Browse...
+CloneFromVersionControlDialog.Button.BrowseRepo=Browse repositories...
+AddProjectFromTemplateDialog.Button.BrowseRepo=Browse repositories...
+SelectRepositoryDialog.Shell.Name=Select Repository
+SelectRepositoryDialog.Label.Provider=Provider
+SelectRepositoryDialog.Label.Host=Host URL
+SelectRepositoryDialog.Label.Host.Tooltip=Base URL of your self-hosted
instance, e.g. https://git.mycorp.com
+SelectRepositoryDialog.Label.Token=Personal access token
+SelectRepositoryDialog.Label.Token.Tooltip=Personal access token with read
access to your repositories (e.g. GitHub PAT with repo scope, GitLab PAT with
read_api scope)
+SelectRepositoryDialog.Label.Username=Username
+SelectRepositoryDialog.Label.AppPassword=App password
+SelectRepositoryDialog.Label.AppPassword.Tooltip=Bitbucket app password with
'Repositories: Read' permission
+SelectRepositoryDialog.Label.Filter=Filter
+SelectRepositoryDialog.Label.Filter.Tooltip=Filter by repository name or
owner. Press Enter or click Search.
+SelectRepositoryDialog.Button.Search=Search
+SelectRepositoryDialog.Button.LoadMore=Load more...
+SelectRepositoryDialog.Table.Col.Name=Name
+SelectRepositoryDialog.Table.Col.Owner=Owner / Namespace
+SelectRepositoryDialog.Table.Col.Visibility=Visibility
+SelectRepositoryDialog.Table.Col.Updated=Last updated
+SelectRepositoryDialog.Visibility.Private=Private
+SelectRepositoryDialog.Visibility.Public=Public
+SelectRepositoryDialog.Status.Loading=Loading repositories...
+SelectRepositoryDialog.Status.Loaded=Showing {0} repositor(ies). Double-click
or select and click OK.
+SelectRepositoryDialog.Status.NeedToken=Please enter a personal access token.
+SelectRepositoryDialog.Status.NeedUsernamePassword=Please enter a username and
app password.
+SelectRepositoryDialog.Status.Error=Failed to load repositories.
+SelectRepositoryDialog.Error.Header=Error loading repositories
+SelectRepositoryDialog.Error.Message=An error occurred while fetching the
repository list.
+AddProjectFromTemplateDialog.Shell.Name=Create project from template
+AddProjectFromTemplateDialog.Label.TemplateSource=Template source
+AddProjectFromTemplateDialog.Label.FromGit=Template from Git
+AddProjectFromTemplateDialog.Label.FromLocal=Local template
+AddProjectFromTemplateDialog.Label.GitUrl=Repository URL
+AddProjectFromTemplateDialog.Label.LocalFolder=Template folder
+AddProjectFromTemplateDialog.Label.ProjectName=Project name
+AddProjectFromTemplateDialog.Label.DestinationFolder=Destination folder
+AddProjectFromTemplateDialog.Label.Token=Authentication token (optional)
+AddProjectFromTemplateDialog.Label.Token.Tooltip=Personal access token for
GitHub, GitLab, etc. Leave empty for public repos or to be prompted.
+AddProjectFromTemplateDialog.Label.ShallowClone=Shallow clone (history
truncated to)
+AddProjectFromTemplateDialog.Label.ShallowClone.Commits=commits
+AddProjectFromTemplateDialog.Button.Browse=Browse...
diff --git a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiMenuWidgets.java
b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiMenuWidgets.java
index 1abaff84ea..cb86d8ca9a 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiMenuWidgets.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiMenuWidgets.java
@@ -149,14 +149,19 @@ public class GuiMenuWidgets extends BaseGuiWidgets {
try {
executeMenuItem(guiMenuItem, instanceId);
} catch (Exception ex) {
+ Throwable cause = ex.getCause() != null ? ex.getCause() : ex;
+ String msg = cause.getMessage();
+ if (msg == null || msg.isEmpty()) {
+ msg = cause.getClass().getSimpleName();
+ }
LogChannel.UI.logError(
"Unable to call method "
+ guiMenuItem.getListenerMethod()
+ " in singleton "
+ guiMenuItem.getListenerClassName()
+ " : "
- + ex.getMessage(),
- e);
+ + msg,
+ ex);
}
});