Skip to main content
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPierre-Charles David2017-10-11 15:05:24 +0000
committerSt├ęphane B├ęgaudeau2017-12-22 11:09:34 +0000
commit4f353e1fe5d96da0d32b052ce008d2355f44703c (patch)
treeefc23e05720e2c73e504f67b72cba5c25c3ef182
parentae4813ce20844eaf3d3438895bc4d458ab064a8e (diff)
downloadorg.eclipse.eef-4f353e1fe5d96da0d32b052ce008d2355f44703c.tar.gz
org.eclipse.eef-4f353e1fe5d96da0d32b052ce008d2355f44703c.tar.xz
org.eclipse.eef-4f353e1fe5d96da0d32b052ce008d2355f44703c.zip
[528330] Handle possible edition conflicts in the text widget
Some conflict situations could cause the loss of user input inside a text field. If the user has started editing a text field when a concurrent refresh occurs and produces a different reference value that the one he started from, detect the potential conflict and react accordingly. If the new reference value is the same, keep the user input without interupting him. The reaction in case of conflict is configurable: the implementation in EEFTextLifecycleManager uses a new preference TEXT_CONFLICT_RESOLUTION_MODE, which is not exposed in the UI but can be configured programmatically using EEFPreferences.setTextConflictResolutionMode(). The default preference value can also be overridden by configuration, using the -pluginCustomization startup flag to point to a plugin_customization.ini file with, for example: org.eclipse.eef.ide.ui/TEXT_CONFLICT_RESOLUTION_MODE=USE_LOCAL_VERSION EEFTextLifecycleManager supports three modes: * USE_MODEL_VERSION: overwrite the widget's content with the value computed from the new version of the model (no user feedback). This is the default. * USE_LOCAL_VERSION: keep the current value begin edited by the end-user in the widget (no user feedback); * ASK_USER: open a simple dialog box to ask the user which version to keep. Note that the simple dialog provided is only suitable for short (single-line) text fields. Conflict resolution can further be customized by providing a custom LifecycleManager which extends the default EEFTextLifecycleManager and overrides either: * askUserToResolveConflict() to take the preference in consideration, but implement ASK_USER differently, for example with a more sophisticated dialog. * resolveEditionConflict() to provide a completely different strategy (possibly ignoring the preference). Bug: 528330 Change-Id: I37125b40fcfaea3fd8dcfc59aa2dcb692dfe6919 Cherry-Picks: 528134 Signed-off-by: Pierre-Charles David <pierre-charles.david@obeo.fr>
-rw-r--r--plugins/org.eclipse.eef.ide.ui/plugin.properties5
-rw-r--r--plugins/org.eclipse.eef.ide.ui/src/org/eclipse/eef/ide/ui/internal/Messages.java12
-rw-r--r--plugins/org.eclipse.eef.ide.ui/src/org/eclipse/eef/ide/ui/internal/preferences/EEFPreferences.java97
-rw-r--r--plugins/org.eclipse.eef.ide.ui/src/org/eclipse/eef/ide/ui/internal/widgets/EEFTextLifecycleManager.java239
4 files changed, 345 insertions, 8 deletions
diff --git a/plugins/org.eclipse.eef.ide.ui/plugin.properties b/plugins/org.eclipse.eef.ide.ui/plugin.properties
index 113a8b444..94995f4e9 100644
--- a/plugins/org.eclipse.eef.ide.ui/plugin.properties
+++ b/plugins/org.eclipse.eef.ide.ui/plugin.properties
@@ -38,3 +38,8 @@ AbstractEEFWidgetLifecycleManager_noDescriptionAvailable=No description availabl
AbstractEEFWidgetLifecycleManager_lockedByOther=This widget is locked by another user
AbstractEEFWidgetLifecycleManager_lockedByMe=This widget is locked for other users
AbstractEEFWidgetLifecycleManager_lockedNoWrite=This widget is locked because of a permission issue
+
+EEFTextLifecycleManager_conflictDialog_title = Model Changed During Edition
+EEFTextLifecycleManager_conflictDialog_message = The model has changed in an incompatible way while you were editing this widget.\n\nNew value from the model: {0}\nEdited value: {1}
+EEFTextLifecycleManager_conflictDialog_choiceNewModelValue = Use new value from the model
+EEFTextLifecycleManager_conflictDialog_choiceLocalEditedValue = Keep the edited value
diff --git a/plugins/org.eclipse.eef.ide.ui/src/org/eclipse/eef/ide/ui/internal/Messages.java b/plugins/org.eclipse.eef.ide.ui/src/org/eclipse/eef/ide/ui/internal/Messages.java
index a54c1d05a..9848d5208 100644
--- a/plugins/org.eclipse.eef.ide.ui/src/org/eclipse/eef/ide/ui/internal/Messages.java
+++ b/plugins/org.eclipse.eef.ide.ui/src/org/eclipse/eef/ide/ui/internal/Messages.java
@@ -85,6 +85,18 @@ public final class Messages {
@TranslatableMessage
public static String AbstractEEFWidgetLifecycleManager_lockedNoWrite;
+ @TranslatableMessage
+ public static String EEFTextLifecycleManager_conflictDialog_title;
+
+ @TranslatableMessage
+ public static String EEFTextLifecycleManager_conflictDialog_message;
+
+ @TranslatableMessage
+ public static String EEFTextLifecycleManager_conflictDialog_choiceNewModelValue;
+
+ @TranslatableMessage
+ public static String EEFTextLifecycleManager_conflictDialog_choiceLocalEditedValue;
+
// CHECKSTYLE:ON
/**
diff --git a/plugins/org.eclipse.eef.ide.ui/src/org/eclipse/eef/ide/ui/internal/preferences/EEFPreferences.java b/plugins/org.eclipse.eef.ide.ui/src/org/eclipse/eef/ide/ui/internal/preferences/EEFPreferences.java
new file mode 100644
index 000000000..1e01dd0ce
--- /dev/null
+++ b/plugins/org.eclipse.eef.ide.ui/src/org/eclipse/eef/ide/ui/internal/preferences/EEFPreferences.java
@@ -0,0 +1,97 @@
+/*******************************************************************************
+ * Copyright (c) 2017 Obeo.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ * Obeo - initial API and implementation
+ *******************************************************************************/
+package org.eclipse.eef.ide.ui.internal.preferences;
+
+import org.eclipse.core.runtime.preferences.DefaultScope;
+import org.eclipse.core.runtime.preferences.IEclipsePreferences;
+import org.eclipse.core.runtime.preferences.InstanceScope;
+import org.eclipse.eef.ide.ui.internal.EEFIdeUiPlugin;
+import org.eclipse.eef.ide.ui.internal.widgets.EEFTextLifecycleManager.ConflictResolutionMode;
+import org.osgi.service.prefs.BackingStoreException;
+
+/**
+ * Preferences for the EEF UI.
+ *
+ * @author pcdavid
+ */
+public final class EEFPreferences {
+ /**
+ * The key for the text conflict resolution mode.
+ */
+ public static final String TEXT_CONFLICT_RESOLUTION_MODE = "TEXT_CONFLICT_RESOLUTION_MODE"; //$NON-NLS-1$
+
+ /**
+ * The default value for text conflict resolution mode.
+ */
+ public static final ConflictResolutionMode DEFAULT_TEXT_CONFLICT_RESOLUTION_MODE = ConflictResolutionMode.ASK_USER;
+
+ /**
+ * The EEF Common preference scope.
+ */
+ private static final IEclipsePreferences PREFERENCES_SCOPE = InstanceScope.INSTANCE.getNode(EEFIdeUiPlugin.PLUGIN_ID);
+
+ /**
+ * The EEF Common default preference scope.
+ */
+ private static final IEclipsePreferences DEFAULT_PREFERENCES_SCOPE = DefaultScope.INSTANCE.getNode(EEFIdeUiPlugin.PLUGIN_ID);
+
+ /**
+ * The constructor.
+ */
+ private EEFPreferences() {
+ // prevent instantiation
+ }
+
+ /**
+ * Indicates how text conflicts should be resolved.
+ *
+ * @return the conflict resolution mode to be used.
+ */
+ public static ConflictResolutionMode getTextConflictResolutionMode() {
+ String mode = PREFERENCES_SCOPE.get(TEXT_CONFLICT_RESOLUTION_MODE, null);
+ if (mode == null) {
+ // No explicit preference was set, use the configured default (from the default scope), or the hard-coded
+ // "default default" if none was set.
+ mode = DEFAULT_PREFERENCES_SCOPE.get(TEXT_CONFLICT_RESOLUTION_MODE, DEFAULT_TEXT_CONFLICT_RESOLUTION_MODE.name());
+ }
+ try {
+ return ConflictResolutionMode.valueOf(mode);
+ } catch (@SuppressWarnings("unused") IllegalArgumentException iae) {
+ return DEFAULT_TEXT_CONFLICT_RESOLUTION_MODE;
+ }
+ }
+
+ /**
+ * Sets the state of the debug mode.
+ *
+ * @param mode
+ * the conflict resolution mode to use.
+ */
+ public static void setTextConflictResolutionMode(ConflictResolutionMode mode) {
+ if (mode == null) {
+ throw new IllegalArgumentException();
+ } else {
+ PREFERENCES_SCOPE.put(TEXT_CONFLICT_RESOLUTION_MODE, mode.name());
+ save();
+ }
+ }
+
+ /**
+ * Save the modification of the preference store.
+ */
+ private static void save() {
+ try {
+ PREFERENCES_SCOPE.flush();
+ } catch (BackingStoreException e) {
+ EEFIdeUiPlugin.getPlugin().error(e.getMessage(), e);
+ }
+ }
+}
diff --git a/plugins/org.eclipse.eef.ide.ui/src/org/eclipse/eef/ide/ui/internal/widgets/EEFTextLifecycleManager.java b/plugins/org.eclipse.eef.ide.ui/src/org/eclipse/eef/ide/ui/internal/widgets/EEFTextLifecycleManager.java
index 76106d4de..4d9be6b66 100644
--- a/plugins/org.eclipse.eef.ide.ui/src/org/eclipse/eef/ide/ui/internal/widgets/EEFTextLifecycleManager.java
+++ b/plugins/org.eclipse.eef.ide.ui/src/org/eclipse/eef/ide/ui/internal/widgets/EEFTextLifecycleManager.java
@@ -1,5 +1,5 @@
/*******************************************************************************
- * Copyright (c) 2015, 2016 Obeo.
+ * Copyright (c) 2015, 2017 Obeo.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
@@ -10,6 +10,8 @@
*******************************************************************************/
package org.eclipse.eef.ide.ui.internal.widgets;
+import java.text.MessageFormat;
+import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import org.eclipse.core.runtime.IStatus;
@@ -21,6 +23,7 @@ import org.eclipse.eef.common.api.utils.Util;
import org.eclipse.eef.common.ui.api.EEFWidgetFactory;
import org.eclipse.eef.common.ui.api.IEEFFormContainer;
import org.eclipse.eef.common.ui.api.SWTUtils;
+import org.eclipse.eef.core.api.EEFExpressionUtils;
import org.eclipse.eef.core.api.EditingContextAdapter;
import org.eclipse.eef.core.api.controllers.EEFControllersFactory;
import org.eclipse.eef.core.api.controllers.IEEFTextController;
@@ -29,7 +32,12 @@ import org.eclipse.eef.ide.ui.api.widgets.AbstractEEFWidgetLifecycleManager;
import org.eclipse.eef.ide.ui.api.widgets.EEFStyleHelper;
import org.eclipse.eef.ide.ui.api.widgets.EEFStyleHelper.IEEFTextStyleCallback;
import org.eclipse.eef.ide.ui.internal.EEFIdeUiPlugin;
+import org.eclipse.eef.ide.ui.internal.Messages;
+import org.eclipse.eef.ide.ui.internal.preferences.EEFPreferences;
import org.eclipse.eef.ide.ui.internal.widgets.styles.EEFColor;
+import org.eclipse.emf.ecore.EObject;
+import org.eclipse.emf.ecore.util.EcoreUtil;
+import org.eclipse.jface.dialogs.MessageDialog;
import org.eclipse.sirius.common.interpreter.api.IInterpreter;
import org.eclipse.sirius.common.interpreter.api.IVariableManager;
import org.eclipse.swt.SWT;
@@ -38,9 +46,12 @@ import org.eclipse.swt.events.FocusListener;
import org.eclipse.swt.events.KeyListener;
import org.eclipse.swt.events.ModifyListener;
import org.eclipse.swt.graphics.Color;
+import org.eclipse.swt.graphics.Image;
import org.eclipse.swt.layout.GridData;
import org.eclipse.swt.widgets.Composite;
import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.Widget;
import org.eclipse.ui.forms.widgets.FormToolkit;
/**
@@ -49,6 +60,25 @@ import org.eclipse.ui.forms.widgets.FormToolkit;
* @author sbegaudeau
*/
public class EEFTextLifecycleManager extends AbstractEEFWidgetLifecycleManager {
+ /**
+ * The different ways an edition conflict can be resolved. Used by the default implementation of
+ * {@link EEFTextLifecycleManager#resolveEditionConflict(Shell, String, String, String)}.
+ */
+ public enum ConflictResolutionMode {
+ /**
+ * Use the version being edited in the widget, overriding the new version computed from the current model state.
+ */
+ USE_LOCAL_VERSION,
+ /**
+ * Use the version computed from the current model state, replacing the text being edited by the user in the
+ * widget.
+ */
+ USE_MODEL_VERSION,
+ /**
+ * Ask the user through a simple dialog which version to keep.
+ */
+ ASK_USER
+ }
/**
* This constant is used in order to tell SWT that the text area should be 300px wide even if it is not useful. The
@@ -104,6 +134,12 @@ public class EEFTextLifecycleManager extends AbstractEEFWidgetLifecycleManager {
private AtomicBoolean updateInProgress = new AtomicBoolean(false);
/**
+ * True only while we are reacting to a notification that the underlying element has been locked by someone else.
+ * When this is the case, we must avoid any attempt to apply our current widget state to the model (it will fail).
+ */
+ private AtomicBoolean lockedByOtherInProgress = new AtomicBoolean(false);
+
+ /**
* The reference value of the text, as last rendered from the state of the actual model.
*/
private String referenceValue = ""; //$NON-NLS-1$
@@ -113,6 +149,76 @@ public class EEFTextLifecycleManager extends AbstractEEFWidgetLifecycleManager {
*/
private boolean isDirty;
+ // CHECKSTYLE:OFF
+ /**
+ * A simple data record to remember un-commited user input for recovery in case of concurrent changes that could
+ * override this input.
+ */
+ private static class Memento {
+ /**
+ * The key used to attach the user input memento to the widget.
+ */
+ public static final String KEY = "eef.widget.text.memento"; //$NON-NLS-1$
+
+ /**
+ * The widget description that was current when the memento was created.
+ */
+ public final EEFTextDescription description;
+ /**
+ * The "self" target element that was current when the memento was created.
+ */
+ public final Object self;
+ /**
+ * The reference value corresponding to the pristine text computed from the model by the valueExpression.
+ */
+ public final String referenceValue;
+ /**
+ * The last (full) value of the text widget entered by the user but not commited yet.
+ */
+ public final String userInput;
+
+ public Memento(EEFTextDescription description, Object self, String referenceValue, String userInpu) {
+ this.description = description;
+ this.self = self;
+ this.referenceValue = referenceValue;
+ this.userInput = userInpu;
+ }
+
+ public boolean appliesTo(EEFTextLifecycleManager lm) {
+ return this.description == lm.description && this.self == lm.variableManager.getVariables().get(EEFExpressionUtils.SELF);
+ }
+
+ public void store(Widget w) {
+ w.setData(KEY, this);
+ }
+
+ public static Memento of(Widget w) {
+ Object data = w.getData(KEY);
+ if (data instanceof Memento) {
+ return (Memento) data;
+ } else {
+ return null;
+ }
+ }
+
+ public static void remove(Widget w) {
+ w.setData(KEY, null);
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder();
+ String newLine = "\n"; //$NON-NLS-1$
+ sb.append("Desc: " + EcoreUtil.getURI(description)).append(newLine); //$NON-NLS-1$
+ sb.append("Self: " + EcoreUtil.getURI((EObject) self)).append(newLine); //$NON-NLS-1$
+ sb.append("Reference Value: " + referenceValue).append(newLine); //$NON-NLS-1$
+ sb.append("User Input: " + userInput).append(newLine); //$NON-NLS-1$
+ sb.append(newLine);
+ return sb.toString();
+ }
+ }
+ // CHECKSTYLE:ON
+
/**
* The constructor.
*
@@ -212,12 +318,16 @@ public class EEFTextLifecycleManager extends AbstractEEFWidgetLifecycleManager {
this.modifyListener = (event) -> {
if (!this.container.isRenderingInProgress() && !updateInProgress.get()) {
this.isDirty = true;
+ Object self = this.variableManager.getVariables().get(EEFExpressionUtils.SELF);
+ String userInput = ((StyledText) event.widget).getText();
+ Memento memento = new Memento(this.description, self, this.referenceValue, userInput);
+ memento.store(event.widget);
}
};
this.text.addModifyListener(this.modifyListener);
this.focusListener = SWTUtils.focusLostAdapter((event) -> {
- if (!this.container.isRenderingInProgress() && this.isDirty) {
+ if (!this.lockedByOtherInProgress.get() && !this.container.isRenderingInProgress() && this.isDirty) {
this.updateValue(false);
}
});
@@ -234,12 +344,9 @@ public class EEFTextLifecycleManager extends AbstractEEFWidgetLifecycleManager {
this.controller.onNewValue((value) -> {
if (!text.isDisposed()) {
- String display = ""; //$NON-NLS-1$
- if (value != null) {
- display = Util.firstNonNull(value.toString(), display);
- }
- if (!(text.getText() != null && text.getText().equals(display))) {
- text.setText(display);
+ String newDisplayText = computeNewText(value);
+ if (!(text.getText() != null && text.getText().equals(newDisplayText))) {
+ text.setText(newDisplayText);
referenceValue = text.getText();
}
this.setStyle();
@@ -247,10 +354,115 @@ public class EEFTextLifecycleManager extends AbstractEEFWidgetLifecycleManager {
text.setEnabled(true);
}
}
+
});
}
/**
+ * Determine the new textual value to display in the widget.
+ *
+ * @param value
+ * the value computed from the model.
+ * @return the textual value to display in the widget.
+ */
+ private String computeNewText(Object value) {
+ String[] newDisplayText = { "" }; //$NON-NLS-1$
+ if (value != null) {
+ newDisplayText[0] = Util.firstNonNull(value.toString(), newDisplayText[0]);
+ }
+ Memento m = Memento.of(text);
+ if (m != null) {
+ boolean resettingToPreviousReferenceValue = Objects.equals(newDisplayText[0], m.referenceValue);
+ boolean userHasUncommitedInput = !Objects.equals(newDisplayText[0], m.userInput);
+ if (m.appliesTo(this) && userHasUncommitedInput) {
+ if (resettingToPreviousReferenceValue) {
+ // Custom user input overrides resetting the same previous referenceValue.
+ newDisplayText[0] = m.userInput;
+ } else if (!Objects.equals(m.userInput, newDisplayText[0])) {
+ // Conflict must be resolved somehow.
+ newDisplayText[0] = resolveEditionConflict(this.text.getShell(), m.referenceValue, m.userInput, newDisplayText[0]);
+ }
+ }
+ Memento.remove(text);
+ }
+ return newDisplayText[0];
+ }
+
+ /**
+ * Handle conflicts between un-commited changes in the widget and concurrent changes in the model that produce a
+ * different value than the original one seen by the user.
+ *
+ * @param shell
+ * the shell to use if user interaction is needed.
+ * @param originalValue
+ * the original, common value, before the user started editing and before the concurrent model change
+ * produced a new text.
+ * @param localEditedVersion
+ * the value as edited by the user, and seen in the UI.
+ * @param newValueFromModel
+ * the new value produced from the new model state.
+ * @return the new value to use in the text field.
+ */
+ protected String resolveEditionConflict(Shell shell, String originalValue, String localEditedVersion, final String newValueFromModel) {
+ String result;
+ switch (EEFPreferences.getTextConflictResolutionMode()) {
+ case USE_LOCAL_VERSION:
+ result = localEditedVersion;
+ break;
+ case USE_MODEL_VERSION:
+ result = newValueFromModel;
+ break;
+ case ASK_USER:
+ result = askUserToResolveConflict(shell, originalValue, localEditedVersion, newValueFromModel);
+ break;
+ default:
+ throw new IllegalStateException();
+ }
+ return result;
+ }
+
+ /**
+ * Open a simple dialog to inform the user of a conflict and ask him which version to keep.
+ *
+ * @param shell
+ * the shell to use if user interaction is needed.
+ * @param originalValue
+ * the original, common value, before the user started editing and before the concurrent model change
+ * produced a new text.
+ * @param localEditedVersion
+ * the value as edited by the user, and seen in the UI.
+ * @param newValueFromModel
+ * the new value produced from the new model state.
+ * @return the value chosen by the user.
+ */
+ protected String askUserToResolveConflict(Shell shell, String originalValue, String localEditedVersion, String newValueFromModel) {
+ final String[] result = { localEditedVersion };
+ // @formatter:off
+ final String[] choices = {
+ Messages.EEFTextLifecycleManager_conflictDialog_choiceNewModelValue,
+ Messages.EEFTextLifecycleManager_conflictDialog_choiceLocalEditedValue,
+ };
+ // @formatter:on
+ shell.getDisplay().syncExec(() -> {
+ String title = Messages.EEFTextLifecycleManager_conflictDialog_title;
+ String message = MessageFormat.format(Messages.EEFTextLifecycleManager_conflictDialog_message, newValueFromModel, localEditedVersion);
+ Image img = shell.getDisplay().getSystemImage(SWT.ICON_QUESTION);
+ MessageDialog dialog = new MessageDialog(shell, title, img, message, MessageDialog.QUESTION, 0, choices);
+ switch (dialog.open()) {
+ case 0:
+ result[0] = newValueFromModel;
+ break;
+ case 1:
+ result[0] = localEditedVersion;
+ break;
+ default:
+ throw new IllegalStateException();
+ }
+ });
+ return result[0];
+ }
+
+ /**
* Updates the value.
*
* @param force
@@ -269,6 +481,7 @@ public class EEFTextLifecycleManager extends AbstractEEFWidgetLifecycleManager {
refresh();
}
this.isDirty = false;
+ Memento.remove(this.text);
this.setStyle();
} finally {
updateInProgress.set(false);
@@ -328,6 +541,16 @@ public class EEFTextLifecycleManager extends AbstractEEFWidgetLifecycleManager {
}
}
+ @Override
+ protected void lockedByOther() {
+ this.lockedByOtherInProgress.set(true);
+ try {
+ super.lockedByOther();
+ } finally {
+ this.lockedByOtherInProgress.set(false);
+ }
+ }
+
/**
* {@inheritDoc}
*

Back to the top