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
6import { workspace, Uri, Disposable, Event, EventEmitter, window, FileSystemProvider, FileChangeEvent, FileStat, FileType, FileChangeType, FileSystemError } from 'vscode';
7import { debounce, throttle } from './decorators';
8import { fromGitUri, toGitUri } from './uri';
9import { Model, ModelChangeEvent, OriginalResourceChangeEvent } from './model';
10import { filterEvent, eventToPromise, isDescendant, pathEquals, EmptyDisposable } from './util';
11import { Repository } from './repository';
12
13interface CacheRow {
14	uri: Uri;
15	timestamp: number;
16}
17
18const THREE_MINUTES = 1000 * 60 * 3;
19const FIVE_MINUTES = 1000 * 60 * 5;
20
21function sanitizeRef(ref: string, path: string, repository: Repository): string {
22	if (ref === '~') {
23		const fileUri = Uri.file(path);
24		const uriString = fileUri.toString();
25		const [indexStatus] = repository.indexGroup.resourceStates.filter(r => r.resourceUri.toString() === uriString);
26		return indexStatus ? '' : 'HEAD';
27	}
28
29	if (/^~\d$/.test(ref)) {
30		return `:${ref[1]}`;
31	}
32
33	return ref;
34}
35
36export class GitFileSystemProvider implements FileSystemProvider {
37
38	private _onDidChangeFile = new EventEmitter<FileChangeEvent[]>();
39	readonly onDidChangeFile: Event<FileChangeEvent[]> = this._onDidChangeFile.event;
40
41	private changedRepositoryRoots = new Set<string>();
42	private cache = new Map<string, CacheRow>();
43	private mtime = new Date().getTime();
44	private disposables: Disposable[] = [];
45
46	constructor(private model: Model) {
47		this.disposables.push(
48			model.onDidChangeRepository(this.onDidChangeRepository, this),
49			model.onDidChangeOriginalResource(this.onDidChangeOriginalResource, this),
50			workspace.registerFileSystemProvider('git', this, { isReadonly: true, isCaseSensitive: true }),
51			workspace.registerResourceLabelFormatter({
52				scheme: 'git',
53				formatting: {
54					label: '${path} (git)',
55					separator: '/'
56				}
57			})
58		);
59
60		setInterval(() => this.cleanup(), FIVE_MINUTES);
61	}
62
63	private onDidChangeRepository({ repository }: ModelChangeEvent): void {
64		this.changedRepositoryRoots.add(repository.root);
65		this.eventuallyFireChangeEvents();
66	}
67
68	private onDidChangeOriginalResource({ uri }: OriginalResourceChangeEvent): void {
69		if (uri.scheme !== 'file') {
70			return;
71		}
72
73		const gitUri = toGitUri(uri, '', { replaceFileExtension: true });
74		this.mtime = new Date().getTime();
75		this._onDidChangeFile.fire([{ type: FileChangeType.Changed, uri: gitUri }]);
76	}
77
78	@debounce(1100)
79	private eventuallyFireChangeEvents(): void {
80		this.fireChangeEvents();
81	}
82
83	@throttle
84	private async fireChangeEvents(): Promise<void> {
85		if (!window.state.focused) {
86			const onDidFocusWindow = filterEvent(window.onDidChangeWindowState, e => e.focused);
87			await eventToPromise(onDidFocusWindow);
88		}
89
90		const events: FileChangeEvent[] = [];
91
92		for (const { uri } of this.cache.values()) {
93			const fsPath = uri.fsPath;
94
95			for (const root of this.changedRepositoryRoots) {
96				if (isDescendant(root, fsPath)) {
97					events.push({ type: FileChangeType.Changed, uri });
98					break;
99				}
100			}
101		}
102
103		if (events.length > 0) {
104			this.mtime = new Date().getTime();
105			this._onDidChangeFile.fire(events);
106		}
107
108		this.changedRepositoryRoots.clear();
109	}
110
111	private cleanup(): void {
112		const now = new Date().getTime();
113		const cache = new Map<string, CacheRow>();
114
115		for (const row of this.cache.values()) {
116			const { path } = fromGitUri(row.uri);
117			const isOpen = workspace.textDocuments
118				.filter(d => d.uri.scheme === 'file')
119				.some(d => pathEquals(d.uri.fsPath, path));
120
121			if (isOpen || now - row.timestamp < THREE_MINUTES) {
122				cache.set(row.uri.toString(), row);
123			} else {
124				// TODO: should fire delete events?
125			}
126		}
127
128		this.cache = cache;
129	}
130
131	watch(): Disposable {
132		return EmptyDisposable;
133	}
134
135	async stat(uri: Uri): Promise<FileStat> {
136		const { submoduleOf, path, ref } = fromGitUri(uri);
137		const repository = submoduleOf ? this.model.getRepository(submoduleOf) : this.model.getRepository(uri);
138		if (!repository) {
139			throw FileSystemError.FileNotFound();
140		}
141
142		let size = 0;
143		try {
144			const details = await repository.getObjectDetails(sanitizeRef(ref, path, repository), path);
145			size = details.size;
146		} catch {
147			// noop
148		}
149		return { type: FileType.File, size: size, mtime: this.mtime, ctime: 0 };
150	}
151
152	readDirectory(): Thenable<[string, FileType][]> {
153		throw new Error('Method not implemented.');
154	}
155
156	createDirectory(): void {
157		throw new Error('Method not implemented.');
158	}
159
160	async readFile(uri: Uri): Promise<Uint8Array> {
161		const { path, ref, submoduleOf } = fromGitUri(uri);
162
163		if (submoduleOf) {
164			const repository = this.model.getRepository(submoduleOf);
165
166			if (!repository) {
167				throw FileSystemError.FileNotFound();
168			}
169
170			const encoder = new TextEncoder();
171
172			if (ref === 'index') {
173				return encoder.encode(await repository.diffIndexWithHEAD(path));
174			} else {
175				return encoder.encode(await repository.diffWithHEAD(path));
176			}
177		}
178
179		const repository = this.model.getRepository(uri);
180
181		if (!repository) {
182			throw FileSystemError.FileNotFound();
183		}
184
185		const timestamp = new Date().getTime();
186		const cacheValue: CacheRow = { uri, timestamp };
187
188		this.cache.set(uri.toString(), cacheValue);
189
190		try {
191			return await repository.buffer(sanitizeRef(ref, path, repository), path);
192		} catch (err) {
193			return new Uint8Array(0);
194		}
195	}
196
197	writeFile(): void {
198		throw new Error('Method not implemented.');
199	}
200
201	delete(): void {
202		throw new Error('Method not implemented.');
203	}
204
205	rename(): void {
206		throw new Error('Method not implemented.');
207	}
208
209	dispose(): void {
210		this.disposables.forEach(d => d.dispose());
211	}
212}
213