Skip to content

Commit

Permalink
Edit based copy and paste (#2536)
Browse files Browse the repository at this point in the history
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]>
  • Loading branch information
AndreasArvidsson and pokey authored Jul 30, 2024
1 parent 44a36e6 commit 94e9fd0
Show file tree
Hide file tree
Showing 17 changed files with 290 additions and 103 deletions.
1 change: 1 addition & 0 deletions packages/common/src/ide/fake/FakeCapabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Capabilities } from "../types/Capabilities";

export class FakeCapabilities implements Capabilities {
commands = {
clipboardPaste: undefined,
clipboardCopy: undefined,
toggleLineComment: undefined,
indentLine: undefined,
Expand Down
14 changes: 13 additions & 1 deletion packages/common/src/ide/types/Capabilities.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
import { CommandId } from "./CommandId";

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

export type CommandCapabilityMap = Record<
type SimpleCommandCapabilityMap = Record<
CommandId,
CommandCapabilities | undefined
>;

export interface CommandCapabilityMap extends SimpleCommandCapabilityMap {
clipboardPaste: boolean | undefined;
}

export interface CommandCapabilities {
acceptsLocation: boolean;
}
1 change: 1 addition & 0 deletions packages/common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,4 @@ export * from "./util/toPlainObject";
export * from "./util/type";
export * from "./util/typeUtils";
export * from "./util/uniqWithHash";
export * from "./util/zipStrict";
3 changes: 1 addition & 2 deletions packages/common/src/types/TextEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,8 @@ export interface EditableTextEditor extends TextEditor {

/**
* Paste clipboard content
* @param ranges A list of {@link Range ranges}
*/
clipboardPaste(ranges?: Range[]): Promise<void>;
clipboardPaste(): Promise<void>;

/**
* Toggle breakpoints. For each of the descriptors in {@link descriptors},
Expand Down
7 changes: 7 additions & 0 deletions packages/common/src/util/zipStrict.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function zipStrict<T1, T2>(list1: T1[], list2: T2[]): [T1, T2][] {
if (list1.length !== list2.length) {
throw new Error("Lists must have the same length");
}

return list1.map((value, index) => [value, list2[index]]);
}
4 changes: 2 additions & 2 deletions packages/cursorless-engine/src/actions/Actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { BreakLine } from "./BreakLine";
import { Bring, Move, Swap } from "./BringMoveSwap";
import Call from "./Call";
import Clear from "./Clear";
import { CopyToClipboard } from "./CopyToClipboard";
import { CutToClipboard } from "./CutToClipboard";
import Deselect from "./Deselect";
import { EditNew } from "./EditNew";
Expand Down Expand Up @@ -42,7 +43,6 @@ import { SetSpecialTarget } from "./SetSpecialTarget";
import ShowParseTree from "./ShowParseTree";
import { IndentLine, OutdentLine } from "./IndentLine";
import {
CopyToClipboard,
ExtractVariable,
Fold,
Rename,
Expand Down Expand Up @@ -75,7 +75,7 @@ export class Actions implements ActionRecord {

callAsFunction = new Call(this);
clearAndSetSelection = new Clear(this);
copyToClipboard = new CopyToClipboard(this.rangeUpdater);
copyToClipboard = new CopyToClipboard(this, this.rangeUpdater);
cutToClipboard = new CutToClipboard(this);
decrement = new Decrement(this);
deselect = new Deselect();
Expand Down
48 changes: 48 additions & 0 deletions packages/cursorless-engine/src/actions/CopyToClipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { FlashStyle } from "@cursorless/common";
import type { RangeUpdater } from "../core/updateSelections/RangeUpdater";
import { ide } from "../singletons/ide.singleton";
import type { Target } from "../typings/target.types";
import { flashTargets } from "../util/targetUtils";
import type { Actions } from "./Actions";
import { CopyToClipboardSimple } from "./SimpleIdeCommandActions";
import type { ActionReturnValue, SimpleAction } from "./actions.types";

interface Options {
showDecorations?: boolean;
}

export class CopyToClipboard implements SimpleAction {
constructor(
private actions: Actions,
private rangeUpdater: RangeUpdater,
) {
this.run = this.run.bind(this);
}

async run(
targets: Target[],
options: Options = { showDecorations: true },
): Promise<ActionReturnValue> {
if (ide().capabilities.commands.clipboardCopy != null) {
const simpleAction = new CopyToClipboardSimple(this.rangeUpdater);
return simpleAction.run(targets, options);
}

if (options.showDecorations) {
await flashTargets(
ide(),
targets,
FlashStyle.referenced,
(target) => target.contentRange,
);
}

// FIXME: We should really keep track of the number of targets from the
// original copy, as is done in VSCode.
const text = targets.map((t) => t.contentText).join("\n");

await ide().clipboard.writeText(text);

return { thatTargets: targets };
}
}
104 changes: 21 additions & 83 deletions packages/cursorless-engine/src/actions/PasteFromClipboard.ts
Original file line number Diff line number Diff line change
@@ -1,90 +1,28 @@
import {
FlashStyle,
RangeExpansionBehavior,
toCharacterRange,
} from "@cursorless/common";
import { RangeUpdater } from "../core/updateSelections/RangeUpdater";
import { performEditsAndUpdateSelections } from "../core/updateSelections/updateSelections";
import { ide } from "../singletons/ide.singleton";
import { Destination } from "../typings/target.types";
import { ensureSingleEditor } from "../util/targetUtils";
import { Actions } from "./Actions";
import { ActionReturnValue } from "./actions.types";
import type { Destination } from "../typings/target.types";
import type { Actions } from "./Actions";
import type { ActionReturnValue } from "./actions.types";
import { PasteFromClipboardUsingCommand } from "./PasteFromClipboardUsingCommand";
import { PasteFromClipboardDirectly } from "./PasteFromClipboardDirectly";

export interface DestinationWithText {
destination: Destination;
text: string;
}

export class PasteFromClipboard {
constructor(
private rangeUpdater: RangeUpdater,
private actions: Actions,
) {}

async run(destinations: Destination[]): Promise<ActionReturnValue> {
const editor = ide().getEditableTextEditor(
ensureSingleEditor(destinations),
);
const originalEditor = ide().activeEditableTextEditor;

// First call editNew in order to insert delimiters if necessary and leave
// the cursor in the right position. Note that this action will focus the
// editor containing the targets
const callbackEdit = async () => {
await this.actions.editNew.run(destinations);
};

const { cursorSelections: originalCursorSelections } =
await performEditsAndUpdateSelections({
rangeUpdater: this.rangeUpdater,
editor,
preserveCursorSelections: true,
callback: callbackEdit,
selections: {
cursorSelections: editor.selections,
},
});

// Then use VSCode paste command, using open ranges at the place where we
// paste in order to capture the pasted text for highlights and `that` mark
const {
originalCursorSelections: updatedCursorSelections,
editorSelections: updatedTargetSelections,
} = await performEditsAndUpdateSelections({
rangeUpdater: this.rangeUpdater,
editor,
callback: () => editor.clipboardPaste(),
selections: {
originalCursorSelections,
editorSelections: {
selections: editor.selections,
behavior: RangeExpansionBehavior.openOpen,
},
},
});

// Reset cursors on the editor where the edits took place.
// NB: We don't focus the editor here because we want to focus the original
// editor, not the one where the edits took place
await editor.setSelections(updatedCursorSelections);

// If necessary focus back original editor
if (originalEditor != null && !originalEditor.isActive) {
// NB: We just do one editor focus at the end, instead of using
// setSelectionsAndFocusEditor because the command might operate on
// multiple editors, so we just do one focus at the end.
await originalEditor.focus();
}

await ide().flashRanges(
updatedTargetSelections.map((selection) => ({
editor,
range: toCharacterRange(selection),
style: FlashStyle.justAdded,
})),
);
private runner: PasteFromClipboardDirectly | PasteFromClipboardUsingCommand;

constructor(rangeUpdater: RangeUpdater, actions: Actions) {
this.run = this.run.bind(this);
this.runner =
ide().capabilities.commands.clipboardPaste != null
? new PasteFromClipboardUsingCommand(rangeUpdater, actions)
: new PasteFromClipboardDirectly(rangeUpdater);
}

return {
thatSelections: updatedTargetSelections.map((selection) => ({
editor: editor,
selection,
})),
};
run(destinations: Destination[]): Promise<ActionReturnValue> {
return this.runner.run(destinations);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import {
FlashStyle,
RangeExpansionBehavior,
toCharacterRange,
zipStrict,
type TextEditor,
} from "@cursorless/common";
import { flatten } from "lodash-es";
import { RangeUpdater } from "../core/updateSelections/RangeUpdater";
import { performEditsAndUpdateSelections } from "../core/updateSelections/updateSelections";
import { ide } from "../singletons/ide.singleton";
import type { Destination } from "../typings/target.types";
import { runForEachEditor } from "../util/targetUtils";
import type { ActionReturnValue } from "./actions.types";
import { DestinationWithText } from "./PasteFromClipboard";

/**
* This action pastes the text from the clipboard into the target editor directly
* by reading the clipboard and inserting the text directly into the editor.
*/
export class PasteFromClipboardDirectly {
constructor(private rangeUpdater: RangeUpdater) {
this.runForEditor = this.runForEditor.bind(this);
}

async run(destinations: Destination[]): Promise<ActionReturnValue> {
const text = await ide().clipboard.readText();
const textLines = text.split(/\r?\n/g);

// FIXME: We should really use the number of targets from the original copy
// action, as is done in VSCode.
const destinationsWithText: DestinationWithText[] =
destinations.length === textLines.length
? zipStrict(destinations, textLines).map(([destination, text]) => ({
destination,
text,
}))
: destinations.map((destination) => ({ destination, text }));

const thatSelections = flatten(
await runForEachEditor(
destinationsWithText,
({ destination }) => destination.editor,
this.runForEditor,
),
);

return { thatSelections };
}

private async runForEditor(
editor: TextEditor,
destinationsWithText: DestinationWithText[],
) {
const edits = destinationsWithText.map(({ destination, text }) =>
destination.constructChangeEdit(text),
);

const { editSelections: updatedEditSelections } =
await performEditsAndUpdateSelections({
rangeUpdater: this.rangeUpdater,
editor: ide().getEditableTextEditor(editor),
edits,
selections: {
editSelections: {
selections: edits.map(({ range }) => range),
behavior: RangeExpansionBehavior.openOpen,
},
},
});

const thatTargetSelections = zipStrict(edits, updatedEditSelections).map(
([edit, selection]) =>
edit.updateRange(selection).toSelection(selection.isReversed),
);

await ide().flashRanges(
thatTargetSelections.map((selection) => ({
editor,
range: toCharacterRange(selection),
style: FlashStyle.justAdded,
})),
);

return thatTargetSelections.map((selection) => ({
editor: editor,
selection,
}));
}
}
Loading

0 comments on commit 94e9fd0

Please sign in to comment.