Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/fileTreeView/browser/githubFileSystemProvider.ts
13401 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { Emitter, Event } from '../../../../base/common/event.js';
7
import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js';
8
import { URI } from '../../../../base/common/uri.js';
9
import { FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileDeleteOptions, IFileOverwriteOptions, IFileSystemProviderWithFileReadWriteCapability, IFileWriteOptions, IStat, createFileSystemProviderError, IFileChange } from '../../../../platform/files/common/files.js';
10
import { IRequestService, asJson } from '../../../../platform/request/common/request.js';
11
import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js';
12
import { CancellationToken } from '../../../../base/common/cancellation.js';
13
import { ILogService } from '../../../../platform/log/common/log.js';
14
import { GITHUB_REMOTE_FILE_SCHEME } from '../../../services/sessions/common/session.js';
15
16
/**
17
* Derives a display name from a github-remote-file URI.
18
* Returns "repo (branch)" or just "repo" when on HEAD.
19
*/
20
export function getGitHubRemoteFileDisplayName(uri: URI): string | undefined {
21
if (uri.scheme !== GITHUB_REMOTE_FILE_SCHEME) {
22
return undefined;
23
}
24
const parts = uri.path.split('/').filter(Boolean);
25
// path = /{owner}/{repo}/{ref}/...
26
if (parts.length >= 3) {
27
const [, repo, ref] = parts;
28
const decodedRepo = decodeURIComponent(repo);
29
const decodedRef = decodeURIComponent(ref);
30
if (decodedRef === 'HEAD') {
31
return decodedRepo;
32
}
33
return `${decodedRepo} (${decodedRef})`;
34
}
35
return undefined;
36
}
37
38
/**
39
* GitHub REST API response for the Trees endpoint.
40
* GET /repos/{owner}/{repo}/git/trees/{tree_sha}?recursive=1
41
*/
42
interface IGitHubTreeResponse {
43
readonly sha: string;
44
readonly url: string;
45
readonly truncated: boolean;
46
readonly tree: readonly IGitHubTreeEntry[];
47
}
48
49
interface IGitHubTreeEntry {
50
readonly path: string;
51
readonly mode: string;
52
readonly type: 'blob' | 'tree';
53
readonly sha: string;
54
readonly size?: number;
55
readonly url: string;
56
}
57
58
interface ITreeCacheEntry {
59
/** Map from path → entry metadata */
60
readonly entries: Map<string, { type: FileType; size: number; sha: string }>;
61
readonly fetchedAt: number;
62
}
63
64
/**
65
* A readonly virtual filesystem provider backed by the GitHub REST API.
66
*
67
* URI format: github-remote-file://github/{owner}/{repo}/{ref}/{path...}
68
*
69
* For example: github-remote-file://github/microsoft/vscode/main/src/vs/base/common/uri.ts
70
*
71
* This provider fetches the full recursive tree from the GitHub Trees API on first
72
* access and caches it. Individual file contents are fetched on demand via the
73
* Blobs API.
74
*/
75
export class GitHubFileSystemProvider extends Disposable implements IFileSystemProviderWithFileReadWriteCapability {
76
77
private readonly _onDidChangeCapabilities = this._register(new Emitter<void>());
78
readonly onDidChangeCapabilities: Event<void> = this._onDidChangeCapabilities.event;
79
80
readonly capabilities: FileSystemProviderCapabilities =
81
FileSystemProviderCapabilities.Readonly |
82
FileSystemProviderCapabilities.FileReadWrite |
83
FileSystemProviderCapabilities.PathCaseSensitive;
84
85
private readonly _onDidChangeFile = this._register(new Emitter<readonly IFileChange[]>());
86
readonly onDidChangeFile: Event<readonly IFileChange[]> = this._onDidChangeFile.event;
87
88
/** Cache keyed by "owner/repo/ref" */
89
private readonly treeCache = new Map<string, ITreeCacheEntry>();
90
91
/** Negative cache for refs that returned 404, keyed by "owner/repo/ref" */
92
private readonly notFoundCache = new Map<string, number>();
93
94
/** In-flight fetch promises keyed by "owner/repo/ref" to deduplicate concurrent requests */
95
private readonly pendingFetches = new Map<string, Promise<ITreeCacheEntry>>();
96
97
/** Cache TTL - 5 minutes */
98
private static readonly CACHE_TTL_MS = 5 * 60 * 1000;
99
100
/** Negative cache TTL - 1 minute */
101
private static readonly NOT_FOUND_CACHE_TTL_MS = 60 * 1000;
102
103
constructor(
104
@IRequestService private readonly requestService: IRequestService,
105
@IAuthenticationService private readonly authenticationService: IAuthenticationService,
106
@ILogService private readonly logService: ILogService,
107
) {
108
super();
109
}
110
111
// --- URI parsing
112
113
/**
114
* Parse a github-remote-file URI into its components.
115
* Format: github-remote-file://github/{owner}/{repo}/{ref}/{path...}
116
*/
117
private parseUri(resource: URI): { owner: string; repo: string; ref: string; path: string } {
118
// authority = "github"
119
// path = /{owner}/{repo}/{ref}/{rest...}
120
const parts = resource.path.split('/').filter(Boolean);
121
if (parts.length < 3) {
122
throw createFileSystemProviderError('Invalid github-remote-file URI: expected /{owner}/{repo}/{ref}/...', FileSystemProviderErrorCode.FileNotFound);
123
}
124
125
const owner = decodeURIComponent(parts[0]);
126
const repo = decodeURIComponent(parts[1]);
127
const ref = decodeURIComponent(parts[2]);
128
const path = parts.slice(3).map(decodeURIComponent).join('/');
129
130
return { owner, repo, ref, path };
131
}
132
133
private getCacheKey(owner: string, repo: string, ref: string): string {
134
return `${owner}/${repo}/${ref}`;
135
}
136
137
// --- GitHub API
138
139
private async getAuthToken(): Promise<string> {
140
let sessions = await this.authenticationService.getSessions('github', [], { silent: true });
141
if (!sessions || sessions.length === 0) {
142
sessions = await this.authenticationService.getSessions('github', [], { createIfNone: true });
143
}
144
if (!sessions || sessions.length === 0) {
145
throw createFileSystemProviderError('No GitHub authentication sessions available', FileSystemProviderErrorCode.Unavailable);
146
}
147
return sessions[0].accessToken ?? '';
148
}
149
150
private fetchTree(owner: string, repo: string, ref: string): Promise<ITreeCacheEntry> {
151
const cacheKey = this.getCacheKey(owner, repo, ref);
152
153
// Check positive cache
154
const cached = this.treeCache.get(cacheKey);
155
if (cached && (Date.now() - cached.fetchedAt) < GitHubFileSystemProvider.CACHE_TTL_MS) {
156
return Promise.resolve(cached);
157
}
158
159
// Check negative cache (recently returned 404)
160
const notFoundAt = this.notFoundCache.get(cacheKey);
161
if (notFoundAt !== undefined && (Date.now() - notFoundAt) < GitHubFileSystemProvider.NOT_FOUND_CACHE_TTL_MS) {
162
return Promise.reject(createFileSystemProviderError(`Tree not found for ${owner}/${repo}@${ref}`, FileSystemProviderErrorCode.FileNotFound));
163
}
164
165
// Deduplicate concurrent requests for the same tree
166
const pending = this.pendingFetches.get(cacheKey);
167
if (pending) {
168
return pending;
169
}
170
171
const promise = this.doFetchTree(owner, repo, ref, cacheKey).finally(() => {
172
this.pendingFetches.delete(cacheKey);
173
});
174
this.pendingFetches.set(cacheKey, promise);
175
return promise;
176
}
177
178
private async doFetchTree(owner: string, repo: string, ref: string, cacheKey: string): Promise<ITreeCacheEntry> {
179
this.logService.info(`[SessionRepoFS] Fetching tree for ${owner}/${repo}@${ref}`);
180
const token = await this.getAuthToken();
181
182
const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/trees/${encodeURIComponent(ref)}?recursive=1`;
183
const response = await this.requestService.request({
184
type: 'GET',
185
url,
186
headers: {
187
'Authorization': `token ${token}`,
188
'Accept': 'application/vnd.github.v3+json',
189
'User-Agent': 'VSCode-SessionRepoFS',
190
},
191
callSite: 'githubFileSystemProvider.fetchTree'
192
}, CancellationToken.None);
193
194
// Cache 404s so we don't keep re-fetching missing trees
195
if (response.res.statusCode === 404) {
196
this.notFoundCache.set(cacheKey, Date.now());
197
throw createFileSystemProviderError(`Tree not found for ${owner}/${repo}@${ref}`, FileSystemProviderErrorCode.FileNotFound);
198
}
199
200
const data = await asJson<IGitHubTreeResponse>(response);
201
202
if (!data) {
203
throw createFileSystemProviderError(`Failed to fetch tree for ${owner}/${repo}@${ref}`, FileSystemProviderErrorCode.Unavailable);
204
}
205
206
const entries = new Map<string, { type: FileType; size: number; sha: string }>();
207
208
// Add root directory entry
209
entries.set('', { type: FileType.Directory, size: 0, sha: data.sha });
210
211
// Track directories implicitly from paths
212
const dirs = new Set<string>();
213
214
for (const entry of data.tree) {
215
const fileType = entry.type === 'tree' ? FileType.Directory : FileType.File;
216
entries.set(entry.path, { type: fileType, size: entry.size ?? 0, sha: entry.sha });
217
218
if (fileType === FileType.Directory) {
219
dirs.add(entry.path);
220
}
221
222
// Ensure parent directories are tracked
223
const pathParts = entry.path.split('/');
224
for (let i = 1; i < pathParts.length; i++) {
225
const parentPath = pathParts.slice(0, i).join('/');
226
if (!dirs.has(parentPath)) {
227
dirs.add(parentPath);
228
if (!entries.has(parentPath)) {
229
entries.set(parentPath, { type: FileType.Directory, size: 0, sha: '' });
230
}
231
}
232
}
233
}
234
235
const cacheEntry: ITreeCacheEntry = { entries, fetchedAt: Date.now() };
236
this.treeCache.set(cacheKey, cacheEntry);
237
return cacheEntry;
238
}
239
240
// --- IFileSystemProvider
241
242
async stat(resource: URI): Promise<IStat> {
243
const { owner, repo, ref, path } = this.parseUri(resource);
244
const tree = await this.fetchTree(owner, repo, ref);
245
const entry = tree.entries.get(path);
246
247
if (!entry) {
248
throw createFileSystemProviderError('File not found', FileSystemProviderErrorCode.FileNotFound);
249
}
250
251
return {
252
type: entry.type,
253
ctime: 0,
254
mtime: 0,
255
size: entry.size,
256
};
257
}
258
259
async readdir(resource: URI): Promise<[string, FileType][]> {
260
const { owner, repo, ref, path } = this.parseUri(resource);
261
const tree = await this.fetchTree(owner, repo, ref);
262
263
const prefix = path ? path + '/' : '';
264
const result: [string, FileType][] = [];
265
266
for (const [entryPath, entry] of tree.entries) {
267
if (!entryPath.startsWith(prefix)) {
268
continue;
269
}
270
271
const relativePath = entryPath.slice(prefix.length);
272
// Only include direct children (no nested paths)
273
if (relativePath && !relativePath.includes('/')) {
274
result.push([relativePath, entry.type]);
275
}
276
}
277
278
return result;
279
}
280
281
async readFile(resource: URI): Promise<Uint8Array> {
282
const { owner, repo, ref, path } = this.parseUri(resource);
283
const tree = await this.fetchTree(owner, repo, ref);
284
const entry = tree.entries.get(path);
285
286
if (!entry || entry.type === FileType.Directory) {
287
throw createFileSystemProviderError('File not found', FileSystemProviderErrorCode.FileNotFound);
288
}
289
290
const token = await this.getAuthToken();
291
292
// Fetch file content via the Blobs API
293
const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/blobs/${encodeURIComponent(entry.sha)}`;
294
const response = await this.requestService.request({
295
type: 'GET',
296
url,
297
headers: {
298
'Authorization': `token ${token}`,
299
'Accept': 'application/vnd.github.v3+json',
300
'User-Agent': 'VSCode-SessionRepoFS',
301
},
302
callSite: 'githubFileSystemProvider.readFile'
303
}, CancellationToken.None);
304
305
const data = await asJson<{ content: string; encoding: string }>(response);
306
if (!data) {
307
throw createFileSystemProviderError(`Failed to read file ${path}`, FileSystemProviderErrorCode.Unavailable);
308
}
309
310
if (data.encoding === 'base64') {
311
const binaryString = atob(data.content.replace(/\n/g, ''));
312
const bytes = new Uint8Array(binaryString.length);
313
for (let i = 0; i < binaryString.length; i++) {
314
bytes[i] = binaryString.charCodeAt(i);
315
}
316
return bytes;
317
}
318
319
return new TextEncoder().encode(data.content);
320
}
321
322
// --- Readonly stubs
323
324
watch(): IDisposable {
325
return Disposable.None;
326
}
327
328
async writeFile(_resource: URI, _content: Uint8Array, _opts: IFileWriteOptions): Promise<void> {
329
throw createFileSystemProviderError('Operation not supported', FileSystemProviderErrorCode.NoPermissions);
330
}
331
332
async mkdir(_resource: URI): Promise<void> {
333
throw createFileSystemProviderError('Operation not supported', FileSystemProviderErrorCode.NoPermissions);
334
}
335
336
async delete(_resource: URI, _opts: IFileDeleteOptions): Promise<void> {
337
throw createFileSystemProviderError('Operation not supported', FileSystemProviderErrorCode.NoPermissions);
338
}
339
340
async rename(_from: URI, _to: URI, _opts: IFileOverwriteOptions): Promise<void> {
341
throw createFileSystemProviderError('Operation not supported', FileSystemProviderErrorCode.NoPermissions);
342
}
343
344
// --- Cache management
345
346
invalidateCache(owner: string, repo: string, ref: string): void {
347
const cacheKey = this.getCacheKey(owner, repo, ref);
348
this.treeCache.delete(cacheKey);
349
this.notFoundCache.delete(cacheKey);
350
}
351
352
override dispose(): void {
353
this.treeCache.clear();
354
this.notFoundCache.clear();
355
this.pendingFetches.clear();
356
super.dispose();
357
}
358
}
359
360