diff --git a/src/main/java/org/exastudio/opcgui/db/AccountDao.java b/src/main/java/org/exastudio/opcgui/db/AccountDao.java index eff49d9..b8e3eb2 100644 --- a/src/main/java/org/exastudio/opcgui/db/AccountDao.java +++ b/src/main/java/org/exastudio/opcgui/db/AccountDao.java @@ -5,8 +5,8 @@ import org.exastudio.opcgui.types.AccountConfig; import org.exastudio.opcgui.types.StorageProvider; import org.jdbi.v3.core.Jdbi; -import org.jdbi.v3.core.statement.StatementContext; import org.jdbi.v3.core.mapper.RowMapper; +import org.jdbi.v3.core.statement.StatementContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/src/main/java/org/exastudio/opcgui/db/SingleBucketDao.java b/src/main/java/org/exastudio/opcgui/db/SingleBucketDao.java new file mode 100644 index 0000000..a0797b5 --- /dev/null +++ b/src/main/java/org/exastudio/opcgui/db/SingleBucketDao.java @@ -0,0 +1,130 @@ +package org.exastudio.opcgui.db; + +import java.util.List; + +import org.exastudio.opcgui.types.SingleBucketConfig; +import org.exastudio.opcgui.types.StorageProvider; +import org.jdbi.v3.core.Jdbi; +import org.jdbi.v3.core.mapper.RowMapper; +import org.jdbi.v3.core.statement.StatementContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import software.amazon.awssdk.regions.Region; + +public final class SingleBucketDao { + private static final Logger logger = LoggerFactory.getLogger(SingleBucketDao.class); + + private static final String UPSERT_SQL = """ + INSERT INTO single_buckets (id, name, bucket_name, provider, access_key, secret_key, region, r2_endpoint) + VALUES (:id, :name, :bucketName, :provider, :accessKey, :secretKey, :region, :r2Endpoint) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + bucket_name = excluded.bucket_name, + provider = excluded.provider, + access_key = excluded.access_key, + secret_key = excluded.secret_key, + region = excluded.region, + r2_endpoint = excluded.r2_endpoint + """; + + private static final String SELECT_ALL_SQL = """ + SELECT id, name, bucket_name, provider, access_key, secret_key, region, r2_endpoint + FROM single_buckets + ORDER BY name COLLATE NOCASE + """; + + private static final String DELETE_SQL = """ + DELETE FROM single_buckets + WHERE id = :id + """; + + private static final RowMapper MAPPER = (rs, ctx) -> mapSingleBucket(rs, ctx); + + private final Jdbi jdbi; + + public SingleBucketDao(Jdbi jdbi) { + this.jdbi = jdbi; + } + + public void save(SingleBucketConfig config) { + if (config == null) { + return; + } + + jdbi.useHandle(handle -> handle.createUpdate(UPSERT_SQL) + .bind("id", config.id()) + .bind("name", config.name()) + .bind("bucketName", config.bucketName()) + .bind("provider", config.provider().name()) + .bind("accessKey", config.accessKey()) + .bind("secretKey", config.secretKey()) + .bind("region", regionValue(config)) + .bind("r2Endpoint", r2EndpointValue(config)) + .execute()); + } + + public List findAll() { + return jdbi.withHandle(handle -> handle.createQuery(SELECT_ALL_SQL) + .map(MAPPER) + .list()); + } + + public void deleteById(String id) { + if (id == null || id.isBlank()) { + return; + } + jdbi.useHandle(handle -> handle.createUpdate(DELETE_SQL) + .bind("id", id) + .execute()); + } + + private static SingleBucketConfig mapSingleBucket(java.sql.ResultSet rs, StatementContext ctx) + throws java.sql.SQLException { + String id = rs.getString("id"); + String name = rs.getString("name"); + String bucketName = rs.getString("bucket_name"); + String providerValue = rs.getString("provider"); + + StorageProvider provider = providerValue == null + ? StorageProvider.AWS_S3 + : StorageProvider.valueOf(providerValue); + + String accessKey = rs.getString("access_key"); + String secretKey = rs.getString("secret_key"); + String regionValue = rs.getString("region"); + String r2Endpoint = rs.getString("r2_endpoint"); + + Region region = null; + if (provider == StorageProvider.AWS_S3 && regionValue != null && !regionValue.isBlank()) { + try { + region = Region.of(regionValue); + } catch (Exception e) { + logger.warn("Invalid region stored for single bucket {}: {}", id, regionValue); + } + } + if (provider == StorageProvider.AWS_S3) { + r2Endpoint = null; + } else if (provider == StorageProvider.CLOUDFLARE_R2) { + region = null; + } + + return new SingleBucketConfig(id, name, bucketName, provider, accessKey, secretKey, region, r2Endpoint); + } + + private static String regionValue(SingleBucketConfig config) { + if (config.provider() != StorageProvider.AWS_S3) { + return null; + } + Region region = config.region(); + return region == null ? null : region.id(); + } + + private static String r2EndpointValue(SingleBucketConfig config) { + if (config.provider() != StorageProvider.CLOUDFLARE_R2) { + return null; + } + String value = config.r2Endpoint(); + return value == null ? null : value.trim(); + } +} diff --git a/src/main/java/org/exastudio/opcgui/types/SingleBucketConfig.java b/src/main/java/org/exastudio/opcgui/types/SingleBucketConfig.java new file mode 100644 index 0000000..05d49f1 --- /dev/null +++ b/src/main/java/org/exastudio/opcgui/types/SingleBucketConfig.java @@ -0,0 +1,9 @@ +package org.exastudio.opcgui.types; + +import software.amazon.awssdk.regions.Region; + +public record SingleBucketConfig(String id, String name, String bucketName, + StorageProvider provider, + String accessKey, String secretKey, + Region region, String r2Endpoint) { +} diff --git a/src/main/java/org/exastudio/opcgui/ui/MainWindow.java b/src/main/java/org/exastudio/opcgui/ui/MainWindow.java index 9d64a78..45371cb 100644 --- a/src/main/java/org/exastudio/opcgui/ui/MainWindow.java +++ b/src/main/java/org/exastudio/opcgui/ui/MainWindow.java @@ -26,11 +26,13 @@ import org.exastudio.opcgui.app.AccountSession; import org.exastudio.opcgui.db.AccountDao; import org.exastudio.opcgui.db.Database; +import org.exastudio.opcgui.db.SingleBucketDao; import org.exastudio.opcgui.types.AccountConfig; import org.exastudio.opcgui.types.BucketAnalytics; import org.exastudio.opcgui.types.BucketInfo; import org.exastudio.opcgui.types.BucketRef; import org.exastudio.opcgui.types.ObjectEntry; +import org.exastudio.opcgui.types.SingleBucketConfig; import org.exastudio.opcgui.types.StorageProvider; import org.exastudio.opcgui.infrastructure.storage.S3Manager; import org.exastudio.opcgui.ui.components.ResizableSplitPane; @@ -57,8 +59,10 @@ public class MainWindow extends JFrame { private final ExecutorService ioExecutor = Executors.newFixedThreadPool(4); private final Map sessions = new LinkedHashMap<>(); private final Map accountConfigs = new LinkedHashMap<>(); + private final Map singleBucketConfigs = new LinkedHashMap<>(); private final Map analyticsCache = new LinkedHashMap<>(); private final AccountDao accountDao = new AccountDao(Database.jdbi()); + private final SingleBucketDao singleBucketDao = new SingleBucketDao(Database.jdbi()); private final Set connectingAccounts = ConcurrentHashMap.newKeySet(); private final SidebarTreePanel sidebar; @@ -74,7 +78,7 @@ public MainWindow() { ensureLogDirectory(); - sidebar = new SidebarTreePanel(this::openAddAccountDialog); + sidebar = new SidebarTreePanel(this::openAddConnectionDialog); sidebar.setBucketSelectedListener(this::handleBucketSelection); sidebar.setAccountSelectedListener(this::handleAccountSelection); sidebar.setAccountActions(this::connectAccountById, this::disconnectAccountById, @@ -136,24 +140,51 @@ private void configureMacWindow() { } // TODO: move all apis into another module - private void openAddAccountDialog() { - AccountDialog dialog = new AccountDialog(this, this::connectAccount); + private void openAddConnectionDialog() { + AccountDialog dialog = new AccountDialog(this, this::connectFromDialog); dialog.setVisible(true); } - private void connectAccount(AccountConfig config, AccountDialog dialog) { - String validationError = validateAccount(config); + private void connectFromDialog(AccountDialog.ConnectionInput input, AccountDialog dialog) { + String validationError = validateConnectionInput(input); if (validationError != null) { dialog.setStatus(validationError); return; } + dialog.setBusy(true); dialog.setStatus("Connecting..."); ioExecutor.submit(() -> { S3Manager manager = null; try { - AccountConfig normalized = normalizeAccount(config); - manager = createManager(normalized); + if (input.isSingleBucket()) { + SingleBucketConfig normalized = normalizeSingleBucket(input); + manager = createManager(normalized.provider(), normalized.accessKey(), normalized.secretKey(), + normalized.region(), normalized.r2Endpoint()); + manager.listEntries(normalized.bucketName(), ""); + try { + singleBucketDao.save(normalized); + } catch (Exception e) { + logger.warn("Failed to persist single bucket", e); + } + AccountConfig sessionConfig = toSessionAccountConfig(normalized); + AccountSession session = new AccountSession(sessionConfig, manager); + SwingUtilities.invokeLater(() -> { + singleBucketConfigs.put(normalized.id(), normalized); + accountConfigs.remove(normalized.id()); + sessions.put(normalized.id(), session); + sidebar.addSingleBucket(normalized); + sidebar.selectAccount(normalized.id()); + statusPanel.setStatus("Connected: " + normalized.name() + + " (single bucket: " + normalized.bucketName() + ")"); + dialog.dispose(); + }); + return; + } + + AccountConfig normalized = normalizeAccount(input); + manager = createManager(normalized.provider(), normalized.accessKey(), normalized.secretKey(), + normalized.region(), normalized.r2Endpoint()); List buckets = manager.listBuckets(); try { accountDao.save(normalized); @@ -163,6 +194,7 @@ private void connectAccount(AccountConfig config, AccountDialog dialog) { AccountSession session = new AccountSession(normalized, manager); SwingUtilities.invokeLater(() -> { accountConfigs.put(normalized.id(), normalized); + singleBucketConfigs.remove(normalized.id()); sessions.put(normalized.id(), session); sidebar.addAccount(normalized, buckets); sidebar.selectAccount(normalized.id()); @@ -173,7 +205,7 @@ private void connectAccount(AccountConfig config, AccountDialog dialog) { dialog.dispose(); }); } catch (Exception e) { - logger.warn("Failed to connect account", e); + logger.warn("Failed to connect from dialog", e); S3Manager failed = manager; if (failed != null) { failed.close(); @@ -205,7 +237,16 @@ private void loadSavedAccounts() { accountConfigs.put(config.id(), config); sidebar.addAccount(config, List.of()); } - statusPanel.setStatus("Accounts loaded. Double-click to connect."); + try { + List singleBuckets = singleBucketDao.findAll(); + for (SingleBucketConfig singleBucket : singleBuckets) { + singleBucketConfigs.put(singleBucket.id(), singleBucket); + sidebar.addSingleBucket(singleBucket); + } + } catch (Exception e) { + logger.warn("Failed to load saved single buckets", e); + } + statusPanel.setStatus("Connections loaded. Double-click to connect."); }); }); } @@ -222,21 +263,10 @@ private void connectAccountById(String accountId) { sidebar.repaintAccountRow(accountId); }); - AccountConfig config = accountConfigs.get(accountId); - if (config == null) { - statusPanel.setStatus("Account not found."); - statusPanel.setStatusBusy(false); - connectingAccounts.remove(accountId); - SwingUtilities.invokeLater(() -> { - sidebar.repaintAccountRow(accountId); - sidebar.setSpinnerActive(!connectingAccounts.isEmpty()); - }); - return; - } - - String validationError = validateAccount(config); - if (validationError != null) { - statusPanel.setStatus("Cannot connect: " + validationError); + AccountConfig accountConfig = accountConfigs.get(accountId); + SingleBucketConfig singleBucketConfig = singleBucketConfigs.get(accountId); + if (accountConfig == null && singleBucketConfig == null) { + statusPanel.setStatus("Connection not found."); statusPanel.setStatusBusy(false); connectingAccounts.remove(accountId); SwingUtilities.invokeLater(() -> { @@ -246,30 +276,51 @@ private void connectAccountById(String accountId) { return; } - statusPanel.setStatus("Connecting to " + config.name() + "..."); + String label = accountConfig != null ? accountConfig.name() : singleBucketConfig.name(); + statusPanel.setStatus("Connecting to " + label + "..."); statusPanel.setStatusBusy(true); + ioExecutor.submit(() -> { S3Manager manager = null; try { - manager = createManager(config); - List buckets = manager.listBuckets(); - AccountSession session = new AccountSession(config, manager); + List buckets; + AccountSession session; + if (singleBucketConfig != null) { + manager = createManager(singleBucketConfig.provider(), singleBucketConfig.accessKey(), + singleBucketConfig.secretKey(), singleBucketConfig.region(), singleBucketConfig.r2Endpoint()); + manager.listEntries(singleBucketConfig.bucketName(), ""); + buckets = List.of(new BucketInfo(singleBucketConfig.bucketName(), null)); + session = new AccountSession(toSessionAccountConfig(singleBucketConfig), manager); + } else { + manager = createManager(accountConfig.provider(), accountConfig.accessKey(), + accountConfig.secretKey(), accountConfig.region(), accountConfig.r2Endpoint()); + buckets = manager.listBuckets(); + session = new AccountSession(accountConfig, manager); + } + AccountConfig finalAccountConfig = accountConfig; + SingleBucketConfig finalSingleBucketConfig = singleBucketConfig; SwingUtilities.invokeLater(() -> { - sessions.put(config.id(), session); - sidebar.updateBuckets(config.id(), buckets); - sidebar.selectAccount(config.id()); + sessions.put(accountId, session); + if (finalSingleBucketConfig != null) { + sidebar.updateSingleBucket(finalSingleBucketConfig); + statusPanel.setStatus("Connected: " + finalSingleBucketConfig.name() + + " (single bucket: " + finalSingleBucketConfig.bucketName() + ")"); + } else if (finalAccountConfig != null) { + sidebar.updateBuckets(finalAccountConfig, buckets); + String bucketStatus = buckets.isEmpty() + ? " (no buckets visible)" + : " (" + buckets.size() + " buckets)"; + statusPanel.setStatus("Connected: " + finalAccountConfig.name() + bucketStatus); + } + sidebar.selectAccount(accountId); connectingAccounts.remove(accountId); sidebar.repaintAccountRow(accountId); sidebar.setSpinnerActive(!connectingAccounts.isEmpty()); - String bucketStatus = buckets.isEmpty() - ? " (no buckets visible)" - : " (" + buckets.size() + " buckets)"; - statusPanel.setStatus("Connected: " + config.name() + bucketStatus); statusPanel.setStatusBusy(false); }); } catch (Exception e) { - logger.warn("Failed to connect account {}", config.name(), e); + logger.warn("Failed to connect entry {}", label, e); if (manager != null) { manager.close(); } @@ -278,10 +329,10 @@ private void connectAccountById(String accountId) { connectingAccounts.remove(accountId); sidebar.repaintAccountRow(accountId); sidebar.setSpinnerActive(!connectingAccounts.isEmpty()); - statusPanel.setStatus("Connection failed: " + config.name()); + statusPanel.setStatus("Connection failed: " + label); statusPanel.setStatusBusy(false); JOptionPane.showMessageDialog(this, - "Failed to connect account '" + config.name() + "':\n" + e.getMessage(), + "Failed to connect entry '" + label + "':\n" + e.getMessage(), "Connection error", JOptionPane.ERROR_MESSAGE); }); @@ -301,7 +352,13 @@ private void disconnectAccountById(String accountId) { session.close(); analyticsCache.keySet().removeIf(ref -> accountId.equals(ref.accountId())); - sidebar.updateBuckets(accountId, List.of()); + AccountConfig config = accountConfigs.get(accountId); + SingleBucketConfig singleBucketConfig = singleBucketConfigs.get(accountId); + if (config != null) { + sidebar.updateBuckets(config, List.of()); + } else if (singleBucketConfig != null) { + sidebar.updateSingleBucket(singleBucketConfig); + } if (currentBucket != null && accountId.equals(currentBucket.accountId())) { currentBucket = null; @@ -318,8 +375,8 @@ private void disconnectAccountById(String accountId) { sidebar.clearSelection(); } - AccountConfig config = accountConfigs.get(accountId); - String label = config == null ? "Disconnected." : "Disconnected: " + config.name(); + String baseName = config != null ? config.name() : (singleBucketConfig != null ? singleBucketConfig.name() : null); + String label = baseName == null ? "Disconnected." : "Disconnected: " + baseName; statusPanel.setStatus(label); statusPanel.setStatusBusy(false); } @@ -329,29 +386,34 @@ private void deleteAccountById(String accountId) { return; } if (isAccountConnecting(accountId)) { - statusPanel.setStatus("Account is connecting. Try again in a moment."); + statusPanel.setStatus("Connection is in progress. Try again in a moment."); return; } AccountConfig config = accountConfigs.get(accountId); - String name = config == null ? "this account" : config.name(); + SingleBucketConfig singleBucketConfig = singleBucketConfigs.get(accountId); + String name = config != null ? config.name() : (singleBucketConfig != null ? singleBucketConfig.name() : "this entry"); boolean confirmed = DeleteAccountDialog.confirm(this, name); if (!confirmed) { return; } - statusPanel.setStatus("Deleting account..."); + statusPanel.setStatus("Deleting entry..."); statusPanel.setStatusBusy(true); ioExecutor.submit(() -> { try { - accountDao.deleteById(accountId); + if (singleBucketConfig != null) { + singleBucketDao.deleteById(accountId); + } else { + accountDao.deleteById(accountId); + } } catch (Exception e) { - logger.warn("Failed to delete account {}", accountId, e); + logger.warn("Failed to delete entry {}", accountId, e); SwingUtilities.invokeLater(() -> { statusPanel.setStatusBusy(false); JOptionPane.showMessageDialog(this, - "Failed to delete account:\n" + e.getMessage(), + "Failed to delete entry:\n" + e.getMessage(), "Delete error", JOptionPane.ERROR_MESSAGE); }); @@ -359,15 +421,15 @@ private void deleteAccountById(String accountId) { } SwingUtilities.invokeLater(() -> { - // If connected, close session and clear any current selection first. if (sessions.containsKey(accountId)) { disconnectAccountById(accountId); } connectingAccounts.remove(accountId); accountConfigs.remove(accountId); + singleBucketConfigs.remove(accountId); sidebar.removeAccount(accountId); sidebar.setSpinnerActive(!connectingAccounts.isEmpty()); - statusPanel.setStatus("Account deleted: " + name); + statusPanel.setStatus("Entry deleted: " + name); statusPanel.setStatusBusy(false); }); }); @@ -405,7 +467,10 @@ private void createBucketForAccount(String accountId) { session.manager().createBucket(bucketName); List buckets = session.manager().listBuckets(); SwingUtilities.invokeLater(() -> { - sidebar.updateBuckets(accountId, buckets); + AccountConfig configBucket = accountConfigs.get(accountId); + if (configBucket != null) { + sidebar.updateBuckets(configBucket, buckets); + } statusPanel.setStatus("Bucket created: " + bucketName); statusPanel.setStatusBusy(false); }); @@ -453,7 +518,10 @@ private void deleteBucketByRef(BucketRef bucketRef) { session.manager().deleteBucket(bucketRef.bucketName()); List buckets = session.manager().listBuckets(); SwingUtilities.invokeLater(() -> { - sidebar.updateBuckets(bucketRef.accountId(), buckets); + AccountConfig config = accountConfigs.get(bucketRef.accountId()); + if (config != null) { + sidebar.updateBuckets(config, buckets); + } analyticsCache.remove(bucketRef); if (bucketRef.equals(currentBucket)) { currentBucket = null; @@ -546,16 +614,27 @@ private void handleAccountSelection(String accountId) { } AccountConfig config = accountConfigs.get(accountId); - String accountName = config == null ? "this account" : config.name(); + SingleBucketConfig singleBucketConfig = singleBucketConfigs.get(accountId); + String accountName = config != null ? config.name() + : (singleBucketConfig != null ? singleBucketConfig.name() : "this entry"); + AccountSession session = sessions.get(accountId); if (session == null) { objectBrowser.clear(); objectBrowser.setControlsEnabled(false); - statusPanel.setStatus("Connect '" + accountName + "' to browse its buckets."); + String connectLabel = singleBucketConfig != null + ? "Connect '" + accountName + "' to browse its bucket." + : "Connect '" + accountName + "' to browse its buckets."; + statusPanel.setStatus(connectLabel); statusPanel.setStatusBusy(false); return; } + if (singleBucketConfig != null) { + handleBucketSelection(new BucketRef(accountId, singleBucketConfig.bucketName())); + return; + } + objectBrowser.setBusy(true); statusPanel.setStatus("Loading buckets..."); statusPanel.setStatusBusy(true); @@ -1012,41 +1091,65 @@ private Path resolvePreviewTempBaseDir() { return Path.of(tempDir); } - private AccountConfig normalizeAccount(AccountConfig config) { - String name = config.name(); + private AccountConfig normalizeAccount(AccountDialog.ConnectionInput input) { + String name = input.name(); if (name == null || name.isBlank()) { - name = config.provider().toString(); + name = input.provider().toString(); } - return new AccountConfig(UUID.randomUUID().toString(), name, - config.provider(), config.accessKey(), config.secretKey(), - config.region(), config.r2Endpoint()); + return new AccountConfig(UUID.randomUUID().toString(), name.trim(), + input.provider(), input.accessKey(), input.secretKey(), + input.region(), input.r2Endpoint()); } - private S3Manager createManager(AccountConfig config) { - if (config.provider() == StorageProvider.AWS_S3) { - return S3Manager.forAws(config.accessKey(), config.secretKey(), config.region()); + private SingleBucketConfig normalizeSingleBucket(AccountDialog.ConnectionInput input) { + String bucketName = input.bucketName() == null ? "" : input.bucketName().trim(); + String name = input.name(); + if (name == null || name.isBlank()) { + name = bucketName; } - return S3Manager.forR2(config.accessKey(), config.secretKey(), config.r2Endpoint()); + return new SingleBucketConfig(UUID.randomUUID().toString(), name.trim(), bucketName, + input.provider(), input.accessKey(), input.secretKey(), input.region(), input.r2Endpoint()); + } + + private AccountConfig toSessionAccountConfig(SingleBucketConfig config) { + return new AccountConfig(config.id(), config.name(), config.provider(), + config.accessKey(), config.secretKey(), config.region(), config.r2Endpoint()); } - private String validateAccount(AccountConfig config) { - if (config.accessKey() == null || config.accessKey().isBlank()) { + private S3Manager createManager(StorageProvider provider, String accessKey, String secretKey, + Region region, String r2Endpoint) { + if (provider == StorageProvider.AWS_S3) { + return S3Manager.forAws(accessKey, secretKey, region); + } + + return S3Manager.forR2(accessKey, secretKey, r2Endpoint); + } + + private String validateConnectionInput(AccountDialog.ConnectionInput input) { + if (input == null) { + return "Invalid input."; + } + if (input.accessKey() == null || input.accessKey().isBlank()) { return "Access key is required."; } - if (config.secretKey() == null || config.secretKey().isBlank()) { + if (input.secretKey() == null || input.secretKey().isBlank()) { return "Secret key is required."; } - if (config.provider() == StorageProvider.AWS_S3) { - Region region = config.region(); + if (input.isSingleBucket() && (input.bucketName() == null || input.bucketName().isBlank())) { + return "Bucket name is required."; + } + + if (input.provider() == StorageProvider.AWS_S3) { + Region region = input.region(); if (region == null) { return "Select an AWS region."; } } else { - if (config.r2Endpoint() == null || config.r2Endpoint().isBlank()) { + if (input.r2Endpoint() == null || input.r2Endpoint().isBlank()) { return "R2 account ID or endpoint is required."; } } diff --git a/src/main/java/org/exastudio/opcgui/ui/components/menu/AccountContextMenu.java b/src/main/java/org/exastudio/opcgui/ui/components/menu/AccountContextMenu.java index ed31237..a45bdea 100644 --- a/src/main/java/org/exastudio/opcgui/ui/components/menu/AccountContextMenu.java +++ b/src/main/java/org/exastudio/opcgui/ui/components/menu/AccountContextMenu.java @@ -79,7 +79,7 @@ public void updateUI() { } public void configure(String accountId, boolean connected, - boolean connecting, + boolean connecting, boolean allowCreateBucket, Consumer onConnect, Consumer onDisconnect, Consumer onCreateBucket, @@ -93,7 +93,7 @@ public void configure(String accountId, boolean connected, // While connecting, disable both actions to avoid duplicate work / races. connectItem.setEnabled(!connected && !connecting); disconnectItem.setEnabled(connected && !connecting); - createBucketItem.setEnabled(connected && !connecting); + createBucketItem.setEnabled(allowCreateBucket && connected && !connecting); deleteItem.setEnabled(!connecting); } diff --git a/src/main/java/org/exastudio/opcgui/ui/components/menu/ObjectContextMenu.java b/src/main/java/org/exastudio/opcgui/ui/components/menu/ObjectContextMenu.java index e101f5c..742deed 100644 --- a/src/main/java/org/exastudio/opcgui/ui/components/menu/ObjectContextMenu.java +++ b/src/main/java/org/exastudio/opcgui/ui/components/menu/ObjectContextMenu.java @@ -13,7 +13,7 @@ import org.exastudio.opcgui.ui.theme.IconManager; public class ObjectContextMenu extends OPBContextMenu { - private final JMenuItem addAccountItem = new JMenuItem("Add Account"); + private final JMenuItem addAccountItem = new JMenuItem("Add Connection"); private Runnable onAddAccount; public ObjectContextMenu() { diff --git a/src/main/java/org/exastudio/opcgui/ui/dialogs/AccountDialog.java b/src/main/java/org/exastudio/opcgui/ui/dialogs/AccountDialog.java index 2600658..35db834 100644 --- a/src/main/java/org/exastudio/opcgui/ui/dialogs/AccountDialog.java +++ b/src/main/java/org/exastudio/opcgui/ui/dialogs/AccountDialog.java @@ -17,7 +17,6 @@ import javax.swing.JPasswordField; import javax.swing.JTextField; -import org.exastudio.opcgui.types.AccountConfig; import org.exastudio.opcgui.types.StorageProvider; import org.exastudio.opcgui.ui.config.UiConfig; @@ -26,13 +25,40 @@ public class AccountDialog extends JDialog { private static final int DIALOG_PADDING = 12; + public enum EntryType { + ACCOUNT("Account"), + SINGLE_BUCKET("Single Bucket"); + + private final String label; + + EntryType(String label) { + this.label = label; + } + + @Override + public String toString() { + return label; + } + } + + public record ConnectionInput(EntryType entryType, String name, String bucketName, + StorageProvider provider, String accessKey, + String secretKey, Region region, String r2Endpoint) { + public boolean isSingleBucket() { + return entryType == EntryType.SINGLE_BUCKET; + } + } + + private final JComboBox entryTypeCombo = new JComboBox<>(EntryType.values()); private final JTextField nameField = new JTextField(); + private final JTextField bucketField = new JTextField(); private final JComboBox providerCombo = new JComboBox<>(StorageProvider.values()); private final JTextField accessKeyField = new JTextField(); private final JPasswordField secretKeyField = new JPasswordField(); private final JComboBox regionCombo; private final JTextField r2EndpointField = new JTextField(); + private final JLabel bucketLabel = new JLabel("Bucket Name"); private final JLabel regionLabel = new JLabel("AWS Region"); private final JLabel endpointLabel = new JLabel("R2 Endpoint"); private final JLabel statusLabel = new JLabel(" "); @@ -41,8 +67,8 @@ public class AccountDialog extends JDialog { private final JButton cancelButton = new JButton("Cancel"); private boolean busy; - public AccountDialog(Window owner, BiConsumer onConnect) { - super(owner, "Add Account", ModalityType.APPLICATION_MODAL); + public AccountDialog(Window owner, BiConsumer onConnect) { + super(owner, "Add Connection", ModalityType.APPLICATION_MODAL); JPanel root = new JPanel(new BorderLayout(UiConfig.SECTION_GAP, UiConfig.SECTION_GAP)); root.setBorder(BorderFactory.createEmptyBorder( @@ -56,14 +82,17 @@ public AccountDialog(Window owner, BiConsumer onCo JPanel form = new JPanel(new GridBagLayout()); int gap = UiConfig.SECTION_GAP; - addField(form, 0, new JLabel("Account Name"), nameField, gap); - addField(form, 1, new JLabel("Provider"), providerCombo, gap); - addField(form, 2, new JLabel("Access Key"), accessKeyField, gap); - addField(form, 3, new JLabel("Secret Key"), secretKeyField, gap); - addField(form, 4, regionLabel, regionCombo, gap); - addField(form, 5, endpointLabel, r2EndpointField, 0); - - providerCombo.addActionListener(event -> updateProviderFields()); + addField(form, 0, new JLabel("Type"), entryTypeCombo, gap); + addField(form, 1, new JLabel("Name"), nameField, gap); + addField(form, 2, bucketLabel, bucketField, gap); + addField(form, 3, new JLabel("Provider"), providerCombo, gap); + addField(form, 4, new JLabel("Access Key"), accessKeyField, gap); + addField(form, 5, new JLabel("Secret Key"), secretKeyField, gap); + addField(form, 6, regionLabel, regionCombo, gap); + addField(form, 7, endpointLabel, r2EndpointField, 0); + + entryTypeCombo.addActionListener(event -> updateVisibility()); + providerCombo.addActionListener(event -> updateVisibility()); JPanel actions = new JPanel(new BorderLayout()); JPanel buttonPanel = new JPanel(new java.awt.FlowLayout(java.awt.FlowLayout.RIGHT, gap, 0)); @@ -73,13 +102,13 @@ public AccountDialog(Window owner, BiConsumer onCo actions.add(buttonPanel, BorderLayout.EAST); cancelButton.addActionListener(event -> dispose()); - connectButton.addActionListener(event -> onConnect.accept(buildConfig(), this)); + connectButton.addActionListener(event -> onConnect.accept(buildInput(), this)); getRootPane().setDefaultButton(connectButton); root.add(form, BorderLayout.CENTER); root.add(actions, BorderLayout.SOUTH); - updateProviderFields(); + updateVisibility(); pack(); setResizable(false); setLocationRelativeTo(owner); @@ -94,17 +123,26 @@ public void setStatus(String status) { statusLabel.setText(status == null ? " " : status); } - public AccountConfig buildConfig() { + public ConnectionInput buildInput() { + EntryType entryType = (EntryType) entryTypeCombo.getSelectedItem(); StorageProvider provider = (StorageProvider) providerCombo.getSelectedItem(); Region region = (Region) regionCombo.getSelectedItem(); - return new AccountConfig("", nameField.getText().trim(), provider, - accessKeyField.getText().trim(), new String(secretKeyField.getPassword()).trim(), - region, r2EndpointField.getText().trim()); + return new ConnectionInput( + entryType, + nameField.getText().trim(), + bucketField.getText().trim(), + provider, + accessKeyField.getText().trim(), + new String(secretKeyField.getPassword()).trim(), + region, + r2EndpointField.getText().trim()); } private void updateEnabledState() { boolean enabled = !busy; + entryTypeCombo.setEnabled(enabled); nameField.setEnabled(enabled); + bucketField.setEnabled(enabled); providerCombo.setEnabled(enabled); accessKeyField.setEnabled(enabled); secretKeyField.setEnabled(enabled); @@ -114,7 +152,12 @@ private void updateEnabledState() { cancelButton.setEnabled(enabled); } - private void updateProviderFields() { + private void updateVisibility() { + EntryType entryType = (EntryType) entryTypeCombo.getSelectedItem(); + boolean isSingleBucket = entryType == EntryType.SINGLE_BUCKET; + bucketLabel.setVisible(isSingleBucket); + bucketField.setVisible(isSingleBucket); + StorageProvider provider = (StorageProvider) providerCombo.getSelectedItem(); boolean isAws = provider == StorageProvider.AWS_S3; regionLabel.setVisible(isAws); diff --git a/src/main/java/org/exastudio/opcgui/ui/panels/SidebarTreePanel.java b/src/main/java/org/exastudio/opcgui/ui/panels/SidebarTreePanel.java index 5d261a4..f92fccd 100644 --- a/src/main/java/org/exastudio/opcgui/ui/panels/SidebarTreePanel.java +++ b/src/main/java/org/exastudio/opcgui/ui/panels/SidebarTreePanel.java @@ -43,6 +43,7 @@ import org.exastudio.opcgui.types.AccountConfig; import org.exastudio.opcgui.types.BucketInfo; import org.exastudio.opcgui.types.BucketRef; +import org.exastudio.opcgui.types.SingleBucketConfig; import org.exastudio.opcgui.types.StorageProvider; import org.exastudio.opcgui.ui.components.menu.AccountContextMenu; import org.exastudio.opcgui.ui.components.menu.BucketContextMenu; @@ -56,11 +57,16 @@ public class SidebarTreePanel extends IslandPanel { private static final int HEADER_VERTICAL_PADDING = 3; - public record AccountNode(String accountId, String name, StorageProvider provider) { + public record AccountNode(String accountId, String name, StorageProvider provider, + boolean singleBucket, String bucketName) { @Override public String toString() { return name; } + + public boolean isSingleBucket() { + return singleBucket; + } } private record PlaceholderNode(String accountId) { @@ -126,7 +132,7 @@ public SidebarTreePanel(Runnable addAccountAction) { setLayout(new BorderLayout()); JButton addButton = new JButton(IconManager.icon("assets/icons/AddUserIcon.svg", 16, 16)); - addButton.setToolTipText("Add Account"); + addButton.setToolTipText("Add Connection"); addButton.addActionListener(event -> { if (this.addAccountAction != null) { this.addAccountAction.run(); @@ -291,15 +297,9 @@ public void setBucketActions(Consumer deleteBucket) { public void addAccount(AccountConfig config, List buckets) { DefaultMutableTreeNode accountNode = new DefaultMutableTreeNode( - new AccountNode(config.id(), config.name(), config.provider())); + new AccountNode(config.id(), config.name(), config.provider(), false, null)); - if (buckets == null || buckets.isEmpty()) { - accountNode.add(new DefaultMutableTreeNode(new PlaceholderNode(config.id()))); - } else { - for (BucketInfo bucket : buckets) { - accountNode.add(new DefaultMutableTreeNode(new BucketNode(config.id(), bucket.name()))); - } - } + populateBuckets(accountNode, config.id(), buckets); treeModel.insertNodeInto(accountNode, root, root.getChildCount()); accountNodes.put(config.id(), accountNode); @@ -313,23 +313,57 @@ public void addAccount(AccountConfig config, List buckets) { }); } - public void updateBuckets(String accountId, List buckets) { - DefaultMutableTreeNode accountNode = accountNodes.get(accountId); + public void addSingleBucket(SingleBucketConfig config) { + if (config == null) { + return; + } + DefaultMutableTreeNode node = new DefaultMutableTreeNode( + new AccountNode(config.id(), config.name(), config.provider(), true, config.bucketName())); + treeModel.insertNodeInto(node, root, root.getChildCount()); + accountNodes.put(config.id(), node); + SwingUtilities.invokeLater(() -> tree.scrollPathToVisible(new TreePath(node.getPath()))); + } + + public void updateBuckets(AccountConfig config, List buckets) { + if (config == null) { + return; + } + DefaultMutableTreeNode accountNode = accountNodes.get(config.id()); if (accountNode == null) { return; } + accountNode.setUserObject(new AccountNode(config.id(), config.name(), config.provider(), false, null)); accountNode.removeAllChildren(); + populateBuckets(accountNode, config.id(), buckets); + + treeModel.nodeStructureChanged(accountNode); + } + + public void updateSingleBucket(SingleBucketConfig config) { + if (config == null) { + return; + } + DefaultMutableTreeNode accountNode = accountNodes.get(config.id()); + if (accountNode == null) { + return; + } + accountNode.setUserObject(new AccountNode(config.id(), config.name(), config.provider(), true, config.bucketName())); + accountNode.removeAllChildren(); + treeModel.nodeStructureChanged(accountNode); + } + + private void populateBuckets(DefaultMutableTreeNode accountNode, String accountId, List buckets) { if (buckets == null || buckets.isEmpty()) { accountNode.add(new DefaultMutableTreeNode(new PlaceholderNode(accountId))); - } else { - for (BucketInfo bucket : buckets) { - accountNode.add(new DefaultMutableTreeNode( - new BucketNode(accountId, bucket.name()))); + return; + } + for (BucketInfo bucket : buckets) { + if (bucket == null || bucket.name() == null || bucket.name().isBlank()) { + continue; } + accountNode.add(new DefaultMutableTreeNode(new BucketNode(accountId, bucket.name()))); } - - treeModel.nodeStructureChanged(accountNode); } public void repaintAccountRow(String accountId) { @@ -467,6 +501,12 @@ public void valueChanged(TreeSelectionEvent event) { bucketSelected.accept(new BucketRef(bucketNode.accountId(), bucketNode.bucketName())); } } else if (userObject instanceof AccountNode accountNode) { + if (accountNode.isSingleBucket()) { + if (bucketSelected != null) { + bucketSelected.accept(new BucketRef(accountNode.accountId(), accountNode.bucketName())); + } + return; + } if (accountSelected != null) { accountSelected.accept(accountNode.accountId()); } @@ -525,7 +565,7 @@ private void maybeShowContextMenu(MouseEvent event) { } boolean connected = accountConnected.test(accountNode.accountId()); boolean connecting = accountConnecting != null && accountConnecting.test(accountNode.accountId()); - accountMenu.configure(accountNode.accountId(), connected, connecting, + accountMenu.configure(accountNode.accountId(), connected, connecting, !accountNode.isSingleBucket(), connectAccount, disconnectAccount, createBucket, deleteAccount); accountMenu.show(event.getComponent(), event.getX(), event.getY()); } @@ -565,6 +605,9 @@ public void treeWillExpand(javax.swing.event.TreeExpansionEvent event) throws Ex if (!(userObject instanceof AccountNode accountNode)) { return; } + if (accountNode.isSingleBucket()) { + return; + } boolean connected = accountConnected.test(accountNode.accountId()); boolean connecting = accountConnecting != null && accountConnecting.test(accountNode.accountId()); diff --git a/src/main/resources/db/migration/V2__add_single_bucket_support.sql b/src/main/resources/db/migration/V2__add_single_bucket_support.sql new file mode 100644 index 0000000..63fe643 --- /dev/null +++ b/src/main/resources/db/migration/V2__add_single_bucket_support.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS single_buckets ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + bucket_name TEXT NOT NULL, + provider TEXT NOT NULL CHECK (provider IN ('AWS_S3', 'CLOUDFLARE_R2')), + access_key TEXT NOT NULL, + secret_key TEXT NOT NULL, + region TEXT NULL, + r2_endpoint TEXT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_single_buckets_name ON single_buckets(name); +CREATE INDEX IF NOT EXISTS idx_single_buckets_provider ON single_buckets(provider); + +CREATE TRIGGER IF NOT EXISTS trg_single_buckets_updated +AFTER UPDATE ON single_buckets +FOR EACH ROW +BEGIN + UPDATE single_buckets + SET updated_at = CURRENT_TIMESTAMP + WHERE id = OLD.id; +END;