diff --git a/FluentTerminal.App.Services/ICommandHistoryService.cs b/FluentTerminal.App.Services/ICommandHistoryService.cs index 02b3ffe..a7af80a 100644 --- a/FluentTerminal.App.Services/ICommandHistoryService.cs +++ b/FluentTerminal.App.Services/ICommandHistoryService.cs @@ -1,18 +1,39 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using FluentTerminal.Models; namespace FluentTerminal.App.Services { public interface ICommandHistoryService { - List GetAll(); + /// + /// Returns the most recent commands from history. + /// + /// Indicates if never used saved profiles should also be returned. + /// Maximal number of items to return. + /// List of all saved profiles. If this argument isn't specified, the method will + /// get the list from . The purpose of this argument is to avoid loading the + /// profiles from settings again (since it includes file system access and JSON deserialization), if we + /// already have them loaded. + List GetHistoryRecentFirst(bool includeProfiles = false, int top = int.MaxValue, + Func> profilesProvider = null); + + /// + /// Returns the most used commands from history. + /// + /// Indicates if never used saved profiles should also be returned. + /// Maximal number of items to return. + /// List of all saved profiles. If this argument isn't specified, the method will + /// get the list from . The purpose of this argument is to avoid loading the + /// profiles from settings again (since it includes file system access and JSON deserialization), if we + /// already have them loaded. + List GetHistoryMostUsedFirst(bool includeProfiles = false, int top = int.MaxValue, + Func> profilesProvider = null); + + void MarkUsed(ShellProfile profile); void Clear(); - bool TryGetCommand(string value, out ExecutedCommand executedCommand); - void Delete(ExecutedCommand executedCommand); - - void Save(ExecutedCommand executedCommand); } } \ No newline at end of file diff --git a/FluentTerminal.App.Services/Implementation/CommandHistoryService.cs b/FluentTerminal.App.Services/Implementation/CommandHistoryService.cs deleted file mode 100644 index 886724c..0000000 --- a/FluentTerminal.App.Services/Implementation/CommandHistoryService.cs +++ /dev/null @@ -1,174 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using FluentTerminal.Models; -using Newtonsoft.Json; - -namespace FluentTerminal.App.Services.Implementation -{ - public class CommandHistoryService : ICommandHistoryService - { - private readonly IApplicationDataContainer _historyContainer; - - public CommandHistoryService(ApplicationDataContainers containers) - { - _historyContainer = containers.HistoryContainer; - } - - public List GetAll() - { - var commands = GetAllPrivate(); - - for (var i = 0; i < commands.Count; i++) - { - var command = commands[i]; - - if (command.ShellProfile == null) continue; - - var newProfile = MoshBackwardCompatibility.FixProfile(command.ShellProfile); - - if (ReferenceEquals(command.ShellProfile, newProfile)) continue; - - if (!command.IsProfile) - { - newProfile.Name = $"{newProfile.Location} {newProfile.Arguments}".Trim(); - } - - var newCommand = new ExecutedCommand - { - Value = newProfile.Name, IsProfile = command.IsProfile, LastExecution = command.LastExecution, - ExecutionCount = command.ExecutionCount, ShellProfile = newProfile - }; - - commands.Insert(i, newCommand); - - commands.RemoveAt(i + 1); - - Delete(command); - - Save(newCommand); - } - - return commands; - } - - private List GetAllPrivate() - { - try - { - return _historyContainer.GetAll() - .Select(c => JsonConvert.DeserializeObject((string) c)).ToList(); - } - catch - { - // ignored - } - - _historyContainer.Clear(); - - return new List(); - } - - public void Clear() => _historyContainer.Clear(); - - public bool TryGetCommand(string value, out ExecutedCommand executedCommand) - { - var found = TryGetCommandPrivate(value, out executedCommand); - - if (!found || executedCommand?.ShellProfile == null) return found; - - var newProfile = MoshBackwardCompatibility.FixProfile(executedCommand.ShellProfile); - - if (ReferenceEquals(executedCommand.ShellProfile, newProfile)) return true; - - if (!executedCommand.IsProfile) - { - newProfile.Name = $"{newProfile.Location} {newProfile.Arguments}".Trim(); - } - - var newCommand = new ExecutedCommand - { - Value = newProfile.Name, - IsProfile = executedCommand.IsProfile, - LastExecution = executedCommand.LastExecution, - ExecutionCount = executedCommand.ExecutionCount, - ShellProfile = newProfile - }; - - Delete(executedCommand); - - Save(newCommand); - - executedCommand = newCommand; - - return true; - } - - private bool TryGetCommandPrivate(string value, out ExecutedCommand executedCommand) - { - var key = GetHash(value); - - executedCommand = null; - - if (!_historyContainer.TryGetValue(key, out var cmd)) return false; - - if (cmd is string cmdStr) - { - try - { - executedCommand = JsonConvert.DeserializeObject(cmdStr); - - return true; - } - catch - { - // ignored - } - } - else if (cmd is ExecutedCommand execCmd) - { - executedCommand = execCmd; - - return true; - } - - // Not a valid command, so delete it: - _historyContainer.Delete(key); - - return false; - - } - - public void Delete(ExecutedCommand executedCommand) => _historyContainer.Delete(GetHash(executedCommand.Value)); - - public void Save(ExecutedCommand executedCommand) => - _historyContainer.WriteValueAsJson(GetHash(executedCommand.Value), executedCommand); - - private static string GetHash(string value) - { - if (string.IsNullOrEmpty(value)) - { - return value; - } - - value = value.ToLowerInvariant(); - - byte[] hashed; - - using (var md5 = MD5.Create()) - { - hashed = md5.ComputeHash(Encoding.UTF32.GetBytes(value)); - } - - var builder = new StringBuilder(); - - foreach (var b in hashed) - { - builder.Append(b.ToString("X2")); - } - - return $"hist_{builder}_{value.Length:##########}"; - } - } -} \ No newline at end of file diff --git a/FluentTerminal.App.Services/Implementation/MoshBackwardCompatibility.cs b/FluentTerminal.App.Services/Implementation/MoshBackwardCompatibility.cs index db0e9f4..e3090bc 100644 --- a/FluentTerminal.App.Services/Implementation/MoshBackwardCompatibility.cs +++ b/FluentTerminal.App.Services/Implementation/MoshBackwardCompatibility.cs @@ -6,7 +6,7 @@ namespace FluentTerminal.App.Services.Implementation { // The purpose of this class is to ensure backward compatibility of mosh profiles and command history // TODO: This class should be probably deleted in few months or so - internal static class MoshBackwardCompatibility + public static class MoshBackwardCompatibility { private static readonly string MoshCommandExe = $"{Constants.MoshCommandName}.exe"; @@ -36,7 +36,7 @@ namespace FluentTerminal.App.Services.Implementation /// as the input profile). It allows us to simply check if anything is changed by comparing the input and output /// profiles for reference equality. /// - internal static T FixProfile(T profile) where T : ShellProfile + public static T FixProfile(T profile) where T : ShellProfile { // If there aren't any arguments, or if the profile isn't a mosh profile, there's nothing to do. Just return. if (string.IsNullOrWhiteSpace(profile.Arguments) || !IsMoshProfile(profile)) return profile; diff --git a/FluentTerminal.App/ViewModels/DelayedHistorySaver.cs b/FluentTerminal.App.ViewModels/DelayedHistorySaver.cs similarity index 89% rename from FluentTerminal.App/ViewModels/DelayedHistorySaver.cs rename to FluentTerminal.App.ViewModels/DelayedHistorySaver.cs index d8db97d..f1d7bf1 100644 --- a/FluentTerminal.App/ViewModels/DelayedHistorySaver.cs +++ b/FluentTerminal.App.ViewModels/DelayedHistorySaver.cs @@ -1,4 +1,5 @@ -using System.Threading; +using System; +using System.Threading; using System.Threading.Tasks; namespace FluentTerminal.App.ViewModels @@ -7,7 +8,7 @@ namespace FluentTerminal.App.ViewModels { private const int DelayMilliseconds = 5000; - private readonly CommandProfileProviderViewModel _vm; + private readonly Action _saveAction; private readonly object _lock = new object(); @@ -18,9 +19,9 @@ namespace FluentTerminal.App.ViewModels private bool _toSave = true; - internal DelayedHistorySaver(CommandProfileProviderViewModel vm) + internal DelayedHistorySaver(Action saveAction) { - _vm = vm; + _saveAction = saveAction; } public void SetSuccessfulSessionStart() @@ -86,7 +87,7 @@ namespace FluentTerminal.App.ViewModels { if (_toSave) { - _vm.SaveToHistory(); + _saveAction.Invoke(); } _toSave = false; diff --git a/FluentTerminal.App.ViewModels/MainViewModel.cs b/FluentTerminal.App.ViewModels/MainViewModel.cs index 30eb15d..eba0421 100644 --- a/FluentTerminal.App.ViewModels/MainViewModel.cs +++ b/FluentTerminal.App.ViewModels/MainViewModel.cs @@ -18,12 +18,19 @@ namespace FluentTerminal.App.ViewModels { public class MainViewModel : ViewModelBase { + #region Constants + + private const int RecentItemsMaxCount = 10; + + #endregion Constants + private readonly IClipboardService _clipboardService; private readonly IDialogService _dialogService; private readonly IDispatcherTimer _dispatcherTimer; private readonly IKeyboardCommandService _keyboardCommandService; private readonly ISettingsService _settingsService; private readonly ITrayProcessCommunicationService _trayProcessCommunicationService; + private readonly ICommandHistoryService _commandHistoryService; private ApplicationSettings _applicationSettings; private string _background; private double _backgroundOpacity; @@ -32,8 +39,8 @@ namespace FluentTerminal.App.ViewModels private string _windowTitle; private List _keyBindings; - public MainViewModel(ISettingsService settingsService, ITrayProcessCommunicationService trayProcessCommunicationService, IDialogService dialogService, IKeyboardCommandService keyboardCommandService, - IApplicationView applicationView, IDispatcherTimer dispatcherTimer, IClipboardService clipboardService) + public MainViewModel(ISettingsService settingsService, ITrayProcessCommunicationService trayProcessCommunicationService, IDialogService dialogService, IKeyboardCommandService keyboardCommandService, + IApplicationView applicationView, IDispatcherTimer dispatcherTimer, IClipboardService clipboardService, ICommandHistoryService commandHistoryService) { MessengerInstance.Register(this, OnApplicationSettingsChanged); MessengerInstance.Register(this, OnCurrentThemeChanged); @@ -42,6 +49,7 @@ namespace FluentTerminal.App.ViewModels MessengerInstance.Register(this, OnSshProfileAdded); MessengerInstance.Register(this, OnSshProfileDeleted); MessengerInstance.Register(this, OnTerminalOptionsChanged); + MessengerInstance.Register(this, OnCommandHistoryChanged); _settingsService = settingsService; @@ -51,6 +59,8 @@ namespace FluentTerminal.App.ViewModels _dispatcherTimer = dispatcherTimer; _clipboardService = clipboardService; _keyboardCommandService = keyboardCommandService; + _commandHistoryService = commandHistoryService; + _keyboardCommandService.RegisterCommandHandler(nameof(Command.NewTab), async () => await AddLocalTabAsync()); _keyboardCommandService.RegisterCommandHandler(nameof(Command.NewSshTab), async () => await AddSshTabAsync()); _keyboardCommandService.RegisterCommandHandler(nameof(Command.NewCustomCommandTab), async () => await AddCustomCommandTabAsync()); @@ -113,6 +123,24 @@ namespace FluentTerminal.App.ViewModels MessengerInstance.Register(this, message => LoadKeyBindings()); } + private IList _recentCommands; + + public IList RecentCommands + { + get => _recentCommands ?? (_recentCommands = _commandHistoryService + .GetHistoryRecentFirst(top: RecentItemsMaxCount).Select(c => + new ProfileCommandViewModel(c.ShellProfile, + new RelayCommand(() => AddTerminalAsync(c.ShellProfile)))).ToList()); + private set => Set(ref _recentCommands, value); + } + + private void OnCommandHistoryChanged(CommandHistoryChangedMessage message) + { + _recentCommands = null; + + ApplicationView.RunOnDispatcherThread(() => RaisePropertyChanged(nameof(RecentCommands)), false); + } + private void LoadKeyBindings() => _keyBindings = _settingsService.GetCommandKeyBindings().Values.SelectMany(v => v).ToList(); @@ -371,6 +399,8 @@ namespace FluentTerminal.App.ViewModels public Task AddTerminalAsync(ShellProfile profile, string terminalState, int position) { + profile.Tag = new DelayedHistorySaver(() => _commandHistoryService.MarkUsed(profile)); + return ApplicationView.RunOnDispatcherThread(() => { var terminal = new TerminalViewModel(_settingsService, _trayProcessCommunicationService, _dialogService, _keyboardCommandService, diff --git a/FluentTerminal.App.ViewModels/ProfileCommandViewModel.cs b/FluentTerminal.App.ViewModels/ProfileCommandViewModel.cs new file mode 100644 index 0000000..ccc7fbc --- /dev/null +++ b/FluentTerminal.App.ViewModels/ProfileCommandViewModel.cs @@ -0,0 +1,19 @@ +using FluentTerminal.Models; +using GalaSoft.MvvmLight; +using GalaSoft.MvvmLight.Command; + +namespace FluentTerminal.App.ViewModels +{ + public class ProfileCommandViewModel : ViewModelBase + { + public ShellProfile Profile { get; } + + public RelayCommand Command { get; } + + public ProfileCommandViewModel(ShellProfile profile, RelayCommand command) + { + Profile = profile; + Command = command; + } + } +} \ No newline at end of file diff --git a/FluentTerminal.App/Dialogs/CustomCommandDialog.xaml.cs b/FluentTerminal.App/Dialogs/CustomCommandDialog.xaml.cs index 7807d34..b293887 100644 --- a/FluentTerminal.App/Dialogs/CustomCommandDialog.xaml.cs +++ b/FluentTerminal.App/Dialogs/CustomCommandDialog.xaml.cs @@ -249,14 +249,7 @@ namespace FluentTerminal.App.Dialogs var result = await _showDialogOperation.AsTask().ContinueWith(t => _dialogResult); - if (result != ContentDialogResult.Primary) - { - return null; - } - - ViewModel.Model.Tag = new DelayedHistorySaver(ViewModel); - - return ViewModel.Model; + return result != ContentDialogResult.Primary ? null : ViewModel.Model; } } } diff --git a/FluentTerminal.App/FluentTerminal.App.csproj b/FluentTerminal.App/FluentTerminal.App.csproj index 8562382..d7bb453 100644 --- a/FluentTerminal.App/FluentTerminal.App.csproj +++ b/FluentTerminal.App/FluentTerminal.App.csproj @@ -126,6 +126,7 @@ AboutDialog.xaml + @@ -154,7 +155,6 @@ - EnvironmentVariablesView.xaml @@ -170,6 +170,7 @@ CreateKeyBindingDialog.xaml + OverlayView.xaml diff --git a/FluentTerminal.App/Services/CommandHistoryService.cs b/FluentTerminal.App/Services/CommandHistoryService.cs new file mode 100644 index 0000000..6a22b7a --- /dev/null +++ b/FluentTerminal.App/Services/CommandHistoryService.cs @@ -0,0 +1,346 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using FluentTerminal.App.Services.Implementation; +using FluentTerminal.Models; +using FluentTerminal.Models.Messages; +using GalaSoft.MvvmLight.Messaging; +using Newtonsoft.Json; + +namespace FluentTerminal.App.Services +{ + public class CommandHistoryService : ICommandHistoryService + { + #region Constants + + // Maximal number of commands to save to history + private const int MaxHistory = 1_000; + + private static readonly DateTime NeverUsedTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + #endregion Constants + + #region Static + + private static string GetHash(string value) + { + if (string.IsNullOrEmpty(value)) + { + return value; + } + + value = value.ToLowerInvariant(); + + byte[] hashed; + + using (var md5 = MD5.Create()) + { + hashed = md5.ComputeHash(Encoding.UTF32.GetBytes(value)); + } + + var builder = new StringBuilder(); + + foreach (var b in hashed) + { + builder.Append(b.ToString("X2")); + } + + return $"hist_{builder}_{value.Length:##########}"; + } + + #endregion Static + + #region Fields + + private readonly IApplicationDataContainer _historyContainer; + private readonly ISettingsService _settingsService; + + private List _history; + + #endregion Fields + + #region Constructor + + public CommandHistoryService(ApplicationDataContainers containers, ISettingsService settingsService) + { + _settingsService = settingsService; + _historyContainer = containers.HistoryContainer; + } + + #endregion Constructor + + #region Methods + + private List GetAllProfiles() => + _settingsService.GetSshProfiles().Union(_settingsService.GetShellProfiles()).ToList(); + + private List GetRawHistory(Func> profilesProvider = null) + { + if (_history == null) + { + _history = LoadHistory(); + + CleanupHistory(profilesProvider); + } + + return _history; + } + + private List LoadHistory() + { + List history = null; + + try + { + history = _historyContainer.GetAll() + .Select(c => JsonConvert.DeserializeObject((string) c)).ToList(); + } + catch + { + // ignored + } + + if (history == null) + { + // If there's an issue with getting history, we simply clear history as if the app haven't been used at all. + // Not too useful fix, but it shouldn't happen ever anyway. + _historyContainer.Clear(); + + return new List(); + } + + FixMoshBackwardCompatibility(history); + + return history; + } + + private void FixMoshBackwardCompatibility(List commands) + { + for (var i = 0; i < commands.Count; i++) + { + var command = commands[i]; + + if (command.ShellProfile == null) continue; + + var newProfile = MoshBackwardCompatibility.FixProfile(command.ShellProfile); + + if (ReferenceEquals(command.ShellProfile, newProfile)) continue; + + if (!command.IsProfile) + { + newProfile.Name = $"{newProfile.Location} {newProfile.Arguments}".Trim(); + } + + var newCommand = new ExecutedCommand + { + Value = newProfile.Name, + IsProfile = command.IsProfile, + LastExecution = command.LastExecution, + ExecutionCount = command.ExecutionCount, + ShellProfile = newProfile + }; + + commands.Insert(i, newCommand); + + commands.RemoveAt(i + 1); + + Delete(command); + + Save(newCommand); + } + } + + private void Save(ExecutedCommand executedCommand) + { + _historyContainer.WriteValueAsJson(GetHash(executedCommand.Value), executedCommand); + + Messenger.Default.Send(new CommandHistoryChangedMessage()); + } + + /// + /// Removes all profiles from history if they are deleted in the settings page meanwhile. + /// + private void CleanupHistory(Func> profilesProvider = null) + { + if (_history == null) + { + // Not loaded yet, so we won't clear. This won't happen though. + + return; + } + + if (!_history.Any()) + { + return; + } + + var profiles = profilesProvider?.Invoke() ?? GetAllProfiles(); + + for (var i = 0; i < _history.Count; i++) + { + var command = _history[i]; + + if ((command.IsProfile || command.ShellProfile == null) && !profiles.Any(p => + string.Equals(p.Name, command.Value, StringComparison.OrdinalIgnoreCase))) + { + _history.Remove(command); + + Delete(command); + + i--; + } + } + } + + private IEnumerable GetHistory(bool byNumberOfUsages, bool includeProfiles, + Func> profilesProvider = null) + { + var allProfiles = new Lazy>(profilesProvider ?? GetAllProfiles); + + var history = GetRawHistory(() => allProfiles.Value); + + if (!history.Any()) + { + yield break; + } + + var ordered = byNumberOfUsages + ? history.OrderByDescending(c => c.ExecutionCount).ThenByDescending(c => c.LastExecution).ToList() + : history.OrderByDescending(c => c.LastExecution).ThenByDescending(c => c.ExecutionCount).ToList(); + + foreach (var command in ordered) + { + if (command.IsProfile || command.ShellProfile == null) + { + var profile = allProfiles.Value.FirstOrDefault(p => + string.Equals(p.Name, command.Value, StringComparison.OrdinalIgnoreCase)); + + if (profile == null) + { + history.Remove(command); + + Delete(command); + } + else + { + allProfiles.Value.Remove(profile); + + var newCommand = command.Clone(); + + newCommand.ShellProfile = profile; + + yield return newCommand; + } + } + else + { + yield return command.Clone(); + } + } + + if (!includeProfiles) + { + yield break; + } + + foreach (var unusedProfile in allProfiles.Value) + { + yield return new ExecutedCommand + { + Value = unusedProfile.Name, + ExecutionCount = 0, + LastExecution = NeverUsedTime, + IsProfile = true, + ShellProfile = unusedProfile + }; + } + } + + /// + public List GetHistoryRecentFirst(bool includeProfiles = false, int top = int.MaxValue, + Func> profileProvider = null) => + GetHistory(false, includeProfiles, profileProvider).Take(top).ToList(); + + /// + public List GetHistoryMostUsedFirst(bool includeProfiles = false, int top = int.MaxValue, + Func> profileProvider = null) => + GetHistory(true, includeProfiles, profileProvider).Take(top).ToList(); + + public void MarkUsed(ShellProfile profile) + { + var history = GetRawHistory(); + + var existing = history.FirstOrDefault(c => + string.Equals(c.Value, profile.Name, StringComparison.OrdinalIgnoreCase)); + + if (existing == null) + { + existing = new ExecutedCommand + { + Value = profile.Name, + IsProfile = GetAllProfiles().Any(p => + string.Equals(p.Name, profile.Name, StringComparison.OrdinalIgnoreCase)) + }; + + if (!existing.IsProfile) + { + existing.ShellProfile = profile; + } + + var overflow = history.Count - MaxHistory + 1; + + if (overflow > 0) + { + // We already have max number of commands in history, so we need to delete some + // Let's first try with cleanup + CleanupHistory(); + + overflow = history.Count - MaxHistory + 1; + + if (overflow > 0) + { + // We will remove the oldest commands + var toRemove = history.OrderBy(c => c.LastExecution).ThenBy(c => c.ExecutionCount) + .Take(overflow).ToList(); + + foreach (var command in toRemove) + { + history.Remove(command); + + Delete(command); + } + } + } + + history.Add(existing); + } + + existing.LastExecution = DateTime.UtcNow; + existing.ExecutionCount++; + + Save(existing); + } + + public void Clear() + { + _history?.Clear(); + + _historyContainer.Clear(); + + Messenger.Default.Send(new CommandHistoryChangedMessage()); + } + + public void Delete(ExecutedCommand executedCommand) + { + _history?.RemoveAll(c => string.Equals(c.Value, executedCommand.Value, StringComparison.OrdinalIgnoreCase)); + + _historyContainer.Delete(GetHash(executedCommand.Value)); + + Messenger.Default.Send(new CommandHistoryChangedMessage()); + } + + #endregion Methods + } +} \ No newline at end of file diff --git a/FluentTerminal.App/Strings/en/Resources.resw b/FluentTerminal.App/Strings/en/Resources.resw index 82dbf6a..1604474 100644 --- a/FluentTerminal.App/Strings/en/Resources.resw +++ b/FluentTerminal.App/Strings/en/Resources.resw @@ -553,7 +553,7 @@ Fuzzy ShellProfileSettings.xaml - Are you sure you want to close "{0}" tab? + Are you sure you want to close "{0}" tab? ViewModel Fuzzy @@ -931,7 +931,7 @@ Fuzzy Identify file does not exist - Use Windows username when opening SSH URLs that don't specify one + Use Windows username when opening SSH URLs that don't specify one Default to Mosh for SSH connections @@ -952,7 +952,7 @@ Fuzzy Mute terminal beeps - Enter a command (e.g. 'ssh username@host') + Enter a command (e.g. 'ssh username@host') Command @@ -1035,11 +1035,7 @@ Fuzzy Word separator characters Fuzzy - - Background image - ThemeSettings.xaml - - - Are you sure you want to delete the background image ? + + Recent \ No newline at end of file diff --git a/FluentTerminal.App/ViewModels/CommandProfileProviderViewModel.cs b/FluentTerminal.App/ViewModels/CommandProfileProviderViewModel.cs index c4cda09..1186403 100644 --- a/FluentTerminal.App/ViewModels/CommandProfileProviderViewModel.cs +++ b/FluentTerminal.App/ViewModels/CommandProfileProviderViewModel.cs @@ -28,7 +28,8 @@ namespace FluentTerminal.App.ViewModels private readonly ITrayProcessCommunicationService _trayProcessCommunicationService; private readonly ICommandHistoryService _historyService; - private List _allCommands; + private readonly List _allCommands; + private readonly Lazy> _allProfiles; private string _processedFilter; @@ -74,7 +75,14 @@ namespace FluentTerminal.App.ViewModels _trayProcessCommunicationService = trayProcessCommunicationService; _historyService = historyService; - FillCommandHistory(); + _allProfiles = new Lazy>(() => + SettingsService.GetSshProfiles().Union(SettingsService.GetShellProfiles()).ToList()); + + _allCommands = _historyService + .GetHistoryMostUsedFirst(true, profilesProvider: () => _allProfiles.Value.ToList()) + .Select(c => new CommandItemViewModel(c)).ToList(); + + Commands = new ObservableCollection(_allCommands); Initialize(Model); } @@ -113,7 +121,8 @@ namespace FluentTerminal.App.ViewModels } var existingProfile = - _allProfiles.FirstOrDefault(p => string.Equals(p.Name, command, StringComparison.OrdinalIgnoreCase)); + _allProfiles.Value.FirstOrDefault(p => + string.Equals(p.Name, command, StringComparison.OrdinalIgnoreCase)); if (existingProfile != null) { @@ -178,7 +187,7 @@ namespace FluentTerminal.App.ViewModels var command = _command?.Trim() ?? string.Empty; if (!string.IsNullOrEmpty(command) && - _allProfiles.Any(p => string.Equals(p.Name, command, StringComparison.OrdinalIgnoreCase))) + _allProfiles.Value.Any(p => string.Equals(p.Name, command, StringComparison.OrdinalIgnoreCase))) { // It's a saved profile return null; @@ -241,83 +250,6 @@ namespace FluentTerminal.App.ViewModels LoadFromProfile(Model); } - #endregion Methods - - #region Command history - - private const int CommandHistoryLimit = 50; - - private static readonly DateTime NeverUsedTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); - - private List _allProfiles; - - private void FillCommandHistory() - { - var savedCommands = _historyService.GetAll(); - - _allProfiles = SettingsService.GetShellProfiles().Union(SettingsService.GetSshProfiles()) - .Where(p => !string.IsNullOrEmpty(p.Name)).ToList(); - - var counter = 0; - - while (counter < savedCommands.Count) - { - var command = savedCommands[counter]; - - if (command.IsProfile) - { - var profile = _allProfiles.FirstOrDefault(p => - string.Equals(p.Name, command.Value, StringComparison.OrdinalIgnoreCase)); - - if (profile == null) - { - _historyService.Delete(command); - - savedCommands.RemoveAt(counter); - - counter--; - } - else - { - command.ShellProfile = profile; - } - } - - counter++; - } - - if (savedCommands.Count > CommandHistoryLimit) - { - var toRemove = savedCommands.OrderBy(c => c.LastExecution) - .Take(savedCommands.Count - CommandHistoryLimit).ToList(); - - foreach (var remove in toRemove) - { - savedCommands.Remove(remove); - - _historyService.Delete(remove); - } - } - - var unusedProfiles = _allProfiles.Where(p => - !savedCommands.Any(c => string.Equals(p.Name, c.Value, StringComparison.OrdinalIgnoreCase))).ToList(); - - IEnumerable commands = savedCommands - .Union(unusedProfiles.Select(p => new ExecutedCommand - { - Value = p.Name, - ExecutionCount = 0, - LastExecution = NeverUsedTime, - IsProfile = true, - ShellProfile = p - })); - - _allCommands = commands.OrderByDescending(c => c.ExecutionCount).ThenByDescending(c => c.LastExecution) - .Select(c => new CommandItemViewModel(c)).ToList(); - - Commands = new ObservableCollection(_allCommands); - } - public void SetFilter(string filter) { filter = filter?.Trim().ToLowerInvariant() ?? string.Empty; @@ -374,7 +306,7 @@ namespace FluentTerminal.App.ViewModels private Task SetFilterAsync(string filter, bool containsPrevious) { - var toCheck = containsPrevious ? (ICollection) Commands : _allCommands; + var toCheck = containsPrevious ? (ICollection)Commands : _allCommands; var words = filter.SplitWords().ToArray(); @@ -422,25 +354,6 @@ namespace FluentTerminal.App.ViewModels }, false); } - public void SaveToHistory() - { - var isProfile = - _allProfiles.Any(p => string.Equals(p.Name, Model.Name, StringComparison.OrdinalIgnoreCase)); - - if (!_historyService.TryGetCommand(Model.Name, out ExecutedCommand command)) - { - command = new ExecutedCommand {ExecutionCount = 0}; - } - - command.Value = Model.Name; - command.ExecutionCount++; - command.LastExecution = DateTime.UtcNow; - command.ShellProfile = isProfile ? null : Model; - command.IsProfile = isProfile; - - _historyService.Save(command); - } - public void RemoveCommand(ExecutedCommand command) { _historyService.Delete(command); @@ -455,12 +368,10 @@ namespace FluentTerminal.App.ViewModels } } - public bool IsProfileCommand(ExecutedCommand command) - { - return _allProfiles.Any(p => command.Value.NullableEqualTo(p.Name, StringComparison.OrdinalIgnoreCase)); - } + public bool IsProfileCommand(ExecutedCommand command) => _allProfiles.Value.Any(p => + command.Value.NullableEqualTo(p.Name, StringComparison.OrdinalIgnoreCase)); - #endregion Command history + #endregion Methods #region Links/shortcuts related diff --git a/FluentTerminal.App/Views/MainPage.xaml b/FluentTerminal.App/Views/MainPage.xaml index 85d9abc..a33f86d 100644 --- a/FluentTerminal.App/Views/MainPage.xaml +++ b/FluentTerminal.App/Views/MainPage.xaml @@ -77,6 +77,7 @@ ToolTipService.ToolTip="About" /> + diff --git a/FluentTerminal.App/Views/MenuExtension.cs b/FluentTerminal.App/Views/MenuExtension.cs new file mode 100644 index 0000000..9e8501a --- /dev/null +++ b/FluentTerminal.App/Views/MenuExtension.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Controls; +using FluentTerminal.App.ViewModels; + +namespace FluentTerminal.App.Views +{ + // https://stackoverflow.com/questions/33839279/bind-obserable-collection-to-menuflyoutsubitem-in-uwp + public static class MenuExtension + { + public static IEnumerable GetSubItems(DependencyObject obj) => + (IEnumerable) obj.GetValue(SubItemsProperty); + + public static void SetSubItems(DependencyObject obj, IEnumerable value) => + obj.SetValue(SubItemsProperty, value); + + public static readonly DependencyProperty SubItemsProperty = + DependencyProperty.Register("SubItems", typeof(IEnumerable), typeof(MenuExtension), + new PropertyMetadata(new List(), (sender, e) => + { + if (sender is MenuFlyoutSubItem subItemsRoot && subItemsRoot.Items != null) + { + subItemsRoot.Items.Clear(); + + if (e.NewValue is IEnumerable commands) + { + foreach (var command in commands) + { + subItemsRoot.Items.Add(new MenuFlyoutItem {Text = command.Profile.Name, Command = command.Command}); + } + + // Hack to enforce changing items in view + //subItemsRoot.UpdateLayout(); + //subItemsRoot.InvalidateArrange(); + subItemsRoot.InvalidateMeasure(); + } + } + })); + } +} \ No newline at end of file diff --git a/FluentTerminal.Models/ExecutedCommand.cs b/FluentTerminal.Models/ExecutedCommand.cs index a2811e1..42ac612 100644 --- a/FluentTerminal.Models/ExecutedCommand.cs +++ b/FluentTerminal.Models/ExecutedCommand.cs @@ -13,5 +13,11 @@ namespace FluentTerminal.Models public int ExecutionCount { get; set; } public ShellProfile ShellProfile { get; set; } + + public ExecutedCommand Clone() => new ExecutedCommand + { + Value = Value, IsProfile = IsProfile, LastExecution = LastExecution, ExecutionCount = ExecutionCount, + ShellProfile = ShellProfile?.Clone() + }; } } \ No newline at end of file diff --git a/FluentTerminal.Models/Messages/CommandHistoryChangedMessage.cs b/FluentTerminal.Models/Messages/CommandHistoryChangedMessage.cs new file mode 100644 index 0000000..a0797bf --- /dev/null +++ b/FluentTerminal.Models/Messages/CommandHistoryChangedMessage.cs @@ -0,0 +1,6 @@ +namespace FluentTerminal.Models.Messages +{ + public class CommandHistoryChangedMessage + { + } +} \ No newline at end of file