From d09c07ab9304ed176f19f89751cd9fc6aec805d8 Mon Sep 17 00:00:00 2001 From: JinmingYang <2214962083@qq.com> Date: Sat, 24 Aug 2024 21:24:56 +0800 Subject: [PATCH] feat: refactor code and add chat webview --- .eslintrc.json | 4 +- package.json | 30 ++ pnpm-lock.yaml | 45 +++ src/extension/ai/get-reference-file-paths.ts | 7 +- .../ai/model-providers/azure-openai.ts | 5 +- src/extension/ai/model-providers/base.ts | 6 +- src/extension/ai/model-providers/claude.ts | 6 +- src/extension/ai/model-providers/openai.ts | 5 +- .../auto-open-corresponding-files.ts | 57 --- src/extension/cleanup.ts | 17 - src/extension/commands/ask-ai/command.ts | 86 ++++ src/extension/commands/ask-ai/index.ts | 82 ---- src/extension/commands/base.command.ts | 24 ++ .../commands/batch-processor/command.ts | 103 +++++ .../batch-processor/get-pre-process-info.ts | 7 +- .../commands/batch-processor/index.ts | 96 ----- .../write-and-save-tmp-file.ts | 8 +- .../commands/code-convert/command.ts | 103 +++++ src/extension/commands/code-convert/index.ts | 107 ----- .../commands/code-viewer-helper/command.ts | 92 +++++ .../commands/code-viewer-helper/index.ts | 100 ----- src/extension/commands/command-manager.ts | 33 ++ .../commands/copy-as-prompt/command.ts | 31 ++ .../commands/copy-as-prompt/index.ts | 26 -- .../commands/expert-code-enhancer/command.ts | 101 +++++ .../commands/expert-code-enhancer/index.ts | 107 ----- src/extension/commands/index.ts | 138 ++----- .../private/copy-file-text.command.ts | 20 + .../commands/private/copy-file-text.ts | 12 - .../commands/private/open-webview.command.ts | 21 + .../quick-close-file-without-save.command.ts | 39 ++ .../private/quick-close-file-without-save.ts | 31 -- .../commands/private/replace-file.command.ts | 71 ++++ .../commands/private/replace-file.ts | 73 ---- .../show-aide-key-usage-info.command.ts | 73 ++++ .../private/show-aide-key-usage-info.ts | 59 --- .../commands/private/show-diff.command.ts | 178 +++++++++ src/extension/commands/private/show-diff.ts | 55 --- .../commands/rename-variable/command.ts | 107 +++++ .../commands/rename-variable/index.ts | 115 ------ .../build-convert-chat-messages.ts | 2 +- src/extension/commands/smart-paste/command.ts | 80 ++++ src/extension/commands/smart-paste/index.ts | 70 ---- src/extension/config.ts | 8 +- src/extension/enable-system-proxy.ts | 69 ---- src/extension/{ => file-utils}/clipboard.ts | 5 +- src/extension/file-utils/create-tmp-file.ts | 376 ------------------ .../file-utils/stream-completion-writer.ts | 168 ++++---- src/extension/file-utils/tmp-file-writer.ts | 108 ----- .../tmp-file/create-tmp-file-and-writer.ts | 109 +++++ .../tmp-file/get-original-file-uri.ts | 15 + .../file-utils/tmp-file/get-tmp-file-info.ts | 72 ++++ .../file-utils/tmp-file/get-tmp-file-uri.ts | 41 ++ .../file-utils/tmp-file/is-tmp-file-uri.ts | 11 + .../file-utils/tmp-file/tmp-file-writer.ts | 110 +++++ src/extension/index.ts | 27 +- .../providers/aide-key-usage-statusbar.ts | 21 - src/extension/providers/index.ts | 15 - src/extension/providers/webview.ts | 110 ----- .../aide-key-usage-statusbar.register.ts | 35 ++ .../auto-open-corresponding-files.register.ts | 69 ++++ src/extension/registers/base.register.ts | 16 + src/extension/registers/index.ts | 21 + src/extension/registers/register-manager.ts | 27 ++ .../registers/system-setup.register.ts | 87 ++++ .../tmp-file-action.register.ts} | 28 +- src/extension/registers/webview.register.ts | 111 ++++++ src/extension/utils.ts | 136 +++---- src/extension/webview-api/api-manager.ts | 240 +++++++++++ .../chat-context-builder.ts | 35 ++ .../webview-api/chat-context-builder/error.ts | 13 + .../webview-api/chat-context-builder/index.ts | 21 + .../chat-context-builder/plugin-manager.ts | 33 ++ .../plugins/base.plugin.ts | 28 ++ .../plugins/code-chunks.plugin.ts | 35 ++ .../plugins/conversation.plugin.ts | 22 + .../plugins/current-file.plugin.ts | 14 + .../plugins/explicit-context.plugin.ts | 17 + .../plugins/git.plugin.ts | 64 +++ .../types/chat-context/code-block.ts | 6 + .../types/chat-context/file-uri.ts | 21 + .../types/chat-context/index.ts | 120 ++++++ .../message/assistant-suggestions.ts | 3 + .../chat-context/message/attachment-info.ts | 8 + .../chat-context/message/basic-message.ts | 23 ++ .../chat-context/message/code-related-info.ts | 100 +++++ .../chat-context/message/git-related-info.ts | 54 +++ .../types/chat-context/message/index.ts | 14 + .../chat-context/message/interpreter-info.ts | 3 + .../types/chat-context/rich-text/index.ts | 35 ++ .../chat-context/rich-text/mention-type.ts | 10 + .../types/chat-context/rich-text/mention.ts | 14 + .../types/chat-context/rich-text/metadata.ts | 24 ++ .../chat-context/rich-text/selection-type.ts | 8 + .../types/langchain-message.ts | 7 + src/extension/webview-api/constant.ts | 3 + .../controllers/base.controller.ts | 50 +++ .../controllers/chat.controller.ts | 57 +++ .../controllers/file.controller.ts | 45 +++ src/extension/webview-api/index.ts | 35 ++ src/extension/webview-api/prompts/chat.ts | 145 +++++++ .../webview-api/prompts/completions.ts | 18 + src/extension/webview-api/prompts/composer.ts | 60 +++ src/extension/webview-api/prompts/context.ts | 0 src/extension/webview-api/types.ts | 41 ++ src/shared/types/msg.ts | 25 -- src/webview/App.tsx | 17 +- src/webview/api/create-webview-api.ts | 91 +++++ src/webview/chat-context-manager/index.ts | 44 ++ .../managers/base.manager.ts | 15 + .../managers/conversation.manager.ts | 46 +++ .../managers/file.manager.ts | 18 + .../managers/settings.manager.ts | 16 + src/webview/helpers/vscode.ts | 9 +- src/webview/types/vscode.d.ts | 12 + tsconfig.json | 3 +- 116 files changed, 3884 insertions(+), 2065 deletions(-) delete mode 100644 src/extension/auto-open-corresponding-files.ts delete mode 100644 src/extension/cleanup.ts create mode 100644 src/extension/commands/ask-ai/command.ts delete mode 100644 src/extension/commands/ask-ai/index.ts create mode 100644 src/extension/commands/base.command.ts create mode 100644 src/extension/commands/batch-processor/command.ts delete mode 100644 src/extension/commands/batch-processor/index.ts create mode 100644 src/extension/commands/code-convert/command.ts delete mode 100644 src/extension/commands/code-convert/index.ts create mode 100644 src/extension/commands/code-viewer-helper/command.ts delete mode 100644 src/extension/commands/code-viewer-helper/index.ts create mode 100644 src/extension/commands/command-manager.ts create mode 100644 src/extension/commands/copy-as-prompt/command.ts delete mode 100644 src/extension/commands/copy-as-prompt/index.ts create mode 100644 src/extension/commands/expert-code-enhancer/command.ts delete mode 100644 src/extension/commands/expert-code-enhancer/index.ts create mode 100644 src/extension/commands/private/copy-file-text.command.ts delete mode 100644 src/extension/commands/private/copy-file-text.ts create mode 100644 src/extension/commands/private/open-webview.command.ts create mode 100644 src/extension/commands/private/quick-close-file-without-save.command.ts delete mode 100644 src/extension/commands/private/quick-close-file-without-save.ts create mode 100644 src/extension/commands/private/replace-file.command.ts delete mode 100644 src/extension/commands/private/replace-file.ts create mode 100644 src/extension/commands/private/show-aide-key-usage-info.command.ts delete mode 100644 src/extension/commands/private/show-aide-key-usage-info.ts create mode 100644 src/extension/commands/private/show-diff.command.ts delete mode 100644 src/extension/commands/private/show-diff.ts create mode 100644 src/extension/commands/rename-variable/command.ts delete mode 100644 src/extension/commands/rename-variable/index.ts create mode 100644 src/extension/commands/smart-paste/command.ts delete mode 100644 src/extension/commands/smart-paste/index.ts delete mode 100644 src/extension/enable-system-proxy.ts rename src/extension/{ => file-utils}/clipboard.ts (98%) delete mode 100644 src/extension/file-utils/create-tmp-file.ts delete mode 100644 src/extension/file-utils/tmp-file-writer.ts create mode 100644 src/extension/file-utils/tmp-file/create-tmp-file-and-writer.ts create mode 100644 src/extension/file-utils/tmp-file/get-original-file-uri.ts create mode 100644 src/extension/file-utils/tmp-file/get-tmp-file-info.ts create mode 100644 src/extension/file-utils/tmp-file/get-tmp-file-uri.ts create mode 100644 src/extension/file-utils/tmp-file/is-tmp-file-uri.ts create mode 100644 src/extension/file-utils/tmp-file/tmp-file-writer.ts delete mode 100644 src/extension/providers/aide-key-usage-statusbar.ts delete mode 100644 src/extension/providers/index.ts delete mode 100644 src/extension/providers/webview.ts create mode 100644 src/extension/registers/aide-key-usage-statusbar.register.ts create mode 100644 src/extension/registers/auto-open-corresponding-files.register.ts create mode 100644 src/extension/registers/base.register.ts create mode 100644 src/extension/registers/index.ts create mode 100644 src/extension/registers/register-manager.ts create mode 100644 src/extension/registers/system-setup.register.ts rename src/extension/{providers/tmp-file-action.ts => registers/tmp-file-action.register.ts} (67%) create mode 100644 src/extension/registers/webview.register.ts create mode 100644 src/extension/webview-api/api-manager.ts create mode 100644 src/extension/webview-api/chat-context-builder/chat-context-builder.ts create mode 100644 src/extension/webview-api/chat-context-builder/error.ts create mode 100644 src/extension/webview-api/chat-context-builder/index.ts create mode 100644 src/extension/webview-api/chat-context-builder/plugin-manager.ts create mode 100644 src/extension/webview-api/chat-context-builder/plugins/base.plugin.ts create mode 100644 src/extension/webview-api/chat-context-builder/plugins/code-chunks.plugin.ts create mode 100644 src/extension/webview-api/chat-context-builder/plugins/conversation.plugin.ts create mode 100644 src/extension/webview-api/chat-context-builder/plugins/current-file.plugin.ts create mode 100644 src/extension/webview-api/chat-context-builder/plugins/explicit-context.plugin.ts create mode 100644 src/extension/webview-api/chat-context-builder/plugins/git.plugin.ts create mode 100644 src/extension/webview-api/chat-context-builder/types/chat-context/code-block.ts create mode 100644 src/extension/webview-api/chat-context-builder/types/chat-context/file-uri.ts create mode 100644 src/extension/webview-api/chat-context-builder/types/chat-context/index.ts create mode 100644 src/extension/webview-api/chat-context-builder/types/chat-context/message/assistant-suggestions.ts create mode 100644 src/extension/webview-api/chat-context-builder/types/chat-context/message/attachment-info.ts create mode 100644 src/extension/webview-api/chat-context-builder/types/chat-context/message/basic-message.ts create mode 100644 src/extension/webview-api/chat-context-builder/types/chat-context/message/code-related-info.ts create mode 100644 src/extension/webview-api/chat-context-builder/types/chat-context/message/git-related-info.ts create mode 100644 src/extension/webview-api/chat-context-builder/types/chat-context/message/index.ts create mode 100644 src/extension/webview-api/chat-context-builder/types/chat-context/message/interpreter-info.ts create mode 100644 src/extension/webview-api/chat-context-builder/types/chat-context/rich-text/index.ts create mode 100644 src/extension/webview-api/chat-context-builder/types/chat-context/rich-text/mention-type.ts create mode 100644 src/extension/webview-api/chat-context-builder/types/chat-context/rich-text/mention.ts create mode 100644 src/extension/webview-api/chat-context-builder/types/chat-context/rich-text/metadata.ts create mode 100644 src/extension/webview-api/chat-context-builder/types/chat-context/rich-text/selection-type.ts create mode 100644 src/extension/webview-api/chat-context-builder/types/langchain-message.ts create mode 100644 src/extension/webview-api/constant.ts create mode 100644 src/extension/webview-api/controllers/base.controller.ts create mode 100644 src/extension/webview-api/controllers/chat.controller.ts create mode 100644 src/extension/webview-api/controllers/file.controller.ts create mode 100644 src/extension/webview-api/index.ts create mode 100644 src/extension/webview-api/prompts/chat.ts create mode 100644 src/extension/webview-api/prompts/completions.ts create mode 100644 src/extension/webview-api/prompts/composer.ts create mode 100644 src/extension/webview-api/prompts/context.ts create mode 100644 src/extension/webview-api/types.ts delete mode 100644 src/shared/types/msg.ts create mode 100644 src/webview/api/create-webview-api.ts create mode 100644 src/webview/chat-context-manager/index.ts create mode 100644 src/webview/chat-context-manager/managers/base.manager.ts create mode 100644 src/webview/chat-context-manager/managers/conversation.manager.ts create mode 100644 src/webview/chat-context-manager/managers/file.manager.ts create mode 100644 src/webview/chat-context-manager/managers/settings.manager.ts create mode 100644 src/webview/types/vscode.d.ts diff --git a/.eslintrc.json b/.eslintrc.json index b345abe..5bd690f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -94,7 +94,9 @@ "@typescript-eslint/ban-types": "off", "import/no-extraneous-dependencies": "off", "react/jsx-no-bind": "off", - "react/react-in-jsx-scope": "off" + "react/react-in-jsx-scope": "off", + "react/function-component-definition": "off", + "react/no-array-index-key": "off" } } ] diff --git a/package.json b/package.json index 655aa16..7b70c73 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,24 @@ "onStartupFinished" ], "contributes": { + "viewsContainers": { + "activitybar": [ + { + "id": "aide", + "title": "AIDE", + "icon": "res/icon-mask.png" + } + ] + }, + "views": { + "aide": [ + { + "type": "webview", + "id": "aide.webview", + "name": "AIDE" + } + ] + }, "commands": [ { "command": "aide.copyAsPrompt", @@ -102,6 +120,11 @@ "command": "aide.batchProcessor", "title": "%command.batchProcessor%" }, + { + "command": "aide.openWebview", + "title": "%command.openWebview%", + "icon": "res/icon.png" + }, { "command": "aide.copyFileText", "title": "%command.copyFileText%", @@ -164,6 +187,10 @@ } ], "editor/title": [ + { + "command": "aide.openWebview", + "group": "navigation@0" + }, { "command": "aide.codeViewerHelper", "group": "navigation@1" @@ -312,6 +339,7 @@ "@langchain/core": "0.2.23", "@langchain/openai": "^0.2.6", "@tomjs/vite-plugin-vscode": "^2.5.5", + "@types/diff": "^5.2.1", "@types/fs-extra": "^11.0.4", "@types/global-agent": "^2.1.3", "@types/node": "^22.2.0", @@ -326,6 +354,7 @@ "@vscode/vsce": "^2.31.1", "@vscode/webview-ui-toolkit": "^1.4.0", "commitizen": "^4.3.0", + "diff": "^5.2.0", "dotenv": "^16.4.5", "eslint": "^8.57.0", "eslint-config-airbnb": "^19.0.4", @@ -359,6 +388,7 @@ "react-dom": "^18.3.1", "rimraf": "^6.0.1", "shell-quote": "^1.8.1", + "simple-git": "^3.25.0", "tsup": "^8.2.4", "typescript": "5.4.5", "undici": "^6.19.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4652983..49959a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@tomjs/vite-plugin-vscode': specifier: ^2.5.5 version: 2.5.5(@swc/core@1.7.10)(postcss@8.4.40)(typescript@5.4.5)(vite@5.4.0(@types/node@22.2.0)(less@4.2.0)) + '@types/diff': + specifier: ^5.2.1 + version: 5.2.1 '@types/fs-extra': specifier: ^11.0.4 version: 11.0.4 @@ -80,6 +83,9 @@ importers: commitizen: specifier: ^4.3.0 version: 4.3.0(@types/node@22.2.0)(typescript@5.4.5) + diff: + specifier: ^5.2.0 + version: 5.2.0 dotenv: specifier: ^16.4.5 version: 16.4.5 @@ -179,6 +185,9 @@ importers: shell-quote: specifier: ^1.8.1 version: 1.8.1 + simple-git: + specifier: ^3.25.0 + version: 3.25.0 tsup: specifier: ^8.2.4 version: 8.2.4(@swc/core@1.7.10)(jiti@1.21.6)(postcss@8.4.40)(tsx@4.16.3)(typescript@5.4.5)(yaml@2.5.0) @@ -1352,6 +1361,12 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@kwsites/file-exists@1.1.1': + resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} + + '@kwsites/promise-deferred@1.1.1': + resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} + '@langchain/anthropic@0.2.14': resolution: {integrity: sha512-qTFlsMej8SE0hz6IrqcQTkza/TGnlc7Tq/9W65TjQGLX51rGCYkprbLfpTi/LL9gahdB9VvB2Q5knUL0/N/xtQ==} engines: {node: '>=18'} @@ -1611,6 +1626,9 @@ packages: '@types/conventional-commits-parser@5.0.0': resolution: {integrity: sha512-loB369iXNmAZglwWATL+WRe+CRMmmBPtpolYzIebFaX4YA3x+BEfLqhUAV9WanycKI3TG1IMr5bMJDajDKLlUQ==} + '@types/diff@5.2.1': + resolution: {integrity: sha512-uxpcuwWJGhe2AR1g8hD9F5OYGCqjqWnBUQFD8gMZsDbv8oPHzxJF6iMO6n8Tk0AdzlxoaaoQhOYlIg/PukVU8g==} + '@types/eslint@8.56.10': resolution: {integrity: sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==} @@ -2599,6 +2617,10 @@ packages: detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + diff@5.2.0: + resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} + engines: {node: '>=0.3.1'} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -4828,6 +4850,9 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-git@3.25.0: + resolution: {integrity: sha512-KIY5sBnzc4yEcJXW7Tdv4viEz8KyG+nU0hay+DWZasvdFOYKeUZ6Xc25LUHHjw0tinPT7O1eY6pzX7pRT1K8rw==} + sirv@2.0.4: resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} engines: {node: '>= 10'} @@ -6714,6 +6739,14 @@ snapshots: '@jridgewell/resolve-uri': 3.1.1 '@jridgewell/sourcemap-codec': 1.4.15 + '@kwsites/file-exists@1.1.1': + dependencies: + debug: 4.3.6 + transitivePeerDependencies: + - supports-color + + '@kwsites/promise-deferred@1.1.1': {} + '@langchain/anthropic@0.2.14(langchain@0.2.16)(openai@4.55.4(zod@3.23.8))': dependencies: '@anthropic-ai/sdk': 0.22.0 @@ -7027,6 +7060,8 @@ snapshots: dependencies: '@types/node': 22.2.0 + '@types/diff@5.2.1': {} + '@types/eslint@8.56.10': dependencies: '@types/estree': 1.0.5 @@ -8214,6 +8249,8 @@ snapshots: detect-node@2.1.0: {} + diff@5.2.0: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -10576,6 +10613,14 @@ snapshots: simple-concat: 1.0.1 optional: true + simple-git@3.25.0: + dependencies: + '@kwsites/file-exists': 1.1.1 + '@kwsites/promise-deferred': 1.1.1 + debug: 4.3.6 + transitivePeerDependencies: + - supports-color + sirv@2.0.4: dependencies: '@polka/url': 1.0.0-next.25 diff --git a/src/extension/ai/get-reference-file-paths.ts b/src/extension/ai/get-reference-file-paths.ts index c359165..487380e 100644 --- a/src/extension/ai/get-reference-file-paths.ts +++ b/src/extension/ai/get-reference-file-paths.ts @@ -1,9 +1,6 @@ import { AbortError } from '@extension/constants' import { traverseFileOrFolders } from '@extension/file-utils/traverse-fs' -import { - getCurrentWorkspaceFolderEditor, - toPlatformPath -} from '@extension/utils' +import { getWorkspaceFolder, toPlatformPath } from '@extension/utils' import * as vscode from 'vscode' import { z } from 'zod' @@ -21,7 +18,7 @@ export const getReferenceFilePaths = async ({ currentFilePath: string abortController?: AbortController }): Promise => { - const { workspaceFolder } = await getCurrentWorkspaceFolderEditor() + const workspaceFolder = getWorkspaceFolder() const allRelativePaths: string[] = [] await traverseFileOrFolders( diff --git a/src/extension/ai/model-providers/azure-openai.ts b/src/extension/ai/model-providers/azure-openai.ts index 9d10c1d..1a05700 100644 --- a/src/extension/ai/model-providers/azure-openai.ts +++ b/src/extension/ai/model-providers/azure-openai.ts @@ -1,13 +1,11 @@ /* eslint-disable no-useless-escape */ import { getConfigKey } from '@extension/config' -import { getContext } from '@extension/context' import { t } from '@extension/i18n' import { AzureChatOpenAI, ChatOpenAI, type ChatOpenAICallOptions } from '@langchain/openai' -import * as vscode from 'vscode' import { parseModelBaseUrl } from '../parse-model-base-url' import { BaseModelProvider } from './base' @@ -16,7 +14,6 @@ export class AzureOpenAIModelProvider extends BaseModelProvider< ChatOpenAI > { async createModel() { - const isDev = getContext().extensionMode !== vscode.ExtensionMode.Production const { url: openaiBaseUrl } = await parseModelBaseUrl() const openaiKey = await getConfigKey('openaiKey') @@ -48,7 +45,7 @@ export class AzureOpenAIModelProvider extends BaseModelProvider< fetch }, temperature: 0.95, // never use 1.0, some models do not support it - verbose: isDev, + verbose: this.isDev, maxRetries: 3 }) ;('https://westeurope.api.microsoft.com/openai/deployments/devName/chat/completions?api-version=AVersion') diff --git a/src/extension/ai/model-providers/base.ts b/src/extension/ai/model-providers/base.ts index afcfcf3..46e81c3 100644 --- a/src/extension/ai/model-providers/base.ts +++ b/src/extension/ai/model-providers/base.ts @@ -1,5 +1,5 @@ import type { MaybePromise } from '@extension/types/common' -import { normalizeLineEndings } from '@extension/utils' +import { getIsDev, normalizeLineEndings } from '@extension/utils' import { InMemoryChatMessageHistory } from '@langchain/core/chat_history' import type { BaseChatModel } from '@langchain/core/language_models/chat_models' import { @@ -50,6 +50,10 @@ export abstract class BaseModelProvider { ) } + get isDev() { + return getIsDev() + } + model?: Model abstract createModel(): MaybePromise diff --git a/src/extension/ai/model-providers/claude.ts b/src/extension/ai/model-providers/claude.ts index 79bad41..4829d53 100644 --- a/src/extension/ai/model-providers/claude.ts +++ b/src/extension/ai/model-providers/claude.ts @@ -1,15 +1,11 @@ import { getConfigKey } from '@extension/config' -import { getContext } from '@extension/context' import { ChatAnthropic } from '@langchain/anthropic' -import * as vscode from 'vscode' import { parseModelBaseUrl } from '../parse-model-base-url' import { BaseModelProvider } from './base' export class AnthropicModelProvider extends BaseModelProvider { async createModel() { - const isDev = getContext().extensionMode !== vscode.ExtensionMode.Production - // anthropic@https://api.anthropic.com const { url: openaiBaseUrl } = await parseModelBaseUrl() const openaiKey = await getConfigKey('openaiKey') @@ -24,7 +20,7 @@ export class AnthropicModelProvider extends BaseModelProvider { model: openaiModel, temperature: 0.95, // never use 1.0, some models do not support it maxRetries: 6, - verbose: isDev + verbose: this.isDev }) return model diff --git a/src/extension/ai/model-providers/openai.ts b/src/extension/ai/model-providers/openai.ts index b489b2b..88e7e5d 100644 --- a/src/extension/ai/model-providers/openai.ts +++ b/src/extension/ai/model-providers/openai.ts @@ -1,7 +1,5 @@ import { getConfigKey } from '@extension/config' -import { getContext } from '@extension/context' import { ChatOpenAI, type ChatOpenAICallOptions } from '@langchain/openai' -import * as vscode from 'vscode' import { parseModelBaseUrl } from '../parse-model-base-url' import { BaseModelProvider } from './base' @@ -10,7 +8,6 @@ export class OpenAIModelProvider extends BaseModelProvider< ChatOpenAI > { async createModel() { - const isDev = getContext().extensionMode !== vscode.ExtensionMode.Production const { url: openaiBaseUrl } = await parseModelBaseUrl() const openaiKey = await getConfigKey('openaiKey') const openaiModel = await getConfigKey('openaiModel') @@ -24,7 +21,7 @@ export class OpenAIModelProvider extends BaseModelProvider< model: openaiModel, temperature: 0.95, // never use 1.0, some models do not support it maxRetries: 3, - verbose: isDev + verbose: this.isDev }) // some third-party language models are not compatible with the openAI specification, diff --git a/src/extension/auto-open-corresponding-files.ts b/src/extension/auto-open-corresponding-files.ts deleted file mode 100644 index 1462dc3..0000000 --- a/src/extension/auto-open-corresponding-files.ts +++ /dev/null @@ -1,57 +0,0 @@ -import * as vscode from 'vscode' - -import { getOriginalFileUri, isTmpFileUri } from './file-utils/create-tmp-file' -import { VsCodeFS } from './file-utils/vscode-fs' -import { logger } from './logger' - -let isHandlingEditorChange = false - -const openCorrespondingFiles = async (tmpUri: vscode.Uri): Promise => { - if (isHandlingEditorChange || !isTmpFileUri(tmpUri)) return - isHandlingEditorChange = true - const originalUri = getOriginalFileUri(tmpUri) - - try { - // check if the original file exists - await VsCodeFS.stat(originalUri.fsPath) - - // open original file - const originalDocument = - await vscode.workspace.openTextDocument(originalUri) - await vscode.window.showTextDocument( - originalDocument, - vscode.ViewColumn.One - ) - - // 重新聚焦到 .aide.vue 文件 - // refocus on the .aide file - const tmpDocument = await vscode.workspace.openTextDocument(tmpUri) - await vscode.window.showTextDocument(tmpDocument, vscode.ViewColumn.Two) - } catch (e) { - logger.warn('openCorrespondingFiles error', e) - } finally { - isHandlingEditorChange = false - } -} - -export const autoOpenCorrespondingFiles = ( - context: vscode.ExtensionContext -) => { - context.subscriptions.push( - vscode.workspace.onDidOpenTextDocument(async document => { - const maybeTmpUri = document.uri - if (isTmpFileUri(maybeTmpUri)) { - await openCorrespondingFiles(maybeTmpUri) - } - }) - ) - - context.subscriptions.push( - vscode.window.onDidChangeActiveTextEditor(editor => { - const maybeTmpUri = editor?.document.uri - if (maybeTmpUri && isTmpFileUri(maybeTmpUri) && !isHandlingEditorChange) { - openCorrespondingFiles(maybeTmpUri) - } - }) - ) -} diff --git a/src/extension/cleanup.ts b/src/extension/cleanup.ts deleted file mode 100644 index ee49c89..0000000 --- a/src/extension/cleanup.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as vscode from 'vscode' - -import { cleanupCodeConvertRunnables } from './commands/code-convert' -import { cleanupCodeViewerHelperRunnables } from './commands/code-viewer-helper' -import { cleanupExpertCodeEnhancerRunnables } from './commands/expert-code-enhancer' -import { cleanupSmartPasteRunnables } from './commands/smart-paste' - -export const cleanup = async (context: vscode.ExtensionContext) => { - context.subscriptions.push( - vscode.workspace.onDidCloseTextDocument(() => { - cleanupCodeConvertRunnables() - cleanupCodeViewerHelperRunnables() - cleanupExpertCodeEnhancerRunnables() - cleanupSmartPasteRunnables() - }) - ) -} diff --git a/src/extension/commands/ask-ai/command.ts b/src/extension/commands/ask-ai/command.ts new file mode 100644 index 0000000..a0413b6 --- /dev/null +++ b/src/extension/commands/ask-ai/command.ts @@ -0,0 +1,86 @@ +import path from 'path' +import { getConfigKey } from '@extension/config' +import { + traverseFileOrFolders, + type FileInfo +} from '@extension/file-utils/traverse-fs' +import { t } from '@extension/i18n' +import { executeCommand } from '@extension/utils' +import { quote } from 'shell-quote' +import * as vscode from 'vscode' + +import { BaseCommand } from '../base.command' + +export class AskAICommand extends BaseCommand { + get commandName(): string { + return 'aide.askAI' + } + + async run(uri: vscode.Uri, selectedUris: vscode.Uri[] = []): Promise { + const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri) + if (!workspaceFolder) throw new Error(t('error.noWorkspace')) + + const selectedItems = selectedUris?.length > 0 ? selectedUris : [uri] + if (selectedItems.length === 0) throw new Error(t('error.noSelection')) + + const selectedFileOrFolders = selectedItems.map(item => item.fsPath) + let filesPrompt = '' + let filesRelativePath = '' + let filesFullPath = '' + + const processFile = async (fileInfo: FileInfo) => { + const { fullPath, relativePath, content } = fileInfo + const language = path.extname(fullPath).slice(1) + const promptFullContent = t( + 'file.content', + relativePath, + language, + content.toString() + ) + + filesPrompt += promptFullContent + filesRelativePath += ` "${quote([relativePath.trim()])}" ` + filesFullPath += ` "${quote([fullPath.trim()])}" ` + } + + await traverseFileOrFolders( + selectedFileOrFolders, + workspaceFolder.uri.fsPath, + processFile + ) + + const aiCommand = await getConfigKey('aiCommand') + const aiCommandCopyBeforeRun = await getConfigKey('aiCommandCopyBeforeRun') + const aiCommandAutoRun = await getConfigKey('aiCommandAutoRun') + let userInput = '' + + if (aiCommand.includes('#{question}')) { + userInput = + (await vscode.window.showInputBox({ + prompt: t('input.aiCommand.prompt'), + placeHolder: t('input.aiCommand.placeholder') + })) || '' + } + + const finalCommand = aiCommand + .replace(/#{filesRelativePath}/g, filesRelativePath) + .replace(/#{filesFullPath}/g, filesFullPath) + .replace(/#{question}/g, ` "${quote([userInput.trim()])}" `) + .replace(/#{content}/g, ` "${quote([filesPrompt.trim()])}" `) + + if (aiCommandCopyBeforeRun) { + await vscode.env.clipboard.writeText(finalCommand) + + // Show info message only if the command is not set to auto-run + if (!aiCommandAutoRun) { + await vscode.window.showInformationMessage( + t('info.commandCopiedToClipboard') + ) + } + } + + if (aiCommandAutoRun) { + await executeCommand(finalCommand, workspaceFolder.uri.fsPath) + } + } +} diff --git a/src/extension/commands/ask-ai/index.ts b/src/extension/commands/ask-ai/index.ts deleted file mode 100644 index bbab6c0..0000000 --- a/src/extension/commands/ask-ai/index.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* eslint-disable @typescript-eslint/no-loop-func */ -import path from 'node:path' -import { getConfigKey } from '@extension/config' -import { - traverseFileOrFolders, - type FileInfo -} from '@extension/file-utils/traverse-fs' -import { t } from '@extension/i18n' -import { executeCommand } from '@extension/utils' -import { quote } from 'shell-quote' -import * as vscode from 'vscode' - -export const handleAskAI = async ( - uri: vscode.Uri, - selectedUris: vscode.Uri[] = [] -) => { - const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri) - if (!workspaceFolder) throw new Error(t('error.noWorkspace')) - - const selectedItems = selectedUris?.length > 0 ? selectedUris : [uri] - if (selectedItems.length === 0) throw new Error(t('error.noSelection')) - - const selectedFileOrFolders = selectedItems.map(item => item.fsPath) - let filesPrompt = '' - let filesRelativePath = '' - let filesFullPath = '' - - const processFile = async (fileInfo: FileInfo) => { - const { fullPath, relativePath, content } = fileInfo - const language = path.extname(fullPath).slice(1) - const promptFullContent = t( - 'file.content', - relativePath, - language, - content.toString() - ) - - filesPrompt += promptFullContent - filesRelativePath += ` "${quote([relativePath.trim()])}" ` - filesFullPath += ` "${quote([fullPath.trim()])}" ` - } - - await traverseFileOrFolders( - selectedFileOrFolders, - workspaceFolder.uri.fsPath, - processFile - ) - - const aiCommand = await getConfigKey('aiCommand') - const aiCommandCopyBeforeRun = await getConfigKey('aiCommandCopyBeforeRun') - const aiCommandAutoRun = await getConfigKey('aiCommandAutoRun') - let userInput = '' - - if (aiCommand.includes('#{question}')) { - userInput = - (await vscode.window.showInputBox({ - prompt: t('input.aiCommand.prompt'), - placeHolder: t('input.aiCommand.placeholder') - })) || '' - } - - const finalCommand = aiCommand - .replace(/#{filesRelativePath}/g, filesRelativePath) - .replace(/#{filesFullPath}/g, filesFullPath) - .replace(/#{question}/g, ` "${quote([userInput.trim()])}" `) - .replace(/#{content}/g, ` "${quote([filesPrompt.trim()])}" `) - - if (aiCommandCopyBeforeRun) { - await vscode.env.clipboard.writeText(finalCommand) - - // Show info message only if the command is not set to auto-run - if (!aiCommandAutoRun) { - await vscode.window.showInformationMessage( - t('info.commandCopiedToClipboard') - ) - } - } - - if (aiCommandAutoRun) { - await executeCommand(finalCommand, workspaceFolder.uri.fsPath) - } -} diff --git a/src/extension/commands/base.command.ts b/src/extension/commands/base.command.ts new file mode 100644 index 0000000..4be47f6 --- /dev/null +++ b/src/extension/commands/base.command.ts @@ -0,0 +1,24 @@ +import { commandWithCatcher } from '@extension/utils' +import * as vscode from 'vscode' + +import type { CommandManager } from './command-manager' + +export abstract class BaseCommand { + constructor( + protected context: vscode.ExtensionContext, + protected commandManager: CommandManager + ) {} + + abstract get commandName(): string + + abstract run(...args: any[]): Promise + + cleanup(): Promise | void {} + + register(): vscode.Disposable { + return vscode.commands.registerCommand( + this.commandName, + commandWithCatcher(this.run.bind(this)) + ) + } +} diff --git a/src/extension/commands/batch-processor/command.ts b/src/extension/commands/batch-processor/command.ts new file mode 100644 index 0000000..dff7590 --- /dev/null +++ b/src/extension/commands/batch-processor/command.ts @@ -0,0 +1,103 @@ +import { getConfigKey } from '@extension/config' +import { isTmpFileUri } from '@extension/file-utils/tmp-file/is-tmp-file-uri' +import { traverseFileOrFolders } from '@extension/file-utils/traverse-fs' +import { t } from '@extension/i18n' +import { createLoading } from '@extension/loading' +import { logger } from '@extension/logger' +import { stateStorage } from '@extension/storage' +import { AbortError } from 'node-fetch' +import pLimit from 'p-limit' +import * as vscode from 'vscode' + +import { BaseCommand } from '../base.command' +import { getPreProcessInfo } from './get-pre-process-info' +import { writeAndSaveTmpFile } from './write-and-save-tmp-file' + +export class BatchProcessorCommand extends BaseCommand { + get commandName(): string { + return 'aide.batchProcessor' + } + + async run(uri: vscode.Uri, selectedUris: vscode.Uri[] = []): Promise { + const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri) + if (!workspaceFolder) throw new Error(t('error.noWorkspace')) + + const selectedItems = selectedUris?.length > 0 ? selectedUris : [uri] + if (selectedItems.length === 0) throw new Error(t('error.noSelection')) + + const selectedFileOrFolders = selectedItems.map(item => item.fsPath) + const filesInfo = await traverseFileOrFolders( + selectedFileOrFolders, + workspaceFolder.uri.fsPath, + fileInfo => fileInfo + ) + const fileRelativePathsForProcess = filesInfo + .filter(fileInfo => !isTmpFileUri(vscode.Uri.file(fileInfo.fullPath))) + .map(fileInfo => fileInfo.relativePath) + + // show input box + const prompt = await vscode.window.showInputBox({ + prompt: t( + 'input.batchProcessor.prompt', + fileRelativePathsForProcess.length + ), + placeHolder: t('input.batchProcessor.placeholder'), + value: stateStorage.getItem('batchProcessorLastPrompt') || '' + }) + + if (!prompt) return + stateStorage.setItem('batchProcessorLastPrompt', prompt) + + const abortController = new AbortController() + const { showProcessLoading, hideProcessLoading } = createLoading() + + try { + showProcessLoading({ + onCancel() { + abortController.abort() + } + }) + + const preProcessInfo = await getPreProcessInfo({ + prompt, + fileRelativePathsForProcess, + abortController + }) + + logger.log('handleBatchProcessor', preProcessInfo) + + if (abortController?.signal.aborted) throw AbortError + + const apiConcurrency = (await getConfigKey('apiConcurrency')) || 1 + const limit = pLimit(apiConcurrency) + const promises = preProcessInfo.processFilePathInfo.map(info => + limit(() => + writeAndSaveTmpFile({ + prompt, + workspacePath: workspaceFolder.uri.fsPath, + allFileRelativePaths: preProcessInfo.allFileRelativePaths, + sourceFileRelativePath: info.sourceFileRelativePath, + processedFileRelativePath: info.processedFileRelativePath, + dependenceFileRelativePath: + preProcessInfo.dependenceFileRelativePath, + abortController + }).catch(err => logger.warn('writeAndSaveTmpFile error', err)) + ) + ) + + await Promise.allSettled(promises) + + hideProcessLoading() + + await vscode.window.showInformationMessage( + t( + 'info.batchProcessorSuccess', + preProcessInfo.processFilePathInfo.length, + prompt + ) + ) + } finally { + hideProcessLoading() + } + } +} diff --git a/src/extension/commands/batch-processor/get-pre-process-info.ts b/src/extension/commands/batch-processor/get-pre-process-info.ts index b383898..2f21d77 100644 --- a/src/extension/commands/batch-processor/get-pre-process-info.ts +++ b/src/extension/commands/batch-processor/get-pre-process-info.ts @@ -2,10 +2,7 @@ import path from 'path' import { createModelProvider } from '@extension/ai/helpers' import { AbortError } from '@extension/constants' import { traverseFileOrFolders } from '@extension/file-utils/traverse-fs' -import { - getCurrentWorkspaceFolderEditor, - toPlatformPath -} from '@extension/utils' +import { getWorkspaceFolder, toPlatformPath } from '@extension/utils' import { z } from 'zod' export interface PreProcessInfo { @@ -31,7 +28,7 @@ export const getPreProcessInfo = async ({ allFileRelativePaths: string[] } > => { - const { workspaceFolder } = await getCurrentWorkspaceFolderEditor() + const workspaceFolder = getWorkspaceFolder() const allFileRelativePaths: string[] = [] await traverseFileOrFolders( diff --git a/src/extension/commands/batch-processor/index.ts b/src/extension/commands/batch-processor/index.ts deleted file mode 100644 index 437b857..0000000 --- a/src/extension/commands/batch-processor/index.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { getConfigKey } from '@extension/config' -import { AbortError } from '@extension/constants' -import { isTmpFileUri } from '@extension/file-utils/create-tmp-file' -import { traverseFileOrFolders } from '@extension/file-utils/traverse-fs' -import { t } from '@extension/i18n' -import { createLoading } from '@extension/loading' -import { logger } from '@extension/logger' -import { stateStorage } from '@extension/storage' -import pLimit from 'p-limit' -import * as vscode from 'vscode' - -import { getPreProcessInfo } from './get-pre-process-info' -import { writeAndSaveTmpFile } from './write-and-save-tmp-file' - -export const handleBatchProcessor = async ( - uri: vscode.Uri, - selectedUris: vscode.Uri[] = [] -) => { - const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri) - if (!workspaceFolder) throw new Error(t('error.noWorkspace')) - - const selectedItems = selectedUris?.length > 0 ? selectedUris : [uri] - if (selectedItems.length === 0) throw new Error(t('error.noSelection')) - - const selectedFileOrFolders = selectedItems.map(item => item.fsPath) - const filesInfo = await traverseFileOrFolders( - selectedFileOrFolders, - workspaceFolder.uri.fsPath, - fileInfo => fileInfo - ) - const fileRelativePathsForProcess = filesInfo - .filter(fileInfo => !isTmpFileUri(vscode.Uri.file(fileInfo.fullPath))) - .map(fileInfo => fileInfo.relativePath) - - // show input box - const prompt = await vscode.window.showInputBox({ - prompt: t( - 'input.batchProcessor.prompt', - fileRelativePathsForProcess.length - ), - placeHolder: t('input.batchProcessor.placeholder'), - value: stateStorage.getItem('batchProcessorLastPrompt') || '' - }) - - if (!prompt) return - stateStorage.setItem('batchProcessorLastPrompt', prompt) - - const abortController = new AbortController() - const { showProcessLoading, hideProcessLoading } = createLoading() - - try { - showProcessLoading({ - onCancel() { - abortController.abort() - } - }) - - const preProcessInfo = await getPreProcessInfo({ - prompt, - fileRelativePathsForProcess, - abortController - }) - - logger.log('handleBatchProcessor', preProcessInfo) - - if (abortController?.signal.aborted) throw AbortError - - const apiConcurrency = (await getConfigKey('apiConcurrency')) || 1 - const limit = pLimit(apiConcurrency) - const promises = preProcessInfo.processFilePathInfo.map(info => - limit(() => - writeAndSaveTmpFile({ - prompt, - workspacePath: workspaceFolder.uri.fsPath, - allFileRelativePaths: preProcessInfo.allFileRelativePaths, - sourceFileRelativePath: info.sourceFileRelativePath, - processedFileRelativePath: info.processedFileRelativePath, - dependenceFileRelativePath: preProcessInfo.dependenceFileRelativePath, - abortController - }).catch(err => logger.warn('writeAndSaveTmpFile error', err)) - ) - ) - - await Promise.allSettled(promises) - hideProcessLoading() - await vscode.window.showInformationMessage( - t( - 'info.batchProcessorSuccess', - preProcessInfo.processFilePathInfo.length, - prompt - ) - ) - } finally { - hideProcessLoading() - } -} diff --git a/src/extension/commands/batch-processor/write-and-save-tmp-file.ts b/src/extension/commands/batch-processor/write-and-save-tmp-file.ts index 76571d0..4cc2ad5 100644 --- a/src/extension/commands/batch-processor/write-and-save-tmp-file.ts +++ b/src/extension/commands/batch-processor/write-and-save-tmp-file.ts @@ -1,8 +1,8 @@ import path from 'path' import { createModelProvider } from '@extension/ai/helpers' import { AbortError } from '@extension/constants' -import { getTmpFileUri } from '@extension/file-utils/create-tmp-file' -import { tmpFileWriter } from '@extension/file-utils/tmp-file-writer' +import { getTmpFileUri } from '@extension/file-utils/tmp-file/get-tmp-file-uri' +import { tmpFileWriter } from '@extension/file-utils/tmp-file/tmp-file-writer' import { VsCodeFS } from '@extension/file-utils/vscode-fs' import { logger } from '@extension/logger' import { getLanguageId } from '@extension/utils' @@ -73,7 +73,9 @@ export const writeAndSaveTmpFile = async ({ enableProcessLoading: false, autoSaveWhenDone: true, autoCloseWhenDone: true, - tmpFileUri: processedFileUri, + tmpFileOptions: { + tmpFileUri: processedFileUri + }, onCancel() { abortController?.abort() }, diff --git a/src/extension/commands/code-convert/command.ts b/src/extension/commands/code-convert/command.ts new file mode 100644 index 0000000..05b3e3d --- /dev/null +++ b/src/extension/commands/code-convert/command.ts @@ -0,0 +1,103 @@ +import { + createModelProvider, + getCurrentSessionIdHistoriesMap +} from '@extension/ai/helpers' +import { showContinueMessage } from '@extension/file-utils/show-continue-message' +import { getOriginalFileUri } from '@extension/file-utils/tmp-file/get-original-file-uri' +import { getTmpFileInfo } from '@extension/file-utils/tmp-file/get-tmp-file-info' +import { tmpFileWriter } from '@extension/file-utils/tmp-file/tmp-file-writer' +import { t } from '@extension/i18n' +import type { RunnableConfig } from '@langchain/core/runnables' +import * as vscode from 'vscode' + +import { BaseCommand } from '../base.command' +import { buildConvertPrompt } from './build-convert-prompt' +import { getTargetLanguageInfo } from './get-target-language-info' + +export class CodeConvertCommand extends BaseCommand { + get commandName(): string { + return 'aide.codeConvert' + } + + async run(): Promise { + const originalFileUri = getOriginalFileUri() + const tmpFileInfo = await getTmpFileInfo(originalFileUri) + + const { targetLanguageId, targetLanguageExt, targetLanguageDescription } = + await getTargetLanguageInfo(tmpFileInfo.originalFileLanguageId) + + // ai + const modelProvider = await createModelProvider() + const aiRunnableAbortController = new AbortController() + const aiRunnable = await modelProvider.createRunnable({ + signal: aiRunnableAbortController.signal + }) + const sessionId = `codeConvert:${tmpFileInfo.tmpFileUri.fsPath}}` + const aiRunnableConfig: RunnableConfig = { + configurable: { + sessionId + } + } + + const sessionIdHistoriesMap = await getCurrentSessionIdHistoriesMap() + const isSessionHistoryExists = !!sessionIdHistoriesMap[sessionId] + const isContinue = tmpFileInfo.isTmpFileHasContent && isSessionHistoryExists + + const prompt = await buildConvertPrompt({ + sourceLanguageId: tmpFileInfo.originalFileLanguageId, + targetLanguageId, + targetLanguageDescription, + sourceCode: tmpFileInfo.originalFileContent + }) + + const tmpFileWriterReturns = await tmpFileWriter({ + tmpFileOptions: { + languageId: targetLanguageId, + ext: targetLanguageExt + }, + onCancel() { + aiRunnableAbortController.abort() + }, + buildAiStream: async () => { + if (!isContinue) { + delete sessionIdHistoriesMap[sessionId] + return aiRunnable.stream({ input: prompt }, aiRunnableConfig) + } + + return aiRunnable.stream( + { + input: + 'continue, please do not reply with any text other than the code, and do not use markdown syntax. go continue.' + }, + aiRunnableConfig + ) + } + }) + + await showContinueMessage({ + tmpFileUri: tmpFileWriterReturns.tmpFileUri, + originalFileContentLineCount: + tmpFileInfo.originalFileContent.split('\n').length, + continueMessage: + t('info.continueMessage') + t('info.iconContinueMessage'), + onContinue: async () => { + await this.run() + } + }) + } + + async cleanup(): Promise { + const openDocumentPaths = new Set( + vscode.workspace.textDocuments.map(doc => doc.uri.fsPath) + ) + + const sessionIdHistoriesMap = await getCurrentSessionIdHistoriesMap() + Object.keys(sessionIdHistoriesMap).forEach(sessionId => { + const path = sessionId.match(/^codeConvert:(.*)$/)?.[1] + + if (path && !openDocumentPaths.has(path)) { + delete sessionIdHistoriesMap[sessionId] + } + }) + } +} diff --git a/src/extension/commands/code-convert/index.ts b/src/extension/commands/code-convert/index.ts deleted file mode 100644 index aad8504..0000000 --- a/src/extension/commands/code-convert/index.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { - createModelProvider, - getCurrentSessionIdHistoriesMap -} from '@extension/ai/helpers' -import { createTmpFileInfo } from '@extension/file-utils/create-tmp-file' -import { showContinueMessage } from '@extension/file-utils/show-continue-message' -import { tmpFileWriter } from '@extension/file-utils/tmp-file-writer' -import { t } from '@extension/i18n' -import type { RunnableConfig } from '@langchain/core/runnables' -import * as vscode from 'vscode' - -import { buildConvertPrompt } from './build-convert-prompt' -import { getTargetLanguageInfo } from './get-target-language-info' - -export const cleanupCodeConvertRunnables = async () => { - const openDocumentPaths = new Set( - vscode.workspace.textDocuments.map(doc => doc.uri.fsPath) - ) - - const sessionIdHistoriesMap = await getCurrentSessionIdHistoriesMap() - - Object.keys(sessionIdHistoriesMap).forEach(sessionId => { - const path = sessionId.match(/^codeConvert:(.*)$/)?.[1] - - if (path && !openDocumentPaths.has(path)) { - delete sessionIdHistoriesMap[sessionId] - } - }) -} - -export const handleCodeConvert = async () => { - const { - originalFileContent, - originalFileLanguageId, - tmpFileUri, - isTmpFileHasContent - } = await createTmpFileInfo() - - const { targetLanguageId, targetLanguageExt, targetLanguageDescription } = - await getTargetLanguageInfo(originalFileLanguageId) - - // ai - const modelProvider = await createModelProvider() - const aiRunnableAbortController = new AbortController() - const aiRunnable = await modelProvider.createRunnable({ - signal: aiRunnableAbortController.signal - }) - const sessionId = `codeConvert:${tmpFileUri.fsPath}}` - const aiRunnableConfig: RunnableConfig = { - configurable: { - sessionId - } - } - - const sessionIdHistoriesMap = await getCurrentSessionIdHistoriesMap() - const isSessionHistoryExists = !!sessionIdHistoriesMap[sessionId] - const isContinue = isTmpFileHasContent && isSessionHistoryExists - - const prompt = await buildConvertPrompt({ - sourceLanguageId: originalFileLanguageId, - targetLanguageId, - targetLanguageDescription, - sourceCode: originalFileContent - }) - - const tmpFileWriterReturns = await tmpFileWriter({ - languageId: targetLanguageId, - ext: targetLanguageExt, - onCancel() { - aiRunnableAbortController.abort() - }, - buildAiStream: async () => { - if (!isContinue) { - // cleanup previous session - delete sessionIdHistoriesMap[sessionId] - - const aiStream = aiRunnable.stream( - { - input: prompt - }, - aiRunnableConfig - ) - return aiStream - } - - // continue - return aiRunnable.stream( - { - input: ` - continue, please do not reply with any text other than the code, and do not use markdown syntax. - go continue. - ` - }, - aiRunnableConfig - ) - } - }) - - await showContinueMessage({ - tmpFileUri: tmpFileWriterReturns.tmpFileUri, - originalFileContentLineCount: originalFileContent.split('\n').length, - continueMessage: t('info.continueMessage') + t('info.iconContinueMessage'), - onContinue: async () => { - await handleCodeConvert() - } - }) -} diff --git a/src/extension/commands/code-viewer-helper/command.ts b/src/extension/commands/code-viewer-helper/command.ts new file mode 100644 index 0000000..913f532 --- /dev/null +++ b/src/extension/commands/code-viewer-helper/command.ts @@ -0,0 +1,92 @@ +import { + createModelProvider, + getCurrentSessionIdHistoriesMap +} from '@extension/ai/helpers' +import { showContinueMessage } from '@extension/file-utils/show-continue-message' +import { getOriginalFileUri } from '@extension/file-utils/tmp-file/get-original-file-uri' +import { getTmpFileInfo } from '@extension/file-utils/tmp-file/get-tmp-file-info' +import { tmpFileWriter } from '@extension/file-utils/tmp-file/tmp-file-writer' +import { t } from '@extension/i18n' +import type { RunnableConfig } from '@langchain/core/runnables' +import * as vscode from 'vscode' + +import { BaseCommand } from '../base.command' +import { buildGeneratePrompt } from './build-generate-prompt' + +export class CodeViewerHelperCommand extends BaseCommand { + get commandName(): string { + return 'aide.codeViewerHelper' + } + + async run(): Promise { + const originalFileUri = getOriginalFileUri() + const tmpFileInfo = await getTmpFileInfo(originalFileUri) + + const modelProvider = await createModelProvider() + const aiRunnableAbortController = new AbortController() + const aiRunnable = await modelProvider.createRunnable({ + signal: aiRunnableAbortController.signal + }) + const sessionId = `codeViewerHelper:${tmpFileInfo.tmpFileUri.fsPath}}` + const aiRunnableConfig: RunnableConfig = { + configurable: { + sessionId + } + } + const sessionIdHistoriesMap = await getCurrentSessionIdHistoriesMap() + const isSessionHistoryExists = !!sessionIdHistoriesMap[sessionId] + const isContinue = tmpFileInfo.isTmpFileHasContent && isSessionHistoryExists + + const prompt = await buildGeneratePrompt({ + sourceLanguage: tmpFileInfo.originalFileLanguageId, + code: tmpFileInfo.originalFileContent + }) + + const tmpFileWriterReturns = await tmpFileWriter({ + tmpFileOptions: { + ext: tmpFileInfo.originalFileExt, + languageId: tmpFileInfo.originalFileLanguageId + }, + onCancel() { + aiRunnableAbortController.abort() + }, + buildAiStream: async () => { + if (!isContinue) { + delete sessionIdHistoriesMap[sessionId] + return aiRunnable.stream({ input: prompt }, aiRunnableConfig) + } + return aiRunnable.stream( + { + input: + 'continue, please do not reply with any text other than the code, and do not use markdown syntax. go continue.' + }, + aiRunnableConfig + ) + } + }) + + await showContinueMessage({ + tmpFileUri: tmpFileWriterReturns?.tmpFileUri, + originalFileContentLineCount: + tmpFileInfo.originalFileContent.split('\n').length, + continueMessage: + t('info.continueMessage') + t('info.iconContinueMessage'), + onContinue: () => this.run() + }) + } + + async cleanup(): Promise { + const openDocumentPaths = new Set( + vscode.workspace.textDocuments.map(doc => doc.uri.fsPath) + ) + + const sessionIdHistoriesMap = await getCurrentSessionIdHistoriesMap() + Object.keys(sessionIdHistoriesMap).forEach(sessionId => { + const path = sessionId.match(/^codeViewerHelper:(.*)$/)?.[1] + + if (path && !openDocumentPaths.has(path)) { + delete sessionIdHistoriesMap[sessionId] + } + }) + } +} diff --git a/src/extension/commands/code-viewer-helper/index.ts b/src/extension/commands/code-viewer-helper/index.ts deleted file mode 100644 index 81cdf71..0000000 --- a/src/extension/commands/code-viewer-helper/index.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { - createModelProvider, - getCurrentSessionIdHistoriesMap -} from '@extension/ai/helpers' -import { createTmpFileInfo } from '@extension/file-utils/create-tmp-file' -import { showContinueMessage } from '@extension/file-utils/show-continue-message' -import { tmpFileWriter } from '@extension/file-utils/tmp-file-writer' -import { t } from '@extension/i18n' -import type { RunnableConfig } from '@langchain/core/runnables' -import * as vscode from 'vscode' - -import { buildGeneratePrompt } from './build-generate-prompt' - -export const cleanupCodeViewerHelperRunnables = async () => { - const openDocumentPaths = new Set( - vscode.workspace.textDocuments.map(doc => doc.uri.fsPath) - ) - const sessionIdHistoriesMap = await getCurrentSessionIdHistoriesMap() - - Object.keys(sessionIdHistoriesMap).forEach(sessionId => { - const path = sessionId.match(/^codeViewerHelper:(.*)$/)?.[1] - - if (path && !openDocumentPaths.has(path)) { - delete sessionIdHistoriesMap[sessionId] - } - }) -} - -export const handleCodeViewerHelper = async () => { - const { - originalFileExt, - originalFileContent, - originalFileLanguageId, - tmpFileUri, - isTmpFileHasContent - } = await createTmpFileInfo() - - // ai - const modelProvider = await createModelProvider() - const aiRunnableAbortController = new AbortController() - const aiRunnable = await modelProvider.createRunnable({ - signal: aiRunnableAbortController.signal - }) - const sessionId = `codeViewerHelper:${tmpFileUri.fsPath}}` - const aiRunnableConfig: RunnableConfig = { - configurable: { - sessionId - } - } - const sessionIdHistoriesMap = await getCurrentSessionIdHistoriesMap() - const isSessionHistoryExists = !!sessionIdHistoriesMap[sessionId] - const isContinue = isTmpFileHasContent && isSessionHistoryExists - - const prompt = await buildGeneratePrompt({ - sourceLanguage: originalFileLanguageId, - code: originalFileContent - }) - - const tmpFileWriterReturns = await tmpFileWriter({ - ext: originalFileExt, - languageId: originalFileLanguageId, - onCancel() { - aiRunnableAbortController.abort() - }, - buildAiStream: async () => { - if (!isContinue) { - // cleanup previous session - delete sessionIdHistoriesMap[sessionId] - - const aiStream = aiRunnable.stream( - { - input: prompt - }, - aiRunnableConfig - ) - return aiStream - } - - // continue - return aiRunnable.stream( - { - input: ` - continue, please do not reply with any text other than the code, and do not use markdown syntax. - go continue. - ` - }, - aiRunnableConfig - ) - } - }) - - await showContinueMessage({ - tmpFileUri: tmpFileWriterReturns?.tmpFileUri, - originalFileContentLineCount: originalFileContent.split('\n').length, - continueMessage: t('info.continueMessage') + t('info.iconContinueMessage'), - onContinue: async () => { - await handleCodeViewerHelper() - } - }) -} diff --git a/src/extension/commands/command-manager.ts b/src/extension/commands/command-manager.ts new file mode 100644 index 0000000..6a6b479 --- /dev/null +++ b/src/extension/commands/command-manager.ts @@ -0,0 +1,33 @@ +import * as vscode from 'vscode' + +import { BaseCommand } from './base.command' + +export class CommandManager { + private commands: BaseCommand[] = [] + + private services: Map = new Map() + + constructor(private context: vscode.ExtensionContext) {} + + registerCommand( + CommandClass: new ( + ...args: ConstructorParameters + ) => BaseCommand + ): void { + const command = new CommandClass(this.context, this) + this.commands.push(command) + this.context.subscriptions.push(command.register()) + } + + registerService(name: string, service: any): void { + this.services.set(name, service) + } + + getService(name: string): any { + return this.services.get(name) + } + + async cleanup(): Promise { + await Promise.allSettled(this.commands.map(command => command.cleanup())) + } +} diff --git a/src/extension/commands/copy-as-prompt/command.ts b/src/extension/commands/copy-as-prompt/command.ts new file mode 100644 index 0000000..91f6273 --- /dev/null +++ b/src/extension/commands/copy-as-prompt/command.ts @@ -0,0 +1,31 @@ +import { getConfigKey } from '@extension/config' +import { getFileOrFoldersPromptInfo } from '@extension/file-utils/get-fs-prompt-info' +import { t } from '@extension/i18n' +import * as vscode from 'vscode' + +import { BaseCommand } from '../base.command' + +export class CopyAsPromptCommand extends BaseCommand { + get commandName(): string { + return 'aide.copyAsPrompt' + } + + async run(uri: vscode.Uri, selectedUris: vscode.Uri[] = []): Promise { + const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri) + if (!workspaceFolder) throw new Error(t('error.noWorkspace')) + + const selectedItems = selectedUris?.length > 0 ? selectedUris : [uri] + if (selectedItems.length === 0) throw new Error(t('error.noSelection')) + + const selectedFileOrFolders = selectedItems.map(item => item.fsPath) + const { promptFullContent } = await getFileOrFoldersPromptInfo( + selectedFileOrFolders, + workspaceFolder.uri.fsPath + ) + const aiPrompt = await getConfigKey('aiPrompt') + const finalPrompt = aiPrompt.replace('#{content}', promptFullContent) + + await vscode.env.clipboard.writeText(finalPrompt) + vscode.window.showInformationMessage(t('info.copied')) + } +} diff --git a/src/extension/commands/copy-as-prompt/index.ts b/src/extension/commands/copy-as-prompt/index.ts deleted file mode 100644 index f0281d6..0000000 --- a/src/extension/commands/copy-as-prompt/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { getConfigKey } from '@extension/config' -import { getFileOrFoldersPromptInfo } from '@extension/file-utils/get-fs-prompt-info' -import { t } from '@extension/i18n' -import * as vscode from 'vscode' - -export const handleCopyAsPrompt = async ( - uri: vscode.Uri, - selectedUris: vscode.Uri[] = [] -) => { - const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri) - if (!workspaceFolder) throw new Error(t('error.noWorkspace')) - - const selectedItems = selectedUris?.length > 0 ? selectedUris : [uri] - if (selectedItems.length === 0) throw new Error(t('error.noSelection')) - - const selectedFileOrFolders = selectedItems.map(item => item.fsPath) - const { promptFullContent } = await getFileOrFoldersPromptInfo( - selectedFileOrFolders, - workspaceFolder.uri.fsPath - ) - const aiPrompt = await getConfigKey('aiPrompt') - const finalPrompt = aiPrompt.replace('#{content}', promptFullContent) - - await vscode.env.clipboard.writeText(finalPrompt) - vscode.window.showInformationMessage(t('info.copied')) -} diff --git a/src/extension/commands/expert-code-enhancer/command.ts b/src/extension/commands/expert-code-enhancer/command.ts new file mode 100644 index 0000000..e32332c --- /dev/null +++ b/src/extension/commands/expert-code-enhancer/command.ts @@ -0,0 +1,101 @@ +import { + createModelProvider, + getCurrentSessionIdHistoriesMap +} from '@extension/ai/helpers' +import { showContinueMessage } from '@extension/file-utils/show-continue-message' +import { getOriginalFileUri } from '@extension/file-utils/tmp-file/get-original-file-uri' +import { getTmpFileInfo } from '@extension/file-utils/tmp-file/get-tmp-file-info' +import { tmpFileWriter } from '@extension/file-utils/tmp-file/tmp-file-writer' +import { t } from '@extension/i18n' +import { getWorkspaceFolder } from '@extension/utils' +import type { RunnableConfig } from '@langchain/core/runnables' +import * as vscode from 'vscode' + +import { BaseCommand } from '../base.command' +import { buildGeneratePrompt } from './build-generate-prompt' + +export class ExpertCodeEnhancerCommand extends BaseCommand { + get commandName(): string { + return 'aide.expertCodeEnhancer' + } + + async run(): Promise { + const workspaceFolder = await getWorkspaceFolder() + const originalFileUri = getOriginalFileUri() + const tmpFileInfo = await getTmpFileInfo(originalFileUri) + + // ai + const modelProvider = await createModelProvider() + const aiRunnableAbortController = new AbortController() + const aiRunnable = await modelProvider.createRunnable({ + signal: aiRunnableAbortController.signal + }) + const sessionId = `expertCodeEnhancer:${tmpFileInfo.tmpFileUri.fsPath}}` + const aiRunnableConfig: RunnableConfig = { + configurable: { + sessionId + } + } + const sessionIdHistoriesMap = await getCurrentSessionIdHistoriesMap() + const isSessionHistoryExists = !!sessionIdHistoriesMap[sessionId] + const isContinue = tmpFileInfo.isTmpFileHasContent && isSessionHistoryExists + + const prompt = await buildGeneratePrompt({ + workspaceFolder, + currentFilePath: originalFileUri.fsPath, + code: tmpFileInfo.originalFileContent, + codeIsFromSelection: tmpFileInfo.originalFileContentIsFromSelection, + abortController: aiRunnableAbortController + }) + + const tmpFileWriterReturns = await tmpFileWriter({ + tmpFileOptions: { + ext: tmpFileInfo.originalFileExt, + languageId: tmpFileInfo.originalFileLanguageId + }, + onCancel() { + aiRunnableAbortController.abort() + }, + buildAiStream: async () => { + if (!isContinue) { + delete sessionIdHistoriesMap[sessionId] + return aiRunnable.stream({ input: prompt }, aiRunnableConfig) + } + + return aiRunnable.stream( + { + input: + 'continue, please do not reply with any text other than the code, and do not use markdown syntax. go continue.' + }, + aiRunnableConfig + ) + } + }) + + await showContinueMessage({ + tmpFileUri: tmpFileWriterReturns?.tmpFileUri, + originalFileContentLineCount: + tmpFileInfo.originalFileContent.split('\n').length, + continueMessage: + t('info.continueMessage') + t('info.iconContinueMessage'), + onContinue: async () => { + await this.run() + } + }) + } + + async cleanup(): Promise { + const openDocumentPaths = new Set( + vscode.workspace.textDocuments.map(doc => doc.uri.fsPath) + ) + const sessionIdHistoriesMap = await getCurrentSessionIdHistoriesMap() + + Object.keys(sessionIdHistoriesMap).forEach(sessionId => { + const path = sessionId.match(/^expertCodeEnhancer:(.*)$/)?.[1] + + if (path && !openDocumentPaths.has(path)) { + delete sessionIdHistoriesMap[sessionId] + } + }) + } +} diff --git a/src/extension/commands/expert-code-enhancer/index.ts b/src/extension/commands/expert-code-enhancer/index.ts deleted file mode 100644 index ee3b013..0000000 --- a/src/extension/commands/expert-code-enhancer/index.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { - createModelProvider, - getCurrentSessionIdHistoriesMap -} from '@extension/ai/helpers' -import { createTmpFileInfo } from '@extension/file-utils/create-tmp-file' -import { showContinueMessage } from '@extension/file-utils/show-continue-message' -import { tmpFileWriter } from '@extension/file-utils/tmp-file-writer' -import { t } from '@extension/i18n' -import { getCurrentWorkspaceFolderEditor } from '@extension/utils' -import type { RunnableConfig } from '@langchain/core/runnables' -import * as vscode from 'vscode' - -import { buildGeneratePrompt } from './build-generate-prompt' - -export const cleanupExpertCodeEnhancerRunnables = async () => { - const openDocumentPaths = new Set( - vscode.workspace.textDocuments.map(doc => doc.uri.fsPath) - ) - const sessionIdHistoriesMap = await getCurrentSessionIdHistoriesMap() - - Object.keys(sessionIdHistoriesMap).forEach(sessionId => { - const path = sessionId.match(/^expertCodeEnhancer:(.*)$/)?.[1] - - if (path && !openDocumentPaths.has(path)) { - delete sessionIdHistoriesMap[sessionId] - } - }) -} - -export const handleExpertCodeEnhancer = async () => { - const { workspaceFolder } = await getCurrentWorkspaceFolderEditor() - const { - originalFileExt, - originalFileContent, - originalFileLanguageId, - originalFileUri, - originalFileContentIsFromSelection, - tmpFileUri, - isTmpFileHasContent - } = await createTmpFileInfo() - - // ai - const modelProvider = await createModelProvider() - const aiRunnableAbortController = new AbortController() - const aiRunnable = await modelProvider.createRunnable({ - signal: aiRunnableAbortController.signal - }) - const sessionId = `expertCodeEnhancer:${tmpFileUri.fsPath}}` - const aiRunnableConfig: RunnableConfig = { - configurable: { - sessionId - } - } - const sessionIdHistoriesMap = await getCurrentSessionIdHistoriesMap() - const isSessionHistoryExists = !!sessionIdHistoriesMap[sessionId] - const isContinue = isTmpFileHasContent && isSessionHistoryExists - - const prompt = await buildGeneratePrompt({ - workspaceFolder, - currentFilePath: originalFileUri.fsPath, - code: originalFileContent, - codeIsFromSelection: originalFileContentIsFromSelection, - abortController: aiRunnableAbortController - }) - - const tmpFileWriterReturns = await tmpFileWriter({ - ext: originalFileExt, - languageId: originalFileLanguageId, - onCancel() { - aiRunnableAbortController.abort() - }, - buildAiStream: async () => { - if (!isContinue) { - // cleanup previous session - delete sessionIdHistoriesMap[sessionId] - - const aiStream = aiRunnable.stream( - { - input: prompt - }, - aiRunnableConfig - ) - return aiStream - } - - // continue - return aiRunnable.stream( - { - input: ` - continue, please do not reply with any text other than the code, and do not use markdown syntax. - go continue. - ` - }, - aiRunnableConfig - ) - } - }) - - await showContinueMessage({ - tmpFileUri: tmpFileWriterReturns?.tmpFileUri, - originalFileContentLineCount: originalFileContent.split('\n').length, - continueMessage: t('info.continueMessage') + t('info.iconContinueMessage'), - onContinue: async () => { - await handleExpertCodeEnhancer() - } - }) -} diff --git a/src/extension/commands/index.ts b/src/extension/commands/index.ts index 325b3b8..ba21a8d 100644 --- a/src/extension/commands/index.ts +++ b/src/extension/commands/index.ts @@ -1,103 +1,37 @@ -import { commandErrorCatcher } from '@extension/utils' -import * as vscode from 'vscode' - -import { handleAskAI } from './ask-ai' -import { handleBatchProcessor } from './batch-processor' -import { handleCodeConvert } from './code-convert' -import { handleCodeViewerHelper } from './code-viewer-helper' -import { handleCopyAsPrompt } from './copy-as-prompt' -import { handleExpertCodeEnhancer } from './expert-code-enhancer' -import { handleCopyFileText } from './private/copy-file-text' -import { handleQuickCloseFileWithoutSave } from './private/quick-close-file-without-save' -import { handleReplaceFile } from './private/replace-file' -import { handleShowAideKeyUsageInfo } from './private/show-aide-key-usage-info' -import { handleShowDiff } from './private/show-diff' -import { handleRenameVariable } from './rename-variable' -import { handleSmartPaste } from './smart-paste' - -export const registerCommands = async (context: vscode.ExtensionContext) => { - const copyDisposable = vscode.commands.registerCommand( - 'aide.copyAsPrompt', - commandErrorCatcher(handleCopyAsPrompt) - ) - const askAIDisposable = vscode.commands.registerCommand( - 'aide.askAI', - commandErrorCatcher(handleAskAI) - ) - - const codeConvertDisposable = vscode.commands.registerCommand( - 'aide.codeConvert', - commandErrorCatcher(handleCodeConvert) - ) - - const codeViewerHelperDisposable = vscode.commands.registerCommand( - 'aide.codeViewerHelper', - commandErrorCatcher(handleCodeViewerHelper) - ) - - const expertCodeEnhancerDisposable = vscode.commands.registerCommand( - 'aide.expertCodeEnhancer', - commandErrorCatcher(handleExpertCodeEnhancer) - ) - - const renameVariableDisposable = vscode.commands.registerCommand( - 'aide.renameVariable', - commandErrorCatcher(handleRenameVariable) - ) - - const smartPasteDisposable = vscode.commands.registerCommand( - 'aide.smartPaste', - commandErrorCatcher(handleSmartPaste) - ) - - const batchProcessorDisposable = vscode.commands.registerCommand( - 'aide.batchProcessor', - commandErrorCatcher(handleBatchProcessor) - ) - - // private command - const copyFileTextDisposable = vscode.commands.registerCommand( - 'aide.copyFileText', - commandErrorCatcher(handleCopyFileText) - ) - - // private command - const quickCloseFileWithoutSaveDisposable = vscode.commands.registerCommand( - 'aide.quickCloseFileWithoutSave', - commandErrorCatcher(handleQuickCloseFileWithoutSave) - ) - - // private command - const replaceFileDisposable = vscode.commands.registerCommand( - 'aide.replaceFile', - commandErrorCatcher(handleReplaceFile) - ) - - // private command - const showDiffDisposable = vscode.commands.registerCommand( - 'aide.showDiff', - commandErrorCatcher(handleShowDiff) - ) - - // private command - const showAideKeyUsageInfoDisposable = vscode.commands.registerCommand( - 'aide.showAideKeyUsageInfo', - commandErrorCatcher(handleShowAideKeyUsageInfo) - ) - - context.subscriptions.push( - copyDisposable, - askAIDisposable, - codeConvertDisposable, - codeViewerHelperDisposable, - expertCodeEnhancerDisposable, - renameVariableDisposable, - smartPasteDisposable, - batchProcessorDisposable, - copyFileTextDisposable, - quickCloseFileWithoutSaveDisposable, - replaceFileDisposable, - showDiffDisposable, - showAideKeyUsageInfoDisposable - ) +import { AskAICommand } from './ask-ai/command' +import type { BaseCommand } from './base.command' +import { CodeConvertCommand } from './code-convert/command' +import { CodeViewerHelperCommand } from './code-viewer-helper/command' +import { CommandManager } from './command-manager' +import { CopyAsPromptCommand } from './copy-as-prompt/command' +import { ExpertCodeEnhancerCommand } from './expert-code-enhancer/command' +import { CopyFileTextCommand } from './private/copy-file-text.command' +import { OpenWebviewCommand } from './private/open-webview.command' +import { QuickCloseFileWithoutSaveCommand } from './private/quick-close-file-without-save.command' +import { ReplaceFileCommand } from './private/replace-file.command' +import { ShowAideKeyUsageInfoCommand } from './private/show-aide-key-usage-info.command' +import { ShowDiffCommand } from './private/show-diff.command' +import { RenameVariableCommand } from './rename-variable/command' +import { SmartPasteCommand } from './smart-paste/command' + +export const registerCommands = (commandManager: CommandManager) => { + const Commands = [ + CopyAsPromptCommand, + AskAICommand, + CodeConvertCommand, + CodeViewerHelperCommand, + ExpertCodeEnhancerCommand, + RenameVariableCommand, + SmartPasteCommand, + + // private command + CopyFileTextCommand, + QuickCloseFileWithoutSaveCommand, + ReplaceFileCommand, + ShowDiffCommand, + ShowAideKeyUsageInfoCommand, + OpenWebviewCommand + ] satisfies (typeof BaseCommand)[] + + Commands.forEach(Command => commandManager.registerCommand(Command)) } diff --git a/src/extension/commands/private/copy-file-text.command.ts b/src/extension/commands/private/copy-file-text.command.ts new file mode 100644 index 0000000..c40fa28 --- /dev/null +++ b/src/extension/commands/private/copy-file-text.command.ts @@ -0,0 +1,20 @@ +import { t } from '@extension/i18n' +import * as vscode from 'vscode' + +import { BaseCommand } from '../base.command' + +export class CopyFileTextCommand extends BaseCommand { + get commandName(): string { + return 'aide.copyFileText' + } + + async run(uri?: vscode.Uri): Promise { + const targetUri = uri || vscode.window.activeTextEditor?.document.uri + + if (!targetUri) throw new Error(t('error.noActiveEditor')) + + const document = await vscode.workspace.openTextDocument(targetUri) + await vscode.env.clipboard.writeText(document.getText()) + vscode.window.showInformationMessage(t('info.copied')) + } +} diff --git a/src/extension/commands/private/copy-file-text.ts b/src/extension/commands/private/copy-file-text.ts deleted file mode 100644 index 900f13d..0000000 --- a/src/extension/commands/private/copy-file-text.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { t } from '@extension/i18n' -import * as vscode from 'vscode' - -export const handleCopyFileText = async (uri?: vscode.Uri) => { - const targetUri = uri || vscode.window.activeTextEditor?.document.uri - - if (!targetUri) throw new Error(t('error.noActiveEditor')) - - const document = await vscode.workspace.openTextDocument(targetUri) - await vscode.env.clipboard.writeText(document.getText()) - vscode.window.showInformationMessage(t('info.copied')) -} diff --git a/src/extension/commands/private/open-webview.command.ts b/src/extension/commands/private/open-webview.command.ts new file mode 100644 index 0000000..c5bef60 --- /dev/null +++ b/src/extension/commands/private/open-webview.command.ts @@ -0,0 +1,21 @@ +import type { AideWebViewProvider } from '@extension/registers/webview.register' + +import { BaseCommand } from '../base.command' + +export class OpenWebviewCommand extends BaseCommand { + get commandName(): string { + return 'aide.openWebview' + } + + async run(): Promise { + const aideWebViewProvider = this.commandManager.getService( + 'AideWebViewProvider' + ) as AideWebViewProvider + + if (aideWebViewProvider) { + await aideWebViewProvider.createOrShowWebviewPanel() + } else { + throw new Error('WebviewProvider not found') + } + } +} diff --git a/src/extension/commands/private/quick-close-file-without-save.command.ts b/src/extension/commands/private/quick-close-file-without-save.command.ts new file mode 100644 index 0000000..2b8159b --- /dev/null +++ b/src/extension/commands/private/quick-close-file-without-save.command.ts @@ -0,0 +1,39 @@ +import { t } from '@extension/i18n' +import * as vscode from 'vscode' + +import { BaseCommand } from '../base.command' + +export class QuickCloseFileWithoutSaveCommand extends BaseCommand { + get commandName(): string { + return 'aide.quickCloseFileWithoutSave' + } + + async run(uri?: vscode.Uri): Promise { + const targetUri = uri || vscode.window.activeTextEditor?.document.uri + if (!targetUri) throw new Error(t('error.noActiveEditor')) + + const targetEditor = vscode.window.visibleTextEditors.find( + editor => editor.document.uri.toString() === targetUri.toString() + ) + + let documentToClose: vscode.TextDocument | undefined + + if (targetEditor) { + documentToClose = targetEditor.document + } else { + documentToClose = vscode.workspace.textDocuments.find( + doc => doc.uri.toString() === targetUri.toString() + ) + } + + if (!documentToClose) throw new Error(t('error.noActiveEditor')) + + await vscode.window.showTextDocument(documentToClose) + + const command = documentToClose.isDirty + ? 'workbench.action.revertAndCloseActiveEditor' + : 'workbench.action.closeActiveEditor' + + await vscode.commands.executeCommand(command) + } +} diff --git a/src/extension/commands/private/quick-close-file-without-save.ts b/src/extension/commands/private/quick-close-file-without-save.ts deleted file mode 100644 index e073291..0000000 --- a/src/extension/commands/private/quick-close-file-without-save.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { t } from '@extension/i18n' -import * as vscode from 'vscode' - -export const handleQuickCloseFileWithoutSave = async (uri?: vscode.Uri) => { - const targetUri = uri || vscode.window.activeTextEditor?.document.uri - if (!targetUri) throw new Error(t('error.noActiveEditor')) - - const targetEditor = vscode.window.visibleTextEditors.find( - editor => editor.document.uri.toString() === targetUri.toString() - ) - - let documentToClose: vscode.TextDocument | undefined - - if (targetEditor) { - documentToClose = targetEditor.document - } else { - documentToClose = vscode.workspace.textDocuments.find( - doc => doc.uri.toString() === targetUri.toString() - ) - } - - if (!documentToClose) throw new Error(t('error.noActiveEditor')) - - await vscode.window.showTextDocument(documentToClose) - - const command = documentToClose.isDirty - ? 'workbench.action.revertAndCloseActiveEditor' - : 'workbench.action.closeActiveEditor' - - await vscode.commands.executeCommand(command) -} diff --git a/src/extension/commands/private/replace-file.command.ts b/src/extension/commands/private/replace-file.command.ts new file mode 100644 index 0000000..ce83d03 --- /dev/null +++ b/src/extension/commands/private/replace-file.command.ts @@ -0,0 +1,71 @@ +import path from 'path' +import { VsCodeFS } from '@extension/file-utils/vscode-fs' +import { t } from '@extension/i18n' +import * as vscode from 'vscode' + +import { BaseCommand } from '../base.command' + +export class ReplaceFileCommand extends BaseCommand { + get commandName(): string { + return 'aide.replaceFile' + } + + async run(fromFileUri: vscode.Uri, toFileUri: vscode.Uri): Promise { + if (!fromFileUri || !toFileUri) throw new Error(t('error.fileNotFound')) + + const toFileContent = ( + await vscode.workspace.openTextDocument(toFileUri) + ).getText() + await this.replaceFileContent(fromFileUri, toFileContent) + + vscode.window.showInformationMessage(t('info.fileReplaceSuccess')) + + // close the toFileUri + await vscode.commands.executeCommand( + 'aide.quickCloseFileWithoutSave', + toFileUri + ) + + // get toFileExt and set it to fromFileUri + const toFileExt = path.extname(toFileUri.fsPath) + if (toFileExt) { + fromFileUri = await this.changeFileExtension(fromFileUri, toFileExt) + } + + try { + // delete the toFileUri + await VsCodeFS.unlink(toFileUri.fsPath) + + // if fromFileUri is not opened, open it + const fromFileDocument = + await vscode.workspace.openTextDocument(fromFileUri) + await vscode.window.showTextDocument(fromFileDocument) + } catch {} + } + + async replaceFileContent(uri: vscode.Uri, content: string) { + const editor = vscode.window.visibleTextEditors.find( + e => e.document.uri.toString() === uri.toString() + ) + if (editor && !editor.selection.isEmpty) { + await editor.edit(editBuilder => + editBuilder.replace(editor.selection, content) + ) + } else { + await VsCodeFS.writeFile(uri.fsPath, content, 'utf8') + } + } + + async changeFileExtension( + uri: vscode.Uri, + newExt: string + ): Promise { + const oldPath = uri.fsPath + const dir = path.dirname(oldPath) + const nameWithoutExt = path.basename(oldPath, path.extname(oldPath)) + const newPath = path.join(dir, `${nameWithoutExt}${newExt}`) + const newUri = vscode.Uri.file(newPath) + await vscode.workspace.fs.rename(uri, newUri) + return newUri + } +} diff --git a/src/extension/commands/private/replace-file.ts b/src/extension/commands/private/replace-file.ts deleted file mode 100644 index cf76eea..0000000 --- a/src/extension/commands/private/replace-file.ts +++ /dev/null @@ -1,73 +0,0 @@ -import path from 'path' -import { VsCodeFS } from '@extension/file-utils/vscode-fs' -import { t } from '@extension/i18n' -import * as vscode from 'vscode' - -const replaceFileContent = async (uri: vscode.Uri, content: string) => { - const editor = vscode.window.visibleTextEditors.find( - e => e.document.uri.toString() === uri.toString() - ) - if (editor && !editor.selection.isEmpty) { - await editor.edit(editBuilder => - editBuilder.replace(editor.selection, content) - ) - } else { - await VsCodeFS.writeFile(uri.fsPath, content, 'utf8') - } -} - -const changeFileExtension = async ( - uri: vscode.Uri, - newExt: string -): Promise => { - const oldPath = uri.fsPath - const dir = path.dirname(oldPath) - const nameWithoutExt = path.basename(oldPath, path.extname(oldPath)) - const newPath = path.join(dir, `${nameWithoutExt}${newExt}`) - const newUri = vscode.Uri.file(newPath) - await vscode.workspace.fs.rename(uri, newUri) - return newUri -} - -/** - * Handles the replacement of a file with another file. - * - * @param fromFileUri - The file will not removed, but its content will be replaced. - * @param toFileUri - The file will be removed. - * @throws An error if either `fromFileUri` or `toFileUri` is not provided. - */ -export const handleReplaceFile = async ( - fromFileUri: vscode.Uri, - toFileUri: vscode.Uri -) => { - if (!fromFileUri || !toFileUri) throw new Error(t('error.fileNotFound')) - - const toFileContent = ( - await vscode.workspace.openTextDocument(toFileUri) - ).getText() - await replaceFileContent(fromFileUri, toFileContent) - - vscode.window.showInformationMessage(t('info.fileReplaceSuccess')) - - // close the toFileUri - await vscode.commands.executeCommand( - 'aide.quickCloseFileWithoutSave', - toFileUri - ) - - // get toFileExt and set it to fromFileUri - const toFileExt = path.extname(toFileUri.fsPath) - if (toFileExt) { - fromFileUri = await changeFileExtension(fromFileUri, toFileExt) - } - - try { - // delete the toFileUri - await VsCodeFS.unlink(toFileUri.fsPath) - - // if fromFileUri is not opened, open it - const fromFileDocument = - await vscode.workspace.openTextDocument(fromFileUri) - await vscode.window.showTextDocument(fromFileDocument) - } catch {} -} diff --git a/src/extension/commands/private/show-aide-key-usage-info.command.ts b/src/extension/commands/private/show-aide-key-usage-info.command.ts new file mode 100644 index 0000000..e39b033 --- /dev/null +++ b/src/extension/commands/private/show-aide-key-usage-info.command.ts @@ -0,0 +1,73 @@ +import { aideKeyUsageInfo } from '@extension/ai/aide-key-request' +import { getConfigKey } from '@extension/config' +import { t } from '@extension/i18n' +import { AideKeyUsageStatusBarRegister } from '@extension/registers/aide-key-usage-statusbar.register' +import { formatNumber } from '@extension/utils' +import * as vscode from 'vscode' + +import { BaseCommand } from '../base.command' + +export class ShowAideKeyUsageInfoCommand extends BaseCommand { + get commandName(): string { + return 'aide.showAideKeyUsageInfo' + } + + async run(): Promise { + const openaiBaseUrl = await getConfigKey('openaiBaseUrl') + const openaiKey = await getConfigKey('openaiKey') + + if (!openaiBaseUrl.includes('api.zyai.online')) + throw new Error(t('error.aideKeyUsageInfoOnlySupportAideModels')) + + const aideKeyUsageStatusBarRegister = this.commandManager.getService( + 'AideKeyUsageStatusBarRegister' + ) as AideKeyUsageStatusBarRegister + + // show loading + aideKeyUsageStatusBarRegister.updateStatusBar( + `$(sync~spin) ${t('info.loading')}` + ) + + try { + const result = await aideKeyUsageInfo({ key: openaiKey }) + + if (result.success) { + // create a nice message to show the result + const { count, subscription } = result.data + + const totalUSD = subscription.hard_limit_usd + const usedUSD = + (subscription.used_quota / subscription.remain_quota) * totalUSD + const remainUSD = totalUSD - usedUSD + const formatUSD = (amount: number) => `$${formatNumber(amount, 2)}` + const formatDate = (timestamp: number) => { + if (timestamp === 0) return t('info.aideKey.neverExpires') + return new Date(timestamp * 1000).toLocaleDateString() + } + + const message = `${t('info.aideKey.usageInfo')}: + +${t('info.aideKey.total')}: ${formatUSD(totalUSD)} + +${t('info.aideKey.used')}: ${formatUSD(usedUSD)} + +${t('info.aideKey.remain')}: ${formatUSD(remainUSD)} + +${t('info.aideKey.callCount')}: ${count.count} + +${t('info.aideKey.validUntil')}: ${formatDate(subscription.access_until)}` + + vscode.window.showInformationMessage(message, { + modal: true + }) + } else { + throw new Error(`Failed to fetch usage info: ${result.message}`) + } + } finally { + // restore the original text of the status bar item + aideKeyUsageStatusBarRegister.updateStatusBar( + `$(info) ${t('info.aideKeyUsageStatusBar.text')}` + ) + } + } +} diff --git a/src/extension/commands/private/show-aide-key-usage-info.ts b/src/extension/commands/private/show-aide-key-usage-info.ts deleted file mode 100644 index 082b92d..0000000 --- a/src/extension/commands/private/show-aide-key-usage-info.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { aideKeyUsageInfo } from '@extension/ai/aide-key-request' -import { getConfigKey } from '@extension/config' -import { t } from '@extension/i18n' -import { updateAideKeyUsageStatusBar } from '@extension/providers/aide-key-usage-statusbar' -import { formatNumber } from '@extension/utils' -import * as vscode from 'vscode' - -export const handleShowAideKeyUsageInfo = async () => { - const openaiBaseUrl = await getConfigKey('openaiBaseUrl') - const openaiKey = await getConfigKey('openaiKey') - - if (!openaiBaseUrl.includes('api.zyai.online')) - throw new Error(t('error.aideKeyUsageInfoOnlySupportAideModels')) - - // show loading - updateAideKeyUsageStatusBar(`$(sync~spin) ${t('info.loading')}`) - - try { - const result = await aideKeyUsageInfo({ key: openaiKey }) - - if (result.success) { - // create a nice message to show the result - const { count, subscription } = result.data - - const totalUSD = subscription.hard_limit_usd - const usedUSD = - (subscription.used_quota / subscription.remain_quota) * totalUSD - const remainUSD = totalUSD - usedUSD - const formatUSD = (amount: number) => `$${formatNumber(amount, 2)}` - const formatDate = (timestamp: number) => { - if (timestamp === 0) return t('info.aideKey.neverExpires') - return new Date(timestamp * 1000).toLocaleDateString() - } - - const message = `${t('info.aideKey.usageInfo')}: - -${t('info.aideKey.total')}: ${formatUSD(totalUSD)} - -${t('info.aideKey.used')}: ${formatUSD(usedUSD)} - -${t('info.aideKey.remain')}: ${formatUSD(remainUSD)} - -${t('info.aideKey.callCount')}: ${count.count} - -${t('info.aideKey.validUntil')}: ${formatDate(subscription.access_until)}` - - vscode.window.showInformationMessage(message, { - modal: true - }) - } else { - throw new Error(`Failed to fetch usage info: ${result.message}`) - } - } finally { - // restore the original text of the status bar item - updateAideKeyUsageStatusBar( - `$(info) ${t('info.aideKeyUsageStatusBar.text')}` - ) - } -} diff --git a/src/extension/commands/private/show-diff.command.ts b/src/extension/commands/private/show-diff.command.ts new file mode 100644 index 0000000..2e1ddf0 --- /dev/null +++ b/src/extension/commands/private/show-diff.command.ts @@ -0,0 +1,178 @@ +import path from 'path' +import { t } from '@extension/i18n' +import * as vscode from 'vscode' + +import { BaseCommand } from '../base.command' + +enum DiffMode { + WithClipboard = 'clipboard', + WithFile = 'file' +} + +interface FromFileInfo { + uri: vscode.Uri + title: string +} + +export class ShowDiffCommand extends BaseCommand { + private readonly DEFAULT_VIEW_COLUMN = vscode.ViewColumn.One + + get commandName(): string { + return 'aide.showDiff' + } + + async run( + fromFileUri: vscode.Uri, + toFileUri?: vscode.Uri, + closeToFile = false + ): Promise { + if (!fromFileUri) throw new Error(t('error.fileNotFound')) + + const fromFileInfo = await this.prepareFromFile(fromFileUri) + await this.prepareToFile(toFileUri, closeToFile) + + const diffMode = this.getDiffMode(toFileUri, closeToFile) + await this.showDiff(diffMode, fromFileInfo, toFileUri) + } + + private async prepareFromFile( + fromFileUri: vscode.Uri + ): Promise { + const fromFileEditor = this.findVisibleEditor(fromFileUri) + const hasSelection = + fromFileEditor?.selection && !fromFileEditor.selection.isEmpty + + if (hasSelection) { + return this.handleSelectedContent(fromFileUri, fromFileEditor!) + } + return { + uri: fromFileUri, + title: path.basename(fromFileUri.fsPath) + } + } + + private findVisibleEditor( + fileUri: vscode.Uri + ): vscode.TextEditor | undefined { + return vscode.window.visibleTextEditors.find( + editor => editor.document.uri.toString() === fileUri.toString() + ) + } + + private async handleSelectedContent( + fromFileUri: vscode.Uri, + fromFileEditor: vscode.TextEditor + ): Promise { + const title = `${path.basename(fromFileUri.fsPath)} (Selection)` + const selectedContent = fromFileEditor.document.getText( + fromFileEditor.selection + ) + const inMemoryDocument = await vscode.workspace.openTextDocument({ + content: selectedContent, + language: fromFileEditor.document.languageId + }) + return { uri: inMemoryDocument.uri, title } + } + + private async prepareToFile( + toFileUri?: vscode.Uri, + closeToFile = false + ): Promise { + if (!toFileUri) return + + const isToFileVisible = this.findVisibleEditor(toFileUri) !== undefined + + if (isToFileVisible) { + await this.moveToFileToFirstColumn(toFileUri) + } else { + await vscode.window.showTextDocument(toFileUri, { + viewColumn: this.DEFAULT_VIEW_COLUMN + }) + } + + if (closeToFile) { + await this.copyToFileContentToClipboard(toFileUri) + await this.closeToFile(toFileUri) + } + } + + private async moveToFileToFirstColumn(toFileUri: vscode.Uri): Promise { + const toFileEditor = await vscode.window.showTextDocument(toFileUri, { + viewColumn: this.DEFAULT_VIEW_COLUMN + }) + await this.closeFileInOtherColumns(toFileUri) + await vscode.window.showTextDocument(toFileEditor.document) + } + + private async copyToFileContentToClipboard( + toFileUri: vscode.Uri + ): Promise { + const toFileDocument = await vscode.workspace.openTextDocument(toFileUri) + await vscode.env.clipboard.writeText(toFileDocument.getText()) + } + + private async closeToFile(toFileUri: vscode.Uri): Promise { + await vscode.commands.executeCommand( + 'aide.quickCloseFileWithoutSave', + toFileUri + ) + } + + private getDiffMode(toFileUri?: vscode.Uri, closeToFile = false): DiffMode { + return !toFileUri || closeToFile + ? DiffMode.WithClipboard + : DiffMode.WithFile + } + + private async showDiff( + mode: DiffMode, + fromFileInfo: FromFileInfo, + toFileUri?: vscode.Uri + ): Promise { + switch (mode) { + case DiffMode.WithClipboard: + await vscode.commands.executeCommand( + 'workbench.files.action.compareWithClipboard', + fromFileInfo.uri + ) + break + case DiffMode.WithFile: + if (toFileUri) { + const toFileTitle = path.basename(toFileUri.fsPath) + const title = `Diff: ${fromFileInfo.title} ↔ ${toFileTitle}` + const options: vscode.TextDocumentShowOptions = { + viewColumn: this.DEFAULT_VIEW_COLUMN + } + await vscode.commands.executeCommand( + 'vscode.diff', + toFileUri, + fromFileInfo.uri, + title, + options + ) + } + break + default: + break + } + } + + private async closeFileInOtherColumns(fileUri: vscode.Uri): Promise { + const editorsToClose = vscode.window.visibleTextEditors.filter( + editor => + editor.document.uri.toString() === fileUri.toString() && + editor.viewColumn !== this.DEFAULT_VIEW_COLUMN + ) + + for (const editor of editorsToClose) { + await vscode.window.showTextDocument(editor.document, { + preserveFocus: false, + preview: false, + viewColumn: editor.viewColumn + }) + await vscode.commands.executeCommand( + 'workbench.action.closeEditorsAndGroup' + ) + } + } +} diff --git a/src/extension/commands/private/show-diff.ts b/src/extension/commands/private/show-diff.ts deleted file mode 100644 index 4c05da1..0000000 --- a/src/extension/commands/private/show-diff.ts +++ /dev/null @@ -1,55 +0,0 @@ -import * as path from 'path' -import { t } from '@extension/i18n' -import * as vscode from 'vscode' - -/** - * Displays a diff view between two files or between a file and a selected portion of another file. - * - * @param fromFileUri - The URI of the source file. - * @param toFileUri - The URI of the target file to compare against. - * @throws An error if either `fromFileUri` or `toFileUri` is not provided. - */ -export const handleShowDiff = async ( - fromFileUri: vscode.Uri, - toFileUri: vscode.Uri -) => { - if (!fromFileUri || !toFileUri) throw new Error(t('error.fileNotFound')) - - const fromFileEditor = vscode.window.visibleTextEditors.find( - editor => editor.document.uri.toString() === fromFileUri.toString() - ) - - let fromFileTitle: string - let finalFromFileUri: vscode.Uri - - if (fromFileEditor && !fromFileEditor.selection.isEmpty) { - // Use selected content from fromFile - fromFileTitle = `${path.basename(fromFileUri.fsPath)} (Selection)` - finalFromFileUri = vscode.Uri.parse(`untitled:${fromFileTitle}`) - - // Create an in-memory document with the selected content - const selectedContent = fromFileEditor.document.getText( - fromFileEditor.selection - ) - const inMemoryDocument = await vscode.workspace.openTextDocument({ - content: selectedContent, - language: fromFileEditor.document.languageId - }) - finalFromFileUri = inMemoryDocument.uri - } else { - // Use entire content of fromFile - finalFromFileUri = fromFileUri - fromFileTitle = path.basename(fromFileUri.fsPath) - } - - const toFileTitle = path.basename(toFileUri.fsPath) - - // Show diff - const title = `Diff: ${fromFileTitle} ↔ ${toFileTitle}` - await vscode.commands.executeCommand( - 'vscode.diff', - toFileUri, - finalFromFileUri, - title - ) -} diff --git a/src/extension/commands/rename-variable/command.ts b/src/extension/commands/rename-variable/command.ts new file mode 100644 index 0000000..7856734 --- /dev/null +++ b/src/extension/commands/rename-variable/command.ts @@ -0,0 +1,107 @@ +import path from 'path' +import { createModelProvider } from '@extension/ai/helpers' +import { t } from '@extension/i18n' +import { createLoading } from '@extension/loading' +import { getActiveEditor, getWorkspaceFolder } from '@extension/utils' +import { AbortError } from 'node-fetch' +import * as vscode from 'vscode' +import { z } from 'zod' + +import { BaseCommand } from '../base.command' +import { buildRenameSuggestionPrompt } from './build-rename-suggestion-prompt' +import { submitRenameVariable } from './submit-rename-variable' + +const renameSuggestionZodSchema = z.object({ + suggestionVariableNameOptions: z + .array( + z.object({ + variableName: z.string().describe('Required! variable name'), + description: z + .string() + .describe( + `Required! About this variable, describe its meaning in my mother tongue ${vscode.env.language}, within 15 words` + ) + }) + ) + .describe('Required! suggested variable names list') +}) + +type RenameSuggestionZodSchema = z.infer + +export class RenameVariableCommand extends BaseCommand { + get commandName(): string { + return 'aide.renameVariable' + } + + async run(): Promise { + const workspaceFolder = getWorkspaceFolder() + const activeEditor = getActiveEditor() + const { selection } = activeEditor + const variableName = activeEditor.document.getText(selection) + const modelProvider = await createModelProvider() + const { showProcessLoading, hideProcessLoading } = createLoading() + + const abortController = new AbortController() + const aiRunnable = await modelProvider.createStructuredOutputRunnable({ + signal: abortController.signal, + zodSchema: renameSuggestionZodSchema, + useHistory: false + }) + + let aiRes: any + try { + showProcessLoading({ + onCancel: () => { + abortController.abort() + } + }) + const prompt = await buildRenameSuggestionPrompt({ + contextCode: activeEditor.document.getText(), + variableName, + selection, + fileRelativePath: path.relative( + workspaceFolder?.uri.fsPath || '', + activeEditor.document.uri.fsPath + ) + }) + + aiRes = await aiRunnable.invoke({ + input: prompt + }) + } finally { + hideProcessLoading() + } + + if (abortController?.signal.aborted) throw AbortError + + const suggestionVariableNameOptions = Array.from( + aiRes?.suggestionVariableNameOptions || [] + ) as RenameSuggestionZodSchema['suggestionVariableNameOptions'] + + if (suggestionVariableNameOptions.length > 0) { + // show quick pick to select a new variable name + const selectedVariableNameOption = await vscode.window.showQuickPick( + suggestionVariableNameOptions.map(item => ({ + label: item.variableName, + description: item.description + })), + { + placeHolder: t('input.selectAiSuggestionsVariableName.prompt'), + title: t('input.selectAiSuggestionsVariableName.prompt') + } + ) + + if (selectedVariableNameOption) { + await submitRenameVariable({ + newName: selectedVariableNameOption.label, + selection + }) + } + } else { + // show info message if no suggestions + vscode.window.showInformationMessage( + t('info.noAiSuggestionsVariableName') + ) + } + } +} diff --git a/src/extension/commands/rename-variable/index.ts b/src/extension/commands/rename-variable/index.ts deleted file mode 100644 index 0e7394c..0000000 --- a/src/extension/commands/rename-variable/index.ts +++ /dev/null @@ -1,115 +0,0 @@ -import path from 'path' -import { - createModelProvider, - getCurrentSessionIdHistoriesMap -} from '@extension/ai/helpers' -import { AbortError } from '@extension/constants' -import { t } from '@extension/i18n' -import { createLoading } from '@extension/loading' -import { getCurrentWorkspaceFolderEditor } from '@extension/utils' -import type { RunnableConfig } from '@langchain/core/runnables' -import * as vscode from 'vscode' -import { z } from 'zod' - -import { buildRenameSuggestionPrompt } from './build-rename-suggestion-prompt' -import { submitRenameVariable } from './submit-rename-variable' - -const renameSuggestionZodSchema = z.object({ - suggestionVariableNameOptions: z - .array( - z.object({ - variableName: z.string().describe('Required! variable name'), - description: z - .string() - .describe( - `Required! About this variable, describe its meaning in my mother tongue ${vscode.env.language}, within 15 words` - ) - }) - ) - .describe('Required! suggested variable names list') -}) - -type RenameSuggestionZodSchema = z.infer - -export const handleRenameVariable = async () => { - const { workspaceFolder, activeEditor } = getCurrentWorkspaceFolderEditor() - const { selection } = activeEditor - const variableName = activeEditor.document.getText(selection) - const modelProvider = await createModelProvider() - const { showProcessLoading, hideProcessLoading } = createLoading() - - const abortController = new AbortController() - const aiRunnable = await modelProvider.createStructuredOutputRunnable({ - signal: abortController.signal, - zodSchema: renameSuggestionZodSchema - }) - const sessionId = `renameVariable:${variableName}` - const aiRunnableConfig: RunnableConfig = { - configurable: { - sessionId - } - } - const sessionIdHistoriesMap = await getCurrentSessionIdHistoriesMap() - - // cleanup old session history - delete sessionIdHistoriesMap[sessionId] - - let aiRes: any - try { - showProcessLoading({ - onCancel: () => { - abortController.abort() - } - }) - const prompt = await buildRenameSuggestionPrompt({ - contextCode: activeEditor.document.getText(), - variableName, - selection, - fileRelativePath: path.relative( - workspaceFolder?.uri.fsPath || '', - activeEditor.document.uri.fsPath - ) - }) - - aiRes = await aiRunnable.invoke( - { - input: prompt - }, - aiRunnableConfig - ) - } finally { - hideProcessLoading() - } - - if (abortController?.signal.aborted) throw AbortError - - const suggestionVariableNameOptions = Array.from( - aiRes?.suggestionVariableNameOptions || [] - ) as RenameSuggestionZodSchema['suggestionVariableNameOptions'] - - if (suggestionVariableNameOptions.length > 0) { - // show quick pick to select a new variable name - const selectedVariableNameOption = await vscode.window.showQuickPick( - suggestionVariableNameOptions.map(item => ({ - label: item.variableName, - description: item.description - })), - { - placeHolder: t('input.selectAiSuggestionsVariableName.prompt'), - title: t('input.selectAiSuggestionsVariableName.prompt') - } - ) - - if (selectedVariableNameOption) { - await submitRenameVariable({ - newName: selectedVariableNameOption.label, - selection - }) - } - } else { - // show info message if no suggestions - vscode.window.showInformationMessage(t('info.noAiSuggestionsVariableName')) - } - - delete sessionIdHistoriesMap[sessionId] -} diff --git a/src/extension/commands/smart-paste/build-convert-chat-messages.ts b/src/extension/commands/smart-paste/build-convert-chat-messages.ts index dbc6c24..bcddae3 100644 --- a/src/extension/commands/smart-paste/build-convert-chat-messages.ts +++ b/src/extension/commands/smart-paste/build-convert-chat-messages.ts @@ -1,6 +1,6 @@ import { getReferenceFilePaths } from '@extension/ai/get-reference-file-paths' -import { safeReadClipboard } from '@extension/clipboard' import { getConfigKey } from '@extension/config' +import { safeReadClipboard } from '@extension/file-utils/clipboard' import { getFileOrFoldersPromptInfo } from '@extension/file-utils/get-fs-prompt-info' import { insertTextAtSelection } from '@extension/file-utils/insert-text-at-selection' import { t } from '@extension/i18n' diff --git a/src/extension/commands/smart-paste/command.ts b/src/extension/commands/smart-paste/command.ts new file mode 100644 index 0000000..c65fd44 --- /dev/null +++ b/src/extension/commands/smart-paste/command.ts @@ -0,0 +1,80 @@ +import { + createModelProvider, + getCurrentSessionIdHistoriesMap +} from '@extension/ai/helpers' +import { streamingCompletionWriter } from '@extension/file-utils/stream-completion-writer' +import { getActiveEditor, getWorkspaceFolder } from '@extension/utils' +import type { BaseMessage } from '@langchain/core/messages' +import * as vscode from 'vscode' + +import { BaseCommand } from '../base.command' +import { buildConvertChatMessages } from './build-convert-chat-messages' + +export class SmartPasteCommand extends BaseCommand { + get commandName(): string { + return 'aide.smartPaste' + } + + async run(): Promise { + const workspaceFolder = getWorkspaceFolder() + const activeEditor = getActiveEditor() + const currentFilePath = activeEditor.document.uri.fsPath + + // ai + const modelProvider = await createModelProvider() + const aiModelAbortController = new AbortController() + const aiModel = (await modelProvider.getModel()).bind({ + signal: aiModelAbortController.signal + }) + + const sessionId = `smartPaste:${currentFilePath}}` + const sessionIdHistoriesMap = await getCurrentSessionIdHistoriesMap() + + // TODO: remove and support continue generate in the future + delete sessionIdHistoriesMap[sessionId] + + await streamingCompletionWriter({ + editor: activeEditor, + onCancel() { + aiModelAbortController.abort() + }, + buildAiStream: async () => { + const convertMessages = await buildConvertChatMessages({ + workspaceFolder, + currentFilePath, + selection: activeEditor.selection, + abortController: aiModelAbortController + }) + + const history = await modelProvider.getHistory(sessionId) + const historyMessages = await history.getMessages() + const currentMessages: BaseMessage[] = convertMessages + const aiStream = aiModel.stream([ + ...historyMessages, + ...currentMessages + ]) + history.addMessages(currentMessages) + + return aiStream + } + }) + + // TODO: remove and support continue generate in the future + delete sessionIdHistoriesMap[sessionId] + } + + async cleanup(): Promise { + const openDocumentPaths = new Set( + vscode.workspace.textDocuments.map(doc => doc.uri.fsPath) + ) + const sessionIdHistoriesMap = await getCurrentSessionIdHistoriesMap() + + Object.keys(sessionIdHistoriesMap).forEach(sessionId => { + const path = sessionId.match(/^smartPaste:(.*)$/)?.[1] + + if (path && !openDocumentPaths.has(path)) { + delete sessionIdHistoriesMap[sessionId] + } + }) + } +} diff --git a/src/extension/commands/smart-paste/index.ts b/src/extension/commands/smart-paste/index.ts deleted file mode 100644 index dccfeb0..0000000 --- a/src/extension/commands/smart-paste/index.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { - createModelProvider, - getCurrentSessionIdHistoriesMap -} from '@extension/ai/helpers' -import { streamingCompletionWriter } from '@extension/file-utils/stream-completion-writer' -import { getCurrentWorkspaceFolderEditor } from '@extension/utils' -import { type BaseMessage } from '@langchain/core/messages' -import * as vscode from 'vscode' - -import { buildConvertChatMessages } from './build-convert-chat-messages' - -export const cleanupSmartPasteRunnables = async () => { - const openDocumentPaths = new Set( - vscode.workspace.textDocuments.map(doc => doc.uri.fsPath) - ) - const sessionIdHistoriesMap = await getCurrentSessionIdHistoriesMap() - - Object.keys(sessionIdHistoriesMap).forEach(sessionId => { - const path = sessionId.match(/^smartPaste:(.*)$/)?.[1] - - if (path && !openDocumentPaths.has(path)) { - delete sessionIdHistoriesMap[sessionId] - } - }) -} - -export const handleSmartPaste = async () => { - const { workspaceFolder, activeEditor } = - await getCurrentWorkspaceFolderEditor() - const currentFilePath = activeEditor.document.uri.fsPath - - // ai - const modelProvider = await createModelProvider() - const aiModelAbortController = new AbortController() - const aiModel = (await modelProvider.getModel()).bind({ - signal: aiModelAbortController.signal - }) - - const sessionId = `smartPaste:${currentFilePath}}` - const sessionIdHistoriesMap = await getCurrentSessionIdHistoriesMap() - - // TODO: remove and support continue generate in the future - delete sessionIdHistoriesMap[sessionId] - - await streamingCompletionWriter({ - editor: activeEditor, - onCancel() { - aiModelAbortController.abort() - }, - buildAiStream: async () => { - const convertMessages = await buildConvertChatMessages({ - workspaceFolder, - currentFilePath, - selection: activeEditor.selection, - abortController: aiModelAbortController - }) - - const history = await modelProvider.getHistory(sessionId) - const historyMessages = await history.getMessages() - const currentMessages: BaseMessage[] = convertMessages - const aiStream = aiModel.stream([...historyMessages, ...currentMessages]) - history.addMessages(currentMessages) - - return aiStream - } - }) - - // TODO: remove and support continue generate in the future - delete sessionIdHistoriesMap[sessionId] -} diff --git a/src/extension/config.ts b/src/extension/config.ts index 8011296..ad5a214 100644 --- a/src/extension/config.ts +++ b/src/extension/config.ts @@ -3,10 +3,10 @@ import * as vscode from 'vscode' import pkg from '../../package.json' import { t, translateVscodeJsonText } from './i18n' import { logger } from './logger' -import { getCurrentWorkspaceFolderEditor, getErrorMsg } from './utils' +import { getErrorMsg, getWorkspaceFolder } from './utils' const pkgConfig = pkg.contributes.configuration.properties -type ConfigKey = keyof { +export type ConfigKey = keyof { [K in keyof typeof pkgConfig as K extends `aide.${infer R}` ? R : never]: (typeof pkgConfig)[K] @@ -127,7 +127,7 @@ export const getConfigKey = async ( required, allowCustomOptionValue } = options || {} - const { workspaceFolder } = getCurrentWorkspaceFolderEditor(false) + const workspaceFolder = getWorkspaceFolder(false) const config = vscode.workspace.getConfiguration('aide', workspaceFolder) const configKeyInfo = { ...configKey[key], @@ -278,7 +278,7 @@ export const setConfigKey = async ( targetForSet = vscode.ConfigurationTarget.Global, allowCustomOptionValue = false } = options || {} - const { workspaceFolder } = getCurrentWorkspaceFolderEditor(false) + const workspaceFolder = getWorkspaceFolder(false) const config = vscode.workspace.getConfiguration('aide', workspaceFolder) const configKeyInfo = configKey[key] as ConfigKeyInfo diff --git a/src/extension/enable-system-proxy.ts b/src/extension/enable-system-proxy.ts deleted file mode 100644 index 7e4a920..0000000 --- a/src/extension/enable-system-proxy.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { bootstrap } from 'global-agent' -import { ProxyAgent, setGlobalDispatcher } from 'undici' - -import { getConfigKey } from './config' -import { logger } from './logger' -import { tryParseJSON } from './utils' - -const getDefaultProxyUrl = () => { - let proxyUrl = '' - - ;['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY'].forEach(key => { - if (proxyUrl) return - - const upperKey = key.toUpperCase() - const lowerKey = key.toLowerCase() - const upperKeyValue = - process.env[upperKey] && process.env[upperKey] !== 'undefined' - ? process.env[upperKey] || '' - : '' - const lowerKeyValue = - process.env[lowerKey] && process.env[lowerKey] !== 'undefined' - ? process.env[lowerKey] || '' - : '' - - proxyUrl = upperKeyValue || lowerKeyValue || '' - }) - - return proxyUrl -} - -export const enableSystemProxy = async () => { - try { - const useSystemProxy = await getConfigKey('useSystemProxy') - if (!useSystemProxy) return - - const proxyUrl = getDefaultProxyUrl() - - bootstrap() - - if (proxyUrl) { - const dispatcher = new ProxyAgent(proxyUrl) - setGlobalDispatcher(dispatcher) - } - } catch (err) { - logger.warn('Failed to enable global proxy', err) - } -} - -export const enableLogFetch = () => { - const originalFetch = globalThis.fetch - const logFetch: typeof globalThis.fetch = (input, init) => { - const reqBody = - typeof init?.body === 'string' - ? tryParseJSON(init.body, true) - : init?.body - - logger.dev.log('fetching...', { - input, - init, - url: input.toString(), - method: init?.method || 'GET', - headers: init?.headers || {}, - body: reqBody - }) - - return originalFetch(input, init) - } - globalThis.fetch = logFetch -} diff --git a/src/extension/clipboard.ts b/src/extension/file-utils/clipboard.ts similarity index 98% rename from src/extension/clipboard.ts rename to src/extension/file-utils/clipboard.ts index 9c47b2f..5e705cf 100644 --- a/src/extension/clipboard.ts +++ b/src/extension/file-utils/clipboard.ts @@ -3,11 +3,10 @@ import crypto from 'crypto' import { promises as fs } from 'fs' import { tmpdir } from 'os' import * as path from 'path' +import { t } from '@extension/i18n' +import { logger } from '@extension/logger' import * as vscode from 'vscode' -import { t } from './i18n' -import { logger } from './logger' - const getClipboardImageAsBase64Url = async (): Promise => { const osPlatform = process.platform const tempDir = tmpdir() diff --git a/src/extension/file-utils/create-tmp-file.ts b/src/extension/file-utils/create-tmp-file.ts deleted file mode 100644 index 22c4c3e..0000000 --- a/src/extension/file-utils/create-tmp-file.ts +++ /dev/null @@ -1,376 +0,0 @@ -import path from 'path' -import { t } from '@extension/i18n' -import { getLanguageId, getLanguageIdExt } from '@extension/utils' -import * as vscode from 'vscode' - -/** - * Returns a temporary file URI based on the original file URI and language ID. - * @param originalFileUri The URI of the original file. - * @param originalFileFullPath The full path of the original file. - * @param languageId The language ID of the file. - * @param untitled Indicates whether the temporary file is untitled. - * @returns The temporary file URI. - */ -export const getTmpFileUri = ({ - originalFileUri, - originalFileFullPath, - languageId, - ext, - untitled = true -}: { - originalFileUri?: vscode.Uri - originalFileFullPath?: string - languageId: string - ext?: string - untitled?: boolean -}) => { - if (!originalFileUri && !originalFileFullPath) - throw new Error(t('error.fileNotFound')) - const filePath = originalFileFullPath || originalFileUri!.fsPath - - const originalFileDir = path.dirname(filePath) - const originalFileName = path.parse(filePath).name - const originalFileExt = path.parse(filePath).ext - const languageExt = ext || getLanguageIdExt(languageId) || languageId - - const finalPath = path.join( - originalFileDir, - `${originalFileName}${originalFileExt}.aide${languageExt ? `.${languageExt}` : ''}` - ) - - if (!untitled) { - return vscode.Uri.file(finalPath) - } - - return vscode.Uri.parse(`untitled:${finalPath}`) -} - -const aideTmpFileRegExp = /\.aide(\.[^.]+)?$/ -export const isTmpFileUri = (uri: vscode.Uri, needUntitled = false) => { - let _isTmpFileUri = aideTmpFileRegExp.test(uri.fsPath) - - if (needUntitled) { - _isTmpFileUri = _isTmpFileUri && uri.scheme === 'untitled' - } - - return _isTmpFileUri -} - -/** - * Retrieves the original file URI based on the temporary file URI. - * If no temporary file URI is provided, it uses the URI of the active text editor. - * If the temporary file URI is an Aide-generated file, it removes the Aide-specific suffix to get the original file URI. - * If no original file URI is found, it throws an error. - * @param tmpFileUri The temporary file URI. - * @returns The original file URI. - * @throws An error if no active text editor is found or if the file is not found. - */ -export const getOriginalFileUri = (tmpFileUri?: vscode.Uri) => { - let maybeTmpFileUri = tmpFileUri - const activeEditor = vscode.window.activeTextEditor - - if (!maybeTmpFileUri) { - if (!activeEditor) { - throw new Error(t('error.noActiveEditor')) - } - maybeTmpFileUri = activeEditor.document.uri - } - - const tmpFileIsAideGenerated = isTmpFileUri(maybeTmpFileUri) - - let originalFileUri - - if (tmpFileIsAideGenerated) { - originalFileUri = vscode.Uri.file( - maybeTmpFileUri.fsPath.replace(aideTmpFileRegExp, '') - ) - } else { - if (!activeEditor) { - throw new Error(t('error.noActiveEditor')) - } - originalFileUri = activeEditor.document.uri - } - - if (!originalFileUri) throw new Error(t('error.fileNotFound')) - - return originalFileUri -} - -/** - * Represents information about a temporary file. - */ -export interface TmpFileInfo { - /** - * The URI of the original file. - */ - originalFileUri: vscode.Uri - - /** - * The text document of the original file. - */ - originalFileDocument: vscode.TextDocument - - /** - * The content of the original file. - */ - originalFileContent: string - - /** - * Indicates whether the original file content is from selection. - */ - originalFileContentIsFromSelection: boolean - - /** - * The language ID of the original file. - */ - originalFileLanguageId: string - - /** - * The extension of the original file. - */ - originalFileExt: string - - /** - * Indicates whether the active file is the original file. - */ - activeIsOriginalFile: boolean - - /** - * Indicates whether the selection is a temporary file. - */ - isSelection: boolean - - /** - * The URI of the temporary file. - */ - tmpFileUri: vscode.Uri - - /** - * Indicates whether the temporary file exists. - */ - isTmpFileExists?: boolean - - /** - * Indicates whether the temporary file has content. - */ - isTmpFileHasContent?: boolean -} - -/** - * Creates temporary file information. - * @returns A promise that resolves to a `TmpFileInfo` object. - * @throws An error if there is no active editor. - */ -export const createTmpFileInfo = async (): Promise => { - const activeEditor = vscode.window.activeTextEditor - - if (!activeEditor) throw new Error(t('error.noActiveEditor')) - - const originalFileUri = getOriginalFileUri() - const activeFileUri = activeEditor.document.uri - const activeIsOriginalFile = originalFileUri.fsPath === activeFileUri.fsPath - let originalFileContent = '' - let isSelection = false - let originalFileDocument: vscode.TextDocument - let originalFileContentIsFromSelection = false - - if (activeIsOriginalFile) { - const { selection } = activeEditor - isSelection = !selection.isEmpty - originalFileContent = isSelection - ? activeEditor.document.getText(selection) - : activeEditor.document.getText() - originalFileDocument = activeEditor.document - originalFileContentIsFromSelection = isSelection - } else { - originalFileDocument = - await vscode.workspace.openTextDocument(originalFileUri) - originalFileContent = originalFileDocument.getText() - } - - const originalFileLanguageId = originalFileDocument.languageId - const originalFileExt = path.extname(originalFileUri.fsPath).slice(1) - const tmpFileUri = getTmpFileUri({ - originalFileUri, - languageId: originalFileLanguageId - }) - const tmpFileDocument = vscode.workspace.textDocuments.find( - document => document.uri.fsPath === tmpFileUri.fsPath - ) - const isTmpFileExists = !!tmpFileDocument - const isTmpFileHasContent = !!tmpFileDocument?.getText() - - return { - originalFileUri, - originalFileDocument, - originalFileContent, - originalFileContentIsFromSelection, - originalFileLanguageId, - originalFileExt, - activeIsOriginalFile, - isSelection, - tmpFileUri, - isTmpFileExists, - isTmpFileHasContent - } -} - -export interface CreateTmpFileOptions { - ext?: string - languageId?: string - tmpFileUri?: vscode.Uri -} - -/** - * Represents the result of writing a temporary file. - */ -export interface WriteTmpFileResult { - /** - * The original file URI. - */ - originalFileUri: vscode.Uri - - /** - * The temporary file URI. - */ - tmpFileUri: vscode.Uri - - /** - * The temporary document. - */ - tmpDocument: vscode.TextDocument - - /** - * Writes the specified text to the temporary file. - * @param text The text to write. - */ - writeText: (text: string) => Promise - - /** - * Writes the specified text part to the temporary file. - * @param textPart The text part to write. - */ - writeTextPart: (textPart: string) => Promise - - /** - * Gets the text of the temporary file. - * @returns The text of the temporary file. - */ - getText: () => string - - /** - * Saves the temporary file. - * @returns A promise that resolves when the temporary file is saved. - */ - save: () => Promise - - /** - * Closes the temporary file. - * @returns A promise that resolves when the temporary file is closed. - */ - close: () => Promise - - /** - * Checks if the temporary file was closed without saving. - * @returns A boolean indicating if the temporary file was closed without saving. - */ - isClosedWithoutSaving: () => boolean -} - -/** - * Creates a temporary file and returns a writer object with various utility functions. - * @param options - The options for creating the temporary file. - * @returns A promise that resolves to a `WriteTmpFileResult` object. - */ -export const createTmpFileAndWriter = async ( - options: CreateTmpFileOptions -): Promise => { - if (!options.languageId && !options.tmpFileUri) - throw new Error( - "createTmpFileAndWriter: Either 'languageId' or 'tmpFileUri' must be provided." - ) - - const originalFileUri = getOriginalFileUri() - - const languageId = - options.languageId || - getLanguageId(path.extname(options.tmpFileUri!.fsPath).slice(1)) - - const tmpFileUri = - options.tmpFileUri || - getTmpFileUri({ - originalFileUri, - languageId: languageId!, - ext: options.ext - }) - - const tmpDocument = await vscode.workspace.openTextDocument(tmpFileUri) - const isDocumentAlreadyShown = vscode.window.visibleTextEditors.some( - editor => editor.document.uri.toString() === tmpDocument.uri.toString() - ) - - if (!isDocumentAlreadyShown) { - await vscode.window.showTextDocument(tmpDocument, { - preview: false, - viewColumn: vscode.ViewColumn.Beside - }) - } - - if (languageId) { - vscode.languages.setTextDocumentLanguage(tmpDocument, languageId) - } - - const writeText = async (text: string) => { - const edit = new vscode.WorkspaceEdit() - - // clean the file - const fullRange = new vscode.Range( - new vscode.Position(0, 0), - tmpDocument.lineAt(tmpDocument.lineCount - 1).range.end - ) - edit.delete(tmpFileUri, fullRange) - - // write the new content - edit.insert(tmpFileUri, new vscode.Position(0, 0), text) - await vscode.workspace.applyEdit(edit) - } - - const writeTextPart = async (textPart: string) => { - const edit = new vscode.WorkspaceEdit() - const position = new vscode.Position(tmpDocument.lineCount, 0) - edit.insert(tmpDocument.uri, position, textPart) - await vscode.workspace.applyEdit(edit) - } - - const getText = () => tmpDocument.getText() - - const save = async () => { - await tmpDocument.save() - } - - const close = async () => { - await vscode.commands.executeCommand( - 'aide.quickCloseFileWithoutSave', - tmpFileUri - ) - } - - const isClosedWithoutSaving = () => { - if (tmpDocument.isClosed) { - return !tmpDocument.getText() - } - return false - } - - return { - originalFileUri, - tmpFileUri, - tmpDocument, - writeText, - writeTextPart, - getText, - save, - close, - isClosedWithoutSaving - } -} diff --git a/src/extension/file-utils/stream-completion-writer.ts b/src/extension/file-utils/stream-completion-writer.ts index a622a7c..fbc11d8 100644 --- a/src/extension/file-utils/stream-completion-writer.ts +++ b/src/extension/file-utils/stream-completion-writer.ts @@ -16,57 +16,87 @@ export interface StreamingCompletionWriterOptions { onCancel?: () => void } -/** - * Writes the completion text from an AI stream to the editor. - * @param options - The options for the streaming completion writer. - * @returns A promise that resolves when the writing is complete. - */ export const streamingCompletionWriter = async ( options: StreamingCompletionWriterOptions ): Promise => { const { editor, buildAiStream, onCancel } = options const ModelProvider = await getCurrentModelProvider() const { showProcessLoading, hideProcessLoading } = createLoading() - const isClosedFile = () => editor.document.isClosed const initialPosition = editor.selection.active - let currentPosition = initialPosition + const writer = new EditorWriter(editor, initialPosition) - const dispose = () => { + try { + showProcessLoading({ onCancel }) + const aiStream = await buildAiStream() + await processAiStream(aiStream, ModelProvider, writer) + await finalizeEditing(editor) + } finally { hideProcessLoading() } +} + +export const processAiStream = async ( + aiStream: IterableReadableStream, + ModelProvider: any, + writer: EditorWriter +): Promise => { + let fullText = '' + for await (const chunk of aiStream) { + if (writer.isDocumentClosed()) { + return + } + + const text = ModelProvider.answerContentToText(chunk.content) + if (!text) continue + + fullText += text + await writer.writeTextPart(text) + + const cleanedText = removeCodeBlockStartSyntax(fullText) + if (cleanedText !== fullText) { + await writer.writeText(cleanedText, fullText.length) + fullText = cleanedText + } + } - const writeTextPart = async (text: string) => { - await editor.edit( + const finalText = removeCodeBlockSyntax(removeCodeBlockEndSyntax(fullText)) + if (finalText !== fullText) { + await writer.writeText(finalText, fullText.length) + } +} + +export const finalizeEditing = async ( + editor: vscode.TextEditor +): Promise => { + await editor.edit(() => {}, { undoStopBefore: true, undoStopAfter: true }) +} + +class EditorWriter { + private currentPosition: vscode.Position + + constructor( + private editor: vscode.TextEditor, + private initialPosition: vscode.Position + ) { + this.currentPosition = initialPosition + } + + async writeTextPart(text: string): Promise { + await this.editor.edit( editBuilder => { - // only insert new text - editBuilder.insert(currentPosition, text) + editBuilder.insert(this.currentPosition, text) }, { undoStopBefore: false, undoStopAfter: false } ) await sleep(10) - - // update current position to the end of the inserted text - currentPosition = editor.document.positionAt( - editor.document.offsetAt(currentPosition) + text.length - ) - - // update editor selection to the new cursor position - editor.selection = new vscode.Selection(currentPosition, currentPosition) + this.updatePosition(this.currentPosition, text.length) } - const writeText = async ( - text: string, - originPosition: vscode.Position, - originText: string - ) => { - // override existing text if any - const startOffset = editor.document.offsetAt(originPosition) - const endOffset = startOffset + originText.length - const endPosition = editor.document.positionAt(endOffset) - const range = new vscode.Range(originPosition, endPosition) - - await editor.edit( + async writeText(text: string, fullTextLength: number): Promise { + const range = this.getReplacementRange(fullTextLength) + + await this.editor.edit( editBuilder => { editBuilder.replace(range, text) }, @@ -74,64 +104,30 @@ export const streamingCompletionWriter = async ( ) await sleep(10) + this.updatePosition(this.initialPosition, text.length) + } - // update current position to the end of the inserted text - currentPosition = editor.document.positionAt( - editor.document.offsetAt(originPosition) + text.length + private updatePosition( + startPosition: vscode.Position, + textLength: number + ): void { + this.currentPosition = this.editor.document.positionAt( + this.editor.document.offsetAt(startPosition) + textLength + ) + this.editor.selection = new vscode.Selection( + this.currentPosition, + this.currentPosition ) - - // update editor selection - editor.selection = new vscode.Selection(currentPosition, currentPosition) } - try { - showProcessLoading({ - onCancel - }) - - const aiStream = await buildAiStream() - - let fullText = '' - for await (const chunk of aiStream) { - if (isClosedFile()) { - dispose() - return - } - - // convert openai answer content to text - const text = ModelProvider.answerContentToText(chunk.content) - - if (!text) continue - - fullText += text - - await writeTextPart(text) - - // remove code block syntax - // for example, remove ```python\n and \n``` - const cleanedText = removeCodeBlockStartSyntax(fullText) - - if (cleanedText !== fullText) { - await writeText(cleanedText, initialPosition, fullText) - fullText = cleanedText - } - } - - // remove code block syntax - // for example, remove ```python\n and \n``` at the start and end - // just confirm the code is clean - const finalText = removeCodeBlockSyntax(removeCodeBlockEndSyntax(fullText)) - - if (finalText !== fullText) { - // write the final code - await writeText(finalText, initialPosition, fullText) - } - - // create an undo stop point after completion - await editor.edit(() => {}, { undoStopBefore: true, undoStopAfter: true }) + private getReplacementRange(fullTextLength: number): vscode.Range { + const startOffset = this.editor.document.offsetAt(this.initialPosition) + const endOffset = startOffset + fullTextLength + const endPosition = this.editor.document.positionAt(endOffset) + return new vscode.Range(this.initialPosition, endPosition) + } - await vscode.commands.executeCommand('editor.action.inlineSuggest.commit') - } finally { - dispose() + isDocumentClosed(): boolean { + return this.editor.document.isClosed } } diff --git a/src/extension/file-utils/tmp-file-writer.ts b/src/extension/file-utils/tmp-file-writer.ts deleted file mode 100644 index 8c6640f..0000000 --- a/src/extension/file-utils/tmp-file-writer.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { getCurrentModelProvider } from '@extension/ai/helpers' -import { createLoading } from '@extension/loading' -import { - removeCodeBlockEndSyntax, - removeCodeBlockStartSyntax, - removeCodeBlockSyntax -} from '@extension/utils' -import type { IterableReadableStream } from '@langchain/core/dist/utils/stream' -import type { AIMessageChunk } from '@langchain/core/messages' - -import { - createTmpFileAndWriter, - type CreateTmpFileOptions, - type WriteTmpFileResult -} from './create-tmp-file' - -export interface TmpFileWriterOptions extends CreateTmpFileOptions { - stopWriteWhenClosed?: boolean - enableProcessLoading?: boolean - autoSaveWhenDone?: boolean - autoCloseWhenDone?: boolean - buildAiStream: () => Promise> - onCancel?: () => void -} - -/** - * Writes temporary file with AI-generated content. - * - * @param options - The options for writing the temporary file. - * @returns A promise that resolves to the result of writing the temporary file. - */ -export const tmpFileWriter = async ( - options: TmpFileWriterOptions -): Promise => { - const { - buildAiStream, - onCancel, - stopWriteWhenClosed = true, - enableProcessLoading = true, - autoSaveWhenDone = false, - autoCloseWhenDone = false, - ...createTmpFileOptions - } = options - - const createTmpFileAndWriterReturns = - await createTmpFileAndWriter(createTmpFileOptions) - const { - writeTextPart, - getText, - writeText, - save, - close, - isClosedWithoutSaving - } = createTmpFileAndWriterReturns - - const ModelProvider = await getCurrentModelProvider() - const { showProcessLoading, hideProcessLoading } = createLoading() - - try { - enableProcessLoading && - showProcessLoading({ - onCancel - }) - - const aiStream = await buildAiStream() - - for await (const chunk of aiStream) { - if (stopWriteWhenClosed && isClosedWithoutSaving()) { - enableProcessLoading && hideProcessLoading() - return createTmpFileAndWriterReturns - } - - // convert openai answer content to text - const text = ModelProvider.answerContentToText(chunk.content) - await writeTextPart(text) - - // remove code block syntax - // for example, remove ```python\n and \n``` - const currentText = getText() - const cleanedText = removeCodeBlockStartSyntax(currentText) - - if (cleanedText !== currentText) { - await writeText(cleanedText) - } - } - - // remove code block syntax - // for example, remove ```python\n and \n``` at the start and end - // just confirm the code is clean - const currentText = getText() - const finalText = removeCodeBlockSyntax( - removeCodeBlockEndSyntax(currentText) - ) - - if (finalText !== currentText) { - // write the final code - await writeText(finalText) - } - - if (autoSaveWhenDone) save() - - if (autoCloseWhenDone) close() - } finally { - enableProcessLoading && hideProcessLoading() - } - - return createTmpFileAndWriterReturns -} diff --git a/src/extension/file-utils/tmp-file/create-tmp-file-and-writer.ts b/src/extension/file-utils/tmp-file/create-tmp-file-and-writer.ts new file mode 100644 index 0000000..bb408fa --- /dev/null +++ b/src/extension/file-utils/tmp-file/create-tmp-file-and-writer.ts @@ -0,0 +1,109 @@ +import path from 'path' +import { getLanguageId } from '@extension/utils' +import * as vscode from 'vscode' + +import { getOriginalFileUri } from './get-original-file-uri' +import { getTmpFileUri } from './get-tmp-file-uri' + +export interface CreateTmpFileOptions { + ext?: string + languageId?: string + tmpFileUri?: vscode.Uri +} + +export interface WriteTmpFileResult { + originalFileUri: vscode.Uri + tmpFileUri: vscode.Uri + tmpDocument: vscode.TextDocument + writeText: (text: string) => Promise + writeTextPart: (textPart: string) => Promise + getText: () => string + save: () => Promise + close: () => Promise + isClosedWithoutSaving: () => boolean +} + +export const createTmpFileAndWriter = async ( + options: CreateTmpFileOptions +): Promise => { + if (!options.languageId && !options.tmpFileUri) { + throw new Error( + "createTmpFileAndWriter: Either 'languageId' or 'tmpFileUri' must be provided." + ) + } + + const originalFileUri = getOriginalFileUri() + const languageId = + options.languageId || + getLanguageId(path.extname(options.tmpFileUri!.fsPath).slice(1)) + const tmpFileUri = + options.tmpFileUri || + getTmpFileUri({ + originalFileUri, + languageId: languageId!, + ext: options.ext + }) + + const tmpDocument = await vscode.workspace.openTextDocument(tmpFileUri) + await showDocumentIfNotVisible(tmpDocument) + + if (languageId) { + vscode.languages.setTextDocumentLanguage(tmpDocument, languageId) + } + + return { + originalFileUri, + tmpFileUri, + tmpDocument, + writeText: (text: string) => writeTextToDocument(tmpDocument, text), + writeTextPart: (textPart: string) => + appendTextToDocument(tmpDocument, textPart), + getText: () => tmpDocument.getText(), + save: () => tmpDocument.save() as Promise, + close: () => closeDocument(tmpFileUri), + isClosedWithoutSaving: () => tmpDocument.isClosed && !tmpDocument.getText() + } +} + +export const showDocumentIfNotVisible = async ( + document: vscode.TextDocument +): Promise => { + const isDocumentAlreadyShown = vscode.window.visibleTextEditors.some( + editor => editor.document.uri.toString() === document.uri.toString() + ) + + if (!isDocumentAlreadyShown) { + await vscode.window.showTextDocument(document, { + preview: false, + viewColumn: vscode.ViewColumn.Beside + }) + } +} + +export const writeTextToDocument = async ( + document: vscode.TextDocument, + text: string +): Promise => { + const edit = new vscode.WorkspaceEdit() + const fullRange = new vscode.Range( + new vscode.Position(0, 0), + document.lineAt(document.lineCount - 1).range.end + ) + edit.delete(document.uri, fullRange) + edit.insert(document.uri, new vscode.Position(0, 0), text) + await vscode.workspace.applyEdit(edit) +} + +async function appendTextToDocument( + document: vscode.TextDocument, + textPart: string +): Promise { + const edit = new vscode.WorkspaceEdit() + const position = new vscode.Position(document.lineCount, 0) + edit.insert(document.uri, position, textPart) + await vscode.workspace.applyEdit(edit) +} + +async function closeDocument(uri: vscode.Uri): Promise { + await vscode.commands.executeCommand('aide.quickCloseFileWithoutSave', uri) +} diff --git a/src/extension/file-utils/tmp-file/get-original-file-uri.ts b/src/extension/file-utils/tmp-file/get-original-file-uri.ts new file mode 100644 index 0000000..a8374d7 --- /dev/null +++ b/src/extension/file-utils/tmp-file/get-original-file-uri.ts @@ -0,0 +1,15 @@ +import { getActiveEditor } from '@extension/utils' +import * as vscode from 'vscode' + +import { AIDE_TMP_FILE_REGEX, isTmpFileUri } from './is-tmp-file-uri' + +export const getOriginalFileUri = (tmpFileUri?: vscode.Uri): vscode.Uri => { + const fileUri = tmpFileUri || getActiveEditor().document.uri + const isAideGeneratedFile = isTmpFileUri(fileUri) + + if (isAideGeneratedFile) { + return vscode.Uri.file(fileUri.fsPath.replace(AIDE_TMP_FILE_REGEX, '')) + } + + return getActiveEditor().document.uri +} diff --git a/src/extension/file-utils/tmp-file/get-tmp-file-info.ts b/src/extension/file-utils/tmp-file/get-tmp-file-info.ts new file mode 100644 index 0000000..6b57c4a --- /dev/null +++ b/src/extension/file-utils/tmp-file/get-tmp-file-info.ts @@ -0,0 +1,72 @@ +import path from 'path' +import { getActiveEditor } from '@extension/utils' +import * as vscode from 'vscode' + +import { getTmpFileUri } from './get-tmp-file-uri' + +export interface TmpFileInfo { + originalFileUri: vscode.Uri + originalFileDocument: vscode.TextDocument + originalFileContent: string + originalFileContentIsFromSelection: boolean + originalFileLanguageId: string + originalFileExt: string + activeIsOriginalFile: boolean + isSelection: boolean + tmpFileUri: vscode.Uri + isTmpFileExists: boolean + isTmpFileHasContent: boolean +} + +export const getTmpFileInfo = async ( + originalFileUri: vscode.Uri +): Promise => { + const activeEditor = getActiveEditor() + const activeFileUri = activeEditor.document.uri + const activeIsOriginalFile = originalFileUri.fsPath === activeFileUri.fsPath + + let originalFileContent = '' + let isSelection = false + let originalFileDocument: vscode.TextDocument + let originalFileContentIsFromSelection = false + + if (activeIsOriginalFile) { + const { selection } = activeEditor + isSelection = !selection.isEmpty + originalFileContent = isSelection + ? activeEditor.document.getText(selection) + : activeEditor.document.getText() + originalFileDocument = activeEditor.document + originalFileContentIsFromSelection = isSelection + } else { + originalFileDocument = + await vscode.workspace.openTextDocument(originalFileUri) + originalFileContent = originalFileDocument.getText() + } + + const originalFileLanguageId = originalFileDocument.languageId + const originalFileExt = path.extname(originalFileUri.fsPath).slice(1) + const tmpFileUri = getTmpFileUri({ + originalFileUri, + languageId: originalFileLanguageId + }) + const tmpFileDocument = vscode.workspace.textDocuments.find( + document => document.uri.fsPath === tmpFileUri.fsPath + ) + const isTmpFileExists = !!tmpFileDocument + const isTmpFileHasContent = !!tmpFileDocument?.getText() + + return { + originalFileUri, + originalFileDocument, + originalFileContent, + originalFileContentIsFromSelection, + originalFileLanguageId, + originalFileExt, + activeIsOriginalFile, + isSelection, + tmpFileUri, + isTmpFileExists, + isTmpFileHasContent + } +} diff --git a/src/extension/file-utils/tmp-file/get-tmp-file-uri.ts b/src/extension/file-utils/tmp-file/get-tmp-file-uri.ts new file mode 100644 index 0000000..e0e48cd --- /dev/null +++ b/src/extension/file-utils/tmp-file/get-tmp-file-uri.ts @@ -0,0 +1,41 @@ +import path from 'path' +import { t } from '@extension/i18n' +import { getLanguageIdExt } from '@extension/utils' +import * as vscode from 'vscode' + +interface TmpFileUriOptions { + originalFileUri?: vscode.Uri + originalFileFullPath?: string + languageId: string + ext?: string + untitled?: boolean +} + +export const getTmpFileUri = ({ + originalFileUri, + originalFileFullPath, + languageId, + ext, + untitled = true +}: TmpFileUriOptions): vscode.Uri => { + if (!originalFileUri && !originalFileFullPath) { + throw new Error(t('error.fileNotFound')) + } + + const filePath = originalFileFullPath || originalFileUri!.fsPath + const { + dir: originalFileDir, + name: originalFileName, + ext: originalFileExt + } = path.parse(filePath) + const languageExt = ext || getLanguageIdExt(languageId) || languageId + + const finalPath = path.join( + originalFileDir, + `${originalFileName}${originalFileExt}.aide${languageExt ? `.${languageExt}` : ''}` + ) + + return untitled + ? vscode.Uri.parse(`untitled:${finalPath}`) + : vscode.Uri.file(finalPath) +} diff --git a/src/extension/file-utils/tmp-file/is-tmp-file-uri.ts b/src/extension/file-utils/tmp-file/is-tmp-file-uri.ts new file mode 100644 index 0000000..cabeed7 --- /dev/null +++ b/src/extension/file-utils/tmp-file/is-tmp-file-uri.ts @@ -0,0 +1,11 @@ +import * as vscode from 'vscode' + +export const AIDE_TMP_FILE_REGEX = /\.aide(\.[^.]+)?$/ + +export const isTmpFileUri = ( + uri: vscode.Uri, + requireUntitled = false +): boolean => { + const isTmpFile = AIDE_TMP_FILE_REGEX.test(uri.fsPath) + return requireUntitled ? isTmpFile && uri.scheme === 'untitled' : isTmpFile +} diff --git a/src/extension/file-utils/tmp-file/tmp-file-writer.ts b/src/extension/file-utils/tmp-file/tmp-file-writer.ts new file mode 100644 index 0000000..44dc8dd --- /dev/null +++ b/src/extension/file-utils/tmp-file/tmp-file-writer.ts @@ -0,0 +1,110 @@ +import { getCurrentModelProvider } from '@extension/ai/helpers' +import { createLoading } from '@extension/loading' +import { + removeCodeBlockEndSyntax, + removeCodeBlockStartSyntax, + removeCodeBlockSyntax +} from '@extension/utils' +import type { IterableReadableStream } from '@langchain/core/dist/utils/stream' +import type { AIMessageChunk } from '@langchain/core/messages' + +import { + createTmpFileAndWriter, + type CreateTmpFileOptions, + type WriteTmpFileResult +} from './create-tmp-file-and-writer' + +export interface TmpFileWriterOptions { + tmpFileOptions: CreateTmpFileOptions + stopWriteWhenClosed?: boolean + enableProcessLoading?: boolean + autoSaveWhenDone?: boolean + autoCloseWhenDone?: boolean + buildAiStream: () => Promise> + onCancel?: () => void +} + +export const tmpFileWriter = async ( + options: TmpFileWriterOptions +): Promise => { + const { + buildAiStream, + onCancel, + stopWriteWhenClosed = true, + enableProcessLoading = true, + autoSaveWhenDone = false, + autoCloseWhenDone = false, + tmpFileOptions + } = options + + const writer = await createTmpFileAndWriter(tmpFileOptions) + const ModelProvider = await getCurrentModelProvider() + const { showProcessLoading, hideProcessLoading } = createLoading() + + try { + if (enableProcessLoading) { + showProcessLoading({ onCancel }) + } + + await processAiStream( + writer, + ModelProvider, + buildAiStream, + stopWriteWhenClosed + ) + + if (autoSaveWhenDone) { + await writer.save() + } + + if (autoCloseWhenDone) { + await writer.close() + } + } finally { + if (enableProcessLoading) { + hideProcessLoading() + } + } + + return writer +} + +const processAiStream = async ( + writer: WriteTmpFileResult, + ModelProvider: any, + buildAiStream: () => Promise>, + stopWriteWhenClosed: boolean +) => { + const aiStream = await buildAiStream() + + for await (const chunk of aiStream) { + if (stopWriteWhenClosed && writer.isClosedWithoutSaving()) { + return + } + + const text = ModelProvider.answerContentToText(chunk.content) + await writer.writeTextPart(text) + + await cleanCodeBlockSyntax(writer) + } + + await finalCleanup(writer) +} + +const cleanCodeBlockSyntax = async (writer: WriteTmpFileResult) => { + const currentText = writer.getText() + const cleanedText = removeCodeBlockStartSyntax(currentText) + + if (cleanedText !== currentText) { + await writer.writeText(cleanedText) + } +} + +const finalCleanup = async (writer: WriteTmpFileResult) => { + const currentText = writer.getText() + const finalText = removeCodeBlockSyntax(removeCodeBlockEndSyntax(currentText)) + + if (finalText !== currentText) { + await writer.writeText(finalText) + } +} diff --git a/src/extension/index.ts b/src/extension/index.ts index d945617..c0e03be 100644 --- a/src/extension/index.ts +++ b/src/extension/index.ts @@ -1,36 +1,27 @@ import * as vscode from 'vscode' import { BaseModelProvider } from './ai/model-providers/base' -import { autoOpenCorrespondingFiles } from './auto-open-corresponding-files' -import { cleanup } from './cleanup' import { registerCommands } from './commands' +import { CommandManager } from './commands/command-manager' import { setContext } from './context' -import { enableLogFetch, enableSystemProxy } from './enable-system-proxy' import { initializeLocalization } from './i18n' import { logger } from './logger' -import { enablePolyfill } from './polyfill' -import { registerProviders } from './providers' -import { initAideKeyUsageStatusBar } from './providers/aide-key-usage-statusbar' +import { setupRegisters } from './registers' +import { RegisterManager } from './registers/register-manager' import { redisStorage, stateStorage } from './storage' export const activate = async (context: vscode.ExtensionContext) => { try { - const isDev = context.extensionMode !== vscode.ExtensionMode.Production - logger.log('"Aide" is now active!') await initializeLocalization() setContext(context) - await enablePolyfill() - await enableSystemProxy() - isDev && enableLogFetch() - - await registerCommands(context) - await registerProviders(context) - await initAideKeyUsageStatusBar(context) - await autoOpenCorrespondingFiles(context) - await cleanup(context) - // await renderWebview(context) + + const commandManager = new CommandManager(context) + await registerCommands(commandManager) + + const registerManager = new RegisterManager(context, commandManager) + await setupRegisters(registerManager) } catch (err) { logger.warn('Failed to activate extension', err) } diff --git a/src/extension/providers/aide-key-usage-statusbar.ts b/src/extension/providers/aide-key-usage-statusbar.ts deleted file mode 100644 index cb203b4..0000000 --- a/src/extension/providers/aide-key-usage-statusbar.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { t } from '@extension/i18n' -import * as vscode from 'vscode' - -let aideKeyUsageStatusBar: vscode.StatusBarItem - -export const initAideKeyUsageStatusBar = (context: vscode.ExtensionContext) => { - aideKeyUsageStatusBar = vscode.window.createStatusBarItem( - vscode.StatusBarAlignment.Right, - 100 - ) - aideKeyUsageStatusBar.text = `$(info) ${t('info.aideKeyUsageStatusBar.text')}` - aideKeyUsageStatusBar.tooltip = t('info.aideKeyUsageStatusBar.tooltip') - aideKeyUsageStatusBar.command = 'aide.showAideKeyUsageInfo' - aideKeyUsageStatusBar.show() - - context.subscriptions.push(aideKeyUsageStatusBar) -} - -export const updateAideKeyUsageStatusBar = (text: string) => { - aideKeyUsageStatusBar.text = text -} diff --git a/src/extension/providers/index.ts b/src/extension/providers/index.ts deleted file mode 100644 index 777cfce..0000000 --- a/src/extension/providers/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import * as vscode from 'vscode' - -import { TmpFileActionCodeLensProvider } from './tmp-file-action' - -export const registerProviders = async (context: vscode.ExtensionContext) => { - const tmpFileActionCodeLensProvider = new TmpFileActionCodeLensProvider() - - // register CodeLensProvider, only for file name contains .aide - context.subscriptions.push( - vscode.languages.registerCodeLensProvider( - { scheme: '*', pattern: '**/*.aide*' }, - tmpFileActionCodeLensProvider - ) - ) -} diff --git a/src/extension/providers/webview.ts b/src/extension/providers/webview.ts deleted file mode 100644 index 65cded5..0000000 --- a/src/extension/providers/webview.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { saveImage, setupHtml } from '@extension/utils' -import type { WebviewToExtensionsMsg } from '@shared/types/msg' -import * as vscode from 'vscode' - -// { -// "viewsContainers": { -// "activitybar": [ -// { -// "id": "aide", -// "title": "Aide", -// "icon": "res/icon-mask.png" -// } -// ] -// }, -// "views": { -// "aide": [ -// { -// "type": "webview", -// "id": "aide.webview", -// "name": "Aide" -// } -// ] -// }, -// } -class AideWebViewProvider implements vscode.WebviewViewProvider { - public static readonly viewType = 'aide.webview' - - private _view?: vscode.WebviewView - - private disposables: vscode.Disposable[] = [] - - constructor( - private readonly _extensionUri: vscode.Uri, - private readonly _context: vscode.ExtensionContext - ) {} - - public resolveWebviewView( - webviewView: vscode.WebviewView - // context: vscode.WebviewViewResolveContext, - // _token: vscode.CancellationToken - ) { - this._view = webviewView - - webviewView.webview.options = { - enableScripts: true, - localResourceRoots: [this._extensionUri] - } - - webviewView.webview.html = this._getHtmlForWebview(webviewView.webview) - - this.disposables.push( - webviewView.webview.onDidReceiveMessage( - async (message: WebviewToExtensionsMsg) => { - switch (message.type) { - case 'save-img': - await saveImage(message.data) - break - case 'show-settings': - await vscode.commands.executeCommand( - 'workbench.action.openSettings', - `@ext:aide-pro` - ) - break - default: - break - } - }, - undefined, - this.disposables - ) - ) - - webviewView.onDidDispose(() => { - while (this.disposables.length) { - const disposable = this.disposables.pop() - if (disposable) { - disposable.dispose() - } - } - }) - } - - public reveal() { - if (this._view) { - this._view.show?.(true) - } - } - - private _getHtmlForWebview(webview: vscode.Webview) { - return setupHtml(webview, this._context) - } -} - -export async function renderWebview( - context: vscode.ExtensionContext -): Promise { - const provider = new AideWebViewProvider(context.extensionUri, context) - const disposable = vscode.window.registerWebviewViewProvider( - AideWebViewProvider.viewType, - provider - ) - - context.subscriptions.push(disposable) - - provider.reveal() - - return () => { - disposable.dispose() - } -} diff --git a/src/extension/registers/aide-key-usage-statusbar.register.ts b/src/extension/registers/aide-key-usage-statusbar.register.ts new file mode 100644 index 0000000..b083e04 --- /dev/null +++ b/src/extension/registers/aide-key-usage-statusbar.register.ts @@ -0,0 +1,35 @@ +import { t } from '@extension/i18n' +import * as vscode from 'vscode' + +import { BaseRegister } from './base.register' + +export class AideKeyUsageStatusBarRegister extends BaseRegister { + statusBar: vscode.StatusBarItem | undefined + + updateStatusBar(text: string): void { + if (this.statusBar) { + this.statusBar.text = text + } + } + + register(): void { + this.statusBar = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right, + 100 + ) + + const { statusBar } = this + + statusBar.text = `$(info) ${t('info.aideKeyUsageStatusBar.text')}` + statusBar.tooltip = t('info.aideKeyUsageStatusBar.tooltip') + statusBar.command = 'aide.showAideKeyUsageInfo' + statusBar.show() + + this.context.subscriptions.push(statusBar) + + this.registerManager.commandManager.registerService( + 'AideKeyUsageStatusBarRegister', + this + ) + } +} diff --git a/src/extension/registers/auto-open-corresponding-files.register.ts b/src/extension/registers/auto-open-corresponding-files.register.ts new file mode 100644 index 0000000..dc898de --- /dev/null +++ b/src/extension/registers/auto-open-corresponding-files.register.ts @@ -0,0 +1,69 @@ +import { getOriginalFileUri } from '@extension/file-utils/tmp-file/get-original-file-uri' +import { isTmpFileUri } from '@extension/file-utils/tmp-file/is-tmp-file-uri' +import { VsCodeFS } from '@extension/file-utils/vscode-fs' +import { logger } from '@extension/logger' +import * as vscode from 'vscode' + +import { BaseRegister } from './base.register' + +export class AutoOpenCorrespondingFilesRegister extends BaseRegister { + private isHandlingEditorChange = false + + register(): void { + // this.context.subscriptions.push( + // vscode.workspace.onDidOpenTextDocument( + // this.handleDocumentOpen.bind(this) + // ), + // vscode.window.onDidChangeActiveTextEditor( + // this.handleEditorChange.bind(this) + // ) + // ) + } + + private async handleDocumentOpen( + document: vscode.TextDocument + ): Promise { + const maybeTmpUri = document.uri + if (isTmpFileUri(maybeTmpUri)) { + await this.openCorrespondingFiles(maybeTmpUri) + } + } + + private handleEditorChange(editor: vscode.TextEditor | undefined): void { + const maybeTmpUri = editor?.document.uri + if ( + maybeTmpUri && + isTmpFileUri(maybeTmpUri) && + !this.isHandlingEditorChange + ) { + this.openCorrespondingFiles(maybeTmpUri) + } + } + + private async openCorrespondingFiles(tmpUri: vscode.Uri): Promise { + if (this.isHandlingEditorChange || !isTmpFileUri(tmpUri)) return + this.isHandlingEditorChange = true + const originalUri = getOriginalFileUri(tmpUri) + + try { + // check if the original file exists + await VsCodeFS.stat(originalUri.fsPath) + + // open original file + const originalDocument = + await vscode.workspace.openTextDocument(originalUri) + await vscode.window.showTextDocument( + originalDocument, + vscode.ViewColumn.One + ) + + // refocus on the .aide file + const tmpDocument = await vscode.workspace.openTextDocument(tmpUri) + await vscode.window.showTextDocument(tmpDocument, vscode.ViewColumn.Two) + } catch (e) { + logger.warn('openCorrespondingFiles error', e) + } finally { + this.isHandlingEditorChange = false + } + } +} diff --git a/src/extension/registers/base.register.ts b/src/extension/registers/base.register.ts new file mode 100644 index 0000000..b33c733 --- /dev/null +++ b/src/extension/registers/base.register.ts @@ -0,0 +1,16 @@ +import type { CommandManager } from '@extension/commands/command-manager' +import * as vscode from 'vscode' + +import type { RegisterManager } from './register-manager' + +export abstract class BaseRegister { + constructor( + protected context: vscode.ExtensionContext, + protected registerManager: RegisterManager, + protected commandManager: CommandManager + ) {} + + abstract register(): void | Promise + + cleanup(): Promise | void {} +} diff --git a/src/extension/registers/index.ts b/src/extension/registers/index.ts new file mode 100644 index 0000000..737b909 --- /dev/null +++ b/src/extension/registers/index.ts @@ -0,0 +1,21 @@ +import { AideKeyUsageStatusBarRegister } from './aide-key-usage-statusbar.register' +import { AutoOpenCorrespondingFilesRegister } from './auto-open-corresponding-files.register' +import { BaseRegister } from './base.register' +import { RegisterManager } from './register-manager' +import { SystemSetupRegister } from './system-setup.register' +import { TmpFileActionRegister } from './tmp-file-action.register' +import { WebviewRegister } from './webview.register' + +export const setupRegisters = async (registerManager: RegisterManager) => { + const Registers = [ + SystemSetupRegister, + TmpFileActionRegister, + AideKeyUsageStatusBarRegister, + AutoOpenCorrespondingFilesRegister, + WebviewRegister + ] satisfies (typeof BaseRegister)[] + + Registers.forEach(async Register => { + await registerManager.setupRegister(Register) + }) +} diff --git a/src/extension/registers/register-manager.ts b/src/extension/registers/register-manager.ts new file mode 100644 index 0000000..cc6f00c --- /dev/null +++ b/src/extension/registers/register-manager.ts @@ -0,0 +1,27 @@ +import type { CommandManager } from '@extension/commands/command-manager' +import * as vscode from 'vscode' + +import { BaseRegister } from './base.register' + +export class RegisterManager { + private registers: BaseRegister[] = [] + + constructor( + private context: vscode.ExtensionContext, + public commandManager: CommandManager + ) {} + + async setupRegister( + RegisterClass: new ( + ...args: ConstructorParameters + ) => BaseRegister + ): Promise { + const register = new RegisterClass(this.context, this, this.commandManager) + await register.register() + this.registers.push(register) + } + + async cleanup(): Promise { + await Promise.allSettled(this.registers.map(register => register.cleanup())) + } +} diff --git a/src/extension/registers/system-setup.register.ts b/src/extension/registers/system-setup.register.ts new file mode 100644 index 0000000..d3c472b --- /dev/null +++ b/src/extension/registers/system-setup.register.ts @@ -0,0 +1,87 @@ +import { getConfigKey } from '@extension/config' +import { logger } from '@extension/logger' +import { enablePolyfill } from '@extension/polyfill' +import { getIsDev, tryParseJSON } from '@extension/utils' +import { bootstrap } from 'global-agent' +import { ProxyAgent, setGlobalDispatcher } from 'undici' + +import { BaseRegister } from './base.register' + +export class SystemSetupRegister extends BaseRegister { + async register(): Promise { + await this.setupPolyfill() + await this.setupSystemProxy() + await this.setupLogFetch() + } + + private async setupPolyfill(): Promise { + await enablePolyfill() + } + + private async setupSystemProxy(): Promise { + try { + const useSystemProxy = await getConfigKey('useSystemProxy') + if (!useSystemProxy) return + + const proxyUrl = this.getDefaultProxyUrl() + + bootstrap() + + if (proxyUrl) { + const dispatcher = new ProxyAgent(proxyUrl) + setGlobalDispatcher(dispatcher) + } + } catch (err) { + logger.warn('Failed to enable global proxy', err) + } + } + + private setupLogFetch(): void { + const isDev = getIsDev() + + if (!isDev) return + + const originalFetch = globalThis.fetch + const logFetch: typeof globalThis.fetch = (input, init) => { + const reqBody = + typeof init?.body === 'string' + ? tryParseJSON(init.body, true) + : init?.body + + logger.dev.log('fetching...', { + input, + init, + url: input.toString(), + method: init?.method || 'GET', + headers: init?.headers || {}, + body: reqBody + }) + + return originalFetch(input, init) + } + globalThis.fetch = logFetch + } + + private getDefaultProxyUrl(): string { + let proxyUrl = '' + + ;['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY'].forEach(key => { + if (proxyUrl) return + + const upperKey = key.toUpperCase() + const lowerKey = key.toLowerCase() + const upperKeyValue = + process.env[upperKey] && process.env[upperKey] !== 'undefined' + ? process.env[upperKey] || '' + : '' + const lowerKeyValue = + process.env[lowerKey] && process.env[lowerKey] !== 'undefined' + ? process.env[lowerKey] || '' + : '' + + proxyUrl = upperKeyValue || lowerKeyValue || '' + }) + + return proxyUrl + } +} diff --git a/src/extension/providers/tmp-file-action.ts b/src/extension/registers/tmp-file-action.register.ts similarity index 67% rename from src/extension/providers/tmp-file-action.ts rename to src/extension/registers/tmp-file-action.register.ts index 7156fd9..dd18821 100644 --- a/src/extension/providers/tmp-file-action.ts +++ b/src/extension/registers/tmp-file-action.register.ts @@ -1,17 +1,17 @@ -import { - getOriginalFileUri, - isTmpFileUri -} from '@extension/file-utils/create-tmp-file' +import { getOriginalFileUri } from '@extension/file-utils/tmp-file/get-original-file-uri' +import { isTmpFileUri } from '@extension/file-utils/tmp-file/is-tmp-file-uri' import { t } from '@extension/i18n' import * as vscode from 'vscode' -export class TmpFileActionCodeLensProvider implements vscode.CodeLensProvider { +import { BaseRegister } from './base.register' + +class TmpFileActionCodeLensProvider implements vscode.CodeLensProvider { private codeLenses: vscode.CodeLens[] = [] private _onDidChangeCodeLenses: vscode.EventEmitter = new vscode.EventEmitter() - public readonly onDidChangeCodeLenses: vscode.Event = + readonly onDidChangeCodeLenses: vscode.Event = this._onDidChangeCodeLenses.event constructor() { @@ -20,7 +20,7 @@ export class TmpFileActionCodeLensProvider implements vscode.CodeLensProvider { }) } - public provideCodeLenses( + provideCodeLenses( document: vscode.TextDocument ): vscode.CodeLens[] | Thenable { if (!isTmpFileUri(document.uri)) return [] @@ -63,3 +63,17 @@ export class TmpFileActionCodeLensProvider implements vscode.CodeLensProvider { }) } } + +export class TmpFileActionRegister extends BaseRegister { + register(): void { + const tmpFileActionCodeLensProvider = new TmpFileActionCodeLensProvider() + + // register CodeLensProvider, only for file name contains .aide + this.context.subscriptions.push( + vscode.languages.registerCodeLensProvider( + { scheme: '*', pattern: '**/*.aide*' }, + tmpFileActionCodeLensProvider + ) + ) + } +} diff --git a/src/extension/registers/webview.register.ts b/src/extension/registers/webview.register.ts new file mode 100644 index 0000000..8588373 --- /dev/null +++ b/src/extension/registers/webview.register.ts @@ -0,0 +1,111 @@ +import { setupHtml } from '@extension/utils' +import { setupWebviewAPIManager } from '@extension/webview-api' +import * as vscode from 'vscode' + +import { BaseRegister } from './base.register' + +export class AideWebViewProvider { + static readonly viewType = 'aide.webview' + + private webviewPanel: vscode.WebviewPanel | undefined + + private sidebarView: vscode.WebviewView | undefined + + private disposes: vscode.Disposable[] = [] + + constructor( + private readonly extensionUri: vscode.Uri, + private readonly context: vscode.ExtensionContext + ) {} + + async resolveSidebarView(webviewView: vscode.WebviewView) { + this.sidebarView = webviewView + await this.setupWebview(webviewView) + } + + async createOrShowWebviewPanel() { + if (this.webviewPanel) { + this.webviewPanel.reveal(vscode.ViewColumn.Beside) + } else { + this.webviewPanel = vscode.window.createWebviewPanel( + AideWebViewProvider.viewType, + 'AIDE', + vscode.ViewColumn.Beside, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [this.extensionUri] + } + ) + await this.setupWebview(this.webviewPanel) + + this.webviewPanel.onDidDispose( + () => { + this.webviewPanel = undefined + }, + null, + this.context.subscriptions + ) + } + } + + private async setupWebview( + webview: vscode.WebviewView | vscode.WebviewPanel + ) { + this.cleanUp() + + const setupWebviewAPIManagerDispose = await setupWebviewAPIManager( + this.context, + webview + ) + this.disposes.push(setupWebviewAPIManagerDispose) + + if ('options' in webview.webview) { + webview.webview.options = { + enableScripts: true, + localResourceRoots: [this.extensionUri] + } + } + webview.webview.html = this.getHtmlForWebview(webview.webview) + } + + revealSidebar() { + this.sidebarView?.show?.(true) + } + + private getHtmlForWebview(webview: vscode.Webview) { + return setupHtml(webview, this.context) + } + + cleanUp() { + this.disposes.forEach(dispose => dispose.dispose()) + this.disposes = [] + } +} + +export class WebviewRegister extends BaseRegister { + private provider: AideWebViewProvider | undefined + + async register(): Promise { + this.provider = new AideWebViewProvider( + this.context.extensionUri, + this.context + ) + + const disposable = vscode.window.registerWebviewViewProvider( + AideWebViewProvider.viewType, + { + resolveWebviewView: webviewView => + this.provider!.resolveSidebarView(webviewView) + } + ) + this.context.subscriptions.push(disposable) + + this.registerManager.commandManager.registerService( + 'AideWebViewProvider', + this.provider + ) + + this.provider.revealSidebar() + } +} diff --git a/src/extension/utils.ts b/src/extension/utils.ts index cbd0d2f..28d2f71 100644 --- a/src/extension/utils.ts +++ b/src/extension/utils.ts @@ -1,4 +1,3 @@ -import type { SaveImgMsgData } from '@shared/types/msg' import * as vscode from 'vscode' import { @@ -7,9 +6,16 @@ import { languageIdExts, languageIds } from './constants' +import { getContext } from './context' import { t } from './i18n' import { logger } from './logger' +export const getIsDev = () => { + const context = getContext() + if (!context) return false + return context.extensionMode !== vscode.ExtensionMode.Production +} + export const getOrCreateTerminal = async ( name: string, cwd: string @@ -42,21 +48,43 @@ export const getErrorMsg = (err: any): string => { return 'An error occurred' } -export const commandErrorCatcher = any>( +export const runWithCathError = async any>( + fn: T, + logLabel = 'runWithCathError' +): Promise | void> => { + try { + return await fn() + } catch (err) { + const errMsg = getErrorMsg(err) + // skip abort error + if (['AbortError', 'Aborted'].includes(errMsg)) return + + logger.warn(logLabel, err) + vscode.window.showErrorMessage(getErrorMsg(err)) + } +} + +// export const commandErrorCatcher = any>( +// commandFn: T +// ): T => +// (async (...args: any[]) => { +// try { +// return await commandFn(...args) +// } catch (err) { +// const errMsg = getErrorMsg(err) +// // skip abort error +// if (['AbortError', 'Aborted'].includes(errMsg)) return + +// logger.warn('commandErrorCatcher', err) +// vscode.window.showErrorMessage(getErrorMsg(err)) +// } +// }) as T + +export const commandWithCatcher = any>( commandFn: T ): T => - (async (...args: any[]) => { - try { - return await commandFn(...args) - } catch (err) { - const errMsg = getErrorMsg(err) - // skip abort error - if (['AbortError', 'Aborted'].includes(errMsg)) return - - logger.warn('commandErrorCatcher', err) - vscode.window.showErrorMessage(getErrorMsg(err)) - } - }) as T + (async (...args: any[]) => + await runWithCathError(() => commandFn(...args))) as T export const getLanguageIdExt = (languageIdORExt: string): string => { if (languageIdExts.includes(languageIdORExt)) return languageIdORExt @@ -79,49 +107,36 @@ export const getLanguageId = (languageIdORExt: string): string => { return languageIdORExt } -export const getCurrentWorkspaceFolderEditor = ( +export const getWorkspaceFolder = ( throwErrorWhenNotFound: T = true as T ): T extends true - ? { workspaceFolder: vscode.WorkspaceFolder; activeEditor: vscode.TextEditor } - : { - workspaceFolder: vscode.WorkspaceFolder | undefined - activeEditor: vscode.TextEditor | undefined - } => { + ? vscode.WorkspaceFolder + : vscode.WorkspaceFolder | undefined => { const activeEditor = vscode.window.activeTextEditor - if (!activeEditor) { - if (throwErrorWhenNotFound) throw new Error(t('error.noActiveEditor')) - return { workspaceFolder: undefined, activeEditor: undefined } as any + + if (activeEditor) { + const workspaceFolder = vscode.workspace.getWorkspaceFolder( + activeEditor.document.uri + ) + if (workspaceFolder) return workspaceFolder } - const workspaceFolder = vscode.workspace.getWorkspaceFolder( - activeEditor.document.uri - ) + const workspaceFolder = vscode.workspace.workspaceFolders?.[0] - if (!workspaceFolder) { - if (throwErrorWhenNotFound) throw new Error(t('error.noWorkspace')) - return { workspaceFolder: undefined, activeEditor } as any - } + if (workspaceFolder) return workspaceFolder - return { workspaceFolder, activeEditor } -} + if (throwErrorWhenNotFound) throw new Error(t('error.noWorkspace')) -// export const getActiveEditorContent = async () => { -// const activeEditor = vscode.window.activeTextEditor + return undefined as any +} -// if (!activeEditor) throw new Error(t('error.noActiveEditor')) +export const getActiveEditor = (): vscode.TextEditor => { + const activeEditor = vscode.window.activeTextEditor -// const { selection } = activeEditor -// const isSelection = !selection.isEmpty -// const content = isSelection -// ? activeEditor.document.getText(selection) -// : activeEditor.document.getText() + if (!activeEditor) throw new Error(t('error.noActiveEditor')) -// return { -// activeEditor, -// content, -// isSelection -// } -// } + return activeEditor +} export const formatNumber = (num: number, fixed: number): string => { const numString = num.toFixed(fixed) @@ -225,33 +240,10 @@ export const showQuickPickWithCustomInput = async ( } export const DEV_SERVER = process.env.VITE_DEV_SERVER_URL -export function setupHtml( +export const setupHtml = ( webview: vscode.Webview, context: vscode.ExtensionContext -) { - return DEV_SERVER +) => + DEV_SERVER ? __getWebviewHtml__(DEV_SERVER) : __getWebviewHtml__(webview, context) -} - -let lastSaveImageDir: string | undefined -export async function saveImage(data: SaveImgMsgData) { - const { workspaceFolder } = await getCurrentWorkspaceFolderEditor() - - if (!lastSaveImageDir) { - lastSaveImageDir = workspaceFolder.uri.fsPath ?? '' - } - - const uri = await vscode.window.showSaveDialog({ - filters: { Images: [data.format] }, - defaultUri: vscode.Uri.file(`${lastSaveImageDir}/${data.fileName}`) - }) - - if (!uri) return - - await vscode.workspace.fs.writeFile( - uri, - Uint8Array.from(atob(data.base64), c => c.charCodeAt(0)) - ) - lastSaveImageDir = uri.fsPath.split('/').slice(0, -1).join('/') -} diff --git a/src/extension/webview-api/api-manager.ts b/src/extension/webview-api/api-manager.ts new file mode 100644 index 0000000..20eb374 --- /dev/null +++ b/src/extension/webview-api/api-manager.ts @@ -0,0 +1,240 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ + +import { logger } from '@extension/logger' +import * as vscode from 'vscode' + +import { + REQUEST_CLEANUP_INTERVAL, + REQUEST_CLEANUP_THRESHOLD, + REQUEST_TIMEOUT +} from './constant' +import type { BaseController } from './controllers/base.controller' +import { + APIError, + APIHandler, + APIMethodMap, + Controller, + type WebviewPanel +} from './types' + +export class APIManager { + private handlers: Record = {} + + private streamHandlers: Record< + string, + (sessionId: string, data: any) => void + > = {} + + private controllers: Record = {} + + private panel: WebviewPanel + + private pendingRequests: Map< + string, + { timestamp: number; reject: (reason?: any) => void } + > = new Map() + + private disposes: vscode.Disposable[] = [] + + constructor( + private context: vscode.ExtensionContext, + panel: WebviewPanel + ) { + this.panel = panel + this.setupMessageListener() + this.startRequestCleaner() + } + + registerController( + ControllerClass: new ( + ...args: ConstructorParameters + ) => BaseController + ) { + const controller = new ControllerClass(this.context, this) + this.controllers[controller.name] = controller + Object.entries(controller.handlers).forEach(([key, handler]) => { + this.handlers[`${controller.name}.${key}`] = handler.bind(controller) + }) + if (controller.streamHandlers) { + Object.entries(controller.streamHandlers).forEach(([key, handler]) => { + this.streamHandlers[`${controller.name}.${key}`] = + handler.bind(controller) + }) + } + } + + private setupMessageListener() { + const onDidReceiveMessageDispose = this.panel.webview.onDidReceiveMessage( + async (message: any) => { + const { id, sessionId, command, params } = message + if (this.pendingRequests.has(id)) { + this.sendErrorToWebview( + id, + sessionId, + 'DUPLICATE_REQUEST', + 'Duplicate request ID' + ) + return + } + + const handler = this.handlers[command] + if (handler) { + const timeoutPromise = this.createTimeout(id, REQUEST_TIMEOUT) + + this.pendingRequests.set(id, { + timestamp: Date.now(), + reject: timeoutPromise.reject + }) + + try { + const result = await Promise.race([ + // @ts-ignore + handler(sessionId, params), + timeoutPromise.promise + ]) + await this.sendResultToWebview(id, sessionId, result) + } catch (error) { + await this.handleError(id, sessionId, command, error) + } finally { + this.pendingRequests.delete(id) + } + } else { + await this.sendErrorToWebview( + id, + sessionId, + 'HANDLER_NOT_FOUND', + `Handler not found: ${command}` + ) + } + } + ) + + this.disposes.push(onDidReceiveMessageDispose) + } + + private createTimeout( + id: string, + ms: number + ): { promise: Promise; reject: (reason?: any) => void } { + let reject: (reason?: any) => void + + const promise = new Promise((_, rej) => { + reject = rej + const timer = setTimeout( + () => rej(new APIError('TIMEOUT', 'Request timed out')), + ms + ) + + this.disposes.push({ + dispose: () => clearTimeout(timer) + }) + }) + return { promise, reject: reject! } + } + + private async sendResultToWebview( + id: string, + sessionId: string, + result: any + ) { + await this.panel.webview.postMessage({ id, sessionId, result }) + } + + private async sendErrorToWebview( + id: string, + sessionId: string, + code: string, + message: string, + details?: any + ) { + await this.panel.webview.postMessage({ + id, + sessionId, + error: { code, message, details } + }) + } + + private async handleError( + id: string, + sessionId: string, + command: string, + error: any + ) { + logger.warn(`Error in handler for ${command}:`, error) + if (error instanceof APIError) { + await this.sendErrorToWebview( + id, + sessionId, + error.code, + error.message, + error.details + ) + } else { + await this.sendErrorToWebview( + id, + sessionId, + 'INTERNAL_ERROR', + 'An unexpected error occurred', + error instanceof Error + ? { message: error.message, stack: error.stack } + : String(error) + ) + } + } + + async sendToWebview(command: string, sessionId: string, data: any) { + const streamHandler = this.streamHandlers[command] + + if (streamHandler) { + streamHandler(sessionId, data) + } + + await this.panel.webview.postMessage({ command, sessionId, data }) + } + + async callHandler( + command: `${string & C}.${string & M}`, + sessionId: string, + params: T[C][M]['params'] + ): Promise { + const handler = this.handlers[command] + + if (handler) { + try { + // @ts-ignore + return (await handler(sessionId, params)) as T[C][M]['result'] + } catch (error) { + logger.warn(`Error in handler ${command}:`, error) + throw error + } + } + + throw new APIError('HANDLER_NOT_FOUND', `Handler not found: ${command}`) + } + + private startRequestCleaner() { + const timer = setInterval(() => { + const now = Date.now() + + for (const [ + id, + { timestamp, reject } + ] of this.pendingRequests.entries()) { + if (now - timestamp > REQUEST_CLEANUP_THRESHOLD) { + reject( + new APIError('TIMEOUT', 'Request timed out and was cleaned up') + ) + this.pendingRequests.delete(id) + } + } + }, REQUEST_CLEANUP_INTERVAL) + + this.disposes.push({ + dispose: () => clearInterval(timer) + }) + } + + cleanUp() { + this.disposes.forEach(dispose => dispose.dispose()) + } +} diff --git a/src/extension/webview-api/chat-context-builder/chat-context-builder.ts b/src/extension/webview-api/chat-context-builder/chat-context-builder.ts new file mode 100644 index 0000000..d075bd0 --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/chat-context-builder.ts @@ -0,0 +1,35 @@ +import { getErrorMsg } from '@extension/utils' + +import { ChatContextBuilderError, PluginError } from './error' +import type { PluginManager } from './plugin-manager' +import type { ChatContext } from './types/chat-context' +import type { LangchainMessageType } from './types/langchain-message' + +export class ChatContextBuilder { + constructor(public pluginManager: PluginManager) {} + + async buildContext( + context: Partial + ): Promise { + try { + let messages: LangchainMessageType[] = [] + + for await (const plugin of this.pluginManager.getAllPlugins()) { + messages = messages.concat( + await plugin.buildContext(context, this.pluginManager) + ) + } + + return messages + } catch (error) { + const errMsg = getErrorMsg(error) + + if (error instanceof PluginError) { + throw error + } else { + // Handle general errors + throw new ChatContextBuilderError(`Failed to build context: ${errMsg}`) + } + } + } +} diff --git a/src/extension/webview-api/chat-context-builder/error.ts b/src/extension/webview-api/chat-context-builder/error.ts new file mode 100644 index 0000000..853c19b --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/error.ts @@ -0,0 +1,13 @@ +export class PluginError extends Error { + constructor(pluginName: string, message: string) { + super(`[${pluginName}] ${message}`) + this.name = 'ChatContextBuilderPluginError' + } +} + +export class ChatContextBuilderError extends Error { + constructor(message: string) { + super(message) + this.name = 'ChatContextBuilderError' + } +} diff --git a/src/extension/webview-api/chat-context-builder/index.ts b/src/extension/webview-api/chat-context-builder/index.ts new file mode 100644 index 0000000..00fd7f5 --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/index.ts @@ -0,0 +1,21 @@ +import { ChatContextBuilder } from './chat-context-builder' +import { PluginManager } from './plugin-manager' +import { CodeChunksPlugin } from './plugins/code-chunks.plugin' +import { ConversationPlugin } from './plugins/conversation.plugin' +import { CurrentFilePlugin } from './plugins/current-file.plugin' +import { ExplicitContextPlugin } from './plugins/explicit-context.plugin' +import { GitPlugin } from './plugins/git.plugin' + +export const createChatContextBuilder = + async (): Promise => { + const pluginManager = new PluginManager() + await pluginManager.registerPlugin(new CurrentFilePlugin()) + await pluginManager.registerPlugin(new ConversationPlugin()) + await pluginManager.registerPlugin(new CodeChunksPlugin()) + await pluginManager.registerPlugin(new ExplicitContextPlugin()) + await pluginManager.registerPlugin(new GitPlugin()) + + const chatContextBuilder = new ChatContextBuilder(pluginManager) + + return chatContextBuilder + } diff --git a/src/extension/webview-api/chat-context-builder/plugin-manager.ts b/src/extension/webview-api/chat-context-builder/plugin-manager.ts new file mode 100644 index 0000000..0e15f0b --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/plugin-manager.ts @@ -0,0 +1,33 @@ +import type { BasePlugin } from './plugins/base.plugin' + +export class PluginManager { + private plugins: Map = new Map() + + async registerPlugin(plugin: BasePlugin): Promise { + await plugin.initialize() + this.plugins.set(plugin.name, plugin) + } + + getPlugin(name: string): BasePlugin | undefined { + return this.plugins.get(name) + } + + getAllPlugins(): BasePlugin[] { + return Array.from(this.plugins.values()) + } + + async cleanupPlugin(name: string): Promise { + const plugin = this.plugins.get(name) + if (plugin) { + await plugin.cleanup() + this.plugins.delete(name) + } + } + + async cleanupAllPlugins(): Promise { + for (const plugin of this.plugins.values()) { + await plugin.cleanup() + } + this.plugins.clear() + } +} diff --git a/src/extension/webview-api/chat-context-builder/plugins/base.plugin.ts b/src/extension/webview-api/chat-context-builder/plugins/base.plugin.ts new file mode 100644 index 0000000..c320dff --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/plugins/base.plugin.ts @@ -0,0 +1,28 @@ +import { getErrorMsg } from '@extension/utils' + +import { PluginError } from '../error' +import type { PluginManager } from '../plugin-manager' +import type { ChatContext } from '../types/chat-context' +import type { LangchainMessageType } from '../types/langchain-message' + +export abstract class BasePlugin { + abstract name: string + + async initialize(): Promise { + // Default implementation + } + + abstract buildContext( + context: Partial, + pluginManager: PluginManager + ): Promise + + protected createError(error: unknown): Error { + const errMsg = getErrorMsg(error) + return new PluginError(this.name, errMsg) + } + + async cleanup(): Promise { + // Default implementation + } +} diff --git a/src/extension/webview-api/chat-context-builder/plugins/code-chunks.plugin.ts b/src/extension/webview-api/chat-context-builder/plugins/code-chunks.plugin.ts new file mode 100644 index 0000000..bab91e9 --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/plugins/code-chunks.plugin.ts @@ -0,0 +1,35 @@ +import { HumanMessage, SystemMessage } from '@langchain/core/messages' + +import type { ChatContext } from '../types/chat-context' +import type { LangchainMessageType } from '../types/langchain-message' +import { BasePlugin } from './base.plugin' + +export class CodeChunksPlugin extends BasePlugin { + name = 'CodeChunks' + + async buildContext( + context: Partial + ): Promise { + const conversation = context.conversation || [] + + const codeChunks = conversation + .filter(msg => msg.type === 'human') + .flatMap(msg => msg.attachedCodeChunks || []) + + if (codeChunks.length === 0) return [] + + const chunksContent = codeChunks + .map( + chunk => + `\`\`\`${chunk.languageIdentifier}:${chunk.relativeWorkspacePath}\n${chunk.lines.join( + '\n' + )}\n\`\`\`` + ) + .join('\n\n') + + return [ + new SystemMessage('Relevant code chunks:'), + new HumanMessage(chunksContent) + ] + } +} diff --git a/src/extension/webview-api/chat-context-builder/plugins/conversation.plugin.ts b/src/extension/webview-api/chat-context-builder/plugins/conversation.plugin.ts new file mode 100644 index 0000000..50ad1ce --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/plugins/conversation.plugin.ts @@ -0,0 +1,22 @@ +import { AIMessage, HumanMessage } from '@langchain/core/messages' + +import type { ChatContext } from '../types/chat-context' +import type { LangchainMessageType } from '../types/langchain-message' +import { BasePlugin } from './base.plugin' + +export class ConversationPlugin extends BasePlugin { + name = 'Conversation' + + async buildContext( + context: Partial + ): Promise { + if (!context.conversation || context.conversation.length === 0) return [] + + return context.conversation.map(msg => { + if (msg.type === 'human') { + return new HumanMessage(msg.text) + } + return new AIMessage(msg.text) + }) + } +} diff --git a/src/extension/webview-api/chat-context-builder/plugins/current-file.plugin.ts b/src/extension/webview-api/chat-context-builder/plugins/current-file.plugin.ts new file mode 100644 index 0000000..483c639 --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/plugins/current-file.plugin.ts @@ -0,0 +1,14 @@ +import type { ChatContext } from '../types/chat-context' +import type { LangchainMessageType } from '../types/langchain-message' +import { BasePlugin } from './base.plugin' + +export class CurrentFilePlugin extends BasePlugin { + name = 'CurrentFile' + + async buildContext( + // eslint-disable-next-line unused-imports/no-unused-vars + context: Partial + ): Promise { + return [] + } +} diff --git a/src/extension/webview-api/chat-context-builder/plugins/explicit-context.plugin.ts b/src/extension/webview-api/chat-context-builder/plugins/explicit-context.plugin.ts new file mode 100644 index 0000000..8f51a43 --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/plugins/explicit-context.plugin.ts @@ -0,0 +1,17 @@ +import { SystemMessage } from '@langchain/core/messages' + +import type { ChatContext } from '../types/chat-context' +import type { LangchainMessageType } from '../types/langchain-message' +import { BasePlugin } from './base.plugin' + +export class ExplicitContextPlugin extends BasePlugin { + name = 'ExplicitContext' + + async buildContext( + context: Partial + ): Promise { + if (!context.explicitContext?.context) return [] + + return [new SystemMessage(context.explicitContext.context)] + } +} diff --git a/src/extension/webview-api/chat-context-builder/plugins/git.plugin.ts b/src/extension/webview-api/chat-context-builder/plugins/git.plugin.ts new file mode 100644 index 0000000..74bb5e6 --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/plugins/git.plugin.ts @@ -0,0 +1,64 @@ +import { SystemMessage } from '@langchain/core/messages' + +import type { ChatContext } from '../types/chat-context' +import type { Message } from '../types/chat-context/message' +import type { LangchainMessageType } from '../types/langchain-message' +import { BasePlugin } from './base.plugin' + +export class GitPlugin extends BasePlugin { + name = 'Git' + + async buildContext( + context: Partial + ): Promise { + const conversation = context.conversation || [] + const gitInfo: string[] = [] + + this.addCommits(conversation, gitInfo) + this.addPullRequests(conversation, gitInfo) + this.addGitDiffs(conversation, gitInfo) + + if (gitInfo.length === 0) return [] + + return [new SystemMessage(gitInfo.join('\n'))] + } + + private addCommits(conversation: Message[], gitInfo: string[]) { + const commits = this.extractFromConversation(conversation, 'commits') + if (commits.length > 0) { + gitInfo.push('Relevant commits:') + commits.forEach(commit => { + gitInfo.push(`- ${JSON.stringify(commit)}`) + }) + } + } + + private addPullRequests(conversation: Message[], gitInfo: string[]) { + const prs = this.extractFromConversation(conversation, 'pullRequests') + if (prs.length > 0) { + gitInfo.push('Relevant pull requests:') + prs.forEach(pr => { + gitInfo.push(`- ${JSON.stringify(pr)}`) + }) + } + } + + private addGitDiffs(conversation: Message[], gitInfo: string[]) { + const diffs = this.extractFromConversation(conversation, 'gitDiffs') + if (diffs.length > 0) { + gitInfo.push('Git diffs:') + diffs.forEach(diff => { + gitInfo.push(`- ${JSON.stringify(diff)}`) + }) + } + } + + private extractFromConversation( + conversation: Message[], + key: K + ): Message[K][] { + return conversation + .filter(msg => msg.type === 'human') + .flatMap(msg => msg[key]) + } +} diff --git a/src/extension/webview-api/chat-context-builder/types/chat-context/code-block.ts b/src/extension/webview-api/chat-context-builder/types/chat-context/code-block.ts new file mode 100644 index 0000000..7d89e2c --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/types/chat-context/code-block.ts @@ -0,0 +1,6 @@ +import type { FileUri } from './file-uri' + +export interface CodeBlock { + uri: FileUri + version: number +} diff --git a/src/extension/webview-api/chat-context-builder/types/chat-context/file-uri.ts b/src/extension/webview-api/chat-context-builder/types/chat-context/file-uri.ts new file mode 100644 index 0000000..1bfff8c --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/types/chat-context/file-uri.ts @@ -0,0 +1,21 @@ +export interface FileUri { + /** + * @example '/Users/xxx/Documents/codes/aide/src/extension/file-utils/create-should-ignore.ts' + */ + fsPath: string + + /** + * @example 'file:///Users/xxx/Documents/codes/aide/src/extension/file-utils/create-should-ignore.ts' + */ + external: string + + /** + * @example '/Users/xxx/Documents/codes/aide/src/extension/file-utils/create-should-ignore.ts' + */ + path: string + + /** + * @example 'file' + */ + scheme: string +} diff --git a/src/extension/webview-api/chat-context-builder/types/chat-context/index.ts b/src/extension/webview-api/chat-context-builder/types/chat-context/index.ts new file mode 100644 index 0000000..c1a342e --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/types/chat-context/index.ts @@ -0,0 +1,120 @@ +import type { CodeBlock } from './code-block' +import type { FileUri } from './file-uri' +import type { Message } from './message' +import type { RichText } from './rich-text' + +export interface IFileContext { + focusedFiles: { + uri: FileUri + fileName: string + }[] + suggestedFiles: any[] + newlyCreatedFiles: { + uri: FileUri + }[] + newlyCreatedFolders: any[] + deleteFileSuggestions: any[] + isReadingLongFile: boolean + hasAddedFiles: boolean + codeBlockData: Record< + string, + { + /** + * @example 0 + */ + version: number + + /** + * @example { + * fsPath: + * '/Users/xxx/Documents/codes/aide/src/extension/file-utils/create-should-ignore.ts', + * external: + * 'file:///Users/xxx/Documents/codes/aide/src/extension/file-utils/create-should-ignore.ts', + * path: '/Users/xxx/Documents/codes/aide/src/extension/file-utils/create-should-ignore.ts', + * scheme: 'file' + * } + */ + predictedUri: FileUri + + /** + * @example { + * fsPath: + * '/Users/xxx/Documents/codes/aide/src/extension/file-utils/create-should-ignore.ts', + * external: + * 'file:///Users/xxx/Documents/codes/aide/src/extension/file-utils/create-should-ignore.ts', + * path: '/Users/xxx/Documents/codes/aide/src/extension/file-utils/create-should-ignore.ts', + * scheme: 'file' + */ + uri: FileUri + + /** + * @example 'completed' + */ + status: string + + /** + * @example [ + * "export const createShouldIgnore = (file: string) => {", + * " return file.startsWith('.') || file.startsWith('node_modules')", + * "}" + * ] + */ + newModelLines: string[] + + /** + * @example [" "] + */ + originalModelLines: string[] + }[] + > +} + +export interface IConversationContext { + conversation: Message[] + references: { + selections: any[] + fileSelections: any[] + folderSelections: any[] + useWeb: boolean + useCodebase: boolean + } + codeSelections: any[] + richText?: RichText + plainText: string +} + +export interface ISettingsContext { + modelName: string + useFastApply: boolean + fastApplyModelName?: string + useChunkSpeculationForLongFiles: boolean + explicitContext: { + /** + * Explicit context provided. + * @example '总是说中文' + */ + context: string + } + clickedCodeBlockContents: string + allowLongFileScan: boolean +} + +export interface IBaseContext { + tabs: + | { + type: 'composer' + } + | { + type: 'code' + codeBlocks: CodeBlock[] + }[] + + createdAt: number + lastUpdatedAt: number +} + +export interface ChatContext + extends IBaseContext, + IFileContext, + IConversationContext, + ISettingsContext {} diff --git a/src/extension/webview-api/chat-context-builder/types/chat-context/message/assistant-suggestions.ts b/src/extension/webview-api/chat-context-builder/types/chat-context/message/assistant-suggestions.ts new file mode 100644 index 0000000..6597271 --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/types/chat-context/message/assistant-suggestions.ts @@ -0,0 +1,3 @@ +export interface AssistantSuggestions { + assistantSuggestedDiffs: any[] +} diff --git a/src/extension/webview-api/chat-context-builder/types/chat-context/message/attachment-info.ts b/src/extension/webview-api/chat-context-builder/types/chat-context/message/attachment-info.ts new file mode 100644 index 0000000..5347f1b --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/types/chat-context/message/attachment-info.ts @@ -0,0 +1,8 @@ +export interface AttachmentInfo { + /** + * @example ['/src/webview/types'] + */ + attachedFolders: string[] + attachedFoldersNew: string[] + images: string[] +} diff --git a/src/extension/webview-api/chat-context-builder/types/chat-context/message/basic-message.ts b/src/extension/webview-api/chat-context-builder/types/chat-context/message/basic-message.ts new file mode 100644 index 0000000..204fdfc --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/types/chat-context/message/basic-message.ts @@ -0,0 +1,23 @@ +import type { MessageType } from '@langchain/core/messages' + +import type { RichText } from '../rich-text' + +export interface BasicMessage { + /** + * @example + * '@index.ts @utils.ts @absolutePath @Web @vscode @ci: fix ci @types 优化一下' + */ + text: string + + richText?: RichText + + /** + * @example 'human' + */ + type: MessageType + + /** + * @example 'dd62428a-94d7-4cbe-a7fb-2b5a2510afg' + */ + bubbleId: string +} diff --git a/src/extension/webview-api/chat-context-builder/types/chat-context/message/code-related-info.ts b/src/extension/webview-api/chat-context-builder/types/chat-context/message/code-related-info.ts new file mode 100644 index 0000000..06771ed --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/types/chat-context/message/code-related-info.ts @@ -0,0 +1,100 @@ +import type { CodeBlock } from '../code-block' + +export interface CodeChunk { + /** + * Relative path of the code chunk in the workspace. + * @example 'src/extension/index.ts' + */ + relativeWorkspacePath: string + + /** + * Start line number of the code chunk. + * @example 1 + */ + startLineNumber: number + + /** + * Lines of code in the chunk. + * @example [ + * "export const sleep = (ms: number) =>', + * " new Promise(resolve => setTimeout(resolve, ms))", + * ] + */ + lines: string[] + + /** + * Strategy used for summarizing the code chunk. + * @example 'SUMMARIZATION_STRATEGY_NONE_UNSPECIFIED' + */ + summarizationStrategy: string + + /** + * Language identifier of the code chunk. + * @example 'typescript' + */ + languageIdentifier: string +} + +export interface CodebaseContextChunk { + /** + * @example 'src/webview/types/vscode.d.ts' + */ + relativeWorkspacePath: string + + range: { + startPosition: { + /** + * @example 1 + */ + line: number + + /** + * @example 1 + */ + column: number + } + endPosition: { + /** + * @example 10 + */ + line: number + + /** + * @example 2 + */ + column: number + } + } + + /** + * @example "import type { WebviewToExtensionsMsg } from '@shared/types'\n\ndeclare global {\n interface Window {\n acquireVsCodeApi(): {\n postMessage(msg: WebviewToExtensionsMsg): void\n setState(state: any): void\n getState(): any\n }\n vscode: ReturnType\n }\n}" + */ + contents: string + detailedLines: { + /** + * @example 'declare global {' + */ + text: string + + /** + * @example 3 + */ + lineNumber: number + + /** + * @example false + */ + isSignature: boolean + }[] +} + +export interface CodeRelatedInfo { + attachedCodeChunks: CodeChunk[] + codebaseContextChunks: CodebaseContextChunk[] + + /** + * modify files by ai + */ + codeBlocks?: CodeBlock[] + userModificationsToSuggestedCodeBlocks: any[] +} diff --git a/src/extension/webview-api/chat-context-builder/types/chat-context/message/git-related-info.ts b/src/extension/webview-api/chat-context-builder/types/chat-context/message/git-related-info.ts new file mode 100644 index 0000000..d8358a1 --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/types/chat-context/message/git-related-info.ts @@ -0,0 +1,54 @@ +export interface GitCommit { + /** + * @example '0bc7f06aa2930c2755c751615cfb2331de41ddb1' + */ + sha: string + + /** + * @example 'ci: fix ci' + */ + message: string + + /** + * @example '' + */ + description: string + diff: { + /** + * @example '.github/workflows/ci.yml' + */ + from: string + + /** + * @example '.github/workflows/ci.yml' + */ + to: string + chunks: { + /** + * @example '@@ -1,6 +1,6 @@ importers:' + */ + content: string + + /** + * @example [ + * 'name: CI', + * 'on:', + * ' push:', + * '+ branches:', + * '+ - main', + * '- branches:', + * '- - master', + * ] + */ + lines: string[] + }[] + }[] + author: string + date: string +} + +export interface GitRelatedInfo { + commits: GitCommit[] + pullRequests: any[] + gitDiffs: any[] +} diff --git a/src/extension/webview-api/chat-context-builder/types/chat-context/message/index.ts b/src/extension/webview-api/chat-context-builder/types/chat-context/message/index.ts new file mode 100644 index 0000000..70340a7 --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/types/chat-context/message/index.ts @@ -0,0 +1,14 @@ +import type { AssistantSuggestions } from './assistant-suggestions' +import type { AttachmentInfo } from './attachment-info' +import type { BasicMessage } from './basic-message' +import type { CodeRelatedInfo } from './code-related-info' +import type { GitRelatedInfo } from './git-related-info' +import type { InterpreterInfo } from './interpreter-info' + +export interface Message + extends BasicMessage, + CodeRelatedInfo, + GitRelatedInfo, + AssistantSuggestions, + InterpreterInfo, + AttachmentInfo {} diff --git a/src/extension/webview-api/chat-context-builder/types/chat-context/message/interpreter-info.ts b/src/extension/webview-api/chat-context-builder/types/chat-context/message/interpreter-info.ts new file mode 100644 index 0000000..46d87dd --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/types/chat-context/message/interpreter-info.ts @@ -0,0 +1,3 @@ +export interface InterpreterInfo { + interpreterResults: any[] +} diff --git a/src/extension/webview-api/chat-context-builder/types/chat-context/rich-text/index.ts b/src/extension/webview-api/chat-context-builder/types/chat-context/rich-text/index.ts new file mode 100644 index 0000000..3c71e5b --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/types/chat-context/rich-text/index.ts @@ -0,0 +1,35 @@ +import type { Mention } from './mention' + +export interface RichTextTextNode { + detail: number + format: number + mode: 'normal' | 'segmented' + style: string + text: string + type: 'text' + version: number +} + +export type RichTextContentNode = Mention | RichTextTextNode + +export interface RichTextParagraph { + children: RichTextContentNode[] + direction: 'ltr' + format: string + indent: number + type: 'paragraph' + version: number +} + +export interface RichTextRootNode { + children: RichTextParagraph[] + direction: 'ltr' + format: string + indent: number + type: 'root' + version: number +} + +export interface RichText { + root: RichTextRootNode +} diff --git a/src/extension/webview-api/chat-context-builder/types/chat-context/rich-text/mention-type.ts b/src/extension/webview-api/chat-context-builder/types/chat-context/rich-text/mention-type.ts new file mode 100644 index 0000000..1a496c2 --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/types/chat-context/rich-text/mention-type.ts @@ -0,0 +1,10 @@ +export enum MentionType { + File = 'file', + Folder = 'folder', + Code = 'code', + Web = 'web', + Doc = 'doc', + Diffs = 'diffs', + Commits = 'commits', + Codebase = 'codebase' +} diff --git a/src/extension/webview-api/chat-context-builder/types/chat-context/rich-text/mention.ts b/src/extension/webview-api/chat-context-builder/types/chat-context/rich-text/mention.ts new file mode 100644 index 0000000..03410f4 --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/types/chat-context/rich-text/mention.ts @@ -0,0 +1,14 @@ +import type { Metadata } from './metadata' + +export interface Mention { + detail: number + format: number + mode: 'segmented' + style: string + text: string + type: 'mention' + version: number + mentionName: string + storedKey: string + metadata: Metadata +} diff --git a/src/extension/webview-api/chat-context-builder/types/chat-context/rich-text/metadata.ts b/src/extension/webview-api/chat-context-builder/types/chat-context/rich-text/metadata.ts new file mode 100644 index 0000000..8e42cf0 --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/types/chat-context/rich-text/metadata.ts @@ -0,0 +1,24 @@ +import type { MentionType } from './mention-type' +import type { SelectionType } from './selection-type' + +export interface Metadata { + selection: { + type: SelectionType + selectionWithoutUuid?: any // This could be further typed based on specific selection types + } + selectedOption: { + key: string + type: MentionType + score: number + name: string + picture: Record + secondaryText?: string + selectionPrecursor?: any + docSelection?: { + docId: string + name: string + url: string + } + diff?: string + } +} diff --git a/src/extension/webview-api/chat-context-builder/types/chat-context/rich-text/selection-type.ts b/src/extension/webview-api/chat-context-builder/types/chat-context/rich-text/selection-type.ts new file mode 100644 index 0000000..2df6b50 --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/types/chat-context/rich-text/selection-type.ts @@ -0,0 +1,8 @@ +export enum SelectionType { + None = 0, + CodeSelection = 1, + FileSelection = 2, + // Add other selection types as needed + FolderSelection = 5, + DocSelection = 6 +} diff --git a/src/extension/webview-api/chat-context-builder/types/langchain-message.ts b/src/extension/webview-api/chat-context-builder/types/langchain-message.ts new file mode 100644 index 0000000..095fae1 --- /dev/null +++ b/src/extension/webview-api/chat-context-builder/types/langchain-message.ts @@ -0,0 +1,7 @@ +import { + AIMessage, + HumanMessage, + SystemMessage +} from '@langchain/core/messages' + +export type LangchainMessageType = HumanMessage | SystemMessage | AIMessage diff --git a/src/extension/webview-api/constant.ts b/src/extension/webview-api/constant.ts new file mode 100644 index 0000000..de2875e --- /dev/null +++ b/src/extension/webview-api/constant.ts @@ -0,0 +1,3 @@ +export const REQUEST_TIMEOUT = 10 * 1000 +export const REQUEST_CLEANUP_THRESHOLD = 60 * 1000 +export const REQUEST_CLEANUP_INTERVAL = 30 * 1000 diff --git a/src/extension/webview-api/controllers/base.controller.ts b/src/extension/webview-api/controllers/base.controller.ts new file mode 100644 index 0000000..2b61489 --- /dev/null +++ b/src/extension/webview-api/controllers/base.controller.ts @@ -0,0 +1,50 @@ +import { logger } from '@extension/logger' +import * as vscode from 'vscode' + +import { APIManager } from '../api-manager' +import { + APIError, + APIMethodMap, + Controller, + type ControllerHandlers, + type ControllerStreamHandlers +} from '../types' + +export abstract class BaseController implements Controller { + abstract name: string + + abstract handlers: ControllerHandlers + + streamHandlers?: ControllerStreamHandlers + + constructor( + protected context: vscode.ExtensionContext, + protected apiManager: APIManager + ) {} + + protected async safeCall< + C extends keyof APIMethodMap, + M extends keyof APIMethodMap[C] + >( + command: `${string & C}.${string & M}`, + sessionId: string, + params: APIMethodMap[C][M]['params'] + ): Promise { + try { + return await this.apiManager.callHandler(command, sessionId, params) + } catch (error) { + logger.warn(`Error calling ${command}:`, error) + + if (error instanceof APIError) { + throw error + } + throw new APIError( + 'INTERNAL_ERROR', + `Error calling ${command}`, + error instanceof Error + ? { message: error.message, stack: error.stack } + : String(error) + ) + } + } +} diff --git a/src/extension/webview-api/controllers/chat.controller.ts b/src/extension/webview-api/controllers/chat.controller.ts new file mode 100644 index 0000000..299b1b1 --- /dev/null +++ b/src/extension/webview-api/controllers/chat.controller.ts @@ -0,0 +1,57 @@ +import { logger } from '@extension/logger' + +import { APIError } from '../types' +import { BaseController } from './base.controller' + +export const fetchChatStream = async ( + sessionId: string, + text: string +): Promise> => { + logger.log(text) + async function* mockStream() { + yield `[Session ${sessionId}] Hello` + yield `[Session ${sessionId}] How` + yield `[Session ${sessionId}] are` + yield `[Session ${sessionId}] you?` + } + return mockStream() +} + +export class ChatController extends BaseController { + name = 'chat' as const + + handlers = { + startChat: async (sessionId: string, params: { text: string }) => { + try { + const stream = await fetchChatStream(sessionId, params.text) + for await (const chunk of stream) { + this.apiManager.sendToWebview( + `${this.name}.streamUpdate`, + sessionId, + chunk + ) + } + // await this.safeCall('file.logChat', sessionId, { message: params.text }) + return 'Chat completed' + } catch (error) { + logger.warn(`Error in startChat for session ${sessionId}:`, error) + if (error instanceof APIError) { + throw error + } + throw new APIError( + 'CHAT_ERROR', + 'An error occurred during the chat', + error instanceof Error + ? { message: error.message, stack: error.stack } + : String(error) + ) + } + } + } + + streamHandlers = { + streamUpdate: (sessionId: string, data: string) => { + logger.log(`Stream update for session ${sessionId}:`, data) + } + } +} diff --git a/src/extension/webview-api/controllers/file.controller.ts b/src/extension/webview-api/controllers/file.controller.ts new file mode 100644 index 0000000..411dacc --- /dev/null +++ b/src/extension/webview-api/controllers/file.controller.ts @@ -0,0 +1,45 @@ +import * as fs from 'fs/promises' +import { logger } from '@extension/logger' + +import { APIError } from '../types' +import { BaseController } from './base.controller' + +export class FileController extends BaseController { + name = 'file' as const + + handlers = { + readFile: async (sessionId: string, params: { path: string }) => { + try { + const content = await fs.readFile(params.path, 'utf-8') + return content + } catch (error) { + logger.warn(`Error reading file for session ${sessionId}:`, error) + throw new APIError( + 'FILE_READ_ERROR', + 'Failed to read file', + error instanceof Error + ? { message: error.message, stack: error.stack } + : String(error) + ) + } + }, + logChat: async (sessionId: string, params: { message: string }) => { + try { + await fs.appendFile( + 'chat.log', + `[Session ${sessionId}] ${params.message}\n` + ) + return 'Logged' + } catch (error) { + logger.warn(`Error logging chat for session ${sessionId}:`, error) + throw new APIError( + 'LOG_ERROR', + 'Failed to log chat', + error instanceof Error + ? { message: error.message, stack: error.stack } + : String(error) + ) + } + } + } +} diff --git a/src/extension/webview-api/index.ts b/src/extension/webview-api/index.ts new file mode 100644 index 0000000..9995e76 --- /dev/null +++ b/src/extension/webview-api/index.ts @@ -0,0 +1,35 @@ +import * as vscode from 'vscode' + +import { APIManager } from './api-manager' +import { ChatController } from './controllers/chat.controller' +import { FileController } from './controllers/file.controller' +import type { APIMethodMap, WebviewPanel } from './types' + +type Mutable = { -readonly [P in keyof T]: T[P] } + +type InstanceTypeOfArray any>> = { + [K in keyof T]: T[K] extends new (...args: any) => infer R ? R : never +} + +const controllerConstructors = [ChatController, FileController] as const + +export type Controllers = Mutable< + InstanceTypeOfArray +> + +export const setupWebviewAPIManager = ( + context: vscode.ExtensionContext, + panel: WebviewPanel +): vscode.Disposable => { + const apiManager = new APIManager(context, panel) + + controllerConstructors.forEach(Controller => { + apiManager.registerController(Controller) + }) + + return { + dispose: () => { + apiManager.cleanUp() + } + } +} diff --git a/src/extension/webview-api/prompts/chat.ts b/src/extension/webview-api/prompts/chat.ts new file mode 100644 index 0000000..b85e0bc --- /dev/null +++ b/src/extension/webview-api/prompts/chat.ts @@ -0,0 +1,145 @@ +export const chatWithCodebaseSystemPrompt = ` +You are an intelligent programmer, powered by GPT-4. You are happy to help answer any questions that the user has (usually they will be about coding). You will be given the context of the code in their file(s) and potentially relevant blocks of code. + +1. Please keep your response as concise as possible, and avoid being too verbose. + +2. Do not lie or make up facts. + +3. If a user messages you in a foreign language, please respond in that language. + +4. Format your response in markdown. + +5. When referencing code blocks in your answer, keep the following guidelines in mind: + + a. Never include line numbers in the output code. + + b. When outputting new code blocks, please specify the language ID after the initial backticks: +\`\`\`python +{{ code }} +\`\`\` + + c. When outputting code blocks for an existing file, include the file path after the initial backticks: +\`\`\`python:src/backend/main.py +{{ code }} +\`\`\` + + d. When referencing a code block the user gives you, only reference the start and end line numbers of the relevant code: +\`\`\`typescript:app/components/Todo.tsx +startLine: 2 +endLine: 30 +\`\`\` +` + +export const chatUserInstructionPrompt = ` +Please also follow these instructions in all of your responses if relevant to my query. No need to acknowledge these instructions directly in your response. + +总是说中文 + +` + +export const chatWithCodebaseFileContextPrompt = ` +# Inputs + +## Current File +Here is the file I'm looking at. It might be truncated from above and below and, if so, is centered around my cursor. +\`\`\`json:package.nls.en.json +当前文件内容 +\`\`\` + +## Potentially Relevant Code Snippets from the current Codebase +\`\`\`json:package.nls.zh-cn.json +相关文件内容 A +\`\`\` + + +\`\`\`json:package.json +相关文件内容 B +\`\`\` + + + + +------- + + + +------- + + + +` + +export const chatWithCodebaseUserPrompt = ` +优化一些翻译问题 + +If you need to reference any of the code blocks I gave you, only output the start and end line numbers. For example: +\`\`\`typescript:app/components/Todo.tsx +startLine: 200 +endLine: 310 +\`\`\` + +If you are writing code, do not include the "line_number|" before each line of code. +` + +export const chatWithFilesSystemPrompt = ` +You are an intelligent programmer, powered by GPT-4o. You are happy to help answer any questions that the user has (usually they will be about coding). + +1. Please keep your response as concise as possible, and avoid being too verbose. + +2. When the user is asking for edits to their code, please output a simplified version of the code block that highlights the changes necessary and adds comments to indicate where unchanged code has been skipped. For example: +\`\`\`file_path +// ... existing code ... +{{ edit_1 }} +// ... existing code ... +{{ edit_2 }} +// ... existing code ... +\`\`\` +The user can see the entire file, so they prefer to only read the updates to the code. Often this will mean that the start/end of the file will be skipped, but that's okay! Rewrite the entire file only if specifically requested. Always provide a brief explanation of the updates, unless the user specifically requests only the code. + +3. Do not lie or make up facts. + +4. If a user messages you in a foreign language, please respond in that language. + +5. Format your response in markdown. + +6. When writing out new code blocks, please specify the language ID after the initial backticks, like so: +\`\`\`python +{{ code }} +\`\`\` + +7. When writing out code blocks for an existing file, please also specify the file path after the initial backticks and restate the method / class your codeblock belongs to, like so: +\`\`\`typescript:app/components/Ref.tsx +function AIChatHistory() { + ... + {{ code }} + ... +} +\`\`\` +` + +export const chatWithFilesCursorFileContextPrompt = ` +# Inputs + +## Current File +Here is the file I'm looking at. It might be truncated from above and below and, if so, is centered around my cursor. +\`\`\`src/extension/auto-task/auto-task.ts +当前文件内容 +\`\`\` +` + +export const chatReplyAIPartPrompt = ` +# Inputs + +Please refer your answer to the following quote(s): +
+这里是 AI 的部分回复 +1. **代码格式化**:统一了代码的格式,使其更易读。 +
+` + +export const chatWithFilesSelectedFileContextPrompt = ` +\`\`\`typescript:src/extension/auto-task/types.ts +选中文件内容 +\`\`\` +优化这个屎山代码 @types.ts +` diff --git a/src/extension/webview-api/prompts/completions.ts b/src/extension/webview-api/prompts/completions.ts new file mode 100644 index 0000000..590fea5 --- /dev/null +++ b/src/extension/webview-api/prompts/completions.ts @@ -0,0 +1,18 @@ +// { +// "prompt": "// Path: src/extension/auto-task/utils.ts\nimport { MAX_RETRIES } from './constants'\n\nexport async function retryOperation(\n operation: () => Promise,\n maxRetries: number = MAX_RETRIES\n): Promise {\n for (let i = 0; i < maxRetries; i++) {\n try {\n return await operation()\n } catch (error) {\n if (i === maxRetries - 1) throw error\n await new Promise(resolve => setTimeout(resolve, 1000 * 2 ** i))\n }\n }\n throw new Error('Max retries reached')\n}\n\nexport const retry = (operation: () => Promise) => retryOperation(operation)\n", +// "suffix": "", +// "max_tokens": 500, +// "temperature": 0.2, +// "top_p": 1, +// "n": 3, +// "stop": ["\n\n\n", "\n```"], +// "nwo": "nicepkg/aide", +// "stream": true, +// "extra": { +// "language": "typescript", +// "next_indent": 0, +// "trim_by_indentation": true, +// "prompt_tokens": 151, +// "suffix_tokens": 0 +// } +// } diff --git a/src/extension/webview-api/prompts/composer.ts b/src/extension/webview-api/prompts/composer.ts new file mode 100644 index 0000000..dcd3934 --- /dev/null +++ b/src/extension/webview-api/prompts/composer.ts @@ -0,0 +1,60 @@ +export const composerContextSystemPrompt = ` +You are an intelligent programmer, powered by GPT-4o. You are happy to help answer any questions that the user has (usually they will be about coding). + +1. Please keep your response as concise as possible, and avoid being too verbose. + +2. When the user is asking for edits to their code, please output a simplified version of the code block that highlights the changes necessary and adds comments to indicate where unchanged code has been skipped. For example: +\`\`\`language:file_path +// ... existing code ... +{{ edit_1 }} +// ... existing code ... +{{ edit_2 }} +// ... existing code ... +\`\`\` +The user can see the entire file, so they prefer to only read the updates to the code. Often this will mean that the start/end of the file will be skipped, but that's okay! Rewrite the entire file only if specifically requested. Always provide a brief explanation of the updates, unless the user specifically requests only the code. +The current file is likely relevant to the edits, even if not specifically @ mentioned in the user's query. + +If you think that any of the imported files will likely need to change, please say so in your response. + +3. Do not lie or make up facts. + +4. If a user messages you in a foreign language, please respond in that language. + +5. Format your response in markdown. + +6. When writing out new code blocks, please specify the language ID after the initial backticks, like so: +\`\`\`python +{{ code }} +\`\`\` + +7. When writing out code blocks for an existing file, please also specify the file path after the initial backticks and restate the method / class the codeblock belongs to, like so: +\`\`\`typescript:app/components/Ref.tsx +function AIChatHistory() { + ... + {{ code }} + ... +} +\`\`\` + +8. For codeblocks used for explanation instead of suggestions, do not reference the file path. + +9. Put code into same codeblocks if they are the same file. + +10. Keep users' comments, unless user specifically requests to modify them. +` + +export const composerContextUserPrompt = ` + +\`\`\`typescript:src/webview/components/AutoTaskUI.tsx +文件 A 的代码 +\`\`\` + + + +\`\`\`typescript:src/webview/App.tsx +文件 B 的代码 +\`\`\` + + +@App.tsx 优化ui +` diff --git a/src/extension/webview-api/prompts/context.ts b/src/extension/webview-api/prompts/context.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/extension/webview-api/types.ts b/src/extension/webview-api/types.ts new file mode 100644 index 0000000..804d9ed --- /dev/null +++ b/src/extension/webview-api/types.ts @@ -0,0 +1,41 @@ +import * as vscode from 'vscode' + +export type WebviewPanel = vscode.WebviewPanel | vscode.WebviewView + +export type APIHandler = ( + this: Controller, + sessionId: string, + params: T +) => Promise + +export type ControllerHandlers = Record +export type ControllerStreamHandlers = Record< + string, + (sessionId: string, data: any) => void +> +export interface Controller { + name: string + handlers: ControllerHandlers + streamHandlers?: ControllerStreamHandlers +} + +export type APIMethodMap = { + [controllerName: string]: { + [methodName: string]: { + params: any + result: any + stream?: any + } + } +} + +export class APIError extends Error { + constructor( + readonly code: string, + message: string, + readonly details?: any + ) { + super(message) + this.name = 'APIError' + } +} diff --git a/src/shared/types/msg.ts b/src/shared/types/msg.ts deleted file mode 100644 index b976ec9..0000000 --- a/src/shared/types/msg.ts +++ /dev/null @@ -1,25 +0,0 @@ -export type SaveImgMsgData = { - fileName: string - format: string - base64: string -} - -export type ExtensionsToWebviewMsg = - | { - type: 'update-code' - data: string - } - | { - type: 'change-theme' - data: 'dark' | 'light' - } - -export type WebviewToExtensionsMsg = - | { - type: 'save-img' - data: SaveImgMsgData - } - | { - type: 'show-settings' - data?: undefined - } diff --git a/src/webview/App.tsx b/src/webview/App.tsx index c638823..a565e79 100644 --- a/src/webview/App.tsx +++ b/src/webview/App.tsx @@ -1,12 +1,15 @@ -import { useState } from 'react' import { VSCodeButton, VSCodeTextField } from '@vscode/webview-ui-toolkit/react' import { vscode } from './helpers/vscode' import './App.css' -function App() { - function onPostMessage() { +import { useState } from 'react' + +import { api } from './api/create-webview-api' + +export default function App() { + const onPostMessage = () => { vscode.postMessage({ command: 'hello', text: 'Hey there partner! 🤠' @@ -22,6 +25,12 @@ function App() { const onGetState = async () => { console.log('state', await vscode.getState()) setState((await vscode.getState()) as string) + + const msg = await api.sendMessage('chat.startChat', Date.now().toString(), { + text: 'Hello' + }) + + console.log('api get msg:', msg) } return ( @@ -55,5 +64,3 @@ function App() { ) } - -export default App diff --git a/src/webview/api/create-webview-api.ts b/src/webview/api/create-webview-api.ts new file mode 100644 index 0000000..f33d4d1 --- /dev/null +++ b/src/webview/api/create-webview-api.ts @@ -0,0 +1,91 @@ +import type { Controllers } from '@extension/webview-api' +import type { + APIHandler, + Controller as ControllerType +} from '@extension/webview-api/types' +import { vscode } from '@webview/helpers/vscode' + +type ExtractMethodMap = T extends { name: infer N } + ? { + [K in N & string]: { + [M in keyof T['handlers']]: T['handlers'][M] extends APIHandler< + infer P, + infer R + > + ? { params: P; result: R } + : never + } + } + : never + +type CombineMethodMaps = ( + T extends (infer U)[] + ? U extends ControllerType + ? ExtractMethodMap + : never + : never +) extends infer O + ? { [K in keyof O]: O[K] } + : never + +type MergeMethodMaps = (T extends any ? (x: T) => void : never) extends ( + x: infer R +) => void + ? R + : never + +const createWebviewApi = () => { + type MethodMap = MergeMethodMaps> + + const callbacks: Record< + string, + { resolve: (value: any) => void; reject: (reason: any) => void } + > = {} + const streamHandlers: Record void> = + {} + + window.addEventListener('message', event => { + const { id, sessionId, command, result, error, data } = event.data + if (id) { + const callback = callbacks[id] + if (callback) { + if (error) { + callback.reject(new Error(JSON.stringify(error))) + } else { + callback.resolve(result) + } + delete callbacks[id] + } + } else if (command) { + const handler = streamHandlers[command] + if (handler) { + handler(sessionId, data) + } + } + }) + + const sendMessage = ( + command: `${string & C}.${string & M}`, + sessionId: string, + params: MethodMap[C][M] extends { params: infer P } ? P : never + ): Promise => + new Promise((resolve, reject) => { + const id = Math.random().toString(36).substring(2) + callbacks[id] = { resolve, reject } + vscode.postMessage({ command, sessionId, params, id }) + }) + + const onStream = ( + command: `${string & C}.${string & M}`, + handler: (sessionId: string, data: any) => void + ): void => { + streamHandlers[command] = handler + } + + return { sendMessage, onStream } +} + +export const api = createWebviewApi() +// api.sendMessage('chat.startChat', 'aaa', { +// text: 'Hello' +// }) diff --git a/src/webview/chat-context-manager/index.ts b/src/webview/chat-context-manager/index.ts new file mode 100644 index 0000000..b6a4a72 --- /dev/null +++ b/src/webview/chat-context-manager/index.ts @@ -0,0 +1,44 @@ +import type { ChatContext } from '@extension/webview-api/chat-context-builder/types/chat-context' + +import { BaseContextManager } from './managers/base.manager' +import { ConversationContextManager } from './managers/conversation.manager' +import { FileContextManager } from './managers/file.manager' +import { SettingsContextManager } from './managers/settings.manager' + +type ContextPart = Partial + +export class ChatContextManager< + T extends Record> +> { + private managers: T + + constructor(managers: T) { + this.managers = managers + } + + getContext(): ChatContext { + return Object.values(this.managers).reduce( + (acc, manager) => ({ + ...acc, + ...manager.getContext() + }), + {} as ChatContext + ) + } + + get(managerKey: K): T[K] { + return this.managers[managerKey] + } +} + +export const createChatContextManager = () => { + const chatContextManager = new ChatContextManager({ + file: new FileContextManager(), + conversation: new ConversationContextManager(), + settings: new SettingsContextManager() + }) + + // chatContextManager.get('file').addFile({ name: 'file1' }) + + return chatContextManager +} diff --git a/src/webview/chat-context-manager/managers/base.manager.ts b/src/webview/chat-context-manager/managers/base.manager.ts new file mode 100644 index 0000000..d7134db --- /dev/null +++ b/src/webview/chat-context-manager/managers/base.manager.ts @@ -0,0 +1,15 @@ +export abstract class BaseContextManager { + protected context: T + + constructor(initialContext: T) { + this.context = initialContext + } + + getContext(): T { + return JSON.parse(JSON.stringify(this.context)) + } + + updateContext(newContext: Partial): void { + this.context = { ...this.context, ...newContext } + } +} diff --git a/src/webview/chat-context-manager/managers/conversation.manager.ts b/src/webview/chat-context-manager/managers/conversation.manager.ts new file mode 100644 index 0000000..f6d721e --- /dev/null +++ b/src/webview/chat-context-manager/managers/conversation.manager.ts @@ -0,0 +1,46 @@ +import type { IConversationContext } from '@extension/webview-api/chat-context-builder/types/chat-context' +import type { Message } from '@extension/webview-api/chat-context-builder/types/chat-context/message' + +import { BaseContextManager } from './base.manager' + +export class ConversationContextManager extends BaseContextManager { + constructor() { + super({ + conversation: [], + references: { + selections: [], + fileSelections: [], + folderSelections: [], + useWeb: false, + useCodebase: false + }, + codeSelections: [], + plainText: '' + }) + } + + addMessage(message: Message): void { + if (!this.context.conversation) { + this.context.conversation = [] + } + this.context.conversation.push(message) + } + + removeMessage(index: number): void { + if ( + this.context.conversation && + index >= 0 && + index < this.context.conversation.length + ) { + this.context.conversation.splice(index, 1) + } + } + + getMessages(): Message[] { + return this.context.conversation || [] + } + + clearConversation(): void { + this.context.conversation = [] + } +} diff --git a/src/webview/chat-context-manager/managers/file.manager.ts b/src/webview/chat-context-manager/managers/file.manager.ts new file mode 100644 index 0000000..26380e8 --- /dev/null +++ b/src/webview/chat-context-manager/managers/file.manager.ts @@ -0,0 +1,18 @@ +import type { IFileContext } from '@extension/webview-api/chat-context-builder/types/chat-context' + +import { BaseContextManager } from './base.manager' + +export class FileContextManager extends BaseContextManager { + constructor() { + super({ + focusedFiles: [], + suggestedFiles: [], + newlyCreatedFiles: [], + newlyCreatedFolders: [], + deleteFileSuggestions: [], + isReadingLongFile: false, + hasAddedFiles: false, + codeBlockData: {} + }) + } +} diff --git a/src/webview/chat-context-manager/managers/settings.manager.ts b/src/webview/chat-context-manager/managers/settings.manager.ts new file mode 100644 index 0000000..4c34b79 --- /dev/null +++ b/src/webview/chat-context-manager/managers/settings.manager.ts @@ -0,0 +1,16 @@ +import type { ISettingsContext } from '@extension/webview-api/chat-context-builder/types/chat-context' + +import { BaseContextManager } from './base.manager' + +export class SettingsContextManager extends BaseContextManager { + constructor() { + super({ + modelName: '', + useFastApply: false, + useChunkSpeculationForLongFiles: false, + explicitContext: { context: '' }, + clickedCodeBlockContents: '', + allowLongFileScan: false + }) + } +} diff --git a/src/webview/helpers/vscode.ts b/src/webview/helpers/vscode.ts index 69b99d4..7254380 100644 --- a/src/webview/helpers/vscode.ts +++ b/src/webview/helpers/vscode.ts @@ -28,12 +28,11 @@ class VSCodeAPIWrapper { * * @param message Abitrary data (must be JSON serializable) to send to the extension context. */ - public postMessage(message: unknown) { + postMessage(message: unknown) { if (this.vsCodeApi) { this.vsCodeApi.postMessage(message) } else { window.parent.postMessage({ type: 'page:message', data: message }, '*') - console.log(message) } } @@ -45,7 +44,7 @@ class VSCodeAPIWrapper { * * @return The current state or `undefined` if no state has been set. */ - public async getState(): Promise { + async getState(): Promise { if (this.vsCodeApi) { return await this.vsCodeApi.getState() } @@ -64,9 +63,7 @@ class VSCodeAPIWrapper { * * @return The new state. */ - public async setState( - newState: T - ): Promise { + async setState(newState: T): Promise { if (this.vsCodeApi) { return await this.vsCodeApi.setState(newState) } diff --git a/src/webview/types/vscode.d.ts b/src/webview/types/vscode.d.ts new file mode 100644 index 0000000..a02f73d --- /dev/null +++ b/src/webview/types/vscode.d.ts @@ -0,0 +1,12 @@ +import type { WebviewToExtensionsMsg } from '@shared/types' + +declare global { + interface Window { + acquireVsCodeApi(): { + postMessage(msg: WebviewToExtensionsMsg): void + setState(state: any): void + getState(): any + } + vscode: ReturnType + } +} diff --git a/tsconfig.json b/tsconfig.json index b1a1028..330e1c1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -41,8 +41,7 @@ "types": ["vite/client", "@types/vscode-webview"], "paths": { "@extension/*": ["src/extension/*"], - "@webview/*": ["./src/webview/*"], - "@shared/*": ["./src/shared/*"] + "@webview/*": ["./src/webview/*"] } }, "include": [