diff --git a/packages/libro-ai-native/src/ai-widget/ai-widget-command-contribution.ts b/packages/libro-ai-native/src/ai-widget/ai-widget-command-contribution.ts new file mode 100644 index 00000000..0fce169b --- /dev/null +++ b/packages/libro-ai-native/src/ai-widget/ai-widget-command-contribution.ts @@ -0,0 +1,70 @@ +import { LibroCellView } from '@difizen/libro-jupyter'; +import type { CommandRegistry } from '@difizen/mana-app'; +import { CommandContribution, inject, singleton } from '@difizen/mana-app'; +import { l10n } from '@difizen/mana-l10n'; + +import { LibroAINativeService } from '../ai-native-service.js'; + +import { AIWidgetCommandRegister } from './ai-widget-command-register.js'; +import { AIWidgetCommands } from './command.js'; + +@singleton({ contrib: CommandContribution }) +export class AIWidgetCommandContribution implements CommandContribution { + @inject(AIWidgetCommandRegister) + protected readonly widgetCommandRegister: AIWidgetCommandRegister; + + @inject(LibroAINativeService) libroAINativeService: LibroAINativeService; + + registerCommands(command: CommandRegistry) { + this.widgetCommandRegister.registerAIWidgetCommand( + command, + AIWidgetCommands['Optimize'], + { + execute: async (code, cell, libro) => { + if (!cell || !(cell instanceof LibroCellView)) { + return; + } + const libroAINativeForCellView = + await this.libroAINativeService.getOrCreateLibroAINativeForCellView( + cell.id, + cell, + ); + libroAINativeForCellView.showAI = true; + + const inCode = + l10n.getLang() === 'en-US' + ? `Could you please optimize this piece of code?:${code}` + : `帮忙优化一下这段代码:${code}`; + libroAINativeForCellView.chatStream({ + content: inCode, + }); + }, + }, + ); + this.widgetCommandRegister.registerAIWidgetCommand( + command, + AIWidgetCommands['Explain'], + { + execute: async (code, cell) => { + if (!cell || !(cell instanceof LibroCellView)) { + return; + } + const libroAINativeForCellView = + await this.libroAINativeService.getOrCreateLibroAINativeForCellView( + cell.id, + cell, + ); + libroAINativeForCellView.showAI = true; + + const inCode = + l10n.getLang() === 'en-US' + ? `Could you please optimize this piece of code?:${code}` + : `帮忙解释一下这段代码:${code}`; + libroAINativeForCellView.chatStream({ + content: inCode, + }); + }, + }, + ); + } +} diff --git a/packages/libro-ai-native/src/ai-widget/ai-widget-command-register.ts b/packages/libro-ai-native/src/ai-widget/ai-widget-command-register.ts new file mode 100644 index 00000000..ab7f0c8f --- /dev/null +++ b/packages/libro-ai-native/src/ai-widget/ai-widget-command-register.ts @@ -0,0 +1,137 @@ +import { LibroService } from '@difizen/libro-jupyter'; +import type { CellView, NotebookView } from '@difizen/libro-jupyter'; +import type { + Command, + CommandHandler, + CommandRegistry, + CommandHandlerWithContext, +} from '@difizen/mana-app'; +import { inject, singleton } from '@difizen/mana-app'; + +export interface GeneralAIWidgetCommandHandler extends CommandHandler { + execute: ( + code?: string, + cell?: CellView, + libro?: NotebookView, + position?: string, + options?: any, + ) => void; + isVisible?: ( + code?: string, + cell?: CellView, + libro?: NotebookView, + position?: string, + options?: any, + ) => boolean; + isEnabled?: ( + code?: string, + cell?: CellView, + libro?: NotebookView, + position?: string, + options?: any, + ) => boolean; + isActive?: ( + code?: string, + cell?: CellView, + libro?: NotebookView, + position?: string, + options?: any, + ) => boolean; +} + +@singleton() +export class AIWidgetCommandRegister { + @inject(LibroService) protected readonly libroService: LibroService; + + toGeneralCommandArgs = ( + ctx: AIWidgetCommandRegister, + code?: string, + cell?: CellView, + libro?: NotebookView, + position?: string, + options?: any, + ): [ + string | undefined, + CellView | undefined, + NotebookView | undefined, + string | undefined, + any, + ] => { + const libroView = libro || ctx.libroService.active; + const cellView = cell || libroView?.model?.active; + return [code, cellView, libroView, position, options]; + }; + + registerAIWidgetCommand( + registry: CommandRegistry, + command: Command, + handler: GeneralAIWidgetCommandHandler, + ) { + const commandHandler: CommandHandlerWithContext = { + execute: ( + ctx, + code?: string, + cell?: CellView, + libro?: NotebookView, + position?: string, + options?: any, + ) => { + return handler.execute( + ...this.toGeneralCommandArgs(ctx, code, cell, libro, position, options), + ); + }, + }; + if (handler.isEnabled) { + commandHandler.isEnabled = ( + ctx, + code?: string, + cell?: CellView, + libro?: NotebookView, + position?: string, + options?: any, + ) => { + if (!handler.isEnabled) { + return true; + } + return handler.isEnabled( + ...this.toGeneralCommandArgs(ctx, code, cell, libro, position, options), + ); + }; + } + if (handler.isVisible) { + commandHandler.isVisible = ( + ctx, + code?: string, + cell?: CellView, + libro?: NotebookView, + position?: string, + options?: any, + ) => { + if (!handler.isVisible) { + return true; + } + return handler.isVisible( + ...this.toGeneralCommandArgs(ctx, code, cell, libro, position, options), + ); + }; + } + if (handler.isActive) { + commandHandler.isActive = ( + ctx, + code?: string, + cell?: CellView, + libro?: NotebookView, + position?: string, + options?: any, + ) => { + if (!handler.isActive) { + return false; + } + return handler.isActive( + ...this.toGeneralCommandArgs(ctx, code, cell, libro, position, options), + ); + }; + } + registry.registerCommandWithContext(command, this, commandHandler); + } +} diff --git a/packages/libro-ai-native/src/ai-widget/ai-widget.ts b/packages/libro-ai-native/src/ai-widget/ai-widget.ts new file mode 100644 index 00000000..c345c681 --- /dev/null +++ b/packages/libro-ai-native/src/ai-widget/ai-widget.ts @@ -0,0 +1,111 @@ +import type { + WidgetActionItem, + WidgetActionHandlerItem, +} from '@difizen/libro-code-editor'; +import { EditorWidgetContribution } from '@difizen/libro-code-editor'; +import { CommandRegistry } from '@difizen/mana-app'; +import { inject, singleton } from '@difizen/mana-app'; + +import { AIWidgetCommands } from './command.js'; + +@singleton({ contrib: [EditorWidgetContribution] }) +export class AIWidget implements EditorWidgetContribution { + private actionsMap: Map = new Map(); + private handlerMap: Map = new Map(); + + canHandle = () => { + return 100; + }; + + @inject(CommandRegistry) protected readonly commandRegistry: CommandRegistry; + + constructor() { + this.registerEditorInlineChat( + { + id: 'ai-comments', + name: 'Comments', + title: 'add comments(readable stream example)', + renderType: 'button', + codeAction: { + isPreferred: true, + kind: 'refactor.rewrite', + }, + }, + { + execute: async (code: string) => { + this.commandRegistry.executeCommand(AIWidgetCommands['Explain'].id, code); + }, + }, + ); + this.registerEditorInlineChat( + { + id: 'ai-optimize', + name: 'Optimize', + renderType: 'button', + codeAction: { + isPreferred: true, + kind: 'refactor.rewrite', + }, + }, + { + execute: async (code: string) => { + this.commandRegistry.executeCommand(AIWidgetCommands['Optimize'].id, code); + }, + }, + ); + } + public getAction(id: string): WidgetActionItem | undefined { + return this.actionsMap.get(id); + } + + public registerEditorInlineChat( + operational: WidgetActionItem, + handler: WidgetActionHandlerItem, + ) { + const isCollect = this.collectActions(operational); + + if (isCollect) { + this.handlerMap.set(operational.id, handler); + } + } + + private collectActions(operational: WidgetActionItem): boolean { + const { id } = operational; + + if (this.actionsMap.has(id)) { + return false; + } + + if (!operational.renderType) { + operational.renderType = 'button'; + } + + if (!operational.order) { + operational.order = 0; + } + + this.actionsMap.set(id, operational); + + return true; + } + + // show & hide + show: () => void; + hide: () => void; + + public getActionButtons(): WidgetActionItem[] { + const actions = Array.from(this.handlerMap.keys()) + .filter((id) => { + const actions_find = this.actionsMap.get(id); + return actions_find && actions_find.renderType === 'button'; + }) + .map((id) => this.actionsMap.get(id)) + .sort((a, b) => (a?.order ?? 0) - (b?.order ?? 0)); + + return actions as WidgetActionItem[]; + } + + getActionHandler(actionId: string) { + return this.handlerMap.get(actionId); + } +} diff --git a/packages/libro-ai-native/src/ai-widget/command.ts b/packages/libro-ai-native/src/ai-widget/command.ts new file mode 100644 index 00000000..cdd37099 --- /dev/null +++ b/packages/libro-ai-native/src/ai-widget/command.ts @@ -0,0 +1,12 @@ +import type { Command } from '@difizen/mana-app'; + +export const AIWidgetCommands: Record = { + Explain: { + id: 'ai-widget:explain', + label: 'EXPLAIN', + }, + Optimize: { + id: 'ai-widget:optimize', + label: 'Optimize', + }, +}; diff --git a/packages/libro-ai-native/src/ai-widget/index.ts b/packages/libro-ai-native/src/ai-widget/index.ts new file mode 100644 index 00000000..30d68a74 --- /dev/null +++ b/packages/libro-ai-native/src/ai-widget/index.ts @@ -0,0 +1,3 @@ +export * from './ai-widget-command-contribution.js'; +export * from './ai-widget-command-register.js'; +export * from './command.js'; diff --git a/packages/libro-ai-native/src/ai-widget/module.ts b/packages/libro-ai-native/src/ai-widget/module.ts new file mode 100644 index 00000000..07f0f31a --- /dev/null +++ b/packages/libro-ai-native/src/ai-widget/module.ts @@ -0,0 +1,10 @@ +import { CodeEditorModule } from '@difizen/libro-code-editor'; +import { ManaModule } from '@difizen/mana-app'; + +import { AIWidgetCommandContribution } from './ai-widget-command-contribution.js'; +import { AIWidgetCommandRegister } from './ai-widget-command-register.js'; +import { AIWidget } from './ai-widget.js'; + +export const LibroAIWidgetModule = ManaModule.create() + .register(AIWidget, AIWidgetCommandRegister, AIWidgetCommandContribution) + .dependOn(CodeEditorModule); diff --git a/packages/libro-ai-native/src/index.ts b/packages/libro-ai-native/src/index.ts index 86cba3ef..3c3df0d2 100644 --- a/packages/libro-ai-native/src/index.ts +++ b/packages/libro-ai-native/src/index.ts @@ -4,3 +4,4 @@ export * from './chat-view.js'; export * from './error-output-model.js'; export * from './libro-ai-native-color-registry.js'; export * from './ai-inline-completions/index.js'; +export * from './ai-widget/index.js'; diff --git a/packages/libro-ai-native/src/module.ts b/packages/libro-ai-native/src/module.ts index 303b15cc..6ccec42c 100644 --- a/packages/libro-ai-native/src/module.ts +++ b/packages/libro-ai-native/src/module.ts @@ -9,6 +9,7 @@ import { LibroAINativeCommandContribution } from './ai-native-command-contributi import { LibroAINativeForCellView } from './ai-native-for-cell-view.js'; import { LibroAINativeCellTopBlank } from './ai-native-output-top.js'; import { LibroAINativeService } from './ai-native-service.js'; +import { LibroAIWidgetModule } from './ai-widget/module.js'; import { LibroAIChatSlotContribution } from './chat-slot-contribution.js'; import { LibroChatView } from './chat-view.js'; import { AIErrorOutputModel } from './error-output-model.js'; @@ -48,4 +49,9 @@ export const LibroAINativeModule = ManaModule.create() }, ) .canload(() => Promise.resolve(LibroAINativeModuleSetting.loadable)) - .dependOn(LibroChatModule, CodeEditorModule, LibroAICompletionModule); + .dependOn( + LibroAIWidgetModule, + LibroChatModule, + CodeEditorModule, + LibroAICompletionModule, + ); diff --git a/packages/libro-code-editor/src/code-editor-protocol.ts b/packages/libro-code-editor/src/code-editor-protocol.ts index 26c49a7c..2d73e149 100644 --- a/packages/libro-code-editor/src/code-editor-protocol.ts +++ b/packages/libro-code-editor/src/code-editor-protocol.ts @@ -45,6 +45,26 @@ export interface IRange { readonly end: IPosition; } +export declare class ISelection implements IRange { + /** + * The position of the first character in the current range. + * + * #### Notes + * If this position is greater than [end] then the range is considered + * to be backward. + */ + readonly start: IPosition; + + /** + * The position of the last character in the current range. + * + * #### Notes + * If this position is less than [start] then the range is considered + * to be backward. + */ + readonly end: IPosition; +} + /** * A selection style. */ @@ -661,6 +681,17 @@ export interface CodeEditorContribution { defaultConfig: IEditorConfig; } +export interface DiffInfo { + // showDiff: (modified: string) => void; + handleApply: () => void; + handleReject: () => void; +} + +export interface IDiffEditor extends IEditor { + originalEditor: IEditor; + modifiedEditor: IEditor; +} + export interface IModelContentChange { /** * The range that got replaced. diff --git a/packages/libro-code-editor/src/code-editor-widget/index.ts b/packages/libro-code-editor/src/code-editor-widget/index.ts new file mode 100644 index 00000000..afd894ed --- /dev/null +++ b/packages/libro-code-editor/src/code-editor-widget/index.ts @@ -0,0 +1,2 @@ +export * from './widget-manager.js'; +export * from './widget-protocol.js'; diff --git a/packages/libro-code-editor/src/code-editor-widget/widget-manager.ts b/packages/libro-code-editor/src/code-editor-widget/widget-manager.ts new file mode 100644 index 00000000..0506ccb6 --- /dev/null +++ b/packages/libro-code-editor/src/code-editor-widget/widget-manager.ts @@ -0,0 +1,26 @@ +import type { Contribution } from '@difizen/mana-app'; +import { contrib, Priority } from '@difizen/mana-app'; +import { singleton } from '@difizen/mana-app'; + +import { EditorWidgetContribution } from './widget-protocol.js'; + +@singleton() +export class EditorWidgetManager { + protected readonly completionsProvider: Contribution.Provider; + + constructor( + @contrib(EditorWidgetContribution) + editorWidgetContribution: Contribution.Provider, + ) { + this.completionsProvider = editorWidgetContribution; + } + + findWidgetProvider() { + const prioritized = Priority.sortSync( + this.completionsProvider.getContributions(), + (contribution) => contribution.canHandle(), + ); + const sorted = prioritized.map((c) => c.value); + return sorted[0]; + } +} diff --git a/packages/libro-code-editor/src/code-editor-widget/widget-protocol.ts b/packages/libro-code-editor/src/code-editor-widget/widget-protocol.ts new file mode 100644 index 00000000..1afb289e --- /dev/null +++ b/packages/libro-code-editor/src/code-editor-widget/widget-protocol.ts @@ -0,0 +1,191 @@ +import type { MaybePromise } from '@difizen/mana-app'; +import { Syringe } from '@difizen/mana-app'; + +import type { IPosition, ISelection } from '../code-editor-protocol.js'; +import type { CancellationToken } from '../index.js'; + +export const EditorWidgetContribution = Syringe.defineToken('EditorWidgetContribution'); +export interface EditorWidgetContribution { + canHandle: () => number; + commandMap: Map; + handlerMap: Map; + getActionButtons: () => WidgetActionItem[]; + getActionHandler: (actionId: string) => WidgetActionHandlerItem | undefined; +} + +export type WidgetActionHandlerItem = BaseInlineHandler< + [code: string, token: CancellationToken] +>; + +export interface BaseInlineHandler { + /** + * 直接执行 action 的操作,点击后 inline chat 立即消失 + */ + execute?: (...args: T) => MaybePromise; + /** + * 在 editor 里预览输出的结果 + */ + providePreviewStrategy?: (...args: T) => MaybePromise; +} + +export interface WidgetActionItem { + /** + * 唯一标识的 id + */ + id: string; + /** + * 用于展示的名称 + */ + name: string; + /** + * hover 上去的 popover 提示 + */ + title?: string; + renderType?: WidgetActionRenderType; + /** + * 排序 + */ + order?: number; + + /** + * Show in code action list, default is not show + * Only support editor inline chat now + * @example {} + */ + codeAction?: CodeActionItem; +} + +export interface CodeActionItem { + title?: string; + kind?: string; + isPreferred?: boolean; + disabled?: string; +} + +export type WidgetActionRenderType = 'button' | 'dropdown'; + +export interface IInlineContentWidget extends ContentWidget { + show: (options?: ShowAIContentOptions | undefined) => void; + hide: (options?: ShowAIContentOptions | undefined) => void; +} + +export interface ShowAIContentOptions { + selection?: ISelection; + position?: IPosition; +} + +export interface ContentWidget { + /** + * Render this content widget in a location where it could overflow the editor's view dom node. + */ + allowEditorOverflow?: boolean; + /** + * Call preventDefault() on mousedown events that target the content widget. + */ + suppressMouseDown?: boolean; + /** + * Get a unique identifier of the content widget. + */ + getId(): string; + /** + * Get the dom node of the content widget. + */ + getDomNode(): HTMLElement; + /** + * Get the placement of the content widget. + * If null is returned, the content widget will be placed off screen. + */ + getPosition(): IContentWidgetPosition | null; + /** + * Optional function that is invoked before rendering + * the content widget. If a dimension is returned the editor will + * attempt to use it. + */ + beforeRender?(): IDimension | null; + /** + * Optional function that is invoked after rendering the content + * widget. Is being invoked with the selected position preference + * or `null` if not rendered. + */ + afterRender?(position: ContentWidgetPositionPreference | null): void; +} + +export interface IDimension { + width: number; + height: number; +} + +/** + * A position for rendering content widgets. + */ +export interface IContentWidgetPosition { + /** + * Desired position which serves as an anchor for placing the content widget. + * The widget will be placed above, at, or below the specified position, based on the + * provided preference. The widget will always touch this position. + * + * Given sufficient horizontal space, the widget will be placed to the right of the + * passed in position. This can be tweaked by providing a `secondaryPosition`. + * + * @see preference + * @see secondaryPosition + */ + position: IPosition | null; + /** + * Optionally, a secondary position can be provided to further define the placing of + * the content widget. The secondary position must have the same line number as the + * primary position. If possible, the widget will be placed such that it also touches + * the secondary position. + */ + secondaryPosition?: IPosition | null; + /** + * Placement preference for position, in order of preference. + */ + preference: ContentWidgetPositionPreference[]; + /** + * Placement preference when multiple view positions refer to the same (model) position. + * This plays a role when injected text is involved. + */ + positionAffinity?: PositionAffinity; +} + +/** + * A positioning preference for rendering content widgets. + */ +export declare enum ContentWidgetPositionPreference { + /** + * Place the content widget exactly at a position + */ + EXACT = 0, + /** + * Place the content widget above a position + */ + ABOVE = 1, + /** + * Place the content widget below a position + */ + BELOW = 2, +} + +export declare enum PositionAffinity { + /** + * Prefers the left most position. + */ + Left = 0, + /** + * Prefers the right most position. + */ + Right = 1, + /** + * No preference. + */ + None = 2, + /** + * If the given position is on injected text, prefers the position left of it. + */ + LeftOfInjectedText = 3, + /** + * If the given position is on injected text, prefers the position right of it. + */ + RightOfInjectedText = 4, +} diff --git a/packages/libro-code-editor/src/index.ts b/packages/libro-code-editor/src/index.ts index a7cccf0b..61b2df3c 100644 --- a/packages/libro-code-editor/src/index.ts +++ b/packages/libro-code-editor/src/index.ts @@ -1,5 +1,6 @@ export * from './code-editor-manager.js'; export * from './code-editor-inline-completions/index.js'; +export * from './code-editor-widget/index.js'; export * from './code-editor-model.js'; export * from './code-editor-protocol.js'; export * from './code-editor-settings.js'; diff --git a/packages/libro-code-editor/src/module.ts b/packages/libro-code-editor/src/module.ts index bd0c5769..576ef5db 100644 --- a/packages/libro-code-editor/src/module.ts +++ b/packages/libro-code-editor/src/module.ts @@ -11,6 +11,10 @@ import { CodeEditorContribution } from './code-editor-protocol.js'; import { CodeEditorSettings } from './code-editor-settings.js'; import { CodeEditorStateManager } from './code-editor-state-manager.js'; import { CodeEditorView } from './code-editor-view.js'; +import { + EditorWidgetContribution, + EditorWidgetManager, +} from './code-editor-widget/index.js'; import { LanguageSpecContribution, LanguageSpecRegistry } from './language-specs.js'; export const CodeEditorModule = ManaModule.create() @@ -23,9 +27,11 @@ export const CodeEditorModule = ManaModule.create() CodeEditorStateManager, LanguageSpecRegistry, InlineCompletionManager, + EditorWidgetManager, ) .contribution( CodeEditorContribution, LanguageSpecContribution, InlineCompletionContribution, + EditorWidgetContribution, ); diff --git a/packages/libro-cofine-editor/package.json b/packages/libro-cofine-editor/package.json index dd549f3b..14e5b1bf 100644 --- a/packages/libro-cofine-editor/package.json +++ b/packages/libro-cofine-editor/package.json @@ -55,13 +55,17 @@ "@difizen/mana-app": "latest", "resize-observer-polyfill": "^1.5.1", "vscode-languageserver-protocol": "^3.17.4", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "@ant-design/icons": "^5.4.0", + "react-dom": "^18.2.0" }, "peerDependencies": { - "react": ">=16" + "react": ">=16", + "antd": "^5.8.6" }, "devDependencies": { "@types/react": "^18.2.25", - "@types/uuid": "^9.0.2" + "@types/uuid": "^9.0.2", + "@types/react-dom": "^18.2.4" } } diff --git a/packages/libro-cofine-editor/src/assets/widget.png b/packages/libro-cofine-editor/src/assets/widget.png new file mode 100644 index 00000000..afe72986 Binary files /dev/null and b/packages/libro-cofine-editor/src/assets/widget.png differ diff --git a/packages/libro-cofine-editor/src/libro-e2-editor.ts b/packages/libro-cofine-editor/src/libro-e2-editor.ts index 6a0e6fc8..9d3d228e 100644 --- a/packages/libro-cofine-editor/src/libro-e2-editor.ts +++ b/packages/libro-cofine-editor/src/libro-e2-editor.ts @@ -3,6 +3,7 @@ import type { CompletionProvider, EditorState, EditorStateFactory, + EditorWidgetContribution, ICoordinate, IEditor, IEditorConfig, @@ -14,7 +15,11 @@ import type { SearchMatch, TooltipProvider, } from '@difizen/libro-code-editor'; -import { defaultConfig, LanguageSpecRegistry } from '@difizen/libro-code-editor'; +import { + defaultConfig, + LanguageSpecRegistry, + EditorWidgetManager, +} from '@difizen/libro-code-editor'; import type { E2Editor } from '@difizen/libro-cofine-editor-core'; import { EditorProvider, MonacoEnvironment } from '@difizen/libro-cofine-editor-core'; import { MIME } from '@difizen/libro-common'; @@ -39,6 +44,7 @@ import { PlaceholderContentWidget } from './placeholder.js'; import type { MonacoEditorOptions, MonacoEditorType, MonacoMatch } from './types.js'; import { MonacoRange, MonacoUri } from './types.js'; import './index.less'; +import { InlineContentWidget } from './widget/ai-widget.js'; export interface LibroE2EditorConfig extends IEditorConfig { /** @@ -314,6 +320,8 @@ export class LibroE2Editor implements IEditor { protected readonly languageSpecRegistry: LanguageSpecRegistry; @inject(CommandRegistry) protected readonly commandRegistry: CommandRegistry; + protected react: InlineContentWidget; + protected defaultLineHeight = 20; protected toDispose = new DisposableCollection(); @@ -384,13 +392,17 @@ export class LibroE2Editor implements IEditor { protected hasHorizontalScrollbar = false; + protected editorWidgetManager: EditorWidgetManager; + constructor( @inject(LibroE2EditorOptions) options: LibroE2EditorOptions, @inject(LibroE2EditorState) state: LibroE2EditorState, @inject(ThemeService) themeService: ThemeService, + @inject(EditorWidgetManager) editorWidgetManager: EditorWidgetManager, @inject(LanguageSpecRegistry) languageSpecRegistry: LanguageSpecRegistry, ) { + this.editorWidgetManager = editorWidgetManager; this.themeService = themeService; this.languageSpecRegistry = languageSpecRegistry; this.host = options.host; @@ -527,6 +539,12 @@ export class LibroE2Editor implements IEditor { }; this._editor = editorPorvider.create(host, options); + + const widgetProvider: EditorWidgetContribution = + this.editorWidgetManager.findWidgetProvider(); + + this.react = new InlineContentWidget(this.monacoEditor!, widgetProvider); + this.toDispose.push( this.monacoEditor?.onDidChangeModelContent((e) => { const value = this.monacoEditor?.getValue(); diff --git a/packages/libro-cofine-editor/src/widget/ai-widget.tsx b/packages/libro-cofine-editor/src/widget/ai-widget.tsx new file mode 100644 index 00000000..ddee17bb --- /dev/null +++ b/packages/libro-cofine-editor/src/widget/ai-widget.tsx @@ -0,0 +1,222 @@ +import type { + EditorWidgetContribution, + IPosition, + IRange, + IContentWidgetPosition, + ISelection, + ContentWidget, +} from '@difizen/libro-code-editor'; +import { DisposableCollection, singleton } from '@difizen/mana-app'; +import { editor } from '@difizen/monaco-editor-core'; +import ReactDOMClient from 'react-dom/client'; + +import type { MonacoEditorType } from '../types.js'; + +import { AIWidgetComponent as AIWidget } from './widget-card/index.js'; + +export interface ShowAIContentOptions { + selection?: ISelection; + position?: IPosition; +} + +export type PlaceHolderContent = string | HTMLElement | undefined; + +@singleton() +export class InlineContentWidget implements ContentWidget { + id = 'editor.widget.ReactInlineContentWidget'; + + protected toDispose: DisposableCollection = new DisposableCollection(); + + allowEditorOverflow = false; + + suppressMouseDown = false; + + positionPreference: editor.ContentWidgetPositionPreference[] = [ + editor.ContentWidgetPositionPreference.BELOW, + ]; + + protected editor: MonacoEditorType; + + protected placeholder: PlaceHolderContent; + + private _isHidden: boolean; + public get isHidden(): boolean { + return this._isHidden; + } + + protected domNode: HTMLDivElement; + + protected options: ShowAIContentOptions | undefined; + + constructor( + protected readonly monacoEditor: MonacoEditorType, + protected readonly widgetProvider: EditorWidgetContribution, + ) { + this.editor = monacoEditor; + this.editor?.onDidChangeCursorSelection(() => this.onDidChangeCursorSelection()); + } + + renderView() { + return ( + { + const handler = this.widgetProvider.getActionHandler(actionId); + const selection = this.editor.getSelection(); + if (!selection) { + return; + } + const code = this.editor.getModel()?.getValueInRange(selection); + handler.execute(code); + this.editor.removeContentWidget(this); + }} + onClose={() => { + this.editor.removeContentWidget(this); + }} + /> + ); + } + + getSelection = () => { + const selection = { + start: { + line: this.editor?.getSelection()?.startLineNumber || 1, + column: this.editor?.getSelection()?.startColumn || 1, + } as IPosition, + end: { + line: this.editor?.getSelection()?.endLineNumber || 1, + column: this.editor?.getSelection()?.endColumn || 1, + } as IPosition, + }; + return selection; + }; + + protected toMonacoRange(range: IRange) { + const selection = range ?? this.getSelection(); + const monacoSelection = { + startLineNumber: selection.start.line || 1, + startColumn: selection.start.column || 1, + endLineNumber: selection.end.line || 1, + endColumn: selection.end.column || 1, + }; + return monacoSelection; + } + + update(placeholder: PlaceHolderContent) { + if (this.disposed) { + return; + } + this.placeholder = placeholder; + this.onDidChangeCursorSelection(); + } + + onDidChangeCursorSelection() { + const isEqual = + this.getSelection().start.column === this.getSelection().end.column && + this.getSelection().start.line === this.getSelection().end.line; + + if (isEqual) { + this.editor.removeContentWidget(this); + } else { + this.editor.addContentWidget(this); + } + } + + setPositionPreference(preferences: editor.ContentWidgetPositionPreference[]): void { + this.positionPreference = preferences; + } + + setOptions(options?: ShowAIContentOptions | undefined): void { + this.options = options; + } + + show(options?: ShowAIContentOptions | undefined): void { + if (!options) { + return; + } + + if ( + this.options && + this.options.selection && + this.options.selection.equalsSelection(options.selection!) + ) { + return; + } + + this.setOptions(options); + this._isHidden = false; + this.editor.addContentWidget(this); + } + + hide() { + this._isHidden = true; + this.editor.removeContentWidget(this); + } + + resume(): void { + if (this._isHidden) { + this._isHidden = false; + this.editor.addContentWidget(this); + } + } + + getId(): string { + return this.id; + } + + layoutContentWidget(): void { + this.editor.layoutContentWidget(this); + } + + getClassName(): string { + return this.getId(); + } + + getDomNode(): HTMLElement { + if (!this.domNode) { + this.domNode = document.createElement('div'); + this.domNode.style.width = 'max-content'; + this.domNode.addEventListener('click', () => { + this.editor.focus(); + }); + } + + const root = ReactDOMClient.createRoot(this.domNode); + root.render(this.renderView()); + // this.layoutContentWidget(); + + return this.domNode; + } + + getPosition(): IContentWidgetPosition | null { + const cursor = this.editor.getPosition(); + + if (!cursor) { + return null; + } + return { + position: { + lineNumber: cursor.lineNumber, + column: cursor.column, + }, + preference: [ + editor.ContentWidgetPositionPreference.ABOVE, + editor.ContentWidgetPositionPreference.BELOW, + ], + }; + } + + getMiniMapWidth(): number { + return this.editor.getLayoutInfo().minimap.minimapWidth; + } + + disposed = false; + dispose() { + if (this.disposed) { + return; + } + this.toDispose.dispose(); + this.editor.removeContentWidget(this); + this.disposed = true; + } +} diff --git a/packages/libro-cofine-editor/src/widget/widget-card/index.css b/packages/libro-cofine-editor/src/widget/widget-card/index.css new file mode 100644 index 00000000..06d72d37 --- /dev/null +++ b/packages/libro-cofine-editor/src/widget/widget-card/index.css @@ -0,0 +1,70 @@ +.imageWrapper { + display: flex; + align-items: center; + height: 28px; + padding-right: 6px; + padding-left: 6px; + border: solid 1px #e2e2e2; + box-shadow: #e2e2e2; + border-radius: 6px; + background-color: #fff; +} + +.imageDisplay { + flex-shrink: 0; + align-self: center; + width: 16px; + height: 16px; + border-radius: 8px; +} + +.captionWrapper { + width: 1px; + min-width: 2px; + min-height: 15px; + margin-bottom: 4px; + margin-left: 6px; + background-color: #d3d3d3; +} + +.textDescription { + display: flex; + align-items: center; + height: 100%; + padding: 0 4px; + cursor: pointer; + margin: 0 4px; + color: #676a6e; + font-size: 12px; +} + +.textDescription:hover { + background-color: #efefef; +} + +.ellipsisButton { + align-self: center; + width: 10px; + height: 10px; + margin-left: 10px; + color: #676a6e; + font-size: 10px; +} + +.actionWrapper { + width: 1px; + min-width: 2px; + min-height: 15px; + margin-bottom: 4px; + margin-left: 16px; + background-color: #d3d3d3; +} + +.closeButton { + align-self: center; + width: 14px; + height: 14px; + cursor: pointer; + color: #676a6e; + font-size: 10px; +} diff --git a/packages/libro-cofine-editor/src/widget/widget-card/index.tsx b/packages/libro-cofine-editor/src/widget/widget-card/index.tsx new file mode 100644 index 00000000..1e7ab077 --- /dev/null +++ b/packages/libro-cofine-editor/src/widget/widget-card/index.tsx @@ -0,0 +1,32 @@ +import { CloseOutlined } from '@ant-design/icons'; +import './index.css'; +import type { WidgetActionItem } from '@difizen/libro-code-editor'; +import { Divider } from 'antd'; + +interface IProps { + operationList: WidgetActionItem[]; + onActionClick: (actionId: string) => void; + onClose?: () => void; +} + +export const AIWidgetComponent = (props: IProps) => { + const { onClose, operationList, onActionClick } = props; + + return ( +
+ + + {operationList.map((item) => ( + onActionClick(item.id)} + > + {item.name} + + ))} + + +
+ ); +};