diff --git a/cursorless-talon/src/apps/cursorless_jetbrains.py b/cursorless-talon/src/apps/cursorless_jetbrains.py new file mode 100644 index 0000000000..fe5ec8727d --- /dev/null +++ b/cursorless-talon/src/apps/cursorless_jetbrains.py @@ -0,0 +1,9 @@ +from talon import Context, actions + +ctx = Context() + +ctx.matches = r""" +app: jetbrains +""" + +ctx.tags = ["user.cursorless"] diff --git a/packages/cursorless-jetbrains/TERMINOLOGY.md b/packages/cursorless-jetbrains/TERMINOLOGY.md new file mode 100644 index 0000000000..bcad13f7fd --- /dev/null +++ b/packages/cursorless-jetbrains/TERMINOLOGY.md @@ -0,0 +1,4 @@ +# TextEditor/TextDocument vs Project/Editor + +1. Each Cursorless "TextDocument" corresponds to a Jetbrains "Editor" tab +2. There is no specific handling of projects diff --git a/packages/cursorless-jetbrains/package.json b/packages/cursorless-jetbrains/package.json new file mode 100644 index 0000000000..9420936128 --- /dev/null +++ b/packages/cursorless-jetbrains/package.json @@ -0,0 +1,43 @@ +{ + "name": "@cursorless/cursorless-jetbrains", + "version": "1.0.0", + "description": "cursorless in jetbrains", + "main": "./out/cursorless.js", + "private": true, + "type": "module", + "scripts": { + "build": "pnpm run esbuild:prod && pnpm run populate-dist", + "compile": "tsc --build", + "esbuild:base": "esbuild ./src/index.ts --format=esm --target=es2020 --conditions=cursorless:bundler --bundle --main-fields=main,module --outfile=./out/cursorless.js --platform=neutral --external:std --external:fs --external:path", + "esbuild": "pnpm run esbuild:base --sourcemap", + "esbuild:prod": "pnpm run esbuild:base --minify", + "populate-dist": "bash ./scripts/populate-dist.sh", + "populate-dist:prod": "bash ./scripts/populate-dist.sh", + "watch:tsc": "pnpm compile --watch", + "watch:esbuild": "pnpm esbuild --watch", + "watch": "pnpm run --filter @cursorless/cursorless-jetbrains --parallel '/^watch:.*/'", + "clean": "rm -rf ./out tsconfig.tsbuildinfo ./dist ./build" + }, + "keywords": [], + "author": "", + "license": "MIT", + "types": "./out/index.d.ts", + "exports": { + ".": { + "cursorless:bundler": "./src/index.ts", + "default": "./out/index.cjs" + } + }, + "dependencies": { + "@cursorless/common": "workspace:*", + "@cursorless/cursorless-engine": "workspace:*", + "@cursorless/test-case-recorder": "workspace:*", + "lodash-es": "^4.17.21", + "vscode-uri": "^3.0.8", + "web-tree-sitter": "0.24.4" + }, + "devDependencies": { + "@types/chai": "^5.0.0", + "@types/lodash-es": "4.17.12" + } +} diff --git a/packages/cursorless-jetbrains/scripts/buildLocal.sh b/packages/cursorless-jetbrains/scripts/buildLocal.sh new file mode 100755 index 0000000000..8982d8eeac --- /dev/null +++ b/packages/cursorless-jetbrains/scripts/buildLocal.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +npm run compile && npm run esbuild + +cp out/cursorless.js ../../../cursorless-jetbrains/src/main/resources/cursorless/ diff --git a/packages/cursorless-jetbrains/scripts/populate-dist.sh b/packages/cursorless-jetbrains/scripts/populate-dist.sh new file mode 100755 index 0000000000..a8c27b8990 --- /dev/null +++ b/packages/cursorless-jetbrains/scripts/populate-dist.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "Populating dist directory..." + +echo "Nothing to do yet..." diff --git a/packages/cursorless-jetbrains/src/extension.ts b/packages/cursorless-jetbrains/src/extension.ts new file mode 100644 index 0000000000..fab1f08f10 --- /dev/null +++ b/packages/cursorless-jetbrains/src/extension.ts @@ -0,0 +1,34 @@ +import type { CursorlessEngine } from "@cursorless/cursorless-engine"; +import { createCursorlessEngine } from "@cursorless/cursorless-engine"; +import type { JetbrainsPlugin } from "./ide/JetbrainsPlugin"; +import Parser from "web-tree-sitter"; +import { JetbrainsTreeSitter } from "./ide/JetbrainsTreeSitter"; +import { JetbrainsTreeSitterQueryProvider } from "./ide/JetbrainsTreeSitterQueryProvider"; +import { pathJoin } from "./ide/pathJoin"; +import { JetbrainsCommandServer } from "./ide/JetbrainsCommandServer"; + +export async function activate( + plugin: JetbrainsPlugin, + wasmDirectory: string, +): Promise { + console.log("activate started"); + await Parser.init({ + locateFile(scriptName: string, _scriptDirectory: string) { + const fullPath = pathJoin(wasmDirectory, scriptName); + return fullPath; + }, + }); + console.log("Parser initialized"); + + const commandServerApi = new JetbrainsCommandServer(plugin.client); + const queryProvider = new JetbrainsTreeSitterQueryProvider(plugin.ide); + const engine = await createCursorlessEngine({ + ide: plugin.ide, + hats: plugin.hats, + treeSitterQueryProvider: queryProvider, + treeSitter: new JetbrainsTreeSitter(wasmDirectory), + commandServerApi: commandServerApi, + }); + console.log("activate completed"); + return engine; +} diff --git a/packages/cursorless-jetbrains/src/ide/JetbrainsCapabilities.ts b/packages/cursorless-jetbrains/src/ide/JetbrainsCapabilities.ts new file mode 100644 index 0000000000..8d4d8b1f14 --- /dev/null +++ b/packages/cursorless-jetbrains/src/ide/JetbrainsCapabilities.ts @@ -0,0 +1,25 @@ +import type { Capabilities, CommandCapabilityMap } from "@cursorless/common"; + +const COMMAND_CAPABILITIES: CommandCapabilityMap = { + clipboardCopy: { acceptsLocation: true }, + clipboardPaste: true, + toggleLineComment: { acceptsLocation: true }, + indentLine: { acceptsLocation: true }, + outdentLine: { acceptsLocation: true }, + rename: { acceptsLocation: true }, + quickFix: { acceptsLocation: true }, + revealDefinition: { acceptsLocation: true }, + revealTypeDefinition: { acceptsLocation: true }, + showHover: undefined, + showDebugHover: undefined, + extractVariable: { acceptsLocation: true }, + fold: { acceptsLocation: true }, + highlight: { acceptsLocation: true }, + unfold: { acceptsLocation: true }, + showReferences: { acceptsLocation: true }, + insertLineAfter: { acceptsLocation: true }, +}; + +export class JetbrainsCapabilities implements Capabilities { + commands = COMMAND_CAPABILITIES; +} diff --git a/packages/cursorless-jetbrains/src/ide/JetbrainsClient.ts b/packages/cursorless-jetbrains/src/ide/JetbrainsClient.ts new file mode 100644 index 0000000000..1ac027e3b3 --- /dev/null +++ b/packages/cursorless-jetbrains/src/ide/JetbrainsClient.ts @@ -0,0 +1,14 @@ +export interface JetbrainsClient { + prePhraseVersion(): string | PromiseLike | null; + clipboardCopy(editorId: string, rangesJson: string): void; + clipboardPaste(editorId: string): void; + hatsUpdated(hatsJson: string): void; + documentUpdated(editorId: string, updateJson: string): void; + setSelection(editorId: string, selectionJson: string): void; + executeCommand(editorId: string, command: string, jsonArgs: string): string; + executeRangeCommand(editorId: string, commandJson: string): string; + insertLineAfter(editorId: string, rangesJson: string): void; + revealLine(editorId: string, line: number, revealAt: string): void; + readQuery(filename: string): string | undefined; + flashRanges(flashRangesJson: string): string | undefined; +} diff --git a/packages/cursorless-jetbrains/src/ide/JetbrainsClipboard.ts b/packages/cursorless-jetbrains/src/ide/JetbrainsClipboard.ts new file mode 100644 index 0000000000..1c94b825fd --- /dev/null +++ b/packages/cursorless-jetbrains/src/ide/JetbrainsClipboard.ts @@ -0,0 +1,23 @@ +import type { Clipboard, Range } from "@cursorless/common"; +import type { JetbrainsClient } from "./JetbrainsClient"; + +export class JetbrainsClipboard implements Clipboard { + constructor(private client: JetbrainsClient) {} + + async readText(): Promise { + return ""; + } + + async writeText(_value: string): Promise { + return; + } + + async copy(editorId: string, ranges: Range[]): Promise { + const rangesJson = JSON.stringify(ranges); + this.client.clipboardCopy(editorId, rangesJson); + } + + async paste(editorId: string): Promise { + this.client.clipboardPaste(editorId); + } +} diff --git a/packages/cursorless-jetbrains/src/ide/JetbrainsCommandServer.ts b/packages/cursorless-jetbrains/src/ide/JetbrainsCommandServer.ts new file mode 100644 index 0000000000..af97926609 --- /dev/null +++ b/packages/cursorless-jetbrains/src/ide/JetbrainsCommandServer.ts @@ -0,0 +1,25 @@ +import type { JetbrainsClient } from "./JetbrainsClient"; +import type { + CommandServerApi, + FocusedElementType, + InboundSignal, +} from "@cursorless/common"; + +export class JetbrainsCommandServer implements CommandServerApi { + private client!: JetbrainsClient; + readonly signals: { prePhrase: InboundSignal } = { + prePhrase: { + getVersion: async () => { + return this.client.prePhraseVersion(); + }, + }, + }; + + constructor(client: JetbrainsClient) { + this.client = client; + } + + getFocusedElementType(): Promise { + return Promise.resolve(undefined); + } +} diff --git a/packages/cursorless-jetbrains/src/ide/JetbrainsConfiguration.ts b/packages/cursorless-jetbrains/src/ide/JetbrainsConfiguration.ts new file mode 100644 index 0000000000..803aaa09b4 --- /dev/null +++ b/packages/cursorless-jetbrains/src/ide/JetbrainsConfiguration.ts @@ -0,0 +1,42 @@ +import type { + Configuration, + ConfigurationScope, + CursorlessConfiguration, +} from "@cursorless/common"; +import { CONFIGURATION_DEFAULTS } from "@cursorless/common"; +import type { GetFieldType, Paths } from "@cursorless/common"; +import { Notifier } from "@cursorless/common"; +import { get } from "lodash-es"; + +export class JetbrainsConfiguration implements Configuration { + private notifier = new Notifier(); + private configuration: CursorlessConfiguration = CONFIGURATION_DEFAULTS; + + constructor(configuration: CursorlessConfiguration) { + this.configuration = configuration; + } + + getOwnConfiguration>( + path: Path, + _scope?: ConfigurationScope, + ): GetFieldType { + return get(this.configuration, path) as GetFieldType< + CursorlessConfiguration, + Path + >; + } + + onDidChangeConfiguration = this.notifier.registerListener; + + updateConfiguration(configuration: CursorlessConfiguration) { + this.configuration = configuration; + this.notifier.notifyListeners(); + } +} + +export function createJetbrainsConfiguration( + configuration: CursorlessConfiguration, +): JetbrainsConfiguration { + return new JetbrainsConfiguration(configuration); +} + diff --git a/packages/cursorless-jetbrains/src/ide/JetbrainsEditor.ts b/packages/cursorless-jetbrains/src/ide/JetbrainsEditor.ts new file mode 100644 index 0000000000..580cfe541b --- /dev/null +++ b/packages/cursorless-jetbrains/src/ide/JetbrainsEditor.ts @@ -0,0 +1,230 @@ +import type { Selection } from "@cursorless/common"; +import { + selectionsEqual, + type BreakpointDescriptor, + type Edit, + type EditableTextEditor, + type InMemoryTextDocument, + type OpenLinkOptions, + type Range, + type RevealLineAt, + type SetSelectionsOpts, + type TextEditor, + type TextEditorOptions, +} from "@cursorless/common"; +import { setSelections } from "./setSelections"; +import type { JetbrainsIDE } from "./JetbrainsIDE"; +import { jetbrainsPerformEdits } from "./jetbrainsPerformEdits"; +import type { JetbrainsClient } from "./JetbrainsClient"; +import { JetbrainsEditorCommand } from "./JetbrainsEditorCommand"; + +export class JetbrainsEditor implements EditableTextEditor { + options: TextEditorOptions = { + tabSize: 4, + insertSpaces: true, + }; + + isActive = true; + isVisible = true; + isEditable = true; + + constructor( + private client: JetbrainsClient, + private ide: JetbrainsIDE, + public id: string, + public document: InMemoryTextDocument, + public visibleRanges: Range[], + public selections: Selection[], + ) {} + + isEqual(other: TextEditor): boolean { + return this.id === other.id; + } + + async setSelections( + selections: Selection[], + _opts?: SetSelectionsOpts, + ): Promise { + // console.log("editor.setSelections"); + if (!selectionsEqual(this.selections, selections)) { + await setSelections(this.client, this.document, this.id, selections); + this.selections = selections; + } + } + + edit(edits: Edit[]): Promise { + // console.log("editor.edit"); + jetbrainsPerformEdits(this.client, this.ide, this.document, this.id, edits); + return Promise.resolve(true); + } + + async clipboardCopy(ranges: Range[]): Promise { + await this.ide.clipboard.copy(this.id, ranges); + } + + async clipboardPaste(): Promise { + await this.ide.clipboard.paste(this.id); + } + + async indentLine(ranges: Range[]): Promise { + const command = new JetbrainsEditorCommand( + ranges ? ranges : [], + true, + true, + "EditorIndentSelection", + ); + await this.client.executeRangeCommand(this.id, JSON.stringify(command)); + } + + async outdentLine(ranges: Range[]): Promise { + const command = new JetbrainsEditorCommand( + ranges ? ranges : [], + true, + true, + "EditorUnindentSelection", + ); + await this.client.executeRangeCommand(this.id, JSON.stringify(command)); + } + + async insertLineAfter(ranges?: Range[]): Promise { + await this.client.insertLineAfter(this.id, JSON.stringify(ranges)); + } + + focus(): Promise { + throw new Error("focus not implemented."); + } + + revealRange(_range: Range): Promise { + return Promise.resolve(); + } + + async revealLine(lineNumber: number, at: RevealLineAt): Promise { + await this.client.revealLine(this.id, lineNumber, at); + } + + openLink( + _range: Range, + _options?: OpenLinkOptions | undefined, + ): Promise { + throw new Error("openLink not implemented."); + } + + async fold(ranges?: Range[] | undefined): Promise { + const command = new JetbrainsEditorCommand( + ranges ? ranges : [], + true, + false, + "CollapseRegion", + ); + await this.client.executeRangeCommand(this.id, JSON.stringify(command)); + } + + async unfold(ranges?: Range[] | undefined): Promise { + const command = new JetbrainsEditorCommand( + ranges ? ranges : [], + true, + false, + "ExpandRegion", + ); + await this.client.executeRangeCommand(this.id, JSON.stringify(command)); + } + + async toggleBreakpoint( + _descriptors?: BreakpointDescriptor[] | undefined, + ): Promise { + throw new Error("toggleBreakpoint not implemented."); + } + + async toggleLineComment(ranges?: Range[] | undefined): Promise { + const command = new JetbrainsEditorCommand( + ranges ? ranges : [], + true, + false, + "CommentByLineComment", + ); + await this.client.executeRangeCommand(this.id, JSON.stringify(command)); + } + + insertSnippet( + _snippet: string, + _ranges?: Range[] | undefined, + ): Promise { + throw new Error("insertSnippet not implemented."); + } + + async rename(range?: Range | undefined): Promise { + const command = new JetbrainsEditorCommand( + range ? [range] : [], + true, + false, + "RenameElement", + ); + await this.client.executeRangeCommand(this.id, JSON.stringify(command)); + } + + async showReferences(range?: Range | undefined): Promise { + const command = new JetbrainsEditorCommand( + range ? [range] : [], + true, + false, + "FindUsages", + ); + await this.client.executeRangeCommand(this.id, JSON.stringify(command)); + } + + async quickFix(range?: Range | undefined): Promise { + const command = new JetbrainsEditorCommand( + range ? [range] : [], + true, + false, + "ShowIntentionActions", + ); + await this.client.executeRangeCommand(this.id, JSON.stringify(command)); + } + + async revealDefinition(range?: Range | undefined): Promise { + const command = new JetbrainsEditorCommand( + range ? [range] : [], + true, + false, + "GotoDeclaration", + ); + await this.client.executeRangeCommand(this.id, JSON.stringify(command)); + } + + async revealTypeDefinition(range?: Range | undefined): Promise { + const command = new JetbrainsEditorCommand( + range ? [range] : [], + true, + false, + "QuickImplementations", + ); + await this.client.executeRangeCommand(this.id, JSON.stringify(command)); + } + + showHover(_range?: Range | undefined): Promise { + throw new Error("showHover not implemented."); + } + + showDebugHover(_range?: Range | undefined): Promise { + throw new Error("showDebugHover not implemented."); + } + + async extractVariable(range?: Range | undefined): Promise { + const command = new JetbrainsEditorCommand( + range ? [range] : [], + true, + false, + "IntroduceVariable", + ); + await this.client.executeRangeCommand(this.id, JSON.stringify(command)); + } + + editNewNotebookCellAbove(): Promise { + throw new Error("editNewNotebookCellAbove not implemented."); + } + + editNewNotebookCellBelow(): Promise { + throw new Error("editNewNotebookCellBelow not implemented."); + } +} diff --git a/packages/cursorless-jetbrains/src/ide/JetbrainsEditorCommand.ts b/packages/cursorless-jetbrains/src/ide/JetbrainsEditorCommand.ts new file mode 100644 index 0000000000..9a057b9c10 --- /dev/null +++ b/packages/cursorless-jetbrains/src/ide/JetbrainsEditorCommand.ts @@ -0,0 +1,10 @@ +import type { Range } from "@cursorless/common"; + +export class JetbrainsEditorCommand { + constructor( + private ranges: Range[], + private singleRange: boolean, + private restoreSelection: boolean, + private ideCommand: string, + ) {} +} diff --git a/packages/cursorless-jetbrains/src/ide/JetbrainsEvents.ts b/packages/cursorless-jetbrains/src/ide/JetbrainsEvents.ts new file mode 100644 index 0000000000..78290ddf8c --- /dev/null +++ b/packages/cursorless-jetbrains/src/ide/JetbrainsEvents.ts @@ -0,0 +1,58 @@ +import type { + Disposable, + TextDocument, + TextDocumentChangeEvent, + TextDocumentContentChangeEvent, +} from "@cursorless/common"; +import { Position, Range } from "@cursorless/common"; + +export function jetbrainsOnDidChangeTextDocument( + _listener: (event: TextDocumentChangeEvent) => void, +): Disposable { + return dummyEvent(); +} + +export function jetbrainsOnDidOpenTextDocument( + _listener: (event: TextDocument) => any, + _thisArgs?: any, + _disposables?: Disposable[] | undefined, +): Disposable { + return dummyEvent(); +} + +export function fromJetbrainsContentChange( + document: TextDocument, + firstLine: number, + lastLine: number, + linedata: string[], +): TextDocumentContentChangeEvent[] { + const result = []; + const text = linedata.join("\n"); + // console.debug( + // `fromJetbrainsContentChange(): document.getText(): '${document.getText()}'`, + // ); + const range = new Range( + new Position(firstLine, 0), + new Position(lastLine - 1, document.lineAt(lastLine - 1).text.length), + ); + const rangeOffset = document.offsetAt(range.start); + const rangeLength = document.offsetAt(range.end) - rangeOffset; + result.push({ + range: range, + rangeOffset: rangeOffset, + rangeLength: rangeLength, + text: text, + }); + // console.debug( + // `fromJetbrainsContentChange(): changes=${JSON.stringify(result)}`, + // ); + return result; +} + +function dummyEvent() { + return { + dispose() { + // empty + }, + }; +} diff --git a/packages/cursorless-jetbrains/src/ide/JetbrainsFlashDescriptor.ts b/packages/cursorless-jetbrains/src/ide/JetbrainsFlashDescriptor.ts new file mode 100644 index 0000000000..065e927b8b --- /dev/null +++ b/packages/cursorless-jetbrains/src/ide/JetbrainsFlashDescriptor.ts @@ -0,0 +1,7 @@ +import type { GeneralizedRange } from "@cursorless/common"; + +export interface JetbrainsFlashDescriptor { + style: string; + editorId: string; + range: GeneralizedRange; +} diff --git a/packages/cursorless-jetbrains/src/ide/JetbrainsHats.ts b/packages/cursorless-jetbrains/src/ide/JetbrainsHats.ts new file mode 100644 index 0000000000..d39a0112e7 --- /dev/null +++ b/packages/cursorless-jetbrains/src/ide/JetbrainsHats.ts @@ -0,0 +1,130 @@ +import type { + Disposable, + HatRange, + Hats, + HatStyleInfo, + HatStyleMap, + Listener, +} from "@cursorless/common"; +import { Notifier } from "@cursorless/common"; +import type { JetbrainsClient } from "./JetbrainsClient"; +import type { JetbrainsHatRange } from "../types/jetbrains.types"; + +export class JetbrainsHats implements Hats { + private isEnabledNotifier: Notifier<[boolean]> = new Notifier(); + private hatStyleChangedNotifier: Notifier<[HatStyleMap]> = new Notifier(); + + private hatRanges: HatRange[] = []; + private client: JetbrainsClient; + enabledHatStyles: HatStyleMap; + private enabledHatShapes = ["default"]; + private hatShapePenalties: Map = new Map([["default", 0]]); + private enabledHatColors = ["default"]; + private hatColorPenalties: Map = new Map([["default", 0]]); + isEnabled: boolean = true; + + constructor(client: JetbrainsClient) { + this.client = client; + this.enabledHatStyles = this.generateHatStyles(); + } + + setHatRanges(hatRanges: HatRange[]): Promise { + // console.log("ASOEE/CL: JetbrainsHats.setHatRanges : " + hatRanges.length); + + this.hatRanges = hatRanges; + const jbHatRanges = this.toJetbransHatRanges(hatRanges); + const hatsJson = JSON.stringify(jbHatRanges); + // console.log("ASOEE/CL: JetbrainsHats.setHatRanges json: " + hatsJson); + this.client.hatsUpdated(hatsJson); + return Promise.resolve(); + } + + setEnabledHatShapes(enabledHatShapes: string[]): void { + this.enabledHatShapes = enabledHatShapes; + this.enabledHatStyles = this.generateHatStyles(); + this.hatStyleChangedNotifier.notifyListeners(this.enabledHatStyles); + } + + setHatShapePenalties(hatShapePenalties: Map): void { + // supplied map is a json map, and not a typescript map, so convert it to typed map + this.hatShapePenalties = new Map( + Object.entries(hatShapePenalties), + ); + this.enabledHatStyles = this.generateHatStyles(); + this.hatStyleChangedNotifier.notifyListeners(this.enabledHatStyles); + } + + setEnabledHatColors(enabledHatColors: string[]): void { + this.enabledHatColors = enabledHatColors; + this.enabledHatStyles = this.generateHatStyles(); + this.hatStyleChangedNotifier.notifyListeners(this.enabledHatStyles); + } + + setHatColorPenalties(hatColorPenalties: Map): void { + // supplied map is a json map, and not a typescript map, so convert it to typed map + this.hatColorPenalties = new Map( + Object.entries(hatColorPenalties), + ); + this.enabledHatStyles = this.generateHatStyles(); + this.hatStyleChangedNotifier.notifyListeners(this.enabledHatStyles); + } + + toJetbransHatRanges(hatRanges: HatRange[]): JetbrainsHatRange[] { + return hatRanges.map((range) => { + return { + styleName: range.styleName, + editorId: range.editor.id, + range: range.range, + }; + }); + } + + private generateHatStyles(): HatStyleMap { + const res = new Map(); + for (const color of this.enabledHatColors) { + const colorPenalty = this.getColorPenalty(color); + for (const shape of this.enabledHatShapes) { + const shapePenalty = this.getShapePenalty(shape); + let styleName: string; + if (shape === "default") { + styleName = color; + } else { + styleName = `${color}-${shape}`; + } + res.set(styleName, { penalty: colorPenalty + shapePenalty }); + } + } + return Object.fromEntries(res); + } + + private getShapePenalty(shape: string) { + let shapePenalty = this.hatShapePenalties.get(shape); + if (shapePenalty == null) { + shapePenalty = shape === "default" ? 0 : 2; + } else { + shapePenalty = shape === "default" ? shapePenalty : shapePenalty + 1; + } + return shapePenalty; + } + + private getColorPenalty(color: string) { + let colorPenalty = this.hatColorPenalties.get(color); + if (colorPenalty == null) { + colorPenalty = color === "default" ? 0 : 1; + } + return colorPenalty; + } + + onDidChangeEnabledHatStyles(listener: Listener<[HatStyleMap]>): Disposable { + return this.hatStyleChangedNotifier.registerListener(listener); + } + + onDidChangeIsEnabled(listener: Listener<[boolean]>): Disposable { + return this.isEnabledNotifier.registerListener(listener); + } + + toggle(isEnabled?: boolean) { + this.isEnabled = isEnabled ?? !this.isEnabled; + this.isEnabledNotifier.notifyListeners(this.isEnabled); + } +} diff --git a/packages/cursorless-jetbrains/src/ide/JetbrainsIDE.ts b/packages/cursorless-jetbrains/src/ide/JetbrainsIDE.ts new file mode 100644 index 0000000000..2bcf2936a2 --- /dev/null +++ b/packages/cursorless-jetbrains/src/ide/JetbrainsIDE.ts @@ -0,0 +1,334 @@ +import { InMemoryTextDocument, Notifier, Range } from "@cursorless/common"; +import type { + Event, + FlashDescriptor, + GeneralizedRange, + QuickPickOptions, + TextDocument, + TextDocumentContentChangeEvent, + TextEditorSelectionChangeEvent, + TextEditorVisibleRangesChangeEvent, + Disposable, + EditableTextEditor, + IDE, + OpenUntitledTextDocumentOptions, + RunMode, + TextDocumentChangeEvent, + TextEditor, + WorkspaceFolder, + NotebookEditor, +} from "@cursorless/common"; +import { pull } from "lodash-es"; +import { JetbrainsCapabilities } from "./JetbrainsCapabilities"; +import { fromJetbrainsContentChange } from "./JetbrainsEvents"; + +import type { JetbrainsClient } from "./JetbrainsClient"; +import { JetbrainsClipboard } from "./JetbrainsClipboard"; +import type { JetbrainsConfiguration } from "./JetbrainsConfiguration"; +import { JetbrainsMessages } from "./JetbrainsMessages"; +import { JetbrainsKeyValueStore } from "./JetbrainsKeyValueStore"; +import type { EditorState } from "../types/types"; +import { createSelection, createTextEditor } from "./createTextEditor"; +import { JetbrainsEditor } from "./JetbrainsEditor"; +import type { JetbrainsFlashDescriptor } from "./JetbrainsFlashDescriptor"; + +export class JetbrainsIDE implements IDE { + readonly configuration: JetbrainsConfiguration; + readonly keyValueStore: JetbrainsKeyValueStore; + readonly messages: JetbrainsMessages; + readonly clipboard: JetbrainsClipboard; + readonly capabilities: JetbrainsCapabilities; + readonly runMode: RunMode = "production"; + readonly visibleNotebookEditors: NotebookEditor[] = []; + // private editorMap; + // private documentMap; + private activeProject: Window | undefined; + private activeEditor: JetbrainsEditor | undefined; + + private disposables: Disposable[] = []; + private assetsRoot_: string | undefined; + private cursorlessJetbrainsPath: string | undefined; + private quickPickReturnValue: string | undefined = undefined; + + private editors: Map = new Map< + string, + JetbrainsEditor + >(); + + private onDidChangeTextDocumentNotifier: Notifier<[TextDocumentChangeEvent]> = + new Notifier(); + private onDidOpenTextDocumentNotifier: Notifier<[TextDocument]> = + new Notifier(); + + private onDidChangeTextDocumentContentNotifier: Notifier< + [TextDocumentContentChangeEvent] + > = new Notifier(); + + constructor( + private client: JetbrainsClient, + configuration: JetbrainsConfiguration, + ) { + this.configuration = configuration; + this.keyValueStore = new JetbrainsKeyValueStore(); + this.messages = new JetbrainsMessages(); + this.clipboard = new JetbrainsClipboard(this.client); + this.capabilities = new JetbrainsCapabilities(); + // this.editorMap = new Map(); + // this.documentMap = new Map(); + this.activeProject = undefined; + this.activeEditor = undefined; + } + + async init() {} + + async showQuickPick( + _items: readonly string[], + _options?: QuickPickOptions, + ): Promise { + throw Error("showQuickPick Not implemented"); + } + + async setHighlightRanges( + _highlightId: string | undefined, + _editor: TextEditor, + _ranges: GeneralizedRange[], + ): Promise { + throw Error("setHighlightRanges Not implemented"); + } + + async flashRanges(flashDescriptors: FlashDescriptor[]): Promise { + console.log("flashRangeses"); + const jbfs = flashDescriptors.map((flashDescriptor) => { + const jbf: JetbrainsFlashDescriptor = { + editorId: flashDescriptor.editor.id, + range: flashDescriptor.range, + style: flashDescriptor.style, + }; + return jbf; + }); + this.client.flashRanges(JSON.stringify(jbfs)); + } + + get assetsRoot(): string { + console.log("get assetsRoot"); + throw new Error("assetsRoot not implemented."); + } + + get cursorlessVersion(): string { + console.log("get cursorlessVersion"); + throw new Error("cursorlessVersion not implemented."); + } + + get workspaceFolders(): readonly WorkspaceFolder[] | undefined { + console.log("get workspaceFolders"); + throw new Error("workspaceFolders not get implemented."); + } + + get activeTextEditor(): TextEditor | undefined { + // console.log("get activeTextEditor"); + return this.activeEditor; + } + + get activeEditableTextEditor(): EditableTextEditor | undefined { + // console.log("get activeEditableTextEditor"); + return this.activeEditor?.isEditable ? this.activeEditor : undefined; + } + + get visibleTextEditors(): TextEditor[] { + // console.log("get visibleTextEditors"); + return [...this.editors.values()].filter((editor) => editor.isVisible); + } + + getEditableTextEditor(editor: TextEditor): EditableTextEditor { + // console.log("getEditableTextEditor"); + if (editor instanceof JetbrainsEditor) { + // console.log("getEditableTextEditor - return current"); + if (editor.isEditable) { + return editor; + } else { + throw Error(`Editor is not editable: ${editor}`); + } + } + throw Error(`Unsupported text editor type: ${editor}`); + } + + public async findInDocument( + _query: string, + _editor: TextEditor, + ): Promise { + throw Error("findInDocument Not implemented"); + } + + public async findInWorkspace(_query: string): Promise { + throw Error("findInWorkspace Not implemented"); + } + + public async openTextDocument(_path: string): Promise { + throw Error("openTextDocument Not implemented"); + } + + public async openUntitledTextDocument( + _options: OpenUntitledTextDocumentOptions, + ): Promise { + throw Error("openUntitledTextDocument Not implemented"); + } + + public async showInputBox(_options?: any): Promise { + throw Error("showInputBox Not implemented"); + } + + public async executeCommand( + _command: string, + ..._args: any[] + ): Promise { + throw new Error("executeCommand Method not implemented."); + } + + public onDidChangeTextDocument( + listener: (event: TextDocumentChangeEvent) => void, + ): Disposable { + return this.onDidChangeTextDocumentNotifier.registerListener(listener); + } + + public onDidOpenTextDocument( + listener: (event: TextDocument) => any, + _thisArgs?: any, + _disposables?: Disposable[] | undefined, + ): Disposable { + return this.onDidOpenTextDocumentNotifier.registerListener(listener); + } + onDidCloseTextDocument: Event = dummyEvent; + onDidChangeActiveTextEditor: Event = dummyEvent; + onDidChangeVisibleTextEditors: Event = dummyEvent; + onDidChangeTextEditorSelection: Event = + dummyEvent; + onDidChangeTextEditorVisibleRanges: Event = + dummyEvent; + + handleCommandError(_err: Error) { + // if (err instanceof OutdatedExtensionError) { + // this.showUpdateExtensionErrorMessage(err); + // } else { + // void showErrorMessage(this.client, err.message); + // } + } + + disposeOnExit(...disposables: Disposable[]): () => void { + this.disposables.push(...disposables); + + return () => pull(this.disposables, ...disposables); + } + + public documentClosed(editorId: string) { + this.editors.delete(editorId); + // console.log( + // "removed editor " + + // editorId + + // "remaining after change: " + + // this.editors.size, + // ); + } + + public documentCreated(editorStateJson: any) { + this.documentChanged(editorStateJson); + const editorState = editorStateJson as EditorState; + const editor = this.editors.get(editorState.id); + if (editor) { + this.onDidOpenTextDocumentNotifier.notifyListeners(editor.document); + } + } + + public documentChanged(editorStateJson: any) { + // console.log( + // "ASOEE/CL: documentChanged : " + JSON.stringify(editorStateJson), + // ); + const editorState = editorStateJson as EditorState; + + const editor = this.updateTextEditors(editorState); + + const linedata = getLines( + editorState.text, + editorState.firstVisibleLine, + editorState.lastVisibleLine, + ); + const contentChangeEvents = fromJetbrainsContentChange( + editor.document, + editorState.firstVisibleLine, + editorState.lastVisibleLine, + linedata, + ); + const documentChangeEvent: TextDocumentChangeEvent = { + document: editor.document, + contentChanges: contentChangeEvents, + }; + // console.log("ASOEE/CL: documentChanged : notify..."); + this.emitDidChangeTextDocument(documentChangeEvent); + // console.log("ASOEE/CL: documentChanged : notify complete"); + } + + emitDidChangeTextDocument(event: TextDocumentChangeEvent) { + this.onDidChangeTextDocumentNotifier.notifyListeners(event); + } + + updateTextEditors(editorState: EditorState): JetbrainsEditor { + let editor = this.editors.get(editorState.id); + if (editor) { + updateEditor(editor, editorState); + } else { + editor = createTextEditor(this.client, this, editorState); + this.editors.set(editorState.id, editor); + } + if (editorState.active) { + this.activeEditor = editor; + } + return editor; + } + + readQuery(filename: string): string | undefined { + return this.client.readQuery(filename); + } +} + +function updateEditor(editor: JetbrainsEditor, editorState: EditorState) { + // console.log("Updating editor " + editorState.id); + const oldDocument = editor.document; + editor.document = new InMemoryTextDocument( + oldDocument.uri, + oldDocument.languageId, + editorState.text, + ); + editor.visibleRanges = [ + new Range( + editorState.firstVisibleLine, + 0, + editorState.lastVisibleLine + 1, + 0, + ), + ]; + editor.selections = editorState.selections.map((selection) => + createSelection(editor.document, selection), + ); + editor.isActive = editorState.active; + editor.isVisible = editorState.visible; + editor.isEditable = editorState.editable; +} + +function getLines(text: string, firstLine: number, lastLine: number) { + const lines = text.split("\n"); + return lines.slice(firstLine, lastLine); +} + +function dummyEvent() { + return { + dispose() { + // empty + }, + }; +} + +export function createIDE( + client: JetbrainsClient, + configuration: JetbrainsConfiguration, +) { + return new JetbrainsIDE(client, configuration); +} diff --git a/packages/cursorless-jetbrains/src/ide/JetbrainsKeyValueStore.ts b/packages/cursorless-jetbrains/src/ide/JetbrainsKeyValueStore.ts new file mode 100644 index 0000000000..ca94a2675f --- /dev/null +++ b/packages/cursorless-jetbrains/src/ide/JetbrainsKeyValueStore.ts @@ -0,0 +1,22 @@ +import type { + KeyValueStore, + KeyValueStoreData, + KeyValueStoreKey, +} from "@cursorless/common"; +import { KEY_VALUE_STORE_DEFAULTS } from "@cursorless/common"; + +export class JetbrainsKeyValueStore implements KeyValueStore { + private readonly data: KeyValueStoreData = { ...KEY_VALUE_STORE_DEFAULTS }; + + get(key: K): KeyValueStoreData[K] { + return this.data[key]; + } + + set( + key: K, + value: KeyValueStoreData[K], + ): Promise { + this.data[key] = value; + return Promise.resolve(); + } +} diff --git a/packages/cursorless-jetbrains/src/ide/JetbrainsMessages.ts b/packages/cursorless-jetbrains/src/ide/JetbrainsMessages.ts new file mode 100644 index 0000000000..ac2b77781c --- /dev/null +++ b/packages/cursorless-jetbrains/src/ide/JetbrainsMessages.ts @@ -0,0 +1,12 @@ +import type { MessageId, Messages, MessageType } from "@cursorless/common"; + +export class JetbrainsMessages implements Messages { + async showMessage( + _type: MessageType, + _id: MessageId, + _message: string, + ..._options: string[] + ): Promise { + return undefined; + } +} diff --git a/packages/cursorless-jetbrains/src/ide/JetbrainsPlugin.ts b/packages/cursorless-jetbrains/src/ide/JetbrainsPlugin.ts new file mode 100644 index 0000000000..5cdc1f1f0f --- /dev/null +++ b/packages/cursorless-jetbrains/src/ide/JetbrainsPlugin.ts @@ -0,0 +1,19 @@ +import type { JetbrainsClient } from "./JetbrainsClient"; +import { JetbrainsHats } from "./JetbrainsHats"; +import type { JetbrainsIDE } from "./JetbrainsIDE"; + +export class JetbrainsPlugin { + constructor( + readonly client: JetbrainsClient, + readonly ide: JetbrainsIDE, + readonly hats: JetbrainsHats, + ) {} +} + +export function createPlugin( + client: JetbrainsClient, + ide: JetbrainsIDE, +): JetbrainsPlugin { + const hats = new JetbrainsHats(client); + return new JetbrainsPlugin(client, ide, hats); +} diff --git a/packages/cursorless-jetbrains/src/ide/JetbrainsTreeSitter.ts b/packages/cursorless-jetbrains/src/ide/JetbrainsTreeSitter.ts new file mode 100644 index 0000000000..b1ecedf226 --- /dev/null +++ b/packages/cursorless-jetbrains/src/ide/JetbrainsTreeSitter.ts @@ -0,0 +1,52 @@ +import type { Range, TextDocument, TreeSitter } from "@cursorless/common"; +import type { Language, SyntaxNode, Tree } from "web-tree-sitter"; +import Parser from "web-tree-sitter"; +import { pathJoin } from "./pathJoin"; + +export class JetbrainsTreeSitter implements TreeSitter { + constructor(private wasmDirectory: string) {} + + parsers = new Map(); + + getTree(document: TextDocument): Tree { + if (this.getLanguage(document.languageId)) { + const parser = this.parsers.get(document.languageId); + if (parser) { + return parser.parse(document.getText()); + } + } + throw new Error("Language not supported"); + } + + async loadLanguage(languageId: string): Promise { + // console.log(`Loading language ${languageId}`); + const parser = new Parser(); + const filePath = pathJoin( + this.wasmDirectory, + `tree-sitter-${languageId}.wasm`, + ); + const language = await Parser.Language.load(filePath); + parser.setLanguage(language); + this.parsers.set(languageId, parser); + return true; + } + + getLanguage(languageId: string): Language | undefined { + return this.parsers.get(languageId)?.getLanguage(); + } + + getNodeAtLocation(document: TextDocument, range: Range): SyntaxNode { + const tree = this.getTree(document); + const node = tree.rootNode.descendantForPosition( + { + row: range.start.line, + column: range.start.character, + }, + { + row: range.end.line, + column: range.end.character, + }, + ); + return node; + } +} diff --git a/packages/cursorless-jetbrains/src/ide/JetbrainsTreeSitterQueryProvider.ts b/packages/cursorless-jetbrains/src/ide/JetbrainsTreeSitterQueryProvider.ts new file mode 100644 index 0000000000..54b98484cf --- /dev/null +++ b/packages/cursorless-jetbrains/src/ide/JetbrainsTreeSitterQueryProvider.ts @@ -0,0 +1,27 @@ +import { + Notifier, + type Disposable, + type RawTreeSitterQueryProvider, +} from "@cursorless/common"; +import type { JetbrainsIDE } from "./JetbrainsIDE"; + +export class JetbrainsTreeSitterQueryProvider + implements RawTreeSitterQueryProvider +{ + private notifier: Notifier = new Notifier(); + private disposables: Disposable[] = []; + + constructor(private ide: JetbrainsIDE) {} + + onChanges = this.notifier.registerListener; + + async readQuery(filename: string): Promise { + // console.log("readQuery", filename); + const queryContents = await this.ide.readQuery(filename); + return queryContents; + } + + dispose() { + this.disposables.forEach((disposable) => disposable.dispose()); + } +} diff --git a/packages/cursorless-jetbrains/src/ide/createTextEditor.ts b/packages/cursorless-jetbrains/src/ide/createTextEditor.ts new file mode 100644 index 0000000000..ea6292d763 --- /dev/null +++ b/packages/cursorless-jetbrains/src/ide/createTextEditor.ts @@ -0,0 +1,55 @@ +import { + InMemoryTextDocument, + Position, + Range, + Selection, + type TextDocument, +} from "@cursorless/common"; +import { URI } from "vscode-uri"; +import type { EditorState, JbPosition, JbSelection } from "../types/types"; +import { JetbrainsEditor } from "./JetbrainsEditor"; +import type { JetbrainsIDE } from "./JetbrainsIDE"; +import type { JetbrainsClient } from "./JetbrainsClient"; + +export function createTextEditor( + client: JetbrainsClient, + ide: JetbrainsIDE, + editorState: EditorState, +): JetbrainsEditor { + // console.log("createTextEditor"); + + const id = editorState.id; + const uri = URI.parse(`talon-jetbrains://${id}`); + const languageId = editorState.languageId ?? "plaintext"; + const document = new InMemoryTextDocument(uri, languageId, editorState.text); + const visibleRanges = [ + new Range(editorState.firstVisibleLine, 0, editorState.lastVisibleLine, 0), + ]; + const selections = editorState.selections.map((selection) => + createSelection(document, selection), + ); + + return new JetbrainsEditor( + client, + ide, + id, + document, + visibleRanges, + selections, + ); +} + +export function createSelection( + document: TextDocument, + selection: JbSelection, +): Selection { + // console.log("createSelection " + JSON.stringify(selection)); + return new Selection( + createPosition(selection.anchor), + createPosition(selection.active), + ); +} + +export function createPosition(jbPosition: JbPosition): Position { + return new Position(jbPosition.line, jbPosition.column); +} diff --git a/packages/cursorless-jetbrains/src/ide/jetbrainsPerformEdits.ts b/packages/cursorless-jetbrains/src/ide/jetbrainsPerformEdits.ts new file mode 100644 index 0000000000..7e7c6a6ab8 --- /dev/null +++ b/packages/cursorless-jetbrains/src/ide/jetbrainsPerformEdits.ts @@ -0,0 +1,32 @@ +import type { Edit } from "@cursorless/common"; +import { type InMemoryTextDocument } from "@cursorless/common"; +import type { EditorEdit } from "../types/types"; +import type { JetbrainsIDE } from "./JetbrainsIDE"; +import type { JetbrainsClient } from "./JetbrainsClient"; + +export function jetbrainsPerformEdits( + client: JetbrainsClient, + ide: JetbrainsIDE, + document: InMemoryTextDocument, + id: string, + edits: Edit[], +) { + const changes = document.edit(edits); + + const editorEdit: EditorEdit = { + text: document.text, + changes: changes.map((change) => ({ + rangeOffset: change.rangeOffset, + rangeLength: change.rangeLength, + text: change.text, + })), + }; + + client.documentUpdated(id, JSON.stringify(editorEdit)); + //jetbrains.actions.user.cursorless_everywhere_edit_text(editorEdit); + + ide.emitDidChangeTextDocument({ + document, + contentChanges: changes, + }); +} diff --git a/packages/cursorless-jetbrains/src/ide/pathJoin.ts b/packages/cursorless-jetbrains/src/ide/pathJoin.ts new file mode 100644 index 0000000000..4dc6e56151 --- /dev/null +++ b/packages/cursorless-jetbrains/src/ide/pathJoin.ts @@ -0,0 +1,11 @@ +export function pathJoin(...segments: string[]): string { + return segments.join(pathSep()); +} + +function pathSep() { + if (/^win/i.test(process.platform)) { + return "\\"; + } else { + return "/"; + } +} diff --git a/packages/cursorless-jetbrains/src/ide/setSelections.ts b/packages/cursorless-jetbrains/src/ide/setSelections.ts new file mode 100644 index 0000000000..80905572c6 --- /dev/null +++ b/packages/cursorless-jetbrains/src/ide/setSelections.ts @@ -0,0 +1,15 @@ +import type { Selection, TextDocument } from "@cursorless/common"; +import type { JetbrainsClient } from "./JetbrainsClient"; + +export function setSelections( + client: JetbrainsClient, + document: TextDocument, + editorId: string, + selections: Selection[], +): Promise { + // console.log("setSelections: " + selections); + const selectionsJson = JSON.stringify(selections); + // console.log("setSelections JSON: " + selectionsJson); + client.setSelection(editorId, selectionsJson); + return Promise.resolve(); +} diff --git a/packages/cursorless-jetbrains/src/index.ts b/packages/cursorless-jetbrains/src/index.ts new file mode 100644 index 0000000000..0c195aa550 --- /dev/null +++ b/packages/cursorless-jetbrains/src/index.ts @@ -0,0 +1,4 @@ +export * from "./ide/JetbrainsPlugin"; +export * from "./ide/JetbrainsConfiguration"; +export * from "./ide/JetbrainsIDE"; +export * from "./extension"; diff --git a/packages/cursorless-jetbrains/src/polyfill.ts b/packages/cursorless-jetbrains/src/polyfill.ts new file mode 100644 index 0000000000..f165d08d6e --- /dev/null +++ b/packages/cursorless-jetbrains/src/polyfill.ts @@ -0,0 +1,25 @@ +const global = globalThis as any; + +// process.env is used by `immer` +if (global.process == null) { + global.process = { + env: {}, + }; +} + +// Allows us to use `console.*` with quickjs +// if (typeof print !== "undefined") { +// global.console = { +// log: print, +// error: print, +// warn: print, +// debug: print, +// }; +// } + +// In quickjs `setTimeout` is not available. +// FIXME: Remove dependency on `setTimeout` in the future. +// https://github.com/cursorless-dev/cursorless/issues/2596 +global.setTimeout = (callback: () => void, _delay: number) => { + callback(); +}; diff --git a/packages/cursorless-jetbrains/src/settimeout-polyfill.ts b/packages/cursorless-jetbrains/src/settimeout-polyfill.ts new file mode 100644 index 0000000000..08f59e28bc --- /dev/null +++ b/packages/cursorless-jetbrains/src/settimeout-polyfill.ts @@ -0,0 +1,5 @@ +const global = globalThis as any; + +global.setTimeout = (callback: () => void, _delay: number) => { + callback(); +}; diff --git a/packages/cursorless-jetbrains/src/testing/JetbrainsTesthelpers.ts b/packages/cursorless-jetbrains/src/testing/JetbrainsTesthelpers.ts new file mode 100644 index 0000000000..19b4e183bb --- /dev/null +++ b/packages/cursorless-jetbrains/src/testing/JetbrainsTesthelpers.ts @@ -0,0 +1,22 @@ +import type { + Command, + CommandResponse, + IDE, + NormalizedIDE, + TestHelpers, +} from "@cursorless/common"; +import type { JetbrainsIDE } from "../ide/JetbrainsIDE"; +import type { StoredTargetMap } from "@cursorless/cursorless-engine"; + +export interface JetbrainsTestHelpers + extends Omit { + talonJsIDE: JetbrainsIDE; + ide: NormalizedIDE; + storedTargets: StoredTargetMap; + injectIde: (ide: IDE) => void; + runCommand(command: Command): Promise; +} + +export interface ActivateReturnValue { + testHelpers?: JetbrainsTestHelpers; +} diff --git a/packages/cursorless-jetbrains/src/types/jetbrains.types.ts b/packages/cursorless-jetbrains/src/types/jetbrains.types.ts new file mode 100644 index 0000000000..648f9077e6 --- /dev/null +++ b/packages/cursorless-jetbrains/src/types/jetbrains.types.ts @@ -0,0 +1,91 @@ +import type { Range } from "@cursorless/common"; +import type { EditorEdit, EditorState, SelectionOffsets } from "./types"; + +export type JetbrainsNamespace = "user"; + +export interface JetbrainsActions { + app: { + notify(body: string, title: string): void; + }; + clip: { + set_text(text: string): void; + text(): string; + }; + edit: { + find(text?: string): void; + }; + user: { + cursorless_everywhere_get_editor_state(): EditorState; + cursorless_everywhere_set_selections(selections: SelectionOffsets[]): void; + cursorless_everywhere_edit_text(edit: EditorEdit): void; + }; +} + +export interface JetbrainsContextActions { + /** + * Executes an RPC command and waits for the result. + * This function is useful when the result of the command is needed + * immediately after execution. + * + * @param commandId - The identifier of the command to be executed. + * @param command - The command object containing necessary parameters. + * @returns A Promise that resolves with the result of the command execution. + */ + private_cursorless_jetbrains_run_and_wait( + commandId: string, + command: unknown, + ): Promise; + /** + * Executes an RPC command without waiting for the result. + * This function is useful for fire-and-forget operations where + * the result is not immediately needed. + * + * @param commandId - The identifier of the command to be executed. + * @param command - The command object containing necessary parameters. + */ + private_cursorless_jetbrains_run_no_wait( + commandId: string, + command: unknown, + ): void; + /** + * Retrieves the response json from the last RPC command execution. + * + * This is useful because Jetbrains doesn't have a way to read the responses from promises, + * but it does wait for them, so we store the response in a global variable and let it be + * read by this action. + * + * @returns The most recent response from an RPC command (JSON stringified). + */ + private_cursorless_jetbrains_get_response_json(): string; +} + +export interface JetbrainsContext { + matches: string; + tags: string[]; + settings: Record; + lists: Record | string[]>; + action_class(name: "user", actions: JetbrainsContextActions): void; +} + +export interface JetbrainsSettings { + get( + name: string, + defaultValue?: T, + ): T | null; +} + +interface JetbrainsContextConstructor { + new (): JetbrainsContext; +} + +export interface Jetbrains { + readonly actions: JetbrainsActions; + readonly settings: JetbrainsSettings; + Context: JetbrainsContextConstructor; +} + +export interface JetbrainsHatRange { + styleName: string; + editorId: string; + range: Range; +} diff --git a/packages/cursorless-jetbrains/src/types/types.ts b/packages/cursorless-jetbrains/src/types/types.ts new file mode 100644 index 0000000000..b8a4ba44fe --- /dev/null +++ b/packages/cursorless-jetbrains/src/types/types.ts @@ -0,0 +1,45 @@ +export interface SelectionOffsets { + // Document offsets + anchor: number; + active: number; +} + +export interface JbSelection { + start: JbPosition; + end: JbPosition; + cursorPosition: JbPosition; + anchor: JbPosition; + active: JbPosition; +} + +export interface JbPosition { + line: number; + column: number; +} + +export interface EditorState { + id: string; + text: string; + languageId?: string; + firstVisibleLine: number; + lastVisibleLine: number; + selections: JbSelection[]; + active: boolean; + visible: boolean; + editable: boolean; +} + +export interface EditorChange { + readonly text: string; + readonly rangeOffset: number; + readonly rangeLength: number; +} + +export interface EditorEdit { + /** + * The new document content after the edit. We provide this for platforms + * where we can't easily handle {@link changes}. + */ + text: string; + changes: EditorChange[]; +} diff --git a/packages/cursorless-jetbrains/tsconfig.json b/packages/cursorless-jetbrains/tsconfig.json new file mode 100644 index 0000000000..3782b54e28 --- /dev/null +++ b/packages/cursorless-jetbrains/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2020" + }, + "references": [ + { + "path": "../common" + }, + { + "path": "../cursorless-engine" + }, + { + "path": "../test-case-recorder" + } + ], + "include": ["src/**/*.ts", "src/**/*.json", "../../typings/**/object.d.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f879ab8f41..1ca78476d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,7 +115,7 @@ importers: version: 29.7.0 ts-jest: specifier: 29.2.5 - version: 29.2.5(@babel/core@7.25.8)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.8))(esbuild@0.24.0)(jest@29.7.0(@types/node@20.16.0)(ts-node@10.9.2(@types/node@20.16.0)(typescript@5.6.3)))(typescript@5.6.3) + version: 29.2.5(@babel/core@7.25.8)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.8))(esbuild@0.24.0)(jest@29.7.0(@types/node@20.16.0))(typescript@5.6.3) typescript: specifier: ^5.6.3 version: 5.6.3 @@ -375,6 +375,34 @@ importers: specifier: ^10.7.3 version: 10.7.3 + packages/cursorless-jetbrains: + dependencies: + '@cursorless/common': + specifier: workspace:* + version: link:../common + '@cursorless/cursorless-engine': + specifier: workspace:* + version: link:../cursorless-engine + '@cursorless/test-case-recorder': + specifier: workspace:* + version: link:../test-case-recorder + lodash-es: + specifier: ^4.17.21 + version: 4.17.21 + vscode-uri: + specifier: ^3.0.8 + version: 3.0.8 + web-tree-sitter: + specifier: 0.24.4 + version: 0.24.4 + devDependencies: + '@types/chai': + specifier: ^5.0.0 + version: 5.0.0 + '@types/lodash-es': + specifier: 4.17.12 + version: 4.17.12 + packages/cursorless-neovim: dependencies: '@cursorless/common': @@ -9768,6 +9796,9 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-tree-sitter@0.24.4: + resolution: {integrity: sha512-sETP1Sf9OTd4LusrKBNznNgTt3fWoWhJnAFaKPiGSeVKXJbZ72qoMpxddKMdVI5BgXv32OI7tkKQre5PmF9reA==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -20992,7 +21023,7 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.2.5(@babel/core@7.25.8)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.8))(esbuild@0.24.0)(jest@29.7.0(@types/node@20.16.0)(ts-node@10.9.2(@types/node@20.16.0)(typescript@5.6.3)))(typescript@5.6.3): + ts-jest@29.2.5(@babel/core@7.25.8)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.8))(esbuild@0.24.0)(jest@29.7.0(@types/node@20.16.0))(typescript@5.6.3): dependencies: bs-logger: 0.2.6 ejs: 3.1.10 @@ -21376,6 +21407,8 @@ snapshots: web-namespaces@2.0.1: {} + web-tree-sitter@0.24.4: {} + webidl-conversions@3.0.1: {} webidl-conversions@7.0.0: {} diff --git a/tsconfig.json b/tsconfig.json index 20ea84c510..29f9d90f93 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,6 +26,9 @@ { "path": "./packages/cursorless-everywhere-talon-e2e" }, + { + "path": "./packages/cursorless-jetbrains" + }, { "path": "./packages/cursorless-neovim" },