diff --git a/FluentTerminal.App.Services.Test/DialogServiceTests.cs b/FluentTerminal.App.Services.Test/DialogServiceTests.cs index c0e902e..b6fcae1 100644 --- a/FluentTerminal.App.Services.Test/DialogServiceTests.cs +++ b/FluentTerminal.App.Services.Test/DialogServiceTests.cs @@ -23,7 +23,7 @@ namespace FluentTerminal.App.Services.Test public void ShowCreateKeyBindingDialog_Default_UsesCreateKeyBindingDialog() { var createKeyBindingDialog = new Mock(); - var dialogService = new DialogService(Mock.Of, Mock.Of, () => createKeyBindingDialog.Object, Mock.Of, Mock.Of, Mock.Of); + var dialogService = new DialogService(Mock.Of, Mock.Of, () => createKeyBindingDialog.Object, Mock.Of, Mock.Of, Mock.Of, Mock.Of); dialogService.ShowCreateKeyBindingDialog(); @@ -36,7 +36,7 @@ namespace FluentTerminal.App.Services.Test var title = string.Empty; var content = _fixture.Create(); var buttons = _fixture.CreateMany(2); - var dialogService = new DialogService(Mock.Of, Mock.Of, Mock.Of, Mock.Of, Mock.Of, Mock.Of); + var dialogService = new DialogService(Mock.Of, Mock.Of, Mock.Of, Mock.Of, Mock.Of, Mock.Of, Mock.Of); Func> showMessageDialogAsnyc = () => dialogService.ShowMessageDialogAsnyc(title, content, buttons.ElementAt(0), buttons.ElementAt(1)); @@ -49,7 +49,7 @@ namespace FluentTerminal.App.Services.Test var title = _fixture.Create(); var content = string.Empty; var buttons = _fixture.CreateMany(2); - var dialogService = new DialogService(Mock.Of, Mock.Of, Mock.Of, Mock.Of, Mock.Of, Mock.Of); + var dialogService = new DialogService(Mock.Of, Mock.Of, Mock.Of, Mock.Of, Mock.Of, Mock.Of, Mock.Of); Func> showMessageDialogAsnyc = () => dialogService.ShowMessageDialogAsnyc(title, content, buttons.ElementAt(0), buttons.ElementAt(1)); @@ -61,7 +61,7 @@ namespace FluentTerminal.App.Services.Test { var title = _fixture.Create(); var content = _fixture.Create(); - var dialogService = new DialogService(Mock.Of, Mock.Of, Mock.Of, Mock.Of, Mock.Of, Mock.Of); + var dialogService = new DialogService(Mock.Of, Mock.Of, Mock.Of, Mock.Of, Mock.Of, Mock.Of, Mock.Of); Func> showMessageDialogAsnyc = () => dialogService.ShowMessageDialogAsnyc(title, content); @@ -75,7 +75,7 @@ namespace FluentTerminal.App.Services.Test var content = _fixture.Create(); var buttons = _fixture.CreateMany(2); var messageDialog = new Mock(); - var dialogService = new DialogService(Mock.Of, () => messageDialog.Object, Mock.Of, Mock.Of, Mock.Of, Mock.Of); + var dialogService = new DialogService(Mock.Of, () => messageDialog.Object, Mock.Of, Mock.Of, Mock.Of, Mock.Of, Mock.Of); dialogService.ShowMessageDialogAsnyc(title, content, buttons.ElementAt(0), buttons.ElementAt(1)); @@ -86,7 +86,7 @@ namespace FluentTerminal.App.Services.Test public void ShowProfileSelectionDialogAsync_Default_UsesShellProfileSelectionDialog() { var shellProfileSelectionDialog = new Mock(); - var dialogService = new DialogService(() => shellProfileSelectionDialog.Object, Mock.Of, Mock.Of, Mock.Of, Mock.Of, Mock.Of); + var dialogService = new DialogService(() => shellProfileSelectionDialog.Object, Mock.Of, Mock.Of, Mock.Of, Mock.Of, Mock.Of, Mock.Of); dialogService.ShowProfileSelectionDialogAsync(); @@ -98,7 +98,7 @@ namespace FluentTerminal.App.Services.Test { var title = "title"; var inputDialog = new Mock(); - var dialogService = new DialogService(Mock.Of, Mock.Of, Mock.Of, () => inputDialog.Object, Mock.Of, Mock.Of); + var dialogService = new DialogService(Mock.Of, Mock.Of, Mock.Of, () => inputDialog.Object, Mock.Of, Mock.Of, Mock.Of); dialogService.ShowInputDialogAsync(title); diff --git a/FluentTerminal.App.Services.Test/TrayProcessCommunicationServiceTests.cs b/FluentTerminal.App.Services.Test/TrayProcessCommunicationServiceTests.cs index d3f0c39..071f51d 100644 --- a/FluentTerminal.App.Services.Test/TrayProcessCommunicationServiceTests.cs +++ b/FluentTerminal.App.Services.Test/TrayProcessCommunicationServiceTests.cs @@ -70,7 +70,7 @@ namespace FluentTerminal.App.Services.Test await trayProcessCommunicationService.ResizeTerminal(terminalId, terminalSize); - appServiceConnection.Verify(x => x.SendMessageAsync(It.Is(d => (byte)d[MessageKeys.Type] == ResizeTerminalRequest.Identifier)), Times.Once); + appServiceConnection.Verify(x => x.SendMessageAsync(It.Is(d => (byte)d[MessageKeys.Type] == (byte)MessageIdentifiers.ResizeTerminalRequest)), Times.Once); } [Fact] @@ -109,7 +109,7 @@ namespace FluentTerminal.App.Services.Test await trayProcessCommunicationService.CloseTerminal(terminalId); - appServiceConnection.Verify(x => x.SendMessageAsync(It.Is(d => (byte)d[MessageKeys.Type] == TerminalExitedRequest.Identifier)), Times.Once); + appServiceConnection.Verify(x => x.SendMessageAsync(It.Is(d => (byte)d[MessageKeys.Type] == (byte)MessageIdentifiers.TerminalExitedRequest)), Times.Once); } [Fact] @@ -121,7 +121,7 @@ namespace FluentTerminal.App.Services.Test var message = new ValueSet { - [MessageKeys.Type] = TerminalExitedRequest.Identifier, + [MessageKeys.Type] = (byte) MessageIdentifiers.TerminalExitedRequest, [MessageKeys.Content] = JsonConvert.SerializeObject(request) }; var settingsService = new Mock(); diff --git a/FluentTerminal.App.Services/Adapters/ApplicationDataContainerAdapter.cs b/FluentTerminal.App.Services/Adapters/ApplicationDataContainerAdapter.cs index 215dc89..4bb9589 100644 --- a/FluentTerminal.App.Services/Adapters/ApplicationDataContainerAdapter.cs +++ b/FluentTerminal.App.Services/Adapters/ApplicationDataContainerAdapter.cs @@ -20,6 +20,11 @@ namespace FluentTerminal.App.Services.Adapters _applicationDataContainer.Values.Remove(key); } + public void Clear() + { + _applicationDataContainer.Values.Clear(); + } + public IEnumerable GetAll() { return _applicationDataContainer.Values.Select(x => x.Value); diff --git a/FluentTerminal.App.Services/Dialogs/ICustomCommandDialog.cs b/FluentTerminal.App.Services/Dialogs/ICustomCommandDialog.cs new file mode 100644 index 0000000..d10f929 --- /dev/null +++ b/FluentTerminal.App.Services/Dialogs/ICustomCommandDialog.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using FluentTerminal.Models; + +namespace FluentTerminal.App.Services.Dialogs +{ + public interface ICustomCommandDialog + { + Task GetCustomCommandAsync(ShellProfile input = null); + } +} \ No newline at end of file diff --git a/FluentTerminal.App.Services/IApplicationDataContainer.cs b/FluentTerminal.App.Services/IApplicationDataContainer.cs index 6d01aa5..4e5a08d 100644 --- a/FluentTerminal.App.Services/IApplicationDataContainer.cs +++ b/FluentTerminal.App.Services/IApplicationDataContainer.cs @@ -14,6 +14,8 @@ namespace FluentTerminal.App.Services void Delete(string key); + void Clear(); + IEnumerable GetAll(); } } \ No newline at end of file diff --git a/FluentTerminal.App.Services/IDialogService.cs b/FluentTerminal.App.Services/IDialogService.cs index 18a0c18..8a5c446 100644 --- a/FluentTerminal.App.Services/IDialogService.cs +++ b/FluentTerminal.App.Services/IDialogService.cs @@ -20,6 +20,8 @@ namespace FluentTerminal.App.Services Task ShowSshConnectionInfoDialogAsync(SshProfile input = null); + Task ShowCustomCommandDialogAsync(ShellProfile input = null); + Task ShowSshProfileSelectionDialogAsync(); } } \ No newline at end of file diff --git a/FluentTerminal.App.Services/ITrayProcessCommunicationService.cs b/FluentTerminal.App.Services/ITrayProcessCommunicationService.cs index 48e2fec..40783cc 100644 --- a/FluentTerminal.App.Services/ITrayProcessCommunicationService.cs +++ b/FluentTerminal.App.Services/ITrayProcessCommunicationService.cs @@ -41,5 +41,7 @@ namespace FluentTerminal.App.Services void MuteTerminal(bool mute); void UpdateSettings(ApplicationSettings settings); + + Task GetCommandPathAsync(string command); } } \ No newline at end of file diff --git a/FluentTerminal.App.Services/Implementation/DefaultValueProvider.cs b/FluentTerminal.App.Services/Implementation/DefaultValueProvider.cs index 73d23bf..e7d1038 100644 --- a/FluentTerminal.App.Services/Implementation/DefaultValueProvider.cs +++ b/FluentTerminal.App.Services/Implementation/DefaultValueProvider.cs @@ -3,7 +3,6 @@ using FluentTerminal.Models; using FluentTerminal.Models.Enums; using System; using System.Collections.Generic; -using Windows.ApplicationModel; using Windows.Foundation.Metadata; using Windows.Storage; @@ -151,6 +150,30 @@ namespace FluentTerminal.App.Services.Implementation } }; + case Command.NewCustomCommandTab: + return new List + { + new KeyBinding + { + Command = nameof(Command.NewCustomCommandTab), + Ctrl = true, + Shift = true, + Key = (int)ExtendedVirtualKey.Q + } + }; + + case Command.NewCustomCommandWindow: + return new List + { + new KeyBinding + { + Command = nameof(Command.NewCustomCommandWindow), + Ctrl = true, + Alt = true, + Key = (int)ExtendedVirtualKey.Q + } + }; + case Command.SavedSshNewTab: return new List { diff --git a/FluentTerminal.App.Services/Implementation/DialogService.cs b/FluentTerminal.App.Services/Implementation/DialogService.cs index c70b599..5184270 100644 --- a/FluentTerminal.App.Services/Implementation/DialogService.cs +++ b/FluentTerminal.App.Services/Implementation/DialogService.cs @@ -12,11 +12,13 @@ namespace FluentTerminal.App.Services.Implementation private readonly Func _createKeyBindingDialogFactory; private readonly Func _inputDialogFactory; private readonly Func _sshConnectionInfoDialogFactory; + private readonly Func _customCommandDialogFactory; private readonly Func _sshProfileSelectionDialogFactory; public DialogService(Func shellProfileSelectionDialogFactory, Func messageDialogFactory, Func createKeyBindingDialogFactory, Func inputDialogFactory, Func sshConnectionInfoDialogFactory, + Func customCommandDialogFactory, Func sshProfileSelectionDialogFactory) { _shellProfileSelectionDialogFactory = shellProfileSelectionDialogFactory; @@ -24,6 +26,7 @@ namespace FluentTerminal.App.Services.Implementation _createKeyBindingDialogFactory = createKeyBindingDialogFactory; _inputDialogFactory = inputDialogFactory; _sshConnectionInfoDialogFactory = sshConnectionInfoDialogFactory; + _customCommandDialogFactory = customCommandDialogFactory; _sshProfileSelectionDialogFactory = sshProfileSelectionDialogFactory; } @@ -81,6 +84,9 @@ namespace FluentTerminal.App.Services.Implementation public Task ShowSshConnectionInfoDialogAsync(SshProfile input = null) => _sshConnectionInfoDialogFactory().GetSshConnectionInfoAsync(input); + public Task ShowCustomCommandDialogAsync(ShellProfile input = null) => + _customCommandDialogFactory().GetCustomCommandAsync(input); + public Task ShowSshProfileSelectionDialogAsync() { var dialog = _sshProfileSelectionDialogFactory(); diff --git a/FluentTerminal.App.Services/Implementation/TrayProcessCommunicationService.cs b/FluentTerminal.App.Services/Implementation/TrayProcessCommunicationService.cs index edf72fe..dc98e99 100644 --- a/FluentTerminal.App.Services/Implementation/TrayProcessCommunicationService.cs +++ b/FluentTerminal.App.Services/Implementation/TrayProcessCommunicationService.cs @@ -52,13 +52,13 @@ namespace FluentTerminal.App.Services.Implementation return _userName; } - GetUserNameResponse response; + StringValueResponse response; // No need to crash for username, so try/catch try { var responseMessage = await _appServiceConnection.SendMessageAsync(CreateMessage(new GetUserNameRequest())); - response = JsonConvert.DeserializeObject((string)responseMessage[MessageKeys.Content]); + response = JsonConvert.DeserializeObject((string)responseMessage[MessageKeys.Content]); } catch (Exception e) { @@ -69,7 +69,7 @@ namespace FluentTerminal.App.Services.Implementation Logger.Instance.Debug("Received GetUserNameResponse: {@response}", response); - _userName = response.UserName; + _userName = response.Value; return _userName; } @@ -205,7 +205,7 @@ namespace FluentTerminal.App.Services.Implementation Logger.Instance.Error("Received output for unknown terminal Id {id}", terminalId); } break; - case TerminalExitedRequest.Identifier: + case (byte) MessageIdentifiers.TerminalExitedRequest: var request = JsonConvert.DeserializeObject((string)messageContent); Logger.Instance.Debug("Received TerminalExitedRequest: {@request}", request); @@ -266,6 +266,49 @@ namespace FluentTerminal.App.Services.Implementation return _appServiceConnection.SendMessageAsync(CreateMessage(request)); } + private static readonly Dictionary CommandPaths = new Dictionary(); + + private static readonly Dictionary CommandErrors = new Dictionary(); + + public async Task GetCommandPathAsync(string command) + { + if (string.IsNullOrWhiteSpace(command)) + { + throw new ArgumentException("Input value is null or empty.", nameof(command)); + } + + command = command.Trim(); + + var commandLower = command.ToLowerInvariant(); + + if (CommandPaths.TryGetValue(commandLower, out var path)) + { + return path; + } + + if (CommandErrors.TryGetValue(commandLower, out var error)) + { + throw new Exception(error); + } + + var request = new GetCommandPathRequest {Command = command}; + + var responseMessage = await _appServiceConnection.SendMessageAsync(CreateMessage(request)); + var response = + JsonConvert.DeserializeObject((string) responseMessage[MessageKeys.Content]); + + if (response.Success) + { + CommandPaths[commandLower] = response.Value; + + return response.Value; + } + + CommandErrors[commandLower] = response.Error; + + throw new Exception(response.Error); + } + private ValueSet CreateMessage(IMessage content) { return new ValueSet diff --git a/FluentTerminal.App.Services/Utilities/I18N.cs b/FluentTerminal.App.Services/Utilities/I18N.cs index 8546f63..e780e0b 100644 --- a/FluentTerminal.App.Services/Utilities/I18N.cs +++ b/FluentTerminal.App.Services/Utilities/I18N.cs @@ -13,5 +13,12 @@ namespace FluentTerminal.App.Services.Utilities var resourceLoader = ResourceLoader.GetForCurrentView(); return resourceLoader.GetString(resource.Replace('.', '/')); } + + public static string TranslateWithFallback(string resource, string fallback) + { + var result = Translate(resource); + + return string.IsNullOrEmpty(result) ? fallback : result; + } } } diff --git a/FluentTerminal.App.ViewModels/MainViewModel.cs b/FluentTerminal.App.ViewModels/MainViewModel.cs index 8ae6c16..7f05569 100644 --- a/FluentTerminal.App.ViewModels/MainViewModel.cs +++ b/FluentTerminal.App.ViewModels/MainViewModel.cs @@ -48,12 +48,14 @@ namespace FluentTerminal.App.ViewModels _keyboardCommandService = keyboardCommandService; _keyboardCommandService.RegisterCommandHandler(nameof(Command.NewTab), async () => await AddLocalTabAsync()); _keyboardCommandService.RegisterCommandHandler(nameof(Command.NewSshTab), async () => await AddSshTabAsync()); + _keyboardCommandService.RegisterCommandHandler(nameof(Command.NewCustomCommandTab), async () => await AddCustomCommandTabAsync()); _keyboardCommandService.RegisterCommandHandler(nameof(Command.ConfigurableNewTab), async () => await AddConfigurableTerminal()); _keyboardCommandService.RegisterCommandHandler(nameof(Command.ChangeTabTitle), async () => await SelectedTerminal.EditTitle()); _keyboardCommandService.RegisterCommandHandler(nameof(Command.CloseTab), CloseCurrentTab); _keyboardCommandService.RegisterCommandHandler(nameof(Command.SavedSshNewTab), async () => await AddSavedSshTerminalAsync()); _keyboardCommandService.RegisterCommandHandler(nameof(Command.SavedSshNewWindow), () => NewWindow(NewWindowAction.ShowSshProfileSelection)); _keyboardCommandService.RegisterCommandHandler(nameof(Command.NewSshWindow), () => NewWindow(NewWindowAction.ShowSshInfoDialog)); + _keyboardCommandService.RegisterCommandHandler(nameof(Command.NewCustomCommandWindow), () => NewWindow(NewWindowAction.ShowCustomCommandDialog)); // 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++) @@ -93,6 +95,7 @@ namespace FluentTerminal.App.ViewModels AddLocalShellCommand = new RelayCommand(async () => await AddLocalTabAsync()); AddSshShellCommand = new RelayCommand(async () => await AddSshTabAsync()); + AddQuickShellCommand = new RelayCommand(async () => await AddCustomCommandTabAsync()); ShowAboutCommand = new RelayCommand(ShowAbout); ShowSettingsCommand = new RelayCommand(ShowSettings); @@ -155,6 +158,7 @@ namespace FluentTerminal.App.ViewModels public RelayCommand AddLocalShellCommand { get; } public RelayCommand AddSshShellCommand { get; } + public RelayCommand AddQuickShellCommand { get; } public string WindowTitle { @@ -266,6 +270,23 @@ namespace FluentTerminal.App.ViewModels } } + public async Task AddCustomCommandTabAsync() + { + var profile = await _dialogService.ShowCustomCommandDialogAsync(); + if (profile == null) + { + // User selected "Cancel" + if (Terminals.Count == 0) + { + await ApplicationView.TryClose(); + } + } + else + { + await AddTerminalAsync(profile); + } + } + public Task AddSshTabOrWindowAsync(Guid shellProfileId) { switch (_applicationSettings.NewTerminalLocation) diff --git a/FluentTerminal.App.ViewModels/Profiles/CommandHistory.cs b/FluentTerminal.App.ViewModels/Profiles/CommandHistory.cs deleted file mode 100644 index e86e27d..0000000 --- a/FluentTerminal.App.ViewModels/Profiles/CommandHistory.cs +++ /dev/null @@ -1,21 +0,0 @@ -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 index 4c13f28..2bfd4a1 100644 --- a/FluentTerminal.App.ViewModels/Profiles/CommonProfileProviderViewModel.cs +++ b/FluentTerminal.App.ViewModels/Profiles/CommonProfileProviderViewModel.cs @@ -88,9 +88,9 @@ namespace FluentTerminal.App.ViewModels.Profiles Initialize(profile); } - protected override void CopyToProfile(ShellProfile profile) + protected override async Task CopyToProfileAsync(ShellProfile profile) { - base.CopyToProfile(profile); + await base.CopyToProfileAsync(profile); profile.Location = _location; profile.Arguments = _arguments; diff --git a/FluentTerminal.App.ViewModels/Profiles/ExecutedCommand.cs b/FluentTerminal.App.ViewModels/Profiles/ExecutedCommand.cs new file mode 100644 index 0000000..024bf36 --- /dev/null +++ b/FluentTerminal.App.ViewModels/Profiles/ExecutedCommand.cs @@ -0,0 +1,18 @@ +using System; +using FluentTerminal.Models; + +namespace FluentTerminal.App.ViewModels.Profiles +{ + public class ExecutedCommand + { + public string Value { get; set; } + + public bool IsProfile { get; set; } + + public DateTime LastExecution { get; set; } + + public int ExecutionCount { get; set; } + + public ShellProfile ShellProfile { get; set; } + } +} \ No newline at end of file diff --git a/FluentTerminal.App.ViewModels/Profiles/ProfileProviderViewModelBase.cs b/FluentTerminal.App.ViewModels/Profiles/ProfileProviderViewModelBase.cs index 4269ab2..899cdf2 100644 --- a/FluentTerminal.App.ViewModels/Profiles/ProfileProviderViewModelBase.cs +++ b/FluentTerminal.App.ViewModels/Profiles/ProfileProviderViewModelBase.cs @@ -60,6 +60,7 @@ namespace FluentTerminal.App.ViewModels.Profiles LoadFromProfile(value); } } + public ObservableCollection TabThemes { get; } public ObservableCollection TerminalThemes { get; } @@ -239,12 +240,13 @@ namespace FluentTerminal.App.ViewModels.Profiles Initialize(profile); } - protected virtual void CopyToProfile(ShellProfile profile) + protected virtual Task CopyToProfileAsync(ShellProfile profile) { profile.LineEndingTranslation = _lineEndingTranslation; profile.UseConPty = _useConPty; profile.TerminalThemeId = _terminalThemeId; profile.TabThemeId = _tabThemeId; + return Task.CompletedTask; } public virtual Task ValidateAsync() @@ -279,7 +281,7 @@ namespace FluentTerminal.App.ViewModels.Profiles if (acceptIfInvalid || string.IsNullOrEmpty(error)) { - CopyToProfile(Model); + await CopyToProfileAsync(Model); } return error; diff --git a/FluentTerminal.App.ViewModels/Profiles/SshConnectViewModel.cs b/FluentTerminal.App.ViewModels/Profiles/SshConnectViewModel.cs index a5c12a7..a10bb1a 100644 --- a/FluentTerminal.App.ViewModels/Profiles/SshConnectViewModel.cs +++ b/FluentTerminal.App.ViewModels/Profiles/SshConnectViewModel.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Text; @@ -21,11 +20,6 @@ namespace FluentTerminal.App.ViewModels.Profiles { #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)); @@ -52,9 +46,6 @@ namespace FluentTerminal.App.ViewModels.Profiles 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; @@ -63,34 +54,6 @@ namespace FluentTerminal.App.ViewModels.Profiles #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 @@ -128,13 +91,7 @@ namespace FluentTerminal.App.ViewModels.Profiles public bool UseMosh { get => _useMosh; - set - { - if (Set(ref _useMosh, value) && !_commandInput) - { - RaisePropertyChanged(nameof(MoshVisible)); - } - } + set => Set(ref _useMosh, value); } private ushort _moshPortFrom; @@ -153,12 +110,6 @@ namespace FluentTerminal.App.ViewModels.Profiles set => Set(ref _moshPortTo, value); } - #endregion Full UI properties - - public bool MoshVisible => _useMosh && !_commandInput; - - public ObservableCollection CommandHistory { get; private set; } - #endregion Properties #region Commands @@ -167,66 +118,28 @@ namespace FluentTerminal.App.ViewModels.Profiles #endregion Commands - #region Constructors + #region Constructor public SshConnectViewModel(ISettingsService settingsService, IApplicationView applicationView, ITrayProcessCommunicationService trayProcessCommunicationService, IFileSystemService fileSystemService, - IApplicationDataContainer historyContainer, SshProfile original = null) : base(settingsService, applicationView, + 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 + #endregion Constructor #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; @@ -282,29 +195,15 @@ namespace FluentTerminal.App.ViewModels.Profiles { base.LoadFromProfile(profile); - CommandInput = _commandInputOriginal; - Initialize((SshProfile)profile); } - protected override void CopyToProfile(ShellProfile profile) + protected override async Task CopyToProfileAsync(ShellProfile profile) { - base.CopyToProfile(profile); + await base.CopyToProfileAsync(profile); - _commandInputOriginal = _commandInput; + SshProfile sshProfile = (SshProfile) 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(); @@ -319,46 +218,7 @@ namespace FluentTerminal.App.ViewModels.Profiles 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() + public override async Task ValidateAsync() { var error = await base.ValidateAsync(); @@ -384,56 +244,8 @@ namespace FluentTerminal.App.ViewModels.Profiles 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) - { - 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 || @@ -533,63 +345,6 @@ namespace FluentTerminal.App.ViewModels.Profiles #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: @@ -599,11 +354,6 @@ namespace FluentTerminal.App.ViewModels.Profiles 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"; @@ -612,41 +362,16 @@ namespace FluentTerminal.App.ViewModels.Profiles 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) => + public static bool CheckScheme(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) + fileSystemService) { Host = uri.Host, UseMosh = MoshUriScheme.Equals(uri.Scheme, StringComparison.OrdinalIgnoreCase) @@ -708,49 +433,7 @@ namespace FluentTerminal.App.ViewModels.Profiles 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> GetUrlAsync() - { - return _commandInput ? GetUrlQuickAsync() : GetUrlFullAsync(); - } - - private async Task> GetUrlFullAsync() + public override async Task> GetUrlAsync() { var error = await base.ValidateAsync(); @@ -833,28 +516,6 @@ namespace FluentTerminal.App.ViewModels.Profiles 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.App.ViewModels/SshProfileViewModel.cs b/FluentTerminal.App.ViewModels/SshProfileViewModel.cs index 2e65ce9..0e84eb5 100644 --- a/FluentTerminal.App.ViewModels/SshProfileViewModel.cs +++ b/FluentTerminal.App.ViewModels/SshProfileViewModel.cs @@ -19,7 +19,7 @@ namespace FluentTerminal.App.ViewModels isNew) { ProfileVm = new SshConnectViewModel(settingsService, applicationView, trayProcessCommunicationService, - fileSystemService, historyContainer, sshProfile); + fileSystemService, sshProfile); } #endregion Constructor diff --git a/FluentTerminal.App/App.xaml.cs b/FluentTerminal.App/App.xaml.cs index fc5dab2..36f41b3 100755 --- a/FluentTerminal.App/App.xaml.cs +++ b/FluentTerminal.App/App.xaml.cs @@ -98,6 +98,7 @@ namespace FluentTerminal.App builder.RegisterType().As().InstancePerDependency(); builder.RegisterType().As().InstancePerDependency(); builder.RegisterType().As().InstancePerDependency(); + builder.RegisterType().As().InstancePerDependency(); builder.RegisterType().As().InstancePerDependency(); builder.RegisterType().As().InstancePerDependency(); builder.RegisterType().As().SingleInstance(); @@ -195,7 +196,10 @@ namespace FluentTerminal.App } catch (Exception ex) { - await new MessageDialog($"Invalid link: {ex.Message}", "Invalid Link").ShowAsync(); + await new MessageDialog( + $"{I18N.TranslateWithFallback("InvalidLink", "Invalid link.")} {ex.Message}", + "Invalid Link") + .ShowAsync(); mainViewModel?.ApplicationView.TryClose(); @@ -214,15 +218,17 @@ namespace FluentTerminal.App } catch (Exception ex) { - await new MessageDialog($"Invalid link: {ex.Message}", "Invalid Link").ShowAsync(); + await new MessageDialog( + $"{I18N.TranslateWithFallback("InvalidLink", "Invalid link.")} {ex.Message}", + "Invalid Link") + .ShowAsync(); mainViewModel?.ApplicationView.TryClose(); return; } - if (!vm.CommandInput && _applicationSettings.AutoFallbackToWindowsUsernameInLinks && - string.IsNullOrEmpty(vm.Username)) + if (_applicationSettings.AutoFallbackToWindowsUsernameInLinks && string.IsNullOrEmpty(vm.Username)) { vm.Username = await _trayProcessCommunicationService.GetUserName(); } @@ -253,7 +259,58 @@ namespace FluentTerminal.App return; } - await new MessageDialog($"Invalid link: {protocolActivated.Uri}", "Invalid Link").ShowAsync(); + if (CommandProfileProviderViewModel.CheckScheme(protocolActivated.Uri)) + { + CommandProfileProviderViewModel vm; + + try + { + vm = CommandProfileProviderViewModel.ParseUri(protocolActivated.Uri, _settingsService, + applicationView, _trayProcessCommunicationService, + _container.Resolve().HistoryContainer); + } + catch (Exception ex) + { + await new MessageDialog( + $"{I18N.TranslateWithFallback("InvalidLink", "Invalid link.")} {ex.Message}", + "Invalid Link") + .ShowAsync(); + + mainViewModel?.ApplicationView.TryClose(); + + return; + } + + var error = await vm.AcceptChangesAsync(true); + + var profile = vm.Model; + + if (!string.IsNullOrEmpty(error)) + { + // Link is valid, but incomplete, so we need to show dialog. + profile = await _dialogService.ShowCustomCommandDialogAsync(profile); + + if (profile == null) + { + // User clicked "Cancel" in the dialog. + mainViewModel?.ApplicationView.TryClose(); + + return; + } + } + + if (mainViewModel == null) + await CreateTerminal(profile, _applicationSettings.NewTerminalLocation); + else + await mainViewModel.AddTerminalAsync(profile); + + return; + } + + await new MessageDialog( + $"{I18N.TranslateWithFallback("InvalidLink", "Invalid link.")} {protocolActivated.Uri}", + "Invalid Link") + .ShowAsync(); mainViewModel?.ApplicationView.TryClose(); @@ -577,6 +634,14 @@ namespace FluentTerminal.App return; } break; + case NewWindowAction.ShowCustomCommandDialog: + profile = await _dialogService.ShowCustomCommandDialogAsync(); + if (profile == null) + { + // Nothing to do if user cancels. + return; + } + break; case NewWindowAction.ShowSshInfoDialog: profile = await _dialogService.ShowSshConnectionInfoDialogAsync(); if (profile == null) diff --git a/FluentTerminal.App/Dialogs/CustomCommandDialog.xaml b/FluentTerminal.App/Dialogs/CustomCommandDialog.xaml new file mode 100644 index 0000000..c9bd234 --- /dev/null +++ b/FluentTerminal.App/Dialogs/CustomCommandDialog.xaml @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +