From c1b464e0d291921eb9308f7d9c02f2fba8887d1f Mon Sep 17 00:00:00 2001 From: Zhiming Ma Date: Sun, 13 Oct 2024 19:03:34 +0800 Subject: [PATCH 1/5] feat(eclipse): add right click menu actions to send message to chat panel (#3266) * feat(eclipse): update chat panel to compatible with server 0.18.0. * feat(eclipse): add right click menu actions to send message to chat panel. * chore(eclipse): update comments. --- clients/eclipse/feature/feature.xml | 4 +- clients/eclipse/plugin/META-INF/MANIFEST.MF | 2 +- clients/eclipse/plugin/chat-panel/index.html | 34 +- clients/eclipse/plugin/plugin.xml | 127 +++- .../tabby4eclipse/DebouncedRunnable.java | 30 + .../tabbyml/tabby4eclipse/StringUtils.java | 40 ++ .../com/tabbyml/tabby4eclipse/Version.java | 77 +++ .../tabbyml/tabby4eclipse/chat/ChatView.java | 540 +++++++----------- .../tabby4eclipse/chat/ChatViewUtils.java | 246 ++++++++ .../commands/chat/AddFileToChat.java | 30 + .../commands/chat/AddSelectionToChat.java | 31 + .../tabby4eclipse/commands/chat/Explain.java | 31 + .../tabby4eclipse/commands/chat/Fix.java | 31 + .../commands/chat/GenerateDocs.java | 31 + .../commands/chat/GenerateTests.java | 31 + .../commands/{ => chat}/OpenChatView.java | 8 +- .../commands/chat/ToggleChatView.java | 65 +++ .../tabby4eclipse/editor/EditorUtils.java | 18 + .../DebouncedDocumentEventTrigger.java | 25 +- .../statusbar/StatusbarContribution.java | 4 +- 20 files changed, 1017 insertions(+), 388 deletions(-) create mode 100644 clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/DebouncedRunnable.java create mode 100644 clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/StringUtils.java create mode 100644 clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/Version.java create mode 100644 clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/chat/ChatViewUtils.java create mode 100644 clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/AddFileToChat.java create mode 100644 clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/AddSelectionToChat.java create mode 100644 clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/Explain.java create mode 100644 clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/Fix.java create mode 100644 clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/GenerateDocs.java create mode 100644 clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/GenerateTests.java rename clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/{ => chat}/OpenChatView.java (60%) create mode 100644 clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/ToggleChatView.java diff --git a/clients/eclipse/feature/feature.xml b/clients/eclipse/feature/feature.xml index fa61eaaaae22..bc946e5802f4 100644 --- a/clients/eclipse/feature/feature.xml +++ b/clients/eclipse/feature/feature.xml @@ -2,7 +2,7 @@ @@ -19,6 +19,6 @@ + version="0.0.2.24"/> diff --git a/clients/eclipse/plugin/META-INF/MANIFEST.MF b/clients/eclipse/plugin/META-INF/MANIFEST.MF index db8271415637..35026e30e7bd 100644 --- a/clients/eclipse/plugin/META-INF/MANIFEST.MF +++ b/clients/eclipse/plugin/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: Tabby Plugin for Eclipse Bundle-SymbolicName: com.tabbyml.tabby4eclipse;singleton:=true -Bundle-Version: 0.0.2.21 +Bundle-Version: 0.0.2.24 Bundle-Activator: com.tabbyml.tabby4eclipse.Activator Bundle-Vendor: com.tabbyml Require-Bundle: org.eclipse.ui, diff --git a/clients/eclipse/plugin/chat-panel/index.html b/clients/eclipse/plugin/chat-panel/index.html index 045d400c7420..101d71d66066 100644 --- a/clients/eclipse/plugin/chat-panel/index.html +++ b/clients/eclipse/plugin/chat-panel/index.html @@ -33,8 +33,8 @@ color: hsl(var(--primary)); } - #message div h4 { - margin: 16px 0; + #message div { + margin: 16px; } #message div p { @@ -85,27 +85,6 @@

Welcome to Tabby Chat

chat.src = url; } - getChatPanel().addEventListener('load', function () { - setTimeout(() => { - // handleChatPanelLoaded is a function injected by the client - handleChatPanelLoaded(); - - const chat = getChatPanel(); - // override iframe style - const theme = document.documentElement.className; - const css = document.documentElement.style.cssText; - chat.contentWindow.postMessage({ style: css, themeClass: theme, }, new URL(chat.src).origin); - // alternatively: - // const chatDocument = chat.contentWindow.document; - // chatDocument.documentElement.className = theme; - // chatDocument.documentElement.style.cssText = css; - // chatDocument.body.style.cssText = css; - - // handleChatPanelStyleApplied is a function injected by the client - setTimeout(handleChatPanelStyleApplied, 100); - }, 300); - }); - function applyStyle(style) { const { theme, css } = JSON.parse(style); document.documentElement.className = theme; @@ -132,6 +111,15 @@

Welcome to Tabby Chat

); } + window.addEventListener("focus", (event) => { + const chat = getChatPanel(); + if (chat.style.cssText == "display: block;") { + setTimeout(() => { + chat.contentWindow.focus(); + }, 1); + } + }); + window.addEventListener("message", (event) => { const chat = getChatPanel(); diff --git a/clients/eclipse/plugin/plugin.xml b/clients/eclipse/plugin/plugin.xml index 9628c08b00a5..43b88aaef881 100644 --- a/clients/eclipse/plugin/plugin.xml +++ b/clients/eclipse/plugin/plugin.xml @@ -56,6 +56,43 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -65,12 +102,13 @@ + name="Open Settings" + icon="image/settings.png" + id="com.tabbyml.tabby4eclipse.commands.openPreferences"> + name="Code Completion"> + + + + + + + + + + + + + + + + + + + class="com.tabbyml.tabby4eclipse.commands.OpenPreferences" + commandId="com.tabbyml.tabby4eclipse.commands.openPreferences"> + + + + + + + + + + + + + + + + diff --git a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/DebouncedRunnable.java b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/DebouncedRunnable.java new file mode 100644 index 000000000000..4855972b4a2b --- /dev/null +++ b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/DebouncedRunnable.java @@ -0,0 +1,30 @@ +package com.tabbyml.tabby4eclipse; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +public class DebouncedRunnable { + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + private ScheduledFuture future; + private final long delay; + private final Runnable task; + + public DebouncedRunnable(Runnable task, long delay) { + this.task = task; + this.delay = delay; + } + + public synchronized void call() { + if (future != null && !future.isDone()) { + future.cancel(true); + } + future = scheduler.schedule(task, delay, TimeUnit.MILLISECONDS); + } + + // FIXME: scheduler shutdown not called + public void shutdown() { + scheduler.shutdown(); + } +} \ No newline at end of file diff --git a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/StringUtils.java b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/StringUtils.java new file mode 100644 index 000000000000..da84c758c476 --- /dev/null +++ b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/StringUtils.java @@ -0,0 +1,40 @@ +package com.tabbyml.tabby4eclipse; + +import org.eclipse.swt.graphics.RGB; + +public class StringUtils { + + public static String escapeCharacters(String jsonString) { + return jsonString.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r") + .replace("\t", "\\t"); + } + + public static String toHsl(RGB rgb) { + double r = rgb.red / 255.0; + double g = rgb.green / 255.0; + double b = rgb.blue / 255.0; + double max = Math.max(r, Math.max(g, b)); + double min = Math.min(r, Math.min(g, b)); + double l = (max + min) / 2.0; + double h, s; + if (max == min) { + h = 0; + s = 0; + } else { + double delta = max - min; + s = l > 0.5 ? delta / (2.0 - max - min) : delta / (max + min); + if (max == r) { + h = (g - b) / delta + (g < b ? 6 : 0); + } else if (max == g) { + h = (b - r) / delta + 2; + } else { + h = (r - g) / delta + 4; + } + h /= 6; + } + h *= 360; + s *= 100; + l *= 100; + return String.format("%.0f, %.0f%%, %.0f%%", h, s, l); + } +} diff --git a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/Version.java b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/Version.java new file mode 100644 index 000000000000..58cba2420a50 --- /dev/null +++ b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/Version.java @@ -0,0 +1,77 @@ +package com.tabbyml.tabby4eclipse; + +public class Version { + private int major; + private int minor; + private int patch; + + public Version(String versionStr) { + int startIndex = 0; + while (startIndex < versionStr.length() && !Character.isDigit(versionStr.charAt(startIndex))) { + startIndex++; + } + if (startIndex >= versionStr.length()) { + return; + } + int endIndex = versionStr.indexOf("-"); + String numPart = (endIndex != -1) ? versionStr.substring(startIndex, endIndex) + : versionStr.substring(startIndex); + + String[] parts = numPart.split("\\."); + if (parts.length > 0) { + this.major = parseInt(parts[0]); + } + if (parts.length > 1) { + this.minor = parseInt(parts[1]); + } + if (parts.length > 2) { + this.patch = parseInt(parts[2]); + } + } + + public int getMajor() { + return major; + } + + public int getMinor() { + return minor; + } + + public int getPatch() { + return patch; + } + + public boolean isGreaterOrEqualThan(Version other) { + if (this.major > other.major) { + return true; + } else if (this.major < other.major) { + return false; + } else { + if (this.minor > other.minor) { + return true; + } else if (this.minor < other.minor) { + return false; + } else { + return this.patch >= other.patch; + } + } + } + + public boolean isEqual(Version other, boolean ignorePatch) { + if (this.major != other.major || this.minor != other.minor) { + return false; + } + if (ignorePatch) { + return true; + } + return this.patch == other.patch; + } + + private int parseInt(String str) { + try { + return Integer.parseInt(str); + } catch (NumberFormatException e) { + return 0; + } + } +} \ No newline at end of file diff --git a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/chat/ChatView.java b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/chat/ChatView.java index d7ce961f270e..6103c83edffe 100644 --- a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/chat/ChatView.java +++ b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/chat/ChatView.java @@ -1,23 +1,16 @@ package com.tabbyml.tabby4eclipse.chat; -import java.net.URI; import java.net.URL; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; -import org.eclipse.core.resources.IFile; -import org.eclipse.core.resources.IProject; -import org.eclipse.core.resources.ResourcesPlugin; import org.eclipse.core.runtime.FileLocator; import org.eclipse.core.runtime.Path; import org.eclipse.core.runtime.Platform; import org.eclipse.jface.resource.ColorRegistry; import org.eclipse.jface.resource.FontRegistry; -import org.eclipse.jface.text.IDocument; -import org.eclipse.jface.text.ITextSelection; -import org.eclipse.jface.viewers.ISelection; import org.eclipse.swt.SWT; import org.eclipse.swt.browser.Browser; import org.eclipse.swt.browser.BrowserFunction; @@ -28,15 +21,8 @@ import org.eclipse.swt.graphics.RGB; import org.eclipse.swt.layout.FillLayout; import org.eclipse.swt.widgets.Composite; -import org.eclipse.ui.IEditorPart; -import org.eclipse.ui.IWorkbenchPage; -import org.eclipse.ui.IWorkbenchWindow; -import org.eclipse.ui.PartInitException; import org.eclipse.ui.PlatformUI; -import org.eclipse.ui.ide.IDE; -import org.eclipse.ui.ide.ResourceUtil; import org.eclipse.ui.part.ViewPart; -import org.eclipse.ui.texteditor.ITextEditor; import org.eclipse.ui.themes.ITheme; import org.osgi.framework.Bundle; @@ -44,39 +30,19 @@ import com.google.gson.reflect.TypeToken; import com.tabbyml.tabby4eclipse.Activator; import com.tabbyml.tabby4eclipse.Logger; +import com.tabbyml.tabby4eclipse.StringUtils; import com.tabbyml.tabby4eclipse.Utils; import com.tabbyml.tabby4eclipse.chat.ChatMessage.FileContext; -import com.tabbyml.tabby4eclipse.editor.EditorUtils; -import com.tabbyml.tabby4eclipse.git.GitProvider; import com.tabbyml.tabby4eclipse.lsp.LanguageServerService; import com.tabbyml.tabby4eclipse.lsp.ServerConfigHolder; import com.tabbyml.tabby4eclipse.lsp.StatusInfoHolder; import com.tabbyml.tabby4eclipse.lsp.protocol.Config; -import com.tabbyml.tabby4eclipse.lsp.protocol.GitRepository; -import com.tabbyml.tabby4eclipse.lsp.protocol.GitRepositoryParams; import com.tabbyml.tabby4eclipse.lsp.protocol.ILanguageServer; import com.tabbyml.tabby4eclipse.lsp.protocol.IStatusService; import com.tabbyml.tabby4eclipse.lsp.protocol.StatusInfo; import com.tabbyml.tabby4eclipse.lsp.protocol.StatusRequestParams; public class ChatView extends ViewPart { - private static final String MIN_SERVER_VERSION = "0.16.0"; - private static final String ID = "com.tabbyml.tabby4eclipse.views.chat"; - - public static void openChatView() { - IWorkbenchWindow workbenchWindow = PlatformUI.getWorkbench().getActiveWorkbenchWindow(); - if (workbenchWindow != null) { - IWorkbenchPage page = workbenchWindow.getActivePage(); - if (page != null) { - try { - page.showView(ID); - } catch (PartInitException e) { - e.printStackTrace(); - } - } - } - } - private Logger logger = new Logger("ChatView"); private Gson gson = new Gson(); @@ -87,6 +53,8 @@ public static void openChatView() { private List browserFunctions = new ArrayList<>(); private boolean isHtmlLoaded = false; + private boolean isChatPanelLoaded = false; + private List pendingScripts = new ArrayList<>(); private Config.ServerConfig currentConfig; private boolean isDark; @@ -103,7 +71,7 @@ public void createPartControl(Composite parent) { setupThemeStyle(); parent.setLayout(new FillLayout()); - browser = new Browser(parent, Utils.isWindows() ? SWT.EDGE : SWT.DEFAULT); + browser = new Browser(parent, Utils.isWindows() ? SWT.EDGE : SWT.WEBKIT); browser.setBackground(new Color(bgActiveColor)); browser.setVisible(false); @@ -122,26 +90,11 @@ public Object function(Object[] arguments) { } }); - browserFunctions.add(new BrowserFunction(browser, "handleChatPanelLoaded") { - @Override - public Object function(Object[] arguments) { - handleChatPanelLoaded(); - return null; - } - }); - - browserFunctions.add(new BrowserFunction(browser, "handleChatPanelStyleApplied") { - @Override - public Object function(Object[] arguments) { - handleChatPanelStyleApplied(); - return null; - } - }); - browserFunctions.add(new BrowserFunction(browser, "handleChatPanelRequest") { @Override public Object function(Object[] arguments) { if (arguments.length > 0) { + logger.info("HandleChatPanelRequest: " + arguments[0]); Request request = gson.fromJson(arguments[0].toString(), Request.class); handleChatPanelRequest(request); } @@ -160,7 +113,7 @@ public Object function(Object[] arguments) { @Override public void setFocus() { - browser.setFocus(); + browser.forceFocus(); } @Override @@ -175,6 +128,109 @@ public void dispose() { super.dispose(); } + public void explainSelectedText() { + sendRequestToChatPanel(new Request("sendMessage", new ArrayList<>() { + { + ChatMessage chatMessage = new ChatMessage(); + chatMessage.setMessage(ChatViewUtils.PROMPT_EXPLAIN); + chatMessage.setSelectContext(ChatViewUtils.getSelectedTextAsFileContext()); + add(chatMessage); + } + })); + } + + public void fixSelectedText() { + // FIXME(@icycodes): collect the diagnostic message provided by IDE or LSP + sendRequestToChatPanel(new Request("sendMessage", new ArrayList<>() { + { + ChatMessage chatMessage = new ChatMessage(); + chatMessage.setMessage(ChatViewUtils.PROMPT_FIX); + chatMessage.setSelectContext(ChatViewUtils.getSelectedTextAsFileContext()); + add(chatMessage); + } + })); + } + + public void generateDocsForSelectedText() { + sendRequestToChatPanel(new Request("sendMessage", new ArrayList<>() { + { + ChatMessage chatMessage = new ChatMessage(); + chatMessage.setMessage(ChatViewUtils.PROMPT_GENERATE_DOCS); + chatMessage.setSelectContext(ChatViewUtils.getSelectedTextAsFileContext()); + add(chatMessage); + } + })); + } + + public void generateTestsForSelectedText() { + sendRequestToChatPanel(new Request("sendMessage", new ArrayList<>() { + { + ChatMessage chatMessage = new ChatMessage(); + chatMessage.setMessage(ChatViewUtils.PROMPT_GENERATE_TESTS); + chatMessage.setSelectContext(ChatViewUtils.getSelectedTextAsFileContext()); + add(chatMessage); + } + })); + } + + public void addSelectedTextAsContext() { + sendRequestToChatPanel(new Request("addRelevantContext", new ArrayList<>() { + { + add(ChatViewUtils.getSelectedTextAsFileContext()); + } + })); + } + + public void addActiveEditorAsContext() { + sendRequestToChatPanel(new Request("addRelevantContext", new ArrayList<>() { + { + add(ChatViewUtils.getActiveEditorAsFileContext()); + } + })); + } + + private void setupThemeStyle() { + ITheme currentTheme = PlatformUI.getWorkbench().getThemeManager().getCurrentTheme(); + ColorRegistry colorRegistry = currentTheme.getColorRegistry(); + bgColor = colorRegistry.getRGB("org.eclipse.ui.workbench.ACTIVE_TAB_BG_START"); + bgActiveColor = colorRegistry.getRGB("org.eclipse.ui.workbench.ACTIVE_TAB_BG_END"); + fgColor = colorRegistry.getRGB("org.eclipse.ui.workbench.ACTIVE_TAB_TEXT_COLOR"); + borderColor = colorRegistry.getRGB("org.eclipse.ui.workbench.ACTIVE_TAB_INNER_KEYLINE_COLOR"); + primaryColor = colorRegistry.getRGB("org.eclipse.ui.workbench.LINK_COLOR"); + isDark = (bgColor.red + bgColor.green + bgColor.blue) / 3 < 128; + + FontRegistry fontRegistry = currentTheme.getFontRegistry(); + FontData[] fontData = fontRegistry.getFontData("org.eclipse.jface.textfont"); + if (fontData.length > 0) { + font = fontData[0].getName(); + fontSize = fontData[0].getHeight(); + } + } + + private String buildCss() { + String css = ""; + if (bgActiveColor != null) { + css += String.format("background-color: hsl(%s);", StringUtils.toHsl(bgActiveColor)); + } + if (bgColor != null) { + css += String.format("--background: %s;", StringUtils.toHsl(bgColor)); + } + if (fgColor != null) { + css += String.format("--foreground: %s;", StringUtils.toHsl(fgColor)); + } + if (borderColor != null) { + css += String.format("--border: %s;", StringUtils.toHsl(borderColor)); + } + if (primaryColor != null) { + css += String.format("--primary: %s;", StringUtils.toHsl(primaryColor)); + } + if (font != null) { + css += String.format("font: %s;", font); + } + css += String.format("font-size: %spt;", fontSize); + return css; + } + private void load() { try { // Find chat panel html file @@ -196,6 +252,7 @@ private void load() { private void handleLoaded() { isHtmlLoaded = true; + isChatPanelLoaded = false; applyStyle(); reloadContent(false); } @@ -222,25 +279,25 @@ private void reloadContent(boolean force) { private void reloadContentForStatus(String status, boolean force) { if (status.equals(StatusInfo.Status.DISCONNECTED)) { - showMessage("Cannot connect to Tabby server, please check your settings."); - showChatPanel(false); + updateContentToMessage("Cannot connect to Tabby server, please check your settings."); + } else if (status.equals(StatusInfo.Status.CONNECTING)) { + updateContentToMessage("Connecting to Tabby server..."); } else if (status.equals(StatusInfo.Status.UNAUTHORIZED)) { - showMessage("Authorization required, please set your token in settings."); - showChatPanel(false); + updateContentToMessage("Authorization required, please set your token in settings."); } else { Map serverHealth = statusInfoHolder.getStatusInfo().getServerHealth(); - String error = checkServerHealth(serverHealth); + String error = ChatViewUtils.checkServerHealth(serverHealth); if (error != null) { - showMessage(error); - showChatPanel(false); + updateContentToMessage(error); } else { // Load main Config.ServerConfig config = serverConfigHolder.getConfig().getServer(); - if (config != null - && (force || currentConfig == null || currentConfig.getEndpoint() != config.getEndpoint() - || currentConfig.getToken() != config.getToken())) { - showMessage("Connecting to Tabby server..."); - showChatPanel(false); + if (config == null) { + updateContentToMessage("Initializing..."); + } else if (force || currentConfig == null || currentConfig.getEndpoint() != config.getEndpoint() + || currentConfig.getToken() != config.getToken()) { + updateContentToMessage("Loading chat panel..."); + isChatPanelLoaded = false; currentConfig = config; loadChatPanel(); } @@ -248,105 +305,31 @@ private void reloadContentForStatus(String status, boolean force) { } } - private void showMessage(String message) { - browser.getDisplay().asyncExec(() -> { - if (message != null) { - browser.execute(String.format("showMessage('%s')", message)); - } else { - browser.execute("showMessage(undefined)"); - } - }); - } - - private void showChatPanel(boolean visiable) { - browser.getDisplay().asyncExec(() -> { - browser.execute(String.format("showChatPanel(%s)", visiable ? "true" : "false")); - }); - } - - private void loadChatPanel() { - // FIXME(@icycodes): set query string to vscode for now to turn on callbacks - String chatUrl = String.format("%s/chat?client=vscode", currentConfig.getEndpoint()); - browser.getDisplay().asyncExec(() -> { - browser.execute(String.format("loadChatPanel('%s')", chatUrl)); - }); + private void updateContentToMessage(String message) { + showMessage(message); + showChatPanel(false); } - private String checkServerHealth(Map serverHealth) { - if (serverHealth == null) { - return "Connecting to Tabby server..."; - } - - if (serverHealth.get("webserver") == null || serverHealth.get("chat_model") == null) { - return "You need to launch the server with the chat model enabled; for example, use `--chat-model Qwen2-1.5B-Instruct`."; - } - - if (serverHealth.containsKey("version")) { - String version = null; - Object versionObj = serverHealth.get("version"); - if (versionObj instanceof String versionStr) { - version = versionStr; - } else if (versionObj instanceof Map versionMap) { - if (versionMap.containsKey("git_describe") - && versionMap.get("git_describe") instanceof String versionStr) { - version = versionStr; - } - } - if (version != null && !isVersionCompatible(version)) { - return String.format( - "Tabby Chat requires Tabby server version %s or later. Your server is running version %s.", - MIN_SERVER_VERSION, version); - } - } - return null; + private void updateContentToChatPanel() { + showMessage(null); + showChatPanel(true); } - private boolean isVersionCompatible(String version) { - String versionStr = version; - if (versionStr != null && versionStr.length() > 0 && versionStr.charAt(0) == 'v') { - versionStr = versionStr.substring(1); - } - String[] versionParts = versionStr.trim().split("\\."); - String[] minVersionParts = MIN_SERVER_VERSION.split("\\."); - - for (int i = 0; i < Math.max(versionParts.length, minVersionParts.length); i++) { - int versionPart = i < versionParts.length ? parseInt(versionParts[i]) : 0; - int minVersionPart = i < minVersionParts.length ? parseInt(minVersionParts[i]) : 0; - - if (versionPart < minVersionPart) { - return false; - } else if (versionPart > minVersionPart) { - return true; - } + private void showMessage(String message) { + if (message != null) { + executeScript(String.format("showMessage('%s')", message)); + } else { + executeScript("showMessage(undefined)"); } - - return true; } - private int parseInt(String str) { - try { - return Integer.parseInt(str); - } catch (NumberFormatException e) { - return 0; - } + private void showChatPanel(boolean visible) { + executeScript(String.format("showChatPanel(%s)", visible ? "true" : "false")); } - private void setupThemeStyle() { - ITheme currentTheme = PlatformUI.getWorkbench().getThemeManager().getCurrentTheme(); - ColorRegistry colorRegistry = currentTheme.getColorRegistry(); - bgColor = colorRegistry.getRGB("org.eclipse.ui.workbench.ACTIVE_TAB_BG_START"); - bgActiveColor = colorRegistry.getRGB("org.eclipse.ui.workbench.ACTIVE_TAB_BG_END"); - fgColor = colorRegistry.getRGB("org.eclipse.ui.workbench.ACTIVE_TAB_TEXT_COLOR"); - borderColor = colorRegistry.getRGB("org.eclipse.ui.workbench.ACTIVE_TAB_INNER_KEYLINE_COLOR"); - primaryColor = colorRegistry.getRGB("org.eclipse.ui.workbench.LINK_COLOR"); - isDark = (bgColor.red + bgColor.green + bgColor.blue) / 3 < 128; - - FontRegistry fontRegistry = currentTheme.getFontRegistry(); - FontData[] fontData = fontRegistry.getFontData("org.eclipse.jface.textfont"); - if (fontData.length > 0) { - font = fontData[0].getName(); - fontSize = fontData[0].getHeight(); - } + private void loadChatPanel() { + String chatUrl = String.format("%s/chat?client=eclipse", currentConfig.getEndpoint()); + executeScript(String.format("loadChatPanel('%s')", chatUrl)); } private void applyStyle() { @@ -358,98 +341,26 @@ private void applyStyle() { put("css", css); } }); - browser.getDisplay().asyncExec(() -> { - browser.execute(String.format("applyStyle('%s')", json)); - browser.setVisible(true); - }); - } - - private String buildCss() { - String css = ""; - if (bgActiveColor != null) { - css += String.format("background-color: hsl(%s);", toHsl(bgActiveColor)); - } - if (bgColor != null) { - css += String.format("--background: %s;", toHsl(bgColor)); - } - if (fgColor != null) { - css += String.format("--foreground: %s;", toHsl(fgColor)); - } - if (borderColor != null) { - css += String.format("--border: %s;", toHsl(borderColor)); - } - if (primaryColor != null) { - css += String.format("--primary: %s;", toHsl(primaryColor)); - } - if (font != null) { - css += String.format("font: %s;", font); - } - css += String.format("font-size: %spt;", fontSize); - return css; + executeScript(String.format("applyStyle('%s')", json)); + browser.setVisible(true); } - private static String toHsl(RGB rgb) { - double r = rgb.red / 255.0; - double g = rgb.green / 255.0; - double b = rgb.blue / 255.0; - double max = Math.max(r, Math.max(g, b)); - double min = Math.min(r, Math.min(g, b)); - double l = (max + min) / 2.0; - double h, s; - if (max == min) { - h = 0; - s = 0; + private void sendRequestToChatPanel(Request request) { + String json = gson.toJson(request); + String script = String.format("sendRequestToChatPanel('%s')", StringUtils.escapeCharacters(json)); + if (isChatPanelLoaded) { + executeScript(script); } else { - double delta = max - min; - s = l > 0.5 ? delta / (2.0 - max - min) : delta / (max + min); - if (max == r) { - h = (g - b) / delta + (g < b ? 6 : 0); - } else if (max == g) { - h = (b - r) / delta + 2; - } else { - h = (r - g) / delta + 4; - } - h /= 6; + pendingScripts.add(script); } - h *= 360; - s *= 100; - l *= 100; - return String.format("%.0f, %.0f%%, %.0f%%", h, s, l); } - private void handleChatPanelLoaded() { - sendRequestToChatPanel(new Request("init", new ArrayList<>() { - { - add(new HashMap<>() { - { - put("fetcherOptions", new HashMap<>() { - { - put("authorization", currentConfig.getToken()); - } - }); - } - }); - } - })); - } - - private void handleChatPanelStyleApplied() { - showMessage(null); - showChatPanel(true); - } - - private void sendRequestToChatPanel(Request request) { - String json = gson.toJson(request); + private void executeScript(String script) { browser.getDisplay().asyncExec(() -> { - browser.execute(String.format("sendRequestToChatPanel('%s')", escapeCharacters(json))); + browser.execute(script); }); } - public static String escapeCharacters(String jsonString) { - return jsonString.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r") - .replace("\t", "\\t"); - } - private void handleChatPanelRequest(Request request) { switch (request.getMethod()) { case "navigate": { @@ -458,7 +369,11 @@ private void handleChatPanelRequest(Request request) { return; } FileContext context = gson.fromJson(gson.toJson(params.get(0)), FileContext.class); - navigateToFileContext(context); + ChatViewUtils.navigateToFileContext(context); + break; + } + case "refresh": { + reloadContent(true); break; } case "onSubmitMessage": { @@ -467,19 +382,16 @@ private void handleChatPanelRequest(Request request) { return; } String message = (String) params.get(0); - List releventContexts = params.size() > 1 - ? releventContexts = gson.fromJson(gson.toJson(params.get(1)), new TypeToken>() { + List relevantContexts = params.size() > 1 + ? relevantContexts = gson.fromJson(gson.toJson(params.get(1)), new TypeToken>() { }.getType()) : null; sendRequestToChatPanel(new Request("sendMessage", new ArrayList<>() { { ChatMessage chatMessage = new ChatMessage(); chatMessage.setMessage(message); - if (releventContexts != null && !releventContexts.isEmpty()) { - chatMessage.setRelevantContext(releventContexts); - } else { - chatMessage.setActiveContext(getActiveContext()); - } + chatMessage.setRelevantContext(relevantContexts); + chatMessage.setActiveContext(ChatViewUtils.getSelectedTextAsFileContext()); add(chatMessage); } })); @@ -491,100 +403,72 @@ private void handleChatPanelRequest(Request request) { return; } String content = (String) params.get(0); - applyContentInEditor(content); + ChatViewUtils.applyContentInEditor(content); break; } - } - } - - private FileContext getActiveContext() { - ITextEditor activeTextEditor = EditorUtils.getActiveTextEditor(); - if (activeTextEditor == null) { - return null; - } - FileContext context = new FileContext(); - - IFile file = ResourceUtil.getFile(activeTextEditor.getEditorInput()); - URI fileUri = file.getLocationURI(); - if (file != null) { - GitRepository gitInfo = GitProvider.getInstance() - .getRepository(new GitRepositoryParams(fileUri.toString())); - IProject project = file.getProject(); - if (gitInfo != null) { - try { - context.setGitUrl(gitInfo.getRemoteUrl()); - String relativePath = new URI(gitInfo.getRoot()).relativize(fileUri).getPath(); - context.setFilePath(relativePath); - } catch (Exception e) { - logger.error("Failed to get git info.", e); + case "onLoaded": { + List params = request.getParams(); + if (params.size() < 1) { + return; + } + Map onLoadedParams = (Map) params.get(0); + String apiVersion = (String) onLoadedParams.getOrDefault("apiVersion", ""); + if (!apiVersion.isBlank()) { + String error = ChatViewUtils.checkChatPanelApiVersion(apiVersion); + if (error != null) { + updateContentToMessage(error); + return; } - } else if (project != null) { - URI projectRoot = project.getLocationURI(); - String relativePath = projectRoot.relativize(fileUri).getPath(); - context.setFilePath(relativePath); - } else { - context.setFilePath(fileUri.toString()); } + initChatPanel(); + break; } - - ISelection selection = activeTextEditor.getSelectionProvider().getSelection(); - if (selection instanceof ITextSelection textSelection) { - if (textSelection.isEmpty() || textSelection.getText().isBlank()) { - IDocument document = activeTextEditor.getDocumentProvider() - .getDocument(activeTextEditor.getEditorInput()); - context.setRange(new FileContext.LineRange(1, document.getNumberOfLines())); - context.setContent(document.get()); - } else { - context.setRange( - new FileContext.LineRange(textSelection.getStartLine() + 1, textSelection.getEndLine() + 1)); - context.setContent(textSelection.getText()); + case "onCopy": { + List params = request.getParams(); + if (params.size() < 1) { + return; } + String content = (String) params.get(0); + ChatViewUtils.setClipboardContent(content); + break; } - return context; - } - - private void navigateToFileContext(FileContext context) { - logger.info("Navigate to file: " + context.getFilePath() + ", line: " + context.getRange().getStart()); - // FIXME(@icycode): the base path could be a git repository root, but it cannot - // be determined here - IFile file = null; - ITextEditor activeTextEditor = EditorUtils.getActiveTextEditor(); - if (activeTextEditor != null) { - // try find file in the project of the active editor - IFile activeFile = ResourceUtil.getFile(activeTextEditor.getEditorInput()); - if (activeFile != null) { - file = activeFile.getProject().getFile(new Path(context.getFilePath())); - } - } else { - // try find file in the workspace - file = ResourcesPlugin.getWorkspace().getRoot().getFileForLocation(new Path(context.getFilePath())); + case "onKeyboardEvent": { + // FIXME: For macOS and windows, the eclipse keyboard shortcuts are not + // available when browser is focused, + // we should handle keyboard events here. + break; } - try { - if (file != null && file.exists()) { - IEditorPart editorPart = IDE.openEditor(EditorUtils.getActiveWorkbenchPage(), file); - if (editorPart instanceof ITextEditor textEditor) { - IDocument document = textEditor.getDocumentProvider().getDocument(textEditor.getEditorInput()); - int offset = document.getLineOffset(context.getRange().getStart() - 1); - textEditor.selectAndReveal(offset, 0); - } - } - } catch (Exception e) { - logger.error("Failed to navigate to file: " + context.getFilePath(), e); } } - private void applyContentInEditor(String content) { - logger.info("Apply content to the active text editor."); - ITextEditor activeTextEditor = EditorUtils.getActiveTextEditor(); - if (activeTextEditor != null) { - try { - IDocument document = activeTextEditor.getDocumentProvider() - .getDocument(activeTextEditor.getEditorInput()); - ITextSelection selection = (ITextSelection) activeTextEditor.getSelectionProvider().getSelection(); - document.replace(selection.getOffset(), selection.getLength(), content); - } catch (Exception e) { - logger.error("Failed to apply content to the active text editor.", e); + private void initChatPanel() { + isChatPanelLoaded = true; + sendRequestToChatPanel(new Request("init", new ArrayList<>() { + { + add(new HashMap<>() { + { + put("fetcherOptions", new HashMap<>() { + { + put("authorization", currentConfig.getToken()); + } + }); + } + }); } - } + })); + sendRequestToChatPanel(new Request("updateTheme", new ArrayList<>() { + { + add(buildCss()); + add(isDark ? "dark" : "light"); + } + })); + browser.getDisplay().timerExec(100, () -> { + updateContentToChatPanel(); + pendingScripts.forEach((script) -> { + executeScript(script); + }); + pendingScripts.clear(); + }); } + } diff --git a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/chat/ChatViewUtils.java b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/chat/ChatViewUtils.java new file mode 100644 index 000000000000..1d628192b3c2 --- /dev/null +++ b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/chat/ChatViewUtils.java @@ -0,0 +1,246 @@ +package com.tabbyml.tabby4eclipse.chat; + +import java.net.URI; +import java.util.Map; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.Path; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.ITextSelection; +import org.eclipse.jface.viewers.ISelection; +import org.eclipse.swt.dnd.Clipboard; +import org.eclipse.swt.dnd.TextTransfer; +import org.eclipse.swt.dnd.Transfer; +import org.eclipse.swt.widgets.Display; +import org.eclipse.ui.IEditorPart; +import org.eclipse.ui.IWorkbenchPage; +import org.eclipse.ui.PartInitException; +import org.eclipse.ui.ide.IDE; +import org.eclipse.ui.ide.ResourceUtil; +import org.eclipse.ui.texteditor.ITextEditor; + +import com.tabbyml.tabby4eclipse.Logger; +import com.tabbyml.tabby4eclipse.Version; +import com.tabbyml.tabby4eclipse.chat.ChatMessage.FileContext; +import com.tabbyml.tabby4eclipse.editor.EditorUtils; +import com.tabbyml.tabby4eclipse.git.GitProvider; +import com.tabbyml.tabby4eclipse.lsp.protocol.GitRepository; +import com.tabbyml.tabby4eclipse.lsp.protocol.GitRepositoryParams; + +public class ChatViewUtils { + private static final String ID = "com.tabbyml.tabby4eclipse.views.chat"; + + private static final String MIN_SERVER_VERSION = "0.18.0"; + private static final String CHAT_PANEL_API_VERSION = "0.2.0"; + private static Logger logger = new Logger("ChatView"); + + public static final String PROMPT_EXPLAIN = "Explain the selected code:"; + public static final String PROMPT_FIX = "Identify and fix potential bugs in the selected code:"; + public static final String PROMPT_GENERATE_DOCS = "Generate documentation for the selected code:"; + public static final String PROMPT_GENERATE_TESTS = "Generate a unit test for the selected code:"; + + public static ChatView openChatView() { + IWorkbenchPage page = EditorUtils.getActiveWorkbenchPage(); + if (page != null) { + try { + page.showView(ID); + return (ChatView) page.findView(ID); + } catch (PartInitException e) { + logger.error("Failed to open chat view.", e); + } + } + return null; + } + + public static ChatView findOpenedView() { + IWorkbenchPage page = EditorUtils.getActiveWorkbenchPage(); + if (page != null && page.findView(ID) instanceof ChatView chatView) { + return chatView; + } + return null; + } + + public static String checkServerHealth(Map serverHealth) { + if (serverHealth == null) { + return "Connecting to Tabby server..."; + } + + if (serverHealth.get("webserver") == null || serverHealth.get("chat_model") == null) { + return "You need to launch the server with the chat model enabled; for example, use `--chat-model Qwen2-1.5B-Instruct`."; + } + + if (serverHealth.containsKey("version")) { + String version = null; + Object versionObj = serverHealth.get("version"); + if (versionObj instanceof String versionStr) { + version = versionStr; + } else if (versionObj instanceof Map versionMap) { + if (versionMap.containsKey("git_describe") + && versionMap.get("git_describe") instanceof String versionStr) { + version = versionStr; + } + } + if (version != null) { + Version parsedVersion = new Version(version); + Version requiredVersion = new Version(MIN_SERVER_VERSION); + if (!parsedVersion.isGreaterOrEqualThan(requiredVersion)) { + return String.format( + "Tabby Chat requires Tabby server version %s or later. Your server is running version %s.", + MIN_SERVER_VERSION, version); + } + } + } + return null; + } + + public static String checkChatPanelApiVersion(String version) { + Version parsedVersion = new Version(version); + Version requiredVersion = new Version(CHAT_PANEL_API_VERSION); + if (!parsedVersion.isEqual(requiredVersion, true)) { + return "Please update your Tabby server and Tabby plugin for Eclipse to the latest version to use chat panel."; + } + return null; + } + + public static FileContext getSelectedTextAsFileContext() { + ITextEditor activeTextEditor = EditorUtils.getActiveTextEditor(); + if (activeTextEditor == null) { + return null; + } + FileContext context = new FileContext(); + ISelection selection = activeTextEditor.getSelectionProvider().getSelection(); + if (selection instanceof ITextSelection textSelection) { + if (!textSelection.isEmpty()) { + String content = textSelection.getText(); + if (!content.isBlank()) { + context.setContent(content); + context.setRange(new FileContext.LineRange(textSelection.getStartLine() + 1, + textSelection.getEndLine() + 1)); + } + } + } + if (context.getContent() == null) { + return null; + } + + IFile file = ResourceUtil.getFile(activeTextEditor.getEditorInput()); + URI fileUri = file.getLocationURI(); + if (file != null) { + GitRepository gitInfo = GitProvider.getInstance() + .getRepository(new GitRepositoryParams(fileUri.toString())); + IProject project = file.getProject(); + if (gitInfo != null) { + try { + context.setGitUrl(gitInfo.getRemoteUrl()); + String relativePath = new URI(gitInfo.getRoot()).relativize(fileUri).getPath(); + context.setFilePath(relativePath); + } catch (Exception e) { + logger.error("Failed to get git info.", e); + } + } else if (project != null) { + URI projectRoot = project.getLocationURI(); + String relativePath = projectRoot.relativize(fileUri).getPath(); + context.setFilePath(relativePath); + } else { + context.setFilePath(fileUri.toString()); + } + } + return context; + } + + public static FileContext getActiveEditorAsFileContext() { + ITextEditor activeTextEditor = EditorUtils.getActiveTextEditor(); + if (activeTextEditor == null) { + return null; + } + FileContext context = new FileContext(); + + IDocument document = EditorUtils.getDocument(activeTextEditor); + context.setRange(new FileContext.LineRange(1, document.getNumberOfLines())); + context.setContent(document.get()); + + IFile file = ResourceUtil.getFile(activeTextEditor.getEditorInput()); + URI fileUri = file.getLocationURI(); + if (file != null) { + GitRepository gitInfo = GitProvider.getInstance() + .getRepository(new GitRepositoryParams(fileUri.toString())); + IProject project = file.getProject(); + if (gitInfo != null) { + try { + context.setGitUrl(gitInfo.getRemoteUrl()); + String relativePath = new URI(gitInfo.getRoot()).relativize(fileUri).getPath(); + context.setFilePath(relativePath); + } catch (Exception e) { + logger.error("Failed to get git info.", e); + } + } else if (project != null) { + URI projectRoot = project.getLocationURI(); + String relativePath = projectRoot.relativize(fileUri).getPath(); + context.setFilePath(relativePath); + } else { + context.setFilePath(fileUri.toString()); + } + } + + return context; + } + + public static void navigateToFileContext(FileContext context) { + logger.info("Navigate to file: " + context.getFilePath() + ", line: " + context.getRange().getStart()); + // FIXME(@icycode): the base path could be a git repository root, but it cannot + // be determined here + IFile file = null; + ITextEditor activeTextEditor = EditorUtils.getActiveTextEditor(); + if (activeTextEditor != null) { + // try find file in the project of the active editor + IFile activeFile = ResourceUtil.getFile(activeTextEditor.getEditorInput()); + if (activeFile != null) { + file = activeFile.getProject().getFile(new Path(context.getFilePath())); + } + } else { + // try find file in the workspace + file = ResourcesPlugin.getWorkspace().getRoot().getFileForLocation(new Path(context.getFilePath())); + } + try { + if (file != null && file.exists()) { + IEditorPart editorPart = IDE.openEditor(EditorUtils.getActiveWorkbenchPage(), file); + if (editorPart instanceof ITextEditor textEditor) { + IDocument document = textEditor.getDocumentProvider().getDocument(textEditor.getEditorInput()); + int offset = document.getLineOffset(context.getRange().getStart() - 1); + textEditor.selectAndReveal(offset, 0); + } + } + } catch (Exception e) { + logger.error("Failed to navigate to file: " + context.getFilePath(), e); + } + } + + public static void setClipboardContent(String content) { + Display display = Display.getCurrent(); + if (display == null) { + display = Display.getDefault(); + } + + Clipboard clipboard = new Clipboard(display); + TextTransfer textTransfer = TextTransfer.getInstance(); + clipboard.setContents(new Object[] { content }, new Transfer[] { textTransfer }); + clipboard.dispose(); + } + + public static void applyContentInEditor(String content) { + logger.info("Apply content to the active text editor."); + ITextEditor activeTextEditor = EditorUtils.getActiveTextEditor(); + if (activeTextEditor != null) { + try { + IDocument document = activeTextEditor.getDocumentProvider() + .getDocument(activeTextEditor.getEditorInput()); + ITextSelection selection = (ITextSelection) activeTextEditor.getSelectionProvider().getSelection(); + document.replace(selection.getOffset(), selection.getLength(), content); + } catch (Exception e) { + logger.error("Failed to apply content to the active text editor.", e); + } + } + } +} diff --git a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/AddFileToChat.java b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/AddFileToChat.java new file mode 100644 index 000000000000..d045ef670942 --- /dev/null +++ b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/AddFileToChat.java @@ -0,0 +1,30 @@ +package com.tabbyml.tabby4eclipse.commands.chat; + +import org.eclipse.core.commands.AbstractHandler; +import org.eclipse.core.commands.ExecutionEvent; +import org.eclipse.core.commands.ExecutionException; + +import com.tabbyml.tabby4eclipse.Logger; +import com.tabbyml.tabby4eclipse.chat.ChatView; +import com.tabbyml.tabby4eclipse.chat.ChatViewUtils; +import com.tabbyml.tabby4eclipse.editor.EditorUtils; + +public class AddFileToChat extends AbstractHandler { + private Logger logger = new Logger("Commands.Chat.AddFileToChat"); + + @Override + public Object execute(ExecutionEvent event) throws ExecutionException { + logger.debug("Open chat view and send message: AddFileToChat"); + ChatView chatView = ChatViewUtils.openChatView(); + if (chatView != null) { + chatView.addActiveEditorAsContext(); + } + return null; + } + + @Override + public boolean isEnabled() { + return EditorUtils.getActiveTextEditor() != null; + } + +} diff --git a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/AddSelectionToChat.java b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/AddSelectionToChat.java new file mode 100644 index 000000000000..9503da501525 --- /dev/null +++ b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/AddSelectionToChat.java @@ -0,0 +1,31 @@ +package com.tabbyml.tabby4eclipse.commands.chat; + +import org.eclipse.core.commands.AbstractHandler; +import org.eclipse.core.commands.ExecutionEvent; +import org.eclipse.core.commands.ExecutionException; + +import com.tabbyml.tabby4eclipse.Logger; +import com.tabbyml.tabby4eclipse.chat.ChatView; +import com.tabbyml.tabby4eclipse.chat.ChatViewUtils; +import com.tabbyml.tabby4eclipse.editor.EditorUtils; + +public class AddSelectionToChat extends AbstractHandler { + private Logger logger = new Logger("Commands.Chat.AddSelectionToChat"); + + @Override + public Object execute(ExecutionEvent event) throws ExecutionException { + logger.debug("Open chat view and send message: AddSelectionToChat"); + ChatView chatView = ChatViewUtils.openChatView(); + if (chatView != null) { + chatView.addSelectedTextAsContext(); + } + return null; + } + + @Override + public boolean isEnabled() { + String selectedText = EditorUtils.getSelectedText(); + return selectedText != null && !selectedText.isBlank(); + } + +} diff --git a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/Explain.java b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/Explain.java new file mode 100644 index 000000000000..1e7c5362f436 --- /dev/null +++ b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/Explain.java @@ -0,0 +1,31 @@ +package com.tabbyml.tabby4eclipse.commands.chat; + +import org.eclipse.core.commands.AbstractHandler; +import org.eclipse.core.commands.ExecutionEvent; +import org.eclipse.core.commands.ExecutionException; + +import com.tabbyml.tabby4eclipse.Logger; +import com.tabbyml.tabby4eclipse.chat.ChatView; +import com.tabbyml.tabby4eclipse.chat.ChatViewUtils; +import com.tabbyml.tabby4eclipse.editor.EditorUtils; + +public class Explain extends AbstractHandler { + private Logger logger = new Logger("Commands.Chat.Expalin"); + + @Override + public Object execute(ExecutionEvent event) throws ExecutionException { + logger.debug("Open chat view and send message: Expalin"); + ChatView chatView = ChatViewUtils.openChatView(); + if (chatView != null) { + chatView.explainSelectedText(); + } + return null; + } + + @Override + public boolean isEnabled() { + String selectedText = EditorUtils.getSelectedText(); + return selectedText != null && !selectedText.isBlank(); + } + +} diff --git a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/Fix.java b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/Fix.java new file mode 100644 index 000000000000..01dd2290b577 --- /dev/null +++ b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/Fix.java @@ -0,0 +1,31 @@ +package com.tabbyml.tabby4eclipse.commands.chat; + +import org.eclipse.core.commands.AbstractHandler; +import org.eclipse.core.commands.ExecutionEvent; +import org.eclipse.core.commands.ExecutionException; + +import com.tabbyml.tabby4eclipse.Logger; +import com.tabbyml.tabby4eclipse.chat.ChatView; +import com.tabbyml.tabby4eclipse.chat.ChatViewUtils; +import com.tabbyml.tabby4eclipse.editor.EditorUtils; + +public class Fix extends AbstractHandler { + private Logger logger = new Logger("Commands.Chat.Fix"); + + @Override + public Object execute(ExecutionEvent event) throws ExecutionException { + logger.debug("Open chat view and send message: Fix"); + ChatView chatView = ChatViewUtils.openChatView(); + if (chatView != null) { + chatView.fixSelectedText(); + } + return null; + } + + @Override + public boolean isEnabled() { + String selectedText = EditorUtils.getSelectedText(); + return selectedText != null && !selectedText.isBlank(); + } + +} diff --git a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/GenerateDocs.java b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/GenerateDocs.java new file mode 100644 index 000000000000..1b5830fe5816 --- /dev/null +++ b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/GenerateDocs.java @@ -0,0 +1,31 @@ +package com.tabbyml.tabby4eclipse.commands.chat; + +import org.eclipse.core.commands.AbstractHandler; +import org.eclipse.core.commands.ExecutionEvent; +import org.eclipse.core.commands.ExecutionException; + +import com.tabbyml.tabby4eclipse.Logger; +import com.tabbyml.tabby4eclipse.chat.ChatView; +import com.tabbyml.tabby4eclipse.chat.ChatViewUtils; +import com.tabbyml.tabby4eclipse.editor.EditorUtils; + +public class GenerateDocs extends AbstractHandler { + private Logger logger = new Logger("Commands.Chat.GenerateDocs"); + + @Override + public Object execute(ExecutionEvent event) throws ExecutionException { + logger.debug("Open chat view and send message: GenerateDocs"); + ChatView chatView = ChatViewUtils.openChatView(); + if (chatView != null) { + chatView.generateDocsForSelectedText(); + } + return null; + } + + @Override + public boolean isEnabled() { + String selectedText = EditorUtils.getSelectedText(); + return selectedText != null && !selectedText.isBlank(); + } + +} diff --git a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/GenerateTests.java b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/GenerateTests.java new file mode 100644 index 000000000000..b6d399a637e1 --- /dev/null +++ b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/GenerateTests.java @@ -0,0 +1,31 @@ +package com.tabbyml.tabby4eclipse.commands.chat; + +import org.eclipse.core.commands.AbstractHandler; +import org.eclipse.core.commands.ExecutionEvent; +import org.eclipse.core.commands.ExecutionException; + +import com.tabbyml.tabby4eclipse.Logger; +import com.tabbyml.tabby4eclipse.chat.ChatView; +import com.tabbyml.tabby4eclipse.chat.ChatViewUtils; +import com.tabbyml.tabby4eclipse.editor.EditorUtils; + +public class GenerateTests extends AbstractHandler { + private Logger logger = new Logger("Commands.Chat.GenerateTests"); + + @Override + public Object execute(ExecutionEvent event) throws ExecutionException { + logger.debug("Open chat view and send message: GenerateTests"); + ChatView chatView = ChatViewUtils.openChatView(); + if (chatView != null) { + chatView.generateTestsForSelectedText(); + } + return null; + } + + @Override + public boolean isEnabled() { + String selectedText = EditorUtils.getSelectedText(); + return selectedText != null && !selectedText.isBlank(); + } + +} diff --git a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/OpenChatView.java b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/OpenChatView.java similarity index 60% rename from clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/OpenChatView.java rename to clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/OpenChatView.java index db3d0a093bed..c9aae7eec1e8 100644 --- a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/OpenChatView.java +++ b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/OpenChatView.java @@ -1,16 +1,20 @@ -package com.tabbyml.tabby4eclipse.commands; +package com.tabbyml.tabby4eclipse.commands.chat; import org.eclipse.core.commands.AbstractHandler; import org.eclipse.core.commands.ExecutionEvent; import org.eclipse.core.commands.ExecutionException; +import com.tabbyml.tabby4eclipse.Logger; import com.tabbyml.tabby4eclipse.chat.ChatView; +import com.tabbyml.tabby4eclipse.chat.ChatViewUtils; public class OpenChatView extends AbstractHandler { + private Logger logger = new Logger("Commands.Chat.OpenChatView"); @Override public Object execute(ExecutionEvent event) throws ExecutionException { - ChatView.openChatView(); + logger.debug("Open chat view."); + ChatViewUtils.openChatView(); return null; } diff --git a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/ToggleChatView.java b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/ToggleChatView.java new file mode 100644 index 000000000000..7a1d7cb7cc9b --- /dev/null +++ b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/chat/ToggleChatView.java @@ -0,0 +1,65 @@ +package com.tabbyml.tabby4eclipse.commands.chat; + +import org.eclipse.core.commands.AbstractHandler; +import org.eclipse.core.commands.ExecutionEvent; +import org.eclipse.core.commands.ExecutionException; +import org.eclipse.ui.IEditorPart; +import org.eclipse.ui.IWorkbenchPage; + +import com.tabbyml.tabby4eclipse.DebouncedRunnable; +import com.tabbyml.tabby4eclipse.Logger; +import com.tabbyml.tabby4eclipse.chat.ChatView; +import com.tabbyml.tabby4eclipse.chat.ChatViewUtils; +import com.tabbyml.tabby4eclipse.editor.EditorUtils; + +public class ToggleChatView extends AbstractHandler { + private Logger logger = new Logger("Commands.Chat.ToggleChatView"); + private static final int DEBOUNCE_INTERVAL = 50; // ms + + // prevent toggle command called many times within a short time + private DebouncedRunnable runnable = new DebouncedRunnable(() -> { + EditorUtils.syncExec(() -> { + logger.debug("Toggle chat view."); + IWorkbenchPage page = EditorUtils.getActiveWorkbenchPage(); + if (page != null) { + boolean chatPanelFocused = page.getActivePart() == ChatViewUtils.findOpenedView(); + if (chatPanelFocused) { + // FIXME: Toggle between chat view and editor using keyboard shortcut is tested + // on Linux only. + // For macOS and windows, the eclipse keyboard shortcuts in not available when + // chat view web browser is focused, + // so this action can only switch to chat panel but cannot switch back for now. + logger.debug("Switch to Editor."); + IEditorPart editorPart = page.getActiveEditor(); + if (editorPart != null) { + page.activate(editorPart); + } + } else { + logger.debug("Switch to ChatView."); + ChatView chatView = ChatViewUtils.openChatView(); + if (chatView != null) { + page.activate(chatView); + + String selectedText = EditorUtils.getSelectedText(); + if (selectedText != null && !selectedText.isBlank()) { + logger.debug("Send message: AddSelectionToChat"); + chatView.addSelectedTextAsContext(); + } + } + } + } + }); + }, DEBOUNCE_INTERVAL); + + @Override + public Object execute(ExecutionEvent event) throws ExecutionException { + runnable.call(); + return null; + } + + @Override + public boolean isEnabled() { + return true; + } + +} diff --git a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/editor/EditorUtils.java b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/editor/EditorUtils.java index 9f0dd4db0bbd..b7447f4dc853 100644 --- a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/editor/EditorUtils.java +++ b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/editor/EditorUtils.java @@ -19,6 +19,8 @@ import org.eclipse.ui.PlatformUI; import org.eclipse.ui.texteditor.ITextEditor; +import com.tabbyml.tabby4eclipse.chat.ChatMessage.FileContext; + public class EditorUtils { public static IWorkbenchPage getActiveWorkbenchPage() { IWorkbenchWindow window = PlatformUI.getWorkbench().getActiveWorkbenchWindow(); @@ -125,4 +127,20 @@ public static int getCurrentOffsetInDocument(ITextEditor textEditor) throws Ille throw new IllegalStateException("Failed to get current offset in document."); }); } + + public static String getSelectedText() { + ITextEditor editor = getActiveTextEditor(); + if (editor != null) { + return getSelectedText(editor); + } + return null; + } + + public static String getSelectedText(ITextEditor textEditor) { + ISelection selection = textEditor.getSelectionProvider().getSelection(); + if (selection instanceof ITextSelection textSelection) { + return textSelection.getText(); + } + return null; + } } diff --git a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/inlineCompletion/DebouncedDocumentEventTrigger.java b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/inlineCompletion/DebouncedDocumentEventTrigger.java index 83cc33aa285c..aaf520978699 100644 --- a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/inlineCompletion/DebouncedDocumentEventTrigger.java +++ b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/inlineCompletion/DebouncedDocumentEventTrigger.java @@ -15,6 +15,7 @@ import org.eclipse.swt.custom.StyledText; import org.eclipse.ui.texteditor.ITextEditor; +import com.tabbyml.tabby4eclipse.DebouncedRunnable; import com.tabbyml.tabby4eclipse.Logger; import com.tabbyml.tabby4eclipse.editor.EditorUtils; @@ -129,28 +130,4 @@ private void handleDocumentChanged(ITextEditor textEditor, DocumentEvent event) logger.debug("handleDocumentChanged: " + event.toString()); documentChangedRunnable.call(); } - - private class DebouncedRunnable { - private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); - private ScheduledFuture future; - private final long delay; - private final Runnable task; - - public DebouncedRunnable(Runnable task, long delay) { - this.task = task; - this.delay = delay; - } - - public synchronized void call() { - if (future != null && !future.isDone()) { - future.cancel(true); - } - future = scheduler.schedule(task, delay, TimeUnit.MILLISECONDS); - } - - // FIXME: scheduler shutdown not called - public void shutdown() { - scheduler.shutdown(); - } - } } diff --git a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/statusbar/StatusbarContribution.java b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/statusbar/StatusbarContribution.java index 35d1165f6aaa..8e557b67d5b7 100644 --- a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/statusbar/StatusbarContribution.java +++ b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/statusbar/StatusbarContribution.java @@ -15,7 +15,7 @@ import org.eclipse.ui.menus.WorkbenchWindowControlContribution; import com.tabbyml.tabby4eclipse.Images; -import com.tabbyml.tabby4eclipse.chat.ChatView; +import com.tabbyml.tabby4eclipse.chat.ChatViewUtils; import com.tabbyml.tabby4eclipse.lsp.LanguageServerService; import com.tabbyml.tabby4eclipse.lsp.StatusInfoHolder; import com.tabbyml.tabby4eclipse.lsp.protocol.StatusInfo; @@ -156,7 +156,7 @@ public void widgetSelected(SelectionEvent e) { openChatViewItem.addSelectionListener(new SelectionAdapter() { @Override public void widgetSelected(SelectionEvent e) { - ChatView.openChatView(); + ChatViewUtils.openChatView(); } }); From f0850a0c25f774906c9391f9d75cb38964404a3b Mon Sep 17 00:00:00 2001 From: Zhiming Ma Date: Sun, 13 Oct 2024 21:47:18 +0800 Subject: [PATCH 2/5] feat(eclipse): support cycle multiple choices. (#3268) --- clients/eclipse/feature/feature.xml | 4 +- clients/eclipse/plugin/META-INF/MANIFEST.MF | 2 +- clients/eclipse/plugin/plugin.xml | 67 +++++++++++--- .../commands/inlineCompletion/Next.java | 25 +++++ .../commands/inlineCompletion/Previous.java | 25 +++++ .../commands/inlineCompletion/Trigger.java | 26 ++++++ .../IInlineCompletionService.java | 22 ++++- .../InlineCompletionService.java | 92 ++++++++++++++++++- 8 files changed, 245 insertions(+), 18 deletions(-) create mode 100644 clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/inlineCompletion/Next.java create mode 100644 clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/inlineCompletion/Previous.java create mode 100644 clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/inlineCompletion/Trigger.java diff --git a/clients/eclipse/feature/feature.xml b/clients/eclipse/feature/feature.xml index bc946e5802f4..a7d93cc17569 100644 --- a/clients/eclipse/feature/feature.xml +++ b/clients/eclipse/feature/feature.xml @@ -2,7 +2,7 @@ @@ -19,6 +19,6 @@ + version="0.0.2.25"/> diff --git a/clients/eclipse/plugin/META-INF/MANIFEST.MF b/clients/eclipse/plugin/META-INF/MANIFEST.MF index 35026e30e7bd..606601c995ec 100644 --- a/clients/eclipse/plugin/META-INF/MANIFEST.MF +++ b/clients/eclipse/plugin/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: Tabby Plugin for Eclipse Bundle-SymbolicName: com.tabbyml.tabby4eclipse;singleton:=true -Bundle-Version: 0.0.2.24 +Bundle-Version: 0.0.2.25 Bundle-Activator: com.tabbyml.tabby4eclipse.Activator Bundle-Vendor: com.tabbyml Require-Bundle: org.eclipse.ui, diff --git a/clients/eclipse/plugin/plugin.xml b/clients/eclipse/plugin/plugin.xml index 43b88aaef881..eb2061be7bb2 100644 --- a/clients/eclipse/plugin/plugin.xml +++ b/clients/eclipse/plugin/plugin.xml @@ -102,27 +102,42 @@ + name="Tabby Code Completion"> + + + + + + + name="Tabby Chat"> @@ -172,6 +187,18 @@ class="com.tabbyml.tabby4eclipse.commands.OpenPreferences" commandId="com.tabbyml.tabby4eclipse.commands.openPreferences"> + + + + + + @@ -221,6 +248,24 @@ schemeId="org.eclipse.ui.defaultAcceleratorConfiguration" sequence="M1+M3+L"> + + + + + + job = LanguageServerService + .getInstance().getServer().execute((server) -> { + ITextDocumentServiceExt textDocumentService = ((ILanguageServer) server) + .getTextDocumentServiceExt(); + return textDocumentService.inlineCompletion(params); + }); + job.thenAccept((completionList) -> { + if (completionList == null || request != current.request) { + return; + } + try { + InlineCompletionList list = request.convertInlineCompletionList(completionList); + int cycleIndex = calcCycleIndex(index, list.getItems().size(), step); + current.response = new InlineCompletionContext.Response(list, cycleIndex); + renderer.show(textViewer, current.response.getActiveCompletionItem()); + EventParams eventParams = buildTelemetryEventParams(EventParams.Type.VIEW); + postTelemetryEvent(eventParams); + } catch (BadLocationException e) { + logger.error("Failed to show inline completion.", e); + } + }); + InlineCompletionContext context = new InlineCompletionContext(request, job, current.response); + current = context; + } else { + int cycleIndex = calcCycleIndex(current.response.getItemIndex(), + current.response.completionList.getItems().size(), step); + current.response.setItemIndex(cycleIndex); + renderer.show(textViewer, current.response.getActiveCompletionItem()); + EventParams eventParams = buildTelemetryEventParams(EventParams.Type.VIEW); + postTelemetryEvent(eventParams); + } + } + + private int calcCycleIndex(int index, int listSize, int step) { + if (listSize <= 1) { + return index; + } + int cycleIndex = index + step; + while (cycleIndex >= listSize) { + cycleIndex -= listSize; + } + if (cycleIndex < 0) { + cycleIndex += listSize; + } + return cycleIndex; + } + @Override public void accept() { ITextEditor textEditor = EditorUtils.getActiveTextEditor(); @@ -258,12 +335,25 @@ public Response(InlineCompletionList completionList) { this.itemIndex = 0; } + public Response(InlineCompletionList completionList, int itemIndex) { + this.completionList = completionList; + this.itemIndex = itemIndex; + } + public InlineCompletionItem getActiveCompletionItem() { if (itemIndex >= 0 && itemIndex < completionList.getItems().size()) { return completionList.getItems().get(itemIndex); } return null; } + + public int getItemIndex() { + return itemIndex; + } + + public void setItemIndex(int itemIndex) { + this.itemIndex = itemIndex; + } } private Request request; From 2f9788860f648a4f230eba04581f6fa22ccaaa80 Mon Sep 17 00:00:00 2001 From: Zhiming Ma Date: Sun, 13 Oct 2024 23:28:40 +0800 Subject: [PATCH 3/5] ci: explicitly set latest tag when creating release. (#3270) --- .github/workflows/release-intellij.yml | 18 ++++++++++++++---- .github/workflows/release.yml | 16 ++++++++++++++-- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-intellij.yml b/.github/workflows/release-intellij.yml index 412e9b9b91ce..754129025a3f 100644 --- a/.github/workflows/release-intellij.yml +++ b/.github/workflows/release-intellij.yml @@ -55,10 +55,8 @@ jobs: run: pnpm install - name: Determine Publish Channel - env: - VERSION_STRING: run: | - if [[ ${{ github.ref_name }} =~ intellij@[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + if [[ ${{ github.ref_name }} =~ ^intellij@[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "PUBLISH_CHANNEL=stable" >> $GITHUB_ENV else echo "PUBLISH_CHANNEL=alpha" >> $GITHUB_ENV @@ -130,11 +128,23 @@ jobs: arguments: signPlugin build-root-directory: clients/intellij + - name: Determine is stable release + run: | + if [[ ${{ github.ref_name }} =~ ^intellij@[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "STABLE_RELEASE=true" >> $GITHUB_ENV + else + echo "STABLE_RELEASE=false" >> $GITHUB_ENV + fi + + - name: Check if stable release + run: echo "Stable Release is ${{ env.STABLE_RELEASE }}" + - name: Create GitHub Release uses: ncipollo/release-action@v1 with: allowUpdates: true - prerelease: true + prerelease: ${{ env.STABLE_RELEASE == 'false' }} + makeLatest: false tag: ${{ github.ref_name }} removeArtifacts: true artifacts: "clients/intellij/build/distributions/intellij-tabby-signed.zip" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e024a39b944..bffbe190ad2e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -329,11 +329,23 @@ jobs: - name: Display structure of created files run: ls -R dist + - name: Determine is stable release + run: | + if [[ ${{ github.ref_name }} =~ ^v@[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "STABLE_RELEASE=true" >> $GITHUB_ENV + else + echo "STABLE_RELEASE=false" >> $GITHUB_ENV + fi + + - name: Check if stable release + run: echo "Stable Release is ${{ env.STABLE_RELEASE }}" + - uses: ncipollo/release-action@v1 if: github.event_name == 'push' with: - allowUpdates: true - prerelease: true + allowUpdates: true + prerelease: ${{ env.STABLE_RELEASE == 'false' }} + makeLatest: ${{ env.STABLE_RELEASE == 'true' }} artifacts: "dist/tabby_*.zip" tag: ${{ github.ref_name }} removeArtifacts: true From dac6145e4d467df469525fe470e3d2b0b31fa167 Mon Sep 17 00:00:00 2001 From: Zhiming Ma Date: Mon, 14 Oct 2024 10:15:51 +0800 Subject: [PATCH 4/5] feat(eclipse): support partially accept inline completion. (#3269) --- clients/eclipse/feature/feature.xml | 4 +- clients/eclipse/plugin/META-INF/MANIFEST.MF | 2 +- clients/eclipse/plugin/plugin.xml | 47 ++++++++++++-- .../commands/inlineCompletion/Accept.java | 3 +- .../inlineCompletion/AcceptNextLine.java | 26 ++++++++ .../inlineCompletion/AcceptNextWord.java | 26 ++++++++ .../tabby4eclipse/editor/EditorUtils.java | 9 ++- .../IInlineCompletionService.java | 8 ++- .../InlineCompletionService.java | 61 +++++++++++++++++-- 9 files changed, 170 insertions(+), 16 deletions(-) create mode 100644 clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/inlineCompletion/AcceptNextLine.java create mode 100644 clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/inlineCompletion/AcceptNextWord.java diff --git a/clients/eclipse/feature/feature.xml b/clients/eclipse/feature/feature.xml index a7d93cc17569..cb0cd824006f 100644 --- a/clients/eclipse/feature/feature.xml +++ b/clients/eclipse/feature/feature.xml @@ -2,7 +2,7 @@ @@ -19,6 +19,6 @@ + version="0.0.2.26"/> diff --git a/clients/eclipse/plugin/META-INF/MANIFEST.MF b/clients/eclipse/plugin/META-INF/MANIFEST.MF index 606601c995ec..7230fab47635 100644 --- a/clients/eclipse/plugin/META-INF/MANIFEST.MF +++ b/clients/eclipse/plugin/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: Tabby Plugin for Eclipse Bundle-SymbolicName: com.tabbyml.tabby4eclipse;singleton:=true -Bundle-Version: 0.0.2.25 +Bundle-Version: 0.0.2.26 Bundle-Activator: com.tabbyml.tabby4eclipse.Activator Bundle-Vendor: com.tabbyml Require-Bundle: org.eclipse.ui, diff --git a/clients/eclipse/plugin/plugin.xml b/clients/eclipse/plugin/plugin.xml index eb2061be7bb2..5cd00f7d994d 100644 --- a/clients/eclipse/plugin/plugin.xml +++ b/clients/eclipse/plugin/plugin.xml @@ -130,6 +130,16 @@ name="Accept Inline Completion" id="com.tabbyml.tabby4eclipse.commands.inlineCompletion.accept"> + + + + + + + + @@ -241,6 +259,15 @@ + + + + + + + + + diff --git a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/inlineCompletion/Accept.java b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/inlineCompletion/Accept.java index 490db7a512e4..109e19954d6a 100644 --- a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/inlineCompletion/Accept.java +++ b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/inlineCompletion/Accept.java @@ -5,6 +5,7 @@ import org.eclipse.core.commands.ExecutionException; import com.tabbyml.tabby4eclipse.Logger; +import com.tabbyml.tabby4eclipse.inlineCompletion.IInlineCompletionService.AcceptType; import com.tabbyml.tabby4eclipse.inlineCompletion.InlineCompletionService; public class Accept extends AbstractHandler { @@ -13,7 +14,7 @@ public class Accept extends AbstractHandler { @Override public Object execute(ExecutionEvent event) throws ExecutionException { logger.debug("Accept the current inline completion."); - InlineCompletionService.getInstance().accept(); + InlineCompletionService.getInstance().accept(AcceptType.FULL_COMPLETION); return null; } diff --git a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/inlineCompletion/AcceptNextLine.java b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/inlineCompletion/AcceptNextLine.java new file mode 100644 index 000000000000..b013ad0771a0 --- /dev/null +++ b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/inlineCompletion/AcceptNextLine.java @@ -0,0 +1,26 @@ +package com.tabbyml.tabby4eclipse.commands.inlineCompletion; + +import org.eclipse.core.commands.AbstractHandler; +import org.eclipse.core.commands.ExecutionEvent; +import org.eclipse.core.commands.ExecutionException; + +import com.tabbyml.tabby4eclipse.Logger; +import com.tabbyml.tabby4eclipse.inlineCompletion.IInlineCompletionService.AcceptType; +import com.tabbyml.tabby4eclipse.inlineCompletion.InlineCompletionService; + +public class AcceptNextLine extends AbstractHandler { + private Logger logger = new Logger("Commands.InlineCompletion.AcceptNextLine"); + + @Override + public Object execute(ExecutionEvent event) throws ExecutionException { + logger.debug("Accept next line of the current inline completion."); + InlineCompletionService.getInstance().accept(AcceptType.NEXT_LINE); + return null; + } + + @Override + public boolean isEnabled() { + return InlineCompletionService.getInstance().isCompletionItemVisible(); + } + +} diff --git a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/inlineCompletion/AcceptNextWord.java b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/inlineCompletion/AcceptNextWord.java new file mode 100644 index 000000000000..4ddb31d2666f --- /dev/null +++ b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/commands/inlineCompletion/AcceptNextWord.java @@ -0,0 +1,26 @@ +package com.tabbyml.tabby4eclipse.commands.inlineCompletion; + +import org.eclipse.core.commands.AbstractHandler; +import org.eclipse.core.commands.ExecutionEvent; +import org.eclipse.core.commands.ExecutionException; + +import com.tabbyml.tabby4eclipse.Logger; +import com.tabbyml.tabby4eclipse.inlineCompletion.IInlineCompletionService.AcceptType; +import com.tabbyml.tabby4eclipse.inlineCompletion.InlineCompletionService; + +public class AcceptNextWord extends AbstractHandler { + private Logger logger = new Logger("Commands.InlineCompletion.AcceptNextLine"); + + @Override + public Object execute(ExecutionEvent event) throws ExecutionException { + logger.debug("Accept next word of the current inline completion."); + InlineCompletionService.getInstance().accept(AcceptType.NEXT_WORD); + return null; + } + + @Override + public boolean isEnabled() { + return InlineCompletionService.getInstance().isCompletionItemVisible(); + } + +} diff --git a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/editor/EditorUtils.java b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/editor/EditorUtils.java index b7447f4dc853..8d7a9be0b6a8 100644 --- a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/editor/EditorUtils.java +++ b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/editor/EditorUtils.java @@ -17,6 +17,7 @@ import org.eclipse.ui.IWorkbenchPage; import org.eclipse.ui.IWorkbenchWindow; import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.contexts.IContextService; import org.eclipse.ui.texteditor.ITextEditor; import com.tabbyml.tabby4eclipse.chat.ChatMessage.FileContext; @@ -67,6 +68,10 @@ public static Display getDisplay(ITextEditor textEditor) { return getStyledTextWidget(textEditor).getDisplay(); } + public static IContextService getContextService(ITextEditor textEditor) { + return textEditor.getSite().getService(IContextService.class); + } + public static void asyncExec(Runnable runnable) { PlatformUI.getWorkbench().getDisplay().asyncExec(runnable); } @@ -127,7 +132,7 @@ public static int getCurrentOffsetInDocument(ITextEditor textEditor) throws Ille throw new IllegalStateException("Failed to get current offset in document."); }); } - + public static String getSelectedText() { ITextEditor editor = getActiveTextEditor(); if (editor != null) { @@ -135,7 +140,7 @@ public static String getSelectedText() { } return null; } - + public static String getSelectedText(ITextEditor textEditor) { ISelection selection = textEditor.getSelectionProvider().getSelection(); if (selection instanceof ITextSelection textSelection) { diff --git a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/inlineCompletion/IInlineCompletionService.java b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/inlineCompletion/IInlineCompletionService.java index 2cb098d573cc..cc192ea29138 100644 --- a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/inlineCompletion/IInlineCompletionService.java +++ b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/inlineCompletion/IInlineCompletionService.java @@ -42,10 +42,16 @@ public interface IInlineCompletionService { */ public void previous(); + enum AcceptType { + FULL_COMPLETION, NEXT_LINE, NEXT_WORD, + } + /** * Accept the current completion item ghost text. + * + * @param type the type of completion to accept. */ - public void accept(); + public void accept(AcceptType type); /** * Dismiss the current completion item ghost text. diff --git a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/inlineCompletion/InlineCompletionService.java b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/inlineCompletion/InlineCompletionService.java index 2c900bac7855..012659dcc7e6 100644 --- a/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/inlineCompletion/InlineCompletionService.java +++ b/clients/eclipse/plugin/src/com/tabbyml/tabby4eclipse/inlineCompletion/InlineCompletionService.java @@ -3,6 +3,8 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.eclipse.jface.text.BadLocationException; import org.eclipse.jface.text.IDocument; @@ -12,6 +14,8 @@ import org.eclipse.lsp4e.LSPEclipseUtils; import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.TextDocumentIdentifier; +import org.eclipse.ui.contexts.IContextActivation; +import org.eclipse.ui.contexts.IContextService; import org.eclipse.ui.texteditor.ITextEditor; import com.tabbyml.tabby4eclipse.Logger; @@ -34,9 +38,12 @@ private static class LazyHolder { private static final IInlineCompletionService INSTANCE = new InlineCompletionService(); } + private static final String INLINE_COMPLETION_VISIBLE_CONTEXT_ID = "com.tabbyml.tabby4eclipse.inlineCompletionVisible"; + private Logger logger = new Logger("InlineCompletionService"); private InlineCompletionRenderer renderer = new InlineCompletionRenderer(); private InlineCompletionContext current; + private IContextActivation inlineCompletionVisibleContext; @Override public boolean isCompletionItemVisible() { @@ -76,6 +83,7 @@ public void trigger(boolean isManualTrigger) { logger.info("Provide inline completion for TextEditor " + textEditor.getTitle() + " at offset " + offset + " with modification stamp " + modificationStamp); renderer.hide(); + deactivateInlineCompletionVisibleContext(textEditor); if (current != null) { if (current.job != null && !current.job.isDone()) { logger.info("Cancel the current job due to new request."); @@ -105,6 +113,7 @@ public void trigger(boolean isManualTrigger) { InlineCompletionList list = request.convertInlineCompletionList(completionList); current.response = new InlineCompletionContext.Response(list); renderer.show(textViewer, current.response.getActiveCompletionItem()); + activateInlineCompletionVisibleContext(textEditor); EventParams eventParams = buildTelemetryEventParams(EventParams.Type.VIEW); postTelemetryEvent(eventParams); } catch (BadLocationException e) { @@ -191,7 +200,7 @@ private int calcCycleIndex(int index, int listSize, int step) { } @Override - public void accept() { + public void accept(AcceptType acceptType) { ITextEditor textEditor = EditorUtils.getActiveTextEditor(); logger.info("Accept inline completion in TextEditor " + textEditor.toString()); if (current == null || current.request == null || current.response == null) { @@ -199,23 +208,47 @@ public void accept() { } int offset = current.request.offset; InlineCompletionItem item = current.response.getActiveCompletionItem(); - EventParams eventParams = buildTelemetryEventParams(EventParams.Type.SELECT); + EventParams eventParams = buildTelemetryEventParams(EventParams.Type.SELECT, + acceptType == AcceptType.FULL_COMPLETION ? null : "line"); renderer.hide(); + deactivateInlineCompletionVisibleContext(textEditor); current = null; int prefixReplaceLength = item.getReplaceRange().getPrefixLength(); int suffixReplaceLength = item.getReplaceRange().getSuffixLength(); String text = item.getInsertText().substring(prefixReplaceLength); - if (text.isEmpty()) { + String textToInsert; + + if (acceptType == AcceptType.NEXT_WORD) { + Pattern pattern = Pattern.compile("\\w+|\\W+"); + Matcher matcher = pattern.matcher(text); + if (matcher.find()) { + textToInsert = matcher.group(); + } else { + textToInsert = text; + } + } else if (acceptType == AcceptType.NEXT_LINE) { + List lines = List.of(text.split("\n")); + String line = lines.get(0); + if (text.isEmpty() && lines.size() > 1) { + line += "\n"; + line += lines.get(1); + } + textToInsert = line; + } else { + textToInsert = text; + } + + if (textToInsert.isEmpty()) { return; } IDocument document = EditorUtils.getDocument(textEditor); EditorUtils.syncExec(textEditor, () -> { try { - document.replace(offset, suffixReplaceLength, text); - ITextSelection selection = new TextSelection(offset + text.length(), 0); + document.replace(offset, suffixReplaceLength, textToInsert); + ITextSelection selection = new TextSelection(offset + textToInsert.length(), 0); textEditor.getSelectionProvider().setSelection(selection); postTelemetryEvent(eventParams); } catch (BadLocationException e) { @@ -230,6 +263,8 @@ public void dismiss() { logger.info("Dismiss inline completion."); EventParams eventParams = buildTelemetryEventParams(EventParams.Type.DISMISS); renderer.hide(); + ITextEditor textEditor = EditorUtils.getActiveTextEditor(); + deactivateInlineCompletionVisibleContext(textEditor); postTelemetryEvent(eventParams); } if (current != null) { @@ -269,6 +304,22 @@ private void postTelemetryEvent(EventParams params) { } } + private void activateInlineCompletionVisibleContext(ITextEditor editor) { + IContextService contextService = EditorUtils.getContextService(editor); + if (contextService != null) { + logger.debug("Activating inline completion visible context."); + inlineCompletionVisibleContext = contextService.activateContext(INLINE_COMPLETION_VISIBLE_CONTEXT_ID); + } + } + + private void deactivateInlineCompletionVisibleContext(ITextEditor editor) { + IContextService contextService = EditorUtils.getContextService(editor); + if (contextService != null && inlineCompletionVisibleContext != null) { + logger.debug("Deactivating inline completion visible context."); + contextService.deactivateContext(inlineCompletionVisibleContext); + } + } + private class InlineCompletionContext { private static class Request { private Logger logger = new Logger("InlineCompletionContext.Request"); From 5dfe7a12555cc95c1eae8fbc6d1a942230b8ea3e Mon Sep 17 00:00:00 2001 From: Jackson Chen <90215880+Sma1lboy@users.noreply.github.com> Date: Mon, 14 Oct 2024 04:45:44 -0500 Subject: [PATCH 5/5] feat(tabby-agent): add recent opened files frontend to code completion requests (#3238) * feat(backend): adding open recent file segments chore: add relevant snippets to client-open-api * feat(protocol): add OpenedFileRequest namespace and types * feat: add FileTracker to code completion and server initialization * feat(fileTracker): adding file tracking provider in lsp server refactor: Add LRUList class to track recently opened files This commit adds a new class called LRUList to track recently opened files in the FileTracker feature. The LRUList class provides methods for inserting, removing, updating, and retrieving files based on their URI. It also maintains a maximum size to limit the number of files stored. The LRUList class is used by the FileTracker feature to keep track of the most recently opened files. When a file is opened or its visibility changes, it is inserted or updated in the LRUList. The list is then pruned to remove the least recently used files if it exceeds the maximum size. This refactor improves the efficiency and organization of the FileTracker feature by separating the logic for tracking files into a dedicated class. * feat(vscode): implement FileTrackerProvider class for tracking visible editors This commit adds a new class called FileTrackerProvider in the vscode/src directory. The FileTrackerProvider class is responsible for collecting visible editors and their visible ranges. It filters out editors that do not have a file name starting with a forward slash ("/"). The collected editors are then sorted based on their URI, with the active editor being prioritized. The FileTrackerProvider class also provides a method to collect the active editor, which returns the URI and visible range of the currently active text editor. These changes are part of the ongoing development of the FileTracker feature, which aims to track and manage recently opened files in the vscode extension. Ref: feat(fileTracker): adding file tracking provider in lsp server * feat(vscode): implement FileTrackerProvider class for tracking visible editors * feat: update snippet count check in extract_snippets_from_segments * feat: adding configuration to recently opened file enable collection of snippets from recent opened files This commit enables the collection of snippets from recent opened files in the CompletionProvider class. It adds a new configuration option `collectSnippetsFromRecentOpenedFiles` to the default configuration, which is set to `true` by default. The maximum number of opened files to collect snippets from is set to 5. These changes are necessary to improve the code completion feature by including snippets from recently opened files. Ref: feat(recent-opened-files): enable collection of snippets from recent opened files * [autofix.ci] apply automated fixes * refactor: using lru-cache package and passing config * fix: fixing typo, remove unuse type * refactor: remove action and rename the notification * feat(config): add maxCharsPerOpenedFiles to default config data. * refactor: optimize code snippet collection algorithm in CompletionProvider. adding max chars size per opened files * chore: remove unused log chore: remove logger import --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../src/codeCompletion/contexts.ts | 16 ++- .../tabby-agent/src/codeCompletion/index.ts | 80 +++++++++++- .../tabby-agent/src/codeSearch/fileTracker.ts | 118 ++++++++++++++++++ clients/tabby-agent/src/config/default.ts | 5 + clients/tabby-agent/src/config/type.d.ts | 7 ++ clients/tabby-agent/src/protocol.ts | 20 +++ clients/tabby-agent/src/server.ts | 4 + .../vscode/src/InlineCompletionProvider.ts | 1 + clients/vscode/src/lsp/Client.ts | 4 + clients/vscode/src/lsp/FileTrackFeature.ts | 59 +++++++++ clients/vscode/src/windowUtils.ts | 67 ++++++++++ 11 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 clients/tabby-agent/src/codeSearch/fileTracker.ts create mode 100644 clients/vscode/src/lsp/FileTrackFeature.ts create mode 100644 clients/vscode/src/windowUtils.ts diff --git a/clients/tabby-agent/src/codeCompletion/contexts.ts b/clients/tabby-agent/src/codeCompletion/contexts.ts index 01255b5f9bfc..751d9b04f554 100644 --- a/clients/tabby-agent/src/codeCompletion/contexts.ts +++ b/clients/tabby-agent/src/codeCompletion/contexts.ts @@ -22,6 +22,7 @@ export type CompletionRequest = { }; declarations?: Declaration[]; relevantSnippetsFromChangedFiles?: CodeSnippet[]; + relevantSnippetsFromOpenedFiles?: CodeSnippet[]; }; export type Declaration = { @@ -68,7 +69,7 @@ export class CompletionContext { declarations?: Declaration[]; relevantSnippetsFromChangedFiles?: CodeSnippet[]; - + snippetsFromOpenedFiles?: CodeSnippet[]; // "default": the cursor is at the end of the line // "fill-in-line": the cursor is not at the end of the line, except auto closed characters // In this case, we assume the completion should be a single line, so multiple lines completion will be dropped. @@ -96,6 +97,7 @@ export class CompletionContext { this.declarations = request.declarations; this.relevantSnippetsFromChangedFiles = request.relevantSnippetsFromChangedFiles; + this.snippetsFromOpenedFiles = request.relevantSnippetsFromOpenedFiles; const lineEnd = isAtLineEndExcludingAutoClosedChar(this.currentLineSuffix); this.mode = lineEnd ? "default" : "fill-in-line"; @@ -203,6 +205,17 @@ export class CompletionContext { }) .sort((a, b) => b.score - a.score); + //FIXME(Sma1lboy): deduplicate in next few PR + const snippetsOpenedFiles = this.snippetsFromOpenedFiles + ?.map((snippet) => { + return { + filepath: snippet.filepath, + body: snippet.text, + score: snippet.score, + }; + }) + .sort((a, b) => b.score - a.score); + // clipboard let clipboard = undefined; if (this.clipboard.length >= config.clipboard.minChars && this.clipboard.length <= config.clipboard.maxChars) { @@ -215,6 +228,7 @@ export class CompletionContext { git_url: gitUrl, declarations, relevant_snippets_from_changed_files: relevantSnippetsFromChangedFiles, + relevant_snippets_from_recently_opened_files: snippetsOpenedFiles, clipboard, }; } diff --git a/clients/tabby-agent/src/codeCompletion/index.ts b/clients/tabby-agent/src/codeCompletion/index.ts index 238738076b4d..3c203ec913ff 100644 --- a/clients/tabby-agent/src/codeCompletion/index.ts +++ b/clients/tabby-agent/src/codeCompletion/index.ts @@ -57,6 +57,7 @@ import { abortSignalFromAnyOf } from "../utils/signal"; import { splitLines, extractNonReservedWordList } from "../utils/string"; import { MutexAbortError, isCanceledError } from "../utils/error"; import { isPositionInRange, intersectionRange } from "../utils/range"; +import { FileTracker } from "../codeSearch/fileTracker"; export class CompletionProvider implements Feature { private readonly logger = getLogger("CompletionProvider"); @@ -80,6 +81,7 @@ export class CompletionProvider implements Feature { private readonly anonymousUsageLogger: AnonymousUsageLogger, private readonly gitContextProvider: GitContextProvider, private readonly recentlyChangedCodeSearch: RecentlyChangedCodeSearch, + private readonly fileTracker: FileTracker, ) {} initialize(connection: Connection, clientCapabilities: ClientCapabilities): ServerCapabilities { @@ -565,7 +567,7 @@ export class CompletionProvider implements Feature { request.declarations = await this.collectDeclarationSnippets(connection, document, position, token); } request.relevantSnippetsFromChangedFiles = await this.collectSnippetsFromRecentlyChangedFiles(document, position); - + request.relevantSnippetsFromOpenedFiles = await this.collectSnippetsFromOpenedFiles(); this.logger.trace("Completed completion context:", { request }); return { request, additionalPrefixLength: additionalContext?.prefix.length }; } @@ -838,6 +840,82 @@ export class CompletionProvider implements Feature { return snippets; } + //get all recently opened files from the file tracker + private async collectSnippetsFromOpenedFiles(): Promise< + { filepath: string; offset: number; text: string; score: number }[] | undefined + > { + const config = this.configurations.getMergedConfig(); + if (!config.completion.prompt.collectSnippetsFromRecentOpenedFiles.enabled) { + return undefined; + } + this.logger.debug("Starting collecting snippets from opened files."); + const recentlyOpenedFiles = this.fileTracker.getAllFilesWithoutActive(); + const codeSnippets: { filepath: string; offset: number; text: string; score: number }[] = []; + const chunkSize = config.completion.prompt.collectSnippetsFromRecentOpenedFiles.maxCharsPerOpenedFiles; + recentlyOpenedFiles.forEach((file) => { + const doc = this.documents.get(file.uri); + if (doc) { + file.lastVisibleRange.forEach((range: Range) => { + this.logger.info( + `Original range: start(${range.start.line},${range.start.character}), end(${range.end.line},${range.end.character})`, + ); + + const startOffset = doc.offsetAt(range.start); + const endOffset = doc.offsetAt(range.end); + const middleOffset = Math.floor((startOffset + endOffset) / 2); + const halfChunkSize = Math.floor(chunkSize / 2); + + const upwardChunkSize = Math.min(halfChunkSize, middleOffset); + const newStartOffset = middleOffset - upwardChunkSize; + + const downwardChunkSize = Math.min(chunkSize - upwardChunkSize, doc.getText().length - middleOffset); + let newEndOffset = middleOffset + downwardChunkSize; + + if (newEndOffset - newStartOffset > chunkSize) { + const excess = newEndOffset - newStartOffset - chunkSize; + newEndOffset -= excess; + } + + let newStart = doc.positionAt(newStartOffset); + let newEnd = doc.positionAt(newEndOffset); + + newStart = { line: newStart.line, character: 0 }; + newEnd = { + line: newEnd.line, + character: doc.getText({ + start: { line: newEnd.line, character: 0 }, + end: { line: newEnd.line + 1, character: 0 }, + }).length, + }; + + this.logger.info( + `New range: start(${newStart.line},${newStart.character}), end(${newEnd.line},${newEnd.character})`, + ); + + const newRange = { start: newStart, end: newEnd }; + let text = doc.getText(newRange); + + if (text.length > chunkSize) { + text = text.substring(0, chunkSize); + } + + this.logger.info(`Text length: ${text.length}`); + this.logger.info(`Upward chunk size: ${upwardChunkSize}, Downward chunk size: ${downwardChunkSize}`); + + codeSnippets.push({ + filepath: file.uri, + offset: newStartOffset, + text: text, + score: file.invisible ? 0.98 : 1, + }); + }); + } + }); + + this.logger.debug("Completed collecting snippets from opened files."); + return codeSnippets; + } + private async submitStats() { const stats = this.completionStats.stats(); if (stats["completion_request"]["count"] > 0) { diff --git a/clients/tabby-agent/src/codeSearch/fileTracker.ts b/clients/tabby-agent/src/codeSearch/fileTracker.ts new file mode 100644 index 000000000000..0d2a5248315c --- /dev/null +++ b/clients/tabby-agent/src/codeSearch/fileTracker.ts @@ -0,0 +1,118 @@ +import { Connection, Range } from "vscode-languageserver"; +import { Feature } from "../feature"; +import { DidChangeActiveEditorNotification, DidChangeActiveEditorParams, ServerCapabilities } from "../protocol"; +import { Configurations } from "../config"; +import { LRUCache } from "lru-cache"; +import { isRangeEqual } from "../utils/range"; + +interface OpenedFile { + uri: string; + //order by range, the left most is the most recent one + lastVisibleRange: Range[]; + invisible: boolean; + isActive: boolean; +} + +export class FileTracker implements Feature { + private fileList = new LRUCache({ + max: this.configurations.getMergedConfig().completion.prompt.collectSnippetsFromRecentOpenedFiles.maxOpenedFiles, + }); + + constructor(private readonly configurations: Configurations) {} + initialize(connection: Connection): ServerCapabilities | Promise { + connection.onNotification(DidChangeActiveEditorNotification.type, (param: DidChangeActiveEditorParams) => { + this.resolveChangedFile(param); + }); + return {}; + } + + resolveChangedFile(param: DidChangeActiveEditorParams) { + const { activeEditor, visibleEditors } = param; + + const visitedPaths = new Set(); + + //get all visible editors + if (visibleEditors) { + visibleEditors.forEach((editor) => { + const visibleFile = this.fileList.get(editor.uri); + if (visibleFile) { + visibleFile.lastVisibleRange = []; + } + }); + + visibleEditors.forEach((editor) => { + let visibleFile = this.fileList.get(editor.uri); + if (!visibleFile) { + visibleFile = { + uri: editor.uri, + lastVisibleRange: [editor.range], + invisible: false, + isActive: false, + }; + this.fileList.set(editor.uri, visibleFile); + } else { + if (visitedPaths.has(visibleFile.uri)) { + const idx = visibleFile.lastVisibleRange.findIndex((range) => isRangeEqual(range, editor.range)); + if (idx === -1) { + visibleFile.lastVisibleRange = [editor.range, ...visibleFile.lastVisibleRange]; + } + visibleFile.invisible = false; + } else { + visibleFile.invisible = false; + visibleFile.lastVisibleRange = [editor.range]; + } + } + visitedPaths.add(visibleFile.uri); + }); + } + + // //get active editor + let file = this.fileList.get(activeEditor.uri); + if (!file) { + file = { + uri: activeEditor.uri, + lastVisibleRange: [activeEditor.range], + invisible: false, + isActive: true, + }; + this.fileList.set(activeEditor.uri, file); + } else { + if (visitedPaths.has(file.uri)) { + const idx = file.lastVisibleRange.findIndex((range) => isRangeEqual(range, activeEditor.range)); + if (idx === -1) { + file.lastVisibleRange = [activeEditor.range, ...file.lastVisibleRange]; + } + } else { + file.lastVisibleRange = [activeEditor.range]; + } + file.invisible = false; + file.isActive = true; + } + visitedPaths.add(file.uri); + + //set invisible flag for all files that are not in the current file list + Array.from(this.fileList.values()) + .filter(this.isOpenedFile) + .forEach((file) => { + if (!visitedPaths.has(file.uri)) { + file.invisible = true; + } + if (file.uri !== activeEditor.uri) { + file.isActive = false; + } + }); + } + private isOpenedFile(file: unknown): file is OpenedFile { + return (file as OpenedFile).uri !== undefined; + } + + /** + * Return All recently opened files by order. [recently opened, ..., oldest] without active file + * @returns return all recently opened files by order + */ + getAllFilesWithoutActive(): OpenedFile[] { + return Array.from(this.fileList.values()) + .filter(this.isOpenedFile) + .filter((f) => !f.isActive); + } +} diff --git a/clients/tabby-agent/src/config/default.ts b/clients/tabby-agent/src/config/default.ts index 885cbd9e5a95..0f17b22ea83e 100644 --- a/clients/tabby-agent/src/config/default.ts +++ b/clients/tabby-agent/src/config/default.ts @@ -38,6 +38,11 @@ export const defaultConfigData: ConfigData = { overlapLines: 1, }, }, + collectSnippetsFromRecentOpenedFiles: { + enabled: true, + maxOpenedFiles: 5, + maxCharsPerOpenedFiles: 500, + }, clipboard: { minChars: 3, maxChars: 2000, diff --git a/clients/tabby-agent/src/config/type.d.ts b/clients/tabby-agent/src/config/type.d.ts index 0526182a9e75..7bf1712df0fa 100644 --- a/clients/tabby-agent/src/config/type.d.ts +++ b/clients/tabby-agent/src/config/type.d.ts @@ -44,6 +44,13 @@ export type ConfigData = { overlapLines: number; }; }; + collectSnippetsFromRecentOpenedFiles: { + enabled: boolean; + //max number of opened files + maxOpenedFiles: number; + //chars size per each opened file + maxCharsPerOpenedFiles: number; + }; clipboard: { minChars: number; maxChars: number; diff --git a/clients/tabby-agent/src/protocol.ts b/clients/tabby-agent/src/protocol.ts index 8a56ba261b02..2bebfa3f61fa 100644 --- a/clients/tabby-agent/src/protocol.ts +++ b/clients/tabby-agent/src/protocol.ts @@ -526,6 +526,26 @@ export type ChatEditResolveCommand = LspCommand & { arguments: [ChatEditResolveParams]; }; +/** + * [Tabby] Did Change Active Editor Notification(➡️) + * + * This method is sent from the client to server when the active editor changed. + * + * + * - method: `tabby/editors/didChangeActiveEditor` + * - params: {@link OpenedFileParams} + * - result: void + */ +export namespace DidChangeActiveEditorNotification { + export const method = "tabby/editors/didChangeActiveEditor"; + export const messageDirection = MessageDirection.clientToServer; + export const type = new ProtocolNotificationType(method); +} +export type DidChangeActiveEditorParams = { + activeEditor: Location; + visibleEditors: Location[] | undefined; +}; + /** * [Tabby] GenerateCommitMessage Request(↩️) * diff --git a/clients/tabby-agent/src/server.ts b/clients/tabby-agent/src/server.ts index b6b5be713d41..4410938b3e0c 100644 --- a/clients/tabby-agent/src/server.ts +++ b/clients/tabby-agent/src/server.ts @@ -47,6 +47,7 @@ import { StatusProvider } from "./status"; import { CommandProvider } from "./command"; import { name as serverName, version as serverVersion } from "../package.json"; import "./utils/array"; +import { FileTracker } from "./codeSearch/fileTracker"; export class Server { private readonly logger = getLogger("TabbyLSP"); @@ -66,6 +67,7 @@ export class Server { private readonly gitContextProvider = new GitContextProvider(); private readonly recentlyChangedCodeSearch = new RecentlyChangedCodeSearch(this.configurations, this.documents); + private readonly fileTracker = new FileTracker(this.configurations); private readonly codeLensProvider = new CodeLensProvider(this.documents); private readonly completionProvider = new CompletionProvider( @@ -76,6 +78,7 @@ export class Server { this.anonymousUsageLogger, this.gitContextProvider, this.recentlyChangedCodeSearch, + this.fileTracker, ); private readonly chatFeature = new ChatFeature(this.tabbyApiClient); private readonly chatEditProvider = new ChatEditProvider(this.configurations, this.tabbyApiClient, this.documents); @@ -188,6 +191,7 @@ export class Server { this.commitMessageGenerator, this.statusProvider, this.commandProvider, + this.fileTracker, ].mapAsync((feature: Feature) => { return feature.initialize(this.connection, clientCapabilities, clientProvidedConfig); }); diff --git a/clients/vscode/src/InlineCompletionProvider.ts b/clients/vscode/src/InlineCompletionProvider.ts index eefd6bce1e6a..9b2bd7ecf259 100644 --- a/clients/vscode/src/InlineCompletionProvider.ts +++ b/clients/vscode/src/InlineCompletionProvider.ts @@ -96,6 +96,7 @@ export class InlineCompletionProvider extends EventEmitter implements InlineComp }; let request: Promise | undefined = undefined; try { + this.client.fileTrack.addingChangeEditor(window.activeTextEditor); request = this.client.languageClient.sendRequest(InlineCompletionRequest.method, params, token); this.ongoing = request; this.emit("didChangeLoading", true); diff --git a/clients/vscode/src/lsp/Client.ts b/clients/vscode/src/lsp/Client.ts index 6e44e6de54b9..d22f60b7912a 100644 --- a/clients/vscode/src/lsp/Client.ts +++ b/clients/vscode/src/lsp/Client.ts @@ -17,12 +17,14 @@ import { Config } from "../Config"; import { InlineCompletionProvider } from "../InlineCompletionProvider"; import { GitProvider } from "../git/GitProvider"; import { getLogger } from "../logger"; +import { FileTrackerFeature } from "./FileTrackFeature"; export class Client { private readonly logger = getLogger(""); readonly agent: AgentFeature; readonly chat: ChatFeature; readonly telemetry: TelemetryFeature; + readonly fileTrack: FileTrackerFeature; constructor( private readonly context: ExtensionContext, readonly languageClient: BaseLanguageClient, @@ -30,9 +32,11 @@ export class Client { this.agent = new AgentFeature(this.languageClient); this.chat = new ChatFeature(this.languageClient); this.telemetry = new TelemetryFeature(this.languageClient); + this.fileTrack = new FileTrackerFeature(this, this.context); this.languageClient.registerFeature(this.agent); this.languageClient.registerFeature(this.chat); this.languageClient.registerFeature(this.telemetry); + this.languageClient.registerFeature(this.fileTrack); this.languageClient.registerFeature(new DataStoreFeature(this.context, this.languageClient)); this.languageClient.registerFeature(new EditorOptionsFeature(this.languageClient)); this.languageClient.registerFeature(new LanguageSupportFeature(this.languageClient)); diff --git a/clients/vscode/src/lsp/FileTrackFeature.ts b/clients/vscode/src/lsp/FileTrackFeature.ts new file mode 100644 index 000000000000..a376d1c446d2 --- /dev/null +++ b/clients/vscode/src/lsp/FileTrackFeature.ts @@ -0,0 +1,59 @@ +import { DidChangeActiveEditorNotification, DidChangeActiveEditorParams } from "tabby-agent"; +import { Client } from "./Client"; +import { ExtensionContext, TextEditor, window } from "vscode"; +import { + DocumentSelector, + FeatureState, + InitializeParams, + ServerCapabilities, + StaticFeature, +} from "vscode-languageclient"; +import EventEmitter from "events"; +import { collectVisibleEditors } from "../windowUtils"; + +export class FileTrackerFeature extends EventEmitter implements StaticFeature { + constructor( + private readonly client: Client, + private readonly context: ExtensionContext, + ) { + super(); + } + fillInitializeParams?: ((params: InitializeParams) => void) | undefined; + fillClientCapabilities(): void { + //nothing + } + preInitialize?: + | ((capabilities: ServerCapabilities, documentSelector: DocumentSelector | undefined) => void) + | undefined; + initialize(): void { + this.context.subscriptions.push( + //when active text editor changes + window.onDidChangeActiveTextEditor(async (editor) => { + await this.addingChangeEditor(editor); + }), + ); + } + getState(): FeatureState { + throw new Error("Method not implemented."); + } + clear(): void { + throw new Error("Method not implemented."); + } + + async addingChangeEditor(editor: TextEditor | undefined) { + if (editor && editor.visibleRanges[0] && editor.document.fileName.startsWith("/")) { + const editorRange = editor.visibleRanges[0]; + const params: DidChangeActiveEditorParams = { + activeEditor: { + uri: editor.document.uri.toString(), + range: { + start: { line: editorRange.start.line, character: editorRange.start.character }, + end: { line: editorRange.end.line, character: editorRange.end.character }, + }, + }, + visibleEditors: collectVisibleEditors(true, editor), + }; + await this.client.languageClient.sendNotification(DidChangeActiveEditorNotification.method, params); + } + } +} diff --git a/clients/vscode/src/windowUtils.ts b/clients/vscode/src/windowUtils.ts new file mode 100644 index 000000000000..72721fc30706 --- /dev/null +++ b/clients/vscode/src/windowUtils.ts @@ -0,0 +1,67 @@ +import { TextEditor, window } from "vscode"; +import { Location } from "vscode-languageclient"; + +export function collectVisibleEditors(exceptActiveEditor = false, activeEditor?: TextEditor): Location[] { + let editors = window.visibleTextEditors + .filter((e) => e.document.fileName.startsWith("/")) + .map((editor) => { + if (!editor.visibleRanges[0]) { + return null; + } + return { + uri: editor.document.uri.toString(), + range: { + start: { + line: editor.visibleRanges[0].start.line, + character: editor.visibleRanges[0].start.character, + }, + end: { + line: editor.visibleRanges[0].end.line, + character: editor.visibleRanges[0].end.character, + }, + }, + } as Location; + }) + .filter((e): e is Location => e !== null) + .sort((a, b) => + a.uri === window.activeTextEditor?.document.uri.toString() + ? -1 + : b.uri === window.activeTextEditor?.document.uri.toString() + ? 1 + : 0, + ); + if (exceptActiveEditor) { + if (activeEditor && activeEditor.visibleRanges[0]) { + const range = activeEditor.visibleRanges[0]; + editors = editors.filter( + (e) => + e.uri !== activeEditor.document.uri.toString() || + e.range.start.line !== range.start.line || + e.range.start.character !== range.start.character || + e.range.end.line !== range.end.line || + e.range.end.character !== range.end.character, + ); + } + } + return editors; +} +export function collectActiveEditor(): Location | undefined { + const activeEditor = window.activeTextEditor; + //only return TextDocument editor + if (!activeEditor || !activeEditor.visibleRanges[0] || !activeEditor.document.fileName.startsWith("/")) { + return undefined; + } + return { + uri: activeEditor.document.uri.toString(), + range: { + start: { + line: activeEditor.visibleRanges[0].start.line, + character: activeEditor.visibleRanges[0].start.character, + }, + end: { + line: activeEditor.visibleRanges[0].end.line, + character: activeEditor.visibleRanges[0].end.character, + }, + }, + }; +}