diff --git a/Flow.Launcher.Infrastructure/Storage/JsonStorage.cs b/Flow.Launcher.Infrastructure/Storage/JsonStorage.cs index 0b10382ee9b..c7eba05fd5c 100644 --- a/Flow.Launcher.Infrastructure/Storage/JsonStorage.cs +++ b/Flow.Launcher.Infrastructure/Storage/JsonStorage.cs @@ -45,6 +45,22 @@ public JsonStorage(string filePath) FilesFolders.ValidateDirectory(DirectoryPath); } + public bool Exists() + { + return File.Exists(FilePath); + } + + public void Delete() + { + foreach (var path in new[] { FilePath, BackupFilePath, TempFilePath }) + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + public async Task LoadAsync() { if (Data != null) diff --git a/Flow.Launcher/Storage/TopMostRecord.cs b/Flow.Launcher/Storage/TopMostRecord.cs index 7f35904a520..d75e9cb7908 100644 --- a/Flow.Launcher/Storage/TopMostRecord.cs +++ b/Flow.Launcher/Storage/TopMostRecord.cs @@ -1,14 +1,115 @@ -using System.Collections.Concurrent; +using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; +using System.Text.Json; using System.Text.Json.Serialization; +using Flow.Launcher.Infrastructure.Storage; using Flow.Launcher.Plugin; namespace Flow.Launcher.Storage { - public class TopMostRecord + public class FlowLauncherJsonStorageTopMostRecord + { + private readonly FlowLauncherJsonStorage _topMostRecordStorage; + private readonly MultipleTopMostRecord _topMostRecord; + + public FlowLauncherJsonStorageTopMostRecord() + { +#pragma warning disable CS0618 // Type or member is obsolete + // Get old data & new data + var topMostRecordStorage = new FlowLauncherJsonStorage(); +#pragma warning restore CS0618 // Type or member is obsolete + _topMostRecordStorage = new FlowLauncherJsonStorage(); + + // Check if data exist + var oldDataExist = topMostRecordStorage.Exists(); + var newDataExist = _topMostRecordStorage.Exists(); + + // If new data exist, it means we have already migrated the old data + // So we can safely delete the old data and load the new data + if (newDataExist) + { + try + { + topMostRecordStorage.Delete(); + } + catch + { + // Ignored - Flow will delete the old data during next startup + } + _topMostRecord = _topMostRecordStorage.Load(); + } + // If new data does not exist and old data exist, we need to migrate the old data to the new data + else if (oldDataExist) + { + // Migrate old data to new data + _topMostRecord = _topMostRecordStorage.Load(); + var oldTopMostRecord = topMostRecordStorage.Load(); + if (oldTopMostRecord == null || oldTopMostRecord.records.IsEmpty) return; + foreach (var record in oldTopMostRecord.records) + { + var newValue = new ConcurrentQueue(); + newValue.Enqueue(record.Value); + _topMostRecord.records.AddOrUpdate(record.Key, newValue, (key, oldValue) => + { + oldValue.Enqueue(record.Value); + return oldValue; + }); + } + + // Delete old data and save the new data + try + { + topMostRecordStorage.Delete(); + } + catch + { + // Ignored - Flow will delete the old data during next startup + } + Save(); + } + // If both data do not exist, we just need to create a new data + else + { + _topMostRecord = _topMostRecordStorage.Load(); + } + } + + public void Save() + { + _topMostRecordStorage.Save(); + } + + public bool IsTopMost(Result result) + { + return _topMostRecord.IsTopMost(result); + } + + public int GetTopMostIndex(Result result) + { + return _topMostRecord.GetTopMostIndex(result); + } + + public void Remove(Result result) + { + _topMostRecord.Remove(result); + } + + public void AddOrUpdate(Result result) + { + _topMostRecord.AddOrUpdate(result); + } + } + + /// + /// Old data structure to support only one top most record for the same query + /// + [Obsolete("Use MultipleTopMostRecord instead. This class will be removed in future versions.")] + internal class TopMostRecord { [JsonInclude] - public ConcurrentDictionary records { get; private set; } = new ConcurrentDictionary(); + public ConcurrentDictionary records { get; private set; } = new(); internal bool IsTopMost(Result result) { @@ -56,12 +157,145 @@ internal void AddOrUpdate(Result result) } } - public class Record + /// + /// New data structure to support multiple top most records for the same query + /// + internal class MultipleTopMostRecord + { + [JsonInclude] + [JsonConverter(typeof(ConcurrentDictionaryConcurrentQueueConverter))] + public ConcurrentDictionary> records { get; private set; } = new(); + + internal bool IsTopMost(Result result) + { + // origin query is null when user select the context menu item directly of one item from query list + // in this case, we do not need to check if the result is top most + if (records.IsEmpty || result.OriginQuery == null || + !records.TryGetValue(result.OriginQuery.RawQuery, out var value)) + { + return false; + } + + // since this dictionary should be very small (or empty) going over it should be pretty fast. + return value.Any(record => record.Equals(result)); + } + + internal int GetTopMostIndex(Result result) + { + // origin query is null when user select the context menu item directly of one item from query list + // in this case, we do not need to check if the result is top most + if (records.IsEmpty || result.OriginQuery == null || + !records.TryGetValue(result.OriginQuery.RawQuery, out var value)) + { + return -1; + } + + // since this dictionary should be very small (or empty) going over it should be pretty fast. + // since the latter items should be more recent, we should return the smaller index for score to subtract + // which can make them more topmost + // A, B, C => 2, 1, 0 => (max - 2), (max - 1), (max - 0) + var index = 0; + foreach (var record in value) + { + if (record.Equals(result)) + { + return value.Count - 1 - index; + } + index++; + } + return -1; + } + + internal void Remove(Result result) + { + // origin query is null when user select the context menu item directly of one item from query list + // in this case, we do not need to remove the record + if (result.OriginQuery == null || + !records.TryGetValue(result.OriginQuery.RawQuery, out var value)) + { + return; + } + + // remove the record from the queue + var queue = new ConcurrentQueue(value.Where(r => !r.Equals(result))); + if (queue.IsEmpty) + { + // if the queue is empty, remove the queue from the dictionary + records.TryRemove(result.OriginQuery.RawQuery, out _); + } + else + { + // change the queue in the dictionary + records[result.OriginQuery.RawQuery] = queue; + } + } + + internal void AddOrUpdate(Result result) + { + // origin query is null when user select the context menu item directly of one item from query list + // in this case, we do not need to add or update the record + if (result.OriginQuery == null) + { + return; + } + + var record = new Record + { + PluginID = result.PluginID, + Title = result.Title, + SubTitle = result.SubTitle, + RecordKey = result.RecordKey + }; + if (!records.TryGetValue(result.OriginQuery.RawQuery, out var value)) + { + // create a new queue if it does not exist + value = new ConcurrentQueue(); + value.Enqueue(record); + records.TryAdd(result.OriginQuery.RawQuery, value); + } + else + { + // add or update the record in the queue + var queue = new ConcurrentQueue(value.Where(r => !r.Equals(result))); // make sure we don't have duplicates + queue.Enqueue(record); + records[result.OriginQuery.RawQuery] = queue; + } + } + } + + /// + /// Because ConcurrentQueue does not support serialization, we need to convert it to a List + /// + internal class ConcurrentDictionaryConcurrentQueueConverter : JsonConverter>> + { + public override ConcurrentDictionary> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var dictionary = JsonSerializer.Deserialize>>(ref reader, options); + var concurrentDictionary = new ConcurrentDictionary>(); + foreach (var kvp in dictionary) + { + concurrentDictionary.TryAdd(kvp.Key, new ConcurrentQueue(kvp.Value)); + } + return concurrentDictionary; + } + + public override void Write(Utf8JsonWriter writer, ConcurrentDictionary> value, JsonSerializerOptions options) + { + var dict = new Dictionary>(); + foreach (var kvp in value) + { + dict.Add(kvp.Key, kvp.Value.ToList()); + } + JsonSerializer.Serialize(writer, dict, options); + } + } + + internal class Record { - public string Title { get; set; } - public string SubTitle { get; set; } - public string PluginID { get; set; } - public string RecordKey { get; set; } + public string Title { get; init; } + public string SubTitle { get; init; } + public string PluginID { get; init; } + public string RecordKey { get; init; } public bool Equals(Result r) { diff --git a/Flow.Launcher/ViewModel/MainViewModel.cs b/Flow.Launcher/ViewModel/MainViewModel.cs index c0b74dc6870..f92a8ed0a5f 100644 --- a/Flow.Launcher/ViewModel/MainViewModel.cs +++ b/Flow.Launcher/ViewModel/MainViewModel.cs @@ -39,11 +39,10 @@ public partial class MainViewModel : BaseModel, ISavable, IDisposable private readonly FlowLauncherJsonStorage _historyItemsStorage; private readonly FlowLauncherJsonStorage _userSelectedRecordStorage; - private readonly FlowLauncherJsonStorage _topMostRecordStorage; + private readonly FlowLauncherJsonStorageTopMostRecord _topMostRecord; private readonly History _history; private int lastHistoryIndex = 1; private readonly UserSelectedRecord _userSelectedRecord; - private readonly TopMostRecord _topMostRecord; private CancellationTokenSource _updateSource; // Used to cancel old query flows private CancellationToken _updateToken; // Used to avoid ObjectDisposedException of _updateSource.Token @@ -143,10 +142,9 @@ public MainViewModel() _historyItemsStorage = new FlowLauncherJsonStorage(); _userSelectedRecordStorage = new FlowLauncherJsonStorage(); - _topMostRecordStorage = new FlowLauncherJsonStorage(); + _topMostRecord = new FlowLauncherJsonStorageTopMostRecord(); _history = _historyItemsStorage.Load(); _userSelectedRecord = _userSelectedRecordStorage.Load(); - _topMostRecord = _topMostRecordStorage.Load(); ContextMenu = new ResultsViewModel(Settings) { @@ -1805,7 +1803,7 @@ public void Save() { _historyItemsStorage.Save(); _userSelectedRecordStorage.Save(); - _topMostRecordStorage.Save(); + _topMostRecord.Save(); } /// @@ -1838,9 +1836,12 @@ public void UpdateResultView(ICollection resultsForUpdates) { foreach (var result in metaResults.Results) { - if (_topMostRecord.IsTopMost(result)) + var deviationIndex = _topMostRecord.GetTopMostIndex(result); + if (deviationIndex != -1) { - result.Score = Result.MaxScore; + // Adjust the score based on the result's position in the top-most list. + // A lower deviationIndex (closer to the top) results in a higher score. + result.Score = Result.MaxScore - deviationIndex; } else {