// License: GPL. For details, see LICENSE file.
package org.openstreetmap.josm.gui.preferences;

import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
import static org.openstreetmap.josm.tools.I18n.tr;

import java.awt.Component;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.Rectangle;
import java.awt.event.ActionEvent;
import java.awt.event.FocusAdapter;
import java.awt.event.FocusEvent;
import java.awt.event.KeyEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.EventObject;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.swing.AbstractAction;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.DefaultListModel;
import javax.swing.DefaultListSelectionModel;
import javax.swing.Icon;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JComponent;
import javax.swing.JFileChooser;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSeparator;
import javax.swing.JTable;
import javax.swing.JToolBar;
import javax.swing.KeyStroke;
import javax.swing.ListCellRenderer;
import javax.swing.ListSelectionModel;
import javax.swing.event.CellEditorListener;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.event.ListSelectionEvent;
import javax.swing.event.ListSelectionListener;
import javax.swing.event.TableModelEvent;
import javax.swing.event.TableModelListener;
import javax.swing.filechooser.FileFilter;
import javax.swing.table.AbstractTableModel;
import javax.swing.table.DefaultTableCellRenderer;
import javax.swing.table.TableCellEditor;

import org.openstreetmap.josm.Main;
import org.openstreetmap.josm.actions.ExtensionFileFilter;
import org.openstreetmap.josm.data.Version;
import org.openstreetmap.josm.gui.ExtendedDialog;
import org.openstreetmap.josm.gui.HelpAwareOptionPane;
import org.openstreetmap.josm.gui.PleaseWaitRunnable;
import org.openstreetmap.josm.gui.util.FileFilterAllFiles;
import org.openstreetmap.josm.gui.util.GuiHelper;
import org.openstreetmap.josm.gui.util.TableHelper;
import org.openstreetmap.josm.gui.widgets.AbstractFileChooser;
import org.openstreetmap.josm.gui.widgets.FileChooserManager;
import org.openstreetmap.josm.gui.widgets.JosmTextField;
import org.openstreetmap.josm.io.CachedFile;
import org.openstreetmap.josm.io.OnlineResource;
import org.openstreetmap.josm.io.OsmTransferException;
import org.openstreetmap.josm.tools.GBC;
import org.openstreetmap.josm.tools.ImageOverlay;
import org.openstreetmap.josm.tools.ImageProvider;
import org.openstreetmap.josm.tools.ImageProvider.ImageSizes;
import org.openstreetmap.josm.tools.LanguageInfo;
import org.openstreetmap.josm.tools.Utils;
import org.xml.sax.SAXException;

public abstract class SourceEditor extends JPanel {

    protected final SourceType sourceType;
    protected final boolean canEnable;

    protected final JTable tblActiveSources;
    protected final ActiveSourcesModel activeSourcesModel;
    protected final JList<ExtendedSourceEntry> lstAvailableSources;
    protected final AvailableSourcesListModel availableSourcesModel;
    protected final String availableSourcesUrl;
    protected final transient List<SourceProvider> sourceProviders;

    protected JTable tblIconPaths;
    protected IconPathTableModel iconPathsModel;

    protected boolean sourcesInitiallyLoaded;

