diff --git a/src/cascadia/LocalTests_TerminalApp/CommandTests.cpp b/src/cascadia/LocalTests_TerminalApp/CommandTests.cpp index 28e53cdf5a0..d8028a88545 100644 --- a/src/cascadia/LocalTests_TerminalApp/CommandTests.cpp +++ b/src/cascadia/LocalTests_TerminalApp/CommandTests.cpp @@ -11,6 +11,7 @@ using namespace Microsoft::Console; using namespace TerminalApp; using namespace winrt::TerminalApp; using namespace winrt::Microsoft::Terminal::TerminalControl; +using namespace winrt::Windows::Foundation::Collections; using namespace WEX::Logging; using namespace WEX::TestExecution; using namespace WEX::Common; @@ -61,25 +62,25 @@ namespace TerminalAppLocalTests const auto commands1Json = VerifyParseSucceeded(commands1String); const auto commands2Json = VerifyParseSucceeded(commands2String); - std::unordered_map commands; - VERIFY_ARE_EQUAL(0u, commands.size()); + IMap commands = winrt::single_threaded_map(); + VERIFY_ARE_EQUAL(0u, commands.Size()); { auto warnings = implementation::Command::LayerJson(commands, commands0Json); VERIFY_ARE_EQUAL(0u, warnings.size()); } - VERIFY_ARE_EQUAL(1u, commands.size()); + VERIFY_ARE_EQUAL(1u, commands.Size()); { auto warnings = implementation::Command::LayerJson(commands, commands1Json); VERIFY_ARE_EQUAL(0u, warnings.size()); } - VERIFY_ARE_EQUAL(2u, commands.size()); + VERIFY_ARE_EQUAL(2u, commands.Size()); { auto warnings = implementation::Command::LayerJson(commands, commands2Json); VERIFY_ARE_EQUAL(0u, warnings.size()); } - VERIFY_ARE_EQUAL(4u, commands.size()); + VERIFY_ARE_EQUAL(4u, commands.Size()); } void CommandTests::LayerCommand() @@ -95,13 +96,13 @@ namespace TerminalAppLocalTests const auto commands2Json = VerifyParseSucceeded(commands2String); const auto commands3Json = VerifyParseSucceeded(commands3String); - std::unordered_map commands; - VERIFY_ARE_EQUAL(0u, commands.size()); + IMap commands = winrt::single_threaded_map(); + VERIFY_ARE_EQUAL(0u, commands.Size()); { auto warnings = implementation::Command::LayerJson(commands, commands0Json); VERIFY_ARE_EQUAL(0u, warnings.size()); - VERIFY_ARE_EQUAL(1u, commands.size()); - auto command = commands.at(L"action0"); + VERIFY_ARE_EQUAL(1u, commands.Size()); + auto command = commands.Lookup(L"action0"); VERIFY_IS_NOT_NULL(command); VERIFY_IS_NOT_NULL(command.Action()); VERIFY_ARE_EQUAL(ShortcutAction::CopyText, command.Action().Action()); @@ -111,8 +112,8 @@ namespace TerminalAppLocalTests { auto warnings = implementation::Command::LayerJson(commands, commands1Json); VERIFY_ARE_EQUAL(0u, warnings.size()); - VERIFY_ARE_EQUAL(1u, commands.size()); - auto command = commands.at(L"action0"); + VERIFY_ARE_EQUAL(1u, commands.Size()); + auto command = commands.Lookup(L"action0"); VERIFY_IS_NOT_NULL(command); VERIFY_IS_NOT_NULL(command.Action()); VERIFY_ARE_EQUAL(ShortcutAction::PasteText, command.Action().Action()); @@ -121,8 +122,8 @@ namespace TerminalAppLocalTests { auto warnings = implementation::Command::LayerJson(commands, commands2Json); VERIFY_ARE_EQUAL(0u, warnings.size()); - VERIFY_ARE_EQUAL(1u, commands.size()); - auto command = commands.at(L"action0"); + VERIFY_ARE_EQUAL(1u, commands.Size()); + auto command = commands.Lookup(L"action0"); VERIFY_IS_NOT_NULL(command); VERIFY_IS_NOT_NULL(command.Action()); VERIFY_ARE_EQUAL(ShortcutAction::NewTab, command.Action().Action()); @@ -133,7 +134,7 @@ namespace TerminalAppLocalTests // This last command should "unbind" the action. auto warnings = implementation::Command::LayerJson(commands, commands3Json); VERIFY_ARE_EQUAL(0u, warnings.size()); - VERIFY_ARE_EQUAL(0u, commands.size()); + VERIFY_ARE_EQUAL(0u, commands.Size()); } } @@ -153,14 +154,14 @@ namespace TerminalAppLocalTests const auto commands0Json = VerifyParseSucceeded(commands0String); - std::unordered_map commands; - VERIFY_ARE_EQUAL(0u, commands.size()); + IMap commands = winrt::single_threaded_map(); + VERIFY_ARE_EQUAL(0u, commands.Size()); auto warnings = implementation::Command::LayerJson(commands, commands0Json); VERIFY_ARE_EQUAL(0u, warnings.size()); - VERIFY_ARE_EQUAL(5u, commands.size()); + VERIFY_ARE_EQUAL(5u, commands.Size()); { - auto command = commands.at(L"command0"); + auto command = commands.Lookup(L"command0"); VERIFY_IS_NOT_NULL(command); VERIFY_IS_NOT_NULL(command.Action()); VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, command.Action().Action()); @@ -170,7 +171,7 @@ namespace TerminalAppLocalTests VERIFY_ARE_EQUAL(winrt::TerminalApp::SplitState::Automatic, realArgs.SplitStyle()); } { - auto command = commands.at(L"command1"); + auto command = commands.Lookup(L"command1"); VERIFY_IS_NOT_NULL(command); VERIFY_IS_NOT_NULL(command.Action()); VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, command.Action().Action()); @@ -180,7 +181,7 @@ namespace TerminalAppLocalTests VERIFY_ARE_EQUAL(winrt::TerminalApp::SplitState::Vertical, realArgs.SplitStyle()); } { - auto command = commands.at(L"command2"); + auto command = commands.Lookup(L"command2"); VERIFY_IS_NOT_NULL(command); VERIFY_IS_NOT_NULL(command.Action()); VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, command.Action().Action()); @@ -190,7 +191,7 @@ namespace TerminalAppLocalTests VERIFY_ARE_EQUAL(winrt::TerminalApp::SplitState::Horizontal, realArgs.SplitStyle()); } { - auto command = commands.at(L"command4"); + auto command = commands.Lookup(L"command4"); VERIFY_IS_NOT_NULL(command); VERIFY_IS_NOT_NULL(command.Action()); VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, command.Action().Action()); @@ -200,7 +201,7 @@ namespace TerminalAppLocalTests VERIFY_ARE_EQUAL(winrt::TerminalApp::SplitState::Automatic, realArgs.SplitStyle()); } { - auto command = commands.at(L"command5"); + auto command = commands.Lookup(L"command5"); VERIFY_IS_NOT_NULL(command); VERIFY_IS_NOT_NULL(command.Action()); VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, command.Action().Action()); @@ -217,17 +218,17 @@ namespace TerminalAppLocalTests const std::string commands0String{ R"([ { "name": { "key": "DuplicateTabCommandKey"}, "command": "copy" } ])" }; const auto commands0Json = VerifyParseSucceeded(commands0String); - std::unordered_map commands; - VERIFY_ARE_EQUAL(0u, commands.size()); + IMap commands = winrt::single_threaded_map(); + VERIFY_ARE_EQUAL(0u, commands.Size()); { auto warnings = implementation::Command::LayerJson(commands, commands0Json); VERIFY_ARE_EQUAL(0u, warnings.size()); - VERIFY_ARE_EQUAL(1u, commands.size()); + VERIFY_ARE_EQUAL(1u, commands.Size()); // NOTE: We're relying on DuplicateTabCommandKey being defined as // "Duplicate Tab" here. If that string changes in our resources, // this test will break. - auto command = commands.at(L"Duplicate tab"); + auto command = commands.Lookup(L"Duplicate tab"); VERIFY_IS_NOT_NULL(command); VERIFY_IS_NOT_NULL(command.Action()); VERIFY_ARE_EQUAL(ShortcutAction::CopyText, command.Action().Action()); @@ -257,18 +258,18 @@ namespace TerminalAppLocalTests const auto commands0Json = VerifyParseSucceeded(commands0String); - std::unordered_map commands; - VERIFY_ARE_EQUAL(0u, commands.size()); + IMap commands = winrt::single_threaded_map(); + VERIFY_ARE_EQUAL(0u, commands.Size()); auto warnings = implementation::Command::LayerJson(commands, commands0Json); VERIFY_ARE_EQUAL(0u, warnings.size()); // There are only 3 commands here: all of the `"none"`, `"auto"`, // `"foo"`, `null`, and bindings all generate the same action, // which will generate just a single name for all of them. - VERIFY_ARE_EQUAL(3u, commands.size()); + VERIFY_ARE_EQUAL(3u, commands.Size()); { - auto command = commands.at(L"Split pane"); + auto command = commands.Lookup(L"Split pane"); VERIFY_IS_NOT_NULL(command); VERIFY_IS_NOT_NULL(command.Action()); VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, command.Action().Action()); @@ -278,7 +279,7 @@ namespace TerminalAppLocalTests VERIFY_ARE_EQUAL(winrt::TerminalApp::SplitState::Automatic, realArgs.SplitStyle()); } { - auto command = commands.at(L"Split pane, direction: vertical"); + auto command = commands.Lookup(L"Split pane, split: vertical"); VERIFY_IS_NOT_NULL(command); VERIFY_IS_NOT_NULL(command.Action()); VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, command.Action().Action()); @@ -288,7 +289,7 @@ namespace TerminalAppLocalTests VERIFY_ARE_EQUAL(winrt::TerminalApp::SplitState::Vertical, realArgs.SplitStyle()); } { - auto command = commands.at(L"Split pane, direction: horizontal"); + auto command = commands.Lookup(L"Split pane, split: horizontal"); VERIFY_IS_NOT_NULL(command); VERIFY_IS_NOT_NULL(command.Action()); VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, command.Action().Action()); @@ -307,14 +308,14 @@ namespace TerminalAppLocalTests const auto commands0Json = VerifyParseSucceeded(commands0String); - std::unordered_map commands; - VERIFY_ARE_EQUAL(0u, commands.size()); + IMap commands = winrt::single_threaded_map(); + VERIFY_ARE_EQUAL(0u, commands.Size()); auto warnings = implementation::Command::LayerJson(commands, commands0Json); VERIFY_ARE_EQUAL(0u, warnings.size()); - VERIFY_ARE_EQUAL(1u, commands.size()); + VERIFY_ARE_EQUAL(1u, commands.Size()); { - auto command = commands.at(L"Split pane"); + auto command = commands.Lookup(L"Split pane"); VERIFY_IS_NOT_NULL(command); VERIFY_IS_NOT_NULL(command.Action()); VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, command.Action().Action()); diff --git a/src/cascadia/LocalTests_TerminalApp/SettingsTests.cpp b/src/cascadia/LocalTests_TerminalApp/SettingsTests.cpp index abd710a6833..4c430688d24 100644 --- a/src/cascadia/LocalTests_TerminalApp/SettingsTests.cpp +++ b/src/cascadia/LocalTests_TerminalApp/SettingsTests.cpp @@ -5,6 +5,7 @@ #include "../TerminalApp/ColorScheme.h" #include "../TerminalApp/CascadiaSettings.h" +#include "../TerminalApp/TerminalPage.h" #include "JsonTestClass.h" #include "TestUtils.h" #include @@ -84,11 +85,48 @@ namespace TerminalAppLocalTests TEST_METHOD(TestCommandsAndKeybindings); + TEST_METHOD(TestIterateCommands); + TEST_METHOD(TestIterateOnGeneratedNamedCommands); + TEST_METHOD(TestIterateOnBadJson); + TEST_METHOD(TestNestedCommands); + TEST_METHOD(TestNestedInNestedCommand); + TEST_METHOD(TestNestedInIterableCommand); + TEST_METHOD(TestIterableInNestedCommand); + TEST_METHOD(TestMixedNestedAndIterableCommand); + TEST_METHOD(TestNestedCommandWithoutName); + TEST_METHOD(TestUnbindNestedCommand); + TEST_METHOD(TestRebindNestedCommand); + TEST_CLASS_SETUP(ClassSetup) { InitializeJsonReader(); return true; } + + private: + void _logCommandNames(winrt::Windows::Foundation::Collections::IMap& commands, const int indentation = 1) + { + if (indentation == 1) + { + Log::Comment((commands.Size() == 0) ? L"Commands:\n " : L"Commands:"); + } + for (const auto& nameAndCommand : commands) + { + Log::Comment(fmt::format(L"{0:>{1}}* {2}->{3}", + L"", + indentation, + nameAndCommand.Key(), + nameAndCommand.Value().Name()) + .c_str()); + + winrt::com_ptr cmdImpl; + cmdImpl.copy_from(winrt::get_self(nameAndCommand.Value())); + if (cmdImpl->HasNestedCommands()) + { + _logCommandNames(cmdImpl->_subcommands, indentation + 2); + } + } + } }; void SettingsTests::TryCreateWinRTType() @@ -2433,7 +2471,7 @@ namespace TerminalAppLocalTests // * A and D share the same name, so they'll only generate a single action. // * F's name is set manually to `null` auto commands = settings._globals.GetCommands(); - VERIFY_ARE_EQUAL(4u, commands.size()); + VERIFY_ARE_EQUAL(4u, commands.Size()); { KeyChord kc{ true, false, false, static_cast('A') }; @@ -2510,9 +2548,9 @@ namespace TerminalAppLocalTests } Log::Comment(L"Now verify the commands"); - + _logCommandNames(commands); { - auto command = commands.at(L"Split pane, direction: Vertical"); + auto command = commands.Lookup(L"Split pane, split: vertical"); VERIFY_IS_NOT_NULL(command); auto actionAndArgs = command.Action(); VERIFY_IS_NOT_NULL(actionAndArgs); @@ -2528,7 +2566,7 @@ namespace TerminalAppLocalTests VERIFY_IS_TRUE(realArgs.TerminalArgs().Profile().empty()); } { - auto command = commands.at(L"ctrl+b"); + auto command = commands.Lookup(L"ctrl+b"); VERIFY_IS_NOT_NULL(command); auto actionAndArgs = command.Action(); VERIFY_IS_NOT_NULL(actionAndArgs); @@ -2544,7 +2582,7 @@ namespace TerminalAppLocalTests VERIFY_IS_TRUE(realArgs.TerminalArgs().Profile().empty()); } { - auto command = commands.at(L"ctrl+c"); + auto command = commands.Lookup(L"ctrl+c"); VERIFY_IS_NOT_NULL(command); auto actionAndArgs = command.Action(); VERIFY_IS_NOT_NULL(actionAndArgs); @@ -2560,7 +2598,7 @@ namespace TerminalAppLocalTests VERIFY_IS_TRUE(realArgs.TerminalArgs().Profile().empty()); } { - auto command = commands.at(L"Split pane, direction: Horizontal"); + auto command = commands.Lookup(L"Split pane, split: horizontal"); VERIFY_IS_NOT_NULL(command); auto actionAndArgs = command.Action(); VERIFY_IS_NOT_NULL(actionAndArgs); @@ -2576,5 +2614,1336 @@ namespace TerminalAppLocalTests VERIFY_IS_TRUE(realArgs.TerminalArgs().Profile().empty()); } } + void SettingsTests::TestIterateCommands() + { + // For this test, put an iterable command with a given `name`, + // containing a ${profile.name} to replace. When we expand it, it should + // have created one command for each profile. + + const std::string settingsJson{ R"( + { + "defaultProfile": "{6239a42c-0000-49a3-80bd-e8fdd045185c}", + "profiles": [ + { + "name": "profile0", + "guid": "{6239a42c-0000-49a3-80bd-e8fdd045185c}", + "historySize": 1, + "commandline": "cmd.exe" + }, + { + "name": "profile1", + "guid": "{6239a42c-1111-49a3-80bd-e8fdd045185c}", + "historySize": 2, + "commandline": "pwsh.exe" + }, + { + "name": "profile2", + "historySize": 3, + "commandline": "wsl.exe" + } + ], + "bindings": [ + { + "name": "iterable command ${profile.name}", + "iterateOn": "profiles", + "command": { "action": "splitPane", "profile": "${profile.name}" } + }, + ], + "schemes": [ { "name": "Campbell" } ] // This is included here to prevent settings validation errors. + })" }; + + const auto guid0 = Microsoft::Console::Utils::GuidFromString(L"{6239a42c-0000-49a3-80bd-e8fdd045185c}"); + const auto guid1 = Microsoft::Console::Utils::GuidFromString(L"{6239a42c-1111-49a3-80bd-e8fdd045185c}"); + + VerifyParseSucceeded(settingsJson); + CascadiaSettings settings{}; + settings._ParseJsonString(settingsJson, false); + settings.LayerJson(settings._userSettings); + + VERIFY_ARE_EQUAL(0u, settings._warnings.size()); + + VERIFY_ARE_EQUAL(3u, settings.GetProfiles().size()); + + auto& commands = settings._globals.GetCommands(); + VERIFY_ARE_EQUAL(1u, commands.Size()); + + { + auto command = commands.Lookup(L"iterable command ${profile.name}"); + VERIFY_IS_NOT_NULL(command); + auto actionAndArgs = command.Action(); + VERIFY_IS_NOT_NULL(actionAndArgs); + VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, actionAndArgs.Action()); + const auto& realArgs = actionAndArgs.Args().try_as(); + VERIFY_IS_NOT_NULL(realArgs); + // Verify the args have the expected value + VERIFY_ARE_EQUAL(winrt::TerminalApp::SplitState::Automatic, realArgs.SplitStyle()); + VERIFY_IS_NOT_NULL(realArgs.TerminalArgs()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().Commandline().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().StartingDirectory().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().TabTitle().empty()); + VERIFY_IS_FALSE(realArgs.TerminalArgs().Profile().empty()); + VERIFY_ARE_EQUAL(L"${profile.name}", realArgs.TerminalArgs().Profile()); + } + + auto expandedCommands = implementation::TerminalPage::_ExpandCommands(commands.GetView(), settings.GetProfiles()); + _logCommandNames(expandedCommands); + + VERIFY_ARE_EQUAL(0u, settings._warnings.size()); + VERIFY_ARE_EQUAL(3u, expandedCommands.Size()); + + { + auto command = expandedCommands.Lookup(L"iterable command profile0"); + VERIFY_IS_NOT_NULL(command); + auto actionAndArgs = command.Action(); + VERIFY_IS_NOT_NULL(actionAndArgs); + VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, actionAndArgs.Action()); + const auto& realArgs = actionAndArgs.Args().try_as(); + VERIFY_IS_NOT_NULL(realArgs); + // Verify the args have the expected value + VERIFY_ARE_EQUAL(winrt::TerminalApp::SplitState::Automatic, realArgs.SplitStyle()); + VERIFY_IS_NOT_NULL(realArgs.TerminalArgs()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().Commandline().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().StartingDirectory().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().TabTitle().empty()); + VERIFY_IS_FALSE(realArgs.TerminalArgs().Profile().empty()); + VERIFY_ARE_EQUAL(L"profile0", realArgs.TerminalArgs().Profile()); + } + + { + auto command = expandedCommands.Lookup(L"iterable command profile1"); + VERIFY_IS_NOT_NULL(command); + auto actionAndArgs = command.Action(); + VERIFY_IS_NOT_NULL(actionAndArgs); + VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, actionAndArgs.Action()); + const auto& realArgs = actionAndArgs.Args().try_as(); + VERIFY_IS_NOT_NULL(realArgs); + // Verify the args have the expected value + VERIFY_ARE_EQUAL(winrt::TerminalApp::SplitState::Automatic, realArgs.SplitStyle()); + VERIFY_IS_NOT_NULL(realArgs.TerminalArgs()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().Commandline().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().StartingDirectory().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().TabTitle().empty()); + VERIFY_IS_FALSE(realArgs.TerminalArgs().Profile().empty()); + VERIFY_ARE_EQUAL(L"profile1", realArgs.TerminalArgs().Profile()); + } + + { + auto command = expandedCommands.Lookup(L"iterable command profile2"); + VERIFY_IS_NOT_NULL(command); + auto actionAndArgs = command.Action(); + VERIFY_IS_NOT_NULL(actionAndArgs); + VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, actionAndArgs.Action()); + const auto& realArgs = actionAndArgs.Args().try_as(); + VERIFY_IS_NOT_NULL(realArgs); + // Verify the args have the expected value + VERIFY_ARE_EQUAL(winrt::TerminalApp::SplitState::Automatic, realArgs.SplitStyle()); + VERIFY_IS_NOT_NULL(realArgs.TerminalArgs()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().Commandline().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().StartingDirectory().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().TabTitle().empty()); + VERIFY_IS_FALSE(realArgs.TerminalArgs().Profile().empty()); + VERIFY_ARE_EQUAL(L"profile2", realArgs.TerminalArgs().Profile()); + } + } + + void SettingsTests::TestIterateOnGeneratedNamedCommands() + { + // For this test, put an iterable command without a given `name` to + // replace. When we expand it, it should still work. + + const std::string settingsJson{ R"( + { + "defaultProfile": "{6239a42c-0000-49a3-80bd-e8fdd045185c}", + "profiles": [ + { + "name": "profile0", + "guid": "{6239a42c-0000-49a3-80bd-e8fdd045185c}", + "historySize": 1, + "commandline": "cmd.exe" + }, + { + "name": "profile1", + "guid": "{6239a42c-1111-49a3-80bd-e8fdd045185c}", + "historySize": 2, + "commandline": "pwsh.exe" + }, + { + "name": "profile2", + "historySize": 3, + "commandline": "wsl.exe" + } + ], + "bindings": [ + { + "iterateOn": "profiles", + "command": { "action": "splitPane", "profile": "${profile.name}" } + }, + ], + "schemes": [ { "name": "Campbell" } ] // This is included here to prevent settings validation errors. + })" }; + + const auto guid0 = Microsoft::Console::Utils::GuidFromString(L"{6239a42c-0000-49a3-80bd-e8fdd045185c}"); + const auto guid1 = Microsoft::Console::Utils::GuidFromString(L"{6239a42c-1111-49a3-80bd-e8fdd045185c}"); + + VerifyParseSucceeded(settingsJson); + CascadiaSettings settings{}; + settings._ParseJsonString(settingsJson, false); + settings.LayerJson(settings._userSettings); + + VERIFY_ARE_EQUAL(0u, settings._warnings.size()); + + VERIFY_ARE_EQUAL(3u, settings.GetProfiles().size()); + + auto& commands = settings._globals.GetCommands(); + VERIFY_ARE_EQUAL(1u, commands.Size()); + + { + auto command = commands.Lookup(L"Split pane, profile: ${profile.name}"); + VERIFY_IS_NOT_NULL(command); + auto actionAndArgs = command.Action(); + VERIFY_IS_NOT_NULL(actionAndArgs); + VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, actionAndArgs.Action()); + const auto& realArgs = actionAndArgs.Args().try_as(); + VERIFY_IS_NOT_NULL(realArgs); + // Verify the args have the expected value + VERIFY_ARE_EQUAL(winrt::TerminalApp::SplitState::Automatic, realArgs.SplitStyle()); + VERIFY_IS_NOT_NULL(realArgs.TerminalArgs()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().Commandline().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().StartingDirectory().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().TabTitle().empty()); + VERIFY_IS_FALSE(realArgs.TerminalArgs().Profile().empty()); + VERIFY_ARE_EQUAL(L"${profile.name}", realArgs.TerminalArgs().Profile()); + } + + auto expandedCommands = implementation::TerminalPage::_ExpandCommands(commands.GetView(), settings.GetProfiles()); + _logCommandNames(expandedCommands); + + VERIFY_ARE_EQUAL(0u, settings._warnings.size()); + VERIFY_ARE_EQUAL(3u, expandedCommands.Size()); + + { + auto command = expandedCommands.Lookup(L"Split pane, profile: profile0"); + VERIFY_IS_NOT_NULL(command); + auto actionAndArgs = command.Action(); + VERIFY_IS_NOT_NULL(actionAndArgs); + VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, actionAndArgs.Action()); + const auto& realArgs = actionAndArgs.Args().try_as(); + VERIFY_IS_NOT_NULL(realArgs); + // Verify the args have the expected value + VERIFY_ARE_EQUAL(winrt::TerminalApp::SplitState::Automatic, realArgs.SplitStyle()); + VERIFY_IS_NOT_NULL(realArgs.TerminalArgs()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().Commandline().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().StartingDirectory().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().TabTitle().empty()); + VERIFY_IS_FALSE(realArgs.TerminalArgs().Profile().empty()); + VERIFY_ARE_EQUAL(L"profile0", realArgs.TerminalArgs().Profile()); + } + + { + auto command = expandedCommands.Lookup(L"Split pane, profile: profile1"); + VERIFY_IS_NOT_NULL(command); + auto actionAndArgs = command.Action(); + VERIFY_IS_NOT_NULL(actionAndArgs); + VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, actionAndArgs.Action()); + const auto& realArgs = actionAndArgs.Args().try_as(); + VERIFY_IS_NOT_NULL(realArgs); + // Verify the args have the expected value + VERIFY_ARE_EQUAL(winrt::TerminalApp::SplitState::Automatic, realArgs.SplitStyle()); + VERIFY_IS_NOT_NULL(realArgs.TerminalArgs()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().Commandline().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().StartingDirectory().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().TabTitle().empty()); + VERIFY_IS_FALSE(realArgs.TerminalArgs().Profile().empty()); + VERIFY_ARE_EQUAL(L"profile1", realArgs.TerminalArgs().Profile()); + } + + { + auto command = expandedCommands.Lookup(L"Split pane, profile: profile2"); + VERIFY_IS_NOT_NULL(command); + auto actionAndArgs = command.Action(); + VERIFY_IS_NOT_NULL(actionAndArgs); + VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, actionAndArgs.Action()); + const auto& realArgs = actionAndArgs.Args().try_as(); + VERIFY_IS_NOT_NULL(realArgs); + // Verify the args have the expected value + VERIFY_ARE_EQUAL(winrt::TerminalApp::SplitState::Automatic, realArgs.SplitStyle()); + VERIFY_IS_NOT_NULL(realArgs.TerminalArgs()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().Commandline().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().StartingDirectory().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().TabTitle().empty()); + VERIFY_IS_FALSE(realArgs.TerminalArgs().Profile().empty()); + VERIFY_ARE_EQUAL(L"profile2", realArgs.TerminalArgs().Profile()); + } + } + + void SettingsTests::TestIterateOnBadJson() + { + // For this test, put an iterable command with a profile name that would + // cause bad json to be filled in. Something like a profile with a name + // of "Foo\"", so the trailing '"' might break the json parsing. + + const std::string settingsJson{ R"( + { + "defaultProfile": "{6239a42c-0000-49a3-80bd-e8fdd045185c}", + "profiles": [ + { + "name": "profile0", + "guid": "{6239a42c-0000-49a3-80bd-e8fdd045185c}", + "historySize": 1, + "commandline": "cmd.exe" + }, + { + "name": "profile1\"", + "guid": "{6239a42c-1111-49a3-80bd-e8fdd045185c}", + "historySize": 2, + "commandline": "pwsh.exe" + }, + { + "name": "profile2", + "historySize": 3, + "commandline": "wsl.exe" + } + ], + "bindings": [ + { + "name": "iterable command ${profile.name}", + "iterateOn": "profiles", + "command": { "action": "splitPane", "profile": "${profile.name}" } + }, + ], + "schemes": [ { "name": "Campbell" } ] // This is included here to prevent settings validation errors. + })" }; + + const auto guid0 = Microsoft::Console::Utils::GuidFromString(L"{6239a42c-0000-49a3-80bd-e8fdd045185c}"); + const auto guid1 = Microsoft::Console::Utils::GuidFromString(L"{6239a42c-1111-49a3-80bd-e8fdd045185c}"); + + VerifyParseSucceeded(settingsJson); + CascadiaSettings settings{}; + settings._ParseJsonString(settingsJson, false); + settings.LayerJson(settings._userSettings); + + VERIFY_ARE_EQUAL(0u, settings._warnings.size()); + + VERIFY_ARE_EQUAL(3u, settings.GetProfiles().size()); + + auto& commands = settings._globals.GetCommands(); + VERIFY_ARE_EQUAL(1u, commands.Size()); + + { + auto command = commands.Lookup(L"iterable command ${profile.name}"); + VERIFY_IS_NOT_NULL(command); + auto actionAndArgs = command.Action(); + VERIFY_IS_NOT_NULL(actionAndArgs); + VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, actionAndArgs.Action()); + const auto& realArgs = actionAndArgs.Args().try_as(); + VERIFY_IS_NOT_NULL(realArgs); + // Verify the args have the expected value + VERIFY_ARE_EQUAL(winrt::TerminalApp::SplitState::Automatic, realArgs.SplitStyle()); + VERIFY_IS_NOT_NULL(realArgs.TerminalArgs()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().Commandline().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().StartingDirectory().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().TabTitle().empty()); + VERIFY_IS_FALSE(realArgs.TerminalArgs().Profile().empty()); + VERIFY_ARE_EQUAL(L"${profile.name}", realArgs.TerminalArgs().Profile()); + } + + settings._ValidateSettings(); + auto expandedCommands = implementation::TerminalPage::_ExpandCommands(commands.GetView(), settings.GetProfiles()); + _logCommandNames(expandedCommands); + + VERIFY_ARE_EQUAL(0u, settings._warnings.size()); + VERIFY_ARE_EQUAL(3u, expandedCommands.Size()); + + { + auto command = expandedCommands.Lookup(L"iterable command profile0"); + VERIFY_IS_NOT_NULL(command); + auto actionAndArgs = command.Action(); + VERIFY_IS_NOT_NULL(actionAndArgs); + VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, actionAndArgs.Action()); + const auto& realArgs = actionAndArgs.Args().try_as(); + VERIFY_IS_NOT_NULL(realArgs); + // Verify the args have the expected value + VERIFY_ARE_EQUAL(winrt::TerminalApp::SplitState::Automatic, realArgs.SplitStyle()); + VERIFY_IS_NOT_NULL(realArgs.TerminalArgs()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().Commandline().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().StartingDirectory().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().TabTitle().empty()); + VERIFY_IS_FALSE(realArgs.TerminalArgs().Profile().empty()); + VERIFY_ARE_EQUAL(L"profile0", realArgs.TerminalArgs().Profile()); + } + + { + auto command = expandedCommands.Lookup(L"iterable command profile1\""); + VERIFY_IS_NOT_NULL(command); + auto actionAndArgs = command.Action(); + VERIFY_IS_NOT_NULL(actionAndArgs); + VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, actionAndArgs.Action()); + const auto& realArgs = actionAndArgs.Args().try_as(); + VERIFY_IS_NOT_NULL(realArgs); + // Verify the args have the expected value + VERIFY_ARE_EQUAL(winrt::TerminalApp::SplitState::Automatic, realArgs.SplitStyle()); + VERIFY_IS_NOT_NULL(realArgs.TerminalArgs()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().Commandline().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().StartingDirectory().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().TabTitle().empty()); + VERIFY_IS_FALSE(realArgs.TerminalArgs().Profile().empty()); + VERIFY_ARE_EQUAL(L"profile1\"", realArgs.TerminalArgs().Profile()); + } + + { + auto command = expandedCommands.Lookup(L"iterable command profile2"); + VERIFY_IS_NOT_NULL(command); + auto actionAndArgs = command.Action(); + VERIFY_IS_NOT_NULL(actionAndArgs); + VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, actionAndArgs.Action()); + const auto& realArgs = actionAndArgs.Args().try_as(); + VERIFY_IS_NOT_NULL(realArgs); + // Verify the args have the expected value + VERIFY_ARE_EQUAL(winrt::TerminalApp::SplitState::Automatic, realArgs.SplitStyle()); + VERIFY_IS_NOT_NULL(realArgs.TerminalArgs()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().Commandline().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().StartingDirectory().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().TabTitle().empty()); + VERIFY_IS_FALSE(realArgs.TerminalArgs().Profile().empty()); + VERIFY_ARE_EQUAL(L"profile2", realArgs.TerminalArgs().Profile()); + } + } + + void SettingsTests::TestNestedCommands() + { + // This test checks a nested command. + // The commands should look like: + // + // + // └─ Connect to ssh... + // ├─ first.com + // └─ second.com + + const std::string settingsJson{ R"( + { + "defaultProfile": "{6239a42c-0000-49a3-80bd-e8fdd045185c}", + "profiles": [ + { + "name": "profile0", + "guid": "{6239a42c-0000-49a3-80bd-e8fdd045185c}", + "historySize": 1, + "commandline": "cmd.exe" + }, + { + "name": "profile1", + "guid": "{6239a42c-1111-49a3-80bd-e8fdd045185c}", + "historySize": 2, + "commandline": "pwsh.exe" + }, + { + "name": "profile2", + "historySize": 3, + "commandline": "wsl.exe" + } + ], + "bindings": [ + { + "name": "Connect to ssh...", + "commands": [ + { + "name": "first.com", + "command": { "action": "newTab", "commandline": "ssh me@first.com" } + }, + { + "name": "second.com", + "command": { "action": "newTab", "commandline": "ssh me@second.com" } + } + ] + }, + ], + "schemes": [ { "name": "Campbell" } ] // This is included here to prevent settings validation errors. + })" }; + + VerifyParseSucceeded(settingsJson); + CascadiaSettings settings{}; + settings._ParseJsonString(settingsJson, false); + settings.LayerJson(settings._userSettings); + + VERIFY_ARE_EQUAL(0u, settings._warnings.size()); + VERIFY_ARE_EQUAL(3u, settings.GetProfiles().size()); + + auto& commands = settings._globals.GetCommands(); + settings._ValidateSettings(); + auto expandedCommands = implementation::TerminalPage::_ExpandCommands(commands.GetView(), settings.GetProfiles()); + _logCommandNames(expandedCommands); + + VERIFY_ARE_EQUAL(0u, settings._warnings.size()); + VERIFY_ARE_EQUAL(1u, expandedCommands.Size()); + + auto rootCommandProj = expandedCommands.Lookup(L"Connect to ssh..."); + VERIFY_IS_NOT_NULL(rootCommandProj); + auto rootActionAndArgs = rootCommandProj.Action(); + VERIFY_IS_NULL(rootActionAndArgs); + + winrt::com_ptr rootCommandImpl; + rootCommandImpl.copy_from(winrt::get_self(rootCommandProj)); + + VERIFY_ARE_EQUAL(2u, rootCommandImpl->_subcommands.Size()); + + { + winrt::hstring commandName{ L"first.com" }; + auto commandProj = rootCommandImpl->_subcommands.Lookup(commandName); + VERIFY_IS_NOT_NULL(commandProj); + auto actionAndArgs = commandProj.Action(); + VERIFY_IS_NOT_NULL(actionAndArgs); + + winrt::com_ptr commandImpl; + commandImpl.copy_from(winrt::get_self(commandProj)); + + VERIFY_IS_FALSE(commandImpl->HasNestedCommands()); + } + { + winrt::hstring commandName{ L"second.com" }; + auto commandProj = rootCommandImpl->_subcommands.Lookup(commandName); + VERIFY_IS_NOT_NULL(commandProj); + auto actionAndArgs = commandProj.Action(); + VERIFY_IS_NOT_NULL(actionAndArgs); + + winrt::com_ptr commandImpl; + commandImpl.copy_from(winrt::get_self(commandProj)); + + VERIFY_IS_FALSE(commandImpl->HasNestedCommands()); + } + } + + void SettingsTests::TestNestedInNestedCommand() + { + // This test checks a nested command that includes nested commands. + // The commands should look like: + // + // + // └─ grandparent + // └─ parent + // ├─ child1 + // └─ child2 + + const std::string settingsJson{ R"( + { + "defaultProfile": "{6239a42c-0000-49a3-80bd-e8fdd045185c}", + "profiles": [ + { + "name": "profile0", + "guid": "{6239a42c-0000-49a3-80bd-e8fdd045185c}", + "historySize": 1, + "commandline": "cmd.exe" + }, + { + "name": "profile1", + "guid": "{6239a42c-1111-49a3-80bd-e8fdd045185c}", + "historySize": 2, + "commandline": "pwsh.exe" + }, + { + "name": "profile2", + "historySize": 3, + "commandline": "wsl.exe" + } + ], + "bindings": [ + { + "name": "grandparent", + "commands": [ + { + "name": "parent", + "commands": [ + { + "name": "child1", + "command": { "action": "newTab", "commandline": "ssh me@first.com" } + }, + { + "name": "child2", + "command": { "action": "newTab", "commandline": "ssh me@second.com" } + } + ] + }, + ] + }, + ], + "schemes": [ { "name": "Campbell" } ] // This is included here to prevent settings validation errors. + })" }; + + VerifyParseSucceeded(settingsJson); + CascadiaSettings settings{}; + settings._ParseJsonString(settingsJson, false); + settings.LayerJson(settings._userSettings); + + VERIFY_ARE_EQUAL(0u, settings._warnings.size()); + VERIFY_ARE_EQUAL(3u, settings.GetProfiles().size()); + + auto& commands = settings._globals.GetCommands(); + settings._ValidateSettings(); + auto expandedCommands = implementation::TerminalPage::_ExpandCommands(commands.GetView(), settings.GetProfiles()); + _logCommandNames(expandedCommands); + + VERIFY_ARE_EQUAL(0u, settings._warnings.size()); + VERIFY_ARE_EQUAL(1u, expandedCommands.Size()); + + auto grandparentCommandProj = expandedCommands.Lookup(L"grandparent"); + VERIFY_IS_NOT_NULL(grandparentCommandProj); + auto grandparentActionAndArgs = grandparentCommandProj.Action(); + VERIFY_IS_NULL(grandparentActionAndArgs); + + winrt::com_ptr grandparentCommandImpl; + grandparentCommandImpl.copy_from(winrt::get_self(grandparentCommandProj)); + + VERIFY_ARE_EQUAL(1u, grandparentCommandImpl->_subcommands.Size()); + + winrt::hstring parentName{ L"parent" }; + auto parentProj = grandparentCommandImpl->_subcommands.Lookup(parentName); + VERIFY_IS_NOT_NULL(parentProj); + auto parentActionAndArgs = parentProj.Action(); + VERIFY_IS_NULL(parentActionAndArgs); + + winrt::com_ptr parentImpl; + parentImpl.copy_from(winrt::get_self(parentProj)); + + VERIFY_ARE_EQUAL(2u, parentImpl->_subcommands.Size()); + { + winrt::hstring childName{ L"child1" }; + auto childProj = parentImpl->_subcommands.Lookup(childName); + VERIFY_IS_NOT_NULL(childProj); + auto childActionAndArgs = childProj.Action(); + VERIFY_IS_NOT_NULL(childActionAndArgs); + + VERIFY_ARE_EQUAL(ShortcutAction::NewTab, childActionAndArgs.Action()); + const auto& realArgs = childActionAndArgs.Args().try_as(); + VERIFY_IS_NOT_NULL(realArgs); + // Verify the args have the expected value + VERIFY_IS_NOT_NULL(realArgs.TerminalArgs()); + VERIFY_IS_FALSE(realArgs.TerminalArgs().Commandline().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().StartingDirectory().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().TabTitle().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().Profile().empty()); + VERIFY_ARE_EQUAL(L"ssh me@first.com", realArgs.TerminalArgs().Commandline()); + + winrt::com_ptr childImpl; + childImpl.copy_from(winrt::get_self(childProj)); + + VERIFY_IS_FALSE(childImpl->HasNestedCommands()); + } + { + winrt::hstring childName{ L"child2" }; + auto childProj = parentImpl->_subcommands.Lookup(childName); + VERIFY_IS_NOT_NULL(childProj); + auto childActionAndArgs = childProj.Action(); + VERIFY_IS_NOT_NULL(childActionAndArgs); + + VERIFY_ARE_EQUAL(ShortcutAction::NewTab, childActionAndArgs.Action()); + const auto& realArgs = childActionAndArgs.Args().try_as(); + VERIFY_IS_NOT_NULL(realArgs); + // Verify the args have the expected value + VERIFY_IS_NOT_NULL(realArgs.TerminalArgs()); + VERIFY_IS_FALSE(realArgs.TerminalArgs().Commandline().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().StartingDirectory().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().TabTitle().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().Profile().empty()); + VERIFY_ARE_EQUAL(L"ssh me@second.com", realArgs.TerminalArgs().Commandline()); + + winrt::com_ptr childImpl; + childImpl.copy_from(winrt::get_self(childProj)); + + VERIFY_IS_FALSE(childImpl->HasNestedCommands()); + } + } + + void SettingsTests::TestNestedInIterableCommand() + { + // This test checks a iterable command that includes a nested command. + // The commands should look like: + // + // + // ├─ profile0... + // | ├─ Split pane, profile: profile0 + // | ├─ Split pane, direction: vertical, profile: profile0 + // | └─ Split pane, direction: horizontal, profile: profile0 + // ├─ profile1... + // | ├─Split pane, profile: profile1 + // | ├─Split pane, direction: vertical, profile: profile1 + // | └─Split pane, direction: horizontal, profile: profile1 + // └─ profile2... + // ├─ Split pane, profile: profile2 + // ├─ Split pane, direction: vertical, profile: profile2 + // └─ Split pane, direction: horizontal, profile: profile2 + + const std::string settingsJson{ R"( + { + "defaultProfile": "{6239a42c-0000-49a3-80bd-e8fdd045185c}", + "profiles": [ + { + "name": "profile0", + "guid": "{6239a42c-0000-49a3-80bd-e8fdd045185c}", + "historySize": 1, + "commandline": "cmd.exe" + }, + { + "name": "profile1", + "guid": "{6239a42c-1111-49a3-80bd-e8fdd045185c}", + "historySize": 2, + "commandline": "pwsh.exe" + }, + { + "name": "profile2", + "historySize": 3, + "commandline": "wsl.exe" + } + ], + "bindings": [ + { + "iterateOn": "profiles", + "name": "${profile.name}...", + "commands": [ + { "command": { "action": "splitPane", "profile": "${profile.name}", "split": "auto" } }, + { "command": { "action": "splitPane", "profile": "${profile.name}", "split": "vertical" } }, + { "command": { "action": "splitPane", "profile": "${profile.name}", "split": "horizontal" } } + ] + } + ], + "schemes": [ { "name": "Campbell" } ] // This is included here to prevent settings validation errors. + })" }; + + VerifyParseSucceeded(settingsJson); + CascadiaSettings settings{}; + settings._ParseJsonString(settingsJson, false); + settings.LayerJson(settings._userSettings); + + VERIFY_ARE_EQUAL(0u, settings._warnings.size()); + VERIFY_ARE_EQUAL(3u, settings.GetProfiles().size()); + + auto& commands = settings._globals.GetCommands(); + settings._ValidateSettings(); + auto expandedCommands = implementation::TerminalPage::_ExpandCommands(commands.GetView(), settings.GetProfiles()); + _logCommandNames(expandedCommands); + + VERIFY_ARE_EQUAL(0u, settings._warnings.size()); + + VERIFY_ARE_EQUAL(3u, expandedCommands.Size()); + + for (auto name : std::vector({ L"profile0", L"profile1", L"profile2" })) + { + winrt::hstring commandName{ name + L"..." }; + auto commandProj = expandedCommands.Lookup(commandName); + VERIFY_IS_NOT_NULL(commandProj); + auto actionAndArgs = commandProj.Action(); + VERIFY_IS_NULL(actionAndArgs); + + winrt::com_ptr commandImpl; + commandImpl.copy_from(winrt::get_self(commandProj)); + + VERIFY_IS_TRUE(commandImpl->HasNestedCommands()); + VERIFY_ARE_EQUAL(3u, commandImpl->_subcommands.Size()); + _logCommandNames(commandImpl->_subcommands); + { + winrt::hstring childCommandName{ fmt::format(L"Split pane, profile: {}", name) }; + auto childCommandProj = commandImpl->_subcommands.Lookup(childCommandName); + VERIFY_IS_NOT_NULL(childCommandProj); + auto childActionAndArgs = childCommandProj.Action(); + VERIFY_IS_NOT_NULL(childActionAndArgs); + + VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, childActionAndArgs.Action()); + const auto& realArgs = childActionAndArgs.Args().try_as(); + VERIFY_IS_NOT_NULL(realArgs); + // Verify the args have the expected value + VERIFY_ARE_EQUAL(winrt::TerminalApp::SplitState::Automatic, realArgs.SplitStyle()); + VERIFY_IS_NOT_NULL(realArgs.TerminalArgs()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().Commandline().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().StartingDirectory().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().TabTitle().empty()); + VERIFY_IS_FALSE(realArgs.TerminalArgs().Profile().empty()); + VERIFY_ARE_EQUAL(name, realArgs.TerminalArgs().Profile()); + + winrt::com_ptr childCommandImpl; + childCommandImpl.copy_from(winrt::get_self(childCommandProj)); + + VERIFY_IS_FALSE(childCommandImpl->HasNestedCommands()); + } + { + winrt::hstring childCommandName{ fmt::format(L"Split pane, split: horizontal, profile: {}", name) }; + auto childCommandProj = commandImpl->_subcommands.Lookup(childCommandName); + VERIFY_IS_NOT_NULL(childCommandProj); + auto childActionAndArgs = childCommandProj.Action(); + VERIFY_IS_NOT_NULL(childActionAndArgs); + + VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, childActionAndArgs.Action()); + const auto& realArgs = childActionAndArgs.Args().try_as(); + VERIFY_IS_NOT_NULL(realArgs); + // Verify the args have the expected value + VERIFY_ARE_EQUAL(winrt::TerminalApp::SplitState::Horizontal, realArgs.SplitStyle()); + VERIFY_IS_NOT_NULL(realArgs.TerminalArgs()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().Commandline().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().StartingDirectory().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().TabTitle().empty()); + VERIFY_IS_FALSE(realArgs.TerminalArgs().Profile().empty()); + VERIFY_ARE_EQUAL(name, realArgs.TerminalArgs().Profile()); + + winrt::com_ptr childCommandImpl; + childCommandImpl.copy_from(winrt::get_self(childCommandProj)); + + VERIFY_IS_FALSE(childCommandImpl->HasNestedCommands()); + } + { + winrt::hstring childCommandName{ fmt::format(L"Split pane, split: vertical, profile: {}", name) }; + auto childCommandProj = commandImpl->_subcommands.Lookup(childCommandName); + VERIFY_IS_NOT_NULL(childCommandProj); + auto childActionAndArgs = childCommandProj.Action(); + VERIFY_IS_NOT_NULL(childActionAndArgs); + + VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, childActionAndArgs.Action()); + const auto& realArgs = childActionAndArgs.Args().try_as(); + VERIFY_IS_NOT_NULL(realArgs); + // Verify the args have the expected value + VERIFY_ARE_EQUAL(winrt::TerminalApp::SplitState::Vertical, realArgs.SplitStyle()); + VERIFY_IS_NOT_NULL(realArgs.TerminalArgs()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().Commandline().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().StartingDirectory().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().TabTitle().empty()); + VERIFY_IS_FALSE(realArgs.TerminalArgs().Profile().empty()); + VERIFY_ARE_EQUAL(name, realArgs.TerminalArgs().Profile()); + + winrt::com_ptr childCommandImpl; + childCommandImpl.copy_from(winrt::get_self(childCommandProj)); + + VERIFY_IS_FALSE(childCommandImpl->HasNestedCommands()); + } + } + } + + void SettingsTests::TestIterableInNestedCommand() + { + // This test checks a nested command that includes an iterable command. + // The commands should look like: + // + // + // └─ New Tab With Profile... + // ├─ Profile 1 + // ├─ Profile 2 + // └─ Profile 3 + + const std::string settingsJson{ R"( + { + "defaultProfile": "{6239a42c-0000-49a3-80bd-e8fdd045185c}", + "profiles": [ + { + "name": "profile0", + "guid": "{6239a42c-0000-49a3-80bd-e8fdd045185c}", + "historySize": 1, + "commandline": "cmd.exe" + }, + { + "name": "profile1", + "guid": "{6239a42c-1111-49a3-80bd-e8fdd045185c}", + "historySize": 2, + "commandline": "pwsh.exe" + }, + { + "name": "profile2", + "historySize": 3, + "commandline": "wsl.exe" + } + ], + "bindings": [ + { + "name": "New Tab With Profile...", + "commands": [ + { + "iterateOn": "profiles", + "command": { "action": "newTab", "profile": "${profile.name}" } + } + ] + } + ], + "schemes": [ { "name": "Campbell" } ] // This is included here to prevent settings validation errors. + })" }; + + VerifyParseSucceeded(settingsJson); + CascadiaSettings settings{}; + settings._ParseJsonString(settingsJson, false); + settings.LayerJson(settings._userSettings); + + VERIFY_ARE_EQUAL(0u, settings._warnings.size()); + VERIFY_ARE_EQUAL(3u, settings.GetProfiles().size()); + + auto& commands = settings._globals.GetCommands(); + settings._ValidateSettings(); + auto expandedCommands = implementation::TerminalPage::_ExpandCommands(commands.GetView(), settings.GetProfiles()); + _logCommandNames(expandedCommands); + + VERIFY_ARE_EQUAL(0u, settings._warnings.size()); + VERIFY_ARE_EQUAL(1u, expandedCommands.Size()); + + auto rootCommandProj = expandedCommands.Lookup(L"New Tab With Profile..."); + VERIFY_IS_NOT_NULL(rootCommandProj); + auto rootActionAndArgs = rootCommandProj.Action(); + VERIFY_IS_NULL(rootActionAndArgs); + + winrt::com_ptr rootCommandImpl; + rootCommandImpl.copy_from(winrt::get_self(rootCommandProj)); + + VERIFY_ARE_EQUAL(3u, rootCommandImpl->_subcommands.Size()); + + for (auto name : std::vector({ L"profile0", L"profile1", L"profile2" })) + { + winrt::hstring commandName{ fmt::format(L"New tab, profile: {}", name) }; + auto commandProj = rootCommandImpl->_subcommands.Lookup(commandName); + VERIFY_IS_NOT_NULL(commandProj); + auto actionAndArgs = commandProj.Action(); + VERIFY_IS_NOT_NULL(actionAndArgs); + + VERIFY_ARE_EQUAL(ShortcutAction::NewTab, actionAndArgs.Action()); + const auto& realArgs = actionAndArgs.Args().try_as(); + VERIFY_IS_NOT_NULL(realArgs); + // Verify the args have the expected value + VERIFY_IS_NOT_NULL(realArgs.TerminalArgs()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().Commandline().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().StartingDirectory().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().TabTitle().empty()); + VERIFY_IS_FALSE(realArgs.TerminalArgs().Profile().empty()); + VERIFY_ARE_EQUAL(name, realArgs.TerminalArgs().Profile()); + + winrt::com_ptr commandImpl; + commandImpl.copy_from(winrt::get_self(commandProj)); + + VERIFY_IS_FALSE(commandImpl->HasNestedCommands()); + } + } + void SettingsTests::TestMixedNestedAndIterableCommand() + { + // This test checks a nested commands that includes an iterable command + // that includes a nested command. + // The commands should look like: + // + // + // └─ New Pane... + // ├─ profile0... + // | ├─ Split automatically + // | ├─ Split vertically + // | └─ Split horizontally + // ├─ profile1... + // | ├─ Split automatically + // | ├─ Split vertically + // | └─ Split horizontally + // └─ profile2... + // ├─ Split automatically + // ├─ Split vertically + // └─ Split horizontally + + const std::string settingsJson{ R"( + { + "defaultProfile": "{6239a42c-0000-49a3-80bd-e8fdd045185c}", + "profiles": [ + { + "name": "profile0", + "guid": "{6239a42c-0000-49a3-80bd-e8fdd045185c}", + "historySize": 1, + "commandline": "cmd.exe" + }, + { + "name": "profile1", + "guid": "{6239a42c-1111-49a3-80bd-e8fdd045185c}", + "historySize": 2, + "commandline": "pwsh.exe" + }, + { + "name": "profile2", + "historySize": 3, + "commandline": "wsl.exe" + } + ], + "bindings": [ + { + "name": "New Pane...", + "commands": [ + { + "iterateOn": "profiles", + "name": "${profile.name}...", + "commands": [ + { "command": { "action": "splitPane", "profile": "${profile.name}", "split": "auto" } }, + { "command": { "action": "splitPane", "profile": "${profile.name}", "split": "vertical" } }, + { "command": { "action": "splitPane", "profile": "${profile.name}", "split": "horizontal" } } + ] + } + ] + } + ], + "schemes": [ { "name": "Campbell" } ] // This is included here to prevent settings validation errors. + })" }; + + VerifyParseSucceeded(settingsJson); + CascadiaSettings settings{}; + settings._ParseJsonString(settingsJson, false); + settings.LayerJson(settings._userSettings); + + VERIFY_ARE_EQUAL(0u, settings._warnings.size()); + VERIFY_ARE_EQUAL(3u, settings.GetProfiles().size()); + + auto& commands = settings._globals.GetCommands(); + settings._ValidateSettings(); + auto expandedCommands = implementation::TerminalPage::_ExpandCommands(commands.GetView(), settings.GetProfiles()); + _logCommandNames(expandedCommands); + + VERIFY_ARE_EQUAL(0u, settings._warnings.size()); + VERIFY_ARE_EQUAL(1u, expandedCommands.Size()); + + auto rootCommandProj = expandedCommands.Lookup(L"New Pane..."); + VERIFY_IS_NOT_NULL(rootCommandProj); + auto rootActionAndArgs = rootCommandProj.Action(); + VERIFY_IS_NULL(rootActionAndArgs); + + winrt::com_ptr rootCommandImpl; + rootCommandImpl.copy_from(winrt::get_self(rootCommandProj)); + + VERIFY_ARE_EQUAL(3u, rootCommandImpl->_subcommands.Size()); + + for (auto name : std::vector({ L"profile0", L"profile1", L"profile2" })) + { + winrt::hstring commandName{ name + L"..." }; + auto commandProj = rootCommandImpl->_subcommands.Lookup(commandName); + VERIFY_IS_NOT_NULL(commandProj); + auto actionAndArgs = commandProj.Action(); + VERIFY_IS_NULL(actionAndArgs); + + winrt::com_ptr commandImpl; + commandImpl.copy_from(winrt::get_self(commandProj)); + + VERIFY_IS_TRUE(commandImpl->HasNestedCommands()); + VERIFY_ARE_EQUAL(3u, commandImpl->_subcommands.Size()); + + _logCommandNames(commandImpl->_subcommands); + { + winrt::hstring childCommandName{ fmt::format(L"Split pane, profile: {}", name) }; + auto childCommandProj = commandImpl->_subcommands.Lookup(childCommandName); + VERIFY_IS_NOT_NULL(childCommandProj); + auto childActionAndArgs = childCommandProj.Action(); + VERIFY_IS_NOT_NULL(childActionAndArgs); + + VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, childActionAndArgs.Action()); + const auto& realArgs = childActionAndArgs.Args().try_as(); + VERIFY_IS_NOT_NULL(realArgs); + // Verify the args have the expected value + VERIFY_ARE_EQUAL(winrt::TerminalApp::SplitState::Automatic, realArgs.SplitStyle()); + VERIFY_IS_NOT_NULL(realArgs.TerminalArgs()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().Commandline().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().StartingDirectory().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().TabTitle().empty()); + VERIFY_IS_FALSE(realArgs.TerminalArgs().Profile().empty()); + VERIFY_ARE_EQUAL(name, realArgs.TerminalArgs().Profile()); + + winrt::com_ptr childCommandImpl; + childCommandImpl.copy_from(winrt::get_self(childCommandProj)); + + VERIFY_IS_FALSE(childCommandImpl->HasNestedCommands()); + } + { + winrt::hstring childCommandName{ fmt::format(L"Split pane, split: horizontal, profile: {}", name) }; + auto childCommandProj = commandImpl->_subcommands.Lookup(childCommandName); + VERIFY_IS_NOT_NULL(childCommandProj); + auto childActionAndArgs = childCommandProj.Action(); + VERIFY_IS_NOT_NULL(childActionAndArgs); + + VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, childActionAndArgs.Action()); + const auto& realArgs = childActionAndArgs.Args().try_as(); + VERIFY_IS_NOT_NULL(realArgs); + // Verify the args have the expected value + VERIFY_ARE_EQUAL(winrt::TerminalApp::SplitState::Horizontal, realArgs.SplitStyle()); + VERIFY_IS_NOT_NULL(realArgs.TerminalArgs()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().Commandline().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().StartingDirectory().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().TabTitle().empty()); + VERIFY_IS_FALSE(realArgs.TerminalArgs().Profile().empty()); + VERIFY_ARE_EQUAL(name, realArgs.TerminalArgs().Profile()); + + winrt::com_ptr childCommandImpl; + childCommandImpl.copy_from(winrt::get_self(childCommandProj)); + + VERIFY_IS_FALSE(childCommandImpl->HasNestedCommands()); + } + { + winrt::hstring childCommandName{ fmt::format(L"Split pane, split: vertical, profile: {}", name) }; + auto childCommandProj = commandImpl->_subcommands.Lookup(childCommandName); + VERIFY_IS_NOT_NULL(childCommandProj); + auto childActionAndArgs = childCommandProj.Action(); + VERIFY_IS_NOT_NULL(childActionAndArgs); + + VERIFY_ARE_EQUAL(ShortcutAction::SplitPane, childActionAndArgs.Action()); + const auto& realArgs = childActionAndArgs.Args().try_as(); + VERIFY_IS_NOT_NULL(realArgs); + // Verify the args have the expected value + VERIFY_ARE_EQUAL(winrt::TerminalApp::SplitState::Vertical, realArgs.SplitStyle()); + VERIFY_IS_NOT_NULL(realArgs.TerminalArgs()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().Commandline().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().StartingDirectory().empty()); + VERIFY_IS_TRUE(realArgs.TerminalArgs().TabTitle().empty()); + VERIFY_IS_FALSE(realArgs.TerminalArgs().Profile().empty()); + VERIFY_ARE_EQUAL(name, realArgs.TerminalArgs().Profile()); + + winrt::com_ptr childCommandImpl; + childCommandImpl.copy_from(winrt::get_self(childCommandProj)); + + VERIFY_IS_FALSE(childCommandImpl->HasNestedCommands()); + } + } + } + + void SettingsTests::TestNestedCommandWithoutName() + { + // This test tests a nested command without a name specified. This type + // of command should just be ignored, since we can't auto-generate names + // for nested commands, they _must_ have names specified. + + const std::string settingsJson{ R"( + { + "defaultProfile": "{6239a42c-0000-49a3-80bd-e8fdd045185c}", + "profiles": [ + { + "name": "profile0", + "guid": "{6239a42c-0000-49a3-80bd-e8fdd045185c}", + "historySize": 1, + "commandline": "cmd.exe" + }, + { + "name": "profile1", + "guid": "{6239a42c-1111-49a3-80bd-e8fdd045185c}", + "historySize": 2, + "commandline": "pwsh.exe" + }, + { + "name": "profile2", + "historySize": 3, + "commandline": "wsl.exe" + } + ], + "bindings": [ + { + "commands": [ + { + "name": "child1", + "command": { "action": "newTab", "commandline": "ssh me@first.com" } + }, + { + "name": "child2", + "command": { "action": "newTab", "commandline": "ssh me@second.com" } + } + ] + }, + ], + "schemes": [ { "name": "Campbell" } ] // This is included here to prevent settings validation errors. + })" }; + + VerifyParseSucceeded(settingsJson); + CascadiaSettings settings{}; + settings._ParseJsonString(settingsJson, false); + settings.LayerJson(settings._userSettings); + + VERIFY_ARE_EQUAL(0u, settings._warnings.size()); + VERIFY_ARE_EQUAL(3u, settings.GetProfiles().size()); + + auto& commands = settings._globals.GetCommands(); + settings._ValidateSettings(); + _logCommandNames(commands); + + VERIFY_ARE_EQUAL(0u, settings._warnings.size()); + + // Because the "parent" command didn't have a name, it couldn't be + // placed into the list of commands. It and it's children are just + // ignored. + VERIFY_ARE_EQUAL(0u, commands.Size()); + } + + void SettingsTests::TestUnbindNestedCommand() + { + // Test that layering a command with `"commands": null` set will unbind a command that already exists. + + const std::string settingsJson{ R"( + { + "defaultProfile": "{6239a42c-0000-49a3-80bd-e8fdd045185c}", + "profiles": [ + { + "name": "profile0", + "guid": "{6239a42c-0000-49a3-80bd-e8fdd045185c}", + "historySize": 1, + "commandline": "cmd.exe" + }, + { + "name": "profile1", + "guid": "{6239a42c-1111-49a3-80bd-e8fdd045185c}", + "historySize": 2, + "commandline": "pwsh.exe" + }, + { + "name": "profile2", + "historySize": 3, + "commandline": "wsl.exe" + } + ], + "bindings": [ + { + "name": "parent", + "commands": [ + { + "name": "child1", + "command": { "action": "newTab", "commandline": "ssh me@first.com" } + }, + { + "name": "child2", + "command": { "action": "newTab", "commandline": "ssh me@second.com" } + } + ] + }, + ], + "schemes": [ { "name": "Campbell" } ] // This is included here to prevent settings validation errors. + })" }; + + const std::string settings1Json{ R"( + { + "defaultProfile": "{6239a42c-0000-49a3-80bd-e8fdd045185c}", + "bindings": [ + { + "name": "parent", + "commands": null + }, + ], + })" }; + + VerifyParseSucceeded(settingsJson); + VerifyParseSucceeded(settings1Json); + + CascadiaSettings settings{}; + settings._ParseJsonString(settingsJson, false); + settings.LayerJson(settings._userSettings); + + VERIFY_ARE_EQUAL(0u, settings._warnings.size()); + VERIFY_ARE_EQUAL(3u, settings.GetProfiles().size()); + + auto& commands = settings._globals.GetCommands(); + settings._ValidateSettings(); + _logCommandNames(commands); + + VERIFY_ARE_EQUAL(0u, settings._warnings.size()); + VERIFY_ARE_EQUAL(1u, commands.Size()); + + Log::Comment(L"Layer second bit of json, to unbind the original command."); + + settings._ParseJsonString(settings1Json, false); + settings.LayerJson(settings._userSettings); + settings._ValidateSettings(); + _logCommandNames(commands); + VERIFY_ARE_EQUAL(0u, settings._warnings.size()); + VERIFY_ARE_EQUAL(0u, commands.Size()); + } + + void SettingsTests::TestRebindNestedCommand() + { + // Test that layering a command with an action set on top of a command + // with nested commands replaces the nested commands with an action. + + const std::string settingsJson{ R"( + { + "defaultProfile": "{6239a42c-0000-49a3-80bd-e8fdd045185c}", + "profiles": [ + { + "name": "profile0", + "guid": "{6239a42c-0000-49a3-80bd-e8fdd045185c}", + "historySize": 1, + "commandline": "cmd.exe" + }, + { + "name": "profile1", + "guid": "{6239a42c-1111-49a3-80bd-e8fdd045185c}", + "historySize": 2, + "commandline": "pwsh.exe" + }, + { + "name": "profile2", + "historySize": 3, + "commandline": "wsl.exe" + } + ], + "bindings": [ + { + "name": "parent", + "commands": [ + { + "name": "child1", + "command": { "action": "newTab", "commandline": "ssh me@first.com" } + }, + { + "name": "child2", + "command": { "action": "newTab", "commandline": "ssh me@second.com" } + } + ] + }, + ], + "schemes": [ { "name": "Campbell" } ] // This is included here to prevent settings validation errors. + })" }; + + const std::string settings1Json{ R"( + { + "defaultProfile": "{6239a42c-0000-49a3-80bd-e8fdd045185c}", + "bindings": [ + { + "name": "parent", + "command": "newTab" + }, + ], + })" }; + + VerifyParseSucceeded(settingsJson); + VerifyParseSucceeded(settings1Json); + + CascadiaSettings settings{}; + settings._ParseJsonString(settingsJson, false); + settings.LayerJson(settings._userSettings); + + VERIFY_ARE_EQUAL(0u, settings._warnings.size()); + VERIFY_ARE_EQUAL(3u, settings.GetProfiles().size()); + + auto& commands = settings._globals.GetCommands(); + settings._ValidateSettings(); + _logCommandNames(commands); + + VERIFY_ARE_EQUAL(0u, settings._warnings.size()); + VERIFY_ARE_EQUAL(1u, commands.Size()); + + { + winrt::hstring commandName{ L"parent" }; + auto commandProj = commands.Lookup(commandName); + VERIFY_IS_NOT_NULL(commandProj); + + winrt::com_ptr commandImpl; + commandImpl.copy_from(winrt::get_self(commandProj)); + + VERIFY_IS_TRUE(commandImpl->HasNestedCommands()); + VERIFY_ARE_EQUAL(2u, commandImpl->_subcommands.Size()); + } + + Log::Comment(L"Layer second bit of json, to unbind the original command."); + settings._ParseJsonString(settings1Json, false); + settings.LayerJson(settings._userSettings); + settings._ValidateSettings(); + _logCommandNames(commands); + VERIFY_ARE_EQUAL(0u, settings._warnings.size()); + VERIFY_ARE_EQUAL(1u, commands.Size()); + + { + winrt::hstring commandName{ L"parent" }; + auto commandProj = commands.Lookup(commandName); + + VERIFY_IS_NOT_NULL(commandProj); + auto actionAndArgs = commandProj.Action(); + VERIFY_IS_NOT_NULL(actionAndArgs); + VERIFY_ARE_EQUAL(ShortcutAction::NewTab, actionAndArgs.Action()); + const auto& realArgs = actionAndArgs.Args().try_as(); + VERIFY_IS_NOT_NULL(realArgs); + + winrt::com_ptr commandImpl; + commandImpl.copy_from(winrt::get_self(commandProj)); + + VERIFY_IS_FALSE(commandImpl->HasNestedCommands()); + } + } } diff --git a/src/cascadia/TerminalApp/AppKeyBindings.cpp b/src/cascadia/TerminalApp/AppKeyBindings.cpp index 4ac3f012c2c..166200cf132 100644 --- a/src/cascadia/TerminalApp/AppKeyBindings.cpp +++ b/src/cascadia/TerminalApp/AppKeyBindings.cpp @@ -53,6 +53,11 @@ namespace winrt::TerminalApp::implementation // - The bound keychord, if this ActionAndArgs is bound to a key, otherwise nullptr. KeyChord AppKeyBindings::GetKeyBindingForActionWithArgs(TerminalApp::ActionAndArgs const& actionAndArgs) { + if (actionAndArgs == nullptr) + { + return { nullptr }; + } + for (auto& kv : _keyShortcuts) { const auto action = kv.second.Action(); diff --git a/src/cascadia/TerminalApp/AppLogic.cpp b/src/cascadia/TerminalApp/AppLogic.cpp index 9b6c3eddc77..b11c1e2a1fd 100644 --- a/src/cascadia/TerminalApp/AppLogic.cpp +++ b/src/cascadia/TerminalApp/AppLogic.cpp @@ -38,7 +38,8 @@ static const std::array(SettingsLoadWar USES_RESOURCE(L"AtLeastOneKeybindingWarning"), USES_RESOURCE(L"TooManyKeysForChord"), USES_RESOURCE(L"MissingRequiredParameter"), - USES_RESOURCE(L"LegacyGlobalsProperty") + USES_RESOURCE(L"LegacyGlobalsProperty"), + USES_RESOURCE(L"FailedToParseCommandJson") }; static const std::array(SettingsLoadErrors::ERRORS_SIZE)> settingsLoadErrorsLabels { USES_RESOURCE(L"NoProfilesText"), diff --git a/src/cascadia/TerminalApp/CascadiaSettings.cpp b/src/cascadia/TerminalApp/CascadiaSettings.cpp index 182ae429b2d..e8b8a06e428 100644 --- a/src/cascadia/TerminalApp/CascadiaSettings.cpp +++ b/src/cascadia/TerminalApp/CascadiaSettings.cpp @@ -694,14 +694,11 @@ void CascadiaSettings::_ValidateNoGlobalsKey() // - The new settings string. std::string CascadiaSettings::_ApplyFirstRunChangesToSettingsTemplate(std::string_view settingsTemplate) const { + // We're using replace_needle_in_haystack_inplace here, because it's more + // efficient to iteratively modify a single string in-place than it is to + // keep copying over the contents and modifying a copy (which + // replace_needle_in_haystack would do). std::string finalSettings{ settingsTemplate }; - auto replace{ [](std::string& haystack, std::string_view needle, std::string_view replacement) { - auto pos{ std::string::npos }; - while ((pos = haystack.rfind(needle, pos)) != std::string::npos) - { - haystack.replace(pos, needle.size(), replacement); - } - } }; std::wstring defaultProfileGuid{ DEFAULT_WINDOWS_POWERSHELL_GUID }; if (const auto psCoreProfileGuid{ _GetProfileGuidByName(PowershellCoreProfileGenerator::GetPreferredPowershellProfileName()) }) @@ -709,14 +706,22 @@ std::string CascadiaSettings::_ApplyFirstRunChangesToSettingsTemplate(std::strin defaultProfileGuid = Utils::GuidToString(*psCoreProfileGuid); } - replace(finalSettings, "%DEFAULT_PROFILE%", til::u16u8(defaultProfileGuid)); + til::replace_needle_in_haystack_inplace(finalSettings, + "%DEFAULT_PROFILE%", + til::u16u8(defaultProfileGuid)); if (const auto appLogic{ winrt::TerminalApp::implementation::AppLogic::Current() }) { - replace(finalSettings, "%VERSION%", til::u16u8(appLogic->ApplicationVersion())); - replace(finalSettings, "%PRODUCT%", til::u16u8(appLogic->ApplicationDisplayName())); + til::replace_needle_in_haystack_inplace(finalSettings, + "%VERSION%", + til::u16u8(appLogic->ApplicationVersion())); + til::replace_needle_in_haystack_inplace(finalSettings, + "%PRODUCT%", + til::u16u8(appLogic->ApplicationDisplayName())); } - replace(finalSettings, "%COMMAND_PROMPT_LOCALIZED_NAME%", RS_A(L"CommandPromptDisplayName")); + til::replace_needle_in_haystack_inplace(finalSettings, + "%COMMAND_PROMPT_LOCALIZED_NAME%", + RS_A(L"CommandPromptDisplayName")); return finalSettings; } diff --git a/src/cascadia/TerminalApp/CascadiaSettingsSerialization.cpp b/src/cascadia/TerminalApp/CascadiaSettingsSerialization.cpp index bba84551f4d..8a4c1c50086 100644 --- a/src/cascadia/TerminalApp/CascadiaSettingsSerialization.cpp +++ b/src/cascadia/TerminalApp/CascadiaSettingsSerialization.cpp @@ -19,7 +19,6 @@ // "Generated Files" directory. using namespace ::TerminalApp; -using namespace winrt::Microsoft::Terminal::TerminalControl; using namespace winrt::TerminalApp; using namespace ::Microsoft::Console; diff --git a/src/cascadia/TerminalApp/Command.cpp b/src/cascadia/TerminalApp/Command.cpp index 6d164bc7445..10b7adba0aa 100644 --- a/src/cascadia/TerminalApp/Command.cpp +++ b/src/cascadia/TerminalApp/Command.cpp @@ -11,15 +11,36 @@ #include using namespace winrt::TerminalApp; +using namespace winrt::Windows::Foundation; using namespace ::TerminalApp; static constexpr std::string_view NameKey{ "name" }; static constexpr std::string_view IconPathKey{ "iconPath" }; static constexpr std::string_view ActionKey{ "command" }; static constexpr std::string_view ArgsKey{ "args" }; +static constexpr std::string_view IterateOnKey{ "iterateOn" }; +static constexpr std::string_view CommandsKey{ "commands" }; + +static constexpr std::string_view IterateOnProfilesValue{ "profiles" }; + +static constexpr std::string_view ProfileName{ "${profile.name}" }; namespace winrt::TerminalApp::implementation { + Command::Command() + { + _setAction(nullptr); + } + + Collections::IMapView Command::NestedCommands() + { + return _subcommands ? _subcommands.GetView() : nullptr; + } + + bool Command::HasNestedCommands() + { + return _subcommands ? _subcommands.Size() > 0 : false; + } // Function Description: // - attempt to get the name of this command from the provided json object. // * If the "name" property is a string, return that value. @@ -99,35 +120,83 @@ namespace winrt::TerminalApp::implementation { auto result = winrt::make_self(); + bool nested = false; + if (const auto iterateOnJson{ json[JsonKey(IterateOnKey)] }) + { + auto s = iterateOnJson.asString(); + if (s == IterateOnProfilesValue) + { + result->_IterateOn = ExpandCommandType::Profiles; + } + } + + // For iterable commands, we'll make another pass at parsing them once + // the json is patched. So ignore parsing sub-commands for now. Commands + // will only be marked iterable on the first pass. + if (const auto nestedCommandsJson{ json[JsonKey(CommandsKey)] }) + { + // Initialize our list of subcommands. + result->_subcommands = winrt::single_threaded_map(); + auto nestedWarnings = Command::LayerJson(result->_subcommands, nestedCommandsJson); + // It's possible that the nested commands have some warnings + warnings.insert(warnings.end(), nestedWarnings.begin(), nestedWarnings.end()); + + nested = true; + } + else if (json.isMember(JsonKey(CommandsKey))) + { + // { "name": "foo", "commands": null } will land in this case, which + // should also be used for unbinding. + return nullptr; + } + // TODO GH#6644: iconPath not implemented quite yet. Can't seem to get // the binding quite right. Additionally, do we want it to be an image, // or a FontIcon? I've had difficulty binding either/or. - if (const auto actionJson{ json[JsonKey(ActionKey)] }) + // If we're a nested command, we can ignore the current action. + if (!nested) { - auto actionAndArgs = ActionAndArgs::FromJson(actionJson, warnings); - - if (actionAndArgs) + if (const auto actionJson{ json[JsonKey(ActionKey)] }) { - result->_setAction(*actionAndArgs); + auto actionAndArgs = ActionAndArgs::FromJson(actionJson, warnings); + + if (actionAndArgs) + { + result->_setAction(*actionAndArgs); + } + else + { + // Something like + // { name: "foo", action: "unbound" } + // will _remove_ the "foo" command, by returning null here. + return nullptr; + } + + // If an iterable command doesn't have a name set, we'll still just + // try and generate a fake name for the command give the string we + // currently have. It'll probably generate something like "New tab, + // profile: ${profile.name}". This string will only be temporarily + // used internally, so there's no problem. + result->_setName(_nameFromJsonOrAction(json, actionAndArgs)); } else { - // Something like - // { name: "foo", action: "unbound" } - // will _remove_ the "foo" command, by returning null here. + // { name: "foo", action: null } will land in this case, which + // should also be used for unbinding. return nullptr; } - - result->_setName(_nameFromJsonOrAction(json, actionAndArgs)); } else { - // { name: "foo", action: null } will land in this case, which - // should also be used for unbinding. - return nullptr; + result->_setName(_nameFromJson(json)); } + // Stash the original json value in this object. If the command is + // iterable, we'll need to re-parse it later, once we know what all the + // values we can iterate on are. + result->_originalJson = json; + if (result->_Name.empty()) { return nullptr; @@ -147,7 +216,7 @@ namespace winrt::TerminalApp::implementation // - json: A Json::Value containing an array of serialized commands // Return Value: // - A vector containing any warnings detected while parsing - std::vector<::TerminalApp::SettingsLoadWarnings> Command::LayerJson(std::unordered_map& commands, + std::vector<::TerminalApp::SettingsLoadWarnings> Command::LayerJson(Windows::Foundation::Collections::IMap& commands, const Json::Value& json) { std::vector<::TerminalApp::SettingsLoadWarnings> warnings; @@ -162,7 +231,7 @@ namespace winrt::TerminalApp::implementation if (result) { // Override commands with the same name - commands.insert_or_assign(result->Name(), *result); + commands.Insert(result->Name(), *result); } else { @@ -172,7 +241,7 @@ namespace winrt::TerminalApp::implementation const auto name = _nameFromJson(value); if (!name.empty()) { - commands.erase(name); + commands.Remove(name); } } } @@ -181,4 +250,160 @@ namespace winrt::TerminalApp::implementation } return warnings; } + + // Function Description: + // - Helper to escape a string as a json string. This function will also + // trim off the leading and trailing double-quotes, so the output string + // can be inserted directly into another json blob. + // Arguments: + // - input: the string to JSON escape. + // Return Value: + // - the input string escaped properly to be inserted into another json blob. + std::string _escapeForJson(const std::string& input) + { + Json::Value inJson{ input }; + Json::StreamWriterBuilder builder; + builder.settings_["indentation"] = ""; + std::string out{ Json::writeString(builder, inJson) }; + if (out.size() >= 2) + { + // trim off the leading/trailing '"'s + auto ss{ out.substr(1, out.size() - 2) }; + return ss; + } + return out; + } + + // Method Description: + // - Iterate over all the provided commands, and recursively expand any + // commands with `iterateOn` set. If we successfully generated expanded + // commands for them, then we'll remove the original command, and add all + // the newly generated commands. + // - For more specific implementation details, see _expandCommand. + // Arguments: + // - commands: a map of commands to expand. Newly created commands will be + // inserted into the map to replace the expandable commands. + // - profiles: A list of all the profiles that this command should be expanded on. + // - warnings: If there were any warnings during parsing, they'll be + // appended to this vector. + // Return Value: + // - + void Command::ExpandCommands(Windows::Foundation::Collections::IMap& commands, + gsl::span profiles, + std::vector<::TerminalApp::SettingsLoadWarnings>& warnings) + { + std::vector commandsToRemove; + std::vector commandsToAdd; + + // First, collect up all the commands that need replacing. + for (const auto& nameAndCmd : commands) + { + auto cmd{ get_self(nameAndCmd.Value()) }; + + auto newCommands = _expandCommand(cmd, profiles, warnings); + if (newCommands.size() > 0) + { + commandsToRemove.push_back(nameAndCmd.Key()); + commandsToAdd.insert(commandsToAdd.end(), newCommands.begin(), newCommands.end()); + } + } + + // Second, remove all the commands that need to be removed. + for (auto& name : commandsToRemove) + { + commands.Remove(name); + } + + // Finally, add all the new commands. + for (auto& cmd : commandsToAdd) + { + commands.Insert(cmd.Name(), cmd); + } + } + + // Function Description: + // - Attempts to expand the given command into many commands, if the command + // has `"iterateOn": "profiles"` set. + // - If it doesn't, this function will do + // nothing and return an empty vector. + // - If it does, we're going to attempt to build a new set of commands using + // the given command as a prototype. We'll attempt to create a new command + // for each and every profile, to replace the original command. + // * For the new commands, we'll replace any instance of "${profile.name}" + // in the original json used to create this action with the name of the + // given profile. + // - If we encounter any errors while re-parsing the json with the replaced + // name, we'll just return immediately. + // - At the end, we'll return all the new commands we've build for the given command. + // Arguments: + // - expandable: the Command to potentially turn into more commands + // - profiles: A list of all the profiles that this command should be expanded on. + // - warnings: If there were any warnings during parsing, they'll be + // appended to this vector. + // Return Value: + // - and empty vector if the command wasn't expandable, otherwise a list of + // the newly-created commands. + std::vector Command::_expandCommand(Command* const expandable, + gsl::span profiles, + std::vector<::TerminalApp::SettingsLoadWarnings>& warnings) + { + std::vector newCommands; + + if (expandable->HasNestedCommands()) + { + ExpandCommands(expandable->_subcommands, profiles, warnings); + } + + if (expandable->_IterateOn == ExpandCommandType::None) + { + return newCommands; + } + + std::string errs; // This string will receive any error text from failing to parse. + std::unique_ptr reader{ Json::CharReaderBuilder::CharReaderBuilder().newCharReader() }; + + // First, get a string for the original Json::Value + auto oldJsonString = expandable->_originalJson.toStyledString(); + + if (expandable->_IterateOn == ExpandCommandType::Profiles) + { + for (const auto& p : profiles) + { + // For each profile, create a new command. This command will have: + // * the icon path and keychord text of the original command + // * the Name will have any instances of "${profile.name}" + // replaced with the profile's name + // * for the action, we'll take the original json, replace any + // instances of "${profile.name}" with the profile's name, + // then re-attempt to parse the action and args. + + // Replace all the keywords in the original json, and try and parse that + + // - Escape the profile name for JSON appropriately + auto escapedProfileName = _escapeForJson(til::u16u8(p.GetName())); + auto newJsonString = til::replace_needle_in_haystack(oldJsonString, + ProfileName, + escapedProfileName); + + // - Now, re-parse the modified value. + Json::Value newJsonValue; + const auto actualDataStart = newJsonString.data(); + const auto actualDataEnd = newJsonString.data() + newJsonString.size(); + if (!reader->parse(actualDataStart, actualDataEnd, &newJsonValue, &errs)) + { + warnings.push_back(::TerminalApp::SettingsLoadWarnings::FailedToParseCommandJson); + // If we encounter a re-parsing error, just stop processing the rest of the commands. + break; + } + + // Pass the new json back though FromJson, to get the new expanded value. + if (auto newCmd{ Command::FromJson(newJsonValue, warnings) }) + { + newCommands.push_back(*newCmd); + } + } + } + + return newCommands; + } } diff --git a/src/cascadia/TerminalApp/Command.h b/src/cascadia/TerminalApp/Command.h index b037df3beb7..700dd87e2d7 100644 --- a/src/cascadia/TerminalApp/Command.h +++ b/src/cascadia/TerminalApp/Command.h @@ -1,4 +1,4 @@ -/*++ +/*++ Copyright (c) Microsoft Corporation Licensed under the MIT license. @@ -20,17 +20,39 @@ Author(s): #include "Command.g.h" #include "TerminalWarnings.h" +#include "Profile.h" #include "..\inc\cppwinrt_utils.h" +// fwdecl unittest classes +namespace TerminalAppLocalTests +{ + class SettingsTests; + class CommandTests; +}; + namespace winrt::TerminalApp::implementation { + enum class ExpandCommandType : uint32_t + { + None = 0, + Profiles + }; + struct Command : CommandT { - Command() = default; + Command(); - static winrt::com_ptr FromJson(const Json::Value& json, std::vector<::TerminalApp::SettingsLoadWarnings>& warnings); - static std::vector<::TerminalApp::SettingsLoadWarnings> LayerJson(std::unordered_map& commands, + static winrt::com_ptr FromJson(const Json::Value& json, + std::vector<::TerminalApp::SettingsLoadWarnings>& warnings); + + static void ExpandCommands(Windows::Foundation::Collections::IMap& commands, + gsl::span profiles, + std::vector<::TerminalApp::SettingsLoadWarnings>& warnings); + + static std::vector<::TerminalApp::SettingsLoadWarnings> LayerJson(Windows::Foundation::Collections::IMap& commands, const Json::Value& json); + bool HasNestedCommands(); + Windows::Foundation::Collections::IMapView NestedCommands(); winrt::Windows::UI::Xaml::Data::INotifyPropertyChanged::PropertyChanged_revoker propertyChangedRevoker; @@ -39,6 +61,18 @@ namespace winrt::TerminalApp::implementation OBSERVABLE_GETSET_PROPERTY(winrt::TerminalApp::ActionAndArgs, Action, _PropertyChangedHandlers); OBSERVABLE_GETSET_PROPERTY(winrt::hstring, KeyChordText, _PropertyChangedHandlers); OBSERVABLE_GETSET_PROPERTY(winrt::Windows::UI::Xaml::Controls::IconSource, IconSource, _PropertyChangedHandlers, nullptr); + + GETSET_PROPERTY(ExpandCommandType, IterateOn, ExpandCommandType::None); + + private: + Json::Value _originalJson; + Windows::Foundation::Collections::IMap _subcommands{ nullptr }; + + static std::vector _expandCommand(Command* const expandable, + gsl::span profiles, + std::vector<::TerminalApp::SettingsLoadWarnings>& warnings); + friend class TerminalAppLocalTests::SettingsTests; + friend class TerminalAppLocalTests::CommandTests; }; } diff --git a/src/cascadia/TerminalApp/Command.idl b/src/cascadia/TerminalApp/Command.idl index f54de393f9e..3aa55c64420 100644 --- a/src/cascadia/TerminalApp/Command.idl +++ b/src/cascadia/TerminalApp/Command.idl @@ -14,5 +14,8 @@ namespace TerminalApp String KeyChordText; Windows.UI.Xaml.Controls.IconSource IconSource; + + Boolean HasNestedCommands(); + Windows.Foundation.Collections.IMapView NestedCommands { get; }; } } diff --git a/src/cascadia/TerminalApp/CommandPalette.cpp b/src/cascadia/TerminalApp/CommandPalette.cpp index 8325500b398..c6c2061b004 100644 --- a/src/cascadia/TerminalApp/CommandPalette.cpp +++ b/src/cascadia/TerminalApp/CommandPalette.cpp @@ -28,6 +28,8 @@ namespace winrt::TerminalApp::implementation InitializeComponent(); _filteredActions = winrt::single_threaded_observable_vector(); + _nestedActionStack = winrt::single_threaded_vector(); + _currentNestedCommands = winrt::single_threaded_vector(); _allCommands = winrt::single_threaded_vector(); _allTabActions = winrt::single_threaded_vector(); @@ -67,6 +69,7 @@ namespace winrt::TerminalApp::implementation else { _searchBox().Focus(FocusState::Programmatic); + _updateFilteredActions(); _filteredActionsView().SelectedIndex(0); } @@ -271,6 +274,29 @@ namespace winrt::TerminalApp::implementation _dispatchCommand(e.ClickedItem().try_as()); } + // Method Description: + // - This is called when the user selects a command with subcommands. It + // will update our UI to now display the list of subcommands instead, and + // clear the search text so the user can search from the new list of + // commands. + // Arguments: + // - + // Return Value: + // - + void CommandPalette::_updateUIForStackChange() + { + if (_searchBox().Text().empty()) + { + // Manually call _filterTextChanged, because setting the text to the + // empty string won't update it for us (as it won't actually change value.) + _filterTextChanged(nullptr, nullptr); + } + + // Changing the value of the search box will trigger _filterTextChanged, + // which will cause us to refresh the list of filterable commands. + _searchBox().Text(L""); + } + // Method Description: // - Retrieve the list of commands that we should currently be filtering. // * If the user has command with subcommands, this will return that command's subcommands. @@ -285,6 +311,11 @@ namespace winrt::TerminalApp::implementation switch (_currentMode) { case CommandPaletteMode::ActionMode: + if (_nestedActionStack.Size() > 0) + { + return _currentNestedCommands; + } + return _allCommands; case CommandPaletteMode::TabSwitcherMode: return _allTabActions; @@ -306,20 +337,44 @@ namespace winrt::TerminalApp::implementation { if (command) { - // Close before we dispatch so that actions that open the command - // palette like the Tab Switcher will be able to have the last laugh. - _close(); - - const auto actionAndArgs = command.Action(); - _dispatch.DoAction(actionAndArgs); - - TraceLoggingWrite( - g_hTerminalAppProvider, // handle to TerminalApp tracelogging provider - "CommandPaletteDispatchedAction", - TraceLoggingDescription("Event emitted when the user selects an action in the Command Palette"), - TraceLoggingUInt32(_searchBox().Text().size(), "SearchTextLength", "Number of characters in the search string"), - TraceLoggingKeyword(MICROSOFT_KEYWORD_MEASURES), - TelemetryPrivacyDataTag(PDT_ProductAndServicePerformance)); + if (command.HasNestedCommands()) + { + // If this Command had subcommands, then don't dispatch the + // action. Instead, display a new list of commands for the user + // to pick from. + _nestedActionStack.Append(command); + _currentNestedCommands.Clear(); + for (const auto& nameAndCommand : command.NestedCommands()) + { + _currentNestedCommands.Append(nameAndCommand.Value()); + } + + _updateUIForStackChange(); + } + else + { + // First stash the search text length, because _close will clear this. + const auto searchTextLength = _searchBox().Text().size(); + + // An action from the root command list has depth=0 + const auto nestedCommandDepth = _nestedActionStack.Size(); + + // Close before we dispatch so that actions that open the command + // palette like the Tab Switcher will be able to have the last laugh. + _close(); + + const auto actionAndArgs = command.Action(); + _dispatch.DoAction(actionAndArgs); + + TraceLoggingWrite( + g_hTerminalAppProvider, // handle to TerminalApp tracelogging provider + "CommandPaletteDispatchedAction", + TraceLoggingDescription("Event emitted when the user selects an action in the Command Palette"), + TraceLoggingUInt32(searchTextLength, "SearchTextLength", "Number of characters in the search string"), + TraceLoggingUInt32(nestedCommandDepth, "NestedCommandDepth", "the depth in the tree of commands for the dispatched action"), + TraceLoggingKeyword(MICROSOFT_KEYWORD_MEASURES), + TelemetryPrivacyDataTag(PDT_ProductAndServicePerformance)); + } } } @@ -707,6 +762,9 @@ namespace winrt::TerminalApp::implementation // Clear the text box each time we close the dialog. This is consistent with VsCode. _searchBox().Text(L""); + + _nestedActionStack.Clear(); + _currentNestedCommands.Clear(); } // Method Description: diff --git a/src/cascadia/TerminalApp/CommandPalette.h b/src/cascadia/TerminalApp/CommandPalette.h index b062b42003c..641f0606f19 100644 --- a/src/cascadia/TerminalApp/CommandPalette.h +++ b/src/cascadia/TerminalApp/CommandPalette.h @@ -37,9 +37,11 @@ namespace winrt::TerminalApp::implementation private: friend struct CommandPaletteT; // for Xaml to bind events + Windows::Foundation::Collections::IVector _allCommands{ nullptr }; + Windows::Foundation::Collections::IVector _currentNestedCommands{ nullptr }; Windows::Foundation::Collections::IObservableVector _filteredActions{ nullptr }; + Windows::Foundation::Collections::IVector _nestedActionStack{ nullptr }; - Windows::Foundation::Collections::IVector _allCommands{ nullptr }; winrt::TerminalApp::ShortcutActionDispatch _dispatch; Windows::Foundation::Collections::IVector _commandsToFilter(); @@ -53,6 +55,8 @@ namespace winrt::TerminalApp::implementation void _keyUpHandler(Windows::Foundation::IInspectable const& sender, Windows::UI::Xaml::Input::KeyRoutedEventArgs const& e); + void _updateUIForStackChange(); + void _rootPointerPressed(Windows::Foundation::IInspectable const& sender, Windows::UI::Xaml::Input::PointerRoutedEventArgs const& e); void _backdropPointerPressed(Windows::Foundation::IInspectable const& sender, Windows::UI::Xaml::Input::PointerRoutedEventArgs const& e); @@ -61,7 +65,9 @@ namespace winrt::TerminalApp::implementation void _selectNextItem(const bool moveDown); void _updateFilteredActions(); + std::vector _collectFilteredActions(); + static int _getWeight(const winrt::hstring& searchText, const winrt::hstring& name); void _close(); diff --git a/src/cascadia/TerminalApp/GlobalAppSettings.cpp b/src/cascadia/TerminalApp/GlobalAppSettings.cpp index afeca688c6d..b9d6175df01 100644 --- a/src/cascadia/TerminalApp/GlobalAppSettings.cpp +++ b/src/cascadia/TerminalApp/GlobalAppSettings.cpp @@ -60,6 +60,7 @@ GlobalAppSettings::GlobalAppSettings() : _WordDelimiters{ DEFAULT_WORD_DELIMITERS }, _DebugFeaturesEnabled{ debugFeaturesDefault } { + _commands = winrt::single_threaded_map(); } GlobalAppSettings::~GlobalAppSettings() @@ -230,7 +231,12 @@ std::vector GlobalAppSettings::GetKeybindings return _keybindingsWarnings; } -const std::unordered_map& GlobalAppSettings::GetCommands() const noexcept +const winrt::Windows::Foundation::Collections::IMap& GlobalAppSettings::GetCommands() const noexcept +{ + return _commands; +} + +winrt::Windows::Foundation::Collections::IMap& GlobalAppSettings::GetCommands() noexcept { return _commands; } diff --git a/src/cascadia/TerminalApp/GlobalAppSettings.h b/src/cascadia/TerminalApp/GlobalAppSettings.h index ba51109fa0b..6a72d0ad857 100644 --- a/src/cascadia/TerminalApp/GlobalAppSettings.h +++ b/src/cascadia/TerminalApp/GlobalAppSettings.h @@ -50,7 +50,8 @@ class TerminalApp::GlobalAppSettings final std::vector GetKeybindingsWarnings() const; - const std::unordered_map& GetCommands() const noexcept; + const winrt::Windows::Foundation::Collections::IMap& GetCommands() const noexcept; + winrt::Windows::Foundation::Collections::IMap& GetCommands() noexcept; // These are implemented manually to handle the string/GUID exchange // by higher layers in the app. @@ -89,7 +90,7 @@ class TerminalApp::GlobalAppSettings final std::vector<::TerminalApp::SettingsLoadWarnings> _keybindingsWarnings; std::unordered_map _colorSchemes; - std::unordered_map _commands; + winrt::Windows::Foundation::Collections::IMap _commands; friend class TerminalAppLocalTests::SettingsTests; friend class TerminalAppLocalTests::ColorSchemeTests; diff --git a/src/cascadia/TerminalApp/Resources/en-US/Resources.resw b/src/cascadia/TerminalApp/Resources/en-US/Resources.resw index c8951fac9f2..9d9cd244648 100644 --- a/src/cascadia/TerminalApp/Resources/en-US/Resources.resw +++ b/src/cascadia/TerminalApp/Resources/en-US/Resources.resw @@ -222,6 +222,10 @@ For more info, see this web page. + + Failed to expand a command with "iterateOn" set. This command will be ignored. + {Locked="\"iterateOn\""} + An optional command, with arguments, to be spawned in the new tab or pane diff --git a/src/cascadia/TerminalApp/TerminalPage.cpp b/src/cascadia/TerminalApp/TerminalPage.cpp index 22547b2e005..abd7768afc9 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -51,6 +51,60 @@ namespace winrt::TerminalApp::implementation InitializeComponent(); } + // Function Description: + // - Recursively check our commands to see if there's a keybinding for + // exactly their action. If there is, label that command with the text + // corresponding to that key chord. + // - Will recurse into nested commands as well. + // Arguments: + // - settings: The settings who's keybindings we should use to look up the key chords from + // - commands: The list of commands to label. + static void _recursiveUpdateCommandKeybindingLabels(std::shared_ptr<::TerminalApp::CascadiaSettings> settings, + Windows::Foundation::Collections::IMapView commands) + { + for (const auto& nameAndCmd : commands) + { + const auto& command = nameAndCmd.Value(); + // If there's a keybinding that's bound to exactly this command, + // then get the string for that keychord and display it as a + // part of the command in the UI. Each Command's KeyChordText is + // unset by default, so we don't need to worry about clearing it + // if there isn't a key associated with it. + auto keyChord{ settings->GetKeybindings().GetKeyBindingForActionWithArgs(command.Action()) }; + + if (keyChord) + { + command.KeyChordText(KeyChordSerialization::ToString(keyChord)); + } + if (command.HasNestedCommands()) + { + _recursiveUpdateCommandKeybindingLabels(settings, command.NestedCommands()); + } + } + } + + static void _recursiveUpdateCommandIcons(Windows::Foundation::Collections::IMapView commands) + { + for (const auto& nameAndCmd : commands) + { + const auto& command = nameAndCmd.Value(); + // Set the default IconSource to a BitmapIconSource with a null source + // (instead of just nullptr) because there's a really weird crash when swapping + // data bound IconSourceElements in a ListViewTemplate (i.e. CommandPalette). + // Swapping between nullptr IconSources and non-null IconSources causes a crash + // to occur, but swapping between IconSources with a null source and non-null IconSources + // work perfectly fine :shrug:. + winrt::Windows::UI::Xaml::Controls::BitmapIconSource icon; + icon.UriSource(nullptr); + command.IconSource(icon); + + if (command.HasNestedCommands()) + { + _recursiveUpdateCommandIcons(command.NestedCommands()); + } + } + } + winrt::fire_and_forget TerminalPage::SetSettings(std::shared_ptr<::TerminalApp::CascadiaSettings> settings, bool needRefreshUI) { @@ -64,36 +118,7 @@ namespace winrt::TerminalApp::implementation co_await winrt::resume_foreground(Dispatcher()); if (auto page{ weakThis.get() }) { - // Update the command palette when settings reload - auto commandsCollection = winrt::single_threaded_vector(); - for (auto& nameAndCommand : _settings->GlobalSettings().GetCommands()) - { - auto command = nameAndCommand.second; - - // If there's a keybinding that's bound to exactly this command, - // then get the string for that keychord and display it as a - // part of the command in the UI. Each Command's KeyChordText is - // unset by default, so we don't need to worry about clearing it - // if there isn't a key associated with it. - auto keyChord{ _settings->GetKeybindings().GetKeyBindingForActionWithArgs(command.Action()) }; - if (keyChord) - { - command.KeyChordText(KeyChordSerialization::ToString(keyChord)); - } - - // Set the default IconSource to a BitmapIconSource with a null source - // (instead of just nullptr) because there's a really weird crash when swapping - // data bound IconSourceElements in a ListViewTemplate (i.e. CommandPalette). - // Swapping between nullptr IconSources and non-null IconSources causes a crash - // to occur, but swapping between IconSources with a null source and non-null IconSources - // work perfectly fine :shrug:. - winrt::Windows::UI::Xaml::Controls::BitmapIconSource icon; - icon.UriSource(nullptr); - command.IconSource(icon); - - commandsCollection.Append(command); - } - CommandPalette().SetCommands(commandsCollection); + _UpdateCommandsForPalette(); } } @@ -1807,10 +1832,12 @@ namespace winrt::TerminalApp::implementation } // Method Description: - // - Responds to changes in the TabView's item list by changing the tabview's - // visibility. This method is also invoked when tabs are dragged / dropped as part of tab reordering - // and this method hands that case as well in concert with TabDragStarting and TabDragCompleted handlers - // that are set up in TerminalPage::Create() + // - Responds to changes in the TabView's item list by changing the + // tabview's visibility. + // - This method is also invoked when tabs are dragged / dropped as part of + // tab reordering and this method hands that case as well in concert with + // TabDragStarting and TabDragCompleted handlers that are set up in + // TerminalPage::Create() // Arguments: // - sender: the control that originated this event // - eventArgs: the event's constituent arguments @@ -1828,6 +1855,10 @@ namespace winrt::TerminalApp::implementation _rearrangeTo = eventArgs.Index(); } } + else + { + _UpdateCommandsForPalette(); + } _UpdateTabView(); } @@ -2004,6 +2035,54 @@ namespace winrt::TerminalApp::implementation _alwaysOnTopChangedHandlers(*this, nullptr); } + // Method Description: + // - Takes a mapping of names->commands and expands them + // Arguments: + // - + // Return Value: + // - + IMap TerminalPage::_ExpandCommands(IMapView commandsToExpand, + gsl::span profiles) + { + std::vector<::TerminalApp::SettingsLoadWarnings> warnings; + IMap copyOfCommands = winrt::single_threaded_map(); + for (const auto& nameAndCommand : commandsToExpand) + { + copyOfCommands.Insert(nameAndCommand.Key(), nameAndCommand.Value()); + } + + Command::ExpandCommands(copyOfCommands, + profiles, + warnings); + + return copyOfCommands; + } + // Method Description: + // - Repopulates the list of commands in the command palette with the + // current commands in the settings. Also updates the keybinding labels to + // reflect any matching keybindings. + // Arguments: + // - + // Return Value: + // - + void TerminalPage::_UpdateCommandsForPalette() + { + IMap copyOfCommands = _ExpandCommands(_settings->GlobalSettings().GetCommands().GetView(), + _settings->GetProfiles()); + + _recursiveUpdateCommandKeybindingLabels(_settings, copyOfCommands.GetView()); + _recursiveUpdateCommandIcons(copyOfCommands.GetView()); + + // Update the command palette when settings reload + auto commandsCollection = winrt::single_threaded_vector(); + for (const auto& nameAndCommand : copyOfCommands) + { + commandsCollection.Append(nameAndCommand.Value()); + } + + CommandPalette().SetCommands(commandsCollection); + } + // Method Description: // - Sets the initial actions to process on startup. We'll make a copy of // this list, and process these actions when we're loaded. diff --git a/src/cascadia/TerminalApp/TerminalPage.h b/src/cascadia/TerminalApp/TerminalPage.h index d89f96ca931..36c8d1328c6 100644 --- a/src/cascadia/TerminalApp/TerminalPage.h +++ b/src/cascadia/TerminalApp/TerminalPage.h @@ -16,6 +16,7 @@ namespace TerminalAppLocalTests { class TabTests; + class SettingsTests; }; namespace winrt::TerminalApp::implementation @@ -134,6 +135,10 @@ namespace winrt::TerminalApp::implementation void _UpdateTabIcon(Tab& tab); void _UpdateTabView(); void _UpdateTabWidthMode(); + void _UpdateCommandsForPalette(); + static winrt::Windows::Foundation::Collections::IMap _ExpandCommands(Windows::Foundation::Collections::IMapView commandsToExpand, + gsl::span profiles); + void _DuplicateTabViewItem(); void _RemoveTabViewItem(const Microsoft::UI::Xaml::Controls::TabViewItem& tabViewItem); void _RemoveTabViewItemByIndex(uint32_t tabIndex); @@ -236,6 +241,7 @@ namespace winrt::TerminalApp::implementation #pragma endregion friend class TerminalAppLocalTests::TabTests; + friend class TerminalAppLocalTests::SettingsTests; }; } diff --git a/src/cascadia/TerminalApp/TerminalWarnings.h b/src/cascadia/TerminalApp/TerminalWarnings.h index aa0bb1b869d..3b0be2b4022 100644 --- a/src/cascadia/TerminalApp/TerminalWarnings.h +++ b/src/cascadia/TerminalApp/TerminalWarnings.h @@ -30,6 +30,7 @@ namespace TerminalApp TooManyKeysForChord = 6, MissingRequiredParameter = 7, LegacyGlobalsProperty = 8, + FailedToParseCommandJson = 9, WARNINGS_SIZE // IMPORTANT: This MUST be the last value in this enum. It's an unused placeholder. }; diff --git a/src/cascadia/TerminalApp/lib/pch.h b/src/cascadia/TerminalApp/lib/pch.h index 4cb7a49f1e3..dcac6c4e1ab 100644 --- a/src/cascadia/TerminalApp/lib/pch.h +++ b/src/cascadia/TerminalApp/lib/pch.h @@ -35,6 +35,7 @@ #include #include #include +#include #include #include #include diff --git a/src/cascadia/WindowsTerminal/AppHost.cpp b/src/cascadia/WindowsTerminal/AppHost.cpp index 1f0fb413e26..0590243e6eb 100644 --- a/src/cascadia/WindowsTerminal/AppHost.cpp +++ b/src/cascadia/WindowsTerminal/AppHost.cpp @@ -6,9 +6,10 @@ #include "../types/inc/Viewport.hpp" #include "../types/inc/utils.hpp" #include "../types/inc/User32Utils.hpp" - #include "resource.h" +#include + using namespace winrt::Windows::UI; using namespace winrt::Windows::UI::Composition; using namespace winrt::Windows::UI::Xaml; diff --git a/src/cascadia/WindowsTerminal/AppHost.h b/src/cascadia/WindowsTerminal/AppHost.h index 4e072411de8..d2b6dd77c69 100644 --- a/src/cascadia/WindowsTerminal/AppHost.h +++ b/src/cascadia/WindowsTerminal/AppHost.h @@ -3,7 +3,6 @@ #include "pch.h" -#include #include #include "NonClientIslandWindow.h" diff --git a/src/cascadia/WindowsTerminal/IslandWindow.h b/src/cascadia/WindowsTerminal/IslandWindow.h index cd593e6663f..29d24156f55 100644 --- a/src/cascadia/WindowsTerminal/IslandWindow.h +++ b/src/cascadia/WindowsTerminal/IslandWindow.h @@ -3,7 +3,6 @@ #include "pch.h" #include "BaseWindow.h" -#include #include #include "../../cascadia/inc/cppwinrt_utils.h" diff --git a/src/inc/til.h b/src/inc/til.h index 36e10ad0e6f..1816d2e0736 100644 --- a/src/inc/til.h +++ b/src/inc/til.h @@ -17,6 +17,7 @@ #include "til/u8u16convert.h" #include "til/spsc.h" #include "til/coalesce.h" +#include "til/replace.h" namespace til // Terminal Implementation Library. Also: "Today I Learned" { diff --git a/src/inc/til/replace.h b/src/inc/til/replace.h new file mode 100644 index 00000000000..466eb035e21 --- /dev/null +++ b/src/inc/til/replace.h @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +#pragma once + +namespace til +{ + namespace details + { + template + struct view_type_oracle + { + }; + + template<> + struct view_type_oracle + { + using type = std::string_view; + }; + + template<> + struct view_type_oracle + { + using type = std::wstring_view; + }; + + } + + // Method Description: + // - This is a function for finding all occurences of a given string + // `needle` in a larger string `haystack`, and replacing them with the + // string `replacement`. + // - This find/replace is done in-place, leaving `haystack` modified as a result. + // Arguments: + // - haystack: The string to find and replace in + // - needle: The string to search for + // - replacement: The string to replace `needle` with + // Return Value: + // - + template + void replace_needle_in_haystack_inplace(T& haystack, + const typename details::view_type_oracle::type& needle, + const typename details::view_type_oracle::type& replacement) + { + auto pos{ T::npos }; + while ((pos = haystack.rfind(needle, pos)) != T::npos) + { + haystack.replace(pos, needle.size(), replacement); + } + } + + // Method Description: + // - This is a function for finding all occurences of a given string + // `needle` in a larger string `haystack`, and replacing them with the + // string `replacement`. + // - This find/replace is done on a copy of `haystack`, leaving `haystack` + // unmodified, and returning a new string. + // Arguments: + // - haystack: The string to search for `needle` in. + // - needle: The string to search for + // - replacement: The string to replace `needle` with + // Return Value: + // - a copy of `haystack` with all instances of `needle` replaced with `replacement`.` + template + T replace_needle_in_haystack(const T& haystack, + const typename details::view_type_oracle::type& needle, + const typename details::view_type_oracle::type& replacement) + { + std::basic_string result{ haystack }; + replace_needle_in_haystack_inplace(result, needle, replacement); + return result; + } +} diff --git a/src/til/ut_til/ReplaceTests.cpp b/src/til/ut_til/ReplaceTests.cpp new file mode 100644 index 00000000000..827c6872cdd --- /dev/null +++ b/src/til/ut_til/ReplaceTests.cpp @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +#include "precomp.h" +#include "WexTestClass.h" + +using namespace WEX::Common; +using namespace WEX::Logging; +using namespace WEX::TestExecution; + +class ReplaceTests +{ + TEST_CLASS(ReplaceTests); + + TEST_METHOD(ReplaceStrings); + TEST_METHOD(ReplaceStringAndViews); + TEST_METHOD(ReplaceStringsInplace); + TEST_METHOD(ReplaceStringAndViewsInplace); + + TEST_METHOD(ReplaceWstrings); + TEST_METHOD(ReplaceWstringAndViews); + TEST_METHOD(ReplaceWstringsInplace); + TEST_METHOD(ReplaceWstringAndViewsInplace); + + // There are explicitly no winrt::hstring tests here, because it's capital-H + // hard to get the winrt hstring header included in this project without + // pulling in all of the winrt machinery. +}; + +void ReplaceTests::ReplaceStrings() +{ + std::string foo{ "foo" }; + + auto temp1 = til::replace_needle_in_haystack(foo, "f", "b"); + VERIFY_ARE_EQUAL("boo", temp1); + + auto temp2 = til::replace_needle_in_haystack(temp1, "o", "00"); + VERIFY_ARE_EQUAL("b0000", temp2); +} + +void ReplaceTests::ReplaceStringAndViews() +{ + std::string foo{ "foo" }; + std::string_view f{ "f" }; + std::string_view b{ "b" }; + std::string_view o{ "o" }; + std::string_view zeroZero{ "00" }; + + auto temp1 = til::replace_needle_in_haystack(foo, f, b); + VERIFY_ARE_EQUAL("boo", temp1); + + auto temp2 = til::replace_needle_in_haystack(temp1, o, zeroZero); + VERIFY_ARE_EQUAL("b0000", temp2); +} + +void ReplaceTests::ReplaceStringsInplace() +{ + std::string foo{ "foo" }; + + til::replace_needle_in_haystack_inplace(foo, "f", "b"); + VERIFY_ARE_EQUAL("boo", foo); + + til::replace_needle_in_haystack_inplace(foo, "o", "00"); + VERIFY_ARE_EQUAL("b0000", foo); +} + +void ReplaceTests::ReplaceStringAndViewsInplace() +{ + std::string foo{ "foo" }; + std::string_view f{ "f" }; + std::string_view b{ "b" }; + std::string_view o{ "o" }; + std::string_view zeroZero{ "00" }; + + til::replace_needle_in_haystack_inplace(foo, f, b); + VERIFY_ARE_EQUAL("boo", foo); + + til::replace_needle_in_haystack_inplace(foo, o, zeroZero); + VERIFY_ARE_EQUAL("b0000", foo); +} + +void ReplaceTests::ReplaceWstrings() +{ + std::wstring foo{ L"foo" }; + + auto temp1 = til::replace_needle_in_haystack(foo, L"f", L"b"); + VERIFY_ARE_EQUAL(L"boo", temp1); + + auto temp2 = til::replace_needle_in_haystack(temp1, L"o", L"00"); + VERIFY_ARE_EQUAL(L"b0000", temp2); +} + +void ReplaceTests::ReplaceWstringAndViews() +{ + std::wstring foo{ L"foo" }; + std::wstring_view f{ L"f" }; + std::wstring_view b{ L"b" }; + std::wstring_view o{ L"o" }; + std::wstring_view zeroZero{ L"00" }; + + auto temp1 = til::replace_needle_in_haystack(foo, f, b); + VERIFY_ARE_EQUAL(L"boo", temp1); + + auto temp2 = til::replace_needle_in_haystack(temp1, o, zeroZero); + VERIFY_ARE_EQUAL(L"b0000", temp2); +} + +void ReplaceTests::ReplaceWstringsInplace() +{ + std::wstring foo{ L"foo" }; + + til::replace_needle_in_haystack_inplace(foo, L"f", L"b"); + VERIFY_ARE_EQUAL(L"boo", foo); + + til::replace_needle_in_haystack_inplace(foo, L"o", L"00"); + VERIFY_ARE_EQUAL(L"b0000", foo); +} + +void ReplaceTests::ReplaceWstringAndViewsInplace() +{ + std::wstring foo{ L"foo" }; + std::wstring_view f{ L"f" }; + std::wstring_view b{ L"b" }; + std::wstring_view o{ L"o" }; + std::wstring_view zeroZero{ L"00" }; + + til::replace_needle_in_haystack_inplace(foo, f, b); + VERIFY_ARE_EQUAL(L"boo", foo); + + til::replace_needle_in_haystack_inplace(foo, o, zeroZero); + VERIFY_ARE_EQUAL(L"b0000", foo); +} diff --git a/src/til/ut_til/til.unit.tests.vcxproj b/src/til/ut_til/til.unit.tests.vcxproj index 52a07246b81..d25d9147f02 100644 --- a/src/til/ut_til/til.unit.tests.vcxproj +++ b/src/til/ut_til/til.unit.tests.vcxproj @@ -19,6 +19,7 @@ + Create