SDKBoyModel
public final class SDKBoyModel {
public static final Version VERSION = Version.parse(SDKBoyModel.class, "/version.properties");
private final SdkManApi sdkMan = new SdkManApi(DEFAULT_SDKMAN_HOME);
private final CandidateModel candidateModel;
private final VersionModel versionModel;
private final PreferencesModel preferencesModel;
public SDKBoyModel() {
candidateModel = new CandidateModel();
versionModel = new VersionModel();
preferencesModel = new PreferencesModel();
}
public CandidateModel candidateModel() {
return candidateModel;
}
public VersionModel versionModel() {
return versionModel;
}
public PreferencesModel preferencesModel() {
return preferencesModel;
}
public void refresh() {
candidateModel.tableModel.items().refresh(_ ->
versionModel.tableModel.items().refresh());
}
public final class CandidateModel {
private final FilterTableModel<CandidateRow, CandidateColumn> tableModel =
FilterTableModel.builder()
.columns(new CandidateTableColumns())
.items(new CandidateItems())
.visible(new CandidateVisible())
.build();
private final Value<String> filter = Value.builder()
.<String>nullable()
.listener(this::onFilterChanged)
.build();
private final State installedOnly = State.builder()
.listener(this::onFilterChanged)
.build();
private CandidateModel() {
tableModel.sort().order(CandidateColumn.NAME).set(ASCENDING);
}
public FilterTableModel<CandidateRow, CandidateColumn> tableModel() {
return tableModel;
}
public Value<String> filter() {
return filter;
}
public State installedOnly() {
return installedOnly;
}
private void onFilterChanged() {
tableModel.items().filter();
tableModel.selection().indexes().clear();
tableModel.selection().indexes().increment();
}
public enum CandidateColumn {
NAME, INSTALLED
}
public record CandidateRow(Candidate candidate, int installed) {
@Override
public String toString() {
return candidate.name();
}
@Override
public boolean equals(Object object) {
if (object == null || getClass() != object.getClass()) {
return false;
}
CandidateRow candidateRow = (CandidateRow) object;
return Objects.equals(candidate.id(), candidateRow.candidate.id());
}
@Override
public int hashCode() {
return Objects.hashCode(candidate.id());
}
}
private static final class CandidateTableColumns implements TableColumns<CandidateRow, CandidateColumn> {
private static final List<CandidateColumn> IDENTIFIERS = List.of(CandidateColumn.values());
@Override
public List<CandidateColumn> identifiers() {
return IDENTIFIERS;
}
@Override
public Class<?> columnClass(CandidateColumn column) {
return switch (column) {
case NAME -> String.class;
case INSTALLED -> Integer.class;
};
}
@Override
public String caption(CandidateColumn column) {
return switch (column) {
case NAME -> "Name";
case INSTALLED -> "Installed";
};
}
@Override
public Object value(CandidateRow row, CandidateColumn column) {
return switch (column) {
case NAME -> row.candidate.name();
case INSTALLED -> row.installed == 0 ? null : row.installed;
};
}
@Override
public Comparator<?> comparator(CandidateColumn identifier) {
if (identifier == CandidateColumn.NAME) {
return Comparator.<String, String>comparing(String::toLowerCase);
}
return TableColumns.super.comparator(identifier);
}
}
private class CandidateItems implements Supplier<Collection<CandidateRow>> {
@Override
public Collection<CandidateRow> get() {
try {
return sdkMan.getCandidates().get().stream()
.map(candidate -> new CandidateRow(candidate,
sdkMan.getLocalInstalledVersions(candidate.id()).size()))
.toList();
}
catch (Exception e) {
throw new RuntimeException(e);
}
}
}
private final class CandidateVisible implements Predicate<CandidateRow> {
@Override
public boolean test(CandidateRow candidateRow) {
if (installedOnly.is() && candidateRow.installed() == 0) {
return false;
}
if (filter.isNull()) {
return true;
}
return candidateRow.candidate.name().toLowerCase().contains(filter.getOrThrow());
}
}
}
public final class VersionModel {
private static final int DONE = 100;
private final FilterTableModel<VersionRow, VersionColumn> tableModel =
FilterTableModel.builder()
.columns(new VersionTableColumns())
.items(new VersionItems())
.visible(new VersionVisible())
.build();
private final State selectedInstalled = State.state();
private final State selectedUsed = State.state();
private final Value<String> filter = Value.builder()
.<String>nullable()
.listener(this::onFilterChanged)
.build();
private final State installedOnly = State.builder()
.listener(this::onFilterChanged)
.build();
private final State downloadedOnly = State.builder()
.listener(this::onFilterChanged)
.build();
private final State usedOnly = State.builder()
.listener(this::onFilterChanged)
.build();
private VersionModel() {
tableModel.selection().item().addConsumer(this::onVersionSelected);
tableModel.sort().order(VersionColumn.VENDOR).set(ASCENDING);
tableModel.sort().order(VersionColumn.VERSION).add(DESCENDING);
candidateModel.tableModel.selection().item().addListener(this::onCandidateSelected);
}
public FilterTableModel<VersionRow, VersionColumn> tableModel() {
return tableModel;
}
public ObservableState selectedInstalled() {
return selectedInstalled.observable();
}
public ObservableState selectedUsed() {
return selectedUsed.observable();
}
public Value<String> filter() {
return filter;
}
public State installedOnly() {
return installedOnly;
}
public State downloadedOnly() {
return downloadedOnly;
}
public State usedOnly() {
return usedOnly;
}
public VersionRow selected() {
return tableModel.selection().item().getOrThrow();
}
public void refresh() {
tableModel.items().refresh();
}
public void install(ProgressReporter<String> progress, State downloading, Observer<?> cancel) {
VersionRow selected = selected();
if (selected.version.available()) {
progress.report(DONE);
}
else {
download(selected, progress, downloading, cancel);
}
progress.publish("Installing");
sdkMan.install(selected.candidate.id(), selected.version.identifier());
progress.publish("Done");
}
public void uninstall() {
VersionRow selected = selected();
sdkMan.uninstall(selected.candidate.id(), selected.version.identifier());
}
public void use() {
VersionRow selected = selected();
try {
sdkMan.changeGlobal(selected.candidate.id(), selected.version.identifier());
}
catch (IOException e) {
throw new RuntimeException(e);
}
}
private void download(VersionRow versionRow, ProgressReporter<String> progress,
State downloading, Observer<?> cancel) {
DownloadTask task = sdkMan.download(versionRow.candidate.id(), versionRow.version.identifier());
task.setProgressInformation(new DownloadProgress(progress, downloading));
Runnable cancelTask = task::cancel;
cancel.addListener(cancelTask);
try {
task.download();
}
finally {
// Prevent a memory leak, the cancel Observer
// comes from a single InstallTask instance
cancel.removeListener(cancelTask);
}
if (task.isCancelled()) {
throw new CancelException();
}
}
private void onFilterChanged() {
tableModel.items().filter();
if (!filter.isNull() || tableModel.selection().empty().is()) {
tableModel.selection().indexes().clear();
tableModel.selection().indexes().increment();
}
}
private void onCandidateSelected() {
tableModel.items().refresh(_ -> {
if (tableModel.selection().empty().is()) {
tableModel.selection().indexes().increment();
}
});
}
private void onVersionSelected(VersionRow versionRow) {
selectedInstalled.set(versionRow != null && versionRow.version.installed());
selectedUsed.set(versionRow != null && versionRow.used());
}
public enum VersionColumn {
VENDOR, VERSION, INSTALLED, DOWNLOADED, USED
}
public record VersionRow(Candidate candidate, CandidateVersion version, VersionInfo versionInfo, boolean used) {
@Override
public boolean equals(Object object) {
if (object == null || getClass() != object.getClass()) {
return false;
}
VersionRow row = (VersionRow) object;
return Objects.equals(candidate.id(), row.candidate.id()) &&
Objects.equals(version.identifier(), row.version.identifier());
}
@Override
public int hashCode() {
return Objects.hash(candidate.id(), version.identifier());
}
}
/**
* Sort semantic version strings correctly, that is,
* ones using the major.minor.patch-metadata format.
* For other formats, textual sorting is used.
* @param version the semantic Version, if available
* @param versionName the version name
*/
public record VersionInfo(Version version, String versionName) implements Comparable<VersionInfo> {
public static VersionInfo of(String version) {
long dots = version.chars().filter(ch -> ch == '.').count();
if (dots > 2) {
return new VersionInfo(null, version);
}
try {
return new VersionInfo(Version.parse(version), version);
}
catch (Exception e) {
return new VersionInfo(null, version);
}
}
@Override
public String toString() {
return version == null ? versionName : version.toString();
}
@Override
public int compareTo(VersionInfo versionInfo) {
if (version != null && versionInfo.version != null) {
return version.compareTo(versionInfo.version);
}
return versionName.compareTo(versionInfo.versionName);
}
}
private static final class VersionTableColumns implements TableColumns<VersionRow, VersionColumn> {
private static final List<VersionColumn> IDENTIFIERS = List.of(VersionColumn.values());
@Override
public List<VersionColumn> identifiers() {
return IDENTIFIERS;
}
@Override
public Class<?> columnClass(VersionColumn column) {
return switch (column) {
case VENDOR -> String.class;
case VERSION -> VersionInfo.class;
case INSTALLED, DOWNLOADED, USED -> Boolean.class;
};
}
@Override
public String caption(VersionColumn column) {
return switch (column) {
case VENDOR -> "Vendor";
case VERSION -> "Version";
case INSTALLED -> "Installed";
case DOWNLOADED -> "Downloaded";
case USED -> "Used";
};
}
@Override
public Object value(VersionRow row, VersionColumn column) {
return switch (column) {
case VENDOR -> row.version.vendor();
case VERSION -> row.versionInfo();
case INSTALLED -> row.version.installed();
case DOWNLOADED -> row.version.available();
case USED -> row.used;
};
}
}
private class VersionItems implements Supplier<Collection<VersionRow>> {
@Override
public Collection<VersionRow> get() {
return candidateModel.tableModel.selection().item().optional()
.map(this::candidateVersions)
.orElse(List.of());
}
private Collection<VersionRow> candidateVersions(CandidateRow candidateRow) {
try {
String inUse = sdkMan.resolveCurrentVersion(candidateRow.candidate().id());
return sdkMan.getVersions(candidateRow.candidate().id()).stream()
.map(version -> new VersionRow(candidateRow.candidate(), version,
VersionInfo.of(version.version()), version.identifier().equals(inUse)))
.toList();
}
catch (Exception e) {
throw new RuntimeException(e);
}
}
}
private final class VersionVisible implements Predicate<VersionRow> {
@Override
public boolean test(VersionRow versionRow) {
CandidateVersion candidateVersion = versionRow.version;
if (installedOnly.is() && !candidateVersion.installed()) {
return false;
}
if (downloadedOnly.is() && !candidateVersion.available()) {
return false;
}
if (usedOnly.is() && !versionRow.used) {
return false;
}
if (filter.isNull()) {
return true;
}
Stream<String> strings = Stream.of(filter.getOrThrow().toLowerCase().split(" "))
.map(String::trim);
String version = candidateVersion.version().toLowerCase();
if (candidateVersion.vendor() == null) {
return strings.allMatch(version::contains);
}
String vendor = candidateVersion.vendor().toLowerCase();
return strings.allMatch(filter -> version.contains(filter) || vendor.contains(filter));
}
}
private static final class DownloadProgress implements ProgressInformation {
private final ProgressReporter<String> progress;
private final State downloading;
private DownloadProgress(ProgressReporter<String> progress, State downloading) {
this.progress = progress;
this.downloading = downloading;
}
@Override
public void publishProgress(int value) {
downloading.set(value >= 1 && value < 100);
if (downloading.is()) {
progress.publish("Downloading");
progress.report(value);
}
else {
progress.publish("Extracting");
}
}
@Override
public void publishState(String state) {}
}
}
public static final class PreferencesModel {
private static final String LOOK_AND_FEEL = "SDKBOY.lookAndFeel";
private static final String CONFIRM_ACTIONS = "SDKBOY.confirmActions";
private static final String CONFIRM_EXIT = "SDKBOY.confirmExit";
private final LoggerProxy logger = LoggerProxy.instance();
private final SdkManUiPreferences sdkManUi = SdkManUiPreferences.getInstance();
private final Value<String> zipExecutable = Value.nullable(sdkManUi.zipExecutable);
private final Value<String> unzipExecutable = Value.nullable(sdkManUi.unzipExecutable);
private final Value<String> tarExecutable = Value.nullable(sdkManUi.tarExecutable);
private final State keepDownloadsAvailable = State.state(sdkManUi.keepDownloadsAvailable);
private final State confirmActions = State.state(getConfirmActionsPreference());
private final State confirmExit = State.state(getConfirmExitPreference());
private final FilterComboBoxModel<Level> logLevels = FilterComboBoxModel.builder()
.items(logger.levels().stream()
.map(Level.class::cast)
.toList())
.build();
private PreferencesModel() {}
public Value<String> zipExecutable() {
return zipExecutable;
}
public Value<String> unzipExecutable() {
return unzipExecutable;
}
public Value<String> tarExecutable() {
return tarExecutable;
}
public State keepDownloadsAvailable() {
return keepDownloadsAvailable;
}
public State confirmActions() {
return confirmActions;
}
public State confirmExit() {
return confirmExit;
}
public FilterComboBoxModel<Level> logLevels() {
return logLevels;
}
public Object logLevel() {
return logger.getLogLevel();
}
public Optional<File> logFile() {
return logger.files().stream()
.map(File::new)
.findFirst();
}
public Optional<File> logDirectory() {
return logger.files().stream()
.map(File::new)
.map(File::getParentFile)
.findFirst();
}
public void setLookAndFeelPreference(LookAndFeelEnabler lookAndFeelEnabler) {
UserPreferences.set(LOOK_AND_FEEL, lookAndFeelEnabler.lookAndFeel().getClass().getName());
}
public static String getLookAndFeelPreference() {
return UserPreferences.get(LOOK_AND_FEEL, DarkFlat.class.getName());
}
public void save() {
UserPreferences.set(CONFIRM_ACTIONS, Boolean.toString(confirmActions.is()));
UserPreferences.set(CONFIRM_EXIT, Boolean.toString(confirmExit.is()));
logger.setLogLevel(logLevels.selection().item().getOrThrow());
sdkManUi.zipExecutable = zipExecutable.get();
sdkManUi.unzipExecutable = unzipExecutable.get();
sdkManUi.tarExecutable = tarExecutable.get();
sdkManUi.keepDownloadsAvailable = keepDownloadsAvailable.is();
try {
UserPreferences.flush();
sdkManUi.save();
}
catch (Exception e) {
throw new RuntimeException(e);
}
}
public void revert() {
confirmActions.set(getConfirmActionsPreference());
confirmExit.set(getConfirmExitPreference());
logLevels.selection().item().set((Level) logger.getLogLevel());
zipExecutable.set(sdkManUi.zipExecutable);
unzipExecutable.set(sdkManUi.unzipExecutable);
tarExecutable.set(sdkManUi.tarExecutable);
keepDownloadsAvailable.set(sdkManUi.keepDownloadsAvailable);
}
private static boolean getConfirmActionsPreference() {
return parseBoolean(UserPreferences.get(CONFIRM_ACTIONS, TRUE.toString()));
}
private static boolean getConfirmExitPreference() {
return parseBoolean(UserPreferences.get(CONFIRM_EXIT, TRUE.toString()));
}
}
}
SDKBoyPanel
public final class SDKBoyPanel extends JPanel {
private static final String SHORTCUTS = """
Alt Mnemonics
Enter Navigate
Up Previous
Down Next
Escape Cancel
Alt-O Description
Alt-S Shortcuts
Alt-P Preferences
Alt-R Refresh
Alt-X Exit
Alt-I/Ins Install
Alt-D/Del Uninstall
Alt-U Use
Alt-C Copy USE Command
Double Click Version
Uninstalled :Install
Installed :Use
Used :Uninstall
""";
private final SDKBoyModel model = new SDKBoyModel();
private final CandidatePanel candidatePanel;
private final VersionPanel versionPanel;
private final State help = State.builder()
.consumer(this::onHelp)
.build();
private PreferencesPanel preferencesPanel;
private SDKBoyPanel() {
super(borderLayout());
setDefaultUncaughtExceptionHandler(new SDKBoyExceptionHandler());
versionPanel = new VersionPanel(model, help);
candidatePanel = new CandidatePanel(model, versionPanel.installTask.active);
initializeUI();
setupKeyEvents();
}
@Override
public void updateUI() {
super.updateUI();
Utilities.updateUI(preferencesPanel);
}
private void initializeUI() {
setBorder(emptyBorder());
add(candidatePanel, WEST);
add(versionPanel, CENTER);
}
private void setupKeyEvents() {
KeyEvents.builder()
.condition(WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
.modifiers(ALT_DOWN_MASK)
.keyCode(VK_O)
.action(command(this::displayDescription))
.enable(this)
.keyCode(VK_P)
.action(command(this::displayPreferences))
.enable(this)
.keyCode(VK_R)
.action(command(versionPanel::refreshCandidates))
.enable(this)
.keyCode(VK_X)
.action(command(this::exit))
.enable(this)
.keyCode(VK_INSERT)
.action(versionPanel.install)
.enable(this)
.keyCode(VK_DELETE)
.action(versionPanel.uninstall)
.enable(this)
.keyCode(VK_I)
.action(versionPanel.install)
.enable(this)
.keyCode(VK_D)
.action(versionPanel.uninstall)
.enable(this)
.keyCode(VK_U)
.action(versionPanel.use)
.enable(this)
.keyCode(VK_C)
.action(versionPanel.copyUseCommand)
.enable(this);
}
private void onHelp(boolean visible) {
if (visible) {
add(new HelpPanel(), EAST);
}
else {
BorderLayout layout = (BorderLayout) getLayout();
remove(layout.getLayoutComponent(EAST));
}
revalidate();
repaint();
}
private void displayPreferences() {
if (preferencesPanel == null) {
preferencesPanel = new PreferencesPanel(model.preferencesModel());
}
Dialogs.okCancel()
.component(preferencesPanel)
.owner(this)
.title("Preferences")
.onOk(model.preferencesModel()::save)
.onCancel(model.preferencesModel()::revert)
.show();
}
private void displayDescription() {
candidatePanel.table.model().selection().item().optional()
.ifPresent(candidateRow -> Dialogs.builder()
.component(textArea()
.value(candidateRow.candidate().description())
.rowsColumns(8, 40)
.editable(false)
.lineWrap(true)
.wrapStyleWord(true)
.scrollPane())
.owner(this)
.title(candidateRow.candidate().name() + " - Description")
.show());
}
private void exit() {
if (confirmExit()) {
parentWindow(this).dispose();
}
}
private boolean confirmExit() {
if (versionPanel.installTask.active.is()) {
return false;
}
return !model.preferencesModel().confirmExit().is() || showConfirmDialog(this,
"Are you sure you want to exit?",
"Confirm Exit", YES_NO_OPTION, QUESTION_MESSAGE) == YES_OPTION;
}
private final class SDKBoyExceptionHandler implements Thread.UncaughtExceptionHandler {
@Override
public void uncaughtException(Thread thread, Throwable throwable) {
throwable.printStackTrace();
Dialogs.exception()
.owner(SDKBoyPanel.this)
.show(throwable);
}
}
private static final class CandidatePanel extends JPanel {
private final FilterTable<CandidateRow, CandidateColumn> table;
private final JTextField filter;
private final JCheckBox installedOnly;
private CandidatePanel(SDKBoyModel model, ObservableState installing) {
super(borderLayout());
CandidateModel candidateModel = model.candidateModel();
ObservableState refreshingVersions = model.versionModel()
.tableModel().items().refresher().active();
table = FilterTable.builder()
.model(candidateModel.tableModel())
.columns(this::configureColumns)
.sortable(false)
.focusable(false)
.selectionMode(SINGLE_SELECTION)
.autoResizeMode(AUTO_RESIZE_ALL_COLUMNS)
.enabled(and(installing.not(), refreshingVersions.not()))
.cellRenderer(CandidateColumn.INSTALLED,
FilterTableCellRenderer.builder()
.columnClass(Integer.class)
.horizontalAlignment(SwingConstants.CENTER)
.build())
.build();
Indexes selectedIndexes = candidateModel.tableModel().selection().indexes();
filter = stringField()
.link(candidateModel.filter())
.hint("Filter...")
.lowerCase(true)
.selectAllOnFocusGained(true)
.transferFocusOnEnter(true)
.keyEvent(KeyEvents.builder()
.keyCode(VK_UP)
.action(command(selectedIndexes::decrement)))
.keyEvent(KeyEvents.builder()
.keyCode(VK_DOWN)
.action(command(selectedIndexes::increment)))
.enabled(installing.not())
.build();
installedOnly = checkBox()
.link(candidateModel.installedOnly())
.text("Installed")
.mnemonic('T')
.focusable(false)
.enabled(installing.not())
.build();
candidateModel.tableModel().items().refresh();
setBorder(createCompoundBorder(createTitledBorder("Candidates"), emptyBorder()));
add(scrollPane()
.view(table)
.preferredWidth(220)
.build(), CENTER);
add(borderLayoutPanel()
.center(filter)
.east(installedOnly)
.build(), SOUTH);
}
private void configureColumns(FilterTableColumn.Builder<CandidateColumn> column) {
if (column.identifier() == CandidateColumn.INSTALLED) {
column.fixedWidth(80);
}
}
}
private static final class VersionPanel extends JPanel {
private static final String JAVA = "Java";
private final SDKBoyModel model;
private final CandidateModel candidateModel;
private final VersionModel versionModel;
private final InstallTask installTask;
private final FilterTable<VersionRow, VersionColumn> table;
private final Value<String> selectedVersionName = Value.nullable();
private final JTextField filter;
private final JCheckBox installedOnly;
private final JCheckBox downloadedOnly;
private final JCheckBox usedOnly;
private final JProgressBar refreshProgress;
private final JProgressBar installProgress;
private final JButton cancelDownload;
private final JPanel installingPanel;
private final JPanel southPanel;
private final Control install;
private final Control uninstall;
private final Control use;
private final Control copyUseCommand;
private final JButton helpButton;
private VersionPanel(SDKBoyModel model, State help) {
super(borderLayout());
this.model = model;
this.candidateModel = model.candidateModel();
this.versionModel = model.versionModel();
this.installTask = new InstallTask();
this.install = Control.builder()
.command(this::install)
.enabled(and(
versionModel.tableModel().selection().empty().not(),
versionModel.selectedInstalled().not()))
.build();
this.uninstall = Control.builder()
.command(this::uninstall)
.enabled(and(
versionModel.tableModel().selection().empty().not(),
versionModel.selectedInstalled()))
.build();
this.use = Control.builder()
.command(this::use)
.enabled(versionModel.selectedUsed().not())
.build();
this.copyUseCommand = Control.builder()
.command(this::copyUseCommand)
.build();
candidateModel.tableModel().selection().item().addConsumer(this::onCandidateSelected);
versionModel.tableModel().items().refresher().active().addConsumer(this::onRefreshing);
versionModel.tableModel().selection().item().addConsumer(this::onVersionSelected);
installTask.active.addConsumer(this::onInstalling);
installTask.downloading.addConsumer(this::onDownloading);
table = FilterTable.builder()
.model(versionModel.tableModel())
.columns(this::configureColumns)
.sortable(false)
.focusable(false)
.selectionMode(SINGLE_SELECTION)
.autoResizeMode(AUTO_RESIZE_ALL_COLUMNS)
.columnReordering(false)
.doubleClick(command(this::onVersionDoubleClick))
.enabled(installTask.active.not())
.build();
table.columnModel().visible(VersionColumn.VENDOR).set(false);
Indexes selectedIndexes = versionModel.tableModel().selection().indexes();
filter = stringField()
.link(versionModel.filter())
.hint("Filter...")
.lowerCase(true)
.selectAllOnFocusGained(true)
.transferFocusOnEnter(true)
.keyEvent(KeyEvents.builder()
.keyCode(VK_UP)
.action(command(selectedIndexes::decrement)))
.keyEvent(KeyEvents.builder()
.keyCode(VK_DOWN)
.action(command(selectedIndexes::increment)))
.enabled(installTask.active.not())
.build();
installedOnly = checkBox()
.link(versionModel.installedOnly())
.text("Installed")
.mnemonic('N')
.focusable(false)
.enabled(installTask.active.not())
.build();
downloadedOnly = checkBox()
.link(versionModel.downloadedOnly())
.text("Downloaded")
.mnemonic('A')
.focusable(false)
.enabled(installTask.active.not())
.build();
usedOnly = checkBox()
.link(versionModel.usedOnly())
.text("Used")
.mnemonic('E')
.focusable(false)
.enabled(installTask.active.not())
.build();
cancelDownload = button()
.control(Control.builder()
.command(installTask::cancel)
.caption("Cancel")
.enabled(installTask.downloading))
.keyEvent(KeyEvents.builder()
.keyCode(VK_ESCAPE)
.action(command(installTask::cancel)))
.build();
refreshProgress = progressBar()
.string("Refreshing...")
.stringPainted(true)
.build();
installProgress = progressBar()
.stringPainted(true)
.build();
installingPanel = borderLayoutPanel()
.center(installProgress)
.east(cancelDownload)
.build();
helpButton = button()
.control(Control.builder()
.command(help::toggle)
.caption("?")
.mnemonic('S'))
.focusable(false)
.build();
southPanel = borderLayoutPanel()
.center(filter)
.east(flexibleGridLayoutPanel(1, 0)
.add(installedOnly)
.add(downloadedOnly)
.add(usedOnly)
.add(helpButton))
.build();
setBorder(createCompoundBorder(createTitledBorder("Versions"), emptyBorder()));
add(scrollPane()
.view(table)
.build(), CENTER);
add(southPanel, SOUTH);
}
@Override
public void updateUI() {
super.updateUI();
Utilities.updateUI(southPanel, refreshProgress, installingPanel, installProgress, cancelDownload);
}
private void onVersionDoubleClick() {
if (versionModel.selectedUsed().is()) {
uninstall();
}
else if (versionModel.selectedInstalled().is()) {
use();
}
else {
install();
}
}
private void install() {
install(() -> {});
}
private void install(Runnable onInstalled) {
if (confirmInstall()) {
ProgressWorker.builder()
.task(installTask)
.onStarted(installTask::started)
.onProgress(installTask::progress)
.onPublish(installTask::publish)
.onDone(installTask::done)
.onResult(() -> installTask.result(onInstalled))
.execute();
}
}
private void uninstall() {
if (confirmUninstall()) {
ProgressWorker.builder()
.task(versionModel::uninstall)
.onResult(model::refresh)
.execute();
}
}
private void use() {
VersionRow selected = versionModel.selected();
if (selected.version().installed()) {
useInstalled();
}
else {
install(this::useInstalled);
}
}
private void useInstalled() {
if (confirmUse()) {
ProgressWorker.builder()
.task(versionModel::use)
.onResult(versionModel::refresh)
.execute();
}
}
private void copyUseCommand() {
VersionRow selected = versionModel.selected();
if (!selected.version().installed()) {
install(() -> copyUseCommand(selected));
}
else {
copyUseCommand(selected);
}
}
private void copyUseCommand(VersionRow versionRow) {
String command = "sdk use " + versionRow.candidate().id() + " " + versionRow.version().identifier();
setClipboard(command);
showMessageDialog(this, command + "\n\ncopied to clipboard", "Copied", INFORMATION_MESSAGE);
}
private boolean confirmInstall() {
return !model.preferencesModel().confirmActions().is() || showConfirmDialog(this,
"Install " + versionName() + "?",
"Confirm install", YES_NO_OPTION) == YES_OPTION;
}
private boolean confirmUninstall() {
return !model.preferencesModel().confirmActions().is() || showConfirmDialog(this,
"Uninstall " + versionName() + "?",
"Confirm uninstall", YES_NO_OPTION) == YES_OPTION;
}
private boolean confirmUse() {
return !model.preferencesModel().confirmActions().is() || showConfirmDialog(this,
"Set " + versionName() + " as your global SDK?",
"Confirm use", YES_NO_OPTION) == YES_OPTION;
}
private String versionName() {
return selectedVersionName.get();
}
private void onCandidateSelected(CandidateRow candidateRow) {
table.columnModel().visible(VersionColumn.VENDOR)
.set(candidateRow != null && JAVA.equals(candidateRow.candidate().name()));
}
private void onRefreshing(boolean refreshing) {
toggleSouthPanel(refreshProgress, refreshing);
}
private void onVersionSelected(VersionRow versionRow) {
selectedVersionName.set(versionRow == null ? null :
versionRow.candidate().name() + " " + versionRow.version().identifier());
}
private void onInstalling(boolean installing) {
toggleSouthPanel(installingPanel, installing);
}
private void onDownloading(boolean downloading) {
installProgress.setIndeterminate(!downloading);
if (downloading) {
cancelDownload.requestFocusInWindow();
}
}
private void refreshCandidates() {
candidateModel.tableModel().items().refresh();
}
private void toggleSouthPanel(JComponent component, boolean embed) {
if (embed) {
southPanel.add(component, NORTH);
}
else {
southPanel.remove(component);
}
revalidate();
repaint();
}
private void configureColumns(FilterTableColumn.Builder<VersionColumn> column) {
switch (column.identifier()) {
case INSTALLED -> column.fixedWidth(80);
case DOWNLOADED -> column.fixedWidth(90);
case USED -> column.fixedWidth(60);
}
}
private final class InstallTask implements ProgressTask<String> {
private final State active = State.state();
private final State downloading = State.state();
private final Event<?> cancel = Event.event();
@Override
public void execute(ProgressReporter<String> progress) {
versionModel.install(progress, downloading, cancel);
}
private void cancel() {
cancel.run();
}
private void started() {
installProgress.setString("Procrastinating");
active.set(true);
}
private void progress(int progress) {
installProgress.getModel().setValue(progress);
}
private void publish(List<String> strings) {
installProgress.setString(strings.getFirst() + " " + versionName());
}
private void done() {
installProgress.setString("");
installProgress.getModel().setValue(0);
filter.requestFocusInWindow();
downloading.set(false);
active.set(false);
}
private void result(Runnable onInstalled) {
model.refresh();
onInstalled.run();
}
}
}
private static final class PreferencesPanel extends JPanel {
private final PreferencesModel preferences;
private final LookAndFeelComboBox lookAndFeelComboBox;
private final ComponentValue<JTextField, String> zipExecutable;
private final ComponentValue<JTextField, String> unzipExecutable;
private final ComponentValue<JTextField, String> tarExecutable;
private final ComponentValue<JCheckBox, Boolean> keepDownloadsAvailable;
private final ComponentValue<JCheckBox, Boolean> confirmActions;
private final ComponentValue<JCheckBox, Boolean> confirmExit;
private final ComponentValue<JComboBox<Level>, Level> logLevel;
private final JButton browseZipExecutableButton;
private final JButton browseUnzipExecutableButton;
private final JButton browseTarExecutableButton;
private final JButton logFileButton;
private final JButton logDirectoryButton;
private PreferencesPanel(PreferencesModel preferences) {
super(borderLayout());
this.preferences = preferences;
lookAndFeelComboBox = LookAndFeelComboBox.builder()
.onSelection(preferences::setLookAndFeelPreference)
.build();
zipExecutable = stringField()
.link(preferences.zipExecutable())
.columns(20)
.selectAllOnFocusGained(true)
.buildValue();
unzipExecutable = stringField()
.link(preferences.unzipExecutable())
.columns(20)
.selectAllOnFocusGained(true)
.buildValue();
tarExecutable = stringField()
.link(preferences.tarExecutable())
.columns(20)
.selectAllOnFocusGained(true)
.buildValue();
Icon directoryIcon = getIcon("FileView.directoryIcon");
browseZipExecutableButton = button()
.control(Control.builder()
.command(() -> browseExecutable(zipExecutable))
.smallIcon(directoryIcon))
.build();
browseUnzipExecutableButton = button()
.control(Control.builder()
.command(() -> browseExecutable(unzipExecutable))
.smallIcon(directoryIcon))
.build();
browseTarExecutableButton = button()
.control(Control.builder()
.command(() -> browseExecutable(tarExecutable))
.smallIcon(directoryIcon))
.build();
logFileButton = button()
.control(Control.builder()
.command(this::openLogFile)
.smallIcon(getIcon("FileView.fileIcon"))
.mnemonic('F')
.description("Open Log File (Alt-F)"))
.build();
logDirectoryButton = button()
.control(Control.builder()
.command(this::openLogDirectory)
.smallIcon(directoryIcon)
.mnemonic('D')
.description("Open Log Directory (Alt-D)"))
.build();
keepDownloadsAvailable = checkBox()
.link(preferences.keepDownloadsAvailable())
.text("Keep downloads available")
.mnemonic('K')
.buildValue();
confirmActions = checkBox()
.link(preferences.confirmActions())
.text("Confirm install, uninstall and use")
.mnemonic('I')
.buildValue();
confirmExit = checkBox()
.link(preferences.confirmExit())
.text("Confirm exit")
.mnemonic('X')
.buildValue();
logLevel = comboBox()
.model(preferences.logLevels())
.value((Level) preferences.logLevel())
.buildValue();
setBorder(emptyBorder());
add(flexibleGridLayoutPanel(0, 1)
.add(label("Look & Feel")
.displayedMnemonic('L')
.labelFor(lookAndFeelComboBox))
.add(lookAndFeelComboBox)
.add(label("Select zip path")
.displayedMnemonic('Z')
.labelFor(zipExecutable.component()))
.add(borderLayoutPanel()
.layout(new BorderLayout(0, 5))
.center(zipExecutable.component())
.east(browseZipExecutableButton))
.add(label("Select unzip path")
.displayedMnemonic('U')
.labelFor(unzipExecutable.component()))
.add(borderLayoutPanel()
.layout(new BorderLayout(0, 5))
.center(unzipExecutable.component())
.east(browseUnzipExecutableButton))
.add(label("Select tar path")
.displayedMnemonic('T')
.labelFor(tarExecutable.component()))
.add(borderLayoutPanel()
.layout(new BorderLayout(0, 5))
.center(tarExecutable.component())
.east(browseTarExecutableButton))
.add(label("Log level")
.displayedMnemonic('V')
.labelFor(logLevel.component()))
.add(borderLayoutPanel()
.layout(new BorderLayout(0, 5))
.center(logLevel.component())
.east(panel()
.layout(new GridLayout(1, 0, 0, 5))
.add(logFileButton)
.add(logDirectoryButton)))
.add(keepDownloadsAvailable.component())
.add(confirmActions.component())
.add(confirmExit.component())
.build(), CENTER);
}
private void openLogFile() {
preferences.logFile().ifPresent(this::open);
}
private void openLogDirectory() {
preferences.logDirectory().ifPresent(this::open);
}
private void open(File file) {
try {
getDesktop().open(file);
}
catch (IOException e) {
throw new RuntimeException(e);
}
}
private void browseExecutable(Value<String> executable) {
executable.set(Dialogs.select()
.files()
.owner(this)
.title("Select executable")
.selectFile()
.toPath()
.toString());
}
}
private static final class HelpPanel extends JPanel {
private final JTextArea shortcuts = textArea()
.value(SHORTCUTS)
.font(monospaceFont())
.editable(false)
.focusable(false)
.build();
private final AboutPanel aboutPanel = new AboutPanel();
private HelpPanel() {
super(borderLayout());
add(borderLayoutPanel()
.center(borderLayoutPanel()
.border(createTitledBorder("Shortcuts"))
.center(shortcuts))
.south(borderLayoutPanel()
.border(createTitledBorder("About"))
.center(aboutPanel))
.build(), CENTER);
}
@Override
public void updateUI() {
super.updateUI();
Utilities.updateUI(shortcuts, aboutPanel);
}
private static Font monospaceFont() {
Font font = UIManager.getFont("TextArea.font");
return new Font(Font.MONOSPACED, font.getStyle(), font.getSize());
}
private static final class AboutPanel extends JPanel {
private final JEditorPane editorPane = new JEditorPane("text/html", """
<html><table>
<tr><td>Copyright:</td><td>Björn Darri</td></tr>
<tr><td>License:</td><td><a href="https://www.gnu.org/licenses/gpl-3.0.en.html">GPL</a></td></tr>
<tr><td>Source:</td><td><a href="https://github.com/codion-is/sdkboy">SDKBOY</a></td></tr>
<tr><td></td><td><a href="https://github.com/sdkman/sdkman-cli">SDKMAN</a></td></tr>
</table></html>
""");
private AboutPanel() {
super(borderLayout());
editorPane.setFont(monospaceFont());
editorPane.setEditable(false);
editorPane.setFocusable(false);
editorPane.addHyperlinkListener(new OpenLink());
add(editorPane, CENTER);
}
}
private static final class OpenLink implements HyperlinkListener {
@Override
public void hyperlinkUpdate(HyperlinkEvent event) {
if (ACTIVATED.equals(event.getEventType())) {
try {
getDesktop().browse(event.getURL().toURI());
}
catch (Exception e) {
throw new RuntimeException(e);
}
}
}
}
}
public static void main(String[] args) {
setDefaultUncaughtExceptionHandler((_, throwable) -> {
throwable.printStackTrace();
Dialogs.exception().show(throwable);
});
findLookAndFeel(getLookAndFeelPreference())
.ifPresent(LookAndFeelEnabler::enable);
SDKBoyPanel sdkBoyPanel = new SDKBoyPanel();
Frames.builder()
.component(sdkBoyPanel)
.title("SDKBOY " + SDKBoyModel.VERSION)
.icon(Logos.logoTransparent())
.centerFrame(true)
.defaultCloseOperation(DO_NOTHING_ON_CLOSE)
.onClosing(_ -> sdkBoyPanel.exit())
.show();
}
}
Module Info
/**
* SDKBOY.
*/
module is.codion.sdkboy {
requires is.codion.swing.common.ui;
requires is.codion.plugin.flatlaf;
requires is.codion.plugin.flatlaf.intellij.themes;
requires ch.qos.logback.classic;
requires java.naming;
requires sdkmanapi;
}
Build
Run the application.
gradlew run
Build a jlink image to build/sdkboy
gradlew jlink
Build a zipped jlink image to build/sdkboy.zip
gradlew jlinkZip
Build the default native installer(s) to build/jpackage
gradlew jpackage
settings.gradle
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.9.0"
}
rootProject.name = "sdkboy"
dependencyResolutionManagement {
repositories {
mavenCentral()
mavenLocal()
}
}
build.gradle.kts
import org.gradle.internal.os.OperatingSystem
plugins {
// The Badass Jlink Plugin provides jlink and jpackage
// functionality and applies the java application plugin
// https://badass-jlink-plugin.beryx.org
id("org.beryx.jlink") version "3.1.2"
// Just for managing the license headers
id("com.diffplug.spotless") version "7.0.1"
// For the asciidoctor docs
id("org.asciidoctor.jvm.convert") version "4.0.4"
// For GitHub Releases
id("com.github.breadmoirai.github-release") version "2.5.2"
}
dependencies {
// Import the Codion Common BOM for dependency version management
implementation(platform(libs.codion.common.bom))
// The Codion Swing Common UI module
implementation(libs.codion.swing.common.ui)
// Include all the standard Flat Look and Feels
implementation(libs.codion.plugin.flatlaf)
// and a bunch of IntelliJ theme based ones
implementation(libs.codion.plugin.flatlaf.intellij.themes)
// The Codion logback plugin so we can configure
// the log level and open the log file/dir
implementation(libs.codion.plugin.logback)
// logback implementation for the log level
implementation(libs.logback)
// SdkManApi + dependencies
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("sdkman-api-0.3.2-SNAPSHOT.jar"))))
implementation(libs.commons.compress)
implementation(libs.jna.platform)
}
version = "1.0.6"
java {
toolchain {
// Use the latest possible Java version
languageVersion.set(JavaLanguageVersion.of(24))
}
}
spotless {
// Just the license headers
java {
licenseHeaderFile("${rootDir}/license_header").yearSeparator(" - ")
}
format("javaMisc") {
target("src/**/package-info.java", "src/**/module-info.java")
licenseHeaderFile("${rootDir}/license_header", "\\/\\*\\*").yearSeparator(" - ")
}
}
// Configure the application plugin, the jlink plugin relies
// on this configuration when building the runtime image
application {
mainModule = "is.codion.sdkboy"
mainClass = "is.codion.sdkboy.ui.SDKBoyPanel"
applicationDefaultJvmArgs = listOf(
// This app doesn't require a lot of memory
"-Xmx32m"
)
}
tasks.withType<JavaCompile>().configureEach {
options.encoding = "UTF-8"
options.isDeprecation = true
}
// Configure the docs generation
tasks.asciidoctor {
dependsOn(tasks.build)
inputs.files(sourceSets.main.get().allSource)
inputs.file(project.buildFile)
baseDirFollowsSourceFile()
attributes(
mapOf(
"source-highlighter" to "prettify",
"tabsize" to "2"
)
)
asciidoctorj {
setVersion("2.5.13")
}
}
// Create a version.properties file containing the application version
tasks.register<WriteProperties>("writeVersion") {
destinationFile = file("${temporaryDir.absolutePath}/version.properties")
property("version", "${project.version}")
}
// Include the version.properties file from above in the
// application resources, see usage in TemplateAppModel
tasks.processResources {
from(tasks.named("writeVersion"))
}
// Configure the Jlink plugin
jlink {
// Specify the jlink image name
imageName = project.name + "-" + project.version + "-" +
OperatingSystem.current().familyName.replace(" ", "").lowercase()
// The options for the jlink task
options = listOf(
"--strip-debug",
"--no-header-files",
"--no-man-pages",
// Add the logback plugin module
"--add-modules",
"is.codion.plugin.logback.proxy"
)
jpackage {
if (OperatingSystem.current().isLinux) {
icon = "src/main/icons/sdkboy.png"
installerType = "deb"
installerOptions = listOf(
"--linux-shortcut"
)
}
if (OperatingSystem.current().isWindows) {
icon = "src/main/icons/sdkboy.ico"
installerType = "msi"
installerOptions = listOf(
"--win-menu",
"--win-shortcut"
)
}
if (OperatingSystem.current().isMacOsX) {
icon = "src/main/icons/sdkboy.icns"
installerType = "dmg"
}
}
}
if (properties.containsKey("githubAccessToken")) {
githubRelease {
token(properties["githubAccessToken"] as String)
owner = "codion-is"
allowUploadToExisting = true
releaseAssets.from(tasks.named("jlinkZip").get().outputs.files)
releaseAssets.from(fileTree(tasks.named("jpackage").get().outputs.files.singleFile) {
exclude(project.name + "/**", project.name + ".app/**")
})
}
}
tasks.named("githubRelease") {
dependsOn(tasks.named("jlinkZip"))
dependsOn(tasks.named("jpackage"))
}
tasks.register<Sync>("copyToGitHubPages") {
group = "documentation"
description = "Copies the documentation to the Codion github pages repository, nevermind"
from(tasks.asciidoctor)
into(
"../codion-pages/doc/" + libs.versions.codion.get()
.replace("-SNAPSHOT", "") + "/tutorials/sdkboy"
)
}