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
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ export interface IPositronNotebookCell extends Disposable {
*/
attachContainer(container: HTMLElement): void;

/**
* Get the container that the cell is attached to
*/
get container(): HTMLElement | undefined;

/**
*
* @param editor Code editor widget associated with cell.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@ export abstract class PositronNotebookCellGeneral extends Disposable implements
this._container = container;
}

get container(): HTMLElement | undefined {
return this._container;
}

attachEditor(editor: CodeEditorWidget): void {
this._editor.set(editor, undefined);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -277,13 +277,20 @@ export class PositronNotebookEditor extends AbstractEditorWithViewState<INoteboo
notebookInstance.attachView(this._parentDiv, scopedContextKeyService);
}

override clearInput(): void {
this._logService.info(this._identifier, 'clearInput');
/**
* Called when this composite should receive keyboard focus.
*/
override focus(): void {
super.focus();

if (this.notebookInstance && this._parentDiv) {
this.notebookInstance.detachView();
console.log('isVisible', this._isVisible.get());
// Drive focus into the notebook instance based on selection state
if (this.notebookInstance) {
this.notebookInstance.grabFocus();
}
}

override clearInput(): void {
this._logService.info(this._identifier, 'clearInput');

if (this.notebookInstance) {
this.notebookInstance.detachView();
Expand Down Expand Up @@ -353,7 +360,11 @@ export class PositronNotebookEditor extends AbstractEditorWithViewState<INoteboo
// Create a scoped context key service rooted at the notebook container so cell scopes inherit it.
const scopedContextKeyService = this._containerScopedContextKeyService = this.contextKeyService.createScoped(this._parentDiv);

const reactRenderer: PositronReactRenderer = this._positronReactRenderer ?? new PositronReactRenderer(this._parentDiv);
// Create renderer if it doesn't exist, otherwise reuse existing renderer
if (!this._positronReactRenderer) {
this._positronReactRenderer = new PositronReactRenderer(this._parentDiv);
}
const reactRenderer = this._positronReactRenderer;

reactRenderer.render(
<NotebookVisibilityProvider isVisible={this._isVisible}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,41 @@ export class PositronNotebookInstance extends Disposable implements IPositronNot
this._positronConsoleService.showNotebookConsole(this.uri, true);
}

/**
* Grabs focus for this notebook based on the current selection state.
* Called when the notebook editor receives focus from the workbench.
*
* Note: This method may be called twice during tab switches:
* - First call: Early, cells may not be rendered yet (no-op via optional chaining)
* - Second call: After render completes, focus succeeds
*/
grabFocus(): void {
const state = this.selectionStateMachine.state.get();

switch (state.type) {
case SelectionState.EditingSelection:
// Focus the editor - enterEditor() already has idempotency checks
this.selectionStateMachine.enterEditor(state.selected);
break;

case SelectionState.SingleSelection:
case SelectionState.MultiSelection: {
// Focus the first selected cell's container
// Optional chaining handles undefined containers gracefully
const cell = state.type === SelectionState.SingleSelection
? state.selected
: state.selected[0];
cell.container?.focus();
break;
}

case SelectionState.NoCells:
// Fall back to notebook container
this._container?.focus();
break;
}
}

/**
* Clears the outputs of all cells in the notebook.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickin
import { selectKernelIcon } from '../../notebook/browser/notebookIcons.js';
import { INotebookKernelService, INotebookKernel } from '../../notebook/common/notebookKernelService.js';
import { PositronNotebookInstance } from './PositronNotebookInstance.js';
import { IPositronNotebookService } from './positronNotebookService.js';
import { POSITRON_RUNTIME_NOTEBOOK_KERNELS_EXTENSION_ID } from '../../runtimeNotebookKernel/common/runtimeNotebookKernelConfig.js';
import { IEditorService } from '../../../services/editor/common/editorService.js';
import { getNotebookInstanceFromEditorPane } from './notebookUtils.js';

export const SELECT_KERNEL_ID_POSITRON = 'positronNotebook.selectKernel';
const NOTEBOOK_ACTIONS_CATEGORY_POSITRON = localize2('positronNotebookActions.category', 'Positron Notebook');
Expand All @@ -38,8 +39,7 @@ class SelectPositronNotebookKernelAction extends Action2 {
async run(accessor: ServicesAccessor, context?: SelectPositronNotebookKernelContext): Promise<boolean> {
const { forceDropdown } = context || { forceDropdown: false };
const notebookKernelService = accessor.get(INotebookKernelService);
const notebookService = accessor.get(IPositronNotebookService);
const activeNotebook = notebookService.getActiveInstance();
const activeNotebook = getNotebookInstanceFromEditorPane(accessor.get(IEditorService));
const quickInputService = accessor.get(IQuickInputService);

if (!activeNotebook) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,19 @@
import { Disposable } from '../../../../../../base/common/lifecycle.js';
import { WorkbenchPhase, registerWorkbenchContribution2 } from '../../../../../common/contributions.js';
import { UndoCommand, RedoCommand } from '../../../../../../editor/browser/editorExtensions.js';
import { IPositronNotebookService } from '../../positronNotebookService.js';
import { POSITRON_NOTEBOOK_EDITOR_CONTAINER_FOCUSED, POSITRON_NOTEBOOK_CELL_EDITOR_FOCUSED } from '../../ContextKeysManager.js';
import { IUndoRedoService } from '../../../../../../platform/undoRedo/common/undoRedo.js';
import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js';
import { IEditorService } from '../../../../../services/editor/common/editorService.js';
import { getNotebookInstanceFromEditorPane } from '../../notebookUtils.js';

class PositronNotebookUndoRedoContribution extends Disposable {

static readonly ID = 'workbench.contrib.positronNotebookUndoRedo';

constructor(
@IUndoRedoService private readonly undoRedoService: IUndoRedoService,
@IPositronNotebookService private readonly positronNotebookService: IPositronNotebookService,
@IEditorService private readonly editorService: IEditorService,
@IContextKeyService private readonly contextKeyService: IContextKeyService
) {
super();
Expand All @@ -30,7 +31,7 @@ class PositronNotebookUndoRedoContribution extends Disposable {

private shouldHandleUndoRedo(): boolean {
// Get the active notebook instance to access its scoped context key service
const instance = this.positronNotebookService.getActiveInstance();
const instance = getNotebookInstanceFromEditorPane(this.editorService);
if (!instance) {
return false;
}
Expand Down Expand Up @@ -61,7 +62,7 @@ class PositronNotebookUndoRedoContribution extends Disposable {
return false;
}

const instance = this.positronNotebookService.getActiveInstance();
const instance = getNotebookInstanceFromEditorPane(this.editorService);
if (!instance) {
return false;
}
Expand All @@ -79,7 +80,7 @@ class PositronNotebookUndoRedoContribution extends Disposable {
return false;
}

const instance = this.positronNotebookService.getActiveInstance();
const instance = getNotebookInstanceFromEditorPane(this.editorService);
if (!instance) {
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

import { CommandsRegistry, ICommandMetadata } from '../../../../../../platform/commands/common/commands.js';
import { ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js';
import { IPositronNotebookService } from '../../positronNotebookService.js';
import { IPositronNotebookCell } from '../../PositronNotebookCells/IPositronNotebookCell.js';
import { NotebookCellActionBarRegistry, INotebookCellActionBarItem } from './actionBarRegistry.js';
import { IDisposable, DisposableStore } from '../../../../../../base/common/lifecycle.js';
Expand All @@ -15,6 +14,8 @@ import { IPositronNotebookCommandKeybinding } from './commandUtils.js';
import { IPositronNotebookInstance } from '../../IPositronNotebookInstance.js';
import { getSelectedCell, getSelectedCells, getEditingCell } from '../../selectionMachine.js';
import { ContextKeyExpr, ContextKeyExpression } from '../../../../../../platform/contextkey/common/contextkey.js';
import { IEditorService } from '../../../../../services/editor/common/editorService.js';
import { getNotebookInstanceFromEditorPane } from '../../notebookUtils.js';

/**
* Options for registering a cell command.
Expand Down Expand Up @@ -80,8 +81,8 @@ export function registerCellCommand({
const commandDisposable = CommandsRegistry.registerCommand({
id: commandId,
handler: (accessor: ServicesAccessor) => {
const notebookService = accessor.get(IPositronNotebookService);
const activeNotebook = notebookService.getActiveInstance();
const editorService = accessor.get(IEditorService);
const activeNotebook = getNotebookInstanceFromEditorPane(editorService);
if (!activeNotebook) {
return;
}
Expand Down
28 changes: 28 additions & 0 deletions src/vs/workbench/contrib/positronNotebook/browser/notebookUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2025 Posit Software, PBC. All rights reserved.
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
*--------------------------------------------------------------------------------------------*/

import { IEditorService } from '../../../services/editor/common/editorService.js';
import { IPositronNotebookInstance } from './IPositronNotebookInstance.js';
import { PositronNotebookEditor } from './PositronNotebookEditor.js';
import { POSITRON_NOTEBOOK_EDITOR_ID } from '../common/positronNotebookCommon.js';

/**
* Retrieves the active Positron notebook instance from the editor service.
*
* @param editorService The editor service
* @returns The active notebook instance, or undefined if no Positron notebook is active
*/
export function getNotebookInstanceFromEditorPane(editorService: IEditorService): IPositronNotebookInstance | undefined {
const activeEditorPane = editorService.activeEditorPane;

// Check if the active editor is a Positron Notebook Editor
if (!activeEditorPane || activeEditorPane.getId() !== POSITRON_NOTEBOOK_EDITOR_ID) {
return undefined;
}

// Extract the notebook instance from the editor
const activeNotebook = (activeEditorPane as PositronNotebookEditor).notebookInstance;
return activeNotebook;
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,6 @@ export interface IPositronNotebookService {
*/
listInstances(uri?: URI): Array<IPositronNotebookInstance>;

/**
* Get the currently active notebook instance, if it exists.
*/
getActiveInstance(): IPositronNotebookInstance | null;

/**
* Register a new notebook instance.
* @param instance The instance to register.
Expand Down Expand Up @@ -68,7 +63,6 @@ class PositronNotebookService extends Disposable implements IPositronNotebookSer
constructor(
@IConfigurationService private readonly _configurationService: IConfigurationService
) {
// Call the disposable constrcutor.
super();
}

Expand All @@ -90,10 +84,6 @@ class PositronNotebookService extends Disposable implements IPositronNotebookSer
return instances;
}

public getActiveInstance(): IPositronNotebookInstance | null {
return this._activeInstance;
}

public registerInstance(instance: IPositronNotebookInstance): void {
if (!this._instanceById.has(instance.id)) {
this._instanceById.set(instance.id, instance);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ import { ContextKeyExpr, ContextKeyExpression } from '../../../../platform/conte
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js';
import { PositronActionBarWidgetRegistry } from '../../../../platform/positronActionBar/browser/positronActionBarWidgetRegistry.js';
import { IPositronNotebookService } from './positronNotebookService.js';
import { POSITRON_NOTEBOOK_EDITOR_CONTAINER_FOCUSED } from './ContextKeysManager.js';
import { POSITRON_NOTEBOOK_EDITOR_ID } from '../common/positronNotebookCommon.js';
import { IPositronNotebookInstance } from './IPositronNotebookInstance.js';
import { NotebookInstanceProvider } from './NotebookInstanceProvider.js';
import { IEditorService } from '../../../services/editor/common/editorService.js';
import { getNotebookInstanceFromEditorPane } from './notebookUtils.js';

/**
* Keybinding configuration for notebook actions.
Expand Down Expand Up @@ -260,8 +261,8 @@ function registerNotebookCommandInternal(options: INotebookCommandOptions): IDis
const commandDisposable = CommandsRegistry.registerCommand({
id: options.commandId,
handler: (accessor: ServicesAccessor) => {
const notebookService = accessor.get(IPositronNotebookService);
const activeNotebook = notebookService.getActiveInstance();
const editorService = accessor.get(IEditorService);
const activeNotebook = getNotebookInstanceFromEditorPane(editorService);
if (!activeNotebook) {
return;
}
Expand Down Expand Up @@ -354,11 +355,9 @@ function registerNotebookWidgetInternal(options: INotebookWidgetOptions): IDispo
componentFactory: (accessor) => {
// Return a wrapper component that provides notebook context
return () => {
// Get the active notebook instance from the service
const notebookService = accessor.get(IPositronNotebookService);
const notebook = notebookService.getActiveInstance();

// If no active notebook, don't render anything
// Get the active notebook using the VS Code pattern
const editorService = accessor.get(IEditorService);
const notebook = getNotebookInstanceFromEditorPane(editorService);
if (!notebook) {
return null;
}
Expand Down
8 changes: 4 additions & 4 deletions test/e2e/tests/notebook/notebook-focus-and-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,9 @@ test.describe('Notebook Focus and Selection', {
const { notebooks, notebooksPositron } = app.workbench;
const keyboard = app.code.driver.page.keyboard;

const clickTab = (name: string) => app.code.driver.page.getByRole('tab', { name }).click();
const TAB_1 = 'Untitled-1.ipynb';
const clickTab = (name: string | RegExp) => app.code.driver.page.getByRole('tab', { name }).click();
// Depending on when this test is run, the untitled notebook may have a different number
const TAB_1 = /Untitled-\d+\.ipynb/;
const TAB_2 = 'bitmap-notebook.ipynb';

// Start a new notebook (tab 1)
Expand All @@ -216,9 +217,8 @@ test.describe('Notebook Focus and Selection', {
await notebooksPositron.expectCellIndexToBeSelected(0, { inEditMode: false });
});

// BUG: https://github.com/posit-dev/positron/issues/9849
// Switch between notebooks to ensure selection is preserved
await test.step.skip('Selection is preserved when switching between editors', async () => {
await test.step('Selection is preserved when switching between editors', async () => {
// Switch back to tab 1 and verify selection is still at cell 2
await clickTab(TAB_1);
await notebooksPositron.expectCellIndexToBeSelected(2, { inEditMode: false });
Expand Down