From 96e32fcc96d6fc1a05d6446bfae506f5599dfa30 Mon Sep 17 00:00:00 2001 From: Aleksandar Pesic Date: Sat, 6 Jul 2019 13:53:00 +0200 Subject: [PATCH] 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. --- .../ApplicationDataContainers.cs | 1 + FluentTerminal.App.Services/Constants.cs | 3 + .../Dialogs/ISshConnectionInfoDialog.cs | 2 +- .../IApplicationView.cs | 2 +- FluentTerminal.App.Services/IDialogService.cs | 2 +- .../ISshHelperService.cs | 19 - .../Implementation/DefaultValueProvider.cs | 1 + .../Implementation/DialogService.cs | 2 +- .../MainViewModel.cs | 11 +- .../Profiles/CommandHistory.cs | 21 + .../CommonProfileProviderViewModel.cs | 146 +++ .../Profiles/ProfileProviderViewModelBase.cs | 396 +++++++++ .../Profiles/SshConnectViewModel.cs | 833 ++++++++++++++++++ .../SshConnectionInfoValidationResult.cs | 2 +- .../Settings/GeneralPageViewModel.cs | 14 + .../Settings/ProfilesPageViewModel.cs | 2 +- .../Settings/SshProfilesPageViewModel.cs | 16 +- .../SettingsViewModel.cs | 6 +- .../ShellProfileViewModel.cs | 387 +------- .../ShellProfileViewModelBase.cs | 219 +++++ .../SshProfileViewModel.cs | 271 +----- .../Adapters/ApplicationViewAdapter.cs | 9 +- FluentTerminal.App/App.xaml.cs | 57 +- .../Converters/I18NConverter.cs | 12 +- FluentTerminal.App/Dialogs/SshInfoDialog.xaml | 459 +++++----- .../Dialogs/SshInfoDialog.xaml.cs | 116 ++- FluentTerminal.App/FluentTerminal.App.csproj | 8 +- FluentTerminal.App/Package.appxmanifest | 5 + .../Services/SshHelperService.cs | 289 ------ .../Views/SettingsPages/GeneralSettings.xaml | 6 + .../SettingsPages/ShellProfileSettings.xaml | 131 +-- .../SettingsPages/SshProfileSettings.xaml | 329 ++++--- .../Views/TemplateSelectors.xaml | 84 ++ .../Views/TemplateSelectors.xaml.cs | 11 + FluentTerminal.Models/ApplicationSettings.cs | 1 + FluentTerminal.Models/ISshConnectionInfo.cs | 34 - FluentTerminal.Models/ShellLocation.cs | 8 - FluentTerminal.Models/ShellProfile.cs | 10 - .../SshConnectionInfoExtensions.cs | 45 - ...onnectionInfoValidationResultExtensions.cs | 30 - FluentTerminal.Models/SshProfile.cs | 11 +- FluentTerminal.SystemTray/Utilities.cs | 8 +- 42 files changed, 2331 insertions(+), 1688 deletions(-) delete mode 100644 FluentTerminal.App.Services/ISshHelperService.cs create mode 100644 FluentTerminal.App.ViewModels/Profiles/CommandHistory.cs create mode 100644 FluentTerminal.App.ViewModels/Profiles/CommonProfileProviderViewModel.cs create mode 100644 FluentTerminal.App.ViewModels/Profiles/ProfileProviderViewModelBase.cs create mode 100644 FluentTerminal.App.ViewModels/Profiles/SshConnectViewModel.cs rename {FluentTerminal.Models/Enums => FluentTerminal.App.ViewModels/Profiles}/SshConnectionInfoValidationResult.cs (86%) create mode 100644 FluentTerminal.App.ViewModels/ShellProfileViewModelBase.cs delete mode 100644 FluentTerminal.App/Services/SshHelperService.cs create mode 100644 FluentTerminal.App/Views/TemplateSelectors.xaml create mode 100644 FluentTerminal.App/Views/TemplateSelectors.xaml.cs delete mode 100644 FluentTerminal.Models/ISshConnectionInfo.cs delete mode 100644 FluentTerminal.Models/ShellLocation.cs delete mode 100644 FluentTerminal.Models/SshConnectionInfoExtensions.cs delete mode 100644 FluentTerminal.Models/SshConnectionInfoValidationResultExtensions.cs diff --git a/FluentTerminal.App.Services/ApplicationDataContainers.cs b/FluentTerminal.App.Services/ApplicationDataContainers.cs index 98b8b4d..16859cf 100644 --- a/FluentTerminal.App.Services/ApplicationDataContainers.cs +++ b/FluentTerminal.App.Services/ApplicationDataContainers.cs @@ -8,5 +8,6 @@ public IApplicationDataContainer KeyBindings { get; set; } public IApplicationDataContainer ShellProfiles { get; set; } public IApplicationDataContainer SshProfiles { get; set; } + public IApplicationDataContainer HistoryContainer { get; set; } } } \ No newline at end of file diff --git a/FluentTerminal.App.Services/Constants.cs b/FluentTerminal.App.Services/Constants.cs index 7560e68..83ceaa9 100644 --- a/FluentTerminal.App.Services/Constants.cs +++ b/FluentTerminal.App.Services/Constants.cs @@ -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"; } } diff --git a/FluentTerminal.App.Services/Dialogs/ISshConnectionInfoDialog.cs b/FluentTerminal.App.Services/Dialogs/ISshConnectionInfoDialog.cs index fa7b87e..f751c20 100644 --- a/FluentTerminal.App.Services/Dialogs/ISshConnectionInfoDialog.cs +++ b/FluentTerminal.App.Services/Dialogs/ISshConnectionInfoDialog.cs @@ -5,6 +5,6 @@ namespace FluentTerminal.App.Services.Dialogs { public interface ISshConnectionInfoDialog { - Task GetSshConnectionInfoAsync(ISshConnectionInfo input); + Task GetSshConnectionInfoAsync(SshProfile input = null); } } \ No newline at end of file diff --git a/FluentTerminal.App.Services/IApplicationView.cs b/FluentTerminal.App.Services/IApplicationView.cs index 7803650..1b719d0 100644 --- a/FluentTerminal.App.Services/IApplicationView.cs +++ b/FluentTerminal.App.Services/IApplicationView.cs @@ -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 TryClose(); diff --git a/FluentTerminal.App.Services/IDialogService.cs b/FluentTerminal.App.Services/IDialogService.cs index 1c984be..18a0c18 100644 --- a/FluentTerminal.App.Services/IDialogService.cs +++ b/FluentTerminal.App.Services/IDialogService.cs @@ -18,7 +18,7 @@ namespace FluentTerminal.App.Services Task ShowCreateKeyBindingDialog(); Task ShowInputDialogAsync(string title); - Task ShowSshConnectionInfoDialogAsync(ISshConnectionInfo input); + Task ShowSshConnectionInfoDialogAsync(SshProfile input = null); Task ShowSshProfileSelectionDialogAsync(); } diff --git a/FluentTerminal.App.Services/ISshHelperService.cs b/FluentTerminal.App.Services/ISshHelperService.cs deleted file mode 100644 index 41d3bdd..0000000 --- a/FluentTerminal.App.Services/ISshHelperService.cs +++ /dev/null @@ -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 GetSshProfileAsync(SshProfile profile); - - Task GetSavedSshProfileAsync(); - - Task ConvertToUriAsync(ISshConnectionInfo sshConnectionInfo); - } -} \ No newline at end of file diff --git a/FluentTerminal.App.Services/Implementation/DefaultValueProvider.cs b/FluentTerminal.App.Services/Implementation/DefaultValueProvider.cs index 30af031..c65b510 100644 --- a/FluentTerminal.App.Services/Implementation/DefaultValueProvider.cs +++ b/FluentTerminal.App.Services/Implementation/DefaultValueProvider.cs @@ -28,6 +28,7 @@ namespace FluentTerminal.App.Services.Implementation ShowCustomTitleInTitlebar = true, UseMoshByDefault = true, AutoFallbackToWindowsUsernameInLinks = true, + UseQuickSshConnectByDefault = false, RTrimCopiedLines = true }; } diff --git a/FluentTerminal.App.Services/Implementation/DialogService.cs b/FluentTerminal.App.Services/Implementation/DialogService.cs index 40e6b1a..c70b599 100644 --- a/FluentTerminal.App.Services/Implementation/DialogService.cs +++ b/FluentTerminal.App.Services/Implementation/DialogService.cs @@ -78,7 +78,7 @@ namespace FluentTerminal.App.Services.Implementation return dialog.SelectProfile(); } - public Task ShowSshConnectionInfoDialogAsync(ISshConnectionInfo input) => + public Task ShowSshConnectionInfoDialogAsync(SshProfile input = null) => _sshConnectionInfoDialogFactory().GetSshConnectionInfoAsync(input); public Task ShowSshProfileSelectionDialogAsync() diff --git a/FluentTerminal.App.ViewModels/MainViewModel.cs b/FluentTerminal.App.ViewModels/MainViewModel.cs index 3abead8..25ae3f9 100644 --- a/FluentTerminal.App.ViewModels/MainViewModel.cs +++ b/FluentTerminal.App.ViewModels/MainViewModel.cs @@ -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) { diff --git a/FluentTerminal.App.ViewModels/Profiles/CommandHistory.cs b/FluentTerminal.App.ViewModels/Profiles/CommandHistory.cs new file mode 100644 index 0000000..e86e27d --- /dev/null +++ b/FluentTerminal.App.ViewModels/Profiles/CommandHistory.cs @@ -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 ExecutedCommands { get; set; } + } +} \ No newline at end of file diff --git a/FluentTerminal.App.ViewModels/Profiles/CommonProfileProviderViewModel.cs b/FluentTerminal.App.ViewModels/Profiles/CommonProfileProviderViewModel.cs new file mode 100644 index 0000000..4c13f28 --- /dev/null +++ b/FluentTerminal.App.ViewModels/Profiles/CommonProfileProviderViewModel.cs @@ -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 +{ + /// + /// Used for common profiles (PowerShell, CMD, WSL). + /// + 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 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 + } +} \ No newline at end of file diff --git a/FluentTerminal.App.ViewModels/Profiles/ProfileProviderViewModelBase.cs b/FluentTerminal.App.ViewModels/Profiles/ProfileProviderViewModelBase.cs new file mode 100644 index 0000000..4269ab2 --- /dev/null +++ b/FluentTerminal.App.ViewModels/Profiles/ProfileProviderViewModelBase.cs @@ -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 +{ + /// + /// 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). + /// + public abstract class ProfileProviderViewModelBase : ViewModelBase + { + #region Static + + private static readonly LineEndingStyle[] LineEndingStylesArray = + Enum.GetValues(typeof(LineEndingStyle)).Cast().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 TabThemes { get; } + + public ObservableCollection TerminalThemes { get; } + + public ObservableCollection LineEndingStyles { get; } = + new ObservableCollection(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(settingsService.GetTabThemes()); + + SelectedTabTheme = TabThemes.First(); + + TerminalThemes = new ObservableCollection(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 ValidateAsync() + { + return Task.FromResult(null); + } + + /// + /// Returns true if view model values aren't equal to the corresponding values of the + /// underlying . + /// + public virtual bool HasChanges() + { + return Model.LineEndingTranslation != _lineEndingTranslation || + Model.UseConPty != _useConPty || + !Model.TerminalThemeId.Equals(_terminalThemeId) || + Model.TabThemeId != _tabThemeId; + } + + /// + /// Copies values from the view model to the underlying . + /// + /// Defines what will happen if the view model data is invalid (if + /// returns non-empty string). If this parameter is set to true, the + /// data will be copied to the underlying anyway. If it's set to false, + /// (default) the underlying won't be changed at all. + /// null or empty string if the operation was successful (the data is valid), or an error + /// message returned by method. + public async Task 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> ParseParams(string uriOpts, char separator) => + uriOpts.Split(separator).Select(ParseSshOptionFromUri).Where(p => p != null); + + private static Tuple 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])); + } + + /// + /// Generates URL. + /// + /// whose first item represents success. If the first item is true, + /// the second contains the URL. If the first item is false, the second contains error message. + public virtual Task> 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> 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 + } +} \ No newline at end of file diff --git a/FluentTerminal.App.ViewModels/Profiles/SshConnectViewModel.cs b/FluentTerminal.App.ViewModels/Profiles/SshConnectViewModel.cs new file mode 100644 index 0000000..2a4821b --- /dev/null +++ b/FluentTerminal.App.ViewModels/Profiles/SshConnectViewModel.cs @@ -0,0 +1,833 @@ +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 +{ + /// + /// View-model for rich-UI based SSH profiles. + /// + public class SshConnectViewModel : ProfileProviderViewModelBase + { + #region Static + + private static readonly Regex CommandValidationRx = new Regex(@"^(?[^\s\.]+)(\.exe)?(\s+(?\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 GetErrors(SshConnectionInfoValidationResult result) + { + if (result == SshConnectionInfoValidationResult.Valid) + { + yield break; + } + + foreach (var value in Enum.GetValues(typeof(SshConnectionInfoValidationResult)) + .Cast().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; + + // 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 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; + } + + FillCommandHistory(); + + Initialize((SshProfile) Model); + + BrowseForIdentityFileCommand = new AsyncCommand(BrowseForIdentityFile); + } + + private SshConnectViewModel(ISettingsService settingsService, IApplicationView applicationView, + ITrayProcessCommunicationService trayProcessCommunicationService, IFileSystemService fileSystemService, + bool useCommandInput) : base(settingsService, applicationView, + new SshProfile {UseMosh = settingsService.GetApplicationSettings().UseMoshByDefault}) + { + _trayProcessCommunicationService = trayProcessCommunicationService; + _fileSystemService = fileSystemService; + _commandInput = 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); + + Initialize((SshProfile)profile); + } + + protected override void CopyToProfile(ShellProfile profile) + { + base.CopyToProfile(profile); + + 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 ValidateAsync() + { + return _commandInput ? ValidateQuickAsync() : ValidateFullAsync(); + } + + private async Task 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 ValidateQuickAsync() + { + var error = await base.ValidateAsync(); + + if (!string.IsNullOrEmpty(error)) + { + return error; + } + + var match = CommandValidationRx.Match(_command?.Trim() ?? string.Empty); + + if (match.Success && match.Groups["args"].Success && AcceptableCommands.Any(c => + c.Equals(match.Groups["cmd"].Value, StringComparison.OrdinalIgnoreCase))) + { + return null; + } + + error = I18N.Translate("InvalidCommand"); + + return string.IsNullOrEmpty(error) ? "Invalid command." : error; + } + + public override bool HasChanges() + { + if (base.HasChanges()) + { + 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 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 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(ExecutedCommandsKey, null) + ?.ExecutedCommands; + + CommandHistory = commands == null + ? new ObservableCollection() + : new ObservableCollection(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(ExecutedCommandsKey, null) ?? + new ExecutedCommandHistory {ExecutedCommands = new List()}; + + 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(@"^(?\d{1,5})[:-](?\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) + { + if (CheckSchemeStandard(uri)) + { + return ParseUriStandard(uri, settingsService, applicationView, trayProcessCommunicationService, + fileSystemService); + } + + if (CheckSchemeCustom(uri)) + { + return ParseUriCustom(uri, settingsService, applicationView, trayProcessCommunicationService, + fileSystemService); + } + + // 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) + { + var vm = new SshConnectViewModel(settingsService, applicationView, trayProcessCommunicationService, + fileSystemService, 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 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) + { + var vm = new SshConnectViewModel(settingsService, applicationView, trayProcessCommunicationService, + fileSystemService, 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> GetUrlAsync() + { + return _commandInput ? GetUrlQuickAsync() : GetUrlFullAsync(); + } + + private async Task> 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> 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 + } +} \ No newline at end of file diff --git a/FluentTerminal.Models/Enums/SshConnectionInfoValidationResult.cs b/FluentTerminal.App.ViewModels/Profiles/SshConnectionInfoValidationResult.cs similarity index 86% rename from FluentTerminal.Models/Enums/SshConnectionInfoValidationResult.cs rename to FluentTerminal.App.ViewModels/Profiles/SshConnectionInfoValidationResult.cs index 8c5863f..bbc1117 100644 --- a/FluentTerminal.Models/Enums/SshConnectionInfoValidationResult.cs +++ b/FluentTerminal.App.ViewModels/Profiles/SshConnectionInfoValidationResult.cs @@ -1,6 +1,6 @@ using System; -namespace FluentTerminal.Models.Enums +namespace FluentTerminal.App.ViewModels.Profiles { [Flags] public enum SshConnectionInfoValidationResult diff --git a/FluentTerminal.App.ViewModels/Settings/GeneralPageViewModel.cs b/FluentTerminal.App.ViewModels/Settings/GeneralPageViewModel.cs index 0f0a304..2097949 100644 --- a/FluentTerminal.App.ViewModels/Settings/GeneralPageViewModel.cs +++ b/FluentTerminal.App.ViewModels/Settings/GeneralPageViewModel.cs @@ -154,6 +154,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; diff --git a/FluentTerminal.App.ViewModels/Settings/ProfilesPageViewModel.cs b/FluentTerminal.App.ViewModels/Settings/ProfilesPageViewModel.cs index 50f666e..3d0931e 100644 --- a/FluentTerminal.App.ViewModels/Settings/ProfilesPageViewModel.cs +++ b/FluentTerminal.App.ViewModels/Settings/ProfilesPageViewModel.cs @@ -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; diff --git a/FluentTerminal.App.ViewModels/Settings/SshProfilesPageViewModel.cs b/FluentTerminal.App.ViewModels/Settings/SshProfilesPageViewModel.cs index 00767e5..34fe9d9 100644 --- a/FluentTerminal.App.ViewModels/Settings/SshProfilesPageViewModel.cs +++ b/FluentTerminal.App.ViewModels/Settings/SshProfilesPageViewModel.cs @@ -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(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; diff --git a/FluentTerminal.App.ViewModels/SettingsViewModel.cs b/FluentTerminal.App.ViewModels/SettingsViewModel.cs index 187df32..ce6a51e 100644 --- a/FluentTerminal.App.ViewModels/SettingsViewModel.cs +++ b/FluentTerminal.App.ViewModels/SettingsViewModel.cs @@ -9,7 +9,8 @@ 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); @@ -18,7 +19,8 @@ namespace FluentTerminal.App.ViewModels 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; diff --git a/FluentTerminal.App.ViewModels/ShellProfileViewModel.cs b/FluentTerminal.App.ViewModels/ShellProfileViewModel.cs index b0dcda3..f1533bf 100644 --- a/FluentTerminal.App.ViewModels/ShellProfileViewModel.cs +++ b/FluentTerminal.App.ViewModels/ShellProfileViewModel.cs @@ -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 + /// + /// Extends , and implements only "Set as default" logic. + /// + public class ShellProfileViewModel : ShellProfileViewModelBase { - #region Static - - private static readonly LineEndingStyle[] LineEndingStylesArray = - Enum.GetValues(typeof(LineEndingStyle)).Cast().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 TabThemes { get; } - - public KeyBindingsViewModel KeyBindings { get; } - - public ObservableCollection TerminalThemes { get; } - - public ObservableCollection LineEndingStyles { get; } = - new ObservableCollection(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(settingsService.GetTabThemes()); - - TerminalThemes = new ObservableCollection - { - 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 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() diff --git a/FluentTerminal.App.ViewModels/ShellProfileViewModelBase.cs b/FluentTerminal.App.ViewModels/ShellProfileViewModelBase.cs new file mode 100644 index 0000000..06b8e85 --- /dev/null +++ b/FluentTerminal.App.ViewModels/ShellProfileViewModelBase.cs @@ -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 +{ + /// + /// Base class for all shell profile view models. Implements logic for saving, editing, resetting, etc. + /// + /// + public abstract class ShellProfileViewModelBase : ViewModelBase where T : ProfileProviderViewModelBase + { + #region Fields + + protected readonly IDialogService DialogService; + protected readonly ISettingsService SettingsService; + + private List _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 + } +} \ No newline at end of file diff --git a/FluentTerminal.App.ViewModels/SshProfileViewModel.cs b/FluentTerminal.App.ViewModels/SshProfileViewModel.cs index 498cf47..2e65ce9 100644 --- a/FluentTerminal.App.ViewModels/SshProfileViewModel.cs +++ b/FluentTerminal.App.ViewModels/SshProfileViewModel.cs @@ -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 - + /// + /// Extends , and doesn't implement any additional logic because in + /// case of SSH profiles no additional logic is needed. + /// + public class SshProfileViewModel : ShellProfileViewModelBase + { #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 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 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 diff --git a/FluentTerminal.App/Adapters/ApplicationViewAdapter.cs b/FluentTerminal.App/Adapters/ApplicationViewAdapter.cs index 79d6f22..eec3e7c 100644 --- a/FluentTerminal.App/Adapters/ApplicationViewAdapter.cs +++ b/FluentTerminal.App/Adapters/ApplicationViewAdapter.cs @@ -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(); } diff --git a/FluentTerminal.App/App.xaml.cs b/FluentTerminal.App/App.xaml.cs index 2690463..8a61bf8 100755 --- a/FluentTerminal.App/App.xaml.cs +++ b/FluentTerminal.App/App.xaml.cs @@ -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 _trayReady = new TaskCompletionSource(); private readonly ISettingsService _settingsService; private readonly ITrayProcessCommunicationService _trayProcessCommunicationService; - private readonly Lazy _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().As().SingleInstance(); @@ -100,7 +101,6 @@ namespace FluentTerminal.App builder.RegisterType().As().InstancePerDependency(); builder.RegisterType().As().InstancePerDependency(); builder.RegisterType().As().SingleInstance(); - builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); builder.RegisterInstance(applicationDataContainers); @@ -111,8 +111,6 @@ namespace FluentTerminal.App _trayProcessCommunicationService = _container.Resolve(); - _sshHelperService = new Lazy(() => _container.Resolve()); - _dialogService = _container.Resolve(); _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(); 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()); } 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. diff --git a/FluentTerminal.App/Converters/I18NConverter.cs b/FluentTerminal.App/Converters/I18NConverter.cs index c4e6b58..f0e2ddf 100644 --- a/FluentTerminal.App/Converters/I18NConverter.cs +++ b/FluentTerminal.App/Converters/I18NConverter.cs @@ -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; } diff --git a/FluentTerminal.App/Dialogs/SshInfoDialog.xaml b/FluentTerminal.App/Dialogs/SshInfoDialog.xaml index 0b68098..70bbf4f 100644 --- a/FluentTerminal.App/Dialogs/SshInfoDialog.xaml +++ b/FluentTerminal.App/Dialogs/SshInfoDialog.xaml @@ -6,271 +6,266 @@ 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"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - - - - - - -