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