Explorar o código

Merge branch 'feature/event-view' into release/1.16.0

# Conflicts:
#	pom.xml
Armin Schrenk hai 4 meses
pai
achega
61ec3bc465
Modificáronse 26 ficheiros con 1171 adicións e 30 borrados
  1. 1 0
      src/main/java/module-info.java
  2. 160 0
      src/main/java/org/cryptomator/common/EventMap.java
  3. 17 1
      src/main/java/org/cryptomator/common/vaults/Vault.java
  4. 14 0
      src/main/java/org/cryptomator/event/Answer.java
  5. 15 0
      src/main/java/org/cryptomator/event/NotificationHandler.java
  6. 27 0
      src/main/java/org/cryptomator/event/VaultEvent.java
  7. 1 0
      src/main/java/org/cryptomator/ui/common/FxmlFile.java
  8. 3 0
      src/main/java/org/cryptomator/ui/controls/FontAwesome5Icon.java
  9. 323 0
      src/main/java/org/cryptomator/ui/eventview/EventListCellController.java
  10. 64 0
      src/main/java/org/cryptomator/ui/eventview/EventListCellFactory.java
  11. 35 0
      src/main/java/org/cryptomator/ui/eventview/EventViewComponent.java
  12. 121 0
      src/main/java/org/cryptomator/ui/eventview/EventViewController.java
  13. 61 0
      src/main/java/org/cryptomator/ui/eventview/EventViewModule.java
  14. 13 0
      src/main/java/org/cryptomator/ui/eventview/EventViewScoped.java
  15. 14 0
      src/main/java/org/cryptomator/ui/eventview/EventViewWindow.java
  16. 14 0
      src/main/java/org/cryptomator/ui/eventview/UpdateEventViewController.java
  17. 9 1
      src/main/java/org/cryptomator/ui/fxapp/FxApplicationModule.java
  18. 9 1
      src/main/java/org/cryptomator/ui/fxapp/FxApplicationWindows.java
  19. 26 2
      src/main/java/org/cryptomator/ui/mainwindow/VaultListController.java
  20. 49 0
      src/main/resources/css/dark_theme.css
  21. 55 2
      src/main/resources/css/light_theme.css
  22. 35 0
      src/main/resources/fxml/eventview.fxml
  23. 45 0
      src/main/resources/fxml/eventview_cell.fxml
  24. 20 9
      src/main/resources/fxml/vault_list.fxml
  25. 11 13
      src/main/resources/fxml/vault_list_cell.fxml
  26. 29 1
      src/main/resources/i18n/strings.properties

+ 1 - 0
src/main/java/module-info.java

