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 CandidateModel candidate = new CandidateModel();
  private final VersionModel version = new VersionModel(candidate);
  private final PreferencesModel preferences = new PreferencesModel();

  public SDKBoyModel() {}

  public CandidateModel candidate() {
    return candidate;
  }

  public VersionModel version() {
    return version;
  }

  public PreferencesModel preferences() {
    return preferences;
  }
}

CandidateModel

public final class CandidateModel {

  private final SdkManApi sdkMan = new SdkManApi(DEFAULT_SDKMAN_HOME);
  private final SwingFilterTableModel<CandidateRow, CandidateColumn> tableModel =
          SwingFilterTableModel.builder()
                  .columns(new CandidateColumns())
                  .items(new CandidateItems())
                  .included(new CandidateIncluded())
                  .build();
  private final Value<String> filter = Value.builder()
          .<String>nullable()
          .listener(this::onFilterChanged)
          .build();
  private final State installedOnly = State.builder()
          .listener(this::onFilterChanged)
          .build();

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

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

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

  public State installedOnly() {
    return installedOnly;
  }

  public SdkManApi sdkMan() {
    return sdkMan;
  }

  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 CandidateColumns 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 Exceptions.runtime(e);
      }
    }
  }

  private final class CandidateIncluded 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());
    }
  }
}

VersionModel

public final class VersionModel {

  private static final int DONE = 100;

  private final SdkManApi sdkMan;
  private final Observable<CandidateRow> selectedCandidate;
  private final SwingFilterTableModel<VersionRow, VersionColumn> tableModel =
          SwingFilterTableModel.builder()
                  .columns(new VersionColumns())
                  .items(new VersionItems())
                  .included(new VersionIncluded())
                  .onItemSelected(this::onVersionSelected)
                  .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();

  VersionModel(CandidateModel candidateModel) {
    sdkMan = candidateModel.sdkMan();
    selectedCandidate = candidateModel.tableModel().selection().item().observable();
    selectedCandidate.addListener(this::onCandidateSelected);
    tableModel.sort().order(VersionColumn.VENDOR).set(ASCENDING);
    tableModel.sort().order(VersionColumn.VERSION).add(DESCENDING);
  }

  public SwingFilterTableModel<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 Exceptions.runtime(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);
      }
      if (version != null) {
        return -1;
      }
      if (versionInfo.version != null) {
        return 1;
      }

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

  private static final class VersionColumns 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 selectedCandidate.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 Exceptions.runtime(e);
      }
    }
  }

  private final class VersionIncluded implements Predicate<VersionRow> {

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

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

      return strings.allMatch(filter -> versionString.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) {}
  }
}

PreferencesModel

