diff options
Diffstat (limited to 'extension/src')
27 files changed, 3326 insertions, 0 deletions
diff --git a/extension/src/README.md b/extension/src/README.md new file mode 100644 index 00000000..bb10f5c8 --- /dev/null +++ b/extension/src/README.md @@ -0,0 +1,77 @@ +# Continue VS Code Extension README + +## How to get started with development + +1. Clone the `continue` repo + +2. Open a VS Code window with the `continue` repo + +3. Package and then start the FastAPI server by following instructions outlined in `package/server/README.md` + +4. Open the `extension` sub-directory of the repo in a second VS Code window + +5. Run `npm install` + +6. Run `npm run clientgen` + +7. Run `cd react-app` + +8. Run `npm run build` + +9. Run `cd ..` to return to `extension` directory + +10. Then run `npm run compile` + +7. Open `src/activate.ts` file (or any TypeScript file) + +7. Press `F5` on your keyboard to start `Run and Debug` mode + +8. `cmd+shift+p` to look at developer console and select Continue commands + +9. Every time you make changes to the code, you need to run `npm run compile` + +10. If you run into a "command not found" error, try running `npm run rebuild` and then `npm run compile` + +## Alternative: Install a packaged version + +You should always have a packaged version installed in VSCode, because when Continue is broken you'll want a stable version to help you debug. There are four key commands in the `package.json`: + +1. `npm run package` will create a .vsix file in the `build/` folder that can then be installed. It is this same file that you can share with others who want to try the extension. + +2. `npm run install-extension` will install the extension to VSCode. You should then see it in your installed extensions in the VSCode sidebar. + +3. `npm run uninstall` will uninstall the extension. You don't always have to do this thanks to the reinstall command, but can be useful when you want to do so manually. + +4. `npm run reinstall` will go through the entire process of uninstalling the existing installed extension, rebuilding, and then installing the new version. You shouldn't be doing this every time you make a change to the extension, but rather when there is some significant update that you would like to make available to yourself (or if you happen to be debugging something which is specific to the packaged extension). + +## Background + +- `src/bridge.ts`: connects this VS Code Extension to our Python backend that interacts with GPT-3 +- `src/debugPanel.ts`: contains the HTML for the full window on the right (used for investigation) +- `src/DebugViewProvider.ts`: contains the HTML for the bottom left panel +- `src/extension.ts`: entry point into the extension, where all of the commands / views are registered (activate function is what happens when you start extension) +- `media/main.js`: handles messages sent from the extension to the webview (bottom left) +- `media/debugPanel.js`: loaded by right window + +## Features + +- `List 10 things that might be wrong` button +- `Write a unit test to reproduce bug` button +- Highlight a code range + `Find Suspicious Code` button +- `Suggest Fix` button +- A fix suggestion shown to you + `Make Edit` button +- Write a docstring for the current function +- Ask a question about your codebase +- Move up / down to the closest suggestion + +## Commands + +- "Write a docstring for the current function" command (windows: `ctrl+alt+l`, mac: `shift+cmd+l`) +- "Open Debug Panel" command +- "Ask a question from input box" command (windows: `ctrl+alt+j`, mac: `shift+cmd+j`) +- "Open Captured Terminal" command +- "Ask a question from webview" command (what context is it given?) +- "Create Terminal" command ??? +- "Suggestion Down" command (windows: `shift+ctrl+down`, mac: `shift+ctrl+down`) +- "Suggestion Up" command (windows: `shift+ctrl+up`, mac: `shift+ctrl+up`) +- "Accept Suggestion" command (windows: `shift+ctrl+enter`, mac: `shift+ctrl+enter`) diff --git a/extension/src/activation/activate.ts b/extension/src/activation/activate.ts new file mode 100644 index 00000000..a0aa560b --- /dev/null +++ b/extension/src/activation/activate.ts @@ -0,0 +1,71 @@ +import * as vscode from "vscode"; +import { registerAllCommands } from "../commands"; +import { registerAllCodeLensProviders } from "../lang-server/codeLens"; +import { sendTelemetryEvent, TelemetryEvent } from "../telemetry"; +import { getExtensionUri } from "../util/vscode"; +import * as path from "path"; +// import { openCapturedTerminal } from "../terminal/terminalEmulator"; +import IdeProtocolClient from "../continueIdeClient"; +import { getContinueServerUrl } from "../bridge"; + +export let extensionContext: vscode.ExtensionContext | undefined = undefined; + +export let ideProtocolClient: IdeProtocolClient | undefined = undefined; + +export function activateExtension( + context: vscode.ExtensionContext, + showTutorial: boolean +) { + sendTelemetryEvent(TelemetryEvent.ExtensionActivated); + + registerAllCodeLensProviders(context); + registerAllCommands(context); + + let serverUrl = getContinueServerUrl(); + + ideProtocolClient = new IdeProtocolClient( + serverUrl.replace("http", "ws") + "/ide/ws", + context + ); + + if (showTutorial && false) { + Promise.all([ + vscode.workspace + .openTextDocument( + path.join(getExtensionUri().fsPath, "examples/python/sum.py") + ) + .then((document) => + vscode.window.showTextDocument(document, { + preview: false, + viewColumn: vscode.ViewColumn.One, + }) + ), + + vscode.workspace + .openTextDocument( + path.join(getExtensionUri().fsPath, "examples/python/main.py") + ) + .then((document) => + vscode.window + .showTextDocument(document, { + preview: false, + viewColumn: vscode.ViewColumn.One, + }) + .then((editor) => { + editor.revealRange( + new vscode.Range(0, 0, 0, 0), + vscode.TextEditorRevealType.InCenter + ); + }) + ), + ]).then(() => { + ideProtocolClient?.openNotebook(); + }); + } else { + // ideProtocolClient?.openNotebook().then(() => { + // // openCapturedTerminal(); + // }); + } + + extensionContext = context; +} diff --git a/extension/src/activation/environmentSetup.ts b/extension/src/activation/environmentSetup.ts new file mode 100644 index 00000000..4816b4b1 --- /dev/null +++ b/extension/src/activation/environmentSetup.ts @@ -0,0 +1,226 @@ +import { getExtensionUri } from "../util/vscode"; +const util = require("util"); +const exec = util.promisify(require("child_process").exec); +const { spawn } = require("child_process"); +import * as path from "path"; +import * as fs from "fs"; +import rebuild from "@electron/rebuild"; +import * as vscode from "vscode"; +import { getContinueServerUrl } from "../bridge"; +import fetch from "node-fetch"; + +async function runCommand(cmd: string): Promise<[string, string | undefined]> { + var stdout: any = ""; + var stderr: any = ""; + try { + var { stdout, stderr } = await exec(cmd); + } catch (e: any) { + stderr = e.stderr; + stdout = e.stdout; + } + if (stderr === "") { + stderr = undefined; + } + if (typeof stdout === "undefined") { + stdout = ""; + } + + return [stdout, stderr]; +} + +async function getPythonCmdAssumingInstalled() { + const [, stderr] = await runCommand("python3 --version"); + if (stderr) { + return "python"; + } + return "python3"; +} + +async function setupPythonEnv() { + console.log("Setting up python env for Continue extension..."); + // First check that python3 is installed + + var [stdout, stderr] = await runCommand("python3 --version"); + let pythonCmd = "python3"; + if (stderr) { + // If not, first see if python3 is aliased to python + var [stdout, stderr] = await runCommand("python --version"); + if ( + (typeof stderr === "undefined" || stderr === "") && + stdout.split(" ")[1][0] === "3" + ) { + // Python3 is aliased to python + pythonCmd = "python"; + } else { + // Python doesn't exist at all + console.log("Python3 not found, downloading..."); + await downloadPython3(); + } + } + let pipCmd = pythonCmd.endsWith("3") ? "pip3" : "pip"; + + let activateCmd = "source env/bin/activate"; + let pipUpgradeCmd = `${pipCmd} install --upgrade pip`; + if (process.platform == "win32") { + activateCmd = ".\\env\\Scripts\\activate"; + pipUpgradeCmd = `${pythonCmd} -m pip install --upgrade pip`; + } + + let command = `cd ${path.join( + getExtensionUri().fsPath, + "scripts" + )} && ${pythonCmd} -m venv env && ${activateCmd} && ${pipUpgradeCmd} && ${pipCmd} install -r requirements.txt`; + var [stdout, stderr] = await runCommand(command); + if (stderr) { + throw new Error(stderr); + } + console.log( + "Successfully set up python env at ", + getExtensionUri().fsPath + "/scripts/env" + ); + + await startContinuePythonServer(); +} + +function readEnvFile(path: string) { + if (!fs.existsSync(path)) { + return {}; + } + let envFile = fs.readFileSync(path, "utf8"); + + let env: { [key: string]: string } = {}; + envFile.split("\n").forEach((line) => { + let [key, value] = line.split("="); + if (typeof key === "undefined" || typeof value === "undefined") { + return; + } + env[key] = value.replace(/"/g, ""); + }); + return env; +} + +function writeEnvFile(path: string, key: string, value: string) { + if (!fs.existsSync(path)) { + fs.writeFileSync(path, `${key}="${value}"`); + return; + } + + let env = readEnvFile(path); + env[key] = value; + + let newEnvFile = ""; + for (let key in env) { + newEnvFile += `${key}="${env[key]}"\n`; + } + fs.writeFileSync(path, newEnvFile); +} + +export async function startContinuePythonServer() { + // Check vscode settings + let serverUrl = getContinueServerUrl(); + if (serverUrl !== "http://localhost:8000") { + return; + } + + let envFile = path.join(getExtensionUri().fsPath, "scripts", ".env"); + let openai_api_key: string | undefined = + readEnvFile(envFile)["OPENAI_API_KEY"]; + while (typeof openai_api_key === "undefined" || openai_api_key === "") { + openai_api_key = await vscode.window.showInputBox({ + prompt: "Enter your OpenAI API key", + placeHolder: "Enter your OpenAI API key", + }); + // Write to .env file + } + writeEnvFile(envFile, "OPENAI_API_KEY", openai_api_key); + + console.log("Starting Continue python server..."); + + // Check if already running by calling /health + try { + let response = await fetch(serverUrl + "/health"); + if (response.status === 200) { + console.log("Continue python server already running"); + return; + } + } catch (e) { + console.log("Error checking for existing server", e); + } + + let activateCmd = "source env/bin/activate"; + let pythonCmd = "python3"; + if (process.platform == "win32") { + activateCmd = ".\\env\\Scripts\\activate"; + pythonCmd = "python"; + } + + let command = `cd ${path.join( + getExtensionUri().fsPath, + "scripts" + )} && ${activateCmd} && cd .. && ${pythonCmd} -m scripts.run_continue_server`; + try { + // exec(command); + let child = spawn(command, { + shell: true, + }); + child.stdout.on("data", (data: any) => { + console.log(`stdout: ${data}`); + }); + child.stderr.on("data", (data: any) => { + console.log(`stderr: ${data}`); + }); + child.on("error", (error: any) => { + console.log(`error: ${error.message}`); + }); + } catch (e) { + console.log("Failed to start Continue python server", e); + } + // Sleep for 3 seconds to give the server time to start + await new Promise((resolve) => setTimeout(resolve, 3000)); + console.log("Successfully started Continue python server"); +} + +async function installNodeModules() { + console.log("Rebuilding node-pty for Continue extension..."); + await rebuild({ + buildPath: getExtensionUri().fsPath, // Folder containing node_modules + electronVersion: "19.1.8", + onlyModules: ["node-pty"], + }); + console.log("Successfully rebuilt node-pty"); +} + +export function isPythonEnvSetup(): boolean { + let pathToEnvCfg = getExtensionUri().fsPath + "/scripts/env/pyvenv.cfg"; + return fs.existsSync(path.join(pathToEnvCfg)); +} + +export async function setupExtensionEnvironment() { + console.log("Setting up environment for Continue extension..."); + await Promise.all([setupPythonEnv()]); +} + +export async function downloadPython3() { + // Download python3 and return the command to run it (python or python3) + let os = process.platform; + let command: string = ""; + let pythonCmd = "python3"; + if (os === "darwin") { + throw new Error("python3 not found"); + } else if (os === "linux") { + command = + "sudo apt update && upgrade && sudo apt install python3 python3-pip"; + } else if (os === "win32") { + command = + "wget -O python_installer.exe https://www.python.org/ftp/python/3.11.3/python-3.11.3-amd64.exe && python_installer.exe /quiet InstallAllUsers=1 PrependPath=1 Include_test=0"; + pythonCmd = "python"; + } + + var [stdout, stderr] = await runCommand(command); + if (stderr) { + throw new Error(stderr); + } + console.log("Successfully downloaded python3"); + + return pythonCmd; +} diff --git a/extension/src/activation/languageClient.ts b/extension/src/activation/languageClient.ts new file mode 100644 index 00000000..5b0bd612 --- /dev/null +++ b/extension/src/activation/languageClient.ts @@ -0,0 +1,115 @@ +/** + * If we wanted to run or use another language server from our extension, this is how we would do it. + */ + +import * as path from "path"; +import { workspace, ExtensionContext, extensions } from "vscode"; + +import { + DefinitionParams, + LanguageClient, + LanguageClientOptions, + ServerOptions, + StateChangeEvent, + TransportKind, + State, +} from "vscode-languageclient/node"; +import { getExtensionUri } from "../util/vscode"; + +let client: LanguageClient; + +export async function startLanguageClient(context: ExtensionContext) { + let pythonLS = startPythonLanguageServer(context); + pythonLS.start(); +} + +export async function makeRequest(method: string, param: any): Promise<any> { + if (!client) { + return; + } else if (client.state === State.Starting) { + return new Promise((resolve, reject) => { + let stateListener = client.onDidChangeState((e: StateChangeEvent) => { + if (e.newState === State.Running) { + stateListener.dispose(); + resolve(client.sendRequest(method, param)); + } else if (e.newState === State.Stopped) { + stateListener.dispose(); + reject(new Error("Language server stopped unexpectedly")); + } + }); + }); + } else { + return client.sendRequest(method, param); + } +} + +export function deactivate(): Thenable<void> | undefined { + if (!client) { + return undefined; + } + return client.stop(); +} + +function startPythonLanguageServer(context: ExtensionContext): LanguageClient { + let extensionPath = getExtensionUri().fsPath; + const command = `cd ${path.join( + extensionPath, + "scripts" + )} && source env/bin/activate.fish && python -m pyls`; + const serverOptions: ServerOptions = { + command: command, + args: ["-vv"], + }; + const clientOptions: LanguageClientOptions = { + documentSelector: ["python"], + synchronize: { + configurationSection: "pyls", + }, + }; + return new LanguageClient(command, serverOptions, clientOptions); +} + +async function startPylance(context: ExtensionContext) { + let pylance = extensions.getExtension("ms-python.vscode-pylance"); + await pylance?.activate(); + if (!pylance) { + return; + } + let { path: lsPath } = await pylance.exports.languageServerFolder(); + + // The server is implemented in node + let serverModule = context.asAbsolutePath(lsPath); + // The debug options for the server + // --inspect=6009: runs the server in Node's Inspector mode so VS Code can attach to the server for debugging + let debugOptions = { execArgv: ["--nolazy", "--inspect=6009"] }; + + // If the extension is launched in debug mode then the debug server options are used + // Otherwise the run options are used + let serverOptions: ServerOptions = { + run: { module: serverModule, transport: TransportKind.ipc }, + debug: { + module: serverModule, + transport: TransportKind.ipc, + options: debugOptions, + }, + }; + + // Options to control the language client + let clientOptions: LanguageClientOptions = { + // Register the server for plain text documents + documentSelector: [{ scheme: "file", language: "python" }], + synchronize: { + // Notify the server about file changes to '.clientrc files contained in the workspace + fileEvents: workspace.createFileSystemWatcher("**/.clientrc"), + }, + }; + + // Create the language client and start the client. + client = new LanguageClient( + "languageServerExample", + "Language Server Example", + serverOptions, + clientOptions + ); + return client; +} diff --git a/extension/src/bridge.ts b/extension/src/bridge.ts new file mode 100644 index 00000000..3e21a205 --- /dev/null +++ b/extension/src/bridge.ts @@ -0,0 +1,292 @@ +import fetch from "node-fetch"; +import * as path from "path"; +import * as vscode from "vscode"; +import { + Configuration, + DebugApi, + RangeInFile, + SerializedDebugContext, + UnittestApi, +} from "./client"; +import { convertSingleToDoubleQuoteJSON } from "./util/util"; +import { getExtensionUri } from "./util/vscode"; +const axios = require("axios").default; +const util = require("util"); +const exec = util.promisify(require("child_process").exec); + +const configuration = new Configuration({ + basePath: get_api_url(), + fetchApi: fetch as any, + middleware: [ + { + pre: async (context) => { + // If there is a SerializedDebugContext in the body, add the files for the filesystem + context.init.body; + + // Add the VS Code Machine Code Header + context.init.headers = { + ...context.init.headers, + "x-vsc-machine-id": vscode.env.machineId, + }; + }, + }, + ], +}); +export const debugApi = new DebugApi(configuration); +export const unittestApi = new UnittestApi(configuration); + +function get_python_path() { + return path.join(getExtensionUri().fsPath, ".."); +} + +export function get_api_url() { + let extensionUri = getExtensionUri(); + let configFile = path.join(extensionUri.fsPath, "config/config.json"); + let config = require(configFile); + + if (config.API_URL) { + return config.API_URL; + } + return "http://localhost:8000"; +} +const API_URL = get_api_url(); + +export function getContinueServerUrl() { + return ( + vscode.workspace.getConfiguration("continue").get<string>("serverUrl") || + "http://localhost:8000" + ); +} + +function build_python_command(cmd: string): string { + return `cd ${get_python_path()} && source env/bin/activate && ${cmd}`; +} + +function listToCmdLineArgs(list: string[]): string { + return list.map((el) => `"$(echo "${el}")"`).join(" "); +} + +export async function runPythonScript( + scriptName: string, + args: string[] +): Promise<any> { + // TODO: Need to make sure that the path to poetry is in the PATH and that it is installed in the first place. Realistically also need to install npm in some cases. + const command = `export PATH="$PATH:/opt/homebrew/bin" && cd ${path.join( + getExtensionUri().fsPath, + "scripts" + )} && source env/bin/activate && python3 ${scriptName} ${listToCmdLineArgs( + args + )}`; + + const { stdout, stderr } = await exec(command); + + try { + let jsonString = stdout.substring( + stdout.indexOf("{"), + stdout.lastIndexOf("}") + 1 + ); + jsonString = convertSingleToDoubleQuoteJSON(jsonString); + return JSON.parse(jsonString); + } catch (e) { + if (stderr) { + throw new Error(stderr); + } else { + throw new Error("Failed to parse JSON: " + e); + } + } +} + +function parseStdout( + stdout: string, + key: string, + until_end: boolean = false +): string { + const prompt = `${key}=`; + let lines = stdout.split("\n"); + + let value: string = ""; + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith(prompt)) { + if (until_end) { + return lines.slice(i).join("\n").substring(prompt.length); + } else { + return lines[i].substring(prompt.length); + } + } + } + return ""; +} + +export async function askQuestion( + question: string, + workspacePath: string +): Promise<{ answer: string; range: vscode.Range; filename: string }> { + const command = build_python_command( + `python3 ${path.join( + get_python_path(), + "ask.py" + )} ask ${workspacePath} "${question}"` + ); + + const { stdout, stderr } = await exec(command); + if (stderr) { + throw new Error(stderr); + } + // Use the output + const answer = parseStdout(stdout, "Answer"); + const filename = parseStdout(stdout, "Filename"); + const startLineno = parseInt(parseStdout(stdout, "Start lineno")); + const endLineno = parseInt(parseStdout(stdout, "End lineno")); + const range = new vscode.Range( + new vscode.Position(startLineno, 0), + new vscode.Position(endLineno, 0) + ); + if (answer && filename && startLineno && endLineno) { + return { answer, filename, range }; + } else { + throw new Error("Error: No answer found"); + } +} + +export async function apiRequest( + endpoint: string, + options: { + method?: string; + query?: { [key: string]: any }; + body?: { [key: string]: any }; + } +): Promise<any> { + let defaults = { + method: "GET", + query: {}, + body: {}, + }; + options = Object.assign(defaults, options); // Second takes over first + if (endpoint.startsWith("/")) endpoint = endpoint.substring(1); + console.log("API request: ", options.body); + + let resp; + try { + resp = await axios({ + method: options.method, + url: `${API_URL}/${endpoint}`, + data: options.body, + params: options.query, + headers: { + "x-vsc-machine-id": vscode.env.machineId, + }, + }); + } catch (err) { + console.log("Error: ", err); + throw err; + } + + return resp.data; +} + +// Write a docstring for the most specific function or class at the current line in the given file +export async function writeDocstringForFunction( + filename: string, + position: vscode.Position +): Promise<{ lineno: number; docstring: string }> { + let resp = await apiRequest("docstring/forline", { + query: { + filecontents: ( + await vscode.workspace.fs.readFile(vscode.Uri.file(filename)) + ).toString(), + lineno: position.line.toString(), + }, + }); + + const lineno = resp.lineno; + const docstring = resp.completion; + if (lineno && docstring) { + return { lineno, docstring }; + } else { + throw new Error("Error: No docstring returned"); + } +} + +export async function findSuspiciousCode( + ctx: SerializedDebugContext +): Promise<RangeInFile[]> { + if (!ctx.traceback) return []; + let files = await getFileContents( + getFilenamesFromPythonStacktrace(ctx.traceback) + ); + let resp = await debugApi.findSusCodeEndpointDebugFindPost({ + findBody: { + traceback: ctx.traceback, + description: ctx.description, + filesystem: files, + }, + }); + let ranges = resp.response; + if ( + ranges.length <= 1 && + ctx.traceback && + ctx.traceback.includes("AssertionError") + ) { + let parsed_traceback = + await debugApi.parseTracebackEndpointDebugParseTracebackGet({ + traceback: ctx.traceback, + }); + let last_frame = parsed_traceback.frames[0]; + if (!last_frame) return []; + ranges = ( + await runPythonScript("build_call_graph.py", [ + last_frame.filepath, + last_frame.lineno.toString(), + last_frame._function, + ]) + ).value; + } + + return ranges; +} + +export async function writeUnitTestForFunction( + filename: string, + position: vscode.Position +): Promise<string> { + let resp = await apiRequest("unittest/forline", { + method: "POST", + body: { + filecontents: ( + await vscode.workspace.fs.readFile(vscode.Uri.file(filename)) + ).toString(), + lineno: position.line, + userid: vscode.env.machineId, + }, + }); + + return resp.completion; +} + +async function getFileContents( + files: string[] +): Promise<{ [key: string]: string }> { + let contents = await Promise.all( + files.map(async (file: string) => { + return ( + await vscode.workspace.fs.readFile(vscode.Uri.file(file)) + ).toString(); + }) + ); + let fileContents: { [key: string]: string } = {}; + for (let i = 0; i < files.length; i++) { + fileContents[files[i]] = contents[i]; + } + return fileContents; +} + +function getFilenamesFromPythonStacktrace(traceback: string): string[] { + let filenames: string[] = []; + for (let line of traceback.split("\n")) { + let match = line.match(/File "(.*)", line/); + if (match) { + filenames.push(match[1]); + } + } + return filenames; +} diff --git a/extension/src/commands.ts b/extension/src/commands.ts new file mode 100644 index 00000000..18f08e31 --- /dev/null +++ b/extension/src/commands.ts @@ -0,0 +1,223 @@ +import * as vscode from "vscode"; +import { + decorationManager, + showAnswerInTextEditor, + showGutterSpinner, + writeAndShowUnitTest, +} from "./decorations"; +import { + acceptSuggestionCommand, + rejectSuggestionCommand, + suggestionDownCommand, + suggestionUpCommand, +} from "./suggestions"; +import * as bridge from "./bridge"; +import { debugPanelWebview, setupDebugPanel } from "./debugPanel"; +// import { openCapturedTerminal } from "./terminal/terminalEmulator"; +import { getRightViewColumn } from "./util/vscode"; +import { + findSuspiciousCode, + runPythonScript, + writeUnitTestForFunction, +} from "./bridge"; +import { sendTelemetryEvent, TelemetryEvent } from "./telemetry"; +import { getLanguageLibrary } from "./languages"; +import { SerializedDebugContext } from "./client"; +import { addFileSystemToDebugContext } from "./util/util"; +import { ideProtocolClient } from "./activation/activate"; + +// Copy everything over from extension.ts +const commandsMap: { [command: string]: (...args: any) => any } = { + "continue.askQuestion": (data: any, webviewView: vscode.WebviewView) => { + if (!vscode.workspace.workspaceFolders) { + return; + } + + answerQuestion( + data.question, + vscode.workspace.workspaceFolders[0].uri.fsPath, + webviewView.webview + ); + }, + "continue.askQuestionFromInput": () => { + vscode.window + .showInputBox({ placeHolder: "Ask away!" }) + .then((question) => { + if (!question || !vscode.workspace.workspaceFolders) { + return; + } + + sendTelemetryEvent(TelemetryEvent.UniversalPromptQuery, { + query: question, + }); + + answerQuestion( + question, + vscode.workspace.workspaceFolders[0].uri.fsPath + ); + }); + }, + "continue.suggestionDown": suggestionDownCommand, + "continue.suggestionUp": suggestionUpCommand, + "continue.acceptSuggestion": acceptSuggestionCommand, + "continue.rejectSuggestion": rejectSuggestionCommand, + "continue.openDebugPanel": () => { + ideProtocolClient?.openNotebook(); + }, + "continue.focusContinueInput": async () => { + if (!debugPanelWebview) { + await ideProtocolClient?.openNotebook(); + } + debugPanelWebview?.postMessage({ + type: "focusContinueInput", + }); + }, + "continue.openCapturedTerminal": () => { + // Happens in webview resolution function + // openCapturedTerminal(); + }, + "continue.findSuspiciousCode": async ( + debugContext: SerializedDebugContext + ) => { + vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Finding suspicious code", + cancellable: false, + }, + async (progress, token) => { + let suspiciousCode = await findSuspiciousCode(debugContext); + debugContext.rangesInFiles = suspiciousCode; + let { filesystem } = addFileSystemToDebugContext(debugContext); + debugPanelWebview?.postMessage({ + type: "findSuspiciousCode", + codeLocations: suspiciousCode, + filesystem, + }); + } + ); + }, + "continue.debugTest": async (fileAndFunctionSpecifier: string) => { + sendTelemetryEvent(TelemetryEvent.AutoDebugThisTest); + let editor = vscode.window.activeTextEditor; + if (editor) editor.document.save(); + let { stdout } = await runPythonScript("run_unit_test.py", [ + fileAndFunctionSpecifier, + ]); + let traceback = getLanguageLibrary( + fileAndFunctionSpecifier.split("::")[0] + ).parseFirstStacktrace(stdout); + if (!traceback) { + vscode.window.showInformationMessage("The test passes!"); + return; + } + vscode.commands.executeCommand("continue.openDebugPanel").then(() => { + setTimeout(() => { + debugPanelWebview?.postMessage({ + type: "traceback", + value: traceback, + }); + }, 500); + }); + }, +}; + +const textEditorCommandsMap: { [command: string]: (...args: any) => {} } = { + "continue.writeUnitTest": async (editor: vscode.TextEditor) => { + let position = editor.selection.active; + + let gutterSpinnerKey = showGutterSpinner(editor, position.line); + try { + let test = await writeUnitTestForFunction( + editor.document.fileName, + position + ); + writeAndShowUnitTest(editor.document.fileName, test); + } catch { + } finally { + decorationManager.deleteDecoration(gutterSpinnerKey); + } + }, + "continue.writeDocstring": async (editor: vscode.TextEditor, _) => { + sendTelemetryEvent(TelemetryEvent.GenerateDocstring); + let gutterSpinnerKey = showGutterSpinner( + editor, + editor.selection.active.line + ); + + const { lineno, docstring } = await bridge.writeDocstringForFunction( + editor.document.fileName, + editor.selection.active + ); + // Can't use the edit given above after an async call + editor.edit((edit) => { + edit.insert(new vscode.Position(lineno, 0), docstring); + decorationManager.deleteDecoration(gutterSpinnerKey); + }); + }, +}; + +export function registerAllCommands(context: vscode.ExtensionContext) { + for (const [command, callback] of Object.entries(commandsMap)) { + context.subscriptions.push( + vscode.commands.registerCommand(command, callback) + ); + } + + for (const [command, callback] of Object.entries(textEditorCommandsMap)) { + context.subscriptions.push( + vscode.commands.registerTextEditorCommand(command, callback) + ); + } +} + +async function answerQuestion( + question: string, + workspacePath: string, + webview: vscode.Webview | undefined = undefined +) { + vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Anwering question...", + cancellable: false, + }, + async (progress, token) => { + try { + let resp = await bridge.askQuestion(question, workspacePath); + // Send the answer back to the webview + if (webview) { + webview.postMessage({ + type: "answerQuestion", + answer: resp.answer, + }); + } + showAnswerInTextEditor(resp.filename, resp.range, resp.answer); + } catch (error: any) { + if (webview) { + webview.postMessage({ + type: "answerQuestion", + answer: error, + }); + } + } + } + ); +} + +// async function suggestFixForAllWorkspaceProblems() { +// Something like this, just figure out the loops for diagnostics vs problems +// let problems = vscode.languages.getDiagnostics(); +// let codeSuggestions = await Promise.all(problems.map((problem) => { +// return bridge.suggestFixForProblem(problem[0].fsPath, problem[1]); +// })); +// for (const [uri, diagnostics] of problems) { +// for (let i = 0; i < diagnostics.length; i++) { +// let diagnostic = diagnostics[i]; +// let suggestedCode = codeSuggestions[i]; +// // If you're going to do this for a bunch of files at once, it will show the unsaved icon in the tab +// // BUT it would be better to have a single window to review all edits +// showSuggestion(uri.fsPath, diagnostic.range, suggestedCode) +// } +// } +// } diff --git a/extension/src/continueIdeClient.ts b/extension/src/continueIdeClient.ts new file mode 100644 index 00000000..6c65415f --- /dev/null +++ b/extension/src/continueIdeClient.ts @@ -0,0 +1,338 @@ +// import { ShowSuggestionRequest } from "../schema/ShowSuggestionRequest"; +import { showSuggestion, SuggestionRanges } from "./suggestions"; +import { openEditorAndRevealRange, getRightViewColumn } from "./util/vscode"; +import { FileEdit } from "../schema/FileEdit"; +import { RangeInFile } from "../schema/RangeInFile"; +import * as vscode from "vscode"; +import { + acceptSuggestionCommand, + rejectSuggestionCommand, +} from "./suggestions"; +import { debugPanelWebview, setupDebugPanel } from "./debugPanel"; +import { FileEditWithFullContents } from "../schema/FileEditWithFullContents"; +const util = require("util"); +const exec = util.promisify(require("child_process").exec); +const WebSocket = require("ws"); +import fs = require("fs"); + +class IdeProtocolClient { + private _ws: WebSocket | null = null; + private _panels: Map<string, vscode.WebviewPanel> = new Map(); + private readonly _serverUrl: string; + private readonly _context: vscode.ExtensionContext; + + private _makingEdit = 0; + + constructor(serverUrl: string, context: vscode.ExtensionContext) { + this._context = context; + this._serverUrl = serverUrl; + let ws = new WebSocket(serverUrl); + this._ws = ws; + ws.onclose = () => { + this._ws = null; + }; + ws.on("message", (data: any) => { + this.handleMessage(JSON.parse(data)); + }); + // Setup listeners for any file changes in open editors + vscode.workspace.onDidChangeTextDocument((event) => { + if (this._makingEdit === 0) { + let fileEdits: FileEditWithFullContents[] = event.contentChanges.map( + (change) => { + return { + fileEdit: { + filepath: event.document.uri.fsPath, + range: { + start: { + line: change.range.start.line, + character: change.range.start.character, + }, + end: { + line: change.range.end.line, + character: change.range.end.character, + }, + }, + replacement: change.text, + }, + fileContents: event.document.getText(), + }; + } + ); + this.send("fileEdits", { fileEdits }); + } else { + this._makingEdit--; + } + }); + } + + async isConnected() { + if (this._ws === null) { + this._ws = new WebSocket(this._serverUrl); + } + // On open, return a promise + if (this._ws!.readyState === WebSocket.OPEN) { + return; + } + return new Promise((resolve, reject) => { + this._ws!.onopen = () => { + resolve(null); + }; + }); + } + + async startCore() { + var { stdout, stderr } = await exec( + "cd /Users/natesesti/Desktop/continue/continue && poetry shell" + ); + if (stderr) { + throw new Error(stderr); + } + var { stdout, stderr } = await exec( + "cd .. && uvicorn continue.src.server.main:app --reload --reload-dir continue" + ); + if (stderr) { + throw new Error(stderr); + } + var { stdout, stderr } = await exec("python3 -m continue.src.libs.ide"); + if (stderr) { + throw new Error(stderr); + } + } + + async send(messageType: string, data: object) { + await this.isConnected(); + let msg = JSON.stringify({ messageType, ...data }); + this._ws!.send(msg); + console.log("Sent message", msg); + } + + async receiveMessage(messageType: string): Promise<any> { + await this.isConnected(); + console.log("Connected to websocket"); + return await new Promise((resolve, reject) => { + if (!this._ws) { + reject("Not connected to websocket"); + } + this._ws!.onmessage = (event: any) => { + let message = JSON.parse(event.data); + console.log("RECEIVED MESSAGE", message); + if (message.messageType === messageType) { + resolve(message); + } + }; + }); + } + + async sendAndReceive(message: any, messageType: string): Promise<any> { + try { + await this.send(messageType, message); + let msg = await this.receiveMessage(messageType); + console.log("Received message", msg); + return msg; + } catch (e) { + console.log("Error sending message", e); + } + } + + async handleMessage(message: any) { + switch (message.messageType) { + case "highlightedCode": + this.send("highlightedCode", { + highlightedCode: this.getHighlightedCode(), + }); + break; + case "workspaceDirectory": + this.send("workspaceDirectory", { + workspaceDirectory: this.getWorkspaceDirectory(), + }); + case "openFiles": + this.send("openFiles", { + openFiles: this.getOpenFiles(), + }); + break; + case "readFile": + this.send("readFile", { + contents: this.readFile(message.filepath), + }); + break; + case "editFile": + let fileEdit = await this.editFile(message.edit); + this.send("editFile", { + fileEdit, + }); + break; + case "saveFile": + this.saveFile(message.filepath); + break; + case "setFileOpen": + this.openFile(message.filepath); + // TODO: Close file + break; + case "openNotebook": + case "connected": + break; + default: + throw Error("Unknown message type:" + message.messageType); + } + } + getWorkspaceDirectory() { + return vscode.workspace.workspaceFolders![0].uri.fsPath; + } + + // ------------------------------------ // + // On message handlers + + showSuggestion(edit: FileEdit) { + // showSuggestion already exists + showSuggestion( + edit.filepath, + new vscode.Range( + edit.range.start.line, + edit.range.start.character, + edit.range.end.line, + edit.range.end.character + ), + edit.replacement + ); + } + + openFile(filepath: string) { + // vscode has a builtin open/get open files + openEditorAndRevealRange(filepath, undefined, vscode.ViewColumn.One); + } + + // ------------------------------------ // + // Initiate Request + + closeNotebook(sessionId: string) { + this._panels.get(sessionId)?.dispose(); + this._panels.delete(sessionId); + } + + async openNotebook() { + console.log("OPENING NOTEBOOK"); + let resp = await this.sendAndReceive({}, "openNotebook"); + let sessionId = resp.sessionId; + console.log("SESSION ID", sessionId); + + let column = getRightViewColumn(); + const panel = vscode.window.createWebviewPanel( + "continue.debugPanelView", + "Continue", + column, + { + enableScripts: true, + retainContextWhenHidden: true, + } + ); + + // And set its HTML content + panel.webview.html = setupDebugPanel(panel, this._context, sessionId); + + this._panels.set(sessionId, panel); + } + + acceptRejectSuggestion(accept: boolean, key: SuggestionRanges) { + if (accept) { + acceptSuggestionCommand(key); + } else { + rejectSuggestionCommand(key); + } + } + + // ------------------------------------ // + // Respond to request + + getOpenFiles(): string[] { + return vscode.window.visibleTextEditors + .filter((editor) => { + return !( + editor.document.uri.fsPath.endsWith("/1") || + (editor.document.languageId === "plaintext" && + editor.document.getText() === + "accessible-buffer-accessible-buffer-") + ); + }) + .map((editor) => { + return editor.document.uri.fsPath; + }); + } + + saveFile(filepath: string) { + vscode.window.visibleTextEditors.forEach((editor) => { + if (editor.document.uri.fsPath === filepath) { + editor.document.save(); + } + }); + } + + readFile(filepath: string): string { + let contents: string | undefined; + vscode.window.visibleTextEditors.forEach((editor) => { + if (editor.document.uri.fsPath === filepath) { + contents = editor.document.getText(); + } + }); + if (!contents) { + contents = fs.readFileSync(filepath, "utf-8"); + } + return contents; + } + + editFile(edit: FileEdit): Promise<FileEditWithFullContents> { + return new Promise((resolve, reject) => { + openEditorAndRevealRange( + edit.filepath, + undefined, + vscode.ViewColumn.One + ).then((editor) => { + let range = new vscode.Range( + edit.range.start.line, + edit.range.start.character, + edit.range.end.line, + edit.range.end.character + 1 + ); + editor.edit((editBuilder) => { + this._makingEdit += 2; // editBuilder.replace takes 2 edits: delete and insert + editBuilder.replace(range, edit.replacement); + resolve({ + fileEdit: edit, + fileContents: editor.document.getText(), + }); + }); + }); + }); + } + + getHighlightedCode(): RangeInFile[] { + // TODO + let rangeInFiles: RangeInFile[] = []; + vscode.window.visibleTextEditors.forEach((editor) => { + editor.selections.forEach((selection) => { + if (!selection.isEmpty) { + rangeInFiles.push({ + filepath: editor.document.uri.fsPath, + range: { + start: { + line: selection.start.line, + character: selection.start.character, + }, + end: { + line: selection.end.line, + character: selection.end.character, + }, + }, + }); + } + }); + }); + return rangeInFiles; + } + + runCommand(command: string) { + vscode.window.terminals[0].sendText(command, true); + // But need to know when it's done executing... + } +} + +export default IdeProtocolClient; diff --git a/extension/src/debugPanel.ts b/extension/src/debugPanel.ts new file mode 100644 index 00000000..66829836 --- /dev/null +++ b/extension/src/debugPanel.ts @@ -0,0 +1,378 @@ +import * as vscode from "vscode"; +import { + debugApi, + getContinueServerUrl, + runPythonScript, + unittestApi, +} from "./bridge"; +import { writeAndShowUnitTest } from "./decorations"; +import { showSuggestion } from "./suggestions"; +import { getLanguageLibrary } from "./languages"; +import { + getExtensionUri, + getNonce, + openEditorAndRevealRange, +} from "./util/vscode"; +import { sendTelemetryEvent, TelemetryEvent } from "./telemetry"; +import { RangeInFile, SerializedDebugContext } from "./client"; +import { addFileSystemToDebugContext } from "./util/util"; + +class StreamManager { + private _fullText: string = ""; + private _insertionPoint: vscode.Position | undefined; + + private _addToEditor(update: string) { + let editor = + vscode.window.activeTextEditor || vscode.window.visibleTextEditors[0]; + + if (typeof this._insertionPoint === "undefined") { + if (editor?.selection.isEmpty) { + this._insertionPoint = editor?.selection.active; + } else { + this._insertionPoint = editor?.selection.end; + } + } + editor?.edit((editBuilder) => { + if (this._insertionPoint) { + editBuilder.insert(this._insertionPoint, update); + this._insertionPoint = this._insertionPoint.translate( + Array.from(update.matchAll(/\n/g)).length, + update.length + ); + } + }); + } + + public closeStream() { + this._fullText = ""; + this._insertionPoint = undefined; + this._codeBlockStatus = "closed"; + this._pendingBackticks = 0; + } + + private _codeBlockStatus: "open" | "closed" | "language-descriptor" = + "closed"; + private _pendingBackticks: number = 0; + public onStreamUpdate(update: string) { + let textToInsert = ""; + for (let i = 0; i < update.length; i++) { + switch (this._codeBlockStatus) { + case "closed": + if (update[i] === "`" && this._fullText.endsWith("``")) { + this._codeBlockStatus = "language-descriptor"; + } + break; + case "language-descriptor": + if (update[i] === " " || update[i] === "\n") { + this._codeBlockStatus = "open"; + } + break; + case "open": + if (update[i] === "`") { + if (this._fullText.endsWith("``")) { + this._codeBlockStatus = "closed"; + this._pendingBackticks = 0; + } else { + this._pendingBackticks += 1; + } + } else { + textToInsert += "`".repeat(this._pendingBackticks) + update[i]; + this._pendingBackticks = 0; + } + break; + } + this._fullText += update[i]; + } + this._addToEditor(textToInsert); + } +} + +let streamManager = new StreamManager(); + +export let debugPanelWebview: vscode.Webview | undefined; +export function setupDebugPanel( + panel: vscode.WebviewPanel, + context: vscode.ExtensionContext | undefined, + sessionId: string +): string { + debugPanelWebview = panel.webview; + panel.onDidDispose(() => { + debugPanelWebview = undefined; + }); + + let extensionUri = getExtensionUri(); + let scriptUri: string; + let styleMainUri: string; + + const isProduction = true; // context?.extensionMode === vscode.ExtensionMode.Development; + if (!isProduction) { + scriptUri = "http://localhost:5173/src/main.tsx"; + styleMainUri = "http://localhost:5173/src/main.css"; + } else { + scriptUri = debugPanelWebview + .asWebviewUri( + vscode.Uri.joinPath(extensionUri, "react-app/dist/assets/index.js") + ) + .toString(); + styleMainUri = debugPanelWebview + .asWebviewUri( + vscode.Uri.joinPath(extensionUri, "react-app/dist/assets/index.css") + ) + .toString(); + } + + const nonce = getNonce(); + + vscode.window.onDidChangeTextEditorSelection((e) => { + if (e.selections[0].isEmpty) { + return; + } + + let rangeInFile: RangeInFile = { + range: e.selections[0], + filepath: e.textEditor.document.fileName, + }; + let filesystem = { + [rangeInFile.filepath]: e.textEditor.document.getText(), + }; + panel.webview.postMessage({ + type: "highlightedCode", + rangeInFile, + filesystem, + }); + + panel.webview.postMessage({ + type: "workspacePath", + value: vscode.workspace.workspaceFolders?.[0].uri.fsPath, + }); + }); + + panel.webview.onDidReceiveMessage(async (data) => { + switch (data.type) { + case "onLoad": { + panel.webview.postMessage({ + type: "onLoad", + vscMachineId: vscode.env.machineId, + apiUrl: getContinueServerUrl(), + sessionId, + }); + break; + } + case "listTenThings": { + sendTelemetryEvent(TelemetryEvent.GenerateIdeas); + let resp = await debugApi.listtenDebugListPost({ + serializedDebugContext: data.debugContext, + }); + panel.webview.postMessage({ + type: "listTenThings", + value: resp.completion, + }); + break; + } + case "suggestFix": { + let completion: string; + let codeSelection = data.debugContext.rangesInFiles?.at(0); + if (codeSelection) { + completion = ( + await debugApi.inlineDebugInlinePost({ + inlineBody: { + filecontents: await vscode.workspace.fs + .readFile(vscode.Uri.file(codeSelection.filepath)) + .toString(), + startline: codeSelection.range.start.line, + endline: codeSelection.range.end.line, + traceback: data.debugContext.traceback, + }, + }) + ).completion; + } else if (data.debugContext.traceback) { + completion = ( + await debugApi.suggestionDebugSuggestionGet({ + traceback: data.debugContext.traceback, + }) + ).completion; + } else { + break; + } + panel.webview.postMessage({ + type: "suggestFix", + value: completion, + }); + break; + } + case "findSuspiciousCode": { + let traceback = getLanguageLibrary(".py").parseFirstStacktrace( + data.debugContext.traceback + ); + if (traceback === undefined) return; + vscode.commands.executeCommand( + "continue.findSuspiciousCode", + data.debugContext + ); + break; + } + case "queryEmbeddings": { + let { results } = await runPythonScript("index.py query", [ + data.query, + 2, + vscode.workspace.workspaceFolders?.[0].uri.fsPath, + ]); + panel.webview.postMessage({ + type: "queryEmbeddings", + results, + }); + break; + } + case "openFile": { + openEditorAndRevealRange(data.path, undefined, vscode.ViewColumn.One); + break; + } + case "streamUpdate": { + // Write code at the position of the cursor + streamManager.onStreamUpdate(data.update); + break; + } + case "closeStream": { + streamManager.closeStream(); + break; + } + case "explainCode": { + sendTelemetryEvent(TelemetryEvent.ExplainCode); + let debugContext: SerializedDebugContext = addFileSystemToDebugContext( + data.debugContext + ); + let resp = await debugApi.explainDebugExplainPost({ + serializedDebugContext: debugContext, + }); + panel.webview.postMessage({ + type: "explainCode", + value: resp.completion, + }); + break; + } + case "withProgress": { + // This message allows withProgress to be used in the webview + if (data.done) { + // Will be caught in the listener created below + break; + } + let title = data.title; + vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title, + cancellable: false, + }, + async () => { + return new Promise<void>((resolve, reject) => { + let listener = panel.webview.onDidReceiveMessage(async (data) => { + if ( + data.type === "withProgress" && + data.done && + data.title === title + ) { + listener.dispose(); + resolve(); + } + }); + }); + } + ); + break; + } + case "makeEdit": { + sendTelemetryEvent(TelemetryEvent.SuggestFix); + let suggestedEdits = data.edits; + + if ( + typeof suggestedEdits === "undefined" || + suggestedEdits.length === 0 + ) { + vscode.window.showInformationMessage( + "Continue couldn't find a fix for this error." + ); + return; + } + + for (let i = 0; i < suggestedEdits.length; i++) { + let edit = suggestedEdits[i]; + await showSuggestion( + edit.filepath, + new vscode.Range( + edit.range.start.line, + edit.range.start.character, + edit.range.end.line, + edit.range.end.character + ), + edit.replacement + ); + } + break; + } + case "generateUnitTest": { + sendTelemetryEvent(TelemetryEvent.CreateTest); + vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Generating Unit Test...", + cancellable: false, + }, + async () => { + for (let i = 0; i < data.debugContext.rangesInFiles?.length; i++) { + let codeSelection = data.debugContext.rangesInFiles?.at(i); + if ( + codeSelection && + codeSelection.filepath && + codeSelection.range + ) { + try { + let filecontents = ( + await vscode.workspace.fs.readFile( + vscode.Uri.file(codeSelection.filepath) + ) + ).toString(); + let resp = + await unittestApi.failingtestUnittestFailingtestPost({ + failingTestBody: { + fp: { + filecontents, + lineno: codeSelection.range.end.line, + }, + description: data.debugContext.description || "", + }, + }); + + if (resp.completion) { + let decorationKey = await writeAndShowUnitTest( + codeSelection.filepath, + resp.completion + ); + break; + } + } catch {} + } + } + } + ); + + break; + } + } + }); + + return `<!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <script>const vscode = acquireVsCodeApi();</script> + <link href="${styleMainUri}" rel="stylesheet"> + + <title>Continue</title> + </head> + <body> + <div id="root"></div> + <script type="module" nonce="${nonce}" src="${scriptUri}"></script> + </body> + </html>`; +} diff --git a/extension/src/decorations.ts b/extension/src/decorations.ts new file mode 100644 index 00000000..456f0c10 --- /dev/null +++ b/extension/src/decorations.ts @@ -0,0 +1,313 @@ +import * as vscode from "vscode"; +import { getRightViewColumn, getTestFile } from "./util/vscode"; +import * as path from "path"; +import { getLanguageLibrary } from "./languages"; + +export function showAnswerInTextEditor( + filename: string, + range: vscode.Range, + answer: string +) { + vscode.workspace.openTextDocument(vscode.Uri.file(filename)).then((doc) => { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return; + } + + // Open file, reveal range, show decoration + vscode.window.showTextDocument(doc).then((new_editor) => { + new_editor.revealRange( + new vscode.Range(range.end, range.end), + vscode.TextEditorRevealType.InCenter + ); + + let decorationType = vscode.window.createTextEditorDecorationType({ + after: { + contentText: answer + "\n", + color: "rgb(0, 255, 0, 0.8)", + }, + backgroundColor: "rgb(0, 255, 0, 0.2)", + }); + new_editor.setDecorations(decorationType, [range]); + vscode.window.showInformationMessage("Answer found!"); + + // Remove decoration when user moves cursor + vscode.window.onDidChangeTextEditorSelection((e) => { + if ( + e.textEditor === new_editor && + e.selections[0].active.line !== range.end.line + ) { + new_editor.setDecorations(decorationType, []); + } + }); + }); + }); +} + +type DecorationKey = { + editorUri: string; + options: vscode.DecorationOptions; + decorationType: vscode.TextEditorDecorationType; +}; + +class DecorationManager { + private editorToDecorations = new Map< + string, + Map<vscode.TextEditorDecorationType, vscode.DecorationOptions[]> + >(); + + constructor() { + vscode.window.onDidChangeVisibleTextEditors((editors) => { + for (const editor of editors) { + if (editor.document.isClosed) { + this.editorToDecorations.delete(editor.document.uri.toString()); + } + } + }); + } + + private rerenderDecorations( + editorUri: string, + decorationType: vscode.TextEditorDecorationType + ) { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return; + } + + const decorationTypes = this.editorToDecorations.get(editorUri); + if (!decorationTypes) { + return; + } + + const decorations = decorationTypes.get(decorationType); + if (!decorations) { + return; + } + + editor.setDecorations(decorationType, decorations); + } + + addDecoration(key: DecorationKey) { + let decorationTypes = this.editorToDecorations.get(key.editorUri); + if (!decorationTypes) { + decorationTypes = new Map(); + decorationTypes.set(key.decorationType, [key.options]); + this.editorToDecorations.set(key.editorUri, decorationTypes); + } + + const decorations = decorationTypes.get(key.decorationType); + if (!decorations) { + decorationTypes.set(key.decorationType, [key.options]); + } else { + decorations.push(key.options); + } + this.rerenderDecorations(key.editorUri, key.decorationType); + } + + deleteDecoration(key: DecorationKey) { + let decorationTypes = this.editorToDecorations.get(key.editorUri); + if (!decorationTypes) { + return; + } + + let decorations = decorationTypes?.get(key.decorationType); + if (!decorations) { + return; + } + + decorations = decorations.filter((decOpts) => decOpts !== key.options); + decorationTypes.set(key.decorationType, decorations); + this.rerenderDecorations(key.editorUri, key.decorationType); + } + + deleteAllDecorations(editorUri: string) { + let decorationTypes = this.editorToDecorations.get(editorUri)?.keys(); + if (!decorationTypes) { + return; + } + this.editorToDecorations.delete(editorUri); + for (let decorationType of decorationTypes) { + this.rerenderDecorations(editorUri, decorationType); + } + } +} + +export const decorationManager = new DecorationManager(); + +function constructBaseKey( + editor: vscode.TextEditor, + lineno: number, + decorationType?: vscode.TextEditorDecorationType +): DecorationKey { + return { + editorUri: editor.document.uri.toString(), + options: { + range: new vscode.Range(lineno, 0, lineno, 0), + }, + decorationType: + decorationType || vscode.window.createTextEditorDecorationType({}), + }; +} + +const gutterSpinnerDecorationType = + vscode.window.createTextEditorDecorationType({ + gutterIconPath: vscode.Uri.file( + path.join(__dirname, "..", "media", "spinner.gif") + ), + gutterIconSize: "contain", + }); + +export function showGutterSpinner( + editor: vscode.TextEditor, + lineno: number +): DecorationKey { + const key = constructBaseKey(editor, lineno, gutterSpinnerDecorationType); + decorationManager.addDecoration(key); + + return key; +} + +export function showLintMessage( + editor: vscode.TextEditor, + lineno: number, + msg: string +): DecorationKey { + const key = constructBaseKey(editor, lineno); + key.decorationType = vscode.window.createTextEditorDecorationType({ + after: { + contentText: "Linting error", + color: "rgb(255, 0, 0, 0.6)", + }, + gutterIconPath: vscode.Uri.file( + path.join(__dirname, "..", "media", "error.png") + ), + gutterIconSize: "contain", + }); + key.options.hoverMessage = msg; + decorationManager.addDecoration(key); + return key; +} + +export function highlightCode( + editor: vscode.TextEditor, + range: vscode.Range, + removeOnClick: boolean = true +): DecorationKey { + const decorationType = vscode.window.createTextEditorDecorationType({ + backgroundColor: "rgb(255, 255, 0, 0.1)", + }); + const key = { + editorUri: editor.document.uri.toString(), + options: { + range, + }, + decorationType, + }; + decorationManager.addDecoration(key); + + if (removeOnClick) { + vscode.window.onDidChangeTextEditorSelection((e) => { + if (e.textEditor === editor) { + decorationManager.deleteDecoration(key); + } + }); + } + + return key; +} + +// Show unit test +const pythonImportDistinguisher = (line: string): boolean => { + if (line.startsWith("from") || line.startsWith("import")) { + return true; + } + return false; +}; +const javascriptImportDistinguisher = (line: string): boolean => { + if (line.startsWith("import")) { + return true; + } + return false; +}; +const importDistinguishersMap: { + [fileExtension: string]: (line: string) => boolean; +} = { + js: javascriptImportDistinguisher, + ts: javascriptImportDistinguisher, + py: pythonImportDistinguisher, +}; +function getImportsFromFileString( + fileString: string, + importDistinguisher: (line: string) => boolean +): Set<string> { + let importLines = new Set<string>(); + for (let line of fileString.split("\n")) { + if (importDistinguisher(line)) { + importLines.add(line); + } + } + return importLines; +} +function removeRedundantLinesFrom( + fileContents: string, + linesToRemove: Set<string> +): string { + let fileLines = fileContents.split("\n"); + fileLines = fileLines.filter((line: string) => { + return !linesToRemove.has(line); + }); + return fileLines.join("\n"); +} + +export async function writeAndShowUnitTest( + filename: string, + test: string +): Promise<DecorationKey> { + return new Promise((resolve, reject) => { + let testFilename = getTestFile(filename, true); + vscode.workspace.openTextDocument(testFilename).then((doc) => { + let fileContent = doc.getText(); + let fileEmpty = fileContent.trim() === ""; + let existingImportLines = getImportsFromFileString( + fileContent, + importDistinguishersMap[doc.fileName.split(".").at(-1) || ".py"] + ); + + // Remove redundant imports, make sure pytest is there + test = removeRedundantLinesFrom(test, existingImportLines); + test = + (fileEmpty + ? `${getLanguageLibrary(".py").writeImport( + testFilename, + filename + )}\nimport pytest\n\n` + : "\n\n") + + test.trim() + + "\n"; + + vscode.window + .showTextDocument(doc, getRightViewColumn()) + .then((editor) => { + let lastLine = editor.document.lineAt(editor.document.lineCount - 1); + let testRange = new vscode.Range( + lastLine.range.end, + new vscode.Position( + test.split("\n").length + lastLine.range.end.line, + 0 + ) + ); + editor + .edit((edit) => { + edit.insert(lastLine.range.end, test); + return true; + }) + .then((success) => { + if (!success) reject("Failed to insert test"); + let key = highlightCode(editor, testRange); + resolve(key); + }); + }); + }); + }); +} diff --git a/extension/src/extension.ts b/extension/src/extension.ts new file mode 100644 index 00000000..e0b94278 --- /dev/null +++ b/extension/src/extension.ts @@ -0,0 +1,37 @@ +/** + * This is the entry point for the extension. + */ + +import * as vscode from "vscode"; +import { + setupExtensionEnvironment, + isPythonEnvSetup, + startContinuePythonServer, +} from "./activation/environmentSetup"; + +async function dynamicImportAndActivate( + context: vscode.ExtensionContext, + showTutorial: boolean +) { + const { activateExtension } = await import("./activation/activate"); + activateExtension(context, showTutorial); +} + +export function activate(context: vscode.ExtensionContext) { + // Only show progress if we have to setup + vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Setting up Continue extension...", + cancellable: false, + }, + async () => { + if (isPythonEnvSetup()) { + await startContinuePythonServer(); + } else { + await setupExtensionEnvironment(); + } + dynamicImportAndActivate(context, true); + } + ); +} diff --git a/extension/src/lang-server/codeLens.ts b/extension/src/lang-server/codeLens.ts new file mode 100644 index 00000000..2a362b62 --- /dev/null +++ b/extension/src/lang-server/codeLens.ts @@ -0,0 +1,99 @@ +import * as vscode from "vscode"; +import { getLanguageLibrary } from "../languages"; +import { editorToSuggestions } from "../suggestions"; + +class SuggestionsCodeLensProvider implements vscode.CodeLensProvider { + public provideCodeLenses( + document: vscode.TextDocument, + token: vscode.CancellationToken + ): vscode.CodeLens[] | Thenable<vscode.CodeLens[]> { + let suggestions = editorToSuggestions.get(document.uri.toString()); + if (!suggestions) { + return []; + } + + let codeLenses: vscode.CodeLens[] = []; + for (let suggestion of suggestions) { + let range = new vscode.Range( + suggestion.oldRange.start, + suggestion.newRange.end + ); + codeLenses.push( + new vscode.CodeLens(range, { + title: "Accept", + command: "continue.acceptSuggestion", + arguments: [suggestion], + }), + new vscode.CodeLens(range, { + title: "Reject", + command: "continue.rejectSuggestion", + arguments: [suggestion], + }) + ); + } + + return codeLenses; + } + + onDidChangeCodeLenses?: vscode.Event<void> | undefined; + + constructor(emitter?: vscode.EventEmitter<void>) { + if (emitter) { + this.onDidChangeCodeLenses = emitter.event; + this.onDidChangeCodeLenses(() => { + if (vscode.window.activeTextEditor) { + this.provideCodeLenses( + vscode.window.activeTextEditor.document, + new vscode.CancellationTokenSource().token + ); + } + }); + } + } +} + +class PytestCodeLensProvider implements vscode.CodeLensProvider { + public provideCodeLenses( + document: vscode.TextDocument, + token: vscode.CancellationToken + ): vscode.CodeLens[] | Thenable<vscode.CodeLens[]> { + let codeLenses: vscode.CodeLens[] = []; + let lineno = 1; + let languageLibrary = getLanguageLibrary(document.fileName); + for (let line of document.getText().split("\n")) { + if ( + languageLibrary.lineIsFunctionDef(line) && + languageLibrary.parseFunctionDefForName(line).startsWith("test_") + ) { + let functionToTest = languageLibrary.parseFunctionDefForName(line); + let fileAndFunctionNameSpecifier = + document.fileName + "::" + functionToTest; + codeLenses.push( + new vscode.CodeLens(new vscode.Range(lineno, 0, lineno, 1), { + title: "Debug This Test", + command: "continue.debugTest", + arguments: [fileAndFunctionNameSpecifier], + }) + ); + } + lineno++; + } + + return codeLenses; + } +} + +const allCodeLensProviders: { [langauge: string]: vscode.CodeLensProvider[] } = + { + python: [new SuggestionsCodeLensProvider(), new PytestCodeLensProvider()], + }; + +export function registerAllCodeLensProviders(context: vscode.ExtensionContext) { + for (let language in allCodeLensProviders) { + for (let codeLensProvider of allCodeLensProviders[language]) { + context.subscriptions.push( + vscode.languages.registerCodeLensProvider(language, codeLensProvider) + ); + } + } +} diff --git a/extension/src/languages/index.d.ts b/extension/src/languages/index.d.ts new file mode 100644 index 00000000..be7ddfbc --- /dev/null +++ b/extension/src/languages/index.d.ts @@ -0,0 +1,13 @@ +export interface LanguageLibrary { + language: string; + fileExtensions: string[]; + parseFirstStacktrace: (stdout: string) => string | undefined; + lineIsFunctionDef: (line: string) => boolean; + parseFunctionDefForName: (line: string) => string; + lineIsComment: (line: string) => boolean; + writeImport: ( + sourcePath: string, + pathToImport: string, + namesToImport?: string[] | undefined + ) => string; +} diff --git a/extension/src/languages/index.ts b/extension/src/languages/index.ts new file mode 100644 index 00000000..31d73a0b --- /dev/null +++ b/extension/src/languages/index.ts @@ -0,0 +1,19 @@ +import pythonLanguageLibrary from "./python"; +import javascriptLanguageLibrary from "./javascript"; +import { LanguageLibrary } from "./index.d"; + +export const languageLibraries: LanguageLibrary[] = [ + pythonLanguageLibrary, + javascriptLanguageLibrary, +]; + +export function getLanguageLibrary(filepath: string): LanguageLibrary { + for (let languageLibrary of languageLibraries) { + for (let fileExtension of languageLibrary.fileExtensions) { + if (filepath.endsWith(fileExtension)) { + return languageLibrary; + } + } + } + throw new Error(`No language library found for file ${filepath}`); +} diff --git a/extension/src/languages/javascript/index.ts b/extension/src/languages/javascript/index.ts new file mode 100644 index 00000000..1c21a2fc --- /dev/null +++ b/extension/src/languages/javascript/index.ts @@ -0,0 +1,16 @@ +import { LanguageLibrary } from "../index.d"; +import { notImplemented } from "../notImplemented"; + +const NI = (propertyName: string) => notImplemented(propertyName, "javascript"); + +const javascriptLangaugeLibrary: LanguageLibrary = { + language: "javascript", + fileExtensions: [".js", ".jsx", ".ts", ".tsx"], + parseFirstStacktrace: NI("parseFirstStacktrace"), + lineIsFunctionDef: NI("lineIsFunctionDef"), + parseFunctionDefForName: NI("parseFunctionDefForName"), + lineIsComment: NI("lineIsComment"), + writeImport: NI("writeImport"), +}; + +export default javascriptLangaugeLibrary; diff --git a/extension/src/languages/notImplemented.ts b/extension/src/languages/notImplemented.ts new file mode 100644 index 00000000..bbba2382 --- /dev/null +++ b/extension/src/languages/notImplemented.ts @@ -0,0 +1,10 @@ +export function notImplemented( + propertyName: string, + langauge: string +): (...args: any[]) => never { + return (...args: any[]) => { + throw new Error( + `Property ${propertyName} not implemented for language ${langauge}.` + ); + }; +} diff --git a/extension/src/languages/python/index.ts b/extension/src/languages/python/index.ts new file mode 100644 index 00000000..50282b45 --- /dev/null +++ b/extension/src/languages/python/index.ts @@ -0,0 +1,74 @@ +import path = require("path"); +import { LanguageLibrary } from "../index.d"; + +const tracebackStart = "Traceback (most recent call last):"; +const tracebackEnd = (buf: string): string | undefined => { + let lines = buf + .split("\n") + .filter((line: string) => line.trim() !== "~~^~~") + .filter((line: string) => line.trim() !== ""); + for (let i = 0; i < lines.length; i++) { + if ( + lines[i].startsWith(" File") && + i + 2 < lines.length && + lines[i + 2][0] !== " " + ) { + return lines.slice(0, i + 3).join("\n"); + } + } + return undefined; +}; + +function parseFirstStacktrace(stdout: string): string | undefined { + let startIdx = stdout.indexOf(tracebackStart); + if (startIdx < 0) return undefined; + stdout = stdout.substring(startIdx); + return tracebackEnd(stdout); +} + +function lineIsFunctionDef(line: string): boolean { + return line.startsWith("def "); +} + +function parseFunctionDefForName(line: string): string { + return line.split("def ")[1].split("(")[0]; +} + +function lineIsComment(line: string): boolean { + return line.trim().startsWith("#"); +} + +function writeImport( + sourcePath: string, + pathToImport: string, + namesToImport: string[] | undefined = undefined +): string { + let segs = path.relative(sourcePath, pathToImport).split(path.sep); + let importFrom = ""; + for (let seg of segs) { + if (seg === "..") { + importFrom = "." + importFrom; + } else { + if (!importFrom.endsWith(".")) { + importFrom += "."; + } + importFrom += seg.split(".").slice(0, -1).join("."); + } + } + + return `from ${importFrom} import ${ + namesToImport ? namesToImport.join(", ") : "*" + }`; +} + +const pythonLangaugeLibrary: LanguageLibrary = { + language: "python", + fileExtensions: [".py"], + parseFirstStacktrace, + lineIsFunctionDef, + parseFunctionDefForName, + lineIsComment, + writeImport, +}; + +export default pythonLangaugeLibrary; diff --git a/extension/src/suggestions.ts b/extension/src/suggestions.ts new file mode 100644 index 00000000..c66fad86 --- /dev/null +++ b/extension/src/suggestions.ts @@ -0,0 +1,311 @@ +import * as vscode from "vscode"; +import { sendTelemetryEvent, TelemetryEvent } from "./telemetry"; +import { openEditorAndRevealRange } from "./util/vscode"; +import { translate, readFileAtRange } from "./util/vscode"; + +export interface SuggestionRanges { + oldRange: vscode.Range; + newRange: vscode.Range; + newSelected: boolean; +} + +/* Keyed by editor.document.uri.toString() */ +export const editorToSuggestions: Map< + string, // URI of file + SuggestionRanges[] +> = new Map(); +export let currentSuggestion: Map<string, number> = new Map(); // Map from editor URI to index of current SuggestionRanges in editorToSuggestions + +// When tab is reopened, rerender the decorations: +vscode.window.onDidChangeActiveTextEditor((editor) => { + if (!editor) return; + rerenderDecorations(editor.document.uri.toString()); +}); +vscode.workspace.onDidOpenTextDocument((doc) => { + rerenderDecorations(doc.uri.toString()); +}); + +let newDecorationType = vscode.window.createTextEditorDecorationType({ + backgroundColor: "rgb(0, 255, 0, 0.1)", + isWholeLine: true, +}); +let oldDecorationType = vscode.window.createTextEditorDecorationType({ + backgroundColor: "rgb(255, 0, 0, 0.1)", + isWholeLine: true, + cursor: "pointer", +}); +let newSelDecorationType = vscode.window.createTextEditorDecorationType({ + backgroundColor: "rgb(0, 255, 0, 0.25)", + isWholeLine: true, + after: { + contentText: "Press ctrl+shift+enter to accept", + margin: "0 0 0 1em", + }, +}); +let oldSelDecorationType = vscode.window.createTextEditorDecorationType({ + backgroundColor: "rgb(255, 0, 0, 0.25)", + isWholeLine: true, + after: { + contentText: "Press ctrl+shift+enter to reject", + margin: "0 0 0 1em", + }, +}); + +export function rerenderDecorations(editorUri: string) { + let suggestions = editorToSuggestions.get(editorUri); + let idx = currentSuggestion.get(editorUri); + let editor = vscode.window.visibleTextEditors.find( + (editor) => editor.document.uri.toString() === editorUri + ); + if (!suggestions || !editor) return; + + let olds: vscode.Range[] = [], + news: vscode.Range[] = [], + oldSels: vscode.Range[] = [], + newSels: vscode.Range[] = []; + for (let i = 0; i < suggestions.length; i++) { + let suggestion = suggestions[i]; + if (typeof idx != "undefined" && idx === i) { + if (suggestion.newSelected) { + olds.push(suggestion.oldRange); + newSels.push(suggestion.newRange); + } else { + oldSels.push(suggestion.oldRange); + news.push(suggestion.newRange); + } + } else { + olds.push(suggestion.oldRange); + news.push(suggestion.newRange); + } + } + editor.setDecorations(oldDecorationType, olds); + editor.setDecorations(newDecorationType, news); + editor.setDecorations(oldSelDecorationType, oldSels); + editor.setDecorations(newSelDecorationType, newSels); + + // Reveal the range in the editor + if (idx === undefined) return; + editor.revealRange( + suggestions[idx].newRange, + vscode.TextEditorRevealType.Default + ); +} + +export function suggestionDownCommand() { + let editor = vscode.window.activeTextEditor; + if (!editor) return; + let editorUri = editor.document.uri.toString(); + let suggestions = editorToSuggestions.get(editorUri); + let idx = currentSuggestion.get(editorUri); + if (!suggestions || idx === undefined) return; + + let suggestion = suggestions[idx]; + if (!suggestion.newSelected) { + suggestion.newSelected = true; + } else if (idx + 1 < suggestions.length) { + currentSuggestion.set(editorUri, idx + 1); + } else return; + rerenderDecorations(editorUri); +} + +export function suggestionUpCommand() { + let editor = vscode.window.activeTextEditor; + if (!editor) return; + let editorUri = editor.document.uri.toString(); + let suggestions = editorToSuggestions.get(editorUri); + let idx = currentSuggestion.get(editorUri); + if (!suggestions || idx === undefined) return; + + let suggestion = suggestions[idx]; + if (suggestion.newSelected) { + suggestion.newSelected = false; + } else if (idx > 0) { + currentSuggestion.set(editorUri, idx - 1); + } else return; + rerenderDecorations(editorUri); +} + +type SuggestionSelectionOption = "old" | "new" | "selected"; +function selectSuggestion( + accept: SuggestionSelectionOption, + key: SuggestionRanges | null = null +) { + let editor = vscode.window.activeTextEditor; + if (!editor) return; + let editorUri = editor.document.uri.toString(); + let suggestions = editorToSuggestions.get(editorUri); + + if (!suggestions) return; + + let idx: number | undefined; + if (key) { + // Use the key to find a specific suggestion + for (let i = 0; i < suggestions.length; i++) { + if ( + suggestions[i].newRange === key.newRange && + suggestions[i].oldRange === key.oldRange + ) { + // Don't include newSelected in the comparison, because it can change + idx = i; + break; + } + } + } else { + // Otherwise, use the current suggestion + idx = currentSuggestion.get(editorUri); + } + if (idx === undefined) return; + + let [suggestion] = suggestions.splice(idx, 1); + + var rangeToDelete: vscode.Range; + switch (accept) { + case "old": + rangeToDelete = suggestion.newRange; + break; + case "new": + rangeToDelete = suggestion.oldRange; + break; + case "selected": + rangeToDelete = suggestion.newSelected + ? suggestion.oldRange + : suggestion.newRange; + } + + rangeToDelete = new vscode.Range( + rangeToDelete.start, + new vscode.Position(rangeToDelete.end.line + 1, 0) + ); + editor.edit((edit) => { + edit.delete(rangeToDelete); + }); + + // Shift the below suggestions up + let linesToShift = rangeToDelete.end.line - rangeToDelete.start.line; + for (let below of suggestions) { + // Assumes there should be no crossover between suggestions. Might want to enforce this. + if ( + below.oldRange.union(below.newRange).start.line > + suggestion.oldRange.union(suggestion.newRange).start.line + ) { + below.oldRange = translate(below.oldRange, -linesToShift); + below.newRange = translate(below.newRange, -linesToShift); + } + } + + if (suggestions.length === 0) { + currentSuggestion.delete(editorUri); + } else { + currentSuggestion.set(editorUri, Math.min(idx, suggestions.length - 1)); + } + rerenderDecorations(editorUri); +} + +export function acceptSuggestionCommand(key: SuggestionRanges | null = null) { + sendTelemetryEvent(TelemetryEvent.SuggestionAccepted); + selectSuggestion("selected", key); +} + +export async function rejectSuggestionCommand( + key: SuggestionRanges | null = null +) { + sendTelemetryEvent(TelemetryEvent.SuggestionRejected); + selectSuggestion("old", key); +} + +export async function showSuggestion( + editorFilename: string, + range: vscode.Range, + suggestion: string +): Promise<boolean> { + let existingCode = await readFileAtRange( + new vscode.Range(range.start, range.end), + editorFilename + ); + + // If any of the outside lines are the same, don't repeat them in the suggestion + let slines = suggestion.split("\n"); + let elines = existingCode.split("\n"); + let linesRemovedBefore = 0; + let linesRemovedAfter = 0; + while (slines.length > 0 && elines.length > 0 && slines[0] === elines[0]) { + slines.shift(); + elines.shift(); + linesRemovedBefore++; + } + + while ( + slines.length > 0 && + elines.length > 0 && + slines[slines.length - 1] === elines[elines.length - 1] + ) { + slines.pop(); + elines.pop(); + linesRemovedAfter++; + } + + suggestion = slines.join("\n"); + if (suggestion === "") return Promise.resolve(false); // Don't even make a suggestion if they are exactly the same + + range = new vscode.Range( + new vscode.Position(range.start.line + linesRemovedBefore, 0), + new vscode.Position( + range.end.line - linesRemovedAfter, + elines.at(-1)?.length || 0 + ) + ); + + let editor = await openEditorAndRevealRange(editorFilename, range); + if (!editor) return Promise.resolve(false); + + return new Promise((resolve, reject) => { + editor! + .edit((edit) => { + if (range.end.line + 1 >= editor.document.lineCount) { + suggestion = "\n" + suggestion; + } + edit.insert( + new vscode.Position(range.end.line + 1, 0), + suggestion + "\n" + ); + }) + .then( + (success) => { + if (success) { + let suggestionRange = new vscode.Range( + new vscode.Position(range.end.line + 1, 0), + new vscode.Position( + range.end.line + suggestion.split("\n").length, + 0 + ) + ); + + const filename = editor!.document.uri.toString(); + if (editorToSuggestions.has(filename)) { + let suggestions = editorToSuggestions.get(filename)!; + suggestions.push({ + oldRange: range, + newRange: suggestionRange, + newSelected: true, + }); + editorToSuggestions.set(filename, suggestions); + currentSuggestion.set(filename, suggestions.length - 1); + } else { + editorToSuggestions.set(filename, [ + { + oldRange: range, + newRange: suggestionRange, + newSelected: true, + }, + ]); + currentSuggestion.set(filename, 0); + } + + rerenderDecorations(filename); + } + resolve(success); + }, + (reason) => reject(reason) + ); + }); +} diff --git a/extension/src/telemetry.ts b/extension/src/telemetry.ts new file mode 100644 index 00000000..ea71a545 --- /dev/null +++ b/extension/src/telemetry.ts @@ -0,0 +1,51 @@ +import * as Segment from "@segment/analytics-node"; +import * as vscode from "vscode"; + +// Setup Segment +const SEGMENT_WRITE_KEY = "57yy2uYXH2bwMuy7djm9PorfFlYqbJL1"; +const analytics = new Segment.Analytics({ writeKey: SEGMENT_WRITE_KEY }); +analytics.identify({ + userId: vscode.env.machineId, + // traits: { + // name: "Michael Bolton", + // email: "mbolton@example.com", + // createdAt: new Date("2014-06-14T02:00:19.467Z"), + // }, +}); + +// Enum of telemetry events +export enum TelemetryEvent { + // Extension has been activated + ExtensionActivated = "ExtensionActivated", + // Suggestion has been accepted + SuggestionAccepted = "SuggestionAccepted", + // Suggestion has been rejected + SuggestionRejected = "SuggestionRejected", + // Queried universal prompt + UniversalPromptQuery = "UniversalPromptQuery", + // `Explain Code` button clicked + ExplainCode = "ExplainCode", + // `Generate Ideas` button clicked + GenerateIdeas = "GenerateIdeas", + // `Suggest Fix` button clicked + SuggestFix = "SuggestFix", + // `Create Test` button clicked + CreateTest = "CreateTest", + // `AutoDebug This Test` button clicked + AutoDebugThisTest = "AutoDebugThisTest", + // Command run to generate docstring + GenerateDocstring = "GenerateDocstring", +} + +export function sendTelemetryEvent( + event: TelemetryEvent, + properties?: Record<string, any> +) { + if (!vscode.env.isTelemetryEnabled) return; + + analytics.track({ + event, + userId: vscode.env.machineId, + properties, + }); +}
\ No newline at end of file diff --git a/extension/src/terminal/snoopers.ts b/extension/src/terminal/snoopers.ts new file mode 100644 index 00000000..a1f8993c --- /dev/null +++ b/extension/src/terminal/snoopers.ts @@ -0,0 +1,133 @@ +export abstract class TerminalSnooper<T> { + abstract onData(data: string): void; + abstract onWrite(data: string): void; + callback: (data: T) => void; + + constructor(callback: (data: T) => void) { + this.callback = callback; + } +} + +function stripAnsi(data: string) { + const pattern = [ + "[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)", + "(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))", + ].join("|"); + + let regex = new RegExp(pattern, "g"); + return data.replace(regex, ""); +} + +export class CommandCaptureSnooper extends TerminalSnooper<string> { + stdinBuffer = ""; + cursorPos = 0; + stdoutHasInterrupted = false; + + static RETURN_KEY = "\r"; + static DEL_KEY = "\x7F"; + static UP_KEY = "\x1B[A"; + static DOWN_KEY = "\x1B[B"; + static RIGHT_KEY = "\x1B[C"; + static LEFT_KEY = "\x1B[D"; + static CONTROL_KEYS = new Set([ + CommandCaptureSnooper.RETURN_KEY, + CommandCaptureSnooper.DEL_KEY, + CommandCaptureSnooper.UP_KEY, + CommandCaptureSnooper.DOWN_KEY, + CommandCaptureSnooper.RIGHT_KEY, + CommandCaptureSnooper.LEFT_KEY, + ]); + + private _cursorLeft() { + this.cursorPos = Math.max(0, this.cursorPos - 1); + } + private _cursorRight() { + this.cursorPos = Math.min(this.stdinBuffer.length, this.cursorPos + 1); + } + // Known issue: This does not handle autocomplete. + // Would be preferable to find a way that didn't require this all, just parsing by command prompt + // but that has it's own challenges + private handleControlKey(data: string): void { + switch (data) { + case CommandCaptureSnooper.DEL_KEY: + this.stdinBuffer = + this.stdinBuffer.slice(0, this.cursorPos - 1) + + this.stdinBuffer.slice(this.cursorPos); + this._cursorLeft(); + break; + case CommandCaptureSnooper.RETURN_KEY: + this.callback(this.stdinBuffer); + this.stdinBuffer = ""; + break; + case CommandCaptureSnooper.UP_KEY: + case CommandCaptureSnooper.DOWN_KEY: + this.stdinBuffer = ""; + break; + case CommandCaptureSnooper.RIGHT_KEY: + this._cursorRight(); + break; + case CommandCaptureSnooper.LEFT_KEY: + this._cursorLeft(); + break; + } + } + + onWrite(data: string): void { + if (CommandCaptureSnooper.CONTROL_KEYS.has(data)) { + this.handleControlKey(data); + } else { + this.stdinBuffer = + this.stdinBuffer.substring(0, this.cursorPos) + + data + + this.stdinBuffer.substring(this.cursorPos); + this._cursorRight(); + } + } + + onData(data: string): void {} +} + +export class PythonTracebackSnooper extends TerminalSnooper<string> { + static tracebackStart = "Traceback (most recent call last):"; + tracebackBuffer = ""; + + static tracebackEnd = (buf: string): string | undefined => { + let lines = buf.split("\n"); + for (let i = 0; i < lines.length; i++) { + if ( + lines[i].startsWith(" File") && + i + 2 < lines.length && + lines[i + 2][0] != " " + ) { + return lines.slice(0, i + 3).join("\n"); + } + } + return undefined; + }; + override onWrite(data: string): void {} + override onData(data: string): void { + let strippedData = stripAnsi(data); + // Strip fully blank and squiggle lines + strippedData = strippedData + .split("\n") + .filter((line) => line.trim().length > 0 && line.trim() !== "~~^~~") + .join("\n"); + // Snoop for traceback + let idx = strippedData.indexOf(PythonTracebackSnooper.tracebackStart); + if (idx >= 0) { + this.tracebackBuffer = strippedData.substr(idx); + } else if (this.tracebackBuffer.length > 0) { + this.tracebackBuffer += "\n" + strippedData; + } + // End of traceback, send to webview + if (this.tracebackBuffer.length > 0) { + let wholeTraceback = PythonTracebackSnooper.tracebackEnd( + this.tracebackBuffer + ); + if (wholeTraceback) { + this.callback(wholeTraceback); + this.tracebackBuffer = ""; + } + } + } +} diff --git a/extension/src/terminal/terminalEmulator.ts b/extension/src/terminal/terminalEmulator.ts new file mode 100644 index 00000000..ba860b24 --- /dev/null +++ b/extension/src/terminal/terminalEmulator.ts @@ -0,0 +1,140 @@ +// /* Terminal emulator - commented because node-pty is causing problems. */ + +// import * as vscode from "vscode"; +// import pty = require("node-pty"); +// import os = require("os"); +// import { extensionContext } from "../activation/activate"; +// import { debugPanelWebview } from "../debugPanel"; // Need to consider having multiple panels, where to store this state. +// import { +// CommandCaptureSnooper, +// PythonTracebackSnooper, +// TerminalSnooper, +// } from "./snoopers"; + +// export function tracebackToWebviewAction(traceback: string) { +// if (debugPanelWebview) { +// debugPanelWebview.postMessage({ +// type: "traceback", +// value: traceback, +// }); +// } else { +// vscode.commands +// .executeCommand("continue.openDebugPanel", extensionContext) +// .then(() => { +// // TODO: Waiting for the webview to load, but should add a hook to the onLoad message event. Same thing in autodebugTest command in commands.ts +// setTimeout(() => { +// debugPanelWebview?.postMessage({ +// type: "traceback", +// value: traceback, +// }); +// }, 500); +// }); +// } +// } + +// const DEFAULT_SNOOPERS = [ +// new PythonTracebackSnooper(tracebackToWebviewAction), +// new CommandCaptureSnooper((data: string) => { +// if (data.trim().startsWith("pytest ")) { +// let fileAndFunctionSpecifier = data.split(" ")[1]; +// vscode.commands.executeCommand( +// "continue.debugTest", +// fileAndFunctionSpecifier +// ); +// } +// }), +// ]; + +// // Whenever a user opens a terminal, replace it with ours +// vscode.window.onDidOpenTerminal((terminal) => { +// if (terminal.name != "Continue") { +// terminal.dispose(); +// openCapturedTerminal(); +// } +// }); + +// function getDefaultShell(): string { +// if (process.platform !== "win32") { +// return os.userInfo().shell; +// } +// switch (process.platform) { +// case "win32": +// return process.env.COMSPEC || "cmd.exe"; +// // case "darwin": +// // return process.env.SHELL || "/bin/zsh"; +// // default: +// // return process.env.SHELL || "/bin/sh"; +// } +// } + +// function getRootDir(): string | undefined { +// var isWindows = os.platform() === "win32"; +// let cwd = isWindows ? process.env.USERPROFILE : process.env.HOME; +// if ( +// vscode.workspace.workspaceFolders && +// vscode.workspace.workspaceFolders.length > 0 +// ) { +// cwd = vscode.workspace.workspaceFolders[0].uri.fsPath; +// } +// return cwd; +// } + +// export function openCapturedTerminal( +// snoopers: TerminalSnooper<string>[] = DEFAULT_SNOOPERS +// ) { +// // If there is another existing, non-Continue terminal, delete it +// let terminals = vscode.window.terminals; +// for (let i = 0; i < terminals.length; i++) { +// if (terminals[i].name != "Continue") { +// terminals[i].dispose(); +// } +// } + +// let env = { ...(process.env as any) }; +// if (os.platform() !== "win32") { +// env["PATH"] += ":" + ["/opt/homebrew/bin", "/opt/homebrew/sbin"].join(":"); +// } + +// var ptyProcess = pty.spawn(getDefaultShell(), [], { +// name: "xterm-256color", +// cols: 160, // TODO: Get size of vscode terminal, and change with resize +// rows: 26, +// cwd: getRootDir(), +// env, +// useConpty: true, +// }); + +// const writeEmitter = new vscode.EventEmitter<string>(); + +// ptyProcess.onData((data: any) => { +// // Let each of the snoopers see the new data +// for (let snooper of snoopers) { +// snooper.onData(data); +// } + +// // Pass data through to terminal +// writeEmitter.fire(data); +// }); +// process.on("exit", () => ptyProcess.kill()); + +// const newPty: vscode.Pseudoterminal = { +// onDidWrite: writeEmitter.event, +// open: () => {}, +// close: () => {}, +// handleInput: (data) => { +// for (let snooper of snoopers) { +// snooper.onWrite(data); +// } +// ptyProcess.write(data); +// }, +// }; +// const terminal = vscode.window.createTerminal({ +// name: "Continue", +// pty: newPty, +// }); +// terminal.show(); + +// setTimeout(() => { +// ptyProcess.write("clear\r"); +// }, 500); +// } diff --git a/extension/src/test/runTest.ts b/extension/src/test/runTest.ts new file mode 100644 index 00000000..27b3ceb2 --- /dev/null +++ b/extension/src/test/runTest.ts @@ -0,0 +1,23 @@ +import * as path from 'path'; + +import { runTests } from '@vscode/test-electron'; + +async function main() { + try { + // The folder containing the Extension Manifest package.json + // Passed to `--extensionDevelopmentPath` + const extensionDevelopmentPath = path.resolve(__dirname, '../../'); + + // The path to test runner + // Passed to --extensionTestsPath + const extensionTestsPath = path.resolve(__dirname, './suite/index'); + + // Download VS Code, unzip it and run the integration test + await runTests({ extensionDevelopmentPath, extensionTestsPath }); + } catch (err) { + console.error('Failed to run tests'); + process.exit(1); + } +} + +main(); diff --git a/extension/src/test/suite/extension.test.ts b/extension/src/test/suite/extension.test.ts new file mode 100644 index 00000000..890820b2 --- /dev/null +++ b/extension/src/test/suite/extension.test.ts @@ -0,0 +1,16 @@ +import { test, describe } from "mocha"; +import * as assert from "assert"; + +// You can import and use all API from the 'vscode' module +// as well as import your extension to test it +import * as vscode from "vscode"; +// import * as myExtension from '../../extension'; + +describe("Extension Test Suite", () => { + vscode.window.showInformationMessage("Start all tests."); + + test("Sample test", () => { + assert.strictEqual(-1, [1, 2, 3].indexOf(5)); + assert.strictEqual(-1, [1, 2, 3].indexOf(0)); + }); +}); diff --git a/extension/src/test/suite/index.ts b/extension/src/test/suite/index.ts new file mode 100644 index 00000000..772a0152 --- /dev/null +++ b/extension/src/test/suite/index.ts @@ -0,0 +1,38 @@ +import * as path from "path"; +import * as Mocha from "mocha"; +import * as glob from "glob"; + +export function run(): Promise<void> { + // Create the mocha test + const mocha = new Mocha({ + ui: "tdd", + color: true, + }); + + const testsRoot = path.resolve(__dirname, ".."); + + return new Promise((c, e) => { + glob("**/**.test.js", { cwd: testsRoot }, (err, files) => { + if (err) { + return e(err); + } + + // Add files to the test suite + files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f))); + + try { + // Run the mocha test + mocha.run((failures: any) => { + if (failures > 0) { + e(new Error(`${failures} tests failed.`)); + } else { + c(); + } + }); + } catch (err) { + console.error(err); + e(err); + } + }); + }); +} diff --git a/extension/src/test/suite/terminalEmulator.test.ts b/extension/src/test/suite/terminalEmulator.test.ts new file mode 100644 index 00000000..c4c159a4 --- /dev/null +++ b/extension/src/test/suite/terminalEmulator.test.ts @@ -0,0 +1,28 @@ +import { test, describe } from "mocha"; +import * as assert from "assert"; +import { PythonTracebackSnooper } from "../../terminal/snoopers"; + +suite("Snoopers", () => { + suite("PythonTracebackSnooper", () => { + test("should detect traceback given all at once", async () => { + let traceback = `Traceback (most recent call last): + File "/Users/natesesti/Desktop/continue/extension/examples/python/main.py", line 10, in <module> + sum(first, second) + File "/Users/natesesti/Desktop/continue/extension/examples/python/sum.py", line 2, in sum + return a + b + TypeError: unsupported operand type(s) for +: 'int' and 'str'`; + let returnedTraceback = await new Promise((resolve) => { + let callback = (data: string) => { + resolve(data); + }; + let snooper = new PythonTracebackSnooper(callback); + snooper.onData(traceback); + }); + assert( + returnedTraceback === traceback, + "Detected \n" + returnedTraceback + ); + }); + test("should detect traceback given in chunks", () => {}); + }); +}); diff --git a/extension/src/test/suite/util.test.ts b/extension/src/test/suite/util.test.ts new file mode 100644 index 00000000..0ba1473b --- /dev/null +++ b/extension/src/test/suite/util.test.ts @@ -0,0 +1,18 @@ +import { test, describe } from "mocha"; +import * as assert from "assert"; +import { convertSingleToDoubleQuoteJSON } from "../../util/util"; + +describe("utils.ts", () => { + test("convertSingleToDoubleQuoteJson", () => { + let pairs = [ + [`{'a': 'b'}`, `{"a": "b"}`], + [`{'a': "b", "c": 'd'}`, `{"a": "b", "c": "d"}`], + [`{'a': '\\'"'}`, `{"a": "'\\""}`], + ]; + for (let pair of pairs) { + let result = convertSingleToDoubleQuoteJSON(pair[0]); + assert(result === pair[1]); + JSON.parse(result); + } + }); +}); diff --git a/extension/src/util/util.ts b/extension/src/util/util.ts new file mode 100644 index 00000000..d33593e1 --- /dev/null +++ b/extension/src/util/util.ts @@ -0,0 +1,115 @@ +import { RangeInFile, SerializedDebugContext } from "../client"; +import * as fs from "fs"; + +function charIsEscapedAtIndex(index: number, str: string): boolean { + if (index === 0) return false; + if (str[index - 1] !== "\\") return false; + return !charIsEscapedAtIndex(index - 1, str); +} + +export function convertSingleToDoubleQuoteJSON(json: string): string { + const singleQuote = "'"; + const doubleQuote = '"'; + const isQuote = (char: string) => + char === doubleQuote || char === singleQuote; + + let newJson = ""; + let insideString = false; + let enclosingQuoteType = doubleQuote; + for (let i = 0; i < json.length; i++) { + if (insideString) { + if (json[i] === enclosingQuoteType && !charIsEscapedAtIndex(i, json)) { + // Close string with a double quote + insideString = false; + newJson += doubleQuote; + } else if (json[i] === singleQuote) { + if (charIsEscapedAtIndex(i, json)) { + // Unescape single quote + newJson = newJson.slice(0, -1); + } + newJson += singleQuote; + } else if (json[i] === doubleQuote) { + if (!charIsEscapedAtIndex(i, json)) { + // Escape double quote + newJson += "\\"; + } + newJson += doubleQuote; + } else { + newJson += json[i]; + } + } else { + if (isQuote(json[i])) { + insideString = true; + enclosingQuoteType = json[i]; + newJson += doubleQuote; + } else { + newJson += json[i]; + } + } + } + + return newJson; +} + +export async function readRangeInFile( + rangeInFile: RangeInFile +): Promise<string> { + const range = rangeInFile.range; + return new Promise((resolve, reject) => { + fs.readFile(rangeInFile.filepath, (err, data) => { + if (err) { + reject(err); + } else { + let lines = data.toString().split("\n"); + if (range.start.line === range.end.line) { + resolve( + lines[rangeInFile.range.start.line].slice( + rangeInFile.range.start.character, + rangeInFile.range.end.character + ) + ); + } else { + let firstLine = lines[range.start.line].slice(range.start.character); + let lastLine = lines[range.end.line].slice(0, range.end.character); + let middleLines = lines.slice(range.start.line + 1, range.end.line); + resolve([firstLine, ...middleLines, lastLine].join("\n")); + } + } + }); + }); +} + +export function codeSelectionsToVirtualFileSystem( + codeSelections: RangeInFile[] +): { + [filepath: string]: string; +} { + let virtualFileSystem: { [filepath: string]: string } = {}; + for (let cs of codeSelections) { + if (!cs.filepath) continue; + if (cs.filepath in virtualFileSystem) continue; + let content = fs.readFileSync(cs.filepath, "utf8"); + virtualFileSystem[cs.filepath] = content; + } + return virtualFileSystem; +} + +export function addFileSystemToDebugContext( + ctx: SerializedDebugContext +): SerializedDebugContext { + ctx.filesystem = codeSelectionsToVirtualFileSystem(ctx.rangesInFiles); + return ctx; +} + +export function debounced(delay: number, fn: Function) { + let timerId: NodeJS.Timeout | null; + return function (...args: any[]) { + if (timerId) { + clearTimeout(timerId); + } + timerId = setTimeout(() => { + fn(...args); + timerId = null; + }, delay); + }; +} diff --git a/extension/src/util/vscode.ts b/extension/src/util/vscode.ts new file mode 100644 index 00000000..4eab98a7 --- /dev/null +++ b/extension/src/util/vscode.ts @@ -0,0 +1,152 @@ +import * as vscode from "vscode"; +import * as path from "path"; +import * as fs from "fs"; + +export function translate(range: vscode.Range, lines: number): vscode.Range { + return new vscode.Range( + range.start.line + lines, + range.start.character, + range.end.line + lines, + range.end.character + ); +} + +export function getNonce() { + let text = ""; + const possible = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + for (let i = 0; i < 32; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; +} + +export function getTestFile( + filename: string, + createFile: boolean = false +): string { + let basename = path.basename(filename).split(".")[0]; + switch (path.extname(filename)) { + case ".py": + basename += "_test"; + break; + case ".js": + case ".jsx": + case ".ts": + case ".tsx": + basename += ".test"; + break; + default: + basename += "_test"; + } + + const directory = path.join(path.dirname(filename), "tests"); + const testFilename = path.join(directory, basename + path.extname(filename)); + + // Optionally, create the file if it doesn't exist + if (createFile && !fs.existsSync(testFilename)) { + if (!fs.existsSync(directory)) { + fs.mkdirSync(directory); + } + fs.writeFileSync(testFilename, ""); + } + + return testFilename; +} + +export function getExtensionUri(): vscode.Uri { + return vscode.extensions.getExtension("Continue.continue")!.extensionUri; +} + +export function getViewColumnOfFile( + filepath: string +): vscode.ViewColumn | undefined { + for (let tabGroup of vscode.window.tabGroups.all) { + for (let tab of tabGroup.tabs) { + if ( + (tab?.input as any)?.uri && + (tab.input as any).uri.fsPath === filepath + ) { + return tabGroup.viewColumn; + } + } + } + return undefined; +} + +export function getRightViewColumn(): vscode.ViewColumn { + // When you want to place in the rightmost panel if there is already more than one, otherwise use Beside + let column = vscode.ViewColumn.Beside; + let columnOrdering = [ + vscode.ViewColumn.One, + vscode.ViewColumn.Beside, + vscode.ViewColumn.Two, + vscode.ViewColumn.Three, + vscode.ViewColumn.Four, + vscode.ViewColumn.Five, + vscode.ViewColumn.Six, + vscode.ViewColumn.Seven, + vscode.ViewColumn.Eight, + vscode.ViewColumn.Nine, + ]; + for (let tabGroup of vscode.window.tabGroups.all) { + if ( + columnOrdering.indexOf(tabGroup.viewColumn) > + columnOrdering.indexOf(column) + ) { + column = tabGroup.viewColumn; + } + } + return column; +} + +export async function readFileAtRange( + range: vscode.Range, + filepath: string +): Promise<string> { + return new Promise((resolve, reject) => { + fs.readFile(filepath, (err, data) => { + if (err) { + reject(err); + } else { + let lines = data.toString().split("\n"); + if (range.isSingleLine) { + resolve( + lines[range.start.line].slice( + range.start.character, + range.end.character + ) + ); + } else { + let firstLine = lines[range.start.line].slice(range.start.character); + let lastLine = lines[range.end.line].slice(0, range.end.character); + let middleLines = lines.slice(range.start.line + 1, range.end.line); + resolve([firstLine, ...middleLines, lastLine].join("\n")); + } + } + }); + }); +} + +export function openEditorAndRevealRange( + editorFilename: string, + range?: vscode.Range, + viewColumn?: vscode.ViewColumn +): Promise<vscode.TextEditor> { + return new Promise((resolve, _) => { + // Check if the editor is already open + vscode.workspace.openTextDocument(editorFilename).then((doc) => { + vscode.window + .showTextDocument( + doc, + getViewColumnOfFile(editorFilename) || viewColumn + ) + .then((editor) => { + if (range) { + editor.revealRange(range); + } + resolve(editor); + }); + }); + }); +} |