mirror of
https://github.com/carlrobertoh/ProxyAI.git
synced 2026-05-20 01:02:02 +00:00
feat: auto apply (#743)
This commit is contained in:
parent
ffa1fbbacf
commit
6fbea7d4b8
25 changed files with 301 additions and 232 deletions
|
|
@ -26,6 +26,10 @@ CodeGPT offers a wide range of features to enhance your development experience:
|
|||
|
||||
Get instant coding advice through a ChatGPT-like interface that accepts image input. Ask questions, share screenshots, seek explanations, or get guidance on your projects without leaving your IDE.
|
||||
|
||||
### Auto Apply
|
||||
|
||||
Automatically insert AI-suggested code directly into your active file. Click the Auto Apply icon to stream changes, view them in diff style, and approve or reject edits directly.
|
||||
|
||||
**Use images**
|
||||
|
||||
Chat with your images. Upload manually or let CodeGPT auto-detect your screenshots.
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ jsoup = "1.17.2"
|
|||
jtokkit = "1.1.0"
|
||||
junit = "5.11.0"
|
||||
kotlin = "2.0.0"
|
||||
llm-client = "0.8.22"
|
||||
llm-client = "0.8.23"
|
||||
okio = "3.9.0"
|
||||
tree-sitter = "0.22.6a"
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ public final class Icons {
|
|||
public static final Icon Ollama = IconLoader.getIcon("/icons/ollama.svg", Icons.class);
|
||||
public static final Icon User = IconLoader.getIcon("/icons/user.svg", Icons.class);
|
||||
public static final Icon Upload = IconLoader.getIcon("/icons/upload.svg", Icons.class);
|
||||
public static final Icon Lightning = IconLoader.getIcon("/icons/lightning.svg", Icons.class);
|
||||
public static final Icon LightningDisabled =
|
||||
IconLoader.getIcon("/icons/lightning.svg", Icons.class);
|
||||
public static final Icon GreenCheckmark =
|
||||
IconLoader.getIcon("/icons/greenCheckmark.svg", Icons.class);
|
||||
public static final Icon SendToTheLeft =
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ public enum ActionType {
|
|||
EDIT_CODE,
|
||||
CREATE_NEW_FILE,
|
||||
COPY_CODE,
|
||||
AUTO_APPLY,
|
||||
REPLACE_IN_MAIN_EDITOR,
|
||||
INSERT_AT_CARET,
|
||||
RELOAD_MESSAGE,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package ee.carlrobert.codegpt.actions;
|
|||
|
||||
import com.intellij.openapi.actionSystem.AnAction;
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent;
|
||||
import com.intellij.openapi.editor.Editor;
|
||||
import ee.carlrobert.codegpt.telemetry.TelemetryAction;
|
||||
import javax.swing.Icon;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
|
@ -10,16 +9,13 @@ import org.jetbrains.annotations.NotNull;
|
|||
public abstract class TrackableAction extends AnAction {
|
||||
|
||||
private final ActionType actionType;
|
||||
protected final Editor editor;
|
||||
|
||||
public TrackableAction(
|
||||
@NotNull Editor editor,
|
||||
String text,
|
||||
String description,
|
||||
Icon icon,
|
||||
ActionType actionType) {
|
||||
super(text, description, icon);
|
||||
this.editor = editor;
|
||||
this.actionType = actionType;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -116,7 +116,6 @@ abstract class ToolWindowCompletionResponseEventListener implements
|
|||
ApplicationManager.getApplication().invokeLater(() -> {
|
||||
try {
|
||||
responsePanel.enableActions();
|
||||
responseContainer.enableActions();
|
||||
if (!responseContainer.isResponseReceived() && !fullMessage.isEmpty()) {
|
||||
responseContainer.withResponse(fullMessage);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import com.intellij.openapi.actionSystem.ActionToolbar;
|
|||
import com.intellij.openapi.actionSystem.AnAction;
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent;
|
||||
import com.intellij.openapi.actionSystem.DefaultActionGroup;
|
||||
import com.intellij.openapi.actionSystem.DefaultCompactActionGroup;
|
||||
import com.intellij.openapi.editor.Editor;
|
||||
import com.intellij.openapi.editor.EditorFactory;
|
||||
import com.intellij.openapi.editor.colors.EditorColorsManager;
|
||||
|
|
@ -28,19 +27,15 @@ import com.intellij.ui.components.ActionLink;
|
|||
import com.intellij.util.ui.JBUI;
|
||||
import ee.carlrobert.codegpt.CodeGPTBundle;
|
||||
import ee.carlrobert.codegpt.actions.toolwindow.ReplaceCodeInMainEditorAction;
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.CompareWithOriginalActionLink;
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.DirectApplyActionLink;
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.AutoApplyAction;
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.CopyAction;
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.DiffAction;
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.EditAction;
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.InsertAtCaretAction;
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.NewFileAction;
|
||||
import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.ReplaceSelectionAction;
|
||||
import ee.carlrobert.codegpt.ui.IconActionButton;
|
||||
import ee.carlrobert.codegpt.util.EditorUtil;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.FlowLayout;
|
||||
import javax.swing.Box;
|
||||
import javax.swing.JPanel;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
|
@ -48,7 +43,6 @@ import org.jetbrains.annotations.Nullable;
|
|||
public class ResponseEditorPanel extends JPanel implements Disposable {
|
||||
|
||||
private final Editor editor;
|
||||
private final JPanel directLinksPanel = new JPanel(new FlowLayout(FlowLayout.TRAILING, 0, 0));
|
||||
|
||||
public ResponseEditorPanel(
|
||||
Project project,
|
||||
|
|
@ -75,28 +69,16 @@ public class ResponseEditorPanel extends JPanel implements Disposable {
|
|||
}
|
||||
}
|
||||
configureEditor(
|
||||
project,
|
||||
(EditorEx) editor,
|
||||
readOnly,
|
||||
new ContextMenuPopupHandler.Simple(group),
|
||||
findLanguageExtensionMapping(markdownLanguage).getValue());
|
||||
add(editor.getComponent(), BorderLayout.CENTER);
|
||||
|
||||
if (highlightedText != null && !highlightedText.isEmpty()) {
|
||||
directLinksPanel.setVisible(false);
|
||||
directLinksPanel.setBorder(JBUI.Borders.emptyTop(8));
|
||||
directLinksPanel.add(new CompareWithOriginalActionLink(project, editor, highlightedText));
|
||||
directLinksPanel.add(Box.createHorizontalStrut(12));
|
||||
directLinksPanel.add(new DirectApplyActionLink(project, editor, highlightedText));
|
||||
add(directLinksPanel, BorderLayout.SOUTH);
|
||||
}
|
||||
|
||||
Disposer.register(disposableParent, this);
|
||||
}
|
||||
|
||||
public void showEditorActions() {
|
||||
directLinksPanel.setVisible(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
EditorFactory.getInstance().releaseEditor(editor);
|
||||
|
|
@ -107,6 +89,7 @@ public class ResponseEditorPanel extends JPanel implements Disposable {
|
|||
}
|
||||
|
||||
private void configureEditor(
|
||||
Project project,
|
||||
EditorEx editorEx,
|
||||
boolean readOnly,
|
||||
ContextMenuPopupHandler popupHandler,
|
||||
|
|
@ -133,22 +116,28 @@ public class ResponseEditorPanel extends JPanel implements Disposable {
|
|||
editorEx.setVerticalScrollbarVisible(false);
|
||||
editorEx.getContentComponent().setBorder(JBUI.Borders.emptyLeft(4));
|
||||
editorEx.setBorder(IdeBorderFactory.createBorder(ColorUtil.fromHex("#48494b")));
|
||||
editorEx.setPermanentHeaderComponent(createHeaderComponent(editorEx, extension, readOnly));
|
||||
editorEx.setPermanentHeaderComponent(
|
||||
createHeaderComponent(project, editorEx, extension, readOnly));
|
||||
editorEx.setHeaderComponent(null);
|
||||
}
|
||||
|
||||
private JPanel createHeaderComponent(EditorEx editorEx, String extension, boolean readOnly) {
|
||||
var headerComponent = new JPanel(new BorderLayout());
|
||||
headerComponent.setBorder(
|
||||
private JPanel createHeaderComponent(
|
||||
Project project,
|
||||
EditorEx editorEx,
|
||||
String extension,
|
||||
boolean readOnly) {
|
||||
var headerPanel = new JPanel(new BorderLayout());
|
||||
headerPanel.setBorder(
|
||||
JBUI.Borders.compound(
|
||||
JBUI.Borders.customLine(ColorUtil.fromHex("#48494b"), 1, 1, 0, 1),
|
||||
JBUI.Borders.empty(4)));
|
||||
headerComponent.add(createExpandLink(editorEx), BorderLayout.LINE_START);
|
||||
headerPanel.add(createExpandLink(editorEx), BorderLayout.LINE_START);
|
||||
if (!readOnly) {
|
||||
headerComponent.add(
|
||||
createHeaderActions(extension, editorEx).getComponent(), BorderLayout.LINE_END);
|
||||
headerPanel.add(
|
||||
createHeaderActions(project, extension, editorEx, headerPanel).getComponent(),
|
||||
BorderLayout.LINE_END);
|
||||
}
|
||||
return headerComponent;
|
||||
return headerPanel;
|
||||
}
|
||||
|
||||
private String getLinkText(boolean expanded) {
|
||||
|
|
@ -178,20 +167,20 @@ public class ResponseEditorPanel extends JPanel implements Disposable {
|
|||
return expandLink;
|
||||
}
|
||||
|
||||
private ActionToolbar createHeaderActions(String extension, EditorEx editorEx) {
|
||||
var actionGroup = new DefaultCompactActionGroup("EDITOR_TOOLBAR_ACTION_GROUP", false);
|
||||
actionGroup.add(new CopyAction(editor));
|
||||
actionGroup.add(new ReplaceSelectionAction(editor));
|
||||
actionGroup.add(new InsertAtCaretAction(editor));
|
||||
private ActionToolbar createHeaderActions(
|
||||
Project project,
|
||||
String extension,
|
||||
EditorEx editorEx,
|
||||
JPanel headerPanel) {
|
||||
var actionGroup = new DefaultActionGroup("EDITOR_TOOLBAR_ACTION_GROUP", false);
|
||||
actionGroup.add(new AutoApplyAction(project, editorEx, headerPanel));
|
||||
actionGroup.add(new InsertAtCaretAction(editorEx));
|
||||
actionGroup.add(new CopyAction(editorEx));
|
||||
actionGroup.addSeparator();
|
||||
|
||||
var wrapper = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0));
|
||||
wrapper.add(new IconActionButton(new CopyAction(editor)));
|
||||
wrapper.add(Box.createHorizontalStrut(4));
|
||||
wrapper.add(new IconActionButton(new ReplaceSelectionAction(editor)));
|
||||
|
||||
var menu = new JBPopupMenu();
|
||||
menu.add(new JBMenuItem(new DiffAction(editorEx, menu.getLocation())));
|
||||
menu.add(new JBMenuItem(new ReplaceSelectionAction(editorEx, menu.getLocation())));
|
||||
menu.add(new JBMenuItem(new EditAction(editorEx)));
|
||||
menu.add(new JBMenuItem(new NewFileAction(editorEx, extension)));
|
||||
|
||||
|
|
|
|||
|
|
@ -15,18 +15,20 @@ import org.jetbrains.annotations.NotNull;
|
|||
|
||||
public class CopyAction extends TrackableAction {
|
||||
|
||||
public CopyAction(@NotNull Editor editor) {
|
||||
private final @NotNull Editor toolwindowEditor;
|
||||
|
||||
public CopyAction(@NotNull Editor toolwindowEditor) {
|
||||
super(
|
||||
editor,
|
||||
CodeGPTBundle.get("toolwindow.chat.editor.action.copy.title"),
|
||||
CodeGPTBundle.get("toolwindow.chat.editor.action.copy.description"),
|
||||
Actions.Copy,
|
||||
ActionType.COPY_CODE);
|
||||
this.toolwindowEditor = toolwindowEditor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleAction(@NotNull AnActionEvent event) {
|
||||
StringSelection stringSelection = new StringSelection(editor.getDocument().getText());
|
||||
StringSelection stringSelection = new StringSelection(toolwindowEditor.getDocument().getText());
|
||||
Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
|
||||
clipboard.setContents(stringSelection, null);
|
||||
|
||||
|
|
@ -36,8 +38,8 @@ public class CopyAction extends TrackableAction {
|
|||
locationOnScreen.y = locationOnScreen.y - 16;
|
||||
|
||||
OverlayUtil.showInfoBalloon(
|
||||
CodeGPTBundle.get("toolwindow.chat.editor.action.copy.success"),
|
||||
locationOnScreen);
|
||||
CodeGPTBundle.get("toolwindow.chat.editor.action.copy.success"),
|
||||
locationOnScreen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ public class DiffAction extends AbstractAction {
|
|||
private final Point locationOnScreen;
|
||||
|
||||
public DiffAction(EditorEx editor, @Nullable Point locationOnScreen) {
|
||||
super("Diff", Actions.DiffWithClipboard);
|
||||
super("Diff Selection", Actions.DiffWithClipboard);
|
||||
this.editor = editor;
|
||||
this.locationOnScreen = locationOnScreen;
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ public class DiffAction extends AbstractAction {
|
|||
public void actionPerformed(ActionEvent event) {
|
||||
var project = requireNonNull(editor.getProject());
|
||||
var mainEditor = FileEditorManager.getInstance(project).getSelectedTextEditor();
|
||||
if (mainEditor != null && !EditorUtil.hasSelection(mainEditor) && locationOnScreen != null) {
|
||||
if (mainEditor == null || !EditorUtil.hasSelection(mainEditor)) {
|
||||
OverlayUtil.showSelectedEditorSelectionWarning(project, locationOnScreen);
|
||||
return;
|
||||
}
|
||||
|
|
@ -36,6 +36,6 @@ public class DiffAction extends AbstractAction {
|
|||
EditorDiffUtil.showDiff(
|
||||
project,
|
||||
editor,
|
||||
mainEditor.getSelectionModel().getSelectedText());
|
||||
requireNonNull(mainEditor.getSelectionModel().getSelectedText()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,13 +17,15 @@ import org.jetbrains.annotations.Nullable;
|
|||
|
||||
public class InsertAtCaretAction extends TrackableAction {
|
||||
|
||||
public InsertAtCaretAction(@NotNull Editor editor) {
|
||||
private final @NotNull Editor toolwindowEditor;
|
||||
|
||||
public InsertAtCaretAction(@NotNull Editor toolwindowEditor) {
|
||||
super(
|
||||
editor,
|
||||
CodeGPTBundle.get("toolwindow.chat.editor.action.insertAtCaret.title"),
|
||||
CodeGPTBundle.get("toolwindow.chat.editor.action.insertAtCaret.description"),
|
||||
Icons.SendToTheLeft,
|
||||
ActionType.INSERT_AT_CARET);
|
||||
this.toolwindowEditor = toolwindowEditor;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -48,7 +50,7 @@ public class InsertAtCaretAction extends TrackableAction {
|
|||
|
||||
@Nullable
|
||||
private Editor getSelectedTextEditor() {
|
||||
return Optional.ofNullable(editor.getProject())
|
||||
return Optional.ofNullable(toolwindowEditor.getProject())
|
||||
.map(FileEditorManager::getInstance)
|
||||
.map(FileEditorManager::getSelectedTextEditor)
|
||||
.orElse(null);
|
||||
|
|
@ -58,7 +60,7 @@ public class InsertAtCaretAction extends TrackableAction {
|
|||
runUndoTransparentWriteAction(() -> {
|
||||
mainEditor.getDocument().insertString(
|
||||
mainEditor.getCaretModel().getOffset(),
|
||||
editor.getDocument().getText());
|
||||
toolwindowEditor.getDocument().getText());
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,36 +3,41 @@ package ee.carlrobert.codegpt.toolwindow.chat.editor.actions;
|
|||
import static java.util.Objects.requireNonNull;
|
||||
|
||||
import com.intellij.icons.AllIcons.Actions;
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent;
|
||||
import com.intellij.openapi.editor.Editor;
|
||||
import com.intellij.openapi.editor.ex.EditorEx;
|
||||
import ee.carlrobert.codegpt.CodeGPTBundle;
|
||||
import ee.carlrobert.codegpt.actions.ActionType;
|
||||
import ee.carlrobert.codegpt.actions.TrackableAction;
|
||||
import ee.carlrobert.codegpt.ui.OverlayUtil;
|
||||
import ee.carlrobert.codegpt.util.EditorUtil;
|
||||
import java.awt.Point;
|
||||
import java.awt.event.ActionEvent;
|
||||
import javax.swing.AbstractAction;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
public class ReplaceSelectionAction extends TrackableAction {
|
||||
public class ReplaceSelectionAction extends AbstractAction {
|
||||
|
||||
public ReplaceSelectionAction(@NotNull Editor editor) {
|
||||
private final @NotNull EditorEx toolwindowEditor;
|
||||
private final Point locationOnScreen;
|
||||
|
||||
public ReplaceSelectionAction(
|
||||
@NotNull EditorEx toolwindowEditor,
|
||||
@Nullable Point locationOnScreen) {
|
||||
super(
|
||||
editor,
|
||||
CodeGPTBundle.get("toolwindow.chat.editor.action.replaceSelection.title"),
|
||||
CodeGPTBundle.get("toolwindow.chat.editor.action.replaceSelection.description"),
|
||||
Actions.Replace,
|
||||
ActionType.REPLACE_IN_MAIN_EDITOR);
|
||||
Actions.Replace);
|
||||
this.toolwindowEditor = toolwindowEditor;
|
||||
this.locationOnScreen = locationOnScreen;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleAction(@NotNull AnActionEvent event) {
|
||||
var project = requireNonNull(event.getProject());
|
||||
public void actionPerformed(ActionEvent event) {
|
||||
var project = requireNonNull(toolwindowEditor.getProject());
|
||||
if (EditorUtil.isMainEditorTextSelected(project)) {
|
||||
var mainEditor = EditorUtil.getSelectedEditor(project);
|
||||
if (mainEditor != null) {
|
||||
EditorUtil.replaceEditorSelection(mainEditor, editor.getDocument().getText());
|
||||
EditorUtil.replaceEditorSelection(mainEditor, toolwindowEditor.getDocument().getText());
|
||||
}
|
||||
} else {
|
||||
OverlayUtil.showSelectedEditorSelectionWarning(event);
|
||||
OverlayUtil.showSelectedEditorSelectionWarning(project, locationOnScreen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,14 +98,6 @@ public class ChatMessageResponseBody extends JPanel {
|
|||
}
|
||||
}
|
||||
|
||||
public void enableActions() {
|
||||
if (highlightedText != null
|
||||
&& !highlightedText.isEmpty()
|
||||
&& currentlyProcessedEditorPanel != null) {
|
||||
currentlyProcessedEditorPanel.showEditorActions();
|
||||
}
|
||||
}
|
||||
|
||||
public ChatMessageResponseBody withResponse(String response) {
|
||||
try {
|
||||
for (var message : MarkdownUtil.splitCodeBlocks(response)) {
|
||||
|
|
@ -173,7 +165,7 @@ public class ChatMessageResponseBody extends JPanel {
|
|||
"<html><p style=\"margin-top: 4px; margin-bottom: 8px;\">%s</p></html>",
|
||||
message);
|
||||
if (responseReceived) {
|
||||
add(createTextPane(errorText, false));
|
||||
add(createTextPane(errorText));
|
||||
} else {
|
||||
currentlyProcessedTextPane.setText(errorText);
|
||||
}
|
||||
|
|
@ -269,12 +261,6 @@ public class ChatMessageResponseBody extends JPanel {
|
|||
}
|
||||
|
||||
private void prepareProcessingText(boolean caretVisible) {
|
||||
if (highlightedText != null
|
||||
&& !highlightedText.isEmpty()
|
||||
&& currentlyProcessedEditorPanel != null) {
|
||||
currentlyProcessedEditorPanel.showEditorActions();
|
||||
}
|
||||
|
||||
currentlyProcessedEditorPanel = null;
|
||||
currentlyProcessedTextPane = createTextPane("", caretVisible);
|
||||
add(currentlyProcessedTextPane);
|
||||
|
|
@ -316,6 +302,10 @@ public class ChatMessageResponseBody extends JPanel {
|
|||
}
|
||||
}
|
||||
|
||||
private JTextPane createTextPane(String text) {
|
||||
return createTextPane(text, false);
|
||||
}
|
||||
|
||||
private JTextPane createTextPane(String text, boolean caretVisible) {
|
||||
var textPane = UIUtil.createTextPane(text, false, event -> {
|
||||
if (FileUtil.exists(event.getDescription()) && ACTIVATED.equals(event.getEventType())) {
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ public class ResponsePanel extends JPanel {
|
|||
|
||||
Body() {
|
||||
super(new BorderLayout());
|
||||
setBorder(JBUI.Borders.empty(4, 8, 8, 8));
|
||||
setBorder(JBUI.Borders.empty(4, 8));
|
||||
}
|
||||
|
||||
public void addContent(JComponent content) {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ data class Event @JsonCreator constructor(
|
|||
ANALYZE_WEB_DOC_COMPLETED,
|
||||
ANALYZE_WEB_DOC_FAILED,
|
||||
PROCESS_CONTEXT,
|
||||
WEB_SEARCH_ITEM
|
||||
WEB_SEARCH_ITEM,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.chat
|
||||
|
||||
import com.intellij.icons.AllIcons.Actions
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.project.Project
|
||||
import ee.carlrobert.codegpt.CodeGPTBundle
|
||||
import ee.carlrobert.codegpt.util.EditorDiffUtil
|
||||
|
||||
class CompareWithOriginalActionLink(
|
||||
project: Project,
|
||||
toolwindowEditor: Editor,
|
||||
highlightedText: String,
|
||||
) : ToolwindowEditorActionLink(
|
||||
project,
|
||||
CodeGPTBundle.get("action.compareWithOriginal.title"),
|
||||
CompareWithOriginalAction(project, toolwindowEditor, highlightedText),
|
||||
highlightedText
|
||||
) {
|
||||
|
||||
init {
|
||||
setIcon(Actions.Diff)
|
||||
}
|
||||
|
||||
class CompareWithOriginalAction(
|
||||
private val project: Project,
|
||||
private val toolwindowEditor: Editor,
|
||||
private val highlightedText: String
|
||||
) : AnAction() {
|
||||
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
EditorDiffUtil.showDiff(project, toolwindowEditor, highlightedText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.chat
|
||||
|
||||
import com.intellij.icons.AllIcons.Actions
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.command.WriteCommandAction
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.editor.ScrollType
|
||||
import com.intellij.openapi.fileEditor.FileEditorManager
|
||||
import com.intellij.openapi.project.Project
|
||||
import ee.carlrobert.codegpt.CodeGPTBundle
|
||||
|
||||
class DirectApplyActionLink(
|
||||
project: Project,
|
||||
toolwindowEditor: Editor,
|
||||
highlightedText: String,
|
||||
) : ToolwindowEditorActionLink(
|
||||
project,
|
||||
CodeGPTBundle.get("action.applyDirectly.title"),
|
||||
DirectApplyAction(project, toolwindowEditor, highlightedText),
|
||||
highlightedText
|
||||
) {
|
||||
|
||||
init {
|
||||
setIcon(Actions.Selectall)
|
||||
}
|
||||
|
||||
class DirectApplyAction(
|
||||
private val project: Project,
|
||||
private val toolwindowEditor: Editor,
|
||||
private val highlightedText: String
|
||||
) : AnAction() {
|
||||
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
val mainEditor = FileEditorManager.getInstance(project).selectedTextEditor
|
||||
?: throw IllegalStateException("No editor selected")
|
||||
val startIndex = mainEditor.document.text.indexOf(highlightedText)
|
||||
if (startIndex == -1) {
|
||||
return
|
||||
}
|
||||
|
||||
val endIndex = startIndex + highlightedText.length
|
||||
val replacement = toolwindowEditor.document.text
|
||||
|
||||
WriteCommandAction.runWriteCommandAction(project) {
|
||||
mainEditor.document.replaceString(startIndex, endIndex, replacement)
|
||||
mainEditor.caretModel.moveToOffset(startIndex + replacement.length)
|
||||
mainEditor.scrollingModel.scrollToCaret(ScrollType.CENTER)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.chat
|
||||
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.application.runInEdt
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.editor.event.DocumentEvent
|
||||
import com.intellij.openapi.editor.event.DocumentListener
|
||||
import com.intellij.openapi.fileEditor.FileEditorManager
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.ui.components.AnActionLink
|
||||
|
||||
open class ToolwindowEditorActionLink(
|
||||
private val project: Project,
|
||||
title: String,
|
||||
action: AnAction,
|
||||
private val highlightedText: String,
|
||||
) : AnActionLink(title, action) {
|
||||
|
||||
private val mainEditor = project.service<FileEditorManager>().selectedTextEditor
|
||||
private val documentListener = object : DocumentListener {
|
||||
override fun documentChanged(event: DocumentEvent) {
|
||||
updateActionState()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
mainEditor?.document?.addDocumentListener(documentListener)
|
||||
autoHideOnDisable = false
|
||||
}
|
||||
|
||||
private fun updateActionState() {
|
||||
val mainEditor = project.service<FileEditorManager>().selectedTextEditor
|
||||
val startIndex = mainEditor?.document?.text?.indexOf(highlightedText)
|
||||
runInEdt {
|
||||
isEnabled = startIndex != null && startIndex != -1
|
||||
toolTipText = if (isEnabled) null else "Original editor state has changed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
package ee.carlrobert.codegpt.toolwindow.chat.editor.actions
|
||||
|
||||
import com.intellij.diff.DiffManager
|
||||
import com.intellij.diff.chains.SimpleDiffRequestChain
|
||||
import com.intellij.diff.editor.ChainDiffVirtualFile
|
||||
import com.intellij.notification.NotificationType
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.application.runInEdt
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.fileEditor.FileEditorManager
|
||||
import com.intellij.openapi.progress.ProgressIndicator
|
||||
import com.intellij.openapi.progress.ProgressManager
|
||||
import com.intellij.openapi.progress.Task
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.Key
|
||||
import com.intellij.testFramework.LightVirtualFile
|
||||
import com.intellij.ui.components.ActionLink
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.JBUI
|
||||
import ee.carlrobert.codegpt.CodeGPTBundle
|
||||
import ee.carlrobert.codegpt.Icons
|
||||
import ee.carlrobert.codegpt.actions.ActionType
|
||||
import ee.carlrobert.codegpt.actions.TrackableAction
|
||||
import ee.carlrobert.codegpt.completions.CompletionClientProvider
|
||||
import ee.carlrobert.codegpt.settings.GeneralSettings
|
||||
import ee.carlrobert.codegpt.settings.service.ServiceType
|
||||
import ee.carlrobert.codegpt.ui.OverlayUtil
|
||||
import ee.carlrobert.codegpt.util.EditorDiffUtil.createDiffRequest
|
||||
import ee.carlrobert.codegpt.util.EditorUtil
|
||||
import ee.carlrobert.codegpt.util.EditorUtil.getSelectedEditor
|
||||
import ee.carlrobert.llm.client.codegpt.request.AutoApplyRequest
|
||||
import ee.carlrobert.llm.client.codegpt.response.CodeGPTException
|
||||
import java.awt.FlowLayout
|
||||
import java.util.*
|
||||
import javax.swing.JButton
|
||||
import javax.swing.JPanel
|
||||
|
||||
class AutoApplyAction(
|
||||
private val project: Project,
|
||||
private val toolwindowEditor: Editor,
|
||||
private val headerPanel: JPanel,
|
||||
) : TrackableAction(
|
||||
CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.title"),
|
||||
CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.description"),
|
||||
Icons.Lightning,
|
||||
ActionType.AUTO_APPLY
|
||||
) {
|
||||
private lateinit var diffRequestId: UUID
|
||||
|
||||
companion object {
|
||||
private val DIFF_REQUEST_KEY = Key.create<String>("codegpt.autoApply.diffRequest")
|
||||
}
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
val isCodeGPTSelected = GeneralSettings.getSelectedService() == ServiceType.CODEGPT
|
||||
|
||||
e.presentation.apply {
|
||||
isEnabled = isCodeGPTSelected
|
||||
icon = if (isCodeGPTSelected) Icons.Lightning else Icons.LightningDisabled
|
||||
text = if (isCodeGPTSelected) {
|
||||
CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.title")
|
||||
} else {
|
||||
CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.disabledTitle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleAction(event: AnActionEvent) {
|
||||
val mainEditor = getSelectedEditor(project)
|
||||
?: throw IllegalStateException("Unable to find active editor")
|
||||
val request = AutoApplyRequest().apply {
|
||||
suggestedChanges = toolwindowEditor.document.text
|
||||
fileContent = mainEditor.document.text
|
||||
}
|
||||
|
||||
headerPanel.getComponent(1).isVisible = false
|
||||
|
||||
val acceptLink = createDisabledActionLink("Accept")
|
||||
val rejectLink = createDisabledActionLink("Reject")
|
||||
|
||||
val actionsPanel = JPanel(FlowLayout(FlowLayout.TRAILING, 8, 0)).apply {
|
||||
border = JBUI.Borders.empty(4, 0)
|
||||
add(acceptLink)
|
||||
add(JBLabel("|"))
|
||||
add(rejectLink)
|
||||
}
|
||||
headerPanel.add(actionsPanel)
|
||||
|
||||
ProgressManager.getInstance().run(
|
||||
ApplyChangesBackgroundTask(
|
||||
project,
|
||||
request,
|
||||
{ modifiedFileContent ->
|
||||
acceptLink.setupLink(mainEditor, actionsPanel) {
|
||||
EditorUtil.updateEditorDocument(mainEditor, modifiedFileContent)
|
||||
}
|
||||
rejectLink.setupLink(mainEditor, actionsPanel)
|
||||
showDiff(mainEditor, modifiedFileContent)
|
||||
},
|
||||
{
|
||||
val errorMessage = if (it is CodeGPTException) {
|
||||
it.detail
|
||||
} else {
|
||||
"Something went wrong while applying changes. ${it.message}"
|
||||
}
|
||||
OverlayUtil.showNotification(errorMessage, NotificationType.ERROR)
|
||||
resetState(mainEditor, actionsPanel)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private fun JButton.setupLink(
|
||||
mainEditor: Editor,
|
||||
actionsPanel: JPanel,
|
||||
onAction: (() -> Unit)? = null
|
||||
) {
|
||||
isEnabled = true
|
||||
addActionListener {
|
||||
resetState(mainEditor, actionsPanel)
|
||||
onAction?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showDiff(mainEditor: Editor, modifiedFileContent: String) {
|
||||
diffRequestId = UUID.randomUUID()
|
||||
|
||||
val tempDiffFile = LightVirtualFile(mainEditor.virtualFile.name, modifiedFileContent)
|
||||
val diffRequest = createDiffRequest(project, tempDiffFile, mainEditor).apply {
|
||||
putUserData(DIFF_REQUEST_KEY, diffRequestId.toString())
|
||||
}
|
||||
|
||||
runInEdt {
|
||||
service<DiffManager>().showDiff(project, diffRequest)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDisabledActionLink(text: String): ActionLink {
|
||||
return ActionLink(text).apply {
|
||||
isEnabled = false
|
||||
autoHideOnDisable = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetState(mainEditor: Editor, actionsPanel: JPanel) {
|
||||
headerPanel.remove(actionsPanel)
|
||||
headerPanel.getComponent(1).isVisible = true
|
||||
val fileEditorManager = project.service<FileEditorManager>()
|
||||
fileEditorManager.openFile(mainEditor.virtualFile, true)
|
||||
|
||||
val diffFile = fileEditorManager.openFiles.firstOrNull {
|
||||
it is ChainDiffVirtualFile && it.chain.requests
|
||||
.filterIsInstance<SimpleDiffRequestChain.DiffRequestProducerWrapper>()
|
||||
.any { chainRequest ->
|
||||
chainRequest.request.getUserData(DIFF_REQUEST_KEY) == diffRequestId.toString()
|
||||
}
|
||||
}
|
||||
if (diffFile != null) {
|
||||
fileEditorManager.closeFile(diffFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class ApplyChangesBackgroundTask(
|
||||
project: Project,
|
||||
private val request: AutoApplyRequest,
|
||||
private val onSuccess: (modifiedFileContent: String) -> Unit,
|
||||
private val onFailure: (ex: Exception) -> Unit,
|
||||
) : Task.Backgroundable(project, "Apply changes", true) {
|
||||
|
||||
override fun run(indicator: ProgressIndicator) {
|
||||
indicator.isIndeterminate = false
|
||||
indicator.fraction = 1.0
|
||||
indicator.text = "CodeGPT: Applying changes"
|
||||
|
||||
try {
|
||||
val modifiedFileContent = CompletionClientProvider.getCodeGPTClient()
|
||||
.applySuggestedChanges(request)
|
||||
.modifiedFileContent
|
||||
onSuccess(modifiedFileContent)
|
||||
} catch (ex: Exception) {
|
||||
onFailure(ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import com.intellij.util.ui.JBUI
|
|||
import ee.carlrobert.codegpt.Icons
|
||||
import ee.carlrobert.codegpt.events.EventDetails
|
||||
import ee.carlrobert.codegpt.events.ProcessContextEventDetails
|
||||
import ee.carlrobert.codegpt.util.MarkdownUtil.convertMdToHtml
|
||||
import java.awt.FlowLayout
|
||||
import javax.swing.*
|
||||
|
||||
|
|
@ -22,21 +23,27 @@ class ResponseBodyProgressPanel : JPanel() {
|
|||
|
||||
init {
|
||||
layout = BoxLayout(this, BoxLayout.Y_AXIS)
|
||||
border = JBUI.Borders.emptyBottom(8)
|
||||
border = JBUI.Borders.empty(4, 0, 8, 0)
|
||||
}
|
||||
|
||||
fun updateProgressContainer(text: String, icon: Icon?) {
|
||||
runInEdt {
|
||||
removeAll()
|
||||
val wrapper: JComponent
|
||||
if (icon != null) {
|
||||
wrapper = JBLabel(text, icon, SwingConstants.LEADING)
|
||||
wrapper.horizontalTextPosition = SwingConstants.LEADING
|
||||
val wrapper = if (icon != null) {
|
||||
JBLabel(
|
||||
"<html>" + convertMdToHtml(text) + "</html>",
|
||||
icon,
|
||||
SwingConstants.LEADING
|
||||
).apply {
|
||||
horizontalAlignment = SwingConstants.LEFT
|
||||
horizontalTextPosition = SwingConstants.RIGHT
|
||||
}
|
||||
} else {
|
||||
wrapper = JPanel(FlowLayout(FlowLayout.LEADING, 0, 0))
|
||||
wrapper.add(JBLabel(text))
|
||||
wrapper.add(Box.createHorizontalStrut(4))
|
||||
wrapper.add(processSpinner)
|
||||
JPanel(FlowLayout(FlowLayout.LEADING, 0, 0)).apply {
|
||||
add(JBLabel("<html>" + convertMdToHtml(text) + "</html>"))
|
||||
add(Box.createHorizontalStrut(4))
|
||||
add(processSpinner)
|
||||
}
|
||||
}
|
||||
add(JBUI.Panels.simplePanel(wrapper))
|
||||
revalidate()
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ object EditorDiffUtil {
|
|||
createTempDiffContent(mainEditor, toolwindowEditor, highlightedText)
|
||||
)
|
||||
DiffManager.getInstance()
|
||||
.showDiff(project, createDiffRequest(project, tempFile, mainEditor, mainEditorFile))
|
||||
.showDiff(project, createDiffRequest(project, tempFile, mainEditor))
|
||||
}
|
||||
|
||||
private fun createTempDiffContent(
|
||||
|
|
@ -48,11 +48,11 @@ object EditorDiffUtil {
|
|||
mainDocumentContent.substring(endIndex)
|
||||
}
|
||||
|
||||
private fun createDiffRequest(
|
||||
@JvmStatic
|
||||
fun createDiffRequest(
|
||||
project: Project,
|
||||
tempFile: VirtualFile,
|
||||
mainEditor: Editor,
|
||||
mainEditorFile: VirtualFile
|
||||
): SimpleDiffRequest {
|
||||
val diffContentFactory = DiffContentFactory.getInstance()
|
||||
val tempFileDiffContent = diffContentFactory.create(project, tempFile).apply {
|
||||
|
|
@ -61,9 +61,9 @@ object EditorDiffUtil {
|
|||
|
||||
return SimpleDiffRequest(
|
||||
CodeGPTBundle.get("editor.diff.title"),
|
||||
diffContentFactory.create(project, mainEditorFile),
|
||||
diffContentFactory.create(project, mainEditor.virtualFile),
|
||||
tempFileDiffContent,
|
||||
mainEditorFile.name,
|
||||
mainEditor.virtualFile.name,
|
||||
CodeGPTBundle.get("editor.diff.local.content.title")
|
||||
).apply {
|
||||
putUserData(
|
||||
|
|
|
|||
|
|
@ -68,8 +68,7 @@ object EditorUtil {
|
|||
|
||||
@JvmStatic
|
||||
fun getSelectedEditor(project: Project): Editor? {
|
||||
val editorManager = FileEditorManager.getInstance(project)
|
||||
return editorManager?.selectedTextEditor
|
||||
return FileEditorManager.getInstance(project)?.selectedTextEditor
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
|
|
|
|||
4
src/main/resources/icons/lighting_disabled.svg
Normal file
4
src/main/resources/icons/lighting_disabled.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.24408 6.65516C8.05415 6.43616 7.96859 6.14552 8.00959 5.85854L8.50238 2.40901L3.10978 8.99996H6.99954C7.28943 8.99996 7.56505 9.12576 7.75499 9.34476C7.94493 9.56376 8.03048 9.85441 7.98949 10.1414L7.4967 13.5909L12.8893 6.99996H8.99954C8.70965 6.99996 8.43402 6.87416 8.24408 6.65516ZM9.74593 0.775195C9.81752 0.274069 9.18453 -0.00392066 8.86398 0.387867L1.66768 9.18334C1.40057 9.50981 1.63285 9.99996 2.05466 9.99996H6.99954L6.25314 15.2247C6.18155 15.7259 6.81454 16.0038 7.1351 15.6121L14.3314 6.81658C14.5985 6.49012 14.3662 5.99996 13.9444 5.99996H8.99954L9.74593 0.775195Z" fill="#9AA7B0" fill-opacity="0.4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 903 B |
4
src/main/resources/icons/lightning.svg
Normal file
4
src/main/resources/icons/lightning.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.24408 6.65516C8.05415 6.43616 7.96859 6.14552 8.00959 5.85854L8.50238 2.40901L3.10978 8.99996H6.99954C7.28943 8.99996 7.56505 9.12576 7.75499 9.34476C7.94493 9.56376 8.03048 9.85441 7.98949 10.1414L7.4967 13.5909L12.8893 6.99996H8.99954C8.70965 6.99996 8.43402 6.87416 8.24408 6.65516ZM9.74593 0.775195C9.81752 0.274069 9.18453 -0.00392066 8.86398 0.387867L1.66768 9.18334C1.40057 9.50981 1.63285 9.99996 2.05466 9.99996H6.99954L6.25314 15.2247C6.18155 15.7259 6.81454 16.0038 7.1351 15.6121L14.3314 6.81658C14.5985 6.49012 14.3662 5.99996 13.9444 5.99996H8.99954L9.74593 0.775195Z" fill="#E66D17"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 882 B |
4
src/main/resources/icons/lightning_dark.svg
Normal file
4
src/main/resources/icons/lightning_dark.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.24408 6.65516C8.05415 6.43616 7.96859 6.14552 8.00959 5.85854L8.50238 2.40901L3.10978 8.99996H6.99954C7.28943 8.99996 7.56505 9.12576 7.75499 9.34476C7.94493 9.56376 8.03048 9.85441 7.98949 10.1414L7.4967 13.5909L12.8893 6.99996H8.99954C8.70965 6.99996 8.43402 6.87416 8.24408 6.65516ZM9.74593 0.775195C9.81752 0.274069 9.18453 -0.00392066 8.86398 0.387867L1.66768 9.18334C1.40057 9.50981 1.63285 9.99996 2.05466 9.99996H6.99954L6.25314 15.2247C6.18155 15.7259 6.81454 16.0038 7.1351 15.6121L14.3314 6.81658C14.5985 6.49012 14.3662 5.99996 13.9444 5.99996H8.99954L9.74593 0.775195Z" fill="#C77D55"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 882 B |
|
|
@ -26,7 +26,7 @@ action.statusbar.disableCompletions.text=Disable Completions
|
|||
action.statusbar.disableCompletions.description=Disable Code Completions
|
||||
action.statusbar.disableCompletions.MainMenu.text=Disable Completions
|
||||
action.compareWithOriginal.title=Compare with Original
|
||||
action.applyDirectly.title=Apply Directly
|
||||
action.applyDirectly.title=Auto Apply
|
||||
settings.displayName=CodeGPT: Settings
|
||||
settings.openaiQuotaExceeded=OpenAI quota exceeded.
|
||||
settingsConfigurable.displayName.label=Display name:
|
||||
|
|
@ -172,7 +172,9 @@ editor.diff.local.content.title=CodeGPT suggested code
|
|||
toolwindow.chat.editor.action.copy.title=Copy
|
||||
toolwindow.chat.editor.action.copy.description=Copy generated code
|
||||
toolwindow.chat.editor.action.copy.success=Code copied!
|
||||
toolwindow.chat.editor.action.diff.title=Diff
|
||||
toolwindow.chat.editor.action.autoApply.title=Auto Apply
|
||||
toolwindow.chat.editor.action.autoApply.disabledTitle=Auto Apply is only available with CodeGPT provider
|
||||
toolwindow.chat.editor.action.autoApply.description=Apply suggested changes automatically
|
||||
toolwindow.chat.editor.action.diff.description=Diff editor code against the generated one
|
||||
toolwindow.chat.editor.action.edit.title=Edit Source
|
||||
toolwindow.chat.editor.action.disableEditing.title=Disable Editing
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue