|
| 1 | +import * as vscode from 'vscode'; |
| 2 | + |
| 3 | +import { Logger } from '../util/logger'; |
| 4 | +import { areSelectionArraysEqual, hashSelectionsArray } from '../util/selections'; |
| 5 | + |
| 6 | +/** |
| 7 | + * Expiry time for our internal selections updates tracking, after which we can reasonably expect an |
| 8 | + * associated VSCode selection change event to have been fired for a given selections update, if one |
| 9 | + * ever will be. |
| 10 | + * |
| 11 | + * We want to be generous enough to allow for delayed VSCode selection change event firings, to |
| 12 | + * avoid treating the delayed event as an external update and re-updating our own selections state |
| 13 | + * based on an internal update we've already accounted for. |
| 14 | + * |
| 15 | + * We also want to discard stale internal updates before too long. Until we untrack them, they will |
| 16 | + * block incoming selection change events (including external ones) from being handled. |
| 17 | + */ |
| 18 | +const SELECTIONS_UPDATE_TO_IGNORE_EXPIRY_MS = 1000; |
| 19 | + |
| 20 | +/** |
| 21 | + * An internal selections update that we want to track. |
| 22 | + */ |
| 23 | +export type InternalSelectionsUpdate = { |
| 24 | + /** Hash for the updated selections, based on each selection's anchor and active position. */ |
| 25 | + selectionsHash: string; |
| 26 | + /** Timestamp (in ms since Unix epoch) of when this selections update was added to the tracker. */ |
| 27 | + trackedAt: number; |
| 28 | +}; |
| 29 | + |
| 30 | +/** |
| 31 | + * Class that helps with tracking internal selections updates and controlling / determining whether |
| 32 | + * to treat incoming {@link vscode.TextEditorSelectionChangeEvent}s as internal/intermediate |
| 33 | + * selections changes to ignore, or as external selections updates that should update our internal |
| 34 | + * selections state. |
| 35 | + * |
| 36 | + * Methods exposed: |
| 37 | + * |
| 38 | + * - {@link shouldIgnoreAsInternalSelectionChangeEvent} - Determines whether to ignore a given |
| 39 | + * VSCode selection change event as an internal/intermediate selections update's change event |
| 40 | + * |
| 41 | + * - {@link startIgnoringIntermediateSelections} - Start ignoring selection change events while |
| 42 | + * running an action |
| 43 | + * |
| 44 | + * - {@link stopIgnoringIntermediateSelections} - Resume handling selection change events after |
| 45 | + * running an action |
| 46 | + * |
| 47 | + * - {@link maybeTrackSelectionsUpdateToIgnore} - Predicts whether the given selections update will |
| 48 | + * trigger a selection change event, and if so, tracks it |
| 49 | + */ |
| 50 | +export class InternalSelectionsTracker { |
| 51 | + // #region Determining whether to ignore a selection change event |
| 52 | + |
| 53 | + /** |
| 54 | + * Determines whether or not to view the given VSCode selection change event as being from an |
| 55 | + * internal or intermediate selections update, and thus ignore it. Removes its associated |
| 56 | + * selections update entry from our tracking, if found. |
| 57 | + * |
| 58 | + * @returns `true` if the event should be ignored, else `false`. |
| 59 | + */ |
| 60 | + public shouldIgnoreAsInternalSelectionChangeEvent( |
| 61 | + event: vscode.TextEditorSelectionChangeEvent, |
| 62 | + ): boolean { |
| 63 | + // First remove stale tracked updates that we shouldn't worry about anymore |
| 64 | + this.cleanupStaleSelectionsUpdatesToIgnore(); |
| 65 | + |
| 66 | + const selectionsHash = hashSelectionsArray(event.selections); |
| 67 | + return ( |
| 68 | + this.maybeUntrackSelectionsUpdateToIgnore(selectionsHash) || |
| 69 | + this.shouldIgnoreAsIntermediateSelection(selectionsHash) |
| 70 | + ); |
| 71 | + } |
| 72 | + |
| 73 | + /** |
| 74 | + * Looks for an equivalent selections update in our tracked selections updates to ignore, and |
| 75 | + * removes it if found. |
| 76 | + * |
| 77 | + * @returns `true` if the selection was found and removed, else `false`. |
| 78 | + */ |
| 79 | + private maybeUntrackSelectionsUpdateToIgnore(selectionsHash: string): boolean { |
| 80 | + const index = this.selectionsUpdatesToIgnore.findIndex( |
| 81 | + (update) => update.selectionsHash === selectionsHash, |
| 82 | + ); |
| 83 | + if (index !== -1) { |
| 84 | + this.selectionsUpdatesToIgnore.splice(index, 1); |
| 85 | + this.logTrace( |
| 86 | + `Ignoring and un-tracking internal selection update's change event ${ |
| 87 | + selectionsHash |
| 88 | + }. Remaining internal selections updates to ignore: ${ |
| 89 | + this.selectionsUpdatesToIgnore.length |
| 90 | + }`, |
| 91 | + ); |
| 92 | + return true; |
| 93 | + } |
| 94 | + return false; |
| 95 | + } |
| 96 | + |
| 97 | + /** |
| 98 | + * Determines whether or not to view a VSCode selection change event (whose selections hash wasn't |
| 99 | + * found in our tracked selections updates to ignore) as being from an internal action's |
| 100 | + * intermediate selection, and thus ignore it. |
| 101 | + * |
| 102 | + * If `shouldIgnoreIntermediateSelections` is `true`, the answer is an easy yes. |
| 103 | + * |
| 104 | + * If not, but we are still tracking internal selections updates whose change events haven't fired |
| 105 | + * yet, we assume we're just processing intermediate selections' change events that have slipped |
| 106 | + * past the end of an action we just finished, and we return `true`. This may sometimes result in |
| 107 | + * unintentionally ignoring an external selection made during or closely after a series of |
| 108 | + * internal updates, especially if any of those internal updates were misguidedly added to our |
| 109 | + * tracking by {@link maybeTrackSelectionsUpdateToIgnore} even though they don't actually trigger |
| 110 | + * selection change events. For now, we err on the side of allowing false negatives (missing an |
| 111 | + * external update) over false positives (re-applying an internal update), but we may want to |
| 112 | + * revisit this tradeoff in the future. |
| 113 | + * |
| 114 | + * If neither condition is met, we return `false`. |
| 115 | + * |
| 116 | + * Logic originally implemented in: [VSCodeVim/Vim#5015](https://github.com/VSCodeVim/Vim/pull/5015) |
| 117 | + * |
| 118 | + * @param untrackedEventSelectionsHash Hashed selections array from a selection change event, that |
| 119 | + * wasn't found in our tracked selections updates to ignore |
| 120 | + * @returns `true` if we should ignore the event as an intermediate selection, else `false` |
| 121 | + */ |
| 122 | + private shouldIgnoreAsIntermediateSelection(untrackedEventSelectionsHash: string): boolean { |
| 123 | + if (this.shouldIgnoreIntermediateSelections) { |
| 124 | + this.logTrace( |
| 125 | + `Ignoring intermediate selection change event ${untrackedEventSelectionsHash} while running action`, |
| 126 | + ); |
| 127 | + return true; |
| 128 | + } |
| 129 | + |
| 130 | + if (this.selectionsUpdatesToIgnore.length > 0) { |
| 131 | + this.logWarn( |
| 132 | + `Treating untracked selection change event ${ |
| 133 | + untrackedEventSelectionsHash |
| 134 | + } as an intermediate selection to ignore; assuming it slipped past the end of an action we just ran, since there are still ${ |
| 135 | + this.selectionsUpdatesToIgnore.length |
| 136 | + } tracked internal selections updates to ignore`, |
| 137 | + ); |
| 138 | + return true; |
| 139 | + } |
| 140 | + |
| 141 | + return false; |
| 142 | + } |
| 143 | + |
| 144 | + // #endregion |
| 145 | + |
| 146 | + // #region Ignoring intermediate selections during an action |
| 147 | + |
| 148 | + /** |
| 149 | + * Whether or not we should be ignoring all incoming VSCode selection change events. |
| 150 | + */ |
| 151 | + private shouldIgnoreIntermediateSelections: boolean = false; |
| 152 | + |
| 153 | + /** |
| 154 | + * To be called when starting an action, to flag that we should start ignoring all incoming |
| 155 | + * VSCode selection change events until we call `stopIgnoringIntermediateSelections`. |
| 156 | + */ |
| 157 | + public startIgnoringIntermediateSelections(): void { |
| 158 | + this.shouldIgnoreIntermediateSelections = true; |
| 159 | + this.logDebug('Now ignoring intermediate selection change events while running action'); |
| 160 | + } |
| 161 | + |
| 162 | + /** |
| 163 | + * To be called after running an action, so we can start handling selection change events again. |
| 164 | + */ |
| 165 | + public stopIgnoringIntermediateSelections(): void { |
| 166 | + this.shouldIgnoreIntermediateSelections = false; |
| 167 | + this.logDebug('Resuming handling of selection change events after running action'); |
| 168 | + } |
| 169 | + |
| 170 | + // #endregion |
| 171 | + |
| 172 | + // #region Tracking internal selections updates |
| 173 | + |
| 174 | + /** |
| 175 | + * Array of hashed and timestamped internal selections updates, whose incoming VSCode selection |
| 176 | + * change events should be ignored. Each tracked update should be removed when we receive its |
| 177 | + * associated selection change event, or cleaned up after an expiry time if its change event never |
| 178 | + * gets fired, so that we don't indefinitely block incoming selection change events from updating |
| 179 | + * our internal state. |
| 180 | + * |
| 181 | + * Note that we intentionally use an array, rather than a set or map, to account for validly |
| 182 | + * identical selection updates which will each trigger their own vscode selection change events; |
| 183 | + * e.g. if we update selections to [1,1][1,1] then [1,2][1,2] and then [1,1][1,1] again. |
| 184 | + */ |
| 185 | + private selectionsUpdatesToIgnore: InternalSelectionsUpdate[] = []; |
| 186 | + |
| 187 | + /** |
| 188 | + * Checks if the selections update will trigger a {@link vscode.TextEditorSelectionChangeEvent}, |
| 189 | + * and if so, tracks it so we know to ignore its incoming selection change event. Note that VSCode |
| 190 | + * only fires a selection change event if the editor's selections actually change in value. |
| 191 | + * |
| 192 | + * However, it should be noted that our check isn't perfect here, and we might misguidedly track |
| 193 | + * an update even if it won't actually trigger a selection change event; hence the need to clean |
| 194 | + * up lingering updates from `selectionsUpdatesToIgnore` after an expiry time. |
| 195 | + * |
| 196 | + * This is because behind the scenes, VSCode seems to quickly reset `editor.selections` after it's |
| 197 | + * been set, before properly processing the update and firing a change event. So it's possible for |
| 198 | + * `editor.selections` to lag behind what we've set it to in the previous `updateView` call, |
| 199 | + * leading us to mistakenly think that the current update will trigger a selection change event, |
| 200 | + * even if it won't actually differ from `editor.selections` when VSCode processes this update. |
| 201 | + * See: [VSCodeVim/Vim#9644](https://github.com/VSCodeVim/Vim/pull/9644) |
| 202 | + */ |
| 203 | + public maybeTrackSelectionsUpdateToIgnore({ |
| 204 | + updatedSelections, |
| 205 | + currentEditorSelections, |
| 206 | + }: { |
| 207 | + updatedSelections: readonly vscode.Selection[]; |
| 208 | + currentEditorSelections: readonly vscode.Selection[]; |
| 209 | + }): void { |
| 210 | + if (areSelectionArraysEqual(updatedSelections, currentEditorSelections)) { |
| 211 | + // VSCode won't fire a selection change event for this update, so there's no need to track it |
| 212 | + return; |
| 213 | + } |
| 214 | + this.trackSelectionsUpdateToIgnore(updatedSelections); |
| 215 | + } |
| 216 | + |
| 217 | + private trackSelectionsUpdateToIgnore(updatedSelections: readonly vscode.Selection[]): void { |
| 218 | + const selectionsHash = hashSelectionsArray(updatedSelections); |
| 219 | + this.selectionsUpdatesToIgnore.push({ selectionsHash, trackedAt: Date.now() }); |
| 220 | + this.logTrace( |
| 221 | + `Tracking ${selectionsHash} as an internal selections update to ignore. Total tracked now: ${this.selectionsUpdatesToIgnore.length}`, |
| 222 | + ); |
| 223 | + } |
| 224 | + |
| 225 | + /** |
| 226 | + * Removes stale entries from `selectionsUpdatesToIgnore`. Ideally there shouldn't be any stale |
| 227 | + * updates present, and instead every tracked update is removed before expiration when we handle |
| 228 | + * its associated selection change event. But if there are, we remove them so that we don't |
| 229 | + * indefinitely ignore incoming selection change events under the incorrect assumption that they |
| 230 | + * were triggered by internal updates we've already accounted for. |
| 231 | + */ |
| 232 | + private cleanupStaleSelectionsUpdatesToIgnore(): void { |
| 233 | + const now = Date.now(); |
| 234 | + this.selectionsUpdatesToIgnore = this.selectionsUpdatesToIgnore.filter((entry) => { |
| 235 | + const { trackedAt, selectionsHash } = entry; |
| 236 | + const age = now - trackedAt; |
| 237 | + const hasExpired = age > SELECTIONS_UPDATE_TO_IGNORE_EXPIRY_MS; |
| 238 | + if (hasExpired) { |
| 239 | + this.logWarn( |
| 240 | + `Un-tracking stale internal selections update ${selectionsHash} after ${ |
| 241 | + age |
| 242 | + }ms without a matching selection change event (expiry_ms: ${SELECTIONS_UPDATE_TO_IGNORE_EXPIRY_MS}ms)`, |
| 243 | + ); |
| 244 | + } |
| 245 | + return !hasExpired; |
| 246 | + }); |
| 247 | + } |
| 248 | + |
| 249 | + // #endregion |
| 250 | + |
| 251 | + // #region Logging helpers |
| 252 | + private logTrace(message: string): void { |
| 253 | + Logger.trace(`[InternalSelectionsTracker] ${message}`); |
| 254 | + } |
| 255 | + private logDebug(message: string): void { |
| 256 | + Logger.debug(`[InternalSelectionsTracker] ${message}`); |
| 257 | + } |
| 258 | + private logWarn(message: string): void { |
| 259 | + Logger.warn(`[InternalSelectionsTracker] ${message}`); |
| 260 | + } |
| 261 | + // #endregion |
| 262 | +} |
0 commit comments