Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 39 additions & 43 deletions packages/app-vscode/src/ide/vscode/VscodeFocusEditor.ts
Original file line number Diff line number Diff line change
@@ -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,
getNotebookFromCellEditor,
isDocumentNotebookCell,
} from "@cursorless/lib-vscode-common";
import type { VscodeTextEditor } from "./VscodeTextEditor";

const columnFocusCommands = {
Expand All @@ -24,36 +27,36 @@ const columnFocusCommands = {
export async function vscodeFocusEditor(
editor: VscodeTextEditor,
): Promise<void> {
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 ||
Expand All @@ -63,41 +66,34 @@ function getViewColumn(editor: TextEditor): ViewColumn | undefined {
return (tab as any).input?.uri?.toString() === uri;
}),
);
return tabGroup?.viewColumn;
}

async function focusNotebookCell(editor: VscodeTextEditor) {
const desiredNotebookEditor = getNotebookFromCellDocument(
editor.vscodeEditor.document,
);
if (desiredNotebookEditor == null) {
throw new Error("Couldn't find notebook editor for given document");
const column = tabGroup?.viewColumn;

if (column == null) {
throw new Error("Couldn't determine view column for editor");
}

const desiredNotebookDocument: NotebookDocument =
desiredNotebookEditor.notebook;
return column;
}

async function focusNotebookCell(editor: VscodeTextEditor) {
const notebookEditor = getNotebookFromCellEditor(editor.vscodeEditor);

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,
}),
];

Expand Down
43 changes: 43 additions & 0 deletions packages/app-vscode/src/ide/vscode/VscodeRevealLine.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import vscode from "vscode";
import { RevealLineAt } from "@cursorless/lib-common";
import {
getCellIndex,
getNotebookFromCellEditor,
isDocumentNotebookCell,
} from "@cursorless/lib-vscode-common";
import type { VscodeTextEditor } from "./VscodeTextEditor";

export async function vscodeRevealLine(
editor: VscodeTextEditor,
lineNumber: number,
at: RevealLineAt,
): Promise<void> {
if (isDocumentNotebookCell(editor.vscodeEditor.document)) {
revealNotebookCell(editor, at);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor the target line when revealing notebook cells

When the target is inside a notebook cell taller than the viewport, this branch discards lineNumber and aligns the entire cell instead. Since Scroll.getLineNumber computes the specific line occupied by the target, crown/center on a token near the bottom of a long cell can leave that token off-screen or at the wrong position; pass the line through and reveal the position within the cell rather than only the cell range.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not supported by the vscode notebook api as far as I can tell

return;
}

// For reveal line to the work we have to have the correct editor focused
if (!editor.isActive) {
await editor.focus();
Expand All @@ -17,3 +27,36 @@ export async function vscodeRevealLine(
at,
});
}

function revealNotebookCell(editor: VscodeTextEditor, at: RevealLineAt) {
const notebookEditor = getNotebookFromCellEditor(editor.vscodeEditor);

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.",
);
Comment on lines +53 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid failing scroll-to-bottom for every notebook target

Whenever scrollToBottom is invoked on a notebook target, this branch throws instead of completing the existing action, so users receive an error for every such command. The lack of a direct notebook reveal enum should be handled with a fallback or approximation rather than making the action fail outright; any unavoidable notebook-specific limitation also needs to be documented.

AGENTS.md reference: AGENTS.md:L32-L32

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no way to make a fall back for this as far as I know

default: {
const _exhaustiveCheck: never = at;
throw new Error(`Unhandled case: ${_exhaustiveCheck}`);
}
}
}
97 changes: 97 additions & 0 deletions packages/app-vscode/src/ide/vscode/notebook.vscode.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}
});
25 changes: 0 additions & 25 deletions packages/app-vscode/src/ide/vscode/notebook/notebook.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/app-web-docs/src/docs/user/actions/scrollToTop.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/lib-common/src/references/actionReferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand All @@ -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: [
{
Expand All @@ -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: [
{
Expand Down
Loading
Loading