    /**
     * Constructs a new {@code SourceEditor}.
     * @param sourceType the type of source managed by this editor
     * @param availableSourcesUrl the URL to the list of available sources
     * @param sourceProviders the list of additional source providers, from plugins
     * @param handleIcons {@code true} if icons may be managed, {@code false} otherwise
     */
    public SourceEditor(SourceType sourceType, String availableSourcesUrl, List<SourceProvider> sourceProviders, boolean handleIcons) {

        this.sourceType = sourceType;
        this.canEnable = sourceType.equals(SourceType.MAP_PAINT_STYLE) || sourceType.equals(SourceType.TAGCHECKER_RULE);

        DefaultListSelectionModel selectionModel = new DefaultListSelectionModel();
        this.availableSourcesModel = new AvailableSourcesListModel(selectionModel);
        this.lstAvailableSources = new JList<>(availableSourcesModel);
        this.lstAvailableSources.setSelectionModel(selectionModel);
        final SourceEntryListCellRenderer listCellRenderer = new SourceEntryListCellRenderer();
        this.lstAvailableSources.setCellRenderer(listCellRenderer);
        this.availableSourcesUrl = availableSourcesUrl;
        this.sourceProviders = sourceProviders;

        selectionModel = new DefaultListSelectionModel();
        activeSourcesModel = new ActiveSourcesModel(selectionModel);
        tblActiveSources = new JTable(activeSourcesModel) {
            // some kind of hack to prevent the table from scrolling slightly to the
            // right when clicking on the text
            @Override
            public void scrollRectToVisible(Rectangle aRect) {
                super.scrollRectToVisible(new Rectangle(0, aRect.y, aRect.width, aRect.height));
            }
        };
        tblActiveSources.putClientProperty("terminateEditOnFocusLost", true);
        tblActiveSources.setSelectionModel(selectionModel);
        tblActiveSources.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
        tblActiveSources.setShowGrid(false);
        tblActiveSources.setIntercellSpacing(new Dimension(0, 0));
        tblActiveSources.setTableHeader(null);
        tblActiveSources.setAutoResizeMode(JTable.AUTO_RESIZE_OFF);
        SourceEntryTableCellRenderer sourceEntryRenderer = new SourceEntryTableCellRenderer();
        if (canEnable) {
            tblActiveSources.getColumnModel().getColumn(0).setMaxWidth(1);
            tblActiveSources.getColumnModel().getColumn(0).setResizable(false);
            tblActiveSources.getColumnModel().getColumn(1).setCellRenderer(sourceEntryRenderer);
        } else {
            tblActiveSources.getColumnModel().getColumn(0).setCellRenderer(sourceEntryRenderer);
        }

        activeSourcesModel.addTableModelListener(new TableModelListener() {
            @Override
            public void tableChanged(TableModelEvent e) {
                listCellRenderer.updateSources(activeSourcesModel.getSources());
                lstAvailableSources.repaint();
            }
        });
        tblActiveSources.addPropertyChangeListener(new PropertyChangeListener() {
            @Override
            public void propertyChange(PropertyChangeEvent evt) {
                listCellRenderer.updateSources(activeSourcesModel.getSources());
                lstAvailableSources.repaint();
            }
        });
        activeSourcesModel.addTableModelListener(new TableModelListener() {
            // Force swing to show horizontal scrollbars for the JTable
            // Yes, this is a little ugly, but should work
            @Override
            public void tableChanged(TableModelEvent e) {
                TableHelper.adjustColumnWidth(tblActiveSources, canEnable ? 1 : 0, 800);
            }
        });
        activeSourcesModel.setActiveSources(getInitialSourcesList());

        final EditActiveSourceAction editActiveSourceAction = new EditActiveSourceAction();
        tblActiveSources.getSelectionModel().addListSelectionListener(editActiveSourceAction);
        tblActiveSources.addMouseListener(new MouseAdapter() {
            @Override
            public void mouseClicked(MouseEvent e) {
                if (e.getClickCount() == 2) {
                    int row = tblActiveSources.rowAtPoint(e.getPoint());
                    int col = tblActiveSources.columnAtPoint(e.getPoint());
                    if (row < 0 || row >= tblActiveSources.getRowCount())
                        return;
                    if (canEnable && col != 1)
                        return;
                    editActiveSourceAction.actionPerformed(null);
                }
            }
        });

        RemoveActiveSourcesAction removeActiveSourcesAction = new RemoveActiveSourcesAction();
        tblActiveSources.getSelectionModel().addListSelectionListener(removeActiveSourcesAction);
        tblActiveSources.getInputMap(JComponent.WHEN_FOCUSED).put(KeyStroke.getKeyStroke(KeyEvent.VK_DELETE, 0), "delete");
        tblActiveSources.getActionMap().put("delete", removeActiveSourcesAction);

        MoveUpDownAction moveUp = null;
        MoveUpDownAction moveDown = null;
        if (sourceType.equals(SourceType.MAP_PAINT_STYLE)) {
            moveUp = new MoveUpDownAction(false);
            moveDown = new MoveUpDownAction(true);
            tblActiveSources.getSelectionModel().addListSelectionListener(moveUp);
            tblActiveSources.getSelectionModel().addListSelectionListener(moveDown);
            activeSourcesModel.addTableModelListener(moveUp);
            activeSourcesModel.addTableModelListener(moveDown);
        }

        ActivateSourcesAction activateSourcesAction = new ActivateSourcesAction();
        lstAvailableSources.addListSelectionListener(activateSourcesAction);
        JButton activate = new JButton(activateSourcesAction);

        setBorder(BorderFactory.createEmptyBorder(0, 0, 0, 0));
        setLayout(new GridBagLayout());

        GridBagConstraints gbc = new GridBagConstraints();
        gbc.gridx = 0;
        gbc.gridy = 0;
        gbc.weightx = 0.5;
        gbc.gridwidth = 2;
        gbc.anchor = GBC.WEST;
        gbc.insets = new Insets(5, 11, 0, 0);

        add(new JLabel(getStr(I18nString.AVAILABLE_SOURCES)), gbc);

        gbc.gridx = 2;
        gbc.insets = new Insets(5, 0, 0, 6);

        add(new JLabel(getStr(I18nString.ACTIVE_SOURCES)), gbc);

        gbc.gridwidth = 1;
        gbc.gridx = 0;
        gbc.gridy++;
        gbc.weighty = 0.8;
        gbc.fill = GBC.BOTH;
        gbc.anchor = GBC.CENTER;
        gbc.insets = new Insets(0, 11, 0, 0);

        JScrollPane sp1 = new JScrollPane(lstAvailableSources);
        add(sp1, gbc);

        gbc.gridx = 1;
        gbc.weightx = 0.0;
        gbc.fill = GBC.VERTICAL;
        gbc.insets = new Insets(0, 0, 0, 0);

        JToolBar middleTB = new JToolBar();
        middleTB.setFloatable(false);
        middleTB.setBorderPainted(false);
        middleTB.setOpaque(false);
        middleTB.add(Box.createHorizontalGlue());
        middleTB.add(activate);
        middleTB.add(Box.createHorizontalGlue());
        add(middleTB, gbc);

        gbc.gridx++;
        gbc.weightx = 0.5;
        gbc.fill = GBC.BOTH;

        JScrollPane sp = new JScrollPane(tblActiveSources);
        add(sp, gbc);
        sp.setColumnHeaderView(null);

        gbc.gridx++;
        gbc.weightx = 0.0;
        gbc.fill = GBC.VERTICAL;
        gbc.insets = new Insets(0, 0, 0, 6);

        JToolBar sideButtonTB = new JToolBar(JToolBar.VERTICAL);
        sideButtonTB.setFloatable(false);
        sideButtonTB.setBorderPainted(false);
        sideButtonTB.setOpaque(false);
        sideButtonTB.add(new NewActiveSourceAction());
        sideButtonTB.add(editActiveSourceAction);
        sideButtonTB.add(removeActiveSourcesAction);
        sideButtonTB.addSeparator(new Dimension(12, 30));
        if (sourceType.equals(SourceType.MAP_PAINT_STYLE)) {
            sideButtonTB.add(moveUp);
            sideButtonTB.add(moveDown);
        }
        add(sideButtonTB, gbc);

        gbc.gridx = 0;
        gbc.gridy++;
        gbc.weighty = 0.0;
        gbc.weightx = 0.5;
        gbc.fill = GBC.HORIZONTAL;
        gbc.anchor = GBC.WEST;
        gbc.insets = new Insets(0, 11, 0, 0);

        JToolBar bottomLeftTB = new JToolBar();
        bottomLeftTB.setFloatable(false);
        bottomLeftTB.setBorderPainted(false);
        bottomLeftTB.setOpaque(false);
        bottomLeftTB.add(new ReloadSourcesAction(availableSourcesUrl, sourceProviders));
        bottomLeftTB.add(Box.createHorizontalGlue());
        add(bottomLeftTB, gbc);

        gbc.gridx = 2;
        gbc.anchor = GBC.CENTER;
        gbc.insets = new Insets(0, 0, 0, 0);

        JToolBar bottomRightTB = new JToolBar();
        bottomRightTB.setFloatable(false);
        bottomRightTB.setBorderPainted(false);
        bottomRightTB.setOpaque(false);
        bottomRightTB.add(Box.createHorizontalGlue());
        bottomRightTB.add(new JButton(new ResetAction()));
        add(bottomRightTB, gbc);

        // Icon configuration
        if (handleIcons) {
            buildIcons(gbc);
        }
    }

