Simplify the EGit UI Activator by using OSGi declarative services

The EGit UI Activator contained a lot of code to deal with external
modifications to git repositories. Refactor this into more manageable
components created dynamically by the framework.

First, the FocusHandler: this maintains a flag whether the application
is active, i.e., whether any of its Shells is the current active shell.
This needs the workbench to have been created, and the FocusHandler has
acquired over time some completely unrelated code that just also
happened to need a workbench. To ensure that it initialized itself only
once the workbench was ready, the FocusHandler scheduled a job that
re-scheduled periodically until the workbench was up.

Split this: move the unrelated code (setting icons for EGit core jobs)
to the newly introduced StartEventListener. Move maintaining the
"application active" state to ApplicationActiveListener, a dedicated
OSGi component triggered on the APP_STARTUP_COMPLETE event. At that
point, we are guaranteed that the workbench is ready, so no job is
needed. The ApplicationActiveListener fires a custom event
APPLICATION_ACTIVE whenever the active state changes; the data of the
event is a boolean indicating the new state (true if active, false
otherwise). Make sure an initial event is fired. The event is posted
asynchronously.

Move all the repository scanning and project refreshing code to a new
component ExternalRepositoryScanner, which in turn listens on that new
APPLICATION_ACTIVE event. The framework will create this component
automatically when the first such event is fired; component creation
will be off the UI thread since the event is posted asynchronously.
Schedule a run whenever the state changes from "inactive" to "active"
to resume scanning.

Unregister the DebugOptionsListener on Activator.stop().

Change-Id: I296d5801d62a6f2c0c8da4166aa36270c1b2ad1b
Signed-off-by: Thomas Wolf <thomas.wolf@paranor.ch>
diff --git a/org.eclipse.egit.ui.test/src/org/eclipse/egit/ui/common/LocalRepositoryTestCase.java b/org.eclipse.egit.ui.test/src/org/eclipse/egit/ui/common/LocalRepositoryTestCase.java
index 57d01a5..05f8367 100644
--- a/org.eclipse.egit.ui.test/src/org/eclipse/egit/ui/common/LocalRepositoryTestCase.java
+++ b/org.eclipse.egit.ui.test/src/org/eclipse/egit/ui/common/LocalRepositoryTestCase.java
@@ -60,6 +60,7 @@
 import org.eclipse.egit.ui.test.Eclipse;
 import org.eclipse.egit.ui.test.TestUtil;
 import org.eclipse.jface.dialogs.IDialogConstants;
+import org.eclipse.jface.preference.IPreferenceStore;
 import org.eclipse.jgit.junit.MockSystemReader;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ConfigConstants;
@@ -262,15 +263,14 @@
 		corePrefs.putBoolean(
 				GitCorePreferences.core_autoIgnoreDerivedResources, false);
 		corePrefs.putBoolean(GitCorePreferences.core_autoShareProjects, false);
+		IPreferenceStore uiPrefs = org.eclipse.egit.ui.Activator.getDefault()
+				.getPreferenceStore();
 		// suppress the configuration dialog
-		org.eclipse.egit.ui.Activator.getDefault().getPreferenceStore()
-				.setValue(UIPreferences.SHOW_INITIAL_CONFIG_DIALOG, false);
+		uiPrefs.setValue(UIPreferences.SHOW_INITIAL_CONFIG_DIALOG, false);
 		// suppress the detached head warning dialog
-		org.eclipse.egit.ui.Activator
-				.getDefault()
-				.getPreferenceStore()
-				.setValue(UIPreferences.SHOW_DETACHED_HEAD_WARNING,
-						false);
+		uiPrefs.setValue(UIPreferences.SHOW_DETACHED_HEAD_WARNING, false);
+		// suppress checking for external changes to git repositories
+		uiPrefs.setValue(UIPreferences.REFRESH_INDEX_INTERVAL, 0);
 		closeGitViews();
 	}
 
@@ -280,9 +280,8 @@
 		if (tempDir.toString().startsWith("/home") && tempDir.exists()) {
 			// see bug 440182: if test has left opened file streams on NFS
 			// mounted directories "delete" will fail because the directory
-			// would contain "stolen NFS file handles"
-			// (something like .nfs* files)
-			// so the "first round" of delete can ignore failures.
+			// would contain "stolen NFS file handles" (something like .nfs*
+			// files) so the "first round" of delete can ignore failures.
 			FileUtils.delete(tempDir, FileUtils.IGNORE_ERRORS
 					| FileUtils.RECURSIVE | FileUtils.RETRY);
 		}
diff --git a/org.eclipse.egit.ui/META-INF/MANIFEST.MF b/org.eclipse.egit.ui/META-INF/MANIFEST.MF
index f641392..4c5f91f 100644
--- a/org.eclipse.egit.ui/META-INF/MANIFEST.MF
+++ b/org.eclipse.egit.ui/META-INF/MANIFEST.MF
@@ -31,6 +31,7 @@
  org.eclipse.ui.views;bundle-version="[3.8.100,4.0.0)",
  org.eclipse.osgi.services;bundle-version="[3.5.100,4.0.0)",
  org.eclipse.e4.core.contexts;bundle-version="[1.5.1,2.0.0)",
+ org.eclipse.e4.core.services;bundle-version="[2.0.0,3.0.0)",
  org.eclipse.e4.ui.workbench;bundle-version="[1.4.0,2.0.0)",
  org.eclipse.ui.workbench;bundle-version="[3.108.0,4.0.0)"
 Bundle-ActivationPolicy: lazy
@@ -130,4 +131,6 @@
  org.eclipse.egit.ui.internal.trace;version="5.11.0";x-internal:=true,
  org.eclipse.egit.ui.internal.variables;version="5.11.0";x-internal:=true
 Service-Component: OSGI-INF/org.eclipse.egit.ui.internal.clone.GitCloneDropAdapter.xml,
