From 1d2e4559c69db21a20b6aa6b9b37cbbecd4100ba Mon Sep 17 00:00:00 2001 From: Duc Trung LE Date: Wed, 18 Dec 2024 17:10:37 +0100 Subject: [PATCH 1/2] Support cell deletion suggestion --- package.json | 1 + packages/base/src/baseSuggestionsManager.ts | 8 +- packages/base/src/icons.ts | 6 + packages/base/src/index.ts | 9 +- .../localSuggestionsManager.ts | 167 ++++++++--------- .../suggestionCellMenu/cellToolbarMenu.tsx | 175 ++++++++++++++++++ packages/base/src/suggestionCellMenu/index.ts | 1 + packages/base/src/suggestionCellMenu/style.ts | 48 +++++ packages/base/src/suggestionsPanel/model.ts | 71 ++++++- .../{cellWidget.ts => baseWidget.ts} | 25 +-- .../suggestionWidget/changeCellWidget.ts | 27 +++ .../suggestionWidget/deleteCellWidget.ts | 19 ++ .../suggestionWidget/index.ts | 4 +- .../suggestionWidget/style.ts | 29 +++ .../suggestionWidget/suggestionToolbar.tsx | 11 +- packages/base/src/suggestionsPanel/widget.ts | 124 +++++++++---- packages/base/src/tools.ts | 133 ++++++++++++- packages/base/src/types.ts | 42 ++++- packages/base/style/icon/cross.svg | 4 + packages/base/style/icon/location.svg | 4 + python/jupyter_suggestions_core/package.json | 6 +- .../schema/commands.json | 3 +- python/jupyter_suggestions_core/src/index.ts | 6 +- .../jupyter_suggestions_core/src/plugins.ts | 65 ++++++- python/jupyter_suggestions_rtc/src/manager.ts | 122 ++++++++---- yarn.lock | 18 +- 26 files changed, 917 insertions(+), 211 deletions(-) create mode 100644 packages/base/src/suggestionCellMenu/cellToolbarMenu.tsx create mode 100644 packages/base/src/suggestionCellMenu/index.ts create mode 100644 packages/base/src/suggestionCellMenu/style.ts rename packages/base/src/suggestionsPanel/suggestionWidget/{cellWidget.ts => baseWidget.ts} (90%) create mode 100644 packages/base/src/suggestionsPanel/suggestionWidget/changeCellWidget.ts create mode 100644 packages/base/src/suggestionsPanel/suggestionWidget/deleteCellWidget.ts create mode 100644 packages/base/style/icon/cross.svg create mode 100644 packages/base/style/icon/location.svg diff --git a/package.json b/package.json index 91911cc..331fa6e 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@types/json-schema": "^7.0.11", "@types/react": "^18.0.26", "@types/react-addons-linked-state-mixin": "^0.14.22", + "@types/react-dom": "^18.0.26", "@typescript-eslint/eslint-plugin": "^6.1.0", "@typescript-eslint/parser": "^6.1.0", "css-loader": "^6.7.1", diff --git a/packages/base/src/baseSuggestionsManager.ts b/packages/base/src/baseSuggestionsManager.ts index b6d6881..27a20bb 100644 --- a/packages/base/src/baseSuggestionsManager.ts +++ b/packages/base/src/baseSuggestionsManager.ts @@ -6,7 +6,8 @@ import { IAllSuggestionData, ISuggestionChange, ISuggestionData, - ISuggestionsManager + ISuggestionsManager, + SuggestionType } from './types'; import { User } from '@jupyterlab/services'; @@ -56,6 +57,7 @@ export abstract class BaseSuggestionsManager implements ISuggestionsManager { notebook: NotebookPanel; cell: Cell; author?: User.IIdentity | null; + type: SuggestionType; }): Promise; abstract acceptSuggestion(options: { @@ -77,8 +79,6 @@ export abstract class BaseSuggestionsManager implements ISuggestionsManager { newSource: string; }): Promise; - protected _suggestionsMap = new Map(); - protected _notebookAdded(tracker: INotebookTracker, panel: NotebookPanel) { panel.disposed.connect(p => { const localPath = p.context.localPath; @@ -88,6 +88,8 @@ export abstract class BaseSuggestionsManager implements ISuggestionsManager { }); } + protected _suggestionsMap = new Map(); + protected _suggestionChanged = new Signal< ISuggestionsManager, ISuggestionChange diff --git a/packages/base/src/icons.ts b/packages/base/src/icons.ts index 071b11c..bed8f14 100644 --- a/packages/base/src/icons.ts +++ b/packages/base/src/icons.ts @@ -3,6 +3,7 @@ import hintStr from '../style/icon/hint.svg'; import minimizeStr from '../style/icon/minimize.svg'; import expandStr from '../style/icon/expand.svg'; import collapseStr from '../style/icon/collapse.svg'; +import locationStr from '../style/icon/location.svg'; export const hintIcon = new LabIcon({ name: 'jupyter-suggestions:hintIcon', @@ -20,3 +21,8 @@ export const collapseIcon = new LabIcon({ name: 'jupyter-suggestions:collapseIcon', svgstr: collapseStr }); + +export const locationIcon = new LabIcon({ + name: 'jupyter-suggestions:locationIcon', + svgstr: locationStr +}); diff --git a/packages/base/src/index.ts b/packages/base/src/index.ts index 4bd1186..717e599 100644 --- a/packages/base/src/index.ts +++ b/packages/base/src/index.ts @@ -1,8 +1,9 @@ -export * from './types'; -export * from './suggestionsPanel'; +export * from './baseSuggestionsManager'; export * from './icons'; -export * from './tokens'; export * from './localSuggestionsManager'; -export * from './baseSuggestionsManager'; export * from './registry'; +export * from './suggestionCellMenu'; +export * from './suggestionsPanel'; +export * from './tokens'; export * from './tools'; +export * from './types'; diff --git a/packages/base/src/localSuggestionsManager/localSuggestionsManager.ts b/packages/base/src/localSuggestionsManager/localSuggestionsManager.ts index 90402b5..2c59a21 100644 --- a/packages/base/src/localSuggestionsManager/localSuggestionsManager.ts +++ b/packages/base/src/localSuggestionsManager/localSuggestionsManager.ts @@ -1,29 +1,25 @@ -import { - Cell, - CellModel, - CodeCellModel, - ICellModel, - MarkdownCellModel, - RawCellModel -} from '@jupyterlab/cells'; +import { Cell, ICellModel } from '@jupyterlab/cells'; import { ICell } from '@jupyterlab/nbformat'; import { NotebookPanel } from '@jupyterlab/notebook'; +import { User } from '@jupyterlab/services'; import { UUID } from '@lumino/coreutils'; import { BaseSuggestionsManager } from '../baseSuggestionsManager'; +import { cloneCellModel, deleteCellById } from '../tools'; import { IAllSuggestionData, IDict, ISuggestionData, ISuggestionMetadata, - ISuggestionsManager + ISuggestionsManager, + SuggestionType } from '../types'; -import { User } from '@jupyterlab/services'; export interface ISerializedSuggessionData { originalCellId: string; newSource: string; metadata: ISuggestionMetadata; + type: SuggestionType; } const METADATA_KEY = 'jupyter_suggestion'; @@ -96,37 +92,50 @@ export class LocalSuggestionsManager notebook: NotebookPanel; cell: Cell; author?: User.IIdentity | null; + type: SuggestionType; }): Promise { - const { notebook, cell, author } = options; - const path = notebook.context.localPath; - if (!this._suggestionsMap.has(path)) { - this._suggestionsMap.set(path, new Map()); - } - const currentSuggestions = this._suggestionsMap.get(path)!; - const cellId = cell.model.id; - if (!currentSuggestions.has(cellId)) { - currentSuggestions.set(cellId, {}); - } - const cellSuggesions = currentSuggestions.get(cellId)!; const suggestionId = UUID.uuid4(); - const suggestionContent: ISuggestionData = { - originalCellId: cellId, - cellModel: this._cloneCellModel(cell.model), - metadata: { author } - }; - cellSuggesions[suggestionId] = suggestionContent; - await this._saveSuggestionToMetadata({ - notebook, - cellId, - suggestionId, - suggestionContent - }); - this._suggestionChanged.emit({ - notebookPath: path, - cellId, - suggestionId, - operator: 'added' - }); + switch (options.type) { + case SuggestionType.delete: + case SuggestionType.change: { + const { notebook, cell, author } = options; + const path = notebook.context.localPath; + if (!this._suggestionsMap.has(path)) { + this._suggestionsMap.set(path, new Map()); + } + const currentSuggestions = this._suggestionsMap.get(path)!; + const cellId = cell.model.id; + if (!currentSuggestions.has(cellId)) { + currentSuggestions.set(cellId, {}); + } + const cellSuggesions = currentSuggestions.get(cellId)!; + + const suggestionContent: ISuggestionData = { + originalCellId: cellId, + cellModel: cloneCellModel(cell.model), + metadata: { author }, + type: options.type + }; + cellSuggesions[suggestionId] = suggestionContent; + await this._saveSuggestionToMetadata({ + notebook, + cellId, + suggestionId, + suggestionContent + }); + this._suggestionChanged.emit({ + notebookPath: path, + cellId, + suggestionId, + operator: 'added' + }); + break; + } + + default: + break; + } + return suggestionId; } @@ -137,20 +146,38 @@ export class LocalSuggestionsManager }): Promise { const { notebook, cellId, suggestionId } = options; const notebookPath = notebook.context.localPath; - const currentSuggestion = await this.getSuggestion({ notebookPath, cellId, suggestionId }); if (currentSuggestion && notebook.content.model?.cells) { - const newSource = currentSuggestion.cellModel.sharedModel.getSource(); - for (const element of notebook.content.model.cells) { - if (element.id === cellId) { - element.sharedModel.setSource(newSource); - await this.deleteSuggestion(options); - return true; + switch (currentSuggestion.type) { + case SuggestionType.change: { + // In case of a change suggestion. the cell model is always defined + const newSource = + currentSuggestion.cellModel!.sharedModel.getSource(); + for (const element of notebook.content.model.cells) { + if (element.id === cellId) { + element.sharedModel.setSource(newSource); + await this.deleteSuggestion(options); + return true; + } + } + break; + } + case SuggestionType.delete: { + const currentNotebook = notebook.content; + const { defaultCell } = currentNotebook.notebookConfig; + const deleted = await deleteCellById({ + currentNotebook, + cellId, + defaultCell + }); + return deleted; } + default: + break; } } return false; @@ -219,12 +246,14 @@ export class LocalSuggestionsManager suggestionContent: ISuggestionData; }) { const { notebook, cellId, suggestionId, suggestionContent } = options; + const { originalCellId, cellModel, metadata, type } = suggestionContent; const currentSuggestions: IDict> = notebook.context.model.getMetadata(METADATA_KEY) ?? {}; const serializedData: ISerializedSuggessionData = { - originalCellId: suggestionContent.originalCellId, - newSource: suggestionContent.cellModel.sharedModel.getSource(), - metadata: suggestionContent.metadata + originalCellId, + newSource: cellModel?.sharedModel?.getSource() ?? '', + metadata: metadata, + type }; const newData = { ...currentSuggestions, @@ -288,50 +317,18 @@ export class LocalSuggestionsManager await this._saveNotebook(notebook); } - private _cloneCellModel( - cellModel: ICellModel, - newSource?: string - ): ICellModel { - let copiedCellModel: CellModel | undefined; - const mimeType = cellModel.mimeType; - switch (cellModel.type) { - case 'code': { - copiedCellModel = new CodeCellModel(); - break; - } - case 'markdown': { - copiedCellModel = new MarkdownCellModel(); - break; - } - case 'raw': { - copiedCellModel = new RawCellModel(); - break; - } - default: - break; - } - - if (!copiedCellModel) { - throw new Error('Invalid cell type'); - } - copiedCellModel.mimeType = mimeType; - copiedCellModel.sharedModel.setSource( - newSource ?? cellModel.sharedModel.getSource() - ); - return copiedCellModel; - } - private _deserializedSuggestion( serializedData: ISerializedSuggessionData, cellMap: IDict ): ISuggestionData { - const { originalCellId, newSource, metadata } = serializedData; + const { originalCellId, newSource, metadata, type } = serializedData; const originalCellModel = cellMap[serializedData.originalCellId]; - const newCellModel = this._cloneCellModel(originalCellModel, newSource); + const newCellModel = cloneCellModel(originalCellModel, newSource); return { originalCellId, cellModel: newCellModel, - metadata + metadata, + type }; } } diff --git a/packages/base/src/suggestionCellMenu/cellToolbarMenu.tsx b/packages/base/src/suggestionCellMenu/cellToolbarMenu.tsx new file mode 100644 index 0000000..142fa13 --- /dev/null +++ b/packages/base/src/suggestionCellMenu/cellToolbarMenu.tsx @@ -0,0 +1,175 @@ +import { Cell } from '@jupyterlab/cells'; +import { + deleteIcon, + editIcon, + ReactWidget, + ToolbarButtonComponent +} from '@jupyterlab/ui-components'; +import * as React from 'react'; +import ReactDOM from 'react-dom'; +import { hintIcon } from '../icons'; +import { + activated, + cellWithSuggestionStyle, + toolbarMenuButtonStyle, + toolbarMenuItemStyle, + toolbarMenuStyle +} from './style'; +import { CommandRegistry } from '@lumino/commands'; +import { COMMAND_IDS } from '../tools'; +import { ISuggestionChange, ISuggestionsModel } from '../types'; +interface IProps { + cell?: Cell; + commands: CommandRegistry; + suggestionModel: ISuggestionsModel; +} +interface IMenuProps { + open: boolean; + left: string; + top: string; + executeAddSuggestion: () => void; + executeAddDeleteCellSuggestion: () => void; +} +const Menu = React.forwardRef((props: IMenuProps, ref) => { + const { + left, + top, + executeAddSuggestion, + executeAddDeleteCellSuggestion, + open + } = props; + return ReactDOM.createPortal( +
+
    +
  • +
    + +
    +
    + Suggest change +
    +
  • +
  • +
    + +
    +
    + Suggest delete +
    +
  • +
