feat: improve tool window's textbox (#621)

* feat: initial smart user input panel implementation

* refactor: clean up
This commit is contained in:
Carl-Robert 2024-07-18 14:18:51 +03:00 committed by Carl-Robert Linnupuu
parent c911f3603d
commit 50d631838f
15 changed files with 965 additions and 462 deletions

View file

@ -1,7 +1,14 @@
package ee.carlrobert.codegpt.settings.service;
import static ee.carlrobert.llm.client.openai.completion.OpenAIChatCompletionModel.GPT_4_O;
import static ee.carlrobert.llm.client.openai.completion.OpenAIChatCompletionModel.GPT_4_VISION_PREVIEW;
import com.intellij.openapi.application.ApplicationManager;
import ee.carlrobert.codegpt.CodeGPTBundle;
import ee.carlrobert.codegpt.settings.service.codegpt.CodeGPTServiceSettings;
import ee.carlrobert.codegpt.settings.service.openai.OpenAISettings;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public enum ServiceType {
@ -56,4 +63,26 @@ public enum ServiceType {
}
return serviceType;
}
public boolean isImageActionSupported() {
return switch (this) {
case ANTHROPIC:
case OLLAMA:
yield true;
case CODEGPT:
var codegptModel = ApplicationManager.getApplication()
.getService(CodeGPTServiceSettings.class)
.getState()
.getChatCompletionSettings()
.getModel();
yield List.of("gpt-4o", "claude-3-opus").contains(codegptModel);
case OPENAI:
var openaiModel = ApplicationManager.getApplication().getService(OpenAISettings.class)
.getState()
.getModel();
yield List.of(GPT_4_VISION_PREVIEW.getCode(), GPT_4_O.getCode()).contains(openaiModel);
default:
yield false;
};
}
}

View file

