/*
 * FileContentManager.java 4 juil. 07
 *
 * Sweet Home 3D, Copyright (c) 2007 Emmanuel PUYBARET / eTeks <info@eteks.com>
 *
 * This program 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 2 of the License, or
 * (at your option) any later version.
 *
 * This program 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, write to the Free Software
 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 */
package com.eteks.sweethome3d.swing;

import java.awt.Color;
import java.awt.Component;
import java.awt.Cursor;
import java.awt.EventQueue;
import java.awt.FileDialog;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.io.File;
import java.io.FilenameFilter;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Vector;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;

import javax.swing.AbstractAction;
import javax.swing.Action;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JFileChooser;
import javax.swing.JOptionPane;
import javax.swing.JTree;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
import javax.swing.event.TreeExpansionEvent;
import javax.swing.event.TreeExpansionListener;
import javax.swing.event.TreeSelectionEvent;
import javax.swing.event.TreeSelectionListener;
import javax.swing.filechooser.FileFilter;
import javax.swing.tree.DefaultMutableTreeNode;
import javax.swing.tree.DefaultTreeCellRenderer;
import javax.swing.tree.DefaultTreeModel;
import javax.swing.tree.TreeNode;
import javax.swing.tree.TreePath;
import javax.swing.tree.TreeSelectionModel;

import com.eteks.sweethome3d.model.Content;
import com.eteks.sweethome3d.model.RecorderException;
import com.eteks.sweethome3d.model.UserPreferences;
import com.eteks.sweethome3d.tools.OperatingSystem;
import com.eteks.sweethome3d.tools.URLContent;
import com.eteks.sweethome3d.viewcontroller.ContentManager;
import com.eteks.sweethome3d.viewcontroller.View;

/**
 * Content manager for files with Swing file choosers.
 * @author Emmanuel Puybaret
 */
public class FileContentManager implements ContentManager {
  private static final String OBJ_EXTENSION = ".obj";
  /**
   * Supported OBJ filter.
   */
  private static final FileFilter [] OBJ_FILTER = {
      new FileFilter() {
        @Override
        public boolean accept(File file) {
          // Accept directories and .obj files
          return file.isDirectory()
              || file.getName().toLowerCase().endsWith(OBJ_EXTENSION);
        }
        
        @Override
        public String getDescription() {
          return "OBJ - Wavefront";
        }
      }};
  /**
   * Supported 3D model file filters.
   */
  private static final FileFilter [] MODEL_FILTERS = {
     OBJ_FILTER [0],
     new FileFilter() {
       @Override
       public boolean accept(File file) {
         // Accept directories and LWS files
         return file.isDirectory()
                || file.getName().toLowerCase().endsWith(".lws");
       }
   
       @Override
       public String getDescription() {
         return "LWS - LightWave Scene";
       }
     },
     new FileFilter() {
       @Override
       public boolean accept(File file) {
         // Accept directories and 3DS files
         return file.isDirectory()
                || file.getName().toLowerCase().endsWith(".3ds");
       }
   
       @Override
       public String getDescription() {
         return "3DS - 3D Studio";
       }
     },
     new FileFilter() {
       @Override
       public boolean accept(File file) {
         // Accept directories and 3DS files
         return file.isDirectory()
                || file.getName().toLowerCase().endsWith(".dae");
       }
   
       @Override
       public String getDescription() {
         return "DAE - Collada";
       }
     },
     new FileFilter() {
       @Override
       public boolean accept(File file) {
         // Accept directories and ZIP files
         return file.isDirectory()
                || file.getName().toLowerCase().endsWith(".zip");
       }
   
       @Override
       public String getDescription() {
         return "ZIP";
       }
     }};
  private static final String PNG_EXTENSION = ".png";
  /**
   * Supported PNG filter.
   */
  private static final FileFilter [] PNG_FILTER = {
      new FileFilter() {
        @Override
        public boolean accept(File file) {
          // Accept directories and .png files
          return file.isDirectory()
              || file.getName().toLowerCase().endsWith(PNG_EXTENSION);
        }
        
        @Override
        public String getDescription() {
          return "PNG";
        }
      }};
  private static final String JPEG_EXTENSION = ".jpg";
  /**
   * Supported JPEG filter.
   */
  private static final FileFilter [] JPEG_FILTER = {
      new FileFilter() {
        @Override
        public boolean accept(File file) {
          // Accept directories and .png files
          return file.isDirectory()
              || file.getName().toLowerCase().endsWith(JPEG_EXTENSION)
              || file.getName().toLowerCase().endsWith("jpeg");
        }
        
        @Override
        public String getDescription() {
          return "JPEG";
        }
      }};
  /**
   * Supported image file filters.
   */
  private static final FileFilter [] IMAGE_FILTERS = {
      new FileFilter() {
        @Override
        public boolean accept(File file) {
          // Accept directories and .sh3d files
          return file.isDirectory()
                 || file.getName().toLowerCase().endsWith(".bmp")
                 || file.getName().toLowerCase().endsWith(".wbmp");
        }
    
        @Override
        public String getDescription() {
          return "BMP";
        }
      },
      new FileFilter() {
        @Override
        public boolean accept(File file) {
          // Accept directories and GIF files
          return file.isDirectory()
                 || file.getName().toLowerCase().endsWith(".gif");
        }
    
        @Override
        public String getDescription() {
          return "GIF";
        }
      },
      JPEG_FILTER [0], 
      PNG_FILTER [0]};
  private static final String MOV_EXTENSION = ".mov";
  /**
   * Supported MOV filter.
   */
  private static final FileFilter [] MOV_FILTER = {
      new FileFilter() {
        @Override
        public boolean accept(File file) {
          // Accept directories and .mov files
          return file.isDirectory()
              || file.getName().toLowerCase().endsWith(MOV_EXTENSION);
        }
        
        @Override
        public String getDescription() {
          return "MOV";
        }
      }};
  private static final String PDF_EXTENSION = ".pdf";
  /**
   * Supported PDF filter.
   */
  private static final FileFilter [] PDF_FILTER = {
      new FileFilter() {
        @Override
        public boolean accept(File file) {
          // Accept directories and .pdf files
          return file.isDirectory()
              || file.getName().toLowerCase().endsWith(PDF_EXTENSION);
        }
        
        @Override
        public String getDescription() {
          return "PDF";
        }
      }};
  private static final String CSV_EXTENSION = ".csv";
  /**
   * Supported CSV filter.
   */
  private static final FileFilter [] CSV_FILTER = {
      new FileFilter() {
        @Override
        public boolean accept(File file) {
          // Accept directories and .csv files
          return file.isDirectory()
              || file.getName().toLowerCase().endsWith(CSV_EXTENSION);
        }
        
        @Override
        public String getDescription() {
          return "CSV - Tab Separated Values";
        }
      }};
  private static final String SVG_EXTENSION = ".svg";
  /**
   * Supported SVG filter.
   */
  private static final FileFilter [] SVG_FILTER = {
      new FileFilter() {
        @Override
        public boolean accept(File file) {
          // Accept directories and .svg files
          return file.isDirectory()
              || file.getName().toLowerCase().endsWith(SVG_EXTENSION);
        }
        
        @Override
        public String getDescription() {
          return "SVG - Scalable Vector Graphics";
        }
      }};
  
