15.4 KB488 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {Repository} from 'isl-server/src/Repository';
9import type {RepositoryContext} from 'isl-server/src/serverTypes';
10import type {CommitInfo, Result} from 'isl/src/types';
11import type {
12 DecorationOptions,
13 Disposable,
14 TextDocument,
15 TextDocumentChangeEvent,
16 TextEditor,
17 TextEditorSelectionChangeEvent,
18} from 'vscode';
19import type {VSCodeReposList} from '../VSCodeRepo';
20
21import {getUsername} from 'isl-server/src/analytics/environment';
22import {relativeDate} from 'isl/src/relativeDate';
23import {LRU} from 'shared/LRU';
24import {debounce} from 'shared/debounce';
25import {nullthrows} from 'shared/utils';
26import {DecorationRangeBehavior, MarkdownString, Position, Range, window, workspace} from 'vscode';
27import {Internal} from '../Internal';
28import {getDiffBlameHoverMarkup} from './blameHover';
29import {getRealignedBlameInfo, shortenAuthorName} from './blameUtils';
30
31function areYouTheAuthor(author: string) {
32 const you = getUsername();
33 return author.includes(you);
34}
35
36type BlameText = {
37 inline: string;
38 hover: string;
39};
40type RepoCaches = {
41 headHash: string;
42 blameCache: LRU<string, CachedBlame>; // Caches file path -> file blame.
43};
44type CachedBlame = {
45 baseBlameLines: Array<[line: string, info: CommitInfo | undefined]>;
46 currentBlameLines:
47 | undefined // undefined if not yet populated with local changes.
48 | Array<[line: string, info: CommitInfo | undefined]>; // undefined entry represents locally changed lines.
49};
50
51const MAX_NUM_FILES_CACHED = 20;
52
53/**
54 * Provides inline blame annotations.
55 *
56 * Blame is fetched via `sl blame` once per file,
57 * based on the current commit hash in your stack.
58 * Blame is invalidated if the head commit changes (commit, amend, goto, ...).
59 *
60 * The current file's content at the current commit is available as part of the fetched blame,
61 * and used to diff with the current file text editor contents.
62 * This difference is used to realign the blame to include your local uncommitted changes.
63 * This diff is run on every text change to the file.
64 *
65 * One line of blame is rendered next to your cursor, and re-rended every time the cursor moves.
66 *
67 * TODO: instead of diffing with the current file contents, we could instead record all your edits
68 * into linelog, and derive blame for that. That would give us timestamps for each change and a way
69 * to quickly go backwards in time.
70 */
71export class InlineBlameProvider implements Disposable {
72 loadingEditors = new Set<string>();
73
74 currentEditor: TextEditor | undefined;
75 currentRepo: Repository | undefined;
76 currentPosition: Position | undefined;
77
78 filesBeingProcessed = new Set<string>();
79 disposables: Array<Disposable> = [];
80 observedRepos = new Map<string, RepoCaches>();
81 decorationType = window.createTextEditorDecorationType({});
82
83 constructor(
84 private reposList: VSCodeReposList,
85 private ctx: RepositoryContext,
86 ) {
87 this.initBasedOnConfig();
88 }
89
90 initBasedOnConfig() {
91 const config = 'sapling.showInlineBlame';
92 const enableBlameByDefault =
93 Internal?.shouldEnableBlameByDefault == null
94 ? /* OSS */ true
95 : Internal?.shouldEnableBlameByDefault();
96 if (workspace.getConfiguration().get<boolean>(config, enableBlameByDefault)) {
97 this.init();
98 }
99 this.disposables.push(
100 workspace.onDidChangeConfiguration(configChange => {
101 if (configChange.affectsConfiguration(config)) {
102 workspace.getConfiguration().get<boolean>(config) ? this.init() : this.deinit();
103 }
104 }),
105 );
106 }
107
108 init(): void {
109 // Since VS Code sometimes opens on a file, we need to ensure that file's blame is loaded
110 const activeEditor = window.activeTextEditor;
111 if (activeEditor && this.isFile(activeEditor)) {
112 this.loadingEditors.add(activeEditor.document.uri.fsPath);
113 this.switchFocusedEditor(activeEditor);
114 }
115
116 const debouncedChangeActiveEditor = debounce((textEditor: TextEditor | undefined) => {
117 this.switchFocusedEditor(textEditor);
118 }, 1500); // TODO: we could use a longer debounce for new files which aren't cached & shorter debounce if cached.
119 this.disposables.push(
120 window.onDidChangeActiveTextEditor(textEditor => {
121 if (textEditor && this.isFile(textEditor)) {
122 // There is a debounce period before any processing is done on the editor.
123 // loadingEditors is used to "lock" keystroke actions during the debounce
124 // period, as well as before the editor finished processing.
125 this.loadingEditors.add(textEditor.document.uri.fsPath);
126 }
127 this.currentEditor = undefined;
128 this.decorationType.dispose();
129
130 debouncedChangeActiveEditor(textEditor);
131 }),
132 );
133
134 // change the current line's blame when moving the cursor
135 const debouncedOnChangeSelection = debounce((event: TextEditorSelectionChangeEvent) => {
136 const selection = event.selections[0];
137 const activePosition = selection.active;
138 if (
139 !this.currentPosition ||
140 (selection.isEmpty && activePosition.line !== this.currentPosition.line)
141 ) {
142 this.currentPosition = activePosition;
143 const uri = event.textEditor.document.uri.fsPath;
144 if (event.kind && !this.loadingEditors.has(uri) && this.isUriCached(uri)) {
145 this.showBlameAtPos(activePosition);
146 }
147 }
148 }, 50);
149 this.disposables.push(
150 window.onDidChangeTextEditorSelection(e => {
151 if (e.kind != null) {
152 debouncedOnChangeSelection(e);
153 }
154 }),
155 );
156
157 // update blame offsets if the document is changed (to account for local changes)
158 const debouncedOnTextDocumentChange = debounce((event: TextDocumentChangeEvent) => {
159 // precondition: event is a file:// scheme change
160 const uri = event.document.uri.fsPath;
161 if (this.loadingEditors.has(uri) || !this.isUriCached(uri)) {
162 return;
163 }
164
165 // Document has been modified, so update that file's blame before showing annotation.
166 this.updateBlame(event.document);
167 if (this.currentPosition) {
168 this.showBlameAtPos(this.currentPosition);
169 }
170 }, 500);
171 this.disposables.push(
172 workspace.onDidChangeTextDocument(e => {
173 if (this.isFile(e)) {
174 this.decorationType.dispose(); // dispose decorations on any change without waiting for debouncing
175 debouncedOnTextDocumentChange(e);
176 }
177 }),
178 );
179
180 this.ctx.logger.info('Initialized inline blame');
181 }
182
183 deinit(): void {
184 this.decorationType.dispose();
185 for (const disposable of this.disposables) {
186 disposable.dispose();
187 }
188 this.disposables = [];
189
190 this.observedRepos.clear();
191 this.loadingEditors.clear();
192 this.filesBeingProcessed.clear();
193
194 this.currentRepo = undefined;
195 this.currentEditor = undefined;
196 this.currentPosition = undefined;
197 }
198
199 private async switchFocusedEditor(textEditor: TextEditor | undefined): Promise<void> {
200 this.currentEditor = textEditor;
201 this.currentPosition = textEditor?.selection.active;
202 if (textEditor && this.isFile(textEditor) && this.currentPosition) {
203 const foundBlame = await this.fetchBlameIfMissing(textEditor);
204 if (foundBlame) {
205 // Update blame before showing in case keystrokes were pressed on load.
206 this.updateBlame(textEditor.document);
207 this.showBlameAtPos(this.currentPosition);
208 }
209 // Editor is finished processing, so remove it from loadingEditors.
210 this.loadingEditors.delete(textEditor.document.uri.fsPath);
211 }
212 }
213
214 /**
215 * blame is fetched by calling `sl blame` only when the head commit or active file changes,
216 * but not if the cursor moves or local edits are made.
217 */
218 private async fetchBlameIfMissing(textEditor: TextEditor): Promise<boolean> {
219 const uri = textEditor.document.uri;
220 const fileUri = uri.fsPath;
221 if (this.filesBeingProcessed.has(fileUri)) {
222 return false;
223 }
224 this.filesBeingProcessed.add(fileUri);
225
226 const repo = this.reposList.repoForPath(fileUri)?.repo;
227 if (!repo) {
228 this.ctx.logger.warn(`Could not fetch Blame: No repository found for path ${uri.fsPath}.`);
229 this.filesBeingProcessed.delete(fileUri);
230 return false;
231 }
232
233 this.currentRepo = repo;
234 const repoUri = repo.info.repoRoot;
235
236 if (this.isUriCached(fileUri)) {
237 this.filesBeingProcessed.delete(fileUri);
238 return true;
239 } else if (!this.observedRepos.has(repoUri)) {
240 // If we have found a new repo, subscribe to that repo before continuing.
241 this.subscribeToRepo(repo);
242 }
243
244 if (fileUri.endsWith('.code-workspace')) {
245 // workspace files are unrecognized.
246 this.filesBeingProcessed.delete(fileUri);
247 return false;
248 }
249
250 const path = uri.fsPath;
251 const startTime = Date.now();
252
253 const repoCaches = this.observedRepos.get(repoUri);
254 if (repoCaches == null) {
255 this.ctx.logger.warn(`Could not fetch Blame: repo not in cache.`);
256 return false;
257 }
258
259 const blame = await this.getBlame(textEditor, repoCaches?.headHash);
260
261 if (blame.error) {
262 this.ctx.tracker.error('BlameLoaded', 'BlameError', blame.error.message, {
263 duration: Date.now() - startTime,
264 });
265 } else {
266 this.ctx.tracker.track('BlameLoaded', {
267 duration: Date.now() - startTime,
268 });
269 }
270
271 this.filesBeingProcessed.delete(fileUri);
272 if (blame.error) {
273 if (blame.error.name === 'No Blame') {
274 this.ctx.logger.info(`No blame found for path ${path}`, blame.error.message);
275 } else {
276 const message = `Failed to fetch Blame for path ${path}`;
277 this.ctx.logger.error(`${message}: `, blame.error.message);
278 return false;
279 }
280 } else if (blame.value.length === 0) {
281 this.ctx.logger.info(`No blame found for path ${path}`);
282 }
283
284 const blameLines = nullthrows(blame.value);
285
286 repoCaches.blameCache.set(fileUri, {
287 baseBlameLines: blameLines,
288 currentBlameLines: undefined,
289 });
290 return true;
291 }
292
293 private async getBlame(
294 textEditor: TextEditor,
295 baseHash: string,
296 ): Promise<Result<Array<[line: string, commit: CommitInfo | undefined]>>> {
297 const uri = textEditor.document.uri.fsPath;
298 const repo = this.reposList.repoForPath(uri)?.repo;
299 try {
300 return {value: await nullthrows(repo).blame(this.ctx, uri, baseHash)};
301 } catch (err: unknown) {
302 return {error: err as Error};
303 }
304 }
305
306 private showBlameAtPos(position: Position): void {
307 this.decorationType.dispose();
308 if (!this.currentEditor) {
309 return;
310 }
311 const blameText = this.getBlameText(position.line);
312 if (!blameText) {
313 return;
314 }
315
316 const endChar = this.currentEditor.document.lineAt(position).range.end.character;
317 const endPosition = new Position(position.line, endChar);
318
319 const range = new Range(endPosition, endPosition);
320 const hoverMessage = new MarkdownString(blameText.hover);
321 const decorationOptions: DecorationOptions = {range, hoverMessage};
322
323 this.assignDecorationType(blameText.inline);
324 this.currentEditor.setDecorations(this.decorationType, [decorationOptions]);
325 }
326
327 private assignDecorationType(contentText: string): void {
328 if (!this.currentEditor) {
329 return;
330 }
331 const margin = '0 0 0 3vw';
332 this.decorationType = window.createTextEditorDecorationType({
333 dark: {
334 after: {
335 color: '#ffffff33',
336 contentText,
337 margin,
338 },
339 },
340 light: {
341 after: {
342 color: '#00000033',
343 contentText,
344 margin,
345 },
346 },
347 rangeBehavior: DecorationRangeBehavior.ClosedOpen,
348 });
349 }
350
351 private getBlameText(line: number): BlameText | undefined {
352 if (this.currentRepo == null) {
353 return undefined;
354 }
355 const repoCaches = this.observedRepos.get(this.currentRepo.info.repoRoot);
356 if (!this.currentEditor || !repoCaches) {
357 return undefined;
358 }
359 const uri = this.currentEditor.document.uri;
360 const revisionSet = repoCaches.blameCache.get(uri.fsPath);
361 if (!revisionSet) {
362 return undefined;
363 }
364
365 if (revisionSet.currentBlameLines == null) {
366 this.updateBlame(this.currentEditor.document);
367 }
368
369 const blameLines = nullthrows(revisionSet.currentBlameLines);
370 if (line >= blameLines.length) {
371 return undefined;
372 }
373 const commit = blameLines[line][1];
374 if (!commit) {
375 return {inline: `(you) \u2022 Local Changes`, hover: ''};
376 }
377
378 const DOT = '\u2022';
379 try {
380 const inline = `${this.authorHint(commit.author)}${relativeDate(
381 commit.date,
382 {},
383 )} ${DOT} ${commit.title.trim()}`;
384 const hover = getDiffBlameHoverMarkup(this.currentRepo, commit);
385 const blameText = {inline, hover};
386 return blameText;
387 } catch (err) {
388 this.ctx.logger.error('Error getting blame text:', err);
389 return undefined;
390 }
391 }
392
393 private authorHint(author: string): string {
394 if (areYouTheAuthor(author)) {
395 return '(you) ';
396 }
397 if (Internal?.showAuthorNameInInlineBlame?.() === false) {
398 // Internally, don't show author inline unless it's you. Hover to see the author.
399 return '';
400 }
401 return shortenAuthorName(author) + ', ';
402 }
403
404 private initRepoCaches(repoUri: string): void {
405 const caches: RepoCaches = {
406 headHash: '',
407 blameCache: new LRU(MAX_NUM_FILES_CACHED),
408 };
409 this.observedRepos.set(repoUri, caches);
410 }
411
412 private subscribeToRepo(repo: Repository): void {
413 const repoUri = repo.info.repoRoot;
414 this.initRepoCaches(repoUri);
415
416 this.disposables.push(
417 repo.subscribeToHeadCommit(head => {
418 const repoCaches = this.observedRepos.get(repoUri);
419 if (!repoCaches) {
420 return;
421 }
422
423 if (head.hash === repoCaches.headHash) {
424 // Same head means the blame can't have changed.
425 return;
426 }
427
428 if (repoCaches.headHash !== '') {
429 this.ctx.logger.info('Head commit changed, invaldating blame.');
430 }
431
432 repoCaches.headHash = head.hash;
433 repoCaches.blameCache.clear();
434 this.switchFocusedEditor(window.activeTextEditor);
435 }),
436 );
437 }
438
439 private isUriCached(uri: string): boolean {
440 for (const repoCaches of this.observedRepos.values()) {
441 if (repoCaches.blameCache.get(uri) != null) {
442 return true;
443 }
444 }
445 return false;
446 }
447
448 private isFile(editorInstance: {document: TextDocument}): boolean {
449 return editorInstance.document.uri.scheme === 'file';
450 }
451
452 private updateBlame(document: TextDocument): void {
453 const uri = document.uri.fsPath;
454 if (this.filesBeingProcessed.has(uri)) {
455 return;
456 }
457 this.filesBeingProcessed.add(uri);
458 const cachedBlame = this.getCachedBlame(document);
459 if (!cachedBlame) {
460 this.filesBeingProcessed.delete(uri);
461 return;
462 }
463
464 const newRevisionInfo = getRealignedBlameInfo(cachedBlame.baseBlameLines, document.getText());
465
466 cachedBlame.currentBlameLines = newRevisionInfo;
467 this.filesBeingProcessed.delete(uri);
468 }
469
470 private getCachedBlame(document: TextDocument): CachedBlame | undefined {
471 const uri = document.uri.fsPath;
472 for (const repoCaches of this.observedRepos.values()) {
473 if (repoCaches.blameCache.get(uri) != null) {
474 return repoCaches.blameCache.get(uri);
475 }
476 }
477 return undefined;
478 }
479
480 dispose(): void {
481 for (const disposable of this.disposables) {
482 disposable.dispose();
483 }
484 this.disposables = [];
485 this.decorationType.dispose();
486 }
487}
488