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.

538 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, GitBlameLine, GitCommit, ICommitFormatOptions } from './gitService';
  11. import { GitLineState, LinesChangeEvent, 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 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 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 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.onDidChangeActiveLines(this.onActiveLinesChanged, 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 onActiveLinesChanged(e: LinesChangeEvent) {
  126. if (!e.pending && e.lines !== undefined) {
  127. this.refresh(e.editor);
  128. return;
  129. }
  130. this.clear(e.editor, (Container.config.statusBar.reduceFlicker && e.reason === 'lines' && e.lines !== undefined) ? 'lines' : undefined);
  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, reason?: 'lines') {
  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. if (this._statusBarItem !== undefined && reason !== 'lines') {
  175. this._statusBarItem.hide();
  176. }
  177. }
  178. async provideDetailsHover(document: TextDocument, position: Position, token: CancellationToken): Promise<Hover | undefined> {
  179. if (this._editor === undefined || this._editor.document !== document || !this._lineTracker.includes(position.line)) return undefined;
  180. const lineState = this._lineTracker.getState(position.line);
  181. const commit = lineState !== undefined ? lineState.commit : undefined;
  182. if (commit === undefined) return undefined;
  183. // Avoid double annotations if we are showing the whole-file hover blame annotations
  184. const fileAnnotations = await Container.annotations.getAnnotationType(this._editor);
  185. if (fileAnnotations !== undefined && Container.config.hovers.annotations.details) return undefined;
  186. const wholeLine = Container.config.hovers.currentLine.over === 'line';
  187. const range = document.validateRange(new Range(position.line, wholeLine ? 0 : RangeEndOfLineIndex, position.line, RangeEndOfLineIndex));
  188. if (!wholeLine && range.start.character !== position.character) return undefined;
  189. // Get the full commit message -- since blame only returns the summary
  190. let logCommit = lineState !== undefined ? lineState.logCommit : undefined;
  191. if (logCommit === undefined && !commit.isUncommitted) {
  192. logCommit = await Container.git.getLogCommitForFile(commit.repoPath, commit.uri.fsPath, { ref: commit.sha });
  193. if (logCommit !== undefined) {
  194. // Preserve the previous commit from the blame commit
  195. logCommit.previousSha = commit.previousSha;
  196. logCommit.previousFileName = commit.previousFileName;
  197. if (lineState !== undefined) {
  198. lineState.logCommit = logCommit;
  199. }
  200. }
  201. }
  202. const trackedDocument = await Container.tracker.get(document);
  203. if (trackedDocument === undefined) return undefined;
  204. const message = Annotations.getHoverMessage(logCommit || commit, Container.config.defaultDateFormat, await Container.git.getRemotes(commit.repoPath), fileAnnotations, position.line);
  205. return new Hover(message, range);
  206. }
  207. async provideChangesHover(document: TextDocument, position: Position, token: CancellationToken): Promise<Hover | undefined> {
  208. if (this._editor === undefined || this._editor.document !== document || !this._lineTracker.includes(position.line)) return undefined;
  209. const lineState = this._lineTracker.getState(position.line);
  210. const commit = lineState !== undefined ? lineState.commit : undefined;
  211. if (commit === undefined) return undefined;
  212. // Avoid double annotations if we are showing the whole-file hover blame annotations
  213. if (Container.config.hovers.annotations.changes) {
  214. const fileAnnotations = await Container.annotations.getAnnotationType(this._editor);
  215. if (fileAnnotations !== undefined) return undefined;
  216. }
  217. const wholeLine = Container.config.hovers.currentLine.over === 'line';
  218. const range = document.validateRange(new Range(position.line, wholeLine ? 0 : RangeEndOfLineIndex, position.line, RangeEndOfLineIndex));
  219. if (!wholeLine && range.start.character !== position.character) return undefined;
  220. const trackedDocument = await Container.tracker.get(document);
  221. if (trackedDocument === undefined) return undefined;
  222. const hover = await Annotations.changesHover(commit, position.line, trackedDocument.uri);
  223. if (hover.hoverMessage === undefined) return undefined;
  224. return new Hover(hover.hoverMessage, range);
  225. }
  226. async showAnnotations(editor: TextEditor | undefined) {
  227. this.setBlameAnnotationState(true, editor);
  228. }
  229. async toggleAnnotations(editor: TextEditor | undefined) {
  230. const state = this.getBlameAnnotationState();
  231. this.setBlameAnnotationState(!state.enabled, editor);
  232. }
  233. private async resumeBlameAnnotations(reason: 'debugging' | 'dirty', editor: TextEditor | undefined, options: { force?: boolean } = {}) {
  234. if (!options.force && (this._blameAnnotationState === undefined || !this._blameAnnotationState.suspended)) return;
  235. let refresh = false;
  236. if (this._blameAnnotationState !== undefined) {
  237. refresh = this._blameAnnotationState.resume(reason);
  238. }
  239. if (editor === undefined || (!options.force && !refresh)) return;
  240. await this.refresh(editor);
  241. }
  242. private async suspendBlameAnnotations(reason: 'debugging' | 'dirty', editor: TextEditor | undefined, options: { force?: boolean } = {}) {
  243. const state = this.getBlameAnnotationState();
  244. // If we aren't enabled, suspend doesn't matter
  245. if (this._blameAnnotationState === undefined && !state.enabled) return false;
  246. if (this._blameAnnotationState === undefined) {
  247. this._blameAnnotationState = new AnnotationState(state.enabled);
  248. }
  249. const refresh = this._blameAnnotationState.suspend(reason);
  250. if (editor === undefined || (!options.force && !refresh)) return;
  251. await this.refresh(editor);
  252. return true;
  253. }
  254. private async setBlameAnnotationState(enabled: boolean, editor: TextEditor | undefined) {
  255. let refresh = true;
  256. if (this._blameAnnotationState === undefined) {
  257. this._blameAnnotationState = new AnnotationState(enabled);
  258. }
  259. else {
  260. refresh = this._blameAnnotationState.reset(enabled);
  261. }
  262. if (editor === undefined || !refresh) return;
  263. await this.refresh(editor);
  264. }
  265. private clearAnnotations(editor: TextEditor | undefined) {
  266. if (editor === undefined) return;
  267. if ((editor as any)._disposed === true) return;
  268. editor.setDecorations(annotationDecoration, []);
  269. }
  270. private getBlameAnnotationState() {
  271. if (this._blameAnnotationState !== undefined) return this._blameAnnotationState;
  272. const cfg = Container.config;
  273. return {
  274. enabled: cfg.currentLine.enabled || cfg.statusBar.enabled || (cfg.hovers.enabled && cfg.hovers.currentLine.enabled)
  275. };
  276. }
  277. private _updateBlameDebounced: (((lines: number[], editor: TextEditor, trackedDocument: TrackedDocument<GitDocumentState>) => void) & IDeferrable) | undefined;
  278. private async refresh(editor: TextEditor | undefined, options: { full?: boolean, trackedDocument?: TrackedDocument<GitDocumentState> } = {}) {
  279. if (editor === undefined && this._editor === undefined) return;
  280. if (editor === undefined || this._lineTracker.lines === undefined) return this.clear(this._editor);
  281. if (this._editor !== editor) {
  282. // If we are changing editor, consider this a full refresh
  283. options.full = true;
  284. // Clear any annotations on the previously active editor
  285. this.clearAnnotations(this._editor);
  286. this._editor = editor;
  287. }
  288. const state = this.getBlameAnnotationState();
  289. if (state.enabled) {
  290. if (options.trackedDocument === undefined) {
  291. options.trackedDocument = await Container.tracker.getOrAdd(editor.document);
  292. }
  293. if (options.trackedDocument.isBlameable) {
  294. if (state.enabled && Container.config.hovers.enabled && Container.config.hovers.currentLine.enabled &&
  295. (options.full || this._hoverProviderDisposable === undefined)) {
  296. this.registerHoverProviders(editor, Container.config.hovers.currentLine);
  297. }
  298. if (this._updateBlameDebounced === undefined) {
  299. this._updateBlameDebounced = Functions.debounce(this.updateBlame, 50, { track: true });
  300. }
  301. this._updateBlameDebounced(this._lineTracker.lines, editor, options.trackedDocument);
  302. return;
  303. }
  304. }
  305. await this.clear(editor);
  306. }
  307. private registerHoverProviders(editor: TextEditor | undefined, providers: { details: boolean, changes: boolean }) {
  308. this.unregisterHoverProviders();
  309. if (editor === undefined) return;
  310. if (!providers.details && !providers.changes) return;
  311. const subscriptions: Disposable[] = [];
  312. if (providers.changes) {
  313. subscriptions.push(languages.registerHoverProvider({ pattern: editor.document.uri.fsPath }, { provideHover: this.provideChangesHover.bind(this) } as HoverProvider));
  314. }
  315. if (providers.details) {
  316. subscriptions.push(languages.registerHoverProvider({ pattern: editor.document.uri.fsPath }, { provideHover: this.provideDetailsHover.bind(this) } as HoverProvider));
  317. }
  318. this._hoverProviderDisposable = Disposable.from(...subscriptions);
  319. }
  320. private unregisterHoverProviders() {
  321. if (this._hoverProviderDisposable !== undefined) {
  322. this._hoverProviderDisposable.dispose();
  323. this._hoverProviderDisposable = undefined;
  324. }
  325. }
  326. private async updateBlame(lines: number[], editor: TextEditor, trackedDocument: TrackedDocument<GitDocumentState>) {
  327. this._lineTracker.reset();
  328. // Make sure we are still on the same line and not pending
  329. if (!this._lineTracker.includesAll(lines) || (this._updateBlameDebounced && this._updateBlameDebounced.pending!())) return;
  330. let blameLines;
  331. if (lines.length === 1) {
  332. const blameLine = editor.document.isDirty
  333. ? await Container.git.getBlameForLineContents(trackedDocument.uri, lines[0], editor.document.getText())
  334. : await Container.git.getBlameForLine(trackedDocument.uri, lines[0]);
  335. if (blameLine === undefined) return this.clear(editor);
  336. blameLines = [blameLine];
  337. }
  338. else {
  339. const blame = editor.document.isDirty
  340. ? await Container.git.getBlameForFileContents(trackedDocument.uri, editor.document.getText())
  341. : await Container.git.getBlameForFile(trackedDocument.uri);
  342. if (blame === undefined) return this.clear(editor);
  343. blameLines = lines.map(l => {
  344. const commitLine = blame.lines[l];
  345. return {
  346. line: commitLine,
  347. commit: blame.commits.get(commitLine.sha)!
  348. };
  349. });
  350. }
  351. // Make sure we are still on the same line, blameable, and not pending, after the await
  352. if (this._lineTracker.includesAll(lines) && trackedDocument.isBlameable && !(this._updateBlameDebounced && this._updateBlameDebounced.pending!())) {
  353. if (!this.getBlameAnnotationState().enabled) return this.clear(editor);
  354. }
  355. const activeLine = blameLines[0];
  356. this._lineTracker.setState(activeLine.line.line, new GitLineState(activeLine.commit));
  357. // I have no idea why I need this protection -- but it happens
  358. if (editor.document === undefined) return;
  359. if (editor.document.isDirty) {
  360. const trackedDocument = await Container.tracker.get(editor.document);
  361. if (trackedDocument !== undefined) {
  362. trackedDocument.setForceDirtyStateChangeOnNextDocumentChange();
  363. }
  364. }
  365. this.updateStatusBar(activeLine.commit, editor);
  366. this.updateTrailingAnnotations(blameLines, editor);
  367. }
  368. private updateStatusBar(commit: GitCommit, editor: TextEditor) {
  369. const cfg = Container.config.statusBar;
  370. if (!cfg.enabled || this._statusBarItem === undefined || !isTextEditor(editor)) return;
  371. this._statusBarItem.text = `$(git-commit) ${CommitFormatter.fromTemplate(cfg.format, commit, {
  372. truncateMessageAtNewLine: true,
  373. dateFormat: cfg.dateFormat === null ? Container.config.defaultDateFormat : cfg.dateFormat
  374. } as ICommitFormatOptions)}`;
  375. switch (cfg.command) {
  376. case StatusBarCommand.ToggleFileBlame:
  377. this._statusBarItem.tooltip = 'Toggle Blame Annotations';
  378. break;
  379. case StatusBarCommand.DiffWithPrevious:
  380. this._statusBarItem.command = Commands.DiffLineWithPrevious;
  381. this._statusBarItem.tooltip = 'Compare Line Revision with Previous';
  382. break;
  383. case StatusBarCommand.DiffWithWorking:
  384. this._statusBarItem.command = Commands.DiffLineWithWorking;
  385. this._statusBarItem.tooltip = 'Compare Line Revision with Working';
  386. break;
  387. case StatusBarCommand.ToggleCodeLens:
  388. this._statusBarItem.tooltip = 'Toggle Git CodeLens';
  389. break;
  390. case StatusBarCommand.ShowQuickCommitDetails:
  391. this._statusBarItem.tooltip = 'Show Commit Details';
  392. break;
  393. case StatusBarCommand.ShowQuickCommitFileDetails:
  394. this._statusBarItem.tooltip = 'Show Line Commit Details';
  395. break;
  396. case StatusBarCommand.ShowQuickFileHistory:
  397. this._statusBarItem.tooltip = 'Show File History';
  398. break;
  399. case StatusBarCommand.ShowQuickCurrentBranchHistory:
  400. this._statusBarItem.tooltip = 'Show Branch History';
  401. break;
  402. }
  403. this._statusBarItem.show();
  404. }
  405. private async updateTrailingAnnotations(lines: GitBlameLine[], editor: TextEditor) {
  406. const cfg = Container.config.currentLine;
  407. if (!cfg.enabled || !isTextEditor(editor)) return;
  408. const decorations = [];
  409. for (const l of lines) {
  410. const line = l.line.line;
  411. const decoration = Annotations.trailing(l.commit, cfg.format, cfg.dateFormat === null ? Container.config.defaultDateFormat : cfg.dateFormat);
  412. decoration.range = editor.document.validateRange(new Range(line, RangeEndOfLineIndex, line, RangeEndOfLineIndex));
  413. decorations.push(decoration);
  414. }
  415. editor.setDecorations(annotationDecoration, decorations);
  416. }
  417. }