  private final UserPreferences           preferences;
  private final String                    sweetHome3DFileExtension;
  private final String                    languageLibraryFileExtension;
  private final String                    furnitureLibraryFileExtension;
  private final String                    texturesLibraryFileExtension;
  private final String                    pluginFileExtension;
  private Map<ContentType, File>          lastDirectories;
  private Map<ContentType, FileFilter []> fileFilters;
  private Map<ContentType, String>        defaultFileExtensions;

  public FileContentManager(final UserPreferences preferences) {  
    this.preferences = preferences;
    this.sweetHome3DFileExtension = preferences.getLocalizedString(FileContentManager.class, "homeExtension");
    this.languageLibraryFileExtension = preferences.getLocalizedString(FileContentManager.class, "languageLibraryExtension");
    this.furnitureLibraryFileExtension = preferences.getLocalizedString(FileContentManager.class, "furnitureLibraryExtension");
    this.texturesLibraryFileExtension = preferences.getLocalizedString(FileContentManager.class, "texturesLibraryExtension");
    this.pluginFileExtension = preferences.getLocalizedString(FileContentManager.class, "pluginExtension");
    this.lastDirectories = new HashMap<ContentManager.ContentType, File>();
    
    // Fill file filters map
    this.fileFilters = new HashMap<ContentType, FileFilter[]>();
    this.fileFilters.put(ContentType.MODEL, MODEL_FILTERS);
    this.fileFilters.put(ContentType.IMAGE, IMAGE_FILTERS);
    this.fileFilters.put(ContentType.MOV, MOV_FILTER);
    this.fileFilters.put(ContentType.PNG, PNG_FILTER);
    this.fileFilters.put(ContentType.JPEG, JPEG_FILTER);
    this.fileFilters.put(ContentType.PDF, PDF_FILTER);
    this.fileFilters.put(ContentType.CSV, CSV_FILTER);
    this.fileFilters.put(ContentType.SVG, SVG_FILTER);
    this.fileFilters.put(ContentType.OBJ, OBJ_FILTER);
    this.fileFilters.put(ContentType.SWEET_HOME_3D, new FileFilter [] {
        new FileFilter() {
          @Override
          public boolean accept(File file) {
            // Accept directories and .sh3d files
            return file.isDirectory()
                || file.getName().toLowerCase().endsWith(sweetHome3DFileExtension);
          }
          
          @Override
          public String getDescription() {
            return preferences.getLocalizedString(FileContentManager.class, "homeDescription");
          }
        }
      });
    this.fileFilters.put(ContentType.LANGUAGE_LIBRARY, new FileFilter [] {
        new FileFilter() {
          @Override
          public boolean accept(File file) {
            // Accept directories and .sh3f files
            return file.isDirectory()
                || file.getName().toLowerCase().endsWith(languageLibraryFileExtension);
          }
         
          @Override
          public String getDescription() {
            return preferences.getLocalizedString(FileContentManager.class, "languageLibraryDescription");
          }
        }
      });
    this.fileFilters.put(ContentType.FURNITURE_LIBRARY, new FileFilter [] {
        new FileFilter() {
          @Override
          public boolean accept(File file) {
            // Accept directories and .sh3f files
            return file.isDirectory()
                || file.getName().toLowerCase().endsWith(furnitureLibraryFileExtension);
          }
          
          @Override
          public String getDescription() {
            return preferences.getLocalizedString(FileContentManager.class, "furnitureLibraryDescription");
          }
        }
      });
    this.fileFilters.put(ContentType.TEXTURES_LIBRARY, new FileFilter [] {
        new FileFilter() {
          @Override
          public boolean accept(File file) {
            // Accept directories and .sh3f files
            return file.isDirectory()
                || file.getName().toLowerCase().endsWith(texturesLibraryFileExtension);
          }
         
          @Override
          public String getDescription() {
            return preferences.getLocalizedString(FileContentManager.class, "texturesLibraryDescription");
          }
        }
      });
    this.fileFilters.put(ContentType.PLUGIN, new FileFilter [] {
        new FileFilter() {
          @Override
          public boolean accept(File file) {
            // Accept directories and .sh3f files
            return file.isDirectory()
                || file.getName().toLowerCase().endsWith(pluginFileExtension);
          }
         
          @Override
          public String getDescription() {
            return preferences.getLocalizedString(FileContentManager.class, "pluginDescription");
          }
        }
      });
    this.fileFilters.put(ContentType.PHOTOS_DIRECTORY, new FileFilter [] {
        new FileFilter() {
          @Override
          public boolean accept(File file) {
            // Accept directories only
            return file.isDirectory();
          }
         
          @Override
          public String getDescription() {
            return "Photos";
          }
        }
      });

    // Fill file default extension map
    this.defaultFileExtensions = new HashMap<ContentType, String>();
    this.defaultFileExtensions.put(ContentType.SWEET_HOME_3D, sweetHome3DFileExtension);
    this.defaultFileExtensions.put(ContentType.LANGUAGE_LIBRARY, languageLibraryFileExtension);
    this.defaultFileExtensions.put(ContentType.FURNITURE_LIBRARY, furnitureLibraryFileExtension);
    this.defaultFileExtensions.put(ContentType.TEXTURES_LIBRARY, texturesLibraryFileExtension);
    this.defaultFileExtensions.put(ContentType.PLUGIN, pluginFileExtension);
    this.defaultFileExtensions.put(ContentType.PNG, PNG_EXTENSION);
    this.defaultFileExtensions.put(ContentType.JPEG, JPEG_EXTENSION);
    this.defaultFileExtensions.put(ContentType.MOV, MOV_EXTENSION);
    this.defaultFileExtensions.put(ContentType.PDF, PDF_EXTENSION);
    this.defaultFileExtensions.put(ContentType.CSV, CSV_EXTENSION);
    this.defaultFileExtensions.put(ContentType.SVG, SVG_EXTENSION);
    this.defaultFileExtensions.put(ContentType.OBJ, OBJ_EXTENSION);
  }
  