    private void buildIcons(GridBagConstraints gbc) {
        DefaultListSelectionModel selectionModel = new DefaultListSelectionModel();
        iconPathsModel = new IconPathTableModel(selectionModel);
        tblIconPaths = new JTable(iconPathsModel);
        tblIconPaths.setSelectionModel(selectionModel);
        tblIconPaths.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
        tblIconPaths.setTableHeader(null);
        tblIconPaths.getColumnModel().getColumn(0).setCellEditor(new FileOrUrlCellEditor(false));
        tblIconPaths.setRowHeight(20);
        tblIconPaths.putClientProperty("terminateEditOnFocusLost", Boolean.TRUE);
        iconPathsModel.setIconPaths(getInitialIconPathsList());

        EditIconPathAction editIconPathAction = new EditIconPathAction();
        tblIconPaths.getSelectionModel().addListSelectionListener(editIconPathAction);

        RemoveIconPathAction removeIconPathAction = new RemoveIconPathAction();
        tblIconPaths.getSelectionModel().addListSelectionListener(removeIconPathAction);
        tblIconPaths.getInputMap(JComponent.WHEN_FOCUSED).put(KeyStroke.getKeyStroke(KeyEvent.VK_DELETE, 0), "delete");
        tblIconPaths.getActionMap().put("delete", removeIconPathAction);

        gbc.gridx = 0;
        gbc.gridy++;
        gbc.weightx = 1.0;
        gbc.gridwidth = GBC.REMAINDER;
        gbc.insets = new Insets(8, 11, 8, 6);

        add(new JSeparator(), gbc);

        gbc.gridy++;
        gbc.insets = new Insets(0, 11, 0, 6);

        add(new JLabel(tr("Icon paths:")), gbc);

        gbc.gridy++;
        gbc.weighty = 0.2;
        gbc.gridwidth = 3;
        gbc.fill = GBC.BOTH;
        gbc.insets = new Insets(0, 11, 0, 0);

        JScrollPane sp = new JScrollPane(tblIconPaths);
        add(sp, gbc);
        sp.setColumnHeaderView(null);

        gbc.gridx = 3;
        gbc.gridwidth = 1;
        gbc.weightx = 0.0;
        gbc.fill = GBC.VERTICAL;
        gbc.insets = new Insets(0, 0, 0, 6);

        JToolBar sideButtonTBIcons = new JToolBar(JToolBar.VERTICAL);
        sideButtonTBIcons.setFloatable(false);
        sideButtonTBIcons.setBorderPainted(false);
        sideButtonTBIcons.setOpaque(false);
        sideButtonTBIcons.add(new NewIconPathAction());
        sideButtonTBIcons.add(editIconPathAction);
        sideButtonTBIcons.add(removeIconPathAction);
        add(sideButtonTBIcons, gbc);
    }

    /**
     * Load the list of source entries that the user has configured.
     */
    public abstract Collection<? extends SourceEntry> getInitialSourcesList();

    /**
     * Load the list of configured icon paths.
     */
    public abstract Collection<String> getInitialIconPathsList();

    /**
     * Get the default list of entries (used when resetting the list).
     */
    public abstract Collection<ExtendedSourceEntry> getDefault();

    /**
     * Save the settings after user clicked "Ok".
     * @return true if restart is required
     */
    public abstract boolean finish();

    /**
     * Provide the GUI strings. (There are differences for MapPaint and Preset)
     */
    protected abstract String getStr(I18nString ident);

    /**
     * Identifiers for strings that need to be provided.
     */
    public enum I18nString { AVAILABLE_SOURCES, ACTIVE_SOURCES, NEW_SOURCE_ENTRY_TOOLTIP, NEW_SOURCE_ENTRY,
        REMOVE_SOURCE_TOOLTIP, EDIT_SOURCE_TOOLTIP, ACTIVATE_TOOLTIP, RELOAD_ALL_AVAILABLE,
        LOADING_SOURCES_FROM, FAILED_TO_LOAD_SOURCES_FROM, FAILED_TO_LOAD_SOURCES_FROM_HELP_TOPIC,
        ILLEGAL_FORMAT_OF_ENTRY }

    public boolean hasActiveSourcesChanged() {
        Collection<? extends SourceEntry> prev = getInitialSourcesList();
        List<SourceEntry> cur = activeSourcesModel.getSources();
        if (prev.size() != cur.size())
            return true;
        Iterator<? extends SourceEntry> p = prev.iterator();
        Iterator<SourceEntry> c = cur.iterator();
        while (p.hasNext()) {
            SourceEntry pe = p.next();
            SourceEntry ce = c.next();
            if (!Objects.equals(pe.url, ce.url) || !Objects.equals(pe.name, ce.name) || pe.active != ce.active)
                return true;
        }
        return false;
    }

    public Collection<SourceEntry> getActiveSources() {
        return activeSourcesModel.getSources();
    }

    public void removeSources(Collection<Integer> idxs) {
        activeSourcesModel.removeIdxs(idxs);
    }

    protected void reloadAvailableSources(String url, List<SourceProvider> sourceProviders) {
        Main.worker.submit(new SourceLoader(url, sourceProviders));
    }

    public void initiallyLoadAvailableSources() {
        if (!sourcesInitiallyLoaded) {
            reloadAvailableSources(availableSourcesUrl, sourceProviders);
        }
        sourcesInitiallyLoaded = true;
    }

    protected static class AvailableSourcesListModel extends DefaultListModel<ExtendedSourceEntry> {
        private transient List<ExtendedSourceEntry> data;
        private DefaultListSelectionModel selectionModel;

        public AvailableSourcesListModel(DefaultListSelectionModel selectionModel) {
            data = new ArrayList<>();
            this.selectionModel = selectionModel;
        }

        public void setSources(List<ExtendedSourceEntry> sources) {
            data.clear();
            if (sources != null) {
                data.addAll(sources);
            }
            fireContentsChanged(this, 0, data.size());
        }

        @Override
        public ExtendedSourceEntry getElementAt(int index) {
            return data.get(index);
        }

        @Override
        public int getSize() {
            if (data == null) return 0;
            return data.size();
        }

        public void deleteSelected() {
            Iterator<ExtendedSourceEntry> it = data.iterator();
            int i = 0;
            while (it.hasNext()) {
                it.next();
                if (selectionModel.isSelectedIndex(i)) {
                    it.remove();
                }
                i++;
            }
            fireContentsChanged(this, 0, data.size());
        }

        public List<ExtendedSourceEntry> getSelected() {
            List<ExtendedSourceEntry> ret = new ArrayList<>();
            for (int i = 0; i < data.size(); i++) {
                if (selectionModel.isSelectedIndex(i)) {
                    ret.add(data.get(i));
                }
            }
            return ret;
        }
    }

    protected class ActiveSourcesModel extends AbstractTableModel {
        private transient List<SourceEntry> data;
        private DefaultListSelectionModel selectionModel;

        public ActiveSourcesModel(DefaultListSelectionModel selectionModel) {
            this.selectionModel = selectionModel;
            this.data = new ArrayList<>();
        }

        @Override
        public int getColumnCount() {
            return canEnable ? 2 : 1;
        }

        @Override
        public int getRowCount() {
            return data == null ? 0 : data.size();
        }

        @Override
        public Object getValueAt(int rowIndex, int columnIndex) {
            if (canEnable && columnIndex == 0)
                return data.get(rowIndex).active;
            else
                return data.get(rowIndex);
        }

        @Override
        public boolean isCellEditable(int rowIndex, int columnIndex) {
            return canEnable && columnIndex == 0;
        }

        @Override
        public Class<?> getColumnClass(int column) {
            if (canEnable && column == 0)
                return Boolean.class;
            else return SourceEntry.class;
        }

