diff --git a/src/actions/EditNewLine.ts b/src/actions/EditNewLine.ts index c1f53d23e4..de025ec46c 100644 --- a/src/actions/EditNewLine.ts +++ b/src/actions/EditNewLine.ts @@ -1,66 +1,139 @@ -import { commands, Range, TextEditor } from "vscode"; +import { + commands as vscommands, + Position, + Range, + Selection, + TextEditor, +} from "vscode"; import { Target } from "../typings/target.types"; import { Graph } from "../typings/Types"; -import { getNotebookFromCellDocument } from "../util/notebook"; +import { createThatMark, ensureSingleEditor } from "../util/targetUtils"; import { Action, ActionReturnValue } from "./actions.types"; class EditNewLine implements Action { - constructor(private graph: Graph, private isAbove: boolean) { + constructor(private graph: Graph, private isBefore: boolean) { this.run = this.run.bind(this); } - private correctForParagraph(targets: Target[]) { - targets.forEach((target) => { - let { start, end } = target.contentRange; - if (target.scopeType === "paragraph") { - if (this.isAbove && target.leadingDelimiter != null) { - start = start.translate({ lineDelta: -1 }); - } else if (!this.isAbove && target.trailingDelimiter != null) { - end = end.translate({ lineDelta: 1 }); - } - target.contentRange = new Range(start, end); - } - }); - } + async run([targets]: [Target[]]): Promise { + const editor = ensureSingleEditor(targets); - private isNotebookEditor(editor: TextEditor) { - return getNotebookFromCellDocument(editor.document) != null; - } + const targetsWithContext = targets.map((target) => ({ + target, + context: target.getEditNewLineContext(this.isBefore), + })); + const commandTargets = targetsWithContext.filter( + ({ context }) => !!(context).command + ); + const delimiterTargets = targetsWithContext.filter( + ({ context }) => !!(context).delimiter + ); - private getCommand(target: Target) { - if (target.scopeType === "notebookCell") { - if (this.isNotebookEditor(target.editor)) { - return this.isAbove - ? "notebook.cell.insertCodeCellAbove" - : "notebook.cell.insertCodeCellBelow"; - } - return this.isAbove - ? "jupyter.insertCellAbove" - : "jupyter.insertCellBelow"; + if (commandTargets.length > 0 && delimiterTargets.length > 0) { + throw new Error("Can't insert edit using command and delimiter at once"); + } + + if (commandTargets.length > 0) { + const commands = commandTargets.map( + ({ context }) => (context).command + ); + return { + thatMark: await this.runCommand(targets, commands), + }; } - return this.isAbove - ? "editor.action.insertLineBefore" - : "editor.action.insertLineAfter"; + + return { + thatMark: await this.runDelimiter(targets, editor), + }; } - async run([targets]: [Target[]]): Promise { - this.correctForParagraph(targets); + async runDelimiter(targets: Target[], editor: TextEditor) { + const edits = targets.map((target) => { + const { contentRange } = target; + const context = target.getEditNewLineContext(this.isBefore); + const delimiter = (context).delimiter as string; - if (this.isAbove) { + // Delimiter is one or more new lines. Handle as lines. + if (delimiter.includes("\n")) { + const lineNumber = this.isBefore + ? contentRange.start.line + : contentRange.end.line; + const line = editor.document.lineAt(lineNumber); + const characterIndex = line.isEmptyOrWhitespace + ? contentRange.start.character + : line.firstNonWhitespaceCharacterIndex; + const padding = line.text.slice(0, characterIndex); + const positionSelection = new Position( + this.isBefore ? lineNumber : lineNumber + delimiter.length, + characterIndex + ); + return { + contentRange, + text: this.isBefore ? padding + delimiter : delimiter + padding, + insertPosition: this.isBefore ? line.range.start : line.range.end, + selection: new Selection(positionSelection, positionSelection), + thatMarkRange: this.isBefore + ? new Range( + contentRange.start.translate({ + lineDelta: delimiter.length, + }), + contentRange.end.translate({ + lineDelta: delimiter.length, + }) + ) + : contentRange, + }; + } + // Delimiter is something else. Handle as inline. + else { + const positionSelection = this.isBefore + ? contentRange.start + : contentRange.end.translate({ + characterDelta: delimiter.length, + }); + return { + contentRange, + text: delimiter, + insertPosition: this.isBefore ? contentRange.start : contentRange.end, + selection: new Selection(positionSelection, positionSelection), + thatMarkRange: this.isBefore + ? new Range( + contentRange.start.translate({ + characterDelta: delimiter.length, + }), + contentRange.end.translate({ + characterDelta: delimiter.length, + }) + ) + : contentRange, + }; + } + }); + + await editor.edit((editBuilder) => { + edits.forEach((edit) => { + editBuilder.replace(edit.insertPosition, edit.text); + }); + }); + + editor.selections = edits.map((edit) => edit.selection); + + const thatMarkRanges = edits.map((edit) => edit.thatMarkRange); + + return createThatMark(targets, thatMarkRanges); + } + + async runCommand(targets: Target[], commands: string[]) { + if (new Set(commands).size > 1) { + throw new Error("Can't run multiple different commands at once"); + } + if (this.isBefore) { await this.graph.actions.setSelectionBefore.run([targets]); } else { await this.graph.actions.setSelectionAfter.run([targets]); } - - const command = this.getCommand(targets[0]); - await commands.executeCommand(command); - - return { - thatMark: targets.map((target) => ({ - selection: target.editor.selection, - editor: target.editor, - })), - }; + await vscommands.executeCommand(commands[0]); + return createThatMark(targets); } } diff --git a/src/processTargets/modifiers/scopeTypeStages/ContainingSyntaxScopeStage.ts b/src/processTargets/modifiers/scopeTypeStages/ContainingSyntaxScopeStage.ts index 67e5c8d621..7453fc1fbf 100644 --- a/src/processTargets/modifiers/scopeTypeStages/ContainingSyntaxScopeStage.ts +++ b/src/processTargets/modifiers/scopeTypeStages/ContainingSyntaxScopeStage.ts @@ -46,7 +46,6 @@ export default class implements ModifierStage { return scopeNodes.map( (scope) => new ScopeTypeTarget({ - delimiter: "\n", ...selectionWithEditorWithContextToTarget(scope), scopeType: this.modifier.scopeType, isReversed: target.isReversed, diff --git a/src/processTargets/targets/BaseTarget.ts b/src/processTargets/targets/BaseTarget.ts index d2b93c5492..501e527c6f 100644 --- a/src/processTargets/targets/BaseTarget.ts +++ b/src/processTargets/targets/BaseTarget.ts @@ -6,15 +6,16 @@ import { Position, RemovalRange, TargetParameters, + EditNewLineContext, } from "../../typings/target.types"; export default class BaseTarget implements Target { editor: TextEditor; isReversed: boolean; + contentRange: Range; + delimiter: string; scopeType?: ScopeType; position?: Position; - delimiter?: string; - contentRange: Range; removalRange?: Range; interiorRange?: Range; boundary?: [Range, Range]; @@ -25,10 +26,10 @@ export default class BaseTarget implements Target { constructor(parameters: TargetParameters) { this.editor = parameters.editor; this.isReversed = parameters.isReversed; + this.contentRange = parameters.contentRange; + this.delimiter = parameters.delimiter ?? " "; this.scopeType = parameters.scopeType; this.position = parameters.position; - this.delimiter = parameters.delimiter; - this.contentRange = parameters.contentRange; this.removalRange = parameters.removalRange; this.interiorRange = parameters.interiorRange; this.boundary = parameters.boundary; @@ -48,13 +49,11 @@ export default class BaseTarget implements Target { /** Possibly add delimiter for positions before/after */ maybeAddDelimiter(text: string): string { - if (this.delimiter != null) { - if (this.position === "before") { - return text + this.delimiter; - } - if (this.position === "after") { - return this.delimiter + text; - } + if (this.position === "before") { + return text + this.delimiter; + } + if (this.position === "after") { + return this.delimiter + text; } return text; } @@ -122,4 +121,10 @@ export default class BaseTarget implements Target { } return this.getRemovalContentHighlightRange(); } + + getEditNewLineContext(_isBefore: boolean): EditNewLineContext { + return { + delimiter: "\n", + }; + } } diff --git a/src/processTargets/targets/DocumentTarget.ts b/src/processTargets/targets/DocumentTarget.ts index 1faf379074..90f2eb07f4 100644 --- a/src/processTargets/targets/DocumentTarget.ts +++ b/src/processTargets/targets/DocumentTarget.ts @@ -1,4 +1,5 @@ import { Range, TextEditor } from "vscode"; +import { EditNewLineContext, ScopeType } from "../../typings/target.types"; import BaseTarget from "./BaseTarget"; interface DocumentTargetParameters { @@ -8,6 +9,10 @@ interface DocumentTargetParameters { } export default class DocumentTarget extends BaseTarget { + scopeType: ScopeType; + delimiter: string; + isLine: boolean; + constructor(parameters: DocumentTargetParameters) { super(parameters); this.scopeType = "document"; diff --git a/src/processTargets/targets/LineTarget.ts b/src/processTargets/targets/LineTarget.ts index 5bca714565..9e1d244a7f 100644 --- a/src/processTargets/targets/LineTarget.ts +++ b/src/processTargets/targets/LineTarget.ts @@ -1,5 +1,5 @@ import { Range, TextEditor } from "vscode"; -import { RemovalRange } from "../../typings/target.types"; +import { RemovalRange, ScopeType } from "../../typings/target.types"; import { parseRemovalRange } from "../../util/targetUtils"; import BaseTarget from "./BaseTarget"; @@ -12,6 +12,10 @@ interface LineTargetParameters { } export default class LineTarget extends BaseTarget { + scopeType: ScopeType; + delimiter: string; + isLine: boolean; + constructor(parameters: LineTargetParameters) { super(parameters); this.scopeType = "line"; diff --git a/src/processTargets/targets/NotebookCellTarget.ts b/src/processTargets/targets/NotebookCellTarget.ts index bb2820cb64..02ca05cd1e 100644 --- a/src/processTargets/targets/NotebookCellTarget.ts +++ b/src/processTargets/targets/NotebookCellTarget.ts @@ -1,4 +1,6 @@ import { Range, TextEditor } from "vscode"; +import { EditNewLineContext } from "../../typings/target.types"; +import { getNotebookFromCellDocument } from "../../util/notebook"; import BaseTarget from "./BaseTarget"; interface NotebookCellTargetParameters { @@ -13,4 +15,21 @@ export default class NotebookCellTarget extends BaseTarget { this.scopeType = "notebookCell"; this.delimiter = "\n"; } + + getEditNewLineContext(isBefore: boolean): EditNewLineContext { + if (this.isNotebookEditor(this.editor)) { + return { + command: isBefore + ? "notebook.cell.insertCodeCellAbove" + : "notebook.cell.insertCodeCellBelow", + }; + } + return { + command: isBefore ? "jupyter.insertCellAbove" : "jupyter.insertCellBelow", + }; + } + + private isNotebookEditor(editor: TextEditor) { + return getNotebookFromCellDocument(editor.document) != null; + } } diff --git a/src/processTargets/targets/ParagraphTarget.ts b/src/processTargets/targets/ParagraphTarget.ts index a2056204bf..ce30d6455b 100644 --- a/src/processTargets/targets/ParagraphTarget.ts +++ b/src/processTargets/targets/ParagraphTarget.ts @@ -1,5 +1,9 @@ import { Range, TextEditor } from "vscode"; -import { RemovalRange } from "../../typings/target.types"; +import { + EditNewLineContext, + RemovalRange, + ScopeType, +} from "../../typings/target.types"; import { parseRemovalRange } from "../../util/targetUtils"; import BaseTarget from "./BaseTarget"; @@ -12,6 +16,10 @@ interface ParagraphTargetParameters { } export default class ParagraphTarget extends BaseTarget { + scopeType: ScopeType; + delimiter: string; + isLine: boolean; + constructor(parameters: ParagraphTargetParameters) { super(parameters); this.scopeType = "paragraph"; @@ -67,4 +75,10 @@ export default class ParagraphTarget extends BaseTarget { ? removalRange.union(delimiterRange) : removalRange; } + + getEditNewLineContext(_isBefore: boolean): EditNewLineContext { + return { + delimiter: this.delimiter, + }; + } } diff --git a/src/processTargets/targets/ScopeTypeTarget.ts b/src/processTargets/targets/ScopeTypeTarget.ts index a2ca12d127..a5ce853637 100644 --- a/src/processTargets/targets/ScopeTypeTarget.ts +++ b/src/processTargets/targets/ScopeTypeTarget.ts @@ -1,9 +1,12 @@ -import { ScopeType, TargetParameters } from "../../typings/target.types"; +import { + EditNewLineContext, + ScopeType, + TargetParameters, +} from "../../typings/target.types"; import BaseTarget from "./BaseTarget"; export interface ScopeTypeTargetParameters extends TargetParameters { scopeType: ScopeType; - delimiter: string; } export default class ScopeTypeTarget extends BaseTarget { @@ -13,6 +16,31 @@ export default class ScopeTypeTarget extends BaseTarget { constructor(parameters: ScopeTypeTargetParameters) { super(parameters); this.scopeType = parameters.scopeType; - this.delimiter = parameters.delimiter; + this.delimiter = + parameters.delimiter ?? this.getDelimiter(parameters.scopeType); + } + + private getDelimiter(scopeType: ScopeType): string { + switch (scopeType) { + case "namedFunction": + case "anonymousFunction": + case "statement": + case "ifStatement": + return "\n"; + case "class": + return "\n\n"; + default: + return " "; + } + } + + getEditNewLineContext(isBefore: boolean): EditNewLineContext { + // This is the default and should implement the default version whatever that is. + if (this.delimiter === "\n") { + return super.getEditNewLineContext(isBefore); + } + return { + delimiter: this.delimiter, + }; } } diff --git a/src/test/suite/fixtures/recorded/actions/drinkArg.yml b/src/test/suite/fixtures/recorded/actions/drinkArg.yml new file mode 100644 index 0000000000..a8d72e49a1 --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/drinkArg.yml @@ -0,0 +1,27 @@ +languageId: typescript +command: + spokenForm: drink arg + version: 2 + action: editNewLineBefore + targets: + - type: primitive + modifiers: + - {type: containingScope, scopeType: argumentOrParameter} + usePrePhraseSnapshot: true +initialState: + documentContents: | + function helloWorld(foo: string, bar: number, baz: string) {} + selections: + - anchor: {line: 0, character: 40} + active: {line: 0, character: 40} + marks: {} +finalState: + documentContents: | + function helloWorld(foo: string, , bar: number, baz: string) {} + selections: + - anchor: {line: 0, character: 33} + active: {line: 0, character: 33} + thatMark: + - anchor: {line: 0, character: 35} + active: {line: 0, character: 46} +fullTargets: [{type: primitive, mark: {type: cursor}, modifiers: [{type: containingScope, scopeType: argumentOrParameter}]}] diff --git a/src/test/suite/fixtures/recorded/actions/drinkBlock.yml b/src/test/suite/fixtures/recorded/actions/drinkBlock.yml new file mode 100644 index 0000000000..bb24a95e80 --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/drinkBlock.yml @@ -0,0 +1,29 @@ +languageId: typescript +command: + spokenForm: drink block + version: 2 + action: editNewLineBefore + targets: + - type: primitive + modifiers: + - {type: containingScope, scopeType: paragraph} + usePrePhraseSnapshot: true +initialState: + documentContents: | + function helloWorld(foo: string, bar: number, baz: string) {} + selections: + - anchor: {line: 0, character: 40} + active: {line: 0, character: 40} + marks: {} +finalState: + documentContents: | + + + function helloWorld(foo: string, bar: number, baz: string) {} + selections: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 0} + thatMark: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 61} +fullTargets: [{type: primitive, mark: {type: cursor}, modifiers: [{type: containingScope, scopeType: paragraph}]}] diff --git a/src/test/suite/fixtures/recorded/actions/pourArg.yml b/src/test/suite/fixtures/recorded/actions/pourArg.yml new file mode 100644 index 0000000000..bd0bfa6792 --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/pourArg.yml @@ -0,0 +1,27 @@ +languageId: typescript +command: + spokenForm: pour arg + version: 2 + action: editNewLineAfter + targets: + - type: primitive + modifiers: + - {type: containingScope, scopeType: argumentOrParameter} + usePrePhraseSnapshot: true +initialState: + documentContents: | + function helloWorld(foo: string, bar: number, baz: string) {} + selections: + - anchor: {line: 0, character: 40} + active: {line: 0, character: 40} + marks: {} +finalState: + documentContents: | + function helloWorld(foo: string, bar: number, , baz: string) {} + selections: + - anchor: {line: 0, character: 46} + active: {line: 0, character: 46} + thatMark: + - anchor: {line: 0, character: 33} + active: {line: 0, character: 44} +fullTargets: [{type: primitive, mark: {type: cursor}, modifiers: [{type: containingScope, scopeType: argumentOrParameter}]}] diff --git a/src/test/suite/fixtures/recorded/actions/pourBlock.yml b/src/test/suite/fixtures/recorded/actions/pourBlock.yml new file mode 100644 index 0000000000..05f2dc3c33 --- /dev/null +++ b/src/test/suite/fixtures/recorded/actions/pourBlock.yml @@ -0,0 +1,29 @@ +languageId: typescript +command: + spokenForm: pour block + version: 2 + action: editNewLineAfter + targets: + - type: primitive + modifiers: + - {type: containingScope, scopeType: paragraph} + usePrePhraseSnapshot: true +initialState: + documentContents: | + function helloWorld(foo: string, bar: number, baz: string) {} + selections: + - anchor: {line: 0, character: 40} + active: {line: 0, character: 40} + marks: {} +finalState: + documentContents: |+ + function helloWorld(foo: string, bar: number, baz: string) {} + + + selections: + - anchor: {line: 2, character: 0} + active: {line: 2, character: 0} + thatMark: + - anchor: {line: 0, character: 0} + active: {line: 0, character: 61} +fullTargets: [{type: primitive, mark: {type: cursor}, modifiers: [{type: containingScope, scopeType: paragraph}]}] diff --git a/src/typings/target.types.ts b/src/typings/target.types.ts index e1a7e2a9a2..ae1bb4f283 100644 --- a/src/typings/target.types.ts +++ b/src/typings/target.types.ts @@ -245,6 +245,8 @@ export interface RemovalRange { exclude?: boolean; } +export type EditNewLineContext = { command: string } | { delimiter: string }; + export interface TargetParameters { /** The text editor used for all ranges */ editor: TextEditor; @@ -299,4 +301,5 @@ export interface Target extends TargetParameters { maybeAddDelimiter(text: string): string; getRemovalRange(): Range; getRemovalHighlightRange(): Range | undefined; + getEditNewLineContext(isBefore: boolean): EditNewLineContext; } diff --git a/src/util/targetUtils.ts b/src/util/targetUtils.ts index fb47a1403a..71aa9ec4b7 100644 --- a/src/util/targetUtils.ts +++ b/src/util/targetUtils.ts @@ -1,11 +1,7 @@ import { zip } from "lodash"; import { Range, Selection, TextEditor } from "vscode"; import { getTokenDelimiters } from "../processTargets/modifiers/scopeTypeStages/TokenStage"; -import { - RemovalRange, - Target, - TargetParameters, -} from "../typings/target.types"; +import { RemovalRange, Target } from "../typings/target.types"; import { SelectionContext, SelectionWithEditor, @@ -131,7 +127,7 @@ export function parseRemovalRange( export function selectionWithEditorWithContextToTarget( selection: SelectionWithEditorWithContext -): TargetParameters { +) { // TODO Only use giving context in the future when all the containing scopes have proper delimiters. // For now fall back on token context const { context } = selection; @@ -170,7 +166,7 @@ export function selectionWithEditorWithContextToTarget( interiorRange, removalRange, boundary, - delimiter: tokenContext?.delimiter ?? containingListDelimiter ?? "\n", + delimiter: containingListDelimiter, leadingDelimiter, trailingDelimiter, };