From 94e9fd0f03b6e6dda1202136a4138b0e2737470c Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Tue, 30 Jul 2024 18:47:11 +0200 Subject: [PATCH] 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 <755842+pokey@users.noreply.github.com> --- .../common/src/ide/fake/FakeCapabilities.ts | 1 + packages/common/src/ide/types/Capabilities.ts | 14 ++- packages/common/src/index.ts | 1 + packages/common/src/types/TextEditor.ts | 3 +- packages/common/src/util/zipStrict.ts | 7 ++ .../cursorless-engine/src/actions/Actions.ts | 4 +- .../src/actions/CopyToClipboard.ts | 48 ++++++++ .../src/actions/PasteFromClipboard.ts | 104 ++++-------------- .../src/actions/PasteFromClipboardDirectly.ts | 90 +++++++++++++++ .../actions/PasteFromClipboardUsingCommand.ts | 96 ++++++++++++++++ .../src/actions/SimpleIdeCommandActions.ts | 2 +- .../src/ide/TalonJsCapabilities.ts | 4 +- .../src/ide/TalonJsEditor.ts | 13 +-- .../src/ide/vscode/VscodeCapabilities.ts | 1 + .../src/ide/vscode/VscodeTextEditorImpl.ts | 2 +- .../src/ide/neovim/NeovimCapabilities.ts | 1 + .../src/ide/neovim/NeovimTextEditorImpl.ts | 2 +- 17 files changed, 290 insertions(+), 103 deletions(-) create mode 100644 packages/common/src/util/zipStrict.ts create mode 100644 packages/cursorless-engine/src/actions/CopyToClipboard.ts create mode 100644 packages/cursorless-engine/src/actions/PasteFromClipboardDirectly.ts create mode 100644 packages/cursorless-engine/src/actions/PasteFromClipboardUsingCommand.ts diff --git a/packages/common/src/ide/fake/FakeCapabilities.ts b/packages/common/src/ide/fake/FakeCapabilities.ts index 81d6b178c6..d45d4f1ddc 100644 --- a/packages/common/src/ide/fake/FakeCapabilities.ts +++ b/packages/common/src/ide/fake/FakeCapabilities.ts @@ -2,6 +2,7 @@ import { Capabilities } from "../types/Capabilities"; export class FakeCapabilities implements Capabilities { commands = { + clipboardPaste: undefined, clipboardCopy: undefined, toggleLineComment: undefined, indentLine: undefined, diff --git a/packages/common/src/ide/types/Capabilities.ts b/packages/common/src/ide/types/Capabilities.ts index fc34a134d4..08226f7fee 100644 --- a/packages/common/src/ide/types/Capabilities.ts +++ b/packages/common/src/ide/types/Capabilities.ts @@ -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; } diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 4de7a90665..3c54d20a40 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -111,3 +111,4 @@ export * from "./util/toPlainObject"; export * from "./util/type"; export * from "./util/typeUtils"; export * from "./util/uniqWithHash"; +export * from "./util/zipStrict"; diff --git a/packages/common/src/types/TextEditor.ts b/packages/common/src/types/TextEditor.ts index 06fb36d362..9e7ed66521 100644 --- a/packages/common/src/types/TextEditor.ts +++ b/packages/common/src/types/TextEditor.ts @@ -149,9 +149,8 @@ export interface EditableTextEditor extends TextEditor { /** * Paste clipboard content - * @param ranges A list of {@link Range ranges} */ - clipboardPaste(ranges?: Range[]): Promise; + clipboardPaste(): Promise; /** * Toggle breakpoints. For each of the descriptors in {@link descriptors}, diff --git a/packages/common/src/util/zipStrict.ts b/packages/common/src/util/zipStrict.ts new file mode 100644 index 0000000000..b967c3d4ee --- /dev/null +++ b/packages/common/src/util/zipStrict.ts @@ -0,0 +1,7 @@ +export function zipStrict(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]]); +} diff --git a/packages/cursorless-engine/src/actions/Actions.ts b/packages/cursorless-engine/src/actions/Actions.ts index 72a2ecad49..a9bf4bb16f 100644 --- a/packages/cursorless-engine/src/actions/Actions.ts +++ b/packages/cursorless-engine/src/actions/Actions.ts @@ -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"; @@ -42,7 +43,6 @@ import { SetSpecialTarget } from "./SetSpecialTarget"; import ShowParseTree from "./ShowParseTree"; import { IndentLine, OutdentLine } from "./IndentLine"; import { - CopyToClipboard, ExtractVariable, Fold, Rename, @@ -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(); diff --git a/packages/cursorless-engine/src/actions/CopyToClipboard.ts b/packages/cursorless-engine/src/actions/CopyToClipboard.ts new file mode 100644 index 0000000000..9b5dee05e3 --- /dev/null +++ b/packages/cursorless-engine/src/actions/CopyToClipboard.ts @@ -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 { + 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 }; + } +} diff --git a/packages/cursorless-engine/src/actions/PasteFromClipboard.ts b/packages/cursorless-engine/src/actions/PasteFromClipboard.ts index 1b148a2f3e..ac0173599f 100644 --- a/packages/cursorless-engine/src/actions/PasteFromClipboard.ts +++ b/packages/cursorless-engine/src/actions/PasteFromClipboard.ts @@ -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 { - 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 { + return this.runner.run(destinations); } } diff --git a/packages/cursorless-engine/src/actions/PasteFromClipboardDirectly.ts b/packages/cursorless-engine/src/actions/PasteFromClipboardDirectly.ts new file mode 100644 index 0000000000..e389c3dbab --- /dev/null +++ b/packages/cursorless-engine/src/actions/PasteFromClipboardDirectly.ts @@ -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 { + 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, + })); + } +} diff --git a/packages/cursorless-engine/src/actions/PasteFromClipboardUsingCommand.ts b/packages/cursorless-engine/src/actions/PasteFromClipboardUsingCommand.ts new file mode 100644 index 0000000000..0ea1da1234 --- /dev/null +++ b/packages/cursorless-engine/src/actions/PasteFromClipboardUsingCommand.ts @@ -0,0 +1,96 @@ +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 type { Destination } from "../typings/target.types"; +import { ensureSingleEditor } from "../util/targetUtils"; +import type { Actions } from "./Actions"; +import type { ActionReturnValue } from "./actions.types"; + +/** + * This action pastes the text from the clipboard into the target editor using + * the IDE's built-in paste command. + */ +export class PasteFromClipboardUsingCommand { + constructor( + private rangeUpdater: RangeUpdater, + private actions: Actions, + ) { + this.run = this.run.bind(this); + } + + async run(destinations: Destination[]): Promise { + 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, + })), + ); + + return { + thatSelections: updatedTargetSelections.map((selection) => ({ + editor: editor, + selection, + })), + }; + } +} diff --git a/packages/cursorless-engine/src/actions/SimpleIdeCommandActions.ts b/packages/cursorless-engine/src/actions/SimpleIdeCommandActions.ts index 0922ee6132..8b6e6b6cf4 100644 --- a/packages/cursorless-engine/src/actions/SimpleIdeCommandActions.ts +++ b/packages/cursorless-engine/src/actions/SimpleIdeCommandActions.ts @@ -58,7 +58,7 @@ abstract class SimpleIdeCommandAction { } } -export class CopyToClipboard extends SimpleIdeCommandAction { +export class CopyToClipboardSimple extends SimpleIdeCommandAction { command: CommandId = "clipboardCopy"; ensureSingleEditor = true; } diff --git a/packages/cursorless-everywhere-talon-core/src/ide/TalonJsCapabilities.ts b/packages/cursorless-everywhere-talon-core/src/ide/TalonJsCapabilities.ts index 7cadf132b5..49db40e82d 100644 --- a/packages/cursorless-everywhere-talon-core/src/ide/TalonJsCapabilities.ts +++ b/packages/cursorless-everywhere-talon-core/src/ide/TalonJsCapabilities.ts @@ -1,8 +1,8 @@ import type { Capabilities, CommandCapabilityMap } from "@cursorless/common"; const COMMAND_CAPABILITIES: CommandCapabilityMap = { - clipboardCopy: { acceptsLocation: true }, - + clipboardCopy: undefined, + clipboardPaste: undefined, toggleLineComment: undefined, rename: undefined, quickFix: undefined, diff --git a/packages/cursorless-everywhere-talon-core/src/ide/TalonJsEditor.ts b/packages/cursorless-everywhere-talon-core/src/ide/TalonJsEditor.ts index f73ce0cfe6..04ce668f82 100644 --- a/packages/cursorless-everywhere-talon-core/src/ide/TalonJsEditor.ts +++ b/packages/cursorless-everywhere-talon-core/src/ide/TalonJsEditor.ts @@ -53,19 +53,12 @@ export class TalonJsEditor implements EditableTextEditor { return Promise.resolve(true); } - async clipboardCopy(ranges: Range[]): Promise { - const text = ranges.map((range) => this.document.getText(range)).join("\n"); - this.talon.actions.clip.set_text(text); + async clipboardCopy(_ranges: Range[]): Promise { + throw Error("clipboardCopy not implemented."); } async clipboardPaste(): Promise { - const text = this.talon.actions.clip.text(); - const edits = this.selections.map((range) => ({ - range, - text, - isReplace: true, - })); - talonJsPerformEdits(this.talon, this.ide, this.document, edits); + throw Error("clipboardPaste not implemented."); } indentLine(_ranges: Range[]): Promise { diff --git a/packages/cursorless-vscode/src/ide/vscode/VscodeCapabilities.ts b/packages/cursorless-vscode/src/ide/vscode/VscodeCapabilities.ts index ec9dcf31e6..4f46ce9f19 100644 --- a/packages/cursorless-vscode/src/ide/vscode/VscodeCapabilities.ts +++ b/packages/cursorless-vscode/src/ide/vscode/VscodeCapabilities.ts @@ -1,6 +1,7 @@ import { Capabilities, CommandCapabilityMap } from "@cursorless/common"; const COMMAND_CAPABILITIES: CommandCapabilityMap = { + clipboardPaste: true, clipboardCopy: { acceptsLocation: false }, toggleLineComment: { acceptsLocation: false }, indentLine: { acceptsLocation: false }, diff --git a/packages/cursorless-vscode/src/ide/vscode/VscodeTextEditorImpl.ts b/packages/cursorless-vscode/src/ide/vscode/VscodeTextEditorImpl.ts index 202b6fd6e3..4078130e92 100644 --- a/packages/cursorless-vscode/src/ide/vscode/VscodeTextEditorImpl.ts +++ b/packages/cursorless-vscode/src/ide/vscode/VscodeTextEditorImpl.ts @@ -175,7 +175,7 @@ export class VscodeTextEditorImpl implements EditableTextEditor { await vscode.commands.executeCommand("editor.action.clipboardCopyAction"); } - public async clipboardPaste(_ranges?: Range[]): Promise { + public async clipboardPaste(): Promise { // We add these sleeps here to workaround a bug in VSCode. See #1521 await sleep(100); await vscode.commands.executeCommand("editor.action.clipboardPasteAction"); diff --git a/packages/neovim-common/src/ide/neovim/NeovimCapabilities.ts b/packages/neovim-common/src/ide/neovim/NeovimCapabilities.ts index 5b9308b60f..1d5a21d298 100644 --- a/packages/neovim-common/src/ide/neovim/NeovimCapabilities.ts +++ b/packages/neovim-common/src/ide/neovim/NeovimCapabilities.ts @@ -2,6 +2,7 @@ import { Capabilities, CommandCapabilityMap } from "@cursorless/common"; const COMMAND_CAPABILITIES: CommandCapabilityMap = { clipboardCopy: { acceptsLocation: false }, + clipboardPaste: true, toggleLineComment: undefined, indentLine: undefined, outdentLine: undefined, diff --git a/packages/neovim-common/src/ide/neovim/NeovimTextEditorImpl.ts b/packages/neovim-common/src/ide/neovim/NeovimTextEditorImpl.ts index 41bf8a7bce..e0ca5848b3 100644 --- a/packages/neovim-common/src/ide/neovim/NeovimTextEditorImpl.ts +++ b/packages/neovim-common/src/ide/neovim/NeovimTextEditorImpl.ts @@ -143,7 +143,7 @@ export class NeovimTextEditorImpl implements EditableTextEditor { await neovimClipboardCopy(this.client, this.neovimIDE); } - public async clipboardPaste(_ranges?: Range[]): Promise { + public async clipboardPaste(): Promise { await neovimClipboardPaste(this.client, this.neovimIDE); }