        @Override
        public void setValueAt(Object aValue, int row, int column) {
            if (row < 0 || row >= getRowCount() || aValue == null)
                return;
            if (canEnable && column == 0) {
                data.get(row).active = !data.get(row).active;
            }
        }

        public void setActiveSources(Collection<? extends SourceEntry> sources) {
            data.clear();
            if (sources != null) {
                for (SourceEntry e : sources) {
                    data.add(new SourceEntry(e));
                }
            }
            fireTableDataChanged();
        }

        public void addSource(SourceEntry entry) {
            if (entry == null) return;
            data.add(entry);
            fireTableDataChanged();
            int idx = data.indexOf(entry);
            if (idx >= 0) {
                selectionModel.setSelectionInterval(idx, idx);
            }
        }

        public void removeSelected() {
            Iterator<SourceEntry> it = data.iterator();
            int i = 0;
            while (it.hasNext()) {
                it.next();
                if (selectionModel.isSelectedIndex(i)) {
                    it.remove();
                }
                i++;
            }
            fireTableDataChanged();
        }

        public void removeIdxs(Collection<Integer> idxs) {
            List<SourceEntry> newData = new ArrayList<>();
            for (int i = 0; i < data.size(); ++i) {
                if (!idxs.contains(i)) {
                    newData.add(data.get(i));
                }
            }
            data = newData;
            fireTableDataChanged();
        }

        public void addExtendedSourceEntries(List<ExtendedSourceEntry> sources) {
            if (sources == null) return;
            for (ExtendedSourceEntry info: sources) {
                data.add(new SourceEntry(info.url, info.name, info.getDisplayName(), true));
            }
            fireTableDataChanged();
            selectionModel.clearSelection();
            for (ExtendedSourceEntry info: sources) {
                int pos = data.indexOf(info);
                if (pos >= 0) {
                    selectionModel.addSelectionInterval(pos, pos);
                }
            }
        }

        public List<SourceEntry> getSources() {
            return new ArrayList<>(data);
        }

        public boolean canMove(int i) {
            int[] sel = tblActiveSources.getSelectedRows();
            if (sel.length == 0)
                return false;
            if (i < 0)
                return sel[0] >= -i;
                else if (i > 0)
                    return sel[sel.length-1] <= getRowCount()-1 - i;
                else
                    return true;
        }

        public void move(int i) {
            if (!canMove(i)) return;
            int[] sel = tblActiveSources.getSelectedRows();
            for (int row: sel) {
                SourceEntry t1 = data.get(row);
                SourceEntry t2 = data.get(row + i);
                data.set(row, t2);
                data.set(row + i, t1);
            }
            selectionModel.clearSelection();
            for (int row: sel) {
                selectionModel.addSelectionInterval(row + i, row + i);
            }
        }
    }

    public static class ExtendedSourceEntry extends SourceEntry implements Comparable<ExtendedSourceEntry> {
        public String simpleFileName;
        public String version;
        public String author;
        public String link;
        public String description;
        public Integer minJosmVersion;

        public ExtendedSourceEntry(String simpleFileName, String url) {
            super(url, null, null, true);
            this.simpleFileName = simpleFileName;
        }

        /**
         * @return string representation for GUI list or menu entry
         */
        public String getDisplayName() {
            return title == null ? simpleFileName : title;
        }

        private void appendRow(StringBuilder s, String th, String td) {
            s.append("<tr><th>").append(th).append("</th><td>").append(td).append("</td</tr>");
        }

        public String getTooltip() {
            StringBuilder s = new StringBuilder();
            appendRow(s, tr("Short Description:"), getDisplayName());
            appendRow(s, tr("URL:"), url);
            if (author != null) {
                appendRow(s, tr("Author:"), author);
            }
            if (link != null) {
                appendRow(s, tr("Webpage:"), link);
            }
            if (description != null) {
                appendRow(s, tr("Description:"), description);
            }
            if (version != null) {
                appendRow(s, tr("Version:"), version);
            }
            if (minJosmVersion != null) {
                appendRow(s, tr("Minimum JOSM Version:"), Integer.toString(minJosmVersion));
            }
            return "<html><style>th{text-align:right}td{width:400px}</style>"
                    + "<table>" + s + "</table></html>";
        }

        @Override
        public String toString() {
            return "<html><b>" + getDisplayName() + "</b>"
                    + (author == null ? "" : " <span color=\"gray\">" + tr("by {0}", author) + "</color>")
                    + "</html>";
        }

        @Override
        public int compareTo(ExtendedSourceEntry o) {
            if (url.startsWith("resource") && !o.url.startsWith("resource"))
                return -1;
            if (o.url.startsWith("resource"))
                return 1;
            else
                return getDisplayName().compareToIgnoreCase(o.getDisplayName());
        }
    }

    private static void prepareFileChooser(String url, AbstractFileChooser fc) {
        if (url == null || url.trim().isEmpty()) return;
        URL sourceUrl = null;
        try {
            sourceUrl = new URL(url);
        } catch (MalformedURLException e) {
            File f = new File(url);
            if (f.isFile()) {
                f = f.getParentFile();
            }
            if (f != null) {
                fc.setCurrentDirectory(f);
            }
            return;
        }
        if (sourceUrl.getProtocol().startsWith("file")) {
            File f = new File(sourceUrl.getPath());
            if (f.isFile()) {
                f = f.getParentFile();
            }
            if (f != null) {
                fc.setCurrentDirectory(f);
            }
        }
    }

    protected class EditSourceEntryDialog extends ExtendedDialog {

        private JosmTextField tfTitle;
        private JosmTextField tfURL;
        private JCheckBox cbActive;

        public EditSourceEntryDialog(Component parent, String title, SourceEntry e) {
            super(parent, title, new String[] {tr("Ok"), tr("Cancel")});

            JPanel p = new JPanel(new GridBagLayout());

            tfTitle = new JosmTextField(60);
            p.add(new JLabel(tr("Name (optional):")), GBC.std().insets(15, 0, 5, 5));
            p.add(tfTitle, GBC.eol().insets(0, 0, 5, 5));

            tfURL = new JosmTextField(60);
            p.add(new JLabel(tr("URL / File:")), GBC.std().insets(15, 0, 5, 0));
            p.add(tfURL, GBC.std().insets(0, 0, 5, 5));
            JButton fileChooser = new JButton(new LaunchFileChooserAction());
            fileChooser.setMargin(new Insets(0, 0, 0, 0));
            p.add(fileChooser, GBC.eol().insets(0, 0, 5, 5));

            if (e != null) {
                if (e.title != null) {
                    tfTitle.setText(e.title);
                }
                tfURL.setText(e.url);
            }

            if (canEnable) {
                cbActive = new JCheckBox(tr("active"), e != null ? e.active : true);
                p.add(cbActive, GBC.eol().insets(15, 0, 5, 0));
            }
            setButtonIcons(new String[] {"ok", "cancel"});
            setContent(p);

            // Make OK button enabled only when a file/URL has been set
            tfURL.getDocument().addDocumentListener(new DocumentListener() {
                @Override
                public void insertUpdate(DocumentEvent e) {
                    updateOkButtonState();
                }

                @Override
                public void removeUpdate(DocumentEvent e) {
                    updateOkButtonState();
                }

                @Override
                public void changedUpdate(DocumentEvent e) {
                    updateOkButtonState();
                }
            });
        }

