diff --git a/app/src/main/java/io/xpipe/app/beacon/AppBeaconCache.java b/app/src/main/java/io/xpipe/app/beacon/AppBeaconCache.java index 9452e6c72..025c4e1e1 100644 --- a/app/src/main/java/io/xpipe/app/beacon/AppBeaconCache.java +++ b/app/src/main/java/io/xpipe/app/beacon/AppBeaconCache.java @@ -36,12 +36,6 @@ public class AppBeaconCache { control.setNonInteractive(); control.start(); - var d = control.getShellDialect().getDumbMode(); - if (!d.supportsAnyPossibleInteraction()) { - control.close(); - d.throwIfUnsupported(); - } - if (existing.isEmpty()) { AppBeaconServer.get().getCache().getShellSessions().add(new BeaconShellSession(ref.get(), control)); } 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 dd40d92e9..96120c07d 100644 --- a/app/src/main/java/io/xpipe/app/beacon/AppBeaconServer.java +++ b/app/src/main/java/io/xpipe/app/beacon/AppBeaconServer.java @@ -31,9 +31,6 @@ public class AppBeaconServer { @Getter private final int port; - @Getter - private final boolean propertyPort; - @Getter private final Set sessions = new HashSet<>(); @@ -47,22 +44,13 @@ public class AppBeaconServer { @Getter private String localAuthSecret; - private AppBeaconServer(int port, boolean propertyPort) { + private AppBeaconServer(int port) { this.port = port; - this.propertyPort = propertyPort; } public static void setupPort() { - int port; - boolean propertyPort; - if (System.getProperty(BeaconConfig.BEACON_PORT_PROP) != null) { - port = BeaconConfig.getUsedPort(); - propertyPort = true; - } else { - port = BeaconConfig.getDefaultBeaconPort(); - propertyPort = false; - } - INSTANCE = new AppBeaconServer(port, propertyPort); + int port = BeaconConfig.getUsedPort(); + INSTANCE = new AppBeaconServer(port); } public static void init() { diff --git a/app/src/main/java/io/xpipe/app/beacon/impl/FsReadExchangeImpl.java b/app/src/main/java/io/xpipe/app/beacon/impl/FsReadExchangeImpl.java index 93eff4052..b3db97e8b 100644 --- a/app/src/main/java/io/xpipe/app/beacon/impl/FsReadExchangeImpl.java +++ b/app/src/main/java/io/xpipe/app/beacon/impl/FsReadExchangeImpl.java @@ -31,8 +31,7 @@ public class FsReadExchangeImpl extends FsReadExchange { var file = BlobManager.get().newBlobFile(); try (var in = fs.openInput(msg.getPath())) { var fixedIn = new FixedSizeInputStream(new BufferedInputStream(in), size); - try (var fileOut = - Files.newOutputStream(file.resolve(msg.getPath().getFileName()))) { + try (var fileOut = Files.newOutputStream(file)) { fixedIn.transferTo(fileOut); } in.transferTo(OutputStream.nullOutputStream()); 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 8b225909d..32d44c1ee 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 @@ -49,11 +49,11 @@ public class AppMcpServer { McpSyncServer syncServer = io.modelcontextprotocol.server.McpServer.sync(transportProvider) .serverInfo(AppNames.ofCurrent().getName(), AppProperties.get().getVersion()) .capabilities(McpSchema.ServerCapabilities.builder() - .resources(true, true) + .resources(false, false) .tools(true) .prompts(false) - .completions() .build()) + .instructions(AppPrefs.get().mcpAdditionalContext().getValue()) .build(); var readOnlyTools = new ArrayList(); @@ -65,13 +65,13 @@ public class AppMcpServer { readOnlyTools.add(McpTools.getFileInfo()); var mutationTools = new ArrayList(); + mutationTools.add(McpTools.openTerminal()); + mutationTools.add(McpTools.openTerminalInline()); mutationTools.add(McpTools.createFile()); mutationTools.add(McpTools.writeFile()); mutationTools.add(McpTools.createDirectory()); mutationTools.add(McpTools.runCommand()); mutationTools.add(McpTools.runScript()); - mutationTools.add(McpTools.openTerminal()); - mutationTools.add(McpTools.openTerminalInline()); mutationTools.add(McpTools.toggleState()); for (McpServerFeatures.SyncToolSpecification readOnlyTool : readOnlyTools) { 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 f4d9ff768..b838c1e06 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 @@ -37,7 +37,7 @@ public interface McpToolHandler .isError(true) .build(); } catch (Throwable e) { - ErrorEventFactory.fromThrowable(e).handle(); + ErrorEventFactory.fromThrowable(e).omit().handle(); return McpSchema.CallToolResult.builder() .addTextContent(e.getMessage()) .isError(true) @@ -137,7 +137,7 @@ public interface McpToolHandler return e.ref(); } - public DataStoreEntryRef getShellStoreRef(String name) throws BeaconClientException { + public DataStoreEntryRef getShellStoreRef(String name, boolean mutation) throws BeaconClientException { var ref = getDataStoreRef(name); var isShell = ref.getStore() instanceof ShellStore; if (!isShell) { @@ -145,6 +145,12 @@ public interface McpToolHandler + DataStorage.get().getStorePath(ref.get()).toString() + " is not a shell connection"); } + 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"); + } + 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 79e7c027f..bb4772c17 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 @@ -3,10 +3,8 @@ package io.xpipe.app.beacon.mcp; import io.xpipe.app.beacon.AppBeaconServer; import io.xpipe.app.core.AppExtensionManager; import io.xpipe.app.core.AppNames; -import io.xpipe.app.ext.ConnectionFileSystem; -import io.xpipe.app.ext.FileEntry; -import io.xpipe.app.ext.FileInfo; -import io.xpipe.app.ext.SingletonSessionStore; +import io.xpipe.app.ext.*; +import io.xpipe.app.hub.comp.StoreViewState; import io.xpipe.app.process.ScriptHelper; import io.xpipe.app.process.ShellControl; import io.xpipe.app.process.TerminalInitScriptConfig; @@ -85,6 +83,10 @@ public final class McpTools { @NonNull String path; + + String information; + + String notes; } public static McpServerFeatures.SyncToolSpecification listSystems() throws IOException { @@ -107,9 +109,14 @@ 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 r = ConnectionResource.builder() .name(e.getName()) .path(DataStorage.get().getStorePath(e).toString()) + .information(info) + .notes(e.getNotes()) .build(); list.add(r); } @@ -138,7 +145,7 @@ public final class McpTools { .callHandler(McpToolHandler.of((req) -> { var path = req.getFilePath("path"); var system = req.getStringArgument("system"); - var shellStore = req.getShellStoreRef(system); + var shellStore = req.getShellStoreRef(system, false); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); var fs = new ConnectionFileSystem(shellSession.getControl()); @@ -165,7 +172,7 @@ public final class McpTools { var path = req.getFilePath("path"); var system = req.getStringArgument("system"); var recursive = req.getOptionalBooleanArgument("recursive").orElse(false); - var shellStore = req.getShellStoreRef(system); + var shellStore = req.getShellStoreRef(system, false); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); var fs = new ConnectionFileSystem(shellSession.getControl()); @@ -194,7 +201,7 @@ public final class McpTools { var system = req.getStringArgument("system"); var recursive = req.getOptionalBooleanArgument("recursive").orElse(false); var pattern = req.getStringArgument("name"); - var shellStore = req.getShellStoreRef(system); + var shellStore = req.getShellStoreRef(system, false); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); var fs = new ConnectionFileSystem(shellSession.getControl()); @@ -226,7 +233,7 @@ public final class McpTools { .callHandler(McpToolHandler.of((req) -> { var path = req.getFilePath("path"); var system = req.getStringArgument("system"); - var shellStore = req.getShellStoreRef(system); + var shellStore = req.getShellStoreRef(system, false); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); var fs = new ConnectionFileSystem(shellSession.getControl()); @@ -267,7 +274,7 @@ public final class McpTools { .callHandler(McpToolHandler.of((req) -> { var path = req.getFilePath("path"); var system = req.getStringArgument("system"); - var shellStore = req.getShellStoreRef(system); + var shellStore = req.getShellStoreRef(system, true); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); var fs = new ConnectionFileSystem(shellSession.getControl()); @@ -300,7 +307,7 @@ public final class McpTools { var path = req.getFilePath("path"); var system = req.getStringArgument("system"); var content = req.getStringArgument("content"); - var shellStore = req.getShellStoreRef(system); + var shellStore = req.getShellStoreRef(system, true); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); var fs = new ConnectionFileSystem(shellSession.getControl()); @@ -323,7 +330,7 @@ public final class McpTools { .callHandler(McpToolHandler.of((req) -> { var path = req.getFilePath("path"); var system = req.getStringArgument("system"); - var shellStore = req.getShellStoreRef(system); + var shellStore = req.getShellStoreRef(system, true); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); var fs = new ConnectionFileSystem(shellSession.getControl()); @@ -347,10 +354,10 @@ public final class McpTools { .callHandler(McpToolHandler.of((req) -> { var command = req.getStringArgument("command"); var system = req.getStringArgument("system"); - var shellStore = req.getShellStoreRef(system); + var shellStore = req.getShellStoreRef(system, true); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); - var out = shellSession.getControl().command(command).readStdoutOrThrow(); + var out = ProcessControlProvider.get().executeMcpCommand(shellSession.getControl(), command); var formatted = CommandDialog.formatOutput(out); return McpSchema.CallToolResult.builder() @@ -370,7 +377,7 @@ public final class McpTools { var directory = req.getFilePath("directory"); var arguments = req.getStringArgument("arguments"); - var shellStore = req.getShellStoreRef(system); + var shellStore = req.getShellStoreRef(system, true); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); var clazz = Class.forName( @@ -407,7 +414,7 @@ public final class McpTools { .callHandler(McpToolHandler.of((req) -> { var system = req.getStringArgument("system"); var directory = req.getOptionalStringArgument("directory"); - var shellStore = req.getShellStoreRef(system); + var shellStore = req.getShellStoreRef(system, true); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); TerminalLaunch.builder() @@ -430,7 +437,7 @@ public final class McpTools { .callHandler(McpToolHandler.of((req) -> { var system = req.getStringArgument("system"); var directory = req.getOptionalStringArgument("directory"); - var shellStore = req.getShellStoreRef(system); + var shellStore = req.getShellStoreRef(system, true); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); var script = shellSession diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserTerminalDockTabModel.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserTerminalDockTabModel.java index 5ee01ed93..d7db04dd3 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserTerminalDockTabModel.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserTerminalDockTabModel.java @@ -14,6 +14,7 @@ import io.xpipe.app.terminal.TerminalDockBrowserComp; import io.xpipe.app.terminal.TerminalDockView; import io.xpipe.app.terminal.TerminalView; import io.xpipe.app.terminal.WindowsTerminalType; +import io.xpipe.app.util.GlobalTimer; import io.xpipe.app.util.ThreadHelper; import javafx.application.Platform; @@ -25,6 +26,7 @@ import javafx.beans.value.ObservableValue; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; +import java.time.Duration; import java.util.UUID; import java.util.function.UnaryOperator; @@ -36,6 +38,7 @@ public final class BrowserTerminalDockTabModel extends BrowserSessionTab { private final BooleanProperty opened = new SimpleBooleanProperty(); private TerminalView.Listener listener; private ObservableBooleanValue viewActive; + private boolean closed; public BrowserTerminalDockTabModel( BrowserAbstractSessionModel browserModel, @@ -147,6 +150,14 @@ public final class BrowserTerminalDockTabModel extends BrowserSessionTab { } } }); + + GlobalTimer.scheduleUntil(Duration.ofMillis(300), false, () -> { + if (viewActive.get()) { + dockModel.clearDeadTerminals(); + dockModel.updateCustomBounds(); + } + return closed; + }); } @Override @@ -155,6 +166,7 @@ public final class BrowserTerminalDockTabModel extends BrowserSessionTab { TerminalView.get().removeListener(listener); } dockModel.onClose(); + closed = true; } @Override diff --git a/app/src/main/java/io/xpipe/app/browser/icon/BrowserIconManager.java b/app/src/main/java/io/xpipe/app/browser/icon/BrowserIconManager.java index 39a505384..7a558dc6e 100644 --- a/app/src/main/java/io/xpipe/app/browser/icon/BrowserIconManager.java +++ b/app/src/main/java/io/xpipe/app/browser/icon/BrowserIconManager.java @@ -19,7 +19,7 @@ public class BrowserIconManager { } public static void loadIfNecessary(String s) { - var res = AppDisplayScale.hasDefaultDisplayScale() ? "24" : "40"; + var res = AppDisplayScale.hasOnlyDefaultDisplayScale() ? "24" : "40"; var key = "browser/" + FilenameUtils.getBaseName(s) + "-" + res + ".png"; if (AppImages.hasImage(key)) { return; 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 e40046bcf..70f64c7a8 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,11 +4,14 @@ 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.util.GlobalTimer; import javafx.beans.binding.Bindings; import javafx.beans.value.ObservableValue; import javafx.scene.Node; @@ -19,7 +22,9 @@ 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; @@ -61,6 +66,15 @@ public class AppLayoutComp extends RegionStructureBuilder { + 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))) { diff --git a/app/src/main/java/io/xpipe/app/comp/base/DialogComp.java b/app/src/main/java/io/xpipe/app/comp/base/DialogComp.java deleted file mode 100644 index 604402bc4..000000000 --- a/app/src/main/java/io/xpipe/app/comp/base/DialogComp.java +++ /dev/null @@ -1,88 +0,0 @@ -package io.xpipe.app.comp.base; - -import io.xpipe.app.comp.RegionBuilder; -import io.xpipe.app.core.AppI18n; - -import javafx.geometry.Pos; -import javafx.scene.control.ScrollPane; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Priority; -import javafx.scene.layout.Region; -import javafx.scene.layout.VBox; - -import atlantafx.base.theme.Styles; -import org.int4.fx.builders.common.AbstractRegionBuilder; - -import java.util.List; - -public abstract class DialogComp extends RegionBuilder { - - protected Region createNavigation() { - HBox buttons = new HBox(); - buttons.setFillHeight(true); - var customButton = bottom(); - if (customButton != null) { - var c = customButton.build(); - buttons.getChildren().add(c); - HBox.setHgrow(c, Priority.ALWAYS); - } - var spacer = new Region(); - HBox.setHgrow(spacer, Priority.SOMETIMES); - buttons.getChildren().add(spacer); - buttons.getStyleClass().add("buttons"); - buttons.setSpacing(5); - buttons.setAlignment(Pos.CENTER_RIGHT); - - buttons.getChildren() - .addAll(customButtons().stream() - .map(buttonComp -> buttonComp.build()) - .toList()); - var nextButton = finishButton(); - buttons.getChildren().add(nextButton.build()); - return buttons; - } - - protected AbstractRegionBuilder finishButton() { - return new ButtonComp(AppI18n.observable(finishKey()), this::finish) - .style(Styles.ACCENT) - .style("next"); - } - - protected String finishKey() { - return "finishStep"; - } - - protected List> customButtons() { - return List.of(); - } - - @Override - public Region createSimple() { - var sp = pane(content()).style("dialog-content").build(); - VBox vbox = new VBox(); - vbox.getChildren().addAll(sp, createNavigation()); - vbox.getStyleClass().add("dialog-comp"); - vbox.setFillWidth(true); - VBox.setVgrow(sp, Priority.ALWAYS); - return vbox; - } - - protected abstract void finish(); - - public abstract AbstractRegionBuilder content(); - - protected AbstractRegionBuilder pane(AbstractRegionBuilder content) { - var entry = content; - return RegionBuilder.of(() -> { - var entryR = entry.build(); - var sp = new ScrollPane(entryR); - sp.setFitToWidth(true); - entryR.minHeightProperty().bind(sp.heightProperty()); - return sp; - }); - } - - public AbstractRegionBuilder bottom() { - return null; - } -} 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 bb76a4671..e2b2c969c 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 @@ -89,13 +89,19 @@ public class IntegratedTextAreaComp extends RegionStructureBuilder { var val = value.getValue() != null ? value.getValue() : ""; - var count = (int) val.lines().count() + (val.endsWith("\n") ? 1 : 0); + var valCount = (int) val.lines().count() + (val.endsWith("\n") ? 1 : 0); + + var promptVal = struc.getTextArea().getPromptText() != null ? struc.getTextArea().getPromptText() : ""; + var promptValCount = (int) promptVal.lines().count() + (promptVal.endsWith("\n") ? 1 : 0); + + var count = Math.max(valCount, promptValCount); // Somehow the handling of trailing newlines is weird // This makes the handling better for JavaFX text areas count++; return Math.max(1, count); }, - value)); + value, + struc.getTextArea().promptTextProperty())); }); var textAreaStruc = textArea.buildStructure(); var copyButton = createOpenButton(); diff --git a/app/src/main/java/io/xpipe/app/comp/base/ListBoxViewComp.java b/app/src/main/java/io/xpipe/app/comp/base/ListBoxViewComp.java index e23ca4a83..0d63b1a59 100644 --- a/app/src/main/java/io/xpipe/app/comp/base/ListBoxViewComp.java +++ b/app/src/main/java/io/xpipe/app/comp/base/ListBoxViewComp.java @@ -62,13 +62,13 @@ public class ListBoxViewComp 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,9 @@ public class ListBoxViewComp extends RegionBuilder { vbox.heightProperty().addListener((observable, oldValue, newValue) -> { dirty.set(true); }); + vbox.getChildren().addListener((ListChangeListener) (change) -> { + dirty.set(true); + }); // We can't directly listen to any parent element changing visibility, so this is a compromise if (AppLayoutModel.get() != null) { @@ -297,12 +309,10 @@ public class ListBoxViewComp extends RegionBuilder { } private void refresh( - ScrollPane scroll, VBox listView, List shown, List all, - Map cache, - boolean refreshVisibilities) { + Map cache) { Runnable update = () -> { if (!Platform.isFxApplicationThread()) { throw new IllegalStateException("Not in FxApplication thread"); @@ -358,9 +368,6 @@ public class ListBoxViewComp extends RegionBuilder { var d = DerivedObservableList.wrap(listView.getChildren(), true); d.setContent(newShown); - if (refreshVisibilities) { - updateVisibilities(scroll, listView); - } }; update.run(); } diff --git a/app/src/main/java/io/xpipe/app/comp/base/MarkdownComp.java b/app/src/main/java/io/xpipe/app/comp/base/MarkdownComp.java index 0ccf1cdbc..ce7ea411d 100644 --- a/app/src/main/java/io/xpipe/app/comp/base/MarkdownComp.java +++ b/app/src/main/java/io/xpipe/app/comp/base/MarkdownComp.java @@ -100,7 +100,7 @@ public class MarkdownComp extends RegionBuilder { var url = AppResources.getResourceURL(AppResources.MAIN_MODULE, theme).orElseThrow(); wv.getEngine().setUserStyleSheetLocation(url.toString()); - PlatformThread.sync(markdown).subscribe(val -> { + markdown.subscribe(val -> { PlatformThread.runLaterIfNeeded(() -> { var file = getHtmlFile(val); if (file != null) { 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 64fae5e7d..612f39020 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 @@ -49,10 +49,11 @@ public class SideMenuBarComp extends RegionBuilder { if (e.action() != null) { e.action().run(); - return; } - value.setValue(e); + if (e.comp() != null) { + value.setValue(e); + } }); b.describe(d -> d.name(e.name())); diff --git a/app/src/main/java/io/xpipe/app/comp/base/ToggleSwitchComp.java b/app/src/main/java/io/xpipe/app/comp/base/ToggleSwitchComp.java index 237b3df03..d4140128c 100644 --- a/app/src/main/java/io/xpipe/app/comp/base/ToggleSwitchComp.java +++ b/app/src/main/java/io/xpipe/app/comp/base/ToggleSwitchComp.java @@ -70,6 +70,7 @@ public class ToggleSwitchComp extends RegionBuilder { s.setGraphic(value.createGraphicNode()); }); }); + s.setAlignment(Pos.CENTER); s.pseudoClassStateChanged(PseudoClass.getPseudoClass("has-graphic"), true); } 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/AppDisplayScale.java b/app/src/main/java/io/xpipe/app/core/AppDisplayScale.java index cbb7f432b..8da1fb35d 100644 --- a/app/src/main/java/io/xpipe/app/core/AppDisplayScale.java +++ b/app/src/main/java/io/xpipe/app/core/AppDisplayScale.java @@ -8,6 +8,7 @@ import javafx.stage.Screen; public class AppDisplayScale { private static Double screenOutputScale; + private static Boolean defaultDisplayScale; public static void init() { try { @@ -15,13 +16,23 @@ public class AppDisplayScale { if (primary != null) { screenOutputScale = primary.getOutputScaleX(); } + + var s = AppPrefs.get().uiScale().getValue(); + if (s != null && s == 100) { + defaultDisplayScale = true; + } + + var allScreensDefault = Screen.getScreens().stream().allMatch(screen -> screen.getOutputScaleX() == 1.0); + if (allScreensDefault) { + defaultDisplayScale = true; + } } catch (Exception e) { ErrorEventFactory.fromThrowable(e).omit().expected().handle(); } } - public static boolean hasDefaultDisplayScale() { - return getEffectiveDisplayScale() == 1.0; + public static boolean hasOnlyDefaultDisplayScale() { + return defaultDisplayScale != null ? defaultDisplayScale : false; } public static double getEffectiveDisplayScale() { 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 5206b3b60..3cb7b6af7 100644 --- a/app/src/main/java/io/xpipe/app/core/AppFontSizes.java +++ b/app/src/main/java/io/xpipe/app/core/AppFontSizes.java @@ -8,6 +8,7 @@ import javafx.scene.Node; import lombok.AllArgsConstructor; import lombok.Value; +import java.lang.ref.WeakReference; import java.util.function.Function; import java.util.regex.Pattern; @@ -75,16 +76,21 @@ public class AppFontSizes { return; } + var ref = new WeakReference<>(node); AppPrefs.get().theme().subscribe((newValue) -> { - var effective = newValue != null ? newValue.getFontSizes().get() : getDefault(); - setFont(node, function.apply(effective)); + var refNode = ref.get(); + if (refNode != null) { + var effective = newValue != null ? newValue.getFontSizes().get() : getDefault(); + setFont(refNode, function.apply(effective)); + } }); AppPrefs.get().useSystemFont().addListener((ignored, ignored2, newValue) -> { - var effective = AppPrefs.get().theme().getValue() != null - ? AppPrefs.get().theme().getValue().getFontSizes().get() - : getDefault(); - setFont(node, function.apply(effective)); + var refNode = ref.get(); + if (refNode != null) { + 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 ba10e3324..6e391ba80 100644 --- a/app/src/main/java/io/xpipe/app/core/AppImages.java +++ b/app/src/main/java/io/xpipe/app/core/AppImages.java @@ -44,7 +44,7 @@ public class AppImages { return; } - var skipLarge = AppDisplayScale.hasDefaultDisplayScale(); + var skipLarge = AppDisplayScale.hasOnlyDefaultDisplayScale(); Files.walkFileTree(basePath, new SimpleFileVisitor<>() { @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { @@ -63,7 +63,7 @@ public class AppImages { private static void loadOsIcons() { AppResources.with(AppResources.MAIN_MODULE, "os", basePath -> { - var skipLarge = AppDisplayScale.hasDefaultDisplayScale(); + var skipLarge = AppDisplayScale.hasOnlyDefaultDisplayScale(); Files.walkFileTree(basePath, new SimpleFileVisitor<>() { @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { @@ -81,7 +81,7 @@ public class AppImages { private static void loadWelcomeImages() { AppResources.with(AppResources.MAIN_MODULE, "welcome", basePath -> { - var skipLarge = AppDisplayScale.hasDefaultDisplayScale(); + var skipLarge = AppDisplayScale.hasOnlyDefaultDisplayScale(); Files.walkFileTree(basePath, new SimpleFileVisitor<>() { @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { 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 a360a0bfb..06e0915c5 100644 --- a/app/src/main/java/io/xpipe/app/core/AppLayoutModel.java +++ b/app/src/main/java/io/xpipe/app/core/AppLayoutModel.java @@ -7,6 +7,7 @@ import io.xpipe.app.hub.comp.StoreLayoutComp; import io.xpipe.app.platform.LabelGraphic; import io.xpipe.app.platform.PlatformThread; import io.xpipe.app.prefs.AppPrefsComp; +import io.xpipe.app.terminal.TerminalDockHubManager; import io.xpipe.app.update.AppDistributionType; import io.xpipe.app.util.*; @@ -117,7 +118,9 @@ public class AppLayoutModel { AppI18n.observable("connections"), new LabelGraphic.IconGraphic("mdi2c-connection"), new StoreLayoutComp(), - null, + () -> { + TerminalDockHubManager.get().hideDock(); + }, new KeyCodeCombination(KeyCode.DIGIT1, KeyCombination.SHORTCUT_DOWN)), new Entry( AppI18n.observable("browser"), 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..ace49c39e 100644 --- a/app/src/main/java/io/xpipe/app/core/AppSystemInfo.java +++ b/app/src/main/java/io/xpipe/app/core/AppSystemInfo.java @@ -260,6 +260,7 @@ public abstract class AppSystemInfo { private Path downloads; private Path desktop; + private Path config; private Boolean vm; public boolean isDebianBased() { @@ -356,6 +357,20 @@ 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")); + } + } + @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..24d75dece 100644 --- a/app/src/main/java/io/xpipe/app/core/AppTheme.java +++ b/app/src/main/java/io/xpipe/app/core/AppTheme.java @@ -251,41 +251,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/mode/AppBaseMode.java b/app/src/main/java/io/xpipe/app/core/mode/AppBaseMode.java index e1fbf2245..56dc2e6c8 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,7 @@ 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.StoreQuickConnect; import io.xpipe.app.hub.comp.StoreViewState; import io.xpipe.app.icon.SystemIconManager; import io.xpipe.app.issue.TrackEvent; @@ -130,6 +131,7 @@ public class AppBaseMode extends AppOperationMode { AppMcpServer.init(); iconsInit.await(); StoreViewState.init(); + StoreQuickConnect.init(); AppMainWindow.loadingText("loadingSettings"); TrackEvent.info("Connection storage initialization thread completed"); }, 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 66% 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 9e01db070..4b0940011 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,18 +1,19 @@ -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; @@ -31,7 +32,7 @@ import java.util.List; @Value @Jacksonized @Builder -public class CustomAgentStrategy implements SshIdentityStrategy { +public class CustomAgentStrategy implements SshIdentityAgentStrategy { @SuppressWarnings("unused") public static OptionsBuilder createOptions( @@ -41,10 +42,13 @@ public class CustomAgentStrategy implements SshIdentityStrategy { var publicKey = new SimpleStringProperty(p.getValue() != null ? p.getValue().getPublicKey() : null); - var socket = AppPrefs.get().sshAgentSocket(); - var socketBinding = BindingsHelper.map(socket, s -> { - return s != null ? s.toString() : AppI18n.get("agentSocketNotConfigured"); - }); + 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( @@ -62,21 +66,22 @@ public class CustomAgentStrategy implements SshIdentityStrategy { .addComp(socketDisplay) .check(val -> Validator.create( val, - AppI18n.observable("agentSocketNotConfigured"), - AppPrefs.get().sshAgentSocket(), + 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("publicKey") + .addComp(new SshAgentKeyListComp(config.getFileSystem(), p, publicKey, false), publicKey) .nameAndDescription("forwardAgent") .addToggle(forward) .nonNull() .hide(!config.isAllowAgentForward()) - .nameAndDescription("publicKey") - .addComp( - new TextFieldComp(publicKey) - .apply(struc -> struc.setPromptText( - "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIBmhLUTJiP...== Your Comment")), - publicKey) .bind( () -> { return new CustomAgentStrategy(forward.get(), publicKey.get()); @@ -90,29 +95,37 @@ public class CustomAgentStrategy implements SshIdentityStrategy { @Override public void prepareParent(ShellControl parent) throws Exception { if (parent.isLocal()) { + var agent = AppPrefs.get().sshAgentSocket().getValue(); + if (agent == null) { + agent = AppPrefs.get().defaultSshAgentSocket().getValue(); + } SshIdentityStateManager.prepareLocalCustomAgent( - parent, AppPrefs.get().sshAgentSocket().getValue()); + 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; } if (AppPrefs.get() != null) { - var socket = AppPrefs.get().sshAgentSocket().getValue(); - if (socket != null) { - return socket.resolveTildeHome(sc.view().userHome()).toString(); + var agent = AppPrefs.get().sshAgentSocket().getValue(); + if (agent == null) { + agent = AppPrefs.get().defaultSshAgentSocket().getValue(); + } + if (agent != null) { + 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); @@ -122,11 +135,15 @@ public class CustomAgentStrategy implements SshIdentityStrategy { 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 85% 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..59b797ede 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,4 +1,4 @@ -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; @@ -7,6 +7,7 @@ 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; @@ -27,7 +28,7 @@ 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) { @@ -36,11 +37,11 @@ public class GpgAgentStrategy implements SshIdentityStrategy { var publicKey = new SimpleStringProperty(p.getValue() != null ? p.getValue().getPublicKey() : null); return new OptionsBuilder() + .nameAndDescription("publicKey") + .addComp(new SshAgentKeyListComp(config.getFileSystem(), p, publicKey, false), publicKey) .nameAndDescription("forwardAgent") .addToggle(forward) .nonNull() - .hide(!config.isAllowAgentForward()) - .nameAndDescription("publicKey") .addComp( new TextFieldComp(publicKey) .apply(struc -> struc.setPromptText( @@ -60,18 +61,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)); @@ -93,9 +82,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,9 +92,12 @@ 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); @@ -117,11 +107,15 @@ public class GpgAgentStrategy implements SshIdentityStrategy { 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 84% 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..8ae6d2cdd 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,11 +1,11 @@ -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; @@ -25,7 +25,7 @@ import java.util.List; @Value @Jacksonized @Builder -public class OpenSshAgentStrategy implements SshIdentityStrategy { +public class OpenSshAgentStrategy implements SshIdentityAgentStrategy { @SuppressWarnings("unused") public static OptionsBuilder createOptions( @@ -39,16 +39,12 @@ public class OpenSshAgentStrategy implements SshIdentityStrategy { .nameAndDescription("agentSocket") .addStaticString(socket != null ? socket : AppI18n.get("agentSocketNotFound")) .hide(OsType.ofLocal() == OsType.WINDOWS) + .nameAndDescription("publicKey") + .addComp(new SshAgentKeyListComp(config.getFileSystem(), p, publicKey, false), publicKey) .nameAndDescription("forwardAgent") .addToggle(forward) .nonNull() .hide(!config.isAllowAgentForward()) - .nameAndDescription("publicKey") - .addComp( - new TextFieldComp(publicKey) - .apply(struc -> struc.setPromptText( - "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIBmhLUTJiP...== Your Comment")), - publicKey) .bind( () -> { return new OpenSshAgentStrategy(forward.get(), publicKey.get()); @@ -68,9 +64,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,13 +72,16 @@ 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); @@ -94,11 +91,15 @@ public class OpenSshAgentStrategy implements SshIdentityStrategy { 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 78% 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..a8b3203bc 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,9 +1,9 @@ -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; @@ -21,7 +21,7 @@ import java.util.List; @Value @Jacksonized @Builder -public class OtherExternalAgentStrategy implements SshIdentityStrategy { +public class OtherExternalAgentStrategy implements SshIdentityAgentStrategy { @SuppressWarnings("unused") public static OptionsBuilder createOptions( @@ -31,16 +31,13 @@ public class OtherExternalAgentStrategy implements SshIdentityStrategy { var publicKey = new SimpleStringProperty(p.getValue() != null ? p.getValue().getPublicKey() : null); return new OptionsBuilder() + .nameAndDescription("publicKey") + .addComp(new SshAgentKeyListComp(config.getFileSystem(), p, publicKey, false), publicKey) .nameAndDescription("forwardAgent") .addToggle(forward) .nonNull() .hide(!config.isAllowAgentForward()) .nameAndDescription("publicKey") - .addComp( - new TextFieldComp(publicKey) - .apply(struc -> struc.setPromptText( - "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIBmhLUTJiP...== Your Comment")), - publicKey) .bind( () -> { return new OtherExternalAgentStrategy(forward.get(), publicKey.get()); @@ -54,10 +51,15 @@ public class OtherExternalAgentStrategy implements SshIdentityStrategy { @Override public void prepareParent(ShellControl parent) throws Exception { if (parent.isLocal()) { - SshIdentityStateManager.prepareLocalExternalAgent(); + SshIdentityStateManager.prepareLocalExternalAgent(null); } } + @Override + public FilePath determinetAgentSocketLocation(ShellControl parent) throws Exception { + return null; + } + @Override public void buildCommand(CommandBuilder builder) {} @@ -70,4 +72,8 @@ public class OtherExternalAgentStrategy implements SshIdentityStrategy { 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 84% 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..5135e0e51 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,6 +1,5 @@ -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; @@ -8,6 +7,8 @@ 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; @@ -30,7 +31,7 @@ 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) { @@ -39,16 +40,12 @@ public class PageantStrategy implements SshIdentityStrategy { var publicKey = new SimpleStringProperty(p.getValue() != null ? p.getValue().getPublicKey() : null); return new OptionsBuilder() + .nameAndDescription("publicKey") + .addComp(new SshAgentKeyListComp(config.getFileSystem(), p, publicKey, false), publicKey) .nameAndDescription("forwardAgent") .addToggle(forward) .nonNull() .hide(!config.isAllowAgentForward()) - .nameAndDescription("publicKey") - .addComp( - new TextFieldComp(publicKey) - .apply(struc -> struc.setPromptText( - "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIBmhLUTJiP...== Your Comment")), - publicKey) .bind( () -> { return new PageantStrategy(forward.get(), publicKey.get()); @@ -67,7 +64,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); @@ -92,20 +89,24 @@ 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) throws Exception { 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); @@ -115,7 +116,7 @@ public class PageantStrategy implements SshIdentityStrategy { 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 + "\"")); } @@ -136,4 +137,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..dd9571c9c --- /dev/null +++ b/app/src/main/java/io/xpipe/app/cred/PasswordManagerAgentStrategy.java @@ -0,0 +1,156 @@ +package io.xpipe.app.cred; + +import com.fasterxml.jackson.annotation.JsonTypeName; +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.platform.Validator; +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.ReadOnlyBooleanWrapper; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +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 pwmanBinding = Bindings.createObjectBinding(() -> { + var pwman = AppPrefs.get().passwordManager().getValue(); + if (pwman == null) { + return AppI18n.get("passwordManagerEmpty"); + } + + if (!pwman.getKeyConfiguration().useAgent()) { + return AppI18n.get("passwordManagerNoAgentSupport"); + } + + return null; + }, AppPrefs.get().passwordManager(), AppI18n.activeLanguage()); + var pwmanProp = new SimpleStringProperty(); + pwmanProp.bind(pwmanBinding); + var pwmanDisplay = new HorizontalComp(List.of( + new LabelComp(pwmanProp) + .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(pwmanProp.isNull()) + .nameAndDescription(useKeyName() ? "agentKeyName" : "publicKey") + .addComp(new SshAgentKeyListComp(config.getFileSystem(), p, identifier, useKeyName()), identifier) + .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) throws Exception { + 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/PublicKeyStrategy.java b/app/src/main/java/io/xpipe/app/cred/PublicKeyStrategy.java new file mode 100644 index 000000000..7cfd5922d --- /dev/null +++ b/app/src/main/java/io/xpipe/app/cred/PublicKeyStrategy.java @@ -0,0 +1,78 @@ +package io.xpipe.app.cred; + +import io.xpipe.core.FailableSupplier; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.util.Optional; + +public interface PublicKeyStrategy { + + Optional getFixedPublicKey(); + + 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; + } + + @Override + public 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 Optional getFixedPublicKey() { + return Optional.empty(); + } + + @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..f603b211d --- /dev/null +++ b/app/src/main/java/io/xpipe/app/cred/SshAgentKeyList.java @@ -0,0 +1,73 @@ +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.DataStorage; +import io.xpipe.app.storage.DataStoreEntryRef; +import lombok.Value; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +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 list = listAgentIdentities(ref, strategy).stream().filter(entry -> { + return (entry.getName() != null && entry.getName().equalsIgnoreCase(identifier)) || entry.getPublicKey().equalsIgnoreCase(identifier); + }).toList(); + + if (list.isEmpty()) { + throw ErrorEventFactory.expected(new IllegalArgumentException("No such agent identity: " + identifier)); + } + + if (list.size() > 1) { + throw ErrorEventFactory.expected(new IllegalArgumentException("Ambiguous agent identities: " + list.stream() + .map(entry -> entry.getName() != null ? entry.getName() : entry.getPublicKey()) + .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("([^ ]+) ([^ ]+)(?: (.+))?"); + 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() > 3 ? 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..85e5249c4 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/cred/SshAgentKeyListComp.java @@ -0,0 +1,99 @@ +package io.xpipe.app.cred; + +import atlantafx.base.controls.Popover; +import atlantafx.base.theme.Styles; +import io.xpipe.app.comp.SimpleRegionBuilder; +import io.xpipe.app.comp.base.ButtonComp; +import io.xpipe.app.comp.base.IconButtonComp; +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.Property; +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 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.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.toString()); + popover.hide(); + e.consume(); + }); + entryButton.setMinWidth(400); + entryButton.setAlignment(Pos.CENTER_LEFT); + 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..9137bc162 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/cred/SshAgentTestComp.java @@ -0,0 +1,79 @@ +package io.xpipe.app.cred; + +import atlantafx.base.controls.Popover; +import atlantafx.base.theme.Styles; +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 org.kordamp.ikonli.javafx.FontIcon; + +import java.util.List; + +public class SshAgentTestComp extends SimpleRegionBuilder { + + private final ObservableValue sshIdentityStrategy; + + public SshAgentTestComp(ObservableValue sshIdentityStrategy) {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(() -> { + 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); + 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..7aa992736 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/cred/SshIdentityAgentStrategy.java @@ -0,0 +1,24 @@ +package io.xpipe.app.cred; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.xpipe.app.ext.ValidationException; +import io.xpipe.app.issue.ErrorEventFactory; +import io.xpipe.app.process.CommandBuilder; +import io.xpipe.app.process.OsFileSystem; +import io.xpipe.app.process.ShellControl; +import io.xpipe.app.secret.SecretNoneStrategy; +import io.xpipe.app.secret.SecretRetrievalStrategy; +import io.xpipe.core.FilePath; +import io.xpipe.core.KeyValue; +import io.xpipe.core.OsType; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +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 96% 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..4b09a4ec3 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,4 +1,4 @@ -package io.xpipe.ext.base.identity.ssh; +package io.xpipe.app.cred; import io.xpipe.app.issue.ErrorAction; import io.xpipe.app.issue.ErrorEvent; @@ -136,7 +136,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 +149,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..d9c51256b 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> 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); if (OsType.ofLocal() != OsType.WINDOWS) { l.add(CustomAgentStrategy.class); } @@ -97,5 +85,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/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/NetworkContainerStoreState.java b/app/src/main/java/io/xpipe/app/ext/NetworkContainerStoreState.java new file mode 100644 index 000000000..b9cdc44c3 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/ext/NetworkContainerStoreState.java @@ -0,0 +1,34 @@ +package io.xpipe.app.ext; + +import io.xpipe.app.process.ShellStoreState; +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 d9d3da1fc..80fae85d9 100644 --- a/app/src/main/java/io/xpipe/app/ext/ProcessControlProvider.java +++ b/app/src/main/java/io/xpipe/app/ext/ProcessControlProvider.java @@ -58,6 +58,8 @@ public abstract class ProcessControlProvider { public abstract ShellDialect getEffectiveLocalDialect(); + public abstract String executeMcpCommand(ShellControl sc, String command) throws Exception; + public ShellDialect getNextFallbackDialect() { var av = getAvailableLocalDialects(); var index = av.indexOf(getEffectiveLocalDialect()); @@ -76,4 +78,7 @@ public abstract class ProcessControlProvider { public abstract void cloneRepository(String url, Path target) throws Exception; public abstract void pullRepository(Path target) throws Exception; + + public abstract DataStore quickConnectStore(String user, String host, Integer port, DataStore existing); + } 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..b49e5ebbc 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,6 @@ 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 Trigger renameTrigger = Trigger.of(); private StoreCategoryWrapper cachedParent; @@ -191,12 +190,6 @@ 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(); 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..c99ecc85f 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 @@ -58,8 +58,8 @@ public class StoreCreationComp extends ModalOverlayContentComp { var provider = model.getProvider().getValue() != null ? model.getProvider().getValue() : providerChoice.getProviders().getFirst(); - var showProviders = (!model.isStaticDisplay() && provider.showProviderChoice()) - || (model.isStaticDisplay() && provider.showProviderChoice()); + var showProviders = !model.isQuickConnect() && ((!model.isStaticDisplay() && provider.showProviderChoice()) + || (model.isStaticDisplay() && provider.showProviderChoice())); if (model.isStaticDisplay()) { providerChoice.apply(struc -> struc.setDisable(true)); } @@ -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..c693830b2 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,23 @@ 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 +54,12 @@ 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 +76,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 +88,7 @@ public class StoreCreationDialog { true); } - public static void showCreation( + public static StoreCreationModel showCreation( String name, DataStore base, DataStoreCreationCategory category, @@ -118,18 +122,19 @@ 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 +145,7 @@ public class StoreCreationDialog { var ex = StoreCreationQueueEntry.findExisting(existingEntry); if (ex.isPresent()) { ex.get().execute(); - return; + return null; } var prop = new SimpleObjectProperty<>(provider); @@ -148,12 +153,13 @@ 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 -> { @@ -205,21 +211,18 @@ 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 +242,7 @@ 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..f2fda216f 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 @@ -153,7 +153,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 +167,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 e978a471d..41db7608b 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 @@ -167,6 +167,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 +215,7 @@ public class StoreCreationModel { } // We didn't change anything - if (store.getValue().isComplete() && !wasChanged()) { + if (store.getValue().isComplete() && !wasChanged() && !isQuickConnect()) { commit(false); return; } 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 38bdcb450..824be633a 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 @@ -1,6 +1,7 @@ package io.xpipe.app.hub.comp; import io.xpipe.app.comp.SimpleRegionBuilder; +import io.xpipe.app.storage.DataStoreEntry; import io.xpipe.app.util.BooleanScope; import javafx.application.Platform; @@ -23,6 +24,7 @@ public class StoreEntryBatchSelectComp extends SimpleRegionBuilder { protected Region createSimple() { var selfUpdate = new SimpleBooleanProperty(false); var cb = new CheckBox(); + externalUpdate(cb); cb.setAllowIndeterminate(true); cb.selectedProperty().addListener((observable, oldValue, newValue) -> { BooleanScope.executeExclusive(selfUpdate, () -> { @@ -64,6 +66,13 @@ public class StoreEntryBatchSelectComp extends SimpleRegionBuilder { } private void externalUpdate(CheckBox checkBox) { + if (section.getWrapper() != null && section.getWrapper().getEntry().getValidity() == DataStoreEntry.Validity.LOAD_FAILED) { + checkBox.setSelected(false); + checkBox.setIndeterminate(false); + checkBox.setDisable(true); + return; + } + var isSelected = section.getWrapper() == null ? checkBox.isSelected() : StoreViewState.get().isBatchModeSelected(section.getWrapper()); 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 1c63cc46c..5e8b720af 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 @@ -459,10 +459,10 @@ public abstract class StoreEntryComp extends SimpleRegionBuilder { var notes = new MenuItem(AppI18n.get("addNotes"), new FontIcon("mdi2c-comment-text-outline")); notes.setOnAction(event -> { - getWrapper().getNotes().setValue(new StoreNotes(null, getDefaultNotes())); + StoreNotesComp.showDialog(getWrapper(), getDefaultNotes()); event.consume(); }); - notes.visibleProperty().bind(BindingsHelper.map(getWrapper().getNotes(), s -> s.getCommited() == null)); + notes.visibleProperty().bind(BindingsHelper.map(getWrapper().getNotes(), s -> s == null)); items.add(items.size(), notes); var freeze = new MenuItem(); 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..a5750070c 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; @@ -14,7 +13,9 @@ import io.xpipe.app.platform.MenuHelper; import io.xpipe.app.util.ObservableSubscriber; import io.xpipe.core.OsType; +import javafx.application.Platform; import javafx.beans.binding.Bindings; +import javafx.beans.property.SimpleStringProperty; import javafx.css.PseudoClass; import javafx.geometry.Insets; import javafx.geometry.Orientation; @@ -40,7 +41,7 @@ public class StoreEntryListOverviewComp extends SimpleRegionBuilder { this.filterTrigger = filterTrigger; } - private Region createGroupListHeader() { + private Region createHeaderBar() { var label = new Label(); var name = BindingsHelper.flatMap( StoreViewState.get().getActiveCategory(), @@ -94,17 +95,10 @@ 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()); @@ -112,9 +106,25 @@ public class StoreEntryListOverviewComp extends SimpleRegionBuilder { batchMode.prefWidthProperty().bind(add.heightProperty()); batchMode.maxWidthProperty().bind(add.heightProperty()); hbox.setSpacing(8); + hbox.setAlignment(Pos.CENTER_LEFT); + return hbox; + } + + + private Region createFilterBar(Region addBar) { + var filter = new StoreFilterComp().build(); + filterTrigger.subscribe(() -> { + filter.requestFocus(); + }); + + filter.minHeightProperty().bind(addBar.heightProperty()); + filter.prefHeightProperty().bind(addBar.heightProperty()); + filter.maxHeightProperty().bind(addBar.heightProperty()); + + var hbox = new HBox(filter); + hbox.setSpacing(8); hbox.setAlignment(Pos.CENTER); HBox.setHgrow(filter, Priority.ALWAYS); - filter.getStyleClass().add("filter-bar"); return hbox; } @@ -267,7 +277,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, createFilterBar(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 61f6df75a..9d729e073 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 @@ -58,7 +58,7 @@ public class StoreEntryWrapper { private final Property color = new SimpleObjectProperty<>(); private final Property category = new SimpleObjectProperty<>(); private final Property summary = new SimpleObjectProperty<>(); - private final Property notes; + private final ObjectProperty notes; private final Property customIcon = new SimpleObjectProperty<>(); private final Property iconFile = new SimpleObjectProperty<>(); private final BooleanProperty sessionActive = new SimpleBooleanProperty(); @@ -69,7 +69,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(); @@ -117,7 +116,7 @@ public class StoreEntryWrapper { shownSummary, AppI18n.activeLanguage()); this.shownInformation = new SimpleObjectProperty<>(); - this.notes = new SimpleObjectProperty<>(new StoreNotes(entry.getNotes(), entry.getNotes())); + this.notes = new SimpleObjectProperty<>(entry.getNotes()); setupListeners(); } @@ -158,12 +157,6 @@ public class StoreEntryWrapper { entry.addListener(() -> PlatformThread.runLaterIfNeeded(() -> { update(); })); - - notes.addListener((observable, oldValue, newValue) -> { - if (newValue.isCommited()) { - entry.setNotes(newValue.getCurrent()); - } - }); } public void stopSession() { @@ -211,7 +204,7 @@ public class StoreEntryWrapper { } orderIndex.setValue(entry.getOrderIndex()); color.setValue(entry.getColor()); - notes.setValue(new StoreNotes(entry.getNotes(), entry.getNotes())); + notes.setValue(entry.getNotes()); customIcon.setValue(entry.getIcon()); readOnly.setValue(entry.isFreeze()); iconFile.setValue(entry.getEffectiveIconFile()); @@ -226,8 +219,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()); diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreFilterComp.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreFilterComp.java new file mode 100644 index 000000000..05beb9143 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreFilterComp.java @@ -0,0 +1,92 @@ +package io.xpipe.app.hub.comp; + +import atlantafx.base.controls.CustomTextField; +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.platform.PlatformThread; +import javafx.application.Platform; +import javafx.beans.binding.Bindings; +import javafx.beans.property.Property; +import javafx.beans.property.SimpleStringProperty; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyCodeCombination; +import javafx.scene.input.KeyEvent; +import org.kordamp.ikonli.javafx.FontIcon; + +import java.util.List; +import java.util.Objects; + +public class StoreFilterComp extends RegionBuilder { + + private final Property rawText = new SimpleStringProperty(); + + private boolean isQuickConnect() { + var v = rawText.getValue(); + return v != null && (v.startsWith("ssh") || "ssh".startsWith(v)); + } + + private boolean isSearch() { + return rawText.getValue() != null && rawText.getValue().length() > 1 && !isQuickConnect(); + } + + @Override + public CustomTextField createSimple() { + var searchIcon = new FontIcon("mdi2m-magnify"); + var launchIcon = new FontIcon("mdi2p-play"); + var filter = new CustomTextField(); + filter.setMinHeight(0); + filter.setMaxHeight(20000); + filter.getStyleClass().add("filter-comp"); + filter.promptTextProperty().bind(AppI18n.observable("storeFilterPrompt")); + filter.rightProperty() + .bind(Bindings.createObjectBinding( + () -> { + return filter.isFocused() ? (isQuickConnect() ? launchIcon : isSearch() ? searchIcon : null) : null; + }, + filter.focusedProperty(), rawText)); + RegionDescriptor.builder().nameKey("search").build().apply(filter); + + filter.addEventFilter(KeyEvent.KEY_PRESSED, event -> { + if (new KeyCodeCombination(KeyCode.ESCAPE).match(event)) { + filter.clear(); + event.consume(); + } else if (isQuickConnect() && new KeyCodeCombination(KeyCode.ENTER).match(event)) { + if (StoreQuickConnect.launchQuickConnect(filter.getText())) { + filter.clear(); + event.consume(); + } + } + }); + + rawText.subscribe(val -> { + PlatformThread.runLaterIfNeeded(() -> { + if (!Objects.equals(filter.getText(), val) && !(val == null && "".equals(filter.getText()))) { + filter.setText(val); + } + + Platform.runLater(() -> { + if (val == null || isSearch()) { + StoreViewState.get().getFilterString().setValue(val); + } else { + StoreViewState.get().getFilterString().setValue(null); + } + }); + }); + }); + + filter.textProperty().addListener((observable, oldValue, n) -> { + // Handle pasted xpipe URLs + if (n != null && n.startsWith("xpipe://")) { + AppOpenArguments.handle(List.of(n)); + filter.setText(null); + return; + } + + rawText.setValue(n != null && n.length() > 0 ? n : null); + }); + + return filter; + } +} 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 ab9133ac7..4e8ce4f30 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,6 +8,7 @@ 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; @@ -22,8 +23,11 @@ import javafx.scene.text.TextAlignment; import atlantafx.base.theme.Styles; import atlantafx.base.theme.Tweaks; +import lombok.Getter; import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; import static atlantafx.base.theme.Styles.TEXT_SMALL; @@ -35,6 +39,9 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp { private final int columns; private final SimpleStringProperty filter; private final Runnable doubleClick; + private final DataStoreEntry entry; + + @Getter private final BooleanProperty busy = new SimpleBooleanProperty(); public StoreIconChoiceComp( @@ -43,13 +50,15 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp { Set icons, int columns, SimpleStringProperty filter, - Runnable doubleClick) { + Runnable doubleClick, DataStoreEntry entry + ) { this.reshow = reshow; this.selected = selected; this.icons = icons; this.columns = columns; this.filter = filter; this.doubleClick = doubleClick; + this.entry = entry; } @Override @@ -58,7 +67,6 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp { if (modalOverlay != null) { ThreadHelper.runFailableAsync(() -> { BooleanScope.executeExclusive(busy, () -> { - SystemIconManager.reloadSourceHashes(); SystemIconManager.loadAllAvailableIconImages(); }); }); @@ -68,7 +76,7 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp { @Override protected Region createSimple() { var table = new TableView>(); - table.disableProperty().bind(PlatformThread.sync(busy)); + table.visibleProperty().bind(PlatformThread.sync(busy.not())); initTable(table); filter.addListener((observable, oldValue, newValue) -> updateData(table, newValue)); busy.addListener((observable, oldValue, newValue) -> { @@ -77,17 +85,22 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp { } }); - var loading = new LoadingIconComp(busy, AppFontSizes::title) - .prefWidth(60) - .hide(busy.not()) - .build(); - var refresh = createRefreshPane(); + var loading = createLoadingPane(); var stack = new StackPane(); - stack.getChildren().addAll(table, refresh, loading); + stack.getChildren().addAll(table, loading); return stack; } + public void refresh() { + ThreadHelper.runFailableAsync(() -> { + BooleanScope.executeExclusive(busy, () -> { + SystemIconManager.rebuild(); + reshow.run(); + }); + }); + } + private void initTable(TableView> table) { if (!SystemIconManager.isCacheOutdated()) { for (int i = 0; i < columns; i++) { @@ -108,22 +121,28 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp { 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 createRefreshPane() { - var refreshing = new SimpleBooleanProperty(false); + private Region createLoadingPane() { var refreshButton = new ButtonComp( AppI18n.observable("refreshIcons"), new SimpleObjectProperty<>(new LabelGraphic.IconGraphic("mdi2r-refresh")), () -> { ThreadHelper.runFailableAsync(() -> { - try (var ignored = new BooleanScope(refreshing).start()) { + BooleanScope.executeExclusive(busy, () -> { SystemIconManager.rebuild(); - } + }); reshow.run(); }); }); - refreshButton.disable(refreshing); + refreshButton.hide(Bindings.createBooleanBinding( + () -> { + return SystemIconManager.hasLoadedAnyImages(); + }, + busy)); + refreshButton.disable(busy); + var text = new LabelComp(AppI18n.observable("refreshIconsDescription")); text.apply(struc -> { struc.setWrapText(true); @@ -131,28 +150,17 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp { struc.setPrefWidth(300); }); text.style(Styles.TEXT_SUBTLE); - text.visible(refreshing); - var vbox = new VerticalComp(List.of(refreshButton, text)).spacing(25); - vbox.hide(Bindings.createBooleanBinding( - () -> { - if (busy.get()) { - return true; - } + text.visible(busy); - var available = icons.stream() - .filter(systemIcon -> AppImages.hasImage( - "icons/" + systemIcon.getSource().getId() + "/" + systemIcon.getId() + "-40.png")) - .sorted(Comparator.comparing(systemIcon -> systemIcon.getId())) - .toList(); - if (available.isEmpty()) { - return false; - } + var loading = new LoadingIconComp(busy, AppFontSizes::title); + loading.prefWidth(50); + loading.prefHeight(50); - return true; - }, - busy, - refreshing)); - vbox.apply(struc -> struc.setAlignment(Pos.CENTER)); + var vbox = new VerticalComp(List.of(text, loading, refreshButton)).spacing(25); + vbox.apply(struc -> { + struc.setAlignment(Pos.CENTER); + struc.setPickOnBounds(false); + }); return vbox.build(); } @@ -166,24 +174,27 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp { .filter(systemIcon -> AppImages.hasImage( "icons/" + systemIcon.getSource().getId() + "/" + systemIcon.getId() + "-40.png")) .sorted(Comparator.comparing(systemIcon -> systemIcon.getId())) - .toList(); - var filtered = available; - if (filterString != null && !filterString.isBlank() && filterString.length() >= 2) { - filtered = available.stream() - .filter(icon -> containsString(icon.getId(), filterString)) - .toList(); + .collect(Collectors.toCollection(ArrayList::new)); + available.addFirst(new SystemIcon(null, "default")); + + List shown; + if (filterString != null && !filterString.isBlank() && filterString.strip().length() >= 2) { + shown = available.stream() + .filter(icon -> containsString(icon.getId(), filterString.strip())) + .collect(Collectors.toCollection(ArrayList::new)); + } else { + shown = new ArrayList<>(available); } - var data = partitionList(filtered, columns); + + var data = partitionList(shown, columns); table.getItems().setAll(data); - var selectMatch = filtered.size() == 1 - || filtered.stream().anyMatch(systemIcon -> systemIcon.getId().equals(filterString)); + var selectMatch = shown.size() == 1 + || shown.stream().anyMatch(systemIcon -> systemIcon.getId().equals(filterString)); // Table updates seem to not always be instant, sometimes the column is not there yet if (selectMatch && table.getColumns().size() > 0) { table.getSelectionModel().select(0, table.getColumns().getFirst()); - selected.setValue(filtered.getFirst()); - } else { - selected.setValue(null); + selected.setValue(shown.getFirst()); } } @@ -242,6 +253,13 @@ public class StoreIconChoiceComp extends ModalOverlayContentComp { return; } + if (icon.getSource() == null) { + root.setText(AppI18n.get("default")); + image.setValue(entry.getProvider().getDisplayIconFileName(entry.getStore())); + setGraphic(root); + return; + } + root.setText(icon.getId()); image.set(SystemIconManager.getAndLoadIconFile(icon)); 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 87fb57ac4..f98012d72 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 @@ -3,10 +3,12 @@ package io.xpipe.app.hub.comp; import io.xpipe.app.comp.base.*; 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.DataStoreEntry; import javafx.application.Platform; +import javafx.beans.binding.Bindings; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; @@ -47,49 +49,52 @@ public class StoreIconChoiceDialog { } }); }); + + var comp = new StoreIconChoiceComp( + () -> { + var showing = overlay.isShowing(); + overlay.close(); + if (showing) { + Platform.runLater(() -> overlay.show()); + } + }, + selected, + SystemIconManager.getIcons(), + 5, + filterText, + () -> { + finish(); + }, + entry); + comp.prefWidth(600); + + var modal = ModalOverlay.of( + "chooseCustomIcon", + comp); + var refresh = new ButtonComp(null, new FontIcon("mdi2r-refresh"), () -> { + comp.refresh(); + }).maxHeight(100).disable(comp.getBusy()); var settings = new ButtonComp(null, new FontIcon("mdomz-settings"), () -> { overlay.close(); AppPrefs.get().selectCategory("icons"); }) + .disable(comp.getBusy()) .maxHeight(100); - var modal = ModalOverlay.of( - "chooseCustomIcon", - new StoreIconChoiceComp( - () -> { - var showing = overlay.isShowing(); - overlay.close(); - if (showing) { - Platform.runLater(() -> overlay.show()); - } - }, - selected, - SystemIconManager.getIcons(), - 5, - filterText, - () -> { - finish(); - }) - .prefWidth(600)); modal.addButtonBarComp(settings); + modal.addButtonBarComp(refresh); modal.addButtonBarComp(filter); - modal.addButton(new ModalButton( - "clear", - () -> { - selected.setValue(null); - finish(); - }, - true, - false)); modal.addButton(ModalButton.ok(() -> { finish(); })) - .augment(button -> button.disableProperty().bind(selected.isNull())); + .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.get() != null && selected.getValue().getSource() != null ? selected.getValue().getSource().getId() + "/" + selected.getValue().getId() : null, 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..40c92f5bd 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 @@ -4,6 +4,7 @@ import io.xpipe.app.comp.BaseRegionBuilder; import io.xpipe.app.comp.RegionBuilder; import io.xpipe.app.comp.SimpleRegionBuilder; import io.xpipe.app.comp.base.*; +import io.xpipe.app.core.AppI18n; import io.xpipe.app.ext.DataStore; import io.xpipe.app.storage.DataStoreEntryRef; @@ -48,7 +49,7 @@ public class StoreListChoiceComp extends SimpleRegionBuilde var listBox = new ListBoxViewComp<>( selectedList, selectedList, - t -> { + t -> { if (t == null) { return null; } diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreNotes.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreNotes.java deleted file mode 100644 index e14efe7da..000000000 --- a/app/src/main/java/io/xpipe/app/hub/comp/StoreNotes.java +++ /dev/null @@ -1,16 +0,0 @@ -package io.xpipe.app.hub.comp; - -import lombok.Value; - -import java.util.Objects; - -@Value -public class StoreNotes { - - String commited; - String current; - - public boolean isCommited() { - return Objects.equals(commited, current); - } -} 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 c0a42703f..cca7dcd4e 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 @@ -1,30 +1,43 @@ package io.xpipe.app.hub.comp; import io.xpipe.app.comp.*; -import io.xpipe.app.comp.base.ButtonComp; -import io.xpipe.app.comp.base.DialogComp; -import io.xpipe.app.comp.base.IconButtonComp; -import io.xpipe.app.comp.base.MarkdownEditorComp; +import io.xpipe.app.comp.base.*; import io.xpipe.app.core.AppFontSizes; -import io.xpipe.app.core.AppI18n; import io.xpipe.app.platform.BindingsHelper; -import io.xpipe.app.prefs.AppPrefs; 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.event.ActionEvent; import javafx.scene.control.Button; -import javafx.scene.paint.Color; +import javafx.scene.input.MouseButton; -import atlantafx.base.controls.Popover; -import org.int4.fx.builders.common.AbstractRegionBuilder; +import java.util.UUID; -import java.util.List; -import java.util.concurrent.atomic.AtomicReference; +public class StoreNotesComp extends RegionBuilder