diff --git a/src/engine/MacroChoiceEngine.editorCommands.test.ts b/src/engine/MacroChoiceEngine.editorCommands.test.ts new file mode 100644 index 00000000..696dc526 --- /dev/null +++ b/src/engine/MacroChoiceEngine.editorCommands.test.ts @@ -0,0 +1,105 @@ +import type { App } from "obsidian"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../formatters/completeFormatter", () => ({ + CompleteFormatter: class CompleteFormatterMock {}, +})); + +vi.mock("obsidian-dataview", () => ({ + getAPI: vi.fn(), +})); + +vi.mock("../main", () => ({ + default: class QuickAddMock {}, +})); + +import { MacroChoiceEngine } from "./MacroChoiceEngine"; +import { EditorCommandType } from "../types/macros/EditorCommands/EditorCommandType"; +import { MoveCursorToFileStartCommand } from "../types/macros/EditorCommands/MoveCursorToFileStartCommand"; +import { MoveCursorToFileEndCommand } from "../types/macros/EditorCommands/MoveCursorToFileEndCommand"; +import { MoveCursorToLineStartCommand } from "../types/macros/EditorCommands/MoveCursorToLineStartCommand"; +import { MoveCursorToLineEndCommand } from "../types/macros/EditorCommands/MoveCursorToLineEndCommand"; + +const callExecuteEditorCommand = async (editorCommandType: EditorCommandType) => { + const executeEditorCommand = ( + MacroChoiceEngine.prototype as unknown as { + executeEditorCommand: ( + command: { editorCommandType: EditorCommandType }, + ) => Promise; + } + ).executeEditorCommand; + const app = {} as App; + + await executeEditorCommand.call({ app }, { editorCommandType }); + return app; +}; + +describe("MacroChoiceEngine editor command dispatch", () => { + let fileStartSpy: ReturnType; + let fileEndSpy: ReturnType; + let lineStartSpy: ReturnType; + let lineEndSpy: ReturnType; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + beforeEach(() => { + fileStartSpy = vi + .spyOn(MoveCursorToFileStartCommand, "run") + .mockImplementation(() => undefined); + fileEndSpy = vi + .spyOn(MoveCursorToFileEndCommand, "run") + .mockImplementation(() => undefined); + lineStartSpy = vi + .spyOn(MoveCursorToLineStartCommand, "run") + .mockImplementation(() => undefined); + lineEndSpy = vi + .spyOn(MoveCursorToLineEndCommand, "run") + .mockImplementation(() => undefined); + }); + + it("dispatches MoveCursorToFileStart", async () => { + const app = await callExecuteEditorCommand( + EditorCommandType.MoveCursorToFileStart + ); + + expect(fileStartSpy).toHaveBeenCalledWith(app); + expect(fileEndSpy).not.toHaveBeenCalled(); + expect(lineStartSpy).not.toHaveBeenCalled(); + expect(lineEndSpy).not.toHaveBeenCalled(); + }); + + it("dispatches MoveCursorToFileEnd", async () => { + const app = await callExecuteEditorCommand( + EditorCommandType.MoveCursorToFileEnd + ); + + expect(fileStartSpy).not.toHaveBeenCalled(); + expect(fileEndSpy).toHaveBeenCalledWith(app); + expect(lineStartSpy).not.toHaveBeenCalled(); + expect(lineEndSpy).not.toHaveBeenCalled(); + }); + + it("dispatches MoveCursorToLineStart", async () => { + const app = await callExecuteEditorCommand( + EditorCommandType.MoveCursorToLineStart + ); + + expect(fileStartSpy).not.toHaveBeenCalled(); + expect(fileEndSpy).not.toHaveBeenCalled(); + expect(lineStartSpy).toHaveBeenCalledWith(app); + expect(lineEndSpy).not.toHaveBeenCalled(); + }); + + it("dispatches MoveCursorToLineEnd", async () => { + const app = await callExecuteEditorCommand( + EditorCommandType.MoveCursorToLineEnd + ); + + expect(fileStartSpy).not.toHaveBeenCalled(); + expect(fileEndSpy).not.toHaveBeenCalled(); + expect(lineStartSpy).not.toHaveBeenCalled(); + expect(lineEndSpy).toHaveBeenCalledWith(app); + }); +}); diff --git a/src/engine/MacroChoiceEngine.ts b/src/engine/MacroChoiceEngine.ts index 1db9b077..6dc91a5b 100644 --- a/src/engine/MacroChoiceEngine.ts +++ b/src/engine/MacroChoiceEngine.ts @@ -26,6 +26,10 @@ import { PasteCommand } from "../types/macros/EditorCommands/PasteCommand"; import { PasteWithFormatCommand } from "../types/macros/EditorCommands/PasteWithFormatCommand"; import { SelectActiveLineCommand } from "../types/macros/EditorCommands/SelectActiveLineCommand"; import { SelectLinkOnActiveLineCommand } from "../types/macros/EditorCommands/SelectLinkOnActiveLineCommand"; +import { MoveCursorToFileStartCommand } from "../types/macros/EditorCommands/MoveCursorToFileStartCommand"; +import { MoveCursorToFileEndCommand } from "../types/macros/EditorCommands/MoveCursorToFileEndCommand"; +import { MoveCursorToLineStartCommand } from "../types/macros/EditorCommands/MoveCursorToLineStartCommand"; +import { MoveCursorToLineEndCommand } from "../types/macros/EditorCommands/MoveCursorToLineEndCommand"; import { waitFor } from "src/utility"; import type { IAIAssistantCommand } from "src/types/macros/QuickCommands/IAIAssistantCommand"; import { runAIAssistant } from "src/ai/AIAssistant"; @@ -448,6 +452,22 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine { case EditorCommandType.SelectLinkOnActiveLine: SelectLinkOnActiveLineCommand.run(this.app); break; + case EditorCommandType.MoveCursorToFileStart: + MoveCursorToFileStartCommand.run(this.app); + break; + case EditorCommandType.MoveCursorToFileEnd: + MoveCursorToFileEndCommand.run(this.app); + break; + case EditorCommandType.MoveCursorToLineStart: + MoveCursorToLineStartCommand.run(this.app); + break; + case EditorCommandType.MoveCursorToLineEnd: + MoveCursorToLineEndCommand.run(this.app); + break; + default: { + const exhaustiveCheck: never = command.editorCommandType; + throw new Error(`Unhandled editor command type: ${exhaustiveCheck}`); + } } } diff --git a/src/gui/MacroGUIs/CommandSequenceEditor.ts b/src/gui/MacroGUIs/CommandSequenceEditor.ts index 85b07e7d..6aeecf30 100644 --- a/src/gui/MacroGUIs/CommandSequenceEditor.ts +++ b/src/gui/MacroGUIs/CommandSequenceEditor.ts @@ -31,6 +31,10 @@ import { PasteCommand } from "../../types/macros/EditorCommands/PasteCommand"; import { PasteWithFormatCommand } from "../../types/macros/EditorCommands/PasteWithFormatCommand"; import { SelectActiveLineCommand } from "../../types/macros/EditorCommands/SelectActiveLineCommand"; import { SelectLinkOnActiveLineCommand } from "../../types/macros/EditorCommands/SelectLinkOnActiveLineCommand"; +import { MoveCursorToFileStartCommand } from "../../types/macros/EditorCommands/MoveCursorToFileStartCommand"; +import { MoveCursorToFileEndCommand } from "../../types/macros/EditorCommands/MoveCursorToFileEndCommand"; +import { MoveCursorToLineStartCommand } from "../../types/macros/EditorCommands/MoveCursorToLineStartCommand"; +import { MoveCursorToLineEndCommand } from "../../types/macros/EditorCommands/MoveCursorToLineEndCommand"; import { AIAssistantCommand } from "../../types/macros/QuickCommands/AIAssistantCommand"; import type { IconType } from "../../types/IconType"; import { settingsStore } from "../../settingsStore"; @@ -289,6 +293,18 @@ export class CommandSequenceEditor { case EditorCommandType.SelectLinkOnActiveLine: command = new SelectLinkOnActiveLineCommand(); break; + case EditorCommandType.MoveCursorToFileStart: + command = new MoveCursorToFileStartCommand(); + break; + case EditorCommandType.MoveCursorToFileEnd: + command = new MoveCursorToFileEndCommand(); + break; + case EditorCommandType.MoveCursorToLineStart: + command = new MoveCursorToLineStartCommand(); + break; + case EditorCommandType.MoveCursorToLineEnd: + command = new MoveCursorToLineEndCommand(); + break; default: log.logError("invalid editor command type"); throw new Error("invalid editor command type"); @@ -320,6 +336,22 @@ export class CommandSequenceEditor { .addOption( EditorCommandType.SelectLinkOnActiveLine, EditorCommandType.SelectLinkOnActiveLine + ) + .addOption( + EditorCommandType.MoveCursorToFileStart, + EditorCommandType.MoveCursorToFileStart + ) + .addOption( + EditorCommandType.MoveCursorToFileEnd, + EditorCommandType.MoveCursorToFileEnd + ) + .addOption( + EditorCommandType.MoveCursorToLineStart, + EditorCommandType.MoveCursorToLineStart + ) + .addOption( + EditorCommandType.MoveCursorToLineEnd, + EditorCommandType.MoveCursorToLineEnd ); }) .addButton((button) => diff --git a/src/types/macros/EditorCommands/EditorCommandType.ts b/src/types/macros/EditorCommands/EditorCommandType.ts index bad36581..ad280d6b 100644 --- a/src/types/macros/EditorCommands/EditorCommandType.ts +++ b/src/types/macros/EditorCommands/EditorCommandType.ts @@ -5,4 +5,8 @@ export enum EditorCommandType { PasteWithFormat = "Paste with format", SelectActiveLine = "Select active line", SelectLinkOnActiveLine = "Select link on active line", + MoveCursorToFileStart = "Move cursor to file start", + MoveCursorToFileEnd = "Move cursor to file end", + MoveCursorToLineStart = "Move cursor to line start", + MoveCursorToLineEnd = "Move cursor to line end", } diff --git a/src/types/macros/EditorCommands/MoveCursorToFileEndCommand.ts b/src/types/macros/EditorCommands/MoveCursorToFileEndCommand.ts new file mode 100644 index 00000000..e6a4e94e --- /dev/null +++ b/src/types/macros/EditorCommands/MoveCursorToFileEndCommand.ts @@ -0,0 +1,17 @@ +import type { App } from "obsidian"; +import { EditorCommand } from "./EditorCommand"; +import { EditorCommandType } from "./EditorCommandType"; + +export class MoveCursorToFileEndCommand extends EditorCommand { + constructor() { + super(EditorCommandType.MoveCursorToFileEnd); + } + + static run(app: App) { + const activeView = EditorCommand.getActiveMarkdownView(app); + const lastLine = activeView.editor.lastLine(); + const lineLength = activeView.editor.getLine(lastLine).length; + + activeView.editor.setCursor({ line: lastLine, ch: lineLength }); + } +} diff --git a/src/types/macros/EditorCommands/MoveCursorToFileStartCommand.ts b/src/types/macros/EditorCommands/MoveCursorToFileStartCommand.ts new file mode 100644 index 00000000..2b941b70 --- /dev/null +++ b/src/types/macros/EditorCommands/MoveCursorToFileStartCommand.ts @@ -0,0 +1,14 @@ +import type { App } from "obsidian"; +import { EditorCommand } from "./EditorCommand"; +import { EditorCommandType } from "./EditorCommandType"; + +export class MoveCursorToFileStartCommand extends EditorCommand { + constructor() { + super(EditorCommandType.MoveCursorToFileStart); + } + + static run(app: App) { + const activeView = EditorCommand.getActiveMarkdownView(app); + activeView.editor.setCursor({ line: 0, ch: 0 }); + } +} diff --git a/src/types/macros/EditorCommands/MoveCursorToLineEndCommand.ts b/src/types/macros/EditorCommands/MoveCursorToLineEndCommand.ts new file mode 100644 index 00000000..c136b08e --- /dev/null +++ b/src/types/macros/EditorCommands/MoveCursorToLineEndCommand.ts @@ -0,0 +1,17 @@ +import type { App } from "obsidian"; +import { EditorCommand } from "./EditorCommand"; +import { EditorCommandType } from "./EditorCommandType"; + +export class MoveCursorToLineEndCommand extends EditorCommand { + constructor() { + super(EditorCommandType.MoveCursorToLineEnd); + } + + static run(app: App) { + const activeView = EditorCommand.getActiveMarkdownView(app); + const { line: lineNumber } = activeView.editor.getCursor(); + const lineLength = activeView.editor.getLine(lineNumber).length; + + activeView.editor.setCursor({ line: lineNumber, ch: lineLength }); + } +} diff --git a/src/types/macros/EditorCommands/MoveCursorToLineStartCommand.ts b/src/types/macros/EditorCommands/MoveCursorToLineStartCommand.ts new file mode 100644 index 00000000..f237c71a --- /dev/null +++ b/src/types/macros/EditorCommands/MoveCursorToLineStartCommand.ts @@ -0,0 +1,16 @@ +import type { App } from "obsidian"; +import { EditorCommand } from "./EditorCommand"; +import { EditorCommandType } from "./EditorCommandType"; + +export class MoveCursorToLineStartCommand extends EditorCommand { + constructor() { + super(EditorCommandType.MoveCursorToLineStart); + } + + static run(app: App) { + const activeView = EditorCommand.getActiveMarkdownView(app); + const { line: lineNumber } = activeView.editor.getCursor(); + + activeView.editor.setCursor({ line: lineNumber, ch: 0 }); + } +} diff --git a/src/types/macros/EditorCommands/navigationCommands.test.ts b/src/types/macros/EditorCommands/navigationCommands.test.ts new file mode 100644 index 00000000..2cfea86e --- /dev/null +++ b/src/types/macros/EditorCommands/navigationCommands.test.ts @@ -0,0 +1,106 @@ +import type { App } from "obsidian"; +import { describe, expect, it, vi } from "vitest"; +import { MoveCursorToFileStartCommand } from "./MoveCursorToFileStartCommand"; +import { MoveCursorToFileEndCommand } from "./MoveCursorToFileEndCommand"; +import { MoveCursorToLineStartCommand } from "./MoveCursorToLineStartCommand"; +import { MoveCursorToLineEndCommand } from "./MoveCursorToLineEndCommand"; + +interface MockEditor { + getCursor: () => { line: number; ch: number }; + getLine: (line: number) => string; + lastLine: () => number; + setCursor: ReturnType; +} + +const createAppWithEditor = (editor: Partial): App => + ({ + workspace: { + getActiveViewOfType: vi.fn().mockReturnValue({ + editor: { + getCursor: () => ({ line: 0, ch: 0 }), + getLine: () => "", + lastLine: () => 0, + setCursor: vi.fn(), + ...editor, + }, + }), + }, + }) as unknown as App; + +const createAppWithoutMarkdownView = (): App => + ({ + workspace: { + getActiveViewOfType: vi.fn().mockReturnValue(null), + }, + }) as unknown as App; + +describe("navigation editor commands", () => { + it("moves cursor to file start", () => { + const setCursor = vi.fn(); + const app = createAppWithEditor({ setCursor }); + + MoveCursorToFileStartCommand.run(app); + + expect(setCursor).toHaveBeenCalledWith({ line: 0, ch: 0 }); + }); + + it("moves cursor to file end", () => { + const setCursor = vi.fn(); + const app = createAppWithEditor({ + lastLine: () => 2, + getLine: (line) => ["first", "second", "third line"][line] ?? "", + setCursor, + }); + + MoveCursorToFileEndCommand.run(app); + + expect(setCursor).toHaveBeenCalledWith({ line: 2, ch: 10 }); + }); + + it("moves cursor to line start", () => { + const setCursor = vi.fn(); + const app = createAppWithEditor({ + getCursor: () => ({ line: 4, ch: 12 }), + setCursor, + }); + + MoveCursorToLineStartCommand.run(app); + + expect(setCursor).toHaveBeenCalledWith({ line: 4, ch: 0 }); + }); + + it("moves cursor to line end", () => { + const setCursor = vi.fn(); + const app = createAppWithEditor({ + getCursor: () => ({ line: 7, ch: 2 }), + getLine: (line) => (line === 7 ? "line length" : ""), + setCursor, + }); + + MoveCursorToLineEndCommand.run(app); + + expect(setCursor).toHaveBeenCalledWith({ line: 7, ch: 11 }); + }); + + it.each([ + { + name: "MoveCursorToFileStartCommand", + run: MoveCursorToFileStartCommand.run, + }, + { + name: "MoveCursorToFileEndCommand", + run: MoveCursorToFileEndCommand.run, + }, + { + name: "MoveCursorToLineStartCommand", + run: MoveCursorToLineStartCommand.run, + }, + { + name: "MoveCursorToLineEndCommand", + run: MoveCursorToLineEndCommand.run, + }, + ])("throws when no active markdown view exists: $name", ({ run }) => { + const app = createAppWithoutMarkdownView(); + expect(() => run(app)).toThrow("no active markdown view."); + }); +});