  /**
   * Returns a {@link URLContent URL content} object that references 
   * the given file path.
   */
  public Content getContent(String contentPath) throws RecorderException {
    try {
      return new URLContent(new File(contentPath).toURI().toURL());
    } catch (IOException ex) {
      throw new RecorderException("Couldn't access to content " + contentPath);
    }
  }
  
  /**
   * Returns the file name of the file path in parameter.
   */
  public String getPresentationName(String contentPath, 
                                    ContentType contentType) {
    switch (contentType) {
      case SWEET_HOME_3D :
        return new File(contentPath).getName();
      default :
        String fileName = new File(contentPath).getName();
        int pointIndex = fileName.lastIndexOf('.');
        if (pointIndex != -1) {
          fileName = fileName.substring(0, pointIndex);
        }
        return fileName;
    }    
  }
  
  /**
   * Returns the file filters available for a given content type.
   * This method may be overridden to add some file filters to existing content types
   * or to define the filters of a user defined content type.
   */
  protected FileFilter [] getFileFilter(ContentType contentPath) {
    if (contentPath == ContentType.USER_DEFINED) {
      throw new IllegalArgumentException("Unknown user defined content type");
    } else {
      return this.fileFilters.get(contentPath);
    }
  }
  
  /**
   * Returns the default file extension of a given content type. 
   * If not <code>null</code> this extension will be appended automatically 
   * to the file name chosen by user in save dialog.
   * This method may be overridden to change the default file extension of an existing content type
   * or to define the default file extension of a user defined content type.
   */
  public String getDefaultFileExtension(ContentType contentType) {
    if (contentType == ContentType.USER_DEFINED) {
      return null;
    } else {
      return this.defaultFileExtensions.get(contentType);
    }
  }
  
