sdkboy
Note
For running and building the app see Build section.

SDKBoyModel

public final class SDKBoyModel {

  public static final Version VERSION = Version.parse(SDKBoyModel.class, "/version.properties");

  private final SdkManApi sdkMan = new SdkManApi(DEFAULT_SDKMAN_HOME);

  private final CandidateModel candidateModel;
  private final VersionModel versionModel;
  private final PreferencesModel preferencesModel;

  public SDKBoyModel() {
    candidateModel = new CandidateModel();
    versionModel = new VersionModel();
    preferencesModel = new PreferencesModel();
  }

  public CandidateModel candidateModel() {
    return candidateModel;
  }

  public VersionModel versionModel() {
    return versionModel;
  }

  public PreferencesModel preferencesModel() {
    return preferencesModel;
  }

  public void refresh() {
    candidateModel.tableModel.items().refresh(_ ->
            versionModel.tableModel.items().refresh());
  }

  public final class CandidateModel {

    private final FilterTableModel<CandidateRow, CandidateColumn> tableModel =
            FilterTableModel.builder()
                    .columns(new CandidateTableColumns())
                    .items(new CandidateItems())
                    .visible(new CandidateVisible())
                    .build();
    private final Value<String> filter = Value.builder()
            .<String>nullable()
            .listener(this::onFilterChanged)
            .build();
    private final State installedOnly = State.builder()
            .listener(this::onFilterChanged)
            .build();

    private CandidateModel() {
      tableModel.sort().order(CandidateColumn.NAME).set(ASCENDING);
    }

    public FilterTableModel<CandidateRow, CandidateColumn> tableModel() {
      return tableModel;
    }

    public Value<String> filter() {
      return filter;
    }

    public State installedOnly() {
      return installedOnly;
    }

    private void onFilterChanged() {
      tableModel.items().filter();
      tableModel.selection().indexes().clear();
      tableModel.selection().indexes().increment();
    }

    public enum CandidateColumn {
      NAME, INSTALLED
    }

    public record CandidateRow(Candidate candidate, int installed) {

      @Override
      public String toString() {
        return candidate.name();
      }

      @Override
      public boolean equals(Object object) {
        if (object == null || getClass() != object.getClass()) {
          return false;
        }
        CandidateRow candidateRow = (CandidateRow) object;

        return Objects.equals(candidate.id(), candidateRow.candidate.id());
      }

      @Override
      public int hashCode() {
        return Objects.hashCode(candidate.id());
      }
    }

    private static final class CandidateTableColumns implements TableColumns<CandidateRow, CandidateColumn> {

      private static final List<CandidateColumn> IDENTIFIERS = List.of(CandidateColumn.values());

      @Override
      public List<CandidateColumn> identifiers() {
        return IDENTIFIERS;
      }

      @Override
      public Class<?> columnClass(CandidateColumn column) {
        return switch (column) {
          case NAME -> String.class;
          case INSTALLED -> Integer.class;
        };
      }

      @Override
      public String caption(CandidateColumn column) {
        return switch (column) {
          case NAME -> "Name";
          case INSTALLED -> "Installed";
        };
      }

      @Override
      public Object value(CandidateRow row, CandidateColumn column) {
        return switch (column) {
          case NAME -> row.candidate.name();
          case INSTALLED -> row.installed == 0 ? null : row.installed;
        };
      }

      @Override
      public Comparator<?> comparator(CandidateColumn identifier) {
        if (identifier == CandidateColumn.NAME) {
          return Comparator.<String, String>comparing(String::toLowerCase);
        }

        return TableColumns.super.comparator(identifier);
      }
    }

    private class CandidateItems implements Supplier<Collection<CandidateRow>> {

      @Override
      public Collection<CandidateRow> get() {
        try {
          return sdkMan.getCandidates().get().stream()
                  .map(candidate -> new CandidateRow(candidate,
                          sdkMan.getLocalInstalledVersions(candidate.id()).size()))
                  .toList();
        }
        catch (Exception e) {
          throw new RuntimeException(e);
        }
      }
    }

    private final class CandidateVisible implements Predicate<CandidateRow> {

      @Override
      public boolean test(CandidateRow candidateRow) {
        if (installedOnly.is() && candidateRow.installed() == 0) {
          return false;
        }
        if (filter.isNull()) {
          return true;
        }

        return candidateRow.candidate.name().toLowerCase().contains(filter.getOrThrow());
      }
    }
  }

  public final class VersionModel {

    private static final int DONE = 100;

    private final FilterTableModel<VersionRow, VersionColumn> tableModel =
            FilterTableModel.builder()
                    .columns(new VersionTableColumns())
                    .items(new VersionItems())
                    .visible(new VersionVisible())
                    .build();
    private final State selectedInstalled = State.state();
    private final State selectedUsed = State.state();
    private final Value<String> filter = Value.builder()
            .<String>nullable()
            .listener(this::onFilterChanged)
            .build();
    private final State installedOnly = State.builder()
            .listener(this::onFilterChanged)
            .build();
    private final State downloadedOnly = State.builder()
            .listener(this::onFilterChanged)
            .build();
    private final State usedOnly = State.builder()
            .listener(this::onFilterChanged)
            .build();

    private VersionModel() {
      tableModel.selection().item().addConsumer(this::onVersionSelected);
      tableModel.sort().order(VersionColumn.VENDOR).set(ASCENDING);
      tableModel.sort().order(VersionColumn.VERSION).add(DESCENDING);
      candidateModel.tableModel.selection().item().addListener(this::onCandidateSelected);
    }

    public FilterTableModel<VersionRow, VersionColumn> tableModel() {
      return tableModel;
    }

    public ObservableState selectedInstalled() {
      return selectedInstalled.observable();
    }

    public ObservableState selectedUsed() {
      return selectedUsed.observable();
    }

    public Value<String> filter() {
      return filter;
    }

    public State installedOnly() {
      return installedOnly;
    }

    public State downloadedOnly() {
      return downloadedOnly;
    }

    public State usedOnly() {
      return usedOnly;
    }

