18 KiB
author | created on | last updated | issue id |
---|---|---|---|
Mike Griese @zadjii-msft | 2020-5-13 | 2022-11-18 | 1571 |
New Tab Menu Customization
Abstract
Many users have lots and lots of profiles that they use. Some of these profiles the user might not use that frequently. When that happens, the new tab dropdown can become quite cluttered.
A common ask is for the ability to reorder and reorganize this dropdown. This spec provides a design for how the user might be able to specify the customization in their settings.
Inspiration
Largely, this spec was inspired by discussion in #1571 and the many linked threads.
Solution Design
This design proposes adding a new setting "newTabMenu"
. When unset, (the
default), the new tab menu is populated with all the profiles, in the order they
appear in the users settings file. When set, this enables the user to control
the appearance of the new tab dropdown. Let's take a look at an example:
{
"profiles":{ ... },
"newTabMenu": [
{ "type":"profile", "profile": "cmd" },
{ "type":"profile", "profile": "Windows PowerShell" },
{ "type":"separator" },
{
"type":"folder",
"name": "ssh",
"icon": "C:\\path\\to\\icon.png",
"entries":[
{ "type":"profile", "profile": "Host 1" },
{ "type":"profile", "profile": "8.8.8.8" },
{ "type":"profile", "profile": "Host 2" }
]
},
{ "type":"separator" },
{ "type":"profile", "profile": "Ubuntu-18.04" },
{ "type":"profile", "profile": "Fedora" }
]
}
If a user were to use this as their new tab menu, that they would get is a menu that looks like this:
fig 1: A very rough mockup of what this feature might look like
There are five type
s of objects in this menu:
"type":"profile"
: This is a profile. Clicking on this entry will open a new tab, with that profile. The profile is identified with the"profile"
parameter, which accepts either a profilename
or GUID. The icon for this entry will be the profile's icon, and the text on the entry will be the profile's name."type":"separator"
: This represents a XAMLMenuFlyoutSeparator
, enabling the user to visually space out entries."type":"folder"
: This represents a nested menu of entries.-
The
"name"
property provides a string of text to display for the group. -
The
"icon"
property provides a path to a image to use as the icon. This property is optional. -
The
"entries"
property specifies a list of menu entries that will appear nested under this entry. This can contain other"type":"folder"
groups as well! -
The
"inline"
property accepts two valuesauto
: When the folder only has one entry in it, don't actually create a nested layer to then menu. Just place the single entry in the layer that folder would occupy. (Useful for dynamic profile sources with only a single entry).never
: (default) Always create a nested entry, even for a single sub-item.
-
The
allowEmpty
property will force this entry to show up in the menu, even if it doesn't have any profiles in it. This defaults tofalse
, meaning that folders without any entries in them will just be ignored when generating the menu. This will be more useful with thematchProfile
entry, below.When this is true, and the folder is empty, we should add a placeholder
<empty>
entry to the menu, to indicate that no profiles were in that folder.- This setting is probably pretty niche, and not a requirement. More of a theoretical suggestion than anything.
- In the case of no entries for this folder, we should make sure to also
reflect the
inline
property:allowEmpty:true
,inline:auto
: just ignore the entry at all. Don't add a placeholder to the parent list.allowEmpty:true
,inline:never
: Add a nested entry, with an<empty>
placeholder.allowEmpty:false
,inline:auto
: just ignore the entry at all. Don't add a placeholder to the parent list.allowEmpty:false
,inline:never
: just ignore the entry at all. Don't add a placeholder to the parent list.
-
"type":"action"
: This represents a menu entry that should execute a specificShortcutAction
.- the
id
property will specify the global action ID (see #6899, #7175) to identify the action to perform when the user selects the entry. Actions with invalid IDs will be ignored and omitted from the list. - The text for this entry will be the action's label (which is
either provided as the
"name"
in the global list of actions, or the generated name if noname
was provided) - The icon for this entry will similarly re-use the action's
icon
.
- the
"type":"remainingProfiles"
: This is a special type of entry that will be expanded to contain one"type":"profile"
entry for every profile that was not already listed in the menu. This will allow users to add one entry for just "all the profiles they haven't manually added to the menu".- This type of entry can only be specified once - trying to add it to the menu
twice will raise a warning, and ignore all but the first
remainingProfiles
entry. - This type of entry can also be set inside a
folder
entry, allowing users to highlight only a couple profiles in the top-level of the menu, but enabling all other profiles to also be accessible. - The "name" of these entries will simply be the name of the profile
- The "icon" of these entries will simply be the profile's icon
- This won't include any profiles that have been included via
matchProfile
entries (below)
- This type of entry can only be specified once - trying to add it to the menu
twice will raise a warning, and ignore all but the first
"type": "matchProfile"
: Expands to all the profiles that match a given string. This lets the user easily specify a whole collection of profiles for a folder, without needing to add them all manually."name"
,"commandline"
or"source"
: These three properties are used to filter the list of profiles, based on the matching property in the profile itself. The value is a string to compare with the corresponding property in the profile. A full string comparison is done - not a regex or partial string match.
The "default" new tab menu could be imagined as the following blob of json:
{
"newTabMenu": [
{ "type":"remainingProfiles" }
]
}
Alternatively, we could consider something like the following. This would place CMD, PowerShell, and all PowerShell cores in the root at the top, followed by nested entries for each subsequent dynamic profile generator.
{
"newTabMenu": [
{ "type":"profile", "profile": "cmd" },
{ "type":"profile", "profile": "Windows PowerShell" },
{ "type": "matchProfile", "source": "Microsoft.Terminal.PowerShellCore" }
{
"type": "folder",
"name": "WSL",
"entries": [ { "type": "matchProfile", "source": "Microsoft.Terminal.Wsl" } ]
},
{
"type": "folder",
"name": "Visual Studio",
"entries": [ { "type": "matchProfile", "source": "Microsoft.Terminal.VisualStudio" } ]
},
// ... etc for other profile generators
{ "type": "remainingProfiles" }
]
}
I might only recommend that for userDefaults.json
, which is the json files
used as a template for a user's new settings file. This would prevent us from
moving the user's cheese too much, if they're already using the Terminal and
happy with their list as is. Especially consider someone who's default profile
is a WSL distro, which would now need two clicks to get to.
note: We will also want to support the same
{ "key": "SomeResourceString"}
syntax used by the Command Palette commands for specifying localizable names, if we chose to pursue this route.
Other considerations
Also considered during the investigation for this feature was re-using the list of profiles to expose the structure of the new tab menu. For example, doing something like:
"profiles": {
"defaults": {},
"list":
[
{ "name": "cmd" },
{ "name": "powershell" },
{ "type": "separator" },
{
"type": "folder" ,
"profiles": [
{ "name": "ubuntu" }
]
}
]
}
This option was not pursued because we felt that it needlessly complicated the
contents of the list of profiles objects. We'd rather have the profiles
list
exclusively contain Profile
objects, and have other elements of the json
refer to those profiles. What if someone would like to have an action that
opened a new tab with profile index 4, and then they set that action as entry 4
in the profile's list? That would certainly be some sort of unexpected behavior.
Additionally, what if someone wants to have an entry that opens a tab with one pane with one profile in it, and another pane with different profile in it? Or what if they want the same profile to appear twice in the menu?
By overloading the structure of the profiles
list, we're forcing all other
consumers of the list of profiles to care about the structure of the elements of
the list. These other consumers should only really care about the list of
profiles, and not necessarily how they're structured in the new tab dropdown.
Furthermore, it complicates the list of profiles, by adding actions intermixed
with the profiles.
The design chosen in this spec more cleanly separates the responsibilities of the list of profiles and the contents of the new tab menu. This way, each object can be defined independent of the structure of the other.
Regarding implementation of matchProfile
entries: In order to build the menu,
we'll evaluate the entries in the following order:
- all explicit
profile
entries - then all
matchProfile
entries, using profiles not already specified - then expand out
remainingProfiles
with anything not found above.
As an example:
{
"newTabMenu": [
{ "type": "matchProfile", "source": "Microsoft.Terminal.Wsl" }
{
"type": "folder",
"name": "WSLs",
"entries": [ { "type": "matchProfile", "source": "Microsoft.Terminal.Wsl" } ]
},
{ "type": "remainingProfiles" }
]
}
For profiles { "Profile A", "Profile B (WSL)", "Profile C (WSL)" }, This would expand to:
New Tab Button ▽
├─ Profile A
├─ Profile B (WSL)
├─ Profile C (WSL)
└─ WSLs
└─ Profile B (WSL)
└─ Profile C (WSL)
UI/UX Design
See the above figure 1.
The profile's icon
will also appear as the icon on profile
entries. If
there's a keybinding bound to open a new tab with that profile, then that will
also be added to the MenuFlyoutItem
as the accelerator text, similar to the
text we have nowadays.
Beneath the list of profiles will always be the same "Settings", "Feedback"
and "About" entries, separated by a MenuFlyoutSeparator
. This is consistent
with the UI as it exists with no customization. These entries cannot be removed
with this feature, only the list of profiles customized.
Capabilities
Accessibility
This menu will be added to the XAML tree in the same fashion as the current new tab flyout, so there should be no dramatic change here.
Security
(no change expected)
Reliability
(no change expected)
Compatibility
(no change expected)
Performance, Power, and Efficiency
(no change expected)
Potential Issues
Currently, the openTab
and splitPane
keybindings will accept a index
parameter to say either:
- "Create a new tab/pane with the N'th profile"
- "Create a new tab/pane with the profile at index N in the new tab dropdown".
These two were previously synonymous, as the N'th profile was always the N'th in the dropdown. However, with this change, we'll be changing the meaning of that argument to mean explicitly the first option - "Open a tab/pane with the N'th profile".
A previous version of this spec considered changing the meaning of that parameter to mean "open the entry at index N", the second option. However, in Command Palette, Addendum 1, we found that naming that command would become unnecessarily complex.
To cover that above scenario, we could consider adding an index
parameter to
the openNewTabDropdown
action. If specified, that would open either the N'th
action in the dropdown (ignoring separators), or open the dropdown with the n'th
item selected.
The N'th entry in the menu won't always be a profile: it might be a folder with more options, or it might be an action (that might not be opening a new tab/pane at all).
Given all the above scenarios, openNewTabDropdown
with an "index":N
parameter will behave in the following ways. If the Nth top-level entry in the
new tab menu is a:
"type":"profile"
: perform thenewTab
orsplitPane
action with that profile."type":"folder"
: Focus the first element in the sub menu, so the user could navigate it with the keyboard."type":"separator"
: Ignore these when counting top-level entries."type":"action"
: Perform the action.
So for example:
New Tab Button ▽
├─ Folder 1
│ └─ Profile A
│ └─ Action B
├─ Separator
├─ Folder 2
│ └─ Profile C
│ └─ Profile D
├─ Action E
└─ Profile F
And assuming the user has bound:
{
"bindings":
[
{ "command": { "action": "openNewTabDropdown", "index": 0 }, "keys": "ctrl+shift+1" },
{ "command": { "action": "openNewTabDropdown", "index": 1 }, "keys": "ctrl+shift+2" },
{ "command": { "action": "openNewTabDropdown", "index": 2 }, "keys": "ctrl+shift+3" },
{ "command": { "action": "openNewTabDropdown", "index": 3 }, "keys": "ctrl+shift+4" },
]
}
- ctrl+shift+1 focuses "Profile A", but the user needs to press enter/space to creates a new tab/split
- ctrl+shift+2 focuses "Profile C", but the user needs to press enter/space to creates a new tab/split
- ctrl+shift+3 performs Action E
- ctrl+shift+4 Creates a new tab/split with Profile F
Future considerations
-
The user could set a
"name"
/"text"
, or"icon"
property to these menu items manually, to override the value from the profile or action. These settings would be totally optional, but it's not unreasonable that someone might want this. -
We may want to consider adding a default icon for all folders or actions in the menu. For example, a folder (like 📁) for
folder
entries, or something like ⚡ for actions. We'll leave these unset by default, and evaluate setting these icons by default in the future. -
Something considered during review was a way to specify "All my WSL profiles". Maybe the user wants to have all their profiles generated by the WSL Distro Generator appear in a "WSL" folder. This would likely require a more elaborate filtering syntax, to be able to select only profiles where a certain property has a specific value. Consider the user who has multiple "SSH me@<some host>.com" profiles, and they want all their "SSH*" profiles to appear in an "SSH" folder. This feels out-of-scope for this spec.
-
A similar structure could potentially also be used for customizing the context menu within a control, or the context menu for the tab. (see #3337)
- In both of those cases, it might be important to somehow refer to the
context of the current tab or control in the json. Think for example about
"Close tab" or "Close other tabs" - currently, those work by knowing which
tab the "action" is specified for, not by actually using a
closeTab
action. In the future, they might need to be implemented as something like- Close Tab:
{ "action": "closeTab", "index": "${selectedTab.index}" }
- Close Other Tabs:
{ "action": "closeTabs", "otherThan": "${selectedTab.index}" }
- Close Tabs to the Right:
{ "action": "closeTabs", "after": "${selectedTab.index}" }
- Close Tab:
- In both of those cases, it might be important to somehow refer to the
context of the current tab or control in the json. Think for example about
"Close tab" or "Close other tabs" - currently, those work by knowing which
tab the "action" is specified for, not by actually using a
-
We may want to consider regex, tag-based, or some other type of matching for
matchProfile
entries in the future. We originally considered using regex formatchProfile
by default, but decided instead on full string matches to leave room for regex matching in the future. Should we chose to pursue something like that, we should use a settings structure like:"type": "profileMatch", "source": { "type": "regex", "value": ".*wsl.*" }
-
We may want to expand
matchProfile
to match on other properties too. (title
?) -
We may want to consider adding support for capture groups, e.g.
{ "type": "profileMatch", "name": { "type": "regex", "value": "^ssh: (.*)" } }
for matching to all your
ssh:
profiles, but populate the name in the entry with that first capture group. So, ["ssh: foo", "ssh: bar"] would just expand to a "foo" and "bar" entry.
Updates
February 2022: Doc updated in response to some discussion in #11326 and
#7774. In those PRs, it became clear that there needs to be a simple way of
collecting up a whole group of profiles automatically for sorting in these
menus. Although discussion centered on how hard it would be for extensions to
provide that customization themselves, the match
statement was added as a way
to allow the user to easily filter those profiles themselves.
This was something we had originally considered as a "future consideration", but ultimately deemed it to be out of scope for the initial spec review.