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