Skip to content

Commit 94e9fd0

Browse files
Edit based copy and paste (#2536)
For IDEs that don't have vscode specific copy and paste behavior I have now implemented a text edit basted copy and paste. This also gives us a starting platform if we want to try to implement vscodes behavior ourself. Fixes #2522 ## Checklist - [/] 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 --------- Co-authored-by: Pokey Rule <[email protected]>
1 parent 44a36e6 commit 94e9fd0

File tree

17 files changed

+290
-103
lines changed

17 files changed

+290
-103
lines changed

packages/common/src/ide/fake/FakeCapabilities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Capabilities } from "../types/Capabilities";
22

33
export class FakeCapabilities implements Capabilities {
44
commands = {
5+
clipboardPaste: undefined,
56
clipboardCopy: undefined,
67
toggleLineComment: undefined,
78
indentLine: undefined,
Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,26 @@
11
import { CommandId } from "./CommandId";
22

33
export interface Capabilities {
4+
/**
5+
* Capabilities of the commands that the IDE supports. Note that for many of
6+
* these commands, if the IDE does not support them, Cursorless will have a
7+
* fairly sophisticated fallback, so it may actually better to report
8+
* `undefined`. This will vary per action, though. In the future We will
9+
* improve our per-action types / docstrings to make this more clear; see
10+
* #1233
11+
*/
412
readonly commands: CommandCapabilityMap;
513
}
614

7-
export type CommandCapabilityMap = Record<
15+
type SimpleCommandCapabilityMap = Record<
816
CommandId,
917
CommandCapabilities | undefined
1018
>;
1119

20+
export interface CommandCapabilityMap extends SimpleCommandCapabilityMap {
21+
clipboardPaste: boolean | undefined;
22+
}
23+
1224
export interface CommandCapabilities {
1325
acceptsLocation: boolean;
1426
}

packages/common/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,4 @@ export * from "./util/toPlainObject";
111111
export * from "./util/type";
112112
export * from "./util/typeUtils";
113113
export * from "./util/uniqWithHash";
114+
export * from "./util/zipStrict";

packages/common/src/types/TextEditor.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,8 @@ export interface EditableTextEditor extends TextEditor {
149149

150150
/**
151151
* Paste clipboard content
152-
* @param ranges A list of {@link Range ranges}
153152
*/
154-
clipboardPaste(ranges?: Range[]): Promise<void>;
153+
clipboardPaste(): Promise<void>;
155154

156155
/**
157156
* Toggle breakpoints. For each of the descriptors in {@link descriptors},

packages/common/src/util/zipStrict.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export function zipStrict<T1, T2>(list1: T1[], list2: T2[]): [T1, T2][] {
2+
if (list1.length !== list2.length) {
3+
throw new Error("Lists must have the same length");
4+
}
5+
6+
return list1.map((value, index) => [value, list2[index]]);
7+
}

packages/cursorless-engine/src/actions/Actions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { BreakLine } from "./BreakLine";
66
import { Bring, Move, Swap } from "./BringMoveSwap";
77
import Call from "./Call";
88
import Clear from "./Clear";
9+
import { CopyToClipboard } from "./CopyToClipboard";
910
import { CutToClipboard } from "./CutToClipboard";
1011
import Deselect from "./Deselect";
1112
import { EditNew } from "./EditNew";
@@ -42,7 +43,6 @@ import { SetSpecialTarget } from "./SetSpecialTarget";
4243
import ShowParseTree from "./ShowParseTree";
4344
import { IndentLine, OutdentLine } from "./IndentLine";
4445
import {
45-
CopyToClipboard,
4646
ExtractVariable,
4747
Fold,
4848
Rename,
@@ -75,7 +75,7 @@ export class Actions implements ActionRecord {
7575

7676
callAsFunction = new Call(this);
7777
clearAndSetSelection = new Clear(this);
78-
copyToClipboard = new CopyToClipboard(this.rangeUpdater);
78+
copyToClipboard = new CopyToClipboard(this, this.rangeUpdater);
7979
cutToClipboard = new CutToClipboard(this);
8080
decrement = new Decrement(this);
8181
deselect = new Deselect();
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { FlashStyle } from "@cursorless/common";
2+
import type { RangeUpdater } from "../core/updateSelections/RangeUpdater";
3+
import { ide } from "../singletons/ide.singleton";
4+
import type { Target } from "../typings/target.types";
5+
import { flashTargets } from "../util/targetUtils";
6+
import type { Actions } from "./Actions";
7+
import { CopyToClipboardSimple } from "./SimpleIdeCommandActions";
8+
import type { ActionReturnValue, SimpleAction } from "./actions.types";
9+
10+
interface Options {
11+
showDecorations?: boolean;
12+
}
13+
14+
export class CopyToClipboard implements SimpleAction {
15+
constructor(
16+
private actions: Actions,
17+
private rangeUpdater: RangeUpdater,
18+
) {
19+
this.run = this.run.bind(this);
20+
}
21+
22+
async run(
23+
targets: Target[],
24+
options: Options = { showDecorations: true },
25+
): Promise<ActionReturnValue> {
26+
if (ide().capabilities.commands.clipboardCopy != null) {
27+
const simpleAction = new CopyToClipboardSimple(this.rangeUpdater);
28+
return simpleAction.run(targets, options);
29+
}
30+
31+
if (options.showDecorations) {
32+
await flashTargets(
33+
ide(),
34+
targets,
35+
FlashStyle.referenced,
36+
(target) => target.contentRange,
37+
);
38+
}
39+
40+
// FIXME: We should really keep track of the number of targets from the
41+
// original copy, as is done in VSCode.
42+
const text = targets.map((t) => t.contentText).join("\n");
43+
44+
await ide().clipboard.writeText(text);
45+
46+
return { thatTargets: targets };
47+
}
48+
}
Lines changed: 21 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,90 +1,28 @@
1-
import {
2-
FlashStyle,
3-
RangeExpansionBehavior,
4-
toCharacterRange,
5-
} from "@cursorless/common";
61
import { RangeUpdater } from "../core/updateSelections/RangeUpdater";
7-
import { performEditsAndUpdateSelections } from "../core/updateSelections/updateSelections";
82
import { ide } from "../singletons/ide.singleton";
9-
import { Destination } from "../typings/target.types";
10-
import { ensureSingleEditor } from "../util/targetUtils";
11-
import { Actions } from "./Actions";
12-
import { ActionReturnValue } from "./actions.types";
3+
import type { Destination } from "../typings/target.types";
4+
import type { Actions } from "./Actions";
5+
import type { ActionReturnValue } from "./actions.types";
6+
import { PasteFromClipboardUsingCommand } from "./PasteFromClipboardUsingCommand";
7+
import { PasteFromClipboardDirectly } from "./PasteFromClipboardDirectly";
8+
9+
export interface DestinationWithText {
10+
destination: Destination;
11+
text: string;
12+
}
1313

1414
export class PasteFromClipboard {
15-
constructor(
16-
private rangeUpdater: RangeUpdater,
17-
private actions: Actions,
18-
) {}
19-
20-
async run(destinations: Destination[]): Promise<ActionReturnValue> {
21-
const editor = ide().getEditableTextEditor(
22-
ensureSingleEditor(destinations),
23-
);
24-
const originalEditor = ide().activeEditableTextEditor;
25-
26-
// First call editNew in order to insert delimiters if necessary and leave
27-
// the cursor in the right position. Note that this action will focus the
28-
// editor containing the targets
29-
const callbackEdit = async () => {
30-
await this.actions.editNew.run(destinations);
31-
};
32-
33-
const { cursorSelections: originalCursorSelections } =
34-
await performEditsAndUpdateSelections({
35-
rangeUpdater: this.rangeUpdater,
36-
editor,
37-
preserveCursorSelections: true,
38-
callback: callbackEdit,
39-
selections: {
40-
cursorSelections: editor.selections,
41-
},
42-
});
43-
44-
// Then use VSCode paste command, using open ranges at the place where we
45-
// paste in order to capture the pasted text for highlights and `that` mark
46-
const {
47-
originalCursorSelections: updatedCursorSelections,
48-
editorSelections: updatedTargetSelections,
49-
} = await performEditsAndUpdateSelections({
50-
rangeUpdater: this.rangeUpdater,
51-
editor,
52-
callback: () => editor.clipboardPaste(),
53-
selections: {
54-
originalCursorSelections,
55-
editorSelections: {
56-
selections: editor.selections,
57-
behavior: RangeExpansionBehavior.openOpen,
58-
},
59-
},
60-
});
61-
62-
// Reset cursors on the editor where the edits took place.
63-
// NB: We don't focus the editor here because we want to focus the original
64-
// editor, not the one where the edits took place
65-
await editor.setSelections(updatedCursorSelections);
66-
67-
// If necessary focus back original editor
68-
if (originalEditor != null && !originalEditor.isActive) {
69-
// NB: We just do one editor focus at the end, instead of using
70-
// setSelectionsAndFocusEditor because the command might operate on
71-
// multiple editors, so we just do one focus at the end.
72-
await originalEditor.focus();
73-
}
74-
75-
await ide().flashRanges(
76-
updatedTargetSelections.map((selection) => ({
77-
editor,
78-
range: toCharacterRange(selection),
79-
style: FlashStyle.justAdded,
80-
})),
81-
);
15+
private runner: PasteFromClipboardDirectly | PasteFromClipboardUsingCommand;
16+
17+
constructor(rangeUpdater: RangeUpdater, actions: Actions) {
18+
this.run = this.run.bind(this);
19+
this.runner =
20+
ide().capabilities.commands.clipboardPaste != null
21+
? new PasteFromClipboardUsingCommand(rangeUpdater, actions)
22+
: new PasteFromClipboardDirectly(rangeUpdater);
23+
}
8224

83-
return {
84-
thatSelections: updatedTargetSelections.map((selection) => ({
85-
editor: editor,
86-
selection,
87-
})),
88-
};
25+
run(destinations: Destination[]): Promise<ActionReturnValue> {
26+
return this.runner.run(destinations);
8927
}
9028
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import {
2+
FlashStyle,
3+
RangeExpansionBehavior,
4+
toCharacterRange,
5+
zipStrict,
6+
type TextEditor,
7+
} from "@cursorless/common";
8+
import { flatten } from "lodash-es";
9+
import { RangeUpdater } from "../core/updateSelections/RangeUpdater";
10+
import { performEditsAndUpdateSelections } from "../core/updateSelections/updateSelections";
11+
import { ide } from "../singletons/ide.singleton";
12+
import type { Destination } from "../typings/target.types";
13+
import { runForEachEditor } from "../util/targetUtils";
14+
import type { ActionReturnValue } from "./actions.types";
15+
import { DestinationWithText } from "./PasteFromClipboard";
16+
17+
/**
18+
* This action pastes the text from the clipboard into the target editor directly
19+
* by reading the clipboard and inserting the text directly into the editor.
20+
*/
21+
export class PasteFromClipboardDirectly {
22+
constructor(private rangeUpdater: RangeUpdater) {
23+
this.runForEditor = this.runForEditor.bind(this);
24+
}
25+
26+
async run(destinations: Destination[]): Promise<ActionReturnValue> {
27+
const text = await ide().clipboard.readText();
28+
const textLines = text.split(/\r?\n/g);
29+
30+
// FIXME: We should really use the number of targets from the original copy
31+
// action, as is done in VSCode.
32+
const destinationsWithText: DestinationWithText[] =
33+
destinations.length === textLines.length
34+
? zipStrict(destinations, textLines).map(([destination, text]) => ({
35+
destination,
36+
text,
37+
}))
38+
: destinations.map((destination) => ({ destination, text }));
39+
40+
const thatSelections = flatten(
41+
await runForEachEditor(
42+
destinationsWithText,
43+
({ destination }) => destination.editor,
44+
this.runForEditor,
45+
),
46+
);
47+
48+
return { thatSelections };
49+
}
50+
51+
private async runForEditor(
52+
editor: TextEditor,
53+
destinationsWithText: DestinationWithText[],
54+
) {
55+
const edits = destinationsWithText.map(({ destination, text }) =>
56+
destination.constructChangeEdit(text),
57+
);
58+
59+
const { editSelections: updatedEditSelections } =
60+
await performEditsAndUpdateSelections({
61+
rangeUpdater: this.rangeUpdater,
62+
editor: ide().getEditableTextEditor(editor),
63+
edits,
64+
selections: {
65+
editSelections: {
66+
selections: edits.map(({ range }) => range),
67+
behavior: RangeExpansionBehavior.openOpen,
68+
},
69+
},
70+
});
71+
72+
const thatTargetSelections = zipStrict(edits, updatedEditSelections).map(
73+
([edit, selection]) =>
74+
edit.updateRange(selection).toSelection(selection.isReversed),
75+
);
76+
77+
await ide().flashRanges(
78+
thatTargetSelections.map((selection) => ({
79+
editor,
80+
range: toCharacterRange(selection),
81+
style: FlashStyle.justAdded,
82+
})),
83+
);
84+
85+
return thatTargetSelections.map((selection) => ({
86+
editor: editor,
87+
selection,
88+
}));
89+
}
90+
}

0 commit comments

Comments
 (0)