From 46976d5451813c4a20fb25bb5ffba3c4b9648be4 Mon Sep 17 00:00:00 2001 From: Piyush Jain Date: Tue, 2 Dec 2025 13:28:35 -0800 Subject: [PATCH 1/2] Updated copy cell to remove outputs --- src/index.ts | 2 + src/overridecopy.ts | 201 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 src/overridecopy.ts diff --git a/src/index.ts b/src/index.ts index acc9ccd..c713914 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,6 +50,7 @@ import { AwarenessKernelStatus } from './kernelstatus'; import { codemirrorYjsPlugin } from './codemirror-binding/plugin'; import { notebookFactoryPlugin } from './notebook-factory'; import { disableSavePlugin } from './disablesave'; +import { overrideCopyPlugin } from './overridecopy'; /** * Initialization data for the @jupyter-ai-contrib/server-documents extension. @@ -326,6 +327,7 @@ const plugins: JupyterFrontEndPlugin[] = [ codemirrorYjsPlugin, backupCellExecutorPlugin, disableSavePlugin, + overrideCopyPlugin, ychat ]; diff --git a/src/overridecopy.ts b/src/overridecopy.ts new file mode 100644 index 0000000..13e4cc8 --- /dev/null +++ b/src/overridecopy.ts @@ -0,0 +1,201 @@ +import { + JupyterFrontEnd, + JupyterFrontEndPlugin +} from '@jupyterlab/application'; +import { Clipboard } from '@jupyterlab/apputils'; +import { INotebookTracker, Notebook, NotebookActions } from '@jupyterlab/notebook'; +import * as nbformat from '@jupyterlab/nbformat'; +import { JSONObject } from '@lumino/coreutils'; + +/** + * The mimetype used for Jupyter cell data. + */ +const JUPYTER_CELL_MIME = 'application/vnd.jupyter.cells'; + +/** + * Get the selected cell(s) as JSON without outputs. + * + * This is based on the private `selectedCells()` function from JupyterLab's + * notebook actions, but clears outputs from code cells. + * + * @param notebook - The target notebook widget. + * @returns A list of selected cells without outputs + */ +function selectedCellsWithoutOutputs(notebook: Notebook): nbformat.ICell[] { + return notebook.widgets + .filter(cell => notebook.isSelectedOrActive(cell)) + .map(cell => cell.model.toJSON()) + .map(cellJSON => { + // Clear outputs from code cells + if (cellJSON.cell_type === 'code') { + (cellJSON as nbformat.ICodeCell).outputs = []; + (cellJSON as nbformat.ICodeCell).execution_count = null; + } + // Remove deletable metadata (same as original implementation) + if ((cellJSON.metadata as JSONObject).deletable !== undefined) { + delete (cellJSON.metadata as JSONObject).deletable; + } + return cellJSON; + }); +} + +/** + * Copy or cut the selected cell data to the clipboard without outputs. + * + * This is based on the private `copyOrCut()` function from JupyterLab's + * notebook actions. + * + * @param notebook - The target notebook widget. + * @param cut - True if the cells should be cut, false if they should be copied. + */ +function copyOrCut(notebook: Notebook, cut: boolean): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + const clipboard = Clipboard.getInstance(); + + notebook.mode = 'command'; + clipboard.clear(); + + // Get selected cells without outputs + const data = selectedCellsWithoutOutputs(notebook); + console.log(data) + + clipboard.setData(JUPYTER_CELL_MIME, data); + + if (cut) { + NotebookActions.deleteCells(notebook); + notebook.lastClipboardInteraction = 'cut'; + } else { + notebook.deselectAll(); + notebook.lastClipboardInteraction = 'copy'; + } +} + +/** + * Duplicate selected cells without outputs. + * + * This is based on the `duplicate()` function from JupyterLab's notebook actions. + * + * @param notebook - The target notebook widget. + */ +function duplicateWithoutOutputs(notebook: Notebook): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + // Get cells without outputs + const values = selectedCellsWithoutOutputs(notebook); + + if (!values || values.length === 0) { + return; + } + + const model = notebook.model; + notebook.mode = 'command'; + + let index = 0; + const prevActiveCellIndex = notebook.activeCellIndex; + + // Find the last selected cell to insert after it (belowSelected mode) + notebook.widgets.forEach((child, childIndex) => { + if (notebook.isSelectedOrActive(child)) { + index = childIndex + 1; + } + }); + + model.sharedModel.transact(() => { + model.sharedModel.insertCells( + index, + values.map(cell => { + // Don't preserve cell IDs for duplicated cells + cell.id = undefined; + return cell; + }) + ); + }); + + notebook.activeCellIndex = prevActiveCellIndex + values.length; + notebook.deselectAll(); +} + +/** + * Plugin to override copy, cut, and duplicate commands to exclude outputs. + */ +export const overrideCopyPlugin: JupyterFrontEndPlugin = { + id: '@jupyter-ai-contrib/server-documents:override-copy-plugin', + description: + 'Overrides copy, cut, and duplicate commands to exclude cell outputs', + autoStart: true, + requires: [INotebookTracker], + activate: (app: JupyterFrontEnd, notebookTracker: INotebookTracker): void => { + /** + * Override commands after app is fully loaded + */ + app.restored.then(() => { + // Helper function to get original command and override only the execute + const overrideCommand = ( + commandId: string, + newExecute: () => void + ) => { + if (!app.commands.hasCommand(commandId)) { + return; + } + + // Get the original command descriptor + const commandRegistry = app.commands as any; + const originalCommand = commandRegistry._commands?.get(commandId); + + if (!originalCommand) { + return; + } + + // Store original properties + const originalOptions = { ...originalCommand }; + + // Remove existing command + if (commandRegistry._commands && commandRegistry._commands.delete) { + commandRegistry._commands.delete(commandId); + } + + // Re-add command with original properties but new execute + app.commands.addCommand(commandId, { + ...originalOptions, + execute: newExecute + }); + }; + + // Helper to get current notebook + const getCurrentNotebook = (): Notebook | null => { + return notebookTracker.currentWidget?.content ?? null; + }; + + // Override copy command + overrideCommand('notebook:copy-cell', () => { + const notebook = getCurrentNotebook(); + if (notebook) { + copyOrCut(notebook, false); + } + }); + + // Override cut command + overrideCommand('notebook:cut-cell', () => { + const notebook = getCurrentNotebook(); + if (notebook) { + copyOrCut(notebook, true); + } + }); + + // Override duplicate command + overrideCommand('notebook:duplicate-below', () => { + const notebook = getCurrentNotebook(); + if (notebook) { + duplicateWithoutOutputs(notebook); + } + }); + + console.log('Copy/cut/duplicate commands overridden to exclude outputs'); + }); + } +}; From 9b926ce2b5c985a2394ce66b68052e6f4040851f Mon Sep 17 00:00:00 2001 From: Piyush Jain Date: Wed, 3 Dec 2025 10:52:50 -0800 Subject: [PATCH 2/2] Added missing segments for copy --- src/overridecopy.ts | 167 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 140 insertions(+), 27 deletions(-) diff --git a/src/overridecopy.ts b/src/overridecopy.ts index 13e4cc8..aaa468c 100644 --- a/src/overridecopy.ts +++ b/src/overridecopy.ts @@ -3,7 +3,7 @@ import { JupyterFrontEndPlugin } from '@jupyterlab/application'; import { Clipboard } from '@jupyterlab/apputils'; -import { INotebookTracker, Notebook, NotebookActions } from '@jupyterlab/notebook'; +import { INotebookTracker, Notebook } from '@jupyterlab/notebook'; import * as nbformat from '@jupyterlab/nbformat'; import { JSONObject } from '@lumino/coreutils'; @@ -40,38 +40,153 @@ function selectedCellsWithoutOutputs(notebook: Notebook): nbformat.ICell[] { } /** - * Copy or cut the selected cell data to the clipboard without outputs. - * - * This is based on the private `copyOrCut()` function from JupyterLab's - * notebook actions. - * - * @param notebook - The target notebook widget. - * @param cut - True if the cells should be cut, false if they should be copied. - */ -function copyOrCut(notebook: Notebook, cut: boolean): void { - if (!notebook.model || !notebook.activeCell) { - return; + * The interface for a widget state. + */ + export interface IState { + /** + * Whether the widget had focus. + */ + wasFocused: boolean; + + /** + * The active cell id before the action. + * + * We cannot rely on the Cell widget or model as it may be + * discarded by action such as move. + */ + activeCellId: string | null; } - const clipboard = Clipboard.getInstance(); + /** + * Get the state of a widget before running an action. + */ + export function getState(notebook: Notebook): IState { + return { + wasFocused: notebook.node.contains(document.activeElement), + activeCellId: notebook.activeCell?.model.id ?? null + }; + } - notebook.mode = 'command'; - clipboard.clear(); + /** + * Handle the state of a widget after running an action. + */ + export async function handleState( + notebook: Notebook, + state: IState, + scrollIfNeeded = false + ): Promise { + const { activeCell, activeCellIndex } = notebook; + if (scrollIfNeeded && activeCell) { + await notebook.scrollToItem(activeCellIndex, 'auto', 0).catch(reason => { + // no-op + }); + } + if (state.wasFocused || notebook.mode === 'edit') { + notebook.activate(); + } + } - // Get selected cells without outputs - const data = selectedCellsWithoutOutputs(notebook); - console.log(data) +/** + * Delete the selected cells. + * + * @param notebook - The target notebook widget. + * + * #### Notes + * The cell after the last selected cell will be activated. + * If the last cell is deleted, then the previous one will be activated. + * It will add a code cell if all cells are deleted. + * This action can be undone. + */ + export function deleteCells(notebook: Notebook): void { + const model = notebook.model!; + const sharedModel = model.sharedModel; + const toDelete: number[] = []; - clipboard.setData(JUPYTER_CELL_MIME, data); + notebook.mode = 'command'; - if (cut) { - NotebookActions.deleteCells(notebook); - notebook.lastClipboardInteraction = 'cut'; - } else { + // Find the cells to delete. + notebook.widgets.forEach((child, index) => { + const deletable = child.model.getMetadata('deletable') !== false; + + if (notebook.isSelectedOrActive(child) && deletable) { + toDelete.push(index); + notebook.model?.deletedCells.push(child.model.id); + } + }); + + // If cells are not deletable, we may not have anything to delete. + if (toDelete.length > 0) { + // Delete the cells as one undo event. + sharedModel.transact(() => { + // Delete cells in reverse order to maintain the correct indices. + toDelete.reverse().forEach(index => { + sharedModel.deleteCell(index); + }); + + // Add a new cell if the notebook is empty. This is done + // within the compound operation to make the deletion of + // a notebook's last cell undoable. + if (sharedModel.cells.length == toDelete.length) { + sharedModel.insertCell(0, { + cell_type: notebook.notebookConfig.defaultCell, + metadata: + notebook.notebookConfig.defaultCell === 'code' + ? { + // This is an empty cell created in empty notebook, thus is trusted + trusted: true + } + : {} + }); + } + }); + // Select the *first* interior cell not deleted or the cell + // *after* the last selected cell. + // Note: The activeCellIndex is clamped to the available cells, + // so if the last cell is deleted the previous cell will be activated. + // The *first* index is the index of the last cell in the initial + // toDelete list due to the `reverse` operation above. + notebook.activeCellIndex = toDelete[0] - toDelete.length + 1; + } + + // Deselect any remaining, undeletable cells. Do this even if we don't + // delete anything so that users are aware *something* happened. notebook.deselectAll(); - notebook.lastClipboardInteraction = 'copy'; } -} + +/** + * Copy or cut the selected cell data to the clipboard without outputs. + * + * @param notebook - The target notebook widget. + * + * @param cut - True if the cells should be cut, false if they should be copied. + */ + export function copyOrCut(notebook: Notebook, cut: boolean): void { + if (!notebook.model || !notebook.activeCell) { + return; + } + + const state = getState(notebook); + const clipboard = Clipboard.getInstance(); + + notebook.mode = 'command'; + clipboard.clear(); + + const data = selectedCellsWithoutOutputs(notebook); + console.log(data) + + clipboard.setData(JUPYTER_CELL_MIME, data); + if (cut) { + deleteCells(notebook); + } else { + notebook.deselectAll(); + } + if (cut) { + notebook.lastClipboardInteraction = 'cut'; + } else { + notebook.lastClipboardInteraction = 'copy'; + } + void handleState(notebook, state); + } /** * Duplicate selected cells without outputs. @@ -194,8 +309,6 @@ export const overrideCopyPlugin: JupyterFrontEndPlugin = { duplicateWithoutOutputs(notebook); } }); - - console.log('Copy/cut/duplicate commands overridden to exclude outputs'); }); } };