From 82d2d48e8a867e53562edcd18ca1f1c8f7a47737 Mon Sep 17 00:00:00 2001 From: crschnick Date: Mon, 4 Nov 2024 22:12:40 +0000 Subject: [PATCH] Dock rework --- .../impl/ConnectionTerminalExchangeImpl.java | 4 +- .../beacon/impl/SshLaunchExchangeImpl.java | 2 +- .../impl/TerminalLaunchExchangeImpl.java | 2 +- .../beacon/impl/TerminalWaitExchangeImpl.java | 4 +- ...omeModel.java => BrowserHomeTabModel.java} | 7 +- .../browser/BrowserTerminalDockTabModel.java | 89 +++++++++ .../app/browser/fs/OpenFileSystemModel.java | 21 ++- .../browser/session/BrowserSessionComp.java | 2 +- .../browser/session/BrowserSessionModel.java | 4 +- .../java/io/xpipe/app/comp/AppLayoutComp.java | 7 +- .../io/xpipe/app/core/AppLayoutModel.java | 44 +++-- .../java/io/xpipe/app/core/mode/BaseMode.java | 2 + .../xpipe/app/core/window/AppMainWindow.java | 3 + .../core/window/NativeWinWindowControl.java | 71 +++++++- .../java/io/xpipe/app/prefs/AppPrefs.java | 7 + .../io/xpipe/app/prefs/TerminalCategory.java | 17 +- .../xpipe/app/prefs/TroubleshootCategory.java | 2 +- .../app/terminal/AlacrittyTerminalType.java | 2 +- .../xpipe/app/terminal/CmdTerminalType.java | 38 ++++ .../app/terminal/DockableTerminalType.java | 8 + .../app/terminal/ExternalTerminalType.java | 105 +---------- .../app/terminal/PowerShellTerminalType.java | 40 ++++ .../xpipe/app/terminal/PwshTerminalType.java | 41 +++++ .../xpipe/app/terminal/TabbyTerminalType.java | 2 +- .../xpipe/app/terminal/TerminalDockComp.java | 75 ++++++++ .../xpipe/app/terminal/TerminalDockModel.java | 172 ++++++++++++++++++ .../TerminalLaunchRequest.java | 4 +- .../TerminalLaunchResult.java | 2 +- .../{util => terminal}/TerminalLauncher.java | 27 +-- .../TerminalLauncherManager.java | 2 +- .../io/xpipe/app/terminal/TerminalView.java | 137 ++++++++++++++ .../app/terminal/TerminalViewInstance.java | 45 +++++ .../app/terminal/WindowsTerminalType.java | 2 +- .../terminal/WindowsTerminalViewInstance.java | 70 +++++++ .../io/xpipe/app/update/AppInstaller.java | 2 +- app/src/main/java/io/xpipe/app/util/Rect.java | 9 + app/src/main/java/module-info.java | 2 +- .../beacon/api/TerminalWaitExchange.java | 2 + .../ext/base/action/RunScriptActionMenu.java | 3 +- .../ext/base/browser/MultiExecuteAction.java | 8 +- .../browser/MultiExecuteSelectionAction.java | 8 +- .../ext/base/browser/OpenTerminalAction.java | 4 + .../ext/base/store/ShellStoreProvider.java | 4 +- lang/app/strings/translations_en.properties | 1 + 44 files changed, 936 insertions(+), 167 deletions(-) rename app/src/main/java/io/xpipe/app/browser/{BrowserHomeModel.java => BrowserHomeTabModel.java} (81%) create mode 100644 app/src/main/java/io/xpipe/app/browser/BrowserTerminalDockTabModel.java create mode 100644 app/src/main/java/io/xpipe/app/terminal/CmdTerminalType.java create mode 100644 app/src/main/java/io/xpipe/app/terminal/DockableTerminalType.java create mode 100644 app/src/main/java/io/xpipe/app/terminal/PowerShellTerminalType.java create mode 100644 app/src/main/java/io/xpipe/app/terminal/PwshTerminalType.java create mode 100644 app/src/main/java/io/xpipe/app/terminal/TerminalDockComp.java create mode 100644 app/src/main/java/io/xpipe/app/terminal/TerminalDockModel.java rename app/src/main/java/io/xpipe/app/{util => terminal}/TerminalLaunchRequest.java (95%) rename app/src/main/java/io/xpipe/app/{util => terminal}/TerminalLaunchResult.java (91%) rename app/src/main/java/io/xpipe/app/{util => terminal}/TerminalLauncher.java (93%) rename app/src/main/java/io/xpipe/app/{util => terminal}/TerminalLauncherManager.java (99%) create mode 100644 app/src/main/java/io/xpipe/app/terminal/TerminalView.java create mode 100644 app/src/main/java/io/xpipe/app/terminal/TerminalViewInstance.java create mode 100644 app/src/main/java/io/xpipe/app/terminal/WindowsTerminalViewInstance.java create mode 100644 app/src/main/java/io/xpipe/app/util/Rect.java diff --git a/app/src/main/java/io/xpipe/app/beacon/impl/ConnectionTerminalExchangeImpl.java b/app/src/main/java/io/xpipe/app/beacon/impl/ConnectionTerminalExchangeImpl.java index 0e2fa42c9..733afa018 100644 --- a/app/src/main/java/io/xpipe/app/beacon/impl/ConnectionTerminalExchangeImpl.java +++ b/app/src/main/java/io/xpipe/app/beacon/impl/ConnectionTerminalExchangeImpl.java @@ -2,12 +2,14 @@ package io.xpipe.app.beacon.impl; import io.xpipe.app.ext.ShellStore; import io.xpipe.app.storage.DataStorage; -import io.xpipe.app.util.TerminalLauncher; +import io.xpipe.app.terminal.TerminalLauncher; import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.api.ConnectionTerminalExchange; import com.sun.net.httpserver.HttpExchange; +import java.util.UUID; + public class ConnectionTerminalExchangeImpl extends ConnectionTerminalExchange { @Override diff --git a/app/src/main/java/io/xpipe/app/beacon/impl/SshLaunchExchangeImpl.java b/app/src/main/java/io/xpipe/app/beacon/impl/SshLaunchExchangeImpl.java index 2b8c8c410..c287553d7 100644 --- a/app/src/main/java/io/xpipe/app/beacon/impl/SshLaunchExchangeImpl.java +++ b/app/src/main/java/io/xpipe/app/beacon/impl/SshLaunchExchangeImpl.java @@ -1,7 +1,7 @@ package io.xpipe.app.beacon.impl; import io.xpipe.app.ext.ProcessControlProvider; -import io.xpipe.app.util.TerminalLauncherManager; +import io.xpipe.app.terminal.TerminalLauncherManager; import io.xpipe.beacon.api.SshLaunchExchange; import io.xpipe.core.process.ShellDialects; diff --git a/app/src/main/java/io/xpipe/app/beacon/impl/TerminalLaunchExchangeImpl.java b/app/src/main/java/io/xpipe/app/beacon/impl/TerminalLaunchExchangeImpl.java index d1982ca7f..03b80342a 100644 --- a/app/src/main/java/io/xpipe/app/beacon/impl/TerminalLaunchExchangeImpl.java +++ b/app/src/main/java/io/xpipe/app/beacon/impl/TerminalLaunchExchangeImpl.java @@ -1,6 +1,6 @@ package io.xpipe.app.beacon.impl; -import io.xpipe.app.util.TerminalLauncherManager; +import io.xpipe.app.terminal.TerminalLauncherManager; import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.api.TerminalLaunchExchange; diff --git a/app/src/main/java/io/xpipe/app/beacon/impl/TerminalWaitExchangeImpl.java b/app/src/main/java/io/xpipe/app/beacon/impl/TerminalWaitExchangeImpl.java index 885247908..edf060862 100644 --- a/app/src/main/java/io/xpipe/app/beacon/impl/TerminalWaitExchangeImpl.java +++ b/app/src/main/java/io/xpipe/app/beacon/impl/TerminalWaitExchangeImpl.java @@ -1,6 +1,7 @@ package io.xpipe.app.beacon.impl; -import io.xpipe.app.util.TerminalLauncherManager; +import io.xpipe.app.terminal.TerminalLauncherManager; +import io.xpipe.app.terminal.TerminalView; import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.BeaconServerException; import io.xpipe.beacon.api.TerminalWaitExchange; @@ -11,6 +12,7 @@ public class TerminalWaitExchangeImpl extends TerminalWaitExchange { @Override public Object handle(HttpExchange exchange, Request msg) throws BeaconClientException, BeaconServerException { TerminalLauncherManager.waitExchange(msg.getRequest()); + TerminalView.get().open(msg.getRequest(), msg.getPid()); return Response.builder().build(); } diff --git a/app/src/main/java/io/xpipe/app/browser/BrowserHomeModel.java b/app/src/main/java/io/xpipe/app/browser/BrowserHomeTabModel.java similarity index 81% rename from app/src/main/java/io/xpipe/app/browser/BrowserHomeModel.java rename to app/src/main/java/io/xpipe/app/browser/BrowserHomeTabModel.java index 676b7db51..8f0deb262 100644 --- a/app/src/main/java/io/xpipe/app/browser/BrowserHomeModel.java +++ b/app/src/main/java/io/xpipe/app/browser/BrowserHomeTabModel.java @@ -6,13 +6,10 @@ import io.xpipe.app.browser.session.BrowserSessionTab; import io.xpipe.app.core.AppI18n; import io.xpipe.app.fxcomps.Comp; import io.xpipe.app.storage.DataColor; -import io.xpipe.core.store.*; -import javafx.beans.property.*; +public final class BrowserHomeTabModel extends BrowserSessionTab { -public final class BrowserHomeModel extends BrowserSessionTab { - - public BrowserHomeModel(BrowserAbstractSessionModel browserModel) { + public BrowserHomeTabModel(BrowserAbstractSessionModel browserModel) { super(browserModel, AppI18n.get("overview"), null); } diff --git a/app/src/main/java/io/xpipe/app/browser/BrowserTerminalDockTabModel.java b/app/src/main/java/io/xpipe/app/browser/BrowserTerminalDockTabModel.java new file mode 100644 index 000000000..d827e024b --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/BrowserTerminalDockTabModel.java @@ -0,0 +1,89 @@ +package io.xpipe.app.browser; + +import io.xpipe.app.browser.session.BrowserAbstractSessionModel; +import io.xpipe.app.browser.session.BrowserSessionModel; +import io.xpipe.app.browser.session.BrowserSessionTab; +import io.xpipe.app.core.AppI18n; +import io.xpipe.app.fxcomps.Comp; +import io.xpipe.app.storage.DataColor; +import io.xpipe.app.terminal.TerminalDockComp; +import io.xpipe.app.terminal.TerminalDockModel; +import io.xpipe.app.terminal.TerminalView; +import io.xpipe.app.terminal.TerminalViewInstance; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; + +import java.util.ArrayList; +import java.util.UUID; + +public final class BrowserTerminalDockTabModel extends BrowserSessionTab { + + private final ObservableList terminalRequests; + private final TerminalDockModel dockModel = new TerminalDockModel(); + + public BrowserTerminalDockTabModel(BrowserAbstractSessionModel browserModel, ObservableList terminalRequests) { + super(browserModel, AppI18n.get("terminal"), null); + this.terminalRequests = terminalRequests; + } + + @Override + public Comp comp() { + return new TerminalDockComp(dockModel); + } + + @Override + public boolean canImmediatelyClose() { + return true; + } + + @Override + public void init() throws Exception { + var terminals = new ArrayList(); + TerminalView.get().addListener(new TerminalView.Listener() { + @Override + public void onSessionOpened(TerminalView.Session session) { + if (!terminalRequests.contains(session.getRequest())) { + return; + } + + var tv = terminals.stream().filter(instance -> instance.getTerminalProcess().equals(session.getTerminal())).findFirst(); + tv.ifPresent(instance -> { + dockModel.trackTerminal(instance); + }); + } + + @Override + public void onSessionClosed(TerminalView.Session session) { + + } + + @Override + public void onTerminalOpened(TerminalViewInstance instance) { + terminals.add(instance); + } + + @Override + public void onTerminalClosed(TerminalViewInstance instance) { +terminals.remove(instance); + } + }); + } + + @Override + public void close() {} + + @Override + public String getIcon() { + return null; + } + + @Override + public DataColor getColor() { + return null; + } + + @Override + public boolean isCloseable() { + return false; + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/fs/OpenFileSystemModel.java b/app/src/main/java/io/xpipe/app/browser/fs/OpenFileSystemModel.java index 8ee1fbc38..e06f13239 100644 --- a/app/src/main/java/io/xpipe/app/browser/fs/OpenFileSystemModel.java +++ b/app/src/main/java/io/xpipe/app/browser/fs/OpenFileSystemModel.java @@ -18,7 +18,7 @@ import io.xpipe.app.issue.ErrorEvent; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.storage.DataStoreEntryRef; import io.xpipe.app.util.BooleanScope; -import io.xpipe.app.util.TerminalLauncher; +import io.xpipe.app.terminal.TerminalLauncher; import io.xpipe.app.util.ThreadHelper; import io.xpipe.core.process.CommandBuilder; import io.xpipe.core.process.ShellControl; @@ -28,9 +28,12 @@ import io.xpipe.core.store.*; import io.xpipe.core.util.FailableConsumer; import io.xpipe.core.util.FailableRunnable; +import javafx.beans.Observable; import javafx.beans.binding.Bindings; import javafx.beans.property.*; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; import lombok.Getter; import lombok.SneakyThrows; @@ -39,6 +42,7 @@ import java.nio.file.Path; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.UUID; import java.util.stream.Stream; @Getter @@ -51,6 +55,7 @@ public final class OpenFileSystemModel extends BrowserStoreSessionTab overlay = new SimpleObjectProperty<>(); private final BooleanProperty inOverview = new SimpleBooleanProperty(); private final Property progress = new SimpleObjectProperty<>(); + private final ObservableList terminalRequests = FXCollections.observableArrayList(); private FileSystem fileSystem; private OpenFileSystemSavedState savedState; private OpenFileSystemCache cache; @@ -253,6 +258,8 @@ public final class OpenFileSystemModel extends BrowserStoreSessionTab adjustedPath .toLowerCase() .startsWith(dialect.getExecutableName().toLowerCase()))) { + var uuid = UUID.randomUUID(); + terminalRequests.add(uuid); TerminalLauncher.open( entry.getEntry(), name, @@ -261,13 +268,17 @@ public final class OpenFileSystemModel extends BrowserStoreSessionTab { - AnchorPane.setTopAnchor(struc.get(), 0.0); + AnchorPane.setTopAnchor(struc.get(), 3.0); AnchorPane.setRightAnchor(struc.get(), 0.0); }) .styleClass("tab-loading-indicator"); diff --git a/app/src/main/java/io/xpipe/app/browser/session/BrowserSessionModel.java b/app/src/main/java/io/xpipe/app/browser/session/BrowserSessionModel.java index e26ad2407..c1bc0d952 100644 --- a/app/src/main/java/io/xpipe/app/browser/session/BrowserSessionModel.java +++ b/app/src/main/java/io/xpipe/app/browser/session/BrowserSessionModel.java @@ -1,6 +1,6 @@ package io.xpipe.app.browser.session; -import io.xpipe.app.browser.BrowserHomeModel; +import io.xpipe.app.browser.BrowserHomeTabModel; import io.xpipe.app.browser.BrowserSavedState; import io.xpipe.app.browser.BrowserSavedStateImpl; import io.xpipe.app.browser.BrowserTransferModel; @@ -27,7 +27,7 @@ public class BrowserSessionModel extends BrowserAbstractSessionModel> { var sidebarR = sidebar.createRegion(); pane.setRight(sidebarR); model.getSelected().addListener((c, o, n) -> { - if (o != null && o.equals(model.getEntries().get(2))) { + if (o != null && o.equals(model.getEntries().get(3))) { AppPrefs.get().save(); DataStorage.get().saveAsync(); } - if (o != null && o.equals(model.getEntries().get(1))) { + if (o != null && o.equals(model.getEntries().get(0))) { StoreViewState.get().updateDisplay(); } + + // TerminalView.get().toggleView(model.getEntries().get(2).equals(n)); }); pane.addEventHandler(KeyEvent.KEY_PRESSED, event -> { sidebarR.getChildrenUnmodifiable().forEach(node -> { 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 2afca9050..22ec1d694 100644 --- a/app/src/main/java/io/xpipe/app/core/AppLayoutModel.java +++ b/app/src/main/java/io/xpipe/app/core/AppLayoutModel.java @@ -3,6 +3,7 @@ package io.xpipe.app.core; import io.xpipe.app.beacon.AppBeaconServer; import io.xpipe.app.browser.session.BrowserSessionComp; import io.xpipe.app.browser.session.BrowserSessionModel; +import io.xpipe.app.terminal.TerminalDockComp; import io.xpipe.app.comp.store.StoreLayoutComp; import io.xpipe.app.fxcomps.Comp; import io.xpipe.app.fxcomps.util.LabelGraphic; @@ -10,6 +11,7 @@ import io.xpipe.app.prefs.AppPrefsComp; import io.xpipe.app.util.Hyperlinks; import io.xpipe.app.util.LicenseProvider; +import io.xpipe.app.terminal.TerminalView; import javafx.beans.property.Property; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ObservableValue; @@ -22,7 +24,6 @@ import lombok.Data; import lombok.Getter; import lombok.extern.jackson.Jacksonized; -import java.time.*; import java.util.ArrayList; import java.util.List; @@ -40,7 +41,7 @@ public class AppLayoutModel { public AppLayoutModel(SavedState savedState) { this.savedState = savedState; this.entries = createEntryList(); - this.selected = new SimpleObjectProperty<>(entries.get(1)); + this.selected = new SimpleObjectProperty<>(entries.get(0)); } public static AppLayoutModel get() { @@ -62,35 +63,49 @@ public class AppLayoutModel { } public void selectBrowser() { - selected.setValue(entries.getFirst()); + selected.setValue(entries.get(1)); } - public void selectSettings() { + public void selectTerminal() { + if (!TerminalView.isSupported()) { + return; + } + selected.setValue(entries.get(2)); } + public void selectSettings() { + selected.setValue(entries.get(TerminalView.isSupported() ? 3 : 2)); + } + public void selectLicense() { - selected.setValue(entries.get(3)); + selected.setValue(entries.get(TerminalView.isSupported() ? 4 : 3)); } public void selectConnections() { - selected.setValue(entries.get(1)); + selected.setValue(entries.get(0)); } private List createEntryList() { var l = new ArrayList<>(List.of( - new Entry( - AppI18n.observable("browser"), - new LabelGraphic.IconGraphic("mdi2f-file-cabinet"), - new BrowserSessionComp(BrowserSessionModel.DEFAULT), - null, - new KeyCodeCombination(KeyCode.DIGIT1, KeyCombination.SHORTCUT_DOWN)), new Entry( AppI18n.observable("connections"), new LabelGraphic.IconGraphic("mdi2c-connection"), new StoreLayoutComp(), null, + new KeyCodeCombination(KeyCode.DIGIT1, KeyCombination.SHORTCUT_DOWN)), + new Entry( + AppI18n.observable("browser"), + new LabelGraphic.IconGraphic("mdi2f-file-cabinet"), + new BrowserSessionComp(BrowserSessionModel.DEFAULT), + null, new KeyCodeCombination(KeyCode.DIGIT2, KeyCombination.SHORTCUT_DOWN)), +// new Entry( +// AppI18n.observable("terminal"), +// new LabelGraphic.IconGraphic("mdi2m-monitor-screenshot"), +// new TerminalDockComp(), +// null, +// new KeyCodeCombination(KeyCode.DIGIT3, KeyCombination.SHORTCUT_DOWN)), new Entry( AppI18n.observable("settings"), new LabelGraphic.IconGraphic("mdsmz-miscellaneous_services"), @@ -129,6 +144,11 @@ public class AppLayoutModel { // () -> Hyperlinks.open(Hyperlinks.GITHUB_WEBTOP), // null) )); + + if (!TerminalView.isSupported()) { + l.remove(2); + } + return l; } diff --git a/app/src/main/java/io/xpipe/app/core/mode/BaseMode.java b/app/src/main/java/io/xpipe/app/core/mode/BaseMode.java index 5e22ae223..b91a1cd26 100644 --- a/app/src/main/java/io/xpipe/app/core/mode/BaseMode.java +++ b/app/src/main/java/io/xpipe/app/core/mode/BaseMode.java @@ -15,6 +15,7 @@ import io.xpipe.app.resources.AppResources; import io.xpipe.app.resources.SystemIcons; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.storage.DataStorageSyncHandler; +import io.xpipe.app.terminal.TerminalView; import io.xpipe.app.update.XPipeDistributionType; import io.xpipe.app.util.*; @@ -67,6 +68,7 @@ public class BaseMode extends OperationMode { FileBridge.init(); BlobManager.init(); ActionProvider.initProviders(); + TerminalView.init(); TrackEvent.info("Finished base components initialization"); initialized = true; } diff --git a/app/src/main/java/io/xpipe/app/core/window/AppMainWindow.java b/app/src/main/java/io/xpipe/app/core/window/AppMainWindow.java index f841d44c9..ebec20905 100644 --- a/app/src/main/java/io/xpipe/app/core/window/AppMainWindow.java +++ b/app/src/main/java/io/xpipe/app/core/window/AppMainWindow.java @@ -264,6 +264,9 @@ public class AppMainWindow { public void show() { stage.show(); + if (OsType.getLocal() == OsType.WINDOWS) { + NativeWinWindowControl.MAIN_WINDOW = new NativeWinWindowControl(stage); + } } private void setupContent(Comp content) { diff --git a/app/src/main/java/io/xpipe/app/core/window/NativeWinWindowControl.java b/app/src/main/java/io/xpipe/app/core/window/NativeWinWindowControl.java index 398f33b36..44257c460 100644 --- a/app/src/main/java/io/xpipe/app/core/window/NativeWinWindowControl.java +++ b/app/src/main/java/io/xpipe/app/core/window/NativeWinWindowControl.java @@ -1,5 +1,8 @@ package io.xpipe.app.core.window; +import com.sun.jna.ptr.IntByReference; +import io.sentry.protocol.User; +import io.xpipe.app.util.Rect; import javafx.stage.Window; import com.sun.jna.Library; @@ -13,10 +16,34 @@ import lombok.Getter; import lombok.SneakyThrows; import java.lang.reflect.Method; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; @Getter public class NativeWinWindowControl { + public static Optional byPid(long pid) { + var ref = new AtomicReference(); + User32.INSTANCE.EnumWindows((hWnd, data) -> { + var visible = User32.INSTANCE.IsWindowVisible(hWnd); + if (!visible) { + return true; + } + + var wpid = new IntByReference(); + User32.INSTANCE.GetWindowThreadProcessId(hWnd, wpid); + if (wpid.getValue() == pid) { + ref.set(new NativeWinWindowControl(hWnd)); + return false; + } else { + return true; + } + }, null); + return Optional.ofNullable(ref.get()); + } + + public static NativeWinWindowControl MAIN_WINDOW; + private final WinDef.HWND windowHandle; @SneakyThrows @@ -38,8 +65,48 @@ public class NativeWinWindowControl { this.windowHandle = windowHandle; } - public void move(int x, int y, int w, int h) { - User32.INSTANCE.SetWindowPos(windowHandle, new WinDef.HWND(), x, y, w, h, 0); + public void removeBorders() { + var style = User32.INSTANCE.GetWindowLong(windowHandle, User32.GWL_STYLE); + var mod = style & ~(User32.WS_CAPTION | User32.WS_THICKFRAME | User32.WS_MAXIMIZEBOX); + User32.INSTANCE.SetWindowLong(windowHandle,User32.GWL_STYLE,mod); + } + + public boolean isIconified() { + return (User32.INSTANCE.GetWindowLong(windowHandle,User32.GWL_STYLE) & User32.WS_MINIMIZE) != 0; + } + + public void alwaysInFront() { + orderRelative(new WinDef.HWND(new Pointer( 0xFFFFFFFFFFFFFFFFL))); + } + + public void defaultOrder() { + orderRelative(new WinDef.HWND(new Pointer( -2))); + } + + public void orderRelative(WinDef.HWND predecessor) { + User32.INSTANCE.SetWindowPos(windowHandle, predecessor, 0, 0, 0, 0, User32.SWP_NOACTIVATE | User32.SWP_NOMOVE | User32.SWP_NOSIZE); + } + + public void show() { + User32.INSTANCE.ShowWindow(windowHandle, User32.SW_RESTORE); + } + + public void close() { + User32.INSTANCE.PostMessage(windowHandle, User32.WM_CLOSE, null, null); + } + + public void minimize() { + User32.INSTANCE.ShowWindow(windowHandle,User32.SW_MINIMIZE); + } + + public void move(Rect bounds) { + User32.INSTANCE.SetWindowPos(windowHandle, null, bounds.getX(), bounds.getY(), bounds.getW(), bounds.getH(), User32.SWP_NOACTIVATE); + } + + public Rect getBounds() { + var rect = new WinDef.RECT(); + User32.INSTANCE.GetWindowRect(windowHandle, rect); + return new Rect(rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top); } public boolean setWindowAttribute(int attribute, boolean attributeValue) { 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 78565fd2a..210ae29e6 100644 --- a/app/src/main/java/io/xpipe/app/prefs/AppPrefs.java +++ b/app/src/main/java/io/xpipe/app/prefs/AppPrefs.java @@ -139,10 +139,17 @@ public class AppPrefs { final BooleanProperty requireDoubleClickForConnections = mapLocal(new SimpleBooleanProperty(false), "requireDoubleClickForConnections", Boolean.class, false); + final BooleanProperty enableTerminalDocking = + mapLocal(new SimpleBooleanProperty(true), "enableTerminalDocking", Boolean.class, true); + public ObservableBooleanValue requireDoubleClickForConnections() { return requireDoubleClickForConnections; } + public ObservableBooleanValue enableTerminalDocking() { + return enableTerminalDocking; + } + @Getter private final Property lockPassword = new SimpleObjectProperty<>(); 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 e91969245..db1181135 100644 --- a/app/src/main/java/io/xpipe/app/prefs/TerminalCategory.java +++ b/app/src/main/java/io/xpipe/app/prefs/TerminalCategory.java @@ -10,12 +10,12 @@ import io.xpipe.app.fxcomps.impl.HorizontalComp; import io.xpipe.app.fxcomps.impl.StackComp; import io.xpipe.app.fxcomps.impl.TextFieldComp; import io.xpipe.app.terminal.ExternalTerminalType; -import io.xpipe.app.util.Hyperlinks; -import io.xpipe.app.util.OptionsBuilder; -import io.xpipe.app.util.TerminalLauncher; -import io.xpipe.app.util.ThreadHelper; +import io.xpipe.app.terminal.TerminalLauncher; +import io.xpipe.app.terminal.TerminalView; +import io.xpipe.app.util.*; import javafx.beans.binding.Bindings; +import javafx.beans.property.SimpleBooleanProperty; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.ListCell; @@ -24,6 +24,7 @@ import javafx.scene.paint.Color; import org.kordamp.ikonli.javafx.FontIcon; import java.util.List; +import java.util.UUID; public class TerminalCategory extends AppPrefsCategory { @@ -44,7 +45,7 @@ public class TerminalCategory extends AppPrefsCategory { "Test", ProcessControlProvider.get() .createLocalProcessControl(true) - .command("echo Test")); + .command("echo Test"), UUID.randomUUID()); } }); }))) @@ -61,7 +62,11 @@ public class TerminalCategory extends AppPrefsCategory { .hide(prefs.terminalType.isNotEqualTo(ExternalTerminalType.CUSTOM))) .addComp(terminalTest) .nameAndDescription("clearTerminalOnInit") - .addToggle(prefs.clearTerminalOnInit)) + .addToggle(prefs.clearTerminalOnInit) + .nameAndDescription("enableTerminalDocking") + .addToggle(prefs.enableTerminalDocking) + .hide(new SimpleBooleanProperty(!TerminalView.isSupported())) + ) .buildComp(); } diff --git a/app/src/main/java/io/xpipe/app/prefs/TroubleshootCategory.java b/app/src/main/java/io/xpipe/app/prefs/TroubleshootCategory.java index 8f0866a2e..04b5f189f 100644 --- a/app/src/main/java/io/xpipe/app/prefs/TroubleshootCategory.java +++ b/app/src/main/java/io/xpipe/app/prefs/TroubleshootCategory.java @@ -10,7 +10,7 @@ import io.xpipe.app.terminal.ExternalTerminalType; import io.xpipe.app.util.DesktopHelper; import io.xpipe.app.util.FileOpener; import io.xpipe.app.util.OptionsBuilder; -import io.xpipe.app.util.TerminalLauncher; +import io.xpipe.app.terminal.TerminalLauncher; import io.xpipe.core.process.OsType; import io.xpipe.core.store.FileNames; import io.xpipe.core.util.XPipeInstallation; diff --git a/app/src/main/java/io/xpipe/app/terminal/AlacrittyTerminalType.java b/app/src/main/java/io/xpipe/app/terminal/AlacrittyTerminalType.java index 7e454811f..a1d13d864 100644 --- a/app/src/main/java/io/xpipe/app/terminal/AlacrittyTerminalType.java +++ b/app/src/main/java/io/xpipe/app/terminal/AlacrittyTerminalType.java @@ -3,7 +3,7 @@ package io.xpipe.app.terminal; import io.xpipe.app.util.LocalShell; import io.xpipe.core.process.CommandBuilder; -public interface AlacrittyTerminalType extends ExternalTerminalType { +public interface AlacrittyTerminalType extends ExternalTerminalType, DockableTerminalType { ExternalTerminalType ALACRITTY_WINDOWS = new Windows(); ExternalTerminalType ALACRITTY_LINUX = new Linux(); diff --git a/app/src/main/java/io/xpipe/app/terminal/CmdTerminalType.java b/app/src/main/java/io/xpipe/app/terminal/CmdTerminalType.java new file mode 100644 index 000000000..6a60cd421 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/terminal/CmdTerminalType.java @@ -0,0 +1,38 @@ +package io.xpipe.app.terminal; + +import io.xpipe.core.process.CommandBuilder; +import io.xpipe.core.process.ShellDialects; + +public class CmdTerminalType extends ExternalTerminalType.SimplePathType implements DockableTerminalType { + + public CmdTerminalType() {super("app.cmd", "cmd.exe", true);} + + @Override + public int getProcessHierarchyOffset() { + return -1; + } + + @Override + public boolean supportsTabs() { + return false; + } + + @Override + public boolean isRecommended() { + return false; + } + + @Override + public boolean supportsColoredTitle() { + return false; + } + + @Override + protected CommandBuilder toCommand(LaunchConfiguration configuration) { + if (configuration.getScriptDialect().equals(ShellDialects.CMD)) { + return CommandBuilder.of().add("/c").addFile(configuration.getScriptFile()); + } + + return CommandBuilder.of().add("/c").add(configuration.getDialectLaunchCommand()); + } +} diff --git a/app/src/main/java/io/xpipe/app/terminal/DockableTerminalType.java b/app/src/main/java/io/xpipe/app/terminal/DockableTerminalType.java new file mode 100644 index 000000000..cedbf47a1 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/terminal/DockableTerminalType.java @@ -0,0 +1,8 @@ +package io.xpipe.app.terminal; + +public interface DockableTerminalType { + + public default int getProcessHierarchyOffset() { + return 0; + } +} diff --git a/app/src/main/java/io/xpipe/app/terminal/ExternalTerminalType.java b/app/src/main/java/io/xpipe/app/terminal/ExternalTerminalType.java index b44b9c8b6..bb1392885 100644 --- a/app/src/main/java/io/xpipe/app/terminal/ExternalTerminalType.java +++ b/app/src/main/java/io/xpipe/app/terminal/ExternalTerminalType.java @@ -23,7 +23,6 @@ import lombok.Value; import lombok.With; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.*; @@ -398,109 +397,12 @@ public interface ExternalTerminalType extends PrefsChoiceValue { } }; - ExternalTerminalType CMD = new SimplePathType("app.cmd", "cmd.exe", true) { + ExternalTerminalType CMD = new CmdTerminalType(); - @Override - public boolean supportsTabs() { - return false; - } + ExternalTerminalType POWERSHELL = new PowerShellTerminalType(); - @Override - public boolean isRecommended() { - return false; - } + ExternalTerminalType PWSH = new PwshTerminalType(); - @Override - public boolean supportsColoredTitle() { - return false; - } - - @Override - protected CommandBuilder toCommand(LaunchConfiguration configuration) { - if (configuration.getScriptDialect().equals(ShellDialects.CMD)) { - return CommandBuilder.of().add("/c").addFile(configuration.getScriptFile()); - } - - return CommandBuilder.of().add("/c").add(configuration.getDialectLaunchCommand()); - } - }; - - ExternalTerminalType POWERSHELL = new SimplePathType("app.powershell", "powershell", true) { - - @Override - public boolean supportsTabs() { - return false; - } - - @Override - public boolean isRecommended() { - return false; - } - - @Override - public boolean supportsColoredTitle() { - return false; - } - - @Override - protected CommandBuilder toCommand(LaunchConfiguration configuration) { - if (configuration.getScriptDialect().equals(ShellDialects.POWERSHELL)) { - return CommandBuilder.of() - .add("-ExecutionPolicy", "Bypass") - .add("-File") - .addFile(configuration.getScriptFile()); - } - - return CommandBuilder.of() - .add("-ExecutionPolicy", "Bypass") - .add("-EncodedCommand") - .add(sc -> { - var base64 = Base64.getEncoder() - .encodeToString(configuration - .getDialectLaunchCommand() - .buildBase(sc) - .getBytes(StandardCharsets.UTF_16LE)); - return "\"" + base64 + "\""; - }); - } - }; - - ExternalTerminalType PWSH = new SimplePathType("app.pwsh", "pwsh", true) { - - @Override - public String getWebsite() { - return "https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell?view=powershell-7.4"; - } - - @Override - public boolean supportsTabs() { - return false; - } - - @Override - public boolean isRecommended() { - return false; - } - - @Override - public boolean supportsColoredTitle() { - return false; - } - - @Override - protected CommandBuilder toCommand(LaunchConfiguration configuration) { - return CommandBuilder.of() - .add("-ExecutionPolicy", "Bypass") - .add("-EncodedCommand") - .add(sc -> { - // Fix for https://github.com/PowerShell/PowerShell/issues/18530#issuecomment-1325691850 - var c = "$env:PSModulePath=\"\";" - + configuration.getDialectLaunchCommand().buildBase(sc); - var base64 = Base64.getEncoder().encodeToString(c.getBytes(StandardCharsets.UTF_16LE)); - return "\"" + base64 + "\""; - }); - } - }; ExternalTerminalType GNOME_TERMINAL = new PathCheckType("app.gnomeTerminal", "gnome-terminal", true) { @Override public String getWebsite() { @@ -1214,4 +1116,5 @@ public interface ExternalTerminalType extends PrefsChoiceValue { protected abstract CommandBuilder toCommand(LaunchConfiguration configuration) throws Exception; } + } diff --git a/app/src/main/java/io/xpipe/app/terminal/PowerShellTerminalType.java b/app/src/main/java/io/xpipe/app/terminal/PowerShellTerminalType.java new file mode 100644 index 000000000..6ab21dc76 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/terminal/PowerShellTerminalType.java @@ -0,0 +1,40 @@ +package io.xpipe.app.terminal; + +import io.xpipe.core.process.CommandBuilder; +import io.xpipe.core.process.ShellDialects; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +public class PowerShellTerminalType extends ExternalTerminalType.SimplePathType implements DockableTerminalType { + + public PowerShellTerminalType() {super("app.powershell", "powershell", true);} + + @Override + public boolean supportsTabs() { + return false; + } + + @Override + public boolean isRecommended() { + return false; + } + + @Override + public boolean supportsColoredTitle() { + return false; + } + + @Override + protected CommandBuilder toCommand(LaunchConfiguration configuration) { + if (configuration.getScriptDialect().equals(ShellDialects.POWERSHELL)) { + return CommandBuilder.of().add("-ExecutionPolicy", "Bypass").add("-File").addFile(configuration.getScriptFile()); + } + + return CommandBuilder.of().add("-ExecutionPolicy", "Bypass").add("-EncodedCommand").add(sc -> { + var base64 = Base64.getEncoder().encodeToString( + configuration.getDialectLaunchCommand().buildBase(sc).getBytes(StandardCharsets.UTF_16LE)); + return "\"" + base64 + "\""; + }); + } +} diff --git a/app/src/main/java/io/xpipe/app/terminal/PwshTerminalType.java b/app/src/main/java/io/xpipe/app/terminal/PwshTerminalType.java new file mode 100644 index 000000000..a5e1c36ef --- /dev/null +++ b/app/src/main/java/io/xpipe/app/terminal/PwshTerminalType.java @@ -0,0 +1,41 @@ +package io.xpipe.app.terminal; + +import io.xpipe.core.process.CommandBuilder; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +public class PwshTerminalType extends ExternalTerminalType.SimplePathType implements DockableTerminalType { + + public PwshTerminalType() {super("app.pwsh", "pwsh", true);} + + @Override + public String getWebsite() { + return "https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell?view=powershell-7.4"; + } + + @Override + public boolean supportsTabs() { + return false; + } + + @Override + public boolean isRecommended() { + return false; + } + + @Override + public boolean supportsColoredTitle() { + return false; + } + + @Override + protected CommandBuilder toCommand(LaunchConfiguration configuration) { + return CommandBuilder.of().add("-ExecutionPolicy", "Bypass").add("-EncodedCommand").add(sc -> { + // Fix for https://github.com/PowerShell/PowerShell/issues/18530#issuecomment-1325691850 + var c = "$env:PSModulePath=\"\";" + configuration.getDialectLaunchCommand().buildBase(sc); + var base64 = Base64.getEncoder().encodeToString(c.getBytes(StandardCharsets.UTF_16LE)); + return "\"" + base64 + "\""; + }); + } +} diff --git a/app/src/main/java/io/xpipe/app/terminal/TabbyTerminalType.java b/app/src/main/java/io/xpipe/app/terminal/TabbyTerminalType.java index e8f584bdb..7bd00a751 100644 --- a/app/src/main/java/io/xpipe/app/terminal/TabbyTerminalType.java +++ b/app/src/main/java/io/xpipe/app/terminal/TabbyTerminalType.java @@ -9,7 +9,7 @@ import io.xpipe.core.process.TerminalInitFunction; import java.nio.file.Path; import java.util.Optional; -public interface TabbyTerminalType extends ExternalTerminalType { +public interface TabbyTerminalType extends ExternalTerminalType, DockableTerminalType { ExternalTerminalType TABBY_WINDOWS = new Windows(); ExternalTerminalType TABBY_MAC_OS = new MacOs(); diff --git a/app/src/main/java/io/xpipe/app/terminal/TerminalDockComp.java b/app/src/main/java/io/xpipe/app/terminal/TerminalDockComp.java new file mode 100644 index 000000000..094608bba --- /dev/null +++ b/app/src/main/java/io/xpipe/app/terminal/TerminalDockComp.java @@ -0,0 +1,75 @@ +package io.xpipe.app.terminal; + +import io.xpipe.app.core.AppI18n; +import io.xpipe.app.core.window.AppMainWindow; +import io.xpipe.app.fxcomps.SimpleComp; +import javafx.geometry.Pos; +import javafx.scene.Cursor; +import javafx.scene.control.Label; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.stage.WindowEvent; + +public class TerminalDockComp extends SimpleComp { + + private final TerminalDockModel model; + + public TerminalDockComp(TerminalDockModel model) {this.model = model;} + + @Override + protected Region createSimple() { + var label = new Label(); + label.textProperty().bind(AppI18n.observable("clickToDock")); + var stack = new StackPane(label); + stack.setAlignment(Pos.CENTER); + stack.setCursor(Cursor.HAND); + stack.boundsInParentProperty().addListener((observable, oldValue, newValue) -> { + update(stack); + }); + var s = AppMainWindow.getInstance().getStage(); + s.xProperty().addListener((observable, oldValue, newValue) -> { + update(stack); + }); + s.yProperty().addListener((observable, oldValue, newValue) -> { + update(stack); + }); + s.widthProperty().addListener((observable, oldValue, newValue) -> { + update(stack); + }); + s.heightProperty().addListener((observable, oldValue, newValue) -> { + update(stack); + }); + s.iconifiedProperty().addListener((observable, oldValue, newValue) -> { + if (newValue) { + model.onWindowMinimize(); + } else { + model.onWindowActivate(); + } + }); + s.focusedProperty().addListener((observable, oldValue, newValue) -> { + if (newValue) { + model.onFocusGain(); + } else { + model.onFocusLost(); + } + }); + s.addEventFilter(WindowEvent.WINDOW_SHOWN,event -> { + update(stack); + }); + s.addEventFilter(WindowEvent.WINDOW_HIDING,event -> { + model.onClose(); + }); + stack.setOnMouseClicked(event -> { + model.clickView(); + event.consume(); + }); + return stack; + } + + private void update(Region region) { + var bounds = region.localToScreen(region.getBoundsInLocal()); + var sx = region.getScene().getWindow().getOutputScaleX(); + var sy = region.getScene().getWindow().getOutputScaleY(); + model.resizeView((int) Math.ceil(bounds.getMinX() * sx), (int) Math.ceil(bounds.getMinY() * sy),(int) Math.floor(bounds.getWidth() * sx), (int) Math.floor(bounds.getHeight() * sy)); + } +} diff --git a/app/src/main/java/io/xpipe/app/terminal/TerminalDockModel.java b/app/src/main/java/io/xpipe/app/terminal/TerminalDockModel.java new file mode 100644 index 000000000..2ac2b4b49 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/terminal/TerminalDockModel.java @@ -0,0 +1,172 @@ +package io.xpipe.app.terminal; + +import io.xpipe.app.issue.TrackEvent; +import io.xpipe.app.prefs.AppPrefs; +import io.xpipe.app.util.Rect; +import io.xpipe.core.process.OsType; + +import java.util.ArrayList; +import java.util.List; + +public class TerminalDockModel { + + public static boolean isSupported() { + return OsType.getLocal() == OsType.WINDOWS; + } + + private Rect viewBounds; + private boolean viewActive; + private final List terminalInstances = new ArrayList<>(); + + public synchronized void trackTerminal(TerminalViewInstance terminal) { + terminalInstances.add(terminal); + } + + public boolean isEnabled() { + return isSupported() && AppPrefs.get().enableTerminalDocking().get(); + } + + public synchronized void toggleView(boolean active) { + TrackEvent.withTrace("Terminal view toggled") + .tag("active", active) + .handle(); + if (viewActive == active) { + return; + } + + this.viewActive = active; + if (active) { + terminalInstances.forEach(terminalInstance -> terminalInstance.alwaysInFront()); + updatePositions(); + } else { + terminalInstances.forEach(terminalInstance -> terminalInstance.back()); + } + } + + public synchronized void onFocusGain() { + if (!viewActive) { + return; + } + + TrackEvent.withTrace("Terminal view focus gained") + .handle(); + terminalInstances.forEach(terminalInstance -> { + if (!terminalInstance.isActive()) { + return; + } + + terminalInstance.updateBoundsState(); + if (terminalInstance.isCustomBounds()) { + return; + } + + terminalInstance.show(); + terminalInstance.alwaysInFront(); + }); + } + + public synchronized void onFocusLost() { + if (!viewActive) { + return; + } + + TrackEvent.withTrace("Terminal view focus lost") + .handle(); + terminalInstances.forEach(terminalInstance -> { + if (!terminalInstance.isActive()) { + return; + } + + terminalInstance.updateBoundsState(); + if (terminalInstance.isCustomBounds()) { + return; + } + + terminalInstance.frontOfMainWindow(); + }); + } + + public synchronized void onWindowActivate() { + TrackEvent.withTrace("Terminal view focus gained") + .handle(); + terminalInstances.forEach(terminalInstance -> { + terminalInstance.updateBoundsState(); + if (terminalInstance.isCustomBounds()) { + return; + } + + terminalInstance.show(); + if (viewActive) { + terminalInstance.alwaysInFront(); + } else { + terminalInstance.back(); + } + }); + } + + public synchronized void onWindowMinimize() { + TrackEvent.withTrace("Terminal view minimized") + .handle(); + + terminalInstances.forEach(terminalInstance -> { + terminalInstance.updateBoundsState(); + if (terminalInstance.isCustomBounds()) { + return; + } + + terminalInstance.minimize(); + }); + } + + public synchronized void onClose() { + TrackEvent.withTrace("Terminal view closed") + .handle(); + + terminalInstances.forEach(terminalInstance -> { + terminalInstance.updateBoundsState(); + if (terminalInstance.isCustomBounds()) { + return; + } + + terminalInstance.close(); + }); + } + + private void updatePositions() { + if (viewBounds == null) { + return; + } + + terminalInstances.forEach(terminalInstance -> { + terminalInstance.updateBoundsState(); + if (terminalInstance.isCustomBounds()) { + return; + } + + terminalInstance.updatePosition(viewBounds); + }); + } + + public void resizeView(int x, int y, int w, int h) { + if (w < 100 || h < 100) { + return; + } + + this.viewBounds = new Rect(x,y,w,h); + TrackEvent.withTrace("Terminal view resized") + .tag("rect", viewBounds) + .handle(); + updatePositions(); + } + + public void clickView() { + TrackEvent.withTrace("Terminal view clicked") + .handle(); + + terminalInstances.forEach(terminalInstance -> { + terminalInstance.show(); + terminalInstance.alwaysInFront(); + terminalInstance.updatePosition(viewBounds); + }); + } +} diff --git a/app/src/main/java/io/xpipe/app/util/TerminalLaunchRequest.java b/app/src/main/java/io/xpipe/app/terminal/TerminalLaunchRequest.java similarity index 95% rename from app/src/main/java/io/xpipe/app/util/TerminalLaunchRequest.java rename to app/src/main/java/io/xpipe/app/terminal/TerminalLaunchRequest.java index ff73d4f2c..bbdbdf3e3 100644 --- a/app/src/main/java/io/xpipe/app/util/TerminalLaunchRequest.java +++ b/app/src/main/java/io/xpipe/app/terminal/TerminalLaunchRequest.java @@ -1,5 +1,7 @@ -package io.xpipe.app.util; +package io.xpipe.app.terminal; +import io.xpipe.app.util.ScriptHelper; +import io.xpipe.app.util.ThreadHelper; import io.xpipe.beacon.BeaconServerException; import io.xpipe.core.process.*; import io.xpipe.core.store.FilePath; diff --git a/app/src/main/java/io/xpipe/app/util/TerminalLaunchResult.java b/app/src/main/java/io/xpipe/app/terminal/TerminalLaunchResult.java similarity index 91% rename from app/src/main/java/io/xpipe/app/util/TerminalLaunchResult.java rename to app/src/main/java/io/xpipe/app/terminal/TerminalLaunchResult.java index 0e9203a3e..fbaee752a 100644 --- a/app/src/main/java/io/xpipe/app/util/TerminalLaunchResult.java +++ b/app/src/main/java/io/xpipe/app/terminal/TerminalLaunchResult.java @@ -1,4 +1,4 @@ -package io.xpipe.app.util; +package io.xpipe.app.terminal; import lombok.Value; diff --git a/app/src/main/java/io/xpipe/app/util/TerminalLauncher.java b/app/src/main/java/io/xpipe/app/terminal/TerminalLauncher.java similarity index 93% rename from app/src/main/java/io/xpipe/app/util/TerminalLauncher.java rename to app/src/main/java/io/xpipe/app/terminal/TerminalLauncher.java index 51244050a..3e40e5c0f 100644 --- a/app/src/main/java/io/xpipe/app/util/TerminalLauncher.java +++ b/app/src/main/java/io/xpipe/app/terminal/TerminalLauncher.java @@ -1,4 +1,4 @@ -package io.xpipe.app.util; +package io.xpipe.app.terminal; import io.xpipe.app.core.AppI18n; import io.xpipe.app.core.AppProperties; @@ -7,7 +7,10 @@ import io.xpipe.app.issue.ErrorEvent; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.storage.DataStoreEntry; -import io.xpipe.app.terminal.ExternalTerminalType; +import io.xpipe.app.util.LicenseProvider; +import io.xpipe.app.util.LicenseRequiredException; +import io.xpipe.app.util.LocalShell; +import io.xpipe.app.util.ScriptHelper; import io.xpipe.core.process.*; import io.xpipe.core.store.FilePath; import io.xpipe.core.util.FailableFunction; @@ -22,15 +25,6 @@ import java.util.UUID; public class TerminalLauncher { - public static void openDirect(String title, FailableFunction command) - throws Exception { - var type = AppPrefs.get().terminalType().getValue(); - if (type == null) { - throw ErrorEvent.expected(new IllegalStateException(AppI18n.get("noTerminalSet"))); - } - openDirect(title, command, type); - } - public static void openDirect( String title, FailableFunction command, ExternalTerminalType type) throws Exception { @@ -53,10 +47,18 @@ public class TerminalLauncher { } public static void open(String title, ProcessControl cc) throws Exception { - open(null, title, null, cc); + open(null, title, null, cc, UUID.randomUUID()); + } + + public static void open(String title, ProcessControl cc, UUID request) throws Exception { + open(null, title, null, cc, request); } public static void open(DataStoreEntry entry, String title, String directory, ProcessControl cc) throws Exception { + open(entry, title, directory, cc, UUID.randomUUID()); + } + + public static void open(DataStoreEntry entry, String title, String directory, ProcessControl cc, UUID request) throws Exception { var type = AppPrefs.get().terminalType().getValue(); if (type == null) { throw ErrorEvent.expected(new IllegalStateException(AppI18n.get("noTerminalSet"))); @@ -73,7 +75,6 @@ public class TerminalLauncher { && type.shouldClear() && AppPrefs.get().clearTerminalOnInit().get(), cc instanceof ShellControl ? type.additionalInitCommands() : TerminalInitFunction.none()); - var request = UUID.randomUUID(); var config = createConfig(request, entry, cleanTitle, adjustedTitle); var latch = TerminalLauncherManager.submitAsync(request, cc, terminalConfig, directory); try { diff --git a/app/src/main/java/io/xpipe/app/util/TerminalLauncherManager.java b/app/src/main/java/io/xpipe/app/terminal/TerminalLauncherManager.java similarity index 99% rename from app/src/main/java/io/xpipe/app/util/TerminalLauncherManager.java rename to app/src/main/java/io/xpipe/app/terminal/TerminalLauncherManager.java index 708b22cf7..6838f56cc 100644 --- a/app/src/main/java/io/xpipe/app/util/TerminalLauncherManager.java +++ b/app/src/main/java/io/xpipe/app/terminal/TerminalLauncherManager.java @@ -1,4 +1,4 @@ -package io.xpipe.app.util; +package io.xpipe.app.terminal; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.beacon.BeaconClientException; diff --git a/app/src/main/java/io/xpipe/app/terminal/TerminalView.java b/app/src/main/java/io/xpipe/app/terminal/TerminalView.java new file mode 100644 index 000000000..f881df459 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/terminal/TerminalView.java @@ -0,0 +1,137 @@ +package io.xpipe.app.terminal; + +import io.xpipe.app.core.AppLayoutModel; +import io.xpipe.app.core.window.NativeWinWindowControl; +import io.xpipe.app.issue.TrackEvent; +import io.xpipe.app.prefs.AppPrefs; +import io.xpipe.app.util.Rect; +import io.xpipe.app.util.ThreadHelper; +import io.xpipe.core.process.OsType; +import javafx.application.Platform; +import lombok.Value; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public class TerminalView { + + public static boolean isSupported() { + return OsType.getLocal() == OsType.WINDOWS; + } + + @Value + public static class Session { + UUID request; + ProcessHandle shell; + ProcessHandle terminal; + } + + public static interface Listener { + + void onSessionOpened(Session session); + + void onSessionClosed(Session session); + + void onTerminalOpened(TerminalViewInstance instance); + + void onTerminalClosed(TerminalViewInstance instance); + } + + private final List sessions = new ArrayList<>(); + private final List terminalInstances = new ArrayList<>(); + private final List listeners = new ArrayList<>(); + + public void addListener(Listener listener) { + this.listeners.add(listener); + } + + public boolean isEnabled() { + return isSupported() && AppPrefs.get().enableTerminalDocking().get(); + } + + public synchronized void open(UUID request, long pid) { + var processHandle = ProcessHandle.of(pid); + if (processHandle.isEmpty() || !processHandle.get().isAlive()) { + return; + } + + var shell = processHandle.get().parent(); + if (shell.isEmpty()) { + return; + } + + var terminal = getTerminalProcess(shell.get()); + if (terminal.isEmpty()) { + return; + } + + var session = new Session(request, shell.get(), terminal.get()); + var instance = terminalInstances.stream().filter(i -> i.getTerminalProcess().equals(terminal.get())).findFirst(); + if (instance.isEmpty()) { + var control = NativeWinWindowControl.byPid(terminal.get().pid()); + if (control.isEmpty()) { + return; + } + var tv = new WindowsTerminalViewInstance(terminal.get(), control.get()); + terminalInstances.add(tv); + listeners.forEach(listener -> listener.onTerminalOpened(tv)); + } + + sessions.add(session); + listeners.forEach(listener -> listener.onSessionOpened(session)); + + TrackEvent.withTrace("Terminal instance opened") + .tag("terminalPid", terminal.get().pid()) + .tag("viewEnabled", isEnabled()) + .handle(); + + if (!isEnabled()) { + return; + } + } + + private Optional getTerminalProcess(ProcessHandle shell) { + var t = AppPrefs.get().terminalType().getValue(); + if (!(t instanceof DockableTerminalType dockableTerminalType)) { + return Optional.empty(); + } + + var off = dockableTerminalType.getProcessHierarchyOffset(); + var current = Optional.of(shell); + for (int i = 0; i < 1 + off; i++) { + current = current.flatMap(processHandle -> processHandle.parent()); + } + return current; + } + + public synchronized void tick() { + sessions.removeIf(session -> !session.shell.isAlive() || !session.terminal.isAlive()); + for (TerminalViewInstance terminalInstance : new ArrayList<>(terminalInstances)) { + var alive = terminalInstance.getTerminalProcess().isAlive(); + if (!alive) { + terminalInstances.remove(terminalInstance); + TrackEvent.withTrace("Terminal session is dead").tag("pid", terminalInstance.getTerminalProcess().pid()).handle(); + listeners.forEach(listener -> listener.onTerminalClosed(terminalInstance)); + } + } + } + + private static TerminalView INSTANCE; + + public static void init() { + var instance = new TerminalView(); + ThreadHelper.createPlatformThread("terminal-view", true, () -> { + while (true) { + instance.tick(); + ThreadHelper.sleep(1000); + } + }).start(); + INSTANCE = instance; + } + + public static TerminalView get() { + return INSTANCE; + } +} diff --git a/app/src/main/java/io/xpipe/app/terminal/TerminalViewInstance.java b/app/src/main/java/io/xpipe/app/terminal/TerminalViewInstance.java new file mode 100644 index 000000000..f1b193f7c --- /dev/null +++ b/app/src/main/java/io/xpipe/app/terminal/TerminalViewInstance.java @@ -0,0 +1,45 @@ +package io.xpipe.app.terminal; + +import io.xpipe.app.util.Rect; +import lombok.Getter; + +@Getter +public abstract class TerminalViewInstance { + + private final ProcessHandle terminalProcess; + + protected Rect lastBounds; + protected boolean customBounds; + + protected TerminalViewInstance(ProcessHandle terminalProcess) {this.terminalProcess = terminalProcess;} + + public abstract void show(); + + public abstract void minimize(); + + public abstract void alwaysInFront(); + + public abstract void back(); + + public abstract void frontOfMainWindow(); + + public abstract void updatePosition(Rect bounds); + + public abstract void close(); + + public abstract boolean isActive(); + + public abstract Rect queryBounds(); + + public final void updateBoundsState() { + if (!isActive()) { + return; + } + + var bounds = queryBounds(); + if (lastBounds != null && !lastBounds.equals(bounds)) { + customBounds = true; + } + lastBounds = bounds; + } +} diff --git a/app/src/main/java/io/xpipe/app/terminal/WindowsTerminalType.java b/app/src/main/java/io/xpipe/app/terminal/WindowsTerminalType.java index b13ebcff3..b76a32cd8 100644 --- a/app/src/main/java/io/xpipe/app/terminal/WindowsTerminalType.java +++ b/app/src/main/java/io/xpipe/app/terminal/WindowsTerminalType.java @@ -11,7 +11,7 @@ import io.xpipe.core.store.FileNames; import java.nio.file.Files; import java.nio.file.Path; -public interface WindowsTerminalType extends ExternalTerminalType { +public interface WindowsTerminalType extends ExternalTerminalType, DockableTerminalType { ExternalTerminalType WINDOWS_TERMINAL = new Standard(); ExternalTerminalType WINDOWS_TERMINAL_PREVIEW = new Preview(); diff --git a/app/src/main/java/io/xpipe/app/terminal/WindowsTerminalViewInstance.java b/app/src/main/java/io/xpipe/app/terminal/WindowsTerminalViewInstance.java new file mode 100644 index 000000000..6741c7678 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/terminal/WindowsTerminalViewInstance.java @@ -0,0 +1,70 @@ +package io.xpipe.app.terminal; + +import io.xpipe.app.core.window.NativeWinWindowControl; +import io.xpipe.app.util.Rect; +import lombok.AccessLevel; +import lombok.experimental.FieldDefaults; + +@FieldDefaults( + makeFinal = true, + level = AccessLevel.PRIVATE) +public final class WindowsTerminalViewInstance extends TerminalViewInstance { + + NativeWinWindowControl control; + + public WindowsTerminalViewInstance(ProcessHandle terminal, NativeWinWindowControl control) { + super(terminal); + this.control = control; + } + + @Override + public void show() { + this.control.show(); + } + + @Override + public void minimize() { + this.control.minimize(); + } + + @Override + public void alwaysInFront() { + this.control.alwaysInFront(); + this.control.removeBorders(); + } + + @Override + public void back() { + control.defaultOrder(); + NativeWinWindowControl.MAIN_WINDOW.alwaysInFront(); + NativeWinWindowControl.MAIN_WINDOW.defaultOrder(); + } + + @Override + public void frontOfMainWindow() { + this.control.alwaysInFront(); + this.control.defaultOrder(); + } + + @Override + public void updatePosition(Rect bounds) { + control.move(bounds); + this.lastBounds = bounds; + this.customBounds = false; + } + + @Override + public void close() { + this.control.close(); + } + + @Override + public boolean isActive() { + return !control.isIconified(); + } + + @Override + public Rect queryBounds() { + return control.getBounds(); + } +} diff --git a/app/src/main/java/io/xpipe/app/update/AppInstaller.java b/app/src/main/java/io/xpipe/app/update/AppInstaller.java index b4d50b8c5..c72cc952e 100644 --- a/app/src/main/java/io/xpipe/app/update/AppInstaller.java +++ b/app/src/main/java/io/xpipe/app/update/AppInstaller.java @@ -7,7 +7,7 @@ import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.terminal.ExternalTerminalType; import io.xpipe.app.util.LocalShell; import io.xpipe.app.util.ScriptHelper; -import io.xpipe.app.util.TerminalLauncher; +import io.xpipe.app.terminal.TerminalLauncher; import io.xpipe.app.util.ThreadHelper; import io.xpipe.core.process.OsType; import io.xpipe.core.process.ShellDialects; diff --git a/app/src/main/java/io/xpipe/app/util/Rect.java b/app/src/main/java/io/xpipe/app/util/Rect.java new file mode 100644 index 000000000..99888c955 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/util/Rect.java @@ -0,0 +1,9 @@ +package io.xpipe.app.util; + +import lombok.Value; + +@Value +public class Rect { + int x, y; + int w, h; +} diff --git a/app/src/main/java/module-info.java b/app/src/main/java/module-info.java index 9502d95e5..04c07d616 100644 --- a/app/src/main/java/module-info.java +++ b/app/src/main/java/module-info.java @@ -7,7 +7,7 @@ import io.xpipe.app.issue.EventHandlerImpl; import io.xpipe.app.storage.DataStateProviderImpl; import io.xpipe.app.util.AppJacksonModule; import io.xpipe.app.util.LicenseProvider; -import io.xpipe.app.util.TerminalLauncher; +import io.xpipe.app.terminal.TerminalLauncher; import io.xpipe.beacon.BeaconInterface; import io.xpipe.core.util.DataStateProvider; import io.xpipe.core.util.ModuleLayerLoader; diff --git a/beacon/src/main/java/io/xpipe/beacon/api/TerminalWaitExchange.java b/beacon/src/main/java/io/xpipe/beacon/api/TerminalWaitExchange.java index 95bbf126f..71d9dcd76 100644 --- a/beacon/src/main/java/io/xpipe/beacon/api/TerminalWaitExchange.java +++ b/beacon/src/main/java/io/xpipe/beacon/api/TerminalWaitExchange.java @@ -22,6 +22,8 @@ public class TerminalWaitExchange extends BeaconInterface entries) { model.withShell( pc -> { + var uuid = UUID.randomUUID(); + model.getTerminalRequests().add(uuid); var cmd = pc.command(createCommand(pc, model, entries)); TerminalLauncher.open( model.getEntry().getEntry(), @@ -41,7 +44,8 @@ public abstract class MultiExecuteSelectionAction implements BranchAction { ? model.getCurrentDirectory() .getPath() : null, - cmd); + cmd, + uuid); }, false); } diff --git a/ext/base/src/main/java/io/xpipe/ext/base/browser/OpenTerminalAction.java b/ext/base/src/main/java/io/xpipe/ext/base/browser/OpenTerminalAction.java index ee8d09f0b..daebee9f9 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/browser/OpenTerminalAction.java +++ b/ext/base/src/main/java/io/xpipe/ext/base/browser/OpenTerminalAction.java @@ -3,6 +3,7 @@ package io.xpipe.ext.base.browser; import io.xpipe.app.browser.action.LeafAction; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.fs.OpenFileSystemModel; +import io.xpipe.app.browser.session.BrowserSessionModel; import io.xpipe.app.core.AppI18n; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.core.store.FileKind; @@ -26,6 +27,9 @@ public class OpenTerminalAction implements LeafAction { model.getCurrentDirectory() != null ? model.getCurrentDirectory().getPath() : null); + if (model.getBrowserModel() instanceof BrowserSessionModel sessionModel) { + sessionModel. + } return; } diff --git a/ext/base/src/main/java/io/xpipe/ext/base/store/ShellStoreProvider.java b/ext/base/src/main/java/io/xpipe/ext/base/store/ShellStoreProvider.java index e20c05c5b..48e192b1d 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/store/ShellStoreProvider.java +++ b/ext/base/src/main/java/io/xpipe/ext/base/store/ShellStoreProvider.java @@ -15,13 +15,15 @@ import io.xpipe.app.resources.SystemIcons; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.storage.DataStoreEntry; import io.xpipe.app.util.ShellStoreFormat; -import io.xpipe.app.util.TerminalLauncher; +import io.xpipe.app.terminal.TerminalLauncher; import io.xpipe.core.process.ShellStoreState; import io.xpipe.ext.base.script.ScriptStore; import javafx.beans.property.BooleanProperty; import javafx.beans.value.ObservableValue; +import java.util.UUID; + public interface ShellStoreProvider extends DataStoreProvider { @Override diff --git a/lang/app/strings/translations_en.properties b/lang/app/strings/translations_en.properties index 4493f8e1d..80258167f 100644 --- a/lang/app/strings/translations_en.properties +++ b/lang/app/strings/translations_en.properties @@ -548,3 +548,4 @@ scriptsIntroStart=Get started checkForSecurityUpdates=Check for security updates #force checkForSecurityUpdatesDescription=XPipe can check for potential security updates separately from normal feature updates. When this is enabled, at least important security updates will be recommended for installation even if the normal update check is disabled.\n\nDisabling this setting will result in no external version request being performed, and you won't be notified about any security updates. +clickToDock=Click to dock terminal