mirror of
https://github.com/ckaczor/azuredatastudio.git
synced 2026-01-30 09:35:39 -05:00
Merge from vscode 8e0f348413f4f616c23a88ae30030efa85811973 (#6381)
* Merge from vscode 8e0f348413f4f616c23a88ae30030efa85811973 * disable strict null check
This commit is contained in:
556
src/vs/platform/files/node/diskFileSystemProvider.ts
Normal file
556
src/vs/platform/files/node/diskFileSystemProvider.ts
Normal file
@@ -0,0 +1,556 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { mkdir, open, close, read, write, fdatasync, Dirent, Stats } from 'fs';
|
||||
import { promisify } from 'util';
|
||||
import { IDisposable, Disposable, toDisposable, dispose, combinedDisposable } from 'vs/base/common/lifecycle';
|
||||
import { IFileSystemProvider, FileSystemProviderCapabilities, IFileChange, IWatchOptions, IStat, FileType, FileDeleteOptions, FileOverwriteOptions, FileWriteOptions, FileOpenOptions, FileSystemProviderErrorCode, createFileSystemProviderError, FileSystemProviderError } from 'vs/platform/files/common/files';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { Event, Emitter } from 'vs/base/common/event';
|
||||
import { isLinux, isWindows } from 'vs/base/common/platform';
|
||||
import { statLink, unlink, move, copy, readFile, truncate, rimraf, RimRafMode, exists, readdirWithFileTypes } from 'vs/base/node/pfs';
|
||||
import { normalize, basename, dirname } from 'vs/base/common/path';
|
||||
import { joinPath } from 'vs/base/common/resources';
|
||||
import { isEqual } from 'vs/base/common/extpath';
|
||||
import { retry, ThrottledDelayer } from 'vs/base/common/async';
|
||||
import { ILogService, LogLevel } from 'vs/platform/log/common/log';
|
||||
import { localize } from 'vs/nls';
|
||||
import { IDiskFileChange, toFileChanges, ILogMessage } from 'vs/platform/files/node/watcher/watcher';
|
||||
import { FileWatcher as UnixWatcherService } from 'vs/platform/files/node/watcher/unix/watcherService';
|
||||
import { FileWatcher as WindowsWatcherService } from 'vs/platform/files/node/watcher/win32/watcherService';
|
||||
import { FileWatcher as NsfwWatcherService } from 'vs/platform/files/node/watcher/nsfw/watcherService';
|
||||
import { FileWatcher as NodeJSWatcherService } from 'vs/platform/files/node/watcher/nodejs/watcherService';
|
||||
|
||||
export interface IWatcherOptions {
|
||||
pollingInterval?: number;
|
||||
usePolling: boolean;
|
||||
}
|
||||
|
||||
export class DiskFileSystemProvider extends Disposable implements IFileSystemProvider {
|
||||
|
||||
constructor(private logService: ILogService, private watcherOptions?: IWatcherOptions) {
|
||||
super();
|
||||
}
|
||||
|
||||
//#region File Capabilities
|
||||
|
||||
onDidChangeCapabilities: Event<void> = Event.None;
|
||||
|
||||
protected _capabilities: FileSystemProviderCapabilities;
|
||||
get capabilities(): FileSystemProviderCapabilities {
|
||||
if (!this._capabilities) {
|
||||
this._capabilities =
|
||||
FileSystemProviderCapabilities.FileReadWrite |
|
||||
FileSystemProviderCapabilities.FileOpenReadWriteClose |
|
||||
FileSystemProviderCapabilities.FileFolderCopy;
|
||||
|
||||
if (isLinux) {
|
||||
this._capabilities |= FileSystemProviderCapabilities.PathCaseSensitive;
|
||||
}
|
||||
}
|
||||
|
||||
return this._capabilities;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region File Metadata Resolving
|
||||
|
||||
async stat(resource: URI): Promise<IStat> {
|
||||
try {
|
||||
const { stat, isSymbolicLink } = await statLink(this.toFilePath(resource)); // cannot use fs.stat() here to support links properly
|
||||
|
||||
return {
|
||||
type: this.toType(stat, isSymbolicLink),
|
||||
ctime: stat.ctime.getTime(),
|
||||
mtime: stat.mtime.getTime(),
|
||||
size: stat.size
|
||||
};
|
||||
} catch (error) {
|
||||
throw this.toFileSystemProviderError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async readdir(resource: URI): Promise<[string, FileType][]> {
|
||||
try {
|
||||
const children = await readdirWithFileTypes(this.toFilePath(resource));
|
||||
|
||||
const result: [string, FileType][] = [];
|
||||
await Promise.all(children.map(async child => {
|
||||
try {
|
||||
let type: FileType;
|
||||
if (child.isSymbolicLink()) {
|
||||
type = (await this.stat(joinPath(resource, child.name))).type; // always resolve target the link points to if any
|
||||
} else {
|
||||
type = this.toType(child);
|
||||
}
|
||||
|
||||
result.push([child.name, type]);
|
||||
} catch (error) {
|
||||
this.logService.trace(error); // ignore errors for individual entries that can arise from permission denied
|
||||
}
|
||||
}));
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw this.toFileSystemProviderError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private toType(entry: Stats | Dirent, isSymbolicLink = entry.isSymbolicLink()): FileType {
|
||||
if (isSymbolicLink) {
|
||||
return FileType.SymbolicLink | (entry.isDirectory() ? FileType.Directory : FileType.File);
|
||||
}
|
||||
|
||||
return entry.isFile() ? FileType.File : entry.isDirectory() ? FileType.Directory : FileType.Unknown;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region File Reading/Writing
|
||||
|
||||
async readFile(resource: URI): Promise<Uint8Array> {
|
||||
try {
|
||||
const filePath = this.toFilePath(resource);
|
||||
|
||||
return await readFile(filePath);
|
||||
} catch (error) {
|
||||
throw this.toFileSystemProviderError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async writeFile(resource: URI, content: Uint8Array, opts: FileWriteOptions): Promise<void> {
|
||||
let handle: number | undefined = undefined;
|
||||
try {
|
||||
const filePath = this.toFilePath(resource);
|
||||
|
||||
// Validate target
|
||||
const fileExists = await exists(filePath);
|
||||
if (fileExists && !opts.overwrite) {
|
||||
throw createFileSystemProviderError(new Error(localize('fileExists', "File already exists")), FileSystemProviderErrorCode.FileExists);
|
||||
} else if (!fileExists && !opts.create) {
|
||||
throw createFileSystemProviderError(new Error(localize('fileNotExists', "File does not exist")), FileSystemProviderErrorCode.FileNotFound);
|
||||
}
|
||||
|
||||
// Open
|
||||
handle = await this.open(resource, { create: true });
|
||||
|
||||
// Write content at once
|
||||
await this.write(handle, 0, content, 0, content.byteLength);
|
||||
} catch (error) {
|
||||
throw this.toFileSystemProviderError(error);
|
||||
} finally {
|
||||
if (typeof handle === 'number') {
|
||||
await this.close(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private writeHandles: Set<number> = new Set();
|
||||
private canFlush: boolean = true;
|
||||
|
||||
async open(resource: URI, opts: FileOpenOptions): Promise<number> {
|
||||
try {
|
||||
const filePath = this.toFilePath(resource);
|
||||
|
||||
let flags: string | undefined = undefined;
|
||||
if (opts.create) {
|
||||
if (isWindows && await exists(filePath)) {
|
||||
try {
|
||||
// On Windows and if the file exists, we use a different strategy of saving the file
|
||||
// by first truncating the file and then writing with r+ flag. This helps to save hidden files on Windows
|
||||
// (see https://github.com/Microsoft/vscode/issues/931) and prevent removing alternate data streams
|
||||
// (see https://github.com/Microsoft/vscode/issues/6363)
|
||||
await truncate(filePath, 0);
|
||||
|
||||
// After a successful truncate() the flag can be set to 'r+' which will not truncate.
|
||||
flags = 'r+';
|
||||
} catch (error) {
|
||||
this.logService.trace(error);
|
||||
}
|
||||
}
|
||||
|
||||
// we take opts.create as a hint that the file is opened for writing
|
||||
// as such we use 'w' to truncate an existing or create the
|
||||
// file otherwise. we do not allow reading.
|
||||
if (!flags) {
|
||||
flags = 'w';
|
||||
}
|
||||
} else {
|
||||
// otherwise we assume the file is opened for reading
|
||||
// as such we use 'r' to neither truncate, nor create
|
||||
// the file.
|
||||
flags = 'r';
|
||||
}
|
||||
|
||||
const handle = await promisify(open)(filePath, flags);
|
||||
|
||||
// remember that this handle was used for writing
|
||||
if (opts.create) {
|
||||
this.writeHandles.add(handle);
|
||||
}
|
||||
|
||||
return handle;
|
||||
} catch (error) {
|
||||
throw this.toFileSystemProviderError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async close(fd: number): Promise<void> {
|
||||
try {
|
||||
// if a handle is closed that was used for writing, ensure
|
||||
// to flush the contents to disk if possible.
|
||||
if (this.writeHandles.delete(fd) && this.canFlush) {
|
||||
try {
|
||||
await promisify(fdatasync)(fd);
|
||||
} catch (error) {
|
||||
// In some exotic setups it is well possible that node fails to sync
|
||||
// In that case we disable flushing and log the error to our logger
|
||||
this.canFlush = false;
|
||||
this.logService.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
return await promisify(close)(fd);
|
||||
} catch (error) {
|
||||
throw this.toFileSystemProviderError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async read(fd: number, pos: number, data: Uint8Array, offset: number, length: number): Promise<number> {
|
||||
try {
|
||||
const result = await promisify(read)(fd, data, offset, length, pos);
|
||||
if (typeof result === 'number') {
|
||||
return result; // node.d.ts fail
|
||||
}
|
||||
|
||||
return result.bytesRead;
|
||||
} catch (error) {
|
||||
throw this.toFileSystemProviderError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async write(fd: number, pos: number, data: Uint8Array, offset: number, length: number): Promise<number> {
|
||||
// we know at this point that the file to write to is truncated and thus empty
|
||||
// if the write now fails, the file remains empty. as such we really try hard
|
||||
// to ensure the write succeeds by retrying up to three times.
|
||||
return retry(() => this.doWrite(fd, pos, data, offset, length), 100 /* ms delay */, 3 /* retries */);
|
||||
}
|
||||
|
||||
private async doWrite(fd: number, pos: number, data: Uint8Array, offset: number, length: number): Promise<number> {
|
||||
try {
|
||||
const result = await promisify(write)(fd, data, offset, length, pos);
|
||||
if (typeof result === 'number') {
|
||||
return result; // node.d.ts fail
|
||||
}
|
||||
|
||||
return result.bytesWritten;
|
||||
} catch (error) {
|
||||
throw this.toFileSystemProviderError(error);
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Move/Copy/Delete/Create Folder
|
||||
|
||||
async mkdir(resource: URI): Promise<void> {
|
||||
try {
|
||||
await promisify(mkdir)(this.toFilePath(resource));
|
||||
} catch (error) {
|
||||
throw this.toFileSystemProviderError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(resource: URI, opts: FileDeleteOptions): Promise<void> {
|
||||
try {
|
||||
const filePath = this.toFilePath(resource);
|
||||
|
||||
await this.doDelete(filePath, opts);
|
||||
} catch (error) {
|
||||
throw this.toFileSystemProviderError(error);
|
||||
}
|
||||
}
|
||||
|
||||
protected async doDelete(filePath: string, opts: FileDeleteOptions): Promise<void> {
|
||||
if (opts.recursive) {
|
||||
await rimraf(filePath, RimRafMode.MOVE);
|
||||
} else {
|
||||
await unlink(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
async rename(from: URI, to: URI, opts: FileOverwriteOptions): Promise<void> {
|
||||
const fromFilePath = this.toFilePath(from);
|
||||
const toFilePath = this.toFilePath(to);
|
||||
|
||||
if (fromFilePath === toFilePath) {
|
||||
return; // simulate node.js behaviour here and do a no-op if paths match
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
// Ensure target does not exist
|
||||
await this.validateTargetDeleted(from, to, 'move', opts && opts.overwrite);
|
||||
|
||||
// Move
|
||||
await move(fromFilePath, toFilePath);
|
||||
} catch (error) {
|
||||
|
||||
// rewrite some typical errors that can happen especially around symlinks
|
||||
// to something the user can better understand
|
||||
if (error.code === 'EINVAL' || error.code === 'EBUSY' || error.code === 'ENAMETOOLONG') {
|
||||
error = new Error(localize('moveError', "Unable to move '{0}' into '{1}' ({2}).", basename(fromFilePath), basename(dirname(toFilePath)), error.toString()));
|
||||
}
|
||||
|
||||
throw this.toFileSystemProviderError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async copy(from: URI, to: URI, opts: FileOverwriteOptions): Promise<void> {
|
||||
const fromFilePath = this.toFilePath(from);
|
||||
const toFilePath = this.toFilePath(to);
|
||||
|
||||
if (fromFilePath === toFilePath) {
|
||||
return; // simulate node.js behaviour here and do a no-op if paths match
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
// Ensure target does not exist
|
||||
await this.validateTargetDeleted(from, to, 'copy', opts && opts.overwrite);
|
||||
|
||||
// Copy
|
||||
await copy(fromFilePath, toFilePath);
|
||||
} catch (error) {
|
||||
|
||||
// rewrite some typical errors that can happen especially around symlinks
|
||||
// to something the user can better understand
|
||||
if (error.code === 'EINVAL' || error.code === 'EBUSY' || error.code === 'ENAMETOOLONG') {
|
||||
error = new Error(localize('copyError', "Unable to copy '{0}' into '{1}' ({2}).", basename(fromFilePath), basename(dirname(toFilePath)), error.toString()));
|
||||
}
|
||||
|
||||
throw this.toFileSystemProviderError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async validateTargetDeleted(from: URI, to: URI, mode: 'move' | 'copy', overwrite?: boolean): Promise<void> {
|
||||
const isPathCaseSensitive = !!(this.capabilities & FileSystemProviderCapabilities.PathCaseSensitive);
|
||||
|
||||
const fromFilePath = this.toFilePath(from);
|
||||
const toFilePath = this.toFilePath(to);
|
||||
|
||||
let isSameResourceWithDifferentPathCase = false;
|
||||
if (!isPathCaseSensitive) {
|
||||
isSameResourceWithDifferentPathCase = isEqual(fromFilePath, toFilePath, true /* ignore case */);
|
||||
}
|
||||
|
||||
if (isSameResourceWithDifferentPathCase && mode === 'copy') {
|
||||
throw createFileSystemProviderError(new Error('File cannot be copied to same path with different path case'), FileSystemProviderErrorCode.FileExists);
|
||||
}
|
||||
|
||||
// handle existing target (unless this is a case change)
|
||||
if (!isSameResourceWithDifferentPathCase && await exists(toFilePath)) {
|
||||
if (!overwrite) {
|
||||
throw createFileSystemProviderError(new Error('File at target already exists'), FileSystemProviderErrorCode.FileExists);
|
||||
}
|
||||
|
||||
// Delete target
|
||||
await this.delete(to, { recursive: true, useTrash: false });
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region File Watching
|
||||
|
||||
private _onDidWatchErrorOccur: Emitter<string> = this._register(new Emitter<string>());
|
||||
readonly onDidErrorOccur: Event<string> = this._onDidWatchErrorOccur.event;
|
||||
|
||||
private _onDidChangeFile: Emitter<IFileChange[]> = this._register(new Emitter<IFileChange[]>());
|
||||
get onDidChangeFile(): Event<IFileChange[]> { return this._onDidChangeFile.event; }
|
||||
|
||||
private recursiveWatcher: WindowsWatcherService | UnixWatcherService | NsfwWatcherService | undefined;
|
||||
private recursiveFoldersToWatch: { path: string, excludes: string[] }[] = [];
|
||||
private recursiveWatchRequestDelayer: ThrottledDelayer<void> = this._register(new ThrottledDelayer<void>(0));
|
||||
|
||||
private recursiveWatcherLogLevelListener: IDisposable | undefined;
|
||||
|
||||
watch(resource: URI, opts: IWatchOptions): IDisposable {
|
||||
if (opts.recursive) {
|
||||
return this.watchRecursive(resource, opts.excludes);
|
||||
}
|
||||
|
||||
return this.watchNonRecursive(resource); // TODO@ben ideally the same watcher can be used in both cases
|
||||
}
|
||||
|
||||
private watchRecursive(resource: URI, excludes: string[]): IDisposable {
|
||||
|
||||
// Add to list of folders to watch recursively
|
||||
const folderToWatch = { path: this.toFilePath(resource), excludes };
|
||||
this.recursiveFoldersToWatch.push(folderToWatch);
|
||||
|
||||
// Trigger update
|
||||
this.refreshRecursiveWatchers();
|
||||
|
||||
return toDisposable(() => {
|
||||
|
||||
// Remove from list of folders to watch recursively
|
||||
this.recursiveFoldersToWatch.splice(this.recursiveFoldersToWatch.indexOf(folderToWatch), 1);
|
||||
|
||||
// Trigger update
|
||||
this.refreshRecursiveWatchers();
|
||||
});
|
||||
}
|
||||
|
||||
private refreshRecursiveWatchers(): void {
|
||||
|
||||
// Buffer requests for recursive watching to decide on right watcher
|
||||
// that supports potentially watching more than one folder at once
|
||||
this.recursiveWatchRequestDelayer.trigger(() => {
|
||||
this.doRefreshRecursiveWatchers();
|
||||
|
||||
return Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
private doRefreshRecursiveWatchers(): void {
|
||||
|
||||
// Reuse existing
|
||||
if (this.recursiveWatcher instanceof NsfwWatcherService) {
|
||||
this.recursiveWatcher.setFolders(this.recursiveFoldersToWatch);
|
||||
}
|
||||
|
||||
// Create new
|
||||
else {
|
||||
|
||||
// Dispose old
|
||||
dispose(this.recursiveWatcher);
|
||||
this.recursiveWatcher = undefined;
|
||||
|
||||
// Create new if we actually have folders to watch
|
||||
if (this.recursiveFoldersToWatch.length > 0) {
|
||||
let watcherImpl: {
|
||||
new(
|
||||
folders: { path: string, excludes: string[] }[],
|
||||
onChange: (changes: IDiskFileChange[]) => void,
|
||||
onLogMessage: (msg: ILogMessage) => void,
|
||||
verboseLogging: boolean,
|
||||
watcherOptions?: IWatcherOptions
|
||||
): WindowsWatcherService | UnixWatcherService | NsfwWatcherService
|
||||
};
|
||||
let watcherOptions = undefined;
|
||||
|
||||
if (this.watcherOptions && this.watcherOptions.usePolling) {
|
||||
// requires a polling watcher
|
||||
watcherImpl = UnixWatcherService;
|
||||
watcherOptions = this.watcherOptions;
|
||||
} else {
|
||||
// Single Folder Watcher
|
||||
if (this.recursiveFoldersToWatch.length === 1) {
|
||||
if (isWindows) {
|
||||
watcherImpl = WindowsWatcherService;
|
||||
} else {
|
||||
watcherImpl = UnixWatcherService;
|
||||
}
|
||||
}
|
||||
|
||||
// Multi Folder Watcher
|
||||
else {
|
||||
watcherImpl = NsfwWatcherService;
|
||||
}
|
||||
}
|
||||
|
||||
// Create and start watching
|
||||
this.recursiveWatcher = new watcherImpl(
|
||||
this.recursiveFoldersToWatch,
|
||||
event => this._onDidChangeFile.fire(toFileChanges(event)),
|
||||
msg => {
|
||||
if (msg.type === 'error') {
|
||||
this._onDidWatchErrorOccur.fire(msg.message);
|
||||
}
|
||||
this.logService[msg.type](msg.message);
|
||||
},
|
||||
this.logService.getLevel() === LogLevel.Trace,
|
||||
watcherOptions
|
||||
);
|
||||
|
||||
if (!this.recursiveWatcherLogLevelListener) {
|
||||
this.recursiveWatcherLogLevelListener = this.logService.onDidChangeLogLevel(_ => {
|
||||
if (this.recursiveWatcher) {
|
||||
this.recursiveWatcher.setVerboseLogging(this.logService.getLevel() === LogLevel.Trace);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private watchNonRecursive(resource: URI): IDisposable {
|
||||
const watcherService = new NodeJSWatcherService(
|
||||
this.toFilePath(resource),
|
||||
changes => this._onDidChangeFile.fire(toFileChanges(changes)),
|
||||
msg => {
|
||||
if (msg.type === 'error') {
|
||||
this._onDidWatchErrorOccur.fire(msg.message);
|
||||
}
|
||||
this.logService[msg.type](msg.message);
|
||||
},
|
||||
this.logService.getLevel() === LogLevel.Trace
|
||||
);
|
||||
const logLevelListener = this.logService.onDidChangeLogLevel(_ => {
|
||||
watcherService.setVerboseLogging(this.logService.getLevel() === LogLevel.Trace);
|
||||
});
|
||||
|
||||
return combinedDisposable(watcherService, logLevelListener);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Helpers
|
||||
|
||||
protected toFilePath(resource: URI): string {
|
||||
return normalize(resource.fsPath);
|
||||
}
|
||||
|
||||
private toFileSystemProviderError(error: NodeJS.ErrnoException): FileSystemProviderError {
|
||||
if (error instanceof FileSystemProviderError) {
|
||||
return error; // avoid double conversion
|
||||
}
|
||||
|
||||
let code: FileSystemProviderErrorCode;
|
||||
switch (error.code) {
|
||||
case 'ENOENT':
|
||||
code = FileSystemProviderErrorCode.FileNotFound;
|
||||
break;
|
||||
case 'EISDIR':
|
||||
code = FileSystemProviderErrorCode.FileIsADirectory;
|
||||
break;
|
||||
case 'EEXIST':
|
||||
code = FileSystemProviderErrorCode.FileExists;
|
||||
break;
|
||||
case 'EPERM':
|
||||
case 'EACCES':
|
||||
code = FileSystemProviderErrorCode.NoPermissions;
|
||||
break;
|
||||
default:
|
||||
code = FileSystemProviderErrorCode.Unknown;
|
||||
}
|
||||
|
||||
return createFileSystemProviderError(error, code);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
dispose(): void {
|
||||
super.dispose();
|
||||
|
||||
dispose(this.recursiveWatcher);
|
||||
this.recursiveWatcher = undefined;
|
||||
|
||||
dispose(this.recursiveWatcherLogLevelListener);
|
||||
this.recursiveWatcherLogLevelListener = undefined;
|
||||
}
|
||||
}
|
||||
128
src/vs/platform/files/node/watcher/nodejs/watcherService.ts
Normal file
128
src/vs/platform/files/node/watcher/nodejs/watcherService.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IDiskFileChange, normalizeFileChanges, ILogMessage } from 'vs/platform/files/node/watcher/watcher';
|
||||
import { Disposable } from 'vs/base/common/lifecycle';
|
||||
import { statLink } from 'vs/base/node/pfs';
|
||||
import { realpath } from 'vs/base/node/extpath';
|
||||
import { watchFolder, watchFile, CHANGE_BUFFER_DELAY } from 'vs/base/node/watcher';
|
||||
import { FileChangeType } from 'vs/platform/files/common/files';
|
||||
import { ThrottledDelayer } from 'vs/base/common/async';
|
||||
import { join, basename } from 'vs/base/common/path';
|
||||
|
||||
export class FileWatcher extends Disposable {
|
||||
private isDisposed: boolean;
|
||||
|
||||
private fileChangesDelayer: ThrottledDelayer<void> = this._register(new ThrottledDelayer<void>(CHANGE_BUFFER_DELAY * 2 /* sync on delay from underlying library */));
|
||||
private fileChangesBuffer: IDiskFileChange[] = [];
|
||||
|
||||
constructor(
|
||||
private path: string,
|
||||
private onFileChanges: (changes: IDiskFileChange[]) => void,
|
||||
private onLogMessage: (msg: ILogMessage) => void,
|
||||
private verboseLogging: boolean
|
||||
) {
|
||||
super();
|
||||
|
||||
this.startWatching();
|
||||
}
|
||||
|
||||
setVerboseLogging(verboseLogging: boolean): void {
|
||||
this.verboseLogging = verboseLogging;
|
||||
}
|
||||
|
||||
private async startWatching(): Promise<void> {
|
||||
try {
|
||||
const { stat, isSymbolicLink } = await statLink(this.path);
|
||||
|
||||
if (this.isDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let pathToWatch = this.path;
|
||||
if (isSymbolicLink) {
|
||||
try {
|
||||
pathToWatch = await realpath(pathToWatch);
|
||||
} catch (error) {
|
||||
this.onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Watch Folder
|
||||
if (stat.isDirectory()) {
|
||||
this._register(watchFolder(pathToWatch, (eventType, path) => {
|
||||
this.onFileChange({
|
||||
type: eventType === 'changed' ? FileChangeType.UPDATED : eventType === 'added' ? FileChangeType.ADDED : FileChangeType.DELETED,
|
||||
path: join(this.path, basename(path)) // ensure path is identical with what was passed in
|
||||
});
|
||||
}, error => this.onError(error)));
|
||||
}
|
||||
|
||||
// Watch File
|
||||
else {
|
||||
this._register(watchFile(pathToWatch, eventType => {
|
||||
this.onFileChange({
|
||||
type: eventType === 'changed' ? FileChangeType.UPDATED : FileChangeType.DELETED,
|
||||
path: this.path // ensure path is identical with what was passed in
|
||||
});
|
||||
}, error => this.onError(error)));
|
||||
}
|
||||
} catch (error) {
|
||||
this.onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private onFileChange(event: IDiskFileChange): void {
|
||||
|
||||
// Add to buffer
|
||||
this.fileChangesBuffer.push(event);
|
||||
|
||||
// Logging
|
||||
if (this.verboseLogging) {
|
||||
this.onVerbose(`${event.type === FileChangeType.ADDED ? '[ADDED]' : event.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${event.path}`);
|
||||
}
|
||||
|
||||
// Handle emit through delayer to accommodate for bulk changes and thus reduce spam
|
||||
this.fileChangesDelayer.trigger(() => {
|
||||
const fileChanges = this.fileChangesBuffer;
|
||||
this.fileChangesBuffer = [];
|
||||
|
||||
// Event normalization
|
||||
const normalizedFileChanges = normalizeFileChanges(fileChanges);
|
||||
|
||||
// Logging
|
||||
if (this.verboseLogging) {
|
||||
normalizedFileChanges.forEach(event => {
|
||||
this.onVerbose(`>> normalized ${event.type === FileChangeType.ADDED ? '[ADDED]' : event.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${event.path}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Fire
|
||||
if (normalizedFileChanges.length > 0) {
|
||||
this.onFileChanges(normalizedFileChanges);
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
private onError(error: string): void {
|
||||
if (!this.isDisposed) {
|
||||
this.onLogMessage({ type: 'error', message: `[File Watcher (node.js)] ${error}` });
|
||||
}
|
||||
}
|
||||
|
||||
private onVerbose(message: string): void {
|
||||
if (!this.isDisposed) {
|
||||
this.onLogMessage({ type: 'trace', message: `[File Watcher (node.js)] ${message}` });
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.isDisposed = true;
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
257
src/vs/platform/files/node/watcher/nsfw/nsfwWatcherService.ts
Normal file
257
src/vs/platform/files/node/watcher/nsfw/nsfwWatcherService.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as glob from 'vs/base/common/glob';
|
||||
import * as extpath from 'vs/base/common/extpath';
|
||||
import * as path from 'vs/base/common/path';
|
||||
import * as platform from 'vs/base/common/platform';
|
||||
import { IDiskFileChange, normalizeFileChanges, ILogMessage } from 'vs/platform/files/node/watcher/watcher';
|
||||
import * as nsfw from 'nsfw';
|
||||
import { IWatcherService, IWatcherRequest, IWatcherOptions } from 'vs/platform/files/node/watcher/nsfw/watcher';
|
||||
import { ThrottledDelayer } from 'vs/base/common/async';
|
||||
import { FileChangeType } from 'vs/platform/files/common/files';
|
||||
import { normalizeNFC } from 'vs/base/common/normalization';
|
||||
import { Event, Emitter } from 'vs/base/common/event';
|
||||
import { realcaseSync, realpathSync } from 'vs/base/node/extpath';
|
||||
|
||||
const nsfwActionToRawChangeType: { [key: number]: number } = [];
|
||||
nsfwActionToRawChangeType[nsfw.actions.CREATED] = FileChangeType.ADDED;
|
||||
nsfwActionToRawChangeType[nsfw.actions.MODIFIED] = FileChangeType.UPDATED;
|
||||
nsfwActionToRawChangeType[nsfw.actions.DELETED] = FileChangeType.DELETED;
|
||||
|
||||
interface IWatcherObjet {
|
||||
start(): any;
|
||||
stop(): any;
|
||||
}
|
||||
|
||||
interface IPathWatcher {
|
||||
ready: Promise<IWatcherObjet>;
|
||||
watcher?: IWatcherObjet;
|
||||
ignored: glob.ParsedPattern[];
|
||||
}
|
||||
|
||||
export class NsfwWatcherService implements IWatcherService {
|
||||
private static readonly FS_EVENT_DELAY = 50; // aggregate and only emit events when changes have stopped for this duration (in ms)
|
||||
|
||||
private _pathWatchers: { [watchPath: string]: IPathWatcher } = {};
|
||||
private _verboseLogging: boolean;
|
||||
private enospcErrorLogged: boolean;
|
||||
|
||||
private _onWatchEvent = new Emitter<IDiskFileChange[]>();
|
||||
readonly onWatchEvent = this._onWatchEvent.event;
|
||||
|
||||
private _onLogMessage = new Emitter<ILogMessage>();
|
||||
readonly onLogMessage: Event<ILogMessage> = this._onLogMessage.event;
|
||||
|
||||
watch(options: IWatcherOptions): Event<IDiskFileChange[]> {
|
||||
return this.onWatchEvent;
|
||||
}
|
||||
|
||||
private _watch(request: IWatcherRequest): void {
|
||||
let undeliveredFileEvents: IDiskFileChange[] = [];
|
||||
const fileEventDelayer = new ThrottledDelayer<void>(NsfwWatcherService.FS_EVENT_DELAY);
|
||||
|
||||
let readyPromiseResolve: (watcher: IWatcherObjet) => void;
|
||||
this._pathWatchers[request.path] = {
|
||||
ready: new Promise<IWatcherObjet>(resolve => readyPromiseResolve = resolve),
|
||||
ignored: Array.isArray(request.excludes) ? request.excludes.map(ignored => glob.parse(ignored)) : []
|
||||
};
|
||||
|
||||
process.on('uncaughtException', (e: Error | string) => {
|
||||
|
||||
// Specially handle ENOSPC errors that can happen when
|
||||
// the watcher consumes so many file descriptors that
|
||||
// we are running into a limit. We only want to warn
|
||||
// once in this case to avoid log spam.
|
||||
// See https://github.com/Microsoft/vscode/issues/7950
|
||||
if (e === 'Inotify limit reached' && !this.enospcErrorLogged) {
|
||||
this.enospcErrorLogged = true;
|
||||
this.error('Inotify limit reached (ENOSPC)');
|
||||
}
|
||||
});
|
||||
|
||||
// NSFW does not report file changes in the path provided on macOS if
|
||||
// - the path uses wrong casing
|
||||
// - the path is a symbolic link
|
||||
// We have to detect this case and massage the events to correct this.
|
||||
let realBasePathDiffers = false;
|
||||
let realBasePathLength = request.path.length;
|
||||
if (platform.isMacintosh) {
|
||||
try {
|
||||
|
||||
// First check for symbolic link
|
||||
let realBasePath = realpathSync(request.path);
|
||||
|
||||
// Second check for casing difference
|
||||
if (request.path === realBasePath) {
|
||||
realBasePath = (realcaseSync(request.path) || request.path);
|
||||
}
|
||||
|
||||
if (request.path !== realBasePath) {
|
||||
realBasePathLength = realBasePath.length;
|
||||
realBasePathDiffers = true;
|
||||
|
||||
this.warn(`Watcher basePath does not match version on disk and will be corrected (original: ${request.path}, real: ${realBasePath})`);
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
nsfw(request.path, events => {
|
||||
for (const e of events) {
|
||||
// Logging
|
||||
if (this._verboseLogging) {
|
||||
const logPath = e.action === nsfw.actions.RENAMED ? path.join(e.directory, e.oldFile || '') + ' -> ' + e.newFile : path.join(e.directory, e.file || '');
|
||||
this.log(`${e.action === nsfw.actions.CREATED ? '[CREATED]' : e.action === nsfw.actions.DELETED ? '[DELETED]' : e.action === nsfw.actions.MODIFIED ? '[CHANGED]' : '[RENAMED]'} ${logPath}`);
|
||||
}
|
||||
|
||||
// Convert nsfw event to IRawFileChange and add to queue
|
||||
let absolutePath: string;
|
||||
if (e.action === nsfw.actions.RENAMED) {
|
||||
// Rename fires when a file's name changes within a single directory
|
||||
absolutePath = path.join(e.directory, e.oldFile || '');
|
||||
if (!this._isPathIgnored(absolutePath, this._pathWatchers[request.path].ignored)) {
|
||||
undeliveredFileEvents.push({ type: FileChangeType.DELETED, path: absolutePath });
|
||||
} else if (this._verboseLogging) {
|
||||
this.log(` >> ignored ${absolutePath}`);
|
||||
}
|
||||
absolutePath = path.join(e.newDirectory || e.directory, e.newFile || '');
|
||||
if (!this._isPathIgnored(absolutePath, this._pathWatchers[request.path].ignored)) {
|
||||
undeliveredFileEvents.push({ type: FileChangeType.ADDED, path: absolutePath });
|
||||
} else if (this._verboseLogging) {
|
||||
this.log(` >> ignored ${absolutePath}`);
|
||||
}
|
||||
} else {
|
||||
absolutePath = path.join(e.directory, e.file || '');
|
||||
if (!this._isPathIgnored(absolutePath, this._pathWatchers[request.path].ignored)) {
|
||||
undeliveredFileEvents.push({
|
||||
type: nsfwActionToRawChangeType[e.action],
|
||||
path: absolutePath
|
||||
});
|
||||
} else if (this._verboseLogging) {
|
||||
this.log(` >> ignored ${absolutePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delay and send buffer
|
||||
fileEventDelayer.trigger(() => {
|
||||
const events = undeliveredFileEvents;
|
||||
undeliveredFileEvents = [];
|
||||
|
||||
if (platform.isMacintosh) {
|
||||
events.forEach(e => {
|
||||
|
||||
// Mac uses NFD unicode form on disk, but we want NFC
|
||||
e.path = normalizeNFC(e.path);
|
||||
|
||||
// Convert paths back to original form in case it differs
|
||||
if (realBasePathDiffers) {
|
||||
e.path = request.path + e.path.substr(realBasePathLength);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Broadcast to clients normalized
|
||||
const res = normalizeFileChanges(events);
|
||||
this._onWatchEvent.fire(res);
|
||||
|
||||
// Logging
|
||||
if (this._verboseLogging) {
|
||||
res.forEach(r => {
|
||||
this.log(` >> normalized ${r.type === FileChangeType.ADDED ? '[ADDED]' : r.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${r.path}`);
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
}).then(watcher => {
|
||||
this._pathWatchers[request.path].watcher = watcher;
|
||||
const startPromise = watcher.start();
|
||||
startPromise.then(() => readyPromiseResolve(watcher));
|
||||
return startPromise;
|
||||
});
|
||||
}
|
||||
|
||||
public setRoots(roots: IWatcherRequest[]): Promise<void> {
|
||||
const promises: Promise<void>[] = [];
|
||||
const normalizedRoots = this._normalizeRoots(roots);
|
||||
|
||||
// Gather roots that are not currently being watched
|
||||
const rootsToStartWatching = normalizedRoots.filter(r => {
|
||||
return !(r.path in this._pathWatchers);
|
||||
});
|
||||
|
||||
// Gather current roots that don't exist in the new roots array
|
||||
const rootsToStopWatching = Object.keys(this._pathWatchers).filter(r => {
|
||||
return normalizedRoots.every(normalizedRoot => normalizedRoot.path !== r);
|
||||
});
|
||||
|
||||
// Logging
|
||||
if (this._verboseLogging) {
|
||||
this.log(`Start watching: [${rootsToStartWatching.map(r => r.path).join(',')}]\nStop watching: [${rootsToStopWatching.join(',')}]`);
|
||||
}
|
||||
|
||||
// Stop watching some roots
|
||||
rootsToStopWatching.forEach(root => {
|
||||
this._pathWatchers[root].ready.then(watcher => watcher.stop());
|
||||
delete this._pathWatchers[root];
|
||||
});
|
||||
|
||||
// Start watching some roots
|
||||
rootsToStartWatching.forEach(root => this._watch(root));
|
||||
|
||||
// Refresh ignored arrays in case they changed
|
||||
roots.forEach(root => {
|
||||
if (root.path in this._pathWatchers) {
|
||||
this._pathWatchers[root.path].ignored = Array.isArray(root.excludes) ? root.excludes.map(ignored => glob.parse(ignored)) : [];
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.all(promises).then(() => undefined);
|
||||
}
|
||||
|
||||
public setVerboseLogging(enabled: boolean): Promise<void> {
|
||||
this._verboseLogging = enabled;
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
public stop(): Promise<void> {
|
||||
for (let path in this._pathWatchers) {
|
||||
let watcher = this._pathWatchers[path];
|
||||
watcher.ready.then(watcher => watcher.stop());
|
||||
delete this._pathWatchers[path];
|
||||
}
|
||||
this._pathWatchers = Object.create(null);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a set of root paths by removing any root paths that are
|
||||
* sub-paths of other roots.
|
||||
*/
|
||||
protected _normalizeRoots(roots: IWatcherRequest[]): IWatcherRequest[] {
|
||||
return roots.filter(r => roots.every(other => {
|
||||
return !(r.path.length > other.path.length && extpath.isEqualOrParent(r.path, other.path));
|
||||
}));
|
||||
}
|
||||
|
||||
private _isPathIgnored(absolutePath: string, ignored: glob.ParsedPattern[]): boolean {
|
||||
return ignored && ignored.some(i => i(absolutePath));
|
||||
}
|
||||
|
||||
private log(message: string) {
|
||||
this._onLogMessage.fire({ type: 'trace', message: `[File Watcher (nswf)] ` + message });
|
||||
}
|
||||
|
||||
private warn(message: string) {
|
||||
this._onLogMessage.fire({ type: 'warn', message: `[File Watcher (nswf)] ` + message });
|
||||
}
|
||||
|
||||
private error(message: string) {
|
||||
this._onLogMessage.fire({ type: 'error', message: `[File Watcher (nswf)] ` + message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
import * as platform from 'vs/base/common/platform';
|
||||
|
||||
import { NsfwWatcherService } from 'vs/platform/files/node/watcher/nsfw/nsfwWatcherService';
|
||||
import { IWatcherRequest } from 'vs/platform/files/node/watcher/nsfw/watcher';
|
||||
|
||||
class TestNsfwWatcherService extends NsfwWatcherService {
|
||||
public normalizeRoots(roots: string[]): string[] {
|
||||
// Work with strings as paths to simplify testing
|
||||
const requests: IWatcherRequest[] = roots.map(r => {
|
||||
return { path: r, excludes: [] };
|
||||
});
|
||||
return this._normalizeRoots(requests).map(r => r.path);
|
||||
}
|
||||
}
|
||||
|
||||
suite('NSFW Watcher Service', () => {
|
||||
suite('_normalizeRoots', () => {
|
||||
test('should not impacts roots that don\'t overlap', () => {
|
||||
const service = new TestNsfwWatcherService();
|
||||
if (platform.isWindows) {
|
||||
assert.deepEqual(service.normalizeRoots(['C:\\a']), ['C:\\a']);
|
||||
assert.deepEqual(service.normalizeRoots(['C:\\a', 'C:\\b']), ['C:\\a', 'C:\\b']);
|
||||
assert.deepEqual(service.normalizeRoots(['C:\\a', 'C:\\b', 'C:\\c\\d\\e']), ['C:\\a', 'C:\\b', 'C:\\c\\d\\e']);
|
||||
} else {
|
||||
assert.deepEqual(service.normalizeRoots(['/a']), ['/a']);
|
||||
assert.deepEqual(service.normalizeRoots(['/a', '/b']), ['/a', '/b']);
|
||||
assert.deepEqual(service.normalizeRoots(['/a', '/b', '/c/d/e']), ['/a', '/b', '/c/d/e']);
|
||||
}
|
||||
});
|
||||
|
||||
test('should remove sub-folders of other roots', () => {
|
||||
const service = new TestNsfwWatcherService();
|
||||
if (platform.isWindows) {
|
||||
assert.deepEqual(service.normalizeRoots(['C:\\a', 'C:\\a\\b']), ['C:\\a']);
|
||||
assert.deepEqual(service.normalizeRoots(['C:\\a', 'C:\\b', 'C:\\a\\b']), ['C:\\a', 'C:\\b']);
|
||||
assert.deepEqual(service.normalizeRoots(['C:\\b\\a', 'C:\\a', 'C:\\b', 'C:\\a\\b']), ['C:\\a', 'C:\\b']);
|
||||
assert.deepEqual(service.normalizeRoots(['C:\\a', 'C:\\a\\b', 'C:\\a\\c\\d']), ['C:\\a']);
|
||||
} else {
|
||||
assert.deepEqual(service.normalizeRoots(['/a', '/a/b']), ['/a']);
|
||||
assert.deepEqual(service.normalizeRoots(['/a', '/b', '/a/b']), ['/a', '/b']);
|
||||
assert.deepEqual(service.normalizeRoots(['/b/a', '/a', '/b', '/a/b']), ['/a', '/b']);
|
||||
assert.deepEqual(service.normalizeRoots(['/a', '/a/b', '/a/c/d']), ['/a']);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
23
src/vs/platform/files/node/watcher/nsfw/watcher.ts
Normal file
23
src/vs/platform/files/node/watcher/nsfw/watcher.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Event } from 'vs/base/common/event';
|
||||
import { IDiskFileChange, ILogMessage } from 'vs/platform/files/node/watcher/watcher';
|
||||
|
||||
export interface IWatcherRequest {
|
||||
path: string;
|
||||
excludes: string[];
|
||||
}
|
||||
|
||||
export interface IWatcherOptions {
|
||||
}
|
||||
|
||||
export interface IWatcherService {
|
||||
watch(options: IWatcherOptions): Event<IDiskFileChange[]>;
|
||||
setRoots(roots: IWatcherRequest[]): Promise<void>;
|
||||
setVerboseLogging(enabled: boolean): Promise<void>;
|
||||
onLogMessage: Event<ILogMessage>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
13
src/vs/platform/files/node/watcher/nsfw/watcherApp.ts
Normal file
13
src/vs/platform/files/node/watcher/nsfw/watcherApp.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Server } from 'vs/base/parts/ipc/node/ipc.cp';
|
||||
import { WatcherChannel } from 'vs/platform/files/node/watcher/nsfw/watcherIpc';
|
||||
import { NsfwWatcherService } from 'vs/platform/files/node/watcher/nsfw/nsfwWatcherService';
|
||||
|
||||
const server = new Server('watcher');
|
||||
const service = new NsfwWatcherService();
|
||||
const channel = new WatcherChannel(service);
|
||||
server.registerChannel('watcher', channel);
|
||||
58
src/vs/platform/files/node/watcher/nsfw/watcherIpc.ts
Normal file
58
src/vs/platform/files/node/watcher/nsfw/watcherIpc.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IChannel, IServerChannel } from 'vs/base/parts/ipc/common/ipc';
|
||||
import { IWatcherRequest, IWatcherService, IWatcherOptions } from './watcher';
|
||||
import { Event } from 'vs/base/common/event';
|
||||
import { IDiskFileChange, ILogMessage } from 'vs/platform/files/node/watcher/watcher';
|
||||
|
||||
export class WatcherChannel implements IServerChannel {
|
||||
|
||||
constructor(private service: IWatcherService) { }
|
||||
|
||||
listen(_: unknown, event: string, arg?: any): Event<any> {
|
||||
switch (event) {
|
||||
case 'watch': return this.service.watch(arg);
|
||||
case 'onLogMessage': return this.service.onLogMessage;
|
||||
}
|
||||
|
||||
throw new Error(`Event not found: ${event}`);
|
||||
}
|
||||
|
||||
call(_: unknown, command: string, arg?: any): Promise<any> {
|
||||
switch (command) {
|
||||
case 'setRoots': return this.service.setRoots(arg);
|
||||
case 'setVerboseLogging': return this.service.setVerboseLogging(arg);
|
||||
case 'stop': return this.service.stop();
|
||||
}
|
||||
|
||||
throw new Error(`Call not found: ${command}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class WatcherChannelClient implements IWatcherService {
|
||||
|
||||
constructor(private channel: IChannel) { }
|
||||
|
||||
watch(options: IWatcherOptions): Event<IDiskFileChange[]> {
|
||||
return this.channel.listen('watch', options);
|
||||
}
|
||||
|
||||
setVerboseLogging(enable: boolean): Promise<void> {
|
||||
return this.channel.call('setVerboseLogging', enable);
|
||||
}
|
||||
|
||||
setRoots(roots: IWatcherRequest[]): Promise<void> {
|
||||
return this.channel.call('setRoots', roots);
|
||||
}
|
||||
|
||||
get onLogMessage(): Event<ILogMessage> {
|
||||
return this.channel.listen('onLogMessage');
|
||||
}
|
||||
|
||||
stop(): Promise<void> {
|
||||
return this.channel.call('stop');
|
||||
}
|
||||
}
|
||||
100
src/vs/platform/files/node/watcher/nsfw/watcherService.ts
Normal file
100
src/vs/platform/files/node/watcher/nsfw/watcherService.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { getNextTickChannel } from 'vs/base/parts/ipc/common/ipc';
|
||||
import { Client } from 'vs/base/parts/ipc/node/ipc.cp';
|
||||
import { IDiskFileChange, ILogMessage } from 'vs/platform/files/node/watcher/watcher';
|
||||
import { WatcherChannelClient } from 'vs/platform/files/node/watcher/nsfw/watcherIpc';
|
||||
import { Disposable } from 'vs/base/common/lifecycle';
|
||||
import { IWatcherRequest } from 'vs/platform/files/node/watcher/nsfw/watcher';
|
||||
import { getPathFromAmdModule } from 'vs/base/common/amd';
|
||||
|
||||
export class FileWatcher extends Disposable {
|
||||
private static readonly MAX_RESTARTS = 5;
|
||||
|
||||
private service: WatcherChannelClient;
|
||||
private isDisposed: boolean;
|
||||
private restartCounter: number;
|
||||
|
||||
constructor(
|
||||
private folders: IWatcherRequest[],
|
||||
private onFileChanges: (changes: IDiskFileChange[]) => void,
|
||||
private onLogMessage: (msg: ILogMessage) => void,
|
||||
private verboseLogging: boolean,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.isDisposed = false;
|
||||
this.restartCounter = 0;
|
||||
|
||||
this.startWatching();
|
||||
}
|
||||
|
||||
private startWatching(): void {
|
||||
const client = this._register(new Client(
|
||||
getPathFromAmdModule(require, 'bootstrap-fork'),
|
||||
{
|
||||
serverName: 'File Watcher (nsfw)',
|
||||
args: ['--type=watcherService'],
|
||||
env: {
|
||||
AMD_ENTRYPOINT: 'vs/platform/files/node/watcher/nsfw/watcherApp',
|
||||
PIPE_LOGGING: 'true',
|
||||
VERBOSE_LOGGING: 'true' // transmit console logs from server to client
|
||||
}
|
||||
}
|
||||
));
|
||||
|
||||
this._register(client.onDidProcessExit(() => {
|
||||
// our watcher app should never be completed because it keeps on watching. being in here indicates
|
||||
// that the watcher process died and we want to restart it here. we only do it a max number of times
|
||||
if (!this.isDisposed) {
|
||||
if (this.restartCounter <= FileWatcher.MAX_RESTARTS) {
|
||||
this.error('terminated unexpectedly and is restarted again...');
|
||||
this.restartCounter++;
|
||||
this.startWatching();
|
||||
} else {
|
||||
this.error('failed to start after retrying for some time, giving up. Please report this as a bug report!');
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Initialize watcher
|
||||
const channel = getNextTickChannel(client.getChannel('watcher'));
|
||||
this.service = new WatcherChannelClient(channel);
|
||||
|
||||
this.service.setVerboseLogging(this.verboseLogging);
|
||||
|
||||
const options = {};
|
||||
this._register(this.service.watch(options)(e => !this.isDisposed && this.onFileChanges(e)));
|
||||
|
||||
this._register(this.service.onLogMessage(m => this.onLogMessage(m)));
|
||||
|
||||
// Start watching
|
||||
this.setFolders(this.folders);
|
||||
}
|
||||
|
||||
setVerboseLogging(verboseLogging: boolean): void {
|
||||
this.verboseLogging = verboseLogging;
|
||||
if (!this.isDisposed) {
|
||||
this.service.setVerboseLogging(verboseLogging);
|
||||
}
|
||||
}
|
||||
|
||||
error(message: string) {
|
||||
this.onLogMessage({ type: 'error', message: `[File Watcher (nsfw)] ${message}` });
|
||||
}
|
||||
|
||||
setFolders(folders: IWatcherRequest[]): void {
|
||||
this.folders = folders;
|
||||
|
||||
this.service.setRoots(folders);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.isDisposed = true;
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as chokidar from 'vscode-chokidar';
|
||||
import * as fs from 'fs';
|
||||
import * as gracefulFs from 'graceful-fs';
|
||||
gracefulFs.gracefulify(fs);
|
||||
import * as extpath from 'vs/base/common/extpath';
|
||||
import * as glob from 'vs/base/common/glob';
|
||||
import { FileChangeType } from 'vs/platform/files/common/files';
|
||||
import { ThrottledDelayer } from 'vs/base/common/async';
|
||||
import { normalizeNFC } from 'vs/base/common/normalization';
|
||||
import { realcaseSync } from 'vs/base/node/extpath';
|
||||
import { isMacintosh, isLinux } from 'vs/base/common/platform';
|
||||
import { IDiskFileChange, normalizeFileChanges, ILogMessage } from 'vs/platform/files/node/watcher/watcher';
|
||||
import { IWatcherRequest, IWatcherService, IWatcherOptions } from 'vs/platform/files/node/watcher/unix/watcher';
|
||||
import { Emitter, Event } from 'vs/base/common/event';
|
||||
|
||||
interface IWatcher {
|
||||
requests: ExtendedWatcherRequest[];
|
||||
stop(): any;
|
||||
}
|
||||
|
||||
interface ExtendedWatcherRequest extends IWatcherRequest {
|
||||
parsedPattern?: glob.ParsedPattern;
|
||||
}
|
||||
|
||||
export class ChokidarWatcherService implements IWatcherService {
|
||||
|
||||
private static readonly FS_EVENT_DELAY = 50; // aggregate and only emit events when changes have stopped for this duration (in ms)
|
||||
private static readonly EVENT_SPAM_WARNING_THRESHOLD = 60 * 1000; // warn after certain time span of event spam
|
||||
|
||||
private _watchers: { [watchPath: string]: IWatcher };
|
||||
private _watcherCount: number;
|
||||
|
||||
private _pollingInterval?: number;
|
||||
private _usePolling?: boolean;
|
||||
private _verboseLogging: boolean;
|
||||
|
||||
private spamCheckStartTime: number;
|
||||
private spamWarningLogged: boolean;
|
||||
private enospcErrorLogged: boolean;
|
||||
|
||||
private _onWatchEvent = new Emitter<IDiskFileChange[]>();
|
||||
readonly onWatchEvent = this._onWatchEvent.event;
|
||||
|
||||
private _onLogMessage = new Emitter<ILogMessage>();
|
||||
readonly onLogMessage: Event<ILogMessage> = this._onLogMessage.event;
|
||||
|
||||
public watch(options: IWatcherOptions): Event<IDiskFileChange[]> {
|
||||
this._pollingInterval = options.pollingInterval;
|
||||
this._usePolling = options.usePolling;
|
||||
this._watchers = Object.create(null);
|
||||
this._watcherCount = 0;
|
||||
return this.onWatchEvent;
|
||||
}
|
||||
|
||||
public setVerboseLogging(enabled: boolean): Promise<void> {
|
||||
this._verboseLogging = enabled;
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public setRoots(requests: IWatcherRequest[]): Promise<void> {
|
||||
const watchers = Object.create(null);
|
||||
const newRequests: string[] = [];
|
||||
|
||||
const requestsByBasePath = normalizeRoots(requests);
|
||||
|
||||
// evaluate new & remaining watchers
|
||||
for (let basePath in requestsByBasePath) {
|
||||
let watcher = this._watchers[basePath];
|
||||
if (watcher && isEqualRequests(watcher.requests, requestsByBasePath[basePath])) {
|
||||
watchers[basePath] = watcher;
|
||||
delete this._watchers[basePath];
|
||||
} else {
|
||||
newRequests.push(basePath);
|
||||
}
|
||||
}
|
||||
// stop all old watchers
|
||||
for (let path in this._watchers) {
|
||||
this._watchers[path].stop();
|
||||
}
|
||||
// start all new watchers
|
||||
for (let basePath of newRequests) {
|
||||
let requests = requestsByBasePath[basePath];
|
||||
watchers[basePath] = this._watch(basePath, requests);
|
||||
}
|
||||
|
||||
this._watchers = watchers;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// for test purposes
|
||||
public get wacherCount() {
|
||||
return this._watcherCount;
|
||||
}
|
||||
|
||||
private _watch(basePath: string, requests: IWatcherRequest[]): IWatcher {
|
||||
if (this._verboseLogging) {
|
||||
this.log(`Start watching: ${basePath}]`);
|
||||
}
|
||||
|
||||
const pollingInterval = this._pollingInterval || 5000;
|
||||
const usePolling = this._usePolling;
|
||||
if (usePolling && this._verboseLogging) {
|
||||
this.log(`Use polling instead of fs.watch: Polling interval ${pollingInterval} ms`);
|
||||
}
|
||||
|
||||
const watcherOpts: chokidar.WatchOptions = {
|
||||
ignoreInitial: true,
|
||||
ignorePermissionErrors: true,
|
||||
followSymlinks: true, // this is the default of chokidar and supports file events through symlinks
|
||||
interval: pollingInterval, // while not used in normal cases, if any error causes chokidar to fallback to polling, increase its intervals
|
||||
binaryInterval: pollingInterval,
|
||||
usePolling: usePolling,
|
||||
disableGlobbing: true // fix https://github.com/Microsoft/vscode/issues/4586
|
||||
};
|
||||
|
||||
const excludes: string[] = [];
|
||||
// if there's only one request, use the built-in ignore-filterering
|
||||
const isSingleFolder = requests.length === 1;
|
||||
if (isSingleFolder) {
|
||||
excludes.push(...requests[0].excludes);
|
||||
}
|
||||
|
||||
if ((isMacintosh || isLinux) && (basePath.length === 0 || basePath === '/')) {
|
||||
excludes.push('/dev/**');
|
||||
if (isLinux) {
|
||||
excludes.push('/proc/**', '/sys/**');
|
||||
}
|
||||
}
|
||||
watcherOpts.ignored = excludes;
|
||||
|
||||
// Chokidar fails when the basePath does not match case-identical to the path on disk
|
||||
// so we have to find the real casing of the path and do some path massaging to fix this
|
||||
// see https://github.com/paulmillr/chokidar/issues/418
|
||||
const realBasePath = isMacintosh ? (realcaseSync(basePath) || basePath) : basePath;
|
||||
const realBasePathLength = realBasePath.length;
|
||||
const realBasePathDiffers = (basePath !== realBasePath);
|
||||
|
||||
if (realBasePathDiffers) {
|
||||
this.warn(`Watcher basePath does not match version on disk and was corrected (original: ${basePath}, real: ${realBasePath})`);
|
||||
}
|
||||
|
||||
let chokidarWatcher: chokidar.FSWatcher | null = chokidar.watch(realBasePath, watcherOpts);
|
||||
this._watcherCount++;
|
||||
|
||||
// Detect if for some reason the native watcher library fails to load
|
||||
if (isMacintosh && chokidarWatcher.options && !chokidarWatcher.options.useFsEvents) {
|
||||
this.warn('Watcher is not using native fsevents library and is falling back to unefficient polling.');
|
||||
}
|
||||
|
||||
let undeliveredFileEvents: IDiskFileChange[] = [];
|
||||
let fileEventDelayer: ThrottledDelayer<undefined> | null = new ThrottledDelayer(ChokidarWatcherService.FS_EVENT_DELAY);
|
||||
|
||||
const watcher: IWatcher = {
|
||||
requests,
|
||||
stop: () => {
|
||||
try {
|
||||
if (this._verboseLogging) {
|
||||
this.log(`Stop watching: ${basePath}]`);
|
||||
}
|
||||
if (chokidarWatcher) {
|
||||
chokidarWatcher.close();
|
||||
this._watcherCount--;
|
||||
chokidarWatcher = null;
|
||||
}
|
||||
if (fileEventDelayer) {
|
||||
fileEventDelayer.cancel();
|
||||
fileEventDelayer = null;
|
||||
}
|
||||
} catch (error) {
|
||||
this.warn('Error while stopping watcher: ' + error.toString());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
chokidarWatcher.on('all', (type: string, path: string) => {
|
||||
if (isMacintosh) {
|
||||
// Mac: uses NFD unicode form on disk, but we want NFC
|
||||
// See also https://github.com/nodejs/node/issues/2165
|
||||
path = normalizeNFC(path);
|
||||
}
|
||||
|
||||
if (path.indexOf(realBasePath) < 0) {
|
||||
return; // we really only care about absolute paths here in our basepath context here
|
||||
}
|
||||
|
||||
// Make sure to convert the path back to its original basePath form if the realpath is different
|
||||
if (realBasePathDiffers) {
|
||||
path = basePath + path.substr(realBasePathLength);
|
||||
}
|
||||
|
||||
let eventType: FileChangeType;
|
||||
switch (type) {
|
||||
case 'change':
|
||||
eventType = FileChangeType.UPDATED;
|
||||
break;
|
||||
case 'add':
|
||||
case 'addDir':
|
||||
eventType = FileChangeType.ADDED;
|
||||
break;
|
||||
case 'unlink':
|
||||
case 'unlinkDir':
|
||||
eventType = FileChangeType.DELETED;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
// if there's more than one request we need to do
|
||||
// extra filtering due to potentially overlapping roots
|
||||
if (!isSingleFolder) {
|
||||
if (isIgnored(path, watcher.requests)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let event = { type: eventType, path };
|
||||
|
||||
// Logging
|
||||
if (this._verboseLogging) {
|
||||
this.log(`${eventType === FileChangeType.ADDED ? '[ADDED]' : eventType === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${path}`);
|
||||
}
|
||||
|
||||
// Check for spam
|
||||
const now = Date.now();
|
||||
if (undeliveredFileEvents.length === 0) {
|
||||
this.spamWarningLogged = false;
|
||||
this.spamCheckStartTime = now;
|
||||
} else if (!this.spamWarningLogged && this.spamCheckStartTime + ChokidarWatcherService.EVENT_SPAM_WARNING_THRESHOLD < now) {
|
||||
this.spamWarningLogged = true;
|
||||
this.warn(`Watcher is busy catching up with ${undeliveredFileEvents.length} file changes in 60 seconds. Latest changed path is "${event.path}"`);
|
||||
}
|
||||
|
||||
// Add to buffer
|
||||
undeliveredFileEvents.push(event);
|
||||
|
||||
if (fileEventDelayer) {
|
||||
// Delay and send buffer
|
||||
fileEventDelayer.trigger(() => {
|
||||
const events = undeliveredFileEvents;
|
||||
undeliveredFileEvents = [];
|
||||
|
||||
// Broadcast to clients normalized
|
||||
const res = normalizeFileChanges(events);
|
||||
this._onWatchEvent.fire(res);
|
||||
|
||||
// Logging
|
||||
if (this._verboseLogging) {
|
||||
res.forEach(r => {
|
||||
this.log(` >> normalized ${r.type === FileChangeType.ADDED ? '[ADDED]' : r.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${r.path}`);
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
chokidarWatcher.on('error', (error: NodeJS.ErrnoException) => {
|
||||
if (error) {
|
||||
|
||||
// Specially handle ENOSPC errors that can happen when
|
||||
// the watcher consumes so many file descriptors that
|
||||
// we are running into a limit. We only want to warn
|
||||
// once in this case to avoid log spam.
|
||||
// See https://github.com/Microsoft/vscode/issues/7950
|
||||
if (error.code === 'ENOSPC') {
|
||||
if (!this.enospcErrorLogged) {
|
||||
this.enospcErrorLogged = true;
|
||||
this.stop();
|
||||
this.error('Inotify limit reached (ENOSPC)');
|
||||
}
|
||||
} else {
|
||||
this.warn(error.toString());
|
||||
}
|
||||
}
|
||||
});
|
||||
return watcher;
|
||||
}
|
||||
|
||||
public stop(): Promise<void> {
|
||||
for (let path in this._watchers) {
|
||||
let watcher = this._watchers[path];
|
||||
watcher.stop();
|
||||
}
|
||||
this._watchers = Object.create(null);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
private log(message: string) {
|
||||
this._onLogMessage.fire({ type: 'trace', message: `[File Watcher (chokidar)] ` + message });
|
||||
}
|
||||
|
||||
private warn(message: string) {
|
||||
this._onLogMessage.fire({ type: 'warn', message: `[File Watcher (chokidar)] ` + message });
|
||||
}
|
||||
|
||||
private error(message: string) {
|
||||
this._onLogMessage.fire({ type: 'error', message: `[File Watcher (chokidar)] ` + message });
|
||||
}
|
||||
}
|
||||
|
||||
function isIgnored(path: string, requests: ExtendedWatcherRequest[]): boolean {
|
||||
for (let request of requests) {
|
||||
if (request.path === path) {
|
||||
return false;
|
||||
}
|
||||
if (extpath.isEqualOrParent(path, request.path)) {
|
||||
if (!request.parsedPattern) {
|
||||
if (request.excludes && request.excludes.length > 0) {
|
||||
let pattern = `{${request.excludes.join(',')}}`;
|
||||
request.parsedPattern = glob.parse(pattern);
|
||||
} else {
|
||||
request.parsedPattern = () => false;
|
||||
}
|
||||
}
|
||||
const relPath = path.substr(request.path.length + 1);
|
||||
if (!request.parsedPattern(relPath)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a set of root paths by grouping by the most parent root path.
|
||||
* equests with Sub paths are skipped if they have the same ignored set as the parent.
|
||||
*/
|
||||
export function normalizeRoots(requests: IWatcherRequest[]): { [basePath: string]: IWatcherRequest[] } {
|
||||
requests = requests.sort((r1, r2) => r1.path.localeCompare(r2.path));
|
||||
let prevRequest: IWatcherRequest | null = null;
|
||||
let result: { [basePath: string]: IWatcherRequest[] } = Object.create(null);
|
||||
for (let request of requests) {
|
||||
let basePath = request.path;
|
||||
let ignored = (request.excludes || []).sort();
|
||||
if (prevRequest && (extpath.isEqualOrParent(basePath, prevRequest.path))) {
|
||||
if (!isEqualIgnore(ignored, prevRequest.excludes)) {
|
||||
result[prevRequest.path].push({ path: basePath, excludes: ignored });
|
||||
}
|
||||
} else {
|
||||
prevRequest = { path: basePath, excludes: ignored };
|
||||
result[basePath] = [prevRequest];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isEqualRequests(r1: IWatcherRequest[], r2: IWatcherRequest[]) {
|
||||
if (r1.length !== r2.length) {
|
||||
return false;
|
||||
}
|
||||
for (let k = 0; k < r1.length; k++) {
|
||||
if (r1[k].path !== r2[k].path || !isEqualIgnore(r1[k].excludes, r2[k].excludes)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isEqualIgnore(i1: string[], i2: string[]) {
|
||||
if (i1.length !== i2.length) {
|
||||
return false;
|
||||
}
|
||||
for (let k = 0; k < i1.length; k++) {
|
||||
if (i1[k] !== i2[k]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
import * as os from 'os';
|
||||
import * as path from 'vs/base/common/path';
|
||||
import * as pfs from 'vs/base/node/pfs';
|
||||
import { normalizeRoots, ChokidarWatcherService } from '../chokidarWatcherService';
|
||||
import { IWatcherRequest } from '../watcher';
|
||||
import * as platform from 'vs/base/common/platform';
|
||||
import { Delayer } from 'vs/base/common/async';
|
||||
import { IDiskFileChange } from 'vs/platform/files/node/watcher/watcher';
|
||||
import { FileChangeType } from 'vs/platform/files/common/files';
|
||||
|
||||
function newRequest(basePath: string, ignored: string[] = []): IWatcherRequest {
|
||||
return { path: basePath, excludes: ignored };
|
||||
}
|
||||
|
||||
function assertNormalizedRootPath(inputPaths: string[], expectedPaths: string[]) {
|
||||
const requests = inputPaths.map(path => newRequest(path));
|
||||
const actual = normalizeRoots(requests);
|
||||
assert.deepEqual(Object.keys(actual).sort(), expectedPaths);
|
||||
}
|
||||
|
||||
function assertNormalizedRequests(inputRequests: IWatcherRequest[], expectedRequests: { [path: string]: IWatcherRequest[] }) {
|
||||
const actual = normalizeRoots(inputRequests);
|
||||
const actualPath = Object.keys(actual).sort();
|
||||
const expectedPaths = Object.keys(expectedRequests).sort();
|
||||
assert.deepEqual(actualPath, expectedPaths);
|
||||
for (let path of actualPath) {
|
||||
let a = expectedRequests[path].sort((r1, r2) => r1.path.localeCompare(r2.path));
|
||||
let e = expectedRequests[path].sort((r1, r2) => r1.path.localeCompare(r2.path));
|
||||
assert.deepEqual(a, e);
|
||||
}
|
||||
}
|
||||
|
||||
function sort(changes: IDiskFileChange[]) {
|
||||
return changes.sort((c1, c2) => {
|
||||
return c1.path.localeCompare(c2.path);
|
||||
});
|
||||
}
|
||||
|
||||
function wait(time: number) {
|
||||
return new Delayer<void>(time).trigger(() => { });
|
||||
}
|
||||
|
||||
async function assertFileEvents(actuals: IDiskFileChange[], expected: IDiskFileChange[]) {
|
||||
let repeats = 40;
|
||||
while ((actuals.length < expected.length) && repeats-- > 0) {
|
||||
await wait(50);
|
||||
}
|
||||
assert.deepEqual(sort(actuals), sort(expected));
|
||||
actuals.length = 0;
|
||||
}
|
||||
|
||||
suite('Chokidar normalizeRoots', () => {
|
||||
test('should not impacts roots that don\'t overlap', () => {
|
||||
if (platform.isWindows) {
|
||||
assertNormalizedRootPath(['C:\\a'], ['C:\\a']);
|
||||
assertNormalizedRootPath(['C:\\a', 'C:\\b'], ['C:\\a', 'C:\\b']);
|
||||
assertNormalizedRootPath(['C:\\a', 'C:\\b', 'C:\\c\\d\\e'], ['C:\\a', 'C:\\b', 'C:\\c\\d\\e']);
|
||||
} else {
|
||||
assertNormalizedRootPath(['/a'], ['/a']);
|
||||
assertNormalizedRootPath(['/a', '/b'], ['/a', '/b']);
|
||||
assertNormalizedRootPath(['/a', '/b', '/c/d/e'], ['/a', '/b', '/c/d/e']);
|
||||
}
|
||||
});
|
||||
|
||||
test('should remove sub-folders of other roots', () => {
|
||||
if (platform.isWindows) {
|
||||
assertNormalizedRootPath(['C:\\a', 'C:\\a\\b'], ['C:\\a']);
|
||||
assertNormalizedRootPath(['C:\\a', 'C:\\b', 'C:\\a\\b'], ['C:\\a', 'C:\\b']);
|
||||
assertNormalizedRootPath(['C:\\b\\a', 'C:\\a', 'C:\\b', 'C:\\a\\b'], ['C:\\a', 'C:\\b']);
|
||||
assertNormalizedRootPath(['C:\\a', 'C:\\a\\b', 'C:\\a\\c\\d'], ['C:\\a']);
|
||||
} else {
|
||||
assertNormalizedRootPath(['/a', '/a/b'], ['/a']);
|
||||
assertNormalizedRootPath(['/a', '/b', '/a/b'], ['/a', '/b']);
|
||||
assertNormalizedRootPath(['/b/a', '/a', '/b', '/a/b'], ['/a', '/b']);
|
||||
assertNormalizedRootPath(['/a', '/a/b', '/a/c/d'], ['/a']);
|
||||
assertNormalizedRootPath(['/a/c/d/e', '/a/b/d', '/a/c/d', '/a/c/e/f', '/a/b'], ['/a/b', '/a/c/d', '/a/c/e/f']);
|
||||
}
|
||||
});
|
||||
|
||||
test('should remove duplicates', () => {
|
||||
if (platform.isWindows) {
|
||||
assertNormalizedRootPath(['C:\\a', 'C:\\a\\', 'C:\\a'], ['C:\\a']);
|
||||
} else {
|
||||
assertNormalizedRootPath(['/a', '/a/', '/a'], ['/a']);
|
||||
assertNormalizedRootPath(['/a', '/b', '/a/b'], ['/a', '/b']);
|
||||
assertNormalizedRootPath(['/b/a', '/a', '/b', '/a/b'], ['/a', '/b']);
|
||||
assertNormalizedRootPath(['/a', '/a/b', '/a/c/d'], ['/a']);
|
||||
}
|
||||
});
|
||||
|
||||
test('nested requests', () => {
|
||||
let p1, p2, p3;
|
||||
if (platform.isWindows) {
|
||||
p1 = 'C:\\a';
|
||||
p2 = 'C:\\a\\b';
|
||||
p3 = 'C:\\a\\b\\c';
|
||||
} else {
|
||||
p1 = '/a';
|
||||
p2 = '/a/b';
|
||||
p3 = '/a/b/c';
|
||||
}
|
||||
const r1 = newRequest(p1, ['**/*.ts']);
|
||||
const r2 = newRequest(p2, ['**/*.js']);
|
||||
const r3 = newRequest(p3, ['**/*.ts']);
|
||||
assertNormalizedRequests([r1, r2], { [p1]: [r1, r2] });
|
||||
assertNormalizedRequests([r2, r1], { [p1]: [r1, r2] });
|
||||
assertNormalizedRequests([r1, r2, r3], { [p1]: [r1, r2, r3] });
|
||||
assertNormalizedRequests([r1, r3], { [p1]: [r1] });
|
||||
assertNormalizedRequests([r2, r3], { [p2]: [r2, r3] });
|
||||
});
|
||||
});
|
||||
|
||||
suite.skip('Chokidar watching', () => {
|
||||
const tmpdir = os.tmpdir();
|
||||
const testDir = path.join(tmpdir, 'chokidartest-' + Date.now());
|
||||
const aFolder = path.join(testDir, 'a');
|
||||
const bFolder = path.join(testDir, 'b');
|
||||
const b2Folder = path.join(bFolder, 'b2');
|
||||
|
||||
const service = new ChokidarWatcherService();
|
||||
const result: IDiskFileChange[] = [];
|
||||
let error: string | null = null;
|
||||
|
||||
suiteSetup(async () => {
|
||||
await pfs.mkdirp(testDir);
|
||||
await pfs.mkdirp(aFolder);
|
||||
await pfs.mkdirp(bFolder);
|
||||
await pfs.mkdirp(b2Folder);
|
||||
|
||||
const opts = { verboseLogging: false, pollingInterval: 200 };
|
||||
service.watch(opts)(e => {
|
||||
if (Array.isArray(e)) {
|
||||
result.push(...e);
|
||||
}
|
||||
});
|
||||
service.onLogMessage(msg => {
|
||||
if (msg.type === 'error') {
|
||||
console.log('set error', msg.message);
|
||||
error = msg.message;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
suiteTeardown(async () => {
|
||||
await pfs.rimraf(testDir, pfs.RimRafMode.MOVE);
|
||||
await service.stop();
|
||||
});
|
||||
|
||||
setup(() => {
|
||||
result.length = 0;
|
||||
assert.equal(error, null);
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
assert.equal(error, null);
|
||||
});
|
||||
|
||||
test('simple file operations, single root, no ignore', async () => {
|
||||
let request: IWatcherRequest = { path: testDir, excludes: [] };
|
||||
service.setRoots([request]);
|
||||
await wait(300);
|
||||
|
||||
assert.equal(service.wacherCount, 1);
|
||||
|
||||
// create a file
|
||||
let testFilePath = path.join(testDir, 'file.txt');
|
||||
await pfs.writeFile(testFilePath, '');
|
||||
await assertFileEvents(result, [{ path: testFilePath, type: FileChangeType.ADDED }]);
|
||||
|
||||
// modify a file
|
||||
await pfs.writeFile(testFilePath, 'Hello');
|
||||
await assertFileEvents(result, [{ path: testFilePath, type: FileChangeType.UPDATED }]);
|
||||
|
||||
// create a folder
|
||||
let testFolderPath = path.join(testDir, 'newFolder');
|
||||
await pfs.mkdirp(testFolderPath);
|
||||
// copy a file
|
||||
let copiedFilePath = path.join(testFolderPath, 'file2.txt');
|
||||
await pfs.copy(testFilePath, copiedFilePath);
|
||||
await assertFileEvents(result, [{ path: copiedFilePath, type: FileChangeType.ADDED }, { path: testFolderPath, type: FileChangeType.ADDED }]);
|
||||
|
||||
// delete a file
|
||||
await pfs.rimraf(copiedFilePath, pfs.RimRafMode.MOVE);
|
||||
let renamedFilePath = path.join(testFolderPath, 'file3.txt');
|
||||
// move a file
|
||||
await pfs.rename(testFilePath, renamedFilePath);
|
||||
await assertFileEvents(result, [{ path: copiedFilePath, type: FileChangeType.DELETED }, { path: testFilePath, type: FileChangeType.DELETED }, { path: renamedFilePath, type: FileChangeType.ADDED }]);
|
||||
|
||||
// delete a folder
|
||||
await pfs.rimraf(testFolderPath, pfs.RimRafMode.MOVE);
|
||||
await assertFileEvents(result, [{ path: testFolderPath, type: FileChangeType.DELETED }, { path: renamedFilePath, type: FileChangeType.DELETED }]);
|
||||
});
|
||||
|
||||
test('simple file operations, ignore', async () => {
|
||||
let request: IWatcherRequest = { path: testDir, excludes: ['**/b/**', '**/*.js', '.git/**'] };
|
||||
service.setRoots([request]);
|
||||
await wait(300);
|
||||
|
||||
assert.equal(service.wacherCount, 1);
|
||||
|
||||
// create various ignored files
|
||||
let file1 = path.join(bFolder, 'file1.txt'); // hidden
|
||||
await pfs.writeFile(file1, 'Hello');
|
||||
let file2 = path.join(b2Folder, 'file2.txt'); // hidden
|
||||
await pfs.writeFile(file2, 'Hello');
|
||||
let folder1 = path.join(bFolder, 'folder1'); // hidden
|
||||
await pfs.mkdirp(folder1);
|
||||
let folder2 = path.join(aFolder, 'b'); // hidden
|
||||
await pfs.mkdirp(folder2);
|
||||
let folder3 = path.join(testDir, '.git'); // hidden
|
||||
await pfs.mkdirp(folder3);
|
||||
let folder4 = path.join(testDir, '.git1');
|
||||
await pfs.mkdirp(folder4);
|
||||
let folder5 = path.join(aFolder, '.git');
|
||||
await pfs.mkdirp(folder5);
|
||||
let file3 = path.join(aFolder, 'file3.js'); // hidden
|
||||
await pfs.writeFile(file3, 'var x;');
|
||||
let file4 = path.join(aFolder, 'file4.txt');
|
||||
await pfs.writeFile(file4, 'Hello');
|
||||
await assertFileEvents(result, [{ path: file4, type: FileChangeType.ADDED }, { path: folder4, type: FileChangeType.ADDED }, { path: folder5, type: FileChangeType.ADDED }]);
|
||||
|
||||
// move some files
|
||||
let movedFile1 = path.join(folder2, 'file1.txt'); // from ignored to ignored
|
||||
await pfs.rename(file1, movedFile1);
|
||||
let movedFile2 = path.join(aFolder, 'file2.txt'); // from ignored to visible
|
||||
await pfs.rename(file2, movedFile2);
|
||||
let movedFile3 = path.join(aFolder, 'file3.txt'); // from ignored file ext to visible
|
||||
await pfs.rename(file3, movedFile3);
|
||||
await assertFileEvents(result, [{ path: movedFile2, type: FileChangeType.ADDED }, { path: movedFile3, type: FileChangeType.ADDED }]);
|
||||
|
||||
// delete all files
|
||||
await pfs.rimraf(movedFile1); // hidden
|
||||
await pfs.rimraf(movedFile2, pfs.RimRafMode.MOVE);
|
||||
await pfs.rimraf(movedFile3, pfs.RimRafMode.MOVE);
|
||||
await pfs.rimraf(folder1); // hidden
|
||||
await pfs.rimraf(folder2); // hidden
|
||||
await pfs.rimraf(folder3); // hidden
|
||||
await pfs.rimraf(folder4, pfs.RimRafMode.MOVE);
|
||||
await pfs.rimraf(folder5, pfs.RimRafMode.MOVE);
|
||||
await pfs.rimraf(file4, pfs.RimRafMode.MOVE);
|
||||
await assertFileEvents(result, [{ path: movedFile2, type: FileChangeType.DELETED }, { path: movedFile3, type: FileChangeType.DELETED }, { path: file4, type: FileChangeType.DELETED }, { path: folder4, type: FileChangeType.DELETED }, { path: folder5, type: FileChangeType.DELETED }]);
|
||||
});
|
||||
|
||||
test('simple file operations, multiple roots', async () => {
|
||||
let request1: IWatcherRequest = { path: aFolder, excludes: ['**/*.js'] };
|
||||
let request2: IWatcherRequest = { path: b2Folder, excludes: ['**/*.ts'] };
|
||||
service.setRoots([request1, request2]);
|
||||
await wait(300);
|
||||
|
||||
assert.equal(service.wacherCount, 2);
|
||||
|
||||
// create some files
|
||||
let folderPath1 = path.join(aFolder, 'folder1');
|
||||
await pfs.mkdirp(folderPath1);
|
||||
let filePath1 = path.join(folderPath1, 'file1.json');
|
||||
await pfs.writeFile(filePath1, '');
|
||||
let filePath2 = path.join(folderPath1, 'file2.js'); // filtered
|
||||
await pfs.writeFile(filePath2, '');
|
||||
let folderPath2 = path.join(b2Folder, 'folder2');
|
||||
await pfs.mkdirp(folderPath2);
|
||||
let filePath3 = path.join(folderPath2, 'file3.ts'); // filtered
|
||||
await pfs.writeFile(filePath3, '');
|
||||
let filePath4 = path.join(testDir, 'file4.json'); // outside roots
|
||||
await pfs.writeFile(filePath4, '');
|
||||
|
||||
await assertFileEvents(result, [{ path: folderPath1, type: FileChangeType.ADDED }, { path: filePath1, type: FileChangeType.ADDED }, { path: folderPath2, type: FileChangeType.ADDED }]);
|
||||
|
||||
// change roots
|
||||
let request3: IWatcherRequest = { path: aFolder, excludes: ['**/*.json'] };
|
||||
service.setRoots([request3]);
|
||||
await wait(300);
|
||||
|
||||
assert.equal(service.wacherCount, 1);
|
||||
|
||||
// delete all
|
||||
await pfs.rimraf(folderPath1, pfs.RimRafMode.MOVE);
|
||||
await pfs.rimraf(folderPath2, pfs.RimRafMode.MOVE);
|
||||
await pfs.rimraf(filePath4, pfs.RimRafMode.MOVE);
|
||||
|
||||
await assertFileEvents(result, [{ path: folderPath1, type: FileChangeType.DELETED }, { path: filePath2, type: FileChangeType.DELETED }]);
|
||||
});
|
||||
|
||||
test('simple file operations, nested roots', async () => {
|
||||
let request1: IWatcherRequest = { path: testDir, excludes: ['**/b2/**'] };
|
||||
let request2: IWatcherRequest = { path: bFolder, excludes: ['**/b3/**'] };
|
||||
service.setRoots([request1, request2]);
|
||||
await wait(300);
|
||||
|
||||
assert.equal(service.wacherCount, 1);
|
||||
|
||||
// create files
|
||||
let filePath1 = path.join(bFolder, 'file1.xml'); // visible by both
|
||||
await pfs.writeFile(filePath1, '');
|
||||
let filePath2 = path.join(b2Folder, 'file2.xml'); // filtered by root1, but visible by root2
|
||||
await pfs.writeFile(filePath2, '');
|
||||
let folderPath1 = path.join(b2Folder, 'b3'); // filtered
|
||||
await pfs.mkdirp(folderPath1);
|
||||
let filePath3 = path.join(folderPath1, 'file3.xml'); // filtered
|
||||
await pfs.writeFile(filePath3, '');
|
||||
|
||||
await assertFileEvents(result, [{ path: filePath1, type: FileChangeType.ADDED }, { path: filePath2, type: FileChangeType.ADDED }]);
|
||||
|
||||
let renamedFilePath2 = path.join(folderPath1, 'file2.xml');
|
||||
// move a file
|
||||
await pfs.rename(filePath2, renamedFilePath2);
|
||||
await assertFileEvents(result, [{ path: filePath2, type: FileChangeType.DELETED }]);
|
||||
|
||||
// delete all
|
||||
await pfs.rimraf(folderPath1, pfs.RimRafMode.MOVE);
|
||||
await pfs.rimraf(filePath1, pfs.RimRafMode.MOVE);
|
||||
|
||||
await assertFileEvents(result, [{ path: filePath1, type: FileChangeType.DELETED }]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
25
src/vs/platform/files/node/watcher/unix/watcher.ts
Normal file
25
src/vs/platform/files/node/watcher/unix/watcher.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Event } from 'vs/base/common/event';
|
||||
import { IDiskFileChange, ILogMessage } from 'vs/platform/files/node/watcher/watcher';
|
||||
|
||||
export interface IWatcherRequest {
|
||||
path: string;
|
||||
excludes: string[];
|
||||
}
|
||||
|
||||
export interface IWatcherOptions {
|
||||
pollingInterval?: number;
|
||||
usePolling?: boolean;
|
||||
}
|
||||
|
||||
export interface IWatcherService {
|
||||
watch(options: IWatcherOptions): Event<IDiskFileChange[]>;
|
||||
setRoots(roots: IWatcherRequest[]): Promise<void>;
|
||||
setVerboseLogging(enabled: boolean): Promise<void>;
|
||||
onLogMessage: Event<ILogMessage>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
13
src/vs/platform/files/node/watcher/unix/watcherApp.ts
Normal file
13
src/vs/platform/files/node/watcher/unix/watcherApp.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Server } from 'vs/base/parts/ipc/node/ipc.cp';
|
||||
import { WatcherChannel } from 'vs/platform/files/node/watcher/unix/watcherIpc';
|
||||
import { ChokidarWatcherService } from 'vs/platform/files/node/watcher/unix/chokidarWatcherService';
|
||||
|
||||
const server = new Server('watcher');
|
||||
const service = new ChokidarWatcherService();
|
||||
const channel = new WatcherChannel(service);
|
||||
server.registerChannel('watcher', channel);
|
||||
58
src/vs/platform/files/node/watcher/unix/watcherIpc.ts
Normal file
58
src/vs/platform/files/node/watcher/unix/watcherIpc.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IChannel, IServerChannel } from 'vs/base/parts/ipc/common/ipc';
|
||||
import { IWatcherRequest, IWatcherService, IWatcherOptions } from './watcher';
|
||||
import { Event } from 'vs/base/common/event';
|
||||
import { IDiskFileChange, ILogMessage } from 'vs/platform/files/node/watcher/watcher';
|
||||
|
||||
export class WatcherChannel implements IServerChannel {
|
||||
|
||||
constructor(private service: IWatcherService) { }
|
||||
|
||||
listen(_: unknown, event: string, arg?: any): Event<any> {
|
||||
switch (event) {
|
||||
case 'watch': return this.service.watch(arg);
|
||||
case 'onLogMessage': return this.service.onLogMessage;
|
||||
}
|
||||
|
||||
throw new Error(`Event not found: ${event}`);
|
||||
}
|
||||
|
||||
call(_: unknown, command: string, arg?: any): Promise<any> {
|
||||
switch (command) {
|
||||
case 'setRoots': return this.service.setRoots(arg);
|
||||
case 'setVerboseLogging': return this.service.setVerboseLogging(arg);
|
||||
case 'stop': return this.service.stop();
|
||||
}
|
||||
|
||||
throw new Error(`Call not found: ${command}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class WatcherChannelClient implements IWatcherService {
|
||||
|
||||
constructor(private channel: IChannel) { }
|
||||
|
||||
watch(options: IWatcherOptions): Event<IDiskFileChange[]> {
|
||||
return this.channel.listen('watch', options);
|
||||
}
|
||||
|
||||
setVerboseLogging(enable: boolean): Promise<void> {
|
||||
return this.channel.call('setVerboseLogging', enable);
|
||||
}
|
||||
|
||||
get onLogMessage(): Event<ILogMessage> {
|
||||
return this.channel.listen('onLogMessage');
|
||||
}
|
||||
|
||||
setRoots(roots: IWatcherRequest[]): Promise<void> {
|
||||
return this.channel.call('setRoots', roots);
|
||||
}
|
||||
|
||||
stop(): Promise<void> {
|
||||
return this.channel.call('stop');
|
||||
}
|
||||
}
|
||||
98
src/vs/platform/files/node/watcher/unix/watcherService.ts
Normal file
98
src/vs/platform/files/node/watcher/unix/watcherService.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { getNextTickChannel } from 'vs/base/parts/ipc/common/ipc';
|
||||
import { Client } from 'vs/base/parts/ipc/node/ipc.cp';
|
||||
import { IDiskFileChange, ILogMessage } from 'vs/platform/files/node/watcher/watcher';
|
||||
import { WatcherChannelClient } from 'vs/platform/files/node/watcher/unix/watcherIpc';
|
||||
import { Disposable } from 'vs/base/common/lifecycle';
|
||||
import { IWatcherRequest, IWatcherOptions } from 'vs/platform/files/node/watcher/unix/watcher';
|
||||
import { getPathFromAmdModule } from 'vs/base/common/amd';
|
||||
|
||||
export class FileWatcher extends Disposable {
|
||||
private static readonly MAX_RESTARTS = 5;
|
||||
|
||||
private isDisposed: boolean;
|
||||
private restartCounter: number;
|
||||
private service: WatcherChannelClient;
|
||||
|
||||
constructor(
|
||||
private folders: IWatcherRequest[],
|
||||
private onFileChanges: (changes: IDiskFileChange[]) => void,
|
||||
private onLogMessage: (msg: ILogMessage) => void,
|
||||
private verboseLogging: boolean,
|
||||
private watcherOptions: IWatcherOptions = {}
|
||||
) {
|
||||
super();
|
||||
|
||||
this.isDisposed = false;
|
||||
this.restartCounter = 0;
|
||||
|
||||
this.startWatching();
|
||||
}
|
||||
|
||||
private startWatching(): void {
|
||||
const client = this._register(new Client(
|
||||
getPathFromAmdModule(require, 'bootstrap-fork'),
|
||||
{
|
||||
serverName: 'File Watcher (chokidar)',
|
||||
args: ['--type=watcherService'],
|
||||
env: {
|
||||
AMD_ENTRYPOINT: 'vs/platform/files/node/watcher/unix/watcherApp',
|
||||
PIPE_LOGGING: 'true',
|
||||
VERBOSE_LOGGING: 'true' // transmit console logs from server to client
|
||||
}
|
||||
}
|
||||
));
|
||||
|
||||
this._register(client.onDidProcessExit(() => {
|
||||
// our watcher app should never be completed because it keeps on watching. being in here indicates
|
||||
// that the watcher process died and we want to restart it here. we only do it a max number of times
|
||||
if (!this.isDisposed) {
|
||||
if (this.restartCounter <= FileWatcher.MAX_RESTARTS) {
|
||||
this.error('terminated unexpectedly and is restarted again...');
|
||||
this.restartCounter++;
|
||||
this.startWatching();
|
||||
} else {
|
||||
this.error('failed to start after retrying for some time, giving up. Please report this as a bug report!');
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Initialize watcher
|
||||
const channel = getNextTickChannel(client.getChannel('watcher'));
|
||||
this.service = new WatcherChannelClient(channel);
|
||||
|
||||
this.service.setVerboseLogging(this.verboseLogging);
|
||||
|
||||
this._register(this.service.watch(this.watcherOptions)(e => !this.isDisposed && this.onFileChanges(e)));
|
||||
|
||||
this._register(this.service.onLogMessage(m => this.onLogMessage(m)));
|
||||
|
||||
// Start watching
|
||||
this.service.setRoots(this.folders);
|
||||
}
|
||||
|
||||
error(message: string) {
|
||||
this.onLogMessage({ type: 'error', message: `[File Watcher (chokidar)] ${message}` });
|
||||
}
|
||||
|
||||
setVerboseLogging(verboseLogging: boolean): void {
|
||||
this.verboseLogging = verboseLogging;
|
||||
this.service.setVerboseLogging(verboseLogging);
|
||||
}
|
||||
|
||||
setFolders(folders: IWatcherRequest[]): void {
|
||||
this.folders = folders;
|
||||
|
||||
this.service.setRoots(folders);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.isDisposed = true;
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
109
src/vs/platform/files/node/watcher/watcher.ts
Normal file
109
src/vs/platform/files/node/watcher/watcher.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { URI as uri } from 'vs/base/common/uri';
|
||||
import { FileChangeType, isParent, IFileChange } from 'vs/platform/files/common/files';
|
||||
import { isLinux } from 'vs/base/common/platform';
|
||||
|
||||
export interface IDiskFileChange {
|
||||
type: FileChangeType;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ILogMessage {
|
||||
type: 'trace' | 'warn' | 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function toFileChanges(changes: IDiskFileChange[]): IFileChange[] {
|
||||
return changes.map(change => ({
|
||||
type: change.type,
|
||||
resource: uri.file(change.path)
|
||||
}));
|
||||
}
|
||||
|
||||
export function normalizeFileChanges(changes: IDiskFileChange[]): IDiskFileChange[] {
|
||||
|
||||
// Build deltas
|
||||
const normalizer = new EventNormalizer();
|
||||
for (const event of changes) {
|
||||
normalizer.processEvent(event);
|
||||
}
|
||||
|
||||
return normalizer.normalize();
|
||||
}
|
||||
|
||||
class EventNormalizer {
|
||||
private normalized: IDiskFileChange[] = [];
|
||||
private mapPathToChange: Map<string, IDiskFileChange> = new Map();
|
||||
|
||||
processEvent(event: IDiskFileChange): void {
|
||||
const existingEvent = this.mapPathToChange.get(event.path);
|
||||
|
||||
// Event path already exists
|
||||
if (existingEvent) {
|
||||
const currentChangeType = existingEvent.type;
|
||||
const newChangeType = event.type;
|
||||
|
||||
// ignore CREATE followed by DELETE in one go
|
||||
if (currentChangeType === FileChangeType.ADDED && newChangeType === FileChangeType.DELETED) {
|
||||
this.mapPathToChange.delete(event.path);
|
||||
this.normalized.splice(this.normalized.indexOf(existingEvent), 1);
|
||||
}
|
||||
|
||||
// flatten DELETE followed by CREATE into CHANGE
|
||||
else if (currentChangeType === FileChangeType.DELETED && newChangeType === FileChangeType.ADDED) {
|
||||
existingEvent.type = FileChangeType.UPDATED;
|
||||
}
|
||||
|
||||
// Do nothing. Keep the created event
|
||||
else if (currentChangeType === FileChangeType.ADDED && newChangeType === FileChangeType.UPDATED) { }
|
||||
|
||||
// Otherwise apply change type
|
||||
else {
|
||||
existingEvent.type = newChangeType;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise store new
|
||||
else {
|
||||
this.normalized.push(event);
|
||||
this.mapPathToChange.set(event.path, event);
|
||||
}
|
||||
}
|
||||
|
||||
normalize(): IDiskFileChange[] {
|
||||
const addedChangeEvents: IDiskFileChange[] = [];
|
||||
const deletedPaths: string[] = [];
|
||||
|
||||
// This algorithm will remove all DELETE events up to the root folder
|
||||
// that got deleted if any. This ensures that we are not producing
|
||||
// DELETE events for each file inside a folder that gets deleted.
|
||||
//
|
||||
// 1.) split ADD/CHANGE and DELETED events
|
||||
// 2.) sort short deleted paths to the top
|
||||
// 3.) for each DELETE, check if there is a deleted parent and ignore the event in that case
|
||||
return this.normalized.filter(e => {
|
||||
if (e.type !== FileChangeType.DELETED) {
|
||||
addedChangeEvents.push(e);
|
||||
|
||||
return false; // remove ADD / CHANGE
|
||||
}
|
||||
|
||||
return true; // keep DELETE
|
||||
}).sort((e1, e2) => {
|
||||
return e1.path.length - e2.path.length; // shortest path first
|
||||
}).filter(e => {
|
||||
if (deletedPaths.some(d => isParent(e.path, d, !isLinux /* ignorecase */))) {
|
||||
return false; // DELETE is ignored if parent is deleted already
|
||||
}
|
||||
|
||||
// otherwise mark as deleted
|
||||
deletedPaths.push(e.path);
|
||||
|
||||
return true;
|
||||
}).concat(addedChangeEvents);
|
||||
}
|
||||
}
|
||||
BIN
src/vs/platform/files/node/watcher/win32/CodeHelper.exe
Normal file
BIN
src/vs/platform/files/node/watcher/win32/CodeHelper.exe
Normal file
Binary file not shown.
8
src/vs/platform/files/node/watcher/win32/CodeHelper.md
Normal file
8
src/vs/platform/files/node/watcher/win32/CodeHelper.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Native File Watching for Windows using C# FileSystemWatcher
|
||||
|
||||
- Repository: https://github.com/Microsoft/vscode-filewatcher-windows
|
||||
|
||||
# Build
|
||||
|
||||
- Build in "Release" config
|
||||
- Copy CodeHelper.exe over into this folder
|
||||
140
src/vs/platform/files/node/watcher/win32/csharpWatcherService.ts
Normal file
140
src/vs/platform/files/node/watcher/win32/csharpWatcherService.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as cp from 'child_process';
|
||||
import { FileChangeType } from 'vs/platform/files/common/files';
|
||||
import * as decoder from 'vs/base/node/decoder';
|
||||
import * as glob from 'vs/base/common/glob';
|
||||
import { IDiskFileChange, ILogMessage } from 'vs/platform/files/node/watcher/watcher';
|
||||
import { getPathFromAmdModule } from 'vs/base/common/amd';
|
||||
|
||||
export class OutOfProcessWin32FolderWatcher {
|
||||
|
||||
private static readonly MAX_RESTARTS = 5;
|
||||
|
||||
private static changeTypeMap: FileChangeType[] = [FileChangeType.UPDATED, FileChangeType.ADDED, FileChangeType.DELETED];
|
||||
|
||||
private ignored: glob.ParsedPattern[];
|
||||
|
||||
private handle: cp.ChildProcess;
|
||||
private restartCounter: number;
|
||||
|
||||
constructor(
|
||||
private watchedFolder: string,
|
||||
ignored: string[],
|
||||
private eventCallback: (events: IDiskFileChange[]) => void,
|
||||
private logCallback: (message: ILogMessage) => void,
|
||||
private verboseLogging: boolean
|
||||
) {
|
||||
this.restartCounter = 0;
|
||||
|
||||
if (Array.isArray(ignored)) {
|
||||
this.ignored = ignored.map(i => glob.parse(i));
|
||||
} else {
|
||||
this.ignored = [];
|
||||
}
|
||||
|
||||
// Logging
|
||||
if (this.verboseLogging) {
|
||||
this.log(`Start watching: ${watchedFolder}`);
|
||||
}
|
||||
|
||||
this.startWatcher();
|
||||
}
|
||||
|
||||
private startWatcher(): void {
|
||||
const args = [this.watchedFolder];
|
||||
if (this.verboseLogging) {
|
||||
args.push('-verbose');
|
||||
}
|
||||
|
||||
this.handle = cp.spawn(getPathFromAmdModule(require, 'vs/platform/files/node/watcher/win32/CodeHelper.exe'), args);
|
||||
|
||||
const stdoutLineDecoder = new decoder.LineDecoder();
|
||||
|
||||
// Events over stdout
|
||||
this.handle.stdout.on('data', (data: Buffer) => {
|
||||
|
||||
// Collect raw events from output
|
||||
const rawEvents: IDiskFileChange[] = [];
|
||||
stdoutLineDecoder.write(data).forEach((line) => {
|
||||
const eventParts = line.split('|');
|
||||
if (eventParts.length === 2) {
|
||||
const changeType = Number(eventParts[0]);
|
||||
const absolutePath = eventParts[1];
|
||||
|
||||
// File Change Event (0 Changed, 1 Created, 2 Deleted)
|
||||
if (changeType >= 0 && changeType < 3) {
|
||||
|
||||
// Support ignores
|
||||
if (this.ignored && this.ignored.some(ignore => ignore(absolutePath))) {
|
||||
if (this.verboseLogging) {
|
||||
this.log(absolutePath);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise record as event
|
||||
rawEvents.push({
|
||||
type: OutOfProcessWin32FolderWatcher.changeTypeMap[changeType],
|
||||
path: absolutePath
|
||||
});
|
||||
}
|
||||
|
||||
// 3 Logging
|
||||
else {
|
||||
this.log(eventParts[1]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger processing of events through the delayer to batch them up properly
|
||||
if (rawEvents.length > 0) {
|
||||
this.eventCallback(rawEvents);
|
||||
}
|
||||
});
|
||||
|
||||
// Errors
|
||||
this.handle.on('error', (error: Error) => this.onError(error));
|
||||
this.handle.stderr.on('data', (data: Buffer) => this.onError(data));
|
||||
|
||||
// Exit
|
||||
this.handle.on('exit', (code: number, signal: string) => this.onExit(code, signal));
|
||||
}
|
||||
|
||||
private onError(error: Error | Buffer): void {
|
||||
this.error('process error: ' + error.toString());
|
||||
}
|
||||
|
||||
private onExit(code: number, signal: string): void {
|
||||
if (this.handle) { // exit while not yet being disposed is unexpected!
|
||||
this.error(`terminated unexpectedly (code: ${code}, signal: ${signal})`);
|
||||
|
||||
if (this.restartCounter <= OutOfProcessWin32FolderWatcher.MAX_RESTARTS) {
|
||||
this.error('is restarted again...');
|
||||
this.restartCounter++;
|
||||
this.startWatcher(); // restart
|
||||
} else {
|
||||
this.error('Watcher failed to start after retrying for some time, giving up. Please report this as a bug report!');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private error(message: string) {
|
||||
this.logCallback({ type: 'error', message: `[File Watcher (C#)] ${message}` });
|
||||
}
|
||||
|
||||
private log(message: string) {
|
||||
this.logCallback({ type: 'trace', message: `[File Watcher (C#)] ${message}` });
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
if (this.handle) {
|
||||
this.handle.kill();
|
||||
this.handle = null!; // StrictNullOverride: nulling out ok in dispose
|
||||
}
|
||||
}
|
||||
}
|
||||
75
src/vs/platform/files/node/watcher/win32/watcherService.ts
Normal file
75
src/vs/platform/files/node/watcher/win32/watcherService.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IDiskFileChange, ILogMessage } from 'vs/platform/files/node/watcher/watcher';
|
||||
import { OutOfProcessWin32FolderWatcher } from 'vs/platform/files/node/watcher/win32/csharpWatcherService';
|
||||
import { posix } from 'vs/base/common/path';
|
||||
import { rtrim, endsWith } from 'vs/base/common/strings';
|
||||
import { IDisposable } from 'vs/base/common/lifecycle';
|
||||
|
||||
export class FileWatcher implements IDisposable {
|
||||
|
||||
private folder: { path: string, excludes: string[] };
|
||||
private service: OutOfProcessWin32FolderWatcher | undefined = undefined;
|
||||
|
||||
constructor(
|
||||
folders: { path: string, excludes: string[] }[],
|
||||
private onFileChanges: (changes: IDiskFileChange[]) => void,
|
||||
private onLogMessage: (msg: ILogMessage) => void,
|
||||
private verboseLogging: boolean
|
||||
) {
|
||||
this.folder = folders[0];
|
||||
|
||||
if (this.folder.path.indexOf('\\\\') === 0 && endsWith(this.folder.path, posix.sep)) {
|
||||
// for some weird reason, node adds a trailing slash to UNC paths
|
||||
// we never ever want trailing slashes as our base path unless
|
||||
// someone opens root ("/").
|
||||
// See also https://github.com/nodejs/io.js/issues/1765
|
||||
this.folder.path = rtrim(this.folder.path, posix.sep);
|
||||
}
|
||||
|
||||
this.service = this.startWatching();
|
||||
}
|
||||
|
||||
private get isDisposed(): boolean {
|
||||
return !this.service;
|
||||
}
|
||||
|
||||
private startWatching(): OutOfProcessWin32FolderWatcher {
|
||||
return new OutOfProcessWin32FolderWatcher(
|
||||
this.folder.path,
|
||||
this.folder.excludes,
|
||||
events => this.onFileEvents(events),
|
||||
message => this.onLogMessage(message),
|
||||
this.verboseLogging
|
||||
);
|
||||
}
|
||||
|
||||
setVerboseLogging(verboseLogging: boolean): void {
|
||||
this.verboseLogging = verboseLogging;
|
||||
if (this.service) {
|
||||
this.service.dispose();
|
||||
this.service = this.startWatching();
|
||||
}
|
||||
}
|
||||
|
||||
private onFileEvents(events: IDiskFileChange[]): void {
|
||||
if (this.isDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Emit through event emitter
|
||||
if (events.length > 0) {
|
||||
this.onFileChanges(events);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.service) {
|
||||
this.service.dispose();
|
||||
this.service = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user