| 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 {Disposable} from 'isl/src/types'; |
| 11 | |
| 12 | import {repositoryCache} from 'isl-server/src/RepositoryCache'; |
| 13 | import {LRU} from 'shared/LRU'; |
| 14 | import {TypedEventEmitter} from 'shared/TypedEventEmitter'; |
| 15 | import {ensureTrailingPathSep} from 'shared/pathUtils'; |
| 16 | import * as vscode from 'vscode'; |
| 17 | |
| 18 | /** |
| 19 | * VSCode's Quick Diff provider systems works by allowing you to describe the equivalent "original" URI |
| 20 | * for the left side of a diff as a new URI with a custom scheme, |
| 21 | * then you add a content provider for that custom URI to give the "original" file contents. |
| 22 | * |
| 23 | * SaplingDiffContentProvider uses a repository to provide the "original" |
| 24 | * file content for a comparison using `sl cat`. |
| 25 | */ |
| 26 | export class SaplingDiffContentProvider implements vscode.TextDocumentContentProvider { |
| 27 | private disposables: Array<vscode.Disposable> = []; |
| 28 | |
| 29 | /** |
| 30 | * VS Code doesn't tell us which uris are currently open, so we need to remember |
| 31 | * uris we've seen before. This is needed so we can tell VS Code which URIs are |
| 32 | * invalidated when the repository changes via this.onDidChange. |
| 33 | */ |
| 34 | private activeUrisByRepo: Map< |
| 35 | Repository | 'unknown', |
| 36 | Set<string /* serialized SaplingDiffEncodedUri */> |
| 37 | > = new Map(); |
| 38 | |
| 39 | /** |
| 40 | * VS Code requests content for uris each time the diff view is focused. |
| 41 | * Diff original content won't change until the current commit is changed, |
| 42 | * so we can cache file contents to avoid repeat `sl cat` calls. |
| 43 | * We don't want to store unlimited file contents in memory, so we use an LRU cache. |
| 44 | * Missing the cache just means re-running `sl cat` again. |
| 45 | */ |
| 46 | private fileContentsByEncodedUri = new LRU<string, string>(20); |
| 47 | |
| 48 | constructor(private ctx: RepositoryContext) { |
| 49 | let subscriptions: Array<Disposable> = []; |
| 50 | repositoryCache.onChangeActiveRepos(activeRepos => { |
| 51 | const knownRoots = activeRepos.map(repo => repo.info.repoRoot); |
| 52 | |
| 53 | // when we add a repo, we need to see if we can now provide changes to any previously requested path |
| 54 | const unownedComparisons = this.activeUrisByRepo.get('unknown'); |
| 55 | if (unownedComparisons) { |
| 56 | const fixed = []; |
| 57 | for (const encoded of unownedComparisons.values()) { |
| 58 | const encodedUri = vscode.Uri.parse(encoded); |
| 59 | const {fsPath} = decodeSaplingDiffUri(encodedUri).originalUri; |
| 60 | for (const root of knownRoots) { |
| 61 | if (fsPath === root || fsPath.startsWith(ensureTrailingPathSep(root))) { |
| 62 | fixed.push(encodedUri); |
| 63 | break; |
| 64 | } |
| 65 | } |
| 66 | } |
| 67 | for (const change of fixed) { |
| 68 | unownedComparisons.delete(change.toString()); |
| 69 | } |
| 70 | for (const change of fixed) { |
| 71 | this.changeEmitter.emit('change', change); |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | subscriptions.forEach(sub => sub.dispose()); |
| 76 | subscriptions = activeRepos.map(repo => |
| 77 | // Whenever the head commit changes, it means our comparisons in that repo are no longer valid, |
| 78 | // for example checking out a new commit or making a new commit changes the comparison. |
| 79 | // TODO: this is slightly wastefully un- and re-subscribing to all repos whenever any of them change. |
| 80 | // However, repos changing is relatively rare. |
| 81 | repo.subscribeToHeadCommit(() => { |
| 82 | // Clear out file cache, so all future fetches re-check with sl cat. |
| 83 | // TODO: This is slightly over-aggressive, since it invalidates other repos too. |
| 84 | // We could instead iterate the cache to delete paths belonging to this repo |
| 85 | this.fileContentsByEncodedUri.clear(); |
| 86 | const uris = this.activeUrisByRepo.get(repo); |
| 87 | if (uris) { |
| 88 | this.ctx.logger.info( |
| 89 | `head commit changed for ${repo.info.repoRoot}, invalidating ${uris.size} diff view contents`, |
| 90 | ); |
| 91 | for (const uri of uris.values()) { |
| 92 | // notify vscode of the change, so it re-runs provideTextDocumentContent |
| 93 | this.changeEmitter.emit('change', vscode.Uri.parse(uri)); |
| 94 | } |
| 95 | } |
| 96 | }), |
| 97 | ); |
| 98 | this.disposables.push({dispose: () => subscriptions.forEach(sub => sub.dispose())}); |
| 99 | }); |
| 100 | |
| 101 | this.disposables.push( |
| 102 | // track closing diff providers to know when you remove from tracked uris |
| 103 | vscode.workspace.onDidCloseTextDocument(e => { |
| 104 | if (e.uri.scheme === SAPLING_DIFF_PROVIDER_SCHEME) { |
| 105 | for (const uris of this.activeUrisByRepo.values()) { |
| 106 | const encodedUri = e.uri.toString(); |
| 107 | if (uris.has(encodedUri)) { |
| 108 | uris.delete(encodedUri); |
| 109 | // No need to clear the file content cache for this uri at this point: |
| 110 | // It is very likely the user can re-open the same diff view without changing |
| 111 | // their head commit. We can use cached file content between these opens |
| 112 | // to avoid running `sl cat`. |
| 113 | } |
| 114 | } |
| 115 | } |
| 116 | }), |
| 117 | ); |
| 118 | } |
| 119 | |
| 120 | private changeEmitter = new TypedEventEmitter<'change', vscode.Uri>(); |
| 121 | onDidChange(callback: (uri: vscode.Uri) => unknown): vscode.Disposable { |
| 122 | this.changeEmitter.on('change', callback); |
| 123 | return { |
| 124 | dispose: () => this.changeEmitter.off('change', callback), |
| 125 | }; |
| 126 | } |
| 127 | |
| 128 | async provideTextDocumentContent( |
| 129 | encodedUri: vscode.Uri, |
| 130 | _token: vscode.CancellationToken, |
| 131 | ): Promise<string | null> { |
| 132 | const encodedUriString = encodedUri.toString(); |
| 133 | const data = decodeSaplingDiffUri(encodedUri); |
| 134 | const {fsPath} = data.originalUri; |
| 135 | |
| 136 | const repo = repositoryCache.cachedRepositoryForPath(fsPath); |
| 137 | |
| 138 | // remember that this URI was requested. |
| 139 | const activeUrisSet = this.activeUrisByRepo.get(repo ?? 'unknown') ?? new Set(); |
| 140 | activeUrisSet.add(encodedUriString); |
| 141 | this.activeUrisByRepo.set(repo ?? 'unknown', activeUrisSet); |
| 142 | |
| 143 | this.ctx.logger.info('repo for path:', repo?.info.repoRoot); |
| 144 | if (repo == null) { |
| 145 | return null; |
| 146 | } |
| 147 | |
| 148 | // try the cache first |
| 149 | const cachedFileContent = this.fileContentsByEncodedUri.get(encodedUriString); |
| 150 | if (cachedFileContent != null) { |
| 151 | return cachedFileContent; |
| 152 | } |
| 153 | |
| 154 | // Ensure we use a ctx appropriate for this repo. `this.ctx` may be an unrelated cwd. |
| 155 | const ctx = repo?.initialConnectionContext; |
| 156 | |
| 157 | // fall back to fetching from the repo |
| 158 | const fetchedFileContent = await repo |
| 159 | .cat(ctx, fsPath, data.revset) |
| 160 | // An error during `cat` usually means the right side of the comparison was added since the left, |
| 161 | // so `cat` claims `no such file` at that revset. |
| 162 | // TODO: it would be more accurate to check that the error is due to this, and return null if not. |
| 163 | .catch(() => ''); |
| 164 | if (fetchedFileContent != null) { |
| 165 | this.fileContentsByEncodedUri.set(encodedUriString, fetchedFileContent); |
| 166 | } |
| 167 | return fetchedFileContent; |
| 168 | } |
| 169 | |
| 170 | public dispose() { |
| 171 | this.disposables.forEach(d => d.dispose()); |
| 172 | this.disposables.length = 0; |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | export function registerSaplingDiffContentProvider(ctx: RepositoryContext): vscode.Disposable { |
| 177 | return vscode.workspace.registerTextDocumentContentProvider( |
| 178 | SAPLING_DIFF_PROVIDER_SCHEME, |
| 179 | new SaplingDiffContentProvider(ctx), |
| 180 | ); |
| 181 | } |
| 182 | |
| 183 | export const SAPLING_DIFF_PROVIDER_SCHEME = 'sapling-diff'; |
| 184 | /** |
| 185 | * {@link vscode.Uri} with scheme of {@link SAPLING_DIFF_PROVIDER_SCHEME} |
| 186 | */ |
| 187 | type SaplingDiffEncodedUri = vscode.Uri; |
| 188 | |
| 189 | type SaplingURIEncodedData = { |
| 190 | revset: string; |
| 191 | }; |
| 192 | |
| 193 | /** |
| 194 | * Encode a normal file's URI plus a comparison revset |
| 195 | * to get the custom URI which {@link SaplingDiffContentProvider} knows how to provide content for |
| 196 | * that file at that revset. |
| 197 | * Decoded by {@link decodeSaplingDiffUri}. |
| 198 | */ |
| 199 | export function encodeSaplingDiffUri(uri: vscode.Uri, revset: string): SaplingDiffEncodedUri { |
| 200 | if (uri.scheme !== 'file') { |
| 201 | throw new Error('encoding non-file:// uris as sapling diff uris is not supported'); |
| 202 | } |
| 203 | return uri.with({ |
| 204 | scheme: SAPLING_DIFF_PROVIDER_SCHEME, |
| 205 | query: JSON.stringify({ |
| 206 | revset, |
| 207 | } as SaplingURIEncodedData), |
| 208 | }); |
| 209 | } |
| 210 | |
| 211 | /** |
| 212 | * Decode a custom URI which was encoded by {@link encodeSaplingDiffUri}, |
| 213 | * to get the original file URI back. |
| 214 | */ |
| 215 | export function decodeSaplingDiffUri(uri: SaplingDiffEncodedUri): { |
| 216 | originalUri: vscode.Uri; |
| 217 | revset: string; |
| 218 | } { |
| 219 | const data = JSON.parse(uri.query) as SaplingURIEncodedData; |
| 220 | return { |
| 221 | originalUri: uri.with({scheme: 'file', query: ''}), |
| 222 | revset: data.revset, |
| 223 | }; |
| 224 | } |
| 225 | |