@ -64,7 +64,7 @@ public class ChatToolWindowPanel extends SimpleToolWindowPanel {
var messageBusConnection = project.getMessageBus().connect();
messageBusConnection.subscribe(IncludeFilesInContextNotifier.FILES_INCLUDED_IN_CONTEXT_TOPIC,
(IncludeFilesInContextNotifier) this::displaySelectedFilesNotification);
(IncludeFilesInContextNotifier) this::updateSelectedFilesNotification);
messageBusConnection.subscribe(AttachImageNotifier.IMAGE_ATTACHMENT_FILE_PATH_TOPIC,
(AttachImageNotifier) filePath -> imageFileAttachmentNotification.show(
Path.of(filePath).getFileName().toString(),
@ -95,8 +95,9 @@ public class ChatToolWindowPanel extends SimpleToolWindowPanel {
return tabbedPane;
}
public void displaySelectedFilesNotification(List<ReferencedFile> referencedFiles) {
public void updateSelectedFilesNotification(List<ReferencedFile> referencedFiles) {
if (referencedFiles.isEmpty()) {
selectedFilesNotification.hideNotification();
return;
}

View file

@ -5,12 +5,10 @@ import static ee.carlrobert.codegpt.ui.UIUtil.createScrollPaneWithSmartScroller;
import static java.lang.String.format;
import com.intellij.openapi.Disposable;
import com.intellij.openapi.actionSystem.ActionPlaces;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.project.Project;
import com.intellij.ui.JBColor;
import com.intellij.util.ui.JBUI;
import com.intellij.util.ui.JBUI.Borders;
import ee.carlrobert.codegpt.CodeGPTKeys;
import ee.carlrobert.codegpt.EncodingManager;
import ee.carlrobert.codegpt.ReferencedFile;
@ -22,19 +20,16 @@ import ee.carlrobert.codegpt.completions.ConversationType;
import ee.carlrobert.codegpt.conversations.Conversation;
import ee.carlrobert.codegpt.conversations.ConversationService;
import ee.carlrobert.codegpt.conversations.message.Message;
import ee.carlrobert.codegpt.settings.GeneralSettings;
import ee.carlrobert.codegpt.settings.service.ServiceType;
import ee.carlrobert.codegpt.telemetry.TelemetryAction;
import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatMessageResponseBody;
import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatToolWindowScrollablePanel;
import ee.carlrobert.codegpt.toolwindow.chat.ui.ResponsePanel;
import ee.carlrobert.codegpt.toolwindow.chat.ui.UserMessagePanel;
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.ModelComboBoxAction;
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.TotalTokensDetails;
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.TotalTokensPanel;
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.UserPromptTextArea;
import ee.carlrobert.codegpt.toolwindow.ui.ChatToolWindowLandingPanel;
import ee.carlrobert.codegpt.ui.OverlayUtil;
import ee.carlrobert.codegpt.ui.textarea.UserInputPanel;
import ee.carlrobert.codegpt.util.EditorUtil;
import ee.carlrobert.codegpt.util.file.FileUtil;
import java.awt.BorderLayout;
@ -44,7 +39,6 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import java.util.function.Consumer;
import javax.swing.JComponent;
import javax.swing.JPanel;
import javax.swing.SwingUtilities;
@ -59,11 +53,13 @@ public class ChatToolWindowTabPanel implements Disposable {
private final Project project;
private final JPanel rootPanel;
private final Conversation conversation;
private final UserPromptTextArea userPromptTextArea;
private final UserInputPanel textArea;
private final ConversationService conversationService;
private final TotalTokensPanel totalTokensPanel;
private final ChatToolWindowScrollablePanel toolWindowScrollablePanel;
private @Nullable CompletionRequestHandler requestHandler;
public ChatToolWindowTabPanel(@NotNull Project project, @NotNull Conversation conversation) {
this.project = project;
this.conversation = conversation;
@ -74,10 +70,9 @@ public class ChatToolWindowTabPanel implements Disposable {
conversation,
EditorUtil.getSelectedEditorSelectedText(project),
this);
userPromptTextArea = new UserPromptTextArea(this::handleSubmit, totalTokensPanel);
textArea = new UserInputPanel(project, this::handleSubmit, this::handleCancel);
textArea.requestFocus();
rootPanel = createRootPanel();
userPromptTextArea.requestFocusInWindow();
userPromptTextArea.requestFocus();
if (conversation.getMessages().isEmpty()) {
displayLandingView();
@ -103,7 +98,7 @@ public class ChatToolWindowTabPanel implements Disposable {
}
public void requestFocusForTextArea() {
userPromptTextArea.focus();
textArea.requestFocus();
}
public void displayLandingView() {
@ -233,24 +228,23 @@ public class ChatToolWindowTabPanel implements Disposable {
return;
}
var requestHandler = new CompletionRequestHandler(
requestHandler = new CompletionRequestHandler(
new ToolWindowCompletionResponseEventListener(
conversationService,
responsePanel,
totalTokensPanel,
userPromptTextArea) {
textArea) {
@Override
public void handleTokensExceededPolicyAccepted() {
call(callParameters, responsePanel);
}
});
userPromptTextArea.setRequestHandler(requestHandler);
userPromptTextArea.setSubmitEnabled(false);
textArea.setSubmitEnabled(false);
requestHandler.call(callParameters);
}
private void handleSubmit(String text) {
private Unit handleSubmit(String text) {
var message = new Message(text);
var editor = EditorUtil.getSelectedEditor(project);
if (editor != null) {
@ -264,37 +258,27 @@ public class ChatToolWindowTabPanel implements Disposable {
}
message.setUserMessage(text);
sendMessage(message, ConversationType.DEFAULT);
return Unit.INSTANCE;
}
private JPanel createUserPromptPanel(ServiceType selectedService) {
private Unit handleCancel() {
if (requestHandler != null) {
requestHandler.cancel();
}
return Unit.INSTANCE;
}
private JPanel createUserPromptPanel() {
var panel = new JPanel(new BorderLayout());
panel.setBorder(JBUI.Borders.compound(
JBUI.Borders.customLine(JBColor.border(), 1, 0, 0, 0),
JBUI.Borders.empty(8)));
var contentManager = project.getService(ChatToolWindowContentManager.class);
panel.add(JBUI.Panels.simplePanel(createUserPromptTextAreaHeader(
project,
selectedService,
(provider) -> {
ConversationService.getInstance().startConversation();
contentManager.createNewTabPanel();
})), BorderLayout.NORTH);
panel.add(JBUI.Panels.simplePanel(userPromptTextArea), BorderLayout.CENTER);
panel.add(JBUI.Panels.simplePanel(totalTokensPanel)
.withBorder(JBUI.Borders.emptyBottom(8)), BorderLayout.NORTH);
panel.add(JBUI.Panels.simplePanel(textArea), BorderLayout.CENTER);
return panel;
}
private JPanel createUserPromptTextAreaHeader(
Project project,
ServiceType selectedService,
Consumer<ServiceType> onModelChange) {
return JBUI.Panels.simplePanel()
.withBorder(Borders.emptyBottom(8))
.andTransparent()
.addToLeft(totalTokensPanel)
.addToRight(new ModelComboBoxAction(project, onModelChange, selectedService)
.createCustomComponent(ActionPlaces.UNKNOWN));
}
private JComponent getLandingView() {
return new ChatToolWindowLandingPanel((action, locationOnScreen) -> {
var editor = EditorUtil.getSelectedEditor(project);
@ -354,8 +338,7 @@ public class ChatToolWindowTabPanel implements Disposable {
gbc.weighty = 0;
gbc.fill = GridBagConstraints.HORIZONTAL;
gbc.gridy = 1;
rootPanel.add(
createUserPromptPanel(GeneralSettings.getSelectedService()), gbc);
rootPanel.add(createUserPromptPanel(), gbc);
return rootPanel;
}
}

View file

@ -14,8 +14,8 @@ import ee.carlrobert.codegpt.telemetry.TelemetryAction;
import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatMessageResponseBody;
import ee.carlrobert.codegpt.toolwindow.chat.ui.ResponsePanel;
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.TotalTokensPanel;
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.UserPromptTextArea;
import ee.carlrobert.codegpt.ui.OverlayUtil;
import ee.carlrobert.codegpt.ui.textarea.UserInputPanel;
import ee.carlrobert.llm.client.openai.completion.ErrorDetails;
import ee.carlrobert.llm.client.you.completion.YouSerpResult;
import java.util.HashMap;
@ -37,7 +37,7 @@ abstract class ToolWindowCompletionResponseEventListener implements
private final ResponsePanel responsePanel;
private final ChatMessageResponseBody responseContainer;
private final TotalTokensPanel totalTokensPanel;
private final UserPromptTextArea userPromptTextArea;
private final UserInputPanel textArea;
private volatile boolean completed;
@ -45,13 +45,13 @@ abstract class ToolWindowCompletionResponseEventListener implements
ConversationService conversationService,
ResponsePanel responsePanel,
TotalTokensPanel totalTokensPanel,
UserPromptTextArea userPromptTextArea) {
UserInputPanel textArea) {
this.encodingManager = EncodingManager.getInstance();
this.conversationService = conversationService;
this.responsePanel = responsePanel;
this.responseContainer = (ChatMessageResponseBody) responsePanel.getContent();
this.totalTokensPanel = totalTokensPanel;
this.userPromptTextArea = userPromptTextArea;
this.textArea = textArea;
}
public abstract void handleTokensExceededPolicyAccepted();
@ -127,7 +127,7 @@ abstract class ToolWindowCompletionResponseEventListener implements
if (containsResults) {
responseContainer.displaySerpResults(serpResults);
}
totalTokensPanel.updateUserPromptTokens(userPromptTextArea.getText());
totalTokensPanel.updateUserPromptTokens(textArea.getText());
totalTokensPanel.updateConversationTokens(callParameters.getConversation());
} finally {
stopStreaming(responseContainer);
@ -142,7 +142,7 @@ abstract class ToolWindowCompletionResponseEventListener implements
private void stopStreaming(ChatMessageResponseBody responseContainer) {
completed = true;
userPromptTextArea.setSubmitEnabled(true);
textArea.setSubmitEnabled(true);
responseContainer.hideCaret();
}
}

View file

@ -37,7 +37,7 @@ public class SelectedFilesAccordion extends JPanel {
private JPanel createContentPanel(Project project, List<String> referencedFilePaths) {
var panel = new JPanel();
panel.setOpaque(false);
panel.setVisible(false);
panel.setVisible(true);
panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
panel.setBorder(JBUI.Borders.empty(4, 0));
referencedFilePaths.stream()

View file

@ -1,231 +0,0 @@
package ee.carlrobert.codegpt.toolwindow.chat.ui.textarea;
import static ee.carlrobert.codegpt.settings.service.ServiceType.ANTHROPIC;
import static ee.carlrobert.codegpt.settings.service.ServiceType.CODEGPT;
import static ee.carlrobert.codegpt.settings.service.ServiceType.OLLAMA;
import static ee.carlrobert.codegpt.settings.service.ServiceType.OPENAI;
import static ee.carlrobert.llm.client.openai.completion.OpenAIChatCompletionModel.GPT_4_O;
import static ee.carlrobert.llm.client.openai.completion.OpenAIChatCompletionModel.GPT_4_VISION_PREVIEW;
import com.intellij.icons.AllIcons;
import com.intellij.openapi.actionSystem.AnAction;
import com.intellij.openapi.actionSystem.AnActionEvent;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.editor.ex.util.EditorUtil;
import com.intellij.openapi.util.registry.Registry;
import com.intellij.ui.DocumentAdapter;
import com.intellij.ui.JBColor;
import com.intellij.ui.components.JBTextArea;
import com.intellij.util.ui.JBUI;
import ee.carlrobert.codegpt.CodeGPTBundle;
import ee.carlrobert.codegpt.Icons;
import ee.carlrobert.codegpt.actions.AttachImageAction;
import ee.carlrobert.codegpt.completions.CompletionRequestHandler;
import ee.carlrobert.codegpt.settings.GeneralSettings;
import ee.carlrobert.codegpt.settings.service.codegpt.CodeGPTServiceSettings;
import ee.carlrobert.codegpt.settings.service.openai.OpenAISettings;
import ee.carlrobert.codegpt.ui.IconActionButton;
import ee.carlrobert.codegpt.ui.UIUtil;
import java.awt.BasicStroke;
import java.awt.BorderLayout;
import java.awt.FlowLayout;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Insets;
import java.awt.RenderingHints;
import java.awt.event.ActionEvent;
import java.awt.event.FocusEvent;
import java.awt.event.FocusListener;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import javax.swing.AbstractAction;
import javax.swing.JPanel;
import javax.swing.UIManager;
import javax.swing.event.DocumentEvent;
import javax.swing.text.BadLocationException;
import org.jetbrains.annotations.NotNull;
public class UserPromptTextArea extends JPanel {
private static final Logger LOG = Logger.getInstance(UserPromptTextArea.class);
private static final JBColor BACKGROUND_COLOR = JBColor.namedColor(
"Editor.SearchField.background", com.intellij.util.ui.UIUtil.getTextFieldBackground());
private final AtomicReference<CompletionRequestHandler> requestHandlerRef =
new AtomicReference<>();
private final JBTextArea textArea;
private final int textAreaRadius = 16;
private final Consumer<String> onSubmit;
private IconActionButton stopButton;
private boolean submitEnabled = true;
public UserPromptTextArea(Consumer<String> onSubmit, TotalTokensPanel totalTokensPanel) {
super(new BorderLayout());
this.onSubmit = onSubmit;
textArea = new JBTextArea();
textArea.getDocument().addDocumentListener(getDocumentAdapter(totalTokensPanel));
textArea.setOpaque(false);
textArea.setBackground(BACKGROUND_COLOR);
textArea.setLineWrap(true);
textArea.setWrapStyleWord(true);
textArea.getEmptyText().setText(CodeGPTBundle.get("toolwindow.chat.textArea.emptyText"));
textArea.setBorder(JBUI.Borders.empty(8, 4));
UIUtil.addShiftEnterInputMap(textArea, new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
try {
handleSubmit();
} finally {
totalTokensPanel.updateUserPromptTokens("");
}
}
});
textArea.addFocusListener(new FocusListener() {
@Override
public void focusGained(FocusEvent e) {
UserPromptTextArea.super.paintBorder(UserPromptTextArea.super.getGraphics());
}
@Override
public void focusLost(FocusEvent e) {
UserPromptTextArea.super.paintBorder(UserPromptTextArea.super.getGraphics());
}
});
updateFont();
init();
}
private DocumentAdapter getDocumentAdapter(TotalTokensPanel totalTokensPanel) {
return new DocumentAdapter() {
@Override
protected void textChanged(@NotNull DocumentEvent event) {
if (submitEnabled) {
try {
var document = event.getDocument();
var text = document.getText(
document.getStartPosition().getOffset(),
document.getEndPosition().getOffset() - 1);
totalTokensPanel.updateUserPromptTokens(text);
} catch (BadLocationException ex) {
LOG.error("Something went wrong while processing user input tokens", ex);
}
}
}
};
}
public String getText() {
return textArea.getText().trim();
}
public void focus() {
textArea.requestFocus();
textArea.requestFocusInWindow();
}
@Override
protected void paintComponent(Graphics g) {
Graphics2D g2 = (Graphics2D) g.create();
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setColor(getBackground());
g2.fillRoundRect(0, 0, getWidth() - 1, getHeight() - 1, textAreaRadius, textAreaRadius);
super.paintComponent(g);
}
@Override
protected void paintBorder(Graphics g) {
Graphics2D g2 = (Graphics2D) g.create();
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setColor(JBUI.CurrentTheme.ActionButton.focusedBorder());
if (textArea.isFocusOwner()) {
g2.setStroke(new BasicStroke(1.5F));
}
g2.drawRoundRect(0, 0, getWidth() - 1, getHeight() - 1, textAreaRadius, textAreaRadius);
}
@Override
public Insets getInsets() {
return JBUI.insets(6, 12, 6, 6);
}
public void setSubmitEnabled(boolean submitEnabled) {
this.submitEnabled = submitEnabled;
stopButton.setEnabled(!submitEnabled);
}
public void setRequestHandler(@NotNull CompletionRequestHandler handler) {
requestHandlerRef.set(handler);
}
private void handleSubmit() {
if (submitEnabled && !textArea.getText().isEmpty()) {
// Replacing each newline with two newlines to ensure proper Markdown formatting
var text = textArea.getText().replace("\n", "\n\n");
onSubmit.accept(text.trim());
textArea.setText("");
}
}
private void init() {
setOpaque(false);
add(textArea, BorderLayout.CENTER);
stopButton = new IconActionButton(
new AnAction("Stop", "Stop current inference", AllIcons.Actions.Suspend) {
@Override
public void actionPerformed(@NotNull AnActionEvent e) {
var handler = requestHandlerRef.get();
if (handler != null) {
handler.cancel();
}
}
});
stopButton.setEnabled(false);
var flowLayout = new FlowLayout(FlowLayout.RIGHT);
flowLayout.setHgap(8);
JPanel iconsPanel = new JPanel(flowLayout);
iconsPanel.add(new IconActionButton(
new AnAction("Send Message", "Send message", Icons.Send) {
@Override
public void actionPerformed(@NotNull AnActionEvent e) {
handleSubmit();
}
}));
if (isImageActionSupported()) {
iconsPanel.add(new IconActionButton(new AttachImageAction()));
}
iconsPanel.add(stopButton);
add(iconsPanel, BorderLayout.EAST);
}
private boolean isImageActionSupported() {
var selectedService = GeneralSettings.getSelectedService();
if (selectedService == ANTHROPIC || selectedService == OLLAMA) {
return true;
}
if (selectedService == CODEGPT) {
var model = ApplicationManager.getApplication().getService(CodeGPTServiceSettings.class)
.getState()
.getChatCompletionSettings()
.getModel();
return List.of("gpt-4o", "claude-3-opus").contains(model);
}
var model = OpenAISettings.getCurrentState().getModel();
return selectedService == OPENAI && (
GPT_4_VISION_PREVIEW.getCode().equals(model) || GPT_4_O.getCode().equals(model));
}
private void updateFont() {
if (Registry.is("ide.find.use.editor.font", false)) {
textArea.setFont(EditorUtil.getEditorFont());
} else {
textArea.setFont(UIManager.getFont("TextField.font"));
}
}
}

View file

@ -1,18 +1,18 @@
package ee.carlrobert.codegpt.actions
import com.intellij.icons.AllIcons
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.fileChooser.FileChooser
import com.intellij.openapi.fileChooser.FileChooserDescriptor
import ee.carlrobert.codegpt.CodeGPTBundle
import ee.carlrobert.codegpt.CodeGPTKeys
import ee.carlrobert.codegpt.Icons
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.AttachImageNotifier
class AttachImageAction : AnAction(
CodeGPTBundle.get("action.attachImage"),
CodeGPTBundle.get("action.attachImageDescription"),
Icons.Upload
AllIcons.FileTypes.Image
) {
override fun actionPerformed(e: AnActionEvent) {

View file

@ -0,0 +1,101 @@
package ee.carlrobert.codegpt.ui.textarea
import com.intellij.openapi.components.service
import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.openapi.editor.colors.EditorFontType
import com.intellij.openapi.editor.ex.util.EditorUtil
import com.intellij.openapi.util.registry.Registry
import com.intellij.ui.JBColor
import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
import ee.carlrobert.codegpt.CodeGPTBundle
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.RenderingHints
import java.awt.event.ActionEvent
import javax.swing.AbstractAction
import javax.swing.JTextPane
import javax.swing.KeyStroke
import javax.swing.UIManager
import javax.swing.text.DefaultStyledDocument
import javax.swing.text.StyleConstants
import javax.swing.text.StyleContext
class CustomTextPane(private val onSubmit: (String) -> Unit) : JTextPane() {
init {
isOpaque = false
background = JBColor.namedColor("Editor.SearchField.background")
document = DefaultStyledDocument()
border = JBUI.Borders.empty(8)
isFocusable = true
font = if (Registry.`is`("ide.find.use.editor.font", false)) {
EditorUtil.getEditorFont()
} else {
UIManager.getFont("TextField.font")
}
inputMap.put(KeyStroke.getKeyStroke("shift ENTER"), "insert-break")
inputMap.put(KeyStroke.getKeyStroke("ENTER"), "text-submit")
actionMap.put("text-submit", object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
onSubmit(text)
}
})
}
fun highlightText(text: String) {
val lastIndex = this.text.lastIndexOf('@')
if (lastIndex != -1) {
val styleContext = StyleContext.getDefaultStyleContext()
val fileNameStyle = styleContext.addStyle("smart-highlighter", null)
val fontFamily = service<EditorColorsManager>().globalScheme
.getFont(EditorFontType.PLAIN)
.deriveFont(JBFont.label().size.toFloat())
.family
StyleConstants.setFontFamily(fileNameStyle, fontFamily)
StyleConstants.setForeground(
fileNameStyle,
JBUI.CurrentTheme.GotItTooltip.codeForeground(true)
)
StyleConstants.setBackground(
fileNameStyle,
JBUI.CurrentTheme.GotItTooltip.codeBackground(true)
)
document.remove(lastIndex + 1, document.length - (lastIndex + 1))
document.insertString(lastIndex + 1, text, fileNameStyle)
styledDocument.setCharacterAttributes(
lastIndex,
text.length,
fileNameStyle,
true
)
document.insertString(
document.length,
" ",
styleContext.getStyle(StyleContext.DEFAULT_STYLE)
)
}
}
override fun paintComponent(g: Graphics) {
super.paintComponent(g)
val g2d = g as Graphics2D
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
if (document.length == 0) {
g2d.color = JBColor.GRAY
g2d.font = if (Registry.`is`("ide.find.use.editor.font", false)) {
EditorUtil.getEditorFont()
} else {
UIManager.getFont("TextField.font")
}
// Draw placeholder
g2d.drawString(
CodeGPTBundle.get("toolwindow.chat.textArea.emptyText"),
insets.left,
g2d.fontMetrics.maxAscent + insets.top
)
}
}
}

View file

@ -0,0 +1,38 @@
package ee.carlrobert.codegpt.ui.textarea
import com.intellij.openapi.components.Service
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile
import ee.carlrobert.codegpt.CodeGPTKeys
import ee.carlrobert.codegpt.ReferencedFile
import ee.carlrobert.codegpt.actions.IncludeFilesInContextNotifier
import ee.carlrobert.codegpt.util.file.FileUtil
import kotlinx.coroutines.*
import java.io.File
@Service
class FileSearchService private constructor(val project: Project) {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
fun searchFiles(searchText: String): List<String> = runBlocking {
withContext(scope.coroutineContext) {
FileUtil.searchProjectFiles(project, searchText).map { it.path }
}
}
fun addFileToSession(file: VirtualFile) {
val filesIncluded =
project.getUserData(CodeGPTKeys.SELECTED_FILES).orEmpty().toMutableList()
filesIncluded.add(ReferencedFile(File(file.path)))
updateFilesInSession(filesIncluded)
}
fun removeFilesFromSession() = updateFilesInSession(mutableListOf())
private fun updateFilesInSession(files: MutableList<ReferencedFile>) {
project.putUserData(CodeGPTKeys.SELECTED_FILES, files)
project.messageBus
.syncPublisher(IncludeFilesInContextNotifier.FILES_INCLUDED_IN_CONTEXT_TOPIC)
.filesIncluded(files)
}
}

View file

@ -0,0 +1,178 @@
package ee.carlrobert.codegpt.ui.textarea
import com.intellij.icons.AllIcons
import com.intellij.openapi.fileTypes.FileTypeManager
import com.intellij.ui.JBColor
import com.intellij.ui.components.JBList
import com.intellij.ui.dsl.builder.AlignX
import com.intellij.ui.dsl.builder.panel
import com.intellij.util.ui.JBUI
import java.awt.Component
import java.awt.Dimension
import java.awt.KeyboardFocusManager
import java.awt.event.KeyAdapter
import java.awt.event.KeyEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.*
class SuggestionList(
listModel: DefaultListModel<SuggestionItem>,
private val onSelected: (SuggestionItem) -> Unit
) : JBList<SuggestionItem>(listModel) {
init {
border = JBUI.Borders.empty()
preferredSize = Dimension(480, (30 * 6))
selectionMode = ListSelectionModel.SINGLE_SELECTION
cellRenderer = SuggestionsListCellRenderer()
KeyboardFocusManager.getCurrentKeyboardFocusManager().addKeyEventDispatcher { e ->
if (e.keyCode == KeyEvent.VK_TAB && e.id == KeyEvent.KEY_PRESSED && isFocusOwner) {
selectNext()
e.consume()
true
} else {
false
}
}
addKeyListener(object : KeyAdapter() {
override fun keyReleased(e: KeyEvent) {
when (e.keyCode) {
KeyEvent.VK_ENTER -> {
onSelected(listModel.get(selectedIndex))
e.consume()
}
}
}
})
addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
val index = locationToIndex(e.point)
if (index >= 0) {
onSelected(listModel.getElementAt(index))
}
}
override fun mouseExited(e: MouseEvent) {
putClientProperty("hoveredIndex", -1)
repaint()
}
})
addMouseMotionListener(object : MouseAdapter() {
override fun mouseMoved(e: MouseEvent) {
val index = locationToIndex(e.point)
if (index != getClientProperty("hoveredIndex")) {
putClientProperty("hoveredIndex", index)
repaint()
}
}
})
}
fun selectNext() {
val newIndex = if (selectedIndex < model.size - 1) selectedIndex + 1 else 0
selectedIndex = newIndex
ensureIndexIsVisible(newIndex)
}
}
private class SuggestionsListCellRenderer : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component = super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus).apply {
setOpaque(false)
}.let { component ->
if (component is JLabel && value is SuggestionItem) {
renderSuggestionItem(component, value, list, index, isSelected, cellHasFocus)
} else {
component
}
}
private fun renderSuggestionItem(
component: JLabel,
value: SuggestionItem,
list: JList<*>?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): JPanel = when (value) {
is SuggestionItem.FileItem -> renderFileItem(component, value)
is SuggestionItem.ActionItem -> renderActionItem(component, value)
}.apply {
setupPanelProperties(list, index, isSelected, cellHasFocus)
}
private fun renderFileItem(component: JLabel, value: SuggestionItem.FileItem): JPanel {
val file = value.file
component.apply {
text = file.name
icon = when {
file.isDirectory -> AllIcons.Nodes.Folder
else -> FileTypeManager.getInstance().getFileTypeByFileName(file.name).icon
}
iconTextGap = 4
}
return panel {
row {
cell(component)
text(truncatePath(480 - component.width - 28, file.path))
.align(AlignX.RIGHT)
.applyToComponent {
font = JBUI.Fonts.smallFont()
foreground = JBColor.gray
}
}
}
}
private fun renderActionItem(component: JLabel, value: SuggestionItem.ActionItem): JPanel {
component.apply {
text = value.action.displayName
icon = value.action.icon
iconTextGap = 4
}
return panel {
row {
cell(component)
}
}
}
private fun JPanel.setupPanelProperties(
list: JList<*>?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
) {
preferredSize = Dimension(preferredSize.width, 30)
border = JBUI.Borders.empty(0, 4, 0, 4)
val isHovered = list?.getClientProperty("hoveredIndex") == index
if (isHovered || isSelected || cellHasFocus) {
background = UIManager.getColor("List.selectionBackground")
foreground = UIManager.getColor("List.selectionForeground")
}
}
private fun truncatePath(maxWidth: Int, fullPath: String): String {
val fontMetrics = getFontMetrics(JBUI.Fonts.smallFont())
if (fontMetrics.stringWidth(fullPath) <= maxWidth) {
return fullPath
}
val ellipsis = "..."
var truncatedPath = fullPath
while (truncatedPath.isNotEmpty() && fontMetrics.stringWidth(ellipsis + truncatedPath) > maxWidth) {
truncatedPath = truncatedPath.substring(1)
}
return ellipsis + truncatedPath
}
}

View file

@ -0,0 +1,95 @@
package ee.carlrobert.codegpt.ui.textarea
import com.intellij.icons.AllIcons
import com.intellij.openapi.application.readAction
import com.intellij.openapi.components.service
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.roots.ProjectFileIndex
import com.intellij.openapi.ui.popup.JBPopup
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.vcsUtil.showAbove
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
import javax.swing.DefaultListModel
import javax.swing.Icon
import javax.swing.JComponent
enum class DefaultAction(val displayName: String, val icon: Icon) {
ATTACH_IMAGE("Attach image", AllIcons.FileTypes.Image),
SEARCH_WEB("Search web", AllIcons.General.Web),
}
sealed class SuggestionItem {
data class FileItem(val file: File) : SuggestionItem()
data class ActionItem(val action: DefaultAction) : SuggestionItem()
}
class SuggestionsPopupManager(
private val project: Project,
private val onSelected: (filePath: String) -> Unit
) {
private var popup: JBPopup? = null
private val listModel = DefaultListModel<SuggestionItem>()
private val list = SuggestionList(listModel) {
if (it is SuggestionItem.FileItem) {
onSelected(it.file.path)
} else if (it is SuggestionItem.ActionItem) {
when (it.action) {
DefaultAction.ATTACH_IMAGE -> {} // todo
DefaultAction.SEARCH_WEB -> {} // todo
}
}
}
fun showPopup(component: JComponent) {
popup = createPopup(component)
popup?.showAbove(component)
val projectFileIndex = project.service<ProjectFileIndex>()
CoroutineScope(Dispatchers.Default).launch {
val openFilePaths = project.service<FileEditorManager>().openFiles
.filter { readAction { projectFileIndex.isInContent(it) } }
.take(6)
.map { it.path }
updateSuggestions(openFilePaths)
}
}
fun hidePopup() {
popup?.cancel()
}
fun isPopupVisible(): Boolean {
return popup?.isVisible ?: false
}
fun updateSuggestions(filePaths: List<String>) {
listModel.clear()
listModel.addAll(filePaths.map { SuggestionItem.FileItem(File(it)) })
}
fun requestFocus() {
list.requestFocus()
}
fun selectNext() {
list.selectNext()
}
private fun createPopup(preferableFocusComponent: JComponent? = null): JBPopup =
service<JBPopupFactory>()
.createComponentPopupBuilder(list, preferableFocusComponent)
.setMovable(true)
.setCancelOnClickOutside(true)
.setCancelOnWindowDeactivation(false)
.setRequestFocus(true)
.setCancelCallback {
listModel.removeAllElements()
true
}
.createPopup()
}

View file

@ -0,0 +1,232 @@
package ee.carlrobert.codegpt.ui.textarea
import com.intellij.icons.AllIcons
import com.intellij.openapi.actionSystem.ActionPlaces
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.service
import com.intellij.openapi.observable.properties.AtomicBooleanProperty
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFileManager
import com.intellij.ui.components.AnActionLink
import com.intellij.ui.dsl.builder.AlignX
import com.intellij.ui.dsl.builder.RightGap
import com.intellij.ui.dsl.builder.panel
import com.intellij.util.ui.JBUI
import ee.carlrobert.codegpt.CodeGPTBundle
import ee.carlrobert.codegpt.Icons
import ee.carlrobert.codegpt.actions.AttachImageAction
import ee.carlrobert.codegpt.conversations.ConversationService
import ee.carlrobert.codegpt.conversations.ConversationsState
import ee.carlrobert.codegpt.settings.GeneralSettings
import ee.carlrobert.codegpt.toolwindow.chat.ChatToolWindowContentManager
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.ModelComboBoxAction
import ee.carlrobert.codegpt.ui.IconActionButton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.awt.*
import java.awt.event.KeyAdapter
import java.awt.event.KeyEvent
import java.nio.file.Paths
import javax.swing.JPanel
import javax.swing.text.StyleContext
import javax.swing.text.StyledDocument
class UserInputPanel(
private val project: Project,
private val onSubmit: (String) -> Unit,
private val onStop: () -> Unit
) : JPanel(BorderLayout()) {
private val suggestionsPopupManager = SuggestionsPopupManager(project) {
handleFileSelection(it)
}
private val textPane = CustomTextPane { handleSubmit() }.apply {
addKeyListener(CustomTextPaneKeyAdapter())
}
private val submitButton = IconActionButton(
object : AnAction(
CodeGPTBundle.get("smartTextPane.submitButton.title"),
CodeGPTBundle.get("smartTextPane.submitButton.description"),
Icons.Send
) {
override fun actionPerformed(e: AnActionEvent) {
handleSubmit()
}
}
)
private val stopButton = IconActionButton(
object : AnAction(
CodeGPTBundle.get("smartTextPane.stopButton.title"),
CodeGPTBundle.get("smartTextPane.stopButton.description"),
AllIcons.Actions.Suspend
) {
override fun actionPerformed(e: AnActionEvent) {
onStop()
}
}
).apply { isEnabled = false }
private val imageActionSupported = AtomicBooleanProperty(isImageActionSupported())
val text: String
get() = textPane.text
init {
isOpaque = false
add(textPane, BorderLayout.CENTER)
add(getFooter(), BorderLayout.SOUTH)
}
private fun getFooter(): JPanel {
val attachImageLink = AnActionLink(CodeGPTBundle.get("shared.image"), AttachImageAction())
.apply {
icon = AllIcons.FileTypes.Image
font = JBUI.Fonts.smallFont()
}
val modelComboBox = ModelComboBoxAction(
project,
{
imageActionSupported.set(isImageActionSupported())
// TODO: Implement a proper session management
if (service<ConversationsState>().state?.currentConversation?.messages?.isNotEmpty() == true) {
service<ConversationService>().startConversation()
project.service<ChatToolWindowContentManager>().createNewTabPanel()
}
},
service<GeneralSettings>().state.selectedService
).createCustomComponent(ActionPlaces.UNKNOWN)
return panel {
twoColumnsRow({
cell(modelComboBox).gap(RightGap.SMALL)
cell(attachImageLink).visibleIf(imageActionSupported)
}, {
panel {
row {
cell(submitButton).gap(RightGap.SMALL)
cell(stopButton)
}
}.align(AlignX.RIGHT)
})
}
}
private fun isImageActionSupported(): Boolean {
return service<GeneralSettings>().state.selectedService.isImageActionSupported
}
fun setSubmitEnabled(enabled: Boolean) {
submitButton.isEnabled = enabled
stopButton.isEnabled = !enabled
}
override fun requestFocus() {
textPane.requestFocus()
textPane.requestFocusInWindow()
}
override fun paintComponent(g: Graphics) {
val g2 = g.create() as Graphics2D
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g2.color = background
g2.fillRoundRect(0, 0, width - 1, height - 1, 16, 16)
super.paintComponent(g)
g2.dispose()
}
override fun paintBorder(g: Graphics) {
val g2 = g.create() as Graphics2D
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g2.color = JBUI.CurrentTheme.ActionButton.focusedBorder()
if (textPane.isFocusOwner) {
g2.stroke = BasicStroke(1.5F)
}
g2.drawRoundRect(0, 0, width - 1, height - 1, 16, 16)
g2.dispose()
}
override fun getInsets(): Insets = JBUI.insets(4)
private fun updateSuggestions() {
CoroutineScope(Dispatchers.Default).launch {
val lastAtIndex = textPane.text.lastIndexOf('@')
if (lastAtIndex != -1) {
val searchText = textPane.text.substring(lastAtIndex + 1)
if (searchText.isNotEmpty()) {
val filePaths = project.service<FileSearchService>().searchFiles(searchText)
suggestionsPopupManager.updateSuggestions(filePaths)
}
} else {
suggestionsPopupManager.hidePopup()
}
}
}
private fun handleSubmit() {
val text = textPane.text.trim()
if (text.isNotEmpty()) {
onSubmit(text)
textPane.text = ""
}
}
private fun handleFileSelection(filePath: String) {
val selectedFile = service<VirtualFileManager>().findFileByNioPath(Paths.get(filePath))
selectedFile?.let { file ->
textPane.highlightText(file.name)
project.service<FileSearchService>().addFileToSession(file)
}
suggestionsPopupManager.hidePopup()
}
inner class CustomTextPaneKeyAdapter : KeyAdapter() {
private val defaultStyle =
StyleContext.getDefaultStyleContext().getStyle(StyleContext.DEFAULT_STYLE)
override fun keyReleased(e: KeyEvent) {
if (text.isEmpty()) {
project.service<FileSearchService>().removeFilesFromSession()
}
// todo
if (!text.contains('@')) {
suggestionsPopupManager.hidePopup()
return
}
when (e.keyCode) {
KeyEvent.VK_UP, KeyEvent.VK_DOWN -> {
suggestionsPopupManager.requestFocus()
suggestionsPopupManager.selectNext()
e.consume()
}
else -> {
if (suggestionsPopupManager.isPopupVisible()) {
updateSuggestions()
}
}
}
}
override fun keyTyped(e: KeyEvent) {
val popupVisible = suggestionsPopupManager.isPopupVisible()
if (e.keyChar == '@' && !popupVisible) {
suggestionsPopupManager.showPopup(textPane)
return
} else if (e.keyChar == '\t') {
suggestionsPopupManager.requestFocus()
suggestionsPopupManager.selectNext()
return
} else if (popupVisible) {
updateSuggestions()
}
val doc = textPane.document as StyledDocument
if (textPane.caretPosition >= 0) {
doc.setCharacterAttributes(textPane.caretPosition, 1, defaultStyle, true)
}
}
}
}

View file

@ -31,9 +31,10 @@ object MarkdownUtil {
}
@JvmStatic
fun convertMdToHtml(message: String?): String {
fun convertMdToHtml(message: String): String {
val options = MutableDataSet()
val document = Parser.builder(options).build().parse(message!!)
options.set(HtmlRenderer.SOFT_BREAK, "<br/>")
val document = Parser.builder(options).build().parse(message)
return HtmlRenderer.builder(options)
.nodeRendererFactory(ResponseNodeRenderer.Factory())
.build()

View file

@ -3,12 +3,17 @@ package ee.carlrobert.codegpt.util.file
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.progress.ProgressIndicator
import com.intellij.openapi.project.Project
import com.intellij.openapi.roots.ProjectFileIndex
import com.intellij.openapi.util.io.FileUtil.createDirectory
import com.intellij.openapi.util.text.StringUtil
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.openapi.vfs.VirtualFileFilter
import ee.carlrobert.codegpt.settings.service.llama.LlamaSettings.getLlamaModelsPath
import java.io.File
import java.io.FileOutputStream
@ -23,191 +28,257 @@ import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardOpenOption
import java.text.DecimalFormat
import java.util.Objects
import java.util.Optional
import java.util.*
import java.util.regex.Pattern
object FileUtil {
private val LOG = Logger.getInstance(FileUtil::class.java)
private val LOG = Logger.getInstance(FileUtil::class.java)
@JvmStatic
fun createFile(directoryPath: Any, fileName: String?, fileContent: String?): File {
requireNotNull(fileContent) { "fileContent null" }
require(!fileName.isNullOrBlank()) { "fileName null or blank" }
val path = when (directoryPath) {
is Path -> directoryPath
is File -> directoryPath.toPath()
is String -> Path.of(directoryPath)
else -> throw IllegalArgumentException("directoryPath must be Path, File or String: $directoryPath")
}
try {
tryCreateDirectory(path)
return Files.writeString(
path.resolve(fileName),
fileContent,
StandardOpenOption.CREATE
).toFile()
} catch (e: IOException) {
throw RuntimeException("Failed to create file", e)
}
}
@JvmStatic
@Throws(IOException::class)
fun copyFileWithProgress(
fileName: String,
url: URL,
bytesRead: LongArray,
fileSize: Long,
indicator: ProgressIndicator
) {
tryCreateDirectory(getLlamaModelsPath())
Channels.newChannel(url.openStream()).use { readableByteChannel ->
FileOutputStream(getLlamaModelsPath().resolve(fileName).toFile()).use { fileOutputStream ->
val buffer = ByteBuffer.allocateDirect(1024 * 10)
while (readableByteChannel.read(buffer) != -1) {
if (indicator.isCanceled) {
readableByteChannel.close()
break
}
buffer.flip()
bytesRead[0] += fileOutputStream.channel.write(buffer).toLong()
buffer.clear()
indicator.fraction = bytesRead[0].toDouble() / fileSize
@JvmStatic
fun createFile(directoryPath: Any, fileName: String?, fileContent: String?): File {
requireNotNull(fileContent) { "fileContent null" }
require(!fileName.isNullOrBlank()) { "fileName null or blank" }
val path = when (directoryPath) {
is Path -> directoryPath
is File -> directoryPath.toPath()
is String -> Path.of(directoryPath)
else -> throw IllegalArgumentException("directoryPath must be Path, File or String: $directoryPath")
}
}
}
}
@JvmStatic
fun getEditorFile(editor: Editor): VirtualFile? {
return FileDocumentManager.getInstance().getFile(editor.document)
}
private fun tryCreateDirectory(directoryPath: Path) {
Files.exists(directoryPath).takeUnless { it } ?: return
try {
createDirectory(directoryPath.toFile())
} catch (e: IOException) {
throw RuntimeException("Failed to create directory", e)
}.takeIf { it } ?: throw RuntimeException("Failed to create directory: $directoryPath")
}
@JvmStatic
fun getFileExtension(filename: String?): String {
val pattern = Pattern.compile("[^.]+$")
val matcher = filename?.let { pattern.matcher(it) }
if (matcher?.find() == true) {
return matcher.group()
}
return ""
}
@JvmStatic
fun findLanguageExtensionMapping(language: String): Map.Entry<String, String> {
val defaultValue = mapOf("Text" to ".txt").entries.first()
val mapper = ObjectMapper()
val extensionToLanguageMappings: List<FileExtensionLanguageDetails>
val languageToExtensionMappings: List<LanguageFileExtensionDetails>
try {
extensionToLanguageMappings = mapper.readValue(
getResourceContent("/fileExtensionLanguageMappings.json"),
object : TypeReference<List<FileExtensionLanguageDetails>>() {
})
languageToExtensionMappings = mapper.readValue(
getResourceContent("/languageFileExtensionMappings.json"),
object : TypeReference<List<LanguageFileExtensionDetails>>() {
})
} catch (e: JsonProcessingException) {
LOG.error("Unable to extract file extension", e)
return defaultValue
}
return findFirstExtension(languageToExtensionMappings, language)
.or {
extensionToLanguageMappings.stream()
.filter { it.extension.equals(language, ignoreCase = true) }
.findFirst()
.flatMap { findFirstExtension(languageToExtensionMappings, it.value) }
}.orElse(defaultValue)
}
fun isUtf8File(filePath: String?): Boolean {
val path = filePath?.let { Paths.get(it) }
try {
Files.newBufferedReader(path).use { reader ->
val c = reader.read()
if (c >= 0) {
reader.transferTo(Writer.nullWriter())
try {
tryCreateDirectory(path)
return Files.writeString(
path.resolve(fileName),
fileContent,
StandardOpenOption.CREATE
).toFile()
} catch (e: IOException) {
throw RuntimeException("Failed to create file", e)
}
return true
}
} catch (e: Exception) {
return false
}
}
@JvmStatic
fun getImageMediaType(fileName: String?): String {
return when (val fileExtension = getFileExtension(fileName)) {
"png" -> "image/png"
"jpg", "jpeg" -> "image/jpeg"
else -> throw IllegalArgumentException("Unsupported image type: $fileExtension")
}
}
@JvmStatic
fun getResourceContent(name: String?): String {
try {
Objects.requireNonNull(name?.let { FileUtil::class.java.getResourceAsStream(it) }).use { stream ->
return String(stream.readAllBytes(), StandardCharsets.UTF_8)
}
} catch (e: IOException) {
throw RuntimeException("Unable to read resource", e)
}
}
@JvmStatic
fun convertFileSize(fileSizeInBytes: Long): String {
val units = arrayOf("B", "KB", "MB", "GB")
var unitIndex = 0
var fileSize = fileSizeInBytes.toDouble()
while (fileSize >= 1024 && unitIndex < units.size - 1) {
fileSize /= 1024.0
unitIndex++
}
return DecimalFormat("#.##").format(fileSize) + " " + units[unitIndex]
}
@JvmStatic
@Throws(IOException::class)
fun copyFileWithProgress(
fileName: String,
url: URL,
bytesRead: LongArray,
fileSize: Long,
indicator: ProgressIndicator
) {
tryCreateDirectory(getLlamaModelsPath())
@JvmStatic
fun convertLongValue(value: Long): String {
if (value >= 1000000) {
return (value / 1000000).toString() + "M"
}
if (value >= 1000) {
return (value / 1000).toString() + "K"
Channels.newChannel(url.openStream()).use { readableByteChannel ->
FileOutputStream(
getLlamaModelsPath().resolve(fileName).toFile()
).use { fileOutputStream ->
val buffer = ByteBuffer.allocateDirect(1024 * 10)
while (readableByteChannel.read(buffer) != -1) {
if (indicator.isCanceled) {
readableByteChannel.close()
break
}
buffer.flip()
bytesRead[0] += fileOutputStream.channel.write(buffer).toLong()
buffer.clear()
indicator.fraction = bytesRead[0].toDouble() / fileSize
}
}
}
}
return value.toString()
}
@JvmStatic
fun getEditorFile(editor: Editor): VirtualFile? {
return FileDocumentManager.getInstance().getFile(editor.document)
}
@JvmStatic
fun findFirstExtension(
languageFileExtensionMappings: List<LanguageFileExtensionDetails>,
language: String
): Optional<Map.Entry<String, String>> {
return languageFileExtensionMappings.stream()
.filter { language.equals(it.name, ignoreCase = true)
&& it.extensions != null
&& it.extensions.stream().anyMatch(String::isNotBlank) }
.findFirst()
.map { java.util.Map.entry(it.name,
it.extensions?.stream()?.filter(String::isNotBlank)?.findFirst()?.orElse("") ?: ""
) }
}
private fun tryCreateDirectory(directoryPath: Path) {
Files.exists(directoryPath).takeUnless { it } ?: return
try {
createDirectory(directoryPath.toFile())
} catch (e: IOException) {
throw RuntimeException("Failed to create directory", e)
}.takeIf { it } ?: throw RuntimeException("Failed to create directory: $directoryPath")
}
@JvmStatic
fun getFileExtension(filename: String?): String {
val pattern = Pattern.compile("[^.]+$")
val matcher = filename?.let { pattern.matcher(it) }
if (matcher?.find() == true) {
return matcher.group()
}
return ""
}
@JvmStatic
fun findLanguageExtensionMapping(language: String): Map.Entry<String, String> {
val defaultValue = mapOf("Text" to ".txt").entries.first()
val mapper = ObjectMapper()
val extensionToLanguageMappings: List<FileExtensionLanguageDetails>
val languageToExtensionMappings: List<LanguageFileExtensionDetails>
try {
extensionToLanguageMappings = mapper.readValue(
getResourceContent("/fileExtensionLanguageMappings.json"),
object : TypeReference<List<FileExtensionLanguageDetails>>() {
})
languageToExtensionMappings = mapper.readValue(
getResourceContent("/languageFileExtensionMappings.json"),
object : TypeReference<List<LanguageFileExtensionDetails>>() {
})
} catch (e: JsonProcessingException) {
LOG.error("Unable to extract file extension", e)
return defaultValue
}
return findFirstExtension(languageToExtensionMappings, language)
.or {
extensionToLanguageMappings.stream()
.filter { it.extension.equals(language, ignoreCase = true) }
.findFirst()
.flatMap { findFirstExtension(languageToExtensionMappings, it.value) }
}.orElse(defaultValue)
}
fun isUtf8File(filePath: String?): Boolean {
val path = filePath?.let { Paths.get(it) }
try {
Files.newBufferedReader(path).use { reader ->
val c = reader.read()
if (c >= 0) {
reader.transferTo(Writer.nullWriter())
}
return true
}
} catch (e: Exception) {
return false
}
}
@JvmStatic
fun getImageMediaType(fileName: String?): String {
return when (val fileExtension = getFileExtension(fileName)) {
"png" -> "image/png"
"jpg", "jpeg" -> "image/jpeg"
else -> throw IllegalArgumentException("Unsupported image type: $fileExtension")
}
}
@JvmStatic
fun getResourceContent(name: String?): String {
try {
Objects.requireNonNull(name?.let { FileUtil::class.java.getResourceAsStream(it) })
.use { stream ->
return String(stream.readAllBytes(), StandardCharsets.UTF_8)
}
} catch (e: IOException) {
throw RuntimeException("Unable to read resource", e)
}
}
@JvmStatic
fun convertFileSize(fileSizeInBytes: Long): String {
val units = arrayOf("B", "KB", "MB", "GB")
var unitIndex = 0
var fileSize = fileSizeInBytes.toDouble()
while (fileSize >= 1024 && unitIndex < units.size - 1) {
fileSize /= 1024.0
unitIndex++
}
return DecimalFormat("#.##").format(fileSize) + " " + units[unitIndex]
}
@JvmStatic
fun convertLongValue(value: Long): String {
if (value >= 1000000) {
return (value / 1000000).toString() + "M"
}
if (value >= 1000) {
return (value / 1000).toString() + "K"
}
return value.toString()
}
@JvmStatic
fun findFirstExtension(
languageFileExtensionMappings: List<LanguageFileExtensionDetails>,
language: String
): Optional<Map.Entry<String, String>> {
return languageFileExtensionMappings.stream()
.filter {
language.equals(it.name, ignoreCase = true)
&& it.extensions != null
&& it.extensions.stream().anyMatch(String::isNotBlank)
}
.findFirst()
.map {
java.util.Map.entry(
it.name,
it.extensions?.stream()?.filter(String::isNotBlank)?.findFirst()?.orElse("")
?: ""
)
}
}
fun searchProjectFiles(
project: Project,
query: String,
maxResults: Int = 6,
): List<VirtualFile> {
val results = mutableListOf<SearchResult>()
val fileIndex = project.service<ProjectFileIndex>()
fileIndex.iterateContent({ file ->
val score = calculateScore(file, query)
if (score > 0) {
results.add(SearchResult(file, score))
}
true
}, object : VirtualFileFilter {
override fun accept(file: VirtualFile): Boolean {
return !file.isDirectory && fileIndex.isInContent(file)
}
override fun toString(): String {
return "NONE"
}
})
return results.sortedByDescending { it.score }
.take(maxResults)
.map { it.file }
}
private fun calculateScore(file: VirtualFile, query: String): Int {
var score = 0
val fileName = file.name
if (fileName.contains(query, ignoreCase = true)) {
score += 10
if (fileName.startsWith(query, ignoreCase = true)) {
score += 5
}
}
if (StringUtil.containsIgnoreCase(fileName, query)) {
score += 3
}
try {
val content = String(file.contentsToByteArray(), Charsets.UTF_8)
if (content.contains(query, ignoreCase = true)) {
score += 2
}
} catch (e: Exception) {
// Ignore
}
return score
}
}
data class SearchResult(val file: VirtualFile, val score: Int)

View file

@ -189,7 +189,7 @@ toolwindow.chat.youProCheckBox.text=Use GPT-4 model
toolwindow.chat.youProCheckBox.enable=Turn on for complex queries
toolwindow.chat.youProCheckBox.disable=Turn off for faster responses
toolwindow.chat.youProCheckBox.notAllowed=Enable by subscribing to YouPro plan
toolwindow.chat.textArea.emptyText=Ask me anything...
toolwindow.chat.textArea.emptyText=Ask anything... Use '@' to include files in the message
service.codegpt.title=CodeGPT
service.openai.title=OpenAI
service.custom.openai.title=Custom OpenAI
@ -227,6 +227,7 @@ action.attachImage=Attach Image
action.attachImageDescription=Attach an image
imageFileChooser.title=Select Image
imageAccordion.title=Attached image
shared.image=Image
shared.chatCompletions=Chat Completions
shared.codeCompletions=Code Completions
codeCompletionsForm.enableFeatureText=Enable code completions
@ -239,4 +240,8 @@ editCodePopover.textField.comment=Provide instructions for the code modification
editCodePopover.submitButton.title=Submit Edit
editCodePopover.acceptButton.title=Accept Suggestion
editCodePopover.followUpButton.title=Submit Follow-up
editCodePopover.cancel.helpText=Esc to cancel
editCodePopover.cancel.helpText=Esc to cancel
smartTextPane.submitButton.title=Send Message
smartTextPane.submitButton.description=Send message
smartTextPane.stopButton.title=Stop
smartTextPane.stopButton.description=Stop completion