Repository: xpipe-io/xpipe Branch: master Commit: 0194ed313d82 Files: 1457 Total size: 6.4 MB Directory structure: gitextract_ign71aq1/ ├── .gitattributes ├── .github/ │ └── dependabot.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── SECURITY.md ├── app/ │ ├── build.gradle │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ ├── io/ │ │ │ │ └── xpipe/ │ │ │ │ └── app/ │ │ │ │ ├── Main.java │ │ │ │ ├── action/ │ │ │ │ │ ├── AbstractAction.java │ │ │ │ │ ├── ActionConfigComp.java │ │ │ │ │ ├── ActionConfirmComp.java │ │ │ │ │ ├── ActionConfirmation.java │ │ │ │ │ ├── ActionJacksonMapper.java │ │ │ │ │ ├── ActionPickComp.java │ │ │ │ │ ├── ActionProvider.java │ │ │ │ │ ├── ActionShortcutComp.java │ │ │ │ │ ├── ActionUrls.java │ │ │ │ │ ├── LauncherUrlProvider.java │ │ │ │ │ ├── SerializableAction.java │ │ │ │ │ ├── StoreContextAction.java │ │ │ │ │ └── XPipeUrlProvider.java │ │ │ │ ├── beacon/ │ │ │ │ │ ├── AppBeaconCache.java │ │ │ │ │ ├── AppBeaconServer.java │ │ │ │ │ ├── BeaconRequestHandler.java │ │ │ │ │ ├── BeaconSession.java │ │ │ │ │ ├── BeaconShellSession.java │ │ │ │ │ ├── BlobManager.java │ │ │ │ │ ├── impl/ │ │ │ │ │ │ ├── ActionExchangeImpl.java │ │ │ │ │ │ ├── AskpassExchangeImpl.java │ │ │ │ │ │ ├── CategoryAddExchangeImpl.java │ │ │ │ │ │ ├── CategoryInfoExchangeImpl.java │ │ │ │ │ │ ├── CategoryQueryExchangeImpl.java │ │ │ │ │ │ ├── CategoryRemoveExchangeImpl.java │ │ │ │ │ │ ├── ConnectionAddExchangeImpl.java │ │ │ │ │ │ ├── ConnectionInfoExchangeImpl.java │ │ │ │ │ │ ├── ConnectionQueryExchangeImpl.java │ │ │ │ │ │ ├── ConnectionRefreshExchangeImpl.java │ │ │ │ │ │ ├── ConnectionRemoveExchangeImpl.java │ │ │ │ │ │ ├── DaemonFocusExchangeImpl.java │ │ │ │ │ │ ├── DaemonModeExchangeImpl.java │ │ │ │ │ │ ├── DaemonOpenExchangeImpl.java │ │ │ │ │ │ ├── DaemonStatusExchangeImpl.java │ │ │ │ │ │ ├── DaemonStopExchangeImpl.java │ │ │ │ │ │ ├── DaemonVersionExchangeImpl.java │ │ │ │ │ │ ├── FsBlobExchangeImpl.java │ │ │ │ │ │ ├── FsReadExchangeImpl.java │ │ │ │ │ │ ├── FsScriptExchangeImpl.java │ │ │ │ │ │ ├── FsWriteExchangeImpl.java │ │ │ │ │ │ ├── HandshakeExchangeImpl.java │ │ │ │ │ │ ├── SecretDecryptExchangeImpl.java │ │ │ │ │ │ ├── SecretEncryptExchangeImpl.java │ │ │ │ │ │ ├── ShellExecExchangeImpl.java │ │ │ │ │ │ ├── ShellStartExchangeImpl.java │ │ │ │ │ │ ├── ShellStopExchangeImpl.java │ │ │ │ │ │ ├── SshLaunchExchangeImpl.java │ │ │ │ │ │ ├── TerminalExternalLaunchExchangeImpl.java │ │ │ │ │ │ ├── TerminalLaunchExchangeImpl.java │ │ │ │ │ │ ├── TerminalPrepareExchangeImpl.java │ │ │ │ │ │ ├── TerminalRegisterExchangeImpl.java │ │ │ │ │ │ └── TerminalWaitExchangeImpl.java │ │ │ │ │ └── mcp/ │ │ │ │ │ ├── AppMcpServer.java │ │ │ │ │ ├── HttpStreamableServerTransportProvider.java │ │ │ │ │ ├── McpSchemaFiles.java │ │ │ │ │ ├── McpToolHandler.java │ │ │ │ │ └── McpTools.java │ │ │ │ ├── browser/ │ │ │ │ │ ├── BrowserAbstractSessionModel.java │ │ │ │ │ ├── BrowserFileChooserSessionComp.java │ │ │ │ │ ├── BrowserFileChooserSessionModel.java │ │ │ │ │ ├── BrowserFullSessionComp.java │ │ │ │ │ ├── BrowserFullSessionModel.java │ │ │ │ │ ├── BrowserSessionTab.java │ │ │ │ │ ├── BrowserSessionTabsComp.java │ │ │ │ │ ├── BrowserStoreSessionTab.java │ │ │ │ │ ├── action/ │ │ │ │ │ │ ├── BrowserAction.java │ │ │ │ │ │ ├── BrowserActionProvider.java │ │ │ │ │ │ ├── BrowserActionProviders.java │ │ │ │ │ │ └── impl/ │ │ │ │ │ │ ├── ApplyFileEditActionProvider.java │ │ │ │ │ │ ├── BrowseInNativeManagerActionProvider.java │ │ │ │ │ │ ├── ChgrpActionProvider.java │ │ │ │ │ │ ├── ChmodActionProvider.java │ │ │ │ │ │ ├── ChownActionProvider.java │ │ │ │ │ │ ├── ComputeDirectorySizesActionProvider.java │ │ │ │ │ │ ├── DeleteActionProvider.java │ │ │ │ │ │ ├── MoveFileActionProvider.java │ │ │ │ │ │ ├── NewDirectoryActionProvider.java │ │ │ │ │ │ ├── NewFileActionProvider.java │ │ │ │ │ │ ├── NewLinkActionProvider.java │ │ │ │ │ │ ├── OpenDirectoryActionProvider.java │ │ │ │ │ │ ├── OpenFileDefaultActionProvider.java │ │ │ │ │ │ ├── OpenFileNativeDetailsActionProvider.java │ │ │ │ │ │ ├── OpenFileNativeManagerActionProvider.java │ │ │ │ │ │ ├── OpenFileWithActionProvider.java │ │ │ │ │ │ ├── RunCommandInBackgroundActionProvider.java │ │ │ │ │ │ ├── RunCommandInBrowserActionProvider.java │ │ │ │ │ │ ├── RunCommandInTerminalActionProvider.java │ │ │ │ │ │ └── TransferFilesActionProvider.java │ │ │ │ │ ├── file/ │ │ │ │ │ │ ├── BrowserBreadcrumbBar.java │ │ │ │ │ │ ├── BrowserClipboard.java │ │ │ │ │ │ ├── BrowserConnectionListComp.java │ │ │ │ │ │ ├── BrowserConnectionListFilterComp.java │ │ │ │ │ │ ├── BrowserContextMenu.java │ │ │ │ │ │ ├── BrowserDialogs.java │ │ │ │ │ │ ├── BrowserEntry.java │ │ │ │ │ │ ├── BrowserFileDuplicates.java │ │ │ │ │ │ ├── BrowserFileInput.java │ │ │ │ │ │ ├── BrowserFileListComp.java │ │ │ │ │ │ ├── BrowserFileListCompEntry.java │ │ │ │ │ │ ├── BrowserFileListFilterComp.java │ │ │ │ │ │ ├── BrowserFileListModel.java │ │ │ │ │ │ ├── BrowserFileListNameCell.java │ │ │ │ │ │ ├── BrowserFileOpener.java │ │ │ │ │ │ ├── BrowserFileOutput.java │ │ │ │ │ │ ├── BrowserFileOverviewComp.java │ │ │ │ │ │ ├── BrowserFileSelectionListComp.java │ │ │ │ │ │ ├── BrowserFileSystemHelper.java │ │ │ │ │ │ ├── BrowserFileSystemHistory.java │ │ │ │ │ │ ├── BrowserFileSystemSavedState.java │ │ │ │ │ │ ├── BrowserFileSystemTabComp.java │ │ │ │ │ │ ├── BrowserFileSystemTabModel.java │ │ │ │ │ │ ├── BrowserFileTransferMode.java │ │ │ │ │ │ ├── BrowserFileTransferOperation.java │ │ │ │ │ │ ├── BrowserGreetingComp.java │ │ │ │ │ │ ├── BrowserHistorySavedState.java │ │ │ │ │ │ ├── BrowserHistorySavedStateImpl.java │ │ │ │ │ │ ├── BrowserHistoryTabComp.java │ │ │ │ │ │ ├── BrowserHistoryTabModel.java │ │ │ │ │ │ ├── BrowserLocalFileSystem.java │ │ │ │ │ │ ├── BrowserNavBarComp.java │ │ │ │ │ │ ├── BrowserOverviewComp.java │ │ │ │ │ │ ├── BrowserQuickAccessButtonComp.java │ │ │ │ │ │ ├── BrowserQuickAccessContextMenu.java │ │ │ │ │ │ ├── BrowserStatusBarComp.java │ │ │ │ │ │ ├── BrowserTerminalDockTabModel.java │ │ │ │ │ │ ├── BrowserTransferComp.java │ │ │ │ │ │ ├── BrowserTransferModel.java │ │ │ │ │ │ └── BrowserTransferProgress.java │ │ │ │ │ ├── icon/ │ │ │ │ │ │ ├── BrowserIconDirectoryType.java │ │ │ │ │ │ ├── BrowserIconFileType.java │ │ │ │ │ │ ├── BrowserIconManager.java │ │ │ │ │ │ ├── BrowserIconVariant.java │ │ │ │ │ │ └── BrowserIcons.java │ │ │ │ │ └── menu/ │ │ │ │ │ ├── BrowserApplicationPathMenuProvider.java │ │ │ │ │ ├── BrowserMenuBranchProvider.java │ │ │ │ │ ├── BrowserMenuCategory.java │ │ │ │ │ ├── BrowserMenuItemProvider.java │ │ │ │ │ ├── BrowserMenuLeafProvider.java │ │ │ │ │ ├── BrowserMenuProviders.java │ │ │ │ │ ├── FileTypeMenuProvider.java │ │ │ │ │ ├── MultiExecuteMenuProvider.java │ │ │ │ │ └── impl/ │ │ │ │ │ ├── BackMenuProvider.java │ │ │ │ │ ├── BrowseInNativeManagerMenuProvider.java │ │ │ │ │ ├── ChgrpMenuProvider.java │ │ │ │ │ ├── ChmodMenuProvider.java │ │ │ │ │ ├── ChownMenuProvider.java │ │ │ │ │ ├── ComputeDirectorySizesMenuProvider.java │ │ │ │ │ ├── CopyMenuProvider.java │ │ │ │ │ ├── CopyPathMenuProvider.java │ │ │ │ │ ├── DeleteMenuProvider.java │ │ │ │ │ ├── DownloadMenuProvider.java │ │ │ │ │ ├── EditFileMenuProvider.java │ │ │ │ │ ├── FollowLinkMenuProvider.java │ │ │ │ │ ├── ForwardMenuProvider.java │ │ │ │ │ ├── GradleRunMenuProvider.java │ │ │ │ │ ├── JarMenuProvider.java │ │ │ │ │ ├── JavapMenuProvider.java │ │ │ │ │ ├── NewItemMenuProvider.java │ │ │ │ │ ├── OpenDirectoryInNewTabMenuProvider.java │ │ │ │ │ ├── OpenDirectoryMenuProvider.java │ │ │ │ │ ├── OpenFileDefaultMenuProvider.java │ │ │ │ │ ├── OpenFileWithMenuProvider.java │ │ │ │ │ ├── OpenNativeFileDetailsMenuProvider.java │ │ │ │ │ ├── OpenTerminalInDirectoryMenuProvider.java │ │ │ │ │ ├── PasteMenuProvider.java │ │ │ │ │ ├── RefreshDirectoryMenuProvider.java │ │ │ │ │ ├── RenameMenuProvider.java │ │ │ │ │ ├── RunFileMenuProvider.java │ │ │ │ │ └── compress/ │ │ │ │ │ ├── BaseUntarMenuProvider.java │ │ │ │ │ ├── BaseUnzipUnixMenuProvider.java │ │ │ │ │ ├── BaseUnzipWindowsActionProvider.java │ │ │ │ │ ├── CompressMenuProvider.java │ │ │ │ │ ├── TarActionProvider.java │ │ │ │ │ ├── UntarActionProvider.java │ │ │ │ │ ├── UntarDirectoryMenuProvider.java │ │ │ │ │ ├── UntarGzDirectoryMenuProvider.java │ │ │ │ │ ├── UntarGzHereMenuProvider.java │ │ │ │ │ ├── UntarHereMenuProvider.java │ │ │ │ │ ├── UnzipActionProvider.java │ │ │ │ │ ├── UnzipDirectoryUnixMenuProvider.java │ │ │ │ │ ├── UnzipDirectoryWindowsActionProvider.java │ │ │ │ │ ├── UnzipHereUnixMenuProvider.java │ │ │ │ │ ├── UnzipHereWindowsActionProvider.java │ │ │ │ │ └── ZipActionProvider.java │ │ │ │ ├── comp/ │ │ │ │ │ ├── BaseRegionBuilder.java │ │ │ │ │ ├── RegionBuilder.java │ │ │ │ │ ├── RegionDescriptor.java │ │ │ │ │ ├── RegionStructure.java │ │ │ │ │ ├── RegionStructureBuilder.java │ │ │ │ │ ├── SimpleRegionBuilder.java │ │ │ │ │ ├── augment/ │ │ │ │ │ │ └── ContextMenuAugment.java │ │ │ │ │ └── base/ │ │ │ │ │ ├── AnchorComp.java │ │ │ │ │ ├── AppLayoutComp.java │ │ │ │ │ ├── AppMainWindowContentComp.java │ │ │ │ │ ├── ButtonComp.java │ │ │ │ │ ├── ChoiceComp.java │ │ │ │ │ ├── ChoicePaneComp.java │ │ │ │ │ ├── ComboTextFieldComp.java │ │ │ │ │ ├── ContextualFileReferenceChoiceComp.java │ │ │ │ │ ├── ContextualFileReferenceSync.java │ │ │ │ │ ├── CountComp.java │ │ │ │ │ ├── DelayedInitComp.java │ │ │ │ │ ├── FilterComp.java │ │ │ │ │ ├── FontIconComp.java │ │ │ │ │ ├── HorizontalComp.java │ │ │ │ │ ├── IconButtonComp.java │ │ │ │ │ ├── InputGroupComp.java │ │ │ │ │ ├── IntComboFieldComp.java │ │ │ │ │ ├── IntFieldComp.java │ │ │ │ │ ├── IntegratedTextAreaComp.java │ │ │ │ │ ├── IntroComp.java │ │ │ │ │ ├── IntroListComp.java │ │ │ │ │ ├── LabelComp.java │ │ │ │ │ ├── LazyTextFieldComp.java │ │ │ │ │ ├── LeftSplitPaneComp.java │ │ │ │ │ ├── ListBoxViewComp.java │ │ │ │ │ ├── ListSelectorComp.java │ │ │ │ │ ├── LoadingIconComp.java │ │ │ │ │ ├── LoadingOverlayComp.java │ │ │ │ │ ├── MarkdownComp.java │ │ │ │ │ ├── MarkdownEditorComp.java │ │ │ │ │ ├── ModalButton.java │ │ │ │ │ ├── ModalOverlay.java │ │ │ │ │ ├── ModalOverlayComp.java │ │ │ │ │ ├── ModalOverlayContentComp.java │ │ │ │ │ ├── ModalOverlayStackComp.java │ │ │ │ │ ├── MultiContentComp.java │ │ │ │ │ ├── OptionsComp.java │ │ │ │ │ ├── PrettyImageComp.java │ │ │ │ │ ├── PrettyImageHelper.java │ │ │ │ │ ├── ScrollComp.java │ │ │ │ │ ├── SecretFieldComp.java │ │ │ │ │ ├── SideMenuBarComp.java │ │ │ │ │ ├── SimpleTitledPaneComp.java │ │ │ │ │ ├── StackComp.java │ │ │ │ │ ├── TextAreaComp.java │ │ │ │ │ ├── TextFieldComp.java │ │ │ │ │ ├── TileButtonComp.java │ │ │ │ │ ├── ToggleGroupComp.java │ │ │ │ │ ├── ToggleSwitchComp.java │ │ │ │ │ ├── TooltipHelper.java │ │ │ │ │ └── VerticalComp.java │ │ │ │ ├── core/ │ │ │ │ │ ├── App.java │ │ │ │ │ ├── AppAotTrain.java │ │ │ │ │ ├── AppArguments.java │ │ │ │ │ ├── AppCache.java │ │ │ │ │ ├── AppConfigurationDialog.java │ │ │ │ │ ├── AppDataLock.java │ │ │ │ │ ├── AppDesktopIntegration.java │ │ │ │ │ ├── AppDisplayScale.java │ │ │ │ │ ├── AppExecutableCache.java │ │ │ │ │ ├── AppExtensionManager.java │ │ │ │ │ ├── AppFileWatcher.java │ │ │ │ │ ├── AppFont.java │ │ │ │ │ ├── AppFontSizes.java │ │ │ │ │ ├── AppGreetingsDialog.java │ │ │ │ │ ├── AppI18n.java │ │ │ │ │ ├── AppI18nData.java │ │ │ │ │ ├── AppImages.java │ │ │ │ │ ├── AppInstallation.java │ │ │ │ │ ├── AppInstance.java │ │ │ │ │ ├── AppLayoutModel.java │ │ │ │ │ ├── AppLocalTemp.java │ │ │ │ │ ├── AppLogs.java │ │ │ │ │ ├── AppNames.java │ │ │ │ │ ├── AppOpenArguments.java │ │ │ │ │ ├── AppPreloader.java │ │ │ │ │ ├── AppProperties.java │ │ │ │ │ ├── AppResources.java │ │ │ │ │ ├── AppRestart.java │ │ │ │ │ ├── AppSid.java │ │ │ │ │ ├── AppStyle.java │ │ │ │ │ ├── AppSystemInfo.java │ │ │ │ │ ├── AppTheme.java │ │ │ │ │ ├── AppTray.java │ │ │ │ │ ├── AppTrayIcon.java │ │ │ │ │ ├── AppVersion.java │ │ │ │ │ ├── AppWindowsLock.java │ │ │ │ │ ├── AppWindowsShutdown.java │ │ │ │ │ ├── check/ │ │ │ │ │ │ ├── AppAndroidLinuxTerminalCheck.java │ │ │ │ │ │ ├── AppAvCheck.java │ │ │ │ │ │ ├── AppDebugModeCheck.java │ │ │ │ │ │ ├── AppDirectoryPermissionsCheck.java │ │ │ │ │ │ ├── AppGpuCheck.java │ │ │ │ │ │ ├── AppJavaOptionsCheck.java │ │ │ │ │ │ ├── AppPathCorruptCheck.java │ │ │ │ │ │ ├── AppPtbDialog.java │ │ │ │ │ │ ├── AppRosettaCheck.java │ │ │ │ │ │ ├── AppShellCheck.java │ │ │ │ │ │ ├── AppShellChecker.java │ │ │ │ │ │ ├── AppSystemFontCheck.java │ │ │ │ │ │ ├── AppTestCommandCheck.java │ │ │ │ │ │ ├── AppWindowsArmCheck.java │ │ │ │ │ │ └── AppWindowsTempCheck.java │ │ │ │ │ ├── mode/ │ │ │ │ │ │ ├── AppBaseMode.java │ │ │ │ │ │ ├── AppGuiMode.java │ │ │ │ │ │ ├── AppOperationMode.java │ │ │ │ │ │ └── AppTrayMode.java │ │ │ │ │ └── window/ │ │ │ │ │ ├── AppDialog.java │ │ │ │ │ ├── AppMainWindow.java │ │ │ │ │ ├── AppModifiedStage.java │ │ │ │ │ ├── AppSideWindow.java │ │ │ │ │ ├── AppWindowBounds.java │ │ │ │ │ ├── AppWindowStyle.java │ │ │ │ │ └── AppWindowTitle.java │ │ │ │ ├── ext/ │ │ │ │ │ ├── CloudSetupProvider.java │ │ │ │ │ ├── ConnectionFileSystem.java │ │ │ │ │ ├── ContainerImageStore.java │ │ │ │ │ ├── ContainerStoreState.java │ │ │ │ │ ├── CountGroupStoreProvider.java │ │ │ │ │ ├── DataStorageExtensionProvider.java │ │ │ │ │ ├── DataStore.java │ │ │ │ │ ├── DataStoreCreationCategory.java │ │ │ │ │ ├── DataStoreProvider.java │ │ │ │ │ ├── DataStoreProviders.java │ │ │ │ │ ├── DataStoreState.java │ │ │ │ │ ├── DataStoreUsageCategory.java │ │ │ │ │ ├── EnabledStoreState.java │ │ │ │ │ ├── ExpandedLifecycleStore.java │ │ │ │ │ ├── ExtensionException.java │ │ │ │ │ ├── FileEntry.java │ │ │ │ │ ├── FileInfo.java │ │ │ │ │ ├── FileKind.java │ │ │ │ │ ├── FileSystem.java │ │ │ │ │ ├── FileSystemStore.java │ │ │ │ │ ├── FixedChildStore.java │ │ │ │ │ ├── FixedHierarchyStore.java │ │ │ │ │ ├── GroupStore.java │ │ │ │ │ ├── GuiDialog.java │ │ │ │ │ ├── HostAddress.java │ │ │ │ │ ├── InternalCacheDataStore.java │ │ │ │ │ ├── LinkFileEntry.java │ │ │ │ │ ├── LocalStore.java │ │ │ │ │ ├── NameableStore.java │ │ │ │ │ ├── NetworkTunnelSession.java │ │ │ │ │ ├── NetworkTunnelStore.java │ │ │ │ │ ├── PrefsChoiceValue.java │ │ │ │ │ ├── PrefsHandler.java │ │ │ │ │ ├── PrefsProvider.java │ │ │ │ │ ├── PrefsValue.java │ │ │ │ │ ├── ProcessControlProvider.java │ │ │ │ │ ├── ScanProvider.java │ │ │ │ │ ├── SelfReferentialStore.java │ │ │ │ │ ├── Session.java │ │ │ │ │ ├── SessionListener.java │ │ │ │ │ ├── SetupToolActionProvider.java │ │ │ │ │ ├── ShellControlFunction.java │ │ │ │ │ ├── ShellControlParentStoreFunction.java │ │ │ │ │ ├── ShellDialectChoiceComp.java │ │ │ │ │ ├── ShellDialectIcons.java │ │ │ │ │ ├── ShellSession.java │ │ │ │ │ ├── ShellStore.java │ │ │ │ │ ├── SingletonSessionStore.java │ │ │ │ │ ├── SingletonSessionStoreProvider.java │ │ │ │ │ ├── StartOnInitStore.java │ │ │ │ │ ├── StatefulDataStore.java │ │ │ │ │ ├── UserScopeStore.java │ │ │ │ │ ├── ValidatableStore.java │ │ │ │ │ ├── ValidationException.java │ │ │ │ │ └── WrapperFileSystem.java │ │ │ │ ├── hub/ │ │ │ │ │ ├── action/ │ │ │ │ │ │ ├── BatchHubProvider.java │ │ │ │ │ │ ├── BatchStoreAction.java │ │ │ │ │ │ ├── HubBranchProvider.java │ │ │ │ │ │ ├── HubLeafProvider.java │ │ │ │ │ │ ├── HubMenuItemProvider.java │ │ │ │ │ │ ├── MultiStoreAction.java │ │ │ │ │ │ ├── StoreAction.java │ │ │ │ │ │ ├── StoreActionCategory.java │ │ │ │ │ │ └── impl/ │ │ │ │ │ │ ├── BrowseHubLeafProvider.java │ │ │ │ │ │ ├── CloneHubLeafProvider.java │ │ │ │ │ │ ├── EditHubLeafProvider.java │ │ │ │ │ │ ├── InitHubLeafProvider.java │ │ │ │ │ │ ├── OpenHubMenuLeafProvider.java │ │ │ │ │ │ ├── OpenSplitHubBatchProvider.java │ │ │ │ │ │ ├── RefreshActionProvider.java │ │ │ │ │ │ ├── RefreshChildrenHubLeafProvider.java │ │ │ │ │ │ ├── RefreshHubLeafProvider.java │ │ │ │ │ │ ├── SampleHubLeafProvider.java │ │ │ │ │ │ ├── ScanHubBatchProvider.java │ │ │ │ │ │ ├── ScanHubLeafProvider.java │ │ │ │ │ │ ├── StartOnInitHubLeafProvider.java │ │ │ │ │ │ └── ToggleActionProvider.java │ │ │ │ │ └── comp/ │ │ │ │ │ ├── DataStoreCategoryChoiceComp.java │ │ │ │ │ ├── DenseStoreEntryComp.java │ │ │ │ │ ├── OsLogoComp.java │ │ │ │ │ ├── StandardStoreEntryComp.java │ │ │ │ │ ├── StoreActiveComp.java │ │ │ │ │ ├── StoreCategoryComp.java │ │ │ │ │ ├── StoreCategoryConfigComp.java │ │ │ │ │ ├── StoreCategoryListComp.java │ │ │ │ │ ├── StoreCategoryWrapper.java │ │ │ │ │ ├── StoreChoiceComp.java │ │ │ │ │ ├── StoreChoicePopover.java │ │ │ │ │ ├── StoreComboChoiceComp.java │ │ │ │ │ ├── StoreCreationComp.java │ │ │ │ │ ├── StoreCreationConsumer.java │ │ │ │ │ ├── StoreCreationDialog.java │ │ │ │ │ ├── StoreCreationMenu.java │ │ │ │ │ ├── StoreCreationModel.java │ │ │ │ │ ├── StoreCreationQueueEntry.java │ │ │ │ │ ├── StoreEntryBatchSelectComp.java │ │ │ │ │ ├── StoreEntryComp.java │ │ │ │ │ ├── StoreEntryListBatchBarComp.java │ │ │ │ │ ├── StoreEntryListComp.java │ │ │ │ │ ├── StoreEntryListOverviewComp.java │ │ │ │ │ ├── StoreEntryWrapper.java │ │ │ │ │ ├── StoreIconChoiceComp.java │ │ │ │ │ ├── StoreIconChoiceDialog.java │ │ │ │ │ ├── StoreIconComp.java │ │ │ │ │ ├── StoreIdentitiesIntroComp.java │ │ │ │ │ ├── StoreIntroComp.java │ │ │ │ │ ├── StoreLayoutComp.java │ │ │ │ │ ├── StoreListChoiceComp.java │ │ │ │ │ ├── StoreNotFoundComp.java │ │ │ │ │ ├── StoreNotesComp.java │ │ │ │ │ ├── StoreOrderIndexDialog.java │ │ │ │ │ ├── StoreProviderChoiceComp.java │ │ │ │ │ ├── StoreQuickAccessButtonComp.java │ │ │ │ │ ├── StoreScriptSourcesIntroComp.java │ │ │ │ │ ├── StoreScriptsIntroComp.java │ │ │ │ │ ├── StoreSection.java │ │ │ │ │ ├── StoreSectionBaseComp.java │ │ │ │ │ ├── StoreSectionComp.java │ │ │ │ │ ├── StoreSectionMiniComp.java │ │ │ │ │ ├── StoreSectionSortMode.java │ │ │ │ │ ├── StoreSidebarComp.java │ │ │ │ │ ├── StoreToggleComp.java │ │ │ │ │ ├── StoreViewState.java │ │ │ │ │ └── SystemStateComp.java │ │ │ │ ├── icon/ │ │ │ │ │ ├── SystemIcon.java │ │ │ │ │ ├── SystemIconCache.java │ │ │ │ │ ├── SystemIconManager.java │ │ │ │ │ ├── SystemIconSource.java │ │ │ │ │ ├── SystemIconSourceData.java │ │ │ │ │ └── SystemIconSourceFile.java │ │ │ │ ├── issue/ │ │ │ │ │ ├── AttachmentHelper.java │ │ │ │ │ ├── ErrorAction.java │ │ │ │ │ ├── ErrorEvent.java │ │ │ │ │ ├── ErrorEventFactory.java │ │ │ │ │ ├── ErrorHandler.java │ │ │ │ │ ├── ErrorHandlerComp.java │ │ │ │ │ ├── ErrorHandlerDialog.java │ │ │ │ │ ├── EventHandler.java │ │ │ │ │ ├── EventHandlerImpl.java │ │ │ │ │ ├── GuiErrorHandler.java │ │ │ │ │ ├── GuiErrorHandlerBase.java │ │ │ │ │ ├── LogErrorHandler.java │ │ │ │ │ ├── SentryErrorHandler.java │ │ │ │ │ ├── SyncErrorHandler.java │ │ │ │ │ ├── TerminalErrorHandler.java │ │ │ │ │ ├── TrackEvent.java │ │ │ │ │ └── UserReportComp.java │ │ │ │ ├── platform/ │ │ │ │ │ ├── BindingsHelper.java │ │ │ │ │ ├── BooleanAnimationTimer.java │ │ │ │ │ ├── ChainedValidator.java │ │ │ │ │ ├── Check.java │ │ │ │ │ ├── ClipboardHelper.java │ │ │ │ │ ├── ColorHelper.java │ │ │ │ │ ├── DerivedObservableList.java │ │ │ │ │ ├── ExclusiveValidator.java │ │ │ │ │ ├── GlobalBooleanProperty.java │ │ │ │ │ ├── GlobalClipboard.java │ │ │ │ │ ├── GlobalDoubleProperty.java │ │ │ │ │ ├── GlobalObjectProperty.java │ │ │ │ │ ├── GlobalStringProperty.java │ │ │ │ │ ├── InputHelper.java │ │ │ │ │ ├── JfxHelper.java │ │ │ │ │ ├── LabelGraphic.java │ │ │ │ │ ├── MacOsPermissions.java │ │ │ │ │ ├── MarkdownHelper.java │ │ │ │ │ ├── MenuHelper.java │ │ │ │ │ ├── NativeBridge.java │ │ │ │ │ ├── NativeMacOsWindowControl.java │ │ │ │ │ ├── NativeWinWindowControl.java │ │ │ │ │ ├── NodeHelper.java │ │ │ │ │ ├── OptionsBuilder.java │ │ │ │ │ ├── OptionsChoiceBuilder.java │ │ │ │ │ ├── PlatformInit.java │ │ │ │ │ ├── PlatformState.java │ │ │ │ │ ├── PlatformThread.java │ │ │ │ │ ├── PlatformThreadWatcher.java │ │ │ │ │ ├── SimpleValidator.java │ │ │ │ │ └── Validator.java │ │ │ │ ├── prefs/ │ │ │ │ │ ├── AboutCategory.java │ │ │ │ │ ├── ApiCategory.java │ │ │ │ │ ├── AppPrefs.java │ │ │ │ │ ├── AppPrefsCategory.java │ │ │ │ │ ├── AppPrefsComp.java │ │ │ │ │ ├── AppPrefsSidebarComp.java │ │ │ │ │ ├── AppPrefsStorageHandler.java │ │ │ │ │ ├── CloseBehaviour.java │ │ │ │ │ ├── CloseBehaviourDialog.java │ │ │ │ │ ├── ConnectionHubCategory.java │ │ │ │ │ ├── DeveloperCategory.java │ │ │ │ │ ├── DisplayCategory.java │ │ │ │ │ ├── EditorCategory.java │ │ │ │ │ ├── ExternalApplicationHelper.java │ │ │ │ │ ├── ExternalApplicationType.java │ │ │ │ │ ├── ExternalEditorType.java │ │ │ │ │ ├── FileBrowserCategory.java │ │ │ │ │ ├── HibernateBehaviour.java │ │ │ │ │ ├── IconsCategory.java │ │ │ │ │ ├── LinksCategory.java │ │ │ │ │ ├── LoggingCategory.java │ │ │ │ │ ├── McpCategory.java │ │ │ │ │ ├── PasswordManagerCategory.java │ │ │ │ │ ├── PasswordManagerTestComp.java │ │ │ │ │ ├── PersonalizationCategory.java │ │ │ │ │ ├── RdpCategory.java │ │ │ │ │ ├── SecurityCategory.java │ │ │ │ │ ├── SshCategory.java │ │ │ │ │ ├── StartupBehaviour.java │ │ │ │ │ ├── SupportedLocale.java │ │ │ │ │ ├── SyncCategory.java │ │ │ │ │ ├── SyncMode.java │ │ │ │ │ ├── SystemCategory.java │ │ │ │ │ ├── TerminalCategory.java │ │ │ │ │ ├── ThirdPartyDependency.java │ │ │ │ │ ├── ThirdPartyDependencyListComp.java │ │ │ │ │ ├── TroubleshootCategory.java │ │ │ │ │ ├── UpdateCheckComp.java │ │ │ │ │ ├── UpdatesCategory.java │ │ │ │ │ ├── VaultAuthentication.java │ │ │ │ │ ├── VaultCategory.java │ │ │ │ │ ├── WorkspaceCreationDialog.java │ │ │ │ │ └── WorkspacesCategory.java │ │ │ │ ├── process/ │ │ │ │ │ ├── BaseElevationHandler.java │ │ │ │ │ ├── CommandBuilder.java │ │ │ │ │ ├── CommandConfiguration.java │ │ │ │ │ ├── CommandControl.java │ │ │ │ │ ├── CommandSupport.java │ │ │ │ │ ├── CommandView.java │ │ │ │ │ ├── CommandViewBase.java │ │ │ │ │ ├── CountDown.java │ │ │ │ │ ├── ElevationFunction.java │ │ │ │ │ ├── ElevationHandler.java │ │ │ │ │ ├── LocalProcessInputStream.java │ │ │ │ │ ├── LocalProcessOutputStream.java │ │ │ │ │ ├── LocalShell.java │ │ │ │ │ ├── NewLine.java │ │ │ │ │ ├── OsFileSystem.java │ │ │ │ │ ├── ParentSystemAccess.java │ │ │ │ │ ├── ProcessControl.java │ │ │ │ │ ├── ProcessExceptionConverter.java │ │ │ │ │ ├── ProcessOutputException.java │ │ │ │ │ ├── PropertiesFormatsParser.java │ │ │ │ │ ├── ScriptHelper.java │ │ │ │ │ ├── SecretReference.java │ │ │ │ │ ├── ShellControl.java │ │ │ │ │ ├── ShellDialect.java │ │ │ │ │ ├── ShellDialectAskpass.java │ │ │ │ │ ├── ShellDialects.java │ │ │ │ │ ├── ShellDumbMode.java │ │ │ │ │ ├── ShellLaunchCommand.java │ │ │ │ │ ├── ShellOpenFunction.java │ │ │ │ │ ├── ShellScript.java │ │ │ │ │ ├── ShellSecurityPolicy.java │ │ │ │ │ ├── ShellSpawnException.java │ │ │ │ │ ├── ShellStoreState.java │ │ │ │ │ ├── ShellTemp.java │ │ │ │ │ ├── ShellTerminalInitCommand.java │ │ │ │ │ ├── ShellTtyState.java │ │ │ │ │ ├── ShellView.java │ │ │ │ │ ├── StubShellControl.java │ │ │ │ │ ├── SudoCache.java │ │ │ │ │ ├── SystemState.java │ │ │ │ │ ├── TerminalInitFunction.java │ │ │ │ │ ├── TerminalInitScriptConfig.java │ │ │ │ │ ├── WorkingDirectoryFunction.java │ │ │ │ │ └── WrapperShellControl.java │ │ │ │ ├── pwman/ │ │ │ │ │ ├── BitwardenPasswordManager.java │ │ │ │ │ ├── DashlanePasswordManager.java │ │ │ │ │ ├── EnpassPasswordManager.java │ │ │ │ │ ├── KeePassXcAssociationComp.java │ │ │ │ │ ├── KeePassXcAssociationKey.java │ │ │ │ │ ├── KeePassXcPasswordManager.java │ │ │ │ │ ├── KeePassXcProxyClient.java │ │ │ │ │ ├── KeeperPasswordManager.java │ │ │ │ │ ├── LastpassPasswordManager.java │ │ │ │ │ ├── OnePasswordManager.java │ │ │ │ │ ├── PassboltPasswordManager.java │ │ │ │ │ ├── PasswordManager.java │ │ │ │ │ ├── PasswordManagerCommand.java │ │ │ │ │ ├── PasswordManagerCommandTemplate.java │ │ │ │ │ ├── PsonoPasswordManager.java │ │ │ │ │ ├── TweetNaClHelper.java │ │ │ │ │ └── WindowsCredentialManager.java │ │ │ │ ├── rdp/ │ │ │ │ │ ├── CustomRdpClient.java │ │ │ │ │ ├── DevolutionsRdpClient.java │ │ │ │ │ ├── ExternalRdpClient.java │ │ │ │ │ ├── FreeRdpClient.java │ │ │ │ │ ├── MstscRdpClient.java │ │ │ │ │ ├── RdpLaunchConfig.java │ │ │ │ │ ├── RemminaRdpClient.java │ │ │ │ │ ├── RemoteDesktopAppRdpClient.java │ │ │ │ │ └── WindowsAppRdpClient.java │ │ │ │ ├── secret/ │ │ │ │ │ ├── EncryptedValue.java │ │ │ │ │ ├── EncryptionKey.java │ │ │ │ │ ├── EncryptionToken.java │ │ │ │ │ ├── PasswordLockSecretValue.java │ │ │ │ │ ├── SecretCustomCommandStrategy.java │ │ │ │ │ ├── SecretInPlaceStrategy.java │ │ │ │ │ ├── SecretManager.java │ │ │ │ │ ├── SecretNoneStrategy.java │ │ │ │ │ ├── SecretPasswordManagerStrategy.java │ │ │ │ │ ├── SecretPromptStrategy.java │ │ │ │ │ ├── SecretQuery.java │ │ │ │ │ ├── SecretQueryFilter.java │ │ │ │ │ ├── SecretQueryFormatter.java │ │ │ │ │ ├── SecretQueryProgress.java │ │ │ │ │ ├── SecretQueryResult.java │ │ │ │ │ ├── SecretQueryState.java │ │ │ │ │ ├── SecretRetrievalStrategy.java │ │ │ │ │ ├── SecretStrategyChoiceConfig.java │ │ │ │ │ └── VaultKeySecretValue.java │ │ │ │ ├── spice/ │ │ │ │ │ ├── CustomSpiceClient.java │ │ │ │ │ ├── ExternalSpiceClient.java │ │ │ │ │ ├── RemoteViewerSpiceClient.java │ │ │ │ │ └── SpiceLaunchConfig.java │ │ │ │ ├── storage/ │ │ │ │ │ ├── ContextualFileReference.java │ │ │ │ │ ├── DataStateHandler.java │ │ │ │ │ ├── DataStorage.java │ │ │ │ │ ├── DataStorageGroupStrategy.java │ │ │ │ │ ├── DataStorageNode.java │ │ │ │ │ ├── DataStorageQuery.java │ │ │ │ │ ├── DataStorageSecret.java │ │ │ │ │ ├── DataStorageSyncHandler.java │ │ │ │ │ ├── DataStorageUserHandler.java │ │ │ │ │ ├── DataStoreCategory.java │ │ │ │ │ ├── DataStoreCategoryConfig.java │ │ │ │ │ ├── DataStoreColor.java │ │ │ │ │ ├── DataStoreEntry.java │ │ │ │ │ ├── DataStoreEntryRef.java │ │ │ │ │ ├── ImpersistentStorage.java │ │ │ │ │ ├── StandardStorage.java │ │ │ │ │ ├── StorageElement.java │ │ │ │ │ └── StorageListener.java │ │ │ │ ├── terminal/ │ │ │ │ │ ├── AlacrittyTerminalType.java │ │ │ │ │ ├── ClinkHelper.java │ │ │ │ │ ├── CmdTerminalType.java │ │ │ │ │ ├── ConfigFileTerminalPrompt.java │ │ │ │ │ ├── ControllableTerminalSession.java │ │ │ │ │ ├── CustomTerminalType.java │ │ │ │ │ ├── ExternalTerminalType.java │ │ │ │ │ ├── FootTerminalType.java │ │ │ │ │ ├── GhosttyTerminalType.java │ │ │ │ │ ├── GnomeConsoleType.java │ │ │ │ │ ├── GnomeTerminalType.java │ │ │ │ │ ├── ITerm2TerminalType.java │ │ │ │ │ ├── KittyTerminalType.java │ │ │ │ │ ├── KonsoleTerminalType.java │ │ │ │ │ ├── MacOsTerminalType.java │ │ │ │ │ ├── MobaXTermTerminalType.java │ │ │ │ │ ├── OhMyPoshTerminalPrompt.java │ │ │ │ │ ├── OhMyZshTerminalPrompt.java │ │ │ │ │ ├── PowerShellTerminalType.java │ │ │ │ │ ├── PtyxisTerminalType.java │ │ │ │ │ ├── PwshTerminalType.java │ │ │ │ │ ├── ScreenTerminalMultiplexer.java │ │ │ │ │ ├── SecureCrtTerminalType.java │ │ │ │ │ ├── StarshipTerminalPrompt.java │ │ │ │ │ ├── TabbyTerminalType.java │ │ │ │ │ ├── TerminalDockBrowserComp.java │ │ │ │ │ ├── TerminalDockHubComp.java │ │ │ │ │ ├── TerminalDockHubManager.java │ │ │ │ │ ├── TerminalDockMode.java │ │ │ │ │ ├── TerminalDockView.java │ │ │ │ │ ├── TerminalLaunch.java │ │ │ │ │ ├── TerminalLaunchConfiguration.java │ │ │ │ │ ├── TerminalLaunchRequest.java │ │ │ │ │ ├── TerminalLaunchResult.java │ │ │ │ │ ├── TerminalLauncher.java │ │ │ │ │ ├── TerminalLauncherManager.java │ │ │ │ │ ├── TerminalMultiplexer.java │ │ │ │ │ ├── TerminalMultiplexerManager.java │ │ │ │ │ ├── TerminalOpenFormat.java │ │ │ │ │ ├── TerminalPaneConfiguration.java │ │ │ │ │ ├── TerminalPrompt.java │ │ │ │ │ ├── TerminalPromptManager.java │ │ │ │ │ ├── TerminalProxyManager.java │ │ │ │ │ ├── TerminalSplitStrategy.java │ │ │ │ │ ├── TerminalView.java │ │ │ │ │ ├── TermiusTerminalType.java │ │ │ │ │ ├── TmuxTerminalMultiplexer.java │ │ │ │ │ ├── TrackableTerminalType.java │ │ │ │ │ ├── WarpTerminalType.java │ │ │ │ │ ├── WaveTerminalType.java │ │ │ │ │ ├── WezTerminalType.java │ │ │ │ │ ├── WindowsTerminalSession.java │ │ │ │ │ ├── WindowsTerminalType.java │ │ │ │ │ ├── XShellTerminalType.java │ │ │ │ │ ├── YakuakeTerminalType.java │ │ │ │ │ └── ZellijTerminalMultiplexer.java │ │ │ │ ├── test/ │ │ │ │ │ ├── ExtensionTest.java │ │ │ │ │ ├── LocalExtensionTest.java │ │ │ │ │ └── TestModule.java │ │ │ │ ├── update/ │ │ │ │ │ ├── AppDistributionType.java │ │ │ │ │ ├── AppDownloads.java │ │ │ │ │ ├── AppInstaller.java │ │ │ │ │ ├── AppRelease.java │ │ │ │ │ ├── ChocoUpdater.java │ │ │ │ │ ├── CommandUpdater.java │ │ │ │ │ ├── GitHubUpdater.java │ │ │ │ │ ├── PortableUpdater.java │ │ │ │ │ ├── UpdateAvailableDialog.java │ │ │ │ │ ├── UpdateChangelogDialog.java │ │ │ │ │ ├── UpdateHandler.java │ │ │ │ │ ├── UpdateNagDialog.java │ │ │ │ │ ├── WebtopUpdater.java │ │ │ │ │ └── WingetUpdater.java │ │ │ │ ├── util/ │ │ │ │ │ ├── AppJacksonModule.java │ │ │ │ │ ├── AskpassAlert.java │ │ │ │ │ ├── AsktextAlert.java │ │ │ │ │ ├── BooleanScope.java │ │ │ │ │ ├── CommandDialog.java │ │ │ │ │ ├── DataStoreFormatter.java │ │ │ │ │ ├── Deobfuscator.java │ │ │ │ │ ├── DesktopHelper.java │ │ │ │ │ ├── DesktopShortcuts.java │ │ │ │ │ ├── DocumentationLink.java │ │ │ │ │ ├── FileBridge.java │ │ │ │ │ ├── FileOpener.java │ │ │ │ │ ├── FileReference.java │ │ │ │ │ ├── FixedSizeInputStream.java │ │ │ │ │ ├── FlatpakCache.java │ │ │ │ │ ├── GithubReleaseDownloader.java │ │ │ │ │ ├── GlobalTimer.java │ │ │ │ │ ├── GroupFile.java │ │ │ │ │ ├── HostHelper.java │ │ │ │ │ ├── HttpHelper.java │ │ │ │ │ ├── HumanReadableFormat.java │ │ │ │ │ ├── Hyperlinks.java │ │ │ │ │ ├── IniFile.java │ │ │ │ │ ├── LicenseProvider.java │ │ │ │ │ ├── LicenseRequiredException.java │ │ │ │ │ ├── LicensedFeature.java │ │ │ │ │ ├── LocalExec.java │ │ │ │ │ ├── LocalFileTracker.java │ │ │ │ │ ├── ModuleAccess.java │ │ │ │ │ ├── ObservableSubscriber.java │ │ │ │ │ ├── PasswdFile.java │ │ │ │ │ ├── RdpConfig.java │ │ │ │ │ ├── Rect.java │ │ │ │ │ ├── RemminaHelper.java │ │ │ │ │ ├── ScanDialog.java │ │ │ │ │ ├── ScanDialogAction.java │ │ │ │ │ ├── ScanDialogBase.java │ │ │ │ │ ├── ScanMultiDialogComp.java │ │ │ │ │ ├── ScanSingleDialogComp.java │ │ │ │ │ ├── SimpleFilterInputStream.java │ │ │ │ │ ├── SshLocalBridge.java │ │ │ │ │ ├── StoreStateFormat.java │ │ │ │ │ ├── ThreadHelper.java │ │ │ │ │ ├── Translatable.java │ │ │ │ │ ├── User32Ex.java │ │ │ │ │ ├── Validators.java │ │ │ │ │ └── WindowsRegistry.java │ │ │ │ └── vnc/ │ │ │ │ ├── CustomVncClient.java │ │ │ │ ├── ExternalVncClient.java │ │ │ │ ├── InternalVncClient.java │ │ │ │ ├── RealVncClient.java │ │ │ │ ├── RemminaVncClient.java │ │ │ │ ├── RemoteViewerVncClient.java │ │ │ │ ├── ScreenSharingVncClient.java │ │ │ │ ├── TigerVncClient.java │ │ │ │ ├── TightVncClient.java │ │ │ │ ├── VncBaseStore.java │ │ │ │ ├── VncCategory.java │ │ │ │ └── VncLaunchConfig.java │ │ │ └── module-info.java │ │ └── resources/ │ │ └── io/ │ │ └── xpipe/ │ │ └── app/ │ │ └── resources/ │ │ ├── file_list.txt │ │ ├── folder_list.txt │ │ ├── font-config/ │ │ │ └── font.css │ │ ├── mcp/ │ │ │ ├── create_directory.json │ │ │ ├── create_file.json │ │ │ ├── find_file.json │ │ │ ├── get_file_info.json │ │ │ ├── help.json │ │ │ ├── list_files.json │ │ │ ├── list_systems.json │ │ │ ├── open_terminal.json │ │ │ ├── open_terminal_inline.json │ │ │ ├── read_file.json │ │ │ ├── run_command.json │ │ │ ├── run_script.json │ │ │ ├── toggle_state.json │ │ │ └── write_file.json │ │ ├── misc/ │ │ │ ├── antivirus.md │ │ │ ├── eula.md │ │ │ ├── github-markdown-dark.css │ │ │ ├── github-markdown-light.css │ │ │ ├── notes_default.md │ │ │ ├── report_privacy_policy.md │ │ │ ├── vault.md │ │ │ ├── vault_empty.md │ │ │ └── welcome.md │ │ ├── style/ │ │ │ ├── about.css │ │ │ ├── alert.css │ │ │ ├── bookmark.css │ │ │ ├── browser.css │ │ │ ├── category.css │ │ │ ├── choice-comp.css │ │ │ ├── color-box.css │ │ │ ├── data-store-list-choice-comp.css │ │ │ ├── dialog-comp.css │ │ │ ├── dropdown-comp.css │ │ │ ├── error-handler-comp.css │ │ │ ├── file-drop-augment.css │ │ │ ├── frame.css │ │ │ ├── header-bars.css │ │ │ ├── intro.css │ │ │ ├── lazy-text-field-comp.css │ │ │ ├── modal-overlay-comp.css │ │ │ ├── options-comp.css │ │ │ ├── popover.css │ │ │ ├── popup-menu.css │ │ │ ├── prefs.css │ │ │ ├── scrollbar.css │ │ │ ├── side-split-pane-comp.css │ │ │ ├── sidebar-comp.css │ │ │ ├── store-entry-comp.css │ │ │ ├── store-mini-section.css │ │ │ ├── style.css │ │ │ ├── third-party.css │ │ │ ├── tile-button-comp.css │ │ │ ├── toggle-switch-comp.css │ │ │ └── tooltip.css │ │ └── theme/ │ │ ├── cupertinoDark.css │ │ ├── cupertinoLight.css │ │ ├── custom.css │ │ ├── dark.css │ │ ├── dracula.css │ │ ├── light.css │ │ ├── mocha.css │ │ ├── nordDark.css │ │ └── nordLight.css │ └── test/ │ └── java/ │ └── Test.java ├── beacon/ │ ├── README.md │ ├── build.gradle │ ├── publish.gradle │ └── src/ │ └── main/ │ ├── java/ │ │ ├── io/ │ │ │ └── xpipe/ │ │ │ └── beacon/ │ │ │ ├── BeaconAuthMethod.java │ │ │ ├── BeaconClient.java │ │ │ ├── BeaconClientErrorResponse.java │ │ │ ├── BeaconClientException.java │ │ │ ├── BeaconClientInformation.java │ │ │ ├── BeaconConfig.java │ │ │ ├── BeaconConnectorException.java │ │ │ ├── BeaconInterface.java │ │ │ ├── BeaconJacksonModule.java │ │ │ ├── BeaconServer.java │ │ │ ├── BeaconServerErrorResponse.java │ │ │ ├── BeaconServerException.java │ │ │ └── api/ │ │ │ ├── ActionExchange.java │ │ │ ├── AskpassExchange.java │ │ │ ├── CategoryAddExchange.java │ │ │ ├── CategoryInfoExchange.java │ │ │ ├── CategoryQueryExchange.java │ │ │ ├── CategoryRemoveExchange.java │ │ │ ├── ConnectionAddExchange.java │ │ │ ├── ConnectionInfoExchange.java │ │ │ ├── ConnectionQueryExchange.java │ │ │ ├── ConnectionRefreshExchange.java │ │ │ ├── ConnectionRemoveExchange.java │ │ │ ├── DaemonFocusExchange.java │ │ │ ├── DaemonModeExchange.java │ │ │ ├── DaemonOpenExchange.java │ │ │ ├── DaemonStatusExchange.java │ │ │ ├── DaemonStopExchange.java │ │ │ ├── DaemonVersionExchange.java │ │ │ ├── FsBlobExchange.java │ │ │ ├── FsReadExchange.java │ │ │ ├── FsScriptExchange.java │ │ │ ├── FsWriteExchange.java │ │ │ ├── HandshakeExchange.java │ │ │ ├── SecretDecryptExchange.java │ │ │ ├── SecretEncryptExchange.java │ │ │ ├── ShellExecExchange.java │ │ │ ├── ShellStartExchange.java │ │ │ ├── ShellStopExchange.java │ │ │ ├── SshLaunchExchange.java │ │ │ ├── TerminalExternalLaunchExchange.java │ │ │ ├── TerminalLaunchExchange.java │ │ │ ├── TerminalPrepareExchange.java │ │ │ ├── TerminalRegisterExchange.java │ │ │ └── TerminalWaitExchange.java │ │ └── module-info.java │ └── resources/ │ └── META-INF/ │ └── services/ │ └── io.xpipe.core.ModuleLayerLoader ├── build.gradle ├── core/ │ ├── README.md │ ├── build.gradle │ ├── publish.gradle │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ ├── io/ │ │ │ │ └── xpipe/ │ │ │ │ └── core/ │ │ │ │ ├── AesSecretValue.java │ │ │ │ ├── CoreJacksonModule.java │ │ │ │ ├── EncryptedSecretValue.java │ │ │ │ ├── FailableBiFunction.java │ │ │ │ ├── FailableConsumer.java │ │ │ │ ├── FailableFunction.java │ │ │ │ ├── FailableRunnable.java │ │ │ │ ├── FailableSupplier.java │ │ │ │ ├── FilePath.java │ │ │ │ ├── InPlaceSecretValue.java │ │ │ │ ├── JacksonMapper.java │ │ │ │ ├── KeyValue.java │ │ │ │ ├── ModuleLayerLoader.java │ │ │ │ ├── OsType.java │ │ │ │ ├── SecretValue.java │ │ │ │ ├── StorePath.java │ │ │ │ ├── StreamCharset.java │ │ │ │ ├── UuidHelper.java │ │ │ │ └── XPipeDaemonMode.java │ │ │ └── module-info.java │ │ └── resources/ │ │ └── META-INF/ │ │ └── services/ │ │ ├── com.fasterxml.jackson.databind.Module │ │ └── io.xpipe.core.ModuleLayerLoader │ └── test/ │ └── java/ │ ├── io/ │ │ └── xpipe/ │ │ └── core/ │ │ └── test/ │ │ └── StorePathTest.java │ └── module-info.java ├── dist/ │ ├── base.gradle │ ├── build.gradle │ ├── changelog/ │ │ ├── 1.0.0.md │ │ ├── 1.0.1.md │ │ ├── 1.1.0.md │ │ ├── 1.1.1.md │ │ ├── 1.1.2.md │ │ ├── 1.1.3.md │ │ ├── 1.2.0.md │ │ ├── 1.3.0.md │ │ ├── 1.3.1.md │ │ ├── 1.3.2.md │ │ ├── 1.4.0.md │ │ ├── 1.4.1.md │ │ ├── 1.4.2.md │ │ ├── 1.5.0.md │ │ ├── 1.5.1.md │ │ ├── 1.5.2.md │ │ ├── 1.5.3.md │ │ ├── 1.6.0.md │ │ ├── 1.7.0.md │ │ ├── 1.7.1.md │ │ ├── 1.7.10.md │ │ ├── 1.7.11.md │ │ ├── 1.7.12.md │ │ ├── 1.7.13.md │ │ ├── 1.7.14.md │ │ ├── 1.7.15.md │ │ ├── 1.7.16.md │ │ ├── 1.7.2.md │ │ ├── 1.7.3.md │ │ ├── 1.7.4.md │ │ ├── 1.7.5.md │ │ ├── 1.7.6.md │ │ ├── 1.7.7.md │ │ ├── 1.7.8.md │ │ ├── 1.7.9.md │ │ ├── 10.0.1_incremental.md │ │ ├── 10.0.2_incremental.md │ │ ├── 10.0.3_incremental.md │ │ ├── 10.0.4_incremental.md │ │ ├── 10.0.md │ │ ├── 10.1.1.md │ │ ├── 10.1.1_incremental.md │ │ ├── 10.1.md │ │ ├── 10.1_incremental.md │ │ ├── 10.2.1.md │ │ ├── 10.2.1_incremental.md │ │ ├── 10.2.2.md │ │ ├── 10.2.2_incremental.md │ │ ├── 10.2.md │ │ ├── 10.2_incremental.md │ │ ├── 11.0.md │ │ ├── 11.1.md │ │ ├── 11.1_incremental.md │ │ ├── 11.2.md │ │ ├── 11.2_incremental.md │ │ ├── 11.3.md │ │ ├── 11.3_incremental.md │ │ ├── 12.0.md │ │ ├── 12.1.md │ │ ├── 12.1_incremental.md │ │ ├── 12.2.md │ │ ├── 12.2_incremental.md │ │ ├── 12.3.1.md │ │ ├── 12.3.1_incremental.md │ │ ├── 12.3.2.md │ │ ├── 12.3.2_incremental.md │ │ ├── 12.3.3.md │ │ ├── 12.3.3_incremental.md │ │ ├── 12.3.4.md │ │ ├── 12.3.4_incremental.md │ │ ├── 12.3.5_incremental.md │ │ ├── 12.3.md │ │ ├── 12.3_incremental.md │ │ ├── 13.0.1.md │ │ ├── 13.0.1_incremental.md │ │ ├── 13.0.md │ │ ├── 13.1.1.md │ │ ├── 13.1.1_incremental.md │ │ ├── 13.1.md │ │ ├── 13.1_incremental.md │ │ ├── 13.2.md │ │ ├── 13.2_incremental.md │ │ ├── 13.3.1.md │ │ ├── 13.3.1_incremental.md │ │ ├── 13.3.2.md │ │ ├── 13.3.2_incremental.md │ │ ├── 13.3.md │ │ ├── 13.3_incremental.md │ │ ├── 13.4.1.md │ │ ├── 13.4.1_incremental.md │ │ ├── 13.4.2.md │ │ ├── 13.4.2_incremental.md │ │ ├── 13.4.3.md │ │ ├── 13.4.3_incremental.md │ │ ├── 13.4.4.md │ │ ├── 13.4.4_incremental.md │ │ ├── 13.4.md │ │ ├── 13.4_incremental.md │ │ ├── 14.0.md │ │ ├── 14.1.1.md │ │ ├── 14.1.1_incremental.md │ │ ├── 14.1.md │ │ ├── 14.1_incremental.md │ │ ├── 14.2.md │ │ ├── 14.2_incremental.md │ │ ├── 15.0.1.md │ │ ├── 15.0.1_incremental.md │ │ ├── 15.0.2.md │ │ ├── 15.0.2_incremental.md │ │ ├── 15.0.md │ │ ├── 15.1.md │ │ ├── 15.1_incremental.md │ │ ├── 15.2.md │ │ ├── 15.2_incremental.md │ │ ├── 15.3.md │ │ ├── 15.3_incremental.md │ │ ├── 15.4.md │ │ ├── 15.4_incremental.md │ │ ├── 15.5.md │ │ ├── 15.5_incremental.md │ │ ├── 15.6.md │ │ ├── 15.6_incremental.md │ │ ├── 15.7.1.md │ │ ├── 15.7.1_incremental.md │ │ ├── 15.7.md │ │ ├── 15.7_incremental.md │ │ ├── 15.8.md │ │ ├── 15.8_incremental.md │ │ ├── 16.0.md │ │ ├── 16.1.md │ │ ├── 16.1_incremental.md │ │ ├── 16.2.1.md │ │ ├── 16.2.1_incremental.md │ │ ├── 16.2.md │ │ ├── 16.2_incremental.md │ │ ├── 16.3.md │ │ ├── 16.3_incremental.md │ │ ├── 16.4.1.md │ │ ├── 16.4.1_incremental.md │ │ ├── 16.4.md │ │ ├── 16.4_incremental.md │ │ ├── 16.5.md │ │ ├── 16.5_incremental.md │ │ ├── 16.6.md │ │ ├── 16.6_incremental.md │ │ ├── 16.7.md │ │ ├── 16.7_incremental.md │ │ ├── 17.0.md │ │ ├── 17.1.md │ │ ├── 17.1_incremental.md │ │ ├── 17.2.md │ │ ├── 17.2_incremental.md │ │ ├── 17.3.md │ │ ├── 17.3_incremental.md │ │ ├── 17.4.md │ │ ├── 17.4_incremental.md │ │ ├── 17.5.md │ │ ├── 17.5_incremental.md │ │ ├── 18.0.1.md │ │ ├── 18.0.1_incremental.md │ │ ├── 18.0.md │ │ ├── 18.1.1.md │ │ ├── 18.1.1_incremental.md │ │ ├── 18.1.md │ │ ├── 18.1_incremental.md │ │ ├── 18.2.md │ │ ├── 18.2_incremental.md │ │ ├── 18.3.md │ │ ├── 18.3_incremental.md │ │ ├── 18.4.md │ │ ├── 18.4_incremental.md │ │ ├── 18.5.md │ │ ├── 18.5_incremental.md │ │ ├── 18.6.md │ │ ├── 18.6_incremental.md │ │ ├── 18.7.md │ │ ├── 18.7_incremental.md │ │ ├── 19.0.1.md │ │ ├── 19.0.2_incremental.md │ │ ├── 19.0.md │ │ ├── 19.1_incremental.md │ │ ├── 19.2_incremental.md │ │ ├── 19.3.1_incremental.md │ │ ├── 19.3_incremental.md │ │ ├── 19.4_incremental.md │ │ ├── 19.5_incremental.md │ │ ├── 19.6_incremental.md │ │ ├── 20.0.1.md │ │ ├── 20.0.md │ │ ├── 20.1.md │ │ ├── 20.2.md │ │ ├── 20.3.md │ │ ├── 20.4.md │ │ ├── 21.0.md │ │ ├── 21.1.1.md │ │ ├── 21.1.md │ │ ├── 21.2.1.md │ │ ├── 21.2.md │ │ ├── 21.3.md │ │ ├── 21.4.md │ │ ├── 21.5.md │ │ ├── 21.6.md │ │ ├── 8.0-6.md │ │ ├── 8.0.1.md │ │ ├── 8.0.md │ │ ├── 8.1.md │ │ ├── 8.1_incremental.md │ │ ├── 8.2.md │ │ ├── 8.2_incremental.md │ │ ├── 8.3.md │ │ ├── 8.3_incremental.md │ │ ├── 8.4.md │ │ ├── 8.4_incremental.md │ │ ├── 8.5.md │ │ ├── 8.5_incremental.md │ │ ├── 8.6.md │ │ ├── 8.6_incremental.md │ │ ├── 9.0.1.md │ │ ├── 9.0.1_incremental.md │ │ ├── 9.0.md │ │ ├── 9.1.md │ │ ├── 9.1_incremental.md │ │ ├── 9.2.md │ │ ├── 9.2_incremental.md │ │ ├── 9.3.md │ │ ├── 9.3_incremental.md │ │ ├── 9.4.1.md │ │ ├── 9.4.1_incremental.md │ │ ├── 9.4.md │ │ └── 9.4_incremental.md │ ├── debug/ │ │ ├── debug_arguments.txt │ │ ├── linux/ │ │ │ └── xpiped_debug.sh │ │ ├── mac/ │ │ │ └── xpiped_debug.sh │ │ └── windows/ │ │ └── xpiped_debug.bat │ ├── fonts/ │ │ ├── allfonts.properties │ │ └── logicalfonts.properties │ ├── jpackage/ │ │ └── Info.plist │ ├── jpackage.gradle │ ├── licenses/ │ │ ├── antlr.license │ │ ├── antlr.properties │ │ ├── atlantafx.license │ │ ├── atlantafx.properties │ │ ├── bc-java.license │ │ ├── bc-java.properties │ │ ├── commons-io.license │ │ ├── commons-io.properties │ │ ├── commons-lang.license │ │ ├── commons-lang.properties │ │ ├── flexmark.license │ │ ├── flexmark.properties │ │ ├── fx-builders.license │ │ ├── fx-builders.properties │ │ ├── github-markdown-css.license │ │ ├── github-markdown-css.properties │ │ ├── graalvm.license │ │ ├── graalvm.properties │ │ ├── ikonli.license │ │ ├── ikonli.properties │ │ ├── inter.license │ │ ├── inter.properties │ │ ├── jackson.license │ │ ├── jackson.properties │ │ ├── java-annotations.license │ │ ├── java-annotations.properties │ │ ├── jna.license │ │ ├── jna.properties │ │ ├── json-schema-validator.license │ │ ├── json-schema-validator.properties │ │ ├── jsvg.license │ │ ├── jsvg.properties │ │ ├── lombok.license │ │ ├── lombok.properties │ │ ├── material2.license │ │ ├── material2.properties │ │ ├── materialdesign2.license │ │ ├── materialdesign2.properties │ │ ├── mcp-sdk.license │ │ ├── mcp-sdk.properties │ │ ├── musl.license │ │ ├── musl.properties │ │ ├── openjfx.license │ │ ├── openjfx.properties │ │ ├── picocli.license │ │ ├── picocli.properties │ │ ├── reactive-streams.license │ │ ├── reactive-streams.properties │ │ ├── reactor-core.license │ │ ├── reactor-core.properties │ │ ├── roboto.license │ │ ├── roboto.properties │ │ ├── sentry.license │ │ ├── sentry.properties │ │ ├── slf4j.license │ │ ├── slf4j.properties │ │ ├── stringtemplate.license │ │ ├── stringtemplate.properties │ │ ├── validatorfx.license │ │ ├── validatorfx.properties │ │ ├── vernacular-vnc.license │ │ ├── vernacular-vnc.properties │ │ ├── vscode-icons.license │ │ ├── vscode-icons.properties │ │ ├── zlib.license │ │ └── zlib.properties │ └── logo/ │ ├── Assets.car │ ├── logo.icns │ └── logo_composer.icon/ │ └── icon.json ├── ext/ │ ├── base/ │ │ ├── build.gradle │ │ └── src/ │ │ └── main/ │ │ ├── java/ │ │ │ ├── io/ │ │ │ │ └── xpipe/ │ │ │ │ └── ext/ │ │ │ │ └── base/ │ │ │ │ ├── desktop/ │ │ │ │ │ ├── DesktopApplicationStore.java │ │ │ │ │ ├── DesktopApplicationStoreProvider.java │ │ │ │ │ └── DesktopBaseStore.java │ │ │ │ ├── host/ │ │ │ │ │ ├── AbstractHostCreationActionProvider.java │ │ │ │ │ ├── AbstractHostStore.java │ │ │ │ │ ├── AbstractHostStoreProvider.java │ │ │ │ │ ├── AbstractHostTransformStore.java │ │ │ │ │ ├── HostAddressChoice.java │ │ │ │ │ ├── HostAddressChoiceComp.java │ │ │ │ │ ├── HostAddressGatewayStore.java │ │ │ │ │ ├── HostAddressIdentityStore.java │ │ │ │ │ ├── HostAddressStore.java │ │ │ │ │ ├── HostAddressSwitchBranchProvider.java │ │ │ │ │ ├── HostAddressSwitchStore.java │ │ │ │ │ └── HostAddressTunnelStore.java │ │ │ │ ├── identity/ │ │ │ │ │ ├── IdentityApplyDialog.java │ │ │ │ │ ├── IdentityApplyHubLeafProvider.java │ │ │ │ │ ├── IdentityChoiceBuilder.java │ │ │ │ │ ├── IdentityMigrationDeserializer.java │ │ │ │ │ ├── IdentitySelectComp.java │ │ │ │ │ ├── IdentityStore.java │ │ │ │ │ ├── IdentityStoreProvider.java │ │ │ │ │ ├── IdentitySummary.java │ │ │ │ │ ├── IdentitySwitchStore.java │ │ │ │ │ ├── IdentityValue.java │ │ │ │ │ ├── LocalIdentityConvertHubLeafProvider.java │ │ │ │ │ ├── LocalIdentityStore.java │ │ │ │ │ ├── LocalIdentityStoreProvider.java │ │ │ │ │ ├── PasswordManagerIdentityStore.java │ │ │ │ │ ├── PasswordManagerIdentityStoreProvider.java │ │ │ │ │ ├── SyncedIdentityStore.java │ │ │ │ │ ├── SyncedIdentityStoreProvider.java │ │ │ │ │ ├── UsernameStrategy.java │ │ │ │ │ └── ssh/ │ │ │ │ │ ├── CustomAgentStrategy.java │ │ │ │ │ ├── CustomPkcs11LibraryStrategy.java │ │ │ │ │ ├── GpgAgentStrategy.java │ │ │ │ │ ├── InPlaceKeyStrategy.java │ │ │ │ │ ├── KeyFileStrategy.java │ │ │ │ │ ├── NoIdentityStrategy.java │ │ │ │ │ ├── OpenSshAgentStrategy.java │ │ │ │ │ ├── OtherExternalAgentStrategy.java │ │ │ │ │ ├── PageantStrategy.java │ │ │ │ │ ├── SshIdentityStateManager.java │ │ │ │ │ ├── SshIdentityStrategy.java │ │ │ │ │ ├── SshIdentityStrategyChoiceConfig.java │ │ │ │ │ └── YubikeyPivStrategy.java │ │ │ │ ├── script/ │ │ │ │ │ ├── PredefinedScriptStore.java │ │ │ │ │ ├── RunBackgroundScriptActionProvider.java │ │ │ │ │ ├── RunFileScriptMenuProvider.java │ │ │ │ │ ├── RunHubBatchScriptActionProvider.java │ │ │ │ │ ├── RunHubScriptActionProvider.java │ │ │ │ │ ├── RunScriptActionProviderMenu.java │ │ │ │ │ ├── RunTerminalScriptActionProvider.java │ │ │ │ │ ├── ScriptCollectionSource.java │ │ │ │ │ ├── ScriptCollectionSourceBrowseActionProvider.java │ │ │ │ │ ├── ScriptCollectionSourceEntry.java │ │ │ │ │ ├── ScriptCollectionSourceImportDialog.java │ │ │ │ │ ├── ScriptCollectionSourceImportHubProvider.java │ │ │ │ │ ├── ScriptCollectionSourceRefreshHubProvider.java │ │ │ │ │ ├── ScriptCollectionSourceStore.java │ │ │ │ │ ├── ScriptCollectionSourceStoreProvider.java │ │ │ │ │ ├── ScriptDataStorageProvider.java │ │ │ │ │ ├── ScriptDialects.java │ │ │ │ │ ├── ScriptHierarchy.java │ │ │ │ │ ├── ScriptQuickEditHubLeafProvider.java │ │ │ │ │ ├── ScriptStore.java │ │ │ │ │ ├── ScriptStoreMigrationDeserializer.java │ │ │ │ │ ├── ScriptStoreProvider.java │ │ │ │ │ ├── ScriptStoreSetup.java │ │ │ │ │ ├── ScriptTextSource.java │ │ │ │ │ └── ScriptUrlSourceRefreshHubProvider.java │ │ │ │ ├── service/ │ │ │ │ │ ├── AbstractServiceGroupStore.java │ │ │ │ │ ├── AbstractServiceGroupStoreProvider.java │ │ │ │ │ ├── AbstractServiceStore.java │ │ │ │ │ ├── AbstractServiceStoreProvider.java │ │ │ │ │ ├── CustomServiceGroupStore.java │ │ │ │ │ ├── CustomServiceGroupStoreProvider.java │ │ │ │ │ ├── CustomServiceStore.java │ │ │ │ │ ├── CustomServiceStoreProvider.java │ │ │ │ │ ├── FixedServiceCreatorStore.java │ │ │ │ │ ├── FixedServiceGroupStore.java │ │ │ │ │ ├── FixedServiceGroupStoreProvider.java │ │ │ │ │ ├── FixedServiceStore.java │ │ │ │ │ ├── FixedServiceStoreProvider.java │ │ │ │ │ ├── MappedServiceStore.java │ │ │ │ │ ├── MappedServiceStoreProvider.java │ │ │ │ │ ├── ServiceAddressRotation.java │ │ │ │ │ ├── ServiceCopyAddressHubLeafProvider.java │ │ │ │ │ ├── ServiceProtocolType.java │ │ │ │ │ ├── ServiceProtocolTypeHelper.java │ │ │ │ │ └── ServiceRefreshHubProvider.java │ │ │ │ └── store/ │ │ │ │ ├── PauseableStore.java │ │ │ │ ├── ShellStoreProvider.java │ │ │ │ ├── StartableStore.java │ │ │ │ ├── StoppableStore.java │ │ │ │ ├── StorePauseActionProvider.java │ │ │ │ ├── StoreRestartActionProvider.java │ │ │ │ ├── StoreStartActionProvider.java │ │ │ │ └── StoreStopActionProvider.java │ │ │ └── module-info.java │ │ └── resources/ │ │ └── io/ │ │ └── xpipe/ │ │ └── ext/ │ │ └── base/ │ │ └── resources/ │ │ ├── extension.properties │ │ └── scripts/ │ │ ├── apt_upgrade.sh │ │ ├── clink.bat │ │ ├── crlf_to_lf.sh │ │ ├── diff.sh │ │ ├── git_config.sh │ │ └── system_health.sh │ ├── proc/ │ │ ├── build.gradle │ │ └── src/ │ │ └── main/ │ │ └── java/ │ │ └── module-info.java │ ├── system/ │ │ ├── build.gradle │ │ └── src/ │ │ └── main/ │ │ ├── java/ │ │ │ ├── io/ │ │ │ │ └── xpipe/ │ │ │ │ └── ext/ │ │ │ │ └── system/ │ │ │ │ ├── incus/ │ │ │ │ │ ├── IncusCommandView.java │ │ │ │ │ ├── IncusContainerActionProviderMenu.java │ │ │ │ │ ├── IncusContainerConsoleActionProvider.java │ │ │ │ │ ├── IncusContainerEditConfigActionProvider.java │ │ │ │ │ ├── IncusContainerEditRunConfigActionProvider.java │ │ │ │ │ ├── IncusContainerStore.java │ │ │ │ │ ├── IncusContainerStoreProvider.java │ │ │ │ │ ├── IncusInstallStore.java │ │ │ │ │ ├── IncusInstallStoreProvider.java │ │ │ │ │ └── IncusScanProvider.java │ │ │ │ ├── lxd/ │ │ │ │ │ ├── LxdCmdStore.java │ │ │ │ │ ├── LxdCmdStoreProvider.java │ │ │ │ │ ├── LxdCommandView.java │ │ │ │ │ ├── LxdContainerActionProviderMenu.java │ │ │ │ │ ├── LxdContainerConsoleActionProvider.java │ │ │ │ │ ├── LxdContainerEditConfigActionProvider.java │ │ │ │ │ ├── LxdContainerEditRunConfigActionProvider.java │ │ │ │ │ ├── LxdContainerStore.java │ │ │ │ │ ├── LxdContainerStoreProvider.java │ │ │ │ │ └── LxdScanProvider.java │ │ │ │ └── podman/ │ │ │ │ ├── PodmanCmdStore.java │ │ │ │ ├── PodmanCmdStoreProvider.java │ │ │ │ ├── PodmanCommandView.java │ │ │ │ ├── PodmanContainerActionProviderMenu.java │ │ │ │ ├── PodmanContainerAttachActionProvider.java │ │ │ │ ├── PodmanContainerInspectActionProvider.java │ │ │ │ ├── PodmanContainerLogsActionProvider.java │ │ │ │ ├── PodmanContainerStore.java │ │ │ │ ├── PodmanContainerStoreProvider.java │ │ │ │ └── PodmanScanProvider.java │ │ │ └── module-info.java │ │ └── resources/ │ │ └── io/ │ │ └── xpipe/ │ │ └── ext/ │ │ └── system/ │ │ └── resources/ │ │ └── extension.properties │ └── uacc/ │ ├── build.gradle │ └── src/ │ └── main/ │ └── java/ │ └── module-info.java ├── get-xpipe.ps1 ├── get-xpipe.sh ├── gradle/ │ ├── gradle_scripts/ │ │ ├── README.md │ │ ├── atlantafx-base-2.0.2.jar │ │ ├── dev_default.properties │ │ ├── extension.gradle │ │ ├── fx-builders-1.0.0-SNAPSHOT.jar │ │ ├── java.gradle │ │ ├── javafx.gradle │ │ ├── jna.gradle │ │ ├── junit.gradle │ │ ├── local_junit_suite.gradle │ │ ├── lombok.gradle │ │ ├── modules.gradle │ │ ├── publish-base.gradle │ │ └── remote_junit_suite.gradle │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── lang/ │ ├── README.md │ ├── strings/ │ │ ├── fixed_en.properties │ │ ├── translations_da.properties │ │ ├── translations_de.properties │ │ ├── translations_en.properties │ │ ├── translations_es.properties │ │ ├── translations_fr.properties │ │ ├── translations_id.properties │ │ ├── translations_it.properties │ │ ├── translations_ja.properties │ │ ├── translations_ko.properties │ │ ├── translations_nl.properties │ │ ├── translations_pl.properties │ │ ├── translations_pt.properties │ │ ├── translations_ru.properties │ │ ├── translations_sv.properties │ │ ├── translations_tr.properties │ │ ├── translations_vi.properties │ │ ├── translations_zh-Hans.properties │ │ └── translations_zh-Hant.properties │ └── texts/ │ ├── termiusSetup_da.md │ ├── termiusSetup_de.md │ ├── termiusSetup_en.md │ ├── termiusSetup_es.md │ ├── termiusSetup_fr.md │ ├── termiusSetup_id.md │ ├── termiusSetup_it.md │ ├── termiusSetup_ja.md │ ├── termiusSetup_ko.md │ ├── termiusSetup_nl.md │ ├── termiusSetup_pl.md │ ├── termiusSetup_pt.md │ ├── termiusSetup_ru.md │ ├── termiusSetup_sv.md │ ├── termiusSetup_tr.md │ ├── termiusSetup_vi.md │ ├── termiusSetup_zh-Hans.md │ ├── termiusSetup_zh-Hant.md │ ├── xshellSetup_da.md │ ├── xshellSetup_de.md │ ├── xshellSetup_en.md │ ├── xshellSetup_es.md │ ├── xshellSetup_fr.md │ ├── xshellSetup_id.md │ ├── xshellSetup_it.md │ ├── xshellSetup_ja.md │ ├── xshellSetup_ko.md │ ├── xshellSetup_nl.md │ ├── xshellSetup_pl.md │ ├── xshellSetup_pt.md │ ├── xshellSetup_ru.md │ ├── xshellSetup_sv.md │ ├── xshellSetup_tr.md │ ├── xshellSetup_vi.md │ ├── xshellSetup_zh-Hans.md │ └── xshellSetup_zh-Hant.md ├── settings.gradle └── version ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ * text=auto eol=lf *.sh text eol=lf *.bat text eol=crlf *.png binary *.xcf binary *.properties linguist-generated ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "gradle" directory: "/" schedule: interval: "daily" - package-ecosystem: github-actions directory: / schedule: interval: "daily" ================================================ FILE: .gitignore ================================================ .gradle/ build/ .idea/* !.idea/codeStyles !.idea/inspectionProfiles lib/ dev.properties extensions.txt dev_storage local/ local*/ local_*/ .vs .vscode obj out bin .DS_Store ComponentsGenerated.wxs !dist/javafx/**/lib !dist/javafx/**/bin xcuserdata/ *.dylib project.xcworkspace translations_patch ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement via [hello@xpipe.io](mailto:hello@xpipe.io). All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ================================================ FILE: CONTRIBUTING.md ================================================ # Development Any contribution is welcomed! There are no real formal contribution guidelines right now, they will maybe come later. ## Repository Structure - [core](core) - Shared core classes of the XPipe Java API, XPipe extensions, and the XPipe daemon implementation. This mainly concerns API classes not a lot of implementation. - [beacon](beacon) - The XPipe beacon component is responsible for handling all communications between the XPipe daemon and the client applications, for example APIs and the CLI - [app](app) - Contains the XPipe daemon implementation and the XPipe desktop application - [dist](dist) - Tools to create a distributable package of XPipe - [ext](ext) - Available XPipe extensions. Essentially every concrete feature implementation is implemented as an extension ## Development Setup You need to have JDK for Java 25 installed to compile the project. If you are on Linux or macOS, you can easily accomplish that by using [SDKMAN](https://sdkman.io/) and running ```bash curl -s "https://get.sdkman.io" | bash . "$HOME/.sdkman/bin/sdkman-init.sh" sdk install java 25.0.2-graalce sdk default java 25.0.2-graalce ``` On Windows, you have to manually install a JDK, e.g. from [Adoptium](https://adoptium.net/temurin/releases/?version=25). You can configure a few development options in the file `app/dev.properties` which will be automatically generated when gradle is first run. You need to have an up-to-date version of XPipe installed on your local system in order to properly run XPipe in a development environment. This is due to the fact that some components are only included in the release version and not in this repository. XPipe is able to automatically detect your local installation and fetch the required components from it when it is run in a development environment. To disable the local installation check, you can set the property `io.xpipe.app.locator.disableInstallationVersionCheck=false` in the file `app/dev.properties`. This allows it to start up even if there are version mismatches. Some things might not work as expected though. You can also use a local PTB installation instead of the stable release version by setting the property `io.xpipe.app.locator.usePtbInstallation=true` in the file `app/dev.properties`. Note that in case the current master branch is ahead of the latest release, it might happen that there are some incompatibilities when loading data from your local XPipe installation. You should therefore always check out the matching version tag for your local repository and local XPipe installation. You can find the available version tags at https://github.com/xpipe-io/xpipe/tags. So for example if you currently have XPipe `21.0` installed, you should run `git reset --hard 21.0` first to properly compile against it. ## Building and Running You can use the gradle wrapper to build and run the project: - `gradlew app:run` will run the desktop application. You can set various useful properties in `app/build.gradle` - `gradlew clean dist` will create a distributable production version in `dist/build/dist/base`. - `gradlew :test` will run the tests of the specified project. You are also able to properly debug the built production application: - The `dist/build/dist/base/app/scripts/xpiped_debug` script will launch the application in debug mode and with a console attached to it ## Modularity and IDEs All XPipe components target [Java 25](https://openjdk.java.net/projects/jdk/25/) and make full use of the Java Module System (JPMS). All components are modularized, including all their dependencies. In case a dependency is (sadly) not modularized yet, module information is manually added using [extra-java-module-info](https://github.com/gradlex-org/extra-java-module-info). Further, note that as this is a pretty complicated Java project that fully utilizes modularity, many IDEs still have problems building this project properly. For example, you can't build this project in eclipse or vscode as it will complain about missing modules. The tested and recommended IDE is IntelliJ. When setting up the project in IntelliJ, make sure that the correct JDK (Java 25) is selected both for the project and for gradle itself. ## Contributing guide Especially when starting out, it might be a good idea to start with easy tasks first. Here's a selection of suitable common tasks that are very easy to implement: ### Interacting via the HTTP API You can create clients that communicate with the XPipe daemon via its HTTP API. To get started, see the [OpenAPI spec](https://docs.xpipe.io/api). ### Implementing support for a new editor All code for handling external editors can be found [here](https://github.com/xpipe-io/xpipe/blob/master/app/src/main/java/io/xpipe/app/prefs/ExternalEditorType.java). There you will find plenty of working examples that you can use as a base for your own implementation. ### Implementing support for a new terminal All code for handling external terminals can be found [here](https://github.com/xpipe-io/xpipe/blob/master/app/src/main/java/io/xpipe/app/terminal/). There you will find plenty of working examples that you can use as a base for your own implementation. ### Adding more context menu actions in the file browser In case you want to implement your own actions for certain file types in the file browser, you can easily do so. You can find most existing actions [here](https://github.com/xpipe-io/xpipe/tree/master/ext/base/src/main/java/io/xpipe/ext/base/browser) to get some inspiration. Once you created your custom classes, you have to register them in your module info, just like [here](https://github.com/xpipe-io/xpipe/blob/master/ext/base/src/main/java/module-info.java). ### Implementing custom actions for the connection hub All actions that you can perform for certain connections in the connection overview tab are implemented using an [Action API](https://github.com/xpipe-io/xpipe/blob/master/app/src/main/java/io/xpipe/app/ext/ActionProvider.java). You can find a sample implementation [here](https://github.com/xpipe-io/xpipe/blob/master/ext/base/src/main/java/io/xpipe/ext/base/action/SampleAction.java) and many common action implementations [here](https://github.com/xpipe-io/xpipe/tree/master/ext/base/src/main/java/io/xpipe/ext/base/action). ### Adding more predefined scripts You can add custom script definitions [here](https://github.com/xpipe-io/xpipe/tree/master/ext/base/src/main/java/io/xpipe/ext/base/script/PredefinedScriptStore.java) and [here](https://github.com/xpipe-io/xpipe/tree/master/ext/base/src/main/resources/io/xpipe/ext/base/resources/scripts). ### Adding more file icons for specific types You can register file types [here](https://github.com/xpipe-io/xpipe/blob/master/app/src/main/resources/io/xpipe/app/resources/file_list.txt) and add the respective icons [here](https://github.com/xpipe-io/xpipe/tree/master/app/src/main/resources/io/xpipe/app/resources/img/browser). The existing file list and icons are taken from the [vscode-icons](https://github.com/vscode-icons/vscode-icons) project. Due to limitations in the file definition list compatibility, some file types might not be listed by their proper extension and are therefore not being applied correctly even though the images and definitions exist already. ### Implementing something else if you want to work on something that was not listed here, you can still do so of course. You can reach out on the [Discord server](https://discord.gg/8y89vS8cRb) to discuss any development plans and get you started. ### Adding translations See the [translation guide](/lang) for details. ================================================ FILE: LICENSE.md ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2023 Christopher Schnick Copyright 2023 XPipe UG (haftungsbeschränkt) Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================

XPipe Banner

## About XPipe is a connection hub that allows you to access your entire server infrastructure from your local desktop. It works on top of your installed command-line programs like SSH, docker, or others, and does not require any setup on your remote systems. It integrates with your favourite text editors, terminals, shells, VNC/RDP clients, password managers, and command-line tools. The platform is designed to be extensible, allowing anyone to add easily support for more tools or to implement custom functionality through a modular extension system. It currently supports: - [SSH](https://docs.xpipe.io/guide/ssh) connections, config files, and tunnels - [Docker](https://docs.xpipe.io/guide/docker) + compose, [Podman](https://docs.xpipe.io/guide/podman), [LXD](https://docs.xpipe.io/guide/lxc), and [incus](https://docs.xpipe.io/guide/lxc) containers - [Proxmox PVE](https://docs.xpipe.io/guide/proxmox), [Hyper-V](https://docs.xpipe.io/guide/hyperv), [KVM](https://docs.xpipe.io/guide/kvm), and [VMware Player/Workstation/Fusion](https://docs.xpipe.io/guide/vmware) virtual machines - [Tailscale](https://docs.xpipe.io/guide/tailscale), [Netbird](https://docs.xpipe.io/guide/netbird), and [Teleport](https://docs.xpipe.io/guide/teleport) connections - [AWS](https://docs.xpipe.io/guide/aws) and [Hetzner Cloud](https://docs.xpipe.io/guide/hcloud) servers - [RDP](https://docs.xpipe.io/guide/rdp) and [VNC](https://docs.xpipe.io/guide/vnc) connections - Windows Subsystem for Linux, Cygwin, and MSYS2 environments - [Kubernetes](https://docs.xpipe.io/guide/kubernetes) clusters, pods, and containers - [Powershell Remote Sessions](https://docs.xpipe.io/guide/pssession) ---
## Connection hub - Easily establish and manage connections to remote systems from a central hub interface - Organize all your connections in hierarchical categories to maintain an overview over hundreds of connections. - Create custom shell login environments to instantly jump into a properly set up shell for every use case - Quickly perform various commonly used actions like starting/stopping systems, establishing tunnels, and more - Create desktop shortcuts and macros that automatically open remote connections in your terminal without having to open any GUI ![Connection hub](https://github.com/xpipe-io/.github/raw/main/img/hub_shadow.png) ## File browser - Interact with the file system of any remote system using a workflow optimized for professionals - Utilize your entire arsenal of locally installed programs to open and edit remote files - Dynamically elevate sessions with sudo when required without having to restart the session - Seamlessly transfer files from and to your system desktop environment - Work and perform transfers on multiple systems at the same time with the built-in tabbed multitasking - Quickly open a terminal session into any directory in your favourite terminal emulator - Customize every action through the scripting system ![Browser](https://github.com/xpipe-io/.github/raw/main/img/browser_shadow.png) ## Terminal launcher - Launches you into a shell session in your favourite terminal with one click. Automatically fills password prompts and more - Comes with support for all commonly used terminal emulators across all operating systems - Supports opening custom terminal emulators as well via a custom command-line spec - Works with all command shells such as bash, zsh, fish, cmd, PowerShell, and more, locally and remote - Integrates with multiplexers like tmux and zellij, plus prompts like starship and oh-my-zsh - Supports opening multiple sessions in split terminal pane views - Connects to a system while the terminal is still starting up, allowing for faster connections than otherwise possible ![Terminal](https://github.com/xpipe-io/.github/raw/main/img/terminal_shadow.png) ## Versatile scripting system - Create reusable simple shell scripts, templates, and groups to run on connected remote systems - Automatically make your scripts available in the PATH on any remote system without any setup - Setup shell init environments for connections to fully customize your work environment for every purpose - Open custom shells and custom remote connections by providing your own commands - Use custom scripts in the file browser ![scripts](https://github.com/xpipe-io/.github/raw/main/img/scripts_shadow.png) ## And much more - You can synchronize your vault across multiple systems and share it with other team members via your own self-hosted git repository - All data is stored exclusively on your systems in a cryptographically secure vault. You can also choose to increase security by using a custom master passphrase for further encryption - XPipe is able to retrieve secrets automatically from your installed password manager and doesn't have store secrets itself - There are no servers involved, all your information stays on your systems. The XPipe application does not send any personal or sensitive information to outside services - XPipe has an integrated MCP server that you can enable. This allows you to easily use all of XPipe's features from an AI agent - Run coherent desktop applications remotely via the uniform desktop application system in XPipe for RDP, VNC, and X11 forwards - Securely tunnel and automatically open remote services with one click with the services integration # Downloads Note that this is a desktop application that should be run on your local desktop workstation, not on any server or containers. It will be able to connect to your server infrastructure from there. For a full reference and instructions, see the [installation docs](https://docs.xpipe.io/guide/installation) and [managed installation docs](https://docs.xpipe.io/guide/managed-installation). ## Windows Installers are the easiest way to get started and come with an optional automatic update functionality: - [Windows .msi Installer (x86-64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-installer-windows-x86_64.msi) - [Windows .msi Installer (ARM 64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-installer-windows-arm64.msi) If you don't like installers, you can also use a portable version that is packaged as an archive: - [Windows .zip Portable (x86-64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-portable-windows-x86_64.zip) - [Windows .zip Portable (ARM 64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-portable-windows-arm64.zip) Alternatively, you can also use the following package managers: - [choco](https://community.chocolatey.org/packages/xpipe) to install it with `choco install xpipe`. - [winget](https://github.com/microsoft/winget-cli) to install it with `winget install xpipe-io.xpipe --source winget`. - [scoop](https://github.com/microsoft/winget-cli) to install it with `scoop install extras/xpipe`. ## macOS Installers are the easiest way to get started and come with an optional automatic update functionality: - [MacOS .pkg Installer (x86-64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-installer-macos-x86_64.pkg) - [MacOS .pkg Installer (ARM 64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-installer-macos-arm64.pkg) If you don't like installers, you can also use a portable version that is packaged as an archive: - [MacOS .dmg Portable (x86-64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-portable-macos-x86_64.dmg) - [MacOS .dmg Portable (ARM 64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-portable-macos-arm64.dmg) Alternatively, you can also use [Homebrew](https://github.com/xpipe-io/homebrew-tap) to install XPipe with `brew install --cask xpipe-io/tap/xpipe`. ## Linux You can install XPipe the fastest by pasting the installation command into your terminal. This will perform the setup automatically. The script supports installation via `apt`, `dnf`, `yum`, `zypper`, `rpm`, and `pacman` on Linux: ``` bash <(curl -sL https://github.com/xpipe-io/xpipe/raw/master/get-xpipe.sh) ``` Of course, there are also other installation methods available. ### Debian-based distros The following debian installers are available: - [Linux .deb Installer (x86-64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-installer-linux-x86_64.deb) - [Linux .deb Installer (ARM 64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-installer-linux-arm64.deb) Note that you should use apt to install the package with `sudo apt install ` as other package managers, for example dpkg, are not able to resolve and install any dependency packages. ### RHEL-based distros The rpm releases are signed with the GPG key https://xpipe.io/signatures/crschnick.asc. You can import it via `rpm --import https://xpipe.io/signatures/crschnick.asc` to allow your rpm-based package manager to verify the release signature. The following rpm installers are available: - [Linux .rpm Installer (x86-64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-installer-linux-x86_64.rpm) - [Linux .rpm Installer (ARM 64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-installer-linux-arm64.rpm) ### Arch There is an official [AUR package](https://aur.archlinux.org/packages/xpipe) available that you can either install manually or via an AUR helper such as with `yay -S xpipe`. ### AppImages Alternatively, there are also AppImages available. These can be useful if you are using an immutable distro. - [Linux .AppImage Portable (x86-64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-portable-linux-x86_64.AppImage) - [Linux .AppImage Portable (ARM 64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-portable-linux-arm64.AppImage) ### NixOS There's an official [xpipe nixpkg](https://search.nixos.org/packages?channel=unstable&show=xpipe&from=0&size=50&sort=relevance&type=packages&query=xpipe) available that you can install with `nix-env -iA nixos.xpipe` on x86_64 Linux systems. This package is however usually not up to date. There is also a custom repository that contains the latest up-to-date release flakes for Linux and macOS systems: https://github.com/xpipe-io/nixpkg. ### Tarball In case you prefer to use an archive version that you can extract anywhere, you can use these: - [Linux .tar.gz Portable (x86-64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-portable-linux-x86_64.tar.gz) - [Linux .tar.gz Portable (ARM 64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-portable-linux-arm64.tar.gz) ### Docker container XPipe is a desktop application first and foremost. It requires a full desktop environment to function with various installed applications such as terminals, editors, shells, CLI tools, and more. So there is no true web-based interface for XPipe. Since it might make sense however to access your XPipe environment from the web, there is also a so-called webtop docker container image for XPipe. [XPipe Webtop](https://github.com/xpipe-io/xpipe-webtop) is a web-based desktop environment that can be run in a container and accessed from a browser via KasmVNC. The desktop environment comes with XPipe and various terminals and editors preinstalled and configured. This image is also available for Kasm Workspaces in the [XPipe Kasm Registry](https://github.com/xpipe-io/kasm-registry). # Further information ## Contributing See [CONTRIBUTING.md](/CONTRIBUTING.md) for details. contrib.rocks image ## Open source model XPipe follows an open core model, which essentially means that the main application is open source while certain other components are not. This mainly concerns the features only available in the homelab/professional plan and the shell handling library implementation. Furthermore, some CI pipelines and tests that run on private servers are also not included in the open repository. The distributed XPipe application consists out of two parts: - The open-source core that you can find this repository. It is licensed under the [Apache License 2.0](/LICENSE.md). - The closed-source extensions, mostly for homelab/professional plan features, which are not included in this repository Additional features are available in the homelab/professional plan. For more details see https://xpipe.io/pricing. If your enterprise puts great emphasis on having access to the full source code, there are also full source-available enterprise options available. ## Documentation You can find the documentation at https://docs.xpipe.io. ## Discord [![Discord](https://discordapp.com/api/guilds/979695018782646285/widget.png?style=banner2)](https://discord.gg/8y89vS8cRb) ================================================ FILE: SECURITY.md ================================================ # Security Due to its nature, XPipe has to handle a lot of sensitive information. Therefore, the security, integrity, and privacy of your data has topmost priority. More information about the security approach of the XPipe application can be found on the documentation website at https://docs.xpipe.io/reference/security. You can report security vulnerabilities in this GitHub repository in a confidential manner. We will get back to you as soon as possible if you do. ================================================ FILE: app/build.gradle ================================================ plugins { id 'application' id 'jvm-test-suite' id 'java-library' } repositories { mavenCentral() } apply from: "$rootDir/gradle/gradle_scripts/java.gradle" apply from: "$rootDir/gradle/gradle_scripts/javafx.gradle" apply from: "$rootDir/gradle/gradle_scripts/jna.gradle" apply from: "$rootDir/gradle/gradle_scripts/lombok.gradle" configurations { implementation.extendsFrom(javafx) api.extendsFrom(jna) } dependencies { api project(':core') api project(':beacon') compileOnly 'org.hamcrest:hamcrest:3.0' compileOnly 'org.junit.jupiter:junit-jupiter-api:5.14.2' compileOnly 'org.junit.jupiter:junit-jupiter-params:5.14.2' api 'com.vladsch.flexmark:flexmark:0.64.8' api 'com.vladsch.flexmark:flexmark-util:0.64.8' api 'com.vladsch.flexmark:flexmark-util-options:0.64.8' api 'com.vladsch.flexmark:flexmark-util-data:0.64.8' api 'com.vladsch.flexmark:flexmark-util-ast:0.64.8' api 'com.vladsch.flexmark:flexmark-util-builder:0.64.8' api 'com.vladsch.flexmark:flexmark-util-sequence:0.64.8' api 'com.vladsch.flexmark:flexmark-util-misc:0.64.8' api 'com.vladsch.flexmark:flexmark-util-dependency:0.64.8' api 'com.vladsch.flexmark:flexmark-util-collection:0.64.8' api 'com.vladsch.flexmark:flexmark-util-format:0.64.8' api 'com.vladsch.flexmark:flexmark-util-html:0.64.8' api 'com.vladsch.flexmark:flexmark-util-visitor:0.64.8' api 'com.vladsch.flexmark:flexmark-ext-tables:0.64.8' api 'com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:0.64.8' api 'com.vladsch.flexmark:flexmark-ext-gfm-tasklist:0.64.8' api 'com.vladsch.flexmark:flexmark-ext-footnotes:0.64.8' api 'com.vladsch.flexmark:flexmark-ext-definition:0.64.8' api 'com.vladsch.flexmark:flexmark-ext-anchorlink:0.64.8' api 'com.vladsch.flexmark:flexmark-ext-yaml-front-matter:0.64.8' api 'com.vladsch.flexmark:flexmark-ext-toc:0.64.8' api ('io.modelcontextprotocol.sdk:mcp-core:0.17.2') { exclude group: "com.ethlo.time", module: "itu" } api ('io.modelcontextprotocol.sdk:mcp-json:0.17.2') { exclude group: "com.ethlo.time", module: "itu" } api ('io.modelcontextprotocol.sdk:mcp-json-jackson2:0.17.2') { exclude group: "com.ethlo.time", module: "itu" exclude group: "com.fasterxml.jackson.dataformat", module: "jackson-dataformat-yaml" } api "io.projectreactor:reactor-core:3.7.9" api "org.reactivestreams:reactive-streams:1.0.4" api ("com.networknt:json-schema-validator:1.5.8") { exclude group: "com.ethlo.time", module: "itu" exclude group: "com.fasterxml.jackson.dataformat", module: "jackson-dataformat-yaml" } api "com.github.weisj:jsvg:1.7.2" api 'io.xpipe:vernacular:1.16' api 'org.bouncycastle:bcprov-jdk18on:1.83' api 'info.picocli:picocli:4.7.7' api 'org.apache.commons:commons-lang3:3.20.0' api 'io.sentry:sentry:8.20.0' api 'commons-io:commons-io:2.21.0' api "com.fasterxml.jackson.core:jackson-databind:2.21.0" api "com.fasterxml.jackson.core:jackson-annotations:2.21" api "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.0" api "org.kordamp.ikonli:ikonli-material2-pack:12.4.0" api "org.kordamp.ikonli:ikonli-materialdesign2-pack:12.4.0" api 'org.kordamp.ikonli:ikonli-bootstrapicons-pack:12.4.0' api "org.kordamp.ikonli:ikonli-javafx:12.4.0" api "org.slf4j:slf4j-api:2.0.17" api "org.slf4j:slf4j-jdk-platform-logging:2.0.17" api 'io.xpipe:modulefs:0.1.8' api 'net.synedra:validatorfx:0.4.2' api files("$rootDir/gradle/gradle_scripts/atlantafx-base-2.0.2.jar") api("org.int4.fx:fx-values:0.4") api files("$rootDir/gradle/gradle_scripts/fx-builders-1.0.0-SNAPSHOT.jar") } apply from: "$rootDir/gradle/gradle_scripts/local_junit_suite.gradle" def extensionJarDepList = project.allExtensions.stream().map(p -> p.getTasksByName('jar', true)).toList() jar { finalizedBy(extensionJarDepList) } application { mainModule = groupName + '.app' mainClass = groupName + '.app.Main' applicationDefaultJvmArgs = jvmRunArgs } run { systemProperty propertyName('useVirtualThreads'), 'false' systemProperty propertyName('mode'), 'gui' systemProperty propertyName('writeLogs'), "true" systemProperty propertyName('writeSysOut'), "true" systemProperty propertyName('developerMode'), "true" systemProperty propertyName('logLevel'), "trace" systemProperty propertyName('fullVersion'), fullVersion systemProperty propertyName('staging'), isStage // Apply passed xpipe properties for (final def e in System.getProperties().entrySet()) { if (e.getKey().toString().contains(snakeProductName)) { systemProperty e.getKey().toString(), e.getValue() } } workingDir = rootDir jvmArgs += ['-XX:+EnableDynamicAgentLoading'] def exts = files(project.allExtensions.stream().map(p -> p.getTasksByName('jar', true)[0].outputs.files.singleFile).toList()) classpath += exts dependsOn(project.allExtensions.stream().map(p -> p.getTasksByName('jar', true)[0]).toList()) } tasks.register('runAttachedDebugger', JavaExec) { workingDir = rootDir classpath = run.classpath mainModule = groupName + '.app' mainClass = groupName + '.app.Main' modularity.inferModulePath = true jvmArgs += jvmRunArgs jvmArgs += List.of( "-javaagent:${System.getProperty("user.home")}/.attachme/attachme-agent-1.2.9.jar=port:7857,host:localhost".toString(), "-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:0" ) jvmArgs += ['-XX:+EnableDynamicAgentLoading'] systemProperties run.systemProperties def exts = files(project.allExtensions.stream().map(p -> p.getTasksByName('jar', true)[0].outputs.files.singleFile).toList()) classpath += exts dependsOn(project.allExtensions.stream().map(p -> p.getTasksByName('jar', true)[0]).toList()) } processResources { doLast { def cssFiles = fileTree(dir: "$sourceSets.main.output.resourcesDir/io/xpipe/app/resources/style") cssFiles.include "**/*.css" cssFiles.each { css -> providers.javaexec { workingDir = projectDir jvmArgs += ["--module-path=${configurations.javafx.asFileTree.asPath},", "--add-modules=javafx.graphics"] mainClass = "com.sun.javafx.css.parser.Css2Bin" args css }.result.get() delete css } } doLast { def resourcesDir = new File(sourceSets.main.output.resourcesDir, "io/xpipe/app/resources/third-party") resourcesDir.mkdirs() copy { from "$rootDir/dist/licenses" into resourcesDir } } } distTar { enabled = false } distZip { enabled = false } assembleDist { enabled = false } ================================================ FILE: app/src/main/java/io/xpipe/app/Main.java ================================================ package io.xpipe.app; import io.xpipe.app.core.AppNames; import io.xpipe.app.core.AppProperties; import io.xpipe.app.core.mode.AppOperationMode; public class Main { static void main(String[] args) { if (args.length == 1 && args[0].equals("version")) { AppProperties.init(args); System.out.println(AppProperties.get().getVersion()); return; } // Since this is not marked as a console application, it will not print anything when you run it in a console on // Windows if (args.length == 1 && args[0].equals("--help")) { System.out.printf(""" The daemon executable %s does not accept any command-line arguments. For a reference on how to use xpipe from the command-line, take a look at https://docs.xpipe.io/cli. %n""", AppNames.ofCurrent().getExecutableName()); return; } AppOperationMode.init(args); } } ================================================ FILE: app/src/main/java/io/xpipe/app/action/AbstractAction.java ================================================ package io.xpipe.app.action; import io.xpipe.app.comp.base.ModalButton; import io.xpipe.app.comp.base.ModalOverlay; import io.xpipe.app.core.AppCache; import io.xpipe.app.core.AppI18n; import io.xpipe.app.core.AppLayoutModel; import io.xpipe.app.core.window.AppDialog; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.issue.TrackEvent; import io.xpipe.app.platform.LabelGraphic; import io.xpipe.app.util.DataStoreFormatter; import io.xpipe.app.util.LicensedFeature; import io.xpipe.app.util.ThreadHelper; import lombok.experimental.SuperBuilder; import java.util.*; import java.util.function.Consumer; @SuperBuilder public abstract class AbstractAction { private static final Set active = new HashSet<>(); private static boolean closed; private static Consumer pick; public static synchronized void expectPick() { if (pick != null) { return; } var show = !AppCache.getBoolean("pickIntroductionShown", false); if (show) { var modal = ModalOverlay.of("actionPickerTitle", AppDialog.dialogTextKey("actionPickerDescription")); modal.addButton(ModalButton.ok()); modal.showAndWait(); AppCache.update("pickIntroductionShown", true); } AppLayoutModel.get().getQueueEntries().add(queueEntry); pick = action -> { if (action instanceof SerializableAction) { cancelPick(); var modal = ModalOverlay.of("actionShortcuts", new ActionPickComp(action).prefWidth(600)); modal.show(); } }; } private static final AppLayoutModel.QueueEntry queueEntry = new AppLayoutModel.QueueEntry( AppI18n.observable("cancelActionPicker"), new LabelGraphic.IconGraphic("mdal-cancel_presentation"), () -> { cancelPick(); return true; }); public static synchronized void cancelPick() { AppLayoutModel.get().getQueueEntries().remove(queueEntry); pick = null; } public static void reset() { closed = true; for (int i = 50; i > 0; i--) { synchronized (active) { var count = active.size(); if (count == 0) { break; } } // Wait 5s max ThreadHelper.sleep(100); } synchronized (active) { for (AbstractAction abstractAction : active) { TrackEvent.info("Action has not quit after timeout: " + abstractAction.toString()); } } } public boolean executeSync() { if (closed) { return false; } synchronized (AbstractAction.class) { if (pick != null) { TrackEvent.withTrace("Picked action").tags(toDisplayMap()).handle(); pick.accept(this); pick = null; return false; } } return executeSyncImpl(true); } public void executeAsync() { if (closed) { return; } synchronized (AbstractAction.class) { if (pick != null) { TrackEvent.withTrace("Picked action").tags(toDisplayMap()).handle(); pick.accept(this); pick = null; return; } } ThreadHelper.runAsync(() -> { executeSyncImpl(true); }); } public boolean executeSyncImpl(boolean confirm) { if (confirm && !ActionConfirmation.confirmAction(this)) { return false; } if (closed) { return false; } checkLicense(); synchronized (active) { active.add(this); } TrackEvent.withTrace("Starting action execution").tags(toDisplayMap()).handle(); try { beforeExecute(); } catch (Throwable t) { ErrorEventFactory.fromThrowable(t).handle(); return false; } try { executeImpl(); return true; } catch (Throwable t) { ErrorEventFactory.fromThrowable(t).handle(); return false; } finally { afterExecute(); synchronized (active) { active.remove(this); } } } public String getId() { return getProvider().getId(); } public String getDisplayName() { var id = getId(); return id != null ? DataStoreFormatter.camelCaseToName(id) : "?"; } public ActionProvider getProvider() { var clazz = getClass(); var enc = clazz.getEnclosingClass(); if (enc == null) { throw new IllegalStateException("No enclosing instance of " + clazz); } return ActionProvider.ALL.stream() .filter(actionProvider -> actionProvider.getClass().equals(enc)) .findFirst() .orElseThrow(IllegalStateException::new); } public String getShortcutName() { return getDisplayName(); } public abstract void executeImpl() throws Exception; protected void beforeExecute() throws Exception {} public boolean isMutation() { return false; } public boolean forceConfirmation() { return false; } public LicensedFeature getLicensedFeature() { return null; } protected void checkLicense() { var feature = getLicensedFeature(); if (feature != null) { feature.throwIfUnsupported(); } } protected void afterExecute() {} public abstract Map toDisplayMap(); } ================================================ FILE: app/src/main/java/io/xpipe/app/action/ActionConfigComp.java ================================================ package io.xpipe.app.action; import io.xpipe.app.comp.BaseRegionBuilder; import io.xpipe.app.comp.SimpleRegionBuilder; import io.xpipe.app.comp.base.*; import io.xpipe.app.ext.DataStore; import io.xpipe.app.hub.action.BatchStoreAction; import io.xpipe.app.hub.action.MultiStoreAction; import io.xpipe.app.hub.action.StoreAction; import io.xpipe.app.hub.comp.StoreChoiceComp; import io.xpipe.app.hub.comp.StoreListChoiceComp; import io.xpipe.app.hub.comp.StoreViewState; import io.xpipe.app.platform.OptionsBuilder; import io.xpipe.app.storage.DataStoreEntryRef; import javafx.beans.property.*; import javafx.collections.FXCollections; import javafx.scene.layout.Region; public class ActionConfigComp extends SimpleRegionBuilder { private final Property action; public ActionConfigComp(Property action) { this.action = action; } @Override protected Region createSimple() { var options = new OptionsBuilder(); options.nameAndDescription("actionStore") .addComp(createChooser()) .nameAndDescription("actionStores") .addComp(createMultiChooser()); options.nameAndDescription("actionConfiguration").addComp(createTextArea()); return options.build(); } @SuppressWarnings("unchecked") private BaseRegionBuilder createMultiChooser() { var listProp = new SimpleListProperty>(FXCollections.observableArrayList()); if (action.getValue() instanceof BatchStoreAction ba) { listProp.setAll(((BatchStoreAction) ba).getRefs()); } else if (action.getValue() instanceof MultiStoreAction ma) { listProp.setAll(((MultiStoreAction) ma).getRefs()); } else { listProp.clear(); } listProp.addListener((obs, o, n) -> { if (action.getValue() instanceof BatchStoreAction ba) { action.setValue(((BatchStoreAction) ba).withRefs(n)); } else if (action.getValue() instanceof MultiStoreAction ma) { action.setValue(((MultiStoreAction) ma).withRefs(n)); } }); var choice = new StoreListChoiceComp<>( listProp, DataStore.class, null, StoreViewState.get().getAllConnectionsCategory()); choice.hide(listProp.emptyProperty()); choice.maxHeight(450); return choice; } @SuppressWarnings("unchecked") private BaseRegionBuilder createChooser() { var singleProp = new SimpleObjectProperty>(); var s = action.getValue() instanceof StoreAction sa ? sa.getRef() : null; singleProp.set((DataStoreEntryRef) s); singleProp.addListener((obs, o, n) -> { if (action.getValue() instanceof StoreAction sa && n != null) { action.setValue(sa.withRef(n.asNeeded())); } }); var choice = new StoreChoiceComp<>( null, singleProp, DataStore.class, ref -> true, StoreViewState.get().getAllConnectionsCategory()); choice.hide(singleProp.isNull()); return choice; } private BaseRegionBuilder createTextArea() { var config = new SimpleStringProperty(); var s = action.getValue() instanceof SerializableAction sa ? sa.toConfigNode() : null; config.set(s != null && s.size() > 0 ? s.toPrettyString() : null); config.addListener((obs, o, n) -> { if (action.getValue() instanceof SerializableAction aa && n != null) { var with = aa.withConfigString(n); if (with.isPresent()) { action.setValue(with.get()); } } }); var area = new IntegratedTextAreaComp(config, false, "action", new SimpleStringProperty("json")); area.hide(config.isNull()); return area; } } ================================================ FILE: app/src/main/java/io/xpipe/app/action/ActionConfirmComp.java ================================================ package io.xpipe.app.action; import io.xpipe.app.comp.BaseRegionBuilder; import io.xpipe.app.comp.RegionBuilder; import io.xpipe.app.comp.SimpleRegionBuilder; import io.xpipe.app.comp.base.ScrollComp; import io.xpipe.app.ext.DataStore; import io.xpipe.app.hub.action.BatchStoreAction; import io.xpipe.app.hub.action.MultiStoreAction; import io.xpipe.app.hub.action.StoreAction; import io.xpipe.app.hub.comp.StoreListChoiceComp; import io.xpipe.app.hub.comp.StoreViewState; import io.xpipe.app.platform.OptionsBuilder; import io.xpipe.app.storage.DataStoreEntryRef; import javafx.beans.property.SimpleListProperty; import javafx.collections.FXCollections; import javafx.scene.control.Label; import javafx.scene.layout.ColumnConstraints; import javafx.scene.layout.GridPane; import javafx.scene.layout.Region; import java.util.List; import java.util.Map; public class ActionConfirmComp extends SimpleRegionBuilder { private final AbstractAction action; public ActionConfirmComp(AbstractAction action) { this.action = action; } @Override protected Region createSimple() { var options = new OptionsBuilder(); var plural = action instanceof BatchStoreAction || action instanceof MultiStoreAction; options.nameAndDescription(plural ? "actionConnections" : "actionConnection") .addComp(createList()); options.nameAndDescription("actionConfiguration").addComp(createTable()); var scroll = new ScrollComp(options.buildComp()); return scroll.build(); } @SuppressWarnings("unchecked") private BaseRegionBuilder createList() { var listProp = new SimpleListProperty>(FXCollections.observableArrayList()); if (action instanceof BatchStoreAction ba) { listProp.setAll(((BatchStoreAction) ba).getRefs()); } else if (action instanceof MultiStoreAction ma) { listProp.setAll(((MultiStoreAction) ma).getRefs()); } else if (action instanceof StoreAction sa) { listProp.setAll(List.of(sa.getRef().asNeeded())); } var choice = new StoreListChoiceComp<>( listProp, DataStore.class, null, StoreViewState.get().getAllConnectionsCategory()); choice.maxHeight(450); choice.setEditable(false); choice.hide(listProp.emptyProperty()); return choice; } private BaseRegionBuilder createTable() { var map = action.toDisplayMap(); return RegionBuilder.of(() -> { var grid = new GridPane(); grid.setHgap(11); grid.setVgap(2); grid.getColumnConstraints().add(new ColumnConstraints(120, 120, 150)); var row = 0; for (Map.Entry e : map.entrySet()) { var name = new Label(e.getKey()); var value = new Label(e.getValue()); value.setWrapText(true); grid.add(name, 0, row); grid.add(value, 1, row); row++; } return grid; }); } } ================================================ FILE: app/src/main/java/io/xpipe/app/action/ActionConfirmation.java ================================================ package io.xpipe.app.action; import io.xpipe.app.comp.base.ModalButton; import io.xpipe.app.comp.base.ModalOverlay; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.storage.DataStoreEntry; import javafx.beans.property.SimpleBooleanProperty; import java.util.List; public class ActionConfirmation { public static boolean confirmAction(AbstractAction action) { if (!action.forceConfirmation() && (!action.isMutation() || !confirmAllModifications(action))) { return true; } var ok = new SimpleBooleanProperty(false); var modal = ModalOverlay.of("confirmAction", new ActionConfirmComp(action).prefWidth(550)); modal.addButton(ModalButton.cancel()); modal.addButton(ModalButton.ok(() -> ok.set(true))); modal.showAndWait(); return ok.get(); } private static boolean confirmAllModifications(AbstractAction action) { var context = getContext(action); return context.stream().anyMatch(dataStoreEntry -> { var config = DataStorage.get().getEffectiveCategoryConfig(dataStoreEntry); return config.getConfirmAllModifications() != null && config.getConfirmAllModifications(); }); } private static List getContext(AbstractAction action) { if (action instanceof StoreContextAction ca) { return ca.getStoreEntryContext(); } return List.of(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/action/ActionJacksonMapper.java ================================================ package io.xpipe.app.action; import io.xpipe.app.ext.DataStore; import io.xpipe.app.hub.action.*; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.storage.DataStorage; import io.xpipe.core.JacksonMapper; import io.xpipe.core.UuidHelper; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; import java.util.ArrayList; public class ActionJacksonMapper { @SuppressWarnings("unchecked") public static T parse(JsonNode tree) throws JsonProcessingException { if (!tree.isObject()) { return null; } var id = tree.get("id"); if (id == null || !id.isTextual()) { return null; } var provider = ActionProvider.ALL.stream() .filter(actionProvider -> id.textValue().equals(actionProvider.getId())) .findFirst(); if (provider.isEmpty()) { return null; } var clazz = provider.get().getActionClass(); if (clazz.isEmpty()) { return null; } var object = (ObjectNode) tree; var ref = tree.get("ref"); var mapper = JacksonMapper.newMapper().enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY); if (ref != null && !ref.isArray() && StoreAction.class.isAssignableFrom(clazz.get())) { validateRef(provider.get(), ref.asText()); var action = mapper.treeToValue(tree, clazz.get()); return (T) action; } var makeBatch = ref != null && ref.isArray() && !MultiStoreAction.class.isAssignableFrom(clazz.get()); if (makeBatch) { if (ref.size() == 0) { return null; } var batchActions = new ArrayList>(); object.remove("ref"); for (JsonNode batchRef : ref) { validateRef(provider.get(), batchRef.asText()); object.set("ref", batchRef); var action = mapper.treeToValue(object, clazz.get()); batchActions.add((StoreAction) action); } return (T) BatchStoreAction.builder().actions(batchActions).build(); } var makeMulti = ref != null && ref.isArray() && MultiStoreAction.class.isAssignableFrom(clazz.get()); if (makeMulti) { validateRef(provider.get(), ref.asText()); object.remove("ref"); object.set("refs", ref); var action = mapper.treeToValue(object, clazz.get()); return (T) action; } return null; } private static void validateRef(ActionProvider provider, String ref) { var uuid = UuidHelper.parse(ref); if (uuid.isEmpty()) { throw ErrorEventFactory.expected(new IllegalArgumentException("Invalid store id: " + ref)); } var entry = DataStorage.get().getStoreEntryIfPresent(uuid.get()); if (entry.isEmpty()) { throw ErrorEventFactory.expected(new IllegalArgumentException("Store not found for id: " + ref)); } if (!entry.get().getValidity().isUsable()) { throw ErrorEventFactory.expected(new IllegalArgumentException( "Store " + DataStorage.get().getStorePath(entry.get()) + " is incomplete")); } if (provider instanceof HubLeafProvider l && (!l.getApplicableClass() .isAssignableFrom(entry.get().getStore().getClass()) || !l.isApplicable(entry.get().ref()))) { throw ErrorEventFactory.expected(new IllegalArgumentException( "Store " + DataStorage.get().getStorePath(entry.get()) + " is not applicable for action type")); } if (provider instanceof BatchHubProvider h && (!h.getApplicableClass() .isAssignableFrom(entry.get().getStore().getClass()) || !h.isActive(entry.get().ref()) || !h.isApplicable(entry.get().ref()))) { throw ErrorEventFactory.expected(new IllegalArgumentException( "Store " + DataStorage.get().getStorePath(entry.get()) + " is not applicable for action type")); } } public static ObjectNode write(AbstractAction value) { if (value instanceof BatchStoreAction b) { var arrayNode = JsonNodeFactory.instance.arrayNode(); b.getActions().stream() .map(a -> { var tree = (ObjectNode) JacksonMapper.getDefault().valueToTree(a); return tree.get("ref"); }) .forEach(n -> arrayNode.add(n)); var tree = (ObjectNode) JacksonMapper.getDefault().valueToTree(b.getActions().getFirst()); tree.set("ref", arrayNode); tree.put("id", b.getActions().getFirst().getId()); return tree; } var tree = (ObjectNode) JacksonMapper.getDefault().valueToTree(value); var treeCopy = JsonNodeFactory.instance.objectNode(); treeCopy.put("id", value.getId()); tree.properties().forEach(p -> { treeCopy.set(p.getKey(), p.getValue()); }); if (value instanceof MultiStoreAction m) { var refs = treeCopy.get("refs"); treeCopy.remove("refs"); treeCopy.set("ref", refs); treeCopy.put("id", m.getId()); return treeCopy; } return treeCopy; } } ================================================ FILE: app/src/main/java/io/xpipe/app/action/ActionPickComp.java ================================================ package io.xpipe.app.action; import io.xpipe.app.comp.base.ModalOverlayContentComp; import io.xpipe.app.comp.base.ScrollComp; import io.xpipe.app.platform.OptionsBuilder; import javafx.beans.property.SimpleObjectProperty; import javafx.scene.layout.Region; public class ActionPickComp extends ModalOverlayContentComp { private final AbstractAction action; public ActionPickComp(AbstractAction action) { this.action = action; } @Override protected Region createSimple() { var prop = new SimpleObjectProperty<>(action); var top = new ActionConfigComp(prop); var bottom = new ActionShortcutComp(prop, () -> { getModalOverlay().close(); }); var options = new OptionsBuilder().addComp(top).addComp(bottom); var scroll = new ScrollComp(options.buildComp()); return scroll.build(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/action/ActionProvider.java ================================================ package io.xpipe.app.action; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.issue.TrackEvent; import io.xpipe.core.ModuleLayerLoader; import java.util.*; public interface ActionProvider { List ALL = new ArrayList<>(); static void initProviders() { TrackEvent.trace("Starting action provider initialization"); for (ActionProvider actionProvider : ALL) { try { actionProvider.init(); // For debugging // if (actionProvider instanceof HubLeafProvider) { // actionProvider.getActionClass().orElseThrow(); // } // if (actionProvider instanceof HubBranchProvider b) { // for (HubMenuItemProvider child : b.getChildren(null)) { // if (ALL.stream().noneMatch(a -> a.getClass().equals(child.getClass()))) { // System.out.println(child.getClass()); // } // } // } } catch (Throwable t) { ErrorEventFactory.fromThrowable(t).handle(); } } TrackEvent.trace("Finished action provider initialization"); } default void init() {} default String getId() { return null; } @SuppressWarnings("unchecked") default Optional> getActionClass() { var child = Arrays.stream(getClass().getDeclaredClasses()) .filter(aClass -> AbstractAction.class.isAssignableFrom(aClass)) .findFirst() .map(aClass -> (Class) aClass); return child.isPresent() ? Optional.of(child.get()) : Optional.empty(); } class Loader implements ModuleLayerLoader { @Override public void init(ModuleLayer layer) { ALL.addAll(ServiceLoader.load(layer, ActionProvider.class).stream() .sorted(Comparator.comparing(p -> p.type().getModule().getName())) .map(p -> p.get()) .toList()); } } } ================================================ FILE: app/src/main/java/io/xpipe/app/action/ActionShortcutComp.java ================================================ package io.xpipe.app.action; import io.xpipe.app.beacon.AppBeaconServer; import io.xpipe.app.comp.BaseRegionBuilder; 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.core.AppProperties; import io.xpipe.app.platform.BindingsHelper; import io.xpipe.app.platform.ClipboardHelper; import io.xpipe.app.platform.OptionsBuilder; import io.xpipe.app.platform.PlatformThread; import io.xpipe.app.update.AppDistributionType; import io.xpipe.app.util.*; import javafx.beans.binding.Bindings; import javafx.beans.property.Property; import javafx.beans.property.SimpleStringProperty; import javafx.scene.layout.Region; import org.kordamp.ikonli.javafx.FontIcon; import java.util.List; public class ActionShortcutComp extends SimpleRegionBuilder { private final Property action; private final Runnable onCreateMacro; public ActionShortcutComp(Property action, Runnable onCreateMacro) { this.action = action; this.onCreateMacro = onCreateMacro; } @Override protected Region createSimple() { var options = new OptionsBuilder(); options.nameAndDescription("actionDesktopShortcut").addComp(createDesktopComp()); options.name(AppDistributionType.get().isSupportsUrls() ? "actionUrlShortcut" : "actionUrlShortcutDisabled"); options.description( AppDistributionType.get().isSupportsUrls() ? AppI18n.observable("actionUrlShortcutDescription") : AppI18n.observable( "actionUrlShortcutDisabledDescription", AppDistributionType.get().toTranslatedString().getValue())); options.addComp(createUrlComp()).disable(!AppDistributionType.get().isSupportsUrls()); options.nameAndDescription("actionApiCall").addComp(createApiComp()); return options.build(); } private BaseRegionBuilder createUrlComp() { var url = new SimpleStringProperty(); action.subscribe((v) -> { var s = ActionUrls.toUrl(v); PlatformThread.runLaterIfNeeded(() -> { url.set(s); }); }); var copyButton = new ButtonComp(null, new FontIcon("mdi2c-clipboard-multiple-outline"), () -> { ClipboardHelper.copyUrl(url.getValue()); }) .describe(d -> d.nameKey("copyUrl")); var field = new TextFieldComp(url); field.apply(struc -> struc.setEditable(false)); var group = new InputGroupComp(List.of(field, copyButton)); group.setMainReference(field); group.hide(Bindings.isNull(url)); return group; } private BaseRegionBuilder createDesktopComp() { var url = BindingsHelper.map(action, abstractAction -> ActionUrls.toUrl(abstractAction)); var name = new SimpleStringProperty(); action.subscribe((v) -> { var s = v.getShortcutName(); PlatformThread.runLaterIfNeeded(() -> { name.set(s); }); }); var copyButton = new ButtonComp(null, new FontIcon("mdi2f-file-move-outline"), () -> { ThreadHelper.runFailableAsync(() -> { var file = DesktopShortcuts.createOpen( name.getValue(), "open \"" + url.getValue() + "\" -d \"" + AppProperties.get().getDataDir() + "\"", null); DesktopHelper.browseFileInDirectory(file); }); }) .describe(d -> d.nameKey("createShortcut")); var field = new TextFieldComp(name); var group = new InputGroupComp(List.of(field, copyButton)); group.setMainReference(field); group.hide(BindingsHelper.map(action, v -> !(v instanceof SerializableAction))); return group; } private BaseRegionBuilder createApiComp() { var url = "curl -X POST \"http://localhost:" + AppBeaconServer.get().getPort() + "/action\" ..."; var text = AppI18n.observable("actionApiUrl", url); var prop = new SimpleStringProperty(); prop.bind(text); var copyButton = new ButtonComp(null, new FontIcon("mdi2c-clipboard-multiple-outline"), () -> { if (action.getValue() instanceof SerializableAction sa) { ClipboardHelper.copyUrl(sa.toNode().toPrettyString()); } }) .describe(d -> d.nameKey("copyBody")); var field = new TextFieldComp(prop, true); field.apply(struc -> struc.setEditable(false)); var group = new InputGroupComp(List.of(field, copyButton)); group.setMainReference(field); group.hide(BindingsHelper.map(action, v -> !(v instanceof SerializableAction))); return group; } @SuppressWarnings("unused") private BaseRegionBuilder createMacroComp() { var button = new ButtonComp( AppI18n.observable("createMacro"), new FontIcon("mdi2c-clipboard-multiple-outline"), onCreateMacro); return button; } } ================================================ FILE: app/src/main/java/io/xpipe/app/action/ActionUrls.java ================================================ package io.xpipe.app.action; import io.xpipe.core.JacksonMapper; import io.xpipe.core.SecretValue; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.SneakyThrows; import java.net.URLDecoder; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.*; import java.util.stream.Collectors; public class ActionUrls { private static String encodeValue(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); } private static List nodeToString(JsonNode node) { if (node.isTextual()) { return List.of(encodeValue(node.asText())); } if (node.isArray()) { var list = new ArrayList(); for (JsonNode c : node) { var r = nodeToString(c); if (r.size() == 1) { list.add(r.getFirst()); } } return list; } var enc = SecretValue.toBase64e(node.toPrettyString().getBytes(StandardCharsets.UTF_8)); return List.of("~" + enc); } @SneakyThrows public static String toUrl(AbstractAction action) { if (!(action instanceof SerializableAction sa)) { return null; } var json = sa.toNode(); var parsed = JacksonMapper.getDefault().treeToValue(json, new TypeReference>() {}); Map> requestParams = new LinkedHashMap<>(); for (Map.Entry e : parsed.entrySet()) { var value = nodeToString(e.getValue()); requestParams.put(e.getKey(), value); } String encodedURL = requestParams.keySet().stream() .map(key -> { var vals = requestParams.get(key); return vals.stream().map(s -> key + "=" + s).collect(Collectors.joining("&")); }) .collect(Collectors.joining("&", "xpipe://action?", "")); return encodedURL; } public static Optional parse(String queryString) throws Exception { var query = splitQuery(queryString); var id = query.get("id"); if (id == null || id.size() != 1) { return Optional.empty(); } var provider = ActionProvider.ALL.stream() .filter(actionProvider -> id.getFirst().equals(actionProvider.getId())) .findFirst(); if (provider.isEmpty()) { return Optional.empty(); } var clazz = provider.get().getActionClass(); if (clazz.isEmpty()) { return Optional.empty(); } if (!SerializableAction.class.isAssignableFrom(clazz.get())) { return Optional.empty(); } var stores = query.get("ref"); if (stores == null || stores.isEmpty()) { return Optional.empty(); } var fixedMap = new LinkedHashMap(); for (var entry : query.entrySet()) { var list = new ArrayList<>(); for (String s : entry.getValue()) { if (s.startsWith("~")) { var json = SecretValue.fromBase64e(s.substring(1)); var node = JacksonMapper.getDefault().readTree(json); list.add(node); } else { list.add(s); } } var unwrapped = list.size() == 1 ? list.getFirst() : list; fixedMap.put(entry.getKey(), unwrapped); } var json = (ObjectNode) JacksonMapper.getDefault().valueToTree(fixedMap); var instance = ActionJacksonMapper.parse(json); return Optional.ofNullable(instance); } private static Map> splitQuery(String query) { if (query == null || query.isBlank()) { return Collections.emptyMap(); } return Arrays.stream(query.split("&")) .map(ActionUrls::splitQueryParameter) .collect(Collectors.groupingBy( AbstractMap.SimpleImmutableEntry::getKey, LinkedHashMap::new, Collectors.mapping(Map.Entry::getValue, Collectors.toList()))); } private static AbstractMap.SimpleImmutableEntry splitQueryParameter(String it) { final int idx = it.indexOf("="); final String key = idx > 0 ? it.substring(0, idx) : it; final String value = idx > 0 && it.length() > idx + 1 ? it.substring(idx + 1) : null; return new AbstractMap.SimpleImmutableEntry<>( URLDecoder.decode(key, StandardCharsets.UTF_8), value != null ? URLDecoder.decode(value, StandardCharsets.UTF_8) : null); } } ================================================ FILE: app/src/main/java/io/xpipe/app/action/LauncherUrlProvider.java ================================================ package io.xpipe.app.action; import java.net.URI; public interface LauncherUrlProvider extends ActionProvider { String getScheme(); AbstractAction createAction(URI uri) throws Exception; } ================================================ FILE: app/src/main/java/io/xpipe/app/action/SerializableAction.java ================================================ package io.xpipe.app.action; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.util.DataStoreFormatter; import io.xpipe.core.JacksonMapper; import io.xpipe.core.UuidHelper; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.experimental.SuperBuilder; import java.util.*; import java.util.stream.Collectors; @SuperBuilder public abstract class SerializableAction extends AbstractAction { public String toString() { return toNode().toPrettyString(); } public ObjectNode toNode() { var json = ActionJacksonMapper.write(this); return json; } public ObjectNode toConfigNode() { var json = toNode(); json.remove("ref"); json.remove("refs"); return json; } public Optional withConfigString(String configString) { try { var tree = (ObjectNode) JacksonMapper.getDefault().readTree(configString); tree.put("id", getId()); SerializableAction action = ActionJacksonMapper.parse(tree); return Optional.ofNullable(action); } catch (Exception ex) { return Optional.empty(); } } @Override public Map toDisplayMap() { var node = toConfigNode(); var map = new LinkedHashMap(); map.put("Action", getDisplayName()); for (Map.Entry property : node.properties()) { if (property.getKey().equals("id")) { continue; } var name = DataStoreFormatter.camelCaseToName(property.getKey()); name = Arrays.stream(name.split(" ")) .filter(s -> !s.equals("Store")) .collect(Collectors.joining(" ")); if (property.getValue().isTextual()) { var value = property.getValue().textValue(); var uuid = UuidHelper.parse(value); if (uuid.isPresent()) { var refName = DataStorage.get() .getStoreEntryIfPresent(uuid.get()) .map(e -> e.getName()) .or(() -> { return DataStorage.get() .getStoreCategoryIfPresent(uuid.get()) .map(c -> c.getName()); }); map.put(name, refName.orElse(value)); } else { map.put(name, value); } } else if (property.getValue().isArray()) { var list = new ArrayList(); for (JsonNode jsonNode : property.getValue()) { var s = jsonNode.asText(); if (!s.isEmpty()) { list.add(s); } } if (!list.isEmpty()) { map.put(name, String.join("\n", list)); } } else if (property.getValue().isBoolean()) { map.put(name, property.getValue().booleanValue() ? "Yes" : "No"); } else { var value = property.getValue().asText(); map.put(name, value); } } return map; } } ================================================ FILE: app/src/main/java/io/xpipe/app/action/StoreContextAction.java ================================================ package io.xpipe.app.action; import io.xpipe.app.storage.DataStoreEntry; import java.util.List; public interface StoreContextAction { List getStoreEntryContext(); } ================================================ FILE: app/src/main/java/io/xpipe/app/action/XPipeUrlProvider.java ================================================ package io.xpipe.app.action; import java.net.URI; public class XPipeUrlProvider implements LauncherUrlProvider { @Override public String getScheme() { return "xpipe"; } @Override public AbstractAction createAction(URI uri) throws Exception { var a = uri.getHost(); if (!"action".equals(a)) { return null; } var query = uri.getQuery(); var action = ActionUrls.parse(query); return action.orElse(null); } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/AppBeaconCache.java ================================================ package io.xpipe.app.beacon; import io.xpipe.app.ext.ShellStore; import io.xpipe.app.storage.DataStoreEntryRef; import io.xpipe.beacon.BeaconClientException; import lombok.Value; import java.util.HashSet; import java.util.Set; import java.util.UUID; @Value public class AppBeaconCache { Set shellSessions = new HashSet<>(); public BeaconShellSession getShellSession(UUID uuid) throws BeaconClientException { var found = shellSessions.stream() .filter(beaconShellSession -> beaconShellSession.getEntry().getUuid().equals(uuid)) .findFirst(); if (found.isEmpty()) { throw new BeaconClientException("No active shell session known for id " + uuid); } return found.get(); } public BeaconShellSession getOrStart(DataStoreEntryRef ref) throws Exception { var existing = AppBeaconServer.get().getCache().getShellSessions().stream() .filter(beaconShellSession -> beaconShellSession.getEntry().equals(ref.get())) .findFirst(); var control = (existing.isPresent() ? existing.get().getControl() : ref.getStore().standaloneControl().start()); control.setNonInteractive(); control.start(); if (existing.isEmpty()) { AppBeaconServer.get().getCache().getShellSessions().add(new BeaconShellSession(ref.get(), control)); } return new BeaconShellSession(ref.get(), control); } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/AppBeaconServer.java ================================================ package io.xpipe.app.beacon; import io.xpipe.app.beacon.mcp.AppMcpServer; import io.xpipe.app.core.AppLocalTemp; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.issue.TrackEvent; import io.xpipe.app.util.DocumentationLink; import io.xpipe.beacon.BeaconConfig; import io.xpipe.beacon.BeaconInterface; import io.xpipe.core.OsType; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; import lombok.Getter; import java.io.IOException; import java.net.HttpURLConnection; import java.net.Inet4Address; import java.net.InetSocketAddress; import java.nio.file.Files; import java.nio.file.attribute.PosixFilePermissions; import java.util.*; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; public class AppBeaconServer { private static AppBeaconServer INSTANCE; @Getter private final int port; @Getter private final Set sessions = new HashSet<>(); @Getter private final AppBeaconCache cache = new AppBeaconCache(); private boolean running; private ExecutorService executor; private HttpServer server; @Getter private String localAuthSecret; private AppBeaconServer(int port) { this.port = port; } public static void setupPort() { int port = BeaconConfig.getUsedPort(); INSTANCE = new AppBeaconServer(port); } public static void init() { try { INSTANCE.initAuthSecret(); INSTANCE.start(); TrackEvent.withInfo("Started http server") .tag("port", INSTANCE.getPort()) .build() .handle(); } catch (Exception ex) { // Not terminal! // We can still continue without the running server ErrorEventFactory.fromThrowable("Unable to start local http server on port " + INSTANCE.getPort(), ex) .documentationLink(DocumentationLink.BEACON_PORT_BIND) .build() .handle(); } } public static void reset() { if (INSTANCE != null) { INSTANCE.stop(); INSTANCE.deleteAuthSecret(); for (BeaconShellSession ss : INSTANCE.getCache().getShellSessions()) { try { ss.getControl().close(); } catch (Exception ex) { ErrorEventFactory.fromThrowable(ex).omit().expected().handle(); } } INSTANCE = null; } } public static AppBeaconServer get() { return INSTANCE; } public void addSession(BeaconSession session) { this.sessions.add(session); } private void stop() { if (!running) { return; } running = false; server.stop(0); executor.shutdown(); try { executor.awaitTermination(30, TimeUnit.SECONDS); } catch (InterruptedException ignored) { } } private void initAuthSecret() throws IOException { var file = BeaconConfig.getLocalBeaconAuthFile(); // Create and set temp dir permissions for Linux AppLocalTemp.getLocalTempDataDirectory(); var id = UUID.randomUUID().toString(); Files.writeString(file, id); if (OsType.ofLocal() != OsType.WINDOWS) { Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-rw----")); } localAuthSecret = id; } private void deleteAuthSecret() { var file = BeaconConfig.getLocalBeaconAuthFile(); try { Files.delete(file); } catch (IOException ignored) { } } private void start() throws IOException { executor = Executors.newFixedThreadPool(5, r -> { Thread t = Executors.defaultThreadFactory().newThread(r); t.setDaemon(true); t.setName("http handler"); t.setUncaughtExceptionHandler((t1, e) -> { ErrorEventFactory.fromThrowable(e).handle(); }); return t; }); server = HttpServer.create( new InetSocketAddress(Inet4Address.getByAddress(new byte[] {0x7f, 0x00, 0x00, 0x01}), port), 10); BeaconInterface.getAll().forEach(beaconInterface -> { var handler = new BeaconRequestHandler<>(beaconInterface); server.createContext(beaconInterface.getPath(), exchange -> { if (!handleCorsHeaders(exchange)) { handler.handle(exchange); } }); }); server.setExecutor(executor); server.createContext("/", exchange -> { if (!handleCorsHeaders(exchange)) { handleCatchAll(exchange); } }); server.createContext("/mcp", exchange -> { if (!handleCorsHeaders(exchange)) { var mcpServer = AppMcpServer.get(); if (mcpServer != null) { mcpServer.createHttpHandler().handle(exchange); } } }); server.start(); running = true; } private boolean handleCorsHeaders(HttpExchange exchange) throws IOException { exchange.getResponseHeaders() .add("Origin", "http://localhost:" + AppBeaconServer.get().getPort()); exchange.getResponseHeaders().add("Vary", "Origin"); exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*"); exchange.getResponseHeaders().add("Access-Control-Allow-Credentials", "true"); exchange.getResponseHeaders().add("Access-Control-Allow-Headers", "*"); exchange.getResponseHeaders().add("Access-Control-Allow-Methods", "*"); if (exchange.getRequestMethod().equals("OPTIONS")) { exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, -1); return true; } else { return false; } } private void handleCatchAll(HttpExchange exchange) throws IOException { exchange.getResponseHeaders().add("Location", DocumentationLink.API.getLink()); exchange.sendResponseHeaders(HttpURLConnection.HTTP_SEE_OTHER, 0); exchange.close(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/BeaconRequestHandler.java ================================================ package io.xpipe.app.beacon; import io.xpipe.app.core.mode.AppOperationMode; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.issue.TrackEvent; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.util.ThreadHelper; import io.xpipe.beacon.*; import io.xpipe.core.JacksonMapper; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import lombok.SneakyThrows; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.util.Arrays; public class BeaconRequestHandler implements HttpHandler { private final BeaconInterface beaconInterface; public BeaconRequestHandler(BeaconInterface beaconInterface) { this.beaconInterface = beaconInterface; } @Override public void handle(HttpExchange exchange) { if (AppOperationMode.isInShutdown() && !beaconInterface.acceptInShutdown()) { writeError(exchange, new BeaconClientErrorResponse("Daemon is currently in shutdown"), 400); return; } if (beaconInterface.requiresCompletedStartup()) { while (AppOperationMode.isInStartup()) { ThreadHelper.sleep(100); } } if (beaconInterface.requiresEnabledApi() && !AppPrefs.get().enableHttpApi().get()) { var ex = new BeaconServerException("HTTP API is not enabled in the settings menu"); writeError(exchange, ex, 403); return; } if (!AppPrefs.get().disableApiAuthentication().get() && beaconInterface.requiresAuthentication()) { var auth = exchange.getRequestHeaders().getFirst("Authorization"); if (auth == null) { writeError(exchange, new BeaconClientErrorResponse("Missing Authorization header"), 401); return; } var token = auth.replace("Bearer ", ""); var session = AppBeaconServer.get().getSessions().stream() .filter(s -> s.getToken().equals(token)) .findFirst() .orElse(null); if (session == null) { writeError(exchange, new BeaconClientErrorResponse("Unknown token"), 403); return; } } handleAuthenticatedRequest(exchange); } private void handleAuthenticatedRequest(HttpExchange exchange) { T object; Object response; try { if (beaconInterface.readRawRequestBody()) { object = createDefaultRequest(beaconInterface); } else { try (InputStream is = exchange.getRequestBody()) { var read = is.readAllBytes(); var rawDataRequestClass = beaconInterface.getRequestClass().getDeclaredFields().length == 1 && beaconInterface .getRequestClass() .getDeclaredFields()[0] .getType() .equals(byte[].class); if (!new String(read, StandardCharsets.US_ASCII).strip().startsWith("{") && rawDataRequestClass) { object = createRawDataRequest(beaconInterface, read); } else { var tree = JacksonMapper.getDefault().readTree(read); TrackEvent.trace("Parsed raw request:\n" + tree.toPrettyString()); var emptyRequestClass = tree.isEmpty() && beaconInterface.getRequestClass().getDeclaredFields().length == 0; object = emptyRequestClass ? createDefaultRequest(beaconInterface) : JacksonMapper.getDefault().treeToValue(tree, beaconInterface.getRequestClass()); TrackEvent.trace("Parsed request object:\n" + object); } } } var sync = beaconInterface.getSynchronizationObject(); if (sync != null) { synchronized (sync) { response = beaconInterface.handle(exchange, object); } } else { response = beaconInterface.handle(exchange, object); } } catch (BeaconClientException clientException) { ErrorEventFactory.fromThrowable(clientException).omit().expected().handle(); writeError(exchange, new BeaconClientErrorResponse(clientException.getMessage()), 400); return; } catch (BeaconServerException serverException) { var cause = serverException.getCause() != null ? serverException.getCause() : serverException; var event = ErrorEventFactory.fromThrowable(cause).omit().handle(); var link = event.getLink(); writeError(exchange, new BeaconServerErrorResponse(cause, link), 500); return; } catch (IOException ex) { // Handle serialization errors as normal exceptions and other IO exceptions as assuming that the connection // is broken if (!ex.getClass().getName().contains("jackson")) { ErrorEventFactory.fromThrowable(ex).omit().expected().handle(); } else { ErrorEventFactory.fromThrowable(ex).omit().expected().handle(); // Make deserialization error message more readable var message = ex.getMessage() .replace("$RequestBuilder", "") .replace("Exchange$Request", "Request") .replace("at [Source: UNKNOWN; byte offset: #UNKNOWN]", "") .replaceAll("(\\w+) is marked non-null but is null", "field $1 is missing from object") .strip(); writeError(exchange, new BeaconClientErrorResponse(message), 400); } return; } catch (Throwable other) { var event = ErrorEventFactory.fromThrowable(other).omit().expected().handle(); var link = event.getLink(); writeError(exchange, new BeaconServerErrorResponse(other, link), 500); return; } try { var emptyResponseClass = beaconInterface.getResponseClass().getDeclaredFields().length == 0; if (!emptyResponseClass && response != null) { TrackEvent.trace("Sending response:\n" + response); TrackEvent.trace("Sending raw response:\n" + JacksonMapper.getCensored().valueToTree(response).toPrettyString()); var bytes = JacksonMapper.getDefault() .valueToTree(response) .toPrettyString() .getBytes(StandardCharsets.UTF_8); exchange.sendResponseHeaders(200, bytes.length); try (OutputStream os = exchange.getResponseBody()) { os.write(bytes); } } else { exchange.sendResponseHeaders(200, -1); } } catch (IOException ioException) { // The exchange implementation might have already sent a response manually if (!"headers already sent".equals(ioException.getMessage())) { ErrorEventFactory.fromThrowable(ioException).omit().expected().handle(); } } catch (Throwable other) { var event = ErrorEventFactory.fromThrowable(other).handle(); var link = event.getLink(); writeError(exchange, new BeaconServerErrorResponse(other, link), 500); } } private void writeError(HttpExchange exchange, Object errorMessage, int code) { try { var bytes = JacksonMapper.getDefault().writeValueAsString(errorMessage).getBytes(StandardCharsets.UTF_8); exchange.sendResponseHeaders(code, bytes.length); try (OutputStream os = exchange.getResponseBody()) { os.write(bytes); } } catch (IOException ex) { ErrorEventFactory.fromThrowable(ex).omit().expected().handle(); } } @SneakyThrows @SuppressWarnings("unchecked") private REQ createDefaultRequest(BeaconInterface beaconInterface) { var c = beaconInterface.getRequestClass().getDeclaredMethod("builder"); c.setAccessible(true); var b = c.invoke(null); var m = b.getClass().getDeclaredMethod("build"); m.setAccessible(true); return (REQ) beaconInterface.getRequestClass().cast(m.invoke(b)); } @SneakyThrows @SuppressWarnings("unchecked") private REQ createRawDataRequest(BeaconInterface beaconInterface, byte[] s) { var c = beaconInterface.getRequestClass().getDeclaredMethod("builder"); c.setAccessible(true); var b = c.invoke(null); var setMethod = Arrays.stream(b.getClass().getDeclaredMethods()) .filter(method -> method.getParameterCount() == 1 && method.getParameters()[0].getType().equals(byte[].class)) .findFirst() .orElseThrow(); setMethod.invoke(b, (Object) s); var m = b.getClass().getDeclaredMethod("build"); m.setAccessible(true); return (REQ) beaconInterface.getRequestClass().cast(m.invoke(b)); } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/BeaconSession.java ================================================ package io.xpipe.app.beacon; import io.xpipe.beacon.BeaconClientInformation; import lombok.Value; @Value public class BeaconSession { BeaconClientInformation clientInformation; String token; } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/BeaconShellSession.java ================================================ package io.xpipe.app.beacon; import io.xpipe.app.process.ShellControl; import io.xpipe.app.storage.DataStoreEntry; import lombok.Value; @Value public class BeaconShellSession { DataStoreEntry entry; ShellControl control; } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/BlobManager.java ================================================ package io.xpipe.app.beacon; import io.xpipe.app.core.AppLocalTemp; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.beacon.BeaconClientException; import org.apache.commons.io.FileUtils; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; public class BlobManager { private static final Path TEMP = AppLocalTemp.getLocalTempDataDirectory("blob"); private static BlobManager INSTANCE; private final Map memoryBlobs = new ConcurrentHashMap<>(); private final Map fileBlobs = new ConcurrentHashMap<>(); public static BlobManager get() { return INSTANCE; } public static void init() { INSTANCE = new BlobManager(); try { FileUtils.forceMkdir(TEMP.toFile()); try { // Remove old files in dir FileUtils.cleanDirectory(TEMP.toFile()); } catch (IOException ignored) { } } catch (IOException e) { ErrorEventFactory.fromThrowable(e).handle(); } } public static void reset() { try { FileUtils.cleanDirectory(TEMP.toFile()); } catch (IOException ignored) { } INSTANCE = null; } public Path newBlobFile() throws IOException { var file = TEMP.resolve(UUID.randomUUID().toString()); FileUtils.forceMkdir(file.getParent().toFile()); return file; } public void store(UUID uuid, byte[] blob) { memoryBlobs.put(uuid, blob); } public void store(UUID uuid, InputStream blob) throws IOException { var file = TEMP.resolve(uuid.toString()); try (var fileOut = Files.newOutputStream(file)) { blob.transferTo(fileOut); } fileBlobs.put(uuid, file); } public InputStream getBlob(UUID uuid) throws Exception { var memory = memoryBlobs.get(uuid); if (memory != null) { return new ByteArrayInputStream(memory); } var found = fileBlobs.get(uuid); if (found == null) { throw new BeaconClientException("No saved data known for id " + uuid); } return Files.newInputStream(found); } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/ActionExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.action.ActionJacksonMapper; import io.xpipe.app.core.AppCache; import io.xpipe.app.core.window.AppDialog; import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.api.ActionExchange; import com.sun.net.httpserver.HttpExchange; public class ActionExchangeImpl extends ActionExchange { @Override public Object handle(HttpExchange exchange, Request msg) throws Exception { var action = ActionJacksonMapper.parse(msg.getAction()); if (action == null) { throw new BeaconClientException("Unable to parse action into known schema"); } if (!checkPermission()) { return Response.builder().build(); } action.executeSyncImpl(msg.isConfirm()); return Response.builder().build(); } private boolean checkPermission() { var cache = AppCache.getBoolean("externalActionPermitted", false); if (cache) { return true; } var r = AppDialog.confirm("externalAction"); if (r) { AppCache.update("externalActionPermitted", true); } return r; } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/AskpassExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.core.AppLayoutModel; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.platform.LabelGraphic; import io.xpipe.app.secret.SecretManager; import io.xpipe.app.secret.SecretQueryState; import io.xpipe.app.terminal.TerminalView; import io.xpipe.app.util.*; import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.api.AskpassExchange; import io.xpipe.core.InPlaceSecretValue; import javafx.beans.property.SimpleStringProperty; import com.sun.net.httpserver.HttpExchange; import java.time.Duration; public class AskpassExchangeImpl extends AskpassExchange { @Override public boolean requiresCompletedStartup() { return false; } @Override public Object handle(HttpExchange exchange, Request msg) throws BeaconClientException { // SSH auth with a smartcard will prompt to confirm user presence // Maybe we can show some dialog for this in the future if (msg.getPrompt() != null && msg.getPrompt().toLowerCase().contains("confirm user presence")) { var shown = AppLayoutModel.get().getQueueEntries().stream().anyMatch(queueEntry -> msg.getPrompt() .equals(queueEntry.getName().getValue())); if (!shown) { var qe = new AppLayoutModel.QueueEntry( new SimpleStringProperty(msg.getPrompt()), new LabelGraphic.IconGraphic("mdi2f-fingerprint"), () -> true); AppLayoutModel.get().getQueueEntries().add(qe); GlobalTimer.delay( () -> { AppLayoutModel.get().getQueueEntries().remove(qe); }, Duration.ofSeconds(10)); } return Response.builder().value(InPlaceSecretValue.of("")).build(); } var prompt = msg.getPrompt(); // sudo-rs uses a different prefix which we don't really need prompt = prompt.replace("[sudo: authenticate]", "[sudo]"); if (msg.getRequest() == null) { var r = AskpassAlert.queryRaw(prompt, null, true); return Response.builder() .value(r.getState() == SecretQueryState.NORMAL ? r.getSecret() : InPlaceSecretValue.of("")) .build(); } var found = msg.getSecretId() != null ? SecretManager.getProgress(msg.getRequest(), msg.getSecretId()) : SecretManager.getProgress(msg.getRequest()); if (found.isEmpty()) { throw new BeaconClientException("Unknown askpass request"); } var p = found.get(); var secret = p.process(prompt); if (p.getState() != SecretQueryState.NORMAL) { var ex = new BeaconClientException(SecretQueryState.toErrorMessage(p.getState())); ErrorEventFactory.preconfigure(ErrorEventFactory.fromThrowable(ex).ignore()); throw ex; } focusTerminalIfNeeded(msg.getPid()); return Response.builder().value(secret.inPlace()).build(); } @Override public boolean requiresEnabledApi() { return false; } private void focusTerminalIfNeeded(long pid) { if (TerminalView.get() == null) { return; } var found = TerminalView.get().findSession(pid); if (found.isEmpty()) { return; } var term = TerminalView.get().getTerminalInstances().stream() .filter(instance -> instance.equals(found.get().getTerminal())) .findFirst(); if (term.isEmpty()) { return; } TerminalView.focus(term.get()); } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/CategoryAddExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.storage.DataStoreCategory; import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.api.CategoryAddExchange; import com.sun.net.httpserver.HttpExchange; public class CategoryAddExchangeImpl extends CategoryAddExchange { @Override public Object handle(HttpExchange exchange, Request msg) throws Throwable { if (DataStorage.get().getStoreCategoryIfPresent(msg.getParent()).isEmpty()) { throw new BeaconClientException("Parent category with id " + msg.getParent() + " does not exist"); } var found = DataStorage.get().getStoreCategories().stream() .filter(dataStoreCategory -> msg.getParent().equals(dataStoreCategory.getParentCategory()) && msg.getName().equals(dataStoreCategory.getName())) .findAny(); if (found.isPresent()) { return Response.builder().category(found.get().getUuid()).build(); } var cat = DataStoreCategory.createNew(msg.getParent(), msg.getName()); DataStorage.get().addStoreCategory(cat); return Response.builder().category(cat.getUuid()).build(); } @Override public Object getSynchronizationObject() { return DataStorage.get(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/CategoryInfoExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.storage.DataStorage; import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.api.CategoryInfoExchange; import io.xpipe.core.JacksonMapper; import com.sun.net.httpserver.HttpExchange; import java.util.ArrayList; import java.util.UUID; public class CategoryInfoExchangeImpl extends CategoryInfoExchange { @Override public Object handle(HttpExchange exchange, Request msg) throws BeaconClientException { var list = new ArrayList(); for (UUID uuid : msg.getCategories()) { var cat = DataStorage.get() .getStoreCategoryIfPresent(uuid) .orElseThrow(() -> new BeaconClientException("Unknown category: " + uuid)); var name = DataStorage.get().getStorePath(cat); var apply = InfoResponse.builder() .lastModified(cat.getLastModified()) .lastUsed(cat.getLastUsed()) .category(cat.getUuid()) .parentCategory(cat.getParentCategory()) .name(name) .config(JacksonMapper.getDefault().valueToTree(cat.getConfig())) .build(); list.add(apply); } return Response.builder().infos(list).build(); } @Override public Object getSynchronizationObject() { return DataStorage.get(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/CategoryQueryExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.storage.DataStorageQuery; import io.xpipe.beacon.api.CategoryQueryExchange; import com.sun.net.httpserver.HttpExchange; public class CategoryQueryExchangeImpl extends CategoryQueryExchange { @Override public Object handle(HttpExchange exchange, Request msg) { var found = DataStorageQuery.queryCategory(msg.getFilter()); return Response.builder() .found(found.stream().map(entry -> entry.getUuid()).toList()) .build(); } @Override public Object getSynchronizationObject() { return DataStorage.get(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/CategoryRemoveExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.storage.DataStoreCategory; import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.api.CategoryRemoveExchange; import com.sun.net.httpserver.HttpExchange; import java.util.ArrayList; import java.util.UUID; public class CategoryRemoveExchangeImpl extends CategoryRemoveExchange { @Override public Object handle(HttpExchange exchange, Request msg) throws BeaconClientException { var toRemove = new ArrayList(); for (UUID uuid : msg.getCategories()) { var cat = DataStorage.get() .getStoreCategoryIfPresent(uuid) .orElseThrow(() -> new BeaconClientException("Unknown category: " + uuid)); if (!DataStorage.get().canDeleteStoreCategory(cat)) { throw new BeaconClientException("Cannot delete category: " + cat.getName()); } toRemove.add(cat); } for (DataStoreCategory cat : toRemove) { DataStorage.get().deleteStoreCategory(cat, msg.isRemoveChildrenCategories(), msg.isRemoveContents()); } return Response.builder().build(); } @Override public Object getSynchronizationObject() { return DataStorage.get(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/ConnectionAddExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.ext.DataStore; import io.xpipe.app.ext.ValidationException; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.storage.DataStoreEntry; import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.api.ConnectionAddExchange; import io.xpipe.core.JacksonMapper; import com.sun.net.httpserver.HttpExchange; public class ConnectionAddExchangeImpl extends ConnectionAddExchange { @Override public Object handle(HttpExchange exchange, Request msg) throws Throwable { var store = JacksonMapper.getDefault().treeToValue(msg.getData(), DataStore.class); if (store == null) { throw new BeaconClientException("Unable to parse store data into valid store"); } var found = DataStorage.get().getStoreEntryIfPresent(store, false); if (found.isEmpty()) { found = DataStorage.get().getStoreEntryIfPresent(msg.getName()); } if (found.isPresent()) { DataStorage.get().updateEntryStore(found.get(), store); return Response.builder().connection(found.get().getUuid()).build(); } if (msg.getCategory() != null && DataStorage.get() .getStoreCategoryIfPresent(msg.getCategory()) .isEmpty()) { throw new BeaconClientException("Category with id " + msg.getCategory() + " does not exist"); } var entry = DataStoreEntry.createNew(msg.getName(), store); if (msg.getCategory() != null) { entry.setCategoryUuid(msg.getCategory()); } try { DataStorage.get().addStoreEntryInProgress(entry); if (msg.getValidate()) { entry.validateOrThrow(); } } catch (Throwable ex) { if (ex instanceof ValidationException) { ErrorEventFactory.expected(ex); } else if (ex instanceof StackOverflowError) { // Cycles in connection graphs can fail hard but are expected ErrorEventFactory.expected(ex); } throw ex; } finally { DataStorage.get().removeStoreEntryInProgress(entry); } DataStorage.get().addStoreEntryIfNotPresent(entry); // Explicitly assign category if (msg.getCategory() != null) { DataStorage.get() .moveEntryToCategory( entry, DataStorage.get() .getStoreCategoryIfPresent(msg.getCategory()) .orElseThrow()); } return Response.builder().connection(entry.getUuid()).build(); } @Override public Object getSynchronizationObject() { return DataStorage.get(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/ConnectionInfoExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.storage.DataStorage; import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.api.ConnectionInfoExchange; import io.xpipe.core.StorePath; import com.sun.net.httpserver.HttpExchange; import org.apache.commons.lang3.ClassUtils; import java.util.ArrayList; import java.util.UUID; import java.util.stream.Collectors; public class ConnectionInfoExchangeImpl extends ConnectionInfoExchange { @Override public Object handle(HttpExchange exchange, Request msg) throws BeaconClientException { var list = new ArrayList(); for (UUID uuid : msg.getConnections()) { var e = DataStorage.get() .getStoreEntryIfPresent(uuid) .orElseThrow(() -> new BeaconClientException("Unknown connection: " + uuid)); var names = DataStorage.get() .getStorePath(DataStorage.get() .getStoreCategoryIfPresent(e.getCategoryUuid()) .orElseThrow()) .getNames(); var cat = new StorePath(names.subList(1, names.size())); var cache = e.getStoreCache().entrySet().stream() .filter(kv -> { return kv.getValue() != null && (ClassUtils.isPrimitiveOrWrapper( kv.getValue().getClass()) || kv.getValue() instanceof String); }) .collect(Collectors.toMap( stringObjectEntry -> stringObjectEntry.getKey(), stringObjectEntry -> stringObjectEntry.getValue())); var apply = InfoResponse.builder() .lastModified(e.getLastModified()) .lastUsed(e.getLastUsed()) .connection(e.getUuid()) .category(cat) .name(DataStorage.get().getStorePath(e)) .rawData(e.getStore()) .usageCategory(e.getProvider().getUsageCategory()) .type(e.getProvider().getId()) .state(e.getStorePersistentState() != null ? e.getStorePersistentState() : new Object()) .cache(cache) .build(); list.add(apply); } return Response.builder().infos(list).build(); } @Override public Object getSynchronizationObject() { return DataStorage.get(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/ConnectionQueryExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.storage.DataStorageQuery; import io.xpipe.beacon.api.ConnectionQueryExchange; import com.sun.net.httpserver.HttpExchange; public class ConnectionQueryExchangeImpl extends ConnectionQueryExchange { @Override public Object handle(HttpExchange exchange, Request msg) { var found = DataStorageQuery.queryEntry(msg.getCategoryFilter(), msg.getConnectionFilter(), msg.getTypeFilter()); return Response.builder() .found(found.stream().map(entry -> entry.getUuid()).toList()) .build(); } @Override public Object getSynchronizationObject() { return DataStorage.get(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/ConnectionRefreshExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.ext.FixedHierarchyStore; import io.xpipe.app.storage.DataStorage; import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.api.ConnectionRefreshExchange; import com.sun.net.httpserver.HttpExchange; public class ConnectionRefreshExchangeImpl extends ConnectionRefreshExchange { @Override public Object handle(HttpExchange exchange, Request msg) throws Throwable { var e = DataStorage.get() .getStoreEntryIfPresent(msg.getConnection()) .orElseThrow(() -> new BeaconClientException("Unknown connection: " + msg.getConnection())); if (e.getStore() instanceof FixedHierarchyStore) { DataStorage.get().refreshChildren(e, true); } else { e.validateOrThrow(); } return Response.builder().build(); } @Override public Object getSynchronizationObject() { return DataStorage.get(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/ConnectionRemoveExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.storage.DataStoreEntry; import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.api.ConnectionRemoveExchange; import com.sun.net.httpserver.HttpExchange; import java.util.ArrayList; import java.util.UUID; public class ConnectionRemoveExchangeImpl extends ConnectionRemoveExchange { @Override public Object handle(HttpExchange exchange, Request msg) throws BeaconClientException { var entries = new ArrayList(); for (UUID uuid : msg.getConnections()) { var e = DataStorage.get() .getStoreEntryIfPresent(uuid) .orElseThrow(() -> new BeaconClientException("Unknown connection: " + uuid)); entries.add(e); } DataStorage.get().deleteWithChildren(entries.toArray(DataStoreEntry[]::new)); return Response.builder().build(); } @Override public Object getSynchronizationObject() { return DataStorage.get(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/DaemonFocusExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.core.mode.AppOperationMode; import io.xpipe.app.core.window.AppMainWindow; import io.xpipe.beacon.api.DaemonFocusExchange; import com.sun.net.httpserver.HttpExchange; public class DaemonFocusExchangeImpl extends DaemonFocusExchange { @Override public Object handle(HttpExchange exchange, Request msg) throws Throwable { if (AppOperationMode.isInStartup()) { return Response.builder().build(); } if (AppOperationMode.GUI.isSupported()) { AppOperationMode.switchToSyncOrThrow(AppOperationMode.GUI); } var w = AppMainWindow.get(); if (w != null) { w.focus(); } return Response.builder().build(); } @Override public boolean requiresEnabledApi() { return false; } @Override public boolean requiresCompletedStartup() { return false; } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/DaemonModeExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.core.mode.AppOperationMode; import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.api.DaemonModeExchange; import com.sun.net.httpserver.HttpExchange; public class DaemonModeExchangeImpl extends DaemonModeExchange { @Override public Object handle(HttpExchange exchange, Request msg) throws BeaconClientException { var mode = AppOperationMode.map(msg.getMode()); if (!mode.isSupported()) { throw new BeaconClientException("Unsupported mode: " + msg.getMode().getDisplayName() + ". Supported: " + String.join( ", ", AppOperationMode.getAll().stream() .filter(AppOperationMode::isSupported) .map(AppOperationMode::getId) .toList())); } AppOperationMode.switchToSyncIfPossible(mode); return DaemonModeExchange.Response.builder().usedMode(msg.getMode()).build(); } @Override public boolean requiresEnabledApi() { return false; } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/DaemonOpenExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.core.AppOpenArguments; import io.xpipe.app.core.mode.AppOperationMode; import io.xpipe.app.platform.PlatformInit; import io.xpipe.beacon.BeaconServerException; import io.xpipe.beacon.api.DaemonOpenExchange; import io.xpipe.core.OsType; import com.sun.net.httpserver.HttpExchange; public class DaemonOpenExchangeImpl extends DaemonOpenExchange { private int openCounter = 0; @Override public boolean requiresCompletedStartup() { return false; } @Override public Object handle(HttpExchange exchange, Request msg) throws BeaconServerException { if (msg.getArguments().isEmpty()) { try { // At this point we are already loading this on another thread // so this call will only perform the waiting PlatformInit.init(true); } catch (Throwable t) { throw new BeaconServerException(t); } // The open command is used as a default opener on Linux // We don't want to overwrite the default startup mode if (OsType.ofLocal() == OsType.LINUX && openCounter++ == 0) { return Response.builder().build(); } AppOperationMode.switchToAsync(AppOperationMode.GUI); } else { AppOpenArguments.handle(msg.getArguments()); } return Response.builder().build(); } @Override public boolean requiresEnabledApi() { return false; } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/DaemonStatusExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.core.mode.AppOperationMode; import io.xpipe.beacon.api.DaemonStatusExchange; import com.sun.net.httpserver.HttpExchange; public class DaemonStatusExchangeImpl extends DaemonStatusExchange { @Override public boolean requiresCompletedStartup() { return false; } @Override public Object handle(HttpExchange exchange, Request body) { String mode; if (AppOperationMode.get() == null) { mode = "none"; } else { mode = AppOperationMode.get().getId(); } return Response.builder().mode(mode).build(); } @Override public boolean requiresEnabledApi() { return false; } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/DaemonStopExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.core.mode.AppOperationMode; import io.xpipe.app.util.ThreadHelper; import io.xpipe.beacon.api.DaemonStopExchange; import com.sun.net.httpserver.HttpExchange; public class DaemonStopExchangeImpl extends DaemonStopExchange { @Override public boolean requiresCompletedStartup() { return false; } @Override public Object handle(HttpExchange exchange, Request msg) { ThreadHelper.runAsync(() -> { ThreadHelper.sleep(1000); AppOperationMode.close(); }); return Response.builder().success(true).build(); } @Override public boolean requiresEnabledApi() { return false; } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/DaemonVersionExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.core.AppProperties; import io.xpipe.app.core.AppVersion; import io.xpipe.app.util.LicenseProvider; import io.xpipe.beacon.api.DaemonVersionExchange; import com.sun.net.httpserver.HttpExchange; public class DaemonVersionExchangeImpl extends DaemonVersionExchange { @Override public boolean requiresCompletedStartup() { return false; } @Override public Object handle(HttpExchange exchange, Request msg) { var jvmVersion = System.getProperty("java.vm.vendor") + " " + System.getProperty("java.vm.name") + " (" + System.getProperty("java.vm.version") + ")"; var version = AppProperties.get().getVersion(); return Response.builder() .version(version) .canonicalVersion(AppVersion.parse(version) .map(appVersion -> appVersion.toString()) .orElse("?")) .buildVersion(AppProperties.get().getBuild()) .jvmVersion(jvmVersion) .plan(LicenseProvider.get().getLicenseId()) .build(); } @Override public boolean requiresEnabledApi() { return false; } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/FsBlobExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.beacon.BlobManager; import io.xpipe.beacon.api.FsBlobExchange; import com.sun.net.httpserver.HttpExchange; import lombok.SneakyThrows; import java.util.UUID; public class FsBlobExchangeImpl extends FsBlobExchange { @Override @SneakyThrows public Object handle(HttpExchange exchange, Request msg) { var id = UUID.randomUUID(); var size = exchange.getRequestBody().available(); if (size > 100_000_000) { BlobManager.get().store(id, exchange.getRequestBody()); } else { BlobManager.get().store(id, exchange.getRequestBody().readAllBytes()); } return Response.builder().blob(id).build(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/FsReadExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.beacon.AppBeaconServer; import io.xpipe.app.beacon.BlobManager; import io.xpipe.app.ext.ConnectionFileSystem; import io.xpipe.app.util.FixedSizeInputStream; import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.api.FsReadExchange; import com.sun.net.httpserver.HttpExchange; import lombok.SneakyThrows; import java.io.BufferedInputStream; import java.io.OutputStream; import java.nio.file.Files; public class FsReadExchangeImpl extends FsReadExchange { @Override @SneakyThrows public Object handle(HttpExchange exchange, Request msg) { var shell = AppBeaconServer.get().getCache().getShellSession(msg.getConnection()); var fs = new ConnectionFileSystem(shell.getControl()); if (!fs.fileExists(msg.getPath())) { throw new BeaconClientException("File does not exist"); } var size = fs.getFileSize(msg.getPath()); if (size > 100_000_000) { 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)) { fixedIn.transferTo(fileOut); } in.transferTo(OutputStream.nullOutputStream()); } exchange.sendResponseHeaders(200, size); try (var fileIn = Files.newInputStream(file); var out = exchange.getResponseBody()) { fileIn.transferTo(out); } } else { byte[] bytes; try (var in = fs.openInput(msg.getPath())) { var fixedIn = new FixedSizeInputStream(new BufferedInputStream(in), size); bytes = fixedIn.readAllBytes(); in.transferTo(OutputStream.nullOutputStream()); } exchange.sendResponseHeaders(200, bytes.length); try (var out = exchange.getResponseBody()) { out.write(bytes); } } return Response.builder().build(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/FsScriptExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.beacon.AppBeaconServer; import io.xpipe.app.beacon.BlobManager; import io.xpipe.app.process.ScriptHelper; import io.xpipe.beacon.api.FsScriptExchange; import com.sun.net.httpserver.HttpExchange; import lombok.SneakyThrows; import java.nio.charset.StandardCharsets; public class FsScriptExchangeImpl extends FsScriptExchange { @Override @SneakyThrows public Object handle(HttpExchange exchange, Request msg) { var shell = AppBeaconServer.get().getCache().getShellSession(msg.getConnection()); String data; try (var in = BlobManager.get().getBlob(msg.getBlob())) { data = new String(in.readAllBytes(), StandardCharsets.UTF_8); } data = shell.getControl().getShellDialect().prepareScriptContent(shell.getControl(), data); var file = ScriptHelper.createExecScript(shell.getControl(), data); return Response.builder().path(file).build(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/FsWriteExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.beacon.AppBeaconServer; import io.xpipe.app.beacon.BlobManager; import io.xpipe.app.ext.ConnectionFileSystem; import io.xpipe.beacon.api.FsWriteExchange; import com.sun.net.httpserver.HttpExchange; import lombok.SneakyThrows; public class FsWriteExchangeImpl extends FsWriteExchange { @Override @SneakyThrows public Object handle(HttpExchange exchange, Request msg) { var shell = AppBeaconServer.get().getCache().getShellSession(msg.getConnection()); var fs = new ConnectionFileSystem(shell.getControl()); try (var in = BlobManager.get().getBlob(msg.getBlob()); var os = fs.openOutput(msg.getPath(), in.available())) { in.transferTo(os); } return Response.builder().build(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/HandshakeExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.beacon.AppBeaconServer; import io.xpipe.app.beacon.BeaconSession; import io.xpipe.app.issue.TrackEvent; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.beacon.BeaconAuthMethod; import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.api.HandshakeExchange; import com.sun.net.httpserver.HttpExchange; import java.util.UUID; public class HandshakeExchangeImpl extends HandshakeExchange { @Override public boolean requiresCompletedStartup() { return false; } @Override public Object handle(HttpExchange exchange, Request request) throws BeaconClientException { if (!checkAuth(request.getAuth())) { throw new BeaconClientException("Authentication failed"); } TrackEvent.withTrace("Handshake request received") .tag("client", request.getClient().toDisplayString()) .handle(); var session = new BeaconSession(request.getClient(), UUID.randomUUID().toString()); AppBeaconServer.get().addSession(session); return Response.builder().sessionToken(session.getToken()).build(); } @Override public boolean requiresEnabledApi() { return false; } private boolean checkAuth(BeaconAuthMethod authMethod) { if (authMethod instanceof BeaconAuthMethod.Local local) { var c = local.getAuthFileContent().strip(); return AppBeaconServer.get().getLocalAuthSecret().equals(c); } if (authMethod instanceof BeaconAuthMethod.ApiKey key) { var c = key.getKey().strip(); return AppPrefs.get().apiKey().get().equals(c); } return false; } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/SecretDecryptExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.storage.DataStorageSecret; import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.api.SecretDecryptExchange; import com.sun.net.httpserver.HttpExchange; import java.io.IOException; public class SecretDecryptExchangeImpl extends SecretDecryptExchange { @Override public Object handle(HttpExchange exchange, Request msg) throws IOException, BeaconClientException { var secret = DataStorageSecret.deserialize(msg.getEncrypted()); if (secret == null) { throw new BeaconClientException("Unable to parse secret"); } return Response.builder().decrypted(new String(secret.getSecret())).build(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/SecretEncryptExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.storage.DataStorageSecret; import io.xpipe.beacon.api.SecretEncryptExchange; import io.xpipe.core.InPlaceSecretValue; import com.sun.net.httpserver.HttpExchange; public class SecretEncryptExchangeImpl extends SecretEncryptExchange { @Override public Object handle(HttpExchange exchange, Request msg) { var secret = DataStorageSecret.ofCurrentSecret(InPlaceSecretValue.of(msg.getValue())); return Response.builder().encrypted(secret.serialize(true)).build(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/ShellExecExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.beacon.AppBeaconServer; import io.xpipe.beacon.api.ShellExecExchange; import com.sun.net.httpserver.HttpExchange; import lombok.SneakyThrows; import java.util.concurrent.atomic.AtomicReference; public class ShellExecExchangeImpl extends ShellExecExchange { @Override @SneakyThrows public Object handle(HttpExchange exchange, Request msg) { var existing = AppBeaconServer.get().getCache().getShellSession(msg.getConnection()); AtomicReference out = new AtomicReference<>(); AtomicReference err = new AtomicReference<>(); long exitCode; try (var command = existing.getControl().command(msg.getCommand()).start()) { var r = command.readStdoutAndStderr(); out.set(r[0]); err.set(r[1]); command.close(); exitCode = command.getExitCode(); } return Response.builder() .stdout(out.get()) .stderr(err.get()) .exitCode(exitCode) .build(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/ShellStartExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.beacon.AppBeaconServer; import io.xpipe.app.beacon.BeaconShellSession; import io.xpipe.app.ext.ShellStore; import io.xpipe.app.storage.DataStorage; import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.api.ShellStartExchange; import io.xpipe.core.JacksonMapper; import com.sun.net.httpserver.HttpExchange; import lombok.SneakyThrows; public class ShellStartExchangeImpl extends ShellStartExchange { @Override @SneakyThrows public Object handle(HttpExchange exchange, Request msg) { var e = DataStorage.get() .getStoreEntryIfPresent(msg.getConnection()) .orElseThrow(() -> new BeaconClientException("Unknown connection")); if (!(e.getStore() instanceof ShellStore s)) { throw new BeaconClientException("Not a shell connection"); } var existing = AppBeaconServer.get().getCache().getShellSessions().stream() .filter(beaconShellSession -> beaconShellSession.getEntry().equals(e)) .findFirst(); var control = (existing.isPresent() ? existing.get().getControl() : s.standaloneControl().start()); 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(e, control)); } var ttyState = JacksonMapper.getDefault().valueToTree(control.getTtyState()).asText(); return Response.builder() .shellDialect(control.getShellDialect().getId()) .osType(control.getOsType()) .osName(control.getOsName()) .temp(control.getSystemTemporaryDirectory()) .ttyState(ttyState) .build(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/ShellStopExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.beacon.AppBeaconServer; import io.xpipe.beacon.api.ShellStopExchange; import com.sun.net.httpserver.HttpExchange; import lombok.SneakyThrows; public class ShellStopExchangeImpl extends ShellStopExchange { @Override @SneakyThrows public Object handle(HttpExchange exchange, Request msg) { var e = AppBeaconServer.get().getCache().getShellSession(msg.getConnection()); e.getControl().close(); AppBeaconServer.get().getCache().getShellSessions().remove(e); return Response.builder().build(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/SshLaunchExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.ext.ProcessControlProvider; import io.xpipe.app.process.ShellDialects; import io.xpipe.app.terminal.TerminalLauncherManager; import io.xpipe.beacon.api.SshLaunchExchange; import com.sun.net.httpserver.HttpExchange; import java.util.List; public class SshLaunchExchangeImpl extends SshLaunchExchange { @Override public Object handle(HttpExchange exchange, Request msg) throws Exception { if ("echo $SHELL".equals(msg.getArguments())) { return Response.builder().command(List.of("echo", "/bin/bash")).build(); } var usedDialect = ShellDialects.getStartableDialects().stream() .filter(dialect -> dialect.getExecutableName().equalsIgnoreCase(msg.getArguments())) .findFirst(); if (msg.getArguments() != null && usedDialect.isEmpty() && !msg.getArguments().contains("SSH_ORIGINAL_COMMAND")) { return Response.builder().command(List.of()).build(); } // There are sometimes multiple requests by a terminal client (e.g. Termius) // This might fail sometimes, but it is expected var r = TerminalLauncherManager.sshLaunchExchange(); var c = ProcessControlProvider.get() .getEffectiveLocalDialect() .getOpenScriptCommand(r.toString()) .buildBaseParts(null); return Response.builder().command(c).build(); } @Override public boolean requiresEnabledApi() { return false; } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/TerminalExternalLaunchExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.core.AppCache; import io.xpipe.app.core.window.AppDialog; import io.xpipe.app.ext.ShellStore; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.storage.DataStorageQuery; import io.xpipe.app.storage.DataStoreEntry; import io.xpipe.app.terminal.TerminalLauncherManager; import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.BeaconServerException; import io.xpipe.beacon.api.TerminalExternalLaunchExchange; import com.sun.net.httpserver.HttpExchange; import java.util.List; public class TerminalExternalLaunchExchangeImpl extends TerminalExternalLaunchExchange { @Override public Object handle(HttpExchange exchange, Request msg) throws BeaconClientException, BeaconServerException { var found = DataStorageQuery.queryUserInput(msg.getConnection()); if (found.isEmpty()) { throw new BeaconClientException("No connection found for input " + msg.getConnection()); } if (found.size() > 1) { throw new BeaconClientException("Multiple connections found: " + found.stream().map(DataStoreEntry::getName).toList()); } var e = found.getFirst(); var isShell = e.getStore() instanceof ShellStore; if (!isShell) { throw new BeaconClientException( "Connection " + DataStorage.get().getStorePath(e).toString() + " is not a shell connection"); } if (!checkPermission()) { return Response.builder().command(List.of()).build(); } var r = TerminalLauncherManager.externalExchange(e.ref(), msg.getArguments()); return Response.builder().command(r).build(); } @Override public boolean requiresEnabledApi() { return false; } @Override public Object getSynchronizationObject() { return DataStorage.get(); } private boolean checkPermission() { var cache = AppCache.getBoolean("externalLaunchPermitted", false); if (cache) { return true; } var r = AppDialog.confirm("externalLaunch"); if (r) { AppCache.update("externalLaunchPermitted", true); } return r; } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/TerminalLaunchExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.terminal.TerminalLauncherManager; import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.BeaconServerException; import io.xpipe.beacon.api.TerminalLaunchExchange; import com.sun.net.httpserver.HttpExchange; public class TerminalLaunchExchangeImpl extends TerminalLaunchExchange { @Override public Object handle(HttpExchange exchange, Request msg) throws BeaconClientException, BeaconServerException { var r = TerminalLauncherManager.launchExchange(msg.getRequest()); return Response.builder().targetFile(r).build(); } @Override public boolean requiresEnabledApi() { return false; } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/TerminalPrepareExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.beacon.api.TerminalPrepareExchange; import com.sun.net.httpserver.HttpExchange; public class TerminalPrepareExchangeImpl extends TerminalPrepareExchange { @Override public Object handle(HttpExchange exchange, Request msg) { var term = AppPrefs.get().terminalType().getValue(); var unicode = term.supportsUnicode(); var escapes = term.supportsEscapes(); return Response.builder() .supportsUnicode(unicode) .supportsEscapeSequences(escapes) .build(); } @Override public boolean requiresEnabledApi() { return false; } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/TerminalRegisterExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.terminal.TerminalLauncherManager; import io.xpipe.app.terminal.TerminalView; import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.api.TerminalRegisterExchange; import com.sun.net.httpserver.HttpExchange; public class TerminalRegisterExchangeImpl extends TerminalRegisterExchange { @Override public Object handle(HttpExchange exchange, Request msg) throws BeaconClientException { TerminalView.get().open(msg.getRequest(), msg.getPid()); TerminalLauncherManager.registerPid(msg.getRequest(), msg.getPid()); return Response.builder().build(); } @Override public boolean requiresEnabledApi() { return false; } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/impl/TerminalWaitExchangeImpl.java ================================================ package io.xpipe.app.beacon.impl; import io.xpipe.app.terminal.TerminalLauncherManager; import io.xpipe.beacon.BeaconServerException; import io.xpipe.beacon.api.TerminalWaitExchange; import com.sun.net.httpserver.HttpExchange; public class TerminalWaitExchangeImpl extends TerminalWaitExchange { @Override public Object handle(HttpExchange exchange, Request msg) throws BeaconServerException { TerminalLauncherManager.waitExchange(msg.getRequest()); return Response.builder().build(); } @Override public boolean requiresEnabledApi() { return false; } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/mcp/AppMcpServer.java ================================================ package io.xpipe.app.beacon.mcp; import io.xpipe.app.core.AppNames; import io.xpipe.app.core.AppProperties; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.util.ThreadHelper; import com.fasterxml.jackson.databind.ObjectMapper; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper; import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.spec.HttpHeaders; import io.modelcontextprotocol.spec.McpSchema; import lombok.SneakyThrows; import lombok.Value; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; @Value public class AppMcpServer { private static AppMcpServer INSTANCE; McpSyncServer mcpSyncServer; HttpStreamableServerTransportProvider transportProvider; List readOnlyTools; List mutationTools; public static AppMcpServer get() { return INSTANCE; } @SneakyThrows public static void init() { var transportProvider = new HttpStreamableServerTransportProvider( new JacksonMcpJsonMapper(new ObjectMapper()), "/mcp", false, (serverRequest) -> McpTransportContext.EMPTY, null); McpSyncServer syncServer = io.modelcontextprotocol.server.McpServer.sync(transportProvider) .serverInfo(AppNames.ofCurrent().getName(), AppProperties.get().getVersion()) .capabilities(McpSchema.ServerCapabilities.builder() .resources(false, false) .tools(true) .prompts(false) .build()) .instructions(AppPrefs.get().mcpAdditionalContext().getValue()) .build(); var readOnlyTools = new ArrayList(); readOnlyTools.add(McpTools.help()); readOnlyTools.add(McpTools.listSystems()); readOnlyTools.add(McpTools.readFile()); readOnlyTools.add(McpTools.listFiles()); readOnlyTools.add(McpTools.findFile()); 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.toggleState()); for (McpServerFeatures.SyncToolSpecification readOnlyTool : readOnlyTools) { syncServer.addTool(readOnlyTool); } var toolsAdded = new AtomicBoolean(); AppPrefs.get().enableMcpMutationTools().subscribe(value -> { for (var mutationTool : mutationTools) { if (value) { syncServer.addTool(mutationTool); } else if (toolsAdded.get()) { syncServer.removeTool(mutationTool.tool().name()); } } if (value) { toolsAdded.set(true); } syncServer.notifyToolsListChanged(); }); INSTANCE = new AppMcpServer(syncServer, transportProvider, readOnlyTools, mutationTools); } public static void reset() { INSTANCE.mcpSyncServer.close(); INSTANCE = null; } public HttpHandler createHttpHandler() { return new HttpHandler() { @Override public void handle(HttpExchange exchange) throws IOException { try (exchange) { if (AppPrefs.get() == null) { transportProvider.sendError(exchange, 503, "Not initialized"); return; } if (!AppPrefs.get().enableMcpServer().get()) { transportProvider.sendError(exchange, 403, "MCP server is not enabled in the settings menu"); if (exchange.getRequestMethod().equals("POST")) { ThreadHelper.runAsync(() -> { ErrorEventFactory.fromMessage( "An external request was made to the XPipe MCP server, however the MCP server is not enabled in the" + " settings menu") .expected() .handle(); }); } return; } if (exchange.getRequestMethod().equals("GET") && exchange.getRequestHeaders().getFirst(HttpHeaders.MCP_SESSION_ID) == null) { var msg = "Session ID required in mcp-session-id header." + " Check whether you are using the streamable HTTP transport and not something else like SSE."; transportProvider.sendError(exchange, 400, msg); ThreadHelper.runAsync(() -> { ErrorEventFactory.fromMessage(msg).expected().handle(); }); return; } if (!AppPrefs.get().disableApiAuthentication().get()) { var apiKey = exchange.getRequestHeaders().getFirst("Authorization"); if (apiKey == null) { transportProvider.sendError(exchange, 403, "Header Authorization is not set"); if (exchange.getRequestMethod().equals("POST")) { ThreadHelper.runAsync(() -> { ErrorEventFactory.fromMessage( "An external request was made to the XPipe MCP server without the header Authorization set. " + "Please configure your MCP client with the Bearer API token you can find the API " + "settings menu") .expected() .handle(); }); } return; } var correct = apiKey.replace("Bearer ", "") .equals(AppPrefs.get().apiKey().get()); if (!correct) { transportProvider.sendError(exchange, 403, "Invalid API key"); if (exchange.getRequestMethod().equals("POST")) { ThreadHelper.runAsync(() -> { ErrorEventFactory.fromMessage( "The Authorization header sent by the MCP client is not correct") .expected() .handle(); }); } return; } } if (exchange.getRequestMethod().equals("GET")) { transportProvider.doGet(exchange); } else if (exchange.getRequestMethod().equals("POST")) { transportProvider.doPost(exchange); } else if (exchange.getRequestMethod().equals("DELETE")) { transportProvider.doDelete(exchange); } else { transportProvider.doOther(exchange); } } } }; } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/mcp/HttpStreamableServerTransportProvider.java ================================================ /* * Copyright 2024-2024 the original author or authors. */ package io.xpipe.app.beacon.mcp; import io.xpipe.app.issue.TrackEvent; import com.sun.net.httpserver.HttpExchange; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.server.McpTransportContextExtractor; import io.modelcontextprotocol.spec.*; import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.util.KeepAliveScheduler; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.io.*; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantLock; public class HttpStreamableServerTransportProvider implements McpStreamableServerTransportProvider { public static final String MESSAGE_EVENT_TYPE = "message"; public static final String UTF_8 = "UTF-8"; public static final String APPLICATION_JSON = "application/json"; public static final String TEXT_EVENT_STREAM = "text/event-stream"; public static final String FAILED_TO_SEND_ERROR_RESPONSE = "Failed to send error response: {}"; private static final Logger logger = LoggerFactory.getLogger(HttpStreamableServerTransportProvider.class); private static final String ACCEPT = "Accept"; private final String mcpEndpoint; private final boolean disallowDelete; private final McpJsonMapper jsonMapper; private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); private final McpTransportContextExtractor contextExtractor; private McpStreamableServerSession.Factory sessionFactory; private volatile boolean isClosing = false; private KeepAliveScheduler keepAliveScheduler; HttpStreamableServerTransportProvider( McpJsonMapper jsonMapper, String mcpEndpoint, boolean disallowDelete, McpTransportContextExtractor contextExtractor, Duration keepAliveInterval) { Assert.notNull(jsonMapper, "ObjectMapper must not be null"); Assert.notNull(mcpEndpoint, "MCP endpoint must not be null"); Assert.notNull(contextExtractor, "Context extractor must not be null"); this.jsonMapper = jsonMapper; this.mcpEndpoint = mcpEndpoint; this.disallowDelete = disallowDelete; this.contextExtractor = contextExtractor; if (keepAliveInterval != null) { this.keepAliveScheduler = KeepAliveScheduler.builder( () -> (isClosing) ? Flux.empty() : Flux.fromIterable(sessions.values())) .initialDelay(keepAliveInterval) .interval(keepAliveInterval) .build(); this.keepAliveScheduler.start(); } } public List protocolVersions() { return List.of( ProtocolVersions.MCP_2024_11_05, ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25); } @Override public void setSessionFactory(McpStreamableServerSession.Factory sessionFactory) { this.sessionFactory = sessionFactory; } @Override public Mono notifyClients(String method, Object params) { if (this.sessions.isEmpty()) { logger.debug("No active sessions to broadcast message to"); return Mono.empty(); } logger.debug("Attempting to broadcast message to {} active sessions", this.sessions.size()); return Mono.fromRunnable(() -> { this.sessions.values().parallelStream().forEach(session -> { try { session.sendNotification(method, params).block(); } catch (Exception e) { logger.error("Failed to send message to session {}: {}", session.getId(), e.getMessage()); } }); }); } /** * Initiates a graceful shutdown of the transport. * * @return A Mono that completes when all cleanup operations are finished */ @Override public Mono closeGracefully() { return Mono.fromRunnable(() -> { this.isClosing = true; logger.debug("Initiating graceful shutdown with {} active sessions", this.sessions.size()); this.sessions.values().parallelStream().forEach(session -> { try { session.closeGracefully().block(); } catch (Exception e) { logger.error("Failed to close session {}: {}", session.getId(), e.getMessage()); } }); this.sessions.clear(); logger.debug("Graceful shutdown completed"); }) .then() .doOnSuccess(v -> { sessions.clear(); logger.debug("Graceful shutdown completed"); if (this.keepAliveScheduler != null) { this.keepAliveScheduler.shutdown(); } }); } public void doGet(HttpExchange exchange) throws IOException { String requestURI = exchange.getRequestURI().toString(); if (!requestURI.endsWith(mcpEndpoint)) { sendError(exchange, 404, null); return; } if (this.isClosing) { sendError(exchange, 503, "Server is shutting down"); return; } List badRequestErrors = new ArrayList<>(); String accept = exchange.getRequestHeaders().getFirst(ACCEPT); if (accept == null || !accept.contains(TEXT_EVENT_STREAM)) { badRequestErrors.add("text/event-stream required in Accept header"); } String sessionId = exchange.getRequestHeaders().getFirst(HttpHeaders.MCP_SESSION_ID); if (sessionId == null || sessionId.isBlank()) { badRequestErrors.add("Session ID required in mcp-session-id header"); } if (!badRequestErrors.isEmpty()) { String combinedMessage = String.join("; ", badRequestErrors); this.sendError(exchange, 400, combinedMessage); return; } McpStreamableServerSession session = this.sessions.get(sessionId); if (session == null) { sendError(exchange, 404, null); return; } logger.debug("Handling GET request for session: {}", sessionId); McpTransportContext transportContext = this.contextExtractor.extract(exchange); try { exchange.getResponseHeaders().add("Content-Type", TEXT_EVENT_STREAM); exchange.getResponseHeaders().add("Content-Encoding", UTF_8); exchange.getResponseHeaders().add("Cache-Control", "no-cache"); exchange.getResponseHeaders().add("Connection", "keep-alive"); exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*"); exchange.sendResponseHeaders(200, 0); var writer = new PrintWriter(exchange.getResponseBody()); HttpServletStreamableMcpSessionTransport sessionTransport = new HttpServletStreamableMcpSessionTransport(sessionId, exchange, writer); // Check if this is a replay request if (exchange.getRequestHeaders().getFirst(HttpHeaders.LAST_EVENT_ID) != null) { String lastId = exchange.getRequestHeaders().getFirst(HttpHeaders.LAST_EVENT_ID); try { session.replay(lastId) .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) .toIterable() .forEach(message -> { try { sessionTransport .sendMessage(message) .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) .block(); } catch (Exception e) { logger.error("Failed to replay message: {}", e.getMessage()); exchange.close(); } }); } catch (Exception e) { logger.error("Failed to replay messages: {}", e.getMessage()); exchange.close(); } } } catch (Exception e) { logger.error("Failed to handle GET request for session {}: {}", sessionId, e.getMessage()); sendError(exchange, 500, null); } } public void sendError(HttpExchange exchange, int code, String message) throws IOException { var b = message != null ? message.getBytes(StandardCharsets.UTF_8) : new byte[0]; exchange.getResponseHeaders().add("Content-Encoding", UTF_8); exchange.sendResponseHeaders(code, b.length != 0 ? b.length : -1); try (OutputStream os = exchange.getResponseBody()) { os.write(b); } TrackEvent.error("MCP server error " + code + ": " + message); } public void doPost(HttpExchange exchange) throws IOException { String requestURI = exchange.getRequestURI().toString(); if (!requestURI.endsWith(mcpEndpoint)) { sendError(exchange, 404, null); return; } if (this.isClosing) { sendError(exchange, 503, "Server is shutting down"); return; } List badRequestErrors = new ArrayList<>(); String accept = exchange.getRequestHeaders().getFirst(ACCEPT); if (accept == null || !accept.contains(TEXT_EVENT_STREAM)) { badRequestErrors.add("text/event-stream required in Accept header"); } if (accept == null || !accept.contains(APPLICATION_JSON)) { badRequestErrors.add("application/json required in Accept header"); } McpTransportContext transportContext = this.contextExtractor.extract(exchange); try { var body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body); // Handle initialization request if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest && jsonrpcRequest.method().equals(McpSchema.METHOD_INITIALIZE)) { if (!badRequestErrors.isEmpty()) { String combinedMessage = String.join("; ", badRequestErrors); this.sendError(exchange, 400, combinedMessage); return; } McpSchema.InitializeRequest initializeRequest = jsonMapper.convertValue(jsonrpcRequest.params(), new TypeRef<>() {}); McpStreamableServerSession.McpStreamableServerSessionInit init = this.sessionFactory.startSession(initializeRequest); this.sessions.put(init.session().getId(), init.session()); try { McpSchema.InitializeResult initResult = init.initResult().block(); String jsonResponse = jsonMapper.writeValueAsString(new McpSchema.JSONRPCResponse( McpSchema.JSONRPC_VERSION, jsonrpcRequest.id(), initResult, null)); var jsonBytes = jsonResponse.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().add("Content-Type", APPLICATION_JSON); exchange.getResponseHeaders().add("Content-Encoding", UTF_8); exchange.getResponseHeaders() .add(HttpHeaders.MCP_SESSION_ID, init.session().getId()); exchange.sendResponseHeaders(200, jsonBytes.length); exchange.getResponseBody().write(jsonBytes); return; } catch (Exception e) { logger.error("Failed to initialize session: {}", e.getMessage()); this.sendError(exchange, 500, "Failed to initialize session: " + e.getMessage()); return; } } String sessionId = exchange.getRequestHeaders().getFirst(HttpHeaders.MCP_SESSION_ID); if (sessionId == null || sessionId.isBlank()) { badRequestErrors.add("Session ID required in mcp-session-id header"); } if (!badRequestErrors.isEmpty()) { String combinedMessage = String.join("; ", badRequestErrors); this.sendError(exchange, 400, combinedMessage); return; } McpStreamableServerSession session = this.sessions.get(sessionId); if (session == null) { this.sendError(exchange, 404, "Session not found: " + sessionId + ". Was the session not refreshed?"); return; } if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) { session.accept(jsonrpcResponse) .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) .block(); exchange.sendResponseHeaders(200, -1); } else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { session.accept(jsonrpcNotification) .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) .block(); exchange.sendResponseHeaders(202, -1); } else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { // For streaming responses, we need to return SSE exchange.getResponseHeaders().add("Content-Type", TEXT_EVENT_STREAM); exchange.getResponseHeaders().add("Content-Encoding", UTF_8); exchange.getResponseHeaders().add("Cache-Control", "no-cache"); exchange.getResponseHeaders().add("Connection", "keep-alive"); exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*"); exchange.sendResponseHeaders(200, 0); var writer = new PrintWriter(exchange.getResponseBody()); HttpServletStreamableMcpSessionTransport sessionTransport = new HttpServletStreamableMcpSessionTransport(sessionId, exchange, writer); try { session.responseStream(jsonrpcRequest, sessionTransport) .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) .block(); } catch (Exception e) { logger.error("Failed to handle request stream: {}", e.getMessage()); exchange.close(); } } else { this.sendError(exchange, 500, "Unknown message type"); } } catch (IllegalArgumentException | IOException e) { logger.error("Failed to deserialize message: {}", e.getMessage()); this.sendError(exchange, 400, "Invalid message format: " + e.getMessage()); } catch (Exception e) { logger.error("Error handling message: {}", e.getMessage()); try { this.sendError(exchange, 500, "Error processing message: " + e.getMessage()); } catch (IOException ex) { logger.error(FAILED_TO_SEND_ERROR_RESPONSE, ex.getMessage()); sendError(exchange, 500, "Error processing message"); } } } public void doOther(HttpExchange exchange) throws IOException { sendError(exchange, 405, "Unsupported HTTP method: " + exchange.getRequestMethod()); } protected void doDelete(HttpExchange exchange) throws IOException { String requestURI = exchange.getRequestURI().toString(); if (!requestURI.endsWith(mcpEndpoint)) { sendError(exchange, 404, null); return; } if (this.isClosing) { sendError(exchange, 503, "Server is shutting down"); return; } if (this.disallowDelete) { sendError(exchange, 405, null); return; } McpTransportContext transportContext = this.contextExtractor.extract(exchange); if (exchange.getRequestHeaders().getFirst(HttpHeaders.MCP_SESSION_ID) == null) { sendError(exchange, 400, "Session ID required in mcp-session-id header"); return; } String sessionId = exchange.getRequestHeaders().getFirst(HttpHeaders.MCP_SESSION_ID); McpStreamableServerSession session = this.sessions.get(sessionId); if (session == null) { sendError(exchange, 404, null); return; } try { session.delete() .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) .block(); this.sessions.remove(sessionId); exchange.sendResponseHeaders(200, -1); } catch (Exception e) { logger.error("Failed to delete session {}: {}", sessionId, e.getMessage()); try { sendError(exchange, 500, e.getMessage()); } catch (IOException ex) { logger.error(FAILED_TO_SEND_ERROR_RESPONSE, ex.getMessage()); sendError(exchange, 500, "Error deleting session"); } } } private void sendEvent(PrintWriter writer, String eventType, String data, String id) throws IOException { if (id != null) { writer.write("id: " + id + "\n"); } writer.write("event: " + eventType + "\n"); writer.write("data: " + data + "\n\n"); writer.flush(); if (writer.checkError()) { throw new IOException("Client disconnected"); } } private class HttpServletStreamableMcpSessionTransport implements McpStreamableServerTransport { private final String sessionId; private final HttpExchange exchange; private final PrintWriter writer; private final ReentrantLock lock = new ReentrantLock(); private volatile boolean closed = false; HttpServletStreamableMcpSessionTransport(String sessionId, HttpExchange exchange, PrintWriter writer) { this.sessionId = sessionId; this.exchange = exchange; this.writer = writer; logger.debug("Streamable session transport {} initialized with SSE writer", sessionId); } @Override public T unmarshalFrom(Object data, TypeRef typeRef) { return jsonMapper.convertValue(data, typeRef); } @Override public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId) { return Mono.fromRunnable(() -> { if (this.closed) { logger.debug("Attempted to send message to closed session: {}", this.sessionId); return; } lock.lock(); try { if (this.closed) { logger.debug("Session {} was closed during message send attempt", this.sessionId); return; } String jsonText = jsonMapper.writeValueAsString(message); HttpStreamableServerTransportProvider.this.sendEvent( writer, MESSAGE_EVENT_TYPE, jsonText, messageId != null ? messageId : this.sessionId); logger.debug("Message sent to session {} with ID {}", this.sessionId, messageId); } catch (Exception e) { var clientDisconnected = "Client disconnected".equals(e.getMessage()); if (!clientDisconnected) { logger.error("Failed to send message to session {}: {}", this.sessionId, e.getMessage()); HttpStreamableServerTransportProvider.this.sessions.remove(this.sessionId); exchange.close(); } } finally { lock.unlock(); } }); } @Override public void close() { lock.lock(); try { if (this.closed) { logger.debug("Session transport {} already closed", this.sessionId); return; } this.closed = true; // HttpServletStreamableServerTransportProvider.this.sessions.remove(this.sessionId); exchange.close(); logger.debug("Successfully completed async context for session {}", sessionId); } catch (Exception e) { logger.warn("Failed to complete async context for session {}: {}", sessionId, e.getMessage()); } finally { lock.unlock(); } } @Override public Mono closeGracefully() { return Mono.fromRunnable(() -> { HttpServletStreamableMcpSessionTransport.this.close(); }); } @Override public Mono sendMessage(McpSchema.JSONRPCMessage message) { return sendMessage(message, null); } } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/mcp/McpSchemaFiles.java ================================================ package io.xpipe.app.beacon.mcp; import io.xpipe.core.JacksonMapper; import io.modelcontextprotocol.spec.McpSchema; import java.io.IOException; import java.nio.charset.StandardCharsets; public class McpSchemaFiles { public static String load(String name) throws IOException { try (var in = McpSchemaFiles.class.getResourceAsStream("/io/xpipe/app/resources/mcp/" + name)) { return new String(in.readAllBytes(), StandardCharsets.UTF_8); } } public static McpSchema.Tool loadTool(String name) throws IOException { var s = load(name); return JacksonMapper.getDefault().readValue(s, McpSchema.Tool.class); } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/mcp/McpToolHandler.java ================================================ package io.xpipe.app.beacon.mcp; import io.xpipe.app.ext.ShellStore; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.storage.DataStorageQuery; import io.xpipe.app.storage.DataStoreEntry; import io.xpipe.app.storage.DataStoreEntryRef; import io.xpipe.beacon.BeaconClientException; import io.xpipe.core.FilePath; import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.spec.McpSchema; import lombok.SneakyThrows; import java.util.Optional; import java.util.function.BiFunction; public interface McpToolHandler extends BiFunction { static McpToolHandler of(McpToolHandler t) { return t; } @Override @SneakyThrows default McpSchema.CallToolResult apply( McpSyncServerExchange mcpSyncServerExchange, McpSchema.CallToolRequest callToolRequest) { var req = new ToolRequest(mcpSyncServerExchange, callToolRequest); try { return handle(req); } catch (BeaconClientException e) { ErrorEventFactory.fromThrowable(e).expected().omit().handle(); return McpSchema.CallToolResult.builder() .addTextContent(e.getMessage()) .isError(true) .build(); } catch (Throwable e) { ErrorEventFactory.fromThrowable(e).omit().handle(); return McpSchema.CallToolResult.builder() .addTextContent(e.getMessage()) .isError(true) .build(); } } McpSchema.CallToolResult handle(ToolRequest request) throws Exception; class ToolRequest { protected final McpSyncServerExchange exchange; protected final McpSchema.CallToolRequest request; public ToolRequest(McpSyncServerExchange exchange, McpSchema.CallToolRequest request) { this.exchange = exchange; this.request = request; } public McpSchema.CallToolRequest getRawRequest() { return request; } public Optional getOptionalStringArgument(String key) { var o = request.arguments().get(key); if (o == null) { return Optional.empty(); } if (!(o instanceof String s) || s.isBlank()) { return Optional.empty(); } return Optional.of(s); } public String getStringArgument(String key) throws BeaconClientException { var o = request.arguments().get(key); if (o == null) { throw new BeaconClientException("Missing argument for key " + key); } if (!(o instanceof String s) || s.isBlank()) { throw new BeaconClientException("Invalid argument for key " + key); } return s; } public Optional getOptionalBooleanArgument(String key) { var o = request.arguments().get(key); if (o == null) { return Optional.empty(); } if (!(o instanceof Boolean b)) { return Optional.empty(); } return Optional.of(b); } public boolean getBooleanArgument(String key) throws BeaconClientException { var o = request.arguments().get(key); if (o == null) { throw new BeaconClientException("Missing argument for key " + key); } if (!(o instanceof Boolean b)) { throw new BeaconClientException("Invalid argument for key " + key); } return b; } public FilePath getFilePath(String key) throws BeaconClientException { var s = getStringArgument(key); var path = FilePath.parse(s); if (path == null) { throw new BeaconClientException("Invalid argument for key " + key); } return path; } public DataStoreEntryRef getDataStoreRef(String name) throws BeaconClientException { var found = DataStorageQuery.queryUserInput(name); if (found.isEmpty()) { throw new BeaconClientException("No connection found for input " + name); } if (found.size() > 1) { throw new BeaconClientException("Multiple connections found: " + found.stream().map(DataStoreEntry::getName).toList()); } var e = found.getFirst(); return e.ref(); } public DataStoreEntryRef getShellStoreRef(String name, boolean mutation) throws BeaconClientException { var ref = getDataStoreRef(name); var isShell = ref.getStore() instanceof ShellStore; if (!isShell) { throw new BeaconClientException("Connection " + 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(); } } } ================================================ FILE: app/src/main/java/io/xpipe/app/beacon/mcp/McpTools.java ================================================ 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.*; 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; import io.xpipe.app.process.WorkingDirectoryFunction; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.storage.DataStorageQuery; import io.xpipe.app.terminal.TerminalLaunch; import io.xpipe.app.util.CommandDialog; import io.xpipe.beacon.BeaconClientException; import io.xpipe.core.FilePath; import io.xpipe.core.JacksonMapper; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper; import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.spec.McpSchema; import lombok.Builder; import lombok.NonNull; import lombok.Value; import lombok.extern.jackson.Jacksonized; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.regex.Pattern; import java.util.stream.Collectors; public final class McpTools { public static McpServerFeatures.SyncToolSpecification help() throws IOException { var tool = McpSchemaFiles.loadTool("help.json"); return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) .callHandler(McpToolHandler.of((req) -> { var ro = AppMcpServer.get().getReadOnlyTools().stream() .filter(syncToolSpecification -> !syncToolSpecification.tool().name().equals("help")) .toList(); var mu = AppMcpServer.get().getMutationTools(); var roList = ro.stream() .map(syncToolSpecification -> "- " + syncToolSpecification.tool().name() + ": " + syncToolSpecification.tool().description()) .collect(Collectors.joining("\n")); var muList = mu.stream() .map(syncToolSpecification -> "- " + syncToolSpecification.tool().name() + ": " + syncToolSpecification.tool().description()) .collect(Collectors.joining("\n")); var text = """ The XPipe MCP server offers the following read-only tools: %s These tools will not modify anything on your system and are safe to use. You can also enable the following potentially destructive tools in the settings menu: %s These tools can perform write operations and other actions that might be potentially destructive. """.formatted(roList, muList); return McpSchema.CallToolResult.builder() .addTextContent(text) .build(); })) .build(); } @Jacksonized @Builder @Value public static class ConnectionResource { @NonNull String name; @NonNull String path; String information; String notes; } public static McpServerFeatures.SyncToolSpecification listSystems() throws IOException { var tool = McpSchemaFiles.loadTool("list_systems.json"); return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) .callHandler(McpToolHandler.of((req) -> { var filter = req.getOptionalStringArgument("filter"); var entries = filter.isPresent() ? DataStorageQuery.queryUserInput(filter.get()) : DataStorage.get().getStoreEntries(); var list = new ArrayList(); for (var e : entries) { if (!e.getValidity().isUsable()) { continue; } if (!e.getProvider().includeInConnectionCount()) { 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); } var json = JsonNodeFactory.instance.arrayNode(); for (var e : list) { json.add(JacksonMapper.getDefault().valueToTree(e)); } var object = JsonNodeFactory.instance.objectNode(); object.set("found", json); return McpSchema.CallToolResult.builder() .structuredContent( new JacksonMcpJsonMapper(JacksonMapper.getDefault()), JacksonMapper.getDefault().writeValueAsString(object)) .build(); })) .build(); } public static McpServerFeatures.SyncToolSpecification readFile() throws IOException { var tool = McpSchemaFiles.loadTool("read_file.json"); return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) .callHandler(McpToolHandler.of((req) -> { var path = req.getFilePath("path"); var system = req.getStringArgument("system"); var shellStore = req.getShellStoreRef(system, false); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); var fs = new ConnectionFileSystem(shellSession.getControl()); if (!fs.fileExists(path)) { throw new BeaconClientException("File " + path + " does not exist"); } try (var in = fs.openInput(path)) { var b = in.readAllBytes(); var s = new String(b, StandardCharsets.UTF_8); return McpSchema.CallToolResult.builder() .addTextContent(s) .build(); } })) .build(); } public static McpServerFeatures.SyncToolSpecification listFiles() throws IOException { var tool = McpSchemaFiles.loadTool("list_files.json"); return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) .callHandler(McpToolHandler.of((req) -> { var path = req.getFilePath("path"); var system = req.getStringArgument("system"); var recursive = req.getOptionalBooleanArgument("recursive").orElse(false); var shellStore = req.getShellStoreRef(system, false); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); var fs = new ConnectionFileSystem(shellSession.getControl()); if (!fs.directoryExists(path)) { throw new BeaconClientException("Directory " + path + " does not exist"); } try (var stream = recursive ? fs.listFilesRecursively(fs, path).stream() : fs.listFiles(fs, path)) { var list = stream.toList(); var builder = McpSchema.CallToolResult.builder(); for (FileEntry e : list) { builder.addTextContent(e.getPath().toString()); } return builder.build(); } })) .build(); } public static McpServerFeatures.SyncToolSpecification findFile() throws IOException { var tool = McpSchemaFiles.loadTool("find_file.json"); return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) .callHandler(McpToolHandler.of((req) -> { var path = req.getFilePath("path"); var system = req.getStringArgument("system"); var recursive = req.getOptionalBooleanArgument("recursive").orElse(false); var pattern = req.getStringArgument("name"); var shellStore = req.getShellStoreRef(system, false); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); var fs = new ConnectionFileSystem(shellSession.getControl()); if (!fs.directoryExists(path)) { throw new BeaconClientException("Directory " + path + " does not exist"); } var regex = Pattern.compile(DataStorageQuery.toRegex(pattern)); try (var stream = recursive ? fs.listFilesRecursively(fs, path).stream() : fs.listFiles(fs, path)) { var list = stream.toList(); var builder = McpSchema.CallToolResult.builder(); list.stream() .filter(fileEntry -> regex.matcher( fileEntry.getPath().toString()) .find()) .forEach(fileEntry -> { builder.addTextContent(fileEntry.getPath().toString()); }); return builder.build(); } })) .build(); } public static McpServerFeatures.SyncToolSpecification getFileInfo() throws IOException { var tool = McpSchemaFiles.loadTool("get_file_info.json"); return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) .callHandler(McpToolHandler.of((req) -> { var path = req.getFilePath("path"); var system = req.getStringArgument("system"); var shellStore = req.getShellStoreRef(system, false); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); var fs = new ConnectionFileSystem(shellSession.getControl()); if (!fs.fileExists(path) && !fs.directoryExists(path)) { throw new BeaconClientException("Path " + path + " does not exist"); } var entry = fs.getFileInfo(path); if (entry.isEmpty()) { throw new BeaconClientException("Path " + path + " does not exist"); } var map = new LinkedHashMap(); map.put("path", entry.get().getPath().toString()); map.put("size", entry.get().getSize()); if (entry.get().getInfo() instanceof FileInfo.Unix u) { map.put("permissions", u.getPermissions()); map.put("user", u.getUser()); map.put("group", u.getGroup()); } else if (entry.get().getInfo() instanceof FileInfo.Windows w) { map.put("attributes", w.getAttributes()); } map.put("type", entry.get().getKind().toString().toLowerCase()); map.put("date", entry.get().getDate().toString()); map.entrySet().removeIf(e -> e.getValue() == null); return McpSchema.CallToolResult.builder() .structuredContent(map) .build(); })) .build(); } public static McpServerFeatures.SyncToolSpecification createFile() throws IOException { var tool = McpSchemaFiles.loadTool("create_file.json"); return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) .callHandler(McpToolHandler.of((req) -> { var path = req.getFilePath("path"); var system = req.getStringArgument("system"); var shellStore = req.getShellStoreRef(system, true); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); var fs = new ConnectionFileSystem(shellSession.getControl()); if (fs.fileExists(path)) { throw new BeaconClientException("File " + path + " does already exist"); } fs.touch(path); if (req.getRawRequest().arguments().containsKey("content")) { var s = req.getRawRequest().arguments().get("content").toString(); var b = s.getBytes(StandardCharsets.UTF_8); try (var out = fs.openOutput(path, b.length)) { out.write(b); } } return McpSchema.CallToolResult.builder() .addTextContent("File created successfully") .build(); })) .build(); } public static McpServerFeatures.SyncToolSpecification writeFile() throws IOException { var tool = McpSchemaFiles.loadTool("write_file.json"); return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) .callHandler(McpToolHandler.of((req) -> { var path = req.getFilePath("path"); var system = req.getStringArgument("system"); var content = req.getStringArgument("content"); var shellStore = req.getShellStoreRef(system, true); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); var fs = new ConnectionFileSystem(shellSession.getControl()); var b = content.getBytes(StandardCharsets.UTF_8); try (var out = fs.openOutput(path, b.length)) { out.write(b); } return McpSchema.CallToolResult.builder() .addTextContent("File written successfully") .build(); })) .build(); } public static McpServerFeatures.SyncToolSpecification createDirectory() throws IOException { var tool = McpSchemaFiles.loadTool("create_directory.json"); return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) .callHandler(McpToolHandler.of((req) -> { var path = req.getFilePath("path"); var system = req.getStringArgument("system"); var shellStore = req.getShellStoreRef(system, true); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); var fs = new ConnectionFileSystem(shellSession.getControl()); if (fs.fileExists(path)) { throw new BeaconClientException("Directory " + path + " does already exist"); } fs.mkdirs(path); return McpSchema.CallToolResult.builder() .addTextContent("Directory created successfully") .build(); })) .build(); } public static McpServerFeatures.SyncToolSpecification runCommand() throws IOException { var tool = McpSchemaFiles.loadTool("run_command.json"); return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) .callHandler(McpToolHandler.of((req) -> { var command = req.getStringArgument("command"); var system = req.getStringArgument("system"); var shellStore = req.getShellStoreRef(system, true); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); var out = ProcessControlProvider.get().executeMcpCommand(shellSession.getControl(), command); var formatted = CommandDialog.formatOutput(out); return McpSchema.CallToolResult.builder() .addTextContent(formatted) .build(); })) .build(); } public static McpServerFeatures.SyncToolSpecification runScript() throws IOException { var tool = McpSchemaFiles.loadTool("run_script.json"); return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) .callHandler(McpToolHandler.of((req) -> { var system = req.getStringArgument("system"); var script = req.getDataStoreRef("script"); var directory = req.getFilePath("directory"); var arguments = req.getStringArgument("arguments"); var shellStore = req.getShellStoreRef(system, true); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); var clazz = Class.forName( AppExtensionManager.getInstance() .getExtendedLayer() .findModule(AppNames.extModuleName("base")) .orElseThrow(), AppNames.extModuleName("base") + ".script.ScriptStore"); var method = clazz.getDeclaredMethod("assembleScriptChain", ShellControl.class); var command = (String) method.invoke(script.getStore(), shellSession.getControl()); var scriptFile = ScriptHelper.createExecScript(shellSession.getControl(), command); var out = shellSession .getControl() .command(shellSession .getControl() .getShellDialect() .runScriptCommand(shellSession.getControl(), scriptFile.toString()) + arguments) .withWorkingDirectory(directory) .readStdoutOrThrow(); var formatted = CommandDialog.formatOutput(out); return McpSchema.CallToolResult.builder() .addTextContent(formatted) .build(); })) .build(); } public static McpServerFeatures.SyncToolSpecification openTerminal() throws IOException { var tool = McpSchemaFiles.loadTool("open_terminal.json"); return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) .callHandler(McpToolHandler.of((req) -> { var system = req.getStringArgument("system"); var directory = req.getOptionalStringArgument("directory"); var shellStore = req.getShellStoreRef(system, true); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); TerminalLaunch.builder() .entry(shellStore.get()) .directory(FilePath.of(directory.orElse(null))) .command(shellSession.getControl()) .launch(); return McpSchema.CallToolResult.builder() .addTextContent("Terminal is launching") .build(); })) .build(); } public static McpServerFeatures.SyncToolSpecification openTerminalInline() throws IOException { var tool = McpSchemaFiles.loadTool("open_terminal_inline.json"); return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) .callHandler(McpToolHandler.of((req) -> { var system = req.getStringArgument("system"); var directory = req.getOptionalStringArgument("directory"); var shellStore = req.getShellStoreRef(system, true); var shellSession = AppBeaconServer.get().getCache().getOrStart(shellStore); var script = shellSession .getControl() .prepareTerminalOpen( TerminalInitScriptConfig.ofName( shellStore.get().getName()), directory.isPresent() ? WorkingDirectoryFunction.fixed(FilePath.parse(directory.get())) : WorkingDirectoryFunction.none()); var json = JsonNodeFactory.instance.objectNode(); json.put("command", script); return McpSchema.CallToolResult.builder() .structuredContent(JacksonMapper.getDefault().writeValueAsString(json)) .build(); })) .build(); } public static McpServerFeatures.SyncToolSpecification toggleState() throws IOException { var tool = McpSchemaFiles.loadTool("toggle_state.json"); return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) .callHandler(McpToolHandler.of((req) -> { var system = req.getStringArgument("system"); var state = req.getBooleanArgument("state"); var ref = req.getDataStoreRef(system); if (!(ref.getStore() instanceof SingletonSessionStore singletonSessionStore)) { throw new BeaconClientException("Not a toggleable connection"); } if (state) { singletonSessionStore.startSessionIfNeeded(); } else { singletonSessionStore.stopSessionIfNeeded(); } return McpSchema.CallToolResult.builder() .addTextContent("Connection state set to " + state) .build(); })) .build(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/BrowserAbstractSessionModel.java ================================================ package io.xpipe.app.browser; import io.xpipe.app.util.BooleanScope; import io.xpipe.app.util.ThreadHelper; import javafx.beans.property.BooleanProperty; import javafx.beans.property.Property; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import lombok.Getter; import java.util.ArrayList; import java.util.List; @Getter public class BrowserAbstractSessionModel { protected final ObservableList sessionEntries = FXCollections.observableArrayList(); protected final Property selectedEntry = new SimpleObjectProperty<>(); protected final BooleanProperty busy = new SimpleBooleanProperty(); public void closeAsync(BrowserSessionTab e) { ThreadHelper.runAsync(() -> { // This is a bit ugly // If we die on tab init, wait a bit with closing to avoid removal while it is still being inited/added ThreadHelper.sleep(100); closeSync(e); }); } public void openSync(T e, BooleanProperty externalBusy) throws Exception { try (var ignored = new BooleanScope(externalBusy != null ? externalBusy : new SimpleBooleanProperty()).start()) { e.init(); // Prevent multiple calls from interfering with each other synchronized (this) { sessionEntries.add(e); // The tab pane doesn't automatically select new tabs selectedEntry.setValue(e); } } } public void closeSync(BrowserSessionTab e) { e.close(); synchronized (BrowserAbstractSessionModel.this) { this.sessionEntries.remove(e); } } public List getSessionEntriesSnapshot() { synchronized (this) { return new ArrayList<>(sessionEntries); } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/BrowserFileChooserSessionComp.java ================================================ package io.xpipe.app.browser; import io.xpipe.app.browser.file.BrowserConnectionListComp; import io.xpipe.app.browser.file.BrowserConnectionListFilterComp; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabComp; import io.xpipe.app.comp.RegionBuilder; import io.xpipe.app.comp.base.*; import io.xpipe.app.core.AppFontSizes; import io.xpipe.app.core.AppLayoutModel; import io.xpipe.app.ext.FileSystemStore; import io.xpipe.app.ext.ShellStore; import io.xpipe.app.hub.comp.StoreEntryWrapper; import io.xpipe.app.hub.comp.StoreViewState; import io.xpipe.app.platform.BindingsHelper; import io.xpipe.app.platform.InputHelper; import io.xpipe.app.platform.PlatformThread; import io.xpipe.app.storage.DataStoreEntry; import io.xpipe.app.storage.DataStoreEntryRef; import io.xpipe.app.util.FileReference; import io.xpipe.app.util.ObservableSubscriber; import io.xpipe.app.util.ThreadHelper; import io.xpipe.core.FilePath; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; import javafx.collections.ListChangeListener; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; import javafx.scene.shape.Rectangle; import java.util.List; import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.function.Predicate; import java.util.function.Supplier; public class BrowserFileChooserSessionComp extends ModalOverlayContentComp { private final BrowserFileChooserSessionModel model; private final Predicate filter; public BrowserFileChooserSessionComp(BrowserFileChooserSessionModel model, Predicate filter) { this.model = model; this.filter = filter; } public static void open( Supplier> store, Supplier initialPath, Consumer file, boolean save, boolean directory, Predicate filter) { var model = new BrowserFileChooserSessionModel(directory); model.setOnFinish(fileStores -> { file.accept(fileStores.size() > 0 ? fileStores.getFirst() : null); }); var comp = new BrowserFileChooserSessionComp(model, filter) .style("browser") .style("chooser"); var selection = new SimpleStringProperty(); model.getFileSelection().addListener((ListChangeListener) c -> { selection.set( c.getList().size() > 0 ? c.getList().getFirst().getRawFileEntry().getPath().toString() : null); }); var selectionField = new TextFieldComp(selection); selectionField.apply(struc -> { struc.setEditable(false); AppFontSizes.base(struc); }); selectionField.style("chooser-selection"); selectionField.hgrow(); var modal = ModalOverlay.of(save ? "saveFileTitle" : "openFileTitle", comp); modal.setRequireCloseButtonForClose(true); modal.addButtonBarComp(selectionField); modal.addButton(new ModalButton("select", () -> model.finishChooser(), true, true)); modal.show(); ThreadHelper.runAsync(() -> { model.openFileSystemAsync(store.get(), null, (sc) -> initialPath.get(), model.getBusy()); }); } @Override protected void setModalOverlay(ModalOverlay modalOverlay) { super.setModalOverlay(modalOverlay); if (modalOverlay == null) { model.closeFileSystem(); } } @Override protected Region createSimple() { Predicate applicable = storeEntryWrapper -> { return (storeEntryWrapper.getEntry().getStore() instanceof ShellStore) && storeEntryWrapper.getEntry().getValidity().isUsable() && filter.test(storeEntryWrapper.getEntry()); }; BiConsumer action = (w, busy) -> { ThreadHelper.runFailableAsync(() -> { var entry = w.getEntry(); if (!entry.getValidity().isUsable()) { return; } // Don't open same system again var current = model.getSelectedEntry().getValue(); if (current != null && entry.ref().equals(current.getEntry())) { return; } if (entry.getStore() instanceof ShellStore) { model.openFileSystemAsync(entry.ref(), null, null, busy); } }); }; var category = new SimpleObjectProperty<>( StoreViewState.get().getActiveCategory().getValue()); var filter = new SimpleStringProperty(); var filterTrigger = new ObservableSubscriber(); var bookmarkTopBar = new BrowserConnectionListFilterComp(filterTrigger, category, filter); var bookmarksList = new BrowserConnectionListComp( BindingsHelper.map( model.getSelectedEntry(), v -> v != null ? v.getEntry().get() : null), applicable, action, category, filter); var bookmarksContainer = new StackComp(List.of(bookmarksList)).style("bookmarks-container"); bookmarksContainer .apply(struc -> { var rec = new Rectangle(); rec.widthProperty().bind(struc.widthProperty()); rec.heightProperty().bind(struc.heightProperty()); rec.setArcHeight(7); rec.setArcWidth(7); struc.getChildren().getFirst().setClip(rec); }) .vgrow(); var stack = RegionBuilder.of(() -> { var s = new StackPane(); model.getSelectedEntry().subscribe(selected -> { PlatformThread.runLaterIfNeeded(() -> { if (selected != null) { s.getChildren().setAll(new BrowserFileSystemTabComp(selected, false).build()); } else { s.getChildren().clear(); } }); }); InputHelper.onKeyCombination( s, new KeyCodeCombination(KeyCode.F, KeyCombination.SHORTCUT_DOWN), false, keyEvent -> { filterTrigger.trigger(); keyEvent.consume(); }); return s; }); var vertical = new VerticalComp(List.of(bookmarkTopBar, bookmarksContainer)).style("left"); var splitPane = new LeftSplitPaneComp(vertical, stack) .withInitialWidth(AppLayoutModel.get().getSavedState().getBrowserConnectionsWidth()) .applyStructure(struc -> { struc.getLeft().setMinWidth(200); struc.getLeft().setMaxWidth(500); }); splitPane.disable(model.getBusy()); return splitPane.prefHeight(2000).build(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/BrowserFileChooserSessionModel.java ================================================ package io.xpipe.app.browser; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.ext.FileSystem; import io.xpipe.app.ext.FileSystemStore; import io.xpipe.app.storage.DataStoreEntryRef; import io.xpipe.app.util.BooleanScope; import io.xpipe.app.util.FileReference; import io.xpipe.app.util.ThreadHelper; import io.xpipe.core.FailableFunction; import io.xpipe.core.FilePath; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; import lombok.Getter; import lombok.Setter; import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; @Getter public class BrowserFileChooserSessionModel extends BrowserAbstractSessionModel { private final ObservableList fileSelection = FXCollections.observableArrayList(); private final boolean directory; @Setter private Consumer> onFinish; public BrowserFileChooserSessionModel(boolean directory) { this.directory = directory; selectedEntry.addListener((observable, oldValue, newValue) -> { if (newValue == null) { fileSelection.clear(); return; } fileSelection.setAll(newValue.getFileList().getSelection()); newValue.getFileList().getSelection().addListener((ListChangeListener) c -> { fileSelection.setAll(newValue.getFileList().getSelection()); }); }); } public void finishChooser() { var chosen = new ArrayList<>( fileSelection.stream().map(be -> be.getRawFileEntry().getPath()).toList()); synchronized (BrowserFileChooserSessionModel.this) { var open = selectedEntry.getValue(); if (open != null) { if (chosen.isEmpty() && directory) { var current = open.getCurrentDirectory(); if (current != null) { chosen.add(current.getPath()); } } ThreadHelper.runAsync(() -> { open.close(); }); } } var stores = chosen.stream() .map(entry -> new FileReference(selectedEntry.getValue().getEntry(), entry)) .toList(); onFinish.accept(stores); } public void closeFileSystem() { synchronized (BrowserFileChooserSessionModel.this) { var open = selectedEntry.getValue(); if (open != null) { ThreadHelper.runAsync(() -> { open.close(); }); } } } public void openFileSystemAsync( DataStoreEntryRef store, FailableFunction, FileSystem, Exception> customFileSystemFactory, FailableFunction path, BooleanProperty externalBusy) { if (store == null) { return; } ThreadHelper.runFailableAsync(() -> { BrowserFileSystemTabModel model; try (var ignored = new BooleanScope(externalBusy != null ? externalBusy : new SimpleBooleanProperty()).start()) { model = new BrowserFileSystemTabModel( this, store, customFileSystemFactory != null ? customFileSystemFactory : ref -> ref.getStore().createFileSystem()); model.init(); // Prevent multiple calls from interfering with each other synchronized (BrowserFileChooserSessionModel.this) { selectedEntry.setValue(model); sessionEntries.add(model); } if (path != null) { var initialPath = path.apply(model); if (initialPath != null) { model.initWithGivenDirectory(initialPath.toDirectory()); return; } } model.initWithDefaultDirectory(); } }); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/BrowserFullSessionComp.java ================================================ package io.xpipe.app.browser; import io.xpipe.app.browser.file.BrowserConnectionListComp; import io.xpipe.app.browser.file.BrowserConnectionListFilterComp; import io.xpipe.app.browser.file.BrowserTransferComp; import io.xpipe.app.comp.RegionBuilder; import io.xpipe.app.comp.SimpleRegionBuilder; import io.xpipe.app.comp.base.*; import io.xpipe.app.core.AppFontSizes; import io.xpipe.app.core.AppLayoutModel; import io.xpipe.app.core.window.AppMainWindow; import io.xpipe.app.ext.ShellStore; import io.xpipe.app.hub.comp.StoreEntryWrapper; import io.xpipe.app.hub.comp.StoreViewState; import io.xpipe.app.platform.BindingsHelper; import io.xpipe.app.platform.InputHelper; import io.xpipe.app.platform.PlatformThread; import io.xpipe.app.util.ObservableSubscriber; import io.xpipe.app.util.ThreadHelper; import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleDoubleProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; import javafx.geometry.Insets; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import javafx.scene.layout.AnchorPane; import javafx.scene.layout.Region; import javafx.scene.shape.Rectangle; import java.util.HashMap; import java.util.List; import java.util.function.BiConsumer; import java.util.function.Predicate; public class BrowserFullSessionComp extends SimpleRegionBuilder { private final BrowserFullSessionModel model; public BrowserFullSessionComp(BrowserFullSessionModel model) { this.model = model; } @Override protected Region createSimple() { var filterTrigger = new ObservableSubscriber(); var left = RegionBuilder.of(() -> createLeftSide(filterTrigger)); var leftSplit = new SimpleDoubleProperty(); var rightSplit = new SimpleDoubleProperty(); var tabs = new BrowserSessionTabsComp(model, leftSplit, rightSplit); tabs.apply(struc -> { struc.setViewOrder(1); struc.setPickOnBounds(false); AnchorPane.setTopAnchor(struc, 0.0); AnchorPane.setBottomAnchor(struc, 0.0); AnchorPane.setLeftAnchor(struc, 0.0); AnchorPane.setRightAnchor(struc, 0.0); }); left.apply(struc -> { struc.paddingProperty() .bind(Bindings.createObjectBinding( () -> new Insets(tabs.getHeaderHeight().get(), 0, 0, 0), tabs.getHeaderHeight())); }); var loadingIndicator = new LoadingIconComp(model.getBusy(), AppFontSizes::xxxl) .apply(struc -> { AnchorPane.setTopAnchor(struc, 0.0); AnchorPane.setRightAnchor(struc, 0.0); }) .style("tab-loading-indicator"); var pinnedStack = createSplitStack(rightSplit, tabs); var loadingStack = new AnchorComp(List.of(tabs, pinnedStack, loadingIndicator)); loadingStack.apply(struc -> struc.setPickOnBounds(false)); var delayedStack = new DelayedInitComp( left, () -> StoreViewState.get() != null && StoreViewState.get().isInitialized()); delayedStack.hide(AppMainWindow.get().getStage().widthProperty().lessThan(1000)); var splitPane = new LeftSplitPaneComp(delayedStack, loadingStack) .withInitialWidth(AppLayoutModel.get().getSavedState().getBrowserConnectionsWidth()) .withOnDividerChange(d -> { if (d > 0.0) { AppLayoutModel.get().getSavedState().setBrowserConnectionsWidth(d); } leftSplit.set(d); }); splitPane.applyStructure(struc -> { struc.getLeft().setMinWidth(200); struc.getLeft().setMaxWidth(500); struc.get().setPickOnBounds(false); }); splitPane.apply(struc -> { struc.skinProperty().subscribe(newValue -> { if (newValue != null) { Platform.runLater(() -> { struc.getChildrenUnmodifiable().forEach(node -> { node.setClip(null); node.setPickOnBounds(false); }); struc.lookupAll(".split-pane-divider").forEach(node -> node.setViewOrder(-1)); }); } }); }); splitPane.apply(struc -> { InputHelper.onKeyCombination( struc, new KeyCodeCombination(KeyCode.F, KeyCombination.SHORTCUT_DOWN), false, keyEvent -> { filterTrigger.trigger(); keyEvent.consume(); }); }); splitPane.style("browser"); var r = splitPane.build(); return r; } private Region createLeftSide(ObservableSubscriber filterTrigger) { Predicate applicable = storeEntryWrapper -> { if (!storeEntryWrapper.getEntry().getValidity().isUsable()) { return false; } if (storeEntryWrapper.getEntry().getStore() instanceof ShellStore) { return true; } return storeEntryWrapper.getEntry().getProvider().launchBrowser(model, storeEntryWrapper.getEntry(), null) != null; }; BiConsumer action = (w, busy) -> { var entry = w.getEntry(); if (!entry.getValidity().isUsable()) { return; } var a = entry.getProvider().launchBrowser(model, entry, busy); if (a != null) { ThreadHelper.runFailableAsync(() -> { a.run(); }); } }; var category = new SimpleObjectProperty<>( StoreViewState.get().getActiveCategory().getValue()); var filter = new SimpleStringProperty(); var bookmarkTopBar = new BrowserConnectionListFilterComp(filterTrigger, category, filter); var bookmarksList = new BrowserConnectionListComp( BindingsHelper.map( model.getSelectedEntry(), v -> v instanceof BrowserStoreSessionTab st ? st.getEntry().get() : null), applicable, action, category, filter); var bookmarksContainer = new StackComp(List.of(bookmarksList)).style("bookmarks-container"); bookmarksContainer .apply(struc -> { var rec = new Rectangle(); rec.widthProperty().bind(struc.widthProperty()); rec.heightProperty().bind(struc.heightProperty()); rec.setArcHeight(11); rec.setArcWidth(11); struc.getChildren().getFirst().setClip(rec); }) .vgrow(); var localDownloadStage = new BrowserTransferComp(model.getLocalTransfersStage()) .hide(Bindings.createBooleanBinding( () -> { if (model.getSessionEntries().size() == 0) { return true; } return false; }, model.getSessionEntries(), model.getSelectedEntry())); localDownloadStage.prefHeight(200); localDownloadStage.maxHeight(200); var vertical = new VerticalComp(List.of(bookmarkTopBar, bookmarksContainer, localDownloadStage)).style("left"); return vertical.build(); } private StackComp createSplitStack(SimpleDoubleProperty rightSplit, BrowserSessionTabsComp tabs) { var cache = new HashMap(); var splitStack = new StackComp(List.of()); splitStack.apply(struc -> struc.setPickOnBounds(false)); splitStack.apply(struc -> { model.getEffectiveRightTab().subscribe((newValue) -> { PlatformThread.runLaterIfNeeded(() -> { var all = model.getAllTabs(); cache.keySet().removeIf(browserSessionTab -> !all.contains(browserSessionTab)); if (newValue == null) { struc.getChildren().clear(); return; } var cached = cache.containsKey(newValue); if (!cached) { cache.put(newValue, newValue.comp().build()); } var r = cache.get(newValue); struc.getChildren().clear(); struc.getChildren().add(r); struc.setMinWidth(rightSplit.get()); struc.setPrefWidth(rightSplit.get()); struc.setMaxWidth(rightSplit.get()); }); }); rightSplit.addListener((observable, oldValue, newValue) -> { struc.setMinWidth(newValue.doubleValue()); struc.setPrefWidth(newValue.doubleValue()); struc.setMaxWidth(newValue.doubleValue()); }); var clip = new Rectangle(); clip.widthProperty().bind(struc.widthProperty()); clip.heightProperty().bind(struc.heightProperty()); struc.setClip(clip); AnchorPane.setBottomAnchor(struc, 0.0); AnchorPane.setRightAnchor(struc, 0.0); tabs.getHeaderHeight().subscribe(number -> { AnchorPane.setTopAnchor(struc, number.doubleValue()); }); }); return splitStack; } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/BrowserFullSessionModel.java ================================================ package io.xpipe.app.browser; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.file.BrowserHistorySavedState; import io.xpipe.app.browser.file.BrowserHistoryTabModel; import io.xpipe.app.browser.file.BrowserTransferModel; import io.xpipe.app.core.AppLayoutModel; import io.xpipe.app.ext.FileSystem; import io.xpipe.app.ext.FileSystemStore; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.storage.DataStoreEntryRef; import io.xpipe.app.util.BooleanScope; import io.xpipe.app.util.ThreadHelper; import io.xpipe.core.FailableFunction; import io.xpipe.core.FilePath; import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.beans.property.Property; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ObservableValue; import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; import javafx.collections.ObservableMap; import lombok.Getter; import java.util.*; @Getter public class BrowserFullSessionModel extends BrowserAbstractSessionModel { public static final BrowserFullSessionModel DEFAULT = new BrowserFullSessionModel(); private final BrowserTransferModel localTransfersStage = new BrowserTransferModel(this); private final Property draggingFiles = new SimpleBooleanProperty(); private final Property globalPinnedTab = new SimpleObjectProperty<>(); private final ObservableMap splits = FXCollections.observableHashMap(); private final ObservableValue effectiveRightTab = createEffectiveRightTab(); private final SequencedSet previousTabs = new LinkedHashSet<>(); public BrowserFullSessionModel() { sessionEntries.addListener((ListChangeListener) c -> { var v = globalPinnedTab.getValue(); if (v != null && !c.getList().contains(v)) { globalPinnedTab.setValue(null); } splits.keySet().removeIf(browserSessionTab -> !c.getList().contains(browserSessionTab)); }); selectedEntry.addListener((observable, oldValue, newValue) -> { if (newValue != null) { previousTabs.remove(newValue); previousTabs.add(newValue); } }); } public static void init() throws Exception { DEFAULT.openSync(new BrowserHistoryTabModel(DEFAULT), null); if (AppPrefs.get().pinLocalMachineOnStartup().get()) { var tab = new BrowserFileSystemTabModel( DEFAULT, DataStorage.get().local().ref(), ref -> ref.getStore() .createFileSystem()); try { DEFAULT.openSync(tab, null); DEFAULT.pinTab(tab); } catch (Exception ex) { // Don't fail startup if this operation fails ErrorEventFactory.fromThrowable(ex).handle(); } } } private ObservableValue createEffectiveRightTab() { return Bindings.createObjectBinding( () -> { var current = selectedEntry.getValue(); if (current == null) { return null; } if (!current.isCloseable()) { return null; } var split = splits.get(current); if (split != null) { return split; } var global = globalPinnedTab.getValue(); if (global == null) { return null; } if (global == selectedEntry.getValue()) { return null; } return global; }, globalPinnedTab, selectedEntry, splits); } public Set getAllTabs() { var set = new HashSet(); set.addAll(sessionEntries); set.addAll(splits.values()); if (globalPinnedTab.getValue() != null) { set.add(globalPinnedTab.getValue()); } return set; } public void splitTab(BrowserSessionTab tab, BrowserSessionTab split) { if (splits.containsKey(tab)) { return; } splits.put(tab, split); ThreadHelper.runFailableAsync(() -> { split.init(); }); } public void unsplitTab(BrowserSessionTab tab) { if (splits.values().remove(tab)) { ThreadHelper.runFailableAsync(() -> { tab.close(); }); } } public void pinTab(BrowserSessionTab tab) { if (tab.equals(globalPinnedTab.getValue())) { return; } globalPinnedTab.setValue(tab); var previousOthers = previousTabs.stream() .filter(browserSessionTab -> browserSessionTab != tab && browserSessionTab.isCloseable()) .toList(); if (previousOthers.size() > 0) { var prev = previousOthers.getLast(); getSelectedEntry().setValue(prev); } } public void unpinTab() { ThreadHelper.runFailableAsync(() -> { globalPinnedTab.setValue(null); }); } public void restoreState(BrowserHistorySavedState state) { ThreadHelper.runAsync(() -> { var l = new ArrayList<>(state.getEntries()); l.forEach(e -> { restoreStateAsync(e, null); // Don't try to run everything in parallel as that can be taxing ThreadHelper.sleep(1000); }); }); } public void restoreStateAsync(BrowserHistorySavedState.Entry e, BooleanProperty busy) { var storageEntry = DataStorage.get().getStoreEntryIfPresent(e.getUuid()); storageEntry.ifPresent(entry -> { openFileSystemAsync(entry.ref(), null, model -> e.getPath(), busy); }); } public void reset() { synchronized (BrowserFullSessionModel.this) { if (globalPinnedTab.getValue() != null) { globalPinnedTab.setValue(null); } var all = new ArrayList<>(sessionEntries); for (var o : all) { // Don't close busy connections gracefully // as we otherwise might lock up if (!o.canImmediatelyClose()) { continue; } // Prevent blocking of shutdown closeAsync(o); } if (all.size() > 0) { ThreadHelper.sleep(1000); } } // Delete all files localTransfersStage.clear(true); } public void openFileSystemAsync( DataStoreEntryRef store, FailableFunction, FileSystem, Exception> customFileSystemFactory, FailableFunction path, BooleanProperty externalBusy) { if (store == null) { return; } ThreadHelper.runFailableAsync(() -> { openFileSystemSync(store, customFileSystemFactory, path, externalBusy, true); }); } public BrowserFileSystemTabModel openFileSystemSync( DataStoreEntryRef store, FailableFunction, FileSystem, Exception> customFileSystemFactory, FailableFunction path, BooleanProperty externalBusy, boolean select) throws Exception { BrowserFileSystemTabModel model; try (var ignored = new BooleanScope(externalBusy != null ? externalBusy : new SimpleBooleanProperty()).start()) { try (var ignored2 = new BooleanScope(busy).exclusive().start()) { model = new BrowserFileSystemTabModel( this, store, customFileSystemFactory != null ? customFileSystemFactory : ref -> ref.getStore().createFileSystem()); model.init(); // Prevent multiple calls from interfering with each other synchronized (BrowserFullSessionModel.this) { sessionEntries.add(model); if (select) { AppLayoutModel.get().selectBrowser(); // The tab pane doesn't automatically select new tabs selectedEntry.setValue(model); } } } } if (path != null) { var applied = path.apply(model); if (applied != null) { model.initWithGivenDirectory(applied.toDirectory()); } else { model.initWithDefaultDirectory(); } } else { model.initWithDefaultDirectory(); } return model; } @Override public void closeSync(BrowserSessionTab e) { var split = splits.get(e); if (split != null) { split.close(); } previousTabs.remove(e); super.closeSync(e); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/BrowserSessionTab.java ================================================ package io.xpipe.app.browser; import io.xpipe.app.comp.BaseRegionBuilder; import io.xpipe.app.storage.DataStoreColor; import javafx.beans.property.BooleanProperty; import javafx.beans.property.Property; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ObservableValue; import lombok.Getter; @Getter public abstract class BrowserSessionTab { protected final BooleanProperty busy = new SimpleBooleanProperty(); protected final BrowserAbstractSessionModel browserModel; protected final Property splitTab = new SimpleObjectProperty<>(); public BrowserSessionTab(BrowserAbstractSessionModel browserModel) { this.browserModel = browserModel; } public abstract BaseRegionBuilder comp(); public abstract boolean canImmediatelyClose(); public abstract void init() throws Exception; public abstract void close(); public abstract ObservableValue getName(); public abstract String getIcon(); public abstract DataStoreColor getColor(); public boolean isCloseable() { return true; } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/BrowserSessionTabsComp.java ================================================ package io.xpipe.app.browser; import io.xpipe.app.comp.BaseRegionBuilder; import io.xpipe.app.comp.RegionBuilder; import io.xpipe.app.comp.SimpleRegionBuilder; import io.xpipe.app.comp.base.LoadingIconComp; import io.xpipe.app.comp.base.PrettyImageHelper; import io.xpipe.app.comp.base.StackComp; import io.xpipe.app.core.App; import io.xpipe.app.core.AppFontSizes; import io.xpipe.app.core.AppI18n; import io.xpipe.app.platform.LabelGraphic; import io.xpipe.app.platform.MenuHelper; import io.xpipe.app.platform.PlatformThread; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.util.BooleanScope; import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.beans.property.DoubleProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleDoubleProperty; import javafx.beans.value.ObservableDoubleValue; import javafx.collections.ListChangeListener; import javafx.css.PseudoClass; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.*; import javafx.scene.control.skin.TabPaneSkin; import javafx.scene.input.*; import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; import atlantafx.base.theme.Styles; import lombok.Getter; import java.util.*; import static atlantafx.base.theme.Styles.DENSE; import static atlantafx.base.theme.Styles.toggleStyleClass; import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS; public class BrowserSessionTabsComp extends SimpleRegionBuilder { private final BrowserFullSessionModel model; private final ObservableDoubleValue leftPadding; private final DoubleProperty rightPadding; @Getter private final DoubleProperty headerHeight; public BrowserSessionTabsComp( BrowserFullSessionModel model, ObservableDoubleValue leftPadding, DoubleProperty rightPadding) { this.model = model; this.leftPadding = leftPadding; this.rightPadding = rightPadding; this.headerHeight = new SimpleDoubleProperty(); } private static void setupKeyEvents(TabPane tabs) { tabs.addEventHandler(KeyEvent.KEY_PRESSED, keyEvent -> { var current = tabs.getSelectionModel().getSelectedItem(); if (current == null) { return; } if (new KeyCodeCombination(KeyCode.W, KeyCombination.SHORTCUT_DOWN).match(keyEvent)) { tabs.getTabs().remove(current); keyEvent.consume(); return; } if (new KeyCodeCombination(KeyCode.W, KeyCombination.SHORTCUT_DOWN, KeyCombination.SHIFT_DOWN) .match(keyEvent)) { tabs.getTabs().clear(); keyEvent.consume(); } if (keyEvent.getCode().isFunctionKey()) { var start = KeyCode.F1.getCode(); var index = keyEvent.getCode().getCode() - start; if (index < tabs.getTabs().size()) { tabs.getSelectionModel().select(index); keyEvent.consume(); return; } } var forward = new KeyCodeCombination(KeyCode.TAB, KeyCombination.SHORTCUT_DOWN); if (forward.match(keyEvent)) { var next = (tabs.getSelectionModel().getSelectedIndex() + 1) % tabs.getTabs().size(); tabs.getSelectionModel().select(next); keyEvent.consume(); return; } var back = new KeyCodeCombination(KeyCode.TAB, KeyCombination.SHORTCUT_DOWN, KeyCombination.SHIFT_DOWN); if (back.match(keyEvent)) { var previous = (tabs.getTabs().size() + tabs.getSelectionModel().getSelectedIndex() - 1) % tabs.getTabs().size(); tabs.getSelectionModel().select(previous); keyEvent.consume(); } }); } public Region createSimple() { var tabs = createTabPane(); var topBackground = RegionBuilder.hspacer().style("top-spacer").build(); leftPadding.subscribe(number -> { StackPane.setMargin(topBackground, new Insets(0, 0, 0, -number.doubleValue() - 3)); }); var stack = new StackPane(topBackground, tabs); stack.setAlignment(Pos.TOP_CENTER); topBackground.prefHeightProperty().bind(headerHeight); topBackground.maxHeightProperty().bind(topBackground.prefHeightProperty()); topBackground.prefWidthProperty().bind(tabs.widthProperty()); return stack; } private TabPane createTabPane() { var tabs = new TabPane(); tabs.setTabDragPolicy(TabPane.TabDragPolicy.REORDER); tabs.setTabMinWidth(Region.USE_PREF_SIZE); tabs.setTabMaxWidth(400); tabs.setTabClosingPolicy(ALL_TABS); tabs.setSkin(new TabPaneSkin(tabs)); Styles.toggleStyleClass(tabs, TabPane.STYLE_CLASS_FLOATING); toggleStyleClass(tabs, DENSE); setupCustomStyle(tabs); // Sync to guarantee that no external changes are made during this synchronized (model) { setupTabEntries(tabs); } setupKeyEvents(tabs); return tabs; } private void setupTabEntries(TabPane tabs) { var map = new HashMap(); // Restore state model.getSessionEntries().forEach(v -> { var t = createTab(tabs, v); map.put(v, t); tabs.getTabs().add(t); }); tabs.getSelectionModel() .select(model.getSessionEntries() .indexOf(model.getSelectedEntry().getValue())); // Used for ignoring changes by the tabpane when new tabs are added. We want to perform the selections manually! var addingTab = new SimpleBooleanProperty(); // Handle selection from platform tabs.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, newValue) -> { if (addingTab.get()) { return; } if (newValue == null) { model.getSelectedEntry().setValue(null); return; } var source = map.entrySet().stream() .filter(openFileSystemModelTabEntry -> openFileSystemModelTabEntry.getValue().equals(newValue)) .findAny() .map(Map.Entry::getKey) .orElse(null); model.getSelectedEntry().setValue(source); }); // Handle selection from model model.getSelectedEntry().addListener((observable, oldValue, newValue) -> { PlatformThread.runLaterIfNeeded(() -> { if (newValue == null) { tabs.getSelectionModel().select(null); return; } var toSelect = map.entrySet().stream() .filter(openFileSystemModelTabEntry -> openFileSystemModelTabEntry.getKey().equals(newValue)) .findAny() .map(Map.Entry::getValue) .orElse(null); if (toSelect == null || !tabs.getTabs().contains(toSelect)) { tabs.getSelectionModel().select(null); return; } tabs.getSelectionModel().select(toSelect); Platform.runLater(() -> { toSelect.getContent().requestFocus(); }); }); }); model.getSessionEntries().addListener((ListChangeListener) c -> { while (c.next()) { for (var r : c.getRemoved()) { PlatformThread.runLaterIfNeeded(() -> { var t = map.remove(r); tabs.getTabs().remove(t); }); } for (var a : c.getAddedSubList()) { PlatformThread.runLaterIfNeeded(() -> { try (var ignored = new BooleanScope(addingTab).start()) { var t = createTab(tabs, a); map.put(a, t); tabs.getTabs().add(t); } }); } } }); tabs.getTabs().addListener((ListChangeListener) c -> { while (c.next()) { for (var r : c.getRemoved()) { var source = map.entrySet().stream() .filter(openFileSystemModelTabEntry -> openFileSystemModelTabEntry.getValue().equals(r)) .findAny() .orElse(null); // Only handle close events that are triggered from the platform if (source == null) { continue; } model.closeAsync(source.getKey()); } } }); } private void setupCustomStyle(TabPane tabs) { tabs.skinProperty().subscribe(newValue -> { if (newValue != null) { Platform.runLater(() -> { tabs.setClip(null); tabs.setPickOnBounds(false); tabs.lookupAll(".tab-header-area").forEach(node -> { node.setClip(null); node.setPickOnBounds(false); var r = (Region) node; r.prefHeightProperty().bind(r.maxHeightProperty()); r.setMinHeight(Region.USE_PREF_SIZE); }); tabs.lookupAll(".headers-region").forEach(node -> { node.setPickOnBounds(false); var r = (Region) node; r.prefHeightProperty().bind(r.maxHeightProperty()); r.setMinHeight(Region.USE_PREF_SIZE); }); Region headerArea = (Region) tabs.lookup(".tab-header-area"); headerArea .paddingProperty() .bind(Bindings.createObjectBinding( () -> { var w = App.getApp().getStage().getWidth(); if (w >= 1000) { return new Insets(2, 0, 4, -leftPadding.get() + 3); } else { return new Insets(2, 0, 4, -leftPadding.get() - 4); } }, App.getApp().getStage().widthProperty(), leftPadding)); tabs.paddingProperty() .bind(Bindings.createObjectBinding( () -> { var w = App.getApp().getStage().getWidth(); if (w >= 1000) { return new Insets(0, 0, 0, -5); } else { return new Insets(0, 0, 0, 5); } }, App.getApp().getStage().widthProperty())); headerHeight.bind(headerArea.heightProperty()); }); } }); } private ContextMenu createContextMenu(TabPane tabs, Tab tab, BrowserSessionTab tabModel) { var cm = MenuHelper.createContextMenu(); if (tabModel.isCloseable()) { var unpin = MenuHelper.createMenuItem(LabelGraphic.none(), "unpinTab"); unpin.visibleProperty() .bind(PlatformThread.sync(Bindings.createBooleanBinding( () -> { return model.getGlobalPinnedTab().getValue() != null && model.getGlobalPinnedTab().getValue().equals(tabModel); }, model.getGlobalPinnedTab()))); unpin.setOnAction(event -> { model.unpinTab(); event.consume(); }); cm.getItems().add(unpin); var pin = MenuHelper.createMenuItem(LabelGraphic.none(), "pinTab"); pin.visibleProperty() .bind(PlatformThread.sync(Bindings.createBooleanBinding( () -> { return model.getGlobalPinnedTab().getValue() == null; }, model.getGlobalPinnedTab()))); pin.setOnAction(event -> { model.pinTab(tabModel); event.consume(); }); cm.getItems().add(pin); } var select = MenuHelper.createMenuItem(LabelGraphic.none(), "selectTab"); select.acceleratorProperty() .bind(Bindings.createObjectBinding( () -> { var start = KeyCode.F1.getCode(); var index = tabs.getTabs().indexOf(tab); var keyCode = Arrays.stream(KeyCode.values()) .filter(code -> code.getCode() == start + index) .findAny() .orElse(null); return keyCode != null ? new KeyCodeCombination(keyCode) : null; }, tabs.getTabs())); select.setOnAction(event -> { tabs.getSelectionModel().select(tab); event.consume(); }); cm.getItems().add(select); cm.getItems().add(new SeparatorMenuItem()); var close = MenuHelper.createMenuItem(LabelGraphic.none(), "closeTab"); close.setAccelerator(new KeyCodeCombination(KeyCode.W, KeyCombination.SHORTCUT_DOWN)); close.setOnAction(event -> { if (tab.isClosable()) { tabs.getTabs().remove(tab); } event.consume(); }); cm.getItems().add(close); var closeOthers = MenuHelper.createMenuItem(LabelGraphic.none(), "closeOtherTabs"); closeOthers.setOnAction(event -> { tabs.getTabs() .removeAll(tabs.getTabs().stream() .filter(t -> t != tab && t.isClosable()) .toList()); event.consume(); }); cm.getItems().add(closeOthers); var closeLeft = MenuHelper.createMenuItem(LabelGraphic.none(), "closeLeftTabs"); closeLeft.setOnAction(event -> { var index = tabs.getTabs().indexOf(tab); tabs.getTabs() .removeAll(tabs.getTabs().stream() .filter(t -> tabs.getTabs().indexOf(t) < index && t.isClosable()) .toList()); event.consume(); }); cm.getItems().add(closeLeft); var closeRight = MenuHelper.createMenuItem(LabelGraphic.none(), "closeRightTabs"); closeRight.setOnAction(event -> { var index = tabs.getTabs().indexOf(tab); tabs.getTabs() .removeAll(tabs.getTabs().stream() .filter(t -> tabs.getTabs().indexOf(t) > index && t.isClosable()) .toList()); event.consume(); }); cm.getItems().add(closeRight); var closeAll = MenuHelper.createMenuItem(LabelGraphic.none(), "closeAllTabs"); closeAll.setAccelerator( new KeyCodeCombination(KeyCode.W, KeyCombination.SHORTCUT_DOWN, KeyCombination.SHIFT_DOWN)); closeAll.setOnAction(event -> { tabs.getTabs() .removeAll( tabs.getTabs().stream().filter(t -> t.isClosable()).toList()); event.consume(); }); cm.getItems().add(closeAll); return cm; } private Tab createTab(TabPane tabs, BrowserSessionTab tabModel) { var tab = new Tab(); if (tabModel.isCloseable()) { tab.setContextMenu(createContextMenu(tabs, tab, tabModel)); } tab.setClosable(tabModel.isCloseable()); // Prevent closing while busy tab.setOnCloseRequest(event -> { if (!tabModel.canImmediatelyClose()) { event.consume(); } }); if (tabModel.getIcon() != null) { var loading = new LoadingIconComp(tabModel.getBusy(), AppFontSizes::base); loading.prefWidth(16); loading.prefHeight(16); var image = tabModel.getIcon(); var logo = PrettyImageHelper.ofFixedSizeSquare(image, 16); logo.apply(struc -> { struc.opacityProperty() .bind(PlatformThread.sync(Bindings.createDoubleBinding( () -> { return !tabModel.getBusy().get() ? 1.0 : 0.15; }, tabModel.getBusy()))); }); var stack = new StackComp(List.of(logo, loading)); tab.setGraphic(stack.build()); } if (tabModel.getBrowserModel() instanceof BrowserFullSessionModel sessionModel) { var global = PlatformThread.sync(sessionModel.getGlobalPinnedTab()); tab.textProperty() .bind(Bindings.createStringBinding( () -> { var n = tabModel.getName().getValue(); return (AppPrefs.get().censorMode().get() ? "*".repeat(n.length()) : n) + (global.getValue() == tabModel ? " (" + AppI18n.get("pinned") + ")" : ""); }, tabModel.getName(), global, AppI18n.activeLanguage(), AppPrefs.get().censorMode())); } else { tab.textProperty().bind(tabModel.getName()); } BaseRegionBuilder comp = tabModel.comp(); var compRegion = comp.build(); var empty = new StackPane(); empty.setMinWidth(180); empty.widthProperty().addListener((observable, oldValue, newValue) -> { if (tabModel.isCloseable() && tabs.getSelectionModel().getSelectedItem() == tab) { rightPadding.setValue(newValue.doubleValue()); } }); var split = new SplitPane(compRegion); if (tabModel.isCloseable()) { split.getItems().add(empty); } tabs.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, newValue) -> { if (tabModel.isCloseable() && newValue == tab) { rightPadding.setValue(empty.getWidth()); } }); model.getEffectiveRightTab().subscribe(browserSessionTab -> { PlatformThread.runLaterIfNeeded(() -> { if (browserSessionTab != null && split.getItems().size() > 1) { split.getItems().set(1, empty); } else if (browserSessionTab != null && split.getItems().size() == 1) { split.getItems().add(empty); } else if (browserSessionTab == null && split.getItems().size() > 1) { split.getItems().remove(1); } }); }); tab.setContent(split); var id = UUID.randomUUID().toString(); tab.setId(id); tabs.skinProperty().subscribe(newValue -> { if (newValue != null) { Platform.runLater(() -> { Label l = (Label) tabs.lookup("#" + id + " .tab-label"); l.setGraphicTextGap(7); var w = l.maxWidthProperty(); l.minWidthProperty().bind(w); l.prefWidthProperty().bind(w); if (!tabModel.isCloseable()) { l.pseudoClassStateChanged(PseudoClass.getPseudoClass("static"), true); } var close = (StackPane) tabs.lookup("#" + id + " .tab-close-button"); close.setPrefWidth(30); StackPane c = (StackPane) tabs.lookup("#" + id + " .tab-container"); c.getStyleClass().add("color-box"); var color = tabModel.getColor(); if (color != null) { c.getStyleClass().add(color.getId()); } else { c.getStyleClass().add("gray"); } c.addEventHandler(DragEvent.DRAG_ENTERED, de -> { // Prevent switch when dragging local files into app if (tabModel.isCloseable() && !de.getDragboard().hasContent(DataFormat.FILES)) { Platform.runLater(() -> tabs.getSelectionModel().select(tab)); de.consume(); } }); }); } }); return tab; } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/BrowserStoreSessionTab.java ================================================ package io.xpipe.app.browser; import io.xpipe.app.comp.BaseRegionBuilder; import io.xpipe.app.ext.DataStore; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.storage.DataStoreColor; import io.xpipe.app.storage.DataStoreEntryRef; import javafx.beans.property.SimpleStringProperty; import javafx.beans.value.ObservableValue; import lombok.Getter; @Getter public abstract class BrowserStoreSessionTab extends BrowserSessionTab { protected final DataStoreEntryRef entry; private final String name; public BrowserStoreSessionTab(BrowserAbstractSessionModel browserModel, DataStoreEntryRef entry) { super(browserModel); this.entry = entry; this.name = DataStorage.get().getStoreEntryDisplayName(entry.get()); } public abstract BaseRegionBuilder comp(); public abstract boolean canImmediatelyClose(); public abstract void init() throws Exception; public abstract void close(); @Override public ObservableValue getName() { return new SimpleStringProperty(name); } @Override public String getIcon() { return entry.get().getEffectiveIconFile(); } @Override public DataStoreColor getColor() { return DataStorage.get().getEffectiveColor(entry.get()); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/action/BrowserAction.java ================================================ package io.xpipe.app.browser.action; import io.xpipe.app.browser.BrowserFullSessionModel; import io.xpipe.app.browser.BrowserStoreSessionTab; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.core.AppLayoutModel; import io.xpipe.app.ext.FileSystemStore; import io.xpipe.app.hub.action.StoreAction; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.core.FilePath; import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.Getter; import lombok.experimental.SuperBuilder; import java.util.List; @SuperBuilder public abstract class BrowserAction extends StoreAction { protected final List files; @JsonIgnore @Getter protected BrowserFileSystemTabModel model; @JsonIgnore private List entries; @Override protected void beforeExecute() throws Exception { AppLayoutModel.get().selectBrowser(); if (model == null) { var found = BrowserFullSessionModel.DEFAULT.getAllTabs().stream() .filter(t -> t instanceof BrowserStoreSessionTab bs && bs.getEntry().equals(ref)) .findFirst(); if (found.isPresent()) { model = (BrowserFileSystemTabModel) found.get(); var target = getTargetDirectory(model); model.cdSync(target.toString()); model.startIfNeeded(); } else { model = BrowserFullSessionModel.DEFAULT.openFileSystemSync( ref.asNeeded(), null, model -> { return getTargetDirectory(model); }, null, true); } validateAutomatedAction(); } model.getBusy().set(true); // Restart in case we exited model.getFileSystem().reinitIfNeeded(); } @Override protected void afterExecute() { model.getBusy().set(false); } private void validateAutomatedAction() throws Exception { var bap = (BrowserActionProvider) getProvider(); if (!bap.isApplicable(getModel(), getEntries())) { throw ErrorEventFactory.expected( new IllegalArgumentException("Selection is not applicable for action type")); } if (files != null) { for (var f : files) { if (!model.getFileSystem().fileExists(f) && !model.getFileSystem().directoryExists(f)) { throw ErrorEventFactory.expected(new IllegalArgumentException("Target " + f + " does not exist")); } } } } private FilePath getTargetDirectory(BrowserFileSystemTabModel model) throws Exception { var isFile = model.getFileSystem().fileExists(files.getFirst()); if (isFile) { return files.getFirst().getParent(); } else { var dir = files.getFirst(); if (!model.getFileSystem().directoryExists(dir)) { throw ErrorEventFactory.expected( new IllegalArgumentException("File or directory does not exist: " + dir)); } return dir; } } public List getEntries() { if (entries != null) { return entries; } entries = files.stream() .map(filePath -> { var be = model.getFileList().getAll().getValue().stream() .filter(browserEntry -> browserEntry.getRawFileEntry().getPath().equals(filePath)) .findFirst(); if (be.isPresent()) { return be.get(); } var current = model.getCurrentDirectory(); if (current != null && filePath.equals(current.getPath())) { return new BrowserEntry(current, model.getFileList()); } return null; }) .filter(browserEntry -> browserEntry != null) .toList(); return entries; } public abstract static class BrowserActionBuilder> extends StoreActionBuilder { public void initEntries(BrowserFileSystemTabModel model, List entries) { ref(model.getEntry().asNeeded()); model(model); files(entries.stream() .map(browserEntry -> browserEntry.getRawFileEntry().getPath()) .toList()); entries(entries); } public void initFiles(BrowserFileSystemTabModel model, List entries) { ref(model.getEntry().asNeeded()); model(model); files(entries); } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/action/BrowserActionProvider.java ================================================ package io.xpipe.app.browser.action; import io.xpipe.app.action.ActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import java.util.List; public interface BrowserActionProvider extends ActionProvider { default boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return true; } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/action/BrowserActionProviders.java ================================================ package io.xpipe.app.browser.action; import io.xpipe.app.action.ActionProvider; public class BrowserActionProviders { public static BrowserActionProvider forClass(Class clazz) { return (BrowserActionProvider) ActionProvider.ALL.stream() .filter(actionProvider -> actionProvider.getClass().equals(clazz)) .findFirst() .orElseThrow(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/action/impl/ApplyFileEditActionProvider.java ================================================ package io.xpipe.app.browser.action.impl; import io.xpipe.app.action.AbstractAction; import io.xpipe.app.action.ActionProvider; import io.xpipe.app.browser.file.BrowserFileInput; import io.xpipe.app.browser.file.BrowserFileOutput; import io.xpipe.app.storage.DataStorage; import lombok.NonNull; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; import java.util.LinkedHashMap; import java.util.Map; public class ApplyFileEditActionProvider implements ActionProvider { @Override public String getId() { return "applyFileEdit"; } @Jacksonized @SuperBuilder public static class Action extends AbstractAction { @NonNull String target; @NonNull BrowserFileInput input; @NonNull BrowserFileOutput output; @Override public void executeImpl() throws Exception { output.beforeTransfer(); try (var out = output.open()) { input.open().transferTo(out); } try { output.onFinish(); } finally { input.onFinish(); } } @Override public boolean isMutation() { return true; } @Override public Map toDisplayMap() { var map = new LinkedHashMap(); map.put("Action", getDisplayName()); var system = output.target(); if (system.isPresent()) { map.put("System", DataStorage.get().getStoreEntryDisplayName(system.get())); } map.put("File", target); return map; } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/action/impl/BrowseInNativeManagerActionProvider.java ================================================ package io.xpipe.app.browser.action.impl; import io.xpipe.app.browser.action.BrowserAction; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.process.ShellControl; import io.xpipe.app.util.DesktopHelper; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; import java.util.List; public class BrowseInNativeManagerActionProvider implements BrowserActionProvider { @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { if (model.getFileSystem().getShell().isEmpty()) { return false; } return model.getFileSystem() .getShell() .orElseThrow() .getLocalSystemAccess() .supportsFileSystemAccess(); } @Override public String getId() { return "browseInNativeFileManager"; } @Jacksonized @SuperBuilder public static class Action extends BrowserAction { @Override public void executeImpl() throws Exception { ShellControl sc = model.getFileSystem().getShell().orElseThrow(); for (BrowserEntry entry : getEntries()) { var e = entry.getRawFileEntry().getPath(); var localFile = sc.getLocalSystemAccess().translateToLocalSystemPath(e); DesktopHelper.browseFileInDirectory(localFile.asLocalPath()); } } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/action/impl/ChgrpActionProvider.java ================================================ package io.xpipe.app.browser.action.impl; import io.xpipe.app.browser.action.BrowserAction; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import lombok.NonNull; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; import java.util.List; public class ChgrpActionProvider implements BrowserActionProvider { @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return model.getFileSystem().supportsChgrp(); } @Override public String getId() { return "chgrp"; } @Jacksonized @SuperBuilder public static class Action extends BrowserAction { @NonNull private final String group; private final boolean recursive; @Override public void executeImpl() throws Exception { for (BrowserEntry entry : getEntries()) { model.getFileSystem().chgrp(entry.getRawFileEntry().getPath(), group, recursive); } model.refreshBrowserEntriesSync(getEntries()); } @Override public boolean isMutation() { return true; } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/action/impl/ChmodActionProvider.java ================================================ package io.xpipe.app.browser.action.impl; import io.xpipe.app.browser.action.BrowserAction; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import lombok.NonNull; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; import java.util.List; public class ChmodActionProvider implements BrowserActionProvider { @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return model.getFileSystem().supportsChmod(); } @Override public String getId() { return "chmod"; } @Jacksonized @SuperBuilder public static class Action extends BrowserAction { @NonNull private final String permissions; private final boolean recursive; @Override public void executeImpl() throws Exception { for (BrowserEntry entry : getEntries()) { model.getFileSystem().chmod(entry.getRawFileEntry().getPath(), permissions, recursive); } model.refreshBrowserEntriesSync(getEntries()); } @Override public boolean isMutation() { return true; } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/action/impl/ChownActionProvider.java ================================================ package io.xpipe.app.browser.action.impl; import io.xpipe.app.browser.action.BrowserAction; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import lombok.NonNull; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; import java.util.List; public class ChownActionProvider implements BrowserActionProvider { @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return model.getFileSystem().supportsChown(); } @Override public String getId() { return "chown"; } @Jacksonized @SuperBuilder public static class Action extends BrowserAction { @NonNull private final String owner; private final boolean recursive; @Override public void executeImpl() throws Exception { for (BrowserEntry entry : getEntries()) { model.getFileSystem().chown(entry.getRawFileEntry().getPath(), owner, recursive); } model.refreshBrowserEntriesSync(getEntries()); } @Override public boolean isMutation() { return true; } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/action/impl/ComputeDirectorySizesActionProvider.java ================================================ package io.xpipe.app.browser.action.impl; import io.xpipe.app.browser.action.BrowserAction; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.ext.FileKind; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; import java.util.List; public class ComputeDirectorySizesActionProvider implements BrowserActionProvider { @Override public String getId() { return "computeDirectorySizes"; } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return model.getFileSystem().supportsDirectorySizes(); } @Jacksonized @SuperBuilder public static class Action extends BrowserAction { @Override public void executeImpl() throws Exception { var entries = getEntries(); if (entries.size() == 1 && entries.getFirst().getRawFileEntry().equals(model.getCurrentDirectory())) { entries = model.getFileList().getAll().getValue(); } for (BrowserEntry be : entries) { if (be.getRawFileEntry().resolved().getKind() != FileKind.DIRECTORY) { continue; } var size = model.getFileSystem() .getDirectorySize(be.getRawFileEntry().resolved().getPath()); var fileEntry = be.getRawFileEntry(); fileEntry.resolved().setSize("" + size); model.getFileList().updateEntry(be.getRawFileEntry().getPath(), fileEntry); } } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/action/impl/DeleteActionProvider.java ================================================ package io.xpipe.app.browser.action.impl; import io.xpipe.app.browser.action.BrowserAction; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.file.*; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; public class DeleteActionProvider implements BrowserActionProvider { @Override public String getId() { return "deleteFile"; } @Jacksonized @SuperBuilder public static class Action extends BrowserAction { @Override public void executeImpl() { var toDelete = getEntries().stream().map(entry -> entry.getRawFileEntry()).toList(); BrowserFileSystemHelper.delete(toDelete); model.refreshSync(); } @Override public boolean isMutation() { return true; } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/action/impl/MoveFileActionProvider.java ================================================ package io.xpipe.app.browser.action.impl; import io.xpipe.app.browser.action.BrowserAction; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.core.FilePath; import lombok.NonNull; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; public class MoveFileActionProvider implements BrowserActionProvider { @Override public String getId() { return "moveFile"; } @Jacksonized @SuperBuilder public static class Action extends BrowserAction { @NonNull FilePath target; @Override public void executeImpl() throws Exception { model.getFileSystem().move(getEntries().getFirst().getRawFileEntry().getPath(), target); model.refreshSync(); } @Override public boolean isMutation() { return true; } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/action/impl/NewDirectoryActionProvider.java ================================================ package io.xpipe.app.browser.action.impl; import io.xpipe.app.browser.action.BrowserAction; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.ext.FileKind; import lombok.NonNull; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; public class NewDirectoryActionProvider implements BrowserActionProvider { @Override public String getId() { return "newDirectory"; } @Jacksonized @SuperBuilder public static class Action extends BrowserAction { @NonNull String name; @Override public void executeImpl() throws Exception { for (BrowserEntry entry : getEntries()) { if (entry.getRawFileEntry().getKind() != FileKind.DIRECTORY) { continue; } var file = entry.getRawFileEntry().getPath().join(name); model.getFileSystem().mkdirs(file); } model.refreshSync(); } @Override public boolean isMutation() { return true; } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/action/impl/NewFileActionProvider.java ================================================ package io.xpipe.app.browser.action.impl; import io.xpipe.app.browser.action.BrowserAction; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.ext.FileKind; import lombok.NonNull; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; public class NewFileActionProvider implements BrowserActionProvider { @Override public String getId() { return "newFile"; } @Jacksonized @SuperBuilder public static class Action extends BrowserAction { @NonNull String name; @Override public void executeImpl() throws Exception { for (BrowserEntry entry : getEntries()) { if (entry.getRawFileEntry().getKind() != FileKind.DIRECTORY) { continue; } var file = entry.getRawFileEntry().getPath().join(name); model.getFileSystem().touch(file); } model.refreshSync(); } @Override public boolean isMutation() { return true; } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/action/impl/NewLinkActionProvider.java ================================================ package io.xpipe.app.browser.action.impl; import io.xpipe.app.browser.action.BrowserAction; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.ext.FileKind; import io.xpipe.core.FilePath; import lombok.NonNull; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; public class NewLinkActionProvider implements BrowserActionProvider { @Override public String getId() { return "newLink"; } @Jacksonized @SuperBuilder public static class Action extends BrowserAction { @NonNull String name; @NonNull FilePath target; @Override public void executeImpl() throws Exception { for (BrowserEntry entry : getEntries()) { if (entry.getRawFileEntry().getKind() != FileKind.DIRECTORY) { continue; } var file = entry.getRawFileEntry().getPath().join(name); model.getFileSystem().symbolicLink(file, target); } model.refreshSync(); } @Override public boolean isMutation() { return true; } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/action/impl/OpenDirectoryActionProvider.java ================================================ package io.xpipe.app.browser.action.impl; import io.xpipe.app.browser.action.BrowserAction; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.ext.FileKind; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; import java.util.List; public class OpenDirectoryActionProvider implements BrowserActionProvider { @Override public String getId() { return "openDirectory"; } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return entries.size() == 1 && entries.stream().allMatch(entry -> entry.getRawFileEntry().getKind() == FileKind.DIRECTORY); } @Jacksonized @SuperBuilder public static class Action extends BrowserAction { @Override public void executeImpl() { var first = getEntries().getFirst(); model.cdSync(first.getRawFileEntry().getPath().toString()); } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/action/impl/OpenFileDefaultActionProvider.java ================================================ package io.xpipe.app.browser.action.impl; import io.xpipe.app.browser.action.BrowserAction; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileOpener; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.ext.FileKind; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; import java.util.List; public class OpenFileDefaultActionProvider implements BrowserActionProvider { @Override public String getId() { return "openFileDefault"; } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return model.getFileList().getEditing().getValue() == null && entries.stream().allMatch(entry -> entry.getRawFileEntry().getKind() == FileKind.FILE); } @Jacksonized @SuperBuilder public static class Action extends BrowserAction { @Override public void executeImpl() { for (var entry : getEntries()) { BrowserFileOpener.openInDefaultApplication(model, entry.getRawFileEntry()); } } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/action/impl/OpenFileNativeDetailsActionProvider.java ================================================ package io.xpipe.app.browser.action.impl; import io.xpipe.app.browser.action.BrowserAction; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.ext.FileKind; import io.xpipe.app.process.CommandBuilder; import io.xpipe.app.process.LocalShell; import io.xpipe.app.process.ShellControl; import io.xpipe.core.OsType; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; import java.util.List; public class OpenFileNativeDetailsActionProvider implements BrowserActionProvider { @Override public String getId() { return "openFileNativeDetails"; } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { if (model.getFileSystem().getShell().isEmpty()) { return false; } var sc = model.getFileSystem().getShell().orElseThrow(); return sc.getLocalSystemAccess().supportsFileSystemAccess(); } @Jacksonized @SuperBuilder public static class Action extends BrowserAction { @Override public void executeImpl() throws Exception { ShellControl sc = model.getFileSystem().getShell().get(); for (BrowserEntry entry : getEntries()) { var e = entry.getRawFileEntry().getPath(); var localFile = sc.getLocalSystemAccess().translateToLocalSystemPath(e); switch (OsType.ofLocal()) { case OsType.Windows ignored -> { var shell = LocalShell.getLocalPowershell(); if (shell.isEmpty()) { return; } var parent = localFile.getParent(); // If we execute this on a drive root there will be no parent, so we have to check for that! var content = parent != null ? String.format( "$shell = New-Object -ComObject Shell.Application; $shell.NameSpace('%s').ParseName('%s').InvokeVerb('Properties')", parent, localFile.getFileName()) : String.format( "$shell = New-Object -ComObject Shell.Application; $shell.NameSpace('%s').Self.InvokeVerb('Properties')", localFile); // The Windows shell invoke verb functionality behaves kinda weirdly and only shows the window // as // long as the parent process is running. // So let's keep one process running // Ignore exit value as this can fail somehow (maybe if the system blocks shell com objects?) shell.get().command(content).notComplex().executeAndCheck(); } case OsType.Linux ignored -> { var dbus = String.format(""" dbus-send --session --print-reply --dest=org.freedesktop.FileManager1 --type=method_call /org/freedesktop/FileManager1 org.freedesktop.FileManager1.ShowItemProperties array:string:"file://%s" string:"" """, localFile); var success = sc.executeSimpleBooleanCommand(dbus); if (success) { return; } sc.command(CommandBuilder.of() .add("xdg-open") .addFile( entry.getRawFileEntry().getKind() == FileKind.DIRECTORY ? e : e.getParent())) .execute(); } case OsType.MacOs ignored -> { sc.osascriptCommand(String.format(""" set fileEntry to (POSIX file "%s") as text tell application "Finder" activate open information window of alias fileEntry end tell """, localFile)).execute(); } } } } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/action/impl/OpenFileNativeManagerActionProvider.java ================================================ package io.xpipe.app.browser.action.impl; import io.xpipe.app.browser.action.BrowserAction; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.process.ShellControl; import io.xpipe.app.util.DesktopHelper; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; import java.util.List; public class OpenFileNativeManagerActionProvider implements BrowserActionProvider { @Override public String getId() { return "openFileNativeManager"; } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { if (model.getFileSystem().getShell().isEmpty()) { return false; } var sc = model.getFileSystem().getShell().orElseThrow(); return sc.getLocalSystemAccess().supportsFileSystemAccess(); } @Jacksonized @SuperBuilder public static class Action extends BrowserAction { @Override public void executeImpl() throws Exception { ShellControl sc = model.getFileSystem().getShell().get(); for (BrowserEntry entry : getEntries()) { var e = entry.getRawFileEntry().getPath(); var localFile = sc.getLocalSystemAccess().translateToLocalSystemPath(e); DesktopHelper.browseFileInDirectory(localFile.asLocalPath()); } } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/action/impl/OpenFileWithActionProvider.java ================================================ package io.xpipe.app.browser.action.impl; import io.xpipe.app.browser.action.BrowserAction; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileOpener; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.ext.FileKind; import io.xpipe.core.OsType; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; import java.util.List; public class OpenFileWithActionProvider implements BrowserActionProvider { @Override public String getId() { return "openFileWith"; } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return OsType.ofLocal() == OsType.WINDOWS && entries.size() == 1 && entries.stream().allMatch(entry -> entry.getRawFileEntry().getKind() == FileKind.FILE); } @Jacksonized @SuperBuilder public static class Action extends BrowserAction { @Override public void executeImpl() { for (var entry : getEntries()) { BrowserFileOpener.openWithAnyApplication(model, entry.getRawFileEntry()); } } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/action/impl/RunCommandInBackgroundActionProvider.java ================================================ package io.xpipe.app.browser.action.impl; import io.xpipe.app.browser.action.BrowserAction; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.process.ProcessOutputException; import lombok.NonNull; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; public class RunCommandInBackgroundActionProvider implements BrowserActionProvider { @Override public String getId() { return "runFileInBackground"; } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return model.getFileSystem().getShell().isPresent(); } @Jacksonized @SuperBuilder public static class Action extends BrowserAction { @NonNull String command; @Override public void executeImpl() throws Exception { AtomicReference out = new AtomicReference<>(); AtomicReference err = new AtomicReference<>(); long exitCode; try (var cc = model.getFileSystem() .getShell() .orElseThrow() .command(command) .withWorkingDirectory(files.getFirst()) .start()) { var r = cc.readStdoutAndStderr(); out.set(r[0]); err.set(r[1]); exitCode = cc.getExitCode(); } model.refreshSync(); // Only throw actual error output if (exitCode != 0) { throw ErrorEventFactory.expected(ProcessOutputException.of(command, exitCode, out.get(), err.get())); } } @Override public boolean isMutation() { return true; } @Override public Map toDisplayMap() { var map = new LinkedHashMap<>(super.toDisplayMap()); map.remove("Title"); map.remove("Files"); map.put("Working Directory", files.getFirst().toString()); return map; } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/action/impl/RunCommandInBrowserActionProvider.java ================================================ package io.xpipe.app.browser.action.impl; import io.xpipe.app.browser.action.BrowserAction; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.util.CommandDialog; import lombok.NonNull; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class RunCommandInBrowserActionProvider implements BrowserActionProvider { @Override public String getId() { return "runCommandInBrowser"; } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return model.getFileSystem().getShell().isPresent(); } @Jacksonized @SuperBuilder public static class Action extends BrowserAction { @NonNull String command; @Override public void executeImpl() { var cmd = model.getFileSystem() .getShell() .orElseThrow() .command(command) .withWorkingDirectory(files.getFirst()); CommandDialog.runAndShow(cmd); model.refreshSync(); } @Override public boolean isMutation() { return true; } @Override public Map toDisplayMap() { var map = new LinkedHashMap<>(super.toDisplayMap()); map.remove("Files"); map.put("Working Directory", files.getFirst().toString()); return map; } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/action/impl/RunCommandInTerminalActionProvider.java ================================================ package io.xpipe.app.browser.action.impl; import io.xpipe.app.browser.action.BrowserAction; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import lombok.NonNull; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class RunCommandInTerminalActionProvider implements BrowserActionProvider { @Override public String getId() { return "runCommandInTerminal"; } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return model.getFileSystem().getShell().isPresent(); } @Jacksonized @SuperBuilder public static class Action extends BrowserAction { String title; @NonNull String command; @Override public void executeImpl() throws Exception { var wd = files.getFirst(); model.openTerminalSync( title, wd, model.getFileSystem() .getShell() .orElseThrow() .command(command) .withWorkingDirectory(wd), true); } @Override public boolean isMutation() { return true; } @Override public Map toDisplayMap() { var map = new LinkedHashMap<>(super.toDisplayMap()); map.remove("Title"); map.remove("Files"); map.put("Working Directory", files.getFirst().toString()); return map; } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/action/impl/TransferFilesActionProvider.java ================================================ package io.xpipe.app.browser.action.impl; import io.xpipe.app.action.AbstractAction; import io.xpipe.app.action.ActionProvider; import io.xpipe.app.action.StoreContextAction; import io.xpipe.app.browser.file.BrowserFileTransferOperation; import io.xpipe.app.ext.FileSystemStore; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.storage.DataStoreEntry; import io.xpipe.app.storage.DataStoreEntryRef; import lombok.NonNull; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; public class TransferFilesActionProvider implements ActionProvider { @Override public String getId() { return "transferFiles"; } @Jacksonized @SuperBuilder public static class Action extends AbstractAction implements StoreContextAction { @NonNull DataStoreEntryRef target; @NonNull BrowserFileTransferOperation operation; boolean download; @Override public void executeImpl() throws Exception { operation.execute(); } @Override public boolean isMutation() { return !download; } @Override public boolean forceConfirmation() { return operation.isMove(); } @Override public Map toDisplayMap() { var name = operation.isMove() ? "Move files" : getDisplayName(); var map = new LinkedHashMap(); map.put("Action", name); map.put( "Sources", operation.getFiles().stream() .map(fileEntry -> fileEntry.getName()) .collect(Collectors.joining("\n"))); map.put("Target system", DataStorage.get().getStoreEntryDisplayName(target.get())); map.put("Target directory", operation.getTarget().getPath().toString()); return map; } @Override public List getStoreEntryContext() { return List.of(target.get()); } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserBreadcrumbBar.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.comp.SimpleRegionBuilder; import io.xpipe.app.platform.PlatformThread; import io.xpipe.app.util.BooleanScope; import io.xpipe.app.util.GlobalTimer; import io.xpipe.app.util.ThreadHelper; import io.xpipe.core.FilePath; import javafx.application.Platform; import javafx.css.PseudoClass; import javafx.scene.Node; import javafx.scene.control.Button; import javafx.scene.control.ButtonBase; import javafx.scene.control.Label; import javafx.scene.input.DragEvent; import javafx.scene.input.TransferMode; import javafx.scene.layout.Region; import javafx.util.Callback; import atlantafx.base.controls.Breadcrumbs; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Objects; public class BrowserBreadcrumbBar extends SimpleRegionBuilder { private final BrowserFileSystemTabModel model; private Instant lastHoverUpdate; public BrowserBreadcrumbBar(BrowserFileSystemTabModel model) { this.model = model; } @Override protected Region createSimple() { Callback, ButtonBase> crumbFactory = crumb -> { var name = crumb.getValue().toString().equals("/") ? "/" : crumb.getValue().getFileName(); var btn = new Button(name, null); btn.setMnemonicParsing(false); btn.setFocusTraversable(false); btn.setOnDragEntered(event -> onDragEntered(btn, crumb.getValue())); btn.setOnDragOver(event -> onDragOver(event)); btn.setOnDragExited(event -> onDragExited(btn)); return btn; }; return createBreadcrumbs(crumbFactory, null); } private void onDragEntered(Button button, FilePath path) { button.pseudoClassStateChanged(PseudoClass.getPseudoClass("hover"), true); var timestamp = Instant.now(); lastHoverUpdate = timestamp; // Reduce printed window updates GlobalTimer.delay( () -> { if (!timestamp.equals(lastHoverUpdate)) { return; } model.cdAsync(path); }, Duration.ofMillis(500)); } private void onDragOver(DragEvent event) { event.acceptTransferModes(TransferMode.COPY_OR_MOVE); event.consume(); } private void onDragExited(Button button) { button.pseudoClassStateChanged(PseudoClass.getPseudoClass("hover"), false); lastHoverUpdate = null; } private Region createBreadcrumbs( Callback, ButtonBase> crumbFactory, Callback, ? extends Node> dividerFactory) { var breadcrumbs = new Breadcrumbs(); breadcrumbs.setMinWidth(0); model.getCurrentPath().subscribe(val -> { PlatformThread.runLaterIfNeeded(() -> { if (val == null) { breadcrumbs.setSelectedCrumb(null); return; } breadcrumbs.setDividerFactory(item -> { if (item == null) { return null; } if (item.isFirst() && item.getValue().toString().equals("/")) { return new Label(""); } return new Label(model.getFileSystem().getFileSeparator()); }); var elements = createBreadcrumbHierarchy(val); Breadcrumbs.BreadCrumbItem items = Breadcrumbs.buildTreeModel(elements.toArray(FilePath[]::new)); breadcrumbs.setSelectedCrumb(items); }); }); if (crumbFactory != null) { breadcrumbs.setCrumbFactory(crumbFactory); } if (dividerFactory != null) { breadcrumbs.setDividerFactory(dividerFactory); } breadcrumbs.selectedCrumbProperty().addListener((obs, old, val) -> { ThreadHelper.runAsync(() -> { BooleanScope.executeExclusive(model.getBusy(), () -> { model.cdSync(val != null ? val.getValue().toString() : null); var now = model.getCurrentPath().getValue(); // If we initiated a cd from the navbar, but it was rejected, reflect the changes if (!Objects.equals(now, val != null ? val.getValue() : null)) { Platform.runLater(() -> { breadcrumbs.setSelectedCrumb(old); }); } }); }); }); return breadcrumbs; } private List createBreadcrumbHierarchy(FilePath filePath) { var f = filePath.toDirectory().toString(); var list = new ArrayList(); int lastElementStart = 0; for (int i = 0; i < f.length(); i++) { if (f.charAt(i) == '\\' || f.charAt(i) == '/') { if (i - lastElementStart > 0) { list.add(FilePath.of(f.substring(0, i)).toDirectory()); } lastElementStart = i + 1; } } if (filePath.toString().startsWith("/")) { list.addFirst(FilePath.of("/")); } return list; } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserClipboard.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.ext.FileEntry; import io.xpipe.app.ext.ProcessControlProvider; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.platform.GlobalClipboard; import io.xpipe.app.platform.GlobalObjectProperty; import javafx.beans.property.Property; import javafx.scene.input.ClipboardContent; import javafx.scene.input.DataFormat; import javafx.scene.input.Dragboard; import lombok.SneakyThrows; import lombok.Value; import java.awt.datatransfer.Clipboard; import java.awt.datatransfer.DataFlavor; import java.io.File; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.UUID; import java.util.function.Consumer; import java.util.stream.Collectors; public class BrowserClipboard { public static final Property currentCopyClipboard = new GlobalObjectProperty<>(); private static final DataFormat DATA_FORMAT = new DataFormat("application/xpipe-file-list"); public static Instance currentDragClipboard; static { GlobalClipboard.addListener(new Consumer<>() { @Override @SuppressWarnings("unchecked") public void accept(Clipboard clipboard) { try { if (!clipboard.isDataFlavorAvailable(DataFlavor.javaFileListFlavor)) { return; } List data = (List) clipboard.getData(DataFlavor.javaFileListFlavor); // Sometimes file data can contain invalid chars. Why? var files = data.stream() .filter(file -> file.toString().chars().noneMatch(value -> Character.isISOControl(value))) .filter(file -> !file.toString().isBlank()) .filter(file -> file.exists()) .map(f -> f.toPath()) .toList(); if (files.size() == 0) { return; } var entries = new ArrayList(); for (Path file : files) { entries.add(BrowserLocalFileSystem.getLocalBrowserEntry(file)); } currentCopyClipboard.setValue( new Instance(UUID.randomUUID(), null, entries, BrowserFileTransferMode.COPY)); } catch (Exception e) { ErrorEventFactory.fromThrowable(e).expected().omit().handle(); } } }); } @SneakyThrows public static ClipboardContent startDrag( FileEntry base, List selected, BrowserFileTransferMode mode) { if (selected.isEmpty()) { return null; } var content = new ClipboardContent(); var id = UUID.randomUUID(); currentDragClipboard = new Instance(id, base, new ArrayList<>(selected), mode); content.put(DATA_FORMAT, currentDragClipboard.toClipboardString()); return content; } @SneakyThrows public static void startCopy(FileEntry base, List selected) { if (selected.isEmpty()) { currentCopyClipboard.setValue(null); return; } var id = UUID.randomUUID(); currentCopyClipboard.setValue(new Instance(id, base, new ArrayList<>(selected), BrowserFileTransferMode.COPY)); } public static Instance retrieveCopy() { return currentCopyClipboard.getValue(); } public static Instance retrieveDrag(Dragboard dragboard) { if (currentDragClipboard == null) { return null; } try { var s = dragboard.getContent(DATA_FORMAT); if (s != null && s.equals(currentDragClipboard.toClipboardString())) { var current = currentDragClipboard; currentDragClipboard = null; return current; } } catch (Exception ex) { return null; } return null; } @Value public static class Instance { UUID uuid; FileEntry baseDirectory; List entries; BrowserFileTransferMode mode; public String toClipboardString() { return entries.stream() .map(fileEntry -> "\"" + fileEntry.getRawFileEntry().getPath() + "\"") .collect(Collectors.joining(ProcessControlProvider.get() .getEffectiveLocalDialect() .getNewLine() .getNewLineString())); } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserConnectionListComp.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.comp.*; import io.xpipe.app.hub.comp.*; import io.xpipe.app.platform.PlatformThread; import io.xpipe.app.storage.DataStoreEntry; import javafx.beans.binding.Bindings; import javafx.beans.property.*; import javafx.beans.value.ObservableValue; import javafx.collections.FXCollections; import javafx.css.PseudoClass; import javafx.scene.control.Button; import javafx.scene.layout.Region; import java.util.HashSet; import java.util.Set; import java.util.function.BiConsumer; import java.util.function.Predicate; public final class BrowserConnectionListComp extends SimpleRegionBuilder { private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected"); private final ObservableValue selected; private final Predicate applicable; private final BiConsumer action; private final Property category; private final Property filter; public BrowserConnectionListComp( ObservableValue selected, Predicate applicable, BiConsumer action, Property category, Property filter) { this.selected = selected; this.applicable = applicable; this.action = action; this.category = category; this.filter = filter; } @Override protected Region createSimple() { var busyEntries = FXCollections.observableSet(new HashSet<>()); BiConsumer> augment = (s, comp) -> { comp.disable(Bindings.createBooleanBinding( () -> { return busyEntries.contains(s) || !applicable.test(s.getWrapper()); }, busyEntries)); comp.apply(struc -> { selected.addListener((observable, oldValue, newValue) -> { PlatformThread.runLaterIfNeeded(() -> { struc.pseudoClassStateChanged( SELECTED, newValue != null && newValue.equals(s.getWrapper().getEntry())); }); }); }); }; var section = new StoreSectionMiniComp( StoreSection.createTopLevel( StoreViewState.get().getAllEntries(), Set.of(), this::filter, filter, category, StoreViewState.get().getEntriesListVisibilityObservable(), StoreViewState.get().getEntriesListUpdateObservable(), new ReadOnlyBooleanWrapper(true)), augment, selectedAction -> { BooleanProperty busy = new SimpleBooleanProperty(false); action.accept(selectedAction.getWrapper(), busy); busy.addListener((observable, oldValue, newValue) -> { if (newValue) { busyEntries.add(selectedAction); } else { busyEntries.remove(selectedAction); } }); }, false); var r = section.vgrow().build(); r.getStyleClass().add("bookmark-list"); return r; } private boolean filter(StoreEntryWrapper w) { return applicable.test(w); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserConnectionListFilterComp.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.comp.SimpleRegionBuilder; import io.xpipe.app.comp.base.FilterComp; import io.xpipe.app.comp.base.HorizontalComp; import io.xpipe.app.core.AppFontSizes; import io.xpipe.app.hub.comp.DataStoreCategoryChoiceComp; import io.xpipe.app.hub.comp.StoreCategoryWrapper; import io.xpipe.app.hub.comp.StoreViewState; import io.xpipe.app.util.ObservableSubscriber; import javafx.beans.property.Property; import javafx.scene.layout.Region; import atlantafx.base.theme.Styles; import lombok.AllArgsConstructor; import lombok.Getter; import java.util.List; @Getter @AllArgsConstructor public final class BrowserConnectionListFilterComp extends SimpleRegionBuilder { private final ObservableSubscriber filterTrigger; private final Property category; private final Property filter; @Override protected Region createSimple() { var category = new DataStoreCategoryChoiceComp( StoreViewState.get().getAllConnectionsCategory(), StoreViewState.get().getActiveCategory(), this.category, true, ignored -> true) .style(Styles.LEFT_PILL) .apply(struc -> { AppFontSizes.base(struc); }); var filter = new FilterComp(this.filter) .style(Styles.RIGHT_PILL) .minWidth(0) .hgrow() .apply(struc -> { AppFontSizes.base(struc); filterTrigger.subscribe(() -> { struc.requestFocus(); }); }); var top = new HorizontalComp(List.of(category, filter)) .apply(struc -> struc.setFillHeight(true)) .apply(struc -> { var first = ((Region) struc.getChildren().get(0)); var second = ((Region) struc.getChildren().get(1)); first.prefHeightProperty().bind(second.heightProperty()); first.minHeightProperty().bind(second.heightProperty()); first.maxHeightProperty().bind(second.heightProperty()); AppFontSizes.xl(struc); }) .style("bookmarks-header") .build(); return top; } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserContextMenu.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.action.ActionProvider; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuItemProvider; import io.xpipe.app.core.AppFontSizes; import io.xpipe.app.platform.InputHelper; import io.xpipe.app.prefs.AppPrefs; import javafx.scene.control.ContextMenu; import javafx.scene.control.SeparatorMenuItem; import javafx.scene.input.KeyEvent; import java.util.ArrayList; import java.util.List; public final class BrowserContextMenu extends ContextMenu { private final BrowserFileSystemTabModel model; private final BrowserEntry source; private final boolean quickAccess; public BrowserContextMenu(BrowserFileSystemTabModel model, BrowserEntry source, boolean quickAccess) { this.model = model; this.source = source; this.quickAccess = quickAccess; createMenu(); } private void createMenu() { AppFontSizes.lg(getStyleableNode()); setAutoHide(!AppPrefs.get().limitedTouchscreenMode().get()); InputHelper.onLeft(this, false, e -> { hide(); e.consume(); }); var empty = source == null; var selected = new ArrayList<>( empty ? List.of(new BrowserEntry(model.getCurrentDirectory(), model.getFileList())) : quickAccess ? List.of() : model.getFileList().getSelection()); if (source != null && !selected.contains(source)) { selected.add(source); } for (var cat : BrowserMenuCategory.values()) { var all = ActionProvider.ALL.stream() .map(actionProvider -> actionProvider instanceof BrowserMenuItemProvider ba ? ba : null) .filter(browserActionProvider -> browserActionProvider != null) .filter(browserAction -> browserAction.getCategory() == cat) .filter(browserAction -> { var used = browserAction.resolveFilesIfNeeded(selected); if (!browserAction.isApplicable(model, used)) { return false; } if (!browserAction.acceptsEmptySelection() && empty) { return false; } return true; }) .toList(); if (all.size() == 0) { continue; } if (getItems().size() > 0) { getItems().add(new SeparatorMenuItem()); } for (var a : all) { var used = a.resolveFilesIfNeeded(selected); var item = a.toMenuItem(model, used); if (item != null) { getItems().add(item); if (a.getShortcut() != null) { addEventHandler(KeyEvent.KEY_PRESSED, event -> { if (!a.getShortcut().match(event)) { return; } hide(); }); } } } } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserDialogs.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.comp.base.ModalButton; import io.xpipe.app.comp.base.ModalOverlay; import io.xpipe.app.core.AppI18n; import io.xpipe.app.core.window.AppDialog; import io.xpipe.core.FilePath; import javafx.beans.property.SimpleObjectProperty; public class BrowserDialogs { public static FileConflictChoice showFileConflictDialog(FilePath file, boolean multiple) { var choice = new SimpleObjectProperty(); var key = multiple ? "fileConflictAlertContentMultiple" : "fileConflictAlertContent"; var w = multiple ? 900 : 400; var modal = ModalOverlay.of( "fileConflictAlertTitle", AppDialog.dialogText(AppI18n.observable(key, file)).prefWidth(w)); modal.addButton(new ModalButton("cancel", () -> choice.set(FileConflictChoice.CANCEL), true, false)); if (multiple) { modal.addButton(new ModalButton("skip", () -> choice.set(FileConflictChoice.SKIP), true, false)); modal.addButton(new ModalButton("skipAll", () -> choice.set(FileConflictChoice.SKIP_ALL), true, false)); } modal.addButton(new ModalButton("replace", () -> choice.set(FileConflictChoice.REPLACE), true, false)); if (multiple) { modal.addButton( new ModalButton("replaceAll", () -> choice.set(FileConflictChoice.REPLACE_ALL), true, false)); } modal.addButton(new ModalButton("rename", () -> choice.set(FileConflictChoice.RENAME), true, false)); if (multiple) { modal.addButton(new ModalButton("renameAll", () -> choice.set(FileConflictChoice.RENAME_ALL), true, false)); } modal.showAndWait(); return choice.get() != null ? choice.get() : FileConflictChoice.CANCEL; } public enum FileConflictChoice { CANCEL, SKIP, SKIP_ALL, REPLACE, REPLACE_ALL, RENAME, RENAME_ALL } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserEntry.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.browser.icon.BrowserIconDirectoryType; import io.xpipe.app.browser.icon.BrowserIconFileType; import io.xpipe.app.ext.FileEntry; import io.xpipe.app.ext.FileKind; import lombok.Getter; @Getter public class BrowserEntry { private final BrowserFileListModel model; private final FileEntry rawFileEntry; private final BrowserIconFileType fileType; private final BrowserIconDirectoryType directoryType; public BrowserEntry(FileEntry rawFileEntry, BrowserFileListModel model) { this.rawFileEntry = rawFileEntry; this.model = model; this.fileType = fileType(rawFileEntry); this.directoryType = directoryType(rawFileEntry); } private static BrowserIconFileType fileType(FileEntry rawFileEntry) { if (rawFileEntry == null) { return null; } rawFileEntry = rawFileEntry.resolved(); if (rawFileEntry.getKind() != FileKind.FILE) { return null; } for (var f : BrowserIconFileType.getAll()) { if (f.matches(rawFileEntry)) { return f; } } return null; } private static BrowserIconDirectoryType directoryType(FileEntry rawFileEntry) { if (rawFileEntry == null) { return null; } rawFileEntry = rawFileEntry.resolved(); if (rawFileEntry.getKind() != FileKind.DIRECTORY) { return null; } for (var f : BrowserIconDirectoryType.getAll()) { if (f.matches(rawFileEntry)) { return f; } } return null; } public String getIcon() { if (fileType != null) { return fileType.getIcon(); } else if (directoryType != null) { return directoryType.getIcon(); } else { return rawFileEntry != null && rawFileEntry.resolved().getKind() == FileKind.DIRECTORY ? "browser/default_folder.svg" : "browser/default_file.svg"; } } public String getFileName() { return getRawFileEntry().getPath().getFileName(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserFileDuplicates.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.ext.FileSystem; import io.xpipe.core.FilePath; import java.util.regex.Pattern; public class BrowserFileDuplicates { public static FilePath renameFileDuplicate(FileSystem fileSystem, FilePath target, boolean dir) throws Exception { // Who has more than 10 copies? for (int i = 0; i < 10; i++) { target = renameFile(target, dir); if ((dir && !fileSystem.directoryExists(target)) || (!dir && !fileSystem.fileExists(target))) { return target; } } return target; } private static FilePath renameFile(FilePath target, boolean dir) { var name = dir ? target.getFileName() : target.getBaseName().getFileName(); var pattern = Pattern.compile("(.+)_(\\d+)"); var matcher = pattern.matcher(name); if (matcher.matches()) { try { var number = Integer.parseInt(matcher.group(2)); var suffix = dir ? "" : target.getExtension().map(s -> "." + s).orElse(""); var newFile = target.getParent().join(matcher.group(1) + "_" + (number + 1) + suffix); return newFile; } catch (NumberFormatException ignored) { } } var ext = target.getExtension(); return FilePath.of( target.removeTrailingSlash().getBaseName() + "_" + 1 + (ext.isPresent() ? "." + ext.get() : "")); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserFileInput.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.core.window.AppDialog; import io.xpipe.app.ext.ConnectionFileSystem; import io.xpipe.app.ext.FileEntry; import io.xpipe.app.ext.FileInfo; import io.xpipe.app.process.CommandBuilder; import io.xpipe.app.process.ElevationFunction; import io.xpipe.core.FilePath; import io.xpipe.core.OsType; import java.io.InputStream; public interface BrowserFileInput { static BrowserFileInput openFileInput(BrowserFileSystemTabModel model, FileEntry file) throws Exception { var defOutput = createFileInputImpl(model, file, false); if (model.getFileSystem().getShell().isEmpty()) { return defOutput; } var sc = model.getFileSystem().getShell().orElseThrow(); var requiresSudo = sc.getOsType() != OsType.WINDOWS && requiresSudo(model, (FileInfo.Unix) file.getInfo(), file.getPath()); if (!requiresSudo) { return defOutput; } var elevate = AppDialog.confirm("fileReadSudo"); if (!elevate) { return defOutput; } var rootOutput = createFileInputImpl(model, file, true); return rootOutput; } private static boolean requiresSudo(BrowserFileSystemTabModel model, FileInfo.Unix info, FilePath filePath) throws Exception { if (model.getFileSystem().getShell().isEmpty()) { return false; } var sc = model.getFileSystem().getShell().get(); if (sc.view().isRoot()) { return false; } if (info != null) { var otherWrite = info.getPermissions().charAt(6) == 'r'; if (otherWrite) { return false; } var userOwned = info.getUid() != null && sc.view().getPasswdFile().getUidForUser(sc.view().user()) == info.getUid() || info.getUser() != null && sc.view().user().equals(info.getUser()); var userWrite = info.getPermissions().charAt(0) == 'r'; if (userOwned && userWrite) { return false; } } var test = model.getFileSystem() .getShell() .orElseThrow() .command(CommandBuilder.of().add("test", "-r").addFile(filePath)) .executeAndCheck(); return !test; } private static BrowserFileInput createFileInputImpl( BrowserFileSystemTabModel model, FileEntry file, boolean elevate) throws Exception { var shell = model.getFileSystem().getShell(); var sc = shell.isEmpty() ? null : elevate ? shell.orElseThrow() .identicalDialectSubShell() .elevated(ElevationFunction.elevated(null)) .start() : model.getFileSystem().getShell().orElseThrow().start(); var fs = elevate ? new ConnectionFileSystem(sc) : model.getFileSystem(); var output = new BrowserFileInput() { @Override public InputStream open() throws Exception { try { return fs.openInput(file.getPath()); } catch (Exception ex) { if (elevate) { fs.close(); } throw ex; } } @Override public void onFinish() throws Exception { if (elevate) { fs.close(); } } }; return output; } static BrowserFileInput none() { return new BrowserFileInput() { @Override public InputStream open() { return null; } @Override public void onFinish() {} }; } static BrowserFileInput of(InputStream in) { return new BrowserFileInput() { @Override public InputStream open() { return in; } @Override public void onFinish() {} }; } InputStream open() throws Exception; void onFinish() throws Exception; } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserFileListComp.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.browser.menu.BrowserMenuProviders; import io.xpipe.app.comp.RegionDescriptor; import io.xpipe.app.comp.SimpleRegionBuilder; import io.xpipe.app.core.AppFontSizes; import io.xpipe.app.core.AppI18n; import io.xpipe.app.ext.FileEntry; import io.xpipe.app.ext.FileInfo; import io.xpipe.app.ext.FileKind; import io.xpipe.app.platform.PlatformThread; import io.xpipe.app.util.*; import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.beans.property.*; import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; import javafx.css.PseudoClass; import javafx.geometry.Bounds; import javafx.scene.control.*; import javafx.scene.control.skin.TableViewSkin; import javafx.scene.control.skin.VirtualFlow; import javafx.scene.input.*; import javafx.scene.layout.Region; import atlantafx.base.theme.Styles; import lombok.SneakyThrows; import java.time.Duration; import java.time.Instant; import java.time.ZoneId; import java.util.*; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiConsumer; import static io.xpipe.app.util.HumanReadableFormat.byteCount; import static javafx.scene.control.TableColumn.SortType.ASCENDING; public final class BrowserFileListComp extends SimpleRegionBuilder { private static final PseudoClass EMPTY = PseudoClass.getPseudoClass("empty"); private static final PseudoClass FILE = PseudoClass.getPseudoClass("file"); private static final PseudoClass FOLDER = PseudoClass.getPseudoClass("folder"); private static final PseudoClass DRAG = PseudoClass.getPseudoClass("drag"); private static final PseudoClass DRAG_OVER = PseudoClass.getPseudoClass("drag-over"); private static final PseudoClass DRAG_INTO_CURRENT = PseudoClass.getPseudoClass("drag-into-current"); private final BrowserFileListModel fileList; private final StringProperty typedSelection = new SimpleStringProperty(""); public BrowserFileListComp(BrowserFileListModel fileList) { this.fileList = fileList; } private static void prepareTableScrollFix(TableView table) { table.lookupAll(".scroll-bar").stream() .filter(node -> node.getPseudoClassStates().contains(PseudoClass.getPseudoClass("horizontal"))) .findFirst() .ifPresent(node -> { Region region = (Region) node; region.setMinHeight(0); region.setPrefHeight(0); region.setMaxHeight(0); }); } @Override protected Region createSimple() { return createTable(); } @SuppressWarnings("unchecked") private TableView createTable() { var filenameCol = new TableColumn(); filenameCol.textProperty().bind(AppI18n.observable("name")); filenameCol.setCellValueFactory(param -> new SimpleStringProperty( param.getValue() != null ? param.getValue().getRawFileEntry().getPath().getFileName() : null)); filenameCol.setComparator(Comparator.comparing(String::toLowerCase)); filenameCol.setSortType(ASCENDING); filenameCol.setCellFactory(col -> new BrowserFileListNameCell(fileList, typedSelection, fileList.getEditing(), col.getTableView())); filenameCol.setReorderable(false); filenameCol.setResizable(false); var sizeCol = new TableColumn(); sizeCol.textProperty().bind(AppI18n.observable("size")); sizeCol.setCellValueFactory(param -> new ReadOnlyStringWrapper( param.getValue().getRawFileEntry().resolved().getSize())); sizeCol.setComparator((size1, size2) -> { if (size1 == null && size2 == null) { return 0; } if (size1 == null) { return -1; } if (size2 == null) { return 1; } try { long long1 = Long.parseLong(size1); long long2 = Long.parseLong(size2); return Long.compare(long1, long2); } catch (NumberFormatException e) { return size1.compareTo(size2); } }); sizeCol.setCellFactory(col -> new FileSizeCell()); sizeCol.setResizable(false); sizeCol.setReorderable(false); var mtimeCol = new TableColumn(); mtimeCol.textProperty().bind(AppI18n.observable("modified")); mtimeCol.setCellValueFactory(param -> new SimpleObjectProperty<>( param.getValue().getRawFileEntry().resolved().getDate())); mtimeCol.setCellFactory(col -> new FileTimeCell()); mtimeCol.setResizable(false); mtimeCol.setPrefWidth(150); mtimeCol.setReorderable(false); var modeCol = new TableColumn(); modeCol.textProperty().bind(AppI18n.observable("attributes")); modeCol.setCellValueFactory(param -> new SimpleObjectProperty<>( param.getValue().getRawFileEntry().resolved().getInfo() instanceof FileInfo.Unix u ? u.getPermissions() : null)); modeCol.setCellFactory(col -> new FileModeCell()); modeCol.setResizable(false); modeCol.setPrefWidth(120); modeCol.setSortable(false); modeCol.setReorderable(false); var ownerCol = new TableColumn(); ownerCol.textProperty().bind(AppI18n.observable("owner")); ownerCol.setCellValueFactory(param -> { return new SimpleObjectProperty<>(formatOwner(param.getValue())); }); ownerCol.setCellFactory(col -> new FileOwnerCell()); ownerCol.setSortable(false); ownerCol.setReorderable(false); ownerCol.setResizable(false); var table = new TableView(); table.setSkin(new TableViewSkin<>(table)); RegionDescriptor.builder() .nameKey("directoryContents") .showTooltips(false) .build() .apply(table); var placeholder = new Label(); var placeholderText = Bindings.createStringBinding( () -> { if (fileList.getFileSystemModel().getCurrentPath().get() == null) { return null; } if (fileList.getFileSystemModel().getBusy().get()) { return null; } return AppI18n.get("emptyDirectory"); }, AppI18n.activeLanguage(), fileList.getFileSystemModel().getBusy(), fileList.getFileSystemModel().getCurrentPath()); placeholder.textProperty().bind(PlatformThread.sync(placeholderText)); table.setPlaceholder(placeholder); AppFontSizes.base(placeholder); table.getStyleClass().add(Styles.STRIPED); table.getColumns().setAll(filenameCol, mtimeCol, modeCol, ownerCol, sizeCol); table.getSortOrder().add(filenameCol); table.setFocusTraversable(true); table.setSortPolicy(param -> { fileList.setComparator(table.getComparator()); return true; }); table.setFixedCellSize(30.0); prepareColumnVisibility(table, filenameCol, mtimeCol, modeCol, ownerCol, sizeCol); prepareTableScrollFix(table); prepareTableSelectionModel(table); prepareTableShortcuts(table); prepareTableEntries(table); prepareTableChanges(table, filenameCol, mtimeCol, modeCol, ownerCol); prepareTypedSelectionModel(table); table.setMinWidth(0); return table; } private void prepareColumnVisibility( TableView table, TableColumn filenameCol, TableColumn mtimeCol, TableColumn modeCol, TableColumn ownerCol, TableColumn sizeCol) { table.widthProperty().subscribe((newValue) -> { if (fileList.getFileSystemModel().getFileSystem().supportsOwnerColumn()) { ownerCol.setVisible(newValue.doubleValue() > 1000); } if (fileList.getFileSystemModel().getFileSystem().supportsModeColumn()) { modeCol.setVisible(newValue.doubleValue() > 600); } mtimeCol.setPrefWidth(newValue.doubleValue() == 0.0 || newValue.doubleValue() > 600 ? 150 : 110); sizeCol.setPrefWidth(newValue.doubleValue() == 0.0 || newValue.doubleValue() > 600 ? 120 : 90); var width = getFilenameWidth(table); filenameCol.setPrefWidth(width); }); } private double getFilenameWidth(TableView tableView) { var sum = tableView.getColumns().stream() .filter(tableColumn -> tableColumn.isVisible() && tableView.getColumns().indexOf(tableColumn) != 0) .mapToDouble(value -> value.getPrefWidth()) .sum() + 7; return Math.max(200, tableView.getWidth() - sum); } @SneakyThrows private String formatOwner(BrowserEntry param) { FileInfo.Unix unix = param.getRawFileEntry().resolved().getInfo() instanceof FileInfo.Unix u ? u : null; if (unix == null) { return null; } if (unix.getUid() == null && unix.getGid() == null && unix.getUser() == null && unix.getGroup() == null) { return null; } var m = fileList.getFileSystemModel(); var v = m.getFileSystem().getShell().isPresent() ? m.getFileSystem().getShell().get().view() : null; var user = unix.getUser() != null ? unix.getUser() : v != null ? v.getPasswdFile().getUsers().getOrDefault(unix.getUid(), "?") : null; var group = unix.getGroup() != null ? unix.getGroup() : v != null ? v.getGroupFile().getGroups().getOrDefault(unix.getGid(), "?") : null; var uid = unix.getUid() != null ? String.valueOf(unix.getUid()) : v != null ? v.getPasswdFile().getUidForUser(user) : null; var gid = unix.getGid() != null ? String.valueOf(unix.getGid()) : v != null ? v.getGroupFile().getGidForGroup(group) : null; var userFormat = user + (uid != null ? " [" + uid + "]" : ""); var groupFormat = group + (gid != null ? " [" + gid + "]" : ""); if (uid != null && uid.equals(gid) && user != null && user.equals(group)) { return userFormat; } if (uid == null && gid == null && user != null && user.equals(group)) { return userFormat; } return userFormat + " / " + groupFormat; } private void prepareTypedSelectionModel(TableView table) { AtomicReference lastFail = new AtomicReference<>(); table.addEventHandler(KeyEvent.KEY_PRESSED, event -> { updateTypedSelection(table, lastFail, event, false); }); table.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> { typedSelection.set(""); lastFail.set(null); }); fileList.getFileSystemModel().getCurrentPath().addListener((observable, oldValue, newValue) -> { typedSelection.set(""); lastFail.set(null); }); table.addEventFilter(KeyEvent.KEY_PRESSED, event -> { if (event.getCode() == KeyCode.ESCAPE) { typedSelection.set(""); lastFail.set(null); } }); } private void updateTypedSelection( TableView table, AtomicReference lastType, KeyEvent event, boolean recursive) { var typed = event.getText(); if (typed.isEmpty()) { return; } if (event.isControlDown() || event.isShiftDown() || event.isAltDown() || event.isMetaDown()) { return; } if (typedSelection.get().isEmpty() && typed.equals(" ")) { return; } var updated = typedSelection.get() + typed; var found = fileList.getShown().getValue().stream() .filter(browserEntry -> browserEntry.getFileName().toLowerCase().startsWith(updated.toLowerCase())) .findFirst(); if (found.isEmpty()) { if (typedSelection.get().isEmpty()) { return; } var inCooldown = lastType.get() != null && Duration.between(lastType.get(), Instant.now()).toMillis() < 1000; if (inCooldown) { lastType.set(Instant.now()); event.consume(); } else { lastType.set(null); typedSelection.set(""); table.getSelectionModel().clearSelection(); if (!recursive) { updateTypedSelection(table, lastType, event, true); } } return; } lastType.set(Instant.now()); typedSelection.set(updated); table.scrollTo(found.get()); table.getSelectionModel().clearAndSelect(fileList.getShown().getValue().indexOf(found.get())); event.consume(); } private void prepareTableSelectionModel(TableView table) { table.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); table.getSelectionModel().setCellSelectionEnabled(false); var updateFromModel = new BooleanScope(new SimpleBooleanProperty()); table.getSelectionModel().getSelectedItems().addListener((ListChangeListener) c -> { if (updateFromModel.get()) { return; } try (var ignored = updateFromModel) { // Attempt to preserve ordering. Works at least when selecting single entries var existing = new HashSet<>(fileList.getSelection()); c.getList().forEach(browserEntry -> { if (!existing.contains(browserEntry)) { fileList.getSelection().add(browserEntry); } }); fileList.getSelection().removeIf(browserEntry -> !c.getList().contains(browserEntry)); } }); fileList.getSelection().addListener((ListChangeListener) c -> { var existing = new HashSet<>(table.getSelectionModel().getSelectedItems()); var toApply = new HashSet<>(c.getList()); if (existing.equals(toApply)) { return; } Platform.runLater(() -> { var tableIndices = table.getSelectionModel().getSelectedItems().stream() .mapToInt(entry -> table.getItems().indexOf(entry)) .toArray(); var indices = c.getList().stream() .mapToInt(entry -> table.getItems().indexOf(entry)) .toArray(); if (Arrays.equals(indices, tableIndices)) { return; } if (indices.length == 0) { table.getSelectionModel().clearSelection(); return; } if (indices.length == 1) { table.getSelectionModel().clearAndSelect(indices[0]); } else { table.getSelectionModel().clearSelection(); table.getSelectionModel().selectIndices(indices[0], indices); } }); }); } private void prepareTableShortcuts(TableView table) { table.addEventFilter(KeyEvent.KEY_PRESSED, event -> { // Don't apply actions while renaming if (fileList.getEditing().getValue() != null) { return; } var selected = fileList.getSelection(); var action = BrowserMenuProviders.getFlattened(fileList.getFileSystemModel(), selected).stream() .filter(browserAction -> browserAction.isApplicable(fileList.getFileSystemModel(), selected) && browserAction.isActive(fileList.getFileSystemModel())) .filter(browserAction -> browserAction.getShortcut() != null) .filter(browserAction -> browserAction.getShortcut().match(event)) .findAny(); action.ifPresent(browserAction -> { // Prevent concurrent modification by creating copy on platform thread var selectionCopy = new ArrayList<>(selected); try { browserAction.execute(fileList.getFileSystemModel(), selectionCopy); } catch (Exception e) { throw new RuntimeException(e); } event.consume(); }); if (action.isPresent()) { return; } if (event.getCode() == KeyCode.ESCAPE) { table.getSelectionModel().clearSelection(); event.consume(); } }); } private void prepareTableEntries(TableView table) { var emptyEntry = new BrowserFileListCompEntry(table, table, null, fileList); table.setOnMouseClicked(event -> { emptyEntry.onMouseClick(event); }); table.setOnMouseDragEntered(event -> { emptyEntry.onMouseDragEntered(event); }); table.setOnDragOver(event -> { emptyEntry.onDragOver(event); }); table.setOnDragEntered(event -> { emptyEntry.onDragEntered(event); }); table.setOnDragDetected(event -> { emptyEntry.startDrag(event); }); table.setOnDragExited(event -> { emptyEntry.onDragExited(event); }); table.setOnDragDropped(event -> { emptyEntry.onDragDrop(event); }); table.setOnDragDone(event -> { emptyEntry.onDragDone(event); }); // Don't let the list view see this event // otherwise it unselects everything as it doesn't understand shift clicks table.addEventFilter(MouseEvent.MOUSE_CLICKED, t -> { if (t.getButton() == MouseButton.PRIMARY && t.isShiftDown() && t.getClickCount() == 1) { t.consume(); } }); table.setRowFactory(param -> { TableRow row = new TableRow<>(); row.accessibleTextProperty() .bind(Bindings.createStringBinding( () -> { return row.getItem() != null ? row.getItem().getFileName() : null; }, row.itemProperty())); row.focusTraversableProperty() .bind(Bindings.createBooleanBinding( () -> { return row.getItem() != null; }, row.itemProperty())); var listEntry = Bindings.createObjectBinding( () -> new BrowserFileListCompEntry(table, row, row.getItem(), fileList), row.itemProperty()); // Don't let the list view see this event // otherwise it unselects everything as it doesn't understand shift clicks row.addEventFilter(MouseEvent.MOUSE_PRESSED, t -> { if (t.getButton() == MouseButton.PRIMARY && t.isShiftDown()) { listEntry.get().onMouseShiftClick(t); } }); row.itemProperty().addListener((observable, oldValue, newValue) -> { row.pseudoClassStateChanged(DRAG, false); row.pseudoClassStateChanged(DRAG_OVER, false); }); row.itemProperty().addListener((observable, oldValue, newValue) -> { row.pseudoClassStateChanged(EMPTY, newValue == null); row.pseudoClassStateChanged( FILE, newValue != null && newValue.getRawFileEntry().getKind() != FileKind.DIRECTORY); row.pseudoClassStateChanged( FOLDER, newValue != null && newValue.getRawFileEntry().getKind() == FileKind.DIRECTORY); }); fileList.getDraggedOverDirectory().addListener((observable, oldValue, newValue) -> { row.pseudoClassStateChanged(DRAG_OVER, newValue != null && newValue == row.getItem()); }); fileList.getDraggedOverEmpty().addListener((observable, oldValue, newValue) -> { table.pseudoClassStateChanged(DRAG_INTO_CURRENT, newValue); }); row.setOnMouseClicked(event -> { listEntry.get().onMouseClick(event); }); row.setOnMouseDragEntered(event -> { listEntry.get().onMouseDragEntered(event); }); row.setOnDragEntered(event -> { listEntry.get().onDragEntered(event); }); row.setOnDragOver(event -> { borderScroll(table, event); listEntry.get().onDragOver(event); }); row.setOnDragDetected(event -> { listEntry.get().startDrag(event); }); row.setOnDragExited(event -> { listEntry.get().onDragExited(event); }); row.setOnDragDropped(event -> { listEntry.get().onDragDrop(event); }); row.setOnDragDone(event -> { listEntry.get().onDragDone(event); }); return row; }); } private void prepareTableChanges( TableView table, TableColumn filenameCol, TableColumn mtimeCol, TableColumn modeCol, TableColumn ownerCol) { var lastDir = new SimpleObjectProperty(); BiConsumer, List> updateHandler = (o, n) -> { PlatformThread.runLaterIfNeeded(() -> { // Optimization for single entry updates if (o != null && n != null && o.size() == n.size()) { var left = new HashSet<>(n); o.forEach(left::remove); if (left.size() == 1) { var updatedEntry = left.iterator().next(); var found = o.stream() .filter(browserEntry -> browserEntry .getRawFileEntry() .getPath() .equals(updatedEntry.getRawFileEntry().getPath())) .findFirst(); if (found.isPresent()) { table.refresh(); table.getItems().set(table.getItems().indexOf(found.get()), updatedEntry); return; } } } table.setDisable(true); var newItems = new ArrayList<>(fileList.getShown().getValue()); table.getItems().clear(); var hasModifiedDate = newItems.size() == 0 || newItems.stream() .anyMatch(entry -> entry.getRawFileEntry().getDate() != null); if (!hasModifiedDate) { mtimeCol.setVisible(false); } else { mtimeCol.setVisible(true); } var hasOwner = fileList.getAll().getValue().stream() .map(browserEntry -> formatOwner(browserEntry)) .anyMatch(s -> s != null); if (hasOwner) { ownerCol.setPrefWidth(fileList.getAll().getValue().stream() .map(browserEntry -> formatOwner(browserEntry)) .map(s -> s != null ? s.length() * 9 : 0) .max(Comparator.naturalOrder()) .orElse(150)); } else { ownerCol.setPrefWidth(0); } if (!fileList.getFileSystemModel().getFileSystem().supportsModeColumn()) { modeCol.setVisible(false); } else { modeCol.setVisible(table.getWidth() > 600); } if (!fileList.getFileSystemModel().getFileSystem().supportsOwnerColumn()) { ownerCol.setVisible(false); } else { if (table.getWidth() > 1000) { ownerCol.setVisible(hasOwner); } else if (!hasOwner) { ownerCol.setVisible(false); } } // Sort the list ourselves as sorting the table would incur a lot of cell updates var obs = FXCollections.observableList(newItems); table.getItems().setAll(obs); var width = getFilenameWidth(table); filenameCol.setPrefWidth(width); TableViewSkin skin = (TableViewSkin) table.getSkin(); var currentDirectory = fileList.getFileSystemModel().getCurrentDirectory(); if (skin != null && !Objects.equals(lastDir.get(), currentDirectory)) { VirtualFlow flow = (VirtualFlow) skin.getChildren().get(1); ScrollBar vbar = (ScrollBar) flow.getChildrenUnmodifiable().get(2); if (vbar.getValue() != 0.0) { table.scrollTo(0); } } lastDir.setValue(currentDirectory); table.setDisable(false); }); }; updateHandler.accept(null, null); fileList.getShown().addListener((observable, oldValue, newValue) -> { // Delay to prevent internal tableview exceptions when sorting var isSortChange = oldValue.size() == newValue.size() && new HashSet<>(oldValue).containsAll(newValue); if (isSortChange) { Platform.runLater(() -> { updateHandler.accept(oldValue, newValue); }); } else { updateHandler.accept(oldValue, newValue); } }); fileList.getFileSystemModel().getCurrentPath().addListener((observable, oldValue, newValue) -> { if (oldValue == null) { updateHandler.accept(null, null); } }); } private void borderScroll(TableView tableView, DragEvent event) { TableViewSkin skin = (TableViewSkin) tableView.getSkin(); if (skin == null) { return; } VirtualFlow flow = (VirtualFlow) skin.getChildren().get(1); ScrollBar vbar = (ScrollBar) flow.getChildrenUnmodifiable().get(2); if (!vbar.isVisible()) { return; } double proximity = 100; Bounds tableBounds = tableView.localToScene(tableView.getBoundsInLocal()); double dragY = event.getSceneY(); // Include table header as well in calculations double topYProximity = tableBounds.getMinY() + proximity + 20; double bottomYProximity = tableBounds.getMaxY() - proximity; // clamp new values between 0 and 1 to prevent scrollbar flicking around at the edges if (dragY < topYProximity) { var scrollValue = Math.min(topYProximity - dragY, 100) / 10000.0; vbar.setValue(Math.max(vbar.getValue() - scrollValue, 0)); } else if (dragY > bottomYProximity) { var scrollValue = Math.min(dragY - bottomYProximity, 100) / 10000.0; vbar.setValue(Math.min(vbar.getValue() + scrollValue, 1.0)); } } private static class FileSizeCell extends TableCell { @Override protected void updateItem(String fileSize, boolean empty) { super.updateItem(fileSize, empty); if (empty || getTableRow() == null || getTableRow().getItem() == null) { setText(null); } else { if (fileSize != null) { try { var l = Long.parseLong(fileSize); setText(byteCount(l)); } catch (NumberFormatException e) { setText(fileSize); } } else { setText(null); } } } } private static class FileModeCell extends TableCell { @Override protected void updateItem(String mode, boolean empty) { super.updateItem(mode, empty); if (empty || getTableRow() == null || getTableRow().getItem() == null) { setText(null); } else { setText(mode); } } } private static class FileOwnerCell extends TableCell { public FileOwnerCell() { setTextOverrun(OverrunStyle.CENTER_ELLIPSIS); } @Override protected void updateItem(String owner, boolean empty) { super.updateItem(owner, empty); if (empty || getTableRow() == null || getTableRow().getItem() == null) { setText(null); } else { setText(owner); } } } private static class FileTimeCell extends TableCell { @Override protected void updateItem(Instant fileTime, boolean empty) { super.updateItem(fileTime, empty); if (empty) { setText(null); } else { setText( fileTime != null ? HumanReadableFormat.date( fileTime.atZone(ZoneId.systemDefault()).toLocalDateTime()) : ""); } } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserFileListCompEntry.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.browser.BrowserFullSessionModel; import io.xpipe.app.core.AppSystemInfo; import io.xpipe.app.ext.FileKind; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.util.BooleanScope; import io.xpipe.app.util.GlobalTimer; import io.xpipe.app.util.ThreadHelper; import io.xpipe.core.OsType; import javafx.scene.Node; import javafx.scene.control.ContextMenu; import javafx.scene.control.TableView; import javafx.scene.image.Image; import javafx.scene.input.*; import lombok.Getter; import java.io.IOException; import java.nio.file.InvalidPathException; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.Objects; @Getter public class BrowserFileListCompEntry { private final TableView tv; private final Node row; private final BrowserEntry item; private final BrowserFileListModel model; private Instant lastHoverUpdate; private ContextMenu lastContextMenu; public BrowserFileListCompEntry( TableView tv, Node row, BrowserEntry item, BrowserFileListModel model) { this.tv = tv; this.row = row; this.item = item; this.model = model; } public void onMouseClick(MouseEvent t) { if (lastContextMenu != null) { lastContextMenu.hide(); lastContextMenu = null; } if (showContextMenu(t)) { var cm = new BrowserContextMenu(model.getFileSystemModel(), item, false); cm.show(row, t.getScreenX(), t.getScreenY()); lastContextMenu = cm; t.consume(); return; } if (t.getButton() == MouseButton.BACK) { ThreadHelper.runFailableAsync(() -> { BooleanScope.executeExclusive(model.getFileSystemModel().getBusy(), () -> { model.getFileSystemModel().backSync(1); }); }); t.consume(); return; } if (t.getButton() == MouseButton.FORWARD) { ThreadHelper.runFailableAsync(() -> { BooleanScope.executeExclusive(model.getFileSystemModel().getBusy(), () -> { model.getFileSystemModel().forthSync(1); }); }); t.consume(); return; } if (item == null) { // Only clear for normal clicks if (t.isStillSincePress()) { model.getSelection().clear(); if (tv != null) { tv.requestFocus(); } } t.consume(); return; } row.requestFocus(); if (t.getClickCount() == 2 && t.getButton() == MouseButton.PRIMARY) { model.onDoubleClick(item); t.consume(); } t.consume(); } private boolean showContextMenu(MouseEvent event) { if (item == null) { return event.getButton() == MouseButton.SECONDARY; } if (item.getRawFileEntry().resolved().getKind() == FileKind.DIRECTORY) { return event.getButton() == MouseButton.SECONDARY; } if (item.getRawFileEntry().resolved().getKind() != FileKind.DIRECTORY) { return event.getButton() == MouseButton.SECONDARY || !AppPrefs.get().editFilesWithDoubleClick().get() && event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2; } return false; } public void onMouseShiftClick(MouseEvent t) { if (t.getButton() != MouseButton.PRIMARY) { return; } var all = tv.getItems(); var index = item != null ? all.indexOf(item) : all.size() - 1; var min = Math.min( index, tv.getSelectionModel().getSelectedIndices().stream() .mapToInt(value -> value) .min() .orElse(1)); var max = Math.max( index, tv.getSelectionModel().getSelectedIndices().stream() .mapToInt(value -> value) .max() .orElse(all.indexOf(item))); var toSelect = new ArrayList(); for (int i = min; i <= max; i++) { if (!model.getSelection().contains(model.getShown().getValue().get(i))) { toSelect.add(model.getShown().getValue().get(i)); } } model.getSelection().addAll(toSelect); t.consume(); } private boolean acceptsDrop(DragEvent event) { // Accept drops from outside the app window if (event.getGestureSource() == null) { // Don't accept 7zip temp files if (OsType.ofLocal() == OsType.WINDOWS && event.getDragboard().getFiles().stream().anyMatch(file -> { try { return file.toPath() .toRealPath() .startsWith( AppSystemInfo.ofWindows().getTemp()) && file.toPath().getFileName().toString().matches("7z[A-Z0-9]+"); } catch (IOException ignored) { return false; } })) { return false; } return true; } BrowserClipboard.Instance cb = BrowserClipboard.currentDragClipboard; if (cb == null) { return false; } if (model.getFileSystemModel().getCurrentDirectory() == null) { return false; } if (!Objects.equals( model.getFileSystemModel().getFileSystem(), cb.getEntries().getFirst().getRawFileEntry().getFileSystem())) { return true; } // Prevent drag and drops of files into the current directory if (cb.getBaseDirectory() != null && cb.getBaseDirectory() .getPath() .equals(model.getFileSystemModel().getCurrentDirectory().getPath()) && (item == null || item.getRawFileEntry().resolved().getKind() != FileKind.DIRECTORY)) { return false; } // Prevent dropping items onto themselves if (item != null && cb.getEntries().contains(item)) { return false; } return true; } public void onDragDrop(DragEvent event) { model.getDraggedOverEmpty().setValue(false); model.getDraggedOverDirectory().setValue(null); // Accept drops from outside the app window if (event.getGestureSource() == null && event.getDragboard().hasFiles()) { Dragboard db = event.getDragboard(); var list = db.getFiles().stream() .map(file -> { try { return file.toPath(); } catch (InvalidPathException ignored) { return null; } }) .filter(path -> path != null) .toList(); var target = item != null && item.getRawFileEntry().resolved().getKind() == FileKind.DIRECTORY ? item.getRawFileEntry().resolved() : model.getFileSystemModel().getCurrentDirectory(); model.getFileSystemModel().dropLocalFilesIntoAsync(target, list); event.setDropCompleted(true); event.consume(); } // Accept drops from inside the app window if (event.getGestureSource() != null) { var db = BrowserClipboard.retrieveDrag(event.getDragboard()); if (db == null) { return; } var files = db.getEntries(); var target = item != null && item.getRawFileEntry().resolved().getKind() == FileKind.DIRECTORY ? item.getRawFileEntry().resolved() : model.getFileSystemModel().getCurrentDirectory(); // We could already have changed the current dir if (target == null) { return; } model.getFileSystemModel() .dropFilesIntoAsync( target, files.stream() .map(browserEntry -> browserEntry.getRawFileEntry().resolved()) .toList(), db.getMode()); event.setDropCompleted(true); event.consume(); } } public void onDragExited(DragEvent event) { if (item != null && item.getRawFileEntry().resolved().getKind() == FileKind.DIRECTORY) { model.getDraggedOverDirectory().setValue(null); } else { model.getDraggedOverEmpty().setValue(false); } lastHoverUpdate = null; event.consume(); } public void startDrag(MouseEvent event) { if (item == null) { return; } if (event.getButton() != MouseButton.PRIMARY) { return; } if (model.getFileSystemModel().getBrowserModel() instanceof BrowserFullSessionModel sessionModel) { sessionModel.getDraggingFiles().setValue(true); } var selected = model.getSelection(); Dragboard db = row.startDragAndDrop(TransferMode.COPY); db.setContent(BrowserClipboard.startDrag( model.getFileSystemModel().getCurrentDirectory(), selected, event.isAltDown() ? BrowserFileTransferMode.MOVE : BrowserFileTransferMode.NORMAL)); Image image = BrowserFileSelectionListComp.snapshot(selected); db.setDragView(image, -20, 15); event.setDragDetect(true); event.consume(); } public void onDragDone(DragEvent event) { if (model.getFileSystemModel().getBrowserModel() instanceof BrowserFullSessionModel sessionModel) { sessionModel.getDraggingFiles().setValue(false); event.consume(); } } public void onDragEntered(DragEvent event) { event.consume(); if (!acceptsDrop(event)) { return; } var isDir = item != null && item.getRawFileEntry().resolved().getKind() == FileKind.DIRECTORY; model.getDraggedOverEmpty().setValue(!isDir); model.getDraggedOverDirectory().setValue(item); if (!isDir) { return; } var timestamp = Instant.now(); lastHoverUpdate = timestamp; // Reduce printed window updates GlobalTimer.delay( () -> { if (!timestamp.equals(lastHoverUpdate)) { return; } if (item != model.getDraggedOverDirectory().getValue()) { return; } model.getFileSystemModel() .cdAsync(item.getRawFileEntry().resolved().getPath()); }, Duration.ofMillis(500)); } public void onDragOver(DragEvent event) { event.consume(); if (!acceptsDrop(event)) { return; } event.acceptTransferModes(TransferMode.COPY_OR_MOVE); event.consume(); } @SuppressWarnings("unchecked") public void onMouseDragEntered(MouseDragEvent event) { event.consume(); if (model.getFileSystemModel().getCurrentDirectory() == null) { return; } if (item == null) { return; } var tv = ((TableView) row.getParent().getParent().getParent().getParent()); tv.getSelectionModel().select(item); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserFileListFilterComp.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.comp.*; import io.xpipe.app.comp.base.TextFieldComp; import io.xpipe.app.platform.InputHelper; import io.xpipe.app.platform.PlatformThread; import javafx.beans.property.Property; import javafx.beans.property.SimpleBooleanProperty; import javafx.geometry.Pos; import javafx.scene.control.Button; import javafx.scene.control.TextField; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import javafx.scene.layout.HBox; import atlantafx.base.theme.Styles; import org.kordamp.ikonli.javafx.FontIcon; public class BrowserFileListFilterComp extends RegionStructureBuilder { private final BrowserFileSystemTabModel model; private final Property filterString; public BrowserFileListFilterComp(BrowserFileSystemTabModel model, Property filterString) { this.model = model; this.filterString = filterString; } @Override public Structure createBase() { var expanded = new SimpleBooleanProperty(); var text = new TextFieldComp(filterString, false).build(); var button = new Button(); RegionDescriptor.builder() .nameKey("search") .shortcut(new KeyCodeCombination(KeyCode.F, KeyCombination.SHORTCUT_DOWN)) .build() .apply(button); button.minWidthProperty().bind(button.heightProperty()); InputHelper.onExactKeyCode(text, KeyCode.ESCAPE, true, keyEvent -> { if (!expanded.get()) { return; } text.clear(); button.fire(); keyEvent.consume(); }); text.focusedProperty().addListener((observable, oldValue, newValue) -> { if (!newValue && filterString.getValue() == null) { if (button.isFocused()) { return; } expanded.set(false); } }); filterString.addListener((observable, oldValue, newValue) -> { PlatformThread.runLaterIfNeeded(() -> { if (newValue == null && !text.isFocused()) { expanded.set(false); } }); }); text.setMinWidth(0); Styles.toggleStyleClass(text, Styles.LEFT_PILL); filterString.subscribe(val -> { PlatformThread.runLaterIfNeeded(() -> { if (val == null) { text.getStyleClass().remove(Styles.SUCCESS); } else { text.getStyleClass().add(Styles.SUCCESS); } }); }); var fi = new FontIcon("mdi2m-magnify"); button.setGraphic(fi); button.setOnAction(event -> { if (model.getCurrentDirectory() == null) { return; } if (expanded.get()) { if (filterString.getValue() == null) { expanded.set(false); } event.consume(); } else { expanded.set(true); text.requestFocus(); event.consume(); } }); var box = new HBox(text, button); box.getStyleClass().add("browser-filter"); box.setAlignment(Pos.CENTER); text.setPrefWidth(0); text.setFocusTraversable(false); button.getStyleClass().add(Styles.FLAT); button.disableProperty().bind(model.getInOverview()); expanded.addListener((observable, oldValue, val) -> { if (val) { text.setPrefWidth(250); text.setFocusTraversable(true); } else { text.setPrefWidth(0); text.setFocusTraversable(false); } }); button.minHeightProperty().bind(text.heightProperty()); button.minWidthProperty().bind(text.heightProperty()); button.maxHeightProperty().bind(text.heightProperty()); button.maxWidthProperty().bind(text.heightProperty()); return new Structure(box, text, button); } public record Structure(HBox box, TextField textField, Button toggleButton) implements RegionStructure { @Override public HBox get() { return box; } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserFileListModel.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.browser.action.impl.MoveFileActionProvider; import io.xpipe.app.ext.FileEntry; import io.xpipe.app.ext.FileKind; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.core.FilePath; import javafx.beans.property.Property; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import lombok.Getter; import java.util.*; import java.util.stream.Stream; @Getter public final class BrowserFileListModel { static final Comparator FILE_TYPE_COMPARATOR = Comparator.comparing(path -> path.getRawFileEntry().resolved().getKind() != FileKind.DIRECTORY); private final BrowserFileSystemTabModel fileSystemModel; private final Property> comparatorProperty = new SimpleObjectProperty<>(FILE_TYPE_COMPARATOR); private final Property> all = new SimpleObjectProperty<>(new ArrayList<>()); private final Property> shown = new SimpleObjectProperty<>(new ArrayList<>()); private final ObservableList selection = FXCollections.observableArrayList(); private final Property draggedOverDirectory = new SimpleObjectProperty<>(); private final Property draggedOverEmpty = new SimpleBooleanProperty(); private final Property editing = new SimpleObjectProperty<>(); public BrowserFileListModel(BrowserFileSystemTabModel fileSystemModel) { this.fileSystemModel = fileSystemModel; fileSystemModel.getFilter().addListener((observable, oldValue, newValue) -> { refreshShown(); }); } public void setAll(Stream newFiles) { try (var s = newFiles) { var l = s.filter(entry -> entry != null) .map(entry -> new BrowserEntry(entry, this)) .toList(); all.setValue(l); refreshShown(); } } public void updateEntry(FilePath p, FileEntry n) { var found = all.getValue().stream() .filter(browserEntry -> browserEntry.getRawFileEntry().getPath().equals(p)) .findFirst(); if (found.isEmpty()) { return; } var index = all.getValue().indexOf(found.get()); var l = new ArrayList<>(all.getValue()); if (n != null) { l.set(index, new BrowserEntry(n, this)); } else { l.remove(index); } all.setValue(l); refreshShown(); } public void setComparator(Comparator comparator) { comparatorProperty.setValue(comparator); refreshShown(); } void refreshShown() { List filtered = fileSystemModel.getFilter().getValue() != null ? all.getValue().stream() .filter(entry -> { var name = entry.getRawFileEntry() .getPath() .getFileName() .toLowerCase(Locale.ROOT); var filterString = fileSystemModel.getFilter().getValue().toLowerCase(Locale.ROOT); return name.contains(filterString); }) .toList() : all.getValue(); var listCopy = new ArrayList<>(filtered); listCopy.sort(order()); shown.setValue(listCopy); } public Comparator order() { var dirsFirst = Comparator.comparing( path -> path.getRawFileEntry().resolved().getKind() != FileKind.DIRECTORY); var comp = comparatorProperty.getValue(); Comparator us = comp != null ? dirsFirst.thenComparing(comp) : dirsFirst; return us; } public BrowserEntry rename(BrowserEntry old, String newName) { if (old == null || newName == null || fileSystemModel == null || fileSystemModel.getCurrentPath().get() == null) { return old; } if (newName.isEmpty() || !newName.strip().equals(newName)) { return old; } var newFullPath = fileSystemModel.getCurrentPath().get().join(newName); // This check will fail on case-insensitive file systems when changing the case of the file // So skip it in this case var skipExistCheck = old.getFileName().equalsIgnoreCase(newName); if (!skipExistCheck) { boolean exists; try { exists = fileSystemModel.getFileSystem().fileExists(newFullPath) || fileSystemModel.getFileSystem().directoryExists(newFullPath); } catch (Exception e) { ErrorEventFactory.fromThrowable(e).handle(); return old; } if (exists) { ErrorEventFactory.fromMessage("Target " + newFullPath + " does already exist") .expected() .handle(); fileSystemModel.refreshSync(); return old; } } try { var builder = MoveFileActionProvider.Action.builder(); builder.initEntries(fileSystemModel, List.of(old)); builder.target(newFullPath); builder.build().executeSync(); var b = all.getValue().stream() .filter(browserEntry -> browserEntry.getRawFileEntry().getPath().equals(newFullPath)) .findFirst() .orElse(old); return b; } catch (Exception e) { ErrorEventFactory.fromThrowable(e).handle(); return old; } } public void onDoubleClick(BrowserEntry entry) { var r = entry.getRawFileEntry().resolved(); if (r.getKind() == FileKind.DIRECTORY) { fileSystemModel.cdAsync(r.getPath().toString()); } if (AppPrefs.get().editFilesWithDoubleClick().get() && r.getKind() == FileKind.FILE) { var selection = new LinkedHashSet<>(getSelection()); selection.add(entry); for (BrowserEntry e : selection) { BrowserFileOpener.openInTextEditor(getFileSystemModel(), e.getRawFileEntry()); } } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserFileListNameCell.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.browser.icon.BrowserIconManager; import io.xpipe.app.comp.base.LazyTextFieldComp; import io.xpipe.app.comp.base.PrettyImageHelper; import io.xpipe.app.ext.FileKind; import io.xpipe.app.platform.InputHelper; import io.xpipe.app.platform.MenuHelper; import io.xpipe.app.platform.PlatformThread; import io.xpipe.app.util.BooleanScope; import io.xpipe.app.util.ThreadHelper; import io.xpipe.core.FilePath; import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.beans.property.*; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableStringValue; import javafx.css.PseudoClass; import javafx.geometry.Pos; import javafx.geometry.Side; import javafx.scene.AccessibleRole; import javafx.scene.Node; import javafx.scene.control.ButtonBase; import javafx.scene.control.TableCell; import javafx.scene.control.TableView; import javafx.scene.control.TextField; import javafx.scene.input.KeyCode; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.Region; import atlantafx.base.controls.Spacer; class BrowserFileListNameCell extends TableCell { private final BrowserFileListModel fileList; private final ObservableStringValue typedSelection; private final StringProperty img = new SimpleStringProperty(); private final StringProperty text = new SimpleStringProperty(); private final BooleanProperty updating = new SimpleBooleanProperty(); private final Property editing; public BrowserFileListNameCell( BrowserFileListModel fileList, ObservableStringValue typedSelection, Property editing, TableView tableView) { this.editing = editing; this.fileList = fileList; this.typedSelection = typedSelection; accessibleTextProperty() .bind(Bindings.createStringBinding( () -> { return getItem() != null ? getItem() : null; }, itemProperty())); setAccessibleRole(AccessibleRole.TEXT); var textField = new LazyTextFieldComp(text) .minWidth(USE_PREF_SIZE) .buildStructure() .getTextField(); var quickAccess = createQuickAccessButton(); setupShortcuts(tableView, (ButtonBase) quickAccess); setupRename(fileList, textField); Node imageView = PrettyImageHelper.ofFixedSize(img, 24, 24).build(); HBox graphic = new HBox(imageView, new Spacer(5), quickAccess, new Spacer(1), textField); quickAccess.prefHeightProperty().bind(graphic.heightProperty()); graphic.setAlignment(Pos.CENTER_LEFT); graphic.setPrefHeight(34); HBox.setHgrow(textField, Priority.ALWAYS); graphic.setAlignment(Pos.CENTER_LEFT); setGraphic(graphic); } private Region createQuickAccessButton() { var quickAccess = new BrowserQuickAccessButtonComp(() -> getTableRow().getItem(), fileList.getFileSystemModel()) .hide(Bindings.createBooleanBinding( () -> { if (getTableRow() == null) { return true; } var item = getTableRow().getItem(); if (item == null) { return false; } var notDir = item.getRawFileEntry().resolved().getKind() != FileKind.DIRECTORY; var isParentLink = item.getRawFileEntry() .equals(fileList.getFileSystemModel().getCurrentParentDirectory()); return notDir || isParentLink; }, itemProperty())) .build(); return quickAccess; } private void setupShortcuts(TableView tableView, ButtonBase quickAccess) { InputHelper.onExactKeyCode(tableView, KeyCode.RIGHT, false, event -> { var selected = fileList.getSelection(); if (selected.size() == 1 && selected.getFirst() == getTableRow().getItem()) { quickAccess.fire(); event.consume(); } }); InputHelper.onExactKeyCode(tableView, KeyCode.SPACE, true, event -> { // Don't show when renaming files if (fileList.getEditing().getValue() != null) { return; } if (!typedSelection.get().isEmpty()) { var selection = typedSelection.get() + " "; var found = fileList.getShown().getValue().stream() .filter(browserEntry -> browserEntry.getFileName().toLowerCase().startsWith(selection)) .findFirst(); // Ugly fix to prevent space from showing the menu when there is a file matching // Due to the table view input map, these events always get sent and consumed, not allowing us to // differentiate between these cases if (found.isPresent()) { return; } } var selected = fileList.getSelection(); // Only show one menu across all selected entries if (selected.size() > 0 && selected.getLast() == getTableRow().getItem()) { var cm = new BrowserContextMenu( fileList.getFileSystemModel(), getTableRow().getItem(), false); MenuHelper.toggleMenuShow(cm, this, Side.RIGHT); event.consume(); } }); } private void setupRename(BrowserFileListModel fileList, TextField textField) { ChangeListener listener = (observable, oldValue, newValue) -> { if (updating.get()) { return; } getTableRow().requestFocus(); var it = getTableRow().getItem(); editing.setValue(null); ThreadHelper.runFailableAsync(() -> { if (it == null) { return; } var r = fileList.rename(it, newValue); Platform.runLater(() -> { updateItem(getItem(), isEmpty()); fileList.getSelection().setAll(r); }); }); }; text.addListener(listener); editing.addListener((observable, oldValue, newValue) -> { var item = getTableRow().getItem(); if (item != null && item.equals(newValue)) { PlatformThread.runLaterIfNeeded(() -> { textField.setDisable(false); textField.requestFocus(); var content = textField.getText(); if (content != null && !content.isEmpty() && !content.startsWith(".")) { var name = FilePath.of(content); var baseNameEnd = item.getRawFileEntry().getKind() == FileKind.DIRECTORY ? content.length() : name.getBaseName().toString().length(); textField.selectRange(0, baseNameEnd); } }); } else { PlatformThread.runLaterIfNeeded(() -> { textField.setDisable(true); }); } }); textField.disabledProperty().addListener((observable, oldValue, newValue) -> { if (!oldValue && newValue) { Platform.runLater(() -> { editing.setValue(null); }); } }); } @Override protected void updateItem(String newName, boolean empty) { // Cancel rename on any change editing.setValue(null); if (updating.get()) { super.updateItem(newName, empty); return; } try (var ignored = new BooleanScope(updating).start()) { super.updateItem(newName, empty); if (empty || newName == null || getTableRow().getItem() == null) { // Don't set image as that would trigger image comp update // and cells are emptied on each change, leading to unnecessary changes // img.set(null); // Visibility seems to be bugged, so use opacity setOpacity(0.0); } else { var icon = getTableRow().getItem().getIcon(); BrowserIconManager.loadIfNecessary(icon); img.set(icon); var isDirectory = getTableRow().getItem().getRawFileEntry().getKind() == FileKind.DIRECTORY; pseudoClassStateChanged(PseudoClass.getPseudoClass("folder"), isDirectory); var normalName = getTableRow().getItem().getRawFileEntry().getKind() == FileKind.LINK ? getTableRow().getItem().getFileName() + " -> " + getTableRow() .getItem() .getRawFileEntry() .resolved() .getPath() : getTableRow().getItem().getFileName(); var fileName = normalName; var info = getTableRow().getItem().getRawFileEntry().getInfo(); var hidden = (info != null && info.explicitlyHidden()) || fileName.startsWith("."); getTableRow().pseudoClassStateChanged(PseudoClass.getPseudoClass("hidden"), hidden); text.set(fileName); // Visibility seems to be bugged, so use opacity setOpacity(1.0); } } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserFileOpener.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.core.AppI18n; import io.xpipe.app.core.window.AppDialog; import io.xpipe.app.ext.FileEntry; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.util.BooleanScope; import io.xpipe.app.util.FileBridge; import io.xpipe.app.util.FileOpener; import io.xpipe.app.util.HumanReadableFormat; import lombok.SneakyThrows; import java.util.Objects; public class BrowserFileOpener { @SneakyThrows private static int calculateKey(BrowserFileSystemTabModel model, FileEntry entry) { // Use different key for empty / non-empty files to prevent any issues from blanked files when transfer fails var empty = model.getFileSystem().getFileSize(entry.getPath()) == 0; return Objects.hash(entry.getPath(), entry.getFileSystem(), entry.getKind(), entry.getInfo(), empty); } public static void openWithAnyApplication(BrowserFileSystemTabModel model, FileEntry entry) { if (model.getFileSystem().getShell().isPresent() && model.getFileSystem().getShell().get().isLocal()) { FileOpener.openWithAnyApplication(entry.getPath().toString()); return; } var file = entry.getPath(); var key = calculateKey(model, entry); FileBridge.get() .openIO( file.getFileName(), key, new BooleanScope(model.getBusy()).exclusive(), () -> BrowserFileInput.openFileInput(model, entry), (size) -> BrowserFileOutput.openFileOutput(model, entry, size), s -> FileOpener.openWithAnyApplication(s)); } public static void openInDefaultApplication(BrowserFileSystemTabModel model, FileEntry entry) { if (model.getFileSystem().getShell().isPresent() && model.getFileSystem().getShell().get().isLocal()) { FileOpener.openInDefaultApplication(entry.getPath().toString()); return; } var file = entry.getPath(); var key = calculateKey(model, entry); FileBridge.get() .openIO( file.getFileName(), key, new BooleanScope(model.getBusy()).exclusive(), () -> BrowserFileInput.openFileInput(model, entry), (size) -> BrowserFileOutput.openFileOutput(model, entry, size), s -> FileOpener.openInDefaultApplication(s)); } public static void openInTextEditor(BrowserFileSystemTabModel model, FileEntry entry) { var editor = AppPrefs.get().externalEditor().getValue(); if (editor == null) { return; } if (model.getFileSystem().getShell().isPresent() && model.getFileSystem().getShell().get().isLocal()) { FileOpener.openInTextEditor(entry.getPath().toString()); return; } var size = entry.getFileSizeLong().orElse(0L); if (size > 1_000_000) { var confirm = AppDialog.confirm( "largeFileWarningTitle", AppI18n.observable("largeFileWarningContent", HumanReadableFormat.byteCount(size))); if (!confirm) { return; } } var file = entry.getPath(); var key = calculateKey(model, entry); FileBridge.get() .openIO( file.getFileName(), key, new BooleanScope(model.getBusy()).exclusive(), () -> BrowserFileInput.openFileInput(model, entry), (os) -> { return BrowserFileOutput.openFileOutput(model, entry, os); }, FileOpener::openInTextEditor); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserFileOutput.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.core.window.AppDialog; import io.xpipe.app.ext.ConnectionFileSystem; import io.xpipe.app.ext.FileEntry; import io.xpipe.app.ext.FileInfo; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.process.CommandBuilder; import io.xpipe.app.process.ElevationFunction; import io.xpipe.app.process.ProcessOutputException; import io.xpipe.app.storage.DataStoreEntry; import io.xpipe.core.FilePath; import io.xpipe.core.OsType; import java.io.OutputStream; import java.util.List; import java.util.Optional; public interface BrowserFileOutput { static BrowserFileOutput openFileOutput(BrowserFileSystemTabModel model, FileEntry file, long totalBytes) throws Exception { var defOutput = createFileOutputImpl(model, file, totalBytes, false); if (model.getFileSystem().getShell().isEmpty()) { return defOutput; } var sc = model.getFileSystem().getShell().orElseThrow(); var requiresSudo = sc.getOsType() != OsType.WINDOWS && requiresSudo(model, (FileInfo.Unix) file.getInfo(), file.getPath()); if (!requiresSudo) { return defOutput; } var elevate = AppDialog.confirm("fileWriteSudo"); if (!elevate) { return defOutput; } var rootOutput = createFileOutputImpl(model, file, totalBytes, true); return rootOutput; } private static boolean requiresSudo(BrowserFileSystemTabModel model, FileInfo.Unix info, FilePath filePath) throws Exception { if (model.getFileSystem().getShell().isEmpty()) { return false; } var sc = model.getFileSystem().getShell().get(); if (sc.view().isRoot()) { return false; } if (info != null) { var otherWrite = info.getPermissions().charAt(7) == 'w'; if (otherWrite) { return false; } var userOwned = info.getUid() != null && sc.view().getPasswdFile().getUidForUser(sc.view().user()) == info.getUid() || info.getUser() != null && sc.view().user().equals(info.getUser()); var userWrite = info.getPermissions().charAt(1) == 'w'; if (userOwned && userWrite) { return false; } } var test = model.getFileSystem() .getShell() .orElseThrow() .command(CommandBuilder.of().add("test", "-w").addFile(filePath)) .executeAndCheck(); return !test; } private static BrowserFileOutput createFileOutputImpl( BrowserFileSystemTabModel model, FileEntry file, long totalBytes, boolean elevate) throws Exception { var shell = model.getFileSystem().getShell(); var sc = shell.isEmpty() ? null : elevate ? shell.orElseThrow() .identicalDialectSubShell() .elevated(ElevationFunction.elevated(null)) .start() : model.getFileSystem().getShell().orElseThrow().start(); var fs = elevate ? new ConnectionFileSystem(sc) : model.getFileSystem(); var checkSudoersFile = shell.isPresent() && file.getPath().startsWith("/etc/sudo"); var output = new BrowserFileOutput() { @Override public Optional target() { return Optional.of(model.getEntry().get()); } @Override public boolean hasOutput() { return true; } @Override public OutputStream open() throws Exception { try { return fs.openOutput(file.getPath(), totalBytes); } catch (Exception ex) { if (elevate) { fs.close(); } throw ex; } } @Override public void beforeTransfer() throws Exception { if (checkSudoersFile) { fs.copy(file.getPath(), sc.getSystemTemporaryDirectory().join(file.getName())); } } @Override public void onFinish() throws Exception { if (checkSudoersFile) { if (sc.view().findProgram("visudo").isPresent()) { try { sc.command(CommandBuilder.of() .add("visudo", "-c", "-f") .addFile(file.getPath())) .execute(); } catch (ProcessOutputException ex) { ErrorEventFactory.fromThrowable(ex).expected().handle(); fs.copy(sc.getSystemTemporaryDirectory().join(file.getName()), file.getPath()); } } } if (elevate) { fs.close(); } model.refreshFileEntriesSync(List.of(file)); } }; return output; } static BrowserFileOutput none() { return new BrowserFileOutput() { @Override public Optional target() { return Optional.empty(); } @Override public boolean hasOutput() { return false; } @Override public OutputStream open() { return null; } @Override public void beforeTransfer() {} @Override public void onFinish() {} }; } Optional target(); boolean hasOutput(); OutputStream open() throws Exception; void beforeTransfer() throws Exception; void onFinish() throws Exception; } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserFileOverviewComp.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.browser.icon.BrowserIcons; import io.xpipe.app.comp.BaseRegionBuilder; import io.xpipe.app.comp.RegionBuilder; import io.xpipe.app.comp.SimpleRegionBuilder; import io.xpipe.app.comp.base.HorizontalComp; import io.xpipe.app.comp.base.ListBoxViewComp; import io.xpipe.app.ext.FileEntry; import javafx.collections.ObservableList; import javafx.geometry.Pos; import javafx.scene.control.Button; import javafx.scene.layout.Region; import lombok.EqualsAndHashCode; import lombok.Value; import java.util.List; import java.util.function.Function; @Value @EqualsAndHashCode(callSuper = true) public class BrowserFileOverviewComp extends SimpleRegionBuilder { BrowserFileSystemTabModel model; ObservableList list; boolean grow; @Override protected Region createSimple() { Function> factory = entry -> { return RegionBuilder.of(() -> { var be = new BrowserEntry(entry, model.getFileList()); var icon = BrowserIcons.createIcon(be.getIcon()); var graphic = new HorizontalComp(List.of( icon, new BrowserQuickAccessButtonComp(() -> new BrowserEntry(entry, model.getFileList()), model))); var l = new Button(entry.getPath().toString(), graphic.build()); l.setGraphicTextGap(1); l.setOnAction(event -> { model.cdAsync(entry.getPath().toString()); event.consume(); }); l.setAlignment(Pos.CENTER_LEFT); l.setMaxWidth(10000); return l; }); }; var c = new ListBoxViewComp<>(list, list, factory, true).style("overview-file-list"); if (!grow) { c.apply(struc -> struc.setFitToHeight(true)); } return c.build(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserFileSelectionListComp.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.browser.icon.BrowserIconManager; import io.xpipe.app.comp.RegionBuilder; import io.xpipe.app.comp.SimpleRegionBuilder; import io.xpipe.app.comp.base.ListBoxViewComp; import io.xpipe.app.comp.base.PrettyImageHelper; import io.xpipe.app.core.AppStyle; import io.xpipe.app.core.window.AppWindowStyle; import io.xpipe.app.platform.BindingsHelper; import io.xpipe.app.platform.PlatformThread; import javafx.beans.binding.Bindings; import javafx.beans.property.SimpleStringProperty; import javafx.beans.value.ObservableValue; import javafx.collections.ObservableList; import javafx.scene.Scene; import javafx.scene.SnapshotParameters; import javafx.scene.control.Label; import javafx.scene.control.OverrunStyle; import javafx.scene.image.Image; import javafx.scene.layout.Region; import javafx.scene.paint.Color; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; import lombok.Value; import java.util.function.Function; @Value @EqualsAndHashCode(callSuper = true) @AllArgsConstructor public class BrowserFileSelectionListComp extends SimpleRegionBuilder { ObservableList list; Function> nameTransformation; public BrowserFileSelectionListComp(ObservableList list) { this(list, entry -> new SimpleStringProperty(entry.getFileName())); } public static Image snapshot(ObservableList list) { var r = new BrowserFileSelectionListComp(list).style("drag").build(); var scene = new Scene(r); AppWindowStyle.addStylesheets(scene); AppStyle.addStylesheets(scene); SnapshotParameters parameters = new SnapshotParameters(); parameters.setFill(Color.TRANSPARENT); return r.snapshot(parameters, null); } @Override protected Region createSimple() { var c = new ListBoxViewComp<>( list, list, entry -> { return RegionBuilder.of(() -> { var icon = entry.getIcon(); BrowserIconManager.loadIfNecessary(icon); var image = PrettyImageHelper.ofFixedSizeSquare(icon, 24) .build(); var t = nameTransformation.apply(entry); var l = new Label(t.getValue(), image); l.setGraphicTextGap(6); l.setTextOverrun(OverrunStyle.CENTER_ELLIPSIS); t.addListener((observable, oldValue, newValue) -> { PlatformThread.runLaterIfNeeded(() -> { l.setText(newValue); }); }); BindingsHelper.preserve(l, t); return l; }); }, true) .style("selected-file-list") .hide(Bindings.isEmpty(list)); return c.build(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserFileSystemHelper.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.ext.FileEntry; import io.xpipe.app.ext.FileKind; import io.xpipe.app.ext.FileSystem; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.core.FilePath; import io.xpipe.core.OsType; import java.time.Instant; import java.util.List; public class BrowserFileSystemHelper { public static String adjustPath(BrowserFileSystemTabModel model, String path) { if (path == null) { return null; } path = path.strip(); if (path.isBlank()) { return null; } if (path.startsWith("\"") && path.endsWith("\"")) { path = path.substring(1, path.length() - 1); } else if (path.startsWith("'") && path.endsWith("'")) { path = path.substring(1, path.length() - 1); } // Handle special case when file system creation has failed var shell = model.getFileSystem().getShell(); if (shell.isEmpty()) { return path; } if (shell.get().getOsType() == OsType.WINDOWS && path.length() == 2 && path.endsWith(":")) { return path + "\\"; } return path; } public static String evaluatePath(BrowserFileSystemTabModel model, String path) throws Exception { if (path == null) { return null; } var shell = model.getFileSystem().getShell(); if (shell.isEmpty() || !shell.get().isRunning(true)) { return path; } try { var r = shell.get() .getShellDialect() .evaluateExpression(shell.get(), path) .readStdoutOrThrow(); return !r.isBlank() ? r : null; } catch (Exception ex) { ErrorEventFactory.expected(ex); throw ex; } } public static FilePath resolveDirectoryPath(BrowserFileSystemTabModel model, FilePath path, boolean allowRewrite) throws Exception { if (path == null) { return null; } var shell = model.getFileSystem().getShell(); if (shell.isEmpty()) { return path; } var tildeResolved = path.resolveTildeHome( model.getFileSystem().getShell().orElseThrow().view().userHome()); var resolved = FilePath.of(shell.get() .getShellDialect() .resolveDirectory(shell.get(), tildeResolved.toString()) .readStdoutOrThrow()); if (!resolved.isAbsolute()) { throw ErrorEventFactory.expected( new IllegalArgumentException(String.format("Directory %s is not absolute", resolved))); } if (allowRewrite && model.getFileSystem().fileExists(resolved)) { return resolved.getParent().toDirectory(); } return resolved.toDirectory(); } public static void validateDirectoryPath(FileSystem fs, FilePath path, boolean verifyExists) throws Exception { if (path == null) { return; } if (verifyExists && !fs.directoryExists(path)) { throw ErrorEventFactory.expected(new IllegalArgumentException( String.format("Directory %s does not exist or is not accessible", path))); } try { fs.directoryAccessible(path); } catch (Exception ex) { ErrorEventFactory.expected(ex); throw ex; } } public static FileEntry getRemoteWrapper(FileSystem fileSystem, FilePath file) throws Exception { return new FileEntry( fileSystem, file, Instant.now(), "" + fileSystem.getFileSize(file), null, fileSystem.directoryExists(file) ? FileKind.DIRECTORY : FileKind.FILE); } public static void delete(List files) { if (files.isEmpty()) { return; } for (var file : files) { try { file.getFileSystem().delete(file.getPath()); } catch (Throwable t) { ErrorEventFactory.fromThrowable(t).handle(); } } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserFileSystemHistory.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.core.FilePath; import javafx.beans.binding.Bindings; import javafx.beans.binding.BooleanBinding; import javafx.beans.property.IntegerProperty; import javafx.beans.property.SimpleIntegerProperty; import java.util.ArrayList; import java.util.List; import java.util.Objects; public final class BrowserFileSystemHistory { private final IntegerProperty cursor = new SimpleIntegerProperty(-1); private final List history = new ArrayList<>(); private final BooleanBinding canGoBack = Bindings.createBooleanBinding(() -> cursor.get() > 0 && history.size() > 1, cursor); private final BooleanBinding canGoForth = Bindings.createBooleanBinding(() -> cursor.get() < history.size() - 1, cursor); public List getForwardHistory(int max) { var l = new ArrayList(); for (var i = cursor.get() + 1; i < Math.min(history.size(), cursor.get() + max); i++) { l.add(history.get(i)); } return l; } public List getBackwardHistory(int max) { var l = new ArrayList(); for (var i = cursor.get() - 1; i >= Math.max(0, cursor.get() - max); i--) { l.add(history.get(i)); } return l; } public FilePath getCurrent() { return history.size() > 0 ? history.get(cursor.get()) : null; } public void updateCurrent(FilePath s) { if (s == null) { return; } var lastString = getCurrent(); if (cursor.get() != -1 && Objects.equals(lastString, s)) { return; } if (canGoForth.get()) { history.subList(cursor.get() + 1, history.size()).clear(); } history.add(s); cursor.set(history.size() - 1); } public FilePath back(int i) { if (!canGoBack.get()) { return null; } cursor.set(Math.max(0, cursor.get() - i)); return history.get(cursor.get()); } public FilePath forth(int i) { if (!canGoForth.get()) { return history.getLast(); } cursor.set(Math.min(history.size() - 1, cursor.get() + i)); return history.get(cursor.get()); } public BooleanBinding canGoBackProperty() { return canGoBack; } public BooleanBinding canGoForthProperty() { return canGoForth; } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserFileSystemSavedState.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.core.AppCache; import io.xpipe.app.util.GlobalTimer; import io.xpipe.core.FilePath; import io.xpipe.core.JacksonMapper; import javafx.application.Platform; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.deser.std.StdDeserializer; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.ser.std.StdSerializer; import lombok.*; import lombok.extern.jackson.Jacksonized; import java.io.IOException; import java.time.Duration; import java.time.Instant; import java.util.*; import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; @AllArgsConstructor @Getter @JsonSerialize(using = BrowserFileSystemSavedState.Serializer.class) @JsonDeserialize(using = BrowserFileSystemSavedState.Deserializer.class) public class BrowserFileSystemSavedState { private static final int STORED = 15; @Setter private BrowserFileSystemTabModel model; private FilePath lastDirectory; @NonNull private ObservableList recentDirectories; public BrowserFileSystemSavedState(FilePath lastDirectory, @NonNull ObservableList recentDirectories) { this.lastDirectory = lastDirectory; this.recentDirectories = recentDirectories; } public BrowserFileSystemSavedState() { lastDirectory = null; recentDirectories = FXCollections.synchronizedObservableList(FXCollections.observableArrayList()); } static BrowserFileSystemSavedState loadForStore(BrowserFileSystemTabModel model) { var state = AppCache.getNonNull( "fs-state-" + model.getEntry().get().getUuid(), BrowserFileSystemSavedState.class, () -> { return new BrowserFileSystemSavedState(); }); state.setModel(model); return state; } public synchronized void save() { if (model == null) { return; } AppCache.update("fs-state-" + model.getEntry().get().getUuid(), this); } public void cd(FilePath dir) { if (dir == null) { lastDirectory = null; return; } lastDirectory = dir; // After 10 seconds GlobalTimer.delayAsync( new Runnable() { @Override public void run() { // Synchronize with platform thread Platform.runLater(() -> { if (Objects.equals(lastDirectory, dir)) { updateRecent(dir); save(); } }); } }, Duration.ofMillis(10000)); } private synchronized void updateRecent(FilePath dir) { var without = dir.removeTrailingSlash(); var with = dir.toDirectory(); var copy = new ArrayList<>(recentDirectories); for (RecentEntry recentEntry : copy) { if (Objects.equals(recentEntry.directory, without) || Objects.equals(recentEntry.directory, with)) { recentDirectories.remove(recentEntry); } } var o = new RecentEntry(with, Instant.now()); if (recentDirectories.size() < STORED) { recentDirectories.addFirst(o); } else { recentDirectories.removeLast(); recentDirectories.addFirst(o); } } public static class Serializer extends StdSerializer { protected Serializer() { super(BrowserFileSystemSavedState.class); } @Override public void serialize(BrowserFileSystemSavedState value, JsonGenerator gen, SerializerProvider provider) throws IOException { var node = JsonNodeFactory.instance.objectNode(); node.set("recentDirectories", JacksonMapper.getDefault().valueToTree(value.getRecentDirectories())); gen.writeTree(node); } } public static class Deserializer extends StdDeserializer { protected Deserializer() { super(BrowserFileSystemSavedState.class); } private static Predicate distinctBy(Function f) { Set objects = new HashSet<>(); return t -> objects.add(f.apply(t)); } @Override @SneakyThrows public BrowserFileSystemSavedState deserialize(JsonParser p, DeserializationContext ctxt) { var tree = (ObjectNode) JacksonMapper.getDefault().readTree(p); JavaType javaType = JacksonMapper.getDefault() .getTypeFactory() .constructCollectionLikeType(List.class, RecentEntry.class); List recentDirectories = JacksonMapper.getDefault().treeToValue(tree.remove("recentDirectories"), javaType); if (recentDirectories == null) { recentDirectories = List.of(); } var cleaned = recentDirectories.stream() .map(recentEntry -> new RecentEntry(recentEntry.directory.toDirectory(), recentEntry.time)) .filter(distinctBy(recentEntry -> recentEntry.getDirectory())) .collect(Collectors.toCollection(CopyOnWriteArrayList::new)); return new BrowserFileSystemSavedState(null, FXCollections.observableList(cleaned)); } } @Value @Jacksonized @Builder public static class RecentEntry { FilePath directory; Instant time; } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserFileSystemTabComp.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.browser.BrowserFullSessionModel; import io.xpipe.app.browser.menu.BrowserMenuProviders; import io.xpipe.app.comp.*; import io.xpipe.app.comp.augment.ContextMenuAugment; import io.xpipe.app.comp.base.*; import io.xpipe.app.core.AppFontSizes; import io.xpipe.app.platform.InputHelper; import io.xpipe.app.platform.MenuHelper; import io.xpipe.app.platform.PlatformThread; import io.xpipe.app.util.GlobalTimer; import io.xpipe.core.FilePath; import javafx.beans.binding.Bindings; import javafx.beans.property.ReadOnlyBooleanWrapper; import javafx.beans.property.SimpleBooleanProperty; import javafx.geometry.Pos; import javafx.scene.control.Button; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import javafx.scene.input.MouseButton; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.Region; import javafx.scene.layout.VBox; import atlantafx.base.controls.Spacer; import org.kordamp.ikonli.javafx.FontIcon; import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.Map; public class BrowserFileSystemTabComp extends SimpleRegionBuilder { private final BrowserFileSystemTabModel model; private final boolean showStatusBar; public BrowserFileSystemTabComp(BrowserFileSystemTabModel model, boolean showStatusBar) { this.model = model; this.showStatusBar = showStatusBar; } @Override protected Region createSimple() { return createContent(); } private Region createContent() { var root = new VBox(); root.setMinWidth(190); var overview = new Button(null, new FontIcon("mdi2m-monitor")); overview.setOnAction(e -> model.cdAsync((FilePath) null)); RegionDescriptor.builder() .nameKey("overview") .shortcut(new KeyCodeCombination(KeyCode.HOME, KeyCombination.ALT_DOWN)) .build() .apply(overview); overview.disableProperty().bind(model.getInOverview()); InputHelper.onKeyCombination( root, new KeyCodeCombination(KeyCode.HOME, KeyCombination.ALT_DOWN), true, keyEvent -> { overview.fire(); keyEvent.consume(); }); var backBtn = BrowserMenuProviders.byId("back", model, List.of()).toButton(root, model, List.of()); var forthBtn = BrowserMenuProviders.byId("forward", model, List.of()).toButton(root, model, List.of()); var refreshBtn = BrowserMenuProviders.byId("refresh", model, List.of()).toButton(root, model, List.of()); // Don't handle key events for this button, we also have that available as a menu item var terminalBtn = BrowserMenuProviders.byId("openInTerminal", model, List.of()).toButton(new Region(), model, List.of()); var menuButton = MenuHelper.createMenuButton(); menuButton.setGraphic(new FontIcon("mdral-folder_open")); new ContextMenuAugment<>( event -> event.getButton() == MouseButton.PRIMARY, null, () -> new BrowserContextMenu(model, null, false)) .accept(menuButton); menuButton.disableProperty().bind(model.getInOverview()); RegionDescriptor.builder().nameKey("directoryOptions").build().apply(menuButton); var smallWidth = Bindings.createBooleanBinding( () -> { return root.getWidth() < 450; }, root.widthProperty()); refreshBtn.managedProperty().bind(smallWidth.not()); refreshBtn.visibleProperty().bind(refreshBtn.managedProperty()); var terminalSupported = BrowserMenuProviders.byId("openInTerminal", model, List.of()).isApplicable(model, List.of()); terminalBtn.managedProperty().bind(smallWidth.not().and(new ReadOnlyBooleanWrapper(terminalSupported))); terminalBtn .visibleProperty() .bind(terminalBtn.managedProperty().and(new ReadOnlyBooleanWrapper(terminalSupported))); var filter = new BrowserFileListFilterComp(model, model.getFilter()) .hide(smallWidth) .buildStructure(); var topBar = new HBox(); topBar.setAlignment(Pos.CENTER); topBar.getStyleClass().add("top-bar"); AppFontSizes.xl(topBar); var navBar = new BrowserNavBarComp(model).buildStructure(); filter.textField().prefHeightProperty().bind(navBar.get().heightProperty()); AppFontSizes.base(navBar.get()); var leftBox = new HBox(overview, backBtn, forthBtn); leftBox.setFillHeight(true); leftBox.getStyleClass().add("button-bar"); var rightBox = new HBox(filter.get(), refreshBtn, terminalBtn, menuButton); rightBox.setFillHeight(true); rightBox.getStyleClass().add("button-bar"); topBar.getChildren().setAll(leftBox, new Spacer(6), navBar.get(), new Spacer(6), rightBox); topBar.setMinWidth(0); if (model.getBrowserModel() instanceof BrowserFullSessionModel fullSessionModel) { var pinButton = new Button(); RegionDescriptor.builder().nameKey("pinTab").build().apply(pinButton); pinButton .graphicProperty() .bind(PlatformThread.sync(Bindings.createObjectBinding( () -> { if (fullSessionModel.getGlobalPinnedTab().getValue() != model) { return new FontIcon("mdi2p-pin"); } return new FontIcon("mdi2p-pin-off"); }, fullSessionModel.getGlobalPinnedTab()))); pinButton.setOnAction(e -> { if (fullSessionModel.getGlobalPinnedTab().getValue() != model) { fullSessionModel.pinTab(model); } else { fullSessionModel.unpinTab(); } e.consume(); }); rightBox.getChildren().add(1, pinButton); squaredSize(navBar.get(), pinButton, true); } squaredSize(navBar.get(), overview, true); squaredSize(navBar.get(), backBtn, true); squaredSize(navBar.get(), forthBtn, true); squaredSize(navBar.get(), refreshBtn, true); squaredSize(navBar.get(), terminalBtn, true); squaredSize(navBar.get(), menuButton, false); var content = createFileListContent(); root.getChildren().addAll(topBar, content); VBox.setVgrow(content, Priority.ALWAYS); root.focusedProperty().addListener((observable, oldValue, newValue) -> { if (newValue) { content.requestFocus(); } }); InputHelper.onKeyCombination( root, new KeyCodeCombination(KeyCode.F, KeyCombination.SHORTCUT_DOWN), true, keyEvent -> { filter.toggleButton().fire(); filter.textField().requestFocus(); keyEvent.consume(); }); InputHelper.onKeyCombination( root, new KeyCodeCombination(KeyCode.L, KeyCombination.SHORTCUT_DOWN), true, keyEvent -> { navBar.textField().requestFocus(); keyEvent.consume(); }); InputHelper.onKeyCombination( root, new KeyCodeCombination(KeyCode.H, KeyCombination.ALT_DOWN), true, keyEvent -> { navBar.historyButton().fire(); keyEvent.consume(); }); InputHelper.onKeyCombination( root, new KeyCodeCombination(KeyCode.UP, KeyCombination.ALT_DOWN), true, keyEvent -> { var p = model.getCurrentParentDirectory(); if (p != null) { model.cdAsync(p.getPath().toString()); } keyEvent.consume(); }); InputHelper.onKeyCombination(root, new KeyCodeCombination(KeyCode.BACK_SPACE), false, keyEvent -> { var p = model.getCurrentParentDirectory(); if (p != null) { model.cdAsync(p.getPath().toString()); } keyEvent.consume(); }); return root; } private void squaredSize(Region ref, Region toResize, boolean width) { if (width) { toResize.minWidthProperty().bind(ref.heightProperty()); } toResize.minHeightProperty().bind(ref.heightProperty().add(-2)); if (width) { toResize.maxWidthProperty().bind(ref.heightProperty()); } toResize.maxHeightProperty().bind(ref.heightProperty().add(-2)); } private Region createFileListContent() { var directoryView = new BrowserFileListComp(model.getFileList()).apply(struc -> VBox.setVgrow(struc, Priority.ALWAYS)); var fileListElements = new ArrayList>(); fileListElements.add(directoryView); if (showStatusBar) { var statusBar = new BrowserStatusBarComp(model); fileListElements.add(statusBar); } var fileList = new VerticalComp(fileListElements) .style("browser-content") .style("color-box") .style("gray") .apply(struc -> { struc.focusedProperty().addListener((observable, oldValue, newValue) -> { if (newValue) { struc.getChildren().getFirst().requestFocus(); } }); }); // Delay show to hide file list changes happening // Not perfect, but covers most of the cases of small directories var showOverview = new SimpleBooleanProperty(true); model.getCurrentPath().subscribe(path -> { GlobalTimer.delay( () -> { showOverview.setValue(path == null); }, Duration.ofMillis(250)); }); var home = new BrowserOverviewComp(model).style("browser-overview"); var stack = new MultiContentComp(false, Map.of(home, showOverview, fileList, showOverview.not()), false); var r = stack.style("browser-content-container").build(); r.focusedProperty().addListener((observable, oldValue, newValue) -> { if (newValue) { if (r.getChildrenUnmodifiable().get(0).isVisible()) { r.getChildrenUnmodifiable().getFirst().requestFocus(); } else { r.getChildrenUnmodifiable().get(1).requestFocus(); } } }); return r; } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserFileSystemTabModel.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.action.ActionProvider; import io.xpipe.app.browser.BrowserAbstractSessionModel; import io.xpipe.app.browser.BrowserFullSessionModel; import io.xpipe.app.browser.BrowserStoreSessionTab; import io.xpipe.app.browser.action.impl.TransferFilesActionProvider; import io.xpipe.app.browser.menu.BrowserMenuItemProvider; import io.xpipe.app.comp.BaseRegionBuilder; import io.xpipe.app.core.window.AppMainWindow; import io.xpipe.app.ext.*; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.process.*; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.storage.DataStoreEntry; import io.xpipe.app.storage.DataStoreEntryRef; import io.xpipe.app.terminal.*; import io.xpipe.app.util.BooleanScope; import io.xpipe.app.util.ThreadHelper; import io.xpipe.core.FailableFunction; import io.xpipe.core.FilePath; import io.xpipe.core.OsType; import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.beans.property.*; import javafx.beans.value.ObservableBooleanValue; import javafx.beans.value.ObservableValue; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import lombok.Getter; import lombok.NonNull; import java.io.IOException; import java.nio.file.Path; import java.time.Duration; import java.time.Instant; import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; @Getter public final class BrowserFileSystemTabModel extends BrowserStoreSessionTab { private static boolean wasTerminalDocked; private final Property filter = new SimpleStringProperty(); private final BrowserFileListModel fileList; private final ReadOnlyObjectWrapper currentPath = new ReadOnlyObjectWrapper<>(); private final BrowserFileSystemHistory history = new BrowserFileSystemHistory(); private final ObservableBooleanValue inOverview = Bindings.createBooleanBinding( () -> { return currentPath.get() == null; }, currentPath); private final ObservableList terminalRequests = FXCollections.observableArrayList(); private final BooleanProperty transferCancelled = new SimpleBooleanProperty(); private final Property progress = new SimpleObjectProperty<>(); private final ObservableList progressesIntervalHistory = FXCollections.observableArrayList(); private final LongProperty progressTransferSpeed = new SimpleLongProperty(); private final Property progressRemaining = new SimpleObjectProperty<>(); private final FailableFunction, FileSystem, Exception> fileSystemFactory; private final StringProperty fileSystemNameSuffix = new SimpleStringProperty(); private WrapperFileSystem fileSystem; private BrowserFileSystemSavedState savedState; public BrowserFileSystemTabModel( BrowserAbstractSessionModel model, DataStoreEntryRef entry, FailableFunction, FileSystem, Exception> fileSystemFactory) { super(model, entry); this.fileList = new BrowserFileListModel(this); this.fileSystemFactory = fileSystemFactory; } @Override public ObservableValue getName() { var name = super.getName(); return Bindings.createStringBinding(() -> { var suffix = fileSystemNameSuffix.get(); return name.getValue() + (suffix != null ? " [" + suffix + "]" : ""); }); } public void updateProgress(BrowserTransferProgress n) { if (n == null) { progress.setValue(null); progressesIntervalHistory.clear(); progressTransferSpeed.setValue(0); return; } if (n.getTransferred() == 0) { progress.setValue(n); return; } var changedHistory = false; if (progress.getValue() != null) { var last = progressesIntervalHistory.isEmpty() ? Instant.EPOCH : progressesIntervalHistory.getLast().getTimestamp(); var elapsed = Duration.between(last, n.getTimestamp()); if (elapsed.toMillis() >= 1000) { progressesIntervalHistory.add(progress.getValue()); changedHistory = true; } } progress.setValue(n); if (progressesIntervalHistory.isEmpty()) { return; } if (changedHistory && progressesIntervalHistory.size() >= 2) { var speed = BrowserTransferProgress.estimateTransferSpeed(progressesIntervalHistory, n); progressTransferSpeed.setValue(speed); var remaining = n.getTotal() - n.getTransferred(); var estimate = remaining / (double) speed; var newDuration = Duration.ofMillis((long) (estimate * 1000.0)); var smooth = progressRemaining.getValue() != null && progressRemaining.getValue().toSeconds() + 1 == newDuration.toSeconds(); if (!smooth) { progressRemaining.setValue(newDuration); } } } public ObservableValue getProgress() { return progress; } public Optional findFile(FilePath path) { return getFileList().getAll().getValue().stream() .filter(browserEntry -> browserEntry.getFileName().equals(path.toString()) || browserEntry.getRawFileEntry().getPath().equals(path)) .findFirst() .map(browserEntry -> browserEntry.getRawFileEntry()); } @Override public BaseRegionBuilder comp() { return new BrowserFileSystemTabComp(this, true); } @Override public boolean canImmediatelyClose() { if (fileSystem.getShell().isEmpty() || !fileSystem.getShell().get().getLock().isLocked()) { return true; } return progress.getValue() == null || progress.getValue().done(); } @Override public void init() throws Exception { BooleanScope.executeExclusive(busy, () -> { var fs = new WrapperFileSystem(fileSystemFactory.apply(getEntry().asNeeded())); Platform.runLater(() -> { getFileSystemNameSuffix().set(fs.getSuffix()); }); if (fs.getShell().isPresent()) { ProcessControlProvider.get().withDefaultScripts(fs.getShell().get()); } fs.open(); // Listen to kill after init as the shell might get killed during init for certain reasons if (fs.getRawShellControl().isPresent()) { fs.getRawShellControl().get().onKill(() -> { browserModel.closeAsync(this); }); } this.fileSystem = fs; // Cache for later usage if (fs.getShell().isPresent()) { fs.getShell().get().view().getPasswdFile(); fs.getShell().get().view().getGroupFile(); } for (var a : ActionProvider.ALL) { if (a instanceof BrowserMenuItemProvider ba) { ba.init(this); } } }); this.savedState = BrowserFileSystemSavedState.loadForStore(this); } @Override public void close() { BooleanScope.executeExclusive(busy, () -> { var current = currentPath.getValue(); if (savedState != null && current != null) { savedState.cd(current); BrowserHistorySavedStateImpl.get() .add(new BrowserHistorySavedState.Entry(getEntry().get().getUuid(), current)); BrowserHistorySavedStateImpl.get().save(); } try { fileSystem.close(); } catch (IOException e) { ErrorEventFactory.fromThrowable(e).handle(); } }); } public void startIfNeeded() throws Exception { fileSystem.reinitIfNeeded(); } public void killTransfer() { transferCancelled.set(true); } public void refreshSync() { cdSyncWithoutCheck(currentPath.get()); } public void refreshBrowserEntriesSync(List entries) { refreshFileEntriesSync( entries.stream().map(BrowserEntry::getRawFileEntry).collect(Collectors.toList())); } public void refreshFileEntriesSync(List entries) { if (fileList.getAll().getValue().size() < 10) { refreshSync(); return; } if (entries.size() > 10 && fileList.getAll().getValue().size() < 100) { refreshSync(); return; } var all = new ArrayList(); all.addAll(entries); for (BrowserEntry browserEntry : fileList.getAll().getValue()) { var fe = browserEntry.getRawFileEntry(); if (fe.getKind() == FileKind.LINK && entries.stream() .anyMatch(o -> o.getPath().equals(fe.resolved().getPath()))) { all.add(fe); } } for (FileEntry fileEntry : entries) { if (fileEntry.getKind() == FileKind.LINK) { all.add(fileEntry.resolved()); } } try { for (var e : all) { var refresh = fileSystem.getFileInfo(e.getPath()); fileList.updateEntry(e.getPath(), refresh.orElse(null)); } } catch (Exception e) { ErrorEventFactory.fromThrowable(e).handle(); } } public FileEntry getCurrentParentDirectory() { if (currentPath.get() == null) { return null; } var parent = currentPath.get().getParent(); if (parent == null) { return null; } return new FileEntry(fileSystem, parent, null, null, null, FileKind.DIRECTORY); } public FileEntry getCurrentDirectory() { if (currentPath.get() == null) { return null; } return new FileEntry(fileSystem, currentPath.get(), null, null, null, FileKind.DIRECTORY); } public void cdAsync(FilePath path) { cdAsync(path != null ? path.toString() : null); } public void cdAsync(String path) { ThreadHelper.runFailableAsync(() -> { BooleanScope.executeExclusive(busy, () -> { cdSync(path); }); }); } public void cdSync(String path) { cdSyncOrRetry(path, false).ifPresent(s -> cdSyncOrRetry(s, false)); } private boolean shouldLaunchSplitTerminal() { if (!AppPrefs.get().enableFileBrowserTerminalDocking().get()) { return false; } if (OsType.ofLocal() != OsType.WINDOWS) { return false; } if (AppMainWindow.get().getStage().getWidth() <= 1380) { return false; } var term = AppPrefs.get().terminalType().getValue(); if (term == null || term.getOpenFormat() == TerminalOpenFormat.TABBED) { return false; } if (!(browserModel instanceof BrowserFullSessionModel f)) { return false; } // Check if the right side is already occupied var existingSplit = f.getEffectiveRightTab().getValue(); if (existingSplit == this) { return false; } if (existingSplit != null && !(existingSplit instanceof BrowserTerminalDockTabModel)) { return false; } return true; } public Optional cdSyncOrRetry(String path, boolean customInput) { if (!fileSystem.isRunning()) { return Optional.empty(); } var cps = currentPath.get() != null ? currentPath.get().toString() : null; if (Objects.equals(path, cps)) { return Optional.empty(); } if (path == null) { savedState.cd(null); currentPath.set(null); fileList.setAll(Stream.of()); return Optional.empty(); } try { // Start shell in case we exited startIfNeeded(); } catch (Exception ex) { ErrorEventFactory.fromThrowable(ex).handle(); return Optional.ofNullable(cps); } // Fix common issues with paths var adjustedPath = BrowserFileSystemHelper.adjustPath(this, path); if (!Objects.equals(path, adjustedPath)) { return Optional.of(adjustedPath); } // Open UNC paths in another tab if needed if (handleUncPath(path)) { return Optional.ofNullable(cps); } // Evaluate optional expressions String evaluatedPath; if (customInput) { try { evaluatedPath = BrowserFileSystemHelper.evaluatePath(this, adjustedPath); } catch (Exception ex) { ErrorEventFactory.fromThrowable(ex).handle(); return Optional.ofNullable(cps); } } else { evaluatedPath = adjustedPath; } if (evaluatedPath == null) { return Optional.empty(); } // Handle commands typed into navigation bar if (customInput && !evaluatedPath.isBlank() && !FilePath.of(evaluatedPath).isAbsolute() && fileSystem.getShell().isPresent()) { var directory = currentPath.get(); var name = adjustedPath; ThreadHelper.runFailableAsync(() -> { if (ShellDialects.getStartableDialects().stream().anyMatch(dialect -> adjustedPath .toLowerCase() .startsWith(dialect.getExecutableName().toLowerCase()))) { var sub = fileSystem.getShell().get().subShell(); var open = new ShellOpenFunction() { @Override public CommandBuilder prepareWithoutInitCommand() { return CommandBuilder.ofString(adjustedPath); } @Override public CommandBuilder prepareWithInitCommand(@NonNull String command) { return CommandBuilder.ofString(command); } }; sub.setDumbOpen(open); sub.setTerminalOpen(open); ThreadHelper.runFailableAsync(() -> { BooleanScope.executeExclusive(busy, () -> { try (var ignored = sub.start()) { openTerminalSync(name, directory, sub, true); } }); }); } else { ThreadHelper.runFailableAsync(() -> { BooleanScope.executeExclusive(busy, () -> { openTerminalSync(name, directory, fileSystem.getShell().get().command(adjustedPath), true); }); }); } }); return Optional.ofNullable(cps); } // Evaluate optional links FilePath resolvedPath; try { resolvedPath = BrowserFileSystemHelper.resolveDirectoryPath(this, FilePath.of(evaluatedPath), customInput); } catch (Exception ex) { ErrorEventFactory.fromThrowable(ex).handle(); return Optional.ofNullable(cps); } if (!Objects.equals(path, resolvedPath.toString())) { return Optional.of(resolvedPath.toString()); } try { BrowserFileSystemHelper.validateDirectoryPath(fileSystem, resolvedPath, true); cdSyncWithoutCheck(resolvedPath); } catch (Exception ex) { ErrorEventFactory.fromThrowable(ex).handle(); return Optional.ofNullable(cps); } return Optional.empty(); } private boolean handleUncPath(String path) { if (path.startsWith("\\\\") && getBrowserModel() instanceof BrowserFullSessionModel bm && getFileSystem() .getShell() .map(shellControl -> shellControl.getShellDialect()) .orElse(null) == ShellDialects.CMD) { var env = ProcessControlProvider.get().subShellEnvironment(getEntry().asNeeded(), ShellDialects.POWERSHELL); var entry = DataStoreEntry.createNew(getName().getValue() + " (PowerShell)", env); entry.setColor(DataStorage.get().getEffectiveColor(getEntry().get())); entry.setCategoryUuid(getEntry().get().getCategoryUuid()); bm.openFileSystemAsync(entry.ref(), null, m -> FilePath.of(path), null); return true; } else { return false; } } private void cdSyncWithoutCheck(FilePath path) { // Assume that the path is normalized to improve performance! // path = FileSystemHelper.normalizeDirectoryPath(this, path); loadFilesSync(path); filter.setValue(null); savedState.cd(path); history.updateCurrent(path); currentPath.set(path); } private boolean loadFilesSync(FilePath dir) { try { startIfNeeded(); var fs = getFileSystem(); if (dir != null) { var stream = fs.listFiles(fs, dir); fileList.setAll(stream); } else { fileList.setAll(Stream.of()); } return true; } catch (Exception e) { fileList.setAll(Stream.of()); ErrorEventFactory.fromThrowable(e).handle(); return false; } } public void dropLocalFilesIntoAsync(FileEntry entry, List files) { ThreadHelper.runFailableAsync(() -> { BooleanScope.executeExclusive(busy, () -> { startIfNeeded(); var op = BrowserFileTransferOperation.ofLocal( entry, files, BrowserFileTransferMode.COPY, true, p -> updateProgress(p), transferCancelled); var action = TransferFilesActionProvider.Action.builder() .operation(op) .target(this.entry.asNeeded()) .build(); // Might have been killed if (action.executeSync() && fileSystem != null && fileSystem.isRunning()) { refreshSync(); } }); }); } public void dropFilesIntoAsync(FileEntry target, List files, BrowserFileTransferMode mode) { // We don't have to do anything in this case if (files.isEmpty()) { return; } ThreadHelper.runFailableAsync(() -> { BooleanScope.executeExclusive(busy, () -> { startIfNeeded(); var op = new BrowserFileTransferOperation( target, files, mode, true, this::updateProgress, transferCancelled); var action = TransferFilesActionProvider.Action.builder() .operation(op) .target(entry.asNeeded()) .build(); action.executeSync(); refreshSync(); }); }); } public void duplicateFile(FileEntry entry) { // Technically we would have to create an action to allow confirmations for this // But in practice, this is almost a non mutable action, so we will save the effort ThreadHelper.runFailableAsync(() -> { BooleanScope.executeExclusive(busy, () -> { startIfNeeded(); var adjusted = BrowserFileDuplicates.renameFileDuplicate( fileSystem, entry.getPath(), entry.getKind() == FileKind.DIRECTORY); fileSystem.copy(entry.getPath(), adjusted); refreshSync(); }); }); } public void initWithGivenDirectory(FilePath dir) { cdSync(dir != null ? dir.toString() : null); } public void initWithDefaultDirectory() { savedState.cd(null); history.updateCurrent(null); } public void openTerminalAsync( String name, FilePath directory, ProcessControl processControl, boolean dockIfPossible) { ThreadHelper.runFailableAsync(() -> { BooleanScope.executeExclusive(busy, () -> { openTerminalSync(name, directory, processControl, dockIfPossible); }); }); } public void openTerminalSync(String name, FilePath directory, ProcessControl processControl, boolean dockIfPossible) throws Exception { var dock = shouldLaunchSplitTerminal() && dockIfPossible; var uuid = UUID.randomUUID(); if (dock && browserModel instanceof BrowserFullSessionModel fullSessionModel && !(fullSessionModel.getSplits().get(this) instanceof BrowserTerminalDockTabModel)) { terminalRequests.add(uuid); fullSessionModel.splitTab(this, new BrowserTerminalDockTabModel(browserModel, this, terminalRequests)); } // If we docked once, we don't want to break it by opening new tabs in maybe still docked tabs var preferTabs = !wasTerminalDocked && !dock; wasTerminalDocked = wasTerminalDocked || dock; TerminalLaunch.builder() .entry(entry.get()) .title(name) .directory(directory) .command(processControl) .request(uuid) .preferTabs(preferTabs) .launch(); // Restart connection as we will have to start it anyway, so we speed it up by doing it preemptively startIfNeeded(); } public void backSync(int i) { var b = history.back(i); if (b != null) { cdSync(b.toString()); } } public void forthSync(int i) { var f = history.forth(i); if (f != null) { cdSync(f.toString()); } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserFileTransferMode.java ================================================ package io.xpipe.app.browser.file; public enum BrowserFileTransferMode { NORMAL, COPY, MOVE } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserFileTransferOperation.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.core.mode.AppOperationMode; import io.xpipe.app.ext.FileEntry; import io.xpipe.app.ext.FileKind; import io.xpipe.app.ext.FileSystem; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.util.ThreadHelper; import io.xpipe.core.FilePath; import javafx.beans.property.BooleanProperty; import javafx.beans.value.ChangeListener; import lombok.Getter; import java.io.*; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.LinkedHashMap; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; public class BrowserFileTransferOperation { private static final int DEFAULT_BUFFER_SIZE = 1024; @Getter private final FileEntry target; @Getter private final List files; private final BrowserFileTransferMode transferMode; private final boolean checkConflicts; private final Consumer progress; private final BooleanProperty cancelled; BrowserDialogs.FileConflictChoice lastConflictChoice; public BrowserFileTransferOperation( FileEntry target, List files, BrowserFileTransferMode transferMode, boolean checkConflicts, Consumer progress, BooleanProperty cancelled) { this.target = target; this.files = files; this.transferMode = transferMode; this.checkConflicts = checkConflicts; this.progress = progress; this.cancelled = cancelled; } public static BrowserFileTransferOperation ofLocal( FileEntry target, List files, BrowserFileTransferMode transferMode, boolean checkConflicts, Consumer progress, BooleanProperty cancelled) { var entries = files.stream() .map(path -> { if (!Files.exists(path)) { return null; } try { return BrowserLocalFileSystem.getLocalFileEntry(path); } catch (Exception e) { throw new RuntimeException(e); } }) .filter(entry -> entry != null) .toList(); return new BrowserFileTransferOperation(target, entries, transferMode, checkConflicts, progress, cancelled); } private void reinitFileSystemsIfNeeded() throws Exception { getFiles().getFirst().getFileSystem().reinitIfNeeded(); getTarget().getFileSystem().reinitIfNeeded(); } private void updateProgress(BrowserTransferProgress progress) { this.progress.accept(progress); } private BrowserDialogs.FileConflictChoice handleChoice(FileSystem fileSystem, FilePath target, boolean multiple) throws Exception { if (lastConflictChoice == BrowserDialogs.FileConflictChoice.CANCEL) { return BrowserDialogs.FileConflictChoice.CANCEL; } if (lastConflictChoice == BrowserDialogs.FileConflictChoice.REPLACE_ALL) { return BrowserDialogs.FileConflictChoice.REPLACE; } if (lastConflictChoice == BrowserDialogs.FileConflictChoice.RENAME_ALL) { return BrowserDialogs.FileConflictChoice.RENAME; } if (fileSystem.fileExists(target)) { if (lastConflictChoice == BrowserDialogs.FileConflictChoice.SKIP_ALL) { return BrowserDialogs.FileConflictChoice.SKIP; } var choice = BrowserDialogs.showFileConflictDialog(target, multiple); if (choice == BrowserDialogs.FileConflictChoice.CANCEL) { lastConflictChoice = BrowserDialogs.FileConflictChoice.CANCEL; return BrowserDialogs.FileConflictChoice.CANCEL; } if (choice == BrowserDialogs.FileConflictChoice.SKIP) { return BrowserDialogs.FileConflictChoice.SKIP; } if (choice == BrowserDialogs.FileConflictChoice.SKIP_ALL) { lastConflictChoice = BrowserDialogs.FileConflictChoice.SKIP_ALL; return BrowserDialogs.FileConflictChoice.SKIP; } if (choice == BrowserDialogs.FileConflictChoice.REPLACE_ALL) { lastConflictChoice = BrowserDialogs.FileConflictChoice.REPLACE_ALL; return BrowserDialogs.FileConflictChoice.REPLACE; } if (choice == BrowserDialogs.FileConflictChoice.RENAME_ALL) { lastConflictChoice = BrowserDialogs.FileConflictChoice.RENAME_ALL; return BrowserDialogs.FileConflictChoice.RENAME; } return choice; } return BrowserDialogs.FileConflictChoice.REPLACE; } private boolean cancelled() { return cancelled.get() || AppOperationMode.isInShutdown(); } public boolean isMove() { if (files.isEmpty()) { return false; } var same = files.getFirst().getFileSystem().equals(target.getFileSystem()); var doesMove = transferMode == BrowserFileTransferMode.MOVE || (same && transferMode == BrowserFileTransferMode.NORMAL); return doesMove; } public void execute() throws Exception { if (files.isEmpty()) { updateProgress(null); return; } reinitFileSystemsIfNeeded(); if (target.getKind() != FileKind.DIRECTORY) { throw new IllegalStateException("Target " + target.getPath() + " is not a directory"); } BrowserFileSystemHelper.validateDirectoryPath(target.getFileSystem(), target.getPath(), true); cancelled.set(false); var same = files.getFirst().getFileSystem().equals(target.getFileSystem()); var doesMove = transferMode == BrowserFileTransferMode.MOVE || (same && transferMode == BrowserFileTransferMode.NORMAL); try { for (var file : files) { if (cancelled()) { break; } if (same) { handleSingleOnSameFileSystem(file); } else { // Transfers might change the working directory var currentDir = file.getFileSystem().pwd(); handleSingleAcrossFileSystems(file); // Expect a kill if (currentDir.isPresent() && !file.getFileSystem().requiresReinit()) { file.getFileSystem().cd(currentDir.get()); } } } if (!same && doesMove) { for (var file : files) { if (cancelled()) { break; } deleteSingle(file); } } } finally { updateProgress(null); } } private void handleSingleOnSameFileSystem(FileEntry source) throws Exception { // Prevent dropping files into itself if ((source.getKind() == FileKind.DIRECTORY && source.getPath().equals(target.getPath())) || (source.getKind() != FileKind.DIRECTORY && source.getPath().getParent().equals(target.getPath()))) { return; } var sourceFile = source.getPath(); var targetFile = target.getPath().join(sourceFile.getFileName()); if (sourceFile.equals(targetFile)) { // Duplicate file by renaming it targetFile = BrowserFileDuplicates.renameFileDuplicate( target.getFileSystem(), targetFile, source.getKind() == FileKind.DIRECTORY); } if (source.getKind() == FileKind.DIRECTORY && target.getFileSystem().directoryExists(targetFile)) { throw ErrorEventFactory.expected( new IllegalArgumentException("Target directory " + targetFile + " does already exist")); } if (checkConflicts) { var fileConflictChoice = handleChoice(target.getFileSystem(), targetFile, files.size() > 1); if (fileConflictChoice == BrowserDialogs.FileConflictChoice.SKIP || fileConflictChoice == BrowserDialogs.FileConflictChoice.CANCEL) { return; } if (fileConflictChoice == BrowserDialogs.FileConflictChoice.RENAME) { targetFile = BrowserFileDuplicates.renameFileDuplicate( target.getFileSystem(), targetFile, source.getKind() == FileKind.DIRECTORY); } } var doesMove = transferMode == BrowserFileTransferMode.MOVE || transferMode == BrowserFileTransferMode.NORMAL; if (doesMove) { target.getFileSystem().move(sourceFile, targetFile); } else { target.getFileSystem().copy(sourceFile, targetFile); } } private void handleSingleAcrossFileSystems(FileEntry source) throws Exception { var flatFiles = new LinkedHashMap(); // Prevent dropping directory into itself if (source.getFileSystem().equals(target.getFileSystem()) && source.getPath().startsWith(target.getPath())) { return; } AtomicLong totalSize = new AtomicLong(); if (source.getKind() == FileKind.DIRECTORY) { // Source might have been deleted meanwhile var exists = source.getFileSystem().directoryExists(source.getPath()); if (!exists) { progress.accept(BrowserTransferProgress.finished(source.getName(), 0)); return; } var directoryName = source.getPath().getFileName(); if (!source.getPath().isRoot()) { flatFiles.put(source, FilePath.of(directoryName)); } var baseRelative = source.getPath().getParent().toDirectory(); source.getFileSystem().traverseFilesRecursively(source.getFileSystem(), source.getPath(), fileEntry -> { if (cancelled()) { progress.accept(BrowserTransferProgress.finished(source.getName() + " ...", totalSize.get())); return false; } var rel = fileEntry.getPath().relativize(baseRelative).toUnix(); flatFiles.put(fileEntry, rel); if (fileEntry.getKind() == FileKind.FILE) { // This one is up-to-date and does not need to be recalculated // If we don't have a size, it doesn't matter that much as the total size is only for display totalSize.addAndGet(fileEntry.getFileSizeLong().orElse(0)); progress.accept(new BrowserTransferProgress(source.getName() + " ...", 0, totalSize.get())); } return true; }); } else if (source.getKind() == FileKind.FILE) { // Source might have been deleted meanwhile var exists = source.getFileSystem().fileExists(source.getPath()); if (!exists) { progress.accept(BrowserTransferProgress.finished(source.getName(), 0)); return; } flatFiles.put(source, FilePath.of(source.getPath().getFileName())); // If we don't have a size, it doesn't matter that much as the total size is only for display totalSize.addAndGet(source.getFileSizeLong().orElse(0)); } else { // Unsupported type, e.g. a socket progress.accept(BrowserTransferProgress.finished(source.getName(), 0)); return; } var originalSourceFs = flatFiles.keySet().iterator().next().getFileSystem(); if (!flatFiles.keySet().stream() .allMatch(fileEntry -> fileEntry.getFileSystem().equals(originalSourceFs))) { throw new IllegalArgumentException("Mixed source file systems"); } var optimizedSourceFs = originalSourceFs.createTransferOptimizedFileSystem(); var targetFs = target.getFileSystem().createTransferOptimizedFileSystem(); try { AtomicLong transferred = new AtomicLong(); for (var e : flatFiles.entrySet()) { if (cancelled()) { return; } var sourceFile = e.getKey(); var fixedRelPath = targetFs.makeFileSystemCompatible(e.getValue()); var targetFile = target.getPath().join(fixedRelPath.toString()); if (sourceFile.getFileSystem().equals(targetFs)) { throw new IllegalStateException(); } if (sourceFile.getKind() == FileKind.DIRECTORY) { targetFs.mkdirs(targetFile); } else if (sourceFile.getKind() == FileKind.FILE) { if (checkConflicts) { var fileConflictChoice = handleChoice(targetFs, targetFile, files.size() > 1 || flatFiles.size() > 1); if (fileConflictChoice == BrowserDialogs.FileConflictChoice.SKIP || fileConflictChoice == BrowserDialogs.FileConflictChoice.CANCEL) { continue; } if (fileConflictChoice == BrowserDialogs.FileConflictChoice.RENAME) { targetFile = BrowserFileDuplicates.renameFileDuplicate(targetFs, targetFile, false); } } transfer(sourceFile.getPath(), optimizedSourceFs, targetFile, targetFs, transferred, totalSize); } } } finally { updateProgress(BrowserTransferProgress.finished(source.getName(), totalSize.get())); if (optimizedSourceFs != originalSourceFs) { optimizedSourceFs.close(); } if (target.getFileSystem() != targetFs) { targetFs.close(); } } } private boolean transferInline(FilePath sourceFile, FileSystem sourceFs, FilePath targetFile, FileSystem targetFs) throws Exception { var wasRun = new AtomicBoolean(false); var active = new AtomicBoolean(true); var ex = new AtomicReference(); ThreadHelper.runAsync(() -> { try { if (targetFs.writeInstantIfPossible(sourceFs, sourceFile, targetFile) || sourceFs.readInstantIfPossible(sourceFile, targetFs, targetFile)) { wasRun.set(true); } active.set(false); } catch (Exception e) { wasRun.set(true); active.set(false); ex.set(e); } }); while (active.get()) { if (cancelled()) { sourceFs.kill(); targetFs.kill(); break; } ThreadHelper.sleep(10); } if (ex.get() != null) { throw ex.get(); } return wasRun.get(); } private void transfer( FilePath sourceFile, FileSystem sourceFs, FilePath targetFile, FileSystem targetFs, AtomicLong transferred, AtomicLong totalSize) throws Exception { if (cancelled()) { return; } updateProgress(new BrowserTransferProgress(sourceFile.getFileName(), 0, 0)); var fileSize = sourceFs.getFileSize(sourceFile); if (transferInline(sourceFile, sourceFs, targetFile, targetFs) || cancelled()) { if (!cancelled()) { updateProgress(BrowserTransferProgress.finished(sourceFile.getFileName(), fileSize)); } return; } InputStream inputStream = null; OutputStream outputStream = null; try { // Read the first few bytes to figure out possible command failure early // before creating the output stream inputStream = new BufferedInputStream(sourceFs.openInput(sourceFile), 1024); inputStream.mark(1024); var streamStart = new byte[1024]; var streamStartLength = inputStream.read(streamStart, 0, 1024); if (streamStartLength < 1024) { inputStream.close(); inputStream = new ByteArrayInputStream(streamStart, 0, streamStartLength); } else { inputStream.reset(); } outputStream = targetFs.openOutput(targetFile, fileSize); transferFile(sourceFile, inputStream, outputStream, transferred, totalSize, fileSize); } catch (Exception ex) { // Mark progress as finished to reset any progress display updateProgress(BrowserTransferProgress.finished(sourceFile.getFileName(), transferred.get())); if (inputStream != null) { try { inputStream.close(); } catch (Exception om) { // This is expected as the process control has to be killed // When calling close, it will throw an exception when it has to kill ErrorEventFactory.fromThrowable(om).expected().omit().handle(); } } if (outputStream != null) { try { outputStream.close(); } catch (Exception om) { // This is expected as the process control has to be killed // When calling close, it will throw an exception when it has to kill ErrorEventFactory.fromThrowable(om).expected().omit().handle(); } } throw ex; } // If we receive a cancel while we are closing, there's a good chance that the close is stuck // Then, we just straight up kill the shells ChangeListener closeCancelListener = (observableValue, oldValue, newValue) -> { if (!newValue) { return; } sourceFs.kill(); targetFs.kill(); }; cancelled.addListener(closeCancelListener); Exception exception = null; try { inputStream.close(); } catch (Exception om) { exception = om; } try { outputStream.close(); } catch (Exception om) { if (exception != null) { exception.addSuppressed(om); } else { exception = om; } } cancelled.removeListener(closeCancelListener); if (exception != null) { ErrorEventFactory.preconfigure(ErrorEventFactory.fromThrowable(exception) .reportable(!cancelled()) .omitted(cancelled())); throw exception; } } private void deleteSingle(FileEntry source) throws Exception { source.getFileSystem().delete(source.getPath()); } private void transferFile( FilePath sourceFile, InputStream inputStream, OutputStream outputStream, AtomicLong transferred, AtomicLong total, long expectedFileSize) throws Exception { // Initialize progress immediately prior to reading anything updateProgress(new BrowserTransferProgress(sourceFile.getFileName(), transferred.get(), total.get())); var killStreams = new AtomicBoolean(false); var exception = new AtomicReference(); var readCount = new AtomicLong(); var thread = ThreadHelper.createPlatformThread("transfer", true, () -> { try { var bs = (int) Math.min(DEFAULT_BUFFER_SIZE, expectedFileSize); byte[] buffer = new byte[bs]; int read; while ((read = inputStream.read(buffer, 0, bs)) > 0) { if (cancelled()) { killStreams.set(true); break; } if (!checkTransferValidity()) { killStreams.set(true); break; } outputStream.write(buffer, 0, read); transferred.addAndGet(read); readCount.addAndGet(read); updateProgress( new BrowserTransferProgress(sourceFile.getFileName(), transferred.get(), total.get())); } outputStream.flush(); inputStream.transferTo(OutputStream.nullOutputStream()); var incomplete = !killStreams.get() && readCount.get() < expectedFileSize; if (incomplete) { throw new IOException("Source file " + sourceFile + " input size mismatch: Expected " + expectedFileSize + " but got " + readCount.get() + ". Did the source file get updated?"); } } catch (Exception ex) { exception.set(ex); killStreams.set(true); } }); thread.start(); while (true) { var alive = thread.isAlive(); var cancelled = cancelled(); if (cancelled) { killStreams(thread, readCount, false); break; } if (alive) { Thread.sleep(100); continue; } if (killStreams.get()) { killStreams(thread, readCount, true); } var ex = exception.get(); if (ex != null) { throw ex; } else { break; } } } private boolean checkTransferValidity() { var sourceFs = files.getFirst().getFileSystem(); var targetFs = target.getFileSystem(); var same = files.getFirst().getFileSystem().equals(target.getFileSystem()); if (!same) { return !sourceFs.requiresReinit() && !targetFs.requiresReinit(); } else { return true; } } private void killStreams(Thread thread, AtomicLong transferred, boolean instant) throws Exception { var sourceFs = files.getFirst().getFileSystem(); var targetFs = target.getFileSystem(); var same = files.getFirst().getFileSystem().equals(target.getFileSystem()); if (!instant && !same && checkTransferValidity()) { var initialTransferred = transferred.get(); if (!thread.join(Duration.ofMillis(2000))) { var nowTransferred = transferred.get(); var stuck = initialTransferred == nowTransferred; if (stuck) { sourceFs.kill(); targetFs.kill(); return; } } } if (!same) { if (sourceFs.getShell().isPresent()) { try { sourceFs.getShell().get().closeStdout(); } catch (Exception ignored) { } } if (targetFs.getShell().isPresent()) { try { targetFs.getShell().get().closeStdin(); } catch (Exception ignored) { } } } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserGreetingComp.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.comp.SimpleRegionBuilder; import io.xpipe.app.core.AppFontSizes; import io.xpipe.app.core.AppI18n; import io.xpipe.app.core.AppLayoutModel; import io.xpipe.app.platform.PlatformThread; import javafx.scene.control.Label; import javafx.scene.layout.Region; import atlantafx.base.theme.Styles; import java.time.LocalDateTime; public class BrowserGreetingComp extends SimpleRegionBuilder { @Override protected Region createSimple() { var r = new Label(getText()); AppLayoutModel.get().getSelected().addListener((observableValue, entry, t1) -> { PlatformThread.runLaterIfNeeded(() -> { r.setText(getText()); }); }); AppFontSizes.title(r); r.getStyleClass().add(Styles.TEXT_BOLD); return r; } private String getText() { var ldt = LocalDateTime.now(); var hour = ldt.getHour(); String text; if (hour > 18 || hour < 5) { text = AppI18n.get("goodEvening"); } else if (hour < 12) { text = AppI18n.get("goodMorning"); } else { text = AppI18n.get("goodAfternoon"); } return text; } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserHistorySavedState.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.core.FilePath; import javafx.collections.ObservableList; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Value; import lombok.extern.jackson.Jacksonized; import java.util.UUID; public interface BrowserHistorySavedState { void add(Entry entry); void save(); ObservableList getEntries(); @Value @Jacksonized @Builder @AllArgsConstructor class Entry { UUID uuid; FilePath path; } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserHistorySavedStateImpl.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.core.AppCache; import io.xpipe.core.JacksonMapper; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.deser.std.StdDeserializer; import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.SneakyThrows; import lombok.Value; import java.util.List; @Value @JsonDeserialize(using = BrowserHistorySavedStateImpl.Deserializer.class) public class BrowserHistorySavedStateImpl implements BrowserHistorySavedState { private static BrowserHistorySavedStateImpl INSTANCE; @JsonSerialize(as = List.class) ObservableList lastSystems; public BrowserHistorySavedStateImpl(List lastSystems) { this.lastSystems = FXCollections.synchronizedObservableList(FXCollections.observableArrayList(lastSystems)); } public static BrowserHistorySavedState get() { if (INSTANCE == null) { INSTANCE = load(); } return INSTANCE; } private static BrowserHistorySavedStateImpl load() { return AppCache.getNonNull("browser-state", BrowserHistorySavedStateImpl.class, () -> { return new BrowserHistorySavedStateImpl( FXCollections.synchronizedObservableList(FXCollections.observableArrayList())); }); } @Override public synchronized void add(BrowserHistorySavedState.Entry entry) { synchronized (lastSystems) { lastSystems.removeIf(e -> e == null || e.getUuid().equals(entry.getUuid())); lastSystems.addFirst(entry); if (lastSystems.size() > 15) { lastSystems.removeLast(); } } } @Override public synchronized void save() { AppCache.update("browser-state", this); } @Override public ObservableList getEntries() { return lastSystems; } public static class Deserializer extends StdDeserializer { protected Deserializer() { super(BrowserHistorySavedStateImpl.class); } @Override @SneakyThrows public BrowserHistorySavedStateImpl deserialize(JsonParser p, DeserializationContext ctxt) { var tree = (ObjectNode) JacksonMapper.getDefault().readTree(p); JavaType javaType = JacksonMapper.getDefault().getTypeFactory().constructCollectionLikeType(List.class, Entry.class); List ls = JacksonMapper.getDefault().treeToValue(tree.remove("lastSystems"), javaType); if (ls == null) { ls = List.of(); } var valid = ls.stream() .filter(entry -> entry.getUuid() != null && entry.getPath() != null) .toList(); return new BrowserHistorySavedStateImpl(valid); } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserHistoryTabComp.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.browser.BrowserFullSessionModel; 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.platform.DerivedObservableList; import io.xpipe.app.platform.LabelGraphic; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.util.DocumentationLink; import io.xpipe.app.util.ThreadHelper; import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ReadOnlyStringWrapper; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.value.ObservableValue; import javafx.collections.ObservableList; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.layout.*; import atlantafx.base.theme.Styles; import java.util.LinkedHashMap; import java.util.List; public class BrowserHistoryTabComp extends SimpleRegionBuilder { private final BrowserFullSessionModel model; public BrowserHistoryTabComp(BrowserFullSessionModel model) { this.model = model; } @Override protected Region createSimple() { var state = BrowserHistorySavedStateImpl.get(); var list = DerivedObservableList.wrap(state.getEntries(), true) .filtered(e -> { if (DataStorage.get() == null) { return false; } var entry = DataStorage.get().getStoreEntryIfPresent(e.getUuid()); if (entry.isEmpty()) { return false; } if (!entry.get().getValidity().isUsable()) { return false; } return true; }) .getList(); var empty = Bindings.createBooleanBinding(() -> list.isEmpty(), list); var contentDisplay = createListDisplay(list); var emptyDisplay = createEmptyDisplay(); var map = new LinkedHashMap, ObservableValue>(); map.put(emptyDisplay, empty); map.put(contentDisplay, empty.not()); var stack = new MultiContentComp(false, map, false); return stack.build(); } private BaseRegionBuilder createListDisplay(ObservableList list) { var state = BrowserHistorySavedStateImpl.get(); var welcome = new BrowserGreetingComp(); var header = new LabelComp(AppI18n.observable("browserWelcomeSystems")); var vbox = new VerticalComp(List.of(welcome, RegionBuilder.vspacer(4), header)); vbox.apply(struc -> struc.setAlignment(Pos.CENTER_LEFT)); var listBox = new ListBoxViewComp<>( list, list, e -> { var disable = new SimpleBooleanProperty(); var entryButton = entryButton(e, disable); var dirButton = dirButton(e, disable); return new HorizontalComp(List.of(entryButton, dirButton)).apply(struc -> { ((Region) struc.getChildren().get(0)) .prefHeightProperty() .bind(struc.heightProperty()); ((Region) struc.getChildren().get(1)) .prefHeightProperty() .bind(struc.heightProperty()); }); }, true) .apply(struc -> { VBox vBox = (VBox) struc.getContent(); vBox.setSpacing(10); }); var tile = new TileButtonComp("restore", "restoreAllSessions", "mdmz-restore", actionEvent -> { model.restoreState(state); actionEvent.consume(); }) .maxWidth(2000) .describe(d -> d.nameKey("restoreAllSessions")); var layout = new VerticalComp(List.of(vbox, RegionBuilder.vspacer(5), listBox, RegionBuilder.hseparator(), tile)); layout.style("welcome"); layout.spacing(14); layout.maxWidth(1000); layout.padding(new Insets(45, 40, 40, 50)); layout.apply(struc -> { struc.setMaxWidth(1000); }); return layout; } private BaseRegionBuilder createEmptyDisplay() { var docs = new IntroComp("browserWelcomeDocs", new LabelGraphic.IconGraphic("mdi2b-book-open-variant")); docs.setButtonAction(() -> { DocumentationLink.INTRO.open(); }); docs.setButtonGraphic(new LabelGraphic.IconGraphic("mdi2w-web")); docs.setButtonDefault(true); var open = new IntroComp( "browserWelcomeEmpty", new LabelGraphic.CompGraphic(PrettyImageHelper.ofSpecificFixedSize("welcome/hips.svg", 100, 122))); open.setButtonGraphic(new LabelGraphic.IconGraphic("mdi2f-folder-open-outline")); open.setButtonAction(() -> { BrowserFullSessionModel.DEFAULT.openFileSystemAsync( DataStorage.get().local().ref(), null, null, null); }); var list = new IntroListComp(List.of(docs, open)); return list; } private BaseRegionBuilder entryButton(BrowserHistorySavedState.Entry e, BooleanProperty disable) { var entry = DataStorage.get().getStoreEntryIfPresent(e.getUuid()); var graphic = entry.get().getEffectiveIconFile(); var view = PrettyImageHelper.ofFixedSize(graphic, 22, 16); var name = Bindings.createStringBinding( () -> { var n = DataStorage.get().getStoreEntryDisplayName(entry.get()); return AppPrefs.get().censorMode().get() ? "*".repeat(n.length()) : n; }, AppPrefs.get().censorMode()); return new ButtonComp(name, view.build(), () -> { ThreadHelper.runAsync(() -> { var storageEntry = DataStorage.get().getStoreEntryIfPresent(e.getUuid()); if (storageEntry.isPresent()) { model.openFileSystemAsync(storageEntry.get().ref(), null, null, disable); } }); }) .minWidth(300) .describe( d -> d.name(new ReadOnlyStringWrapper(DataStorage.get().getStoreEntryDisplayName(entry.get())))) .disable(disable) .style("entry-button") .style(Styles.LEFT_PILL) .apply(struc -> struc.setAlignment(Pos.CENTER_LEFT)); } private BaseRegionBuilder dirButton(BrowserHistorySavedState.Entry e, BooleanProperty disable) { var name = Bindings.createStringBinding( () -> { var n = e.getPath(); return AppPrefs.get().censorMode().get() ? "*".repeat(n.toString().length()) : n.toString(); }, AppPrefs.get().censorMode()); return new ButtonComp(name, () -> { ThreadHelper.runAsync(() -> { model.restoreStateAsync(e, disable); }); }) .describe(d -> d.name(new ReadOnlyStringWrapper(e.getPath().toString()))) .disable(disable) .style("directory-button") .apply(struc -> struc.setMaxWidth(20000)) .style(Styles.RIGHT_PILL) .hgrow() .apply(struc -> struc.setAlignment(Pos.CENTER_LEFT)); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserHistoryTabModel.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.browser.BrowserAbstractSessionModel; import io.xpipe.app.browser.BrowserFullSessionModel; import io.xpipe.app.browser.BrowserSessionTab; import io.xpipe.app.comp.BaseRegionBuilder; import io.xpipe.app.core.AppI18n; import io.xpipe.app.storage.DataStoreColor; import javafx.beans.value.ObservableValue; public final class BrowserHistoryTabModel extends BrowserSessionTab { public BrowserHistoryTabModel(BrowserAbstractSessionModel browserModel) { super(browserModel); } @Override public BaseRegionBuilder comp() { return new BrowserHistoryTabComp((BrowserFullSessionModel) browserModel); } @Override public boolean canImmediatelyClose() { return true; } @Override public void init() {} @Override public void close() {} @Override public ObservableValue getName() { return AppI18n.observable("history").map(s -> " " + s + " "); } @Override public String getIcon() { return null; } @Override public DataStoreColor getColor() { return null; } @Override public boolean isCloseable() { return false; } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserLocalFileSystem.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.ext.FileEntry; import io.xpipe.app.ext.FileKind; import io.xpipe.app.ext.FileSystem; import io.xpipe.app.ext.LocalStore; import io.xpipe.core.FilePath; import java.nio.file.Files; import java.nio.file.Path; public class BrowserLocalFileSystem { private static FileSystem localFileSystem; public static void init() throws Exception { if (localFileSystem == null) { localFileSystem = new LocalStore().createFileSystem(); localFileSystem.open(); } else { localFileSystem.reinitIfNeeded(); } } public static void reset() throws Exception { if (localFileSystem != null) { localFileSystem.close(); localFileSystem = null; } } public static FileEntry getLocalFileEntry(Path file) throws Exception { init(); return new FileEntry( localFileSystem.open(), FilePath.of(file), Files.getLastModifiedTime(file).toInstant(), "" + Files.size(file), null, Files.isDirectory(file) ? FileKind.DIRECTORY : FileKind.FILE); } public static BrowserEntry getLocalBrowserEntry(Path file) throws Exception { var e = getLocalFileEntry(file); return new BrowserEntry(e, null); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserNavBarComp.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.browser.icon.BrowserIconManager; import io.xpipe.app.comp.RegionBuilder; import io.xpipe.app.comp.RegionStructure; import io.xpipe.app.comp.RegionStructureBuilder; import io.xpipe.app.comp.augment.ContextMenuAugment; import io.xpipe.app.comp.base.ButtonComp; import io.xpipe.app.comp.base.PrettyImageHelper; import io.xpipe.app.comp.base.TextFieldComp; import io.xpipe.app.core.AppFontSizes; import io.xpipe.app.platform.LabelGraphic; import io.xpipe.app.platform.MenuHelper; import io.xpipe.app.platform.PlatformThread; import io.xpipe.app.util.BooleanScope; import io.xpipe.app.util.ThreadHelper; import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.beans.property.SimpleStringProperty; import javafx.css.PseudoClass; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.*; import javafx.scene.input.*; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; import javafx.scene.shape.Rectangle; import atlantafx.base.theme.Styles; public class BrowserNavBarComp extends RegionStructureBuilder { private static final PseudoClass INVISIBLE = PseudoClass.getPseudoClass("invisible"); private final BrowserFileSystemTabModel model; public BrowserNavBarComp(BrowserFileSystemTabModel model) { this.model = model; } @Override public Structure createBase() { var pathBar = createPathBar(); var graphic = Bindings.createStringBinding( () -> { if (model.getCurrentDirectory() == null) { return null; } var icon = new BrowserEntry(model.getCurrentDirectory(), model.getFileList()).getIcon(); BrowserIconManager.loadIfNecessary(icon); return icon; }, PlatformThread.sync(model.getCurrentPath())); var breadcrumbsGraphic = PrettyImageHelper.ofFixedSize(graphic, 24, 24) .style("path-graphic") .build(); var homeButton = new ButtonComp(null, breadcrumbsGraphic, null) .describe(d -> d.nameKey("directoryOptions")) .apply(new ContextMenuAugment<>(event -> event.getButton() == MouseButton.PRIMARY, null, () -> { return model.getInOverview().get() ? null : new BrowserContextMenu(model, null, false); })) .build(); homeButton.getStyleClass().add(Styles.LEFT_PILL); homeButton.getStyleClass().add("path-graphic-button"); AppFontSizes.sm(homeButton); var historyButton = new ButtonComp(null, new LabelGraphic.IconGraphic("mdi2h-history"), null) .describe( d -> d.nameKey("history").shortcut(new KeyCodeCombination(KeyCode.H, KeyCombination.ALT_DOWN))) .style(Styles.RIGHT_PILL) .apply(new ContextMenuAugment<>( event -> event.getButton() == MouseButton.PRIMARY, null, this::createContextMenu)) .build(); AppFontSizes.xs(historyButton); var breadcrumbs = new BrowserBreadcrumbBar(model); var pathRegion = pathBar.build(); var breadcrumbsRegion = breadcrumbs.build(); breadcrumbsRegion.setOnMouseClicked(event -> { pathRegion.requestFocus(); event.consume(); }); breadcrumbsRegion.setFocusTraversable(false); breadcrumbsRegion .visibleProperty() .bind(Bindings.createBooleanBinding( () -> { return !pathRegion.isFocused() && !model.getInOverview().get(); }, pathRegion.focusedProperty(), PlatformThread.sync(model.getInOverview()))); var stack = new StackPane(pathRegion, breadcrumbsRegion); stack.setAlignment(Pos.CENTER_LEFT); pathRegion.prefHeightProperty().bind(stack.heightProperty()); stack.widthProperty().addListener((observable, oldValue, newValue) -> { setMargin(stack, breadcrumbsRegion); }); model.getCurrentPath().addListener((observable, oldValue, newValue) -> { PlatformThread.runLaterIfNeeded(() -> { setMargin(stack, breadcrumbsRegion); }); }); // Prevent overflow var clip = new Rectangle(); clip.widthProperty().bind(stack.widthProperty()); clip.heightProperty().bind(stack.heightProperty()); stack.setClip(clip); HBox.setHgrow(stack, Priority.ALWAYS); var topBox = new HBox(homeButton, stack, historyButton); topBox.setFillHeight(true); topBox.setAlignment(Pos.CENTER); homeButton.minWidthProperty().bind(pathRegion.heightProperty()); homeButton.maxWidthProperty().bind(pathRegion.heightProperty()); homeButton.minHeightProperty().bind(pathRegion.heightProperty()); homeButton.maxHeightProperty().bind(pathRegion.heightProperty()); historyButton.minHeightProperty().bind(pathRegion.heightProperty()); historyButton.maxHeightProperty().bind(pathRegion.heightProperty()); topBox.setPickOnBounds(false); HBox.setHgrow(topBox, Priority.ALWAYS); return new Structure(topBox, pathRegion, historyButton); } private void setMargin(StackPane stackPane, Region region) { var off = region.getWidth() - stackPane.getWidth(); if (off <= 0) { StackPane.setMargin(region, new Insets(0, 0, 0, 0)); } else { StackPane.setMargin(region, new Insets(0, 20, 0, -off - 20)); } } private RegionBuilder createPathBar() { var path = new SimpleStringProperty(); model.getCurrentPath().subscribe((newValue) -> { PlatformThread.runLaterIfNeeded(() -> { path.set(newValue != null ? newValue.toString() : null); }); }); path.addListener((observable, oldValue, newValue) -> { ThreadHelper.runFailableAsync(() -> { BooleanScope.executeExclusive(model.getBusy(), () -> { var changed = model.cdSyncOrRetry(newValue != null && !newValue.isBlank() ? newValue : null, true); changed.ifPresent(s -> { Platform.runLater(() -> path.set(!s.isBlank() ? s : null)); }); }); }); }); var pathBar = new TextFieldComp(path, true).style(Styles.CENTER_PILL).style("path-text"); pathBar.describe(d -> d.nameKey("currentPath")); pathBar.apply(struc -> { struc.focusedProperty().subscribe(val -> { struc.pseudoClassStateChanged( INVISIBLE, !val && !model.getInOverview().get()); if (val) { Platform.runLater(() -> { struc.end(); }); } }); struc.addEventHandler(KeyEvent.KEY_PRESSED, ke -> { if (ke.getCode().equals(KeyCode.ENTER)) { ke.consume(); } }); model.getInOverview().subscribe(val -> { // Pseudo classes do not apply if set instantly before shown // If we start a new tab with a directory set, we have to set the pseudo class one pulse later Platform.runLater(() -> { struc.pseudoClassStateChanged(INVISIBLE, !val && !struc.isFocused()); }); }); }); return pathBar; } private ContextMenu createContextMenu() { var cm = MenuHelper.createContextMenu(); var f = model.getHistory().getForwardHistory(8).stream().toList(); for (int i = f.size() - 1; i >= 0; i--) { if (f.get(i) == null) { continue; } var mi = new MenuItem(f.get(i).toString()); int target = i + 1; mi.setOnAction(event -> { ThreadHelper.runFailableAsync(() -> { BooleanScope.executeExclusive(model.getBusy(), () -> { model.forthSync(target); }); }); event.consume(); }); cm.getItems().add(mi); } if (!f.isEmpty()) { cm.getItems().add(new SeparatorMenuItem()); } if (model.getHistory().getCurrent() != null) { var current = new MenuItem(model.getHistory().getCurrent().toString()); current.setDisable(true); cm.getItems().add(current); } var b = model.getHistory().getBackwardHistory(Integer.MAX_VALUE).stream() .toList(); if (!b.isEmpty()) { cm.getItems().add(new SeparatorMenuItem()); } for (int i = 0; i < b.size(); i++) { if (b.get(i) == null) { continue; } var mi = new MenuItem(b.get(i).toString()); int target = i + 1; mi.setOnAction(event -> { ThreadHelper.runFailableAsync(() -> { BooleanScope.executeExclusive(model.getBusy(), () -> { model.backSync(target); }); }); event.consume(); }); cm.getItems().add(mi); } cm.addEventHandler(Menu.ON_SHOWING, e -> { Node content = cm.getSkin().getNode(); if (content instanceof Region r) { r.setMaxHeight(600); } }); return cm; } public record Structure(HBox box, TextField textField, Button historyButton) implements RegionStructure { @Override public HBox get() { return box; } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserOverviewComp.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.comp.BaseRegionBuilder; import io.xpipe.app.comp.SimpleRegionBuilder; import io.xpipe.app.comp.base.SimpleTitledPaneComp; import io.xpipe.app.comp.base.VerticalComp; import io.xpipe.app.core.AppI18n; import io.xpipe.app.ext.FileEntry; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.platform.DerivedObservableList; import io.xpipe.app.util.ThreadHelper; import io.xpipe.core.FilePath; import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.collections.FXCollections; import javafx.scene.layout.Priority; import javafx.scene.layout.Region; import javafx.scene.layout.VBox; import lombok.SneakyThrows; import java.util.ArrayList; public class BrowserOverviewComp extends SimpleRegionBuilder { private final BrowserFileSystemTabModel model; public BrowserOverviewComp(BrowserFileSystemTabModel model) { this.model = model; } @Override @SneakyThrows protected Region createSimple() { // The open file system might have already been closed var list = new ArrayList>(); var recent = DerivedObservableList.wrap(model.getSavedState().getRecentDirectories(), true) .mapped(s -> FileEntry.ofDirectory(model.getFileSystem(), s.getDirectory())) .getList(); var recentOverview = new BrowserFileOverviewComp(model, recent, true); var recentPane = new SimpleTitledPaneComp(AppI18n.observable("recent"), recentOverview, false); recentPane.hide(Bindings.isEmpty(recent)); list.add(recentPane); var commonPlatform = FXCollections.synchronizedObservableList(FXCollections.observableArrayList()); ThreadHelper.runFailableAsync(() -> { try { var all = new ArrayList(); for (FilePath cd : model.getFileSystem().listCommonDirectories()) { var entry = FileEntry.ofDirectory(model.getFileSystem(), cd); var fs = model.getFileSystem(); if (fs.directoryExists(entry.getPath())) { all.add(entry); } } Platform.runLater(() -> { commonPlatform.setAll(all); }); } catch (Exception e) { // The file system can die ErrorEventFactory.fromThrowable(e).expected().omit().handle(); } }); var commonOverview = new BrowserFileOverviewComp(model, commonPlatform, false); var commonPane = new SimpleTitledPaneComp(AppI18n.observable("common"), commonOverview, false) .apply(struc -> VBox.setVgrow(struc, Priority.NEVER)); commonPane.hide(Bindings.isEmpty(commonPlatform)); list.add(commonPane); var rootPlatform = FXCollections.synchronizedObservableList(FXCollections.observableArrayList()); ThreadHelper.runFailableAsync(() -> { var roots = model.getFileSystem().listRoots().stream() .map(s -> FileEntry.ofDirectory(model.getFileSystem(), s)) .toList(); Platform.runLater(() -> { rootPlatform.setAll(roots); }); }); var rootsOverview = new BrowserFileOverviewComp(model, rootPlatform, false); var rootsPane = new SimpleTitledPaneComp(AppI18n.observable("roots"), rootsOverview, false); rootsPane.hide(Bindings.isEmpty(rootPlatform)); list.add(rootsPane); var vbox = new VerticalComp(list).style("overview"); var r = vbox.build(); return r; } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserQuickAccessButtonComp.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.comp.RegionDescriptor; import io.xpipe.app.comp.SimpleRegionBuilder; import io.xpipe.app.comp.base.IconButtonComp; import io.xpipe.app.platform.InputHelper; import javafx.scene.layout.Region; import java.util.function.Supplier; public class BrowserQuickAccessButtonComp extends SimpleRegionBuilder { private final Supplier base; private final BrowserFileSystemTabModel model; public BrowserQuickAccessButtonComp(Supplier base, BrowserFileSystemTabModel model) { this.base = base; this.model = model; } @Override protected Region createSimple() { var cm = new BrowserQuickAccessContextMenu(base, model); var button = new IconButtonComp("mdi2c-chevron-double-right"); button.describe(d -> d.nameKey("quickAccess").focusTraversal(RegionDescriptor.FocusTraversal.DISABLED)); button.apply(struc -> { struc.setOnAction(event -> { if (!cm.isShowing()) { cm.showMenu(struc); } else { cm.hide(); } event.consume(); }); InputHelper.onRight(struc, false, keyEvent -> { cm.showMenu(struc); keyEvent.consume(); }); }); button.style("quick-access-button"); return button.build(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserQuickAccessContextMenu.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.browser.icon.BrowserIcons; import io.xpipe.app.comp.base.ModalOverlay; import io.xpipe.app.core.window.AppDialog; import io.xpipe.app.ext.FileEntry; import io.xpipe.app.ext.FileKind; import io.xpipe.app.platform.BooleanAnimationTimer; import io.xpipe.app.platform.InputHelper; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.util.BooleanScope; import io.xpipe.app.util.ThreadHelper; import javafx.application.Platform; import javafx.beans.property.SimpleBooleanProperty; import javafx.collections.ListChangeListener; import javafx.geometry.Side; import javafx.scene.Node; import javafx.scene.control.ContextMenu; import javafx.scene.control.Menu; import javafx.scene.control.MenuItem; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; import javafx.scene.input.MouseEvent; import javafx.scene.layout.Region; import lombok.Getter; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.function.Supplier; import java.util.stream.Collectors; public class BrowserQuickAccessContextMenu extends ContextMenu { private final Supplier base; private final BrowserFileSystemTabModel model; private ContextMenu shownBrowserActionsMenu; private boolean expandBrowserActionMenuKey; private boolean keyBasedNavigation; private boolean closeBrowserActionMenuKey; public BrowserQuickAccessContextMenu(Supplier base, BrowserFileSystemTabModel model) { this.base = base; this.model = model; addEventFilter(Menu.ON_SHOWING, e -> { Node content = getSkin().getNode(); if (content instanceof Region r) { r.setMaxWidth(500); } }); addEventFilter(Menu.ON_SHOWN, e -> { Platform.runLater(() -> { var items = getItems(); if (items.size() > 0) { items.getFirst().getStyleableNode().requestFocus(); } }); }); InputHelper.onLeft(this, false, e -> { hide(); e.consume(); }); setAutoHide(!AppPrefs.get().limitedTouchscreenMode().get()); getStyleClass().add("condensed"); var modalListener = new ListChangeListener() { @Override public void onChanged(Change c) { if (!c.getList().isEmpty()) { hide(); } } }; showingProperty().addListener((observable, oldValue, newValue) -> { if (newValue) { AppDialog.getModalOverlays().addListener(modalListener); } else { AppDialog.getModalOverlays().removeListener(modalListener); } }); } public void showMenu(Node anchor) { getItems().clear(); ThreadHelper.runFailableAsync(() -> { var entry = base.get(); if (entry == null) { return; } if (entry.getRawFileEntry().resolved().getKind() != FileKind.DIRECTORY) { return; } var r = new Menu(); updateMenuItems(r, entry, true); Platform.runLater(() -> { getItems().addAll(r.getItems()); // Prevent NPE in show() if (getScene() == null || anchor == null || anchor.getScene() == null) { return; } show(anchor, Side.RIGHT, 0, 0); }); }); } private MenuItem createItem(BrowserEntry browserEntry) { return new QuickAccessMenu(browserEntry).getMenu(); } private List updateMenuItems(Menu m, BrowserEntry entry, boolean updateInstantly) throws Exception { List list = new ArrayList<>(); BooleanScope.executeExclusive(model.getBusy(), () -> { var dir = entry.getRawFileEntry().resolved().getPath(); try (var stream = model.getFileSystem().listFiles(model.getFileSystem(), dir)) { var l = stream.toList(); // Wait until all files are listed, i.e. do not skip the stream elements list.addAll(l.subList(0, Math.min(l.size(), 150))); } }); var newItems = new ArrayList(); if (list.isEmpty()) { var empty = new Menu(""); empty.getStyleClass().add("leaf"); newItems.add(empty); } else { var browserEntries = list.stream() .map(fileEntry -> new BrowserEntry(fileEntry, model.getFileList())) .toList(); var menus = browserEntries.stream() .sorted(model.getFileList().order()) .collect(Collectors.toMap(e -> e, e -> createItem(e), (v1, v2) -> v2, LinkedHashMap::new)); var dirs = browserEntries.stream() .filter(e -> e.getRawFileEntry().getKind() == FileKind.DIRECTORY) .toList(); // Expand subdir if only one // Note that if we have a link to the directory itself, we shouldn't do it, otherwise we are stuck in a loop if (dirs.size() == 1 && !dirs.getFirst() .getRawFileEntry() .getPath() .equals(entry.getRawFileEntry().getPath())) { updateMenuItems((Menu) menus.get(dirs.getFirst()), dirs.getFirst(), true); } newItems.addAll(menus.values()); } if (updateInstantly) { m.getItems().setAll(newItems); } return newItems; } @Getter class QuickAccessMenu { private final BrowserEntry browserEntry; private final Menu menu; private final MenuItem empty; private ContextMenu browserActionMenu; public QuickAccessMenu(BrowserEntry browserEntry) { empty = new Menu("..."); empty.getStyleClass().add("leaf"); this.browserEntry = browserEntry; this.menu = new Menu( // Use original name, not the link target browserEntry.getRawFileEntry().getName(), BrowserIcons.createIcon(browserEntry.getIcon()).build()); createMenu(); addInputListeners(); } private void createMenu() { var fileEntry = browserEntry.getRawFileEntry(); if (fileEntry.resolved().getKind() != FileKind.DIRECTORY) { createFileMenu(); } else { createDirectoryMenu(); } } private void createFileMenu() { menu.setMnemonicParsing(false); menu.addEventFilter(Menu.ON_SHOWN, event -> { menu.hide(); if (keyBasedNavigation && expandBrowserActionMenuKey) { if (!hideBrowserActionsMenu()) { showBrowserActionsMenu(); } } }); menu.setOnAction(event -> { if (event.getTarget() != menu) { return; } if (!hideBrowserActionsMenu()) { showBrowserActionsMenu(); } }); menu.getStyleClass().add("leaf"); menu.getItems().add(empty); } private void createDirectoryMenu() { menu.setMnemonicParsing(false); menu.getItems().add(empty); addHoverHandling(); menu.setOnAction(event -> { if (event.getTarget() != menu) { return; } if (hideBrowserActionsMenu()) { menu.show(); event.consume(); return; } showBrowserActionsMenu(); event.consume(); }); menu.addEventFilter(Menu.ON_SHOWING, event -> { hideBrowserActionsMenu(); }); menu.addEventFilter(Menu.ON_SHOWN, event -> { if (keyBasedNavigation && expandBrowserActionMenuKey) { if (hideBrowserActionsMenu()) { menu.show(); } else { showBrowserActionsMenu(); } } else if (keyBasedNavigation) { expandDirectoryMenu(empty); } }); menu.addEventFilter(Menu.ON_HIDING, event -> { if (closeBrowserActionMenuKey) { menu.show(); } }); } private void addHoverHandling() { var hover = new SimpleBooleanProperty(); menu.addEventFilter(Menu.ON_SHOWING, event -> { if (!keyBasedNavigation) { hover.set(true); } }); menu.addEventFilter(Menu.ON_HIDING, event -> { if (!keyBasedNavigation) { hover.set(false); } }); new BooleanAnimationTimer(hover, 100, () -> { expandDirectoryMenu(empty); }) .start(); } private void addInputListeners() { menu.parentPopupProperty().subscribe(contextMenu -> { if (contextMenu != null) { contextMenu.addEventFilter(KeyEvent.KEY_PRESSED, event -> { keyBasedNavigation = true; if (event.getCode().equals(KeyCode.SPACE) || event.getCode().equals(KeyCode.ENTER)) { expandBrowserActionMenuKey = true; } else { expandBrowserActionMenuKey = false; } if (event.getCode().equals(KeyCode.LEFT) && browserActionMenu != null && browserActionMenu.isShowing()) { closeBrowserActionMenuKey = true; } else { closeBrowserActionMenuKey = false; } }); contextMenu.addEventFilter(MouseEvent.ANY, event -> { keyBasedNavigation = false; }); } }); } private void expandDirectoryMenu(MenuItem empty) { if (menu.isShowing() && !menu.getItems().getFirst().equals(empty)) { return; } ThreadHelper.runFailableAsync(() -> { var newItems = updateMenuItems(menu, browserEntry, false); Platform.runLater(() -> { var reshow = (browserActionMenu == null || !browserActionMenu.isShowing()) && menu.isShowing(); if (reshow) { menu.hide(); } menu.getItems().setAll(newItems); if (reshow) { menu.show(); } }); }); } private boolean hideBrowserActionsMenu() { if (shownBrowserActionsMenu != null && shownBrowserActionsMenu.isShowing()) { shownBrowserActionsMenu.hide(); shownBrowserActionsMenu = null; return true; } return false; } private void showBrowserActionsMenu() { if (browserActionMenu == null) { this.browserActionMenu = new BrowserContextMenu(model, browserEntry, true); this.browserActionMenu.setOnAction(e -> { hide(); }); InputHelper.onLeft(this.browserActionMenu, true, keyEvent -> { this.browserActionMenu.hide(); keyEvent.consume(); }); } menu.hide(); browserActionMenu.show(menu.getStyleableNode(), Side.RIGHT, 0, 0); shownBrowserActionsMenu = browserActionMenu; Platform.runLater(() -> { var items = browserActionMenu.getItems(); if (items.size() > 0) { items.getFirst().getStyleableNode().requestFocus(); } }); } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserStatusBarComp.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.comp.BaseRegionBuilder; import io.xpipe.app.comp.RegionBuilder; import io.xpipe.app.comp.SimpleRegionBuilder; import io.xpipe.app.comp.augment.ContextMenuAugment; import io.xpipe.app.comp.base.HorizontalComp; import io.xpipe.app.comp.base.IconButtonComp; import io.xpipe.app.comp.base.LabelComp; import io.xpipe.app.core.AppFontSizes; import io.xpipe.app.platform.BindingsHelper; import io.xpipe.app.platform.PlatformThread; import io.xpipe.app.util.HumanReadableFormat; import io.xpipe.app.util.ThreadHelper; import javafx.beans.binding.Bindings; import javafx.geometry.Pos; import javafx.scene.input.MouseButton; import javafx.scene.layout.Region; import lombok.EqualsAndHashCode; import lombok.Value; import java.util.List; @Value @EqualsAndHashCode(callSuper = true) public class BrowserStatusBarComp extends SimpleRegionBuilder { BrowserFileSystemTabModel model; @Override protected Region createSimple() { var bar = new HorizontalComp(List.of( createProgressNameStatus(), createProgressStatus(), createProgressEstimateStatus(), RegionBuilder.hspacer(), createClipboardStatus(), createSelectionStatus(), createKillButton())); bar.spacing(15); bar.style("status-bar"); bar.apply(struc -> { struc.widthProperty().subscribe(value -> { var veryConstrained = value.doubleValue() < 600; var somewhatConstrained = value.doubleValue() < 710; struc.getChildren().get(2).setVisible(!somewhatConstrained); struc.getChildren().get(2).setManaged(!somewhatConstrained); struc.getChildren().get(4).setVisible(!veryConstrained); struc.getChildren().get(4).setManaged(!veryConstrained); struc.getChildren().get(5).setVisible(!veryConstrained); struc.getChildren().get(5).setManaged(!veryConstrained); }); }); var r = bar.build(); r.setOnDragDetected(event -> { event.consume(); r.startFullDrag(); }); AppFontSizes.xs(r); simulateEmptyCell(r); return r; } private BaseRegionBuilder createKillButton() { var button = new IconButtonComp("mdi2s-stop", () -> { ThreadHelper.runAsync(() -> { model.killTransfer(); }); }); button.describe(d -> d.nameKey("killTransfer")); var cancel = PlatformThread.sync(model.getTransferCancelled()); var hide = Bindings.createBooleanBinding( () -> { if (model.getProgress().getValue() == null) { return true; } if (cancel.getValue()) { return true; } return false; }, cancel, model.getProgress()); button.hide(hide); return button; } private BaseRegionBuilder createProgressEstimateStatus() { var text = Bindings.createStringBinding( () -> { var p = model.getProgress().getValue(); var expected = model.getProgressRemaining().getValue(); if (p == null || expected == null) { return null; } // Handle unknown transfers if (p.getTotal() == 0) { return HumanReadableFormat.byteCount(p.getTransferred()); } var elapsed = (p.getTotal() - p.getTransferred() / (double) p.getTotal()) * expected.toMillis(); var show = elapsed > 3000; if (!show) { return "..."; } var time = HumanReadableFormat.duration(expected) + " @ "; var progress = HumanReadableFormat.transferSpeed( model.getProgressTransferSpeed().getValue()); return time + progress; }, model.getProgressRemaining(), model.getProgressTransferSpeed(), model.getProgress()); var progressComp = new LabelComp(text) .style("progress") .apply(struc -> struc.setAlignment(Pos.CENTER_LEFT)) .minWidth(Region.USE_PREF_SIZE); return progressComp; } private BaseRegionBuilder createProgressStatus() { var text = BindingsHelper.map(model.getProgress(), p -> { if (p == null) { return null; } else { var transferred = HumanReadableFormat.progressByteCount(p.getTransferred()); // Handle unknown transfers if (p.getTotal() == 0) { if (p.getTransferred() == 0) { return "..."; } else { return transferred; } } var all = HumanReadableFormat.byteCount(p.getTotal()); return transferred + " / " + all; } }); var progressComp = new LabelComp(text) .style("progress") .apply(struc -> struc.setAlignment(Pos.CENTER_LEFT)) .minWidth(Region.USE_PREF_SIZE); return progressComp; } private BaseRegionBuilder createProgressNameStatus() { var text = BindingsHelper.map(model.getProgress(), p -> { if (p == null) { return null; } else { return p.getName(); } }); var progressComp = new LabelComp(text) .style("progress") .apply(struc -> struc.setAlignment(Pos.CENTER_LEFT)) .hgrow(); return progressComp; } private BaseRegionBuilder createClipboardStatus() { var cc = BrowserClipboard.currentCopyClipboard; var ccCount = Bindings.createStringBinding( () -> { if (cc.getValue() != null && cc.getValue().getEntries().size() > 0) { return cc.getValue().getEntries().size() + " file" + (cc.getValue().getEntries().size() > 1 ? "s" : "") + " in clipboard"; } else { return null; } }, cc); return new LabelComp(ccCount).minWidth(Region.USE_PREF_SIZE); } private BaseRegionBuilder createSelectionStatus() { var selectedCount = Bindings.createIntegerBinding( () -> { return model.getFileList().getSelection().size(); }, model.getFileList().getSelection()); var allCount = Bindings.createIntegerBinding( () -> { return model.getFileList().getAll().getValue().size(); }, model.getFileList().getAll()); var selectedComp = new LabelComp(Bindings.createStringBinding( () -> { if (selectedCount.getValue() == 0) { return null; } else { return selectedCount.getValue() + " / " + allCount.getValue() + " selected"; } }, selectedCount, allCount)); return selectedComp.minWidth(Region.USE_PREF_SIZE); } private void simulateEmptyCell(Region r) { var emptyEntry = new BrowserFileListCompEntry(null, r, null, model.getFileList()); r.setOnMouseClicked(e -> { emptyEntry.onMouseClick(e); }); r.setOnMouseDragEntered(event -> { emptyEntry.onMouseDragEntered(event); }); r.setOnDragOver(event -> { emptyEntry.onDragOver(event); }); r.setOnDragEntered(event -> { emptyEntry.onDragEntered(event); }); r.setOnDragDetected(event -> { emptyEntry.startDrag(event); }); r.setOnDragExited(event -> { emptyEntry.onDragExited(event); }); r.setOnDragDropped(event -> { emptyEntry.onDragDrop(event); }); r.setOnDragDone(event -> { emptyEntry.onDragDone(event); }); // Use status bar as an extension of file list new ContextMenuAugment<>( mouseEvent -> mouseEvent.getButton() == MouseButton.SECONDARY, null, () -> new BrowserContextMenu(model, null, false)) .accept(r); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserTerminalDockTabModel.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.browser.BrowserAbstractSessionModel; import io.xpipe.app.browser.BrowserFullSessionModel; import io.xpipe.app.browser.BrowserSessionTab; import io.xpipe.app.comp.BaseRegionBuilder; import io.xpipe.app.comp.base.ModalOverlay; import io.xpipe.app.core.AppI18n; import io.xpipe.app.core.AppLayoutModel; import io.xpipe.app.core.window.AppDialog; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.storage.DataStoreColor; 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; import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.value.ObservableBooleanValue; 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; public final class BrowserTerminalDockTabModel extends BrowserSessionTab { private final BrowserSessionTab origin; private final ObservableList terminalRequests; private final TerminalDockView dockModel = new TerminalDockView(UnaryOperator.identity()); private final BooleanProperty opened = new SimpleBooleanProperty(); private TerminalView.Listener listener; private ObservableBooleanValue viewActive; private boolean closed; public BrowserTerminalDockTabModel( BrowserAbstractSessionModel browserModel, BrowserSessionTab origin, ObservableList terminalRequests) { super(browserModel); this.origin = origin; this.terminalRequests = terminalRequests; } @Override public BaseRegionBuilder comp() { return new TerminalDockBrowserComp(dockModel, opened); } @Override public boolean canImmediatelyClose() { return true; } @Override public void init() throws Exception { listener = new TerminalView.Listener() { @Override public void onSessionOpened(TerminalView.ShellSession session) { if (!terminalRequests.contains(session.getRequest())) { return; } opened.set(true); var closed = dockModel.closeOtherTerminals(session.getRequest()); // Closing and opening windows at the same time might be problematic for some bad implementations if (closed) { ThreadHelper.sleep(250); } var controllable = session.getTerminal().controllable(); if (controllable.isEmpty()) { return; } dockModel.trackTerminal(controllable.get(), true); } @Override public void onSessionClosed(TerminalView.ShellSession session) { if (!terminalRequests.contains(session.getRequest())) { return; } // Ugly fix for Windows Terminal instances not closing properly if multiple windows exist if (AppPrefs.get().terminalType().getValue() instanceof WindowsTerminalType) { var sessions = TerminalView.get().getSessions(); var others = sessions.stream() .filter(shellSession -> shellSession.getTerminal().equals(session.getTerminal())) .count(); if (others == 0) { session.getTerminal().controllable().ifPresent(controllableTerminalSession -> { controllableTerminalSession.close(); }); } } } @Override public void onTerminalClosed(TerminalView.TerminalSession instance) { refreshShowingState(); } }; TerminalView.get().addListener(listener); // If the terminal launch fails ThreadHelper.runAsync(() -> { ThreadHelper.sleep(5000); if (!opened.get()) { refreshShowingState(); } }); viewActive = Bindings.createBooleanBinding( () -> { return this.browserModel.getSelectedEntry().getValue() == origin && AppLayoutModel.get() .getEntries() .indexOf(AppLayoutModel.get() .getSelected() .getValue()) == 1; }, this.browserModel.getSelectedEntry(), AppLayoutModel.get().getSelected()); viewActive.subscribe(aBoolean -> { Platform.runLater(() -> { if (aBoolean) { dockModel.activateView(); } else { dockModel.deactivateView(); } }); }); AppDialog.getModalOverlays().addListener((ListChangeListener) c -> { if (c.getList().size() > 0) { dockModel.deactivateView(); } else { if (viewActive.get()) { dockModel.activateView(); } else { dockModel.deactivateView(); } } }); GlobalTimer.scheduleUntil(Duration.ofMillis(300), false, () -> { if (viewActive.get()) { dockModel.clearDeadTerminals(); dockModel.updateCustomBounds(); } return closed; }); } @Override public void close() { if (listener != null) { TerminalView.get().removeListener(listener); } dockModel.onClose(); closed = true; } @Override public ObservableValue getName() { return AppI18n.observable("terminal"); } @Override public String getIcon() { return null; } @Override public DataStoreColor getColor() { return null; } private void refreshShowingState() { var sessions = TerminalView.get().getSessions(); var remaining = sessions.stream() .filter(s -> terminalRequests.contains(s.getRequest()) && s.getTerminal().isRunning()) .toList(); if (remaining.isEmpty()) { ((BrowserFullSessionModel) browserModel).unsplitTab(BrowserTerminalDockTabModel.this); } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserTransferComp.java ================================================ package io.xpipe.app.browser.file; 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.platform.DerivedObservableList; import io.xpipe.app.util.ThreadHelper; import javafx.beans.binding.Bindings; import javafx.beans.property.SimpleStringProperty; import javafx.collections.FXCollections; import javafx.css.PseudoClass; import javafx.geometry.Insets; import javafx.scene.control.ContentDisplay; import javafx.scene.image.Image; import javafx.scene.input.*; import javafx.scene.layout.Region; import javafx.scene.text.TextAlignment; import org.kordamp.ikonli.javafx.FontIcon; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.util.List; import java.util.Optional; public class BrowserTransferComp extends SimpleRegionBuilder { private final BrowserTransferModel model; public BrowserTransferComp(BrowserTransferModel model) { this.model = model; } @Override protected Region createSimple() { var background = new LabelComp(AppI18n.observable("transferDescription")) .apply(struc -> struc.setGraphic(new FontIcon("mdi2d-download-outline"))) .apply(struc -> struc.setWrapText(true)) .apply(struc -> struc.setTextAlignment(TextAlignment.CENTER)) .apply(struc -> struc.setContentDisplay(ContentDisplay.TOP)) .visible(model.getEmpty()); var backgroundStack = new StackComp(List.of(background)) .style("color-box") .style("gray") .style("download-background"); var binding = DerivedObservableList.wrap(model.getItems(), true) .mapped(item -> item.getBrowserEntry()) .getList(); var list = new BrowserFileSelectionListComp(binding, entry -> { var sourceItem = model.getCurrentItems().stream() .filter(item -> item.getBrowserEntry() == entry) .findAny(); if (sourceItem.isEmpty()) { return new SimpleStringProperty("?"); } synchronized (sourceItem.get().getProgress()) { return Bindings.createStringBinding( () -> { var p = sourceItem.get().getProgress().getValue(); if (p == null || p.getTotal() == 0) { return entry.getFileName(); } var hideProgress = sourceItem.get().getDownloadFinished().get(); var share = p.getTransferred() * 100 / p.getTotal(); var progressSuffix = hideProgress ? "" : " " + share + "%"; return entry.getFileName() + progressSuffix; }, sourceItem.get().getProgress()); } }).vgrow(); var dragNotice = new LabelComp(AppI18n.observable("dragLocalFiles")) .apply(struc -> struc.setGraphic(new FontIcon("mdi2h-hand-back-left-outline"))) .apply(struc -> struc.setWrapText(true)) .hide(Bindings.or(model.getEmpty(), model.getTransferring())); var clearButton = new IconButtonComp("mdi2c-close", () -> { ThreadHelper.runAsync(() -> { model.clear(true); }); }) .hide(Bindings.or(model.getEmpty(), model.getTransferring())) .describe(d -> d.nameKey("clearTransferDescription")); var downloadButton = new IconButtonComp("mdi2f-folder-move-outline", null) .apply(struc -> { struc.setOnMouseClicked(e -> { if (e.getButton() == MouseButton.PRIMARY) { var open = !e.isShiftDown(); ThreadHelper.runFailableAsync(() -> { model.transferToDownloads(open); }); e.consume(); } }); struc.setOnAction(e -> { ThreadHelper.runFailableAsync(() -> { model.transferToDownloads(true); }); e.consume(); }); }) .hide(Bindings.or(model.getEmpty(), model.getTransferring())) .describe(d -> d.nameKey("downloadStageDescription")); var bottom = new HorizontalComp(List.of( RegionBuilder.hspacer(), dragNotice, RegionBuilder.hspacer(), downloadButton, RegionBuilder.hspacer(4), clearButton)); var listBox = new VerticalComp(List.of(list, bottom)) .spacing(5) .padding(new Insets(10, 10, 5, 10)) .apply(struc -> struc.setMinHeight(200)) .apply(struc -> struc.setMaxHeight(200)); var stack = new StackComp(List.of(backgroundStack, listBox)).apply(struc -> { struc.addEventFilter(DragEvent.DRAG_ENTERED, event -> { struc.pseudoClassStateChanged(PseudoClass.getPseudoClass("drag-over"), true); }); struc.addEventFilter( DragEvent.DRAG_EXITED, event -> struc.pseudoClassStateChanged(PseudoClass.getPseudoClass("drag-over"), false)); struc.setOnDragOver(event -> { // Accept drops from inside the app window if (event.getGestureSource() != null && event.getGestureSource() != struc) { event.acceptTransferModes(TransferMode.ANY); event.consume(); } }); struc.setOnDragDropped(event -> { // Accept drops from inside the app window if (event.getGestureSource() != null) { var drag = BrowserClipboard.retrieveDrag(event.getDragboard()); if (drag == null) { return; } if (!(model.getBrowserSessionModel().getSelectedEntry().getValue() instanceof BrowserFileSystemTabModel fileSystemModel)) { return; } var files = drag.getEntries(); model.drop(fileSystemModel, files); event.setDropCompleted(true); event.consume(); } }); struc.setOnDragDetected(event -> { var items = model.getCurrentItems(); var selected = items.stream().map(item -> item.getBrowserEntry()).toList(); var files = items.stream() .filter(item -> item.getDownloadFinished().get()) .map(item -> { try { var file = item.getLocalFile(); if (!Files.exists(file)) { return Optional.empty(); } return Optional.of(file.toRealPath().toFile()); } catch (IOException e) { throw new RuntimeException(e); } }) .flatMap(Optional::stream) .toList(); if (files.isEmpty()) { return; } var cc = new ClipboardContent(); cc.putFiles(files); Dragboard db = struc.startDragAndDrop(TransferMode.COPY); db.setContent(cc); Image image = BrowserFileSelectionListComp.snapshot(FXCollections.observableList(selected)); db.setDragView(image, -20, 15); event.setDragDetect(true); event.consume(); }); struc.setOnDragDone(event -> { if (!event.isAccepted()) { return; } // The files might not have been transferred yet // We can't listen to this, so just don't delete them model.clear(false); event.consume(); }); }); stack.apply(struc -> { model.getBrowserSessionModel().getDraggingFiles().addListener((observable, oldValue, newValue) -> { struc.pseudoClassStateChanged(PseudoClass.getPseudoClass("highlighted"), newValue); }); }); var r = stack.style("transfer").build(); return r; } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserTransferModel.java ================================================ package io.xpipe.app.browser.file; import io.xpipe.app.browser.BrowserFullSessionModel; import io.xpipe.app.browser.action.impl.TransferFilesActionProvider; import io.xpipe.app.core.AppLocalTemp; import io.xpipe.app.core.AppSystemInfo; import io.xpipe.app.core.mode.AppOperationMode; import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.process.OsFileSystem; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.util.BooleanScope; import io.xpipe.app.util.DesktopHelper; import io.xpipe.app.util.ThreadHelper; import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.beans.property.Property; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ObservableBooleanValue; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import lombok.Value; import org.apache.commons.io.FileUtils; import java.io.IOException; import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.List; import java.util.Optional; @Value public class BrowserTransferModel { private static final Path TEMP = AppLocalTemp.getLocalTempDataDirectory("download"); BrowserFullSessionModel browserSessionModel; ObservableList items = FXCollections.observableArrayList(); ObservableBooleanValue empty = Bindings.createBooleanBinding(() -> items.isEmpty(), items); BooleanProperty transferring = new SimpleBooleanProperty(); public BrowserTransferModel(BrowserFullSessionModel browserSessionModel) { this.browserSessionModel = browserSessionModel; var thread = ThreadHelper.createPlatformThread("file downloader", true, () -> { while (true) { Optional toDownload; synchronized (items) { toDownload = items.stream() .filter(item -> !item.getDownloadFinished().get()) .findFirst(); } if (toDownload.isPresent()) { downloadSingle(toDownload.get()); } else { ThreadHelper.sleep(20); } } }); thread.start(); } public List getCurrentItems() { synchronized (items) { return new ArrayList<>(items); } } private void cleanItem(Item item) { if (!Files.isDirectory(TEMP)) { return; } if (!Files.exists(item.getLocalFile())) { return; } try { FileUtils.forceDelete(item.getLocalFile().toFile()); } catch (IOException e) { ErrorEventFactory.fromThrowable(e).handle(); } } public void clear(boolean delete) { List toClear; synchronized (items) { toClear = items.stream() .filter(item -> item.getDownloadFinished().get()) .toList(); if (toClear.isEmpty()) { return; } items.removeAll(toClear); } if (delete) { for (Item item : toClear) { cleanItem(item); } } } public void drop(BrowserFileSystemTabModel model, List entries) { synchronized (items) { entries.forEach(entry -> { var resolved = entry.getRawFileEntry().resolved(); var name = resolved.getName(); if (items.stream().anyMatch(item -> item.getName().equals(name))) { return; } var fixedFile = OsFileSystem.ofLocal().makeFileSystemCompatible(resolved.getPath()); Path file = TEMP.resolve(fixedFile.getFileName()); var item = new Item(model, name, entry, file); items.add(item); }); } } public void downloadSingle(Item item) { try { FileUtils.forceMkdir(TEMP.toFile()); } catch (IOException e) { ErrorEventFactory.fromThrowable(e).handle(); return; } if (item.getDownloadFinished().get()) { return; } var itemModel = item.getOpenFileSystemModel(); if (itemModel == null) { return; } if (AppOperationMode.isInShutdown()) { return; } try (var ignored = new BooleanScope(itemModel.getBusy()).exclusive().start()) { transferring.setValue(true); var op = new BrowserFileTransferOperation( BrowserLocalFileSystem.getLocalFileEntry(TEMP), List.of(item.getBrowserEntry().getRawFileEntry().resolved()), BrowserFileTransferMode.COPY, false, progress -> { // Don't update item progress to keep it as finished if (progress == null) { itemModel.updateProgress(null); return; } synchronized (item.getProgress()) { item.getProgress().setValue(progress); } itemModel.updateProgress(progress); }, itemModel.getTransferCancelled()); var action = TransferFilesActionProvider.Action.builder() .operation(op) .target(DataStorage.get().local().ref()) .download(true) .build(); if (!action.executeSync()) { synchronized (items) { items.remove(item); } } } catch (Throwable t) { ErrorEventFactory.fromThrowable(t).handle(); synchronized (items) { items.remove(item); } } finally { transferring.setValue(false); } } public void transferToDownloads(boolean open) throws Exception { List toMove; synchronized (items) { toMove = items.stream() .filter(item -> item.getDownloadFinished().get()) .toList(); if (toMove.isEmpty()) { return; } items.removeAll(toMove); } var files = toMove.stream().map(item -> item.getLocalFile()).toList(); var downloads = getDownloadsTargetDirectory(); Files.createDirectories(downloads); Path firstToOpen = null; for (Path file : files) { if (!Files.exists(file)) { continue; } var target = downloads.resolve(file.getFileName()); // Prevent DirectoryNotEmptyException if (Files.exists(target) && Files.isDirectory(target)) { FileUtils.deleteDirectory(target.toFile()); } if (Files.isDirectory(file)) { FileUtils.moveDirectory(file.toFile(), target.toFile()); } else { Files.move(file, target, StandardCopyOption.REPLACE_EXISTING); } if (firstToOpen == null) { firstToOpen = target; } } if (open && firstToOpen != null) { DesktopHelper.browseFileInDirectory(firstToOpen); } } private Path getDownloadsTargetDirectory() { var def = AppSystemInfo.ofCurrent().getDownloads(); var custom = AppPrefs.get().downloadsDirectory().getValue(); if (custom == null) { return def; } try { var path = custom.asLocalPath(); if (Files.isDirectory(path)) { return path; } } catch (InvalidPathException ignored) { } return def; } @Value public static class Item { BrowserFileSystemTabModel openFileSystemModel; String name; BrowserEntry browserEntry; Path localFile; Property progress; ObservableBooleanValue downloadFinished; public Item( BrowserFileSystemTabModel openFileSystemModel, String name, BrowserEntry browserEntry, Path localFile) { this.openFileSystemModel = openFileSystemModel; this.name = name; this.browserEntry = browserEntry; this.localFile = localFile; this.progress = new SimpleObjectProperty<>(); this.downloadFinished = Bindings.createBooleanBinding( () -> { return progress.getValue() != null && progress.getValue().done(); }, progress); } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/file/BrowserTransferProgress.java ================================================ package io.xpipe.app.browser.file; import lombok.Value; import java.time.Duration; import java.time.Instant; import java.util.List; @Value public class BrowserTransferProgress { String name; long transferred; long total; Instant timestamp = Instant.now(); public static BrowserTransferProgress finished(String name, long size) { return new BrowserTransferProgress(name, size, size); } public static long estimateTransferSpeed(BrowserTransferProgress start, BrowserTransferProgress end) { var diff = end.transferred - start.transferred; var duration = Duration.between(start.timestamp, end.timestamp); return (long) (diff / (duration.toMillis() / 1000.0)); } public static long estimateTransferSpeed(List list, BrowserTransferProgress now) { if (list.isEmpty()) { return 0; } var rSize = list.size() > 1 ? list.size() - 1 : list.size(); var r = new double[rSize]; for (int i = 0; i < rSize; i++) { r[i] = estimateTransferSpeed(list.get(i), now); } double sum = 0; var lookBack = Math.min(r.length, 5); for (int i = 0; i < lookBack; i++) { sum += r[r.length - i - 1]; } return (long) (sum / lookBack); } public boolean done() { return transferred >= total; } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/icon/BrowserIconDirectoryType.java ================================================ package io.xpipe.app.browser.icon; import io.xpipe.app.core.AppResources; import io.xpipe.app.ext.FileEntry; import io.xpipe.app.ext.FileKind; import java.io.BufferedReader; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Set; import java.util.stream.Collectors; public abstract class BrowserIconDirectoryType { private static final List ALL = new ArrayList<>(); public static synchronized void loadDefinitions() { ALL.add(new BrowserIconDirectoryType() { @Override public boolean matches(FileEntry entry) { return entry.getPath().toString().equals("/") || entry.getPath().toString().matches("\\w:\\\\"); } @Override public String getIcon() { return "browser/default_root_folder.svg"; } }); AppResources.with(AppResources.MAIN_MODULE, "folder_list.txt", path -> { try (var reader = new BufferedReader(new InputStreamReader(Files.newInputStream(path), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { var split = line.split("\\|"); var filter = Arrays.stream(split[1].split(",")) .map(s -> { return s.strip(); }) .collect(Collectors.toSet()); var closedIcon = "browser/" + split[2].strip(); var lightClosedIcon = split.length > 4 ? "browser/" + split[4].strip() : closedIcon; ALL.add(new Simple(new BrowserIconVariant(lightClosedIcon, closedIcon), filter)); } } }); } public static synchronized List getAll() { return ALL; } public abstract boolean matches(FileEntry entry); public abstract String getIcon(); public static class Simple extends BrowserIconDirectoryType { private final BrowserIconVariant closed; private final Set names; public Simple(BrowserIconVariant closed, Set names) { this.closed = closed; this.names = names; } @Override public boolean matches(FileEntry entry) { if (entry.getKind() != FileKind.DIRECTORY) { return false; } var name = entry.getPath().getFileName(); return names.contains(name); } @Override public String getIcon() { return this.closed.getIcon(); } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/icon/BrowserIconFileType.java ================================================ package io.xpipe.app.browser.icon; import io.xpipe.app.core.AppResources; import io.xpipe.app.ext.FileEntry; import io.xpipe.app.ext.FileKind; import lombok.Getter; import java.io.BufferedReader; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.*; import java.util.stream.Collectors; public abstract class BrowserIconFileType { private static final List ALL = new ArrayList<>(); public static synchronized BrowserIconFileType byId(String id) { return ALL.stream() .filter(fileType -> fileType.getId().equals(id)) .findAny() .orElseThrow(); } public static synchronized void loadDefinitions() { AppResources.with(AppResources.MAIN_MODULE, "file_list.txt", path -> { try (var reader = new BufferedReader(new InputStreamReader(Files.newInputStream(path), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { var split = line.split("\\|"); var id = split[0].strip(); var filter = Arrays.stream(split[1].split(",")) .map(s -> { var r = s.strip(); if (r.startsWith(".")) { return r; } if (r.contains(".")) { return r; } return "." + r; }) .collect(Collectors.toSet()); var darkIcon = "browser/" + split[2].strip(); var lightIcon = (split.length > 3 ? "browser/" + split[3].strip() : darkIcon); ALL.add(new BrowserIconFileType.Simple(id, lightIcon, darkIcon, filter)); } } }); } public static synchronized List getAll() { return ALL; } public abstract String getId(); public abstract boolean matches(FileEntry entry); public abstract String getIcon(); @Getter public static class Simple extends BrowserIconFileType { private final String id; private final BrowserIconVariant icon; private final Set endings; public Simple(String id, String lightIcon, String darkIcon, Set endings) { this.icon = new BrowserIconVariant(lightIcon, darkIcon); this.id = id; this.endings = endings; } @Override public boolean matches(FileEntry entry) { if (entry.getKind() == FileKind.DIRECTORY) { return false; } var name = entry.getPath().getFileName(); var ext = entry.getPath().getExtension(); return (ext.isPresent() && endings.contains("." + ext.get().toLowerCase(Locale.ROOT))) || endings.contains(name); } @Override public String getIcon() { return icon.getIcon(); } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/icon/BrowserIconManager.java ================================================ package io.xpipe.app.browser.icon; import io.xpipe.app.core.AppDisplayScale; import io.xpipe.app.core.AppImages; import io.xpipe.app.core.AppResources; import org.apache.commons.io.FilenameUtils; public class BrowserIconManager { private static boolean loaded; public static synchronized void init() { if (!loaded) { BrowserIconFileType.loadDefinitions(); BrowserIconDirectoryType.loadDefinitions(); loaded = true; } } public static void loadIfNecessary(String s) { var res = AppDisplayScale.hasOnlyDefaultDisplayScale() ? "24" : "40"; var key = "browser/" + FilenameUtils.getBaseName(s) + "-" + res + ".png"; if (AppImages.hasImage(key)) { return; } AppResources.with(AppResources.MAIN_MODULE, key, file -> { AppImages.loadImage(file, key); }); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/icon/BrowserIconVariant.java ================================================ package io.xpipe.app.browser.icon; import io.xpipe.app.prefs.AppPrefs; public class BrowserIconVariant { private final String lightIcon; private final String darkIcon; public BrowserIconVariant(String lightIcon, String darkIcon) { this.lightIcon = lightIcon; this.darkIcon = darkIcon; } protected final String getIcon() { var t = AppPrefs.get() != null ? AppPrefs.get().theme().getValue() : null; if (t == null) { return lightIcon; } return t.isDark() ? darkIcon : lightIcon; } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/icon/BrowserIcons.java ================================================ package io.xpipe.app.browser.icon; import io.xpipe.app.comp.BaseRegionBuilder; import io.xpipe.app.comp.base.PrettyImageHelper; public class BrowserIcons { public static BaseRegionBuilder createDefaultFileIcon() { var s = "browser/default_file.svg"; BrowserIconManager.loadIfNecessary(s); return PrettyImageHelper.ofFixedSizeSquare(s, 24); } public static BaseRegionBuilder createDefaultDirectoryIcon() { var s = "browser/default_folder.svg"; BrowserIconManager.loadIfNecessary(s); return PrettyImageHelper.ofFixedSizeSquare(s, 24); } public static BaseRegionBuilder createContextMenuIcon(BrowserIconFileType type) { BrowserIconManager.loadIfNecessary(type.getIcon()); return PrettyImageHelper.ofFixedSizeSquare(type.getIcon(), 16); } public static BaseRegionBuilder createIcon(String s) { BrowserIconManager.loadIfNecessary(s); return PrettyImageHelper.ofFixedSizeSquare(s, 24); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/BrowserApplicationPathMenuProvider.java ================================================ package io.xpipe.app.browser.menu; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import lombok.SneakyThrows; import java.util.List; public interface BrowserApplicationPathMenuProvider extends BrowserMenuItemProvider { String getExecutable(); @Override default void init(BrowserFileSystemTabModel model) throws Exception { if (model.getFileSystem().getShell().isEmpty()) { return; } // Cache result for later calls model.getFileSystem().getShell().get().view().isInPath(getExecutable(), true); } @Override default boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return model.getFileSystem().getShell().isPresent(); } @Override @SneakyThrows default boolean isActive(BrowserFileSystemTabModel model) { // This will always return without an exception as it is cached return model.getFileSystem().getShell().orElseThrow().view().isInPath(getExecutable(), true); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/BrowserMenuBranchProvider.java ================================================ package io.xpipe.app.browser.menu; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import javafx.scene.control.Menu; import javafx.scene.control.MenuItem; import java.util.List; public interface BrowserMenuBranchProvider extends BrowserMenuItemProvider { default MenuItem toMenuItem(BrowserFileSystemTabModel model, List selected) { var m = new Menu(getName(model, selected).getValue() + " ..."); for (var sub : getBranchingActions(model, selected)) { var subselected = resolveFilesIfNeeded(selected); if (!sub.isApplicable(model, subselected)) { continue; } var item = sub.toMenuItem(model, subselected); if (item != null) { m.getItems().add(item); } } if (m.getItems().isEmpty()) { return null; } var graphic = getIcon(); if (graphic != null) { m.setGraphic(graphic.createGraphicNode()); } m.setDisable(!isActive(model)); return m; } List getBranchingActions( BrowserFileSystemTabModel model, List entries); } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/BrowserMenuCategory.java ================================================ package io.xpipe.app.browser.menu; public enum BrowserMenuCategory { CUSTOM, OPEN, COPY_PASTE, ACTION, MUTATION } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/BrowserMenuItemProvider.java ================================================ package io.xpipe.app.browser.menu; import io.xpipe.app.action.ActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.platform.LabelGraphic; import javafx.beans.value.ObservableValue; import javafx.scene.control.MenuItem; import javafx.scene.input.KeyCombination; import java.util.List; public interface BrowserMenuItemProvider extends ActionProvider { MenuItem toMenuItem(BrowserFileSystemTabModel model, List selected); default void init(BrowserFileSystemTabModel model) throws Exception {} default boolean automaticallyResolveLinks() { return true; } default List resolveFilesIfNeeded(List selected) { return automaticallyResolveLinks() ? selected.stream() .map(browserEntry -> new BrowserEntry(browserEntry.getRawFileEntry().resolved(), browserEntry.getModel())) .toList() : selected; } default LabelGraphic getIcon() { return null; } default BrowserMenuCategory getCategory() { return null; } default KeyCombination getShortcut() { return null; } ObservableValue getName(BrowserFileSystemTabModel model, List entries); default boolean acceptsEmptySelection() { return false; } default boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return true; } default boolean isActive(BrowserFileSystemTabModel model) { return true; } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/BrowserMenuLeafProvider.java ================================================ package io.xpipe.app.browser.menu; import io.xpipe.app.action.AbstractAction; import io.xpipe.app.browser.action.BrowserAction; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.action.BrowserActionProviders; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.comp.RegionDescriptor; import io.xpipe.app.hub.action.StoreAction; import io.xpipe.app.storage.DataStoreEntryRef; import javafx.scene.control.Button; import javafx.scene.control.MenuItem; import javafx.scene.input.KeyEvent; import javafx.scene.layout.Region; import lombok.SneakyThrows; import java.util.List; public interface BrowserMenuLeafProvider extends BrowserMenuItemProvider { default void execute(BrowserFileSystemTabModel model, List entries) { createAction(model, entries).executeAsync(); } default Class getDelegateActionProvider() { return null; } @SneakyThrows default AbstractAction createAction(BrowserFileSystemTabModel model, List entries) { var c = getDelegateActionProvider() != null ? BrowserActionProviders.forClass(getDelegateActionProvider()) .getActionClass() .orElseThrow() : getActionClass().orElseThrow(); var bm = c.getDeclaredMethod("builder"); bm.setAccessible(true); var b = bm.invoke(null); if (StoreAction.class.isAssignableFrom(c)) { var refMethod = b.getClass().getMethod("ref", DataStoreEntryRef.class); refMethod.setAccessible(true); refMethod.invoke(b, model.getEntry()); } if (BrowserAction.class.isAssignableFrom(c)) { var modelMethod = b.getClass().getMethod("model", BrowserFileSystemTabModel.class); modelMethod.setAccessible(true); modelMethod.invoke(b, model); var entriesMethod = b.getClass().getMethod("initEntries", BrowserFileSystemTabModel.class, List.class); entriesMethod.setAccessible(true); entriesMethod.invoke(b, model, entries); } var m = b.getClass().getDeclaredMethod("build"); m.setAccessible(true); var defValue = c.cast(m.invoke(b)); return defValue; } default Button toButton(Region root, BrowserFileSystemTabModel model, List selected) { var name = getName(model, selected); var b = new Button(); b.setOnAction(event -> { try { execute(model, selected); } catch (Exception e) { throw new RuntimeException(e); } event.consume(); }); RegionDescriptor.builder().name(name).shortcut(getShortcut()).build().apply(b); var graphic = getIcon(); if (graphic != null) { b.setGraphic(graphic.createGraphicNode()); } b.setMnemonicParsing(false); root.addEventFilter(KeyEvent.KEY_PRESSED, event -> { if (getShortcut() != null && getShortcut().match(event)) { b.fire(); event.consume(); } }); b.setDisable(!isActive(model)); model.getCurrentPath().addListener((observable, oldValue, newValue) -> { b.setDisable(!isActive(model)); }); return b; } default MenuItem toMenuItem(BrowserFileSystemTabModel model, List selected) { var name = getName(model, selected); var mi = new MenuItem(); mi.textProperty().bind(name); mi.setOnAction(event -> { try { execute(model, selected); } catch (Exception e) { throw new RuntimeException(e); } event.consume(); }); if (getShortcut() != null) { mi.setAccelerator(getShortcut()); } var graphic = getIcon(); if (graphic != null) { mi.setGraphic(graphic.createGraphicNode()); } mi.setMnemonicParsing(false); mi.setDisable(!isActive(model)); return mi; } @Override default boolean isApplicable(BrowserFileSystemTabModel model, List entries) { if (getDelegateActionProvider() != null) { var provider = BrowserActionProviders.forClass(getDelegateActionProvider()); return provider.isApplicable(model, entries); } else { return true; } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/BrowserMenuProviders.java ================================================ package io.xpipe.app.browser.menu; import io.xpipe.app.action.ActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import java.util.List; public class BrowserMenuProviders { public static List getFlattened( BrowserFileSystemTabModel model, List entries) { return ActionProvider.ALL.stream() .map(browserAction -> browserAction instanceof BrowserMenuItemProvider ba ? getFlattened(ba, model, entries) : List.of()) .flatMap(List::stream) .toList(); } public static List getFlattened( BrowserMenuItemProvider browserAction, BrowserFileSystemTabModel model, List entries) { return browserAction instanceof BrowserMenuLeafProvider ? List.of((BrowserMenuLeafProvider) browserAction) : browserAction.isApplicable(model, entries) ? ((BrowserMenuBranchProvider) browserAction) .getBranchingActions(model, entries).stream() .map(action -> getFlattened(action, model, entries)) .flatMap(List::stream) .toList() : List.of(); } public static BrowserMenuLeafProvider byId(String id, BrowserFileSystemTabModel model, List entries) { return getFlattened(model, entries).stream() .filter(browserAction -> id.equals(browserAction.getId())) .findAny() .orElseThrow(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/FileTypeMenuProvider.java ================================================ package io.xpipe.app.browser.menu; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.icon.BrowserIconFileType; import io.xpipe.app.browser.icon.BrowserIcons; import io.xpipe.app.platform.LabelGraphic; import java.util.List; public interface FileTypeMenuProvider extends BrowserMenuItemProvider { @Override default LabelGraphic getIcon() { return new LabelGraphic.CompGraphic(BrowserIcons.createContextMenuIcon(getType())); } @Override default boolean isApplicable(BrowserFileSystemTabModel model, List entries) { var t = getType(); return entries.stream().allMatch(entry -> t.matches(entry.getRawFileEntry())); } BrowserIconFileType getType(); } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/MultiExecuteMenuProvider.java ================================================ package io.xpipe.app.browser.menu; import io.xpipe.app.browser.action.impl.RunCommandInBackgroundActionProvider; import io.xpipe.app.browser.action.impl.RunCommandInBrowserActionProvider; import io.xpipe.app.browser.action.impl.RunCommandInTerminalActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.core.AppI18n; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.process.CommandBuilder; import javafx.beans.value.ObservableValue; import lombok.SneakyThrows; import java.util.List; public abstract class MultiExecuteMenuProvider implements BrowserMenuBranchProvider { protected abstract List createCommand(BrowserFileSystemTabModel model, List entries); @Override public List getBranchingActions( BrowserFileSystemTabModel model, List entries) { return List.of( new BrowserMenuLeafProvider() { @Override @SneakyThrows public void execute(BrowserFileSystemTabModel model, List entries) { if (model.getCurrentPath().getValue() == null) { return; } var commands = createCommand(model, entries); for (CommandBuilder command : commands) { var builder = RunCommandInTerminalActionProvider.Action.builder(); builder.initFiles( model, List.of(model.getCurrentPath().getValue())); builder.command(command.buildFull( model.getFileSystem().getShell().orElseThrow())); builder.build().executeAsync(); } } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return AppPrefs.get().terminalType().getValue() != null; } @Override public ObservableValue getName( BrowserFileSystemTabModel model, List entries) { var t = AppPrefs.get().terminalType().getValue(); return AppI18n.observable( "executeInTerminal", t != null ? t.toTranslatedString().getValue() : "?"); } }, new BrowserMenuLeafProvider() { @Override @SneakyThrows public void execute(BrowserFileSystemTabModel model, List entries) { var commands = createCommand(model, entries); for (CommandBuilder command : commands) { var builder = RunCommandInBrowserActionProvider.Action.builder(); builder.initFiles( model, List.of(model.getCurrentPath().getValue())); builder.command(command.buildFull( model.getFileSystem().getShell().orElseThrow())); builder.build().executeAsync(); } } @Override public ObservableValue getName( BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("runInFileBrowser"); } }, new BrowserMenuLeafProvider() { @Override @SneakyThrows public void execute(BrowserFileSystemTabModel model, List entries) { var commands = createCommand(model, entries); for (CommandBuilder command : commands) { var builder = RunCommandInBackgroundActionProvider.Action.builder(); builder.initFiles( model, List.of(model.getCurrentPath().getValue())); builder.command(command.buildFull( model.getFileSystem().getShell().orElseThrow())); builder.build().executeAsync(); } } @Override public ObservableValue getName( BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("runSilent"); } }); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/BackMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.platform.LabelGraphic; import io.xpipe.app.util.BooleanScope; import io.xpipe.app.util.ThreadHelper; import javafx.beans.value.ObservableValue; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import java.util.List; public class BackMenuProvider implements BrowserMenuLeafProvider { @Override public void execute(BrowserFileSystemTabModel model, List entries) { ThreadHelper.runAsync(() -> { BooleanScope.executeExclusive(model.getBusy(), () -> { model.backSync(1); }); }); } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return false; } public String getId() { return "back"; } @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2a-arrow-left"); } @Override public KeyCombination getShortcut() { return new KeyCodeCombination(KeyCode.LEFT, KeyCombination.ALT_DOWN); } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("back"); } @Override public boolean isActive(BrowserFileSystemTabModel model) { return model.getHistory().canGoBackProperty().get(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/BrowseInNativeManagerMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.action.impl.BrowseInNativeManagerActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.platform.LabelGraphic; import io.xpipe.core.OsType; import javafx.beans.value.ObservableValue; import java.util.List; public class BrowseInNativeManagerMenuProvider implements BrowserMenuLeafProvider { @Override public Class getDelegateActionProvider() { return BrowseInNativeManagerActionProvider.class; } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.OPEN; } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return switch (OsType.ofLocal()) { case OsType.Windows ignored -> AppI18n.observable("browseInWindowsExplorer"); case OsType.Linux ignored -> AppI18n.observable("browseInDefaultFileManager"); case OsType.MacOs ignored -> AppI18n.observable("browseInFinder"); }; } @Override public boolean acceptsEmptySelection() { return true; } @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2f-folder-eye-outline"); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/ChgrpMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.action.impl.ChgrpActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.menu.BrowserMenuBranchProvider; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuItemProvider; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.comp.RegionBuilder; import io.xpipe.app.comp.base.ModalOverlay; import io.xpipe.app.core.AppI18n; import io.xpipe.app.ext.FileKind; import io.xpipe.app.platform.LabelGraphic; import javafx.beans.property.SimpleStringProperty; import javafx.beans.value.ObservableValue; import javafx.scene.control.TextField; import lombok.SneakyThrows; import java.util.List; import java.util.stream.Stream; public class ChgrpMenuProvider implements BrowserMenuBranchProvider { @SneakyThrows private static List getLeafActions(BrowserFileSystemTabModel model, boolean recursive) { if (model.getFileSystem().getShell().isEmpty()) { return List.of(new CustomProvider(recursive)); } List actions = Stream.concat( model.getFileSystem().getShell().get().view().getGroupFile().getGroups().entrySet().stream() .filter(e -> !e.getValue().equals("nohome") && !e.getValue().equals("nogroup") && !e.getValue().equals("nobody") && (e.getKey().equals(0) || e.getKey() >= 900)) .map(e -> e.getValue()) .map(s -> (BrowserMenuLeafProvider) new FixedProvider(s, recursive)), Stream.of(new CustomProvider(recursive))) .toList(); return actions; } @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2a-account-group-outline"); } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.MUTATION; } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("chgrp"); } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return model.getFileSystem().supportsChgrp(); } @Override public List getBranchingActions( BrowserFileSystemTabModel model, List entries) { if (entries.stream() .anyMatch(browserEntry -> browserEntry.getRawFileEntry().getKind() == FileKind.DIRECTORY)) { return List.of(new FlatProvider(), new RecursiveProvider()); } else { return getLeafActions(model, false); } } private static class FlatProvider implements BrowserMenuBranchProvider { @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2f-file-outline"); } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("flat"); } @Override public List getBranchingActions( BrowserFileSystemTabModel model, List entries) { return getLeafActions(model, false); } } private static class RecursiveProvider implements BrowserMenuBranchProvider { @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2f-file-tree"); } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("recursive"); } @Override public List getBranchingActions( BrowserFileSystemTabModel model, List entries) { return getLeafActions(model, true); } } private static class FixedProvider implements BrowserMenuLeafProvider { private final String group; private final boolean recursive; private FixedProvider(String group, boolean recursive) { this.group = group; this.recursive = recursive; } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return new SimpleStringProperty(group); } @Override public void execute(BrowserFileSystemTabModel model, List entries) { var builder = ChgrpActionProvider.Action.builder(); builder.initEntries(model, entries); builder.group(group); builder.recursive(recursive); var action = builder.build(); action.executeAsync(); } } private static class CustomProvider implements BrowserMenuLeafProvider { private final boolean recursive; private CustomProvider(boolean recursive) { this.recursive = recursive; } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("custom"); } @Override public void execute(BrowserFileSystemTabModel model, List entries) { var group = new SimpleStringProperty(); var modal = ModalOverlay.of( "groupName", RegionBuilder.of(() -> { var creationName = new TextField(); creationName.textProperty().bindBidirectional(group); return creationName; }) .prefWidth(350)); modal.withDefaultButtons(() -> { if (group.getValue() == null) { return; } var builder = ChgrpActionProvider.Action.builder(); builder.initEntries(model, entries); builder.group(group.getValue()); builder.recursive(recursive); var action = builder.build(); action.executeAsync(); }); modal.show(); } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/ChmodMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.action.impl.ChmodActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.menu.BrowserMenuBranchProvider; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuItemProvider; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.comp.RegionBuilder; import io.xpipe.app.comp.base.ModalOverlay; import io.xpipe.app.core.AppI18n; import io.xpipe.app.ext.FileKind; import io.xpipe.app.platform.LabelGraphic; import javafx.beans.property.SimpleStringProperty; import javafx.beans.value.ObservableValue; import javafx.scene.control.TextField; import java.util.List; public class ChmodMenuProvider implements BrowserMenuBranchProvider { private static List getLeafActions(BrowserFileSystemTabModel ignored, boolean recursive) { var custom = new CustomProvider(recursive); return List.of( new FixedProvider("400", recursive), new FixedProvider("600", recursive), new FixedProvider("644", recursive), new FixedProvider("700", recursive), new FixedProvider("755", recursive), new FixedProvider("777", recursive), new FixedProvider("u+x", recursive), new FixedProvider("a+x", recursive), custom); } @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2w-wrench-outline"); } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.MUTATION; } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("chmod"); } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return model.getFileSystem().supportsChmod(); } @Override public List getBranchingActions( BrowserFileSystemTabModel model, List entries) { if (entries.stream() .anyMatch(browserEntry -> browserEntry.getRawFileEntry().getKind() == FileKind.DIRECTORY)) { return List.of(new FlatProvider(), new RecursiveProvider()); } else { return getLeafActions(model, false); } } private static class FlatProvider implements BrowserMenuBranchProvider { @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2f-file-outline"); } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("flat"); } @Override public List getBranchingActions( BrowserFileSystemTabModel model, List entries) { return getLeafActions(model, false); } } private static class RecursiveProvider implements BrowserMenuBranchProvider { @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2f-file-tree"); } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("recursive"); } @Override public List getBranchingActions( BrowserFileSystemTabModel model, List entries) { return getLeafActions(model, true); } } private static class FixedProvider implements BrowserMenuLeafProvider { private final String permissions; private final boolean recursive; private FixedProvider(String permissions, boolean recursive) { this.permissions = permissions; this.recursive = recursive; } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return new SimpleStringProperty(permissions); } @Override public void execute(BrowserFileSystemTabModel model, List entries) { var builder = ChmodActionProvider.Action.builder(); builder.initEntries(model, entries); builder.permissions(permissions); builder.recursive(recursive); var action = builder.build(); action.executeAsync(); } } private static class CustomProvider implements BrowserMenuLeafProvider { private final boolean recursive; private CustomProvider(boolean recursive) { this.recursive = recursive; } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("custom"); } @Override public void execute(BrowserFileSystemTabModel model, List entries) { var permissions = new SimpleStringProperty(); var modal = ModalOverlay.of( "chmodPermissions", RegionBuilder.of(() -> { var creationName = new TextField(); creationName.textProperty().bindBidirectional(permissions); return creationName; }) .prefWidth(350)); modal.withDefaultButtons(() -> { if (permissions.getValue() == null) { return; } var builder = ChmodActionProvider.Action.builder(); builder.initEntries(model, entries); builder.permissions(permissions.getValue()); builder.recursive(recursive); var action = builder.build(); action.executeAsync(); }); modal.show(); } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/ChownMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.action.impl.ChownActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.menu.BrowserMenuBranchProvider; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuItemProvider; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.comp.RegionBuilder; import io.xpipe.app.comp.base.ModalOverlay; import io.xpipe.app.core.AppI18n; import io.xpipe.app.ext.FileKind; import io.xpipe.app.platform.LabelGraphic; import javafx.beans.property.SimpleStringProperty; import javafx.beans.value.ObservableValue; import javafx.scene.control.TextField; import lombok.SneakyThrows; import java.util.List; import java.util.stream.Stream; public class ChownMenuProvider implements BrowserMenuBranchProvider { @SneakyThrows private static List getLeafActions(BrowserFileSystemTabModel model, boolean recursive) { if (model.getFileSystem().getShell().isEmpty()) { return List.of(new CustomProvider(recursive)); } var actions = Stream.concat( model.getFileSystem().getShell().get().view().getPasswdFile().getUsers().entrySet().stream() .filter(e -> !e.getValue().equals("nohome") && !e.getValue().equals("nobody") && (e.getKey().equals(0) || e.getKey() >= 900)) .map(e -> e.getValue()) .map(s -> (BrowserMenuLeafProvider) new FixedProvider(s, recursive)), Stream.of(new CustomProvider(recursive))) .toList(); return actions; } @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2a-account-edit"); } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.MUTATION; } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("chown"); } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return model.getFileSystem().supportsChown(); } @Override public List getBranchingActions( BrowserFileSystemTabModel model, List entries) { if (entries.stream() .anyMatch(browserEntry -> browserEntry.getRawFileEntry().getKind() == FileKind.DIRECTORY)) { return List.of(new FlatProvider(), new RecursiveProvider()); } else { return getLeafActions(model, false); } } private static class FlatProvider implements BrowserMenuBranchProvider { @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2f-file-outline"); } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("flat"); } @Override public List getBranchingActions( BrowserFileSystemTabModel model, List entries) { return getLeafActions(model, false); } } private static class RecursiveProvider implements BrowserMenuBranchProvider { @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2f-file-tree"); } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("recursive"); } @Override public List getBranchingActions( BrowserFileSystemTabModel model, List entries) { return getLeafActions(model, true); } } private static class FixedProvider implements BrowserMenuLeafProvider { private final String owner; private final boolean recursive; private FixedProvider(String owner, boolean recursive) { this.owner = owner; this.recursive = recursive; } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return new SimpleStringProperty(owner); } @Override public void execute(BrowserFileSystemTabModel model, List entries) { var builder = ChownActionProvider.Action.builder(); builder.initEntries(model, entries); builder.owner(owner); builder.recursive(recursive); var action = builder.build(); action.executeAsync(); } } private static class CustomProvider implements BrowserMenuLeafProvider { private final boolean recursive; private CustomProvider(boolean recursive) { this.recursive = recursive; } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("custom"); } @Override public void execute(BrowserFileSystemTabModel model, List entries) { var user = new SimpleStringProperty(); var modal = ModalOverlay.of( "userName", RegionBuilder.of(() -> { var creationName = new TextField(); creationName.textProperty().bindBidirectional(user); return creationName; }) .prefWidth(350)); modal.withDefaultButtons(() -> { if (user.getValue() == null) { return; } var builder = ChownActionProvider.Action.builder(); builder.initEntries(model, entries); builder.owner(user.getValue()); builder.recursive(recursive); var action = builder.build(); action.executeAsync(); }); modal.show(); } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/ComputeDirectorySizesMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.action.AbstractAction; import io.xpipe.app.browser.action.impl.ComputeDirectorySizesActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.ext.FileKind; import io.xpipe.app.platform.LabelGraphic; import javafx.beans.value.ObservableValue; import java.util.List; public class ComputeDirectorySizesMenuProvider implements BrowserMenuLeafProvider { public String getId() { return "computeDirectorySizes"; } @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2f-format-list-text"); } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.ACTION; } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { var topLevel = entries.size() == 1 && entries.getFirst().getRawFileEntry().equals(model.getCurrentDirectory()); return AppI18n.observable(topLevel ? "computeDirectorySizes" : "computeSize"); } @Override public boolean acceptsEmptySelection() { return true; } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return model.getFileSystem().supportsDirectorySizes() && entries.stream() .allMatch(browserEntry -> browserEntry.getRawFileEntry().getKind() == FileKind.DIRECTORY); } @Override public AbstractAction createAction(BrowserFileSystemTabModel model, List entries) { var builder = ComputeDirectorySizesActionProvider.Action.builder(); builder.initEntries(model, entries); return builder.build(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/CopyMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.file.BrowserClipboard; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.platform.LabelGraphic; import javafx.beans.value.ObservableValue; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import java.util.List; public class CopyMenuProvider implements BrowserMenuLeafProvider { @Override public void execute(BrowserFileSystemTabModel model, List entries) { BrowserClipboard.startCopy(model.getCurrentDirectory(), entries); } @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdoal-file_copy"); } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.COPY_PASTE; } @Override public KeyCombination getShortcut() { return new KeyCodeCombination(KeyCode.C, KeyCombination.SHORTCUT_DOWN); } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("copy"); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/CopyPathMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.menu.BrowserMenuBranchProvider; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.ext.FileKind; import io.xpipe.app.platform.ClipboardHelper; import io.xpipe.app.platform.LabelGraphic; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ObservableValue; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import java.util.List; import java.util.stream.Collectors; public class CopyPathMenuProvider implements BrowserMenuBranchProvider { private static String centerEllipsis(String input, int length) { if (input == null) { return ""; } if (input.length() <= length) { return input; } var half = (length / 2) - 5; return input.substring(0, half) + " ... " + input.substring(input.length() - half); } @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2c-content-copy"); } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.COPY_PASTE; } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("copyLocation"); } @Override public boolean acceptsEmptySelection() { return true; } @Override public List getBranchingActions( BrowserFileSystemTabModel model, List entries) { return List.of( new BrowserMenuLeafProvider() { @Override public KeyCombination getShortcut() { return new KeyCodeCombination(KeyCode.C, KeyCombination.ALT_DOWN, KeyCombination.SHORTCUT_DOWN); } @Override public ObservableValue getName( BrowserFileSystemTabModel model, List entries) { if (entries.size() == 1) { return new SimpleObjectProperty<>(centerEllipsis( entries.getFirst() .getRawFileEntry() .getPath() .toString(), 50)); } return AppI18n.observable("absolutePaths"); } @Override public void execute(BrowserFileSystemTabModel model, List entries) { var s = entries.stream() .map(entry -> entry.getRawFileEntry().getPath().toString()) .collect(Collectors.joining("\n")); ClipboardHelper.copyText(s); } }, new BrowserMenuLeafProvider() { @Override public boolean automaticallyResolveLinks() { return false; } @Override public ObservableValue getName( BrowserFileSystemTabModel model, List entries) { if (entries.size() == 1) { return new SimpleObjectProperty<>(centerEllipsis( entries.getFirst() .getRawFileEntry() .getPath() .toString(), 50)); } return AppI18n.observable("absoluteLinkPaths"); } @Override public void execute(BrowserFileSystemTabModel model, List entries) { var s = entries.stream() .map(entry -> entry.getRawFileEntry().getPath().toString()) .collect(Collectors.joining("\n")); ClipboardHelper.copyText(s); } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return entries.stream() .allMatch(browserEntry -> browserEntry.getRawFileEntry().getKind() == FileKind.LINK); } }, new BrowserMenuLeafProvider() { @Override public ObservableValue getName( BrowserFileSystemTabModel model, List entries) { if (entries.size() == 1) { return new SimpleObjectProperty<>("\"" + centerEllipsis( entries.getFirst() .getRawFileEntry() .getPath() .toString(), 50) + "\""); } return AppI18n.observable("absolutePathsQuoted"); } @Override public void execute(BrowserFileSystemTabModel model, List entries) { var s = entries.stream() .map(entry -> "\"" + entry.getRawFileEntry().getPath() + "\"") .collect(Collectors.joining("\n")); ClipboardHelper.copyText(s); } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return entries.stream().anyMatch(entry -> entry.getRawFileEntry() .getPath() .toString() .contains(" ")); } }, new BrowserMenuLeafProvider() { @Override public KeyCombination getShortcut() { return new KeyCodeCombination( KeyCode.C, KeyCombination.SHIFT_DOWN, KeyCombination.SHORTCUT_DOWN); } @Override public ObservableValue getName( BrowserFileSystemTabModel model, List entries) { if (entries.size() == 1) { return new SimpleObjectProperty<>(centerEllipsis( entries.getFirst() .getRawFileEntry() .getPath() .getFileName(), 50)); } return AppI18n.observable("fileNames"); } @Override public void execute(BrowserFileSystemTabModel model, List entries) { var s = entries.stream() .map(entry -> entry.getRawFileEntry().getPath().getFileName()) .collect(Collectors.joining("\n")); ClipboardHelper.copyText(s); } }, new BrowserMenuLeafProvider() { @Override public boolean automaticallyResolveLinks() { return false; } @Override public ObservableValue getName( BrowserFileSystemTabModel model, List entries) { if (entries.size() == 1) { return new SimpleObjectProperty<>(centerEllipsis( entries.getFirst() .getRawFileEntry() .getPath() .getFileName(), 50)); } return AppI18n.observable("linkFileNames"); } @Override public void execute(BrowserFileSystemTabModel model, List entries) { var s = entries.stream() .map(entry -> entry.getRawFileEntry().getPath().getFileName()) .collect(Collectors.joining("\n")); ClipboardHelper.copyText(s); } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return entries.stream() .allMatch(browserEntry -> browserEntry.getRawFileEntry().getKind() == FileKind.LINK) && entries.stream().anyMatch(browserEntry -> !browserEntry .getFileName() .equals(browserEntry .getRawFileEntry() .resolved() .getPath() .getFileName())); } }, new BrowserMenuLeafProvider() { @Override public ObservableValue getName( BrowserFileSystemTabModel model, List entries) { if (entries.size() == 1) { return new SimpleObjectProperty<>("\"" + centerEllipsis( entries.getFirst() .getRawFileEntry() .getPath() .getFileName(), 50) + "\""); } return AppI18n.observable("fileNamesQuoted"); } @Override public void execute(BrowserFileSystemTabModel model, List entries) { var s = entries.stream() .map(entry -> "\"" + entry.getRawFileEntry().getPath().getFileName() + "\"") .collect(Collectors.joining("\n")); ClipboardHelper.copyText(s); } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return entries.stream().anyMatch(entry -> entry.getRawFileEntry() .getPath() .getFileName() .contains(" ")); } }); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/DeleteMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.action.impl.DeleteActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.ext.FileKind; import io.xpipe.app.platform.LabelGraphic; import javafx.beans.value.ObservableValue; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import java.util.List; public class DeleteMenuProvider implements BrowserMenuLeafProvider { @Override public Class getDelegateActionProvider() { return DeleteActionProvider.class; } @Override public boolean automaticallyResolveLinks() { return false; } @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2d-delete"); } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.MUTATION; } @Override public KeyCombination getShortcut() { return new KeyCodeCombination(KeyCode.DELETE); } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable( "deleteFile", entries.stream() .anyMatch(browserEntry -> browserEntry.getRawFileEntry().getKind() == FileKind.LINK) ? "link" : ""); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/DownloadMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.BrowserFullSessionModel; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.platform.LabelGraphic; import javafx.beans.value.ObservableValue; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import java.util.List; public class DownloadMenuProvider implements BrowserMenuLeafProvider { @Override public void execute(BrowserFileSystemTabModel model, List entries) { var transfer = model.getBrowserModel(); if (!(transfer instanceof BrowserFullSessionModel fullSessionModel)) { return; } fullSessionModel.getLocalTransfersStage().drop(model, entries); } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { var transfer = model.getBrowserModel(); if (!(transfer instanceof BrowserFullSessionModel)) { return false; } return true; } public String getId() { return "download"; } @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2d-download"); } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.ACTION; } @Override public KeyCombination getShortcut() { return new KeyCodeCombination(KeyCode.D, KeyCombination.SHORTCUT_DOWN); } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("download"); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/EditFileMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileOpener; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.ext.FileKind; import io.xpipe.app.platform.LabelGraphic; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.util.ThreadHelper; import javafx.beans.value.ObservableValue; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import java.util.List; public class EditFileMenuProvider implements BrowserMenuLeafProvider { @Override public void execute(BrowserFileSystemTabModel model, List entries) { ThreadHelper.runAsync(() -> { for (BrowserEntry entry : entries) { BrowserFileOpener.openInTextEditor(model, entry.getRawFileEntry()); } }); } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return entries.stream().allMatch(entry -> entry.getRawFileEntry().getKind() == FileKind.FILE); } @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2p-pencil"); } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.OPEN; } @Override public KeyCombination getShortcut() { return new KeyCodeCombination(KeyCode.E, KeyCombination.SHORTCUT_DOWN); } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { var e = AppPrefs.get().externalEditor().getValue(); return AppI18n.observable( "editWithEditor", e != null ? e.toTranslatedString().getValue() : "?"); } @Override public boolean isActive(BrowserFileSystemTabModel model) { var e = AppPrefs.get().externalEditor().getValue(); return e != null; } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/FollowLinkMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.ext.FileKind; import io.xpipe.app.platform.LabelGraphic; import javafx.beans.value.ObservableValue; import java.util.List; public class FollowLinkMenuProvider implements BrowserMenuLeafProvider { @Override public void execute(BrowserFileSystemTabModel model, List entries) { var target = entries.getFirst().getRawFileEntry().resolved().getPath().getParent(); model.cdAsync(target); } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return entries.size() == 1 && entries.stream() .allMatch(entry -> entry.getRawFileEntry().getKind() == FileKind.LINK && entry.getRawFileEntry().resolved().getKind() != FileKind.DIRECTORY); } @Override public boolean automaticallyResolveLinks() { return false; } @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2a-arrow-top-right-thick"); } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.OPEN; } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("followLink"); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/ForwardMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.platform.LabelGraphic; import io.xpipe.app.util.BooleanScope; import io.xpipe.app.util.ThreadHelper; import javafx.beans.value.ObservableValue; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import java.util.List; public class ForwardMenuProvider implements BrowserMenuLeafProvider { @Override public void execute(BrowserFileSystemTabModel model, List entries) { ThreadHelper.runAsync(() -> { BooleanScope.executeExclusive(model.getBusy(), () -> { model.forthSync(1); }); }); } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return false; } public String getId() { return "forward"; } @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2a-arrow-right"); } @Override public KeyCombination getShortcut() { return new KeyCodeCombination(KeyCode.RIGHT, KeyCombination.ALT_DOWN); } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("goForward"); } @Override public boolean isActive(BrowserFileSystemTabModel model) { return model.getHistory().canGoForthProperty().get(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/GradleRunMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.comp.RegionBuilder; import io.xpipe.app.comp.base.ModalOverlay; import io.xpipe.app.core.AppI18n; import io.xpipe.app.ext.FileKind; import io.xpipe.app.platform.LabelGraphic; import io.xpipe.app.process.CommandBuilder; import io.xpipe.core.OsType; import javafx.beans.property.SimpleStringProperty; import javafx.beans.value.ObservableValue; import javafx.scene.control.TextField; import java.util.List; public class GradleRunMenuProvider implements BrowserMenuLeafProvider { @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { if (model.getFileSystem().getShell().isEmpty()) { return false; } if (entries.size() != 1) { return false; } if (entries.getFirst().getRawFileEntry().getKind() != FileKind.FILE) { return false; } OsType.Any osType = model.getFileSystem().getShell().orElseThrow().getOsType(); var ext = switch (osType) { case OsType.Windows ignored -> "gradlew.bat"; default -> "gradlew"; }; if (!entries.getFirst().getFileName().equalsIgnoreCase(ext)) { return false; } return true; } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("runTask"); } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.CUSTOM; } @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2e-elephant"); } @Override public void execute(BrowserFileSystemTabModel model, List entries) { var tasks = new SimpleStringProperty(); var modal = ModalOverlay.of( "gradleTasks", RegionBuilder.of(() -> { var creationName = new TextField(); creationName.textProperty().bindBidirectional(tasks); return creationName; }) .prefWidth(350)); modal.withDefaultButtons(() -> { var fixedTasks = tasks.getValue(); if (fixedTasks == null) { return; } var parent = entries.getFirst().getRawFileEntry().getPath().getParent(); var command = model.getFileSystem().getShell().orElseThrow().command(CommandBuilder.of() .add("sh") .addFile(entries.getFirst().getRawFileEntry().getPath()) .add(fixedTasks) ); model.openTerminalAsync(fixedTasks, parent, command, true); }); modal.show(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/JarMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.icon.BrowserIconFileType; import io.xpipe.app.browser.menu.*; import io.xpipe.app.process.CommandBuilder; import javafx.beans.property.SimpleStringProperty; import javafx.beans.value.ObservableValue; import java.util.List; public class JarMenuProvider extends MultiExecuteMenuProvider implements BrowserApplicationPathMenuProvider, FileTypeMenuProvider { @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.CUSTOM; } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { var arg = entries.size() == 1 ? entries.getFirst().getFileName() : "(" + entries.size() + ")"; return new SimpleStringProperty("java -jar " + arg); } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { if (!BrowserApplicationPathMenuProvider.super.isApplicable(model, entries)) { return false; } return FileTypeMenuProvider.super.isApplicable(model, entries); } @Override public BrowserIconFileType getType() { return BrowserIconFileType.byId("jar"); } @Override public String getExecutable() { return "java"; } @Override protected List createCommand(BrowserFileSystemTabModel model, List entries) { return entries.stream() .map(browserEntry -> { return CommandBuilder.of() .add("java", "-jar") .addFile(browserEntry.getRawFileEntry().getPath()); }) .toList(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/JavapMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.icon.BrowserIconFileType; import io.xpipe.app.browser.menu.BrowserApplicationPathMenuProvider; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.browser.menu.FileTypeMenuProvider; import io.xpipe.app.process.CommandBuilder; import io.xpipe.app.process.ShellControl; import io.xpipe.app.util.FileOpener; import io.xpipe.app.util.ThreadHelper; import javafx.beans.property.SimpleStringProperty; import javafx.beans.value.ObservableValue; import java.util.List; public class JavapMenuProvider implements FileTypeMenuProvider, BrowserApplicationPathMenuProvider, BrowserMenuLeafProvider { @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.CUSTOM; } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { var arg = entries.size() == 1 ? entries.getFirst().getFileName() : "(" + entries.size() + ")"; return new SimpleStringProperty("javap -c -p " + arg); } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { if (!BrowserApplicationPathMenuProvider.super.isApplicable(model, entries) || !BrowserMenuLeafProvider.super.isApplicable(model, entries)) { return false; } return FileTypeMenuProvider.super.isApplicable(model, entries); } @Override public BrowserIconFileType getType() { return BrowserIconFileType.byId("class"); } @Override public void execute(BrowserFileSystemTabModel model, List entries) { ThreadHelper.runFailableAsync(() -> { ShellControl sc = model.getFileSystem().getShell().orElseThrow(); for (BrowserEntry entry : entries) { var command = CommandBuilder.of() .add("javap", "-c", "-p") .addFile(entry.getRawFileEntry().getPath()); var out = sc.command(command) .withWorkingDirectory(model.getCurrentDirectory().getPath()) .readStdoutOrThrow(); FileOpener.openReadOnlyString(out); } }); } @Override public String getExecutable() { return "java"; } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/NewItemMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.action.impl.NewDirectoryActionProvider; import io.xpipe.app.browser.action.impl.NewFileActionProvider; import io.xpipe.app.browser.action.impl.NewLinkActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.icon.BrowserIcons; import io.xpipe.app.browser.menu.BrowserMenuBranchProvider; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.comp.RegionBuilder; import io.xpipe.app.comp.base.ModalOverlay; import io.xpipe.app.core.AppI18n; import io.xpipe.app.ext.FileKind; import io.xpipe.app.platform.LabelGraphic; import io.xpipe.app.platform.OptionsBuilder; import io.xpipe.core.FilePath; import javafx.beans.property.SimpleStringProperty; import javafx.beans.value.ObservableValue; import javafx.scene.control.TextField; import java.util.List; public class NewItemMenuProvider implements BrowserMenuBranchProvider { @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2p-plus-box-outline"); } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.ACTION; } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("new"); } @Override public boolean acceptsEmptySelection() { return true; } @Override public List getBranchingActions( BrowserFileSystemTabModel model, List entries) { return List.of( new BrowserMenuLeafProvider() { @Override public void execute(BrowserFileSystemTabModel model, List entries) { var name = new SimpleStringProperty(); var modal = ModalOverlay.of( "newFile", RegionBuilder.of(() -> { var creationName = new TextField(); creationName.textProperty().bindBidirectional(name); return creationName; }) .prefWidth(350)); modal.withDefaultButtons(() -> { if (name.getValue() == null || name.getValue().isEmpty()) { return; } var fixedFiles = entries.stream() .map(browserEntry -> browserEntry.getRawFileEntry().getKind() == FileKind.DIRECTORY ? browserEntry .getRawFileEntry() .getPath() : browserEntry .getRawFileEntry() .getPath() .getParent()) .toList(); var builder = NewFileActionProvider.Action.builder(); builder.initFiles(model, fixedFiles); builder.name(name.getValue().strip()); builder.build().executeAsync(); }); modal.show(); } @Override public LabelGraphic getIcon() { return new LabelGraphic.CompGraphic(BrowserIcons.createDefaultFileIcon()); } @Override public ObservableValue getName( BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("file"); } }, new BrowserMenuLeafProvider() { @Override public void execute(BrowserFileSystemTabModel model, List entries) { var name = new SimpleStringProperty(); var modal = ModalOverlay.of( "newDirectory", RegionBuilder.of(() -> { var creationName = new TextField(); creationName.textProperty().bindBidirectional(name); return creationName; }) .prefWidth(350)); modal.withDefaultButtons(() -> { if (name.getValue() == null || name.getValue().isEmpty()) { return; } var fixedFiles = entries.stream() .map(browserEntry -> browserEntry.getRawFileEntry().getKind() == FileKind.DIRECTORY ? browserEntry .getRawFileEntry() .getPath() : browserEntry .getRawFileEntry() .getPath() .getParent()) .toList(); var builder = NewDirectoryActionProvider.Action.builder(); builder.initFiles(model, fixedFiles); builder.name(name.getValue().strip()); builder.build().executeAsync(); }); modal.show(); } @Override public LabelGraphic getIcon() { return new LabelGraphic.CompGraphic(BrowserIcons.createDefaultDirectoryIcon()); } @Override public ObservableValue getName( BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("directory"); } }, new BrowserMenuLeafProvider() { @Override public void execute(BrowserFileSystemTabModel model, List entries) { var linkName = new SimpleStringProperty(); var target = new SimpleStringProperty(); var modal = ModalOverlay.of( "newLink", new OptionsBuilder() .name("linkName") .addString(linkName) .name("targetPath") .addString(target) .buildComp() .prefWidth(350)); modal.withDefaultButtons(() -> { if (linkName.getValue() == null || linkName.getValue().isEmpty() || target.getValue() == null || target.getValue().isEmpty()) { return; } var fixedFiles = entries.stream() .map(browserEntry -> browserEntry.getRawFileEntry().getKind() == FileKind.DIRECTORY ? browserEntry .getRawFileEntry() .getPath() : browserEntry .getRawFileEntry() .getPath() .getParent()) .toList(); var builder = NewLinkActionProvider.Action.builder(); builder.initFiles(model, fixedFiles); builder.name(linkName.getValue().strip()); builder.target(FilePath.of(target.getValue())); builder.build().executeAsync(); }); modal.show(); } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return model.getFileSystem().supportsLinkCreation(); } @Override public LabelGraphic getIcon() { return new LabelGraphic.CompGraphic(BrowserIcons.createDefaultFileIcon()); } @Override public ObservableValue getName( BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("symbolicLink"); } }); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/OpenDirectoryInNewTabMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.BrowserFullSessionModel; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.ext.FileKind; import io.xpipe.app.platform.LabelGraphic; import javafx.beans.value.ObservableValue; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import java.util.List; public class OpenDirectoryInNewTabMenuProvider implements BrowserMenuLeafProvider { @Override public void execute(BrowserFileSystemTabModel model, List entries) { if (model.getBrowserModel() instanceof BrowserFullSessionModel bm) { bm.openFileSystemAsync( model.getEntry(), null, m -> entries.getFirst().getRawFileEntry().getPath(), null); } } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return model.getBrowserModel() instanceof BrowserFullSessionModel && entries.size() == 1 && entries.stream().allMatch(entry -> entry.getRawFileEntry().getKind() == FileKind.DIRECTORY); } @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2f-folder-open-outline"); } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.OPEN; } @Override public KeyCombination getShortcut() { return new KeyCodeCombination(KeyCode.ENTER, KeyCombination.SHIFT_DOWN); } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("openInNewTab"); } @Override public boolean acceptsEmptySelection() { return true; } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/OpenDirectoryMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.action.impl.OpenDirectoryActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.platform.LabelGraphic; import javafx.beans.value.ObservableValue; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import java.util.List; public class OpenDirectoryMenuProvider implements BrowserMenuLeafProvider { @Override public Class getDelegateActionProvider() { return OpenDirectoryActionProvider.class; } @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2f-folder-open"); } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.OPEN; } @Override public KeyCombination getShortcut() { return new KeyCodeCombination(KeyCode.ENTER); } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("open"); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/OpenFileDefaultMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.action.impl.OpenFileDefaultActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.platform.LabelGraphic; import javafx.beans.value.ObservableValue; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import java.util.List; public class OpenFileDefaultMenuProvider implements BrowserMenuLeafProvider { @Override public Class getDelegateActionProvider() { return OpenFileDefaultActionProvider.class; } @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2b-book-open-variant"); } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.OPEN; } @Override public KeyCombination getShortcut() { return new KeyCodeCombination(KeyCode.ENTER); } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("openWithDefaultApplication"); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/OpenFileWithMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.action.impl.OpenFileWithActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.ext.FileKind; import io.xpipe.app.platform.LabelGraphic; import io.xpipe.core.OsType; import javafx.beans.value.ObservableValue; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import java.util.List; public class OpenFileWithMenuProvider implements BrowserMenuLeafProvider { @Override public Class getDelegateActionProvider() { return OpenFileWithActionProvider.class; } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return OsType.ofLocal() == OsType.WINDOWS && entries.size() == 1 && entries.stream().allMatch(entry -> entry.getRawFileEntry().getKind() == FileKind.FILE); } @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2b-book-open-page-variant-outline"); } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.OPEN; } @Override public KeyCombination getShortcut() { return new KeyCodeCombination(KeyCode.ENTER, KeyCombination.SHIFT_DOWN); } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("openFileWith"); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/OpenNativeFileDetailsMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.action.impl.OpenFileNativeDetailsActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.platform.LabelGraphic; import javafx.beans.value.ObservableValue; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import java.util.List; public class OpenNativeFileDetailsMenuProvider implements BrowserMenuLeafProvider { @Override public Class getDelegateActionProvider() { return OpenFileNativeDetailsActionProvider.class; } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.OPEN; } @Override public KeyCombination getShortcut() { return new KeyCodeCombination(KeyCode.ENTER, KeyCombination.ALT_DOWN); } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("showDetails"); } @Override public boolean acceptsEmptySelection() { return true; } @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2f-folder-information-outline"); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/OpenTerminalInDirectoryMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.ext.FileKind; import io.xpipe.app.platform.LabelGraphic; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.core.FilePath; import javafx.beans.value.ObservableValue; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import java.util.Collections; import java.util.List; public class OpenTerminalInDirectoryMenuProvider implements BrowserMenuLeafProvider { @Override public void execute(BrowserFileSystemTabModel model, List entries) { var dirs = entries.size() > 0 ? entries.stream() .map(browserEntry -> browserEntry.getRawFileEntry().getPath()) .toList() : model.getCurrentDirectory() != null ? List.of(model.getCurrentDirectory().getPath()) : Collections.singletonList((FilePath) null); for (var dir : dirs) { var name = model.getFileSystem().supportsTerminalWorkingDirectory() && dir != null ? dir.toString() : null; model.openTerminalAsync( name, model.getFileSystem().supportsTerminalWorkingDirectory() ? dir : null, model.getFileSystem().getRawShellControl().orElseThrow(), dirs.size() == 1); } } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return model.getFileSystem().supportsTerminalOpen() && entries.stream().allMatch(entry -> entry.getRawFileEntry().getKind() == FileKind.DIRECTORY); } public String getId() { return "openInTerminal"; } @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2c-console"); } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.OPEN; } @Override public KeyCombination getShortcut() { return new KeyCodeCombination(KeyCode.T, KeyCombination.SHORTCUT_DOWN); } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("openInTerminal"); } @Override public boolean isActive(BrowserFileSystemTabModel model) { var t = AppPrefs.get().terminalType().getValue(); return t != null; } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/PasteMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.file.BrowserClipboard; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.file.BrowserFileTransferMode; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.ext.FileKind; import io.xpipe.app.platform.LabelGraphic; import javafx.beans.value.ObservableValue; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import java.util.List; public class PasteMenuProvider implements BrowserMenuLeafProvider { @Override public void execute(BrowserFileSystemTabModel model, List entries) { var clipboard = BrowserClipboard.retrieveCopy(); if (clipboard == null) { return; } var target = entries.size() == 1 && entries.getFirst().getRawFileEntry().getKind() == FileKind.DIRECTORY ? entries.getFirst().getRawFileEntry() : model.getCurrentDirectory(); var files = clipboard.getEntries(); if (files.size() == 0) { return; } var isDuplication = files.size() == 1 && model.getFileSystem() .equals(files.getFirst().getRawFileEntry().getFileSystem()) && target.getPath() .equals(files.getFirst().getRawFileEntry().getPath().getParent()); if (isDuplication) { model.duplicateFile(files.getFirst().getRawFileEntry()); } else { model.dropFilesIntoAsync( target, files.stream() .map(browserEntry -> browserEntry.getRawFileEntry()) .toList(), BrowserFileTransferMode.COPY); } } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { var clipboard = BrowserClipboard.retrieveCopy(); if (clipboard == null) { return false; } return (entries.size() == 1 && entries.stream() .allMatch(entry -> entry.getRawFileEntry().getKind() == FileKind.DIRECTORY)) || entries.stream().allMatch(entry -> entry.getRawFileEntry().getKind() == FileKind.FILE); } @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2c-content-paste"); } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.COPY_PASTE; } @Override public KeyCombination getShortcut() { return new KeyCodeCombination(KeyCode.V, KeyCombination.SHORTCUT_DOWN); } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("paste"); } @Override public boolean acceptsEmptySelection() { return true; } @Override public boolean isActive(BrowserFileSystemTabModel model) { return BrowserClipboard.retrieveCopy() != null; } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/RefreshDirectoryMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.platform.LabelGraphic; import io.xpipe.app.util.BooleanScope; import io.xpipe.app.util.ThreadHelper; import javafx.beans.value.ObservableValue; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import java.util.List; public class RefreshDirectoryMenuProvider implements BrowserMenuLeafProvider { @Override public void execute(BrowserFileSystemTabModel model, List entries) { ThreadHelper.runAsync(() -> { BooleanScope.executeExclusive(model.getBusy(), () -> { model.refreshSync(); }); }); } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return false; } public String getId() { return "refresh"; } @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdmz-refresh"); } @Override public KeyCombination getShortcut() { return new KeyCodeCombination(KeyCode.F5); } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("refresh"); } @Override public boolean isActive(BrowserFileSystemTabModel model) { return !model.getInOverview().get(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/RenameMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.ext.FileKind; import io.xpipe.app.platform.LabelGraphic; import javafx.beans.value.ObservableValue; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import java.util.List; public class RenameMenuProvider implements BrowserMenuLeafProvider { @Override public void execute(BrowserFileSystemTabModel model, List entries) { model.getFileList().getEditing().setValue(entries.getFirst()); } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return entries.size() == 1 && entries.getFirst().getRawFileEntry().getKind() != FileKind.LINK; } @Override public boolean automaticallyResolveLinks() { return false; } @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2r-rename-box"); } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.MUTATION; } @Override public KeyCombination getShortcut() { return new KeyCodeCombination(KeyCode.R, KeyCombination.SHORTCUT_DOWN); } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("rename"); } @Override public String getId() { return "renameFile"; } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/RunFileMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.MultiExecuteMenuProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.ext.FileEntry; import io.xpipe.app.ext.FileKind; import io.xpipe.app.platform.LabelGraphic; import io.xpipe.app.process.CommandBuilder; import io.xpipe.app.process.ShellDialects; import io.xpipe.core.OsType; import javafx.beans.value.ObservableValue; import java.util.List; import java.util.stream.Stream; public class RunFileMenuProvider extends MultiExecuteMenuProvider { private boolean isExecutable(FileEntry e) { if (e.getKind() != FileKind.FILE) { return false; } var shell = e.getFileSystem().getShell(); if (shell.isEmpty()) { return false; } var os = shell.get().getOsType(); if (e.getInfo() != null && e.getInfo().possiblyExecutable() && os != OsType.WINDOWS) { return true; } if (os == OsType.WINDOWS && Stream.of("exe", "bat", "ps1", "cmd") .anyMatch(s -> e.getPath().toString().endsWith(s))) { return true; } if (ShellDialects.isPowershell(shell.get()) && Stream.of("ps1").anyMatch(s -> e.getPath().toString().endsWith(s))) { return true; } if (Stream.of("sh", "command").anyMatch(s -> e.getPath().toString().endsWith(s))) { return true; } return false; } @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2p-play"); } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.CUSTOM; } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("run"); } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return entries.stream().allMatch(entry -> isExecutable(entry.getRawFileEntry())); } @Override protected List createCommand(BrowserFileSystemTabModel model, List entries) { var sc = model.getFileSystem().getShell().orElseThrow(); return entries.stream() .map(browserEntry -> { return CommandBuilder.of() .add(sc.getShellDialect() .runScriptCommand( sc, browserEntry .getRawFileEntry() .getPath() .toString())); }) .toList(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/compress/BaseUntarMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl.compress; import io.xpipe.app.action.AbstractAction; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.icon.BrowserIconFileType; import io.xpipe.app.browser.icon.BrowserIcons; import io.xpipe.app.browser.menu.BrowserApplicationPathMenuProvider; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.platform.LabelGraphic; import io.xpipe.app.process.OsFileSystem; import io.xpipe.core.FilePath; import javafx.beans.value.ObservableValue; import java.util.List; public class BaseUntarMenuProvider implements BrowserApplicationPathMenuProvider, BrowserMenuLeafProvider { private final boolean gz; private final boolean toDirectory; public BaseUntarMenuProvider(boolean gz, boolean toDirectory) { this.gz = gz; this.toDirectory = toDirectory; } @Override public LabelGraphic getIcon() { return new LabelGraphic.CompGraphic(BrowserIcons.createContextMenuIcon(BrowserIconFileType.byId("zip"))); } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.CUSTOM; } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { var sep = OsFileSystem.of(model.getFileSystem().getShell().orElseThrow().getOsType()) .getFileSystemSeparator(); var dir = entries.size() > 1 ? "[...]" : getTarget(entries.getFirst().getRawFileEntry().getPath()).getFileName() + sep; return toDirectory ? AppI18n.observable("untarDirectory", dir) : AppI18n.observable("untarHere"); } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { if (!BrowserApplicationPathMenuProvider.super.isApplicable(model, entries) || !BrowserMenuLeafProvider.super.isApplicable(model, entries)) { return false; } if (gz) { return entries.stream() .allMatch(entry -> entry.getRawFileEntry() .getPath() .toString() .endsWith(".tar.gz") || entry.getRawFileEntry().getPath().toString().endsWith(".tgz")); } return entries.stream() .allMatch(entry -> entry.getRawFileEntry().getPath().toString().endsWith(".tar")); } @Override public String getExecutable() { return "tar"; } @Override public AbstractAction createAction(BrowserFileSystemTabModel model, List entries) { var builder = UntarActionProvider.Action.builder(); builder.initEntries(model, entries); builder.gz(gz); builder.toDirectory(toDirectory); return builder.build(); } @Override public boolean automaticallyResolveLinks() { return false; } private FilePath getTarget(FilePath name) { return FilePath.of(name.toString() .replaceAll("\\.tar$", "") .replaceAll("\\.tar.gz$", "") .replaceAll("\\.tgz$", "")); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/compress/BaseUnzipUnixMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl.compress; import io.xpipe.app.action.AbstractAction; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.icon.BrowserIconFileType; import io.xpipe.app.browser.icon.BrowserIcons; import io.xpipe.app.browser.menu.BrowserApplicationPathMenuProvider; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.platform.LabelGraphic; import io.xpipe.app.process.OsFileSystem; import io.xpipe.core.OsType; import javafx.beans.value.ObservableValue; import java.util.List; public abstract class BaseUnzipUnixMenuProvider implements BrowserMenuLeafProvider, BrowserApplicationPathMenuProvider { private final boolean toDirectory; public BaseUnzipUnixMenuProvider(boolean toDirectory) { this.toDirectory = toDirectory; } @Override public LabelGraphic getIcon() { return new LabelGraphic.CompGraphic(BrowserIcons.createContextMenuIcon(BrowserIconFileType.byId("zip"))); } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.CUSTOM; } @Override public boolean automaticallyResolveLinks() { return false; } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { var sep = OsFileSystem.of(model.getFileSystem().getShell().orElseThrow().getOsType()) .getFileSystemSeparator(); var dir = entries.size() > 1 ? "[...]" : UnzipActionProvider.getTarget( entries.getFirst().getRawFileEntry().getPath()) .getFileName() + sep; return toDirectory ? AppI18n.observable("unzipDirectory", dir) : AppI18n.observable("unzipHere"); } @Override public String getExecutable() { return "unzip"; } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { if (!BrowserApplicationPathMenuProvider.super.isApplicable(model, entries) || !BrowserMenuLeafProvider.super.isApplicable(model, entries)) { return false; } return entries.stream() .allMatch(entry -> entry.getRawFileEntry().getPath().toString().endsWith(".zip")) && model.getFileSystem().getShell().orElseThrow().getOsType() != OsType.WINDOWS; } @Override public AbstractAction createAction(BrowserFileSystemTabModel model, List entries) { var builder = UnzipActionProvider.Action.builder(); builder.initEntries(model, entries); builder.toDirectory(toDirectory); return builder.build(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/compress/BaseUnzipWindowsActionProvider.java ================================================ package io.xpipe.app.browser.menu.impl.compress; import io.xpipe.app.action.AbstractAction; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.icon.BrowserIconFileType; import io.xpipe.app.browser.icon.BrowserIcons; import io.xpipe.app.browser.menu.BrowserMenuCategory; import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.platform.LabelGraphic; import io.xpipe.app.process.OsFileSystem; import io.xpipe.core.OsType; import javafx.beans.value.ObservableValue; import java.util.List; public abstract class BaseUnzipWindowsActionProvider implements BrowserMenuLeafProvider { private final boolean toDirectory; public BaseUnzipWindowsActionProvider(boolean toDirectory) { this.toDirectory = toDirectory; } @Override public boolean automaticallyResolveLinks() { return false; } @Override public LabelGraphic getIcon() { return new LabelGraphic.CompGraphic(BrowserIcons.createContextMenuIcon(BrowserIconFileType.byId("zip"))); } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.CUSTOM; } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { var sep = OsFileSystem.of(model.getFileSystem().getShell().orElseThrow().getOsType()) .getFileSystemSeparator(); var dir = entries.size() > 1 ? "[...]" : UnzipActionProvider.getTarget( entries.getFirst().getRawFileEntry().getPath()) .getFileName() + sep; return toDirectory ? AppI18n.observable("unzipDirectory", dir) : AppI18n.observable("unzipHere"); } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { if (model.getFileSystem().getShell().isEmpty()) { return false; } return entries.stream() .allMatch(entry -> entry.getRawFileEntry().getPath().toString().endsWith(".zip")) && model.getFileSystem().getShell().orElseThrow().getOsType() == OsType.WINDOWS; } @Override public AbstractAction createAction(BrowserFileSystemTabModel model, List entries) { var builder = UnzipActionProvider.Action.builder(); builder.initEntries(model, entries); builder.toDirectory(toDirectory); return builder.build(); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/compress/CompressMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl.compress; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.menu.*; import io.xpipe.app.comp.RegionBuilder; import io.xpipe.app.comp.base.ModalOverlay; import io.xpipe.app.core.AppI18n; import io.xpipe.app.ext.FileKind; import io.xpipe.app.platform.LabelGraphic; import io.xpipe.core.OsType; import javafx.beans.property.SimpleStringProperty; import javafx.beans.value.ObservableValue; import javafx.scene.control.TextField; import lombok.SneakyThrows; import java.util.List; public class CompressMenuProvider implements BrowserMenuBranchProvider { @Override public void init(BrowserFileSystemTabModel model) throws Exception { if (model.getFileSystem().getShell().isEmpty()) { return; } var sc = model.getFileSystem().getShell().orElseThrow(); sc.view().isInPath("tar", true); sc.view().isInPath("zip", true); } @Override public LabelGraphic getIcon() { return new LabelGraphic.IconGraphic("mdi2a-archive"); } @Override public BrowserMenuCategory getCategory() { return BrowserMenuCategory.ACTION; } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable("compress"); } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { if (model.getFileSystem().getShell().isEmpty()) { return false; } var ext = List.of("zip", "tar", "tar.gz", "tgz", "rar", "xar"); if (entries.stream().anyMatch(browserEntry -> ext.stream().anyMatch(s -> browserEntry .getRawFileEntry() .getPath() .toString() .toLowerCase() .endsWith("." + s)))) { return false; } return true; } @Override public List getBranchingActions( BrowserFileSystemTabModel model, List entries) { var contentsOptions = entries.size() == 1 && entries.getFirst().getRawFileEntry().getKind() == FileKind.DIRECTORY; if (contentsOptions) { return List.of(new BranchProvider(false), new BranchProvider(true)); } return List.of( new ZipActionProvider(false), new TarBasedActionProvider(false, true) { @Override protected String getExtension() { return "tar.gz"; } }, new TarBasedActionProvider(false, false) { @Override protected String getExtension() { return "tar"; } }); } private abstract static class LeafProvider implements BrowserMenuLeafProvider { protected final boolean directory; private LeafProvider(boolean directory) { this.directory = directory; } @Override public boolean automaticallyResolveLinks() { return false; } @Override public void execute(BrowserFileSystemTabModel model, List entries) { var name = new SimpleStringProperty(directory ? entries.getFirst().getFileName() : null); var modal = ModalOverlay.of( "archiveName", RegionBuilder.of(() -> { var creationName = new TextField(); creationName.textProperty().bindBidirectional(name); return creationName; }) .prefWidth(350)); modal.withDefaultButtons(() -> { var fixedName = name.getValue(); if (fixedName == null) { return; } if (!fixedName.endsWith(getExtension())) { fixedName = fixedName + "." + getExtension(); } create(fixedName, model, entries); }); modal.show(); } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return new SimpleStringProperty("." + getExtension()); } protected abstract void create(String fileName, BrowserFileSystemTabModel model, List entries); protected abstract String getExtension(); } private class BranchProvider implements BrowserMenuBranchProvider { private final boolean directory; private BranchProvider(boolean directory) { this.directory = directory; } @Override public LabelGraphic getIcon() { return directory ? new LabelGraphic.IconGraphic("mdi2f-file-tree") : new LabelGraphic.IconGraphic("mdi2f-file-outline"); } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { return AppI18n.observable(directory ? "excludeRoot" : "includeRoot"); } @Override public List getBranchingActions( BrowserFileSystemTabModel model, List entries) { return List.of( new ZipActionProvider(directory), new TarBasedActionProvider(directory, true) { @Override protected String getExtension() { return "tar.gz"; } }, new TarBasedActionProvider(directory, false) { @Override protected String getExtension() { return "tar"; } }); } } private class ZipActionProvider extends LeafProvider { private ZipActionProvider(boolean directory) { super(directory); } @Override protected void create(String fileName, BrowserFileSystemTabModel model, List entries) { var builder = io.xpipe.app.browser.menu.impl.compress.ZipActionProvider.Action.builder(); builder.initEntries(model, entries); builder.target(model.getCurrentDirectory().getPath().join(fileName)); builder.directoryContentOnly(directory); builder.build().executeAsync(); } @Override protected String getExtension() { return "zip"; } } private abstract class TarBasedActionProvider extends LeafProvider { private final boolean gz; private TarBasedActionProvider(boolean directory, boolean gz) { super(directory); this.gz = gz; } @Override protected void create(String fileName, BrowserFileSystemTabModel model, List entries) { var builder = TarActionProvider.Action.builder(); builder.initEntries(model, entries); builder.target(model.getCurrentDirectory().getPath().join(fileName)); builder.directoryContentOnly(directory); builder.gz(gz); builder.build().executeAsync(); } @Override @SneakyThrows public boolean isActive(BrowserFileSystemTabModel model) { return model.getFileSystem().getShell().orElseThrow().view().isInPath("tar", true); } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return model.getFileSystem().getShell().orElseThrow().getOsType() != OsType.WINDOWS || !directory; } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/compress/TarActionProvider.java ================================================ package io.xpipe.app.browser.menu.impl.compress; import io.xpipe.app.browser.action.BrowserAction; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.process.CommandBuilder; import io.xpipe.core.FilePath; import lombok.NonNull; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; public class TarActionProvider implements BrowserActionProvider { @Override public String getId() { return "tar"; } @Jacksonized @SuperBuilder public static class Action extends BrowserAction { @NonNull private final FilePath target; private final boolean directoryContentOnly; private final boolean gz; @Override public void executeImpl() throws Exception { var sc = model.getFileSystem().getShell().orElseThrow(); var args = "c" + (gz ? "z" : "") + "f"; var tar = CommandBuilder.of().add("tar", args).addFile(target); var base = model.getCurrentDirectory().getPath(); if (directoryContentOnly) { var dir = getEntries().getFirst().getRawFileEntry().getPath(); // Fix for bsd find, remove / var command = CommandBuilder.of() .add("find") .addFile(dir.removeTrailingSlash().toUnix()) .add("|", "sed") .addLiteral("s,^" + dir.toDirectory().toUnix() + "*,,") .add("|"); command.add(tar).add("-C").addFile(dir.toDirectory().toUnix()).add("-T", "-"); sc.command(command).execute(); } else { var command = CommandBuilder.of().add(tar); for (BrowserEntry entry : getEntries()) { var rel = entry.getRawFileEntry().getPath().relativize(base); command.addFile(rel); } sc.command(command).execute(); } model.refreshSync(); } @Override public boolean isMutation() { return true; } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UntarActionProvider.java ================================================ package io.xpipe.app.browser.menu.impl.compress; import io.xpipe.app.browser.action.BrowserAction; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.process.CommandBuilder; import io.xpipe.app.process.ShellControl; import io.xpipe.core.FilePath; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; public class UntarActionProvider implements BrowserActionProvider { @Override public String getId() { return "untar"; } @Jacksonized @SuperBuilder public static class Action extends BrowserAction { private final boolean gz; private final boolean toDirectory; @Override public void executeImpl() throws Exception { ShellControl sc = model.getFileSystem().getShell().orElseThrow(); for (BrowserEntry entry : getEntries()) { var target = getTarget(entry.getRawFileEntry().getPath()); var c = CommandBuilder.of().add("tar"); var args = "x" + (gz ? "z" : "") + "f"; c.add(args); c.addFile(entry.getRawFileEntry().getPath()); if (toDirectory) { model.getFileSystem().mkdirs(target); } sc.command(c) .withWorkingDirectory( toDirectory ? target : model.getCurrentDirectory().getPath()) .execute(); } model.refreshSync(); } @Override public boolean isMutation() { return true; } private FilePath getTarget(FilePath name) { return FilePath.of(name.toString() .replaceAll("\\.tar$", "") .replaceAll("\\.tar.gz$", "") .replaceAll("\\.tgz$", "")); } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UntarDirectoryMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl.compress; public class UntarDirectoryMenuProvider extends BaseUntarMenuProvider { public UntarDirectoryMenuProvider() { super(false, true); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UntarGzDirectoryMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl.compress; public class UntarGzDirectoryMenuProvider extends BaseUntarMenuProvider { public UntarGzDirectoryMenuProvider() { super(true, true); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UntarGzHereMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl.compress; public class UntarGzHereMenuProvider extends BaseUntarMenuProvider { public UntarGzHereMenuProvider() { super(true, false); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UntarHereMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl.compress; public class UntarHereMenuProvider extends BaseUntarMenuProvider { public UntarHereMenuProvider() { super(false, false); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UnzipActionProvider.java ================================================ package io.xpipe.app.browser.menu.impl.compress; import io.xpipe.app.browser.action.BrowserAction; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.process.CommandBuilder; import io.xpipe.app.process.ShellControl; import io.xpipe.app.process.ShellDialects; import io.xpipe.core.FilePath; import io.xpipe.core.OsType; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; public class UnzipActionProvider implements BrowserActionProvider { public static FilePath getTarget(FilePath name) { return FilePath.of(name.toString().replaceAll("\\.zip$", "")); } @Override public String getId() { return "unzip"; } @Jacksonized @SuperBuilder public static class Action extends BrowserAction { private final boolean toDirectory; @Override public void executeImpl() throws Exception { var sc = model.getFileSystem().getShell().orElseThrow(); if (sc.getOsType() == OsType.WINDOWS) { if (ShellDialects.isPowershell(sc)) { for (BrowserEntry entry : getEntries()) { runPowershellCommand(sc, model, entry); } } else { try (var sub = sc.subShell(ShellDialects.POWERSHELL)) { for (BrowserEntry entry : getEntries()) { runPowershellCommand(sub, model, entry); } } } } else { for (BrowserEntry entry : getEntries()) { var command = CommandBuilder.of() .add("unzip", "-o") .addFile(entry.getRawFileEntry().getPath()); if (toDirectory) { command.add("-d") .addFile(getTarget(entry.getRawFileEntry().getPath())); } try (var cc = sc.command(command) .withWorkingDirectory(model.getCurrentDirectory().getPath()) .start()) { cc.discardOrThrow(); } } } model.refreshSync(); } @Override public boolean isMutation() { return true; } private void runPowershellCommand(ShellControl sc, BrowserFileSystemTabModel model, BrowserEntry entry) throws Exception { var command = CommandBuilder.of().add("Expand-Archive", "-Force"); if (toDirectory) { var target = getTarget(entry.getRawFileEntry().getPath()); command.add("-DestinationPath").addFile(target); } command.add("-Path").addFile(entry.getRawFileEntry().getPath()); sc.command(command) .withWorkingDirectory(model.getCurrentDirectory().getPath()) .execute(); } } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UnzipDirectoryUnixMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl.compress; public class UnzipDirectoryUnixMenuProvider extends BaseUnzipUnixMenuProvider { public UnzipDirectoryUnixMenuProvider() { super(true); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UnzipDirectoryWindowsActionProvider.java ================================================ package io.xpipe.app.browser.menu.impl.compress; public class UnzipDirectoryWindowsActionProvider extends BaseUnzipWindowsActionProvider { public UnzipDirectoryWindowsActionProvider() { super(true); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UnzipHereUnixMenuProvider.java ================================================ package io.xpipe.app.browser.menu.impl.compress; public class UnzipHereUnixMenuProvider extends BaseUnzipUnixMenuProvider { public UnzipHereUnixMenuProvider() { super(false); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UnzipHereWindowsActionProvider.java ================================================ package io.xpipe.app.browser.menu.impl.compress; public class UnzipHereWindowsActionProvider extends BaseUnzipWindowsActionProvider { public UnzipHereWindowsActionProvider() { super(false); } } ================================================ FILE: app/src/main/java/io/xpipe/app/browser/menu/impl/compress/ZipActionProvider.java ================================================ package io.xpipe.app.browser.menu.impl.compress; import io.xpipe.app.browser.action.BrowserAction; import io.xpipe.app.browser.action.BrowserActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.ext.FileKind; import io.xpipe.app.process.CommandBuilder; import io.xpipe.app.process.ShellDialects; import io.xpipe.core.FilePath; import io.xpipe.core.OsType; import lombok.NonNull; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; public class ZipActionProvider implements BrowserActionProvider { @Override public String getId() { return "zip"; } @Jacksonized @SuperBuilder public static class Action extends BrowserAction { @NonNull private final FilePath target; private final boolean directoryContentOnly; @Override public void executeImpl() throws Exception { try { var sc = model.getFileSystem().getShell().orElseThrow(); if (sc.getOsType() == OsType.WINDOWS) { var base = model.getCurrentDirectory().getPath(); var command = CommandBuilder.of() .add("Compress-Archive", "-Force", "-DestinationPath") .addFile(target) .add("-Path"); for (int i = 0; i < getEntries().size(); i++) { var rel = getEntries().get(i).getRawFileEntry().getPath().relativize(base); if (getEntries().get(i).getRawFileEntry().getKind() == FileKind.DIRECTORY && directoryContentOnly) { command.addQuoted(rel.toDirectory().toWindows() + "*"); } else { command.addFile(rel.toWindows()); } if (i != getEntries().size() - 1) { command.add(","); } } if (ShellDialects.isPowershell(sc)) { sc.command(command).withWorkingDirectory(base).execute(); } else { try (var sub = sc.subShell(ShellDialects.POWERSHELL)) { sub.command(command).withWorkingDirectory(base).execute(); } } } else { var command = CommandBuilder.of().add("zip", "-q", "-y", "-r", "-"); for (BrowserEntry entry : getEntries()) { var base = target.getParent(); var rel = entry.getRawFileEntry() .getPath() .relativize(base) .toUnix(); if (entry.getRawFileEntry().getKind() == FileKind.DIRECTORY && directoryContentOnly) { command.add("."); } else { command.addFile(rel); } } command.add(">").addFile(target); if (directoryContentOnly) { sc.command(command) .withWorkingDirectory(getEntries() .getFirst() .getRawFileEntry() .getPath()) .execute(); } else { sc.command(command).execute(); } } } finally { model.refreshSync(); } } @Override public boolean isMutation() { return true; } } } ================================================ FILE: app/src/main/java/io/xpipe/app/comp/BaseRegionBuilder.java ================================================ package io.xpipe.app.comp; import io.xpipe.app.platform.BindingsHelper; import io.xpipe.app.platform.PlatformThread; import javafx.beans.value.ObservableValue; import javafx.geometry.Insets; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.Region; import javafx.scene.layout.VBox; import org.int4.fx.builders.common.AbstractRegionBuilder; import java.util.function.Consumer; public abstract class BaseRegionBuilder> extends AbstractRegionBuilder { public BaseRegionBuilder() { apply(t -> { BindingsHelper.preserve(t, BaseRegionBuilder.this); }); } public B hgrow() { apply(t -> HBox.setHgrow(t, Priority.ALWAYS)); return self(); } public B vgrow() { apply(t -> VBox.setVgrow(t, Priority.ALWAYS)); return self(); } public B describe(Consumer c) { apply(r -> { var b = RegionDescriptor.builder(); c.accept(b); b.build().apply(r); }); return self(); } public B visible(ObservableValue o) { return apply(struc -> { var region = struc; BindingsHelper.preserve(region, o); o.subscribe(n -> { PlatformThread.runLaterIfNeeded(() -> { region.setVisible(n); }); }); }); } public B padding(Insets insets) { return apply(struc -> struc.setPadding(insets)); } public B disable(ObservableValue o) { return apply(struc -> { var region = struc; BindingsHelper.preserve(region, o); o.subscribe(n -> { PlatformThread.runLaterIfNeeded(() -> { region.setDisable(n); }); }); }); } public B show(ObservableValue when) { return this.hide(when.map((b) -> !b).orElse(true)); } public B hide(ObservableValue o) { return apply(struc -> { var region = struc; BindingsHelper.preserve(region, o); o.subscribe(n -> { PlatformThread.runLaterIfNeeded(() -> { if (!n) { region.setVisible(true); region.setManaged(true); } else { region.setVisible(false); region.setManaged(false); } }); }); }); } } ================================================ FILE: app/src/main/java/io/xpipe/app/comp/RegionBuilder.java ================================================ package io.xpipe.app.comp; import javafx.geometry.Orientation; import javafx.scene.control.Separator; import javafx.scene.layout.Region; import atlantafx.base.controls.Spacer; import java.util.function.Supplier; public abstract class RegionBuilder extends BaseRegionBuilder> { public static RegionBuilder empty() { return of(() -> { var r = new Region(); r.getStyleClass().add("empty"); return r; }); } public static RegionBuilder hspacer() { return of(() -> new Spacer(Orientation.HORIZONTAL)); } public static RegionBuilder hspacer(double size) { return of(() -> new Spacer(size)); } public static RegionBuilder vspacer() { return of(() -> new Spacer(Orientation.VERTICAL)); } public static RegionBuilder vspacer(double size) { return of(() -> new Spacer(size, Orientation.VERTICAL)); } public static RegionBuilder hseparator() { return of(() -> new Separator(Orientation.HORIZONTAL)); } public static RegionBuilder vseparator() { return of(() -> new Separator(Orientation.VERTICAL)); } public static RegionBuilder of(Supplier r) { return new RegionBuilder<>() { @Override protected R createSimple() { return r.get(); } }; } @Override public final T build() { var r = createSimple(); initialize(r); return r; } protected abstract T createSimple(); } ================================================ FILE: app/src/main/java/io/xpipe/app/comp/RegionDescriptor.java ================================================ package io.xpipe.app.comp; import io.xpipe.app.comp.base.TooltipHelper; import io.xpipe.app.core.AppI18n; import io.xpipe.app.platform.PlatformThread; import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.beans.property.ReadOnlyObjectWrapper; import javafx.beans.value.ObservableValue; import javafx.scene.control.Tooltip; import javafx.scene.input.KeyCombination; import javafx.scene.layout.Region; import lombok.Builder; import lombok.Value; @Value @Builder public class RegionDescriptor { ObservableValue name; ObservableValue description; KeyCombination shortcut; FocusTraversal focusTraversal; @Builder.Default boolean showTooltips = true; public enum FocusTraversal { DISABLED, ENABLED_FOR_ACCESSIBILITY, ENABLED } public void apply(Region r) { var accessibleText = getName() != null ? Bindings.createStringBinding( () -> { var s = getName().getValue() + "\n\n"; if (getShortcut() != null) { s += AppI18n.get("shortcut") + ": " + getShortcut().getDisplayText(); } return s; }, AppI18n.activeLanguage(), getName()) : null; if (showTooltips) { var tooltipText = Bindings.createStringBinding( () -> { var s = ""; if (getName() != null) { s += getName().getValue() + "\n\n"; } if (getDescription() != null) { var desc = getDescription().getValue(); if (desc != null) { s += desc + "\n\n"; } } if (getShortcut() != null) { s += AppI18n.get("shortcut") + ": " + getShortcut().getDisplayText(); } return s.strip(); }, AppI18n.activeLanguage(), getName() != null ? getName() : new ReadOnlyObjectWrapper<>(), getDescription() != null ? getDescription() : new ReadOnlyObjectWrapper<>()); var tt = TooltipHelper.create(PlatformThread.sync(tooltipText)); Tooltip.install(r, tt); } if (accessibleText != null) { r.accessibleTextProperty().bind(PlatformThread.sync(getName())); } if (getDescription() != null) { r.accessibleHelpProperty().bind(PlatformThread.sync(getDescription())); } if (getFocusTraversal() != null) { switch (getFocusTraversal()) { case DISABLED -> { r.setFocusTraversable(false); } case ENABLED_FOR_ACCESSIBILITY -> { r.focusTraversableProperty().bind(Platform.accessibilityActiveProperty()); } case ENABLED -> { r.setFocusTraversable(true); } } } } public static class RegionDescriptorBuilder { public RegionDescriptorBuilder nameKey(String key) { return name(AppI18n.observable(key)); } } } ================================================ FILE: app/src/main/java/io/xpipe/app/comp/RegionStructure.java ================================================ package io.xpipe.app.comp; import javafx.scene.layout.Region; public interface RegionStructure { R get(); } ================================================ FILE: app/src/main/java/io/xpipe/app/comp/RegionStructureBuilder.java ================================================ package io.xpipe.app.comp; import javafx.scene.layout.Region; import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; public abstract class RegionStructureBuilder> extends BaseRegionBuilder> { private final List> options = new ArrayList<>(); public final RegionStructureBuilder applyStructure(Consumer option) { options.add(option); return self(); } protected final void initializeStructure(S obj) { for (Consumer option : options) { option.accept(obj); } } @Override public final R build() { S struc = buildStructure(); return struc.get(); } public final S buildStructure() { S struc = createBase(); initializeStructure(struc); initialize(struc.get()); return struc; } protected abstract S createBase(); } ================================================ FILE: app/src/main/java/io/xpipe/app/comp/SimpleRegionBuilder.java ================================================ package io.xpipe.app.comp; import javafx.scene.layout.Region; public abstract class SimpleRegionBuilder extends RegionBuilder {} ================================================ FILE: app/src/main/java/io/xpipe/app/comp/augment/ContextMenuAugment.java ================================================ package io.xpipe.app.comp.augment; import javafx.event.ActionEvent; import javafx.geometry.Side; import javafx.scene.control.ButtonBase; import javafx.scene.control.ContextMenu; import javafx.scene.input.KeyEvent; import javafx.scene.input.MouseEvent; import javafx.scene.layout.Region; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Predicate; import java.util.function.Supplier; public class ContextMenuAugment implements Consumer { private final Predicate mouseEventCheck; private final Predicate keyEventCheck; private final Supplier contextMenu; public ContextMenuAugment( Predicate mouseEventCheck, Predicate keyEventCheck, Supplier contextMenu) { this.mouseEventCheck = mouseEventCheck; this.keyEventCheck = keyEventCheck; this.contextMenu = contextMenu; } @Override public void accept(S struc) { var currentContextMenu = new AtomicReference(); Supplier hide = () -> { if (currentContextMenu.get() != null && currentContextMenu.get().isShowing()) { currentContextMenu.get().hide(); currentContextMenu.set(null); return true; } return false; }; var r = struc; r.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> { if (mouseEventCheck != null && mouseEventCheck.test(event)) { if (!hide.get()) { var cm = contextMenu.get(); if (cm != null) { cm.show(r, event.getScreenX(), event.getScreenY()); currentContextMenu.set(cm); } } event.consume(); } }); r.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> { if (mouseEventCheck != null && mouseEventCheck.test(event)) { event.consume(); } }); r.addEventHandler(KeyEvent.KEY_RELEASED, event -> { if (keyEventCheck != null && keyEventCheck.test(event)) { event.consume(); } }); r.addEventHandler(KeyEvent.KEY_PRESSED, event -> { if (keyEventCheck != null && keyEventCheck.test(event)) { if (!hide.get()) { var cm = contextMenu.get(); if (cm != null) { cm.show(r, Side.BOTTOM, 0, 0); currentContextMenu.set(cm); } } event.consume(); } }); if (r instanceof ButtonBase buttonBase && keyEventCheck == null) { buttonBase.addEventHandler(ActionEvent.ACTION, event -> { if (buttonBase.getOnAction() != null) { return; } if (!hide.get()) { var cm = contextMenu.get(); if (cm != null) { cm.show(r, Side.TOP, 0, 0); currentContextMenu.set(cm); } } event.consume(); }); } } } ================================================ FILE: app/src/main/java/io/xpipe/app/comp/base/AnchorComp.java ================================================ package io.xpipe.app.comp.base; import io.xpipe.app.comp.BaseRegionBuilder; import io.xpipe.app.comp.RegionBuilder; import javafx.scene.layout.AnchorPane; import java.util.List; public class AnchorComp extends RegionBuilder { private final List> comps; public AnchorComp(List> comps) { this.comps = List.copyOf(comps); } @Override public AnchorPane createSimple() { var pane = new AnchorPane(); for (var c : comps) { pane.getChildren().add(c.build()); } return pane; } } ================================================ FILE: app/src/main/java/io/xpipe/app/comp/base/AppLayoutComp.java ================================================ package io.xpipe.app.comp.base; 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; import javafx.scene.Parent; import javafx.scene.control.ButtonBase; import javafx.scene.input.KeyCodeCombination; 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; import java.util.Map; import java.util.stream.Collectors; public class AppLayoutComp extends RegionStructureBuilder { @Override public Structure createBase() { var model = AppLayoutModel.get(); Map, ObservableValue> map = model.getEntries().stream() .filter(entry -> entry.comp() != null) .collect(Collectors.toMap( entry -> entry.comp(), entry -> Bindings.createBooleanBinding( () -> { return model.getSelected().getValue().equals(entry); }, model.getSelected()), (v1, v2) -> v2, LinkedHashMap::new)); var multi = new MultiContentComp(true, map, true); multi.style("background"); var pane = new BorderPane(); var sidebar = new SideMenuBarComp(model.getSelected(), model.getEntries(), model.getQueueEntries()); StackPane multiR = (StackPane) multi.build(); pane.setCenter(multiR); var sidebarR = sidebar.build(); pane.setRight(sidebarR); model.getSelected().addListener((c, o, n) -> { if (o != null && o.equals(model.getEntries().get(2))) { var prefs = AppPrefs.get(); if (prefs != null) { prefs.save(); } var storage = DataStorage.get(); if (storage != null) { storage.saveAsync(); } if (AppPrefs.get() != null && AppPrefs.get().getRequiresRestart().get()) { GlobalTimer.delay(() -> { var modal = ModalOverlay.of("prefsRestartTitle", AppDialog.dialogTextKey("prefsRestartContent")); modal.addButton(ModalButton.cancel()); modal.addButton(new ModalButton("restart", () -> AppRestart.restart(), true, true)); modal.show(); }, Duration.ofSeconds(1)); } } if (o != null && o.equals(model.getEntries().get(0))) { var svs = StoreViewState.get(); if (svs != null) { svs.triggerStoreListUpdate(); } } }); pane.addEventHandler(KeyEvent.KEY_PRESSED, event -> { sidebarR.getChildrenUnmodifiable().forEach(node -> { var shortcut = (KeyCodeCombination) node.getProperties().get("shortcut"); if (shortcut != null && shortcut.match(event)) { ((ButtonBase) ((Parent) node).getChildrenUnmodifiable().get(1)).fire(); event.consume(); } }); }); pane.getStyleClass().add("layout"); return new Structure(pane, multiR, sidebarR, new ArrayList<>(multiR.getChildren())); } public record Structure(BorderPane pane, StackPane stack, Region sidebar, List children) implements RegionStructure { public void prepareAddition() { stack.getChildren().clear(); sidebar.setDisable(true); } public void show() { stack.getChildren().add(children.getFirst()); for (int i = 1; i < children.size(); i++) { children.get(i).setVisible(false); children.get(i).setManaged(false); stack.getChildren().add(children.get(i)); } PlatformThread.runNestedLoopIteration(); sidebar.setDisable(false); stack.requestFocus(); } @Override public BorderPane get() { return pane; } } } ================================================ FILE: app/src/main/java/io/xpipe/app/comp/base/AppMainWindowContentComp.java ================================================ package io.xpipe.app.comp.base; import io.xpipe.app.comp.RegionBuilder; import io.xpipe.app.comp.SimpleRegionBuilder; import io.xpipe.app.core.*; import io.xpipe.app.core.window.AppDialog; import io.xpipe.app.core.window.AppMainWindow; import io.xpipe.app.issue.TrackEvent; import io.xpipe.app.platform.ColorHelper; import io.xpipe.app.platform.PlatformThread; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.util.GlobalTimer; import javafx.animation.*; import javafx.beans.binding.Bindings; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleIntegerProperty; import javafx.collections.ListChangeListener; import javafx.css.PseudoClass; import javafx.geometry.Pos; import javafx.scene.effect.DropShadow; import javafx.scene.image.ImageView; import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import javafx.scene.paint.Color; import javafx.stage.Stage; import javafx.stage.Window; import java.time.Duration; public class AppMainWindowContentComp extends SimpleRegionBuilder { private final Stage stage; public AppMainWindowContentComp(Stage stage) { this.stage = stage; } @Override public Region createSimple() { var overlay = AppDialog.getModalOverlays(); var loaded = AppMainWindow.getLoadedContent(); var sidebarPresent = new SimpleBooleanProperty(); var bg = RegionBuilder.of(() -> { var loadingIcon = new ImageView(); loadingIcon.setFitWidth(80); loadingIcon.setFitHeight(80); var dark = AppPrefs.get() != null && AppPrefs.get().theme().getValue().isDark(); loadingIcon.setOpacity(dark ? 0.95 : 0.93); var color = AppPrefs.get() != null ? ColorHelper.withOpacity( AppPrefs.get().theme().getValue().getEmphasisColor().get(), dark ? 0.7 : 0.85) : Color.TRANSPARENT; DropShadow shadow = new DropShadow(); shadow.setRadius(10); shadow.setColor(color); var loadingAnimation = new AnimationTimer() { long offset; @Override public void handle(long now) { // Increment offset as we are always having 60fps // Prevents animation jumps when the animation timer isn't called for a long time offset += 1000 / 60; // Move shadow in a circle var rad = -(offset % 1300.0) / 1300.0 * 2 * Math.PI; var x = Math.sin(rad); var y = Math.cos(rad); shadow.setOffsetX(x * 3); shadow.setOffsetY(y * 3); } }; loadingIcon.setEffect(shadow); loadingAnimation.start(); // This allows for assigning logos even if AppImages has not been initialized yet AppResources.with(AppResources.MAIN_MODULE, "", path -> { var image = AppPrefs.get() != null && AppPrefs.get().theme().getValue().isDark() ? path.resolve("loading-160-dark.png") : path.resolve("loading-160.png"); loadingIcon.setImage(AppImages.loadImage(image)); }); var version = new LabelComp( (AppNames.ofCurrent().getName()) + " " + AppProperties.get().getVersion()); version.apply(struc -> { AppFontSizes.apply(struc, appFontSizes -> "15"); struc.setOpacity(0.65); }); var loadingTextCounter = new SimpleIntegerProperty(); GlobalTimer.scheduleUntil(Duration.ofMillis(500), false, () -> { if (loaded.getValue() != null) { return true; } loadingTextCounter.set((loadingTextCounter.get() + 1) % 4); return false; }); var loadingTextAnimated = Bindings.createStringBinding( () -> { var base = AppMainWindow.getLoadingText().getValue(); if (base == null) { return null; } return base + " " + (".".repeat(loadingTextCounter.get())) + (" ".repeat(3 - loadingTextCounter.get())); }, AppMainWindow.getLoadingText(), loadingTextCounter); var text = new LabelComp(loadingTextAnimated); text.style("loading-text"); text.apply(struc -> { struc.setOpacity(0.8); }); var vbox = new VBox( RegionBuilder.vspacer().build(), loadingIcon, RegionBuilder.vspacer(19).build(), version.build(), RegionBuilder.vspacer().build(), text.build(), RegionBuilder.vspacer(20).build()); vbox.setAlignment(Pos.CENTER); var pane = new StackPane(vbox); pane.setAlignment(Pos.TOP_LEFT); pane.getStyleClass().add("background"); loaded.subscribe(struc -> { if (struc != null) { TrackEvent.info("Window content node set"); PlatformThread.runNestedLoopIteration(); struc.prepareAddition(); pane.getStyleClass().remove("background"); loadingAnimation.stop(); pane.getChildren().remove(vbox); pane.getChildren().add(struc.get()); sidebarPresent.set(true); PlatformThread.runNestedLoopIteration(); struc.show(); TrackEvent.info("Window content node shown"); } else if (!pane.getChildren().contains(vbox)) { loadingTextCounter.set(3); TrackEvent.info("Window content node removed"); PlatformThread.runNestedLoopIteration(); pane.getChildren().clear(); pane.getStyleClass().add("background"); pane.getChildren().add(vbox); sidebarPresent.set(false); loadingAnimation.start(); PlatformThread.runNestedLoopIteration(); } }); overlay.addListener((ListChangeListener) c -> { if (c.next() && c.wasAdded()) { AppMainWindow.get().focus(); // Close blocking modal windows var childWindows = Window.getWindows().stream() .filter(window -> window instanceof Stage s && stage.equals(s.getOwner())) .toList(); childWindows.forEach(window -> { ((Stage) window).close(); }); } }); return pane; }); var modal = new ModalOverlayStackComp(bg, overlay); var r = modal.build(); var p = r.lookupAll(".modal-overlay-stack-element"); sidebarPresent.subscribe(v -> { if (v) { p.forEach(node -> { node.pseudoClassStateChanged(PseudoClass.getPseudoClass("loaded"), true); }); } }); return r; } } ================================================ FILE: app/src/main/java/io/xpipe/app/comp/base/ButtonComp.java ================================================ package io.xpipe.app.comp.base; import io.xpipe.app.comp.RegionBuilder; import io.xpipe.app.platform.LabelGraphic; import io.xpipe.app.platform.PlatformThread; import javafx.beans.property.ReadOnlyObjectWrapper; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ObservableValue; import javafx.css.Size; import javafx.css.SizeUnits; import javafx.scene.Node; import javafx.scene.control.Button; import lombok.AllArgsConstructor; import lombok.Getter; import org.kordamp.ikonli.javafx.FontIcon; @Getter @AllArgsConstructor public class ButtonComp extends RegionBuilder