// ***************************************************************************** // Copyright (C) 2023 TypeFox and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { Command, CommandContribution, CommandHandler, CommandRegistry, MenuContribution, MenuModelRegistry, nls, URI } from '@theia/core'; import { inject, injectable } from '@theia/core/shared/inversify'; import { ApplicationShell, codicon, KeybindingContribution, KeybindingRegistry } from '@theia/core/lib/browser'; import { NotebookModel } from '../view-model/notebook-model'; import { NotebookService } from '../service/notebook-service'; import { CellEditType, CellKind, NotebookCommand } from '../../common'; import { NotebookKernelQuickPickService } from '../service/notebook-kernel-quick-pick-service'; import { NotebookExecutionService } from '../service/notebook-execution-service'; import { NotebookEditorWidgetService } from '../service/notebook-editor-widget-service'; import { NOTEBOOK_CELL_CURSOR_FIRST_LINE, NOTEBOOK_CELL_CURSOR_LAST_LINE, NOTEBOOK_CELL_FOCUSED, NOTEBOOK_EDITOR_FOCUSED, NOTEBOOK_HAS_OUTPUTS, NOTEBOOK_OUTPUT_FOCUSED } from './notebook-context-keys'; import { NotebookClipboardService } from '../service/notebook-clipboard-service'; import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; import { NotebookEditorWidget } from '../notebook-editor-widget'; export namespace NotebookCommands { export const ADD_NEW_CELL_COMMAND = Command.toDefaultLocalizedCommand({ id: 'notebook.add-new-cell', iconClass: codicon('add') }); export const ADD_NEW_MARKDOWN_CELL_COMMAND = Command.toDefaultLocalizedCommand({ id: 'notebook.add-new-markdown-cell', iconClass: codicon('add'), tooltip: nls.localizeByDefault('Add Markdown Cell') } as NotebookCommand); export const ADD_NEW_CODE_CELL_COMMAND = Command.toDefaultLocalizedCommand({ id: 'notebook.add-new-code-cell', iconClass: codicon('add'), tooltip: nls.localizeByDefault('Add Code Cell') } as NotebookCommand); export const SELECT_KERNEL_COMMAND = Command.toDefaultLocalizedCommand({ id: 'notebook.selectKernel', category: 'Notebook', iconClass: codicon('server-environment') }); export const EXECUTE_NOTEBOOK_COMMAND = Command.toDefaultLocalizedCommand({ id: 'notebook.execute', category: 'Notebook', iconClass: codicon('run-all') }); export const CLEAR_ALL_OUTPUTS_COMMAND = Command.toDefaultLocalizedCommand({ id: 'notebook.clear-all-outputs', category: 'Notebook', iconClass: codicon('clear-all') }); export const CHANGE_SELECTED_CELL = Command.toDefaultLocalizedCommand({ id: 'notebook.change-selected-cell', category: 'Notebook', }); export const CUT_SELECTED_CELL = Command.toDefaultLocalizedCommand({ id: 'notebook.cell.cut', category: 'Notebook', }); export const COPY_SELECTED_CELL = Command.toDefaultLocalizedCommand({ id: 'notebook.cell.copy', category: 'Notebook', }); export const PASTE_CELL = Command.toDefaultLocalizedCommand({ id: 'notebook.cell.paste', category: 'Notebook', }); export const NOTEBOOK_FIND = Command.toDefaultLocalizedCommand({ id: 'notebook.find', category: 'Notebook', }); export const CENTER_ACTIVE_CELL = Command.toDefaultLocalizedCommand({ id: 'notebook.centerActiveCell', category: 'Notebook', }); } export enum CellChangeDirection { Up = 'up', Down = 'down' } @injectable() export class NotebookActionsContribution implements CommandContribution, MenuContribution, KeybindingContribution { @inject(NotebookService) protected notebookService: NotebookService; @inject(NotebookKernelQuickPickService) protected notebookKernelQuickPickService: NotebookKernelQuickPickService; @inject(NotebookExecutionService) protected notebookExecutionService: NotebookExecutionService; @inject(ApplicationShell) protected shell: ApplicationShell; @inject(NotebookEditorWidgetService) protected notebookEditorWidgetService: NotebookEditorWidgetService; @inject(NotebookClipboardService) protected notebookClipboardService: NotebookClipboardService; @inject(ContextKeyService) protected contextKeyService: ContextKeyService; registerCommands(commands: CommandRegistry): void { commands.registerCommand(NotebookCommands.ADD_NEW_CELL_COMMAND, { execute: (notebookModel: NotebookModel, cellKind: CellKind = CellKind.Markup, index?: number | 'above' | 'below', focusContainer?: boolean) => { notebookModel = notebookModel ?? this.notebookEditorWidgetService.focusedEditor?.model; const viewModel = this.notebookEditorWidgetService.focusedEditor?.viewModel; let insertIndex: number = 0; if (typeof index === 'number' && index >= 0) { insertIndex = index; } else if (viewModel?.selectedCell && typeof index === 'string') { // if index is -1 insert below otherwise at the index of the selected cell which is above the selected. insertIndex = notebookModel.cells.indexOf(viewModel.selectedCell) + (index === 'below' ? 1 : 0); } let cellLanguage: string = 'markdown'; if (cellKind === CellKind.Code) { cellLanguage = this.notebookService.getCodeCellLanguage(notebookModel); } notebookModel.applyEdits([{ editType: CellEditType.Replace, index: insertIndex, count: 0, cells: [{ cellKind, language: cellLanguage, source: '', outputs: [], metadata: {}, }] }], true); if (focusContainer) { viewModel?.cellViewModels.get(viewModel.selectedCell?.handle ?? -1)?.requestBlurEditor(); } } }); commands.registerCommand(NotebookCommands.ADD_NEW_MARKDOWN_CELL_COMMAND, this.editableCommandHandler( notebookModel => commands.executeCommand(NotebookCommands.ADD_NEW_CELL_COMMAND.id, notebookModel, CellKind.Markup, 'below') )); commands.registerCommand(NotebookCommands.ADD_NEW_CODE_CELL_COMMAND, this.editableCommandHandler( notebookModel => commands.executeCommand(NotebookCommands.ADD_NEW_CELL_COMMAND.id, notebookModel, CellKind.Code, 'below') )); commands.registerCommand(NotebookCommands.SELECT_KERNEL_COMMAND, this.editableCommandHandler( notebookModel => this.notebookKernelQuickPickService.showQuickPick(notebookModel) )); commands.registerCommand(NotebookCommands.EXECUTE_NOTEBOOK_COMMAND, this.editableCommandHandler( notebookModel => this.notebookExecutionService.executeNotebookCells(notebookModel, notebookModel.cells) )); commands.registerCommand(NotebookCommands.CLEAR_ALL_OUTPUTS_COMMAND, this.editableCommandHandler( notebookModel => notebookModel.applyEdits(notebookModel.cells.map(cell => ({ editType: CellEditType.Output, handle: cell.handle, deleteCount: cell.outputs.length, outputs: [] })), false) )); commands.registerCommand(NotebookCommands.CHANGE_SELECTED_CELL, { execute: (change: number | CellChangeDirection) => { const focusedEditor = this.notebookEditorWidgetService.focusedEditor; const model = focusedEditor?.model; const viewModel = focusedEditor?.viewModel; if (model && typeof change === 'number') { viewModel?.setSelectedCell(model.cells[change]); } else if (model && viewModel?.selectedCell) { const currentIndex = model.cells.indexOf(viewModel?.selectedCell); const shouldFocusEditor = this.contextKeyService.match('editorTextFocus'); if (change === CellChangeDirection.Up && currentIndex > 0) { viewModel?.setSelectedCell(model.cells[currentIndex - 1]); if ((viewModel?.selectedCell?.cellKind === CellKind.Code || (viewModel?.selectedCell?.cellKind === CellKind.Markup && viewModel?.selectedCellViewModel?.editing)) && shouldFocusEditor) { viewModel?.cellViewModels.get(viewModel.selectedCell.handle)?.requestFocusEditor('lastLine'); } } else if (change === CellChangeDirection.Down && currentIndex < model.cells.length - 1) { viewModel?.setSelectedCell(model.cells[currentIndex + 1]); if ((viewModel?.selectedCell?.cellKind === CellKind.Code || (viewModel?.selectedCell?.cellKind === CellKind.Markup && viewModel?.selectedCellViewModel?.editing)) && shouldFocusEditor) { viewModel?.cellViewModels.get(viewModel.selectedCell.handle)?.requestFocusEditor(); } } if (viewModel?.selectedCell.cellKind === CellKind.Markup) { // since were losing focus from the cell editor, we need to focus the notebook editor again focusedEditor?.node.focus(); } } } } ); commands.registerCommand({ id: 'list.focusUp' }, { execute: () => commands.executeCommand(NotebookCommands.CHANGE_SELECTED_CELL.id, CellChangeDirection.Up) }); commands.registerCommand({ id: 'list.focusDown' }, { execute: () => commands.executeCommand(NotebookCommands.CHANGE_SELECTED_CELL.id, CellChangeDirection.Down) }); commands.registerCommand(NotebookCommands.CUT_SELECTED_CELL, this.editableCommandHandler( () => { const model = this.notebookEditorWidgetService.focusedEditor?.model; const selectedCell = this.notebookEditorWidgetService.focusedEditor?.viewModel?.selectedCell; if (selectedCell && model) { model.applyEdits([{ editType: CellEditType.Replace, index: model.cells.indexOf(selectedCell), count: 1, cells: [] }], true); this.notebookClipboardService.copyCell(selectedCell); } })); commands.registerCommand(NotebookCommands.COPY_SELECTED_CELL, { execute: () => { const viewModel = this.notebookEditorWidgetService.focusedEditor?.viewModel; const selectedCell = viewModel?.selectedCell; if (selectedCell) { this.notebookClipboardService.copyCell(selectedCell); } } }); commands.registerCommand(NotebookCommands.PASTE_CELL, { isEnabled: () => !Boolean(this.notebookEditorWidgetService.focusedEditor?.model?.readOnly), isVisible: () => !Boolean(this.notebookEditorWidgetService.focusedEditor?.model?.readOnly), execute: (position?: 'above') => { const copiedCell = this.notebookClipboardService.getCell(); if (copiedCell) { const model = this.notebookEditorWidgetService.focusedEditor?.model; const viewModel = this.notebookEditorWidgetService.focusedEditor?.viewModel; const insertIndex = viewModel?.selectedCell && model ? model.cells.indexOf(viewModel?.selectedCell) + (position === 'above' ? 0 : 1) : 0; model?.applyEdits([{ editType: CellEditType.Replace, index: insertIndex, count: 0, cells: [copiedCell] }], true); } } }); commands.registerCommand(NotebookCommands.NOTEBOOK_FIND, { execute: () => { this.notebookEditorWidgetService.focusedEditor?.showFindWidget(); } }); commands.registerCommand(NotebookCommands.CENTER_ACTIVE_CELL, { execute: (editor?: NotebookEditorWidget) => { const viewModel = editor ? editor.viewModel : this.notebookEditorWidgetService.focusedEditor?.viewModel; viewModel?.selectedCell?.requestCenterEditor(); } }); } protected editableCommandHandler(execute: (notebookModel: NotebookModel) => void): CommandHandler { return { isEnabled: (item: URI | NotebookModel) => this.withModel(item, model => !Boolean(model?.readOnly), false), isVisible: (item: URI | NotebookModel) => this.withModel(item, model => !Boolean(model?.readOnly), false), execute: (uri: URI | NotebookModel) => { this.withModel(uri, execute, undefined); } }; } protected withModel(item: URI | NotebookModel, execute: (notebookModel: NotebookModel) => T, defaultValue: T): T { if (item instanceof URI) { const model = this.notebookService.getNotebookEditorModel(item); if (!model) { return defaultValue; } item = model; } return execute(item); } registerMenus(menus: MenuModelRegistry): void { // independent submenu for plugins to add commands menus.registerSubmenu(NotebookMenus.NOTEBOOK_MAIN_TOOLBAR, 'Notebook Main Toolbar'); // Add Notebook Cell items menus.registerMenuAction(NotebookMenus.NOTEBOOK_MAIN_TOOLBAR_CELL_ADD_GROUP, { commandId: NotebookCommands.ADD_NEW_CODE_CELL_COMMAND.id, label: nls.localizeByDefault('Code'), icon: codicon('add'), }); menus.registerMenuAction(NotebookMenus.NOTEBOOK_MAIN_TOOLBAR_CELL_ADD_GROUP, { commandId: NotebookCommands.ADD_NEW_MARKDOWN_CELL_COMMAND.id, label: nls.localizeByDefault('Markdown'), icon: codicon('add'), }); // Execution related items menus.registerMenuAction(NotebookMenus.NOTEBOOK_MAIN_TOOLBAR_EXECUTION_GROUP, { commandId: NotebookCommands.EXECUTE_NOTEBOOK_COMMAND.id, label: nls.localizeByDefault('Run All'), icon: codicon('run-all'), order: '10' }); menus.registerMenuAction(NotebookMenus.NOTEBOOK_MAIN_TOOLBAR_EXECUTION_GROUP, { commandId: NotebookCommands.CLEAR_ALL_OUTPUTS_COMMAND.id, label: nls.localizeByDefault('Clear All Outputs'), icon: codicon('clear-all'), order: '30', when: NOTEBOOK_HAS_OUTPUTS }); menus.registerSubmenu(NotebookMenus.NOTEBOOK_MAIN_TOOLBAR_HIDDEN_ITEMS_CONTEXT_MENU, ''); } registerKeybindings(keybindings: KeybindingRegistry): void { keybindings.registerKeybindings( { command: NotebookCommands.CHANGE_SELECTED_CELL.id, keybinding: 'up', args: CellChangeDirection.Up, when: `(!editorTextFocus || ${NOTEBOOK_CELL_CURSOR_FIRST_LINE}) && !suggestWidgetVisible && ${NOTEBOOK_EDITOR_FOCUSED} && ${NOTEBOOK_CELL_FOCUSED}` }, { command: NotebookCommands.CHANGE_SELECTED_CELL.id, keybinding: 'down', args: CellChangeDirection.Down, when: `(!editorTextFocus || ${NOTEBOOK_CELL_CURSOR_LAST_LINE}) && !suggestWidgetVisible && ${NOTEBOOK_EDITOR_FOCUSED} && ${NOTEBOOK_CELL_FOCUSED}` }, { command: NotebookCommands.CUT_SELECTED_CELL.id, keybinding: 'ctrlcmd+x', when: `${NOTEBOOK_EDITOR_FOCUSED} && !inputFocus && !${NOTEBOOK_OUTPUT_FOCUSED}` }, { command: NotebookCommands.COPY_SELECTED_CELL.id, keybinding: 'ctrlcmd+c', when: `${NOTEBOOK_EDITOR_FOCUSED} && !inputFocus && !${NOTEBOOK_OUTPUT_FOCUSED}` }, { command: NotebookCommands.PASTE_CELL.id, keybinding: 'ctrlcmd+v', when: `${NOTEBOOK_EDITOR_FOCUSED} && !inputFocus && !${NOTEBOOK_OUTPUT_FOCUSED}` }, { command: NotebookCommands.NOTEBOOK_FIND.id, keybinding: 'ctrlcmd+f', when: `${NOTEBOOK_EDITOR_FOCUSED}` }, { command: NotebookCommands.CENTER_ACTIVE_CELL.id, keybinding: 'ctrlcmd+l', when: `${NOTEBOOK_EDITOR_FOCUSED}` } ); } } export namespace NotebookMenus { export const NOTEBOOK_MAIN_TOOLBAR = ['notebook', 'toolbar']; export const NOTEBOOK_MAIN_TOOLBAR_CELL_ADD_GROUP = [...NOTEBOOK_MAIN_TOOLBAR, 'cell-add-group']; export const NOTEBOOK_MAIN_TOOLBAR_EXECUTION_GROUP = [...NOTEBOOK_MAIN_TOOLBAR, 'cell-execution-group']; export const NOTEBOOK_MAIN_TOOLBAR_HIDDEN_ITEMS_CONTEXT_MENU = ['notebook-main-toolbar-hidden-items-context-menu']; }