/**
* Copyright (C) 2013-2014 Olaf Lessenich
* Copyright (C) 2014-2015 University of Passau, Germany
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
* MA 02110-1301 USA
*
* Contributors:
* Olaf Lessenich <lessenic@fim.uni-passau.de>
* Georg Seibt <seibt@fim.uni-passau.de>
*/
package de.fosd.jdime.gui;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Pattern;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.concurrent.Task;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonBar;
import javafx.scene.control.ButtonType;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ListView;
import javafx.scene.control.Tab;
import javafx.scene.control.TabPane;
import javafx.scene.control.TextField;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeTableColumn;
import javafx.scene.control.TreeTableRow;
import javafx.scene.control.TreeTableView;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.stage.FileChooser;
import javafx.stage.Modality;
import javafx.stage.Stage;
import javafx.stage.Window;
import de.fosd.jdime.config.JDimeConfig;
import static de.fosd.jdime.config.JDimeConfig.ALLOW_INVALID;
import static de.fosd.jdime.config.JDimeConfig.BUFFERED_LINES;
import static de.fosd.jdime.config.JDimeConfig.DEFAULT_ARGS;
import static de.fosd.jdime.config.JDimeConfig.DEFAULT_BASE;
import static de.fosd.jdime.config.JDimeConfig.DEFAULT_JDIME_EXEC;
import static de.fosd.jdime.config.JDimeConfig.DEFAULT_LEFT;
import static de.fosd.jdime.config.JDimeConfig.DEFAULT_RIGHT;
/**
* A simple JavaFX GUI for JDime.
*/
@SuppressWarnings("unused")
public final class GUI extends Application {
private static final Logger LOG = Logger.getLogger(GUI.class.getCanonicalName());
private static final String TITLE = "JDime";
private static final String JVM_DEBUG_PARAMS = "-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005";
private static final String STARTSCRIPT_JVM_ENV_VAR = "JAVA_OPTS";
private static final Pattern DUMP_GRAPH = Pattern.compile(".*-mode\\s+dumpgraph.*");
@FXML
ListView<String> output;
@FXML
TextField left;
@FXML
TextField base;
@FXML
TextField right;
@FXML
TextField jDime;
@FXML
TextField cmdArgs;
@FXML
CheckBox debugMode;
@FXML
TabPane tabPane;
@FXML
Tab outputTab;
@FXML
private StackPane cancelPane;
@FXML
private GridPane controlsPane;
@FXML
private Button historyPrevious;
@FXML
private Button historyNext;
private int bufferedLines;
private boolean allowInvalid;
private File lastChooseDir;
private List<TextField> textFields;
private long histHashLastSave;
private History history;
private Task<Void> jDimeExec;
private Process jDimeProcess;
/**
* Launches the GUI with the given <code>args</code>.
*
* @param args
* the command line arguments
*/
public static void main(String[] args) {
launch(args);
}
@Override
public void start(Stage primaryStage) throws Exception {
FXMLLoader loader = new FXMLLoader(getClass().getResource(getClass().getSimpleName() + ".fxml"));
loader.setController(this);
Parent root = loader.load();
Scene scene = new Scene(root);
textFields = Arrays.asList(left, base, right, jDime, cmdArgs);
loadConfig();
history = new History();
histHashLastSave = history.storeHash();
historyNext.disableProperty().bind(history.hasNextProperty().not());
historyPrevious.disableProperty().bind(history.hasPreviousProperty().not());
primaryStage.setTitle(TITLE);
primaryStage.setScene(scene);
primaryStage.setOnCloseRequest(event -> {
if (histHashLastSave != history.storeHash()) {
Optional<ButtonType> res = showYesNoDialog("Unsaved changes to the history. Save now?", primaryStage.getOwner());
if (res.isPresent()) {
if (res.get().getButtonData() == ButtonBar.ButtonData.YES) {
saveClicked(new ActionEvent(primaryStage, event.getTarget()));
} else if (res.get().getButtonData() == ButtonBar.ButtonData.CANCEL_CLOSE) {
event.consume();
}
} else {
event.consume();
}
}
});
primaryStage.show();
}
/**
* Loads the config values from the <code>JDimeConfig</code>.
*/
private void loadConfig() {
JDimeConfig config = new JDimeConfig();
config.get(DEFAULT_JDIME_EXEC).ifPresent(s -> jDime.setText(s.trim()));
config.get(DEFAULT_ARGS).ifPresent(s -> cmdArgs.setText(s.trim()));
config.get(DEFAULT_LEFT).ifPresent(left::setText);
config.get(DEFAULT_BASE).ifPresent(base::setText);
config.get(DEFAULT_RIGHT).ifPresent(right::setText);
bufferedLines = config.getInteger(BUFFERED_LINES).orElse(100);
allowInvalid = config.getBoolean(ALLOW_INVALID).orElse(false);
}
/**
* Shows a <code>FileChooser</code> and returns the chosen <code>File</code>. Sets <code>lastChooseDir</code>
* to the parent file of the returned <code>File</code>.
*
* @param event
* the <code>ActionEvent</code> that occurred in the action listener
*
* @return the chosen <code>File</code> or <code>null</code> if the dialog was closed
*/
private File getChosenFile(ActionEvent event) {
FileChooser chooser = new FileChooser();
Window window = ((Node) event.getTarget()).getScene().getWindow();
if (lastChooseDir != null && lastChooseDir.isDirectory()) {
chooser.setInitialDirectory(lastChooseDir);
}
return chooser.showOpenDialog(window);
}
/**
* Called when the 'Choose' button for the left file is clicked.
*
* @param event
* the <code>ActionEvent</code> that occurred
*/
public void chooseLeft(ActionEvent event) {
File leftArtifact = getChosenFile(event);
if (leftArtifact != null) {
lastChooseDir = leftArtifact.getParentFile();
left.setText(leftArtifact.getAbsolutePath());
}
}
/**
* Called when the 'Choose' button for the base file is clicked.
*
* @param event
* the <code>ActionEvent</code> that occurred
*/
public void chooseBase(ActionEvent event) {
File baseArtifact = getChosenFile(event);
if (baseArtifact != null) {
lastChooseDir = baseArtifact.getParentFile();
base.setText(baseArtifact.getAbsolutePath());
}
}
/**
* Called when the 'Choose' button for the right file is clicked.
*
* @param event
* the <code>ActionEvent</code> that occurred
*/
public void chooseRight(ActionEvent event) {
File rightArtifact = getChosenFile(event);
if (rightArtifact != null) {
lastChooseDir = rightArtifact.getParentFile();
right.setText(rightArtifact.getAbsolutePath());
}
}
/**
* Called when the 'Choose' button for the JDime executable is clicked.
*
* @param event
* the <code>ActionEvent</code> that occurred
*/
public void chooseJDime(ActionEvent event) {
File jDimeBinary = getChosenFile(event);
if (jDimeBinary != null) {
lastChooseDir = jDimeBinary.getParentFile();
jDime.setText(jDimeBinary.getAbsolutePath());
}
}
/**
* Called when the '{@literal >}' button for the history is clicked.
*/
public void historyNext() {
history.applyNext(this);
}
/**
* Called when the '{@literal <}' button for the history is clicked.
*/
public void historyPrevious() {
history.applyPrevious(this);
}
/**
* Called when the history 'Save' button is clicked.
*/
public void saveClicked(ActionEvent event) {
Window owner = ((Node) event.getTarget()).getScene().getWindow();
FileChooser chooser = new FileChooser();
chooser.getExtensionFilters().addAll(new FileChooser.ExtensionFilter("History File", "*.xml"));
chooser.setInitialFileName("History");
File file = chooser.showSaveDialog(owner);
if (file != null) {
try {
history.store(file);
histHashLastSave = history.storeHash();
showAlert("Save successful!", Alert.AlertType.INFORMATION);
} catch (IOException e) {
LOG.log(Level.WARNING, e, () -> "Could not store the history in " + file.getAbsolutePath());
showAlert("Save failed. See the log for more info.", Alert.AlertType.WARNING);
}
}
}
/**
* Called when the history 'Load' button is clicked.
*/
public void loadClicked(ActionEvent event) {
Window owner = ((Node) event.getTarget()).getScene().getWindow();
if (histHashLastSave != history.storeHash()) {
Optional<ButtonType> res = showYesNoDialog("Save the current history before overwriting it?", owner);
if (res.isPresent()) {
if (res.get().getButtonData() == ButtonBar.ButtonData.YES) {
saveClicked(event);
} else if (res.get().getButtonData() == ButtonBar.ButtonData.CANCEL_CLOSE) {
return;
}
} else {
return;
}
}
FileChooser chooser = new FileChooser();
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("History File", "*.xml"));
File file = chooser.showOpenDialog(owner);
if (file != null) {
try {
Optional<History> loadedHistory = History.load(file);
if (loadedHistory.isPresent()) {
history = loadedHistory.get();
histHashLastSave = history.storeHash();
historyNext.disableProperty().bind(history.hasNextProperty().not());
historyPrevious.disableProperty().bind(history.hasPreviousProperty().not());
history.apply(this, history.getSize());
showAlert("Load successful!", Alert.AlertType.INFORMATION);
}
} catch (IOException e) {
LOG.log(Level.WARNING, e, () -> "Could not load the history from " + file.getAbsolutePath());
showAlert("Load failed. See the log for more info.", Alert.AlertType.WARNING);
}
}
}
/**
* Shows a simple <code>Alert</code> of the given <code>type</code>.
*
* @param content
* the content <code>String</code> for the <code>Alert</code>
* @param type
* the type of the <code>Alert</code>
*/
private void showAlert(String content, Alert.AlertType type) {
Alert alert = new Alert(type);
alert.setHeaderText(null);
alert.setContentText(content);
alert.show();
}
/**
* Shows a blocking Yes/No/Cancel dialog and returns a <code>ButtonType</code> whose <code>ButtonData</code> is one
* of the corresponding enum values ({@link ButtonBar.ButtonData#YES}, {@link ButtonBar.ButtonData#NO},
* {@link ButtonBar.ButtonData#CANCEL_CLOSE}).
*
* @param content
* the content <code>String</code> for the <code>Alert</code>
* @param owner
* the owner <code>Window</code> for the dialog, may be null in which case the dialog will be non-modal
* @return the optional <code>ButtonType</code> returned from {@link Alert#showAndWait()}
*/
private Optional<ButtonType> showYesNoDialog(String content, Window owner) {
Alert alert = new Alert(Alert.AlertType.CONFIRMATION);
ButtonType yesType = new ButtonType("Yes", ButtonBar.ButtonData.YES);
ButtonType noType = new ButtonType("No", ButtonBar.ButtonData.NO);
ButtonType cancel = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
alert.setHeaderText(null);
alert.setContentText(content);
alert.getButtonTypes().setAll(yesType, noType, cancel);
if (owner != null) {
alert.initOwner(owner);
alert.initModality(Modality.WINDOW_MODAL);
}
return alert.showAndWait();
}
/**
* Called when the 'Cancel' button is clicked.
*/
public void cancelClicked() throws InterruptedException {
jDimeProcess.destroyForcibly().waitFor();
jDimeExec.cancel(true);
}
/**
* Called when the 'Run' button is clicked.
*/
public void runClicked() {
boolean valid = textFields.stream().allMatch(tf -> {
if (tf == cmdArgs) {
return true;
}
if (tf == base) {
return tf.getText().trim().isEmpty() || new File(tf.getText()).exists();
}
return new File(tf.getText()).exists();
});
if (!valid && !allowInvalid) {
return;
}
jDimeExec = new Task<Void>() {
@Override
protected Void call() throws Exception {
ProcessBuilder builder = new ProcessBuilder();
List<String> command = new ArrayList<>();
String input;
input = jDime.getText().trim();
if (!input.isEmpty()) {
command.add(input);
}
List<String> args = Arrays.asList(cmdArgs.getText().trim().split("\\s+"));
if (!args.isEmpty()) {
command.addAll(args);
}
input = left.getText().trim();
if (!input.isEmpty()) {
command.add(input);
}
input = base.getText().trim();
if (!input.isEmpty()) {
command.add(input);
}
input = right.getText().trim();
if (!input.isEmpty()) {
command.add(input);
}
builder.command(command);
builder.redirectErrorStream(true);
File workingDir = new File(jDime.getText()).getParentFile();
if (workingDir != null && workingDir.exists()) {
builder.directory(workingDir);
}
if (debugMode.isSelected()) {
builder.environment().put(STARTSCRIPT_JVM_ENV_VAR, JVM_DEBUG_PARAMS);
}
jDimeProcess = builder.start();
Charset cs = StandardCharsets.UTF_8;
try (BufferedReader r = new BufferedReader(new InputStreamReader(jDimeProcess.getInputStream(), cs))) {
List<String> lines = new ArrayList<>(bufferedLines + 1);
boolean stop = false;
String line;
do {
do {
if ((line = r.readLine()) != null) {
lines.add(line);
if (lines.size() >= bufferedLines) {
List<String> toAdd = new ArrayList<>(lines);
Platform.runLater(() -> output.getItems().addAll(toAdd));
lines.clear();
}
} else {
stop = true;
}
} while (r.ready());
try {
Thread.sleep(100);
} catch (InterruptedException e) {
stop = true;
}
} while (!Thread.interrupted() && !stop && jDimeProcess.isAlive());
Platform.runLater(() -> output.getItems().addAll(lines));
}
try {
jDimeProcess.waitFor();
} catch (InterruptedException ignored) {
jDimeProcess.destroyForcibly();
}
return null;
}
};
jDimeExec.setOnRunning(event -> {
controlsPane.setDisable(true);
cancelPane.setVisible(true);
});
jDimeExec.setOnSucceeded(event -> {
tabPane.getTabs().retainAll(outputTab);
if (isDumpGraph(cmdArgs.getText())) {
GraphvizParser parser = new GraphvizParser(output.getItems());
parser.setOnSucceeded(roots -> {
addTabs(parser.getValue());
reactivate();
});
parser.setOnFailed(event1 -> {
LOG.log(Level.WARNING, event1.getSource().getException(), () -> "Graphviz parsing failed.");
reactivate();
});
new Thread(parser).start();
} else {
reactivate();
}
});
jDimeExec.setOnCancelled(event -> reactivate());
jDimeExec.setOnFailed(event -> {
LOG.log(Level.WARNING, event.getSource().getException(), () -> "JDime execution failed.");
reactivate();
});
output.setItems(FXCollections.observableArrayList());
Thread jDimeT = new Thread(jDimeExec);
jDimeT.setName("JDime Task Thread");
jDimeT.start();
}
/**
* Saves the current state of the GUI to the history and then reactivates the user controls.
*/
private void reactivate() {
history.storeCurrent(this);
cancelPane.setVisible(false);
controlsPane.setDisable(false);
}
/**
* Adds <code>Tab</code>s containing <code>TreeTableView</code>s for every <code>TreeDumpNode</code> root in the
* given <code>List</code>.
*
* @param roots
* the roots of the trees to display
*/
private void addTabs(List<TreeItem<TreeDumpNode>> roots) {
roots.forEach(root -> tabPane.getTabs().add(getTreeTableViewTab(root)));
}
/**
* Returns whether the given JDime command line arguments contain the parameters necessary to activate graph
* dump mode.
*
* @param cmdArgs
* the JDime command line arguments
* @return true iff JDime starts in dump graph mode with the given command line arguments
*/
static boolean isDumpGraph(String cmdArgs) {
return DUMP_GRAPH.matcher(cmdArgs).matches();
}
/**
* Returns a <code>Tab</code> containing a <code>TreeTableView</code> displaying the with the given
* <code>root</code>.
*
* @param root
* the root of the tree to display
* @return a <code>Tab</code> containing the tree
*/
static Tab getTreeTableViewTab(TreeItem<TreeDumpNode> root) {
TreeTableView<TreeDumpNode> tableView = new TreeTableView<>(root);
TreeTableColumn<TreeDumpNode, String> id = new TreeTableColumn<>("ID");
TreeTableColumn<TreeDumpNode, String> label = new TreeTableColumn<>("AST Type");
tableView.setRowFactory(param -> {
TreeTableRow<TreeDumpNode> row = new TreeTableRow<>();
TreeDumpNode node = row.getItem();
if (node == null) {
return row;
}
String color = node.getFillColor();
if (color != null) {
try {
BackgroundFill fill = new BackgroundFill(Color.valueOf(color), CornerRadii.EMPTY, Insets.EMPTY);
row.setBackground(new Background(fill));
} catch (IllegalArgumentException e) {
LOG.fine(() -> String.format("Could not convert '%s' to a JavaFX Color.", color));
}
}
return row;
});
id.setCellValueFactory(param -> param.getValue().getValue().idProperty());
label.setCellValueFactory(param -> param.getValue().getValue().labelProperty());
tableView.getColumns().setAll(Arrays.asList(label, id));
return new Tab("Tree View", tableView);
}
}