        private void updateOkButtonState() {
            buttons.get(0).setEnabled(!Utils.strip(tfURL.getText()).isEmpty());
        }

        @Override
        public void setupDialog() {
            super.setupDialog();
            updateOkButtonState();
        }

        class LaunchFileChooserAction extends AbstractAction {
            public LaunchFileChooserAction() {
                putValue(SMALL_ICON, ImageProvider.get("open"));
                putValue(SHORT_DESCRIPTION, tr("Launch a file chooser to select a file"));
            }

            @Override
            public void actionPerformed(ActionEvent e) {
                FileFilter ff;
                switch (sourceType) {
                case MAP_PAINT_STYLE:
                    ff = new ExtensionFileFilter("xml,mapcss,css,zip", "xml", tr("Map paint style file (*.xml, *.mapcss, *.zip)"));
                    break;
                case TAGGING_PRESET:
                    ff = new ExtensionFileFilter("xml,zip", "xml", tr("Preset definition file (*.xml, *.zip)"));
                    break;
                case TAGCHECKER_RULE:
                    ff = new ExtensionFileFilter("validator.mapcss,zip", "validator.mapcss", tr("Tag checker rule (*.validator.mapcss, *.zip)"));
                    break;
                default:
                    Main.error("Unsupported source type: "+sourceType);
                    return;
                }
                FileChooserManager fcm = new FileChooserManager(true)
                        .createFileChooser(true, null, Arrays.asList(ff, FileFilterAllFiles.getInstance()), ff, JFileChooser.FILES_ONLY);
                prepareFileChooser(tfURL.getText(), fcm.getFileChooser());
                AbstractFileChooser fc = fcm.openFileChooser(JOptionPane.getFrameForComponent(SourceEditor.this));
                if (fc != null) {
                    tfURL.setText(fc.getSelectedFile().toString());
                }
            }
        }

        @Override
        public String getTitle() {
            return tfTitle.getText();
        }

        public String getURL() {
            return tfURL.getText();
        }

        public boolean active() {
            if (!canEnable)
                throw new UnsupportedOperationException();
            return cbActive.isSelected();
        }
    }

    class NewActiveSourceAction extends AbstractAction {
        public NewActiveSourceAction() {
            putValue(NAME, tr("New"));
            putValue(SHORT_DESCRIPTION, getStr(I18nString.NEW_SOURCE_ENTRY_TOOLTIP));
            putValue(SMALL_ICON, ImageProvider.get("dialogs", "add"));
        }

        @Override
        public void actionPerformed(ActionEvent evt) {
            EditSourceEntryDialog editEntryDialog = new EditSourceEntryDialog(
                    SourceEditor.this,
                    getStr(I18nString.NEW_SOURCE_ENTRY),
                    null);
            editEntryDialog.showDialog();
            if (editEntryDialog.getValue() == 1) {
                boolean active = true;
                if (canEnable) {
                    active = editEntryDialog.active();
                }
                activeSourcesModel.addSource(new SourceEntry(
                        editEntryDialog.getURL(),
                        null, editEntryDialog.getTitle(), active));
                activeSourcesModel.fireTableDataChanged();
            }
        }
    }

    class RemoveActiveSourcesAction extends AbstractAction implements ListSelectionListener {

        public RemoveActiveSourcesAction() {
            putValue(NAME, tr("Remove"));
            putValue(SHORT_DESCRIPTION, getStr(I18nString.REMOVE_SOURCE_TOOLTIP));
            putValue(SMALL_ICON, ImageProvider.get("dialogs", "delete"));
            updateEnabledState();
        }

        protected final void updateEnabledState() {
            setEnabled(tblActiveSources.getSelectedRowCount() > 0);
        }

        @Override
        public void valueChanged(ListSelectionEvent e) {
            updateEnabledState();
        }

        @Override
        public void actionPerformed(ActionEvent e) {
            activeSourcesModel.removeSelected();
        }
    }

    class EditActiveSourceAction extends AbstractAction implements ListSelectionListener {
        public EditActiveSourceAction() {
            putValue(NAME, tr("Edit"));
            putValue(SHORT_DESCRIPTION, getStr(I18nString.EDIT_SOURCE_TOOLTIP));
            putValue(SMALL_ICON, ImageProvider.get("dialogs", "edit"));
            updateEnabledState();
        }

        protected final void updateEnabledState() {
            setEnabled(tblActiveSources.getSelectedRowCount() == 1);
        }

        @Override
        public void valueChanged(ListSelectionEvent e) {
            updateEnabledState();
        }

        @Override
        public void actionPerformed(ActionEvent evt) {
            int pos = tblActiveSources.getSelectedRow();
            if (pos < 0 || pos >= tblActiveSources.getRowCount())
                return;

            SourceEntry e = (SourceEntry) activeSourcesModel.getValueAt(pos, 1);

            EditSourceEntryDialog editEntryDialog = new EditSourceEntryDialog(
                    SourceEditor.this, tr("Edit source entry:"), e);
            editEntryDialog.showDialog();
            if (editEntryDialog.getValue() == 1) {
                if (e.title != null || !"".equals(editEntryDialog.getTitle())) {
                    e.title = editEntryDialog.getTitle();
                    if ("".equals(e.title)) {
                        e.title = null;
                    }
                }
                e.url = editEntryDialog.getURL();
                if (canEnable) {
                    e.active = editEntryDialog.active();
                }
                activeSourcesModel.fireTableRowsUpdated(pos, pos);
            }
        }
    }

    /**
     * The action to move the currently selected entries up or down in the list.
     */
    class MoveUpDownAction extends AbstractAction implements ListSelectionListener, TableModelListener {
        private final int increment;

        public MoveUpDownAction(boolean isDown) {
            increment = isDown ? 1 : -1;
            putValue(SMALL_ICON, isDown ? ImageProvider.get("dialogs", "down") : ImageProvider.get("dialogs", "up"));
            putValue(SHORT_DESCRIPTION, isDown ? tr("Move the selected entry one row down.") : tr("Move the selected entry one row up."));
            updateEnabledState();
        }

        public final void updateEnabledState() {
            setEnabled(activeSourcesModel.canMove(increment));
        }

        @Override
        public void actionPerformed(ActionEvent e) {
            activeSourcesModel.move(increment);
        }

