/*
 * $Id: ModuleManagerWindow.java 5992 2009-09-04 21:52:50Z uckelman $
 *
 * Copyright (c) 2000-2009 by Brent Easton, Rodney Kinney, Joel Uckelman 
 *
 * This library is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Library General Public
 * License (LGPL) as published by the Free Software Foundation.
 *
 * 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
 * Library General Public License for more details.
 *
 * You should have received a copy of the GNU Library General Public
 * License along with this library; if not, copies are available
 * at http://www.opensource.org.
 */

package VASSAL.launch;

import java.awt.BorderLayout;
import java.awt.CardLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.Frame;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Rectangle;
import java.awt.Toolkit;
import java.awt.event.ActionEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.SortedSet;
import java.util.TreeSet;

import javax.swing.AbstractAction;
import javax.swing.Action;
import javax.swing.BoxLayout;
import javax.swing.Icon;
import javax.swing.ImageIcon;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JEditorPane;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.JTree;
import javax.swing.ListSelectionModel;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
import javax.swing.border.TitledBorder;
import javax.swing.event.TreeExpansionEvent;
import javax.swing.event.TreeSelectionEvent;
import javax.swing.event.TreeSelectionListener;
import javax.swing.event.TreeWillExpandListener;
import javax.swing.table.DefaultTableCellRenderer;
import javax.swing.text.html.HTMLEditorKit;
import javax.swing.tree.DefaultTreeCellRenderer;
import javax.swing.tree.TreePath;

import net.miginfocom.swing.MigLayout;

import org.jdesktop.swingx.JXTreeTable;
import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode;
import org.jdesktop.swingx.treetable.DefaultTreeTableModel;

import VASSAL.Info;
import VASSAL.build.module.Documentation;
import VASSAL.build.module.ExtensionsManager;
import VASSAL.build.module.metadata.AbstractMetaData;
import VASSAL.build.module.metadata.ExtensionMetaData;
import VASSAL.build.module.metadata.MetaDataFactory;
import VASSAL.build.module.metadata.ModuleMetaData;
import VASSAL.build.module.metadata.SaveMetaData;
import VASSAL.chat.CgiServerStatus;
import VASSAL.chat.ui.ServerStatusView;
import VASSAL.configure.BooleanConfigurer;
import VASSAL.configure.DirectoryConfigurer;
import VASSAL.configure.IntConfigurer;
import VASSAL.configure.StringArrayConfigurer;
import VASSAL.i18n.Resources;
import VASSAL.preferences.PositionOption;
import VASSAL.preferences.Prefs;
import VASSAL.tools.ApplicationIcons;
import VASSAL.tools.BrowserSupport;
import VASSAL.tools.ComponentSplitter;
import VASSAL.tools.ErrorDialog;
import VASSAL.tools.FutureUtils;
import VASSAL.tools.SequenceEncoder;
import VASSAL.tools.WriteErrorDialog;
import VASSAL.tools.filechooser.FileChooser;
import VASSAL.tools.filechooser.ModuleExtensionFileFilter;
import VASSAL.tools.io.IOUtils;
import VASSAL.tools.logging.Logger;
import VASSAL.tools.logging.LogPane;
import VASSAL.tools.menu.CheckBoxMenuItemProxy;
import VASSAL.tools.menu.MenuBarProxy;
import VASSAL.tools.menu.MenuManager;
import VASSAL.tools.menu.MenuProxy;
import VASSAL.tools.version.UpdateCheckAction;

public class ModuleManagerWindow extends JFrame {
  private static final long serialVersionUID = 1L;

  private static final String SHOW_STATUS_KEY = "showServerStatus";
  private static final String DIVIDER_LOCATION_KEY = "moduleManagerDividerLocation";
  private static final int COLUMNS = 3;
  private static final int KEY_COLUMN = 0;
  private static final int VERSION_COLUMN = 1;
  private static final int SPARE_COLUMN = 2;
  private static final String[] columnHeadings = new String[COLUMNS];

  private final ImageIcon moduleIcon;
  private final ImageIcon activeExtensionIcon;
  private final ImageIcon inactiveExtensionIcon;
  private final ImageIcon openGameFolderIcon;
  private final ImageIcon closedGameFolderIcon;
  private final ImageIcon fileIcon;
  
  private StringArrayConfigurer recentModuleConfig;
  private File selectedModule;

  private CardLayout modulePanelLayout;
  private JPanel moduleView;
  private ComponentSplitter.SplitPane serverStatusView;
  
  private MyTreeNode rootNode;
  private MyTree tree;
  private MyTreeTableModel treeModel;
  private MyTreeNode selectedNode;

  private long lastExpansionTime;
  private TreePath lastExpansionPath;
  
  private IntConfigurer dividerLocationConfig;

  private static final long doubleClickInterval;
  static {
    final Object dci =
      Toolkit.getDefaultToolkit().getDesktopProperty("awt.multiClickInterval");
    doubleClickInterval = dci instanceof Integer ? (Integer) dci : 200L;
  }

  public static ModuleManagerWindow getInstance() {
    return instance;
  }

  private static final ModuleManagerWindow instance = new ModuleManagerWindow();

