Skip to content

Latest commit

 

History

History
444 lines (377 loc) · 18.3 KB

#1571 - New Tab Menu Customization.md

File metadata and controls

444 lines (377 loc) · 18.3 KB
author created on last updated issue id
Mike Griese @zadjii-msft
2020-05-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

fig 1: A very rough mockup of what this feature might look like

There are five types 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 profile name 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 XAML MenuFlyoutSeparator, 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 values

      • auto: 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 to false, meaning that folders without any entries in them will just be ignored when generating the menu. This will be more useful with the matchProfile 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 specific ShortcutAction.
    • 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 no name was provided)
    • The icon for this entry will similarly re-use the action's icon.
  • "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)
  • "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 the newTab or splitPane 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}" }
  • 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 for matchProfile 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.