diff --git a/app/build.gradle b/app/build.gradle index 76c86a143..bfc151a3f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -48,13 +48,10 @@ dependencies { api 'com.vladsch.flexmark:flexmark-ext-yaml-front-matter:0.64.8' api 'com.vladsch.flexmark:flexmark-ext-toc:0.64.8' - api ('io.modelcontextprotocol.sdk:mcp-core:0.17.2') { + api ('io.modelcontextprotocol.sdk:mcp-core:1.0.0') { exclude group: "com.ethlo.time", module: "itu" } - api ('io.modelcontextprotocol.sdk:mcp-json:0.17.2') { - exclude group: "com.ethlo.time", module: "itu" - } - api ('io.modelcontextprotocol.sdk:mcp-json-jackson2:0.17.2') { + api ('io.modelcontextprotocol.sdk:mcp-json-jackson2:1.0.0') { exclude group: "com.ethlo.time", module: "itu" exclude group: "com.fasterxml.jackson.dataformat", module: "jackson-dataformat-yaml" } @@ -73,9 +70,9 @@ dependencies { api 'org.apache.commons:commons-lang3:3.20.0' api 'io.sentry:sentry:8.20.0' api 'commons-io:commons-io:2.21.0' - api "com.fasterxml.jackson.core:jackson-databind:2.21.0" + api "com.fasterxml.jackson.core:jackson-databind:2.21.1" api "com.fasterxml.jackson.core:jackson-annotations:2.21" - api "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.0" + api "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.1" api "org.kordamp.ikonli:ikonli-material2-pack:12.4.0" api "org.kordamp.ikonli:ikonli-materialdesign2-pack:12.4.0" api 'org.kordamp.ikonli:ikonli-bootstrapicons-pack:12.4.0' diff --git a/app/src/main/java/io/xpipe/app/action/ActionShortcutComp.java b/app/src/main/java/io/xpipe/app/action/ActionShortcutComp.java index 41b4a0a9b..66622c518 100644 --- a/app/src/main/java/io/xpipe/app/action/ActionShortcutComp.java +++ b/app/src/main/java/io/xpipe/app/action/ActionShortcutComp.java @@ -84,7 +84,8 @@ public class ActionShortcutComp extends SimpleRegionBuilder { ThreadHelper.runFailableAsync(() -> { var file = DesktopShortcuts.createOpen( name.getValue(), - "open \"" + url.getValue() + "\" -d \"" + AppProperties.get().getDataDir() + "\"", + "open \"" + url.getValue() + "\" -d \"" + + AppProperties.get().getDataDir() + "\"", null); DesktopHelper.browseFileInDirectory(file); }); diff --git a/app/src/main/java/io/xpipe/app/action/LauncherUrlProvider.java b/app/src/main/java/io/xpipe/app/action/LauncherUrlProvider.java index 669829c57..000c60808 100644 --- a/app/src/main/java/io/xpipe/app/action/LauncherUrlProvider.java +++ b/app/src/main/java/io/xpipe/app/action/LauncherUrlProvider.java @@ -1,9 +1,27 @@ package io.xpipe.app.action; import java.net.URI; +import java.util.List; +import java.util.Objects; +import java.util.Optional; public interface LauncherUrlProvider extends ActionProvider { + static List getAll() { + return ActionProvider.ALL.stream() + .map(actionProvider -> actionProvider instanceof LauncherUrlProvider lup ? lup : null) + .filter(Objects::nonNull) + .toList(); + } + + static Optional find(String url) { + return ActionProvider.ALL.stream() + .filter(actionProvider -> actionProvider instanceof LauncherUrlProvider lup + && url.toLowerCase().startsWith(lup.getScheme().toLowerCase() + ":")) + .findFirst() + .map(lup -> (LauncherUrlProvider) lup); + } + String getScheme(); AbstractAction createAction(URI uri) throws Exception; diff --git a/app/src/main/java/io/xpipe/app/action/QuickConnectProvider.java b/app/src/main/java/io/xpipe/app/action/QuickConnectProvider.java new file mode 100644 index 000000000..fc4e9eb98 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/action/QuickConnectProvider.java @@ -0,0 +1,44 @@ +package io.xpipe.app.action; + +import io.xpipe.app.ext.DataStore; +import io.xpipe.app.hub.action.impl.OpenHubMenuLeafProvider; +import io.xpipe.app.storage.DataStoreEntry; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public interface QuickConnectProvider extends ActionProvider { + + static List getAll() { + return ActionProvider.ALL.stream() + .map(actionProvider -> actionProvider instanceof QuickConnectProvider qcp ? qcp : null) + .filter(Objects::nonNull) + .toList(); + } + + static Optional find(String input) { + return ActionProvider.ALL.stream() + .filter(actionProvider -> actionProvider instanceof QuickConnectProvider qcp + && (input.length() <= qcp.getName().length() + && input.toLowerCase() + .startsWith(qcp.getName().toLowerCase()) + || input.toLowerCase().startsWith(qcp.getName().toLowerCase()))) + .findFirst() + .map(qcp -> (QuickConnectProvider) qcp); + } + + String getName(); + + Optional findExisting(DataStore store); + + DataStore createStore(String arguments, DataStore existing); + + String getPlaceholder(); + + boolean skipDialogIfPossible(); + + default void open(DataStoreEntry e) throws Exception { + OpenHubMenuLeafProvider.Action.builder().ref(e.ref()).build().executeSync(); + } +} diff --git a/app/src/main/java/io/xpipe/app/beacon/AppBeaconServer.java b/app/src/main/java/io/xpipe/app/beacon/AppBeaconServer.java index 96120c07d..7e99954d4 100644 --- a/app/src/main/java/io/xpipe/app/beacon/AppBeaconServer.java +++ b/app/src/main/java/io/xpipe/app/beacon/AppBeaconServer.java @@ -4,6 +4,7 @@ import io.xpipe.app.beacon.mcp.AppMcpServer; import io.xpipe.app.core.AppLocalTemp; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.issue.TrackEvent; +import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.util.DocumentationLink; import io.xpipe.beacon.BeaconConfig; import io.xpipe.beacon.BeaconInterface; @@ -170,13 +171,16 @@ public class AppBeaconServer { } private boolean handleCorsHeaders(HttpExchange exchange) throws IOException { - exchange.getResponseHeaders() - .add("Origin", "http://localhost:" + AppBeaconServer.get().getPort()); - exchange.getResponseHeaders().add("Vary", "Origin"); - exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*"); - exchange.getResponseHeaders().add("Access-Control-Allow-Credentials", "true"); - exchange.getResponseHeaders().add("Access-Control-Allow-Headers", "*"); - exchange.getResponseHeaders().add("Access-Control-Allow-Methods", "*"); + if (AppPrefs.get().enableHttpApi().get()) { + exchange.getResponseHeaders() + .add("Origin", "http://localhost:" + AppBeaconServer.get().getPort()); + exchange.getResponseHeaders().add("Vary", "Origin"); + exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*"); + exchange.getResponseHeaders().add("Access-Control-Allow-Credentials", "true"); + exchange.getResponseHeaders().add("Access-Control-Allow-Headers", "*"); + exchange.getResponseHeaders().add("Access-Control-Allow-Methods", "*"); + } + if (exchange.getRequestMethod().equals("OPTIONS")) { exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, -1); return true; diff --git a/app/src/main/java/io/xpipe/app/beacon/impl/AskpassExchangeImpl.java b/app/src/main/java/io/xpipe/app/beacon/impl/AskpassExchangeImpl.java index 3b676f206..25e100740 100644 --- a/app/src/main/java/io/xpipe/app/beacon/impl/AskpassExchangeImpl.java +++ b/app/src/main/java/io/xpipe/app/beacon/impl/AskpassExchangeImpl.java @@ -51,7 +51,7 @@ public class AskpassExchangeImpl extends AskpassExchange { prompt = prompt.replace("[sudo: authenticate]", "[sudo]"); if (msg.getRequest() == null) { - var r = AskpassAlert.queryRaw(prompt, null, true); + var r = AskpassAlert.queryRaw(prompt, null, false); return Response.builder() .value(r.getState() == SecretQueryState.NORMAL ? r.getSecret() : InPlaceSecretValue.of("")) .build(); diff --git a/app/src/main/java/io/xpipe/app/beacon/mcp/AppMcpServer.java b/app/src/main/java/io/xpipe/app/beacon/mcp/AppMcpServer.java index 32d44c1ee..bd4a51a5b 100644 --- a/app/src/main/java/io/xpipe/app/beacon/mcp/AppMcpServer.java +++ b/app/src/main/java/io/xpipe/app/beacon/mcp/AppMcpServer.java @@ -10,7 +10,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import io.modelcontextprotocol.common.McpTransportContext; -import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper; +import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.spec.HttpHeaders; diff --git a/app/src/main/java/io/xpipe/app/beacon/mcp/HttpStreamableServerTransportProvider.java b/app/src/main/java/io/xpipe/app/beacon/mcp/HttpStreamableServerTransportProvider.java index 7ecf28869..eaac7a101 100644 --- a/app/src/main/java/io/xpipe/app/beacon/mcp/HttpStreamableServerTransportProvider.java +++ b/app/src/main/java/io/xpipe/app/beacon/mcp/HttpStreamableServerTransportProvider.java @@ -83,7 +83,10 @@ public class HttpStreamableServerTransportProvider implements McpStreamableServe public List protocolVersions() { return List.of( - ProtocolVersions.MCP_2024_11_05, ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25); + ProtocolVersions.MCP_2024_11_05, + ProtocolVersions.MCP_2025_03_26, + ProtocolVersions.MCP_2025_06_18, + ProtocolVersions.MCP_2025_11_25); } @Override diff --git a/app/src/main/java/io/xpipe/app/beacon/mcp/McpToolHandler.java b/app/src/main/java/io/xpipe/app/beacon/mcp/McpToolHandler.java index b838c1e06..ca55c3a13 100644 --- a/app/src/main/java/io/xpipe/app/beacon/mcp/McpToolHandler.java +++ b/app/src/main/java/io/xpipe/app/beacon/mcp/McpToolHandler.java @@ -137,7 +137,8 @@ public interface McpToolHandler return e.ref(); } - public DataStoreEntryRef getShellStoreRef(String name, boolean mutation) throws BeaconClientException { + public DataStoreEntryRef getShellStoreRef(String name, boolean mutation) + throws BeaconClientException { var ref = getDataStoreRef(name); var isShell = ref.getStore() instanceof ShellStore; if (!isShell) { @@ -145,10 +146,12 @@ public interface McpToolHandler + DataStorage.get().getStorePath(ref.get()).toString() + " is not a shell connection"); } - var disableMutation = DataStorage.get().getEffectiveCategoryConfig(ref.get()).getDontAllowScripts(); + var disableMutation = + DataStorage.get().getEffectiveCategoryConfig(ref.get()).getDontAllowScripts(); if (mutation && disableMutation != null && disableMutation) { throw new BeaconClientException("Modifications to connection " - + DataStorage.get().getStorePath(ref.get()).toString() + " is disabled by the category setting"); + + DataStorage.get().getStorePath(ref.get()).toString() + + " is disabled by the category setting"); } return ref.asNeeded(); diff --git a/app/src/main/java/io/xpipe/app/beacon/mcp/McpTools.java b/app/src/main/java/io/xpipe/app/beacon/mcp/McpTools.java index bb4772c17..5db5dce9a 100644 --- a/app/src/main/java/io/xpipe/app/beacon/mcp/McpTools.java +++ b/app/src/main/java/io/xpipe/app/beacon/mcp/McpTools.java @@ -18,7 +18,7 @@ import io.xpipe.core.FilePath; import io.xpipe.core.JacksonMapper; import com.fasterxml.jackson.databind.node.JsonNodeFactory; -import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper; +import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.spec.McpSchema; import lombok.Builder; @@ -109,8 +109,13 @@ public final class McpTools { continue; } - var section = StoreViewState.get().getSectionForWrapper(StoreViewState.get().getEntryWrapper(e)); - var info = section.isPresent() ? e.getProvider().informationString(section.get()).getValue() : null; + var section = StoreViewState.get() + .getSectionForWrapper(StoreViewState.get().getEntryWrapper(e)); + var info = section.isPresent() + ? e.getProvider() + .informationString(section.get()) + .getValue() + : null; var r = ConnectionResource.builder() .name(e.getName()) diff --git a/app/src/main/java/io/xpipe/app/browser/BrowserFileChooserSessionComp.java b/app/src/main/java/io/xpipe/app/browser/BrowserFileChooserSessionComp.java index e814e6946..d4229bbce 100644 --- a/app/src/main/java/io/xpipe/app/browser/BrowserFileChooserSessionComp.java +++ b/app/src/main/java/io/xpipe/app/browser/BrowserFileChooserSessionComp.java @@ -11,6 +11,7 @@ import io.xpipe.app.core.AppLayoutModel; import io.xpipe.app.ext.FileSystemStore; import io.xpipe.app.ext.ShellStore; import io.xpipe.app.hub.comp.StoreEntryWrapper; +import io.xpipe.app.hub.comp.StoreFilter; import io.xpipe.app.hub.comp.StoreViewState; import io.xpipe.app.platform.BindingsHelper; import io.xpipe.app.platform.InputHelper; @@ -123,7 +124,7 @@ public class BrowserFileChooserSessionComp extends ModalOverlayContentComp { var category = new SimpleObjectProperty<>( StoreViewState.get().getActiveCategory().getValue()); - var filter = new SimpleStringProperty(); + var filter = new SimpleObjectProperty(); var filterTrigger = new ObservableSubscriber(); var bookmarkTopBar = new BrowserConnectionListFilterComp(filterTrigger, category, filter); var bookmarksList = new BrowserConnectionListComp( @@ -141,6 +142,7 @@ public class BrowserFileChooserSessionComp extends ModalOverlayContentComp { rec.heightProperty().bind(struc.heightProperty()); rec.setArcHeight(7); rec.setArcWidth(7); + rec.setSmooth(false); struc.getChildren().getFirst().setClip(rec); }) .vgrow(); @@ -168,7 +170,7 @@ public class BrowserFileChooserSessionComp extends ModalOverlayContentComp { var splitPane = new LeftSplitPaneComp(vertical, stack) .withInitialWidth(AppLayoutModel.get().getSavedState().getBrowserConnectionsWidth()) .applyStructure(struc -> { - struc.getLeft().setMinWidth(200); + struc.getLeft().setMinWidth(250); struc.getLeft().setMaxWidth(500); }); splitPane.disable(model.getBusy()); diff --git a/app/src/main/java/io/xpipe/app/browser/BrowserFullSessionComp.java b/app/src/main/java/io/xpipe/app/browser/BrowserFullSessionComp.java index 81b923291..1438f1d5d 100644 --- a/app/src/main/java/io/xpipe/app/browser/BrowserFullSessionComp.java +++ b/app/src/main/java/io/xpipe/app/browser/BrowserFullSessionComp.java @@ -11,6 +11,7 @@ import io.xpipe.app.core.AppLayoutModel; import io.xpipe.app.core.window.AppMainWindow; import io.xpipe.app.ext.ShellStore; import io.xpipe.app.hub.comp.StoreEntryWrapper; +import io.xpipe.app.hub.comp.StoreFilter; import io.xpipe.app.hub.comp.StoreViewState; import io.xpipe.app.platform.BindingsHelper; import io.xpipe.app.platform.InputHelper; @@ -23,7 +24,6 @@ import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleDoubleProperty; import javafx.beans.property.SimpleObjectProperty; -import javafx.beans.property.SimpleStringProperty; import javafx.geometry.Insets; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; @@ -90,7 +90,7 @@ public class BrowserFullSessionComp extends SimpleRegionBuilder { leftSplit.set(d); }); splitPane.applyStructure(struc -> { - struc.getLeft().setMinWidth(200); + struc.getLeft().setMinWidth(250); struc.getLeft().setMaxWidth(500); struc.get().setPickOnBounds(false); }); @@ -149,7 +149,7 @@ public class BrowserFullSessionComp extends SimpleRegionBuilder { var category = new SimpleObjectProperty<>( StoreViewState.get().getActiveCategory().getValue()); - var filter = new SimpleStringProperty(); + var filter = new SimpleObjectProperty(); var bookmarkTopBar = new BrowserConnectionListFilterComp(filterTrigger, category, filter); var bookmarksList = new BrowserConnectionListComp( BindingsHelper.map( @@ -165,6 +165,7 @@ public class BrowserFullSessionComp extends SimpleRegionBuilder { bookmarksContainer .apply(struc -> { var rec = new Rectangle(); + rec.setSmooth(false); rec.widthProperty().bind(struc.widthProperty()); rec.heightProperty().bind(struc.heightProperty()); rec.setArcHeight(11); @@ -225,6 +226,7 @@ public class BrowserFullSessionComp extends SimpleRegionBuilder { }); var clip = new Rectangle(); + clip.setSmooth(false); clip.widthProperty().bind(struc.widthProperty()); clip.heightProperty().bind(struc.heightProperty()); struc.setClip(clip); diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserConnectionListComp.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserConnectionListComp.java index af1c94808..7294b8c27 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserConnectionListComp.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserConnectionListComp.java @@ -25,14 +25,14 @@ public final class BrowserConnectionListComp extends SimpleRegionBuilder { private final Predicate applicable; private final BiConsumer action; private final Property category; - private final Property filter; + private final Property filter; public BrowserConnectionListComp( ObservableValue selected, Predicate applicable, BiConsumer action, Property category, - Property filter) { + Property filter) { this.selected = selected; this.applicable = applicable; this.action = action; diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserConnectionListFilterComp.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserConnectionListFilterComp.java index f77805a75..d1424689d 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserConnectionListFilterComp.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserConnectionListFilterComp.java @@ -4,8 +4,9 @@ import io.xpipe.app.comp.SimpleRegionBuilder; import io.xpipe.app.comp.base.FilterComp; import io.xpipe.app.comp.base.HorizontalComp; import io.xpipe.app.core.AppFontSizes; -import io.xpipe.app.hub.comp.DataStoreCategoryChoiceComp; +import io.xpipe.app.hub.comp.StoreCategoryChoiceComp; import io.xpipe.app.hub.comp.StoreCategoryWrapper; +import io.xpipe.app.hub.comp.StoreFilter; import io.xpipe.app.hub.comp.StoreViewState; import io.xpipe.app.util.ObservableSubscriber; @@ -24,21 +25,21 @@ public final class BrowserConnectionListFilterComp extends SimpleRegionBuilder { private final ObservableSubscriber filterTrigger; private final Property category; - private final Property filter; + private final Property filter; @Override protected Region createSimple() { - var category = new DataStoreCategoryChoiceComp( + var category = new StoreCategoryChoiceComp( StoreViewState.get().getAllConnectionsCategory(), StoreViewState.get().getActiveCategory(), this.category, true, - ignored -> true) + ignored -> true) .style(Styles.LEFT_PILL) .apply(struc -> { AppFontSizes.base(struc); }); - var filter = new FilterComp(this.filter) + var filter = FilterComp.ofStoreFilter(this.filter) .style(Styles.RIGHT_PILL) .minWidth(0) .hgrow() diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileInput.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileInput.java index 7552af4b4..da52a9639 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileInput.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileInput.java @@ -47,7 +47,7 @@ public interface BrowserFileInput { return false; } - if (info != null) { + if (info != null && info.getPermissions() != null) { var otherWrite = info.getPermissions().charAt(6) == 'r'; if (otherWrite) { return false; diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileSystemTabComp.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileSystemTabComp.java index f35b24858..b6819fbae 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileSystemTabComp.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileSystemTabComp.java @@ -241,7 +241,7 @@ public class BrowserFileSystemTabComp extends SimpleRegionBuilder { }); var home = new BrowserOverviewComp(model).style("browser-overview"); - var stack = new MultiContentComp(false, Map.of(home, showOverview, fileList, showOverview.not()), false); + var stack = new MultiContentComp(false, Map.of(home, showOverview, fileList, showOverview.not())); var r = stack.style("browser-content-container").build(); r.focusedProperty().addListener((observable, oldValue, newValue) -> { if (newValue) { diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileSystemTabModel.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileSystemTabModel.java index 32f41fbb4..ca2078e4d 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileSystemTabModel.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileSystemTabModel.java @@ -425,7 +425,8 @@ public final class BrowserFileSystemTabModel extends BrowserStoreSessionTab { BooleanScope.executeExclusive(busy, () -> { - openTerminalSync(name, directory, fileSystem.getShell().get().command(adjustedPath), true); + openTerminalSync( + name, directory, fileSystem.getShell().get().command(adjustedPath), true); }); }); } @@ -590,7 +591,7 @@ public final class BrowserFileSystemTabModel extends BrowserStoreSessionTab, ObservableValue>(); map.put(emptyDisplay, empty); map.put(contentDisplay, empty.not()); - var stack = new MultiContentComp(false, map, false); + var stack = new MultiContentComp(false, map); return stack.build(); } diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserNavBarComp.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserNavBarComp.java index 96312a81d..69250af71 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserNavBarComp.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserNavBarComp.java @@ -113,6 +113,7 @@ public class BrowserNavBarComp extends RegionStructureBuilder { var entry = base.get(); @@ -264,7 +265,9 @@ public class BrowserQuickAccessContextMenu extends ContextMenu { } }); new BooleanAnimationTimer(hover, 100, () -> { - expandDirectoryMenu(empty); + if (!keyBasedNavigation) { + expandDirectoryMenu(empty); + } }) .start(); } @@ -289,7 +292,12 @@ public class BrowserQuickAccessContextMenu extends ContextMenu { } }); contextMenu.addEventFilter(MouseEvent.ANY, event -> { - keyBasedNavigation = false; + var mouseEvent = event.getEventType() == MouseEvent.MOUSE_PRESSED + || event.getEventType() == MouseEvent.MOUSE_MOVED; + keyBasedNavigation = keyBasedNavigation && !mouseEvent; + if (keyBasedNavigation) { + event.consume(); + } }); } }); diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserTransferComp.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserTransferComp.java index 3676bf9af..7032ec2d8 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserTransferComp.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserTransferComp.java @@ -51,29 +51,32 @@ public class BrowserTransferComp extends SimpleRegionBuilder { .mapped(item -> item.getBrowserEntry()) .getList(); var list = new BrowserFileSelectionListComp(binding, entry -> { - var sourceItem = model.getCurrentItems().stream() - .filter(item -> item.getBrowserEntry() == entry) - .findAny(); - if (sourceItem.isEmpty()) { - return new SimpleStringProperty("?"); - } - synchronized (sourceItem.get().getProgress()) { - return Bindings.createStringBinding( - () -> { - var p = sourceItem.get().getProgress().getValue(); - if (p == null || p.getTotal() == 0) { - return entry.getFileName(); - } + var sourceItem = model.getCurrentItems().stream() + .filter(item -> item.getBrowserEntry() == entry) + .findAny(); + if (sourceItem.isEmpty()) { + return new SimpleStringProperty("?"); + } + synchronized (sourceItem.get().getProgress()) { + return Bindings.createStringBinding( + () -> { + var p = sourceItem.get().getProgress().getValue(); + if (p == null || p.getTotal() == 0) { + return entry.getFileName(); + } - var hideProgress = - sourceItem.get().getDownloadFinished().get(); - var share = p.getTransferred() * 100 / p.getTotal(); - var progressSuffix = hideProgress ? "" : " " + share + "%"; - return entry.getFileName() + progressSuffix; - }, - sourceItem.get().getProgress()); - } - }).vgrow(); + var hideProgress = sourceItem + .get() + .getDownloadFinished() + .get(); + var share = p.getTransferred() * 100 / p.getTotal(); + var progressSuffix = hideProgress ? "" : " " + share + "%"; + return entry.getFileName() + progressSuffix; + }, + sourceItem.get().getProgress()); + } + }) + .vgrow(); var dragNotice = new LabelComp(AppI18n.observable("dragLocalFiles")) .apply(struc -> struc.setGraphic(new FontIcon("mdi2h-hand-back-left-outline"))) .apply(struc -> struc.setWrapText(true)) diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/GradleRunMenuProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/GradleRunMenuProvider.java index 0b44ec376..b6ec02b05 100644 --- a/app/src/main/java/io/xpipe/app/browser/menu/impl/GradleRunMenuProvider.java +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/GradleRunMenuProvider.java @@ -11,6 +11,7 @@ import io.xpipe.app.ext.FileKind; import io.xpipe.app.platform.LabelGraphic; import io.xpipe.app.process.CommandBuilder; import io.xpipe.core.OsType; + import javafx.beans.property.SimpleStringProperty; import javafx.beans.value.ObservableValue; import javafx.scene.control.TextField; @@ -34,10 +35,11 @@ public class GradleRunMenuProvider implements BrowserMenuLeafProvider { } OsType.Any osType = model.getFileSystem().getShell().orElseThrow().getOsType(); - var ext = switch (osType) { - case OsType.Windows ignored -> "gradlew.bat"; - default -> "gradlew"; - }; + var ext = + switch (osType) { + case OsType.Windows ignored -> "gradlew.bat"; + default -> "gradlew"; + }; if (!entries.getFirst().getFileName().equalsIgnoreCase(ext)) { return false; @@ -79,15 +81,16 @@ public class GradleRunMenuProvider implements BrowserMenuLeafProvider { } var parent = entries.getFirst().getRawFileEntry().getPath().getParent(); - var command = model.getFileSystem().getShell().orElseThrow().command(CommandBuilder.of() - .add("sh") - .addFile(entries.getFirst().getRawFileEntry().getPath()) - .add(fixedTasks) - ); + var command = model.getFileSystem() + .getShell() + .orElseThrow() + .command(CommandBuilder.of() + .add("sh") + .addFile(entries.getFirst().getRawFileEntry().getPath()) + .add(fixedTasks)); model.openTerminalAsync(fixedTasks, parent, command, true); }); modal.show(); } - } diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UnzipActionProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UnzipActionProvider.java index 33568ab23..5c293551f 100644 --- a/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UnzipActionProvider.java +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UnzipActionProvider.java @@ -34,17 +34,12 @@ public class UnzipActionProvider implements BrowserActionProvider { public void executeImpl() throws Exception { var sc = model.getFileSystem().getShell().orElseThrow(); if (sc.getOsType() == OsType.WINDOWS) { - if (ShellDialects.isPowershell(sc)) { + sc.enforceDialect(ShellDialects.POWERSHELL, p -> { for (BrowserEntry entry : getEntries()) { - runPowershellCommand(sc, model, entry); + runPowershellCommand(p, model, entry); } - } else { - try (var sub = sc.subShell(ShellDialects.POWERSHELL)) { - for (BrowserEntry entry : getEntries()) { - runPowershellCommand(sub, model, entry); - } - } - } + return null; + }); } else { for (BrowserEntry entry : getEntries()) { var command = CommandBuilder.of() diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/ZipActionProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/ZipActionProvider.java index 6f07dd570..a89ce1926 100644 --- a/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/ZipActionProvider.java +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/ZipActionProvider.java @@ -53,13 +53,10 @@ public class ZipActionProvider implements BrowserActionProvider { } } - if (ShellDialects.isPowershell(sc)) { - sc.command(command).withWorkingDirectory(base).execute(); - } else { - try (var sub = sc.subShell(ShellDialects.POWERSHELL)) { - sub.command(command).withWorkingDirectory(base).execute(); - } - } + sc.enforceDialect(ShellDialects.POWERSHELL, p -> { + p.command(command).withWorkingDirectory(base).execute(); + return null; + }); } else { var command = CommandBuilder.of().add("zip", "-q", "-y", "-r", "-"); for (BrowserEntry entry : getEntries()) { diff --git a/app/src/main/java/io/xpipe/app/comp/base/AppLayoutComp.java b/app/src/main/java/io/xpipe/app/comp/base/AppLayoutComp.java index 70f64c7a8..d2d640394 100644 --- a/app/src/main/java/io/xpipe/app/comp/base/AppLayoutComp.java +++ b/app/src/main/java/io/xpipe/app/comp/base/AppLayoutComp.java @@ -4,27 +4,22 @@ import io.xpipe.app.comp.BaseRegionBuilder; import io.xpipe.app.comp.RegionStructure; import io.xpipe.app.comp.RegionStructureBuilder; import io.xpipe.app.core.AppLayoutModel; -import io.xpipe.app.core.AppRestart; -import io.xpipe.app.core.window.AppDialog; -import io.xpipe.app.hub.comp.StoreViewState; import io.xpipe.app.platform.PlatformThread; -import io.xpipe.app.prefs.AppPrefs; -import io.xpipe.app.storage.DataStorage; +import io.xpipe.app.terminal.TerminalDockHubManager; -import io.xpipe.app.util.GlobalTimer; import javafx.beans.binding.Bindings; import javafx.beans.value.ObservableValue; import javafx.scene.Node; import javafx.scene.Parent; import javafx.scene.control.ButtonBase; +import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; +import javafx.scene.input.KeyCombination; import javafx.scene.input.KeyEvent; import javafx.scene.layout.BorderPane; import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; -import org.bouncycastle.math.raw.Mod; -import java.time.Duration; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -47,7 +42,7 @@ public class AppLayoutComp extends RegionStructureBuilder v2, LinkedHashMap::new)); - var multi = new MultiContentComp(true, map, true); + var multi = new MultiContentComp(true, map); multi.style("background"); var pane = new BorderPane(); @@ -56,34 +51,6 @@ public class AppLayoutComp extends RegionStructureBuilder { - if (o != null && o.equals(model.getEntries().get(2))) { - var prefs = AppPrefs.get(); - if (prefs != null) { - prefs.save(); - } - var storage = DataStorage.get(); - if (storage != null) { - storage.saveAsync(); - } - - if (AppPrefs.get() != null && AppPrefs.get().getRequiresRestart().get()) { - GlobalTimer.delay(() -> { - var modal = ModalOverlay.of("prefsRestartTitle", AppDialog.dialogTextKey("prefsRestartContent")); - modal.addButton(ModalButton.cancel()); - modal.addButton(new ModalButton("restart", () -> AppRestart.restart(), true, true)); - modal.show(); - }, Duration.ofSeconds(1)); - } - } - - if (o != null && o.equals(model.getEntries().get(0))) { - var svs = StoreViewState.get(); - if (svs != null) { - svs.triggerStoreListUpdate(); - } - } - }); pane.addEventHandler(KeyEvent.KEY_PRESSED, event -> { sidebarR.getChildrenUnmodifiable().forEach(node -> { var shortcut = (KeyCodeCombination) node.getProperties().get("shortcut"); @@ -92,6 +59,13 @@ public class AppLayoutComp extends RegionStructureBuilder(multiR.getChildren())); diff --git a/app/src/main/java/io/xpipe/app/comp/base/ContextualFileReferenceChoiceComp.java b/app/src/main/java/io/xpipe/app/comp/base/ContextualFileReferenceChoiceComp.java index 6c8fd9309..09603a8e4 100644 --- a/app/src/main/java/io/xpipe/app/comp/base/ContextualFileReferenceChoiceComp.java +++ b/app/src/main/java/io/xpipe/app/comp/base/ContextualFileReferenceChoiceComp.java @@ -27,7 +27,6 @@ import javafx.scene.control.ListCell; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; -import lombok.NonNull; import lombok.Setter; import lombok.Value; import org.kordamp.ikonli.javafx.FontIcon; diff --git a/app/src/main/java/io/xpipe/app/comp/base/FilterComp.java b/app/src/main/java/io/xpipe/app/comp/base/FilterComp.java index 439c85697..86ab25c50 100644 --- a/app/src/main/java/io/xpipe/app/comp/base/FilterComp.java +++ b/app/src/main/java/io/xpipe/app/comp/base/FilterComp.java @@ -4,10 +4,12 @@ import io.xpipe.app.comp.RegionBuilder; import io.xpipe.app.comp.RegionDescriptor; import io.xpipe.app.core.AppI18n; import io.xpipe.app.core.AppOpenArguments; +import io.xpipe.app.hub.comp.StoreFilter; import io.xpipe.app.platform.PlatformThread; import javafx.beans.binding.Bindings; import javafx.beans.property.Property; +import javafx.beans.property.SimpleStringProperty; import javafx.scene.Cursor; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; @@ -15,6 +17,8 @@ import javafx.scene.input.KeyEvent; import javafx.scene.input.MouseButton; import atlantafx.base.controls.CustomTextField; +import javafx.scene.layout.Pane; +import javafx.scene.shape.Rectangle; import org.kordamp.ikonli.javafx.FontIcon; import java.util.List; @@ -22,6 +26,14 @@ import java.util.Objects; public class FilterComp extends RegionBuilder { + public static FilterComp ofStoreFilter(Property filter) { + var prop = new SimpleStringProperty(); + prop.subscribe(s -> { + filter.setValue(StoreFilter.of(s)); + }); + return new FilterComp(prop); + } + private final Property filterText; public FilterComp(Property filterText) { @@ -83,6 +95,15 @@ public class FilterComp extends RegionBuilder { filterText.setValue(n != null && n.length() > 0 ? n : null); }); + // Fix caret not being visible on right side when overflowing + filter.setSkin(filter.createDefaultSkin()); + Pane pane = (Pane) filter.getChildrenUnmodifiable().getFirst(); + var rec = new Rectangle(); + rec.widthProperty().bind(pane.widthProperty().add(2)); + rec.heightProperty().bind(pane.heightProperty()); + rec.setSmooth(false); + filter.getChildrenUnmodifiable().getFirst().setClip(rec); + return filter; } } diff --git a/app/src/main/java/io/xpipe/app/comp/base/IntegratedTextAreaComp.java b/app/src/main/java/io/xpipe/app/comp/base/IntegratedTextAreaComp.java index e2b2c969c..7f3423ebd 100644 --- a/app/src/main/java/io/xpipe/app/comp/base/IntegratedTextAreaComp.java +++ b/app/src/main/java/io/xpipe/app/comp/base/IntegratedTextAreaComp.java @@ -91,8 +91,11 @@ public class IntegratedTextAreaComp extends RegionStructureBuilder extends RegionBuilder { vbox.setFocusTraversable(false); var scroll = new ScrollPane(vbox); - refresh(scroll, vbox, shown, all, cache, false); + refresh(vbox, shown, all, cache); var hadScene = new AtomicBoolean(false); scroll.sceneProperty().subscribe(scene -> { if (scene != null) { hadScene.set(true); - refresh(scroll, vbox, shown, all, cache, true); + refresh(vbox, shown, all, cache); } }); @@ -78,7 +78,7 @@ public class ListBoxViewComp extends RegionBuilder { return; } - refresh(scroll, vbox, c.getList(), all, cache, true); + refresh(vbox, c.getList(), all, cache); }); }); @@ -118,12 +118,21 @@ public class ListBoxViewComp extends RegionBuilder { var dirty = new SimpleBooleanProperty(); var animationTimer = new AnimationTimer() { + + private long delayThresholdCrossed; + @Override public void handle(long now) { if (!dirty.get()) { return; } + var ms = now / 1_000_000; + if (ms < delayThresholdCrossed + (super.hashCode() % 100)) { + return; + } + delayThresholdCrossed = ms; + updateVisibilities(scroll, vbox); dirty.set(false); } @@ -146,6 +155,19 @@ public class ListBoxViewComp extends RegionBuilder { vbox.heightProperty().addListener((observable, oldValue, newValue) -> { dirty.set(true); }); + vbox.getChildren().addListener((ListChangeListener) (change) -> { + dirty.set(true); + }); + shown.addListener((ListChangeListener) (change) -> { + Platform.runLater(() -> { + dirty.set(true); + }); + }); + scroll.sceneProperty().addListener((observable, oldValue, newValue) -> { + if (newValue != null) { + dirty.set(true); + } + }); // We can't directly listen to any parent element changing visibility, so this is a compromise if (AppLayoutModel.get() != null) { @@ -296,13 +318,7 @@ public class ListBoxViewComp extends RegionBuilder { // } } - private void refresh( - ScrollPane scroll, - VBox listView, - List shown, - List all, - Map cache, - boolean refreshVisibilities) { + private void refresh(VBox vbox, List shown, List all, Map cache) { Runnable update = () -> { if (!Platform.isFxApplicationThread()) { throw new IllegalStateException("Not in FxApplication thread"); @@ -344,7 +360,7 @@ public class ListBoxViewComp extends RegionBuilder { .filter(region -> region != null) .toList(); - if (listView.getChildren().equals(newShown)) { + if (vbox.getChildren().equals(newShown)) { return; } @@ -356,11 +372,8 @@ public class ListBoxViewComp extends RegionBuilder { r.pseudoClassStateChanged(LAST, i == newShown.size() - 1); } - var d = DerivedObservableList.wrap(listView.getChildren(), true); + var d = DerivedObservableList.wrap(vbox.getChildren(), true); d.setContent(newShown); - if (refreshVisibilities) { - updateVisibilities(scroll, listView); - } }; update.run(); } diff --git a/app/src/main/java/io/xpipe/app/comp/base/MultiContentComp.java b/app/src/main/java/io/xpipe/app/comp/base/MultiContentComp.java index 46e12985d..c07a58c67 100644 --- a/app/src/main/java/io/xpipe/app/comp/base/MultiContentComp.java +++ b/app/src/main/java/io/xpipe/app/comp/base/MultiContentComp.java @@ -2,7 +2,6 @@ package io.xpipe.app.comp.base; import io.xpipe.app.comp.BaseRegionBuilder; import io.xpipe.app.comp.SimpleRegionBuilder; -import io.xpipe.app.issue.TrackEvent; import io.xpipe.app.platform.PlatformThread; import javafx.application.Platform; @@ -18,13 +17,10 @@ import java.util.Map; public class MultiContentComp extends SimpleRegionBuilder { private final boolean requestFocus; - private final boolean log; private final Map, ObservableValue> content; - public MultiContentComp( - boolean requestFocus, Map, ObservableValue> content, boolean log) { + public MultiContentComp(boolean requestFocus, Map, ObservableValue> content) { this.requestFocus = requestFocus; - this.log = log; this.content = FXCollections.observableMap(content); } @@ -53,14 +49,7 @@ public class MultiContentComp extends SimpleRegionBuilder { }); for (Map.Entry, ObservableValue> e : content.entrySet()) { - var name = e.getKey().getClass().getSimpleName(); - if (log) { - TrackEvent.trace("Creating content tab region for element " + name); - } var r = e.getKey().build(); - if (log) { - TrackEvent.trace("Created content tab region for element " + name); - } e.getValue().subscribe(val -> { PlatformThread.runLaterIfNeeded(() -> { r.setManaged(val); @@ -73,33 +62,8 @@ public class MultiContentComp extends SimpleRegionBuilder { }); }); m.put(e.getKey(), r); - if (log) { - TrackEvent.trace("Added content tab region for element " + name); - } } return stack; } - - // Lazy impl - // @Override - // protected Region createSimple() { - // var stack = new StackPane(); - // for (Map.Entry, ObservableValue> e : content.entrySet()) { - // var r = e.getKey().build(); - // e.getValue().subscribe(val -> { - // PlatformThread.runLaterIfNeeded(() -> { - // r.setManaged(val); - // r.setVisible(val); - // if (val && !stack.getChildren().contains(r)) { - // stack.getChildren().add(r); - // } else { - // stack.getChildren().remove(r); - // } - // }); - // }); - // } - // - // return stack; - // } } diff --git a/app/src/main/java/io/xpipe/app/comp/base/SecretFieldComp.java b/app/src/main/java/io/xpipe/app/comp/base/SecretFieldComp.java index cebd6a8f9..34a23e81c 100644 --- a/app/src/main/java/io/xpipe/app/comp/base/SecretFieldComp.java +++ b/app/src/main/java/io/xpipe/app/comp/base/SecretFieldComp.java @@ -121,7 +121,7 @@ public class SecretFieldComp extends RegionStructureBuilder { - ClipboardHelper.copyPassword(value.getValue()); + ClipboardHelper.copyPassword(value.getValue(), true); }) .describe(d -> d.nameKey("copy")); diff --git a/app/src/main/java/io/xpipe/app/comp/base/SideMenuBarComp.java b/app/src/main/java/io/xpipe/app/comp/base/SideMenuBarComp.java index 612f39020..daa298025 100644 --- a/app/src/main/java/io/xpipe/app/comp/base/SideMenuBarComp.java +++ b/app/src/main/java/io/xpipe/app/comp/base/SideMenuBarComp.java @@ -7,9 +7,12 @@ import io.xpipe.app.core.AppLayoutModel; import io.xpipe.app.core.AppProperties; import io.xpipe.app.core.mode.AppOperationMode; import io.xpipe.app.platform.PlatformThread; +import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.update.AppDistributionType; import io.xpipe.app.update.UpdateAvailableDialog; +import io.xpipe.app.update.UpdateHandler; import io.xpipe.app.util.Hyperlinks; +import io.xpipe.app.util.ThreadHelper; import javafx.application.Platform; import javafx.beans.binding.Bindings; @@ -66,18 +69,25 @@ public class SideMenuBarComp extends RegionBuilder { } { - var b = new IconButtonComp("mdi2u-update", () -> UpdateAvailableDialog.showIfNeeded(false)); + var b = new IconButtonComp("mdi2u-update", () -> { + var r = UpdateAvailableDialog.showIfNeeded(false); + if (!r) { + AppPrefs.get().selectCategory("about"); + ThreadHelper.runFailableAsync(() -> { + UpdateHandler uh = AppDistributionType.get().getUpdateHandler(); + uh.prepareUpdate(); + }); + } + }); b.describe(d -> d.nameKey("updateAvailableTooltip")); var stack = createStyle(null, b); + var h = AppDistributionType.get().getUpdateHandler(); stack.hide(Bindings.createBooleanBinding( () -> { - return AppDistributionType.get() - .getUpdateHandler() - .getPreparedUpdate() - .getValue() - == null; + return h.getPreparedUpdate().getValue() == null; }, - AppDistributionType.get().getUpdateHandler().getPreparedUpdate())); + h.getPreparedUpdate(), + h.getBusy())); vbox.getChildren().add(stack.build()); } diff --git a/app/src/main/java/io/xpipe/app/comp/base/TextFieldComp.java b/app/src/main/java/io/xpipe/app/comp/base/TextFieldComp.java index e10e75ff4..f37a22374 100644 --- a/app/src/main/java/io/xpipe/app/comp/base/TextFieldComp.java +++ b/app/src/main/java/io/xpipe/app/comp/base/TextFieldComp.java @@ -6,7 +6,10 @@ import io.xpipe.app.platform.PlatformThread; import javafx.beans.property.Property; import javafx.beans.property.SimpleStringProperty; import javafx.scene.control.TextField; +import javafx.scene.control.skin.TextFieldSkin; import javafx.scene.input.KeyCode; +import javafx.scene.layout.Pane; +import javafx.scene.shape.Rectangle; import java.util.Objects; @@ -70,6 +73,15 @@ public class TextFieldComp extends RegionBuilder { } }); + // Fix caret not being visible on right side when overflowing + text.setSkin(new TextFieldSkin(text)); + Pane pane = (Pane) text.getChildrenUnmodifiable().getFirst(); + var rec = new Rectangle(); + rec.widthProperty().bind(pane.widthProperty().add(2)); + rec.heightProperty().bind(pane.heightProperty()); + rec.setSmooth(false); + text.getChildrenUnmodifiable().getFirst().setClip(rec); + return text; } } diff --git a/app/src/main/java/io/xpipe/app/core/AppConfigurationDialog.java b/app/src/main/java/io/xpipe/app/core/AppConfigurationDialog.java index ed45d854b..c3c1a50d8 100644 --- a/app/src/main/java/io/xpipe/app/core/AppConfigurationDialog.java +++ b/app/src/main/java/io/xpipe/app/core/AppConfigurationDialog.java @@ -6,6 +6,7 @@ import io.xpipe.app.comp.base.ScrollComp; import io.xpipe.app.core.window.AppDialog; import io.xpipe.app.platform.OptionsBuilder; import io.xpipe.app.prefs.EditorCategory; +import io.xpipe.app.prefs.PasswordManagerCategory; import io.xpipe.app.prefs.PersonalizationCategory; import io.xpipe.app.prefs.TerminalCategory; import io.xpipe.app.util.DocumentationLink; @@ -24,6 +25,7 @@ public class AppConfigurationDialog { .sub(PersonalizationCategory.themeChoice()) .sub(TerminalCategory.terminalChoice(false)) .sub(EditorCategory.editorChoice()) + .sub(PasswordManagerCategory.passwordManagerChoice()) .buildComp(); options.style("initial-setup"); options.style("prefs-container"); diff --git a/app/src/main/java/io/xpipe/app/core/AppFontSizes.java b/app/src/main/java/io/xpipe/app/core/AppFontSizes.java index 3cb7b6af7..79e68236a 100644 --- a/app/src/main/java/io/xpipe/app/core/AppFontSizes.java +++ b/app/src/main/java/io/xpipe/app/core/AppFontSizes.java @@ -88,7 +88,9 @@ public class AppFontSizes { AppPrefs.get().useSystemFont().addListener((ignored, ignored2, newValue) -> { var refNode = ref.get(); if (refNode != null) { - var effective = AppPrefs.get().theme().getValue() != null ? AppPrefs.get().theme().getValue().getFontSizes().get() : getDefault(); + var effective = AppPrefs.get().theme().getValue() != null + ? AppPrefs.get().theme().getValue().getFontSizes().get() + : getDefault(); setFont(refNode, function.apply(effective)); } }); diff --git a/app/src/main/java/io/xpipe/app/core/AppImages.java b/app/src/main/java/io/xpipe/app/core/AppImages.java index 6e391ba80..e7246005a 100644 --- a/app/src/main/java/io/xpipe/app/core/AppImages.java +++ b/app/src/main/java/io/xpipe/app/core/AppImages.java @@ -170,6 +170,10 @@ public class AppImages { return; } + if (images.containsKey(key)) { + return; + } + images.put(key, loadImage(p)); } 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 06e0915c5..6cdd3b1b1 100644 --- a/app/src/main/java/io/xpipe/app/core/AppLayoutModel.java +++ b/app/src/main/java/io/xpipe/app/core/AppLayoutModel.java @@ -3,10 +3,16 @@ package io.xpipe.app.core; import io.xpipe.app.browser.BrowserFullSessionComp; import io.xpipe.app.browser.BrowserFullSessionModel; import io.xpipe.app.comp.BaseRegionBuilder; +import io.xpipe.app.comp.base.ModalButton; +import io.xpipe.app.comp.base.ModalOverlay; +import io.xpipe.app.core.window.AppDialog; import io.xpipe.app.hub.comp.StoreLayoutComp; +import io.xpipe.app.hub.comp.StoreViewState; import io.xpipe.app.platform.LabelGraphic; import io.xpipe.app.platform.PlatformThread; +import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.prefs.AppPrefsComp; +import io.xpipe.app.storage.DataStorage; import io.xpipe.app.terminal.TerminalDockHubManager; import io.xpipe.app.update.AppDistributionType; import io.xpipe.app.util.*; @@ -60,6 +66,46 @@ public class AppLayoutModel { public static void init() { var state = AppCache.getNonNull("layoutState", SavedState.class, () -> new SavedState(270, 300)); INSTANCE = new AppLayoutModel(state); + INSTANCE.addListeners(); + } + + private void addListeners() { + getSelected().addListener((c, o, n) -> { + if (o != null && o.equals(getEntries().get(2))) { + var prefs = AppPrefs.get(); + if (prefs != null) { + prefs.save(); + } + var storage = DataStorage.get(); + if (storage != null) { + ThreadHelper.runAsync(() -> { + storage.refreshEntries(); + storage.saveAsync(); + }); + } + + if (AppPrefs.get() != null + && AppPrefs.get().getRequiresRestart().get()) { + GlobalTimer.delay( + () -> { + var modal = ModalOverlay.of( + "prefsRestartTitle", AppDialog.dialogTextKey("prefsRestartContent")); + modal.addButton(ModalButton.cancel()); + modal.addButton(new ModalButton("restart", () -> AppRestart.restart(), true, true)); + modal.show(); + }, + Duration.ofSeconds(1)); + AppPrefs.get().getRequiresRestart().set(false); + } + } + + if (o != null && o.equals(getEntries().get(0))) { + var svs = StoreViewState.get(); + if (svs != null) { + svs.triggerStoreListUpdate(); + } + } + }); } public static void reset() { diff --git a/app/src/main/java/io/xpipe/app/core/AppOpenArguments.java b/app/src/main/java/io/xpipe/app/core/AppOpenArguments.java index 08005c9e5..4d8e0a8b2 100644 --- a/app/src/main/java/io/xpipe/app/core/AppOpenArguments.java +++ b/app/src/main/java/io/xpipe/app/core/AppOpenArguments.java @@ -1,7 +1,6 @@ package io.xpipe.app.core; import io.xpipe.app.action.AbstractAction; -import io.xpipe.app.action.ActionProvider; import io.xpipe.app.action.LauncherUrlProvider; import io.xpipe.app.browser.action.impl.OpenDirectoryActionProvider; import io.xpipe.app.core.mode.AppOperationMode; @@ -81,15 +80,11 @@ public class AppOpenArguments { var uri = URI.create(input); var scheme = uri.getScheme(); if (scheme != null) { - var action = uri.getScheme(); - var found = ActionProvider.ALL.stream() - .filter(actionProvider -> actionProvider instanceof LauncherUrlProvider lcs - && lcs.getScheme().equalsIgnoreCase(action)) - .findFirst(); + var found = LauncherUrlProvider.find(input); if (found.isPresent()) { AbstractAction a; try { - a = ((LauncherUrlProvider) found.get()).createAction(uri); + a = found.get().createAction(uri); } catch (Exception e) { ErrorEventFactory.fromThrowable(e).omit().expected().handle(); return List.of(); diff --git a/app/src/main/java/io/xpipe/app/core/AppProperties.java b/app/src/main/java/io/xpipe/app/core/AppProperties.java index 4ca81b336..28b90fdbe 100644 --- a/app/src/main/java/io/xpipe/app/core/AppProperties.java +++ b/app/src/main/java/io/xpipe/app/core/AppProperties.java @@ -10,6 +10,8 @@ import lombok.Value; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.Instant; import java.util.*; @Value @@ -235,4 +237,19 @@ public class AppProperties { public Optional getCanonicalVersion() { return Optional.ofNullable(canonicalVersion); } + + public Instant getFirstStartupDate() { + var dir = defaultDataDir; + if (!Files.exists(dir)) { + return Instant.now(); + } + + try { + var attr = Files.readAttributes(dir, BasicFileAttributes.class); + return attr.creationTime().toInstant(); + } catch (Exception e) { + ErrorEventFactory.fromThrowable(e).expected().omit().handle(); + return Instant.now(); + } + } } diff --git a/app/src/main/java/io/xpipe/app/core/AppSystemInfo.java b/app/src/main/java/io/xpipe/app/core/AppSystemInfo.java index 72b1d9d8c..5e86ddf39 100644 --- a/app/src/main/java/io/xpipe/app/core/AppSystemInfo.java +++ b/app/src/main/java/io/xpipe/app/core/AppSystemInfo.java @@ -215,8 +215,10 @@ public abstract class AppSystemInfo { var r = Shell32Util.getKnownFolderPath(KnownFolders.FOLDERID_Downloads); // Replace 8.3 filename return (downloads = Path.of(r).toRealPath()); - } catch (Throwable e) { - ErrorEventFactory.fromThrowable(e).handle(); + } catch (Throwable e) { + if (!(e instanceof Win32Exception)) { + ErrorEventFactory.fromThrowable(e).handle(); + } var fallback = getUserHome().resolve("Downloads"); return (downloads = fallback); } @@ -232,7 +234,9 @@ public abstract class AppSystemInfo { // Replace 8.3 filename return (documents = Path.of(r).toRealPath()); } catch (Throwable e) { - ErrorEventFactory.fromThrowable(e).handle(); + if (!(e instanceof Win32Exception)) { + ErrorEventFactory.fromThrowable(e).handle(); + } var fallback = getUserHome().resolve("Documents"); return (documents = fallback); } @@ -249,7 +253,9 @@ public abstract class AppSystemInfo { // Replace 8.3 filename return (desktop = Path.of(r).toRealPath()); } catch (Throwable e) { - ErrorEventFactory.fromThrowable(e).handle(); + if (!(e instanceof Win32Exception)) { + ErrorEventFactory.fromThrowable(e).handle(); + } var fallback = getUserHome().resolve("Desktop"); return (desktop = fallback); } @@ -260,6 +266,7 @@ public abstract class AppSystemInfo { private Path downloads; private Path desktop; + private Path config; private Boolean vm; public boolean isDebianBased() { @@ -356,6 +363,31 @@ public abstract class AppSystemInfo { return (desktop = fallback); } + public Path getConfigDir() { + if (config != null) { + return config; + } + + if (System.getenv("XDG_CONFIG_HOME") != null) { + return (config = Path.of(System.getenv("XDG_CONFIG_HOME"))); + } else { + return (config = AppSystemInfo.ofLinux().getUserHome().resolve(".config")); + } + } + + public Path getRuntimeDir() { + if (config != null) { + return config; + } + + if (System.getenv("XDG_RUNTIME_DIR") != null) { + return (config = Path.of(System.getenv("XDG_RUNTIME_DIR"))); + } else { + // Bad fallback, but we don't want to run any commands to retrieve uids here + return (config = Path.of("/run/user/1000")); + } + } + @Override public Path getTemp() { return Path.of(System.getProperty("java.io.tmpdir")); diff --git a/app/src/main/java/io/xpipe/app/core/AppTheme.java b/app/src/main/java/io/xpipe/app/core/AppTheme.java index 12cf48cf9..cb3d86426 100644 --- a/app/src/main/java/io/xpipe/app/core/AppTheme.java +++ b/app/src/main/java/io/xpipe/app/core/AppTheme.java @@ -9,10 +9,6 @@ import io.xpipe.app.platform.PlatformThread; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.core.OsType; -import javafx.animation.Interpolator; -import javafx.animation.KeyFrame; -import javafx.animation.KeyValue; -import javafx.animation.Timeline; import javafx.application.Application; import javafx.application.ColorScheme; import javafx.application.Platform; @@ -21,12 +17,8 @@ import javafx.beans.value.ObservableValue; import javafx.collections.MapChangeListener; import javafx.css.PseudoClass; import javafx.scene.Node; -import javafx.scene.image.Image; -import javafx.scene.image.ImageView; -import javafx.scene.layout.Pane; import javafx.scene.paint.Color; import javafx.stage.Stage; -import javafx.util.Duration; import atlantafx.base.theme.*; import lombok.AllArgsConstructor; @@ -98,11 +90,13 @@ public class AppTheme { r.pseudoClassStateChanged(PseudoClass.getPseudoClass(OsType.ofLocal().getId()), true); - Theme.ALL.forEach(theme -> { - r.pseudoClassStateChanged( - PseudoClass.getPseudoClass(theme.getCssId()), - theme.getCssId().equals(t.getCssId())); - }); + if (t != null) { + Theme.ALL.forEach(theme -> { + r.pseudoClassStateChanged( + PseudoClass.getPseudoClass(theme.getCssId()), + theme.getCssId().equals(t.getCssId())); + }); + } if (t != null) { r.pseudoClassStateChanged(LIGHT, !t.isDark()); @@ -251,41 +245,8 @@ public class AppTheme { TrackEvent.debug("Setting theme " + newTheme.getId() + " for scene"); - // Don't animate transition in performance mode - if (AppPrefs.get() == null || AppPrefs.get().performanceMode().get()) { - newTheme.apply(); - return; - } - - var stage = window.getStage(); - var scene = stage.getScene(); - Pane root = (Pane) scene.getRoot(); - Image snapshot = null; - try { - scene.snapshot(null); - } catch (Exception ex) { - // This can fail if there is no window / screen I guess? - ErrorEventFactory.fromThrowable(ex).expected().omit().handle(); - return; - } - ImageView imageView = new ImageView(snapshot); - root.getChildren().add(imageView); + // Don't animate anything for performance reasons newTheme.apply(); - - Platform.runLater(() -> { - // Animate! - var transition = new Timeline( - new KeyFrame( - Duration.millis(0), - new KeyValue(imageView.opacityProperty(), 1, Interpolator.EASE_OUT)), - new KeyFrame( - Duration.millis(600), - new KeyValue(imageView.opacityProperty(), 0, Interpolator.EASE_OUT))); - transition.setOnFinished(e -> { - root.getChildren().remove(imageView); - }); - transition.play(); - }); }); } diff --git a/app/src/main/java/io/xpipe/app/core/AppWindowsLock.java b/app/src/main/java/io/xpipe/app/core/AppWindowsLock.java index 19290788d..592e1a30e 100644 --- a/app/src/main/java/io/xpipe/app/core/AppWindowsLock.java +++ b/app/src/main/java/io/xpipe/app/core/AppWindowsLock.java @@ -4,11 +4,11 @@ import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.issue.TrackEvent; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.util.ThreadHelper; +import io.xpipe.app.util.User32Ex; import com.sun.jna.Pointer; import com.sun.jna.platform.win32.*; import com.sun.jna.win32.StdCallLibrary; -import io.xpipe.app.util.User32Ex; import lombok.RequiredArgsConstructor; import lombok.Setter; @@ -92,5 +92,4 @@ public class AppWindowsLock { return User32.INSTANCE.CallWindowProc(oldWindowProc, hwnd, uMsg, wParam, lParam); } } - } diff --git a/app/src/main/java/io/xpipe/app/core/mode/AppBaseMode.java b/app/src/main/java/io/xpipe/app/core/mode/AppBaseMode.java index e1fbf2245..74187a624 100644 --- a/app/src/main/java/io/xpipe/app/core/mode/AppBaseMode.java +++ b/app/src/main/java/io/xpipe/app/core/mode/AppBaseMode.java @@ -16,6 +16,8 @@ import io.xpipe.app.core.window.AppWindowTitle; import io.xpipe.app.ext.DataStoreProviders; import io.xpipe.app.ext.ProcessControlProvider; import io.xpipe.app.ext.StartOnInitStore; +import io.xpipe.app.hub.comp.StoreFilterState; +import io.xpipe.app.hub.comp.StoreQuickConnect; import io.xpipe.app.hub.comp.StoreViewState; import io.xpipe.app.icon.SystemIconManager; import io.xpipe.app.issue.TrackEvent; @@ -129,7 +131,9 @@ public class AppBaseMode extends AppOperationMode { storageLoaded.countDown(); AppMcpServer.init(); iconsInit.await(); + StoreFilterState.init(); StoreViewState.init(); + StoreQuickConnect.init(); AppMainWindow.loadingText("loadingSettings"); TrackEvent.info("Connection storage initialization thread completed"); }, @@ -209,6 +213,7 @@ public class AppBaseMode extends AppOperationMode { AppBeaconServer.reset(); KeePassXcPasswordManager.reset(); StoreViewState.reset(); + StoreFilterState.reset(); AppLayoutModel.reset(); AppTheme.reset(); PlatformState.teardown(); diff --git a/app/src/main/java/io/xpipe/app/core/window/AppDialog.java b/app/src/main/java/io/xpipe/app/core/window/AppDialog.java index 03bad176e..eec766578 100644 --- a/app/src/main/java/io/xpipe/app/core/window/AppDialog.java +++ b/app/src/main/java/io/xpipe/app/core/window/AppDialog.java @@ -141,6 +141,13 @@ public class AppDialog { .prefWidth(450); } + public static void information(String translationKey) { + var content = dialogTextKey(translationKey + "Content"); + var modal = ModalOverlay.of(translationKey + "Title", content); + modal.addButton(ModalButton.ok()); + show(modal); + } + public static boolean confirm(String translationKey) { var confirmed = new AtomicBoolean(false); var content = dialogTextKey(translationKey + "Content"); diff --git a/app/src/main/java/io/xpipe/app/core/window/AppMainWindow.java b/app/src/main/java/io/xpipe/app/core/window/AppMainWindow.java index aef2e1e8c..0d7318e2f 100644 --- a/app/src/main/java/io/xpipe/app/core/window/AppMainWindow.java +++ b/app/src/main/java/io/xpipe/app/core/window/AppMainWindow.java @@ -52,7 +52,6 @@ public class AppMainWindow { private final BooleanProperty windowActive = new SimpleBooleanProperty(false); private volatile Instant lastUpdate; - private boolean shown = false; private AppMainWindow(Stage stage) { this.stage = stage; @@ -189,8 +188,16 @@ public class AppMainWindow { AppWindowsShutdown.registerHook(ctrl.getWindowHandle()); } } + } - shown = true; + public void hide() { + PlatformThread.runLaterIfNeeded(() -> { + if (!stage.isShowing()) { + return; + } + + stage.hide(); + }); } public void focus() { @@ -300,12 +307,6 @@ public class AppMainWindow { // Close other windows Stage.getWindows().stream().filter(w -> !w.equals(stage)).toList().forEach(w -> w.fireEvent(e)); - // Iconifying stages on Windows will break if the window is closed - // Work around this issue it by re-showing it immediately before hiding it again - if (OsType.ofLocal() == OsType.WINDOWS) { - stage.setIconified(false); - } - // Close self stage.close(); AppOperationMode.onWindowClose(); diff --git a/app/src/main/java/io/xpipe/app/core/window/AppSideWindow.java b/app/src/main/java/io/xpipe/app/core/window/AppSideWindow.java index 767643e44..19c6d833a 100644 --- a/app/src/main/java/io/xpipe/app/core/window/AppSideWindow.java +++ b/app/src/main/java/io/xpipe/app/core/window/AppSideWindow.java @@ -83,7 +83,7 @@ public class AppSideWindow { public static Alert createEmptyAlert() { Alert alert = new Alert(Alert.AlertType.NONE); - if (AppMainWindow.get() != null) { + if (AppMainWindow.get() != null && AppMainWindow.get().getStage().isShowing() && !AppMainWindow.get().getStage().isIconified()) { alert.initOwner(AppMainWindow.get().getStage()); } alert.getDialogPane().getScene().setFill(Color.TRANSPARENT); diff --git a/app/src/main/java/io/xpipe/app/core/window/AppWindowBounds.java b/app/src/main/java/io/xpipe/app/core/window/AppWindowBounds.java index dcd1415ff..a38394b89 100644 --- a/app/src/main/java/io/xpipe/app/core/window/AppWindowBounds.java +++ b/app/src/main/java/io/xpipe/app/core/window/AppWindowBounds.java @@ -24,7 +24,7 @@ public class AppWindowBounds { var o = oldValue.doubleValue(); if (stage.isShowing() && areNumbersValid(o, n)) { // Ignore rounding events - if (Math.abs(n - o) < 0.5) { + if (Math.abs(n - o) <= 0.5) { return; } @@ -40,7 +40,7 @@ public class AppWindowBounds { var o = oldValue.doubleValue(); if (stage.isShowing() && areNumbersValid(o, n)) { // Ignore rounding events - if (Math.abs(n - o) < 0.5) { + if (Math.abs(n - o) <= 0.5) { return; } diff --git a/app/src/main/java/io/xpipe/app/core/window/AppWindowStyle.java b/app/src/main/java/io/xpipe/app/core/window/AppWindowStyle.java index 3ee7d178d..2af5a558a 100644 --- a/app/src/main/java/io/xpipe/app/core/window/AppWindowStyle.java +++ b/app/src/main/java/io/xpipe/app/core/window/AppWindowStyle.java @@ -59,6 +59,12 @@ public class AppWindowStyle { return false; }); + scene.focusOwnerProperty().addListener((observable, oldValue, newValue) -> { + if (newValue == null || !newValue.isFocusTraversable()) { + keyInput.set(false); + } + }); + scene.addEventFilter(KeyEvent.KEY_PRESSED, event -> { var c = event.getCode(); var list = List.of(KeyCode.SPACE, KeyCode.ENTER, KeyCode.SHIFT, KeyCode.TAB); diff --git a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/CustomAgentStrategy.java b/app/src/main/java/io/xpipe/app/cred/CustomAgentStrategy.java similarity index 64% rename from ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/CustomAgentStrategy.java rename to app/src/main/java/io/xpipe/app/cred/CustomAgentStrategy.java index 04aad5c2d..4724be7a7 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/CustomAgentStrategy.java +++ b/app/src/main/java/io/xpipe/app/cred/CustomAgentStrategy.java @@ -1,21 +1,20 @@ -package io.xpipe.ext.base.identity.ssh; +package io.xpipe.app.cred; import io.xpipe.app.comp.base.ButtonComp; import io.xpipe.app.comp.base.HorizontalComp; import io.xpipe.app.comp.base.TextFieldComp; import io.xpipe.app.core.AppI18n; -import io.xpipe.app.platform.BindingsHelper; import io.xpipe.app.platform.OptionsBuilder; import io.xpipe.app.platform.Validator; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.process.CommandBuilder; import io.xpipe.app.process.ShellControl; +import io.xpipe.core.FilePath; import io.xpipe.core.KeyValue; import io.xpipe.core.OsType; import javafx.beans.binding.Bindings; import javafx.beans.property.Property; -import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleStringProperty; import javafx.geometry.Insets; @@ -32,23 +31,24 @@ import java.util.List; @Value @Jacksonized @Builder -public class CustomAgentStrategy implements SshIdentityStrategy { +public class CustomAgentStrategy implements SshIdentityAgentStrategy { @SuppressWarnings("unused") public static OptionsBuilder createOptions( Property p, SshIdentityStrategyChoiceConfig config) { - var forward = - new SimpleBooleanProperty(p.getValue() != null && p.getValue().isForwardAgent()); var publicKey = new SimpleStringProperty(p.getValue() != null ? p.getValue().getPublicKey() : null); - var socketBinding = Bindings.createObjectBinding(() -> { - var agent = AppPrefs.get().sshAgentSocket().getValue(); - if (agent == null) { - agent = AppPrefs.get().defaultSshAgentSocket().getValue(); - } - return agent != null ? agent.toString() : AppI18n.get("agentSocketNotConfigured"); - }, AppPrefs.get().defaultSshAgentSocket(), AppPrefs.get().sshAgentSocket()); + var socketBinding = Bindings.createObjectBinding( + () -> { + var agent = AppPrefs.get().sshAgentSocket().getValue(); + if (agent == null) { + agent = AppPrefs.get().defaultSshAgentSocket().getValue(); + } + return agent != null ? agent.toString() : AppI18n.get("agentSocketNotConfigured"); + }, + AppPrefs.get().defaultSshAgentSocket(), + AppPrefs.get().sshAgentSocket()); var socketProp = new SimpleStringProperty(); socketProp.bind(socketBinding); var socketDisplay = new HorizontalComp(List.of( @@ -66,34 +66,31 @@ public class CustomAgentStrategy implements SshIdentityStrategy { .addComp(socketDisplay) .check(val -> Validator.create( val, - AppI18n.observable("agentSocketNotConfigured"), Bindings.createObjectBinding(() -> { - var agent = AppPrefs.get().sshAgentSocket().getValue(); - if (agent == null) { - agent = AppPrefs.get().defaultSshAgentSocket().getValue(); - } - return agent; - }, AppPrefs.get().sshAgentSocket(), AppPrefs.get().defaultSshAgentSocket()), + AppI18n.observable("agentSocketNotConfigured"), + Bindings.createObjectBinding( + () -> { + var agent = AppPrefs.get().sshAgentSocket().getValue(); + if (agent == null) { + agent = AppPrefs.get() + .defaultSshAgentSocket() + .getValue(); + } + return agent; + }, + AppPrefs.get().sshAgentSocket(), + AppPrefs.get().defaultSshAgentSocket()), i -> { return i != null; })) - .nameAndDescription("forwardAgent") - .addToggle(forward) - .nonNull() - .hide(!config.isAllowAgentForward()) .nameAndDescription("publicKey") - .addComp( - new TextFieldComp(publicKey) - .apply(struc -> struc.setPromptText( - "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIBmhLUTJiP...== Your Comment")), - publicKey) + .addComp(new SshAgentKeyListComp(config.getFileSystem(), p, publicKey, false), publicKey) .bind( () -> { - return new CustomAgentStrategy(forward.get(), publicKey.get()); + return new CustomAgentStrategy(publicKey.get()); }, p); } - boolean forwardAgent; String publicKey; @Override @@ -103,15 +100,12 @@ public class CustomAgentStrategy implements SshIdentityStrategy { if (agent == null) { agent = AppPrefs.get().defaultSshAgentSocket().getValue(); } - SshIdentityStateManager.prepareLocalCustomAgent( - parent, agent); + SshIdentityStateManager.prepareLocalCustomAgent(parent, agent); } } @Override - public void buildCommand(CommandBuilder builder) {} - - private String getIdentityAgent(ShellControl sc) throws Exception { + public FilePath determinetAgentSocketLocation(ShellControl sc) throws Exception { if (!sc.isLocal() || sc.getOsType() == OsType.WINDOWS) { return null; } @@ -122,27 +116,33 @@ public class CustomAgentStrategy implements SshIdentityStrategy { agent = AppPrefs.get().defaultSshAgentSocket().getValue(); } if (agent != null) { - return agent.resolveTildeHome(sc.view().userHome()).toString(); + return agent.resolveTildeHome(sc.view().userHome()); } } return null; } + @Override + public void buildCommand(CommandBuilder builder) {} + @Override public List configOptions(ShellControl sc) throws Exception { var file = SshIdentityStrategy.getPublicKeyPath(sc, publicKey); var l = new ArrayList<>(List.of( new KeyValue("IdentitiesOnly", file.isPresent() ? "yes" : "no"), - new KeyValue("ForwardAgent", forwardAgent ? "yes" : "no"), new KeyValue("IdentityFile", file.isPresent() ? file.get().toString() : "none"), new KeyValue("PKCS11Provider", "none"))); - var agent = getIdentityAgent(sc); + var agent = determinetAgentSocketLocation(sc); if (agent != null) { l.add(new KeyValue("IdentityAgent", "\"" + agent + "\"")); } return l; } + + public PublicKeyStrategy getPublicKeyStrategy() { + return PublicKeyStrategy.Fixed.of(publicKey); + } } diff --git a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/CustomPkcs11LibraryStrategy.java b/app/src/main/java/io/xpipe/app/cred/CustomPkcs11LibraryStrategy.java similarity index 97% rename from ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/CustomPkcs11LibraryStrategy.java rename to app/src/main/java/io/xpipe/app/cred/CustomPkcs11LibraryStrategy.java index b60d78e19..d51faa689 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/CustomPkcs11LibraryStrategy.java +++ b/app/src/main/java/io/xpipe/app/cred/CustomPkcs11LibraryStrategy.java @@ -1,4 +1,4 @@ -package io.xpipe.ext.base.identity.ssh; +package io.xpipe.app.cred; import io.xpipe.app.comp.base.ContextualFileReferenceChoiceComp; import io.xpipe.app.ext.ValidationException; @@ -116,8 +116,7 @@ public class CustomPkcs11LibraryStrategy implements SshIdentityStrategy { new KeyValue("IdentityAgent", "none")); } - @Override - public String getPublicKey() { + public PublicKeyStrategy getPublicKeyStrategy() { return null; } } diff --git a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/GpgAgentStrategy.java b/app/src/main/java/io/xpipe/app/cred/GpgAgentStrategy.java similarity index 73% rename from ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/GpgAgentStrategy.java rename to app/src/main/java/io/xpipe/app/cred/GpgAgentStrategy.java index e5d4b437e..eee104d25 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/GpgAgentStrategy.java +++ b/app/src/main/java/io/xpipe/app/cred/GpgAgentStrategy.java @@ -1,17 +1,16 @@ -package io.xpipe.ext.base.identity.ssh; +package io.xpipe.app.cred; import io.xpipe.app.comp.base.TextFieldComp; import io.xpipe.app.core.AppSystemInfo; import io.xpipe.app.platform.OptionsBuilder; import io.xpipe.app.process.CommandBuilder; -import io.xpipe.app.process.LocalShell; import io.xpipe.app.process.ShellControl; import io.xpipe.app.util.LicenseProvider; +import io.xpipe.core.FilePath; import io.xpipe.core.KeyValue; import io.xpipe.core.OsType; import javafx.beans.property.Property; -import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleStringProperty; import com.fasterxml.jackson.annotation.JsonTypeName; @@ -27,20 +26,15 @@ import java.util.List; @Jacksonized @Builder @JsonTypeName("gpgAgent") -public class GpgAgentStrategy implements SshIdentityStrategy { +public class GpgAgentStrategy implements SshIdentityAgentStrategy { @SuppressWarnings("unused") public static OptionsBuilder createOptions(Property p, SshIdentityStrategyChoiceConfig config) { - var forward = - new SimpleBooleanProperty(p.getValue() != null && p.getValue().isForwardAgent()); var publicKey = new SimpleStringProperty(p.getValue() != null ? p.getValue().getPublicKey() : null); return new OptionsBuilder() - .nameAndDescription("forwardAgent") - .addToggle(forward) - .nonNull() - .hide(!config.isAllowAgentForward()) .nameAndDescription("publicKey") + .addComp(new SshAgentKeyListComp(config.getFileSystem(), p, publicKey, false), publicKey) .addComp( new TextFieldComp(publicKey) .apply(struc -> struc.setPromptText( @@ -48,7 +42,7 @@ public class GpgAgentStrategy implements SshIdentityStrategy { publicKey) .bind( () -> { - return new GpgAgentStrategy(forward.get(), publicKey.get()); + return new GpgAgentStrategy(publicKey.get()); }, p); } @@ -60,18 +54,6 @@ public class GpgAgentStrategy implements SshIdentityStrategy { return supported; } - try { - var found = LocalShell.getShell() - .view() - .findProgram("gpg-connect-agent") - .isPresent(); - if (!found) { - return (supported = false); - } - } catch (Exception ex) { - return (supported = false); - } - if (OsType.ofLocal() == OsType.WINDOWS) { var file = AppSystemInfo.ofWindows().getRoamingAppData().resolve("gnupg", "gpg-agent.conf"); return (supported = Files.exists(file)); @@ -81,7 +63,6 @@ public class GpgAgentStrategy implements SshIdentityStrategy { } } - boolean forwardAgent; String publicKey; @Override @@ -93,9 +74,7 @@ public class GpgAgentStrategy implements SshIdentityStrategy { } @Override - public void buildCommand(CommandBuilder builder) {} - - private String getIdentityAgent(ShellControl sc) throws Exception { + public FilePath determinetAgentSocketLocation(ShellControl sc) throws Exception { if (sc.getOsType() == OsType.WINDOWS) { return null; } @@ -105,23 +84,29 @@ public class GpgAgentStrategy implements SshIdentityStrategy { return null; } - return r; + return FilePath.of(r); } + @Override + public void buildCommand(CommandBuilder builder) {} + @Override public List configOptions(ShellControl sc) throws Exception { var file = SshIdentityStrategy.getPublicKeyPath(sc, publicKey); var l = new ArrayList<>(List.of( new KeyValue("IdentitiesOnly", file.isPresent() ? "yes" : "no"), - new KeyValue("ForwardAgent", forwardAgent ? "yes" : "no"), new KeyValue("IdentityFile", file.isPresent() ? file.get().toString() : "none"), new KeyValue("PKCS11Provider", "none"))); - var agent = getIdentityAgent(sc); + var agent = determinetAgentSocketLocation(sc); if (agent != null) { l.add(new KeyValue("IdentityAgent", "\"" + agent + "\"")); } return l; } + + public PublicKeyStrategy getPublicKeyStrategy() { + return PublicKeyStrategy.Fixed.of(publicKey); + } } diff --git a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/InPlaceKeyStrategy.java b/app/src/main/java/io/xpipe/app/cred/InPlaceKeyStrategy.java similarity index 98% rename from ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/InPlaceKeyStrategy.java rename to app/src/main/java/io/xpipe/app/cred/InPlaceKeyStrategy.java index e69566632..383dce771 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/InPlaceKeyStrategy.java +++ b/app/src/main/java/io/xpipe/app/cred/InPlaceKeyStrategy.java @@ -1,4 +1,4 @@ -package io.xpipe.ext.base.identity.ssh; +package io.xpipe.app.cred; import io.xpipe.app.comp.base.ButtonComp; import io.xpipe.app.comp.base.InputGroupComp; @@ -184,4 +184,8 @@ public class InPlaceKeyStrategy implements SshIdentityStrategy { + Math.abs(Objects.hash(this, AppSystemInfo.ofCurrent().getUser())) + ".key"); return temp; } + + public PublicKeyStrategy getPublicKeyStrategy() { + return PublicKeyStrategy.Fixed.of(publicKey); + } } diff --git a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/KeyFileStrategy.java b/app/src/main/java/io/xpipe/app/cred/KeyFileStrategy.java similarity index 98% rename from ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/KeyFileStrategy.java rename to app/src/main/java/io/xpipe/app/cred/KeyFileStrategy.java index fc695f6a2..264cfa899 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/KeyFileStrategy.java +++ b/app/src/main/java/io/xpipe/app/cred/KeyFileStrategy.java @@ -1,4 +1,4 @@ -package io.xpipe.ext.base.identity.ssh; +package io.xpipe.app.cred; import io.xpipe.app.comp.base.*; import io.xpipe.app.core.AppI18n; @@ -268,4 +268,8 @@ public class KeyFileStrategy implements SshIdentityStrategy { } return s; } + + public PublicKeyStrategy getPublicKeyStrategy() { + return PublicKeyStrategy.Fixed.of(publicKey); + } } diff --git a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/NoIdentityStrategy.java b/app/src/main/java/io/xpipe/app/cred/NoIdentityStrategy.java similarity index 92% rename from ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/NoIdentityStrategy.java rename to app/src/main/java/io/xpipe/app/cred/NoIdentityStrategy.java index 2a34bc0f6..b856912c8 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/NoIdentityStrategy.java +++ b/app/src/main/java/io/xpipe/app/cred/NoIdentityStrategy.java @@ -1,4 +1,4 @@ -package io.xpipe.ext.base.identity.ssh; +package io.xpipe.app.cred; import io.xpipe.app.process.CommandBuilder; import io.xpipe.app.process.ShellControl; @@ -30,7 +30,7 @@ public class NoIdentityStrategy implements SshIdentityStrategy { } @Override - public String getPublicKey() { + public PublicKeyStrategy getPublicKeyStrategy() { return null; } diff --git a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/OpenSshAgentStrategy.java b/app/src/main/java/io/xpipe/app/cred/OpenSshAgentStrategy.java similarity index 70% rename from ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/OpenSshAgentStrategy.java rename to app/src/main/java/io/xpipe/app/cred/OpenSshAgentStrategy.java index 5e4b9e973..a2403fa9e 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/OpenSshAgentStrategy.java +++ b/app/src/main/java/io/xpipe/app/cred/OpenSshAgentStrategy.java @@ -1,16 +1,15 @@ -package io.xpipe.ext.base.identity.ssh; +package io.xpipe.app.cred; -import io.xpipe.app.comp.base.TextFieldComp; import io.xpipe.app.core.AppI18n; import io.xpipe.app.platform.OptionsBuilder; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.process.CommandBuilder; import io.xpipe.app.process.ShellControl; +import io.xpipe.core.FilePath; import io.xpipe.core.KeyValue; import io.xpipe.core.OsType; import javafx.beans.property.Property; -import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleStringProperty; import com.fasterxml.jackson.annotation.JsonTypeName; @@ -25,38 +24,27 @@ import java.util.List; @Value @Jacksonized @Builder -public class OpenSshAgentStrategy implements SshIdentityStrategy { +public class OpenSshAgentStrategy implements SshIdentityAgentStrategy { @SuppressWarnings("unused") public static OptionsBuilder createOptions( Property p, SshIdentityStrategyChoiceConfig config) { var socket = AppPrefs.get().defaultSshAgentSocket().getValue(); - var forward = - new SimpleBooleanProperty(p.getValue() != null && p.getValue().isForwardAgent()); var publicKey = new SimpleStringProperty(p.getValue() != null ? p.getValue().getPublicKey() : null); return new OptionsBuilder() .nameAndDescription("agentSocket") .addStaticString(socket != null ? socket : AppI18n.get("agentSocketNotFound")) .hide(OsType.ofLocal() == OsType.WINDOWS) - .nameAndDescription("forwardAgent") - .addToggle(forward) - .nonNull() - .hide(!config.isAllowAgentForward()) .nameAndDescription("publicKey") - .addComp( - new TextFieldComp(publicKey) - .apply(struc -> struc.setPromptText( - "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIBmhLUTJiP...== Your Comment")), - publicKey) + .addComp(new SshAgentKeyListComp(config.getFileSystem(), p, publicKey, false), publicKey) .bind( () -> { - return new OpenSshAgentStrategy(forward.get(), publicKey.get()); + return new OpenSshAgentStrategy(publicKey.get()); }, p); } - boolean forwardAgent; String publicKey; @Override @@ -68,9 +56,7 @@ public class OpenSshAgentStrategy implements SshIdentityStrategy { } @Override - public void buildCommand(CommandBuilder builder) {} - - private String getIdentityAgent(ShellControl sc) throws Exception { + public FilePath determinetAgentSocketLocation(ShellControl sc) throws Exception { if (sc.getOsType() == OsType.WINDOWS) { return null; } @@ -78,27 +64,33 @@ public class OpenSshAgentStrategy implements SshIdentityStrategy { if (AppPrefs.get() != null) { var socket = AppPrefs.get().defaultSshAgentSocket().getValue(); if (socket != null) { - return socket.resolveTildeHome(sc.view().userHome()).toString(); + return socket.resolveTildeHome(sc.view().userHome()); } } return null; } + @Override + public void buildCommand(CommandBuilder builder) {} + @Override public List configOptions(ShellControl sc) throws Exception { var file = SshIdentityStrategy.getPublicKeyPath(sc, publicKey); var l = new ArrayList<>(List.of( new KeyValue("IdentitiesOnly", file.isPresent() ? "yes" : "no"), - new KeyValue("ForwardAgent", forwardAgent ? "yes" : "no"), new KeyValue("IdentityFile", file.isPresent() ? file.get().toString() : "none"), new KeyValue("PKCS11Provider", "none"))); - var agent = getIdentityAgent(sc); + var agent = determinetAgentSocketLocation(sc); if (agent != null) { l.add(new KeyValue("IdentityAgent", "\"" + agent + "\"")); } return l; } + + public PublicKeyStrategy getPublicKeyStrategy() { + return PublicKeyStrategy.Fixed.of(publicKey); + } } diff --git a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/OtherExternalAgentStrategy.java b/app/src/main/java/io/xpipe/app/cred/OtherExternalAgentStrategy.java similarity index 63% rename from ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/OtherExternalAgentStrategy.java rename to app/src/main/java/io/xpipe/app/cred/OtherExternalAgentStrategy.java index 8d2dff75e..493326a3d 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/OtherExternalAgentStrategy.java +++ b/app/src/main/java/io/xpipe/app/cred/OtherExternalAgentStrategy.java @@ -1,13 +1,12 @@ -package io.xpipe.ext.base.identity.ssh; +package io.xpipe.app.cred; -import io.xpipe.app.comp.base.TextFieldComp; import io.xpipe.app.platform.OptionsBuilder; import io.xpipe.app.process.CommandBuilder; import io.xpipe.app.process.ShellControl; +import io.xpipe.core.FilePath; import io.xpipe.core.KeyValue; import javafx.beans.property.Property; -import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleStringProperty; import com.fasterxml.jackson.annotation.JsonTypeName; @@ -21,43 +20,38 @@ import java.util.List; @Value @Jacksonized @Builder -public class OtherExternalAgentStrategy implements SshIdentityStrategy { +public class OtherExternalAgentStrategy implements SshIdentityAgentStrategy { @SuppressWarnings("unused") public static OptionsBuilder createOptions( Property p, SshIdentityStrategyChoiceConfig config) { - var forward = - new SimpleBooleanProperty(p.getValue() != null && p.getValue().isForwardAgent()); var publicKey = new SimpleStringProperty(p.getValue() != null ? p.getValue().getPublicKey() : null); return new OptionsBuilder() - .nameAndDescription("forwardAgent") - .addToggle(forward) - .nonNull() - .hide(!config.isAllowAgentForward()) .nameAndDescription("publicKey") - .addComp( - new TextFieldComp(publicKey) - .apply(struc -> struc.setPromptText( - "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIBmhLUTJiP...== Your Comment")), - publicKey) + .addComp(new SshAgentKeyListComp(config.getFileSystem(), p, publicKey, false), publicKey) + .nameAndDescription("publicKey") .bind( () -> { - return new OtherExternalAgentStrategy(forward.get(), publicKey.get()); + return new OtherExternalAgentStrategy(publicKey.get()); }, p); } - boolean forwardAgent; String publicKey; @Override public void prepareParent(ShellControl parent) throws Exception { if (parent.isLocal()) { - SshIdentityStateManager.prepareLocalExternalAgent(); + SshIdentityStateManager.prepareLocalExternalAgent(null); } } + @Override + public FilePath determinetAgentSocketLocation(ShellControl parent) { + return null; + } + @Override public void buildCommand(CommandBuilder builder) {} @@ -66,8 +60,11 @@ public class OtherExternalAgentStrategy implements SshIdentityStrategy { var file = SshIdentityStrategy.getPublicKeyPath(sc, publicKey); return List.of( new KeyValue("IdentitiesOnly", file.isPresent() ? "yes" : "no"), - new KeyValue("ForwardAgent", forwardAgent ? "yes" : "no"), new KeyValue("IdentityFile", file.isPresent() ? file.get().toString() : "none"), new KeyValue("PKCS11Provider", "none")); } + + public PublicKeyStrategy getPublicKeyStrategy() { + return PublicKeyStrategy.Fixed.of(publicKey); + } } diff --git a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/PageantStrategy.java b/app/src/main/java/io/xpipe/app/cred/PageantStrategy.java similarity index 72% rename from ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/PageantStrategy.java rename to app/src/main/java/io/xpipe/app/cred/PageantStrategy.java index 100cc841c..acddcae04 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/PageantStrategy.java +++ b/app/src/main/java/io/xpipe/app/cred/PageantStrategy.java @@ -1,18 +1,17 @@ -package io.xpipe.ext.base.identity.ssh; +package io.xpipe.app.cred; -import io.xpipe.app.comp.base.TextFieldComp; import io.xpipe.app.core.AppSystemInfo; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.platform.OptionsBuilder; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.process.CommandBuilder; -import io.xpipe.app.process.LocalShell; import io.xpipe.app.process.ShellControl; +import io.xpipe.app.util.LocalExec; +import io.xpipe.core.FilePath; import io.xpipe.core.KeyValue; import io.xpipe.core.OsType; import javafx.beans.property.Property; -import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleStringProperty; import com.fasterxml.jackson.annotation.JsonTypeName; @@ -30,28 +29,18 @@ import java.util.List; @Value @Jacksonized @Builder -public class PageantStrategy implements SshIdentityStrategy { +public class PageantStrategy implements SshIdentityAgentStrategy { @SuppressWarnings("unused") public static OptionsBuilder createOptions(Property p, SshIdentityStrategyChoiceConfig config) { - var forward = - new SimpleBooleanProperty(p.getValue() != null && p.getValue().isForwardAgent()); var publicKey = new SimpleStringProperty(p.getValue() != null ? p.getValue().getPublicKey() : null); return new OptionsBuilder() - .nameAndDescription("forwardAgent") - .addToggle(forward) - .nonNull() - .hide(!config.isAllowAgentForward()) .nameAndDescription("publicKey") - .addComp( - new TextFieldComp(publicKey) - .apply(struc -> struc.setPromptText( - "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIBmhLUTJiP...== Your Comment")), - publicKey) + .addComp(new SshAgentKeyListComp(config.getFileSystem(), p, publicKey, false), publicKey) .bind( () -> { - return new PageantStrategy(forward.get(), publicKey.get()); + return new PageantStrategy(publicKey.get()); }, p); } @@ -67,7 +56,7 @@ public class PageantStrategy implements SshIdentityStrategy { return true; } else { try { - var found = LocalShell.getShell().view().findProgram("pageant").isPresent(); + var found = LocalExec.readStdoutIfPossible("which", "pageant").isPresent(); return (supported = found); } catch (Exception ex) { return (supported = false); @@ -75,7 +64,6 @@ public class PageantStrategy implements SshIdentityStrategy { } } - boolean forwardAgent; String publicKey; @Override @@ -92,30 +80,33 @@ public class PageantStrategy implements SshIdentityStrategy { throw ErrorEventFactory.expected(new IllegalStateException( "Pageant is not running as the primary agent via the $SSH_AUTH_SOCK variable.")); } + } else if (parent.isLocal()) { + // Check if it exists + getPageantWindowsPipe(); } } @Override - public void buildCommand(CommandBuilder builder) {} - - private String getIdentityAgent(ShellControl sc) { + public FilePath determinetAgentSocketLocation(ShellControl sc) { if (sc.isLocal() && sc.getOsType() == OsType.WINDOWS) { - return getPageantWindowsPipe(); + return FilePath.of(getPageantWindowsPipe()); } return null; } + @Override + public void buildCommand(CommandBuilder builder) {} + @Override public List configOptions(ShellControl sc) throws Exception { var file = SshIdentityStrategy.getPublicKeyPath(sc, publicKey); var l = new ArrayList<>(List.of( new KeyValue("IdentitiesOnly", file.isPresent() ? "yes" : "no"), - new KeyValue("ForwardAgent", forwardAgent ? "yes" : "no"), new KeyValue("IdentityFile", file.isPresent() ? file.get().toString() : "none"), new KeyValue("PKCS11Provider", "none"))); - var agent = getIdentityAgent(sc); + var agent = determinetAgentSocketLocation(sc); if (agent != null) { l.add(new KeyValue("IdentityAgent", "\"" + agent + "\"")); } @@ -125,7 +116,8 @@ public class PageantStrategy implements SshIdentityStrategy { private String getPageantWindowsPipe() { Memory p = new Memory(WinBase.WIN32_FIND_DATA.sizeOf()); - var r = Kernel32.INSTANCE.FindFirstFile("\\\\.\\pipe\\*pageant." + AppSystemInfo.ofCurrent().getUser() + "*", p); + var r = Kernel32.INSTANCE.FindFirstFile( + "\\\\.\\pipe\\*pageant." + AppSystemInfo.ofCurrent().getUser() + "*", p); if (r == WinBase.INVALID_HANDLE_VALUE) { throw ErrorEventFactory.expected(new IllegalStateException("Pageant is not running")); } @@ -136,4 +128,8 @@ public class PageantStrategy implements SshIdentityStrategy { var file = "\\\\.\\pipe\\" + fd.getFileName(); return file; } + + public PublicKeyStrategy getPublicKeyStrategy() { + return PublicKeyStrategy.Fixed.of(publicKey); + } } diff --git a/app/src/main/java/io/xpipe/app/cred/PasswordManagerAgentStrategy.java b/app/src/main/java/io/xpipe/app/cred/PasswordManagerAgentStrategy.java new file mode 100644 index 000000000..f5b721350 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/cred/PasswordManagerAgentStrategy.java @@ -0,0 +1,167 @@ +package io.xpipe.app.cred; + +import io.xpipe.app.comp.base.ButtonComp; +import io.xpipe.app.comp.base.HorizontalComp; +import io.xpipe.app.comp.base.LabelComp; +import io.xpipe.app.core.AppI18n; +import io.xpipe.app.ext.ValidationException; +import io.xpipe.app.platform.OptionsBuilder; +import io.xpipe.app.prefs.AppPrefs; +import io.xpipe.app.process.CommandBuilder; +import io.xpipe.app.process.ShellControl; +import io.xpipe.app.pwman.PasswordManagerKeyConfiguration; +import io.xpipe.app.storage.DataStorage; +import io.xpipe.app.util.Validators; +import io.xpipe.core.FilePath; +import io.xpipe.core.KeyValue; + +import javafx.beans.binding.Bindings; +import javafx.beans.property.Property; +import javafx.beans.property.SimpleStringProperty; +import javafx.geometry.Insets; +import javafx.geometry.Pos; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; +import org.kordamp.ikonli.javafx.FontIcon; + +import java.util.List; + +@JsonTypeName("passwordManagerAgent") +@Value +@Jacksonized +@Builder +public class PasswordManagerAgentStrategy implements SshIdentityAgentStrategy { + + @SuppressWarnings("unused") + public static OptionsBuilder createOptions( + Property p, SshIdentityStrategyChoiceConfig config) { + var identifier = + new SimpleStringProperty(p.getValue() != null ? p.getValue().getIdentifier() : null); + + var pwmanError = Bindings.createObjectBinding( + () -> { + var pwman = AppPrefs.get().passwordManager().getValue(); + if (pwman == null) { + return AppI18n.get("passwordManagerEmpty"); + } + + if (!pwman.supportsKeyConfiguration()) { + return AppI18n.get("passwordManagerNoAgentSupport"); + } + + if (!pwman.getKeyConfiguration().useAgent()) { + return AppI18n.get("passwordManagerNoAgentConfigured"); + } + + return null; + }, + AppPrefs.get().passwordManager(), + AppI18n.activeLanguage()); + var pwmanErrorProp = new SimpleStringProperty(); + pwmanErrorProp.bind(pwmanError); + var pwmanDisplay = new HorizontalComp(List.of( + new LabelComp(pwmanErrorProp) + .maxWidth(10000) + .apply(label -> label.setAlignment(Pos.CENTER_LEFT)) + .hgrow(), + new ButtonComp(null, new FontIcon("mdomz-settings"), () -> { + AppPrefs.get().selectCategory("passwordManager"); + }) + .padding(new Insets(7)))) + .spacing(9); + + return new OptionsBuilder() + .nameAndDescription("passwordManagerSshKeyConfig") + .addComp(pwmanDisplay) + .hide(pwmanErrorProp.isNull()) + .nameAndDescription(useKeyName() ? "agentKeyName" : "publicKey") + .addComp(new SshAgentKeyListComp(config.getFileSystem(), p, identifier, useKeyName()), identifier) + .disable(pwmanErrorProp.isNotNull()) + .nonNull() + .hide(!config.isAllowAgentForward()) + .bind( + () -> { + return new PasswordManagerAgentStrategy(identifier.get()); + }, + p); + } + + String identifier; + + private static PasswordManagerKeyConfiguration getConfig() { + var pwman = AppPrefs.get().passwordManager().getValue(); + return pwman != null + && pwman.getKeyConfiguration() != null + && pwman.getKeyConfiguration().useAgent() + ? pwman.getKeyConfiguration() + : null; + } + + private static boolean useKeyName() { + var config = getConfig(); + return config != null && config.supportsAgentKeyNames(); + } + + @Override + public void checkComplete() throws ValidationException { + Validators.nonNull(identifier); + var config = getConfig(); + if (config == null) { + throw new ValidationException(AppI18n.get("passwordManagerSshKeysNotSupported")); + } + } + + @Override + public void prepareParent(ShellControl parent) throws Exception { + var config = getConfig(); + if (config != null) { + var strat = config.getSshIdentityStrategy(null, false); + strat.prepareParent(parent); + } + } + + @Override + public FilePath determinetAgentSocketLocation(ShellControl parent) { + var config = getConfig(); + return config != null ? FilePath.of(config.getDefaultSocketLocation()) : null; + } + + @Override + public void buildCommand(CommandBuilder builder) { + var config = getConfig(); + if (config != null) { + var strat = config.getSshIdentityStrategy(null, false); + strat.buildCommand(builder); + } + } + + @Override + public List configOptions(ShellControl sc) throws Exception { + var config = getConfig(); + if (config != null) { + var strat = config.getSshIdentityStrategy(getPublicKeyStrategy().retrievePublicKey(), false); + return strat.configOptions(sc); + } else { + return List.of(); + } + } + + @Override + public PublicKeyStrategy getPublicKeyStrategy() { + if (identifier == null) { + return null; + } + + if (!useKeyName()) { + return PublicKeyStrategy.Fixed.of(identifier); + } + + return new PublicKeyStrategy.Dynamic(() -> { + return SshAgentKeyList.findAgentIdentity(DataStorage.get().local().ref(), this, identifier) + .toString(); + }); + } +} diff --git a/app/src/main/java/io/xpipe/app/cred/PasswordManagerInPlaceKeyStrategy.java b/app/src/main/java/io/xpipe/app/cred/PasswordManagerInPlaceKeyStrategy.java new file mode 100644 index 000000000..17ec3c37a --- /dev/null +++ b/app/src/main/java/io/xpipe/app/cred/PasswordManagerInPlaceKeyStrategy.java @@ -0,0 +1,83 @@ +package io.xpipe.app.cred; + +import io.xpipe.app.comp.base.*; +import io.xpipe.app.core.App; +import io.xpipe.app.platform.OptionsBuilder; +import io.xpipe.app.prefs.AppPrefs; +import io.xpipe.app.process.CommandBuilder; +import io.xpipe.app.process.ShellControl; +import io.xpipe.core.FilePath; +import io.xpipe.core.KeyValue; + +import javafx.beans.binding.Bindings; +import javafx.beans.property.Property; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; +import org.kordamp.ikonli.javafx.FontIcon; + +import java.util.List; + +@JsonTypeName("passwordManagerInPlaceKey") +@Value +@Jacksonized +@Builder +public class PasswordManagerInPlaceKeyStrategy implements SshIdentityAgentStrategy { + + @SuppressWarnings("unused") + public static OptionsBuilder createOptions( + Property p, SshIdentityStrategyChoiceConfig config) { + var options = new OptionsBuilder(); + var prefs = AppPrefs.get(); + var keyProperty = options.map(p, PasswordManagerInPlaceKeyStrategy::getKey); + var field = new TextFieldComp(keyProperty).apply(struc -> struc.promptTextProperty() + .bind(Bindings.createStringBinding( + () -> { + return prefs.passwordManager().getValue() != null + ? prefs.passwordManager().getValue().getKeyPlaceholder() + : "?"; + }, + prefs.passwordManager()))); + var button = new ButtonComp(null, new FontIcon("mdomz-settings"), () -> { + AppPrefs.get().selectCategory("passwordManager"); + App.getApp().getStage().requestFocus(); + }); + var content = new InputGroupComp(List.of(field, button)); + content.setMainReference(field); + return options.nameAndDescription("passwordManagerInPlaceKeyKey") + .addComp(content, keyProperty) + .nonNull() + .bind( + () -> { + return PasswordManagerInPlaceKeyStrategy.builder() + .key(keyProperty.get()) + .build(); + }, + p); + } + + String key; + + @Override + public void prepareParent(ShellControl parent) {} + + @Override + public void buildCommand(CommandBuilder builder) {} + + @Override + public List configOptions(ShellControl sc) { + return List.of(); + } + + @Override + public PublicKeyStrategy getPublicKeyStrategy() { + return null; + } + + @Override + public FilePath determinetAgentSocketLocation(ShellControl parent) { + return null; + } +} diff --git a/app/src/main/java/io/xpipe/app/cred/PublicKeyStrategy.java b/app/src/main/java/io/xpipe/app/cred/PublicKeyStrategy.java new file mode 100644 index 000000000..bf02b76d7 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/cred/PublicKeyStrategy.java @@ -0,0 +1,71 @@ +package io.xpipe.app.cred; + +import io.xpipe.core.FailableSupplier; + +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.util.Optional; + +public interface PublicKeyStrategy { + + String retrievePublicKey() throws Exception; + + @EqualsAndHashCode + @ToString + final class Fixed implements PublicKeyStrategy { + + public static Fixed of(String publicKey) { + return publicKey != null ? new Fixed(publicKey) : null; + } + + private final String publicKey; + + public Fixed(String publicKey) { + this.publicKey = publicKey; + } + + public String get() { + return publicKey; + } + + private Optional getFixedPublicKey() { + return Optional.ofNullable(publicKey); + } + + @Override + public String retrievePublicKey() { + return getFixedPublicKey().orElseThrow(); + } + } + + final class Dynamic implements PublicKeyStrategy { + + private final FailableSupplier publicKey; + + public Dynamic(FailableSupplier publicKey) { + this.publicKey = publicKey; + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } + + @Override + public boolean equals(Object obj) { + return obj instanceof Dynamic; + } + + @Override + public String toString() { + return ""; + } + + @Override + public String retrievePublicKey() throws Exception { + var r = publicKey.get(); + return r; + } + } +} diff --git a/app/src/main/java/io/xpipe/app/cred/SshAgentKeyList.java b/app/src/main/java/io/xpipe/app/cred/SshAgentKeyList.java new file mode 100644 index 000000000..a011afaeb --- /dev/null +++ b/app/src/main/java/io/xpipe/app/cred/SshAgentKeyList.java @@ -0,0 +1,101 @@ +package io.xpipe.app.cred; + +import io.xpipe.app.ext.ShellStore; +import io.xpipe.app.issue.ErrorEventFactory; +import io.xpipe.app.process.CommandBuilder; +import io.xpipe.app.storage.DataStoreEntryRef; + +import lombok.Value; + +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public class SshAgentKeyList { + + @Value + public static class Entry { + + String type; + String publicKey; + String name; + + @Override + public String toString() { + return type + " " + publicKey + (name != null ? " " + name : ""); + } + } + + public static Entry findAgentIdentity( + DataStoreEntryRef ref, SshIdentityAgentStrategy strategy, String identifier) throws Exception { + var all = listAgentIdentities(ref, strategy); + var list = all.stream() + .filter(entry -> { + return (entry.getName() != null && entry.getName().equalsIgnoreCase(identifier)) + || entry.getPublicKey().equals(identifier) + || (entry.getType() + " " + entry.getPublicKey()).equals(identifier); + }) + .toList(); + + if (list.isEmpty()) { + var noNames = all.stream().allMatch(entry -> entry.getName() == null); + + var isPublicKey = identifier.contains(" "); + if (!isPublicKey) { + try { + Base64.getDecoder().decode(identifier); + isPublicKey = true; + } catch (IllegalArgumentException ignored) { + } + } + + if (noNames) { + if (!isPublicKey) { + throw ErrorEventFactory.expected(new IllegalArgumentException("Found no agent identity for name " + + identifier + " as the agent does not support names. Use a public key instead")); + } + } + + throw ErrorEventFactory.expected(new IllegalArgumentException( + "Found no agent identity for " + (isPublicKey ? "public key " : "name ") + identifier)); + } + + if (list.size() > 1) { + throw ErrorEventFactory.expected(new IllegalArgumentException("Ambiguous agent identities: " + + list.stream() + .map(entry -> entry.getName() != null ? entry.getName() : entry.toString()) + .collect(Collectors.joining(", ")))); + } + + return list.getFirst(); + } + + public static List listAgentIdentities(DataStoreEntryRef ref, SshIdentityAgentStrategy strategy) + throws Exception { + var session = ref.getStore().getOrStartSession(); + strategy.prepareParent(session); + + var socket = strategy.determinetAgentSocketLocation(session); + var out = session.command(CommandBuilder.of() + .add("ssh-add", "-L") + .fixedEnvironment("SSH_AUTH_SOCK", socket != null ? socket.toString() : null)) + .readStdoutOrThrow(); + var pattern = Pattern.compile("([^ ]+) ([^ ]+)\\s*(?: (.+))?"); + var lines = out.lines().toList(); + var list = new ArrayList(); + for (String line : lines) { + var matcher = pattern.matcher(line); + if (!matcher.matches()) { + continue; + } + + var type = matcher.group(1); + var publicKey = matcher.group(2); + var name = matcher.groupCount() > 2 ? matcher.group(3) : null; + list.add(new Entry(type, publicKey, name)); + } + return list; + } +} diff --git a/app/src/main/java/io/xpipe/app/cred/SshAgentKeyListComp.java b/app/src/main/java/io/xpipe/app/cred/SshAgentKeyListComp.java new file mode 100644 index 000000000..0faeae774 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/cred/SshAgentKeyListComp.java @@ -0,0 +1,108 @@ +package io.xpipe.app.cred; + +import io.xpipe.app.comp.SimpleRegionBuilder; +import io.xpipe.app.comp.base.ButtonComp; +import io.xpipe.app.comp.base.InputGroupComp; +import io.xpipe.app.comp.base.TextFieldComp; +import io.xpipe.app.core.AppI18n; +import io.xpipe.app.ext.ShellStore; +import io.xpipe.app.platform.LabelGraphic; +import io.xpipe.app.storage.DataStorage; +import io.xpipe.app.storage.DataStoreEntryRef; +import io.xpipe.app.util.ThreadHelper; + +import javafx.application.Platform; +import javafx.beans.property.StringProperty; +import javafx.beans.value.ObservableValue; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; + +import atlantafx.base.controls.Popover; +import atlantafx.base.theme.Styles; + +import java.util.List; + +public class SshAgentKeyListComp extends SimpleRegionBuilder { + + private final ObservableValue> ref; + private final ObservableValue sshIdentityStrategy; + private final StringProperty value; + private final boolean useKeyNames; + + public SshAgentKeyListComp( + ObservableValue> ref, + ObservableValue sshIdentityStrategy, + StringProperty value, + boolean useKeyNames) { + this.ref = ref; + this.sshIdentityStrategy = sshIdentityStrategy; + this.value = value; + this.useKeyNames = useKeyNames; + } + + @Override + protected Region createSimple() { + var field = new TextFieldComp(value); + field.apply(struc -> struc.setPromptText( + useKeyNames ? "" : "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIBmhLUTJiP...== ")); + var button = new ButtonComp(null, new LabelGraphic.IconGraphic("mdi2m-magnify-scan"), null); + button.apply(struc -> { + struc.setOnAction(event -> { + DataStoreEntryRef refToUse = ref != null && ref.getValue() != null + ? ref.getValue() + : DataStorage.get().local().ref(); + ThreadHelper.runFailableAsync(() -> { + var list = SshAgentKeyList.listAgentIdentities(refToUse, sshIdentityStrategy.getValue()); + Platform.runLater(() -> { + var popover = new Popover(); + popover.setArrowLocation(Popover.ArrowLocation.TOP_CENTER); + + if (list.size() > 0) { + var content = new VBox(); + content.setPadding(new Insets(10)); + content.setFillWidth(true); + var header = new Label(AppI18n.get("sshAgentHasKeys")); + header.setPadding(new Insets(0, 0, 8, 8)); + content.getChildren().add(header); + for (SshAgentKeyList.Entry entry : list) { + var buttonName = entry.getType() + " " + + (entry.getName() != null ? entry.getName() : entry.getPublicKey()); + var entryButton = new Button(buttonName); + entryButton.setMaxWidth(400); + entryButton.getStyleClass().add(Styles.FLAT); + entryButton.setOnAction(e -> { + value.setValue( + useKeyNames && entry.getName() != null + ? entry.getName() + : entry.getType() + " " + entry.getPublicKey()); + popover.hide(); + e.consume(); + }); + entryButton.setMinWidth(400); + entryButton.setAlignment(Pos.CENTER_LEFT); + entryButton.setMnemonicParsing(false); + content.getChildren().add(entryButton); + } + popover.setContentNode(content); + } else { + var content = new Label(AppI18n.get("sshAgentNoKeys")); + content.setPadding(new Insets(10)); + popover.setContentNode(content); + } + + var target = struc.getParent().getChildrenUnmodifiable().getFirst(); + popover.show(target); + }); + }); + event.consume(); + }); + }); + var inputGroup = new InputGroupComp(List.of(field, button)); + inputGroup.setMainReference(field); + return inputGroup.build(); + } +} diff --git a/app/src/main/java/io/xpipe/app/cred/SshAgentTestComp.java b/app/src/main/java/io/xpipe/app/cred/SshAgentTestComp.java new file mode 100644 index 000000000..9dfc1ad07 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/cred/SshAgentTestComp.java @@ -0,0 +1,84 @@ +package io.xpipe.app.cred; + +import io.xpipe.app.comp.SimpleRegionBuilder; +import io.xpipe.app.comp.base.ButtonComp; +import io.xpipe.app.core.AppI18n; +import io.xpipe.app.ext.ShellStore; +import io.xpipe.app.platform.LabelGraphic; +import io.xpipe.app.storage.DataStorage; +import io.xpipe.app.storage.DataStoreEntryRef; +import io.xpipe.app.util.ThreadHelper; + +import javafx.application.Platform; +import javafx.beans.value.ObservableValue; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; + +import atlantafx.base.controls.Popover; +import atlantafx.base.theme.Styles; + +public class SshAgentTestComp extends SimpleRegionBuilder { + + private final Runnable beforeTest; + private final ObservableValue sshIdentityStrategy; + + public SshAgentTestComp( + Runnable beforeTest, ObservableValue sshIdentityStrategy) { + this.beforeTest = beforeTest; + this.sshIdentityStrategy = sshIdentityStrategy; + } + + @Override + protected Region createSimple() { + var button = new ButtonComp(AppI18n.observable("test"), new LabelGraphic.IconGraphic("mdi2p-play"), null); + button.padding(new Insets(6, 9, 6, 9)); + button.apply(struc -> { + struc.setOnAction(event -> { + DataStoreEntryRef refToUse = + DataStorage.get().local().ref(); + ThreadHelper.runFailableAsync(() -> { + beforeTest.run(); + var list = SshAgentKeyList.listAgentIdentities(refToUse, sshIdentityStrategy.getValue()); + Platform.runLater(() -> { + var popover = new Popover(); + popover.setArrowLocation(Popover.ArrowLocation.LEFT_CENTER); + + if (list.size() > 0) { + var content = new VBox(); + content.setPadding(new Insets(10)); + content.setFillWidth(true); + var header = new Label(AppI18n.get("sshAgentHasKeys")); + header.setPadding(new Insets(0, 0, 8, 8)); + content.getChildren().add(header); + for (SshAgentKeyList.Entry entry : list) { + var buttonName = entry.getType() + " " + + (entry.getName() != null ? entry.getName() : entry.getPublicKey()); + var entryButton = new Button(buttonName); + entryButton.setMaxWidth(400); + entryButton.getStyleClass().add(Styles.FLAT); + entryButton.setMinWidth(400); + entryButton.setAlignment(Pos.CENTER_LEFT); + entryButton.setMnemonicParsing(false); + content.getChildren().add(entryButton); + } + popover.setContentNode(content); + } else { + var content = new Label(AppI18n.get("sshAgentNoKeys")); + content.setPadding(new Insets(10)); + popover.setContentNode(content); + } + + var target = struc; + popover.show(target); + }); + }); + event.consume(); + }); + }); + return button.build(); + } +} diff --git a/app/src/main/java/io/xpipe/app/cred/SshIdentityAgentStrategy.java b/app/src/main/java/io/xpipe/app/cred/SshIdentityAgentStrategy.java new file mode 100644 index 000000000..e0dcc1d22 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/cred/SshIdentityAgentStrategy.java @@ -0,0 +1,11 @@ +package io.xpipe.app.cred; + +import io.xpipe.app.process.ShellControl; +import io.xpipe.core.FilePath; + +public interface SshIdentityAgentStrategy extends SshIdentityStrategy { + + void prepareParent(ShellControl parent) throws Exception; + + FilePath determinetAgentSocketLocation(ShellControl parent) throws Exception; +} diff --git a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/SshIdentityStateManager.java b/app/src/main/java/io/xpipe/app/cred/SshIdentityStateManager.java similarity index 89% rename from ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/SshIdentityStateManager.java rename to app/src/main/java/io/xpipe/app/cred/SshIdentityStateManager.java index 029372c36..741a131bc 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/SshIdentityStateManager.java +++ b/app/src/main/java/io/xpipe/app/cred/SshIdentityStateManager.java @@ -1,9 +1,10 @@ -package io.xpipe.ext.base.identity.ssh; +package io.xpipe.app.cred; import io.xpipe.app.issue.ErrorAction; import io.xpipe.app.issue.ErrorEvent; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.process.*; +import io.xpipe.app.util.DocumentationLink; import io.xpipe.core.FilePath; import io.xpipe.core.OsType; @@ -45,7 +46,7 @@ public class SshIdentityStateManager { if (external && !gpgRunning && !opensshRunning) { throw ErrorEventFactory.expected(new IllegalStateException( - "An external password manager agent is running, but XPipe requested to use another SSH agent. You have to disable the " + "An external password manager agent is running, but XPipe requested to use another SSH agent. You have to disable the other running " + "password manager agent first.")); } @@ -112,15 +113,16 @@ public class SshIdentityStateManager { var r = c.readStdoutAndStderr(); if (c.getExitCode() != 0) { var posixMessage = sc.getOsType() != OsType.WINDOWS - ? authSock != null - ? " and the socket " + authSock - : " and the SSH agent socket in the settings menu" + ? authSock != null ? " at the socket location " + authSock : "" : ""; - var ex = new IllegalStateException("Unable to list agent identities via command ssh-add -l:\n" + r[0] - + "\n" - + r[1] - + "\nPlease check your SSH agent configuration%s.".formatted(posixMessage)); + var joined = (!r[0].isEmpty() ? r[0] + "\n" : "") + r[1]; + var ex = new IllegalStateException("Unable to list agent identities via command ssh-add -l:\n" + joined + + "\n\n" + "Please check whether the agent is running correctly%s.".formatted(posixMessage)); var eventBuilder = ErrorEventFactory.fromThrowable(ex).expected(); + if (joined.toLowerCase().contains("signing failed: agent refused operation") + || joined.toLowerCase().contains("error connecting to agent: connecting refused")) { + eventBuilder.documentationLink(DocumentationLink.SSH_AGENT_REFUSAL); + } ErrorEventFactory.preconfigure(eventBuilder); throw ex; } @@ -136,7 +138,7 @@ public class SshIdentityStateManager { } } - public static synchronized void prepareLocalExternalAgent() throws Exception { + public static synchronized void prepareLocalExternalAgent(FilePath socket) throws Exception { if (runningAgent == RunningAgent.EXTERNAL_AGENT) { return; } @@ -149,12 +151,12 @@ public class SshIdentityStateManager { if (!pipeExists) { // No agent is running throw ErrorEventFactory.expected(new IllegalStateException( - "An external password manager agent is set for this connection, but no external SSH agent is running. Make sure that the " - + "agent is started in your password manager")); + "An external agent is configured, but no external SSH agent is running. Make sure that the external " + + "agent is started")); } } - checkLocalAgentIdentities(null); + checkLocalAgentIdentities(socket != null ? socket.toString() : null); runningAgent = RunningAgent.EXTERNAL_AGENT; } diff --git a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/SshIdentityStrategy.java b/app/src/main/java/io/xpipe/app/cred/SshIdentityStrategy.java similarity index 77% rename from ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/SshIdentityStrategy.java rename to app/src/main/java/io/xpipe/app/cred/SshIdentityStrategy.java index 90daccb1a..05f48db76 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/SshIdentityStrategy.java +++ b/app/src/main/java/io/xpipe/app/cred/SshIdentityStrategy.java @@ -1,4 +1,4 @@ -package io.xpipe.ext.base.identity.ssh; +package io.xpipe.app.cred; import io.xpipe.app.ext.ValidationException; import io.xpipe.app.issue.ErrorEventFactory; @@ -11,7 +11,6 @@ import io.xpipe.core.FilePath; import io.xpipe.core.KeyValue; import io.xpipe.core.OsType; -import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; import java.util.ArrayList; @@ -19,26 +18,15 @@ import java.util.List; import java.util.Optional; @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") -@JsonSubTypes({ - @JsonSubTypes.Type(value = NoIdentityStrategy.class), - @JsonSubTypes.Type(value = KeyFileStrategy.class), - @JsonSubTypes.Type(value = InPlaceKeyStrategy.class), - @JsonSubTypes.Type(value = OpenSshAgentStrategy.class), - @JsonSubTypes.Type(value = PageantStrategy.class), - @JsonSubTypes.Type(value = CustomAgentStrategy.class), - @JsonSubTypes.Type(value = GpgAgentStrategy.class), - @JsonSubTypes.Type(value = YubikeyPivStrategy.class), - @JsonSubTypes.Type(value = CustomPkcs11LibraryStrategy.class), - @JsonSubTypes.Type(value = OtherExternalAgentStrategy.class) -}) public interface SshIdentityStrategy { - static List> getSubclasses() { + static List> getAvailable() { var l = new ArrayList>(); l.add(NoIdentityStrategy.class); l.add(InPlaceKeyStrategy.class); l.add(KeyFileStrategy.class); l.add(OpenSshAgentStrategy.class); + l.add(PasswordManagerAgentStrategy.class); if (OsType.ofLocal() != OsType.WINDOWS) { l.add(CustomAgentStrategy.class); } @@ -55,6 +43,24 @@ public interface SshIdentityStrategy { return l; } + static List> getClasses() { + var l = new ArrayList>(); + l.add(NoIdentityStrategy.class); + l.add(InPlaceKeyStrategy.class); + l.add(KeyFileStrategy.class); + l.add(OpenSshAgentStrategy.class); + l.add(PasswordManagerAgentStrategy.class); + l.add(PasswordManagerInPlaceKeyStrategy.class); + l.add(CustomAgentStrategy.class); + l.add(GpgAgentStrategy.class); + l.add(PageantStrategy.class); + l.add(YubikeyPivStrategy.class); + l.add(CustomPkcs11LibraryStrategy.class); + l.add(OtherExternalAgentStrategy.class); + + return l; + } + static Optional getPublicKeyPath(ShellControl sc, String publicKey) throws Exception { if (publicKey == null || publicKey.isBlank()) { return Optional.empty(); @@ -97,5 +103,5 @@ public interface SshIdentityStrategy { return new SecretNoneStrategy(); } - String getPublicKey(); + PublicKeyStrategy getPublicKeyStrategy(); } diff --git a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/SshIdentityStrategyChoiceConfig.java b/app/src/main/java/io/xpipe/app/cred/SshIdentityStrategyChoiceConfig.java similarity index 91% rename from ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/SshIdentityStrategyChoiceConfig.java rename to app/src/main/java/io/xpipe/app/cred/SshIdentityStrategyChoiceConfig.java index ae8f74f1f..dead1746e 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/SshIdentityStrategyChoiceConfig.java +++ b/app/src/main/java/io/xpipe/app/cred/SshIdentityStrategyChoiceConfig.java @@ -1,4 +1,4 @@ -package io.xpipe.ext.base.identity.ssh; +package io.xpipe.app.cred; import io.xpipe.app.ext.ShellStore; import io.xpipe.app.storage.DataStoreEntryRef; diff --git a/ext/base/src/main/java/io/xpipe/ext/base/identity/UsernameStrategy.java b/app/src/main/java/io/xpipe/app/cred/UsernameStrategy.java similarity index 98% rename from ext/base/src/main/java/io/xpipe/ext/base/identity/UsernameStrategy.java rename to app/src/main/java/io/xpipe/app/cred/UsernameStrategy.java index b898f369b..bbba93057 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/identity/UsernameStrategy.java +++ b/app/src/main/java/io/xpipe/app/cred/UsernameStrategy.java @@ -1,4 +1,4 @@ -package io.xpipe.ext.base.identity; +package io.xpipe.app.cred; import io.xpipe.core.FailableSupplier; diff --git a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/YubikeyPivStrategy.java b/app/src/main/java/io/xpipe/app/cred/YubikeyPivStrategy.java similarity index 97% rename from ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/YubikeyPivStrategy.java rename to app/src/main/java/io/xpipe/app/cred/YubikeyPivStrategy.java index d91322226..86e98e9dd 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/identity/ssh/YubikeyPivStrategy.java +++ b/app/src/main/java/io/xpipe/app/cred/YubikeyPivStrategy.java @@ -1,4 +1,4 @@ -package io.xpipe.ext.base.identity.ssh; +package io.xpipe.app.cred; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.process.CommandBuilder; @@ -81,7 +81,7 @@ public class YubikeyPivStrategy implements SshIdentityStrategy { } @Override - public String getPublicKey() { + public PublicKeyStrategy getPublicKeyStrategy() { return null; } } diff --git a/app/src/main/java/io/xpipe/app/ext/DataStore.java b/app/src/main/java/io/xpipe/app/ext/DataStore.java index 10f99ea4c..442b4cfe3 100644 --- a/app/src/main/java/io/xpipe/app/ext/DataStore.java +++ b/app/src/main/java/io/xpipe/app/ext/DataStore.java @@ -1,10 +1,16 @@ package io.xpipe.app.ext; +import io.xpipe.app.storage.DataStoreEntryRef; + import com.fasterxml.jackson.annotation.JsonTypeInfo; +import java.util.List; + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") public interface DataStore { + List> getDependencies(); + default boolean isComplete() { try { checkComplete(); diff --git a/app/src/main/java/io/xpipe/app/ext/DataStoreDependencies.java b/app/src/main/java/io/xpipe/app/ext/DataStoreDependencies.java new file mode 100644 index 000000000..7feb33575 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/ext/DataStoreDependencies.java @@ -0,0 +1,33 @@ +package io.xpipe.app.ext; + +import io.xpipe.app.storage.DataStoreEntryRef; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +public class DataStoreDependencies { + + @SuppressWarnings({"unchecked", "rawtypes"}) + public static List> of(Object... dependencies) { + var l = new ArrayList>(); + for (Object dependency : dependencies) { + if (dependency instanceof DataStoreEntryRef r) { + l.add(r); + } else if (dependency instanceof List li) { + l.addAll(li.stream().filter(o -> o != null).toList()); + } + } + return l; + } + + public static List> of(DataStoreEntryRef... dependencies) { + return Arrays.stream(dependencies).filter(Objects::nonNull).toList(); + } + + public static List> of(List> refs) { + return refs.stream().filter(Objects::nonNull).collect(Collectors.toList()); + } +} diff --git a/app/src/main/java/io/xpipe/app/ext/DataStoreProvider.java b/app/src/main/java/io/xpipe/app/ext/DataStoreProvider.java index 26f4c4c0c..e1018adcf 100644 --- a/app/src/main/java/io/xpipe/app/ext/DataStoreProvider.java +++ b/app/src/main/java/io/xpipe/app/ext/DataStoreProvider.java @@ -27,6 +27,10 @@ import java.util.UUID; public interface DataStoreProvider { + default boolean allowCreation() { + return true; + } + default boolean showIncompleteInfo() { return false; } diff --git a/app/src/main/java/io/xpipe/app/ext/DependentDataStore.java b/app/src/main/java/io/xpipe/app/ext/DependentDataStore.java new file mode 100644 index 000000000..60e3f71bd --- /dev/null +++ b/app/src/main/java/io/xpipe/app/ext/DependentDataStore.java @@ -0,0 +1,10 @@ +package io.xpipe.app.ext; + +import io.xpipe.app.storage.DataStoreEntryRef; + +import java.util.List; + +public interface DependentDataStore extends DataStore { + + List> getDependencies(); +} diff --git a/app/src/main/java/io/xpipe/app/ext/GroupStore.java b/app/src/main/java/io/xpipe/app/ext/GroupStore.java index 355dde3b5..c6967f204 100644 --- a/app/src/main/java/io/xpipe/app/ext/GroupStore.java +++ b/app/src/main/java/io/xpipe/app/ext/GroupStore.java @@ -2,10 +2,17 @@ package io.xpipe.app.ext; import io.xpipe.app.storage.DataStoreEntryRef; +import java.util.List; + public interface GroupStore extends DataStore { DataStoreEntryRef getParent(); + @Override + default List> getDependencies() { + return DataStoreDependencies.of(getParent()); + } + @Override default void checkComplete() throws Throwable { var p = getParent(); diff --git a/app/src/main/java/io/xpipe/app/ext/LocalStore.java b/app/src/main/java/io/xpipe/app/ext/LocalStore.java index 8f6cceeec..b50a166d5 100644 --- a/app/src/main/java/io/xpipe/app/ext/LocalStore.java +++ b/app/src/main/java/io/xpipe/app/ext/LocalStore.java @@ -7,6 +7,8 @@ import io.xpipe.app.storage.DataStoreEntryRef; import com.fasterxml.jackson.annotation.JsonTypeName; import lombok.Value; +import java.util.List; + @JsonTypeName("local") @Value public class LocalStore implements NetworkTunnelStore, ShellStore, StatefulDataStore { @@ -39,4 +41,9 @@ public class LocalStore implements NetworkTunnelStore, ShellStore, StatefulDataS public NetworkTunnelSession createTunnelSession(int localPort, int remotePort, String address) { return null; } + + @Override + public List> getDependencies() { + return List.of(); + } } diff --git a/app/src/main/java/io/xpipe/app/ext/NetworkContainerStoreState.java b/app/src/main/java/io/xpipe/app/ext/NetworkContainerStoreState.java new file mode 100644 index 000000000..2ff960668 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/ext/NetworkContainerStoreState.java @@ -0,0 +1,34 @@ +package io.xpipe.app.ext; + +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.experimental.FieldDefaults; +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) +@Getter +@EqualsAndHashCode(callSuper = true) +@SuperBuilder(toBuilder = true) +@Jacksonized +public class NetworkContainerStoreState extends ContainerStoreState { + + String ipv4; + String ipv6; + + @Override + public DataStoreState mergeCopy(DataStoreState newer) { + var n = (NetworkContainerStoreState) newer; + var b = toBuilder(); + mergeBuilder(n, b); + return b.build(); + } + + protected void mergeBuilder( + NetworkContainerStoreState css, NetworkContainerStoreState.NetworkContainerStoreStateBuilder b) { + super.mergeBuilder(css, b); + b.ipv4 = css.ipv4; + b.ipv6 = css.ipv6; + } +} diff --git a/app/src/main/java/io/xpipe/app/ext/ProcessControlProvider.java b/app/src/main/java/io/xpipe/app/ext/ProcessControlProvider.java index d515aab5c..aa7739ab2 100644 --- a/app/src/main/java/io/xpipe/app/ext/ProcessControlProvider.java +++ b/app/src/main/java/io/xpipe/app/ext/ProcessControlProvider.java @@ -10,7 +10,6 @@ import io.xpipe.app.process.ShellDialect; import io.xpipe.app.secret.SecretRetrievalStrategy; import io.xpipe.app.storage.DataStoreEntryRef; import io.xpipe.app.vnc.VncBaseStore; -import io.xpipe.core.FilePath; import io.xpipe.core.SecretValue; import javafx.beans.property.Property; @@ -79,6 +78,4 @@ public abstract class ProcessControlProvider { public abstract void cloneRepository(String url, Path target) throws Exception; public abstract void pullRepository(Path target) throws Exception; - - public abstract void checkSshAgent(ShellControl sc, FilePath agent) throws Exception; } diff --git a/app/src/main/java/io/xpipe/app/hub/action/impl/InitHubLeafProvider.java b/app/src/main/java/io/xpipe/app/hub/action/impl/InitHubLeafProvider.java index 328b4f2d0..6e776c016 100644 --- a/app/src/main/java/io/xpipe/app/hub/action/impl/InitHubLeafProvider.java +++ b/app/src/main/java/io/xpipe/app/hub/action/impl/InitHubLeafProvider.java @@ -14,18 +14,7 @@ public abstract class InitHubLeafProvider implements Hub public void init() { ThreadHelper.runFailableAsync(() -> { available = check(); - - // Update entries to potentially show item - if (available != null) { - StoreViewState.get().getAllEntries().getList().stream() - .filter(w -> w.getValidity().getValue().isUsable()) - .forEach(w -> { - if (getApplicableClass() - .isAssignableFrom(w.getStore().getValue().getClass())) { - w.update(); - } - }); - } + StoreViewState.get().updateWrappers(); }); } diff --git a/app/src/main/java/io/xpipe/app/hub/action/impl/ScanHubBatchProvider.java b/app/src/main/java/io/xpipe/app/hub/action/impl/ScanHubBatchProvider.java index 202f31c26..8eaa910b5 100644 --- a/app/src/main/java/io/xpipe/app/hub/action/impl/ScanHubBatchProvider.java +++ b/app/src/main/java/io/xpipe/app/hub/action/impl/ScanHubBatchProvider.java @@ -38,6 +38,10 @@ public class ScanHubBatchProvider implements BatchHubProvider { @Override public boolean isApplicable(DataStoreEntryRef o) { + if (!o.get().getProvider().shouldShowScan()) { + return false; + } + var state = o.get().getStorePersistentState(); if (state instanceof SystemState systemState) { return (systemState.getShellDialect() == null diff --git a/app/src/main/java/io/xpipe/app/hub/action/impl/ScanHubLeafProvider.java b/app/src/main/java/io/xpipe/app/hub/action/impl/ScanHubLeafProvider.java index 07f1a6493..0157c742d 100644 --- a/app/src/main/java/io/xpipe/app/hub/action/impl/ScanHubLeafProvider.java +++ b/app/src/main/java/io/xpipe/app/hub/action/impl/ScanHubLeafProvider.java @@ -27,6 +27,11 @@ public class ScanHubLeafProvider implements HubLeafProvider { return true; } + @Override + public boolean isApplicable(DataStoreEntryRef o) { + return o.get().getProvider().shouldShowScan(); + } + @Override public ObservableValue getName(DataStoreEntryRef store) { return AppI18n.observable("scanConnections"); diff --git a/app/src/main/java/io/xpipe/app/hub/comp/DataStoreCategoryChoiceComp.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreCategoryChoiceComp.java similarity index 82% rename from app/src/main/java/io/xpipe/app/hub/comp/DataStoreCategoryChoiceComp.java rename to app/src/main/java/io/xpipe/app/hub/comp/StoreCategoryChoiceComp.java index c8074ba62..13e3f2944 100644 --- a/app/src/main/java/io/xpipe/app/hub/comp/DataStoreCategoryChoiceComp.java +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreCategoryChoiceComp.java @@ -1,6 +1,7 @@ package io.xpipe.app.hub.comp; import io.xpipe.app.comp.SimpleRegionBuilder; +import io.xpipe.app.comp.base.PrettyImageHelper; import io.xpipe.app.platform.PlatformThread; import javafx.beans.property.Property; @@ -15,7 +16,7 @@ import lombok.Value; import java.util.function.Predicate; -public class DataStoreCategoryChoiceComp extends SimpleRegionBuilder { +public class StoreCategoryChoiceComp extends SimpleRegionBuilder { private final StoreCategoryWrapper root; private final Property external; @@ -23,12 +24,12 @@ public class DataStoreCategoryChoiceComp extends SimpleRegionBuilder { private final boolean applyExternalInitially; private final Predicate filter; - public DataStoreCategoryChoiceComp( + public StoreCategoryChoiceComp( StoreCategoryWrapper root, Property external, Property value, - boolean applyExternalInitially, Predicate filter - ) { + boolean applyExternalInitially, + Predicate filter) { this.root = root; this.external = external; this.value = value; @@ -57,7 +58,10 @@ public class DataStoreCategoryChoiceComp extends SimpleRegionBuilder { if (!applyExternalInitially) { value.setValue(last); } - var box = new ComboBox<>(StoreViewState.get().getSortedCategories(root).filtered(filter).getList()); + var box = new ComboBox<>(StoreViewState.get() + .getSortedCategories(root, true) + .filtered(filter) + .getList()); box.setValue(value.getValue()); box.valueProperty().addListener((observable, oldValue, newValue) -> { value.setValue(newValue); @@ -83,9 +87,12 @@ public class DataStoreCategoryChoiceComp extends SimpleRegionBuilder { super.updateItem(w, empty); textProperty().unbind(); if (w != null) { + setGraphic(PrettyImageHelper.ofFixedSizeSquare(w.getIconFile().getValue(), 16) + .build()); textProperty().bind(w.getShownName()); setPadding(new Insets(6, 6, 6, 8 + (indent ? w.getDepth() * 8 : 0))); } else { + setGraphic(null); setText("None"); } } diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreCategoryComp.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreCategoryComp.java index 221b52007..0b951a25c 100644 --- a/app/src/main/java/io/xpipe/app/hub/comp/StoreCategoryComp.java +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreCategoryComp.java @@ -22,10 +22,7 @@ import javafx.beans.property.SimpleStringProperty; import javafx.css.PseudoClass; import javafx.geometry.Insets; import javafx.geometry.Pos; -import javafx.scene.control.ContextMenu; -import javafx.scene.control.Menu; -import javafx.scene.control.MenuItem; -import javafx.scene.control.SeparatorMenuItem; +import javafx.scene.control.*; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyEvent; @@ -37,6 +34,7 @@ import lombok.EqualsAndHashCode; import lombok.Value; import org.kordamp.ikonli.javafx.FontIcon; +import java.util.Arrays; import java.util.Comparator; import java.util.List; import java.util.Locale; @@ -46,6 +44,7 @@ import java.util.Locale; public class StoreCategoryComp extends SimpleRegionBuilder { private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected"); + private static final PseudoClass ROOT = PseudoClass.getPseudoClass("root"); StoreCategoryWrapper category; @@ -136,20 +135,25 @@ public class StoreCategoryComp extends SimpleRegionBuilder { var count = new CountComp( category.getShownContainedEntriesCount(), category.getAllContainedEntriesCount(), - string -> "(" + string + ")"); + string -> "[" + string + "]"); + count.style("count"); count.hide(Bindings.equal(0, category.getShownContainedEntriesCount())); count.minWidth(Region.USE_PREF_SIZE); + var iconButton = new StoreCategoryIconComp(category, 16); + var showStatus = hover.or(new SimpleBooleanProperty(DataStorage.get().supportsSync())) .or(showing) .or(focus); var h = new HorizontalComp(List.of( expandButton, - RegionBuilder.hspacer(category.getCategory().getParentCategory() == null ? 3 : 0), + RegionBuilder.hspacer(2), + iconButton, + RegionBuilder.hspacer(4), RegionBuilder.of(() -> name).hgrow(), RegionBuilder.hspacer(2), count, - RegionBuilder.hspacer(7), + RegionBuilder.hspacer(9), statusButton.hide(showStatus.not()))); h.padding(new Insets(0, 10, 0, (category.getDepth() * 10))); @@ -193,6 +197,7 @@ public class StoreCategoryComp extends SimpleRegionBuilder { var v = new VerticalComp(List.of(categoryButton, children.hide(hide))); v.style("category"); v.apply(struc -> { + struc.pseudoClassStateChanged(ROOT, category.getCategory().getParentCategory() == null); StoreViewState.get().getActiveCategory().subscribe(val -> { struc.pseudoClassStateChanged(SELECTED, val.equals(category)); }); @@ -226,6 +231,7 @@ public class StoreCategoryComp extends SimpleRegionBuilder { newCategory.setOnAction(event -> { StoreViewState.get().createNewCategory(category); }); + newCategory.setDisable(!DataStorage.get().canCreateStoreCategoryWithin(category.getCategory())); contextMenu.getItems().add(newCategory); contextMenu.getItems().add(new SeparatorMenuItem()); @@ -243,18 +249,48 @@ public class StoreCategoryComp extends SimpleRegionBuilder { }); contextMenu.getItems().add(rename); + var color = new Menu(AppI18n.get("color"), new FontIcon("mdi2f-format-color-fill")); + var none = new MenuItem(); + none.textProperty().bind(AppI18n.observable("none")); + none.setOnAction(event -> { + category.getCategory().setConfig(category.getCategory().getConfig().withColor(null)); + event.consume(); + }); + none.setGraphic(DataStoreColor.createDisplayGraphic(null)); + color.getItems().add(none); + Arrays.stream(DataStoreColor.values()).forEach(dataStoreColor -> { + MenuItem m = new MenuItem(); + m.textProperty().bind(AppI18n.observable(dataStoreColor.getId())); + m.setOnAction(event -> { + category.getCategory() + .setConfig(category.getCategory().getConfig().withColor(dataStoreColor)); + event.consume(); + }); + m.setGraphic(DataStoreColor.createDisplayGraphic(dataStoreColor)); + color.getItems().add(m); + }); + contextMenu.getItems().add(color); + contextMenu.getItems().add(new SeparatorMenuItem()); if (category.canMove()) { var move = new Menu(AppI18n.get("moveTo"), new FontIcon("mdi2f-folder-move-outline")); StoreViewState.get() - .getSortedCategories(getCategory().getRoot()) + .getSortedCategories(getCategory().getRoot(), true) .getList() .forEach(storeCategoryWrapper -> { - MenuItem m = new MenuItem(); - m.textProperty() - .setValue(" ".repeat(storeCategoryWrapper.getDepth()) - + storeCategoryWrapper.getName().getValue()); + var m = new CustomMenuItem(); + + var l = new Label(); + l.setGraphic(PrettyImageHelper.ofFixedSizeSquare( + storeCategoryWrapper.getIconFile().getValue(), 16) + .padding(new Insets(0, 0, 1, 0)) + .build()); + l.setText(storeCategoryWrapper.getName().getValue()); + l.setPadding(new Insets(0, 1, 1, storeCategoryWrapper.getDepth() * 10)); + m.setContent(l); + l.prefWidthProperty().bind(contextMenu.widthProperty().subtract(40)); + m.setOnAction(event -> { category.moveToParent(storeCategoryWrapper.getCategory()); event.consume(); diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreCategoryConfigComp.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreCategoryConfigComp.java index 566e381f1..e36c64cb7 100644 --- a/app/src/main/java/io/xpipe/app/hub/comp/StoreCategoryConfigComp.java +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreCategoryConfigComp.java @@ -1,28 +1,26 @@ package io.xpipe.app.hub.comp; +import io.xpipe.app.comp.RegionBuilder; import io.xpipe.app.comp.SimpleRegionBuilder; -import io.xpipe.app.comp.base.ChoiceComp; import io.xpipe.app.comp.base.ModalButton; import io.xpipe.app.comp.base.ModalOverlay; +import io.xpipe.app.comp.base.ToggleGroupComp; import io.xpipe.app.core.AppI18n; import io.xpipe.app.ext.DataStore; import io.xpipe.app.platform.OptionsBuilder; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.storage.DataStoreCategoryConfig; -import io.xpipe.app.storage.DataStoreColor; import io.xpipe.app.storage.DataStoreEntry; import javafx.beans.property.Property; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ObservableValue; -import javafx.scene.control.ListCell; import javafx.scene.control.ScrollPane; import javafx.scene.layout.Region; import lombok.AllArgsConstructor; import java.util.LinkedHashMap; -import java.util.function.Supplier; @AllArgsConstructor public class StoreCategoryConfigComp extends SimpleRegionBuilder { @@ -43,16 +41,25 @@ public class StoreCategoryConfigComp extends SimpleRegionBuilder { modal.show(); } + private RegionBuilder createToggle(Property prop, Boolean inherited) { + var map = new LinkedHashMap>(); + map.put(Boolean.FALSE, AppI18n.observable("no")); + map.put( + null, + AppI18n.observable("inherit", inherited != null && inherited ? AppI18n.get("yes") : AppI18n.get("no"))); + map.put(Boolean.TRUE, AppI18n.observable("yes")); + var comp = new ToggleGroupComp<>(prop, new SimpleObjectProperty<>(map)); + return comp; + } + @Override protected Region createSimple() { - var colors = new LinkedHashMap>(); - colors.put(null, AppI18n.observable("none")); - for (DataStoreColor value : DataStoreColor.values()) { - colors.put(value, AppI18n.observable(value.getId())); - } + var parents = DataStorage.get().getCategoryParentHierarchy(wrapper.getCategory()); + var parentConfig = parents.size() > 1 + ? DataStorage.get().getEffectiveCategoryConfig(parents.get(parents.size() - 2)) + : DataStoreCategoryConfig.empty(); var c = config.getValue(); - var color = new SimpleObjectProperty<>(c.getColor()); var scripts = new SimpleObjectProperty<>(c.getDontAllowScripts()); var confirm = new SimpleObjectProperty<>(c.getConfirmAllModifications()); var sync = new SimpleObjectProperty<>(c.getSync()); @@ -66,52 +73,28 @@ public class StoreCategoryConfigComp extends SimpleRegionBuilder { : null); var connectionsCategory = wrapper.getRoot().equals(StoreViewState.get().getAllConnectionsCategory()); - var colorChoice = new ChoiceComp<>(color, colors, false); - colorChoice.apply(struc -> { - Supplier> cell = () -> new ListCell<>() { - @Override - protected void updateItem(DataStoreColor color, boolean empty) { - super.updateItem(color, empty); - if (color == null) { - setText(AppI18n.get("none")); - setGraphic(DataStoreColor.createDisplayGraphic(null)); - return; - } - - setText(AppI18n.get(color.getId())); - setGraphic(DataStoreColor.createDisplayGraphic(color)); - } - }; - struc.setButtonCell(cell.get()); - struc.setCellFactory(ignored -> { - return cell.get(); - }); - }); - var options = new OptionsBuilder(); var specialCategorySync = !wrapper.getCategory().canShare(); var syncDisable = !DataStorage.get().supportsSync() || ((sync.getValue() == null || !sync.getValue()) && !wrapper.getCategory().canShare()); - var syncHide = !DataStorage.get().supportsSync(); options.name( specialCategorySync ? AppI18n.observable( "categorySyncSpecial", wrapper.getName().getValue()) : AppI18n.observable("categorySync")) .description("categorySyncDescription") - .addYesNoToggle(sync) + .addComp(createToggle(sync, parentConfig.getSync()), sync) .disable(syncDisable) - .hide(syncHide) .nameAndDescription("categoryDontAllowScripts") - .addYesNoToggle(scripts) + .addComp(createToggle(scripts, parentConfig.getDontAllowScripts()), scripts) .hide(!connectionsCategory) .nameAndDescription("categoryConfirmAllModifications") - .addYesNoToggle(confirm) + .addComp(createToggle(confirm, parentConfig.getConfirmAllModifications()), confirm) .hide(!connectionsCategory) .nameAndDescription("categoryFreeze") - .addYesNoToggle(freeze) + .addComp(createToggle(freeze, parentConfig.getFreezeConfigurations()), freeze) .hide(!connectionsCategory) .nameAndDescription("categoryDefaultIdentity") .addComp( @@ -123,12 +106,10 @@ public class StoreCategoryConfigComp extends SimpleRegionBuilder { StoreViewState.get().getAllIdentitiesCategory()), ref) .hide(!connectionsCategory) - .nameAndDescription("categoryColor") - .addComp(colorChoice, color) .bind( () -> { return new DataStoreCategoryConfig( - color.get(), + c.getColor(), scripts.get(), confirm.get(), sync.get(), diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreCategoryIconComp.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreCategoryIconComp.java new file mode 100644 index 000000000..048eea547 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreCategoryIconComp.java @@ -0,0 +1,58 @@ +package io.xpipe.app.hub.comp; + +import io.xpipe.app.comp.SimpleRegionBuilder; +import io.xpipe.app.comp.base.PrettyImageHelper; + +import javafx.beans.binding.Bindings; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; + +import lombok.AllArgsConstructor; +import org.kordamp.ikonli.javafx.FontIcon; + +@AllArgsConstructor +public class StoreCategoryIconComp extends SimpleRegionBuilder { + + private final StoreCategoryWrapper wrapper; + private final int size; + + @Override + protected Region createSimple() { + var imageComp = PrettyImageHelper.ofFixedSize(wrapper.getIconFile(), size, size); + var storeIcon = imageComp.build(); + storeIcon.setPadding(new Insets(0, 0, 1, 0)); + + var dots = new FontIcon("mdi2d-dots-horizontal"); + dots.setIconSize((int) (size * 1.1)); + + var stack = new StackPane(storeIcon, dots); + stack.setMinHeight(size); + stack.setMinWidth(size); + stack.setMaxHeight(size); + stack.setMaxWidth(size); + stack.getStyleClass().add("icon"); + stack.setAlignment(Pos.CENTER); + + dots.visibleProperty().bind(stack.hoverProperty()); + storeIcon + .opacityProperty() + .bind(Bindings.createDoubleBinding( + () -> { + return stack.isHover() ? 0.5 : 1.0; + }, + stack.hoverProperty())); + + stack.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> { + if (event.getButton() == MouseButton.PRIMARY) { + StoreIconChoiceDialog.show(wrapper.getCategory()); + event.consume(); + } + }); + + return stack; + } +} diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreCategoryWrapper.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreCategoryWrapper.java index 8241c82b6..ed8464305 100644 --- a/app/src/main/java/io/xpipe/app/hub/comp/StoreCategoryWrapper.java +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreCategoryWrapper.java @@ -36,7 +36,7 @@ public class StoreCategoryWrapper { private final IntegerProperty allContainedEntriesCount = new SimpleIntegerProperty(); private final BooleanProperty expanded = new SimpleBooleanProperty(); private final Property color = new SimpleObjectProperty<>(); - private final BooleanProperty largeCategoryOptimizations = new SimpleBooleanProperty(); + private final Property iconFile = new SimpleObjectProperty<>(); private final Trigger renameTrigger = Trigger.of(); private StoreCategoryWrapper cachedParent; @@ -100,6 +100,16 @@ public class StoreCategoryWrapper { return cachedParent; } + public boolean isHierarchyExpanded() { + StoreCategoryWrapper current = this; + while ((current = current.getParent()) != null) { + if (!current.getExpanded().get()) { + return false; + } + } + return true; + } + public void select() { PlatformThread.runLaterIfNeeded(() -> { StoreViewState.get().getActiveCategory().setValue(this); @@ -152,12 +162,17 @@ public class StoreCategoryWrapper { this.expanded.set(!expanded.getValue()); } - public void update() { + public synchronized void update() { // We are probably in shutdown then if (StoreViewState.get() == null) { return; } + // We received a delayed update after removal + if (!DataStorage.get().getStoreCategories().contains(category)) { + return; + } + // Avoid reupdating name when changed from the name property! var catName = translatedName(category.getName()); if (!catName.equals(name.getValue())) { @@ -169,6 +184,7 @@ public class StoreCategoryWrapper { DataStorage.get().getEffectiveCategoryConfig(category).getSync())); expanded.setValue(category.isExpanded()); color.setValue(DataStorage.get().getEffectiveCategoryConfig(category).getColor()); + iconFile.setValue(category.getEffectiveIconFile()); var allEntries = new ArrayList<>(StoreViewState.get().getAllEntries().getList()); directContainedEntries.setContent(allEntries.stream() @@ -191,15 +207,9 @@ public class StoreCategoryWrapper { .sum(); allContainedEntriesCount.setValue(direct + sub); - var performanceCount = - AppPrefs.get().showChildCategoriesInParentCategory().get() ? allContainedEntriesCount.get() : direct; - if (performanceCount > 500) { - largeCategoryOptimizations.setValue(true); - } - var directFiltered = directContainedEntries.getList().stream() .filter(storeEntryWrapper -> { - var filter = StoreViewState.get().getFilterString().getValue(); + var filter = StoreFilterState.get().getEffectiveFilter().getValue(); if (filter != null) { var matches = storeEntryWrapper.matchesFilter(filter); return matches; @@ -217,6 +227,8 @@ public class StoreCategoryWrapper { Optional.ofNullable(getParent()).ifPresent(storeCategoryWrapper -> { storeCategoryWrapper.update(); }); + + StoreViewState.get().refreshActiveCategory(); } private String translatedName(String original) { diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreChoicePopover.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreChoicePopover.java index 675c7fd69..9469ae23a 100644 --- a/app/src/main/java/io/xpipe/app/hub/comp/StoreChoicePopover.java +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreChoicePopover.java @@ -18,7 +18,6 @@ import javafx.beans.binding.Bindings; import javafx.beans.property.Property; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleObjectProperty; -import javafx.beans.property.SimpleStringProperty; import javafx.collections.ListChangeListener; import javafx.geometry.Insets; import javafx.scene.Node; @@ -82,7 +81,7 @@ public class StoreChoicePopover { : (rootCategory != null ? (rootCategory.getRoot().equals(cur.getRoot()) ? cur : rootCategory) : cur)); - var filterText = new SimpleStringProperty(); + var storeFilter = new SimpleObjectProperty(); popover = new Popover(); Predicate applicable = storeEntryWrapper -> { var e = storeEntryWrapper.getEntry(); @@ -120,7 +119,7 @@ public class StoreChoicePopover { StoreViewState.get().getAllEntries(), Set.of(), applicable, - filterText, + storeFilter, selectedCategory, StoreViewState.get().getEntriesListVisibilityObservable(), StoreViewState.get().getEntriesListUpdateObservable(), @@ -138,14 +137,16 @@ public class StoreChoicePopover { }, initialExpanded); - var category = new DataStoreCategoryChoiceComp( + var category = new StoreCategoryChoiceComp( rootCategory != null ? rootCategory.getRoot() : null, StoreViewState.get().getActiveCategory(), selectedCategory, explicitCategory == null, - ignored -> true) + ignored -> true) .style(Styles.LEFT_PILL); - var filter = new FilterComp(filterText).style(Styles.CENTER_PILL).hgrow(); + var filter = FilterComp.ofStoreFilter(storeFilter) + .style(Styles.CENTER_PILL) + .hgrow(); var addButton = RegionBuilder.of(() -> { var m = MenuHelper.createMenuButton(); @@ -204,7 +205,7 @@ public class StoreChoicePopover { content.setFillWidth(true); content.getStyleClass().add("choice-comp-content"); content.setPrefWidth(480); - content.setMaxHeight(550); + content.setMaxHeight(430); popover.setContentNode(content); popover.setCloseButtonEnabled(true); diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreComboChoiceComp.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreComboChoiceComp.java index 70f2eb3cb..eac8b27cd 100644 --- a/app/src/main/java/io/xpipe/app/hub/comp/StoreComboChoiceComp.java +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreComboChoiceComp.java @@ -44,7 +44,6 @@ public class StoreComboChoiceComp extends SimpleRegionBuild private final Property> selected; private final Function stringConverter; private final StoreChoicePopover popover; - private final boolean requireComplete; public StoreComboChoiceComp( Function stringConverter, @@ -56,7 +55,6 @@ public class StoreComboChoiceComp extends SimpleRegionBuild boolean requireComplete) { this.stringConverter = stringConverter; this.selected = selected; - this.requireComplete = requireComplete; var popoverProp = new SimpleObjectProperty<>( selected.getValue() != null ? selected.getValue().getRef() : null); diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreCreationComp.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreCreationComp.java index f7981ad88..8e8c7c4bf 100644 --- a/app/src/main/java/io/xpipe/app/hub/comp/StoreCreationComp.java +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreCreationComp.java @@ -88,7 +88,6 @@ public class StoreCreationComp extends ModalOverlayContentComp { }); } - var propOptions = createStoreProperties(); model.getInitialStore().setValue(model.getStore().getValue()); var valSp = new GraphicDecorationStackPane(); @@ -103,7 +102,11 @@ public class StoreCreationComp extends ModalOverlayContentComp { } full.sub(d.getOptions()); - full.sub(propOptions); + + if (!model.isQuickConnect()) { + var propOptions = createStoreProperties(); + full.sub(propOptions); + } var comp = full.buildComp(); var region = comp.style("store-creator-options").build(); diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreCreationDialog.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreCreationDialog.java index e5ef37475..ed9abea96 100644 --- a/app/src/main/java/io/xpipe/app/hub/comp/StoreCreationDialog.java +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreCreationDialog.java @@ -26,17 +26,24 @@ import java.util.function.Predicate; public class StoreCreationDialog { - public static void showEdit(DataStoreEntry e) { - showEdit(e, dataStoreEntry -> {}); + public static StoreCreationModel showEdit(DataStoreEntry e) { + return showEdit(e, dataStoreEntry -> {}); } - public static void showEdit(DataStoreEntry e, Consumer c) { - showEdit(e, e.getStore(), c); + public static StoreCreationModel showEdit(DataStoreEntry e, Consumer c) { + return showEdit(e, e.getStore(), true, c); } - public static void showEdit(DataStoreEntry e, DataStore base, Consumer c) { + public static StoreCreationModel showEdit( + DataStoreEntry e, DataStore base, boolean addToStorage, Consumer c) { StoreCreationConsumer consumer = (newE, validated) -> { ThreadHelper.runAsync(() -> { + if (!addToStorage) { + DataStorage.get().updateEntry(e, newE); + c.accept(e); + return; + } + if (!DataStorage.get().getStoreEntries().contains(e) || DataStorage.get().getEffectiveReadOnlyState(e)) { DataStorage.get().addStoreEntryIfNotPresent(newE); @@ -48,14 +55,13 @@ public class StoreCreationDialog { var madeValid = !e.getValidity().isUsable() && newE.getValidity().isUsable(); DataStorage.get().updateEntry(e, newE); - if (madeValid) { - if (validated - && e.getProvider().shouldShowScan() - && AppPrefs.get() - .openConnectionSearchWindowOnConnectionCreation() - .get()) { - ScanDialog.showSingleAsync(e); - } + if (madeValid + && validated + && e.getProvider().shouldShowScan() + && AppPrefs.get() + .openConnectionSearchWindowOnConnectionCreation() + .get()) { + ScanDialog.showSingleAsync(e); } } } @@ -72,11 +78,11 @@ public class StoreCreationDialog { c.accept(e); }); }; - show(e.getName(), DataStoreProviders.byStore(base), base, v -> true, consumer, true, e); + return show(e.getName(), DataStoreProviders.byStore(base), base, v -> true, consumer, true, e); } - public static void showCreation(DataStoreProvider selected, DataStoreCreationCategory category) { - showCreation( + public static StoreCreationModel showCreation(DataStoreProvider selected, DataStoreCreationCategory category) { + return showCreation( null, selected != null ? selected.defaultStore(DataStorage.get().getSelectedCategory()) : null, category, @@ -84,7 +90,7 @@ public class StoreCreationDialog { true); } - public static void showCreation( + public static StoreCreationModel showCreation( String name, DataStore base, DataStoreCreationCategory category, @@ -118,18 +124,20 @@ public class StoreCreationDialog { ErrorEventFactory.fromThrowable(ex).handle(); } }; - show( + return show( name, prov, base, - dataStoreProvider -> (category != null && category.equals(dataStoreProvider.getCreationCategory())) + dataStoreProvider -> (category != null + && dataStoreProvider.allowCreation() + && category.equals(dataStoreProvider.getCreationCategory())) || dataStoreProvider.equals(prov), consumer, false, null); } - private static void show( + private static StoreCreationModel show( String initialName, DataStoreProvider provider, DataStore s, @@ -140,7 +148,7 @@ public class StoreCreationDialog { var ex = StoreCreationQueueEntry.findExisting(existingEntry); if (ex.isPresent()) { ex.get().execute(); - return; + return null; } var prop = new SimpleObjectProperty<>(provider); @@ -148,14 +156,14 @@ public class StoreCreationDialog { var model = new StoreCreationModel(prop, store, filter, initialName, existingEntry, staticDisplay, con); var modal = createModalOverlay(model); modal.show(); + return model; } private static ModalOverlay createModalOverlay(StoreCreationModel model) { var comp = new StoreCreationComp(model); comp.prefWidth(650); - var nameKey = model.storeTypeNameKey() + "Add"; + var nameKey = model.isQuickConnect() ? "quickConnect" : model.storeTypeNameKey() + "Add"; var modal = ModalOverlay.of(nameKey, comp); - var queueEntry = StoreCreationQueueEntry.of(model, modal); comp.apply(struc -> { struc.addEventHandler(KeyEvent.KEY_PRESSED, e -> { if (e.getCode() == KeyCode.ESCAPE) { @@ -167,16 +175,21 @@ public class StoreCreationDialog { } }); }); - modal.hideable(queueEntry); - AppLayoutModel.get().getSelected().addListener((observable, oldValue, newValue) -> { - if (model.getFinished().get() || !modal.isShowing()) { - return; - } - modal.hide(); - AppLayoutModel.get().getQueueEntries().add(queueEntry); - }); - modal.setRequireCloseButtonForClose(true); + if (!model.isQuickConnect()) { + var queueEntry = StoreCreationQueueEntry.of(model, modal); + modal.hideable(queueEntry); + AppLayoutModel.get().getSelected().addListener((observable, oldValue, newValue) -> { + if (model.getFinished().get() || !modal.isShowing()) { + return; + } + + modal.hide(); + AppLayoutModel.get().getQueueEntries().add(queueEntry); + }); + modal.setRequireCloseButtonForClose(true); + } + var loadingLabel = new LabelComp(Bindings.createStringBinding( () -> { return model.getBusy().get() ? AppI18n.get("testingConnection") : null; @@ -205,21 +218,23 @@ public class StoreCreationDialog { .augment(button -> { button.visibleProperty().bind(Bindings.not(model.canConnect())); })); - modal.addButton(new ModalButton( - "skip", - () -> { - model.commit(false); - modal.close(); - }, - false, - false)) - .augment(button -> { - button.visibleProperty().bind(model.getSkippable()); - button.disableProperty().bind(model.getBusy()); - }); + if (!model.isQuickConnect()) { + modal.addButton(new ModalButton( + "skip", + () -> { + model.commit(false); + modal.close(); + }, + false, + false)) + .augment(button -> { + button.visibleProperty().bind(model.getSkippable()); + button.disableProperty().bind(model.getBusy()); + }); + } modal.addButton(new ModalButton( - "finish", + model.isQuickConnect() ? "connect" : "finish", () -> { model.finish(); }, @@ -239,7 +254,9 @@ public class StoreCreationDialog { button.textProperty() .bind(Bindings.createStringBinding( () -> { - return !model.getBusy().get() ? AppI18n.get("finish") : null; + return !model.getBusy().get() + ? AppI18n.get(model.isQuickConnect() ? "connect" : "finish") + : null; }, PlatformThread.sync(model.getBusy()), AppI18n.activeLanguage())); diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreCreationMenu.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreCreationMenu.java index abaf5d605..7f66f79be 100644 --- a/app/src/main/java/io/xpipe/app/hub/comp/StoreCreationMenu.java +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreCreationMenu.java @@ -54,6 +54,10 @@ public class StoreCreationMenu { menu.getItems().add(categoryMenu("addDesktop", "mdi2c-camera-plus", DataStoreCreationCategory.DESKTOP, null)); + menu.getItems().add(cloudMenu()); + + menu.getItems().add(new SeparatorMenuItem()); + menu.getItems() .add(categoryMenu( "addIdentity", @@ -61,8 +65,6 @@ public class StoreCreationMenu { DataStoreCreationCategory.IDENTITY, "localIdentity")); - menu.getItems().add(cloudMenu()); - menu.getItems().add(new SeparatorMenuItem()); menu.getItems() @@ -72,13 +74,6 @@ public class StoreCreationMenu { .add(categoryMenu( "addTunnel", "mdi2v-vector-polyline-plus", DataStoreCreationCategory.TUNNEL, "sshLocalTunnel")); - menu.getItems() - .add(categoryMenu( - "addFileSystem", - "mdi2f-folder-plus-outline", - DataStoreCreationCategory.FILE_SYSTEM, - "genericS3Bucket")); - menu.getItems().add(new SeparatorMenuItem()); menu.getItems() @@ -104,6 +99,13 @@ public class StoreCreationMenu { }); actionMenu.getItems().addFirst(item); + menu.getItems() + .add(categoryMenu( + "addFileSystem", + "mdi2f-folder-plus-outline", + DataStoreCreationCategory.FILE_SYSTEM, + "genericS3Bucket")); + menu.getItems().add(categoryMenu("addSerial", "mdi2s-serial-port", DataStoreCreationCategory.SERIAL, "serial")); menu.getItems().add(new SeparatorMenuItem()); @@ -153,7 +155,7 @@ public class StoreCreationMenu { }); int lastOrder = providers.getFirst().getOrderPriority(); - for (io.xpipe.app.ext.DataStoreProvider dataStoreProvider : providers) { + for (var dataStoreProvider : providers) { if (dataStoreProvider.getOrderPriority() != lastOrder) { menu.getItems().add(new SeparatorMenuItem()); lastOrder = dataStoreProvider.getOrderPriority(); @@ -167,6 +169,7 @@ public class StoreCreationMenu { StoreCreationDialog.showCreation(dataStoreProvider, category); event.consume(); }); + item.setDisable(!dataStoreProvider.allowCreation()); menu.getItems().add(item); } return menu; diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreCreationModel.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreCreationModel.java index eb0d45da0..e34928c8e 100644 --- a/app/src/main/java/io/xpipe/app/hub/comp/StoreCreationModel.java +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreCreationModel.java @@ -95,7 +95,9 @@ public class StoreCreationModel { DataStorage.get().getSelectedCategory().getUuid(), name.getValue(), store.getValue()); - var entryRef = existingEntry != null ? existingEntry : DataStorage.get().getDefaultDisplayParent(initial).orElse(initial); + var entryRef = existingEntry != null + ? existingEntry + : DataStorage.get().getDefaultDisplayParent(initial).orElse(initial); var targetCategory = getTargetCategory(entryRef); return DataStoreEntry.createNew( UUID.randomUUID(), targetCategory.getUuid(), name.getValue(), store.getValue()); @@ -167,6 +169,10 @@ public class StoreCreationModel { store)); } + boolean isQuickConnect() { + return existingEntry != null && existingEntry.getUuid().equals(StoreQuickConnect.STORE_ID); + } + void connect() { var temp = entry.getValue() != null ? entry.getValue() : DataStoreEntry.createTempWrapper(store.getValue()); var action = OpenHubMenuLeafProvider.Action.builder().ref(temp.ref()).build(); @@ -211,7 +217,7 @@ public class StoreCreationModel { } // We didn't change anything - if (store.getValue().isComplete() && !wasChanged()) { + if (store.getValue().isComplete() && !wasChanged() && !isQuickConnect()) { commit(false); return; } @@ -307,7 +313,7 @@ public class StoreCreationModel { || p.getCreationCategory().getCategory().equals(DataStorage.ALL_CONNECTIONS_CATEGORY_UUID) ? "connection" : p.getCreationCategory().getCategory().equals(DataStorage.ALL_SCRIPTS_CATEGORY_UUID) - ? (p.getId().equals("scriptGroup") ? "scriptGroup" : "script") + ? "script" : p.getCreationCategory().getCategory().equals(DataStorage.ALL_IDENTITIES_CATEGORY_UUID) ? "identity" : "macro"; diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreEntryBatchSelectComp.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreEntryBatchSelectComp.java index 824be633a..59e4daa6a 100644 --- a/app/src/main/java/io/xpipe/app/hub/comp/StoreEntryBatchSelectComp.java +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreEntryBatchSelectComp.java @@ -66,7 +66,8 @@ public class StoreEntryBatchSelectComp extends SimpleRegionBuilder { } private void externalUpdate(CheckBox checkBox) { - if (section.getWrapper() != null && section.getWrapper().getEntry().getValidity() == DataStoreEntry.Validity.LOAD_FAILED) { + if (section.getWrapper() != null + && section.getWrapper().getEntry().getValidity() == DataStoreEntry.Validity.LOAD_FAILED) { checkBox.setSelected(false); checkBox.setIndeterminate(false); checkBox.setDisable(true); 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 5e8b720af..8f0c8c7e9 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 @@ -304,7 +304,7 @@ public abstract class StoreEntryComp extends SimpleRegionBuilder { } protected BaseRegionBuilder createIcon(int w, int h, Consumer fontSize) { - var icon = new StoreIconComp(getWrapper(), w, h); + var icon = new StoreEntryIconComp(getWrapper(), w, h); icon.apply(struc -> { struc.opacityProperty() .bind(Bindings.createDoubleBinding( @@ -644,13 +644,25 @@ public abstract class StoreEntryComp extends SimpleRegionBuilder { var move = new Menu(AppI18n.get("category"), new FontIcon("mdi2f-folder-move-outline")); StoreViewState.get() .getSortedCategories( - getWrapper().getCategory().getValue().getRoot()) + getWrapper().getCategory().getValue().getRoot(), true) .getList() .forEach(storeCategoryWrapper -> { - MenuItem m = new MenuItem(); - m.textProperty() - .setValue(" ".repeat(storeCategoryWrapper.getDepth()) - + storeCategoryWrapper.getName().getValue()); + var m = new CustomMenuItem(); + + var l = new Label(); + l.setGraphic(PrettyImageHelper.ofFixedSizeSquare( + storeCategoryWrapper + .getIconFile() + .getValue(), + 16) + .padding(new Insets(0, 0, 1, 0)) + .build()); + l.setText(storeCategoryWrapper.getName().getValue()); + l.setPadding(new Insets(0, 1, 1, storeCategoryWrapper.getDepth() * 10)); + m.setContent(l); + l.prefWidthProperty() + .bind(contextMenu.widthProperty().subtract(40)); + m.setOnAction(event -> { getWrapper().moveTo(storeCategoryWrapper.getCategory()); event.consume(); diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreEntryFilterCompBar.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreEntryFilterCompBar.java new file mode 100644 index 000000000..e0612256a --- /dev/null +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreEntryFilterCompBar.java @@ -0,0 +1,23 @@ +package io.xpipe.app.hub.comp; + +import io.xpipe.app.comp.SimpleRegionBuilder; +import io.xpipe.app.util.ObservableSubscriber; + +import javafx.scene.layout.Region; + +public class StoreEntryFilterCompBar extends SimpleRegionBuilder { + + private final ObservableSubscriber filterTrigger; + + public StoreEntryFilterCompBar(ObservableSubscriber filterTrigger) { + this.filterTrigger = filterTrigger; + } + + @Override + public Region createSimple() { + var bar = new StoreFilterFieldComp(filterTrigger); + bar.style("bar"); + bar.style("store-filter-bar"); + return bar.build(); + } +} diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreIconComp.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreEntryIconComp.java similarity index 97% rename from app/src/main/java/io/xpipe/app/hub/comp/StoreIconComp.java rename to app/src/main/java/io/xpipe/app/hub/comp/StoreEntryIconComp.java index 11af73128..47c02a32f 100644 --- a/app/src/main/java/io/xpipe/app/hub/comp/StoreIconComp.java +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreEntryIconComp.java @@ -16,7 +16,7 @@ import lombok.AllArgsConstructor; import org.kordamp.ikonli.javafx.FontIcon; @AllArgsConstructor -public class StoreIconComp extends SimpleRegionBuilder { +public class StoreEntryIconComp extends SimpleRegionBuilder { private final StoreEntryWrapper wrapper; private final int w; diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreEntryListComp.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreEntryListComp.java index 60b284c05..008d06723 100644 --- a/app/src/main/java/io/xpipe/app/hub/comp/StoreEntryListComp.java +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreEntryListComp.java @@ -52,7 +52,7 @@ public class StoreEntryListComp extends SimpleRegionBuilder { }); // Reset scroll - StoreViewState.get().getFilterString().addListener((observable, oldValue, newValue) -> { + StoreFilterState.get().getEffectiveFilter().addListener((observable, oldValue, newValue) -> { struc.setVvalue(0); }); @@ -181,6 +181,6 @@ public class StoreEntryListComp extends SimpleRegionBuilder { map.put(new StoreScriptSourcesIntroComp(), showScriptSourcesIntro); map.put(new StoreIdentitiesIntroComp(), showIdentitiesIntro); - return new MultiContentComp(false, map, false).build(); + return new MultiContentComp(false, map).build(); } } diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreEntryListOverviewComp.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreEntryListOverviewComp.java index c7f613d59..b7d9dc4e0 100644 --- a/app/src/main/java/io/xpipe/app/hub/comp/StoreEntryListOverviewComp.java +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreEntryListOverviewComp.java @@ -4,7 +4,6 @@ import io.xpipe.app.comp.BaseRegionBuilder; import io.xpipe.app.comp.RegionBuilder; import io.xpipe.app.comp.SimpleRegionBuilder; import io.xpipe.app.comp.base.CountComp; -import io.xpipe.app.comp.base.FilterComp; import io.xpipe.app.comp.base.IconButtonComp; import io.xpipe.app.core.AppFontSizes; import io.xpipe.app.core.AppI18n; @@ -22,7 +21,6 @@ import javafx.geometry.Pos; import javafx.scene.control.Label; import javafx.scene.control.Separator; import javafx.scene.layout.HBox; -import javafx.scene.layout.Priority; import javafx.scene.layout.Region; import javafx.scene.layout.VBox; import javafx.scene.text.TextAlignment; @@ -34,13 +32,9 @@ import java.util.function.Function; public class StoreEntryListOverviewComp extends SimpleRegionBuilder { - private final ObservableSubscriber filterTrigger; + private final ObservableSubscriber quickConnectTrigger = new ObservableSubscriber(); - public StoreEntryListOverviewComp(ObservableSubscriber filterTrigger) { - this.filterTrigger = filterTrigger; - } - - private Region createGroupListHeader() { + private Region createHeaderBar() { var label = new Label(); var name = BindingsHelper.flatMap( StoreViewState.get().getActiveCategory(), @@ -77,11 +71,11 @@ public class StoreEntryListOverviewComp extends SimpleRegionBuilder { label, c, RegionBuilder.hspacer().build(), - createIndexSortButton().build(), - sep, createDateSortButton().build(), RegionBuilder.hspacer(2).build(), - createAlphabeticalSortButton().build()); + createAlphabeticalSortButton().build(), + sep, + createIndexSortButton().build()); if (OsType.ofLocal() == OsType.MACOS) { AppFontSizes.xxxl(label); AppFontSizes.xxxl(c); @@ -94,28 +88,20 @@ public class StoreEntryListOverviewComp extends SimpleRegionBuilder { return topBar; } - private Region createGroupListFilter() { - var filter = new FilterComp(StoreViewState.get().getFilterString()).build(); - filterTrigger.subscribe(() -> { - filter.requestFocus(); - }); + private Region createAddBar() { var add = createAddButton(); var batchMode = createBatchModeButton().build(); - var hbox = new HBox(add, filter, batchMode); - filter.minHeightProperty().bind(add.heightProperty()); - filter.prefHeightProperty().bind(add.heightProperty()); - filter.maxHeightProperty().bind(add.heightProperty()); + var hbox = new HBox(add, batchMode); + 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(filter, Priority.ALWAYS); - filter.getStyleClass().add("filter-bar"); + hbox.setSpacing(8); + hbox.setAlignment(Pos.CENTER_LEFT); return hbox; } @@ -267,7 +253,8 @@ public class StoreEntryListOverviewComp extends SimpleRegionBuilder { @Override public Region createSimple() { - var bar = new VBox(createGroupListHeader(), createGroupListFilter()); + var addBar = createAddBar(); + var bar = new VBox(createHeaderBar(), addBar); bar.setFillWidth(true); bar.getStyleClass().add("bar"); bar.getStyleClass().add("store-header-bar"); 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 9c8c0618d..05e24dc1d 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 @@ -59,7 +59,6 @@ public class StoreEntryWrapper { private final Property category = new SimpleObjectProperty<>(); private final Property summary = new SimpleObjectProperty<>(); private final ObjectProperty notes; - private final Property customIcon = new SimpleObjectProperty<>(); private final Property iconFile = new SimpleObjectProperty<>(); private final BooleanProperty sessionActive = new SimpleBooleanProperty(); private final Property store = new SimpleObjectProperty<>(); @@ -69,7 +68,6 @@ public class StoreEntryWrapper { private final ObservableValue shownSummary; private final ObservableValue shownDescription; private final Property shownInformation; - private final BooleanProperty largeCategoryOptimizations = new SimpleBooleanProperty(); private final BooleanProperty readOnly = new SimpleBooleanProperty(); private final BooleanProperty renaming = new SimpleBooleanProperty(); private final BooleanProperty pinToTop = new SimpleBooleanProperty(); @@ -206,7 +204,6 @@ public class StoreEntryWrapper { orderIndex.setValue(entry.getOrderIndex()); color.setValue(entry.getColor()); notes.setValue(entry.getNotes()); - customIcon.setValue(entry.getIcon()); readOnly.setValue(entry.isFreeze()); iconFile.setValue(entry.getEffectiveIconFile()); busy.setValue(entry.getBusyCounter().get() != 0); @@ -220,8 +217,6 @@ public class StoreEntryWrapper { storeCategoryWrapper.getCategory().getUuid().equals(entry.getCategoryUuid())) .findFirst() .orElse(StoreViewState.get().getAllConnectionsCategory())); - largeCategoryOptimizations.setValue( - category.getValue().getLargeCategoryOptimizations().getValue()); perUser.setValue( !category.getValue().getRoot().equals(StoreViewState.get().getAllIdentitiesCategory()) && entry.isPerUserStore()); @@ -540,36 +535,22 @@ public class StoreEntryWrapper { } } - public boolean matchesFilter(String filter) { - if (filter == null || name.getValue().toLowerCase().contains(filter.toLowerCase())) { + public boolean matchesFilter(StoreFilter filter) { + if (filter == null) { return true; } - if (getEntry().getUuid().toString().equalsIgnoreCase(filter)) { - return true; + var l = new ArrayList(); + l.add(name.getValue()); + l.add(getEntry().getUuid().toString()); + if (entry.getValidity().isUsable()) { + l.addAll(entry.getProvider().getSearchableTerms(entry.getStore())); + l.add(AppI18n.get(entry.getProvider().getId() + ".displayName")); } - - if (entry.getValidity().isUsable() - && entry.getProvider().getSearchableTerms(entry.getStore()).stream() - .anyMatch(s -> s.toLowerCase().contains(filter.toLowerCase()))) { - return true; - } - - var is = information.getValue(); - if (is != null && is.toLowerCase().contains(filter.toLowerCase())) { - return true; - } - - var ss = summary.getValue(); - if (ss != null && ss.toLowerCase().contains(filter.toLowerCase())) { - return true; - } - - if (tags.stream().anyMatch(s -> s.toLowerCase().contains(filter.toLowerCase()))) { - return true; - } - - return false; + l.add(information.getValue()); + l.add(summary.getValue()); + l.addAll(tags); + return filter.matches(l); } public Property nameProperty() { diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreFilter.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreFilter.java new file mode 100644 index 000000000..b01f4d60d --- /dev/null +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreFilter.java @@ -0,0 +1,53 @@ +package io.xpipe.app.hub.comp; + +import lombok.Value; + +import java.util.Arrays; +import java.util.List; + +@Value +public class StoreFilter { + + public static StoreFilter of(String s) { + if (s == null || s.isEmpty()) { + return null; + } + + var split = s.split(","); + var l = Arrays.stream(split) + .map(sub -> sub.strip()) + .filter(sub -> !sub.isEmpty()) + .toList(); + return new StoreFilter(l); + } + + List parts; + + public boolean matches(List input) { + if (input == null) { + return false; + } + + for (String part : parts) { + var partl = part.toLowerCase(); + var found = false; + for (String s : input) { + if (s == null || s.isEmpty()) { + continue; + } + + var sl = s.toLowerCase(); + if (sl.contains(partl)) { + found = true; + break; + } + } + + if (!found) { + return false; + } + } + + return true; + } +} diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreFilterFieldComp.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreFilterFieldComp.java new file mode 100644 index 000000000..a7d36d71f --- /dev/null +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreFilterFieldComp.java @@ -0,0 +1,174 @@ +package io.xpipe.app.hub.comp; + +import io.xpipe.app.comp.RegionBuilder; +import io.xpipe.app.comp.RegionDescriptor; +import io.xpipe.app.comp.SimpleRegionBuilder; +import io.xpipe.app.comp.base.IconButtonComp; +import io.xpipe.app.comp.base.InputGroupComp; +import io.xpipe.app.core.AppFontSizes; +import io.xpipe.app.core.AppI18n; +import io.xpipe.app.platform.PlatformThread; +import io.xpipe.app.prefs.AppPrefs; +import io.xpipe.app.util.ObservableSubscriber; + +import javafx.beans.binding.Bindings; +import javafx.geometry.Bounds; +import javafx.scene.Cursor; +import javafx.scene.control.skin.TextFieldSkin; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Region; + +import atlantafx.base.controls.CustomTextField; +import atlantafx.base.controls.Popover; +import atlantafx.base.theme.Styles; +import javafx.scene.shape.Rectangle; + +import java.util.List; +import java.util.Objects; + +public class StoreFilterFieldComp extends SimpleRegionBuilder { + + private final ObservableSubscriber filterTrigger; + + public StoreFilterFieldComp(ObservableSubscriber filterTrigger) { + this.filterTrigger = filterTrigger; + } + + @Override + public Region createSimple() { + var state = StoreFilterState.get(); + + var popover = new Popover(); + popover.setContentNode(new StoreFilterStateComp().build()); + popover.setPrefWidth(600); + popover.setArrowLocation(Popover.ArrowLocation.TOP_CENTER); + popover.setAutoHide(!AppPrefs.get().limitedTouchscreenMode().get()); + + var field = new CustomTextField(); + + filterTrigger.subscribe(() -> { + field.requestFocus(); + }); + + field.focusedProperty().subscribe(focus -> { + if (focus) { + popover.hide(); + } else { + state.onFocusLost(); + } + }); + + var clearButton = new IconButtonComp("mdi2c-close", () -> { + field.clear(); + }) + .build(); + clearButton.setCursor(Cursor.DEFAULT); + AppFontSizes.sm(clearButton); + var searchButton = new IconButtonComp("mdi2m-magnify", () -> { + if (state.onApply()) { + field.clear(); + } + }) + .build(); + searchButton.setCursor(Cursor.DEFAULT); + AppFontSizes.sm(searchButton); + var launchButton = new IconButtonComp("mdi2p-play", () -> { + if (state.onApply()) { + field.clear(); + } + }) + .build(); + launchButton.setCursor(Cursor.DEFAULT); + AppFontSizes.sm(launchButton); + + field.setMinHeight(0); + field.setMaxHeight(20000); + field.getStyleClass().add("store-filter-comp"); + field.promptTextProperty().bind(AppI18n.observable("storeFilterPrompt")); + field.rightProperty() + .bind(Bindings.createObjectBinding( + () -> { + if (!field.isFocused()) { + return state.getEffectiveFilter().getValue() != null ? clearButton : null; + } + + if (state.getIsQuickConnectString().get() + || state.getIsUrlString().get()) { + return launchButton; + } + + if (state.getIsSearchString().get()) { + return searchButton; + } + + return null; + }, + field.focusedProperty(), + state.getRawText())); + RegionDescriptor.builder().nameKey("search").showTooltips(false).build().apply(field); + + field.addEventFilter(KeyEvent.KEY_PRESSED, event -> { + if (event.getCode() == KeyCode.ENTER) { + if (state.onApply()) { + field.clear(); + } + field.getParent().getParent().requestFocus(); + event.consume(); + } else if (event.getCode() == KeyCode.ESCAPE) { + field.clear(); + field.getParent().getParent().requestFocus(); + event.consume(); + } + }); + + state.getRawText().subscribe(val -> { + PlatformThread.runLaterIfNeeded(() -> { + var wasFocused = field.isFocused(); + if (!wasFocused) { + field.requestFocus(); + } + + if (!Objects.equals(field.getText(), val) && !(val == null && "".equals(field.getText()))) { + field.setText(val); + } + + if (!wasFocused) { + field.end(); + } + }); + }); + + field.textProperty().addListener((observable, oldValue, n) -> { + state.getRawText().setValue(n != null && n.length() > 0 ? n : null); + }); + + // Fix caret not being visible on right side when overflowing + field.setSkin(field.createDefaultSkin()); + Pane pane = (Pane) field.getChildrenUnmodifiable().getFirst(); + var rec = new Rectangle(); + rec.widthProperty().bind(pane.widthProperty().add(2)); + rec.heightProperty().bind(pane.heightProperty()); + rec.setSmooth(false); + field.getChildrenUnmodifiable().getFirst().setClip(rec); + + var menuButton = new IconButtonComp("mdi2a-animation-play", () -> { + Bounds bounds = field.localToScreen(field.getBoundsInLocal()); + popover.show(field, bounds.getMinX() + (field.getWidth() / 1.5), bounds.getMaxY() - 4.0); + }); + menuButton.describe(d -> d.nameKey("quickConnect")); + menuButton.style("quick-connect-button"); + menuButton.apply(struc -> { + struc.getStyleClass().remove(Styles.FLAT); + }); + menuButton.prefWidth(30); + + var fieldComp = RegionBuilder.of(() -> field); + var inputGroup = new InputGroupComp(List.of(fieldComp, menuButton)); + inputGroup.setMainReference(fieldComp); + inputGroup.prefHeight(47); + inputGroup.minHeight(47); + return inputGroup.build(); + } +} diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreFilterState.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreFilterState.java new file mode 100644 index 000000000..b058c597a --- /dev/null +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreFilterState.java @@ -0,0 +1,198 @@ +package io.xpipe.app.hub.comp; + +import io.xpipe.app.action.LauncherUrlProvider; +import io.xpipe.app.action.QuickConnectProvider; +import io.xpipe.app.core.AppCache; +import io.xpipe.app.issue.ErrorEventFactory; +import io.xpipe.app.platform.DerivedObservableList; + +import javafx.beans.binding.Bindings; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.beans.value.ObservableBooleanValue; +import javafx.beans.value.ObservableValue; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.type.TypeFactory; +import lombok.Getter; + +import java.net.URI; +import java.util.List; + +public class StoreFilterState { + + @Getter + private final DerivedObservableList recentSearches = DerivedObservableList.synchronizedArrayList(true); + + @Getter + private final DerivedObservableList recentQuickConnections = + DerivedObservableList.synchronizedArrayList(true); + + @Getter + private final StringProperty rawText = new SimpleStringProperty(); + + @Getter + private final ObservableBooleanValue isQuickConnectString = Bindings.createBooleanBinding( + () -> { + var v = rawText.getValue(); + if (v == null) { + return false; + } + + return QuickConnectProvider.find(v).isPresent(); + }, + rawText); + + @Getter + private final ObservableBooleanValue isUrlString = Bindings.createBooleanBinding( + () -> { + var v = rawText.getValue(); + if (v == null) { + return false; + } + + return LauncherUrlProvider.find(v).isPresent(); + }, + rawText); + + private final BooleanProperty forceFilter = new SimpleBooleanProperty(); + + @Getter + private final ObservableBooleanValue isSearchString = Bindings.createBooleanBinding( + () -> { + return rawText.getValue() != null + && (forceFilter.getValue() || rawText.getValue().length() > 2) + && (forceFilter.getValue() || (!isUrlString.getValue() && !isQuickConnectString.getValue())); + }, + rawText, + isQuickConnectString, + isUrlString, + forceFilter); + + @Getter + private final ObservableValue effectiveFilter = Bindings.createObjectBinding( + () -> { + return isSearchString.get() ? StoreFilter.of(rawText.getValue()) : null; + }, + rawText, + isSearchString, + forceFilter); + + private static StoreFilterState INSTANCE; + + public static StoreFilterState get() { + return INSTANCE; + } + + public static void init() { + var type = TypeFactory.defaultInstance().constructType(new TypeReference>() {}); + List recentSearches = AppCache.getNonNull("recentSearches", type, () -> List.of()); + List recentQuickConnections = AppCache.getNonNull("recentQuickConnections", type, () -> List.of()); + + INSTANCE = new StoreFilterState(); + INSTANCE.recentSearches.setContent(recentSearches); + INSTANCE.recentQuickConnections.setContent(recentQuickConnections); + + INSTANCE.rawText.subscribe(ignored -> { + INSTANCE.forceFilter.set(false); + }); + } + + public static void reset() { + AppCache.update("recentSearches", INSTANCE.recentSearches.getList()); + AppCache.update("recentQuickConnections", INSTANCE.recentQuickConnections.getList()); + INSTANCE = null; + } + + public void set(String s) { + rawText.setValue(s); + } + + public void putFilter(String s) { + synchronized (recentSearches) { + var l = recentSearches.getList(); + l.remove(s); + if (l.size() == 3) { + l.addFirst(s); + l.removeLast(); + } else { + l.addFirst(s); + } + } + } + + public void putQuickConnect(String s) { + synchronized (recentQuickConnections) { + var l = recentQuickConnections.getList(); + l.remove(s); + if (l.size() == 3) { + l.addFirst(s); + l.removeLast(); + } else { + l.addFirst(s); + } + } + } + + public boolean onFocusLost() { + if (!isSearchString.get()) { + return false; + } + + return apply(false); + } + + public boolean onApply() { + return apply(true); + } + + private boolean apply(boolean force) { + if (rawText.getValue() == null) { + return false; + } + + if (isSearchString.getValue()) { + putFilter(rawText.getValue()); + return false; + } + + if (isUrlString.getValue()) { + var provider = LauncherUrlProvider.find(rawText.getValue()); + if (provider.isEmpty()) { + return false; + } + + try { + var action = provider.get().createAction(URI.create(rawText.get())); + action.executeAsync(); + return true; + } catch (Exception e) { + ErrorEventFactory.fromThrowable(e).handle(); + return false; + } + } + + if (isQuickConnectString.getValue()) { + var prefix = !rawText.getValue().contains(" "); + if (force && prefix) { + forceFilter.set(true); + return false; + } + + var provider = QuickConnectProvider.find(rawText.getValue()); + if (provider.isEmpty()) { + return false; + } + + var r = StoreQuickConnect.launchQuickConnect(rawText.getValue()); + if (r) { + putQuickConnect(rawText.getValue()); + } + return r; + } + + return false; + } +} diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreFilterStateComp.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreFilterStateComp.java new file mode 100644 index 000000000..f62b1cbc6 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreFilterStateComp.java @@ -0,0 +1,88 @@ +package io.xpipe.app.hub.comp; + +import io.xpipe.app.action.QuickConnectProvider; +import io.xpipe.app.comp.RegionBuilder; +import io.xpipe.app.comp.SimpleRegionBuilder; +import io.xpipe.app.comp.base.ButtonComp; +import io.xpipe.app.comp.base.LabelComp; +import io.xpipe.app.comp.base.ListBoxViewComp; +import io.xpipe.app.core.AppFontSizes; +import io.xpipe.app.core.AppI18n; +import io.xpipe.app.platform.OptionsBuilder; + +import javafx.beans.binding.Bindings; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.collections.FXCollections; +import javafx.geometry.Pos; +import javafx.scene.layout.Region; + +import atlantafx.base.theme.Styles; + +import java.util.List; + +public class StoreFilterStateComp extends SimpleRegionBuilder { + + private ButtonComp createButton(String text, String value) { + var button = new ButtonComp(new ReadOnlyObjectWrapper<>(text), () -> { + if (value != null) { + StoreFilterState.get().set(value); + } + }); + button.disable(value == null); + button.apply(r -> r.setAlignment(Pos.CENTER_LEFT)); + button.apply(AppFontSizes::sm); + button.style(Styles.FLAT); + button.maxWidth(250); + return button; + } + + @Override + protected Region createSimple() { + var state = StoreFilterState.get(); + + var searches = state.getRecentSearches().getList(); + var searchesEmpty = Bindings.isEmpty(searches); + var searchesList = new ListBoxViewComp<>(searches, searches, s -> createButton(s, s), false); + + var searchesPlaceholders = FXCollections.observableList(List.of( + AppI18n.get("recentSearchesDescriptionNames"), + AppI18n.get("recentSearchesDescriptionTags"), + AppI18n.get("recentSearchesDescriptionTypes"), + AppI18n.get("recentSearchesDescriptionState"), + AppI18n.get("recentSearchesDescriptionJoin"))); + var searchesEmptyList = + new ListBoxViewComp<>(searchesPlaceholders, searchesPlaceholders, s -> createButton(s, null), false); + + var quickConnections = state.getRecentQuickConnections().getList(); + var quickConnectionsEmpty = Bindings.isEmpty(quickConnections); + var quickConnectionsList = + new ListBoxViewComp<>(quickConnections, quickConnections, s -> createButton(s, s), false); + + var quickConnectionsPlaceholders = FXCollections.observableArrayList(QuickConnectProvider.getAll().stream() + .map(p -> p.getPlaceholder()) + .toList()); + var quickConnectionsEmptyList = new ListBoxViewComp<>( + quickConnectionsPlaceholders, + quickConnectionsPlaceholders, + s -> createButton(s, s.split(" ")[0] + " "), + false); + + var options = new OptionsBuilder() + .addComp(new LabelComp(AppI18n.observable("recentSearches"))) + .hide(searchesEmpty) + .addComp(searchesList) + .hide(searchesEmpty) + .addComp(new LabelComp(AppI18n.observable("recentQuickConnections"))) + .hide(quickConnectionsEmpty) + .addComp(quickConnectionsList) + .hide(quickConnectionsEmpty) + .addComp(RegionBuilder.hseparator()) + .addComp(new LabelComp(AppI18n.observable("recentSearchesDescription"))) + .addComp(searchesEmptyList) + .addComp(new LabelComp(AppI18n.observable("recentQuickConnectionsDescription"))) + .addComp(quickConnectionsEmptyList) + .build(); + options.getStyleClass().add("store-filter-state-comp"); + return options; + } +} diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreIconChoiceComp.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreIconChoiceComp.java index 0f585008d..eb00ae63f 100644 --- a/app/src/main/java/io/xpipe/app/hub/comp/StoreIconChoiceComp.java +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreIconChoiceComp.java @@ -8,11 +8,9 @@ import io.xpipe.app.icon.SystemIcon; import io.xpipe.app.icon.SystemIconManager; import io.xpipe.app.platform.LabelGraphic; import io.xpipe.app.platform.PlatformThread; -import io.xpipe.app.storage.DataStoreEntry; import io.xpipe.app.util.BooleanScope; import io.xpipe.app.util.ThreadHelper; -import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.beans.property.*; import javafx.geometry.Pos; @@ -28,7 +26,6 @@ import lombok.Getter; import java.util.*; import java.util.stream.Collectors; -import java.util.stream.Stream; import static atlantafx.base.theme.Styles.TEXT_SMALL; @@ -39,7 +36,7 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp { private final int columns; private final SimpleStringProperty filter; private final Runnable doubleClick; - private final DataStoreEntry entry; + private final String defaultIcon; @Getter private final BooleanProperty busy = new SimpleBooleanProperty(); @@ -49,14 +46,14 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp { Set icons, int columns, SimpleStringProperty filter, - Runnable doubleClick, DataStoreEntry entry - ) { + Runnable doubleClick, + String defaultIcon) { this.selected = selected; this.icons = icons; this.columns = columns; this.filter = filter; this.doubleClick = doubleClick; - this.entry = entry; + this.defaultIcon = defaultIcon; } @Override @@ -79,7 +76,9 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp { filter.addListener((observable, oldValue, newValue) -> updateData(table, newValue)); busy.addListener((observable, oldValue, newValue) -> { if (oldValue && !newValue) { - updateData(table, filter.getValue()); + PlatformThread.runLaterIfNeeded(() -> { + updateData(table, filter.getValue()); + }); } }); @@ -99,6 +98,12 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp { } private void initTable(TableView> table) { + table.setPlaceholder(new Region()); + table.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_ALL_COLUMNS); + table.getSelectionModel().setCellSelectionEnabled(true); + table.getStyleClass().add("icon-browser"); + table.disableProperty().bind(PlatformThread.sync(busy)); + for (int i = 0; i < columns; i++) { var col = new TableColumn, SystemIcon>("col" + i); final int colIndex = i; @@ -111,12 +116,6 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp { col.getStyleClass().add(Tweaks.ALIGN_CENTER); table.getColumns().add(col); } - - table.setPlaceholder(new Region()); - table.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_ALL_COLUMNS); - table.getSelectionModel().setCellSelectionEnabled(true); - table.getStyleClass().add("icon-browser"); - table.disableProperty().bind(PlatformThread.sync(busy)); } private Region createLoadingPane() { @@ -132,7 +131,7 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp { }); refreshButton.hide(Bindings.createBooleanBinding( () -> { - return SystemIconManager.hasLoadedAnyImages(); + return SystemIconManager.hasLoadedAnyImages() && !SystemIconManager.isCacheOutdated(); }, busy)); refreshButton.disable(busy); @@ -159,11 +158,6 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp { } private void updateData(TableView> table, String filterString) { - if (SystemIconManager.isCacheOutdated()) { - table.getItems().clear(); - return; - } - var available = icons.stream() .filter(systemIcon -> AppImages.hasImage( "icons/" + systemIcon.getSource().getId() + "/" + systemIcon.getId() + "-40.png")) @@ -172,7 +166,9 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp { available.addFirst(new SystemIcon(null, "default")); List shown; - if (filterString != null && !filterString.isBlank() && filterString.strip().length() >= 2) { + if (filterString != null + && !filterString.isBlank() + && filterString.strip().length() >= 2) { shown = available.stream() .filter(icon -> containsString(icon.getId(), filterString.strip())) .collect(Collectors.toCollection(ArrayList::new)); @@ -249,13 +245,13 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp { if (icon.getSource() == null) { root.setText(AppI18n.get("default")); - image.setValue(entry.getProvider().getDisplayIconFileName(entry.getStore())); + image.setValue(defaultIcon); setGraphic(root); return; } root.setText(icon.getId()); - image.set(SystemIconManager.getAndLoadIconFile(icon)); + image.set(SystemIconManager.getAndLoadIconFile(icon, false)); setGraphic(root); } } diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreIconChoiceDialog.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreIconChoiceDialog.java index c8447e5f9..083e40d17 100644 --- a/app/src/main/java/io/xpipe/app/hub/comp/StoreIconChoiceDialog.java +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreIconChoiceDialog.java @@ -5,6 +5,7 @@ import io.xpipe.app.icon.SystemIcon; import io.xpipe.app.icon.SystemIconManager; import io.xpipe.app.platform.PlatformThread; import io.xpipe.app.prefs.AppPrefs; +import io.xpipe.app.storage.DataStoreCategory; import io.xpipe.app.storage.DataStoreEntry; import javafx.application.Platform; @@ -16,21 +17,34 @@ import javafx.beans.property.SimpleStringProperty; import lombok.Getter; import org.kordamp.ikonli.javafx.FontIcon; +import java.util.function.Consumer; + public class StoreIconChoiceDialog { private final ObjectProperty selected = new SimpleObjectProperty<>(); - private final DataStoreEntry entry; + private final String defaultIcon; + private final Consumer iconApply; @Getter private final ModalOverlay overlay; - public StoreIconChoiceDialog(DataStoreEntry entry) { - this.entry = entry; + public StoreIconChoiceDialog(String defaultIcon, Consumer iconApply) { + this.defaultIcon = defaultIcon; + this.iconApply = iconApply; this.overlay = createOverlay(); } public static void show(DataStoreEntry entry) { - var dialog = new StoreIconChoiceDialog(entry); + var dialog = new StoreIconChoiceDialog(entry.getProvider().getDisplayIconFileName(entry.getStore()), s -> { + entry.setIcon(s != null && s.getSource() != null ? s.getSource().getId() + "/" + s.getId() : null, true); + }); + dialog.getOverlay().show(); + } + + public static void show(DataStoreCategory entry) { + var dialog = new StoreIconChoiceDialog(entry.getDefaultIconFile(), s -> { + entry.setIcon(s != null && s.getSource() != null ? s.getSource().getId() + "/" + s.getId() : null, true); + }); dialog.getOverlay().show(); } @@ -58,15 +72,15 @@ public class StoreIconChoiceDialog { () -> { finish(); }, - entry); + defaultIcon); comp.prefWidth(600); - var modal = ModalOverlay.of( - "chooseCustomIcon", - comp); + var modal = ModalOverlay.of("chooseCustomIcon", comp); var refresh = new ButtonComp(null, new FontIcon("mdi2r-refresh"), () -> { - comp.refresh(); - }).maxHeight(100).disable(comp.getBusy()); + comp.refresh(); + }) + .maxHeight(100) + .disable(comp.getBusy()); var settings = new ButtonComp(null, new FontIcon("mdomz-settings"), () -> { overlay.close(); AppPrefs.get().selectCategory("icons"); @@ -79,19 +93,19 @@ public class StoreIconChoiceDialog { modal.addButton(ModalButton.ok(() -> { finish(); })) - .augment(button -> button.disableProperty().bind(Bindings.createBooleanBinding(() -> { - return selected.get() == null || comp.getBusy().get(); - }, selected, PlatformThread.sync(comp.getBusy())))); + .augment(button -> button.disableProperty() + .bind(Bindings.createBooleanBinding( + () -> { + return selected.get() == null + || comp.getBusy().get(); + }, + selected, + PlatformThread.sync(comp.getBusy())))); return modal; } private void finish() { - entry.setIcon( - selected.get() != null && selected.getValue().getSource() != null - ? selected.getValue().getSource().getId() + "/" - + selected.getValue().getId() - : null, - true); + iconApply.accept(selected.get()); overlay.close(); } } diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreListChoiceComp.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreListChoiceComp.java index c1c605911..8eca5a115 100644 --- a/app/src/main/java/io/xpipe/app/hub/comp/StoreListChoiceComp.java +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreListChoiceComp.java @@ -117,7 +117,14 @@ public class StoreListChoiceComp extends SimpleRegionBuilde list.add(RegionBuilder.vspacer(5).hide(Bindings.isEmpty(selectedList))); list.add(add); } - var vbox = new VerticalComp(list).apply(struc -> struc.setFillWidth(true)); + var vbox = new VerticalComp(list).apply(struc -> { + struc.setFillWidth(true); + struc.focusedProperty().subscribe(focus -> { + if (focus) { + struc.getChildren().getLast().requestFocus(); + } + }); + }); return vbox.style("data-store-list-choice-comp").build(); } } 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 cca7dcd4e..245d84a62 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 @@ -3,12 +3,9 @@ package io.xpipe.app.hub.comp; import io.xpipe.app.comp.*; import io.xpipe.app.comp.base.*; import io.xpipe.app.core.AppFontSizes; -import io.xpipe.app.platform.BindingsHelper; import io.xpipe.app.storage.DataStorage; - import io.xpipe.app.util.FileOpener; -import javafx.application.Platform; -import javafx.beans.property.Property; + import javafx.beans.property.ReadOnlyStringWrapper; import javafx.beans.property.SimpleStringProperty; import javafx.scene.control.Button; @@ -26,16 +23,24 @@ public class StoreNotesComp extends RegionBuilder