diff --git a/app/src/main/java/io/xpipe/app/prefs/AppPrefs.java b/app/src/main/java/io/xpipe/app/prefs/AppPrefs.java index a4d79c834..c5fe29b76 100644 --- a/app/src/main/java/io/xpipe/app/prefs/AppPrefs.java +++ b/app/src/main/java/io/xpipe/app/prefs/AppPrefs.java @@ -313,7 +313,6 @@ public class AppPrefs { DataStorage.get().forceRewrite(); } }); - INSTANCE.terminalProxy.setValue(UUID.fromString("08438e45-1d9f-4ce6-bbd7-cf47514d15f1")); } public static void setLocalDefaultsIfNeeded() { diff --git a/app/src/main/java/io/xpipe/app/prefs/TerminalCategory.java b/app/src/main/java/io/xpipe/app/prefs/TerminalCategory.java index 5e7e3f8a4..0ccc93733 100644 --- a/app/src/main/java/io/xpipe/app/prefs/TerminalCategory.java +++ b/app/src/main/java/io/xpipe/app/prefs/TerminalCategory.java @@ -1,25 +1,35 @@ package io.xpipe.app.prefs; import io.xpipe.app.comp.Comp; -import io.xpipe.app.comp.base.ButtonComp; -import io.xpipe.app.comp.base.ChoiceComp; -import io.xpipe.app.comp.base.HorizontalComp; -import io.xpipe.app.comp.base.StackComp; -import io.xpipe.app.comp.base.TextFieldComp; +import io.xpipe.app.comp.base.*; +import io.xpipe.app.comp.store.StoreChoiceComp; +import io.xpipe.app.comp.store.StoreViewState; import io.xpipe.app.core.AppI18n; import io.xpipe.app.core.AppProperties; import io.xpipe.app.ext.PrefsChoiceValue; import io.xpipe.app.ext.ProcessControlProvider; +import io.xpipe.app.ext.ShellStore; import io.xpipe.app.issue.ErrorEvent; +import io.xpipe.app.password.PasswordManager; +import io.xpipe.app.storage.DataStorage; +import io.xpipe.app.storage.DataStoreEntryRef; import io.xpipe.app.terminal.ExternalTerminalType; import io.xpipe.app.terminal.TerminalLauncher; +import io.xpipe.app.terminal.TerminalMultiplexer; +import io.xpipe.app.terminal.TerminalProxyManager; import io.xpipe.app.util.*; +import io.xpipe.core.process.OsType; import javafx.application.Platform; import javafx.beans.binding.Bindings; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.ListCell; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; import javafx.scene.paint.Color; import org.kordamp.ikonli.javafx.FontIcon; @@ -79,8 +89,14 @@ public class TerminalCategory extends AppPrefsCategory { .apply(struc -> struc.get().setPromptText("myterminal -e $CMD")) .hide(prefs.terminalType.isNotEqualTo(ExternalTerminalType.CUSTOM))) .addComp(terminalTest) - .pref(prefs.clearTerminalOnInit) - .addToggle(prefs.clearTerminalOnInit)) + ) + .sub(terminalProxy()) + .sub(terminalMultiplexer()) + .sub(terminalInitScript()) + .sub(new OptionsBuilder() + .pref(prefs.clearTerminalOnInit) + .addToggle(prefs.clearTerminalOnInit) + ) .addTitle("sessionLogging") .sub(new OptionsBuilder() .pref(prefs.enableTerminalLogging) @@ -152,4 +168,64 @@ public class TerminalCategory extends AppPrefsCategory { struc.get().setSpacing(10); }); } + + private OptionsBuilder terminalProxy() { + var prefs = AppPrefs.get(); + var ref = new SimpleObjectProperty>(prefs.terminalProxy().getValue() != null ? + DataStorage.get().getStoreEntryIfPresent(prefs.terminalProxy().getValue()).orElse(DataStorage.get().local()).ref() : + DataStorage.get().local().ref()); + ref.addListener((observable, oldValue, newValue) -> { + prefs.terminalProxy.setValue(newValue != null ? newValue.get().getUuid() : null); + }); + return new OptionsBuilder() + .nameAndDescription("terminalEnvironment") + .addComp(new StoreChoiceComp<>(StoreChoiceComp.Mode.PROXY, null, ref, ShellStore.class, + r -> TerminalProxyManager.canUseAsProxy(r), StoreViewState.get().getAllConnectionsCategory()), ref) + .hide(OsType.getLocal() != OsType.WINDOWS); + } + + + private OptionsBuilder terminalInitScript() { + var prefs = AppPrefs.get(); + var ref = new SimpleObjectProperty>(); + prefs.terminalProxy().subscribe(uuid -> { + ref.set(uuid != null ? + DataStorage.get().getStoreEntryIfPresent(uuid).orElse(DataStorage.get().local()).ref() : + DataStorage.get().local().ref()); + }); + var script = new SimpleObjectProperty<>(prefs.terminalInitScript().getValue()); + return new OptionsBuilder() + .nameAndDescription("terminalInitScript") + .addComp(IntegratedTextAreaComp.script(ref, script).minHeight(150), script); + } + + private OptionsBuilder terminalMultiplexer() { + var prefs = AppPrefs.get(); + var choiceBuilder = OptionsChoiceBuilder.builder() + .property(prefs.terminalMultiplexer) + .allowNull(true) + .subclasses(TerminalMultiplexer.getClasses()) + .transformer(entryComboBox -> { + var websiteLinkButton = + new ButtonComp(AppI18n.observable("website"), new FontIcon("mdi2w-web"), () -> { + var l = prefs.terminalMultiplexer().getValue().getDocsLink(); + if (l != null) { + Hyperlinks.open(l); + } + }); + websiteLinkButton.minWidth(Region.USE_PREF_SIZE); + websiteLinkButton.disable(Bindings.createBooleanBinding(() -> { + return prefs.terminalMultiplexer.getValue().getDocsLink() == null; + }, prefs.terminalMultiplexer)); + + var hbox = new HBox(entryComboBox, websiteLinkButton.createRegion()); + HBox.setHgrow(entryComboBox, Priority.ALWAYS); + hbox.setSpacing(10); + return hbox; + }).build(); + var choice = choiceBuilder.build().buildComp(); + return new OptionsBuilder() + .nameAndDescription("terminalMultiplexer") + .addComp(choice); + } } diff --git a/app/src/main/java/io/xpipe/app/terminal/TerminalLaunchConfiguration.java b/app/src/main/java/io/xpipe/app/terminal/TerminalLaunchConfiguration.java index 3e4d57e14..9b2373803 100644 --- a/app/src/main/java/io/xpipe/app/terminal/TerminalLaunchConfiguration.java +++ b/app/src/main/java/io/xpipe/app/terminal/TerminalLaunchConfiguration.java @@ -27,13 +27,13 @@ import java.time.format.DateTimeFormatter; import java.util.UUID; @Value +@With public class TerminalLaunchConfiguration { DataColor color; String coloredTitle; String cleanTitle; boolean preferTabs; - @With FilePath scriptFile; ShellDialect scriptDialect; diff --git a/app/src/main/java/io/xpipe/app/terminal/TerminalLaunchRequest.java b/app/src/main/java/io/xpipe/app/terminal/TerminalLaunchRequest.java index 240322622..cc493b394 100644 --- a/app/src/main/java/io/xpipe/app/terminal/TerminalLaunchRequest.java +++ b/app/src/main/java/io/xpipe/app/terminal/TerminalLaunchRequest.java @@ -1,6 +1,5 @@ package io.xpipe.app.terminal; -import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.util.ScriptHelper; import io.xpipe.app.util.ThreadHelper; import io.xpipe.beacon.BeaconServerException; @@ -95,7 +94,7 @@ public class TerminalLaunchRequest { }; try { - var command = TerminalLauncher.launchMultiplexer(processControl, config, wd); + var command = TerminalLauncher.createLaunchCommand(processControl, config, wd); var file = ScriptHelper.createLocalExecScript(command); setResult(new TerminalLaunchResult.ResultSuccess(file.asLocalPath())); } catch (Exception e) { diff --git a/app/src/main/java/io/xpipe/app/terminal/TerminalLauncher.java b/app/src/main/java/io/xpipe/app/terminal/TerminalLauncher.java index 5d4dda89f..789b4c330 100644 --- a/app/src/main/java/io/xpipe/app/terminal/TerminalLauncher.java +++ b/app/src/main/java/io/xpipe/app/terminal/TerminalLauncher.java @@ -129,6 +129,9 @@ public class TerminalLauncher { var latch = TerminalLauncherManager.submitAsync(request, cc, terminalConfig, directory); try { if (!checkMultiplexerLaunch(request, config)) { + if (preferTabs && shouldUseMultiplexer()) { + config = config.withPreferTabs(false).withCleanTitle("XPipe").withColoredTitle("XPipe"); + } type.launch(config); } latch.await(); @@ -141,7 +144,25 @@ public class TerminalLauncher { } } + private static boolean shouldUseMultiplexer() { + var type = AppPrefs.get().terminalType().getValue(); + if (type.getOpenFormat() == TerminalOpenFormat.TABBED) { + return false; + } + + var multiplexer = AppPrefs.get().terminalMultiplexer().getValue(); + return multiplexer != null; + } + private static boolean checkMultiplexerLaunch(UUID request, TerminalLaunchConfiguration config) throws Exception { + if (!config.isPreferTabs()) { + return false; + } + + if (!shouldUseMultiplexer()) { + return false; + } + if (!TerminalMultiplexerManager.requiresNewTerminalSession(request)) { var control = TerminalProxyManager.getProxy(); if (control.isPresent()) { @@ -149,7 +170,7 @@ public class TerminalLauncher { var title = type.useColoredTitle() ? config.getColoredTitle() : config.getCleanTitle(); var openCommand = control.get().prepareTerminalOpen(TerminalInitScriptConfig.ofName(title), WorkingDirectoryFunction.none()); var multiplexer = AppPrefs.get().terminalMultiplexer().getValue(); - var fullCommand = multiplexer.launchScriptExternal(openCommand).toString(); + var fullCommand = multiplexer.launchScriptExternal(control.get(), openCommand).toString(); control.get().command(fullCommand).execute(); return true; } @@ -157,13 +178,13 @@ public class TerminalLauncher { return false; } - public static String launchMultiplexer(ProcessControl processControl, TerminalInitScriptConfig config, WorkingDirectoryFunction wd) throws Exception { + public static String createLaunchCommand(ProcessControl processControl, TerminalInitScriptConfig config, WorkingDirectoryFunction wd) throws Exception { var initScript = AppPrefs.get().terminalInitScript().getValue(); var initialCommand = initScript != null ? initScript.toString() : ""; var openCommand = processControl.prepareTerminalOpen(config, wd); var proxy = TerminalProxyManager.getProxy(); var multiplexer = AppPrefs.get().terminalMultiplexer().getValue(); - var fullCommand = initialCommand + "\n" + (multiplexer != null ? multiplexer.launchScriptSession(openCommand).toString() : openCommand); + var fullCommand = initialCommand + "\n" + (multiplexer != null ? multiplexer.launchScriptSession(proxy.isPresent() ? proxy.get() : LocalShell.getShell(), openCommand).toString() : openCommand); if (proxy.isPresent()) { var proxyOpenCommand = fullCommand; var proxyLaunchCommand = proxy.get().prepareIntermediateTerminalOpen( diff --git a/app/src/main/java/io/xpipe/app/terminal/TerminalMultiplexer.java b/app/src/main/java/io/xpipe/app/terminal/TerminalMultiplexer.java index 1c07ef8b2..eace6b721 100644 --- a/app/src/main/java/io/xpipe/app/terminal/TerminalMultiplexer.java +++ b/app/src/main/java/io/xpipe/app/terminal/TerminalMultiplexer.java @@ -1,6 +1,7 @@ package io.xpipe.app.terminal; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.xpipe.core.process.ShellControl; import io.xpipe.core.process.ShellScript; import io.xpipe.core.util.ValidationException; @@ -20,7 +21,7 @@ public interface TerminalMultiplexer { String getDocsLink(); - ShellScript launchScriptExternal(String command) throws Exception; + ShellScript launchScriptExternal(ShellControl control, String command) throws Exception; - ShellScript launchScriptSession(String command) throws Exception; + ShellScript launchScriptSession(ShellControl control, String command) throws Exception; } diff --git a/app/src/main/java/io/xpipe/app/terminal/TerminalProxyManager.java b/app/src/main/java/io/xpipe/app/terminal/TerminalProxyManager.java index 1433b2bac..67cbebbd5 100644 --- a/app/src/main/java/io/xpipe/app/terminal/TerminalProxyManager.java +++ b/app/src/main/java/io/xpipe/app/terminal/TerminalProxyManager.java @@ -13,6 +13,7 @@ import io.xpipe.core.process.WorkingDirectoryFunction; import io.xpipe.core.store.DataStore; import lombok.Value; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -26,6 +27,28 @@ public class TerminalProxyManager { private static ActiveSession activeSession; + public static boolean canUseAsProxy(DataStoreEntryRef ref) { + if (!ref.get().getValidity().isUsable()) { + return false; + } + + if (ref.get().equals(DataStorage.get().local())) { + return true; + } + + var parent = DataStorage.get().getDefaultDisplayParent(ref.get()); + if (parent.isEmpty()) { + return false; + } + + if (!parent.get().equals(DataStorage.get().local()) && !DataStorage.get().local().equals(DataStorage.get().getDefaultDisplayParent(parent.get()).orElse(null))) { + return false; + } + + var id = ref.get().getProvider().getId(); + return List.of("gitWindows", "cygwin", "msys2", "wsl").contains(id); + } + public static Optional getProxy() { var uuid = AppPrefs.get().terminalProxy().getValue(); var hasCustomTerminalShell = uuid != null && diff --git a/app/src/main/java/io/xpipe/app/terminal/ZellijTerminalMultiplexer.java b/app/src/main/java/io/xpipe/app/terminal/ZellijTerminalMultiplexer.java index 427d53a63..08a96ecc4 100644 --- a/app/src/main/java/io/xpipe/app/terminal/ZellijTerminalMultiplexer.java +++ b/app/src/main/java/io/xpipe/app/terminal/ZellijTerminalMultiplexer.java @@ -46,18 +46,24 @@ public class ZellijTerminalMultiplexer implements TerminalMultiplexer { } @Override - public ShellScript launchScriptExternal(String command) throws Exception { + public ShellScript launchScriptExternal(ShellControl control, String command) throws Exception { return ShellScript.lines( "zellij attach --create-background xpipe", - "zellij run --close-on-exit -- " + command + "zellij -s xpipe action new-tab", + "zellij -s xpipe action write-chars -- " + command.replaceAll("\\\\", "\\\\\\\\").replaceAll("\"", "\\\\\"") + .replaceAll(" ", "\\\\ "), + "zellij -s xpipe action write 10" ); } @Override - public ShellScript launchScriptSession(String command) throws Exception { + public ShellScript launchScriptSession(ShellControl control, String command) throws Exception { return ShellScript.lines( + "zellij delete-session -f xpipe", "zellij attach --create-background xpipe", - "zellij run --close-on-exit -- " + command, + "zellij -s xpipe action new-tab", + "zellij -s xpipe action write-chars -- " + command.replaceAll("\\\\", "\\\\\\\\").replaceAll("\"", "\\\\\"").replaceAll(" ", "\\\\ "), + "zellij -s xpipe action write 10", "zellij attach xpipe" ); } diff --git a/app/src/main/java/io/xpipe/app/util/OptionsBuilder.java b/app/src/main/java/io/xpipe/app/util/OptionsBuilder.java index b2bed5ddc..80b94a48d 100644 --- a/app/src/main/java/io/xpipe/app/util/OptionsBuilder.java +++ b/app/src/main/java/io/xpipe/app/util/OptionsBuilder.java @@ -213,6 +213,10 @@ public class OptionsBuilder { return this; } + public OptionsBuilder hide(boolean b) { + return hide(new SimpleBooleanProperty(b)); + } + public OptionsBuilder hide(ObservableValue b) { lastCompHeadReference.hide(b); return this; diff --git a/app/src/main/java/io/xpipe/app/util/OptionsChoiceBuilder.java b/app/src/main/java/io/xpipe/app/util/OptionsChoiceBuilder.java index 4e72bd5e8..4d684660c 100644 --- a/app/src/main/java/io/xpipe/app/util/OptionsChoiceBuilder.java +++ b/app/src/main/java/io/xpipe/app/util/OptionsChoiceBuilder.java @@ -43,17 +43,21 @@ public class OptionsChoiceBuilder { private final Property property; private final List> subclasses; private final Function, Region> transformer; + private final boolean allowNull; @SuppressWarnings("unchecked") public OptionsBuilder build() { Property s = (Property) property; var sub = subclasses; - var selectedIndex = s.getValue() == null ? -1 : sub.stream().filter(c -> c.equals(s.getValue().getClass())) + var selectedIndex = s.getValue() == null ? (allowNull ? 0 : -1) : sub.stream().filter(c -> c.equals(s.getValue().getClass())) .findFirst().map(c -> sub.indexOf(c)) - .orElse(0); + .orElse(-1); var selected = new SimpleIntegerProperty(selectedIndex); var properties = new ArrayList>(); + if (allowNull) { + properties.add(new SimpleObjectProperty<>()); + } for (int i = 0; i < sub.size(); i++) { properties.add(new SimpleObjectProperty<>(selectedIndex == i ? s.getValue() : null)); } @@ -72,6 +76,9 @@ public class OptionsChoiceBuilder { }); var map = new LinkedHashMap, OptionsBuilder>(); + if (allowNull) { + map.put(AppI18n.observable("none"), new OptionsBuilder()); + } for (int i = 0; i < sub.size(); i++) { map.put(AppI18n.observable(createIdForClass(sub.get(i))), createOptionsForClass(sub.get(i), properties.get(i))); } diff --git a/lang/strings/translations_en.properties b/lang/strings/translations_en.properties index eec403235..8f62d008d 100644 --- a/lang/strings/translations_en.properties +++ b/lang/strings/translations_en.properties @@ -1369,3 +1369,9 @@ chooseTemplate=Choose template keePassXcPlaceholder=KeePassXC entry URL passwordManagerTestPlaceholder=Enter $NAME$ hide=Hide +terminalEnvironment=Terminal environment +terminalEnvironmentDescription=In case you want to use features of a local Linux-based environment for your terminal customization, e.g. WSL, Cygwin, MSYS2, or Git bash, you can use them as the terminal environment.\n\nAny custom terminal init commands and terminal multiplexer configuration will then be run in this environment. +terminalInitScript=Terminal init script +terminalInitScriptDescription=Commands to run in the terminal environment prior to the connection being launched.\n\nYou can use this to configure the terminal environment with colors, terminal multiplexers, and more on startup. +terminalMultiplexer=Terminal multiplexer +terminalMultiplexerDescription=The terminal multiplexer to use as an alternative to tabs in a terminal.\n\nThis will replace certain terminal handling characteristics, e.g. tab handling, with the multiplexer functionality.