@@ -59,6 +59,7 @@ open module org.cryptomator.desktop {
 
 	uses org.cryptomator.common.locationpresets.LocationPresetsProvider;
 	uses SSLContextProvider;
+	uses org.cryptomator.event.NotificationHandler;
 
 	provides TrayMenuController with AwtTrayMenuController;
 	provides Configurator with LogbackConfiguratorFactory;

+ 160 - 0
src/main/java/org/cryptomator/common/EventMap.java

@@ -0,0 +1,160 @@
+package org.cryptomator.common;
+
+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 org.cryptomator.event.VaultEvent;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import javafx.beans.InvalidationListener;
+import javafx.collections.FXCollections;
+import javafx.collections.MapChangeListener;
+import javafx.collections.ObservableMap;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Map containing {@link VaultEvent}s.
+ * The map is keyed by the ciphertext path of the affected resource _and_ the {@link FilesystemEvent}s class in order to group same events
+ * <p>
+ * Use {@link EventMap#put(VaultEvent)} to add an element and {@link EventMap#remove(VaultEvent)} to remove it.
+ * <p>
+ * The map is size restricted to {@value MAX_SIZE} elements. If a _new_ element (i.e. not already present) is added, the least recently added is removed.
+ */
+@Singleton
+public class EventMap implements ObservableMap<EventMap.EventKey, VaultEvent> {
+
+	private static final int MAX_SIZE = 300;
+
+	public record EventKey(Path ciphertextPath, Class<? extends FilesystemEvent> c) {}
+
+	private final ObservableMap<EventMap.EventKey, VaultEvent> delegate;
+
+	@Inject
+	public EventMap() {
+		delegate = FXCollections.observableHashMap();
+	}
+
+	@Override
+	public void addListener(MapChangeListener<? super EventKey, ? super VaultEvent> mapChangeListener) {
+		delegate.addListener(mapChangeListener);
+	}
+
+	@Override
+	public void removeListener(MapChangeListener<? super EventKey, ? super VaultEvent> mapChangeListener) {
+		delegate.removeListener(mapChangeListener);
+	}
+
+	@Override
+	public int size() {
+		return delegate.size();
+	}
+
+	@Override
+	public boolean isEmpty() {
+		return delegate.isEmpty();
+	}
+
+	@Override
+	public boolean containsKey(Object key) {
+		return delegate.containsKey(key);
+	}
+
+	@Override
+	public boolean containsValue(Object value) {
+		return delegate.containsValue(value);
+	}
+
+	@Override
+	public VaultEvent get(Object key) {
+		return delegate.get(key);
+	}
+
+	@Override
+	public @Nullable VaultEvent put(EventKey key, VaultEvent value) {
+		return delegate.put(key, value);
+	}
+
+	@Override
+	public VaultEvent remove(Object key) {
+		return delegate.remove(key);
+	}
+
+	@Override
+	public void putAll(@NotNull Map<? extends EventKey, ? extends VaultEvent> m) {
+		delegate.putAll(m);
+	}
+
+	@Override
+	public void clear() {
+		delegate.clear();
+	}
+
+	@Override
+	public @NotNull Set<EventKey> keySet() {
+		return delegate.keySet();
+	}
+
+	@Override
+	public @NotNull Collection<VaultEvent> values() {
+		return delegate.values();
+	}
+
+	@Override
+	public @NotNull Set<Entry<EventKey, VaultEvent>> entrySet() {
+		return delegate.entrySet();
+	}
+
+	@Override
+	public void addListener(InvalidationListener invalidationListener) {
+		delegate.addListener(invalidationListener);
+	}
+
+	@Override
+	public void removeListener(InvalidationListener invalidationListener) {
+		delegate.removeListener(invalidationListener);
+	}
+
+	public synchronized void put(VaultEvent e) {
+		//compute key
+		var key = computeKey(e.actualEvent());
+		//if-else
+		var nullOrEntry = delegate.get(key);
+		if (nullOrEntry == null) {
+			if (size() == MAX_SIZE) {
+				delegate.entrySet().stream() //
+						.min(Comparator.comparing(entry -> entry.getValue().actualEvent().getTimestamp())) //
+						.ifPresent(oldestEntry -> delegate.remove(oldestEntry.getKey()));
+			}
+			delegate.put(key, e);
+		} else {
+			delegate.put(key, nullOrEntry.incrementCount(e.actualEvent()));
+		}
+	}
+
+	public synchronized VaultEvent remove(VaultEvent similar) {
+		//compute key
+		var key = computeKey(similar.actualEvent());
+		return this.remove(key);
+	}
+
+	private EventKey computeKey(FilesystemEvent e) {
+		var p = switch (e) {
+			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 EventKey(p, e.getClass());
+	}
+}

+ 17 - 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.common.EventMap;
 import org.cryptomator.common.mount.Mounter;
 import org.cryptomator.common.settings.Settings;
 import org.cryptomator.common.settings.VaultSettings;
@@ -18,9 +19,11 @@ 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;
+import org.cryptomator.event.VaultEvent;
 import org.cryptomator.integrations.mount.MountFailedException;
 import org.cryptomator.integrations.mount.Mountpoint;
 import org.cryptomator.integrations.mount.UnmountFailedException;
@@ -32,6 +35,7 @@ import org.slf4j.LoggerFactory;
 
 import javax.inject.Inject;
 import javax.inject.Named;
+import javafx.application.Platform;
 import javafx.beans.Observable;
 import javafx.beans.binding.Bindings;
 import javafx.beans.binding.BooleanBinding;
@@ -74,6 +78,7 @@ public class Vault {
 	private final ObjectBinding<Mountpoint> mountPoint;
 	private final Mounter mounter;
 	private final Settings settings;
+	private final EventMap eventMap;
 	private final BooleanProperty showingStats;
 
 	private final AtomicReference<Mounter.MountHandle> mountHandle = new AtomicReference<>(null);
@@ -85,7 +90,8 @@ public class Vault {
 		  VaultState state, //
 		  @Named("lastKnownException") ObjectProperty<Exception> lastKnownException, //
 		  VaultStats stats, //
-		  Mounter mounter, Settings settings) {
+		  Mounter mounter, Settings settings, //
+		  EventMap eventMap) {
 		this.vaultSettings = vaultSettings;
 		this.configCache = configCache;
 		this.cryptoFileSystem = cryptoFileSystem;
@@ -102,6 +108,7 @@ public class Vault {
 		this.mountPoint = Bindings.createObjectBinding(this::getMountPoint, state);
 		this.mounter = mounter;
 		this.settings = settings;
+		this.eventMap = eventMap;
 		this.showingStats = new SimpleBooleanProperty(false);
 		this.quickAccessEntry = new AtomicReference<>(null);
 	}
@@ -143,6 +150,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 +259,14 @@ public class Vault {
 		}
 	}
 
+
+	private void consumeVaultEvent(FilesystemEvent e) {
+		var wrapper = new VaultEvent(this, e);
+		Platform.runLater(() -> {
+			eventMap.put(wrapper);
+		});
+	}
+
 	// ******************************************************************************
 	// Observable Properties
 	// *******************************************************************************

+ 14 - 0
src/main/java/org/cryptomator/event/Answer.java

@@ -0,0 +1,14 @@
+package org.cryptomator.event;
+
+public sealed interface Answer permits Answer.DoNothing, Answer.DoSomething {
+
+
+	record DoNothing() implements Answer {}
+
+	record DoSomething(Runnable action) implements Answer {
+
+		void run() {
+			action.run();
+		}
+	}
+}

+ 15 - 0
src/main/java/org/cryptomator/event/NotificationHandler.java

@@ -0,0 +1,15 @@
+package org.cryptomator.event;
+
+import org.cryptomator.integrations.common.IntegrationsLoader;
+
+import java.util.ServiceLoader;
+import java.util.stream.Stream;
+
+public interface NotificationHandler {
+
+	Answer handle(VaultEvent e);
+
+	static Stream<NotificationHandler> loadAll() {
+		return IntegrationsLoader.loadAll(ServiceLoader.load(NotificationHandler.class), NotificationHandler.class);
+	}
+}

+ 27 - 0
src/main/java/org/cryptomator/event/VaultEvent.java

@@ -0,0 +1,27 @@
+package org.cryptomator.event;
+
+import org.cryptomator.common.vaults.Vault;
+import org.cryptomator.cryptofs.event.FilesystemEvent;
+
+import java.time.Instant;
+
+public record VaultEvent(Vault v, FilesystemEvent actualEvent, int count) implements Comparable<VaultEvent> {
+
+	public VaultEvent(Vault v, FilesystemEvent actualEvent) {
+		this(v, actualEvent, 1);
+	}
+
+	@Override
+	public int compareTo(VaultEvent other) {
+		var timeResult = actualEvent.getTimestamp().compareTo(other.actualEvent().getTimestamp());
+		if(timeResult != 0) {
+			return timeResult;
+		} else {
+			return this.equals(other) ? 0 : this.actualEvent.getClass().getName().compareTo(other.actualEvent.getClass().getName());
+		}
+	}
+
+	public VaultEvent incrementCount(FilesystemEvent update) {
+		return new VaultEvent(v, update, count+1);
+	}
+}

+ 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"), //

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

@@ -0,0 +1,323 @@
+package org.cryptomator.ui.eventview;
+
+import org.cryptomator.common.EventMap;
+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.event.VaultEvent;
+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.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 EventMap eventMap;
+	@Nullable
+	private final RevealPathService revealService;
+	private final ResourceBundle resourceBundle;
+	private final ObjectProperty<VaultEvent> event;
+	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(EventMap eventMap, Optional<RevealPathService> revealService, ResourceBundle resourceBundle) {
+		this.eventMap = eventMap;
+		this.revealService = revealService.orElseGet(() -> null);
+		this.resourceBundle = resourceBundle;
+		this.event = new SimpleObjectProperty<>(null);
+		this.eventMessage = new SimpleStringProperty();
+		this.eventDescription = new SimpleStringProperty();
+		this.eventIcon = new SimpleObjectProperty<>();
+		this.eventCount = ObservableUtil.mapWithDefault(event, e -> e.count() == 1? "" : "("+ e.count() +")", "");
+		this.vaultUnlocked = ObservableUtil.mapWithDefault(event.flatMap(e -> e.v().unlockedProperty()), Function.identity(), false);
+		this.readableTime = ObservableUtil.mapWithDefault(event, e -> LOCAL_TIME_FORMATTER.format(e.actualEvent().getTimestamp()), "");
+		this.readableDate = ObservableUtil.mapWithDefault(event, e -> LOCAL_DATE_FORMATTER.format(e.actualEvent().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 setEvent(@NotNull VaultEvent item) {
+		event.set(item);
+		eventActionsMenu.hide();
+		eventActionsMenu.getItems().clear();
+		eventTooltip.setText(item.v().getDisplayName());
+		addAction("generic.action.dismiss", () -> eventMap.remove(item));
+		switch (item.actualEvent()) {
+			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 {
+			var e = event.getValue();
+			return resourceBundle.getString("eventView.entry.vaultLocked.description").formatted(e != null ? e.v().getDisplayName() : "");
+		}
+	}
+
+
+	@FXML
+	public void toggleEventActionsMenu() {
+		var e = event.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 = event.getValue().v();
+		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();
+	}
+}

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

@@ -0,0 +1,64 @@
+package org.cryptomator.ui.eventview;
+
+import org.cryptomator.event.VaultEvent;
+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;
+
+@EventViewScoped
+public class EventListCellFactory implements Callback<ListView<VaultEvent>, ListCell<VaultEvent>> {
+
+	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<VaultEvent> call(ListView<VaultEvent> 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<VaultEvent> {
+
+		private final Parent root;
+		private final EventListCellController controller;
+
+		public Cell(Parent root, EventListCellController controller) {
+			this.root = root;
+			this.controller = controller;
+		}
+
+		@Override
+		protected void updateItem(VaultEvent 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.setEvent(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();
+	}
+}

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

@@ -0,0 +1,121 @@
+package org.cryptomator.ui.eventview;
+
+import org.cryptomator.common.EventMap;
+import org.cryptomator.common.vaults.Vault;
+import org.cryptomator.event.VaultEvent;
+import org.cryptomator.ui.common.FxController;
+
+import javax.inject.Inject;
+import javafx.beans.value.ObservableValue;
+import javafx.collections.FXCollections;
+import javafx.collections.ListChangeListener;
+import javafx.collections.MapChangeListener;
+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.Comparator;
+import java.util.ResourceBundle;
+
+@EventViewScoped
+public class EventViewController implements FxController {
+
+	private final EventMap eventMap;
+	private final ObservableList<VaultEvent> eventList;
+	private final FilteredList<VaultEvent> filteredEventList;
+	private final ObservableList<Vault> vaults;
+	private final SortedList<VaultEvent> reversedEventList;
+	private final ObservableList<Vault> choiceBoxEntries;
+	private final ResourceBundle resourceBundle;
+	private final EventListCellFactory cellFactory;
+
+	@FXML
+	ChoiceBox<Vault> vaultFilterChoiceBox;
+	@FXML
+	ListView<VaultEvent> eventListView;
+
+	@Inject
+	public EventViewController(EventMap eventMap, ObservableList<Vault> vaults, ResourceBundle resourceBundle, EventListCellFactory cellFactory) {
+		this.eventMap = eventMap;
+		this.eventList = FXCollections.observableArrayList();
+		this.filteredEventList = eventList.filtered(_ -> true);
+		this.vaults = vaults;
+		this.reversedEventList = new SortedList<>(filteredEventList, Comparator.reverseOrder());
+		this.choiceBoxEntries = FXCollections.observableArrayList();
+		this.resourceBundle = resourceBundle;
+		this.cellFactory = cellFactory;
+	}
+
+	@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());
+			}
+		});
+
+		eventList.addAll(eventMap.values());
+		eventMap.addListener((MapChangeListener<? super EventMap.EventKey, ? super VaultEvent>) this::updateList);
+		eventListView.setCellFactory(cellFactory);
+		eventListView.setItems(reversedEventList);
+
+		vaultFilterChoiceBox.setItems(choiceBoxEntries);
+		vaultFilterChoiceBox.valueProperty().addListener(this::applyVaultFilter);
+		vaultFilterChoiceBox.setConverter(new VaultConverter(resourceBundle));
+	}
+
+	private void updateList(MapChangeListener.Change<? extends EventMap.EventKey, ? extends VaultEvent> change) {
+		if (change.wasAdded() && change.wasRemoved()) {
+			//entry updated
+			eventList.remove(change.getValueRemoved());
+			eventList.addLast(change.getValueAdded());
+		} else if (change.wasAdded()) {
+			eventList.addLast(change.getValueAdded());
+		} else { //removed
+			eventList.remove(change.getValueRemoved());
+		}
+	}
+
+	private void applyVaultFilter(ObservableValue<? extends Vault> v, Vault oldV, Vault newV) {
+		if (newV == null) {
+			filteredEventList.setPredicate(_ -> true);
+		} else {
+			filteredEventList.setPredicate(e -> e.v().equals(newV));
+		}
+	}
+
+	@FXML
+	void clearEvents() {
+		eventMap.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();
+		}
+	}
+
+}

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

@@ -0,0 +1,61 @@
+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 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) {
+		Stage stage = factory.create();
+		stage.setHeight(498);
+		stage.setTitle(resourceBundle.getString("eventView.title"));
+		stage.setResizable(true);
+		stage.initModality(Modality.NONE);
+		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 {
+
+}

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

@@ -0,0 +1,14 @@
+package org.cryptomator.ui.eventview;
+
+import org.cryptomator.ui.common.FxController;
+
+import javax.inject.Inject;
+
+@EventViewScoped
+public class UpdateEventViewController implements FxController {
+
+	@Inject
+	public UpdateEventViewController() {
+
+	}
+}

+ 9 - 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;
@@ -33,7 +34,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 +68,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);
 		}
 	}
-
 }

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

