Path: blob/main/src/vs/sessions/contrib/fileTreeView/browser/githubFileSystemProvider.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Emitter, Event } from '../../../../base/common/event.js';6import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js';7import { URI } from '../../../../base/common/uri.js';8import { FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileDeleteOptions, IFileOverwriteOptions, IFileSystemProviderWithFileReadWriteCapability, IFileWriteOptions, IStat, createFileSystemProviderError, IFileChange } from '../../../../platform/files/common/files.js';9import { IRequestService, asJson } from '../../../../platform/request/common/request.js';10import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js';11import { CancellationToken } from '../../../../base/common/cancellation.js';12import { ILogService } from '../../../../platform/log/common/log.js';13import { GITHUB_REMOTE_FILE_SCHEME } from '../../../services/sessions/common/session.js';1415/**16* Derives a display name from a github-remote-file URI.17* Returns "repo (branch)" or just "repo" when on HEAD.18*/19export function getGitHubRemoteFileDisplayName(uri: URI): string | undefined {20if (uri.scheme !== GITHUB_REMOTE_FILE_SCHEME) {21return undefined;22}23const parts = uri.path.split('/').filter(Boolean);24// path = /{owner}/{repo}/{ref}/...25if (parts.length >= 3) {26const [, repo, ref] = parts;27const decodedRepo = decodeURIComponent(repo);28const decodedRef = decodeURIComponent(ref);29if (decodedRef === 'HEAD') {30return decodedRepo;31}32return `${decodedRepo} (${decodedRef})`;33}34return undefined;35}3637/**38* GitHub REST API response for the Trees endpoint.39* GET /repos/{owner}/{repo}/git/trees/{tree_sha}?recursive=140*/41interface IGitHubTreeResponse {42readonly sha: string;43readonly url: string;44readonly truncated: boolean;45readonly tree: readonly IGitHubTreeEntry[];46}4748interface IGitHubTreeEntry {49readonly path: string;50readonly mode: string;51readonly type: 'blob' | 'tree';52readonly sha: string;53readonly size?: number;54readonly url: string;55}5657interface ITreeCacheEntry {58/** Map from path → entry metadata */59readonly entries: Map<string, { type: FileType; size: number; sha: string }>;60readonly fetchedAt: number;61}6263/**64* A readonly virtual filesystem provider backed by the GitHub REST API.65*66* URI format: github-remote-file://github/{owner}/{repo}/{ref}/{path...}67*68* For example: github-remote-file://github/microsoft/vscode/main/src/vs/base/common/uri.ts69*70* This provider fetches the full recursive tree from the GitHub Trees API on first71* access and caches it. Individual file contents are fetched on demand via the72* Blobs API.73*/74export class GitHubFileSystemProvider extends Disposable implements IFileSystemProviderWithFileReadWriteCapability {7576private readonly _onDidChangeCapabilities = this._register(new Emitter<void>());77readonly onDidChangeCapabilities: Event<void> = this._onDidChangeCapabilities.event;7879readonly capabilities: FileSystemProviderCapabilities =80FileSystemProviderCapabilities.Readonly |81FileSystemProviderCapabilities.FileReadWrite |82FileSystemProviderCapabilities.PathCaseSensitive;8384private readonly _onDidChangeFile = this._register(new Emitter<readonly IFileChange[]>());85readonly onDidChangeFile: Event<readonly IFileChange[]> = this._onDidChangeFile.event;8687/** Cache keyed by "owner/repo/ref" */88private readonly treeCache = new Map<string, ITreeCacheEntry>();8990/** Negative cache for refs that returned 404, keyed by "owner/repo/ref" */91private readonly notFoundCache = new Map<string, number>();9293/** In-flight fetch promises keyed by "owner/repo/ref" to deduplicate concurrent requests */94private readonly pendingFetches = new Map<string, Promise<ITreeCacheEntry>>();9596/** Cache TTL - 5 minutes */97private static readonly CACHE_TTL_MS = 5 * 60 * 1000;9899/** Negative cache TTL - 1 minute */100private static readonly NOT_FOUND_CACHE_TTL_MS = 60 * 1000;101102constructor(103@IRequestService private readonly requestService: IRequestService,104@IAuthenticationService private readonly authenticationService: IAuthenticationService,105@ILogService private readonly logService: ILogService,106) {107super();108}109110// --- URI parsing111112/**113* Parse a github-remote-file URI into its components.114* Format: github-remote-file://github/{owner}/{repo}/{ref}/{path...}115*/116private parseUri(resource: URI): { owner: string; repo: string; ref: string; path: string } {117// authority = "github"118// path = /{owner}/{repo}/{ref}/{rest...}119const parts = resource.path.split('/').filter(Boolean);120if (parts.length < 3) {121throw createFileSystemProviderError('Invalid github-remote-file URI: expected /{owner}/{repo}/{ref}/...', FileSystemProviderErrorCode.FileNotFound);122}123124const owner = decodeURIComponent(parts[0]);125const repo = decodeURIComponent(parts[1]);126const ref = decodeURIComponent(parts[2]);127const path = parts.slice(3).map(decodeURIComponent).join('/');128129return { owner, repo, ref, path };130}131132private getCacheKey(owner: string, repo: string, ref: string): string {133return `${owner}/${repo}/${ref}`;134}135136// --- GitHub API137138private async getAuthToken(): Promise<string> {139let sessions = await this.authenticationService.getSessions('github', [], { silent: true });140if (!sessions || sessions.length === 0) {141sessions = await this.authenticationService.getSessions('github', [], { createIfNone: true });142}143if (!sessions || sessions.length === 0) {144throw createFileSystemProviderError('No GitHub authentication sessions available', FileSystemProviderErrorCode.Unavailable);145}146return sessions[0].accessToken ?? '';147}148149private fetchTree(owner: string, repo: string, ref: string): Promise<ITreeCacheEntry> {150const cacheKey = this.getCacheKey(owner, repo, ref);151152// Check positive cache153const cached = this.treeCache.get(cacheKey);154if (cached && (Date.now() - cached.fetchedAt) < GitHubFileSystemProvider.CACHE_TTL_MS) {155return Promise.resolve(cached);156}157158// Check negative cache (recently returned 404)159const notFoundAt = this.notFoundCache.get(cacheKey);160if (notFoundAt !== undefined && (Date.now() - notFoundAt) < GitHubFileSystemProvider.NOT_FOUND_CACHE_TTL_MS) {161return Promise.reject(createFileSystemProviderError(`Tree not found for ${owner}/${repo}@${ref}`, FileSystemProviderErrorCode.FileNotFound));162}163164// Deduplicate concurrent requests for the same tree165const pending = this.pendingFetches.get(cacheKey);166if (pending) {167return pending;168}169170const promise = this.doFetchTree(owner, repo, ref, cacheKey).finally(() => {171this.pendingFetches.delete(cacheKey);172});173this.pendingFetches.set(cacheKey, promise);174return promise;175}176177private async doFetchTree(owner: string, repo: string, ref: string, cacheKey: string): Promise<ITreeCacheEntry> {178this.logService.info(`[SessionRepoFS] Fetching tree for ${owner}/${repo}@${ref}`);179const token = await this.getAuthToken();180181const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/trees/${encodeURIComponent(ref)}?recursive=1`;182const response = await this.requestService.request({183type: 'GET',184url,185headers: {186'Authorization': `token ${token}`,187'Accept': 'application/vnd.github.v3+json',188'User-Agent': 'VSCode-SessionRepoFS',189},190callSite: 'githubFileSystemProvider.fetchTree'191}, CancellationToken.None);192193// Cache 404s so we don't keep re-fetching missing trees194if (response.res.statusCode === 404) {195this.notFoundCache.set(cacheKey, Date.now());196throw createFileSystemProviderError(`Tree not found for ${owner}/${repo}@${ref}`, FileSystemProviderErrorCode.FileNotFound);197}198199const data = await asJson<IGitHubTreeResponse>(response);200201if (!data) {202throw createFileSystemProviderError(`Failed to fetch tree for ${owner}/${repo}@${ref}`, FileSystemProviderErrorCode.Unavailable);203}204205const entries = new Map<string, { type: FileType; size: number; sha: string }>();206207// Add root directory entry208entries.set('', { type: FileType.Directory, size: 0, sha: data.sha });209210// Track directories implicitly from paths211const dirs = new Set<string>();212213for (const entry of data.tree) {214const fileType = entry.type === 'tree' ? FileType.Directory : FileType.File;215entries.set(entry.path, { type: fileType, size: entry.size ?? 0, sha: entry.sha });216217if (fileType === FileType.Directory) {218dirs.add(entry.path);219}220221// Ensure parent directories are tracked222const pathParts = entry.path.split('/');223for (let i = 1; i < pathParts.length; i++) {224const parentPath = pathParts.slice(0, i).join('/');225if (!dirs.has(parentPath)) {226dirs.add(parentPath);227if (!entries.has(parentPath)) {228entries.set(parentPath, { type: FileType.Directory, size: 0, sha: '' });229}230}231}232}233234const cacheEntry: ITreeCacheEntry = { entries, fetchedAt: Date.now() };235this.treeCache.set(cacheKey, cacheEntry);236return cacheEntry;237}238239// --- IFileSystemProvider240241async stat(resource: URI): Promise<IStat> {242const { owner, repo, ref, path } = this.parseUri(resource);243const tree = await this.fetchTree(owner, repo, ref);244const entry = tree.entries.get(path);245246if (!entry) {247throw createFileSystemProviderError('File not found', FileSystemProviderErrorCode.FileNotFound);248}249250return {251type: entry.type,252ctime: 0,253mtime: 0,254size: entry.size,255};256}257258async readdir(resource: URI): Promise<[string, FileType][]> {259const { owner, repo, ref, path } = this.parseUri(resource);260const tree = await this.fetchTree(owner, repo, ref);261262const prefix = path ? path + '/' : '';263const result: [string, FileType][] = [];264265for (const [entryPath, entry] of tree.entries) {266if (!entryPath.startsWith(prefix)) {267continue;268}269270const relativePath = entryPath.slice(prefix.length);271// Only include direct children (no nested paths)272if (relativePath && !relativePath.includes('/')) {273result.push([relativePath, entry.type]);274}275}276277return result;278}279280async readFile(resource: URI): Promise<Uint8Array> {281const { owner, repo, ref, path } = this.parseUri(resource);282const tree = await this.fetchTree(owner, repo, ref);283const entry = tree.entries.get(path);284285if (!entry || entry.type === FileType.Directory) {286throw createFileSystemProviderError('File not found', FileSystemProviderErrorCode.FileNotFound);287}288289const token = await this.getAuthToken();290291// Fetch file content via the Blobs API292const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/blobs/${encodeURIComponent(entry.sha)}`;293const response = await this.requestService.request({294type: 'GET',295url,296headers: {297'Authorization': `token ${token}`,298'Accept': 'application/vnd.github.v3+json',299'User-Agent': 'VSCode-SessionRepoFS',300},301callSite: 'githubFileSystemProvider.readFile'302}, CancellationToken.None);303304const data = await asJson<{ content: string; encoding: string }>(response);305if (!data) {306throw createFileSystemProviderError(`Failed to read file ${path}`, FileSystemProviderErrorCode.Unavailable);307}308309if (data.encoding === 'base64') {310const binaryString = atob(data.content.replace(/\n/g, ''));311const bytes = new Uint8Array(binaryString.length);312for (let i = 0; i < binaryString.length; i++) {313bytes[i] = binaryString.charCodeAt(i);314}315return bytes;316}317318return new TextEncoder().encode(data.content);319}320321// --- Readonly stubs322323watch(): IDisposable {324return Disposable.None;325}326327async writeFile(_resource: URI, _content: Uint8Array, _opts: IFileWriteOptions): Promise<void> {328throw createFileSystemProviderError('Operation not supported', FileSystemProviderErrorCode.NoPermissions);329}330331async mkdir(_resource: URI): Promise<void> {332throw createFileSystemProviderError('Operation not supported', FileSystemProviderErrorCode.NoPermissions);333}334335async delete(_resource: URI, _opts: IFileDeleteOptions): Promise<void> {336throw createFileSystemProviderError('Operation not supported', FileSystemProviderErrorCode.NoPermissions);337}338339async rename(_from: URI, _to: URI, _opts: IFileOverwriteOptions): Promise<void> {340throw createFileSystemProviderError('Operation not supported', FileSystemProviderErrorCode.NoPermissions);341}342343// --- Cache management344345invalidateCache(owner: string, repo: string, ref: string): void {346const cacheKey = this.getCacheKey(owner, repo, ref);347this.treeCache.delete(cacheKey);348this.notFoundCache.delete(cacheKey);349}350351override dispose(): void {352this.treeCache.clear();353this.notFoundCache.clear();354this.pendingFetches.clear();355super.dispose();356}357}358359360