  public ModuleManagerWindow() {
    setTitle("VASSAL");
    setLayout(new BoxLayout(getContentPane(), BoxLayout.X_AXIS));

    ApplicationIcons.setFor(this);

    final AbstractAction shutDownAction = new AbstractAction() {
      private static final long serialVersionUID = 1L;

      public void actionPerformed(ActionEvent e) {
        if (!AbstractLaunchAction.shutDown()) return;

        final Prefs gl = Prefs.getGlobalPrefs();
        try {
          gl.write();
          gl.close();
        }
        catch (IOException ex) {
          WriteErrorDialog.error(ex, gl.getFile());
        }
        finally {
          IOUtils.closeQuietly(gl);
        }

        FutureUtils.wait(Logger.logAndWait("-- Exiting"));
        System.exit(0);
      }
    };
    shutDownAction.putValue(Action.NAME, Resources.getString(Resources.QUIT));

    setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
    addWindowListener(new WindowAdapter() {
      @Override
       public void windowClosing(WindowEvent e) {
        shutDownAction.actionPerformed(null);
      }
    });  

    // setup menubar and actions
    final MenuManager mm = MenuManager.getInstance();
    final MenuBarProxy mb = mm.getMenuBarProxyFor(this);

    // file menu
    final MenuProxy fileMenu =
      new MenuProxy(Resources.getString("General.file"));
    fileMenu.setMnemonic(Resources.getString("General.file.shortcut").charAt(0));
    
    fileMenu.add(mm.addKey("Main.play_module"));
    fileMenu.add(mm.addKey("Main.edit_module"));
    fileMenu.add(mm.addKey("Main.new_module"));
    fileMenu.add(mm.addKey("Main.import_module"));
    fileMenu.addSeparator();

    if (!Info.isMacOSX()) {
      fileMenu.add(mm.addKey("Prefs.edit_preferences"));
      fileMenu.addSeparator();
      fileMenu.add(mm.addKey("General.quit"));
    }

    // tools menu
    final MenuProxy toolsMenu =
      new MenuProxy(Resources.getString("General.tools"));
   
    // Initialize Global Preferences
    Prefs.getGlobalPrefs().getEditor().initDialog(this);
    Prefs.initSharedGlobalPrefs();
    
    final BooleanConfigurer serverStatusConfig =
      new BooleanConfigurer(SHOW_STATUS_KEY, null, Boolean.FALSE);
    Prefs.getGlobalPrefs().addOption(null, serverStatusConfig); 

    dividerLocationConfig = new IntConfigurer(DIVIDER_LOCATION_KEY, null, -10);
    Prefs.getGlobalPrefs().addOption(null, dividerLocationConfig);
    
    toolsMenu.add(new CheckBoxMenuItemProxy(new AbstractAction(
                   Resources.getString("Chat.server_status")) {
      private static final long serialVersionUID = 1L;

      public void actionPerformed(ActionEvent e) {
        serverStatusView.toggleVisibility();
        serverStatusConfig.setValue(
          serverStatusConfig.booleanValue() ? Boolean.FALSE : Boolean.TRUE);
        if (serverStatusView.isVisible()) {
          setDividerLocation(getPreferredDividerLocation());        
        }
      }
    }, serverStatusConfig.booleanValue()));
 
    // help menu
    final MenuProxy helpMenu =
      new MenuProxy(Resources.getString("General.help"));
    helpMenu.setMnemonic(Resources.getString("General.help.shortcut").charAt(0));

    helpMenu.add(mm.addKey("General.help"));
    helpMenu.add(mm.addKey("Main.tour"));
    helpMenu.add(mm.addKey("Help.user_guide"));
    helpMenu.addSeparator();
    helpMenu.add(mm.addKey("UpdateCheckAction.update_check"));
    helpMenu.add(mm.addKey("Help.error_log"));

    if (!Info.isMacOSX()) {
      helpMenu.addSeparator();
      helpMenu.add(mm.addKey("AboutScreen.about_vassal"));
    }

    mb.add(fileMenu);
    mb.add(toolsMenu);
    mb.add(helpMenu);

    // add actions
    mm.addAction("Main.play_module", new Player.PromptLaunchAction(this));
    mm.addAction("Main.edit_module", new Editor.PromptLaunchAction(this));
    mm.addAction("Main.new_module", new Editor.NewModuleLaunchAction(this));
    mm.addAction("Main.import_module",
      new Editor.PromptImportLaunchAction(this));
    mm.addAction("Prefs.edit_preferences",
      Prefs.getGlobalPrefs().getEditor().getEditAction());
    mm.addAction("General.quit", shutDownAction);

    final File docbase = Documentation.getDocumentationBaseDir();

    try {
      mm.addAction(
        "General.help", 
        new ShowInBrowserAction(
          Resources.getString(Resources.HELP),
          docbase,
          "README.html",
          null
        )
      );
    }
    catch (MalformedURLException e) {
      ErrorDialog.bug(e);
    }

    try {
      mm.addAction(
        "Help.user_guide",
        new ShowInBrowserAction(
          Resources.getString("Help.user_guide"),
          docbase,
          "userguide/userguide.pdf",
           null
        )
      );
    }
    catch (MalformedURLException e) {
      ErrorDialog.bug(e);
    }
    
    mm.addAction("Main.tour", new LaunchTourAction(this));
    mm.addAction("AboutScreen.about_vassal", new AboutVASSALAction(this));
    mm.addAction("UpdateCheckAction.update_check", new UpdateCheckAction(this));
    mm.addAction("Help.error_log", new ShowErrorLogAction(this));

    setJMenuBar(mm.getMenuBarFor(this));

    // Load Icons
    moduleIcon = new ImageIcon(
      getClass().getResource("/images/mm-module.png"));
    activeExtensionIcon = new ImageIcon(
      getClass().getResource("/images/mm-extension-active.png"));
    inactiveExtensionIcon = new ImageIcon(
      getClass().getResource("/images/mm-extension-inactive.png"));
    openGameFolderIcon = new ImageIcon(
      getClass().getResource("/images/mm-gamefolder-open.png"));
    closedGameFolderIcon = new ImageIcon(
      getClass().getResource("/images/mm-gamefolder-closed.png"));
    fileIcon = new ImageIcon(getClass().getResource("/images/mm-file.png"));
    
    // build module controls 
    final JPanel moduleControls = new JPanel(new BorderLayout());
    modulePanelLayout = new CardLayout();
    moduleView = new JPanel(modulePanelLayout);
    buildTree();
    final JScrollPane scroll = new JScrollPane(tree);
    moduleView.add(scroll, "modules");

    final JEditorPane l = new JEditorPane("text/html",
      Resources.getString("ModuleManager.quickstart"));
    l.setEditable(false);

    // Try to get background color and font from LookAndFeel;
    // otherwise, use dummy JLabel to get color and font.
    Color bg = UIManager.getColor("control");
    Font font = UIManager.getFont("Label.font");

    if (bg == null || font == null) {
      final JLabel dummy = new JLabel();
      if (bg == null) bg = dummy.getBackground();
      if (font == null) font = dummy.getFont();
    }
    
    l.setBackground(bg);
    ((HTMLEditorKit) l.getEditorKit()).getStyleSheet().addRule(
      "body { font: " + font.getFamily() + " " + font.getSize() + "pt }");

    l.addHyperlinkListener(BrowserSupport.getListener());

// FIXME: use MigLayout for this!
    // this is necessary to get proper vertical alignment
    final JPanel p = new JPanel(new GridBagLayout());
    final GridBagConstraints c = new GridBagConstraints();
    c.fill = GridBagConstraints.HORIZONTAL;
    c.anchor = GridBagConstraints.CENTER;
    p.add(l, c);

    moduleView.add(p, "quickStart");
    modulePanelLayout.show(
      moduleView, getModuleCount() == 0 ? "quickStart" : "modules");
    moduleControls.add(moduleView, BorderLayout.CENTER);
    moduleControls.setBorder(new TitledBorder(
      Resources.getString("ModuleManager.recent_modules")));

    add(moduleControls);

    // build server status controls
    final ServerStatusView serverStatusControls =
      new ServerStatusView(new CgiServerStatus());
    serverStatusControls.setBorder(
      new TitledBorder(Resources.getString("Chat.server_status")));   

    serverStatusView = new ComponentSplitter().splitRight(
      moduleControls, serverStatusControls, false);
    serverStatusView.revalidate();
    
    // show the server status controls according to the prefs
    if (serverStatusConfig.booleanValue()) {
      serverStatusView.showComponent();
    }
    
    setDividerLocation(getPreferredDividerLocation());
    serverStatusView.addPropertyChangeListener("dividerLocation",
                                               new PropertyChangeListener(){
      public void propertyChange(PropertyChangeEvent e) {
        setPreferredDividerLocation((Integer) e.getNewValue());
      }
    });
    
    final Rectangle r = Info.getScreenBounds(this);
    serverStatusControls.setPreferredSize(
      new Dimension((int) (r.width / 3.5), 0));

    setSize(3 * r.width / 4, 3 * r.height / 4);
    
    // Save/load the window position and size in prefs
    final PositionOption option =
      new PositionOption(PositionOption.key + "ModuleManager", this);
    Prefs.getGlobalPrefs().addOption(option);
  }
  
