diff --git a/README.md b/README.md index ecd7f0f..3ec5c3b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Inline CodeLens indicators for overridden and implemented methods in Python. - **Cross-File Support**: Works across your workspace. - **Implementation Detection**: Identifies methods that are implemented/overridden by parent/subclasses. +- **Optional Gutter Markers**: Shows gutter icons for overrides and subclass implementations with hover details. - **CodeLens Navigation**: - **Overrides**: Click the "Overrides Parent.method" CodeLens to navigate to the parent definition. - **Implementations**: Click "Implemented in Child.method" to navigate to the subclass implementation. @@ -45,6 +46,7 @@ Inline CodeLens indicators for overridden and implemented methods in Python. | Setting | Description | |------------------------------------------ |---------------------------------------------------------------------------------------------------------- | +| `pythonOverrideMark.gutterIcons.enabled` | Enable gutter icons for overrides and methods implemented in subclasses. Default is `false` | | `pythonOverrideMark.maxInheritanceDepth` | Maximum depth to search for parent classes (recursive inheritance). Default is 3. Set to 0 for unlimited | ## How it Works @@ -53,7 +55,8 @@ This extension uses the VS Code Python extension's Language Server Protocol (LSP 1. **Override Detection**: It scans the active document for class definitions and resolves their parent classes (even across files). It then compares methods to identify overrides. 2. **Implementation Detection**: It finds references to the current class to identify subclasses. It then checks those subclasses for methods that implement or override methods in the parent. -3. **Performance**: Detection is debounced (default 500ms) and optimized to skip non-class symbols to ensure a smooth editing experience. +3. **Gutter Markers**: Gutter icons are visual indicators with hover details. Navigation still goes through the existing CodeLens commands. +4. **Performance**: Detection is debounced (default 500ms) and optimized to skip non-class symbols to ensure a smooth editing experience. ## Future Improvements diff --git a/assets/implementation-dark.svg b/assets/implementation-dark.svg new file mode 100644 index 0000000..2c27a78 --- /dev/null +++ b/assets/implementation-dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/implementation-light.svg b/assets/implementation-light.svg new file mode 100644 index 0000000..c7ff0e6 --- /dev/null +++ b/assets/implementation-light.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/override-dark.svg b/assets/override-dark.svg new file mode 100644 index 0000000..1451998 --- /dev/null +++ b/assets/override-dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/override-light.svg b/assets/override-light.svg new file mode 100644 index 0000000..6b12ecc --- /dev/null +++ b/assets/override-light.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/package.json b/package.json index 4b6e161..331daa6 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,11 @@ "type": "number", "default": 3, "description": "Maximum depth to search for parent classes (recursive inheritance). Set to 0 for unlimited (use with caution)." + }, + "pythonOverrideMark.gutterIcons.enabled": { + "type": "boolean", + "default": false, + "description": "Show gutter icons for overridden methods and methods implemented in subclasses. Disabled by default." } } } diff --git a/src/extension.ts b/src/extension.ts index 84fc369..cb88bc5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,16 +2,23 @@ import * as vscode from 'vscode'; import { OverrideDetector } from './overrideDetector'; import { OverrideCodeLensProvider } from './codeLensProvider'; import { SubclassCache, ReferenceClassificationCache } from './caching'; +import { OverrideGutterManager } from './gutterManager'; +import { OverrideHoverProvider } from './hoverProvider'; export function activate(context: vscode.ExtensionContext) { const detector = new OverrideDetector(); const codeLensProvider = new OverrideCodeLensProvider(); + const gutterManager = new OverrideGutterManager(context.extensionUri); + const hoverProvider = new OverrideHoverProvider(); + + context.subscriptions.push(gutterManager); // Register CodeLens Provider context.subscriptions.push( - vscode.languages.registerCodeLensProvider({ language: 'python', scheme: 'file' }, codeLensProvider) + vscode.languages.registerCodeLensProvider({ language: 'python', scheme: 'file' }, codeLensProvider), + vscode.languages.registerHoverProvider({ language: 'python', scheme: 'file' }, hoverProvider) ); let activeEditor = vscode.window.activeTextEditor; @@ -25,25 +32,49 @@ export function activate(context: vscode.ExtensionContext) { // Debounce const delay = vscode.workspace.getConfiguration('pythonOverrideMark').get('debounceDelay', 500); timeout = setTimeout(() => { - if (activeEditor && activeEditor.document.languageId === 'python') { - // Ensure Python extension is activated - const pythonExtension = vscode.extensions.getExtension('ms-python.python'); - if (pythonExtension && !pythonExtension.isActive) { + const editor = activeEditor; - pythonExtension.activate().then(() => { + if (!editor) { + gutterManager.clear(); + hoverProvider.updateResults(undefined, []); + return; + } - // Re-trigger update after activation - triggerUpdate(); - }); - return; // Exit to avoid running detection before Python extension is ready - } + if (editor.document.languageId !== 'python') { + gutterManager.clear(); + hoverProvider.updateResults(undefined, []); + return; + } + + // Ensure Python extension is activated + const pythonExtension = vscode.extensions.getExtension('ms-python.python'); + if (pythonExtension && !pythonExtension.isActive) { - detector.detectOverrides(activeEditor).then(items => { - codeLensProvider.updateResults(items); - }).catch(error => { - console.error('Error updating override marks:', error); + pythonExtension.activate().then(() => { + + // Re-trigger update after activation + triggerUpdate(); }); + return; // Exit to avoid running detection before Python extension is ready } + + detector.detectOverrides(editor).then(items => { + if (editor !== activeEditor) { + return; + } + + codeLensProvider.updateResults(items); + gutterManager.update(editor, items); + hoverProvider.updateResults(editor, items); + }).catch(error => { + if (editor === activeEditor) { + codeLensProvider.updateResults([]); + gutterManager.update(editor, []); + hoverProvider.updateResults(editor, []); + } + + console.error('Error updating override marks:', error); + }); }, delay); }; @@ -55,6 +86,9 @@ export function activate(context: vscode.ExtensionContext) { context.subscriptions.push( vscode.window.onDidChangeActiveTextEditor(editor => { activeEditor = editor; + gutterManager.clear(); + hoverProvider.updateResults(undefined, []); + if (editor) { triggerUpdate(); } @@ -76,6 +110,26 @@ export function activate(context: vscode.ExtensionContext) { if (involvesClass) { SubclassCache.getInstance().clear(); } + }), + vscode.workspace.onDidChangeConfiguration(event => { + if (!event.affectsConfiguration('pythonOverrideMark')) { + return; + } + + if (event.affectsConfiguration('pythonOverrideMark.gutterIcons.enabled')) { + const gutterIconsEnabled = vscode.workspace.getConfiguration('pythonOverrideMark').get('gutterIcons.enabled', false); + + if (!gutterIconsEnabled) { + gutterManager.clear(); + } + } + + if (activeEditor?.document.languageId === 'python') { + triggerUpdate(); + } else { + gutterManager.clear(); + hoverProvider.updateResults(undefined, []); + } }) ); diff --git a/src/gutterManager.ts b/src/gutterManager.ts new file mode 100644 index 0000000..df42fbb --- /dev/null +++ b/src/gutterManager.ts @@ -0,0 +1,93 @@ +import * as vscode from 'vscode'; +import { OverrideItem } from './types'; + +export class OverrideGutterManager implements vscode.Disposable { + private readonly overrideDecorationType: vscode.TextEditorDecorationType; + private readonly implementationDecorationType: vscode.TextEditorDecorationType; + private activeEditor: vscode.TextEditor | undefined; + + constructor(extensionUri: vscode.Uri) { + const assetsUri = vscode.Uri.joinPath(extensionUri, 'assets'); + + this.overrideDecorationType = vscode.window.createTextEditorDecorationType({ + isWholeLine: true, + gutterIconSize: 'contain', + rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed, + light: { + gutterIconPath: vscode.Uri.joinPath(assetsUri, 'override-light.svg') + }, + dark: { + gutterIconPath: vscode.Uri.joinPath(assetsUri, 'override-dark.svg') + } + }); + + this.implementationDecorationType = vscode.window.createTextEditorDecorationType({ + isWholeLine: true, + gutterIconSize: 'contain', + rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed, + light: { + gutterIconPath: vscode.Uri.joinPath(assetsUri, 'implementation-light.svg') + }, + dark: { + gutterIconPath: vscode.Uri.joinPath(assetsUri, 'implementation-dark.svg') + } + }); + } + + public update(editor: vscode.TextEditor | undefined, items: OverrideItem[]): void { + if (this.activeEditor && this.activeEditor !== editor) { + this.clearEditor(this.activeEditor); + } + + this.activeEditor = editor; + + if (!editor || editor.document.languageId !== 'python' || !this.isEnabled()) { + if (editor) { + this.clearEditor(editor); + } + return; + } + + const overrideDecorations: vscode.DecorationOptions[] = []; + const implementationDecorations: vscode.DecorationOptions[] = []; + + for (const item of items) { + if (item.type === 'override' && item.parentMethodName) { + overrideDecorations.push({ + range: item.range + }); + } + + if (item.type === 'implementation' && item.childMethods && item.childMethods.length > 0) { + implementationDecorations.push({ + range: item.range + }); + } + } + + editor.setDecorations(this.overrideDecorationType, overrideDecorations); + editor.setDecorations(this.implementationDecorationType, implementationDecorations); + } + + public clear(): void { + if (this.activeEditor) { + this.clearEditor(this.activeEditor); + this.activeEditor = undefined; + } + } + + public dispose(): void { + this.clear(); + this.overrideDecorationType.dispose(); + this.implementationDecorationType.dispose(); + } + + private isEnabled(): boolean { + return vscode.workspace.getConfiguration('pythonOverrideMark').get('gutterIcons.enabled', false); + } + + private clearEditor(editor: vscode.TextEditor): void { + editor.setDecorations(this.overrideDecorationType, []); + editor.setDecorations(this.implementationDecorationType, []); + } +} diff --git a/src/hoverProvider.ts b/src/hoverProvider.ts new file mode 100644 index 0000000..523e300 --- /dev/null +++ b/src/hoverProvider.ts @@ -0,0 +1,69 @@ +import * as vscode from 'vscode'; +import { OverrideItem } from './types'; + +export class OverrideHoverProvider implements vscode.HoverProvider { + private documentUri: string | undefined; + private items: OverrideItem[] = []; + + public updateResults(editor: vscode.TextEditor | undefined, items: OverrideItem[]): void { + this.documentUri = editor?.document.uri.toString(); + this.items = items; + } + + public provideHover(document: vscode.TextDocument, position: vscode.Position): vscode.Hover | undefined { + if (!this.isEnabled()) { + return undefined; + } + + if (document.uri.toString() !== this.documentUri) { + return undefined; + } + + const messages: string[] = []; + let hoverRange: vscode.Range | undefined; + + for (const item of this.items) { + const lineRange = document.lineAt(item.range.start.line).range; + if (!lineRange.contains(position)) { + continue; + } + + const message = this.getHoverMessage(item); + if (!message) { + continue; + } + + if (!messages.includes(message)) { + messages.push(message); + } + + hoverRange = hoverRange + ? hoverRange.union(lineRange) + : lineRange; + } + + if (messages.length === 0 || !hoverRange) { + return undefined; + } + + return new vscode.Hover(messages.join('\n\n'), hoverRange); + } + + private getHoverMessage(item: OverrideItem): string | undefined { + if (item.type === 'override' && item.parentMethodName) { + return `Overrides ${item.parentMethodName}`; + } + + if (item.type === 'implementation' && item.childMethods && item.childMethods.length > 0) { + return item.childMethods.length === 1 + ? 'Implemented in 1 subclass' + : `Implemented in ${item.childMethods.length} subclasses`; + } + + return undefined; + } + + private isEnabled(): boolean { + return vscode.workspace.getConfiguration('pythonOverrideMark').get('gutterIcons.enabled', false); + } +}