Skip to content

Commit e8cd6c2

Browse files
authored
Clean up stale ourSelections to not indefinitely ignore external selection changes (#9644)
- Add updatedAt timestamps to ourSelections (now named ourSelectionsUpdates) - During selection change handler, clean up ourSelectionsUpdates older than 1s, which should have already triggered selection change events - This prevents external selection updates from being ignored for too long when internal updates have been misguidedly added to ourSelections (which is hard to fully avoid due to vscode.editor.selections unreliability) Fixes #8903, fixes #5746, fixes #5555, fixes: #8765
1 parent abd49b2 commit e8cd6c2

File tree

3 files changed

+79
-20
lines changed

3 files changed

+79
-20
lines changed

extensionBase.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Jump } from './src/jumps/jump';
88
import { Mode } from './src/mode/mode';
99
import { ModeHandler } from './src/mode/modeHandler';
1010
import { ModeHandlerMap } from './src/mode/modeHandlerMap';
11+
import { cleanupOurSelectionsUpdates, hashSelections } from './src/mode/ourSelectionsUpdates';
1112
import { Register } from './src/register/register';
1213
import { CompositionState } from './src/state/compositionState';
1314
import { globalState } from './src/state/globalState';
@@ -283,24 +284,27 @@ export async function activate(context: vscode.ExtensionContext, handleLocal: bo
283284
return;
284285
}
285286