- OSGI-INF/org.eclipse.egit.ui.internal.StartEventListener.xml
+ OSGI-INF/org.eclipse.egit.ui.internal.StartEventListener.xml,
+ OSGI-INF/org.eclipse.egit.ui.internal.ApplicationActiveListener.xml,
+ OSGI-INF/org.eclipse.egit.ui.internal.ExternalRepositoryScanner.xml
diff --git a/org.eclipse.egit.ui/OSGI-INF/org.eclipse.egit.ui.internal.ApplicationActiveListener.xml b/org.eclipse.egit.ui/OSGI-INF/org.eclipse.egit.ui.internal.ApplicationActiveListener.xml
new file mode 100644
index 0000000..458e133
--- /dev/null
+++ b/org.eclipse.egit.ui/OSGI-INF/org.eclipse.egit.ui.internal.ApplicationActiveListener.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<scr:component xmlns:scr="http://www.osgi.org/xmlns/scr/v1.1.0" deactivate="shutDown" name="org.eclipse.egit.ui.internal.ApplicationActiveListener">
+   <property name="event.topics">org/eclipse/e4/ui/LifeCycle/appStartupComplete
+org/eclipse/e4/ui/LifeCycle/appShutdownStarted
+   </property>
+   <service>
+      <provide interface="org.osgi.service.event.EventHandler"/>
+   </service>
+   <implementation class="org.eclipse.egit.ui.internal.ApplicationActiveListener"/>
+</scr:component>
\ No newline at end of file
diff --git a/org.eclipse.egit.ui/OSGI-INF/org.eclipse.egit.ui.internal.ExternalRepositoryScanner.xml b/org.eclipse.egit.ui/OSGI-INF/org.eclipse.egit.ui.internal.ExternalRepositoryScanner.xml
new file mode 100644
index 0000000..f51440d
--- /dev/null
+++ b/org.eclipse.egit.ui/OSGI-INF/org.eclipse.egit.ui.internal.ExternalRepositoryScanner.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<scr:component xmlns:scr="http://www.osgi.org/xmlns/scr/v1.1.0" activate="startUp" deactivate="shutDown" name="org.eclipse.egit.ui.internal.ExternalRepositoryScanner">
+   <property name="event.topics" value="org/eclipse/egit/ui/APPLICATION_ACTIVE"/>
+   <service>
+      <provide interface="org.osgi.service.event.EventHandler"/>
+   </service>
+   <implementation class="org.eclipse.egit.ui.internal.ExternalRepositoryScanner"/>
+</scr:component>
\ No newline at end of file
diff --git a/org.eclipse.egit.ui/src/org/eclipse/egit/ui/Activator.java b/org.eclipse.egit.ui/src/org/eclipse/egit/ui/Activator.java
index e07872b..e0cd8fe 100755
--- a/org.eclipse.egit.ui/src/org/eclipse/egit/ui/Activator.java
+++ b/org.eclipse.egit.ui/src/org/eclipse/egit/ui/Activator.java
@@ -16,39 +16,20 @@
  *******************************************************************************/
 package org.eclipse.egit.ui;
 
-import java.io.File;
-import java.io.IOException;
 import java.lang.reflect.InvocationTargetException;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Dictionary;
-import java.util.HashSet;
 import java.util.Hashtable;
 import java.util.Iterator;
-import java.util.LinkedHashMap;
 import java.util.List;
-import java.util.Map;
-import java.util.Set;
 
-import org.eclipse.core.resources.IProject;
-import org.eclipse.core.runtime.CoreException;
 import org.eclipse.core.runtime.IProgressMonitor;
 import org.eclipse.core.runtime.IStatus;
-import org.eclipse.core.runtime.OperationCanceledException;
 import org.eclipse.core.runtime.Platform;
 import org.eclipse.core.runtime.Status;
-import org.eclipse.core.runtime.SubMonitor;
-import org.eclipse.core.runtime.jobs.Job;
-import org.eclipse.egit.core.JobFamilies;
-import org.eclipse.egit.core.RepositoryCache;
 import org.eclipse.egit.core.RepositoryUtil;
-import org.eclipse.egit.core.internal.ResourceRefreshHandler;
-import org.eclipse.egit.core.internal.job.RuleUtil;
-import org.eclipse.egit.core.project.RepositoryMapping;
 import org.eclipse.egit.ui.internal.ConfigurationChecker;
 import org.eclipse.egit.ui.internal.KnownHosts;
-import org.eclipse.egit.ui.internal.RepositoryCacheRule;
-import org.eclipse.egit.ui.internal.UIIcons;
 import org.eclipse.egit.ui.internal.UIText;
 import org.eclipse.egit.ui.internal.credentials.EGitCredentialsProvider;
 import org.eclipse.egit.ui.internal.trace.GitTraceLocation;
@@ -61,27 +42,18 @@
 import org.eclipse.jface.text.templates.TemplateContextType;
 import org.eclipse.jface.util.IPropertyChangeListener;
 import org.eclipse.jface.util.PropertyChangeEvent;
-import org.eclipse.jgit.events.IndexChangedListener;
-import org.eclipse.jgit.events.ListenerHandle;
-import org.eclipse.jgit.events.WorkingTreeModifiedEvent;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.CredentialsProvider;
-import org.eclipse.jgit.treewalk.FileTreeIterator;
-import org.eclipse.jgit.treewalk.TreeWalk;
-import org.eclipse.jgit.treewalk.filter.PathFilterGroup;
 import org.eclipse.osgi.service.debug.DebugOptions;
 import org.eclipse.osgi.service.debug.DebugOptionsListener;
 import org.eclipse.swt.graphics.Font;
 import org.eclipse.swt.widgets.Display;
-import org.eclipse.ui.IWindowListener;
-import org.eclipse.ui.IWorkbenchWindow;
 import org.eclipse.ui.PlatformUI;
 import org.eclipse.ui.plugin.AbstractUIPlugin;
-import org.eclipse.ui.progress.IProgressService;
 import org.eclipse.ui.progress.WorkbenchJob;
 import org.eclipse.ui.statushandlers.StatusManager;
 import org.eclipse.ui.themes.ITheme;
 import org.osgi.framework.BundleContext;
