diff --git a/README.md b/README.md index 20660c93..4dee85e8 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,18 @@ See more context about each line, including the author and the changelist descri The format of the annotations is customisable in the extension configuration if this is too much information. +### ChangelistLens - See changelist information as you code + +The ChangelistLens feature provides inline changelist information for the currently selected line, similar to GitLens but for Perforce. This feature helps you understand code history without leaving your editor: + +- Shows changelist number, author, and time information for the selected line +- Displays information at the end of the line for minimal distraction +- Provides detailed changelist information on hover +- Customizable appearance with different information density levels +- Toggle on/off with a simple command + +To enable or disable this feature, use the command "Perforce: Toggle changelist information display" or configure it in settings with `perforce.changelistLens.enabled`. + ### New revision & changelist quick pick Looking at a diff or annotation? Dive in to the depot with a single click to see some context: @@ -327,51 +339,51 @@ You can specify how you want the extension to activate by setting the parameter ## Configuration -|Name |Type |Description -|-----------------------------------|-----------|----------- -|`perforce.client` |`string` |Use the specified client -|`perforce.user` |`string` |Use the specified user -|`perforce.port` |`string` |Use the specified protocol:host:port -|`perforce.password` |`string` |Use the specified password -|`perforce.charset` |`enum` |Use the specified charset for unicode or utf16 files -|  -|`perforce.editOnFileSave` |`boolean` |Automatically open a file for edit when saved -|`perforce.editOnFileModified` |`boolean` |Automatically open a file for edit when Modified -|`perforce.addOnFileCreate` |`boolean` |Automatically Add a file to depot when Created -|`perforce.deleteOnFileDelete` |`boolean` |Automatically delete a file from depot when deleted -|  -|`perforce.dir` |`string` |Overrides any PWD setting (current working directory) and replaces it with the specified directory -|`perforce.command` |`string` |Configure a path to p4 or an alternate command if needed -|`perforce.realpath` |`boolean` |**Experimental** Try to resolve real file path before executing command -|  -|`perforce.activationMode` |`string` |Controls when to activate the extension (`always`,`autodetect`,`off`) -|`perforce.enableP4ConfigScanOnStartup` | `boolean` | When enabled (default), the extension scans the workspace for `P4CONFIG` files on startup. In large workspaces without `P4CONFIG` files this can be disabled to improve performance -|`perforce.scm.activateOnFileOpen` | `boolean` | Controls whether the extension attempts to create an SCM provider each time a file outside of a known perforce client workspace is opened -|`perforce.scm.deactivateOnFileClose` | `boolean` | Controls whether an SCM provider is de-activated when there are no more related files or folders open in the editor -|  -|`perforce.countBadge` |`string` |Controls the badge counter for Perforce (`all`,`off`) -|`perforce.annotate.followBranches` |`boolean` |Whether to follow branch actions when annotating a file -|`perforce.annotate.gutterColumns` |`object` |**Experimental** Format for annotation summary messages -|`perforce.changelistSearch.maxResults` | `number` | The maximum number of results to show in the changelist search -|`perforce.changelistOrder` |`string` |Specifies the direction of the changelist sorting (`descending`,`ascending`) -|`perforce.scmFileChanges` |`boolean` |Open file changes when selected in SCM Explorer -|`perforce.ignoredChangelistPrefix` |`string` |Specifies the prefix of the changelists to be ignored. -|`perforce.hideNonWorkspaceFiles` |`enum` |Controls how files outside of the current VS Code workspace are shown in the SCM Provider -|`perforce.syncMode` |`enum` |Controls whether to sync the whole perforce client or just the VS code workspace when using the default sync command -|`perforce.fileShelveMode` |`enum` |Controls behaviour when shelving or unshelving an individual file from the SCM view -|`perforce.hideShelvedFiles` |`boolean` |Hide shelved files in the SCM Explorer. -|`perforce.hideEmptyChangelists` |`boolean` |Hide changelists with no file in the SCM Explorer. -|`perforce.hideSubmitIcon` |`boolean` |Don't show the submit icon next to the changelist description. -|`perforce.promptBeforeSubmit` |`boolean` |Whether to prompt for confirmation before submitting a saved changelist. -|`perforce.resolve.p4editor` |`string` |Overrides P4EDITOR when running resolve commands -|`perforce.swarmHost` |`string` |Specifies the hostname of the Swarm server for annotation links. (`https://localhost`) -|`perforce.editorButtons.diffPrevAndNext` |`enum` |Controls when to show buttons on the editor title menu for diffing next / previous -|  -|`perforce.explorer.showSyncCommands`|`boolean` |Whether to show commands in the explorer context menu for syncing files and folders -|`perforce.explorer.showBasicOpCommands`|`boolean` |Whether to show commands in the explorer context menu for adding, editing and reverting files -|`perforce.explorer.showFileOpCommands`|`boolean` |Whether to show commands in the explorer context menu for moving files with p4 move -|  -|`perforce.bottleneck.maxConcurrent` |`number` |Limit the maximum number of perforce commands running at any given time. +| Name | Type | Description | +| ---------------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `perforce.client` | `string` | Use the specified client | +| `perforce.user` | `string` | Use the specified user | +| `perforce.port` | `string` | Use the specified protocol:host:port | +| `perforce.password` | `string` | Use the specified password | +| `perforce.charset` | `enum` | Use the specified charset for unicode or utf16 files | +|   | +| `perforce.editOnFileSave` | `boolean` | Automatically open a file for edit when saved | +| `perforce.editOnFileModified` | `boolean` | Automatically open a file for edit when Modified | +| `perforce.addOnFileCreate` | `boolean` | Automatically Add a file to depot when Created | +| `perforce.deleteOnFileDelete` | `boolean` | Automatically delete a file from depot when deleted | +|   | +| `perforce.dir` | `string` | Overrides any PWD setting (current working directory) and replaces it with the specified directory | +| `perforce.command` | `string` | Configure a path to p4 or an alternate command if needed | +| `perforce.realpath` | `boolean` | **Experimental** Try to resolve real file path before executing command | +|   | +| `perforce.activationMode` | `string` | Controls when to activate the extension (`always`,`autodetect`,`off`) | +| `perforce.enableP4ConfigScanOnStartup` | `boolean` | When enabled (default), the extension scans the workspace for `P4CONFIG` files on startup. In large workspaces without `P4CONFIG` files this can be disabled to improve performance | +| `perforce.scm.activateOnFileOpen` | `boolean` | Controls whether the extension attempts to create an SCM provider each time a file outside of a known perforce client workspace is opened | +| `perforce.scm.deactivateOnFileClose` | `boolean` | Controls whether an SCM provider is de-activated when there are no more related files or folders open in the editor | +|   | +| `perforce.countBadge` | `string` | Controls the badge counter for Perforce (`all`,`off`) | +| `perforce.annotate.followBranches` | `boolean` | Whether to follow branch actions when annotating a file | +| `perforce.annotate.gutterColumns` | `object` | **Experimental** Format for annotation summary messages | +| `perforce.changelistSearch.maxResults` | `number` | The maximum number of results to show in the changelist search | +| `perforce.changelistOrder` | `string` | Specifies the direction of the changelist sorting (`descending`,`ascending`) | +| `perforce.scmFileChanges` | `boolean` | Open file changes when selected in SCM Explorer | +| `perforce.ignoredChangelistPrefix` | `string` | Specifies the prefix of the changelists to be ignored. | +| `perforce.hideNonWorkspaceFiles` | `enum` | Controls how files outside of the current VS Code workspace are shown in the SCM Provider | +| `perforce.syncMode` | `enum` | Controls whether to sync the whole perforce client or just the VS code workspace when using the default sync command | +| `perforce.fileShelveMode` | `enum` | Controls behaviour when shelving or unshelving an individual file from the SCM view | +| `perforce.hideShelvedFiles` | `boolean` | Hide shelved files in the SCM Explorer. | +| `perforce.hideEmptyChangelists` | `boolean` | Hide changelists with no file in the SCM Explorer. | +| `perforce.hideSubmitIcon` | `boolean` | Don't show the submit icon next to the changelist description. | +| `perforce.promptBeforeSubmit` | `boolean` | Whether to prompt for confirmation before submitting a saved changelist. | +| `perforce.resolve.p4editor` | `string` | Overrides P4EDITOR when running resolve commands | +| `perforce.swarmHost` | `string` | Specifies the hostname of the Swarm server for annotation links. (`https://localhost`) | +| `perforce.editorButtons.diffPrevAndNext` | `enum` | Controls when to show buttons on the editor title menu for diffing next / previous | +|   | +| `perforce.explorer.showSyncCommands` | `boolean` | Whether to show commands in the explorer context menu for syncing files and folders | +| `perforce.explorer.showBasicOpCommands` | `boolean` | Whether to show commands in the explorer context menu for adding, editing and reverting files | +| `perforce.explorer.showFileOpCommands` | `boolean` | Whether to show commands in the explorer context menu for moving files with p4 move | +|   | +| `perforce.bottleneck.maxConcurrent` | `number` | Limit the maximum number of perforce commands running at any given time. | ## Command and Context Variables @@ -389,14 +401,14 @@ For example, the following task prints out the changelist number, provided the c In all cases, the command name and the context variable name are the same -| Name | description -|-----------------------------------|--------------- -| `perforce.currentFile.status` | Whether the file is open / in the workspace. Possible values: `OPEN`, `NOT_OPEN`, `NOT_IN_WORKSPACE` -| `perforce.currentFile.depotPath` | The depot path of the file (**only** provided if the file is open) -| `perforce.currentFile.revision` | The open revision of the file (**only** provided if the file is open) -| `perforce.currentFile.changelist` | The changelist in which the file is open -| `perforce.currentFile.operation` | The perforce operation for the file, e.g. `edit`, `move/add` -| `perforce.currentFile.filetype` | The perforce file type of the file, e.g. `text` +| Name | description | +| --------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `perforce.currentFile.status` | Whether the file is open / in the workspace. Possible values: `OPEN`, `NOT_OPEN`, `NOT_IN_WORKSPACE` | +| `perforce.currentFile.depotPath` | The depot path of the file (**only** provided if the file is open) | +| `perforce.currentFile.revision` | The open revision of the file (**only** provided if the file is open) | +| `perforce.currentFile.changelist` | The changelist in which the file is open | +| `perforce.currentFile.operation` | The perforce operation for the file, e.g. `edit`, `move/add` | +| `perforce.currentFile.filetype` | The perforce file type of the file, e.g. `text` | ## Common Questions diff --git a/package-lock.json b/package-lock.json index a55b16de..5426aa07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "perforce", - "version": "4.15.7", + "version": "4.16.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "perforce", - "version": "4.15.7", + "version": "4.16.1", "license": "MIT", "devDependencies": { "@arrows/composition": "^1.2.2", diff --git a/package.json b/package.json index 55abec1c..e292ed7c 100644 --- a/package.json +++ b/package.json @@ -443,6 +443,28 @@ "type": "boolean", "default": true, "description": "Whether to show a warning when editing a change or job spec and the editor is set to use spaces instead of tabs" + }, + "perforce.changelistLens.enabled": { + "type": "boolean", + "default": true, + "description": "Enable changelist lens feature to display modification information and changelist for each line of code in the editor", + "scope": "window" + }, + "perforce.changelistLens.style": { + "type": "string", + "enum": [ + "all", + "compact", + "minimalist" + ], + "enumDescriptions": [ + "Show complete information: user, revision number and time", + "Show compact information: user and revision number", + "Show only revision number" + ], + "default": "all", + "description": "Control the information style displayed by the changelist lens", + "scope": "window" } } }, @@ -961,6 +983,11 @@ "command": "perforce.clearMementos", "title": "Clear persisted memento data", "category": "Perforce" + }, + { + "command": "perforce.toggleChangelistLens", + "title": "Toggle changelist information display", + "category": "Perforce" } ], "submenus": [ @@ -1775,6 +1802,24 @@ "highContrast": "#BEBEBE" } }, + { + "id": "perforce.changelistLensForeground", + "description": "Specifies the foreground color of the changelist lens", + "defaults": { + "dark": "#555555", + "light": "#666666", + "highContrast": "#CCCCCC" + } + }, + { + "id": "perforce.changelistLensBorder", + "description": "Specifies the border color of the changelist lens", + "defaults": { + "dark": "#333333", + "light": "#CCCCCC", + "highContrast": "#3F7A94" + } + }, { "id": "perforce.lineHighlightBackgroundColor", "description": "Specifies the background color of the associated line highlights in annotations", diff --git a/src/ChangelistLens.ts b/src/ChangelistLens.ts new file mode 100644 index 00000000..0486f70f --- /dev/null +++ b/src/ChangelistLens.ts @@ -0,0 +1,324 @@ +import * as vscode from "vscode"; +import * as p4 from "./api/PerforceApi"; +import { Display } from "./Display"; +import * as Path from "path"; + +import { timeAgo, toReadableDateTime, toReadableDate } from "./DateFormatter"; +import * as PerforceUri from "./PerforceUri"; +import { configAccessor } from "./ConfigService"; +import { + AnnotationProvider, + annotate, + makeHoverMessage, +} from "./annotations/AnnotationProvider"; + +/** + * Change information type definition + */ +export interface ChangeInfo { + revision: string; + change: string; + user: string; + date: Date; + description: string; +} + +/** + * ChangelistLens feature manager + * Provides changelist information display in code with hover details + * Unlike AnnotationProvider, this only shows change information on the selected line + */ +export class ChangelistLens implements vscode.Disposable { + private static _instance: ChangelistLens | undefined; + private disposables: vscode.Disposable[] = []; + private decorationType: vscode.TextEditorDecorationType; + private currentSelectedLine: number = -1; // Track current selected line + private annotations: (p4.Annotation | undefined)[] = []; // Store annotations for all lines + private fileLogsMap: Map = new Map(); // Store changelist info, key is changelist number + + /** + * Get singleton instance + */ + public static getInstance(): ChangelistLens { + if (!this._instance) { + this._instance = new ChangelistLens(); + } + return this._instance; + } + + /** + * Constructor + */ + private constructor() { + // Create decoration type - only show changelist info at end of line, using theme colors + this.decorationType = vscode.window.createTextEditorDecorationType({ + after: { + margin: "0 0 0 10em", + textDecoration: "none", + // Use theme colors instead of hardcoded values + color: new vscode.ThemeColor("perforce.changelistLensForeground"), + border: "1px solid", + borderColor: new vscode.ThemeColor("perforce.changelistLensBorder"), + }, + rangeBehavior: vscode.DecorationRangeBehavior.ClosedOpen, + }); + + // Register event listeners + this.disposables.push( + vscode.window.onDidChangeActiveTextEditor( + this.onDidChangeActiveTextEditor.bind(this), + this + ), + vscode.workspace.onDidSaveTextDocument((document) => + this.onDidSaveTextDocument(document) + ), + // Add selection change event listener + vscode.window.onDidChangeTextEditorSelection( + this.onDidChangeTextEditorSelection.bind(this), + this + ) + ); + + // Process current active editor immediately + if (vscode.window.activeTextEditor) { + this.onDidChangeActiveTextEditor(vscode.window.activeTextEditor); + } + } + + /** + * Handle editor selection change event + */ + private onDidChangeTextEditorSelection( + event: vscode.TextEditorSelectionChangeEvent + ): void { + const editor = event.textEditor; + if (!editor || editor.document.uri.scheme !== "file") { + return; + } + + // Get selected line + const selection = editor.selection; + const newSelectedLine = selection.active.line; + + // If selected line changed, update decorations + if (this.currentSelectedLine !== newSelectedLine) { + this.currentSelectedLine = newSelectedLine; + this.updateDecorations(editor); + } + } + + /** + * Handle editor change event + */ + private onDidChangeActiveTextEditor(editor: vscode.TextEditor | undefined): void { + if (!editor || editor.document.uri.scheme !== "file") { + return; + } + + // Clear previous data and get annotations for new file + this.clearCache(); + + // Set current selected line + this.currentSelectedLine = editor.selection.active.line; + + // Get and apply decorations + this.getAnnotationsForFile(editor.document.uri) + .then(() => { + this.updateDecorations(editor); + }) + .catch((err) => { + Display.channel.appendLine( + `[ChangelistLens] Error getting file annotations: ${err}` + ); + }); + } + + /** + * Handle document save event + */ + private onDidSaveTextDocument(document: vscode.TextDocument): void { + // Find editor for this document from all visible editors + vscode.window.visibleTextEditors.forEach((editor) => { + if (editor.document.uri.toString() === document.uri.toString()) { + // Clear cache and get annotations again + this.clearCache(); + this.getAnnotationsForFile(document.uri) + .then(() => { + this.updateDecorations(editor); + }) + .catch((err) => { + Display.channel.appendLine( + `[ChangelistLens] Error getting file annotations: ${err}` + ); + }); + } + }); + } + + /** + * Get file annotations + */ + private async getAnnotationsForFile(uri: vscode.Uri): Promise { + try { + // Removed log for better performance + + const underlying = PerforceUri.getUsableWorkspace(uri) ?? uri; + const followBranches = configAccessor.annotateFollowBranches; + + // Get file annotations + const annotationsPromise = p4.annotate(underlying, { + file: uri, + outputChangelist: true, + outputUser: true, + followBranches, + }); + + // Get file history + const logPromise = p4.getFileHistory(underlying, { + file: uri, + followBranches, + }); + + const [annotations, logs] = await Promise.all([ + annotationsPromise, + logPromise, + ]); + + // Save annotations and changelist information + this.annotations = annotations; + + // Store changelist information in Map for quick lookup + logs.forEach((log) => { + this.fileLogsMap.set(log.chnum, log); + }); + + // Removed log for better performance + } catch (err) { + Display.channel.appendLine( + `[ChangelistLens] Error getting file annotations: ${err}` + ); + throw err; + } + } + + /** + * Update decorations for the specified editor + * Only decorate the currently selected line + */ + private updateDecorations(editor: vscode.TextEditor): void { + // Clear all existing decorations + editor.setDecorations(this.decorationType, []); + + // If no annotations or invalid selected line, return + if ( + this.annotations.length === 0 || + this.currentSelectedLine < 0 || + this.currentSelectedLine >= this.annotations.length + ) { + return; + } + + // Get annotation for current selected line + const annotation = this.annotations[this.currentSelectedLine]; + if (!annotation || !annotation.revisionOrChnum) { + return; + } + + try { + // Get changelist information + const changeInfo = this.fileLogsMap.get(annotation.revisionOrChnum); + if (!changeInfo) { + return; + } + + // Create decoration options + const decoration = this.createDecorationForChange(annotation, changeInfo); + if (decoration) { + // Apply only to current selected line + editor.setDecorations(this.decorationType, [decoration]); + + // Removed log for better performance + } + } catch (err) { + Display.channel.appendLine( + `[ChangelistLens] Error applying decorations: ${err}` + ); + } + } + + /** + * Create decoration options for a change + */ + private createDecorationForChange( + annotation: p4.Annotation, + change: p4.FileLogItem + ): vscode.DecorationOptions | undefined { + if (!annotation || !change) { + return undefined; + } + + try { + // Create display text - changelist number, user and time + const timeStr = timeAgo.format(change.date ?? new Date()); + + const displayText = `CL: ${change.chnum} | ${change.user} | ${timeStr}`; + + // Get underlying URI for the file + const underlying = + PerforceUri.getUsableWorkspace( + vscode.window.activeTextEditor?.document.uri ?? vscode.Uri.file("") + ) ?? vscode.window.activeTextEditor?.document.uri; + + if (!underlying) { + return undefined; + } + + // Use makeHoverMessage function from AnnotationProvider to create hover message + const hoverMessage = makeHoverMessage(underlying, change, change); + + // Return decoration options - using theme colors consistent with constructor definition + return { + range: new vscode.Range( + this.currentSelectedLine, + Number.MAX_SAFE_INTEGER, + this.currentSelectedLine, + Number.MAX_SAFE_INTEGER + ), + renderOptions: { + after: { + contentText: displayText, + fontStyle: "italic", + // Use theme colors we defined instead of hardcoded colors + // This keeps consistency with the decorator definition in constructor + }, + }, + hoverMessage, + }; + } catch (err) { + Display.channel.appendLine( + `[ChangelistLens] Error creating decoration: ${err}` + ); + return undefined; + } + } + + /** + * Clear cached annotations and changelist information + */ + public clearCache(): void { + this.annotations = []; + this.fileLogsMap.clear(); + this.currentSelectedLine = -1; + } + + /** + * Release resources + */ + public dispose(): void { + this.disposables.forEach((d) => d.dispose()); + this.disposables = []; + this.decorationType.dispose(); + this.clearCache(); + ChangelistLens._instance = undefined; + } +} diff --git a/src/ConfigService.ts b/src/ConfigService.ts index e2ad571e..648e9df2 100644 --- a/src/ConfigService.ts +++ b/src/ConfigService.ts @@ -167,6 +167,14 @@ export class ConfigAccessor { return SyncMode.WHOLE_CLIENT; } } + + public get changelistLensStyle(): string { + return this.getConfigItem("changelistLens.style") ?? "all"; + } + + public get changelistLensEnabled(): boolean { + return this.getConfigItem("changelistLens.enabled") ?? true; + } } export const configAccessor = new ConfigAccessor(); diff --git a/src/annotations/AnnotationProvider.ts b/src/annotations/AnnotationProvider.ts index 762fc372..73d2b422 100644 --- a/src/annotations/AnnotationProvider.ts +++ b/src/annotations/AnnotationProvider.ts @@ -335,6 +335,8 @@ function makeHoverMessage( return markdown; } +export { makeHoverMessage }; + function makeDecorationForChange( ageRating: number, isTop: boolean, diff --git a/src/extension.ts b/src/extension.ts index c3de4e32..0e74a647 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -19,6 +19,7 @@ import { isTruthy } from "./TsUtils"; import { registerChangelistSearch } from "./search/ChangelistTreeView"; import { createSpecEditor } from "./SpecEditor"; import { clearAllMementos } from "./MementoItem"; +import { ChangelistLens } from "./ChangelistLens"; let _isRegistered = false; const _disposable: vscode.Disposable[] = []; @@ -550,6 +551,14 @@ function doOneTimeRegistration() { ) ); + // If changelistLens feature is enabled, initialize ChangelistLens + const showChangelistLens = vscode.workspace + .getConfiguration("perforce") + .get("changelistLens.enabled"); + if (showChangelistLens) { + _disposable.push(ChangelistLens.getInstance()); + } + _disposable.push(vscode.workspace.onDidOpenTextDocument(onDidOpenTextDocument)); _disposable.push(vscode.workspace.onDidCloseTextDocument(onDidCloseTextDocument)); @@ -557,6 +566,12 @@ function doOneTimeRegistration() { PerforceCommands.registerCommands(); PerforceSCMProvider.registerCommands(); registerChangelistSearch(_context); + _disposable.push( + vscode.commands.registerCommand( + "perforce.toggleChangelistLens", + toggleChangelistLens + ) + ); } } @@ -795,3 +810,33 @@ async function clearMementos() { await clearAllMementos(_context.workspaceState, _context.globalState); } } + +/** + * Toggle the changelistLens feature on/off + */ +async function toggleChangelistLens() { + const config = vscode.workspace.getConfiguration("perforce"); + const isEnabled = config.get("changelistLens.enabled"); + + // Toggle setting + await config.update( + "changelistLens.enabled", + !isEnabled, + vscode.ConfigurationTarget.Global + ); + + if (!isEnabled) { + // If previously disabled, now enable, initialize instance + _disposable.push(ChangelistLens.getInstance()); + vscode.window.showInformationMessage( + "Perforce changelist information display enabled" + ); + } else { + // If previously enabled, now disable, dispose instance + const lens = ChangelistLens.getInstance(); + lens.dispose(); + vscode.window.showInformationMessage( + "Perforce changelist information display disabled" + ); + } +}