Merge pull request #118482 from microsoft/rebornix/nb-list-focus

Rebornix/nb list focus
This commit is contained in:
Peng Lyu 2021-03-12 11:56:14 -08:00 committed by GitHub
commit 1a5d7f4f8e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 737 additions and 332 deletions

View file

@ -286,28 +286,9 @@ suite('Notebook API tests', function () {
]
});
const secondCell = vscode.window.activeNotebookEditor!.document.cells[1];
const moveCellEvent = asPromise<vscode.NotebookCellsChangeEvent>(vscode.notebook.onDidChangeNotebookCells);
await vscode.commands.executeCommand('notebook.cell.moveUp');
const moveCellEventRet = await moveCellEvent;
assert.deepStrictEqual(moveCellEventRet, {
document: vscode.window.activeNotebookEditor!.document,
changes: [
{
start: 1,
deletedCount: 1,
deletedItems: [secondCell],
items: []
},
{
start: 0,
deletedCount: 0,
deletedItems: [],
items: [vscode.window.activeNotebookEditor?.document.cells[0]]
}
]
});
await moveCellEvent;
const cellOutputChange = asPromise<vscode.NotebookCellOutputsChangeEvent>(vscode.notebook.onDidChangeCellOutputs);
await vscode.commands.executeCommand('notebook.cell.execute');
@ -350,25 +331,7 @@ suite('Notebook API tests', function () {
assert.strictEqual(vscode.window.activeNotebookEditor!.document.cells.indexOf(activeCell!), 0);
const moveChange = asPromise(vscode.notebook.onDidChangeNotebookCells);
await vscode.commands.executeCommand('notebook.cell.moveDown');
const ret = await moveChange;
assert.deepStrictEqual(ret, {
document: vscode.window.activeNotebookEditor?.document,
changes: [
{
start: 0,
deletedCount: 1,
deletedItems: [activeCell],
items: []
},
{
start: 1,
deletedCount: 0,
deletedItems: [],
items: [activeCell]
}
]
});
await moveChange;
await saveAllEditors();
await closeAllEditors();

View file

@ -0,0 +1,244 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { KeyCode, KeyMod } from 'vs/base/common/keyCodes';
import { localize } from 'vs/nls';
import { registerAction2 } from 'vs/platform/actions/common/actions';
import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey';
import { InputFocusedContext } from 'vs/platform/contextkey/common/contextkeys';
import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry';
import { INotebookCellActionContext, NotebookCellAction } from 'vs/workbench/contrib/notebook/browser/contrib/coreActions';
import { expandCellRangesWithHiddenCells, NOTEBOOK_EDITOR_FOCUSED } from 'vs/workbench/contrib/notebook/browser/notebookBrowser';
import * as icons from 'vs/workbench/contrib/notebook/browser/notebookIcons';
import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
import { CellEditType, cellRangeContains, cellRangesToIndexes, SelectionStateType } from 'vs/workbench/contrib/notebook/common/notebookCommon';
import { cloneNotebookCellTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookCellTextModel';
const MOVE_CELL_UP_COMMAND_ID = 'notebook.cell.moveUp';
const MOVE_CELL_DOWN_COMMAND_ID = 'notebook.cell.moveDown';
const COPY_CELL_UP_COMMAND_ID = 'notebook.cell.copyUp';
const COPY_CELL_DOWN_COMMAND_ID = 'notebook.cell.copyDown';
registerAction2(class extends NotebookCellAction {
constructor() {
super(
{
id: MOVE_CELL_UP_COMMAND_ID,
title: localize('notebookActions.moveCellUp', "Move Cell Up"),
icon: icons.moveUpIcon,
keybinding: {
primary: KeyMod.Alt | KeyCode.UpArrow,
when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, InputFocusedContext.toNegated()),
weight: KeybindingWeight.WorkbenchContrib
}
});
}
async runWithContext(accessor: ServicesAccessor, context: INotebookCellActionContext) {
return moveCellRange(context, 'up');
}
});
registerAction2(class extends NotebookCellAction {
constructor() {
super(
{
id: MOVE_CELL_DOWN_COMMAND_ID,
title: localize('notebookActions.moveCellDown', "Move Cell Down"),
icon: icons.moveDownIcon,
keybinding: {
primary: KeyMod.Alt | KeyCode.DownArrow,
when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, InputFocusedContext.toNegated()),
weight: KeybindingWeight.WorkbenchContrib
}
});
}
async runWithContext(accessor: ServicesAccessor, context: INotebookCellActionContext) {
return moveCellRange(context, 'down');
}
});
async function moveCellRange(context: INotebookCellActionContext, direction: 'up' | 'down'): Promise<void> {
const viewModel = context.notebookEditor.viewModel;
if (!viewModel) {
return;
}
if (!viewModel.metadata.editable) {
return;
}
const selections = context.notebookEditor.getSelections();
const modelRanges = expandCellRangesWithHiddenCells(context.notebookEditor, context.notebookEditor.viewModel!, selections);
const range = modelRanges[0];
if (!range || range.start === range.end) {
return;
}
if (direction === 'up') {
if (range.start === 0) {
return;
}
const indexAbove = range.start - 1;
const finalSelection = { start: range.start - 1, end: range.end - 1 };
const focus = context.notebookEditor.getFocus();
const newFocus = cellRangeContains(range, focus) ? { start: focus.start - 1, end: focus.end - 1 } : { start: range.start - 1, end: range.start };
viewModel.notebookDocument.applyEdits([
{
editType: CellEditType.Move,
index: indexAbove,
length: 1,
newIdx: range.end - 1
}],
true,
{
kind: SelectionStateType.Index,
focus: viewModel.getFocus(),
selections: viewModel.getSelections()
},
() => ({ kind: SelectionStateType.Index, focus: newFocus, selections: [finalSelection] }),
undefined
);
const focusRange = viewModel.getSelections()[0] ?? viewModel.getFocus();
context.notebookEditor.revealCellRangeInView(focusRange);
} else {
if (range.end >= viewModel.length) {
return;
}
const indexBelow = range.end;
const finalSelection = { start: range.start + 1, end: range.end + 1 };
const focus = context.notebookEditor.getFocus();
const newFocus = cellRangeContains(range, focus) ? { start: focus.start + 1, end: focus.end + 1 } : { start: range.start + 1, end: range.start + 2 };
viewModel.notebookDocument.applyEdits([
{
editType: CellEditType.Move,
index: indexBelow,
length: 1,
newIdx: range.start
}],
true,
{
kind: SelectionStateType.Index,
focus: viewModel.getFocus(),
selections: viewModel.getSelections()
},
() => ({ kind: SelectionStateType.Index, focus: newFocus, selections: [finalSelection] }),
undefined
);
const focusRange = viewModel.getSelections()[0] ?? viewModel.getFocus();
context.notebookEditor.revealCellRangeInView(focusRange);
}
}
registerAction2(class extends NotebookCellAction {
constructor() {
super(
{
id: COPY_CELL_UP_COMMAND_ID,
title: localize('notebookActions.copyCellUp', "Copy Cell Up"),
keybinding: {
primary: KeyMod.Alt | KeyMod.Shift | KeyCode.UpArrow,
when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, InputFocusedContext.toNegated()),
weight: KeybindingWeight.WorkbenchContrib
}
});
}
async runWithContext(accessor: ServicesAccessor, context: INotebookCellActionContext) {
return copyCellRange(context, 'up');
}
});
registerAction2(class extends NotebookCellAction {
constructor() {
super(
{
id: COPY_CELL_DOWN_COMMAND_ID,
title: localize('notebookActions.copyCellDown', "Copy Cell Down"),
keybinding: {
primary: KeyMod.Alt | KeyMod.Shift | KeyCode.DownArrow,
when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, InputFocusedContext.toNegated()),
weight: KeybindingWeight.WorkbenchContrib
}
});
}
async runWithContext(accessor: ServicesAccessor, context: INotebookCellActionContext) {
return copyCellRange(context, 'down');
}
});
async function copyCellRange(context: INotebookCellActionContext, direction: 'up' | 'down'): Promise<void> {
const viewModel = context.notebookEditor.viewModel;
if (!viewModel) {
return;
}
if (!viewModel.metadata.editable) {
return;
}
const selections = context.notebookEditor.getSelections();
const modelRanges = expandCellRangesWithHiddenCells(context.notebookEditor, context.notebookEditor.viewModel!, selections);
const range = modelRanges[0];
if (!range || range.start === range.end) {
return;
}
if (direction === 'up') {
// insert up, without changing focus and selections
const focus = viewModel.getFocus();
const selections = viewModel.getSelections();
viewModel.notebookDocument.applyEdits([
{
editType: CellEditType.Replace,
index: range.end,
count: 0,
cells: cellRangesToIndexes([range]).map(index => cloneNotebookCellTextModel(viewModel.viewCells[index].model))
}],
true,
{
kind: SelectionStateType.Index,
focus: focus,
selections: selections
},
() => ({ kind: SelectionStateType.Index, focus: focus, selections: selections }),
undefined
);
} else {
// insert down, move selections
const focus = viewModel.getFocus();
const selections = viewModel.getSelections();
const newCells = cellRangesToIndexes([range]).map(index => cloneNotebookCellTextModel(viewModel.viewCells[index].model));
const countDelta = newCells.length;
const newFocus = { start: focus.start + countDelta, end: focus.end + countDelta };
const newSelections = [{ start: range.start + countDelta, end: range.end + countDelta }];
viewModel.notebookDocument.applyEdits([
{
editType: CellEditType.Replace,
index: range.end,
count: 0,
cells: cellRangesToIndexes([range]).map(index => cloneNotebookCellTextModel(viewModel.viewCells[index].model))
}],
true,
{
kind: SelectionStateType.Index,
focus: focus,
selections: selections
},
() => ({ kind: SelectionStateType.Index, focus: newFocus, selections: newSelections }),
undefined
);
const focusRange = viewModel.getSelections()[0] ?? viewModel.getFocus();
context.notebookEditor.revealCellRangeInView(focusRange);
}
}

