diff --git a/cursorless-talon/src/apps/cursorless_vscode.py b/cursorless-talon/src/apps/cursorless_vscode.py index 7fda2ac7973..01451bb5e5a 100644 --- a/cursorless-talon/src/apps/cursorless_vscode.py +++ b/cursorless-talon/src/apps/cursorless_vscode.py @@ -32,3 +32,9 @@ def private_cursorless_show_settings_in_ide(): ) actions.sleep("250ms") actions.insert("cursorless") + + def private_cursorless_show_sidebar(): + """Show Cursorless sidebar""" + actions.user.private_cursorless_run_rpc_command_and_wait( + "workbench.view.extension.cursorless" + ) diff --git a/cursorless-talon/src/cursorless.py b/cursorless-talon/src/cursorless.py index 86147fb1ebe..d64791219d5 100644 --- a/cursorless-talon/src/cursorless.py +++ b/cursorless-talon/src/cursorless.py @@ -12,3 +12,6 @@ class Actions: def private_cursorless_show_settings_in_ide(): """Show Cursorless-specific settings in ide""" + + def private_cursorless_show_sidebar(): + """Show Cursorless-specific settings in ide""" diff --git a/cursorless-talon/src/cursorless.talon b/cursorless-talon/src/cursorless.talon index 9b784f684c0..d2fa383a64a 100644 --- a/cursorless-talon/src/cursorless.talon +++ b/cursorless-talon/src/cursorless.talon @@ -40,3 +40,6 @@ tag: user.cursorless {user.cursorless_homophone} settings: user.private_cursorless_show_settings_in_ide() + +bar {user.cursorless_homophone}: + user.private_cursorless_show_sidebar() diff --git a/images/icon.svg b/images/icon.svg new file mode 100644 index 00000000000..e7fb1c351db --- /dev/null +++ b/images/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/common/src/cursorlessCommandIds.ts b/packages/common/src/cursorlessCommandIds.ts index f1c69de9f55..fb9b0fc7dec 100644 --- a/packages/common/src/cursorlessCommandIds.ts +++ b/packages/common/src/cursorlessCommandIds.ts @@ -69,6 +69,12 @@ export const cursorlessCommandDescriptions: Record< "Resume test case recording", ), ["cursorless.showDocumentation"]: new VisibleCommand("Show documentation"), + ["cursorless.showScopeVisualizer"]: new VisibleCommand( + "Show the scope visualizer", + ), + ["cursorless.hideScopeVisualizer"]: new VisibleCommand( + "Hide the scope visualizer", + ), ["cursorless.command"]: new HiddenCommand("The core cursorless command"), ["cursorless.showQuickPick"]: new HiddenCommand( @@ -110,10 +116,4 @@ export const cursorlessCommandDescriptions: Record< ["cursorless.keyboard.modal.modeToggle"]: new HiddenCommand( "Toggle the cursorless modal mode", ), - ["cursorless.showScopeVisualizer"]: new HiddenCommand( - "Show the scope visualizer", - ), - ["cursorless.hideScopeVisualizer"]: new HiddenCommand( - "Hide the scope visualizer", - ), }; diff --git a/packages/cursorless-vscode/package.json b/packages/cursorless-vscode/package.json index 1f09ca8c2ec..711770b31d5 100644 --- a/packages/cursorless-vscode/package.json +++ b/packages/cursorless-vscode/package.json @@ -46,6 +46,7 @@ ], "activationEvents": [ "onLanguage", + "onView:cursorless.scopes", "onCommand:cursorless.command", "onCommand:cursorless.internal.updateCheatsheetDefaults", "onCommand:cursorless.keyboard.escape", @@ -77,6 +78,14 @@ } }, "contributes": { + "views": { + "cursorless": [ + { + "id": "cursorless.scopes", + "name": "Scopes" + } + ] + }, "commands": [ { "command": "cursorless.toggleDecorations", @@ -106,6 +115,14 @@ "command": "cursorless.showDocumentation", "title": "Cursorless: Show documentation" }, + { + "command": "cursorless.showScopeVisualizer", + "title": "Cursorless: Show the scope visualizer" + }, + { + "command": "cursorless.hideScopeVisualizer", + "title": "Cursorless: Hide the scope visualizer" + }, { "command": "cursorless.command", "title": "Cursorless: The core cursorless command", @@ -175,16 +192,6 @@ "command": "cursorless.keyboard.modal.modeToggle", "title": "Cursorless: Toggle the cursorless modal mode", "enablement": "false" - }, - { - "command": "cursorless.showScopeVisualizer", - "title": "Cursorless: Show the scope visualizer", - "enablement": "false" - }, - { - "command": "cursorless.hideScopeVisualizer", - "title": "Cursorless: Hide the scope visualizer", - "enablement": "false" } ], "colors": [ @@ -1032,6 +1039,15 @@ "fontCharacter": "\\E900" } } + }, + "viewsContainers": { + "activitybar": [ + { + "id": "cursorless", + "title": "Cursorless", + "icon": "images/icon.svg" + } + ] } }, "sponsor": { diff --git a/packages/cursorless-vscode/src/ScopeTreeProvider.ts b/packages/cursorless-vscode/src/ScopeTreeProvider.ts new file mode 100644 index 00000000000..def69ab0d33 --- /dev/null +++ b/packages/cursorless-vscode/src/ScopeTreeProvider.ts @@ -0,0 +1,311 @@ +import { + CursorlessCommandId, + Disposer, + ScopeProvider, + ScopeSupport, + ScopeSupportLevels, + ScopeTypeInfo, +} from "@cursorless/common"; +import { CustomSpokenFormGenerator } from "@cursorless/cursorless-engine"; +import { + CURSORLESS_SCOPE_TREE_VIEW_ID, + VscodeApi, +} from "@cursorless/vscode-common"; +import { isEqual } from "lodash"; +import type { + Event, + ExtensionContext, + TreeDataProvider, + TreeItemLabel, + TreeView, + TreeViewVisibilityChangeEvent, +} from "vscode"; +import { + EventEmitter, + MarkdownString, + ThemeIcon, + TreeItem, + TreeItemCollapsibleState, +} from "vscode"; +import { URI } from "vscode-uri"; +import { + ScopeVisualizer, + VisualizationType, +} from "./ScopeVisualizerCommandApi"; + +export const DONT_SHOW_TALON_UPDATE_MESSAGE_KEY = "dontShowUpdateTalonMessage"; + +export class ScopeTreeProvider implements TreeDataProvider { + private visibleDisposable: Disposer | undefined; + private treeView: TreeView; + private supportLevels: ScopeSupportLevels = []; + private shownUpdateTalonMessage = false; + + private _onDidChangeTreeData: EventEmitter< + MyTreeItem | undefined | null | void + > = new EventEmitter(); + readonly onDidChangeTreeData: Event = + this._onDidChangeTreeData.event; + + constructor( + private vscodeApi: VscodeApi, + private context: ExtensionContext, + private scopeProvider: ScopeProvider, + private scopeVisualizer: ScopeVisualizer, + private customSpokenFormGenerator: CustomSpokenFormGenerator, + private hasCommandServer: boolean, + ) { + this.treeView = vscodeApi.window.createTreeView( + CURSORLESS_SCOPE_TREE_VIEW_ID, + { + treeDataProvider: this, + }, + ); + + this.context.subscriptions.push( + this.treeView, + this.treeView.onDidChangeVisibility(this.onDidChangeVisible, this), + this, + ); + } + + static create( + vscodeApi: VscodeApi, + context: ExtensionContext, + scopeProvider: ScopeProvider, + scopeVisualizer: ScopeVisualizer, + customSpokenFormGenerator: CustomSpokenFormGenerator, + hasCommandServer: boolean, + ): ScopeTreeProvider { + const treeProvider = new ScopeTreeProvider( + vscodeApi, + context, + scopeProvider, + scopeVisualizer, + customSpokenFormGenerator, + hasCommandServer, + ); + treeProvider.init(); + return treeProvider; + } + + init() { + if (this.treeView.visible) { + this.registerScopeSupportListener(); + } + } + + onDidChangeVisible(e: TreeViewVisibilityChangeEvent) { + if (e.visible) { + if (this.visibleDisposable != null) { + return; + } + + this.registerScopeSupportListener(); + } else { + if (this.visibleDisposable == null) { + return; + } + + this.visibleDisposable.dispose(); + this.visibleDisposable = undefined; + } + } + + private registerScopeSupportListener() { + this.visibleDisposable = new Disposer(); + this.visibleDisposable.push( + this.scopeProvider.onDidChangeScopeSupport((supportLevels) => { + this.supportLevels = supportLevels; + this._onDidChangeTreeData.fire(); + }), + this.scopeVisualizer.onDidChangeScopeType(() => { + this._onDidChangeTreeData.fire(); + }), + ); + } + + getTreeItem(element: MyTreeItem): MyTreeItem { + return element; + } + + getChildren(element?: MyTreeItem): MyTreeItem[] { + if (element == null) { + this.possiblyShowUpdateTalonMessage(); + return getSupportCategories(); + } + + if (element instanceof SupportCategoryTreeItem) { + return this.getScopeTypesWithSupport(element.scopeSupport); + } + + throw new Error("Unexpected element"); + } + + private async possiblyShowUpdateTalonMessage() { + if ( + !this.customSpokenFormGenerator.needsInitialTalonUpdate || + this.shownUpdateTalonMessage || + !this.hasCommandServer || + (await this.context.globalState.get(DONT_SHOW_TALON_UPDATE_MESSAGE_KEY)) + ) { + return; + } + + this.shownUpdateTalonMessage = true; + + const HOW_BUTTON_TEXT = "How?"; + const DONT_SHOW_AGAIN_BUTTON_TEXT = "Don't show again"; + const result = await this.vscodeApi.window.showInformationMessage( + "In order to see your custom spoken forms in the sidebar, you'll need to update your Cursorless Talon files.", + HOW_BUTTON_TEXT, + DONT_SHOW_AGAIN_BUTTON_TEXT, + ); + + if (result === HOW_BUTTON_TEXT) { + await this.vscodeApi.env.openExternal( + URI.parse( + "https://www.cursorless.org/docs/user/updating/#updating-the-talon-side", + ), + ); + } else if (result === DONT_SHOW_AGAIN_BUTTON_TEXT) { + await this.context.globalState.update( + DONT_SHOW_TALON_UPDATE_MESSAGE_KEY, + true, + ); + } + } + + getScopeTypesWithSupport(scopeSupport: ScopeSupport): ScopeSupportTreeItem[] { + return this.supportLevels + .filter( + (supportLevel) => + supportLevel.support === scopeSupport && + (supportLevel.spokenForm.type !== "error" || + !supportLevel.spokenForm.isPrivate), + ) + .map( + (supportLevel) => + new ScopeSupportTreeItem( + supportLevel, + isEqual(supportLevel.scopeType, this.scopeVisualizer.scopeType), + ), + ) + .sort((a, b) => { + if ( + a.scopeTypeInfo.spokenForm.type !== b.scopeTypeInfo.spokenForm.type + ) { + return a.scopeTypeInfo.spokenForm.type === "error" ? 1 : -1; + } + + if ( + a.scopeTypeInfo.isLanguageSpecific !== + b.scopeTypeInfo.isLanguageSpecific + ) { + return a.scopeTypeInfo.isLanguageSpecific ? -1 : 1; + } + + return a.label.label.localeCompare(b.label.label); + }); + } + + dispose() { + this.visibleDisposable?.dispose(); + } +} + +function getSupportCategories(): SupportCategoryTreeItem[] { + return [ + new SupportCategoryTreeItem( + "Present", + ScopeSupport.supportedAndPresentInEditor, + TreeItemCollapsibleState.Expanded, + ), + new SupportCategoryTreeItem( + "Not present", + ScopeSupport.supportedButNotPresentInEditor, + TreeItemCollapsibleState.Expanded, + ), + new SupportCategoryTreeItem( + "Legacy", + ScopeSupport.supportedLegacy, + TreeItemCollapsibleState.Expanded, + ), + new SupportCategoryTreeItem( + "Unsupported", + ScopeSupport.unsupported, + TreeItemCollapsibleState.Collapsed, + ), + ]; +} + +class ScopeSupportTreeItem extends TreeItem { + public label!: TreeItemLabel; + + /** + * @param scopeTypeInfo The scope type info + * @param isVisualized Whether the scope type is currently being visualized + with the scope visualizer + */ + constructor( + public scopeTypeInfo: ScopeTypeInfo, + isVisualized: boolean, + ) { + let label: string; + let tooltip: string | undefined; + + if (scopeTypeInfo.spokenForm.type === "success") { + label = scopeTypeInfo.spokenForm.spokenForms + .map((spokenForm) => `"${spokenForm}"`) + .join(" | "); + } else { + label = "-"; + tooltip = scopeTypeInfo.spokenForm.requiresTalonUpdate + ? "Requires Talon update" + : "Spoken form disabled; see [customization docs](https://www.cursorless.org/docs/user/customization/#talon-side-settings)"; + } + + super( + { + label, + highlights: isVisualized ? [[0, label.length]] : [], + }, + TreeItemCollapsibleState.None, + ); + + this.tooltip = tooltip == null ? tooltip : new MarkdownString(tooltip); + this.description = scopeTypeInfo.humanReadableName; + + this.command = isVisualized + ? { + command: + "cursorless.hideScopeVisualizer" satisfies CursorlessCommandId, + title: "Hide the scope visualizer", + } + : { + command: + "cursorless.showScopeVisualizer" satisfies CursorlessCommandId, + arguments: [ + scopeTypeInfo.scopeType, + "content" satisfies VisualizationType, + ], + title: `Visualize ${scopeTypeInfo.humanReadableName}`, + }; + + if (scopeTypeInfo.isLanguageSpecific) { + this.iconPath = new ThemeIcon("code"); + } + } +} + +class SupportCategoryTreeItem extends TreeItem { + constructor( + label: string, + public readonly scopeSupport: ScopeSupport, + collapsibleState: TreeItemCollapsibleState, + ) { + super(label, collapsibleState); + } +} + +type MyTreeItem = ScopeSupportTreeItem | SupportCategoryTreeItem; diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts index 67d8233f578..af3affa0caf 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -35,6 +35,7 @@ import { import { KeyboardCommands } from "./keyboard/KeyboardCommands"; import { registerCommands } from "./registerCommands"; import { ReleaseNotes } from "./ReleaseNotes"; +import { ScopeTreeProvider } from "./ScopeTreeProvider"; import { ScopeVisualizer, ScopeVisualizerListener, @@ -97,6 +98,15 @@ export async function activate( const keyboardCommands = KeyboardCommands.create(context, statusBarItem); const scopeVisualizer = createScopeVisualizer(normalizedIde, scopeProvider); + ScopeTreeProvider.create( + vscodeApi, + context, + scopeProvider, + scopeVisualizer, + customSpokenFormGenerator, + commandServerApi != null, + ); + registerCommands( context, vscodeIDE, diff --git a/packages/cursorless-vscode/src/ide/vscode/VscodeGlobalState.ts b/packages/cursorless-vscode/src/ide/vscode/VscodeGlobalState.ts index 760524d893a..6e0f2744c97 100644 --- a/packages/cursorless-vscode/src/ide/vscode/VscodeGlobalState.ts +++ b/packages/cursorless-vscode/src/ide/vscode/VscodeGlobalState.ts @@ -1,7 +1,8 @@ -import type { ExtensionContext } from "vscode"; import type { State, StateData, StateKey } from "@cursorless/common"; import { STATE_DEFAULTS } from "@cursorless/common"; +import type { ExtensionContext } from "vscode"; import { VERSION_KEY } from "../../ReleaseNotes"; +import { DONT_SHOW_TALON_UPDATE_MESSAGE_KEY } from "../../ScopeTreeProvider"; export default class VscodeGlobalState implements State { constructor(private extensionContext: ExtensionContext) { @@ -9,6 +10,7 @@ export default class VscodeGlobalState implements State { extensionContext.globalState.setKeysForSync([ ...Object.keys(STATE_DEFAULTS), VERSION_KEY, + DONT_SHOW_TALON_UPDATE_MESSAGE_KEY, ]); } diff --git a/packages/cursorless-vscode/src/scripts/populateDist/assets.ts b/packages/cursorless-vscode/src/scripts/populateDist/assets.ts index b628120496c..d84df78ed22 100644 --- a/packages/cursorless-vscode/src/scripts/populateDist/assets.ts +++ b/packages/cursorless-vscode/src/scripts/populateDist/assets.ts @@ -25,6 +25,7 @@ export const assets: Asset[] = [ }, { source: "../../images/hats", destination: "images/hats" }, { source: "../../images/icon.png", destination: "images/icon.png" }, + { source: "../../images/icon.svg", destination: "images/icon.svg" }, { source: "../../schemas", destination: "schemas" }, { source: "../../third-party-licenses.csv", diff --git a/packages/meta-updater/src/getCursorlessVscodeFields.ts b/packages/meta-updater/src/getCursorlessVscodeFields.ts index 2abf53d185a..424eaf36342 100644 --- a/packages/meta-updater/src/getCursorlessVscodeFields.ts +++ b/packages/meta-updater/src/getCursorlessVscodeFields.ts @@ -21,6 +21,10 @@ export function getCursorlessVscodeFields(input: PackageJson) { // Causes extension to activate whenever any text editor is opened "onLanguage", + // Causes extension to activate when the Cursorless scope support side bar + // is opened + "onView:cursorless.scopes", + // Causes extension to activate when any Cursorless command is run. // Technically we don't need to do this since VSCode 1.74.0, but we support // older versions diff --git a/packages/vscode-common/src/cursorlessSideBarIds.ts b/packages/vscode-common/src/cursorlessSideBarIds.ts new file mode 100644 index 00000000000..c51bb171474 --- /dev/null +++ b/packages/vscode-common/src/cursorlessSideBarIds.ts @@ -0,0 +1 @@ +export const CURSORLESS_SCOPE_TREE_VIEW_ID = "cursorless.scopes"; diff --git a/packages/vscode-common/src/index.ts b/packages/vscode-common/src/index.ts index c6a679e3bc0..1b1db4c637d 100644 --- a/packages/vscode-common/src/index.ts +++ b/packages/vscode-common/src/index.ts @@ -5,3 +5,4 @@ export * from "./vscodeUtil"; export * from "./runCommand"; export * from "./VscodeApi"; export * from "./ScopeVisualizerColorConfig"; +export * from "./cursorlessSideBarIds";