001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.preferences.plugin;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005import static org.openstreetmap.josm.tools.I18n.trn;
006
007import java.awt.BorderLayout;
008import java.awt.Component;
009import java.awt.GraphicsEnvironment;
010import java.awt.GridBagConstraints;
011import java.awt.GridBagLayout;
012import java.awt.GridLayout;
013import java.awt.Insets;
014import java.awt.event.ActionEvent;
015import java.awt.event.ComponentAdapter;
016import java.awt.event.ComponentEvent;
017import java.lang.reflect.InvocationTargetException;
018import java.util.ArrayList;
019import java.util.Arrays;
020import java.util.Collection;
021import java.util.Collections;
022import java.util.Iterator;
023import java.util.LinkedList;
024import java.util.List;
025import java.util.Set;
026import java.util.regex.Pattern;
027
028import javax.swing.AbstractAction;
029import javax.swing.BorderFactory;
030import javax.swing.DefaultListModel;
031import javax.swing.JButton;
032import javax.swing.JCheckBox;
033import javax.swing.JLabel;
034import javax.swing.JList;
035import javax.swing.JOptionPane;
036import javax.swing.JPanel;
037import javax.swing.JScrollPane;
038import javax.swing.JTabbedPane;
039import javax.swing.JTextArea;
040import javax.swing.SwingUtilities;
041import javax.swing.UIManager;
042import javax.swing.event.DocumentEvent;
043import javax.swing.event.DocumentListener;
044
045import org.openstreetmap.josm.Main;
046import org.openstreetmap.josm.actions.ExpertToggleAction;
047import org.openstreetmap.josm.data.Version;
048import org.openstreetmap.josm.gui.HelpAwareOptionPane;
049import org.openstreetmap.josm.gui.HelpAwareOptionPane.ButtonSpec;
050import org.openstreetmap.josm.gui.help.HelpUtil;
051import org.openstreetmap.josm.gui.preferences.DefaultTabPreferenceSetting;
052import org.openstreetmap.josm.gui.preferences.PreferenceSetting;
053import org.openstreetmap.josm.gui.preferences.PreferenceSettingFactory;
054import org.openstreetmap.josm.gui.preferences.PreferenceTabbedPane;
055import org.openstreetmap.josm.gui.preferences.PreferenceTabbedPane.PreferencePanel;
056import org.openstreetmap.josm.gui.util.GuiHelper;
057import org.openstreetmap.josm.gui.widgets.JosmTextField;
058import org.openstreetmap.josm.gui.widgets.SelectAllOnFocusGainedDecorator;
059import org.openstreetmap.josm.plugins.PluginDownloadTask;
060import org.openstreetmap.josm.plugins.PluginInformation;
061import org.openstreetmap.josm.plugins.ReadLocalPluginInformationTask;
062import org.openstreetmap.josm.plugins.ReadRemotePluginInformationTask;
063import org.openstreetmap.josm.tools.GBC;
064import org.openstreetmap.josm.tools.ImageProvider;
065import org.openstreetmap.josm.tools.Utils;
066
067/**
068 * Preference settings for plugins.
069 * @since 168
070 */
071public final class PluginPreference extends DefaultTabPreferenceSetting {
072
073    /**
074     * Factory used to create a new {@code PluginPreference}.
075     */
076    public static class Factory implements PreferenceSettingFactory {
077        @Override
078        public PreferenceSetting createPreferenceSetting() {
079            return new PluginPreference();
080        }
081    }
082
083    private JosmTextField tfFilter;
084    private PluginListPanel pnlPluginPreferences;
085    private PluginPreferencesModel model;
086    private JScrollPane spPluginPreferences;
087    private PluginUpdatePolicyPanel pnlPluginUpdatePolicy;
088
089    /**
090     * is set to true if this preference pane has been selected by the user
091     */
092    private boolean pluginPreferencesActivated;
093
094    private PluginPreference() {
095        super(/* ICON(preferences/) */ "plugin", tr("Plugins"), tr("Configure available plugins."), false, new JTabbedPane());
096    }
097
098    /**
099     * Returns the download summary string to be shown.
100     * @param task The plugin download task that has completed
101     * @return the download summary string to be shown. Contains summary of success/failed plugins.
102     */
103    public static String buildDownloadSummary(PluginDownloadTask task) {
104        Collection<PluginInformation> downloaded = task.getDownloadedPlugins();
105        Collection<PluginInformation> failed = task.getFailedPlugins();
106        Exception exception = task.getLastException();
107        StringBuilder sb = new StringBuilder();
108        if (!downloaded.isEmpty()) {
109            sb.append(trn(
110                    "The following plugin has been downloaded <strong>successfully</strong>:",
111                    "The following {0} plugins have been downloaded <strong>successfully</strong>:",
112                    downloaded.size(),
113                    downloaded.size()
114                    ));
115            sb.append("<ul>");
116            for (PluginInformation pi: downloaded) {
117                sb.append("<li>").append(pi.name).append(" (").append(pi.version).append(")</li>");
118            }
119            sb.append("</ul>");
120        }
121        if (!failed.isEmpty()) {
122            sb.append(trn(
123                    "Downloading the following plugin has <strong>failed</strong>:",
124                    "Downloading the following {0} plugins has <strong>failed</strong>:",
125                    failed.size(),
126                    failed.size()
127                    ));
128            sb.append("<ul>");
129            for (PluginInformation pi: failed) {
130                sb.append("<li>").append(pi.name).append("</li>");
131            }
132            sb.append("</ul>");
133        }
134        if (exception != null) {
135            // Same i18n string in ExceptionUtil.explainBadRequest()
136            sb.append(tr("<br>Error message(untranslated): {0}", exception.getMessage()));
137        }
138        return sb.toString();
139    }
140
141    /**
142     * Notifies user about result of a finished plugin download task.
143     * @param parent The parent component
144     * @param task The finished plugin download task
145     * @param restartRequired true if a restart is required
146     * @since 6797
147     */
148    public static void notifyDownloadResults(final Component parent, PluginDownloadTask task, boolean restartRequired) {
149        final Collection<PluginInformation> failed = task.getFailedPlugins();
150        final StringBuilder sb = new StringBuilder();
151        sb.append("<html>")
152          .append(buildDownloadSummary(task));
153        if (restartRequired) {
154            sb.append(tr("Please restart JOSM to activate the downloaded plugins."));
155        }
156        sb.append("</html>");
157        if (!GraphicsEnvironment.isHeadless()) {
158            GuiHelper.runInEDTAndWait(() -> HelpAwareOptionPane.showOptionDialog(
159                    parent,
160                    sb.toString(),
161                    tr("Update plugins"),
162                    !failed.isEmpty() ? JOptionPane.WARNING_MESSAGE : JOptionPane.INFORMATION_MESSAGE,
163                            HelpUtil.ht("/Preferences/Plugins")
164                    ));
165        }
166    }
167
168    private JPanel buildSearchFieldPanel() {
169        JPanel pnl = new JPanel(new GridBagLayout());
170        pnl.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
171        GridBagConstraints gc = new GridBagConstraints();
172
173        gc.anchor = GridBagConstraints.NORTHWEST;
174        gc.fill = GridBagConstraints.HORIZONTAL;
175        gc.weightx = 0.0;
176        gc.insets = new Insets(0, 0, 0, 3);
177        pnl.add(new JLabel(tr("Search:")), gc);
178
179        gc.gridx = 1;
180        gc.weightx = 1.0;
181        tfFilter = new JosmTextField();
182        pnl.add(tfFilter, gc);
183        tfFilter.setToolTipText(tr("Enter a search expression"));
184        SelectAllOnFocusGainedDecorator.decorate(tfFilter);
185        tfFilter.getDocument().addDocumentListener(new SearchFieldAdapter());
186        return pnl;
187    }
188
189    private JPanel buildActionPanel() {
190        JPanel pnl = new JPanel(new GridLayout(1, 4));
191
192        pnl.add(new JButton(new DownloadAvailablePluginsAction()));
193        pnl.add(new JButton(new UpdateSelectedPluginsAction()));
194        ExpertToggleAction.addVisibilitySwitcher(pnl.add(new JButton(new SelectByListAction())));
195        ExpertToggleAction.addVisibilitySwitcher(pnl.add(new JButton(new ConfigureSitesAction())));
196        return pnl;
197    }
198
199    private JPanel buildPluginListPanel() {
200        JPanel pnl = new JPanel(new BorderLayout());
201        pnl.add(buildSearchFieldPanel(), BorderLayout.NORTH);
202        model = new PluginPreferencesModel();
203        pnlPluginPreferences = new PluginListPanel(model);
204        spPluginPreferences = GuiHelper.embedInVerticalScrollPane(pnlPluginPreferences);
205        spPluginPreferences.getVerticalScrollBar().addComponentListener(
206                new ComponentAdapter() {
207                    @Override
208                    public void componentShown(ComponentEvent e) {
209                        spPluginPreferences.setBorder(UIManager.getBorder("ScrollPane.border"));
210                    }
211
212                    @Override
213                    public void componentHidden(ComponentEvent e) {
214                        spPluginPreferences.setBorder(null);
215                    }
216                }
217                );
218
219        pnl.add(spPluginPreferences, BorderLayout.CENTER);
220        pnl.add(buildActionPanel(), BorderLayout.SOUTH);
221        return pnl;
222    }
223
224    private JTabbedPane buildContentPane() {
225        JTabbedPane pane = getTabPane();
226        pnlPluginUpdatePolicy = new PluginUpdatePolicyPanel();
227        pane.addTab(tr("Plugins"), buildPluginListPanel());
228        pane.addTab(tr("Plugin update policy"), pnlPluginUpdatePolicy);
229        return pane;
230    }
231
232    @Override
233    public void addGui(final PreferenceTabbedPane gui) {
234        GridBagConstraints gc = new GridBagConstraints();
235        gc.weightx = 1.0;
236        gc.weighty = 1.0;
237        gc.anchor = GridBagConstraints.NORTHWEST;
238        gc.fill = GridBagConstraints.BOTH;
239        PreferencePanel plugins = gui.createPreferenceTab(this);
240        plugins.add(buildContentPane(), gc);
241        readLocalPluginInformation();
242        pluginPreferencesActivated = true;
243    }
244
245    private void configureSites() {
246        ButtonSpec[] options = new ButtonSpec[] {
247                new ButtonSpec(
248                        tr("OK"),
249                        ImageProvider.get("ok"),
250                        tr("Accept the new plugin sites and close the dialog"),
251                        null /* no special help topic */
252                        ),
253                        new ButtonSpec(
254                                tr("Cancel"),
255                                ImageProvider.get("cancel"),
256                                tr("Close the dialog"),
257                                null /* no special help topic */
258                                )
259        };
260        PluginConfigurationSitesPanel pnl = new PluginConfigurationSitesPanel();
261
262        int answer = HelpAwareOptionPane.showOptionDialog(
263                pnlPluginPreferences,
264                pnl,
265                tr("Configure Plugin Sites"),
266                JOptionPane.QUESTION_MESSAGE,
267                null,
268                options,
269                options[0],
270                null /* no help topic */
271                );
272        if (answer != 0 /* OK */)
273            return;
274        Main.pref.setPluginSites(pnl.getUpdateSites());
275    }
276
277    /**
278     * Replies the set of plugins waiting for update or download
279     *
280     * @return the set of plugins waiting for update or download
281     */
282    public Set<PluginInformation> getPluginsScheduledForUpdateOrDownload() {
283        return model != null ? model.getPluginsScheduledForUpdateOrDownload() : null;
284    }
285
286    /**
287     * Replies the list of plugins which have been added by the user to the set of activated plugins
288     *
289     * @return the list of newly activated plugins
290     */
291    public List<PluginInformation> getNewlyActivatedPlugins() {
292        return model != null ? model.getNewlyActivatedPlugins() : null;
293    }
294
295    @Override
296    public boolean ok() {
297        if (!pluginPreferencesActivated)
298            return false;
299        pnlPluginUpdatePolicy.rememberInPreferences();
300        if (model.isActivePluginsChanged()) {
301            List<String> l = new LinkedList<>(model.getSelectedPluginNames());
302            Collections.sort(l);
303            Main.pref.putCollection("plugins", l);
304            if (!model.getNewlyDeactivatedPlugins().isEmpty())
305                return true;
306            for (PluginInformation pi : model.getNewlyActivatedPlugins()) {
307                if (!pi.canloadatruntime)
308                    return true;
309            }
310        }
311        return false;
312    }
313
314    /**
315     * Reads locally available information about plugins from the local file system.
316     * Scans cached plugin lists from plugin download sites and locally available
317     * plugin jar files.
318     *
319     */
320    public void readLocalPluginInformation() {
321        final ReadLocalPluginInformationTask task = new ReadLocalPluginInformationTask();
322        Runnable r = () -> {
323            if (!task.isCanceled()) {
324                SwingUtilities.invokeLater(() -> {
325                    model.setAvailablePlugins(task.getAvailablePlugins());
326                    pnlPluginPreferences.refreshView();
327                });
328            }
329        };
330        Main.worker.submit(task);
331        Main.worker.submit(r);
332    }
333
334    /**
335     * The action for downloading the list of available plugins
336     */
337    class DownloadAvailablePluginsAction extends AbstractAction {
338
339        /**
340         * Constructs a new {@code DownloadAvailablePluginsAction}.
341         */
342        DownloadAvailablePluginsAction() {
343            putValue(NAME, tr("Download list"));
344            putValue(SHORT_DESCRIPTION, tr("Download the list of available plugins"));
345            new ImageProvider("download").getResource().attachImageIcon(this);
346        }
347
348        @Override
349        public void actionPerformed(ActionEvent e) {
350            Collection<String> pluginSites = Main.pref.getOnlinePluginSites();
351            if (pluginSites.isEmpty()) {
352                return;
353            }
354            final ReadRemotePluginInformationTask task = new ReadRemotePluginInformationTask(pluginSites);
355            Runnable continuation = () -> {
356                if (!task.isCanceled()) {
357                    SwingUtilities.invokeLater(() -> {
358                        model.updateAvailablePlugins(task.getAvailablePlugins());
359                        pnlPluginPreferences.refreshView();
360                        Main.pref.putInteger("pluginmanager.version", Version.getInstance().getVersion()); // fix #7030
361                    });
362                }
363            };
364            Main.worker.submit(task);
365            Main.worker.submit(continuation);
366        }
367    }
368
369    /**
370     * The action for updating the list of selected plugins
371     */
372    class UpdateSelectedPluginsAction extends AbstractAction {
373        UpdateSelectedPluginsAction() {
374            putValue(NAME, tr("Update plugins"));
375            putValue(SHORT_DESCRIPTION, tr("Update the selected plugins"));
376            new ImageProvider("dialogs", "refresh").getResource().attachImageIcon(this);
377        }
378
379        protected void alertNothingToUpdate() {
380            try {
381                SwingUtilities.invokeAndWait(() -> HelpAwareOptionPane.showOptionDialog(
382                        pnlPluginPreferences,
383                        tr("All installed plugins are up to date. JOSM does not have to download newer versions."),
384                        tr("Plugins up to date"),
385                        JOptionPane.INFORMATION_MESSAGE,
386                        null // FIXME: provide help context
387                        ));
388            } catch (InterruptedException | InvocationTargetException e) {
389                Main.error(e);
390            }
391        }
392
393        @Override
394        public void actionPerformed(ActionEvent e) {
395            final List<PluginInformation> toUpdate = model.getSelectedPlugins();
396            // the async task for downloading plugins
397            final PluginDownloadTask pluginDownloadTask = new PluginDownloadTask(
398                    pnlPluginPreferences,
399                    toUpdate,
400                    tr("Update plugins")
401                    );
402            // the async task for downloading plugin information
403            final ReadRemotePluginInformationTask pluginInfoDownloadTask = new ReadRemotePluginInformationTask(
404                    Main.pref.getOnlinePluginSites());
405
406            // to be run asynchronously after the plugin download
407            //
408            final Runnable pluginDownloadContinuation = () -> {
409                if (pluginDownloadTask.isCanceled())
410                    return;
411                boolean restartRequired = false;
412                for (PluginInformation pi : pluginDownloadTask.getDownloadedPlugins()) {
413                    if (!model.getNewlyActivatedPlugins().contains(pi) || !pi.canloadatruntime) {
414                        restartRequired = true;
415                        break;
416                    }
417                }
418                notifyDownloadResults(pnlPluginPreferences, pluginDownloadTask, restartRequired);
419                model.refreshLocalPluginVersion(pluginDownloadTask.getDownloadedPlugins());
420                model.clearPendingPlugins(pluginDownloadTask.getDownloadedPlugins());
421                GuiHelper.runInEDT(pnlPluginPreferences::refreshView);
422            };
423
424            // to be run asynchronously after the plugin list download
425            //
426            final Runnable pluginInfoDownloadContinuation = () -> {
427                if (pluginInfoDownloadTask.isCanceled())
428                    return;
429                model.updateAvailablePlugins(pluginInfoDownloadTask.getAvailablePlugins());
430                // select plugins which actually have to be updated
431                //
432                Iterator<PluginInformation> it = toUpdate.iterator();
433                while (it.hasNext()) {
434                    PluginInformation pi = it.next();
435                    if (!pi.isUpdateRequired()) {
436                        it.remove();
437                    }
438                }
439                if (toUpdate.isEmpty()) {
440                    alertNothingToUpdate();
441                    return;
442                }
443                pluginDownloadTask.setPluginsToDownload(toUpdate);
444                Main.worker.submit(pluginDownloadTask);
445                Main.worker.submit(pluginDownloadContinuation);
446            };
447
448            Main.worker.submit(pluginInfoDownloadTask);
449            Main.worker.submit(pluginInfoDownloadContinuation);
450        }
451    }
452
453    /**
454     * The action for configuring the plugin download sites
455     *
456     */
457    class ConfigureSitesAction extends AbstractAction {
458        ConfigureSitesAction() {
459            putValue(NAME, tr("Configure sites..."));
460            putValue(SHORT_DESCRIPTION, tr("Configure the list of sites where plugins are downloaded from"));
461            new ImageProvider("dialogs", "settings").getResource().attachImageIcon(this);
462        }
463
464        @Override
465        public void actionPerformed(ActionEvent e) {
466            configureSites();
467        }
468    }
469
470    /**
471     * The action for selecting the plugins given by a text file compatible to JOSM bug report.
472     * @author Michael Zangl
473     */
474    class SelectByListAction extends AbstractAction {
475        SelectByListAction() {
476            putValue(NAME, tr("Load from list..."));
477            putValue(SHORT_DESCRIPTION, tr("Load plugins from a list of plugins"));
478        }
479
480        @Override
481        public void actionPerformed(ActionEvent e) {
482            JTextArea textField = new JTextArea(10, 0);
483            JCheckBox deleteNotInList = new JCheckBox(tr("Disable all other plugins"));
484
485            JLabel helpLabel = new JLabel("<html>" + Utils.join("<br/>", Arrays.asList(
486                    tr("Enter a list of plugins you want to download."),
487                    tr("You should add one plugin id per line, version information is ignored."),
488                    tr("You can copy+paste the list of a status report here."))) + "</html>");
489
490            if (JOptionPane.OK_OPTION == JOptionPane.showConfirmDialog(GuiHelper.getFrameForComponent(getTabPane()),
491                    new Object[] {helpLabel, new JScrollPane(textField), deleteNotInList},
492                    tr("Load plugins from list"), JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE)) {
493                activatePlugins(textField, deleteNotInList.isSelected());
494            }
495        }
496
497        private void activatePlugins(JTextArea textField, boolean deleteNotInList) {
498            String[] lines = textField.getText().split("\n");
499            List<String> toActivate = new ArrayList<>();
500            List<String> notFound = new ArrayList<>();
501            Pattern regex = Pattern.compile("^[-+\\s]*|\\s[\\(\\)\\d\\s]*");
502            for (String line : lines) {
503                String name = regex.matcher(line).replaceAll("");
504                if (name.isEmpty()) {
505                    continue;
506                }
507                PluginInformation plugin = model.getPluginInformation(name);
508                if (plugin == null) {
509                    notFound.add(name);
510                } else {
511                    toActivate.add(name);
512                }
513            }
514
515            if (notFound.isEmpty() || confirmIgnoreNotFound(notFound)) {
516                activatePlugins(toActivate, deleteNotInList);
517            }
518        }
519
520        private void activatePlugins(List<String> toActivate, boolean deleteNotInList) {
521            if (deleteNotInList) {
522                for (String name : model.getSelectedPluginNames()) {
523                    if (!toActivate.contains(name)) {
524                        model.setPluginSelected(name, false);
525                    }
526                }
527            }
528            for (String name : toActivate) {
529                model.setPluginSelected(name, true);
530            }
531            pnlPluginPreferences.refreshView();
532        }
533
534        private boolean confirmIgnoreNotFound(List<String> notFound) {
535            String list = "<ul><li>" + Utils.join("</li><li>", notFound) + "</li></ul>";
536            String message = "<html>" + tr("The following plugins were not found. Continue anyway?") + list + "</html>";
537            return JOptionPane.showConfirmDialog(GuiHelper.getFrameForComponent(getTabPane()),
538                    message) == JOptionPane.OK_OPTION;
539        }
540    }
541
542    /**
543     * Applies the current filter condition in the filter text field to the model.
544     */
545    class SearchFieldAdapter implements DocumentListener {
546        private void filter() {
547            String expr = tfFilter.getText().trim();
548            if (expr.isEmpty()) {
549                expr = null;
550            }
551            model.filterDisplayedPlugins(expr);
552            pnlPluginPreferences.refreshView();
553        }
554
555        @Override
556        public void changedUpdate(DocumentEvent evt) {
557            filter();
558        }
559
560        @Override
561        public void insertUpdate(DocumentEvent evt) {
562            filter();
563        }
564
565        @Override
566        public void removeUpdate(DocumentEvent evt) {
567            filter();
568        }
569    }
570
571    private static class PluginConfigurationSitesPanel extends JPanel {
572
573        private final DefaultListModel<String> model = new DefaultListModel<>();
574
575        PluginConfigurationSitesPanel() {
576            super(new GridBagLayout());
577            add(new JLabel(tr("Add JOSM Plugin description URL.")), GBC.eol());
578            for (String s : Main.pref.getPluginSites()) {
579                model.addElement(s);
580            }
581            final JList<String> list = new JList<>(model);
582            add(new JScrollPane(list), GBC.std().fill());
583            JPanel buttons = new JPanel(new GridBagLayout());
584            buttons.add(new JButton(new AbstractAction(tr("Add")) {
585                @Override
586                public void actionPerformed(ActionEvent e) {
587                    String s = JOptionPane.showInputDialog(
588                            GuiHelper.getFrameForComponent(PluginConfigurationSitesPanel.this),
589                            tr("Add JOSM Plugin description URL."),
590                            tr("Enter URL"),
591                            JOptionPane.QUESTION_MESSAGE
592                            );
593                    if (s != null && !s.isEmpty()) {
594                        model.addElement(s);
595                    }
596                }
597            }), GBC.eol().fill(GBC.HORIZONTAL));
598            buttons.add(new JButton(new AbstractAction(tr("Edit")) {
599                @Override
600                public void actionPerformed(ActionEvent e) {
601                    if (list.getSelectedValue() == null) {
602                        JOptionPane.showMessageDialog(
603                                GuiHelper.getFrameForComponent(PluginConfigurationSitesPanel.this),
604                                tr("Please select an entry."),
605                                tr("Warning"),
606                                JOptionPane.WARNING_MESSAGE
607                                );
608                        return;
609                    }
610                    String s = (String) JOptionPane.showInputDialog(
611                            Main.parent,
612                            tr("Edit JOSM Plugin description URL."),
613                            tr("JOSM Plugin description URL"),
614                            JOptionPane.QUESTION_MESSAGE,
615                            null,
616                            null,
617                            list.getSelectedValue()
618                            );
619                    if (s != null && !s.isEmpty()) {
620                        model.setElementAt(s, list.getSelectedIndex());
621                    }
622                }
623            }), GBC.eol().fill(GBC.HORIZONTAL));
624            buttons.add(new JButton(new AbstractAction(tr("Delete")) {
625                @Override
626                public void actionPerformed(ActionEvent event) {
627                    if (list.getSelectedValue() == null) {
628                        JOptionPane.showMessageDialog(
629                                GuiHelper.getFrameForComponent(PluginConfigurationSitesPanel.this),
630                                tr("Please select an entry."),
631                                tr("Warning"),
632                                JOptionPane.WARNING_MESSAGE
633                                );
634                        return;
635                    }
636                    model.removeElement(list.getSelectedValue());
637                }
638            }), GBC.eol().fill(GBC.HORIZONTAL));
639            add(buttons, GBC.eol());
640        }
641
642        protected List<String> getUpdateSites() {
643            if (model.getSize() == 0)
644                return Collections.emptyList();
645            List<String> ret = new ArrayList<>(model.getSize());
646            for (int i = 0; i < model.getSize(); i++) {
647                ret.add(model.get(i));
648            }
649            return ret;
650        }
651    }
652}