+
, + document.body + ); +}); +function _CellToolbarMenuReact(props: IProps) { + const { commands, cell, suggestionModel } = props; + const [open, setOpen] = React.useState(false); + const forceUpdate = React.useReducer(x => x + 1, 0); + const [pos, setPos] = React.useState<{ top: string; left: string }>({ + top: '0px', + left: '0px' + }); + const currentDiv = React.useRef(null); + const menuRef = React.useRef(null); + const updatePosition = React.useCallback(() => { + if (currentDiv.current) { + const clientRect = currentDiv.current.getBoundingClientRect(); + const left = clientRect.left; + const top = clientRect.top; + setPos({ left: `${left}px`, top: `${top}px` }); + } + }, []); + const toggleOpen = React.useCallback(() => { + updatePosition(); + setOpen(old => !old); + }, [updatePosition]); + const executeAddSuggestion = React.useCallback(() => { + commands.execute(COMMAND_IDS.addCellSuggestion); + setOpen(false); + }, [commands]); + const executeAddDeleteCellSuggestion = React.useCallback(() => { + commands.execute(COMMAND_IDS.addDeleteCellSuggestion); + setOpen(false); + }, [commands]); + + React.useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as any)) { + setOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [currentDiv]); + const cellSuggestions = cell?.model?.id + ? (suggestionModel.getCellSuggestions({ + cellId: cell.model.id + }) ?? {}) + : {}; + const hasSuggestion = Object.keys(cellSuggestions).length; + React.useEffect(() => { + const handler = ( + model: ISuggestionsModel, + changed: Omit + ) => { + if (changed.cellId === cell?.model?.id) { + setTimeout(() => { + forceUpdate[1](); + }, 0); // Refresh in the next tick + } + }; + suggestionModel.suggestionChanged.connect(handler); + return () => { + suggestionModel.suggestionChanged.disconnect(handler); + }; + }); + return ( +
+ + +
+ ); +} + +const CellToolbarMenuReact = React.memo(_CellToolbarMenuReact); + +export class CellToolbarMenu extends ReactWidget { + constructor(private options: IProps) { + super(); + } + + render(): JSX.Element { + return ; + } +} diff --git a/packages/base/src/suggestionCellMenu/index.ts b/packages/base/src/suggestionCellMenu/index.ts new file mode 100644 index 0000000..70751d4 --- /dev/null +++ b/packages/base/src/suggestionCellMenu/index.ts @@ -0,0 +1 @@ +export * from './cellToolbarMenu'; diff --git a/packages/base/src/suggestionCellMenu/style.ts b/packages/base/src/suggestionCellMenu/style.ts new file mode 100644 index 0000000..76ff1dd --- /dev/null +++ b/packages/base/src/suggestionCellMenu/style.ts @@ -0,0 +1,48 @@ +import { style } from 'typestyle'; + +export const toolbarMenuButtonStyle = style({ + height: '29px' +}); + +export const toolbarMenuStyle = style({ + height: 'auto', + width: 'max-content', + color: 'var(--jp-ui-font-color1)', + textAlign: 'justify', + borderRadius: '6px', + padding: '0 5px', + position: 'fixed', + display: 'table', + visibility: 'hidden', + transform: 'translateX(calc(-100% + 30px)) translateY(calc(30px))' +}); + +export const toolbarMenuItemStyle = style({ + $nest: { + '&:hover': { + background: 'var(--jp-layout-color2)' + }, + '& > .lm-Menu-itemIcon': { + display: 'inline-block', + verticalAlign: 'middle', + $nest: { + '& > div': { + display: 'flex' + } + } + } + } +}); + +export const activated = style({ + boxShadow: 'inset 0px 0px 2px 2px var(--neutral-fill-hover)', + background: 'var(--jp-layout-color0)' +}); + +export const cellWithSuggestionStyle = style({ + $nest: { + '& path': { + fill: 'var(--jp-brand-color1)!important' + } + } +}); diff --git a/packages/base/src/suggestionsPanel/model.ts b/packages/base/src/suggestionsPanel/model.ts index 2d4640f..3893d76 100644 --- a/packages/base/src/suggestionsPanel/model.ts +++ b/packages/base/src/suggestionsPanel/model.ts @@ -13,9 +13,10 @@ import { ISuggestionData, ISuggestionsManager, ISuggestionsModel, - ISuggestionViewData + ISuggestionViewData, + SuggestionType } from '../types'; -import { detectCellChangedEvent, getCellMap } from '../tools'; +import { cloneCellModel, detectCellChangedEvent, getCellMap } from '../tools'; export class SuggestionsModel implements ISuggestionsModel { constructor(options: SuggestionsModel.IOptions) { @@ -65,13 +66,14 @@ export class SuggestionsModel implements ISuggestionsModel { getSuggestionManagerName(): string { return this._suggestionsManager?.name ?? ''; } - async addSuggestion(): Promise { + async addSuggestion(options: { type: SuggestionType }): Promise { const activeCell = this._notebookPanel?.content.activeCell; if (activeCell && this._notebookPanel && this._suggestionsManager) { await this._suggestionsManager.addSuggestion({ notebook: this._notebookPanel, cell: activeCell, - author: this._userManager.identity + author: this._userManager.identity, + type: options.type }); } } @@ -101,11 +103,18 @@ export class SuggestionsModel implements ISuggestionsModel { }): Promise { const { cellId, suggestionId } = options; if (cellId && this._notebookPanel && this._suggestionsManager) { - return await this._suggestionsManager.acceptSuggestion({ + const accepted = await this._suggestionsManager.acceptSuggestion({ notebook: this._notebookPanel, cellId, suggestionId }); + if (accepted) { + if (this._allSuggestions && this._allSuggestions.has(cellId)) { + const cellSuggestions = this._allSuggestions.get(cellId)!; + delete cellSuggestions[suggestionId]; + } + } + return accepted; } return false; } @@ -155,6 +164,23 @@ export class SuggestionsModel implements ISuggestionsModel { return cellSuggestions[suggestionId]; } + getCellSuggestions(options: { + cellId: string; + }): IDict | undefined { + if (!this._filePath || !this._suggestionsManager) { + return; + } + const { cellId } = options; + let cellSuggestions: IDict | undefined = undefined; + if (!this._allSuggestions?.has(cellId)) { + cellSuggestions = {}; + this._allSuggestions?.set(cellId, cellSuggestions); + } else { + cellSuggestions = this._allSuggestions.get(cellId)!; + } + return cellSuggestions; + } + getActiveCell(): Cell | null | undefined { return this._notebookPanel?.content.activeCell; } @@ -218,20 +244,42 @@ export class SuggestionsModel implements ISuggestionsModel { } } + nativateToCell(cellId?: string): void { + if (cellId && this._notebookPanel) { + const index = this.getCellIndex(cellId); + if (index !== -1) { + const target = this._notebookPanel.content._findCellById(cellId); + if (target) { + this._notebookPanel.content.activeCellIndex = index; + this._notebookPanel.content.scrollToCell(target.cell); + } + } + } + } + private _convertSuggestionFromManager( source?: ISuggestionData ): ISuggestionViewData | undefined { if (!source || !this._notebookPanel) { return; } - const { originalCellId, metadata, cellModel } = source; + const { originalCellId, metadata, cellModel, type } = source; const cells = this._notebookPanel.context.model.cells; for (const it of cells) { if (it.id === originalCellId) { + let newCellModel: ICellModel; + if (type === SuggestionType.delete || cellModel === null) { + // if `cellModel` is null, it's a cell deletion suggestion, + // we just use the origianl model to render the suggestion widget + newCellModel = cloneCellModel(it); + } else { + newCellModel = cellModel; + } return { - cellModel, + cellModel: newCellModel, originalCellModel: it, - metadata + metadata, + type }; } } @@ -374,6 +422,13 @@ export class SuggestionsModel implements ISuggestionsModel { const pd = this._promiseQueue[cellId]; pd.resolve(); } else { + if (changed.operator === 'deleted') { + const { cellId, suggestionId } = changed; + if (this._allSuggestions?.has(cellId)) { + const cellSuggestions = this._allSuggestions.get(cellId)!; + delete cellSuggestions[suggestionId]; + } + } this._suggestionChanged.emit(newChanged); } } diff --git a/packages/base/src/suggestionsPanel/suggestionWidget/cellWidget.ts b/packages/base/src/suggestionsPanel/suggestionWidget/baseWidget.ts similarity index 90% rename from packages/base/src/suggestionsPanel/suggestionWidget/cellWidget.ts rename to packages/base/src/suggestionsPanel/suggestionWidget/baseWidget.ts index 1c53a8d..ad1627b 100644 --- a/packages/base/src/suggestionsPanel/suggestionWidget/cellWidget.ts +++ b/packages/base/src/suggestionsPanel/suggestionWidget/baseWidget.ts @@ -27,17 +27,16 @@ import { Signal } from '@lumino/signaling'; import { Panel } from '@lumino/widgets'; import { ISuggestionViewData } from '../../types'; -import { diffTextExtensionFactory } from '../cmExtension'; import { suggestionCellStyle } from './style'; import { SuggestionToolbar } from './suggestionToolbar'; -export class CellWidget extends Panel { - constructor(options: CellWidget.IOptions) { +export class BaseCellwidget extends Panel { + constructor(options: BaseCellwidget.IOptions) { super(options); const { suggestionData, liveUpdate } = options; const { originalCellModel, cellModel } = suggestionData; this.addClass(suggestionCellStyle); - this._cellId = cellModel.id as string | undefined; + this._cellId = cellModel?.id as string | undefined; const cellWidget = this._createCell( originalCellModel, cellModel, @@ -50,6 +49,7 @@ export class CellWidget extends Panel { toggleMinimized: this.toggleMinimized.bind(this), deleteCallback: options.deleteCallback, acceptCallback: options.acceptCallback, + navigateCallback: options.navigateCallback, state: this._state, metadata: suggestionData.metadata }); @@ -77,7 +77,7 @@ export class CellWidget extends Panel { } } - private _cmExtensioRegistry( + protected _cmExtensioRegistry( originalCell: ICellModel, liveUpdate: boolean ): EditorExtensionRegistry { @@ -103,18 +103,10 @@ export class CellWidget extends Panel { ); } }); - registry.addExtension({ - name: 'suggestion-view', - factory: options => { - return EditorExtensionRegistry.createImmutableExtension([ - diffTextExtensionFactory({ originalCell, liveUpdate }) - ]); - } - }); return registry; } - private _cmLanguageRegistry(): EditorLanguageRegistry { + protected _cmLanguageRegistry(): EditorLanguageRegistry { const languages = new EditorLanguageRegistry(); EditorLanguageRegistry.getDefaultLanguages() .filter(language => @@ -136,7 +128,7 @@ export class CellWidget extends Panel { }); return languages; } - private _createCell( + protected _createCell( originalCell: ICellModel, cellModel: ICellModel, liveUpdate: boolean @@ -201,11 +193,12 @@ export class CellWidget extends Panel { private _cellWidget: CodeCell | MarkdownCell | RawCell | undefined; } -export namespace CellWidget { +export namespace BaseCellwidget { export interface IOptions extends Panel.IOptions { suggestionData: ISuggestionViewData; deleteCallback: () => Promise; acceptCallback: () => Promise; + navigateCallback: () => Promise; liveUpdate: boolean; } } diff --git a/packages/base/src/suggestionsPanel/suggestionWidget/changeCellWidget.ts b/packages/base/src/suggestionsPanel/suggestionWidget/changeCellWidget.ts new file mode 100644 index 0000000..2a8e3e5 --- /dev/null +++ b/packages/base/src/suggestionsPanel/suggestionWidget/changeCellWidget.ts @@ -0,0 +1,27 @@ +import { ICellModel } from '@jupyterlab/cells'; +import { EditorExtensionRegistry } from '@jupyterlab/codemirror'; + +import { diffTextExtensionFactory } from '../cmExtension'; +import { BaseCellwidget } from './baseWidget'; + +export class SuggestChangeCellWidget extends BaseCellwidget { + constructor(options: BaseCellwidget.IOptions) { + super(options); + } + + protected _cmExtensioRegistry( + originalCell: ICellModel, + liveUpdate: boolean + ): EditorExtensionRegistry { + const registry = super._cmExtensioRegistry(originalCell, liveUpdate); + registry.addExtension({ + name: 'suggestion-view', + factory: options => { + return EditorExtensionRegistry.createImmutableExtension([ + diffTextExtensionFactory({ originalCell, liveUpdate }) + ]); + } + }); + return registry; + } +} diff --git a/packages/base/src/suggestionsPanel/suggestionWidget/deleteCellWidget.ts b/packages/base/src/suggestionsPanel/suggestionWidget/deleteCellWidget.ts new file mode 100644 index 0000000..2e2d844 --- /dev/null +++ b/packages/base/src/suggestionsPanel/suggestionWidget/deleteCellWidget.ts @@ -0,0 +1,19 @@ +import { ICellModel, CodeCell, MarkdownCell, RawCell } from '@jupyterlab/cells'; +import { BaseCellwidget } from './baseWidget'; +import { deletedCellStyle } from './style'; + +export class SuggestDeleteCellWidget extends BaseCellwidget { + constructor(options: BaseCellwidget.IOptions) { + super(options); + } + + protected _createCell( + originalCell: ICellModel, + cellModel: ICellModel, + liveUpdate: boolean + ): CodeCell | MarkdownCell | RawCell | undefined { + const cellWidget = super._createCell(originalCell, cellModel, liveUpdate); + cellWidget?.addClass(deletedCellStyle); + return cellWidget; + } +} diff --git a/packages/base/src/suggestionsPanel/suggestionWidget/index.ts b/packages/base/src/suggestionsPanel/suggestionWidget/index.ts index e55cc24..d040e59 100644 --- a/packages/base/src/suggestionsPanel/suggestionWidget/index.ts +++ b/packages/base/src/suggestionsPanel/suggestionWidget/index.ts @@ -1,3 +1,5 @@ -export * from './cellWidget'; +export * from './changeCellWidget'; +export * from './deleteCellWidget'; export * from './suggestionToolbar'; export * from './style'; +export * from './baseWidget'; diff --git a/packages/base/src/suggestionsPanel/suggestionWidget/style.ts b/packages/base/src/suggestionsPanel/suggestionWidget/style.ts index d72dc8a..c700cc3 100644 --- a/packages/base/src/suggestionsPanel/suggestionWidget/style.ts +++ b/packages/base/src/suggestionsPanel/suggestionWidget/style.ts @@ -1,4 +1,6 @@ import { style } from 'typestyle'; +import { NestedCSSProperties } from 'typestyle/lib/types'; +import crossStr from '../../../style/icon/cross.svg'; export const toolbarStyle = style({ height: '26px', @@ -33,3 +35,30 @@ export const suggestionCellSelectedStyle = style({ } } }); + +const commonStyle: NestedCSSProperties = { + content: '""', + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + backgroundRepeat: 'no-repeat', + backgroundSize: '100% 100%', + + pointerEvents: 'none' +}; +export const deletedCellStyle = style({ + $nest: { + '&::before': { + ...commonStyle, + backgroundImage: `url("data:image/svg+xml;utf8,${encodeURIComponent(crossStr)}")`, + zIndex: 2000 + }, + '&::after': { + ...commonStyle, + backgroundColor: '#ff000012', + zIndex: 3000 + } + } +}); diff --git a/packages/base/src/suggestionsPanel/suggestionWidget/suggestionToolbar.tsx b/packages/base/src/suggestionsPanel/suggestionWidget/suggestionToolbar.tsx index 68bf04f..f4726ac 100644 --- a/packages/base/src/suggestionsPanel/suggestionWidget/suggestionToolbar.tsx +++ b/packages/base/src/suggestionsPanel/suggestionWidget/suggestionToolbar.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { ReactWidget, ToolbarButtonComponent } from '@jupyterlab/apputils'; import { toolbarButtonStyle, toolbarStyle } from './style'; import { checkIcon, closeIcon } from '@jupyterlab/ui-components'; -import { collapseIcon, expandIcon } from '../../icons'; +import { collapseIcon, expandIcon, locationIcon } from '../../icons'; import { IObservableMap } from '@jupyterlab/observables'; import { JSONValue } from '@lumino/coreutils'; import { ISuggestionMetadata } from '../../types'; @@ -12,6 +12,7 @@ interface IProps { toggleMinimized: (min: boolean) => void; deleteCallback: () => Promise; acceptCallback: () => Promise; + navigateCallback: () => Promise; metadata: ISuggestionMetadata; } function _SuggestionToolbarReact(props: IProps) { @@ -33,7 +34,7 @@ function _SuggestionToolbarReact(props: IProps) { state.changed.disconnect(handler); }; }, [state]); - const userData = props.metadata.author; + const userData = props.metadata?.author; return (
{userData && ( @@ -63,6 +64,12 @@ function _SuggestionToolbarReact(props: IProps) { onClick={minimizeClick} iconLabel={elementState.minimized ? 'Expand' : 'Collapse'} /> +
); } diff --git a/packages/base/src/suggestionsPanel/widget.ts b/packages/base/src/suggestionsPanel/widget.ts index a3328a6..32fd811 100644 --- a/packages/base/src/suggestionsPanel/widget.ts +++ b/packages/base/src/suggestionsPanel/widget.ts @@ -1,17 +1,23 @@ +import { ISharedCell } from '@jupyter/ydoc'; +import { Dialog, showDialog } from '@jupyterlab/apputils'; import { PanelWithToolbar } from '@jupyterlab/ui-components'; +import { Debouncer } from '@lumino/polling'; import { Panel, Widget } from '@lumino/widgets'; import { IDict, ISuggestionChange, + ISuggestionsModel, ISuggestionViewData, - ISuggestionsModel + SuggestionType } from '../types'; -import { CellWidget, suggestionCellSelectedStyle } from './suggestionWidget'; import { suggestionsWidgetAreaStyle } from './style'; -import { Dialog, showDialog } from '@jupyterlab/apputils'; -import { ISharedCell } from '@jupyter/ydoc'; -import { Debouncer } from '@lumino/polling'; +import { + BaseCellwidget, + SuggestChangeCellWidget, + SuggestDeleteCellWidget, + suggestionCellSelectedStyle +} from './suggestionWidget'; export class SuggestionsWidget extends PanelWithToolbar { constructor(options: SuggestionsWidget.IOptions) { @@ -55,28 +61,53 @@ export class SuggestionsWidget extends PanelWithToolbar { suggestionData: suggestion }); const cellIdx = this._model.getCellIndex(cellId); - let cellSuggestionPanel = this._cellSuggestionsPanel.get(cellId); - if (!cellSuggestionPanel) { - cellSuggestionPanel = new Panel(); - this._cellSuggestionsPanel.set(cellId, cellSuggestionPanel); - this._suggestionsArea.insertWidget(cellIdx, cellSuggestionPanel); + let cellSuggestionPanelData = this._cellSuggestionsPanel.get(cellId); + + if (!cellSuggestionPanelData) { + const cellSuggestionPanel = new Panel(); + cellSuggestionPanelData = { + panel: cellSuggestionPanel, + cellIndex: cellIdx + }; + this._cellSuggestionsPanel.set(cellId, cellSuggestionPanelData); + let elementBefore: Widget | undefined = undefined; + const allIndex = [...this._cellSuggestionsPanel.values()].sort( + (a, b) => a.cellIndex - b.cellIndex + ); + for (const element of allIndex) { + if (element.cellIndex < cellIdx) { + elementBefore = element.panel; + } else { + break; + } + } + let indexToInsert = 0; + if (elementBefore) { + indexToInsert = + this._suggestionsArea.widgets.indexOf(elementBefore) + 1; + } + this._suggestionsArea.insertWidget( + indexToInsert, + cellSuggestionPanel + ); } - cellSuggestionPanel.addWidget(widget); + cellSuggestionPanelData.panel.addWidget(widget); this._highlightCellSuggestions(cellId); } break; } case 'deleted': { - const cellSuggestionsPanel = this._cellSuggestionsPanel.get(cellId); - if (!cellSuggestionsPanel) { + const cellSuggestionsData = this._cellSuggestionsPanel.get(cellId); + if (!cellSuggestionsData) { break; } - const allWidgets = [...cellSuggestionsPanel.widgets]; + const allWidgets = [...cellSuggestionsData.panel.widgets]; for (const element of allWidgets) { if (element.id === suggestionId) { element.dispose(); element.parent = null; + break; } } @@ -102,48 +133,55 @@ export class SuggestionsWidget extends PanelWithToolbar { } private _highlightCellSuggestions(cellId: string): void { this._cellSuggestionsPanel.forEach((p, k) => { + const { panel } = p; if (k === cellId) { - p.addClass(suggestionCellSelectedStyle); + panel.addClass(suggestionCellSelectedStyle); let lastElement: Widget | undefined = undefined; - for (const element of p.widgets) { - (element as CellWidget).toggleMinimized(false); + for (const element of panel.widgets) { + (element as BaseCellwidget).toggleMinimized(false); lastElement = element; } - this._scrollToWidget(lastElement); + this._scrollToWidget(panel, lastElement); } else { - p.removeClass(suggestionCellSelectedStyle); + panel.removeClass(suggestionCellSelectedStyle); } }); } - private _scrollToWidget(w?: Widget) { - if (!w) { + private _scrollToWidget(parent?: Widget, w?: Widget) { + if (!parent || !w) { return; } - const topPos = w.node.offsetTop; - this._suggestionsArea.node.scrollTop = topPos; + const parentToTop = parent.node.offsetTop; + + const childToParent = w.node.offsetTop; + this._suggestionsArea.node.scrollTop = parentToTop + childToParent; } private _renderSuggestions() { const allSuggestions = this._model.allSuggestions; - this._cellSuggestionsPanel.forEach(p => { - const allWidgets = [...p.widgets]; + this._cellSuggestionsPanel.forEach(it => { + const { panel } = it; + const allWidgets = [...panel.widgets]; for (const element of allWidgets) { - p.layout?.removeWidget(element); + panel.layout?.removeWidget(element); element.dispose(); element.parent = null; } - p.dispose(); - p.parent = null; - this._suggestionsArea.layout?.removeWidget(p); + panel.dispose(); + panel.parent = null; + this._suggestionsArea.layout?.removeWidget(panel); }); this._cellSuggestionsPanel.clear(); if (allSuggestions) { const suggestionPanelByIndex: IDict = {}; for (const [cellId, val] of allSuggestions.entries()) { const cellSuggestionPanel = new Panel(); - this._cellSuggestionsPanel.set(cellId, cellSuggestionPanel); const cellIdx = this._model.getCellIndex(cellId); + this._cellSuggestionsPanel.set(cellId, { + panel: cellSuggestionPanel, + cellIndex: cellIdx + }); suggestionPanelByIndex[cellIdx] = cellSuggestionPanel; Object.entries(val).forEach(([suggestionId, suggestionData]) => { const { widget } = this._widgetFactory({ @@ -174,7 +212,7 @@ export class SuggestionsWidget extends PanelWithToolbar { private _updatePanelTitle(): void { let count = 0; this._cellSuggestionsPanel.forEach(it => { - count += it.widgets.length; + count += it.panel.widgets.length; }); if (count !== 0) { this.title.label = `All Suggestions (${count})`; @@ -186,7 +224,7 @@ export class SuggestionsWidget extends PanelWithToolbar { private _widgetFactory(options: { suggestionId: string; suggestionData: ISuggestionViewData; - }): { widget: CellWidget } { + }): { widget: BaseCellwidget } { const { suggestionId, suggestionData } = options; const cellId = suggestionData.originalCellModel.id as string | undefined; const deleteCallback = async () => { @@ -235,10 +273,25 @@ export class SuggestionsWidget extends PanelWithToolbar { } } }; - const w = new CellWidget({ + const navigateCallback = async () => { + this._model.nativateToCell(cellId); + }; + let Cls: typeof BaseCellwidget = BaseCellwidget; + switch (suggestionData.type) { + case SuggestionType.change: + Cls = SuggestChangeCellWidget; + break; + case SuggestionType.delete: + Cls = SuggestDeleteCellWidget; + break; + default: + break; + } + const w = new Cls({ suggestionData, deleteCallback, acceptCallback, + navigateCallback, liveUpdate: this._model.sourceLiveUpdate }); @@ -247,7 +300,10 @@ export class SuggestionsWidget extends PanelWithToolbar { } private _suggestionsArea = new Panel(); - private _cellSuggestionsPanel = new Map(); + private _cellSuggestionsPanel = new Map< + string, + { panel: Panel; cellIndex: number } + >(); private _model: ISuggestionsModel; } diff --git a/packages/base/src/tools.ts b/packages/base/src/tools.ts index 2dc7ce2..4b68661 100644 --- a/packages/base/src/tools.ts +++ b/packages/base/src/tools.ts @@ -1,4 +1,9 @@ -import { ISharedCell, NotebookChange, YCellType } from '@jupyter/ydoc'; +import { + ISharedCell, + NotebookChange, + YCellType, + YNotebook +} from '@jupyter/ydoc'; import { CellModel, CodeCellModel, @@ -6,9 +11,21 @@ import { MarkdownCellModel, RawCellModel } from '@jupyterlab/cells'; -import { NotebookPanel } from '@jupyterlab/notebook'; +import { Notebook, NotebookPanel } from '@jupyterlab/notebook'; import { IDict } from './types'; +import { PromiseDelegate } from '@lumino/coreutils'; + +export const COMMAND_IDS = { + /** + * Command to add a cell suggestion. + */ + addCellSuggestion: 'jupyter-suggestions-core:add-cell-suggestion', + /** + * Command to add a cell deletion suggestion. + */ + addDeleteCellSuggestion: 'jupyter-suggestions-core:add-delete-cell-suggestion' +}; /** * Generates a mapping of cell IDs to cell models for a given notebook. @@ -39,6 +56,7 @@ export function detectCellChangedEvent(changed: NotebookChange): | { event: 'deleted' | 'moved'; movedCells?: ISharedCell[]; + deletedIdx?: number; } | undefined { const { cellsChange } = changed; @@ -47,22 +65,26 @@ export function detectCellChangedEvent(changed: NotebookChange): let haveDelete = false; let haveInsert: ISharedCell[] | undefined; let haveRetain = false; + let retainIndex = 0; + let deleteIndex = 0; for (const c of cellsChange) { if (c.delete !== undefined) { haveDelete = true; + deleteIndex = c.delete; } if (c.insert !== undefined) { haveInsert = c.insert; } if (c.retain !== undefined) { haveRetain = true; + retainIndex = c.retain; } } if (haveDelete) { if (haveRetain && haveInsert) { return { event: 'moved', movedCells: haveInsert }; } - return { event: 'deleted' }; + return { event: 'deleted', deletedIdx: retainIndex + deleteIndex - 1 }; } } return; @@ -111,3 +133,108 @@ export function cellModelFromYCell(options: { } return copiedCellModel; } + +/** + * Delete a cell in the shared notebook by id + * + * @param {{ + * currentNotebook: Notebook; + * sharedModel: ISharedDocument; + * }} options + */ +export async function deleteCellById(options: { + cellId: string; + sharedModel?: YNotebook; + defaultCell?: string; + currentNotebook?: Notebook; +}): Promise { + const { cellId, defaultCell, currentNotebook } = options; + let sharedModel = options.sharedModel; + + const pd = new PromiseDelegate(); + if (currentNotebook) { + sharedModel = currentNotebook.model?.sharedModel as YNotebook; + } + if (sharedModel) { + let cellIndex = -1; + const allCells = sharedModel.cells; + for (let index = 0; index < allCells.length; index++) { + const element = allCells[index]; + if (element.getId() === cellId) { + cellIndex = index; + break; + } + } + + if (cellIndex !== -1) { + const handler = (nb: YNotebook, changed: NotebookChange) => { + const cellChangedEvent = detectCellChangedEvent(changed); + if (cellChangedEvent?.event === 'deleted') { + const deletedIndex = cellChangedEvent.deletedIdx; + if (deletedIndex === cellIndex) { + pd.resolve(true); + sharedModel.changed.disconnect(handler); + } + } + }; + sharedModel.changed.connect(handler); + sharedModel.transact(() => { + sharedModel.deleteCell(cellIndex); + // 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 === 1) { + sharedModel.insertCell(0, { + cell_type: defaultCell ?? 'code', + metadata: + defaultCell === 'code' + ? { + trusted: true + } + : {} + }); + } + if (currentNotebook) { + currentNotebook.activeCellIndex = cellIndex; + } + }); + currentNotebook?.deselectAll(); + } + } else { + pd.resolve(false); + } + return pd.promise; +} + +export function cloneCellModel( + cellModel: ICellModel, + newSource?: string +): ICellModel { + let copiedCellModel: CellModel | undefined; + const mimeType = cellModel.mimeType; + switch (cellModel.type) { + case 'code': { + copiedCellModel = new CodeCellModel(); + break; + } + case 'markdown': { + copiedCellModel = new MarkdownCellModel(); + break; + } + case 'raw': { + copiedCellModel = new RawCellModel(); + break; + } + default: + break; + } + + if (!copiedCellModel) { + throw new Error('Invalid cell type'); + } + copiedCellModel.mimeType = mimeType; + copiedCellModel.sharedModel.setSource( + newSource ?? cellModel.sharedModel.getSource() + ); + return copiedCellModel; +} diff --git a/packages/base/src/types.ts b/packages/base/src/types.ts index 426ad78..27a7e22 100644 --- a/packages/base/src/types.ts +++ b/packages/base/src/types.ts @@ -3,6 +3,11 @@ import { ISignal } from '@lumino/signaling'; import { IDisposable } from '@lumino/disposable'; import { Cell, ICellModel } from '@jupyterlab/cells'; import { User } from '@jupyterlab/services'; + +export enum SuggestionType { + change = 'CHANGE', + delete = 'DELETE' +} export interface IDict { [key: string]: T; } @@ -21,9 +26,15 @@ export interface ISuggestionData { originalCellId: string; /** - * The model of the suggestion cell. + * The model of the suggestion cell, `null` if it's a cell deletion + * suggestion */ - cellModel: ICellModel; + cellModel: ICellModel | null; + + /** + * Suggestion type. + */ + type: SuggestionType; /** * Suggestion metadata. @@ -41,7 +52,7 @@ export interface ISuggestionViewData { originalCellModel: ICellModel; /** - * The model of the suggestion cell. + * The model of the suggestion cell */ cellModel: ICellModel; @@ -49,6 +60,11 @@ export interface ISuggestionViewData { * Suggestion metadata. */ metadata: ISuggestionMetadata; + + /** + * Suggestion type. + */ + type: SuggestionType; } /** @@ -131,7 +147,7 @@ export interface ISuggestionsModel extends IDisposable { * * @returns A promise that resolves when the suggestion is added. */ - addSuggestion(): Promise; + addSuggestion(options: { type: SuggestionType }): Promise; /** * Deletes a suggestion from a specified cell. @@ -183,6 +199,17 @@ export interface ISuggestionsModel extends IDisposable { suggestionId: string; }): Promise; + /** + * Retrieves all suggestions of a cell by cell ID. + * + * @param options - An object containing the cell ID. + * @returns The suggestion data or undefined + * if not found. + */ + getCellSuggestions(options: { + cellId: string; + }): IDict | undefined; + /** * Retrieves the index of a cell by its ID. * @@ -197,6 +224,12 @@ export interface ISuggestionsModel extends IDisposable { * @returns The cell instance, or -1 if the cell is not found. */ getActiveCell(): Cell | null | undefined; + + /** + * Scroll to the cell by its id + * + */ + nativateToCell(cellId?: string): void; } export interface ISuggestionChange { @@ -255,6 +288,7 @@ export interface ISuggestionsManager extends IDisposable { notebook: NotebookPanel; cell: Cell; author?: User.IIdentity | null; + type: SuggestionType; }): Promise; /** diff --git a/packages/base/style/icon/cross.svg b/packages/base/style/icon/cross.svg new file mode 100644 index 0000000..b2f2020 --- /dev/null +++ b/packages/base/style/icon/cross.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/base/style/icon/location.svg b/packages/base/style/icon/location.svg new file mode 100644 index 0000000..6783a0f --- /dev/null +++ b/packages/base/style/icon/location.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/python/jupyter_suggestions_core/package.json b/python/jupyter_suggestions_core/package.json index 543ee03..9eb4727 100644 --- a/python/jupyter_suggestions_core/package.json +++ b/python/jupyter_suggestions_core/package.json @@ -46,14 +46,16 @@ "dependencies": { "@jupyter/suggestions-base": "^0.1.0", "@jupyterlab/application": "^4.0.0", + "@jupyterlab/apputils": "^4.0.0", + "@jupyterlab/cells": "^4.0.0", "@jupyterlab/notebook": "^4.0.0", "@jupyterlab/settingregistry": "^4.0.0", "@jupyterlab/translation": "^4.0.0", - "@jupyterlab/ui-components": "^4.0.0", - "@rjsf/utils": "^5.22.0" + "@jupyterlab/ui-components": "^4.0.0" }, "devDependencies": { "@jupyterlab/builder": "^4.0.0", + "@rjsf/utils": "^5.22.0", "rimraf": "^3.0.2", "typescript": "^5", "yjs": "^13.5.0" diff --git a/python/jupyter_suggestions_core/schema/commands.json b/python/jupyter_suggestions_core/schema/commands.json index 6d01605..065bef3 100644 --- a/python/jupyter_suggestions_core/schema/commands.json +++ b/python/jupyter_suggestions_core/schema/commands.json @@ -6,8 +6,7 @@ "jupyter.lab.toolbars": { "Cell": [ { - "name": "jupyter-suggestions-core:add-cell-suggestion", - "command": "jupyter-suggestions-core:add-cell-suggestion" + "name": "jupyter-suggestions-core:cell-suggestion-menu" } ] } diff --git a/python/jupyter_suggestions_core/src/index.ts b/python/jupyter_suggestions_core/src/index.ts index daf3d08..c83b9ce 100644 --- a/python/jupyter_suggestions_core/src/index.ts +++ b/python/jupyter_suggestions_core/src/index.ts @@ -3,7 +3,8 @@ import { suggestionsPanelPlugin, commandsPlugin, suggestionsManagerPlugin, - registryPlugin + registryPlugin, + cellToolbarPlugin } from './plugins'; export default [ @@ -11,5 +12,6 @@ export default [ suggestionsModelPlugin, suggestionsPanelPlugin, commandsPlugin, - suggestionsManagerPlugin + suggestionsManagerPlugin, + cellToolbarPlugin ]; diff --git a/python/jupyter_suggestions_core/src/plugins.ts b/python/jupyter_suggestions_core/src/plugins.ts index c9185bf..8c25ae7 100644 --- a/python/jupyter_suggestions_core/src/plugins.ts +++ b/python/jupyter_suggestions_core/src/plugins.ts @@ -1,4 +1,6 @@ import { + CellToolbarMenu, + COMMAND_IDS, hintIcon, ISuggestionsManagerRegistry, ISuggestionsManagerRegistryToken, @@ -7,13 +9,16 @@ import { LocalSuggestionsManager, SuggestionsManagerRegistry, SuggestionsModel, - SuggestionsPanelWidget + SuggestionsPanelWidget, + SuggestionType } from '@jupyter/suggestions-base'; import { ILayoutRestorer, JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application'; +import { IToolbarWidgetRegistry } from '@jupyterlab/apputils'; +import { Cell } from '@jupyterlab/cells'; import { INotebookTracker } from '@jupyterlab/notebook'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { ITranslator, nullTranslator } from '@jupyterlab/translation'; @@ -21,7 +26,9 @@ import { IFormRenderer, IFormRendererRegistry } from '@jupyterlab/ui-components'; + import { SuggestionsSettingComponent } from './settingrenderer'; + import type { FieldProps } from '@rjsf/utils'; const NAME_SPACE = '@jupyter/suggestions-core'; @@ -62,13 +69,6 @@ export const suggestionsModelPlugin: JupyterFrontEndPlugin = } }; -export const COMMAND_IDS = { - /** - * Command to add a cell suggestion. - */ - addCellSuggestion: 'jupyter-suggestions-core:add-cell-suggestion' -}; - export const commandsPlugin: JupyterFrontEndPlugin = { id: `${NAME_SPACE}:commands`, description: 'A JupyterLab extension for suggesting changes.', @@ -86,22 +86,35 @@ export const commandsPlugin: JupyterFrontEndPlugin = { const translator = translator_ ?? nullTranslator; const trans = translator.load('jupyterlab'); commands.addCommand(COMMAND_IDS.addCellSuggestion, { - icon: hintIcon, caption: trans.__('Add suggestion'), execute: async () => { const current = tracker.currentWidget; if (current !== model.currentNotebookPanel) { await model.switchNotebook(current); } - await model.addSuggestion(); + await model.addSuggestion({ type: SuggestionType.change }); + }, + isVisible: () => true + }); + + commands.addCommand(COMMAND_IDS.addDeleteCellSuggestion, { + caption: trans.__('Add delete cell suggestion'), + execute: async () => { + const current = tracker.currentWidget; + if (current !== model.currentNotebookPanel) { + await model.switchNotebook(current); + } + await model.addSuggestion({ type: SuggestionType.delete }); }, isVisible: () => true }); tracker.activeCellChanged.connect(() => { commands.notifyCommandChanged(COMMAND_IDS.addCellSuggestion); + commands.notifyCommandChanged(COMMAND_IDS.addDeleteCellSuggestion); }); tracker.selectionChanged.connect(() => { commands.notifyCommandChanged(COMMAND_IDS.addCellSuggestion); + commands.notifyCommandChanged(COMMAND_IDS.addDeleteCellSuggestion); }); } }; @@ -223,3 +236,35 @@ export const registryPlugin: JupyterFrontEndPlugin return registryManager; } }; + +export const cellToolbarPlugin: JupyterFrontEndPlugin = { + id: `${NAME_SPACE}:cell-toolbar`, + description: 'A JupyterLab extension for suggesting changes.', + autoStart: true, + requires: [INotebookTracker, ISuggestionsModelToken], + optional: [ITranslator, IToolbarWidgetRegistry], + activate: ( + app: JupyterFrontEnd, + tracker: INotebookTracker, + model: ISuggestionsModel, + translator_: ITranslator | null, + toolbarRegistry: IToolbarWidgetRegistry | null + ) => { + console.log(`${NAME_SPACE}:cell-toolbar is activated`); + const { commands } = app; + if (toolbarRegistry) { + toolbarRegistry.addFactory( + 'Cell', + 'jupyter-suggestions-core:cell-suggestion-menu', + cell => { + const w = new CellToolbarMenu({ + cell, + commands, + suggestionModel: model + }); + return w; + } + ); + } + } +}; diff --git a/python/jupyter_suggestions_rtc/src/manager.ts b/python/jupyter_suggestions_rtc/src/manager.ts index ba6c87b..c2be863 100644 --- a/python/jupyter_suggestions_rtc/src/manager.ts +++ b/python/jupyter_suggestions_rtc/src/manager.ts @@ -7,11 +7,14 @@ import { import { BaseSuggestionsManager, cellModelFromYCell, + deleteCellById, detectCellChangedEvent, IAllSuggestionData, IDict, ISuggestionData, - ISuggestionsManager + ISuggestionMetadata, + ISuggestionsManager, + SuggestionType } from '@jupyter/suggestions-base'; import { NotebookChange, YCellType, YNotebook } from '@jupyter/ydoc'; import { Cell, CellModel, ICellModel } from '@jupyterlab/cells'; @@ -24,6 +27,13 @@ import { WebsocketProvider as YWebsocketProvider } from 'y-websocket'; const DOCUMENT_PROVIDER_URL = 'api/collaboration/room'; +interface IForkMetadata { + cellId?: string; + path?: string; + mimeType?: string; + suggestionType: SuggestionType; + metadata?: ISuggestionMetadata; +} export class RtcSuggestionsManager extends BaseSuggestionsManager implements ISuggestionsManager @@ -57,12 +67,20 @@ export class RtcSuggestionsManager async getAllSuggestions( notebook: NotebookPanel ): Promise { - const rootDocId = notebook.context.model.sharedModel.getState( + const path = notebook.context.localPath; + let rootDocId = notebook.context.model.sharedModel.getState( 'document_id' - ) as string; + ) as string | undefined; + let format = 'json'; + let type = 'notebook'; + if (rootDocId) { + [format, type] = rootDocId.split(':'); + } else { + const docSession = await requestDocSession(format, type, path); + rootDocId = `${format}:${type}:${docSession.fileId}`; + this._serverSession = docSession.sessionId; + } - const [format, type] = rootDocId.split(':'); - const path = notebook.context.localPath; if (!this._serverSession) { const docSession = await requestDocSession(format, type, path); this._serverSession = docSession.sessionId; @@ -78,9 +96,11 @@ export class RtcSuggestionsManager cellMap[element.id] = element; } for (const [forkRoomId, forkData] of Object.entries(allForks)) { - const forkMeta = JSON.parse(forkData.description ?? '{}'); + const forkMeta = JSON.parse( + forkData.description ?? '{}' + ) as IForkMetadata; const cellId = forkMeta.cellId; - if (!cellMap[cellId]) { + if (!cellId || !cellMap[cellId]) { continue; } const metadata = forkMeta.metadata; @@ -94,7 +114,8 @@ export class RtcSuggestionsManager const data: ISuggestionData = { cellModel, originalCellId: cellId, - metadata + metadata: metadata ?? {}, + type: forkMeta.suggestionType }; if (currentSuggestion.has(cellId)) { const currentData = currentSuggestion.get(cellId)!; @@ -128,24 +149,27 @@ export class RtcSuggestionsManager notebook: NotebookPanel; cell: Cell; author?: User.IIdentity | null; + type: SuggestionType; }): Promise { - const { notebook, cell, author } = options; + const { notebook, cell, author, type } = options; const path = notebook.context.localPath; const cellId = cell.model.id; const rootId = notebook.context.model.sharedModel.getState( 'document_id' ) as string; + const forkMetadata: IForkMetadata = { + cellId, + path, + mimeType: cell.model.mimeType, + suggestionType: type, + metadata: { author } + }; const response = await this._forkManager.createFork({ rootId, synchronize: true, //TODO: Update when the fork manager supports metadata - description: JSON.stringify({ - cellId, - path, - mimeType: cell.model.mimeType, - metadata: { author } - }) + description: JSON.stringify(forkMetadata) }); return response?.fork_roomid ?? ''; } @@ -155,11 +179,28 @@ export class RtcSuggestionsManager cellId: string; suggestionId: string; }): Promise { - const { suggestionId } = options; + const { suggestionId, cellId, notebook } = options; + const notebookPath = notebook.context.localPath; + const suggestionData = await this.getSuggestion({ + notebookPath, + cellId, + suggestionId + }); + if (!suggestionData) { + return false; + } + const suggestionType = suggestionData.type; + if (suggestionType === SuggestionType.delete) { + const sharedModel = this._allSharedNotebook.get(suggestionId); + const deleted = await deleteCellById({ sharedModel, cellId }); + if (!deleted) { + return deleted; + } + } try { + this._removeSharedNotebook(suggestionId); await this._forkManager.deleteFork({ forkId: suggestionId, merge: true }); - this._removeSharedNotebook(suggestionId); return true; } catch (e) { console.error(e); @@ -173,8 +214,19 @@ export class RtcSuggestionsManager suggestionId: string; }): Promise { const { suggestionId } = options; - await this._forkManager.deleteFork({ forkId: suggestionId, merge: false }); - this._removeSharedNotebook(suggestionId); + setTimeout(() => { + if (this._allSharedNotebook.has(suggestionId)) { + this._removeSharedNotebook(suggestionId); + try { + this._forkManager.deleteFork({ + forkId: suggestionId, + merge: false + }); + } catch (e) { + // + } + } + }, 0); } async updateSuggestion(options: { @@ -200,10 +252,10 @@ export class RtcSuggestionsManager changed: IForkChangedEvent ) { const forkInfo = changed.fork_info; - const forkMeta = JSON.parse(forkInfo.description ?? '{}'); + const forkMeta = JSON.parse(forkInfo.description ?? '{}') as IForkMetadata; const { cellId, path } = forkMeta; const suggestionId = changed.fork_roomid; - if (this._suggestionsMap.has(path)) { + if (path && cellId && this._suggestionsMap.has(path)) { const nbSuggestions = this._suggestionsMap.get(path); if (nbSuggestions && nbSuggestions.has(cellId)) { delete nbSuggestions.get(cellId)![suggestionId]; @@ -232,13 +284,13 @@ export class RtcSuggestionsManager forkCellId: string; forkRoomId: string; notebookPath: string; + suggestionType: SuggestionType; } ) { const { forkCellMimeType, forkCellId, forkRoomId, notebookPath } = options; const cellChangedEvent = detectCellChangedEvent(changed); if (cellChangedEvent) { const { event } = cellChangedEvent; - if (event === 'moved') { const movedCells = cellChangedEvent.movedCells ?? []; const cellMap: IDict = {}; @@ -273,8 +325,8 @@ export class RtcSuggestionsManager changed: IForkChangedEvent ) { const forkInfo = changed.fork_info; - const forkMeta = JSON.parse(forkInfo.description ?? '{}'); - const { cellId, path, mimeType, metadata } = forkMeta; + const forkMeta = JSON.parse(forkInfo.description ?? '{}') as IForkMetadata; + const { cellId, path, mimeType, metadata, suggestionType } = forkMeta; const rootId = forkInfo.root_roomid; const suggestionId = changed.fork_roomid; if (!path || !cellId) { @@ -297,14 +349,16 @@ export class RtcSuggestionsManager rootDocId: rootId, forkRoomId: suggestionId, cellId, - mimeType, + mimeType: mimeType ?? 'text/plain', forkMeta }); const suggestionContent: ISuggestionData = { originalCellId: cellId, cellModel, - metadata + metadata: metadata ?? {}, + type: suggestionType }; + cellSuggesions[suggestionId] = suggestionContent; this._suggestionChanged.emit({ notebookPath: path, @@ -318,13 +372,13 @@ export class RtcSuggestionsManager forkRoomId: string; cellId: string; mimeType: string; - forkMeta: any; - }): Promise { + forkMeta: IForkMetadata; + }): Promise { const { rootDocId, forkRoomId, cellId, mimeType, forkMeta } = options; const [format, type] = rootDocId.split(':'); const sharedModelFactory = this._drive.sharedModelFactory.documentFactories.get(type); - const pd = new PromiseDelegate(); + const pd = new PromiseDelegate(); if (sharedModelFactory) { const shared = sharedModelFactory({ path: forkRoomId, @@ -335,10 +389,11 @@ export class RtcSuggestionsManager this._allSharedNotebook.set(forkRoomId, shared); const handler = (sender: YNotebook, args: NotebookChange) => this._handleForkNotebookChanged(sender, args, { - forkCellMimeType: forkMeta.mimeType, - forkCellId: forkMeta.cellId, + forkCellMimeType: mimeType, + forkCellId: cellId, forkRoomId, - notebookPath: forkMeta.path + notebookPath: forkMeta.path!, + suggestionType: forkMeta.suggestionType }); shared.changed.connect(handler); shared.disposed.connect(() => { @@ -370,6 +425,9 @@ export class RtcSuggestionsManager } else { pd.reject('Invalid cell type'); } + } else { + // Cell deletion suggestion + pd.resolve(null); } } }); diff --git a/yarn.lock b/yarn.lock index bf4ea8c..b14dd1d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -575,7 +575,9 @@ __metadata: dependencies: "@jupyter/suggestions-base": ^0.1.0 "@jupyterlab/application": ^4.0.0 + "@jupyterlab/apputils": ^4.0.0 "@jupyterlab/builder": ^4.0.0 + "@jupyterlab/cells": ^4.0.0 "@jupyterlab/notebook": ^4.0.0 "@jupyterlab/settingregistry": ^4.0.0 "@jupyterlab/translation": ^4.0.0 @@ -595,6 +597,7 @@ __metadata: "@types/json-schema": ^7.0.11 "@types/react": ^18.0.26 "@types/react-addons-linked-state-mixin": ^0.14.22 + "@types/react-dom": ^18.0.26 "@typescript-eslint/eslint-plugin": ^6.1.0 "@typescript-eslint/parser": ^6.1.0 css-loader: ^6.7.1 @@ -2385,6 +2388,15 @@ __metadata: languageName: node linkType: hard +"@types/react-dom@npm:^18.0.26": + version: 18.3.5 + resolution: "@types/react-dom@npm:18.3.5" + peerDependencies: + "@types/react": ^18.0.0 + checksum: 95c757684f71e761515c5a11299e5feec550c72bb52975487f360e6f0d359b26454c26eaf2ce45dd22748205aa9b2c2fe0abe7005ebcbd233a7615283ac39a7d + languageName: node + linkType: hard + "@types/react@npm:*": version: 19.0.1 resolution: "@types/react@npm:19.0.1" @@ -10320,7 +10332,7 @@ __metadata: "typescript@patch:typescript@>=3 < 6#~builtin, typescript@patch:typescript@^5#~builtin": version: 5.7.2 - resolution: "typescript@patch:typescript@npm%3A5.7.2#~builtin::version=5.7.2&hash=e012d7" + resolution: "typescript@patch:typescript@npm%3A5.7.2#~builtin::version=5.7.2&hash=85af82" bin: tsc: bin/tsc tsserver: bin/tsserver @@ -10330,11 +10342,11 @@ __metadata: "typescript@patch:typescript@~5.0.2#~builtin": version: 5.0.4 - resolution: "typescript@patch:typescript@npm%3A5.0.4#~builtin::version=5.0.4&hash=b5f058" + resolution: "typescript@patch:typescript@npm%3A5.0.4#~builtin::version=5.0.4&hash=85af82" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: d26b6ba97b6d163c55dbdffd9bbb4c211667ebebc743accfeb2c8c0154aace7afd097b51165a72a5bad2cf65a4612259344ff60f8e642362aa1695c760d303ac + checksum: bb309d320c59a26565fb3793dba550576ab861018ff3fd1b7fccabbe46ae4a35546bc45f342c0a0b6f265c801ccdf64ffd68f548f117ceb7f0eac4b805cd52a9 languageName: node linkType: hard From f409c47bc75238b0e2e432648d4de57ae69ad16c Mon Sep 17 00:00:00 2001 From: Duc Trung Le Date: Thu, 19 Dec 2024 10:32:56 +0100 Subject: [PATCH 2/2] Update packages/base/src/types.ts Co-authored-by: Jeremy Tuloup --- packages/base/src/suggestionsPanel/model.ts | 2 +- packages/base/src/suggestionsPanel/widget.ts | 2 +- packages/base/src/types.ts | 2 +- yarn.lock | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/base/src/suggestionsPanel/model.ts b/packages/base/src/suggestionsPanel/model.ts index 3893d76..3192ac6 100644 --- a/packages/base/src/suggestionsPanel/model.ts +++ b/packages/base/src/suggestionsPanel/model.ts @@ -244,7 +244,7 @@ export class SuggestionsModel implements ISuggestionsModel { } } - nativateToCell(cellId?: string): void { + scrollToCell(cellId?: string): void { if (cellId && this._notebookPanel) { const index = this.getCellIndex(cellId); if (index !== -1) { diff --git a/packages/base/src/suggestionsPanel/widget.ts b/packages/base/src/suggestionsPanel/widget.ts index 32fd811..95f7a70 100644 --- a/packages/base/src/suggestionsPanel/widget.ts +++ b/packages/base/src/suggestionsPanel/widget.ts @@ -274,7 +274,7 @@ export class SuggestionsWidget extends PanelWithToolbar { } }; const navigateCallback = async () => { - this._model.nativateToCell(cellId); + this._model.scrollToCell(cellId); }; let Cls: typeof BaseCellwidget = BaseCellwidget; switch (suggestionData.type) { diff --git a/packages/base/src/types.ts b/packages/base/src/types.ts index 27a7e22..c64d29b 100644 --- a/packages/base/src/types.ts +++ b/packages/base/src/types.ts @@ -229,7 +229,7 @@ export interface ISuggestionsModel extends IDisposable { * Scroll to the cell by its id * */ - nativateToCell(cellId?: string): void; + scrollToCell(cellId?: string): void; } export interface ISuggestionChange { diff --git a/yarn.lock b/yarn.lock index b14dd1d..cf725f9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10332,7 +10332,7 @@ __metadata: "typescript@patch:typescript@>=3 < 6#~builtin, typescript@patch:typescript@^5#~builtin": version: 5.7.2 - resolution: "typescript@patch:typescript@npm%3A5.7.2#~builtin::version=5.7.2&hash=85af82" + resolution: "typescript@patch:typescript@npm%3A5.7.2#~builtin::version=5.7.2&hash=e012d7" bin: tsc: bin/tsc tsserver: bin/tsserver @@ -10342,11 +10342,11 @@ __metadata: "typescript@patch:typescript@~5.0.2#~builtin": version: 5.0.4 - resolution: "typescript@patch:typescript@npm%3A5.0.4#~builtin::version=5.0.4&hash=85af82" + resolution: "typescript@patch:typescript@npm%3A5.0.4#~builtin::version=5.0.4&hash=b5f058" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: bb309d320c59a26565fb3793dba550576ab861018ff3fd1b7fccabbe46ae4a35546bc45f342c0a0b6f265c801ccdf64ffd68f548f117ceb7f0eac4b805cd52a9 + checksum: d26b6ba97b6d163c55dbdffd9bbb4c211667ebebc743accfeb2c8c0154aace7afd097b51165a72a5bad2cf65a4612259344ff60f8e642362aa1695c760d303ac languageName: node linkType: hard