+import org.osgi.framework.ServiceRegistration;
 
 /**
  * This is a plugin singleton mostly controlling logging.
@@ -313,13 +285,10 @@
 	}
 
 	private ResourceManager resourceManager;
-	private RepositoryChangeScanner rcs;
 
-	private ResourceRefreshJob refreshJob;
 	private DebugOptions debugOptions;
 
-	private volatile boolean uiIsActive;
-	private IWindowListener focusListener;
+	private ServiceRegistration<?> serviceRegistration;
 
 	/**
 	 * Construct the {@link Activator} egit ui plugin singleton instance
@@ -340,11 +309,10 @@
 		Dictionary<String, String> props = new Hashtable<>(4);
 		props.put(DebugOptions.LISTENER_SYMBOLICNAME, context.getBundle()
 				.getSymbolicName());
-		context.registerService(DebugOptionsListener.class.getName(), this,
+		serviceRegistration = context.registerService(
+				DebugOptionsListener.class.getName(), this,
 				props);
 
-		setupRepoChangeScanner();
-		setupFocusHandling();
 		setupCredentialsProvider();
 		ConfigurationChecker.checkConfiguration();
 
@@ -391,101 +359,6 @@
 		job.schedule();
 	}
 
-	/**
-	 * @return true if at least one Eclipse window is active
-	 */
-	static boolean isActive() {
-		return getDefault().uiIsActive;
-	}
-
-
-	private void setupFocusHandling() {
-		focusListener = new IWindowListener() {
-
-			private void updateUiState() {
-				Display.getCurrent().asyncExec(new Runnable() {
-					@Override
-					public void run() {
-						boolean wasActive = uiIsActive;
-						uiIsActive = Display.getCurrent().getActiveShell() != null;
-						if (uiIsActive != wasActive
-								&& GitTraceLocation.REPOSITORYCHANGESCANNER
-										.isActive())
-							traceUiIsActive();
-					}
-
-					private void traceUiIsActive() {
-						StringBuilder message = new StringBuilder(
-								"workbench is "); //$NON-NLS-1$
-						message.append(uiIsActive ? "active" : "inactive"); //$NON-NLS-1$//$NON-NLS-2$
-						GitTraceLocation.getTrace().trace(
-								GitTraceLocation.REPOSITORYCHANGESCANNER
-										.getLocation(), message.toString());
-					}
-				});
-			}
-
-			@Override
-			public void windowOpened(IWorkbenchWindow window) {
-				updateUiState();
-			}
-
-			@Override
-			public void windowDeactivated(IWorkbenchWindow window) {
-				updateUiState();
-			}
-
-			@Override
-			public void windowClosed(IWorkbenchWindow window) {
-				updateUiState();
-			}
-
-			@Override
-			public void windowActivated(IWorkbenchWindow window) {
-				updateUiState();
-				// 500: give the UI task a chance to update the active state
-				rcs.schedule(500);
-			}
-		};
-		Job job = new Job(UIText.Activator_setupFocusListener) {
-			@Override
-			protected IStatus run(IProgressMonitor monitor) {
-				if (PlatformUI.isWorkbenchRunning()) {
-					PlatformUI.getWorkbench().addWindowListener(focusListener);
-					registerCoreJobFamilyIcons();
-				} else {
-					schedule(1000L);
-				}
-				return Status.OK_STATUS;
-			}
-
-			/**
-			 * register progress icons for jobs from core plugin
-			 */
-			private void registerCoreJobFamilyIcons() {
-				PlatformUI.getWorkbench().getDisplay()
-						.asyncExec(() -> {
-							IProgressService service = PlatformUI.getWorkbench()
-									.getProgressService();
-
-							service.registerIconForFamily(UIIcons.PULL,
-									JobFamilies.PULL);
-							service.registerIconForFamily(UIIcons.REPOSITORY,
-									JobFamilies.AUTO_IGNORE);
-							service.registerIconForFamily(UIIcons.REPOSITORY,
-									JobFamilies.AUTO_SHARE);
-							service.registerIconForFamily(UIIcons.REPOSITORY,
-									JobFamilies.INDEX_DIFF_CACHE_UPDATE);
-							service.registerIconForFamily(UIIcons.REPOSITORY,
-									JobFamilies.REPOSITORY_CHANGED);
-						});
-			}
-		};
-		job.setSystem(true);
-		job.setUser(false);
-		job.schedule();
-	}
-
 	@Override
 	public void optionsChanged(DebugOptions options) {
 		// initialize the trace stuff
@@ -533,397 +406,12 @@
 			listener.propertyChange(event);
 	}
 
