diff --git a/app/src/main/java/io/xpipe/app/hub/comp/DenseStoreEntryComp.java b/app/src/main/java/io/xpipe/app/hub/comp/DenseStoreEntryComp.java index a817320e1..071f5af85 100644 --- a/app/src/main/java/io/xpipe/app/hub/comp/DenseStoreEntryComp.java +++ b/app/src/main/java/io/xpipe/app/hub/comp/DenseStoreEntryComp.java @@ -69,6 +69,7 @@ public class DenseStoreEntryComp extends StoreEntryComp { var grid = new GridPane(); grid.setHgap(8); + var tags = createTags().createRegion(); var index = createOrderIndex().createRegion(); var name = createName().createRegion(); name.maxWidthProperty() @@ -111,7 +112,7 @@ public class DenseStoreEntryComp extends StoreEntryComp { grid.getColumnConstraints().addAll(nameCC); var active = new StoreActiveComp(getWrapper()).createRegion(); - var nameBox = new HBox(name, index, userIcon, pinIcon, notes); + var nameBox = new HBox(name, tags, index, userIcon, pinIcon, notes); getWrapper().getSessionActive().subscribe(aBoolean -> { if (!aBoolean) { nameBox.getChildren().remove(active); @@ -119,7 +120,7 @@ public class DenseStoreEntryComp extends StoreEntryComp { nameBox.getChildren().add(1, active); } }); - nameBox.setSpacing(6); + nameBox.setSpacing(4); nameBox.setAlignment(Pos.CENTER_LEFT); grid.addRow(0, nameBox); diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StandardStoreEntryComp.java b/app/src/main/java/io/xpipe/app/hub/comp/StandardStoreEntryComp.java index 7f4c2dfae..f9e388f94 100644 --- a/app/src/main/java/io/xpipe/app/hub/comp/StandardStoreEntryComp.java +++ b/app/src/main/java/io/xpipe/app/hub/comp/StandardStoreEntryComp.java @@ -30,6 +30,7 @@ public class StandardStoreEntryComp extends StoreEntryComp { protected Region createContent() { var name = createName().createRegion(); + var tags = createTags().createRegion(); var index = createOrderIndex().createRegion(); var notes = new StoreNotesComp(getWrapper()).createRegion(); var userIcon = createUserIcon().createRegion(); @@ -55,8 +56,8 @@ public class StandardStoreEntryComp extends StoreEntryComp { grid.getColumnConstraints().add(new ColumnConstraints(52)); var active = new StoreActiveComp(getWrapper()).createRegion(); - var nameBox = new HBox(name, index, userIcon, pinIcon, notes); - nameBox.setSpacing(6); + var nameBox = new HBox(name, tags, index, userIcon, pinIcon, notes); + nameBox.setSpacing(4); nameBox.setAlignment(Pos.CENTER_LEFT); grid.add(nameBox, 2, 0); GridPane.setVgrow(nameBox, Priority.ALWAYS); diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreEntryComp.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreEntryComp.java index 13fe76ae0..0206dfb95 100644 --- a/app/src/main/java/io/xpipe/app/hub/comp/StoreEntryComp.java +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreEntryComp.java @@ -1,6 +1,7 @@ package io.xpipe.app.hub.comp; import io.xpipe.app.action.ActionProvider; +import io.xpipe.app.browser.action.impl.ChmodActionProvider; import io.xpipe.app.comp.Comp; import io.xpipe.app.comp.SimpleComp; import io.xpipe.app.comp.augment.ContextMenuAugment; @@ -19,12 +20,10 @@ import io.xpipe.app.util.*; import io.xpipe.core.OsType; import javafx.beans.binding.Bindings; -import javafx.beans.property.IntegerProperty; -import javafx.beans.property.SimpleBooleanProperty; -import javafx.beans.property.SimpleIntegerProperty; -import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.*; import javafx.beans.value.ObservableDoubleValue; import javafx.css.PseudoClass; +import javafx.event.ActionEvent; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; @@ -238,6 +237,17 @@ public abstract class StoreEntryComp extends SimpleComp { return name; } + protected Comp createTags() { + var tagsProp = Bindings.createStringBinding(() -> { + return getWrapper().getTags().stream().map(s -> { + return "[" + s + "]"; + }).collect(Collectors.joining(" ")); + }, getWrapper().getTags()); + var tagsLabel = new LabelComp(tagsProp); + tagsLabel.apply(struc -> struc.get().setOpacity(0.85)); + return tagsLabel; + } + protected Comp createOrderIndex() { var prop = new SimpleStringProperty(); getWrapper().getOrderIndex().subscribe(number -> { @@ -247,6 +257,7 @@ public abstract class StoreEntryComp extends SimpleComp { }); var comp = new LabelComp(prop); comp.styleClass("orderIndex"); + comp.apply(struc -> struc.get().setOpacity(0.85)); return comp; } @@ -260,6 +271,7 @@ public abstract class StoreEntryComp extends SimpleComp { struc.get().setOpacity(1.0); }); button.hide(Bindings.not(getWrapper().getPerUser())); + button.apply(struc -> struc.get().setOpacity(0.85)); return button; } @@ -272,6 +284,7 @@ public abstract class StoreEntryComp extends SimpleComp { struc.get().setOpacity(1.0); }); button.hide(Bindings.not(getWrapper().getPinToTop())); + button.apply(struc -> struc.get().setOpacity(0.85)); return button; } @@ -502,6 +515,48 @@ public abstract class StoreEntryComp extends SimpleComp { } { + var tags = new Menu(AppI18n.get("tags"), new FontIcon("mdi2t-tag-text-outline")); + + var allTags = StoreViewState.get().getAllAvailableTags(); + for (String tag : allTags) { + var tagItem = new MenuItem(tag); + if (getWrapper().getTags().contains(tag)) { + tagItem.setGraphic(new FontIcon("mdi2c-check")); + } + tagItem.addEventFilter(ActionEvent.ACTION, event -> { + getWrapper().toggleTag(tag); + event.consume(); + }); + tags.getItems().add(tagItem); + } + + if (allTags.size() > 0) { + tags.getItems().add(new SeparatorMenuItem()); + } + + var index = ContextMenuHelper.item(new LabelGraphic.IconGraphic("mdi2t-tag-plus-outline"), "createTag"); + index.setOnAction(event -> { + var tagName = new SimpleStringProperty(); + var modal = ModalOverlay.of( + "addNewTag", + Comp.of(() -> { + var creationName = new TextField(); + creationName.textProperty().bindBidirectional(tagName); + return creationName; + }) + .prefWidth(350)); + modal.withDefaultButtons(() -> { + getWrapper().getEntry().addTag(tagName.getValue()); + }); + modal.show(); + event.consume(); + }); + tags.getItems().add(index); + + items.add(tags); + } + + { var order = new Menu(AppI18n.get("order"), new FontIcon("mdi2f-format-list-bulleted")); var index = new MenuItem(AppI18n.get("index"), new FontIcon("mdi2o-order-numeric-ascending")); diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreEntryWrapper.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreEntryWrapper.java index 3cf3264f3..a77a3d6d0 100644 --- a/app/src/main/java/io/xpipe/app/hub/comp/StoreEntryWrapper.java +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreEntryWrapper.java @@ -13,6 +13,8 @@ import io.xpipe.app.hub.action.HubLeafProvider; import io.xpipe.app.hub.action.HubMenuItemProvider; import io.xpipe.app.hub.action.impl.EditHubLeafProvider; import io.xpipe.app.issue.ErrorEventFactory; +import io.xpipe.app.platform.BindingsHelper; +import io.xpipe.app.platform.DerivedObservableList; import io.xpipe.app.platform.PlatformThread; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.storage.DataStorage; @@ -27,6 +29,7 @@ import javafx.beans.property.*; import javafx.beans.value.ObservableValue; import javafx.collections.FXCollections; +import javafx.collections.ObservableList; import lombok.Getter; import java.time.Duration; @@ -71,6 +74,7 @@ public class StoreEntryWrapper { private final BooleanProperty pinToTop = new SimpleBooleanProperty(); private final IntegerProperty orderIndex = new SimpleIntegerProperty(); private final BooleanProperty effectiveBusy = new SimpleBooleanProperty(); + private final ObservableList tags = FXCollections.observableArrayList(); private boolean effectiveBusyProviderBound = false; public StoreEntryWrapper(DataStoreEntry entry) { @@ -212,6 +216,9 @@ public class StoreEntryWrapper { && entry.isPerUserStore()); pinToTop.setValue(entry.isPinToTop()); + var orderedTags = entry.getTags().stream().sorted().toList(); + DerivedObservableList.wrap(tags, true).setContent(orderedTags); + var storeChanged = store.getValue() != entry.getStore(); store.setValue(entry.getStore()); if (storeChanged || !information.isBound()) { @@ -400,6 +407,14 @@ public class StoreEntryWrapper { return Optional.of(StoreViewState.get().getCategoryWrapper(cat.get())); } + public void toggleTag(String tag) { + if (tags.contains(tag)) { + entry.removeTag(tag); + } else { + entry.addTag(tag); + } + } + public void mergeBreakOutCategory() { ThreadHelper.runAsync(() -> { DataStorage.get().mergeBreakOutCategory(entry); @@ -539,6 +554,10 @@ public class StoreEntryWrapper { return true; } + if (tags.stream().anyMatch(s -> s.toLowerCase().contains(filter.toLowerCase()))) { + return true; + } + return false; } diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreNotesComp.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreNotesComp.java index f148125f2..ab8213388 100644 --- a/app/src/main/java/io/xpipe/app/hub/comp/StoreNotesComp.java +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreNotesComp.java @@ -42,6 +42,7 @@ public class StoreNotesComp extends Comp { .hide(BindingsHelper.map(n, s -> s.getCommited() == null && s.getCurrent() == null)) .createStructure() .get(); + button.setOpacity(0.85); button.prefWidthProperty().bind(button.heightProperty()); var prop = new SimpleStringProperty(n.getValue().getCurrent()); diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreViewState.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreViewState.java index 6a759e5e2..e845fd089 100644 --- a/app/src/main/java/io/xpipe/app/hub/comp/StoreViewState.java +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreViewState.java @@ -152,6 +152,14 @@ public class StoreViewState { return INSTANCE; } + public List getAllAvailableTags() { + var l = new LinkedHashSet(); + for (StoreEntryWrapper storeEntryWrapper : getAllEntries().getList()) { + l.addAll(storeEntryWrapper.getTags()); + } + return l.stream().sorted().toList(); + } + public ObservableIntegerValue entriesCount(Predicate filter, Observable... observables) { return Bindings.size(allEntries .filtered( diff --git a/app/src/main/java/io/xpipe/app/storage/DataStoreEntry.java b/app/src/main/java/io/xpipe/app/storage/DataStoreEntry.java index 8169b32c9..11115b64f 100644 --- a/app/src/main/java/io/xpipe/app/storage/DataStoreEntry.java +++ b/app/src/main/java/io/xpipe/app/storage/DataStoreEntry.java @@ -88,6 +88,8 @@ public class DataStoreEntry extends StorageElement { @NonFinal UUID breakOutCategory; + List tags; + private DataStoreEntry( Path directory, UUID uuid, @@ -107,7 +109,8 @@ public class DataStoreEntry extends StorageElement { boolean freeze, boolean pinToTop, int orderIndex, - UUID breakOutCategory) { + UUID breakOutCategory, List tags + ) { super(directory, uuid, name, lastUsed, lastModified, expanded, dirty); this.color = color; this.categoryUuid = categoryUuid; @@ -123,6 +126,7 @@ public class DataStoreEntry extends StorageElement { this.pinToTop = pinToTop; this.orderIndex = orderIndex; this.breakOutCategory = breakOutCategory; + this.tags = tags; } public static DataStoreEntry createTempWrapper(@NonNull DataStore store) { @@ -147,7 +151,8 @@ public class DataStoreEntry extends StorageElement { false, false, 0, - null); + null, + new ArrayList<>()); } public static DataStoreEntry createNew(@NonNull NameableStore store) { @@ -191,7 +196,8 @@ public class DataStoreEntry extends StorageElement { false, false, 0, - null); + null, + new ArrayList<>()); return entry; } @@ -243,6 +249,18 @@ public class DataStoreEntry extends StorageElement { var pinToTop = Optional.ofNullable(json.get("pinToTop")) .map(jsonNode -> jsonNode.booleanValue()) .orElse(false); + var tags = Optional.ofNullable(json.get("tags")) + .map(jsonNode -> { + List l = new ArrayList<>(); + for (JsonNode node : jsonNode) { + var tag = node.asText(); + if (!tag.isBlank()) { + l.add(tag); + } + } + return l; + }) + .orElse(new ArrayList<>()); var iconNode = json.get("icon"); String icon = iconNode != null && !iconNode.isNull() ? iconNode.asText() : null; @@ -322,7 +340,8 @@ public class DataStoreEntry extends StorageElement { freeze, pinToTop, orderIndex, - breakOutCategory)); + breakOutCategory, + tags)); } public String getEffectiveIconFile() { @@ -471,6 +490,33 @@ public class DataStoreEntry extends StorageElement { } } + public void addTag(String tag) { + if (tags == null || tag.isBlank()) { + return; + } + + tag = tag.strip(); + + if (tags.contains(tag)) { + return; + } + + tags.add(tag); + notifyUpdate(false, true); + } + + public void removeTag(String tag) { + if (tags == null || tag.isBlank()) { + return; + } + + tag = tag.strip(); + + if (tags.remove(tag)) { + notifyUpdate(false, true); + } + } + public void setCategoryUuid(UUID categoryUuid) { var changed = !Objects.equals(this.categoryUuid, categoryUuid); this.categoryUuid = categoryUuid; @@ -510,6 +556,11 @@ public class DataStoreEntry extends StorageElement { obj.put("pinToTop", pinToTop); obj.put("orderIndex", orderIndex); + var tagsArray = obj.putArray("tags"); + for (String tag : tags) { + tagsArray.add(tag); + } + ObjectNode stateObj = JsonNodeFactory.instance.objectNode(); stateObj.put("lastUsed", lastUsed.toString()); stateObj.put("lastModified", lastModified.toString()); diff --git a/lang/strings/translations_en.properties b/lang/strings/translations_en.properties index 68042d885..fac684a87 100644 --- a/lang/strings/translations_en.properties +++ b/lang/strings/translations_en.properties @@ -1699,3 +1699,7 @@ tunnelToLocalhostDescription=Automatically tunnel the remote port to localhost #context: SSH jump server, noun useGatewayAsJumpServer=Use gateway as jump server useGatewayAsJumpServerDescription=Use the gateway as a jump server with ProxyJump +tags=Tags +tag=Tag +addNewTag=Create new tag +createTag=Create tag ...