mirror of
https://github.com/microsoft/vscode.git
synced 2026-09-03 07:44:02 +01:00
294 lines
10 KiB
TypeScript
294 lines
10 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
import { Emitter, Event } from '../../../../base/common/event.js';
|
|
import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js';
|
|
import { URI } from '../../../../base/common/uri.js';
|
|
import { FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileDeleteOptions, IFileOverwriteOptions, IFileSystemProviderWithFileReadWriteCapability, IFileWriteOptions, IStat, createFileSystemProviderError, IFileChange } from '../../../../platform/files/common/files.js';
|
|
import { IRequestService, asJson } from '../../../../platform/request/common/request.js';
|
|
import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js';
|
|
import { CancellationToken } from '../../../../base/common/cancellation.js';
|
|
import { ILogService } from '../../../../platform/log/common/log.js';
|
|
|
|
export const GITHUB_REMOTE_FILE_SCHEME = 'github-remote-file';
|
|
|
|
/**
|
|
* GitHub REST API response for the Trees endpoint.
|
|
* GET /repos/{owner}/{repo}/git/trees/{tree_sha}?recursive=1
|
|
*/
|
|
interface IGitHubTreeResponse {
|
|
readonly sha: string;
|
|
readonly url: string;
|
|
readonly truncated: boolean;
|
|
readonly tree: readonly IGitHubTreeEntry[];
|
|
}
|
|
|
|
interface IGitHubTreeEntry {
|
|
readonly path: string;
|
|
readonly mode: string;
|
|
readonly type: 'blob' | 'tree';
|
|
readonly sha: string;
|
|
readonly size?: number;
|
|
readonly url: string;
|
|
}
|
|
|
|
interface ITreeCacheEntry {
|
|
/** Map from path → entry metadata */
|
|
readonly entries: Map<string, { type: FileType; size: number; sha: string }>;
|
|
readonly fetchedAt: number;
|
|
}
|
|
|
|
/**
|
|
* A readonly virtual filesystem provider backed by the GitHub REST API.
|
|
*
|
|
* URI format: github-remote-file://github/{owner}/{repo}/{ref}/{path...}
|
|
*
|
|
* For example: github-remote-file://github/microsoft/vscode/main/src/vs/base/common/uri.ts
|
|
*
|
|
* This provider fetches the full recursive tree from the GitHub Trees API on first
|
|
* access and caches it. Individual file contents are fetched on demand via the
|
|
* Blobs API.
|
|
*/
|
|
export class GitHubFileSystemProvider extends Disposable implements IFileSystemProviderWithFileReadWriteCapability {
|
|
|
|
private readonly _onDidChangeCapabilities = this._register(new Emitter<void>());
|
|
readonly onDidChangeCapabilities: Event<void> = this._onDidChangeCapabilities.event;
|
|
|
|
readonly capabilities: FileSystemProviderCapabilities =
|
|
FileSystemProviderCapabilities.Readonly |
|
|
FileSystemProviderCapabilities.FileReadWrite |
|
|
FileSystemProviderCapabilities.PathCaseSensitive;
|
|
|
|
private readonly _onDidChangeFile = this._register(new Emitter<readonly IFileChange[]>());
|
|
readonly onDidChangeFile: Event<readonly IFileChange[]> = this._onDidChangeFile.event;
|
|
|
|
/** Cache keyed by "owner/repo/ref" */
|
|
private readonly treeCache = new Map<string, ITreeCacheEntry>();
|
|
|
|
/** Cache TTL - 5 minutes */
|
|
private static readonly CACHE_TTL_MS = 5 * 60 * 1000;
|
|
|
|
constructor(
|
|
@IRequestService private readonly requestService: IRequestService,
|
|
@IAuthenticationService private readonly authenticationService: IAuthenticationService,
|
|
@ILogService private readonly logService: ILogService,
|
|
) {
|
|
super();
|
|
}
|
|
|
|
// --- URI parsing
|
|
|
|
/**
|
|
* Parse a github-remote-file URI into its components.
|
|
* Format: github-remote-file://github/{owner}/{repo}/{ref}/{path...}
|
|
*/
|
|
private parseUri(resource: URI): { owner: string; repo: string; ref: string; path: string } {
|
|
// authority = "github"
|
|
// path = /{owner}/{repo}/{ref}/{rest...}
|
|
const parts = resource.path.split('/').filter(Boolean);
|
|
if (parts.length < 3) {
|
|
throw createFileSystemProviderError('Invalid github-remote-file URI: expected /{owner}/{repo}/{ref}/...', FileSystemProviderErrorCode.FileNotFound);
|
|
}
|
|
|
|
const owner = parts[0];
|
|
const repo = parts[1];
|
|
const ref = parts[2];
|
|
const path = parts.slice(3).join('/');
|
|
|
|
return { owner, repo, ref, path };
|
|
}
|
|
|
|
private getCacheKey(owner: string, repo: string, ref: string): string {
|
|
return `${owner}/${repo}/${ref}`;
|
|
}
|
|
|
|
// --- GitHub API
|
|
|
|
private async getAuthToken(): Promise<string> {
|
|
const sessions = await this.authenticationService.getSessions('github', ['repo']);
|
|
if (sessions.length > 0) {
|
|
return sessions[0].accessToken;
|
|
}
|
|
|
|
// Try to create a session if none exists
|
|
const session = await this.authenticationService.createSession('github', ['repo']);
|
|
return session.accessToken;
|
|
}
|
|
|
|
private async fetchTree(owner: string, repo: string, ref: string): Promise<ITreeCacheEntry> {
|
|
const cacheKey = this.getCacheKey(owner, repo, ref);
|
|
const cached = this.treeCache.get(cacheKey);
|
|
if (cached && (Date.now() - cached.fetchedAt) < GitHubFileSystemProvider.CACHE_TTL_MS) {
|
|
return cached;
|
|
}
|
|
|
|
this.logService.info(`[SessionRepoFS] Fetching tree for ${owner}/${repo}@${ref}`);
|
|
const token = await this.getAuthToken();
|
|
|
|
const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/trees/${encodeURIComponent(ref)}?recursive=1`;
|
|
const response = await this.requestService.request({
|
|
type: 'GET',
|
|
url,
|
|
headers: {
|
|
'Authorization': `token ${token}`,
|
|
'Accept': 'application/vnd.github.v3+json',
|
|
'User-Agent': 'VSCode-SessionRepoFS',
|
|
},
|
|
}, CancellationToken.None);
|
|
|
|
const data = await asJson<IGitHubTreeResponse>(response);
|
|
if (!data) {
|
|
throw createFileSystemProviderError(`Failed to fetch tree for ${owner}/${repo}@${ref}`, FileSystemProviderErrorCode.Unavailable);
|
|
}
|
|
|
|
const entries = new Map<string, { type: FileType; size: number; sha: string }>();
|
|
|
|
// Add root directory entry
|
|
entries.set('', { type: FileType.Directory, size: 0, sha: data.sha });
|
|
|
|
// Track directories implicitly from paths
|
|
const dirs = new Set<string>();
|
|
|
|
for (const entry of data.tree) {
|
|
const fileType = entry.type === 'tree' ? FileType.Directory : FileType.File;
|
|
entries.set(entry.path, { type: fileType, size: entry.size ?? 0, sha: entry.sha });
|
|
|
|
if (fileType === FileType.Directory) {
|
|
dirs.add(entry.path);
|
|
}
|
|
|
|
// Ensure parent directories are tracked
|
|
const pathParts = entry.path.split('/');
|
|
for (let i = 1; i < pathParts.length; i++) {
|
|
const parentPath = pathParts.slice(0, i).join('/');
|
|
if (!dirs.has(parentPath)) {
|
|
dirs.add(parentPath);
|
|
if (!entries.has(parentPath)) {
|
|
entries.set(parentPath, { type: FileType.Directory, size: 0, sha: '' });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const cacheEntry: ITreeCacheEntry = { entries, fetchedAt: Date.now() };
|
|
this.treeCache.set(cacheKey, cacheEntry);
|
|
return cacheEntry;
|
|
}
|
|
|
|
// --- IFileSystemProvider
|
|
|
|
async stat(resource: URI): Promise<IStat> {
|
|
const { owner, repo, ref, path } = this.parseUri(resource);
|
|
const tree = await this.fetchTree(owner, repo, ref);
|
|
const entry = tree.entries.get(path);
|
|
|
|
if (!entry) {
|
|
throw createFileSystemProviderError('File not found', FileSystemProviderErrorCode.FileNotFound);
|
|
}
|
|
|
|
return {
|
|
type: entry.type,
|
|
ctime: 0,
|
|
mtime: 0,
|
|
size: entry.size,
|
|
};
|
|
}
|
|
|
|
async readdir(resource: URI): Promise<[string, FileType][]> {
|
|
const { owner, repo, ref, path } = this.parseUri(resource);
|
|
const tree = await this.fetchTree(owner, repo, ref);
|
|
|
|
const prefix = path ? path + '/' : '';
|
|
const result: [string, FileType][] = [];
|
|
|
|
for (const [entryPath, entry] of tree.entries) {
|
|
if (!entryPath.startsWith(prefix)) {
|
|
continue;
|
|
}
|
|
|
|
const relativePath = entryPath.slice(prefix.length);
|
|
// Only include direct children (no nested paths)
|
|
if (relativePath && !relativePath.includes('/')) {
|
|
result.push([relativePath, entry.type]);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async readFile(resource: URI): Promise<Uint8Array> {
|
|
const { owner, repo, ref, path } = this.parseUri(resource);
|
|
const tree = await this.fetchTree(owner, repo, ref);
|
|
const entry = tree.entries.get(path);
|
|
|
|
if (!entry || entry.type === FileType.Directory) {
|
|
throw createFileSystemProviderError('File not found', FileSystemProviderErrorCode.FileNotFound);
|
|
}
|
|
|
|
const token = await this.getAuthToken();
|
|
|
|
// Fetch file content via the Blobs API
|
|
const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/blobs/${encodeURIComponent(entry.sha)}`;
|
|
const response = await this.requestService.request({
|
|
type: 'GET',
|
|
url,
|
|
headers: {
|
|
'Authorization': `token ${token}`,
|
|
'Accept': 'application/vnd.github.v3+json',
|
|
'User-Agent': 'VSCode-SessionRepoFS',
|
|
},
|
|
}, CancellationToken.None);
|
|
|
|
const data = await asJson<{ content: string; encoding: string }>(response);
|
|
if (!data) {
|
|
throw createFileSystemProviderError(`Failed to read file ${path}`, FileSystemProviderErrorCode.Unavailable);
|
|
}
|
|
|
|
if (data.encoding === 'base64') {
|
|
const binaryString = atob(data.content.replace(/\n/g, ''));
|
|
const bytes = new Uint8Array(binaryString.length);
|
|
for (let i = 0; i < binaryString.length; i++) {
|
|
bytes[i] = binaryString.charCodeAt(i);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
return new TextEncoder().encode(data.content);
|
|
}
|
|
|
|
// --- Readonly stubs
|
|
|
|
watch(): IDisposable {
|
|
return Disposable.None;
|
|
}
|
|
|
|
async writeFile(_resource: URI, _content: Uint8Array, _opts: IFileWriteOptions): Promise<void> {
|
|
throw createFileSystemProviderError('Operation not supported', FileSystemProviderErrorCode.NoPermissions);
|
|
}
|
|
|
|
async mkdir(_resource: URI): Promise<void> {
|
|
throw createFileSystemProviderError('Operation not supported', FileSystemProviderErrorCode.NoPermissions);
|
|
}
|
|
|
|
async delete(_resource: URI, _opts: IFileDeleteOptions): Promise<void> {
|
|
throw createFileSystemProviderError('Operation not supported', FileSystemProviderErrorCode.NoPermissions);
|
|
}
|
|
|
|
async rename(_from: URI, _to: URI, _opts: IFileOverwriteOptions): Promise<void> {
|
|
throw createFileSystemProviderError('Operation not supported', FileSystemProviderErrorCode.NoPermissions);
|
|
}
|
|
|
|
// --- Cache management
|
|
|
|
invalidateCache(owner: string, repo: string, ref: string): void {
|
|
this.treeCache.delete(this.getCacheKey(owner, repo, ref));
|
|
}
|
|
|
|
override dispose(): void {
|
|
this.treeCache.clear();
|
|
super.dispose();
|
|
}
|
|
}
|