-	/**
-	 * A Job that looks at the repository meta data and triggers a refresh of
-	 * the resources in the affected projects.
-	 */
-	private static class RepositoryChangeScanner extends Job
-			implements IPropertyChangeListener {
-
-		// volatile in order to ensure thread synchronization
-		private volatile boolean doReschedule;
-
-		private int interval;
-
-		private final ResourceRefreshJob refresher;
-
-		private final RepositoryCache repositoryCache;
-
-		private Collection<WorkingTreeModifiedEvent> events;
-
-		private final IndexChangedListener listener = event -> {
-			if (event.isInternal()) {
-				return;
-			}
-			Repository repository = event.getRepository();
-			if (repository.isBare()) {
-				return;
-			}
-			List<String> directories = new ArrayList<>();
-			for (IProject project : RuleUtil.getProjects(repository)) {
-				if (project.isAccessible()) {
-					RepositoryMapping mapping = RepositoryMapping
-							.getMapping(project);
-					if (mapping != null
-							&& repository == mapping.getRepository()) {
-						String repoRelativePath = mapping
-								.getRepoRelativePath(project);
-						if (repoRelativePath == null) {
-							continue;
-						}
-						if (GitTraceLocation.REPOSITORYCHANGESCANNER
-								.isActive()) {
-							GitTraceLocation.getTrace().trace(
-									GitTraceLocation.REPOSITORYCHANGESCANNER
-											.getLocation(),
-									"Scanning project " + project.getName()); //$NON-NLS-1$
-						}
-						try (TreeWalk w = new TreeWalk(repository)) {
-							w.addTree(new FileTreeIterator(repository));
-							if (!repoRelativePath.isEmpty()) {
-								w.setFilter(PathFilterGroup
-										.createFromStrings(repoRelativePath));
-							} else {
-								directories.add("/"); //$NON-NLS-1$
-							}
-							w.setRecursive(false);
-							while (w.next()) {
-								if (w.isSubtree()) {
-									FileTreeIterator iter = w.getTree(0,
-											FileTreeIterator.class);
-									if (iter != null
-											&& !iter.isEntryIgnored()) {
-										directories
-												.add(w.getPathString() + '/');
-										w.enterSubtree();
-									}
-								}
-							}
-						} catch (IOException e) {
-							// Ignore.
-						}
-						if (GitTraceLocation.REPOSITORYCHANGESCANNER
-								.isActive()) {
-							GitTraceLocation.getTrace().trace(
-									GitTraceLocation.REPOSITORYCHANGESCANNER
-											.getLocation(),
-									"Scanned project " + project.getName()); //$NON-NLS-1$
-						}
-					}
-				}
-			}
-			if (directories.isEmpty()) {
-				return;
-			}
-			WorkingTreeModifiedEvent evt = new WorkingTreeModifiedEvent(
-					directories, null);
-			evt.setRepository(repository);
-			events.add(evt);
-		};
-
-		public RepositoryChangeScanner(ResourceRefreshJob refresher) {
-			super(UIText.Activator_repoScanJobName);
-			this.refresher = refresher;
-			setRule(new RepositoryCacheRule());
-			setSystem(true);
-			setUser(false);
-			repositoryCache = org.eclipse.egit.core.Activator.getDefault()
-					.getRepositoryCache();
-			updateRefreshInterval();
-		}
-
-		@Override
-		public boolean shouldSchedule() {
-			return doReschedule;
-		}
-
-		@Override
-		public boolean shouldRun() {
-			return doReschedule;
-		}
-
-		public void setReschedule(boolean reschedule) {
-			doReschedule = reschedule;
-		}
-
-		@Override
-		protected IStatus run(IProgressMonitor monitor) {
-			// When people use Git from the command line a lot of changes
-			// may happen. Don't scan when inactive depending on the user's
-			// choice.
-			if (getDefault().getPreferenceStore()
-					.getBoolean(UIPreferences.REFRESH_ONLY_WHEN_ACTIVE)
-					&& !isActive()) {
-				monitor.done();
-				return Status.OK_STATUS;
-			}
-
-			Repository[] repos = repositoryCache.getAllRepositories();
-			if (repos.length == 0) {
-				return Status.OK_STATUS;
-			}
-
-			monitor.beginTask(UIText.Activator_scanningRepositories,
-					repos.length);
-			try {
-				events = new ArrayList<>();
-				for (Repository repo : repos) {
-					if (monitor.isCanceled()) {
-						break;
-					}
-					if (GitTraceLocation.REPOSITORYCHANGESCANNER.isActive()) {
-						GitTraceLocation.getTrace().trace(
-								GitTraceLocation.REPOSITORYCHANGESCANNER
-										.getLocation(),
-								"Scanning " + repo + " for changes"); //$NON-NLS-1$ //$NON-NLS-2$
-					}
-
-					if (!repo.isBare()) {
-						// Set up index change listener for the repo and tear it
-						// down afterwards
-						ListenerHandle handle = null;
-						try {
-							handle = repo.getListenerList()
-									.addIndexChangedListener(listener);
-							repo.scanForRepoChanges();
-						} finally {
-							if (handle != null) {
-								handle.remove();
-							}
-						}
-					}
-					monitor.worked(1);
-				}
-				if (!monitor.isCanceled()) {
-					refresher.trigger(events);
-				}
-				events.clear();
-			} catch (IOException e) {
-				if (GitTraceLocation.REPOSITORYCHANGESCANNER.isActive()) {
-					GitTraceLocation.getTrace().trace(
-							GitTraceLocation.REPOSITORYCHANGESCANNER
-									.getLocation(),
-							"Stopped rescheduling " + getName() + " job"); //$NON-NLS-1$ //$NON-NLS-2$
-				}
-				return createErrorStatus(UIText.Activator_scanError, e);
-			} finally {
-				monitor.done();
-			}
-			if (GitTraceLocation.REPOSITORYCHANGESCANNER.isActive()) {
-				GitTraceLocation.getTrace().trace(
-						GitTraceLocation.REPOSITORYCHANGESCANNER.getLocation(),
-						"Rescheduling " + getName() + " job"); //$NON-NLS-1$ //$NON-NLS-2$
-			}
-			schedule(interval);
-			return Status.OK_STATUS;
-		}
-
-		@Override
-		public void propertyChange(PropertyChangeEvent event) {
-			if (!UIPreferences.REFRESH_INDEX_INTERVAL
-					.equals(event.getProperty())) {
-				return;
-			}
-			updateRefreshInterval();
-		}
-
-		private void updateRefreshInterval() {
-			interval = getRefreshIndexInterval();
-			setReschedule(interval > 0);
-			cancel();
-			schedule(interval);
-		}
-
-		/**
-		 * @return interval in milliseconds for automatic index check, 0 is if
-		 *         check should be disabled
-		 */
-		private static int getRefreshIndexInterval() {
-			return 1000 * getDefault().getPreferenceStore()
-					.getInt(UIPreferences.REFRESH_INDEX_INTERVAL);
-		}
-	}
-
-	/**
-	 * Refreshes parts of the workspace changed by JGit operations. This will
-	 * not refresh any git-ignored resources since those are not reported in the
-	 * {@link WorkingTreeModifiedEvent}.
-	 */
-	private static class ResourceRefreshJob extends Job {
-
-		public ResourceRefreshJob() {
-			super(UIText.Activator_refreshJobName);
-			setUser(false);
-			setSystem(true);
-		}
-
-		/**
-		 * Internal helper class to record batched accumulated results from
-		 * several {@link WorkingTreeModifiedEvent}s.
-		 */
-		private static class WorkingTreeChanges {
-
-			private final File workTree;
-
-			private final Set<String> modified;
-
-			private final Set<String> deleted;
-
-			public WorkingTreeChanges(WorkingTreeModifiedEvent event) {
-				workTree = event.getRepository().getWorkTree()
-						.getAbsoluteFile();
-				modified = new HashSet<>(event.getModified());
-				deleted = new HashSet<>(event.getDeleted());
-			}
-
-			public File getWorkTree() {
-				return workTree;
-			}
-
-			public Set<String> getModified() {
-				return modified;
-			}
-
-			public Set<String> getDeleted() {
-				return deleted;
-			}
-
-			public boolean isEmpty() {
-				return modified.isEmpty() && deleted.isEmpty();
-			}
-
-			public WorkingTreeChanges merge(WorkingTreeModifiedEvent event) {
-				modified.removeAll(event.getDeleted());
-				deleted.removeAll(event.getModified());
-				modified.addAll(event.getModified());
-				deleted.addAll(event.getDeleted());
-				return this;
-			}
-		}
-
-		private Map<File, WorkingTreeChanges> repositoriesChanged = new LinkedHashMap<>();
-
-		@Override
-		public IStatus run(IProgressMonitor monitor) {
-			try {
-				List<WorkingTreeChanges> changes;
-				synchronized (repositoriesChanged) {
-					if (repositoriesChanged.isEmpty()) {
-						return Status.OK_STATUS;
-					}
-					changes = new ArrayList<>(repositoriesChanged.values());
-					repositoriesChanged.clear();
-				}
-
-				SubMonitor progress = SubMonitor.convert(monitor,
-						changes.size());
-				try {
-					for (WorkingTreeChanges change : changes) {
-						if (progress.isCanceled()) {
-							return Status.CANCEL_STATUS;
-						}
-						ResourceRefreshHandler handler = new ResourceRefreshHandler();
-						handler.refreshRepository(new WorkingTreeModifiedEvent(
-								change.getModified(), change.getDeleted()),
-								change.getWorkTree(), progress.newChild(1));
-					}
-				} catch (OperationCanceledException oe) {
-					return Status.CANCEL_STATUS;
-				} catch (CoreException e) {
-					handleError(UIText.Activator_refreshFailed, e, false);
-					return new Status(IStatus.ERROR, getPluginId(),
-							e.getMessage());
-				}
-
-				if (!monitor.isCanceled()) {
-					// re-schedule if we got some changes in the meantime
-					synchronized (repositoriesChanged) {
-						if (!repositoriesChanged.isEmpty()) {
-							schedule(100);
-						}
-					}
-				}
-			} finally {
-				monitor.done();
-			}
-			return Status.OK_STATUS;
-		}
-
-		/**
-		 * Record which projects have changes. Initiate a resource refresh job
-		 * if the user settings allow it.
-		 *
-		 * @param events
-		 *            The {@link WorkingTreeModifiedEvent}s that triggered this
-		 *            refresh
-		 */
-		public void trigger(Collection<WorkingTreeModifiedEvent> events) {
-			boolean haveChanges = false;
-			for (WorkingTreeModifiedEvent event : events) {
-				if (event.isEmpty()) {
-					continue;
-				}
-				Repository repo = event.getRepository();
-				if (repo == null || repo.isBare()) {
-					continue; // Should never occur
-				}
-				File gitDir = repo.getDirectory();
-				synchronized (repositoriesChanged) {
-					WorkingTreeChanges changes = repositoriesChanged
-							.get(gitDir);
-					if (changes == null) {
-						repositoriesChanged.put(gitDir,
-								new WorkingTreeChanges(event));
-					} else {
-						changes.merge(event);
-						if (changes.isEmpty()) {
-							// Actually, this cannot happen.
-							repositoriesChanged.remove(gitDir);
-						}
-					}
-				}
-				haveChanges = true;
-			}
-			if (haveChanges) {
-				schedule();
-			}
-		}
-	}
-
-	private void setupRepoChangeScanner() {
-		refreshJob = new ResourceRefreshJob();
-		rcs = new RepositoryChangeScanner(refreshJob);
-		getPreferenceStore().addPropertyChangeListener(rcs);
-	}
-
 	@Override
 	public void stop(final BundleContext context) throws Exception {
-		if (focusListener != null) {
-			if (PlatformUI.isWorkbenchRunning()) {
-				PlatformUI.getWorkbench().removeWindowListener(focusListener);
-			}
-			focusListener = null;
+		if (serviceRegistration != null) {
+			serviceRegistration.unregister();
+			serviceRegistration = null;
 		}
-
-		if (GitTraceLocation.REPOSITORYCHANGESCANNER.isActive()) {
-			GitTraceLocation.getTrace().trace(
-					GitTraceLocation.REPOSITORYCHANGESCANNER.getLocation(),
-					"Trying to cancel " + rcs.getName() + " job"); //$NON-NLS-1$ //$NON-NLS-2$
-		}
-
-		getPreferenceStore().removePropertyChangeListener(rcs);
-		rcs.setReschedule(false);
-		rcs.cancel();
-		refreshJob.cancel();
-
-		rcs.join();
-		refreshJob.join();
-		if (GitTraceLocation.REPOSITORYCHANGESCANNER.isActive()) {
-			GitTraceLocation.getTrace().trace(
-					GitTraceLocation.REPOSITORYCHANGESCANNER.getLocation(),
-					"Jobs terminated"); //$NON-NLS-1$
-		}
-
 		if (resourceManager != null) {
 			resourceManager.dispose();
 			resourceManager = null;
@@ -937,6 +425,7 @@
 		KnownHosts.store();
 		super.saveDialogSettings();
 	}
+
 	/**
 	 * @return the {@link RepositoryUtil} instance
 	 */
diff --git a/org.eclipse.egit.ui/src/org/eclipse/egit/ui/internal/ApplicationActiveListener.java b/org.eclipse.egit.ui/src/org/eclipse/egit/ui/internal/ApplicationActiveListener.java
new file mode 100644
index 0000000..cb674be
--- /dev/null
+++ b/org.eclipse.egit.ui/src/org/eclipse/egit/ui/internal/ApplicationActiveListener.java
@@ -0,0 +1,132 @@
+/*******************************************************************************
+ * Copyright (c) 2021 Thomas Wolf <thomas.wolf@paranor.ch> and others.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License 2.0
+ * which accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *
+ * Contributors:
+ *    Thomas Wolf - factored out of Activator
+ *******************************************************************************/
+package org.eclipse.egit.ui.internal;
+
+import org.eclipse.e4.core.services.events.IEventBroker;
+import org.eclipse.e4.ui.workbench.UIEvents;
+import org.eclipse.swt.SWTException;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.ui.IWindowListener;
+import org.eclipse.ui.IWorkbenchWindow;
+import org.eclipse.ui.PlatformUI;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Deactivate;
+import org.osgi.service.event.Event;
+import org.osgi.service.event.EventConstants;
+import org.osgi.service.event.EventHandler;
+
+/**
+ * Determines whether any shell of the application is active and fires an event
+ * when the value changes.
+ */
+@Component(property = {
+		EventConstants.EVENT_TOPIC + '='
+				+ UIEvents.UILifeCycle.APP_STARTUP_COMPLETE,
+		EventConstants.EVENT_TOPIC + '='
+				+ UIEvents.UILifeCycle.APP_SHUTDOWN_STARTED })
+public class ApplicationActiveListener implements EventHandler {
+
+	/**
+	 * Event topic for the events posted by this component.
+	 */
+	public static final String TOPIC_APPLICATION_ACTIVE = "org/eclipse/egit/ui/APPLICATION_ACTIVE"; //$NON-NLS-1$
+
+	private volatile WindowTracker listener;
+
+	@Override
+	public void handleEvent(Event event) {
+		String topic = event.getTopic();
+		if (topic == null) {
+			return;
+		}
+		switch (topic) {
+		case UIEvents.UILifeCycle.APP_STARTUP_COMPLETE:
+			if (listener == null) {
+				listener = new WindowTracker();
+				listener.update();
+				PlatformUI.getWorkbench().addWindowListener(listener);
+			}
+			break;
+		case UIEvents.UILifeCycle.APP_SHUTDOWN_STARTED:
+			shutDown();
+			break;
+		default:
+			break;
+		}
+	}
+
+	@Deactivate
+	void shutDown() {
+		if (listener != null) {
+			PlatformUI.getWorkbench().removeWindowListener(listener);
+			listener = null;
+		}
+	}
+
+	private static class WindowTracker implements IWindowListener {
+
+		private boolean isActive;
+
+		void update() {
+			if (PlatformUI.isWorkbenchRunning()) {
+				Display display = PlatformUI.getWorkbench().getDisplay();
+				if (display != null && !display.isDisposed()) {
+					try {
+						display.asyncExec(() -> {
+							boolean wasActive = isActive;
+							isActive = !display.isDisposed()
+									&& display.getActiveShell() != null;
+							if (wasActive != isActive) {
+								notify(isActive);
+							}
+						});
+					} catch (SWTException e) {
+						// Silently ignore -- display was disposed already
+					}
+				}
+			}
+		}
+
+		private void notify(boolean active) {
+			if (PlatformUI.isWorkbenchRunning()) {
+				IEventBroker broker = PlatformUI.getWorkbench()
+						.getService(IEventBroker.class);
+				if (broker != null) {
+					broker.post(TOPIC_APPLICATION_ACTIVE,
+							Boolean.valueOf(active));
+				}
+			}
+		}
+
+		@Override
+		public void windowActivated(IWorkbenchWindow window) {
+			update();
+		}
+
+		@Override
+		public void windowDeactivated(IWorkbenchWindow window) {
+			update();
+		}
+
+		@Override
+		public void windowClosed(IWorkbenchWindow window) {
+			update();
+		}
+
+		@Override
+		public void windowOpened(IWorkbenchWindow window) {
+			update();
+		}
+	}
+}
diff --git a/org.eclipse.egit.ui/src/org/eclipse/egit/ui/internal/ExternalRepositoryScanner.java b/org.eclipse.egit.ui/src/org/eclipse/egit/ui/internal/ExternalRepositoryScanner.java
new file mode 100644
index 0000000..4bf3ddf
--- /dev/null
+++ b/org.eclipse.egit.ui/src/org/eclipse/egit/ui/internal/ExternalRepositoryScanner.java
@@ -0,0 +1,491 @@
+/*******************************************************************************
+ * Copyright (c) 2021 Thomas Wolf <thomas.wolf@paranor.ch> and others.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License 2.0
+ * which accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *
+ * Contributors:
+ *    Thomas Wolf - factored out of Activator
+ *******************************************************************************/
+package org.eclipse.egit.ui.internal;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.eclipse.core.resources.IProject;
+import org.eclipse.core.runtime.CoreException;
+import org.eclipse.core.runtime.IProgressMonitor;
+import org.eclipse.core.runtime.IStatus;
+import org.eclipse.core.runtime.OperationCanceledException;
+import org.eclipse.core.runtime.Status;
+import org.eclipse.core.runtime.SubMonitor;
+import org.eclipse.core.runtime.jobs.Job;
+import org.eclipse.e4.core.services.events.IEventBroker;
+import org.eclipse.egit.core.RepositoryCache;
+import org.eclipse.egit.core.internal.ResourceRefreshHandler;
+import org.eclipse.egit.core.internal.job.RuleUtil;
+import org.eclipse.egit.core.project.RepositoryMapping;
+import org.eclipse.egit.ui.Activator;
+import org.eclipse.egit.ui.UIPreferences;
+import org.eclipse.egit.ui.internal.trace.GitTraceLocation;
+import org.eclipse.jface.util.IPropertyChangeListener;
+import org.eclipse.jface.util.PropertyChangeEvent;
+import org.eclipse.jgit.events.IndexChangedListener;
+import org.eclipse.jgit.events.ListenerHandle;
+import org.eclipse.jgit.events.WorkingTreeModifiedEvent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.treewalk.FileTreeIterator;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.treewalk.filter.PathFilterGroup;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Deactivate;
+import org.osgi.service.event.Event;
+import org.osgi.service.event.EventConstants;
+import org.osgi.service.event.EventHandler;
+
+/**
+ * A component that scans for external changes made to git repositories.
+ * Depending on user preference setting, this scanning is done only when the
+ * workbench is active.
+ */
+@Component(property = EventConstants.EVENT_TOPIC + '='
+		+ ApplicationActiveListener.TOPIC_APPLICATION_ACTIVE)
+public class ExternalRepositoryScanner implements EventHandler {
+
+	private AtomicBoolean isActive = new AtomicBoolean();
+
+	private ResourceRefreshJob refreshJob;
+
+	private RepositoryChangeScanner scanner;
+
+	@Override
+	public void handleEvent(Event event) {
+		if (ApplicationActiveListener.TOPIC_APPLICATION_ACTIVE
+				.equals(event.getTopic())) {
+			Object value = event.getProperty(IEventBroker.DATA);
+			if (value instanceof Boolean) {
+				boolean newValue = ((Boolean) value).booleanValue();
+				if (isActive.compareAndSet(!newValue, newValue) && newValue) {
+					scanner.schedule();
+				}
+			}
+		}
+	}
+
+	@Activate
+	void startUp() {
+		refreshJob = new ResourceRefreshJob();
+		scanner = new RepositoryChangeScanner(refreshJob, isActive);
+		Activator.getDefault().getPreferenceStore()
+				.addPropertyChangeListener(scanner);
+	}
+
+	@Deactivate
+	void shutDown() {
+		if (GitTraceLocation.REPOSITORYCHANGESCANNER.isActive()) {
+			GitTraceLocation.getTrace().trace(
+					GitTraceLocation.REPOSITORYCHANGESCANNER.getLocation(),
+					"Trying to cancel " + scanner.getName() + " job"); //$NON-NLS-1$ //$NON-NLS-2$
+		}
+
+		Activator.getDefault().getPreferenceStore()
+				.removePropertyChangeListener(scanner);
+		scanner.setReschedule(false);
+		scanner.cancel();
+		refreshJob.cancel();
+
+		try {
+			scanner.join();
+			refreshJob.join();
+			if (GitTraceLocation.REPOSITORYCHANGESCANNER.isActive()) {
+				GitTraceLocation.getTrace().trace(
+						GitTraceLocation.REPOSITORYCHANGESCANNER.getLocation(),
+						"Jobs terminated"); //$NON-NLS-1$
+			}
+		} catch (InterruptedException e) {
+			if (GitTraceLocation.REPOSITORYCHANGESCANNER.isActive()) {
+				GitTraceLocation.getTrace().trace(
+						GitTraceLocation.REPOSITORYCHANGESCANNER.getLocation(),
+						"Jobs termination interrupted"); //$NON-NLS-1$
+			}
+			Thread.currentThread().interrupt();
+		}
+	}
+
+	/**
+	 * A Job that looks at the repository meta data and triggers a refresh of
+	 * the resources in the affected projects.
+	 */
+	private static class RepositoryChangeScanner extends Job
+			implements IPropertyChangeListener {
+
+		// volatile in order to ensure thread synchronization
+		private volatile boolean doReschedule;
+
+		private volatile int interval;
+
+		private final ResourceRefreshJob refresher;
+
+		private final AtomicBoolean workbenchActive;
+
+		private final RepositoryCache repositoryCache;
+
+		private Collection<WorkingTreeModifiedEvent> events;
+
+		private final IndexChangedListener listener = event -> {
+			if (event.isInternal()) {
+				return;
+			}
+			Repository repository = event.getRepository();
+			if (repository.isBare()) {
+				return;
+			}
+			List<String> directories = new ArrayList<>();
+			for (IProject project : RuleUtil.getProjects(repository)) {
+				if (project.isAccessible()) {
+					RepositoryMapping mapping = RepositoryMapping
+							.getMapping(project);
+					if (mapping != null
+							&& repository == mapping.getRepository()) {
+						String repoRelativePath = mapping
+								.getRepoRelativePath(project);
+						if (repoRelativePath == null) {
+							continue;
+						}
+						if (GitTraceLocation.REPOSITORYCHANGESCANNER
+								.isActive()) {
+							GitTraceLocation.getTrace().trace(
+									GitTraceLocation.REPOSITORYCHANGESCANNER
+											.getLocation(),
+									"Scanning project " + project.getName()); //$NON-NLS-1$
+						}
+						try (TreeWalk w = new TreeWalk(repository)) {
+							w.addTree(new FileTreeIterator(repository));
+							if (!repoRelativePath.isEmpty()) {
+								w.setFilter(PathFilterGroup
+										.createFromStrings(repoRelativePath));
+							} else {
+								directories.add("/"); //$NON-NLS-1$
+							}
+							w.setRecursive(false);
+							while (w.next()) {
+								if (w.isSubtree()) {
+									FileTreeIterator iter = w.getTree(0,
+											FileTreeIterator.class);
+									if (iter != null
+											&& !iter.isEntryIgnored()) {
+										directories
+												.add(w.getPathString() + '/');
+										w.enterSubtree();
+									}
+								}
+							}
+						} catch (IOException e) {
+							// Ignore.
+						}
+						if (GitTraceLocation.REPOSITORYCHANGESCANNER
+								.isActive()) {
+							GitTraceLocation.getTrace().trace(
+									GitTraceLocation.REPOSITORYCHANGESCANNER
+											.getLocation(),
+									"Scanned project " + project.getName()); //$NON-NLS-1$
+						}
+					}
+				}
+			}
+			if (directories.isEmpty()) {
+				return;
+			}
+			WorkingTreeModifiedEvent evt = new WorkingTreeModifiedEvent(
+					directories, null);
+			evt.setRepository(repository);
+			events.add(evt);
+		};
+
+		public RepositoryChangeScanner(ResourceRefreshJob refresher,
+				AtomicBoolean workbenchActive) {
+			super(UIText.Activator_repoScanJobName);
+			this.refresher = refresher;
+			this.workbenchActive = workbenchActive;
+			setRule(new RepositoryCacheRule());
+			setSystem(true);
+			setUser(false);
+			repositoryCache = org.eclipse.egit.core.Activator.getDefault()
+					.getRepositoryCache();
+			updateRefreshInterval();
+		}
+
+		@Override
+		public boolean shouldSchedule() {
+			return doReschedule;
+		}
+
+		@Override
+		public boolean shouldRun() {
+			return doReschedule;
+		}
+
+		public void setReschedule(boolean reschedule) {
+			doReschedule = reschedule;
+		}
+
+		@Override
+		protected IStatus run(IProgressMonitor monitor) {
+			// When people use Git from the command line a lot of changes
+			// may happen. Don't scan when inactive depending on the user's
+			// choice.
+			if (Activator.getDefault().getPreferenceStore()
+					.getBoolean(UIPreferences.REFRESH_ONLY_WHEN_ACTIVE)
+					&& !workbenchActive.get()) {
+				monitor.done();
+				return Status.OK_STATUS;
+			}
+
+			Repository[] repos = repositoryCache.getAllRepositories();
+			if (repos.length == 0) {
+				schedule(interval);
+				return Status.OK_STATUS;
+			}
+
+			monitor.beginTask(UIText.Activator_scanningRepositories,
+					repos.length);
+			try {
+				events = new ArrayList<>();
+				for (Repository repo : repos) {
+					if (monitor.isCanceled()) {
+						break;
+					}
+					if (GitTraceLocation.REPOSITORYCHANGESCANNER.isActive()) {
+						GitTraceLocation.getTrace()
+								.trace(GitTraceLocation.REPOSITORYCHANGESCANNER
+										.getLocation(),
+										"Scanning " + repo + " for changes"); //$NON-NLS-1$ //$NON-NLS-2$
+					}
+
+					if (!repo.isBare()) {
+						// Set up index change listener for the repo and tear it
+						// down afterwards
+						ListenerHandle handle = null;
+						try {
+							handle = repo.getListenerList()
+									.addIndexChangedListener(listener);
+							repo.scanForRepoChanges();
+						} finally {
+							if (handle != null) {
+								handle.remove();
+							}
+						}
+					}
+					monitor.worked(1);
+				}
+				if (!monitor.isCanceled()) {
+					refresher.trigger(events);
+				}
+				events.clear();
+			} catch (IOException e) {
+				if (GitTraceLocation.REPOSITORYCHANGESCANNER.isActive()) {
+					GitTraceLocation.getTrace().trace(
+							GitTraceLocation.REPOSITORYCHANGESCANNER
+									.getLocation(),
+							"Stopped rescheduling " + getName() + " job"); //$NON-NLS-1$ //$NON-NLS-2$
+				}
+				return Activator.createErrorStatus(UIText.Activator_scanError,
+						e);
+			} finally {
+				monitor.done();
+			}
+			if (GitTraceLocation.REPOSITORYCHANGESCANNER.isActive()) {
+				GitTraceLocation.getTrace().trace(
+						GitTraceLocation.REPOSITORYCHANGESCANNER.getLocation(),
+						"Rescheduling " + getName() + " job"); //$NON-NLS-1$ //$NON-NLS-2$
+			}
+			schedule(interval);
+			return Status.OK_STATUS;
+		}
+
+		@Override
+		public void propertyChange(PropertyChangeEvent event) {
+			if (!UIPreferences.REFRESH_INDEX_INTERVAL
+					.equals(event.getProperty())) {
+				return;
+			}
+			updateRefreshInterval();
+		}
+
+		private void updateRefreshInterval() {
+			interval = getRefreshIndexInterval();
+			setReschedule(interval > 0);
+			cancel();
+			schedule(interval);
+		}
+
+		/**
+		 * @return interval in milliseconds for automatic index check, 0 is if
+		 *         check should be disabled
+		 */
+		private static int getRefreshIndexInterval() {
+			return 1000 * Activator.getDefault().getPreferenceStore()
+					.getInt(UIPreferences.REFRESH_INDEX_INTERVAL);
+		}
+	}
+
+	/**
+	 * Refreshes parts of the workspace changed by JGit operations. This will
+	 * not refresh any git-ignored resources since those are not reported in the
+	 * {@link WorkingTreeModifiedEvent}.
+	 */
+	private static class ResourceRefreshJob extends Job {
+
+		public ResourceRefreshJob() {
+			super(UIText.Activator_refreshJobName);
+			setUser(false);
+			setSystem(true);
+		}
+
+		/**
+		 * Internal helper class to record batched accumulated results from
+		 * several {@link WorkingTreeModifiedEvent}s.
+		 */
+		private static class WorkingTreeChanges {
+
+			private final File workTree;
+
+			private final Set<String> modified;
+
+			private final Set<String> deleted;
+
+			public WorkingTreeChanges(WorkingTreeModifiedEvent event) {
+				workTree = event.getRepository().getWorkTree()
+						.getAbsoluteFile();
+				modified = new HashSet<>(event.getModified());
+				deleted = new HashSet<>(event.getDeleted());
+			}
+
+			public File getWorkTree() {
+				return workTree;
+			}
+
+			public Set<String> getModified() {
+				return modified;
+			}
+
+			public Set<String> getDeleted() {
+				return deleted;
+			}
+
+			public boolean isEmpty() {
+				return modified.isEmpty() && deleted.isEmpty();
+			}
+
+			public WorkingTreeChanges merge(WorkingTreeModifiedEvent event) {
+				modified.removeAll(event.getDeleted());
+				deleted.removeAll(event.getModified());
+				modified.addAll(event.getModified());
+				deleted.addAll(event.getDeleted());
+				return this;
+			}
+		}
+
+		private Map<File, WorkingTreeChanges> repositoriesChanged = new LinkedHashMap<>();
+
+		@Override
+		public IStatus run(IProgressMonitor monitor) {
+			try {
+				List<WorkingTreeChanges> changes;
+				synchronized (repositoriesChanged) {
+					if (repositoriesChanged.isEmpty()) {
+						return Status.OK_STATUS;
+					}
+					changes = new ArrayList<>(repositoriesChanged.values());
+					repositoriesChanged.clear();
+				}
+
+				SubMonitor progress = SubMonitor.convert(monitor,
+						changes.size());
+				try {
+					for (WorkingTreeChanges change : changes) {
+						if (progress.isCanceled()) {
+							return Status.CANCEL_STATUS;
+						}
+						ResourceRefreshHandler handler = new ResourceRefreshHandler();
+						handler.refreshRepository(new WorkingTreeModifiedEvent(
+								change.getModified(), change.getDeleted()),
+								change.getWorkTree(), progress.newChild(1));
+					}
+				} catch (OperationCanceledException oe) {
+					return Status.CANCEL_STATUS;
+				} catch (CoreException e) {
+					Activator.handleError(UIText.Activator_refreshFailed, e,
+							false);
+					return new Status(IStatus.ERROR, Activator.getPluginId(),
+							e.getMessage());
+				}
+
+				if (!monitor.isCanceled()) {
+					// re-schedule if we got some changes in the meantime
+					synchronized (repositoriesChanged) {
+						if (!repositoriesChanged.isEmpty()) {
+							schedule(100);
+						}
+					}
+				}
+			} finally {
+				monitor.done();
+			}
+			return Status.OK_STATUS;
+		}
+
+		/**
+		 * Record which projects have changes. Initiate a resource refresh job
+		 * if the user settings allow it.
+		 *
+		 * @param events
+		 *            The {@link WorkingTreeModifiedEvent}s that triggered this
+		 *            refresh
+		 */
+		public void trigger(Collection<WorkingTreeModifiedEvent> events) {
+			boolean haveChanges = false;
+			for (WorkingTreeModifiedEvent event : events) {
+				if (event.isEmpty()) {
+					continue;
+				}
+				Repository repo = event.getRepository();
+				if (repo == null || repo.isBare()) {
+					continue; // Should never occur
+				}
+				File gitDir = repo.getDirectory();
+				synchronized (repositoriesChanged) {
+					WorkingTreeChanges changes = repositoriesChanged
+							.get(gitDir);
+					if (changes == null) {
+						repositoriesChanged.put(gitDir,
+								new WorkingTreeChanges(event));
+					} else {
+						changes.merge(event);
+						if (changes.isEmpty()) {
+							// Actually, this cannot happen.
+							repositoriesChanged.remove(gitDir);
+						}
+					}
+				}
+				haveChanges = true;
+			}
+			if (haveChanges) {
+				schedule();
+			}
+		}
+	}
+}
diff --git a/org.eclipse.egit.ui/src/org/eclipse/egit/ui/internal/StartEventListener.java b/org.eclipse.egit.ui/src/org/eclipse/egit/ui/internal/StartEventListener.java
index 3a7304d..278c91f 100644
--- a/org.eclipse.egit.ui/src/org/eclipse/egit/ui/internal/StartEventListener.java
+++ b/org.eclipse.egit.ui/src/org/eclipse/egit/ui/internal/StartEventListener.java
@@ -13,8 +13,10 @@
 import java.util.concurrent.atomic.AtomicBoolean;
 
 import org.eclipse.e4.ui.workbench.UIEvents;
+import org.eclipse.egit.core.JobFamilies;
 import org.eclipse.egit.ui.internal.selection.SelectionRepositoryStateCache;
 import org.eclipse.ui.PlatformUI;
+import org.eclipse.ui.progress.IProgressService;
 import org.osgi.service.component.annotations.Activate;
 import org.osgi.service.component.annotations.Component;
 import org.osgi.service.component.annotations.Deactivate;
@@ -35,6 +37,7 @@
 	private void startInternalComponents() {
 		if (started.compareAndSet(false, true)) {
 			SelectionRepositoryStateCache.INSTANCE.initialize();
+			registerCoreJobFamilyIcons();
 		}
 	}
 
@@ -61,4 +64,21 @@
 			SelectionRepositoryStateCache.INSTANCE.dispose();
 		}
 	}
+
+	private void registerCoreJobFamilyIcons() {
+		PlatformUI.getWorkbench().getDisplay().asyncExec(() -> {
+			IProgressService service = PlatformUI.getWorkbench()
+					.getProgressService();
+
+			service.registerIconForFamily(UIIcons.PULL, JobFamilies.PULL);
+			service.registerIconForFamily(UIIcons.REPOSITORY,
+					JobFamilies.AUTO_IGNORE);
+			service.registerIconForFamily(UIIcons.REPOSITORY,
+					JobFamilies.AUTO_SHARE);
+			service.registerIconForFamily(UIIcons.REPOSITORY,
+					JobFamilies.INDEX_DIFF_CACHE_UPDATE);
+			service.registerIconForFamily(UIIcons.REPOSITORY,
+					JobFamilies.REPOSITORY_CHANGED);
+		});
+	}
 }