  /**
   * Returns <code>true</code> if the file path in parameter is accepted
   * for <code>contentType</code>.
   */
  public boolean isAcceptable(String contentPath, 
                              ContentType contentType) {
    for (FileFilter filter : getFileFilter(contentType)) {
      if (filter.accept(new File(contentPath))) {
        return true;
      }
    }
    return false;
  }
  
  /**
   * Returns <code>true</code> if the given content type is for directories.
   */
  protected boolean isDirectory(ContentType contentType) {
    return contentType == ContentType.PHOTOS_DIRECTORY;
  }
  
  /**
   * Returns the file path chosen by user with an open file dialog.
   * @return the file path or <code>null</code> if user canceled its choice.
   */
  public String showOpenDialog(View        parentView,
                               String      dialogTitle,
                               ContentType contentType) {
    // Use native file dialog under Mac OS X
    if (OperatingSystem.isMacOSX()
        && !isDirectory(contentType)) {
      return showFileDialog(parentView, dialogTitle, contentType, null, false);
    } else {
      return showFileChooser(parentView, dialogTitle, contentType, null, false);
    }
  }
  
  /**
   * Returns the file path chosen by user with a save file dialog.
   * If this file already exists, the user will be prompted whether 
   * he wants to overwrite this existing file. 
   * @return the chosen file path or <code>null</code> if user canceled its choice.
   */
  public String showSaveDialog(View        parentView,
                               String      dialogTitle,
                               ContentType contentType,
                               String      path) {
    String defaultExtension = getDefaultFileExtension(contentType);
    if (path != null) {
      // If path has an extension, remove it and build a path that matches contentType
      int extensionIndex = path.lastIndexOf('.');
      if (extensionIndex != -1) {
        path = path.substring(0, extensionIndex);
        if (defaultExtension != null) {
          path += defaultExtension;
        }
      }
    }
    
    String savedPath;
    // Use native file dialog under Mac OS X    
    if (OperatingSystem.isMacOSX()
        && !isDirectory(contentType)) {
      savedPath = showFileDialog(parentView, dialogTitle, contentType, path, true);
    } else {
      savedPath = showFileChooser(parentView, dialogTitle, contentType, path, true);
    }
    
    boolean addedExtension = false;
    if (savedPath != null) {
      if (defaultExtension != null) {
        if (!savedPath.toLowerCase().endsWith(defaultExtension)) {
          savedPath += defaultExtension;
          addedExtension = true;
        }
      }

      // If no extension was added to file under Mac OS X, 
      // FileDialog already asks to user if he wants to overwrite savedName
      if (OperatingSystem.isMacOSX()
          && !addedExtension) {
        return savedPath;
      }
      if (!isDirectory(contentType)) {
        // If the file exists, prompt user if he wants to overwrite it
        File savedFile = new File(savedPath);
        if (savedFile.exists()
            && !confirmOverwrite(parentView, savedFile.getName())) {
          return showSaveDialog(parentView, dialogTitle, contentType, savedPath);
        }
      }
    }
    return savedPath;
  }
  