public 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 SwingFilterComboBoxModel<Level> logLevels = SwingFilterComboBoxModel.builder()
          .items(logger.levels().stream()
                  .map(Level.class::cast)
                  .toList())
          .build();

  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 SwingFilterComboBoxModel<Level> logLevels() {
    return logLevels;
  }

  public Level logLevel() {
    return (Level) logger.getLogLevel(logger.rootLogger());
  }

  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(String lookAndFeelClassName) {
    UserPreferences.put(LOOK_AND_FEEL, lookAndFeelClassName);
  }

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

  public void save() {
    UserPreferences.put(CONFIRM_ACTIONS, Boolean.toString(confirmActions.is()));
    UserPreferences.put(CONFIRM_EXIT, Boolean.toString(confirmExit.is()));
    logger.setLogLevel(logger.rootLogger(), 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 Exceptions.runtime(e);
    }
  }

  public void revert() {
    confirmActions.set(getConfirmActionsPreference());
    confirmExit.set(getConfirmExitPreference());
    logLevels.selection().item().set((Level) logger.getLogLevel(logger.rootLogger()));
    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/PageUp     Previous
          Down/PageDown 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 candidate;
  private final VersionPanel version;
  private final State help = State.builder()
          .consumer(this::onHelp)
          .build();

  private PreferencesPanel preferences;

  private SDKBoyPanel() {
    super(borderLayout());
    setDefaultUncaughtExceptionHandler(new SDKBoyExceptionHandler());
    version = new VersionPanel(model, help);
    candidate = new CandidatePanel(model.candidate(), version.installing(),
            model.version().tableModel().items().refresher().active());
    initializeUI();
    setupKeyEvents();
  }

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

  private void initializeUI() {
    setBorder(emptyBorder());
    add(candidate, WEST);
    add(version, CENTER);
  }

  private void setupKeyEvents() {
    KeyEvents.builder()
            .condition(WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
            .modifiers(ALT_DOWN_MASK)
            .keyCode(VK_P)
            .action(command(this::preferences))
            .enable(this)
            .keyCode(VK_X)
            .action(command(this::exit))
            .enable(this)
            .keyCode(VK_O)
            .action(candidate.controls().description())
            .enable(this)
            .keyCode(VK_R)
            .action(candidate.controls().refresh())
            .enable(this)
            .keyCode(VK_INSERT)
            .action(version.controls().install())
            .enable(this)
            .keyCode(VK_DELETE)
            .action(version.controls().uninstall())
            .enable(this)
            .keyCode(VK_I)
            .action(version.controls().install())
            .enable(this)
            .keyCode(VK_D)
            .action(version.controls().uninstall())
            .enable(this)
            .keyCode(VK_U)
            .action(version.controls().use())
            .enable(this)
            .keyCode(VK_C)
            .action(version.controls().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 preferences() {
    if (preferences == null) {
      preferences = new PreferencesPanel(model.preferences());
    }
    Dialogs.okCancel()
            .component(preferences)
            .owner(this)
            .title("Preferences")
            .onOk(model.preferences()::save)
            .onCancel(model.preferences()::revert)
            .show();
  }

  private void exit() {
    if (confirmExit()) {
      Ancestor.window().of(this).dispose();
    }
  }

  private boolean confirmExit() {
    if (version.installing().is()) {
      return false;
    }

    return !model.preferences().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 HelpPanel extends JPanel {

    private final JTextArea shortcuts = textArea()
            .value(SHORTCUTS)
            .font(HelpPanel::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) {
      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.getFont()));
        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 Exceptions.runtime(e);
          }
        }
      }
    }
  }

  static void main() {
    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(svgIcon(SDKBoyPanel.class.getResource("logo.svg"), 68, Color.BLACK))
            .centerFrame(true)
            .defaultCloseOperation(DO_NOTHING_ON_CLOSE)
            .onClosing(_ -> sdkBoyPanel.exit())
            .show();
  }
}

CandidatePanel

final class CandidatePanel extends JPanel {

  private final CandidateModel candidate;
  private final FilterTable<CandidateRow, CandidateColumn> table;
  private final JTextField filter;
  private final JCheckBox installedOnly;
  private final CandidateControls controls;

  CandidatePanel(CandidateModel model, ObservableState installing, ObservableState refreshing) {
    super(borderLayout());
    candidate = model;
    controls = new CandidateControls();
    table = FilterTable.builder()
            .model(this.candidate.tableModel())
            .columns(this::configureColumns)
            .sortable(false)
            .focusable(false)
            .selectionMode(SINGLE_SELECTION)
            .autoResizeMode(AUTO_RESIZE_ALL_COLUMNS)
            .columnReordering(false)
            .rowsFillViewport(true)
            .enabled(and(installing.not(), refreshing.not()))
            .cellRenderer(CandidateColumn.INSTALLED, Integer.class, renderer -> renderer
                    .horizontalAlignment(SwingConstants.CENTER))
            .build();
    filter = createFilterField(candidate.filter(), table, installing);
    installedOnly = checkBox()
            .link(candidate.installedOnly())
            .text("Installed")
            .mnemonic('T')
            .focusable(false)
            .enabled(installing.not())
            .build();
    setBorder(createCompoundBorder(createTitledBorder("Candidates"), emptyBorder()));
    add(scrollPane()
            .view(table)
            .preferredWidth(220)
            .build(), CENTER);
    add(borderLayoutPanel()
            .center(filter)
            .east(installedOnly)
            .build(), SOUTH);
  }

  CandidateControls controls() {
    return controls;
  }

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

