diff --git a/intellij/src/main/java/software/aws/toolkits/jetbrains/ui/KeyValueEditor.form b/intellij/src/main/java/software/aws/toolkits/jetbrains/ui/KeyValueEditor.form
new file mode 100644
index 00000000000..dfe1f97e0cc
--- /dev/null
+++ b/intellij/src/main/java/software/aws/toolkits/jetbrains/ui/KeyValueEditor.form
@@ -0,0 +1,52 @@
+
+
diff --git a/intellij/src/main/java/software/aws/toolkits/jetbrains/ui/KeyValueEditor.java b/intellij/src/main/java/software/aws/toolkits/jetbrains/ui/KeyValueEditor.java
new file mode 100644
index 00000000000..d5b1fd066e2
--- /dev/null
+++ b/intellij/src/main/java/software/aws/toolkits/jetbrains/ui/KeyValueEditor.java
@@ -0,0 +1,74 @@
+package software.aws.toolkits.jetbrains.ui;
+
+import com.intellij.openapi.ui.DialogWrapper;
+import com.intellij.openapi.ui.ValidationInfo;
+import com.intellij.openapi.util.Pair;
+import java.awt.Component;
+import java.util.function.BiFunction;
+import javax.swing.Action;
+import javax.swing.JComponent;
+import javax.swing.JPanel;
+import javax.swing.JTextField;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+public class KeyValueEditor extends DialogWrapper {
+ private final BiFunction, Pair, ValidationInfo> validator;
+ private JTextField keyField;
+ private JTextField valueField;
+ private JPanel component;
+
+ public KeyValueEditor(Component parent,
+ KeyValue initialValue,
+ BiFunction, Pair, ValidationInfo> validator) {
+ super(parent, false);
+ this.validator = validator;
+
+ if (initialValue == null) {
+ setTitle("Create New Key-Value");
+ } else {
+ setTitle("Edit Key-Value");
+ keyField.setText(initialValue.getKey());
+ valueField.setText(initialValue.getValue());
+ }
+ init();
+ }
+
+ @Nullable
+ @Override
+ public JComponent getPreferredFocusedComponent() {
+ return keyField;
+ }
+
+ @Nullable
+ @Override
+ protected JComponent createCenterPanel() {
+ return component;
+ }
+
+ @Nullable
+ @Override
+ protected ValidationInfo doValidate() {
+ if(validator != null) {
+ return validator.apply(Pair.create(getKey(), keyField), Pair.create(getValue(), valueField));
+ } else {
+ return null;
+ }
+ }
+
+ @NotNull
+ @Override
+ protected Action[] createActions() {
+ return new Action[] {getOKAction(), getCancelAction()};
+ }
+
+ public String getKey() {
+ return keyField.getText().trim();
+ }
+
+ public String getValue() {
+ return valueField.getText().trim();
+ }
+}
+
+
diff --git a/intellij/src/main/java/software/aws/toolkits/jetbrains/ui/KeyValueTableEditor.form b/intellij/src/main/java/software/aws/toolkits/jetbrains/ui/KeyValueTableEditor.form
new file mode 100644
index 00000000000..7c8643a4630
--- /dev/null
+++ b/intellij/src/main/java/software/aws/toolkits/jetbrains/ui/KeyValueTableEditor.form
@@ -0,0 +1,35 @@
+
+
diff --git a/intellij/src/main/java/software/aws/toolkits/jetbrains/ui/KeyValueTableEditor.java b/intellij/src/main/java/software/aws/toolkits/jetbrains/ui/KeyValueTableEditor.java
new file mode 100644
index 00000000000..9b2eaf2cf63
--- /dev/null
+++ b/intellij/src/main/java/software/aws/toolkits/jetbrains/ui/KeyValueTableEditor.java
@@ -0,0 +1,127 @@
+package software.aws.toolkits.jetbrains.ui;
+
+import com.intellij.icons.AllIcons;
+import com.intellij.openapi.actionSystem.AnActionEvent;
+import com.intellij.openapi.application.ApplicationManager;
+import com.intellij.openapi.ui.ValidationInfo;
+import com.intellij.openapi.util.Pair;
+import com.intellij.ui.AnActionButton;
+import com.intellij.ui.ToolbarDecorator;
+import com.intellij.ui.components.JBLabel;
+import com.intellij.util.ui.UIUtil;
+import java.awt.BorderLayout;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.BiFunction;
+import java.util.function.Supplier;
+import javax.swing.JComponent;
+import javax.swing.JPanel;
+
+public class KeyValueTableEditor {
+ private final Supplier> refreshLambda;
+
+ private KeyValueTable table;
+ private JBLabel remainingItems;
+ private JPanel keyValueTableHolder;
+ @SuppressWarnings("unused") // Needed in order to embed this into another form
+ private JPanel content;
+
+ private List initialValues;
+ private BiFunction, Pair, ValidationInfo> entryValidator;
+
+ public KeyValueTableEditor(Supplier> refreshLambda, Integer itemLimit,
+ BiFunction, Pair, ValidationInfo> entryValidator,
+ Runnable changeListener) {
+ this.refreshLambda = refreshLambda;
+ this.entryValidator = entryValidator;
+
+ ToolbarDecorator toolbar = ToolbarDecorator.createDecorator(table)
+ .disableUpDownActions()
+ .setAddAction(e -> this.createOrEdit(null))
+ .setAddActionUpdater(e -> !table.isBusy())
+ .setRemoveActionUpdater(e -> !table.isBusy())
+ .setEditAction(e -> this.createOrEdit(table.getSelectedObject()))
+ .setEditActionUpdater(e -> !table.isBusy())
+ .addExtraAction(new AnActionButton("Refresh", AllIcons.Actions.Refresh) {
+ @Override
+ public void actionPerformed(AnActionEvent e) {
+ verifyAndRefresh();
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return !table.isBusy();
+ }
+ });
+
+ table.getModel().addTableModelListener(e -> {
+ if (itemLimit != null) {
+ int remaining = itemLimit - (table.getItems().size());
+ remainingItems.setText("Remaining " + remaining + " of " + itemLimit);
+ remainingItems.setVisible(true);
+ } else {
+ remainingItems.setVisible(false);
+ }
+ });
+
+ table.getModel().addTableModelListener(e -> changeListener.run());
+
+ keyValueTableHolder.add(toolbar.createPanel(), BorderLayout.CENTER);
+
+ remainingItems.setForeground(UIUtil.getLabelDisabledForeground());
+ }
+
+ private void verifyAndRefresh() {
+ if (table.getModel().equals(initialValues)) {
+ if(!MessageUtils.verifyLossOfChanges(table)) {
+ return;
+ }
+ }
+
+ refresh();
+ }
+
+ public void refresh() {
+ table.setBusy(true);
+ ApplicationManager.getApplication().executeOnPooledThread(() -> {
+ if (refreshLambda != null) {
+ List updatedValues = refreshLambda.get();
+ initialValues = updatedValues;
+ table.getModel().setItems(new ArrayList<>(updatedValues));
+ }
+ table.setBusy(false);
+ });
+ }
+
+ private void createOrEdit(KeyValue selectedObject) {
+ KeyValueEditor entryEditor = new KeyValueEditor(table, selectedObject, entryValidator);
+ if (entryEditor.showAndGet()) {
+ if (selectedObject != null) {
+ selectedObject.setKey(entryEditor.getKey());
+ selectedObject.setValue(entryEditor.getValue());
+ } else {
+ table.getModel().addRow(new KeyValue(entryEditor.getKey(), entryEditor.getValue()));
+ }
+ }
+ }
+
+ public boolean isModified() {
+ return !table.getItems().equals(initialValues);
+ }
+
+ public void reset() {
+ table.getModel().setItems(new ArrayList<>(initialValues));
+ }
+
+ public List getItems() {
+ return table.getItems();
+ }
+
+ public void setBusy(boolean busy) {
+ table.setBusy(busy);
+ }
+
+ public boolean isBusy() {
+ return table.isBusy();
+ }
+}
diff --git a/intellij/src/main/java/software/aws/toolkits/jetbrains/ui/s3/BucketDetailsPanel.form b/intellij/src/main/java/software/aws/toolkits/jetbrains/ui/s3/BucketDetailsPanel.form
new file mode 100644
index 00000000000..72740ee42d2
--- /dev/null
+++ b/intellij/src/main/java/software/aws/toolkits/jetbrains/ui/s3/BucketDetailsPanel.form
@@ -0,0 +1,157 @@
+
+
diff --git a/intellij/src/main/java/software/aws/toolkits/jetbrains/ui/s3/BucketDetailsPanel.java b/intellij/src/main/java/software/aws/toolkits/jetbrains/ui/s3/BucketDetailsPanel.java
new file mode 100644
index 00000000000..d0e3bedbed5
--- /dev/null
+++ b/intellij/src/main/java/software/aws/toolkits/jetbrains/ui/s3/BucketDetailsPanel.java
@@ -0,0 +1,173 @@
+package software.aws.toolkits.jetbrains.ui.s3;
+
+import static com.intellij.ui.IdeBorderFactory.TITLED_BORDER_LEFT_INSET;
+import static com.intellij.ui.IdeBorderFactory.TITLED_BORDER_RIGHT_INSET;
+import static com.intellij.ui.IdeBorderFactory.TITLED_BORDER_TOP_INSET;
+
+import software.aws.toolkits.jetbrains.aws.s3.S3BucketVirtualFile;
+import software.aws.toolkits.jetbrains.ui.KeyValue;
+import software.aws.toolkits.jetbrains.ui.KeyValueTableEditor;
+import software.aws.toolkits.jetbrains.ui.MessageUtils;
+import com.amazonaws.intellij.utils.DateUtils;
+import com.amazonaws.services.s3.AmazonS3;
+import com.amazonaws.services.s3.model.BucketTaggingConfiguration;
+import com.amazonaws.services.s3.model.Region;
+import com.amazonaws.services.s3.model.TagSet;
+import com.intellij.openapi.application.ApplicationManager;
+import com.intellij.openapi.ide.CopyPasteManager;
+import com.intellij.openapi.project.Project;
+import com.intellij.openapi.ui.ValidationInfo;
+import com.intellij.openapi.util.Pair;
+import com.intellij.ui.IdeBorderFactory;
+import com.intellij.ui.components.JBLabel;
+import com.intellij.util.ui.JBUI;
+import com.intellij.util.ui.TextTransferable;
+import java.awt.Insets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import javax.swing.JButton;
+import javax.swing.JComponent;
+import javax.swing.JPanel;
+
+public class BucketDetailsPanel {
+ private static final int BUCKET_TAG_LIMIT = 50;
+ private static final int MAX_KEY_LENGTH = 128;
+ private static final int MAX_VALUE_LENGTH = 256;
+ private static final Pattern KEY_VALUE_VALID_REGEX = Pattern.compile("^([\\p{L}\\p{Z}\\p{N}_.:/=+\\-]*)$");
+ private static final String KEY_VALUE_VALIDATION_ERROR =
+ "The string can contain only the set of Unicode letters, digits, whitespace, '_', '.', '/', '=', '+', '-'";
+
+ private final S3BucketVirtualFile bucketVirtualFile;
+ private JPanel contentPanel;
+ private JPanel tagPanel;
+ private JBLabel bucketNameLabel;
+ private JBLabel region;
+ private JBLabel versioning;
+ private JBLabel creationDate;
+ private JButton copyArnButton;
+ private KeyValueTableEditor tags;
+ private JButton applyButton;
+ private JButton cancelButton;
+
+ public BucketDetailsPanel(Project project, S3BucketVirtualFile bucketVirtualFile) {
+ this.bucketVirtualFile = bucketVirtualFile;
+
+ this.contentPanel.setBorder(IdeBorderFactory.createTitledBorder("Bucket Details", false));
+
+ this.bucketNameLabel.setText(bucketVirtualFile.getName());
+
+ this.region.setText(determineRegion());
+ this.versioning.setText(bucketVirtualFile.getVersioningStatus());
+ this.creationDate.setText(DateUtils.formatDate(bucketVirtualFile.getTimeStamp()));
+
+ Insets insets = JBUI.insets(TITLED_BORDER_TOP_INSET, TITLED_BORDER_LEFT_INSET, 0, TITLED_BORDER_RIGHT_INSET);
+ this.tagPanel.setBorder(IdeBorderFactory.createTitledBorder("Tags", false, insets));
+
+ this.copyArnButton.addActionListener(e -> {
+ String arn = "arn:aws:s3:::" + bucketVirtualFile.getName();
+ CopyPasteManager.getInstance().setContents(new TextTransferable(arn));
+ });
+
+ this.applyButton.addActionListener(actionEvent -> applyChanges());
+ this.cancelButton.addActionListener(actionEvent -> cancelChanges());
+
+ this.tags.refresh();
+ }
+
+
+ private void createUIComponents() {
+ tags = new KeyValueTableEditor(this::loadTags, BUCKET_TAG_LIMIT, this::validateTag, this::updateButtons);
+ }
+
+ private List loadTags() {
+ AmazonS3 s3Client = bucketVirtualFile.getFileSystem().getS3Client();
+ BucketTaggingConfiguration bucketTags = s3Client.getBucketTaggingConfiguration(
+ bucketVirtualFile.getName());
+ if (bucketTags == null) {
+ return Collections.emptyList();
+ }
+
+ return bucketTags.getTagSet()
+ .getAllTags()
+ .entrySet()
+ .stream()
+ .map(entry -> new KeyValue(entry.getKey(), entry.getValue()))
+ .collect(Collectors.toList());
+ }
+
+ private ValidationInfo validateTag(Pair keyPair, Pair valuePair) {
+ String key = keyPair.getFirst();
+ JComponent keyInput = keyPair.getSecond();
+ if (key.length() < 1 || key.length() >= MAX_KEY_LENGTH) {
+ return new ValidationInfo("Key must be between 1 and " + MAX_KEY_LENGTH + " characters", keyInput);
+ }
+
+ if (!KEY_VALUE_VALID_REGEX.matcher(key).matches()) {
+ return new ValidationInfo(KEY_VALUE_VALIDATION_ERROR, keyInput);
+ }
+
+ String value = valuePair.getFirst();
+ JComponent valueInput = valuePair.getSecond();
+ if (value.length() >= MAX_VALUE_LENGTH) {
+ return new ValidationInfo("Key must be between 1 and " + MAX_VALUE_LENGTH + " characters", valueInput);
+ }
+
+ if (!KEY_VALUE_VALID_REGEX.matcher(value).matches()) {
+ return new ValidationInfo(KEY_VALUE_VALIDATION_ERROR, valueInput);
+ }
+
+ return null;
+ }
+
+ private void updateButtons() {
+ if (tags.isModified() && !tags.isBusy()) {
+ applyButton.setEnabled(true);
+ cancelButton.setEnabled(true);
+ } else {
+ applyButton.setEnabled(false);
+ cancelButton.setEnabled(false);
+ }
+ }
+
+ private void applyChanges() {
+ tags.setBusy(true);
+ updateButtons();
+ ApplicationManager.getApplication().executeOnPooledThread(this::updateTags);
+ }
+
+ private void updateTags() {
+ List newTags = tags.getItems();
+ Map tagMap = newTags.stream().collect(Collectors.toMap(KeyValue::getKey, KeyValue::getValue));
+ BucketTaggingConfiguration config = new BucketTaggingConfiguration(Collections.singleton(new TagSet(tagMap)));
+ bucketVirtualFile.getFileSystem().getS3Client().setBucketTaggingConfiguration(bucketVirtualFile.getName(), config);
+ tags.setBusy(false);
+ }
+
+ private void cancelChanges() {
+ if (!MessageUtils.verifyLossOfChanges(contentPanel)) {
+ return;
+ }
+
+ tags.reset();
+ updateButtons();
+ }
+
+ private String determineRegion() {
+ Region region = bucketVirtualFile.getRegion();
+ if (region != null) {
+ if (region == Region.US_Standard) {
+ return "us-east-1";
+ }
+ return region.getFirstRegionId();
+ } else {
+ return "Unknown";
+ }
+ }
+
+ public JComponent getComponent() {
+ return contentPanel;
+ }
+}
diff --git a/intellij/src/main/java/software/aws/toolkits/jetbrains/ui/s3/ObjectDetailsPanel.form b/intellij/src/main/java/software/aws/toolkits/jetbrains/ui/s3/ObjectDetailsPanel.form
new file mode 100644
index 00000000000..3e188b608c8
--- /dev/null
+++ b/intellij/src/main/java/software/aws/toolkits/jetbrains/ui/s3/ObjectDetailsPanel.form
@@ -0,0 +1,183 @@
+
+
diff --git a/intellij/src/main/java/software/aws/toolkits/jetbrains/ui/s3/ObjectDetailsPanel.java b/intellij/src/main/java/software/aws/toolkits/jetbrains/ui/s3/ObjectDetailsPanel.java
new file mode 100644
index 00000000000..c7236b5217c
--- /dev/null
+++ b/intellij/src/main/java/software/aws/toolkits/jetbrains/ui/s3/ObjectDetailsPanel.java
@@ -0,0 +1,187 @@
+package software.aws.toolkits.jetbrains.ui.s3;
+
+
+import com.amazonaws.intellij.utils.DateUtils;
+import com.amazonaws.services.s3.AmazonS3;
+import com.amazonaws.services.s3.model.CopyObjectRequest;
+import com.amazonaws.services.s3.model.GetObjectTaggingRequest;
+import com.amazonaws.services.s3.model.GetObjectTaggingResult;
+import com.amazonaws.services.s3.model.ObjectMetadata;
+import com.amazonaws.services.s3.model.ObjectTagging;
+import com.amazonaws.services.s3.model.SetObjectTaggingRequest;
+import com.amazonaws.services.s3.model.Tag;
+import com.intellij.openapi.application.ApplicationManager;
+import com.intellij.openapi.ide.CopyPasteManager;
+import com.intellij.openapi.util.text.StringUtil;
+import com.intellij.ui.IdeBorderFactory;
+import com.intellij.ui.components.JBLabel;
+import com.intellij.ui.components.JBTabbedPane;
+import com.intellij.util.ui.JBUI;
+import com.intellij.util.ui.TextTransferable;
+import java.awt.Insets;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+import javax.swing.JButton;
+import javax.swing.JComponent;
+import javax.swing.JPanel;
+import javax.swing.JTabbedPane;
+import javax.swing.SwingConstants;
+import org.jetbrains.annotations.NotNull;
+import software.aws.toolkits.jetbrains.aws.s3.S3VirtualFile;
+import software.aws.toolkits.jetbrains.ui.KeyValue;
+import software.aws.toolkits.jetbrains.ui.KeyValueTableEditor;
+import software.aws.toolkits.jetbrains.ui.MessageUtils;
+
+public class ObjectDetailsPanel {
+ private final S3VirtualFile s3File;
+ private JPanel contentPanel;
+ private JBLabel objectNameLabel;
+ private JBLabel size;
+ private JBLabel modifiedDate;
+ private JButton copyArnButton;
+ private JButton applyButton;
+ private JButton cancelButton;
+ private JTabbedPane tabbedPanel;
+ private KeyValueTableEditor tags;
+ private KeyValueTableEditor metadata;
+ private JBLabel eTag;
+
+ public ObjectDetailsPanel(S3VirtualFile s3File) {
+ this.s3File = s3File;
+
+ this.contentPanel.setBorder(IdeBorderFactory.createTitledBorder("Object Details", false));
+
+ this.objectNameLabel.setText(s3File.getName());
+
+ this.size.setText(StringUtil.formatFileSize(s3File.getLength()));
+ this.modifiedDate.setText(DateUtils.formatDate(s3File.getTimeStamp()));
+ this.eTag.setText(s3File.getETag());
+
+ this.copyArnButton.addActionListener(e -> {
+ String arn = "arn:aws:s3:::" + s3File.getPath();
+ CopyPasteManager.getInstance().setContents(new TextTransferable(arn));
+ });
+
+ this.applyButton.addActionListener(actionEvent -> applyChanges());
+ this.cancelButton.addActionListener(actionEvent -> cancelChanges());
+
+ this.tags.refresh();
+ this.metadata.refresh();
+ }
+
+ private void createUIComponents() {
+ tags = new KeyValueTableEditor(this::loadTags, null, null, this::onValueChanged);
+ metadata = new KeyValueTableEditor(this::loadMetadata, null, null, this::onValueChanged);
+
+ tabbedPanel = new JBTabbedPane(SwingConstants.TOP) {
+ @NotNull
+ @Override
+ protected Insets getInsetsForTabComponent() {
+ return JBUI.emptyInsets();
+ }
+ };
+ }
+
+ private List loadTags() {
+ AmazonS3 s3Client = s3File.getFileSystem().getS3Client();
+ GetObjectTaggingRequest taggingRequest = new GetObjectTaggingRequest(s3File.getBucketName(), s3File.getKey());
+ GetObjectTaggingResult objectTags = s3Client.getObjectTagging(taggingRequest);
+ if (objectTags == null) {
+ return Collections.emptyList();
+ }
+
+ return objectTags.getTagSet()
+ .stream()
+ .map(entry -> new KeyValue(entry.getKey(), entry.getValue()))
+ .collect(Collectors.toList());
+ }
+
+ private void updateTags(List newTags) {
+ List tags = newTags.stream()
+ .map(keyValue -> new Tag(keyValue.getKey(), keyValue.getValue()))
+ .collect(Collectors.toList());
+
+ AmazonS3 s3Client = s3File.getFileSystem().getS3Client();
+ s3Client.setObjectTagging(new SetObjectTaggingRequest(s3File.getBucketName(), s3File.getKey(), new ObjectTagging(tags)));
+ }
+
+ private List loadMetadata() {
+ AmazonS3 s3Client = s3File.getFileSystem().getS3Client();
+ ObjectMetadata objectMetadata = s3Client.getObjectMetadata(s3File.getBucketName(), s3File.getKey());
+ return objectMetadata.getUserMetadata()
+ .entrySet()
+ .stream()
+ .map(entry -> new KeyValue(entry.getKey(), entry.getValue()))
+ .collect(Collectors.toList());
+ }
+
+ private void onValueChanged() {
+ if (tags.isModified() || metadata.isModified()) {
+ applyButton.setEnabled(true);
+ cancelButton.setEnabled(true);
+ } else {
+ applyButton.setEnabled(false);
+ cancelButton.setEnabled(false);
+ }
+ }
+
+ private void applyChanges() {
+ metadata.setBusy(true);
+ tags.setBusy(true);
+ applyButton.setEnabled(false);
+ cancelButton.setEnabled(false);
+
+ // To update metadata, we need to issue a copy
+ if (metadata.isModified()) {
+ CopyObjectRequest copyObjectRequest = new CopyObjectRequest(s3File.getBucketName(), s3File.getKey(),
+ s3File.getBucketName(), s3File.getKey());
+ ObjectMetadata newObjectMetadata = new ObjectMetadata();
+ metadata.getItems().forEach(keyValue -> newObjectMetadata.addUserMetadata(keyValue.getKey(), keyValue.getValue()));
+ copyObjectRequest.setNewObjectMetadata(newObjectMetadata);
+
+ if (tags.isModified()) {
+ copyObjectRequest.setNewObjectTagging(getObjectTagging());
+ }
+
+ ApplicationManager.getApplication().executeOnPooledThread(() -> {
+ s3File.getFileSystem().getS3Client().copyObject(copyObjectRequest);
+ metadata.refresh();
+ tags.refresh();
+ });
+ } else {
+ ApplicationManager.getApplication().executeOnPooledThread(() -> {
+ SetObjectTaggingRequest setObjectTaggingRequest = new SetObjectTaggingRequest(s3File.getBucketName(),
+ s3File.getKey(),
+ getObjectTagging());
+ s3File.getFileSystem().getS3Client().setObjectTagging(setObjectTaggingRequest);
+ tags.refresh();
+ });
+ }
+ }
+
+ private ObjectTagging getObjectTagging() {
+ return new ObjectTagging(tags.getItems()
+ .stream()
+ .map(keyValue -> new Tag(keyValue.getKey(), keyValue.getValue()))
+ .collect(Collectors.toList()));
+ }
+
+ private void cancelChanges() {
+ if (!MessageUtils.verifyLossOfChanges(contentPanel)) {
+ return;
+ }
+
+ if (tags.isModified()) {
+ tags.reset();
+ }
+
+ if (metadata.isModified()) {
+ metadata.reset();
+ }
+ }
+
+ public JComponent getComponent() {
+ return contentPanel;
+ }
+}
diff --git a/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/aws/s3/S3BucketViewerPanel.kt b/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/aws/s3/S3BucketViewerPanel.kt
new file mode 100644
index 00000000000..081bb71ecfc
--- /dev/null
+++ b/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/aws/s3/S3BucketViewerPanel.kt
@@ -0,0 +1,92 @@
+package software.aws.toolkits.jetbrains.aws.s3
+
+import com.intellij.ui.JBSplitter
+
+import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
+import com.intellij.openapi.fileChooser.FileSystemTree
+import com.intellij.openapi.fileChooser.FileSystemTreeFactory
+import com.intellij.openapi.fileEditor.FileEditorManager
+import com.intellij.openapi.project.Project
+import com.intellij.ui.ToolbarDecorator
+import com.intellij.ui.components.JBLabel
+import com.intellij.ui.components.panels.Wrapper
+import software.aws.toolkits.jetbrains.ui.s3.BucketDetailsPanel
+import software.aws.toolkits.jetbrains.ui.s3.ObjectDetailsPanel
+import java.awt.BorderLayout
+import java.awt.event.MouseAdapter
+import java.awt.event.MouseEvent
+import javax.swing.JComponent
+import javax.swing.JPanel
+
+class S3BucketViewerPanel(private val project: Project, private val s3bucket: S3BucketVirtualFile) {
+ private val splitPanel: JBSplitter
+ private val s3FileTree: S3FileTree
+ private val detailPane: Wrapper
+
+ init {
+ s3FileTree = S3FileTree()
+
+ detailPane = Wrapper(JBLabel())
+
+ splitPanel = JBSplitter(0.25f)
+ splitPanel.firstComponent = s3FileTree
+ splitPanel.secondComponent = detailPane
+ }
+
+ val component: JComponent
+ get() = splitPanel
+
+ private var details: JComponent? = null
+ set(component) {
+ detailPane.setContent(component ?: JBLabel())
+ }
+
+ private inner class S3FileTree() : JPanel(BorderLayout()) {
+ private val fileSystemTree: FileSystemTree
+
+ init {
+ val fileDescriptor = FileChooserDescriptorFactory.createSingleFileOrFolderDescriptor()
+ .withRoots(s3bucket)
+ .withTreeRootVisible(true)
+
+ fileSystemTree = FileSystemTreeFactory.SERVICE.getInstance()
+ .createFileSystemTree(project, fileDescriptor)
+
+ val tree = fileSystemTree.tree
+ tree.addMouseListener(object : MouseAdapter() {
+ override fun mouseClicked(e: MouseEvent) {
+ if(e.clickCount >= 2) {
+ handleDoubleClick()
+ }
+ }
+ })
+ tree.addTreeSelectionListener { handleSelectionChange(fileSystemTree) }
+
+ val toolbar = ToolbarDecorator.createDecorator(tree)
+
+ add(toolbar.createPanel(), BorderLayout.CENTER)
+ }
+
+ private fun handleDoubleClick() {
+ val selectedFile = fileSystemTree.selectedFile
+ when(selectedFile) {
+ is S3VirtualFile -> FileEditorManager.getInstance(project).openFile(selectedFile, true)
+ }
+ }
+
+ private fun handleSelectionChange(tree: FileSystemTree) {
+ val selectedFiles = tree.selectedFiles
+ if (selectedFiles.size != 1) {
+ details = null
+ return
+ }
+
+ val selectedFile = selectedFiles[0]
+ details = when (selectedFile) {
+ is S3BucketVirtualFile -> BucketDetailsPanel(project, selectedFile).component
+ is S3VirtualFile -> ObjectDetailsPanel(selectedFile).component
+ else -> null
+ }
+ }
+ }
+}
diff --git a/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/aws/s3/S3FileTypes.kt b/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/aws/s3/S3FileTypes.kt
new file mode 100644
index 00000000000..f8a2599c8db
--- /dev/null
+++ b/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/aws/s3/S3FileTypes.kt
@@ -0,0 +1,52 @@
+package software.aws.toolkits.jetbrains.aws.s3
+
+import com.intellij.icons.AllIcons
+import com.intellij.openapi.fileTypes.FileTypeConsumer
+import com.intellij.openapi.fileTypes.FileTypeFactory
+import com.intellij.openapi.fileTypes.ex.FileTypeIdentifiableByVirtualFile
+import com.intellij.openapi.vfs.VirtualFile
+import software.aws.toolkits.jetbrains.ui.S3_BUCKET_ICON
+import javax.swing.Icon
+
+class BucketFileType : FileTypeIdentifiableByVirtualFile {
+ override fun getDefaultExtension() = ""
+
+ override fun getIcon(): Icon = S3_BUCKET_ICON
+
+ override fun getCharset(file: VirtualFile, content: ByteArray) = null
+
+ override fun getName() = "S3 Bucket"
+
+ override fun getDescription() = name
+
+ override fun isBinary() = true
+
+ override fun isMyFileType(file: VirtualFile) = file is S3BucketVirtualFile
+
+ override fun isReadOnly() = true
+}
+
+class DirectoryFileType : FileTypeIdentifiableByVirtualFile {
+ override fun getDefaultExtension() = ""
+
+ override fun getIcon(): Icon = AllIcons.Nodes.Folder
+
+ override fun getCharset(file: VirtualFile, content: ByteArray) = null
+
+ override fun getName() = "S3 Directory"
+
+ override fun getDescription() = name
+
+ override fun isBinary() = true
+
+ override fun isMyFileType(file: VirtualFile) = file is S3VirtualDirectory
+
+ override fun isReadOnly() = true
+}
+
+class S3FileTypeFactory : FileTypeFactory() {
+ override fun createFileTypes(consumer: FileTypeConsumer) {
+ consumer.consume(BucketFileType())
+ consumer.consume(DirectoryFileType())
+ }
+}
\ No newline at end of file
diff --git a/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/aws/s3/S3VirtualFileSystem.kt b/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/aws/s3/S3VirtualFileSystem.kt
new file mode 100644
index 00000000000..ac00991f18f
--- /dev/null
+++ b/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/aws/s3/S3VirtualFileSystem.kt
@@ -0,0 +1,302 @@
+package software.aws.toolkits.jetbrains.aws.s3
+
+import com.amazonaws.services.s3.AmazonS3
+import com.amazonaws.services.s3.model.*
+import com.intellij.openapi.util.io.FileTooBigException
+import com.intellij.openapi.util.io.FileUtilRt
+import com.intellij.openapi.vfs.VirtualFile
+import com.intellij.openapi.vfs.VirtualFileListener
+import com.intellij.openapi.vfs.VirtualFileSystem
+import com.intellij.util.PathUtil
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+
+class S3VirtualFileSystem(val s3Client: AmazonS3) : VirtualFileSystem() {
+ override fun deleteFile(requestor: Any?, vFile: VirtualFile) {
+ TODO("not implemented")
+ }
+
+ override fun getProtocol(): String {
+ return "s3"
+ }
+
+ fun getBuckets(): List {
+ return s3Client.listBuckets().map { S3BucketVirtualFile(this, it) }
+ }
+
+ override fun createChildDirectory(requestor: Any?, vDir: VirtualFile, dirName: String): VirtualFile {
+ TODO("not implemented")
+ }
+
+ override fun addVirtualFileListener(listener: VirtualFileListener) {}
+
+ override fun isReadOnly(): Boolean = true
+
+ override fun findFileByPath(path: String): VirtualFile? {
+ TODO("not implemented")
+ }
+
+ override fun renameFile(requestor: Any?, vFile: VirtualFile, newName: String) {
+ TODO("not implemented")
+ }
+
+ override fun createChildFile(requestor: Any?, vDir: VirtualFile, fileName: String): VirtualFile {
+ TODO("not implemented")
+ }
+
+ override fun refreshAndFindFileByPath(path: String): VirtualFile? {
+ TODO("not implemented")
+ }
+
+ override fun removeVirtualFileListener(listener: VirtualFileListener) {}
+
+ override fun copyFile(requestor: Any?, virtualFile: VirtualFile, newParent: VirtualFile, copyName: String): VirtualFile {
+ TODO("not implemented")
+ }
+
+ override fun moveFile(requestor: Any?, vFile: VirtualFile, newParent: VirtualFile) {
+ TODO("not implemented")
+ }
+
+ override fun refresh(asynchronous: Boolean) {
+ TODO("not implemented")
+ }
+}
+
+class S3BucketVirtualFile(private val fileSystem: S3VirtualFileSystem, private val bucket: Bucket) : VirtualFile() {
+ override fun getName(): String = bucket.name
+
+ override fun getFileSystem() = fileSystem
+
+ override fun getPath() = name
+
+ override fun isWritable() = false
+
+ override fun isDirectory() = true
+
+ override fun isValid() = true
+
+ override fun getParent() = null
+
+ val versioningEnabled by lazy { versioningStatus != BucketVersioningConfiguration.OFF }
+
+ val versioningStatus by lazy { fileSystem.s3Client.getBucketVersioningConfiguration(bucket.name).status }
+
+ val region: Region? by lazy {
+ try {
+ Region.fromValue(fileSystem.s3Client.getBucketLocation(bucket.name))
+ } catch (e: IllegalArgumentException) {
+ null
+ }
+ }
+
+ override fun getChildren(): Array {
+ val s3Client = fileSystem.s3Client
+
+ val request = ListObjectsV2Request()
+ .withBucketName(bucket.name)
+ .withDelimiter("/")
+ val children = arrayListOf()
+
+ do {
+ val result = s3Client.listObjectsV2(request)
+
+ children.addAll(result.commonPrefixes.map { S3VirtualDirectory(fileSystem, result.bucketName, it, this) })
+ children.addAll(result.objectSummaries.map {
+ if (it.key.endsWith("/")) {
+ S3VirtualDirectory(fileSystem, bucket.name, it.key, this)
+ } else {
+ S3VirtualFile(fileSystem, it, this)
+ }
+ })
+
+ request.continuationToken = result.nextContinuationToken
+ } while (result.isTruncated)
+
+ return children.toTypedArray()
+ }
+
+
+ @Throws(IOException::class)
+ override fun getOutputStream(requestor: Any, newModificationStamp: Long, newTimeStamp: Long): OutputStream {
+ throw IOException("getOutputStream() cannot be called against a bucket")
+ }
+
+ @Throws(IOException::class)
+ override fun contentsToByteArray(): ByteArray {
+ throw IOException("contentsToByteArray() cannot be called against a bucket")
+ }
+
+ @Throws(IOException::class)
+ override fun getInputStream(): InputStream {
+ throw IOException("getInputStream() cannot be called against a bucket")
+ }
+
+ override fun getTimeStamp() = bucket.creationDate.time
+
+ override fun getLength() = -1L
+
+ override fun getModificationStamp() = -1L
+
+ override fun refresh(asynchronous: Boolean, recursive: Boolean, postRunnable: Runnable?) {}
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) {
+ return true
+ }
+ if (other?.javaClass != javaClass) {
+ return false
+ }
+
+ other as S3BucketVirtualFile
+
+ if (bucket.name != other.bucket.name) {
+ return false
+ }
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ return bucket.name.hashCode()
+ }
+}
+
+abstract class BaseS3VirtualObject(protected val s3FileSystem: S3VirtualFileSystem,
+ val bucketName: String,
+ val key: String,
+ private val _parent: VirtualFile) : VirtualFile() {
+ override fun getName() = PathUtil.getFileName(key)
+
+ override fun getFileSystem() = s3FileSystem
+
+ override fun getPath() = bucketName + "/" + key
+
+ override fun getParent(): VirtualFile = _parent
+
+ override fun refresh(asynchronous: Boolean, recursive: Boolean, postRunnable: Runnable?) {}
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) {
+ return true
+ }
+ if (other?.javaClass != javaClass) {
+ return false
+ }
+
+ other as BaseS3VirtualObject
+
+ if (bucketName != other.bucketName || key != other.key) {
+ return false
+ }
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = bucketName.hashCode()
+ result = 31 * result + key.hashCode()
+ return result
+ }
+}
+
+class S3VirtualDirectory(s3FileSystem: S3VirtualFileSystem, bucketName: String, private val directory: String, private val _parent: VirtualFile)
+ : BaseS3VirtualObject(s3FileSystem, bucketName, directory, _parent) {
+ override fun getChildren(): Array {
+ val s3Client = fileSystem.s3Client
+ val request = ListObjectsV2Request()
+ .withBucketName(bucketName)
+ .withPrefix(key)
+ .withDelimiter("/")
+ val children = arrayListOf()
+
+ do {
+ val result = s3Client.listObjectsV2(request)
+
+ children.addAll(result.commonPrefixes.map { S3VirtualDirectory(fileSystem, result.bucketName, it, this) })
+ children.addAll(result.objectSummaries
+ .filterNot { it.key == key }
+ .map {
+ if (it.key.endsWith("/")) {
+ S3VirtualDirectory(fileSystem, bucketName, it.key, this)
+ } else {
+ S3VirtualFile(fileSystem, it, this)
+ }
+ })
+
+ request.continuationToken = result.nextContinuationToken
+ } while (result.isTruncated)
+
+ return children.toTypedArray()
+ }
+
+ @Throws(IOException::class)
+ override fun getOutputStream(requestor: Any, newModificationStamp: Long, newTimeStamp: Long): OutputStream {
+ throw IOException("getOutputStream() cannot be called against a virtual directory")
+ }
+
+ @Throws(IOException::class)
+ override fun contentsToByteArray(): ByteArray {
+ throw IOException("contentsToByteArray() cannot be called against a virtual directory")
+ }
+
+ @Throws(IOException::class)
+ override fun getInputStream(): InputStream {
+ throw IOException("getInputStream() cannot be called against a virtual directory")
+ }
+
+ override fun isDirectory() = true
+
+ override fun isWritable() = false
+
+ override fun isValid() = true
+
+ override fun getTimeStamp() = -1L
+
+ override fun getLength() = -1L
+
+ override fun getModificationStamp() = -1L
+}
+
+class S3VirtualFile(s3FileSystem: S3VirtualFileSystem, private val obj: S3ObjectSummary, private val _parent: VirtualFile)
+ : BaseS3VirtualObject(s3FileSystem, obj.bucketName, obj.key, _parent) {
+ val eTag: String
+ get() = obj.eTag
+
+ override fun getChildren(): Array = arrayOf()
+
+ @Throws(IOException::class)
+ override fun getOutputStream(requestor: Any, newModificationStamp: Long, newTimeStamp: Long): OutputStream {
+ if (isDirectory) {
+ throw IOException("getOutputStream() cannot be called against a directory")
+ }
+
+ TODO("not implemented")
+ }
+
+ @Throws(IOException::class)
+ override fun contentsToByteArray(): ByteArray {
+ if (FileUtilRt.isTooLarge(obj.size)) {
+ throw FileTooBigException(path)
+ }
+ return inputStream.readBytes(obj.size.toInt())
+ }
+
+ @Throws(IOException::class)
+ override fun getInputStream(): InputStream {
+ return fileSystem.s3Client.getObject(obj.bucketName, obj.key).objectContent
+ }
+
+ override fun isDirectory() = key.endsWith("/")
+
+ override fun isWritable() = false
+
+ override fun isValid() = true
+
+ override fun getTimeStamp() = obj.lastModified.time
+
+ override fun getLength() = obj.size
+
+ override fun getModificationStamp() = -1L
+}
\ No newline at end of file
diff --git a/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/s3/explorer/AwsExplorerS3Node.kt b/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/s3/explorer/AwsExplorerS3Node.kt
index 9c20139f01d..b8faa8126f6 100644
--- a/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/s3/explorer/AwsExplorerS3Node.kt
+++ b/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/s3/explorer/AwsExplorerS3Node.kt
@@ -1,14 +1,20 @@
package software.aws.toolkits.jetbrains.s3.explorer
+
import com.amazonaws.services.s3.AmazonS3
import com.amazonaws.services.s3.model.Bucket
import com.intellij.ide.util.treeView.AbstractTreeNode
+import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.project.Project
+import software.aws.toolkits.jetbrains.aws.s3.S3BucketVirtualFile
+import software.aws.toolkits.jetbrains.aws.s3.S3VirtualFileSystem
import software.aws.toolkits.jetbrains.core.AwsClientManager
import software.aws.toolkits.jetbrains.ui.S3_BUCKET_ICON
import software.aws.toolkits.jetbrains.ui.S3_SERVICE_ICON
import software.aws.toolkits.jetbrains.ui.explorer.AwsExplorerNode
import software.aws.toolkits.jetbrains.ui.explorer.AwsExplorerServiceRootNode
+import javax.swing.tree.DefaultMutableTreeNode
+import javax.swing.tree.DefaultTreeModel
class AwsExplorerS3RootNode(project: Project) :
AwsExplorerServiceRootNode(project, "Amazon S3", S3_SERVICE_ICON) {
@@ -25,11 +31,15 @@ class AwsExplorerS3RootNode(project: Project) :
class AwsExplorerBucketNode(project: Project, private val bucket: Bucket) :
AwsExplorerNode(project, bucket, S3_BUCKET_ICON) {
- override fun getChildren(): Collection> {
- return emptyList()
- }
+ private val editorManager = FileEditorManager.getInstance(project)
+ private val client: AmazonS3 = AwsClientManager.getInstance(project).getClient()
+
+ override fun getChildren(): Collection> = emptyList()
+
+ override fun toString(): String = bucket.name
- override fun toString(): String {
- return bucket.name
+ override fun onDoubleClick(model: DefaultTreeModel, selectedElement: DefaultMutableTreeNode) {
+ val bucketVirtualFile = S3BucketVirtualFile(S3VirtualFileSystem(client), bucket)
+ editorManager.openFile(bucketVirtualFile, true)
}
}
\ No newline at end of file
diff --git a/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/ui/Icons.kt b/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/ui/Icons.kt
index 87d05d09802..4627284bcc2 100644
--- a/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/ui/Icons.kt
+++ b/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/ui/Icons.kt
@@ -2,26 +2,27 @@ package software.aws.toolkits.jetbrains.ui
import com.intellij.openapi.util.IconLoader
-val AWS_ICON = IconLoader.getIcon("/icons/aws-box.gif")
-val S3_BUCKET_ICON = IconLoader.getIcon("/icons/bucket.png")
-val S3_SERVICE_ICON = IconLoader.getIcon("/icons/s3-service.png")
-val LAMBDA_SERVICE_ICON = IconLoader.getIcon("/icons/lambda-service.png")
-val LAMBDA_SERVICE_ICON_LARGE = IconLoader.getIcon("/icons/lambda-service-large.png")
-val LAMBDA_FUNCTION_ICON = IconLoader.getIcon("/icons/function.png")
-val INFO_ICON = IconLoader.getIcon("/icons/information.png")
-val SQS_SERVICE_ICON = IconLoader.getIcon("/icons/sqs-service.png")
-val SQS_QUEUE_ICON = IconLoader.getIcon("/icons/index.png")
-val SNS_SERVICE_ICON = IconLoader.getIcon("/icons/sns-service.png")
-val SNS_TOPIC_ICON = IconLoader.getIcon("/icons/sns-topic.png")
-val EC2_SERVICE_ICON = IconLoader.getIcon("/icons/rds-service.png")
-val EC2_INSTANCE_ICON = IconLoader.getIcon("/icons/index.png")
-val ADD_ICON = IconLoader.getIcon("/icons/add.png")
+@JvmField val AWS_ICON = IconLoader.getIcon("/icons/aws-box.gif")
+@JvmField val S3_BUCKET_ICON = IconLoader.getIcon("/icons/bucket.png")
+@JvmField val S3_SERVICE_ICON = IconLoader.getIcon("/icons/s3-service.png")
+@JvmField val LAMBDA_SERVICE_ICON = IconLoader.getIcon("/icons/lambda-service.png")
+@JvmField val LAMBDA_SERVICE_ICON_LARGE = IconLoader.getIcon("/icons/lambda-service-large.png")
+@JvmField val LAMBDA_FUNCTION_ICON = IconLoader.getIcon("/icons/function.png")
+@JvmField val INFO_ICON = IconLoader.getIcon("/icons/information.png")
+@JvmField val SQS_SERVICE_ICON = IconLoader.getIcon("/icons/sqs-service.png")
+@JvmField val SQS_QUEUE_ICON = IconLoader.getIcon("/icons/index.png")
+@JvmField val SNS_SERVICE_ICON = IconLoader.getIcon("/icons/sns-service.png")
+@JvmField val SNS_TOPIC_ICON = IconLoader.getIcon("/icons/sns-topic.png")
+@JvmField val EC2_SERVICE_ICON = IconLoader.getIcon("/icons/rds-service.png")
+@JvmField val EC2_INSTANCE_ICON = IconLoader.getIcon("/icons/index.png")
-val EU_ICON = IconLoader.getIcon("/icons/flags/eu.png")
-val USA_ICON = IconLoader.getIcon("/icons/flags/us.png")
-val SINGAPORE_ICON = IconLoader.getIcon("/icons/flags/singapore.png")
-val JAPAN_ICON = IconLoader.getIcon("/icons/flags/japan.png")
-val AUSTRALIA_ICON = IconLoader.getIcon("/icons/flags/australia.png")
-val BRAZIL_ICON = IconLoader.getIcon("/icons/flags/brazil.png")
-val IRELAND_ICON = IconLoader.getIcon("/icons/flags/ireland.png")
\ No newline at end of file
+@JvmField val ADD_ICON = IconLoader.getIcon("/icons/add.png")
+
+@JvmField val EU_ICON = IconLoader.getIcon("/icons/flags/eu.png")
+@JvmField val USA_ICON = IconLoader.getIcon("/icons/flags/us.png")
+@JvmField val SINGAPORE_ICON = IconLoader.getIcon("/icons/flags/singapore.png")
+@JvmField val JAPAN_ICON = IconLoader.getIcon("/icons/flags/japan.png")
+@JvmField val AUSTRALIA_ICON = IconLoader.getIcon("/icons/flags/australia.png")
+@JvmField val BRAZIL_ICON = IconLoader.getIcon("/icons/flags/brazil.png")
+@JvmField val IRELAND_ICON = IconLoader.getIcon("/icons/flags/ireland.png")
\ No newline at end of file
diff --git a/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/ui/KeyValueTable.kt b/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/ui/KeyValueTable.kt
new file mode 100644
index 00000000000..39a2c0443c8
--- /dev/null
+++ b/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/ui/KeyValueTable.kt
@@ -0,0 +1,64 @@
+package software.aws.toolkits.jetbrains.ui
+
+import com.intellij.ui.table.TableView
+import com.intellij.util.ui.ColumnInfo
+import com.intellij.util.ui.ListTableModel
+import javax.swing.JTable
+import javax.swing.RowSorter
+import javax.swing.SortOrder
+import javax.swing.table.TableRowSorter
+
+class KeyValueTable(initialItems: List = mutableListOf()) : TableView(createModel()) {
+ init {
+ autoResizeMode = (JTable.AUTO_RESIZE_LAST_COLUMN)
+ isStriped = true
+ emptyText.text = "No entries"
+ tableHeader.reorderingAllowed = false
+
+ val sorter = TableRowSorter>(model)
+ sorter.setSortable(0, true)
+ sorter.setSortable(1, true)
+ sorter.sortsOnUpdates = true
+ sorter.sortKeys = listOf(RowSorter.SortKey(0, SortOrder.ASCENDING))
+
+ rowSorter = sorter
+
+ model.items = initialItems
+ }
+
+ var isBusy: Boolean = false
+ set(busy) {
+ setPaintBusy(busy)
+ field = busy
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ override fun getModel(): ListTableModel {
+ return super.getModel() as ListTableModel
+ }
+
+ companion object {
+ private fun createModel(): ListTableModel {
+ val tableModel = ListTableModel(*createColumns())
+ tableModel.isSortable = true
+
+ return tableModel
+ }
+
+ private fun createColumns(): Array {
+ return arrayOf(
+ StringColumn("Key", { it.key }),
+ StringColumn("Value", { it.value })
+ )
+ }
+ }
+}
+
+private class StringColumn(name: String, private val extractor: (KeyValue) -> String)
+ : ColumnInfo(name) {
+ override fun valueOf(keyValue: KeyValue): String {
+ return extractor.invoke(keyValue)
+ }
+}
+
+data class KeyValue(var key: String, var value: String)
diff --git a/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/ui/MessageUtils.kt b/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/ui/MessageUtils.kt
new file mode 100644
index 00000000000..675fe45a301
--- /dev/null
+++ b/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/ui/MessageUtils.kt
@@ -0,0 +1,20 @@
+package software.aws.toolkits.jetbrains.ui
+
+import com.intellij.openapi.ui.Messages
+import javax.swing.JComponent
+
+class MessageUtils {
+ companion object {
+ @JvmStatic
+ fun verifyLossOfChanges(parent: JComponent): Boolean {
+ val result = Messages.showOkCancelDialog(parent,
+ "You have uncommitted changes, this will erase those changes. Continue?",
+ "Confirm?",
+ Messages.YES_BUTTON,
+ Messages.CANCEL_BUTTON,
+ Messages.getWarningIcon())
+
+ return result == Messages.OK
+ }
+ }
+}
diff --git a/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/ui/s3/S3BucketViewer.kt b/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/ui/s3/S3BucketViewer.kt
new file mode 100644
index 00000000000..fd98e269e07
--- /dev/null
+++ b/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/ui/s3/S3BucketViewer.kt
@@ -0,0 +1,66 @@
+package software.aws.toolkits.jetbrains.ui.s3
+
+
+import com.intellij.codeHighlighting.BackgroundEditorHighlighter
+import com.intellij.openapi.fileEditor.FileEditor
+import com.intellij.openapi.fileEditor.FileEditorLocation
+import com.intellij.openapi.fileEditor.FileEditorPolicy
+import com.intellij.openapi.fileEditor.FileEditorProvider
+import com.intellij.openapi.fileEditor.FileEditorState
+import com.intellij.openapi.fileEditor.FileEditorStateLevel
+import com.intellij.openapi.project.DumbAware
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.util.UserDataHolderBase
+import com.intellij.openapi.vfs.VirtualFile
+import software.aws.toolkits.jetbrains.aws.s3.S3BucketViewerPanel
+import software.aws.toolkits.jetbrains.aws.s3.S3BucketVirtualFile
+import java.beans.PropertyChangeListener
+import javax.swing.JComponent
+
+class S3BucketViewer(private val project: Project, private val s3Bucket: S3BucketVirtualFile)
+ : UserDataHolderBase(), FileEditor {
+
+ private val bucketViewer: S3BucketViewerPanel = S3BucketViewerPanel(project, s3Bucket)
+
+ override fun getName() = "S3 Bucket Viewer"
+
+ override fun getComponent(): JComponent = bucketViewer.component
+
+ override fun dispose() {}
+
+ override fun isModified(): Boolean = false
+
+ override fun getState(level: FileEditorStateLevel): FileEditorState = FileEditorState.INSTANCE
+
+ override fun setState(state: FileEditorState) {}
+
+ override fun getPreferredFocusedComponent(): JComponent? = null
+
+ override fun getCurrentLocation(): FileEditorLocation? = null
+
+ override fun selectNotify() {}
+
+ override fun deselectNotify() {}
+
+ override fun getBackgroundHighlighter(): BackgroundEditorHighlighter? = null
+
+ override fun isValid(): Boolean = true
+
+ override fun addPropertyChangeListener(listener: PropertyChangeListener) {}
+
+ override fun removePropertyChangeListener(listener: PropertyChangeListener) {}
+}
+
+class S3BucketViewerProvider : FileEditorProvider, DumbAware {
+ override fun getEditorTypeId() = EDITOR_TYPE_ID
+
+ override fun accept(project: Project, file: VirtualFile) = file is S3BucketVirtualFile
+
+ override fun createEditor(project: Project, file: VirtualFile) = S3BucketViewer(project, file as S3BucketVirtualFile)
+
+ override fun getPolicy() = FileEditorPolicy.HIDE_DEFAULT_EDITOR
+
+ companion object {
+ const val EDITOR_TYPE_ID = "s3Bucket"
+ }
+}
\ No newline at end of file
diff --git a/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/utils/DateUtils.kt b/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/utils/DateUtils.kt
new file mode 100644
index 00000000000..e51b6caf052
--- /dev/null
+++ b/intellij/src/main/kotlin/software/aws/toolkits/jetbrains/utils/DateUtils.kt
@@ -0,0 +1,17 @@
+package com.amazonaws.intellij.utils
+
+import java.time.Instant
+import java.time.ZoneOffset
+import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
+
+class DateUtils {
+ companion object {
+ @JvmStatic
+ fun formatDate(epochMills: Long): String {
+ val instant = Instant.ofEpochMilli(epochMills)
+
+ return DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.ofInstant(instant, ZoneOffset.UTC))
+ }
+ }
+}
\ No newline at end of file
diff --git a/intellij/src/main/resources/META-INF/plugin.xml b/intellij/src/main/resources/META-INF/plugin.xml
index d97d7a68641..d6a71c22074 100644
--- a/intellij/src/main/resources/META-INF/plugin.xml
+++ b/intellij/src/main/resources/META-INF/plugin.xml
@@ -28,6 +28,9 @@
+
+
+