/*
* The MIT License (MIT)
*
* Copyright (c) 2017 hsz Jakub Chrzanowski <jakub@hsz.mobi>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package mobi.hsz.idea.gitignore;
import com.intellij.AppTopics;
import com.intellij.dvcs.repo.Repository;
import com.intellij.ide.projectView.ProjectView;
import com.intellij.ide.startup.StartupManagerEx;
import com.intellij.openapi.application.AccessToken;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.components.AbstractProjectComponent;
import com.intellij.openapi.editor.Document;
import com.intellij.openapi.fileEditor.FileDocumentManager;
import com.intellij.openapi.fileEditor.FileDocumentManagerAdapter;
import com.intellij.openapi.progress.ProgressIndicator;
import com.intellij.openapi.project.DumbService;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.startup.StartupManager;
import com.intellij.openapi.vcs.ProjectLevelVcsManager;
import com.intellij.openapi.vcs.VcsListener;
import com.intellij.openapi.vfs.*;
import com.intellij.psi.*;
import com.intellij.psi.impl.PsiManagerImpl;
import com.intellij.psi.impl.file.impl.FileManager;
import com.intellij.psi.impl.file.impl.FileManagerImpl;
import com.intellij.psi.search.FileTypeIndex;
import com.intellij.psi.search.GlobalSearchScope;
import com.intellij.util.Alarm;
import com.intellij.util.ConcurrencyUtil;
import com.intellij.util.containers.ContainerUtil;
import com.intellij.util.containers.HashMap;
import com.intellij.util.io.storage.HeavyProcessLatch;
import com.intellij.util.messages.MessageBusConnection;
import com.intellij.util.messages.Topic;
import mobi.hsz.idea.gitignore.file.type.IgnoreFileType;
import mobi.hsz.idea.gitignore.lang.IgnoreLanguage;
import mobi.hsz.idea.gitignore.psi.IgnoreFile;
import mobi.hsz.idea.gitignore.settings.IgnoreSettings;
import mobi.hsz.idea.gitignore.util.CacheMap;
import mobi.hsz.idea.gitignore.util.RefreshProgress;
import mobi.hsz.idea.gitignore.util.Utils;
import org.jetbrains.annotations.NonNls;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Collection;
import java.util.ConcurrentModificationException;
import java.util.List;
import java.util.concurrent.ExecutorService;
import static mobi.hsz.idea.gitignore.settings.IgnoreSettings.KEY;
/**
* {@link IgnoreManager} handles ignore files indexing and status caching.
*
* @author Jakub Chrzanowski <jakub@hsz.mobi>
* @since 1.0
*/
public class IgnoreManager extends AbstractProjectComponent {
/** Delay between checking if psiManager was initialized. */
private static final int REQUEST_DELAY = 200;
/** Thread executor name. */
@NonNls
private static final String PROCESS_NAME = "Ignore indexing";
/** {@link CacheMap} instance. */
@NotNull
private final CacheMap cache;
/** {@link PsiManager} instance. */
@NotNull
private final PsiManagerImpl psiManager;
/** {@link VirtualFileManager} instance. */
@NotNull
private final VirtualFileManager virtualFileManager;
/** {@link IgnoreSettings} instance. */
@NotNull
private final IgnoreSettings settings;
/** {@link MessageBusConnection} instance. */
private MessageBusConnection messageBus;
/** {@link IgnoreManager} working flag. */
private boolean working;
/** {@link ExecutorService} thread queue. */
@NotNull
private final ExecutorService queue = ConcurrencyUtil.newSingleThreadExecutor(PROCESS_NAME);
/** {@link ProgressIndicator} instance. */
@NotNull
private final ProgressIndicator refreshIndicator = new RefreshProgress(IgnoreBundle.message("cache.indexing"));
/** {@link VirtualFileListener} instance to watch filesystem changes. */
@NotNull
private final VirtualFileListener virtualFileListener = new VirtualFileAdapter() {
/** Flag to obtain if file was {@link IgnoreFileType} before. */
private boolean wasIgnoreFileType;
/**
* Fired when a virtual file is renamed from within IDEA, or its writable status is changed.
* For files renamed externally, {@link #fileCreated} and {@link #fileDeleted} events will be fired.
*
* @param event the event object containing information about the change.
*/
@Override
public void propertyChanged(@NotNull VirtualFilePropertyEvent event) {
if (event.getPropertyName().equals("name")) {
boolean isIgnoreFileType = isIgnoreFileType(event);
if (isIgnoreFileType && !wasIgnoreFileType) {
addFile(event);
} else if (!isIgnoreFileType && wasIgnoreFileType) {
cache.cleanup(event.getFile());
}
}
}
/**
* Fired before the change of a name or writable status of a file is processed.
*
* @param event the event object containing information about the change.
*/
@Override
public void beforePropertyChange(@NotNull VirtualFilePropertyEvent event) {
wasIgnoreFileType = isIgnoreFileType(event);
}
/**
* Fired when a virtual file is created. This event is not fired for files discovered during initial
* VFS initialization.
*
* @param event the event object containing information about the change
*/
@Override
public void fileCreated(@NotNull VirtualFileEvent event) {
addFile(event);
}
/**
* Triggers {@link CacheMap#cleanup(VirtualFile)}.
*
* @param event current event
*/
@Override
public void fileDeleted(@NotNull VirtualFileEvent event) {
cache.cleanup(event.getFile());
}
/**
* Fired when a virtual file is copied from within IDEA.
*
* @param event the event object containing information about the change.
*/
@Override
public void fileCopied(@NotNull VirtualFileCopyEvent event) {
addFile(event);
}
/**
* Adds {@link IgnoreFile} to the {@link CacheMap}.
*
* @param event current event
*/
private void addFile(@NotNull VirtualFileEvent event) {
if (isIgnoreFileType(event)) {
IgnoreFile file = getIgnoreFile(event.getFile());
if (file != null) {
cache.add(file);
}
}
}
/**
* Checks if event was fired on the {@link IgnoreFileType} file.
*
* @param event current event
* @return event called on {@link IgnoreFileType}
*/
private boolean isIgnoreFileType(@NotNull VirtualFileEvent event) {
return event.getFile().getFileType() instanceof IgnoreFileType;
}
};
/** {@link PsiTreeChangeListener} instance to check if {@link IgnoreFile} content was changed. */
@NotNull
private final PsiTreeChangeListener psiTreeChangeListener = new PsiTreeChangeAdapter() {
@Override
public void childrenChanged(@NotNull PsiTreeChangeEvent event) {
if (event.getParent() instanceof IgnoreFile) {
IgnoreFile ignoreFile = (IgnoreFile) event.getParent();
if (((IgnoreLanguage) ignoreFile.getLanguage()).isEnabled()) {
cache.hasChanged(ignoreFile);
}
}
}
};
/** {@link IgnoreSettings} listener to watch changes in the plugin's settings. */
@NotNull
private final IgnoreSettings.Listener settingsListener = new IgnoreSettings.Listener() {
@Override
public void onChange(@NotNull KEY key, Object value) {
switch (key) {
case IGNORED_FILE_STATUS:
toggle((Boolean) value);
break;
case OUTER_IGNORE_RULES:
case LANGUAGES:
if (isEnabled()) {
if (working) {
cache.clear();
retrieve();
} else {
enable();
}
}
break;
case HIDE_IGNORED_FILES:
ProjectView.getInstance(myProject).refresh();
break;
}
}
};
/** {@link VcsListener} instance. */
@NotNull
private final VcsListener vcsListener = new VcsListener() {
private boolean initialized;
@Override
public void directoryMappingChanged() {
if (working && initialized) {
cache.clear();
retrieve();
}
initialized = true;
}
};
/** Document listener to trigger */
private DocumentSyncListener documentSyncListener = new DocumentSyncListener();
/**
* Returns {@link IgnoreManager} service instance.
*
* @param project current project
* @return {@link IgnoreManager instance}
*/
@NotNull
public static IgnoreManager getInstance(@NotNull final Project project) {
return project.getComponent(IgnoreManager.class);
}
/**
* Constructor builds {@link IgnoreManager} instance.
*
* @param project current project
*/
public IgnoreManager(@NotNull final Project project) {
super(project);
cache = new CacheMap(project);
psiManager = (PsiManagerImpl) PsiManager.getInstance(project);
virtualFileManager = VirtualFileManager.getInstance();
settings = IgnoreSettings.getInstance();
}
/**
* Helper for fetching {@link IgnoreFile} using {@link VirtualFile}.
*
* @param file current file
* @return {@link IgnoreFile}
*/
@Nullable
private IgnoreFile getIgnoreFile(@Nullable VirtualFile file) {
if (file == null || !file.exists()) {
return null;
}
PsiFile psiFile = psiManager.findFile(file);
if (psiFile == null || !(psiFile instanceof IgnoreFile)) {
return null;
}
return (IgnoreFile) psiFile;
}
/**
* Checks if file is ignored.
*
* @param file current file
* @return file is ignored
*/
public boolean isFileIgnored(@NotNull final VirtualFile file) {
return isEnabled() && cache.isFileIgnored(file);
}
/**
* Checks if file is ignored and tracked.
*
* @param file current file
* @return file is ignored and tracked
*/
public boolean isFileIgnoredAndTracked(@NotNull final VirtualFile file) {
return isEnabled() && cache.isFileTrackedIgnored(file);
}
/**
* Checks if file's parents are ignored.
*
* @param file current file
* @return file's parents are ignored
*/
public boolean isParentIgnored(@NotNull final VirtualFile file) {
if (!isEnabled()) {
return false;
}
VirtualFile parent = file.getParent();
while (parent != null && Utils.isInProject(parent, myProject)) {
if (isFileIgnored(parent)) {
return true;
}
parent = parent.getParent();
}
return false;
}
/**
* Checks if ignored files watching is enabled.
*
* @return enabled
*/
private boolean isEnabled() {
return settings.isIgnoredFileStatus();
}
/**
* Invoked when the project corresponding to this component instance is opened.<p>
* Note that components may be created for even unopened projects and this method can be never
* invoked for a particular component instance (for example for default project).
*/
@Override
public void projectOpened() {
if (isEnabled() && !working) {
enable();
}
}
/**
* Enable manager.
*/
private void enable() {
if (working) {
return;
}
virtualFileManager.addVirtualFileListener(virtualFileListener);
psiManager.addPsiTreeChangeListener(psiTreeChangeListener);
settings.addListener(settingsListener);
messageBus = myProject.getMessageBus().connect();
messageBus.subscribe(ProjectLevelVcsManager.VCS_CONFIGURATION_CHANGED, vcsListener);
messageBus.subscribe(AppTopics.FILE_DOCUMENT_SYNC, documentSyncListener);
working = true;
retrieve();
}
/**
* Invoked when the project corresponding to this component instance is closed.<p>
* Note that components may be created for even unopened projects and this method can be never
* invoked for a particular component instance (for example for default project).
*/
@Override
public void projectClosed() {
disable();
}
/**
* Disable manager.
*/
private void disable() {
virtualFileManager.removeVirtualFileListener(virtualFileListener);
psiManager.removePsiTreeChangeListener(psiTreeChangeListener);
settings.removeListener(settingsListener);
if (messageBus != null) {
messageBus.disconnect();
}
cache.clear();
working = false;
}
/**
* Runs {@link #enable()} or {@link #disable()} depending on the passed value.
*
* @param enable or disable
*/
private void toggle(@NotNull Boolean enable) {
if (enable) {
enable();
} else {
disable();
}
}
/** Triggers caching actions. */
private void retrieve() {
if (!Alarm.isEventDispatchThread()) {
return;
}
DumbService.getInstance(myProject).smartInvokeLater(new Runnable() {
@Override
public void run() {
queue.submit(new Runnable() {
@Override
public void run() {
refreshIndicator.start();
final AccessToken token = HeavyProcessLatch.INSTANCE.processStarted(PROCESS_NAME);
try {
FileManager fileManager = psiManager.getFileManager();
if (!(fileManager instanceof FileManagerImpl)) {
return;
}
final StartupManagerEx startupManager =
(StartupManagerEx) StartupManager.getInstance(myProject);
while (!startupManager.postStartupActivityPassed()) {
try {
Thread.sleep(REQUEST_DELAY);
} catch (InterruptedException ignored) {
}
}
// Search for Ignore files in the project
final GlobalSearchScope scope = GlobalSearchScope.allScope(myProject);
final List<IgnoreFile> files = ContainerUtil.newArrayList();
AccessToken readAccessToken = ApplicationManager.getApplication().acquireReadActionLock();
try {
for (final IgnoreLanguage language : IgnoreBundle.LANGUAGES) {
if (language.isEnabled()) {
try {
Collection<VirtualFile> virtualFiles = FileTypeIndex
.getFiles(language.getFileType(), scope);
for (VirtualFile virtualFile : virtualFiles) {
ContainerUtil.addIfNotNull(files, getIgnoreFile(virtualFile));
}
} catch (IndexOutOfBoundsException ignored) {
}
}
}
} finally {
readAccessToken.finish();
}
Utils.ignoreFilesSort(files);
addTasksFor(files);
// Search for outer files
if (settings.isOuterIgnoreRules()) {
readAccessToken = ApplicationManager.getApplication().acquireReadActionLock();
try {
for (IgnoreLanguage language : IgnoreBundle.LANGUAGES) {
if (!language.isEnabled()) {
continue;
}
for (VirtualFile outerFile : language.getOuterFiles(myProject)) {
if (outerFile.exists()) {
PsiFile psiFile = psiManager.findFile(outerFile);
if (psiFile != null) {
try {
IgnoreFile outerIgnoreFile = (IgnoreFile) PsiFileFactory
.getInstance(myProject)
.createFileFromText(
language.getFilename(),
language,
psiFile.getText()
);
outerIgnoreFile.setOriginalFile(psiFile);
addTaskFor(outerIgnoreFile);
} catch (ConcurrentModificationException ignored) {
}
}
}
}
}
} finally {
readAccessToken.finish();
}
}
} finally {
token.finish();
refreshIndicator.stop();
}
}
/**
* Adds {@link IgnoreFile} to the cache processor queue.
*
* @param files to cache
*/
private void addTasksFor(@NotNull final List<IgnoreFile> files) {
if (files.isEmpty()) {
return;
}
addTaskFor(files.remove(0), files);
}
/**
* Adds {@link IgnoreFile} to the cache processor queue.
*
* @param file to cache
*/
private void addTaskFor(@Nullable final IgnoreFile file) {
addTaskFor(file, null);
}
/**
* Adds {@link IgnoreFile} to the cache processor queue.
*
* @param file to cache
* @param dependentFiles files to cache if not ignored by given file
*/
private void addTaskFor(@Nullable final IgnoreFile file,
@Nullable final List<IgnoreFile> dependentFiles) {
if (file == null) {
return;
}
final VirtualFile virtualFile = file.getVirtualFile();
VirtualFile projectDir = myProject.getBaseDir();
if ((!file.isOuter() && (virtualFile == null || isFileIgnored(virtualFile))) ||
projectDir == null) {
return;
} else {
cache.add(file);
}
if (dependentFiles == null || dependentFiles.isEmpty()) {
return;
}
for (IgnoreFile dependentFile : dependentFiles) {
VirtualFile dependentVirtualFile = dependentFile.getVirtualFile();
if (dependentVirtualFile != null && !isFileIgnored(dependentVirtualFile) &&
!isParentIgnored(dependentVirtualFile)) {
addTaskFor(dependentFile);
}
}
}
});
}
});
}
/**
* Returns tracked and ignored files stored in {@link CacheMap#trackedIgnoredFiles}.
*
* @return tracked and ignored files map
*/
public HashMap<VirtualFile, Repository> getTrackedIgnoredFiles() {
return cache.getTrackedIgnoredFiles();
}
/**
* Listener bounded with {@link TrackedIgnoredListener#TRACKED_IGNORED} topic to inform
* about new entries.
*/
public interface TrackedIgnoredListener {
/** Topic for detected tracked and indexed files. */
Topic<TrackedIgnoredListener> TRACKED_IGNORED =
Topic.create("New tracked and indexed files detected", TrackedIgnoredListener.class);
void handleFiles(@NotNull HashMap<VirtualFile, Repository> files);
}
/**
* Listener bounded with {@link RefreshTrackedIgnoredListener#TRACKED_IGNORED_REFRESH} topic to
* trigger tracked and ignored files list.
*/
public interface RefreshTrackedIgnoredListener {
/** Topic for refresh tracked and indexed files. */
Topic<RefreshTrackedIgnoredListener> TRACKED_IGNORED_REFRESH =
Topic.create("New tracked and indexed files detected", RefreshTrackedIgnoredListener.class);
void refresh();
}
/**
* FileDocumentManagerListener implementation to trigger {@link CacheMap#refresh()}
* on every {@link IgnoreFileType} file save event.
*/
public class DocumentSyncListener extends FileDocumentManagerAdapter {
/** {@link FileDocumentManager} instance. */
private final FileDocumentManager manager;
/** Constructor. */
DocumentSyncListener() {
manager = FileDocumentManager.getInstance();
}
/**
* Checks if saved {@link Document} has type of {@link IgnoreFileType} and triggers
* {@link CacheMap#refresh()} method.
*
* @param document saved document
*/
@Override
public void beforeDocumentSaving(@NotNull Document document) {
final VirtualFile file = manager.getFile(document);
if (Utils.getFileType(file) != null && Utils.isInProject(file, myProject)) {
cache.refresh();
}
}
}
/**
* Unique name of this component. If there is another component with the same name or
* name is null internal assertion will occur.
*
* @return the name of this component
*/
@NonNls
@NotNull
@Override
public String getComponentName() {
return "IgnoreManager";
}
}