287+
// Clean up stale `ourSelectionUpdates` that never triggered a selectionChange event, before
288+
// we use them in our logic below
289+
mh.selectionsChanged.ourSelectionsUpdates = cleanupOurSelectionsUpdates(
290+
mh.selectionsChanged.ourSelectionsUpdates,
291+
);
292+
286293
if (e.kind !== vscode.TextEditorSelectionChangeKind.Mouse) {
287-
const selectionsHash = e.selections.reduce(
288-
(hash, s) =>
289-
hash +
290-
`[${s.anchor.line}, ${s.anchor.character}; ${s.active.line}, ${s.active.character}]`,
291-
'',
294+
const selectionsHash = hashSelections(e.selections);
295+
const idx = mh.selectionsChanged.ourSelectionsUpdates.findIndex(
296+
(entry) => entry.selectionsHash === selectionsHash,
292297
);
293-
const idx = mh.selectionsChanged.ourSelections.indexOf(selectionsHash);
294298
if (idx > -1) {
295-
mh.selectionsChanged.ourSelections.splice(idx, 1);
299+
mh.selectionsChanged.ourSelectionsUpdates.splice(idx, 1);
296300
Logger.trace(
297-
`Ignoring selection: ${selectionsHash}. ${mh.selectionsChanged.ourSelections.length} left`,
301+
`Ignoring selection: ${selectionsHash}. ${mh.selectionsChanged.ourSelectionsUpdates.length} left`,
298302
);
299303
return;
300304
} else if (mh.selectionsChanged.ignoreIntermediateSelections) {
301305
Logger.trace(`Ignoring intermediate selection change: ${selectionsHash}`);
302306
return;
303-
} else if (mh.selectionsChanged.ourSelections.length > 0) {
307+
} else if (mh.selectionsChanged.ourSelectionsUpdates.length > 0) {
304308
// Some intermediate selection must have slipped in after setting the
305309
// 'ignoreIntermediateSelections' to false. Which means we didn't count
306310
// for it yet, but since we have selections to be ignored then we probably

src/mode/modeHandler.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { IBaseAction } from '../actions/types';
1111
import { Cursor } from '../common/motion/cursor';
1212
import { configuration } from '../configuration/configuration';
1313
import { decoration } from '../configuration/decoration';
14+
import { isLiteralMode, remapKey } from '../configuration/langmap';
1415
import { Notation } from '../configuration/notation';
1516
import { Remappers } from '../configuration/remapper';
1617
import { Jump } from '../jumps/jump';
@@ -58,7 +59,7 @@ import {
5859
isStatusBarMode,
5960
isVisualMode,
6061
} from './mode';
61-
import { isLiteralMode, remapKey } from '../configuration/langmap';
62+
import { OurSelectionsUpdate, hashSelections } from './ourSelectionsUpdates';
6263

6364
interface IModeHandlerMap {
6465
get(editorId: Uri): ModeHandler | undefined;
@@ -88,7 +89,7 @@ export class ModeHandler implements vscode.Disposable, IModeHandler {
8889
* Used internally to ignore selection changes that were performed by us.
8990
* 'ignoreIntermediateSelections': set to true when running an action, during this time
9091
* all selections change events will be ignored.
91-
* 'ourSelections': keeps track of our selections that will trigger a selection change event
92+
* 'ourSelectionsUpdates': keeps track of our selections that will trigger a selection change event
9293
* so that we can ignore them.
9394
*/
9495
public selectionsChanged = {
@@ -101,7 +102,7 @@ export class ModeHandler implements vscode.Disposable, IModeHandler {
101102
* keeps track of our selections that will trigger a selection change event
102103
* so that we can ignore them.
103104
*/
104-
ourSelections: Array<string>(),
105+
ourSelectionsUpdates: Array<OurSelectionsUpdate>(),
105106
};
106107

107108
/**
@@ -1456,16 +1457,11 @@ export class ModeHandler implements vscode.Disposable, IModeHandler {
14561457
);
14571458

14581459
if (willTriggerChange) {
1459-
const selectionsHash = selections.reduce(
1460-
(hash, s) =>
1461-
hash +
1462-
`[${s.anchor.line}, ${s.anchor.character}; ${s.active.line}, ${s.active.character}]`,
1463-
'',
1464-
);
1465-
this.selectionsChanged.ourSelections.push(selectionsHash);
1460+
const selectionsHash = hashSelections(selections);
1461+
this.selectionsChanged.ourSelectionsUpdates.push({ selectionsHash, updatedAt: Date.now() });
14661462
Logger.trace(
14671463
`Adding selection change to be ignored! (total: ${
1468-
this.selectionsChanged.ourSelections.length
1464+
this.selectionsChanged.ourSelectionsUpdates.length
14691465
}) Hash: ${selectionsHash}, Selections: ${selections[0].anchor.toString()}, ${selections[0].active.toString()}`,
14701466
);
14711467
}

src/mode/ourSelectionsUpdates.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import * as vscode from 'vscode';
2+
3+
import { Logger } from '../util/logger';
4+
5+
/** Expiry time for our selections updates queue, after which we can reasonably expect a selection
6+
* update to have triggered a VSCode selection change event resulting in its dequeueing.
7+
*
8+
* We want the expiry time to be generous enough to allow for delayed VSCode event firings. If we
9+
* remove an update too early, we might mistakenly treat its change event as an external change that
10+
* we should be tracking, rather than an internal one we've already accounted for.
11+
*
12+
* We also want the expiry time to be short enough to discard stale internal updates, whose change
13+
* events have likely already fired, or will never fire. Note that VSCode selectionChange events
14+
* only fire if the editor's selections have actually changed in value, hence why we compare against
15+
* `editor.selections` to choose whether to add an update to `ourSelectionsUpdates`. But because we
16+
* receive VSCode's selection events at a different rate from our own updates, `editor.selections`
17+
* can be outdated when we do that comparison, and we can mistakenly think an internal update will
18+
* trigger a selectionChange event when it actually won't. Such an update will be added to
19+
* `ourSelectionsUpdates`, and will only be removed when we clean it up after the expiry time. */
20+
const SELECTIONS_UPDATE_EXPIRY_MS = 1000;
21+
22+
/**
23+
* An internally-triggered selections update, which will trigger a VSCode selection change event
24+
* that we can ignore as an already-tracked change.
25+
*/
26+
export type OurSelectionsUpdate = {
27+
selectionsHash: string;
28+
updatedAt: number;
29+
};
30+
31+
/**
32+
* Removes our selections updates that are older than the expiry time. Ideally there shouldn't be
33+
* any stale updates present when this is called. But if there are, we remove them so that VSCode
34+
* selectionChange events aren't being ignored indefinitely under the incorrect assumption that
35+
* they were triggered by internal updates we've already tracked.
36+
*/
37+
export function cleanupOurSelectionsUpdates(
38+
ourSelectionsUpdates: OurSelectionsUpdate[],
39+
): OurSelectionsUpdate[] {
40+
const now = Date.now();
41+
return ourSelectionsUpdates.filter((entry) => {
42+
const age = now - entry.updatedAt;
43+
const shouldKeep = age < SELECTIONS_UPDATE_EXPIRY_MS;
44+
if (!shouldKeep) {
45+
Logger.warn(
46+
`OurSelectionsUpdate ${entry.selectionsHash} still around after ${age}ms; removing now`,
47+
);
48+
}
49+
return shouldKeep;
50+
});
51+
}
52+
53+
export function hashSelections(selections: readonly vscode.Selection[]): string {
54+
return selections.reduce(
55+
(hash, s) =>
56+
hash + `[${s.anchor.line}, ${s.anchor.character}; ${s.active.line}, ${s.active.character}]`,
57+
'',
58+
);
59+
}

0 commit comments

Comments
 (0)