diff --git a/cursorless-talon/src/apps/cursorless_vscode.py b/cursorless-talon/src/apps/cursorless_vscode.py index 1f6e43fe8d..0956c89df9 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.key("right") + + 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/cheatsheet/sections/get_scope_visualizer.py b/cursorless-talon/src/cheatsheet/sections/get_scope_visualizer.py index b7483ae96c..69115fbe1e 100644 --- a/cursorless-talon/src/cheatsheet/sections/get_scope_visualizer.py +++ b/cursorless-talon/src/cheatsheet/sections/get_scope_visualizer.py @@ -24,4 +24,14 @@ def get_scope_visualizer(): ], ], }, + { + "id": "show_scope_sidebar", + "type": "command", + "variations": [ + { + "spokenForm": "bar cursorless", + "description": "Show cursorless sidebar", + }, + ], + }, ] diff --git a/cursorless-talon/src/cursorless.py b/cursorless-talon/src/cursorless.py index 86147fb1eb..d64791219d 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 9b784f684c..d2fa383a64 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/docs/user/scope-sidebar.md b/docs/user/scope-sidebar.md new file mode 100644 index 0000000000..5d26688583 --- /dev/null +++ b/docs/user/scope-sidebar.md @@ -0,0 +1,3 @@ +# The Cursorless sidebar + +You can say `"bar cursorless"` to show the Cursorless sidebar. Currently, the sidebar just contains a section showing a list of all scopes, organized by whether they are present and supported in the active text editor. As you type, the list of present scopes will update in real time. Clicking on a scope will visualize it using the [scope visualizer](scope-visualizer.md). Note that for legacy scopes, we can't tell whether they are present in the active text editor, so we list them under a separate Legacy section. Clicking on these scopes will not visualize them, as we also don't support visualizing legacy scopes. diff --git a/images/logo.svg b/images/logo.svg new file mode 100644 index 0000000000..1f19410e82 --- /dev/null +++ b/images/logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/common/src/cursorlessCommandIds.ts b/packages/common/src/cursorlessCommandIds.ts index f1c69de9f5..fb9b0fc7de 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/common/src/cursorlessSideBarIds.ts b/packages/common/src/cursorlessSideBarIds.ts new file mode 100644 index 0000000000..c51bb17147 --- /dev/null +++ b/packages/common/src/cursorlessSideBarIds.ts @@ -0,0 +1 @@ +export const CURSORLESS_SCOPE_TREE_VIEW_ID = "cursorless.scopes"; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index f5292a2091..57b6bbb467 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,4 +1,5 @@ export * from "./cursorlessCommandIds"; +export * from "./cursorlessSideBarIds"; export * from "./testUtil/extractTargetedMarks"; export { default as FakeIDE } from "./ide/fake/FakeIDE"; export type { Message } from "./ide/spy/SpyMessages"; diff --git a/packages/cursorless-vscode/package.json b/packages/cursorless-vscode/package.json index eedff513e5..5a4efc12c4 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/logo.svg" + } + ] } }, "sponsor": { diff --git a/packages/cursorless-vscode/src/ScopeTreeProvider.ts b/packages/cursorless-vscode/src/ScopeTreeProvider.ts new file mode 100644 index 0000000000..21c1b02078 --- /dev/null +++ b/packages/cursorless-vscode/src/ScopeTreeProvider.ts @@ -0,0 +1,302 @@ +import { + CURSORLESS_SCOPE_TREE_VIEW_ID, + CursorlessCommandId, + ScopeProvider, + ScopeSupport, + ScopeSupportInfo, + ScopeTypeInfo, + disposableFrom, +} from "@cursorless/common"; +import { CustomSpokenFormGenerator } from "@cursorless/cursorless-engine"; +import { VscodeApi } from "@cursorless/vscode-common"; +import { isEqual } from "lodash"; +import type { + Disposable, + 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: Disposable | undefined; + private treeView: TreeView; + private supportLevels: ScopeSupportInfo[] = []; + 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, + ); + + 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 = disposableFrom( + 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 && + // Skip scope if it doesn't have a spoken form and it's private. That + // is the default state for scopes that are private; we don't want to + // show these to the user. + !( + 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 + ) { + // Scopes with no spoken form are sorted to the bottom + return a.scopeTypeInfo.spokenForm.type === "error" ? 1 : -1; + } + + if ( + a.scopeTypeInfo.isLanguageSpecific !== + b.scopeTypeInfo.isLanguageSpecific + ) { + // Then language-specific scopes are sorted to the top + return a.scopeTypeInfo.isLanguageSpecific ? -1 : 1; + } + + // Then alphabetical by label + return a.label.label.localeCompare(b.label.label); + }); + } + + dispose() { + this.visibleDisposable?.dispose(); + } +} + +function getSupportCategories(): SupportCategoryTreeItem[] { + return [ + new SupportCategoryTreeItem(ScopeSupport.supportedAndPresentInEditor), + new SupportCategoryTreeItem(ScopeSupport.supportedButNotPresentInEditor), + new SupportCategoryTreeItem(ScopeSupport.supportedLegacy), + new SupportCategoryTreeItem(ScopeSupport.unsupported), + ]; +} + +class ScopeSupportTreeItem extends TreeItem { + public readonly label!: TreeItemLabel; + + /** + * @param scopeTypeInfo The scope type info + * @param isVisualized Whether the scope type is currently being visualized + with the scope visualizer + */ + constructor( + public readonly 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; see [update instructions](https://www.cursorless.org/docs/user/updating/#updating-the-talon-side)" + : "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(public readonly scopeSupport: ScopeSupport) { + let label: string; + let description: string; + let collapsibleState: TreeItemCollapsibleState; + switch (scopeSupport) { + case ScopeSupport.supportedAndPresentInEditor: + label = "Present"; + description = "in active editor"; + collapsibleState = TreeItemCollapsibleState.Expanded; + break; + case ScopeSupport.supportedButNotPresentInEditor: + label = "Supported"; + description = "but not present in active editor"; + collapsibleState = TreeItemCollapsibleState.Expanded; + break; + case ScopeSupport.supportedLegacy: + label = "Legacy"; + description = "may or may not be present in active editor"; + collapsibleState = TreeItemCollapsibleState.Expanded; + break; + case ScopeSupport.unsupported: + label = "Unsupported"; + description = "unsupported in language of active editor"; + collapsibleState = TreeItemCollapsibleState.Collapsed; + break; + } + + super(label, collapsibleState); + this.description = description; + } +} + +type MyTreeItem = ScopeSupportTreeItem | SupportCategoryTreeItem; diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts index ccee23d147..54df310148 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -38,6 +38,7 @@ import { import { KeyboardCommands } from "./keyboard/KeyboardCommands"; import { registerCommands } from "./registerCommands"; import { ReleaseNotes } from "./ReleaseNotes"; +import { ScopeTreeProvider } from "./ScopeTreeProvider"; import { ScopeVisualizer, ScopeVisualizerListener, @@ -100,6 +101,15 @@ export async function activate( const keyboardCommands = KeyboardCommands.create(context, statusBarItem); const scopeVisualizer = createScopeVisualizer(normalizedIde, scopeProvider); + new ScopeTreeProvider( + 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 87f8c287fc..20e60d38fc 100644 --- a/packages/cursorless-vscode/src/ide/vscode/VscodeGlobalState.ts +++ b/packages/cursorless-vscode/src/ide/vscode/VscodeGlobalState.ts @@ -2,6 +2,7 @@ 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"; import { PERFORMED_PR_1868_SHAPE_UPDATE_INIT_KEY } from "./hats/performPr1868ShapeUpdateInit"; export default class VscodeGlobalState implements State { @@ -11,6 +12,7 @@ export default class VscodeGlobalState implements State { ...Object.keys(STATE_DEFAULTS), VERSION_KEY, PERFORMED_PR_1868_SHAPE_UPDATE_INIT_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 b628120496..dc5d09571d 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/logo.svg", destination: "images/logo.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 2abf53d185..d6a16d6f0a 100644 --- a/packages/meta-updater/src/getCursorlessVscodeFields.ts +++ b/packages/meta-updater/src/getCursorlessVscodeFields.ts @@ -1,4 +1,5 @@ import { + CURSORLESS_SCOPE_TREE_VIEW_ID, cursorlessCommandDescriptions, cursorlessCommandIds, } from "@cursorless/common"; @@ -21,6 +22,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_SCOPE_TREE_VIEW_ID}`, + // 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