You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

532 lines
22 KiB

Major refactor/rework -- many new features and breaking changes Adds all-new, beautiful, highly customizable and themeable, file blame annotations Adds all-new configurability and themeability to the current line blame annotations Adds all-new configurability to the status bar blame information Adds all-new configurability over which commands are added to which menus via the `gitlens.advanced.menus` setting Adds better configurability over where Git code lens will be shown -- both by default and per language Adds an all-new `changes` (diff) hover annotation to the current line - provides instant access to the line's previous version Adds `Toggle Line Blame Annotations` command (`gitlens.toggleLineBlame`) - toggles the current line blame annotations on and off Adds `Show Line Blame Annotations` command (`gitlens.showLineBlame`) - shows the current line blame annotations Adds `Toggle File Blame Annotations` command (`gitlens.toggleFileBlame`) - toggles the file blame annotations on and off Adds `Show File Blame Annotations` command (`gitlens.showFileBlame`) - shows the file blame annotations Adds `Open File in Remote` command (`gitlens.openFileInRemote`) to the `editor/title` context menu Adds `Open Repo in Remote` command (`gitlens.openRepoInRemote`) to the `editor/title` context menu Changes the position of the `Open File in Remote` command (`gitlens.openFileInRemote`) in the context menus - now in the `navigation` group Changes the `Toggle Git Code Lens` command (`gitlens.toggleCodeLens`) to always toggle the Git code lens on and off Removes the on-demand `trailing` file blame annotations -- didn't work out and just ended up with a ton of visual noise Removes `Toggle Blame Annotations` command (`gitlens.toggleBlame`) - replaced by the `Toggle File Blame Annotations` command (`gitlens.toggleFileBlame`) Removes `Show Blame Annotations` command (`gitlens.showBlame`) - replaced by the `Show File Blame Annotations` command (`gitlens.showFileBlame`)
7 years ago
  1. 'use strict';
  2. import { Functions, IDeferrable } from './system';
  3. import { CancellationToken, ConfigurationChangeEvent, debug, DecorationRangeBehavior, DecorationRenderOptions, Disposable, Hover, HoverProvider, languages, Position, Range, StatusBarAlignment, StatusBarItem, TextDocument, TextEditor, TextEditorDecorationType, window } from 'vscode';
  4. import { Annotations } from './annotations/annotations';
  5. import { Commands } from './commands';
  6. import { configuration, IConfig, StatusBarCommand } from './configuration';
  7. import { isTextEditor, RangeEndOfLineIndex } from './constants';
  8. import { Container } from './container';
  9. import { DocumentBlameStateChangeEvent, DocumentDirtyIdleTriggerEvent, DocumentDirtyStateChangeEvent, GitDocumentState, TrackedDocument } from './trackers/documentTracker';
  10. import { CommitFormatter, GitCommit, GitCommitLine, ICommitFormatOptions } from './gitService';
  11. import { GitLineState, LineChangeEvent, LineTracker } from './trackers/lineTracker';
  12. const annotationDecoration: TextEditorDecorationType = window.createTextEditorDecorationType({
  13. after: {
  14. margin: '0 0 0 3em',
  15. textDecoration: 'none'
  16. },
  17. rangeBehavior: DecorationRangeBehavior.ClosedOpen
  18. } as DecorationRenderOptions);
  19. class AnnotationState {
  20. constructor(private _enabled: boolean) { }
  21. get enabled(): boolean {
  22. return this.suspended ? false : this._enabled;
  23. }
  24. private _suspendReason?: 'debugging' | 'dirty';
  25. get suspended(): boolean {
  26. return this._suspendReason !== undefined;
  27. }
  28. reset(enabled: boolean): boolean {
  29. // returns whether or not a refresh is required
  30. if (this._enabled === enabled && !this.suspended) return false;
  31. this._enabled = enabled;
  32. this._suspendReason = undefined;
  33. return true;
  34. }
  35. resume(reason: 'debugging' | 'dirty'): boolean {
  36. // returns whether or not a refresh is required
  37. const refresh = this._suspendReason !== undefined;
  38. this._suspendReason = undefined;
  39. return refresh;
  40. }
  41. suspend(reason: 'debugging' | 'dirty'): boolean {
  42. // returns whether or not a refresh is required
  43. const refresh = this._suspendReason === undefined;
  44. this._suspendReason = reason;
  45. return refresh;
  46. }
  47. }
  48. export class CurrentLineController extends Disposable {
  49. private _blameAnnotationState: AnnotationState | undefined;
  50. private _editor: TextEditor | undefined;
  51. private _lineTracker: LineTracker<GitLineState>;
  52. private _statusBarItem: StatusBarItem | undefined;
  53. private _disposable: Disposable;
  54. private _debugSessionEndDisposable: Disposable | undefined;
  55. private _hoverProviderDisposable: Disposable | undefined;
  56. private _lineTrackingDisposable: Disposable | undefined;
  57. constructor() {
  58. super(() => this.dispose());
  59. this._lineTracker = new LineTracker<GitLineState>();
  60. this._disposable = Disposable.from(
  61. this._lineTracker,
  62. configuration.onDidChange(this.onConfigurationChanged, this),
  63. Container.annotations.onDidToggleAnnotations(this.onFileAnnotationsToggled, this),
  64. debug.onDidStartDebugSession(this.onDebugSessionStarted, this)
  65. );
  66. this.onConfigurationChanged(configuration.initializingChangeEvent);
  67. }
  68. dispose() {
  69. this.clearAnnotations(this._editor);
  70. this.unregisterHoverProviders();
  71. this._debugSessionEndDisposable && this._debugSessionEndDisposable.dispose();
  72. this._lineTrackingDisposable && this._lineTrackingDisposable.dispose();
  73. this._statusBarItem && this._statusBarItem.dispose();
  74. this._disposable && this._disposable.dispose();
  75. }
  76. private onConfigurationChanged(e: ConfigurationChangeEvent) {
  77. const initializing = configuration.initializing(e);
  78. const cfg = configuration.get<IConfig>();
  79. let changed = false;
  80. if (initializing || configuration.changed(e, configuration.name('currentLine').value)) {
  81. changed = true;
  82. this._blameAnnotationState = undefined;
  83. }
  84. if (initializing || configuration.changed(e, configuration.name('hovers').value)) {
  85. changed = true;
  86. this.unregisterHoverProviders();
  87. }
  88. if (initializing || configuration.changed(e, configuration.name('statusBar').value)) {
  89. changed = true;
  90. if (cfg.statusBar.enabled) {
  91. const alignment = cfg.statusBar.alignment !== 'left' ? StatusBarAlignment.Right : StatusBarAlignment.Left;
  92. if (this._statusBarItem !== undefined && this._statusBarItem.alignment !== alignment) {
  93. this._statusBarItem.dispose();
  94. this._statusBarItem = undefined;
  95. }
  96. this._statusBarItem = this._statusBarItem || window.createStatusBarItem(alignment, alignment === StatusBarAlignment.Right ? 1000 : 0);
  97. this._statusBarItem.command = cfg.statusBar.command;
  98. }
  99. else if (this._statusBarItem !== undefined) {
  100. this._statusBarItem.dispose();
  101. this._statusBarItem = undefined;
  102. }
  103. }
  104. if (!changed) return;
  105. const trackCurrentLine = cfg.currentLine.enabled || cfg.statusBar.enabled || (cfg.hovers.enabled && cfg.hovers.currentLine.enabled) ||
  106. (this._blameAnnotationState !== undefined && this._blameAnnotationState.enabled);
  107. if (trackCurrentLine) {
  108. this._lineTracker.start();
  109. this._lineTrackingDisposable = this._lineTrackingDisposable || Disposable.from(
  110. this._lineTracker.onDidChangeActiveLine(this.onActiveLineChanged, this),
  111. Container.tracker.onDidChangeBlameState(this.onBlameStateChanged, this),
  112. Container.tracker.onDidChangeDirtyState(this.onDirtyStateChanged, this),
  113. Container.tracker.onDidTriggerDirtyIdle(this.onDirtyIdleTriggered, this)
  114. );
  115. }
  116. else {
  117. this._lineTracker.stop();
  118. if (this._lineTrackingDisposable !== undefined) {
  119. this._lineTrackingDisposable.dispose();
  120. this._lineTrackingDisposable = undefined;
  121. }
  122. }
  123. this.refresh(window.activeTextEditor, { full: true });
  124. }
  125. private onActiveLineChanged(e: LineChangeEvent) {
  126. if (!e.pending && e.line !== undefined) {
  127. this.refresh(e.editor);
  128. return;
  129. }
  130. this.clear(e.editor);
  131. }
  132. private onBlameStateChanged(e: DocumentBlameStateChangeEvent<GitDocumentState>) {
  133. if (e.blameable) {
  134. this.refresh(e.editor);
  135. return;
  136. }
  137. this.clear(e.editor);
  138. }
  139. private onDebugSessionStarted() {
  140. if (this.suspendBlameAnnotations('debugging', window.activeTextEditor)) {
  141. this._debugSessionEndDisposable = debug.onDidTerminateDebugSession(this.onDebugSessionEnded, this);
  142. }
  143. }
  144. private onDebugSessionEnded() {
  145. if (this._debugSessionEndDisposable !== undefined) {
  146. this._debugSessionEndDisposable.dispose();
  147. this._debugSessionEndDisposable = undefined;
  148. }
  149. this.resumeBlameAnnotations('debugging', window.activeTextEditor);
  150. }
  151. private onDirtyIdleTriggered(e: DocumentDirtyIdleTriggerEvent<GitDocumentState>) {
  152. const maxLines = configuration.get<number>(configuration.name('advanced')('blame')('sizeThresholdAfterEdit').value);
  153. if (maxLines > 0 && e.document.lineCount > maxLines) return;
  154. this.resumeBlameAnnotations('dirty', window.activeTextEditor);
  155. }
  156. private async onDirtyStateChanged(e: DocumentDirtyStateChangeEvent<GitDocumentState>) {
  157. if (e.dirty) {
  158. this.suspendBlameAnnotations('dirty', window.activeTextEditor);
  159. }
  160. else {
  161. this.resumeBlameAnnotations('dirty', window.activeTextEditor, { force: true });
  162. }
  163. }
  164. private onFileAnnotationsToggled() {
  165. this.refresh(window.activeTextEditor);
  166. }
  167. async clear(editor: TextEditor | undefined) {
  168. if (this._editor !== editor && this._editor !== undefined) {
  169. this.clearAnnotations(this._editor);
  170. }
  171. this.clearAnnotations(editor);
  172. this._lineTracker.reset();
  173. this.unregisterHoverProviders();
  174. this._statusBarItem && this._statusBarItem.hide();
  175. }
  176. async provideDetailsHover(document: TextDocument, position: Position, token: CancellationToken): Promise<Hover | undefined> {
  177. if (this._editor === undefined || this._editor.document !== document) return undefined;
  178. if (this._lineTracker.line !== position.line) return undefined;
  179. const commit = this._lineTracker.state !== undefined ? this._lineTracker.state.commit : undefined;
  180. if (commit === undefined) return undefined;
  181. // Avoid double annotations if we are showing the whole-file hover blame annotations
  182. const fileAnnotations = await Container.annotations.getAnnotationType(this._editor);
  183. if (fileAnnotations !== undefined && Container.config.hovers.annotations.details) return undefined;
  184. const wholeLine = Container.config.hovers.currentLine.over === 'line';
  185. const range = document.validateRange(new Range(position.line, wholeLine ? 0 : RangeEndOfLineIndex, position.line, RangeEndOfLineIndex));
  186. if (!wholeLine && range.start.character !== position.character) return undefined;
  187. // Get the full commit message -- since blame only returns the summary
  188. let logCommit = this._lineTracker.state !== undefined ? this._lineTracker.state.logCommit : undefined;
  189. if (logCommit === undefined && !commit.isUncommitted) {
  190. logCommit = await Container.git.getLogCommitForFile(commit.repoPath, commit.uri.fsPath, { ref: commit.sha });
  191. if (logCommit !== undefined) {
  192. // Preserve the previous commit from the blame commit
  193. logCommit.previousSha = commit.previousSha;
  194. logCommit.previousFileName = commit.previousFileName;
  195. if (this._lineTracker.state !== undefined) {
  196. this._lineTracker.state.logCommit = logCommit;
  197. }
  198. }
  199. }
  200. const trackedDocument = await Container.tracker.get(document);
  201. if (trackedDocument === undefined) return undefined;
  202. const message = Annotations.getHoverMessage(logCommit || commit, Container.config.defaultDateFormat, trackedDocument.hasRemotes, fileAnnotations);
  203. return new Hover(message, range);
  204. }
  205. async provideChangesHover(document: TextDocument, position: Position, token: CancellationToken): Promise<Hover | undefined> {
  206. if (this._editor === undefined || this._editor.document !== document) return undefined;
  207. if (this._lineTracker.line !== position.line) return undefined;
  208. const commit = this._lineTracker.state !== undefined ? this._lineTracker.state.commit : undefined;
  209. if (commit === undefined) return undefined;
  210. // Avoid double annotations if we are showing the whole-file hover blame annotations
  211. if (Container.config.hovers.annotations.changes) {
  212. const fileAnnotations = await Container.annotations.getAnnotationType(this._editor);
  213. if (fileAnnotations !== undefined) return undefined;
  214. }
  215. const wholeLine = Container.config.hovers.currentLine.over === 'line';
  216. const range = document.validateRange(new Range(position.line, wholeLine ? 0 : RangeEndOfLineIndex, position.line, RangeEndOfLineIndex));
  217. if (!wholeLine && range.start.character !== position.character) return undefined;
  218. const trackedDocument = await Container.tracker.get(document);
  219. if (trackedDocument === undefined) return undefined;
  220. const hover = await Annotations.changesHover(commit, position.line, trackedDocument.uri);
  221. if (hover.hoverMessage === undefined) return undefined;
  222. return new Hover(hover.hoverMessage, range);
  223. }
  224. async show(commit: GitCommit, blameLine: GitCommitLine, editor: TextEditor, line: number) {
  225. // I have no idea why I need this protection -- but it happens
  226. if (editor.document === undefined) return;
  227. if (editor.document.isDirty) {
  228. const trackedDocument = await Container.tracker.get(editor.document);
  229. if (trackedDocument !== undefined) {
  230. trackedDocument.setForceDirtyStateChangeOnNextDocumentChange();
  231. }
  232. }
  233. this.updateStatusBar(commit, editor);
  234. this.updateTrailingAnnotation(commit, blameLine, editor, line);
  235. }
  236. async showAnnotations(editor: TextEditor | undefined) {
  237. this.setBlameAnnotationState(true, editor);
  238. }
  239. async toggleAnnotations(editor: TextEditor | undefined) {
  240. const state = this.getBlameAnnotationState();
  241. this.setBlameAnnotationState(!state.enabled, editor);
  242. }
  243. private async resumeBlameAnnotations(reason: 'debugging' | 'dirty', editor: TextEditor | undefined, options: { force?: boolean } = {}) {
  244. if (!options.force && (this._blameAnnotationState === undefined || !this._blameAnnotationState.suspended)) return;
  245. let refresh = false;
  246. if (this._blameAnnotationState !== undefined) {
  247. refresh = this._blameAnnotationState.resume(reason);
  248. }
  249. if (editor === undefined || (!options.force && !refresh)) return;
  250. await this.refresh(editor);
  251. }
  252. private async suspendBlameAnnotations(reason: 'debugging' | 'dirty', editor: TextEditor | undefined, options: { force?: boolean } = {}) {
  253. const state = this.getBlameAnnotationState();
  254. // If we aren't enabled, suspend doesn't matter
  255. if (this._blameAnnotationState === undefined && !state.enabled) return false;
  256. if (this._blameAnnotationState === undefined) {
  257. this._blameAnnotationState = new AnnotationState(state.enabled);
  258. }
  259. const refresh = this._blameAnnotationState.suspend(reason);
  260. if (editor === undefined || (!options.force && !refresh)) return;
  261. await this.refresh(editor);
  262. return true;
  263. }
  264. private async setBlameAnnotationState(enabled: boolean, editor: TextEditor | undefined) {
  265. let refresh = true;
  266. if (this._blameAnnotationState === undefined) {
  267. this._blameAnnotationState = new AnnotationState(enabled);
  268. }
  269. else {
  270. refresh = this._blameAnnotationState.reset(enabled);
  271. }
  272. if (editor === undefined || !refresh) return;
  273. await this.refresh(editor);
  274. }
  275. private clearAnnotations(editor: TextEditor | undefined) {
  276. if (editor === undefined) return;
  277. editor.setDecorations(annotationDecoration, []);
  278. }
  279. private getBlameAnnotationState() {
  280. if (this._blameAnnotationState !== undefined) return this._blameAnnotationState;
  281. const cfg = Container.config;
  282. return {
  283. enabled: cfg.currentLine.enabled || cfg.statusBar.enabled || (cfg.hovers.enabled && cfg.hovers.currentLine.enabled)
  284. };
  285. }
  286. private _updateBlameDebounced: (((line: number, editor: TextEditor, trackedDocument: TrackedDocument<GitDocumentState>) => void) & IDeferrable) | undefined;
  287. private async refresh(editor: TextEditor | undefined, options: { full?: boolean, trackedDocument?: TrackedDocument<GitDocumentState> } = {}) {
  288. if (editor === undefined && this._editor === undefined) return;
  289. if (editor === undefined || this._lineTracker.line === undefined) {
  290. this.clear(this._editor);
  291. return;
  292. }
  293. if (this._editor !== editor) {
  294. // If we are changing editor, consider this a full refresh
  295. options.full = true;
  296. // Clear any annotations on the previously active editor
  297. this.clearAnnotations(this._editor);
  298. this._editor = editor;
  299. }
  300. const state = this.getBlameAnnotationState();
  301. if (state.enabled) {
  302. if (options.trackedDocument === undefined) {
  303. options.trackedDocument = await Container.tracker.getOrAdd(editor.document);
  304. }
  305. if (options.trackedDocument.isBlameable) {
  306. if (state.enabled && Container.config.hovers.enabled && Container.config.hovers.currentLine.enabled &&
  307. (options.full || this._hoverProviderDisposable === undefined)) {
  308. this.registerHoverProviders(editor, Container.config.hovers.currentLine);
  309. }
  310. if (this._updateBlameDebounced === undefined) {
  311. this._updateBlameDebounced = Functions.debounce(this.updateBlame, 50, { track: true });
  312. }
  313. this._updateBlameDebounced(this._lineTracker.line, editor, options.trackedDocument);
  314. return;
  315. }
  316. }
  317. await this.clear(editor);
  318. }
  319. private registerHoverProviders(editor: TextEditor | undefined, providers: { details: boolean, changes: boolean }) {
  320. this.unregisterHoverProviders();
  321. if (editor === undefined) return;
  322. if (!providers.details && !providers.changes) return;
  323. const subscriptions: Disposable[] = [];
  324. if (providers.changes) {
  325. subscriptions.push(languages.registerHoverProvider({ pattern: editor.document.uri.fsPath }, { provideHover: this.provideChangesHover.bind(this) } as HoverProvider));
  326. }
  327. if (providers.details) {
  328. subscriptions.push(languages.registerHoverProvider({ pattern: editor.document.uri.fsPath }, { provideHover: this.provideDetailsHover.bind(this) } as HoverProvider));
  329. }
  330. this._hoverProviderDisposable = Disposable.from(...subscriptions);
  331. }
  332. private unregisterHoverProviders() {
  333. if (this._hoverProviderDisposable !== undefined) {
  334. this._hoverProviderDisposable.dispose();
  335. this._hoverProviderDisposable = undefined;
  336. }
  337. }
  338. private async updateBlame(line: number, editor: TextEditor, trackedDocument: TrackedDocument<GitDocumentState>) {
  339. this._lineTracker.reset();
  340. // Make sure we are still on the same line and not pending
  341. if (this._lineTracker.line !== line || (this._updateBlameDebounced && this._updateBlameDebounced.pending!())) return;
  342. const blameLine = editor.document.isDirty
  343. ? await Container.git.getBlameForLineContents(trackedDocument.uri, line, editor.document.getText())
  344. : await Container.git.getBlameForLine(trackedDocument.uri, line);
  345. let commit;
  346. let commitLine;
  347. // Make sure we are still on the same line, blameable, and not pending, after the await
  348. if (this._lineTracker.line === line && trackedDocument.isBlameable && !(this._updateBlameDebounced && this._updateBlameDebounced.pending!())) {
  349. const state = this.getBlameAnnotationState();
  350. if (state.enabled) {
  351. commitLine = blameLine === undefined ? undefined : blameLine.line;
  352. commit = blameLine === undefined ? undefined : blameLine.commit;
  353. }
  354. }
  355. if (this._lineTracker.state === undefined) {
  356. this._lineTracker.state = new GitLineState(commit);
  357. }
  358. if (commit !== undefined && commitLine !== undefined) {
  359. this.show(commit, commitLine, editor, line);
  360. return;
  361. }
  362. this.clear(editor);
  363. }
  364. private updateStatusBar(commit: GitCommit, editor: TextEditor) {
  365. const cfg = Container.config.statusBar;
  366. if (!cfg.enabled || this._statusBarItem === undefined || !isTextEditor(editor)) return;
  367. this._statusBarItem.text = `$(git-commit) ${CommitFormatter.fromTemplate(cfg.format, commit, {
  368. truncateMessageAtNewLine: true,
  369. dateFormat: cfg.dateFormat === null ? Container.config.defaultDateFormat : cfg.dateFormat
  370. } as ICommitFormatOptions)}`;
  371. switch (cfg.command) {
  372. case StatusBarCommand.ToggleFileBlame:
  373. this._statusBarItem.tooltip = 'Toggle Blame Annotations';
  374. break;
  375. case StatusBarCommand.DiffWithPrevious:
  376. this._statusBarItem.command = Commands.DiffLineWithPrevious;
  377. this._statusBarItem.tooltip = 'Compare Line Revision with Previous';
  378. break;
  379. case StatusBarCommand.DiffWithWorking:
  380. this._statusBarItem.command = Commands.DiffLineWithWorking;
  381. this._statusBarItem.tooltip = 'Compare Line Revision with Working';
  382. break;
  383. case StatusBarCommand.ToggleCodeLens:
  384. this._statusBarItem.tooltip = 'Toggle Git CodeLens';
  385. break;
  386. case StatusBarCommand.ShowQuickCommitDetails:
  387. this._statusBarItem.tooltip = 'Show Commit Details';
  388. break;
  389. case StatusBarCommand.ShowQuickCommitFileDetails:
  390. this._statusBarItem.tooltip = 'Show Line Commit Details';
  391. break;
  392. case StatusBarCommand.ShowQuickFileHistory:
  393. this._statusBarItem.tooltip = 'Show File History';
  394. break;
  395. case StatusBarCommand.ShowQuickCurrentBranchHistory:
  396. this._statusBarItem.tooltip = 'Show Branch History';
  397. break;
  398. }
  399. this._statusBarItem.show();
  400. }
  401. private async updateTrailingAnnotation(commit: GitCommit, blameLine: GitCommitLine, editor: TextEditor, line?: number) {
  402. const cfg = Container.config.currentLine;
  403. if (!cfg.enabled || !isTextEditor(editor)) return;
  404. line = line === undefined ? blameLine.line : line;
  405. const decoration = Annotations.trailing(commit, cfg.format, cfg.dateFormat === null ? Container.config.defaultDateFormat : cfg.dateFormat);
  406. decoration.range = editor.document.validateRange(new Range(line, RangeEndOfLineIndex, line, RangeEndOfLineIndex));
  407. editor.setDecorations(annotationDecoration, [decoration]);
  408. }
  409. }