VASSAL.launch.ModuleManagerWindow.java Source code

Java tutorial

Introduction

Here is the source code for VASSAL.launch.ModuleManagerWindow.java

Source

/*
 * $Id$
 *
 * 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.Cursor;
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.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.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang.SystemUtils;
import org.jdesktop.swingx.JXTreeTable;
import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode;
import org.jdesktop.swingx.treetable.DefaultTreeTableModel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

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.ShowHelpAction;
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.SequenceEncoder;
import VASSAL.tools.WriteErrorDialog;
import VASSAL.tools.filechooser.FileChooser;
import VASSAL.tools.filechooser.ModuleExtensionFileFilter;
import VASSAL.tools.io.IOUtils;
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 Logger logger = LoggerFactory.getLogger(ModuleManagerWindow.class);

    private static final String SHOW_STATUS_KEY = "showServerStatus";
    private static final String DIVIDER_LOCATION_KEY = "moduleManagerDividerLocation";
    private static final int COLUMNS = 4;
    private static final int KEY_COLUMN = 0;
    private static final int VERSION_COLUMN = 1;
    private static final int VASSAL_COLUMN = 2;
    private static final int SPARE_COLUMN = 3;
    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);
                }

                logger.info("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 (!SystemUtils.IS_OS_MAC_OSX) {
            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.addSeparator();
        helpMenu.add(mm.addKey("UpdateCheckAction.update_check"));
        helpMenu.add(mm.addKey("Help.error_log"));

        if (!SystemUtils.IS_OS_MAC_OSX) {
            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);

        URL url = null;
        try {
            url = new File(Documentation.getDocumentationBaseDir(), "README.html").toURI().toURL();
        } catch (MalformedURLException e) {
            ErrorDialog.bug(e);
        }
        mm.addAction("General.help", new ShowHelpAction(url, null));

        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);
    }

    public void setWaitCursor(boolean wait) {
        setCursor(Cursor.getPredefinedCursor(wait ? Cursor.WAIT_CURSOR : Cursor.DEFAULT_CURSOR));
    }

    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(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.info(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.info(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());
                    }
                }
            }
        });

        // 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(100);

        tree.getColumnModel().getColumn(VASSAL_COLUMN).setCellRenderer(new VersionCellRenderer());
        tree.getColumnModel().getColumn(VASSAL_COLUMN).setMinWidth(100);

        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 static class MyTreeTableModel extends DefaultTreeTableModel {
        public MyTreeTableModel(MyTreeNode rootNode) {
            super(rootNode);
            columnHeadings[KEY_COLUMN] = Resources.getString("ModuleManager.module");
            columnHeadings[VERSION_COLUMN] = Resources.getString("ModuleManager.version");
            columnHeadings[VASSAL_COLUMN] = Resources.getString("ModuleManager.vassal_version");
            columnHeadings[SPARE_COLUMN] = Resources.getString("ModuleManager.description");
        }

        public int getColumnCount() {
            return COLUMNS;
        }

        public String getColumnName(int col) {
            return columnHeadings[col];
        }

        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 static class MyTree extends JXTreeTable {
        private static final long serialVersionUID = 1L;

        public MyTree(MyTreeTableModel treeModel) {
            super(treeModel);
        }

        // FIXME: Where's the rest of the comment???
        /**
         * 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 static 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 static 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();
            case VASSAL_COLUMN:
                return getVassalVersion();
            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 getVassalVersion() {
            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());
        }

        public 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);
                    cleanupTileCache();
                }
            });

            m.addSeparator();
            m.add(addFolderAction);
            m.addSeparator();
            m.add(newExtensionAction);
            m.add(addExtensionAction);
            return m;
        }

        public void cleanupTileCache() {
            final String hstr = DigestUtils.shaHex(metadata.getName() + "_" + metadata.getVersion());

            final File tdir = new File(Info.getConfDir(), "tiles/" + hstr);
            if (tdir.exists()) {
                try {
                    FileUtils.forceDelete(tdir);
                } catch (IOException e) {
                    WriteErrorDialog.error(e, tdir);
                }
            }
        }

        /*
         * 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 getVassalVersion() {
            return metadata == null ? "" : metadata.getVassalVersion();
        }

        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 static 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 static 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) {
            // FIXME: don't create a new one each time!
            final File logfile = new File(Info.getHomeDir(), "errorLog");
            final LogPane lp = new LogPane(logfile);

            // 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.pack();
            d.setVisible(true);
        }
    }
}