From e698cdc2e0c9c051342e59436623fcd77c3d02e7 Mon Sep 17 00:00:00 2001 From: Dan Kurtz Date: Mon, 3 Jan 2022 10:55:07 -0800 Subject: [PATCH 1/7] 117: show annotation for current line --- package-lock.json | 11 +++ package.json | 10 +++ package.nls.json | 3 +- src/annotations.ts | 168 +++++++++++++++++++++++++++++++++++++++++++++ src/diff.ts | 62 +++++++++++++++++ src/hg.ts | 36 ++++++++++ src/main.ts | 4 +- src/repository.ts | 11 +++ 8 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 src/annotations.ts diff --git a/package-lock.json b/package-lock.json index b866931..370b4ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "tmp": "0.2.1", + "vscode-diff": "^2.0.2", "vscode-nls": "^2.0.1" }, "devDependencies": { @@ -2182,6 +2183,11 @@ "integrity": "sha512-8OQ9CL+VWyt3JStj7HX7/ciTL2V3Rl1Wf5OL+SNTm0yK1KvtReVulksyeRnCANHHuUxHlQig+JJDlUhBt1NQDQ==", "dev": true }, + "node_modules/vscode-diff": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/vscode-diff/-/vscode-diff-2.0.2.tgz", + "integrity": "sha512-ic1x9f1hbSxCMci/gR04skY6X9DmRDnJuUkd9Y0dsvCQR5mWYBxB0MUMTl8vAYOpRWD1AAHzzsKOxqAvu9p8Uw==" + }, "node_modules/vscode-nls": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-2.0.2.tgz", @@ -4042,6 +4048,11 @@ "integrity": "sha512-8OQ9CL+VWyt3JStj7HX7/ciTL2V3Rl1Wf5OL+SNTm0yK1KvtReVulksyeRnCANHHuUxHlQig+JJDlUhBt1NQDQ==", "dev": true }, + "vscode-diff": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/vscode-diff/-/vscode-diff-2.0.2.tgz", + "integrity": "sha512-ic1x9f1hbSxCMci/gR04skY6X9DmRDnJuUkd9Y0dsvCQR5mWYBxB0MUMTl8vAYOpRWD1AAHzzsKOxqAvu9p8Uw==" + }, "vscode-nls": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-2.0.2.tgz", diff --git a/package.json b/package.json index 00cabfd..39c6e66 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,11 @@ "category": "Hg", "icon": "$(add)" }, + { + "command": "hg.annotate", + "title": "%command.annotate%", + "category": "Hg" + }, { "command": "hg.branch", "title": "%command.branch%", @@ -346,6 +351,10 @@ ], "menus": { "commandPalette": [ + { + "command": "hg.annotate", + "when": "config.hg.enabled && !hg.missing" + }, { "command": "hg.clone", "when": "config.hg.enabled && !hg.missing" @@ -991,6 +1000,7 @@ }, "dependencies": { "tmp": "0.2.1", + "vscode-diff": "^2.0.2", "vscode-nls": "^2.0.1" }, "devDependencies": { diff --git a/package.nls.json b/package.nls.json index 3472141..dd33335 100644 --- a/package.nls.json +++ b/package.nls.json @@ -1,4 +1,5 @@ { + "command.annotate": "Annotate", "command.clone": "Clone", "command.init": "Initialize Repository (Hg)", "command.close": "Close Repository", @@ -78,4 +79,4 @@ "config.pushPullScope.current": "Only the current branch / bookmark", "config.pushPullScope.default": "Only the default branch / bookmarks on the default branch", "config.useBookmarks": "Use bookmarks instead of branches" -} +} \ No newline at end of file diff --git a/src/annotations.ts b/src/annotations.ts new file mode 100644 index 0000000..d08dfb5 --- /dev/null +++ b/src/annotations.ts @@ -0,0 +1,168 @@ +import { DiffComputer, IDiffComputerOpts, ILineChange } from "vscode-diff"; + +import { debounce } from "./decorators"; + +import { applyLineChangesToAnnotations } from "./diff"; + +import { + DecorationOptions, + Disposable, + Range, + TextEditorDecorationType, + window, + DecorationRangeBehavior, + DecorationRenderOptions, + TextEditorSelectionChangeEvent, + TextDocument, + ThemeColor, +} from "vscode"; +import { Hg, ILineAnnotation } from "./hg"; +import { Model } from "./model"; +import { Repository } from "./repository"; + +const annotationDecoration: TextEditorDecorationType = window.createTextEditorDecorationType( + { + after: { + margin: "0 0 0 3em", + textDecoration: "none", + }, + rangeBehavior: DecorationRangeBehavior.ClosedOpen, + } as DecorationRenderOptions +); + +// const gutterAnnotationDecoration: TextEditorDecorationType = window.createTextEditorDecorationType({ + +// }); + +export class LineTracker extends Disposable { + private disposable: Disposable | undefined; + private hg: Hg; + private model: Model; + + constructor(hg: Hg, model: Model) { + super(() => this.dispose()); + this.hg = hg; + this.model = model; + this.start(); + } + + async diffHeadAndEditorContents( + document: TextDocument, + repo: Repository + ): Promise { + const editorLines = document.getText().split("\n"); + const headContents = ( + await repo.show("wdir()", document.uri.fsPath) + ).split("\n"); + const options: IDiffComputerOpts = { + shouldPostProcessCharChanges: false, + shouldIgnoreTrimWhitespace: true, + shouldMakePrettyDiff: false, + shouldComputeCharChanges: false, + maxComputationTime: 0, // time in milliseconds, 0 => no computation limit. + }; + const diffComputer = new DiffComputer( + headContents, + editorLines, + options + ); + const lineChanges: ILineChange[] = diffComputer.computeDiff().changes; + return lineChanges; + } + + @debounce(0) + async onTextEditorSelectionChanged( + event: TextEditorSelectionChangeEvent + ): Promise { + const selections = event.selections.map((x) => x.active.line); + const repo = this.model.repositories[0]; + if (event.textEditor.document.uri.scheme != "file") { + return; + } + console.log(); + const uncommittedChangeDiffs = await this.diffHeadAndEditorContents( + event.textEditor.document, + repo + ); + const workingCopyAnnotations = await repo.annotate( + event.textEditor.document.uri, + "wdir()" + ); + const annotations = applyLineChangesToAnnotations( + workingCopyAnnotations, + uncommittedChangeDiffs + ); + + const decorations = this.generateDecorations( + annotations, + selections, + event.textEditor.document + ); + + event.textEditor.setDecorations(annotationDecoration, decorations); + } + + generateDecorations( + annotations: ILineAnnotation[], + selections: number[], + document: TextDocument + ): DecorationOptions[] { + const annotationColor = new ThemeColor( + "editorOverviewRuler.modifiedForeground" + ); + const decorations = annotations.map((annotation, l) => { + let text = ""; + if (selections.includes(l)) { + text = this.formatAnnotation(annotation); + } + return { + range: document.validateRange( + new Range( + l, + Number.MAX_SAFE_INTEGER, + l, + Number.MAX_SAFE_INTEGER + ) + ), + renderOptions: { + after: { + color: annotationColor, + contentText: text, + fontWeight: "normal", + }, + }, + }; + }); + return decorations; + } + + formatAnnotation(annotation: ILineAnnotation): string { + if (annotation.hash == "ffffffffffff") { + return "Uncommitted changes"; + } else { + return `${ + annotation.user + }, ${annotation.date?.toLocaleDateString()} • ${ + annotation.description + }`; + } + } + + dispose(): void { + this.stop(); + } + start(): void { + this.disposable = Disposable.from( + window.onDidChangeTextEditorSelection( + this.onTextEditorSelectionChanged, + this + ) + ); + } + stop(): void { + if (this.disposable) { + this.disposable.dispose(); + } + this.disposable = undefined; + } +} diff --git a/src/diff.ts b/src/diff.ts index b5894f9..0f5851e 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -1,4 +1,5 @@ import { TextDocument, Range } from "vscode"; +import { ILineAnnotation } from "./hg"; export interface LineChange { readonly originalStartLineNumber: number; @@ -74,3 +75,64 @@ export function applyLineChanges( return result.join(""); } + +// Apply diffs to line annotations to reflect uncommitted changes. +export function applyLineChangesToAnnotations( + original: ILineAnnotation[], + diffs: LineChange[] +): ILineAnnotation[] { + const result: ILineAnnotation[] = []; + let currentLine = 0; + + for (const diff of diffs) { + const isInsertion = diff.originalEndLineNumber === 0; + const isDeletion = diff.modifiedEndLineNumber === 0; + + let endLine = isInsertion + ? diff.originalStartLineNumber + : diff.originalStartLineNumber - 1; + + // if this is a deletion at the very end of the document,then we need to account + // for a newline at the end of the last line which may have been deleted + // https://github.com/Microsoft/vscode/issues/59670 + if (isDeletion && diff.originalStartLineNumber === original.length) { + endLine -= 1; + } + + result.push(...original.slice(currentLine, endLine)); + + if (!isDeletion) { + const fromLine = diff.modifiedStartLineNumber - 1; + + // if this is an insertion at the very end of the document, + // then we must start the next range after the last character of the + // previous line, in order to take the correct eol + // if ( + // isInsertion && + // diff.originalStartLineNumber === original.length + // ) { + // fromLine -= 1; + // fromCharacter = modified.lineAt(fromLine).range.end.character; + // } + + result.push( + ...new Array( + diff.modifiedEndLineNumber - fromLine + ).fill({ + hash: "ffffffffffff", + user: "You", + date: undefined, + description: "Uncommitted changes", + } as ILineAnnotation) + ); + } + + currentLine = isInsertion + ? diff.originalStartLineNumber + : diff.originalEndLineNumber; + } + + result.push(...original.slice(currentLine, original.length)); + + return result; +} diff --git a/src/hg.ts b/src/hg.ts index 3fc2ba1..b86fa49 100644 --- a/src/hg.ts +++ b/src/hg.ts @@ -100,6 +100,13 @@ export interface GetRefsOptions { excludeTags?: boolean; } +export interface ILineAnnotation { + hash: string; + user: string; + date?: Date; + description: string; +} + export enum RefType { Branch, Tag, @@ -1643,6 +1650,35 @@ export class Repository { ); } + async annotate(filePath: string, rev?: string): Promise { + const templateFields = [ + "short(node)", + "shortdate(date)", + "person(author)", + "firstline(desc)", + ]; + const args = [ + "annotate", + "-T", + `"{lines % '{${templateFields.join("}|{")}}\\n'}"`, + filePath, + ]; + if (rev) { + args.push("-r", rev); + } + const annotateResult = await this.run(args); + const lines = annotateResult.stdout.trim().split("\n"); + return lines.map((annotation) => { + const components = annotation.split("|", templateFields.length); + return { + hash: components[0], + date: new Date(components[1]), + user: components[2], + description: components[3], + } as ILineAnnotation; + }); + } + async getParents(revision?: string): Promise { return this.getLogEntries({ revQuery: `parents(${revision || ""})` }); } diff --git a/src/main.ts b/src/main.ts index 7111eb8..b547b94 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,6 +16,7 @@ import { } from "vscode"; import { HgFinder, Hg, IHg } from "./hg"; import { Model } from "./model"; +import { LineTracker } from "./annotations"; import { CommandCenter } from "./commands"; import { warnAboutMissingHg } from "./interaction"; import { HgFileSystemProvider } from "./fileSystemProvider"; @@ -68,7 +69,8 @@ async function init( disposables.push( new CommandCenter(hg, model, outputChannel), - new HgFileSystemProvider(model) + new HgFileSystemProvider(model), + new LineTracker(hg, model) ); await checkHgVersion(info); diff --git a/src/repository.ts b/src/repository.ts index ab46a48..7f67340 100644 --- a/src/repository.ts +++ b/src/repository.ts @@ -37,6 +37,7 @@ import { GetRefsOptions, RefType, MoveOptions, + ILineAnnotation, } from "./hg"; import { anyEvent, @@ -266,6 +267,7 @@ export class Resource implements SourceControlResourceState { export const enum Operation { Status = "Status", Add = "Add", + Annotate = "Annotate", Commit = "Commit", Clean = "Clean", Branch = "Branch", @@ -295,6 +297,7 @@ export const enum Operation { function isReadOnly(operation: Operation): boolean { switch (operation) { + case Operation.Annotate: case Operation.GetCommitTemplate: case Operation.Show: case Operation.RollbackDryRun: @@ -1665,6 +1668,14 @@ export class Repository implements IDisposable, QuickDiffProvider { return this.repository.getLogEntries(opts); } + @throttle + public async annotate(uri: Uri, rev?: string): Promise { + return await this.run(Operation.Annotate, () => { + const filePath = this.mapFileUriToRepoRelativePath(uri); + return this.repository.annotate(filePath, rev); + }); + } + @throttle private async updateModelState(): Promise { this._repoStatus = await this.repository.getSummary(); From 615bcd1dbfcb99ba73b87345365d6a3b6aa517f2 Mon Sep 17 00:00:00 2001 From: Dan Kurtz Date: Mon, 3 Jan 2022 10:55:12 -0800 Subject: [PATCH 2/7] #117: config value for line annotations --- README.md | 5 +++++ package.json | 5 +++++ package.nls.json | 3 ++- src/annotations.ts | 31 +++++++++++++++++++++++++++---- src/config.ts | 4 ++++ 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 57fcc22..b7aa789 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ - Rebase support. +- Show annotation for current line + # Feedback & Contributing - Please report any bugs, suggestions or documentation requests via the [Github issues](https://github.com/mrcrowl/vscode-hg/issues) (_yes_, I see the irony). @@ -135,6 +137,9 @@ `"cli"` — spawn a new `hg` process per command (default). `"server"` — run a command server process  _i.e. `hg serve --cmdserve`_ +`hg.annotationEnabled` +- Enables annotation decorations at end of lines + # Acknowledgements [ajansveld](https://github.com/ajansveld), [hoffmael](https://github.com/hoffmael), [nioh-wiki](https://github.com/nioh-wiki), [joaomoreno](https://github.com/joaomoreno), [nsgundy](https://github.com/nsgundy) diff --git a/package.json b/package.json index 39c6e66..4f20250 100644 --- a/package.json +++ b/package.json @@ -975,6 +975,11 @@ "%config.pushPullScope.default%" ], "default": "all" + }, + "hg.lineAnnotationEnabled": { + "type": "boolean", + "description": "%config.lineAnnotationEnabled%", + "default": false } } }, diff --git a/package.nls.json b/package.nls.json index dd33335..893261e 100644 --- a/package.nls.json +++ b/package.nls.json @@ -78,5 +78,6 @@ "config.pushPullScope.all": "All branches / bookmarks", "config.pushPullScope.current": "Only the current branch / bookmark", "config.pushPullScope.default": "Only the default branch / bookmarks on the default branch", - "config.useBookmarks": "Use bookmarks instead of branches" + "config.useBookmarks": "Use bookmarks instead of branches", + "config.lineAnnotationEnabled": "Show annotation at the end of the line" } \ No newline at end of file diff --git a/src/annotations.ts b/src/annotations.ts index d08dfb5..9ea1c29 100644 --- a/src/annotations.ts +++ b/src/annotations.ts @@ -15,10 +15,13 @@ import { TextEditorSelectionChangeEvent, TextDocument, ThemeColor, + workspace, + ConfigurationChangeEvent, } from "vscode"; import { Hg, ILineAnnotation } from "./hg"; import { Model } from "./model"; import { Repository } from "./repository"; +import typedConfig from "./config"; const annotationDecoration: TextEditorDecorationType = window.createTextEditorDecorationType( { @@ -36,6 +39,7 @@ const annotationDecoration: TextEditorDecorationType = window.createTextEditorDe export class LineTracker extends Disposable { private disposable: Disposable | undefined; + private configDisposable: Disposable; private hg: Hg; private model: Model; @@ -43,7 +47,27 @@ export class LineTracker extends Disposable { super(() => this.dispose()); this.hg = hg; this.model = model; - this.start(); + this.configDisposable = Disposable.from( + workspace.onDidChangeConfiguration( + this.onConfigurationChanged, + this + ) + ); + this.applyConfiguration(); + } + + onConfigurationChanged(e: ConfigurationChangeEvent): void { + if (e.affectsConfiguration("hg")) { + this.applyConfiguration(); + } + } + + applyConfiguration(): void { + if (typedConfig.lineAnnotationEnabled) { + this.start(); + } else { + this.stop(); + } } async diffHeadAndEditorContents( @@ -150,6 +174,7 @@ export class LineTracker extends Disposable { dispose(): void { this.stop(); + this.configDisposable.dispose(); } start(): void { this.disposable = Disposable.from( @@ -160,9 +185,7 @@ export class LineTracker extends Disposable { ); } stop(): void { - if (this.disposable) { - this.disposable.dispose(); - } + this.disposable?.dispose(); this.disposable = undefined; } } diff --git a/src/config.ts b/src/config.ts index e7c5e1a..2a9deb4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -83,6 +83,10 @@ class Config { this.pushPullScope ); } + + get lineAnnotationEnabled(): boolean { + return this.get("lineAnnotationEnabled", true); + } } const typedConfig = new Config(); From 92e9d9c1de14ae034c17604f3ae5d07e3728f343 Mon Sep 17 00:00:00 2001 From: Dan Kurtz Date: Mon, 3 Jan 2022 10:55:12 -0800 Subject: [PATCH 3/7] #117: basic cache of annotations --- src/annotations.ts | 41 ++++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/src/annotations.ts b/src/annotations.ts index 9ea1c29..6637216 100644 --- a/src/annotations.ts +++ b/src/annotations.ts @@ -17,6 +17,7 @@ import { ThemeColor, workspace, ConfigurationChangeEvent, + Uri, } from "vscode"; import { Hg, ILineAnnotation } from "./hg"; import { Model } from "./model"; @@ -33,9 +34,34 @@ const annotationDecoration: TextEditorDecorationType = window.createTextEditorDe } as DecorationRenderOptions ); -// const gutterAnnotationDecoration: TextEditorDecorationType = window.createTextEditorDecorationType({ +const fileCache = new (class LineAnnotationCache { + private fileAnnotationCache = new Map(); + + async getFileAnnotations( + repo: Repository, + uri: Uri + ): Promise { + // Cache file annotations + if (!this.fileAnnotationCache.has(uri)) { + this.fileAnnotationCache.set( + uri, + await repo.annotate(uri, "wdir()") + ); + } + return this.fileAnnotationCache.get(uri)!; + } -// }); + clearFileCache(file?: Uri | TextDocument): void { + // Clear cache of a single file if given, or the whole thing + if (file instanceof Uri) { + this.fileAnnotationCache.delete(file); + } else if (file) { + this.fileAnnotationCache.delete((file as TextDocument).uri); + } else { + this.fileAnnotationCache.clear(); + } + } +})(); export class LineTracker extends Disposable { private disposable: Disposable | undefined; @@ -103,14 +129,13 @@ export class LineTracker extends Disposable { if (event.textEditor.document.uri.scheme != "file") { return; } - console.log(); const uncommittedChangeDiffs = await this.diffHeadAndEditorContents( event.textEditor.document, repo ); - const workingCopyAnnotations = await repo.annotate( - event.textEditor.document.uri, - "wdir()" + const workingCopyAnnotations = await fileCache.getFileAnnotations( + repo, + event.textEditor.document.uri ); const annotations = applyLineChangesToAnnotations( workingCopyAnnotations, @@ -181,11 +206,13 @@ export class LineTracker extends Disposable { window.onDidChangeTextEditorSelection( this.onTextEditorSelectionChanged, this - ) + ), + workspace.onDidCloseTextDocument(fileCache.clearFileCache) ); } stop(): void { this.disposable?.dispose(); this.disposable = undefined; + fileCache.clearFileCache(); } } From be08cb02de48c340d559a54b4f504020b57bc59d Mon Sep 17 00:00:00 2001 From: Dan Kurtz Date: Mon, 3 Jan 2022 10:55:12 -0800 Subject: [PATCH 4/7] 117: annotations for entire file A "Toggle Annotations" command annotates the current file. The annotations disappear when the file is edited. Each annotated editor has a GutterAnnotationProvider that provides the annotations. A FileAnnotationController monitors editor events and manages the lifecycle of the GutterAnnotationProviders. --- package.json | 14 +-- package.nls.json | 3 +- src/annotations.ts | 299 +++++++++++++++++++++++++++++++++++++++++---- src/commands.ts | 30 ++++- src/main.ts | 4 +- 5 files changed, 315 insertions(+), 35 deletions(-) diff --git a/package.json b/package.json index 4f20250..7170799 100644 --- a/package.json +++ b/package.json @@ -52,11 +52,6 @@ "category": "Hg", "icon": "$(add)" }, - { - "command": "hg.annotate", - "title": "%command.annotate%", - "category": "Hg" - }, { "command": "hg.branch", "title": "%command.branch%", @@ -347,13 +342,18 @@ "command": "hg.unshelveContinue", "title": "%command.unshelveContinue%", "category": "Hg" + }, + { + "command": "hg.toggleAnnotations", + "title": "%command.toggleAnnotations%", + "category": "Hg" } ], "menus": { "commandPalette": [ { - "command": "hg.annotate", - "when": "config.hg.enabled && !hg.missing" + "command": "hg.toggleAnnotations", + "when": "config.hg.enabled && !hg.missing && hgOpenRepositoryCount != 0" }, { "command": "hg.clone", diff --git a/package.nls.json b/package.nls.json index 893261e..a091938 100644 --- a/package.nls.json +++ b/package.nls.json @@ -54,6 +54,7 @@ "command.unshelveKeep": "Unshelve and Keep", "command.unshelveAbort": "Abort unshelve", "command.unshelveContinue": "Continue unshelve", + "command.toggleAnnotations": "Toggle annotations for this file", "config.enabled": "Whether Hg is enabled", "config.path": "Path to the 'hg' executable (only required if auto-detection fails)", "config.autoInOut": "Whether auto-incoming/outgoing counts are enabled", @@ -79,5 +80,5 @@ "config.pushPullScope.current": "Only the current branch / bookmark", "config.pushPullScope.default": "Only the default branch / bookmarks on the default branch", "config.useBookmarks": "Use bookmarks instead of branches", - "config.lineAnnotationEnabled": "Show annotation at the end of the line" + "config.lineAnnotationEnabled": "Show hg annotation at the end of the line" } \ No newline at end of file diff --git a/src/annotations.ts b/src/annotations.ts index 6637216..435fba7 100644 --- a/src/annotations.ts +++ b/src/annotations.ts @@ -18,13 +18,18 @@ import { workspace, ConfigurationChangeEvent, Uri, + TextEditor, + MarkdownString, + TextDocumentChangeEvent, } from "vscode"; import { Hg, ILineAnnotation } from "./hg"; import { Model } from "./model"; import { Repository } from "./repository"; import typedConfig from "./config"; -const annotationDecoration: TextEditorDecorationType = window.createTextEditorDecorationType( +const GUTTER_CHARACTER_WIDTH = 50; + +const currentLineDecoration: TextEditorDecorationType = window.createTextEditorDecorationType( { after: { margin: "0 0 0 3em", @@ -34,6 +39,15 @@ const annotationDecoration: TextEditorDecorationType = window.createTextEditorDe } as DecorationRenderOptions ); +const gutterDecoration: TextEditorDecorationType = window.createTextEditorDecorationType( + { + before: { + margin: "0", + textDecoration: "none", + }, + rangeBehavior: DecorationRangeBehavior.ClosedOpen, + } as DecorationRenderOptions +); const fileCache = new (class LineAnnotationCache { private fileAnnotationCache = new Map(); @@ -63,11 +77,11 @@ const fileCache = new (class LineAnnotationCache { } })(); -export class LineTracker extends Disposable { - private disposable: Disposable | undefined; - private configDisposable: Disposable; - private hg: Hg; - private model: Model; +abstract class BaseAnnotationProvider extends Disposable { + protected disposable: Disposable | undefined; + protected configDisposable: Disposable; + protected hg: Hg; + protected model: Model; constructor(hg: Hg, model: Model) { super(() => this.dispose()); @@ -88,13 +102,7 @@ export class LineTracker extends Disposable { } } - applyConfiguration(): void { - if (typedConfig.lineAnnotationEnabled) { - this.start(); - } else { - this.stop(); - } - } + abstract applyConfiguration(): void; async diffHeadAndEditorContents( document: TextDocument, @@ -120,6 +128,27 @@ export class LineTracker extends Disposable { return lineChanges; } + dispose(): void { + this.stop(); + this.configDisposable.dispose(); + } + + stop(): void { + this.disposable?.dispose(); + this.disposable = undefined; + fileCache.clearFileCache(); + } +} + +export class CurrentLineAnnotationProvider extends BaseAnnotationProvider { + applyConfiguration(): void { + if (typedConfig.lineAnnotationEnabled) { + this.start(); + } else { + this.stop(); + } + } + @debounce(0) async onTextEditorSelectionChanged( event: TextEditorSelectionChangeEvent @@ -148,7 +177,7 @@ export class LineTracker extends Disposable { event.textEditor.document ); - event.textEditor.setDecorations(annotationDecoration, decorations); + event.textEditor.setDecorations(currentLineDecoration, decorations); } generateDecorations( @@ -197,10 +226,6 @@ export class LineTracker extends Disposable { } } - dispose(): void { - this.stop(); - this.configDisposable.dispose(); - } start(): void { this.disposable = Disposable.from( window.onDidChangeTextEditorSelection( @@ -210,9 +235,241 @@ export class LineTracker extends Disposable { workspace.onDidCloseTextDocument(fileCache.clearFileCache) ); } +} + +export class GutterAnnotationProvider extends BaseAnnotationProvider { + private _editor: TextEditor; + private _decorations: DecorationOptions[] | undefined; + + constructor(editor: TextEditor, hg: Hg, model: Model) { + super(hg, model); + this._editor = editor; + } + + applyConfiguration(): void { + return; + } + + async provideAnnotations(): Promise { + const repo = this.model.repositories[0]; + const uncommittedChangeDiffs = await this.diffHeadAndEditorContents( + this._editor.document, + repo + ); + const workingCopyAnnotations = await fileCache.getFileAnnotations( + repo, + this._editor.document.uri + ); + const annotations = applyLineChangesToAnnotations( + workingCopyAnnotations, + uncommittedChangeDiffs + ); + + const decorations = this.generateDecorations( + annotations, + [], + this._editor.document + ); + + this.setDecorations(decorations); + } + + protected setDecorations(decorations: DecorationOptions[]): void { + if (this._decorations?.length) { + this.clearDecorations(); + } + + this._decorations = decorations; + if (this._decorations?.length) { + this._editor.setDecorations(gutterDecoration, decorations); + } + } + + clearDecorations(): void { + if (this._editor == null) return; + + this._editor.setDecorations(gutterDecoration, []); + this._decorations = undefined; + } + + generateDecorations( + annotations: ILineAnnotation[], + selections: number[], + document: TextDocument + ): DecorationOptions[] { + const annotationColor = new ThemeColor("input.foreground"); + let previousHash: string | undefined; + const decorations = annotations.map((annotation, l) => { + let text: string; + if (annotation.hash != previousHash) { + text = this.formatAnnotation(annotation); + previousHash = annotation.hash; + } else { + text = "".padEnd(GUTTER_CHARACTER_WIDTH); + } + + return { + range: document.validateRange(new Range(l, 0, l, 0)), + renderOptions: { + before: { + backgroundColor: new ThemeColor( + "editorGroupHeader.tabsBackground" + ), + color: annotationColor, + contentText: text, + fontWeight: "normal", + height: "100%", + margin: "0 26px -1px 0", + textDecoration: "overline solid rgba(0, 0, 0, .2)", + width: `calc(${GUTTER_CHARACTER_WIDTH}ch)`, + }, + }, + }; + }); + return decorations; + } + + formatAnnotation(annotation: ILineAnnotation): string { + if (annotation.hash == "ffffffffffff") { + return "Uncommitted changes".padEnd(GUTTER_CHARACTER_WIDTH); + } else if (!annotation.description) { + return annotation.hash.padEnd(GUTTER_CHARACTER_WIDTH); + } else { + const dateString = annotation.date?.toLocaleDateString() || ""; + const descriptionWidth = + GUTTER_CHARACTER_WIDTH - dateString.length - 2; + let description; + if (annotation.description.length >= descriptionWidth) { + description = + annotation.description.substring(0, descriptionWidth - 1) + + "…"; + } else { + description = annotation.description.padEnd( + descriptionWidth, + "\u00a0" + ); + } + return `${description} ${dateString}`; + } + } + + restore(editor: TextEditor): void { + this._editor = editor; + if (this._decorations?.length) { + this._editor.setDecorations(gutterDecoration, this._decorations); + } + } + + start(): void { + this.provideAnnotations(); + } + stop(): void { - this.disposable?.dispose(); - this.disposable = undefined; - fileCache.clearFileCache(); + this.clearDecorations(); + fileCache.clearFileCache(this._editor.document); + } + + dispose(): void { + this.stop(); + } +} + +export class FileAnnotationController implements Disposable { + private _hg: Hg; + private _model: Model; + private _disposables = new Array(); + private _annotationProviders = new Map(); + + constructor(hg: Hg, model: Model) { + this._hg = hg; + this._model = model; + this._disposables.push( + workspace.onDidChangeTextDocument(this.onDocumentChanged, this), + workspace.onDidCloseTextDocument(this.onDocumentClosed, this), + window.onDidChangeVisibleTextEditors( + this.onVisibleTextEditorsChanged, + this + ) + ); + } + + isShowing(editor: TextEditor): boolean { + return this._annotationProviders.has(this.getProviderKey(editor)); + } + + toggle(editor: TextEditor): void { + if (this.isShowing(editor)) { + this.clear(editor.document); + } else { + this.show(editor); + } + } + + getProvider( + editor: TextEditor | undefined + ): GutterAnnotationProvider | undefined { + if (editor == null || editor.document == null) return undefined; + const key = this.getProviderKey(editor); + return this._annotationProviders.get(key); + } + + getProviderKey(docOrEd: TextDocument | TextEditor): Uri { + let document: TextDocument; + if ("document" in docOrEd) { + document = docOrEd.document; + } else { + document = docOrEd; + } + return document.uri; + } + + show(editor: TextEditor): void { + if (this.getProvider(editor)) { + return; + } + + const provider = new GutterAnnotationProvider( + editor, + this._hg, + this._model + ); + provider.start(); + this._annotationProviders.set(this.getProviderKey(editor), provider); + } + + clear(document: TextDocument): void { + const key = this.getProviderKey(document); + if (!this._annotationProviders.has(key)) { + return; + } + const provider = this._annotationProviders.get(key)!; + provider.dispose(); + this._annotationProviders.delete(key); + } + + @debounce(50) + onDocumentChanged(e: TextDocumentChangeEvent): void { + // Clear the annotation on edit + this.clear(e.document); + } + + onDocumentClosed(document: TextDocument): void { + this.clear(document); + } + + onVisibleTextEditorsChanged(editors: readonly TextEditor[]): void { + // VS Code clears decorations on tab change, so we need to restore them + let provider: GutterAnnotationProvider | undefined; + for (const e of editors) { + provider = this.getProvider(e); + if (provider == null) continue; + + void provider.restore(e); + } + } + + dispose(): void { + this._disposables.forEach((provider) => provider.dispose()); + this._annotationProviders.forEach((provider) => provider.dispose()); } } diff --git a/src/commands.ts b/src/commands.ts index 2bf1c0f..3e36ff5 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -43,6 +43,7 @@ import { LogEntriesOptions, Repository, } from "./repository"; +import { FileAnnotationController } from "./annotations"; import * as path from "path"; import * as os from "os"; import { @@ -97,15 +98,14 @@ function command(commandId: string, options: CommandOptions = {}): Function { export class CommandCenter { private model: Model; private disposables: Disposable[]; + private fileAnnotationsController: FileAnnotationController; constructor( private hg: Hg, - model: Model | undefined, + model: Model, private outputChannel: OutputChannel ) { - if (model) { - this.model = model; - } + this.model = model; this.disposables = Commands.map( ({ commandId, key, method, options }) => { @@ -123,6 +123,11 @@ export class CommandCenter { // } } ); + this.fileAnnotationsController = new FileAnnotationController( + hg, + model + ); + this.disposables.push(this.fileAnnotationsController); } @command("hg.refresh", { repository: true }) @@ -1602,6 +1607,23 @@ export class CommandCenter { } } + // Toggle line annotations for the current file + @command("hg.toggleAnnotations", { repository: true }) + async toggleAnnotate(_repository: Repository): Promise { + const activeEditor = window.activeTextEditor; + // TODO: allow annotate of a specific hg rev + if (!activeEditor || activeEditor.document.uri.scheme != "file") { + return; + } + try { + this.fileAnnotationsController.toggle(activeEditor); + } catch (ex) { + void window.showErrorMessage( + `Unable to toggle file ${activeEditor.document.uri} annotations. See output channel for more details` + ); + } + } + private async diffFile( repository: Repository, rev1: Revision, diff --git a/src/main.ts b/src/main.ts index b547b94..f3cb42e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,7 +16,7 @@ import { } from "vscode"; import { HgFinder, Hg, IHg } from "./hg"; import { Model } from "./model"; -import { LineTracker } from "./annotations"; +import { CurrentLineAnnotationProvider } from "./annotations"; import { CommandCenter } from "./commands"; import { warnAboutMissingHg } from "./interaction"; import { HgFileSystemProvider } from "./fileSystemProvider"; @@ -70,7 +70,7 @@ async function init( disposables.push( new CommandCenter(hg, model, outputChannel), new HgFileSystemProvider(model), - new LineTracker(hg, model) + new CurrentLineAnnotationProvider(hg, model) ); await checkHgVersion(info); From d91daa12016d5e3301698301c1f90ad541f2e4e0 Mon Sep 17 00:00:00 2001 From: Dan Kurtz Date: Mon, 3 Jan 2022 10:55:12 -0800 Subject: [PATCH 5/7] Annotate includes a hover with a link to more info about the revision --- src/annotations.ts | 25 ++++++++++++++++++++++--- src/commands.ts | 11 +++++++++++ src/interaction.ts | 6 ++++-- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/annotations.ts b/src/annotations.ts index 435fba7..0b0fc3b 100644 --- a/src/annotations.ts +++ b/src/annotations.ts @@ -307,9 +307,9 @@ export class GutterAnnotationProvider extends BaseAnnotationProvider { } else { text = "".padEnd(GUTTER_CHARACTER_WIDTH); } - - return { + const decoration = { range: document.validateRange(new Range(l, 0, l, 0)), + hoverMessage: this.hoverForAnnotation(annotation, document.uri), renderOptions: { before: { backgroundColor: new ThemeColor( @@ -319,16 +319,35 @@ export class GutterAnnotationProvider extends BaseAnnotationProvider { contentText: text, fontWeight: "normal", height: "100%", - margin: "0 26px -1px 0", + margin: "0 26px -1px 6px", textDecoration: "overline solid rgba(0, 0, 0, .2)", width: `calc(${GUTTER_CHARACTER_WIDTH}ch)`, }, }, }; + return decoration; }); return decorations; } + hoverForAnnotation( + annotation: ILineAnnotation, + _fileUri: Uri + ): MarkdownString { + const commandArgs = encodeURIComponent( + JSON.stringify([annotation.hash]) + ); + const commandUri = Uri.parse(`command:hg.logRev?${commandArgs}`); + let hoverMsg = new MarkdownString( + `[${annotation.hash}](${commandUri}): ` + ); + hoverMsg = hoverMsg.appendText( + `${annotation.user} • ${annotation.description}` + ); + hoverMsg.isTrusted = true; + return hoverMsg; + } + formatAnnotation(annotation: ILineAnnotation): string { if (annotation.hash == "ffffffffffff") { return "Uncommitted changes".padEnd(GUTTER_CHARACTER_WIDTH); diff --git a/src/commands.ts b/src/commands.ts index 3e36ff5..fca7de6 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1485,6 +1485,17 @@ export class CommandCenter { ); } + @command("hg.logRev", { repository: true }) + async logRev(repository: Repository, rev: string): Promise { + const commit = await repository.getCommitDetails(rev); + const selectedFile = await interaction.presentCommitDetails( + commit, + undefined, + this.createLogMenuAPI(repository) + ); + selectedFile?.run(); + } + @command("hg.logDefault", { repository: true }) async logDefault(repository: Repository): Promise { interaction.presentLogMenu( diff --git a/src/interaction.ts b/src/interaction.ts index 59eacf0..7d162d0 100644 --- a/src/interaction.ts +++ b/src/interaction.ts @@ -815,7 +815,7 @@ export namespace interaction { export async function presentCommitDetails( details: CommitDetails, - back: RunnableQuickPickItem, + back: RunnableQuickPickItem | undefined, commands: LogMenuAPI ): Promise { const placeHolder = describeCommitOneLine(details); @@ -828,10 +828,12 @@ export namespace interaction { const backToSelfRunnable = () => presentCommitDetails(details, back, commands); const items = [ - back, asLabelItem("Files", undefined, backToSelfRunnable), ...filePickItems, ]; + if (back) { + items.unshift(back); + } const choice = await window.showQuickPick( items, From f31bab70ed01742271b5ed450ff9e50108148750 Mon Sep 17 00:00:00 2001 From: Dan Kurtz Date: Mon, 3 Jan 2022 10:55:12 -0800 Subject: [PATCH 6/7] Clear cache when repo changes --- src/annotations.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/annotations.ts b/src/annotations.ts index 0b0fc3b..86271a2 100644 --- a/src/annotations.ts +++ b/src/annotations.ts @@ -23,7 +23,7 @@ import { TextDocumentChangeEvent, } from "vscode"; import { Hg, ILineAnnotation } from "./hg"; -import { Model } from "./model"; +import { Model, ModelChangeEvent } from "./model"; import { Repository } from "./repository"; import typedConfig from "./config"; @@ -226,13 +226,22 @@ export class CurrentLineAnnotationProvider extends BaseAnnotationProvider { } } + @debounce(1000) + onRepositoryChanged(_e: ModelChangeEvent): void { + fileCache.clearFileCache(); + } + start(): void { this.disposable = Disposable.from( window.onDidChangeTextEditorSelection( this.onTextEditorSelectionChanged, this ), - workspace.onDidCloseTextDocument(fileCache.clearFileCache) + workspace.onDidCloseTextDocument( + fileCache.clearFileCache, + fileCache + ), + this.model.onDidChangeRepository(this.onRepositoryChanged, this) ); } } From 001f8d71ce54861a09d07847a5a357e7b7b091cb Mon Sep 17 00:00:00 2001 From: Dan Kurtz Date: Mon, 3 Jan 2022 10:55:12 -0800 Subject: [PATCH 7/7] Prettier fixes --- README.md | 3 ++- package.nls.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b7aa789..5e7dd27 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,8 @@ `"server"` — run a command server process  _i.e. `hg serve --cmdserve`_ `hg.annotationEnabled` -- Enables annotation decorations at end of lines + +- Enables annotation decorations at end of lines # Acknowledgements diff --git a/package.nls.json b/package.nls.json index a091938..6e80de9 100644 --- a/package.nls.json +++ b/package.nls.json @@ -81,4 +81,4 @@ "config.pushPullScope.default": "Only the default branch / bookmarks on the default branch", "config.useBookmarks": "Use bookmarks instead of branches", "config.lineAnnotationEnabled": "Show hg annotation at the end of the line" -} \ No newline at end of file +}