    public VersionRow selected() {
      return tableModel.selection().item().getOrThrow();
    }

    public void refresh() {
      tableModel.items().refresh();
    }

    public void install(ProgressReporter<String> progress, State downloading, Observer<?> cancel) {
      VersionRow selected = selected();
      if (selected.version.available()) {
        progress.report(DONE);
      }
      else {
        download(selected, progress, downloading, cancel);
      }
      progress.publish("Installing");
      sdkMan.install(selected.candidate.id(), selected.version.identifier());
      progress.publish("Done");
    }

    public void uninstall() {
      VersionRow selected = selected();
      sdkMan.uninstall(selected.candidate.id(), selected.version.identifier());
    }

    public void use() {
      VersionRow selected = selected();
      try {
        sdkMan.changeGlobal(selected.candidate.id(), selected.version.identifier());
      }
      catch (IOException e) {
        throw new RuntimeException(e);
      }
    }

    private void download(VersionRow versionRow, ProgressReporter<String> progress,
                          State downloading, Observer<?> cancel) {
      DownloadTask task = sdkMan.download(versionRow.candidate.id(), versionRow.version.identifier());
      task.setProgressInformation(new DownloadProgress(progress, downloading));
      Runnable cancelTask = task::cancel;
      cancel.addListener(cancelTask);
      try {
        task.download();
      }
      finally {
        // Prevent a memory leak, the cancel Observer
        // comes from a single InstallTask instance
        cancel.removeListener(cancelTask);
      }
      if (task.isCancelled()) {
        throw new CancelException();
      }
    }

    private void onFilterChanged() {
      tableModel.items().filter();
      if (!filter.isNull() || tableModel.selection().empty().is()) {
        tableModel.selection().indexes().clear();
        tableModel.selection().indexes().increment();
      }
    }

    private void onCandidateSelected() {
      tableModel.items().refresh(_ -> {
        if (tableModel.selection().empty().is()) {
          tableModel.selection().indexes().increment();
        }
      });
    }

    private void onVersionSelected(VersionRow versionRow) {
      selectedInstalled.set(versionRow != null && versionRow.version.installed());
      selectedUsed.set(versionRow != null && versionRow.used());
    }

    public enum VersionColumn {
      VENDOR, VERSION, INSTALLED, DOWNLOADED, USED
    }

    public record VersionRow(Candidate candidate, CandidateVersion version, VersionInfo versionInfo, boolean used) {

      @Override
      public boolean equals(Object object) {
        if (object == null || getClass() != object.getClass()) {
          return false;
        }

        VersionRow row = (VersionRow) object;

        return Objects.equals(candidate.id(), row.candidate.id()) &&
                Objects.equals(version.identifier(), row.version.identifier());
      }

      @Override
      public int hashCode() {
        return Objects.hash(candidate.id(), version.identifier());
      }
    }

    /**
     * Sort semantic version strings correctly, that is,
     * ones using the major.minor.patch-metadata format.
     * For other formats, textual sorting is used.
     * @param version the semantic Version, if available
     * @param versionName the version name
     */
    public record VersionInfo(Version version, String versionName) implements Comparable<VersionInfo> {

      public static VersionInfo of(String version) {
        long dots = version.chars().filter(ch -> ch == '.').count();
        if (dots > 2) {
          return new VersionInfo(null, version);
        }
        try {
          return new VersionInfo(Version.parse(version), version);
        }
        catch (Exception e) {
          return new VersionInfo(null, version);
        }
      }

      @Override
      public String toString() {
        return version == null ? versionName : version.toString();
      }

      @Override
      public int compareTo(VersionInfo versionInfo) {
        if (version != null && versionInfo.version != null) {
          return version.compareTo(versionInfo.version);
        }

        return versionName.compareTo(versionInfo.versionName);
      }
    }

    private static final class VersionTableColumns implements TableColumns<VersionRow, VersionColumn> {

      private static final List<VersionColumn> IDENTIFIERS = List.of(VersionColumn.values());

      @Override
      public List<VersionColumn> identifiers() {
        return IDENTIFIERS;
      }

      @Override
      public Class<?> columnClass(VersionColumn column) {
        return switch (column) {
          case VENDOR -> String.class;
          case VERSION -> VersionInfo.class;
          case INSTALLED, DOWNLOADED, USED -> Boolean.class;
        };
      }

      @Override
      public String caption(VersionColumn column) {
        return switch (column) {
          case VENDOR -> "Vendor";
          case VERSION -> "Version";
          case INSTALLED -> "Installed";
          case DOWNLOADED -> "Downloaded";
          case USED -> "Used";
        };
      }

      @Override
      public Object value(VersionRow row, VersionColumn column) {
        return switch (column) {
          case VENDOR -> row.version.vendor();
          case VERSION -> row.versionInfo();
          case INSTALLED -> row.version.installed();
          case DOWNLOADED -> row.version.available();
          case USED -> row.used;
        };
      }
    }

    private class VersionItems implements Supplier<Collection<VersionRow>> {

      @Override
      public Collection<VersionRow> get() {
        return candidateModel.tableModel.selection().item().optional()
                .map(this::candidateVersions)
                .orElse(List.of());
      }

      private Collection<VersionRow> candidateVersions(CandidateRow candidateRow) {
        try {
          String inUse = sdkMan.resolveCurrentVersion(candidateRow.candidate().id());

          return sdkMan.getVersions(candidateRow.candidate().id()).stream()
                  .map(version -> new VersionRow(candidateRow.candidate(), version,
                          VersionInfo.of(version.version()), version.identifier().equals(inUse)))
                  .toList();
        }
        catch (Exception e) {
          throw new RuntimeException(e);
        }
      }
    }

    private final class VersionVisible implements Predicate<VersionRow> {