        @Override
        public void valueChanged(ListSelectionEvent e) {
            updateEnabledState();
        }

        @Override
        public void tableChanged(TableModelEvent e) {
            updateEnabledState();
        }
    }

    class ActivateSourcesAction extends AbstractAction implements ListSelectionListener {
        public ActivateSourcesAction() {
            putValue(SHORT_DESCRIPTION, getStr(I18nString.ACTIVATE_TOOLTIP));
            putValue(SMALL_ICON, ImageProvider.get("preferences", "activate-right"));
            updateEnabledState();
        }

        protected final void updateEnabledState() {
            setEnabled(lstAvailableSources.getSelectedIndices().length > 0);
        }

        @Override
        public void valueChanged(ListSelectionEvent e) {
            updateEnabledState();
        }

        @Override
        public void actionPerformed(ActionEvent e) {
            List<ExtendedSourceEntry> sources = availableSourcesModel.getSelected();
            int josmVersion = Version.getInstance().getVersion();
            if (josmVersion != Version.JOSM_UNKNOWN_VERSION) {
                Collection<String> messages = new ArrayList<>();
                for (ExtendedSourceEntry entry : sources) {
                    if (entry.minJosmVersion != null && entry.minJosmVersion > josmVersion) {
                        messages.add(tr("Entry ''{0}'' requires JOSM Version {1}. (Currently running: {2})",
                                entry.title,
                                Integer.toString(entry.minJosmVersion),
                                Integer.toString(josmVersion))
                        );
                    }
                }
                if (!messages.isEmpty()) {
                    ExtendedDialog dlg = new ExtendedDialog(Main.parent, tr("Warning"), new String[] {tr("Cancel"), tr("Continue anyway")});
                    dlg.setButtonIcons(new Icon[] {
                        ImageProvider.get("cancel"),
                        new ImageProvider("ok").setMaxSize(ImageSizes.LARGEICON).addOverlay(
                                new ImageOverlay(new ImageProvider("warning-small"), 0.5, 0.5, 1.0, 1.0)).get()
                    });
                    dlg.setToolTipTexts(new String[] {
                        tr("Cancel and return to the previous dialog"),
                        tr("Ignore warning and install style anyway")});
                    dlg.setContent("<html>" + tr("Some entries have unmet dependencies:") +
                            "<br>" + Utils.join("<br>", messages) + "</html>");
                    dlg.setIcon(JOptionPane.WARNING_MESSAGE);
                    if (dlg.showDialog().getValue() != 2)
                        return;
                }
            }
            activeSourcesModel.addExtendedSourceEntries(sources);
        }
    }

    class ResetAction extends AbstractAction {

        public ResetAction() {
            putValue(NAME, tr("Reset"));
            putValue(SHORT_DESCRIPTION, tr("Reset to default"));
            putValue(SMALL_ICON, ImageProvider.get("preferences", "reset"));
        }

        @Override
        public void actionPerformed(ActionEvent e) {
            activeSourcesModel.setActiveSources(getDefault());
        }
    }

    class ReloadSourcesAction extends AbstractAction {
        private final String url;
        private final transient List<SourceProvider> sourceProviders;

        public ReloadSourcesAction(String url, List<SourceProvider> sourceProviders) {
            putValue(NAME, tr("Reload"));
            putValue(SHORT_DESCRIPTION, tr(getStr(I18nString.RELOAD_ALL_AVAILABLE), url));
            putValue(SMALL_ICON, ImageProvider.get("dialogs", "refresh"));
            this.url = url;
            this.sourceProviders = sourceProviders;
            setEnabled(!Main.isOffline(OnlineResource.JOSM_WEBSITE));
        }

        @Override
        public void actionPerformed(ActionEvent e) {
            CachedFile.cleanup(url);
            reloadAvailableSources(url, sourceProviders);
        }
    }

    protected static class IconPathTableModel extends AbstractTableModel {
        private List<String> data;
        private DefaultListSelectionModel selectionModel;

        public IconPathTableModel(DefaultListSelectionModel selectionModel) {
            this.selectionModel = selectionModel;
            this.data = new ArrayList<>();
        }

        @Override
        public int getColumnCount() {
            return 1;
        }

        @Override
        public int getRowCount() {
            return data == null ? 0 : data.size();
        }

        @Override
        public Object getValueAt(int rowIndex, int columnIndex) {
            return data.get(rowIndex);
        }

        @Override
        public boolean isCellEditable(int rowIndex, int columnIndex) {
            return true;
        }

        @Override
        public void setValueAt(Object aValue, int rowIndex, int columnIndex) {
            updatePath(rowIndex, (String) aValue);
        }

        public void setIconPaths(Collection<String> paths) {
            data.clear();
            if (paths != null) {
                data.addAll(paths);
            }
            sort();
            fireTableDataChanged();
        }

        public void addPath(String path) {
            if (path == null) return;
            data.add(path);
            sort();
            fireTableDataChanged();
            int idx = data.indexOf(path);
            if (idx >= 0) {
                selectionModel.setSelectionInterval(idx, idx);
            }
        }

        public void updatePath(int pos, String path) {
            if (path == null) return;
            if (pos < 0 || pos >= getRowCount()) return;
            data.set(pos, path);
            sort();
            fireTableDataChanged();
            int idx = data.indexOf(path);
            if (idx >= 0) {
                selectionModel.setSelectionInterval(idx, idx);
            }
        }

        public void removeSelected() {
            Iterator<String> it = data.iterator();
            int i = 0;
            while (it.hasNext()) {
                it.next();
                if (selectionModel.isSelectedIndex(i)) {
                    it.remove();
                }
                i++;
            }
            fireTableDataChanged();
            selectionModel.clearSelection();
        }

        protected void sort() {
            Collections.sort(
                    data,
                    new Comparator<String>() {
                        @Override
                        public int compare(String o1, String o2) {
                            if (o1.isEmpty() && o2.isEmpty())
                                return 0;
                            if (o1.isEmpty()) return 1;
                            if (o2.isEmpty()) return -1;
                            return o1.compareTo(o2);
                        }
                    }
                    );
        }

        public List<String> getIconPaths() {
            return new ArrayList<>(data);
        }
    }

    class NewIconPathAction extends AbstractAction {
        public NewIconPathAction() {
            putValue(NAME, tr("New"));
            putValue(SHORT_DESCRIPTION, tr("Add a new icon path"));
            putValue(SMALL_ICON, ImageProvider.get("dialogs", "add"));
        }

        @Override
        public void actionPerformed(ActionEvent e) {
            iconPathsModel.addPath("");
            tblIconPaths.editCellAt(iconPathsModel.getRowCount() -1, 0);
        }
    }

