diff --git a/CHANGELOG.md b/CHANGELOG.md index fdd4c8c..ded5913 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [Unreleased] +### Added +- Adds multi-cursor support to current line annotations — closes [#291](https://github.com/eamodio/vscode-gitlens/issues/291) + ## [8.0.2] - 2018-02-19 ### Fixed - Fixes button colors on the Welcome and Settings pages to follow the color theme properly diff --git a/src/currentLineController.ts b/src/currentLineController.ts index 37d6694..2b796ab 100644 --- a/src/currentLineController.ts +++ b/src/currentLineController.ts @@ -7,8 +7,8 @@ import { configuration, IConfig, StatusBarCommand } from './configuration'; import { isTextEditor, RangeEndOfLineIndex } from './constants'; import { Container } from './container'; import { DocumentBlameStateChangeEvent, DocumentDirtyIdleTriggerEvent, DocumentDirtyStateChangeEvent, GitDocumentState, TrackedDocument } from './trackers/documentTracker'; -import { CommitFormatter, GitCommit, GitCommitLine, ICommitFormatOptions } from './gitService'; -import { GitLineState, LineChangeEvent, LineTracker } from './trackers/lineTracker'; +import { CommitFormatter, GitBlameLine, GitCommit, ICommitFormatOptions } from './gitService'; +import { GitLineState, LinesChangeEvent, LineTracker } from './trackers/lineTracker'; const annotationDecoration: TextEditorDecorationType = window.createTextEditorDecorationType({ after: { @@ -142,7 +142,7 @@ export class CurrentLineController extends Disposable { this._lineTracker.start(); this._lineTrackingDisposable = this._lineTrackingDisposable || Disposable.from( - this._lineTracker.onDidChangeActiveLine(this.onActiveLineChanged, this), + this._lineTracker.onDidChangeActiveLines(this.onActiveLinesChanged, this), Container.tracker.onDidChangeBlameState(this.onBlameStateChanged, this), Container.tracker.onDidChangeDirtyState(this.onDirtyStateChanged, this), Container.tracker.onDidTriggerDirtyIdle(this.onDirtyIdleTriggered, this) @@ -160,14 +160,14 @@ export class CurrentLineController extends Disposable { this.refresh(window.activeTextEditor, { full: true }); } - private onActiveLineChanged(e: LineChangeEvent) { - if (!e.pending && e.line !== undefined) { + private onActiveLinesChanged(e: LinesChangeEvent) { + if (!e.pending && e.lines !== undefined) { this.refresh(e.editor); return; } - this.clear(e.editor, (Container.config.statusBar.reduceFlicker && e.reason === 'line' && e.line !== undefined) ? 'line' : undefined); + this.clear(e.editor, (Container.config.statusBar.reduceFlicker && e.reason === 'lines' && e.lines !== undefined) ? 'lines' : undefined); } private onBlameStateChanged(e: DocumentBlameStateChangeEvent) { @@ -215,7 +215,7 @@ export class CurrentLineController extends Disposable { this.refresh(window.activeTextEditor); } - async clear(editor: TextEditor | undefined, reason?: 'line') { + async clear(editor: TextEditor | undefined, reason?: 'lines') { if (this._editor !== editor && this._editor !== undefined) { this.clearAnnotations(this._editor); } @@ -224,16 +224,16 @@ export class CurrentLineController extends Disposable { this._lineTracker.reset(); this.unregisterHoverProviders(); - if (this._statusBarItem !== undefined && reason !== 'line') { + if (this._statusBarItem !== undefined && reason !== 'lines') { this._statusBarItem.hide(); } } async provideDetailsHover(document: TextDocument, position: Position, token: CancellationToken): Promise { - if (this._editor === undefined || this._editor.document !== document) return undefined; - if (this._lineTracker.line !== position.line) return undefined; + if (this._editor === undefined || this._editor.document !== document || !this._lineTracker.includes(position.line)) return undefined; - const commit = this._lineTracker.state !== undefined ? this._lineTracker.state.commit : undefined; + const lineState = this._lineTracker.getState(position.line); + const commit = lineState !== undefined ? lineState.commit : undefined; if (commit === undefined) return undefined; // Avoid double annotations if we are showing the whole-file hover blame annotations @@ -246,7 +246,7 @@ export class CurrentLineController extends Disposable { if (!wholeLine && range.start.character !== position.character) return undefined; // Get the full commit message -- since blame only returns the summary - let logCommit = this._lineTracker.state !== undefined ? this._lineTracker.state.logCommit : undefined; + let logCommit = lineState !== undefined ? lineState.logCommit : undefined; if (logCommit === undefined && !commit.isUncommitted) { logCommit = await Container.git.getLogCommitForFile(commit.repoPath, commit.uri.fsPath, { ref: commit.sha }); if (logCommit !== undefined) { @@ -254,8 +254,8 @@ export class CurrentLineController extends Disposable { logCommit.previousSha = commit.previousSha; logCommit.previousFileName = commit.previousFileName; - if (this._lineTracker.state !== undefined) { - this._lineTracker.state.logCommit = logCommit; + if (lineState !== undefined) { + lineState.logCommit = logCommit; } } } @@ -268,10 +268,10 @@ export class CurrentLineController extends Disposable { } async provideChangesHover(document: TextDocument, position: Position, token: CancellationToken): Promise { - if (this._editor === undefined || this._editor.document !== document) return undefined; - if (this._lineTracker.line !== position.line) return undefined; + if (this._editor === undefined || this._editor.document !== document || !this._lineTracker.includes(position.line)) return undefined; - const commit = this._lineTracker.state !== undefined ? this._lineTracker.state.commit : undefined; + const lineState = this._lineTracker.getState(position.line); + const commit = lineState !== undefined ? lineState.commit : undefined; if (commit === undefined) return undefined; // Avoid double annotations if we are showing the whole-file hover blame annotations @@ -294,21 +294,6 @@ export class CurrentLineController extends Disposable { return new Hover(hover.hoverMessage, range); } - async show(commit: GitCommit, blameLine: GitCommitLine, editor: TextEditor, line: number) { - // I have no idea why I need this protection -- but it happens - if (editor.document === undefined) return; - - if (editor.document.isDirty) { - const trackedDocument = await Container.tracker.get(editor.document); - if (trackedDocument !== undefined) { - trackedDocument.setForceDirtyStateChangeOnNextDocumentChange(); - } - } - - this.updateStatusBar(commit, editor); - this.updateTrailingAnnotation(commit, blameLine, editor, line); - } - async showAnnotations(editor: TextEditor | undefined) { this.setBlameAnnotationState(true, editor); } @@ -378,16 +363,12 @@ export class CurrentLineController extends Disposable { }; } - private _updateBlameDebounced: (((line: number, editor: TextEditor, trackedDocument: TrackedDocument) => void) & IDeferrable) | undefined; + private _updateBlameDebounced: (((lines: number[], editor: TextEditor, trackedDocument: TrackedDocument) => void) & IDeferrable) | undefined; private async refresh(editor: TextEditor | undefined, options: { full?: boolean, trackedDocument?: TrackedDocument } = {}) { if (editor === undefined && this._editor === undefined) return; - if (editor === undefined || this._lineTracker.line === undefined) { - this.clear(this._editor); - - return; - } + if (editor === undefined || this._lineTracker.lines === undefined) return this.clear(this._editor); if (this._editor !== editor) { // If we are changing editor, consider this a full refresh @@ -414,7 +395,7 @@ export class CurrentLineController extends Disposable { if (this._updateBlameDebounced === undefined) { this._updateBlameDebounced = Functions.debounce(this.updateBlame, 50, { track: true }); } - this._updateBlameDebounced(this._lineTracker.line, editor, options.trackedDocument); + this._updateBlameDebounced(this._lineTracker.lines, editor, options.trackedDocument); return; } @@ -447,39 +428,56 @@ export class CurrentLineController extends Disposable { } } - private async updateBlame(line: number, editor: TextEditor, trackedDocument: TrackedDocument) { + private async updateBlame(lines: number[], editor: TextEditor, trackedDocument: TrackedDocument) { this._lineTracker.reset(); // Make sure we are still on the same line and not pending - if (this._lineTracker.line !== line || (this._updateBlameDebounced && this._updateBlameDebounced.pending!())) return; + if (!this._lineTracker.includesAll(lines) || (this._updateBlameDebounced && this._updateBlameDebounced.pending!())) return; - const blameLine = editor.document.isDirty - ? await Container.git.getBlameForLineContents(trackedDocument.uri, line, editor.document.getText()) - : await Container.git.getBlameForLine(trackedDocument.uri, line); + let blameLines; + if (lines.length === 1) { + const blameLine = editor.document.isDirty + ? await Container.git.getBlameForLineContents(trackedDocument.uri, lines[0], editor.document.getText()) + : await Container.git.getBlameForLine(trackedDocument.uri, lines[0]); + if (blameLine === undefined) return this.clear(editor); - let commit; - let commitLine; + blameLines = [blameLine]; + } + else { + const blame = editor.document.isDirty + ? await Container.git.getBlameForFileContents(trackedDocument.uri, editor.document.getText()) + : await Container.git.getBlameForFile(trackedDocument.uri); + if (blame === undefined) return this.clear(editor); + + blameLines = lines.map(l => { + const commitLine = blame.lines[l]; + return { + line: commitLine, + commit: blame.commits.get(commitLine.sha)! + }; + }); + } // Make sure we are still on the same line, blameable, and not pending, after the await - if (this._lineTracker.line === line && trackedDocument.isBlameable && !(this._updateBlameDebounced && this._updateBlameDebounced.pending!())) { - const state = this.getBlameAnnotationState(); - if (state.enabled) { - commitLine = blameLine === undefined ? undefined : blameLine.line; - commit = blameLine === undefined ? undefined : blameLine.commit; - } + if (this._lineTracker.includesAll(lines) && trackedDocument.isBlameable && !(this._updateBlameDebounced && this._updateBlameDebounced.pending!())) { + if (!this.getBlameAnnotationState().enabled) return this.clear(editor); } - if (this._lineTracker.state === undefined) { - this._lineTracker.state = new GitLineState(commit); - } + const activeLine = blameLines[0]; + this._lineTracker.setState(activeLine.line.line, new GitLineState(activeLine.commit)); - if (commit !== undefined && commitLine !== undefined) { - this.show(commit, commitLine, editor, line); + // I have no idea why I need this protection -- but it happens + if (editor.document === undefined) return; - return; + if (editor.document.isDirty) { + const trackedDocument = await Container.tracker.get(editor.document); + if (trackedDocument !== undefined) { + trackedDocument.setForceDirtyStateChangeOnNextDocumentChange(); + } } - this.clear(editor); + this.updateStatusBar(activeLine.commit, editor); + this.updateTrailingAnnotations(blameLines, editor); } private updateStatusBar(commit: GitCommit, editor: TextEditor) { @@ -523,15 +521,19 @@ export class CurrentLineController extends Disposable { this._statusBarItem.show(); } - private async updateTrailingAnnotation(commit: GitCommit, blameLine: GitCommitLine, editor: TextEditor, line?: number) { + private async updateTrailingAnnotations(lines: GitBlameLine[], editor: TextEditor) { const cfg = Container.config.currentLine; if (!cfg.enabled || !isTextEditor(editor)) return; - line = line === undefined ? blameLine.line : line; + const decorations = []; + for (const l of lines) { + const line = l.line.line; - const decoration = Annotations.trailing(commit, cfg.format, cfg.dateFormat === null ? Container.config.defaultDateFormat : cfg.dateFormat); - decoration.range = editor.document.validateRange(new Range(line, RangeEndOfLineIndex, line, RangeEndOfLineIndex)); + const decoration = Annotations.trailing(l.commit, cfg.format, cfg.dateFormat === null ? Container.config.defaultDateFormat : cfg.dateFormat); + decoration.range = editor.document.validateRange(new Range(line, RangeEndOfLineIndex, line, RangeEndOfLineIndex)); + decorations.push(decoration); + } - editor.setDecorations(annotationDecoration, [decoration]); + editor.setDecorations(annotationDecoration, decorations); } } \ No newline at end of file diff --git a/src/git/models/blame.ts b/src/git/models/blame.ts index 4d822ee..c92a958 100644 --- a/src/git/models/blame.ts +++ b/src/git/models/blame.ts @@ -10,7 +10,7 @@ export interface GitBlame { } export interface GitBlameLine { - readonly author: GitAuthor; + readonly author?: GitAuthor; readonly commit: GitBlameCommit; readonly line: GitCommitLine; } diff --git a/src/trackers/lineTracker.ts b/src/trackers/lineTracker.ts index 916e669..d18aeff 100644 --- a/src/trackers/lineTracker.ts +++ b/src/trackers/lineTracker.ts @@ -5,25 +5,25 @@ import { isTextEditor } from './../constants'; export { GitLineState } from './gitDocumentState'; -export interface LineChangeEvent { +export interface LinesChangeEvent { readonly editor: TextEditor | undefined; - readonly line: number | undefined; + readonly lines: number[] | undefined; - readonly reason: 'editor' | 'line'; + readonly reason: 'editor' | 'lines'; readonly pending?: boolean; } export class LineTracker extends Disposable { - private _onDidChangeActiveLine = new EventEmitter(); - get onDidChangeActiveLine(): Event { - return this._onDidChangeActiveLine.event; + private _onDidChangeActiveLines = new EventEmitter(); + get onDidChangeActiveLines(): Event { + return this._onDidChangeActiveLines.event; } private _disposable: Disposable | undefined; private _editor: TextEditor | undefined; - state: T | undefined; + private readonly _state: Map = new Map(); constructor() { super(() => this.dispose()); @@ -39,34 +39,50 @@ export class LineTracker extends Disposable { this.reset(); this._editor = editor; - this._line = editor !== undefined ? editor.selection.active.line : undefined; + this._lines = editor !== undefined ? editor.selections.map(s => s.active.line) : undefined; - this.fireLineChanged({ editor: editor, line: this._line, reason: 'editor' }); + this.fireLinesChanged({ editor: editor, lines: this._lines, reason: 'editor' }); } private onTextEditorSelectionChanged(e: TextEditorSelectionChangeEvent) { // If this isn't for our cached editor and its not a real editor -- kick out if (this._editor !== e.textEditor && !isTextEditor(e.textEditor)) return; - const reason = this._editor === e.textEditor ? 'line' : 'editor'; + const reason = this._editor === e.textEditor ? 'lines' : 'editor'; - const line = e.selections[0].active.line; - if (this._editor === e.textEditor && this._line === line) return; + const lines = e.selections.map(s => s.active.line); + if (this._editor === e.textEditor && this.includesAll(lines)) return; this.reset(); this._editor = e.textEditor; - this._line = line; + this._lines = lines; - this.fireLineChanged({ editor: this._editor, line: this._line, reason: reason }); + this.fireLinesChanged({ editor: this._editor, lines: this._lines, reason: reason }); } - private _line: number | undefined; - get line() { - return this._line; + getState(line: number): T | undefined { + return this._state.get(line); + } + + setState(line: number, state: T | undefined) { + this._state.set(line, state); + } + + private _lines: number[] | undefined; + get lines(): number[] | undefined { + return this._lines; + } + + includes(line: number): boolean { + return this._lines !== undefined && this._lines.includes(line); + } + + includesAll(lines: number[] | undefined): boolean { + return LineTracker.includesAll(lines, this._lines); } reset() { - this.state = undefined; + this._state.clear(); } start() { @@ -83,46 +99,53 @@ export class LineTracker extends Disposable { stop() { if (this._disposable === undefined) return; - if (this._lineChangedDebounced !== undefined) { - this._lineChangedDebounced.cancel(); + if (this._linesChangedDebounced !== undefined) { + this._linesChangedDebounced.cancel(); } this._disposable.dispose(); this._disposable = undefined; } - private _lineChangedDebounced: (((e: LineChangeEvent) => void) & IDeferrable) | undefined; + private _linesChangedDebounced: (((e: LinesChangeEvent) => void) & IDeferrable) | undefined; - private fireLineChanged(e: LineChangeEvent) { - if (e.line === undefined) { + private fireLinesChanged(e: LinesChangeEvent) { + if (e.lines === undefined) { setImmediate(() => { if (window.activeTextEditor !== e.editor) return; - if (this._lineChangedDebounced !== undefined) { - this._lineChangedDebounced.cancel(); + if (this._linesChangedDebounced !== undefined) { + this._linesChangedDebounced.cancel(); } - this._onDidChangeActiveLine.fire(e); + this._onDidChangeActiveLines.fire(e); }); return; } - if (this._lineChangedDebounced === undefined) { - this._lineChangedDebounced = Functions.debounce((e: LineChangeEvent) => { + if (this._linesChangedDebounced === undefined) { + this._linesChangedDebounced = Functions.debounce((e: LinesChangeEvent) => { if (window.activeTextEditor !== e.editor) return; - // Make sure we are still on the same line - if (e.line !== (e.editor && e.editor.selection.active.line)) return; + // Make sure we are still on the same lines + if (!LineTracker.includesAll(e.lines , (e.editor && e.editor.selections.map(s => s.active.line)))) return; - this._onDidChangeActiveLine.fire(e); + this._onDidChangeActiveLines.fire(e); }, 250, { track: true }); } // If we have no pending moves, then fire an immediate pending event, and defer the real event - if (!this._lineChangedDebounced.pending!()) { - this._onDidChangeActiveLine.fire({ ...e, pending: true }); + if (!this._linesChangedDebounced.pending!()) { + this._onDidChangeActiveLines.fire({ ...e, pending: true }); } - this._lineChangedDebounced(e); + this._linesChangedDebounced(e); + } + + static includesAll(lines1: number[] | undefined, lines2: number[] | undefined): boolean { + if (lines1 === undefined && lines2 === undefined) return true; + if (lines1 === undefined || lines2 === undefined) return false; + + return lines2.length === lines1.length && lines2.every((v, i) => v === lines1[i]); } } \ No newline at end of file