Skip to content

feat: implement add-on manager #435

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
125 changes: 125 additions & 0 deletions src/main/java/de/php_perfect/intellij/ddev/addon/AddonCache.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading