Skip to content

Commit d9fed92

Browse files
authored
keyboard: Undo stack for keyboard target (#2420)
- Fixes #2413 ## Checklist - [x] I have added [tests](https://www.cursorless.org/docs/contributing/test-case-recorder/) - [-] I have updated the [docs](https://github.com/cursorless-dev/cursorless/tree/main/docs) and [cheatsheet](https://github.com/cursorless-dev/cursorless/tree/main/cursorless-talon/src/cheatsheet) - [-] I have not broken the cheatsheet
1 parent f92c6f8 commit d9fed92

File tree

10 files changed

+197
-4
lines changed

10 files changed

+197
-4
lines changed

packages/common/src/cursorlessCommandIds.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export const cursorlessCommandIds = [
3131
"cursorless.keyboard.modal.modeOff",
3232
"cursorless.keyboard.modal.modeOn",
3333
"cursorless.keyboard.modal.modeToggle",
34+
"cursorless.keyboard.undoTarget",
35+
"cursorless.keyboard.redoTarget",
3436
"cursorless.keyboard.targeted.clearTarget",
3537
"cursorless.keyboard.targeted.runActionOnTarget",
3638
"cursorless.keyboard.targeted.targetHat",
@@ -136,4 +138,10 @@ export const cursorlessCommandDescriptions: Record<
136138
["cursorless.keyboard.modal.modeToggle"]: new HiddenCommand(
137139
"Toggle the cursorless modal mode",
138140
),
141+
["cursorless.keyboard.undoTarget"]: new HiddenCommand(
142+
"Undo keyboard targeting changes",
143+
),
144+
["cursorless.keyboard.redoTarget"]: new HiddenCommand(
145+
"Redo keyboard targeting changes",
146+
),
139147
};

packages/cursorless-engine/src/core/StoredTargets.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,55 @@
1-
import { Notifier } from "@cursorless/common";
1+
import { DefaultMap, Notifier } from "@cursorless/common";
22
import { Target } from "../typings/target.types";
33
import { StoredTargetKey, storedTargetKeys } from "@cursorless/common";
4+
import { UndoStack } from "./UndoStack";
5+
6+
const MAX_HISTORY_LENGTH = 25;
47

58
/**
69
* Used to store targets between commands. This is used by marks like `that`
710
* and `source`.
811
*/
912
export class StoredTargetMap {
1013
private targetMap: Map<StoredTargetKey, Target[] | undefined> = new Map();
14+
15+
// FIXME: Keep these targets up to date as document changes
16+
private targetHistory: DefaultMap<StoredTargetKey, UndoStack<Target[]>> =
17+
new DefaultMap(() => new UndoStack<Target[]>(MAX_HISTORY_LENGTH));
18+
1119
private notifier = new Notifier<[StoredTargetKey, Target[] | undefined]>();
1220

13-
set(key: StoredTargetKey, targets: Target[] | undefined) {
21+
set(
22+
key: StoredTargetKey,
23+
targets: Target[] | undefined,
24+
{ history = false }: { history?: boolean } = {},
25+
) {
1426
this.targetMap.set(key, targets);
27+
if (history && targets != null) {
28+
this.targetHistory.get(key).push(targets);
29+
}
1530
this.notifier.notifyListeners(key, targets);
1631
}
1732

1833
get(key: StoredTargetKey) {
1934
return this.targetMap.get(key);
2035
}
2136

37+
undo(key: StoredTargetKey) {
38+
const targets = this.targetHistory.get(key).undo();
39+
40+
if (targets != null) {
41+
this.set(key, targets, { history: false });
42+
}
43+
}
44+
45+
redo(key: StoredTargetKey) {
46+
const targets = this.targetHistory.get(key).redo();
47+
48+
if (targets != null) {
49+
this.set(key, targets, { history: false });
50+
}
51+
}
52+
2253
onStoredTargets(
2354
callback: (key: StoredTargetKey, targets: Target[] | undefined) => void,
2455
) {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import assert from "node:assert";
2+
import { UndoStack } from "./UndoStack";
3+
4+
suite("UndoStack", () => {
5+
test("should undo and redo", () => {
6+
const undoStack = new UndoStack<string>(3);
7+
undoStack.push("a");
8+
undoStack.push("b");
9+
undoStack.push("c");
10+
assert.strictEqual(undoStack.undo(), "b");
11+
assert.strictEqual(undoStack.undo(), "a");
12+
assert.strictEqual(undoStack.undo(), undefined);
13+
assert.strictEqual(undoStack.redo(), "b");
14+
assert.strictEqual(undoStack.redo(), "c");
15+
assert.strictEqual(undoStack.redo(), undefined);
16+
});
17+
18+
test("should clobber stack if push after undo", () => {
19+
const undoStack = new UndoStack<string>(3);
20+
undoStack.push("a");
21+
undoStack.push("b");
22+
undoStack.push("c");
23+
assert.strictEqual(undoStack.undo(), "b");
24+
undoStack.push("d");
25+
assert.strictEqual(undoStack.undo(), "b");
26+
assert.strictEqual(undoStack.redo(), "d");
27+
assert.strictEqual(undoStack.redo(), undefined);
28+
});
29+
30+
test("should truncate history if max lenght exceeded", () => {
31+
const undoStack = new UndoStack<string>(3);
32+
undoStack.push("a");
33+
undoStack.push("b");
34+
undoStack.push("c");
35+
undoStack.push("d");
36+
assert.strictEqual(undoStack.undo(), "c");
37+
assert.strictEqual(undoStack.undo(), "b");
38+
assert.strictEqual(undoStack.undo(), undefined);
39+
});
40+
41+
test("should handle empty undo and redo", () => {
42+
const undoStack = new UndoStack<string>(3);
43+
assert.strictEqual(undoStack.undo(), undefined);
44+
assert.strictEqual(undoStack.redo(), undefined);
45+
});
46+
47+
test("should handle redo at end of stack", () => {
48+
const undoStack = new UndoStack<string>(3);
49+
undoStack.push("a");
50+
assert.strictEqual(undoStack.redo(), undefined);
51+
});
52+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* Keeps track of the history of a piece of state. You can push new states onto
3+
* the stack, undo to previous states, and redo to future states.
4+
*/
5+
export class UndoStack<T> {
6+
private stack: T[] = [];
7+
private index: number | undefined = undefined;
8+
9+
constructor(private maxLength: number) {}
10+
11+
/**
12+
* Push a new state onto the stack. If {@link undo} has been called, the
13+
* future states will be dropped and the new state will be pushed onto the
14+
* stack.
15+
*
16+
* @param item The new state to push onto the stack
17+
*/
18+
push(item: T) {
19+
if (this.index != null) {
20+
this.stack.splice(
21+
this.index + 1,
22+
this.stack.length - this.index - 1,
23+
item,
24+
);
25+
} else {
26+
this.stack.push(item);
27+
}
28+
29+
if (this.stack.length > this.maxLength) {
30+
this.stack.shift();
31+
}
32+
33+
this.index = this.stack.length - 1;
34+
}
35+
36+
/**
37+
* Undo to the previous state.
38+
*
39+
* @returns The previous state, or `undefined` if there are no previous states
40+
*/
41+
undo(): T | undefined {
42+
if (this.index != null && this.index > 0) {
43+
this.index--;
44+
return this.stack[this.index];
45+
}
46+
47+
return undefined;
48+
}
49+
50+
/**
51+
* Redo to the next state.
52+
*
53+
* @returns The next state, or `undefined` if there are no future states
54+
*/
55+
redo(): T | undefined {
56+
if (this.index != null && this.index < this.stack.length - 1) {
57+
this.index++;
58+
return this.stack[this.index];
59+
}
60+
61+
return undefined;
62+
}
63+
}

packages/cursorless-engine/src/core/commandRunner/CommandRunnerImpl.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export class CommandRunnerImpl implements CommandRunner {
8484
constructStoredTarget(newSourceTargets, newSourceSelections),
8585
);
8686
this.storedTargets.set("instanceReference", newInstanceReferenceTargets);
87-
this.storedTargets.set("keyboard", newKeyboardTargets);
87+
this.storedTargets.set("keyboard", newKeyboardTargets, { history: true });
8888

8989
return { returnValue };
9090
}

packages/cursorless-vscode-e2e/src/suite/keyboard/basic.vscode.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,27 @@ const testCases: TestCase[] = [
106106
keySequence: ["da", "nst", " ", "c"],
107107
finalContent: "aaa bbb ddd",
108108
},
109+
{
110+
name: "keyboard undo",
111+
initialContent: "aaa bbb",
112+
// keyboard air
113+
// keyboard bat
114+
// undo keyboard
115+
// clear
116+
keySequence: ["da", "db", "vu", "c"],
117+
finalContent: " bbb",
118+
},
119+
{
120+
name: "keyboard redo",
121+
initialContent: "aaa bbb",
122+
// keyboard air
123+
// keyboard bat
124+
// undo keyboard
125+
// redo keyboard
126+
// clear
127+
keySequence: ["da", "db", "vu", "vr", "c"],
128+
finalContent: "aaa ",
129+
},
109130
];
110131

111132
suite("Basic keyboard test", async function () {

packages/cursorless-vscode/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,16 @@
189189
"command": "cursorless.keyboard.modal.modeToggle",
190190
"title": "Cursorless: Toggle the cursorless modal mode",
191191
"enablement": "false"
192+
},
193+
{
194+
"command": "cursorless.keyboard.undoTarget",
195+
"title": "Cursorless: Undo keyboard targeting changes",
196+
"enablement": "false"
197+
},
198+
{
199+
"command": "cursorless.keyboard.redoTarget",
200+
"title": "Cursorless: Redo keyboard targeting changes",
201+
"enablement": "false"
192202
}
193203
],
194204
"colors": [

packages/cursorless-vscode/src/extension.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ export async function activate(
146146
scopeVisualizer,
147147
keyboardCommands,
148148
hats,
149+
storedTargets,
149150
);
150151

151152
new ReleaseNotes(vscodeApi, context, normalizedIde.messages).maybeShow();

packages/cursorless-vscode/src/keyboard/keyboard-config.fixture.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,9 @@
8383
"keepChangedSelection": true,
8484
"exitCursorlessMode": true
8585
},
86-
" ": "cursorless.repeatPreviousCommand"
86+
" ": "cursorless.repeatPreviousCommand",
87+
"vu": "cursorless.keyboard.undoTarget",
88+
"vr": "cursorless.keyboard.redoTarget"
8789
},
8890
"cursorless.experimental.keyboard.modal.keybindings.pairedDelimiter": {
8991
"wl": "angleBrackets",

packages/cursorless-vscode/src/registerCommands.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from "@cursorless/common";
77
import {
88
CommandApi,
9+
StoredTargetMap,
910
TestCaseRecorder,
1011
analyzeCommandHistory,
1112
showCheatsheet,
@@ -30,6 +31,7 @@ export function registerCommands(
3031
scopeVisualizer: ScopeVisualizer,
3132
keyboardCommands: KeyboardCommands,
3233
hats: VscodeHats,
34+
storedTargets: StoredTargetMap,
3335
): void {
3436
const runCommandWrapper = async (run: () => Promise<unknown>) => {
3537
try {
@@ -114,6 +116,9 @@ export function registerCommands(
114116
["cursorless.keyboard.modal.modeOn"]: keyboardCommands.modal.modeOn,
115117
["cursorless.keyboard.modal.modeOff"]: keyboardCommands.modal.modeOff,
116118
["cursorless.keyboard.modal.modeToggle"]: keyboardCommands.modal.modeToggle,
119+
120+
["cursorless.keyboard.undoTarget"]: () => storedTargets.undo("keyboard"),
121+
["cursorless.keyboard.redoTarget"]: () => storedTargets.redo("keyboard"),
117122
};
118123

119124
extensionContext.subscriptions.push(

0 commit comments

Comments
 (0)