extension: support follow cursor and sorting in package outline Package Outline now supports follow cursor, sort by name, and sort by position, matching the controls available in Outline. This change switches the Package Outline view to a TreeView so the active symbol can be revealed as the cursor moves. It also adds view actions for follow cursor and both sort modes, and keeps symbol ordering stable by name or source position. The cursor matching logic also handles receiver methods returned by gopls.package_symbols, where methods may be grouped under a type without being nested inside that type's source range. Fixes golang/vscode-go#3998 Change-Id: I0ed9cc1526dd84bac44a29cec918b53465b81274 GitHub-Last-Rev: 6f9c67f1bb2c1d5bdae45307a8670b075d990e37 GitHub-Pull-Request: golang/vscode-go#4008 Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/761181 Reviewed-by: Madeline Kalil <mkalil@google.com> LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Hongxiang Jiang <hxjiang@golang.org> Reviewed-by: Carlos Amedee <carlos@golang.org>
diff --git a/docs/commands.md b/docs/commands.md index 271faa2..7780fb9 100644 --- a/docs/commands.md +++ b/docs/commands.md
@@ -39,6 +39,14 @@ List all the Go tools being used by this extension along with their locations. +### `Package Outline: Sort By Name` + +Sort Package Outline symbols alphabetically. + +### `Package Outline: Sort By Position` + +Sort Package Outline symbols by source position. + ### `Go: Test Function At Cursor` Runs a unit test at the cursor.
diff --git a/extension/package.json b/extension/package.json index 868bdbf..be82dc2 100644 --- a/extension/package.json +++ b/extension/package.json
@@ -224,6 +224,16 @@ "description": "List all the Go tools being used by this extension along with their locations." }, { + "command": "go.packageOutline.sortByName", + "title": "Package Outline: Sort By Name", + "description": "Sort Package Outline symbols alphabetically." + }, + { + "command": "go.packageOutline.sortByPosition", + "title": "Package Outline: Sort By Position", + "description": "Sort Package Outline symbols by source position." + }, + { "command": "go.test.cursor", "title": "Go: Test Function At Cursor", "description": "Runs a unit test at the cursor." @@ -3846,6 +3856,16 @@ "command": "go.explorer.refresh", "when": "view == go.explorer", "group": "navigation" + }, + { + "command": "go.packageOutline.sortByName", + "when": "view == go.package.outline && go.packageOutline.sortOrder != 'name'", + "group": "2_packageOutline" + }, + { + "command": "go.packageOutline.sortByPosition", + "when": "view == go.package.outline && go.packageOutline.sortOrder != 'position'", + "group": "2_packageOutline" } ], "view/item/context": [
diff --git a/extension/src/goPackageOutline.ts b/extension/src/goPackageOutline.ts index ce57fdf..9b24b17 100644 --- a/extension/src/goPackageOutline.ts +++ b/extension/src/goPackageOutline.ts
@@ -12,6 +12,11 @@ Symbols: PackageSymbolData[]; } +enum PackageOutlineSortOrder { + Position = 'position', + Name = 'name' +} + export class GoPackageOutlineProvider implements vscode.TreeDataProvider<PackageSymbol> { private _onDidChangeTreeData: vscode.EventEmitter<PackageSymbol | undefined> = new vscode.EventEmitter< PackageSymbol | undefined @@ -21,13 +26,30 @@ public result?: PackageSymbolsCommandResult; public activeDocument?: vscode.TextDocument; + public view?: vscode.TreeView<PackageSymbol>; + + private readonly collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); + private packageSymbols: PackageSymbol[] = []; + private packageItem = this.createPackageItem(); + private sortOrder = PackageOutlineSortOrder.Position; + private lastRevealedSymbol?: PackageSymbol; static setup(ctx: vscode.ExtensionContext) { const provider = new this(ctx); - const { - window: { registerTreeDataProvider } - } = vscode; - ctx.subscriptions.push(registerTreeDataProvider('go.package.outline', provider)); + provider.view = vscode.window.createTreeView('go.package.outline', { + treeDataProvider: provider, + showCollapseAll: true + }); + ctx.subscriptions.push(provider.view); + ctx.subscriptions.push( + vscode.commands.registerCommand('go.packageOutline.sortByName', () => + provider.setSortOrder(PackageOutlineSortOrder.Name) + ), + vscode.commands.registerCommand('go.packageOutline.sortByPosition', () => + provider.setSortOrder(PackageOutlineSortOrder.Position) + ) + ); + provider.updateContextKeys(); return provider; } @@ -51,46 +73,24 @@ this.reload(e?.document); }) ); + ctx.subscriptions.push( + vscode.window.onDidChangeTextEditorSelection((e) => { + void this.revealActiveSymbol(e.textEditor); + }) + ); } getTreeItem(element: PackageSymbol) { return element; } + // TreeView.reveal uses getParent to expand the path to nested symbols. + getParent(element: PackageSymbol): PackageSymbol | undefined { + return element.parent; + } + rootItems(): Promise<PackageSymbol[]> { - const list = Array<PackageSymbol>(); - // Add a tree item to display the current package name. Its "command" value will be undefined and thus - // will not link anywhere when clicked - list.push( - new PackageSymbol( - { - name: this.result?.PackageName ? 'Current Package: ' + this.result.PackageName : '', - detail: '', - kind: 0, - range: new vscode.Range(0, 0, 0, 0), - selectionRange: new vscode.Range(0, 0, 0, 0), - children: [], - file: 0 - }, - [], - vscode.TreeItemCollapsibleState.None - ) - ); - const res = this.result; - if (res) { - res.Symbols?.forEach((d) => - list.push( - new PackageSymbol( - d, - res.Files ?? [], - d.children?.length > 0 - ? vscode.TreeItemCollapsibleState.Collapsed - : vscode.TreeItemCollapsibleState.None - ) - ) - ); - } - return new Promise((resolve) => resolve(list)); + return Promise.resolve([this.packageItem, ...this.sortSymbols(this.packageSymbols)]); } getChildren(element?: PackageSymbol): Thenable<PackageSymbol[] | undefined> { @@ -98,13 +98,18 @@ if (!element) { return this.rootItems(); } - return Promise.resolve(element.children); + return Promise.resolve(this.sortSymbols(element.children)); } async reload(e?: vscode.TextDocument) { if (e?.languageId !== 'go' || e?.uri?.scheme !== 'file') { this.result = undefined; this.activeDocument = undefined; + this.packageSymbols = []; + this.packageItem = this.createPackageItem(); + this.lastRevealedSymbol = undefined; + vscode.commands.executeCommand('setContext', 'go.showPackageOutline', false); + this._onDidChangeTreeData.fire(undefined); return; } this.activeDocument = e; @@ -113,15 +118,135 @@ URI: e.uri.toString() })) as PackageSymbolsCommandResult; this.result = res; + this.packageSymbols = this.createPackageSymbols(res); + this.packageItem = this.createPackageItem(res.PackageName); + this.lastRevealedSymbol = undefined; // Show the Package Outline explorer if the request returned symbols for the current package vscode.commands.executeCommand('setContext', 'go.showPackageOutline', res?.Symbols?.length > 0); this._onDidChangeTreeData.fire(undefined); + await this.revealActiveSymbol(vscode.window.activeTextEditor); } catch (e) { + this.result = undefined; + this.packageSymbols = []; + this.packageItem = this.createPackageItem(); + this.lastRevealedSymbol = undefined; // Hide the Package Outline explorer vscode.commands.executeCommand('setContext', 'go.showPackageOutline', false); + this._onDidChangeTreeData.fire(undefined); console.log('ERROR', e); } } + + private createPackageSymbols(res: PackageSymbolsCommandResult): PackageSymbol[] { + return (res.Symbols ?? []).map( + (symbol) => + new PackageSymbol( + symbol, + res.Files ?? [], + symbol.children?.length > 0 + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None + ) + ); + } + + private createPackageItem(packageName?: string): PackageSymbol { + return new PackageSymbol( + { + name: packageName ? 'Current Package: ' + packageName : '', + detail: '', + kind: 0, + range: new vscode.Range(0, 0, 0, 0), + selectionRange: new vscode.Range(0, 0, 0, 0), + children: [], + file: 0 + }, + [], + vscode.TreeItemCollapsibleState.None + ); + } + + private sortSymbols(symbols: readonly PackageSymbol[]): PackageSymbol[] { + return [...symbols].sort((a, b) => this.compareSymbols(a, b)); + } + + // Sort alphabetically when requested, otherwise preserve source order. + private compareSymbols(a: PackageSymbol, b: PackageSymbol): number { + if (this.sortOrder === PackageOutlineSortOrder.Name) { + const byName = this.collator.compare(a.symbolName, b.symbolName); + if (byName !== 0) { + return byName; + } + return this.compareByPosition(a, b); + } + const byPosition = this.compareByPosition(a, b); + if (byPosition !== 0) { + return byPosition; + } + return this.collator.compare(a.symbolName, b.symbolName); + } + + private compareByPosition(a: PackageSymbol, b: PackageSymbol): number { + if (a.fileIndex !== b.fileIndex) { + return a.fileIndex - b.fileIndex; + } + if (a.range.start.line !== b.range.start.line) { + return a.range.start.line - b.range.start.line; + } + return a.range.start.character - b.range.start.character; + } + + private setSortOrder(sortOrder: PackageOutlineSortOrder) { + if (this.sortOrder === sortOrder) { + return; + } + this.sortOrder = sortOrder; + vscode.commands.executeCommand('setContext', 'go.packageOutline.sortOrder', sortOrder); + this.lastRevealedSymbol = undefined; + this._onDidChangeTreeData.fire(undefined); + void this.revealActiveSymbol(vscode.window.activeTextEditor); + } + + private updateContextKeys() { + vscode.commands.executeCommand('setContext', 'go.packageOutline.sortOrder', this.sortOrder); + } + + private async revealActiveSymbol(editor?: vscode.TextEditor) { + if (!this.view || !editor || editor.document !== this.activeDocument) { + return; + } + const symbol = this.findSymbolAtPosition(this.packageSymbols, editor.document.uri, editor.selection.active); + if (!symbol) { + this.lastRevealedSymbol = undefined; + return; + } + if (symbol === this.lastRevealedSymbol) { + return; + } + this.lastRevealedSymbol = symbol; + try { + await this.view.reveal(symbol, { expand: true, select: true }); + } catch (e) { + console.log('ERROR', e); + } + } + + private findSymbolAtPosition( + symbols: readonly PackageSymbol[], + uri: vscode.Uri, + position: vscode.Position + ): PackageSymbol | undefined { + for (const symbol of symbols) { + const childMatch = this.findSymbolAtPosition(symbol.children, uri, position); + if (childMatch) { + return childMatch; + } + if (symbol.contains(uri, position)) { + return symbol; + } + } + return undefined; + } } interface PackageSymbolData { @@ -168,12 +293,26 @@ } export class PackageSymbol extends vscode.TreeItem { + public readonly children: PackageSymbol[]; + constructor( private readonly data: PackageSymbolData, private readonly files: string[], - public readonly collapsibleState: vscode.TreeItemCollapsibleState + public readonly collapsibleState: vscode.TreeItemCollapsibleState, + public readonly parent?: PackageSymbol ) { super(data.name, collapsibleState); + this.children = (data.children ?? []).map( + (child) => + new PackageSymbol( + child, + files, + child.children?.length > 0 + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None, + this + ) + ); const file = files[data.file ?? 0]; this.resourceUri = files && files.length > 0 ? vscode.Uri.parse(file) : undefined; const [icon, kind] = this.getSymbolInfo(); @@ -195,17 +334,28 @@ : undefined; } - get children(): PackageSymbol[] | undefined { - return this.data.children?.map( - (c) => - new PackageSymbol( - c, - this.files, - c.children?.length > 0 - ? vscode.TreeItemCollapsibleState.Collapsed - : vscode.TreeItemCollapsibleState.None - ) - ); + get range(): vscode.Range { + return this.data.range; + } + + get fileIndex(): number { + return this.data.file ?? 0; + } + + get symbolName(): string { + return this.data.name; + } + + contains(uri: vscode.Uri, position: vscode.Position): boolean { + if (this.resourceUri?.toString() !== uri.toString()) { + return false; + } + const { start, end } = this.range; + const afterStart = + start.line < position.line || (start.line === position.line && start.character <= position.character); + const beforeEnd = + end.line > position.line || (end.line === position.line && end.character >= position.character); + return afterStart && beforeEnd; } private getSymbolInfo(): [vscode.ThemeIcon | undefined, string] {
diff --git a/extension/test/integration/goPackageOutline.test.ts b/extension/test/integration/goPackageOutline.test.ts index e81dba7..967f0f2 100644 --- a/extension/test/integration/goPackageOutline.test.ts +++ b/extension/test/integration/goPackageOutline.test.ts
@@ -28,6 +28,10 @@ provider = GoPackageOutlineProvider.setup(ctx); }); + setup(async () => { + await vscode.commands.executeCommand('go.packageOutline.sortByPosition'); + }); + suiteTeardown(() => { ctx.teardown(); }); @@ -37,7 +41,7 @@ vscode.Uri.file(path.join(fixtureDir, 'symbols_1.go')) ); await window.showTextDocument(document); - await sleep(500); // wait for gopls response + await waitForOutlineResult(provider, 'package_outline_test'); const res = provider.result; assert.strictEqual(res?.PackageName, 'package_outline_test'); assert.strictEqual(res?.Files.length, 2); @@ -51,7 +55,7 @@ vscode.Uri.file(path.join(fixtureDir, 'symbols_1.go')) ); await window.showTextDocument(document); - await sleep(500); // wait for gopls response + await waitForOutlineResult(provider, 'package_outline_test'); await vscode.commands.executeCommand('setContext', 'go.showPackageOutline'); const children = await provider.getChildren(); const receiver = children?.find((symbol) => symbol.label === 'TestReceiver'); @@ -70,7 +74,7 @@ vscode.Uri.file(path.join(fixtureDir, 'symbols_1.go')) ); await window.showTextDocument(document); - await sleep(500); // wait for gopls response + await waitForOutlineResult(provider, 'package_outline_test'); await vscode.commands.executeCommand('setContext', 'go.showPackageOutline'); const children = await provider.getChildren(); const receiver = children?.find((symbol) => symbol.label === 'TestReceiver'); @@ -87,6 +91,66 @@ assert.strictEqual(window.activeTextEditor?.selection.active.character, 0); }); + test('sort by name orders symbols alphabetically', async () => { + const document = await vscode.workspace.openTextDocument( + vscode.Uri.file(path.join(fixtureDir, 'symbols_1.go')) + ); + await window.showTextDocument(document); + await waitForOutlineResult(provider, 'package_outline_test'); + await vscode.commands.executeCommand('go.packageOutline.sortByName'); + const children = await provider.getChildren(); + assert.deepStrictEqual( + (children ?? []).slice(1).map((symbol) => symbol.label), + ['main', 'print', 'TestReceiver'] + ); + const receiver = children?.find((symbol) => symbol.label === 'TestReceiver'); + assert.ok(receiver, 'receiver symbol not found'); + const receiverChildren = await provider.getChildren(receiver); + assert.deepStrictEqual( + (receiverChildren ?? []).map((symbol) => symbol.label), + ['field1', 'field2', 'field3', 'method1', 'method2', 'method3'] + ); + }); + + test('sort by position orders symbols by source location', async () => { + const document = await vscode.workspace.openTextDocument( + vscode.Uri.file(path.join(fixtureDir, 'symbols_1.go')) + ); + await window.showTextDocument(document); + await waitForOutlineResult(provider, 'package_outline_test'); + const children = await provider.getChildren(); + assert.deepStrictEqual( + (children ?? []).slice(1).map((symbol) => symbol.label), + ['print', 'main', 'TestReceiver'] + ); + }); + + test('cursor changes reveal the active symbol', async () => { + const document1 = await vscode.workspace.openTextDocument( + vscode.Uri.file(path.join(fixtureDir, 'symbols_1.go')) + ); + await window.showTextDocument(document1); + await waitForOutlineResult(provider, 'package_outline_test'); + await moveCursor(document1, 19); + await sleep(500); // wait for tree view reveal + assert.strictEqual( + ((provider as unknown) as { lastRevealedSymbol?: PackageSymbol }).lastRevealedSymbol?.label, + 'method1' + ); + + const document2 = await vscode.workspace.openTextDocument( + vscode.Uri.file(path.join(fixtureDir, 'symbols_2.go')) + ); + await window.showTextDocument(document2); + await waitForOutlineResult(provider, 'package_outline_test'); + await moveCursor(document2, 2); + await sleep(500); // wait for tree view reveal + assert.strictEqual( + ((provider as unknown) as { lastRevealedSymbol?: PackageSymbol }).lastRevealedSymbol?.label, + 'method2' + ); + }); + test('non-go file does not trigger outline', async () => { const document = await vscode.workspace.openTextDocument( vscode.Uri.file(path.join(fixtureDir, 'symbols_3.ts')) @@ -101,6 +165,23 @@ return new Promise((resolve) => setTimeout(resolve, ms)); } +async function waitForOutlineResult(provider: GoPackageOutlineProvider, packageName: string) { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + if (provider.result?.PackageName === packageName) { + return; + } + await sleep(100); + } + assert.fail(`timed out waiting for outline result for ${packageName}`); +} + +async function moveCursor(document: vscode.TextDocument, line: number, character = 0) { + const editor = await window.showTextDocument(document); + const position = new vscode.Position(line, character); + editor.selection = new vscode.Selection(position, position); +} + function clickSymbol(symbol: PackageSymbol) { if (symbol.command) { vscode.commands.executeCommand(symbol.command.command, ...(symbol.command.arguments || []));