Bläddra i källkod

Feature: Event view (#3780)

Armin Schrenk 2 månader sedan
förälder
incheckning
6a26d95c15
24 ändrade filer med 1137 tillägg och 18 borttagningar
  1. 12 1
      src/main/java/org/cryptomator/common/vaults/Vault.java
  2. 8 0
      src/main/java/org/cryptomator/event/FSEventBucket.java
  3. 5 0
      src/main/java/org/cryptomator/event/FSEventBucketContent.java
  4. 107 0
      src/main/java/org/cryptomator/event/FileSystemEventAggregator.java
  5. 1 0
      src/main/java/org/cryptomator/ui/common/FxmlFile.java
  6. 3 0
      src/main/java/org/cryptomator/ui/controls/FontAwesome5Icon.java
  7. 329 0
      src/main/java/org/cryptomator/ui/eventview/EventListCellController.java
  8. 66 0
      src/main/java/org/cryptomator/ui/eventview/EventListCellFactory.java
  9. 35 0
      src/main/java/org/cryptomator/ui/eventview/EventViewComponent.java
  10. 132 0
      src/main/java/org/cryptomator/ui/eventview/EventViewController.java
  11. 67 0
      src/main/java/org/cryptomator/ui/eventview/EventViewModule.java
  12. 13 0
      src/main/java/org/cryptomator/ui/eventview/EventViewScoped.java
  13. 14 0
      src/main/java/org/cryptomator/ui/eventview/EventViewWindow.java
  14. 4 1
      src/main/java/org/cryptomator/ui/fxapp/FxApplication.java
  15. 12 1
      src/main/java/org/cryptomator/ui/fxapp/FxApplicationModule.java
  16. 9 1
      src/main/java/org/cryptomator/ui/fxapp/FxApplicationWindows.java
  17. 67 0
      src/main/java/org/cryptomator/ui/fxapp/FxFSEventList.java
  18. 16 2
      src/main/java/org/cryptomator/ui/mainwindow/VaultListController.java
  19. 50 0
      src/main/resources/css/dark_theme.css
  20. 52 2
      src/main/resources/css/light_theme.css
  21. 35 0
      src/main/resources/fxml/eventview.fxml
  22. 45 0
      src/main/resources/fxml/eventview_cell.fxml
  23. 26 9
      src/main/resources/fxml/vault_list.fxml
  24. 29 1
      src/main/resources/i18n/strings.properties

+ 12 - 1
src/main/java/org/cryptomator/common/vaults/Vault.java

@@ -10,6 +10,7 @@ package org.cryptomator.common.vaults;
 
 import org.apache.commons.lang3.SystemUtils;
 import org.cryptomator.common.Constants;
+import org.cryptomator.event.FileSystemEventAggregator;
 import org.cryptomator.common.mount.Mounter;
 import org.cryptomator.common.settings.Settings;
 import org.cryptomator.common.settings.VaultSettings;
@@ -18,6 +19,7 @@ import org.cryptomator.cryptofs.CryptoFileSystemProperties;
 import org.cryptomator.cryptofs.CryptoFileSystemProperties.FileSystemFlags;
 import org.cryptomator.cryptofs.CryptoFileSystemProvider;
 import org.cryptomator.cryptofs.common.FileSystemCapabilityChecker;
+import org.cryptomator.cryptofs.event.FilesystemEvent;
 import org.cryptomator.cryptolib.api.CryptoException;
 import org.cryptomator.cryptolib.api.MasterkeyLoader;
 import org.cryptomator.cryptolib.api.MasterkeyLoadingFailedException;
@@ -74,6 +76,7 @@ public class Vault {
 	private final ObjectBinding<Mountpoint> mountPoint;
 	private final Mounter mounter;
 	private final Settings settings;
+	private final FileSystemEventAggregator fileSystemEventAggregator;
 	private final BooleanProperty showingStats;
 
 	private final AtomicReference<Mounter.MountHandle> mountHandle = new AtomicReference<>(null);
@@ -85,7 +88,8 @@ public class Vault {
 		  VaultState state, //
 		  @Named("lastKnownException") ObjectProperty<Exception> lastKnownException, //
 		  VaultStats stats, //
-		  Mounter mounter, Settings settings) {
+		  Mounter mounter, Settings settings, //
+		  FileSystemEventAggregator fileSystemEventAggregator) {
 		this.vaultSettings = vaultSettings;
 		this.configCache = configCache;
 		this.cryptoFileSystem = cryptoFileSystem;
@@ -102,6 +106,7 @@ public class Vault {
 		this.mountPoint = Bindings.createObjectBinding(this::getMountPoint, state);
 		this.mounter = mounter;
 		this.settings = settings;
+		this.fileSystemEventAggregator = fileSystemEventAggregator;
 		this.showingStats = new SimpleBooleanProperty(false);
 		this.quickAccessEntry = new AtomicReference<>(null);
 	}
@@ -143,6 +148,7 @@ public class Vault {
 				.withFlags(flags) //
 				.withMaxCleartextNameLength(vaultSettings.maxCleartextFilenameLength.get()) //
 				.withVaultConfigFilename(Constants.VAULTCONFIG_FILENAME) //
+				.withFilesystemEventConsumer(this::consumeVaultEvent) //
 				.build();
 		return CryptoFileSystemProvider.newFileSystem(getPath(), fsProps);
 	}
@@ -251,6 +257,11 @@ public class Vault {
 		}
 	}
 
+
+	private void consumeVaultEvent(FilesystemEvent e) {
+		fileSystemEventAggregator.put(this, e);
+	}
+
 	// ******************************************************************************
 	// Observable Properties
 	// *******************************************************************************

+ 8 - 0
src/main/java/org/cryptomator/event/FSEventBucket.java

@@ -0,0 +1,8 @@
+package org.cryptomator.event;
+
+import org.cryptomator.common.vaults.Vault;
+import org.cryptomator.cryptofs.event.FilesystemEvent;
+
+import java.nio.file.Path;
+
+public record FSEventBucket(Vault vault, Path idPath, Class<? extends FilesystemEvent> c) {}

+ 5 - 0
src/main/java/org/cryptomator/event/FSEventBucketContent.java

@@ -0,0 +1,5 @@
+package org.cryptomator.event;
+
+import org.cryptomator.cryptofs.event.FilesystemEvent;
+
+public record FSEventBucketContent(FilesystemEvent mostRecentEvent, int count) {}

+ 107 - 0
src/main/java/org/cryptomator/event/FileSystemEventAggregator.java

@@ -0,0 +1,107 @@
+package org.cryptomator.event;
+
+import org.cryptomator.common.vaults.Vault;
+import org.cryptomator.cryptofs.event.BrokenDirFileEvent;
+import org.cryptomator.cryptofs.event.BrokenFileNodeEvent;
+import org.cryptomator.cryptofs.event.ConflictResolutionFailedEvent;
+import org.cryptomator.cryptofs.event.ConflictResolvedEvent;
+import org.cryptomator.cryptofs.event.DecryptionFailedEvent;
+import org.cryptomator.cryptofs.event.FilesystemEvent;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Aggregator for {@link FilesystemEvent}s.
+ * <p>
+ * The aggregator groups filesystem events by the vault where the event occurred, an identifying path (clear- or ciphertext) and the event class (aka type).
+ * A group is called an {@link FSEventBucket}, its {@link FSEventBucketContent} is the most recent event object and a count of how often the event already occurred.
+ */
+@Singleton
+public class FileSystemEventAggregator {
+
+	private final ConcurrentHashMap<FSEventBucket, FSEventBucketContent> map;
+	private final AtomicBoolean hasUpdates;
+
+	@Inject
+	public FileSystemEventAggregator() {
+		this.map = new ConcurrentHashMap<>();
+		this.hasUpdates = new AtomicBoolean(false);
+	}
+
+	/**
+	 * Adds the given event to the map. If a bucket for this event already exists, only the count is updated and the event set as the most recent one.
+	 *
+	 * @param v Vault where the event occurred
+	 * @param e Actual {@link FilesystemEvent}
+	 */
+	public void put(Vault v, FilesystemEvent e) {
+		var key = computeKey(v, e);
+		map.compute(key, (k, val) -> {
+			if (val == null) {
+				return new FSEventBucketContent(e, 1);
+			} else {
+				return new FSEventBucketContent(e, val.count() + 1);
+			}
+		});
+		hasUpdates.set(true);
+	}
+
+	/**
+	 * Removes an event bucket from the map.
+	 */
+	public FSEventBucketContent remove(FSEventBucket key) {
+		var content = map.remove(key);
+		hasUpdates.set(true);
+		return content;
+	}
+
+	/**
+	 * Clears the event map.
+	 */
+	public void clear() {
+		map.clear();
+		hasUpdates.set(true);
+	}
+
+
+	public boolean hasMaybeUpdates() {
+		return hasUpdates.get();
+	}
+
+	/**
+	 * Clones the map entries into a collection.
+	 * <p>
+	 * The collection is first cleared, then all map entries are added in one bulk operation. Cleans the hasUpdates status.
+	 *
+	 * @param target collection which is first cleared and then the EntrySet copied to.
+	 */
+	public void cloneTo(Collection<Map.Entry<FSEventBucket, FSEventBucketContent>> target) {
+		hasUpdates.set(false);
+		target.clear();
+		target.addAll(map.entrySet());
+	}
+
+	/**
+	 * Method to compute the identifying key for a given filesystem event
+	 *
+	 * @param v Vault where the event occurred
+	 * @param event Actual {@link FilesystemEvent}
+	 * @return a {@link FSEventBucket} used in the map and lru cache
+	 */
+	private static FSEventBucket computeKey(Vault v, FilesystemEvent event) {
+		var p = switch (event) {
+			case DecryptionFailedEvent(_, Path ciphertextPath, _) -> ciphertextPath;
+			case ConflictResolvedEvent(_, _, _, _, Path resolvedCiphertext) -> resolvedCiphertext;
+			case ConflictResolutionFailedEvent(_, _, Path conflictingCiphertext, _) -> conflictingCiphertext;
+			case BrokenDirFileEvent(_, Path ciphertext) -> ciphertext;
+			case BrokenFileNodeEvent(_, _, Path ciphertext) -> ciphertext;
+		};
+		return new FSEventBucket(v, p, event.getClass());
+	}
+}

+ 1 - 0
src/main/java/org/cryptomator/ui/common/FxmlFile.java

@@ -13,6 +13,7 @@ public enum FxmlFile {
 	CONVERTVAULT_HUBTOPASSWORD_CONVERT("/fxml/convertvault_hubtopassword_convert.fxml"), //
 	CONVERTVAULT_HUBTOPASSWORD_SUCCESS("/fxml/convertvault_hubtopassword_success.fxml"), //
 	ERROR("/fxml/error.fxml"), //
+	EVENT_VIEW("/fxml/eventview.fxml"), //
 	FORGET_PASSWORD("/fxml/forget_password.fxml"), //
 	HEALTH_START("/fxml/health_start.fxml"), //
 	HEALTH_CHECK_LIST("/fxml/health_check_list.fxml"), //

+ 3 - 0
src/main/java/org/cryptomator/ui/controls/FontAwesome5Icon.java

@@ -7,6 +7,7 @@ public enum FontAwesome5Icon {
 	ANCHOR("\uF13D"), //
 	ARROW_UP("\uF062"), //
 	BAN("\uF05E"), //
+	BELL("\uF0F3"), //
 	BUG("\uF188"), //
 	CARET_DOWN("\uF0D7"), //
 	CARET_RIGHT("\uF0Da"), //
@@ -15,10 +16,12 @@ public enum FontAwesome5Icon {
 	CLIPBOARD("\uF328"), //
 	COG("\uF013"), //
 	COGS("\uF085"), //
+	COMPRESS_ALT("\uF422"), //
 	COPY("\uF0C5"), //
 	CROWN("\uF521"), //
 	DONATE("\uF4B9"), //
 	EDIT("\uF044"), //
+	ELLIPSIS_V("\uF142"), //
 	EXCHANGE_ALT("\uF362"), //
 	EXCLAMATION("\uF12A"), //
 	EXCLAMATION_CIRCLE("\uF06A"), //

+ 329 - 0
src/main/java/org/cryptomator/ui/eventview/EventListCellController.java

@@ -0,0 +1,329 @@
+package org.cryptomator.ui.eventview;
+
+import org.cryptomator.event.FSEventBucket;
+import org.cryptomator.event.FSEventBucketContent;
+import org.cryptomator.event.FileSystemEventAggregator;
+import org.cryptomator.common.Nullable;
+import org.cryptomator.common.ObservableUtil;
+import org.cryptomator.cryptofs.CryptoPath;
+import org.cryptomator.cryptofs.event.BrokenDirFileEvent;
+import org.cryptomator.cryptofs.event.BrokenFileNodeEvent;
+import org.cryptomator.cryptofs.event.ConflictResolutionFailedEvent;
+import org.cryptomator.cryptofs.event.ConflictResolvedEvent;
+import org.cryptomator.cryptofs.event.DecryptionFailedEvent;
+import org.cryptomator.integrations.revealpath.RevealFailedException;
+import org.cryptomator.integrations.revealpath.RevealPathService;
+import org.cryptomator.ui.common.FxController;
+import org.cryptomator.ui.controls.FontAwesome5Icon;
+import org.jetbrains.annotations.NotNull;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.inject.Inject;
+import javafx.beans.binding.Bindings;
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleBooleanProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.beans.property.SimpleStringProperty;
+import javafx.beans.property.StringProperty;
+import javafx.beans.value.ObservableValue;
+import javafx.fxml.FXML;
+import javafx.geometry.Side;
+import javafx.scene.control.Button;
+import javafx.scene.control.ContextMenu;
+import javafx.scene.control.MenuItem;
+import javafx.scene.control.Tooltip;
+import javafx.scene.input.Clipboard;
+import javafx.scene.input.ClipboardContent;
+import javafx.scene.layout.HBox;
+import javafx.util.Duration;
+import java.nio.file.Path;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.FormatStyle;
+import java.util.Map;
+import java.util.Optional;
+import java.util.ResourceBundle;
+import java.util.function.Function;
+
+public class EventListCellController implements FxController {
+
+	private static final Logger LOG = LoggerFactory.getLogger(EventListCellController.class);
+	private static final DateTimeFormatter LOCAL_DATE_FORMATTER = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT).withZone(ZoneId.systemDefault());
+	private static final DateTimeFormatter LOCAL_TIME_FORMATTER = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withZone(ZoneId.systemDefault());
+
+	private final FileSystemEventAggregator fileSystemEventAggregator;
+	@Nullable
+	private final RevealPathService revealService;
+	private final ResourceBundle resourceBundle;
+	private final ObjectProperty<Map.Entry<FSEventBucket, FSEventBucketContent>> eventEntry;
+	private final StringProperty eventMessage;
+	private final StringProperty eventDescription;
+	private final ObjectProperty<FontAwesome5Icon> eventIcon;
+	private final ObservableValue<String> eventCount;
+	private final ObservableValue<Boolean> vaultUnlocked;
+	private final ObservableValue<String> readableTime;
+	private final ObservableValue<String> readableDate;
+	private final ObservableValue<String> message;
+	private final ObservableValue<String> description;
+	private final ObservableValue<FontAwesome5Icon> icon;
+	private final BooleanProperty actionsButtonVisible;
+	private final Tooltip eventTooltip;
+
+	@FXML
+	HBox root;
+	@FXML
+	ContextMenu eventActionsMenu;
+	@FXML
+	Button eventActionsButton;
+
+	@Inject
+	public EventListCellController(FileSystemEventAggregator fileSystemEventAggregator, Optional<RevealPathService> revealService, ResourceBundle resourceBundle) {
+		this.fileSystemEventAggregator = fileSystemEventAggregator;
+		this.revealService = revealService.orElseGet(() -> null);
+		this.resourceBundle = resourceBundle;
+		this.eventEntry = new SimpleObjectProperty<>(null);
+		this.eventMessage = new SimpleStringProperty();
+		this.eventDescription = new SimpleStringProperty();
+		this.eventIcon = new SimpleObjectProperty<>();
+		this.eventCount = ObservableUtil.mapWithDefault(eventEntry, e -> e.getValue().count() == 1? "" : "("+ e.getValue().count() +")", "");
+		this.vaultUnlocked = ObservableUtil.mapWithDefault(eventEntry.flatMap(e -> e.getKey().vault().unlockedProperty()), Function.identity(), false);
+		this.readableTime = ObservableUtil.mapWithDefault(eventEntry, e -> LOCAL_TIME_FORMATTER.format(e.getValue().mostRecentEvent().getTimestamp()), "");
+		this.readableDate = ObservableUtil.mapWithDefault(eventEntry, e -> LOCAL_DATE_FORMATTER.format(e.getValue().mostRecentEvent().getTimestamp()), "");
+		this.message = Bindings.createStringBinding(this::selectMessage, vaultUnlocked, eventMessage);
+		this.description = Bindings.createStringBinding(this::selectDescription, vaultUnlocked, eventDescription);
+		this.icon = Bindings.createObjectBinding(this::selectIcon, vaultUnlocked, eventIcon);
+		this.actionsButtonVisible = new SimpleBooleanProperty();
+		this.eventTooltip = new Tooltip();
+		eventTooltip.setShowDelay(Duration.millis(500.0));
+	}
+
+	@FXML
+	public void initialize() {
+		actionsButtonVisible.bind(Bindings.createBooleanBinding(this::determineActionsButtonVisibility, root.hoverProperty(), eventActionsMenu.showingProperty(), vaultUnlocked));
+		vaultUnlocked.addListener((_, _, newValue) -> eventActionsMenu.hide());
+		Tooltip.install(root, eventTooltip);
+	}
+
+	private boolean determineActionsButtonVisibility() {
+		return vaultUnlocked.getValue() && (eventActionsMenu.isShowing() || root.isHover());
+	}
+
+	public void setEventEntry(@NotNull Map.Entry<FSEventBucket, FSEventBucketContent> item) {
+		eventEntry.set(item);
+		eventActionsMenu.hide();
+		eventActionsMenu.getItems().clear();
+		eventTooltip.setText(item.getKey().vault().getDisplayName());
+		addAction("generic.action.dismiss", () -> {
+			fileSystemEventAggregator.remove(item.getKey());
+		});
+		switch (item.getValue().mostRecentEvent()) {
+			case ConflictResolvedEvent fse -> this.adjustToConflictResolvedEvent(fse);
+			case ConflictResolutionFailedEvent fse -> this.adjustToConflictEvent(fse);
+			case DecryptionFailedEvent fse -> this.adjustToDecryptionFailedEvent(fse);
+			case BrokenDirFileEvent fse -> this.adjustToBrokenDirFileEvent(fse);
+			case BrokenFileNodeEvent fse -> this.adjustToBrokenFileNodeEvent(fse);
+		}
+	}
+
+
+	private void adjustToBrokenFileNodeEvent(BrokenFileNodeEvent bfe) {
+		eventIcon.setValue(FontAwesome5Icon.TIMES);
+		eventMessage.setValue(resourceBundle.getString("eventView.entry.brokenFileNode.message"));
+		eventDescription.setValue(bfe.ciphertextPath().getFileName().toString());
+		if (revealService != null) {
+			addAction("eventView.entry.brokenFileNode.showEncrypted", () -> reveal(revealService, convertVaultPathToSystemPath(bfe.ciphertextPath())));
+		} else {
+			addAction("eventView.entry.brokenFileNode.copyEncrypted", () -> copyToClipboard(convertVaultPathToSystemPath(bfe.ciphertextPath()).toString()));
+		}
+		addAction("eventView.entry.brokenFileNode.copyDecrypted", () -> copyToClipboard(convertVaultPathToSystemPath(bfe.cleartextPath()).toString()));
+	}
+
+	private void adjustToConflictResolvedEvent(ConflictResolvedEvent cre) {
+		eventIcon.setValue(FontAwesome5Icon.CHECK);
+		eventMessage.setValue(resourceBundle.getString("eventView.entry.conflictResolved.message"));
+		eventDescription.setValue(cre.resolvedCiphertextPath().getFileName().toString());
+		if (revealService != null) {
+			addAction("eventView.entry.conflictResolved.showDecrypted", () -> reveal(revealService, convertVaultPathToSystemPath(cre.resolvedCleartextPath())));
+		} else {
+			addAction("eventView.entry.conflictResolved.copyDecrypted", () -> copyToClipboard(convertVaultPathToSystemPath(cre.resolvedCleartextPath()).toString()));
+		}
+	}
+
+	private void adjustToConflictEvent(ConflictResolutionFailedEvent cfe) {
+		eventIcon.setValue(FontAwesome5Icon.COMPRESS_ALT);
+		eventMessage.setValue(resourceBundle.getString("eventView.entry.conflict.message"));
+		eventDescription.setValue(cfe.conflictingCiphertextPath().getFileName().toString());
+		if (revealService != null) {
+			addAction("eventView.entry.conflict.showDecrypted", () -> reveal(revealService, convertVaultPathToSystemPath(cfe.canonicalCleartextPath())));
+			addAction("eventView.entry.conflict.showEncrypted", () -> reveal(revealService, cfe.conflictingCiphertextPath()));
+		} else {
+			addAction("eventView.entry.conflict.copyDecrypted", () -> copyToClipboard(convertVaultPathToSystemPath(cfe.canonicalCleartextPath()).toString()));
+			addAction("eventView.entry.conflict.copyEncrypted", () -> copyToClipboard(cfe.conflictingCiphertextPath().toString()));
+		}
+	}
+
+	private void adjustToDecryptionFailedEvent(DecryptionFailedEvent dfe) {
+		eventIcon.setValue(FontAwesome5Icon.BAN);
+		eventMessage.setValue(resourceBundle.getString("eventView.entry.decryptionFailed.message"));
+		eventDescription.setValue(dfe.ciphertextPath().getFileName().toString());
+		if (revealService != null) {
+			addAction("eventView.entry.decryptionFailed.showEncrypted", () -> reveal(revealService, dfe.ciphertextPath()));
+		} else {
+			addAction("eventView.entry.decryptionFailed.copyEncrypted", () -> copyToClipboard(dfe.ciphertextPath().toString()));
+		}
+	}
+
+	private void adjustToBrokenDirFileEvent(BrokenDirFileEvent bde) {
+		eventIcon.setValue(FontAwesome5Icon.TIMES);
+		eventMessage.setValue(resourceBundle.getString("eventView.entry.brokenDirFile.message"));
+		eventDescription.setValue(bde.ciphertextPath().getParent().getFileName().toString());
+		if (revealService != null) {
+			addAction("eventView.entry.brokenDirFile.showEncrypted", () -> reveal(revealService, bde.ciphertextPath()));
+		} else {
+			addAction("eventView.entry.brokenDirFile.copyEncrypted", () -> copyToClipboard(bde.ciphertextPath().toString()));
+		}
+	}
+
+	private void addAction(String localizationKey, Runnable action) {
+		var entry = new MenuItem(resourceBundle.getString(localizationKey));
+		entry.getStyleClass().addLast("dropdown-button-context-menu-item");
+		entry.setOnAction(_ -> action.run());
+		eventActionsMenu.getItems().addLast(entry);
+	}
+
+
+	private FontAwesome5Icon selectIcon() {
+		if (vaultUnlocked.getValue()) {
+			return eventIcon.getValue();
+		} else {
+			return FontAwesome5Icon.LOCK;
+		}
+	}
+
+	private String selectMessage() {
+		if (vaultUnlocked.getValue()) {
+			return eventMessage.getValue();
+		} else {
+			return resourceBundle.getString("eventView.entry.vaultLocked.message");
+		}
+	}
+
+	private String selectDescription() {
+		if (vaultUnlocked.getValue()) {
+			return eventDescription.getValue();
+		} else if (eventEntry.getValue() != null) {
+			var e = eventEntry.getValue().getKey();
+			return resourceBundle.getString("eventView.entry.vaultLocked.description").formatted(e != null ? e.vault().getDisplayName() : "");
+		} else {
+			return "";
+		}
+	}
+
+
+	@FXML
+	public void toggleEventActionsMenu() {
+		var e = eventEntry.get();
+		if (e != null) {
+			if (eventActionsMenu.isShowing()) {
+				eventActionsMenu.hide();
+			} else {
+				eventActionsMenu.show(eventActionsButton, Side.BOTTOM, 0.0, 0.0);
+			}
+		}
+	}
+
+	private Path convertVaultPathToSystemPath(Path p) {
+		if (!(p instanceof CryptoPath)) {
+			throw new IllegalArgumentException("Path " + p + " is not a vault path");
+		}
+		var v = eventEntry.getValue().getKey().vault();
+		if (!v.isUnlocked()) {
+			return Path.of(System.getProperty("user.home"));
+		}
+
+		var mountUri = v.getMountPoint().uri();
+		var internalPath = p.toString().substring(1);
+		return Path.of(mountUri.getPath().concat(internalPath).substring(1));
+	}
+
+	private void reveal(RevealPathService s, Path p) {
+		try {
+			s.reveal(p);
+		} catch (RevealFailedException e) {
+			LOG.warn("Failed to show path  {}", p, e);
+		}
+	}
+
+	private void copyToClipboard(String s) {
+		var content = new ClipboardContent();
+		content.putString(s);
+		Clipboard.getSystemClipboard().setContent(content);
+	}
+
+	//-- property accessors --
+	public ObservableValue<String> messageProperty() {
+		return message;
+	}
+
+	public String getMessage() {
+		return message.getValue();
+	}
+
+	public ObservableValue<String> countProperty() {
+		return eventCount;
+	}
+
+	public String getCount() {
+		return eventCount.getValue();
+	}
+
+	public ObservableValue<String> descriptionProperty() {
+		return description;
+	}
+
+	public String getDescription() {
+		return description.getValue();
+	}
+
+	public ObservableValue<FontAwesome5Icon> iconProperty() {
+		return icon;
+	}
+
+	public FontAwesome5Icon getIcon() {
+		return icon.getValue();
+	}
+
+	public ObservableValue<Boolean> actionsButtonVisibleProperty() {
+		return actionsButtonVisible;
+	}
+
+	public boolean isActionsButtonVisible() {
+		return actionsButtonVisible.getValue();
+	}
+
+	public ObservableValue<String> eventLocalTimeProperty() {
+		return readableTime;
+	}
+
+	public String getEventLocalTime() {
+		return readableTime.getValue();
+	}
+
+	public ObservableValue<String> eventLocalDateProperty() {
+		return readableDate;
+	}
+
+	public String getEventLocalDate() {
+		return readableDate.getValue();
+	}
+
+	public ObservableValue<Boolean> vaultUnlockedProperty() {
+		return vaultUnlocked;
+	}
+
+	public boolean isVaultUnlocked() {
+		return vaultUnlocked.getValue();
+	}
+}

+ 66 - 0
src/main/java/org/cryptomator/ui/eventview/EventListCellFactory.java

@@ -0,0 +1,66 @@
+package org.cryptomator.ui.eventview;
+
+import org.cryptomator.event.FSEventBucket;
+import org.cryptomator.event.FSEventBucketContent;
+import org.cryptomator.ui.common.FxmlLoaderFactory;
+
+import javax.inject.Inject;
+import javafx.fxml.FXMLLoader;
+import javafx.scene.Parent;
+import javafx.scene.control.ContentDisplay;
+import javafx.scene.control.ListCell;
+import javafx.scene.control.ListView;
+import javafx.util.Callback;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Map;
+
+@EventViewScoped
+public class EventListCellFactory implements Callback<ListView<Map.Entry<FSEventBucket, FSEventBucketContent>>, ListCell<Map.Entry<FSEventBucket, FSEventBucketContent>>> {
+
+	private static final String FXML_PATH = "/fxml/eventview_cell.fxml";
+
+	private final FxmlLoaderFactory fxmlLoaders;
+
+	@Inject
+	EventListCellFactory(@EventViewWindow FxmlLoaderFactory fxmlLoaders) {
+		this.fxmlLoaders = fxmlLoaders;
+	}
+
+
+	@Override
+	public ListCell<Map.Entry<FSEventBucket, FSEventBucketContent>> call(ListView<Map.Entry<FSEventBucket, FSEventBucketContent>> eventListView) {
+		try {
+			FXMLLoader fxmlLoader = fxmlLoaders.load(FXML_PATH);
+			return new Cell(fxmlLoader.getRoot(), fxmlLoader.getController());
+		} catch (IOException e) {
+			throw new UncheckedIOException("Failed to load %s.".formatted(FXML_PATH), e);
+		}
+	}
+
+	private static class Cell extends ListCell<Map.Entry<FSEventBucket, FSEventBucketContent>> {
+
+		private final Parent root;
+		private final EventListCellController controller;
+
+		public Cell(Parent root, EventListCellController controller) {
+			this.root = root;
+			this.controller = controller;
+		}
+
+		@Override
+		protected void updateItem(Map.Entry<FSEventBucket, FSEventBucketContent> item, boolean empty) {
+			super.updateItem(item, empty);
+
+			if (empty || item == null) {
+				setGraphic(null);
+				this.getStyleClass().remove("list-cell");
+			} else {
+				this.getStyleClass().addLast("list-cell");
+				setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
+				setGraphic(root);
+				controller.setEventEntry(item);
+			}
+		}
+	}
+}

+ 35 - 0
src/main/java/org/cryptomator/ui/eventview/EventViewComponent.java

@@ -0,0 +1,35 @@
+package org.cryptomator.ui.eventview;
+
+import dagger.Lazy;
+import dagger.Subcomponent;
+import org.cryptomator.ui.common.FxmlFile;
+import org.cryptomator.ui.common.FxmlScene;
+
+import javafx.scene.Scene;
+import javafx.stage.Stage;
+
+@EventViewScoped
+@Subcomponent(modules = {EventViewModule.class})
+public interface EventViewComponent {
+
+	@EventViewWindow
+	Stage window();
+
+	@FxmlScene(FxmlFile.EVENT_VIEW)
+	Lazy<Scene> scene();
+
+	default Stage showEventViewerWindow() {
+		Stage stage = window();
+		stage.setScene(scene().get());
+		stage.sizeToScene();
+		stage.show();
+		stage.requestFocus();
+		return stage;
+	}
+
+	@Subcomponent.Factory
+	interface Factory {
+
+		EventViewComponent create();
+	}
+}

+ 132 - 0
src/main/java/org/cryptomator/ui/eventview/EventViewController.java

@@ -0,0 +1,132 @@
+package org.cryptomator.ui.eventview;
+
+import org.cryptomator.common.vaults.Vault;
+import org.cryptomator.event.FSEventBucket;
+import org.cryptomator.event.FSEventBucketContent;
+import org.cryptomator.event.FileSystemEventAggregator;
+import org.cryptomator.ui.common.FxController;
+import org.cryptomator.ui.fxapp.FxFSEventList;
+
+import javax.inject.Inject;
+import javafx.beans.value.ObservableValue;
+import javafx.collections.FXCollections;
+import javafx.collections.ListChangeListener;
+import javafx.collections.ObservableList;
+import javafx.collections.transformation.FilteredList;
+import javafx.collections.transformation.SortedList;
+import javafx.fxml.FXML;
+import javafx.scene.control.ChoiceBox;
+import javafx.scene.control.ListView;
+import javafx.util.StringConverter;
+import java.util.Map;
+import java.util.ResourceBundle;
+
+@EventViewScoped
+public class EventViewController implements FxController {
+
+	private final FilteredList<Map.Entry<FSEventBucket, FSEventBucketContent>> filteredEventList;
+	private final ObservableList<Vault> vaults;
+	private final FileSystemEventAggregator aggregator;
+	private final SortedList<Map.Entry<FSEventBucket, FSEventBucketContent>> sortedEventList;
+	private final ObservableList<Vault> choiceBoxEntries;
+	private final ResourceBundle resourceBundle;
+	private final EventListCellFactory cellFactory;
+
+	@FXML
+	ChoiceBox<Vault> vaultFilterChoiceBox;
+	@FXML
+	ListView<Map.Entry<FSEventBucket, FSEventBucketContent>> eventListView;
+
+	@Inject
+	public EventViewController(FxFSEventList fxFSEventList, ObservableList<Vault> vaults, ResourceBundle resourceBundle, EventListCellFactory cellFactory, FileSystemEventAggregator aggregator) {
+		this.filteredEventList = fxFSEventList.getObservableList().filtered(_ -> true);
+		this.vaults = vaults;
+		this.aggregator = aggregator;
+		this.sortedEventList = new SortedList<>(filteredEventList, this::compareBuckets);
+		this.choiceBoxEntries = FXCollections.observableArrayList();
+		this.resourceBundle = resourceBundle;
+		this.cellFactory = cellFactory;
+	}
+
+	/**
+	 * Comparison method for the lru cache. During comparsion the map is accessed.
+	 * First the entries are compared by the event timestamp, then vaultId, then identifying path and lastly by class name.
+	 *
+	 * @param left an entry of a {@link FSEventBucket} and its content
+	 * @param right another entry of a {@link FSEventBucket} plus content, compared to {@code left}
+	 * @return a negative integer, zero, or a positive integer as the first argument is less than, equal to, or greater than the second.
+	 */
+	private int compareBuckets(Map.Entry<FSEventBucket, FSEventBucketContent> left, Map.Entry<FSEventBucket, FSEventBucketContent> right) {
+		var t1 = left.getValue().mostRecentEvent().getTimestamp();
+		var t2 = right.getValue().mostRecentEvent().getTimestamp();
+		var timeComparison = t1.compareTo(t2);
+		if (timeComparison != 0) {
+			return -timeComparison; //we need the reverse timesorting
+		}
+		var vaultIdComparison = left.getKey().vault().getId().compareTo(right.getKey().vault().getId());
+		if (vaultIdComparison != 0) {
+			return vaultIdComparison;
+		}
+		var pathComparison = left.getKey().idPath().compareTo(right.getKey().idPath());
+		if (pathComparison != 0) {
+			return pathComparison;
+		}
+		return left.getKey().c().getName().compareTo(right.getKey().c().getName());
+	}
+
+	@FXML
+	public void initialize() {
+		choiceBoxEntries.add(null);
+		choiceBoxEntries.addAll(vaults);
+		vaults.addListener((ListChangeListener<? super Vault>) c -> {
+			while (c.next()) {
+				choiceBoxEntries.removeAll(c.getRemoved());
+				choiceBoxEntries.addAll(c.getAddedSubList());
+			}
+		});
+
+		eventListView.setCellFactory(cellFactory);
+		eventListView.setItems(sortedEventList);
+
+		vaultFilterChoiceBox.setItems(choiceBoxEntries);
+		vaultFilterChoiceBox.valueProperty().addListener(this::applyVaultFilter);
+		vaultFilterChoiceBox.setConverter(new VaultConverter(resourceBundle));
+	}
+
+	private void applyVaultFilter(ObservableValue<? extends Vault> v, Vault oldV, Vault newV) {
+		if (newV == null) {
+			filteredEventList.setPredicate(_ -> true);
+		} else {
+			filteredEventList.setPredicate(e -> e.getKey().vault().equals(newV));
+		}
+	}
+
+	@FXML
+	void clearEvents() {
+		aggregator.clear();
+	}
+
+	private static class VaultConverter extends StringConverter<Vault> {
+
+		private final ResourceBundle resourceBundle;
+
+		VaultConverter(ResourceBundle resourceBundle) {
+			this.resourceBundle = resourceBundle;
+		}
+
+		@Override
+		public String toString(Vault v) {
+			if (v == null) {
+				return resourceBundle.getString("eventView.filter.allVaults");
+			} else {
+				return v.getDisplayName();
+			}
+		}
+
+		@Override
+		public Vault fromString(String displayLanguage) {
+			throw new UnsupportedOperationException();
+		}
+	}
+
+}

+ 67 - 0
src/main/java/org/cryptomator/ui/eventview/EventViewModule.java

@@ -0,0 +1,67 @@
+package org.cryptomator.ui.eventview;
+
+import dagger.Binds;
+import dagger.Module;
+import dagger.Provides;
+import dagger.multibindings.IntoMap;
+import org.cryptomator.ui.common.DefaultSceneFactory;
+import org.cryptomator.ui.common.FxController;
+import org.cryptomator.ui.common.FxControllerKey;
+import org.cryptomator.ui.common.FxmlFile;
+import org.cryptomator.ui.common.FxmlLoaderFactory;
+import org.cryptomator.ui.common.FxmlScene;
+import org.cryptomator.ui.common.StageFactory;
+import org.cryptomator.ui.fxapp.FxFSEventList;
+
+import javax.inject.Provider;
+import javafx.scene.Scene;
+import javafx.stage.Modality;
+import javafx.stage.Stage;
+import java.util.Map;
+import java.util.ResourceBundle;
+
+@Module
+abstract class EventViewModule {
+
+	@Provides
+	@EventViewScoped
+	@EventViewWindow
+	static Stage provideStage(StageFactory factory, ResourceBundle resourceBundle, FxFSEventList fxFSEventList) {
+		Stage stage = factory.create();
+		stage.setHeight(498);
+		stage.setTitle(resourceBundle.getString("eventView.title"));
+		stage.setResizable(true);
+		stage.initModality(Modality.NONE);
+		stage.focusedProperty().addListener((_,_,isFocused) -> {
+			if(isFocused) {
+				fxFSEventList.unreadEventsProperty().setValue(false);
+			}
+		});
+		return stage;
+	}
+
+	@Provides
+	@EventViewScoped
+	@EventViewWindow
+	static FxmlLoaderFactory provideFxmlLoaderFactory(Map<Class<? extends FxController>, Provider<FxController>> factories, DefaultSceneFactory sceneFactory, ResourceBundle resourceBundle) {
+		return new FxmlLoaderFactory(factories, sceneFactory, resourceBundle);
+	}
+
+	@Provides
+	@FxmlScene(FxmlFile.EVENT_VIEW)
+	@EventViewScoped
+	static Scene provideEventViewerScene(@EventViewWindow FxmlLoaderFactory fxmlLoaders) {
+		return fxmlLoaders.createScene(FxmlFile.EVENT_VIEW);
+	}
+
+
+	@Binds
+	@IntoMap
+	@FxControllerKey(EventViewController.class)
+	abstract FxController bindEventViewController(EventViewController controller);
+
+	@Binds
+	@IntoMap
+	@FxControllerKey(EventListCellController.class)
+	abstract FxController bindEventListCellController(EventListCellController controller);
+}

+ 13 - 0
src/main/java/org/cryptomator/ui/eventview/EventViewScoped.java

@@ -0,0 +1,13 @@
+package org.cryptomator.ui.eventview;
+
+import javax.inject.Scope;
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+@Scope
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@interface EventViewScoped {
+
+}

+ 14 - 0
src/main/java/org/cryptomator/ui/eventview/EventViewWindow.java

@@ -0,0 +1,14 @@
+package org.cryptomator.ui.eventview;
+
+import javax.inject.Qualifier;
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+@Qualifier
+@Documented
+@Retention(RUNTIME)
+@interface EventViewWindow {
+
+}

+ 4 - 1
src/main/java/org/cryptomator/ui/fxapp/FxApplication.java

@@ -29,9 +29,10 @@ public class FxApplication {
 	private final FxApplicationStyle applicationStyle;
 	private final FxApplicationTerminator applicationTerminator;
 	private final AutoUnlocker autoUnlocker;
+	private final FxFSEventList fxFSEventList;
 
 	@Inject
-	FxApplication(@Named("startupTime") long startupTime, Environment environment, Settings settings, AppLaunchEventHandler launchEventHandler, Lazy<TrayMenuComponent> trayMenu, FxApplicationWindows appWindows, FxApplicationStyle applicationStyle, FxApplicationTerminator applicationTerminator, AutoUnlocker autoUnlocker) {
+	FxApplication(@Named("startupTime") long startupTime, Environment environment, Settings settings, AppLaunchEventHandler launchEventHandler, Lazy<TrayMenuComponent> trayMenu, FxApplicationWindows appWindows, FxApplicationStyle applicationStyle, FxApplicationTerminator applicationTerminator, AutoUnlocker autoUnlocker, FxFSEventList fxFSEventList) {
 		this.startupTime = startupTime;
 		this.environment = environment;
 		this.settings = settings;
@@ -41,6 +42,7 @@ public class FxApplication {
 		this.applicationStyle = applicationStyle;
 		this.applicationTerminator = applicationTerminator;
 		this.autoUnlocker = autoUnlocker;
+		this.fxFSEventList = fxFSEventList;
 	}
 
 	public void start() {
@@ -85,6 +87,7 @@ public class FxApplication {
 		migrateAndInformDokanyRemoval();
 
 		launchEventHandler.startHandlingLaunchEvents();
+		fxFSEventList.schedulePollForUpdates();
 		autoUnlocker.tryUnlockForTimespan(2, TimeUnit.MINUTES);
 	}
 

+ 12 - 1
src/main/java/org/cryptomator/ui/fxapp/FxApplicationModule.java

@@ -8,6 +8,7 @@ package org.cryptomator.ui.fxapp;
 import dagger.Module;
 import dagger.Provides;
 import org.cryptomator.ui.error.ErrorComponent;
+import org.cryptomator.ui.eventview.EventViewComponent;
 import org.cryptomator.ui.health.HealthCheckComponent;
 import org.cryptomator.ui.lock.LockComponent;
 import org.cryptomator.ui.mainwindow.MainWindowComponent;
@@ -19,6 +20,9 @@ import org.cryptomator.ui.unlock.UnlockComponent;
 import org.cryptomator.ui.updatereminder.UpdateReminderComponent;
 import org.cryptomator.ui.vaultoptions.VaultOptionsComponent;
 
+import javax.inject.Named;
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.SimpleBooleanProperty;
 import javafx.scene.image.Image;
 import java.io.IOException;
 import java.io.InputStream;
@@ -33,7 +37,8 @@ import java.io.InputStream;
 		ErrorComponent.class, //
 		HealthCheckComponent.class, //
 		UpdateReminderComponent.class, //
-		ShareVaultComponent.class})
+		ShareVaultComponent.class, //
+		EventViewComponent.class})
 abstract class FxApplicationModule {
 
 	private static Image createImageFromResource(String resourceName) throws IOException {
@@ -66,4 +71,10 @@ abstract class FxApplicationModule {
 		return builder.build();
 	}
 
+	@Provides
+	@FxApplicationScoped
+	static EventViewComponent provideEventViewComponent(EventViewComponent.Factory factory) {
+		return factory.create();
+	}
+
 }

+ 9 - 1
src/main/java/org/cryptomator/ui/fxapp/FxApplicationWindows.java

@@ -8,6 +8,7 @@ import org.cryptomator.integrations.tray.TrayIntegrationProvider;
 import org.cryptomator.ui.dialogs.Dialogs;
 import org.cryptomator.ui.dialogs.SimpleDialog;
 import org.cryptomator.ui.error.ErrorComponent;
+import org.cryptomator.ui.eventview.EventViewComponent;
 import org.cryptomator.ui.lock.LockComponent;
 import org.cryptomator.ui.mainwindow.MainWindowComponent;
 import org.cryptomator.ui.preferences.PreferencesComponent;
@@ -52,6 +53,7 @@ public class FxApplicationWindows {
 	private final UpdateReminderComponent.Factory updateReminderWindowFactory;
 	private final LockComponent.Factory lockWorkflowFactory;
 	private final ErrorComponent.Factory errorWindowFactory;
+	private final Lazy<EventViewComponent> eventViewWindow;
 	private final ExecutorService executor;
 	private final VaultOptionsComponent.Factory vaultOptionsWindow;
 	private final ShareVaultComponent.Factory shareVaultWindow;
@@ -70,6 +72,7 @@ public class FxApplicationWindows {
 								ErrorComponent.Factory errorWindowFactory, //
 								VaultOptionsComponent.Factory vaultOptionsWindow, //
 								ShareVaultComponent.Factory shareVaultWindow, //
+								Lazy<EventViewComponent> eventViewWindow, //
 								ExecutorService executor, //
 								Dialogs dialogs) {
 		this.primaryStage = primaryStage;
@@ -81,6 +84,7 @@ public class FxApplicationWindows {
 		this.updateReminderWindowFactory = updateReminderWindowFactory;
 		this.lockWorkflowFactory = lockWorkflowFactory;
 		this.errorWindowFactory = errorWindowFactory;
+		this.eventViewWindow = eventViewWindow;
 		this.executor = executor;
 		this.vaultOptionsWindow = vaultOptionsWindow;
 		this.shareVaultWindow = shareVaultWindow;
@@ -184,6 +188,11 @@ public class FxApplicationWindows {
 				});
 	}
 
+
+	public CompletionStage<Stage> showEventViewer() {
+		return CompletableFuture.supplyAsync(() -> eventViewWindow.get().showEventViewerWindow(), Platform::runLater).whenComplete(this::reportErrors);
+	}
+
 	/**
 	 * Displays the generic error scene in the given window.
 	 *
@@ -201,5 +210,4 @@ public class FxApplicationWindows {
 			LOG.error("Failed to display stage", error);
 		}
 	}
-
 }

+ 67 - 0
src/main/java/org/cryptomator/ui/fxapp/FxFSEventList.java

@@ -0,0 +1,67 @@
+package org.cryptomator.ui.fxapp;
+
+import org.cryptomator.event.FSEventBucket;
+import org.cryptomator.event.FSEventBucketContent;
+import org.cryptomator.event.FileSystemEventAggregator;
+
+import javax.inject.Inject;
+import javafx.application.Platform;
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.SimpleBooleanProperty;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import java.util.Map;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * List of all occurred filesystem events.
+ * <p>
+ * The list exposes an observable list and a property to listen for updates. Internally it polls the {@link FileSystemEventAggregator} in a regular interval for updates.
+ * If an update is available, the list from the {@link FileSystemEventAggregator } is cloned to this list on the FX application thread.
+ */
+@FxApplicationScoped
+public class FxFSEventList {
+
+	private final ObservableList<Map.Entry<FSEventBucket, FSEventBucketContent>> events;
+	private final FileSystemEventAggregator eventAggregator;
+	private final ScheduledExecutorService scheduler;
+	private final BooleanProperty unreadEvents;
+
+	@Inject
+	public FxFSEventList(FileSystemEventAggregator fsEventAggregator, ScheduledExecutorService scheduler) {
+		this.events = FXCollections.observableArrayList();
+		this.eventAggregator = fsEventAggregator;
+		this.scheduler = scheduler;
+		this.unreadEvents = new SimpleBooleanProperty(false);
+	}
+
+	public void schedulePollForUpdates() {
+		scheduler.schedule(this::checkForEventUpdates, 1000, TimeUnit.MILLISECONDS);
+	}
+
+	/**
+	 * Checks for event updates and reschedules.
+	 * If updates are available, the aggregated events are copied from back- to the frontend.
+	 * Reschedules itself on successful execution
+	 */
+	private void checkForEventUpdates() {
+		if (eventAggregator.hasMaybeUpdates()) {
+			Platform.runLater(() -> {
+				eventAggregator.cloneTo(events);
+				unreadEvents.setValue(true);
+				schedulePollForUpdates();
+			});
+		} else {
+			schedulePollForUpdates();
+		}
+	}
+
+	public ObservableList<Map.Entry<FSEventBucket, FSEventBucketContent>> getObservableList() {
+		return events;
+	}
+
+	public BooleanProperty unreadEventsProperty() {
+		return unreadEvents;
+	}
+}

+ 16 - 2
src/main/java/org/cryptomator/ui/mainwindow/VaultListController.java

@@ -10,6 +10,7 @@ import org.cryptomator.ui.addvaultwizard.AddVaultWizardComponent;
 import org.cryptomator.ui.common.FxController;
 import org.cryptomator.ui.common.VaultService;
 import org.cryptomator.ui.dialogs.Dialogs;
+import org.cryptomator.ui.fxapp.FxFSEventList;
 import org.cryptomator.ui.fxapp.FxApplicationWindows;
 import org.cryptomator.ui.preferences.SelectedPreferencesTab;
 import org.slf4j.Logger;
@@ -35,7 +36,6 @@ import javafx.scene.input.KeyCode;
 import javafx.scene.input.KeyEvent;
 import javafx.scene.input.MouseEvent;
 import javafx.scene.input.TransferMode;
-import javafx.scene.layout.HBox;
 import javafx.scene.layout.StackPane;
 import javafx.stage.Stage;
 import java.io.File;
@@ -67,6 +67,7 @@ public class VaultListController implements FxController {
 	private final VaultListCellFactory cellFactory;
 	private final AddVaultWizardComponent.Builder addVaultWizard;
 	private final BooleanBinding emptyVaultList;
+	private final BooleanProperty unreadEvents;
 	private final VaultListManager vaultListManager;
 	private final BooleanProperty draggingVaultOver = new SimpleBooleanProperty();
 	private final ResourceBundle resourceBundle;
@@ -92,7 +93,8 @@ public class VaultListController implements FxController {
 						ResourceBundle resourceBundle, //
 						FxApplicationWindows appWindows, //
 						Settings settings, //
-						Dialogs dialogs) {
+						Dialogs dialogs, //
+						FxFSEventList fxFSEventList) {
 		this.mainWindow = mainWindow;
 		this.vaults = vaults;
 		this.selectedVault = selectedVault;
@@ -105,6 +107,7 @@ public class VaultListController implements FxController {
 		this.dialogs = dialogs;
 
 		this.emptyVaultList = Bindings.isEmpty(vaults);
+		this.unreadEvents = fxFSEventList.unreadEventsProperty();
 
 		selectedVault.addListener(this::selectedVaultDidChange);
 		cellSize = settings.compactMode.map(compact -> compact ? 30.0 : 60.0);
@@ -264,6 +267,10 @@ public class VaultListController implements FxController {
 		appWindows.showPreferencesWindow(SelectedPreferencesTab.ANY);
 	}
 
+	@FXML
+	public void showEventViewer() {
+		appWindows.showEventViewer();
+	}
 	// Getter and Setter
 
 	public BooleanBinding emptyVaultListProperty() {
@@ -290,4 +297,11 @@ public class VaultListController implements FxController {
 		return cellSize.getValue();
 	}
 
+	public ObservableValue<Boolean> unreadEventsPresentProperty() {
+		return unreadEvents;
+	}
+
+	public boolean getUnreadEventsPresent() {
+		return unreadEvents.getValue();
+	}
 }

+ 50 - 0
src/main/resources/css/dark_theme.css

@@ -302,6 +302,10 @@
 	-fx-font-size: 1.0em;
 }
 
+.list-cell .header-misc {
+	-fx-font-size: 1.0em;
+}
+
 .list-cell .detail-label {
 	-fx-text-fill: TEXT_FILL_MUTED;
 	-fx-font-size: 0.8em;
@@ -330,6 +334,42 @@
 	-fx-fill: transparent;
 }
 
+/*******************************************************************************
+ *                                                                             *
+ * Event List                                                                  *
+ *                                                                             *
+ ******************************************************************************/
+
+.event-window .button-bar {
+	-fx-min-height:42px;
+	-fx-max-height:42px;
+	-fx-background-color: MAIN_BG;
+	-fx-border-color: transparent transparent CONTROL_BORDER_NORMAL transparent;
+	-fx-border-width: 0 0 1px 0;
+}
+
+.event-window .button-bar .button-right {
+	-fx-border-color: transparent transparent transparent CONTROL_BORDER_NORMAL;
+	-fx-border-width: 0 0 0 1px;
+	-fx-background-color: MAIN_BG;
+	-fx-background-radius: 0px;
+	-fx-min-height: 42px;
+	-fx-max-height: 42px;
+}
+
+.event-window .button-bar .button-right:armed {
+	-fx-background-color: CONTROL_BORDER_NORMAL, CONTROL_BG_ARMED;
+}
+
+.event-window .list-view .list-cell:hover {
+	-fx-background-color: CONTROL_BG_SELECTED;
+}
+
+.event-window .list-view .list-cell:selected {
+	-fx-background-color: PRIMARY, CONTROL_BG_SELECTED;
+	-fx-background-insets: 0, 0 0 0 3px;
+}
+
 /*******************************************************************************
  *                                                                             *
  * NotificationBar                                                             *
@@ -599,6 +639,16 @@
 	-fx-graphic-text-gap: 9px;
 }
 
+/*******************************************************************************
+ *                                                                             *
+ * Update indicator
+ *                                                                             *
+ ******************************************************************************/
+
+.icon-update-indicator {
+	-fx-fill: RED_5;
+}
+
 /*******************************************************************************
  *                                                                             *
  * Hyperlinks                                                                  *

+ 52 - 2
src/main/resources/css/light_theme.css

@@ -301,6 +301,10 @@
 	-fx-font-size: 1.0em;
 }
 
+.list-cell .header-misc {
+	-fx-font-size: 1.0em;
+}
+
 .list-cell .detail-label {
 	-fx-text-fill: TEXT_FILL_MUTED;
 	-fx-font-size: 0.8em;
@@ -329,6 +333,42 @@
 	-fx-fill: transparent;
 }
 
+/*******************************************************************************
+ *                                                                             *
+ * Event List                                                                  *
+ *                                                                             *
+ ******************************************************************************/
+
+.event-window .button-bar {
+	-fx-min-height:42px;
+	-fx-max-height:42px;
+	-fx-background-color: MAIN_BG;
+	-fx-border-color: transparent transparent CONTROL_BORDER_NORMAL transparent;
+	-fx-border-width: 0 0 1px 0;
+}
+
+.event-window .button-bar .button-right {
+	-fx-border-color: transparent transparent transparent CONTROL_BORDER_NORMAL;
+	-fx-border-width: 0 0 0 1px;
+	-fx-background-color: MAIN_BG;
+	-fx-background-radius: 0px;
+	-fx-min-height: 42px;
+	-fx-max-height: 42px;
+}
+
+.event-window .button-bar .button-right:armed {
+	-fx-background-color: CONTROL_BORDER_NORMAL, CONTROL_BG_ARMED;
+}
+
+.event-window .list-view .list-cell:hover {
+	-fx-background-color: CONTROL_BG_SELECTED;
+}
+
+.event-window .list-view .list-cell:selected {
+	-fx-background-color: PRIMARY, CONTROL_BG_SELECTED;
+	-fx-background-insets: 0, 0 0 0 3px;
+}
+
 /*******************************************************************************
  *                                                                             *
  * NotificationBar                                                             *
@@ -598,6 +638,16 @@
 	-fx-graphic-text-gap: 9px;
 }
 
+/*******************************************************************************
+ *                                                                             *
+ * Update indicator
+ *                                                                             *
+ ******************************************************************************/
+
+.icon-update-indicator {
+	-fx-fill: RED_5;
+}
+
 /*******************************************************************************
  *                                                                             *
  * Hyperlinks                                                                  *
@@ -810,11 +860,11 @@
 
 /*******************************************************************************
  *                                                                             *
- * Add Vault - MenuItem                                                                    *
+ * Dropdown button context menu
  *                                                                             *
  ******************************************************************************/
 
-.add-vault-menu-item {
+.dropdown-button-context-menu-item {
     -fx-padding: 4px 8px;
 }
 

+ 35 - 0
src/main/resources/fxml/eventview.fxml

@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<?import javafx.scene.control.Button?>
+<?import javafx.scene.control.ListView?>
+<?import javafx.scene.layout.HBox?>
+<?import javafx.scene.layout.Region?>
+<?import javafx.scene.layout.VBox?>
+<?import org.cryptomator.ui.controls.FontAwesome5IconView?>
+<?import javafx.scene.control.ChoiceBox?>
+<?import javafx.geometry.Insets?>
+<?import javafx.scene.control.Tooltip?>
+<VBox xmlns:fx="http://javafx.com/fxml"
+	  xmlns="http://javafx.com/javafx"
+	  fx:controller="org.cryptomator.ui.eventview.EventViewController"
+	  minWidth="300"
+	  prefWidth="300"
+	  styleClass="event-window"
+	  >
+	<HBox styleClass="button-bar" alignment="CENTER">
+		<padding>
+			<Insets left="6" />
+		</padding>
+		<ChoiceBox fx:id="vaultFilterChoiceBox"/>
+		<Region HBox.hgrow="ALWAYS"/>
+		<Button styleClass="button-right" onAction="#clearEvents" contentDisplay="GRAPHIC_ONLY">
+			<graphic>
+				<FontAwesome5IconView glyph="TRASH" glyphSize="16"/>
+			</graphic>
+			<tooltip>
+				<Tooltip text="%eventView.clearListButton.tooltip"/>
+			</tooltip>
+		</Button>
+	</HBox>
+	<ListView fx:id="eventListView" fixedCellSize="60" VBox.vgrow="ALWAYS"/>
+</VBox>

+ 45 - 0
src/main/resources/fxml/eventview_cell.fxml

@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<?import org.cryptomator.ui.controls.FontAwesome5IconView?>
+<?import javafx.geometry.Insets?>
+<?import javafx.scene.control.Button?>
+<?import javafx.scene.control.ContextMenu?>
+<?import javafx.scene.control.Label?>
+<?import javafx.scene.layout.HBox?>
+<?import javafx.scene.layout.VBox?>
+<HBox xmlns:fx="http://javafx.com/fxml"
+	  xmlns="http://javafx.com/javafx"
+	  fx:controller="org.cryptomator.ui.eventview.EventListCellController"
+	  prefHeight="60"
+	  prefWidth="200"
+	  spacing="12"
+	  alignment="CENTER_LEFT"
+	  fx:id="root">
+	<padding>
+		<Insets topRightBottomLeft="12"/>
+	</padding>
+	<!-- Remark Check the containing list view for a fixed cell size before editing height properties -->
+	<VBox alignment="CENTER" minWidth="20">
+		<FontAwesome5IconView glyph="${controller.icon}" HBox.hgrow="NEVER" glyphSize="16"/>
+	</VBox>
+	<VBox spacing="4" HBox.hgrow="ALWAYS">
+		<HBox spacing="4">
+			<Label styleClass="header-label" text="${controller.message}"/>
+			<Label styleClass="header-misc" text="${controller.count}" visible="${controller.vaultUnlocked}"/>
+		</HBox>
+		<Label text="${controller.description}"/>
+	</VBox>
+	<Button fx:id="eventActionsButton" contentDisplay="GRAPHIC_ONLY" onAction="#toggleEventActionsMenu" managed="${controller.actionsButtonVisible}" visible="${controller.actionsButtonVisible}">
+		<graphic>
+			<FontAwesome5IconView glyph="ELLIPSIS_V" glyphSize="16"/>
+		</graphic>
+	</Button>
+	<VBox alignment="CENTER" maxWidth="64" minWidth="64" visible="${!controller.actionsButtonVisible}" managed="${!controller.actionsButtonVisible}">
+		<Label text="${controller.eventLocalTime}" />
+		<Label text="${controller.eventLocalDate}" />
+	</VBox>
+
+	<fx:define>
+		<ContextMenu fx:id="eventActionsMenu"/>
+	</fx:define>
+</HBox>

+ 26 - 9
src/main/resources/fxml/vault_list.fxml

@@ -1,17 +1,21 @@
 <?xml version="1.0" encoding="UTF-8"?>
 
 <?import org.cryptomator.ui.controls.FontAwesome5IconView?>
+<?import javafx.scene.control.Button?>
+<?import javafx.scene.control.ContextMenu?>
 <?import javafx.scene.control.Label?>
 <?import javafx.scene.control.ListView?>
+<?import javafx.scene.control.MenuItem?>
+<?import javafx.scene.control.Tooltip?>
+<?import javafx.scene.layout.HBox?>
 <?import javafx.scene.layout.Region?>
 <?import javafx.scene.layout.StackPane?>
 <?import javafx.scene.layout.VBox?>
-<?import javafx.scene.control.ContextMenu?>
-<?import javafx.scene.control.MenuItem?>
-<?import javafx.scene.layout.HBox?>
-<?import javafx.geometry.Insets?>
 <?import javafx.scene.shape.Arc?>
-<?import javafx.scene.control.Button?>
+<?import javafx.scene.shape.Circle?>
+<?import javafx.geometry.Insets?>
+<?import javafx.scene.shape.Rectangle?>
+<?import javafx.scene.layout.AnchorPane?>
 <StackPane xmlns:fx="http://javafx.com/fxml"
 		   xmlns="http://javafx.com/javafx"
 		   fx:id="root"
@@ -42,6 +46,19 @@
 				</graphic>
 			</Button>
 			<Region HBox.hgrow="ALWAYS"/>
+			<StackPane>
+				<Button onMouseClicked="#showEventViewer" styleClass="button-right" minWidth="20" contentDisplay="GRAPHIC_ONLY" mnemonicParsing="false">
+					<graphic>
+						<FontAwesome5IconView glyph="BELL" glyphSize="16"/>
+					</graphic>
+					<tooltip>
+						<Tooltip text="%main.vaultlist.showEventsButton.tooltip"/>
+					</tooltip>
+				</Button>
+				<AnchorPane mouseTransparent="true" minWidth="12" maxWidth="12" minHeight="12" maxHeight="12" StackPane.alignment="CENTER">
+					<Circle radius="4" styleClass="icon-update-indicator" AnchorPane.topAnchor="-8" AnchorPane.rightAnchor="-6" visible="${controller.unreadEventsPresent}" />
+				</AnchorPane>
+			</StackPane>
 			<Button onMouseClicked="#showPreferences" styleClass="button-right" alignment="CENTER" minWidth="20" contentDisplay="GRAPHIC_ONLY">
 				<graphic>
 					<FontAwesome5IconView glyph="COG" glyphSize="16"/>
@@ -53,14 +70,14 @@
 	<fx:define>
 		<ContextMenu fx:id="addVaultContextMenu">
 			<items>
-				<MenuItem styleClass="add-vault-menu-item" text="%main.vaultlist.addVaultBtn.menuItemNew" onAction="#didClickAddNewVault" >
+				<MenuItem styleClass="dropdown-button-context-menu-item" text="%main.vaultlist.addVaultBtn.menuItemNew" onAction="#didClickAddNewVault">
 					<graphic>
-						<FontAwesome5IconView glyph="PLUS" textAlignment="CENTER" wrappingWidth="14" />
+						<FontAwesome5IconView glyph="PLUS" textAlignment="CENTER" wrappingWidth="14"/>
 					</graphic>
 				</MenuItem>
-				<MenuItem styleClass="add-vault-menu-item" text="%main.vaultlist.addVaultBtn.menuItemExisting" onAction="#didClickAddExistingVault" >
+				<MenuItem styleClass="dropdown-button-context-menu-item" text="%main.vaultlist.addVaultBtn.menuItemExisting" onAction="#didClickAddExistingVault">
 					<graphic>
-						<FontAwesome5IconView glyph="FOLDER_OPEN" textAlignment="CENTER" wrappingWidth="14" />
+						<FontAwesome5IconView glyph="FOLDER_OPEN" textAlignment="CENTER" wrappingWidth="14"/>
 					</graphic>
 				</MenuItem>
 			</items>

+ 29 - 1
src/main/resources/i18n/strings.properties

@@ -2,6 +2,7 @@
 additionalStyleSheets=
 
 # Generics
+generic.action.dismiss=Dismiss
 ## Button
 generic.button.apply=Apply
 generic.button.back=Back
@@ -395,6 +396,7 @@ main.vaultlist.contextMenu.vaultoptions=Show Vault Options
 main.vaultlist.contextMenu.reveal=Reveal Drive
 main.vaultlist.addVaultBtn.menuItemNew=Create New Vault...
 main.vaultlist.addVaultBtn.menuItemExisting=Open Existing Vault...
+main.vaultlist.showEventsButton.tooltip=Open event view
 ##Notificaition
 main.notification.updateAvailable=Update is available.
 main.notification.support=Support Cryptomator.
@@ -577,4 +579,30 @@ shareVault.hub.message=How to share a Hub vault
 shareVault.hub.description=In order to share the vault content with another team member, you have to perform two steps:
 shareVault.hub.instruction.1=1. Share access of the encrypted vault folder via cloud storage.
 shareVault.hub.instruction.2=2. Grant access to team member in Cryptomator Hub.
-shareVault.hub.openHub=Open Cryptomator Hub
+shareVault.hub.openHub=Open Cryptomator Hub
+
+# Event View
+eventView.title=Events
+eventView.filter.allVaults=All
+eventView.clearListButton.tooltip=Dismiss all
+## event list entries
+eventView.entry.vaultLocked.message=***********
+eventView.entry.vaultLocked.description=Unlock "%s" for details
+eventView.entry.conflictResolved.message=Resolved conflict
+eventView.entry.conflictResolved.showDecrypted=Show decrypted file
+eventView.entry.conflictResolved.copyDecrypted=Copy decrypted path
+eventView.entry.conflict.message=Conflict resolution failed
+eventView.entry.conflict.showDecrypted=Show decrypted, original file
+eventView.entry.conflict.copyDecrypted=Copy decrypted, original path
+eventView.entry.conflict.showEncrypted=Show conflicting, encrypted file
+eventView.entry.conflict.copyEncrypted=Copy conflicting, encrypted path
+eventView.entry.decryptionFailed.message=Decryption failed
+eventView.entry.decryptionFailed.showEncrypted=Show encrypted file
+eventView.entry.decryptionFailed.copyEncrypted=Copy encrypted path
+eventView.entry.brokenDirFile.message=Broken directory link
+eventView.entry.brokenDirFile.showEncrypted=Show broken, encrypted link
+eventView.entry.brokenDirFile.copyEncrypted=Copy path of broken link
+eventView.entry.brokenFileNode.message=Broken filesystem node
+eventView.entry.brokenFileNode.showEncrypted=Show broken, encrypted node
+eventView.entry.brokenFileNode.copyEncrypted=Copy path of broken, encrypted node
+eventView.entry.brokenFileNode.copyDecrypted=Copy decrypted path