View file

@ -8,13 +8,12 @@ import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle
import { Registry } from 'vs/platform/registry/common/platform';
import { Extensions as WorkbenchExtensions, IWorkbenchContributionsRegistry } from 'vs/workbench/common/contributions';
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
import { getNotebookEditorFromEditorPane, ICellViewModel } from 'vs/workbench/contrib/notebook/browser/notebookBrowser';
import * as UUID from 'vs/base/common/uuid';
import { expandCellRangesWithHiddenCells, getNotebookEditorFromEditorPane, ICellViewModel } from 'vs/workbench/contrib/notebook/browser/notebookBrowser';
import { CopyAction, CutAction, PasteAction } from 'vs/editor/contrib/clipboard/clipboard';
import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService';
import { CellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookViewModel';
import { NotebookCellTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookCellTextModel';
import { CellEditType, ICellEditOperation, SelectionStateType } from 'vs/workbench/contrib/notebook/common/notebookCommon';
import { CellViewModel, NotebookViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookViewModel';
import { cloneNotebookCellTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookCellTextModel';
import { CellEditType, ICellEditOperation, ICellRange, SelectionStateType } from 'vs/workbench/contrib/notebook/common/notebookCommon';
import { INotebookService } from 'vs/workbench/contrib/notebook/common/notebookService';
class NotebookClipboardContribution extends Disposable {
@ -46,6 +45,10 @@ class NotebookClipboardContribution extends Disposable {
return false;
}
if (!editor.hasModel()) {
return false;
}
if (editor.hasOutputTextSelection()) {
document.execCommand('copy');
return true;
@ -53,7 +56,8 @@ class NotebookClipboardContribution extends Disposable {
const clipboardService = accessor.get<IClipboardService>(IClipboardService);
const notebookService = accessor.get<INotebookService>(INotebookService);
const selectedCells = editor.getSelectionViewModels();
const selectionRanges = expandCellRangesWithHiddenCells(editor, editor.viewModel, editor.viewModel.getSelections());
const selectedCells = this._cellRangeToViewCells(editor.viewModel, selectionRanges);
if (!selectedCells.length) {
return false;
@ -91,35 +95,11 @@ class NotebookClipboardContribution extends Disposable {
return false;
}
const cloneMetadata = (cell: NotebookCellTextModel) => {
return {
editable: cell.metadata?.editable,
breakpointMargin: cell.metadata?.breakpointMargin,
hasExecutionOrder: cell.metadata?.hasExecutionOrder,
inputCollapsed: cell.metadata?.inputCollapsed,
outputCollapsed: cell.metadata?.outputCollapsed,
custom: cell.metadata?.custom
};
};
const cloneCell = (cell: NotebookCellTextModel) => {
return {
source: cell.getValue(),
language: cell.language,
cellKind: cell.cellKind,
outputs: cell.outputs.map(output => ({
outputs: output.outputs,
/* paste should generate new outputId */ outputId: UUID.generateUuid()
})),
metadata: cloneMetadata(cell)
};
};
if (activeCell) {
const currCellIndex = viewModel.getCellIndex(activeCell);
let topPastedCell: CellViewModel | undefined = undefined;
pasteCells.items.reverse().map(cell => cloneCell(cell)).forEach(pasteCell => {
pasteCells.items.reverse().map(cell => cloneNotebookCellTextModel(cell)).forEach(pasteCell => {
const newIdx = typeof currCellIndex === 'number' ? currCellIndex + 1 : 0;
topPastedCell = viewModel.createCell(newIdx, pasteCell.source, pasteCell.language, pasteCell.cellKind, pasteCell.metadata, pasteCell.outputs, true);
});
@ -133,7 +113,7 @@ class NotebookClipboardContribution extends Disposable {
}
let topPastedCell: CellViewModel | undefined = undefined;
pasteCells.items.reverse().map(cell => cloneCell(cell)).forEach(pasteCell => {
pasteCells.items.reverse().map(cell => cloneNotebookCellTextModel(cell)).forEach(pasteCell => {
topPastedCell = viewModel.createCell(0, pasteCell.source, pasteCell.language, pasteCell.cellKind, pasteCell.metadata, pasteCell.outputs, true);
});
@ -167,23 +147,30 @@ class NotebookClipboardContribution extends Disposable {
const clipboardService = accessor.get<IClipboardService>(IClipboardService);
const notebookService = accessor.get<INotebookService>(INotebookService);
const selectedCells = editor.getSelectionViewModels();
const selectionRanges = expandCellRangesWithHiddenCells(editor, viewModel, viewModel.getSelections());
const selectedCells = this._cellRangeToViewCells(viewModel, selectionRanges);
if (!selectedCells.length) {
return false;
}
clipboardService.writeText(selectedCells.map(cell => cell.getText()).join('\n'));
const selectionIndexes = selectedCells.map(cell => [cell, viewModel.getCellIndex(cell)] as [ICellViewModel, number]).sort((a, b) => b[1] - a[1]);
const edits: ICellEditOperation[] = selectionIndexes.map(value => ({ editType: CellEditType.Replace, index: value[1], count: 1, cells: [] }));
const firstSelectIndex = selectionIndexes.sort((a, b) => a[1] - b[1])[0][1];
const edits: ICellEditOperation[] = selectionRanges.map(range => ({ editType: CellEditType.Replace, index: range.start, count: range.end - range.start, cells: [] }));
const firstSelectIndex = selectionRanges[0].start;
/**
* If we have cells, 0, 1, 2, 3, 4, 5, 6
* and cells 1, 2 are selected, and then we delete cells 1 and 2
* the new focused cell should still be at index 1
*/
const newFocusedCellIndex = firstSelectIndex < viewModel.notebookDocument.cells.length
? firstSelectIndex
: viewModel.notebookDocument.cells.length - 1;
viewModel.notebookDocument.applyEdits(edits, true, { kind: SelectionStateType.Index, selections: viewModel.getSelections() }, () => {
viewModel.notebookDocument.applyEdits(edits, true, { kind: SelectionStateType.Index, focus: viewModel.getFocus(), selections: selectionRanges }, () => {
return {
kind: SelectionStateType.Index,
focus: { start: newFocusedCellIndex, end: newFocusedCellIndex + 1 },
selections: [{ start: newFocusedCellIndex, end: newFocusedCellIndex + 1 }]
};
}, undefined, true);
@ -193,6 +180,15 @@ class NotebookClipboardContribution extends Disposable {
});
}
}
private _cellRangeToViewCells(viewModel: NotebookViewModel, ranges: ICellRange[]) {
const cells: ICellViewModel[] = [];
ranges.forEach(range => {
cells.push(...viewModel.viewCells.slice(range.start, range.end));
});
return cells;
}
}
const workbenchContributionsRegistry = Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench);

View file

@ -59,14 +59,10 @@ const EDIT_CELL_COMMAND_ID = 'notebook.cell.edit';
const QUIT_EDIT_CELL_COMMAND_ID = 'notebook.cell.quitEdit';
const DELETE_CELL_COMMAND_ID = 'notebook.cell.delete';
const MOVE_CELL_UP_COMMAND_ID = 'notebook.cell.moveUp';
const MOVE_CELL_DOWN_COMMAND_ID = 'notebook.cell.moveDown';
const COPY_CELL_COMMAND_ID = 'notebook.cell.copy';
const CUT_CELL_COMMAND_ID = 'notebook.cell.cut';
const PASTE_CELL_COMMAND_ID = 'notebook.cell.paste';
const PASTE_CELL_ABOVE_COMMAND_ID = 'notebook.cell.pasteAbove';
const COPY_CELL_UP_COMMAND_ID = 'notebook.cell.copyUp';
const COPY_CELL_DOWN_COMMAND_ID = 'notebook.cell.copyDown';
const SPLIT_CELL_COMMAND_ID = 'notebook.cell.split';
const JOIN_CELL_ABOVE_COMMAND_ID = 'notebook.cell.joinAbove';
const JOIN_CELL_BELOW_COMMAND_ID = 'notebook.cell.joinBelow';
@ -223,7 +219,7 @@ abstract class NotebookAction extends Action2 {
}
}
abstract class NotebookCellAction<T = INotebookCellActionContext> extends NotebookAction {
export abstract class NotebookCellAction<T = INotebookCellActionContext> extends NotebookAction {
protected isCellActionContext(context?: unknown): context is INotebookCellActionContext {
return !!context && !!(context as INotebookCellActionContext).notebookEditor && !!(context as INotebookCellActionContext).cell;
}
@ -1038,66 +1034,6 @@ registerAction2(class extends NotebookCellAction {
}
});
async function moveCell(context: INotebookCellActionContext, direction: 'up' | 'down'): Promise<void> {
const result = direction === 'up' ?
await context.notebookEditor.moveCellUp(context.cell) :
await context.notebookEditor.moveCellDown(context.cell);
if (result) {
// move cell command only works when the cell container has focus
context.notebookEditor.focusNotebookCell(result, 'container');
}
}
async function copyCell(context: INotebookCellActionContext, direction: 'up' | 'down'): Promise<void> {
const text = context.cell.getText();
const newCellDirection = direction === 'up' ? 'above' : 'below';
const newCell = context.notebookEditor.insertNotebookCell(context.cell, context.cell.cellKind, newCellDirection, text);
if (newCell) {
context.notebookEditor.focusNotebookCell(newCell, 'container');
}
}
registerAction2(class extends NotebookCellAction {
constructor() {
super(
{
id: MOVE_CELL_UP_COMMAND_ID,
title: localize('notebookActions.moveCellUp', "Move Cell Up"),
icon: icons.moveUpIcon,
keybinding: {
primary: KeyMod.Alt | KeyCode.UpArrow,
when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, InputFocusedContext.toNegated()),
weight: KeybindingWeight.WorkbenchContrib
}
});
}
async runWithContext(accessor: ServicesAccessor, context: INotebookCellActionContext) {
return moveCell(context, 'up');
}
});
registerAction2(class extends NotebookCellAction {
constructor() {
super(
{
id: MOVE_CELL_DOWN_COMMAND_ID,
title: localize('notebookActions.moveCellDown', "Move Cell Down"),
icon: icons.moveDownIcon,
keybinding: {
primary: KeyMod.Alt | KeyCode.DownArrow,
when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, InputFocusedContext.toNegated()),
weight: KeybindingWeight.WorkbenchContrib
}
});
}
async runWithContext(accessor: ServicesAccessor, context: INotebookCellActionContext) {
return moveCell(context, 'down');
}
});
registerAction2(class extends NotebookCellAction {
constructor() {
super(
@ -1287,44 +1223,6 @@ registerAction2(class extends NotebookCellAction {
}
});
registerAction2(class extends NotebookCellAction {
constructor() {
super(
{
id: COPY_CELL_UP_COMMAND_ID,
title: localize('notebookActions.copyCellUp', "Copy Cell Up"),
keybinding: {
primary: KeyMod.Alt | KeyMod.Shift | KeyCode.UpArrow,
when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, InputFocusedContext.toNegated()),
weight: KeybindingWeight.WorkbenchContrib
}
});
}
async runWithContext(accessor: ServicesAccessor, context: INotebookCellActionContext) {
return copyCell(context, 'up');
}
});
registerAction2(class extends NotebookCellAction {
constructor() {
super(
{
id: COPY_CELL_DOWN_COMMAND_ID,
title: localize('notebookActions.copyCellDown', "Copy Cell Down"),
keybinding: {
primary: KeyMod.Alt | KeyMod.Shift | KeyCode.DownArrow,
when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, InputFocusedContext.toNegated()),
weight: KeybindingWeight.WorkbenchContrib
}
});
}
async runWithContext(accessor: ServicesAccessor, context: INotebookCellActionContext) {
return copyCell(context, 'down');
}
});
registerAction2(class extends NotebookCellAction {
constructor() {
super({

View file

@ -390,8 +390,8 @@ export class NotebookCellOutline implements IOutline<OutlineEntry> {
includeCodeCells = this._configurationService.getValue<boolean>('notebook.breadcrumbs.showCodeCells');
}
const selectedCellIndex = viewModel.getSelection().start;
const selected = viewModel.getCellByIndex(selectedCellIndex)?.handle;
const focusedCellIndex = viewModel.getFocus().start;
const focused = viewModel.getCellByIndex(focusedCellIndex)?.handle;
const entries: OutlineEntry[] = [];
for (let i = 0; i < viewModel.viewCells.length; i++) {
@ -434,7 +434,7 @@ export class NotebookCellOutline implements IOutline<OutlineEntry> {
entries.push(new OutlineEntry(entries.length, 7, cell, preview, isMarkdown ? Codicon.markdown : Codicon.code));
}
if (cell.handle === selected) {
if (cell.handle === focused) {
this._activeEntry = entries[entries.length - 1];
}
@ -518,7 +518,7 @@ export class NotebookCellOutline implements IOutline<OutlineEntry> {
const { viewModel } = this._editor;
if (viewModel) {
const cell = viewModel.getCellByIndex(viewModel.getSelection().start);
const cell = viewModel.getCellByIndex(viewModel.getFocus().start);
if (cell) {
for (let entry of this._entries) {
newActive = entry.find(cell, []);

View file

@ -66,6 +66,7 @@ import 'vs/workbench/contrib/notebook/browser/contrib/marker/markerProvider';
import 'vs/workbench/contrib/notebook/browser/contrib/outline/notebookOutline';
import 'vs/workbench/contrib/notebook/browser/contrib/status/editorStatus';
import 'vs/workbench/contrib/notebook/browser/contrib/undoRedo/notebookUndoRedo';
import 'vs/workbench/contrib/notebook/browser/contrib/cellOperations/cellOperations';
import 'vs/workbench/contrib/notebook/browser/contrib/viewportCustomMarkdown/viewportCustomMarkdown';

View file

@ -22,7 +22,7 @@ import { OutputRenderer } from 'vs/workbench/contrib/notebook/browser/view/outpu
import { RunStateRenderer, TimerRenderer } from 'vs/workbench/contrib/notebook/browser/view/renderers/cellRenderer';
import { CellViewModel, IModelDecorationsChangeAccessor, NotebookViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookViewModel';
import { NotebookCellTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookCellTextModel';
import { CellKind, NotebookCellMetadata, NotebookDocumentMetadata, INotebookKernel, ICellRange, IOrderedMimeType, INotebookRendererInfo, ICellOutput, IOutputItemDto } from 'vs/workbench/contrib/notebook/common/notebookCommon';
import { CellKind, NotebookCellMetadata, NotebookDocumentMetadata, INotebookKernel, ICellRange, IOrderedMimeType, INotebookRendererInfo, ICellOutput, IOutputItemDto, cellRangesToIndexes, reduceRanges } from 'vs/workbench/contrib/notebook/common/notebookCommon';
import { Webview } from 'vs/workbench/contrib/webview/browser/webview';
import { NotebookTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookTextModel';
import { IMenu } from 'vs/platform/actions/common/actions';
@ -231,6 +231,7 @@ export interface ICellViewModel extends IGenericCellViewModel {
readonly model: NotebookCellTextModel;
readonly id: string;
readonly textBuffer: IReadonlyTextBuffer;
readonly layoutInfo: { totalHeight: number; };
dragging: boolean;
handle: number;
uri: URI;
@ -318,8 +319,7 @@ export interface INotebookEditorCreationOptions {
export interface IActiveNotebookEditor extends INotebookEditor {
viewModel: NotebookViewModel;
// selection is never undefined when the editor is attached to a document.
getSelection(): ICellRange;
getFocus(): ICellRange;
}
export const NOTEBOOK_EDITOR_ID = 'workbench.editor.notebook';
@ -331,7 +331,6 @@ export interface INotebookEditor extends ICommonNotebookEditor {
// from the old IEditor
readonly onDidChangeVisibleRanges: Event<void>;
readonly onDidChangeSelection: Event<void>;
getSelection(): ICellRange | undefined;
getSelections(): ICellRange[];
visibleRanges: ICellRange[];
textModel?: NotebookTextModel;
@ -571,6 +570,23 @@ export interface INotebookEditor extends ICommonNotebookEditor {
*/
revealRangeInCenterIfOutsideViewportAsync(cell: ICellViewModel, range: Range): Promise<void>;
/**
* Get the view index of a cell
*/
getViewIndex(cell: ICellViewModel): number;
/**
* @param startIndex Inclusive
* @param endIndex Exclusive
*/
getCellRangeFromViewRange(startIndex: number, endIndex: number): ICellRange | undefined;
/**
* @param startIndex Inclusive
* @param endIndex Exclusive
*/
getCellsFromViewRange(startIndex: number, endIndex: number): ICellViewModel[];
/**
* Set hidden areas on cell text models.
*/
@ -640,6 +656,9 @@ export interface INotebookCellList {
attachViewModel(viewModel: NotebookViewModel): void;
clear(): void;
getViewIndex(cell: ICellViewModel): number | undefined;
getViewIndex2(modelIndex: number): number | undefined;
getModelIndex(cell: CellViewModel): number | undefined;
getModelIndex2(viewIndex: number): number | undefined;
getVisibleRangesPlusViewportAboveBelow(): ICellRange[];
focusElement(element: ICellViewModel): void;
selectElement(element: ICellViewModel): void;
@ -883,3 +902,30 @@ export function updateEditorTopPadding(top: number) {
export function getEditorTopPadding() {
return EDITOR_TOP_PADDING;
}
export function expandCellRangesWithHiddenCells(editor: INotebookEditor, viewModel: NotebookViewModel, ranges: ICellRange[]) {
// assuming ranges are sorted and no overlap
const indexes = cellRangesToIndexes(ranges);
let modelRanges: ICellRange[] = [];
indexes.forEach(index => {
const viewCell = viewModel.viewCells[index];
if (!viewCell) {
return;
}
const viewIndex = editor.getViewIndex(viewCell);
if (viewIndex < 0) {
return;
}
const nextViewIndex = viewIndex + 1;
const range = editor.getCellRangeFromViewRange(viewIndex, nextViewIndex);
if (range) {
modelRanges.push(range);
}
});
return reduceRanges(modelRanges);
}

View file

@ -319,8 +319,8 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor
return this.viewModel?.getSelections() ?? [];
}
getSelection() {
return this.viewModel?.getSelection();
getFocus() {
return this.viewModel?.getFocus() ?? { start: 0, end: 0 };
}
getSelectionViewModels(): ICellViewModel[] {
@ -424,6 +424,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor
keyboardSupport: false,
mouseSupport: true,
multipleSelectionSupport: true,
selectionNavigation: true,
enableKeyboardNavigation: true,
additionalScrollHeight: 0,
transformOptimization: false, //(isMacintosh && isNative) || getTitleBarStyle(this.configurationService, this.environmentService) === 'native',
@ -1039,6 +1040,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor
} else if (this._list.length > 0) {
this.viewModel?.updateSelectionsState({
kind: SelectionStateType.Index,
focus: { start: 0, end: 1 },
selections: [{ start: 0, end: 1 }]
});
}
@ -1283,6 +1285,49 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor
return this._list.revealElementRangeInCenterIfOutsideViewportAsync(cell, range);
}
getViewIndex(cell: ICellViewModel): number {
if (!this._list) {
return -1;
}
return this._list.getViewIndex(cell) ?? -1;
}
getCellRangeFromViewRange(startIndex: number, endIndex: number): ICellRange | undefined {
if (!this.viewModel) {
return undefined;
}
const modelIndex = this._list.getModelIndex2(startIndex);
if (modelIndex === undefined) {
throw new Error(`startIndex ${startIndex} out of boundary`);
}
if (endIndex >= this._list.length) {
// it's the end
const endModelIndex = this.viewModel.length;
return { start: modelIndex, end: endModelIndex };
} else {
const endModelIndex = this._list.getModelIndex2(endIndex);
if (endModelIndex === undefined) {
throw new Error(`endIndex ${endIndex} out of boundary`);
}
return { start: modelIndex, end: endModelIndex };
}
}
getCellsFromViewRange(startIndex: number, endIndex: number): ICellViewModel[] {
if (!this.viewModel) {
return [];
}
const range = this.getCellRangeFromViewRange(startIndex, endIndex);
if (!range) {
return [];
}
return this.viewModel.viewCells.slice(range.start, range.end);
}
setCellEditorSelection(cell: ICellViewModel, range: Range): void {
this._list.setCellSelection(cell, range);
}

View file

@ -293,7 +293,7 @@ export class NotebookCellList extends WorkbenchList<CellViewModel> implements ID
// convert model selections to view selections
const viewSelections = cellRangesToIndexes(model.getSelections()).map(index => model.getCellByIndex(index)).filter(cell => !!cell).map(cell => this._getViewIndexUpperBound(cell!));
this.setSelection(viewSelections, undefined, true);
const primary = cellRangesToIndexes([model.getSelection()]).map(index => model.getCellByIndex(index)).filter(cell => !!cell).map(cell => this._getViewIndexUpperBound(cell!));
const primary = cellRangesToIndexes([model.getFocus()]).map(index => model.getCellByIndex(index)).filter(cell => !!cell).map(cell => this._getViewIndexUpperBound(cell!));
if (primary.length) {
this.setFocus(primary, undefined, true);
@ -363,6 +363,8 @@ export class NotebookCellList extends WorkbenchList<CellViewModel> implements ID
}
if (!hasDifference) {
// they call 'setHiddenAreas' for a reason, even if the ranges are still the same, it's possible that the hiddenRangeSum is not update to date
this._updateHiddenRangePrefixSum(newRanges);
return false;
}
}
@ -373,6 +375,16 @@ export class NotebookCellList extends WorkbenchList<CellViewModel> implements ID
this._hiddenRangeIds = hiddenAreaIds;
// set hidden ranges prefix sum
this._updateHiddenRangePrefixSum(newRanges);
if (triggerViewUpdate) {
this.updateHiddenAreasInView(oldRanges, newRanges);
}
return true;
}
private _updateHiddenRangePrefixSum(newRanges: ICellRange[]) {
let start = 0;
let index = 0;
const ret: number[] = [];
@ -397,12 +409,6 @@ export class NotebookCellList extends WorkbenchList<CellViewModel> implements ID
}
this.hiddenRangesPrefixSum = new PrefixSumComputer(values);
if (triggerViewUpdate) {
this.updateHiddenAreasInView(oldRanges, newRanges);
}
return true;
}
/**
@ -445,12 +451,30 @@ export class NotebookCellList extends WorkbenchList<CellViewModel> implements ID
if (!selectionsLeft.length && this._viewModel!.viewCells.length) {
// after splice, the selected cells are deleted
this._viewModel!.updateSelectionsState({ kind: SelectionStateType.Index, selections: [{ start: 0, end: 1 }] });
this._viewModel!.updateSelectionsState({ kind: SelectionStateType.Index, focus: { start: 0, end: 1 }, selections: [{ start: 0, end: 1 }] });
}
}
getModelIndex(cell: CellViewModel): number | undefined {
const viewIndex = this.indexOf(cell);
return this.getModelIndex2(viewIndex);
}
getModelIndex2(viewIndex: number): number | undefined {
if (!this.hiddenRangesPrefixSum) {
return viewIndex;
}
const modelIndex = this.hiddenRangesPrefixSum.getAccumulatedValue(viewIndex - 1);
return modelIndex;
}
getViewIndex(cell: ICellViewModel) {
const modelIndex = this._viewModel!.getCellIndex(cell);
return this.getViewIndex2(modelIndex);
}
getViewIndex2(modelIndex: number): number | undefined {
if (!this.hiddenRangesPrefixSum) {
return modelIndex;
}
@ -598,30 +622,12 @@ export class NotebookCellList extends WorkbenchList<CellViewModel> implements ID
focusNext(n: number | undefined, loop: boolean | undefined, browserEvent?: UIEvent, filter?: (element: CellViewModel) => boolean): void {
this._focusNextPreviousDelegate.onFocusNext(() => {
super.focusNext(n, loop, browserEvent, filter);
const focus = this.getFocus();
if (focus.length) {
const focusedElementHandle = this.element(focus[0]).handle;
this._viewModel?.updateSelectionsState({
kind: SelectionStateType.Handle,
primary: focusedElementHandle,
selections: [focusedElementHandle]
}, 'view');
}
});
}
focusPrevious(n: number | undefined, loop: boolean | undefined, browserEvent?: UIEvent, filter?: (element: CellViewModel) => boolean): void {
this._focusNextPreviousDelegate.onFocusPrevious(() => {
super.focusPrevious(n, loop, browserEvent, filter);
const focus = this.getFocus();
if (focus.length) {
const focusedElementHandle = this.element(focus[0]).handle;
this._viewModel?.updateSelectionsState({
kind: SelectionStateType.Handle,
primary: focusedElementHandle,
selections: [focusedElementHandle]
}, 'view');
}
});
}
@ -701,7 +707,7 @@ export class NotebookCellList extends WorkbenchList<CellViewModel> implements ID
const endElementHeight = this.view.elementHeight(endIndex);
if (endElementTop >= wrapperBottom) {
return this._revealInternal(startIndex, false, CellRevealPosition.Top);
return this._revealInternal(endIndex, false, CellRevealPosition.Bottom);
}
if (endElementTop < wrapperBottom) {

View file

@ -76,9 +76,8 @@ export class CodeCell extends Disposable {
});
const updateForFocusMode = () => {
if (this.notebookEditor.getSelection().start !== this.notebookEditor.viewModel.getCellIndex(viewCell)) {
if (this.notebookEditor.getFocus().start !== this.notebookEditor.viewModel.getCellIndex(viewCell)) {
templateData.container.classList.toggle('cell-editor-focus', viewCell.focusMode === CellFocusMode.Editor);
return;
}
if (viewCell.focusMode === CellFocusMode.Editor) {

View file

@ -32,7 +32,7 @@ export class NotebookCellSelectionCollection extends Disposable {
private _primary: ICellRange | null = null;
private _selections: ICellRange[] = [{ start: 0, end: 0 }];
private _selections: ICellRange[] = [];
get selections(): ICellRange[] {
return this._selections;
@ -42,36 +42,17 @@ export class NotebookCellSelectionCollection extends Disposable {
return this._selections[0];
}
get focus(): ICellRange {
return this._primary ?? { start: 0, end: 0 };
}
setState(primary: ICellRange | null, selections: ICellRange[], forceEventEmit: boolean, source: 'view' | 'model') {
if (primary !== null) {
const primaryRange = primary;
// TODO@rebornix deal with overlap
const newSelections = [primaryRange, ...selections.filter(selection => !(selection.start === primaryRange.start && selection.end === primaryRange.end)).sort((a, b) => a.start - b.start)];
const changed = primary !== this._primary || !rangesEqual(this._selections, selections);
const changed = primary !== this._primary || !rangesEqual(this._selections, newSelections);
this._primary = primary;
this._selections = newSelections;
if (!this._selections.length) {
this._selections.push({ start: 0, end: 0 });
}
if (changed || forceEventEmit) {
this._onDidChangeSelection.fire(source);
}
} else {
const changed = primary !== this._primary || !rangesEqual(this._selections, selections);
this._primary = primary;
this._selections = selections;
if (!this._selections.length) {
this._selections.push({ start: 0, end: 0 });
}
if (changed || forceEventEmit) {
this._onDidChangeSelection.fire(source);
}
this._primary = primary;
this._selections = selections;
if (changed || forceEventEmit) {
this._onDidChangeSelection.fire(source);
}
}

View file

@ -24,7 +24,7 @@ import { NotebookEventDispatcher, NotebookMetadataChangedEvent } from 'vs/workbe
import { CellFoldingState, EditorFoldingStateDelegate } from 'vs/workbench/contrib/notebook/browser/contrib/fold/foldingModel';
import { MarkdownCellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/markdownCellViewModel';
import { NotebookCellTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookCellTextModel';
import { CellKind, NotebookCellMetadata, INotebookSearchOptions, ICellRange, NotebookCellsChangeType, ICell, NotebookCellTextModelSplice, CellEditType, IOutputDto, SelectionStateType, ISelectionState, cellIndexesToRanges, cellRangesToIndexes } from 'vs/workbench/contrib/notebook/common/notebookCommon';
import { CellKind, NotebookCellMetadata, INotebookSearchOptions, ICellRange, NotebookCellsChangeType, ICell, NotebookCellTextModelSplice, CellEditType, IOutputDto, SelectionStateType, ISelectionState, cellIndexesToRanges, cellRangesToIndexes, reduceRanges } from 'vs/workbench/contrib/notebook/common/notebookCommon';
import { FoldingRegions } from 'vs/editor/contrib/folding/foldingRanges';
import { NotebookTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookTextModel';
import { MarkdownRenderer } from 'vs/editor/browser/core/markdownRenderer';
@ -353,8 +353,8 @@ export class NotebookViewModel extends Disposable implements EditorFoldingStateD
});
}
getSelection() {
return this._selectionCollection.selection;
getFocus() {
return this._selectionCollection.focus;
}
getSelections() {
@ -396,13 +396,13 @@ export class NotebookViewModel extends Disposable implements EditorFoldingStateD
const selections = cellIndexesToRanges(state.selections.map(sel => this.getCellIndexByHandle(sel)))
.map(range => this.validateRange(range))
.filter(range => range !== null) as ICellRange[];
this._selectionCollection.setState(primarySelection, selections, true, source);
this._selectionCollection.setState(primarySelection, reduceRanges(selections), true, source);
} else {
const primarySelection = this.validateRange(state.selections[0]);
const primarySelection = this.validateRange(state.focus);
const selections = state.selections
.map(range => this.validateRange(range))
.filter(range => range !== null) as ICellRange[];
this._selectionCollection.setState(primarySelection, selections, true, source);
this._selectionCollection.setState(primarySelection, reduceRanges(selections), true, source);
}
}
}
@ -715,14 +715,14 @@ export class NotebookViewModel extends Disposable implements EditorFoldingStateD
}
deleteCell(index: number, synchronous: boolean, pushUndoStop: boolean = true) {
const primarySelectionIndex = this.getSelection()?.start ?? null;
const focusSelectionIndex = this.getFocus()?.start ?? null;
let endPrimarySelection: number | null = null;
if (index === primarySelectionIndex) {
if (primarySelectionIndex < this.length - 1) {
endPrimarySelection = this._viewCells[primarySelectionIndex + 1].handle;
} else if (primarySelectionIndex === this.length - 1 && this.length > 1) {
endPrimarySelection = this._viewCells[primarySelectionIndex - 1].handle;
if (index === focusSelectionIndex) {
if (focusSelectionIndex < this.length - 1) {
endPrimarySelection = this._viewCells[focusSelectionIndex + 1].handle;
} else if (focusSelectionIndex === this.length - 1 && this.length > 1) {
endPrimarySelection = this._viewCells[focusSelectionIndex - 1].handle;
}
}
@ -736,7 +736,7 @@ export class NotebookViewModel extends Disposable implements EditorFoldingStateD
cells: []
}],
synchronous,
{ kind: SelectionStateType.Index, selections: this.getSelections() },
{ kind: SelectionStateType.Index, focus: this.getFocus(), selections: this.getSelections() },
() => ({ kind: SelectionStateType.Handle, primary: endPrimarySelection, selections: endSelections }),
undefined,
pushUndoStop
@ -764,7 +764,7 @@ export class NotebookViewModel extends Disposable implements EditorFoldingStateD
length,
newIdx
}
], synchronous, { kind: SelectionStateType.Index, selections: this.getSelections() }, () => ({ kind: SelectionStateType.Index, selections: [{ start: newIdx, end: newIdx + 1 }] }), undefined);
], synchronous, { kind: SelectionStateType.Index, focus: this.getFocus(), selections: this.getSelections() }, () => ({ kind: SelectionStateType.Index, focus: { start: newIdx, end: newIdx + 1 }, selections: [{ start: newIdx, end: newIdx + 1 }] }), undefined);
return true;
}

View file

@ -14,6 +14,7 @@ import { ISelectionState, NotebookCellMetadata } from 'vs/workbench/contrib/note
export interface ITextCellEditingDelegate {
insertCell?(index: number, cell: NotebookCellTextModel, endSelections?: ISelectionState): void;
deleteCell?(index: number, endSelections?: ISelectionState): void;
replaceCell?(index: number, count: number, cells: NotebookCellTextModel[], endSelections?: ISelectionState): void;
moveCell?(fromIndex: number, length: number, toIndex: number, beforeSelections: ISelectionState | undefined, endSelections?: ISelectionState): void;
updateCellMetadata?(index: number, newMetadata: NotebookCellMetadata): void;
}
@ -63,34 +64,22 @@ export class SpliceCellsEdit implements IResourceUndoRedoElement {
}
undo(): void {
if (!this.editingDelegate.deleteCell || !this.editingDelegate.insertCell) {
throw new Error('Notebook Insert/Delete Cell not implemented for Undo/Redo');
if (!this.editingDelegate.replaceCell) {
throw new Error('Notebook Replace Cell not implemented for Undo/Redo');
}
this.diffs.forEach(diff => {
for (let i = 0; i < diff[2].length; i++) {
this.editingDelegate.deleteCell!(diff[0], this.beforeHandles);
}
diff[1].reverse().forEach(cell => {
this.editingDelegate.insertCell!(diff[0], cell, this.beforeHandles);
});
this.editingDelegate.replaceCell!(diff[0], diff[2].length, diff[1], this.beforeHandles);
});
}
redo(): void {
if (!this.editingDelegate.deleteCell || !this.editingDelegate.insertCell) {
throw new Error('Notebook Insert/Delete Cell not implemented for Undo/Redo');
if (!this.editingDelegate.replaceCell) {
throw new Error('Notebook Replace Cell not implemented for Undo/Redo');
}
this.diffs.reverse().forEach(diff => {
for (let i = 0; i < diff[1].length; i++) {
this.editingDelegate.deleteCell!(diff[0], this.endHandles);
}
diff[2].reverse().forEach(cell => {
this.editingDelegate.insertCell!(diff[0], cell, this.endHandles);
});
this.editingDelegate.replaceCell!(diff[0], diff[1].length, diff[2], this.endHandles);
});
}
}

View file

@ -7,6 +7,7 @@ import { Emitter, Event } from 'vs/base/common/event';
import { ICell, NotebookCellOutputsSplice, CellKind, NotebookCellMetadata, NotebookDocumentMetadata, TransientOptions, IOutputDto, ICellOutput } from 'vs/workbench/contrib/notebook/common/notebookCommon';
import { PieceTreeTextBufferBuilder } from 'vs/editor/common/model/pieceTreeTextBuffer/pieceTreeTextBufferBuilder';
import { URI } from 'vs/base/common/uri';
import * as UUID from 'vs/base/common/uuid';
import * as model from 'vs/editor/common/model';
import { Range } from 'vs/editor/common/core/range';
import { Disposable } from 'vs/base/common/lifecycle';
@ -179,3 +180,27 @@ export class NotebookCellTextModel extends Disposable implements ICell {
super.dispose();
}
}
export function cloneMetadata(cell: NotebookCellTextModel) {
return {
editable: cell.metadata?.editable,
breakpointMargin: cell.metadata?.breakpointMargin,
hasExecutionOrder: cell.metadata?.hasExecutionOrder,
inputCollapsed: cell.metadata?.inputCollapsed,
outputCollapsed: cell.metadata?.outputCollapsed,
custom: cell.metadata?.custom
};
}
export function cloneNotebookCellTextModel(cell: NotebookCellTextModel) {
return {
source: cell.getValue(),
language: cell.language,
cellKind: cell.cellKind,
outputs: cell.outputs.map(output => ({
outputs: output.outputs,
/* paste should generate new outputId */ outputId: UUID.generateUuid()
})),
metadata: cloneMetadata(cell)
};
}

View file

@ -414,6 +414,7 @@ export class NotebookTextModel extends Disposable implements INotebookTextModel
this._operationManager.pushEditOperation(new SpliceCellsEdit(this.uri, undoDiff, {
insertCell: (index, cell, endSelections) => { this._insertNewCell(index, [cell], true, endSelections); },
deleteCell: (index, endSelections) => { this._removeCell(index, 1, true, endSelections); },
replaceCell: (index, count, cells, endSelections) => { this._replaceNewCells(index, count, cells, true, endSelections); },
}, undefined, undefined), undefined, undefined);
}
@ -505,6 +506,27 @@ export class NotebookTextModel extends Disposable implements INotebookTextModel
this._eventEmitter.emit({ kind: NotebookCellsChangeType.ModelChange, changes: [[index, count, []]], transient: false }, synchronous, endSelections);
}
private _replaceNewCells(index: number, count: number, cells: NotebookCellTextModel[], synchronous: boolean, endSelections: ISelectionState | undefined) {
for (let i = index; i < index + count; i++) {
const cell = this._cells[i];
this._cellListeners.get(cell.handle)?.dispose();
this._cellListeners.delete(cell.handle);
}
for (let i = 0; i < cells.length; i++) {
this._mapping.set(cells[i].handle, cells[i]);
const dirtyStateListener = cells[i].onDidChangeContent(() => {
this._eventEmitter.emit({ kind: NotebookCellsChangeType.ChangeCellContent, transient: false }, true);
});
this._cellListeners.set(cells[i].handle, dirtyStateListener);
}
this._cells.splice(index, count, ...cells);
this._eventEmitter.emit({ kind: NotebookCellsChangeType.ModelChange, changes: [[index, count, cells]], transient: false }, synchronous, endSelections);
}
private _isCellMetadataChanged(a: NotebookCellMetadata, b: NotebookCellMetadata) {
const keys = new Set([...Object.keys(a || {}), ...Object.keys(b || {})]);
for (let key of keys) {

View file

@ -305,10 +305,7 @@ export interface ISelectionHandleState {
export interface ISelectionIndexState {
kind: SelectionStateType.Index;
/**
* [primarySelection, ...secondarySelections]
*/
focus: ICellRange;
selections: ICellRange[];
}
@ -792,6 +789,7 @@ export interface INotebookDecorationRenderOptions {
export function cellIndexesToRanges(indexes: number[]) {
indexes.sort((a, b) => a - b);
const first = indexes.shift();
if (first === undefined) {
@ -819,3 +817,36 @@ export function cellRangesToIndexes(ranges: ICellRange[]) {
return indexes;
}
/**
* todo@rebornix notebookBrowser.reduceCellRanges
* @returns
*/
export function reduceRanges(ranges: ICellRange[]) {
const sorted = ranges.sort((a, b) => a.start - b.start);
const first = sorted[0];
if (!first) {
return [];
}
return sorted.reduce((prev: ICellRange[], curr) => {
const last = prev[prev.length - 1];
if (last.end >= curr.start) {
last.end = Math.max(last.end, curr.end);
} else {
prev.push(curr);
}
return prev;
}, [first] as ICellRange[]);
}
/**
* todo@rebornix test and sort
* @param range
* @param other
* @returns
*/
export function cellRangeContains(range: ICellRange, other: ICellRange): boolean {
return other.start >= range.start && other.end <= range.end;
}

View file

@ -8,6 +8,7 @@ import { NOTEBOOK_DISPLAY_ORDER, sortMimeTypes, CellKind, diff, CellUri, cellRan
import { TestCell, setupInstantiationService } from 'vs/workbench/contrib/notebook/test/testNotebookEditor';
import { URI } from 'vs/base/common/uri';
import { ITextModelService } from 'vs/editor/common/services/resolverService';
import { reduceCellRanges } from 'vs/workbench/contrib/notebook/browser/notebookBrowser';
suite('NotebookCommon', () => {
const instantiationService = setupInstantiationService();
@ -313,5 +314,19 @@ suite('CellRange', function () {
assert.deepStrictEqual(cellIndexesToRanges([0, 1]), [{ start: 0, end: 2 }]);
assert.deepStrictEqual(cellIndexesToRanges([0, 1, 2]), [{ start: 0, end: 3 }]);
assert.deepStrictEqual(cellIndexesToRanges([0, 1, 3]), [{ start: 0, end: 2 }, { start: 3, end: 4 }]);
assert.deepStrictEqual(cellIndexesToRanges([1, 0]), [{ start: 0, end: 2 }]);
assert.deepStrictEqual(cellIndexesToRanges([1, 2, 0]), [{ start: 0, end: 3 }]);
assert.deepStrictEqual(cellIndexesToRanges([3, 1, 0]), [{ start: 0, end: 2 }, { start: 3, end: 4 }]);
assert.deepStrictEqual(cellIndexesToRanges([9, 10]), [{ start: 9, end: 11 }]);
assert.deepStrictEqual(cellIndexesToRanges([10, 9]), [{ start: 9, end: 11 }]);
});
test('Reduce ranges', function () {
assert.deepStrictEqual(reduceCellRanges([{ start: 0, end: 1 }, { start: 1, end: 2 }]), [{ start: 0, end: 2 }]);
assert.deepStrictEqual(reduceCellRanges([{ start: 0, end: 2 }, { start: 1, end: 3 }]), [{ start: 0, end: 3 }]);
assert.deepStrictEqual(reduceCellRanges([{ start: 1, end: 3 }, { start: 0, end: 2 }]), [{ start: 0, end: 3 }]);
assert.deepStrictEqual(reduceCellRanges([{ start: 0, end: 2 }, { start: 4, end: 5 }]), [{ start: 0, end: 2 }, { start: 4, end: 5 }]);
});
});

View file

@ -4,42 +4,28 @@
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import { ITextModelService } from 'vs/editor/common/services/resolverService';
import { FoldingModel, updateFoldingStateAtIndex } from 'vs/workbench/contrib/notebook/browser/contrib/fold/foldingModel';
import { NotebookCellSelectionCollection } from 'vs/workbench/contrib/notebook/browser/viewModel/cellSelectionCollection';
import { CellKind, SelectionStateType } from 'vs/workbench/contrib/notebook/common/notebookCommon';
import { createNotebookCellList, setupInstantiationService, withTestNotebook } from 'vs/workbench/contrib/notebook/test/testNotebookEditor';
import { CellEditType, CellKind, SelectionStateType } from 'vs/workbench/contrib/notebook/common/notebookCommon';
import { createNotebookCellList, setupInstantiationService, TestCell, withTestNotebook } from 'vs/workbench/contrib/notebook/test/testNotebookEditor';
suite('NotebookSelection', () => {
test('selection is never empty', function () {
test('focus is never empty', function () {
const selectionCollection = new NotebookCellSelectionCollection();
assert.deepStrictEqual(selectionCollection.selections, [{ start: 0, end: 0 }]);
assert.deepStrictEqual(selectionCollection.focus, { start: 0, end: 0 });
selectionCollection.setState(null, [], true, 'model');
assert.deepStrictEqual(selectionCollection.selections, [{ start: 0, end: 0 }]);
});
test('selections[0] is primary selection', function () {
const selectionCollection = new NotebookCellSelectionCollection();
selectionCollection.setState(null, [{ start: 0, end: 1 }, { start: 3, end: 5 }], true, 'model');
assert.deepStrictEqual(selectionCollection.selection, { start: 0, end: 1 });
assert.deepStrictEqual(selectionCollection.selections, [{ start: 0, end: 1 }, { start: 3, end: 5 }]);
selectionCollection.setState({ start: 0, end: 1 }, [{ start: 3, end: 5 }], true, 'model');
assert.deepStrictEqual(selectionCollection.selection, { start: 0, end: 1 });
assert.deepStrictEqual(selectionCollection.selections, [{ start: 0, end: 1 }, { start: 3, end: 5 }]);
selectionCollection.setState({ start: 0, end: 1 }, [], true, 'model');
assert.deepStrictEqual(selectionCollection.selection, { start: 0, end: 1 });
assert.deepStrictEqual(selectionCollection.selections, [{ start: 0, end: 1 }]);
assert.deepStrictEqual(selectionCollection.focus, { start: 0, end: 0 });
});
});
suite('NotebookCellList focus/selection', () => {
const instantiationService = setupInstantiationService();
const textModelService = instantiationService.get(ITextModelService);
test('notebook cell list setFocus', function () {
withTestNotebook(
test('notebook cell list setFocus', async function () {
await withTestNotebook(
instantiationService,
[
['var a = 1;', 'javascript', CellKind.Code, [], {}],
@ -51,16 +37,16 @@ suite('NotebookCellList focus/selection', () => {
assert.strictEqual(cellList.length, 2);
cellList.setFocus([0]);
assert.deepStrictEqual(viewModel.getSelection(), { start: 0, end: 1 });
assert.deepStrictEqual(viewModel.getFocus(), { start: 0, end: 1 });
cellList.setFocus([1]);
assert.deepStrictEqual(viewModel.getSelection(), { start: 1, end: 2 });
assert.deepStrictEqual(viewModel.getFocus(), { start: 1, end: 2 });
cellList.detachViewModel();
});
});
test('notebook cell list setSelections', function () {
withTestNotebook(
test('notebook cell list setSelections', async function () {
await withTestNotebook(
instantiationService,
[
['var a = 1;', 'javascript', CellKind.Code, [], {}],
@ -73,18 +59,76 @@ suite('NotebookCellList focus/selection', () => {
assert.strictEqual(cellList.length, 2);
cellList.setSelection([0]);
// the only selection is also the focus
assert.deepStrictEqual(viewModel.getSelection(), { start: 0, end: 1 });
assert.deepStrictEqual(viewModel.getSelections(), [{ start: 0, end: 1 }]);
// set selection does not modify focus
cellList.setSelection([1]);
assert.deepStrictEqual(viewModel.getSelection(), { start: 0, end: 1 });
// `getSelections()` now returns all focus/selection ranges
assert.deepStrictEqual(viewModel.getSelections(), [{ start: 0, end: 1 }, { start: 1, end: 2 }]);
assert.deepStrictEqual(viewModel.getSelections(), [{ start: 1, end: 2 }]);
});
});
test('notebook cell list focus/selection with folding regions', function () {
withTestNotebook(
test('notebook cell list setFocus', async function () {
await withTestNotebook(
instantiationService,
[
['var a = 1;', 'javascript', CellKind.Code, [], {}],
['var b = 2;', 'javascript', CellKind.Code, [], {}]
],
(editor, viewModel) => {
const cellList = createNotebookCellList(instantiationService);
cellList.attachViewModel(viewModel);
assert.strictEqual(cellList.length, 2);
cellList.setFocus([0]);
assert.deepStrictEqual(viewModel.getFocus(), { start: 0, end: 1 });
cellList.setFocus([1]);
assert.deepStrictEqual(viewModel.getFocus(), { start: 1, end: 2 });
cellList.setSelection([1]);
assert.deepStrictEqual(viewModel.getSelections(), [{ start: 1, end: 2 }]);
});
});
test('notebook cell list focus/selection from UI', async function () {
await withTestNotebook(
instantiationService,
[
['# header a', 'markdown', CellKind.Markdown, [], {}],
['var b = 1;', 'javascript', CellKind.Code, [], {}],
['# header b', 'markdown', CellKind.Markdown, [], {}],
['var b = 2;', 'javascript', CellKind.Code, [], {}],
['# header c', 'markdown', CellKind.Markdown, [], {}]
],
(editor, viewModel) => {
const cellList = createNotebookCellList(instantiationService);
cellList.attachViewModel(viewModel);
assert.deepStrictEqual(viewModel.getFocus(), { start: 0, end: 1 });
assert.deepStrictEqual(viewModel.getSelections(), [{ start: 0, end: 1 }]);
// arrow down, move both focus and selections
cellList.setFocus([1], new KeyboardEvent('keydown'), undefined);
cellList.setSelection([1], new KeyboardEvent('keydown'), undefined);
assert.deepStrictEqual(viewModel.getFocus(), { start: 1, end: 2 });
assert.deepStrictEqual(viewModel.getSelections(), [{ start: 1, end: 2 }]);
// shift+arrow down, expands selection
cellList.setFocus([2], new KeyboardEvent('keydown'), undefined);
cellList.setSelection([1, 2]);
assert.deepStrictEqual(viewModel.getFocus(), { start: 2, end: 3 });
assert.deepStrictEqual(viewModel.getSelections(), [{ start: 1, end: 3 }]);
// arrow down, will move focus but not expand selection
cellList.setFocus([3], new KeyboardEvent('keydown'), undefined);
assert.deepStrictEqual(viewModel.getFocus(), { start: 3, end: 4 });
assert.deepStrictEqual(viewModel.getSelections(), [{ start: 1, end: 3 }]);
});
});
test('notebook cell list focus/selection with folding regions', async function () {
await withTestNotebook(
instantiationService,
[
['# header a', 'markdown', CellKind.Markdown, [], {}],
@ -100,6 +144,8 @@ suite('NotebookCellList focus/selection', () => {
const cellList = createNotebookCellList(instantiationService);
cellList.attachViewModel(viewModel);
assert.strictEqual(cellList.length, 5);
assert.deepStrictEqual(viewModel.getFocus(), { start: 0, end: 1 });
assert.deepStrictEqual(viewModel.getSelections(), [{ start: 0, end: 1 }]);
cellList.setFocus([0]);
updateFoldingStateAtIndex(foldingModel, 0, true);
@ -108,25 +154,110 @@ suite('NotebookCellList focus/selection', () => {
cellList.setHiddenAreas(viewModel.getHiddenRanges(), true);
assert.strictEqual(cellList.length, 3);
// currently, focus on a folded cell will only select the cell itself, excluding its "inner" cells
assert.deepStrictEqual(viewModel.getSelection(), { start: 0, end: 1 });
// currently, focus on a folded cell will only focus the cell itself, excluding its "inner" cells
assert.deepStrictEqual(viewModel.getFocus(), { start: 0, end: 1 });
assert.deepStrictEqual(viewModel.getSelections(), [{ start: 0, end: 1 }]);
cellList.focusNext(1, false);
// focus next should skip the folded items
assert.deepStrictEqual(viewModel.getSelection(), { start: 2, end: 3 });
assert.deepStrictEqual(viewModel.getSelections(), [{ start: 2, end: 3 }]);
assert.deepStrictEqual(viewModel.getFocus(), { start: 2, end: 3 });
assert.deepStrictEqual(viewModel.getSelections(), [{ start: 0, end: 1 }]);
// unfold
updateFoldingStateAtIndex(foldingModel, 2, false);
viewModel.updateFoldingRanges(foldingModel.regions);
cellList.setHiddenAreas(viewModel.getHiddenRanges(), true);
assert.strictEqual(cellList.length, 4);
assert.deepStrictEqual(viewModel.getSelection(), { start: 2, end: 3 });
assert.deepStrictEqual(viewModel.getFocus(), { start: 2, end: 3 });
});
});
test('notebook validate range', () => {
withTestNotebook(
test('notebook cell list focus/selection with folding regions and applyEdits', async function () {
await withTestNotebook(
instantiationService,
[
['# header a', 'markdown', CellKind.Markdown, [], {}],
['var b = 1;', 'javascript', CellKind.Code, [], {}],
['# header b', 'markdown', CellKind.Markdown, [], {}],
['var b = 2;', 'javascript', CellKind.Code, [], {}],
['var c = 3', 'javascript', CellKind.Markdown, [], {}],
['# header d', 'markdown', CellKind.Markdown, [], {}],
['var e = 4;', 'javascript', CellKind.Code, [], {}],
],
(editor, viewModel) => {
const foldingModel = new FoldingModel();
foldingModel.attachViewModel(viewModel);
const cellList = createNotebookCellList(instantiationService);
cellList.attachViewModel(viewModel);
cellList.setFocus([0]);
cellList.setSelection([0]);
updateFoldingStateAtIndex(foldingModel, 0, true);
updateFoldingStateAtIndex(foldingModel, 2, true);
viewModel.updateFoldingRanges(foldingModel.regions);
cellList.setHiddenAreas(viewModel.getHiddenRanges(), true);
assert.strictEqual(cellList.getModelIndex2(0), 0);
assert.strictEqual(cellList.getModelIndex2(1), 2);
viewModel.notebookDocument.applyEdits([{
editType: CellEditType.Replace, index: 0, count: 2, cells: []
}], true, undefined, () => undefined, undefined, false);
viewModel.updateFoldingRanges(foldingModel.regions);
cellList.setHiddenAreas(viewModel.getHiddenRanges(), true);
assert.strictEqual(cellList.getModelIndex2(0), 0);
assert.strictEqual(cellList.getModelIndex2(1), 3);
// mimic undo
viewModel.notebookDocument.applyEdits([{
editType: CellEditType.Replace, index: 0, count: 0, cells: [
new TestCell(viewModel.viewType, 7, '# header f', 'markdown', CellKind.Code, [], textModelService),
new TestCell(viewModel.viewType, 8, 'var g = 5;', 'javascript', CellKind.Code, [], textModelService)
]
}], true, undefined, () => undefined, undefined, false);
viewModel.updateFoldingRanges(foldingModel.regions);
cellList.setHiddenAreas(viewModel.getHiddenRanges(), true);
assert.strictEqual(cellList.getModelIndex2(0), 0);
assert.strictEqual(cellList.getModelIndex2(1), 1);
assert.strictEqual(cellList.getModelIndex2(2), 2);
});
});
test('notebook cell list getModelIndex', async function () {
await withTestNotebook(
instantiationService,
[
['# header a', 'markdown', CellKind.Markdown, [], {}],
['var b = 1;', 'javascript', CellKind.Code, [], {}],
['# header b', 'markdown', CellKind.Markdown, [], {}],
['var b = 2;', 'javascript', CellKind.Code, [], {}],
['# header c', 'markdown', CellKind.Markdown, [], {}]
],
(editor, viewModel) => {
const foldingModel = new FoldingModel();
foldingModel.attachViewModel(viewModel);
const cellList = createNotebookCellList(instantiationService);
cellList.attachViewModel(viewModel);
updateFoldingStateAtIndex(foldingModel, 0, true);
updateFoldingStateAtIndex(foldingModel, 2, true);
viewModel.updateFoldingRanges(foldingModel.regions);
cellList.setHiddenAreas(viewModel.getHiddenRanges(), true);
assert.deepStrictEqual(cellList.getModelIndex2(-1), 0);
assert.deepStrictEqual(cellList.getModelIndex2(0), 0);
assert.deepStrictEqual(cellList.getModelIndex2(1), 2);
assert.deepStrictEqual(cellList.getModelIndex2(2), 4);
});
});
test('notebook validate range', async () => {
await withTestNotebook(
instantiationService,
[
['# header a', 'markdown', CellKind.Markdown, [], {}],
@ -145,30 +276,31 @@ suite('NotebookCellList focus/selection', () => {
});
});
test('notebook updateSelectionState', function () {
withTestNotebook(
test('notebook updateSelectionState', async function () {
await withTestNotebook(
instantiationService,
[
['# header a', 'markdown', CellKind.Markdown, [], {}],
['var b = 1;', 'javascript', CellKind.Code, [], {}]
],
(editor, viewModel) => {
viewModel.updateSelectionsState({ kind: SelectionStateType.Index, selections: [{ start: 1, end: 2 }, { start: -1, end: 0 }] });
viewModel.updateSelectionsState({ kind: SelectionStateType.Index, focus: { start: 1, end: 2 }, selections: [{ start: 1, end: 2 }, { start: -1, end: 0 }] });
assert.deepStrictEqual(viewModel.getSelections(), [{ start: 1, end: 2 }]);
});
});
test('notebook cell selection w/ cell deletion', function () {
withTestNotebook(
test('notebook cell selection w/ cell deletion', async function () {
await withTestNotebook(
instantiationService,
[
['# header a', 'markdown', CellKind.Markdown, [], {}],
['var b = 1;', 'javascript', CellKind.Code, [], {}]
],
(editor, viewModel) => {
viewModel.updateSelectionsState({ kind: SelectionStateType.Index, selections: [{ start: 1, end: 2 }] });
viewModel.updateSelectionsState({ kind: SelectionStateType.Index, focus: { start: 1, end: 2 }, selections: [{ start: 1, end: 2 }] });
viewModel.deleteCell(1, true, false);
assert.deepStrictEqual(viewModel.getSelections(), [{ start: 0, end: 1 }]);
assert.deepStrictEqual(viewModel.getFocus(), { start: 0, end: 1 });
assert.deepStrictEqual(viewModel.getSelections(), []);
});
});
});

View file

@ -279,7 +279,7 @@ suite('NotebookTextModel', () => {
textModel.applyEdits([
{ editType: CellEditType.Replace, index: 1, count: 1, cells: [] },
{ editType: CellEditType.Replace, index: 1, count: 0, cells: [new TestCell(viewModel.viewType, 5, 'var e = 5;', 'javascript', CellKind.Code, [], textModelService)] },
], true, undefined, () => ({ kind: SelectionStateType.Index, selections: [{ start: 0, end: 1 }] }), undefined);
], true, undefined, () => ({ kind: SelectionStateType.Index, focus: { start: 0, end: 1 }, selections: [{ start: 0, end: 1 }] }), undefined);
assert.equal(textModel.cells.length, 4);
assert.equal(textModel.cells[0].getValue(), 'var a = 1;');
@ -318,7 +318,7 @@ suite('NotebookTextModel', () => {
editType: CellEditType.Metadata,
metadata: { editable: false },
}
], true, undefined, () => ({ kind: SelectionStateType.Index, selections: [{ start: 0, end: 1 }] }), undefined);
], true, undefined, () => ({ kind: SelectionStateType.Index, focus: { start: 0, end: 1 }, selections: [{ start: 0, end: 1 }] }), undefined);
assert.notEqual(changeEvent, undefined);
assert.equal(changeEvent!.rawEvents.length, 2);

View file

@ -67,6 +67,18 @@ export class TestNotebookEditor implements INotebookEditor {
creationOptions: INotebookEditorCreationOptions = { isEmbedded: false };
constructor(readonly viewModel: NotebookViewModel) { }
getCellRangeFromViewRange(startIndex: number, endIndex: number): ICellRange | undefined {
throw new Error('Method not implemented.');
}
getViewIndex(cell: ICellViewModel): number {
throw new Error('Method not implemented.');
}
getCellsFromViewRange(startIndex: number, endIndex: number): ICellViewModel[] {
throw new Error('Method not implemented.');
}
getFocus(): ICellRange | undefined {
return undefined;
}
getVisibleRangesPlusViewportAboveBelow(): ICellRange[] {
throw new Error('Method not implemented.');
}