/* * This file is part of muCommander, http://www.mucommander.com * Copyright (C) 2002-2016 Maxence Bernard * * muCommander is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 3 of the License, or * (at your option) any later version. * * muCommander 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 General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package com.mucommander.ui.dialog.file; import java.awt.BorderLayout; import java.awt.Dimension; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; import java.lang.reflect.InvocationTargetException; import javax.swing.JButton; import javax.swing.JCheckBox; import javax.swing.JComboBox; import javax.swing.JLabel; import javax.swing.JPanel; import javax.swing.SwingUtilities; import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.mucommander.commons.file.util.FileSet; import com.mucommander.commons.file.util.PathUtils; import com.mucommander.job.impl.TransferFileJob; import com.mucommander.text.Translator; import com.mucommander.ui.dialog.DialogToolkit; import com.mucommander.ui.icon.SpinningDial; import com.mucommander.ui.layout.YBoxPanel; import com.mucommander.ui.main.MainFrame; import com.mucommander.ui.text.FilePathField; /** * This class is an abstract dialog which allows the user to enter the destination of a transfer in a text field * and control some options such as the default action to perform when a file already exists in the destination, or * if the files should be checked for integrity. * <p> * The initial path displayed in the text field is the one returned by {@link #computeInitialPath(FileSet)}. * When the dialog is confirmed by the user, either by pressing the 'OK' button or the 'Enter' key, the destination * path is resolved and checked with {@link #isValidDestination(PathUtils.ResolvedDestination, String)}. If the * path is a valid destination, a job instance is created using * {@link #createTransferFileJob(ProgressDialog, PathUtils.ResolvedDestination, int)} and started. If it isn't, * the user is notified with an error message. * </p> * * @author Maxence Bernard */ public abstract class TransferDestinationDialog extends JobDialog implements ActionListener, DocumentListener { private static final Logger LOGGER = LoggerFactory.getLogger(TransferDestinationDialog.class); protected String errorDialogTitle; private boolean enableTransferOptions; private YBoxPanel mainPanel; private FilePathField pathField; private SpinningDial spinningDial; private JComboBox fileExistsActionComboBox; private JCheckBox skipErrorsCheckBox; private JCheckBox verifyIntegrityCheckBox; private JCheckBox runInBackgroundCheckBox; private JButton okButton; /** Background thread that is currently being executed, <code>null</code> if there is none. */ private Thread thread; // Dialog size constraints protected final static Dimension MINIMUM_DIALOG_DIMENSION = new Dimension(360,0); // Dialog width should not exceed 360, height is not an issue (always the same) protected final static Dimension MAXIMUM_DIALOG_DIMENSION = new Dimension(400,10000); private final static int DEFAULT_ACTIONS[] = { FileCollisionDialog.CANCEL_ACTION, FileCollisionDialog.SKIP_ACTION, FileCollisionDialog.OVERWRITE_ACTION, FileCollisionDialog.OVERWRITE_IF_OLDER_ACTION, FileCollisionDialog.RESUME_ACTION, FileCollisionDialog.RENAME_ACTION }; private final static String DEFAULT_ACTIONS_TEXT[] = { FileCollisionDialog.CANCEL_TEXT, FileCollisionDialog.SKIP_TEXT, FileCollisionDialog.OVERWRITE_TEXT, FileCollisionDialog.OVERWRITE_IF_OLDER_TEXT, FileCollisionDialog.RESUME_TEXT, FileCollisionDialog.RENAME_TEXT }; public TransferDestinationDialog(MainFrame mainFrame, FileSet files, String title, String labelText, String okText, String errorDialogTitle, boolean enableTransferOptions) { super(mainFrame, title, files); this.errorDialogTitle = errorDialogTitle; this.enableTransferOptions = enableTransferOptions; mainPanel = new YBoxPanel(); mainPanel.add(new JLabel(labelText+" :")); // Create a path field with auto-completion capabilities pathField = new FilePathField(); JPanel borderPanel = new JPanel(new BorderLayout()); borderPanel.add(pathField, BorderLayout.CENTER); // Spinning dial displayed while I/O-bound operations are being performed in a separate thread spinningDial = new SpinningDial(false); borderPanel.add(new JLabel(spinningDial), BorderLayout.EAST); mainPanel.add(borderPanel); mainPanel.addSpace(10); pathField.getDocument().addDocumentListener(this); // Path field will receive initial focus setInitialFocusComponent(pathField); if(enableTransferOptions) { // Combo box that allows the user to choose the default action when a file already exists in destination mainPanel.add(new JLabel(Translator.get("destination_dialog.file_exists_action")+" :")); fileExistsActionComboBox = new JComboBox(); fileExistsActionComboBox.addItem(Translator.get("ask")); int nbChoices = DEFAULT_ACTIONS_TEXT.length; for(int i=0; i<nbChoices; i++) fileExistsActionComboBox.addItem(DEFAULT_ACTIONS_TEXT[i]); mainPanel.add(fileExistsActionComboBox); mainPanel.addSpace(10); skipErrorsCheckBox = new JCheckBox(Translator.get("destination_dialog.skip_errors")); mainPanel.add(skipErrorsCheckBox); verifyIntegrityCheckBox = new JCheckBox(Translator.get("destination_dialog.verify_integrity")); mainPanel.add(verifyIntegrityCheckBox); } runInBackgroundCheckBox = new JCheckBox(Translator.get("destination_dialog.run_in_background")); mainPanel.add(runInBackgroundCheckBox); getContentPane().add(mainPanel, BorderLayout.NORTH); // Create file details button and OK/cancel buttons and lay them out a single row JPanel fileDetailsPanel = createFileDetailsPanel(); okButton = new JButton(okText); // Prevent the dialog from being validated while the initial path is being set. okButton.setEnabled(false); JButton cancelButton = new JButton(Translator.get("cancel")); YBoxPanel buttonsPanel = new YBoxPanel(10); buttonsPanel.add(createButtonsPanel(createFileDetailsButton(fileDetailsPanel), DialogToolkit.createOKCancelPanel(okButton, cancelButton, getRootPane(), this))); buttonsPanel.add(fileDetailsPanel); getContentPane().add(buttonsPanel, BorderLayout.SOUTH); // Set minimum/maximum dimension setMinimumSize(MINIMUM_DIALOG_DIMENSION); setMaximumSize(MAXIMUM_DIALOG_DIMENSION); // Dispose this dialog when the close window button is pressed setDefaultCloseOperation(DISPOSE_ON_CLOSE); addWindowListener(new WindowAdapter() { @Override public void windowOpened(WindowEvent e) { // Spawn a new thread that retrieves the initial path (I/O-bound) and sets the path field accordingly startThread(new InitialPathRetriever()); } @Override public void windowClosed(WindowEvent e) { // Interrupt any ongoing thread when the dialog has been closed, regardless of how it has been closed. interruptOngoingThread(); } }); } /** * Returns the main panel that contains the path field. * * @return the main panel that contains the path field. */ protected YBoxPanel getMainPanel() { return mainPanel; } /** * Returns the field where the destination path has to be entered. * * @return the field where the destination path has to be entered. */ protected FilePathField getPathField() { return pathField; } /** * Interrupts any ongoing thread and starts the given one. The spinning dial is set to 'animated'. * * @param thread the thread to start */ private synchronized void startThread(Thread thread) { // Interrupt any ongoing thread interruptOngoingThread(); // Spin the dial spinningDial.setAnimated(true); // Start the thread this.thread = thread; thread.start(); } /** * Interrupts the ongoing thread if there is one, does nothing otherwise. */ private synchronized void interruptOngoingThread() { if(thread!=null) { LOGGER.trace("Calling interrupt() on "+thread); thread.interrupt(); // Set the current thread to null thread = null; } } /** * This method checks that the given resolved destination is valid. This implementation returns <code>true</code> * if the resolved destination is not <code>null</code> and, in case there is more than one file to process, if the * destination is a folder that exists. This method can safely be overridden by subclasses to change the behavior. * <p> * Returning <code>true</code> will cause the job to go ahead and be started. Returning <code>false</code> will * pop up an error dialog that notifies the user that the path is incorrect. * </p> * <p> * This method is called from a dedicated thread so that it can safely perform I/O operations without any chance * of locking the event thread. * </p> * * @param resolvedDest the resolved destination * @param destPath the path, as it was entered in the path field * @return <code>true</code> if the given resolved destination is valid */ protected boolean isValidDestination(PathUtils.ResolvedDestination resolvedDest, String destPath) { return (resolvedDest!=null && (files.size()==1 || resolvedDest.getDestinationType()==PathUtils.ResolvedDestination.EXISTING_FOLDER)); } /** * This method is called after the destination has been validated to start the job, with the resolved destination * that has been validated by {@link #isValidDestination(PathUtils.ResolvedDestination, String)}. * * @param resolvedDest the resolved destination */ private void startJob(PathUtils.ResolvedDestination resolvedDest) { int defaultFileExistsAction = FileCollisionDialog.ASK_ACTION; boolean skipErrors = false; boolean verifyIntegrity = false; boolean runInBackground = runInBackgroundCheckBox.isSelected(); if (enableTransferOptions) { // Retrieve default action when a file exists in destination, default choice // (if not specified by the user) is 'Ask' defaultFileExistsAction = fileExistsActionComboBox.getSelectedIndex(); if(defaultFileExistsAction==0) defaultFileExistsAction = FileCollisionDialog.ASK_ACTION; else defaultFileExistsAction = DEFAULT_ACTIONS[defaultFileExistsAction-1]; // Note: we don't remember default action on purpose: we want the user to specify it each time, // it would be too dangerous otherwise. skipErrors = skipErrorsCheckBox.isSelected(); verifyIntegrity = verifyIntegrityCheckBox.isSelected(); } ProgressDialog progressDialog = new ProgressDialog(mainFrame, getProgressDialogTitle()); TransferFileJob job = createTransferFileJob(progressDialog, resolvedDest, defaultFileExistsAction); if(job!=null) { job.setAutoSkipErrors(skipErrors); job.setIntegrityCheckEnabled(verifyIntegrity); job.setRunInBackground(runInBackground); progressDialog.start(job); } } /** * Called when the path has changed while {@link InitialPathRetriever} is running. */ private void textUpdated() { synchronized(this) { if(thread!=null && thread instanceof InitialPathRetriever) { // Interrupt InitialPathRetriever interruptOngoingThread(); // Enable okButton.setEnabled(true); pathField.getDocument().removeDocumentListener(this); } } } ////////////////////////////// // DocumentListener methods // ////////////////////////////// public void insertUpdate(DocumentEvent e) { textUpdated(); } public void removeUpdate(DocumentEvent e) { textUpdated(); } public void changedUpdate(DocumentEvent e) { } /////////////////////////////////// // ActionListener implementation // /////////////////////////////////// public void actionPerformed(ActionEvent e) { Object source = e.getSource(); if(source == okButton) { // Disable the OK button and path field while the current path is being resolved okButton.setEnabled(false); pathField.setEnabled(false); // Start resolving the path startThread(new PathResolver()); } else { // Cancel button dispose(); } } ////////////////////// // Abstract methods // ////////////////////// /** * Called when the dialog has just been created to compute the initial path, based on the user file selection. * * <p>This method is called from a dedicated thread so that it can safely perform I/O operations without any chance * of locking the event thread.</p> * * @param files files that were selected/marked by the user * @return a {@link PathFieldContent} containing the initial path to set in the path field */ protected abstract PathFieldContent computeInitialPath(FileSet files); /** * Called after the dialog has been confirmed by the user and the resolved destination has been * {@link #isValidDestination(PathUtils.ResolvedDestination, String) validated} to create the * {@link TransferFileJob} instance that will subsequently be started. * * <p>This method is called from a dedicated thread so that it can safely perform I/O operations without any chance * of locking the event thread.</p> * * @param progressDialog the progress dialog that will show the job's progression * @param resolvedDest the resolved and validated destination * @param defaultFileExistsAction the value of the 'default action when file exists' choice * @return the {@link TransferFileJob} instance that will subsequently be started */ protected abstract TransferFileJob createTransferFileJob(ProgressDialog progressDialog, PathUtils.ResolvedDestination resolvedDest, int defaultFileExistsAction); /** * Returns the title to be used in the progress dialog. * * @return the title to be used in the progress dialog. */ protected abstract String getProgressDialogTitle(); /////////////////// // Inner classes // /////////////////// /** * Retrieves the initial path to be set in the path field by calling {@link TransferDestinationDialog#computeInitialPath(FileSet)}. * Since this operation can be I/O-bound, it is performed in a separate thread. */ private class InitialPathRetriever extends Thread { /** True if the thread has been interrupted */ private boolean interrupted; @Override public void run() { final PathFieldContent pathFieldContent = computeInitialPath(files); // Perform UI tasks in the AWT event thread try { SwingUtilities.invokeAndWait(new Runnable() { public void run() { spinningDial.setAnimated(false); if(!interrupted) { // Document change events are no longer needed pathField.getDocument().removeDocumentListener(TransferDestinationDialog.this); // Set the path field's text and selection pathFieldContent.feedToPathField(pathField); okButton.setEnabled(true); } } }); } catch(InterruptedException e) { LOGGER.trace("Interrupted", e); } catch(InvocationTargetException e) { LOGGER.debug("Caught exception", e); } // Set the current thread to null synchronized(TransferDestinationDialog.this) { if(thread==this) // This thread may have been interrupted already thread = null; } } /** * Overridden to trap interruptions ({@link #isInterrupted()} doesn't seem to be working as advertised). */ @Override public void interrupt() { super.interrupt(); this.interrupted = true; } } /** * Resolves the path entered in the path field into a {@link PathUtils.ResolvedDestination} instance and validates * it using {@link TransferDestinationDialog#isValidDestination(PathUtils.ResolvedDestination, String)}. * Since both of those operations can be I/O-bound, they are performed in a separate thread. * <p> * If the destination is valid, the job is started using {@link TransferDestinationDialog#startJob(PathUtils.ResolvedDestination)} * and this dialog is disposed. Otherwise, a error dialog is displayed to notify the user that the path he has * entered is invalid and invite him to try again. * </p> */ private class PathResolver extends Thread { /** True if the thread has been interrupted */ private boolean interrupted; @Override public void run() { spinningDial.setAnimated(false); final String destPath = pathField.getText(); // Resolves destination folder (I/O bound) final PathUtils.ResolvedDestination resolvedDest = PathUtils.resolveDestination(destPath, mainFrame.getActivePanel().getCurrentFolder()); // Resolves destination folder (I/O bound) final boolean isValid = isValidDestination(resolvedDest, destPath); // Perform UI tasks in the AWT event thread SwingUtilities.invokeLater(new Runnable() { public void run() { if(interrupted) { dispose(); } else if(isValid) { dispose(); startJob(resolvedDest); } else { showErrorDialog(Translator.get("invalid_path", destPath), errorDialogTitle); // Re-enable the OK button and path field so that a new path can be entered okButton.setEnabled(true); pathField.setEnabled(true); } } }); // Set the current thread to null synchronized(TransferDestinationDialog.this) { if(thread==this) // This thread may have been interrupted already thread = null; } } /** * Overridden to trap interruptions ({@link #isInterrupted()} doesn't seem to be working as advertised). */ @Override public void interrupt() { super.interrupt(); this.interrupted = true; } } }