  private void refresh() {
    candidate.tableModel().items().refresh();
  }

  private void displayDescription() {
    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(CandidatePanel.this)
                    .title(candidateRow.candidate().name() + " - Description")
                    .show());
  }

  static JTextField createFilterField(Value<String> filter, FilterTable<?, ?> table, ObservableState installing) {
    Indexes selectedIndexes = table.model().selection().indexes();

    return stringField()
            .link(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)))
            .keyEvent(KeyEvents.builder()
                    .keyCode(VK_PAGE_UP)
                    .action(pageUpControl(table)))
            .keyEvent(KeyEvents.builder()
                    .keyCode(VK_PAGE_DOWN)
                    .action(pageDownControl(table)))
            .enabled(installing.not())
            .build();
  }

  private static Control pageDownControl(FilterTable<?, ?> table) {
    return command(() -> {
      int visibleRowCount = Ancestor.ofType(JScrollPane.class).of(table).get().getViewport().getHeight() / table.getRowHeight();
      table.model().selection().index().update(index ->
              Math.min((index == -1 ? 0 : index) + visibleRowCount - 1, table.model().items().included().size() - 1));
    });
  }

  private static Control pageUpControl(FilterTable<?, ?> table) {
    return command(() -> {
      int visibleRowCount = Ancestor.ofType(JScrollPane.class).of(table).get().getViewport().getHeight() / table.getRowHeight();
      table.model().selection().index().update(index ->
              Math.max((index == -1 ? 0 : index) - visibleRowCount + 1, 0));
    });
  }

  final class CandidateControls {

    private final Control refresh;
    private final Control description;

    private CandidateControls() {
      refresh = command(CandidatePanel.this::refresh);
      description = command(CandidatePanel.this::displayDescription);
    }

    Control refresh() {
      return refresh;
    }

    Control description() {
      return description;
    }
  }
}

VersionPanel

final class VersionPanel extends JPanel {

  private static final String JAVA = "Java";

  private final VersionModel version;
  private final PreferencesModel preferences;
  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 JButton helpButton;
  private final SouthComponent southComponent;
  private final VersionControls controls;

  VersionPanel(SDKBoyModel model, State help) {
    super(borderLayout());
    version = model.version();
    preferences = model.preferences();
    installTask = new InstallTask();
    controls = new VersionControls();
    model.candidate().tableModel().selection().item().addConsumer(this::onCandidateSelected);
    version.tableModel().items().refresher().active().addConsumer(this::onRefreshing);
    version.tableModel().selection().item().addConsumer(this::onVersionSelected);
    installTask.active.addConsumer(this::onInstalling);
    installTask.downloading.addConsumer(this::onDownloading);
    table = FilterTable.builder()
            .model(version.tableModel())
            .columns(this::configureColumns)
            .sortable(false)
            .focusable(false)
            .selectionMode(SINGLE_SELECTION)
            .autoResizeMode(AUTO_RESIZE_ALL_COLUMNS)
            .columnReordering(false)
            .hideColumns(VersionColumn.VENDOR)
            .rowsFillViewport(true)
            .doubleClick(command(this::onVersionDoubleClick))
            .enabled(installTask.active.not())
            .build();
    filter = createFilterField(version.filter(), table, installTask.active);
    installedOnly = checkBox()
            .link(version.installedOnly())
            .text("Installed")
            .mnemonic('N')
            .focusable(false)
            .enabled(installTask.active.not())
            .build();
    downloadedOnly = checkBox()
            .link(version.downloadedOnly())
            .text("Downloaded")
            .mnemonic('A')
            .focusable(false)
            .enabled(installTask.active.not())
            .build();
    usedOnly = checkBox()
            .link(version.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();
    southComponent = new SouthComponent();
    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);
  }

  VersionControls controls() {
    return controls;
  }

  ObservableState installing() {
    return installTask.active.observable();
  }

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

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

  private void install(Runnable onSuccess) {
    if (confirmInstall()) {
      ProgressWorker.builder()
              .task(installTask)
              .onSuccess(onSuccess)
              .execute();
    }
  }

