blob: 8887e3309e996577f9fcaee1a1ab0b83f0f280f3 [file] [log] [blame]
/*
* Copyright (C) 2015 The Android Open Source Project
*
* Licensed 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 com.android.tools.idea.gradle.project.subset;
import com.android.builder.model.Variant;
import com.android.tools.idea.gradle.IdeaAndroidProject;
import com.android.tools.idea.gradle.IdeaAndroidProject.SourceFileContainerInfo;
import com.android.tools.idea.gradle.IdeaGradleProject;
import com.android.tools.idea.gradle.IdeaJavaProject;
import com.android.tools.idea.gradle.project.AndroidGradleNotification;
import com.android.tools.idea.gradle.project.GradleExperimentalSettings;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.intellij.ide.util.PropertiesComponent;
import com.intellij.openapi.components.ServiceManager;
import com.intellij.openapi.externalSystem.model.DataNode;
import com.intellij.openapi.externalSystem.model.project.ModuleData;
import com.intellij.openapi.externalSystem.model.project.ProjectData;
import com.intellij.openapi.progress.ProgressIndicator;
import com.intellij.openapi.progress.Task;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.vfs.VirtualFile;
import org.jetbrains.annotations.NonNls;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.util.*;
import static com.android.tools.idea.gradle.AndroidProjectKeys.*;
import static com.android.tools.idea.gradle.util.GradleUtil.getCachedProjectData;
import static com.android.tools.idea.gradle.util.Projects.populate;
import static com.intellij.notification.NotificationType.ERROR;
import static com.intellij.notification.NotificationType.INFORMATION;
import static com.intellij.openapi.externalSystem.model.ProjectKeys.MODULE;
import static com.intellij.openapi.externalSystem.util.ExternalSystemApiUtil.find;
import static com.intellij.openapi.externalSystem.util.ExternalSystemApiUtil.findAll;
import static com.intellij.openapi.vfs.VfsUtilCore.virtualToIoFile;
import static com.intellij.util.ArrayUtil.toStringArray;
import static com.intellij.util.ui.UIUtil.invokeLaterIfNeeded;
/**
* A project subset is a feature where users can select the modules in a project to include when an existing project is imported into the
* IDE. This feature is handy when users work with big projects (e.g. 300+ modules) but, in practice, modify sources in a few of them. A
* smaller set of source code can make IDE's performance better (e.g. indexing and building.)
*/
public final class ProjectSubset {
@NonNls private static final String PROJECT_SUBSET_PROPERTY_NAME = "com.android.studio.selected.modules.on.import";
private static final String MODULE_LOOKUP_MESSAGE_TITLE = "Module Lookup";
@NotNull private Project myProject;
@NotNull
public static ProjectSubset getInstance(@NotNull Project project) {
return ServiceManager.getService(project, ProjectSubset.class);
}
public ProjectSubset(@NotNull Project project) {
myProject = project;
}
public static boolean isSettingEnabled() {
return GradleExperimentalSettings.getInstance().SELECT_MODULES_ON_PROJECT_IMPORT;
}
public boolean hasCachedModules() {
Collection<DataNode<ModuleData>> modules = getCachedModuleData();
return modules != null && !modules.isEmpty();
}
public void addOrRemoveModules() {
Collection<DataNode<ModuleData>> modules = getCachedModuleData();
if (modules != null) {
Collection<String> selectedModuleNames = Collections.emptySet();
String[] selection = getSelection();
if (selection != null) {
selectedModuleNames = Sets.newHashSet(selection);
}
Collection<DataNode<ModuleData>> selectedModules = showModuleSelectionDialog(modules, selectedModuleNames);
if (selectedModules != null) {
setSelection(selectedModules);
if (!Arrays.equals(getSelection(), selection)) {
populate(myProject, selectedModules);
}
}
}
}
/**
* Finds and includes the module that contains the given file.
* <p>
* When using the "Project Subset" feature it is possible that the user knows which file she wants to edit but not the module where
* such file is. This method tries to find the module that includes the given file in the folders that it marked as "source", either
* production or test code.
* </p>
* <p>
* The search is based on the Gradle models for both Android and Java modules. If the search finds more than one module that might contain
* the file, the IDE will display a dialog where the user can see the potential matches and choose the module to include in the project.
* </p>
* @param virtualFile the given file.
*/
public void findAndIncludeModuleContainingSourceFile(@NotNull VirtualFile virtualFile) {
final Collection<DataNode<ModuleData>> modules = getCachedModuleData();
if (modules != null && !modules.isEmpty()) {
final Project project = myProject;
final File file = virtualToIoFile(virtualFile);
new Task.Modal(project, "Looking up Module", false) {
@Override
public void run(@NotNull ProgressIndicator indicator) {
List<ModuleSearchResult> results = Lists.newArrayList();
String[] storedSelection = getSelection();
Set<String> selection = storedSelection != null ? Sets.newHashSet(storedSelection) : Sets.<String>newHashSet();
List<DataNode<ModuleData>> selectedModules = Lists.newArrayList();
int doneCount = 0;
for (DataNode<ModuleData> moduleNode : modules) {
indicator.setFraction(++doneCount / modules.size());
ModuleData module = moduleNode.getData();
String name = module.getExternalName();
boolean selected = selection.contains(name);
if (selected) {
// This module is already included in the project. We need to mark it as "selected" so when we are done searching we don't
// exclude it by accident.
selectedModules.add(moduleNode);
}
ModuleSearchResult result = containsSourceFile(moduleNode, file, selected);
if (result != null) {
// Even though the module is already included, we add it to the search results, because the module might not be the one that
// actually contains the file, and the user might need to exclude it in the case that the module that contains the file has
// the same path as the already-included module.
results.add(result);
}
}
int resultCount = results.size();
if (resultCount == 0) {
// Nothing found.
invokeLaterIfNeeded(new Runnable() {
@Override
public void run() {
String text = String.format("Unable to find a module containing the file '%1$s' in a source directory.", file.getName());
AndroidGradleNotification notification = AndroidGradleNotification.getInstance(project);
notification.showBalloon(MODULE_LOOKUP_MESSAGE_TITLE, text, ERROR);
}
});
}
else if (resultCount == 1) {
// If there is one result,just apply it.
addResultAndPopulateProject(results.get(0), selectedModules, file);
}
else {
// We need to let user decide which modules to include.
showModuleSelectionDialog(results, selectedModules, file);
}
}
}.queue();
}
}
/**
* Checks in the Android and Java models to see if the module contains the given file.
* @param moduleNode represents the module that is not included yet in the IDE.
* @param file the given file.
* @param selected indicates whether the module is included in the project.
* @return the result of the search, or {@code null} if this module does not contain the given file.
*/
@Nullable
private static ModuleSearchResult containsSourceFile(@NotNull DataNode<ModuleData> moduleNode, @NotNull File file, boolean selected) {
DataNode<IdeaAndroidProject> androidProjectNode = find(moduleNode, IDE_ANDROID_PROJECT);
if (androidProjectNode != null) {
IdeaAndroidProject androidModel = androidProjectNode.getData();
SourceFileContainerInfo result = androidModel.containsSourceFile(file);
if (result != null) {
return new ModuleSearchResult(moduleNode, result, selected);
}
}
DataNode<IdeaJavaProject> javaProjectNode = find(moduleNode, IDE_JAVA_PROJECT);
if (javaProjectNode != null) {
IdeaJavaProject javaProject = javaProjectNode.getData();
if (javaProject.containsSourceFile(file)) {
return new ModuleSearchResult(moduleNode, null, selected);
}
}
return null;
}
/**
* Adds the module in the given search results to the IDE. If the search result indicates the variant where the file is, this method
* will select such variant in the Android model.
* @param result the search result.
* @param selectedModules all the modules to be included in the project.
* @param file the file to include in the project.
*/
private void addResultAndPopulateProject(@NotNull ModuleSearchResult result,
@NotNull List<DataNode<ModuleData>> selectedModules,
@NotNull File file) {
DataNode<ModuleData> moduleNode = result.moduleNode;
String moduleName = getNameOf(moduleNode);
final String text;
if (result.selected) {
String tmp = String.format("File '%1$s' is already in module '%2$s'", file.getName(), moduleName);
SourceFileContainerInfo containerInfo = result.containerInfo;
if (containerInfo != null) {
containerInfo.updateSelectedVariantIn(moduleNode);
Variant variant = containerInfo.variant;
if (variant != null) {
tmp += String.format(", variant '%1$s'", variant.getName());
}
}
text = tmp;
}
else {
text = String.format("Module '%1$s' was added to the project.", moduleName);
SourceFileContainerInfo containerInfo = result.containerInfo;
if (containerInfo != null) {
containerInfo.updateSelectedVariantIn(moduleNode);
}
selectedModules.add(moduleNode);
setSelection(selectedModules);
}
invokeLaterIfNeeded(new Runnable() {
@Override
public void run() {
AndroidGradleNotification notification = AndroidGradleNotification.getInstance(myProject);
notification.showBalloon(MODULE_LOOKUP_MESSAGE_TITLE, text, INFORMATION);
}
});
populate(myProject, selectedModules);
}
/**
* Displays the "Select Modules" dialog. This method is invoked when the search for a module containing a file returns more than one
* result. The user now needs to select the module(s) to include.
* @param searchResults includes the modules that might contain the given file.
* @param selection all the modules that need to be included in the project.
* @param file the file to include in the project.
*/
private void showModuleSelectionDialog(@NotNull List<ModuleSearchResult> searchResults,
final @NotNull List<DataNode<ModuleData>> selection,
final @NotNull File file) {
final List<DataNode<ModuleData>> finalSelection = Lists.newArrayList(selection);
final List<DataNode<ModuleData>> modulesToDisplayInDialog = Lists.newArrayList();
final Map<String, ModuleSearchResult> resultsByModuleName = Maps.newHashMap();
for (ModuleSearchResult result : searchResults) {
DataNode<ModuleData> module = result.moduleNode;
modulesToDisplayInDialog.add(module);
if (result.selected) {
finalSelection.remove(module);
}
String moduleName = getNameOf(module);
resultsByModuleName.put(moduleName, result);
}
invokeLaterIfNeeded(new Runnable() {
@Override
public void run() {
ModulesToImportDialog dialog = new ModulesToImportDialog(modulesToDisplayInDialog, myProject);
String description = String.format("The file '%1$s' may be include in one of the following modules.", file.getName());
dialog.setDescription(description);
dialog.clearSelection();
if (dialog.showAndGet()) {
Collection<DataNode<ModuleData>> selectedModules = dialog.getSelectedModules();
if (!selectedModules.isEmpty()) {
for (DataNode<ModuleData> selectedModule : selectedModules) {
String name = getNameOf(selectedModule);
ModuleSearchResult result = resultsByModuleName.get(name);
if (result != null) {
SourceFileContainerInfo containerInfo = result.containerInfo;
if (containerInfo != null) {
containerInfo.updateSelectedVariantIn(selectedModule);
}
}
}
finalSelection.addAll(selectedModules);
setSelection(finalSelection);
populate(myProject, finalSelection);
}
}
}
});
}
public void findAndIncludeModules(@NotNull final Collection<String> moduleGradlePaths) {
final Collection<DataNode<ModuleData>> modules = getCachedModuleData();
if (modules != null && !modules.isEmpty()) {
final Project project = myProject;
new Task.Modal(project, "Finding Missing Modules", false) {
@Override
public void run(@NotNull ProgressIndicator indicator) {
String[] originalSelection = getSelection();
Set<String> selection = originalSelection != null ? Sets.newHashSet(originalSelection) : Sets.<String>newHashSet();
List<DataNode<ModuleData>> selectedModules = Lists.newArrayList();
boolean found = false;
int doneCount = 0;
for (DataNode<ModuleData> module : modules) {
indicator.setFraction(++doneCount / modules.size());
String name = getNameOf(module);
if (selection.contains(name)) {
selectedModules.add(module);
continue;
}
DataNode<IdeaGradleProject> gradleProjectNode = find(module, IDE_GRADLE_PROJECT);
if (gradleProjectNode != null) {
IdeaGradleProject gradleProject = gradleProjectNode.getData();
if (moduleGradlePaths.contains(gradleProject.getGradlePath())) {
selection.add(name);
selectedModules.add(module);
found = true;
}
}
}
if (!selectedModules.isEmpty() && found) {
setSelection(selectedModules);
populate(project, selectedModules);
}
}
}.queue();
}
}
@Nullable
public Collection<DataNode<ModuleData>> getCachedModuleData() {
DataNode<ProjectData> projectData = getCachedProjectData(myProject);
if (projectData != null) {
return findAll(projectData, MODULE);
}
return null;
}
@Nullable
public Collection<DataNode<ModuleData>> showModuleSelectionDialog(@NotNull Collection<DataNode<ModuleData>> modules) {
Set<String> noSelection = Collections.emptySet();
return showModuleSelectionDialog(modules, noSelection);
}
@Nullable
private Collection<DataNode<ModuleData>> showModuleSelectionDialog(@NotNull Collection<DataNode<ModuleData>> modules,
@NotNull Collection<String> selectedModuleNames) {
ModulesToImportDialog dialog = new ModulesToImportDialog(modules, myProject);
if (!selectedModuleNames.isEmpty()) {
dialog.updateSelection(selectedModuleNames);
}
if (dialog.showAndGet()) {
Collection<DataNode<ModuleData>> selectedModules = dialog.getSelectedModules();
// Store the name of the selected modules, so future 'project sync' invocations won't add unselected modules.
setSelection(selectedModules);
return selectedModules;
}
return null;
}
private void setSelection(@NotNull Collection<DataNode<ModuleData>> modules) {
List<String> moduleNames = Lists.newArrayListWithExpectedSize(modules.size());
for (DataNode<ModuleData> module : modules) {
moduleNames.add(getNameOf(module));
}
// Persist the selected modules between sessions.
updateSelection(moduleNames);
}
@NotNull
private static String getNameOf(@NotNull DataNode<ModuleData> module) {
return module.getData().getExternalName();
}
public void clearSelection() {
updateSelection(null);
}
private void updateSelection(@Nullable List<String> moduleNames) {
String[] values = moduleNames != null ? toStringArray(moduleNames) : null;
PropertiesComponent.getInstance(myProject).setValues(PROJECT_SUBSET_PROPERTY_NAME, values);
}
@Nullable
public String[] getSelection() {
return PropertiesComponent.getInstance(myProject).getValues(PROJECT_SUBSET_PROPERTY_NAME);
}
private static class ModuleSearchResult {
@NotNull public final DataNode<ModuleData> moduleNode;
@Nullable public final SourceFileContainerInfo containerInfo;
public final boolean selected;
ModuleSearchResult(@NotNull DataNode<ModuleData> moduleNode, @Nullable SourceFileContainerInfo containerInfo, boolean selected) {
this.moduleNode = moduleNode;
this.containerInfo = containerInfo;
this.selected = selected;
}
}
}