From 13b415a28157512928101d4f682c6e66ffb8e96b Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Wed, 21 Oct 2026 15:48:50 +0200 Subject: [PATCH 1/2] Support scrolling in vscode notebooks --- .../src/ide/vscode/VscodeFocusEditor.ts | 80 +++++++++---------- .../src/ide/vscode/VscodeRevealLine.ts | 45 +++++++++++ .../src/ide/vscode/notebook/notebook.ts | 25 ------ packages/lib-vscode-common/src/notebook.ts | 47 ++++++++++- 4 files changed, 128 insertions(+), 69 deletions(-) delete mode 100644 packages/app-vscode/src/ide/vscode/notebook/notebook.ts diff --git a/packages/app-vscode/src/ide/vscode/VscodeFocusEditor.ts b/packages/app-vscode/src/ide/vscode/VscodeFocusEditor.ts index 96628eee599..7019f15634c 100644 --- a/packages/app-vscode/src/ide/vscode/VscodeFocusEditor.ts +++ b/packages/app-vscode/src/ide/vscode/VscodeFocusEditor.ts @@ -1,7 +1,10 @@ -import type { NotebookDocument, TextEditor } from "vscode"; +import type { TextEditor } from "vscode"; import { commands, TabInputTextDiff, ViewColumn, window } from "vscode"; -import { getCellIndex } from "@cursorless/lib-vscode-common"; -import { getNotebookFromCellDocument } from "./notebook/notebook"; +import { + getCellIndex, + getNotebookFromCellDocument, + isDocumentNotebookCell, +} from "@cursorless/lib-vscode-common"; import type { VscodeTextEditor } from "./VscodeTextEditor"; const columnFocusCommands = { @@ -24,36 +27,36 @@ const columnFocusCommands = { export async function vscodeFocusEditor( editor: VscodeTextEditor, ): Promise { + if (isDocumentNotebookCell(editor.vscodeEditor.document)) { + await focusNotebookCell(editor); + return; + } + const viewColumn = getViewColumn(editor.vscodeEditor); - if (viewColumn != null) { - await commands.executeCommand(columnFocusCommands[viewColumn]); - if (editor.isDiffEditorOriginal && !editor.isActive) { - // There is no way of directly focusing the left hand side of a diff - // editor. Switch side if needed. + await commands.executeCommand(columnFocusCommands[viewColumn]); - await commands.executeCommand("diffEditor.switchSide"); - } else if (editor.isSearchEditor) { - // Focusing the search editor brings focus back to the input field. This - // command moves selection into the actual editor text. + if (editor.isDiffEditorOriginal && !editor.isActive) { + // There is no way of directly focusing the left hand side of a diff + // editor. Switch side if needed. - await commands.executeCommand("search.action.focusNextSearchResult"); - } - } else { - // If the view column is null we see if it's a notebook and try to see if we - // can just move around in the notebook to focus the correct editor + await commands.executeCommand("diffEditor.switchSide"); + } else if (editor.isSearchEditor) { + // Focusing the search editor brings focus back to the input field. This + // command moves selection into the actual editor text. - await focusNotebookCell(editor); + await commands.executeCommand("search.action.focusNextSearchResult"); } } -function getViewColumn(editor: TextEditor): ViewColumn | undefined { +function getViewColumn(editor: TextEditor): ViewColumn { if (editor.viewColumn != null) { return editor.viewColumn; } + const uri = editor.document.uri.toString(); const tabGroup = window.tabGroups.all.find((tabGroup) => - tabGroup.tabs.find((tab) => { + tabGroup.tabs.some((tab) => { if (tab.input instanceof TabInputTextDiff) { return ( tab.input.original.toString() === uri || @@ -63,41 +66,36 @@ function getViewColumn(editor: TextEditor): ViewColumn | undefined { return (tab as any).input?.uri?.toString() === uri; }), ); - return tabGroup?.viewColumn; + + const column = tabGroup?.viewColumn; + + if (column == null) { + throw new Error("Couldn't determine view column for editor"); + } + + return column; } async function focusNotebookCell(editor: VscodeTextEditor) { - const desiredNotebookEditor = getNotebookFromCellDocument( + const notebookEditor = getNotebookFromCellDocument( editor.vscodeEditor.document, ); - if (desiredNotebookEditor == null) { - throw new Error("Couldn't find notebook editor for given document"); - } - - const desiredNotebookDocument: NotebookDocument = - desiredNotebookEditor.notebook; await commands.executeCommand( columnFocusCommands[ - desiredNotebookEditor.viewColumn as keyof typeof columnFocusCommands + notebookEditor.viewColumn as keyof typeof columnFocusCommands ], ); - const desiredEditorIndex = getCellIndex( - desiredNotebookDocument, + const cellIndex = getCellIndex( + notebookEditor.notebook, editor.vscodeEditor.document, ); - if (desiredEditorIndex < 0) { - throw new Error( - "Couldn't find cell corresponding to given document in notebook editor", - ); - } - - desiredNotebookEditor.selections = [ - desiredNotebookEditor.selection.with({ - start: desiredEditorIndex, - end: desiredEditorIndex + 1, + notebookEditor.selections = [ + notebookEditor.selection.with({ + start: cellIndex, + end: cellIndex + 1, }), ]; diff --git a/packages/app-vscode/src/ide/vscode/VscodeRevealLine.ts b/packages/app-vscode/src/ide/vscode/VscodeRevealLine.ts index 2bee9f569b1..ca3ae82eed0 100644 --- a/packages/app-vscode/src/ide/vscode/VscodeRevealLine.ts +++ b/packages/app-vscode/src/ide/vscode/VscodeRevealLine.ts @@ -1,5 +1,10 @@ import vscode from "vscode"; import { RevealLineAt } from "@cursorless/lib-common"; +import { + getCellIndex, + getNotebookFromCellDocument, + isDocumentNotebookCell, +} from "@cursorless/lib-vscode-common"; import type { VscodeTextEditor } from "./VscodeTextEditor"; export async function vscodeRevealLine( @@ -7,6 +12,11 @@ export async function vscodeRevealLine( lineNumber: number, at: RevealLineAt, ): Promise { + if (isDocumentNotebookCell(editor.vscodeEditor.document)) { + revealNotebookCell(editor, at); + return; + } + // For reveal line to the work we have to have the correct editor focused if (!editor.isActive) { await editor.focus(); @@ -17,3 +27,38 @@ export async function vscodeRevealLine( at, }); } + +function revealNotebookCell(editor: VscodeTextEditor, at: RevealLineAt) { + const notebookEditor = getNotebookFromCellDocument( + editor.vscodeEditor.document, + ); + + const cellIndex = getCellIndex( + notebookEditor.notebook, + editor.vscodeEditor.document, + ); + + notebookEditor.revealRange( + new vscode.NotebookRange(cellIndex, cellIndex), + getNotebookRevealType(at), + ); +} + +function getNotebookRevealType( + at: RevealLineAt, +): vscode.NotebookEditorRevealType { + switch (at) { + case RevealLineAt.top: + return vscode.NotebookEditorRevealType.AtTop; + case RevealLineAt.center: + return vscode.NotebookEditorRevealType.InCenter; + case RevealLineAt.bottom: + throw new Error( + "VS Code's notebook API doesn't support bottom alignment.", + ); + default: { + const _exhaustiveCheck: never = at; + throw new Error(`Unhandled case: ${_exhaustiveCheck}`); + } + } +} diff --git a/packages/app-vscode/src/ide/vscode/notebook/notebook.ts b/packages/app-vscode/src/ide/vscode/notebook/notebook.ts deleted file mode 100644 index 6c25ec25172..00000000000 --- a/packages/app-vscode/src/ide/vscode/notebook/notebook.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { TextDocument } from "vscode"; -import { window } from "vscode"; - -/** - * Given a document corresponding to a single cell, retrieve the notebook - * document for the entire notebook - * @param document The document corresponding to the given cell - * @returns The notebook document corresponding to the notebook containing the - * given cell - */ -export function getNotebookFromCellDocument(document: TextDocument) { - const { notebookEditor } = - window.visibleNotebookEditors - .flatMap((notebookEditor) => - notebookEditor.notebook.getCells().map((cell) => ({ - notebookEditor, - cell, - })), - ) - .find( - ({ cell }) => cell.document.uri.toString() === document.uri.toString(), - ) ?? {}; - - return notebookEditor; -} diff --git a/packages/lib-vscode-common/src/notebook.ts b/packages/lib-vscode-common/src/notebook.ts index 9ec45e83162..b029f056e57 100644 --- a/packages/lib-vscode-common/src/notebook.ts +++ b/packages/lib-vscode-common/src/notebook.ts @@ -1,4 +1,14 @@ -import type { NotebookDocument, TextDocument } from "vscode"; +import type { NotebookDocument, NotebookEditor, TextDocument } from "vscode"; +import { window } from "vscode"; + +/** + * Checks if the given document corresponds to a notebook cell + * @param document The document to check + * @returns True if the document corresponds to a notebook cell, false otherwise + */ +export function isDocumentNotebookCell(document: TextDocument) { + return document.uri.scheme === "vscode-notebook-cell"; +} /** * Returns the index of the cell corresponding to the given document in the @@ -11,10 +21,41 @@ import type { NotebookDocument, TextDocument } from "vscode"; export function getCellIndex( notebookDocument: NotebookDocument, document: TextDocument, -) { - return notebookDocument +): number { + const index = notebookDocument .getCells() .findIndex( (cell) => cell.document.uri.toString() === document.uri.toString(), ); + + if (index === -1) { + throw new Error( + "Couldn't find cell corresponding to given document in notebook editor", + ); + } + + return index; +} + +/** + * Given a document corresponding to a single cell, retrieve the notebook + * editor for the entire notebook + * @param document The document corresponding to the given cell + * @returns The notebook editor corresponding to the notebook containing the + * given cell + */ +export function getNotebookFromCellDocument( + document: TextDocument, +): NotebookEditor { + const editor = window.visibleNotebookEditors.find((editor) => + editor.notebook + .getCells() + .some((cell) => cell.document.uri.toString() === document.uri.toString()), + ); + + if (editor == null) { + throw new Error("Couldn't find notebook editor for given document"); + } + + return editor; } From 3340b0c9f233381135613b936b1caa4b497b346e Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Wed, 21 Oct 2026 16:12:57 +0200 Subject: [PATCH 2/2] Disambiguate the same document in multiple editors --- .../src/ide/vscode/VscodeFocusEditor.ts | 6 +- .../src/ide/vscode/VscodeRevealLine.ts | 6 +- .../src/ide/vscode/notebook.vscode.test.ts | 97 +++++++++++++++++++ .../src/docs/user/actions/scrollToBottom.mdx | 2 + .../src/docs/user/actions/scrollToCenter.mdx | 2 + .../src/docs/user/actions/scrollToTop.mdx | 2 + .../src/references/actionReferences.ts | 3 + packages/lib-vscode-common/src/notebook.ts | 50 ++++++++-- 8 files changed, 150 insertions(+), 18 deletions(-) create mode 100644 packages/app-vscode/src/ide/vscode/notebook.vscode.test.ts diff --git a/packages/app-vscode/src/ide/vscode/VscodeFocusEditor.ts b/packages/app-vscode/src/ide/vscode/VscodeFocusEditor.ts index 7019f15634c..6bc9d6e7cc7 100644 --- a/packages/app-vscode/src/ide/vscode/VscodeFocusEditor.ts +++ b/packages/app-vscode/src/ide/vscode/VscodeFocusEditor.ts @@ -2,7 +2,7 @@ import type { TextEditor } from "vscode"; import { commands, TabInputTextDiff, ViewColumn, window } from "vscode"; import { getCellIndex, - getNotebookFromCellDocument, + getNotebookFromCellEditor, isDocumentNotebookCell, } from "@cursorless/lib-vscode-common"; import type { VscodeTextEditor } from "./VscodeTextEditor"; @@ -77,9 +77,7 @@ function getViewColumn(editor: TextEditor): ViewColumn { } async function focusNotebookCell(editor: VscodeTextEditor) { - const notebookEditor = getNotebookFromCellDocument( - editor.vscodeEditor.document, - ); + const notebookEditor = getNotebookFromCellEditor(editor.vscodeEditor); await commands.executeCommand( columnFocusCommands[ diff --git a/packages/app-vscode/src/ide/vscode/VscodeRevealLine.ts b/packages/app-vscode/src/ide/vscode/VscodeRevealLine.ts index ca3ae82eed0..3ab2c479a99 100644 --- a/packages/app-vscode/src/ide/vscode/VscodeRevealLine.ts +++ b/packages/app-vscode/src/ide/vscode/VscodeRevealLine.ts @@ -2,7 +2,7 @@ import vscode from "vscode"; import { RevealLineAt } from "@cursorless/lib-common"; import { getCellIndex, - getNotebookFromCellDocument, + getNotebookFromCellEditor, isDocumentNotebookCell, } from "@cursorless/lib-vscode-common"; import type { VscodeTextEditor } from "./VscodeTextEditor"; @@ -29,9 +29,7 @@ export async function vscodeRevealLine( } function revealNotebookCell(editor: VscodeTextEditor, at: RevealLineAt) { - const notebookEditor = getNotebookFromCellDocument( - editor.vscodeEditor.document, - ); + const notebookEditor = getNotebookFromCellEditor(editor.vscodeEditor); const cellIndex = getCellIndex( notebookEditor.notebook, diff --git a/packages/app-vscode/src/ide/vscode/notebook.vscode.test.ts b/packages/app-vscode/src/ide/vscode/notebook.vscode.test.ts new file mode 100644 index 00000000000..0261e666ef8 --- /dev/null +++ b/packages/app-vscode/src/ide/vscode/notebook.vscode.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { createSandbox } from "sinon"; +import type { NotebookEditor, TextDocument, TextEditor } from "vscode"; +import { Uri, ViewColumn, window } from "vscode"; +import { getNotebookFromCellEditor } from "@cursorless/lib-vscode-common"; + +suite("Notebook editor lookup", () => { + const sandbox = createSandbox(); + const document = { + uri: Uri.parse("vscode-notebook-cell:/test.ipynb#cell1"), + } as TextDocument; + let first: NotebookEditor; + let second: NotebookEditor; + let cellEditor: TextEditor; + let visibleNotebookEditors: NotebookEditor[]; + let activeTextEditor: TextEditor | undefined; + let activeNotebookEditor: NotebookEditor | undefined; + + setup(() => { + first = createNotebookEditor(ViewColumn.One); + second = createNotebookEditor(ViewColumn.Two); + cellEditor = { document } as TextEditor; + visibleNotebookEditors = [first, second]; + activeTextEditor = undefined; + activeNotebookEditor = undefined; + sandbox + .stub(window, "visibleNotebookEditors") + .get(() => visibleNotebookEditors); + sandbox.stub(window, "activeTextEditor").get(() => activeTextEditor); + sandbox + .stub(window, "activeNotebookEditor") + .get(() => activeNotebookEditor); + }); + + teardown(() => sandbox.restore()); + + test("resolves a notebook visible in only one group", () => { + visibleNotebookEditors = [second]; + + assert.equal(getNotebookFromCellEditor(cellEditor), second); + }); + + test("uses the target editor's group rather than the active notebook", () => { + cellEditor = { document, viewColumn: ViewColumn.Two } as TextEditor; + activeNotebookEditor = first; + + assert.equal(getNotebookFromCellEditor(cellEditor), second); + }); + + test("resolves the active cell editor in the second split", () => { + activeTextEditor = cellEditor; + activeNotebookEditor = second; + + assert.equal(getNotebookFromCellEditor(cellEditor), second); + }); + + test("rejects an inactive target even when its document is active", () => { + activeTextEditor = { document } as TextEditor; + activeNotebookEditor = second; + + assert.throws( + () => getNotebookFromCellEditor(cellEditor), + /split notebook is unsupported/u, + ); + }); + + test("rejects an active notebook that doesn't contain the target", () => { + activeTextEditor = cellEditor; + activeNotebookEditor = createNotebookEditor(ViewColumn.Three, { + uri: Uri.parse("vscode-notebook-cell:/other.ipynb#cell1"), + } as TextDocument); + + assert.throws( + () => getNotebookFromCellEditor(cellEditor), + /split notebook is unsupported/u, + ); + }); + + test("reports when the notebook is no longer visible", () => { + visibleNotebookEditors = []; + + assert.throws( + () => getNotebookFromCellEditor(cellEditor), + /Couldn't find notebook editor/u, + ); + }); + + function createNotebookEditor( + viewColumn: ViewColumn, + cellDocument = document, + ): NotebookEditor { + return { + notebook: { getCells: () => [{ document: cellDocument }] }, + viewColumn, + } as NotebookEditor; + } +}); diff --git a/packages/app-web-docs/src/docs/user/actions/scrollToBottom.mdx b/packages/app-web-docs/src/docs/user/actions/scrollToBottom.mdx index 95b8bf92806..c9ce5fa040d 100644 --- a/packages/app-web-docs/src/docs/user/actions/scrollToBottom.mdx +++ b/packages/app-web-docs/src/docs/user/actions/scrollToBottom.mdx @@ -4,6 +4,8 @@ sidebar_label: Bottom # Bottom (Scroll to bottom) +Scrolls the target to the bottom of the viewport. In a notebook this is not supported due to a limitation in VS Code's notebook API. + Cursorless ID: `scrollToBottom` ## Spoken form diff --git a/packages/app-web-docs/src/docs/user/actions/scrollToCenter.mdx b/packages/app-web-docs/src/docs/user/actions/scrollToCenter.mdx index 423b8f90fe9..22ba3571616 100644 --- a/packages/app-web-docs/src/docs/user/actions/scrollToCenter.mdx +++ b/packages/app-web-docs/src/docs/user/actions/scrollToCenter.mdx @@ -4,6 +4,8 @@ sidebar_label: Center # Center (Scroll to center) +Scrolls the target to the center of the viewport. In a notebook scrolls the cell containing the target to the center of the viewport. + Cursorless ID: `scrollToCenter` ## Spoken form diff --git a/packages/app-web-docs/src/docs/user/actions/scrollToTop.mdx b/packages/app-web-docs/src/docs/user/actions/scrollToTop.mdx index 2ee21f9fe4c..d24c9ea87ef 100644 --- a/packages/app-web-docs/src/docs/user/actions/scrollToTop.mdx +++ b/packages/app-web-docs/src/docs/user/actions/scrollToTop.mdx @@ -4,6 +4,8 @@ sidebar_label: Crown # Crown (Scroll to top) +Scrolls the target to the top of the viewport. In a notebook scrolls the cell containing the target to the top of the viewport. + Cursorless ID: `scrollToTop` ## Spoken form diff --git a/packages/lib-common/src/references/actionReferences.ts b/packages/lib-common/src/references/actionReferences.ts index 8b91c4b2bb4..e38f856e24d 100644 --- a/packages/lib-common/src/references/actionReferences.ts +++ b/packages/lib-common/src/references/actionReferences.ts @@ -936,6 +936,7 @@ export const actionReferences = { scrollToTop: { name: "Scroll to top", defaultSpokenForm: "crown", + description: `Scrolls the target to the top of the viewport. In a notebook scrolls the cell containing the target to the top of the viewport.`, group: { id: "scroll", index: 0 }, syntaxes: [ { @@ -954,6 +955,7 @@ export const actionReferences = { scrollToCenter: { name: "Scroll to center", defaultSpokenForm: "center", + description: `Scrolls the target to the center of the viewport. In a notebook scrolls the cell containing the target to the center of the viewport.`, group: { id: "scroll", index: 1 }, syntaxes: [ { @@ -972,6 +974,7 @@ export const actionReferences = { scrollToBottom: { name: "Scroll to bottom", defaultSpokenForm: "bottom", + description: `Scrolls the target to the bottom of the viewport. In a notebook this is not supported due to a limitation in VS Code's notebook API.`, group: { id: "scroll", index: 2 }, syntaxes: [ { diff --git a/packages/lib-vscode-common/src/notebook.ts b/packages/lib-vscode-common/src/notebook.ts index b029f056e57..60468b0928d 100644 --- a/packages/lib-vscode-common/src/notebook.ts +++ b/packages/lib-vscode-common/src/notebook.ts @@ -1,4 +1,9 @@ -import type { NotebookDocument, NotebookEditor, TextDocument } from "vscode"; +import type { + NotebookDocument, + NotebookEditor, + TextDocument, + TextEditor, +} from "vscode"; import { window } from "vscode"; /** @@ -38,24 +43,49 @@ export function getCellIndex( } /** - * Given a document corresponding to a single cell, retrieve the notebook - * editor for the entire notebook - * @param document The document corresponding to the given cell + * Retrieve the notebook editor containing a cell editor. Reject ambiguous + * split notebook targets when VS Code doesn't expose their owning editor. + * @param cellEditor The text editor corresponding to the given cell * @returns The notebook editor corresponding to the notebook containing the * given cell */ -export function getNotebookFromCellDocument( - document: TextDocument, +export function getNotebookFromCellEditor( + cellEditor: TextEditor, ): NotebookEditor { - const editor = window.visibleNotebookEditors.find((editor) => + const uri = cellEditor.document.uri.toString(); + const editors = window.visibleNotebookEditors.filter((editor) => editor.notebook .getCells() - .some((cell) => cell.document.uri.toString() === document.uri.toString()), + .some((cell) => cell.document.uri.toString() === uri), ); - if (editor == null) { + if (editors.length === 0) { throw new Error("Couldn't find notebook editor for given document"); } - return editor; + if (editors.length === 1) { + return editors[0]; + } + + if (cellEditor.viewColumn != null) { + const editor = editors.find( + (editor) => editor.viewColumn === cellEditor.viewColumn, + ); + if (editor != null) { + return editor; + } + } + + const activeNotebookEditor = window.activeNotebookEditor; + if ( + cellEditor === window.activeTextEditor && + activeNotebookEditor != null && + editors.includes(activeNotebookEditor) + ) { + return activeNotebookEditor; + } + + throw new Error( + "Targeting this cell in a split notebook is unsupported because VS Code doesn't expose which notebook editor contains it.", + ); }