@@ -34,14 +34,26 @@ public record RecentCommandsManager : IRecentCommandsManager
3434 // evicted still-useful history within a single busy session on active machines.
3535 internal const int MaxHistoryEntries = 500 ;
3636
37- // Legacy history items (persisted before LastUsed existed) deserialize with a default
38- // timestamp. Treat them as used one day ago: recent enough that they still rank, mild
39- // enough that a single fresh use outranks them, and uniform so their relative order falls
40- // back to Uses (frequency) instead of collapsing to all-equal or zero.
37+ // Defensive fallback for any history item that carries a default (missing) LastUsed - for
38+ // example a value constructed without a timestamp, or state written by a build that predates
39+ // the LastUsed field. Such an item is treated as used one day ago: recent enough that it
40+ // still ranks, mild enough that a single fresh use outranks it, and uniform so a group of
41+ // them falls back to Uses (frequency) ordering instead of collapsing to all-equal or zero.
42+ // In practice this rarely fires: earlier builds never actually persisted history (the
43+ // internal History property was dropped by the serializer, see [JsonInclude] below), so
44+ // upgrading users start from an empty store rather than one full of timestamp-less items.
4145 internal static readonly TimeSpan LegacyBackdate = TimeSpan . FromDays ( 1 ) ;
4246
4347 private ImmutableList < HistoryItem > ? _history = ImmutableList < HistoryItem > . Empty ;
4448
49+ // Cached commandId -> entry lookup over History, rebuilt lazily whenever History is
50+ // (re)assigned and never persisted. ScoreTopLevelItem calls GetCommandHistoryWeight for
51+ // every candidate item on every keystroke, and History can hold up to MaxHistoryEntries
52+ // (500) entries, so a plain linear scan would be O(items x history) per keystroke. The
53+ // dictionary keeps each lookup O(1) so the hot path stays cheap as the store grows.
54+ [ JsonIgnore ]
55+ private Dictionary < string , HistoryItem > ? _index ;
56+
4557 // Persisted so recent-command frecency (including the LastUsed timestamps) survives a
4658 // restart. [JsonInclude] is required because the property is internal; without it the
4759 // source-generated serializer emits an empty object and history is silently dropped.
@@ -50,7 +62,38 @@ public record RecentCommandsManager : IRecentCommandsManager
5062 internal ImmutableList < HistoryItem > History
5163 {
5264 get => _history ?? ImmutableList < HistoryItem > . Empty ;
53- init => _history = value ;
65+ init
66+ {
67+ _history = value ;
68+
69+ // Invalidate the cached lookup so it is rebuilt from the new list on next use.
70+ // A record 'with' copy carries over the old field, so this reset is what keeps
71+ // the index from going stale after History changes.
72+ _index = null ;
73+ }
74+ }
75+
76+ private Dictionary < string , HistoryItem > Index
77+ {
78+ get
79+ {
80+ if ( _index is null )
81+ {
82+ // Ordinal to match the string '==' comparison the previous linear scan used.
83+ var map = new Dictionary < string , HistoryItem > ( StringComparer . Ordinal ) ;
84+ foreach ( var item in History )
85+ {
86+ // History is most-recent-first and command ids are unique in it, but keep
87+ // the first (most recent) occurrence if a duplicate ever slips in so the
88+ // lookup matches the old FirstOrDefault behavior.
89+ map . TryAdd ( item . CommandId , item ) ;
90+ }
91+
92+ _index = map ;
93+ }
94+
95+ return _index ;
96+ }
5497 }
5598
5699 public RecentCommandsManager ( )
@@ -68,14 +111,13 @@ public int GetCommandHistoryWeight(string commandId)
68111 /// </summary>
69112 internal int GetCommandHistoryWeight ( string commandId , DateTimeOffset now )
70113 {
71- var entry = History . FirstOrDefault ( item => item . CommandId == commandId ) ;
72- if ( entry is null )
114+ if ( ! Index . TryGetValue ( commandId , out var entry ) )
73115 {
74116 return 0 ;
75117 }
76118
77- // Migrate legacy entries (no timestamp) to a mild backdate so they degrade to
78- // Uses-ordering instead of appearing brand-new or invisible.
119+ // Migrate items with a default (missing) timestamp to a mild backdate so they degrade
120+ // to Uses-ordering instead of appearing brand-new or invisible. See LegacyBackdate .
79121 var lastUsed = entry . LastUsed == default ? now - LegacyBackdate : entry . LastUsed ;
80122
81123 // Clamp age at zero so a slightly-future timestamp (e.g. clock skew) can't amplify.
0 commit comments