From 827dea58a1b0c5a15da4d0c126d6afa72323227f Mon Sep 17 00:00:00 2001 From: Aly Thobani Date: Mon, 11 May 2026 17:08:45 -0700 Subject: [PATCH 01/10] feat(motions): add code fence jump motions --- main.ts | 3 + motions/jumpToCodeFence.ts | 31 ++++++++ tests/createFakeCodeMirrorEditor.ts | 39 ++++++++++ tests/jumpToCodeFence.test.ts | 117 ++++++++++++++++++++++++++++ 4 files changed, 190 insertions(+) create mode 100644 motions/jumpToCodeFence.ts create mode 100644 tests/createFakeCodeMirrorEditor.ts create mode 100644 tests/jumpToCodeFence.test.ts diff --git a/main.ts b/main.ts index 7911085..a0b40d2 100644 --- a/main.ts +++ b/main.ts @@ -3,6 +3,7 @@ import { App, EditorSelection, MarkdownView, Notice, Editor as ObsidianEditor, P import { followLinkUnderCursor } from './actions/followLinkUnderCursor'; import { moveDownSkippingFolds, moveUpSkippingFolds } from './actions/moveSkippingFolds'; +import { jumpToNextCodeFence, jumpToPreviousCodeFence } from './motions/jumpToCodeFence'; import { jumpToNextHeading, jumpToPreviousHeading } from './motions/jumpToHeading'; import { jumpToNextLink, jumpToPreviousLink } from './motions/jumpToLink'; import { defineAndMapObsidianVimAction, defineAndMapObsidianVimMotion } from './utils/obsidianVimCommand'; @@ -429,6 +430,8 @@ export default class VimrcPlugin extends Plugin { defineAndMapObsidianVimCommands(vimObject: VimApi) { defineAndMapObsidianVimMotion(vimObject, jumpToNextHeading, ']]'); defineAndMapObsidianVimMotion(vimObject, jumpToPreviousHeading, '[['); + defineAndMapObsidianVimMotion(vimObject, jumpToNextCodeFence, 'gc'); + defineAndMapObsidianVimMotion(vimObject, jumpToPreviousCodeFence, 'gC'); defineAndMapObsidianVimMotion(vimObject, jumpToNextLink, 'gl'); defineAndMapObsidianVimMotion(vimObject, jumpToPreviousLink, 'gL'); diff --git a/motions/jumpToCodeFence.ts b/motions/jumpToCodeFence.ts new file mode 100644 index 0000000..4a23123 --- /dev/null +++ b/motions/jumpToCodeFence.ts @@ -0,0 +1,31 @@ +import { jumpToPattern } from "../utils/jumpToPattern"; +import { MotionFn } from "../utils/vimApi"; + +/** Regex for a Markdown code fence line, which begins with at least three backticks. */ +export const CODE_FENCE_REGEX = /^```+/gm; + +/** + * Jumps to the repeat-th next code fence. + */ +export const jumpToNextCodeFence: MotionFn = (cm, cursorPosition, { repeat }) => { + return jumpToPattern({ + cm, + cursorPosition, + repeat, + regex: CODE_FENCE_REGEX, + direction: "next", + }); +}; + +/** + * Jumps to the repeat-th previous code fence. + */ +export const jumpToPreviousCodeFence: MotionFn = (cm, cursorPosition, { repeat }) => { + return jumpToPattern({ + cm, + cursorPosition, + repeat, + regex: CODE_FENCE_REGEX, + direction: "previous", + }); +}; diff --git a/tests/createFakeCodeMirrorEditor.ts b/tests/createFakeCodeMirrorEditor.ts new file mode 100644 index 0000000..46b0769 --- /dev/null +++ b/tests/createFakeCodeMirrorEditor.ts @@ -0,0 +1,39 @@ +import { Editor as CodeMirrorEditor } from "codemirror"; +import { EditorPosition } from "obsidian"; + +/** + * Returns a fake CodeMirror editor bound to the given `contentLines`. + */ +export function createFakeCodeMirrorEditor( + contentLines: string[] +): Pick { + const content = contentLines.join("\n"); + const lineStartIndexes = getLineStartIndexes(contentLines); + return { + getValue: () => content, + indexFromPos: ({ line, ch }: EditorPosition) => lineStartIndexes[line] + ch, + posFromIndex: (index: number) => getEditorPositionForIndex(index, lineStartIndexes), + }; +} + +function getLineStartIndexes(lines: string[]): number[] { + const lineStartIndexes: number[] = []; + let currentIndex = 0; + for (const line of lines) { + lineStartIndexes.push(currentIndex); + currentIndex += line.length + 1; + } + return lineStartIndexes; +} + +function getEditorPositionForIndex( + index: number, + lineStartIndexes: number[] +): EditorPosition { + for (let line = lineStartIndexes.length - 1; line >= 0; line -= 1) { + if (lineStartIndexes[line] <= index) { + return { line, ch: index - lineStartIndexes[line] }; + } + } + return { line: 0, ch: 0 }; +} diff --git a/tests/jumpToCodeFence.test.ts b/tests/jumpToCodeFence.test.ts new file mode 100644 index 0000000..ed228bc --- /dev/null +++ b/tests/jumpToCodeFence.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "vitest"; +import { + jumpToNextCodeFence, + jumpToPreviousCodeFence, +} from "../motions/jumpToCodeFence"; +import { createFakeCodeMirrorEditor } from "./createFakeCodeMirrorEditor"; + +describe("jumpToNextCodeFence", () => { + test("jumps to the next opening code fence", () => { + const cm = createFakeCodeMirrorEditor([ + "intro", + "```ts", + "const answer = 42;", + "```", + "outro", + ]); + + const nextCodeFence = jumpToNextCodeFence( + cm as any, + { line: 0, ch: 0 }, + { repeat: 1 } + ); + + expect(nextCodeFence).toEqual({ line: 1, ch: 0 }); + }); + + test("jumps to the next closing code fence", () => { + const cm = createFakeCodeMirrorEditor([ + "intro", + "```ts", + "const answer = 42;", + "```", + "outro", + ]); + + const nextCodeFence = jumpToNextCodeFence( + cm as any, + { line: 2, ch: 0 }, + { repeat: 1 } + ); + + expect(nextCodeFence).toEqual({ line: 3, ch: 0 }); + }); + + test("wraps to the first code fence after the last one", () => { + const cm = createFakeCodeMirrorEditor([ + "intro", + "```ts", + "const answer = 42;", + "```", + "outro", + ]); + + const nextCodeFence = jumpToNextCodeFence( + cm as any, + { line: 4, ch: 0 }, + { repeat: 1 } + ); + + expect(nextCodeFence).toEqual({ line: 1, ch: 0 }); + }); + + test("ignores backticks that are not code fence lines", () => { + const cm = createFakeCodeMirrorEditor([ + "here are inline backticks ``` not a fence", + "```ts", + "const answer = 42;", + "```", + ]); + + const nextCodeFence = jumpToNextCodeFence( + cm as any, + { line: 0, ch: 0 }, + { repeat: 1 } + ); + + expect(nextCodeFence).toEqual({ line: 1, ch: 0 }); + }); +}); + +describe("jumpToPreviousCodeFence", () => { + test("jumps to the previous closing code fence", () => { + const cm = createFakeCodeMirrorEditor([ + "intro", + "```ts", + "const answer = 42;", + "```", + "outro", + ]); + + const previousCodeFence = jumpToPreviousCodeFence( + cm as any, + { line: 4, ch: 0 }, + { repeat: 1 } + ); + + expect(previousCodeFence).toEqual({ line: 3, ch: 0 }); + }); + + test("jumps to the previous opening code fence", () => { + const cm = createFakeCodeMirrorEditor([ + "intro", + "```ts", + "const answer = 42;", + "```", + "outro", + ]); + + const previousCodeFence = jumpToPreviousCodeFence( + cm as any, + { line: 2, ch: 0 }, + { repeat: 1 } + ); + + expect(previousCodeFence).toEqual({ line: 1, ch: 0 }); + }); +}); From fdc3aa054e8b82259308c1422c2fd6b95380333b Mon Sep 17 00:00:00 2001 From: Aly Thobani Date: Mon, 11 May 2026 17:28:48 -0700 Subject: [PATCH 02/10] refactor(tests): deduplicate code fence motion specs --- tests/jumpToCodeFence.test.ts | 123 +++++++++++++--------------------- 1 file changed, 46 insertions(+), 77 deletions(-) diff --git a/tests/jumpToCodeFence.test.ts b/tests/jumpToCodeFence.test.ts index ed228bc..cc481d5 100644 --- a/tests/jumpToCodeFence.test.ts +++ b/tests/jumpToCodeFence.test.ts @@ -5,113 +5,82 @@ import { } from "../motions/jumpToCodeFence"; import { createFakeCodeMirrorEditor } from "./createFakeCodeMirrorEditor"; +const CODE_FENCE_CONTENT_LINES = [ + "intro", + "```ts", + "const answer = 42;", + "```", + "outro", +]; + describe("jumpToNextCodeFence", () => { test("jumps to the next opening code fence", () => { - const cm = createFakeCodeMirrorEditor([ - "intro", - "```ts", - "const answer = 42;", - "```", - "outro", - ]); - - const nextCodeFence = jumpToNextCodeFence( - cm as any, + expectNextCodeFencePosition( { line: 0, ch: 0 }, - { repeat: 1 } + { line: 1, ch: 0 } ); - - expect(nextCodeFence).toEqual({ line: 1, ch: 0 }); }); test("jumps to the next closing code fence", () => { - const cm = createFakeCodeMirrorEditor([ - "intro", - "```ts", - "const answer = 42;", - "```", - "outro", - ]); - - const nextCodeFence = jumpToNextCodeFence( - cm as any, + expectNextCodeFencePosition( { line: 2, ch: 0 }, - { repeat: 1 } + { line: 3, ch: 0 } ); - - expect(nextCodeFence).toEqual({ line: 3, ch: 0 }); }); test("wraps to the first code fence after the last one", () => { - const cm = createFakeCodeMirrorEditor([ - "intro", - "```ts", - "const answer = 42;", - "```", - "outro", - ]); - - const nextCodeFence = jumpToNextCodeFence( - cm as any, + expectNextCodeFencePosition( { line: 4, ch: 0 }, - { repeat: 1 } + { line: 1, ch: 0 } ); - - expect(nextCodeFence).toEqual({ line: 1, ch: 0 }); }); test("ignores backticks that are not code fence lines", () => { - const cm = createFakeCodeMirrorEditor([ - "here are inline backticks ``` not a fence", - "```ts", - "const answer = 42;", - "```", - ]); - - const nextCodeFence = jumpToNextCodeFence( - cm as any, + expectNextCodeFencePosition( { line: 0, ch: 0 }, - { repeat: 1 } + { line: 1, ch: 0 }, + [ + "here are inline backticks ``` not a fence", + "```ts", + "const answer = 42;", + "```", + ] ); - - expect(nextCodeFence).toEqual({ line: 1, ch: 0 }); }); }); describe("jumpToPreviousCodeFence", () => { test("jumps to the previous closing code fence", () => { - const cm = createFakeCodeMirrorEditor([ - "intro", - "```ts", - "const answer = 42;", - "```", - "outro", - ]); - - const previousCodeFence = jumpToPreviousCodeFence( - cm as any, + expectPreviousCodeFencePosition( { line: 4, ch: 0 }, - { repeat: 1 } + { line: 3, ch: 0 } ); - - expect(previousCodeFence).toEqual({ line: 3, ch: 0 }); }); test("jumps to the previous opening code fence", () => { - const cm = createFakeCodeMirrorEditor([ - "intro", - "```ts", - "const answer = 42;", - "```", - "outro", - ]); - - const previousCodeFence = jumpToPreviousCodeFence( - cm as any, + expectPreviousCodeFencePosition( { line: 2, ch: 0 }, - { repeat: 1 } + { line: 1, ch: 0 } ); - - expect(previousCodeFence).toEqual({ line: 1, ch: 0 }); }); }); + +function expectNextCodeFencePosition( + cursorPosition: { line: number; ch: number }, + expectedPosition: { line: number; ch: number }, + contentLines: string[] = CODE_FENCE_CONTENT_LINES +): void { + const cm = createFakeCodeMirrorEditor(contentLines); + const nextCodeFence = jumpToNextCodeFence(cm as any, cursorPosition, { repeat: 1 }); + expect(nextCodeFence).toEqual(expectedPosition); +} + +function expectPreviousCodeFencePosition( + cursorPosition: { line: number; ch: number }, + expectedPosition: { line: number; ch: number }, + contentLines: string[] = CODE_FENCE_CONTENT_LINES +): void { + const cm = createFakeCodeMirrorEditor(contentLines); + const previousCodeFence = jumpToPreviousCodeFence(cm as any, cursorPosition, { repeat: 1 }); + expect(previousCodeFence).toEqual(expectedPosition); +} From b46f370b377cdee18735f242cf46e4620f9a5873 Mon Sep 17 00:00:00 2001 From: Aly Thobani Date: Mon, 11 May 2026 17:29:39 -0700 Subject: [PATCH 03/10] chore: format test file --- tests/jumpToCodeFence.test.ts | 54 +++++++++-------------------------- 1 file changed, 13 insertions(+), 41 deletions(-) diff --git a/tests/jumpToCodeFence.test.ts b/tests/jumpToCodeFence.test.ts index cc481d5..032bd30 100644 --- a/tests/jumpToCodeFence.test.ts +++ b/tests/jumpToCodeFence.test.ts @@ -1,67 +1,39 @@ import { describe, expect, test } from "vitest"; -import { - jumpToNextCodeFence, - jumpToPreviousCodeFence, -} from "../motions/jumpToCodeFence"; +import { jumpToNextCodeFence, jumpToPreviousCodeFence } from "../motions/jumpToCodeFence"; import { createFakeCodeMirrorEditor } from "./createFakeCodeMirrorEditor"; -const CODE_FENCE_CONTENT_LINES = [ - "intro", - "```ts", - "const answer = 42;", - "```", - "outro", -]; +const CODE_FENCE_CONTENT_LINES = ["intro", "```ts", "const answer = 42;", "```", "outro"]; describe("jumpToNextCodeFence", () => { test("jumps to the next opening code fence", () => { - expectNextCodeFencePosition( - { line: 0, ch: 0 }, - { line: 1, ch: 0 } - ); + expectNextCodeFencePosition({ line: 0, ch: 0 }, { line: 1, ch: 0 }); }); test("jumps to the next closing code fence", () => { - expectNextCodeFencePosition( - { line: 2, ch: 0 }, - { line: 3, ch: 0 } - ); + expectNextCodeFencePosition({ line: 2, ch: 0 }, { line: 3, ch: 0 }); }); test("wraps to the first code fence after the last one", () => { - expectNextCodeFencePosition( - { line: 4, ch: 0 }, - { line: 1, ch: 0 } - ); + expectNextCodeFencePosition({ line: 4, ch: 0 }, { line: 1, ch: 0 }); }); test("ignores backticks that are not code fence lines", () => { - expectNextCodeFencePosition( - { line: 0, ch: 0 }, - { line: 1, ch: 0 }, - [ - "here are inline backticks ``` not a fence", - "```ts", - "const answer = 42;", - "```", - ] - ); + expectNextCodeFencePosition({ line: 0, ch: 0 }, { line: 1, ch: 0 }, [ + "here are inline backticks ``` not a fence", + "```ts", + "const answer = 42;", + "```", + ]); }); }); describe("jumpToPreviousCodeFence", () => { test("jumps to the previous closing code fence", () => { - expectPreviousCodeFencePosition( - { line: 4, ch: 0 }, - { line: 3, ch: 0 } - ); + expectPreviousCodeFencePosition({ line: 4, ch: 0 }, { line: 3, ch: 0 }); }); test("jumps to the previous opening code fence", () => { - expectPreviousCodeFencePosition( - { line: 2, ch: 0 }, - { line: 1, ch: 0 } - ); + expectPreviousCodeFencePosition({ line: 2, ch: 0 }, { line: 1, ch: 0 }); }); }); From 803ad685a00afdc1aef3b92c38d1478cba6bc5ac Mon Sep 17 00:00:00 2001 From: Aly Thobani Date: Mon, 11 May 2026 17:31:26 -0700 Subject: [PATCH 04/10] refactor(tests): use EditorPosition in code fence specs --- tests/jumpToCodeFence.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/jumpToCodeFence.test.ts b/tests/jumpToCodeFence.test.ts index 032bd30..05b135d 100644 --- a/tests/jumpToCodeFence.test.ts +++ b/tests/jumpToCodeFence.test.ts @@ -1,3 +1,4 @@ +import { EditorPosition } from "obsidian"; import { describe, expect, test } from "vitest"; import { jumpToNextCodeFence, jumpToPreviousCodeFence } from "../motions/jumpToCodeFence"; import { createFakeCodeMirrorEditor } from "./createFakeCodeMirrorEditor"; @@ -38,8 +39,8 @@ describe("jumpToPreviousCodeFence", () => { }); function expectNextCodeFencePosition( - cursorPosition: { line: number; ch: number }, - expectedPosition: { line: number; ch: number }, + cursorPosition: EditorPosition, + expectedPosition: EditorPosition, contentLines: string[] = CODE_FENCE_CONTENT_LINES ): void { const cm = createFakeCodeMirrorEditor(contentLines); @@ -48,8 +49,8 @@ function expectNextCodeFencePosition( } function expectPreviousCodeFencePosition( - cursorPosition: { line: number; ch: number }, - expectedPosition: { line: number; ch: number }, + cursorPosition: EditorPosition, + expectedPosition: EditorPosition, contentLines: string[] = CODE_FENCE_CONTENT_LINES ): void { const cm = createFakeCodeMirrorEditor(contentLines); From 97e75ae2266a6b521ddf32900d5fff3cda4cea6a Mon Sep 17 00:00:00 2001 From: Aly Thobani Date: Mon, 11 May 2026 17:35:56 -0700 Subject: [PATCH 05/10] docs(readme): document code fence jump mappings --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 27ebfe5..67d8066 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ Finally, this plugin also provides the following motions/mappings by default: - `[[` and `]]` to jump to the previous and next Markdown heading. - `zk` and `zj` to move up and down while skipping folds. +- `gc` and `gC` to jump to the next and previous Markdown code fence. - `gl` and `gL` to jump to the next and previous link. - `gf` to open the link or file under the cursor (temporarily moving the cursor if necessary—e.g. if it's on the first square bracket of a [[Wikilink]]). From c15140ee5682a904f58d04b6d7e572fb3e651483 Mon Sep 17 00:00:00 2001 From: Aly Thobani Date: Tue, 12 May 2026 08:27:44 -0700 Subject: [PATCH 06/10] test(motions): expand code fence motion coverage --- tests/jumpToCodeFence.test.ts | 105 ++++++++++++++++++++++++++++++---- 1 file changed, 95 insertions(+), 10 deletions(-) diff --git a/tests/jumpToCodeFence.test.ts b/tests/jumpToCodeFence.test.ts index 05b135d..990e856 100644 --- a/tests/jumpToCodeFence.test.ts +++ b/tests/jumpToCodeFence.test.ts @@ -4,6 +4,24 @@ import { jumpToNextCodeFence, jumpToPreviousCodeFence } from "../motions/jumpToC import { createFakeCodeMirrorEditor } from "./createFakeCodeMirrorEditor"; const CODE_FENCE_CONTENT_LINES = ["intro", "```ts", "const answer = 42;", "```", "outro"]; +const INLINE_BACKTICK_CONTENT_LINES = [ + "here are inline backticks ``` not a fence", + "```ts", + "const answer = 42;", + "```", +]; +const MULTIPLE_CODE_FENCE_CONTENT_LINES = [ + "intro", + "```ts", + "const firstAnswer = 42;", + "```", + "middle", + "````js", + "const secondAnswer = 7;", + "````", + "outro", +]; +const NO_CODE_FENCE_CONTENT_LINES = ["intro", "plain text", "outro"]; describe("jumpToNextCodeFence", () => { test("jumps to the next opening code fence", () => { @@ -19,12 +37,48 @@ describe("jumpToNextCodeFence", () => { }); test("ignores backticks that are not code fence lines", () => { - expectNextCodeFencePosition({ line: 0, ch: 0 }, { line: 1, ch: 0 }, [ - "here are inline backticks ``` not a fence", - "```ts", - "const answer = 42;", - "```", - ]); + expectNextCodeFencePosition( + { line: 0, ch: 0 }, + { line: 1, ch: 0 }, + INLINE_BACKTICK_CONTENT_LINES + ); + }); + + test("jumps to the second next code fence when repeat is two", () => { + expectNextCodeFencePosition( + { line: 0, ch: 0 }, + { line: 3, ch: 0 }, + MULTIPLE_CODE_FENCE_CONTENT_LINES, + 2 + ); + }); + + test("jumps to the closing code fence when cursor is within a code block", () => { + expectNextCodeFencePosition({ line: 2, ch: 5 }, { line: 3, ch: 0 }); + }); + + test("jumps to the next distinct code fence when cursor is on the first backtick of a fence", () => { + expectNextCodeFencePosition({ line: 1, ch: 0 }, { line: 3, ch: 0 }); + }); + + test("jumps to the next distinct code fence when cursor is in the middle of a fence", () => { + expectNextCodeFencePosition({ line: 1, ch: 1 }, { line: 3, ch: 0 }); + }); + + test("returns the original cursor position when there are no code fences", () => { + expectNextCodeFencePosition( + { line: 1, ch: 2 }, + { line: 1, ch: 2 }, + NO_CODE_FENCE_CONTENT_LINES + ); + }); + + test("matches fences longer than three backticks", () => { + expectNextCodeFencePosition( + { line: 4, ch: 0 }, + { line: 5, ch: 0 }, + MULTIPLE_CODE_FENCE_CONTENT_LINES + ); }); }); @@ -36,24 +90,55 @@ describe("jumpToPreviousCodeFence", () => { test("jumps to the previous opening code fence", () => { expectPreviousCodeFencePosition({ line: 2, ch: 0 }, { line: 1, ch: 0 }); }); + + test("jumps to the second previous code fence when repeat is two", () => { + expectPreviousCodeFencePosition( + { line: 8, ch: 0 }, + { line: 5, ch: 0 }, + MULTIPLE_CODE_FENCE_CONTENT_LINES, + 2 + ); + }); + + test("jumps to the opening code fence when cursor is within a code block", () => { + expectPreviousCodeFencePosition({ line: 2, ch: 5 }, { line: 1, ch: 0 }); + }); + + test("jumps to the previous distinct code fence when cursor is on the first backtick of a fence", () => { + expectPreviousCodeFencePosition({ line: 3, ch: 0 }, { line: 1, ch: 0 }); + }); + + test("jumps to the previous distinct code fence when cursor is in the middle of a fence", () => { + expectPreviousCodeFencePosition({ line: 3, ch: 1 }, { line: 1, ch: 0 }); + }); + + test("returns the original cursor position when there are no code fences", () => { + expectPreviousCodeFencePosition( + { line: 1, ch: 2 }, + { line: 1, ch: 2 }, + NO_CODE_FENCE_CONTENT_LINES + ); + }); }); function expectNextCodeFencePosition( cursorPosition: EditorPosition, expectedPosition: EditorPosition, - contentLines: string[] = CODE_FENCE_CONTENT_LINES + contentLines: string[] = CODE_FENCE_CONTENT_LINES, + repeat = 1 ): void { const cm = createFakeCodeMirrorEditor(contentLines); - const nextCodeFence = jumpToNextCodeFence(cm as any, cursorPosition, { repeat: 1 }); + const nextCodeFence = jumpToNextCodeFence(cm as any, cursorPosition, { repeat }); expect(nextCodeFence).toEqual(expectedPosition); } function expectPreviousCodeFencePosition( cursorPosition: EditorPosition, expectedPosition: EditorPosition, - contentLines: string[] = CODE_FENCE_CONTENT_LINES + contentLines: string[] = CODE_FENCE_CONTENT_LINES, + repeat = 1 ): void { const cm = createFakeCodeMirrorEditor(contentLines); - const previousCodeFence = jumpToPreviousCodeFence(cm as any, cursorPosition, { repeat: 1 }); + const previousCodeFence = jumpToPreviousCodeFence(cm as any, cursorPosition, { repeat }); expect(previousCodeFence).toEqual(expectedPosition); } From bcad4fd8184c7493858fd0172155a0e41d1313a2 Mon Sep 17 00:00:00 2001 From: Aly Thobani Date: Sun, 24 May 2026 15:54:12 -0700 Subject: [PATCH 07/10] docs: update docstrings for regexes for clarity --- motions/jumpToCodeFence.ts | 18 ++++++++++++++---- motions/jumpToHeading.ts | 8 +++----- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/motions/jumpToCodeFence.ts b/motions/jumpToCodeFence.ts index 4a23123..a68e27e 100644 --- a/motions/jumpToCodeFence.ts +++ b/motions/jumpToCodeFence.ts @@ -1,8 +1,18 @@ import { jumpToPattern } from "../utils/jumpToPattern"; import { MotionFn } from "../utils/vimApi"; -/** Regex for a Markdown code fence line, which begins with at least three backticks. */ -export const CODE_FENCE_REGEX = /^```+/gm; +/** Naive Regex for a Markdown code fence, i.e. a line beginning with at least three backticks. + * + * "Naive" for two reasons: + * 1. False negatives: Fences could be made of tildes instead of backticks. This is less common, but + * should be easy to add support for if users request it. + * 2. False positives: It could match lines within non-Markdown codeblocks. But since triple + * backticks aren't a syntax feature of any other programming language (AFAIK, at the time of this + * writing), this should be rare enough that the naive regex should work well in practice. + * + * Matches only the fence itself, so can be used for jumping to either an opening or closing fence. + */ +const NAIVE_CODE_FENCE_REGEX = /^```+/gm; /** * Jumps to the repeat-th next code fence. @@ -12,7 +22,7 @@ export const jumpToNextCodeFence: MotionFn = (cm, cursorPosition, { repeat }) => cm, cursorPosition, repeat, - regex: CODE_FENCE_REGEX, + regex: NAIVE_CODE_FENCE_REGEX, direction: "next", }); }; @@ -25,7 +35,7 @@ export const jumpToPreviousCodeFence: MotionFn = (cm, cursorPosition, { repeat } cm, cursorPosition, repeat, - regex: CODE_FENCE_REGEX, + regex: NAIVE_CODE_FENCE_REGEX, direction: "previous", }); }; diff --git a/motions/jumpToHeading.ts b/motions/jumpToHeading.ts index 67a1d3a..42cd69c 100644 --- a/motions/jumpToHeading.ts +++ b/motions/jumpToHeading.ts @@ -11,6 +11,8 @@ const NAIVE_HEADING_REGEX = /^#{1,6} /gm; /** Regex for a Markdown fenced codeblock, which begins with some number >=3 of backticks at the * start of a line. It either ends on the nearest future line that starts with at least as many * backticks (\1 back-reference), or extends to the end of the string if no such future line exists. + * + * Matches the entire codeblock. */ const FENCED_CODEBLOCK_REGEX = /(^```+)(.*?^\1|.*)/gms; @@ -24,11 +26,7 @@ export const jumpToNextHeading: MotionFn = (cm, cursorPosition, { repeat }) => { /** * Jumps to the repeat-th previous heading. */ -export const jumpToPreviousHeading: MotionFn = ( - cm, - cursorPosition, - { repeat } -) => { +export const jumpToPreviousHeading: MotionFn = (cm, cursorPosition, { repeat }) => { return jumpToHeading({ cm, cursorPosition, repeat, direction: "previous" }); }; From 72e932010bc4dd2f2fa590d9b144265d09b9e7e3 Mon Sep 17 00:00:00 2001 From: Aly Thobani Date: Sun, 24 May 2026 16:58:56 -0700 Subject: [PATCH 08/10] docs: update README line about jump to code fence motion --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 67d8066..eebb9a7 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ Finally, this plugin also provides the following motions/mappings by default: - `[[` and `]]` to jump to the previous and next Markdown heading. - `zk` and `zj` to move up and down while skipping folds. -- `gc` and `gC` to jump to the next and previous Markdown code fence. +- `gc` and `gC` to jump to the next and previous Markdown code fence (line starting with >= 3 backticks). Can jump to both opening and closing fences. - `gl` and `gL` to jump to the next and previous link. - `gf` to open the link or file under the cursor (temporarily moving the cursor if necessary—e.g. if it's on the first square bracket of a [[Wikilink]]). From 80721a30536388a40ee84bdaa83911accf916334 Mon Sep 17 00:00:00 2001 From: Aly Thobani Date: Sun, 24 May 2026 17:16:17 -0700 Subject: [PATCH 09/10] docs: update README to mention the provided motions support repeat and show remapping example for them --- README.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index eebb9a7..ac286f7 100644 --- a/README.md +++ b/README.md @@ -86,12 +86,19 @@ In some cases you can find workarounds by experimenting, and the easiest way to Finally, this plugin also provides the following motions/mappings by default: -- `[[` and `]]` to jump to the previous and next Markdown heading. -- `zk` and `zj` to move up and down while skipping folds. -- `gc` and `gC` to jump to the next and previous Markdown code fence (line starting with >= 3 backticks). Can jump to both opening and closing fences. -- `gl` and `gL` to jump to the next and previous link. +- `[[` and `]]` to jump to the previous and next Markdown heading. Supports repeat (e.g. `2]]` to jump two headings forward). +- `zk` and `zj` to move up and down while skipping folds. Supports repeat. +- `gc` and `gC` to jump to the next and previous Markdown code fence (opening or closing fence; just any line starting with >= 3 backticks). Supports repeat. +- `gl` and `gL` to jump to the next and previous link. Supports repeat. - `gf` to open the link or file under the cursor (temporarily moving the cursor if necessary—e.g. if it's on the first square bracket of a [[Wikilink]]). +You can of course remap these as you wish. E.g. if you prefer `'h` and `gh` for jumping to headings: + +```vim +map 'h [[ +map gh ]] +``` + ## Installation In the Obsidian.md settings under "Community plugins", click on "Turn on community plugins", then browse to this plugin. From 0b1168b2ae720650d41a175fcbaad58d886dfdb2 Mon Sep 17 00:00:00 2001 From: Aly Thobani Date: Sun, 24 May 2026 17:32:38 -0700 Subject: [PATCH 10/10] chore: replace unnecessary CodeMirrorEditor import aliases - CodeMirror.Editor is globally available as a type (and already used in other files), so lets just stick consistently to using that --- motions/jumpToHeading.ts | 5 ++--- tests/createFakeCodeMirrorEditor.ts | 3 +-- utils/jumpToPattern.ts | 3 +-- utils/obsidianVimCommand.ts | 3 +-- utils/vimApi.ts | 5 ++--- 5 files changed, 7 insertions(+), 12 deletions(-) diff --git a/motions/jumpToHeading.ts b/motions/jumpToHeading.ts index 42cd69c..f4ee545 100644 --- a/motions/jumpToHeading.ts +++ b/motions/jumpToHeading.ts @@ -1,4 +1,3 @@ -import { Editor as CodeMirrorEditor } from "codemirror"; import { EditorPosition } from "obsidian"; import { isWithinMatch, jumpToPattern } from "../utils/jumpToPattern"; import { MotionFn } from "../utils/vimApi"; @@ -43,7 +42,7 @@ function jumpToHeading({ repeat, direction, }: { - cm: CodeMirrorEditor; + cm: CodeMirror.Editor; cursorPosition: EditorPosition; repeat: number; direction: "next" | "previous"; @@ -60,7 +59,7 @@ function jumpToHeading({ }); } -function findAllCodeblocks(cm: CodeMirrorEditor): RegExpExecArray[] { +function findAllCodeblocks(cm: CodeMirror.Editor): RegExpExecArray[] { const content = cm.getValue(); return [...content.matchAll(FENCED_CODEBLOCK_REGEX)]; } diff --git a/tests/createFakeCodeMirrorEditor.ts b/tests/createFakeCodeMirrorEditor.ts index 46b0769..db06a49 100644 --- a/tests/createFakeCodeMirrorEditor.ts +++ b/tests/createFakeCodeMirrorEditor.ts @@ -1,4 +1,3 @@ -import { Editor as CodeMirrorEditor } from "codemirror"; import { EditorPosition } from "obsidian"; /** @@ -6,7 +5,7 @@ import { EditorPosition } from "obsidian"; */ export function createFakeCodeMirrorEditor( contentLines: string[] -): Pick { +): Pick { const content = contentLines.join("\n"); const lineStartIndexes = getLineStartIndexes(contentLines); return { diff --git a/utils/jumpToPattern.ts b/utils/jumpToPattern.ts index 05e3b4b..80120fb 100644 --- a/utils/jumpToPattern.ts +++ b/utils/jumpToPattern.ts @@ -1,4 +1,3 @@ -import { Editor as CodeMirrorEditor } from "codemirror"; import { EditorPosition } from "obsidian"; /** @@ -27,7 +26,7 @@ export function jumpToPattern({ filterMatch = () => true, direction, }: { - cm: CodeMirrorEditor; + cm: CodeMirror.Editor; cursorPosition: EditorPosition; repeat: number; regex: RegExp; diff --git a/utils/obsidianVimCommand.ts b/utils/obsidianVimCommand.ts index 39f1499..e270d22 100644 --- a/utils/obsidianVimCommand.ts +++ b/utils/obsidianVimCommand.ts @@ -2,14 +2,13 @@ * Utility types and functions for defining Obsidian-specific Vim commands. */ -import { Editor as CodeMirrorEditor } from "codemirror"; import VimrcPlugin from "../main"; import { MotionFn, VimApi } from "./vimApi"; export type ObsidianActionFn = ( vimrcPlugin: VimrcPlugin, // Included so we can run Obsidian commands as part of the action - cm: CodeMirrorEditor, + cm: CodeMirror.Editor, actionArgs: { repeat: number }, ) => void; diff --git a/utils/vimApi.ts b/utils/vimApi.ts index 60a662c..9e105da 100644 --- a/utils/vimApi.ts +++ b/utils/vimApi.ts @@ -6,17 +6,16 @@ * https://libvoyant.ucr.edu/resources/codemirror/doc/manual.html */ -import { Editor as CodeMirrorEditor } from "codemirror"; import { EditorPosition } from "obsidian"; export type MotionFn = ( - cm: CodeMirrorEditor, + cm: CodeMirror.Editor, cursorPosition: EditorPosition, // called `head` in the API motionArgs: { repeat: number } ) => EditorPosition; export type ActionFn = ( - cm: CodeMirrorEditor, + cm: CodeMirror.Editor, actionArgs: { repeat: number }, ) => void;