diff --git a/backend/test/backend/main.cpp b/backend/test/backend/main.cpp index 29b44869..3edce237 100644 --- a/backend/test/backend/main.cpp +++ b/backend/test/backend/main.cpp @@ -21,6 +21,8 @@ #include "test_pseudo_terminal.hpp" #include "test_ui_options.hpp" #include "test_legacy_migration.hpp" +#include "test_terminal_renderer_migration.hpp" +#include "test_session_layouts.hpp" #include "test_ssh_user_selection.hpp" #include "test_environment.hpp" #include "test_termios.hpp" diff --git a/backend/test/backend/test_session_layouts.hpp b/backend/test/backend/test_session_layouts.hpp new file mode 100644 index 00000000..8a88d597 --- /dev/null +++ b/backend/test/backend/test_session_layouts.hpp @@ -0,0 +1,111 @@ +#pragma once + +#include + +#include +#include + +#include +#include + +namespace Test +{ + namespace + { + Persistence::SessionOptions + serverWithLayouts(Persistence::TerminalEngineType type, std::map layouts) + { + auto session = Persistence::SessionOptions{}; + session.type = type; + session.layouts = std::move(layouts); + return session; + } + + nlohmann::json layoutWith(std::string const& widget) + { + return nlohmann::json{{"main", {{"widgets", {widget}}}}}; + } + } + + TEST(SessionLayoutsTests, LayoutsOfServersAreGatheredByType) + { + Persistence::State state{}; + state.sessions["server"] = + serverWithLayouts(Persistence::TerminalEngineType::ssh, {{"files", layoutWith("file-explorer")}}); + state.sessions["shell"] = + serverWithLayouts(Persistence::TerminalEngineType::shell, {{"wide", layoutWith("terminal")}}); + + EXPECT_TRUE(state.gatherSessionLayouts()); + + ASSERT_TRUE(state.sessionLayouts.has_value()); + EXPECT_EQ(state.sessionLayouts->ssh, (std::map{{"files", layoutWith("file-explorer")}})); + EXPECT_EQ(state.sessionLayouts->shell, (std::map{{"wide", layoutWith("terminal")}})); + } + + TEST(SessionLayoutsTests, SameNameAndLayoutIsMergedIntoOne) + { + Persistence::State state{}; + state.sessions["alpha"] = + serverWithLayouts(Persistence::TerminalEngineType::ssh, {{"files", layoutWith("file-explorer")}}); + state.sessions["beta"] = + serverWithLayouts(Persistence::TerminalEngineType::ssh, {{"files", layoutWith("file-explorer")}}); + + state.gatherSessionLayouts(); + + EXPECT_EQ(state.sessionLayouts->ssh.size(), 1u); + EXPECT_TRUE(state.sessionLayouts->ssh.contains("files")); + } + + TEST(SessionLayoutsTests, SameNameWithAnotherLayoutGetsTheServerName) + { + Persistence::State state{}; + state.sessions["alpha"] = + serverWithLayouts(Persistence::TerminalEngineType::ssh, {{"files", layoutWith("file-explorer")}}); + state.sessions["beta"] = serverWithLayouts(Persistence::TerminalEngineType::ssh, {{"files", layoutWith("terminal")}}); + + state.gatherSessionLayouts(); + + auto const& layouts = state.sessionLayouts->ssh; + ASSERT_EQ(layouts.size(), 2u); + EXPECT_EQ(layouts.at("files"), layoutWith("file-explorer")); + EXPECT_EQ(layouts.at("files (beta)"), layoutWith("terminal")); + } + + TEST(SessionLayoutsTests, LayoutsOfServersStayForEarlierVersions) + { + Persistence::State state{}; + state.sessions["server"] = + serverWithLayouts(Persistence::TerminalEngineType::ssh, {{"files", layoutWith("file-explorer")}}); + + state.gatherSessionLayouts(); + + ASSERT_TRUE(state.sessions["server"].layouts.has_value()); + EXPECT_EQ(state.sessions["server"].layouts->size(), 1u); + } + + TEST(SessionLayoutsTests, GatheringRunsOnlyOnce) + { + Persistence::State state{}; + state.sessions["server"] = + serverWithLayouts(Persistence::TerminalEngineType::ssh, {{"files", layoutWith("file-explorer")}}); + state.gatherSessionLayouts(); + state.sessionLayouts->ssh.erase("files"); + + EXPECT_FALSE(state.gatherSessionLayouts()); + EXPECT_TRUE(state.sessionLayouts->ssh.empty()); + } + + TEST(SessionLayoutsTests, GatheredLayoutsSurviveSaving) + { + Persistence::State state{}; + state.sessions["server"] = + serverWithLayouts(Persistence::TerminalEngineType::ssh, {{"files", layoutWith("file-explorer")}}); + state.gatherSessionLayouts(); + + auto reloaded = nlohmann::json(state).get(); + + ASSERT_TRUE(reloaded.sessionLayouts.has_value()); + EXPECT_EQ(reloaded.sessionLayouts->ssh, state.sessionLayouts->ssh); + EXPECT_FALSE(reloaded.gatherSessionLayouts()); + } +} diff --git a/backend/test/backend/test_terminal_renderer_migration.hpp b/backend/test/backend/test_terminal_renderer_migration.hpp new file mode 100644 index 00000000..d24eef53 --- /dev/null +++ b/backend/test/backend/test_terminal_renderer_migration.hpp @@ -0,0 +1,99 @@ +#pragma once + +#include + +#include +#include + +#include +#include +#include + +namespace Test +{ + namespace + { + Persistence::State stateWithRenderers(std::string const& profileRenderer, std::string const& sessionRenderer) + { + Persistence::State state{}; + state.terminalOptions["default"].renderer = profileRenderer; + state.terminalOptions["unset"] = Persistence::TerminalOptions{}; + state.sessions["server"].terminalOptions.value().renderer = sessionRenderer; + return state; + } + } + + TEST(TerminalRendererMigrationTests, MarksTheMigrationAsApplied) + { + auto state = stateWithRenderers("dom", "dom"); + + EXPECT_TRUE(state.migrateTerminalRendererFromDom()); + + ASSERT_TRUE(state.appliedMigrations.has_value()); + EXPECT_EQ(*state.appliedMigrations, std::vector{"terminalRendererFromDom"}); + } + +#ifdef __linux__ + TEST(TerminalRendererMigrationTests, MovesProfilesAndSessionsFromDomToWebgl) + { + auto state = stateWithRenderers("dom", "dom"); + + state.migrateTerminalRendererFromDom(); + + EXPECT_EQ(state.terminalOptions["default"].renderer, "webgl"); + EXPECT_FALSE(state.terminalOptions["unset"].renderer.has_value()); + EXPECT_EQ(state.sessions["server"].terminalOptions.value().renderer, "webgl"); + } +#else + TEST(TerminalRendererMigrationTests, KeepsDomWhereItWasNeverTheDefault) + { + auto state = stateWithRenderers("dom", "dom"); + + state.migrateTerminalRendererFromDom(); + + EXPECT_EQ(state.terminalOptions["default"].renderer, "dom"); + EXPECT_EQ(state.sessions["server"].terminalOptions.value().renderer, "dom"); + } +#endif + + TEST(TerminalRendererMigrationTests, DomChosenAfterTheMigrationIsKept) + { + auto state = stateWithRenderers("webgl", "webgl"); + state.migrateTerminalRendererFromDom(); + state.terminalOptions["default"].renderer = "dom"; + state.sessions["server"].terminalOptions.value().renderer = "dom"; + + EXPECT_FALSE(state.migrateTerminalRendererFromDom()); + + EXPECT_EQ(state.terminalOptions["default"].renderer, "dom"); + EXPECT_EQ(state.sessions["server"].terminalOptions.value().renderer, "dom"); + EXPECT_EQ(state.appliedMigrations->size(), 1u); + } + + TEST(TerminalRendererMigrationTests, AppliedMigrationsSurviveSaving) + { + auto state = stateWithRenderers("dom", "dom"); + state.migrateTerminalRendererFromDom(); + + auto reloaded = nlohmann::json(state).get(); + reloaded.terminalOptions["default"].renderer = "dom"; + + EXPECT_FALSE(reloaded.migrateTerminalRendererFromDom()); + EXPECT_EQ(reloaded.terminalOptions["default"].renderer, "dom"); + } + + TEST(TerminalRendererMigrationTests, MissingAppliedMigrationsIsNotReportedAsMissing) + { + auto state = nlohmann::json::object().get(); + + const auto missing = state.collectMissingMembers(state); + + EXPECT_TRUE(std::ranges::none_of( + missing, + [](std::string const& name) + { + return name.contains("appliedMigrations"); + } + )); + } +} diff --git a/dependencies/5cript-nui-components b/dependencies/5cript-nui-components index f1b5fa79..5a315bd8 160000 --- a/dependencies/5cript-nui-components +++ b/dependencies/5cript-nui-components @@ -1 +1 @@ -Subproject commit f1b5fa798d68268685142e7d16c28e63b6974ca6 +Subproject commit 5a315bd8e5e096e89e8e01ac5b77dc6b5cfb4da8 diff --git a/frontend/include/frontend/session_area.hpp b/frontend/include/frontend/session_area.hpp index e6cd2c12..182e19e9 100644 --- a/frontend/include/frontend/session_area.hpp +++ b/frontend/include/frontend/session_area.hpp @@ -54,11 +54,15 @@ class SessionArea void removeActiveSession(); Session* getActiveSession(); - std::optional getActiveSessionLayout(); + /** + * @brief Whether a session is selected, whose layout can be saved. + */ + Nui::Observed const& hasActiveSession() const; Session* getSessionByLayoutId(std::string const& layoutId); private: std::optional findSessionIndexByTabId(int tabId) const; + void updateHasActiveSession(); /** * @brief Reconnect entry point invoked from a Session's requestReconnect diff --git a/frontend/include/frontend/settings.hpp b/frontend/include/frontend/settings.hpp index 2e4bc200..84c83581 100644 --- a/frontend/include/frontend/settings.hpp +++ b/frontend/include/frontend/settings.hpp @@ -25,7 +25,7 @@ class Settings Settings( Persistence::StateHolder* stateHolder, FrontendEvents* events, - std::function()> const& obtainCurrentLayout, + std::function(Persistence::TerminalEngineType)> const& obtainCurrentLayout, InputDialog& inputDialog, ConfirmDialog& confirmDialog, MultiInputDialog& multiInputDialog, diff --git a/frontend/include/frontend/settings/general_settings.hpp b/frontend/include/frontend/settings/general_settings.hpp index dd0ab14c..386c77be 100644 --- a/frontend/include/frontend/settings/general_settings.hpp +++ b/frontend/include/frontend/settings/general_settings.hpp @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include #include @@ -40,6 +42,11 @@ struct GeneralSettings */ SettingFactory fileTrackingFactory_; + /** + * @brief Creates the identities of the layout settings. + */ + SettingFactory layoutsFactory_; + public: struct Collapsibles { @@ -48,6 +55,7 @@ struct GeneralSettings Nui::Observed userInterface{true}; Nui::Observed localFilesystemOptions{true}; Nui::Observed fileTrackingOptions{true}; + Nui::Observed layouts{true}; } collapsibleStates; struct Localization @@ -91,6 +99,12 @@ struct GeneralSettings BoolSetting<> checkRemoteChanges; } fileTrackingOptions; + struct Layouts + { + LayoutSetting ssh; + LayoutSetting shell; + } layouts; + LogOptions logOptions; Nui::ListenRemover availableThemesListener; bool darkLightEventOriginatesHere{false}; @@ -100,6 +114,8 @@ struct GeneralSettings SettingFactory const& factory, std::function const& onChange, FrontendEvents* events, + std::function(Persistence::TerminalEngineType)> const& obtainCurrentLayout, + ConfirmDialog& confirmDialog, InputDialog& inputDialog, MultiInputDialog& multiInputDialog ); diff --git a/frontend/include/frontend/settings/layout_setting.hpp b/frontend/include/frontend/settings/layout_setting.hpp index fab1ba80..0b3aa6ab 100644 --- a/frontend/include/frontend/settings/layout_setting.hpp +++ b/frontend/include/frontend/settings/layout_setting.hpp @@ -11,6 +11,7 @@ #include #include +#include class LayoutSetting : public Setting> { @@ -32,13 +33,7 @@ class LayoutSetting : public Settingfirst; - } - else - selected_ = ""; + refreshNames(); } OutfacingValueType value() const override { @@ -50,12 +45,22 @@ class LayoutSetting : public Settingvalue(*value); else + { state_ = ValueType{}; + refreshNames(); + } } + private: + /** + * @brief Lists the layout names for the dropdown and keeps the selection on an existing layout. + */ + void refreshNames(); + private: ConfirmDialog* confirmDialog_; InputDialog* newItemDialog_; Nui::Observed selected_; + Nui::Observed> names_; std::function()> obtainCurrentLayout_; }; \ No newline at end of file diff --git a/frontend/include/frontend/settings/session_options.hpp b/frontend/include/frontend/settings/session_options.hpp index 505e687a..82d98977 100644 --- a/frontend/include/frontend/settings/session_options.hpp +++ b/frontend/include/frontend/settings/session_options.hpp @@ -5,10 +5,8 @@ #include #include #include -#include #include #include -#include #include #include @@ -28,7 +26,6 @@ struct SessionOptions ComboSetting icon; TextSetting orderBy; BoolSetting<> isStartupSession; - LayoutSetting layout; TerminalOptions terminalOptions; TermiosSettings termios; @@ -46,7 +43,6 @@ struct SessionOptions SessionOptions( SettingFactory const& factory, std::function const& onChange, - std::function()> const& obtainCurrentLayout, ConfirmDialog& confirmDialog, InputDialog& newItemDialog, MultiInputDialog& multiInputDialog diff --git a/frontend/include/frontend/toolbar.hpp b/frontend/include/frontend/toolbar.hpp index da447a06..3b966d53 100644 --- a/frontend/include/frontend/toolbar.hpp +++ b/frontend/include/frontend/toolbar.hpp @@ -3,12 +3,18 @@ #include #include #include +#include #include #include +#include #include #include +#include +#include +#include + class SessionArea; class Settings; class NotificationCenter; @@ -21,6 +27,7 @@ class Toolbar FrontendEvents* events, ConfirmDialog* confirmDialog, DirectConnectDialog* directConnectDialog, + InputDialog* inputDialog, ThemeController& themeController ); ROAR_PIMPL_SPECIAL_FUNCTIONS(Toolbar); @@ -35,6 +42,34 @@ class Toolbar private: void connectLayoutsChanged(); void reloadLayouts(); + /** + * @brief Asks for a name and saves the layout of the active session for all sessions of its type. + */ + void saveLayout(); + /** + * @brief Stores a layout under a name in the layouts of a session type and saves the settings. + * + * @param type The session type that gets the layout. + * @param layoutName The name to store the layout under, replacing a layout of the same name. + * @param layout The layout to store. + */ + void storeLayout(Persistence::TerminalEngineType type, std::string const& layoutName, nlohmann::json const& layout); + /** + * @brief Asks for confirmation and removes the selected layout from the type of the selected server. + */ + void deleteLayout(); + /** + * @brief Changes the layouts of a session type in freshly loaded settings, saves them and reloads the dropdown. + * + * @param type The session type whose layouts change. + * @param modifier Changes the layouts of the type. + * @param selectAfterwards The layout to select once saved, if the selected server is still of that type. + */ + void modifyLayouts( + Persistence::TerminalEngineType type, + std::function&)> modifier, + std::string const& selectAfterwards + ); private: struct Implementation; diff --git a/frontend/source/frontend/main_page.cpp b/frontend/source/frontend/main_page.cpp index 1fe7e5d2..756aa666 100644 --- a/frontend/source/frontend/main_page.cpp +++ b/frontend/source/frontend/main_page.cpp @@ -83,13 +83,16 @@ struct MainPage::Implementation , multiInputDialog{"MultiInputDialog"} , sidebar{stateHolder, events} , notificationCenter{events} - , toolbar{stateHolder, events, &confirmDialog, &directConnectDialog, themeController} + , toolbar{stateHolder, events, &confirmDialog, &directConnectDialog, &newItemAskDialog, themeController} , commandStoreClient{} , snippetImportDialog{"SnippetImportDialog", &commandStoreClient} , snippetPresetsDialog{"SnippetPresetsDialog", &commandStoreClient, &snippetImportDialog} , sessionArea{stateHolder, events, &newItemAskDialog, &confirmDialog, &filePropertyDialog, &archiveTransferDialog, &snippetImportDialog, &snippetPresetsDialog, &toolbar, &commandStoreClient} - , settings{stateHolder, events, [this](){ - return sessionArea.getActiveSessionLayout(); + , settings{stateHolder, events, [this](Persistence::TerminalEngineType type) -> std::optional { + Session* session = sessionArea.getActiveSession(); + if (!session || session->engineOptions().type != type) + return std::nullopt; + return session->getLayout(); }, newItemAskDialog, confirmDialog, multiInputDialog, newSessionDialog} , licenses{events} , onboarding{stateHolder, events} diff --git a/frontend/source/frontend/session_area.cpp b/frontend/source/frontend/session_area.cpp index 747a0081..f8cc116f 100644 --- a/frontend/source/frontend/session_area.cpp +++ b/frontend/source/frontend/session_area.cpp @@ -70,6 +70,7 @@ struct SessionArea::Implementation Nui::Observed>> sessions; Nui::RpcClient::AutoUnregister dropHandlerUnregister_; ScriptNuiComponents::Tabs tabs; + Nui::Observed hasActiveSession{false}; Nui::ListenRemover newSessionListener{}; /// Active reconnect cycles, keyed by outer tabId. Each cycle preserves @@ -265,6 +266,7 @@ void SessionArea::removeSession(int tabId) { impl_->sessions.erase(impl_->sessions.begin() + index); impl_->tabs.remove(tabId); + updateHasActiveSession(); Nui::globalEventContext.executeActiveEventsImmediately(); } ); @@ -293,6 +295,19 @@ void SessionArea::setSelected(int tabId) } Log::info("Selected session: {}", *newIndex); impl_->sessions.value()[*newIndex]->visible(true); + updateHasActiveSession(); +} + +Nui::Observed const& SessionArea::hasActiveSession() const +{ + return impl_->hasActiveSession; +} + +void SessionArea::updateHasActiveSession() +{ + const bool hasSession = getActiveSession() != nullptr; + if (impl_->hasActiveSession.value() != hasSession) + impl_->hasActiveSession = hasSession; } std::optional SessionArea::findSessionIndexByTabId(int tabId) const @@ -837,14 +852,6 @@ void SessionArea::cancelReconnectTimers(int tabId) } } -std::optional SessionArea::getActiveSessionLayout() -{ - Session* activeSession = getActiveSession(); - if (activeSession) - return activeSession->getLayout(); - return std::nullopt; -} - Session* SessionArea::getActiveSession() { const auto selected = impl_->tabs.selectedId(); diff --git a/frontend/source/frontend/session_components/session_layout_initializer.cpp b/frontend/source/frontend/session_components/session_layout_initializer.cpp index 540568f8..0086f377 100644 --- a/frontend/source/frontend/session_components/session_layout_initializer.cpp +++ b/frontend/source/frontend/session_components/session_layout_initializer.cpp @@ -369,10 +369,11 @@ void SessionLayoutInitializer::initialize() if (!layout && impl_->layoutName != Constants::defaultLayoutName) { - if (impl_->engineOptions->layouts && impl_->layoutName) + auto const& sessionLayouts = impl_->stateHolder->stateCache().sessionLayouts; + if (sessionLayouts && impl_->layoutName) { - if (auto iter = impl_->engineOptions->layouts->find(*impl_->layoutName); - iter != impl_->engineOptions->layouts->end()) + auto const& layouts = sessionLayouts->of(impl_->engineOptions->type); + if (auto iter = layouts.find(*impl_->layoutName); iter != layouts.end()) { layout = iter->second; } diff --git a/frontend/source/frontend/session_components/session_options.cpp b/frontend/source/frontend/session_components/session_options.cpp index c8ac7813..3c9b0960 100644 --- a/frontend/source/frontend/session_components/session_options.cpp +++ b/frontend/source/frontend/session_components/session_options.cpp @@ -50,9 +50,9 @@ void SessionOptions::loadLayoutNames() impl_->layoutNames.value().clear(); if (const auto iter = state.sessions.find(*impl_->persistenceSessionName); - iter != end(state.sessions) && iter->second.layouts) + iter != end(state.sessions) && state.sessionLayouts) { - for (const auto& [name, session] : *iter->second.layouts) + for (const auto& [name, layout] : state.sessionLayouts->of(iter->second.type)) { impl_->layoutNames.value().push_back(name); } diff --git a/frontend/source/frontend/settings.cpp b/frontend/source/frontend/settings.cpp index 2b0b5211..2616e0c9 100644 --- a/frontend/source/frontend/settings.cpp +++ b/frontend/source/frontend/settings.cpp @@ -193,7 +193,7 @@ struct Settings::Implementation Implementation( Persistence::StateHolder* stateHolder, FrontendEvents* events, - std::function()> const& obtainCurrentLayout, + std::function(Persistence::TerminalEngineType)> const& obtainCurrentLayout, InputDialog& inputDialog, ConfirmDialog& confirmDialog, MultiInputDialog& multiInputDialog, @@ -207,7 +207,15 @@ struct Settings::Implementation , confirmDialog{&confirmDialog} , multiInputDialog{&multiInputDialog} , newSessionDialog{&newSessionDialog} - , generalSettings{SettingFactory{&searchIndex, SettingScope::General}, onChange, events, inputDialog, multiInputDialog} + , generalSettings{ + SettingFactory{&searchIndex, SettingScope::General}, + onChange, + events, + obtainCurrentLayout, + confirmDialog, + inputDialog, + multiInputDialog + } , termiosSettings{inheritable().within({"termiosGroupName"}), [onChange, reloadInheritance]() { onChange(); @@ -241,7 +249,6 @@ struct Settings::Implementation , currentSessionOptions{ SettingFactory{&searchIndex, SettingScope::Session}, onChange, - obtainCurrentLayout, confirmDialog, inputDialog, multiInputDialog @@ -257,7 +264,7 @@ struct Settings::Implementation Settings::Settings( Persistence::StateHolder* stateHolder, FrontendEvents* events, - std::function()> const& obtainCurrentLayout, + std::function(Persistence::TerminalEngineType)> const& obtainCurrentLayout, InputDialog& inputDialog, ConfirmDialog& confirmDialog, MultiInputDialog& multiInputDialog, @@ -1717,8 +1724,7 @@ Nui::ElementRenderer Settings::currentSession() ) ), impl_->currentSessionOptions.orderBy(), - impl_->currentSessionOptions.isStartupSession(), - impl_->currentSessionOptions.layout() + impl_->currentSessionOptions.isStartupSession() ); return fragment( diff --git a/frontend/source/frontend/settings/general_settings.cpp b/frontend/source/frontend/settings/general_settings.cpp index eda05310..b43d2a04 100644 --- a/frontend/source/frontend/settings/general_settings.cpp +++ b/frontend/source/frontend/settings/general_settings.cpp @@ -8,6 +8,8 @@ GeneralSettings::GeneralSettings( SettingFactory const& factory, std::function const& onChange, FrontendEvents* events, + std::function(Persistence::TerminalEngineType)> const& obtainCurrentLayout, + ConfirmDialog& confirmDialog, InputDialog& inputDialog, MultiInputDialog& multiInputDialog ) @@ -15,6 +17,7 @@ GeneralSettings::GeneralSettings( , userInterfaceFactory_{factory.within({"userInterfaceGroupHeader"})} , localFilesystemFactory_{factory.within({"localFilesystemOptionsGroupHeader"})} , fileTrackingFactory_{factory.within({"fileTrackingOptionsGroupHeader"})} + , layoutsFactory_{factory.within({"layoutsGroupHeader"})} , localization{ .language = { {"en_US", "de_DE"}, @@ -303,6 +306,26 @@ GeneralSettings::GeneralSettings( }, }, } + , layouts{ + .ssh = LayoutSetting{ + layoutsFactory_.identity({"general", "layouts", "ssh"}), + onChange, + [obtainCurrentLayout]() { + return obtainCurrentLayout(Persistence::TerminalEngineType::ssh); + }, + confirmDialog, + inputDialog, + }, + .shell = LayoutSetting{ + layoutsFactory_.identity({"general", "layouts", "shell"}), + onChange, + [obtainCurrentLayout]() { + return obtainCurrentLayout(Persistence::TerminalEngineType::shell); + }, + confirmDialog, + inputDialog, + }, + } , logOptions(factory.within({"loggingAndErrorReportingGroupHeader"}), onChange) , availableThemesListener{ Nui::smartListen( @@ -376,6 +399,11 @@ void GeneralSettings::applyToState(Persistence::State& state) const state.localFilesystemOptions.openRiskyFiles = localFilesystemOptions.openRiskyFiles.value(); logOptions.applyToState(state.logOptions); + + if (!state.sessionLayouts) + state.sessionLayouts.emplace(); + state.sessionLayouts->ssh = layouts.ssh.value(); + state.sessionLayouts->shell = layouts.shell.value(); } void GeneralSettings::loadFromState(Persistence::State const& state) { @@ -414,6 +442,10 @@ void GeneralSettings::loadFromState(Persistence::State const& state) ); logOptions.loadFromState(state.logOptions); + + const auto sessionLayouts = state.sessionLayouts.value_or(Persistence::SessionLayouts{}); + layouts.ssh.value(sessionLayouts.ssh); + layouts.shell.value(sessionLayouts.shell); } void GeneralSettings::assumeDefaultsFrom(Persistence::State const&) { @@ -477,6 +509,11 @@ Nui::ElementRenderer GeneralSettings::render( fileTrackingOptions.deleteRemoteOnLocalDelete(), fileTrackingOptions.checkRemoteChanges() ); + + auto layoutsUi = fragment( + layouts.ssh(), + layouts.shell() + ); // clang-format on // clang-format off @@ -520,6 +557,14 @@ Nui::ElementRenderer GeneralSettings::render( .addGroup = addGroup, .removeGroup = removeGroup, .onChangeGroup = onChange, + }), + group({ + .isCollapsed = collapsibleStates.layouts, + .content = std::move(layoutsUi), + .headerTitle = language->getObserved("settings", "layoutsGroupHeader"), + .addGroup = addGroup, + .removeGroup = removeGroup, + .onChangeGroup = onChange, }) ); // clang-format on diff --git a/frontend/source/frontend/settings/layout_setting.cpp b/frontend/source/frontend/settings/layout_setting.cpp index b7f18c65..bf830bc3 100644 --- a/frontend/source/frontend/settings/layout_setting.cpp +++ b/frontend/source/frontend/settings/layout_setting.cpp @@ -20,9 +20,21 @@ LayoutSetting::LayoutSetting( , confirmDialog_{&confirmDialog} , newItemDialog_{&newItemDialog} , selected_{} + , names_{} , obtainCurrentLayout_{std::move(obtainCurrentLayout)} {} +void LayoutSetting::refreshNames() +{ + names_.value().clear(); + for (auto const& [name, layout] : state_.value()) + names_.value().push_back(name); + if (!state_.value().contains(selected_.value())) + selected_.value() = names_.value().empty() ? std::string{} : names_.value().front(); + names_.modifyNow(); + selected_.modifyNow(); +} + Nui::ElementRenderer LayoutSetting::renderRow(LanguageObservedText labelText) { namespace Snc = ScriptNuiComponents; @@ -38,12 +50,12 @@ Nui::ElementRenderer LayoutSetting::renderRow(LanguageObservedText labelText) style = "color: var(--color); margin-right: 10px" }(std::move(labelText)), div{class_ = "layout-setting-controls"}( - Snc::select(Snc::SelectOptions{ + Snc::select(Snc::SelectOptions{ .activeOption = selected_, - .options = state_, + .options = names_, .attributes { - disabled = observe(state_).generate([](auto const& state) { - return state.empty(); + disabled = observe(names_).generate([](auto const& names) { + return names.empty(); }), }, .onChange = [this](auto const&, auto const& /*event*/) @@ -83,8 +95,9 @@ Nui::ElementRenderer LayoutSetting::renderRow(LanguageObservedText labelText) if (auto currentLayout = obtainCurrentLayout_()) { currentKeys[*newKey] = *currentLayout; - selected_ = *newKey; + selected_.value() = *newKey; state_.modifyNow(); + refreshNames(); onChange_(); } else @@ -134,6 +147,7 @@ Nui::ElementRenderer LayoutSetting::renderRow(LanguageObservedText labelText) state_.value().erase(keyToRemove); state_.modifyNow(); + refreshNames(); onChange_(); }, }); diff --git a/frontend/source/frontend/settings/session_options.cpp b/frontend/source/frontend/settings/session_options.cpp index 311a572c..8e4d0e9c 100644 --- a/frontend/source/frontend/settings/session_options.cpp +++ b/frontend/source/frontend/settings/session_options.cpp @@ -13,7 +13,6 @@ using namespace std::string_literals; SessionOptions::SessionOptions( SettingFactory const& factory, std::function const& onChange, - std::function()> const& obtainCurrentLayout, ConfirmDialog& confirmDialog, InputDialog& newItemDialog, MultiInputDialog& multiInputDialog @@ -69,15 +68,6 @@ SessionOptions::SessionOptions( onChange, valueReset(isStartupSession, onChange, Persistence::SessionOptions{}.startupSession) } - , layout{ - basicSettingsFactory_.identity( - {"sessionOptions", "layout"}, {.labelKey = SettingKeyPath{"layoutSetting", "layoutKeysLabel"}} - ), - onChange, - obtainCurrentLayout, - confirmDialog, - newItemDialog - } , terminalOptions{factory.within({"terminalOptionsGroupName"}), onChange} , termios{factory.within({"termiosGroupName"}), onChange} , queueOptions{factory.within({"queueOptionsGroupName"}), onChange} @@ -104,7 +94,6 @@ void SessionOptions::applyToState(Persistence::SessionOptions& state) const assignIfValid(state.icon, icon); assignIfValid(state.orderBy, orderBy); assignIfValid(state.startupSession, isStartupSession); - state.layouts = layout.value(); terminalOptions.applyToState(state.terminalOptions.value()); termios.applyToState(state.termios.value()); queueOptions.applyToState(state.queueOptions.value()); @@ -140,7 +129,6 @@ void SessionOptions::loadFromState(Persistence::SessionOptions const& state, boo icon.value(state.icon); orderBy.value(state.orderBy); isStartupSession.value(state.startupSession); - layout.value(state.layouts); terminalOptions.loadFromState(state.terminalOptions.value()); termios.loadFromState(state.termios.value()); queueOptions.loadFromState(state.queueOptions.value()); diff --git a/frontend/source/frontend/terminal/terminal_channel.cpp b/frontend/source/frontend/terminal/terminal_channel.cpp index 4ae59924..907cf178 100644 --- a/frontend/source/frontend/terminal/terminal_channel.cpp +++ b/frontend/source/frontend/terminal/terminal_channel.cpp @@ -27,17 +27,24 @@ globalThis.terminalUtility.stringToUint8Array = (str) => { return new TextEncoder().encode(str); }; globalThis.terminalUtility.terminals = new Map(); -globalThis.terminalUtility.createTerminal = (host, options) => { - - let renderer = undefined; - if (options.hasOwnProperty("renderer")) { - if (options.renderer === "dom") { - renderer = undefined; - } else if (options.renderer === "webgl") { - renderer = new WebglAddon(); - } +// The WebGL addon activates on open and throws there without WebGL2, so it is loaded after opening. +// Without it, or once its context is lost, xterm renders with the DOM renderer. +const loadWebglRenderer = (terminal, addons) => { + const webgl = new WebglAddon(); + try { + terminal.loadAddon(webgl); + } catch (error) { + webgl.dispose(); + return; } + webgl.onContextLoss(() => { + webgl.dispose(); + addons.rendererAddon = undefined; + }); + addons.rendererAddon = webgl; +}; +globalThis.terminalUtility.createTerminal = (host, options) => { const defaultedOptions = { cursorBlink: true, fontSize: 14, @@ -54,7 +61,7 @@ globalThis.terminalUtility.createTerminal = (host, options) => { const terminal = new Terminal(defaultedOptions); const addons = { fitAddon: new FitAddon(), - rendererAddon: renderer, + rendererAddon: undefined, serializeAddon: new SerializeAddon() }; for (const [key, value] of Object.entries(addons)) @@ -89,6 +96,8 @@ globalThis.terminalUtility.createTerminal = (host, options) => { const openAndObserve = () => { terminal.open(host); + if (options.renderer === "webgl") + loadWebglRenderer(terminal, addons); resizeObserver.observe(host); terminal.focus(); }; diff --git a/frontend/source/frontend/toolbar.cpp b/frontend/source/frontend/toolbar.cpp index fc61149d..a7bb6820 100644 --- a/frontend/source/frontend/toolbar.cpp +++ b/frontend/source/frontend/toolbar.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -21,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -28,6 +30,7 @@ #include #include #include +#include #include #include @@ -44,6 +47,7 @@ struct Toolbar::Implementation NotificationCenter* notificationCenter{nullptr}; ConfirmDialog* confirmDialog; DirectConnectDialog* directConnectDialog; + InputDialog* inputDialog; ThemeController* themeController; Nui::Observed activeTerminalEngine{}; Nui::Observed selectedLayout{}; @@ -62,12 +66,14 @@ struct Toolbar::Implementation FrontendEvents* events, ConfirmDialog* confirmDialog, DirectConnectDialog* directConnectDialog, + InputDialog* inputDialog, ThemeController& themeController ) : stateHolder{stateHolder} , events{events} , confirmDialog{confirmDialog} , directConnectDialog{directConnectDialog} + , inputDialog{inputDialog} , themeController{&themeController} , terminalEngines{} , layouts{} @@ -84,6 +90,22 @@ struct Toolbar::Implementation * @brief Names the users of the selected server in the current language, keeping the picked one. */ void loadUserLabels(); + /** + * @brief Names a layout in the dropdown, the default layout in the current language. + * + * @param name The layout name as stored, Constants::defaultLayoutName for the default layout. + */ + std::string layoutLabel(std::string const& name) const; + /** + * @brief The type of the server selected in the toolbar, nothing if it is not in the settings. + */ + std::optional selectedServerType() const; + /** + * @brief Tells the user that the settings could not be saved. + * + * @param error The error the state holder reported. + */ + void showSaveError(std::string const& error); Nui::ElementRenderer notificationsButton(); Nui::ElementRenderer userSelect(); }; @@ -135,9 +157,17 @@ Toolbar::Toolbar( FrontendEvents* events, ConfirmDialog* confirmDialog, DirectConnectDialog* directConnectDialog, + InputDialog* inputDialog, ThemeController& themeController ) - : impl_(std::make_unique(stateHolder, events, confirmDialog, directConnectDialog, themeController)) + : impl_(std::make_unique( + stateHolder, + events, + confirmDialog, + directConnectDialog, + inputDialog, + themeController + )) { Log::info("Toolbar::Toolbar"); impl_->updateSessionsList( @@ -151,6 +181,8 @@ Toolbar::Toolbar( [this](std::string const&) { impl_->loadUserLabels(); + impl_->layouts.modifyNow(); + impl_->selectedLayout.modifyNow(); } ); @@ -214,6 +246,34 @@ void Toolbar::Implementation::loadUserLabels() userIndices = std::move(indices); } +std::optional Toolbar::Implementation::selectedServerType() const +{ + auto const& sessions = stateHolder->stateCache().sessions; + const auto iter = sessions.find(activeTerminalEngine.value()); + if (iter == sessions.end()) + return std::nullopt; + return iter->second.type; +} + +std::string Toolbar::Implementation::layoutLabel(std::string const& name) const +{ + if (name == Constants::defaultLayoutName) + return language->get("toolbar", "defaultLayout"); + return name; +} + +void Toolbar::Implementation::showSaveError(std::string const& error) +{ + confirmDialog->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Danger, + .headerText = language->get("toolbar", "errorSavingSettingsHeader"), + .text = fmt::format( + fmt::runtime(language->get("toolbar", "errorSavingSettings") + ": {}"), language->translate(error) + ), + .buttons = ConfirmDialog::Button::Ok, + }); +} + Nui::ElementRenderer Toolbar::Implementation::userSelect() { using namespace Nui::Attributes; @@ -230,6 +290,7 @@ Nui::ElementRenderer Toolbar::Implementation::userSelect() return indices.size() < 2u ? "display: none;" : ""; }), }, + .label = language->get("toolbar", "userLabel"), .activeRenderer = [this](auto const&) -> Nui::ElementRenderer { return span{}(Nui::observe(selectedUser, userIndices), [this]() -> std::string { if (selectedUser.value() >= userLabels.size()) @@ -253,23 +314,154 @@ void Toolbar::reloadLayouts() { impl_->reloadUsers(); - impl_->selectedLayout = ""; impl_->layouts.value().clear(); impl_->layouts.value().push_back(std::string{Constants::defaultLayoutName}); - if (const auto iter = impl_->stateHolder->stateCache().sessions.find(impl_->activeTerminalEngine.value()); - iter != impl_->stateHolder->stateCache().sessions.end()) + auto const& sessionLayouts = impl_->stateHolder->stateCache().sessionLayouts; + if (const auto type = impl_->selectedServerType(); type && sessionLayouts) + { + for (auto const& [name, layout] : sessionLayouts->of(*type)) + impl_->layouts.value().push_back(name); + } + impl_->selectedLayout = std::string{Constants::defaultLayoutName}; + impl_->layouts.modifyNow(); +} + +void Toolbar::saveLayout() +{ + Session* session = impl_->sessionArea ? impl_->sessionArea->getActiveSession() : nullptr; + auto layout = session ? session->getLayout() : std::nullopt; + if (!layout) { - if (iter->second.layouts) + impl_->confirmDialog->open({ + .headerText = language->get("settings", "layoutSetting", "saveLayoutErrorHeader"), + .text = language->get("settings", "layoutSetting", "saveLayoutErrorText"), + .buttons = ConfirmDialog::Button::Ok, + }); + return; + } + + const auto type = session->engineOptions().type; + const auto& selected = impl_->selectedLayout.value(); + impl_->inputDialog->open({ + .whatFor = language->get( + "toolbar", type == Persistence::TerminalEngineType::shell ? "saveLayoutForShell" : "saveLayoutForSsh" + ), + .headerText = language->get("settings", "layoutSetting", "addLayoutKeyHeader"), + .initialValue = selected == Constants::defaultLayoutName ? std::string{} : selected, + .onConfirm = + [this, type, layout = std::move(*layout)](std::optional const& layoutName) { - for (auto const& [name, layout] : iter->second.layouts.value()) - impl_->layouts.value().push_back(name); + if (!layoutName || layoutName->empty()) + return; - if (!impl_->layouts.value().empty()) - impl_->selectedLayout = impl_->layouts.value().front(); + if (*layoutName == Constants::defaultLayoutName) + { + impl_->confirmDialog->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Danger, + .headerText = language->get("settings", "layoutSetting", "saveLayoutErrorHeader"), + .text = language->get("toolbar", "layoutNameReserved"), + .buttons = ConfirmDialog::Button::Ok, + }); + return; + } - impl_->layouts.modifyNow(); + auto const& sessionLayouts = impl_->stateHolder->stateCache().sessionLayouts; + if (!sessionLayouts || !sessionLayouts->of(type).contains(*layoutName)) + { + storeLayout(type, *layoutName, layout); + return; + } + + impl_->confirmDialog->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Warning, + .headerText = language->get("toolbar", "overwriteLayoutHeader"), + .text = fmt::format(fmt::runtime(language->get("toolbar", "overwriteLayout") + ": {}"), *layoutName), + .buttons = ConfirmDialog::Button::Ok | ConfirmDialog::Button::Cancel, + .onClose = + [this, type, layoutName = *layoutName, layout](std::optional button) + { + if (button == ConfirmDialog::Button::Ok) + storeLayout(type, layoutName, layout); + }, + }); + }, + }); +} + +void Toolbar::storeLayout( + Persistence::TerminalEngineType type, + std::string const& layoutName, + nlohmann::json const& layout +) +{ + modifyLayouts( + type, + [layoutName, layout](std::map& layouts) + { + layouts[layoutName] = layout; + }, + layoutName + ); +} + +void Toolbar::deleteLayout() +{ + const auto type = impl_->selectedServerType(); + const auto layoutName = impl_->selectedLayout.value(); + if (!type || layoutName == Constants::defaultLayoutName) + return; + + impl_->confirmDialog->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Warning, + .headerText = language->get("settings", "layoutSetting", "removeLayoutKeyConfirmHeader"), + .text = fmt::format( + fmt::runtime(language->get("settings", "layoutSetting", "removeLayoutKeyConfirmText") + ": {}"), layoutName + ), + .buttons = ConfirmDialog::Button::Ok | ConfirmDialog::Button::Cancel, + .onClose = + [this, type = *type, layoutName](std::optional button) + { + if (button != ConfirmDialog::Button::Ok) + return; + modifyLayouts( + type, + [layoutName](std::map& layouts) + { + layouts.erase(layoutName); + }, + std::string{Constants::defaultLayoutName} + ); + }, + }); +} + +void Toolbar::modifyLayouts( + Persistence::TerminalEngineType type, + std::function&)> modifier, + std::string const& selectAfterwards +) +{ + impl_->stateHolder->loadModifySave( + [type, modifier = std::move(modifier)](Persistence::State& state) + { + if (!state.sessionLayouts) + state.sessionLayouts.emplace(); + modifier(state.sessionLayouts->of(type)); + }, + [this, type, selectAfterwards](std::optional const& error) + { + if (error) + { + impl_->showSaveError(*error); + return; + } + if (impl_->selectedServerType() != type) + return; + reloadLayouts(); + impl_->selectedLayout.value() = selectAfterwards; + impl_->selectedLayout.modifyNow(); } - } + ); } ROAR_PIMPL_SPECIAL_FUNCTIONS_IMPL(Toolbar); @@ -342,6 +534,7 @@ Nui::ElementRenderer Toolbar::operator()() Snc::select(Snc::SelectOptionsactiveTerminalEngine), decltype(impl_->terminalEngines)>{ .activeOption = impl_->activeTerminalEngine, .options = impl_->terminalEngines, + .label = language->get("toolbar", "serverLabel"), .onChange = [this](auto const&, Nui::WebApi::MouseEvent const&) { reloadLayouts(); }, @@ -349,18 +542,6 @@ Nui::ElementRenderer Toolbar::operator()() return Nui::val::global("generateId")().as(); } }), - Snc::select(Snc::SelectOptionsselectedLayout), decltype(impl_->layouts)>{ - .activeOption = impl_->selectedLayout, - .options = impl_->layouts, - .attributes = { - !(reference.onMaterialize([this](auto){ - connectLayoutsChanged(); - })) - }, - .makeId = [](){ - return Nui::val::global("generateId")().as(); - } - }), impl_->userSelect(), Snc::button({ .text = language->get("toolbar", "connect"), @@ -400,6 +581,7 @@ Nui::ElementRenderer Toolbar::operator()() }, }, }), + div{class_ = "toolbar-separator"}(), Snc::button({ .text = language->get("toolbar", "newSession"), .icon = GeneratedSvgs::add(), @@ -414,7 +596,7 @@ Nui::ElementRenderer Toolbar::operator()() }, .styleVariant = Nui::observe(impl_->terminalEngines).generate( [](auto const& engines) { - return engines.empty() ? Snc::StyleVariant::Primary : Snc::StyleVariant::Regular; + return engines.empty() ? Snc::StyleVariant::Primary : Snc::StyleVariant::Transparent; } ), }), @@ -438,6 +620,57 @@ Nui::ElementRenderer Toolbar::operator()() }); }, }, + .styleVariant = Snc::StyleVariant::Transparent, + }), + div{class_ = "toolbar-separator"}(), + Snc::select(Snc::SelectOptionsselectedLayout), decltype(impl_->layouts)>{ + .activeOption = impl_->selectedLayout, + .options = impl_->layouts, + .attributes = { + !(reference.onMaterialize([this](auto){ + connectLayoutsChanged(); + })), + }, + .label = language->get("toolbar", "layoutLabel"), + .activeRenderer = [this](auto const&) -> Nui::ElementRenderer { + return Nui::Elements::span{}(Nui::observe(impl_->selectedLayout), [this]() { + return impl_->layoutLabel(impl_->selectedLayout.value()); + }); + }, + .elementRenderer = [this](std::string const& name) -> Nui::ElementRenderer { + return Nui::Elements::span{}(impl_->layoutLabel(name)); + }, + .makeId = [](){ + return Nui::val::global("generateId")().as(); + } + }), + Snc::button({ + .icon = Ui5Icons::save(), + .attributes = { + Nui::Attributes::title = language->get("toolbar", "saveLayout"), + disabled = Nui::observe(impl_->sessionArea->hasActiveSession()).generate( + [](bool hasSession) -> std::optional { + return hasSession ? std::nullopt : std::optional{true}; + } + ), + onClick = [this]() { + saveLayout(); + }, + }, + }), + Snc::button({ + .icon = GeneratedSvgs::delete_(), + .attributes = { + Nui::Attributes::title = language->get("toolbar", "deleteLayout"), + disabled = Nui::observe(impl_->selectedLayout).generate( + [](std::string const& layoutName) -> std::optional { + return layoutName == Constants::defaultLayoutName ? std::optional{true} : std::nullopt; + } + ), + onClick = [this]() { + deleteLayout(); + }, + }, }), // spacer: div{ @@ -476,16 +709,7 @@ Nui::ElementRenderer Toolbar::operator()() [this](std::optional const& error) { if (error) - { - impl_->confirmDialog->open({ - .styleVariant = ScriptNuiComponents::StyleVariant::Danger, - .headerText = language->get("toolbar", "errorSavingSettingsHeader"), - .text = fmt::format( - fmt::runtime(language->get("toolbar", "errorSavingSettings") + ": {}"), language->translate(*error) - ), - .buttons = ConfirmDialog::Button::Ok, - }); - } + impl_->showSaveError(*error); } ); }, diff --git a/persistence/include/persistence/state/session_layouts.hpp b/persistence/include/persistence/state/session_layouts.hpp new file mode 100644 index 00000000..7915c40f --- /dev/null +++ b/persistence/include/persistence/state/session_layouts.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +#include + +#include +#include + +namespace Persistence +{ + /** + * @brief The saved panel layouts, shared by all sessions of a type. + */ + struct SessionLayouts : public DefaultMissingMember + { + std::map ssh{}; + std::map shell{}; + + /** + * @brief The layouts shared by the sessions of a type. + * + * @param type The type of the sessions. + */ + std::map& of(TerminalEngineType type); + + /** + * @brief The layouts shared by the sessions of a type. + * + * @param type The type of the sessions. + */ + std::map const& of(TerminalEngineType type) const; + }; + BOOST_DESCRIBE_STRUCT(SessionLayouts, (), (ssh, shell)) +} diff --git a/persistence/include/persistence/state/state.hpp b/persistence/include/persistence/state/state.hpp index 364ccd30..f7201150 100644 --- a/persistence/include/persistence/state/state.hpp +++ b/persistence/include/persistence/state/state.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -58,6 +59,14 @@ namespace Persistence LocalizationOptions localizationOptions{}; std::optional lastDirectConnect{}; std::optional lastDirectConnectInheritables{}; + /** + * @brief The names of the one-time migrations already applied, so a value set afterwards is kept. + */ + std::optional> appliedMigrations{}; + /** + * @brief The layouts shared by the sessions of a type. Missing until gathered from the servers. + */ + std::optional sessionLayouts{}; State fullyResolve() const; @@ -71,6 +80,22 @@ namespace Persistence * to one keeps the servers usable. */ void writeLegacyUsers(); + + /** + * @brief Moves the terminal renderer from DOM to WebGL once. Earlier Linux versions had to default to DOM. + * + * @return Whether the migration ran and the state has to be saved. + */ + bool migrateTerminalRendererFromDom(); + + /** + * @brief Gathers the layouts earlier versions saved per server into the layouts of their type, if not done + * yet. A name used with a different layout by another server of the type gets the server name appended. + * The layouts of the servers stay for earlier versions. + * + * @return Whether the layouts were gathered and the state has to be saved. + */ + bool gatherSessionLayouts(); }; BOOST_DESCRIBE_STRUCT( @@ -89,6 +114,8 @@ namespace Persistence fileTrackingOptions, localFilesystemOptions, lastDirectConnect, - lastDirectConnectInheritables) + lastDirectConnectInheritables, + appliedMigrations, + sessionLayouts) ) } \ No newline at end of file diff --git a/persistence/source/persistence/CMakeLists.txt b/persistence/source/persistence/CMakeLists.txt index 3cfeb1b2..26a612b4 100644 --- a/persistence/source/persistence/CMakeLists.txt +++ b/persistence/source/persistence/CMakeLists.txt @@ -3,6 +3,7 @@ add_library( STATIC state/state.cpp state/session_options.cpp + state/session_layouts.cpp state/termios.cpp state/ui_options.cpp legacy_migration.cpp diff --git a/persistence/source/persistence/state/session_layouts.cpp b/persistence/source/persistence/state/session_layouts.cpp new file mode 100644 index 00000000..953248a1 --- /dev/null +++ b/persistence/source/persistence/state/session_layouts.cpp @@ -0,0 +1,14 @@ +#include + +namespace Persistence +{ + std::map& SessionLayouts::of(TerminalEngineType type) + { + return type == TerminalEngineType::shell ? shell : ssh; + } + + std::map const& SessionLayouts::of(TerminalEngineType type) const + { + return type == TerminalEngineType::shell ? shell : ssh; + } +} diff --git a/persistence/source/persistence/state/state.cpp b/persistence/source/persistence/state/state.cpp index 2f101549..5de82823 100644 --- a/persistence/source/persistence/state/state.cpp +++ b/persistence/source/persistence/state/state.cpp @@ -1,5 +1,9 @@ #include #include +#include + +#include +#include namespace Persistence { @@ -57,4 +61,52 @@ namespace Persistence if (lastDirectConnect) lastDirectConnect->writeLegacyUser(); } + + bool State::migrateTerminalRendererFromDom() + { + constexpr std::string_view migration = "terminalRendererFromDom"; + if (!appliedMigrations) + appliedMigrations = std::vector{}; + if (std::ranges::find(*appliedMigrations, migration) != appliedMigrations->end()) + return false; + appliedMigrations->emplace_back(migration); + +#ifdef __linux__ + const auto moveToWebgl = [](TerminalOptions& options) + { + if (options.renderer == "dom") + options.renderer = "webgl"; + }; + for (auto& [name, options] : terminalOptions) + moveToWebgl(options); + for (auto& [name, session] : sessions) + moveToWebgl(session.terminalOptions.value()); +#endif + return true; + } + + bool State::gatherSessionLayouts() + { + if (sessionLayouts) + return false; + sessionLayouts = SessionLayouts{}; + + for (auto const& [serverName, session] : sessions) + { + if (!session.layouts) + continue; + auto& layouts = sessionLayouts->of(session.type); + for (auto const& [layoutName, layout] : *session.layouts) + { + auto name = layoutName; + for (int attempt = 1; layouts.contains(name) && layouts[name] != layout; ++attempt) + { + name = attempt == 1 ? fmt::format("{} ({})", layoutName, serverName) + : fmt::format("{} ({}) {}", layoutName, serverName, attempt); + } + layouts.emplace(name, layout); + } + } + return true; + } } diff --git a/persistence/source/persistence/state_holder_backend.cpp b/persistence/source/persistence/state_holder_backend.cpp index 667088f5..7536fd41 100644 --- a/persistence/source/persistence/state_holder_backend.cpp +++ b/persistence/source/persistence/state_holder_backend.cpp @@ -199,6 +199,17 @@ namespace Persistence } } + if (stateCache_.migrateTerminalRendererFromDom()) + { + Log::info("Applied the one-time move of the terminal renderer from DOM to WebGL."); + mustSave = true; + } + if (stateCache_.gatherSessionLayouts()) + { + Log::info("Gathered the layouts of the servers into the layouts of their type."); + mustSave = true; + } + bool hasMissingDefaults = false; auto termiosDefault = stateCache_.termios.find("default"); if (termiosDefault == stateCache_.termios.end()) @@ -225,11 +236,7 @@ namespace Persistence .fontSize = 12, .lineHeight = 1, .cursorBlink = false, -#ifdef __linux__ - .renderer = "dom", -#else .renderer = "webgl", -#endif .letterSpacing = 0, .theme = TerminalTheme{ .background = "#202020", @@ -256,11 +263,7 @@ namespace Persistence .fontSize = 14, .lineHeight = 1.0, .cursorBlink = false, -#ifdef __linux__ - .renderer = "dom", -#else .renderer = "webgl", -#endif .letterSpacing = 0, .theme = TerminalTheme{ .background = "#0e0e14", diff --git a/static/assets/languages/de_DE.yaml b/static/assets/languages/de_DE.yaml index 62bbae72..1f849406 100644 --- a/static/assets/languages/de_DE.yaml +++ b/static/assets/languages/de_DE.yaml @@ -536,6 +536,7 @@ settings: unknownSession: "Unbekannte Sitzung" localFilesystemOptionsGroupHeader: "Optionen für das lokale Dateisystem" fileTrackingOptionsGroupHeader: "Dateiüberwachung Standardwerte" + layoutsGroupHeader: "Layouts" saving: "Speichern..." addNew: "Neu hinzufügen!" configuration: "Konfiguration" @@ -590,7 +591,6 @@ settings: logDirectoryHelpText: "Das Verzeichnis, in dem die Protokolldateien gespeichert werden. Wenn der String mit %key% beginnt, wird er durch einen speziellen Pfad ersetzt. Die folgenden Schlüssel werden den folgenden Verzeichnissen zugeordnet: ~, %userprofile%: Heimatverzeichnis. %appdata%: AppData/Roaming unter Windows, Heimatverzeichnis unter Linux. %localappdata%: AppData/Local unter Windows, Heimatverzeichnis unter Linux. %temp%: Temp-Verzeichnis. %config_home%, %config_home2%, %config_home3%: Konfigurationsverzeichnis (1,2,3 ist Home, Dokumente, AppData unter Windows). %state_home%, %state_home2%, %state_home3%: Zustandsverzeichnis. %data_home%, %data_home2%, %data_home3%: Datenverzeichnis. Unter macOS sind Konfigurations-, Zustands- und Datenverzeichnis ~/Library/Application Support." disableFileLoggingHelpText: "Wenn aktiviert, wird die Protokollierung in Dateien deaktiviert. Protokolle werden nur in der Konsole ausgegeben." layoutSetting: - layoutKeysLabel: "Gespeicherte Layouts" deleteLayoutKey: "Layout löschen" saveCurrentLayout: "Aktuelles Layout speichern" @@ -608,7 +608,6 @@ settings: removeLayoutKeyConfirmText: "Möchten Sie das folgende Layout wirklich entfernen" sessionOptions: terminalEngineType: "Sitzungs Typ" - layoutHelpText: "Layout Konfiguration für diese Sitzung. Hier können Layouts gespeichert und gelöscht werden." icon: "Sitzungssymbol" orderBy: "Sondersortierung" isStartupSession: "Starte Sitzung bei Programmstart?" @@ -656,7 +655,7 @@ settings: maxReconnectBackoffMsHelpText: "Die längste Wartezeit zwischen zwei Reconnect-Versuchen in Millisekunden. Die Wartezeit verdoppelt sich mit jedem Versuch, beginnend bei einer Sekunde, bis sie diese Grenze erreicht." maxReconnectBackoffMsSearchTags: "Wiederholen, Neu verbinden, Verzögerung, warten" users: "Benutzer" - usersHelpText: "Die Benutzer, mit denen man sich an diesem Server anmelden kann. Vor dem Verbinden wird einer in der Werkzeugleiste neben dem Layout ausgewählt; der erste Benutzer ist der Standard." + usersHelpText: "Die Benutzer, mit denen man sich an diesem Server anmelden kann. Vor dem Verbinden wird einer in der Werkzeugleiste neben dem Server ausgewählt; der erste Benutzer ist der Standard." usersSearchTags: "Anmeldung, Konto, Zugangsdaten, Schlüssel" userWithoutName: "(kein Benutzername)" resetUsersConfirmHeader: "Alle Benutzer entfernen?" @@ -1056,6 +1055,11 @@ settings: deleteRemoteOnLocalDeleteHelpText: "Standardwert für neue überwachte Einträge: Wenn eine überwachte Datei lokal gelöscht wird, auch auf dem Remote löschen." checkRemoteChanges: "Remote vor erneutem Hochladen prüfen" checkRemoteChangesHelpText: "Standardwert für neue überwachte Einträge: Vor dem erneuten Hochladen einer bearbeiteten Datei prüfen, ob sich die Kopie auf dem Remote seit dem Öffnen oder dem letzten Hochladen geändert hat, und vor dem Ersetzen fragen." + layouts: + ssh: "SSH-Layouts" + sshHelpText: "Layouts, die alle SSH-Server teilen. Beim Speichern wird das Layout des offenen SSH-Tabs übernommen." + shell: "Lokale Shell-Layouts" + shellHelpText: "Layouts, die alle lokalen Shells teilen. Beim Speichern wird das Layout des offenen lokalen Shell-Tabs übernommen." colorSetting: pickButton: "Wählen" persistence: @@ -1082,9 +1086,20 @@ rootWarning: text: "nui-sftp läuft mit Root-Rechten.\n\nDas ist gefährlich und unnötig:\n - Übertragene oder neu erstellte Dateien gehören anschließend root.\n - Ein Fehler, ein bösartiger Server oder ein kompromittiertes Gegenüber kann Ihr gesamtes System beschädigen.\n - Die Benutzer-D-Bus-Sitzung ist nicht verfügbar, daher funktioniert das Öffnen von Dateien mit externen Anwendungen nicht.\n - SSH-Schlüssel, Agents und Konfiguration aus Ihrem eigentlichen Heimatverzeichnis werden nicht verwendet.\n\nBitte schließen Sie die Anwendung und starten Sie sie als normaler Benutzer neu. Verwenden Sie sudo nur, wenn Sie wirklich Dateien bearbeiten müssen, die das erfordern, und ändern Sie lieber Dateiberechtigungen als die gesamte Oberfläche als root zu starten." toolbar: cannotUpdateLayouts: "Layouts können nicht aktualisiert werden." + defaultLayout: "Standard-Layout" + serverLabel: "Server" + layoutLabel: "Layout" + userLabel: "Benutzer" newSession: "Server Hinzufügen" connect: "Verbinden" endSession: "Sitzung Beenden" + saveLayout: "Layout Speichern" + deleteLayout: "Layout Löschen" + saveLayoutForSsh: "Geben Sie einen Namen ein. Das Layout wird für alle SSH-Server gespeichert." + saveLayoutForShell: "Geben Sie einen Namen ein. Das Layout wird für alle lokalen Shells gespeichert." + layoutNameReserved: "Dieser Name ist für das Standard-Layout reserviert. Bitte wählen Sie einen anderen Namen." + overwriteLayoutHeader: "Layout ersetzen" + overwriteLayout: "Ein Layout mit diesem Namen existiert bereits. Ersetzen" directConnect: "Direktverbindung" userSelect: "Anmelden als Benutzer" errorSavingSettingsHeader: "Einstellungen konnten nicht gespeichert werden" diff --git a/static/assets/languages/en_US.yaml b/static/assets/languages/en_US.yaml index 07cc8f70..1917bde5 100644 --- a/static/assets/languages/en_US.yaml +++ b/static/assets/languages/en_US.yaml @@ -532,6 +532,7 @@ settings: globalInheritables: "Inheritable Settings" localFilesystemOptionsGroupHeader: "Local Filesystem Options" fileTrackingOptionsGroupHeader: "File Tracking Defaults" + layoutsGroupHeader: "Layouts" errorLoadingSettings: "Error occured while loading in the settings menu." errorSavingSettingsHeader: "Could not save settings" errorSavingSettings: "Error occured while saving in the settings menu." @@ -590,7 +591,6 @@ settings: logDirectoryHelpText: "The directory where log files are stored. if the string starts with %key% it is replaced with a special path. The following keys map to the following directories: ~, %userprofile%: home directory. %appdata%: AppData/Roaming on windows, home on linux. %localappdata%: AppData/Local on windows, home on linux. %temp%: Temp directory. %config_home%, %config_home2%, %config_home3%: Configuration directory (1,2,3 is Home, Documents, AppData on windows). %state_home%, %state_home2%, %state_home3%: State directory. %data_home%, %data_home2%, %data_home3%: Data directory. On macOS the configuration, state and data directories are ~/Library/Application Support." disableFileLoggingHelpText: "When enabled, logging to files is disabled. Logs will only be output to the console." layoutSetting: - layoutKeysLabel: "Saved layouts" deleteLayoutKey: "Delete layout" saveCurrentLayout: "Save current layout" @@ -608,7 +608,6 @@ settings: removeLayoutKeyConfirmText: "Are you sure you want to remove the following layout" sessionOptions: terminalEngineType: "Session Type" - layoutHelpText: "Layout configuration for this session. Here you can save and delete layouts." icon: "Session Icon" orderBy: "Sort by this instead" isStartupSession: "Is Startup Session" @@ -656,7 +655,7 @@ settings: maxReconnectBackoffMsHelpText: "The longest wait between two reconnect attempts, in milliseconds. The wait doubles with every attempt, starting at one second, until it reaches this cap." maxReconnectBackoffMsSearchTags: "retry, reconnect, delay, wait" users: "Users" - usersHelpText: "The users to log in to this server with. Pick one in the toolbar next to the layout before connecting; the first user is the default one." + usersHelpText: "The users to log in to this server with. Pick one in the toolbar next to the server before connecting; the first user is the default one." usersSearchTags: "login, account, credentials, key" userWithoutName: "(no user name)" resetUsersConfirmHeader: "Remove all users?" @@ -1058,6 +1057,11 @@ settings: deleteRemoteOnLocalDeleteHelpText: "Default for new tracked entries: when a tracked file is deleted locally, also delete it on the remote." checkRemoteChanges: "Check Remote Before Reupload" checkRemoteChangesHelpText: "Default for new tracked entries: before re-uploading an edited file, check whether the remote copy changed since it was opened or last uploaded, and ask before replacing it." + layouts: + ssh: "SSH Layouts" + sshHelpText: "Layouts shared by all SSH servers. Saving takes the layout of the open SSH tab." + shell: "Local Shell Layouts" + shellHelpText: "Layouts shared by all local shells. Saving takes the layout of the open local shell tab." colorSetting: pickButton: "Pick" persistence: @@ -1084,9 +1088,20 @@ rootWarning: text: "nui-sftp is running with root privileges.\n\nThis is dangerous and unnecessary:\n - Files transferred or created will be owned by root.\n - A bug, a malicious server, or a compromised remote can damage your entire system.\n - The user D-Bus session is unavailable, so opening files with external applications will not work.\n - SSH keys, agents and configuration normally found in your real home directory will not be picked up.\n\nClose the application and restart it as your normal user. Use sudo only when you genuinely need to manipulate files that require it, and prefer changing file ownership over running the whole GUI as root." toolbar: cannotUpdateLayouts: "Cannot update layouts." + defaultLayout: "Default layout" + serverLabel: "Server" + layoutLabel: "Layout" + userLabel: "User" newSession: "Add Server" connect: "Connect" endSession: "End Session" + saveLayout: "Save Layout" + deleteLayout: "Delete Layout" + saveLayoutForSsh: "Enter a name. The layout is saved for all SSH servers." + saveLayoutForShell: "Enter a name. The layout is saved for all local shells." + layoutNameReserved: "This name is reserved for the default layout. Please choose a different name." + overwriteLayoutHeader: "Replace layout" + overwriteLayout: "A layout with this name already exists. Replace it" directConnect: "Direct Connect" userSelect: "User to log in as" errorSavingSettingsHeader: "Could not save settings" diff --git a/static/styles/toolbar.css b/static/styles/toolbar.css index d54c5973..65523eea 100644 --- a/static/styles/toolbar.css +++ b/static/styles/toolbar.css @@ -12,17 +12,27 @@ } } -/* Inline brand-mark shown at the left of the toolbar. Defaults to a flat - theme-tinted plate; themes (e.g. Nebula) override the chrome for richer +.toolbar-separator { + align-self: stretch; + width: 1px; + margin: 4px 3px; + flex-shrink: 0; + background-color: var(--border-color-fields); +} + +/* Inline brand-mark shown at the left of the toolbar. Defaults to the palette + of the primary button; themes (e.g. Nebula) override the chrome for richer looks. */ .toolbar-brand-mark { - width: 36px; - height: 36px; - border-radius: 6px; + box-sizing: border-box; + width: 34px; + height: 34px; + border: 1px solid var(--script-nui-components-button-border-color-primary); + border-radius: var(--script-nui-components-button-border-radius, 0.25rem); display: grid; place-items: center; - color: var(--theme-color); - background: color-mix(in srgb, var(--theme-color) 18%, var(--background-color)); + color: var(--script-nui-components-button-color); + background: var(--script-nui-components-button-color-primary); overflow: hidden; flex-shrink: 0; } diff --git a/themes/Nebula.css b/themes/Nebula.css index f3187c39..87e3543c 100644 --- a/themes/Nebula.css +++ b/themes/Nebula.css @@ -197,7 +197,7 @@ 0 0 0 1px rgba(255, 255, 255, 0.12) inset !important; } -:root[data-custom-theme="Nebula"] .script-nui-button:not([data-variant="primary"]):not([data-variant="danger"]) { +:root[data-custom-theme="Nebula"] .script-nui-button:not([data-variant="primary"]):not([data-variant="danger"]):not([data-variant="transparent"]) { background: rgba(255, 255, 255, 0.04) !important; border: 1px solid rgba(255, 255, 255, 0.13) !important; color: var(--color) !important; @@ -383,7 +383,7 @@ 0 0 0 1px rgba(255, 255, 255, 0.20) inset !important; } -:root[data-custom-theme="Nebula"][data-theme="light"] .script-nui-button:not([data-variant="primary"]):not([data-variant="danger"]) { +:root[data-custom-theme="Nebula"][data-theme="light"] .script-nui-button:not([data-variant="primary"]):not([data-variant="danger"]):not([data-variant="transparent"]) { background: rgba(255, 255, 255, 0.55) !important; border: 1px solid rgba(0, 0, 0, 0.13) !important; color: var(--color) !important;