    class RemoveIconPathAction extends AbstractAction implements ListSelectionListener {
        public RemoveIconPathAction() {
            putValue(NAME, tr("Remove"));
            putValue(SHORT_DESCRIPTION, tr("Remove the selected icon paths"));
            putValue(SMALL_ICON, ImageProvider.get("dialogs", "delete"));
            updateEnabledState();
        }

        protected final void updateEnabledState() {
            setEnabled(tblIconPaths.getSelectedRowCount() > 0);
        }

        @Override
        public void valueChanged(ListSelectionEvent e) {
            updateEnabledState();
        }

        @Override
        public void actionPerformed(ActionEvent e) {
            iconPathsModel.removeSelected();
        }
    }

    class EditIconPathAction extends AbstractAction implements ListSelectionListener {
        public EditIconPathAction() {
            putValue(NAME, tr("Edit"));
            putValue(SHORT_DESCRIPTION, tr("Edit the selected icon path"));
            putValue(SMALL_ICON, ImageProvider.get("dialogs", "edit"));
            updateEnabledState();
        }

        protected final void updateEnabledState() {
            setEnabled(tblIconPaths.getSelectedRowCount() == 1);
        }

        @Override
        public void valueChanged(ListSelectionEvent e) {
            updateEnabledState();
        }

        @Override
        public void actionPerformed(ActionEvent e) {
            int row = tblIconPaths.getSelectedRow();
            tblIconPaths.editCellAt(row, 0);
        }
    }

    static class SourceEntryListCellRenderer extends JLabel implements ListCellRenderer<ExtendedSourceEntry> {

        private final ImageIcon GREEN_CHECK = ImageProvider.getIfAvailable("misc", "green_check");
        private final ImageIcon GRAY_CHECK = ImageProvider.getIfAvailable("misc", "gray_check");
        private final Map<String, SourceEntry> entryByUrl = new HashMap<>();

        @Override
        public Component getListCellRendererComponent(JList<? extends ExtendedSourceEntry> list, ExtendedSourceEntry value,
                int index, boolean isSelected, boolean cellHasFocus) {
            String s = value.toString();
            setText(s);
            if (isSelected) {
                setBackground(list.getSelectionBackground());
                setForeground(list.getSelectionForeground());
            } else {
                setBackground(list.getBackground());
                setForeground(list.getForeground());
            }
            setEnabled(list.isEnabled());
            setFont(list.getFont());
            setFont(getFont().deriveFont(Font.PLAIN));
            setOpaque(true);
            setToolTipText(value.getTooltip());
            final SourceEntry sourceEntry = entryByUrl.get(value.url);
            setIcon(sourceEntry == null ? null : sourceEntry.active ? GREEN_CHECK : GRAY_CHECK);
            return this;
        }

        public void updateSources(List<SourceEntry> sources) {
            synchronized (entryByUrl) {
                entryByUrl.clear();
                for (SourceEntry i : sources) {
                    entryByUrl.put(i.url, i);
                }
            }
        }
    }

    class SourceLoader extends PleaseWaitRunnable {
        private final String url;
        private final List<SourceProvider> sourceProviders;
        private BufferedReader reader;
        private boolean canceled;
        private final List<ExtendedSourceEntry> sources = new ArrayList<>();

        public SourceLoader(String url, List<SourceProvider> sourceProviders) {
            super(tr(getStr(I18nString.LOADING_SOURCES_FROM), url));
            this.url = url;
            this.sourceProviders = sourceProviders;
        }

        @Override
        protected void cancel() {
            canceled = true;
            Utils.close(reader);
        }

        protected void warn(Exception e) {
            String emsg = e.getMessage() != null ? e.getMessage() : e.toString();
            emsg = emsg.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
            final String msg = tr(getStr(I18nString.FAILED_TO_LOAD_SOURCES_FROM), url, emsg);

            GuiHelper.runInEDT(new Runnable() {
                @Override
                public void run() {
                    HelpAwareOptionPane.showOptionDialog(
                            Main.parent,
                            msg,
                            tr("Error"),
                            JOptionPane.ERROR_MESSAGE,
                            ht(getStr(I18nString.FAILED_TO_LOAD_SOURCES_FROM_HELP_TOPIC))
                            );
                }
            });
        }

        @Override
        protected void realRun() throws SAXException, IOException, OsmTransferException {
            String lang = LanguageInfo.getLanguageCodeXML();
            try {
                sources.addAll(getDefault());

                for (SourceProvider provider : sourceProviders) {
                    for (SourceEntry src : provider.getSources()) {
                        if (src instanceof ExtendedSourceEntry) {
                            sources.add((ExtendedSourceEntry) src);
                        }
                    }
                }

                InputStream stream = new CachedFile(url).getInputStream();
                reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8));

                String line;
                ExtendedSourceEntry last = null;