  /**
   * Displays an AWT open file dialog.
   */
  private String showFileDialog(View               parentView,
                                String             dialogTitle,
                                final ContentType  contentType,
                                String             path, 
                                boolean            save) {
    FileDialog fileDialog = new FileDialog(
        JOptionPane.getFrameForComponent((JComponent)parentView));

    // Set selected file
    if (save && path != null) {
      fileDialog.setFile(new File(path).getName());
    }
    // Set supported files filter 
    fileDialog.setFilenameFilter(new FilenameFilter() {
        public boolean accept(File dir, String name) {          
          return isAcceptable(new File(dir, name).toString(), contentType);
        }
      });

      // Update directory
    File directory = getLastDirectory(contentType);
    if (directory != null) {
      fileDialog.setDirectory(directory.toString());
    }
    if (save) {
      fileDialog.setMode(FileDialog.SAVE);
    } else {
      fileDialog.setMode(FileDialog.LOAD);
    }

    if (dialogTitle == null) {
      dialogTitle = getFileDialogTitle(save);
    }
    fileDialog.setTitle(dialogTitle);
    
    fileDialog.setVisible(true);
    
    String selectedFile = fileDialog.getFile();
    // If user chose a file
    if (selectedFile != null) {
      // Retrieve directory for future calls
      directory = new File(fileDialog.getDirectory());
      // Store current directory
      setLastDirectory(contentType, directory);
      // Return selected file
      return directory + File.separator + selectedFile;
    } else {
      return null;
    }
  }

  /**
   * Returns the last directory used for the given content type.
   * @return the last directory for <code>contentType</code> or the default last directory 
   *         if it's not set. If <code>contentType</code> is <code>null</code>, the
   *         returned directory will be the default last one or <code>null</code> if it's not set yet.
   */
  protected File getLastDirectory(ContentType contentType) {
    File directory = this.lastDirectories.get(contentType);
    if (directory == null) {
      directory = this.lastDirectories.get(null);
    }
    return directory;
  }

  /**
   * Stores the last directory for the given content type.
   */
  protected void setLastDirectory(ContentType contentType, File directory) {
    this.lastDirectories.put(contentType, directory);
    // Store default last directory in null content 
    this.lastDirectories.put(null, directory);
  }

  /**
   * Displays a Swing open file chooser.
   */
  private String showFileChooser(View          parentView,
                                 String        dialogTitle,
                                 ContentType   contentType,
                                 String        path,
                                 boolean       save) {
    final JFileChooser fileChooser;
    if (isDirectory(contentType)) {
      fileChooser = new DirectoryChooser(this.preferences);
    } else {
      fileChooser = new JFileChooser();
    }
    if (dialogTitle == null) {
      dialogTitle = getFileDialogTitle(save);
    }
    fileChooser.setDialogTitle(dialogTitle);

    // Update directory
    File directory = getLastDirectory(contentType);
    if (directory != null && directory.exists()) {
      fileChooser.setCurrentDirectory(directory);
      if (isDirectory(contentType)) {
        fileChooser.setSelectedFile(directory);
      }
    }    
    // Set selected file
    if (save 
        && path != null
        && (directory == null || !isDirectory(contentType))) {
      fileChooser.setSelectedFile(new File(path));
    }    
    // Set supported files filter 
    FileFilter acceptAllFileFilter = fileChooser.getAcceptAllFileFilter();
    fileChooser.addChoosableFileFilter(acceptAllFileFilter);
    FileFilter [] contentFileFilters = getFileFilter(contentType);
    for (FileFilter filter : contentFileFilters) {
      fileChooser.addChoosableFileFilter(filter);
    }
    // If there's only one file filter, select it 
    if (contentFileFilters.length == 1) {
      fileChooser.setFileFilter(contentFileFilters [0]);
    } else {
      fileChooser.setFileFilter(acceptAllFileFilter);
    }
    int option;
    if (isDirectory(contentType)) {
      option = fileChooser.showDialog((JComponent)parentView,
          this.preferences.getLocalizedString(FileContentManager.class, "selectFolderButton.text"));
    } else if (save) {
      option = fileChooser.showSaveDialog((JComponent)parentView);
    } else {
      option = fileChooser.showOpenDialog((JComponent)parentView);
    }    
    if (option == JFileChooser.APPROVE_OPTION) {
      // Retrieve last directory for future calls
      if (isDirectory(contentType)) {
        directory = fileChooser.getSelectedFile();
      } else {
        directory = fileChooser.getCurrentDirectory();
      }
      // Store last directory
      setLastDirectory(contentType, directory);
      // Return selected file
      return fileChooser.getSelectedFile().toString();
    } else {
      return null;
    }
  }

