diff --git a/src/main/java/de/php_perfect/intellij/ddev/actions/DdevAddAddonAction.java b/src/main/java/de/php_perfect/intellij/ddev/actions/DdevAddAddonAction.java new file mode 100644 index 00000000..e09cc506 --- /dev/null +++ b/src/main/java/de/php_perfect/intellij/ddev/actions/DdevAddAddonAction.java @@ -0,0 +1,23 @@ +package de.php_perfect.intellij.ddev.actions; + +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.project.Project; +import de.php_perfect.intellij.ddev.ui.AddonListPopup; +import org.jetbrains.annotations.NotNull; + +/** + * Action to add an add-on to a DDEV project. + */ +public final class DdevAddAddonAction extends DdevAddonAction { + + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + Project project = e.getProject(); + + if (project == null) { + return; + } + + new AddonListPopup(project).show(); + } +} diff --git a/src/main/java/de/php_perfect/intellij/ddev/actions/DdevAddonAction.java b/src/main/java/de/php_perfect/intellij/ddev/actions/DdevAddonAction.java new file mode 100644 index 00000000..6a9867fa --- /dev/null +++ b/src/main/java/de/php_perfect/intellij/ddev/actions/DdevAddonAction.java @@ -0,0 +1,48 @@ +package de.php_perfect.intellij.ddev.actions; + +import com.intellij.openapi.actionSystem.ActionUpdateThread; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.project.Project; +import de.php_perfect.intellij.ddev.state.DdevStateManager; +import de.php_perfect.intellij.ddev.state.State; +import org.jetbrains.annotations.NotNull; + +/** + * Base class for DDEV add-on related actions. + */ +public abstract class DdevAddonAction extends DdevAwareAction { + + @Override + public void update(@NotNull AnActionEvent e) { + super.update(e); + + Project project = e.getProject(); + + if (project == null) { + e.getPresentation().setEnabled(false); + return; + } + + e.getPresentation().setEnabled(isActive(project)); + } + + @Override + protected boolean isActive(@NotNull Project project) { + final State state = DdevStateManager.getInstance(project).getState(); + + if (!state.isAvailable() || !state.isConfigured()) { + return false; + } + + if (state.getDescription() == null) { + return false; + } + + return state.getDescription().getStatus() == de.php_perfect.intellij.ddev.cmd.Description.Status.RUNNING; + } + + @Override + public @NotNull ActionUpdateThread getActionUpdateThread() { + return ActionUpdateThread.BGT; + } +} diff --git a/src/main/java/de/php_perfect/intellij/ddev/actions/DdevDeleteAddonAction.java b/src/main/java/de/php_perfect/intellij/ddev/actions/DdevDeleteAddonAction.java new file mode 100644 index 00000000..ee86219b --- /dev/null +++ b/src/main/java/de/php_perfect/intellij/ddev/actions/DdevDeleteAddonAction.java @@ -0,0 +1,40 @@ +package de.php_perfect.intellij.ddev.actions; + +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.ui.Messages; +import de.php_perfect.intellij.ddev.cmd.DdevAddon; +import de.php_perfect.intellij.ddev.cmd.DdevRunner; +import de.php_perfect.intellij.ddev.ui.AddonDeletePopup; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * Action to remove an add-on from a DDEV project. + */ +public final class DdevDeleteAddonAction extends DdevAddonAction { + + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + Project project = e.getProject(); + + if (project == null) { + return; + } + + List<DdevAddon> installedAddons = DdevRunner.getInstance().getInstalledAddons(project); + + if (installedAddons.isEmpty()) { + Messages.showInfoMessage( + project, + "No add-ons installed that can be removed.", + "DDEV Add-Ons" + ); + return; + } + + // Show the custom add-on delete popup + new AddonDeletePopup(project, installedAddons).show(); + } +} diff --git a/src/main/java/de/php_perfect/intellij/ddev/addon/AddonCache.java b/src/main/java/de/php_perfect/intellij/ddev/addon/AddonCache.java new file mode 100644 index 00000000..77008dbc --- /dev/null +++ b/src/main/java/de/php_perfect/intellij/ddev/addon/AddonCache.java @@ -0,0 +1,125 @@ +package de.php_perfect.intellij.ddev.addon; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.project.ProjectManager; +import com.intellij.openapi.project.ProjectManagerListener; +import de.php_perfect.intellij.ddev.cmd.DdevAddon; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Cache for DDEV addons to avoid repeated calls to the DDEV CLI. + */ +public class AddonCache { + private static final Logger LOG = Logger.getInstance(AddonCache.class); + private static final ConcurrentHashMap<Project, AddonCache> INSTANCES = new ConcurrentHashMap<>(); + // Cache expiration time: 24 hours in milliseconds + private static final long CACHE_EXPIRATION_TIME = 24L * 60 * 60 * 1000; + + private final Project project; + private final AtomicBoolean isRefreshing = new AtomicBoolean(false); + private List<DdevAddon> availableAddons = Collections.synchronizedList(Collections.emptyList()); + private final AtomicLong lastRefreshTime = new AtomicLong(0); + + /** + * Gets the AddonCache instance for the given project. + * + * @param project The project to get the cache for + * @return The AddonCache instance + */ + public static @NotNull AddonCache getInstance(@NotNull Project project) { + return INSTANCES.computeIfAbsent(project, AddonCache::new); + } + + private AddonCache(@NotNull Project project) { + this.project = project; + + // Initialize the cache in the background + refreshCacheAsync(); + + // Register a project dispose listener to remove the cache when the project is closed + ProjectManager.getInstance().addProjectManagerListener(project, new ProjectManagerListener() { + @Override + public void projectClosed(@NotNull Project closedProject) { + if (project.equals(closedProject)) { + INSTANCES.remove(project); + } + } + }); + } + + /** + * Gets the list of available addons from the cache. + * If the cache is empty or expired, it will be refreshed asynchronously. + * + * @return The list of available addons + */ + public @NotNull List<DdevAddon> getAvailableAddons() { + // If the cache is empty or expired, refresh it asynchronously + if (availableAddons.isEmpty() || isCacheExpired()) { + refreshCacheAsync(); + } + return availableAddons; + } + + /** + * Checks if the cache is expired. + * + * @return true if the cache is expired, false otherwise + */ + private boolean isCacheExpired() { + return System.currentTimeMillis() - lastRefreshTime.get() > CACHE_EXPIRATION_TIME; + } + + /** + * Refreshes the cache asynchronously. + */ + public void refreshCacheAsync() { + // If already refreshing, don't start another refresh + if (isRefreshing.compareAndSet(false, true)) { + LOG.debug("Starting async refresh of addon cache for project: " + project.getName()); + + ApplicationManager.getApplication().executeOnPooledThread(() -> { + try { + List<DdevAddon> addons = fetchAvailableAddons(); + if (addons != null) { + availableAddons = addons; + lastRefreshTime.set(System.currentTimeMillis()); + LOG.info("Refreshed addon cache for project: " + project.getName() + ", found " + addons.size() + " addons"); + } + } catch (Exception e) { + LOG.error("Failed to refresh addon cache for project: " + project.getName(), e); + } finally { + isRefreshing.set(false); + } + }); + } + } + + /** + * Fetches the list of available addons from the DDEV CLI. + * + * @return The list of available addons, or null if the operation failed + */ + @Nullable + private List<DdevAddon> fetchAvailableAddons() { + // Execute the DDEV command to get all available add-ons + AddonUtilsService addonUtilsService = AddonUtilsService.getInstance(); + Map<String, Object> jsonObject = addonUtilsService.executeAddonCommand(project, "list", "--all", "--json-output"); + + // Parse the JSON response into a list of DdevAddon objects + List<DdevAddon> addons = addonUtilsService.parseAvailableAddons(jsonObject); + + // Return null if no addons were found (to trigger appropriate logging) + return addons.isEmpty() ? null : addons; + } +} diff --git a/src/main/java/de/php_perfect/intellij/ddev/addon/AddonCacheStartupActivity.java b/src/main/java/de/php_perfect/intellij/ddev/addon/AddonCacheStartupActivity.java new file mode 100644 index 00000000..f2ccd8df --- /dev/null +++ b/src/main/java/de/php_perfect/intellij/ddev/addon/AddonCacheStartupActivity.java @@ -0,0 +1,39 @@ +package de.php_perfect.intellij.ddev.addon; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.startup.ProjectActivity; +import de.php_perfect.intellij.ddev.state.DdevStateManager; +import de.php_perfect.intellij.ddev.state.State; +import kotlin.Unit; +import kotlin.coroutines.Continuation; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Startup activity to initialize the AddonCache when the project is opened. + */ +public class AddonCacheStartupActivity implements ProjectActivity { + private static final Logger LOG = Logger.getInstance(AddonCacheStartupActivity.class); + + @Nullable + @Override + public Object execute(@NotNull Project project, @NotNull Continuation<? super Unit> continuation) { + LOG.debug("Initializing AddonCache for project: " + project.getName()); + + // Only initialize the cache if DDEV is available and configured + State state = DdevStateManager.getInstance(project).getState(); + if (state.isAvailable() && state.isConfigured()) { + // This will create the cache instance and trigger an async refresh in the background + ApplicationManager.getApplication().executeOnPooledThread(() -> { + AddonCache.getInstance(project); + LOG.info("AddonCache initialized for project: " + project.getName()); + }); + } else { + LOG.debug("Skipping AddonCache initialization for project: " + project.getName() + " because DDEV is not available or not configured"); + } + + return Unit.INSTANCE; + } +} diff --git a/src/main/java/de/php_perfect/intellij/ddev/addon/AddonUtilsService.java b/src/main/java/de/php_perfect/intellij/ddev/addon/AddonUtilsService.java new file mode 100644 index 00000000..d0f47f0a --- /dev/null +++ b/src/main/java/de/php_perfect/intellij/ddev/addon/AddonUtilsService.java @@ -0,0 +1,78 @@ +package de.php_perfect.intellij.ddev.addon; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.project.Project; +import de.php_perfect.intellij.ddev.cmd.DdevAddon; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Map; + +/** + * Service interface for DDEV addon-related operations. + */ +public interface AddonUtilsService { + /** + * Executes a DDEV addon command and returns the JSON output. + * + * @param project The project to execute the command for + * @param parameters The command parameters + * @return The parsed JSON object, or null if the command failed + */ + @Nullable + Map<String, Object> executeAddonCommand(@NotNull Project project, @NotNull String... parameters); + + /** + * Processes an addon name to remove the "ddev-" prefix if present. + * + * @param name The addon name + * @return The processed addon name + */ + @NotNull + String processAddonName(@NotNull String name); + + /** + * Determines if an addon is official based on the vendor name. + * + * @param vendorName The vendor name + * @return true if the addon is official, false otherwise + */ + boolean isOfficialAddon(@Nullable String vendorName); + + /** + * Gets the addon type based on whether it's official or not. + * + * @param vendorName The vendor name + * @return "official" if the addon is official, "community" otherwise + */ + @NotNull + String getAddonType(@Nullable String vendorName); + + /** + * Parses available addons from a JSON object. + * + * @param jsonObject The JSON object to parse + * @return A list of available addons, or an empty list if parsing failed + */ + @NotNull + List<DdevAddon> parseAvailableAddons(@Nullable Map<String, Object> jsonObject); + + /** + * Parses installed addons from a JSON object. + * + * @param jsonObject The JSON object to parse + * @return A list of installed addons, or an empty list if parsing failed + */ + @NotNull + List<DdevAddon> parseInstalledAddons(@Nullable Map<String, Object> jsonObject); + + /** + * Gets the AddonUtilsService instance. + * + * @return The AddonUtilsService instance + */ + static AddonUtilsService getInstance() { + return ApplicationManager.getApplication().getService(AddonUtilsService.class); + } +} diff --git a/src/main/java/de/php_perfect/intellij/ddev/addon/AddonUtilsServiceImpl.java b/src/main/java/de/php_perfect/intellij/ddev/addon/AddonUtilsServiceImpl.java new file mode 100644 index 00000000..26bb1c78 --- /dev/null +++ b/src/main/java/de/php_perfect/intellij/ddev/addon/AddonUtilsServiceImpl.java @@ -0,0 +1,230 @@ +package de.php_perfect.intellij.ddev.addon; + +import com.intellij.execution.ExecutionException; +import com.intellij.execution.configurations.GeneralCommandLine; +import com.intellij.execution.process.ProcessOutput; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import de.php_perfect.intellij.ddev.cmd.DdevAddon; +import de.php_perfect.intellij.ddev.cmd.DdevRunnerImpl; +import de.php_perfect.intellij.ddev.cmd.ProcessExecutor; +import de.php_perfect.intellij.ddev.cmd.parser.JsonUtil; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/** + * Implementation of the AddonUtilsService interface. + */ +public class AddonUtilsServiceImpl implements AddonUtilsService { + private static final Logger LOG = Logger.getInstance(AddonUtilsServiceImpl.class); + private static final int COMMAND_TIMEOUT = 60000; // 60 seconds + + @Override + @Nullable + public Map<String, Object> executeAddonCommand(@NotNull Project project, @NotNull String... parameters) { + try { + // Build the command line + GeneralCommandLine commandLine = DdevRunnerImpl.createCommandLine("add-on", project); + for (String param : parameters) { + commandLine.addParameter(param); + } + + // Execute the command + ProcessOutput output = executeCommand(commandLine); + if (output == null) { + return null; + } + + // Check the exit code + if (output.getExitCode() != 0) { + LOG.warn("DDEV command failed: " + output.getStderr()); + return null; + } + + // Parse the JSON output + String stdout = output.getStdout(); + if (stdout.isEmpty()) { + LOG.warn("Empty output from DDEV command"); + return null; + } + + // Parse the JSON + return JsonUtil.parseJson(stdout); + } catch (Exception e) { + LOG.error("Error executing DDEV addon command", e); + return null; + } + } + + /** + * Executes a command line and returns the process output. + * + * @param commandLine The command line to execute + * @return The process output, or null if the command failed + */ + @Nullable + private ProcessOutput executeCommand(@NotNull GeneralCommandLine commandLine) { + try { + return ProcessExecutor.getInstance().executeCommandLine(commandLine, COMMAND_TIMEOUT, false); + } catch (ExecutionException e) { + LOG.error("Failed to execute DDEV command: " + e.getMessage(), e); + return null; + } + } + + @Override + @NotNull + public String processAddonName(@NotNull String name) { + if (name.startsWith("ddev-")) { + return name.substring(5); + } + return name; + } + + @Override + public boolean isOfficialAddon(@Nullable String vendorName) { + return "ddev".equals(vendorName); + } + + @Override + @NotNull + public String getAddonType(@Nullable String vendorName) { + return isOfficialAddon(vendorName) ? "official" : "community"; + } + + /** + * Generic method to parse a list of add-ons from a JSON object. + * + * @param jsonObject The JSON object to parse + * @param parser The function to parse each add-on JSON object + * @param errorMessage The error message to log if parsing fails + * @return A list of add-ons, or an empty list if parsing failed + */ + @NotNull + private List<DdevAddon> parseAddons( + @Nullable Map<String, Object> jsonObject, + Function<Map<String, Object>, DdevAddon> parser, + String errorMessage) { + + if (jsonObject == null) { + return Collections.emptyList(); + } + + try { + List<Map<String, Object>> rawAddons = JsonUtil.getList(jsonObject, "raw"); + List<DdevAddon> addons = new ArrayList<>(); + + for (Map<String, Object> addonJson : rawAddons) { + parseAddon(parser, addonJson, addons); + } + + return addons; + } catch (Exception e) { + LOG.error(errorMessage, e); + return Collections.emptyList(); + } + } + + /** + * Parses a single addon and adds it to the list if successful. + * + * @param parser The function to parse the addon JSON object + * @param addonJson The JSON object to parse + * @param addons The list to add the parsed addon to + */ + private void parseAddon( + Function<Map<String, Object>, DdevAddon> parser, + Map<String, Object> addonJson, + List<DdevAddon> addons) { + try { + DdevAddon addon = parser.apply(addonJson); + if (addon != null) { + addons.add(addon); + } + } catch (Exception e) { + // Skip this addon if there's an issue with it + LOG.warn("Skipping addon due to error: " + e.getMessage()); + } + } + + @Override + @NotNull + public List<DdevAddon> parseAvailableAddons(@Nullable Map<String, Object> jsonObject) { + return parseAddons( + jsonObject, + addonJson -> { + String name = JsonUtil.getString(addonJson, "name"); + String fullName = JsonUtil.getString(addonJson, "full_name"); + String description = JsonUtil.getString(addonJson, "description"); + int stars = JsonUtil.getInt(addonJson, "stargazers_count"); + + // Extract owner information + Map<String, Object> ownerJson = JsonUtil.getMap(addonJson, "owner"); + String vendorName = ownerJson != null ? JsonUtil.getString(ownerJson, "login") : null; + + // Process the addon name and determine its type + String addonName = processAddonName(name); + String type = getAddonType(vendorName); + + return new DdevAddon( + addonName, + description, + null, + type, + stars, + vendorName, + fullName + ); + }, + "Failed to parse available addons" + ); + } + + @Override + @NotNull + public List<DdevAddon> parseInstalledAddons(@Nullable Map<String, Object> jsonObject) { + return parseAddons( + jsonObject, + addonJson -> { + // Get the addon name and repository + String name = JsonUtil.getString(addonJson, "Name"); + String repository = JsonUtil.getString(addonJson, "Repository"); + String version = JsonUtil.getString(addonJson, "Version"); + + // Process the addon name + String addonName = processAddonName(name); + + // Extract vendor name from repository + String vendorName = null; + String fullName = addonName; + if (repository.contains("/")) { + String[] parts = repository.split("/"); + if (parts.length > 1) { + vendorName = parts[0]; + fullName = repository; + } + } + + // Determine the type + String type = getAddonType(vendorName); + + return new DdevAddon( + addonName, + repository, + version, + type, + 0, + vendorName, + fullName + ); + }, + "Failed to parse installed addons" + ); + } +} diff --git a/src/main/java/de/php_perfect/intellij/ddev/cmd/DdevAddon.java b/src/main/java/de/php_perfect/intellij/ddev/cmd/DdevAddon.java new file mode 100644 index 00000000..831a486f --- /dev/null +++ b/src/main/java/de/php_perfect/intellij/ddev/cmd/DdevAddon.java @@ -0,0 +1,135 @@ +package de.php_perfect.intellij.ddev.cmd; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Represents a DDEV add-on that can be added to or removed from a DDEV project. + */ +public class DdevAddon { + private final @NotNull String name; + private final @NotNull String description; + private final @Nullable String version; + private final @Nullable String type; + private final int stars; + private final @Nullable String vendorName; + private final @Nullable String fullName; + + public DdevAddon(@NotNull String name, @NotNull String description, @Nullable String version, + @Nullable String type, int stars, @Nullable String vendorName, @Nullable String fullName) { + this.name = name; + this.description = description; + this.version = version; + this.type = type; + this.stars = stars; + this.vendorName = vendorName; + this.fullName = fullName; + } + + /** + * Gets the name of the add-on. + * + * @return The add-on name + */ + public @NotNull String getName() { + return name; + } + + /** + * Gets the description of the add-on. + * + * @return The add-on description + */ + public @NotNull String getDescription() { + return description; + } + + /** + * Gets the version of the add-on, if available. + * + * @return The add-on version, or null if not specified + */ + public @Nullable String getVersion() { + return version; + } + + /** + * Gets the type of the add-on, if available. + * + * @return The add-on type, or null if not specified + */ + public @Nullable String getType() { + return type; + } + + /** + * Gets the number of stars of the add-on. + * + * @return The add-on stars + */ + public int getStars() { + return stars; + } + + /** + * Gets the vendor name of the add-on, if available. + * + * @return The add-on vendor name, or null if not specified + */ + public @Nullable String getVendorName() { + return vendorName; + } + + /** + * Gets the full name of the add-on (with vendor prefix), if available. + * This is the name that should be used for installation. + * + * @return The full add-on name, or null if not specified + */ + public @Nullable String getFullName() { + return fullName; + } + + /** + * Gets the name to use for installation. + * If fullName is available, it will be used; otherwise, name will be used. + * + * @return The name to use for installation + */ + public @NotNull String getInstallName() { + if (fullName != null && !fullName.isEmpty()) { + return fullName; + } + return name; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + + // Add name + sb.append(name); + + // Add vendor name as a comment if available + if (vendorName != null && !vendorName.isEmpty()) { + sb.append(" (").append(vendorName).append(")"); + } + + // Add version if available + if (version != null) { + sb.append(" v").append(version); + } + + // Add type if available + if (type != null) { + sb.append(" [").append(type).append("]"); + } + + // Add stars if available + if (stars > 0) { + sb.append(" ★").append(stars); + } + + return sb.toString(); + } +} diff --git a/src/main/java/de/php_perfect/intellij/ddev/cmd/DdevRunner.java b/src/main/java/de/php_perfect/intellij/ddev/cmd/DdevRunner.java index ff9b0f7d..49a40e86 100644 --- a/src/main/java/de/php_perfect/intellij/ddev/cmd/DdevRunner.java +++ b/src/main/java/de/php_perfect/intellij/ddev/cmd/DdevRunner.java @@ -4,6 +4,8 @@ import com.intellij.openapi.project.Project; import org.jetbrains.annotations.NotNull; +import java.util.List; + public interface DdevRunner { void start(@NotNull Project project); @@ -20,6 +22,38 @@ public interface DdevRunner { void config(@NotNull Project project); + /** + * Gets a list of available add-ons that can be added to a DDEV project. + * + * @param project The project to get available add-ons for + * @return A list of available add-ons + */ + @NotNull List<DdevAddon> getAvailableAddons(@NotNull Project project); + + /** + * Gets a list of add-ons currently installed in a DDEV project. + * + * @param project The project to get installed add-ons for + * @return A list of installed add-ons + */ + @NotNull List<DdevAddon> getInstalledAddons(@NotNull Project project); + + /** + * Adds an add-on to a DDEV project. + * + * @param project The project to add the add-on to + * @param addon The add-on to add + */ + void addAddon(@NotNull Project project, @NotNull DdevAddon addon); + + /** + * Removes an add-on from a DDEV project. + * + * @param project The project to remove the add-on from + * @param addon The add-on to remove + */ + void deleteAddon(@NotNull Project project, @NotNull DdevAddon addon); + static DdevRunner getInstance() { return ApplicationManager.getApplication().getService(DdevRunner.class); } diff --git a/src/main/java/de/php_perfect/intellij/ddev/cmd/DdevRunnerImpl.java b/src/main/java/de/php_perfect/intellij/ddev/cmd/DdevRunnerImpl.java index b3469978..e3707066 100644 --- a/src/main/java/de/php_perfect/intellij/ddev/cmd/DdevRunnerImpl.java +++ b/src/main/java/de/php_perfect/intellij/ddev/cmd/DdevRunnerImpl.java @@ -3,64 +3,71 @@ import com.intellij.execution.configurations.GeneralCommandLine; import com.intellij.execution.configurations.PtyCommandLine; import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.extensions.ExtensionPointName; import com.intellij.openapi.fileEditor.FileEditorManager; import com.intellij.openapi.project.Project; import com.intellij.openapi.vfs.VirtualFile; import de.php_perfect.intellij.ddev.DdevConfigArgumentProvider; import de.php_perfect.intellij.ddev.DdevIntegrationBundle; +import de.php_perfect.intellij.ddev.addon.AddonCache; +import de.php_perfect.intellij.ddev.addon.AddonUtilsService; import de.php_perfect.intellij.ddev.state.DdevConfigLoader; import de.php_perfect.intellij.ddev.state.DdevStateManager; import de.php_perfect.intellij.ddev.state.State; import org.jetbrains.annotations.NotNull; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Objects; public final class DdevRunnerImpl implements DdevRunner { + private static final Logger LOG = Logger.getInstance(DdevRunnerImpl.class); private static final ExtensionPointName<DdevConfigArgumentProvider> CONFIG_ARGUMENT_PROVIDER_EP = ExtensionPointName.create("de.php_perfect.intellij.ddev.ddevConfigArgumentProvider"); @Override public void start(@NotNull Project project) { final String title = DdevIntegrationBundle.message("ddev.run.start"); final Runner runner = Runner.getInstance(project); - runner.run(this.createCommandLine("start", project), title, () -> this.updateDescription(project)); + runner.run(DdevRunnerImpl.createCommandLine("start", project), title, () -> this.updateDescription(project)); } @Override public void restart(@NotNull Project project) { final String title = DdevIntegrationBundle.message("ddev.run.restart"); final Runner runner = Runner.getInstance(project); - runner.run(this.createCommandLine("restart", project), title, () -> this.updateDescription(project)); + runner.run(DdevRunnerImpl.createCommandLine("restart", project), title, () -> this.updateDescription(project)); } @Override public void stop(@NotNull Project project) { final String title = DdevIntegrationBundle.message("ddev.run.stop"); final Runner runner = Runner.getInstance(project); - runner.run(this.createCommandLine("stop", project), title, () -> this.updateDescription(project)); + runner.run(DdevRunnerImpl.createCommandLine("stop", project), title, () -> this.updateDescription(project)); } @Override public void powerOff(@NotNull Project project) { final String title = DdevIntegrationBundle.message("ddev.run.powerOff"); final Runner runner = Runner.getInstance(project); - runner.run(this.createCommandLine("poweroff", project), title, () -> this.updateDescription(project)); + runner.run(DdevRunnerImpl.createCommandLine("poweroff", project), title, () -> this.updateDescription(project)); } @Override public void delete(@NotNull Project project) { final String title = DdevIntegrationBundle.message("ddev.run.delete"); final Runner runner = Runner.getInstance(project); - runner.run(this.createCommandLine("delete", project), title, () -> this.updateDescription(project)); + runner.run(DdevRunnerImpl.createCommandLine("delete", project), title, () -> this.updateDescription(project)); } @Override public void share(@NotNull Project project) { final String title = DdevIntegrationBundle.message("ddev.run.share"); final Runner runner = Runner.getInstance(project); - runner.run(this.createCommandLine("share", project), title); + runner.run(DdevRunnerImpl.createCommandLine("share", project), title, () -> this.updateDescription(project)); } @Override @@ -82,15 +89,21 @@ private void openConfig(@NotNull Project project) { } private void updateDescription(Project project) { - ApplicationManager.getApplication().executeOnPooledThread(() -> DdevStateManager.getInstance(project).updateDescription()); + ApplicationManager.getApplication().executeOnPooledThread(() -> + DdevStateManager.getInstance(project).updateDescription() + // No need to refresh the addon cache here, it will be refreshed when needed + ); } private void updateConfiguration(Project project) { - ApplicationManager.getApplication().executeOnPooledThread(() -> DdevStateManager.getInstance(project).updateConfiguration()); + ApplicationManager.getApplication().executeOnPooledThread(() -> + DdevStateManager.getInstance(project).updateConfiguration() + // No need to refresh the addon cache here, it will be refreshed when needed + ); } private @NotNull GeneralCommandLine buildConfigCommandLine(@NotNull Project project) { - final GeneralCommandLine commandLine = this.createCommandLine("config", project) + final GeneralCommandLine commandLine = DdevRunnerImpl.createCommandLine("config", project) .withParameters("--auto"); for (final DdevConfigArgumentProvider ddevConfigArgumentProvider : CONFIG_ARGUMENT_PROVIDER_EP.getExtensionList()) { @@ -100,7 +113,7 @@ private void updateConfiguration(Project project) { return commandLine; } - private @NotNull GeneralCommandLine createCommandLine(@NotNull String ddevAction, @NotNull Project project) { + public static @NotNull GeneralCommandLine createCommandLine(@NotNull String ddevAction, @NotNull Project project) { State state = DdevStateManager.getInstance(project).getState(); return new PtyCommandLine(List.of(Objects.requireNonNull(state.getDdevBinary()), ddevAction)) @@ -110,4 +123,81 @@ private void updateConfiguration(Project project) { .withCharset(StandardCharsets.UTF_8) .withEnvironment("DDEV_NONINTERACTIVE", "true"); } + + @Override + public @NotNull List<DdevAddon> getAvailableAddons(@NotNull Project project) { + LOG.debug("Getting available add-ons for project: " + project.getName()); + + State state = DdevStateManager.getInstance(project).getState(); + + if (!state.isAvailable() || !state.isConfigured()) { + LOG.warn("DDEV is not available or not configured for project: " + project.getName()); + return new ArrayList<>(); + } + + // Get the list of installed add-ons to exclude them from the available add-ons + List<String> installedAddonNames = getInstalledAddons(project).stream() + .map(DdevAddon::getName) + .toList(); + + // Get the available addons from the cache + // The getAvailableAddons method will handle refreshing if needed + List<DdevAddon> allAddons = AddonCache.getInstance(project).getAvailableAddons(); + + // Filter out installed add-ons + return allAddons.stream() + .filter(addon -> !installedAddonNames.contains(addon.getName())) + .toList(); + } + + + + @Override + public @NotNull List<DdevAddon> getInstalledAddons(@NotNull Project project) { + LOG.debug("Getting installed add-ons for project: " + project.getName()); + State state = DdevStateManager.getInstance(project).getState(); + + if (!state.isAvailable() || !state.isConfigured()) { + LOG.warn("DDEV is not available or not configured for project: " + project.getName()); + return Collections.emptyList(); + } + + // Execute the DDEV command to get installed add-ons + AddonUtilsService addonUtilsService = AddonUtilsService.getInstance(); + Map<String, Object> jsonObject = addonUtilsService.executeAddonCommand(project, "list", "--installed", "--json-output"); + + // Parse the JSON response into a list of DdevAddon objects + return addonUtilsService.parseInstalledAddons(jsonObject); + } + + @Override + public void addAddon(@NotNull Project project, @NotNull DdevAddon addon) { + String installName = addon.getInstallName(); + LOG.debug("Adding add-on: " + installName + " to project: " + project.getName()); + final String title = DdevIntegrationBundle.message("ddev.run.addAddon", addon.getName()); + final Runner runner = Runner.getInstance(project); + + // Use the DDEV add-on get command to properly add the add-on + GeneralCommandLine commandLine = DdevRunnerImpl.createCommandLine("add-on", project) + .withParameters("get", installName); + + runner.run(commandLine, title, () -> this.updateDescription(project)); + } + + @Override + public void deleteAddon(@NotNull Project project, @NotNull DdevAddon addon) { + // For the installed add-ons list, we need to use the original name from the list + // This is the name without any modifications + String addonName = addon.getName(); + + LOG.debug("Removing add-on: " + addonName + " from project: " + project.getName()); + final String title = DdevIntegrationBundle.message("ddev.run.deleteAddon", addon.getName()); + final Runner runner = Runner.getInstance(project); + + // Use the DDEV add-on remove command to properly remove the add-on + GeneralCommandLine commandLine = DdevRunnerImpl.createCommandLine("add-on", project) + .withParameters("remove", addonName); + + runner.run(commandLine, title, () -> this.updateDescription(project)); + } } diff --git a/src/main/java/de/php_perfect/intellij/ddev/cmd/Description.java b/src/main/java/de/php_perfect/intellij/ddev/cmd/Description.java index e520ece1..9c09f2e1 100644 --- a/src/main/java/de/php_perfect/intellij/ddev/cmd/Description.java +++ b/src/main/java/de/php_perfect/intellij/ddev/cmd/Description.java @@ -36,7 +36,6 @@ public enum Status { private final @Nullable String mailpitHttpUrl; - private final @Nullable Map<String, Service> services; @SerializedName("dbinfo") diff --git a/src/main/java/de/php_perfect/intellij/ddev/cmd/ProcessExecutorImpl.java b/src/main/java/de/php_perfect/intellij/ddev/cmd/ProcessExecutorImpl.java index 41a41f52..8aef68e9 100644 --- a/src/main/java/de/php_perfect/intellij/ddev/cmd/ProcessExecutorImpl.java +++ b/src/main/java/de/php_perfect/intellij/ddev/cmd/ProcessExecutorImpl.java @@ -5,33 +5,93 @@ import com.intellij.execution.configurations.GeneralCommandLine; import com.intellij.execution.process.CapturingProcessHandler; import com.intellij.execution.process.ProcessOutput; +import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.diagnostic.Logger; -import com.intellij.openapi.progress.EmptyProgressIndicator; -import com.intellij.openapi.progress.ProgressManager; import de.php_perfect.intellij.ddev.cmd.wsl.WslAware; import org.jetbrains.annotations.NotNull; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicReference; public class ProcessExecutorImpl implements ProcessExecutor { public static final Logger LOG = Logger.getInstance(ProcessExecutorImpl.class); + private static final String FAILED_EXECUTE_COMMAND_MSG = "Failed to execute command: "; public @NotNull ProcessOutput executeCommandLine(GeneralCommandLine commandLine, int timeout, boolean loginShell) throws ExecutionException { + // Check if we're on the EDT + if (ApplicationManager.getApplication().isDispatchThread()) { + // If we're on the EDT, run the command in a background thread + return executeCommandLineInBackground(commandLine, timeout, loginShell); + } else { + // If we're already on a background thread, run the command directly + return executeCommandLineDirectly(commandLine, timeout, loginShell); + } + } + + private @NotNull ProcessOutput executeCommandLineDirectly(GeneralCommandLine commandLine, int timeout, boolean loginShell) throws ExecutionException { + final GeneralCommandLine patchedCommandLine = WslAware.patchCommandLine(commandLine, loginShell); + + try { + CapturingProcessHandler processHandler = new CapturingProcessHandler(patchedCommandLine); + ProcessOutput output = processHandler.runProcess(timeout); + + LOG.debug("command: " + processHandler.getCommandLine() + " returned: " + output); + return output; + } catch (ExecutionException e) { + LOG.error(FAILED_EXECUTE_COMMAND_MSG + patchedCommandLine.getCommandLineString(), e); + throw e; + } + } + + private @NotNull ProcessOutput executeCommandLineInBackground(GeneralCommandLine commandLine, int timeout, boolean loginShell) throws ExecutionException { final GeneralCommandLine patchedCommandLine = WslAware.patchCommandLine(commandLine, loginShell); final AtomicReference<ProcessOutput> outputReference = new AtomicReference<>(); + final AtomicReference<ExecutionException> exceptionReference = new AtomicReference<>(); - ProgressManager.getInstance().runProcess(() -> { + // Create a future that will be completed when the task is done + Future<ProcessOutput> future = ApplicationManager.getApplication().executeOnPooledThread(() -> { try { CapturingProcessHandler processHandler = new CapturingProcessHandler(patchedCommandLine); ProcessOutput output = processHandler.runProcess(timeout); outputReference.set(output); LOG.debug("command: " + processHandler.getCommandLine() + " returned: " + output); + return output; } catch (ExecutionException e) { + LOG.error(FAILED_EXECUTE_COMMAND_MSG + patchedCommandLine.getCommandLineString(), e); + exceptionReference.set(e); throw new UncheckedExecutionException(e); } - }, new EmptyProgressIndicator()); + }); + + try { + // Wait for the future to complete with a timeout + future.get(timeout, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + LOG.error(FAILED_EXECUTE_COMMAND_MSG + patchedCommandLine.getCommandLineString(), e); + future.cancel(true); + // Restore the interrupted status + Thread.currentThread().interrupt(); + throw new ExecutionException(FAILED_EXECUTE_COMMAND_MSG + e.getMessage(), e); + } catch (java.util.concurrent.ExecutionException | TimeoutException e) { + LOG.error(FAILED_EXECUTE_COMMAND_MSG + patchedCommandLine.getCommandLineString(), e); + future.cancel(true); + throw new ExecutionException(FAILED_EXECUTE_COMMAND_MSG + e.getMessage(), e); + } + + // Check if an exception was thrown + if (exceptionReference.get() != null) { + throw exceptionReference.get(); + } + + // Return the output + ProcessOutput output = outputReference.get(); + if (output == null) { + throw new ExecutionException(FAILED_EXECUTE_COMMAND_MSG + "No output returned"); + } - return outputReference.get(); + return output; } } diff --git a/src/main/java/de/php_perfect/intellij/ddev/cmd/parser/JsonUtil.java b/src/main/java/de/php_perfect/intellij/ddev/cmd/parser/JsonUtil.java new file mode 100644 index 00000000..88d6cdc6 --- /dev/null +++ b/src/main/java/de/php_perfect/intellij/ddev/cmd/parser/JsonUtil.java @@ -0,0 +1,211 @@ +package de.php_perfect.intellij.ddev.cmd.parser; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSyntaxException; +import com.intellij.openapi.diagnostic.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +/** + * Utility class for parsing JSON. + */ +public final class JsonUtil { + private static final Logger LOG = Logger.getInstance(JsonUtil.class); + + private JsonUtil() { + // Utility class + } + + /** + * Parses a JSON string into a Map. + * + * @param json The JSON string to parse + * @return A Map representing the JSON object, or null if parsing fails + */ + @Nullable + public static Map<String, Object> parseJson(@NotNull String json) { + try { + JsonElement jsonElement = JsonParser.parseString(json); + if (jsonElement.isJsonObject()) { + return convertJsonObjectToMap(jsonElement.getAsJsonObject()); + } else { + LOG.warn("JSON is not an object: " + json); + return null; + } + } catch (JsonSyntaxException e) { + LOG.error("Failed to parse JSON: " + json, e); + return null; + } + } + + /** + * Converts a JsonObject to a Map. + * + * @param jsonObject The JsonObject to convert + * @return A Map representing the JsonObject + */ + private static Map<String, Object> convertJsonObjectToMap(JsonObject jsonObject) { + Map<String, Object> map = new HashMap<>(); + for (Entry<String, JsonElement> entry : jsonObject.entrySet()) { + String key = entry.getKey(); + JsonElement value = entry.getValue(); + map.put(key, convertJsonElementToObject(value)); + } + return map; + } + + /** + * Converts a JsonElement to a Java object. + * + * @param jsonElement The JsonElement to convert + * @return A Java object representing the JsonElement + */ + private static Object convertJsonElementToObject(JsonElement jsonElement) { + if (jsonElement.isJsonNull()) { + return null; + } else if (jsonElement.isJsonPrimitive()) { + JsonPrimitive primitive = jsonElement.getAsJsonPrimitive(); + if (primitive.isBoolean()) { + return primitive.getAsBoolean(); + } else if (primitive.isNumber()) { + return primitive.getAsNumber(); + } else { + return primitive.getAsString(); + } + } else if (jsonElement.isJsonArray()) { + List<Object> list = new ArrayList<>(); + JsonArray jsonArray = jsonElement.getAsJsonArray(); + for (JsonElement element : jsonArray) { + list.add(convertJsonElementToObject(element)); + } + return list; + } else if (jsonElement.isJsonObject()) { + return convertJsonObjectToMap(jsonElement.getAsJsonObject()); + } else { + return null; + } + } + + /** + * Gets a list of maps from a JSON object. + * + * @param json The JSON object as a Map + * @param key The key of the array in the JSON object + * @return A list of maps representing the JSON array, or an empty list if the key is not found + */ + @NotNull + public static List<Map<String, Object>> getList(@Nullable Map<String, Object> json, @NotNull String key) { + if (json == null) { + return new ArrayList<>(); + } + + Object value = json.get(key); + if (value instanceof List<?> list) { + List<Map<String, Object>> result = new ArrayList<>(); + for (Object item : list) { + if (item instanceof Map<?, ?> map) { + @SuppressWarnings("unchecked") + Map<String, Object> typedMap = (Map<String, Object>) map; + result.add(typedMap); + } + } + return result; + } + + return new ArrayList<>(); + } + + /** + * Gets a string value from a JSON object. + * + * @param json The JSON object as a Map + * @param key The key of the string in the JSON object + * @return The string value, or an empty string if the key is not found + */ + @NotNull + public static String getString(@Nullable Map<String, Object> json, @NotNull String key) { + if (json == null) { + return ""; + } + + Object value = json.get(key); + if (value instanceof String string) { + return string; + } + + return value != null ? value.toString() : ""; + } + + /** + * Gets a boolean value from a JSON object. + * + * @param json The JSON object as a Map + * @param key The key of the boolean in the JSON object + * @return The boolean value, or false if the key is not found + */ + public static boolean getBoolean(@Nullable Map<String, Object> json, @NotNull String key) { + if (json == null) { + return false; + } + + Object value = json.get(key); + if (value instanceof Boolean boolValue) { + return boolValue; + } + + return false; + } + + /** + * Gets an integer value from a JSON object. + * + * @param json The JSON object as a Map + * @param key The key of the integer in the JSON object + * @return The integer value, or 0 if the key is not found + */ + public static int getInt(@Nullable Map<String, Object> json, @NotNull String key) { + if (json == null) { + return 0; + } + + Object value = json.get(key); + if (value instanceof Number number) { + return number.intValue(); + } + + return 0; + } + + /** + * Gets a map value from a JSON object. + * + * @param json The JSON object as a Map + * @param key The key of the map in the JSON object + * @return The map value, or null if the key is not found + */ + @Nullable + public static Map<String, Object> getMap(@Nullable Map<String, Object> json, @NotNull String key) { + if (json == null) { + return null; + } + + Object value = json.get(key); + if (value instanceof Map<?, ?> map) { + @SuppressWarnings("unchecked") + Map<String, Object> typedMap = (Map<String, Object>) map; + return typedMap; + } + + return null; + } +} diff --git a/src/main/java/de/php_perfect/intellij/ddev/ui/AbstractAddonPopup.java b/src/main/java/de/php_perfect/intellij/ddev/ui/AbstractAddonPopup.java new file mode 100644 index 00000000..72263403 --- /dev/null +++ b/src/main/java/de/php_perfect/intellij/ddev/ui/AbstractAddonPopup.java @@ -0,0 +1,400 @@ +package de.php_perfect.intellij.ddev.ui; + +import com.intellij.icons.AllIcons; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.ui.popup.ComponentPopupBuilder; +import com.intellij.openapi.ui.popup.JBPopup; +import com.intellij.openapi.ui.popup.JBPopupFactory; +import com.intellij.openapi.ui.popup.JBPopupListener; +import com.intellij.openapi.ui.popup.LightweightWindowEvent; +import com.intellij.ui.ColoredListCellRenderer; +import com.intellij.ui.SimpleTextAttributes; +import com.intellij.ui.components.JBList; +import com.intellij.ui.components.JBScrollPane; +import com.intellij.ui.components.JBTextField; +import com.intellij.util.ui.JBUI; +import de.php_perfect.intellij.ddev.cmd.DdevAddon; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.swing.*; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.event.ListSelectionEvent; +import java.awt.*; +import java.util.ArrayList; +import java.util.List; + +/** + * Base class for DDEV addon popups with common functionality. + */ +public abstract class AbstractAddonPopup { + protected static final Logger LOG = Logger.getInstance(AbstractAddonPopup.class); + + protected final Project project; + protected final List<DdevAddon> allAddons = new ArrayList<>(); + protected final JBList<DdevAddon> addonList; + protected final DefaultListModel<DdevAddon> listModel; + protected final JBTextField searchField; + protected final JPanel panel; + protected final JButton refreshButton; + protected JBPopup popup; + protected Timer refreshTimer; + + /** + * Creates a new addon popup. + * + * @param project The project context + * @param searchPlaceholder Text to show in the search field when empty + * @param initialAddons Initial list of addons to display (can be empty) + * @param dimensions Initial dimensions for the popup + */ + protected AbstractAddonPopup(@NotNull Project project, @NotNull String searchPlaceholder, + @Nullable List<DdevAddon> initialAddons, @NotNull Dimension dimensions) { + this.project = project; + + // Initialize components + this.listModel = new DefaultListModel<>(); + this.addonList = new JBList<>(listModel); + this.searchField = new JBTextField(); + this.refreshButton = new JButton("Refresh", AllIcons.Actions.Refresh); + this.panel = new JPanel(new BorderLayout()); + + // Add initial addons if provided + if (initialAddons != null) { + // Sort add-ons by name + this.allAddons.addAll(initialAddons.stream() + .sorted((a1, a2) -> a1.getName().compareToIgnoreCase(a2.getName())) + .toList()); + } + + // Initialize UI + initializeUI(searchPlaceholder, dimensions); + + // Update the list initially + updateList(""); + } + + /** + * Sets up the UI components. + * + * @param searchPlaceholder Text to show in search field when empty + * @param dimensions Initial dimensions for the popup + */ + protected void initializeUI(@NotNull String searchPlaceholder, @NotNull Dimension dimensions) { + // Set up the search field + searchField.getEmptyText().setText(searchPlaceholder); + searchField.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + updateList(searchField.getText()); + } + + @Override + public void removeUpdate(DocumentEvent e) { + updateList(searchField.getText()); + } + + @Override + public void changedUpdate(DocumentEvent e) { + updateList(searchField.getText()); + } + }); + + // Set up the add-on list + addonList.setCellRenderer(createListCellRenderer()); + addonList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + addonList.addListSelectionListener(this::handleListSelection); + + // Set up the refresh button + refreshButton.setToolTipText(getRefreshButtonTooltip()); + refreshButton.addActionListener(e -> handleRefreshAction()); + + // Create the panel + JPanel searchPanel = new JPanel(new BorderLayout()); + searchPanel.add(searchField, BorderLayout.CENTER); + searchPanel.add(refreshButton, BorderLayout.EAST); + searchPanel.setBorder(JBUI.Borders.empty(5)); + + panel.add(searchPanel, BorderLayout.NORTH); + panel.add(new JBScrollPane(addonList), BorderLayout.CENTER); + panel.setPreferredSize(dimensions); + } + + /** + * Updates the list based on the search text. + * + * @param searchText The text to filter by + */ + protected void updateList(String searchText) { + listModel.clear(); + + List<DdevAddon> filteredAddons = filterAddons(searchText); + for (DdevAddon addon : filteredAddons) { + listModel.addElement(addon); + } + + if (filteredAddons.isEmpty()) { + handleEmptyList(searchText); + } + } + + /** + * Filters addons based on search text. + * + * @param searchText The text to filter by + * @return Filtered list of addons + */ + protected List<DdevAddon> filterAddons(String searchText) { + if (searchText == null || searchText.trim().isEmpty()) { + return new ArrayList<>(allAddons); + } + + String lowerCaseSearchText = searchText.toLowerCase(); + + return allAddons.stream() + .filter(addon -> { + // Check if the add-on name contains the search text + if (addon.getName().toLowerCase().contains(lowerCaseSearchText)) { + return true; + } + + // Check if the description contains the search text + if (addon.getDescription().toLowerCase().contains(lowerCaseSearchText)) { + return true; + } + + // Check if the vendor name contains the search text + if (addon.getVendorName() != null && + addon.getVendorName().toLowerCase().contains(lowerCaseSearchText)) { + return true; + } + + // Check if the type contains the search text + return addon.getType() != null && + addon.getType().toLowerCase().contains(lowerCaseSearchText); + }) + .toList(); + } + + /** + * Shows the popup. + */ + public void show() { + ComponentPopupBuilder builder = JBPopupFactory.getInstance() + .createComponentPopupBuilder(panel, searchField) + .setTitle(getPopupTitle()) + .setResizable(true) + .setMovable(true) + .setRequestFocus(true); + + popup = builder.createPopup(); + + // Add listeners + popup.addListener(createPopupListener()); + + // Show the popup + popup.showCenteredInCurrentWindow(project); + } + + /** + * Creates a cell renderer for the addon list. + * + * @return The cell renderer + */ + protected ListCellRenderer<DdevAddon> createListCellRenderer() { + return new AddonListCellRenderer(getAddonIcon(), shouldShowDescription()); + } + + /** + * Creates a popup listener. + * + * @return The popup listener + */ + protected JBPopupListener createPopupListener() { + return new JBPopupListener() { + @Override + public void onClosed(@NotNull LightweightWindowEvent event) { + // Hook for cleanup actions + handlePopupClosed(); + } + }; + } + + // Abstract methods to be implemented by subclasses + + /** + * Gets the title for the popup. + * + * @return The popup title + */ + protected abstract @NotNull String getPopupTitle(); + + /** + * Gets the tooltip text for the refresh button. + * + * @return The tooltip text + */ + protected abstract @NotNull String getRefreshButtonTooltip(); + + /** + * Handles the list selection event. + * + * @param e The list selection event + */ + protected abstract void handleListSelection(ListSelectionEvent e); + + /** + * Handles the refresh button action. + * This is a template method that calls the specific implementation methods. + */ + protected void handleRefreshAction() { + // Show a loading indicator + addonList.setPaintBusy(true); + addonList.setEmptyText("Refreshing add-ons..."); + + // Stop any existing timer + stopRefreshTimer(); + + // Create a new timer to refresh the add-ons + refreshTimer = new Timer(getRefreshDelayMillis(), event -> { + // Get the refreshed add-ons + List<DdevAddon> refreshedAddons = getRefreshedAddons(); + + // Sort add-ons by name + List<DdevAddon> sortedAddons = refreshedAddons.stream() + .sorted((a1, a2) -> a1.getName().compareToIgnoreCase(a2.getName())) + .toList(); + + // Update the allAddons list + allAddons.clear(); + allAddons.addAll(sortedAddons); + + // Update the list + updateList(searchField.getText()); + + // Stop the loading indicator + addonList.setPaintBusy(false); + + // Log the refresh + LOG.debug("Add-ons refreshed successfully: " + refreshedAddons.size() + " add-ons found"); + }); + + refreshTimer.setRepeats(false); + refreshTimer.start(); + } + + /** + * Gets the delay in milliseconds to wait before refreshing the add-ons. + * Default is 1000ms (1 second). + * + * @return The delay in milliseconds + */ + protected int getRefreshDelayMillis() { + return 1000; + } + + /** + * Gets the refreshed add-ons. + * This method should be implemented by subclasses to provide the specific add-ons to display. + * + * @return The refreshed add-ons + */ + protected abstract List<DdevAddon> getRefreshedAddons(); + + /** + * Stops the refresh timer if it's running. + */ + protected void stopRefreshTimer() { + if (refreshTimer != null && refreshTimer.isRunning()) { + refreshTimer.stop(); + } + } + + /** + * Handles what to show when the filtered list is empty. + * + * @param searchText The current search text + */ + protected abstract void handleEmptyList(String searchText); + + /** + * Gets the icon to use for addons in the list. + * + * @return The icon + */ + protected abstract @NotNull Icon getAddonIcon(); + + /** + * Determines whether to show the description in the list. + * + * @return true to show the description, false otherwise + */ + protected abstract boolean shouldShowDescription(); + + /** + * Called when the popup is closed. + * Default implementation stops the refresh timer. + */ + protected void handlePopupClosed() { + stopRefreshTimer(); + } + + /** + * Cell renderer for addon items. + */ + protected static class AddonListCellRenderer extends ColoredListCellRenderer<DdevAddon> { + private final transient Icon addonIcon; + private final boolean showDescription; + + /** + * Creates a new addon list cell renderer. + * + * @param addonIcon The icon to use for addons + * @param showDescription Whether to show the description + */ + public AddonListCellRenderer(@NotNull Icon addonIcon, boolean showDescription) { + this.addonIcon = addonIcon; + this.showDescription = showDescription; + } + + @Override + protected void customizeCellRenderer(@NotNull JList<? extends DdevAddon> list, DdevAddon addon, + int index, boolean selected, boolean hasFocus) { + // Set the add-on name + append(addon.getName(), SimpleTextAttributes.REGULAR_ATTRIBUTES); + + // Add vendor name as a comment if available + if (addon.getVendorName() != null && !addon.getVendorName().isEmpty()) { + append(" (" + addon.getVendorName() + ")", SimpleTextAttributes.GRAYED_ATTRIBUTES); + } + + // Add version if available + if (addon.getVersion() != null) { + append(" v" + addon.getVersion(), SimpleTextAttributes.GRAYED_ATTRIBUTES); + } + + // Add type if available + if (addon.getType() != null) { + SimpleTextAttributes typeAttributes = "official".equals(addon.getType()) + ? new SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, JBUI.CurrentTheme.Link.Foreground.ENABLED) + : SimpleTextAttributes.GRAY_ATTRIBUTES; + append(" [" + addon.getType() + "]", typeAttributes); + } + + // Add stars if available + if (addon.getStars() > 0) { + append(" ★" + addon.getStars(), SimpleTextAttributes.GRAY_ATTRIBUTES); + } + + // Add description if needed + if (showDescription && !addon.getDescription().isEmpty()) { + append(" - " + addon.getDescription(), SimpleTextAttributes.GRAY_ATTRIBUTES); + } + + // Set icon + setIcon(addonIcon); + } + } +} diff --git a/src/main/java/de/php_perfect/intellij/ddev/ui/AddonDeletePopup.java b/src/main/java/de/php_perfect/intellij/ddev/ui/AddonDeletePopup.java new file mode 100644 index 00000000..33a8e82a --- /dev/null +++ b/src/main/java/de/php_perfect/intellij/ddev/ui/AddonDeletePopup.java @@ -0,0 +1,73 @@ +package de.php_perfect.intellij.ddev.ui; + +import com.intellij.icons.AllIcons; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.ui.Messages; +import de.php_perfect.intellij.ddev.cmd.DdevAddon; +import de.php_perfect.intellij.ddev.cmd.DdevRunner; +import org.jetbrains.annotations.NotNull; + +import javax.swing.*; +import javax.swing.event.ListSelectionEvent; +import java.awt.*; +import java.util.List; + +/** + * Custom popup for displaying and filtering installed DDEV add-ons for deletion. + */ +public class AddonDeletePopup extends AbstractAddonPopup { + public AddonDeletePopup(@NotNull Project project, @NotNull List<DdevAddon> addons) { + super(project, "Search installed add-ons...", addons, new Dimension(500, 300)); + } + + @Override + protected @NotNull String getPopupTitle() { + return "Select a DDEV Add-on to Remove"; + } + + @Override + protected @NotNull String getRefreshButtonTooltip() { + return "Refresh the list of installed add-ons"; + } + + @Override + protected void handleListSelection(ListSelectionEvent e) { + if (!e.getValueIsAdjusting() && addonList.getSelectedValue() != null) { + DdevAddon selectedAddon = addonList.getSelectedValue(); + popup.closeOk(null); + + int result = Messages.showYesNoDialog( + project, + "Are you sure you want to remove the add-on '" + selectedAddon.getName() + "'?", + "Confirm Add-on Removal", + Messages.getQuestionIcon() + ); + + if (result == Messages.YES) { + DdevRunner.getInstance().deleteAddon(project, selectedAddon); + } + } + } + + @Override + protected List<DdevAddon> getRefreshedAddons() { + return DdevRunner.getInstance().getInstalledAddons(project); + } + + @Override + protected void handleEmptyList(String searchText) { + // If no add-ons match the search, show a message + addonList.setEmptyText("No add-ons found matching '" + searchText + "'"); + } + + @Override + protected @NotNull Icon getAddonIcon() { + return AllIcons.Actions.GC; + } + + @Override + protected boolean shouldShowDescription() { + // Skip the description as it's redundant (just says "Installed add-on: X") + return false; + } +} diff --git a/src/main/java/de/php_perfect/intellij/ddev/ui/AddonListPopup.java b/src/main/java/de/php_perfect/intellij/ddev/ui/AddonListPopup.java new file mode 100644 index 00000000..085c495d --- /dev/null +++ b/src/main/java/de/php_perfect/intellij/ddev/ui/AddonListPopup.java @@ -0,0 +1,160 @@ +package de.php_perfect.intellij.ddev.ui; + +import com.intellij.icons.AllIcons; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import de.php_perfect.intellij.ddev.addon.AddonCache; +import de.php_perfect.intellij.ddev.cmd.DdevAddon; +import de.php_perfect.intellij.ddev.cmd.DdevRunner; +import org.jetbrains.annotations.NotNull; + +import javax.swing.*; +import javax.swing.event.ListSelectionEvent; +import java.awt.*; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Custom popup for displaying and filtering DDEV add-ons. + */ +public class AddonListPopup extends AbstractAddonPopup { + private static final Logger LOG = Logger.getInstance(AddonListPopup.class); + private final AtomicBoolean isLoading = new AtomicBoolean(false); + private Timer loadingCheckTimer; + + /** + * Creates a new AddonListPopup with an empty list that will be populated asynchronously. + * + * @param project The project to get add-ons for + */ + public AddonListPopup(@NotNull Project project) { + super(project, "Search add-ons...", null, new Dimension(600, 400)); + + // Show loading indicator + addonList.setPaintBusy(true); + addonList.setEmptyText("Loading add-ons..."); + + // Start loading add-ons in the background + loadAddonsAsync(); + } + + /** + * Loads add-ons asynchronously and updates the UI when they are available. + */ + private void loadAddonsAsync() { + if (isLoading.compareAndSet(false, true)) { + // Trigger a refresh of the cache to make sure we get the latest data + AddonCache.getInstance(project).refreshCacheAsync(); + + // Create a timer to check for add-ons periodically + loadingCheckTimer = new Timer(500, e -> { + List<DdevAddon> availableAddons = getRefreshedAddons(); + + if (!availableAddons.isEmpty()) { + // We have add-ons, stop the timer and update the UI + loadingCheckTimer.stop(); + + // Sort and update the list using the same logic as in handleRefreshAction + List<DdevAddon> sortedAddons = availableAddons.stream() + .sorted((a1, a2) -> a1.getName().compareToIgnoreCase(a2.getName())) + .toList(); + + allAddons.clear(); + allAddons.addAll(sortedAddons); + updateList(searchField.getText()); + + // Stop the loading indicator + addonList.setPaintBusy(false); + + LOG.debug("Add-ons loaded successfully: " + availableAddons.size() + " add-ons found"); + } + }); + + loadingCheckTimer.setRepeats(true); + loadingCheckTimer.start(); + } + } + + @Override + protected @NotNull String getPopupTitle() { + return "Select a DDEV Add-on to Add"; + } + + @Override + protected @NotNull String getRefreshButtonTooltip() { + return "Refresh the list of available add-ons"; + } + + @Override + protected void handleListSelection(ListSelectionEvent e) { + if (!e.getValueIsAdjusting() && addonList.getSelectedValue() != null) { + DdevAddon selectedAddon = addonList.getSelectedValue(); + popup.closeOk(null); + DdevRunner.getInstance().addAddon(project, selectedAddon); + } + } + + @Override + protected void handleRefreshAction() { + // Show a loading indicator + addonList.setPaintBusy(true); + addonList.setEmptyText("Refreshing add-ons..."); + isLoading.set(true); + + // Clear the current list + allAddons.clear(); + updateList(searchField.getText()); + + // Refresh the cache in the background + AddonCache.getInstance(project).refreshCacheAsync(); + + // Use the base class refresh logic + super.handleRefreshAction(); + } + + @Override + protected List<DdevAddon> getRefreshedAddons() { + List<DdevAddon> availableAddons = DdevRunner.getInstance().getAvailableAddons(project); + if (!availableAddons.isEmpty()) { + isLoading.set(false); + } + return availableAddons; + } + + @Override + protected int getRefreshDelayMillis() { + return 500; // Use a shorter delay for available add-ons + } + + @Override + protected void handleEmptyList(String searchText) { + if (isLoading.get()) { + // If we're still loading, show a loading message + addonList.setEmptyText("Loading add-ons..."); + } else if (allAddons.isEmpty()) { + // If there are no add-ons at all, show a message + addonList.setEmptyText("No add-ons available"); + } else { + // If no add-ons match the search, show a message + addonList.setEmptyText("No add-ons found matching '" + searchText + "'"); + } + } + + @Override + protected @NotNull Icon getAddonIcon() { + return AllIcons.Nodes.Plugin; + } + + @Override + protected boolean shouldShowDescription() { + return true; + } + + @Override + protected void handlePopupClosed() { + super.handlePopupClosed(); + if (loadingCheckTimer != null && loadingCheckTimer.isRunning()) { + loadingCheckTimer.stop(); + } + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 685b7867..3653b4ee 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -74,6 +74,8 @@ serviceInterface="de.php_perfect.intellij.ddev.cmd.parser.JsonParser"/> <applicationService serviceImplementation="de.php_perfect.intellij.ddev.cmd.DockerImpl" serviceInterface="de.php_perfect.intellij.ddev.cmd.Docker"/> + <applicationService serviceImplementation="de.php_perfect.intellij.ddev.addon.AddonUtilsServiceImpl" + serviceInterface="de.php_perfect.intellij.ddev.addon.AddonUtilsService"/> <projectService serviceImplementation="de.php_perfect.intellij.ddev.index.ManagedConfigurationIndexImpl" serviceInterface="de.php_perfect.intellij.ddev.index.ManagedConfigurationIndex"/> @@ -98,6 +100,7 @@ key="settings.title"/> <postStartupActivity implementation="de.php_perfect.intellij.ddev.InitPluginActivity"/> + <postStartupActivity implementation="de.php_perfect.intellij.ddev.addon.AddonCacheStartupActivity"/> <statusBarWidgetFactory order="before light.edit.large.file.encoding.widget" implementation="de.php_perfect.intellij.ddev.statusBar.DdevStatusBarWidgetFactoryImpl" @@ -147,6 +150,18 @@ <override-text place="MainMenu"/> <override-text place="EditorPopup" use-text-of-place="MainMenu"/> </action> + <separator/> + <action id="DdevIntegration.Run.AddAddon" icon="AllIcons.General.Add" + class="de.php_perfect.intellij.ddev.actions.DdevAddAddonAction"> + <override-text place="MainMenu"/> + <override-text place="EditorPopup" use-text-of-place="MainMenu"/> + </action> + <action id="DdevIntegration.Run.DeleteAddon" icon="AllIcons.General.Remove" + class="de.php_perfect.intellij.ddev.actions.DdevDeleteAddonAction"> + <override-text place="MainMenu"/> + <override-text place="EditorPopup" use-text-of-place="MainMenu"/> + </action> + </group> <group id="DdevIntegration.Services" class="de.php_perfect.intellij.ddev.actions.ServicesActionGroup"/> <action id="DdevIntegration.CheckVersion" class="de.php_perfect.intellij.ddev.actions.CheckVersionAction"/> diff --git a/src/main/resources/messages/DdevIntegrationBundle.properties b/src/main/resources/messages/DdevIntegrationBundle.properties index 6dacb929..76d216ba 100644 --- a/src/main/resources/messages/DdevIntegrationBundle.properties +++ b/src/main/resources/messages/DdevIntegrationBundle.properties @@ -45,6 +45,12 @@ action.DdevIntegration.Run.Share.description=Share project on the internet via n action.DdevIntegration.Run.Config.text=Configure DDEV Project action.DdevIntegration.Run.Config.MainMenu.text=Configure action.DdevIntegration.Run.Config.description=Create a DDEV configuration in the current project +action.DdevIntegration.Run.AddAddon.text=Add Add-On to DDEV Project +action.DdevIntegration.Run.AddAddon.MainMenu.text=Add Add-On +action.DdevIntegration.Run.AddAddon.description=Add an add-on to your DDEV project +action.DdevIntegration.Run.DeleteAddon.text=Remove Add-On from DDEV Project +action.DdevIntegration.Run.DeleteAddon.MainMenu.text=Remove Add-On +action.DdevIntegration.Run.DeleteAddon.description=Remove an add-on from your DDEV project action.DdevIntegration.CheckVersion.text=Check for DDEV Updates action.DdevIntegration.CheckVersion.description=Check whether a new DDEV version is available for download action.DdevIntegration.Terminal.text=DDEV Web Container @@ -85,6 +91,9 @@ ddev.run.powerOff=DDEV Power Off ddev.run.delete=DDEV Delete ddev.run.share=DDEV Share ddev.run.config=DDEV Config + +ddev.run.addAddon=DDEV Add Add-on: {0} +ddev.run.deleteAddon=DDEV Delete Add-on: {0} # Status Bar statusBar.displayName=DDEV Status statusBar.toolTip=DDEV Status and Services