                while ((line = reader.readLine()) != null && !canceled) {
                    if (line.trim().isEmpty()) {
                        continue; // skip empty lines
                    }
                    if (line.startsWith("\t")) {
                        Matcher m = Pattern.compile("^\t([^:]+): *(.+)$").matcher(line);
                        if (!m.matches()) {
                            Main.error(tr(getStr(I18nString.ILLEGAL_FORMAT_OF_ENTRY), url, line));
                            continue;
                        }
                        if (last != null) {
                            String key = m.group(1);
                            String value = m.group(2);
                            if ("author".equals(key) && last.author == null) {
                                last.author = value;
                            } else if ("version".equals(key)) {
                                last.version = value;
                            } else if ("link".equals(key) && last.link == null) {
                                last.link = value;
                            } else if ("description".equals(key) && last.description == null) {
                                last.description = value;
                            } else if ((lang + "shortdescription").equals(key) && last.title == null) {
                                last.title = value;
                            } else if ("shortdescription".equals(key) && last.title == null) {
                                last.title = value;
                            } else if ((lang + "title").equals(key) && last.title == null) {
                                last.title = value;
                            } else if ("title".equals(key) && last.title == null) {
                                last.title = value;
                            } else if ("name".equals(key) && last.name == null) {
                                last.name = value;
                            } else if ((lang + "author").equals(key)) {
                                last.author = value;
                            } else if ((lang + "link").equals(key)) {
                                last.link = value;
                            } else if ((lang + "description").equals(key)) {
                                last.description = value;
                            } else if ("min-josm-version".equals(key)) {
                                try {
                                    last.minJosmVersion = Integer.valueOf(value);
                                } catch (NumberFormatException e) {
                                    // ignore
                                }
                            }
                        }
                    } else {
                        last = null;
                        Matcher m = Pattern.compile("^(.+);(.+)$").matcher(line);
                        if (m.matches()) {
                            sources.add(last = new ExtendedSourceEntry(m.group(1), m.group(2)));
                        } else {
                            Main.error(tr(getStr(I18nString.ILLEGAL_FORMAT_OF_ENTRY), url, line));
                        }
                    }
                }
            } catch (IOException e) {
                if (canceled)
                    // ignore the exception and return
                    return;
                OsmTransferException ex = new OsmTransferException(e);
                ex.setUrl(url);
                warn(ex);
                return;
            }
        }

        @Override
        protected void finish() {
            Collections.sort(sources);
            availableSourcesModel.setSources(sources);
        }
    }

    static class SourceEntryTableCellRenderer extends DefaultTableCellRenderer {
        @Override
        public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) {
            if (value == null)
                return this;
            return super.getTableCellRendererComponent(table,
                    fromSourceEntry((SourceEntry) value), isSelected, hasFocus, row, column);
        }

        private String fromSourceEntry(SourceEntry entry) {
            if (entry == null)
                return null;
            StringBuilder s = new StringBuilder("<html><b>");
            if (entry.title != null) {
                s.append(entry.title).append("</b> <span color=\"gray\">");
            }
            s.append(entry.url);
            if (entry.title != null) {
                s.append("</span>");
            }
            s.append("</html>");
            return s.toString();
        }
    }

    class FileOrUrlCellEditor extends JPanel implements TableCellEditor {
        private JosmTextField tfFileName;
        private CopyOnWriteArrayList<CellEditorListener> listeners;
        private String value;
        private boolean isFile;

        /**
         * build the GUI
         */
        protected final void build() {
            setLayout(new GridBagLayout());
            GridBagConstraints gc = new GridBagConstraints();
            gc.gridx = 0;
            gc.gridy = 0;
            gc.fill = GridBagConstraints.BOTH;
            gc.weightx = 1.0;
            gc.weighty = 1.0;
            add(tfFileName = new JosmTextField(), gc);

            gc.gridx = 1;
            gc.gridy = 0;
            gc.fill = GridBagConstraints.BOTH;
            gc.weightx = 0.0;
            gc.weighty = 1.0;
            add(new JButton(new LaunchFileChooserAction()));

            tfFileName.addFocusListener(
                    new FocusAdapter() {
                        @Override
                        public void focusGained(FocusEvent e) {
                            tfFileName.selectAll();
                        }
                    }
                    );
        }

        public FileOrUrlCellEditor(boolean isFile) {
            this.isFile = isFile;
            listeners = new CopyOnWriteArrayList<>();
            build();
        }

        @Override
        public void addCellEditorListener(CellEditorListener l) {
            if (l != null) {
                listeners.addIfAbsent(l);
            }
        }

        protected void fireEditingCanceled() {
            for (CellEditorListener l: listeners) {
                l.editingCanceled(new ChangeEvent(this));
            }
        }

        protected void fireEditingStopped() {
            for (CellEditorListener l: listeners) {
                l.editingStopped(new ChangeEvent(this));
            }
        }

        @Override
        public void cancelCellEditing() {
            fireEditingCanceled();
        }

        @Override
        public Object getCellEditorValue() {
            return value;
        }

        @Override
        public boolean isCellEditable(EventObject anEvent) {
            if (anEvent instanceof MouseEvent)
                return ((MouseEvent) anEvent).getClickCount() >= 2;
                return true;
        }

        @Override
        public void removeCellEditorListener(CellEditorListener l) {
            listeners.remove(l);
        }

        @Override
        public boolean shouldSelectCell(EventObject anEvent) {
            return true;
        }

        @Override
        public boolean stopCellEditing() {
            value = tfFileName.getText();
            fireEditingStopped();
            return true;
        }

        public void setInitialValue(String initialValue) {
            this.value = initialValue;
            if (initialValue == null) {
                this.tfFileName.setText("");
            } else {
                this.tfFileName.setText(initialValue);
            }
        }

        @Override
        public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int column) {
            setInitialValue((String) value);
            tfFileName.selectAll();
            return this;
        }

        class LaunchFileChooserAction extends AbstractAction {
            public LaunchFileChooserAction() {
                putValue(NAME, "...");
                putValue(SHORT_DESCRIPTION, tr("Launch a file chooser to select a file"));
            }

            @Override
            public void actionPerformed(ActionEvent e) {
                FileChooserManager fcm = new FileChooserManager(true).createFileChooser();
                if (!isFile) {
                    fcm.getFileChooser().setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
                }
                prepareFileChooser(tfFileName.getText(), fcm.getFileChooser());
                AbstractFileChooser fc = fcm.openFileChooser(JOptionPane.getFrameForComponent(SourceEditor.this));
                if (fc != null) {
                    tfFileName.setText(fc.getSelectedFile().toString());
                }
            }
        }
    }

    public abstract static class SourcePrefHelper {

        private final String pref;

        /**
         * Constructs a new {@code SourcePrefHelper} for the given preference key.
         * @param pref The preference key
         */
        public SourcePrefHelper(String pref) {
            this.pref = pref;
        }

        /**
         * Returns the default sources provided by JOSM core.
         * @return the default sources provided by JOSM core
         */
        public abstract Collection<ExtendedSourceEntry> getDefault();

        public abstract Map<String, String> serialize(SourceEntry entry);

        public abstract SourceEntry deserialize(Map<String, String> entryStr);

        /**
         * Returns the list of sources.
         * @return The list of sources
         */
        public List<SourceEntry> get() {

            Collection<Map<String, String>> src = Main.pref.getListOfStructs(pref, (Collection<Map<String, String>>) null);
            if (src == null)
                return new ArrayList<SourceEntry>(getDefault());

            List<SourceEntry> entries = new ArrayList<>();
            for (Map<String, String> sourcePref : src) {
                SourceEntry e = deserialize(new HashMap<>(sourcePref));
                if (e != null) {
                    entries.add(e);
                }
            }
            return entries;
        }

        public boolean put(Collection<? extends SourceEntry> entries) {
            Collection<Map<String, String>> setting = new ArrayList<>(entries.size());
            for (SourceEntry e : entries) {
                setting.add(serialize(e));
            }
            return Main.pref.putListOfStructs(pref, setting);
        }

        /**
         * Returns the set of active source URLs.
         * @return The set of active source URLs.
         */
        public final Set<String> getActiveUrls() {
            Set<String> urls = new LinkedHashSet<>(); // retain order
            for (SourceEntry e : get()) {
                if (e.active) {
                    urls.add(e.url);
                }
            }
            return urls;
        }
    }

    /**
     * Defers loading of sources to the first time the adequate tab is selected.
     * @param tab The preferences tab
     * @param component The tab component
     * @since 6670
     */
    public final void deferLoading(final DefaultTabPreferenceSetting tab, final Component component) {
        tab.getTabPane().addChangeListener(
                new ChangeListener() {
                    @Override
                    public void stateChanged(ChangeEvent e) {
                        if (tab.getTabPane().getSelectedComponent() == component) {
                            SourceEditor.this.initiallyLoadAvailableSources();
                        }
                    }
                }
                );
    }
}