      @Override
      public boolean test(VersionRow versionRow) {
        CandidateVersion candidateVersion = versionRow.version;
        if (installedOnly.is() && !candidateVersion.installed()) {
          return false;
        }
        if (downloadedOnly.is() && !candidateVersion.available()) {
          return false;
        }
        if (usedOnly.is() && !versionRow.used) {
          return false;
        }
        if (filter.isNull()) {
          return true;
        }

        Stream<String> strings = Stream.of(filter.getOrThrow().toLowerCase().split(" "))
                .map(String::trim);
        String version = candidateVersion.version().toLowerCase();
        if (candidateVersion.vendor() == null) {
          return strings.allMatch(version::contains);
        }
        String vendor = candidateVersion.vendor().toLowerCase();

        return strings.allMatch(filter -> version.contains(filter) || vendor.contains(filter));
      }
    }

    private static final class DownloadProgress implements ProgressInformation {

      private final ProgressReporter<String> progress;
      private final State downloading;

      private DownloadProgress(ProgressReporter<String> progress, State downloading) {
        this.progress = progress;
        this.downloading = downloading;
      }

      @Override
      public void publishProgress(int value) {
        downloading.set(value >= 1 && value < 100);
        if (downloading.is()) {
          progress.publish("Downloading");
          progress.report(value);
        }
        else {
          progress.publish("Extracting");
        }
      }

      @Override
      public void publishState(String state) {}
    }
  }

  public static final class PreferencesModel {

    private static final String LOOK_AND_FEEL = "SDKBOY.lookAndFeel";
    private static final String CONFIRM_ACTIONS = "SDKBOY.confirmActions";
    private static final String CONFIRM_EXIT = "SDKBOY.confirmExit";

    private final LoggerProxy logger = LoggerProxy.instance();
    private final SdkManUiPreferences sdkManUi = SdkManUiPreferences.getInstance();
    private final Value<String> zipExecutable = Value.nullable(sdkManUi.zipExecutable);
    private final Value<String> unzipExecutable = Value.nullable(sdkManUi.unzipExecutable);
    private final Value<String> tarExecutable = Value.nullable(sdkManUi.tarExecutable);
    private final State keepDownloadsAvailable = State.state(sdkManUi.keepDownloadsAvailable);
    private final State confirmActions = State.state(getConfirmActionsPreference());
    private final State confirmExit = State.state(getConfirmExitPreference());
    private final FilterComboBoxModel<Level> logLevels = FilterComboBoxModel.builder()
            .items(logger.levels().stream()
                    .map(Level.class::cast)
                    .toList())
            .build();

    private PreferencesModel() {}

    public Value<String> zipExecutable() {
      return zipExecutable;
    }

    public Value<String> unzipExecutable() {
      return unzipExecutable;
    }

    public Value<String> tarExecutable() {
      return tarExecutable;
    }

    public State keepDownloadsAvailable() {
      return keepDownloadsAvailable;
    }

    public State confirmActions() {
      return confirmActions;
    }

    public State confirmExit() {
      return confirmExit;
    }

    public FilterComboBoxModel<Level> logLevels() {
      return logLevels;
    }

    public Object logLevel() {
      return logger.getLogLevel();
    }

    public Optional<File> logFile() {
      return logger.files().stream()
              .map(File::new)
              .findFirst();
    }

    public Optional<File> logDirectory() {
      return logger.files().stream()
              .map(File::new)
              .map(File::getParentFile)
              .findFirst();
    }

    public void setLookAndFeelPreference(LookAndFeelEnabler lookAndFeelEnabler) {
      UserPreferences.set(LOOK_AND_FEEL, lookAndFeelEnabler.lookAndFeel().getClass().getName());
    }

    public static String getLookAndFeelPreference() {
      return UserPreferences.get(LOOK_AND_FEEL, DarkFlat.class.getName());
    }

    public void save() {
      UserPreferences.set(CONFIRM_ACTIONS, Boolean.toString(confirmActions.is()));
      UserPreferences.set(CONFIRM_EXIT, Boolean.toString(confirmExit.is()));
      logger.setLogLevel(logLevels.selection().item().getOrThrow());
      sdkManUi.zipExecutable = zipExecutable.get();
      sdkManUi.unzipExecutable = unzipExecutable.get();
      sdkManUi.tarExecutable = tarExecutable.get();
      sdkManUi.keepDownloadsAvailable = keepDownloadsAvailable.is();
      try {
        UserPreferences.flush();
        sdkManUi.save();
      }
      catch (Exception e) {
        throw new RuntimeException(e);
      }
    }

    public void revert() {
      confirmActions.set(getConfirmActionsPreference());
      confirmExit.set(getConfirmExitPreference());
      logLevels.selection().item().set((Level) logger.getLogLevel());
      zipExecutable.set(sdkManUi.zipExecutable);
      unzipExecutable.set(sdkManUi.unzipExecutable);
      tarExecutable.set(sdkManUi.tarExecutable);
      keepDownloadsAvailable.set(sdkManUi.keepDownloadsAvailable);
    }

    private static boolean getConfirmActionsPreference() {
      return parseBoolean(UserPreferences.get(CONFIRM_ACTIONS, TRUE.toString()));
    }

    private static boolean getConfirmExitPreference() {
      return parseBoolean(UserPreferences.get(CONFIRM_EXIT, TRUE.toString()));
    }
  }
}

SDKBoyPanel

public final class SDKBoyPanel extends JPanel {

  private static final String SHORTCUTS = """
          Alt          Mnemonics
          Enter        Navigate
          Up           Previous
          Down         Next
          Escape       Cancel
          Alt-O        Description
          Alt-S        Shortcuts
          Alt-P        Preferences
          Alt-R        Refresh
          Alt-X        Exit
          Alt-I/Ins    Install
          Alt-D/Del    Uninstall
          Alt-U        Use
          Alt-C        Copy USE Command
          Double Click Version
          Uninstalled :Install
          Installed   :Use
          Used        :Uninstall
          """;

  private final SDKBoyModel model = new SDKBoyModel();
  private final CandidatePanel candidatePanel;
  private final VersionPanel versionPanel;
  private final State help = State.builder()
          .consumer(this::onHelp)
          .build();

  private PreferencesPanel preferencesPanel;