  /**
   * Returns default file dialog title.
   */
  protected String getFileDialogTitle(boolean save) {
    if (save) {
      return this.preferences.getLocalizedString(FileContentManager.class, "saveDialog.title");
    } else {
      return this.preferences.getLocalizedString(FileContentManager.class, "openDialog.title");
    }
  }
    
  /**
   * Displays a dialog that let user choose whether he wants to overwrite 
   * file <code>path</code> or not.
   * @return <code>true</code> if user confirmed to overwrite.
   */
  protected boolean confirmOverwrite(View parentView, String path) {
    // Retrieve displayed text in buttons and message
    String message = this.preferences.getLocalizedString(FileContentManager.class, "confirmOverwrite.message", path);
    String title = this.preferences.getLocalizedString(FileContentManager.class, "confirmOverwrite.title");
    String replace = this.preferences.getLocalizedString(FileContentManager.class, "confirmOverwrite.overwrite");
    String cancel = this.preferences.getLocalizedString(FileContentManager.class, "confirmOverwrite.cancel");
    
    return JOptionPane.showOptionDialog(SwingUtilities.getRootPane((JComponent)parentView), 
        message, title, JOptionPane.OK_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE,
        null, new Object [] {replace, cancel}, cancel) == JOptionPane.OK_OPTION;
  }
  
  /**
   * A file chooser dedicated to directory choice.
   */
  private static class DirectoryChooser extends JFileChooser {
    private Executor               fileSystemViewExecutor;
    private JTree                  directoriesTree;
    private TreeSelectionListener  treeSelectionListener;
    private PropertyChangeListener selectedFileListener;
    private Action                 createDirectoryAction;

    public DirectoryChooser(final UserPreferences preferences) {
      setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
      this.fileSystemViewExecutor = Executors.newSingleThreadExecutor();
      this.directoriesTree = new JTree(new DefaultTreeModel(new DirectoryNode()));
      this.directoriesTree.setRootVisible(false);
      this.directoriesTree.setEditable(false);
      this.directoriesTree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION);
      this.directoriesTree.setCellRenderer(new DefaultTreeCellRenderer() {
          @Override
          public Component getTreeCellRendererComponent(JTree tree, Object value, boolean selected, boolean expanded,
                                                        boolean leaf, int row, boolean hasFocus) {
            DirectoryNode node = (DirectoryNode)value;
            File file = (File)node.getUserObject();
            super.getTreeCellRendererComponent(tree, DirectoryChooser.this.getName(file), 
                selected, expanded, leaf, row, hasFocus);
            setIcon(DirectoryChooser.this.getIcon(file));
            if (!node.isWritable()) {
              setForeground(Color.GRAY);
            } 
            return this;
          }
        });
      this.treeSelectionListener = new TreeSelectionListener() {
          public void valueChanged(TreeSelectionEvent ev) {
            TreePath selectionPath = directoriesTree.getSelectionPath();
            if (selectionPath != null) {
              DirectoryNode selectedNode = (DirectoryNode)selectionPath.getLastPathComponent();
              setSelectedFile((File)selectedNode.getUserObject());
            }
          }
        };
      this.directoriesTree.addTreeSelectionListener(this.treeSelectionListener);
      
      this.selectedFileListener = new PropertyChangeListener() {
          public void propertyChange(PropertyChangeEvent ev) {
            showSelectedFile();
          }
        };
      addPropertyChangeListener(SELECTED_FILE_CHANGED_PROPERTY, this.selectedFileListener);
      
      this.directoriesTree.addTreeExpansionListener(new TreeExpansionListener() {
          public void treeCollapsed(TreeExpansionEvent ev) {
            if (ev.getPath().isDescendant(directoriesTree.getSelectionPath())) {
              // If selected node becomes hidden select not hidden parent
              removePropertyChangeListener(SELECTED_FILE_CHANGED_PROPERTY, selectedFileListener);
              directoriesTree.setSelectionPath(ev.getPath());
              addPropertyChangeListener(SELECTED_FILE_CHANGED_PROPERTY, selectedFileListener);
            }
          }
          
          public void treeExpanded(TreeExpansionEvent ev) {
          }
        });
      
