import cls from 'classnames';
import React, { useCallback, useEffect } from 'react';
import ReactDOMClient from 'react-dom/client';
import { Autowired, Injectable } from '@opensumi/di';
import { KeybindingRegistry, useDisposable } from '@opensumi/ide-core-browser';
import { AI_INLINE_DIFF_PARTIAL_EDIT } from '@opensumi/ide-core-browser/lib/ai-native/command';
import { Emitter, Event, IPosition, isDefined, isUndefined, localize, uuid } from '@opensumi/ide-core-common';
import {
ICodeEditor,
IEditorDecorationsCollection,
IModelDecorationsChangedEvent,
Position,
} from '@opensumi/ide-monaco';
import { ReactInlineContentWidget } from '@opensumi/ide-monaco/lib/browser/ai-native/BaseInlineContentWidget';
import { URI } from '@opensumi/ide-monaco/lib/browser/monaco-api';
import { ContentWidgetPositionPreference } from '@opensumi/ide-monaco/lib/browser/monaco-exports/editor';
import { EditorOption } from '@opensumi/monaco-editor-core/esm/vs/editor/common/config/editorOptions';
import { IScrollEvent } from '@opensumi/monaco-editor-core/esm/vs/editor/common/editorCommon';
import { LineTokens } from '@opensumi/monaco-editor-core/esm/vs/editor/common/tokens/lineTokens';
import { IOptions, ZoneWidget } from '@opensumi/monaco-editor-core/esm/vs/editor/contrib/zoneWidget/browser/zoneWidget';
import { UndoRedoGroup } from '@opensumi/monaco-editor-core/esm/vs/platform/undoRedo/common/undoRedo';
import {
DeltaDecorations,
EnhanceDecorationsCollection,
IDeltaDecorationsOptions,
} from '../../model/enhanceDecorationsCollection';
import { renderLines } from '../ghost-text-widget/index';
import styles from './inline-stream-diff.module.less';
export const ActiveLineDecoration = 'activeLine-decoration';
export const AddedRangeDecoration = 'added-range-decoration';
export const PendingRangeDecoration = 'pending-range-decoration';
interface IPartialEditWidgetComponent {
acceptSequence: string;
discardSequence: string;
}
type IWidgetStatus = 'accept' | 'discard' | 'pending';
export interface IRemovedWidgetState {
textLines: ITextLinesTokens[];
position: IPosition;
}
export interface ITextLinesTokens {
text: string;
lineTokens: LineTokens;
}
const PartialEditComponent = (props: {
keyStrings: IPartialEditWidgetComponent;
onAccept: () => void;
onDiscard: () => void;
editor: ICodeEditor;
}) => {
const { keyStrings, onAccept, onDiscard, editor } = props;
const [scrollLeft, setScrollLeft] = React.useState(0);
const handleAccept = useCallback(() => {
onAccept?.();
}, [onAccept]);
const handleDiscard = useCallback(() => {
onDiscard?.();
}, [onDiscard]);
useDisposable(
() =>
editor.onDidScrollChange((event: IScrollEvent) => {
const { scrollLeftChanged, scrollLeft } = event;
if (scrollLeftChanged) {
setScrollLeft(scrollLeft);
}
}),
[editor],
);
return (
{localize('aiNative.inline.diff.accept')}
{keyStrings.acceptSequence}
{localize('aiNative.inline.diff.reject')}
{keyStrings.discardSequence}
);
};
export interface IPartialEditWidgetOptions {
/**
* In some case, we don't want to show the accept and reject button
*/
hideAcceptPartialEditWidget?: boolean;
}
@Injectable({ multiple: true })
export class AcceptPartialEditWidget extends ReactInlineContentWidget {
static ID = 'AcceptPartialEditWidgetID';
@Autowired(KeybindingRegistry)
private readonly keybindingRegistry: KeybindingRegistry;
private _id: string;
private _addedRangeId: string;
private readonly _onAccept = this.registerDispose(new Emitter());
public readonly onAccept: Event = this._onAccept.event;
private readonly _onDiscard = this.registerDispose(new Emitter());
public readonly onDiscard: Event = this._onDiscard.event;
positionPreference = [ContentWidgetPositionPreference.EXACT];
constructor(protected readonly editor: ICodeEditor, protected editWidgetOptions?: IPartialEditWidgetOptions) {
super(editor);
}
public addedLinesCount: number = 0;
public deletedLinesCount: number = 0;
public status: IWidgetStatus = 'pending';
private _group: UndoRedoGroup;
public get group(): UndoRedoGroup {
return this._group;
}
private getSequenceKeyStrings(): IPartialEditWidgetComponent | undefined {
let keybindings = this.keybindingRegistry.getKeybindingsForCommand(AI_INLINE_DIFF_PARTIAL_EDIT.id);
keybindings = keybindings.sort((a, b) => b.args - a.args);
if (!keybindings || (keybindings.length !== 2 && keybindings.some((k) => isUndefined(k.resolved)))) {
return;
}
return {
acceptSequence: this.keybindingRegistry.acceleratorForSequence(keybindings[0].resolved!, '')[0],
discardSequence: this.keybindingRegistry.acceleratorForSequence(keybindings[1].resolved!, '')[0],
};
}
public renderView(): React.ReactNode {
if (this.editWidgetOptions?.hideAcceptPartialEditWidget) {
return;
}
const keyStrings = this.getSequenceKeyStrings();
if (!keyStrings) {
return;
}
return (
this._onAccept.fire()}
onDiscard={() => this._onDiscard.fire()}
editor={this.editor}
/>
);
}
public id(): string {
if (!this._id) {
this._id = `${AcceptPartialEditWidget.ID}_${uuid(4)}`;
}
return this._id;
}
public getClassName(): string {
return styles.accept_partial_edit_widget_id;
}
public recordAddedRangeId(id: string): void {
this._addedRangeId = id;
}
public getAddedRangeId(): string {
return this._addedRangeId;
}
public resume(): void {
this.status = 'pending';
this.addedLinesCount = 0;
this.deletedLinesCount = 0;
super.resume();
}
public setGroup(group): void {
this._group = group;
}
get isPending(): boolean {
return this.status === 'pending';
}
public accept(addedLinesCount: number, deletedLinesCount: number): void {
this.status = 'accept';
this.addedLinesCount = addedLinesCount;
this.deletedLinesCount = deletedLinesCount;
super.hide();
}
get isAccepted(): boolean {
return this.status === 'accept';
}
public discard(addedLinesCount: number, deletedLinesCount: number): void {
this.status = 'discard';
this.addedLinesCount = addedLinesCount;
this.deletedLinesCount = deletedLinesCount;
super.hide();
}
get isRejected(): boolean {
return this.status === 'discard';
}
}
const RemovedWidgetComponent = ({ dom, editor }) => {
const ref = React.useRef(null);
const [scrollLeft, setScrollLeft] = React.useState(0);
const [marginWidth, setMarginWidth] = React.useState(0);
useEffect(() => {
if (dom && ref && ref.current) {
ref.current.appendChild(dom);
}
}, [dom, ref]);
useDisposable(
() =>
editor.onDidScrollChange((event: IScrollEvent) => {
const { scrollLeftChanged, scrollLeft } = event;
if (scrollLeftChanged) {
setScrollLeft(scrollLeft);
}
}),
[editor],
);
useDisposable(() => {
setMarginWidth(editor.getOption(EditorOption.layoutInfo).contentLeft);
return editor.onDidChangeConfiguration((event) => {
if (event.hasChanged(EditorOption.layoutInfo)) {
setMarginWidth(editor.getOption(EditorOption.layoutInfo).contentLeft);
}
});
}, [editor]);
return (
);
};
export interface IRemovedZoneWidgetOptions extends IOptions {
isHidden?: boolean;
recordPosition?: Position;
undoRedoGroup?: UndoRedoGroup;
}
export class RemovedZoneWidget extends ZoneWidget {
private root: ReactDOMClient.Root;
private _recordPosition: Position;
private _hidden: boolean = false;
get isHidden(): boolean {
return this._hidden;
}
private _group: UndoRedoGroup;
public get group(): UndoRedoGroup {
return this._group;
}
public status: IWidgetStatus = 'pending';
constructor(editor: ICodeEditor, public readonly textLines: ITextLinesTokens[], options: IRemovedZoneWidgetOptions) {
super(editor, options);
if (isDefined(options.isHidden)) {
this._hidden = options.isHidden;
}
if (isDefined(options.recordPosition)) {
this._recordPosition = options.recordPosition;
}
if (isDefined(options.undoRedoGroup)) {
this._group = options.undoRedoGroup;
}
// 监听 position 的位置变化
const positionMarkerId = this['_positionMarkerId'] as IEditorDecorationsCollection;
this._disposables.add(
positionMarkerId.onDidChange((event: IModelDecorationsChangedEvent) => {
const range = positionMarkerId.getRange(0);
if (range) {
this._recordPosition = range.getStartPosition();
}
}),
);
}
setGroup(group): void {
this._group = group;
}
_fillContainer(container: HTMLElement): void {
container.classList.add(styles.inline_diff_remove_zone_widget_container);
this.root = ReactDOMClient.createRoot(container);
}
getRemovedTextLines(): string[] {
return this.textLines.map((v) => v.text);
}
get height() {
return this.textLines.length;
}
getLastPosition(): Position {
return this.position || this._recordPosition;
}
accept(): void {
this.status = 'accept';
this.hide();
}
discard(): void {
this.status = 'discard';
super.hide();
}
hide(): void {
this._hidden = true;
super.hide();
}
resume(): void {
this.status = 'pending';
const position = this.getLastPosition();
if (position) {
this.show(position, this.height);
}
}
override show(pos: IPosition, heightInLines: number): void {
this._hidden = false;
this.status = 'pending';
super.show(pos, heightInLines);
}
override revealRange(): void {}
override create(): void {
super.create();
this.mountRender();
}
mountRender(): void {
const dom = document.createElement('div');
renderLines(
dom,
this.editor.getOption(EditorOption.tabIndex),
this.textLines.map(({ text: content, lineTokens }) => ({
content,
decorations: [],
lineTokens,
})),
this.editor.getOptions(),
);
this.root.render();
}
dispose(): void {
this.root.unmount();
super.dispose();
}
}
class AddedRangeDeltaDecorations extends DeltaDecorations {
public status: IWidgetStatus = 'pending';
accept(): void {
this.status = 'accept';
super.hide();
}
discard(): void {
this.status = 'discard';
super.hide();
}
}
export class AddedRangeDecorationsCollection extends EnhanceDecorationsCollection {
protected override createDecorations(metaData: IDeltaDecorationsOptions) {
return new AddedRangeDeltaDecorations(metaData);
}
}