  private SDKBoyPanel() {
    super(borderLayout());
    setDefaultUncaughtExceptionHandler(new SDKBoyExceptionHandler());
    versionPanel = new VersionPanel(model, help);
    candidatePanel = new CandidatePanel(model, versionPanel.installTask.active);
    initializeUI();
    setupKeyEvents();
  }

  @Override
  public void updateUI() {
    super.updateUI();
    Utilities.updateUI(preferencesPanel);
  }

  private void initializeUI() {
    setBorder(emptyBorder());
    add(candidatePanel, WEST);
    add(versionPanel, CENTER);
  }

  private void setupKeyEvents() {
    KeyEvents.builder()
            .condition(WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
            .modifiers(ALT_DOWN_MASK)
            .keyCode(VK_O)
            .action(command(this::displayDescription))
            .enable(this)
            .keyCode(VK_P)
            .action(command(this::displayPreferences))
            .enable(this)
            .keyCode(VK_R)
            .action(command(versionPanel::refreshCandidates))
            .enable(this)
            .keyCode(VK_X)
            .action(command(this::exit))
            .enable(this)
            .keyCode(VK_INSERT)
            .action(versionPanel.install)
            .enable(this)
            .keyCode(VK_DELETE)
            .action(versionPanel.uninstall)
            .enable(this)
            .keyCode(VK_I)
            .action(versionPanel.install)
            .enable(this)
            .keyCode(VK_D)
            .action(versionPanel.uninstall)
            .enable(this)
            .keyCode(VK_U)
            .action(versionPanel.use)
            .enable(this)
            .keyCode(VK_C)
            .action(versionPanel.copyUseCommand)
            .enable(this);
  }

  private void onHelp(boolean visible) {
    if (visible) {
      add(new HelpPanel(), EAST);
    }
    else {
      BorderLayout layout = (BorderLayout) getLayout();
      remove(layout.getLayoutComponent(EAST));
    }
    revalidate();
    repaint();
  }

  private void displayPreferences() {
    if (preferencesPanel == null) {
      preferencesPanel = new PreferencesPanel(model.preferencesModel());
    }
    Dialogs.okCancel()
            .component(preferencesPanel)
            .owner(this)
            .title("Preferences")
            .onOk(model.preferencesModel()::save)
            .onCancel(model.preferencesModel()::revert)
            .show();
  }

  private void displayDescription() {
    candidatePanel.table.model().selection().item().optional()
            .ifPresent(candidateRow -> Dialogs.builder()
                    .component(textArea()
                            .value(candidateRow.candidate().description())
                            .rowsColumns(8, 40)
                            .editable(false)
                            .lineWrap(true)
                            .wrapStyleWord(true)
                            .scrollPane())
                    .owner(this)
                    .title(candidateRow.candidate().name() + " - Description")
                    .show());
  }

  private void exit() {
    if (confirmExit()) {
      parentWindow(this).dispose();
    }
  }

  private boolean confirmExit() {
    if (versionPanel.installTask.active.is()) {
      return false;
    }

    return !model.preferencesModel().confirmExit().is() || showConfirmDialog(this,
            "Are you sure you want to exit?",
            "Confirm Exit", YES_NO_OPTION, QUESTION_MESSAGE) == YES_OPTION;
  }

  private final class SDKBoyExceptionHandler implements Thread.UncaughtExceptionHandler {

    @Override
    public void uncaughtException(Thread thread, Throwable throwable) {
      throwable.printStackTrace();
      Dialogs.exception()
              .owner(SDKBoyPanel.this)
              .show(throwable);
    }
  }

  private static final class CandidatePanel extends JPanel {

    private final FilterTable<CandidateRow, CandidateColumn> table;
    private final JTextField filter;
    private final JCheckBox installedOnly;

    private CandidatePanel(SDKBoyModel model, ObservableState installing) {
      super(borderLayout());
      CandidateModel candidateModel = model.candidateModel();
      ObservableState refreshingVersions = model.versionModel()
              .tableModel().items().refresher().active();
      table = FilterTable.builder()
              .model(candidateModel.tableModel())
              .columns(this::configureColumns)
              .sortable(false)
              .focusable(false)
              .selectionMode(SINGLE_SELECTION)
              .autoResizeMode(AUTO_RESIZE_ALL_COLUMNS)
              .enabled(and(installing.not(), refreshingVersions.not()))
              .cellRenderer(CandidateColumn.INSTALLED,
                      FilterTableCellRenderer.builder()
                              .columnClass(Integer.class)
                              .horizontalAlignment(SwingConstants.CENTER)
                              .build())
              .build();
      Indexes selectedIndexes = candidateModel.tableModel().selection().indexes();
      filter = stringField()
              .link(candidateModel.filter())
              .hint("Filter...")
              .lowerCase(true)
              .selectAllOnFocusGained(true)
              .transferFocusOnEnter(true)
              .keyEvent(KeyEvents.builder()
                      .keyCode(VK_UP)
                      .action(command(selectedIndexes::decrement)))
              .keyEvent(KeyEvents.builder()
                      .keyCode(VK_DOWN)
                      .action(command(selectedIndexes::increment)))
              .enabled(installing.not())
              .build();
      installedOnly = checkBox()
              .link(candidateModel.installedOnly())
              .text("Installed")
              .mnemonic('T')
              .focusable(false)
              .enabled(installing.not())
              .build();
      candidateModel.tableModel().items().refresh();
      setBorder(createCompoundBorder(createTitledBorder("Candidates"), emptyBorder()));
      add(scrollPane()
              .view(table)
              .preferredWidth(220)
              .build(), CENTER);
      add(borderLayoutPanel()
              .center(filter)
              .east(installedOnly)
              .build(), SOUTH);
    }

    private void configureColumns(FilterTableColumn.Builder<CandidateColumn> column) {
      if (column.identifier() == CandidateColumn.INSTALLED) {
        column.fixedWidth(80);
      }
    }
  }

  private static final class VersionPanel extends JPanel {

    private static final String JAVA = "Java";

    private final SDKBoyModel model;
    private final CandidateModel candidateModel;
    private final VersionModel versionModel;
    private final InstallTask installTask;
    private final FilterTable<VersionRow, VersionColumn> table;
    private final Value<String> selectedVersionName = Value.nullable();
    private final JTextField filter;
    private final JCheckBox installedOnly;
    private final JCheckBox downloadedOnly;
    private final JCheckBox usedOnly;
    private final JProgressBar refreshProgress;
    private final JProgressBar installProgress;
    private final JButton cancelDownload;
    private final JPanel installingPanel;
    private final JPanel southPanel;
    private final Control install;
    private final Control uninstall;
    private final Control use;
    private final Control copyUseCommand;
    private final JButton helpButton;

    private VersionPanel(SDKBoyModel model, State help) {
      super(borderLayout());
      this.model = model;
      this.candidateModel = model.candidateModel();
      this.versionModel = model.versionModel();
      this.installTask = new InstallTask();
      this.install = Control.builder()
              .command(this::install)
              .enabled(and(
                      versionModel.tableModel().selection().empty().not(),
                      versionModel.selectedInstalled().not()))
              .build();
      this.uninstall = Control.builder()
              .command(this::uninstall)
              .enabled(and(
                      versionModel.tableModel().selection().empty().not(),
                      versionModel.selectedInstalled()))
              .build();
      this.use = Control.builder()
              .command(this::use)
              .enabled(versionModel.selectedUsed().not())
              .build();
      this.copyUseCommand = Control.builder()
              .command(this::copyUseCommand)
              .build();
      candidateModel.tableModel().selection().item().addConsumer(this::onCandidateSelected);
      versionModel.tableModel().items().refresher().active().addConsumer(this::onRefreshing);
      versionModel.tableModel().selection().item().addConsumer(this::onVersionSelected);
      installTask.active.addConsumer(this::onInstalling);
      installTask.downloading.addConsumer(this::onDownloading);
      table = FilterTable.builder()
              .model(versionModel.tableModel())
              .columns(this::configureColumns)
              .sortable(false)
              .focusable(false)
              .selectionMode(SINGLE_SELECTION)
              .autoResizeMode(AUTO_RESIZE_ALL_COLUMNS)
              .columnReordering(false)
              .doubleClick(command(this::onVersionDoubleClick))
              .enabled(installTask.active.not())
              .build();
      table.columnModel().visible(VersionColumn.VENDOR).set(false);
      Indexes selectedIndexes = versionModel.tableModel().selection().indexes();
      filter = stringField()
              .link(versionModel.filter())
              .hint("Filter...")
              .lowerCase(true)
              .selectAllOnFocusGained(true)
              .transferFocusOnEnter(true)
              .keyEvent(KeyEvents.builder()
                      .keyCode(VK_UP)
                      .action(command(selectedIndexes::decrement)))
              .keyEvent(KeyEvents.builder()
                      .keyCode(VK_DOWN)
                      .action(command(selectedIndexes::increment)))
              .enabled(installTask.active.not())
              .build();
      installedOnly = checkBox()
              .link(versionModel.installedOnly())
              .text("Installed")
              .mnemonic('N')
              .focusable(false)
              .enabled(installTask.active.not())
              .build();
      downloadedOnly = checkBox()
              .link(versionModel.downloadedOnly())
              .text("Downloaded")
              .mnemonic('A')
              .focusable(false)
              .enabled(installTask.active.not())
              .build();
      usedOnly = checkBox()
              .link(versionModel.usedOnly())
              .text("Used")
              .mnemonic('E')
              .focusable(false)
              .enabled(installTask.active.not())
              .build();
      cancelDownload = button()
              .control(Control.builder()
                      .command(installTask::cancel)
                      .caption("Cancel")
                      .enabled(installTask.downloading))
              .keyEvent(KeyEvents.builder()
                      .keyCode(VK_ESCAPE)
                      .action(command(installTask::cancel)))
              .build();
      refreshProgress = progressBar()
              .string("Refreshing...")
              .stringPainted(true)
              .build();
      installProgress = progressBar()
              .stringPainted(true)
              .build();
      installingPanel = borderLayoutPanel()
              .center(installProgress)
              .east(cancelDownload)
              .build();
      helpButton = button()
              .control(Control.builder()
                      .command(help::toggle)
                      .caption("?")
                      .mnemonic('S'))
              .focusable(false)
              .build();
      southPanel = borderLayoutPanel()
              .center(filter)
              .east(flexibleGridLayoutPanel(1, 0)
                      .add(installedOnly)
                      .add(downloadedOnly)
                      .add(usedOnly)
                      .add(helpButton))
              .build();
      setBorder(createCompoundBorder(createTitledBorder("Versions"), emptyBorder()));
      add(scrollPane()
              .view(table)
              .build(), CENTER);
      add(southPanel, SOUTH);
    }

    @Override
    public void updateUI() {
      super.updateUI();
      Utilities.updateUI(southPanel, refreshProgress, installingPanel, installProgress, cancelDownload);
    }

    private void onVersionDoubleClick() {
      if (versionModel.selectedUsed().is()) {
        uninstall();
      }
      else if (versionModel.selectedInstalled().is()) {
        use();
      }
      else {
        install();
      }
    }

    private void install() {
      install(() -> {});
    }

    private void install(Runnable onInstalled) {
      if (confirmInstall()) {
        ProgressWorker.builder()
                .task(installTask)
                .onStarted(installTask::started)
                .onProgress(installTask::progress)
                .onPublish(installTask::publish)
                .onDone(installTask::done)
                .onResult(() -> installTask.result(onInstalled))
                .execute();
      }
    }

    private void uninstall() {
      if (confirmUninstall()) {
        ProgressWorker.builder()
                .task(versionModel::uninstall)
                .onResult(model::refresh)
                .execute();
      }
    }

    private void use() {
      VersionRow selected = versionModel.selected();
      if (selected.version().installed()) {
        useInstalled();
      }
      else {
        install(this::useInstalled);
      }
    }

    private void useInstalled() {
      if (confirmUse()) {
        ProgressWorker.builder()
                .task(versionModel::use)
                .onResult(versionModel::refresh)
                .execute();
      }
    }

    private void copyUseCommand() {
      VersionRow selected = versionModel.selected();
      if (!selected.version().installed()) {
        install(() -> copyUseCommand(selected));
      }
      else {
        copyUseCommand(selected);
      }
    }

    private void copyUseCommand(VersionRow versionRow) {
      String command = "sdk use " + versionRow.candidate().id() + " " + versionRow.version().identifier();
      setClipboard(command);
      showMessageDialog(this, command + "\n\ncopied to clipboard", "Copied", INFORMATION_MESSAGE);
    }

    private boolean confirmInstall() {
      return !model.preferencesModel().confirmActions().is() || showConfirmDialog(this,
              "Install " + versionName() + "?",
              "Confirm install", YES_NO_OPTION) == YES_OPTION;
    }

    private boolean confirmUninstall() {
      return !model.preferencesModel().confirmActions().is() || showConfirmDialog(this,
              "Uninstall " + versionName() + "?",
              "Confirm uninstall", YES_NO_OPTION) == YES_OPTION;
    }

    private boolean confirmUse() {
      return !model.preferencesModel().confirmActions().is() || showConfirmDialog(this,
              "Set " + versionName() + " as your global SDK?",
              "Confirm use", YES_NO_OPTION) == YES_OPTION;
    }

    private String versionName() {
      return selectedVersionName.get();
    }

    private void onCandidateSelected(CandidateRow candidateRow) {
      table.columnModel().visible(VersionColumn.VENDOR)
              .set(candidateRow != null && JAVA.equals(candidateRow.candidate().name()));
    }

    private void onRefreshing(boolean refreshing) {
      toggleSouthPanel(refreshProgress, refreshing);
    }

    private void onVersionSelected(VersionRow versionRow) {
      selectedVersionName.set(versionRow == null ? null :
              versionRow.candidate().name() + " " + versionRow.version().identifier());
    }

    private void onInstalling(boolean installing) {
      toggleSouthPanel(installingPanel, installing);
    }

    private void onDownloading(boolean downloading) {
      installProgress.setIndeterminate(!downloading);
      if (downloading) {
        cancelDownload.requestFocusInWindow();
      }
    }

    private void refreshCandidates() {
      candidateModel.tableModel().items().refresh();
    }

    private void toggleSouthPanel(JComponent component, boolean embed) {
      if (embed) {
        southPanel.add(component, NORTH);
      }
      else {
        southPanel.remove(component);
      }
      revalidate();
      repaint();
    }

    private void configureColumns(FilterTableColumn.Builder<VersionColumn> column) {
      switch (column.identifier()) {
        case INSTALLED -> column.fixedWidth(80);
        case DOWNLOADED -> column.fixedWidth(90);
        case USED -> column.fixedWidth(60);
      }
    }

    private final class InstallTask implements ProgressTask<String> {

      private final State active = State.state();
      private final State downloading = State.state();
      private final Event<?> cancel = Event.event();

      @Override
      public void execute(ProgressReporter<String> progress) {
        versionModel.install(progress, downloading, cancel);
      }

      private void cancel() {
        cancel.run();
      }

      private void started() {
        installProgress.setString("Procrastinating");
        active.set(true);
      }

      private void progress(int progress) {
        installProgress.getModel().setValue(progress);
      }

      private void publish(List<String> strings) {
        installProgress.setString(strings.getFirst() + " " + versionName());
      }

      private void done() {
        installProgress.setString("");
        installProgress.getModel().setValue(0);
        filter.requestFocusInWindow();
        downloading.set(false);
        active.set(false);
      }

      private void result(Runnable onInstalled) {
        model.refresh();
        onInstalled.run();
      }
    }
  }

  private static final class PreferencesPanel extends JPanel {

    private final PreferencesModel preferences;
    private final LookAndFeelComboBox lookAndFeelComboBox;
    private final ComponentValue<JTextField, String> zipExecutable;
    private final ComponentValue<JTextField, String> unzipExecutable;
    private final ComponentValue<JTextField, String> tarExecutable;
    private final ComponentValue<JCheckBox, Boolean> keepDownloadsAvailable;
    private final ComponentValue<JCheckBox, Boolean> confirmActions;
    private final ComponentValue<JCheckBox, Boolean> confirmExit;
    private final ComponentValue<JComboBox<Level>, Level> logLevel;
    private final JButton browseZipExecutableButton;
    private final JButton browseUnzipExecutableButton;
    private final JButton browseTarExecutableButton;
    private final JButton logFileButton;
    private final JButton logDirectoryButton;

    private PreferencesPanel(PreferencesModel preferences) {
      super(borderLayout());
      this.preferences = preferences;
      lookAndFeelComboBox = LookAndFeelComboBox.builder()
              .onSelection(preferences::setLookAndFeelPreference)
              .build();
      zipExecutable = stringField()
              .link(preferences.zipExecutable())
              .columns(20)
              .selectAllOnFocusGained(true)
              .buildValue();
      unzipExecutable = stringField()
              .link(preferences.unzipExecutable())
              .columns(20)
              .selectAllOnFocusGained(true)
              .buildValue();
      tarExecutable = stringField()
              .link(preferences.tarExecutable())
              .columns(20)
              .selectAllOnFocusGained(true)
              .buildValue();
      Icon directoryIcon = getIcon("FileView.directoryIcon");
      browseZipExecutableButton = button()
              .control(Control.builder()
                      .command(() -> browseExecutable(zipExecutable))
                      .smallIcon(directoryIcon))
              .build();
      browseUnzipExecutableButton = button()
              .control(Control.builder()
                      .command(() -> browseExecutable(unzipExecutable))
                      .smallIcon(directoryIcon))
              .build();
      browseTarExecutableButton = button()
              .control(Control.builder()
                      .command(() -> browseExecutable(tarExecutable))
                      .smallIcon(directoryIcon))
              .build();
      logFileButton = button()
              .control(Control.builder()
                      .command(this::openLogFile)
                      .smallIcon(getIcon("FileView.fileIcon"))
                      .mnemonic('F')
                      .description("Open Log File (Alt-F)"))
              .build();
      logDirectoryButton = button()
              .control(Control.builder()
                      .command(this::openLogDirectory)
                      .smallIcon(directoryIcon)
                      .mnemonic('D')
                      .description("Open Log Directory (Alt-D)"))
              .build();
      keepDownloadsAvailable = checkBox()
              .link(preferences.keepDownloadsAvailable())
              .text("Keep downloads available")
              .mnemonic('K')
              .buildValue();
      confirmActions = checkBox()
              .link(preferences.confirmActions())
              .text("Confirm install, uninstall and use")
              .mnemonic('I')
              .buildValue();
      confirmExit = checkBox()
              .link(preferences.confirmExit())
              .text("Confirm exit")
              .mnemonic('X')
              .buildValue();
      logLevel = comboBox()
              .model(preferences.logLevels())
              .value((Level) preferences.logLevel())
              .buildValue();
      setBorder(emptyBorder());
      add(flexibleGridLayoutPanel(0, 1)
              .add(label("Look & Feel")
                      .displayedMnemonic('L')
                      .labelFor(lookAndFeelComboBox))
              .add(lookAndFeelComboBox)
              .add(label("Select zip path")
                      .displayedMnemonic('Z')
                      .labelFor(zipExecutable.component()))
              .add(borderLayoutPanel()
                      .layout(new BorderLayout(0, 5))
                      .center(zipExecutable.component())
                      .east(browseZipExecutableButton))
              .add(label("Select unzip path")
                      .displayedMnemonic('U')
                      .labelFor(unzipExecutable.component()))
              .add(borderLayoutPanel()
                      .layout(new BorderLayout(0, 5))
                      .center(unzipExecutable.component())
                      .east(browseUnzipExecutableButton))
              .add(label("Select tar path")
                      .displayedMnemonic('T')
                      .labelFor(tarExecutable.component()))
              .add(borderLayoutPanel()
                      .layout(new BorderLayout(0, 5))
                      .center(tarExecutable.component())
                      .east(browseTarExecutableButton))
              .add(label("Log level")
                      .displayedMnemonic('V')
                      .labelFor(logLevel.component()))
              .add(borderLayoutPanel()
                      .layout(new BorderLayout(0, 5))
                      .center(logLevel.component())
                      .east(panel()
                              .layout(new GridLayout(1, 0, 0, 5))
                              .add(logFileButton)
                              .add(logDirectoryButton)))
              .add(keepDownloadsAvailable.component())
              .add(confirmActions.component())
              .add(confirmExit.component())
              .build(), CENTER);
    }

    private void openLogFile() {
      preferences.logFile().ifPresent(this::open);
    }

    private void openLogDirectory() {
      preferences.logDirectory().ifPresent(this::open);
    }

    private void open(File file) {
      try {
        getDesktop().open(file);
      }
      catch (IOException e) {
        throw new RuntimeException(e);
      }
    }

    private void browseExecutable(Value<String> executable) {
      executable.set(Dialogs.select()
              .files()
              .owner(this)
              .title("Select executable")
              .selectFile()
              .toPath()
              .toString());
    }
  }

  private static final class HelpPanel extends JPanel {

    private final JTextArea shortcuts = textArea()
            .value(SHORTCUTS)
            .font(monospaceFont())
            .editable(false)
            .focusable(false)
            .build();
    private final AboutPanel aboutPanel = new AboutPanel();

    private HelpPanel() {
      super(borderLayout());
      add(borderLayoutPanel()
              .center(borderLayoutPanel()
                      .border(createTitledBorder("Shortcuts"))
                      .center(shortcuts))
              .south(borderLayoutPanel()
                      .border(createTitledBorder("About"))
                      .center(aboutPanel))
              .build(), CENTER);
    }

    @Override
    public void updateUI() {
      super.updateUI();
      Utilities.updateUI(shortcuts, aboutPanel);
    }

    private static Font monospaceFont() {
      Font font = UIManager.getFont("TextArea.font");

      return new Font(Font.MONOSPACED, font.getStyle(), font.getSize());
    }

    private static final class AboutPanel extends JPanel {

      private final JEditorPane editorPane = new JEditorPane("text/html", """
              <html><table>
                <tr><td>Copyright:</td><td>Björn Darri</td></tr>
                <tr><td>License:</td><td><a href="https://www.gnu.org/licenses/gpl-3.0.en.html">GPL</a></td></tr>
                <tr><td>Source:</td><td><a href="https://github.com/codion-is/sdkboy">SDKBOY</a></td></tr>
                <tr><td></td><td><a href="https://github.com/sdkman/sdkman-cli">SDKMAN</a></td></tr>
              </table></html>
              """);

      private AboutPanel() {
        super(borderLayout());
        editorPane.setFont(monospaceFont());
        editorPane.setEditable(false);
        editorPane.setFocusable(false);
        editorPane.addHyperlinkListener(new OpenLink());
        add(editorPane, CENTER);
      }
    }

    private static final class OpenLink implements HyperlinkListener {

      @Override
      public void hyperlinkUpdate(HyperlinkEvent event) {
        if (ACTIVATED.equals(event.getEventType())) {
          try {
            getDesktop().browse(event.getURL().toURI());
          }
          catch (Exception e) {
            throw new RuntimeException(e);
          }
        }
      }
    }
  }

  public static void main(String[] args) {
    setDefaultUncaughtExceptionHandler((_, throwable) -> {
      throwable.printStackTrace();
      Dialogs.exception().show(throwable);
    });
    findLookAndFeel(getLookAndFeelPreference())
            .ifPresent(LookAndFeelEnabler::enable);

    SDKBoyPanel sdkBoyPanel = new SDKBoyPanel();

    Frames.builder()
            .component(sdkBoyPanel)
            .title("SDKBOY " + SDKBoyModel.VERSION)
            .icon(Logos.logoTransparent())
            .centerFrame(true)
            .defaultCloseOperation(DO_NOTHING_ON_CLOSE)
            .onClosing(_ -> sdkBoyPanel.exit())
            .show();
  }
}

Module Info

/**
 * SDKBOY.
 */
module is.codion.sdkboy {
  requires is.codion.swing.common.ui;
  requires is.codion.plugin.flatlaf;
  requires is.codion.plugin.flatlaf.intellij.themes;
  requires ch.qos.logback.classic;
  requires java.naming;
  requires sdkmanapi;
}

Build

Run the application.

gradlew run

Build a jlink image to build/sdkboy

gradlew jlink

Build a zipped jlink image to build/sdkboy.zip

gradlew jlinkZip

Build the default native installer(s) to build/jpackage

gradlew jpackage
settings.gradle
plugins {
    id("org.gradle.toolchains.foojay-resolver-convention") version "0.9.0"
}

rootProject.name = "sdkboy"

dependencyResolutionManagement {
    repositories {
        mavenCentral()
        mavenLocal()
    }
}
build.gradle.kts
import org.gradle.internal.os.OperatingSystem

plugins {
    // The Badass Jlink Plugin provides jlink and jpackage
    // functionality and applies the java application plugin
    // https://badass-jlink-plugin.beryx.org
    id("org.beryx.jlink") version "3.1.2"
    // Just for managing the license headers
    id("com.diffplug.spotless") version "7.0.1"
    // For the asciidoctor docs
    id("org.asciidoctor.jvm.convert") version "4.0.4"
    // For GitHub Releases
    id("com.github.breadmoirai.github-release") version "2.5.2"
}

dependencies {
    // Import the Codion Common BOM for dependency version management
    implementation(platform(libs.codion.common.bom))
    
    // The Codion Swing Common UI module
    implementation(libs.codion.swing.common.ui)
    // Include all the standard Flat Look and Feels
    implementation(libs.codion.plugin.flatlaf)
    // and a bunch of IntelliJ theme based ones
    implementation(libs.codion.plugin.flatlaf.intellij.themes)
    // The Codion logback plugin so we can configure
    // the log level and open the log file/dir
    implementation(libs.codion.plugin.logback)
    // logback implementation for the log level
    implementation(libs.logback)

    // SdkManApi + dependencies
    implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("sdkman-api-0.3.2-SNAPSHOT.jar"))))
    implementation(libs.commons.compress)
    implementation(libs.jna.platform)
}

