Compare commits

...
12 Commits
Author SHA1 Message Date
FelixSe 90c299da83 Increase version 2019-07-14 18:06:18 +02:00
FelixSe 02a695056a Revert "Upgrade xterm.js to v3.15.0-beta70 (#444)"
This reverts commit b87ec9f348.
2019-07-14 11:58:31 +02:00
FelixSe 50a3a7c422 Increase version 2019-07-11 21:33:41 +02:00
FelixSe 3f699f40e3 Update translations 2019-07-11 21:14:36 +02:00
Max Risuhin b87ec9f348 Upgrade xterm.js to v3.15.0-beta70 (#444) 2019-07-11 20:52:33 +02:00
Felix 79dda1d4fd Fix crash on profiles (#440) 2019-07-09 22:00:05 +02:00
Max Risuhin 5467bafaed Settings option to mute conhost. (#437) 2019-07-09 21:49:07 +02:00
Aleksandar Pesic ac3190111d Fixing a bug with creating new profiles. (#436) 2019-07-09 21:20:42 +02:00
Aleksandar Pesic 298b402d5d Implementing more descriptive error messages on invalid quick-command input. (#434) 2019-07-07 21:34:33 +02:00
Aleksandar Pesic a7b734b3bf Adding some translations, fixing remote profiles layout, and fixing a minor bug with quick commands. (#433) 2019-07-07 15:52:30 +02:00
Aleksandar Pesic 3dd95e994f Fixing a bug with opening shortcuts. (#432) 2019-07-06 15:40:44 +02:00
Aleksandar Pesic 96e32fcc96 Implemented "Quick SSH" dialog (#423)
* Simplifying view models, step 1: Extracting common terminal configuration into TerminalInfoViewModel.cs.

* Simplifying a bit.

* SSH view models simplfication.

* Refactoring duplicated code.

* Refactoring duplicated code.

* Renaming a resource file.

* Cleanup.

* Fixing an issue with TemplateSelectors.

* QuickSshDialog created.

* Minor fix.

* Wiring up QuickSshDialog.

* Minor fix.

* Fixed an issue with shared resources.

* Single-text-box dialog works.

* Improving "Quick SSH" look.

* Improving data validation workflow to avoid double validations.

* Refactoring view models and templates to avoid duplicated code.

* Minor fix.

* Fixing a bug with mosh.

* Cleanup.

* Cleanup.

* Moving profile view models in a separate folder (namespace).

* Few comment lines (short description) added to profile view models.

* Removing IDisposable from view-models because event-caused memory leaks will be solved differently.

* Implemented quick-ssh shortcuts.

* Fixing quick SSH command validation.

* Minor change.

* Minor change.

* Combining "SSH Info" and "Quick SSH" into a single dialog.

* Quick SSH profiles.

* Fixing a minor bug with custom links.

* Fixing remote profiles look and feel.

* Minor improvements.

* Fixing a minor bug.

* Minor changes based on Felix' comments.

* Saving executed commands history implemented.
2019-07-06 13:53:00 +02:00
53 changed files with 2814 additions and 1702 deletions

No files matched your search

@@ -8,5 +8,6 @@
public IApplicationDataContainer KeyBindings { get; set; }
public IApplicationDataContainer ShellProfiles { get; set; }
public IApplicationDataContainer SshProfiles { get; set; }
public IApplicationDataContainer HistoryContainer { get; set; }
}
}
+3
View File
@@ -7,5 +7,8 @@
public const string ShellProfilesContainerName = "ShellProfiles";
public const string SshProfilesContainerName = "SshProfiles";
public const byte TerminalBufferRequestIdentifier = 0;
public const string SshCommandName = "ssh";
public const string MoshCommandName = "mosh";
public const string ExecutedCommandsContainerName = "HistoryContainer";
}
}
@@ -5,6 +5,6 @@ namespace FluentTerminal.App.Services.Dialogs
{
public interface ISshConnectionInfoDialog
{
Task<ISshConnectionInfo> GetSshConnectionInfoAsync(ISshConnectionInfo input);
Task<SshProfile> GetSshConnectionInfoAsync(SshProfile input = null);
}
}
@@ -14,7 +14,7 @@ namespace FluentTerminal.App.Services
int Id { get; }
string Title { get; set; }
Task RunOnDispatcherThread(Action action);
Task RunOnDispatcherThread(Action action, bool enforceNewSchedule = true);
Task<bool> TryClose();
@@ -18,7 +18,7 @@ namespace FluentTerminal.App.Services
Task<KeyBinding> ShowCreateKeyBindingDialog();
Task<string> ShowInputDialogAsync(string title);
Task<ISshConnectionInfo> ShowSshConnectionInfoDialogAsync(ISshConnectionInfo input);
Task<SshProfile> ShowSshConnectionInfoDialogAsync(SshProfile input = null);
Task<SshProfile> ShowSshProfileSelectionDialogAsync();
}
@@ -1,19 +0,0 @@
using System;
using System.Threading.Tasks;
using FluentTerminal.Models;
namespace FluentTerminal.App.Services
{
public interface ISshHelperService
{
bool IsSsh(Uri uri);
ISshConnectionInfo ParseSsh(Uri uri);
Task<SshProfile> GetSshProfileAsync(SshProfile profile);
Task<SshProfile> GetSavedSshProfileAsync();
Task<string> ConvertToUriAsync(ISshConnectionInfo sshConnectionInfo);
}
}
@@ -35,5 +35,7 @@ namespace FluentTerminal.App.Services
Task<string[]> GetFilesFromSshConfigDirAsync();
Task<bool> CheckFileExistsAsync(string path);
void MuteTerminal(bool mute);
}
}
@@ -28,7 +28,9 @@ namespace FluentTerminal.App.Services.Implementation
ShowCustomTitleInTitlebar = true,
UseMoshByDefault = true,
AutoFallbackToWindowsUsernameInLinks = true,
RTrimCopiedLines = true
UseQuickSshConnectByDefault = false,
RTrimCopiedLines = true,
MuteTerminalBeeps = true
};
}
@@ -78,7 +78,7 @@ namespace FluentTerminal.App.Services.Implementation
return dialog.SelectProfile();
}
public Task<ISshConnectionInfo> ShowSshConnectionInfoDialogAsync(ISshConnectionInfo input) =>
public Task<SshProfile> ShowSshConnectionInfoDialogAsync(SshProfile input = null) =>
_sshConnectionInfoDialogFactory().GetSshConnectionInfoAsync(input);
public Task<SshProfile> ShowSshProfileSelectionDialogAsync()
@@ -138,6 +138,11 @@ namespace FluentTerminal.App.Services.Implementation
return JsonConvert.DeserializeObject<CommonResponse>((string) responseMessage[MessageKeys.Content]).Success;
}
public void MuteTerminal(bool mute)
{
_appServiceConnection.SendMessageAsync(CreateMessage(new MuteTerminalRequest { Mute = mute }));
}
public async Task<CreateTerminalResponse> CreateTerminal(byte id, TerminalSize size, ShellProfile shellProfile, SessionType sessionType)
{
var request = new CreateTerminalRequest
@@ -21,7 +21,6 @@ namespace FluentTerminal.App.ViewModels
private readonly IKeyboardCommandService _keyboardCommandService;
private readonly ISettingsService _settingsService;
private readonly ITrayProcessCommunicationService _trayProcessCommunicationService;
private readonly ISshHelperService _sshHelperService;
private ApplicationSettings _applicationSettings;
private string _background;
private double _backgroundOpacity;
@@ -30,7 +29,7 @@ namespace FluentTerminal.App.ViewModels
private string _windowTitle;
public MainViewModel(ISettingsService settingsService, ITrayProcessCommunicationService trayProcessCommunicationService, IDialogService dialogService, IKeyboardCommandService keyboardCommandService,
IApplicationView applicationView, IDispatcherTimer dispatcherTimer, IClipboardService clipboardService, ISshHelperService sshHelperService)
IApplicationView applicationView, IDispatcherTimer dispatcherTimer, IClipboardService clipboardService)
{
_settingsService = settingsService;
_settingsService.CurrentThemeChanged += OnCurrentThemeChanged;
@@ -46,7 +45,6 @@ namespace FluentTerminal.App.ViewModels
ApplicationView = applicationView;
_dispatcherTimer = dispatcherTimer;
_clipboardService = clipboardService;
_sshHelperService = sshHelperService;
_keyboardCommandService = keyboardCommandService;
_keyboardCommandService.RegisterCommandHandler(nameof(Command.NewTab), async () => await AddLocalTabAsync());
_keyboardCommandService.RegisterCommandHandler(nameof(Command.NewSshTab), async () => await AddSshTabAsync());
@@ -56,7 +54,7 @@ namespace FluentTerminal.App.ViewModels
_keyboardCommandService.RegisterCommandHandler(nameof(Command.SavedSshNewTab), async () => await AddSavedSshTerminalAsync());
_keyboardCommandService.RegisterCommandHandler(nameof(Command.SavedSshNewWindow), () => NewWindow(NewWindowAction.ShowSshProfileSelection));
_keyboardCommandService.RegisterCommandHandler(nameof(Command.NewSshWindow), () => NewWindow(NewWindowAction.ShowSshInfoDialog));
// Add all of the commands for switching to a tab of a given ID, if there's one open there
for (int i = 0; i < 9; i++)
{
@@ -246,7 +244,7 @@ namespace FluentTerminal.App.ViewModels
public async Task AddSshTabAsync()
{
SshProfile profile = await _sshHelperService.GetSshProfileAsync(new SshProfile());
SshProfile profile = await _dialogService.ShowSshConnectionInfoDialogAsync();
if (profile == null)
{
// User selected "Cancel"
@@ -260,6 +258,7 @@ namespace FluentTerminal.App.ViewModels
await AddTerminalAsync(profile);
}
}
public Task AddSshTabOrWindowAsync(Guid shellProfileId)
{
switch (_applicationSettings.NewTerminalLocation)
@@ -277,7 +276,7 @@ namespace FluentTerminal.App.ViewModels
public async Task AddSavedSshTerminalAsync()
{
ShellProfile profile = await _sshHelperService.GetSavedSshProfileAsync();
ShellProfile profile = await _dialogService.ShowSshProfileSelectionDialogAsync();
if (profile == null)
{
@@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
namespace FluentTerminal.App.ViewModels.Profiles
{
public class ExecutedCommand
{
public string Command { get; set; }
public string Args { get; set; }
public DateTime LastExecution { get; set; }
public int ExecutionCount { get; set; }
}
public class ExecutedCommandHistory
{
public List<ExecutedCommand> ExecutedCommands { get; set; }
}
}
@@ -0,0 +1,146 @@
using System.Threading.Tasks;
using FluentTerminal.App.Services;
using FluentTerminal.App.Services.Utilities;
using FluentTerminal.App.ViewModels.Infrastructure;
using FluentTerminal.Models;
namespace FluentTerminal.App.ViewModels.Profiles
{
/// <summary>
/// Used for common profiles (PowerShell, CMD, WSL).
/// </summary>
public class CommonProfileProviderViewModel : ProfileProviderViewModelBase
{
#region Fields
private readonly IFileSystemService _fileSystemService;
#endregion Fields
#region Properties
public bool PreInstalled { get; private set; }
private string _location;
public string Location
{
get => _location;
set => Set(ref _location, value);
}
private string _workingDirectory;
public string WorkingDirectory
{
get => _workingDirectory;
set => Set(ref _workingDirectory, value);
}
private string _arguments;
public string Arguments
{
get => _arguments;
set => Set(ref _arguments, value);
}
#endregion Properties
#region Commands
public IAsyncCommand BrowseForCustomShellCommand { get; }
public IAsyncCommand BrowseForWorkingDirectoryCommand { get; }
#endregion Commands
#region Constructor
public CommonProfileProviderViewModel(ISettingsService settingsService, IApplicationView applicationView,
IFileSystemService fileSystemService, ShellProfile original = null) : base(settingsService, applicationView,
original)
{
_fileSystemService = fileSystemService;
BrowseForCustomShellCommand = new AsyncCommand(BrowseForCustomShell);
BrowseForWorkingDirectoryCommand = new AsyncCommand(BrowseForWorkingDirectory);
Initialize(Model);
}
#endregion Constructor
#region Methods
private void Initialize(ShellProfile profile)
{
PreInstalled = profile.PreInstalled;
Location = profile.Location;
Arguments = profile.Arguments;
WorkingDirectory = profile.WorkingDirectory;
}
protected override void LoadFromProfile(ShellProfile profile)
{
base.LoadFromProfile(profile);
Initialize(profile);
}
protected override void CopyToProfile(ShellProfile profile)
{
base.CopyToProfile(profile);
profile.Location = _location;
profile.Arguments = _arguments;
profile.WorkingDirectory = _workingDirectory;
}
public override async Task<string> ValidateAsync()
{
var error = await base.ValidateAsync();
if (!string.IsNullOrEmpty(error))
{
return error;
}
if (!string.IsNullOrEmpty(_location))
{
return null;
}
error = I18N.Translate("LocationCantBeEmpty");
return string.IsNullOrEmpty(error) ? "Location cannot be empty." : error;
}
public override bool HasChanges()
{
return base.HasChanges() || !Model.Location.NullableEqualTo(_location) ||
!Model.WorkingDirectory.NullableEqualTo(_workingDirectory) ||
!Model.Arguments.NullableEqualTo(_arguments);
}
private async Task BrowseForCustomShell()
{
var file = await _fileSystemService.OpenFile(new[] { ".exe" }).ConfigureAwait(true);
if (file != null)
{
Location = file.Path;
}
}
private async Task BrowseForWorkingDirectory()
{
var directory = await _fileSystemService.BrowseForDirectory().ConfigureAwait(true);
if (directory != null)
{
WorkingDirectory = directory;
}
}
#endregion Methods
}
}
@@ -0,0 +1,396 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using System.Web;
using FluentTerminal.App.Services;
using FluentTerminal.Models;
using FluentTerminal.Models.Enums;
using GalaSoft.MvvmLight;
namespace FluentTerminal.App.ViewModels.Profiles
{
/// <summary>
/// Base class for all profile view model classes. It contains properties shared by all other view models
/// (theme-related properties, line-ending translation, and WinPTY/ConPTY selection).
/// </summary>
public abstract class ProfileProviderViewModelBase : ViewModelBase
{
#region Static
private static readonly LineEndingStyle[] LineEndingStylesArray =
Enum.GetValues(typeof(LineEndingStyle)).Cast<LineEndingStyle>().ToArray();
#endregion Static
#region Fields
protected readonly ISettingsService SettingsService;
protected readonly IApplicationView ApplicationView;
#endregion Fields
#region Properties
private ShellProfile _model;
public ShellProfile Model
{
get => _model;
set
{
if (value == null)
{
throw new ArgumentNullException();
}
if (ReferenceEquals(_model, value))
{
return;
}
if (_model != null && _model.GetType() != value.GetType())
{
throw new ArgumentException($"The value has to be of type {_model.GetType().Name}.");
}
_model = value;
LoadFromProfile(value);
}
}
public ObservableCollection<TabTheme> TabThemes { get; }
public ObservableCollection<TerminalTheme> TerminalThemes { get; }
public ObservableCollection<LineEndingStyle> LineEndingStyles { get; } =
new ObservableCollection<LineEndingStyle>(LineEndingStylesArray);
private LineEndingStyle _lineEndingTranslation;
public LineEndingStyle LineEndingTranslation
{
get => _lineEndingTranslation;
set => Set(ref _lineEndingTranslation, value);
}
private TabTheme _selectedTabTheme;
public TabTheme SelectedTabTheme
{
get => _selectedTabTheme ?? (_selectedTabTheme = TabThemes.FirstOrDefault(t => t.Id.Equals(_tabThemeId)));
set
{
if (value == null)
{
// Ignore attempt setting null.
return;
}
// Ensure that it's from the list
var theme = TabThemes.Contains(value)
? value
: TabThemes.FirstOrDefault(t => t.Id == value.Id) ?? TabThemes.First();
if (Set(ref _selectedTabTheme, theme))
{
_tabThemeId = theme.Id;
}
}
}
private int _tabThemeId;
public int TabThemeId
{
get => _tabThemeId;
set
{
if (Set(ref _tabThemeId, value))
{
SelectedTabTheme = TabThemes.FirstOrDefault(t => t.Id.Equals(value));
}
}
}
private TerminalTheme _selectedTerminalTheme;
public TerminalTheme SelectedTerminalTheme
{
get => _selectedTerminalTheme ??
(_selectedTerminalTheme = TerminalThemes.FirstOrDefault(t => t.Id.Equals(_terminalThemeId)));
set
{
TerminalTheme theme = value;
if (theme == null)
{
// How to handle attempt to setting null theme?
// 1) Throw an exception:
//throw new ArgumentNullException();
// 2) Ignore the attempt:
//return;
// 3) Use default theme (probably the best):
theme = TerminalThemes.First();
}
else if (!TerminalThemes.Contains(theme))
{
// Ensure that it's from the list
theme = TerminalThemes.FirstOrDefault(t => t.Id.Equals(theme.Id)) ?? TerminalThemes.First();
}
if (Set(ref _selectedTerminalTheme, theme))
{
_terminalThemeId = theme.Id;
}
}
}
private Guid _terminalThemeId;
public Guid TerminalThemeId
{
get => _terminalThemeId;
set
{
if (Set(ref _terminalThemeId, value))
{
SelectedTerminalTheme = TerminalThemes.FirstOrDefault(t => t.Id.Equals(value));
}
}
}
private bool _useConPty;
public bool UseConPty
{
get => _useConPty;
set => Set(ref _useConPty, value);
}
#endregion Properties
#region Constructor
protected ProfileProviderViewModelBase(ISettingsService settingsService, IApplicationView applicationView,
ShellProfile original = null)
{
SettingsService = settingsService;
ApplicationView = applicationView;
_model = original ?? new ShellProfile();
TabThemes = new ObservableCollection<TabTheme>(settingsService.GetTabThemes());
SelectedTabTheme = TabThemes.First();
TerminalThemes = new ObservableCollection<TerminalTheme>(settingsService.GetThemes());
TerminalThemes.Insert(0, new TerminalTheme { Id = Guid.Empty, Name = "Default" });
SelectedTerminalTheme = TerminalThemes.First();
settingsService.ThemeAdded += OnThemeAdded;
settingsService.ThemeDeleted += OnThemeDeleted;
Initialize(Model);
}
#endregion Constructor
#region Methods
private void OnThemeDeleted(object sender, Guid e)
{
ApplicationView.RunOnDispatcherThread(() =>
{
var theme = TerminalThemes.FirstOrDefault(t => t.Id.Equals(e));
if (theme == null)
{
return;
}
TerminalThemes.Remove(theme);
if (_terminalThemeId.Equals(e))
{
SelectedTerminalTheme = TerminalThemes.First();
}
}, false);
}
private void OnThemeAdded(object sender, TerminalTheme e)
{
ApplicationView.RunOnDispatcherThread(() => TerminalThemes.Add(e), false);
}
private void Initialize(ShellProfile profile)
{
LineEndingTranslation = profile.LineEndingTranslation;
UseConPty = profile.UseConPty;
TerminalThemeId = profile.TerminalThemeId;
TabThemeId = profile.TabThemeId;
}
protected virtual void LoadFromProfile(ShellProfile profile)
{
Initialize(profile);
}
protected virtual void CopyToProfile(ShellProfile profile)
{
profile.LineEndingTranslation = _lineEndingTranslation;
profile.UseConPty = _useConPty;
profile.TerminalThemeId = _terminalThemeId;
profile.TabThemeId = _tabThemeId;
}
public virtual Task<string> ValidateAsync()
{
return Task.FromResult<string>(null);
}
/// <summary>
/// Returns <c>true</c> if view model values aren't equal to the corresponding values of the
/// underlying <see cref="ShellProfile"/>.
/// </summary>
public virtual bool HasChanges()
{
return Model.LineEndingTranslation != _lineEndingTranslation ||
Model.UseConPty != _useConPty ||
!Model.TerminalThemeId.Equals(_terminalThemeId) ||
Model.TabThemeId != _tabThemeId;
}
/// <summary>
/// Copies values from the view model to the underlying <see cref="ShellProfile"/>.
/// </summary>
/// <param name="acceptIfInvalid">Defines what will happen if the view model data is invalid (if
/// <see cref="ValidateAsync"/> returns non-empty string). If this parameter is set to <c>true</c>, the
/// data will be copied to the underlying <see cref="ShellProfile"/> anyway. If it's set to <c>false</c>,
/// (default) the underlying <see cref="ShellProfile"/> won't be changed at all.</param>
/// <returns><c>null</c> or empty string if the operation was successful (the data is valid), or an error
/// message returned by <see cref="ValidateAsync"/> method.</returns>
public async Task<string> AcceptChangesAsync(bool acceptIfInvalid = false)
{
var error = await ValidateAsync();
if (acceptIfInvalid || string.IsNullOrEmpty(error))
{
CopyToProfile(Model);
}
return error;
}
public void RejectChanges()
{
ApplicationView.RunOnDispatcherThread(() => LoadFromProfile(Model), false);
}
#endregion Methods
#region Links/shortcuts related
private const string UseConPtyQueryStringName = "conpty";
private const string LineEndingQueryStringName = "lineending";
private const string TerminalThemeIdQueryStringName = "theme";
private const string TabThemeIdQueryStringName = "tab";
private const string ShortcutFileFormat = @"[{{000214A0-0000-0000-C000-000000000046}}]
Prop3=19,0
[InternetShortcut]
IDList=
URL={0}
";
public static string GetShortcutFileContent(string url) => string.Format(ShortcutFileFormat, url);
public static IEnumerable<Tuple<string, string>> ParseParams(string uriOpts, char separator) =>
uriOpts.Split(separator).Select(ParseSshOptionFromUri).Where(p => p != null);
private static Tuple<string, string> ParseSshOptionFromUri(string option)
{
string[] nv = option.Split('=');
if (nv.Length != 2 || string.IsNullOrEmpty(nv[0]))
{
// For now simply ignore invalid options
return null;
//throw new FormatException($"Invalid SSH option '{option}'.");
}
return Tuple.Create(HttpUtility.UrlDecode(nv[0]), HttpUtility.UrlDecode(nv[1]));
}
/// <summary>
/// Generates URL.
/// </summary>
/// <returns><see cref="Tuple"/> whose first item represents success. If the first item is <c>true</c>,
/// the second contains the URL. If the first item is <c>false</c>, the second contains error message.</returns>
public virtual Task<Tuple<bool, string>> GetUrlAsync() =>
Task.FromResult(Tuple.Create(false, "Not supported."));
public string GetBaseQueryString()
{
var queryString =
$"{UseConPtyQueryStringName}={_useConPty}&{LineEndingQueryStringName}={_lineEndingTranslation}";
if (_tabThemeId != TabThemes.First().Id)
{
queryString += $"&{TabThemeIdQueryStringName}={_tabThemeId:##########}";
}
if (!_terminalThemeId.Equals(TerminalThemes.First().Id))
{
queryString += $"&{TerminalThemeIdQueryStringName}={_terminalThemeId}";
}
return queryString;
}
public void LoadBaseFromQueryString(IList<Tuple<string, string>> queryStringParams)
{
if (queryStringParams == null)
{
return;
}
var keyValue = queryStringParams.FirstOrDefault(t =>
UseConPtyQueryStringName.Equals(t.Item1, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrEmpty(keyValue?.Item2) && bool.TryParse(keyValue.Item2?.ToLower(), out bool useConPty))
{
UseConPty = useConPty;
}
keyValue = queryStringParams.FirstOrDefault(t =>
LineEndingQueryStringName.Equals(t.Item1, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrEmpty(keyValue?.Item2) && Enum.TryParse(keyValue.Item2, true, out LineEndingStyle lineEndingTranslation))
{
LineEndingTranslation = lineEndingTranslation;
}
keyValue = queryStringParams.FirstOrDefault(t =>
TabThemeIdQueryStringName.Equals(t.Item1, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrEmpty(keyValue?.Item2) && int.TryParse(keyValue.Item2, out int tabThemeId))
{
TabThemeId = tabThemeId;
}
keyValue = queryStringParams.FirstOrDefault(t =>
TerminalThemeIdQueryStringName.Equals(t.Item1, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrEmpty(keyValue?.Item2) && Guid.TryParse(keyValue.Item2, out Guid terminalThemeId))
{
TerminalThemeId = terminalThemeId;
}
}
#endregion Links/shortcuts related
}
}
@@ -0,0 +1,860 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Web;
using FluentTerminal.App.Services;
using FluentTerminal.App.Services.Utilities;
using FluentTerminal.App.ViewModels.Infrastructure;
using FluentTerminal.Models;
namespace FluentTerminal.App.ViewModels.Profiles
{
/// <summary>
/// View-model for rich-UI based SSH profiles.
/// </summary>
public class SshConnectViewModel : ProfileProviderViewModelBase
{
#region Static
private static readonly Regex CommandValidationRx = new Regex(@"^(?<cmd>[^\s\.]+)(\.exe)?(\s+(?<args>\S.*))?$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly string[] AcceptableCommands = { Constants.SshCommandName };
public static string GetErrorString(SshConnectionInfoValidationResult result, string separator = "; ") =>
string.Join(separator, GetErrors(result));
public static IEnumerable<string> GetErrors(SshConnectionInfoValidationResult result)
{
if (result == SshConnectionInfoValidationResult.Valid)
{
yield break;
}
foreach (var value in Enum.GetValues(typeof(SshConnectionInfoValidationResult))
.Cast<SshConnectionInfoValidationResult>().Where(r => r != SshConnectionInfoValidationResult.Valid))
{
if ((value & result) == value)
{
yield return I18N.Translate($"{nameof(SshConnectionInfoValidationResult)}.{value}");
}
}
}
#endregion Static
#region Fields
private readonly ITrayProcessCommunicationService _trayProcessCommunicationService;
private readonly IFileSystemService _fileSystemService;
private readonly IApplicationDataContainer _historyContainer;
private bool _commandInputOriginal;
// To prevent validating the existence of the same file multiple times because it's kinda expensive
private string _validatedIdentityFile;
#endregion Fields
#region Properties
private bool _commandInput;
public bool CommandInput
{
get => _commandInput;
set
{
if (Set(ref _commandInput, value) && _useMosh)
{
RaisePropertyChanged(nameof(MoshVisible));
}
}
}
#region Quick SSH properties
private string _command;
public string Command
{
get => _command;
set => Set(ref _command, value);
}
#endregion Quick SSH properties
#region Full UI properties
private string _host;
public string Host
{
get => _host;
set => Set(ref _host, value);
}
private ushort _sshPort;
public ushort SshPort
{
get => _sshPort;
set => Set(ref _sshPort, value);
}
private string _username;
public string Username
{
get => _username;
set => Set(ref _username, value);
}
private string _identityFile;
public string IdentityFile
{
get => _identityFile;
set => Set(ref _identityFile, value);
}
private bool _useMosh;
public bool UseMosh
{
get => _useMosh;
set
{
if (Set(ref _useMosh, value) && !_commandInput)
{
RaisePropertyChanged(nameof(MoshVisible));
}
}
}
private ushort _moshPortFrom;
public ushort MoshPortFrom
{
get => _moshPortFrom;
set => Set(ref _moshPortFrom, value);
}
private ushort _moshPortTo;
public ushort MoshPortTo
{
get => _moshPortTo;
set => Set(ref _moshPortTo, value);
}
#endregion Full UI properties
public bool MoshVisible => _useMosh && !_commandInput;
public ObservableCollection<string> CommandHistory { get; private set; }
#endregion Properties
#region Commands
public IAsyncCommand BrowseForIdentityFileCommand { get; }
#endregion Commands
#region Constructors
public SshConnectViewModel(ISettingsService settingsService, IApplicationView applicationView,
ITrayProcessCommunicationService trayProcessCommunicationService, IFileSystemService fileSystemService,
IApplicationDataContainer historyContainer, SshProfile original = null) : base(settingsService, applicationView,
original ?? new SshProfile { UseMosh = settingsService.GetApplicationSettings().UseMoshByDefault })
{
_trayProcessCommunicationService = trayProcessCommunicationService;
_fileSystemService = fileSystemService;
_historyContainer = historyContainer;
if (string.IsNullOrEmpty(Model.Location))
{
_commandInput = settingsService.GetApplicationSettings().UseQuickSshConnectByDefault;
}
else if (string.IsNullOrEmpty(((SshProfile)Model).Host))
{
_commandInput = true;
}
else
{
_commandInput = false;
}
_commandInputOriginal = _commandInput;
FillCommandHistory();
Initialize((SshProfile)Model);
BrowseForIdentityFileCommand = new AsyncCommand(BrowseForIdentityFile);
}
private SshConnectViewModel(ISettingsService settingsService, IApplicationView applicationView,
ITrayProcessCommunicationService trayProcessCommunicationService, IFileSystemService fileSystemService,
IApplicationDataContainer historyContainer, bool useCommandInput) : base(settingsService, applicationView,
new SshProfile { UseMosh = settingsService.GetApplicationSettings().UseMoshByDefault })
{
_trayProcessCommunicationService = trayProcessCommunicationService;
_fileSystemService = fileSystemService;
_historyContainer = historyContainer;
_commandInput = useCommandInput;
_commandInputOriginal = useCommandInput;
FillCommandHistory();
Initialize((SshProfile)Model);
BrowseForIdentityFileCommand = new AsyncCommand(BrowseForIdentityFile);
}
#endregion Constructors
#region Methods
// Fills the view model properties from the input sshProfile
private void Initialize(SshProfile sshProfile)
{
Command = string.IsNullOrEmpty(sshProfile.Location) ? string.Empty : $"{sshProfile.Location} {sshProfile.Arguments}";
Host = sshProfile.Host;
SshPort = sshProfile.SshPort;
Username = sshProfile.Username;
if (string.IsNullOrEmpty(Username))
{
_trayProcessCommunicationService.GetUserName().ContinueWith(t =>
{
var username = t.Result;
if (string.IsNullOrEmpty(Username) && !string.IsNullOrEmpty(username))
{
ApplicationView.RunOnDispatcherThread(() => Username = username, false);
}
}, TaskContinuationOptions.OnlyOnRanToCompletion);
}
IdentityFile = sshProfile.IdentityFile;
UseMosh = sshProfile.UseMosh;
MoshPortFrom = sshProfile.MoshPortFrom;
MoshPortTo = sshProfile.MoshPortTo;
}
private async Task BrowseForIdentityFile()
{
var file = await _fileSystemService.OpenFile(new[] { "*" });
if (file != null)
{
IdentityFile = file.Path;
}
}
private string GetArgumentsString()
{
StringBuilder sb = new StringBuilder();
if (_sshPort != SshProfile.DefaultSshPort)
sb.Append($"-p {_sshPort:#####} ");
if (!string.IsNullOrEmpty(_identityFile))
sb.Append($"-i \"{_identityFile}\" ");
sb.Append($"{_username}@{_host}");
if (_useMosh)
sb.Append($" {_moshPortFrom}:{_moshPortTo}");
return sb.ToString();
}
protected override void LoadFromProfile(ShellProfile profile)
{
base.LoadFromProfile(profile);
CommandInput = _commandInputOriginal;
Initialize((SshProfile)profile);
}
protected override void CopyToProfile(ShellProfile profile)
{
base.CopyToProfile(profile);
_commandInputOriginal = _commandInput;
if (_commandInput)
{
CopyToProfileQuick((SshProfile)profile);
}
else
{
CopyToProfileFull((SshProfile)profile);
}
}
private void CopyToProfileFull(SshProfile sshProfile)
{
sshProfile.Location = _useMosh ? Constants.MoshCommandName : Constants.SshCommandName;
sshProfile.Arguments = GetArgumentsString();
sshProfile.WorkingDirectory = null;
sshProfile.Host = _host;
sshProfile.SshPort = _sshPort;
sshProfile.Username = _username;
sshProfile.IdentityFile = _identityFile;
sshProfile.UseMosh = _useMosh;
sshProfile.MoshPortFrom = _moshPortFrom;
sshProfile.MoshPortTo = _moshPortTo;
}
private void CopyToProfileQuick(SshProfile sshProfile)
{
var command = _command?.Trim();
if (string.IsNullOrEmpty(command))
{
sshProfile.Location = null;
sshProfile.Arguments = null;
return;
}
var match = CommandValidationRx.Match(command);
if (!match.Success)
{
// Should not happen ever because this method gets called only if validation succeeds.
throw new Exception("Invalid command.");
}
sshProfile.Location = match.Groups["cmd"].Value;
sshProfile.Arguments = match.Groups["args"].Success ? match.Groups["args"].Value.Trim() : null;
sshProfile.WorkingDirectory = null;
sshProfile.Host = null;
sshProfile.SshPort = 0;
sshProfile.Username = null;
sshProfile.IdentityFile = null;
sshProfile.UseMosh = false;
sshProfile.MoshPortFrom = 0;
sshProfile.MoshPortTo = 0;
}
public override Task<string> ValidateAsync()
{
return _commandInput ? ValidateQuickAsync() : ValidateFullAsync();
}
private async Task<string> ValidateFullAsync()
{
var error = await base.ValidateAsync();
if (!string.IsNullOrEmpty(error))
{
return error;
}
var result = await GetSshInfoValidationResultAsync();
if (result == SshConnectionInfoValidationResult.Valid)
{
return null;
}
error = GetErrorString(result, Environment.NewLine);
if (string.IsNullOrEmpty(error))
{
error = "Invalid input.";
}
return error;
}
private async Task<string> ValidateQuickAsync()
{
var error = await base.ValidateAsync();
if (!string.IsNullOrEmpty(error))
{
return error;
}
var match = CommandValidationRx.Match(_command?.Trim() ?? string.Empty);
if (!match.Success)
{
error = I18N.Translate("InvalidCommand");
return string.IsNullOrEmpty(error) ? "Invalid command." : error;
}
if (!AcceptableCommands.Any(c => c.Equals(match.Groups["cmd"].Value, StringComparison.OrdinalIgnoreCase)))
{
error = I18N.Translate("UnsupportedCommand");
return string.IsNullOrEmpty(error)
? $"Unsupported command: {match.Groups["cmd"]}."
: $"{error} {match.Groups["cmd"]}";
}
if (!match.Groups["args"].Success)
{
error = I18N.Translate("CommandArgumentsMandatory");
return string.IsNullOrEmpty(error) ? "Command arguments are missing." : error;
}
return null;
}
public override bool HasChanges()
{
// ReSharper disable once ArrangeRedundantParentheses
if (base.HasChanges() || (_commandInput != _commandInputOriginal))
{
return true;
}
if (_commandInput)
{
return !_command.NullableEqualTo($"{Model.Location} {Model.Arguments}");
}
var original = (SshProfile)Model;
return base.HasChanges() || !original.Host.NullableEqualTo(_host) || original.SshPort != _sshPort ||
!original.Username.NullableEqualTo(_username) ||
!original.IdentityFile.NullableEqualTo(_identityFile) || original.UseMosh != _useMosh ||
original.MoshPortFrom != _moshPortFrom || original.MoshPortTo != _moshPortTo;
}
public async Task<SshConnectionInfoValidationResult> GetSshInfoValidationResultAsync()
{
var result = SshConnectionInfoValidationResult.Valid;
if (string.IsNullOrEmpty(_username))
{
result |= SshConnectionInfoValidationResult.UsernameEmpty;
}
if (string.IsNullOrEmpty(_host))
{
result |= SshConnectionInfoValidationResult.HostEmpty;
}
if (_sshPort < 1)
{
result |= SshConnectionInfoValidationResult.SshPortZeroOrNegative;
}
if (!await CheckIdentityFileExistsAsync())
{
result |= SshConnectionInfoValidationResult.IdentityFileDoesNotExist;
}
if (!_useMosh)
{
return result;
}
if (_moshPortFrom < 1)
{
result |= SshConnectionInfoValidationResult.MoshPortZeroOrNegative;
}
if (_moshPortFrom > _moshPortTo)
{
result |= SshConnectionInfoValidationResult.MoshPortRangeInvalid;
}
return result;
}
private async Task<bool> CheckIdentityFileExistsAsync()
{
var identityFile = _identityFile;
if (string.IsNullOrEmpty(identityFile) ||
identityFile.Equals(_validatedIdentityFile, StringComparison.OrdinalIgnoreCase))
{
return true;
}
// Here we need to take into account that files from ssh config dir can be provided by name, without full path.
string fullPath;
if (Path.IsPathRooted(identityFile))
{
fullPath = identityFile;
}
else
{
var sshConfigDir = await _trayProcessCommunicationService.GetSshConfigDirAsync();
if (string.IsNullOrEmpty(sshConfigDir))
{
return false;
}
fullPath = Path.Combine(sshConfigDir, identityFile);
}
if (await _trayProcessCommunicationService.CheckFileExistsAsync(fullPath))
{
_validatedIdentityFile = identityFile;
IdentityFile = fullPath;
return true;
}
return false;
}
public void SetValidatedIdentityFile(string identityFile)
{
_validatedIdentityFile = identityFile;
IdentityFile = identityFile;
}
#endregion Methods
#region Command history
private const string ExecutedCommandsKey = "ExecutedCommands";
private const int CommandHistoryLimit = 20;
private void FillCommandHistory()
{
var commands = _historyContainer.ReadValueFromJson<ExecutedCommandHistory>(ExecutedCommandsKey, null)
?.ExecutedCommands;
CommandHistory = commands == null
? new ObservableCollection<string>()
: new ObservableCollection<string>(commands.Select(c => $"{c.Command} {c.Args}"));
}
public void SaveCommand(string cmd, string args)
{
cmd = cmd?.Trim();
if (string.IsNullOrEmpty(cmd))
{
throw new ArgumentNullException(nameof(cmd));
}
var commandHistory =
_historyContainer.ReadValueFromJson<ExecutedCommandHistory>(ExecutedCommandsKey, null) ??
new ExecutedCommandHistory { ExecutedCommands = new List<ExecutedCommand>() };
var command = commandHistory.ExecutedCommands.FirstOrDefault(c =>
string.Equals(cmd, c.Command, StringComparison.Ordinal) && args.NullableEqualTo(c.Args));
if (command == null)
{
command = new ExecutedCommand
{ Command = cmd, Args = args, ExecutionCount = 1, LastExecution = DateTime.UtcNow };
}
else
{
command.ExecutionCount++;
command.LastExecution = DateTime.UtcNow;
commandHistory.ExecutedCommands.Remove(command);
}
commandHistory.ExecutedCommands.Insert(0, command);
while (commandHistory.ExecutedCommands.Count > CommandHistoryLimit)
{
commandHistory.ExecutedCommands.RemoveAt(CommandHistoryLimit);
}
_historyContainer.WriteValueAsJson(ExecutedCommandsKey, commandHistory);
}
#endregion Command history
#region Links/shortcuts related
// Resources:
// https://tools.ietf.org/html/draft-ietf-secsh-scp-sftp-ssh-uri-04#section-3
// https://man.openbsd.org/ssh
private const string SshUriScheme = "ssh";
private const string MoshUriScheme = "mosh";
private const string CustomSshUriScheme = "sshft";
private const string CustomUriHost = "fluent.terminal";
private const string CustomCommandQueryStringName = "cmd";
private const string CustomArgumentsQueryStringName = "args";
// Constant derived from https://man.openbsd.org/ssh
private const string IdentityFileOptionName = "IdentityFile";
private static readonly string[] ValidMoshPortsNames = { "mosh_ports", "mosh-ports" };
private static readonly Regex MoshRangeRx =
new Regex(@"^(?<from>\d{1,5})[:-](?<to>\d{1,5})$", RegexOptions.Compiled);
public static bool CheckScheme(Uri uri) => CheckSchemeStandard(uri) || CheckSchemeCustom(uri);
private static bool CheckSchemeStandard(Uri uri) =>
SshUriScheme.Equals(uri?.Scheme, StringComparison.OrdinalIgnoreCase) ||
MoshUriScheme.Equals(uri?.Scheme, StringComparison.OrdinalIgnoreCase);
private static bool CheckSchemeCustom(Uri uri) =>
CustomSshUriScheme.Equals(uri?.Scheme, StringComparison.OrdinalIgnoreCase);
public static SshConnectViewModel ParseUri(Uri uri, ISettingsService settingsService,
IApplicationView applicationView, ITrayProcessCommunicationService trayProcessCommunicationService,
IFileSystemService fileSystemService, IApplicationDataContainer historyContainer)
{
if (CheckSchemeStandard(uri))
{
return ParseUriStandard(uri, settingsService, applicationView, trayProcessCommunicationService,
fileSystemService, historyContainer);
}
if (CheckSchemeCustom(uri))
{
return ParseUriCustom(uri, settingsService, applicationView, trayProcessCommunicationService,
fileSystemService, historyContainer);
}
// Won't happen ever
throw new Exception($"Unsupported URI scheme: {uri.Scheme}");
}
private static SshConnectViewModel ParseUriStandard(Uri uri, ISettingsService settingsService,
IApplicationView applicationView, ITrayProcessCommunicationService trayProcessCommunicationService,
IFileSystemService fileSystemService, IApplicationDataContainer historyContainer)
{
var vm = new SshConnectViewModel(settingsService, applicationView, trayProcessCommunicationService,
fileSystemService, historyContainer, false)
{
Host = uri.Host,
UseMosh = MoshUriScheme.Equals(uri.Scheme, StringComparison.OrdinalIgnoreCase)
};
if (uri.Port >= 0)
vm.SshPort = (ushort)uri.Port;
if (!string.IsNullOrEmpty(uri.UserInfo))
{
string[] parts = uri.UserInfo.Split(';');
if (parts.Length > 2)
throw new FormatException($"UserInfo part contains {parts.Length} elements.");
vm.Username = HttpUtility.UrlDecode(parts[0]);
if (parts.Length > 1)
{
// For now we are only interested in IdentityFile option
Tuple<string, string> identityFileOption = ParseParams(parts[1], ',').FirstOrDefault(p =>
string.Equals(p.Item1, IdentityFileOptionName, StringComparison.OrdinalIgnoreCase));
vm.IdentityFile = identityFileOption?.Item2;
}
}
// ReSharper disable once ConstantConditionalAccessQualifier
string queryString = uri.Query?.Trim();
if (string.IsNullOrEmpty(queryString))
{
return vm;
}
if (queryString.StartsWith("?", StringComparison.Ordinal))
{
queryString = queryString.Substring(1);
}
var queryStringParams = ParseParams(queryString, '&').ToList();
var moshPorts = queryStringParams.FirstOrDefault(p =>
ValidMoshPortsNames.Any(n => string.Equals(p.Item1, n, StringComparison.OrdinalIgnoreCase)));
if (moshPorts != null)
{
Match match = MoshRangeRx.Match(moshPorts.Item2);
if (match.Success)
{
vm.MoshPortFrom = ushort.Parse(match.Groups["from"].Value);
vm.MoshPortTo = ushort.Parse(match.Groups["to"].Value);
}
}
vm.LoadBaseFromQueryString(queryStringParams);
return vm;
}
private static SshConnectViewModel ParseUriCustom(Uri uri, ISettingsService settingsService,
IApplicationView applicationView, ITrayProcessCommunicationService trayProcessCommunicationService,
IFileSystemService fileSystemService, IApplicationDataContainer historyContainer)
{
var vm = new SshConnectViewModel(settingsService, applicationView, trayProcessCommunicationService,
fileSystemService, historyContainer, true);
// ReSharper disable once ConstantConditionalAccessQualifier
string queryString = uri.Query?.Trim();
if (string.IsNullOrEmpty(queryString))
{
return vm;
}
if (queryString.StartsWith("?", StringComparison.Ordinal))
{
queryString = queryString.Substring(1);
}
var queryStringParams = ParseParams(queryString, '&').ToList();
var cmdParam = queryStringParams.FirstOrDefault(t =>
CustomCommandQueryStringName.Equals(t.Item1, StringComparison.OrdinalIgnoreCase));
var argsParam = queryStringParams.FirstOrDefault(t =>
CustomArgumentsQueryStringName.Equals(t.Item1, StringComparison.OrdinalIgnoreCase));
vm._command =
$"{(string.IsNullOrEmpty(cmdParam?.Item2) ? Constants.SshCommandName : cmdParam.Item2)} {argsParam?.Item2}"
.Trim();
vm.LoadBaseFromQueryString(queryStringParams);
return vm;
}
public override Task<Tuple<bool, string>> GetUrlAsync()
{
return _commandInput ? GetUrlQuickAsync() : GetUrlFullAsync();
}
private async Task<Tuple<bool, string>> GetUrlFullAsync()
{
var error = await base.ValidateAsync();
if (!string.IsNullOrEmpty(error))
{
return Tuple.Create(false, error);
}
var result = await GetSshInfoValidationResultAsync();
if (result != SshConnectionInfoValidationResult.Valid &&
// For links we can ignore missing username
result != SshConnectionInfoValidationResult.UsernameEmpty)
{
error = GetErrorString(result, Environment.NewLine);
if (string.IsNullOrEmpty(error))
{
error = "Invalid input.";
}
return Tuple.Create(false, error);
}
StringBuilder sb = new StringBuilder(_useMosh ? MoshUriScheme : SshUriScheme);
sb.Append("://");
bool containsUserInfo = false;
if (!string.IsNullOrEmpty(_username))
{
sb.Append(HttpUtility.UrlEncode(_username));
containsUserInfo = true;
}
if (!string.IsNullOrEmpty(_identityFile))
{
sb.Append(";");
if (!string.IsNullOrEmpty(_identityFile))
{
sb.Append($"{IdentityFileOptionName}={HttpUtility.UrlEncode(_identityFile)}");
}
containsUserInfo = true;
}
if (containsUserInfo)
sb.Append("@");
sb.Append(_host);
if (_sshPort != SshProfile.DefaultSshPort)
{
sb.Append($":{_sshPort:#####}");
}
sb.Append("/");
bool queryStringAdded = false;
if (_useMosh)
{
sb.Append(
$"?{ValidMoshPortsNames[0]}={_moshPortFrom:#####}-{_moshPortTo:#####}");
queryStringAdded = true;
}
var terminalInfoQueryString = GetBaseQueryString();
if (!string.IsNullOrEmpty(terminalInfoQueryString))
{
sb.Append($"{(queryStringAdded ? "&" : "?")}{terminalInfoQueryString}");
}
return Tuple.Create(true, sb.ToString());
}
private async Task<Tuple<bool, string>> GetUrlQuickAsync()
{
var error = await base.ValidateAsync();
if (!string.IsNullOrEmpty(error))
{
return Tuple.Create(false, error);
}
var match = CommandValidationRx.Match(_command);
if (!match.Success)
{
error = I18N.Translate("InvalidCommand");
return Tuple.Create(false, string.IsNullOrEmpty(error) ? "Invalid command." : error);
}
return Tuple.Create(true,
$"{CustomSshUriScheme}://{CustomUriHost}/?{CustomCommandQueryStringName}={Constants.SshCommandName}&{CustomArgumentsQueryStringName}={HttpUtility.UrlEncode(match.Groups["args"].Value.Trim())}&{GetBaseQueryString()}");
}
#endregion Links/shortcuts related
}
}
@@ -1,6 +1,6 @@
using System;
namespace FluentTerminal.Models.Enums
namespace FluentTerminal.App.ViewModels.Profiles
{
[Flags]
public enum SshConnectionInfoValidationResult
@@ -22,15 +22,17 @@ namespace FluentTerminal.App.ViewModels.Settings
private string _startupTaskErrorMessage;
private bool _needsToRestart;
private readonly IApplicationLanguageService _applicationLanguageService;
private readonly ITrayProcessCommunicationService _trayProcessCommunicationService;
public GeneralPageViewModel(ISettingsService settingsService, IDialogService dialogService, IDefaultValueProvider defaultValueProvider,
IStartupTaskService startupTaskService, IApplicationLanguageService applicationLanguageService)
IStartupTaskService startupTaskService, IApplicationLanguageService applicationLanguageService, ITrayProcessCommunicationService trayProcessCommunicationService)
{
_settingsService = settingsService;
_dialogService = dialogService;
_defaultValueProvider = defaultValueProvider;
_startupTaskService = startupTaskService;
_applicationLanguageService = applicationLanguageService;
_trayProcessCommunicationService = trayProcessCommunicationService;
_applicationSettings = _settingsService.GetApplicationSettings();
@@ -154,6 +156,20 @@ namespace FluentTerminal.App.ViewModels.Settings
}
}
public bool UseQuickSshConnectByDefault
{
get => _applicationSettings.UseQuickSshConnectByDefault;
set
{
if (_applicationSettings.UseQuickSshConnectByDefault != value)
{
_applicationSettings.UseQuickSshConnectByDefault = value;
_settingsService.SaveApplicationSettings(_applicationSettings);
RaisePropertyChanged();
}
}
}
public bool RTrimCopiedLines
{
get => _applicationSettings.RTrimCopiedLines;
@@ -168,6 +184,22 @@ namespace FluentTerminal.App.ViewModels.Settings
}
}
public bool MuteTerminalBeeps
{
get => _applicationSettings.MuteTerminalBeeps;
set
{
if (_applicationSettings.MuteTerminalBeeps != value)
{
_applicationSettings.MuteTerminalBeeps = value;
_settingsService.SaveApplicationSettings(_applicationSettings);
RaisePropertyChanged();
_trayProcessCommunicationService.MuteTerminal(value);
}
}
}
public bool BottomIsSelected
{
get => TabsPosition == TabsPosition.Bottom;
@@ -335,6 +367,7 @@ namespace FluentTerminal.App.ViewModels.Settings
UseMoshByDefault = defaults.UseMoshByDefault;
AutoFallbackToWindowsUsernameInLinks = defaults.AutoFallbackToWindowsUsernameInLinks;
RTrimCopiedLines = defaults.RTrimCopiedLines;
MuteTerminalBeeps = defaults.MuteTerminalBeeps;
}
}
@@ -98,7 +98,7 @@ namespace FluentTerminal.App.ViewModels.Settings
private void Clone(ShellProfileViewModel shellProfile)
{
var cloned = shellProfile.Model.Clone();
var cloned = shellProfile.ProfileVm.Model.Clone();
cloned.Id = Guid.NewGuid();
cloned.PreInstalled = false;
@@ -109,8 +109,6 @@ namespace FluentTerminal.App.ViewModels.Settings
private void AddShellProfile(ShellProfile shellProfile)
{
_settingsService.SaveShellProfile(shellProfile, true);
var viewModel = new ShellProfileViewModel(shellProfile, _settingsService, _dialogService, _fileSystemService, _applicationView, _defaultValueProvider, true);
viewModel.EditCommand.Execute(null);
viewModel.SetAsDefault += OnShellProfileSetAsDefault;
@@ -16,28 +16,28 @@ namespace FluentTerminal.App.ViewModels.Settings
private readonly IFileSystemService _fileSystemService;
private SshProfileViewModel _selectedShellProfile;
private readonly IApplicationView _applicationView;
private readonly IDefaultValueProvider _defaultValueProvider;
private readonly ITrayProcessCommunicationService _trayProcessCommunicationService;
private readonly IApplicationDataContainer _historyContainer;
public SshProfilesPageViewModel(ISettingsService settingsService, IDialogService dialogService,
IFileSystemService fileSystemService, IApplicationView applicationView,
IDefaultValueProvider defaultValueProvider,
ITrayProcessCommunicationService trayProcessCommunicationService)
ITrayProcessCommunicationService trayProcessCommunicationService,
IApplicationDataContainer historyContainer)
{
_settingsService = settingsService;
_dialogService = dialogService;
_fileSystemService = fileSystemService;
_applicationView = applicationView;
_defaultValueProvider = defaultValueProvider;
_trayProcessCommunicationService = trayProcessCommunicationService;
_historyContainer = historyContainer;
CreateSshProfileCommand = new RelayCommand(CreateSshProfile);
CloneCommand = new RelayCommand<SshProfileViewModel>(Clone);
foreach (var sshProfile in _settingsService.GetSshProfiles())
{
var viewModel = new SshProfileViewModel(sshProfile, settingsService, dialogService,
fileSystemService, applicationView, defaultValueProvider, _trayProcessCommunicationService, false);
var viewModel = new SshProfileViewModel(sshProfile, settingsService, dialogService, fileSystemService,
applicationView, _trayProcessCommunicationService, historyContainer, false);
viewModel.Deleted += OnSshProfileDeleted;
SshProfiles.Add(viewModel);
}
@@ -83,7 +83,7 @@ namespace FluentTerminal.App.ViewModels.Settings
private void Clone(SshProfileViewModel shellProfile)
{
var cloned = (SshProfile) shellProfile.Model.Clone();
var cloned = (SshProfile) shellProfile.ProfileVm.Model.Clone();
cloned.Id = Guid.NewGuid();
cloned.PreInstalled = false;
@@ -99,7 +99,7 @@ namespace FluentTerminal.App.ViewModels.Settings
private void AddSshProfile(SshProfile sshProfile)
{
var viewModel = new SshProfileViewModel(sshProfile, _settingsService, _dialogService, _fileSystemService,
_applicationView, _defaultValueProvider, _trayProcessCommunicationService, true);
_applicationView, _trayProcessCommunicationService, _historyContainer, true);
viewModel.EditCommand.Execute(null);
viewModel.Deleted += OnSshProfileDeleted;
@@ -9,16 +9,18 @@ namespace FluentTerminal.App.ViewModels
{
public SettingsViewModel(ISettingsService settingsService, IDefaultValueProvider defaultValueProvider, IDialogService dialogService,
ITrayProcessCommunicationService trayProcessCommunicationService, IThemeParserFactory themeParserFactory, ISystemFontService systemFontService,
IFileSystemService fileSystemService, IStartupTaskService startupTaskService, IUpdateService updateService, IApplicationView applicationView, IApplicationLanguageService applicationLanguageService)
IFileSystemService fileSystemService, IStartupTaskService startupTaskService, IUpdateService updateService, IApplicationView applicationView,
IApplicationLanguageService applicationLanguageService, ApplicationDataContainers containers)
{
About = new AboutPageViewModel(settingsService, updateService, applicationView);
KeyBindings = new KeyBindingsPageViewModel(settingsService, dialogService, defaultValueProvider, trayProcessCommunicationService);
General = new GeneralPageViewModel(settingsService, dialogService, defaultValueProvider, startupTaskService, applicationLanguageService);
General = new GeneralPageViewModel(settingsService, dialogService, defaultValueProvider, startupTaskService, applicationLanguageService, trayProcessCommunicationService);
Profiles = new ProfilesPageViewModel(settingsService, dialogService, defaultValueProvider, fileSystemService, applicationView);
Terminal = new TerminalPageViewModel(settingsService, dialogService, defaultValueProvider, systemFontService);
Themes = new ThemesPageViewModel(settingsService, dialogService, defaultValueProvider, themeParserFactory, fileSystemService);
Mouse = new MousePageViewModel(settingsService, dialogService, defaultValueProvider);
SshProfiles = new SshProfilesPageViewModel(settingsService, dialogService, fileSystemService, applicationView, defaultValueProvider, trayProcessCommunicationService);
SshProfiles = new SshProfilesPageViewModel(settingsService, dialogService, fileSystemService,
applicationView, trayProcessCommunicationService, containers.HistoryContainer);
}
public event EventHandler Closed;
@@ -1,73 +1,28 @@
using FluentTerminal.App.Services;
using FluentTerminal.App.Services.Utilities;
using FluentTerminal.App.ViewModels.Infrastructure;
using FluentTerminal.App.ViewModels.Settings;
using FluentTerminal.Models;
using FluentTerminal.Models.Enums;
using GalaSoft.MvvmLight;
using GalaSoft.MvvmLight.Command;
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using FluentTerminal.App.ViewModels.Profiles;
namespace FluentTerminal.App.ViewModels
{
public class ShellProfileViewModel : ViewModelBase
/// <summary>
/// Extends <see cref="ShellProfileViewModelBase{T}"/>, and implements only "Set as default" logic.
/// </summary>
public class ShellProfileViewModel : ShellProfileViewModelBase<CommonProfileProviderViewModel>
{
#region Static
private static readonly LineEndingStyle[] LineEndingStylesArray =
Enum.GetValues(typeof(LineEndingStyle)).Cast<LineEndingStyle>().ToArray();
#endregion Static
#region Fields
private ShellProfile _fallbackProfile;
protected readonly IDialogService DialogService;
protected readonly IFileSystemService FileSystemService;
protected readonly ISettingsService SettingsService;
protected readonly IApplicationView ApplicationView;
protected readonly IDefaultValueProvider DefaultValueProvider;
private readonly IDefaultValueProvider _defaultValueProvider;
#endregion Fields
#region Properties
protected bool IsNew { get; set; }
public Guid Id { get; private set; }
public ShellProfile Model { get; private set; }
public bool PreInstalled { get; private set; }
private bool _useConPty;
public bool UseConPty
{
get => _useConPty;
set => Set(ref _useConPty, value);
}
private string _arguments;
public string Arguments
{
get => _arguments;
set => Set(ref _arguments, value);
}
private bool _inEditMode;
public bool InEditMode
{
get => _inEditMode;
set => Set(ref _inEditMode, value);
}
private bool _isDefault;
public bool IsDefault
@@ -76,180 +31,35 @@ namespace FluentTerminal.App.ViewModels
set => Set(ref _isDefault, value);
}
private string _location;
public string Location
{
get => _location;
set => Set(ref _location, value);
}
private string _name;
public string Name
{
get => _name;
set => Set(ref _name, value);
}
private TabTheme _selectedTabTheme;
public TabTheme SelectedTabTheme
{
get => _selectedTabTheme ?? (_selectedTabTheme = TabThemes.FirstOrDefault(t => t.Id.Equals(_tabThemeId)));
set
{
if (value != null && Set(ref _selectedTabTheme, value))
{
_tabThemeId = value.Id;
}
}
}
private int _tabThemeId;
public int TabThemeId
{
get => _tabThemeId;
set
{
if (Set(ref _tabThemeId, value))
{
SelectedTabTheme = TabThemes.FirstOrDefault(t => t.Id.Equals(value));
}
}
}
private TerminalTheme _selectedTerminalTheme;
public TerminalTheme SelectedTerminalTheme
{
get => _selectedTerminalTheme ??
(_selectedTerminalTheme = TerminalThemes.FirstOrDefault(t => t.Id.Equals(_terminalThemeId)));
set
{
if (value != null && Set(ref _selectedTerminalTheme, value))
{
_terminalThemeId = value.Id;
}
}
}
private Guid _terminalThemeId;
public Guid TerminalThemeId
{
get => _terminalThemeId;
set
{
if (Set(ref _terminalThemeId, value))
{
SelectedTerminalTheme = TerminalThemes.FirstOrDefault(t => t.Id.Equals(value));
}
}
}
private string _workingDirectory;
public string WorkingDirectory
{
get => _workingDirectory;
set => Set(ref _workingDirectory, value);
}
private LineEndingStyle _lineEndingTranslation;
public LineEndingStyle LineEndingTranslation
{
get => _lineEndingTranslation;
set => Set(ref _lineEndingTranslation, value);
}
public ObservableCollection<TabTheme> TabThemes { get; }
public KeyBindingsViewModel KeyBindings { get; }
public ObservableCollection<TerminalTheme> TerminalThemes { get; }
public ObservableCollection<LineEndingStyle> LineEndingStyles { get; } =
new ObservableCollection<LineEndingStyle>(LineEndingStylesArray);
#endregion Properties
#region Events
public event EventHandler Deleted;
public event EventHandler SetAsDefault;
#endregion Events
#region Commands
public IAsyncCommand BrowseForCustomShellCommand { get; }
public IAsyncCommand BrowseForWorkingDirectoryCommand { get; }
public IAsyncCommand CancelEditCommand { get; }
public IAsyncCommand DeleteCommand { get; }
public RelayCommand EditCommand { get; }
public IAsyncCommand SaveChangesCommand { get; }
public RelayCommand SetDefaultCommand { get; }
public IAsyncCommand RestoreDefaultsCommand { get; }
public IAsyncCommand AddKeyboardShortcutCommand { get; }
#endregion Commands
#region Constrcutor
public ShellProfileViewModel(ShellProfile shellProfile, ISettingsService settingsService,
IDialogService dialogService, IFileSystemService fileSystemService, IApplicationView applicationView,
IDefaultValueProvider defaultValueProvider, bool isNew)
IDefaultValueProvider defaultValueProvider, bool isNew) : base(shellProfile, settingsService, dialogService,
isNew)
{
Model = shellProfile;
SettingsService = settingsService;
DialogService = dialogService;
FileSystemService = fileSystemService;
ApplicationView = applicationView;
DefaultValueProvider = defaultValueProvider;
IsNew = isNew;
_defaultValueProvider = defaultValueProvider;
SettingsService.ThemeAdded += OnThemeAdded;
SettingsService.ThemeDeleted += OnThemeDeleted;
TabThemes = new ObservableCollection<TabTheme>(settingsService.GetTabThemes());
TerminalThemes = new ObservableCollection<TerminalTheme>
{
new TerminalTheme
{
Id = Guid.Empty,
Name = "Default"
}
};
foreach (var theme in SettingsService.GetThemes())
{
TerminalThemes.Add(theme);
}
KeyBindings = new KeyBindingsViewModel(shellProfile.Id.ToString(), DialogService, string.Empty, false);
InitializeViewModelPropertiesPrivate(shellProfile);
ProfileVm = new CommonProfileProviderViewModel(settingsService, applicationView, fileSystemService,
shellProfile);
SetDefaultCommand = new RelayCommand(SetDefault);
DeleteCommand = new AsyncCommand(Delete, CanDelete);
EditCommand = new RelayCommand(Edit);
CancelEditCommand = new AsyncCommand(CancelEdit);
SaveChangesCommand = new AsyncCommand(SaveChangesAsync);
AddKeyboardShortcutCommand = new AsyncCommand(AddKeyboardShortcut);
BrowseForCustomShellCommand = new AsyncCommand(BrowseForCustomShell);
BrowseForWorkingDirectoryCommand = new AsyncCommand(BrowseForWorkingDirectory);
RestoreDefaultsCommand = new AsyncCommand(RestoreDefaults);
}
@@ -257,97 +67,9 @@ namespace FluentTerminal.App.ViewModels
#region Methods
// Loads view model properties from the input shellProfile
private void InitializeViewModelPropertiesPrivate(ShellProfile shellProfile)
{
Id = shellProfile.Id;
Name = shellProfile.Name;
Arguments = shellProfile.Arguments;
Location = shellProfile.Location;
WorkingDirectory = shellProfile.WorkingDirectory;
TerminalThemeId = shellProfile.TerminalThemeId;
TabThemeId = shellProfile.TabThemeId;
PreInstalled = shellProfile.PreInstalled;
LineEndingTranslation = shellProfile.LineEndingTranslation;
UseConPty = shellProfile.UseConPty;
KeyBindings.Clear();
foreach (var keyBinding in shellProfile.KeyBindings.Select(x => new KeyBinding(x)).ToList())
{
KeyBindings.Add(keyBinding);
}
}
// Loads view model properties from the input shellProfile
protected virtual void InitializeViewModelProperties(ShellProfile shellProfile) =>
InitializeViewModelPropertiesPrivate(shellProfile);
// Fills the input profile properties the view model properties. (Inverse of InitializeViewModelProperties()).
protected virtual Task FillProfileAsync(ShellProfile profile)
{
profile.Id = Id;
profile.Name = Name;
profile.Arguments = Arguments;
profile.Location = Location;
profile.WorkingDirectory = WorkingDirectory;
profile.TerminalThemeId = TerminalThemeId;
profile.TabThemeId = TabThemeId;
profile.PreInstalled = PreInstalled;
profile.LineEndingTranslation = LineEndingTranslation;
profile.UseConPty = UseConPty;
profile.KeyBindings = KeyBindings.KeyBindings.Select(x => x.Model).ToList();
return Task.CompletedTask;
}
// Used to "remember" the current state.
protected virtual async Task<ShellProfile> CreateProfileAsync()
{
var profile = new ShellProfile();
await FillProfileAsync(profile);
return profile;
}
private void OnThemeAdded(object sender, TerminalTheme e)
{
ApplicationView.RunOnDispatcherThread(() =>
{
TerminalThemes.Add(e);
});
}
private void OnThemeDeleted(object sender, Guid e)
{
if (SelectedTerminalTheme.Id == e)
{
ApplicationView.RunOnDispatcherThread(() =>
{
SelectedTerminalTheme = TerminalThemes.FirstOrDefault(x => x.Id == Guid.Empty);
Model.TerminalThemeId = Guid.Empty;
if (_fallbackProfile != null)
{
_fallbackProfile.TerminalThemeId = Guid.Empty;
}
});
}
}
public virtual async Task SaveChangesAsync()
{
await FillProfileAsync(Model);
SettingsService.SaveShellProfile(Model);
KeyBindings.Editable = false;
InEditMode = false;
IsNew = false;
}
private async Task RestoreDefaults()
{
if (InEditMode || !PreInstalled)
if (InEditMode || !ProfileVm.PreInstalled)
{
throw new InvalidOperationException();
}
@@ -357,87 +79,24 @@ namespace FluentTerminal.App.ViewModels
if (result == DialogButton.OK)
{
Model = DefaultValueProvider.GetPreinstalledShellProfiles().FirstOrDefault(x => x.Id == Model.Id);
InitializeViewModelProperties(Model);
SettingsService.SaveShellProfile(Model);
}
}
var profile = _defaultValueProvider.GetPreinstalledShellProfiles().FirstOrDefault(x => x.Id.Equals(Id));
public Task AddKeyboardShortcut()
{
return KeyBindings.ShowAddKeyBindingDialog();
}
private async Task BrowseForCustomShell()
{
var file = await FileSystemService.OpenFile(new[] { ".exe" }).ConfigureAwait(true);
if (file != null)
{
Location = file.Path;
}
}
private async Task BrowseForWorkingDirectory()
{
var directory = await FileSystemService.BrowseForDirectory().ConfigureAwait(true);
if (directory != null)
{
WorkingDirectory = directory;
}
}
private async Task CancelEdit()
{
if (IsNew)
{
await Delete();
}
else
{
ShellProfile changedProfile = await CreateProfileAsync();
if (!_fallbackProfile.EqualTo(changedProfile))
if (profile == null)
{
var result = await DialogService.ShowMessageDialogAsnyc(I18N.Translate("PleaseConfirm"), I18N.Translate("ConfirmDiscardChanges"), DialogButton.OK, DialogButton.Cancel).ConfigureAwait(true);
if (result == DialogButton.OK)
{
// Cancelled, so rollback
InitializeViewModelProperties(_fallbackProfile);
KeyBindings.Editable = false;
InEditMode = false;
}
}
else
{
KeyBindings.Editable = false;
InEditMode = false;
// Should not happen ever, but...
return;
}
ProfileVm.Model = profile;
Initialize(profile);
SettingsService.SaveShellProfile(profile);
}
}
private bool CanDelete()
protected override bool CanDelete()
{
return !Model.PreInstalled;
}
private async Task Delete()
{
var result = await DialogService.ShowMessageDialogAsnyc(I18N.Translate("PleaseConfirm"), I18N.Translate("ConfirmDeleteProfile"), DialogButton.OK, DialogButton.Cancel).ConfigureAwait(true);
if (result == DialogButton.OK)
{
Deleted?.Invoke(this, EventArgs.Empty);
}
}
private void Edit()
{
_fallbackProfile = Model.Clone();
KeyBindings.Editable = true;
InEditMode = true;
return !ProfileVm.PreInstalled;
}
private void SetDefault()
@@ -0,0 +1,219 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FluentTerminal.App.Services;
using FluentTerminal.App.Services.Utilities;
using FluentTerminal.App.ViewModels.Infrastructure;
using FluentTerminal.App.ViewModels.Profiles;
using FluentTerminal.App.ViewModels.Settings;
using FluentTerminal.Models;
using GalaSoft.MvvmLight;
using GalaSoft.MvvmLight.Command;
namespace FluentTerminal.App.ViewModels
{
/// <summary>
/// Base class for all shell profile view models. Implements logic for saving, editing, resetting, etc.
/// </summary>
/// <typeparam name="T"></typeparam>
public abstract class ShellProfileViewModelBase<T> : ViewModelBase where T : ProfileProviderViewModelBase
{
#region Fields
protected readonly IDialogService DialogService;
protected readonly ISettingsService SettingsService;
private List<KeyBinding> _bindingsBeforeEdit;
private string _nameBeforeEdit;
#endregion Fields
#region Properties
protected bool IsNew { get; private set; }
public Guid Id { get; private set; }
private bool _inEditMode;
public bool InEditMode
{
get => _inEditMode;
set => Set(ref _inEditMode, value);
}
private string _name;
public string Name
{
get => _name;
set => Set(ref _name, value);
}
public KeyBindingsViewModel KeyBindings { get; }
public T ProfileVm { get; protected set; }
#endregion Properties
#region Events
public event EventHandler Deleted;
#endregion Events
#region Commands
public IAsyncCommand CancelEditCommand { get; }
public IAsyncCommand DeleteCommand { get; }
public RelayCommand EditCommand { get; }
public IAsyncCommand SaveChangesCommand { get; }
public IAsyncCommand AddKeyboardShortcutCommand { get; }
#endregion Commands
#region Constructor
protected ShellProfileViewModelBase(ShellProfile shellProfile, ISettingsService settingsService, IDialogService dialogService, bool isNew)
{
SettingsService = settingsService;
DialogService = dialogService;
IsNew = isNew;
KeyBindings = new KeyBindingsViewModel(shellProfile.Id.ToString(), dialogService, string.Empty, false);
Initialize(shellProfile);
DeleteCommand = new AsyncCommand(Delete, CanDelete);
EditCommand = new RelayCommand(Edit);
CancelEditCommand = new AsyncCommand(CancelEdit);
SaveChangesCommand = new AsyncCommand(SaveChangesAsync);
AddKeyboardShortcutCommand = new AsyncCommand(AddKeyboardShortcut);
}
#endregion Constructor
#region Methods
// Loads view model properties from the input shellProfile
protected void Initialize(ShellProfile shellProfile)
{
Id = shellProfile.Id;
Name = shellProfile.Name;
KeyBindings.Clear();
foreach (var keyBinding in shellProfile.KeyBindings.Select(x => new KeyBinding(x)).ToList())
{
KeyBindings.Add(keyBinding);
}
}
protected virtual bool HasChanges()
{
return ProfileVm.HasChanges() || !_nameBeforeEdit.NullableEqualTo(_name) ||
!(_bindingsBeforeEdit?.SequenceEqual(KeyBindings.KeyBindings.Select(x => x.Model).ToList()) ??
false);
}
private async Task Delete()
{
var result = await DialogService.ShowMessageDialogAsnyc(I18N.Translate("PleaseConfirm"),
I18N.Translate("ConfirmDeleteProfile"), DialogButton.OK, DialogButton.Cancel).ConfigureAwait(true);
if (result == DialogButton.OK)
{
Deleted?.Invoke(this, EventArgs.Empty);
}
}
protected abstract bool CanDelete();
private void Edit()
{
if (InEditMode)
{
return;
}
_bindingsBeforeEdit = KeyBindings.KeyBindings.Select(x => x.Model).ToList();
_nameBeforeEdit = _name;
KeyBindings.Editable = true;
InEditMode = true;
}
private async Task CancelEdit()
{
if (IsNew)
{
await Delete();
}
else
{
if (HasChanges())
{
var result = await DialogService.ShowMessageDialogAsnyc(I18N.Translate("PleaseConfirm"),
I18N.Translate("ConfirmDiscardChanges"), DialogButton.OK, DialogButton.Cancel)
.ConfigureAwait(true);
if (result == DialogButton.OK)
{
// Cancelled, so rollback
ProfileVm.RejectChanges();
KeyBindings.Editable = false;
InEditMode = false;
}
}
else
{
KeyBindings.Editable = false;
InEditMode = false;
}
}
}
public virtual async Task SaveChangesAsync()
{
var error = await ProfileVm.AcceptChangesAsync();
if (!string.IsNullOrEmpty(error))
{
await DialogService.ShowMessageDialogAsnyc(I18N.Translate("InvalidInput"), error, DialogButton.OK);
return;
}
var profile = ProfileVm.Model;
profile.Id = Id;
profile.Name = _name;
profile.KeyBindings = KeyBindings.KeyBindings.Select(x => x.Model).ToList();
if (profile is SshProfile sshProfile)
{
SettingsService.SaveSshProfile(sshProfile, IsNew);
}
else
{
SettingsService.SaveShellProfile(profile, IsNew);
}
KeyBindings.Editable = false;
InEditMode = false;
IsNew = false;
}
public Task AddKeyboardShortcut()
{
return KeyBindings.ShowAddKeyBindingDialog();
}
#endregion Methods
}
}
@@ -1,275 +1,34 @@
using FluentTerminal.App.Services;
using FluentTerminal.App.Services.Utilities;
using FluentTerminal.App.ViewModels.Infrastructure;
using FluentTerminal.App.ViewModels.Profiles;
using FluentTerminal.Models;
using FluentTerminal.Models.Enums;
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace FluentTerminal.App.ViewModels
{
public class SshProfileViewModel : ShellProfileViewModel, ISshConnectionInfo
{
#region Fields
private readonly ITrayProcessCommunicationService _trayProcessCommunicationService;
// To prevent validating the existence of the same file multiple times because it's kinda expensive
private string _validatedIdentityFile;
#endregion Fields
#region Properties
private string _host;
public string Host
{
get => _host;
set => Set(ref _host, value);
}
private ushort _sshPort;
public ushort SshPort
{
get => _sshPort;
set => Set(ref _sshPort, value);
}
private string _username;
public string Username
{
get => _username;
set => Set(ref _username, value);
}
private string _identityFile;
public string IdentityFile
{
get => _identityFile;
set => Set(ref _identityFile, value);
}
private bool _useMosh;
public bool UseMosh
{
get => _useMosh;
set => Set(ref _useMosh, value);
}
private ushort _moshPortFrom;
public ushort MoshPortFrom
{
get => _moshPortFrom;
set => Set(ref _moshPortFrom, value);
}
private ushort _moshPortTo;
public ushort MoshPortTo
{
get => _moshPortTo;
set => Set(ref _moshPortTo, value);
}
#endregion Properties
#region Commands
public IAsyncCommand BrowseForIdentityFileCommand { get; }
#endregion Commands
/// <summary>
/// Extends <see cref="ShellProfileViewModelBase{T}"/>, and doesn't implement any additional logic because in
/// case of SSH profiles no additional logic is needed.
/// </summary>
public class SshProfileViewModel : ShellProfileViewModelBase<SshConnectViewModel>
{
#region Constructor
public SshProfileViewModel(SshProfile sshProfile, ISettingsService settingsService,
IDialogService dialogService, IFileSystemService fileSystemService, IApplicationView applicationView,
IDefaultValueProvider defaultValueProvider,
ITrayProcessCommunicationService trayProcessCommunicationService, bool isNew) : base(
sshProfile ?? new SshProfile(), settingsService, dialogService, fileSystemService, applicationView,
defaultValueProvider, isNew)
ITrayProcessCommunicationService trayProcessCommunicationService,
IApplicationDataContainer historyContainer, bool isNew) : base(sshProfile, settingsService, dialogService,
isNew)
{
_trayProcessCommunicationService = trayProcessCommunicationService;
InitializeViewModelPropertiesPrivate(sshProfile ?? new SshProfile());
BrowseForIdentityFileCommand = new AsyncCommand(BrowseForIdentityFile);
ProfileVm = new SshConnectViewModel(settingsService, applicationView, trayProcessCommunicationService,
fileSystemService, historyContainer, sshProfile);
}
#endregion Constructor
#region Methods
// Fills the remaining view model properties (those which aren't filled by the base method) from the input sshProfile
private void InitializeViewModelPropertiesPrivate(SshProfile sshProfile)
protected override bool CanDelete()
{
Host = sshProfile.Host;
SshPort = sshProfile.SshPort;
Username = sshProfile.Username;
if (string.IsNullOrEmpty(Username))
{
_trayProcessCommunicationService.GetUserName().ContinueWith(t =>
{
var username = t.Result;
if (string.IsNullOrEmpty(Username) && !string.IsNullOrEmpty(username))
{
ApplicationView.RunOnDispatcherThread(() => Username = username);
}
}, TaskContinuationOptions.OnlyOnRanToCompletion);
}
IdentityFile = sshProfile.IdentityFile;
UseMosh = sshProfile.UseMosh;
MoshPortFrom = sshProfile.MoshPortFrom;
MoshPortTo = sshProfile.MoshPortTo;
}
protected override void InitializeViewModelProperties(ShellProfile sshProfile)
{
base.InitializeViewModelProperties(sshProfile);
InitializeViewModelPropertiesPrivate((SshProfile)sshProfile);
}
protected override async Task FillProfileAsync(ShellProfile profile)
{
await base.FillProfileAsync(profile);
profile.Location = _useMosh ? ShellLocation.Mosh : ShellLocation.SSH;
profile.Arguments = GetArgumentsString();
profile.WorkingDirectory = null;
var sshProfile = (SshProfile)profile;
sshProfile.Host = Host;
sshProfile.SshPort = SshPort;
sshProfile.Username = Username;
sshProfile.IdentityFile = IdentityFile;
sshProfile.UseMosh = UseMosh;
sshProfile.MoshPortFrom = MoshPortFrom;
sshProfile.MoshPortTo = MoshPortTo;
}
public virtual Task AcceptChangesAsync() => FillProfileAsync(Model);
protected override async Task<ShellProfile> CreateProfileAsync()
{
var profile = new SshProfile();
await FillProfileAsync(profile);
return profile;
}
private string GetArgumentsString()
{
StringBuilder sb = new StringBuilder();
if (_sshPort != SshProfile.DefaultSshPort)
sb.Append($"-p {_sshPort:#####} ");
if (!string.IsNullOrEmpty(_identityFile))
sb.Append($"-i \"{_identityFile}\" ");
sb.Append($"{_username}@{_host}");
if (_useMosh)
sb.Append($" {_moshPortFrom}:{_moshPortTo}");
return sb.ToString();
}
public override async Task SaveChangesAsync()
{
var result = await ValidateAsync();
if (result != SshConnectionInfoValidationResult.Valid)
{
await DialogService.ShowMessageDialogAsnyc(I18N.Translate("InvalidInput"),
result.GetErrorString(Environment.NewLine), DialogButton.OK);
return;
}
await AcceptChangesAsync();
SettingsService.SaveSshProfile((SshProfile) Model, IsNew);
KeyBindings.Editable = false;
InEditMode = false;
IsNew = false;
}
private async Task BrowseForIdentityFile()
{
var file = await FileSystemService.OpenFile(new[] { "*" }).ConfigureAwait(true);
if (file != null)
{
IdentityFile = file.Path;
}
}
public async Task<SshConnectionInfoValidationResult> ValidateAsync()
{
var result = this.GetValidationResult();
var identityFile = _identityFile;
if (!string.IsNullOrEmpty(identityFile) && !string.Equals(identityFile, _validatedIdentityFile,
StringComparison.OrdinalIgnoreCase))
{
// Here we need to take into account that files from ssh config dir can be provided by name, without full path.
string fullPath;
if (identityFile.Contains(Path.PathSeparator))
{
fullPath = identityFile;
}
else
{
var sshConfigDir = await _trayProcessCommunicationService.GetSshConfigDirAsync();
if (string.IsNullOrEmpty(sshConfigDir))
{
result |= SshConnectionInfoValidationResult.IdentityFileDoesNotExist;
return result;
}
fullPath = Path.Combine(sshConfigDir, identityFile);
}
if (await _trayProcessCommunicationService.CheckFileExistsAsync(fullPath))
{
_validatedIdentityFile = identityFile;
IdentityFile = fullPath;
}
else
{
result |= SshConnectionInfoValidationResult.IdentityFileDoesNotExist;
}
}
return result;
}
public void SetValidatedIdentityFile(string identityFile)
{
_validatedIdentityFile = identityFile;
IdentityFile = identityFile;
return true;
}
#endregion Methods
@@ -47,8 +47,15 @@ namespace FluentTerminal.App.Adapters
return ApiInformation.IsApiContractPresent(api, version);
}
public Task RunOnDispatcherThread(Action action)
public Task RunOnDispatcherThread(Action action, bool enforceNewSchedule = true)
{
if (!enforceNewSchedule && _dispatcher.HasThreadAccess)
{
action();
return Task.CompletedTask;
}
return _dispatcher.RunAsync(CoreDispatcherPriority.Normal, () => action()).AsTask();
}
+32 -25
View File
@@ -35,6 +35,7 @@ using Windows.UI.Xaml.Controls;
using FluentTerminal.App.Utilities;
using IContainer = Autofac.IContainer;
using FluentTerminal.App.Services.Utilities;
using FluentTerminal.App.ViewModels.Profiles;
namespace FluentTerminal.App
{
@@ -44,7 +45,6 @@ namespace FluentTerminal.App
private readonly TaskCompletionSource<int> _trayReady = new TaskCompletionSource<int>();
private readonly ISettingsService _settingsService;
private readonly ITrayProcessCommunicationService _trayProcessCommunicationService;
private readonly Lazy<ISshHelperService> _sshHelperService;
private readonly IDialogService _dialogService;
private bool _alreadyLaunched;
private bool _isLaunching;
@@ -73,7 +73,8 @@ namespace FluentTerminal.App
KeyBindings = new ApplicationDataContainerAdapter(ApplicationData.Current.RoamingSettings.CreateContainer(Constants.KeyBindingsContainerName, ApplicationDataCreateDisposition.Always)),
ShellProfiles = new ApplicationDataContainerAdapter(ApplicationData.Current.LocalSettings.CreateContainer(Constants.ShellProfilesContainerName, ApplicationDataCreateDisposition.Always)),
Themes = new ApplicationDataContainerAdapter(ApplicationData.Current.RoamingSettings.CreateContainer(Constants.ThemesContainerName, ApplicationDataCreateDisposition.Always)),
SshProfiles = new ApplicationDataContainerAdapter(ApplicationData.Current.RoamingSettings.CreateContainer(Constants.SshProfilesContainerName, ApplicationDataCreateDisposition.Always))
SshProfiles = new ApplicationDataContainerAdapter(ApplicationData.Current.RoamingSettings.CreateContainer(Constants.SshProfilesContainerName, ApplicationDataCreateDisposition.Always)),
HistoryContainer = new ApplicationDataContainerAdapter(ApplicationData.Current.RoamingSettings.CreateContainer(Constants.ExecutedCommandsContainerName, ApplicationDataCreateDisposition.Always))
};
var builder = new ContainerBuilder();
builder.RegisterType<SettingsService>().As<ISettingsService>().SingleInstance();
@@ -100,7 +101,6 @@ namespace FluentTerminal.App
builder.RegisterType<ApplicationViewAdapter>().As<IApplicationView>().InstancePerDependency();
builder.RegisterType<DispatcherTimerAdapter>().As<IDispatcherTimer>().InstancePerDependency();
builder.RegisterType<StartupTaskService>().As<IStartupTaskService>().SingleInstance();
builder.RegisterType<SshHelperService>().As<ISshHelperService>().SingleInstance();
builder.RegisterType<ApplicationLanguageService>().As<IApplicationLanguageService>().SingleInstance();
builder.RegisterInstance(applicationDataContainers);
@@ -111,8 +111,6 @@ namespace FluentTerminal.App
_trayProcessCommunicationService = _container.Resolve<ITrayProcessCommunicationService>();
_sshHelperService = new Lazy<ISshHelperService>(() => _container.Resolve<ISshHelperService>());
_dialogService = _container.Resolve<IDialogService>();
_applicationSettings = _settingsService.GetApplicationSettings();
@@ -169,21 +167,31 @@ namespace FluentTerminal.App
if (args is ProtocolActivatedEventArgs protocolActivated)
{
MainViewModel mainViewModel = null;
// IApplicationView to use for creating view models
IApplicationView applicationView;
if (!_alreadyLaunched)
if (_alreadyLaunched)
{
applicationView =
(_mainViewModels.FirstOrDefault(o => o.ApplicationView.Id == _activeWindowId) ??
_mainViewModels.Last()).ApplicationView;
}
else
{
// App wasn't launched before double clicking a shortcut, so we have to create a window
// in order to be able to communicate with user.
mainViewModel = _container.Resolve<MainViewModel>();
await CreateMainView(typeof(MainPage), mainViewModel, true);
applicationView = mainViewModel.ApplicationView;
}
bool isSsh;
try
{
isSsh = _sshHelperService.Value.IsSsh(protocolActivated.Uri);
isSsh = SshConnectViewModel.CheckScheme(protocolActivated.Uri);
}
catch (Exception ex)
{
@@ -196,11 +204,13 @@ namespace FluentTerminal.App
if (isSsh)
{
SshProfileViewModel connectionInfo;
SshConnectViewModel vm;
try
{
connectionInfo = (SshProfileViewModel) _sshHelperService.Value.ParseSsh(protocolActivated.Uri);
vm = SshConnectViewModel.ParseUri(protocolActivated.Uri, _settingsService, applicationView,
_trayProcessCommunicationService, _container.Resolve<IFileSystemService>(),
_container.Resolve<ApplicationDataContainers>().HistoryContainer);
}
catch (Exception ex)
{
@@ -211,25 +221,22 @@ namespace FluentTerminal.App
return;
}
if (_applicationSettings.AutoFallbackToWindowsUsernameInLinks &&
string.IsNullOrEmpty(connectionInfo.Username))
if (!vm.CommandInput && _applicationSettings.AutoFallbackToWindowsUsernameInLinks &&
string.IsNullOrEmpty(vm.Username))
{
connectionInfo.Username = await _trayProcessCommunicationService.GetUserName();
vm.Username = await _trayProcessCommunicationService.GetUserName();
}
SshConnectionInfoValidationResult result = await connectionInfo.ValidateAsync();
var error = await vm.AcceptChangesAsync(true);
if (result == SshConnectionInfoValidationResult.Valid)
{
await connectionInfo.AcceptChangesAsync();
}
else
SshProfile profile = (SshProfile) vm.Model;
if (!string.IsNullOrEmpty(error))
{
// Link is valid, but incomplete (i.e. username missing), so we need to show dialog.
connectionInfo =
(SshProfileViewModel) await _dialogService.ShowSshConnectionInfoDialogAsync(connectionInfo);
profile = await _dialogService.ShowSshConnectionInfoDialogAsync(profile);
if (connectionInfo == null)
if (profile == null)
{
// User clicked "Cancel" in the dialog.
mainViewModel?.ApplicationView.TryClose();
@@ -239,9 +246,9 @@ namespace FluentTerminal.App
}
if (mainViewModel == null)
await CreateTerminal(connectionInfo.Model, _applicationSettings.NewTerminalLocation);
await CreateTerminal(profile, _applicationSettings.NewTerminalLocation);
else
await mainViewModel.AddTerminalAsync(connectionInfo.Model);
await mainViewModel.AddTerminalAsync(profile);
return;
}
@@ -551,7 +558,7 @@ namespace FluentTerminal.App
}
break;
case NewWindowAction.ShowSshProfileSelection:
profile = await _sshHelperService.Value.GetSavedSshProfileAsync();
profile = await _dialogService.ShowSshProfileSelectionDialogAsync();
if (profile == null)
{
// Nothing to do if user cancels.
@@ -559,7 +566,7 @@ namespace FluentTerminal.App
}
break;
case NewWindowAction.ShowSshInfoDialog:
profile = await _sshHelperService.Value.GetSshProfileAsync(new SshProfile());
profile = await _dialogService.ShowSshConnectionInfoDialogAsync();
if (profile == null)
{
// Nothing to do if user cancels.
+10 -2
View File
@@ -6,15 +6,23 @@ namespace FluentTerminal.App.Converters
{
public class I18NConverter : IValueConverter
{
// To avoid empty translations
private static string Translate(string resource)
{
var translation = I18N.Translate(resource);
return string.IsNullOrEmpty(translation) ? $"[ {resource} ]" : translation;
}
public object Convert(object value, Type targetType, object parameter, string language)
{
if (value is Enum enumValue && parameter is string enumType)
{
return I18N.Translate($"{enumType}.{enumValue}");
return Translate($"{enumType}.{enumValue}");
}
else if (parameter is string resource)
{
return I18N.Translate(resource);
return Translate(resource);
}
return null;
}
+229 -232
View File
@@ -6,271 +6,268 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:enums="using:FluentTerminal.Models.Enums"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="using:FluentTerminal.Models"
xmlns:views="using:FluentTerminal.App.Views"
xmlns:vm="using:FluentTerminal.App.ViewModels"
xmlns:profiles="using:FluentTerminal.App.ViewModels.Profiles"
x:Uid="SshInfoDialog"
Title="SSH Info"
d:DataContext="{d:DesignInstance vm:SshProfileViewModel,
d:DataContext="{d:DesignInstance profiles:SshConnectViewModel,
IsDesignTimeCreatable=False}"
DefaultButton="Primary"
Loading="OnLoading"
PrimaryButtonClick="SshInfoDialog_OnPrimaryButtonClick"
mc:Ignorable="d">
<ContentDialog.Resources>
<views:TabThemeTemplateSelector x:Key="TabThemeTemplateSelector">
<views:TabThemeTemplateSelector.DefaultTemplate>
<DataTemplate x:DataType="models:TabTheme">
<StackPanel Orientation="Horizontal">
<FontIcon
Margin="0,0,6,0"
FontFamily="Segoe MDL2 Assets"
Glyph="&#xF126;" />
<TextBlock Text="{x:Bind Name, Mode=OneWay}" />
</StackPanel>
</DataTemplate>
</views:TabThemeTemplateSelector.DefaultTemplate>
<views:TabThemeTemplateSelector.ColoredTemplate>
<DataTemplate x:DataType="models:TabTheme">
<StackPanel Orientation="Horizontal">
<FontIcon
Margin="0,0,6,0"
FontFamily="Segoe MDL2 Assets"
Foreground="{x:Bind Color}"
Glyph="&#xF127;" />
<TextBlock Text="{x:Bind Name, Mode=OneWay}" />
</StackPanel>
</DataTemplate>
</views:TabThemeTemplateSelector.ColoredTemplate>
</views:TabThemeTemplateSelector>
<views:TerminalThemeTemplateSelector x:Key="TerminalThemeTemplateSelector">
<views:TerminalThemeTemplateSelector.DefaultTemplate>
<DataTemplate x:DataType="models:TerminalTheme">
<TextBlock FontStyle="Italic" Text="{x:Bind Name}" />
</DataTemplate>
</views:TerminalThemeTemplateSelector.DefaultTemplate>
<views:TerminalThemeTemplateSelector.ThemeTemplate>
<DataTemplate x:DataType="models:TerminalTheme">
<TextBlock Text="{x:Bind Name}" />
</DataTemplate>
</views:TerminalThemeTemplateSelector.ThemeTemplate>
</views:TerminalThemeTemplateSelector>
<ContentDialog.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<views:TemplateSelectors />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</ContentDialog.Resources>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<Grid Width="500">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
<RowDefinition />
<RowDefinition Height="Auto" />
<RowDefinition />
</Grid.RowDefinitions>
<TextBox
x:Name="UserTextBox"
x:Uid="UserTextBox"
Grid.Row="0"
Grid.Column="0"
Width="140"
PlaceholderText="user"
Text="{Binding Username, Mode=TwoWay}" />
<TextBlock
Grid.Row="0"
Grid.Column="1"
FontSize="22"
Text="@" />
<TextBox
x:Name="HostTextBox"
x:Uid="HostTextBox"
Grid.Row="0"
Grid.Column="2"
PlaceholderText="host"
Text="{Binding Host, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock
Grid.Row="0"
Grid.Column="3"
Margin="2,0,0,0"
FontSize="22"
Text=":" />
<TextBox
x:Uid="PortTextBox"
Grid.Row="0"
Grid.Column="4"
Margin="0,0,4,0"
HorizontalAlignment="Stretch"
BeforeTextChanging="Port_OnBeforeTextChanging"
PlaceholderText="port"
Text="{Binding SshPort, Mode=TwoWay}" />
<TextBox
x:Uid="IdentityFileTextBox"
Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="3"
Margin="0,10,0,0"
IsReadOnly="False"
PlaceholderText="Identity file path"
Text="{Binding IdentityFile, Mode=TwoWay}" />
<Button
x:Uid="BrowseButton"
Grid.Row="1"
Grid.Column="4"
Margin="0,10,4,0"
HorizontalAlignment="Stretch"
Click="BrowseButtonOnClick"
Content="Browse" />
<CheckBox
x:Uid="UseMoshCheckBox"
Grid.Row="2"
Grid.Column="0"
Grid.ColumnSpan="5"
Margin="0,10,0,0"
Content="Use Mosh"
IsChecked="{Binding UseMosh, Mode=TwoWay}" />
<controls:Expander
x:Uid="AdvancedExpander"
Grid.Row="3"
Grid.Column="0"
Grid.ColumnSpan="5"
Margin="0,10,0,0"
Padding="0,10,0,10"
HorizontalContentAlignment="Stretch"
Header="Advanced"
IsExpanded="False">
<Grid Margin="5,20,5,20">
<StackPanel Orientation="Vertical" Width="500">
<CheckBox
x:Uid="UseCommandInput"
Content="Use quick SSH"
IsChecked="{Binding CommandInput, Mode=TwoWay}" />
<Grid HorizontalAlignment="Stretch"
Visibility="{Binding CommandInput, Converter={StaticResource FalseToVisibleConverter}}"
Margin="0 8 2 0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="8" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
<RowDefinition />
<RowDefinition />
<RowDefinition Height="Auto" />
<RowDefinition />
</Grid.RowDefinitions>
<TextBlock
x:Uid="LineEndingModeOnPaste"
Grid.Row="0"
Grid.Column="0"
Margin="0,0,8,0"
VerticalAlignment="Center"
Text="Line ending on paste:" />
<ComboBox
Grid.Row="0"
Grid.Column="1"
Grid.ColumnSpan="3"
Margin="0,0,0,0"
VerticalAlignment="Center"
ItemsSource="{x:Bind LineEndingStyles}"
SelectedItem="{Binding LineEndingTranslation, Mode=TwoWay}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="enums:LineEndingStyle">
<TextBlock Text="{x:Bind Converter={StaticResource I18NConverter}, ConverterParameter=LineEndingStyle}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock
x:Uid="MoshPorts"
Grid.Row="1"
Grid.Column="0"
Margin="0,8,8,0"
VerticalAlignment="Center"
Text="Mosh ports:"
Visibility="{Binding UseMosh}" />
<!-- Row -->
<TextBox
Grid.Row="1"
Grid.Column="1"
Margin="0,8,0,0"
VerticalAlignment="Center"
BeforeTextChanging="Port_OnBeforeTextChanging"
Text="{Binding MoshPortFrom, Mode=TwoWay}"
Visibility="{Binding UseMosh}" />
x:Name="UserTextBox"
x:Uid="UserTextBox"
Grid.Row="0"
Grid.Column="0"
Width="140"
PlaceholderText="user"
Text="{Binding Username, Mode=TwoWay}" />
<TextBlock
Grid.Row="1"
Grid.Row="0"
Grid.Column="1"
FontSize="22"
Text="@" />
<TextBox
Grid.Row="0"
Grid.Column="2"
Margin="2,8,0,0"
VerticalAlignment="Center"
Text=":"
Visibility="{Binding UseMosh}" />
<TextBox
Grid.Row="1"
x:Name="HostTextBox"
x:Uid="HostTextBox"
PlaceholderText="host"
Text="{Binding Host, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock
Grid.Row="0"
Grid.Column="3"
Margin="0,8,0,0"
VerticalAlignment="Center"
Margin="2,0,0,0"
FontSize="22"
Text=":" />
<TextBox
x:Uid="PortTextBox"
Grid.Row="0"
Grid.Column="4"
HorizontalAlignment="Stretch"
BeforeTextChanging="Port_OnBeforeTextChanging"
Text="{Binding MoshPortTo, Mode=TwoWay}"
Visibility="{Binding UseMosh}" />
PlaceholderText="port"
Text="{Binding SshPort, Mode=TwoWay}" />
<TextBlock
x:Uid="SshInfoDialog_TabColor"
Grid.Row="2"
<!-- Row -->
<TextBox
x:Uid="IdentityFileTextBox"
Grid.Row="1"
Grid.Column="0"
Margin="0,8,8,0"
VerticalAlignment="Center"
Text="Tab color:" />
<ComboBox
Grid.Row="2"
Grid.Column="1"
Grid.ColumnSpan="3"
Margin="0,8,0,0"
IsReadOnly="False"
PlaceholderText="Identity file path"
Text="{Binding IdentityFile, Mode=TwoWay}"
Margin="0,8,0,0" />
<Button
x:Uid="BrowseButton"
Grid.Row="1"
Grid.Column="4"
HorizontalAlignment="Stretch"
ItemTemplateSelector="{StaticResource TabThemeTemplateSelector}"
ItemsSource="{x:Bind TabThemes, Mode=OneWay}"
SelectedItem="{Binding SelectedTabTheme, Mode=TwoWay}" />
<TextBlock
x:Uid="Theme"
Grid.Row="3"
Grid.Column="0"
Margin="0,8,8,0"
VerticalAlignment="Center"
Text="Theme:" />
<ComboBox
Grid.Row="3"
Grid.Column="1"
Grid.ColumnSpan="3"
Margin="0,8,0,0"
HorizontalAlignment="Stretch"
ItemTemplateSelector="{StaticResource TerminalThemeTemplateSelector}"
ItemsSource="{x:Bind TerminalThemes, Mode=OneWay}"
SelectedItem="{Binding SelectedTerminalTheme, Mode=TwoWay}" />
Click="BrowseButtonOnClick"
Content="Browse"
Margin="0,8,0,0" />
<!-- Row -->
<CheckBox
Grid.Row="4"
x:Uid="UseMoshCheckBox"
Grid.Row="2"
Grid.Column="0"
Grid.ColumnSpan="4"
Margin="0,8,0,0"
Content="{x:Bind Converter={StaticResource I18NConverter}, ConverterParameter=UseConPty}"
IsChecked="{Binding UseConPty, Mode=TwoWay}" />
Grid.ColumnSpan="5"
Content="Use Mosh"
IsChecked="{Binding UseMosh, Mode=TwoWay}"
Margin="0,8,0,0"/>
</Grid>
</controls:Expander>
<Grid
Grid.Row="4"
Grid.Column="0"
Grid.ColumnSpan="5">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button
x:Uid="SaveShortcutButton"
Grid.Row="0"
Grid.Column="1"
Margin="0,10,4,0"
HorizontalAlignment="Stretch"
Click="SaveLink_OnClick"
Content="Save Shortcut" />
</Grid>
</Grid>
<TextBox x:Name="CommandTextBox"
x:Uid="CommandTextBox"
PlaceholderText="SSH command (e.g. 'ssh username@host')"
Text="{Binding Command, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
Visibility="{Binding CommandInput, Converter={StaticResource TrueToVisibleConverter}}"
Margin="0 8 2 0"/>
<controls:Expander
x:Uid="AdvancedExpander"
Margin="0,10,0,0"
Padding="0,10,0,10"
HorizontalContentAlignment="Stretch"
Header="Advanced"
IsExpanded="False">
<Grid Margin="5,20,5,20">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="8" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
<RowDefinition />
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<!-- Row -->
<TextBlock
x:Uid="MoshPorts"
Grid.Row="0"
Grid.Column="0"
VerticalAlignment="Center"
Text="Mosh ports:"
Visibility="{Binding MoshVisible}"
Margin="0,8,8,0" />
<TextBox
Grid.Row="0"
Grid.Column="1"
VerticalAlignment="Center"
BeforeTextChanging="Port_OnBeforeTextChanging"
Text="{Binding MoshPortFrom, Mode=TwoWay}"
Visibility="{Binding MoshVisible}"
Margin="0,8,0,0" />
<TextBlock
Grid.Row="0"
Grid.Column="2"
VerticalAlignment="Center"
Text=":"
Visibility="{Binding MoshVisible}"
Margin="2,8,0,0" />
<TextBox
Grid.Row="0"
Grid.Column="3"
VerticalAlignment="Center"
BeforeTextChanging="Port_OnBeforeTextChanging"
Text="{Binding MoshPortTo, Mode=TwoWay}"
Visibility="{Binding MoshVisible}"
Margin="0,8,0,0" />
<!-- Row -->
<TextBlock
x:Uid="LineEndingModeOnPaste"
Grid.Row="1"
Grid.Column="0"
VerticalAlignment="Center"
Text="Line ending on paste:"
Margin="0,8,8,0" />
<ComboBox
Grid.Row="1"
Grid.Column="1"
Grid.ColumnSpan="3"
VerticalAlignment="Center"
HorizontalAlignment="Stretch"
ItemsSource="{Binding LineEndingStyles}"
SelectedItem="{Binding LineEndingTranslation, Mode=TwoWay}"
Margin="0,8,0,0">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="enums:LineEndingStyle">
<TextBlock
Text="{x:Bind Converter={StaticResource I18NConverter}, ConverterParameter=LineEndingStyle}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!-- Row -->
<TextBlock
x:Uid="SshInfoDialog_TabColor"
Grid.Row="2"
Grid.Column="0"
VerticalAlignment="Center"
Text="Tab color:"
Margin="0,8,8,0" />
<ComboBox
Grid.Row="2"
Grid.Column="1"
Grid.ColumnSpan="3"
HorizontalAlignment="Stretch"
ItemTemplateSelector="{StaticResource TabThemeTemplateSelector}"
ItemsSource="{Binding TabThemes, Mode=OneWay}"
SelectedItem="{Binding SelectedTabTheme, Mode=TwoWay}"
Margin="0,8,0,0" />
<!-- Row -->
<TextBlock
x:Uid="Theme"
Grid.Row="3"
Grid.Column="0"
VerticalAlignment="Center"
Text="Theme:"
Margin="0,8,8,0" />
<ComboBox
Grid.Row="3"
Grid.Column="1"
Grid.ColumnSpan="3"
HorizontalAlignment="Stretch"
ItemTemplateSelector="{StaticResource TerminalThemeTemplateSelector}"
ItemsSource="{Binding TerminalThemes, Mode=OneWay}"
SelectedItem="{Binding SelectedTerminalTheme, Mode=TwoWay}"
Margin="0,8,0,0" />
<!-- Row -->
<CheckBox
Grid.Row="4"
Grid.Column="0"
Grid.ColumnSpan="4"
Content="{x:Bind Converter={StaticResource I18NConverter}, ConverterParameter=UseConPty}"
IsChecked="{Binding UseConPty, Mode=TwoWay}"
Margin="0,8,0,0" />
</Grid>
</controls:Expander>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button
x:Uid="SaveShortcutButton"
Grid.Row="0"
Grid.Column="1"
Margin="0,10,4,0"
HorizontalAlignment="Stretch"
Click="SaveLink_OnClick"
Content="Save Shortcut" />
</Grid>
</StackPanel>
</ScrollViewer>
</ContentDialog>
</ContentDialog>
@@ -10,66 +10,49 @@ using Windows.UI.Xaml.Controls;
using FluentTerminal.App.Services.Dialogs;
using FluentTerminal.App.Utilities;
using FluentTerminal.App.Services;
using FluentTerminal.App.ViewModels;
using FluentTerminal.App.Services.Utilities;
using FluentTerminal.Models.Enums;
using FluentTerminal.App.ViewModels.Profiles;
using FluentTerminal.Models;
using System.Collections.ObjectModel;
namespace FluentTerminal.App.Dialogs
{
// ReSharper disable once RedundantExtendsListEntry
public sealed partial class SshInfoDialog : ContentDialog, ISshConnectionInfoDialog
{
private const string ShortcutFileFormat = @"[{{000214A0-0000-0000-C000-000000000046}}]
Prop3=19,0
[InternetShortcut]
IDList=
URL={0}
";
private readonly ISshHelperService _sshHelperService;
private readonly ISettingsService _settingsService;
private readonly IApplicationView _applicationView;
private readonly IFileSystemService _fileSystemService;
private readonly ITrayProcessCommunicationService _trayProcessCommunicationService;
private readonly IApplicationDataContainer _historyContainer;
public IEnumerable<LineEndingStyle> LineEndingStyles { get; } = (LineEndingStyle[])Enum.GetValues(typeof(LineEndingStyle));
public ObservableCollection<TabTheme> TabThemes { get; }
public ObservableCollection<TerminalTheme> TerminalThemes { get; }
public SshInfoDialog(ISettingsService settingsService, ISshHelperService sshHelperService,
ITrayProcessCommunicationService trayProcessCommunicationService)
public SshInfoDialog(ISettingsService settingsService, IApplicationView applicationView,
IFileSystemService fileSystemService, ITrayProcessCommunicationService trayProcessCommunicationService,
ApplicationDataContainers containers)
{
_sshHelperService = sshHelperService;
_settingsService = settingsService;
_applicationView = applicationView;
_fileSystemService = fileSystemService;
_trayProcessCommunicationService = trayProcessCommunicationService;
_historyContainer = containers.HistoryContainer;
InitializeComponent();
PrimaryButtonText = I18N.Translate("OK");
SecondaryButtonText = I18N.Translate("Cancel");
var currentTheme = settingsService.GetCurrentTheme();
RequestedTheme = ContrastHelper.GetIdealThemeForBackgroundColor(currentTheme.Colors.Background);
TabThemes = new ObservableCollection<TabTheme>(settingsService.GetTabThemes());
TerminalThemes = new ObservableCollection<TerminalTheme>
{
new TerminalTheme
{
Id = Guid.Empty,
Name = "Default"
}
};
foreach (var theme in settingsService.GetThemes())
{
TerminalThemes.Add(theme);
}
}
private void SetupFocus()
{
SshProfileViewModel vm = (SshProfileViewModel)DataContext;
SshConnectViewModel vm = (SshConnectViewModel) DataContext;
if (string.IsNullOrEmpty(vm.Username))
if (vm.CommandInput)
{
CommandTextBox.Focus(FocusState.Programmatic);
}
else if (string.IsNullOrEmpty(vm.Username))
{
UserTextBox.Focus(FocusState.Programmatic);
}
@@ -85,12 +68,12 @@ URL={0}
private async void OnLoading(FrameworkElement sender, object args)
{
SshProfileViewModel vm = (SshProfileViewModel)DataContext;
SshConnectViewModel vm = (SshConnectViewModel) DataContext;
if (!string.IsNullOrEmpty(vm.Username))
return;
vm.Username = await _trayProcessCommunicationService.GetUserName();
if (!vm.CommandInput && string.IsNullOrEmpty(vm.Username))
{
vm.Username = await _trayProcessCommunicationService.GetUserName();
}
SetupFocus();
}
@@ -102,36 +85,39 @@ URL={0}
openPicker.FileTypeFilter.Add("*");
StorageFile file = await openPicker.PickSingleFileAsync();
if (file != null)
{
((SshProfileViewModel)DataContext).SetValidatedIdentityFile(file.Path);
((SshConnectViewModel) DataContext).SetValidatedIdentityFile(file.Path);
}
}
private async void SaveLink_OnClick(object sender, RoutedEventArgs e)
{
SshProfileViewModel vm = (SshProfileViewModel) DataContext;
SshConnectViewModel vm = (SshConnectViewModel) DataContext;
var validationResult = await vm.ValidateAsync();
var link = await vm.GetUrlAsync();
if (validationResult != SshConnectionInfoValidationResult.Valid &&
// We may ignore empty username for links
validationResult != SshConnectionInfoValidationResult.UsernameEmpty)
if (!link.Item1)
{
await new MessageDialog(validationResult.GetErrorString(Environment.NewLine),
I18N.Translate("InvalidInput")).ShowAsync();
await new MessageDialog(link.Item2, I18N.Translate("InvalidInput")).ShowAsync();
return;
}
string content = string.Format(ShortcutFileFormat, await _sshHelperService.ConvertToUriAsync(vm));
var content = ProfileProviderViewModelBase.GetShortcutFileContent(link.Item2);
string fileName = string.IsNullOrEmpty(vm.Username) ? $"{vm.Host}.url" : $"{vm.Username}@{vm.Host}.url";
FileSavePicker savePicker = new FileSavePicker {SuggestedStartLocation = PickerLocationId.Desktop};
FileSavePicker savePicker = new FileSavePicker {SuggestedFileName = fileName, SuggestedStartLocation = PickerLocationId.Desktop};
if (!vm.CommandInput)
{
savePicker.SuggestedFileName = string.IsNullOrEmpty(vm.Username) ? $"{vm.Host}.url" : $"{vm.Username}@{vm.Host}.url";
}
savePicker.FileTypeChoices.Add("Shortcut", new List<string> {".url"});
StorageFile file = await savePicker.PickSaveFileAsync();
if (file == null)
{
return;
@@ -151,13 +137,13 @@ URL={0}
{
var deferral = args.GetDeferral();
var result = await ((ISshConnectionInfo) DataContext).ValidateAsync();
var error = await ((SshConnectViewModel)DataContext).AcceptChangesAsync();
if (result != SshConnectionInfoValidationResult.Valid)
if (!string.IsNullOrEmpty(error))
{
args.Cancel = true;
await new MessageDialog(result.GetErrorString(Environment.NewLine), I18N.Translate("InvalidInput")).ShowAsync();
await new MessageDialog(error, I18N.Translate("InvalidInput")).ShowAsync();
SetupFocus();
}
@@ -168,22 +154,26 @@ URL={0}
private void Port_OnBeforeTextChanging(TextBox sender, TextBoxBeforeTextChangingEventArgs args) =>
args.Cancel = string.IsNullOrEmpty(args.NewText) || args.NewText.Any(c => !char.IsDigit(c));
public async Task<ISshConnectionInfo> GetSshConnectionInfoAsync(ISshConnectionInfo input)
public async Task<SshProfile> GetSshConnectionInfoAsync(SshProfile input = null)
{
DataContext = input;
var vm = new SshConnectViewModel(_settingsService, _applicationView, _trayProcessCommunicationService,
_fileSystemService, _historyContainer, input);
Focus(FocusState.Programmatic);
DataContext = vm;
if (await ShowAsync() != ContentDialogResult.Primary)
{
return null;
}
var vm = (SshProfileViewModel) DataContext;
var profile = (SshProfile) vm.Model;
await vm.AcceptChangesAsync();
if (vm.CommandInput)
{
vm.SaveCommand(profile.Location, profile.Arguments);
}
return vm;
return profile;
}
}
}
+7 -1
View File
@@ -146,7 +146,6 @@
<Compile Include="Services\ApplicationLanguageService.cs" />
<Compile Include="Services\ClipboardService.cs" />
<Compile Include="Services\FileSystemService.cs" />
<Compile Include="Services\SshHelperService.cs" />
<Compile Include="Services\StartupTaskService.cs" />
<Compile Include="Services\SystemFontService.cs" />
<Compile Include="Utilities\ColorExtensions.cs" />
@@ -198,6 +197,9 @@
<DependentUpon>TabBar.xaml</DependentUpon>
</Compile>
<Compile Include="Views\TabBarBackgroundBindingHelper.cs" />
<Compile Include="Views\TemplateSelectors.xaml.cs">
<DependentUpon>TemplateSelectors.xaml</DependentUpon>
</Compile>
<Compile Include="Views\TerminalKeybindTemplateSelector.cs" />
<Compile Include="Views\TabThemeTemplateSelector.cs" />
<Compile Include="Views\TerminalColorPicker.xaml.cs">
@@ -457,6 +459,10 @@
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Views\TemplateSelectors.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Views\XtermTerminalView.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
+6 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest" xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5" xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10" IgnorableNamespaces="uap mp desktop rescap">
<Identity Name="53621FSApps.FluentTerminal" Publisher="CN=BDC72197-B634-438A-B863-16DCB7D90C85" Version="0.5.1.0" />
<Identity Name="53621FSApps.FluentTerminal" Publisher="CN=BDC72197-B634-438A-B863-16DCB7D90C85" Version="0.5.3.0" />
<mp:PhoneIdentity PhoneProductId="f7225014-f405-4342-ad2e-ebf6ac60ea2c" PhonePublisherId="00000000-0000-0000-0000-000000000000" />
<Properties>
<DisplayName>Fluent Terminal</DisplayName>
@@ -52,6 +52,11 @@
<uap:DisplayName>Mobile Shell Session</uap:DisplayName>
</uap:Protocol>
</uap:Extension>
<uap:Extension Category="windows.protocol">
<uap:Protocol Name="sshft">
<uap:DisplayName>Fluent-specific SSH</uap:DisplayName>
</uap:Protocol>
</uap:Extension>
</Extensions>
</Application>
</Applications>
@@ -1,289 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Web;
using FluentTerminal.App.ViewModels;
using FluentTerminal.Models;
using FluentTerminal.Models.Enums;
namespace FluentTerminal.App.Services
{
// Resources:
// https://tools.ietf.org/html/draft-ietf-secsh-scp-sftp-ssh-uri-04#section-3
// https://man.openbsd.org/ssh
public class SshHelperService : ISshHelperService
{
#region Constants
private const string SshUriScheme = "ssh";
private const string MoshUriScheme = "mosh";
private const string ThemeQueryStringParam = "theme";
private const string TabQueryStringParam = "tab";
// Constant derived from https://man.openbsd.org/ssh
private const string IdentityFileOptionName = "IdentityFile";
private static readonly string[] ValidMoshPortsNames = { "mosh_ports", "mosh-ports" };
private static readonly Regex MoshRangeRx =
new Regex(@"^(?<from>\d{1,5})[:-](?<to>\d{1,5})$", RegexOptions.Compiled);
#endregion Constants
#region Static
private static IEnumerable<Tuple<string, string>> ParseParams(string uriOpts, char separator) =>
uriOpts.Split(separator).Select(ParseSshOptionFromUri).Where(p => p != null);
private static Tuple<string, string> ParseSshOptionFromUri(string option)
{
string[] nv = option.Split('=');
if (nv.Length != 2 || string.IsNullOrEmpty(nv[0]))
{
// For now simply ignore invalid options
return null;
//throw new FormatException($"Invalid SSH option '{option}'.");
}
return Tuple.Create(HttpUtility.UrlDecode(nv[0]), HttpUtility.UrlDecode(nv[1]));
}
#endregion Static
#region Fields
private readonly ISettingsService _settingsService;
private readonly IDialogService _dialogService;
private readonly IFileSystemService _fileSystemService;
private readonly IApplicationView _applicationView;
private readonly IDefaultValueProvider _defaultValueProvider;
private readonly ITrayProcessCommunicationService _trayProcessCommunicationService;
#endregion Fields
#region Constructor
public SshHelperService(ISettingsService settingsService, IDialogService dialogService,
IFileSystemService fileSystemService, IApplicationView applicationView,
IDefaultValueProvider defaultValueProvider,
ITrayProcessCommunicationService trayProcessCommunicationService)
{
_settingsService = settingsService;
_dialogService = dialogService;
_fileSystemService = fileSystemService;
_applicationView = applicationView;
_defaultValueProvider = defaultValueProvider;
_trayProcessCommunicationService = trayProcessCommunicationService;
}
#endregion Constructor
#region Methods
public bool IsSsh(Uri uri) =>
SshUriScheme.Equals(uri?.Scheme, StringComparison.OrdinalIgnoreCase) ||
MoshUriScheme.Equals(uri?.Scheme, StringComparison.OrdinalIgnoreCase);
public ISshConnectionInfo ParseSsh(Uri uri)
{
SshProfileViewModel vm = new SshProfileViewModel(null, _settingsService, _dialogService,
_fileSystemService, _applicationView, _defaultValueProvider, _trayProcessCommunicationService, true)
{
Host = uri.Host,
UseMosh = MoshUriScheme.Equals(uri.Scheme, StringComparison.OrdinalIgnoreCase)
};
if (uri.Port >= 0)
vm.SshPort = (ushort)uri.Port;
if (!string.IsNullOrEmpty(uri.UserInfo))
{
string[] parts = uri.UserInfo.Split(';');
if (parts.Length > 2)
throw new FormatException($"UserInfo part contains {parts.Length} elements.");
vm.Username = HttpUtility.UrlDecode(parts[0]);
if (parts.Length > 1)
{
// For now we are only interested in IdentityFile option
Tuple<string, string> identityFileOption = ParseParams(parts[1], ',').FirstOrDefault(p =>
string.Equals(p.Item1, IdentityFileOptionName, StringComparison.OrdinalIgnoreCase));
vm.IdentityFile = identityFileOption?.Item2;
}
}
if (string.IsNullOrEmpty(uri.Query))
{
return vm;
}
string queryString = uri.Query?.Trim();
if (string.IsNullOrEmpty(queryString))
{
return vm;
}
if (queryString.StartsWith('?'))
{
queryString = queryString.Substring(1);
}
foreach (Tuple<string, string> param in ParseParams(queryString, '&'))
{
string paramName = param.Item1.ToLower();
if (ValidMoshPortsNames.Contains(paramName))
{
Match match = MoshRangeRx.Match(param.Item2);
if (match.Success)
{
vm.MoshPortFrom = ushort.Parse(match.Groups["from"].Value);
vm.MoshPortTo = ushort.Parse(match.Groups["to"].Value);
}
continue;
}
if (param.Item1.Equals(ThemeQueryStringParam, StringComparison.OrdinalIgnoreCase))
{
if (Guid.TryParse(param.Item2, out Guid themeId))
{
vm.TerminalThemeId = themeId;
}
continue;
}
if (param.Item2.Equals(TabQueryStringParam, StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(param.Item2, out int tabThemeId))
{
vm.TabThemeId = tabThemeId;
}
}
// For now we are ignoring unknown query string arguments
}
return vm;
}
public async Task<SshProfile> GetSshProfileAsync(SshProfile profile)
{
SshProfileViewModel vm = new SshProfileViewModel(profile, _settingsService, _dialogService,
_fileSystemService, _applicationView, _defaultValueProvider, _trayProcessCommunicationService, true);
vm.UseMosh = _settingsService.GetApplicationSettings().UseMoshByDefault;
vm = (SshProfileViewModel) await _dialogService.ShowSshConnectionInfoDialogAsync(vm);
return (SshProfile) vm?.Model;
}
public Task<SshProfile> GetSavedSshProfileAsync() =>
_dialogService.ShowSshProfileSelectionDialogAsync();
public async Task<string> ConvertToUriAsync(ISshConnectionInfo sshConnectionInfo)
{
var result = await sshConnectionInfo.ValidateAsync();
if (result != SshConnectionInfoValidationResult.Valid &&
// We can ignore empty username here
result != SshConnectionInfoValidationResult.UsernameEmpty)
throw new ArgumentException(result.GetErrorString(), nameof(sshConnectionInfo));
SshProfileViewModel sshConnectionInfoVm = (SshProfileViewModel) sshConnectionInfo;
StringBuilder sb = new StringBuilder(sshConnectionInfoVm.UseMosh ? MoshUriScheme : SshUriScheme);
sb.Append("://");
bool containsUserInfo = false;
if (!string.IsNullOrEmpty(sshConnectionInfoVm.Username))
{
sb.Append(HttpUtility.UrlEncode(sshConnectionInfoVm.Username));
containsUserInfo = true;
}
if (!string.IsNullOrEmpty(sshConnectionInfoVm.IdentityFile))
{
sb.Append(";");
if (!string.IsNullOrEmpty(sshConnectionInfoVm.IdentityFile))
{
sb.Append($"{IdentityFileOptionName}={HttpUtility.UrlEncode(sshConnectionInfoVm.IdentityFile)}");
}
containsUserInfo = true;
}
if (containsUserInfo)
sb.Append("@");
sb.Append(sshConnectionInfoVm.Host);
if (sshConnectionInfoVm.SshPort != SshProfile.DefaultSshPort)
{
sb.Append(":");
sb.Append(sshConnectionInfoVm.SshPort.ToString("#####"));
}
sb.Append("/");
bool queryStringAdded = false;
if (sshConnectionInfoVm.UseMosh)
{
sb.Append(
$"?{ValidMoshPortsNames[0]}={sshConnectionInfo.MoshPortFrom:#####}-{sshConnectionInfo.MoshPortTo:#####}");
queryStringAdded = true;
}
if (!sshConnectionInfo.TerminalThemeId.Equals(Guid.Empty))
{
if (!queryStringAdded)
{
sb.Append("?");
queryStringAdded = true;
}
else
{
sb.Append("&");
}
sb.Append($"{ThemeQueryStringParam}={sshConnectionInfo.TerminalThemeId}" );
}
if (sshConnectionInfo.TabThemeId != 0)
{
if (!queryStringAdded)
{
sb.Append("?");
}
else
{
sb.Append("&");
}
sb.Append($"{TabQueryStringParam}={sshConnectionInfo.TabThemeId:##########}");
}
return sb.ToString();
}
#endregion Methods
}
}
@@ -889,4 +889,31 @@ Fuzzy</comment>
<data name="RTrimCopiedLines.Header" xml:space="preserve">
<value>Nachfolgende Leerzeichen aus kopierten Zeilen entfernen</value>
</data>
<data name="QuickSshDefault.Header" xml:space="preserve">
<value>Quick SSH standardmäßig benutzen</value>
</data>
<data name="InvalidCommand" xml:space="preserve">
<value>Ungültiger Befehl</value>
</data>
<data name="LocationCantBeEmpty" xml:space="preserve">
<value>Dateipfad darf nicht leer sein</value>
</data>
<data name="MuteTerminalBeeps.Header" xml:space="preserve">
<value>Hinweistöne stummschalten</value>
</data>
<data name="UseCommandInput.Content" xml:space="preserve">
<value>Quick SSH verwenden</value>
</data>
<data name="CommandTextBox.PlaceholderText" xml:space="preserve">
<value>SSH-Befehl (z.b. &apos;ssh username@host&apos;)</value>
</data>
<data name="CommandLine.Header" xml:space="preserve">
<value>Befehl</value>
</data>
<data name="UnsupportedCommand" xml:space="preserve">
<value>Nicht unterstüzter Befehl:</value>
</data>
<data name="CommandArgumentsMandatory" xml:space="preserve">
<value>Befehlsargument fehlen</value>
</data>
</root>
@@ -927,4 +927,31 @@ Fuzzy</comment>
<data name="RTrimCopiedLines.Header" xml:space="preserve">
<value>Right trim copied lines</value>
</data>
<data name="QuickSshDefault.Header" xml:space="preserve">
<value>Use quick SSH by default</value>
</data>
<data name="InvalidCommand" xml:space="preserve">
<value>Invalid command.</value>
</data>
<data name="LocationCantBeEmpty" xml:space="preserve">
<value>Location cannot be empty.</value>
</data>
<data name="MuteTerminalBeeps.Header" xml:space="preserve">
<value>Mute terminal beeps</value>
</data>
<data name="UseCommandInput.Content" xml:space="preserve">
<value>Use quick SSH</value>
</data>
<data name="CommandTextBox.PlaceholderText" xml:space="preserve">
<value>SSH command (e.g. &apos;ssh username@host&apos;)</value>
</data>
<data name="CommandLine.Header" xml:space="preserve">
<value>Command</value>
</data>
<data name="UnsupportedCommand" xml:space="preserve">
<value>Unsupported command:</value>
</data>
<data name="CommandArgumentsMandatory" xml:space="preserve">
<value>Command arguments are missing.</value>
</data>
</root>
@@ -880,4 +880,31 @@
<data name="RTrimCopiedLines.Header" xml:space="preserve">
<value>Right trim copied lines</value>
</data>
<data name="QuickSshDefault.Header" xml:space="preserve">
<value>Use quick SSH by default</value>
</data>
<data name="InvalidCommand" xml:space="preserve">
<value>Invalid command.</value>
</data>
<data name="LocationCantBeEmpty" xml:space="preserve">
<value>Location cannot be empty.</value>
</data>
<data name="MuteTerminalBeeps.Header" xml:space="preserve">
<value>Mute terminal beeps</value>
</data>
<data name="UseCommandInput.Content" xml:space="preserve">
<value>Use quick SSH</value>
</data>
<data name="CommandTextBox.PlaceholderText" xml:space="preserve">
<value>SSH command (e.g. &apos;ssh username@host&apos;)</value>
</data>
<data name="CommandLine.Header" xml:space="preserve">
<value>Command</value>
</data>
<data name="UnsupportedCommand" xml:space="preserve">
<value>Unsupported command:</value>
</data>
<data name="CommandArgumentsMandatory" xml:space="preserve">
<value>Command arguments are missing.</value>
</data>
</root>
@@ -921,4 +921,31 @@ Fuzzy</comment>
<data name="RTrimCopiedLines.Header" xml:space="preserve">
<value>Right trim copied lines</value>
</data>
<data name="QuickSshDefault.Header" xml:space="preserve">
<value>Use quick SSH by default</value>
</data>
<data name="InvalidCommand" xml:space="preserve">
<value>Invalid command.</value>
</data>
<data name="LocationCantBeEmpty" xml:space="preserve">
<value>Location cannot be empty.</value>
</data>
<data name="MuteTerminalBeeps.Header" xml:space="preserve">
<value>Mute terminal beeps</value>
</data>
<data name="UseCommandInput.Content" xml:space="preserve">
<value>Use quick SSH</value>
</data>
<data name="CommandTextBox.PlaceholderText" xml:space="preserve">
<value>SSH command (e.g. &apos;ssh username@host&apos;)</value>
</data>
<data name="CommandLine.Header" xml:space="preserve">
<value>Command</value>
</data>
<data name="UnsupportedCommand" xml:space="preserve">
<value>Unsupported command:</value>
</data>
<data name="CommandArgumentsMandatory" xml:space="preserve">
<value>Command arguments are missing.</value>
</data>
</root>
@@ -173,11 +173,23 @@
Header="Use Windows username when opening SSH URLs that don't specify one"
IsOn="{x:Bind ViewModel.AutoFallbackToWindowsUsernameInLinks, Mode=TwoWay}" />
<ToggleSwitch
x:Uid="QuickSshDefault"
Margin="{StaticResource ItemMargin}"
Header="Use quick SSH by default"
IsOn="{x:Bind ViewModel.UseQuickSshConnectByDefault, Mode=TwoWay}" />
<ToggleSwitch
x:Uid="RTrimCopiedLines"
Margin="{StaticResource ItemMargin}"
Header="Right trim copied lines"
IsOn="{x:Bind ViewModel.RTrimCopiedLines, Mode=TwoWay}" />
<ToggleSwitch
x:Uid="MuteTerminalBeeps"
Margin="{StaticResource ItemMargin}"
Header="Mute terminal beeps"
IsOn="{x:Bind ViewModel.MuteTerminalBeeps, Mode=TwoWay}" />
</StackPanel>
</ScrollViewer>
</Grid>
@@ -4,90 +4,17 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Microsoft.Toolkit.Uwp.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="using:FluentTerminal.Models"
xmlns:settings="using:FluentTerminal.App.ViewModels.Settings"
xmlns:viewmodels="using:FluentTerminal.App.ViewModels"
xmlns:views="using:FluentTerminal.App.Views"
xmlns:enums="using:FluentTerminal.Models.Enums"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewmodels="using:FluentTerminal.App.ViewModels"
xmlns:views="using:FluentTerminal.App.Views"
mc:Ignorable="d">
<Page.Resources>
<views:TabThemeTemplateSelector x:Key="TabThemeTemplateSelector">
<views:TabThemeTemplateSelector.DefaultTemplate>
<DataTemplate x:DataType="models:TabTheme">
<StackPanel Orientation="Horizontal">
<FontIcon
Margin="0,0,6,0"
FontFamily="Segoe MDL2 Assets"
Glyph="&#xF126;" />
<TextBlock Text="{x:Bind Name, Mode=OneWay}" />
</StackPanel>
</DataTemplate>
</views:TabThemeTemplateSelector.DefaultTemplate>
<views:TabThemeTemplateSelector.ColoredTemplate>
<DataTemplate x:DataType="models:TabTheme">
<StackPanel Orientation="Horizontal">
<FontIcon
Margin="0,0,6,0"
FontFamily="Segoe MDL2 Assets"
Foreground="{x:Bind Color}"
Glyph="&#xF127;" />
<TextBlock Text="{x:Bind Name, Mode=OneWay}" />
</StackPanel>
</DataTemplate>
</views:TabThemeTemplateSelector.ColoredTemplate>
</views:TabThemeTemplateSelector>
<views:TerminalThemeTemplateSelector x:Key="TerminalThemeTemplateSelector">
<views:TerminalThemeTemplateSelector.DefaultTemplate>
<DataTemplate x:DataType="models:TerminalTheme">
<TextBlock FontStyle="Italic" Text="{x:Bind Name}" />
</DataTemplate>
</views:TerminalThemeTemplateSelector.DefaultTemplate>
<views:TerminalThemeTemplateSelector.ThemeTemplate>
<DataTemplate x:DataType="models:TerminalTheme">
<TextBlock Text="{x:Bind Name}" />
</DataTemplate>
</views:TerminalThemeTemplateSelector.ThemeTemplate>
</views:TerminalThemeTemplateSelector>
<views:TerminalKeybindTemplateSelector x:Key="TerminalKeybindTemplateSelector">
<views:TerminalKeybindTemplateSelector.DefaultTemplate>
<DataTemplate x:DataType="settings:KeyBindingsViewModel">
<TextBlock x:Uid="None" FontStyle="Italic" />
</DataTemplate>
</views:TerminalKeybindTemplateSelector.DefaultTemplate>
<views:TerminalKeybindTemplateSelector.KeyBindingsTemplate>
<DataTemplate x:DataType="settings:KeyBindingsViewModel">
<views:KeyBindingsView
Margin="0,6,0,0"
ShowCommandName="False"
ViewModel="{x:Bind}" />
</DataTemplate>
</views:TerminalKeybindTemplateSelector.KeyBindingsTemplate>
</views:TerminalKeybindTemplateSelector>
<views:NoValueTemplateSelector x:Key="NoValueTemplateSelector">
<views:NoValueTemplateSelector.DefaultTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock x:Uid="None" FontStyle="Italic" />
</DataTemplate>
</views:NoValueTemplateSelector.DefaultTemplate>
<views:NoValueTemplateSelector.CustomTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Text="{x:Bind}" />
</DataTemplate>
</views:NoValueTemplateSelector.CustomTemplate>
</views:NoValueTemplateSelector>
<views:BooleanTemplateSelector x:Key="UseConPtyTemplateSelector">
<views:BooleanTemplateSelector.TrueTemplate>
<DataTemplate x:DataType="x:Boolean">
<TextBlock x:Uid="Yes" />
</DataTemplate>
</views:BooleanTemplateSelector.TrueTemplate>
<views:BooleanTemplateSelector.FalseTemplate>
<DataTemplate x:DataType="x:Boolean">
<TextBlock x:Uid="No" />
</DataTemplate>
</views:BooleanTemplateSelector.FalseTemplate>
</views:BooleanTemplateSelector>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<views:TemplateSelectors />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Page.Resources>
<Grid Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
<Grid.ColumnDefinitions>
@@ -147,13 +74,13 @@
Command="{x:Bind ViewModel.SelectedShellProfile.DeleteCommand, Mode=OneWay}"
Icon="Delete"
Label="Delete"
Visibility="{x:Bind ViewModel.SelectedShellProfile.PreInstalled, Mode=OneWay, Converter={StaticResource FalseToVisibleConverter}}" />
Visibility="{x:Bind ViewModel.SelectedShellProfile.ProfileVm.PreInstalled, Mode=OneWay, Converter={StaticResource FalseToVisibleConverter}}" />
<AppBarButton
x:Uid="Restore"
Command="{x:Bind ViewModel.SelectedShellProfile.RestoreDefaultsCommand, Mode=OneWay}"
Icon="Undo"
Label="Restore Defaults"
Visibility="{x:Bind ViewModel.SelectedShellProfile.PreInstalled, Mode=OneWay, Converter={StaticResource TrueToVisibleConverter}}" />
Visibility="{x:Bind ViewModel.SelectedShellProfile.ProfileVm.PreInstalled, Mode=OneWay, Converter={StaticResource TrueToVisibleConverter}}" />
</CommandBar>
<CommandBar
Grid.Column="1"
@@ -248,7 +175,7 @@
Margin="{StaticResource ItemMargin}"
HorizontalAlignment="Left"
Header="Name"
IsEnabled="{x:Bind PreInstalled, Converter={StaticResource NegateConverter}, Mode=OneWay}"
IsEnabled="{x:Bind ProfileVm.PreInstalled, Converter={StaticResource NegateConverter}, Mode=OneWay}"
IsSpellCheckEnabled="False"
Text="{x:Bind Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
@@ -257,15 +184,15 @@
x:Uid="ShellLocation"
Width="300"
Header="Shell executable location"
IsEnabled="{x:Bind PreInstalled, Converter={StaticResource NegateConverter}, Mode=OneWay}"
IsEnabled="{x:Bind ProfileVm.PreInstalled, Converter={StaticResource NegateConverter}, Mode=OneWay}"
IsSpellCheckEnabled="False"
Text="{x:Bind Location, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
Text="{x:Bind ProfileVm.Location, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<Button
Margin="6,0,0,0"
VerticalAlignment="Bottom"
Command="{x:Bind BrowseForCustomShellCommand}"
Command="{x:Bind ProfileVm.BrowseForCustomShellCommand}"
Content="..."
IsEnabled="{x:Bind PreInstalled, Converter={StaticResource NegateConverter}, Mode=OneWay}"
IsEnabled="{x:Bind ProfileVm.PreInstalled, Converter={StaticResource NegateConverter}, Mode=OneWay}"
Style="{StaticResource ButtonRevealStyle}" />
</StackPanel>
@@ -276,11 +203,11 @@
Header="Working directory"
IsEnabled="{x:Bind InEditMode, Mode=OneWay}"
IsSpellCheckEnabled="False"
Text="{x:Bind WorkingDirectory, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
Text="{x:Bind ProfileVm.WorkingDirectory, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<Button
Margin="6,0,0,0"
VerticalAlignment="Bottom"
Command="{x:Bind BrowseForWorkingDirectoryCommand}"
Command="{x:Bind ProfileVm.BrowseForWorkingDirectoryCommand}"
Content="..."
Style="{StaticResource ButtonRevealStyle}" />
</StackPanel>
@@ -293,7 +220,7 @@
Header="Arguments"
IsEnabled="{x:Bind InEditMode, Mode=OneWay}"
IsSpellCheckEnabled="False"
Text="{x:Bind Arguments, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
Text="{x:Bind ProfileVm.Arguments, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<ComboBox
x:Uid="TabColor"
@@ -302,8 +229,8 @@
Header="Tab color"
IsEnabled="{x:Bind InEditMode, Mode=OneWay}"
ItemTemplateSelector="{StaticResource TabThemeTemplateSelector}"
ItemsSource="{x:Bind TabThemes, Mode=OneWay}"
SelectedItem="{Binding SelectedTabTheme, Mode=TwoWay}" />
ItemsSource="{x:Bind ProfileVm.TabThemes, Mode=OneWay}"
SelectedItem="{Binding ProfileVm.SelectedTabTheme, Mode=TwoWay}" />
<ComboBox
x:Uid="ProfileTheme"
@@ -312,21 +239,21 @@
Header="Theme"
IsEnabled="{x:Bind InEditMode, Mode=OneWay}"
ItemTemplateSelector="{StaticResource TerminalThemeTemplateSelector}"
ItemsSource="{x:Bind TerminalThemes, Mode=OneWay}"
SelectedItem="{Binding SelectedTerminalTheme, Mode=TwoWay}" />
ItemsSource="{x:Bind ProfileVm.TerminalThemes, Mode=OneWay}"
SelectedItem="{Binding ProfileVm.SelectedTerminalTheme, Mode=TwoWay}" />
<ToggleSwitch
Margin="{StaticResource ItemMargin}"
Header="Use ConPty"
IsEnabled="{x:Bind InEditMode, Mode=OneWay}"
IsOn="{x:Bind UseConPty, Mode=TwoWay}" />
IsOn="{x:Bind ProfileVm.UseConPty, Mode=TwoWay}" />
<ComboBox
x:Uid="LineEndingModeOnPaste"
x:Uid="LineEndingMode"
Margin="{StaticResource ItemMargin}"
VerticalAlignment="Center"
ItemsSource="{x:Bind LineEndingStyles}"
SelectedItem="{Binding LineEndingTranslation, Mode=TwoWay}">
ItemsSource="{x:Bind ProfileVm.LineEndingStyles}"
SelectedItem="{Binding ProfileVm.LineEndingTranslation, Mode=TwoWay}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="enums:LineEndingStyle">
<TextBlock Text="{x:Bind Converter={StaticResource I18NConverter}, ConverterParameter=LineEndingStyle}" />
@@ -357,7 +284,7 @@
x:Uid="ShellLocation"
Margin="{StaticResource ItemMargin}"
Header="Shell executable location"
Text="{x:Bind Location, Mode=OneWay}" />
Text="{x:Bind ProfileVm.Location, Mode=OneWay}" />
<controls:HeaderedContentControl
x:Uid="WorkingDir"
@@ -365,7 +292,7 @@
HorizontalContentAlignment="Stretch"
Header="Working directory">
<ContentControl
Content="{x:Bind WorkingDirectory, Mode=OneWay}"
Content="{x:Bind ProfileVm.WorkingDirectory, Mode=OneWay}"
ContentTemplateSelector="{StaticResource NoValueTemplateSelector}"
FontSize="18"
FontWeight="Light" />
@@ -377,7 +304,7 @@
HorizontalContentAlignment="Stretch"
Header="Arguments">
<ContentControl
Content="{x:Bind Arguments, Mode=OneWay}"
Content="{x:Bind ProfileVm.Arguments, Mode=OneWay}"
ContentTemplateSelector="{StaticResource NoValueTemplateSelector}"
FontSize="18"
FontWeight="Light" />
@@ -390,7 +317,7 @@
Header="Tab color">
<ContentControl
Margin="0,6,0,0"
Content="{x:Bind SelectedTabTheme, Mode=OneWay}"
Content="{x:Bind ProfileVm.SelectedTabTheme, Mode=OneWay}"
ContentTemplateSelector="{StaticResource TabThemeTemplateSelector}"
FontSize="18"
FontWeight="Light" />
@@ -402,7 +329,7 @@
Header="Theme">
<ContentControl
Margin="0,6,0,0"
Content="{x:Bind SelectedTerminalTheme, Mode=OneWay}"
Content="{x:Bind ProfileVm.SelectedTerminalTheme, Mode=OneWay}"
ContentTemplateSelector="{StaticResource TerminalThemeTemplateSelector}"
FontSize="18"
FontWeight="Light" />
@@ -414,7 +341,7 @@
Header="Use ConPty">
<ContentControl
Margin="0,6,0,0"
Content="{x:Bind UseConPty, Mode=OneWay}"
Content="{x:Bind ProfileVm.UseConPty, Mode=OneWay}"
ContentTemplateSelector="{StaticResource UseConPtyTemplateSelector}"
FontSize="18"
FontWeight="Light" />
@@ -426,7 +353,7 @@
HorizontalContentAlignment="Stretch"
Header="Line ending translation mode">
<ContentControl
Content="{x:Bind LineEndingTranslation, Mode=OneWay, Converter={StaticResource I18NConverter}, ConverterParameter=LineEndingStyle}"
Content="{x:Bind ProfileVm.LineEndingTranslation, Mode=OneWay, Converter={StaticResource I18NConverter}, ConverterParameter=LineEndingStyle}"
ContentTemplateSelector="{StaticResource TerminalThemeTemplateSelector}"
FontSize="18"
FontWeight="Light" />
@@ -6,90 +6,17 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:enums="using:FluentTerminal.Models.Enums"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="using:FluentTerminal.Models"
xmlns:settings="using:FluentTerminal.App.ViewModels.Settings"
xmlns:viewmodels="using:FluentTerminal.App.ViewModels"
xmlns:views="using:FluentTerminal.App.Views"
xmlns:profiles="using:FluentTerminal.App.ViewModels.Profiles"
mc:Ignorable="d">
<Page.Resources>
<views:TabThemeTemplateSelector x:Key="TabThemeTemplateSelector">
<views:TabThemeTemplateSelector.DefaultTemplate>
<DataTemplate x:DataType="models:TabTheme">
<StackPanel Orientation="Horizontal">
<FontIcon
Margin="0,0,6,0"
FontFamily="Segoe MDL2 Assets"
Glyph="&#xF126;" />
<TextBlock Text="{x:Bind Name, Mode=OneWay}" />
</StackPanel>
</DataTemplate>
</views:TabThemeTemplateSelector.DefaultTemplate>
<views:TabThemeTemplateSelector.ColoredTemplate>
<DataTemplate x:DataType="models:TabTheme">
<StackPanel Orientation="Horizontal">
<FontIcon
Margin="0,0,6,0"
FontFamily="Segoe MDL2 Assets"
Foreground="{x:Bind Color}"
Glyph="&#xF127;" />
<TextBlock Text="{x:Bind Name, Mode=OneWay}" />
</StackPanel>
</DataTemplate>
</views:TabThemeTemplateSelector.ColoredTemplate>
</views:TabThemeTemplateSelector>
<views:TerminalThemeTemplateSelector x:Key="TerminalThemeTemplateSelector">
<views:TerminalThemeTemplateSelector.DefaultTemplate>
<DataTemplate x:DataType="models:TerminalTheme">
<TextBlock FontStyle="Italic" Text="{x:Bind Name}" />
</DataTemplate>
</views:TerminalThemeTemplateSelector.DefaultTemplate>
<views:TerminalThemeTemplateSelector.ThemeTemplate>
<DataTemplate x:DataType="models:TerminalTheme">
<TextBlock Text="{x:Bind Name}" />
</DataTemplate>
</views:TerminalThemeTemplateSelector.ThemeTemplate>
</views:TerminalThemeTemplateSelector>
<views:TerminalKeybindTemplateSelector x:Key="TerminalKeybindTemplateSelector">
<views:TerminalKeybindTemplateSelector.DefaultTemplate>
<DataTemplate x:DataType="settings:KeyBindingsViewModel">
<TextBlock x:Uid="None" FontStyle="Italic" />
</DataTemplate>
</views:TerminalKeybindTemplateSelector.DefaultTemplate>
<views:TerminalKeybindTemplateSelector.KeyBindingsTemplate>
<DataTemplate x:DataType="settings:KeyBindingsViewModel">
<views:KeyBindingsView
Margin="0,6,0,0"
ShowCommandName="False"
ViewModel="{x:Bind}" />
</DataTemplate>
</views:TerminalKeybindTemplateSelector.KeyBindingsTemplate>
</views:TerminalKeybindTemplateSelector>
<views:NoValueTemplateSelector x:Key="NoValueTemplateSelector">
<views:NoValueTemplateSelector.DefaultTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock x:Uid="None" FontStyle="Italic" />
</DataTemplate>
</views:NoValueTemplateSelector.DefaultTemplate>
<views:NoValueTemplateSelector.CustomTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Text="{x:Bind}" />
</DataTemplate>
</views:NoValueTemplateSelector.CustomTemplate>
</views:NoValueTemplateSelector>
<views:BooleanTemplateSelector x:Key="UseConPtyTemplateSelector">
<views:BooleanTemplateSelector.TrueTemplate>
<DataTemplate x:DataType="x:Boolean">
<TextBlock x:Uid="Yes" />
</DataTemplate>
</views:BooleanTemplateSelector.TrueTemplate>
<views:BooleanTemplateSelector.FalseTemplate>
<DataTemplate x:DataType="x:Boolean">
<TextBlock x:Uid="No" />
</DataTemplate>
</views:BooleanTemplateSelector.FalseTemplate>
</views:BooleanTemplateSelector>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<views:TemplateSelectors />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Page.Resources>
<Grid Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
<Grid.ColumnDefinitions>
@@ -233,10 +160,17 @@
IsSpellCheckEnabled="False"
Text="{x:Bind Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<CheckBox
x:Uid="UseCommandInput"
Content="Use quick SSH"
IsChecked="{Binding ProfileVm.CommandInput, Mode=TwoWay}"
Margin="{StaticResource ItemMargin}" />
<Grid
Width="340"
Margin="{StaticResource ItemMargin}"
HorizontalAlignment="Left">
HorizontalAlignment="Left"
Visibility="{Binding ProfileVm.CommandInput, Converter={StaticResource FalseToVisibleConverter}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="90" />
<ColumnDefinition Width="20" />
@@ -251,7 +185,7 @@
Header="Username"
IsEnabled="{x:Bind InEditMode, Mode=OneWay}"
IsSpellCheckEnabled="False"
Text="{x:Bind Username, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
Text="{x:Bind ProfileVm.Username, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock
Grid.Column="1"
@@ -268,7 +202,7 @@
Header="Host"
IsEnabled="{x:Bind InEditMode, Mode=OneWay}"
IsSpellCheckEnabled="False"
Text="{x:Bind Host, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
Text="{x:Bind ProfileVm.Host, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock
Grid.Column="3"
@@ -285,19 +219,22 @@
Header="{x:Bind Converter={StaticResource I18NConverter},ConverterParameter=PortTextBox.PlaceholderText}"
IsEnabled="{x:Bind InEditMode, Mode=OneWay}"
IsSpellCheckEnabled="False"
Text="{x:Bind SshPort, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
Text="{x:Bind ProfileVm.SshPort, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</Grid>
<StackPanel Margin="{StaticResource ItemMargin}" Orientation="Horizontal">
<StackPanel
Margin="{StaticResource ItemMargin}"
Orientation="Horizontal"
Visibility="{Binding ProfileVm.CommandInput, Converter={StaticResource FalseToVisibleConverter}}">
<TextBox
Width="300"
Header="{x:Bind Converter={StaticResource I18NConverter}, ConverterParameter=IdentityFileTextBox.PlaceholderText}"
IsSpellCheckEnabled="False"
Text="{x:Bind IdentityFile, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
Text="{x:Bind ProfileVm.IdentityFile, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<Button
Margin="6,0,0,0"
VerticalAlignment="Bottom"
Command="{x:Bind BrowseForIdentityFileCommand}"
Command="{x:Bind ProfileVm.BrowseForIdentityFileCommand}"
Content="..."
Style="{StaticResource ButtonRevealStyle}" />
</StackPanel>
@@ -306,37 +243,49 @@
Margin="{StaticResource ItemMargin}"
Header="{x:Bind Converter={StaticResource I18NConverter}, ConverterParameter=UseMoshCheckBox.Content}"
IsEnabled="{x:Bind InEditMode, Mode=OneWay}"
IsOn="{x:Bind UseMosh, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
IsOn="{x:Bind ProfileVm.UseMosh, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
Visibility="{Binding ProfileVm.CommandInput, Converter={StaticResource FalseToVisibleConverter}}" />
<TextBlock
Margin="{StaticResource ItemMargin}"
x:Uid="MoshPortRange"
Text="Mosh port range"
Visibility="{x:Bind ProfileVm.MoshVisible, Converter={StaticResource TrueToVisibleConverter}, Mode=OneWay}"/>
<StackPanel
Margin="{StaticResource ItemMargin}"
Orientation="Horizontal"
Visibility="{x:Bind UseMosh, Converter={StaticResource TrueToVisibleConverter}, Mode=OneWay}">
Visibility="{x:Bind ProfileVm.MoshVisible, Converter={StaticResource TrueToVisibleConverter}, Mode=OneWay}">
<TextBox
x:Uid="MoshPortFrom"
Width="120"
Margin="{StaticResource ItemMargin}"
HorizontalAlignment="Left"
Header="Mosh Port From"
IsEnabled="{x:Bind InEditMode, Mode=OneWay}"
IsSpellCheckEnabled="False"
Text="{x:Bind MoshPortFrom, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
Text="{x:Bind ProfileVm.MoshPortFrom, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock
Width="8"
Margin="3,50,0,0"
Margin="3,26,0,0"
HorizontalAlignment="Center"
FontSize="22"
Text=":" />
<TextBox
x:Uid="MoshPortTo"
Width="120"
Margin="{StaticResource ItemMargin}"
HorizontalAlignment="Left"
Header="Mosh Port To"
IsEnabled="{x:Bind InEditMode, Mode=OneWay}"
IsSpellCheckEnabled="False"
Text="{x:Bind MoshPortTo, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
Text="{x:Bind ProfileVm.MoshPortTo, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<TextBox
x:Uid="CommandTextBox"
PlaceholderText="SSH command (e.g. 'ssh username@host')"
Text="{Binding ProfileVm.Command, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
Visibility="{Binding ProfileVm.CommandInput, Converter={StaticResource TrueToVisibleConverter}}"
Margin="0 8 2 0" />
<ComboBox
x:Uid="TabColor"
Width="150"
@@ -344,8 +293,8 @@
Header="Tab color"
IsEnabled="{x:Bind InEditMode, Mode=OneWay}"
ItemTemplateSelector="{StaticResource TabThemeTemplateSelector}"
ItemsSource="{x:Bind TabThemes, Mode=OneWay}"
SelectedItem="{Binding SelectedTabTheme, Mode=TwoWay}" />
ItemsSource="{x:Bind ProfileVm.TabThemes, Mode=OneWay}"
SelectedItem="{Binding ProfileVm.SelectedTabTheme, Mode=TwoWay}" />
<ComboBox
x:Uid="ProfileTheme"
@@ -354,20 +303,21 @@
Header="Theme"
IsEnabled="{x:Bind InEditMode, Mode=OneWay}"
ItemTemplateSelector="{StaticResource TerminalThemeTemplateSelector}"
ItemsSource="{x:Bind TerminalThemes, Mode=OneWay}"
SelectedItem="{Binding SelectedTerminalTheme, Mode=TwoWay}" />
ItemsSource="{x:Bind ProfileVm.TerminalThemes, Mode=OneWay}"
SelectedItem="{Binding ProfileVm.SelectedTerminalTheme, Mode=TwoWay}" />
<ToggleSwitch
Margin="{StaticResource ItemMargin}"
Header="{x:Bind Converter={StaticResource I18NConverter}, ConverterParameter=UseConPty}"
IsEnabled="{x:Bind InEditMode, Mode=OneWay}"
IsOn="{x:Bind UseConPty, Mode=TwoWay}" />
IsOn="{x:Bind ProfileVm.UseConPty, Mode=TwoWay}" />
<ComboBox
x:Uid="LineEndingMode"
Margin="{StaticResource ItemMargin}"
VerticalAlignment="Center"
ItemsSource="{x:Bind LineEndingStyles}"
SelectedItem="{Binding LineEndingTranslation, Mode=TwoWay}">
ItemsSource="{x:Bind ProfileVm.LineEndingStyles}"
SelectedItem="{Binding ProfileVm.LineEndingTranslation, Mode=TwoWay}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="enums:LineEndingStyle">
<TextBlock Text="{x:Bind Converter={StaticResource I18NConverter}, ConverterParameter=LineEndingStyle}" />
@@ -388,143 +338,188 @@
<views:KeyBindingsView ShowCommandName="False" ViewModel="{x:Bind KeyBindings}" />
</StackPanel>
</StackPanel>
<StackPanel Visibility="{x:Bind InEditMode, Mode=OneWay, Converter={StaticResource FalseToVisibleConverter}}">
<StackPanel Visibility="{Binding InEditMode, Mode=OneWay, Converter={StaticResource FalseToVisibleConverter}}">
<TextBlock
Margin="{StaticResource ItemMargin}"
Style="{StaticResource SubtitleTextBlockStyle}"
Text="{x:Bind Name, Mode=OneWay}" />
<TextBlock Margin="0,30,0,0" Text="{x:Bind Converter={StaticResource I18NConverter},ConverterParameter=SshInfoDialog.Title}" />
<StackPanel Margin="0,-20,0,0" Orientation="Horizontal">
<TextBlock
Margin="{StaticResource ItemMargin}"
FontSize="18"
FontWeight="Light"
Style="{StaticResource SubtitleTextBlockStyle}"
Text="{x:Bind Username, Mode=OneWay}" />
<TextBlock
Margin="0,23,4,0"
FontSize="18"
FontWeight="Light"
Style="{StaticResource SubtitleTextBlockStyle}"
Text="@"
TextAlignment="Center" />
<TextBlock
Margin="{StaticResource ItemMargin}"
FontSize="18"
FontWeight="Light"
Style="{StaticResource SubtitleTextBlockStyle}"
Text="{x:Bind Host, Mode=OneWay}" />
<TextBlock
Margin="0,23,4,0"
FontSize="18"
FontWeight="Light"
Style="{StaticResource SubtitleTextBlockStyle}"
Text=":"
TextAlignment="Center" />
<TextBlock
Margin="{StaticResource ItemMargin}"
FontSize="18"
FontWeight="Light"
Style="{StaticResource SubtitleTextBlockStyle}"
Text="{x:Bind SshPort, Mode=OneWay}" />
</StackPanel>
<controls:HeaderedContentControl
x:Uid="CommandLine"
DataContext="{Binding ProfileVm}"
Margin="{StaticResource ItemMargin}"
HorizontalContentAlignment="Stretch"
Header="{x:Bind Converter={StaticResource I18NConverter}, ConverterParameter=IdentityFileTextBox.PlaceholderText}">
Header="Command"
Visibility="{Binding CommandInput, Converter={StaticResource TrueToVisibleConverter}}">
<ContentControl>
<ContentControl.ContentTemplate>
<DataTemplate x:DataType="profiles:SshConnectViewModel">
<TextBlock
FontSize="18"
FontWeight="Light"
Style="{StaticResource SubtitleTextBlockStyle}"
Text="{Binding Command, Mode=OneWay}" />
</DataTemplate>
</ContentControl.ContentTemplate>
</ContentControl>
</controls:HeaderedContentControl>
<controls:HeaderedContentControl
DataContext="{Binding ProfileVm}"
Margin="{StaticResource ItemMargin}"
HorizontalContentAlignment="Stretch"
Header="{x:Bind Converter={StaticResource I18NConverter}, ConverterParameter=SshInfoDialog.Title}"
Visibility="{Binding CommandInput, Converter={StaticResource FalseToVisibleConverter}}">
<ContentControl>
<ContentControl.ContentTemplate>
<DataTemplate x:DataType="profiles:SshConnectViewModel">
<StackPanel Orientation="Horizontal">
<TextBlock
FontSize="18"
FontWeight="Light"
Style="{StaticResource SubtitleTextBlockStyle}"
Text="{Binding Username, Mode=OneWay}" />
<TextBlock
Margin="0,0,4,0"
FontSize="18"
FontWeight="Light"
Style="{StaticResource SubtitleTextBlockStyle}"
Text="@"
TextAlignment="Center" />
<TextBlock
FontSize="18"
FontWeight="Light"
Style="{StaticResource SubtitleTextBlockStyle}"
Text="{Binding Host, Mode=OneWay}" />
<TextBlock
Margin="0,0,4,0"
FontSize="18"
FontWeight="Light"
Style="{StaticResource SubtitleTextBlockStyle}"
Text=":"
TextAlignment="Center" />
<TextBlock
FontSize="18"
FontWeight="Light"
Style="{StaticResource SubtitleTextBlockStyle}"
Text="{Binding SshPort, Mode=OneWay}" />
</StackPanel>
</DataTemplate>
</ContentControl.ContentTemplate>
</ContentControl>
</controls:HeaderedContentControl>
<controls:HeaderedContentControl
DataContext="{Binding ProfileVm}"
Margin="{StaticResource ItemMargin}"
HorizontalContentAlignment="Stretch"
Header="{x:Bind Converter={StaticResource I18NConverter}, ConverterParameter=IdentityFileTextBox.PlaceholderText}"
Visibility="{Binding CommandInput, Converter={StaticResource FalseToVisibleConverter}}">
<ContentControl
Content="{x:Bind IdentityFile, Mode=OneWay}"
Content="{Binding IdentityFile, Mode=OneWay}"
ContentTemplateSelector="{StaticResource NoValueTemplateSelector}"
FontSize="18"
FontWeight="Light" />
</controls:HeaderedContentControl>
<controls:HeaderedContentControl
DataContext="{Binding ProfileVm}"
Margin="{StaticResource ItemMargin}"
HorizontalContentAlignment="Stretch"
Header="{x:Bind Converter={StaticResource I18NConverter}, ConverterParameter=UseMoshCheckBox.Content}">
Header="{x:Bind Converter={StaticResource I18NConverter}, ConverterParameter=UseMoshCheckBox.Content}"
Visibility="{Binding CommandInput, Converter={StaticResource FalseToVisibleConverter}}">
<ContentControl
Margin="0,6,0,0"
Content="{x:Bind UseMosh, Mode=OneWay}"
Content="{Binding UseMosh, Mode=OneWay}"
ContentTemplateSelector="{StaticResource UseConPtyTemplateSelector}"
FontSize="18"
FontWeight="Light" />
</controls:HeaderedContentControl>
<TextBlock
x:Uid="MoshPortRange"
Margin="0,30,0,0"
Text="Mosh Port Range"
Visibility="{x:Bind UseMosh, Converter={StaticResource TrueToVisibleConverter}, Mode=OneWay}" />
<StackPanel
Margin="0,-20,0,0"
Orientation="Horizontal"
Visibility="{x:Bind UseMosh, Converter={StaticResource TrueToVisibleConverter}, Mode=OneWay}">
<TextBlock
Margin="{StaticResource ItemMargin}"
FontSize="18"
FontWeight="Light"
Style="{StaticResource SubtitleTextBlockStyle}"
Text="{x:Bind MoshPortFrom, Mode=OneWay}" />
<TextBlock
Margin="0,23,3,0"
FontSize="18"
FontWeight="Light"
Style="{StaticResource SubtitleTextBlockStyle}"
Text=":"
TextAlignment="Center" />
<TextBlock
Margin="{StaticResource ItemMargin}"
FontSize="18"
FontWeight="Light"
Style="{StaticResource SubtitleTextBlockStyle}"
Text="{x:Bind MoshPortTo, Mode=OneWay}" />
</StackPanel>
<controls:HeaderedContentControl
DataContext="{Binding ProfileVm}"
Margin="{StaticResource ItemMargin}"
HorizontalContentAlignment="Stretch"
Header="{x:Bind Converter={StaticResource I18NConverter}, ConverterParameter=MoshPortRange.Text}"
Visibility="{Binding MoshVisible, Converter={StaticResource TrueToVisibleConverter}}">
<ContentControl>
<ContentControl.ContentTemplate>
<DataTemplate x:DataType="profiles:SshConnectViewModel">
<StackPanel
Orientation="Horizontal">
<TextBlock
FontSize="18"
FontWeight="Light"
Style="{StaticResource SubtitleTextBlockStyle}"
Text="{Binding MoshPortFrom, Mode=OneWay}" />
<TextBlock
Margin="0,0,3,0"
FontSize="18"
FontWeight="Light"
Style="{StaticResource SubtitleTextBlockStyle}"
Text=":"
TextAlignment="Center" />
<TextBlock
FontSize="18"
FontWeight="Light"
Style="{StaticResource SubtitleTextBlockStyle}"
Text="{Binding MoshPortTo, Mode=OneWay}" />
</StackPanel>
</DataTemplate>
</ContentControl.ContentTemplate>
</ContentControl>
</controls:HeaderedContentControl>
<controls:HeaderedContentControl
DataContext="{Binding ProfileVm}"
x:Uid="TabColor"
Margin="{StaticResource ItemMargin}"
HorizontalContentAlignment="Stretch"
Header="Tab color">
<ContentControl
Margin="0,6,0,0"
Content="{x:Bind SelectedTabTheme, Mode=OneWay}"
Content="{Binding SelectedTabTheme, Mode=OneWay}"
ContentTemplateSelector="{StaticResource TabThemeTemplateSelector}"
FontSize="18"
FontWeight="Light" />
</controls:HeaderedContentControl>
<controls:HeaderedContentControl
DataContext="{Binding ProfileVm}"
Margin="{StaticResource ItemMargin}"
HorizontalContentAlignment="Stretch"
Header="Theme">
<ContentControl
Margin="0,6,0,0"
Content="{x:Bind SelectedTerminalTheme, Mode=OneWay}"
Content="{Binding SelectedTerminalTheme, Mode=OneWay}"
ContentTemplateSelector="{StaticResource TerminalThemeTemplateSelector}"
FontSize="18"
FontWeight="Light" />
</controls:HeaderedContentControl>
<controls:HeaderedContentControl
DataContext="{Binding ProfileVm}"
Margin="{StaticResource ItemMargin}"
HorizontalContentAlignment="Stretch"
Header="{x:Bind Converter={StaticResource I18NConverter}, ConverterParameter=UseConPty}">
<ContentControl
Margin="0,6,0,0"
Content="{x:Bind UseConPty, Mode=OneWay}"
Content="{Binding UseConPty, Mode=OneWay}"
ContentTemplateSelector="{StaticResource UseConPtyTemplateSelector}"
FontSize="18"
FontWeight="Light" />
</controls:HeaderedContentControl>
<controls:HeaderedContentControl
DataContext="{Binding ProfileVm}"
x:Uid="LineEndingMode"
Margin="{StaticResource ItemMargin}"
HorizontalContentAlignment="Stretch"
Header="Line ending translation mode">
<ContentControl
Content="{x:Bind LineEndingTranslation, Mode=OneWay, Converter={StaticResource I18NConverter}, ConverterParameter=LineEndingStyle}"
Content="{Binding LineEndingTranslation, Mode=OneWay, Converter={StaticResource I18NConverter}, ConverterParameter=LineEndingStyle}"
ContentTemplateSelector="{StaticResource TerminalThemeTemplateSelector}"
FontSize="18"
FontWeight="Light" />
@@ -536,7 +531,7 @@
HorizontalContentAlignment="Stretch"
Header="Keyboard shortcut">
<ContentControl
Content="{x:Bind KeyBindings, Mode=OneWay}"
Content="{Binding KeyBindings, Mode=OneWay}"
ContentTemplateSelector="{StaticResource TerminalKeybindTemplateSelector}"
FontSize="18"
FontWeight="Light" />
@@ -0,0 +1,84 @@
<ResourceDictionary
x:Class="FluentTerminal.App.Views.TemplateSelectors"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="using:FluentTerminal.App.Views"
xmlns:models="using:FluentTerminal.Models"
xmlns:settings="using:FluentTerminal.App.ViewModels.Settings">
<views:TabThemeTemplateSelector x:Key="TabThemeTemplateSelector">
<views:TabThemeTemplateSelector.DefaultTemplate>
<DataTemplate x:DataType="models:TabTheme">
<StackPanel Orientation="Horizontal">
<FontIcon
Margin="0,0,6,0"
FontFamily="Segoe MDL2 Assets"
Glyph="&#xF126;" />
<TextBlock Text="{x:Bind Name, Mode=OneWay}" />
</StackPanel>
</DataTemplate>
</views:TabThemeTemplateSelector.DefaultTemplate>
<views:TabThemeTemplateSelector.ColoredTemplate>
<DataTemplate x:DataType="models:TabTheme">
<StackPanel Orientation="Horizontal">
<FontIcon
Margin="0,0,6,0"
FontFamily="Segoe MDL2 Assets"
Foreground="{x:Bind Color}"
Glyph="&#xF127;" />
<TextBlock Text="{x:Bind Name, Mode=OneWay}" />
</StackPanel>
</DataTemplate>
</views:TabThemeTemplateSelector.ColoredTemplate>
</views:TabThemeTemplateSelector>
<views:TerminalThemeTemplateSelector x:Key="TerminalThemeTemplateSelector">
<views:TerminalThemeTemplateSelector.DefaultTemplate>
<DataTemplate x:DataType="models:TerminalTheme">
<TextBlock FontStyle="Italic" Text="{x:Bind Name}" />
</DataTemplate>
</views:TerminalThemeTemplateSelector.DefaultTemplate>
<views:TerminalThemeTemplateSelector.ThemeTemplate>
<DataTemplate x:DataType="models:TerminalTheme">
<TextBlock Text="{x:Bind Name}" />
</DataTemplate>
</views:TerminalThemeTemplateSelector.ThemeTemplate>
</views:TerminalThemeTemplateSelector>
<views:TerminalKeybindTemplateSelector x:Key="TerminalKeybindTemplateSelector">
<views:TerminalKeybindTemplateSelector.DefaultTemplate>
<DataTemplate x:DataType="settings:KeyBindingsViewModel">
<TextBlock x:Uid="None" FontStyle="Italic" />
</DataTemplate>
</views:TerminalKeybindTemplateSelector.DefaultTemplate>
<views:TerminalKeybindTemplateSelector.KeyBindingsTemplate>
<DataTemplate x:DataType="settings:KeyBindingsViewModel">
<views:KeyBindingsView
Margin="0,6,0,0"
ShowCommandName="False"
ViewModel="{x:Bind}" />
</DataTemplate>
</views:TerminalKeybindTemplateSelector.KeyBindingsTemplate>
</views:TerminalKeybindTemplateSelector>
<views:NoValueTemplateSelector x:Key="NoValueTemplateSelector">
<views:NoValueTemplateSelector.DefaultTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock x:Uid="None" FontStyle="Italic" />
</DataTemplate>
</views:NoValueTemplateSelector.DefaultTemplate>
<views:NoValueTemplateSelector.CustomTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Text="{x:Bind}" />
</DataTemplate>
</views:NoValueTemplateSelector.CustomTemplate>
</views:NoValueTemplateSelector>
<views:BooleanTemplateSelector x:Key="UseConPtyTemplateSelector">
<views:BooleanTemplateSelector.TrueTemplate>
<DataTemplate x:DataType="x:Boolean">
<TextBlock x:Uid="Yes" />
</DataTemplate>
</views:BooleanTemplateSelector.TrueTemplate>
<views:BooleanTemplateSelector.FalseTemplate>
<DataTemplate x:DataType="x:Boolean">
<TextBlock x:Uid="No" />
</DataTemplate>
</views:BooleanTemplateSelector.FalseTemplate>
</views:BooleanTemplateSelector>
</ResourceDictionary>
@@ -0,0 +1,11 @@
namespace FluentTerminal.App.Views
{
// Added due https://docs.microsoft.com/en-us/windows/uwp/data-binding/data-binding-in-depth#resource-dictionaries-with-xbind
public partial class TemplateSelectors
{
public TemplateSelectors()
{
InitializeComponent();
}
}
}
@@ -19,6 +19,8 @@ namespace FluentTerminal.Models
public bool ShowCustomTitleInTitlebar { get; set; }
public bool UseMoshByDefault { get; set; }
public bool AutoFallbackToWindowsUsernameInLinks { get; set; }
public bool UseQuickSshConnectByDefault { get; set; }
public bool RTrimCopiedLines { get; set; }
public bool MuteTerminalBeeps { get; set; }
}
}
@@ -1,34 +0,0 @@
using System;
using System.Threading.Tasks;
using FluentTerminal.Models.Enums;
namespace FluentTerminal.Models
{
public interface ISshConnectionInfo
{
string Host { get; set; }
ushort SshPort { get; set; }
string Username { get; set; }
string IdentityFile { get; set; }
LineEndingStyle LineEndingTranslation { get; set; }
bool UseMosh { get; set; }
ushort MoshPortFrom { get; set; }
ushort MoshPortTo { get; set; }
Guid TerminalThemeId { get; set; }
int TabThemeId { get; set; }
Task<SshConnectionInfoValidationResult> ValidateAsync();
bool UseConPty { get; set; }
}
}
@@ -0,0 +1,11 @@
namespace FluentTerminal.Models.Requests
{
public class MuteTerminalRequest : IMessage
{
public const byte Identifier = 14;
byte IMessage.Identifier => Identifier;
public bool Mute { get; set; }
}
}
-8
View File
@@ -1,8 +0,0 @@
namespace FluentTerminal.Models
{
public sealed class ShellLocation
{
public const string SSH = "SSH";
public const string Mosh = "MOSH";
}
}
-10
View File
@@ -42,7 +42,6 @@ namespace FluentTerminal.Models
public int TabThemeId { get; set; }
public LineEndingStyle LineEndingTranslation { get; set; }
public Dictionary<string, string> EnvironmentVariables { get; } = new Dictionary<string, string>();
public bool UseConPty { get; set; }
public string TranslateLineEndings(string content)
@@ -90,14 +89,5 @@ namespace FluentTerminal.Models
}
public virtual ShellProfile Clone() => new ShellProfile(this);
/// <summary>
/// Checks the profile for errors and returns string describing the found errors, if any.
/// Returns null if no errors found.
/// </summary>
public virtual string ValidateAndGetErrors()
{
return null;
}
}
}
@@ -1,45 +0,0 @@
using FluentTerminal.Models.Enums;
namespace FluentTerminal.Models
{
public static class SshConnectionInfoExtensions
{
public static SshConnectionInfoValidationResult GetValidationResult(this ISshConnectionInfo sshConnectionInfo)
{
var result = SshConnectionInfoValidationResult.Valid;
if (string.IsNullOrEmpty(sshConnectionInfo.Username))
{
result |= SshConnectionInfoValidationResult.UsernameEmpty;
}
if (string.IsNullOrEmpty(sshConnectionInfo.Host))
{
result |= SshConnectionInfoValidationResult.HostEmpty;
}
if (sshConnectionInfo.SshPort < 1)
{
result |= SshConnectionInfoValidationResult.SshPortZeroOrNegative;
}
if (!sshConnectionInfo.UseMosh)
{
return result;
}
if (sshConnectionInfo.MoshPortFrom < 1)
{
result |= SshConnectionInfoValidationResult.MoshPortZeroOrNegative;
}
if (sshConnectionInfo.MoshPortFrom > sshConnectionInfo.MoshPortTo)
{
result |= SshConnectionInfoValidationResult.MoshPortRangeInvalid;
}
return result;
}
}
}
@@ -1,30 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FluentTerminal.Models.Enums;
namespace FluentTerminal.Models
{
public static class SshConnectionInfoValidationResultExtensions
{
public static string GetErrorString(this SshConnectionInfoValidationResult result, string separator = "; ") =>
string.Join(separator, result.GetErrors());
public static IEnumerable<string> GetErrors(this SshConnectionInfoValidationResult result)
{
if (result == SshConnectionInfoValidationResult.Valid)
{
yield break;
}
foreach (var value in Enum.GetValues(typeof(SshConnectionInfoValidationResult))
.Cast<SshConnectionInfoValidationResult>().Where(r => r != SshConnectionInfoValidationResult.Valid))
{
if ((value & result) == value)
{
yield return Resources.GetString($"{nameof(SshConnectionInfoValidationResult)}.{value}");
}
}
}
}
}
+1 -10
View File
@@ -1,11 +1,10 @@
using FluentTerminal.Models.Enums;
using System.Linq;
using System.Threading.Tasks;
using Windows.Foundation.Metadata;
namespace FluentTerminal.Models
{
public class SshProfile : ShellProfile, ISshConnectionInfo
public class SshProfile : ShellProfile
{
#region Constants
@@ -83,14 +82,6 @@ namespace FluentTerminal.Models
public override ShellProfile Clone() => new SshProfile(this);
public Task<SshConnectionInfoValidationResult> ValidateAsync()
{
return Task.FromResult(this.GetValidationResult());
// Here we don't check if IdentityFile exists because this class isn't used in UI (if it's saved - it exists).
}
public override string ValidateAndGetErrors() => this.GetValidationResult().GetErrorString();
#endregion Methods
}
}
@@ -133,6 +133,7 @@
<Compile Include="Services\WinPty\WinPtySession.cs" />
<Compile Include="SystemTrayApplicationContext.cs" />
<Compile Include="Utilities.cs" />
<Compile Include="VolumeControl.cs" />
<EmbeddedResource Include="Properties\Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
+1
View File
@@ -90,6 +90,7 @@ namespace FluentTerminal.SystemTray
Task.Run(() => container.Resolve<IUpdateService>().CheckForUpdate());
var settingsService = container.Resolve<ISettingsService>();
Task.Run(() => Utilities.MuteTerminal(settingsService.GetApplicationSettings().MuteTerminalBeeps));
if (settingsService.GetApplicationSettings().EnableTrayIcon)
{
Application.Run(container.Resolve<SystemTrayApplicationContext>());
@@ -117,6 +117,9 @@ namespace FluentTerminal.SystemTray.Services
case CheckFileExistsRequest.Identifier:
await HandleCheckFileExistsRequest(args);
break;
case MuteTerminalRequest.Identifier:
await HandleMuteTerminalRequest(args);
break;
default:
Logger.Instance.Error("Received unknown message type: {messageType}", messageType);
break;
@@ -235,6 +238,13 @@ namespace FluentTerminal.SystemTray.Services
deferral.Complete();
}
private async Task HandleMuteTerminalRequest(AppServiceRequestReceivedEventArgs args)
{
var messageContent = (string)args.Request.Message[MessageKeys.Content];
var request = JsonConvert.DeserializeObject<MuteTerminalRequest>(messageContent);
Utilities.MuteTerminal(request.Mute);
}
private async Task HandleCheckFileExistsRequest(AppServiceRequestReceivedEventArgs args)
{
var deferral = args.GetDeferral();
+49 -4
View File
@@ -1,11 +1,12 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net.NetworkInformation;
using System.Reflection;
using System.Windows.Input;
using FluentTerminal.Models;
using FluentTerminal.App.Services;
using FluentTerminal.Models.Enums;
@@ -500,11 +501,11 @@ namespace FluentTerminal.SystemTray
public static string ResolveLocation(string location)
{
switch (location)
switch (location?.ToLower())
{
case ShellLocation.SSH:
case Constants.SshCommandName:
return GetSshPath();
case ShellLocation.Mosh:
case Constants.MoshCommandName:
return GetMoshPath();
default:
return location;
@@ -567,5 +568,49 @@ namespace FluentTerminal.SystemTray
writer.Write(content);
}
}
internal static void MuteTerminal(bool mute)
{
bool? deviceMuted = VolumeControl.GetDefaultAudioEndpointMute();
if (deviceMuted != true)
{
VolumeControl.SetDefaultAudioEndpointMute(true);
}
Process cmdProcess = new Process();
cmdProcess.StartInfo.FileName = "cmd.exe";
cmdProcess.StartInfo.WindowStyle = ProcessWindowStyle.Hidden;
cmdProcess.StartInfo.Arguments = "/k \x07";
cmdProcess.Start();
bool wasConhostMuted = false;
int attempt = 0;
while (wasConhostMuted == false && attempt++ < 2 && !cmdProcess.HasExited)
{
cmdProcess.WaitForExit(1500);
foreach (Process conhost in Process.GetProcessesByName("conhost"))
{
try
{
bool? isMuted = null;
isMuted = VolumeControl.GetAudioSessionMute(conhost.Id);
if (isMuted != null && isMuted != mute)
{
VolumeControl.SetAudioSessionMute(conhost.Id, mute);
wasConhostMuted = true;
Logger.Instance.Debug($"Mute state for conhost process with id={conhost.Id} was set to {mute}.");
}
}
catch (Exception) { }
}
}
cmdProcess.Kill();
if (deviceMuted != true)
{
VolumeControl.SetDefaultAudioEndpointMute(false);
}
}
}
}
+224
View File
@@ -0,0 +1,224 @@
using System;
using System.Runtime.InteropServices;
namespace FluentTerminal.SystemTray
{
public static class VolumeControl
{
public static bool? GetAudioSessionMute(int processId)
{
ISimpleAudioVolume volume = GetAudioVolume(processId);
if (volume == null)
return null;
volume.GetMute(out var mute);
Marshal.ReleaseComObject(volume);
return mute;
}
public static void SetAudioSessionMute(int processId, bool mute)
{
ISimpleAudioVolume volume = GetAudioVolume(processId);
if (volume == null)
return;
Guid guid = Guid.Empty;
volume.SetMute(mute, ref guid);
Marshal.ReleaseComObject(volume);
}
public static bool? GetDefaultAudioEndpointMute()
{
IMMDevice speakers = GetDefaultDevice();
Guid IID_IAudioEndpointVolume = typeof(IAudioEndpointVolume).GUID;
speakers.Activate(ref IID_IAudioEndpointVolume, 0, IntPtr.Zero, out var result);
IAudioEndpointVolume audioEndpoint = (IAudioEndpointVolume)result;
if (audioEndpoint == null)
return null;
audioEndpoint.GetMute(out var mute);
Marshal.ReleaseComObject(audioEndpoint);
return mute;
}
public static void SetDefaultAudioEndpointMute(bool mute)
{
IMMDevice speakers = GetDefaultDevice();
Guid IID_IAudioEndpointVolume = typeof(IAudioEndpointVolume).GUID;
speakers.Activate(ref IID_IAudioEndpointVolume, 0, IntPtr.Zero, out var result);
IAudioEndpointVolume audioEndpoint = (IAudioEndpointVolume)result;
if (audioEndpoint == null)
return;
Guid guid = Guid.Empty;
audioEndpoint.SetMute(mute, ref guid);
Marshal.ReleaseComObject(audioEndpoint);
}
private static IMMDevice GetDefaultDevice()
{
IMMDeviceEnumerator deviceEnumerator = (IMMDeviceEnumerator)(new MMDeviceEnumerator());
deviceEnumerator.GetDefaultAudioEndpoint(EDataFlow.eRender, ERole.eMultimedia, out var device);
Marshal.ReleaseComObject(deviceEnumerator);
return device;
}
private static ISimpleAudioVolume GetAudioVolume(int pid)
{
IMMDevice device = GetDefaultDevice();
Guid IID_IAudioSessionManager2 = typeof(IAudioSessionManager2).GUID;
device.Activate(ref IID_IAudioSessionManager2, 0, IntPtr.Zero, out var result);
IAudioSessionManager2 sessionManager = (IAudioSessionManager2)result;
sessionManager.GetSessionEnumerator(out var sessionEnumerator);
sessionEnumerator.GetCount(out var count);
ISimpleAudioVolume volumeControl = null;
for (int i = 0; i < count; i++)
{
sessionEnumerator.GetSession(i, out var sessionControl2);
sessionControl2.GetProcessId(out var processId);
if (processId == pid)
{
volumeControl = sessionControl2 as ISimpleAudioVolume;
break;
}
Marshal.ReleaseComObject(sessionControl2);
}
Marshal.ReleaseComObject(sessionEnumerator);
Marshal.ReleaseComObject(sessionManager);
Marshal.ReleaseComObject(device);
return volumeControl;
}
}
[ComImport]
[Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
internal class MMDeviceEnumerator
{
}
internal enum EDataFlow
{
eRender,
eCapture,
eAll,
EDataFlow_enum_count
}
internal enum ERole
{
eConsole,
eMultimedia,
eCommunications,
ERole_enum_count
}
[Guid("5CDF2C82-841E-4546-9722-0CF74078229A"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IAudioEndpointVolume
{
int NoImp1();
int NoImp2();
int NoImp3();
int NoImp4();
int NoImp5();
int NoImp6();
int NoImp7();
int NoImp8();
int NoImp9();
int NoImp10();
int NoImp11();
[PreserveSig]
int SetMute([MarshalAs(UnmanagedType.Bool)] Boolean bMute, ref Guid pguidEventContext);
[PreserveSig]
int GetMute(out bool pbMute);
}
[Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMMDeviceEnumerator
{
int NoImp1();
[PreserveSig]
int GetDefaultAudioEndpoint(EDataFlow dataFlow, ERole role, out IMMDevice ppDevice);
}
[Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMMDevice
{
[PreserveSig]
int Activate(ref Guid iid, int clsCtx, IntPtr pActivationParams, [MarshalAs(UnmanagedType.IUnknown)] out object ppInterface);
}
[Guid("77AA99A0-1BD6-484F-8BC7-2C654C9A9B6F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IAudioSessionManager2
{
int NoImp1();
int NoImp2();
[PreserveSig]
int GetSessionEnumerator(out IAudioSessionEnumerator SessionEnum);
}
[Guid("E2F5BB11-0570-40CA-ACDD-3AA01277DEE8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IAudioSessionEnumerator
{
[PreserveSig]
int GetCount(out int SessionCount);
[PreserveSig]
int GetSession(int SessionCount, out IAudioSessionControl2 Session);
}
[Guid("87CE5498-68D6-44E5-9215-6DA47EF883D8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface ISimpleAudioVolume
{
[PreserveSig]
int SetMasterVolume(float fLevel, ref Guid EventContext);
[PreserveSig]
int GetMasterVolume(out float pfLevel);
[PreserveSig]
int SetMute(bool bMute, ref Guid EventContext);
[PreserveSig]
int GetMute(out bool pbMute);
}
[Guid("bfb7ff88-7239-4fc9-8fa2-07c950be9c6d"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IAudioSessionControl2
{
// IAudioSessionControl
int NoImp0();
int NoImp1();
int NoImp2();
int NoImp3();
int NoImp4();
int NoImp5();
int NoImp6();
int NoImp7();
int NoImp8();
// IAudioSessionControl2
[PreserveSig]
int GetSessionIdentifier([MarshalAs(UnmanagedType.LPWStr)] out string pRetVal);
[PreserveSig]
int GetSessionInstanceIdentifier([MarshalAs(UnmanagedType.LPWStr)] out string pRetVal);
[PreserveSig]
int GetProcessId(out int pRetVal);
[PreserveSig]
int IsSystemSoundsSession();
[PreserveSig]
int SetDuckingPreference(bool optOut);
}
}