      // Create an action used to create additional directories
      final String newDirectoryText = UIManager.getString("FileChooser.win32.newFolder");
      this.createDirectoryAction = new AbstractAction(newDirectoryText) {
          public void actionPerformed(ActionEvent ev) {
            String newDirectoryNameBase = OperatingSystem.isWindows() || OperatingSystem.isMacOSX()
                ? newDirectoryText
                : UIManager.getString("FileChooser.other.newFolder");
            String newDirectoryName = newDirectoryNameBase;
            // Search a new directory name that doesn't exist
            DirectoryNode parentNode = (DirectoryNode)directoriesTree.getLastSelectedPathComponent();
            File parentDirectory = (File)parentNode.getUserObject();
            for (int i = 2; new File(parentDirectory, newDirectoryName).exists(); i++) {
              newDirectoryName = newDirectoryNameBase;
              if (OperatingSystem.isWindows() || OperatingSystem.isMacOSX()) {
                newDirectoryName += " ";
              }
              newDirectoryName += i;
            }
            newDirectoryName = (String)JOptionPane.showInputDialog(DirectoryChooser.this, 
                preferences.getLocalizedString(FileContentManager.class, "createFolder.message"),                
                newDirectoryText, JOptionPane.QUESTION_MESSAGE, null, null, newDirectoryName);
            if (newDirectoryName != null) {
              File newDirectory = new File(parentDirectory, newDirectoryName);
              if (!newDirectory.mkdir()) {
                String newDirectoryErrorText = UIManager.getString("FileChooser.newFolderErrorText");
                JOptionPane.showMessageDialog(DirectoryChooser.this, 
                    newDirectoryErrorText, newDirectoryErrorText, JOptionPane.ERROR_MESSAGE);
              } else {
                parentNode.updateChildren(parentNode.getChildDirectories());
                ((DefaultTreeModel)directoriesTree.getModel()).nodeStructureChanged(parentNode);
                setSelectedFile(newDirectory);
              }
            }
          }
        };
      
      setSelectedFile(getFileSystemView().getHomeDirectory());
    }
    
    /**
     * Selects the given directory or its parent if it's a file.
     */
    @Override
    public void setSelectedFile(File file) {
      if (file != null && file.isFile()) {
        file = file.getParentFile();
      }
      super.setSelectedFile(file);
    }
    
