addons/vscode/extension/DiffContentProvider.tsblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {Repository} from 'isl-server/src/Repository';
b69ab319import type {RepositoryContext} from 'isl-server/src/serverTypes';
b69ab3110import type {Disposable} from 'isl/src/types';
b69ab3111
b69ab3112import {repositoryCache} from 'isl-server/src/RepositoryCache';
b69ab3113import {LRU} from 'shared/LRU';
b69ab3114import {TypedEventEmitter} from 'shared/TypedEventEmitter';
b69ab3115import {ensureTrailingPathSep} from 'shared/pathUtils';
b69ab3116import * as vscode from 'vscode';
b69ab3117
b69ab3118/**
b69ab3119 * VSCode's Quick Diff provider systems works by allowing you to describe the equivalent "original" URI
b69ab3120 * for the left side of a diff as a new URI with a custom scheme,
b69ab3121 * then you add a content provider for that custom URI to give the "original" file contents.
b69ab3122 *
b69ab3123 * SaplingDiffContentProvider uses a repository to provide the "original"
b69ab3124 * file content for a comparison using `sl cat`.
b69ab3125 */
b69ab3126export class SaplingDiffContentProvider implements vscode.TextDocumentContentProvider {
b69ab3127 private disposables: Array<vscode.Disposable> = [];
b69ab3128
b69ab3129 /**
b69ab3130 * VS Code doesn't tell us which uris are currently open, so we need to remember
b69ab3131 * uris we've seen before. This is needed so we can tell VS Code which URIs are
b69ab3132 * invalidated when the repository changes via this.onDidChange.
b69ab3133 */
b69ab3134 private activeUrisByRepo: Map<
b69ab3135 Repository | 'unknown',
b69ab3136 Set<string /* serialized SaplingDiffEncodedUri */>
b69ab3137 > = new Map();
b69ab3138
b69ab3139 /**
b69ab3140 * VS Code requests content for uris each time the diff view is focused.
b69ab3141 * Diff original content won't change until the current commit is changed,
b69ab3142 * so we can cache file contents to avoid repeat `sl cat` calls.
b69ab3143 * We don't want to store unlimited file contents in memory, so we use an LRU cache.
b69ab3144 * Missing the cache just means re-running `sl cat` again.
b69ab3145 */
b69ab3146 private fileContentsByEncodedUri = new LRU<string, string>(20);
b69ab3147
b69ab3148 constructor(private ctx: RepositoryContext) {
b69ab3149 let subscriptions: Array<Disposable> = [];
b69ab3150 repositoryCache.onChangeActiveRepos(activeRepos => {
b69ab3151 const knownRoots = activeRepos.map(repo => repo.info.repoRoot);
b69ab3152
b69ab3153 // when we add a repo, we need to see if we can now provide changes to any previously requested path
b69ab3154 const unownedComparisons = this.activeUrisByRepo.get('unknown');
b69ab3155 if (unownedComparisons) {
b69ab3156 const fixed = [];
b69ab3157 for (const encoded of unownedComparisons.values()) {
b69ab3158 const encodedUri = vscode.Uri.parse(encoded);
b69ab3159 const {fsPath} = decodeSaplingDiffUri(encodedUri).originalUri;
b69ab3160 for (const root of knownRoots) {
b69ab3161 if (fsPath === root || fsPath.startsWith(ensureTrailingPathSep(root))) {
b69ab3162 fixed.push(encodedUri);
b69ab3163 break;
b69ab3164 }
b69ab3165 }
b69ab3166 }
b69ab3167 for (const change of fixed) {
b69ab3168 unownedComparisons.delete(change.toString());
b69ab3169 }
b69ab3170 for (const change of fixed) {
b69ab3171 this.changeEmitter.emit('change', change);
b69ab3172 }
b69ab3173 }
b69ab3174
b69ab3175 subscriptions.forEach(sub => sub.dispose());
b69ab3176 subscriptions = activeRepos.map(repo =>
b69ab3177 // Whenever the head commit changes, it means our comparisons in that repo are no longer valid,
b69ab3178 // for example checking out a new commit or making a new commit changes the comparison.
b69ab3179 // TODO: this is slightly wastefully un- and re-subscribing to all repos whenever any of them change.
b69ab3180 // However, repos changing is relatively rare.
b69ab3181 repo.subscribeToHeadCommit(() => {
b69ab3182 // Clear out file cache, so all future fetches re-check with sl cat.
b69ab3183 // TODO: This is slightly over-aggressive, since it invalidates other repos too.
b69ab3184 // We could instead iterate the cache to delete paths belonging to this repo
b69ab3185 this.fileContentsByEncodedUri.clear();
b69ab3186 const uris = this.activeUrisByRepo.get(repo);
b69ab3187 if (uris) {
b69ab3188 this.ctx.logger.info(
b69ab3189 `head commit changed for ${repo.info.repoRoot}, invalidating ${uris.size} diff view contents`,
b69ab3190 );
b69ab3191 for (const uri of uris.values()) {
b69ab3192 // notify vscode of the change, so it re-runs provideTextDocumentContent
b69ab3193 this.changeEmitter.emit('change', vscode.Uri.parse(uri));
b69ab3194 }
b69ab3195 }
b69ab3196 }),
b69ab3197 );
b69ab3198 this.disposables.push({dispose: () => subscriptions.forEach(sub => sub.dispose())});
b69ab3199 });
b69ab31100
b69ab31101 this.disposables.push(
b69ab31102 // track closing diff providers to know when you remove from tracked uris
b69ab31103 vscode.workspace.onDidCloseTextDocument(e => {
b69ab31104 if (e.uri.scheme === SAPLING_DIFF_PROVIDER_SCHEME) {
b69ab31105 for (const uris of this.activeUrisByRepo.values()) {
b69ab31106 const encodedUri = e.uri.toString();
b69ab31107 if (uris.has(encodedUri)) {
b69ab31108 uris.delete(encodedUri);
b69ab31109 // No need to clear the file content cache for this uri at this point:
b69ab31110 // It is very likely the user can re-open the same diff view without changing
b69ab31111 // their head commit. We can use cached file content between these opens
b69ab31112 // to avoid running `sl cat`.
b69ab31113 }
b69ab31114 }
b69ab31115 }
b69ab31116 }),
b69ab31117 );
b69ab31118 }
b69ab31119
b69ab31120 private changeEmitter = new TypedEventEmitter<'change', vscode.Uri>();
b69ab31121 onDidChange(callback: (uri: vscode.Uri) => unknown): vscode.Disposable {
b69ab31122 this.changeEmitter.on('change', callback);
b69ab31123 return {
b69ab31124 dispose: () => this.changeEmitter.off('change', callback),
b69ab31125 };
b69ab31126 }
b69ab31127
b69ab31128 async provideTextDocumentContent(
b69ab31129 encodedUri: vscode.Uri,
b69ab31130 _token: vscode.CancellationToken,
b69ab31131 ): Promise<string | null> {
b69ab31132 const encodedUriString = encodedUri.toString();
b69ab31133 const data = decodeSaplingDiffUri(encodedUri);
b69ab31134 const {fsPath} = data.originalUri;
b69ab31135
b69ab31136 const repo = repositoryCache.cachedRepositoryForPath(fsPath);
b69ab31137
b69ab31138 // remember that this URI was requested.
b69ab31139 const activeUrisSet = this.activeUrisByRepo.get(repo ?? 'unknown') ?? new Set();
b69ab31140 activeUrisSet.add(encodedUriString);
b69ab31141 this.activeUrisByRepo.set(repo ?? 'unknown', activeUrisSet);
b69ab31142
b69ab31143 this.ctx.logger.info('repo for path:', repo?.info.repoRoot);
b69ab31144 if (repo == null) {
b69ab31145 return null;
b69ab31146 }
b69ab31147
b69ab31148 // try the cache first
b69ab31149 const cachedFileContent = this.fileContentsByEncodedUri.get(encodedUriString);
b69ab31150 if (cachedFileContent != null) {
b69ab31151 return cachedFileContent;
b69ab31152 }
b69ab31153
b69ab31154 // Ensure we use a ctx appropriate for this repo. `this.ctx` may be an unrelated cwd.
b69ab31155 const ctx = repo?.initialConnectionContext;
b69ab31156
b69ab31157 // fall back to fetching from the repo
b69ab31158 const fetchedFileContent = await repo
b69ab31159 .cat(ctx, fsPath, data.revset)
b69ab31160 // An error during `cat` usually means the right side of the comparison was added since the left,
b69ab31161 // so `cat` claims `no such file` at that revset.
b69ab31162 // TODO: it would be more accurate to check that the error is due to this, and return null if not.
b69ab31163 .catch(() => '');
b69ab31164 if (fetchedFileContent != null) {
b69ab31165 this.fileContentsByEncodedUri.set(encodedUriString, fetchedFileContent);
b69ab31166 }
b69ab31167 return fetchedFileContent;
b69ab31168 }
b69ab31169
b69ab31170 public dispose() {
b69ab31171 this.disposables.forEach(d => d.dispose());
b69ab31172 this.disposables.length = 0;
b69ab31173 }
b69ab31174}
b69ab31175
b69ab31176export function registerSaplingDiffContentProvider(ctx: RepositoryContext): vscode.Disposable {
b69ab31177 return vscode.workspace.registerTextDocumentContentProvider(
b69ab31178 SAPLING_DIFF_PROVIDER_SCHEME,
b69ab31179 new SaplingDiffContentProvider(ctx),
b69ab31180 );
b69ab31181}
b69ab31182
b69ab31183export const SAPLING_DIFF_PROVIDER_SCHEME = 'sapling-diff';
b69ab31184/**
b69ab31185 * {@link vscode.Uri} with scheme of {@link SAPLING_DIFF_PROVIDER_SCHEME}
b69ab31186 */
b69ab31187type SaplingDiffEncodedUri = vscode.Uri;
b69ab31188
b69ab31189type SaplingURIEncodedData = {
b69ab31190 revset: string;
b69ab31191};
b69ab31192
b69ab31193/**
b69ab31194 * Encode a normal file's URI plus a comparison revset
b69ab31195 * to get the custom URI which {@link SaplingDiffContentProvider} knows how to provide content for
b69ab31196 * that file at that revset.
b69ab31197 * Decoded by {@link decodeSaplingDiffUri}.
b69ab31198 */
b69ab31199export function encodeSaplingDiffUri(uri: vscode.Uri, revset: string): SaplingDiffEncodedUri {
b69ab31200 if (uri.scheme !== 'file') {
b69ab31201 throw new Error('encoding non-file:// uris as sapling diff uris is not supported');
b69ab31202 }
b69ab31203 return uri.with({
b69ab31204 scheme: SAPLING_DIFF_PROVIDER_SCHEME,
b69ab31205 query: JSON.stringify({
b69ab31206 revset,
b69ab31207 } as SaplingURIEncodedData),
b69ab31208 });
b69ab31209}
b69ab31210
b69ab31211/**
b69ab31212 * Decode a custom URI which was encoded by {@link encodeSaplingDiffUri},
b69ab31213 * to get the original file URI back.
b69ab31214 */
b69ab31215export function decodeSaplingDiffUri(uri: SaplingDiffEncodedUri): {
b69ab31216 originalUri: vscode.Uri;
b69ab31217 revset: string;
b69ab31218} {
b69ab31219 const data = JSON.parse(uri.query) as SaplingURIEncodedData;
b69ab31220 return {
b69ab31221 originalUri: uri.with({scheme: 'file', query: ''}),
b69ab31222 revset: data.revset,
b69ab31223 };
b69ab31224}