  protected void setDividerLocation(int i) {
    final int loc = i;
    final Runnable r = new Runnable() {
      public void run() {
        serverStatusView.setDividerLocation(loc);
      }
    };
    SwingUtilities.invokeLater(r); 
  }
  
  protected void setPreferredDividerLocation(int i) {
    dividerLocationConfig.setValue(new Integer(i));
  }
  
  protected int getPreferredDividerLocation() {
    return dividerLocationConfig.getIntValue(500);
  }

  protected void buildTree() {
    recentModuleConfig = new StringArrayConfigurer("RecentModules", null);
    Prefs.getGlobalPrefs().addOption(null, recentModuleConfig);
    final List<String> missingModules = new ArrayList<String>();
    final List<ModuleInfo> moduleList = new ArrayList<ModuleInfo>();
    for (String s : recentModuleConfig.getStringArray()) {
      final ModuleInfo module = new ModuleInfo(s);
      if (module.getFile().exists() && module.isValid()) {
        moduleList.add(module);
      }
      else {
        missingModules.add(s);
      }
    }

    for (String s : missingModules) {
      Logger.log(Resources.getString("ModuleManager.removing_module", s));
      moduleList.remove(s);
      recentModuleConfig.removeValue(s);
    }

    Collections.sort(moduleList, new Comparator<ModuleInfo>() {
      public int compare(ModuleInfo f1, ModuleInfo f2) {
        return f1.compareTo(f2);
      }
    });
    
    rootNode = new MyTreeNode (new RootInfo());
    
    for (ModuleInfo moduleInfo : moduleList) {    
      final MyTreeNode moduleNode = new MyTreeNode(moduleInfo);
      for (ExtensionInfo ext : moduleInfo.getExtensions()) {
        final MyTreeNode extensionNode = new MyTreeNode(ext);
        moduleNode.add(extensionNode);
      }
      
      final ArrayList<File> missingFolders = new ArrayList<File>();
      
      for (File f : moduleInfo.getFolders()) {
        if (f.exists() && f.isDirectory()) {
          final GameFolderInfo folderInfo = new GameFolderInfo(f, moduleInfo);
          final MyTreeNode folderNode = new MyTreeNode(folderInfo);
          moduleNode.add(folderNode);
          final ArrayList<File> l = new ArrayList<File>();

          final File[] files = f.listFiles();
          if (files == null) continue;

          for (File f1 : files) {
            if (f1.isFile()) {
              l.add(f1);
            }
          }
          Collections.sort(l);
        
          for (File f2 : l) {
            final SaveFileInfo fileInfo = new SaveFileInfo(f2, folderInfo);
            if (fileInfo.isValid() && fileInfo.belongsToModule()) {
              final MyTreeNode fileNode = new MyTreeNode(fileInfo);          
              folderNode.add(fileNode);
            }
          }
        }
        else {
          missingFolders.add(f);
        }
      }
      
      for (File mf : missingFolders) {
        Logger.log(
          Resources.getString("ModuleManager.removing_folder", mf.getPath()));
        moduleInfo.removeFolder(mf);
      }
      
      rootNode.add(moduleNode);
    }
    
    updateModuleList(); 
    
    treeModel = new MyTreeTableModel(rootNode);
    tree = new MyTree(treeModel);
    
    tree.setRootVisible(false);
    tree.setEditable(false);
    
    tree.setTreeCellRenderer(new MyTreeCellRenderer());
    
    tree.addMouseListener(new MouseAdapter() {
      @Override
      public void mouseClicked(MouseEvent e) {
        if (e.getClickCount() == 2) {
          final TreePath path =
            tree.getPathForLocation(e.getPoint().x, e.getPoint().y);

          // do nothing if not on a node, or if this node was expanded
          // or collapsed during the past doubleClickInterval milliseconds
          if (path == null || (lastExpansionPath == path &&
              e.getWhen() - lastExpansionTime <= doubleClickInterval)) return;

          selectedNode = (MyTreeNode) path.getLastPathComponent();

          final int row = tree.getRowForPath(path);          
          if (row < 0) return;

          final AbstractInfo target =
            (AbstractInfo) selectedNode.getUserObject();

          // launch module or load save, otherwise expand or collapse node
          if (target instanceof ModuleInfo) {
            final ModuleInfo modInfo = (ModuleInfo) target; 
            if (modInfo.isModuleTooNew()) {
              ErrorDialog.show(
                "Error.module_too_new",
                modInfo.getFile().getPath(),
                modInfo.getVassalVersion(),
                Info.getVersion()
              );
              return;
            }
            else {
              ((ModuleInfo) target).play();  
            }
          }
          else if (target instanceof SaveFileInfo) {
            ((SaveFileInfo) target).play();
          }
          else if (tree.isExpanded(row)) {
            tree.collapseRow(row);
          }
          else {
            tree.expandRow(row); 
          }
        }
      } 

      @Override
      public void mouseReleased(MouseEvent e) {
        final TreePath path =
          tree.getPathForLocation(e.getPoint().x, e.getPoint().y);
        if (path == null) return;
              
        selectedNode = (MyTreeNode) path.getLastPathComponent();

        if (e.isMetaDown()) {
          final int row = tree.getRowForPath(path);          
          if (row >= 0) {
            tree.clearSelection();
            tree.addRowSelectionInterval(row, row);
            final AbstractInfo target =
              (AbstractInfo) selectedNode.getUserObject();
            target.buildPopup(row).show(tree, e.getX(), e.getY());
          }
        }
      }
    });

    // We capture the time and location of clicks which would cause
    // expansion in order to distinguish these from clicks which
    // might launch a module or game.
    tree.addTreeWillExpandListener(new TreeWillExpandListener() {
      public void treeWillCollapse(TreeExpansionEvent e) {
        lastExpansionTime = System.currentTimeMillis();
        lastExpansionPath = e.getPath();
      }

      public void treeWillExpand(TreeExpansionEvent e) {
        lastExpansionTime = System.currentTimeMillis();
        lastExpansionPath = e.getPath();
      }
    });

    // This ensures that double-clicks always start the module but
    // doesn't prevent single-clicks on the handles from working. 
    tree.setToggleClickCount(3);

    tree.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
    tree.addTreeSelectionListener(new TreeSelectionListener() {
      public void valueChanged(TreeSelectionEvent e) {
        final MyTreeNode node = (MyTreeNode) e.getPath().getLastPathComponent();
        final AbstractInfo target = node.getNodeInfo();
        if (target instanceof ModuleInfo) {
          setSelectedModule(target.getFile());
        }
        else {
          if (node.getParent() != null) {
            setSelectedModule(node.getParentModuleFile());
          }
        }
      }
    });
    
    columnHeadings[KEY_COLUMN] = "";
    columnHeadings[VERSION_COLUMN] = Resources.getString("ModuleManager.version");
    columnHeadings[SPARE_COLUMN] = "";
    
    // FIXME: Width handling needs improvement. Also save in prefs
    tree.getColumnModel().getColumn(KEY_COLUMN).setMinWidth(250);
    
    tree.getColumnModel().getColumn(VERSION_COLUMN)
                         .setCellRenderer(new VersionCellRenderer());
    tree.getColumnModel().getColumn(VERSION_COLUMN).setMinWidth(75);
    
    tree.getColumnModel().getColumn(SPARE_COLUMN).setMinWidth(10);
    tree.getColumnModel().getColumn(SPARE_COLUMN).setPreferredWidth(600);
    
    // FIXME: How to set alignment of individual header components?
    tree.getTableHeader().setAlignmentX(JComponent.CENTER_ALIGNMENT);
    
  }

