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 VersionModel versionModel = new VersionModel();

  public VersionModel versionModel() {
    return versionModel;
  }

  public static final class CandidateModel {

    private final SdkManApi sdkMan;

    private final FilterTableModel<CandidateRow, CandidateColumn> tableModel =
            FilterTableModel.builder(new CandidateTableColumns())
                    .supplier(new CandidateSupplier())
                    .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(SdkManApi sdkMan) {
      this.sdkMan = sdkMan;
      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 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 CandidateSupplier 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.get() && candidateRow.installed() == 0) {
          return false;
        }
        if (filter.isNull()) {
          return true;
        }

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

  public static final class VersionModel {

    private static final int DONE = 100;

    private final SdkManApi sdkMan = new SdkManApi(DEFAULT_SDKMAN_HOME);

    private final CandidateModel candidateModel;

    private final FilterTableModel<VersionRow, VersionColumn> tableModel =
            FilterTableModel.builder(new VersionTableColumns())
                    .supplier(new VersionSupplier())
                    .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() {
      this.candidateModel = new CandidateModel(sdkMan);
      this.candidateModel.tableModel.selection().item().addListener(this::onCandidateSelected);
      tableModel.selection().item().addConsumer(this::onVersionSelected);
      tableModel.sort().order(VersionColumn.VENDOR).set(ASCENDING);
      tableModel.sort().order(VersionColumn.VERSION).add(DESCENDING);
    }

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

    public CandidateModel candidateModel() {
      return candidateModel;
    }

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

    public State selectedVersionUsed() {
      return selectedUsed;
    }

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

    public State installedOnly() {
      return installedOnly;
    }

    public State downloadedOnly() {
      return downloadedOnly;
    }

    public State usedOnly() {
      return usedOnly;
    }

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

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

    public void refreshCandidates() {
      VersionRow selected = selectedVersion();
      candidateModel.tableModel.items().refresh(candidates ->
              tableModel.items().refresh(versions ->
                      tableModel.selection().item().set(selected)));
    }

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

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

    public void use() {
      VersionRow versionRow = selectedVersion();
      try {
        sdkMan.changeGlobal(versionRow.candidate.id(), versionRow.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().get()) {
        tableModel.selection().indexes().clear();
        tableModel.selection().indexes().increment();
      }
    }

    private void onCandidateSelected() {
      tableModel.items().refresh(_ -> {
        if (tableModel.selection().empty().get()) {
          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 "normal" version strings correctly, that is,
     * ones using the major.minor.patch-metadata format.
     * For other formats, textual sorting is used.
     * @param version the standard Version, if downloaded
     * @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 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 VersionSupplier implements Supplier<Collection<VersionRow>> {

      @Override
      public Collection<VersionRow> get() {
        CandidateRow candidateRow = candidateModel.tableModel.selection().item().get();
        if (candidateRow == null) {
          return List.of();
        }
        try {
          String inUse = sdkMan.resolveCurrentVersion(candidateRow.candidate().id());

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

      private static VersionRow createRow(CandidateRow candidateRow, CandidateVersion version, String inUse) {
        return new VersionRow(candidateRow.candidate(), version,
                VersionInfo.of(version.version()), version.identifier().equals(inUse));
      }
    }

    private final class VersionVisible implements Predicate<VersionRow> {

      @Override
      public boolean test(VersionRow versionRow) {
        CandidateVersion candidateVersion = versionRow.version;
        if (installedOnly.get() && !candidateVersion.installed()) {
          return false;
        }
        if (downloadedOnly.get() && !candidateVersion.available()) {
          return false;
        }
        if (usedOnly.get() && !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);
        if (downloading.get()) {
          progress.publish("Downloading");
          progress.report(value);
        }
        else {
          progress.publish("Extracting");
        }
      }

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

SDKBoyPanel

public final class SDKBoyPanel extends JPanel {

  private static final String SHORTCUTS = """
          Alt          Mnemonics
          Enter        Navigate
          Up           Previous
          Down         Next
          Escape       Cancel
          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
                       To Clipboard         
          Double Click Version
          Not Installed > Install
          Installed     > Use
          In Use        > Uninstall
          """;

  private static final String LOOK_AND_FEEL_KEY =
          SDKBoyPanel.class.getName() + ".lookAndFeel";
  private static final String CONFIRM_ACTIONS_KEY =
          SDKBoyPanel.class.getName() + ".confirmActions";
  private static final String CONFIRM_EXIT_KEY =
          SDKBoyPanel.class.getName() + ".confirmExit";

  private final VersionPanel versionPanel;
  private final State help = State.builder()
          .consumer(this::onHelpChanged)
          .build();

  private PreferencesPanel preferencesPanel;

  private SDKBoyPanel() {
    super(borderLayout());
    setDefaultUncaughtExceptionHandler(new SDKBoyExceptionHandler());
    SDKBoyModel model = new SDKBoyModel();
    versionPanel = new VersionPanel(model.versionModel(), help);
    initializeUI();
    setupKeyEvents();
  }

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

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

  private void setupKeyEvents() {
    KeyEvents.Builder keyEvent = KeyEvents.builder()
            .condition(WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
            .modifiers(ALT_DOWN_MASK);
    keyEvent.keyCode(VK_P)
            .action(command(this::displayPreferences))
            .enable(this);
    keyEvent.keyCode(VK_R)
            .action(command(versionPanel::refreshCandidates))
            .enable(this);
    keyEvent.keyCode(VK_X)
            .action(command(this::exit))
            .enable(this);
    keyEvent.keyCode(VK_INSERT)
            .action(versionPanel.installControl)
            .enable(this);
    keyEvent.keyCode(VK_DELETE)
            .action(versionPanel.uninstallControl)
            .enable(this);
    keyEvent.keyCode(VK_I)
            .action(versionPanel.installControl)
            .enable(this);
    keyEvent.keyCode(VK_D)
            .action(versionPanel.uninstallControl)
            .enable(this);
    keyEvent.keyCode(VK_U)
            .action(versionPanel.useControl)
            .enable(this);
    keyEvent.keyCode(VK_C)
            .modifiers(ALT_DOWN_MASK)
            .action(versionPanel.copyUseCommandControl)
            .enable(this);
  }

  private void onHelpChanged(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(versionPanel.preferences);
    }
    okCancelDialog(preferencesPanel)
            .owner(this)
            .title("Preferences")
            .onOk(preferencesPanel::save)
            .onCancel(preferencesPanel::cancel)
            .show();
  }

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

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

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

  private static void setConfirmActionsPreference(boolean confirmActions) {
    setUserPreference(CONFIRM_ACTIONS_KEY, Boolean.toString(confirmActions));
  }

  private static boolean getConfirmActionsPreference() {
    return parseBoolean(getUserPreference(CONFIRM_ACTIONS_KEY, TRUE.toString()));
  }

  private static void setConfirmExitPreference(boolean confirmExit) {
    setUserPreference(CONFIRM_EXIT_KEY, Boolean.toString(confirmExit));
  }

  private static boolean getConfirmExitPreference() {
    return parseBoolean(getUserPreference(CONFIRM_EXIT_KEY, TRUE.toString()));
  }

  private static void setLookAndFeelPreference(LookAndFeelEnabler lookAndFeelEnabler) {
    setUserPreference(LOOK_AND_FEEL_KEY, lookAndFeelEnabler.lookAndFeel().getClass().getName());
  }

  private static String getLookAndFeelPreference() {
    return getUserPreference(LOOK_AND_FEEL_KEY, DarkFlat.class.getName());
  }

  private final class SDKBoyExceptionHandler implements Thread.UncaughtExceptionHandler {

    @Override
    public void uncaughtException(Thread thread, Throwable throwable) {
      throwable.printStackTrace();
      exceptionDialog()
              .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(CandidateModel model, ObservableState installing,
                           ObservableState refreshingVersions) {
      super(borderLayout());
      table = FilterTable.builder(model.tableModel(), createColumns())
              .sortingEnabled(false)
              .focusable(false)
              .selectionMode(SINGLE_SELECTION)
              .autoResizeMode(AUTO_RESIZE_ALL_COLUMNS)
              .enabled(and(installing.not(), refreshingVersions.not()))
              .cellRenderer(CandidateColumn.INSTALLED,
                      FilterTableCellRenderer.builder(Integer.class)
                              .horizontalAlignment(CENTER)
                              .build())
              .build();
      Indexes selectedIndexes = model.tableModel().selection().indexes();
      filter = stringField(model.filter())
              .hint("Filter...")
              .lowerCase(true)
              .selectAllOnFocusGained(true)
              .transferFocusOnEnter(true)
              .keyEvent(KeyEvents.builder(VK_UP)
                      .action(Control.builder()
                              .command(selectedIndexes::decrement)
                              .enabled(and(installing.not(), refreshingVersions.not()))
                              .build()))
              .keyEvent(KeyEvents.builder(VK_DOWN)
                      .action(Control.builder()
                              .command(selectedIndexes::increment)
                              .enabled(and(installing.not(), refreshingVersions.not()))
                              .build()))
              .enabled(installing.not())
              .build();
      installedOnly = checkBox(model.installedOnly())
              .text("Installed")
              .mnemonic('T')
              .focusable(false)
              .enabled(installing.not())
              .build();
      setBorder(createCompoundBorder(createTitledBorder("Candidates"), emptyBorder()));
      add(scrollPane(table)
              .preferredWidth(220)
              .build(), BorderLayout.CENTER);
      add(borderLayoutPanel()
              .centerComponent(filter)
              .eastComponent(installedOnly)
              .build(), SOUTH);
    }

    private List<FilterTableColumn<CandidateColumn>> createColumns() {
      return List.of(
              FilterTableColumn.builder(CandidateColumn.NAME, 0)
                      .headerValue("Name")
                      .build(),
              FilterTableColumn.builder(CandidateColumn.INSTALLED, 1)
                      .headerValue("Installed")
                      .fixedWidth(80)
                      .build());
    }
  }

  private static final class VersionPanel extends JPanel {

    private static final String JAVA = "Java";

    private final VersionModel model;
    private final PreferencesModel preferences = new PreferencesModel();
    private final CandidatePanel candidatePanel;
    private final State help;
    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 installControl;
    private final Control uninstallControl;
    private final Control useControl;
    private final Control copyUseCommandControl;
    private final JButton helpButton;

    private VersionPanel(VersionModel model, State help) {
      super(borderLayout());
      this.model = model;
      this.help = help;
      this.installTask = new InstallTask();
      this.installControl = Control.builder()
              .command(this::install)
              .enabled(and(
                      model.tableModel().selection().empty().not(),
                      model.selectedInstalled().not()))
              .build();
      this.uninstallControl = Control.builder()
              .command(this::uninstall)
              .enabled(and(
                      model.tableModel().selection().empty().not(),
                      model.selectedInstalled()))
              .build();
      this.useControl = Control.builder()
              .command(this::use)
              .enabled(model.selectedVersionUsed().not())
              .build();
      this.copyUseCommandControl = Control.builder()
              .command(this::copyUseCommand)
              .build();
      this.candidatePanel = new CandidatePanel(model.candidateModel(), installTask.active, model.tableModel().items().refresher().active());
      model.candidateModel().tableModel().items().refresh();
      model.candidateModel().tableModel().selection().item().addConsumer(this::onCandidateChanged);
      model.tableModel().items().refresher().active().addConsumer(this::onRefreshingChanged);
      model.tableModel().selection().item().addConsumer(this::onVersionChanged);
      installTask.active.addConsumer(this::onInstallActiveChanged);
      installTask.downloading.addConsumer(this::onDownloadingChanged);
      table = FilterTable.builder(model.tableModel(), createColumns())
              .sortingEnabled(false)
              .focusable(false)
              .selectionMode(SINGLE_SELECTION)
              .autoResizeMode(AUTO_RESIZE_ALL_COLUMNS)
              .columnReorderingAllowed(false)
              .doubleClick(command(this::onVersionDoubleClick))
              .enabled(installTask.active.not())
              .build();
      table.columnModel().visible(VersionColumn.VENDOR).set(false);
      Indexes selectedIndexes = model.tableModel().selection().indexes();
      filter = stringField(model.filter())
              .hint("Filter...")
              .lowerCase(true)
              .selectAllOnFocusGained(true)
              .transferFocusOnEnter(true)
              .keyEvent(KeyEvents.builder(VK_UP)
                      .action(command(selectedIndexes::decrement)))
              .keyEvent(KeyEvents.builder(VK_DOWN)
                      .action(command(selectedIndexes::increment)))
              .enabled(installTask.active.not())
              .build();
      installedOnly = checkBox(model.installedOnly())
              .text("Installed")
              .mnemonic('N')
              .focusable(false)
              .enabled(installTask.active.not())
              .build();
      downloadedOnly = checkBox(model.downloadedOnly())
              .text("Downloaded")
              .mnemonic('A')
              .focusable(false)
              .enabled(installTask.active.not())
              .build();
      usedOnly = checkBox(model.usedOnly())
              .text("Used")
              .mnemonic('E')
              .focusable(false)
              .enabled(installTask.active.not())
              .build();
      cancelDownload = button(Control.builder()
              .command(installTask::cancel)
              .caption("Cancel")
              .enabled(installTask.downloading))
              .keyEvent(KeyEvents.builder(VK_ESCAPE)
                      .action(command(installTask::cancel)))
              .build();
      refreshProgress = progressBar()
              .string("Refreshing...")
              .stringPainted(true)
              .build();
      installProgress = progressBar()
              .stringPainted(true)
              .indeterminate(false)
              .build();
      installingPanel = borderLayoutPanel(borderLayout())
              .centerComponent(installProgress)
              .eastComponent(cancelDownload)
              .build();
      helpButton = button(Control.builder()
              .command(this::toggleHelp)
              .caption("?")
              .mnemonic('S'))
              .focusable(false)
              .build();
      southPanel = borderLayoutPanel()
              .centerComponent(borderLayoutPanel()
                      .centerComponent(filter)
                      .eastComponent(flexibleGridLayoutPanel(1, 0)
                              .add(installedOnly)
                              .add(downloadedOnly)
                              .add(usedOnly)
                              .add(helpButton)
                              .build())
                      .build())
              .build();
      setBorder(createCompoundBorder(createTitledBorder("Versions"), emptyBorder()));
      add(scrollPane(table).build(), BorderLayout.CENTER);
      add(southPanel, SOUTH);
    }

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

    private void onVersionDoubleClick() {
      if (model.selectedVersionUsed().get()) {
        uninstall();
      }
      else if (model.selectedInstalled().get()) {
        use();
      }
      else {
        install();
      }
    }

    private void toggleHelp() {
      help.set(!help.get());
    }

    private void install() {
      install(null);
    }

    private void install(Runnable onResult) {
      if (confirmInstall()) {
        ProgressWorker.builder(installTask)
                .onStarted(installTask::started)
                .onProgress(installTask::progress)
                .onPublish(installTask::publish)
                .onDone(installTask::done)
                .onResult(() -> {
                  model.refreshCandidates();
                  if (onResult != null) {
                    onResult.run();
                  }
                })
                .execute();
      }
    }

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

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

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

    private void copyUseCommand() {
      VersionRow versionRow = model.selectedVersion();
      if (!versionRow.version().installed()) {
        install(() -> copyUseCommand(versionRow));
      }
      else {
        copyUseCommand(versionRow);
      }
    }

    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.get() || showConfirmDialog(this,
              "Install " + versionName() + "?",
              "Confirm install", YES_NO_OPTION) == YES_OPTION;
    }

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

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

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

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

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

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

    private void onInstallActiveChanged(boolean installActive) {
      toggleSouthPanel(installingPanel, installActive);
    }

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

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

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

    private List<FilterTableColumn<VersionColumn>> createColumns() {
      return List.of(
              FilterTableColumn.builder(VersionColumn.VENDOR, 0)
                      .headerValue("Vendor")
                      .build(),
              FilterTableColumn.builder(VersionColumn.VERSION, 1)
                      .headerValue("Version")
                      .build(),
              FilterTableColumn.builder(VersionColumn.INSTALLED, 2)
                      .headerValue("Installed")
                      .fixedWidth(80)
                      .build(),
              FilterTableColumn.builder(VersionColumn.DOWNLOADED, 3)
                      .headerValue("Downloaded")
                      .fixedWidth(90)
                      .build(),
              FilterTableColumn.builder(VersionColumn.USED, 4)
                      .headerValue("Used")
                      .fixedWidth(60)
                      .build());
    }

    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) {
        model.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();
        active.set(false);
      }
    }
  }

  private static final class PreferencesModel {

    private static final LoggerProxy LOGGER = LoggerProxy.instance();

    private final SdkManUiPreferences sdkManUi = SdkManUiPreferences.getInstance();
    private final State confirmActions = State.state(getConfirmActionsPreference());
    private final State confirmExit = State.state(getConfirmExitPreference());
    private final FilterComboBoxModel<Level> logLevels =
            FilterComboBoxModel.builder(LOGGER.levels().stream()
                            .map(Level.class::cast)
                            .toList())
                    .build();
  }

  private static final class PreferencesPanel extends JPanel {

    private final SDKBoyPanel.PreferencesModel preferences;

    private final ComponentValue<String, JTextField> zipExecutable;
    private final ComponentValue<String, JTextField> unzipExecutable;
    private final ComponentValue<String, JTextField> tarExecutable;
    private final ComponentValue<Boolean, JCheckBox> keepDownloadsDownloaded;
    private final ComponentValue<Boolean, JCheckBox> confirmActions;
    private final ComponentValue<Boolean, JCheckBox> confirmExit;
    private final ComponentValue<Level, JComboBox<Level>> logLevel;
    private final JButton browseZipExecutableButton;
    private final JButton browseUnzipExecutableButton;
    private final JButton browseTarExecutableButton;
    private final JButton logFileButton;
    private final JButton logDirectoryButton;
    private final LookAndFeelComboBox lookAndFeelComboBox = LookAndFeelComboBox.builder()
            .onSelection(SDKBoyPanel::setLookAndFeelPreference)
            .build();

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

    private void save() {
      setConfirmActionsPreference(preferences.confirmActions.get());
      setConfirmExitPreference(preferences.confirmExit.get());
      PreferencesModel.LOGGER.setLogLevel(logLevel.getOrThrow());
      preferences.sdkManUi.zipExecutable = zipExecutable.get();
      preferences.sdkManUi.unzipExecutable = unzipExecutable.get();
      preferences.sdkManUi.tarExecutable = tarExecutable.get();
      preferences.sdkManUi.keepDownloadsAvailable = keepDownloadsDownloaded.getOrThrow();
      try {
        flushUserPreferences();
        preferences.sdkManUi.save();
      }
      catch (Exception e) {
        throw new RuntimeException(e);
      }
    }

    private void cancel() {
      preferences.confirmActions.set(getConfirmActionsPreference());
      preferences.confirmExit.set(getConfirmExitPreference());
      logLevel.set((Level) PreferencesModel.LOGGER.getLogLevel());
      zipExecutable.set(preferences.sdkManUi.zipExecutable);
      unzipExecutable.set(preferences.sdkManUi.unzipExecutable);
      tarExecutable.set(preferences.sdkManUi.tarExecutable);
      keepDownloadsDownloaded.set(preferences.sdkManUi.keepDownloadsAvailable);
    }

    private void openLogFile() {
      PreferencesModel.LOGGER.files().stream()
              .map(File::new)
              .findFirst()
              .ifPresent(this::open);
    }

    private void openLogDirectory() {
      PreferencesModel.LOGGER.files().stream()
              .map(File::new)
              .map(File::getParentFile)
              .findFirst()
              .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(fileSelectionDialog()
              .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()
              .centerComponent(borderLayoutPanel()
                      .border(createTitledBorder("Shortcuts"))
                      .centerComponent(shortcuts)
                      .build())
              .southComponent(borderLayoutPanel()
                      .border(createTitledBorder("About"))
                      .centerComponent(aboutPanel)
                      .build())
              .build(), BorderLayout.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, BorderLayout.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();
      exceptionDialog().show(throwable);
    });

    findLookAndFeel(getLookAndFeelPreference())
            .ifPresent(LookAndFeelEnabler::enable);

    SDKBoyPanel sdkBoyPanel = new SDKBoyPanel();

    componentDialog(sdkBoyPanel)
            .title("SDKBOY " + SDKBoyModel.VERSION)
            .icon(Logos.logoTransparent())
            .disposeOnEscape(false)
            .confirmCloseListener(exit ->
                    exit.set(sdkBoyPanel.confirmExit()))
            .onBuild(dialog ->
                    dialog.setDefaultCloseOperation(DO_NOTHING_ON_CLOSE))
            .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()
    }
    versionCatalogs {
        libs {
            version("codion", "0.18.34")
            version("logback", "1.5.18")
            version("commons", "1.27.1")
            version("jna", "5.17.0");

            library("codion-swing-common-ui", "is.codion", "codion-swing-common-ui").versionRef("codion")
            library("codion-plugin-flatlaf", "is.codion", "codion-plugin-flatlaf").versionRef("codion")
            library("codion-plugin-flatlaf-intellij-themes", "is.codion", "codion-plugin-flatlaf-intellij-themes").versionRef("codion")
            library("codion-plugin-logback", "is.codion", "codion-plugin-logback-proxy").versionRef("codion")
            library("logback", "ch.qos.logback", "logback-classic").versionRef("logback")
            library("commons-compress", "org.apache.commons", "commons-compress").versionRef("commons")
            library("jna-platform", "net.java.dev.jna", "jna-platform").versionRef("jna");
        }
    }
}
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.1"
    // 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"
}

dependencies {
    // 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 = "0.9.7"

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 {
    inputs.dir("src")
    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
    // 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 || OperatingSystem.current().isMacOsX) {
            icon = "src/main/icons/sdkboy.png"
        }
        if (OperatingSystem.current().isLinux) {
            installerOptions = listOf("--linux-shortcut")
        }
        if (OperatingSystem.current().isWindows) {
            icon = "src/main/icons/sdkboy.ico"
            installerOptions = listOf(
                "--win-menu",
                "--win-shortcut"
            )
        }
    }
}

tasks.register("tagRelease") {
    group = "other"
    description = "Tags the current version as a release"

    doLast {
        if (project.version.toString().contains("SNAPSHOT")) {
            throw GradleException("Thou shalt not tag a snapshot release")
        }
        val tagName = "v" + project.version
        providers.exec { commandLine("git", "push", "origin") }.result.get()
        providers.exec { commandLine("git", "tag", "-a", tagName, "-m", "$tagName release") }.result.get()
        providers.exec { commandLine("git", "push", "origin", tagName) }.result.get()
    }
}

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