    /**
     * Shows asynchronously the selected file in the directories tree, 
     * filling the parents siblings hierarchy if necessary.
     */
    private void showSelectedFile() {
      final File selectedFile = getSelectedFile();
      if (selectedFile != null) {
        final DirectoryNode rootNode = (DirectoryNode)this.directoriesTree.getModel().getRoot();
        this.fileSystemViewExecutor.execute(new Runnable() {
            public void run() {
              try {
                EventQueue.invokeAndWait(new Runnable() {
                    public void run() {
                      createDirectoryAction.setEnabled(false);
                      if (directoriesTree.isShowing()) {
                        directoriesTree.setCursor(Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR));
                      }
                    }
                  });
                File cononicalFile = selectedFile.getCanonicalFile();
                // Search parents of the selected file
                List<File> parentsAndFile = new ArrayList<File>();
                for (File file = cononicalFile; 
                    file != null;
                    file = getFileSystemView().getParentDirectory(file)) {
                  parentsAndFile.add(0, file);
                }
                // Build path of tree nodes
                final List<DirectoryNode> pathToFileNode = new ArrayList<DirectoryNode>();
                DirectoryNode node = rootNode;
                pathToFileNode.add(node);
                for (final File file : parentsAndFile) {
                  final File [] childDirectories = node.isLoaded() 
                      ? null 
                      : node.getChildDirectories();
                  // Search in a child of the node has a user object equal to file
                  final DirectoryNode currentNode = node;
                  EventQueue.invokeAndWait(new Runnable() {
                      public void run() {
                        if (!currentNode.isLoaded()) {
                          currentNode.updateChildren(childDirectories);
                          ((DefaultTreeModel)directoriesTree.getModel()).nodeStructureChanged(currentNode);
                        }
                        for (int i = 0, n = currentNode.getChildCount(); i < n; i++) {
                          DirectoryNode child = (DirectoryNode)currentNode.getChildAt(i);
                          if (file.equals(child.getUserObject())) {
                            pathToFileNode.add(child);
                            break;
                          }
                        }
                      }
                    });
                  node = pathToFileNode.get(pathToFileNode.size() - 1);
                  if (currentNode == node) {
                    // Give up since file wasn't found
                    break;
                  }
                }
                  
                if (pathToFileNode.size() > 1) {
                  final TreePath path = new TreePath(pathToFileNode.toArray(new TreeNode [pathToFileNode.size()]));
                  EventQueue.invokeAndWait(new Runnable() {
                      public void run() {
                        directoriesTree.removeTreeSelectionListener(treeSelectionListener);
                        directoriesTree.expandPath(path);
                        directoriesTree.setSelectionPath(path);
                        directoriesTree.scrollRowToVisible(directoriesTree.getRowForPath(path));
                        directoriesTree.addTreeSelectionListener(treeSelectionListener);
                      }
                    });                    
                }
                
              } catch (IOException ex) {
                // Ignore directories that can't be found 
              } catch (InterruptedException ex) {
                // Give up if interrupted
              } catch (InvocationTargetException ex) {
                ex.printStackTrace();
              } finally {
                EventQueue.invokeLater(new Runnable() {
                    public void run() {
                      createDirectoryAction.setEnabled(directoriesTree.getSelectionCount() > 0 
                          && ((DirectoryNode)directoriesTree.getSelectionPath().getLastPathComponent()).isWritable());
                      directoriesTree.setCursor(Cursor.getDefaultCursor());
                    }
                  });
              }
            }
          });
      }
    }

    @Override
    public int showDialog(Component parent, final String approveButtonText) {
      final JButton createDirectoryButton = new JButton(this.createDirectoryAction);
      final JButton approveButton = new JButton(approveButtonText);
      Object cancelOption = UIManager.get("FileChooser.cancelButtonText");
      Object [] options;
      if (OperatingSystem.isMacOSX()) {
        options = new Object [] {approveButton, cancelOption, createDirectoryButton};
      } else {
        options = new Object [] {createDirectoryButton, approveButton, cancelOption};
      }
      // Display chooser in a resizable dialog
      final JOptionPane optionPane = new JOptionPane(SwingTools.createScrollPane(this.directoriesTree), 
          JOptionPane.PLAIN_MESSAGE, JOptionPane.DEFAULT_OPTION, null, options, approveButton); 
      final JDialog dialog = optionPane.createDialog(SwingUtilities.getRootPane(parent), getDialogTitle());
      dialog.setResizable(true);
      dialog.pack();
      if (this.directoriesTree.getSelectionCount() > 0) {
        this.directoriesTree.scrollPathToVisible(this.directoriesTree.getSelectionPath());
        boolean validDirectory = ((DirectoryNode)this.directoriesTree.getSelectionPath().getLastPathComponent()).isWritable();
        approveButton.setEnabled(validDirectory);
        createDirectoryAction.setEnabled(validDirectory);
      }
      this.directoriesTree.addTreeSelectionListener(new TreeSelectionListener() {
          public void valueChanged(TreeSelectionEvent ev) {
            TreePath selectedPath = ev.getPath();
            boolean validDirectory = selectedPath != null 
                && ((DirectoryNode)ev.getPath().getLastPathComponent()).isWritable();
            approveButton.setEnabled(validDirectory);
            createDirectoryAction.setEnabled(validDirectory); 
          }
        });
      approveButton.addActionListener(new ActionListener() {
          public void actionPerformed(ActionEvent ev) {
            optionPane.setValue(approveButtonText);
            dialog.setVisible(false);
          }
        });
      dialog.setMinimumSize(dialog.getPreferredSize());
      dialog.setVisible(true);
      dialog.dispose();
      if (approveButtonText.equals(optionPane.getValue())) {
        return JFileChooser.APPROVE_OPTION;
      } else {
        return JFileChooser.CANCEL_OPTION;
      }
    }

    /**
     * Directory nodes which children are loaded when needed. 
     */
    private class DirectoryNode extends DefaultMutableTreeNode {
      private boolean loaded;
      private boolean writable;

      private DirectoryNode() {
        super(null);
      }

      private DirectoryNode(File file) {
        super(file);
        this.writable = file.canWrite();
      }

      public boolean isWritable() {
        return this.writable;
      }
      
      @Override
      public int getChildCount() {
        if (!this.loaded) {
          this.loaded = true;
          return updateChildren(getChildDirectories());
        } else {
          return super.getChildCount();
        }
      }

      public File [] getChildDirectories() {
        File [] childFiles = getUserObject() == null
            ? getFileSystemView().getRoots()
            : getFileSystemView().getFiles((File)getUserObject(), true);
        if (childFiles != null) {
          List<File> childDirectories = new ArrayList<File>(childFiles.length);
          for (File childFile : childFiles) {
            if (isTraversable(childFile)) {
              childDirectories.add(childFile);
            }
          }
          return childDirectories.toArray(new File [childDirectories.size()]);
        } else {
          return new File [0];
        }
      }
      
      public boolean isLoaded() {
        return this.loaded;
      }

      public int updateChildren(File [] childDirectories) {
        if (this.children == null) {
          this.children = new Vector<File>(childDirectories.length); 
        }          
        synchronized (this.children) {
          removeAllChildren();
          for (File childFile : childDirectories) {
            add(new DirectoryNode(childFile));
          }
          return childDirectories.length;
        }
      }
    }
  }
}
