Skip to content

Commit 769b6b6

Browse files
Match Windows Terminal new-tab click modifiers (#42)
## Summary - use Ctrl+Click to open elevated tabs on Windows - support Alt+Click to split the active pane - support Shift+Click to open the selected profile in a new window - expose the modifier behavior in new-tab tooltips ## Validation - `dotnet test tests\Devolutions.Terminal.App.Tests\Devolutions.Terminal.App.Tests.csproj -c Debug --no-restore` - `dotnet test tests\Devolutions.Terminal.UI.Tests\Devolutions.Terminal.UI.Tests.csproj -c Debug --no-restore` Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent f678e8d commit 769b6b6

4 files changed

Lines changed: 105 additions & 53 deletions

File tree

‎src/Devolutions.Terminal.App/Models/NewTabMenuResolver.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ public sealed record ResolvedNewTabMenuItem(
2020

2121
public static class NewTabMenuResolver
2222
{
23-
public static ProfileSettings ForMenuLaunch(ProfileSettings profile, bool shiftHeld)
23+
public static ProfileSettings ForMenuLaunch(ProfileSettings profile, bool elevate)
2424
{
2525
ArgumentNullException.ThrowIfNull(profile);
26-
return shiftHeld && OperatingSystem.IsWindows()
26+
return elevate && OperatingSystem.IsWindows()
2727
? profile.WithOverrides(new NewTerminalArgs(Elevate: true))
2828
: profile;
2929
}

‎src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs‎

Lines changed: 92 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ public partial class MainWindow :
8282
private readonly DispatcherTimer _notificationTimer;
8383
private PixelPoint? _normalPosition;
8484
private WindowSizeState _normalSize = new();
85+
private KeyModifiers _newTabButtonModifiers;
8586

8687
public MainWindow() : this(0, string.Empty, null)
8788
{
@@ -145,11 +146,12 @@ public MainWindow(
145146
var newTabShortcut = _settings.ActionMap
146147
.GetKeyBindingForAction("Terminal.OpenNewTab")?
147148
.ToDisplayString();
148-
ToolTip.SetTip(
149-
NewTabButton,
150-
string.IsNullOrWhiteSpace(newTabShortcut)
151-
? "New tab"
152-
: $"New tab ({newTabShortcut})");
149+
ToolTip.SetTip(NewTabButton, NewTabButtonTooltip(newTabShortcut));
150+
NewTabButton.AddHandler(
151+
PointerPressedEvent,
152+
(_, e) => _newTabButtonModifiers = e.KeyModifiers,
153+
RoutingStrategies.Tunnel,
154+
handledEventsToo: true);
153155
ApplyWindowChrome();
154156
RefreshJumpList();
155157
_stateStore = stateStore ?? SettingsService.LoadApplicationState();
@@ -373,8 +375,28 @@ activation.Actions[0] is
373375
} &&
374376
terminal == new NewTerminalArgs()));
375377

376-
private async void NewTab_OnClick(object? sender, RoutedEventArgs e) =>
377-
await CreateTabAsync(_settings.GetDefaultProfile()).ConfigureAwait(true);
378+
private async void NewTab_OnClick(object? sender, RoutedEventArgs e)
379+
{
380+
var modifiers = _newTabButtonModifiers;
381+
_newTabButtonModifiers = KeyModifiers.None;
382+
await LaunchProfileAsync(_settings.GetDefaultProfile(), modifiers).ConfigureAwait(true);
383+
}
384+
385+
private static string NewTabButtonTooltip(string? newTabShortcut)
386+
{
387+
var lines = new List<string>
388+
{
389+
string.IsNullOrWhiteSpace(newTabShortcut) ? "New tab" : $"New tab ({newTabShortcut})",
390+
"Alt+Click to split the current window",
391+
"Shift+Click to open a new window",
392+
};
393+
if (OperatingSystem.IsWindows())
394+
{
395+
lines.Add("Ctrl+Click to open as administrator");
396+
}
397+
398+
return string.Join(Environment.NewLine, lines);
399+
}
378400

379401
private void Menu_OnClick(object? sender, RoutedEventArgs e)
380402
{
@@ -507,29 +529,26 @@ private MenuItem CreateMenuItem(ResolvedNewTabMenuItem item)
507529
menu.Icon = CreateTabIcon(ProfileVisualDefaults.Icon(item.Profile));
508530
menu.InputGesture = ProfileMenuGesture(item.Profile);
509531
var profile = item.Profile;
510-
var shiftHeld = false;
511-
if (OperatingSystem.IsWindows())
512-
{
513-
ToolTip.SetTip(menu, profile.Elevate
514-
? "Opens as administrator"
515-
: "Shift-click to open as administrator");
516-
menu.AddHandler(
517-
PointerPressedEvent,
518-
(_, e) => shiftHeld = e.KeyModifiers.HasFlag(KeyModifiers.Shift),
519-
RoutingStrategies.Tunnel,
520-
handledEventsToo: true);
521-
menu.AddHandler(
522-
KeyDownEvent,
523-
(_, e) => shiftHeld = e.KeyModifiers.HasFlag(KeyModifiers.Shift),
524-
RoutingStrategies.Tunnel,
525-
handledEventsToo: true);
526-
}
532+
var pressedModifiers = KeyModifiers.None;
533+
ToolTip.SetTip(
534+
menu,
535+
profile.Elevate ? "Opens as administrator" : NewTabButtonTooltip(newTabShortcut: null));
536+
menu.AddHandler(
537+
PointerPressedEvent,
538+
(_, e) => pressedModifiers = e.KeyModifiers,
539+
RoutingStrategies.Tunnel,
540+
handledEventsToo: true);
541+
menu.AddHandler(
542+
KeyDownEvent,
543+
(_, e) => pressedModifiers = e.KeyModifiers,
544+
RoutingStrategies.Tunnel,
545+
handledEventsToo: true);
527546

528547
menu.Command = new RelayCommand(() =>
529548
{
530-
var elevate = shiftHeld;
531-
shiftHeld = false;
532-
_ = CreateTabAsync(NewTabMenuResolver.ForMenuLaunch(profile, elevate));
549+
var modifiers = pressedModifiers;
550+
pressedModifiers = KeyModifiers.None;
551+
_ = LaunchProfileAsync(profile, modifiers);
533552
});
534553
}
535554
else if (item.ActionId is { } actionId &&
@@ -545,6 +564,51 @@ private MenuItem CreateMenuItem(ResolvedNewTabMenuItem item)
545564
return menu;
546565
}
547566

567+
private void OpenNewWindow(INewContentArgs? content)
568+
{
569+
content ??= new NewTerminalArgs();
570+
if (_newWindowRequested is not null)
571+
{
572+
_newWindowRequested(new(
573+
null,
574+
null,
575+
null,
576+
null,
577+
TerminalWindowLaunchMode.Default,
578+
[new(ShortcutAction.NewTab, new NewTabArgs(content))]));
579+
}
580+
else
581+
{
582+
new MainWindow(ResolveProfile(content)).Show();
583+
}
584+
}
585+
586+
/// <summary>
587+
/// Launches <paramref name="profile"/> honoring the same click modifiers as Windows Terminal:
588+
/// Alt splits the current pane, Shift opens a new window, and Ctrl opens elevated (Windows only).
589+
/// </summary>
590+
private async Task LaunchProfileAsync(ProfileSettings profile, KeyModifiers modifiers)
591+
{
592+
var elevate = OperatingSystem.IsWindows() && modifiers.HasFlag(KeyModifiers.Control);
593+
var effectiveProfile = NewTabMenuResolver.ForMenuLaunch(profile, elevate);
594+
595+
if (modifiers.HasFlag(KeyModifiers.Alt))
596+
{
597+
await SplitActivePaneAsync(PaneSplitOrientation.Vertical, effectiveProfile).ConfigureAwait(true);
598+
return;
599+
}
600+
601+
if (modifiers.HasFlag(KeyModifiers.Shift))
602+
{
603+
OpenNewWindow(new NewTerminalArgs(
604+
Profile: profile.Guid ?? profile.Name,
605+
Elevate: elevate ? true : null));
606+
return;
607+
}
608+
609+
await CreateTabAsync(effectiveProfile).ConfigureAwait(true);
610+
}
611+
548612
private async Task CreateTabAsync(ProfileSettings profile)
549613
{
550614
TerminalPane? pane = null;
@@ -2023,22 +2087,7 @@ args with
20232087
});
20242088
Register(ShortcutAction.NewWindow, ActionScope.Application, _ => true, action =>
20252089
{
2026-
var content = (action.Args as NewWindowArgs)?.ContentArgs ?? new NewTerminalArgs();
2027-
if (_newWindowRequested is not null)
2028-
{
2029-
_newWindowRequested(new(
2030-
null,
2031-
null,
2032-
null,
2033-
null,
2034-
TerminalWindowLaunchMode.Default,
2035-
[new(ShortcutAction.NewTab, new NewTabArgs(content))]));
2036-
}
2037-
else
2038-
{
2039-
new MainWindow(ResolveProfile(content)).Show();
2040-
}
2041-
2090+
OpenNewWindow((action.Args as NewWindowArgs)?.ContentArgs);
20422091
return Task.CompletedTask;
20432092
});
20442093
Register(ShortcutAction.CloseWindow, ActionScope.Window, _ => true, _ =>

‎tests/Devolutions.Terminal.App.Tests/NewTabMenuResolverTests.cs‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,18 +67,18 @@ public void EmptyFoldersAreOnlyIncludedWhenAllowed()
6767
}
6868

6969
[Fact]
70-
public void ShiftOpeningAProfileElevatesOnlyThatLaunchOnWindows()
70+
public void CtrlClickOpeningAProfileElevatesOnlyThatLaunchOnWindows()
7171
{
7272
var local = ProfileSettings.CreateCmd();
73-
var normal = NewTabMenuResolver.ForMenuLaunch(local, shiftHeld: false);
74-
var shifted = NewTabMenuResolver.ForMenuLaunch(local, shiftHeld: true);
73+
var normal = NewTabMenuResolver.ForMenuLaunch(local, elevate: false);
74+
var elevated = NewTabMenuResolver.ForMenuLaunch(local, elevate: true);
7575

7676
Assert.Same(local, normal);
77-
Assert.Equal(OperatingSystem.IsWindows(), shifted.Elevate);
77+
Assert.Equal(OperatingSystem.IsWindows(), elevated.Elevate);
7878
Assert.False(local.Elevate);
7979
if (OperatingSystem.IsWindows())
8080
{
81-
Assert.NotSame(local, shifted);
81+
Assert.NotSame(local, elevated);
8282
}
8383
}
8484
}

