8.5 KB225 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 {Disposable} from 'isl/src/types';
11
12import {repositoryCache} from 'isl-server/src/RepositoryCache';
13import {LRU} from 'shared/LRU';
14import {TypedEventEmitter} from 'shared/TypedEventEmitter';
15import {ensureTrailingPathSep} from 'shared/pathUtils';
16import * 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 */
26export 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
176export function registerSaplingDiffContentProvider(ctx: RepositoryContext): vscode.Disposable {
177 return vscode.workspace.registerTextDocumentContentProvider(
178 SAPLING_DIFF_PROVIDER_SCHEME,
179 new SaplingDiffContentProvider(ctx),
180 );
181}
182
183export const SAPLING_DIFF_PROVIDER_SCHEME = 'sapling-diff';
184/**
185 * {@link vscode.Uri} with scheme of {@link SAPLING_DIFF_PROVIDER_SCHEME}
186 */
187type SaplingDiffEncodedUri = vscode.Uri;
188
189type 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 */
199export 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 */
215export 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