diff --git a/src/icons.ts b/src/icons.ts index 23ccb342..a9e5d86a 100644 --- a/src/icons.ts +++ b/src/icons.ts @@ -22,6 +22,7 @@ import notebookSvg from '../style/icons/notebook.svg'; import logoSvg from '../style/icons/logo.svg'; import runSvg from '../style/icons/run.svg'; import runCellSvg from '../style/icons/run-cell.svg'; +import stopCellSvg from '../style/icons/stop-cell.svg'; import refreshSvg from '../style/icons/refresh.svg'; import stopSvg from '../style/icons/stop.svg'; import fastForwardSvg from '../style/icons/fast-forward.svg'; @@ -94,6 +95,10 @@ export namespace EverywhereIcons { name: 'everywhere:run-cell', svgstr: runCellSvg }); + export const stopCell = new LabIcon({ + name: 'everywhere:stop-cell', + svgstr: stopCellSvg + }); export const downloadCaret = new LabIcon({ name: 'everywhere:download-caret', svgstr: downloadCaretSvg diff --git a/src/run-button.ts b/src/run-button.ts index 18e31452..ab977bcb 100644 --- a/src/run-button.ts +++ b/src/run-button.ts @@ -40,13 +40,16 @@ import { JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application' import { IEditorServices } from '@jupyterlab/codeeditor'; import { ToolbarButton } from '@jupyterlab/ui-components'; import { Widget, PanelLayout } from '@lumino/widgets'; -import { Notebook, NotebookPanel } from '@jupyterlab/notebook'; +import { Notebook, NotebookPanel, NotebookActions } from '@jupyterlab/notebook'; import { EverywhereIcons } from './icons'; +import { Cell, CodeCell, ICellModel } from '@jupyterlab/cells'; +import { Message } from '@lumino/messaging'; const INPUT_PROMPT_CLASS = 'jp-InputPrompt'; const INPUT_AREA_PROMPT_INDICATOR_CLASS = 'jp-InputArea-prompt-indicator'; const INPUT_AREA_PROMPT_INDICATOR_EMPTY_CLASS = 'jp-InputArea-prompt-indicator-empty'; const INPUT_AREA_PROMPT_RUN_CLASS = 'jp-InputArea-prompt-run'; +const INPUT_AREA_PROMPT_STOP_CLASS = 'jp-InputArea-prompt-stop'; export interface IInputPromptIndicator extends Widget { executionCount: string | null; @@ -84,6 +87,8 @@ export class JEInputPrompt extends Widget implements IInputPrompt { private _customExecutionCount: string | null = null; private _promptIndicator: InputPromptIndicator; private _runButton: ToolbarButton; + private _stopButton: ToolbarButton; + private _ownerCell: CodeCell | null = null; constructor(private _app: JupyterFrontEnd) { super(); @@ -92,6 +97,7 @@ export class JEInputPrompt extends Widget implements IInputPrompt { const layout = (this.layout = new PanelLayout()); this._promptIndicator = new InputPromptIndicator(); layout.addWidget(this._promptIndicator); + this._runButton = new ToolbarButton({ icon: EverywhereIcons.runCell, onClick: () => { @@ -102,6 +108,110 @@ export class JEInputPrompt extends Widget implements IInputPrompt { this._runButton.addClass(INPUT_AREA_PROMPT_RUN_CLASS); this._runButton.addClass('je-cell-run-button'); layout.addWidget(this._runButton); + + this._stopButton = new ToolbarButton({ + icon: EverywhereIcons.stopCell, + onClick: async () => { + const panel = this._app.shell.currentWidget; + if (!(panel instanceof NotebookPanel)) { + return; + } + try { + const kernel = panel.sessionContext.session?.kernel; + if (kernel && typeof kernel.interrupt === 'function') { + await kernel.interrupt(); + } else { + await panel.sessionContext.restartKernel(); + } + } catch (err) { + console.warn('Failed to stop execution (interrupt/restart):', err); + } + }, + tooltip: 'Stop running this cell' + }); + this._stopButton.addClass(INPUT_AREA_PROMPT_STOP_CLASS); + this._stopButton.addClass('je-cell-stop-button'); + layout.addWidget(this._stopButton); + + this._applyPointerEvents(false); + } + + /** + * Make sure the correct button is clickable; we disable pointer events for + * the hidden one to avoid the "wrong tooltip" and accidental clicks. + */ + private _applyPointerEvents(isExecuting: boolean) { + const runEl = this._runButton.node; + const stopEl = this._stopButton.node; + if (isExecuting) { + runEl.style.pointerEvents = 'none'; + stopEl.style.pointerEvents = 'auto'; + } else { + runEl.style.pointerEvents = 'auto'; + stopEl.style.pointerEvents = 'none'; + } + } + + protected onAfterAttach(msg: Message): void { + super.onAfterAttach(msg); + + let w: Widget | null = this.parent; + while (w && !(w instanceof CodeCell)) { + w = w.parent; + } + if (w instanceof CodeCell) { + this._ownerCell = w; + + NotebookActions.executionScheduled.connect(this._onExecutionScheduled, this); + NotebookActions.executed.connect(this._onExecuted, this); + + this._setExecuting(false); + } + } + + protected onBeforeDetach(msg: Message): void { + super.onBeforeDetach(msg); + if (this._ownerCell) { + NotebookActions.executionScheduled.disconnect(this._onExecutionScheduled, this); + NotebookActions.executed.disconnect(this._onExecuted, this); + } + this._ownerCell = null; + } + + dispose(): void { + if (this._ownerCell) { + NotebookActions.executionScheduled.disconnect(this._onExecutionScheduled, this); + NotebookActions.executed.disconnect(this._onExecuted, this); + this._ownerCell = null; + } + super.dispose(); + } + + // Per-cell execution state handler + private _onExecutionScheduled( + _sender: unknown, + args: { notebook: Notebook; cell: Cell } + ) { + if (this._ownerCell && args.cell === this._ownerCell) { + this._setExecuting(true); + } + } + + private _onExecuted( + _sender: unknown, + args: { notebook: Notebook; cell: Cell; success: boolean; error?: unknown } + ) { + if (this._ownerCell && args.cell === this._ownerCell) { + this._setExecuting(false); + } + } + + private _setExecuting(flag: boolean) { + this.toggleClass('je-executing', flag); + if (this._ownerCell) { + this._ownerCell.toggleClass('je-executing', flag); + } + this._applyPointerEvents(flag); } get executionCount(): string | null { diff --git a/style/base.css b/style/base.css index 7eddffd7..108ab469 100644 --- a/style/base.css +++ b/style/base.css @@ -130,13 +130,15 @@ top: 5px; } -.jp-InputArea-prompt-run.je-cell-run-button { +.jp-InputArea-prompt-run.je-cell-run-button, +.jp-InputArea-prompt-stop.je-cell-stop-button { position: absolute; right: 8px; top: 50%; transform: translateY(-50%) scale(calc(var(--je-scale) * 2.5)); opacity: 0; transition: opacity 0.15s ease-in-out; + pointer-events: none; } /* Don't show run button on raw cells */ @@ -144,9 +146,27 @@ display: none; } +/* Don't show stop button on raw cells */ +.jp-RawCell .jp-InputArea-prompt-stop.je-cell-stop-button { + display: none; +} + .jp-Cell:hover:not(.jp-RawCell) .jp-InputArea-prompt-run.je-cell-run-button, .jp-Cell.jp-mod-active:not(.jp-RawCell) .jp-InputArea-prompt-run.je-cell-run-button { opacity: 1; + pointer-events: auto; +} + +.jp-Cell.je-executing:not(.jp-RawCell) .jp-InputArea-prompt-stop.je-cell-stop-button, +.jp-InputPrompt.je-executing:not(.jp-RawCell) .jp-InputArea-prompt-stop.je-cell-stop-button { + opacity: 1; + pointer-events: auto; +} + +.jp-Cell.je-executing:not(.jp-RawCell) .jp-InputArea-prompt-run.je-cell-run-button, +.jp-InputPrompt.je-executing:not(.jp-RawCell) .jp-InputArea-prompt-run.je-cell-run-button { + opacity: 0; + pointer-events: none; } /* Hide all dirty state indicators */ diff --git a/style/icons/stop-cell.svg b/style/icons/stop-cell.svg new file mode 100644 index 00000000..59141dfb --- /dev/null +++ b/style/icons/stop-cell.svg @@ -0,0 +1,3 @@ + + +