mirror of
https://github.com/carlrobertoh/ProxyAI.git
synced 2026-05-09 19:45:16 +00:00
1.0.9 - Add markdown code syntax highlighting and copy functionality, remove plugin required for restart
This commit is contained in:
parent
31dc6ce6aa
commit
d1bd09b89b
13 changed files with 192 additions and 66 deletions
|
|
@ -4,7 +4,7 @@ plugins {
|
|||
}
|
||||
|
||||
group = "ee.carlrobert"
|
||||
version = "1.0.8"
|
||||
version = "1.0.9"
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
|
|
@ -16,6 +16,10 @@ intellij {
|
|||
plugins.set(listOf())
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.fifesoft:rsyntaxtextarea:3.3.2")
|
||||
}
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import java.util.ArrayList;
|
|||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
public final class ApiClient {
|
||||
|
||||
|
|
@ -30,9 +31,9 @@ public final class ApiClient {
|
|||
return instance;
|
||||
}
|
||||
|
||||
public void getCompletionsAsync(String userPrompt, Consumer<String> onMessage) {
|
||||
public void getCompletionsAsync(String userPrompt, Consumer<String> onMessage, @Nullable Consumer<String> onComplete) {
|
||||
var prompt = buildCompletePrompt(userPrompt);
|
||||
this.client.sendAsync(buildHttpRequest(prompt), respInfo -> subscribe(respInfo, userPrompt, onMessage));
|
||||
this.client.sendAsync(buildHttpRequest(prompt), respInfo -> subscribe(respInfo, userPrompt, onMessage, onComplete));
|
||||
}
|
||||
|
||||
public void clearQueries() {
|
||||
|
|
@ -40,7 +41,11 @@ public final class ApiClient {
|
|||
}
|
||||
|
||||
private String buildCompletePrompt(String prompt) {
|
||||
var basePrompt = new StringBuilder("You are ChatGPT, a large language model trained by OpenAI.\n");
|
||||
var basePrompt = new StringBuilder("""
|
||||
You are ChatGPT, a large language model trained by OpenAI.
|
||||
One of your main goals is code generation but not only.
|
||||
Answer in a markdown language. Markdown code blocks should contain language whenever possible.
|
||||
""");
|
||||
queries.forEach(query ->
|
||||
basePrompt.append("User:\n")
|
||||
.append(query.getKey())
|
||||
|
|
@ -81,11 +86,20 @@ public final class ApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
private BodySubscriber<Void> subscribe(ResponseInfo responseInfo, String userPrompt, Consumer<String> onMessage) {
|
||||
private BodySubscriber<Void> subscribe(
|
||||
ResponseInfo responseInfo,
|
||||
String userPrompt,
|
||||
Consumer<String> onMessage,
|
||||
@Nullable Consumer<String> onComplete) {
|
||||
if (responseInfo.statusCode() == 200) {
|
||||
return new Subscriber((messageData ->
|
||||
onMessage.accept(messageData.choices().get(0).text())),
|
||||
(finalMsg) -> queries.add(Map.entry(userPrompt, finalMsg)));
|
||||
(finalMsg) -> {
|
||||
queries.add(Map.entry(userPrompt, finalMsg));
|
||||
if (onComplete != null) {
|
||||
onComplete.accept(finalMsg);
|
||||
}
|
||||
});
|
||||
} else if (responseInfo.statusCode() == 401) {
|
||||
onMessage.accept("Incorrect API key provided.\n" +
|
||||
"You can find your API key at https://platform.openai.com/account/api-keys.");
|
||||
|
|
|
|||
|
|
@ -9,13 +9,27 @@ import java.nio.ByteBuffer;
|
|||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Flow;
|
||||
import java.util.concurrent.Flow.Subscription;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class Subscriber implements HttpResponse.BodySubscriber<Void> {
|
||||
|
||||
protected static final Pattern dataLinePattern = Pattern.compile("^data: ?(.*)$");
|
||||
private static final Pattern dataLinePattern = Pattern.compile("^data: ?(.*)$");
|
||||
private volatile Subscription subscription;
|
||||
private volatile String deferredText;
|
||||
private final Consumer<? super ApiResponse> messageDataConsumer;
|
||||
private final CompletableFuture<Void> future;
|
||||
private final Consumer<String> onComplete;
|
||||
private final StringBuilder msgBuilder = new StringBuilder();
|
||||
|
||||
public Subscriber(Consumer<? super ApiResponse> messageDataConsumer, Consumer<String> onComplete) {
|
||||
this.messageDataConsumer = messageDataConsumer;
|
||||
this.future = new CompletableFuture<>();
|
||||
this.subscription = null;
|
||||
this.deferredText = null;
|
||||
this.onComplete = onComplete;
|
||||
}
|
||||
|
||||
protected static ApiResponse extractMessageData(String[] messageLines) {
|
||||
var responseBuilder = new StringBuilder();
|
||||
|
|
@ -33,23 +47,8 @@ public class Subscriber implements HttpResponse.BodySubscriber<Void> {
|
|||
}
|
||||
}
|
||||
|
||||
protected final Consumer<? super ApiResponse> messageDataConsumer;
|
||||
protected final CompletableFuture<Void> future;
|
||||
protected volatile Flow.Subscription subscription;
|
||||
protected volatile String deferredText;
|
||||
private final Consumer<String> onComplete;
|
||||
private final StringBuilder msgBuilder = new StringBuilder();
|
||||
|
||||
public Subscriber(Consumer<? super ApiResponse> messageDataConsumer, Consumer<String> onComplete) {
|
||||
this.messageDataConsumer = messageDataConsumer;
|
||||
this.future = new CompletableFuture<>();
|
||||
this.subscription = null;
|
||||
this.deferredText = null;
|
||||
this.onComplete = onComplete;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSubscribe(Flow.Subscription subscription) {
|
||||
public void onSubscribe(Subscription subscription) {
|
||||
this.subscription = subscription;
|
||||
try {
|
||||
this.deferredText = "";
|
||||
|
|
@ -66,19 +65,18 @@ public class Subscriber implements HttpResponse.BodySubscriber<Void> {
|
|||
var deferredText = this.deferredText;
|
||||
|
||||
for (var buffer : buffers) {
|
||||
var s = deferredText + UTF_8.decode(buffer);
|
||||
var tokens = s.split("\n\n", -1);
|
||||
var decodedText = deferredText + UTF_8.decode(buffer);
|
||||
var tokens = decodedText.split("\n\n", -1);
|
||||
|
||||
for (var i = 0; i < tokens.length - 1; i++) {
|
||||
var message = tokens[i];
|
||||
var data = extractMessageData(message.split("\n"));
|
||||
var choice = data.choices().get(0);
|
||||
var response = extractMessageData(tokens[i].split("\n"));
|
||||
var choice = response.choices().get(0);
|
||||
if ("stop".equals(choice.finishReason())) {
|
||||
onComplete();
|
||||
} else {
|
||||
msgBuilder.append(choice.text());
|
||||
}
|
||||
this.messageDataConsumer.accept(data);
|
||||
this.messageDataConsumer.accept(response);
|
||||
}
|
||||
deferredText = tokens[tokens.length - 1];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,13 +18,13 @@ public class AskAction extends AnAction {
|
|||
public void actionPerformed(@NotNull AnActionEvent event) {
|
||||
var project = event.getProject();
|
||||
if (project != null) {
|
||||
ApiClient.getInstance().clearQueries();
|
||||
var toolWindowService = ApplicationManager.getApplication().getService(ToolWindowService.class);
|
||||
var toolWindow = toolWindowService.getToolWindow(project);
|
||||
toolWindow.show();
|
||||
toolWindow.setTitle("");
|
||||
toolWindowService.removeAll();
|
||||
toolWindowService.paintLandingView();
|
||||
var toolWindowService = ApplicationManager.getApplication().getService(ToolWindowService.class);
|
||||
var toolWindow = toolWindowService.getToolWindow(project);
|
||||
ApiClient.getInstance().clearQueries();
|
||||
toolWindow.show();
|
||||
toolWindow.setTitle("");
|
||||
toolWindowService.removeAll();
|
||||
toolWindowService.paintLandingView();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@ public abstract class BaseAction extends AnAction {
|
|||
var project = event.getProject();
|
||||
var editor = event.getData(PlatformDataKeys.EDITOR);
|
||||
if (editor != null && project != null) {
|
||||
ApiClient.getInstance().clearQueries();
|
||||
var toolWindowService = ApplicationManager.getApplication().getService(ToolWindowService.class);
|
||||
var selectedText = editor.getSelectionModel().getSelectedText();
|
||||
ApiClient.getInstance().clearQueries();
|
||||
initToolWindow(toolWindowService.getToolWindow(project));
|
||||
toolWindowService.removeAll();
|
||||
toolWindowService.paintUserMessage(selectedText);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import static ee.carlrobert.chatgpt.ide.toolwindow.ToolWindowUtil.createIconLabe
|
|||
import static ee.carlrobert.chatgpt.ide.toolwindow.ToolWindowUtil.createTextArea;
|
||||
import static ee.carlrobert.chatgpt.ide.toolwindow.ToolWindowUtil.justifyLeft;
|
||||
|
||||
import com.intellij.ide.ui.LafManager;
|
||||
import com.intellij.ide.ui.LafManagerListener;
|
||||
import com.intellij.openapi.options.ShowSettingsUtil;
|
||||
import com.intellij.openapi.project.Project;
|
||||
import com.intellij.openapi.roots.ui.componentsList.components.ScrollablePanel;
|
||||
|
|
@ -13,12 +15,15 @@ import ee.carlrobert.chatgpt.EmptyCallback;
|
|||
import ee.carlrobert.chatgpt.client.ApiClient;
|
||||
import ee.carlrobert.chatgpt.ide.settings.SettingsConfigurable;
|
||||
import ee.carlrobert.chatgpt.ide.settings.SettingsState;
|
||||
import ee.carlrobert.chatgpt.ide.toolwindow.components.SyntaxTextArea;
|
||||
import icons.Icons;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.GridBagLayout;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import javax.annotation.Nullable;
|
||||
import javax.swing.Box;
|
||||
import javax.swing.ImageIcon;
|
||||
|
|
@ -27,10 +32,11 @@ import javax.swing.JPanel;
|
|||
import javax.swing.SwingConstants;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
public class ToolWindowService {
|
||||
public class ToolWindowService implements LafManagerListener {
|
||||
|
||||
private ScrollablePanel scrollablePanel;
|
||||
private boolean isLandingViewVisible;
|
||||
private static final List<SyntaxTextArea> textAreas = new ArrayList<>();
|
||||
|
||||
public void setScrollablePanel(ScrollablePanel scrollablePanel) {
|
||||
this.scrollablePanel = scrollablePanel;
|
||||
|
|
@ -65,15 +71,23 @@ public class ToolWindowService {
|
|||
});
|
||||
scrollablePanel.add(justifyLeft(label));
|
||||
} else {
|
||||
var textArea = createTextArea("", false);
|
||||
var textArea = new SyntaxTextArea();
|
||||
scrollablePanel.add(textArea);
|
||||
textAreas.add(textArea);
|
||||
|
||||
ApiClient.getInstance().getCompletionsAsync(prompt, (message) -> {
|
||||
textArea.append(message);
|
||||
if (scrollToBottom != null) {
|
||||
scrollToBottom.call();
|
||||
}
|
||||
});
|
||||
var messageCounter = new AtomicInteger(0);
|
||||
ApiClient.getInstance().getCompletionsAsync(
|
||||
prompt,
|
||||
(message) -> {
|
||||
if (messageCounter.getAndIncrement() == 0) {
|
||||
message = message.replace("\n", "");
|
||||
}
|
||||
textArea.append(message);
|
||||
if (scrollToBottom != null) {
|
||||
scrollToBottom.call();
|
||||
}
|
||||
},
|
||||
(finalMessage) -> textArea.displayCopyButton());
|
||||
}
|
||||
|
||||
addSpacing(16);
|
||||
|
|
@ -111,11 +125,18 @@ public class ToolWindowService {
|
|||
scrollablePanel.removeAll();
|
||||
}
|
||||
|
||||
public void addSpacing(int height) {
|
||||
private void addSpacing(int height) {
|
||||
scrollablePanel.add(Box.createVerticalStrut(height));
|
||||
}
|
||||
|
||||
public void addIconLabel(ImageIcon imageIcon, String text) {
|
||||
private void addIconLabel(ImageIcon imageIcon, String text) {
|
||||
scrollablePanel.add(justifyLeft(createIconLabel(imageIcon, text)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void lookAndFeelChanged(@NotNull LafManager source) {
|
||||
for (var textArea : textAreas) {
|
||||
textArea.changeStyleViaThemeXml();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@ package ee.carlrobert.chatgpt.ide.toolwindow;
|
|||
|
||||
import com.intellij.ui.JBColor;
|
||||
import java.awt.Component;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Font;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.Box;
|
||||
import javax.swing.ImageIcon;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JTextArea;
|
||||
|
||||
|
|
@ -31,6 +34,14 @@ public class ToolWindowUtil {
|
|||
return iconLabel;
|
||||
}
|
||||
|
||||
public static JButton createIconButton(ImageIcon imageIcon) {
|
||||
var button = new JButton(imageIcon);
|
||||
button.setBorder(BorderFactory.createEmptyBorder());
|
||||
button.setContentAreaFilled(false);
|
||||
button.setPreferredSize(new Dimension(imageIcon.getIconWidth(), imageIcon.getIconHeight()));
|
||||
return button;
|
||||
}
|
||||
|
||||
public static Box justifyLeft(Component component) {
|
||||
Box box = Box.createHorizontalBox();
|
||||
box.add(component);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
package ee.carlrobert.chatgpt.ide.toolwindow.components;
|
||||
|
||||
import static ee.carlrobert.chatgpt.ide.toolwindow.ToolWindowUtil.createIconButton;
|
||||
|
||||
import com.intellij.util.ui.JBUI;
|
||||
import com.intellij.util.ui.UIUtil;
|
||||
import icons.Icons;
|
||||
import java.awt.Toolkit;
|
||||
import java.awt.datatransfer.Clipboard;
|
||||
import java.awt.datatransfer.StringSelection;
|
||||
import java.io.IOException;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import javax.swing.JButton;
|
||||
import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea;
|
||||
import org.fife.ui.rsyntaxtextarea.SyntaxConstants;
|
||||
import org.fife.ui.rsyntaxtextarea.Theme;
|
||||
|
||||
public class SyntaxTextArea extends RSyntaxTextArea {
|
||||
|
||||
public SyntaxTextArea() {
|
||||
super("");
|
||||
setStyles();
|
||||
}
|
||||
|
||||
public Matcher getMarkdownMatcher() {
|
||||
return Pattern.compile("`{3}([\\w]*)\\n([\\S\\s]+?)\\n`{3}").matcher(getText());
|
||||
}
|
||||
|
||||
public void displayCopyButton() {
|
||||
if (getMarkdownMatcher().matches()) {
|
||||
ComponentBorder cb = new ComponentBorder(createCopyButton());
|
||||
cb.setAlignment(TOP_ALIGNMENT);
|
||||
cb.install(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void setStyles() {
|
||||
setMargin(JBUI.insets(5));
|
||||
setAntiAliasingEnabled(true);
|
||||
setEnabled(false);
|
||||
setPaintTabLines(false);
|
||||
setHighlightCurrentLine(false);
|
||||
setLineWrap(true);
|
||||
setWrapStyleWord(true);
|
||||
setSyntaxEditingStyle(SyntaxConstants.SYNTAX_STYLE_MARKDOWN);
|
||||
changeStyleViaThemeXml();
|
||||
}
|
||||
|
||||
private void copyToClipboard() {
|
||||
var text = getText();
|
||||
var matcher = getMarkdownMatcher();
|
||||
if (matcher.find()) {
|
||||
text = matcher.group(2);
|
||||
}
|
||||
|
||||
StringSelection stringSelection = new StringSelection(text);
|
||||
Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
|
||||
clipboard.setContents(stringSelection, null);
|
||||
}
|
||||
|
||||
public void changeStyleViaThemeXml() {
|
||||
var baseThemePath = "/org/fife/ui/rsyntaxtextarea/themes/";
|
||||
try {
|
||||
Theme theme = Theme.load(getClass().getResourceAsStream(
|
||||
UIUtil.isUnderDarcula() ? baseThemePath + "dark.xml" : baseThemePath + "idea.xml"));
|
||||
theme.apply(this);
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
private JButton createCopyButton() {
|
||||
var button = createIconButton(Icons.CopyImageIcon);
|
||||
button.addActionListener(e -> {
|
||||
copyToClipboard();
|
||||
button.setIcon(Icons.DoubleTickImageIcon);
|
||||
});
|
||||
return button;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,16 @@
|
|||
package ee.carlrobert.chatgpt.ide.toolwindow.components;
|
||||
|
||||
import static ee.carlrobert.chatgpt.ide.toolwindow.ToolWindowUtil.createIconButton;
|
||||
|
||||
import com.intellij.ui.JBColor;
|
||||
import com.intellij.util.ui.JBUI;
|
||||
import ee.carlrobert.chatgpt.EmptyCallback;
|
||||
import icons.Icons;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.event.ActionEvent;
|
||||
import java.awt.event.ActionListener;
|
||||
import java.awt.event.FocusEvent;
|
||||
import java.awt.event.FocusListener;
|
||||
import javax.swing.AbstractAction;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JTextArea;
|
||||
|
|
@ -19,6 +20,7 @@ public class TextArea extends JTextArea {
|
|||
|
||||
public TextArea(EmptyCallback onSubmit, JScrollPane textAreaScrollPane) {
|
||||
setForeground(JBColor.GRAY);
|
||||
setMargin(JBUI.insets(5));
|
||||
addFocusListener(getFocusListener());
|
||||
addSubmitButton(onSubmit, textAreaScrollPane);
|
||||
|
||||
|
|
@ -44,11 +46,7 @@ public class TextArea extends JTextArea {
|
|||
}
|
||||
|
||||
private JButton createSubmitButton(ActionListener submitButtonListener) {
|
||||
var sendIcon = Icons.SendImageIcon;
|
||||
var button = new JButton(sendIcon);
|
||||
button.setBorder(BorderFactory.createEmptyBorder());
|
||||
button.setContentAreaFilled(false);
|
||||
button.setPreferredSize(new Dimension(sendIcon.getIconWidth(), sendIcon.getIconHeight()));
|
||||
var button = createIconButton(Icons.SendImageIcon);
|
||||
button.addActionListener(submitButtonListener);
|
||||
return button;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ public class Icons {
|
|||
public static final ImageIcon SendImageIcon = getImageIcon("/icons/send-icon.png");
|
||||
public static final ImageIcon SunImageIcon = getImageIcon("/icons/sun-icon.png");
|
||||
public static final ImageIcon UserImageIcon = getImageIcon("/icons/user-icon.png");
|
||||
public static final ImageIcon CopyImageIcon = getImageIcon("/icons/copy-icon.png");
|
||||
public static final ImageIcon DoubleTickImageIcon = getImageIcon("/icons/double-tick-icon.png");
|
||||
|
||||
private static ImageIcon getImageIcon(String path) {
|
||||
return new ImageIcon(Objects.requireNonNull(Icons.class.getResource(path)));
|
||||
|
|
|
|||
|
|
@ -1,17 +1,7 @@
|
|||
<!-- Plugin Configuration File. Read more: https://plugins.jetbrains.com/docs/intellij/plugin-configuration-file.html -->
|
||||
<idea-plugin require-restart="true">
|
||||
<idea-plugin>
|
||||
<id>ee.carlrobert.chatgpt</id>
|
||||
|
||||
<!-- Public plugin name should be written in Title Case.
|
||||
Guidelines: https://plugins.jetbrains.com/docs/marketplace/plugin-overview-page.html#plugin-name -->
|
||||
<name>OpenAI - ChatGPT</name>
|
||||
|
||||
<!-- A displayed Vendor name or Organization ID displayed on the Plugins Page. -->
|
||||
<vendor email="carlrobertoh@gmail.com" url="https://carlrobert.ee">Carl-Robert Linnupuu</vendor>
|
||||
|
||||
<!-- Description of the plugin displayed on the Plugin Page and IDE Plugin Manager.
|
||||
Simple HTML elements (text formatting, paragraphs, and lists) can be added inside of <![CDATA[ ]]> tag.
|
||||
Guidelines: https://plugins.jetbrains.com/docs/marketplace/plugin-overview-page.html#plugin-description -->
|
||||
<description><![CDATA[
|
||||
<p><b>ChatGPT as your copilot to level up your developer experience.</b></p>
|
||||
<p>This is the perfect assistant for any programmer who wants to improve their coding skills and make more efficient use of the time.</p>
|
||||
|
|
@ -31,6 +21,7 @@
|
|||
<change-notes>
|
||||
<![CDATA[
|
||||
<ul>
|
||||
<li><b>1.0.9</b> Add code syntax highlighting and copy functionality, remove plugin required for restart</li>
|
||||
<li><b>1.0.8</b> Migrate to Java 17, fix ToolWindow NPE</li>
|
||||
<li><b>1.0.7</b> Code refactoring, add readme</li>
|
||||
<li><b>1.0.6</b> Display proper message for insufficient quota</li>
|
||||
|
|
@ -46,6 +37,12 @@
|
|||
|
||||
<depends>com.intellij.modules.platform</depends>
|
||||
|
||||
<applicationListeners>
|
||||
<listener
|
||||
class="ee.carlrobert.chatgpt.ide.toolwindow.ToolWindowService"
|
||||
topic="com.intellij.ide.ui.LafManagerListener"/>
|
||||
</applicationListeners>
|
||||
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
<postStartupActivity implementation="ee.carlrobert.chatgpt.ide.notification.ShowNotificationActivity"/>
|
||||
|
||||
|
|
|
|||
BIN
src/main/resources/icons/copy-icon.png
Normal file
BIN
src/main/resources/icons/copy-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 276 B |
BIN
src/main/resources/icons/double-tick-icon.png
Normal file
BIN
src/main/resources/icons/double-tick-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 371 B |
Loading…
Add table
Add a link
Reference in a new issue