version = "1.0.6"

java {
    toolchain {
        // Use the latest possible Java version
        languageVersion.set(JavaLanguageVersion.of(24))
    }
}

spotless {
    // Just the license headers
    java {
        licenseHeaderFile("${rootDir}/license_header").yearSeparator(" - ")
    }
    format("javaMisc") {
        target("src/**/package-info.java", "src/**/module-info.java")
        licenseHeaderFile("${rootDir}/license_header", "\\/\\*\\*").yearSeparator(" - ")
    }
}

// Configure the application plugin, the jlink plugin relies
// on this configuration when building the runtime image
application {
    mainModule = "is.codion.sdkboy"
    mainClass = "is.codion.sdkboy.ui.SDKBoyPanel"
    applicationDefaultJvmArgs = listOf(
        // This app doesn't require a lot of memory
        "-Xmx32m"
    )
}

tasks.withType<JavaCompile>().configureEach {
    options.encoding = "UTF-8"
    options.isDeprecation = true
}

// Configure the docs generation
tasks.asciidoctor {
    dependsOn(tasks.build)
    inputs.files(sourceSets.main.get().allSource)
    inputs.file(project.buildFile)

    baseDirFollowsSourceFile()

    attributes(
        mapOf(
            "source-highlighter" to "prettify",
            "tabsize" to "2"
        )
    )
    asciidoctorj {
        setVersion("2.5.13")
    }
}