@@ -1,11 +1,13 @@
 package org.cryptomator.ui.mainwindow;
 
 import org.apache.commons.lang3.SystemUtils;
+import org.cryptomator.common.EventMap;
 import org.cryptomator.common.settings.Settings;
 import org.cryptomator.common.vaults.Vault;
 import org.cryptomator.common.vaults.VaultListManager;
 import org.cryptomator.cryptofs.CryptoFileSystemProvider;
 import org.cryptomator.cryptofs.DirStructure;
+import org.cryptomator.event.VaultEvent;
 import org.cryptomator.ui.addvaultwizard.AddVaultWizardComponent;
 import org.cryptomator.ui.common.FxController;
 import org.cryptomator.ui.common.VaultService;
@@ -23,6 +25,7 @@ import javafx.beans.property.ObjectProperty;
 import javafx.beans.property.SimpleBooleanProperty;
 import javafx.beans.value.ObservableValue;
 import javafx.collections.ListChangeListener;
+import javafx.collections.MapChangeListener;
 import javafx.collections.ObservableList;
 import javafx.fxml.FXML;
 import javafx.geometry.Side;
@@ -35,7 +38,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 +69,8 @@ public class VaultListController implements FxController {
 	private final VaultListCellFactory cellFactory;
 	private final AddVaultWizardComponent.Builder addVaultWizard;
 	private final BooleanBinding emptyVaultList;
+	private final EventMap eventMap;
+	private final BooleanProperty newEventsPresent;
 	private final VaultListManager vaultListManager;
 	private final BooleanProperty draggingVaultOver = new SimpleBooleanProperty();
 	private final ResourceBundle resourceBundle;
@@ -92,7 +96,8 @@ public class VaultListController implements FxController {
 						ResourceBundle resourceBundle, //
 						FxApplicationWindows appWindows, //
 						Settings settings, //
-						Dialogs dialogs) {
+						Dialogs dialogs, //
+						EventMap eventMap) {
 		this.mainWindow = mainWindow;
 		this.vaults = vaults;
 		this.selectedVault = selectedVault;
@@ -105,6 +110,13 @@ public class VaultListController implements FxController {
 		this.dialogs = dialogs;
 
 		this.emptyVaultList = Bindings.isEmpty(vaults);
+		this.eventMap = eventMap;
+		this.newEventsPresent = new SimpleBooleanProperty(false);
+		eventMap.addListener((MapChangeListener<? super EventMap.EventKey, ? super VaultEvent>) change -> {
+			if (change.wasAdded()) {
+				newEventsPresent.setValue(true);
+			}
+		});
 
 		selectedVault.addListener(this::selectedVaultDidChange);
 		cellSize = settings.compactMode.map(compact -> compact ? 30.0 : 60.0);
@@ -264,6 +276,11 @@ public class VaultListController implements FxController {
 		appWindows.showPreferencesWindow(SelectedPreferencesTab.ANY);
 	}
 
+	@FXML
+	public void showEventViewer() {
+		appWindows.showEventViewer();
+		newEventsPresent.setValue(false);
+	}
 	// Getter and Setter
 
 	public BooleanBinding emptyVaultListProperty() {
@@ -290,4 +307,11 @@ public class VaultListController implements FxController {
 		return cellSize.getValue();
 	}
 
+	public ObservableValue<Boolean> newEventsPresentProperty() {
+		return newEventsPresent;
+	}
+
+	public boolean getNewEventsPresent() {
+		return newEventsPresent.getValue();
+	}
 }

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

@@ -341,6 +341,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                                                             *
@@ -610,6 +646,19 @@
 	-fx-graphic-text-gap: 9px;
 }
 
+/*******************************************************************************
+ *                                                                             *
+ * Update indicator
+ *                                                                             *
+ ******************************************************************************/
+
+.update-indicator {
+	-fx-background-color: white, RED_5;
+	-fx-background-insets: 1px, 2px;
+	-fx-background-radius: 6px, 5px;
+	-fx-effect: dropshadow(three-pass-box, rgba(0, 0, 0, 0.8), 2, 0, 0, 0);
+}
+
 /*******************************************************************************
  *                                                                             *
  * Hyperlinks                                                                  *

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

@@ -312,6 +312,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;
@@ -340,6 +344,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                                                             *
@@ -609,6 +649,19 @@
 	-fx-graphic-text-gap: 9px;
 }
 
+/*******************************************************************************
+ *                                                                             *
+ * Update indicator
+ *                                                                             *
+ ******************************************************************************/
+
+.update-indicator {
+	-fx-background-color: white, RED_5;
+	-fx-background-insets: 1px, 2px;
+	-fx-background-radius: 6px, 5px;
+	-fx-effect: dropshadow(three-pass-box, rgba(0, 0, 0, 0.8), 2, 0, 0, 0);
+}
+
 /*******************************************************************************
  *                                                                             *
  * Hyperlinks                                                                  *
@@ -821,11 +874,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>

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

@@ -1,17 +1,17 @@
 <?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?>
 <StackPane xmlns:fx="http://javafx.com/fxml"
 		   xmlns="http://javafx.com/javafx"
 		   fx:id="root"
@@ -42,6 +42,17 @@
 				</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>
+				<Region styleClass="update-indicator" visible="${controller.newEventsPresent}" mouseTransparent="true" StackPane.alignment="TOP_RIGHT" prefWidth="12" prefHeight="12" maxWidth="-Infinity" maxHeight="-Infinity"/>
+			</StackPane>
 			<Button onMouseClicked="#showPreferences" styleClass="button-right" alignment="CENTER" minWidth="20" contentDisplay="GRAPHIC_ONLY">
 				<graphic>
 					<FontAwesome5IconView glyph="COG" glyphSize="16"/>
@@ -53,14 +64,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>

+ 11 - 13
src/main/resources/fxml/vault_list_cell.fxml

@@ -14,17 +14,15 @@
 	  spacing="12"
 	  alignment="CENTER_LEFT">
 	<!-- Remark Check the containing list view for a fixed cell size before editing height properties -->
-	<children>
-		<VBox alignment="CENTER" minWidth="20">
-			<FontAwesome5IconView fx:id="vaultStateView" glyph="${controller.glyph}" HBox.hgrow="NEVER" glyphSize="16"/>
-		</VBox>
-		<VBox spacing="4" HBox.hgrow="ALWAYS">
-			<Label styleClass="header-label" text="${controller.vault.displayName}"/>
-			<Label styleClass="detail-label" text="${controller.vault.displayablePath}" textOverrun="CENTER_ELLIPSIS" visible="${!controller.compactMode}" managed="${!controller.compactMode}">
-				<tooltip>
-					<Tooltip text="${controller.vault.displayablePath}"/>
-				</tooltip>
-			</Label>
-		</VBox>
-	</children>
+	<VBox alignment="CENTER" minWidth="20">
+		<FontAwesome5IconView fx:id="vaultStateView" glyph="${controller.glyph}" HBox.hgrow="NEVER" glyphSize="16"/>
+	</VBox>
+	<VBox spacing="4" HBox.hgrow="ALWAYS">
+		<Label styleClass="header-label" text="${controller.vault.displayName}"/>
+		<Label styleClass="detail-label" text="${controller.vault.displayablePath}" textOverrun="CENTER_ELLIPSIS" visible="${!controller.compactMode}" managed="${!controller.compactMode}">
+			<tooltip>
+				<Tooltip text="${controller.vault.displayablePath}"/>
+			</tooltip>
+		</Label>
+	</VBox>
 </HBox>

+ 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.
@@ -581,4 +583,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