diff --git a/README.md b/README.md index 343b758..6374994 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ The following ECL specific commands are available. Note: These commands will * | Submit (No Archive) | ctrl/cmd+F5 | Submit raw ECL without creating an archive | | Verify ECL Signature | | Verify ECL Digital Signature | | Language Reference Lookup | shift+F1 | For the currently selected text, search the online ECL language reference | -| Insert Record Definition | ctrl/cmd+I R | Fetches record definition for given logical file | +| Insert Record Definition | ctrl/cmd+R | Fetches record definition for given logical file | #### Within the ECL Code Editor Tab Context Menu: diff --git a/package-lock.json b/package-lock.json index 24cf127..7571bb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "@types/react": "17.0.80", "@types/react-dom": "17.0.25", "@types/tmp": "0.2.6", - "@types/vscode": "1.76.0", + "@types/vscode": "1.92.0", "@types/vscode-notebook-renderer": "1.72.3", "@typescript-eslint/eslint-plugin": "8.2.0", "@typescript-eslint/parser": "8.2.0", @@ -69,7 +69,7 @@ "uuid": "10.0.0" }, "engines": { - "vscode": "^1.76.0" + "vscode": "^1.92.0" } }, "node_modules/@azure/abort-controller": { @@ -2973,9 +2973,9 @@ "license": "MIT" }, "node_modules/@types/vscode": { - "version": "1.76.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.76.0.tgz", - "integrity": "sha512-CQcY3+Fe5hNewHnOEAVYj4dd1do/QHliXaknAEYSXx2KEHUzFibDZSKptCon+HPgK55xx20pR+PBJjf0MomnBA==", + "version": "1.92.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.92.0.tgz", + "integrity": "sha512-DcZoCj17RXlzB4XJ7IfKdPTcTGDLYvTOcTNkvtjXWF+K2TlKzHHkBEXNWQRpBIXixNEUgx39cQeTFunY0E2msw==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index fd8d045..1945acb 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "@types/react": "17.0.80", "@types/react-dom": "17.0.25", "@types/tmp": "0.2.6", - "@types/vscode": "1.76.0", + "@types/vscode": "1.92.0", "@types/vscode-notebook-renderer": "1.72.3", "@typescript-eslint/eslint-plugin": "8.2.0", "@typescript-eslint/parser": "8.2.0", @@ -116,7 +116,7 @@ "url": "https://github.com/hpcc-systems/vscode-ecl.git" }, "engines": { - "vscode": "^1.76.0" + "vscode": "^1.92.0" }, "galleryBanner": { "color": "#CFB69A", @@ -205,6 +205,25 @@ "path": "./snippets/kel.json" } ], + "chatParticipants": [ + { + "id": "chat.ecl", + "fullName": "ECL", + "name": "ecl", + "description": "HPCC-Platform Assistant to help with ECL development", + "isSticky": true, + "commands": [ + { + "name": "create", + "description": "Create a new ECL file" + }, + { + "name": "docs", + "description": "Information about the ECL language" + } + ] + } + ], "viewsWelcome": [], "commands": [ { diff --git a/src/ecl/chat/constants.ts b/src/ecl/chat/constants.ts new file mode 100644 index 0000000..7f6c8e6 --- /dev/null +++ b/src/ecl/chat/constants.ts @@ -0,0 +1,53 @@ +import * as vscode from "vscode"; + +export const ECL_COMMAND_ID = "ecl"; +export const PROCESS_COPILOT_CREATE_CMD = "ecl.createFiles"; +export const PROCESS_COPILOT_CREATE_CMD_TITLE = "Create ECL file"; +export const COPILOT_CREATE_CMD = "ECL file"; + +export const OWNER = "hpcc-systems"; +export const REPO = "HPCC-Platform"; +export const BRANCH = "master"; +export const SAMPLE_COLLECTION_URL = `https://cdn.jsdelivr.net/gh/${OWNER}/${REPO}@${BRANCH}/`; + +export const MODEL_VENDOR: string = "copilot"; + +enum LANGUAGE_MODEL_ID { + GPT_3 = "gpt-3.5-turbo", + GPT_4 = "gpt-4", + GPT_4o = "gpt-4o" +} + +export const MODEL_SELECTOR: vscode.LanguageModelChatSelector = { vendor: MODEL_VENDOR, family: LANGUAGE_MODEL_ID.GPT_4o }; + +export const FETCH_ISSUE_DETAIL_CMD = "Fetch Issue Details Command"; + +export enum commands { + DOCS = "docs", + ISSUES = "issues", +} + +const GREETINGS = [ + "Let me think how I can assist you... 🤔", + "Just a moment, I'm pondering... 💭", + "Give me a second, I'm working on it... ⏳", + "Hold on, let me figure this out... 🧐", + "One moment, I'm processing your request... ⏲️", + "Checking inside Gavins brain... 💭", + "Dans the man for this... 🧐", + "Working on your request... 🚀", + "Lets see what schmoo can do... 🕵️‍♂️", + "Let's see what we can do... 🕵️‍♂️", + "Let's get this sorted... 🗂️", + "Calling Jake for an answer... 💭", + "Hang tight, I'm on the case... 🕵️‍♀️", + "Analyzing the situation... 📊", + "Preparing the solution... 🛠️", + "Searching for the answer... 🔍", + "Maybe Mark knows... 🤔", + "Investigating the problem... 🕵️‍♂️" +]; + +export const getRandomGreeting = () => { + return GREETINGS[Math.floor(Math.random() * GREETINGS.length)]; +}; diff --git a/src/ecl/chat/index.ts b/src/ecl/chat/index.ts new file mode 100644 index 0000000..29716d5 --- /dev/null +++ b/src/ecl/chat/index.ts @@ -0,0 +1,91 @@ +import * as vscode from "vscode"; +import { commands, getRandomGreeting } from "./constants"; +import localize from "../../util/localize"; +import { handleDocsCommand } from "./prompts/docs"; +import { handleIssueManagement } from "./prompts/issues"; + +const ECL_PARTICIPANT_ID = "chat.ecl"; + +interface IECLChatResult extends vscode.ChatResult { + metadata: { + command: string; + } +} + +function handleError(logger: vscode.TelemetryLogger, err: any, stream: vscode.ChatResponseStream): void { + logger.logError(err); + + if (err instanceof vscode.LanguageModelError) { + console.log(err.message, err.code, err.cause); + if (err.cause instanceof Error && err.cause.message.includes("off_topic")) { + stream.markdown(localize("I'm sorry, I can only explain ECL related topics.")); + } + } else { + throw err; + } +} + +let eclChat: ECLChat; + +export class ECLChat { + protected constructor(ctx: vscode.ExtensionContext) { + const handler: vscode.ChatRequestHandler = async (request: vscode.ChatRequest, ctx: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise => { + let cmdResult: any; + stream.progress(localize(getRandomGreeting())); + try { + if (request.command === commands.ISSUES) { + cmdResult = await handleIssueManagement(request, stream, token); + logger.logUsage("request", { kind: commands.ISSUES }); + } else { + cmdResult = await handleDocsCommand(request, stream, token); + } + } catch (err) { + handleError(logger, err, stream); + } + + return { + metadata: { + command: request.command || "", + }, + }; + + }; + + const chatParticipant = vscode.chat.createChatParticipant(ECL_PARTICIPANT_ID, handler); + chatParticipant.iconPath = vscode.Uri.joinPath(ctx.extensionUri, "resources/hpcc-icon.png"); + + chatParticipant.followupProvider = { + provideFollowups(result: IECLChatResult, context: vscode.ChatContext, token: vscode.CancellationToken) { + return []; + } + }; + + const logger = vscode.env.createTelemetryLogger({ + sendEventData(eventName, data) { + // Capture event telemetry + console.log(`Event: ${eventName}`); + console.log(`Data: ${JSON.stringify(data)}`); + }, + sendErrorData(error, data) { + console.error(`Error: ${error}`); + console.error(`Data: ${JSON.stringify(data)}`); + } + }); + + ctx.subscriptions.push(chatParticipant.onDidReceiveFeedback((feedback: vscode.ChatResultFeedback) => { + logger.logUsage("chatResultFeedback", { + kind: feedback.kind + }); + })); + } + + static attach(ctx: vscode.ExtensionContext): ECLChat { + if (!eclChat) { + eclChat = new ECLChat(ctx); + } + return eclChat; + } +} + +export function deactivate() { } + diff --git a/src/ecl/chat/prompts/docs.tsx b/src/ecl/chat/prompts/docs.tsx new file mode 100644 index 0000000..3647292 --- /dev/null +++ b/src/ecl/chat/prompts/docs.tsx @@ -0,0 +1,65 @@ +import * as vscode from "vscode"; +import { AssistantMessage, BasePromptElementProps, PromptElement, PromptSizing, TextChunk, UserMessage, } from "@vscode/prompt-tsx"; +import { commands, MODEL_SELECTOR } from "../constants"; +import { getChatResponse } from "../utils/index"; +import { fetchContext, fetchIndexes, Hit, matchTopics } from "../../docs"; +import * as prompts from "./templates/default"; + +export interface PromptProps extends BasePromptElementProps { + userQuery: string; +} + +export interface DocsPromptProps extends PromptProps { + hits: Hit[] +} + +export class DocsPrompt extends PromptElement { + + render(state: void, sizing: PromptSizing) { + return ( + <> + {prompts.SYSTEM_MESSAGE} + + {this.props.hits.map((hit, idx) => ( + + {JSON.stringify(hit)} + + ))} + + {this.props.userQuery} + + ); + } +} + +export async function handleDocsCommand(request: vscode.ChatRequest, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise<{ metadata: { command: string, hits: Hit[] } }> { + const [model] = await vscode.lm.selectChatModels(MODEL_SELECTOR); + if (model) { + const hits = await fetchContext(request.prompt); + let promptProps: DocsPromptProps; + if (!hits.length) { + promptProps = { + userQuery: `Suggest several (more 3 or more) web links that exist in the previous html content above that might help with the following question "${request.prompt}". The user can not see the above content. Explain why they might be helpful`, + hits: await fetchIndexes() + }; + } else { + promptProps = { + userQuery: request.prompt, + hits, + }; + } + + const chatResponse = await getChatResponse(DocsPrompt, promptProps, token); + for await (const fragment of chatResponse.text) { + stream.markdown(fragment); + } + + return { + metadata: { + command: commands.DOCS, + hits: promptProps.hits, + }, + }; + } +} + diff --git a/src/ecl/chat/prompts/issues.tsx b/src/ecl/chat/prompts/issues.tsx new file mode 100644 index 0000000..08febef --- /dev/null +++ b/src/ecl/chat/prompts/issues.tsx @@ -0,0 +1,170 @@ +import * as vscode from "vscode"; +import { BasePromptElementProps, PromptElement, PromptSizing, UserMessage, } from "@vscode/prompt-tsx"; +import localize from "../../../util/localize"; +import { commands } from "../constants"; +import { Comment, fetchAllIssues, fetchIssueDetailsByNumber, fetchLatestIssueDetails, getGitHubClient, Issue } from "../utils/github"; +import { FETCH_ISSUE_DETAIL_CMD } from "../constants"; +import { getChatResponse } from "../utils/index"; + +export const SYSTEM_MESSAGE = `You are a software product owner and you help your developers providing additional information +for working on current software development task from github issue details.`; + +export interface PromptProps extends BasePromptElementProps { + userQuery: string; +} + +export interface IssuesManagePromptProps extends PromptProps { + issueDetails: string; +} + +export interface IssuesManagePromptProps extends PromptProps { + issueDetails: string; +} + +export class IssuesManagePrompt extends PromptElement { + + render(state: void, sizing: PromptSizing) { + return ( + <> + {SYSTEM_MESSAGE} + + Here are the Github issue details
+ {this.props.issueDetails}
+ Explain the issue details to the developer and probably provide some additional information.
+ Provide the response in nice markdown format to the user. +
+ + ); + } +} + +// Extracts owner and repo name from the workspace's Git configuration +async function extractRepoDetailsFromWorkspace(): Promise<{ owner: string; repoName: string } | null> { + const gitExtension = vscode.extensions.getExtension("vscode.git")?.exports; + if (!gitExtension) { + vscode.window.showErrorMessage("Unable to load Git extension"); + return null; + } + + const api = gitExtension.getAPI(1); + if (api.repositories.length === 0) { + vscode.window.showInformationMessage("No Git repositories found"); + return null; + } + + const repo = api.repositories[0]; + const remotes = repo.state.remotes; + if (remotes.length === 0) { + vscode.window.showInformationMessage("No remotes found"); + return null; + } + + const remoteUrl = remotes[0].fetchUrl; + const match = remoteUrl?.match(/github\.com[:/](.+?)\/(.+?)(?:\.git)?$/); + if (!match) { + vscode.window.showErrorMessage("Unable to parse GitHub repository URL"); + return null; + } + + return { owner: match[1], repoName: match[2] }; +} + +function streamIssueDetails( + stream: vscode.ChatResponseStream, + issue: any, + comments: Comment[] +) { + stream.progress(`Issue "${issue.title}" loaded.`); + stream.markdown(`Issue: **${issue.title}**\n\n`); + stream.markdown(issue.body?.replaceAll("\n", "\n> ") + ""); + if (comments?.length > 0) { + stream.markdown("\n\n_Comments_\n"); + comments?.map((comment) => + stream.markdown(`\n> ${comment.body?.replaceAll("\n", "\n> ") + ""}\n`) + ); + } + stream.markdown("\n\n----\n\n"); +} + +async function streamCopilotResponse(stream: vscode.ChatResponseStream, issueDetails: Issue, promptProps: IssuesManagePromptProps, token: vscode.CancellationToken) { + stream.progress("Copilot suggestion...."); + try { + const issueDetailsStr = `The issue to work on has the title: "${issueDetails?.title}" and the description: ${issueDetails?.body}. Use that information to give better answer for the following user query.` + + (issueDetails?.comments && issueDetails?.comments?.length > 0 + ? `Do also regard the comments: ${issueDetails?.comments + ?.map((comment) => comment.body) + .join("\n\n") + "" + }` + : ""); + promptProps.issueDetails = issueDetailsStr; + const chatResponse = await getChatResponse(IssuesManagePrompt, promptProps, token,); + for await (const fragment of chatResponse.text) { + stream.markdown(fragment); + } + } catch (error) { + stream.markdown("Some Network issues. Please try again.."); + } +} + +// Main handler for issue management +export async function handleIssueManagement(request: vscode.ChatRequest, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise<{ metadata: { command: string } }> { + const userQuery = request.prompt.toLowerCase(); + const workspaceDetails = await extractRepoDetailsFromWorkspace(); + if (!workspaceDetails) { + stream.markdown("Repository details not found."); + return { metadata: { command: commands.ISSUES } }; + } + + const promptProps: IssuesManagePromptProps = { + userQuery: request.prompt, + issueDetails: "", + }; + + const octokit = await getGitHubClient(); + + // Determine action based on user query + if (userQuery.includes("latest issue")) { + const issueDetails = await fetchLatestIssueDetails(workspaceDetails.owner, workspaceDetails.repoName, octokit); + if (!issueDetails) { + stream.markdown("Latest issue details not found."); + return { metadata: { command: commands.ISSUES } }; + } + streamIssueDetails(stream, issueDetails, issueDetails.comments); + await streamCopilotResponse(stream, issueDetails, promptProps, token); + } else if (userQuery.match(/issue #(\d+)/)) { + const match = userQuery.match(/issue #(\d+)/); + const issueNumber = match ? parseInt(match[1]) : null; + if (!issueNumber) { + stream.markdown("Issue number not found."); + return { metadata: { command: commands.ISSUES } }; + } + const issueDetails = await fetchIssueDetailsByNumber(workspaceDetails.owner, workspaceDetails.repoName, issueNumber, octokit); + if (!issueDetails) { + stream.markdown(`Details for issue #${issueNumber} not found.`); + return { metadata: { command: commands.ISSUES } }; + } + streamIssueDetails(stream, issueDetails, issueDetails.comments); + await streamCopilotResponse(stream, issueDetails, promptProps, token); + } else if (userQuery.includes("list")) { + const issuesList = await fetchAllIssues(workspaceDetails.owner, workspaceDetails.repoName, octokit, 5); + if (!issuesList || issuesList.length === 0) { + stream.markdown("No issues found."); + return { metadata: { command: commands.ISSUES } }; + } + stream.markdown("Here are the latest issues:\n"); + issuesList.forEach((issue: any) => { + stream.button({ + command: FETCH_ISSUE_DETAIL_CMD, + title: `${issue.number}: ${issue.title}`, + tooltip: localize(`Fetch details for Issue #${issue.number}`), + arguments: [issue], + + }); + }); + } else { + stream.markdown("I'm not sure how to help with that. You can ask for the 'latest issue', 'list all issues', or about a specific 'issue #number'."); + return { metadata: { command: commands.ISSUES } }; + } + + return { metadata: { command: commands.ISSUES } }; +} diff --git a/src/ecl/chat/prompts/templates/default.ts b/src/ecl/chat/prompts/templates/default.ts new file mode 100644 index 0000000..3fd272e --- /dev/null +++ b/src/ecl/chat/prompts/templates/default.ts @@ -0,0 +1,18 @@ +export const SYSTEM_MESSAGE = `\ +You are an expert in the ECL programming language by hpcc-systems. The user wants help with the ECL programming language. Your job is to answer specific ECL related questions. Folow the instruction and think step by step. + + + 1. Do not suggest using any other tools other than what has been previously mentioned. + 2. All answers should be formatted as markdown. + 3. Assume the user is only interested in using the ECL language. + 4. The question will be prefaced with an array of JSON objects from the online help files, each will contain: + * label + * url + * content + 5. When referencing help content, always provide the URL, do not make up an URL use the URL from step 4. + 6. Do not overwhelm the user with too much information. Keep responses short and sweet. + 7. All code blocks should be formatted as ECL code using \`\`\`ecl. + 8. If you are not sure about the answer reply with "I can only answer questions about ECL." + 9. Think step by step and provide the answer. + +`; diff --git a/src/ecl/chat/utils/chatResponse.ts b/src/ecl/chat/utils/chatResponse.ts new file mode 100644 index 0000000..0cd86ed --- /dev/null +++ b/src/ecl/chat/utils/chatResponse.ts @@ -0,0 +1,18 @@ +import * as vscode from "vscode"; +import { BasePromptElementProps, PromptElementCtor, renderPrompt } from "@vscode/prompt-tsx"; +import { MODEL_SELECTOR } from "../constants"; + +export interface PromptProps extends BasePromptElementProps { + userQuery: string; +} + +export async function getChatResponse, P extends PromptProps>(prompt: T, promptProps: P, token: vscode.CancellationToken): Promise> { + const [model] = await vscode.lm.selectChatModels(MODEL_SELECTOR); + if (model) { + const { messages } = await renderPrompt(prompt, promptProps, { modelMaxPromptTokens: model.maxInputTokens }, model); + return await model.sendRequest(messages, {}, token); + } else { + throw new Error("No model found"); + } +} + diff --git a/src/ecl/chat/utils/file.ts b/src/ecl/chat/utils/file.ts new file mode 100644 index 0000000..aa97164 --- /dev/null +++ b/src/ecl/chat/utils/file.ts @@ -0,0 +1,63 @@ +import * as vscode from "vscode"; +import * as path from "path"; +import * as fs from "fs/promises"; + +export interface File { + name: string; + path: string; + content?: string; +} +export async function createFileWithContent( + filePath: string, + content: string +): Promise { + const fileUri = vscode.Uri.file(filePath); + await vscode.workspace.fs.writeFile(fileUri, Buffer.from(content, "utf8")); +} + +async function createFileAndOpenInEditor(file: any, baseUri: vscode.Uri) { + const newFilePath = vscode.Uri.joinPath(baseUri, file.path); + const dirPath = path.dirname(newFilePath.fsPath); + await fs.mkdir(dirPath, { recursive: true }); + try { + await vscode.workspace.fs.stat(newFilePath); + } catch (err) { + await createFileWithContent(newFilePath.fsPath, file.content); + const document = await vscode.workspace.openTextDocument(newFilePath); + const editor = await vscode.window.showTextDocument(document); + await vscode.commands.executeCommand("editor.action.formatDocument"); + } +} + +export async function createFolderAndFiles(files: any[]): Promise { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (workspaceFolders) { + const baseUri = workspaceFolders[0].uri; + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Creating files", + cancellable: false, + }, + async (progress) => { + if (files.length > 0) { + const blockName = files[0].path.split("/")[1]; + const blockPath = path.join(baseUri.fsPath, "/blocks/" + blockName); + try { + await fs.rmdir(blockPath, { recursive: true }); + } catch (err) { + console.error(`Failed to delete directory: ${err}`); + } + } + + await Promise.all(files.map(async (file, i) => { + await createFileAndOpenInEditor(file, baseUri); + progress.report({ + increment: 100.0 / files.length, + message: `Creating file: ${file.path}`, + }); + })); + } + ); + } +} \ No newline at end of file diff --git a/src/ecl/chat/utils/github.ts b/src/ecl/chat/utils/github.ts new file mode 100644 index 0000000..ce436ee --- /dev/null +++ b/src/ecl/chat/utils/github.ts @@ -0,0 +1,93 @@ +import * as vscode from "vscode"; +import { Octokit } from "@octokit/rest"; + +export interface Comment { + id: number; + url: string; + body?: string | undefined; +} + +export interface Issue { + title: string; + body: string; + comments: Comment[]; +} + +export async function getGitHubClient(): Promise { + const session = await vscode.authentication.getSession("github", ["repo"], { createIfNone: true }); + return new Octokit({ auth: session.accessToken }); +} + +export async function fetchLatestIssueDetails(owner: string, repoName: string, octokit: Octokit): Promise { + try { + const issues = await octokit.issues.listForRepo({ + owner, + repo: repoName, + state: "open", + per_page: 1, + sort: "created", + direction: "desc", + }); + + if (issues.data.length === 0) { + vscode.window.showInformationMessage("No open issues found in the repository"); + return null; + } + + const latestIssue = issues.data[0]; + const commentsResponse = await octokit.issues.listComments({ + owner, + repo: repoName, + issue_number: latestIssue.number, + }); + + return { + title: latestIssue.title, + body: latestIssue.body || "", + comments: commentsResponse.data, + }; + } catch (error) { + vscode.window.showErrorMessage(`Error fetching issue details: ${error}`); + return null; + } +} + +export async function fetchIssueDetailsByNumber(owner: string, repoName: string, issueNumber: number, octokit: Octokit): Promise { + try { + const issue = await octokit.issues.get({ + owner, + repo: repoName, + issue_number: issueNumber, + }); + + const commentsResponse = await octokit.issues.listComments({ + owner, + repo: repoName, + issue_number: issueNumber, + }); + + return { + title: issue.data.title, + body: issue.data.body || "", + comments: commentsResponse.data, + }; + } catch (error) { + vscode.window.showErrorMessage(`Error fetching issue details: ${error}`); + return null; + } +} + +export async function fetchAllIssues(owner: string, repoName: string, octokit: Octokit, top_n: number): Promise { + try { + const issues = await octokit.issues.listForRepo({ + owner, + repo: repoName, + state: "open", + per_page: top_n, // Use top_n for per_page + }); + return issues.data.length > top_n ? issues.data.slice(0, top_n) : issues.data; + } catch (error) { + vscode.window.showErrorMessage(`Error fetching issue details: ${error}`); + return null; + } +} \ No newline at end of file diff --git a/src/ecl/chat/utils/index.ts b/src/ecl/chat/utils/index.ts new file mode 100644 index 0000000..50960ba --- /dev/null +++ b/src/ecl/chat/utils/index.ts @@ -0,0 +1 @@ +export * from "./chatResponse"; \ No newline at end of file diff --git a/src/ecl/command.ts b/src/ecl/command.ts index 11e62fb..535d4f8 100644 --- a/src/ecl/command.ts +++ b/src/ecl/command.ts @@ -10,6 +10,7 @@ import { eclWatchPanelView } from "./eclWatchPanelView"; import { ECLResultNode, ECLWUNode } from "./eclWatchTree"; import localize from "../util/localize"; import { createDirectory, exists, writeFile } from "../util/fs"; +import { ECLR_EN_US, matchTopics, SLR_EN_US } from "./docs"; import { SaveData } from "./saveData"; const IMPORT_MARKER = "//Import:"; @@ -89,45 +90,23 @@ export class ECLCommands { } } - private ECLR_EN_US = "https://hpccsystems.com/wp-content/uploads/_documents/ECLR_EN_US"; showLanguageReference() { - vscode.commands.executeCommand("vscode.open", vscode.Uri.parse(`${this.ECLR_EN_US}/index.html`)); + vscode.commands.executeCommand("vscode.open", vscode.Uri.parse(`${ECLR_EN_US}/index.html`)); } - private SLR_EN_US = "https://hpccsystems.com/wp-content/uploads/_documents/SLR_EN_US"; showStandardLibraryReference() { - vscode.commands.executeCommand("vscode.open", vscode.Uri.parse(`${this.SLR_EN_US}/index.html`)); + vscode.commands.executeCommand("vscode.open", vscode.Uri.parse(`${SLR_EN_US}/index.html`)); } - async parseIndex(url: string): Promise<[string, string, string, string][]> { - return fetch(`${url}/index.html`) - .then(response => response.text()) - .then(html => [...html.matchAll(/([^<]+)<\/a>/g)]) - .then(matches => matches.map(row => { - const cleaned = row[2].split("\n").map(str => str.trim()).join(" "); - return [row[0], row[1], cleaned, url]; - })); - } - - protected _html: Promise<[string, string, string, string][]>; - searchTerm(editor: vscode.TextEditor) { + async searchTerm(editor: vscode.TextEditor) { if (vscode.window.activeTextEditor) { const range = vscode.window.activeTextEditor.document.getWordRangeAtPosition(editor.selection.active); const searchTerm = editor.document.getText(range); - if (!this._html) { - const langRef = this.parseIndex(this.ECLR_EN_US); - const stdLib = this.parseIndex(this.SLR_EN_US); - this._html = Promise.all([langRef, stdLib]).then(([langRef, stdLib]) => { - return langRef.concat(stdLib); - }); + const matches = await matchTopics(searchTerm); + const picked = await vscode.window.showQuickPick(matches, { canPickMany: false }); + if (picked) { + vscode.commands.executeCommand("vscode.open", picked.uri); } - this._html.then(async (links: [string, string, string, string][]) => { - const matches = links.filter(row => row[2].toLowerCase().indexOf(searchTerm.toLowerCase()) >= 0); - const picked = await vscode.window.showQuickPick(matches.map(row => ({ label: row[2], row })), { canPickMany: false }); - if (picked) { - vscode.commands.executeCommand("vscode.open", vscode.Uri.parse(`${picked.row[3]}/${picked.row[1]}`)); - } - }); } } diff --git a/src/ecl/docs/index.ts b/src/ecl/docs/index.ts new file mode 100644 index 0000000..4bcc139 --- /dev/null +++ b/src/ecl/docs/index.ts @@ -0,0 +1 @@ +export * from "./onlineHelp"; \ No newline at end of file diff --git a/src/ecl/docs/onlineHelp.ts b/src/ecl/docs/onlineHelp.ts new file mode 100644 index 0000000..6f405f1 --- /dev/null +++ b/src/ecl/docs/onlineHelp.ts @@ -0,0 +1,148 @@ +import * as vscode from "vscode"; +import { convert } from "html-to-text"; + +export const ECLR_EN_US = "https://hpccsystems.com/wp-content/uploads/_documents/ECLR_EN_US"; +export const SLR_EN_US = "https://hpccsystems.com/wp-content/uploads/_documents/SLR_EN_US"; +export const ProgrammersGuide_EN_US = "https://hpccsystems.com/wp-content/uploads/_documents/ProgrammersGuide_EN_US"; + +interface HelpMeta { + orig: string; + partialUrl: string; + title: string; + titleParts: string[]; + url: string; +} + +async function parseIndex(url: string): Promise { + return fetch(`${url}/index.html`) + .then(response => response.text()) + .then(html => [...html.matchAll(/([^<]+)<\/a>/g)]) + .then(matches => matches.map(row => { + const title = row[2].split("\n").join(" | "); + const titleParts = row[2].split("\n").join(" ").split(" ").map(str => str.trim().toLocaleLowerCase()).filter(term => !!term); + return { + orig: row[0], + partialUrl: row[1], + title, + titleParts, + url + }; + })); +} + +let _labelUriCache: Promise; +interface MatchUri { + label: string; + uri: vscode.Uri; + exact: boolean; +} + +export async function matchTopics(_word: string): Promise { + if (!_labelUriCache) { + const langRef = parseIndex(ECLR_EN_US); + const stdLib = parseIndex(SLR_EN_US); + const progGuide = parseIndex(ProgrammersGuide_EN_US); + _labelUriCache = Promise.all([langRef, stdLib, progGuide]).then(([langRef, stdLib, progGuide]) => { + return progGuide.concat(stdLib).concat(langRef); + }); + } + + const word = _word.toLocaleLowerCase(); + return _labelUriCache.then((links: HelpMeta[]) => { + return links + .filter(row => { + return row.titleParts.some(term => term.indexOf(word) >= 0); + }).map(row => { + return { + label: row.title, + uri: vscode.Uri.parse(`${row.url}/${row.partialUrl}`), + exact: row.titleParts.includes(word) + }; + }).sort((a, b) => a.exact ? -1 : b.exact ? 1 : 0); + }); +} + +const commonFillWords = [ + "the", "and", "for", "with", "from", "that", "this", "which", "what", "how", + "why", "when", "where", "who", "whom", "whose", "will", "would", "should", "could", + "can", "may", "might", "shall", "must", "have", "has", "had", "do", "does", "did", + "is", "are", "was", "were", "be", "been", "being", "it", "its", "they", "them", + "their", "our", "we", "us", "you", "your", "my", "mine", "his", "her", + "he", "she", "it", "its", "him", "her", "his", "hers", "they", "them", "their", + "theirs", "we", "us", "our", "ours", "you", "your", "yours", "my", "mine", + "our", "ours", "your", "yours", "my", "mine", "his", "her", "he", "she", "about", "know" +]; + +function simpleNormalize(text: string): string[] { + const s = text.toLocaleLowerCase().replace(/[^a-z0-9]/g, " "); + + const words = s.split(" ") + .filter((word) => word.length > 2) + .filter((word) => !commonFillWords.includes(word)) + .map((e) => e.trim()) + .filter((e) => !!e); + + return words; +} + +export interface Hit { + label: string; + url: string; + exact: number; + content: string; + error?: string; +} +const _labelContentCache: { [id: string]: Promise } = {}; + +function fetchContent(url: string, label: string, exact: number): Promise { + if (!_labelContentCache[url]) { + _labelContentCache[url] = fetch(url) + .then(async response => { + return { + label, + url, + exact, + content: convert(await response.text(), { wordwrap: false }) + }; + }).catch(e => { + return { + label, + url, + content: "", + exact: 0, + error: e.message() + }; + }); + } + return _labelContentCache[url]; +} + +export async function fetchIndexes(): Promise { + return Promise.all([ECLR_EN_US, SLR_EN_US, ProgrammersGuide_EN_US].map(url => { + return fetchContent(url, url, 0); + })); +} + +export async function fetchContext(query: string): Promise { + + const terms = simpleNormalize(query); + const retVal: Promise[] = []; + await Promise.all(terms.map(term => { + let exact = 0; + return matchTopics(term) + .then(async matches => { + matches.forEach((match) => { + if (match.exact) { + exact++; + } + + const url = match.uri.toString(); + retVal.push(fetchContent(url, match.label, match.exact ? 1 : 0)); + }); + }); + })); + + return Promise.all(retVal).then(hits => { + return hits.sort((a, b) => b.exact - a.exact); + }); +} diff --git a/src/ecl/main.ts b/src/ecl/main.ts index f8b1e7c..d8cb75b 100644 --- a/src/ecl/main.ts +++ b/src/ecl/main.ts @@ -10,6 +10,7 @@ import { initLogger, Level } from "./util"; import { ECLWatchTree } from "./eclWatchTree"; import { ECLWatchPanelView } from "./eclWatchPanelView"; import { HPCCResources } from "./hpccResources"; +import { ECLChat } from "./chat"; const eclConfig = vscode.workspace.getConfiguration("ecl"); initLogger(eclConfig.get("debugLogging") ? Level.debug : Level.info); @@ -35,4 +36,6 @@ export function activate(ctx: vscode.ExtensionContext): void { ECLTerminal.attach(ctx); logger.debug("Activating HPCCResources"); HPCCResources.attach(ctx); + logger.debug("Activating Chat"); + ECLChat.attach(ctx); }