// Create a version.properties file containing the application version
tasks.register<WriteProperties>("writeVersion") {
    destinationFile = file("${temporaryDir.absolutePath}/version.properties")
    property("version", "${project.version}")
}

// Include the version.properties file from above in the
// application resources, see usage in TemplateAppModel
tasks.processResources {
    from(tasks.named("writeVersion"))
}

// Configure the Jlink plugin
jlink {
    // Specify the jlink image name
    imageName = project.name + "-" + project.version + "-" +
            OperatingSystem.current().familyName.replace(" ", "").lowercase()
    // The options for the jlink task
    options = listOf(
        "--strip-debug",
        "--no-header-files",
        "--no-man-pages",
        // Add the logback plugin module
        "--add-modules",
        "is.codion.plugin.logback.proxy"
    )

    jpackage {
        if (OperatingSystem.current().isLinux) {
            icon = "src/main/icons/sdkboy.png"
            installerType = "deb"
            installerOptions = listOf(
                "--linux-shortcut"
            )
        }
        if (OperatingSystem.current().isWindows) {
            icon = "src/main/icons/sdkboy.ico"
            installerType = "msi"
            installerOptions = listOf(
                "--win-menu",
                "--win-shortcut"
            )
        }
        if (OperatingSystem.current().isMacOsX) {
            icon = "src/main/icons/sdkboy.icns"
            installerType = "dmg"
        }
    }
}

if (properties.containsKey("githubAccessToken")) {
    githubRelease {
        token(properties["githubAccessToken"] as String)
        owner = "codion-is"
        allowUploadToExisting = true
        releaseAssets.from(tasks.named("jlinkZip").get().outputs.files)
        releaseAssets.from(fileTree(tasks.named("jpackage").get().outputs.files.singleFile) {
            exclude(project.name + "/**", project.name + ".app/**")
        })
    }
}

tasks.named("githubRelease") {
    dependsOn(tasks.named("jlinkZip"))
    dependsOn(tasks.named("jpackage"))
}

tasks.register<Sync>("copyToGitHubPages") {
    group = "documentation"
    description = "Copies the documentation to the Codion github pages repository, nevermind"
    from(tasks.asciidoctor)
    into(
        "../codion-pages/doc/" + libs.versions.codion.get()
            .replace("-SNAPSHOT", "") + "/tutorials/sdkboy"
    )
}