| 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 | |
| 8 | import type {Repository} from 'isl-server/src/Repository'; |
| 9 | import type {RepositoryContext} from 'isl-server/src/serverTypes'; |
| 10 | import type {CommitInfo, Result} from 'isl/src/types'; |
| 11 | import type { |
| 12 | DecorationOptions, |
| 13 | Disposable, |
| 14 | TextDocument, |
| 15 | TextDocumentChangeEvent, |
| 16 | TextEditor, |
| 17 | TextEditorSelectionChangeEvent, |
| 18 | } from 'vscode'; |
| 19 | import type {VSCodeReposList} from '../VSCodeRepo'; |
| 20 | |
| 21 | import {getUsername} from 'isl-server/src/analytics/environment'; |
| 22 | import {relativeDate} from 'isl/src/relativeDate'; |
| 23 | import {LRU} from 'shared/LRU'; |
| 24 | import {debounce} from 'shared/debounce'; |
| 25 | import {nullthrows} from 'shared/utils'; |
| 26 | import {DecorationRangeBehavior, MarkdownString, Position, Range, window, workspace} from 'vscode'; |
| 27 | import {Internal} from '../Internal'; |
| 28 | import {getDiffBlameHoverMarkup} from './blameHover'; |
| 29 | import {getRealignedBlameInfo, shortenAuthorName} from './blameUtils'; |
| 30 | |
| 31 | function areYouTheAuthor(author: string) { |
| 32 | const you = getUsername(); |
| 33 | return author.includes(you); |
| 34 | } |
| 35 | |
| 36 | type BlameText = { |
| 37 | inline: string; |
| 38 | hover: string; |
| 39 | }; |
| 40 | type RepoCaches = { |
| 41 | headHash: string; |
| 42 | blameCache: LRU<string, CachedBlame>; // Caches file path -> file blame. |
| 43 | }; |
| 44 | type 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 | |
| 51 | const 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 | */ |
| 71 | export 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 | |