  /** 
   * A File has been saved or created by the Player or the Editor. Update
   * the display as necessary.
   * @param f The file
   */
  public void update(File f) {
    final AbstractMetaData data = MetaDataFactory.buildMetaData(f);
    
    // Module.
    // If we already have this module added, just refresh it, otherwise add it in.    
    if (data instanceof ModuleMetaData) {
      final MyTreeNode moduleNode = rootNode.findNode(f);
      if (moduleNode == null) {
        addModule(f);
      }
      else {
        moduleNode.refresh();
      }      
    }
    
    // Extension.
    // Check to see if it has been saved into one of the extension directories
    // for any module we already know of. Refresh the module
    else if (data instanceof ExtensionMetaData) {
      for (int i = 0; i < rootNode.getChildCount(); i++) {
        final MyTreeNode moduleNode = rootNode.getChild(i); 
        final ModuleInfo moduleInfo = (ModuleInfo) moduleNode.getNodeInfo();
        for (ExtensionInfo ext : moduleInfo.getExtensions()) {
          if (ext.getFile().equals(f)) {
            moduleNode.refresh();
            return;
          }
        }
      }
    }
    
    // Save Game or Log file. 
    // If the parent of the save file is already recorded as a Game Folder, 
    // pass the file off to the Game Folder to handle. Otherwise, ignore it.
    else if (data instanceof SaveMetaData) {
      for (int i = 0; i < rootNode.getChildCount(); i++) {
        final MyTreeNode moduleNode = rootNode.getChild(i);
        final MyTreeNode folderNode = moduleNode.findNode(f.getParentFile());
        if (folderNode != null &&
            folderNode.getNodeInfo() instanceof GameFolderInfo) {
          ((GameFolderInfo) folderNode.getNodeInfo()).update(f);  
          return;
        }
      }      
    }

    tree.repaint();
  }
  
  /**
   * Return the number of Modules added to the Module Manager
   * 
   * @return Number of modules
   */
  private int getModuleCount() {
    return rootNode.getChildCount();
  }
  
  public File getSelectedModule() {
    return selectedModule;
  }

  private void setSelectedModule(File selectedModule) {
    this.selectedModule = selectedModule;
  }

  public void addModule(File f) {
    if (!rootNode.contains(f)) {
      final ModuleInfo moduleInfo = new ModuleInfo(f);
      if (moduleInfo.isValid()) {
        final MyTreeNode moduleNode = new MyTreeNode(moduleInfo);
        treeModel.insertNodeInto(moduleNode, rootNode,
                                 rootNode.findInsertIndex(moduleInfo));
        for (ExtensionInfo ext : moduleInfo.getExtensions()) {
          final MyTreeNode extensionNode = new MyTreeNode(ext);
          treeModel.insertNodeInto(extensionNode, moduleNode,
              moduleNode.findInsertIndex(ext));
        }
        updateModuleList();
      }
    }
  }

  public void removeModule(File f) {
    final MyTreeNode moduleNode = rootNode.findNode(f);
    treeModel.removeNodeFromParent(moduleNode);
    updateModuleList();
  }
 
  public File getModuleByName(String name) {
    if (name == null) return null;

    for (int i = 0; i < rootNode.getChildCount(); i++) {
      final ModuleInfo module =
        (ModuleInfo) rootNode.getChild(i).getNodeInfo();
      
      if (name.equals(module.getModuleName())) return module.getFile(); 
    }

    return null;
  }
 
  private void updateModuleList() {
    final List<String> l = new ArrayList<String>();
    for (int i = 0; i < rootNode.getChildCount(); i++) {
      final ModuleInfo module =
        (ModuleInfo) (rootNode.getChild(i)).getNodeInfo();
      l.add(module.encode());
    }
    recentModuleConfig.setValue(l.toArray(new String[l.size()]));
    modulePanelLayout.show(
      moduleView, getModuleCount() == 0 ? "quickStart" : "modules");
  }
  
  /** *************************************************************************
   * Custom Tree table model:-
   *  - Return column count
   *  - Return column headings
   */
  private class MyTreeTableModel extends DefaultTreeTableModel {
  
    public MyTreeTableModel(MyTreeNode rootNode) {
      super(rootNode);
    }

    public int getColumnCount() {
      return COLUMNS;
    }
    
    public String getColumnName(int col) {
      switch (col) {
      case VERSION_COLUMN : return Resources.getString("ModuleManager.version");
      default: return "";
      }
    }
    
    public Object getValueAt(Object node, int column)  {
      return ((MyTreeNode) node).getValueAt(column);
    }
  }
  
  /**
   * Custom implementation of JXTreeTable
   * Fix for bug on startup generating illegal column numbers
   *
   */
  private class MyTree extends JXTreeTable {

    private static final long serialVersionUID = 1L;

    public MyTree(MyTreeTableModel treeModel) {
      super(treeModel);
    }
    
    /**
     * There appears to be a bug/strange interaction between JXTreetable and the ComponentSplitter
     * when the Component
     */
    public String getToolTipText(MouseEvent event) {
      if (getComponentAt(event.getPoint().x, event.getPoint().y) == null) return null;  
      return super.getToolTipText(event);
    }
  }
  
  /**
   * Custom Tree cell renderer:-
   *   - Add file name as tooltip
   *   - Handle expanded display (some nodes use the same icon for expanded/unexpanded)
   *   - Gray out inactve extensions
   *   - Gray out Save Games that belong to other modules
   */
  private class MyTreeCellRenderer extends DefaultTreeCellRenderer {

    private static final long serialVersionUID = 1L;
    
    public Component getTreeCellRendererComponent(
        JTree tree, Object value, boolean selected, boolean expanded,
        boolean leaf, int row, boolean hasFocus) {
      super.getTreeCellRendererComponent(
        tree, value, selected, expanded, leaf, row, hasFocus);
      final AbstractInfo info = ((MyTreeNode) value).getNodeInfo();
      setText(info.toString());
      setToolTipText(info.getToolTipText());
      setIcon(info.getIcon(expanded));
      setForeground(info.getTreeCellFgColor());
      return this;
    }
  }  
  
  /** *************************************************************************
   * Custom cell render for Version column
   *   - Center data
   */
  private class VersionCellRenderer extends DefaultTableCellRenderer  {
    
    private static final long serialVersionUID = 1L;
    
    public VersionCellRenderer() {
      super();
      this.setHorizontalAlignment(CENTER); 
    }
    