‎tests/Devolutions.Terminal.UI.Tests/WindowActionRegistrationTests.cs‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ namespace Devolutions.Terminal.UI.Tests;
1212
public sealed class WindowActionRegistrationTests
1313
{
1414
[AvaloniaFact]
15-
public void NewTabDropdownUsesExistingProfileEntriesForShiftLaunch()
15+
public void NewTabDropdownUsesExistingProfileEntriesForCtrlClickLaunch()
1616
{
1717
var window = new MainWindow();
1818

@@ -25,9 +25,12 @@ public void NewTabDropdownUsesExistingProfileEntriesForShiftLaunch()
2525
Assert.NotEmpty(profiles);
2626
foreach (var profile in profiles)
2727
{
28+
var tip = Assert.IsType<string>(ToolTip.GetTip(profile));
29+
Assert.Contains("Alt+Click to split the current window", tip);
30+
Assert.Contains("Shift+Click to open a new window", tip);
2831
Assert.Equal(
29-
OperatingSystem.IsWindows() ? "Shift-click to open as administrator" : null,
30-
ToolTip.GetTip(profile));
32+
OperatingSystem.IsWindows(),
33+
tip.Contains("Ctrl+Click to open as administrator", StringComparison.Ordinal));
3134
}
3235
}
3336

0 commit comments

Comments
 (0)