  private void uninstall() {
    if (confirmUninstall()) {
      ProgressWorker.builder()
              .task(version::uninstall)
              .onSuccess(version::refresh)
              .execute();
    }
  }

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

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

  private void copyUseCommand() {
    VersionRow selected = version.selected();
    if (selected.version().installed()) {
      copyUseCommand(selected);
    }
    else {
      install(() -> 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 !preferences.confirmActions().is() || showConfirmDialog(this,
            "Install " + versionName() + "?",
            "Confirm install", YES_NO_OPTION) == YES_OPTION;
  }

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

  private boolean confirmUse() {
    return !preferences.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) {
    southComponent.toggle(refreshProgress, refreshing);
  }

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

  private void onInstalling(boolean installing) {
    southComponent.toggle(installingPanel, installing);
  }

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

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

  final class VersionControls {

    private final Control install;
    private final Control uninstall;
    private final Control use;
    private final Control copyUseCommand;

    private VersionControls() {
      install = Control.builder()
              .command(VersionPanel.this::install)
              .enabled(and(
                      version.tableModel().selection().empty().not(),
                      version.selectedInstalled().not()))
              .build();
      uninstall = Control.builder()
              .command(VersionPanel.this::uninstall)
              .enabled(and(
                      version.tableModel().selection().empty().not(),
                      version.selectedInstalled()))
              .build();
      use = Control.builder()
              .command(VersionPanel.this::use)
              .enabled(version.selectedUsed().not())
              .build();
      copyUseCommand = Control.builder()
              .command(VersionPanel.this::copyUseCommand)
              .build();
    }

    Control install() {
      return install;
    }

    Control uninstall() {
      return uninstall;
    }

    Control use() {
      return use;
    }

    Control copyUseCommand() {
      return copyUseCommand;
    }
  }

  private final class InstallTask implements ProgressTaskHandler<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) {
      version.install(progress, downloading, cancel);
    }

    @Override
    public void onStarted() {
      installProgress.setString("Procrastinating");
      active.set(true);
    }

    @Override
    public void onProgress(int progress) {
      installProgress.setValue(progress);
    }

    @Override
    public void onPublish(List<String> status) {
      installProgress.setString(status.getFirst() + " " + versionName());
    }

    @Override
    public void onDone() {
      installProgress.setString("");
      installProgress.setValue(0);
      filter.requestFocusInWindow();
      downloading.set(false);
      active.set(false);
    }

    @Override
    public void onSuccess() {
      version.refresh();
    }

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

  private final class SouthComponent {

    private static final int SHOW_DELAY = 350;

    private @Nullable DelayedAction show;

    private void toggle(JComponent component, boolean visible) {
      if (visible) {
        show = delayedAction(() -> show(component), SHOW_DELAY);
      }
      else {
        hide(component);
      }
    }

    private void show(JComponent component) {
      southPanel.add(component, NORTH);
      revalidate();
      repaint();
    }

    private void hide(JComponent component) {
      cancel();
      southPanel.remove(component);
      revalidate();
      repaint();
    }

    private void cancel() {
      if (show != null) {
        show.cancel();
        show = null;
      }
    }
  }
}

PreferencesPanel

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;

  PreferencesPanel(PreferencesModel preferences) {
    super(borderLayout());
    this.preferences = preferences;
    lookAndFeelComboBox = LookAndFeelComboBox.builder()
            .onSelection(this::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(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 setLookAndFeelPreference(LookAndFeelEnabler lookAndFeelEnabler) {
    preferences.setLookAndFeelPreference(lookAndFeelEnabler.lookAndFeel().getClass().getName());
  }

  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 Exceptions.runtime(e);
    }
  }

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

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 "1.0.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 "4.0.2"
    // Just for managing the license headers
    id("com.diffplug.spotless") version "8.2.1"
    // For the asciidoc 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.themes)
    // 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.1.3"

java {
    toolchain {
        // Use the latest possible Java version
        languageVersion.set(JavaLanguageVersion.of(26))
        vendor.set(JvmVendorSpec.ORACLE)
    }
}

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 "rouge",
            "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 SDKBoyModel
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"))
}