    public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) {
      super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
      return this;
    }
  }
  
  /** *************************************************************************
   * Custom TreeTable Node
   */
  private static class MyTreeNode extends DefaultMutableTreeTableNode {
    
    public MyTreeNode(AbstractInfo nodeInfo) {
      super(nodeInfo);
      nodeInfo.setTreeNode(this);
    }

    public AbstractInfo getNodeInfo() {
      return (AbstractInfo) getUserObject();
    }
    
    public File getFile() {
      return getNodeInfo().getFile();
    }
    
    public void refresh() {
      getNodeInfo().refresh();
    }
   
    @Override
    public void setValueAt(Object aValue, int column) {
    }
    
    @Override
    public Object getValueAt(int column) {
      return getNodeInfo().getValueAt(column);
    }
    
    public MyTreeNode getChild(int index) {
      return (MyTreeNode) super.getChildAt(index);
    }
    
    public MyTreeNode findNode(File f) {
      for (int i = 0; i < getChildCount(); i++) {
        final MyTreeNode moduleNode = getChild(i);

        // NB: we canonicalize because File.equals() does not
        // always return true when one File is a relative path.
        try {
          f = f.getCanonicalFile();
        }
        catch (IOException e) {
          f = f.getAbsoluteFile();
        }

        if (f.equals(moduleNode.getNodeInfo().getFile())) {
          return moduleNode;
        }
      }
      return null;
    }
    
    public boolean contains(File f) {
      return findNode(f) != null;
    }

    public int findInsertIndex(AbstractInfo info) {
      for (int i = 0; i < getChildCount(); i++) {
        final MyTreeNode childNode = getChild(i);
        if (childNode.getNodeInfo().compareTo(info) >= 0) {
          return i;
        }
      }
      return getChildCount();
    }
    
    /**
     * Return the Module node enclosing this node
     * 
     * @return Parent Tree Node
     */
    public MyTreeNode getParentModuleNode() {
      final AbstractInfo info = getNodeInfo();
      if (info instanceof RootInfo) {
        return null;
      }
      else if (info instanceof ModuleInfo) {
        return this;
      }
      else if ((MyTreeNode) getParent() == null) {
        return null;
      }
      else {        
        return ((MyTreeNode) getParent()).getParentModuleNode();
      }
    }
    
    /**
     * Return the Module file of the Module node enclosing this node
     * 
     * @return Module File
     */
    public File getParentModuleFile() {
      final MyTreeNode parentNode = getParentModuleNode();
      return parentNode == null ? null : parentNode.getFile();
    }
  }
  
  /** *************************************************************************
   * All tree nodes encapsulate a User-defined object holding the user
   * data for that node. In the ModuleManager, all user-defined objects
   * are subclasses of AbstractInfo 
   */
  private abstract class AbstractInfo implements Comparable<AbstractInfo> {
    protected File file;
    protected Icon openIcon;
    protected Icon closedIcon;
    protected boolean valid = true;
    protected String error = "";
    protected MyTreeNode node;
    
    public AbstractInfo(File f, Icon open, Icon closed) {
      setFile(f);
      setIcon(open, closed);
    }
    public AbstractInfo(File f, Icon i) {
      this (f, i, i);
    }
    
    public AbstractInfo(File f) {
      this(f, null);
    }
    
    public AbstractInfo() {
    }
   
    @Override 
    public String toString() {
      return file == null ? "" : file.getName();
    }
    
    public File getFile() {
      return file;
    }
    
    public void setFile(File f) {
      if (f == null) return;

      try {
        file = f.getCanonicalFile();
      }
      catch (IOException e) {
        file = f.getAbsoluteFile();
      }
    }
    
    public String getToolTipText() {
      if (file == null) {
        return "";
      }
      else {
        return file.getPath();
      }
    }
    
    public int compareTo(AbstractInfo info) {
      return getSortKey().compareTo(info.getSortKey());
    }
    
    public JPopupMenu buildPopup(int row) {
      return null;
    }
    
    public Icon getIcon(boolean expanded) {
      return expanded ? openIcon : closedIcon;
    }
    
    public void setIcon(Icon i) {
      setIcon(i, i);
    }
    
    public void setIcon(Icon open, Icon closed) {
      openIcon = open;
      closedIcon = closed;
    }
    
    public String getValueAt(int column) {
      switch (column) {
      case KEY_COLUMN:
        return toString();
      case VERSION_COLUMN:
        return getVersion();
      default:
        return null;
      }
    }
    
    public void setValid(boolean b) {
      valid = b;
    }
    
    public boolean isValid() {
      return valid;
    }
    
    public void setError(String s) {
      error = s;
    }
    
    public String getError() {
      return error;
    }
    
    public String getVersion() {
      return "";
    }
    
    public String getComments() {
      return "";
    }
    
    public MyTreeNode getTreeNode() {
      return node;
    }
    
    public void setTreeNode(MyTreeNode n) {
      node = n;
    }
    
    /**
     * Return a String used to sort different types of AbstractInfo's that are
     * children of the same parent.
     * 
     * @return sort key
     */
    public abstract String getSortKey();
    
    /**
     * Return the color of the text used to display the name in column 1.
     * Over-ride this to change color depending on item state.
     * 
     *  @return cell text color
     */
    public Color getTreeCellFgColor() {
      return Color.black;
    }
    
    /** 
     * Refresh yourself and any children
     */
    public void refresh() {
      refreshChildren();
    }
    
    public void refreshChildren() {
      for (int i = 0; i < node.getChildCount(); i++) {
        (node.getChild(i)).refresh();
      }      
    }
  }
  
  /** *************************************************************************
   * Root Node User Information - Root node is hidden, so not much action here.
   */
  private class RootInfo extends AbstractInfo {
    public RootInfo() {
      super(null);
    }

    public String getSortKey() {
      return "";
    } 
  }
  
  /** *************************************************************************
   * Module Node User Information
   */
  public class ModuleInfo extends AbstractInfo {

    private ExtensionsManager extMgr;
    private SortedSet<File> gameFolders = new TreeSet<File>();
    private ModuleMetaData metadata;
    
    private Action newExtensionAction =
      new NewExtensionLaunchAction(ModuleManagerWindow.this);
    
    private AbstractAction addExtensionAction =
      new AbstractAction(Resources.getString("ModuleManager.add_extension")) {

      private static final long serialVersionUID = 1L;

      public void actionPerformed(ActionEvent e) {
        final FileChooser fc = FileChooser.createFileChooser(
          ModuleManagerWindow.this, (DirectoryConfigurer)
            Prefs.getGlobalPrefs().getOption(Prefs.MODULES_DIR_KEY));
        if (fc.showOpenDialog() == FileChooser.APPROVE_OPTION) {
          final File selectedFile = fc.getSelectedFile();
          final ExtensionInfo testExtInfo = new ExtensionInfo(selectedFile, true, null);
          if (testExtInfo.isValid()) {
            final File f = getExtensionsManager().setActive(fc.getSelectedFile(), true);
            final MyTreeNode moduleNode = rootNode.findNode(selectedModule);
            final ExtensionInfo extInfo = new ExtensionInfo(f, true, (ModuleInfo) moduleNode.getNodeInfo());
            if (extInfo.isValid()) {
              final MyTreeNode extNode = new MyTreeNode(extInfo);
              treeModel.insertNodeInto(extNode, moduleNode, moduleNode.findInsertIndex(extInfo));
            }
          }
          else {
            JOptionPane.showMessageDialog(null, testExtInfo.getError(), null, JOptionPane.ERROR_MESSAGE);
          }
        }
      }
    };
    
    private AbstractAction addFolderAction = new AbstractAction(
        Resources.getString("ModuleManager.add_save_game_folder")) {
      private static final long serialVersionUID = 1L;

      public void actionPerformed(ActionEvent e) {
        final FileChooser fc = FileChooser.createFileChooser(
          ModuleManagerWindow.this, (DirectoryConfigurer)
            Prefs.getGlobalPrefs().getOption(Prefs.MODULES_DIR_KEY),
            FileChooser.DIRECTORIES_ONLY);
        if (fc.showOpenDialog() == FileChooser.APPROVE_OPTION) {
          addFolder(fc.getSelectedFile());
        }
      }
    };
    
    public ModuleInfo(File f) {
      super(f, moduleIcon);
      extMgr = new ExtensionsManager(f); 
      loadMetaData();  
    }
    
    protected void loadMetaData() {
      AbstractMetaData data = MetaDataFactory.buildMetaData(file);
      if (data != null && data instanceof ModuleMetaData) {
        setValid(true);
        metadata = (ModuleMetaData) data;
      }
      else {
        setValid(false);
      }  
    }
    
    protected boolean isModuleTooNew() {
      return metadata == null ? false : Info.isModuleTooNew(metadata.getVassalVersion());
    }
    
    protected String getVassalVersion() {
      return metadata == null ? "" : metadata.getVassalVersion();
    }

    /**
     * Initialise ModuleInfo based on a saved preference string.
     * See encode().
     * 
     * @param s Preference String
     */
    public ModuleInfo(String s) {
      SequenceEncoder.Decoder sd = new SequenceEncoder.Decoder(s, ';');
      setFile(new File(sd.nextToken()));
      setIcon(moduleIcon);
      loadMetaData();
      extMgr = new ExtensionsManager(getFile());  
      while (sd.hasMoreTokens()) {
        gameFolders.add(new File(sd.nextToken()));
      }
    }
    
    /**
     * Refresh this module and all children
     */
    public void refresh() {
      loadMetaData();
      
      // Remove any missing children
      final MyTreeNode[] nodes = new MyTreeNode[getTreeNode().getChildCount()];
      for (int i = 0; i < getTreeNode().getChildCount(); i++) {
        nodes[i] = getTreeNode().getChild(i);
      }
      for (int i = 0; i < nodes.length; i++) {
        if (!nodes[i].getFile().exists()) {
          treeModel.removeNodeFromParent(nodes[i]);
        }
      }  
      
      // Refresh or add any existing children
      for (ExtensionInfo ext : getExtensions()) {
        MyTreeNode extNode = getTreeNode().findNode(ext.getFile());
        if (extNode == null) {
          if (ext.isValid()) {
            extNode = new MyTreeNode(ext);
            treeModel.insertNodeInto(extNode, getTreeNode(), getTreeNode().findInsertIndex(ext));
          }
        }
        else {
          extNode.refresh();
        }
      }
    }
    
    /**
     * Encode any information which needs to be recorded in the Preference entry for this module:-
     *  - Path to Module File
     *  - Paths to any child Save Game Folders
     *  
     *  @return encoded data
     */
    public String encode() {
      final SequenceEncoder se = new SequenceEncoder(file.getPath(), ';');
      for (File f : gameFolders) {
        se.append(f.getPath());
      }
      return se.getValue();
    }
    
    public ExtensionsManager getExtensionsManager() {
      return extMgr;
    }

    public void addFolder(File f) {
      // try to create the directory if it doesn't exist
      if (!f.exists() && !f.mkdirs()) {
        JOptionPane.showMessageDialog(
          ModuleManagerWindow.this,
          Resources.getString("Install.error_unable_to_create", f.getPath()),
          "Error",
          JOptionPane.ERROR_MESSAGE
        );
        
        return;
      }

      gameFolders.add(f);
      final MyTreeNode moduleNode = rootNode.findNode(selectedModule);
      final GameFolderInfo folderInfo =
        new GameFolderInfo(f, (ModuleInfo) moduleNode.getNodeInfo());
      final MyTreeNode folderNode = new MyTreeNode(folderInfo);
      final int idx = moduleNode.findInsertIndex(folderInfo);
      treeModel.insertNodeInto(folderNode, moduleNode, idx);

      for (File file : f.listFiles()) {
        if (file.isFile()) {
          final SaveFileInfo fileInfo = new SaveFileInfo(file, folderInfo);
          if (fileInfo.isValid() && fileInfo.belongsToModule()) {
            final MyTreeNode fileNode = new MyTreeNode(fileInfo);
            treeModel.insertNodeInto(fileNode, folderNode,
                                     folderNode.findInsertIndex(fileInfo));
          }
        }
      }
      updateModuleList();
    }
    
    public void removeFolder(File f) {
      gameFolders.remove(f);
    }
    
    public SortedSet<File> getFolders() {
      return gameFolders;
    }
    
    public List<ExtensionInfo> getExtensions() {
      final List<ExtensionInfo> l = new ArrayList<ExtensionInfo>();
      for (File f : extMgr.getActiveExtensions()) {
        l.add(new ExtensionInfo(f, true, this));
      }
      for (File f : extMgr.getInactiveExtensions()) {
        l.add(new ExtensionInfo(f, false, this));
      }
      Collections.sort(l);
      return l;
    }
    
    public void play() {
      new Player.LaunchAction(
          ModuleManagerWindow.this, file).actionPerformed(null);
    }
   
    @Override 
    public JPopupMenu buildPopup(int row) {
      final JPopupMenu m = new JPopupMenu();
      final Action playAction = new Player.LaunchAction(ModuleManagerWindow.this, file);
      playAction.setEnabled(!Info.isModuleTooNew(metadata.getVassalVersion()));
      m.add(playAction);
      final Action editAction = new Editor.ListLaunchAction(ModuleManagerWindow.this, file);
      editAction.setEnabled(!Info.isModuleTooNew(metadata.getVassalVersion()));
      m.add(editAction);
      m.add(new AbstractAction(Resources.getString("General.remove")) {
        private static final long serialVersionUID = 1L;

        public void actionPerformed(ActionEvent e) {
          removeModule(file);
        }
      });

      m.addSeparator();
      m.add(addFolderAction);
      m.addSeparator();
      m.add(newExtensionAction);
      m.add(addExtensionAction);
      return m;
    }
    
    /*
     * Is the module currently being Played or Edited?
     */
    public boolean isInUse() {
      return AbstractLaunchAction.isInUse(file) ||
             AbstractLaunchAction.isEditing(file);
    }
   
    @Override 
    public String getVersion() {
      return metadata.getVersion();
    }
    
    public String getLocalizedDescription() {
      return metadata.getLocalizedDescription();
    }
    
    public String getModuleName() {
      return metadata.getName();
    }
   
    @Override 
    public String toString() {
      return metadata.getLocalizedName();
    }
    
    @Override 
    public String getValueAt(int column) {
      return column == SPARE_COLUMN ?
        getLocalizedDescription() : super.getValueAt(column);
    }
    
    public String getSortKey() {
      return metadata == null ? "" : metadata.getLocalizedName();
    }
    
    public Color getTreeCellFgColor() {
      return Info.isModuleTooNew(getVassalVersion()) ? Color.GRAY : Color.BLACK;
    }
  }
  
  /** *************************************************************************
   * Extension Node User Information
   */
  private class ExtensionInfo extends AbstractInfo {
    
    private boolean active;
    private ModuleInfo moduleInfo;
    private ExtensionMetaData metadata;
    
    public ExtensionInfo(File file, boolean active, ModuleInfo module) {
      super(file, active ? activeExtensionIcon : inactiveExtensionIcon);
      this.active = active;
      moduleInfo = module;
      loadMetaData();
    }
    
    protected void loadMetaData() {   
      AbstractMetaData data = MetaDataFactory.buildMetaData(file);
      if (data != null && data instanceof ExtensionMetaData) {
        setValid(true);
        metadata = (ExtensionMetaData) data;
      }
      else {
        setError(Resources.getString("ModuleManager.invalid_extension"));
        setValid(false);
      }
    }
    
    @Override
    public void refresh() {
      loadMetaData();
      setActive(getExtensionsManager().isExtensionActive(getFile()));
      tree.repaint();
    }
    
    public boolean isActive() {
      return active;
    }
    
    public void setActive(boolean b) {
      active = b;
      setIcon(active ? activeExtensionIcon : inactiveExtensionIcon);
    }
    
    @Override 
    public String getVersion() {
      return metadata == null ? "" : metadata.getVersion();
    }
    
    public String getDescription() {
      return metadata == null ? "" : metadata.getDescription();
    }
    
    public ExtensionsManager getExtensionsManager() {
      return moduleInfo == null ? null : moduleInfo.getExtensionsManager();
    }

    @Override 
    public String toString() {
      String s = getFile().getName();
      String st = "";
      if (metadata == null) {
        st = Resources.getString("ModuleManager.invalid");
      }
      if (!active) {
        st += st.length() > 0 ? "," : "";
        st += Resources.getString("ModuleManager.inactive");
      }
      if (st.length() > 0) {
        s += " (" + st + ")";
      }
      
      return s;
    }
    
    @Override 
    public JPopupMenu buildPopup(int row) {
      final JPopupMenu m = new JPopupMenu();
      m.add(new ActivateExtensionAction(Resources.getString(isActive() ?
            "ModuleManager.deactivate" : "ModuleManager.activate")));

      final Action editAction = new EditExtensionLaunchAction(
          ModuleManagerWindow.this, getFile(), getSelectedModule());
      editAction.setEnabled(!Info.isModuleTooNew(metadata.getVassalVersion()));
      m.add(editAction);
      return m;
    }
   
    @Override 
    public Color getTreeCellFgColor() {
      // FIXME: should get colors from LAF
      if (isActive()) {
        return metadata == null ? Color.red : Color.black;
      }
      else {
        return metadata == null ? Color.pink : Color.gray;
      }
    }
    
    @Override 
    public String getValueAt(int column) {
      return column == SPARE_COLUMN ?
        getDescription() : super.getValueAt(column);
    }
    
    /*
     * Is the extension, or its owning module currently being Played or Edited?
     */
    public boolean isInUse() {
      return AbstractLaunchAction.isInUse(file) ||
             AbstractLaunchAction.isEditing(file);
    }
    
    private class ActivateExtensionAction extends AbstractAction {
      private static final long serialVersionUID = 1L;

      public ActivateExtensionAction (String s) {
        super(s);
        setEnabled(!isInUse() && ! moduleInfo.isInUse());
      }
      
      public void actionPerformed(ActionEvent evt) {
        setFile(getExtensionsManager().setActive(getFile(), !isActive()));          
        setActive(getExtensionsManager().isExtensionActive(getFile()));
        final TreePath path = tree.getPathForRow(tree.getSelectedRow());
        final MyTreeNode extNode = (MyTreeNode) path.getLastPathComponent();
        treeModel.setValueAt("", extNode, 0);
      }
    }

    /**
     * Sort Extensions by File Name
     */
    public String getSortKey() {
      return getFile().getName();
    }
  }
  
  /** *************************************************************************
   * Saved Game Folder Node User Information
   */
  private class GameFolderInfo extends AbstractInfo {
    protected String comment;
    protected ModuleInfo moduleInfo;
    protected long dtm;
    
    public GameFolderInfo(File f, ModuleInfo m) {
      super(f, openGameFolderIcon, closedGameFolderIcon);
      moduleInfo = m;
      dtm = f.lastModified();
    }
    
    public JPopupMenu buildPopup(int row) {
      final JPopupMenu m = new JPopupMenu();
      m.add(new AbstractAction(Resources.getString("General.refresh")) {
        private static final long serialVersionUID = 1L;
        
        public void actionPerformed(ActionEvent e) {
          refresh();
        }        
      });
      m.addSeparator();
      m.add(new AbstractAction(Resources.getString("General.remove")) {
        private static final long serialVersionUID = 1L;

        public void actionPerformed(ActionEvent e) {
          final MyTreeNode moduleNode = rootNode.findNode(moduleInfo.getFile());
          final MyTreeNode folderNode = moduleNode.findNode(getFile());
          treeModel.removeNodeFromParent(folderNode);
          moduleInfo.removeFolder(getFile());
          updateModuleList();
        }
      });

      return m;
    }
    
    public ModuleInfo getModuleInfo() {
      return moduleInfo;
    }
    
    public void refresh() {
      
      // Remove any files that no longer exist
      for (int i = getTreeNode().getChildCount()-1; i >= 0; i--) {
        final MyTreeNode fileNode = getTreeNode().getChild(i);
        final SaveFileInfo fileInfo = (SaveFileInfo) fileNode.getNodeInfo();
        if (!fileInfo.getFile().exists()) {
          treeModel.removeNodeFromParent(fileNode);
        }
      }
      
      // Refresh any that are. Only include Save files belonging to this
      // module, or that are pre vassal 3.1
      final File[] files = getFile().listFiles();
      if (files == null) return;

      for (File f : files) {
        final AbstractMetaData fdata = MetaDataFactory.buildMetaData(f);
        if (fdata != null) {
          if (fdata instanceof SaveMetaData) {
            final String moduleName = ((SaveMetaData) fdata).getModuleName();
            if (moduleName == null ||
                moduleName.length() == 0 ||
                moduleName.equals(getModuleInfo().getModuleName())) {
              update(f);
            }
          }
        }
      }
    }
    
    /**
     * Update the display for the specified save File, or add it in if 
     * we don't already know about it.
     * @param f
     */
    public void update(File f) {
      for (int i = 0; i < getTreeNode().getChildCount(); i++) {
        final SaveFileInfo fileInfo = (SaveFileInfo) (getTreeNode().getChild(i)).getNodeInfo();
        if (fileInfo.getFile().equals(f)) {
          fileInfo.refresh();
          return;
        }
      }
      final SaveFileInfo fileInfo = new SaveFileInfo(f, this);
      final MyTreeNode fileNode = new MyTreeNode(fileInfo);
      treeModel.insertNodeInto(fileNode, getTreeNode(),
          getTreeNode().findInsertIndex(fileInfo));
    }
    
    /**
     * Force Game Folders to sort after extensions
     */ 
    public String getSortKey() {
      return "~~~"+getFile().getName();
    }
  }
  
  /** *************************************************************************
   * Saved Game File Node User Information
   */
  private class SaveFileInfo extends AbstractInfo {

    protected GameFolderInfo folderInfo;  // Owning Folder
    protected SaveMetaData metadata;      // Save file metadata
    
    public SaveFileInfo(File f, GameFolderInfo folder) {
      super(f, fileIcon);  
      folderInfo = folder;
      loadMetaData();
    }
    
    protected void loadMetaData() {
      AbstractMetaData data = MetaDataFactory.buildMetaData(file);
      if (data != null && data instanceof SaveMetaData) {
        metadata = (SaveMetaData) data;
        setValid(true);
      }
      else {
        setValid(false);
      }
    }
    
    public void refresh() {
      loadMetaData();
      tree.repaint();
    }
    
    @Override 
    public JPopupMenu buildPopup(int row) {
      final JPopupMenu m = new JPopupMenu();
      m.add(new Player.LaunchAction(
        ModuleManagerWindow.this, getModuleFile(), file));
      return m;
    }
    
    protected File getModuleFile() {
      return folderInfo.getModuleInfo().getFile();
    }
    
    public void play() {
      new Player.LaunchAction(
        ModuleManagerWindow.this, getModuleFile(), file).actionPerformed(null);
    } 
    
    @Override 
    public String getValueAt(int column) {
      return column == SPARE_COLUMN ?
        buildComments() : super.getValueAt(column);
    }
    
    private String buildComments() {
      String comments = "";
      if (!belongsToModule()) {
        if (metadata != null && metadata.getModuleName().length() > 0) {
          comments = "[" + metadata.getModuleName() +  "] ";
        }
      }
      comments += (metadata == null ? "" : metadata.getDescription());
      return comments;
    }
    
    private boolean belongsToModule() {
      return metadata != null 
        && (metadata.getModuleName().length() == 0 ||
        folderInfo.getModuleInfo().getModuleName().equals(
          metadata.getModuleName()));
    }
    
    @Override 
    public Color getTreeCellFgColor() {
      // FIXME: should get colors from LAF
       return belongsToModule() ? Color.black : Color.gray;
     }
   
    @Override 
    public String getVersion() {
      return metadata == null ? "" : metadata.getModuleVersion();
    }

    /**
     * Sort Save Files by file name
     */
    public String getSortKey() {
      return this.getFile().getName();
    }
  }
  
  /**
   * Action to create a New Extension and edit it in another process.
   */
  private class NewExtensionLaunchAction extends AbstractLaunchAction {
    private static final long serialVersionUID = 1L;

    public NewExtensionLaunchAction(Frame frame) {
      super(Resources.getString("ModuleManager.new_extension"), frame, 
        Editor.class.getName(),
        new LaunchRequest(LaunchRequest.Mode.NEW_EXT)
      );
    }

    @Override
    public void actionPerformed(ActionEvent e) {
      lr.module = getSelectedModule();

      // register that this module is being used
      if (editing.contains(lr.module)) return;
      Integer count = using.get(lr.module);
      using.put(lr.module, count == null ? 1 : ++count);

      super.actionPerformed(e);
    }

    @Override
    protected LaunchTask getLaunchTask() {
      return new LaunchTask() {
        @Override
        protected void done() {
          super.done();

          // reduce the using count
          Integer count = using.get(lr.module);
          if (count == 1) using.remove(lr.module);
          else using.put(lr.module, --count);
        }
      };
    }
  }

  /**
   * Action to Edit an Extension in another process
   */
  private class EditExtensionLaunchAction extends AbstractLaunchAction {
    private static final long serialVersionUID = 1L;

    public EditExtensionLaunchAction(Frame frame, File extension, File module) {
      super(Resources.getString("Editor.edit_extension"), frame,
        Editor.class.getName(),
        new LaunchRequest(LaunchRequest.Mode.EDIT_EXT, module, extension)
      );

      setEnabled(!using.containsKey(module) &&
                 !editing.contains(module) &&
                 !editing.contains(extension) &&
                 !using.containsKey(extension));
    }

    @Override
    public void actionPerformed(ActionEvent e) {
      // check that neither this module nor this extension is being edited
      if (editing.contains(lr.module) || editing.contains(lr.extension)) return;

      // register that this module is being used
      Integer count = using.get(lr.module);
      using.put(lr.module, count == null ? 1 : ++count);

      // register that this extension is being edited 
      editing.add(lr.extension);

      super.actionPerformed(e);
      setEnabled(false);
    }

    @Override
    protected void addFileFilters(FileChooser fc) {
      fc.addChoosableFileFilter(new ModuleExtensionFileFilter());
    }

    @Override
    protected LaunchTask getLaunchTask() {
      return new LaunchTask() {
        @Override
        protected void done() {
          super.done();

          // reduce the using count for module
          Integer count = using.get(lr.module);
          if (count == 1) using.remove(lr.module);
          else using.put(lr.module, --count);

          // reduce that this extension is done being edited
          editing.remove(lr.extension);
          setEnabled(true);
        }
      };
    } 
  }

  private class ShowErrorLogAction extends AbstractAction {
    private static final long serialVersionUID = 1L;

    private Frame frame;

    public ShowErrorLogAction(Frame frame) {
      super(Resources.getString("Help.error_log"));
      this.frame = frame;
    }

    public void actionPerformed(ActionEvent e) {
      final LogPane lp = new LogPane();

      FileInputStream in = null;
      try {
        in = new FileInputStream(new File(Info.getHomeDir(), "errorLog"));
        lp.setText(IOUtils.toString(in));
      }
      catch (IOException ex) {
        // FIXME: What to do here????
      }

      Logger.addLogListener(lp);

// FIXME: this should have its own key. Probably keys should be renamed
// to reflect what they are labeling, e.g., Help.show_error_log_menu_item,
// Help.error_log_dialog_title.
      final JDialog d =
        new JDialog(frame, Resources.getString("Help.error_log"));
      d.setLayout(new MigLayout("insets 0"));
      d.add(new JScrollPane(lp), "grow, push, w 500, h 600");

      d.setLocationRelativeTo(frame);
      d.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);
      d.addWindowListener(new WindowAdapter() {
        public void windowClosed(WindowEvent e) {
          // unregister the LogPane when this dialog is closed
          Logger.removeLogListener(lp);
        }
      });

      d.pack();
      d.setVisible(true);
    }
  }
}
