diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserHistoryTabComp.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserHistoryTabComp.java index 6bd6d78e7..0b87fd24f 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserHistoryTabComp.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserHistoryTabComp.java @@ -101,7 +101,7 @@ public class BrowserHistoryTabComp extends SimpleComp { .grow(true, false) .accessibleTextKey("restoreAllSessions"); - var layout = new VerticalComp(List.of(vbox, Comp.vspacer(5), listBox, Comp.separator(), tile)); + var layout = new VerticalComp(List.of(vbox, Comp.vspacer(5), listBox, Comp.hseparator(), tile)); layout.styleClass("welcome"); layout.spacing(14); layout.maxWidth(1000); diff --git a/app/src/main/java/io/xpipe/app/comp/Comp.java b/app/src/main/java/io/xpipe/app/comp/Comp.java index aad527642..a3c3bf906 100644 --- a/app/src/main/java/io/xpipe/app/comp/Comp.java +++ b/app/src/main/java/io/xpipe/app/comp/Comp.java @@ -58,10 +58,14 @@ public abstract class Comp> { }; } - public static Comp> separator() { + public static Comp> hseparator() { return of(() -> new Separator(Orientation.HORIZONTAL)); } + public static Comp> vseparator() { + return of(() -> new Separator(Orientation.VERTICAL)); + } + @SuppressWarnings("unchecked") public static , OR extends Region> Comp> derive( Comp comp, Function r) { diff --git a/app/src/main/java/io/xpipe/app/comp/augment/ContextMenuAugment.java b/app/src/main/java/io/xpipe/app/comp/augment/ContextMenuAugment.java index c2e0f3bce..a687ae701 100644 --- a/app/src/main/java/io/xpipe/app/comp/augment/ContextMenuAugment.java +++ b/app/src/main/java/io/xpipe/app/comp/augment/ContextMenuAugment.java @@ -88,7 +88,7 @@ public class ContextMenuAugment> implements Augment> { - private final List> entries; + private final ObservableList> entries; public HorizontalComp(List> comps) { - entries = List.copyOf(comps); + entries = FXCollections.observableArrayList(List.copyOf(comps)); + } + + public HorizontalComp(ObservableList> entries) { + this.entries = PlatformThread.sync(entries); } public Comp> spacing(double spacing) { @@ -23,8 +32,11 @@ public class HorizontalComp extends Comp> { @Override public CompStructure createBase() { - HBox b = new HBox(); + var b = new HBox(); b.getStyleClass().add("horizontal-comp"); + entries.addListener((ListChangeListener>) c -> { + b.getChildren().setAll(c.getList().stream().map(Comp::createRegion).toList()); + }); for (var entry : entries) { b.getChildren().add(entry.createRegion()); } diff --git a/app/src/main/java/io/xpipe/app/comp/base/ToolbarComp.java b/app/src/main/java/io/xpipe/app/comp/base/ToolbarComp.java new file mode 100644 index 000000000..aaccaeec6 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/comp/base/ToolbarComp.java @@ -0,0 +1,40 @@ +package io.xpipe.app.comp.base; + +import io.xpipe.app.comp.Comp; +import io.xpipe.app.comp.CompStructure; +import io.xpipe.app.comp.SimpleCompStructure; +import io.xpipe.app.util.PlatformThread; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.geometry.Pos; +import javafx.scene.control.ToolBar; +import javafx.scene.layout.HBox; + +import java.util.List; + +public class ToolbarComp extends Comp> { + + private final ObservableList> entries; + + public ToolbarComp(List> comps) { + entries = FXCollections.observableArrayList(List.copyOf(comps)); + } + + public ToolbarComp(ObservableList> entries) { + this.entries = PlatformThread.sync(entries); + } + + @Override + public CompStructure createBase() { + var b = new ToolBar(); + b.getStyleClass().add("horizontal-comp"); + entries.addListener((ListChangeListener>) c -> { + b.getItems().setAll(c.getList().stream().map(Comp::createRegion).toList()); + }); + for (var entry : entries) { + b.getItems().add(entry.createRegion()); + } + return new SimpleCompStructure<>(b); + } +} diff --git a/app/src/main/java/io/xpipe/app/comp/store/DenseStoreEntryComp.java b/app/src/main/java/io/xpipe/app/comp/store/DenseStoreEntryComp.java index 5fbbdb9e1..29acdc885 100644 --- a/app/src/main/java/io/xpipe/app/comp/store/DenseStoreEntryComp.java +++ b/app/src/main/java/io/xpipe/app/comp/store/DenseStoreEntryComp.java @@ -8,6 +8,7 @@ import javafx.beans.binding.Bindings; import javafx.geometry.HPos; import javafx.geometry.Insets; import javafx.geometry.Pos; +import javafx.geometry.VPos; import javafx.scene.control.Label; import javafx.scene.layout.*; @@ -71,9 +72,20 @@ public class DenseStoreEntryComp extends StoreEntryComp { var notes = new StoreNotesComp(getWrapper()).createRegion(); var userIcon = createUserIcon().createRegion(); + var selection = createBatchSelection().createRegion(); + grid.add(selection, 0, 0, 1, 2); + grid.getColumnConstraints().add(new ColumnConstraints(25)); + StoreViewState.get().getBatchMode().subscribe(batch -> { + if (batch) { + grid.getColumnConstraints().set(0, new ColumnConstraints(25)); + } else { + grid.getColumnConstraints().set(0, new ColumnConstraints(-8)); + } + }); + var storeIcon = createIcon(28, 24); GridPane.setHalignment(storeIcon, HPos.CENTER); - grid.add(storeIcon, 0, 0); + grid.add(storeIcon, 1, 0); grid.getColumnConstraints().add(new ColumnConstraints(34)); var customSize = content != null ? 100 : 0; diff --git a/app/src/main/java/io/xpipe/app/comp/store/StandardStoreEntryComp.java b/app/src/main/java/io/xpipe/app/comp/store/StandardStoreEntryComp.java index 5c47c790f..a16189958 100644 --- a/app/src/main/java/io/xpipe/app/comp/store/StandardStoreEntryComp.java +++ b/app/src/main/java/io/xpipe/app/comp/store/StandardStoreEntryComp.java @@ -38,15 +38,26 @@ public class StandardStoreEntryComp extends StoreEntryComp { grid.setHgap(6); grid.setVgap(OsType.getLocal() == OsType.MACOS ? 2 : 0); + var selection = createBatchSelection(); + grid.add(selection.createRegion(), 0, 0, 1, 2); + grid.getColumnConstraints().add(new ColumnConstraints(25)); + StoreViewState.get().getBatchMode().subscribe(batch -> { + if (batch) { + grid.getColumnConstraints().set(0, new ColumnConstraints(25)); + } else { + grid.getColumnConstraints().set(0, new ColumnConstraints(-6)); + } + }); + var storeIcon = createIcon(46, 40); - grid.add(storeIcon, 0, 0, 1, 2); + grid.add(storeIcon, 1, 0, 1, 2); grid.getColumnConstraints().add(new ColumnConstraints(52)); var active = new StoreActiveComp(getWrapper()).createRegion(); var nameBox = new HBox(name, userIcon, notes); nameBox.setSpacing(6); nameBox.setAlignment(Pos.CENTER_LEFT); - grid.add(nameBox, 1, 0); + grid.add(nameBox, 2, 0); GridPane.setVgrow(nameBox, Priority.ALWAYS); getWrapper().getSessionActive().subscribe(aBoolean -> { if (!aBoolean) { @@ -59,7 +70,7 @@ public class StandardStoreEntryComp extends StoreEntryComp { var summaryBox = new HBox(createSummary()); summaryBox.setAlignment(Pos.TOP_LEFT); GridPane.setVgrow(summaryBox, Priority.ALWAYS); - grid.add(summaryBox, 1, 1); + grid.add(summaryBox, 2, 1); var nameCC = new ColumnConstraints(); nameCC.setMinWidth(100); @@ -67,7 +78,7 @@ public class StandardStoreEntryComp extends StoreEntryComp { nameCC.setPrefWidth(100); grid.getColumnConstraints().addAll(nameCC); - grid.add(createInformation(), 2, 0, 1, 2); + grid.add(createInformation(), 3, 0, 1, 2); var info = new ColumnConstraints(); info.prefWidthProperty().bind(content != null ? INFO_WITH_CONTENT_WIDTH : INFO_NO_CONTENT_WIDTH); info.setHalignment(HPos.LEFT); @@ -84,7 +95,7 @@ public class StandardStoreEntryComp extends StoreEntryComp { controls.setAlignment(Pos.CENTER_RIGHT); controls.setSpacing(10); controls.setPadding(new Insets(0, 0, 0, 10)); - grid.add(controls, 3, 0, 1, 2); + grid.add(controls, 4, 0, 1, 2); grid.getColumnConstraints().add(custom); grid.getStyleClass().add("store-entry-grid"); diff --git a/app/src/main/java/io/xpipe/app/comp/store/StoreEntryBatchSelectComp.java b/app/src/main/java/io/xpipe/app/comp/store/StoreEntryBatchSelectComp.java new file mode 100644 index 000000000..60500d705 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/comp/store/StoreEntryBatchSelectComp.java @@ -0,0 +1,66 @@ +package io.xpipe.app.comp.store; + +import io.xpipe.app.comp.SimpleComp; +import javafx.application.Platform; +import javafx.collections.ListChangeListener; +import javafx.scene.control.CheckBox; +import javafx.scene.control.Label; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Region; + +public class StoreEntryBatchSelectComp extends SimpleComp { + + private final StoreSection section; + + public StoreEntryBatchSelectComp(StoreSection section) {this.section = section;} + + @Override + protected Region createSimple() { + var cb = new CheckBox(); + cb.setAllowIndeterminate(true); + cb.selectedProperty().addListener((observable, oldValue, newValue) -> { + if (newValue) { + StoreViewState.get().selectBatchMode(section); + } else { + StoreViewState.get().unselectBatchMode(section); + } + }); + + StoreViewState.get().getBatchModeSelection().getList().addListener((ListChangeListener) c -> { + Platform.runLater(() -> { + update(cb); + }); + }); + section.getShownChildren().getList().addListener((ListChangeListener) c -> { + if (cb.isSelected()) { + StoreViewState.get().selectBatchMode(section); + } else { + StoreViewState.get().unselectBatchMode(section); + } + }); + + cb.getStyleClass().add("batch-mode-selector"); + cb.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> { + if (event.getButton() == MouseButton.PRIMARY) { + cb.setSelected(!cb.isSelected()); + event.consume(); + } + }); + return cb; + } + + private void update(CheckBox checkBox) { + var isSelected = StoreViewState.get().isSectionSelected(section); + checkBox.setSelected(isSelected); + if (section.getShownChildren().getList().size() == 0) { + checkBox.setIndeterminate(false); + return; + } + + var count = section.getShownChildren().getList().stream().filter(c -> StoreViewState.get().getBatchModeSelection().getList().contains(c.getWrapper())).count(); + checkBox.setIndeterminate(count > 0 && count != section.getShownChildren().getList().size()); + return; + } +} diff --git a/app/src/main/java/io/xpipe/app/comp/store/StoreEntryComp.java b/app/src/main/java/io/xpipe/app/comp/store/StoreEntryComp.java index 3b327d3b8..ef353e9bc 100644 --- a/app/src/main/java/io/xpipe/app/comp/store/StoreEntryComp.java +++ b/app/src/main/java/io/xpipe/app/comp/store/StoreEntryComp.java @@ -86,6 +86,7 @@ public abstract class StoreEntryComp extends SimpleComp { var r = createContent(); var buttonBar = r.lookup(".button-bar"); var iconChooser = r.lookup(".icon"); + var batchMode = r.lookup(".batch-mode-selector"); var button = new Button(); button.setGraphic(r); @@ -103,6 +104,7 @@ public abstract class StoreEntryComp extends SimpleComp { }); button.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> { var notOnButton = NodeHelper.isParent(iconChooser, event.getTarget()) + || NodeHelper.isParent(batchMode, event.getTarget()) || NodeHelper.isParent(buttonBar, event.getTarget()); if (AppPrefs.get().requireDoubleClickForConnections().get() && !notOnButton) { if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() != 2) { @@ -116,6 +118,7 @@ public abstract class StoreEntryComp extends SimpleComp { }); button.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> { var notOnButton = NodeHelper.isParent(iconChooser, event.getTarget()) + || NodeHelper.isParent(batchMode, event.getTarget()) || NodeHelper.isParent(buttonBar, event.getTarget()); if (AppPrefs.get().requireDoubleClickForConnections().get() && !notOnButton) { if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() != 2) { @@ -274,6 +277,12 @@ public abstract class StoreEntryComp extends SimpleComp { return settingsButton; } + protected Comp createBatchSelection() { + var c = new StoreEntryBatchSelectComp(section); + c.hide(StoreViewState.get().getBatchMode().not()); + return c; + } + protected ContextMenu createContextMenu() { var contextMenu = ContextMenuHelper.create(); diff --git a/app/src/main/java/io/xpipe/app/comp/store/StoreEntryListComp.java b/app/src/main/java/io/xpipe/app/comp/store/StoreEntryListComp.java index 28e779dfa..cec5dbd71 100644 --- a/app/src/main/java/io/xpipe/app/comp/store/StoreEntryListComp.java +++ b/app/src/main/java/io/xpipe/app/comp/store/StoreEntryListComp.java @@ -2,17 +2,22 @@ package io.xpipe.app.comp.store; import io.xpipe.app.comp.Comp; import io.xpipe.app.comp.SimpleComp; +import io.xpipe.app.comp.base.HorizontalComp; import io.xpipe.app.comp.base.ListBoxViewComp; import io.xpipe.app.comp.base.MultiContentComp; +import io.xpipe.app.comp.base.VerticalComp; import io.xpipe.app.core.AppCache; import io.xpipe.app.core.AppLayoutModel; import javafx.beans.binding.Bindings; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.value.ObservableValue; +import javafx.geometry.Insets; import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; import java.util.LinkedHashMap; +import java.util.List; public class StoreEntryListComp extends SimpleComp { @@ -48,7 +53,15 @@ public class StoreEntryListComp extends SimpleComp { struc.get().setVvalue(0); }); }); - return content.styleClass("store-list-comp"); + content.styleClass("store-list-comp"); + content.vgrow(); + + var statusBar = new StoreEntryListStatusBarComp(); + statusBar.apply(struc -> { + VBox.setMargin(struc.get(), new Insets(3, 6, 4, 2)); + }); + statusBar.hide(StoreViewState.get().getBatchMode().not()); + return new VerticalComp(List.of(content, statusBar)); } @Override diff --git a/app/src/main/java/io/xpipe/app/comp/store/StoreEntryListOverviewComp.java b/app/src/main/java/io/xpipe/app/comp/store/StoreEntryListOverviewComp.java index 735984913..035cf98e5 100644 --- a/app/src/main/java/io/xpipe/app/comp/store/StoreEntryListOverviewComp.java +++ b/app/src/main/java/io/xpipe/app/comp/store/StoreEntryListOverviewComp.java @@ -1,5 +1,6 @@ package io.xpipe.app.comp.store; +import atlantafx.base.theme.Styles; import io.xpipe.app.comp.Comp; import io.xpipe.app.comp.SimpleComp; import io.xpipe.app.comp.base.CountComp; @@ -91,24 +92,30 @@ public class StoreEntryListOverviewComp extends SimpleComp { StoreViewState.get().getFilterString().setValue(newValue); }); }); - var filter = new FilterComp(StoreViewState.get().getFilterString()); - var f = filter.createRegion(); - var button = createAddButton(); - var hbox = new HBox(button, f); - f.minHeightProperty().bind(button.heightProperty()); - f.prefHeightProperty().bind(button.heightProperty()); - f.maxHeightProperty().bind(button.heightProperty()); + var filter = new FilterComp(StoreViewState.get().getFilterString()).createRegion(); + var add = createAddButton(); + var batchMode = createBatchModeButton().createRegion(); + var hbox = new HBox(add, filter, batchMode); + filter.minHeightProperty().bind(add.heightProperty()); + filter.prefHeightProperty().bind(add.heightProperty()); + filter.maxHeightProperty().bind(add.heightProperty()); + batchMode.minHeightProperty().bind(add.heightProperty()); + batchMode.prefHeightProperty().bind(add.heightProperty()); + batchMode.maxHeightProperty().bind(add.heightProperty()); + batchMode.minWidthProperty().bind(add.heightProperty()); + batchMode.prefWidthProperty().bind(add.heightProperty()); + batchMode.maxWidthProperty().bind(add.heightProperty()); hbox.setSpacing(8); hbox.setAlignment(Pos.CENTER); - HBox.setHgrow(f, Priority.ALWAYS); + HBox.setHgrow(filter, Priority.ALWAYS); - f.getStyleClass().add("filter-bar"); + filter.getStyleClass().add("filter-bar"); return hbox; } private Region createAddButton() { var menu = new MenuButton(null, new FontIcon("mdi2p-plus-thick")); - menu.textProperty().bind(AppI18n.observable("addConnections")); + menu.textProperty().bind(AppI18n.observable("new")); menu.setAlignment(Pos.CENTER); menu.setTextAlignment(TextAlignment.CENTER); StoreCreationMenu.addButtons(menu); @@ -124,6 +131,29 @@ public class StoreEntryListOverviewComp extends SimpleComp { return menu; } + + private Comp createBatchModeButton() { + var batchMode = StoreViewState.get().getBatchMode(); + var b = new IconButtonComp("mdi2l-layers", () -> { + batchMode.setValue(!batchMode.getValue()); + }); + b.apply(struc -> { + struc + .get() + .opacityProperty() + .bind(Bindings.createDoubleBinding( + () -> { + if (batchMode.getValue()) { + return 1.0; + } + return 0.4; + }, + batchMode)); + struc.get().getStyleClass().remove(Styles.FLAT); + }); + return b; + } + private Comp createAlphabeticalSortButton() { var sortMode = StoreViewState.get().getSortMode(); var icon = Bindings.createObjectBinding( diff --git a/app/src/main/java/io/xpipe/app/comp/store/StoreEntryListStatusBarComp.java b/app/src/main/java/io/xpipe/app/comp/store/StoreEntryListStatusBarComp.java new file mode 100644 index 000000000..c7febc439 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/comp/store/StoreEntryListStatusBarComp.java @@ -0,0 +1,176 @@ +package io.xpipe.app.comp.store; + +import atlantafx.base.theme.Styles; +import io.xpipe.app.comp.Comp; +import io.xpipe.app.comp.SimpleComp; +import io.xpipe.app.comp.augment.ContextMenuAugment; +import io.xpipe.app.comp.base.*; +import io.xpipe.app.core.AppFontSizes; +import io.xpipe.app.core.AppI18n; +import io.xpipe.app.ext.ActionProvider; +import io.xpipe.app.storage.DataStoreEntryRef; +import io.xpipe.app.util.*; +import io.xpipe.core.store.DataStore; +import javafx.beans.binding.Bindings; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.geometry.Pos; +import javafx.scene.control.Menu; +import javafx.scene.control.MenuItem; +import javafx.scene.input.MouseButton; +import javafx.scene.layout.Region; + +import java.util.ArrayList; +import java.util.List; + +public class StoreEntryListStatusBarComp extends SimpleComp { + + @Override + protected Region createSimple() { + var checkbox = new StoreEntryBatchSelectComp(StoreViewState.get().getCurrentTopLevelSection()); + var l = new LabelComp(Bindings.createStringBinding(() -> { + return AppI18n.get("connectionsSelected", StoreViewState.get().getEffectiveBatchModeSelection().getList().size()); + }, StoreViewState.get().getEffectiveBatchModeSelection().getList(), AppI18n.activeLanguage())); + l.minWidth(Region.USE_PREF_SIZE); + l.apply(struc -> { + struc.get().setAlignment(Pos.CENTER); + }); + var actions = new ToolbarComp(createActions()); + var close = new IconButtonComp("mdi2c-close", () -> { + StoreViewState.get().getBatchMode().setValue(false); + }); + close.apply(struc -> { + struc.get().getStyleClass().remove(Styles.FLAT); + struc.get().minWidthProperty().bind(struc.get().heightProperty()); + struc.get().prefWidthProperty().bind(struc.get().heightProperty()); + struc.get().maxWidthProperty().bind(struc.get().heightProperty()); + }); + var bar = new HorizontalComp(List.of(checkbox, Comp.hspacer(12), l, Comp.hspacer(20), actions, Comp.hspacer(), Comp.hspacer(20), close)); + bar.apply(struc -> { + struc.get().setFillHeight(true); + struc.get().setAlignment(Pos.CENTER_LEFT); + }); + bar.minHeight(40); + bar.prefHeight(40); + bar.styleClass("bar"); + bar.styleClass("store-entry-list-status-bar"); + return bar.createRegion(); + } + + private ObservableList> createActions() { + var l = new DerivedObservableList(FXCollections.observableArrayList(), true); + StoreViewState.get().getEffectiveBatchModeSelection().getList().addListener((ListChangeListener) c -> { + l.setContent(getCompatibleActionProviders()); + }); + return l.>mapped(actionProvider -> { + return buildButton(actionProvider); + }).getList(); + } + + private List getCompatibleActionProviders() { + var l = StoreViewState.get().getEffectiveBatchModeSelection().getList(); + if (l.isEmpty()) { + return List.of(); + } + + var all = new ArrayList<>(ActionProvider.ALL); + for (StoreEntryWrapper w : l) { + var actions = ActionProvider.ALL.stream().filter(actionProvider -> { + var s = actionProvider.getBatchDataStoreCallSite(); + if (s == null) { + return false; + } + + if (!s.getApplicableClass().isAssignableFrom(w.getStore().getValue().getClass())) { + return false; + } + + if (!s.isApplicable(w.getEntry().ref())) { + return false; + } + + return true; + }).toList(); + all.removeIf(actionProvider -> !actions.contains(actionProvider)); + } + return all; + } + + @SuppressWarnings("unchecked") + private Comp buildButton(ActionProvider p) { + ActionProvider.BatchDataStoreCallSite s = (ActionProvider.BatchDataStoreCallSite) p.getBatchDataStoreCallSite(); + if (s == null) { + return Comp.empty(); + } + + List> childrenRefs = StoreViewState.get().getEffectiveBatchModeSelection().getList().stream().map( + storeEntryWrapper -> storeEntryWrapper.getEntry().ref()).toList(); + var batchActions = s.getChildren(childrenRefs); + var button = new ButtonComp(s.getName(), new SimpleObjectProperty<>(new LabelGraphic.IconGraphic(s.getIcon())), () -> { + if (batchActions.size() > 0) { + return; + } + + runActions(s); + }); + if (batchActions.size() > 0) { + button.apply(new ContextMenuAugment<>( + mouseEvent -> mouseEvent.getButton() == MouseButton.PRIMARY, keyEvent -> false, () -> { + var cm = ContextMenuHelper.create(); + s.getChildren(childrenRefs) + .forEach(childProvider -> { + var menu = buildMenuItemForAction(childrenRefs, childProvider); + cm.getItems().add(menu); + }); + return cm; + })); + } + return button; + } + + + @SuppressWarnings("unchecked") + private MenuItem buildMenuItemForAction(List> batch, ActionProvider a) { + ActionProvider.BatchDataStoreCallSite s = (ActionProvider.BatchDataStoreCallSite) a.getBatchDataStoreCallSite(); + var name = s.getName(); + var icon = s.getIcon(); + var children = s.getChildren(batch); + if (children.size() > 0) { + var menu = new Menu(); + menu.textProperty().bind(name); + menu.setGraphic(new LabelGraphic.IconGraphic(icon).createGraphicNode()); + var items = children.stream() + .filter(actionProvider -> actionProvider.getBatchDataStoreCallSite() != null) + .map(c -> buildMenuItemForAction(batch, c)) + .toList(); + menu.getItems().addAll(items); + return menu; + } else { + var item = new MenuItem(); + item.textProperty().bind(name); + item.setGraphic(new LabelGraphic.IconGraphic(icon).createGraphicNode()); + item.setOnAction(event -> { + runActions(s); + event.consume(); + if (event.getTarget() instanceof Menu m) { + m.getParentPopup().hide(); + } + }); + return item; + } + } + + @SuppressWarnings("unchecked") + private void runActions(ActionProvider.BatchDataStoreCallSite s) { + ThreadHelper.runFailableAsync(() -> { + var l = new ArrayList<>( StoreViewState.get().getEffectiveBatchModeSelection().getList()); + var mapped = l.stream().map(w -> w.getEntry().ref()).toList(); + var action = ((ActionProvider.BatchDataStoreCallSite) s).createAction(mapped); + if (action != null) { + action.execute(); + } + }); + } +} diff --git a/app/src/main/java/io/xpipe/app/comp/store/StoreLayoutComp.java b/app/src/main/java/io/xpipe/app/comp/store/StoreLayoutComp.java index 431454f34..4f584ef19 100644 --- a/app/src/main/java/io/xpipe/app/comp/store/StoreLayoutComp.java +++ b/app/src/main/java/io/xpipe/app/comp/store/StoreLayoutComp.java @@ -21,7 +21,7 @@ public class StoreLayoutComp extends SimpleComp { AppLayoutModel.get().getSavedState().setSidebarWidth(aDouble); }) .createStructure(); - struc.getLeft().setMinWidth(260); + struc.getLeft().setMinWidth(270); struc.getLeft().setMaxWidth(500); struc.get().getStyleClass().add("store-layout"); InputHelper.onKeyCombination( diff --git a/app/src/main/java/io/xpipe/app/comp/store/StoreSectionComp.java b/app/src/main/java/io/xpipe/app/comp/store/StoreSectionComp.java index 2a607379b..5633a3dce 100644 --- a/app/src/main/java/io/xpipe/app/comp/store/StoreSectionComp.java +++ b/app/src/main/java/io/xpipe/app/comp/store/StoreSectionComp.java @@ -156,7 +156,7 @@ public class StoreSectionComp extends Comp> { section.getShownChildren().getList()); var full = new VerticalComp(List.of( topEntryList, - Comp.separator().hide(expanded.not()), + Comp.hseparator().hide(expanded.not()), content.styleClass("children-content") .hide(Bindings.or( Bindings.not(section.getWrapper().getExpanded()), diff --git a/app/src/main/java/io/xpipe/app/comp/store/StoreViewState.java b/app/src/main/java/io/xpipe/app/comp/store/StoreViewState.java index 0f5dfa8e2..313e0d3cd 100644 --- a/app/src/main/java/io/xpipe/app/comp/store/StoreViewState.java +++ b/app/src/main/java/io/xpipe/app/comp/store/StoreViewState.java @@ -1,6 +1,7 @@ package io.xpipe.app.comp.store; import io.xpipe.app.core.AppCache; +import io.xpipe.app.ext.DataStoreUsageCategory; import io.xpipe.app.issue.ErrorEvent; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.storage.DataStorage; @@ -14,6 +15,8 @@ import javafx.application.Platform; import javafx.beans.property.*; import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; import lombok.Getter; import java.util.*; @@ -42,6 +45,23 @@ public class StoreViewState { @Getter private final Property sortMode = new SimpleObjectProperty<>(); + @Getter + private final BooleanProperty batchMode = new SimpleBooleanProperty(true); + @Getter + private final DerivedObservableList batchModeSelection = new DerivedObservableList<>(FXCollections.observableArrayList(), true); + @Getter + private final DerivedObservableList effectiveBatchModeSelection = batchModeSelection.filtered(storeEntryWrapper -> { + if (!storeEntryWrapper.getValidity().getValue().isUsable()) { + return false; + } + + if (storeEntryWrapper.getEntry().getProvider().getUsageCategory() == DataStoreUsageCategory.GROUP) { + return false; + } + + return true; + }); + @Getter private StoreSection currentTopLevelSection; @@ -60,6 +80,7 @@ public class StoreViewState { INSTANCE.initSections(); INSTANCE.updateContent(); INSTANCE.initFilterListener(); + INSTANCE.initBatchListener(); } public static void reset() { @@ -80,6 +101,36 @@ public class StoreViewState { return INSTANCE; } + public void selectBatchMode(StoreSection section) { + var wrapper = section.getWrapper(); + if (wrapper != null && !batchModeSelection.getList().contains(wrapper)) { + batchModeSelection.getList().add(wrapper); + } + if (wrapper == null || (wrapper.getValidity().getValue().isUsable() && wrapper.getEntry().getProvider().getUsageCategory() == DataStoreUsageCategory.GROUP)) { + section.getShownChildren().getList().forEach(c -> selectBatchMode(c)); + } + } + + public void unselectBatchMode(StoreSection section) { + var wrapper = section.getWrapper(); + if (wrapper != null) { + batchModeSelection.getList().remove(wrapper); + } + if (wrapper == null || (wrapper.getValidity().getValue().isUsable() && wrapper.getEntry().getProvider().getUsageCategory() == DataStoreUsageCategory.GROUP)) { + section.getShownChildren().getList().forEach(c -> unselectBatchMode(c)); + } + } + + public boolean isSectionSelected(StoreSection section) { + if (section.getWrapper() == null) { + var batchSet = new HashSet<>(batchModeSelection.getList()); + var childSet = section.getShownChildren().getList().stream().map(s -> s.getWrapper()).toList(); + return batchSet.containsAll(childSet); + } + + return getBatchModeSelection().getList().contains(section.getWrapper()); + } + private void updateContent() { categories.getList().forEach(c -> c.update()); allEntries.getList().forEach(e -> e.update()); @@ -115,6 +166,14 @@ public class StoreViewState { }); } + private void initBatchListener() { + allEntries.getList().addListener((ListChangeListener) c -> { + batchModeSelection.getList().removeIf(storeEntryWrapper -> { + return allEntries.getList().contains(storeEntryWrapper); + }); + }); + } + private void initContent() { allEntries .getList() diff --git a/app/src/main/java/io/xpipe/app/core/AppLayoutModel.java b/app/src/main/java/io/xpipe/app/core/AppLayoutModel.java index c65a5b2c5..2804ce710 100644 --- a/app/src/main/java/io/xpipe/app/core/AppLayoutModel.java +++ b/app/src/main/java/io/xpipe/app/core/AppLayoutModel.java @@ -46,7 +46,7 @@ public class AppLayoutModel { } public static void init() { - var state = AppCache.getNonNull("layoutState", SavedState.class, () -> new SavedState(260, 300)); + var state = AppCache.getNonNull("layoutState", SavedState.class, () -> new SavedState(270, 300)); INSTANCE = new AppLayoutModel(state); } diff --git a/app/src/main/java/io/xpipe/app/ext/ActionProvider.java b/app/src/main/java/io/xpipe/app/ext/ActionProvider.java index 60551db5d..57b1ff567 100644 --- a/app/src/main/java/io/xpipe/app/ext/ActionProvider.java +++ b/app/src/main/java/io/xpipe/app/ext/ActionProvider.java @@ -52,6 +52,10 @@ public interface ActionProvider { return null; } + default BatchDataStoreCallSite getBatchDataStoreCallSite() { + return null; + } + default DefaultDataStoreCallSite getDefaultDataStoreCallSite() { return null; } @@ -191,6 +195,41 @@ public interface ActionProvider { } } + interface BatchDataStoreCallSite { + + ObservableValue getName(); + + String getIcon(); + + Class getApplicableClass(); + + default boolean isApplicable(DataStoreEntryRef o) { + return true; + } + + default Action createAction(List> stores) { + var individual = stores.stream().map(ref -> { + return createAction(ref); + }).filter(action -> action != null).toList(); + return new Action() { + @Override + public void execute() throws Exception { + for (Action action : individual) { + action.execute(); + } + } + }; + } + + default Action createAction(DataStoreEntryRef store) { + return null; + } + + default List getChildren(List> batch) { + return List.of(); + } + } + class Loader implements ModuleLayerLoader { @Override diff --git a/app/src/main/java/io/xpipe/app/prefs/AboutCategory.java b/app/src/main/java/io/xpipe/app/prefs/AboutCategory.java index fcb6be37f..8adb1bdc4 100644 --- a/app/src/main/java/io/xpipe/app/prefs/AboutCategory.java +++ b/app/src/main/java/io/xpipe/app/prefs/AboutCategory.java @@ -80,7 +80,7 @@ public class AboutCategory extends AppPrefsCategory { protected Comp create() { var props = createProperties().padding(new Insets(0, 0, 0, 5)); var update = new UpdateCheckComp().grow(true, false); - return new VerticalComp(List.of(props, Comp.separator(), update, Comp.separator(), createLinks())) + return new VerticalComp(List.of(props, Comp.hseparator(), update, Comp.hseparator(), createLinks())) .apply(s -> s.get().setFillWidth(true)) .apply(struc -> struc.get().setSpacing(15)) .styleClass("information") diff --git a/app/src/main/java/io/xpipe/app/prefs/IconsCategory.java b/app/src/main/java/io/xpipe/app/prefs/IconsCategory.java index c37874328..79e8cf76e 100644 --- a/app/src/main/java/io/xpipe/app/prefs/IconsCategory.java +++ b/app/src/main/java/io/xpipe/app/prefs/IconsCategory.java @@ -131,9 +131,9 @@ public class IconsCategory extends AppPrefsCategory { var vbox = new VerticalComp(List.of( Comp.vspacer(10), box, - Comp.separator(), + Comp.hseparator(), refreshButton, - Comp.separator(), + Comp.hseparator(), addDirectoryButton, addGitButton)); vbox.spacing(10); diff --git a/app/src/main/java/io/xpipe/app/util/OptionsBuilder.java b/app/src/main/java/io/xpipe/app/util/OptionsBuilder.java index 1fdf3f4c1..3efc4dd72 100644 --- a/app/src/main/java/io/xpipe/app/util/OptionsBuilder.java +++ b/app/src/main/java/io/xpipe/app/util/OptionsBuilder.java @@ -322,7 +322,7 @@ public class OptionsBuilder { } public OptionsBuilder separator() { - return addComp(Comp.separator()); + return addComp(Comp.hseparator()); } public OptionsBuilder name(String nameKey) { diff --git a/app/src/main/java/io/xpipe/app/util/ScanDialog.java b/app/src/main/java/io/xpipe/app/util/ScanDialog.java index 0ed5fb0bb..4e303240a 100644 --- a/app/src/main/java/io/xpipe/app/util/ScanDialog.java +++ b/app/src/main/java/io/xpipe/app/util/ScanDialog.java @@ -2,76 +2,44 @@ package io.xpipe.app.util; import io.xpipe.app.comp.base.ModalButton; import io.xpipe.app.comp.base.ModalOverlay; -import io.xpipe.app.ext.ScanProvider; import io.xpipe.app.ext.ShellStore; -import io.xpipe.app.issue.ErrorEvent; import io.xpipe.app.storage.DataStoreEntry; -import io.xpipe.core.process.ShellControl; +import io.xpipe.app.storage.DataStoreEntryRef; import io.xpipe.core.process.ShellTtyState; import io.xpipe.core.process.SystemState; -import javafx.collections.ObservableList; +import java.util.List; public class ScanDialog { public static void showAsync(DataStoreEntry entry) { - ThreadHelper.runAsync(() -> { - var showForCon = entry == null - || (entry.getStore() instanceof ShellStore - && (!(entry.getStorePersistentState() instanceof SystemState systemState) - || systemState.getTtyState() == null - || systemState.getTtyState() == ShellTtyState.NONE)); - if (showForCon) { - showForShellStore(entry); - } - }); - } - - public static void showForShellStore(DataStoreEntry initial) { - var action = new ScanDialogAction() { - - @Override - public boolean scan( - ObservableList all, - ObservableList selected, - DataStoreEntry entry, - ShellControl sc) { - if (!sc.canHaveSubshells()) { - return false; - } - - if (!sc.getShellDialect().getDumbMode().supportsAnyPossibleInteraction()) { - return false; - } - - if (sc.getTtyState() != ShellTtyState.NONE) { - return false; - } - - var providers = ScanProvider.getAll(); - for (ScanProvider scanProvider : providers) { - try { - // Previous scan operation could have exited the shell - sc.start(); - ScanProvider.ScanOpportunity operation = scanProvider.create(entry, sc); - if (operation != null) { - if (!operation.isDisabled() && operation.isDefaultSelected()) { - selected.add(operation); - } - all.add(operation); - } - } catch (Exception ex) { - ErrorEvent.fromThrowable(ex).handle(); - } - } - return true; - } - }; - show(initial, action); + var showForCon = entry == null + || (entry.getStore() instanceof ShellStore + && (!(entry.getStorePersistentState() instanceof SystemState systemState) + || systemState.getTtyState() == null + || systemState.getTtyState() == ShellTtyState.NONE)); + if (showForCon) { + show(entry, ScanDialogAction.shellScanAction()); + } } private static void show(DataStoreEntry initialStore, ScanDialogAction action) { - var comp = new ScanDialogComp(initialStore != null ? initialStore.ref() : null, action); + var comp = new ScanSingleDialogComp(initialStore != null ? initialStore.ref() : null, action); + var modal = ModalOverlay.of("scanAlertTitle", comp); + var button = new ModalButton( + "ok", + () -> { + comp.finish(); + }, + false, + true); + button.augment(r -> r.disableProperty().bind(PlatformThread.sync(comp.getBusy()))); + modal.addButton(button); + modal.show(); + } + + public static void showMulti(List> entries, ScanDialogAction action) { + var comp = new ScanMultiDialogComp(entries, action); var modal = ModalOverlay.of("scanAlertTitle", comp); var button = new ModalButton( "ok", diff --git a/app/src/main/java/io/xpipe/app/util/ScanDialogAction.java b/app/src/main/java/io/xpipe/app/util/ScanDialogAction.java index 4b92f63e7..5185a1c10 100644 --- a/app/src/main/java/io/xpipe/app/util/ScanDialogAction.java +++ b/app/src/main/java/io/xpipe/app/util/ScanDialogAction.java @@ -1,13 +1,59 @@ package io.xpipe.app.util; import io.xpipe.app.ext.ScanProvider; +import io.xpipe.app.issue.ErrorEvent; import io.xpipe.app.storage.DataStoreEntry; import io.xpipe.core.process.ShellControl; +import io.xpipe.core.process.ShellTtyState; import javafx.collections.ObservableList; public interface ScanDialogAction { + static ScanDialogAction shellScanAction() { + var action = new ScanDialogAction() { + + @Override + public boolean scan( + ObservableList all, + ObservableList selected, + DataStoreEntry entry, + ShellControl sc) { + if (!sc.canHaveSubshells()) { + return false; + } + + if (!sc.getShellDialect().getDumbMode().supportsAnyPossibleInteraction()) { + return false; + } + + if (sc.getTtyState() != ShellTtyState.NONE) { + return false; + } + + var providers = ScanProvider.getAll(); + for (ScanProvider scanProvider : providers) { + try { + // Previous scan operation could have exited the shell + sc.start(); + ScanProvider.ScanOpportunity operation = scanProvider.create(entry, sc); + if (operation != null) { + if (!operation.isDisabled() && operation.isDefaultSelected()) { + selected.add(operation); + } + all.add(operation); + } + } catch (Exception ex) { + ErrorEvent.fromThrowable(ex).handle(); + } + } + return true; + } + }; + return action; + } + + boolean scan( ObservableList all, ObservableList selected, diff --git a/app/src/main/java/io/xpipe/app/util/ScanDialogBase.java b/app/src/main/java/io/xpipe/app/util/ScanDialogBase.java new file mode 100644 index 000000000..1a7f3f2b1 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/util/ScanDialogBase.java @@ -0,0 +1,139 @@ +package io.xpipe.app.util; + +import io.xpipe.app.comp.Comp; +import io.xpipe.app.comp.base.ListSelectorComp; +import io.xpipe.app.comp.base.LoadingOverlayComp; +import io.xpipe.app.comp.store.StoreChoiceComp; +import io.xpipe.app.comp.store.StoreViewState; +import io.xpipe.app.core.AppI18n; +import io.xpipe.app.ext.ScanProvider; +import io.xpipe.app.ext.ShellStore; +import io.xpipe.app.issue.ErrorEvent; +import io.xpipe.app.storage.DataStorage; +import io.xpipe.app.storage.DataStoreEntry; +import io.xpipe.app.storage.DataStoreEntryRef; +import javafx.application.Platform; +import javafx.beans.property.*; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import lombok.Getter; + +import java.util.ArrayList; +import java.util.function.Function; + +import static javafx.scene.layout.Priority.ALWAYS; + +public class ScanDialogBase { + + private final boolean expand; + private final Runnable closeAction; + private final ScanDialogAction action; + private final ObservableList> entries; + private final ObservableList available = + FXCollections.synchronizedObservableList(FXCollections.observableArrayList()); + private final ListProperty selected = + new SimpleListProperty<>(FXCollections.synchronizedObservableList(FXCollections.observableArrayList())); + + @Getter + private final BooleanProperty busy = new SimpleBooleanProperty(); + + public ScanDialogBase(boolean expand, Runnable closeAction, ScanDialogAction action, ObservableList> entries) { + this.expand = expand; + this.closeAction = closeAction; + this.action = action; + this.entries = entries; + } + + public void finish() throws Exception { + if (entries.isEmpty()) { + return; + } + + BooleanScope.executeExclusive(busy, () -> { + for (var entry : entries) { + if (expand) { + entry.get().setExpanded(true); + } + var copy = new ArrayList<>(selected); + for (var a : copy) { + // If the user decided to remove the selected entry + // while the scan is running, just return instantly + if (!DataStorage.get().getStoreEntriesSet().contains(entry.get())) { + return; + } + + // Previous scan operation could have exited the shell + var sc = entry.getStore().getOrStartSession(); + + try { + a.getProvider().scan(entry.get(), sc); + } catch (Throwable ex) { + ErrorEvent.fromThrowable(ex).handle(); + } + } + } + }); + } + + private void onUpdate() { + available.clear(); + selected.clear(); + + if (entries.isEmpty()) { + return; + } + + ThreadHelper.runFailableAsync(() -> { + BooleanScope.executeExclusive(busy, () -> { + for (var entry : entries) { + boolean r; + try { + var sc = entry.getStore().getOrStartSession(); + r = action.scan(available, selected, entry.get(), sc); + } catch (Throwable t) { + closeAction.run(); + throw t; + } + if (!r) { + closeAction.run(); + } + } + }); + }); + } + + public Comp createContent() { + StackPane stackPane = new StackPane(); + stackPane.getStyleClass().add("scan-list"); + VBox.setVgrow(stackPane, ALWAYS); + + Function nameFunc = (ScanProvider.ScanOpportunity s) -> { + var n = AppI18n.get(s.getNameKey()); + if (s.getLicensedFeatureId() == null) { + return n; + } + + var suffix = LicenseProvider.get().getFeature(s.getLicensedFeatureId()); + return n + suffix.getDescriptionSuffix().map(d -> " (" + d + ")").orElse(""); + }; + var r = new ListSelectorComp<>( + available, + nameFunc, + selected, + scanOperation -> scanOperation.isDisabled(), + () -> available.size() > 3) + .createRegion(); + stackPane.getChildren().add(r); + + onUpdate(); + entries.addListener((ListChangeListener>) c -> onUpdate()); + + var comp = LoadingOverlayComp.noProgress(Comp.of(() -> stackPane), busy) + .vgrow(); + return comp; + } +} diff --git a/app/src/main/java/io/xpipe/app/util/ScanDialogComp.java b/app/src/main/java/io/xpipe/app/util/ScanDialogComp.java deleted file mode 100644 index e73dc352e..000000000 --- a/app/src/main/java/io/xpipe/app/util/ScanDialogComp.java +++ /dev/null @@ -1,170 +0,0 @@ -package io.xpipe.app.util; - -import io.xpipe.app.comp.Comp; -import io.xpipe.app.comp.base.ListSelectorComp; -import io.xpipe.app.comp.base.LoadingOverlayComp; -import io.xpipe.app.comp.base.ModalOverlayContentComp; -import io.xpipe.app.comp.store.StoreChoiceComp; -import io.xpipe.app.comp.store.StoreViewState; -import io.xpipe.app.core.AppI18n; -import io.xpipe.app.ext.ScanProvider; -import io.xpipe.app.ext.ShellStore; -import io.xpipe.app.issue.ErrorEvent; -import io.xpipe.app.storage.DataStorage; -import io.xpipe.app.storage.DataStoreEntryRef; - -import javafx.application.Platform; -import javafx.beans.property.*; -import javafx.collections.FXCollections; -import javafx.collections.ObservableList; -import javafx.scene.layout.Region; -import javafx.scene.layout.StackPane; -import javafx.scene.layout.VBox; - -import lombok.Getter; - -import java.util.ArrayList; -import java.util.function.Function; - -import static javafx.scene.layout.Priority.ALWAYS; - -class ScanDialogComp extends ModalOverlayContentComp { - - private final DataStoreEntryRef initialStore; - private final ScanDialogAction action; - private final ObjectProperty> entry; - private final ObservableList available = - FXCollections.synchronizedObservableList(FXCollections.observableArrayList()); - private final ListProperty selected = - new SimpleListProperty<>(FXCollections.synchronizedObservableList(FXCollections.observableArrayList())); - - @Getter - private final BooleanProperty busy = new SimpleBooleanProperty(); - - ScanDialogComp(DataStoreEntryRef entry, ScanDialogAction action) { - this.initialStore = entry; - this.entry = new SimpleObjectProperty<>(entry); - this.action = action; - } - - protected void finish() { - ThreadHelper.runFailableAsync(() -> { - if (entry.get() == null) { - return; - } - - Platform.runLater(() -> { - var modal = getModalOverlay(); - if (modal != null) { - modal.close(); - } - }); - - BooleanScope.executeExclusive(busy, () -> { - entry.get().get().setExpanded(true); - var copy = new ArrayList<>(selected); - for (var a : copy) { - // If the user decided to remove the selected entry - // while the scan is running, just return instantly - if (!DataStorage.get() - .getStoreEntriesSet() - .contains(entry.get().get())) { - return; - } - - // Previous scan operation could have exited the shell - var sc = entry.get().getStore().getOrStartSession(); - - try { - a.getProvider().scan(entry.get().get(), sc); - } catch (Throwable ex) { - ErrorEvent.fromThrowable(ex).handle(); - } - } - }); - }); - } - - private void onUpdate(DataStoreEntryRef newValue) { - available.clear(); - selected.clear(); - - if (newValue == null) { - return; - } - - ThreadHelper.runFailableAsync(() -> { - BooleanScope.executeExclusive(busy, () -> { - boolean r; - try { - var sc = entry.get().getStore().getOrStartSession(); - r = action.scan(available, selected, newValue.get(), sc); - } catch (Throwable t) { - var modal = getModalOverlay(); - if (initialStore != null && modal != null) { - modal.close(); - } - throw t; - } - if (!r) { - var modal = getModalOverlay(); - if (initialStore != null && modal != null) { - modal.close(); - } - } - }); - }); - } - - @Override - protected Region createSimple() { - StackPane stackPane = new StackPane(); - stackPane.getStyleClass().add("scan-list"); - - var b = new OptionsBuilder() - .name("scanAlertChoiceHeader") - .description("scanAlertChoiceHeaderDescription") - .addComp(new StoreChoiceComp<>( - StoreChoiceComp.Mode.OTHER, - null, - entry, - ShellStore.class, - store1 -> true, - StoreViewState.get().getAllConnectionsCategory()) - .disable(busy.or(new SimpleBooleanProperty(initialStore != null)))) - .name("scanAlertHeader") - .description("scanAlertHeaderDescription") - .addComp(LoadingOverlayComp.noProgress(Comp.of(() -> stackPane), busy) - .vgrow()) - .buildComp() - .prefWidth(500) - .prefHeight(680) - .apply(struc -> { - VBox.setVgrow(struc.get().getChildren().get(1), ALWAYS); - }); - - Function nameFunc = (ScanProvider.ScanOpportunity s) -> { - var n = AppI18n.get(s.getNameKey()); - if (s.getLicensedFeatureId() == null) { - return n; - } - - var suffix = LicenseProvider.get().getFeature(s.getLicensedFeatureId()); - return n + suffix.getDescriptionSuffix().map(d -> " (" + d + ")").orElse(""); - }; - var r = new ListSelectorComp<>( - available, - nameFunc, - selected, - scanOperation -> scanOperation.isDisabled(), - () -> available.size() > 3) - .createRegion(); - stackPane.getChildren().add(r); - - entry.subscribe(newValue -> { - onUpdate(newValue); - }); - - return b.createRegion(); - } -} diff --git a/app/src/main/java/io/xpipe/app/util/ScanMultiDialogComp.java b/app/src/main/java/io/xpipe/app/util/ScanMultiDialogComp.java new file mode 100644 index 000000000..4f48a1681 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/util/ScanMultiDialogComp.java @@ -0,0 +1,61 @@ +package io.xpipe.app.util; + +import io.xpipe.app.comp.base.ModalOverlayContentComp; +import io.xpipe.app.comp.store.StoreChoiceComp; +import io.xpipe.app.comp.store.StoreViewState; +import io.xpipe.app.ext.ShellStore; +import io.xpipe.app.storage.DataStoreEntryRef; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; + +import java.util.List; + +import static javafx.scene.layout.Priority.ALWAYS; + +class ScanMultiDialogComp extends ModalOverlayContentComp { + + private final ScanDialogBase base; + + ScanMultiDialogComp(List> entries, ScanDialogAction action) { + ObservableList> list = FXCollections.observableArrayList(entries); + this.base = new ScanDialogBase(true, () -> { + var modal = getModalOverlay(); + if (modal != null) { + modal.close(); + } + }, action, list); + } + + void finish() { + ThreadHelper.runFailableAsync(() -> { + base.finish(); + }); + } + + BooleanProperty getBusy() { + return base.getBusy(); + } + + @Override + protected Region createSimple() { + var list = base.createContent(); + var b = new OptionsBuilder() + .name("scanAlertHeader") + .description("scanAlertHeaderDescription") + .addComp(list.vgrow()) + .buildComp() + .prefWidth(500) + .prefHeight(680) + .apply(struc -> { + VBox.setVgrow(struc.get().getChildren().getFirst(), ALWAYS); + });; + return b.createRegion(); + } +} diff --git a/app/src/main/java/io/xpipe/app/util/ScanSingleDialogComp.java b/app/src/main/java/io/xpipe/app/util/ScanSingleDialogComp.java new file mode 100644 index 000000000..e08482a9b --- /dev/null +++ b/app/src/main/java/io/xpipe/app/util/ScanSingleDialogComp.java @@ -0,0 +1,80 @@ +package io.xpipe.app.util; + +import io.xpipe.app.comp.base.ModalOverlayContentComp; +import io.xpipe.app.comp.store.StoreChoiceComp; +import io.xpipe.app.comp.store.StoreViewState; +import io.xpipe.app.ext.ShellStore; +import io.xpipe.app.storage.DataStoreEntryRef; + +import javafx.beans.property.*; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; + +import static javafx.scene.layout.Priority.ALWAYS; + +class ScanSingleDialogComp extends ModalOverlayContentComp { + + private final DataStoreEntryRef initialStore; + private final ObjectProperty> entry; + private final ScanDialogBase base; + + @SuppressWarnings("unchecked") + ScanSingleDialogComp(DataStoreEntryRef entry, ScanDialogAction action) { + this.initialStore = entry; + this.entry = new SimpleObjectProperty<>(entry); + + ObservableList> list = FXCollections.observableArrayList(); + this.entry.subscribe(v -> { + if (v != null) { + list.setAll(v); + } else { + list.clear(); + } + }); + this.base = new ScanDialogBase(true, () -> { + var modal = getModalOverlay(); + if (initialStore != null && modal != null) { + modal.close(); + } + }, action, list); + } + + void finish() { + ThreadHelper.runFailableAsync(() -> { + base.finish(); + }); + } + + BooleanProperty getBusy() { + return base.getBusy(); + } + + @Override + protected Region createSimple() { + var list = base.createContent(); + var b = new OptionsBuilder() + .name("scanAlertChoiceHeader") + .description("scanAlertChoiceHeaderDescription") + .addComp(new StoreChoiceComp<>( + StoreChoiceComp.Mode.OTHER, + null, + entry, + ShellStore.class, + store1 -> true, + StoreViewState.get().getAllConnectionsCategory()) + .disable(base.getBusy().or(new SimpleBooleanProperty(initialStore != null)))) + .name("scanAlertHeader") + .description("scanAlertHeaderDescription") + .addComp(list.vgrow()) + .buildComp() + .prefWidth(500) + .prefHeight(680) + .apply(struc -> { + VBox.setVgrow(struc.get().getChildren().get(1), ALWAYS); + }); + return b.createRegion(); + } +} diff --git a/app/src/main/java/module-info.java b/app/src/main/java/module-info.java index ea9a139a5..c45874b1a 100644 --- a/app/src/main/java/module-info.java +++ b/app/src/main/java/module-info.java @@ -55,7 +55,6 @@ open module io.xpipe.app { requires io.xpipe.modulefs; requires io.xpipe.core; requires static lombok; - requires java.desktop; requires org.apache.commons.io; requires org.apache.commons.lang3; requires javafx.base; @@ -91,6 +90,7 @@ open module io.xpipe.app { requires jdk.jdwp.agent; requires java.net.http; requires org.bouncycastle.provider; + requires org.jetbrains.annotations; uses TerminalLauncher; uses io.xpipe.app.ext.ActionProvider; diff --git a/app/src/main/resources/io/xpipe/app/resources/style/store-entry-comp.css b/app/src/main/resources/io/xpipe/app/resources/style/store-entry-comp.css index 442922075..d84ada5f4 100644 --- a/app/src/main/resources/io/xpipe/app/resources/style/store-entry-comp.css +++ b/app/src/main/resources/io/xpipe/app/resources/style/store-entry-comp.css @@ -1,3 +1,13 @@ +.store-entry-list-status-bar { +-fx-padding: 0 8 0 8; + -fx-border-radius: 0 0 4 4; + -fx-background-radius: 0 0 4 4; +} + +.store-entry-list-status-bar .button { +-fx-padding: 4 8; +} + .store-list-comp.scroll-pane * { -fx-icon-color: -color-fg-default; } diff --git a/ext/base/src/main/java/io/xpipe/ext/base/action/RunScriptActionMenu.java b/ext/base/src/main/java/io/xpipe/ext/base/action/RunScriptActionMenu.java index 76f6eda65..b2f518137 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/action/RunScriptActionMenu.java +++ b/ext/base/src/main/java/io/xpipe/ext/base/action/RunScriptActionMenu.java @@ -10,6 +10,7 @@ import io.xpipe.app.storage.DataStoreEntryRef; import io.xpipe.app.terminal.TerminalLauncher; import io.xpipe.core.process.ShellTtyState; import io.xpipe.core.process.SystemState; +import io.xpipe.core.store.DataStore; import io.xpipe.ext.base.script.ScriptHierarchy; import javafx.beans.property.SimpleStringProperty; @@ -71,6 +72,40 @@ public class RunScriptActionMenu implements ActionProvider { } }; } + + @Override + public BatchDataStoreCallSite getBatchDataStoreCallSite() { + return new BatchDataStoreCallSite() { + + @Override + public ObservableValue getName() { + var t = AppPrefs.get().terminalType().getValue(); + return AppI18n.observable( + "executeInTerminal", + t != null ? t.toTranslatedString().getValue() : "?"); + } + + @Override + public String getIcon() { + return "mdi2d-desktop-mac"; + } + + @Override + public Class getApplicableClass() { + return ShellStore.class; + } + + @Override + public ActionProvider.Action createAction(DataStoreEntryRef store) { + return new Action(store); + } + + @Override + public List getChildren(List> batch) { + return List.of(); + } + }; + } } @Value @@ -116,6 +151,37 @@ public class RunScriptActionMenu implements ActionProvider { } }; } + + @Override + public BatchDataStoreCallSite getBatchDataStoreCallSite() { + return new BatchDataStoreCallSite() { + + @Override + public Class getApplicableClass() { + return ShellStore.class; + } + + @Override + public ObservableValue getName() { + return AppI18n.observable("executeInBackground"); + } + + @Override + public String getIcon() { + return "mdi2f-flip-to-back"; + } + + @Override + public ActionProvider.Action createAction(DataStoreEntryRef store) { + return new Action(store); + } + + @Override + public List getChildren(List> batch) { + return List.of(); + } + }; + } } @Value @@ -189,6 +255,44 @@ public class RunScriptActionMenu implements ActionProvider { } }; } + + @Override + public BatchDataStoreCallSite getBatchDataStoreCallSite() { + return new BatchDataStoreCallSite() { + + @Override + public ObservableValue getName() { + return new SimpleStringProperty(hierarchy.getBase().get().getName()); + } + + @Override + public String getIcon() { + return "mdi2p-play-box-multiple-outline"; + } + + @Override + public Class getApplicableClass() { + return ShellStore.class; + } + + @Override + public Action createAction(DataStoreEntryRef store) { + return null; + } + + @Override + public List getChildren(List> batch) { + if (hierarchy.isLeaf()) { + return List.of( + new TerminalRunActionProvider(hierarchy), new BackgroundRunActionProvider(hierarchy)); + } + + return hierarchy.getChildren().stream() + .map(c -> new ScriptActionProvider(c)) + .toList(); + } + }; + } } private static class NoScriptsActionProvider implements ActionProvider { @@ -225,6 +329,36 @@ public class RunScriptActionMenu implements ActionProvider { } }; } + + @Override + public BatchDataStoreCallSite getBatchDataStoreCallSite() { + return new BatchDataStoreCallSite() { + @Override + public ObservableValue getName() { + return AppI18n.observable("noScriptsAvailable"); + } + + @Override + public String getIcon() { + return "mdi2i-image-filter-none"; + } + + @Override + public Class getApplicableClass() { + return ShellStore.class; + } + + @Override + public ActionProvider.Action createAction(DataStoreEntryRef store) { + return null; + } + + @Override + public List getChildren(List> batch) { + return List.of(); + } + }; + } } private static class NoStateActionProvider implements ActionProvider { @@ -342,4 +476,49 @@ public class RunScriptActionMenu implements ActionProvider { } }; } + + @Override + public BatchDataStoreCallSite getBatchDataStoreCallSite() { + return new BatchDataStoreCallSite() { + + @Override + public Class getApplicableClass() { + return ShellStore.class; + } + + @Override + public ObservableValue getName() { + return AppI18n.observable("runScript"); + } + + @Override + public String getIcon() { + return "mdi2p-play-box-multiple-outline"; + } + + @Override + public Action createAction(DataStoreEntryRef store) { + return null; + } + + @Override + public List getChildren(List> batch) { + var hierarchy = ScriptHierarchy.buildEnabledHierarchy(ref -> { + if (!ref.getStore().isRunnableScript()) { + return false; + } + + return true; + }); + var list = hierarchy.getChildren().stream() + .map(c -> new ScriptActionProvider(c)) + .toList(); + if (list.isEmpty()) { + return List.of(new NoScriptsActionProvider()); + } else { + return list; + } + } + }; + } } diff --git a/ext/base/src/main/java/io/xpipe/ext/base/action/ScanStoreAction.java b/ext/base/src/main/java/io/xpipe/ext/base/action/ScanStoreAction.java index d9eeec600..c810ed7f9 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/action/ScanStoreAction.java +++ b/ext/base/src/main/java/io/xpipe/ext/base/action/ScanStoreAction.java @@ -6,6 +6,7 @@ import io.xpipe.app.ext.ShellStore; import io.xpipe.app.storage.DataStoreEntry; import io.xpipe.app.storage.DataStoreEntryRef; import io.xpipe.app.util.ScanDialog; +import io.xpipe.app.util.ScanDialogAction; import io.xpipe.core.process.ShellTtyState; import io.xpipe.core.process.SystemState; @@ -13,6 +14,8 @@ import javafx.beans.value.ObservableValue; import lombok.Value; +import java.util.List; + public class ScanStoreAction implements ActionProvider { @Override @@ -61,6 +64,34 @@ public class ScanStoreAction implements ActionProvider { }; } + @Override + public BatchDataStoreCallSite getBatchDataStoreCallSite() { + return new BatchDataStoreCallSite() { + + @Override + public ObservableValue getName() { + return AppI18n.observable("addConnections"); + } + + @Override + public String getIcon() { + return "mdi2l-layers-plus"; + } + + @Override + public Class getApplicableClass() { + return ShellStore.class; + } + + @Override + public ActionProvider.Action createAction(List> stores) { + return () -> { + ScanDialog.showMulti(stores, ScanDialogAction.shellScanAction()); + }; + } + }; + } + @Value static class Action implements ActionProvider.Action { @@ -69,7 +100,7 @@ public class ScanStoreAction implements ActionProvider { @Override public void execute() { if (entry == null || entry.getStore() instanceof ShellStore) { - ScanDialog.showForShellStore(entry); + ScanDialog.showAsync(entry); } } } diff --git a/ext/base/src/main/java/io/xpipe/ext/base/service/ServiceRefreshAction.java b/ext/base/src/main/java/io/xpipe/ext/base/service/ServiceRefreshAction.java index b3abd527c..6c03be288 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/service/ServiceRefreshAction.java +++ b/ext/base/src/main/java/io/xpipe/ext/base/service/ServiceRefreshAction.java @@ -47,6 +47,37 @@ public class ServiceRefreshAction implements ActionProvider { }; } + @Override + public BatchDataStoreCallSite getBatchDataStoreCallSite() { + return new BatchDataStoreCallSite() { + + @Override + public boolean isApplicable(DataStoreEntryRef o) { + return o.getStore().allowManualServicesRefresh(); + } + + @Override + public ObservableValue getName() { + return AppI18n.observable("refreshServices"); + } + + @Override + public String getIcon() { + return "mdi2w-web"; + } + + @Override + public Class getApplicableClass() { + return FixedServiceCreatorStore.class; + } + + @Override + public ActionProvider.Action createAction(DataStoreEntryRef store) { + return new Action(store); + } + }; + } + @Value static class Action implements ActionProvider.Action { diff --git a/ext/base/src/main/java/io/xpipe/ext/base/store/StorePauseAction.java b/ext/base/src/main/java/io/xpipe/ext/base/store/StorePauseAction.java index 44fea90de..d581300ec 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/store/StorePauseAction.java +++ b/ext/base/src/main/java/io/xpipe/ext/base/store/StorePauseAction.java @@ -41,6 +41,32 @@ public class StorePauseAction implements ActionProvider { }; } + @Override + public BatchDataStoreCallSite getBatchDataStoreCallSite() { + return new BatchDataStoreCallSite() { + + @Override + public ObservableValue getName() { + return AppI18n.observable("pause"); + } + + @Override + public String getIcon() { + return "mdi2p-pause"; + } + + @Override + public Class getApplicableClass() { + return PauseableStore.class; + } + + @Override + public ActionProvider.Action createAction(DataStoreEntryRef store) { + return new Action(store); + } + }; + } + @Value static class Action implements ActionProvider.Action { diff --git a/ext/base/src/main/java/io/xpipe/ext/base/store/StoreRestartAction.java b/ext/base/src/main/java/io/xpipe/ext/base/store/StoreRestartAction.java index 4384bc1a9..32a97bf38 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/store/StoreRestartAction.java +++ b/ext/base/src/main/java/io/xpipe/ext/base/store/StoreRestartAction.java @@ -47,6 +47,37 @@ public class StoreRestartAction implements ActionProvider { }; } + @Override + public BatchDataStoreCallSite getBatchDataStoreCallSite() { + return new BatchDataStoreCallSite() { + + @Override + public ObservableValue getName() { + return AppI18n.observable("restart"); + } + + @Override + public String getIcon() { + return "mdi2r-restart"; + } + + @Override + public Class getApplicableClass() { + return DataStore.class; + } + + @Override + public ActionProvider.Action createAction(DataStoreEntryRef store) { + return new Action(store); + } + + @Override + public boolean isApplicable(DataStoreEntryRef o) { + return o.getStore() instanceof StartableStore && o.getStore() instanceof StoppableStore; + } + }; + } + @Value static class Action implements ActionProvider.Action { diff --git a/ext/base/src/main/java/io/xpipe/ext/base/store/StoreStartAction.java b/ext/base/src/main/java/io/xpipe/ext/base/store/StoreStartAction.java index 8e5dcac28..4e4526149 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/store/StoreStartAction.java +++ b/ext/base/src/main/java/io/xpipe/ext/base/store/StoreStartAction.java @@ -41,6 +41,32 @@ public class StoreStartAction implements ActionProvider { }; } + @Override + public BatchDataStoreCallSite getBatchDataStoreCallSite() { + return new BatchDataStoreCallSite() { + + @Override + public ObservableValue getName() { + return AppI18n.observable("start"); + } + + @Override + public String getIcon() { + return "mdi2p-play"; + } + + @Override + public Class getApplicableClass() { + return StartableStore.class; + } + + @Override + public ActionProvider.Action createAction(DataStoreEntryRef store) { + return new Action(store); + } + }; + } + @Value static class Action implements ActionProvider.Action { diff --git a/ext/base/src/main/java/io/xpipe/ext/base/store/StoreStopAction.java b/ext/base/src/main/java/io/xpipe/ext/base/store/StoreStopAction.java index cd259b679..83ea47538 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/store/StoreStopAction.java +++ b/ext/base/src/main/java/io/xpipe/ext/base/store/StoreStopAction.java @@ -41,6 +41,32 @@ public class StoreStopAction implements ActionProvider { }; } + @Override + public BatchDataStoreCallSite getBatchDataStoreCallSite() { + return new BatchDataStoreCallSite() { + + @Override + public ObservableValue getName() { + return AppI18n.observable("stop"); + } + + @Override + public String getIcon() { + return "mdi2s-stop"; + } + + @Override + public Class getApplicableClass() { + return StoppableStore.class; + } + + @Override + public ActionProvider.Action createAction(DataStoreEntryRef store) { + return new Action(store); + } + }; + } + @Value static class Action implements ActionProvider.Action { diff --git a/lang/strings/translations_en.properties b/lang/strings/translations_en.properties index a18baee00..32d1efa74 100644 --- a/lang/strings/translations_en.properties +++ b/lang/strings/translations_en.properties @@ -33,7 +33,7 @@ addCommand=Command ... addAutomatically=Search Automatically ... addOther=Add Other ... addConnection=Add Connection -addConnections=New +new=New selectType=Select Type selectTypeDescription=Select connection type #context: computer shell program @@ -672,7 +672,6 @@ openFileWith=Open with ... openWithDefaultApplication=Open with default application rename=Rename run=Run -new=New openInTerminal=Open in terminal file=File directory=Directory @@ -858,9 +857,11 @@ sshConfig=SSH config files autostart=Automatically connect on XPipe startup acceptHostKey=Accept host key modifyHostKeyPermissions=Modify host key permissions -attachContainer=Attach to container +#force +attachContainer=Attach openInVsCode=Open in VSCode -containerLogs=Show container logs +#force +containerLogs=Show logs openSftpClient=Open in external SFTP client openTermius=Open in Termius showInternalInstances=Show internal instances @@ -945,10 +946,10 @@ k8sCmd.displayName=kubectl client k8sCmd.displayDescription=Access Kubernetes clusters via kubectl k8sClusters=Kubernetes clusters shells=Available shells -startContainer=Start container -stopContainer=Stop container -inspectContainer=Inspect container -inspectContext=Inspect context +#force +inspectContainer=Inspect +#force +inspectContext=Inspect k8sClusterNameDescription=The name of the context the cluster is in. #context: kubernetes pod=Pod @@ -960,7 +961,8 @@ k8sClusterNamespaceDescription=The custom namespace or the default one if empty k8sConfigLocation=Config file k8sConfigLocationDescription=The custom kubeconfig file or the default one if left empty #context: kubernetes -inspectPod=Inspect pod +#force +inspectPod=Inspect showAllContainers=Show non-running containers showAllPods=Show non-running pods k8sPodHostDescription=The host on which the pod is located @@ -1348,3 +1350,6 @@ noScriptStateAvailable=Refresh to determine script compatibility ... documentationDescription=Check out the documentation disableApiHttpsTlsCheck=Disable API HTTPS request certificate verification disableApiHttpsTlsCheckDescription=If your organization is decrypting your HTTPS traffic in firewalls using SSL interception, any update checks or license checks will fail due to the certificates not matching up. You can fix this by enabling this option and disabling TLS certificate validation. +connectionsSelected=$NUMBER$ connections selected +#force +addConnections=Add connections