deploy: current vibn theia state
Some checks failed
Playwright Tests / Playwright Tests (ubuntu-22.04, Node.js 22.x) (push) Has been cancelled
3PP License Check / 3PP License Check (11, 22.x, ubuntu-22.04) (push) Has been cancelled
Publish packages to NPM / Perform Publishing (push) Has been cancelled

Made-with: Cursor
This commit is contained in:
2026-02-27 12:01:08 -08:00
commit 8bb5110148
3782 changed files with 640947 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [
'../../configs/build.eslintrc.json'
],
parserOptions: {
tsconfigRootDir: __dirname,
project: 'tsconfig.json'
}
};

View File

@@ -0,0 +1,31 @@
<div align='center'>
<br />
<img src='https://raw.githubusercontent.com/eclipse-theia/theia/master/logo/theia.svg?sanitize=true' alt='theia-ext-logo' width='100px' />
<h2>ECLIPSE THEIA - FILESYSTEM EXTENSION</h2>
<hr />
</div>
## Description
The `@theia/filesystem` extension provides functionality to interact with the filesystem, including services such as watching, uploading, and the base `file-tree` widget.
## Additional Information
- [API documentation for `@theia/filesystem`](https://eclipse-theia.github.io/theia/docs/next/modules/_theia_filesystem.html)
- [Theia - GitHub](https://github.com/eclipse-theia/theia)
- [Theia - Website](https://theia-ide.org/)
## License
- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/)
- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp)
## Trademark
"Theia" is a trademark of the Eclipse Foundation
<https://www.eclipse.org/theia>

View File

@@ -0,0 +1,86 @@
{
"name": "@theia/filesystem",
"version": "1.68.0",
"description": "Theia - FileSystem Extension",
"dependencies": {
"@theia/core": "1.68.0",
"@types/body-parser": "^1.17.0",
"@types/multer": "^1.4.7",
"@types/tar-fs": "^1.16.1",
"@types/tar-stream": "^3.1.4",
"async-mutex": "^0.3.1",
"body-parser": "^1.18.3",
"http-status-codes": "^1.3.0",
"ignore": "^6.0.0",
"minimatch": "^10.0.3",
"multer": "^2.0.1",
"opfs-worker": "1.3.1",
"rimraf": "^5.0.0",
"stat-mode": "^1.0.0",
"tar-fs": "^3.0.9",
"tar-stream": "^3.1.7",
"trash": "^7.2.0",
"tslib": "^2.6.2",
"vscode-languageserver-textdocument": "^1.0.1"
},
"publishConfig": {
"access": "public"
},
"theiaExtensions": [
{
"preload": "lib/electron-browser/preload",
"electronMain": "lib/electron-main/electron-main-module"
},
{
"frontend": "lib/browser/filesystem-frontend-module",
"secondaryWindow": "lib/browser/filesystem-frontend-module",
"backend": "lib/node/filesystem-backend-module"
},
{
"frontendOnly": "lib/browser-only/browser-only-filesystem-frontend-module"
},
{
"frontend": "lib/browser/download/file-download-frontend-module",
"frontendOnly": "lib/browser-only/download/file-download-frontend-module",
"backend": "lib/node/download/file-download-backend-module"
},
{
"frontend": "lib/browser/file-dialog/file-dialog-module"
},
{
"frontendElectron": "lib/electron-browser/file-dialog/electron-file-dialog-module"
}
],
"keywords": [
"theia-extension"
],
"license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0",
"repository": {
"type": "git",
"url": "https://github.com/eclipse-theia/theia.git"
},
"bugs": {
"url": "https://github.com/eclipse-theia/theia/issues"
},
"homepage": "https://github.com/eclipse-theia/theia",
"files": [
"lib",
"src"
],
"scripts": {
"build": "theiaext build",
"clean": "theiaext clean",
"compile": "theiaext compile",
"lint": "theiaext lint",
"test": "theiaext test",
"test:watch": "theiaext test:watch",
"watch": "theiaext watch"
},
"devDependencies": {
"@theia/ext-scripts": "1.68.0"
},
"nyc": {
"extends": "../../configs/nyc.json"
},
"gitHead": "21358137e41342742707f660b8e222f940a27652"
}

View File

@@ -0,0 +1,48 @@
// *****************************************************************************
// Copyright (C) 2023 EclipseSource and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ContainerModule } from '@theia/core/shared/inversify';
import { FileSystemProvider } from '../common/files';
import { OPFSFileSystemProvider } from './opfs-filesystem-provider';
import { RemoteFileSystemProvider, RemoteFileSystemServer } from '../common/remote-file-system-provider';
import { OPFSInitialization, DefaultOPFSInitialization } from './opfs-filesystem-initialization';
import { BrowserOnlyFileSystemProviderServer } from './browser-only-filesystem-provider-server';
import { FileUploadService } from '../common/upload/file-upload';
import { FileUploadServiceImpl } from './upload/file-upload-service-impl';
export default new ContainerModule((bind, _unbind, isBound, rebind) => {
bind(DefaultOPFSInitialization).toSelf();
bind(OPFSFileSystemProvider).toSelf();
bind(OPFSInitialization).toService(DefaultOPFSInitialization);
if (isBound(FileUploadService)) {
rebind(FileUploadService).to(FileUploadServiceImpl).inSingletonScope();
} else {
bind(FileUploadService).to(FileUploadServiceImpl).inSingletonScope();
}
if (isBound(FileSystemProvider)) {
rebind(FileSystemProvider).to(OPFSFileSystemProvider).inSingletonScope();
} else {
bind(FileSystemProvider).to(OPFSFileSystemProvider).inSingletonScope();
}
if (isBound(RemoteFileSystemProvider)) {
rebind(RemoteFileSystemServer).to(BrowserOnlyFileSystemProviderServer).inSingletonScope();
} else {
bind(RemoteFileSystemServer).to(BrowserOnlyFileSystemProviderServer).inSingletonScope();
}
});

View File

@@ -0,0 +1,32 @@
// *****************************************************************************
// Copyright (C) 2023 EclipseSource and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable } from '@theia/core/shared/inversify';
import { FileSystemProviderServer } from '../common/remote-file-system-provider';
import { Event } from '@theia/core';
/**
* Backend component.
*
* JSON-RPC server exposing a wrapped file system provider remotely.
*/
@injectable()
export class BrowserOnlyFileSystemProviderServer extends FileSystemProviderServer {
// needed because users expect implicitly the RemoteFileSystemServer to be a RemoteFileSystemProxyFactory
onDidOpenConnection = Event.None;
onDidCloseConnection = Event.None;
}

View File

@@ -0,0 +1,56 @@
// *****************************************************************************
// Copyright (C) 2025 Maksim Kachurin and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { SelectionService } from '@theia/core/lib/common/selection-service';
import { CommandContribution, CommandRegistry } from '@theia/core/lib/common/command';
import { UriAwareCommandHandler } from '@theia/core/lib/common/uri-command-handler';
import { FileDownloadService } from '../../common/download/file-download';
import { FileDownloadCommands } from '../../browser/download/file-download-command-contribution';
@injectable()
export class FileDownloadCommandContribution implements CommandContribution {
@inject(FileDownloadService)
protected readonly downloadService: FileDownloadService;
@inject(SelectionService)
protected readonly selectionService: SelectionService;
registerCommands(registry: CommandRegistry): void {
registry.registerCommand(
FileDownloadCommands.DOWNLOAD,
UriAwareCommandHandler.MultiSelect(this.selectionService, {
execute: uris => this.executeDownload(uris),
isEnabled: uris => this.isDownloadEnabled(uris),
isVisible: uris => this.isDownloadVisible(uris),
})
);
}
protected async executeDownload(uris: URI[], options?: { copyLink?: boolean }): Promise<void> {
this.downloadService.download(uris, options);
}
protected isDownloadEnabled(uris: URI[]): boolean {
return uris.length > 0 && uris.every(u => u.scheme === 'file');
}
protected isDownloadVisible(uris: URI[]): boolean {
return this.isDownloadEnabled(uris);
}
}

View File

@@ -0,0 +1,26 @@
// *****************************************************************************
// Copyright (C) 2025 Maksim Kachurin and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ContainerModule } from '@theia/core/shared/inversify';
import { FileDownloadService } from '../../common/download/file-download';
import { FileDownloadServiceImpl } from './file-download-service';
import { CommandContribution } from '@theia/core/lib/common';
import { FileDownloadCommandContribution } from './file-download-command-contribution';
export default new ContainerModule(bind => {
bind(FileDownloadService).to(FileDownloadServiceImpl).inSingletonScope();
bind(CommandContribution).to(FileDownloadCommandContribution).inSingletonScope();
});

View File

@@ -0,0 +1,726 @@
// *****************************************************************************
// Copyright (C) 2025 Maksim Kachurin and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { ILogger } from '@theia/core/lib/common/logger';
import { MessageService } from '@theia/core/lib/common/message-service';
import { FileSystemPreferences } from '../../common/filesystem-preferences';
import { nls } from '@theia/core';
import { BinaryBuffer } from '@theia/core/lib/common/buffer';
import { binaryStreamToWebStream } from '@theia/core/lib/common/stream';
import { FileService } from '../../browser/file-service';
import type { FileDownloadService } from '../../common/download/file-download';
import * as tarStream from 'tar-stream';
import { minimatch } from 'minimatch';
@injectable()
export class FileDownloadServiceImpl implements FileDownloadService {
@inject(FileService)
protected readonly fileService: FileService;
@inject(ILogger)
protected readonly logger: ILogger;
@inject(MessageService)
protected readonly messageService: MessageService;
@inject(FileSystemPreferences)
protected readonly preferences: FileSystemPreferences;
private readonly ignorePatterns: string[] = [];
protected getFileSizeThreshold(): number {
return this.preferences['files.maxFileSizeMB'] * 1024 * 1024;
}
/**
* Check if streaming download is supported (File System Access API)
*/
protected isStreamingSupported(): boolean {
if (!globalThis.isSecureContext) {
return false;
}
if (!('showSaveFilePicker' in globalThis)) {
return false;
}
try {
return typeof (globalThis as unknown as { ReadableStream?: { prototype?: { pipeTo?: unknown } } }).ReadableStream?.prototype?.pipeTo === 'function';
} catch {
return false;
}
}
async download(uris: URI[], options?: never): Promise<void> {
if (uris.length === 0) {
return;
}
const abortController = new AbortController();
try {
const progress = await this.messageService.showProgress(
{
text: nls.localize(
'theia/filesystem/prepareDownload',
'Preparing download...'
),
options: { cancelable: true },
},
() => {
abortController.abort();
}
);
try {
await this.doDownload(uris, abortController.signal);
} finally {
progress.cancel();
}
} catch (e) {
if (!abortController.signal.aborted) {
this.logger.error(
`Error occurred when downloading: ${uris.map(u =>
u.toString(true)
)}.`,
e
);
this.messageService.error(
nls.localize(
'theia/filesystem/downloadError',
'Failed to download files. See console for details.'
)
);
}
}
}
protected async doDownload(
uris: URI[],
abortSignal: AbortSignal
): Promise<void> {
try {
const { files, directories, totalSize, stats } =
await this.collectFiles(uris, abortSignal);
if (abortSignal.aborted) {
return;
}
if (
totalSize > this.getFileSizeThreshold() &&
this.isStreamingSupported()
) {
await this.streamDownloadToFile(
uris,
files,
directories,
stats,
abortSignal
);
} else {
let data: Blob;
let filename: string = 'theia-download.tar';
if (uris.length === 1) {
const stat = stats[0];
if (stat.isDirectory) {
filename = `${stat.name}.tar`;
data = await this.createArchiveBlob(async tarPack => {
await this.addFilesToArchive(
tarPack,
files,
directories,
abortSignal
);
}, abortSignal);
} else {
filename = stat.name;
const content = await this.fileService.readFile(
uris[0]
);
data = new Blob([content.value.buffer as BlobPart], {
type: 'application/octet-stream',
});
}
} else {
data = await this.createArchiveBlob(async tarPack => {
await this.addFilesToArchive(
tarPack,
files,
directories,
abortSignal
);
}, abortSignal);
}
if (!abortSignal.aborted) {
this.blobDownload(data, filename);
}
}
} catch (error) {
if (!abortSignal.aborted) {
this.logger.error('Failed to download files', error);
throw error;
}
}
}
protected async createArchiveBlob(
populateArchive: (tarPack: tarStream.Pack) => Promise<void>,
abortSignal: AbortSignal
): Promise<Blob> {
const stream = this.createArchiveStream(abortSignal, populateArchive);
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
while (true) {
if (abortSignal.aborted) {
throw new Error('Operation aborted');
}
const { done, value } = await reader.read();
if (done) {
break;
}
chunks.push(value!);
total += value!.byteLength;
}
const out = new Uint8Array(total);
let off = 0;
for (const c of chunks) {
out.set(c, off);
off += c.byteLength;
}
return new Blob([out], { type: 'application/x-tar' });
} finally {
try {
reader.releaseLock();
} catch {}
}
}
/**
* Create ReadableStream from a single file using FileService streaming
*/
protected async createFileStream(
uri: URI,
abortSignal: AbortSignal
): Promise<ReadableStream<Uint8Array>> {
if (abortSignal.aborted) {
throw new Error('Operation aborted');
}
const fileStreamContent = await this.fileService.readFileStream(uri);
return binaryStreamToWebStream(fileStreamContent.value, abortSignal);
}
protected async addFileToArchive(
tarPack: tarStream.Pack,
file: { uri: URI; path: string; size: number },
abortSignal: AbortSignal
): Promise<void> {
if (abortSignal.aborted) {
return;
}
try {
const name = this.sanitizeFilename(file.path);
const size = file.size;
const entry = tarPack.entry({ name, size });
const fileStreamContent = await this.fileService.readFileStream(
file.uri
);
const src = fileStreamContent.value;
return new Promise<void>((resolve, reject) => {
const cleanup = () => {
src.removeListener?.('data', onData);
src.removeListener?.('end', onEnd);
src.removeListener?.('error', onError);
entry.removeListener?.('error', onEntryError);
abortSignal.removeEventListener('abort', onAbort);
};
const onAbort = () => {
cleanup();
entry.destroy?.();
reject(new Error('Operation aborted'));
};
let ended = false;
let pendingWrite: Promise<void> | undefined = undefined;
const onData = async (chunk: BinaryBuffer) => {
if (abortSignal.aborted || ended) {
return;
}
src.pause?.();
const u8 = new Uint8Array(
chunk.buffer as unknown as ArrayBuffer
);
const canWrite = entry.write(u8);
if (!canWrite) {
pendingWrite = new Promise<void>(resolveDrain => {
entry.once('drain', resolveDrain);
});
await pendingWrite;
pendingWrite = undefined;
}
if (!ended) {
src.resume?.();
}
};
const onEnd = async () => {
ended = true;
if (pendingWrite) {
await pendingWrite;
}
cleanup();
entry.end();
resolve();
};
const onError = (err: Error) => {
cleanup();
try {
entry.destroy?.(err);
} catch {}
reject(err);
};
const onEntryError = (err: Error) => {
cleanup();
reject(
new Error(`Entry error for ${name}: ${err.message}`)
);
};
if (abortSignal.aborted) {
return onAbort();
}
abortSignal.addEventListener('abort', onAbort, { once: true });
entry.on?.('error', onEntryError);
src.on?.('data', onData);
src.on?.('end', onEnd);
src.on?.('error', onError);
});
} catch (error) {
this.logger.error(
`Failed to read file ${file.uri.toString()}:`,
error
);
throw error;
}
}
protected async addFilesToArchive(
tarPack: tarStream.Pack,
files: Array<{ uri: URI; path: string; size: number }>,
directories: Array<{ path: string }>,
abortSignal: AbortSignal
): Promise<void> {
const uniqueDirs = new Set<string>();
for (const dir of directories) {
const normalizedPath = this.sanitizeFilename(dir.path) + '/';
uniqueDirs.add(normalizedPath);
}
for (const dirPath of uniqueDirs) {
try {
const entry = tarPack.entry({
name: dirPath,
type: 'directory',
});
entry.end();
} catch (error) {
this.logger.error(
`Failed to add directory ${dirPath}:`,
error
);
}
}
for (const file of files) {
if (abortSignal.aborted) {
break;
}
try {
await this.addFileToArchive(
tarPack,
file,
abortSignal
);
} catch (error) {
this.logger.error(
`Failed to read file ${file.uri.toString()}:`,
error
);
}
}
}
protected createArchiveStream(
abortSignal: AbortSignal,
populateArchive: (tarPack: tarStream.Pack) => Promise<void>
): globalThis.ReadableStream<Uint8Array> {
const tarPack = tarStream.pack();
return new ReadableStream<Uint8Array>({
start(
controller: ReadableStreamDefaultController<Uint8Array>
): void {
const cleanup = () => {
try {
tarPack.removeAllListeners();
} catch {}
try {
tarPack.destroy?.();
} catch {}
abortSignal.removeEventListener('abort', onAbort);
};
const onAbort = () => {
cleanup();
controller.error(new Error('Operation aborted'));
};
if (abortSignal.aborted) {
onAbort();
return;
}
abortSignal.addEventListener('abort', onAbort, { once: true });
tarPack.on('data', (chunk: Uint8Array) => {
if (abortSignal.aborted) {
return;
}
try {
controller.enqueue(chunk);
} catch (error) {
cleanup();
controller.error(error as Error);
}
});
tarPack.once('end', () => {
cleanup();
controller.close();
});
tarPack.once('error', error => {
cleanup();
controller.error(error);
});
populateArchive(tarPack)
.then(() => {
if (!abortSignal.aborted) {
tarPack.finalize();
}
})
.catch(error => {
cleanup();
controller.error(error);
});
},
cancel: () => {
try {
tarPack.finalize?.();
tarPack.destroy?.();
} catch {}
},
});
}
protected async streamDownloadToFile(
uris: URI[],
files: Array<{ uri: URI; path: string; size: number }>,
directories: Array<{ path: string }>,
stats: Array<{ name: string; isDirectory: boolean; size?: number }>,
abortSignal: AbortSignal
): Promise<void> {
let filename = 'theia-download.tar';
if (uris.length === 1) {
const stat = stats[0];
filename = stat.isDirectory ? `${stat.name}.tar` : stat.name;
}
const isArchive = filename.endsWith('.tar');
let fileHandle: FileSystemFileHandle;
try {
// @ts-expect-error non-standard
fileHandle = await window.showSaveFilePicker({
suggestedName: filename,
types: isArchive
? [
{
description: 'Archive files',
accept: { 'application/x-tar': ['.tar'] },
},
]
: undefined,
});
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return;
}
throw error;
}
let stream: ReadableStream<Uint8Array>;
if (uris.length === 1) {
const stat = await this.fileService.resolve(uris[0]);
stream = stat.isDirectory
? this.createArchiveStream(abortSignal, async tarPack => {
await this.addFilesToArchive(
tarPack,
files,
directories,
abortSignal
);
})
: await this.createFileStream(uris[0], abortSignal);
} else {
stream = this.createArchiveStream(abortSignal, async tarPack => {
await this.addFilesToArchive(
tarPack,
files,
directories,
abortSignal
);
});
}
const writable = await fileHandle.createWritable();
try {
await stream.pipeTo(writable, { signal: abortSignal });
} catch (error) {
try {
await writable.abort?.();
} catch {}
throw error;
}
}
protected blobDownload(data: Blob, filename: string): void {
const url = URL.createObjectURL(data);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 0);
}
protected sanitizeFilename(filename: string): string {
return filename
.replace(/[\\:*?"<>|]/g, '_') // Replace Windows-problematic chars
.replace(/\.\./g, '__') // Replace .. to prevent directory traversal
.replace(/^\/+/g, '') // Remove leading slashes
.replace(/\/+$/, '') // Remove trailing slashes for files
.replace(/[\u0000-\u001f\u007f]/g, '_') // Replace control characters
.replace(/\/+/g, '/')
.replace(/^\.$/, '_')
.replace(/^$/, '_');
}
protected shouldIncludeFile(path: string): boolean {
return !this.ignorePatterns.some((pattern: string) =>
minimatch(path, pattern)
);
}
/**
* Collect all files and calculate total size
*/
protected async collectFiles(
uris: URI[],
abortSignal?: AbortSignal
): Promise<{
files: Array<{ uri: URI; path: string; size: number }>;
directories: Array<{ path: string }>;
totalSize: number;
stats: Array<{ name: string; isDirectory: boolean; size?: number }>;
}> {
const files: Array<{ uri: URI; path: string; size: number }> = [];
const directories: Array<{ path: string }> = [];
let totalSize = 0;
const stats: Array<{
name: string;
isDirectory: boolean;
size?: number;
}> = [];
for (const uri of uris) {
if (abortSignal?.aborted) {
break;
}
try {
const stat = await this.fileService.resolve(uri, {
resolveMetadata: true,
});
stats.push({
name: stat.name,
isDirectory: stat.isDirectory,
size: stat.size,
});
if (abortSignal?.aborted) {
break;
}
if (!stat.isDirectory) {
const size = stat.size || 0;
files.push({ uri, path: stat.name, size });
totalSize += size;
continue;
}
if (!stat.children?.length) {
directories.push({ path: stat.name });
continue;
}
directories.push({ path: stat.name });
const dirResult = await this.collectFilesFromDirectory(
uri,
stat.name,
abortSignal
);
files.push(...dirResult.files);
directories.push(...dirResult.directories);
totalSize += dirResult.files.reduce(
(sum, file) => sum + file.size,
0
);
} catch (error) {
this.logger.warn(
`Failed to collect files from ${uri.toString()}:`,
error
);
stats.push({
name: uri.path.name || 'unknown',
isDirectory: false,
size: 0,
});
}
}
return { files, directories, totalSize, stats };
}
/**
* Recursively collect files from a directory
*/
protected async collectFilesFromDirectory(
dirUri: URI,
basePath: string,
abortSignal?: AbortSignal
): Promise<{
files: Array<{ uri: URI; path: string; size: number }>;
directories: Array<{ path: string }>;
}> {
const files: Array<{ uri: URI; path: string; size: number }> = [];
const directories: Array<{ path: string }> = [];
try {
const dirStat = await this.fileService.resolve(dirUri);
if (abortSignal?.aborted) {
return { files, directories };
}
// Empty directory - add it to preserve structure
if (!dirStat.children?.length) {
directories.push({ path: basePath });
return { files, directories };
}
for (const child of dirStat.children) {
if (abortSignal?.aborted) {
break;
}
const childPath = basePath
? `${basePath}/${child.name}`
: child.name;
if (!this.shouldIncludeFile(childPath)) {
continue;
}
if (child.isDirectory) {
directories.push({ path: childPath });
const subResult = await this.collectFilesFromDirectory(
child.resource,
childPath,
abortSignal
);
files.push(...subResult.files);
directories.push(...subResult.directories);
} else {
const childStat = await this.fileService.resolve(
child.resource
);
files.push({
uri: child.resource,
path: childPath,
size: childStat.size || 0,
});
}
}
} catch (error) {
this.logger.warn(
`Failed to collect files from directory ${dirUri.toString()}:`,
error
);
}
return { files, directories };
}
}

View File

@@ -0,0 +1,170 @@
// *****************************************************************************
// Copyright (C) 2025 Maksim Kachurin and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { minimatch, type MinimatchOptions } from 'minimatch';
import ignore from 'ignore';
import type URI from '@theia/core/lib/common/uri';
/**
* Normalizes glob patterns to be consistent with ripgrep behavior.
*
* Examples of transformations:
* - "*.js" -> "**\/*.js" (make non-root patterns match anywhere)
* - "src/" -> "src\/**" (directory patterns match all contents)
* - "!*.log" -> "!**\/*.log" (negation patterns)
* - "src/**\/**\/file.js" -> "src/**\/file.js" (collapse repeated double-star patterns)
*
* @param glob - The glob pattern to normalize
* @returns The normalized glob pattern
*/
export function normalizeGlob(glob: string): string {
let neg = '';
let root = false;
// Handle negation patterns (starting with '!')
if (glob.startsWith('!')) {
neg = '!';
glob = glob.slice(1);
}
// Convert Windows backslashes to forward slashes for consistency
glob = glob.replace(/\\/g, '/');
// Remove redundant './' prefix (same as current directory)
if (glob.startsWith('./')) {
glob = glob.slice(1);
}
// Check if pattern is root-anchored (starts with '/')
if (glob.startsWith('/')) {
root = true;
}
// Convert directory patterns to match all contents
// "src/" becomes "src/**" to match everything inside the directory
if (glob.endsWith('/') && !glob.endsWith('/**')) {
glob = glob + '**';
}
// Make non-root patterns match anywhere in the directory tree
// "*.js" becomes "**/*.js" to match .js files anywhere
if (!root && !glob.startsWith('**')) {
glob = '**/' + glob;
}
// Clean up repeated '**/' patterns
// "src/**/**/file.js" becomes "src/**/file.js"
glob = glob.replace(/(\*\*\/)+\*\*\//g, '**/');
// Restore negation prefix if it was present
return neg + glob;
}
/**
* Checks if a text matches any of the minimatch patterns
* @param text - The text to check
* @param patterns - The patterns to check
* @returns True if the text matches any of the patterns, false otherwise
*/
export function matchesPattern(text: string, patterns: string[], opts?: MinimatchOptions): boolean {
return patterns.some(pattern => minimatch(text, pattern, opts));
}
/**
* Creates a new ignore pattern matcher for managing ignore patterns.
* @returns An object with add and ignores methods
*/
export function createIgnoreMatcher(): { add: (patterns: string | string[]) => void; ignores: (path: string) => boolean } {
const ig = ignore();
return {
add: (patterns: string | string[]) => ig.add(patterns),
ignores: (path: string) => ig.ignores(path)
};
}
/**
* Processes ignore files (.gitignore, .ignore, .rgignore) in a directory.
* @param dir - The directory URI to process
* @param read - Function to read the ignore file content
* @returns Array of processed ignore patterns relative to the directory contains that ignore file
*/
export async function getIgnorePatterns(dir: URI, read: (uri: URI) => Promise<string>): Promise<string[]> {
const fromPath = dir.path.toString();
const ignoreFiles = await Promise.allSettled(
['.gitignore', '.ignore', '.rgignore'].map(file => read(dir.resolve(file)))
);
const lines = ignoreFiles
.filter(result => result.status === 'fulfilled')
.flatMap((result: PromiseFulfilledResult<string>) =>
result.value
.split('\n')
.map(line => prefixGitignoreLine(fromPath, line))
.filter((line): line is string => typeof line === 'string')
);
return lines;
}
/**
* Convert patterns from dir base to root-relative git semantics.
* @param baseRel - The base relative path
* @param raw - The raw pattern
* @returns The processed pattern
*/
function prefixGitignoreLine(baseRel: string, raw: string): string | undefined {
let line = raw.replace(/\r?\n$/, '');
if (!line || /^\s*#/.test(line)) {
return undefined;
}
// handle escaped leading '!' and '#'
const escapedBang = line.startsWith('\\!');
const escapedHash = line.startsWith('\\#');
if (escapedBang || escapedHash) {
line = line.slice(1);
}
const neg = !escapedBang && line.startsWith('!');
if (neg) { line = line.slice(1); }
// normalize slashes in the pattern part
line = line.replace(/\\/g, '/');
// strip leading "./"
if (line.startsWith('./')) { line = line.slice(2); }
const anchored = line.startsWith('/');
const hasSlash = line.includes('/');
const prefix = baseRel ? baseRel.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '') : '';
let pattern: string;
if (anchored) {
// "/foo" in base -> "base/foo"
pattern = (prefix ? `${prefix}${line}` : line.slice(1)); // remove leading '/' if no base
} else if (hasSlash) {
// "bar/*.js" in base -> "base/bar/*.js"
pattern = prefix ? `${prefix}/${line}` : line;
} else {
// "foo" in base -> "base/**/foo"
pattern = prefix ? `${prefix}/**/${line}` : line;
}
return neg ? `!${pattern}` : pattern;
}

View File

@@ -0,0 +1,39 @@
// *****************************************************************************
// Copyright (C) 2023 EclipseSource and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import type { OPFSFileSystemProvider } from './opfs-filesystem-provider';
import { injectable } from '@theia/core/shared/inversify';
export const OPFSInitialization = Symbol('OPFSInitialization');
export interface OPFSInitialization {
getBroadcastChannel(): BroadcastChannel;
getRootDirectory(): Promise<string> | string;
initializeFS(provider: OPFSFileSystemProvider): Promise<void>;
}
@injectable()
export class DefaultOPFSInitialization implements OPFSInitialization {
getBroadcastChannel(): BroadcastChannel {
return new BroadcastChannel('theia-opfs-events');
}
getRootDirectory(): Promise<string> | string {
return '/';
}
async initializeFS(provider: OPFSFileSystemProvider): Promise<void> {
}
}

View File

@@ -0,0 +1,559 @@
// *****************************************************************************
// Copyright (C) 2024 EclipseSource and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
import {
FileChange, FileChangeType, FileDeleteOptions,
FileOverwriteOptions, FileSystemProviderCapabilities,
FileSystemProviderError,
FileSystemProviderErrorCode,
FileSystemProviderWithFileReadWriteCapability,
FileSystemProviderWithFileFolderCopyCapability,
FileSystemProviderWithOpenReadWriteCloseCapability,
FileType, FileWriteOptions, Stat, WatchOptions, createFileSystemProviderError,
FileOpenOptions, FileUpdateOptions, FileUpdateResult,
type FileReadStreamOptions
} from '../common/files';
import { Emitter, Event, URI, Disposable, DisposableCollection, type CancellationToken } from '@theia/core';
import { EncodingService } from '@theia/core/lib/common/encoding-service';
import { BinaryBuffer } from '@theia/core/lib/common/buffer';
import { TextDocumentContentChangeEvent } from '@theia/core/shared/vscode-languageserver-protocol';
import { TextDocument } from 'vscode-languageserver-textdocument';
import { OPFSFileSystem, WatchEventType, type FileStat, type OPFSError, type WatchEvent } from 'opfs-worker';
import { OPFSInitialization } from './opfs-filesystem-initialization';
import { ReadableStreamEvents, newWriteableStream } from '@theia/core/lib/common/stream';
import { readFileIntoStream } from '../common/io';
import { FileUri } from '@theia/core/lib/common/file-uri';
@injectable()
export class OPFSFileSystemProvider implements Disposable,
FileSystemProviderWithFileReadWriteCapability,
FileSystemProviderWithOpenReadWriteCloseCapability,
FileSystemProviderWithFileFolderCopyCapability {
private readonly BUFFER_SIZE = 64 * 1024;
capabilities: FileSystemProviderCapabilities =
FileSystemProviderCapabilities.FileReadWrite |
FileSystemProviderCapabilities.FileOpenReadWriteClose |
FileSystemProviderCapabilities.FileFolderCopy |
FileSystemProviderCapabilities.Update;
onDidChangeCapabilities: Event<void> = Event.None;
private readonly onDidChangeFileEmitter = new Emitter<readonly FileChange[]>();
readonly onDidChangeFile = this.onDidChangeFileEmitter.event;
readonly onFileWatchError: Event<void> = Event.None;
@inject(OPFSInitialization)
protected readonly initialization: OPFSInitialization;
@inject(EncodingService)
protected readonly encodingService: EncodingService;
private fs!: OPFSFileSystem;
private initialized: Promise<true>;
protected readonly toDispose = new DisposableCollection(
this.onDidChangeFileEmitter
);
/**
* Initializes the OPFS file system provider
*/
@postConstruct()
protected init(): void {
const setup = async (): Promise<true> => {
const root = await this.initialization.getRootDirectory();
const broadcastChannel = this.initialization.getBroadcastChannel();
// Set up file change listening via BroadcastChannel
broadcastChannel.onmessage = this.handleFileSystemChange.bind(this);
// Initialize the file system
this.fs = new OPFSFileSystem({
root,
broadcastChannel,
hashAlgorithm: false,
});
await this.initialization.initializeFS(new Proxy(this, {
get(target, prop, receiver): unknown {
if (prop === 'initialized') {
return Promise.resolve(true);
}
return Reflect.get(target, prop, receiver);
}
}));
return true;
};
this.initialized = setup();
}
/**
* Watches a resource for file system changes
*/
watch(resource: URI, opts: WatchOptions): Disposable {
if (!resource || !resource.path) {
return Disposable.NULL;
}
const unwatch = this.fs.watch(formatPath(resource), {
recursive: opts.recursive,
exclude: opts.excludes,
});
return Disposable.create(unwatch);
}
/**
* Creates an index from the map of entries
*/
async createIndex(entries: Map<URI, Uint8Array>): Promise<void> {
const arrayEntries: [string, Uint8Array][] = [];
for (const [uri, content] of entries) {
arrayEntries.push([formatPath(uri), content]);
}
await this.fs.createIndex(arrayEntries);
}
/**
* Retrieves the current file system index
*/
async index(): Promise<Map<URI, Stat>> {
const opfsIndex = await this.fs.index();
const index = new Map<URI, Stat>();
for (const [path, stats] of opfsIndex.entries()) {
const uri = new URI(path);
index.set(uri, formatStat(stats));
}
return index;
}
/**
* Clears the file system
*/
async clear(): Promise<void> {
try {
await this.fs.clear();
} catch (error) {
throw toFileSystemProviderError(error as Error | OPFSError);
}
}
/**
* Checks if a resource exists
*/
async exists(resource: URI): Promise<boolean> {
if (!resource || !resource.path) {
return false;
}
await this.initialized;
try {
return await this.fs.exists(formatPath(resource));
} catch (error) {
throw toFileSystemProviderError(error as Error | OPFSError);
}
}
/**
* Gets file system statistics for a resource
*/
async stat(resource: URI): Promise<Stat> {
if (!resource || !resource.path) {
throw createFileSystemProviderError('Invalid resource URI', FileSystemProviderErrorCode.FileNotFound);
}
await this.initialized;
try {
const path = formatPath(resource);
const stats = await this.fs.stat(path);
return formatStat(stats);
} catch (error) {
throw toFileSystemProviderError(error as Error | OPFSError);
}
}
/**
* Creates a directory
*/
async mkdir(resource: URI): Promise<void> {
if (!resource || !resource.path) {
throw createFileSystemProviderError('Invalid resource URI', FileSystemProviderErrorCode.FileNotFound);
}
await this.initialized;
try {
const path = formatPath(resource);
await this.fs.mkdir(path, { recursive: true });
this.onDidChangeFileEmitter.fire([{ resource, type: FileChangeType.ADDED }]);
} catch (error) {
throw toFileSystemProviderError(error as Error | OPFSError);
}
}
/**
* Reads directory contents
*/
async readdir(resource: URI): Promise<[string, FileType][]> {
if (!resource || !resource.path) {
throw createFileSystemProviderError('Invalid resource URI', FileSystemProviderErrorCode.FileNotFound);
}
await this.initialized;
try {
const path = formatPath(resource);
const entries = await this.fs.readDir(path);
return entries.map(entry => [
entry.name,
entry.isFile ? FileType.File : FileType.Directory
]);
} catch (error) {
throw toFileSystemProviderError(error as Error | OPFSError);
}
}
/**
* Deletes a resource
*/
async delete(resource: URI, opts: FileDeleteOptions): Promise<void> {
if (!resource || !resource.path) {
throw createFileSystemProviderError('Invalid resource URI', FileSystemProviderErrorCode.FileNotFound);
}
await this.initialized;
try {
const path = formatPath(resource);
await this.fs.remove(path, { recursive: opts.recursive });
} catch (error) {
throw toFileSystemProviderError(error as Error | OPFSError);
}
}
/**
* Renames a resource from one location to another
*/
async rename(from: URI, to: URI, opts: FileOverwriteOptions): Promise<void> {
if (!from || !from.path || !to || !to.path) {
throw createFileSystemProviderError('Invalid source or destination URI', FileSystemProviderErrorCode.FileNotFound);
}
await this.initialized;
try {
const fromPath = formatPath(from);
const toPath = formatPath(to);
await this.fs.rename(fromPath, toPath, {
overwrite: opts.overwrite,
});
} catch (error) {
throw toFileSystemProviderError(error as Error | OPFSError);
}
}
/**
* Copies a resource from one location to another
*/
async copy(from: URI, to: URI, opts: FileOverwriteOptions): Promise<void> {
if (!from || !from.path || !to || !to.path) {
throw createFileSystemProviderError('Invalid source or destination URI', FileSystemProviderErrorCode.FileNotFound);
}
await this.initialized;
try {
const fromPath = formatPath(from);
const toPath = formatPath(to);
await this.fs.copy(fromPath, toPath, {
overwrite: opts.overwrite,
recursive: true,
});
} catch (error) {
throw toFileSystemProviderError(error as Error | OPFSError);
}
}
/**
* Reads file content as binary data
*/
async readFile(resource: URI): Promise<Uint8Array> {
if (!resource || !resource.path) {
throw createFileSystemProviderError('Invalid resource URI', FileSystemProviderErrorCode.FileNotFound);
}
await this.initialized;
try {
return await this.fs.readFile(formatPath(resource), 'binary') as Uint8Array;
} catch (error) {
throw toFileSystemProviderError(error as Error | OPFSError);
}
}
/**
* Reads file content as a stream
*/
readFileStream(resource: URI, opts: FileReadStreamOptions, token: CancellationToken): ReadableStreamEvents<Uint8Array> {
const stream = newWriteableStream<Uint8Array>(chunks => BinaryBuffer.concat(chunks.map(chunk => BinaryBuffer.wrap(chunk))).buffer);
readFileIntoStream(this, resource, stream, data => data.buffer, {
...opts,
bufferSize: this.BUFFER_SIZE
}, token);
return stream;
}
/**
* Writes binary content to a file
*/
async writeFile(resource: URI, content: Uint8Array, opts: FileWriteOptions): Promise<void> {
await this.initialized;
let handle: number | undefined = undefined;
if (!resource || !resource.path) {
throw createFileSystemProviderError('Invalid resource URI', FileSystemProviderErrorCode.FileNotFound);
}
if (!content || !(content instanceof Uint8Array)) {
throw createFileSystemProviderError('Invalid content: must be Uint8Array', FileSystemProviderErrorCode.Unknown);
}
try {
const path = formatPath(resource);
if (!opts.create || !opts.overwrite) {
const fileExists = await this.fs.exists(path);
if (fileExists) {
if (!opts.overwrite) {
throw createFileSystemProviderError('File already exists', FileSystemProviderErrorCode.FileExists);
}
} else if (!opts.create) {
throw createFileSystemProviderError('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 toFileSystemProviderError(error as Error | OPFSError);
} finally {
if (typeof handle === 'number') {
await this.close(handle);
}
}
}
// #region Open/Read/Write/Close Operations
/**
* Opens a file and returns a file descriptor
*/
async open(resource: URI, opts: FileOpenOptions): Promise<number> {
await this.initialized;
if (!resource || !resource.path) {
throw createFileSystemProviderError('Invalid resource URI', FileSystemProviderErrorCode.FileNotFound);
}
try {
const path = formatPath(resource);
const fileExists = await this.fs.exists(path);
if (!opts.create && !fileExists) {
throw createFileSystemProviderError('File does not exist', FileSystemProviderErrorCode.FileNotFound);
}
const fd = await this.fs.open(path, {
create: opts.create,
truncate: opts.create
});
return fd;
} catch (error) {
throw toFileSystemProviderError(error as Error | OPFSError);
}
}
/**
* Closes a file descriptor
*/
async close(fd: number): Promise<void> {
try {
await this.fs.close(fd);
} catch (error) {
throw toFileSystemProviderError(error as Error | OPFSError);
}
}
/**
* Reads data from a file descriptor
*/
async read(fd: number, pos: number, data: Uint8Array, offset: number, length: number): Promise<number> {
try {
const result = await this.fs.read(fd, data, offset, length, pos);
return result.bytesRead;
} catch (error) {
throw toFileSystemProviderError(error as Error | OPFSError);
}
}
/**
* Writes data to a file descriptor
*/
async write(fd: number, pos: number, data: Uint8Array, offset: number, length: number): Promise<number> {
try {
return await this.fs.write(fd, data, offset, length, pos, true);
} catch (error) {
throw toFileSystemProviderError(error as Error | OPFSError);
}
}
// #endregion
// #region Text File Updates
/**
* Updates a text file with content changes
*/
async updateFile(resource: URI, changes: TextDocumentContentChangeEvent[], opts: FileUpdateOptions): Promise<FileUpdateResult> {
try {
const content = await this.readFile(resource);
const decoded = this.encodingService.decode(BinaryBuffer.wrap(content), opts.readEncoding);
const newContent = TextDocument.update(TextDocument.create('', '', 1, decoded), changes, 2).getText();
const encoding = await this.encodingService.toResourceEncoding(opts.writeEncoding, {
overwriteEncoding: opts.overwriteEncoding,
read: async length => {
const fd = await this.open(resource, { create: false });
try {
const data = new Uint8Array(length);
await this.read(fd, 0, data, 0, length);
return data;
} finally {
await this.close(fd);
}
}
});
const encoded = this.encodingService.encode(newContent, encoding);
await this.writeFile(resource, encoded.buffer, { create: false, overwrite: true });
const stat = await this.stat(resource);
return Object.assign(stat, { encoding: encoding.encoding });
} catch (error) {
throw toFileSystemProviderError(error as Error | OPFSError);
}
}
// #endregion
/**
* Handles file system change events from BroadcastChannel
*/
private async handleFileSystemChange(event: MessageEvent<WatchEvent>): Promise<void> {
if (!event.data?.path) {
return;
}
const resource = new URI('file://' + event.data.path);
let changeType: FileChangeType;
if (event.data.type === WatchEventType.Added) {
changeType = FileChangeType.ADDED;
} else if (event.data.type === WatchEventType.Removed) {
changeType = FileChangeType.DELETED;
} else {
changeType = FileChangeType.UPDATED;
}
this.onDidChangeFileEmitter.fire([{ resource, type: changeType }]);
}
/**
* Disposes the file system provider
*/
dispose(): void {
this.toDispose.dispose();
}
}
/**
* Formats a URI or string resource to a file system path
*/
function formatPath(resource: URI | string): string {
return FileUri.fsPath(resource);
}
/**
* Creates a Stat object from OPFS stats
*/
function formatStat(stats: FileStat): Stat {
return {
type: stats.isDirectory ? FileType.Directory : FileType.File,
ctime: new Date(stats.ctime).getTime(),
mtime: new Date(stats.mtime).getTime(),
size: stats.size
};
}
/**
* Converts OPFS errors to file system provider errors
*/
function toFileSystemProviderError(error: OPFSError | Error): FileSystemProviderError {
if (error instanceof FileSystemProviderError) {
return error;
}
let code: FileSystemProviderErrorCode;
if (error.name === 'NotFoundError' || error.name === 'ENOENT') {
code = FileSystemProviderErrorCode.FileNotFound;
} else if (error.name === 'NotAllowedError' || error.name === 'SecurityError' || error.name === 'EACCES') {
code = FileSystemProviderErrorCode.NoPermissions;
} else if (error.name === 'QuotaExceededError' || error.name === 'ENOSPC') {
code = FileSystemProviderErrorCode.FileTooLarge;
} else if (error.name === 'PathError' || error.name === 'INVALID_PATH') {
code = FileSystemProviderErrorCode.FileNotADirectory;
} else {
code = FileSystemProviderErrorCode.Unknown;
}
return createFileSystemProviderError(error, code);
}

View File

@@ -0,0 +1,408 @@
// *****************************************************************************
// Copyright (C) 2025 Maksim Kachurin
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { CancellationTokenSource, CancellationToken, checkCancelled, isCancelled } from '@theia/core/lib/common/cancellation';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { MessageService } from '@theia/core/lib/common/message-service';
import { Progress } from '@theia/core/lib/common/message-service-protocol';
import throttle = require('@theia/core/shared/lodash.throttle');
import { Semaphore } from 'async-mutex';
import { FileService } from '../../browser/file-service';
import { ConfirmDialog, Dialog } from '@theia/core/lib/browser';
import { nls } from '@theia/core/lib/common/nls';
import { Emitter, Event } from '@theia/core/lib/common/event';
import { FileSystemPreferences } from '../../common/filesystem-preferences';
import { fileToStream } from '@theia/core/lib/common/stream';
import { minimatch } from 'minimatch';
import type { FileUploadService } from '../../common/upload/file-upload';
interface UploadState {
uploaded?: boolean;
failed?: boolean;
}
interface UploadFilesParams {
source: FileUploadService.Source,
progress: Progress,
token: CancellationToken,
onDidUpload?: (uri: string) => void,
}
@injectable()
export class FileUploadServiceImpl implements FileUploadService {
static TARGET = 'target';
static UPLOAD = 'upload';
protected readonly onDidUploadEmitter = new Emitter<string[]>();
protected uploadForm: FileUploadService.Form;
protected deferredUpload?: Deferred<FileUploadService.UploadResult>;
@inject(FileSystemPreferences)
protected fileSystemPreferences: FileSystemPreferences;
@inject(FileService)
protected fileService: FileService;
@inject(MessageService)
protected readonly messageService: MessageService;
private readonly ignorePatterns: string[] = [];
get onDidUpload(): Event<string[]> {
return this.onDidUploadEmitter.event;
}
get maxConcurrentUploads(): number {
const maxConcurrentUploads = this.fileSystemPreferences['files.maxConcurrentUploads'];
return maxConcurrentUploads > 0 ? maxConcurrentUploads : Infinity;
}
@postConstruct()
protected init(): void {
this.uploadForm = this.createUploadForm();
}
protected createUploadForm(): FileUploadService.Form {
const targetInput = document.createElement('input');
targetInput.type = 'text';
targetInput.spellcheck = false;
targetInput.name = FileUploadServiceImpl.TARGET;
targetInput.classList.add('theia-input');
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.classList.add('theia-input');
fileInput.name = FileUploadServiceImpl.UPLOAD;
fileInput.multiple = true;
const form = document.createElement('form');
form.style.display = 'none';
form.enctype = 'multipart/form-data';
form.append(targetInput);
form.append(fileInput);
document.body.appendChild(form);
fileInput.addEventListener('change', () => {
if (this.deferredUpload && fileInput.value) {
const source: FileUploadService.Source = new FormData(form);
// clean up for reuse
fileInput.value = '';
const targetUri = new URI(<string>source.get(FileUploadServiceImpl.TARGET));
const { resolve, reject } = this.deferredUpload;
this.deferredUpload = undefined;
const { onDidUpload } = this.uploadForm;
this.withProgress(
(progress, token) => this.uploadFiles(targetUri, { source, progress, token, onDidUpload })
).then(resolve, reject);
}
});
return { targetInput, fileInput };
}
async upload(targetUri: string | URI, params: FileUploadService.UploadParams): Promise<FileUploadService.UploadResult> {
const { source, onDidUpload } = params || {};
if (source) {
return this.withProgress(
(progress, token) => this.uploadFiles(
typeof targetUri === 'string' ? new URI(targetUri) : targetUri,
{ source, progress, token, onDidUpload }
)
);
}
this.deferredUpload = new Deferred<FileUploadService.UploadResult>();
this.uploadForm.targetInput.value = String(targetUri);
this.uploadForm.fileInput.click();
this.uploadForm.onDidUpload = onDidUpload;
return this.deferredUpload.promise;
}
protected async withProgress<T>(
cb: (progress: Progress, token: CancellationToken) => Promise<T>
): Promise<T> {
const cancellationSource = new CancellationTokenSource();
const { token } = cancellationSource;
const text = nls.localize('theia/filesystem/uploadFiles', 'Saving Files');
const progress = await this.messageService.showProgress(
{ text, options: { cancelable: true } },
() => cancellationSource.cancel()
);
try {
return await cb(progress, token);
} finally {
progress.cancel();
}
}
protected async confirmOverwrite(fileUri: URI): Promise<boolean> {
const dialog = new ConfirmDialog({
title: nls.localizeByDefault('Replace'),
msg: nls.localizeByDefault("A file or folder with the name '{0}' already exists in the destination folder. Do you want to replace it?", fileUri.path.base),
ok: nls.localizeByDefault('Replace'),
cancel: Dialog.CANCEL
});
return !!await dialog.open();
}
/**
* Upload all files to the filesystem
*/
protected async uploadFiles(targetUri: URI, params: UploadFilesParams): Promise<FileUploadService.UploadResult> {
const status = new Map<URI, UploadState>();
const report = throttle(() => {
const list = Array.from(status.values());
const total = list.length;
const done = list.filter(item => item.uploaded).length;
params.progress.report({
message: nls.localize('theia/filesystem/processedOutOf', 'Processed {0} out of {1}', done, total),
work: { total, done }
});
}, 100);
const uploads: Promise<void>[] = [];
const uploadSemaphore = new Semaphore(this.maxConcurrentUploads);
try {
const files = await this.enumerateFiles(targetUri, params.source, params.token);
for (const { file, uri } of files) {
checkCancelled(params.token);
// Check exists and confirm overwrite before adding to queue
if (await this.fileService.exists(uri) && !await this.confirmOverwrite(uri)) {
continue;
}
status.set(uri, {
uploaded: false
});
report();
uploads.push(uploadSemaphore.runExclusive(async () => {
const entry = status.get(uri);
try {
checkCancelled(params.token);
await this.uploadFile(file, uri);
if (entry) {
entry.uploaded = true;
report();
}
if (params.onDidUpload) {
params.onDidUpload(uri.toString(true));
}
} catch (error) {
if (entry) {
entry.failed = true;
report();
}
throw error;
}
}));
}
checkCancelled(params.token);
await Promise.all(uploads);
} catch (error) {
uploadSemaphore.cancel();
if (!isCancelled(error)) {
this.messageService.error(nls.localize('theia/filesystem/uploadFailed', 'An error occurred while saving a file. {0}', error.message));
throw error;
}
}
const uploaded = Array.from(status.keys()).map(uri => uri.toString(true));
this.onDidUploadEmitter.fire(uploaded);
return { uploaded };
}
/**
* Upload (write) a file directly to the filesystem
*/
protected async uploadFile(
file: File,
targetUri: URI
): Promise<void> {
await this.fileService.writeFile(targetUri, fileToStream(file));
}
/**
* Normalize sources into an array of { file, uri } objects
*/
protected async enumerateFiles(targetUri: URI, source: FileUploadService.Source, token: CancellationToken): Promise<{ file: File; uri: URI }[]> {
checkCancelled(token);
if (source instanceof FormData) {
// Handle FormData declaratively
const files = source.getAll(FileUploadServiceImpl.UPLOAD)
.filter((entry): entry is File => entry instanceof File)
.filter(entry => this.shouldIncludeFile(entry.name))
.map(entry => ({
file: entry,
uri: targetUri.resolve(entry.name)
}));
return files;
} else if (source instanceof DataTransfer) {
// Use WebKit Entries for folder traversal
if (source.items && this.supportsWebKitEntries()) {
// Collect all files first
const allFiles: { file: File; uri: URI }[] = [];
const items = Array.from(source.items);
const entries = items.map(item => item.webkitGetAsEntry()).filter((entry): entry is WebKitEntry => !!entry);
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const filesFromEntry = await this.traverseEntry(targetUri, entry!, token);
allFiles.push(...filesFromEntry);
}
return allFiles;
} else {
// Fall back to flat file list
return Array.from(source.files)
.filter((file): file is File => !!file)
.filter(file => this.shouldIncludeFile(file.name))
.map(file => ({
file,
uri: targetUri.resolve(file.name)
}));
}
} else {
// Handle CustomDataTransfer declaratively
const files = await Promise.all(
Array.from(source)
.map(async ([, item]) => {
const fileData = item.asFile();
if (fileData && this.shouldIncludeFile(fileData.name)) {
const data = await fileData.data();
return {
file: new File([data as BlobPart], fileData.name, { type: 'application/octet-stream' }),
uri: targetUri.resolve(fileData.name)
};
}
return undefined;
})
);
return files.filter(Boolean) as { file: File; uri: URI }[];
}
}
/**
* Traverse WebKit Entries (files and folders)
*/
protected async traverseEntry(
base: URI,
entry: WebKitEntry,
token: CancellationToken
): Promise<{ file: File; uri: URI }[]> {
if (!entry) {
return [];
}
// Skip system entries
if (!this.shouldIncludeFile(entry.name)) {
return [];
}
// directory
if (entry.isDirectory) {
const dir = entry as WebKitDirectoryEntry;
const newBase = base.resolve(dir.name);
const entries = await this.readAllEntries(dir, token);
checkCancelled(token);
const chunks = await Promise.all(
entries.map(sub => this.traverseEntry(newBase, sub, token))
);
return chunks.flat();
}
// file
const fileEntry = entry as WebKitFileEntry;
const file = await this.readFileEntry(fileEntry, token);
checkCancelled(token);
return [{ file, uri: base.resolve(entry.name) }];
}
/**
* Read all entries from a WebKit directory entry
*/
protected async readAllEntries(
dir: WebKitDirectoryEntry,
token: CancellationToken
): Promise<WebKitEntry[]> {
const reader = dir.createReader();
const out: WebKitEntry[] = [];
while (true) {
checkCancelled(token);
const batch = await new Promise<WebKitEntry[]>((resolve, reject) =>
reader.readEntries(resolve, reject)
);
if (!batch.length) {break; }
out.push(...batch);
// yield to the event loop to keep UI responsive
await Promise.resolve();
}
return out;
}
/**
* Read a file from a WebKit file entry
*/
protected async readFileEntry(
fileEntry: WebKitFileEntry,
token: CancellationToken
): Promise<File> {
checkCancelled(token);
try {
return await new Promise<File>((resolve, reject) => fileEntry.file(resolve, reject));
} catch (err) {
throw err;
}
}
protected supportsWebKitEntries(): boolean {
return typeof DataTransferItem.prototype.webkitGetAsEntry === 'function';
}
protected shouldIncludeFile(path: string): boolean {
return !this.ignorePatterns.some((pattern: string) => minimatch(path, pattern));
}
}

View File

@@ -0,0 +1,43 @@
// *****************************************************************************
// Copyright (C) 2019 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import URI from '@theia/core/lib/common/uri';
import { Breadcrumb } from '@theia/core/lib/browser/breadcrumbs/breadcrumbs-constants';
import { FilepathBreadcrumbType } from './filepath-breadcrumbs-contribution';
export class FilepathBreadcrumb implements Breadcrumb {
constructor(
readonly uri: URI,
readonly label: string,
readonly longLabel: string,
readonly iconClass: string,
readonly containerClass: string,
) { }
get id(): string {
return this.type.toString() + '_' + this.uri.toString();
}
get type(): symbol {
return FilepathBreadcrumbType;
}
}
export namespace FilepathBreadcrumb {
export function is(breadcrumb: Breadcrumb): breadcrumb is FilepathBreadcrumb {
return 'uri' in breadcrumb;
}
}

View File

@@ -0,0 +1,62 @@
// *****************************************************************************
// Copyright (C) 2019 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { Container, interfaces, injectable, inject } from '@theia/core/shared/inversify';
import { TreeProps, ContextMenuRenderer, TreeNode, open, NodeProps, defaultTreeProps } from '@theia/core/lib/browser';
import { FileTreeModel, FileStatNode, createFileTreeContainer, FileTreeWidget } from '../file-tree';
const BREADCRUMBS_FILETREE_CLASS = 'theia-FilepathBreadcrumbFileTree';
export function createFileTreeBreadcrumbsContainer(parent: interfaces.Container): Container {
const child = createFileTreeContainer(parent);
child.unbind(FileTreeWidget);
child.rebind(TreeProps).toConstantValue({ ...defaultTreeProps, virtualized: false });
child.bind(BreadcrumbsFileTreeWidget).toSelf();
return child;
}
export function createFileTreeBreadcrumbsWidget(parent: interfaces.Container): BreadcrumbsFileTreeWidget {
return createFileTreeBreadcrumbsContainer(parent).get(BreadcrumbsFileTreeWidget);
}
@injectable()
export class BreadcrumbsFileTreeWidget extends FileTreeWidget {
constructor(
@inject(TreeProps) props: TreeProps,
@inject(FileTreeModel) override readonly model: FileTreeModel,
@inject(ContextMenuRenderer) contextMenuRenderer: ContextMenuRenderer
) {
super(props, model, contextMenuRenderer);
this.addClass(BREADCRUMBS_FILETREE_CLASS);
}
protected override createNodeAttributes(node: TreeNode, props: NodeProps): React.Attributes & React.HTMLAttributes<HTMLElement> {
const elementAttrs = super.createNodeAttributes(node, props);
return {
...elementAttrs,
draggable: false
};
}
protected override tapNode(node?: TreeNode): void {
if (FileStatNode.is(node) && !node.fileStat.isDirectory) {
open(this.openerService, node.uri, { preview: true });
} else {
super.tapNode(node);
}
}
}

View File

@@ -0,0 +1,129 @@
// *****************************************************************************
// Copyright (C) 2019 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { Disposable, Emitter, Event } from '@theia/core';
import { injectable, inject } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { Breadcrumb, BreadcrumbsContribution, CompositeTreeNode, LabelProvider, SelectableTreeNode, Widget } from '@theia/core/lib/browser';
import { FilepathBreadcrumb } from './filepath-breadcrumb';
import { BreadcrumbsFileTreeWidget } from './filepath-breadcrumbs-container';
import { DirNode } from '../file-tree';
import { FileService } from '../file-service';
import { FileStat } from '../../common/files';
export const FilepathBreadcrumbType = Symbol('FilepathBreadcrumb');
export interface FilepathBreadcrumbClassNameFactory {
(location: URI, index: number): string;
}
@injectable()
export class FilepathBreadcrumbsContribution implements BreadcrumbsContribution {
@inject(LabelProvider)
protected readonly labelProvider: LabelProvider;
@inject(FileService)
protected readonly fileSystem: FileService;
@inject(BreadcrumbsFileTreeWidget)
protected readonly breadcrumbsFileTreeWidget: BreadcrumbsFileTreeWidget;
protected readonly onDidChangeBreadcrumbsEmitter = new Emitter<URI>();
get onDidChangeBreadcrumbs(): Event<URI> {
return this.onDidChangeBreadcrumbsEmitter.event;
}
readonly type = FilepathBreadcrumbType;
readonly priority: number = 100;
async computeBreadcrumbs(uri: URI): Promise<Breadcrumb[]> {
if (uri.scheme !== 'file') {
return [];
}
const getContainerClass = this.getContainerClassCreator(uri);
const getIconClass = this.getIconClassCreator(uri);
return uri.allLocations
.map((location, index) => {
const icon = getIconClass(location, index);
const containerClass = getContainerClass(location, index);
return new FilepathBreadcrumb(
location,
this.labelProvider.getName(location),
this.labelProvider.getLongName(location),
icon,
containerClass,
);
})
.filter(b => this.filterBreadcrumbs(uri, b))
.reverse();
}
protected getContainerClassCreator(fileURI: URI): FilepathBreadcrumbClassNameFactory {
return (location, index) => location.isEqual(fileURI) ? 'file' : 'folder';
}
protected getIconClassCreator(fileURI: URI): FilepathBreadcrumbClassNameFactory {
return (location, index) => location.isEqual(fileURI) ? this.labelProvider.getIcon(location) + ' file-icon' : '';
}
protected filterBreadcrumbs(_: URI, breadcrumb: FilepathBreadcrumb): boolean {
return !breadcrumb.uri.path.isRoot;
}
async attachPopupContent(breadcrumb: Breadcrumb, parent: HTMLElement): Promise<Disposable | undefined> {
if (!FilepathBreadcrumb.is(breadcrumb)) {
return undefined;
}
const folderFileStat = await this.fileSystem.resolve(breadcrumb.uri.parent);
if (folderFileStat) {
const rootNode = await this.createRootNode(folderFileStat);
if (rootNode) {
const { model } = this.breadcrumbsFileTreeWidget;
await model.navigateTo({ ...rootNode, visible: false });
Widget.attach(this.breadcrumbsFileTreeWidget, parent);
const toDisposeOnTreePopulated = model.onChanged(() => {
if (CompositeTreeNode.is(model.root) && model.root.children.length > 0) {
toDisposeOnTreePopulated.dispose();
const targetNode = model.getNode(breadcrumb.uri.path.toString());
if (targetNode && SelectableTreeNode.is(targetNode)) {
model.selectNode(targetNode);
}
this.breadcrumbsFileTreeWidget.activate();
}
});
return {
dispose: () => {
// Clear model otherwise the next time a popup is opened the old model is rendered first
// and is shown for a short time period.
toDisposeOnTreePopulated.dispose();
this.breadcrumbsFileTreeWidget.model.root = undefined;
Widget.detach(this.breadcrumbsFileTreeWidget);
}
};
}
}
}
protected async createRootNode(folderToOpen: FileStat): Promise<DirNode | undefined> {
const folderUri = folderToOpen.resource;
const rootUri = folderToOpen.isDirectory ? folderUri : folderUri.parent;
const rootStat = await this.fileSystem.resolve(rootUri);
if (rootStat) {
return DirNode.createRoot(rootStat);
}
}
}

View File

@@ -0,0 +1,83 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { isChrome } from '@theia/core/lib/browser/browser';
import { environment } from '@theia/core/shared/@theia/application-package/lib/environment';
import { SelectionService } from '@theia/core/lib/common/selection-service';
import { Command, CommandContribution, CommandRegistry } from '@theia/core/lib/common/command';
import { UriAwareCommandHandler } from '@theia/core/lib/common/uri-command-handler';
import { FileDownloadService } from '../../common/download/file-download';
import { CommonCommands } from '@theia/core/lib/browser';
@injectable()
export class FileDownloadCommandContribution implements CommandContribution {
@inject(FileDownloadService)
protected readonly downloadService: FileDownloadService;
@inject(SelectionService)
protected readonly selectionService: SelectionService;
registerCommands(registry: CommandRegistry): void {
registry.registerCommand(
FileDownloadCommands.DOWNLOAD,
UriAwareCommandHandler.MultiSelect(this.selectionService, {
execute: uris => this.executeDownload(uris),
isEnabled: uris => this.isDownloadEnabled(uris),
isVisible: uris => this.isDownloadVisible(uris),
})
);
registry.registerCommand(
FileDownloadCommands.COPY_DOWNLOAD_LINK,
UriAwareCommandHandler.MultiSelect(this.selectionService, {
execute: uris => this.executeDownload(uris, { copyLink: true }),
isEnabled: uris => isChrome && this.isDownloadEnabled(uris),
isVisible: uris => isChrome && this.isDownloadVisible(uris),
})
);
}
protected async executeDownload(uris: URI[], options?: { copyLink?: boolean }): Promise<void> {
this.downloadService.download(uris, options);
}
protected isDownloadEnabled(uris: URI[]): boolean {
return !environment.electron.is() && uris.length > 0 && uris.every(u => u.scheme === 'file');
}
protected isDownloadVisible(uris: URI[]): boolean {
return this.isDownloadEnabled(uris);
}
}
export namespace FileDownloadCommands {
export const DOWNLOAD = Command.toDefaultLocalizedCommand({
id: 'file.download',
category: CommonCommands.FILE_CATEGORY,
label: 'Download'
});
export const COPY_DOWNLOAD_LINK = Command.toLocalizedCommand({
id: 'file.copyDownloadLink',
category: CommonCommands.FILE_CATEGORY,
label: 'Copy Download Link'
}, 'theia/filesystem/copyDownloadLink', CommonCommands.FILE_CATEGORY_KEY);
}

View File

@@ -0,0 +1,26 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ContainerModule } from '@theia/core/shared/inversify';
import { CommandContribution } from '@theia/core/lib/common/command';
import { FileDownloadService } from '../../common/download/file-download';
import { FileDownloadServiceImpl } from './file-download-service';
import { FileDownloadCommandContribution } from './file-download-command-contribution';
export default new ContainerModule(bind => {
bind(FileDownloadService).to(FileDownloadServiceImpl).inSingletonScope();
bind(CommandContribution).to(FileDownloadCommandContribution).inSingletonScope();
});

View File

@@ -0,0 +1,174 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable, named } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { ILogger } from '@theia/core/lib/common/logger';
import { Endpoint } from '@theia/core/lib/browser/endpoint';
import { MessageService } from '@theia/core/lib/common/message-service';
import { addClipboardListener } from '@theia/core/lib/browser/widgets';
import { nls } from '@theia/core';
import type { FileDownloadData, FileDownloadService } from '../../common/download/file-download';
@injectable()
export class FileDownloadServiceImpl implements FileDownloadService {
protected anchor: HTMLAnchorElement | undefined;
protected downloadCounter: number = 0;
@inject(ILogger)
@named('file-download')
protected readonly logger: ILogger;
@inject(MessageService)
protected readonly messageService: MessageService;
protected handleCopy(event: ClipboardEvent, downloadUrl: string): void {
if (downloadUrl && event.clipboardData) {
event.clipboardData.setData('text/plain', downloadUrl);
event.preventDefault();
this.messageService.info(nls.localize('theia/filesystem/copiedToClipboard', 'Copied the download link to the clipboard.'));
}
}
async cancelDownload(id: string): Promise<void> {
await fetch(`${this.endpoint()}/download/?id=${id}&cancel=true`);
}
async download(uris: URI[], options?: FileDownloadService.DownloadOptions): Promise<void> {
let cancel = false;
if (uris.length === 0) {
return;
}
const copyLink = options && options.copyLink ? true : false;
try {
const text: string = copyLink ?
nls.localize('theia/filesystem/prepareDownloadLink', 'Preparing download link...') :
nls.localize('theia/filesystem/prepareDownload', 'Preparing download...');
const [progress, result] = await Promise.all([
this.messageService.showProgress({
text: text,
options: { cancelable: true }
}, () => { cancel = true; }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
new Promise<{ response: Response, jsonResponse: any }>(async resolve => {
const resp = await fetch(this.request(uris));
const jsonResp = await resp.json();
resolve({ response: resp, jsonResponse: jsonResp });
})
]);
const { response, jsonResponse } = result;
if (cancel) {
this.cancelDownload(jsonResponse.id);
return;
}
const { status, statusText } = response;
if (status === 200) {
progress.cancel();
const downloadUrl = `${this.endpoint()}/download/?id=${jsonResponse.id}`;
if (copyLink) {
if (document.documentElement) {
const toDispose = addClipboardListener(document.documentElement, 'copy', e => {
toDispose.dispose();
this.handleCopy(e, downloadUrl);
});
document.execCommand('copy');
}
} else {
this.forceDownload(jsonResponse.id, decodeURIComponent(jsonResponse.name));
}
} else {
throw new Error(`Received unexpected status code: ${status}. [${statusText}]`);
}
} catch (e) {
this.logger.error(`Error occurred when downloading: ${uris.map(u => u.toString(true))}.`, e);
}
}
protected async forceDownload(id: string, title: string): Promise<void> {
let url: string | undefined;
try {
if (this.anchor === undefined) {
this.anchor = document.createElement('a');
}
const endpoint = this.endpoint();
url = `${endpoint}/download/?id=${id}`;
this.anchor.href = url;
this.anchor.style.display = 'none';
this.anchor.download = title;
document.body.appendChild(this.anchor);
this.anchor.click();
} finally {
// make sure anchor is removed from parent
if (this.anchor && this.anchor.parentNode) {
this.anchor.parentNode.removeChild(this.anchor);
}
if (url) {
URL.revokeObjectURL(url);
}
}
}
protected request(uris: URI[]): Request {
const url = this.url(uris);
const init = this.requestInit(uris);
return new Request(url, init);
}
protected requestInit(uris: URI[]): RequestInit {
if (uris.length === 1) {
return {
body: undefined,
method: 'GET'
};
}
return {
method: 'PUT',
body: JSON.stringify(this.body(uris)),
headers: new Headers({ 'Content-Type': 'application/json' }),
};
}
protected body(uris: URI[]): FileDownloadData {
return {
uris: uris.map(u => u.toString(true))
};
}
protected url(uris: URI[]): string {
const endpoint = this.endpoint();
if (uris.length === 1) {
// tslint:disable-next-line:whitespace
const [uri,] = uris;
return `${endpoint}/?uri=${uri.toString()}`;
}
return endpoint;
}
protected endpoint(): string {
const url = this.filesUrl();
return url.endsWith('/') ? url.slice(0, -1) : url;
}
protected filesUrl(): string {
return new Endpoint({ path: 'files' }).getRestUrl().toString();
}
}

View File

@@ -0,0 +1,67 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { interfaces, Container } from '@theia/core/shared/inversify';
import { Tree, TreeModel, TreeProps, defaultTreeProps } from '@theia/core/lib/browser';
import { createFileTreeContainer, FileTreeModel, FileTreeWidget } from '../file-tree';
import { OpenFileDialog, OpenFileDialogProps, SaveFileDialog, SaveFileDialogProps } from './file-dialog';
import { FileDialogModel } from './file-dialog-model';
import { FileDialogWidget } from './file-dialog-widget';
import { FileDialogTree } from './file-dialog-tree';
export function createFileDialogContainer(parent: interfaces.Container): Container {
const child = createFileTreeContainer(parent);
child.unbind(FileTreeModel);
child.bind(FileDialogModel).toSelf();
child.rebind(TreeModel).toService(FileDialogModel);
child.unbind(FileTreeWidget);
child.bind(FileDialogWidget).toSelf();
child.bind(FileDialogTree).toSelf();
child.rebind(Tree).toService(FileDialogTree);
return child;
}
export function createOpenFileDialogContainer(parent: interfaces.Container, props: OpenFileDialogProps): Container {
const container = createFileDialogContainer(parent);
container.rebind(TreeProps).toConstantValue({
...defaultTreeProps,
multiSelect: props.canSelectMany,
search: true
});
container.bind(OpenFileDialogProps).toConstantValue(props);
container.bind(OpenFileDialog).toSelf();
return container;
}
export function createSaveFileDialogContainer(parent: interfaces.Container, props: SaveFileDialogProps): Container {
const container = createFileDialogContainer(parent);
container.rebind(TreeProps).toConstantValue({
...defaultTreeProps,
multiSelect: false,
search: true
});
container.bind(SaveFileDialogProps).toConstantValue(props);
container.bind(SaveFileDialog).toSelf();
return container;
}

View File

@@ -0,0 +1,59 @@
// *****************************************************************************
// Copyright (C) 2023 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { nls } from '@theia/core';
import { ReactRenderer } from '@theia/core/lib/browser';
import { inject, postConstruct } from '@theia/core/shared/inversify';
import * as React from '@theia/core/shared/react';
import { FileDialogTree } from './file-dialog-tree';
const TOGGLE_HIDDEN_PANEL_CLASS = 'theia-ToggleHiddenPanel';
const TOGGLE_HIDDEN_CONTAINER_CLASS = 'theia-ToggleHiddenInputContainer';
const CHECKBOX_CLASS = 'theia-ToggleHiddenInputCheckbox';
export const HiddenFilesToggleRendererFactory = Symbol('HiddenFilesToggleRendererFactory');
export interface HiddenFilesToggleRendererFactory {
(fileDialogTree: FileDialogTree): FileDialogHiddenFilesToggleRenderer;
}
export class FileDialogHiddenFilesToggleRenderer extends ReactRenderer {
@inject(FileDialogTree) fileDialogTree: FileDialogTree;
@postConstruct()
protected init(): void {
this.host.classList.add(TOGGLE_HIDDEN_PANEL_CLASS);
this.render();
}
protected readonly handleCheckboxChanged = (e: React.ChangeEvent<HTMLInputElement>): void => this.onCheckboxChanged(e);
protected override doRender(): React.ReactNode {
return (
<div className={TOGGLE_HIDDEN_CONTAINER_CLASS}>
{nls.localize('theia/fileDialog/showHidden', 'Show hidden files')}
<input
type='checkbox'
className={CHECKBOX_CLASS}
onChange={this.handleCheckboxChanged}
/>
</div>
);
}
protected onCheckboxChanged(e: React.ChangeEvent<HTMLInputElement>): void {
const { checked } = e.target;
this.fileDialogTree.showHidden = checked;
e.stopPropagation();
}
}

View File

@@ -0,0 +1,96 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { Emitter, Event } from '@theia/core/lib/common';
import { TreeNode, SelectableTreeNode } from '@theia/core/lib/browser';
import { DirNode, FileNode, FileTreeModel, FileStatNode } from '../file-tree';
import { FileDialogTree } from './file-dialog-tree';
@injectable()
export class FileDialogModel extends FileTreeModel {
@inject(FileDialogTree) override readonly tree: FileDialogTree;
protected readonly onDidOpenFileEmitter = new Emitter<void>();
protected _initialLocation: URI | undefined;
private _disableFileSelection: boolean = false;
@postConstruct()
protected override init(): void {
super.init();
this.toDispose.push(this.onDidOpenFileEmitter);
}
/**
* Returns the first valid location that was set by calling the `navigateTo` method. Once the initial location has a defined value, it will not change.
* Can be `undefined`.
*/
get initialLocation(): URI | undefined {
return this._initialLocation;
}
set disableFileSelection(isSelectable: boolean) {
this._disableFileSelection = isSelectable;
}
override async navigateTo(nodeOrId: TreeNode | string | undefined): Promise<TreeNode | undefined> {
const result = await super.navigateTo(nodeOrId);
if (!this._initialLocation && FileStatNode.is(result)) {
this._initialLocation = result.uri;
}
return result;
}
get onDidOpenFile(): Event<void> {
return this.onDidOpenFileEmitter.event;
}
protected override doOpenNode(node: TreeNode): void {
if (FileNode.is(node)) {
this.onDidOpenFileEmitter.fire(undefined);
} else if (DirNode.is(node)) {
this.navigateTo(node);
} else {
super.doOpenNode(node);
}
}
override getNextSelectableNode(node: SelectableTreeNode | undefined = this.getFocusedNode()): SelectableTreeNode | undefined {
let nextNode: SelectableTreeNode | undefined = node;
do {
nextNode = super.getNextSelectableNode(nextNode);
} while (FileStatNode.is(nextNode) && !this.isFileStatNodeSelectable(nextNode));
return nextNode;
}
override getPrevSelectableNode(node: SelectableTreeNode | undefined = this.getFocusedNode()): SelectableTreeNode | undefined {
let prevNode: SelectableTreeNode | undefined = node;
do {
prevNode = super.getPrevSelectableNode(prevNode);
} while (FileStatNode.is(prevNode) && !this.isFileStatNodeSelectable(prevNode));
return prevNode;
}
private isFileStatNodeSelectable(node: FileStatNode): boolean {
return !(!node.fileStat.isDirectory && this._disableFileSelection);
}
canNavigateUpward(): boolean {
const treeRoot = this.tree.root;
return FileStatNode.is(treeRoot) && !treeRoot.uri.path.isRoot;
}
}

View File

@@ -0,0 +1,44 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ContainerModule } from '@theia/core/shared/inversify';
import { LocationListRenderer, LocationListRendererFactory, LocationListRendererOptions } from '../location';
import { FileDialogHiddenFilesToggleRenderer, HiddenFilesToggleRendererFactory } from './file-dialog-hidden-files-renderer';
import { DefaultFileDialogService, FileDialogService } from './file-dialog-service';
import { FileDialogTree } from './file-dialog-tree';
import { FileDialogTreeFiltersRenderer, FileDialogTreeFiltersRendererFactory, FileDialogTreeFiltersRendererOptions } from './file-dialog-tree-filters-renderer';
export default new ContainerModule(bind => {
bind(DefaultFileDialogService).toSelf().inSingletonScope();
bind(FileDialogService).toService(DefaultFileDialogService);
bind(LocationListRendererFactory).toFactory(context => (options: LocationListRendererOptions) => {
const childContainer = context.container.createChild();
childContainer.bind(LocationListRendererOptions).toConstantValue(options);
childContainer.bind(LocationListRenderer).toSelf().inSingletonScope();
return childContainer.get(LocationListRenderer);
});
bind(FileDialogTreeFiltersRendererFactory).toFactory(context => (options: FileDialogTreeFiltersRendererOptions) => {
const childContainer = context.container.createChild();
childContainer.bind(FileDialogTreeFiltersRendererOptions).toConstantValue(options);
childContainer.bind(FileDialogTreeFiltersRenderer).toSelf().inSingletonScope();
return childContainer.get(FileDialogTreeFiltersRenderer);
});
bind(HiddenFilesToggleRendererFactory).toFactory(({ container }) => (fileDialogTree: FileDialogTree) => {
const child = container.createChild();
child.bind(FileDialogTree).toConstantValue(fileDialogTree);
child.bind(FileDialogHiddenFilesToggleRenderer).toSelf().inSingletonScope();
return child.get(FileDialogHiddenFilesToggleRenderer);
});
});

View File

@@ -0,0 +1,99 @@
// *****************************************************************************
// Copyright (C) 2018 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { MaybeArray, UNTITLED_SCHEME, nls } from '@theia/core/lib/common';
import { LabelProvider } from '@theia/core/lib/browser';
import { FileStat } from '../../common/files';
import { DirNode } from '../file-tree';
import { OpenFileDialogFactory, OpenFileDialogProps, SaveFileDialogFactory, SaveFileDialogProps } from './file-dialog';
import { FileService } from '../file-service';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import { UserWorkingDirectoryProvider } from '@theia/core/lib/browser/user-working-directory-provider';
export const FileDialogService = Symbol('FileDialogService');
export interface FileDialogService {
showOpenDialog(props: OpenFileDialogProps & { canSelectMany: true }, folder?: FileStat): Promise<MaybeArray<URI> | undefined>;
showOpenDialog(props: OpenFileDialogProps, folder?: FileStat): Promise<URI | undefined>;
showOpenDialog(props: OpenFileDialogProps, folder?: FileStat): Promise<MaybeArray<URI> | undefined>;
showSaveDialog(props: SaveFileDialogProps, folder?: FileStat): Promise<URI | undefined>
}
@injectable()
export class DefaultFileDialogService implements FileDialogService {
@inject(EnvVariablesServer)
protected readonly environments: EnvVariablesServer;
@inject(FileService)
protected readonly fileService: FileService;
@inject(OpenFileDialogFactory) protected readonly openFileDialogFactory: OpenFileDialogFactory;
@inject(LabelProvider) protected readonly labelProvider: LabelProvider;
@inject(SaveFileDialogFactory) protected readonly saveFileDialogFactory: SaveFileDialogFactory;
@inject(UserWorkingDirectoryProvider) protected readonly rootProvider: UserWorkingDirectoryProvider;
async showOpenDialog(props: OpenFileDialogProps & { canSelectMany: true }, folder?: FileStat): Promise<MaybeArray<URI> | undefined>;
async showOpenDialog(props: OpenFileDialogProps, folder?: FileStat): Promise<URI | undefined>;
async showOpenDialog(props: OpenFileDialogProps, folder?: FileStat): Promise<MaybeArray<URI> | undefined> {
const title = props.title || nls.localizeByDefault('Open');
const rootNode = await this.getRootNode(folder);
if (rootNode) {
const dialog = this.openFileDialogFactory(Object.assign(props, { title }));
await dialog.model.navigateTo(rootNode);
const value = await dialog.open();
if (value) {
if (!Array.isArray(value)) {
return value.uri;
}
return value.map(node => node.uri);
}
}
return undefined;
}
async showSaveDialog(props: SaveFileDialogProps, folder?: FileStat): Promise<URI | undefined> {
const title = props.title || nls.localizeByDefault('Save');
const rootNode = await this.getRootNode(folder);
if (rootNode) {
const dialog = this.saveFileDialogFactory(Object.assign(props, { title }));
await dialog.model.navigateTo(rootNode);
return dialog.open();
}
return undefined;
}
protected async getRootNode(folderToOpen?: FileStat): Promise<DirNode | undefined> {
const folderExists = folderToOpen
&& folderToOpen.resource.scheme !== UNTITLED_SCHEME
&& await this.fileService.exists(folderToOpen.resource);
const folder = folderToOpen && folderExists ? folderToOpen : {
resource: await this.rootProvider.getUserWorkingDir(),
isDirectory: true
};
const folderUri = folder.resource;
const rootUri = folder.isDirectory ? folderUri : folderUri.parent;
try {
const rootStat = await this.fileService.resolve(rootUri);
return DirNode.createRoot(rootStat);
} catch { }
return undefined;
}
}

View File

@@ -0,0 +1,100 @@
// *****************************************************************************
// Copyright (C) 2018 Red Hat, Inc. and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ReactRenderer } from '@theia/core/lib/browser/widgets/react-renderer';
import { FileDialogTree } from './file-dialog-tree';
import * as React from '@theia/core/shared/react';
import { inject, injectable } from '@theia/core/shared/inversify';
export const FILE_TREE_FILTERS_LIST_CLASS = 'theia-FileTreeFiltersList';
/**
* A set of file filters that are used by the dialog. Each entry is a human readable label,
* like "TypeScript", and an array of extensions, e.g.
* ```ts
* {
* 'Images': ['png', 'jpg']
* 'TypeScript': ['ts', 'tsx']
* }
* ```
*/
export class FileDialogTreeFilters {
[name: string]: string[];
}
export const FileDialogTreeFiltersRendererFactory = Symbol('FileDialogTreeFiltersRendererFactory');
export interface FileDialogTreeFiltersRendererFactory {
(options: FileDialogTreeFiltersRendererOptions): FileDialogTreeFiltersRenderer;
}
export const FileDialogTreeFiltersRendererOptions = Symbol('FileDialogTreeFiltersRendererOptions');
export interface FileDialogTreeFiltersRendererOptions {
suppliedFilters: FileDialogTreeFilters;
fileDialogTree: FileDialogTree;
}
@injectable()
export class FileDialogTreeFiltersRenderer extends ReactRenderer {
readonly appliedFilters: FileDialogTreeFilters;
readonly suppliedFilters: FileDialogTreeFilters;
readonly fileDialogTree: FileDialogTree;
constructor(
@inject(FileDialogTreeFiltersRendererOptions) readonly options: FileDialogTreeFiltersRendererOptions
) {
super();
this.suppliedFilters = options.suppliedFilters;
this.fileDialogTree = options.fileDialogTree;
this.appliedFilters = { ...this.suppliedFilters, 'All Files': [], };
}
protected readonly handleFilterChanged = (e: React.ChangeEvent<HTMLSelectElement>) => this.onFilterChanged(e);
protected override doRender(): React.ReactNode {
if (!this.appliedFilters) {
return undefined;
}
const options = Object.keys(this.appliedFilters).map(value => this.renderLocation(value));
return <select className={'theia-select ' + FILE_TREE_FILTERS_LIST_CLASS} onChange={this.handleFilterChanged}>{...options}</select>;
}
protected renderLocation(value: string): React.ReactNode {
return <option value={value} key={value}>{value}</option>;
}
protected onFilterChanged(e: React.ChangeEvent<HTMLSelectElement>): void {
const locationList = this.locationList;
if (locationList) {
const value = locationList.value;
const filters = this.appliedFilters[value];
this.fileDialogTree.setFilter(filters);
}
e.preventDefault();
e.stopPropagation();
}
get locationList(): HTMLSelectElement | undefined {
const locationList = this.host.getElementsByClassName(FILE_TREE_FILTERS_LIST_CLASS)[0];
if (locationList instanceof HTMLSelectElement) {
return locationList;
}
return undefined;
}
}

View File

@@ -0,0 +1,89 @@
// *****************************************************************************
// Copyright (C) 2018 Red Hat, Inc. and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable } from '@theia/core/shared/inversify';
import { DirNode, FileTree } from '../file-tree';
import { TreeNode, CompositeTreeNode } from '@theia/core/lib/browser/tree/tree';
import { FileStat } from '../../common/files';
@injectable()
export class FileDialogTree extends FileTree {
protected _showHidden = false;
set showHidden(show: boolean) {
this._showHidden = show;
this.refresh();
}
get showHidden(): boolean {
return this._showHidden;
}
protected isHiddenFile = (fileStat: FileStat): boolean => {
const { name } = fileStat;
const filename = name ?? '';
const isHidden = filename.startsWith('.');
return isHidden;
};
/**
* Extensions for files to be shown
*/
protected fileExtensions: string[] = [];
/**
* Sets extensions for filtering files
*
* @param fileExtensions array of extensions
*/
setFilter(fileExtensions: string[]): void {
this.fileExtensions = fileExtensions.slice();
this.refresh();
}
protected override async toNodes(fileStat: FileStat, parent: CompositeTreeNode): Promise<TreeNode[]> {
if (!fileStat.children) {
return [];
}
const result = await Promise.all(
fileStat.children
.filter(child => this.isVisible(child))
.map(child => this.toNode(child, parent))
);
return result.sort(DirNode.compare);
}
/**
* Determines whether file or folder can be shown
*
* @param fileStat resource to check
*/
protected isVisible(fileStat: FileStat): boolean {
if (!this._showHidden && this.isHiddenFile(fileStat)) {
return false;
}
if (fileStat.isDirectory) {
return true;
}
if (this.fileExtensions.length === 0) {
return true;
}
return !this.fileExtensions.every(value => fileStat.resource.path.ext !== '.' + value);
}
}

View File

@@ -0,0 +1,86 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject } from '@theia/core/shared/inversify';
import { ContextMenuRenderer, NodeProps, TreeProps, TreeNode, SELECTED_CLASS, FOCUS_CLASS } from '@theia/core/lib/browser';
import { FileTreeWidget, FileStatNode } from '../file-tree';
import { FileDialogModel } from './file-dialog-model';
export const FILE_DIALOG_CLASS = 'theia-FileDialog';
export const NOT_SELECTABLE_CLASS = 'theia-mod-not-selectable';
@injectable()
export class FileDialogWidget extends FileTreeWidget {
private _disableFileSelection: boolean = false;
constructor(
@inject(TreeProps) props: TreeProps,
@inject(FileDialogModel) override readonly model: FileDialogModel,
@inject(ContextMenuRenderer) contextMenuRenderer: ContextMenuRenderer
) {
super(props, model, contextMenuRenderer);
this.addClass(FILE_DIALOG_CLASS);
}
set disableFileSelection(isSelectable: boolean) {
this._disableFileSelection = isSelectable;
this.model.disableFileSelection = isSelectable;
}
protected override createNodeAttributes(node: TreeNode, props: NodeProps): React.Attributes & React.HTMLAttributes<HTMLElement> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const attr = super.createNodeAttributes(node, props) as any;
if (this.shouldDisableSelection(node)) {
const keys = Object.keys(attr);
keys.forEach(k => {
if (['className', 'style', 'title'].indexOf(k) < 0) {
delete attr[k];
}
});
}
return attr;
}
protected override handleEnter(event: KeyboardEvent): boolean | void {
// Handle ENTER in the dialog to Accept.
// Tree view will just expand/collapse the node. This works also with arrow keys or SPACE.
return false;
}
protected override handleEscape(event: KeyboardEvent): boolean | void {
// Handle ESC in the dialog to Cancel and close the Dialog.
return false;
}
protected override createNodeClassNames(node: TreeNode, props: NodeProps): string[] {
const classNames = super.createNodeClassNames(node, props);
if (this.shouldDisableSelection(node)) {
[SELECTED_CLASS, FOCUS_CLASS].forEach(name => {
const ind = classNames.indexOf(name);
if (ind >= 0) {
classNames.splice(ind, 1);
}
});
classNames.push(NOT_SELECTABLE_CLASS);
}
return classNames;
}
protected shouldDisableSelection(node: TreeNode): boolean {
return FileStatNode.is(node) && !node.fileStat.isDirectory && this._disableFileSelection;
}
}

View File

@@ -0,0 +1,434 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import { Message } from '@theia/core/shared/@lumino/messaging';
import { Disposable, MaybeArray, nls } from '@theia/core/lib/common';
import { AbstractDialog, DialogProps, setEnabled, createIconButton, Widget, codiconArray, Key, LabelProvider } from '@theia/core/lib/browser';
import { FileStatNode } from '../file-tree';
import { LocationListRenderer, LocationListRendererFactory } from '../location';
import { FileDialogModel } from './file-dialog-model';
import { FileDialogWidget } from './file-dialog-widget';
import { FileDialogTreeFiltersRenderer, FileDialogTreeFilters, FileDialogTreeFiltersRendererFactory } from './file-dialog-tree-filters-renderer';
import URI from '@theia/core/lib/common/uri';
import { Panel } from '@theia/core/shared/@lumino/widgets';
import * as DOMPurify from '@theia/core/shared/dompurify';
import { FileDialogHiddenFilesToggleRenderer, HiddenFilesToggleRendererFactory } from './file-dialog-hidden-files-renderer';
export const OpenFileDialogFactory = Symbol('OpenFileDialogFactory');
export interface OpenFileDialogFactory {
(props: OpenFileDialogProps): OpenFileDialog;
}
export const SaveFileDialogFactory = Symbol('SaveFileDialogFactory');
export interface SaveFileDialogFactory {
(props: SaveFileDialogProps): SaveFileDialog;
}
export const SAVE_DIALOG_CLASS = 'theia-SaveFileDialog';
export const NAVIGATION_PANEL_CLASS = 'theia-NavigationPanel';
export const NAVIGATION_BACK_CLASS = 'theia-NavigationBack';
export const NAVIGATION_FORWARD_CLASS = 'theia-NavigationForward';
export const NAVIGATION_HOME_CLASS = 'theia-NavigationHome';
export const NAVIGATION_UP_CLASS = 'theia-NavigationUp';
export const NAVIGATION_LOCATION_LIST_PANEL_CLASS = 'theia-LocationListPanel';
export const FILTERS_PANEL_CLASS = 'theia-FiltersPanel';
export const FILTERS_LABEL_CLASS = 'theia-FiltersLabel';
export const FILTERS_LIST_PANEL_CLASS = 'theia-FiltersListPanel';
export const FILENAME_PANEL_CLASS = 'theia-FileNamePanel';
export const FILENAME_LABEL_CLASS = 'theia-FileNameLabel';
export const FILENAME_TEXTFIELD_CLASS = 'theia-FileNameTextField';
export const CONTROL_PANEL_CLASS = 'theia-ControlPanel';
export const TOOLBAR_ITEM_TRANSFORM_TIMEOUT = 100;
export class FileDialogProps extends DialogProps {
/**
* A set of file filters that are used by the dialog. Each entry is a human readable label,
* like "TypeScript", and an array of extensions, e.g.
* ```ts
* {
* 'Images': ['png', 'jpg']
* 'TypeScript': ['ts', 'tsx']
* }
* ```
*/
filters?: FileDialogTreeFilters;
/**
* Determines if the dialog window should be modal.
* Defaults to `true`.
*/
modal?: boolean;
}
@injectable()
export class OpenFileDialogProps extends FileDialogProps {
/**
* A human-readable string for the accept button.
*/
openLabel?: string;
/**
* Allow to select files, defaults to `true`.
*/
canSelectFiles?: boolean;
/**
* Allow to select folders, defaults to `false`.
*/
canSelectFolders?: boolean;
/**
* Allow to select many files or folders.
*/
canSelectMany?: boolean;
}
@injectable()
export class SaveFileDialogProps extends FileDialogProps {
/**
* A human-readable string for the accept button.
*/
saveLabel?: string;
/**
* A human-readable value for the input.
*/
inputValue?: string;
}
export abstract class FileDialog<T> extends AbstractDialog<T> {
protected back: HTMLSpanElement;
protected forward: HTMLSpanElement;
protected home: HTMLSpanElement;
protected up: HTMLSpanElement;
protected locationListRenderer: LocationListRenderer;
protected treeFiltersRenderer: FileDialogTreeFiltersRenderer | undefined;
protected hiddenFilesToggleRenderer: FileDialogHiddenFilesToggleRenderer;
protected treePanel: Panel;
@inject(FileDialogWidget) readonly widget: FileDialogWidget;
@inject(LocationListRendererFactory) readonly locationListFactory: LocationListRendererFactory;
@inject(FileDialogTreeFiltersRendererFactory) readonly treeFiltersFactory: FileDialogTreeFiltersRendererFactory;
@inject(HiddenFilesToggleRendererFactory) readonly hiddenFilesToggleFactory: HiddenFilesToggleRendererFactory;
constructor(
@inject(FileDialogProps) override readonly props: FileDialogProps
) {
super(props);
}
@postConstruct()
init(): void {
this.treePanel = new Panel();
this.treePanel.addWidget(this.widget);
this.toDispose.push(this.treePanel);
this.toDispose.push(this.model.onChanged(() => this.update()));
this.toDispose.push(this.model.onDidOpenFile(() => this.accept()));
this.toDispose.push(this.model.onSelectionChanged(() => this.update()));
const navigationPanel = document.createElement('div');
navigationPanel.classList.add(NAVIGATION_PANEL_CLASS);
this.contentNode.appendChild(navigationPanel);
navigationPanel.appendChild(this.back = createIconButton(...codiconArray('chevron-left', true)));
this.back.classList.add(NAVIGATION_BACK_CLASS);
this.back.title = nls.localize('theia/filesystem/dialog/navigateBack', 'Navigate Back');
navigationPanel.appendChild(this.forward = createIconButton(...codiconArray('chevron-right', true)));
this.forward.classList.add(NAVIGATION_FORWARD_CLASS);
this.forward.title = nls.localize('theia/filesystem/dialog/navigateForward', 'Navigate Forward');
navigationPanel.appendChild(this.home = createIconButton(...codiconArray('home', true)));
this.home.classList.add(NAVIGATION_HOME_CLASS);
this.home.title = nls.localize('theia/filesystem/dialog/initialLocation', 'Go To Initial Location');
navigationPanel.appendChild(this.up = createIconButton(...codiconArray('arrow-up', true)));
this.up.classList.add(NAVIGATION_UP_CLASS);
this.up.title = nls.localize('theia/filesystem/dialog/navigateUp', 'Navigate Up One Directory');
const locationListRendererHost = document.createElement('div');
this.locationListRenderer = this.locationListFactory({ model: this.model, host: locationListRendererHost });
this.toDispose.push(this.locationListRenderer);
this.locationListRenderer.host.classList.add(NAVIGATION_LOCATION_LIST_PANEL_CLASS);
navigationPanel.appendChild(this.locationListRenderer.host);
this.hiddenFilesToggleRenderer = this.hiddenFilesToggleFactory(this.widget.model.tree);
this.contentNode.appendChild(this.hiddenFilesToggleRenderer.host);
if (this.props.filters) {
this.treeFiltersRenderer = this.treeFiltersFactory({ suppliedFilters: this.props.filters, fileDialogTree: this.widget.model.tree });
const filters = Object.keys(this.props.filters);
if (filters.length) {
this.widget.model.tree.setFilter(this.props.filters[filters[0]]);
}
}
}
get model(): FileDialogModel {
return this.widget.model;
}
protected override onUpdateRequest(msg: Message): void {
super.onUpdateRequest(msg);
setEnabled(this.back, this.model.canNavigateBackward());
setEnabled(this.forward, this.model.canNavigateForward());
setEnabled(this.home, !!this.model.initialLocation
&& !!this.model.location
&& this.model.initialLocation.toString() !== this.model.location.toString());
setEnabled(this.up, this.model.canNavigateUpward());
this.locationListRenderer.render();
if (this.treeFiltersRenderer) {
this.treeFiltersRenderer.render();
}
this.widget.update();
}
protected override handleEnter(event: KeyboardEvent): boolean | void {
if (event.target instanceof HTMLTextAreaElement || this.targetIsDirectoryInput(event.target) || this.targetIsInputToggle(event.target)) {
return false;
}
this.accept();
}
protected override handleEscape(event: KeyboardEvent): boolean | void {
if (event.target instanceof HTMLTextAreaElement || this.targetIsDirectoryInput(event.target)) {
return false;
}
this.close();
}
protected targetIsDirectoryInput(target: EventTarget | null): boolean {
return target instanceof HTMLInputElement && target.classList.contains(LocationListRenderer.Styles.LOCATION_TEXT_INPUT_CLASS);
}
protected targetIsInputToggle(target: EventTarget | null): boolean {
return target instanceof HTMLSpanElement && target.classList.contains(LocationListRenderer.Styles.LOCATION_INPUT_TOGGLE_CLASS);
}
protected appendFiltersPanel(): void {
if (this.treeFiltersRenderer) {
const filtersPanel = document.createElement('div');
filtersPanel.classList.add(FILTERS_PANEL_CLASS);
this.contentNode.appendChild(filtersPanel);
const titlePanel = document.createElement('div');
titlePanel.innerHTML = DOMPurify.sanitize(nls.localize('theia/filesystem/format', 'Format:'));
titlePanel.classList.add(FILTERS_LABEL_CLASS);
filtersPanel.appendChild(titlePanel);
this.treeFiltersRenderer.host.classList.add(FILTERS_LIST_PANEL_CLASS);
filtersPanel.appendChild(this.treeFiltersRenderer.host);
}
}
protected override onAfterAttach(msg: Message): void {
Widget.attach(this.treePanel, this.contentNode);
this.toDisposeOnDetach.push(Disposable.create(() => {
Widget.detach(this.treePanel);
this.locationListRenderer.dispose();
if (this.treeFiltersRenderer) {
this.treeFiltersRenderer.dispose();
}
}));
this.appendFiltersPanel();
this.appendCloseButton(nls.localizeByDefault('Cancel'));
this.appendAcceptButton(this.getAcceptButtonLabel());
this.addKeyListener(this.back, Key.ENTER, () => {
this.addTransformEffectToIcon(this.back);
this.model.navigateBackward();
}, 'click');
this.addKeyListener(this.forward, Key.ENTER, () => {
this.addTransformEffectToIcon(this.forward);
this.model.navigateForward();
}, 'click');
this.addKeyListener(this.home, Key.ENTER, () => {
this.addTransformEffectToIcon(this.home);
if (this.model.initialLocation) {
this.model.location = this.model.initialLocation;
}
}, 'click');
this.addKeyListener(this.up, Key.ENTER, () => {
this.addTransformEffectToIcon(this.up);
if (this.model.location) {
this.model.location = this.model.location.parent;
}
}, 'click');
super.onAfterAttach(msg);
}
protected addTransformEffectToIcon(element: HTMLSpanElement): void {
const icon = element.getElementsByTagName('i')[0];
icon.classList.add('active');
setTimeout(() => icon.classList.remove('active'), TOOLBAR_ITEM_TRANSFORM_TIMEOUT);
}
protected abstract getAcceptButtonLabel(): string;
protected override onActivateRequest(msg: Message): void {
this.widget.activate();
}
}
@injectable()
export class OpenFileDialog extends FileDialog<MaybeArray<FileStatNode>> {
constructor(@inject(OpenFileDialogProps) override readonly props: OpenFileDialogProps) {
super(props);
}
@postConstruct()
override init(): void {
super.init();
const { props } = this;
if (props.canSelectFiles !== undefined) {
this.widget.disableFileSelection = !props.canSelectFiles;
}
}
protected getAcceptButtonLabel(): string {
return this.props.openLabel ? this.props.openLabel : nls.localizeByDefault('Open');
}
protected override isValid(value: MaybeArray<FileStatNode>): string {
if (value && !this.props.canSelectMany && value instanceof Array) {
return nls.localize('theia/filesystem/dialog/multipleItemMessage', 'You can select only one item');
}
return '';
}
get value(): MaybeArray<FileStatNode> {
if (this.widget.model.selectedFileStatNodes.length === 1) {
return this.widget.model.selectedFileStatNodes[0];
} else {
return this.widget.model.selectedFileStatNodes;
}
}
protected override async accept(): Promise<void> {
const selection = this.value;
if (!this.props.canSelectFolders
&& !Array.isArray(selection)
&& selection.fileStat.isDirectory) {
this.widget.model.openNode(selection);
return;
}
super.accept();
}
}
@injectable()
export class SaveFileDialog extends FileDialog<URI | undefined> {
protected fileNameField: HTMLInputElement | undefined;
@inject(LabelProvider)
protected readonly labelProvider: LabelProvider;
constructor(@inject(SaveFileDialogProps) override readonly props: SaveFileDialogProps) {
super(props);
}
@postConstruct()
override init(): void {
super.init();
const { widget } = this;
widget.addClass(SAVE_DIALOG_CLASS);
}
protected getAcceptButtonLabel(): string {
return this.props.saveLabel ? this.props.saveLabel : nls.localizeByDefault('Save');
}
protected override onUpdateRequest(msg: Message): void {
// Update file name field when changing a selection
if (this.fileNameField) {
if (this.widget.model.selectedFileStatNodes.length === 1) {
const node = this.widget.model.selectedFileStatNodes[0];
if (!node.fileStat.isDirectory) {
this.fileNameField.value = this.labelProvider.getName(node);
}
} else {
this.fileNameField.value = '';
}
}
// Continue updating the dialog
super.onUpdateRequest(msg);
}
protected override isValid(value: URI | undefined): string | boolean {
if (this.fileNameField && this.fileNameField.value) {
return '';
}
return false;
}
get value(): URI | undefined {
if (this.fileNameField && this.widget.model.selectedFileStatNodes.length === 1) {
const node = this.widget.model.selectedFileStatNodes[0];
if (node.fileStat.isDirectory) {
return node.uri.resolve(this.fileNameField.value);
}
return node.uri.parent.resolve(this.fileNameField.value);
}
return undefined;
}
protected override onAfterAttach(msg: Message): void {
super.onAfterAttach(msg);
const fileNamePanel = document.createElement('div');
fileNamePanel.classList.add(FILENAME_PANEL_CLASS);
this.contentNode.appendChild(fileNamePanel);
const titlePanel = document.createElement('div');
titlePanel.innerHTML = DOMPurify.sanitize(nls.localizeByDefault('Name:'));
titlePanel.classList.add(FILENAME_LABEL_CLASS);
fileNamePanel.appendChild(titlePanel);
this.fileNameField = document.createElement('input');
this.fileNameField.type = 'text';
this.fileNameField.spellcheck = false;
this.fileNameField.classList.add('theia-input', FILENAME_TEXTFIELD_CLASS);
this.fileNameField.value = this.props.inputValue || '';
fileNamePanel.appendChild(this.fileNameField);
this.fileNameField.onkeyup = () => this.validate();
}
}

View File

@@ -0,0 +1,20 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export * from './file-dialog';
export * from './file-dialog-container';
export * from './file-dialog-tree-filters-renderer';
export * from './file-dialog-service';

View File

@@ -0,0 +1,255 @@
// *****************************************************************************
// Copyright (C) 2024 Toro Cloud Pty Ltd and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
let disableJSDOM = enableJSDOM();
import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
FrontendApplicationConfigProvider.set({});
import { Disposable, Emitter, URI } from '@theia/core';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { expect } from 'chai';
import * as sinon from 'sinon';
import { FileChangesEvent, FileChangeType, FileStatWithMetadata } from '../common/files';
import { FileResource } from './file-resource';
import { FileService } from './file-service';
disableJSDOM();
describe.only('file-resource', () => {
const sandbox = sinon.createSandbox();
const mockEmitter = new Emitter();
const mockOnChangeEmitter = new Emitter<FileChangesEvent>();
const mockFileService = new FileService();
before(() => {
disableJSDOM = enableJSDOM();
});
beforeEach(() => {
sandbox.restore();
sandbox.stub(mockFileService, 'onDidFilesChange').get(() =>
mockOnChangeEmitter.event
);
sandbox.stub(mockFileService, 'onDidRunOperation').returns(Disposable.NULL);
sandbox.stub(mockFileService, 'watch').get(() =>
mockEmitter.event
);
sandbox.stub(mockFileService, 'onDidChangeFileSystemProviderCapabilities').get(() =>
mockEmitter.event
);
sandbox.stub(mockFileService, 'onDidChangeFileSystemProviderReadOnlyMessage').get(() =>
mockEmitter.event
);
});
after(() => {
disableJSDOM();
});
it('should save contents and not trigger change event', async () => {
const resource = new FileResource(new URI('file://test/file.txt'),
mockFileService, { readOnly: false, shouldOpenAsText: () => Promise.resolve(true), shouldOverwrite: () => Promise.resolve(true) });
const onChangeSpy = sandbox.spy();
resource.onDidChangeContents(onChangeSpy);
const deferred = new Deferred<FileStatWithMetadata & { encoding: string }>();
sandbox.stub(mockFileService, 'write')
.callsFake(() =>
deferred.promise
);
sandbox.stub(mockFileService, 'resolve')
.resolves({
mtime: 1,
ctime: 0,
size: 0,
etag: '',
isFile: true,
isDirectory: false,
isSymbolicLink: false,
isReadonly: false,
name: 'file.txt',
resource: new URI('file://test/file.txt')
});
resource.saveContents!('test');
await new Promise(resolve => setTimeout(resolve, 0));
mockOnChangeEmitter.fire(new FileChangesEvent(
[{
resource: new URI('file://test/file.txt'),
type: FileChangeType.UPDATED
}]
));
await new Promise(resolve => setImmediate(resolve));
expect(onChangeSpy.called).to.be.false;
deferred.resolve({
mtime: 0,
ctime: 0,
size: 0,
etag: '',
encoding: 'utf-8',
isFile: true,
isDirectory: false,
isSymbolicLink: false,
isReadonly: false,
name: 'file.txt',
resource: new URI('file://test/file.txt')
});
await new Promise(resolve => setImmediate(resolve));
expect(resource.version).to.deep.equal({ etag: '', mtime: 0, encoding: 'utf-8' });
});
it('should save content changes and not trigger change event', async () => {
sandbox.stub(mockFileService, 'hasCapability').returns(true);
const resource = new FileResource(new URI('file://test/file.txt'),
mockFileService, { readOnly: false, shouldOpenAsText: () => Promise.resolve(true), shouldOverwrite: () => Promise.resolve(true) });
const onChangeSpy = sandbox.spy();
resource.onDidChangeContents(onChangeSpy);
sandbox.stub(mockFileService, 'read')
.resolves({
mtime: 1,
ctime: 0,
size: 0,
etag: '',
name: 'file.txt',
resource: new URI('file://test/file.txt'),
value: 'test',
encoding: 'utf-8'
});
await resource.readContents!();
const deferred = new Deferred<FileStatWithMetadata & { encoding: string }>();
sandbox.stub(mockFileService, 'update')
.callsFake(() =>
deferred.promise
);
sandbox.stub(mockFileService, 'resolve')
.resolves({
mtime: 1,
ctime: 0,
size: 0,
etag: '',
isFile: true,
isDirectory: false,
isSymbolicLink: false,
isReadonly: false,
name: 'file.txt',
resource: new URI('file://test/file.txt')
});
resource.saveContentChanges!([{
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } },
rangeLength: 0,
text: 'test'
}]);
await new Promise(resolve => setTimeout(resolve, 0));
mockOnChangeEmitter.fire(new FileChangesEvent(
[{
resource: new URI('file://test/file.txt'),
type: FileChangeType.UPDATED
}]
));
await new Promise(resolve => setImmediate(resolve));
expect(onChangeSpy.called).to.be.false;
deferred.resolve({
mtime: 0,
ctime: 0,
size: 0,
etag: '',
encoding: 'utf-8',
isFile: true,
isDirectory: false,
isSymbolicLink: false,
isReadonly: false,
name: 'file.txt',
resource: new URI('file://test/file.txt')
});
await new Promise(resolve => setImmediate(resolve));
expect(resource.version).to.deep.equal({ etag: '', mtime: 0, encoding: 'utf-8' });
});
it('should trigger change event if file is updated and not in sync', async () => {
const resource = new FileResource(new URI('file://test/file.txt'),
mockFileService, { readOnly: false, shouldOpenAsText: () => Promise.resolve(true), shouldOverwrite: () => Promise.resolve(true) });
const onChangeSpy = sandbox.spy();
resource.onDidChangeContents(onChangeSpy);
sandbox.stub(mockFileService, 'read')
.resolves({
mtime: 1,
ctime: 0,
size: 0,
etag: '',
name: 'file.txt',
resource: new URI('file://test/file.txt'),
value: 'test',
encoding: 'utf-8'
});
await resource.readContents!();
sandbox.stub(mockFileService, 'resolve')
.resolves({
mtime: 2,
ctime: 0,
size: 0,
etag: '',
isFile: true,
isDirectory: false,
isSymbolicLink: false,
isReadonly: false,
name: 'file.txt',
resource: new URI('file://test/file.txt')
});
mockOnChangeEmitter.fire(new FileChangesEvent(
[{
resource: new URI('file://test/file.txt'),
type: FileChangeType.UPDATED
}]
));
await new Promise(resolve => setImmediate(resolve));
expect(onChangeSpy.called).to.be.true;
});
});

View File

@@ -0,0 +1,404 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject } from '@theia/core/shared/inversify';
import { Resource, ResourceVersion, ResourceResolver, ResourceError, ResourceSaveOptions } from '@theia/core/lib/common/resource';
import { DisposableCollection } from '@theia/core/lib/common/disposable';
import { Emitter, Event } from '@theia/core/lib/common/event';
import { Readable, ReadableStream } from '@theia/core/lib/common/stream';
import URI from '@theia/core/lib/common/uri';
import { FileOperation, FileOperationError, FileOperationResult, ETAG_DISABLED, FileSystemProviderCapabilities, FileReadStreamOptions, BinarySize } from '../common/files';
import { FileService, TextFileOperationError, TextFileOperationResult } from './file-service';
import { ConfirmDialog, Dialog } from '@theia/core/lib/browser/dialogs';
import { LabelProvider } from '@theia/core/lib/browser/label-provider';
import { GENERAL_MAX_FILE_SIZE_MB } from '../common/filesystem-preferences';
import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state';
import { nls } from '@theia/core';
import { MarkdownString } from '@theia/core/lib/common/markdown-rendering';
import { Mutex } from 'async-mutex';
export interface FileResourceVersion extends ResourceVersion {
readonly encoding: string;
readonly mtime: number;
readonly etag: string;
}
export namespace FileResourceVersion {
export function is(version: ResourceVersion | undefined): version is FileResourceVersion {
return !!version && 'encoding' in version && 'mtime' in version && 'etag' in version;
}
}
export interface FileResourceOptions {
readOnly: boolean | MarkdownString
shouldOverwrite: () => Promise<boolean>
shouldOpenAsText: (error: string) => Promise<boolean>
}
export class FileResource implements Resource {
protected acceptTextOnly = true;
protected limits: FileReadStreamOptions['limits'];
protected readonly toDispose = new DisposableCollection();
protected readonly onDidChangeContentsEmitter = new Emitter<void>();
readonly onDidChangeContents: Event<void> = this.onDidChangeContentsEmitter.event;
protected readonly onDidChangeReadOnlyEmitter = new Emitter<boolean | MarkdownString>();
readonly onDidChangeReadOnly: Event<boolean | MarkdownString> = this.onDidChangeReadOnlyEmitter.event;
protected _version: FileResourceVersion | undefined;
get version(): FileResourceVersion | undefined {
return this._version;
}
get encoding(): string | undefined {
return this._version?.encoding;
}
get readOnly(): boolean | MarkdownString {
return this.options.readOnly;
}
protected writingLock = new Mutex();
constructor(
readonly uri: URI,
protected readonly fileService: FileService,
protected readonly options: FileResourceOptions
) {
this.toDispose.push(this.onDidChangeContentsEmitter);
this.toDispose.push(this.onDidChangeReadOnlyEmitter);
this.toDispose.push(this.fileService.onDidFilesChange(event => {
if (event.contains(this.uri)) {
this.sync();
}
}));
this.toDispose.push(this.fileService.onDidRunOperation(e => {
if ((e.isOperation(FileOperation.DELETE) || e.isOperation(FileOperation.MOVE)) && e.resource.isEqualOrParent(this.uri)) {
this.sync();
}
}));
try {
this.toDispose.push(this.fileService.watch(this.uri));
} catch (e) {
console.error(e);
}
this.updateSavingContentChanges();
this.toDispose.push(this.fileService.onDidChangeFileSystemProviderCapabilities(async e => {
if (e.scheme === this.uri.scheme) {
this.updateReadOnly();
}
}));
this.toDispose.push(this.fileService.onDidChangeFileSystemProviderReadOnlyMessage(async e => {
if (e.scheme === this.uri.scheme) {
this.updateReadOnly();
}
}));
}
protected async updateReadOnly(): Promise<void> {
const oldReadOnly = this.options.readOnly;
const readOnlyMessage = this.fileService.getReadOnlyMessage(this.uri);
if (readOnlyMessage) {
this.options.readOnly = readOnlyMessage;
} else {
this.options.readOnly = this.fileService.hasCapability(this.uri, FileSystemProviderCapabilities.Readonly);
}
if (this.options.readOnly !== oldReadOnly) {
this.updateSavingContentChanges();
this.onDidChangeReadOnlyEmitter.fire(this.options.readOnly);
}
}
dispose(): void {
this.toDispose.dispose();
}
async readContents(options?: { encoding?: string }): Promise<string> {
try {
const encoding = options?.encoding || this.version?.encoding;
const stat = await this.fileService.read(this.uri, {
encoding,
etag: ETAG_DISABLED,
acceptTextOnly: this.acceptTextOnly,
limits: this.limits
});
this._version = {
encoding: stat.encoding,
etag: stat.etag,
mtime: stat.mtime
};
return stat.value;
} catch (e) {
if (e instanceof TextFileOperationError && e.textFileOperationResult === TextFileOperationResult.FILE_IS_BINARY) {
if (await this.shouldOpenAsText(nls.localize('theia/filesystem/fileResource/binaryTitle', 'The file is either binary or uses an unsupported text encoding.'))) {
this.acceptTextOnly = false;
return this.readContents(options);
}
} else if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_TOO_LARGE) {
const stat = await this.fileService.resolve(this.uri, { resolveMetadata: true });
const maxFileSize = GENERAL_MAX_FILE_SIZE_MB * 1024 * 1024;
if (this.limits?.size !== maxFileSize && await this.shouldOpenAsText(nls.localize(
'theia/filesystem/fileResource/largeFileTitle', 'The file is too large ({0}).', BinarySize.formatSize(stat.size)))) {
this.limits = {
size: maxFileSize
};
return this.readContents(options);
}
} else if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_NOT_FOUND) {
this._version = undefined;
const { message, stack } = e;
throw ResourceError.NotFound({
message, stack,
data: {
uri: this.uri
}
});
}
throw e;
}
}
async readStream(options?: { encoding?: string }): Promise<ReadableStream<string>> {
try {
const encoding = options?.encoding || this.version?.encoding;
const stat = await this.fileService.readStream(this.uri, {
encoding,
etag: ETAG_DISABLED,
acceptTextOnly: this.acceptTextOnly,
limits: this.limits
});
this._version = {
encoding: stat.encoding,
etag: stat.etag,
mtime: stat.mtime
};
return stat.value;
} catch (e) {
if (e instanceof TextFileOperationError && e.textFileOperationResult === TextFileOperationResult.FILE_IS_BINARY) {
if (await this.shouldOpenAsText(nls.localize('theia/filesystem/fileResource/binaryTitle', 'The file is either binary or uses an unsupported text encoding.'))) {
this.acceptTextOnly = false;
return this.readStream(options);
}
} else if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_TOO_LARGE) {
const stat = await this.fileService.resolve(this.uri, { resolveMetadata: true });
const maxFileSize = GENERAL_MAX_FILE_SIZE_MB * 1024 * 1024;
if (this.limits?.size !== maxFileSize && await this.shouldOpenAsText(nls.localize(
'theia/filesystem/fileResource/largeFileTitle', 'The file is too large ({0}).', BinarySize.formatSize(stat.size)))) {
this.limits = {
size: maxFileSize
};
return this.readStream(options);
}
} else if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_NOT_FOUND) {
this._version = undefined;
const { message, stack } = e;
throw ResourceError.NotFound({
message, stack,
data: {
uri: this.uri
}
});
}
throw e;
}
}
protected doWrite = async (content: string | Readable<string>, options?: ResourceSaveOptions): Promise<void> => {
const version = options?.version || this._version;
const current = FileResourceVersion.is(version) ? version : undefined;
const etag = current?.etag;
const releaseLock = await this.writingLock.acquire();
try {
const stat = await this.fileService.write(this.uri, content, {
encoding: options?.encoding,
overwriteEncoding: options?.overwriteEncoding,
etag,
mtime: current?.mtime
});
this._version = {
etag: stat.etag,
mtime: stat.mtime,
encoding: stat.encoding
};
} catch (e) {
if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_MODIFIED_SINCE) {
if (etag !== ETAG_DISABLED && await this.shouldOverwrite()) {
return this.doWrite(content, { ...options, version: { stat: { ...current, etag: ETAG_DISABLED } } });
}
const { message, stack } = e;
throw ResourceError.OutOfSync({ message, stack, data: { uri: this.uri } });
}
throw e;
} finally {
releaseLock();
}
};
saveStream?: Resource['saveStream'];
saveContents?: Resource['saveContents'];
saveContentChanges?: Resource['saveContentChanges'];
protected updateSavingContentChanges(): void {
if (this.readOnly) {
delete this.saveContentChanges;
delete this.saveContents;
delete this.saveStream;
} else {
this.saveContents = this.doWrite;
this.saveStream = this.doWrite;
if (this.fileService.hasCapability(this.uri, FileSystemProviderCapabilities.Update)) {
this.saveContentChanges = this.doSaveContentChanges;
}
}
}
protected doSaveContentChanges: Resource['saveContentChanges'] = async (changes, options) => {
const version = options?.version || this._version;
const current = FileResourceVersion.is(version) ? version : undefined;
if (!current) {
throw ResourceError.NotFound({ message: 'has not been read yet', data: { uri: this.uri } });
}
const etag = current?.etag;
const releaseLock = await this.writingLock.acquire();
try {
const stat = await this.fileService.update(this.uri, changes, {
readEncoding: current.encoding,
encoding: options?.encoding,
overwriteEncoding: options?.overwriteEncoding,
etag,
mtime: current?.mtime
});
this._version = {
etag: stat.etag,
mtime: stat.mtime,
encoding: stat.encoding
};
} catch (e) {
if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_NOT_FOUND) {
const { message, stack } = e;
throw ResourceError.NotFound({ message, stack, data: { uri: this.uri } });
}
if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_MODIFIED_SINCE) {
const { message, stack } = e;
throw ResourceError.OutOfSync({ message, stack, data: { uri: this.uri } });
}
throw e;
} finally {
releaseLock();
}
};
async guessEncoding(): Promise<string> {
// TODO limit size
const content = await this.fileService.read(this.uri, { autoGuessEncoding: true });
return content.encoding;
}
protected async sync(): Promise<void> {
if (await this.isInSync()) {
return;
}
this.onDidChangeContentsEmitter.fire(undefined);
}
protected async isInSync(): Promise<boolean> {
try {
await this.writingLock.waitForUnlock();
const stat = await this.fileService.resolve(this.uri, { resolveMetadata: true });
return !!this.version && this.version.mtime >= stat.mtime;
} catch {
return !this.version;
}
}
protected async shouldOverwrite(): Promise<boolean> {
return this.options.shouldOverwrite();
}
protected async shouldOpenAsText(error: string): Promise<boolean> {
return this.options.shouldOpenAsText(error);
}
}
@injectable()
export class FileResourceResolver implements ResourceResolver {
/** This resolver interacts with the VSCode plugin system in a way that can cause delays. Most other resource resolvers fail immediately, so this one should be tried late. */
readonly priority = -10;
@inject(FileService)
protected readonly fileService: FileService;
@inject(LabelProvider)
protected readonly labelProvider: LabelProvider;
@inject(FrontendApplicationStateService)
protected readonly applicationState: FrontendApplicationStateService;
async resolve(uri: URI): Promise<FileResource> {
let stat;
try {
stat = await this.fileService.resolve(uri);
} catch (e) {
if (!(e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_NOT_FOUND)) {
throw e;
}
}
if (stat && stat.isDirectory) {
throw new Error('The given uri is a directory: ' + this.labelProvider.getLongName(uri));
}
const readOnlyMessage = this.fileService.getReadOnlyMessage(uri);
const isFileSystemReadOnly = this.fileService.hasCapability(uri, FileSystemProviderCapabilities.Readonly);
const readOnly = readOnlyMessage ?? (isFileSystemReadOnly ? isFileSystemReadOnly : (stat?.isReadonly ?? false));
return new FileResource(uri, this.fileService, {
readOnly: readOnly,
shouldOverwrite: () => this.shouldOverwrite(uri),
shouldOpenAsText: error => this.shouldOpenAsText(uri, error)
});
}
protected async shouldOverwrite(uri: URI): Promise<boolean> {
const dialog = new ConfirmDialog({
title: nls.localize('theia/filesystem/fileResource/overwriteTitle', "The file '{0}' has been changed on the file system.", this.labelProvider.getName(uri)),
msg: nls.localize('theia/fileSystem/fileResource/overWriteBody',
"Do you want to overwrite the changes made to '{0}' on the file system?", this.labelProvider.getLongName(uri)),
ok: Dialog.YES,
cancel: Dialog.NO,
});
return !!await dialog.open();
}
protected async shouldOpenAsText(uri: URI, error: string): Promise<boolean> {
switch (this.applicationState.state) {
case 'init':
case 'started_contributions':
case 'attached_shell':
return true; // We're restoring state - assume that we should open files that were previously open.
default: {
const dialog = new ConfirmDialog({
title: error,
msg: nls.localize('theia/filesystem/fileResource/binaryFileQuery',
"Opening it might take some time and might make the IDE unresponsive. Do you want to open '{0}' anyway?", this.labelProvider.getLongName(uri)
),
ok: Dialog.YES,
cancel: Dialog.NO,
});
return !!await dialog.open();
}
}
}
}

View File

@@ -0,0 +1,44 @@
// *****************************************************************************
// Copyright (C) 2019 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { SelectionService } from '@theia/core/lib/common/selection-service';
import { SelectionCommandHandler } from '@theia/core/lib/common/selection-command-handler';
import { isObject } from '@theia/core/lib/common';
import { FileStat } from '../common/files';
export interface FileSelection {
fileStat: FileStat
}
export namespace FileSelection {
export function is(arg: unknown): arg is FileSelection {
return isObject<FileSelection>(arg) && FileStat.is(arg.fileStat);
}
export class CommandHandler extends SelectionCommandHandler<FileSelection> {
constructor(
protected override readonly selectionService: SelectionService,
protected override readonly options: SelectionCommandHandler.Options<FileSelection>
) {
super(
selectionService,
arg => FileSelection.is(arg) ? arg : undefined,
options
);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { interfaces, Container } from '@theia/core/shared/inversify';
import { CompressedExpansionService, CompressionToggle, createTreeContainer, TreeCompressionService, TreeContainerProps } from '@theia/core/lib/browser';
import { FileTree } from './file-tree';
import { FileTreeModel } from './file-tree-model';
import { FileTreeWidget } from './file-tree-widget';
const fileTreeDefaults: Partial<TreeContainerProps> = {
tree: FileTree,
model: FileTreeModel,
widget: FileTreeWidget,
expansionService: CompressedExpansionService,
};
export function createFileTreeContainer(parent: interfaces.Container, overrides?: Partial<TreeContainerProps>): Container {
const child = createTreeContainer(parent, { ...fileTreeDefaults, ...overrides });
child.bind(CompressionToggle).toConstantValue({ compress: false });
child.bind(TreeCompressionService).toSelf().inSingletonScope();
return child;
}

View File

@@ -0,0 +1,159 @@
// *****************************************************************************
// Copyright (C) 2022 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { Event, Emitter, nls } from '@theia/core/lib/common';
import { Decoration, DecorationsService } from '@theia/core/lib/browser/decorations-service';
import { TreeNode, TreeDecoration, TreeDecorator, Tree, TopDownTreeIterator } from '@theia/core/lib/browser';
import { MaybePromise } from '@theia/core/lib/common/types';
import { ColorRegistry } from '@theia/core/lib/browser/color-registry';
import { FileStatNode } from './file-tree';
@injectable()
export class FileTreeDecoratorAdapter implements TreeDecorator {
readonly id = 'decorations-service-tree-decorator-adapter';
protected readonly bubbleTooltip = nls.localizeByDefault('Contains emphasized items');
@inject(DecorationsService) protected readonly decorationsService: DecorationsService;
@inject(ColorRegistry) protected readonly colorRegistry: ColorRegistry;
protected readonly onDidChangeDecorationsEmitter = new Emitter<(tree: Tree) => Map<string, TreeDecoration.Data>>();
protected decorationsByUri = new Map<string, TreeDecoration.Data>();
protected parentDecorations = new Map<string, TreeDecoration.Data>();
get onDidChangeDecorations(): Event<(tree: Tree) => Map<string, TreeDecoration.Data>> {
return this.onDidChangeDecorationsEmitter.event;
}
@postConstruct()
protected init(): void {
this.decorationsService.onDidChangeDecorations(newDecorations => {
this.updateDecorations(this.decorationsByUri.keys(), newDecorations.keys());
this.fireDidChangeDecorations();
});
}
decorations(tree: Tree): MaybePromise<Map<string, TreeDecoration.Data>> {
return this.collectDecorations(tree);
}
protected collectDecorations(tree: Tree): Map<string, TreeDecoration.Data> {
const decorations = new Map();
if (tree.root) {
for (const node of new TopDownTreeIterator(tree.root)) {
const uri = this.getUriForNode(node);
if (uri) {
const stringified = uri.toString();
const ownDecoration = this.decorationsByUri.get(stringified);
const bubbledDecoration = this.parentDecorations.get(stringified);
const combined = this.mergeDecorations(ownDecoration, bubbledDecoration);
if (combined) {
decorations.set(node.id, combined);
}
}
}
}
return decorations;
}
protected mergeDecorations(ownDecoration?: TreeDecoration.Data, bubbledDecoration?: TreeDecoration.Data): TreeDecoration.Data | undefined {
if (!ownDecoration) {
return bubbledDecoration;
} else if (!bubbledDecoration) {
return ownDecoration;
} else {
const tailDecorations = (bubbledDecoration.tailDecorations ?? []).concat(ownDecoration.tailDecorations ?? []);
return {
...bubbledDecoration,
tailDecorations
};
}
}
protected updateDecorations(oldKeys: IterableIterator<string>, newKeys: IterableIterator<string>): void {
this.parentDecorations.clear();
const newDecorations = new Map<string, TreeDecoration.Data>();
const handleUri = (rawUri: string) => {
if (!newDecorations.has(rawUri)) {
const uri = new URI(rawUri);
const decorations = this.decorationsService.getDecoration(uri, false);
if (decorations.length) {
newDecorations.set(rawUri, this.toTheiaDecoration(decorations, false));
this.propagateDecorationsByUri(uri, decorations);
}
}
};
for (const rawUri of oldKeys) {
handleUri(rawUri);
}
for (const rawUri of newKeys) {
handleUri(rawUri);
}
this.decorationsByUri = newDecorations;
}
protected toTheiaDecoration(decorations: Decoration[], bubble?: boolean): TreeDecoration.Data {
const color = decorations[0].colorId ? `var(${this.colorRegistry.toCssVariableName(decorations[0].colorId)})` : undefined;
const fontData = color ? { color } : undefined;
return {
priority: decorations[0].weight,
fontData,
tailDecorations: decorations.map(decoration => this.toTailDecoration(decoration, fontData, bubble))
};
}
protected toTailDecoration(decoration: Decoration, fontData?: TreeDecoration.FontData, bubble?: boolean): TreeDecoration.TailDecoration.AnyConcrete {
if (bubble) {
return { icon: 'circle', fontData, tooltip: this.bubbleTooltip };
}
return { data: decoration.letter ?? '', fontData, tooltip: decoration.tooltip };
}
protected propagateDecorationsByUri(child: URI, decorations: Decoration[]): void {
const highestPriorityBubblingDecoration = decorations.find(decoration => decoration.bubble);
if (highestPriorityBubblingDecoration) {
const bubbleDecoration = this.toTheiaDecoration([highestPriorityBubblingDecoration], true);
let parent = child.parent;
let handledRoot = false;
while (!handledRoot) {
handledRoot = parent.path.isRoot;
const parentString = parent.toString();
const existingDecoration = this.parentDecorations.get(parentString);
if (!existingDecoration || this.compareWeight(bubbleDecoration, existingDecoration) < 0) {
this.parentDecorations.set(parentString, bubbleDecoration);
} else {
break;
}
parent = parent.parent;
}
}
}
/**
* Sort higher priorities earlier. I.e. positive number means right higher than left.
*/
protected compareWeight(left: Decoration, right: Decoration): number {
return (right.weight ?? 0) - (left.weight ?? 0);
}
protected getUriForNode(node: TreeNode): string | undefined {
return FileStatNode.getUri(node);
}
fireDidChangeDecorations(): void {
this.onDidChangeDecorationsEmitter.fire(tree => this.collectDecorations(tree));
}
}

View File

@@ -0,0 +1,53 @@
// *****************************************************************************
// Copyright (C) 2019 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject } from '@theia/core/shared/inversify';
import { LabelProviderContribution, DidChangeLabelEvent, LabelProvider } from '@theia/core/lib/browser/label-provider';
import { FileStatNode } from './file-tree';
import { TreeLabelProvider } from '@theia/core/lib/browser/tree/tree-label-provider';
@injectable()
export class FileTreeLabelProvider implements LabelProviderContribution {
@inject(LabelProvider)
protected readonly labelProvider: LabelProvider;
@inject(TreeLabelProvider)
protected readonly treeLabelProvider: TreeLabelProvider;
canHandle(element: object): number {
return FileStatNode.is(element) ?
this.treeLabelProvider.canHandle(element) + 1 :
0;
}
getIcon(node: FileStatNode): string {
return this.labelProvider.getIcon(node.fileStat);
}
getName(node: FileStatNode): string {
return this.labelProvider.getName(node.fileStat);
}
getDescription(node: FileStatNode): string {
return this.labelProvider.getLongName(node.fileStat);
}
affects(node: FileStatNode, event: DidChangeLabelEvent): boolean {
return event.affects(node.fileStat);
}
}

View File

@@ -0,0 +1,194 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { CompositeTreeNode, TreeNode, ConfirmDialog, CompressedTreeModel, Dialog } from '@theia/core/lib/browser';
import { FileStatNode, DirNode, FileNode } from './file-tree';
import { LocationService } from '../location';
import { LabelProvider } from '@theia/core/lib/browser/label-provider';
import { FileService } from '../file-service';
import { FileOperationError, FileOperationResult, FileChangesEvent, FileChangeType, FileChange } from '../../common/files';
import { MessageService } from '@theia/core/lib/common/message-service';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import { FileSystemUtils } from '../../common';
import { nls } from '@theia/core';
@injectable()
export class FileTreeModel extends CompressedTreeModel implements LocationService {
@inject(LabelProvider) protected readonly labelProvider: LabelProvider;
@inject(FileService)
protected readonly fileService: FileService;
@inject(MessageService)
protected readonly messageService: MessageService;
@inject(EnvVariablesServer)
protected readonly environments: EnvVariablesServer;
@postConstruct()
protected override init(): void {
super.init();
this.toDispose.push(this.fileService.onDidFilesChange(changes => this.onFilesChanged(changes)));
}
get location(): URI | undefined {
const root = this.root;
if (FileStatNode.is(root)) {
return root.uri;
}
return undefined;
}
set location(uri: URI | undefined) {
if (uri) {
this.fileService.resolve(uri).then(fileStat => {
if (fileStat) {
const node = DirNode.createRoot(fileStat);
this.navigateTo(node);
}
}).catch(() => {
// no-op, allow failures for file dialog text input
});
} else {
this.navigateTo(undefined);
}
}
async drives(): Promise<URI[]> {
try {
const drives = await this.environments.getDrives();
return drives.map(uri => new URI(uri));
} catch (e) {
this.logger.error('Error when loading drives.', e);
return [];
}
}
get selectedFileStatNodes(): Readonly<FileStatNode>[] {
return this.selectedNodes.filter(FileStatNode.is);
}
*getNodesByUri(uri: URI): IterableIterator<TreeNode> {
const node = this.getNode(uri.toString());
if (node) {
yield node;
}
}
protected onFilesChanged(changes: FileChangesEvent): void {
if (!this.refreshAffectedNodes(this.getAffectedUris(changes)) && this.isRootAffected(changes)) {
this.refresh();
}
}
protected isRootAffected(changes: FileChangesEvent): boolean {
const root = this.root;
if (FileStatNode.is(root)) {
return changes.contains(root.uri, FileChangeType.ADDED) || changes.contains(root.uri, FileChangeType.UPDATED);
}
return false;
}
protected getAffectedUris(changes: FileChangesEvent): URI[] {
return changes.changes.filter(change => !this.isFileContentChanged(change)).map(change => change.resource);
}
protected isFileContentChanged(change: FileChange): boolean {
return change.type === FileChangeType.UPDATED && FileNode.is(this.getNodesByUri(change.resource).next().value);
}
protected refreshAffectedNodes(uris: URI[]): boolean {
const nodes = this.getAffectedNodes(uris);
for (const node of nodes.values()) {
this.refresh(node);
}
return nodes.size !== 0;
}
protected getAffectedNodes(uris: URI[]): Map<string, CompositeTreeNode> {
const nodes = new Map<string, CompositeTreeNode>();
for (const uri of uris) {
for (const node of this.getNodesByUri(uri.parent)) {
if (DirNode.is(node) && (node.expanded || (this.compressionToggle.compress && this.compressionService.isCompressionParticipant(node)))) {
nodes.set(node.id, node);
}
}
}
return nodes;
}
async copy(source: URI, target: Readonly<FileStatNode>): Promise<URI> {
/** If the target is a file or if the target is a directory, but is the same as the source, use the parent of the target as a destination. */
const parentNode = (target.fileStat.isFile || target.uri.isEqual(source)) ? target.parent : target;
if (!FileStatNode.is(parentNode)) {
throw new Error('Parent of file has to be a FileStatNode');
}
let targetUri = parentNode.uri.resolve(source.path.base);
try {
const parent = await this.fileService.resolve(parentNode.uri);
const sourceFileStat = await this.fileService.resolve(source);
targetUri = FileSystemUtils.generateUniqueResourceURI(parent, targetUri, sourceFileStat.isDirectory, 'copy');
await this.fileService.copy(source, targetUri);
} catch (e) {
this.messageService.error(e.message);
}
return targetUri;
}
/**
* Move the given source file or directory to the given target directory.
*/
async move(source: TreeNode, target: TreeNode): Promise<URI | undefined> {
if (DirNode.is(target) && FileStatNode.is(source)) {
const name = source.fileStat.name;
const targetUri = target.uri.resolve(name);
if (source.uri.isEqual(targetUri)) { return; }
try {
await this.fileService.move(source.uri, targetUri);
return targetUri;
} catch (e) {
if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_MOVE_CONFLICT) {
const fileName = this.labelProvider.getName(source);
if (await this.shouldReplace(fileName)) {
try {
await this.fileService.move(source.uri, targetUri, { overwrite: true });
return targetUri;
} catch (e2) {
this.messageService.error(e2.message);
}
}
} else {
this.messageService.error(e.message);
}
}
}
return undefined;
}
protected async shouldReplace(fileName: string): Promise<boolean> {
const dialog = new ConfirmDialog({
title: nls.localize('theia/filesystem/replaceTitle', 'Replace File'),
msg: nls.localizeByDefault('{0} already exists. Are you sure you want to overwrite it?', fileName),
ok: Dialog.YES,
cancel: Dialog.NO
});
return !!await dialog.open();
}
}

View File

@@ -0,0 +1,327 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as React from '@theia/core/shared/react';
import { injectable, inject } from '@theia/core/shared/inversify';
import { DisposableCollection, Disposable } from '@theia/core/lib/common/disposable';
import URI from '@theia/core/lib/common/uri';
import { UriSelection } from '@theia/core/lib/common/selection';
import { isCancelled } from '@theia/core/lib/common/cancellation';
import { ContextMenuRenderer, NodeProps, TreeProps, TreeNode, CompositeTreeNode, CompressedTreeWidget, CompressedNodeProps } from '@theia/core/lib/browser';
import { DirNode, FileStatNode, FileStatNodeData } from './file-tree';
import { FileTreeModel } from './file-tree-model';
import { IconThemeService } from '@theia/core/lib/browser/icon-theme-service';
import { ApplicationShell } from '@theia/core/lib/browser/shell';
import { FileStat, FileType } from '../../common/files';
import { isOSX } from '@theia/core';
import { FileUploadService } from '../../common/upload/file-upload';
export const FILE_TREE_CLASS = 'theia-FileTree';
export const FILE_STAT_NODE_CLASS = 'theia-FileStatNode';
export const DIR_NODE_CLASS = 'theia-DirNode';
export const FILE_STAT_ICON_CLASS = 'theia-FileStatIcon';
@injectable()
export class FileTreeWidget extends CompressedTreeWidget {
protected readonly toCancelNodeExpansion = new DisposableCollection();
@inject(FileUploadService)
protected readonly uploadService: FileUploadService;
@inject(IconThemeService)
protected readonly iconThemeService: IconThemeService;
constructor(
@inject(TreeProps) props: TreeProps,
@inject(FileTreeModel) override readonly model: FileTreeModel,
@inject(ContextMenuRenderer) contextMenuRenderer: ContextMenuRenderer
) {
super(props, model, contextMenuRenderer);
this.addClass(FILE_TREE_CLASS);
this.toDispose.push(this.toCancelNodeExpansion);
}
protected override createNodeClassNames(node: TreeNode, props: NodeProps): string[] {
const classNames = super.createNodeClassNames(node, props);
if (FileStatNode.is(node)) {
classNames.push(FILE_STAT_NODE_CLASS);
}
if (DirNode.is(node)) {
classNames.push(DIR_NODE_CLASS);
}
return classNames;
}
protected override renderIcon(node: TreeNode, props: NodeProps): React.ReactNode {
const icon = this.toNodeIcon(node);
if (icon) {
return <div className={icon + ' file-icon'}></div>;
}
// eslint-disable-next-line no-null/no-null
return null;
}
protected override createContainerAttributes(): React.HTMLAttributes<HTMLElement> {
const attrs = super.createContainerAttributes();
return {
...attrs,
onDragEnter: event => this.handleDragEnterEvent(this.model.root, event),
onDragOver: event => this.handleDragOverEvent(this.model.root, event),
onDragLeave: event => this.handleDragLeaveEvent(this.model.root, event),
onDrop: event => this.handleDropEvent(this.model.root, event)
};
}
protected override createNodeAttributes(node: TreeNode, props: NodeProps): React.Attributes & React.HTMLAttributes<HTMLElement> {
return {
...super.createNodeAttributes(node, props),
...this.getNodeDragHandlers(node, props),
title: this.getNodeTooltip(node)
};
}
protected getNodeTooltip(node: TreeNode): string | undefined {
const operativeNode = this.compressionService.getCompressionChain(node)?.tail() ?? node;
const uri = UriSelection.getUri(operativeNode);
return uri ? uri.path.fsPath() : undefined;
}
protected override getCaptionChildEventHandlers(node: TreeNode, props: CompressedNodeProps): React.Attributes & React.HtmlHTMLAttributes<HTMLElement> {
return {
...super.getCaptionChildEventHandlers(node, props),
...this.getNodeDragHandlers(node, props),
};
}
protected getNodeDragHandlers(node: TreeNode, props: CompressedNodeProps): React.Attributes & React.HtmlHTMLAttributes<HTMLElement> {
return {
onDragStart: event => this.handleDragStartEvent(node, event),
onDragEnter: event => this.handleDragEnterEvent(node, event),
onDragOver: event => this.handleDragOverEvent(node, event),
onDragLeave: event => this.handleDragLeaveEvent(node, event),
onDrop: event => this.handleDropEvent(node, event),
draggable: FileStatNode.is(node),
};
}
protected handleDragStartEvent(node: TreeNode, event: React.DragEvent): void {
event.stopPropagation();
if (event.dataTransfer) {
let selectedNodes;
if (this.model.selectedNodes.find(selected => TreeNode.equals(selected, node))) {
selectedNodes = [...this.model.selectedNodes];
} else {
selectedNodes = [node];
}
this.setSelectedTreeNodesAsData(event.dataTransfer, node, selectedNodes);
const uris = selectedNodes.filter(FileStatNode.is).map(n => n.fileStat.resource);
if (uris.length > 0) {
ApplicationShell.setDraggedEditorUris(event.dataTransfer, uris);
}
let label: string;
if (selectedNodes.length === 1) {
label = this.toNodeName(node);
} else {
label = String(selectedNodes.length);
}
const dragImage = document.createElement('div');
dragImage.className = 'theia-file-tree-drag-image';
dragImage.textContent = label;
document.body.appendChild(dragImage);
event.dataTransfer.setDragImage(dragImage, -10, -10);
setTimeout(() => document.body.removeChild(dragImage), 0);
}
}
protected handleDragEnterEvent(node: TreeNode | undefined, event: React.DragEvent): void {
event.preventDefault();
event.stopPropagation();
this.toCancelNodeExpansion.dispose();
const containing = DirNode.getContainingDir(node);
if (!!containing && !containing.selected) {
this.model.selectNode(containing);
}
}
protected handleDragOverEvent(node: TreeNode | undefined, event: React.DragEvent): void {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = this.getDropEffect(event);
if (!this.toCancelNodeExpansion.disposed) {
return;
}
const timer = setTimeout(() => {
const containing = DirNode.getContainingDir(node);
if (!!containing && !containing.expanded) {
this.model.expandNode(containing);
}
}, 500);
this.toCancelNodeExpansion.push(Disposable.create(() => clearTimeout(timer)));
}
protected handleDragLeaveEvent(node: TreeNode | undefined, event: React.DragEvent): void {
event.preventDefault();
event.stopPropagation();
this.toCancelNodeExpansion.dispose();
}
protected async handleDropEvent(node: TreeNode | undefined, event: React.DragEvent): Promise<void> {
try {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = this.getDropEffect(event);
const containing = this.getDropTargetDirNode(node);
if (containing) {
const resources = this.getSelectedTreeNodesFromData(event.dataTransfer);
if (resources.length > 0) {
for (const treeNode of resources) {
if (event.dataTransfer.dropEffect === 'copy' && FileStatNode.is(treeNode)) {
await this.model.copy(treeNode.uri, containing);
} else {
await this.model.move(treeNode, containing);
}
}
} else {
await this.uploadService.upload(containing.uri, { source: event.dataTransfer });
}
}
} catch (e) {
if (!isCancelled(e)) {
console.error(e);
}
}
}
protected getDropTargetDirNode(node: TreeNode | undefined): DirNode | undefined {
if (CompositeTreeNode.is(node) && node.id === 'WorkspaceNodeId') {
if (node.children.length === 1) {
return DirNode.getContainingDir(node.children[0]);
} else if (node.children.length > 1) {
// move file to the last root folder in multi-root scenario
return DirNode.getContainingDir(node.children[node.children.length - 1]);
}
}
return DirNode.getContainingDir(node);
}
protected getDropEffect(event: React.DragEvent): 'copy' | 'move' {
const isCopy = isOSX ? event.altKey : event.ctrlKey;
return isCopy ? 'copy' : 'move';
}
protected setTreeNodeAsData(data: DataTransfer, node: TreeNode): void {
data.setData('tree-node', node.id);
}
protected setSelectedTreeNodesAsData(data: DataTransfer, sourceNode: TreeNode, relatedNodes: TreeNode[]): void {
this.setTreeNodeAsData(data, sourceNode);
data.setData('selected-tree-nodes', JSON.stringify(relatedNodes.map(node => node.id)));
}
protected getTreeNodeFromData(data: DataTransfer): TreeNode | undefined {
const id = data.getData('tree-node');
return this.model.getNode(id);
}
protected getSelectedTreeNodesFromData(data: DataTransfer): TreeNode[] {
const resources = data.getData('selected-tree-nodes');
if (!resources) {
return [];
}
const ids: string[] = JSON.parse(resources);
return ids.map(id => this.model.getNode(id)).filter(node => node !== undefined) as TreeNode[];
}
protected get hidesExplorerArrows(): boolean {
const theme = this.iconThemeService.getDefinition(this.iconThemeService.current);
return !!theme && !!theme.hidesExplorerArrows;
}
protected override renderExpansionToggle(node: TreeNode, props: NodeProps): React.ReactNode {
if (this.hidesExplorerArrows) {
// eslint-disable-next-line no-null/no-null
return null;
}
return super.renderExpansionToggle(node, props);
}
protected override getPaddingLeft(node: TreeNode, props: NodeProps): number {
if (this.hidesExplorerArrows) {
// additional left padding instead of top-level expansion toggle
return super.getPaddingLeft(node, props) + this.props.leftPadding;
}
return super.getPaddingLeft(node, props);
}
protected override needsExpansionTogglePadding(node: TreeNode): boolean {
const theme = this.iconThemeService.getDefinition(this.iconThemeService.current);
if (theme && (theme.hidesExplorerArrows || (theme.hasFileIcons && !theme.hasFolderIcons))) {
return false;
}
return super.needsExpansionTogglePadding(node);
}
protected override deflateForStorage(node: TreeNode): object {
const deflated = super.deflateForStorage(node);
if (FileStatNode.is(node) && FileStatNodeData.is(deflated)) {
deflated.uri = node.uri.toString();
delete deflated['fileStat'];
deflated.stat = FileStat.toStat(node.fileStat);
}
return deflated;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected override inflateFromStorage(node: any, parent?: TreeNode): TreeNode {
if (FileStatNodeData.is(node)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fileStatNode: FileStatNode = node as any;
const resource = new URI(node.uri);
fileStatNode.uri = resource;
let stat: typeof node['stat'];
// in order to support deprecated FileStat
if (node.fileStat) {
stat = {
type: node.fileStat.isDirectory ? FileType.Directory : FileType.File,
mtime: node.fileStat.mtime,
size: node.fileStat.size
};
delete node['fileStat'];
} else if (node.stat) {
stat = node.stat;
delete node['stat'];
}
if (stat) {
fileStatNode.fileStat = FileStat.fromStat(resource, stat);
}
}
const inflated = super.inflateFromStorage(node, parent);
if (DirNode.is(inflated)) {
inflated.fileStat.children = [];
for (const child of inflated.children) {
if (FileStatNode.is(child)) {
inflated.fileStat.children.push(child.fileStat);
}
}
}
return inflated;
}
protected override getDepthPadding(depth: number): number {
// add additional depth so file nodes are rendered with padding in relation to the top level root node.
return super.getDepthPadding(depth + 1);
}
}

View File

@@ -0,0 +1,183 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { isObject, Mutable } from '@theia/core/lib/common';
import { TreeNode, CompositeTreeNode, SelectableTreeNode, ExpandableTreeNode, TreeImpl } from '@theia/core/lib/browser';
import { FileStat, Stat, FileType, FileOperationError, FileOperationResult } from '../../common/files';
import { UriSelection } from '@theia/core/lib/common/selection';
import { MessageService } from '@theia/core/lib/common/message-service';
import { FileSelection } from '../file-selection';
import { FileService } from '../file-service';
@injectable()
export class FileTree extends TreeImpl {
@inject(FileService)
protected readonly fileService: FileService;
@inject(MessageService)
protected readonly messagingService: MessageService;
override async resolveChildren(parent: CompositeTreeNode): Promise<TreeNode[]> {
if (FileStatNode.is(parent)) {
const fileStat = await this.resolveFileStat(parent);
if (fileStat) {
return this.toNodes(fileStat, parent);
}
return [];
}
return super.resolveChildren(parent);
}
protected async resolveFileStat(node: FileStatNode): Promise<FileStat | undefined> {
try {
const fileStat = await this.fileService.resolve(node.uri);
node.fileStat = fileStat;
return fileStat;
} catch (e) {
if (!(e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_NOT_FOUND)) {
this.messagingService.error(e.message);
}
return undefined;
}
}
protected async toNodes(fileStat: FileStat, parent: CompositeTreeNode): Promise<TreeNode[]> {
if (!fileStat.children) {
return [];
}
const result = await Promise.all(fileStat.children.map(async child =>
this.toNode(child, parent)
));
return result.sort(DirNode.compare);
}
protected toNode(fileStat: FileStat, parent: CompositeTreeNode): FileNode | DirNode {
const uri = fileStat.resource;
const id = this.toNodeId(uri, parent);
const node = this.getNode(id);
if (fileStat.isDirectory) {
if (DirNode.is(node)) {
node.fileStat = fileStat;
return node;
}
return <DirNode>{
id, uri, fileStat, parent,
expanded: false,
selected: false,
children: []
};
}
if (FileNode.is(node)) {
node.fileStat = fileStat;
return node;
}
return <FileNode>{
id, uri, fileStat, parent,
selected: false
};
}
protected toNodeId(uri: URI, parent: CompositeTreeNode): string {
return uri.path.toString();
}
}
export interface FileStatNode extends SelectableTreeNode, Mutable<UriSelection>, FileSelection {
}
export namespace FileStatNode {
export function is(node: unknown): node is FileStatNode {
return isObject(node) && 'fileStat' in node;
}
export function getUri(node: TreeNode | undefined): string | undefined {
if (is(node)) {
return node.fileStat.resource.toString();
}
return undefined;
}
}
export type FileStatNodeData = Omit<FileStatNode, 'uri' | 'fileStat'> & {
uri: string
stat?: Stat | { type: FileType } & Partial<Stat>
fileStat?: FileStat
};
export namespace FileStatNodeData {
export function is(node: unknown): node is FileStatNodeData {
return isObject(node) && 'uri' in node && ('fileStat' in node || 'stat' in node);
}
}
export type FileNode = FileStatNode;
export namespace FileNode {
export function is(node: unknown): node is FileNode {
return FileStatNode.is(node) && !node.fileStat.isDirectory;
}
}
export type DirNode = FileStatNode & ExpandableTreeNode;
export namespace DirNode {
export function is(node: unknown): node is DirNode {
return FileStatNode.is(node) && node.fileStat.isDirectory;
}
export function compare(node: TreeNode, node2: TreeNode): number {
return DirNode.dirCompare(node, node2) || uriCompare(node, node2);
}
export function uriCompare(node: TreeNode, node2: TreeNode): number {
if (FileStatNode.is(node)) {
if (FileStatNode.is(node2)) {
return node.uri.displayName.localeCompare(node2.uri.displayName);
}
return 1;
}
if (FileStatNode.is(node2)) {
return -1;
}
return 0;
}
export function dirCompare(node: TreeNode, node2: TreeNode): number {
const a = DirNode.is(node) ? 1 : 0;
const b = DirNode.is(node2) ? 1 : 0;
return b - a;
}
export function createRoot(fileStat: FileStat): DirNode {
const uri = fileStat.resource;
const id = uri.toString();
return {
id, uri, fileStat,
visible: true,
parent: undefined,
children: [],
expanded: true,
selected: false
};
}
export function getContainingDir(node: TreeNode | undefined): DirNode | undefined {
let containing = node;
while (!!containing && !is(containing)) {
containing = containing.parent;
}
return containing;
}
}

View File

@@ -0,0 +1,22 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export * from './file-tree';
export * from './file-tree-model';
export * from './file-tree-widget';
export * from './file-tree-container';
export * from './file-tree-decorator-adapter';
export * from './file-tree-label-provider';

View File

@@ -0,0 +1,395 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { CorePreferences, nls } from '@theia/core';
import {
ApplicationShell,
CommonCommands,
ExpandableTreeNode,
FrontendApplication,
FrontendApplicationContribution,
NavigatableWidget, NavigatableWidgetOptions,
OpenerService,
Saveable,
StatefulWidget,
WidgetManager,
open
} from '@theia/core/lib/browser';
import { MimeService } from '@theia/core/lib/browser/mime-service';
import { TreeWidgetSelection } from '@theia/core/lib/browser/tree/tree-widget-selection';
import { Emitter, MaybePromise, SelectionService, isCancelled } from '@theia/core/lib/common';
import { Command, CommandContribution, CommandRegistry } from '@theia/core/lib/common/command';
import { Deferred } from '@theia/core/lib/common/promise-util';
import URI from '@theia/core/lib/common/uri';
import { environment } from '@theia/core/shared/@theia/application-package/lib/environment';
import { inject, injectable } from '@theia/core/shared/inversify';
import { UserWorkingDirectoryProvider } from '@theia/core/lib/browser/user-working-directory-provider';
import { FileChangeType, FileChangesEvent, FileOperation } from '../common/files';
import { FileDialogService, SaveFileDialogProps } from './file-dialog';
import { FileSelection } from './file-selection';
import { FileService, UserFileOperationEvent } from './file-service';
import { FileSystemPreferences } from '../common/filesystem-preferences';
import { FileUploadService } from '../common/upload/file-upload';
export namespace FileSystemCommands {
export const UPLOAD = Command.toLocalizedCommand({
id: 'file.upload',
category: CommonCommands.FILE_CATEGORY,
label: 'Upload Files...'
}, 'theia/filesystem/uploadFiles', CommonCommands.FILE_CATEGORY_KEY);
}
export interface NavigatableWidgetMoveSnapshot {
dirty?: object,
view?: object
}
@injectable()
export class FileSystemFrontendContribution implements FrontendApplicationContribution, CommandContribution {
@inject(ApplicationShell)
protected readonly shell: ApplicationShell;
@inject(WidgetManager)
protected readonly widgetManager: WidgetManager;
@inject(MimeService)
protected readonly mimeService: MimeService;
@inject(FileSystemPreferences)
protected readonly preferences: FileSystemPreferences;
@inject(CorePreferences)
protected readonly corePreferences: CorePreferences;
@inject(SelectionService)
protected readonly selectionService: SelectionService;
@inject(FileUploadService)
protected readonly uploadService: FileUploadService;
@inject(FileService)
protected readonly fileService: FileService;
@inject(FileDialogService)
protected readonly fileDialogService: FileDialogService;
@inject(OpenerService)
protected readonly openerService: OpenerService;
@inject(UserWorkingDirectoryProvider)
protected readonly workingDirectory: UserWorkingDirectoryProvider;
protected onDidChangeEditorFileEmitter = new Emitter<{ editor: NavigatableWidget, type: FileChangeType }>();
readonly onDidChangeEditorFile = this.onDidChangeEditorFileEmitter.event;
protected readonly userOperations = new Map<number, Deferred<void>>();
protected queueUserOperation(event: UserFileOperationEvent): void {
const moveOperation = new Deferred<void>();
this.userOperations.set(event.correlationId, moveOperation);
this.run(() => moveOperation.promise);
}
protected resolveUserOperation(event: UserFileOperationEvent): void {
const operation = this.userOperations.get(event.correlationId);
if (operation) {
this.userOperations.delete(event.correlationId);
operation.resolve();
}
}
initialize(): void {
this.fileService.onDidFilesChange(event => this.run(() => this.updateWidgets(event)));
this.fileService.onWillRunUserOperation(event => {
this.queueUserOperation(event);
event.waitUntil(this.runEach((uri, widget) => this.pushMove(uri, widget, event)));
});
this.fileService.onDidFailUserOperation(event => event.waitUntil((async () => {
await this.runEach((uri, widget) => this.revertMove(uri, widget, event));
this.resolveUserOperation(event);
})()));
this.fileService.onDidRunUserOperation(event => event.waitUntil((async () => {
await this.runEach((uri, widget) => this.applyMove(uri, widget, event));
this.resolveUserOperation(event);
})()));
this.uploadService.onDidUpload(files => {
this.doHandleUpload(files);
});
}
onStart?(app: FrontendApplication): MaybePromise<void> {
this.updateAssociations();
this.preferences.onPreferenceChanged(e => {
if (e.preferenceName === 'files.associations') {
this.updateAssociations();
}
});
}
registerCommands(commands: CommandRegistry): void {
commands.registerCommand(FileSystemCommands.UPLOAD, {
isEnabled: (...args: unknown[]) => {
const selection = this.getSelection(...args);
return !!selection && !environment.electron.is();
},
isVisible: () => !environment.electron.is(),
execute: (...args: unknown[]) => {
const selection = this.getSelection(...args);
if (selection) {
return this.upload(selection);
}
}
});
commands.registerCommand(CommonCommands.NEW_FILE, {
execute: (...args: unknown[]) => {
this.handleNewFileCommand(args);
}
});
}
protected async upload(selection: FileSelection): Promise<FileUploadService.UploadResult | undefined> {
try {
const source = TreeWidgetSelection.getSource(this.selectionService.selection);
const fileUploadResult = await this.uploadService.upload(selection.fileStat.isDirectory ? selection.fileStat.resource : selection.fileStat.resource.parent);
if (ExpandableTreeNode.is(selection) && source) {
await source.model.expandNode(selection);
}
return fileUploadResult;
} catch (e) {
if (!isCancelled(e)) {
console.error(e);
}
}
}
protected async doHandleUpload(uploads: string[]): Promise<void> {
// Only handle single file uploads
if (uploads.length === 1) {
const uri = new URI(uploads[0]);
// Close all existing widgets for this URI
const widgets = this.shell.widgets.filter(widget => NavigatableWidget.getUri(widget)?.isEqual(uri));
await this.shell.closeMany(widgets, {
// Don't ask to save the file if it's dirty
// The user has already confirmed the file overwrite
save: false
});
// Open a new editor for this URI
open(this.openerService, uri);
}
}
/**
* Opens a save dialog to create a new file.
*
* @param args The first argument is the name of the new file. The second argument is the parent directory URI.
*/
protected async handleNewFileCommand(args: unknown[]): Promise<void> {
const fileName = (args !== undefined && typeof args[0] === 'string') ? args[0] : undefined;
const title = nls.localizeByDefault('Create File');
const props: SaveFileDialogProps = { title, saveLabel: title, inputValue: fileName };
const dirUri = (args[1] instanceof URI) ? args[1] : await this.workingDirectory.getUserWorkingDir();
const directory = await this.fileService.resolve(dirUri);
const filePath = await this.fileDialogService.showSaveDialog(props, directory.isDirectory ? directory : undefined);
if (filePath) {
const file = await this.fileService.createFile(filePath);
open(this.openerService, file.resource);
}
}
protected getSelection(...args: unknown[]): FileSelection | undefined {
const { selection } = this.selectionService;
return this.toSelection(args[0]) ?? (Array.isArray(selection) ? selection.find(FileSelection.is) : this.toSelection(selection));
};
protected toSelection(arg: unknown): FileSelection | undefined {
return FileSelection.is(arg) ? arg : undefined;
}
protected pendingOperation = Promise.resolve();
protected run(operation: () => MaybePromise<void>): Promise<void> {
return this.pendingOperation = this.pendingOperation.then(async () => {
try {
await operation();
} catch (e) {
console.error(e);
}
});
}
protected async runEach(participant: (resourceUri: URI, widget: NavigatableWidget) => Promise<void>): Promise<void> {
const promises: Promise<void>[] = [];
for (const [resourceUri, widget] of NavigatableWidget.get(this.shell.widgets)) {
promises.push(participant(resourceUri, widget));
}
await Promise.all(promises);
}
protected readonly moveSnapshots = new Map<string, NavigatableWidgetMoveSnapshot>();
protected popMoveSnapshot(resourceUri: URI): NavigatableWidgetMoveSnapshot | undefined {
const snapshotKey = resourceUri.toString();
const snapshot = this.moveSnapshots.get(snapshotKey);
if (snapshot) {
this.moveSnapshots.delete(snapshotKey);
}
return snapshot;
}
protected applyMoveSnapshot(widget: NavigatableWidget, snapshot: NavigatableWidgetMoveSnapshot | undefined): void {
if (!snapshot) {
return undefined;
}
if (snapshot.dirty) {
const saveable = Saveable.get(widget);
if (saveable && saveable.applySnapshot) {
saveable.applySnapshot(snapshot.dirty);
}
}
if (snapshot.view && StatefulWidget.is(widget)) {
widget.restoreState(snapshot.view);
}
}
protected async pushMove(resourceUri: URI, widget: NavigatableWidget, event: UserFileOperationEvent): Promise<void> {
const newResourceUri = this.createMoveToUri(resourceUri, widget, event);
if (!newResourceUri) {
return;
}
const snapshot: NavigatableWidgetMoveSnapshot = {};
const saveable = Saveable.get(widget);
if (StatefulWidget.is(widget)) {
snapshot.view = widget.storeState();
}
if (saveable && saveable.dirty) {
if (saveable.createSnapshot) {
snapshot.dirty = saveable.createSnapshot();
}
if (saveable.revert) {
await saveable.revert({ soft: true });
}
}
this.moveSnapshots.set(newResourceUri.toString(), snapshot);
}
protected async revertMove(resourceUri: URI, widget: NavigatableWidget, event: UserFileOperationEvent): Promise<void> {
const newResourceUri = this.createMoveToUri(resourceUri, widget, event);
if (!newResourceUri) {
return;
}
const snapshot = this.popMoveSnapshot(newResourceUri);
this.applyMoveSnapshot(widget, snapshot);
}
protected async applyMove(resourceUri: URI, widget: NavigatableWidget, event: UserFileOperationEvent): Promise<void> {
const newResourceUri = this.createMoveToUri(resourceUri, widget, event);
if (!newResourceUri) {
return;
}
const snapshot = this.popMoveSnapshot(newResourceUri);
const description = this.widgetManager.getDescription(widget);
if (!description) {
return;
}
const { factoryId, options } = description;
if (!NavigatableWidgetOptions.is(options)) {
return;
}
const newWidget = await this.widgetManager.getOrCreateWidget<NavigatableWidget>(factoryId, <NavigatableWidgetOptions>{
...options,
uri: newResourceUri.toString()
});
this.applyMoveSnapshot(newWidget, snapshot);
const area = this.shell.getAreaFor(widget) || 'main';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pending: Promise<any>[] = [this.shell.addWidget(newWidget, {
area, ref: widget
})];
if (this.shell.activeWidget === widget) {
pending.push(this.shell.activateWidget(newWidget.id));
} else if (widget.isVisible) {
pending.push(this.shell.revealWidget(newWidget.id));
}
pending.push(this.shell.closeWidget(widget.id, { save: false }));
await Promise.all(pending);
}
protected createMoveToUri(resourceUri: URI, widget: NavigatableWidget, event: UserFileOperationEvent): URI | undefined {
if (event.operation !== FileOperation.MOVE) {
return undefined;
}
const path = event.source?.relative(resourceUri);
const targetUri = path && event.target.resolve(path);
return targetUri && widget.createMoveToUri(targetUri);
}
protected readonly deletedSuffix = `(${nls.localizeByDefault('Deleted')})`;
protected async updateWidgets(event: FileChangesEvent): Promise<void> {
if (!event.gotDeleted() && !event.gotAdded()) {
return;
}
const dirty = new Set<string>();
const toClose = new Map<string, NavigatableWidget[]>();
for (const [uri, widget] of NavigatableWidget.get(this.shell.widgets)) {
this.updateWidget(uri, widget, event, { dirty, toClose: toClose });
}
if (this.corePreferences['workbench.editor.closeOnFileDelete']) {
const doClose = [];
for (const [uri, widgets] of toClose.entries()) {
if (!dirty.has(uri)) {
doClose.push(...widgets);
}
}
await this.shell.closeMany(doClose);
}
}
protected updateWidget(uri: URI, widget: NavigatableWidget, event: FileChangesEvent, { dirty, toClose }: {
dirty: Set<string>;
toClose: Map<string, NavigatableWidget[]>
}): void {
const label = widget.title.label;
const deleted = label.endsWith(this.deletedSuffix);
if (event.contains(uri, FileChangeType.DELETED)) {
const uriString = uri.toString();
if (Saveable.isDirty(widget)) {
dirty.add(uriString);
}
if (!deleted) {
widget.title.label += this.deletedSuffix;
this.onDidChangeEditorFileEmitter.fire({ editor: widget, type: FileChangeType.DELETED });
}
const widgets = toClose.get(uriString) || [];
widgets.push(widget);
toClose.set(uriString, widgets);
} else if (event.contains(uri, FileChangeType.ADDED)) {
if (deleted) {
widget.title.label = widget.title.label.substring(0, label.length - this.deletedSuffix.length);
this.onDidChangeEditorFileEmitter.fire({ editor: widget, type: FileChangeType.ADDED });
}
}
}
protected updateAssociations(): void {
const fileAssociations = this.preferences['files.associations'];
const mimeAssociations = Object.keys(fileAssociations).map(filepattern => ({ id: fileAssociations[filepattern], filepattern }));
this.mimeService.setAssociations(mimeAssociations);
}
}

View File

@@ -0,0 +1,82 @@
// *****************************************************************************
// Copyright (C) 2017-2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import '../../src/browser/style/index.css';
import { ContainerModule, interfaces } from '@theia/core/shared/inversify';
import { ResourceResolver, CommandContribution } from '@theia/core/lib/common';
import { WebSocketConnectionProvider, FrontendApplicationContribution, LabelProviderContribution, BreadcrumbsContribution } from '@theia/core/lib/browser';
import { FileResourceResolver } from './file-resource';
import { bindFileSystemPreferences } from '../common/filesystem-preferences';
import { FileSystemFrontendContribution } from './filesystem-frontend-contribution';
import { FileTreeDecoratorAdapter, FileTreeLabelProvider } from './file-tree';
import { FileService, FileServiceContribution } from './file-service';
import { RemoteFileSystemProvider, RemoteFileSystemServer, remoteFileSystemPath, RemoteFileSystemProxyFactory } from '../common/remote-file-system-provider';
import { bindContributionProvider } from '@theia/core/lib/common/contribution-provider';
import { RemoteFileServiceContribution } from './remote-file-service-contribution';
import { FileSystemWatcherErrorHandler } from './filesystem-watcher-error-handler';
import { FilepathBreadcrumbsContribution } from './breadcrumbs/filepath-breadcrumbs-contribution';
import { BreadcrumbsFileTreeWidget, createFileTreeBreadcrumbsWidget } from './breadcrumbs/filepath-breadcrumbs-container';
import { FilesystemSaveableService } from './filesystem-saveable-service';
import { SaveableService } from '@theia/core/lib/browser/saveable-service';
import { VSCodeFileServiceContribution, VSCodeFileSystemProvider } from './vscode-file-service-contribution';
import { FileUploadService } from '../common/upload/file-upload';
import { FileUploadServiceImpl } from './upload/file-upload-service-impl';
export default new ContainerModule((bind, unbind, isBound, rebind) => {
bindFileSystemPreferences(bind);
bindContributionProvider(bind, FileServiceContribution);
bind(FileService).toSelf().inSingletonScope();
bind(RemoteFileSystemServer).toDynamicValue(ctx =>
WebSocketConnectionProvider.createProxy(ctx.container, remoteFileSystemPath, new RemoteFileSystemProxyFactory())
);
bind(RemoteFileSystemProvider).toSelf().inSingletonScope();
bind(RemoteFileServiceContribution).toSelf().inSingletonScope();
bind(FileServiceContribution).toService(RemoteFileServiceContribution);
bind(VSCodeFileSystemProvider).toSelf().inSingletonScope();
bind(VSCodeFileServiceContribution).toSelf().inSingletonScope();
bind(FileServiceContribution).toService(VSCodeFileServiceContribution);
bind(FileSystemWatcherErrorHandler).toSelf().inSingletonScope();
bindFileResource(bind);
bind(FileUploadService).to(FileUploadServiceImpl).inSingletonScope();
bind(FileSystemFrontendContribution).toSelf().inSingletonScope();
bind(CommandContribution).toService(FileSystemFrontendContribution);
bind(FrontendApplicationContribution).toService(FileSystemFrontendContribution);
bind(FileTreeLabelProvider).toSelf().inSingletonScope();
bind(LabelProviderContribution).toService(FileTreeLabelProvider);
bind(BreadcrumbsFileTreeWidget).toDynamicValue(ctx =>
createFileTreeBreadcrumbsWidget(ctx.container)
);
bind(FilepathBreadcrumbsContribution).toSelf().inSingletonScope();
bind(BreadcrumbsContribution).toService(FilepathBreadcrumbsContribution);
bind(FilesystemSaveableService).toSelf().inSingletonScope();
rebind(SaveableService).toService(FilesystemSaveableService);
bind(FileTreeDecoratorAdapter).toSelf().inSingletonScope();
});
export function bindFileResource(bind: interfaces.Bind): void {
bind(FileResourceResolver).toSelf().inSingletonScope();
bind(ResourceResolver).toService(FileResourceResolver);
}

View File

@@ -0,0 +1,149 @@
// *****************************************************************************
// Copyright (C) 2022 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { environment, MessageService, nls } from '@theia/core';
import { inject, injectable } from '@theia/core/shared/inversify';
import { Navigatable, Saveable, SaveableSource, SaveOptions, Widget, open, OpenerService, ConfirmDialog, CommonCommands, LabelProvider } from '@theia/core/lib/browser';
import { SaveableService } from '@theia/core/lib/browser/saveable-service';
import URI from '@theia/core/lib/common/uri';
import { FileService } from './file-service';
import { FileDialogService } from './file-dialog';
import { BinaryBuffer } from '@theia/core/lib/common/buffer';
@injectable()
export class FilesystemSaveableService extends SaveableService {
@inject(MessageService)
protected readonly messageService: MessageService;
@inject(FileService)
protected readonly fileService: FileService;
@inject(FileDialogService)
protected readonly fileDialogService: FileDialogService;
@inject(OpenerService)
protected readonly openerService: OpenerService;
@inject(LabelProvider)
protected readonly labelProvider: LabelProvider;
/**
* This method ensures a few things about `widget`:
* - `widget.getResourceUri()` actually returns a URI.
* - `widget.saveable.createSnapshot` or `widget.saveable.serialize` is defined.
* - `widget.saveable.revert` is defined.
*/
override canSaveAs(widget: Widget | undefined): widget is Widget & SaveableSource & Navigatable {
return widget !== undefined
&& Saveable.isSource(widget)
&& (typeof widget.saveable.createSnapshot === 'function' || typeof widget.saveable.serialize === 'function' || typeof widget.saveable.saveAs === 'function')
&& typeof widget.saveable.revert === 'function'
&& Navigatable.is(widget)
&& widget.getResourceUri() !== undefined;
}
/**
* Save `sourceWidget` to a new file picked by the user.
*/
override async saveAs(sourceWidget: Widget & SaveableSource & Navigatable, options?: SaveOptions): Promise<URI | undefined> {
let exist: boolean = false;
let overwrite: boolean = false;
let selected: URI | undefined;
const canSave = this.canSaveNotSaveAs(sourceWidget);
const uri: URI = sourceWidget.getResourceUri()!;
let filters: { [name: string]: string[] } = { 'All Files': ['*'] };
if (sourceWidget.saveable.filters) {
filters = { ...sourceWidget.saveable.filters(), ...filters };
}
do {
selected = await this.fileDialogService.showSaveDialog(
{
title: CommonCommands.SAVE_AS.label!,
filters: filters,
inputValue: uri.path.base
});
if (selected) {
exist = await this.fileService.exists(selected);
if (exist) {
overwrite = await this.confirmOverwrite(selected);
}
}
} while ((selected && exist && !overwrite) || (selected?.isEqual(uri) && !canSave));
if (selected && selected.isEqual(uri)) {
return this.save(sourceWidget, options);
} else if (selected) {
try {
await this.saveSnapshot(sourceWidget, selected, overwrite);
return selected;
} catch (e) {
console.warn(e);
}
}
}
/**
* Saves the current snapshot of the {@link sourceWidget} to the target file
* and replaces the widget with a new one that contains the snapshot content
*
* @param sourceWidget widget to save as `target`.
* @param target The new URI for the widget.
* @param overwrite
*/
protected async saveSnapshot(sourceWidget: Widget & SaveableSource & Navigatable, target: URI, overwrite: boolean): Promise<void> {
const saveable = sourceWidget.saveable;
if (saveable.saveAs) {
// Some widgets have their own "Save As" implementation, such as the custom plugin editors
await saveable.saveAs({
target
});
} else {
// Most other editors simply allow us to serialize the content and write it to the target file.
let buffer: BinaryBuffer;
if (saveable.serialize) {
buffer = await saveable.serialize();
} else if (saveable.createSnapshot) {
const snapshot = saveable.createSnapshot();
const content = Saveable.Snapshot.read(snapshot) ?? '';
buffer = BinaryBuffer.fromString(content);
} else {
throw new Error('Cannot save the widget as the saveable does not provide a snapshot or a serialize method.');
}
if (await this.fileService.exists(target)) {
// Do not fire the `onDidCreate` event as the file already exists.
await this.fileService.writeFile(target, buffer);
} else {
// Ensure to actually call `create` as that fires the `onDidCreate` event.
await this.fileService.createFile(target, buffer, { overwrite });
}
}
await saveable.revert!();
await open(this.openerService, target, { widgetOptions: { ref: sourceWidget, mode: 'tab-replace' } });
}
async confirmOverwrite(uri: URI): Promise<boolean> {
// Electron already handles the confirmation so do not prompt again.
if (this.isElectron()) {
return true;
}
// Prompt users for confirmation before overwriting.
const confirmed = await new ConfirmDialog({
title: nls.localizeByDefault('Overwrite'),
msg: nls.localizeByDefault('{0} already exists. Are you sure you want to overwrite it?', this.labelProvider.getName(uri))
}).open();
return !!confirmed;
}
private isElectron(): boolean {
return environment.electron.is();
}
}

View File

@@ -0,0 +1,60 @@
// *****************************************************************************
// Copyright (C) 2020 Arm and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject } from '@theia/core/shared/inversify';
import { environment } from '@theia/core/shared/@theia/application-package/lib/environment';
import { MessageService } from '@theia/core';
import { WindowService } from '@theia/core/lib/browser/window/window-service';
@injectable()
export class FileSystemWatcherErrorHandler {
@inject(MessageService) protected readonly messageService: MessageService;
@inject(WindowService) protected readonly windowService: WindowService;
protected watchHandlesExhausted: boolean = false;
protected get instructionsLink(): string {
return 'https://code.visualstudio.com/docs/setup/linux#_visual-studio-code-is-unable-to-watch-for-file-changes-in-this-large-workspace-error-enospc';
}
public async handleError(): Promise<void> {
if (!this.watchHandlesExhausted) {
this.watchHandlesExhausted = true;
if (this.isElectron()) {
const instructionsAction = 'Instructions';
const action = await this.messageService.warn(
'Unable to watch for file changes in this large workspace. Please follow the instructions link to resolve this issue.',
{ timeout: 60000 },
instructionsAction
);
if (action === instructionsAction) {
this.windowService.openNewWindow(this.instructionsLink, { external: true });
}
} else {
await this.messageService.warn(
'Unable to watch for file changes in this large workspace. The information you see may not include recent file changes.',
{ timeout: 60000 }
);
}
}
}
protected isElectron(): boolean {
return environment.electron.is();
}
}

View File

@@ -0,0 +1,20 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export * from './location';
export * from './file-tree';
export * from './file-dialog';
export * from './file-resource';

View File

@@ -0,0 +1,18 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export * from './location-service';
export * from './location-renderer';

View File

@@ -0,0 +1,406 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import URI from '@theia/core/lib/common/uri';
import { LocationService } from './location-service';
import * as React from '@theia/core/shared/react';
import { FileService } from '../file-service';
import { DisposableCollection, Emitter, Path } from '@theia/core/lib/common';
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import { FileDialogModel } from '../file-dialog/file-dialog-model';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import { ReactRenderer } from '@theia/core/lib/browser/widgets/react-renderer';
import { codicon } from '@theia/core/lib/browser';
interface AutoSuggestDataEvent {
parent: string;
children: string[];
}
class ResolvedDirectoryCache {
protected pendingResolvedDirectories = new Map<string, Promise<void>>();
protected cachedDirectories = new Map<string, string[]>();
protected directoryResolvedEmitter = new Emitter<AutoSuggestDataEvent>();
readonly onDirectoryDidResolve = this.directoryResolvedEmitter.event;
constructor(protected readonly fileService: FileService) { }
tryResolveChildDirectories(inputAsURI: URI): string[] | undefined {
const parentDirectory = inputAsURI.path.dir.toString();
const cachedDirectories = this.cachedDirectories.get(parentDirectory);
const pendingDirectories = this.pendingResolvedDirectories.get(parentDirectory);
if (cachedDirectories) {
return cachedDirectories;
} else if (!pendingDirectories) {
this.pendingResolvedDirectories.set(parentDirectory, this.createResolutionPromise(parentDirectory));
}
return undefined;
}
protected async createResolutionPromise(directoryToResolve: string): Promise<void> {
return this.fileService.resolve(new URI(directoryToResolve)).then(({ children }) => {
if (children) {
const childDirectories = children.filter(child => child.isDirectory)
.map(directory => `${directory.resource.path}/`);
this.cachedDirectories.set(directoryToResolve, childDirectories);
this.directoryResolvedEmitter.fire({ parent: directoryToResolve, children: childDirectories });
}
}).catch(e => {
// no-op
});
}
}
export const LocationListRendererFactory = Symbol('LocationListRendererFactory');
export interface LocationListRendererFactory {
(options: LocationListRendererOptions): LocationListRenderer;
}
export const LocationListRendererOptions = Symbol('LocationListRendererOptions');
export interface LocationListRendererOptions {
model: FileDialogModel;
host?: HTMLElement;
}
@injectable()
export class LocationListRenderer extends ReactRenderer {
@inject(FileService) protected readonly fileService: FileService;
@inject(EnvVariablesServer) protected readonly variablesServer: EnvVariablesServer;
protected directoryCache: ResolvedDirectoryCache;
protected service: LocationService;
protected toDisposeOnNewCache = new DisposableCollection();
protected _drives: URI[] | undefined;
protected _doShowTextInput = false;
protected homeDir: string;
get doShowTextInput(): boolean {
return this._doShowTextInput;
}
set doShowTextInput(doShow: boolean) {
this._doShowTextInput = doShow;
if (doShow) {
this.initResolveDirectoryCache();
}
}
protected lastUniqueTextInputLocation: URI | undefined;
protected previousAutocompleteMatch: string;
protected doAttemptAutocomplete = true;
constructor(
@inject(LocationListRendererOptions) readonly options: LocationListRendererOptions
) {
super(options.host);
this.service = options.model;
this.doLoadDrives();
this.doAfterRender = this.doAfterRender.bind(this);
}
@postConstruct()
protected init(): void {
this.doInit();
}
protected async doInit(): Promise<void> {
const homeDirWithPrefix = await this.variablesServer.getHomeDirUri();
this.homeDir = (new URI(homeDirWithPrefix)).path.toString();
}
override render(): void {
if (!this.toDispose.disposed) {
this.hostRoot.render(this.doRender());
}
}
protected initResolveDirectoryCache(): void {
this.toDisposeOnNewCache.dispose();
this.directoryCache = new ResolvedDirectoryCache(this.fileService);
this.toDisposeOnNewCache.push(this.directoryCache.onDirectoryDidResolve(({ parent, children }) => {
if (this.locationTextInput) {
const expandedPath = Path.untildify(this.locationTextInput.value, this.homeDir);
const inputParent = (new URI(expandedPath)).path.dir.toString();
if (inputParent === parent) {
this.tryRenderFirstMatch(this.locationTextInput, children);
}
}
}));
}
protected doAfterRender = (): void => {
const locationList = this.locationList;
const locationListTextInput = this.locationTextInput;
if (locationList) {
const currentLocation = this.service.location;
locationList.value = currentLocation ? currentLocation.toString() : '';
} else if (locationListTextInput) {
locationListTextInput.focus();
}
};
protected readonly handleLocationChanged = (e: React.ChangeEvent<HTMLSelectElement>) => this.onLocationChanged(e);
protected readonly handleTextInputOnChange = (e: React.ChangeEvent<HTMLInputElement>) => this.trySuggestDirectory(e);
protected readonly handleTextInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => this.handleControlKeys(e);
protected readonly handleIconKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => this.toggleInputOnKeyDown(e);
protected readonly handleTextInputOnBlur = () => this.toggleToSelectInput();
protected readonly handleTextInputMouseDown = (e: React.MouseEvent<HTMLSpanElement>) => this.toggleToTextInputOnMouseDown(e);
protected override doRender(): React.ReactElement {
return (
<>
{this.renderInputIcon()}
{this.doShowTextInput
? this.renderTextInput()
: this.renderSelectInput()
}
</>
);
}
protected renderInputIcon(): React.ReactNode {
return (
<span
// onMouseDown is used since it will fire before 'onBlur'. This prevents
// a re-render when textinput is in focus and user clicks toggle icon
onMouseDown={this.handleTextInputMouseDown}
onKeyDown={this.handleIconKeyDown}
className={LocationListRenderer.Styles.LOCATION_INPUT_TOGGLE_CLASS}
tabIndex={0}
id={`${this.doShowTextInput ? 'text-input' : 'select-input'}`}
title={this.doShowTextInput
? LocationListRenderer.Tooltips.TOGGLE_SELECT_INPUT
: LocationListRenderer.Tooltips.TOGGLE_TEXT_INPUT}
ref={this.doAfterRender}
>
<i className={codicon(this.doShowTextInput ? 'folder-opened' : 'edit')} />
</span>
);
}
protected renderTextInput(): React.ReactNode {
return (
<input className={'theia-select ' + LocationListRenderer.Styles.LOCATION_TEXT_INPUT_CLASS}
defaultValue={this.service.location?.path.fsPath()}
onBlur={this.handleTextInputOnBlur}
onChange={this.handleTextInputOnChange}
onKeyDown={this.handleTextInputKeyDown}
spellCheck={false}
/>
);
}
protected renderSelectInput(): React.ReactNode {
const options = this.collectLocations().map(value => this.renderLocation(value));
return (
<select className={`theia-select ${LocationListRenderer.Styles.LOCATION_LIST_CLASS}`}
onChange={this.handleLocationChanged}>
{...options}
</select>
);
}
protected toggleInputOnKeyDown(e: React.KeyboardEvent<HTMLSpanElement>): void {
if (e.key === 'Enter') {
this.doShowTextInput = true;
this.render();
}
}
protected toggleToTextInputOnMouseDown(e: React.MouseEvent<HTMLSpanElement>): void {
if (e.currentTarget.id === 'select-input') {
e.preventDefault();
this.doShowTextInput = true;
this.render();
}
}
protected toggleToSelectInput(): void {
if (this.doShowTextInput) {
this.doShowTextInput = false;
this.render();
}
}
/**
* Collects the available locations based on the currently selected, and appends the available drives to it.
*/
protected collectLocations(): LocationListRenderer.Location[] {
const location = this.service.location;
const locations: LocationListRenderer.Location[] = (!!location ? location.allLocations : []).map(uri => ({ uri }));
if (this._drives) {
const drives = this._drives.map(uri => ({ uri, isDrive: true }));
// `URI.allLocations` returns with the URI without the trailing slash unlike `FileUri.create(fsPath)`.
// to be able to compare file:///path/to/resource with file:///path/to/resource/.
const toUriString = (uri: URI) => {
const toString = uri.toString();
return toString.endsWith('/') ? toString.slice(0, -1) : toString;
};
drives.forEach(drive => {
const index = locations.findIndex(loc => toUriString(loc.uri) === toUriString(drive.uri));
// Ignore drives which are already discovered as a location based on the current model root URI.
if (index === -1) {
// Make sure, it does not have the trailing slash.
locations.push({ uri: new URI(toUriString(drive.uri)), isDrive: true });
} else {
// This is necessary for Windows to be able to show `/e:/` as a drive and `c:` as "non-drive" in the same way.
// `URI.path.toString()` Vs. `URI.displayName` behaves a bit differently on Windows.
// https://github.com/eclipse-theia/theia/pull/3038#issuecomment-425944189
locations[index].isDrive = true;
}
});
}
this.doLoadDrives();
return locations;
}
/**
* Asynchronously loads the drives (if not yet available) and triggers a UI update on success with the new values.
*/
protected doLoadDrives(): void {
if (!this._drives) {
this.service.drives().then(drives => {
// If the `drives` are empty, something already went wrong.
if (drives.length > 0) {
this._drives = drives;
this.render();
}
});
}
}
protected renderLocation(location: LocationListRenderer.Location): React.ReactNode {
const { uri, isDrive } = location;
const value = uri.toString();
return <option value={value} key={uri.toString()}>{isDrive ? uri.path.fsPath() : uri.displayName}</option>;
}
protected onLocationChanged(e: React.ChangeEvent<HTMLSelectElement>): void {
const locationList = this.locationList;
if (locationList) {
const value = locationList.value;
const uri = new URI(value);
this.trySetNewLocation(uri);
e.preventDefault();
e.stopPropagation();
}
}
protected trySetNewLocation(newLocation: URI): void {
if (this.lastUniqueTextInputLocation === undefined) {
this.lastUniqueTextInputLocation = this.service.location;
}
// prevent consecutive repeated locations from being added to location history
if (this.lastUniqueTextInputLocation?.path.toString() !== newLocation.path.toString()) {
this.lastUniqueTextInputLocation = newLocation;
this.service.location = newLocation;
}
}
protected trySuggestDirectory(e: React.ChangeEvent<HTMLInputElement>): void {
if (this.doAttemptAutocomplete) {
const inputElement = e.currentTarget;
const { value } = inputElement;
if ((value.startsWith('/') || value.startsWith('~/')) && value.slice(-1) !== '/') {
const expandedPath = Path.untildify(value, this.homeDir);
const valueAsURI = new URI(expandedPath);
const autocompleteDirectories = this.directoryCache.tryResolveChildDirectories(valueAsURI);
if (autocompleteDirectories) {
this.tryRenderFirstMatch(inputElement, autocompleteDirectories);
}
}
}
}
protected tryRenderFirstMatch(inputElement: HTMLInputElement, children: string[]): void {
const { value, selectionStart } = inputElement;
if (this.locationTextInput) {
const expandedPath = Path.untildify(value, this.homeDir);
const firstMatch = children?.find(child => child.includes(expandedPath));
if (firstMatch) {
const contractedPath = value.startsWith('~') ? Path.tildify(firstMatch, this.homeDir) : firstMatch;
this.locationTextInput.value = contractedPath;
this.locationTextInput.selectionStart = selectionStart;
this.locationTextInput.selectionEnd = firstMatch.length;
}
}
}
protected handleControlKeys(e: React.KeyboardEvent<HTMLInputElement>): void {
this.doAttemptAutocomplete = e.key !== 'Backspace';
if (e.key === 'Enter') {
const locationTextInput = this.locationTextInput;
if (locationTextInput) {
// expand '~' if present and remove extra whitespace and any trailing slashes or periods.
const sanitizedInput = locationTextInput.value.trim().replace(/[\/\\.]*$/, '');
const untildifiedInput = Path.untildify(sanitizedInput, this.homeDir);
const uri = new URI(untildifiedInput);
this.trySetNewLocation(uri);
this.toggleToSelectInput();
}
} else if (e.key === 'Escape') {
this.toggleToSelectInput();
} else if (e.key === 'Tab') {
e.preventDefault();
const textInput = this.locationTextInput;
if (textInput) {
textInput.selectionStart = textInput.value.length;
}
}
e.stopPropagation();
}
get locationList(): HTMLSelectElement | undefined {
const locationList = this.host.getElementsByClassName(LocationListRenderer.Styles.LOCATION_LIST_CLASS)[0];
if (locationList instanceof HTMLSelectElement) {
return locationList;
}
return undefined;
}
get locationTextInput(): HTMLInputElement | undefined {
const locationTextInput = this.host.getElementsByClassName(LocationListRenderer.Styles.LOCATION_TEXT_INPUT_CLASS)[0];
if (locationTextInput instanceof HTMLInputElement) {
return locationTextInput;
}
return undefined;
}
override dispose(): void {
super.dispose();
this.toDisposeOnNewCache.dispose();
}
}
export namespace LocationListRenderer {
export namespace Styles {
export const LOCATION_LIST_CLASS = 'theia-LocationList';
export const LOCATION_INPUT_TOGGLE_CLASS = 'theia-LocationInputToggle';
export const LOCATION_TEXT_INPUT_CLASS = 'theia-LocationTextInput';
}
export namespace Tooltips {
export const TOGGLE_TEXT_INPUT = 'Switch to text-based input';
export const TOGGLE_SELECT_INPUT = 'Switch to location list';
}
export interface Location {
uri: URI;
isDrive?: boolean;
}
}

View File

@@ -0,0 +1,22 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import URI from '@theia/core/lib/common/uri';
export interface LocationService {
location: URI | undefined;
drives(): Promise<URI[]>;
}

View File

@@ -0,0 +1,38 @@
// *****************************************************************************
// Copyright (C) 2020 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable } from '@theia/core/shared/inversify';
import { FileServiceContribution, FileService } from './file-service';
import { RemoteFileSystemProvider } from '../common/remote-file-system-provider';
@injectable()
export class RemoteFileServiceContribution implements FileServiceContribution {
@inject(RemoteFileSystemProvider)
protected readonly provider: RemoteFileSystemProvider;
registerFileSystemProviders(service: FileService): void {
const registering = this.provider.ready.then(() =>
service.registerProvider('file', this.provider)
);
service.onWillActivateFileSystemProvider(event => {
if (event.scheme === 'file') {
event.waitUntil(registering);
}
});
}
}

View File

@@ -0,0 +1,208 @@
/********************************************************************************
* Copyright (C) 2017 TypeFox and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
********************************************************************************/
:root {
--theia-private-file-dialog-input-height: 21px;
--theia-private-location-list-panel-left: 92px;
--theia-private-location-list-panel-width: 407px;
--theia-private-navigation-panel-icon-size: 21px;
--theia-private-navigation-panel-line-height: 23px;
}
/*
* Open and Save file dialogs
*/
.dialogContent .theia-FileDialog,
.dialogContent .theia-SaveFileDialog,
.dialogContent .theia-ResponsiveFileDialog {
height: 500px;
width: 500px;
border: 1px solid var(--theia-editorWidget-border);
background: var(--theia-dropdown-background);
}
@media only screen and (max-height: 700px) {
.dialogContent .theia-FileDialog,
.dialogContent .theia-SaveFileDialog,
.dialogContent .theia-ResponsiveFileDialog {
height: 300px;
}
}
.dialogContent .theia-NavigationPanel,
.dialogContent .theia-FiltersPanel,
.dialogContent .theia-FileNamePanel {
display: block;
position: relative;
overflow: hidden;
}
.dialogContent .theia-NavigationPanel,
.dialogContent .theia-FiltersPanel {
min-height: 27px;
}
.dialogContent .theia-FileNamePanel {
height: 31px;
}
/*
* Navigation panel items
*/
.dialogContent .theia-NavigationPanel span {
position: absolute;
top: 2px;
line-height: var(--theia-private-navigation-panel-line-height);
cursor: pointer;
width: var(--theia-private-navigation-panel-icon-size);
text-align: center;
}
.dialogContent .theia-NavigationPanel span:focus {
outline: none;
box-shadow: none;
}
.dialogContent .theia-NavigationPanel span:focus-visible {
outline-width: 1px;
outline-style: solid;
outline-offset: -1px;
opacity: 1 !important;
outline-color: var(--theia-focusBorder);
}
.dialogContent span.theia-mod-disabled {
pointer-events: none;
cursor: default;
}
.dialogContent span.theia-mod-disabled .action-label {
background: none;
}
.dialogContent .theia-NavigationBack {
left: auto;
}
.dialogContent .theia-NavigationForward {
left: 23px;
}
.dialogContent .theia-NavigationHome {
left: 45px;
}
.dialogContent .theia-NavigationUp {
left: 67px;
}
.dialogContent .theia-LocationListPanel {
position: absolute;
display: flex;
top: 1px;
left: var(--theia-private-location-list-panel-left);
width: var(--theia-private-location-list-panel-width);
height: var(--theia-private-file-dialog-input-height);
}
.dialogContent .theia-LocationInputToggle {
text-align: center;
left: 0;
width: var(--theia-private-navigation-panel-icon-size);
height: var(--theia-private-navigation-panel-icon-size);
z-index: 1;
}
.dialogContent .theia-LocationList,
.dialogContent .theia-LocationTextInput {
box-sizing: content-box;
padding: unset;
position: absolute;
top: 0;
left: 0;
height: var(--theia-private-file-dialog-input-height);
border: var(--theia-border-width) solid var(--theia-input-border);
}
.dialogContent .theia-LocationList,
.dialogContent .theia-LocationTextInput {
padding-left: var(--theia-private-navigation-panel-icon-size);
width: calc(100% - var(--theia-private-navigation-panel-icon-size));
}
/*
* Filters panel items
*/
.dialogContent .theia-FiltersLabel {
position: absolute;
left: 1px;
top: 0px;
line-height: 27px;
}
.dialogContent .theia-FiltersListPanel {
position: absolute;
left: 72px;
top: 3px;
}
.dialogContent .theia-FileTreeFiltersList {
width: 427px;
height: var(--theia-private-file-dialog-input-height);
}
/*
* File name panel items
*/
.dialogContent .theia-FileNameLabel {
position: absolute;
left: 1px;
top: 0px;
line-height: 23px;
}
.dialogContent .theia-FileNameTextField {
position: absolute;
left: 72px;
top: 0px;
width: 420px;
}
/*
* Control panel items
*/
.dialogContent .theia-ControlPanel {
display: flex;
flex-direction: row;
justify-content: flex-end;
margin-bottom: 0px;
}
.dialogContent .theia-ControlPanel > * {
margin-left: 4px;
}
.dialogContent .theia-ToggleHiddenInputContainer {
display: flex;
justify-content: flex-end;
align-items: center;
padding-top: var(--theia-ui-padding);
}

View File

@@ -0,0 +1,64 @@
/********************************************************************************
* Copyright (C) 2017 TypeFox and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
********************************************************************************/
.theia-file-icons-js {
/*
Here, the `line-height` ensures that FontAwesome and `file-icons-js` container has the same height.
Ideally, it would be 1 em, but since we set the max height above (and other places too) with 0.8
multiplier, we use 0.8 em here too.
*/
line-height: 0.8em;
}
.theia-file-icons-js:before {
font-size: calc(var(--theia-content-font-size) * 0.8);
}
.lm-TabBar-tabIcon.theia-file-icons-js.file-icon {
padding-left: 1px !important;
padding-right: 3px !important;
}
.theia-file-icons-js.file-icon {
min-width: var(--theia-icon-size);
display: flex;
justify-content: center;
align-items: center;
padding-left: 2px;
padding-right: 4px;
}
.default-folder-icon,
.default-file-icon {
padding-right: 6px;
}
.fa-file:before,
.fa-folder:before,
.theia-file-icons-js:before {
text-align: center;
margin-right: 4px;
}
.theia-app-sides .theia-file-icons-js {
max-height: none;
line-height: inherit;
}
.theia-app-sides .theia-file-icons-js:before {
font-size: var(--theia-private-sidebar-icon-size);
margin-right: 0px;
}

View File

@@ -0,0 +1,20 @@
/********************************************************************************
* Copyright (C) 2019 TypeFox and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
********************************************************************************/
.theia-FilepathBreadcrumbFileTree {
height: auto;
max-height: 200px;
}

View File

@@ -0,0 +1,36 @@
/********************************************************************************
* Copyright (C) 2017 TypeFox and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
********************************************************************************/
@import "./file-dialog.css";
@import "./file-icons.css";
@import "./filepath-breadcrumbs.css";
.theia-file-tree-drag-image {
position: absolute;
/*
make sure you don't see it flashing
*/
top: -1000px;
font-size: var(--theia-ui-font-size1);
display: inline-block;
padding: 1px calc(var(--theia-ui-padding) * 2);
border-radius: 10px;
background: var(--theia-list-activeSelectionBackground);
color: var(--theia-list-activeSelectionForeground);
outline: 1px solid var(--theia-contrastActiveBorder);
outline-offset: -1px;
}

View File

@@ -0,0 +1,511 @@
// *****************************************************************************
// Copyright (C) 2019 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
/* eslint-disable @typescript-eslint/no-explicit-any */
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { CancellationTokenSource, CancellationToken, checkCancelled, cancelled, isCancelled } from '@theia/core/lib/common/cancellation';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { MessageService } from '@theia/core/lib/common/message-service';
import { Progress } from '@theia/core/lib/common/message-service-protocol';
import { Endpoint } from '@theia/core/lib/browser/endpoint';
import throttle = require('@theia/core/shared/lodash.throttle');
import { HTTP_FILE_UPLOAD_PATH } from '../../common/file-upload';
import { Semaphore } from 'async-mutex';
import { FileSystemPreferences } from '../../common/filesystem-preferences';
import { FileService } from '../file-service';
import { ConfirmDialog, Dialog } from '@theia/core/lib/browser';
import { nls } from '@theia/core/lib/common/nls';
import { Emitter, Event } from '@theia/core/lib/common/event';
import type { CustomDataTransfer, FileUploadService } from '../../common/upload/file-upload';
interface UploadFilesParams {
source: FileUploadService.Source,
progress: Progress,
token: CancellationToken,
leaveInTemp?: boolean,
onDidUpload?: (uri: string) => void,
}
export const HTTP_UPLOAD_URL: string = new Endpoint({ path: HTTP_FILE_UPLOAD_PATH }).getRestUrl().toString(true);
@injectable()
export class FileUploadServiceImpl implements FileUploadService {
static TARGET = 'target';
static UPLOAD = 'upload';
protected readonly onDidUploadEmitter = new Emitter<string[]>();
get onDidUpload(): Event<string[]> {
return this.onDidUploadEmitter.event;
}
protected uploadForm: FileUploadService.Form;
protected deferredUpload?: Deferred<FileUploadService.UploadResult>;
@inject(MessageService)
protected readonly messageService: MessageService;
@inject(FileSystemPreferences)
protected fileSystemPreferences: FileSystemPreferences;
@inject(FileService)
protected fileService: FileService;
get maxConcurrentUploads(): number {
const maxConcurrentUploads = this.fileSystemPreferences['files.maxConcurrentUploads'];
return maxConcurrentUploads > 0 ? maxConcurrentUploads : Infinity;
}
@postConstruct()
protected init(): void {
this.uploadForm = this.createUploadForm();
}
protected createUploadForm(): FileUploadService.Form {
const targetInput = document.createElement('input');
targetInput.type = 'text';
targetInput.spellcheck = false;
targetInput.name = FileUploadServiceImpl.TARGET;
targetInput.classList.add('theia-input');
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.classList.add('theia-input');
fileInput.name = FileUploadServiceImpl.UPLOAD;
fileInput.multiple = true;
const form = document.createElement('form');
form.style.display = 'none';
form.enctype = 'multipart/form-data';
form.append(targetInput);
form.append(fileInput);
document.body.appendChild(form);
fileInput.addEventListener('change', () => {
if (this.deferredUpload && fileInput.value) {
const source: FileUploadService.Source = new FormData(form);
// clean up to allow upload to the same folder twice
fileInput.value = '';
const targetUri = new URI(<string>source.get(FileUploadServiceImpl.TARGET));
const { resolve, reject } = this.deferredUpload;
this.deferredUpload = undefined;
const { onDidUpload } = this.uploadForm;
this.withProgress(
(progress, token) => this.uploadAll(targetUri, { source, progress, token, onDidUpload })
).then(resolve, reject);
}
});
return { targetInput, fileInput };
}
async upload(targetUri: string | URI, params?: FileUploadService.UploadParams): Promise<FileUploadService.UploadResult> {
const { source, onDidUpload, leaveInTemp } = params || {};
if (source) {
return this.withProgress(
(progress, token) => this.uploadAll(
typeof targetUri === 'string' ? new URI(targetUri) : targetUri,
{ source, progress, token, leaveInTemp, onDidUpload }
)
);
}
this.deferredUpload = new Deferred<FileUploadService.UploadResult>();
this.uploadForm.targetInput.value = String(targetUri);
this.uploadForm.fileInput.click();
this.uploadForm.onDidUpload = params?.onDidUpload;
return this.deferredUpload.promise;
}
protected getUploadUrl(): string {
return HTTP_UPLOAD_URL;
}
protected async uploadAll(targetUri: URI, params: UploadFilesParams): Promise<FileUploadService.UploadResult> {
const responses: Promise<void>[] = [];
const status = new Map<File, {
total: number
done: number
uploaded?: boolean
}>();
const result: FileUploadService.UploadResult = {
uploaded: []
};
/**
* When `false`: display the uploading progress.
* When `true`: display the server-processing progress.
*/
let waitingForResponses = false;
const report = throttle(() => {
if (waitingForResponses) {
/** Number of files being processed. */
const total = status.size;
/** Number of files uploaded and processed. */
let done = 0;
for (const item of status.values()) {
if (item.uploaded) {
done += 1;
}
}
params.progress.report({
message: nls.localize('theia/filesystem/processedOutOf', 'Processed {0} out of {1}', done, total),
work: { total, done }
});
} else {
/** Total number of bytes being uploaded. */
let total = 0;
/** Current number of bytes uploaded. */
let done = 0;
for (const item of status.values()) {
total += item.total;
done += item.done;
}
params.progress.report({
message: nls.localize('theia/filesystem/uploadedOutOf', 'Uploaded {0} out of {1}', result.uploaded.length, status.size),
work: { total, done }
});
}
}, 100);
const uploads: Promise<void>[] = [];
const uploadSemaphore = new Semaphore(this.maxConcurrentUploads);
try {
await this.index(targetUri, params.source, {
token: params.token,
progress: params.progress,
accept: async item => {
if (await this.fileService.exists(item.uri) && !await this.confirmOverwrite(item.uri)) {
return;
}
// Track and initialize the file in the status map:
status.set(item.file, { total: item.file.size, done: 0 });
report();
// Don't await here: the semaphore will organize the uploading tasks, not the async indexer.
uploads.push(uploadSemaphore.runExclusive(async () => {
checkCancelled(params.token);
const { upload, response } = this.uploadFile(item.file, item.uri, params.token, params.leaveInTemp, (total, done) => {
const entry = status.get(item.file);
if (entry) {
entry.total = total;
entry.done = done;
report();
}
});
function onError(error: Error): void {
status.delete(item.file);
throw error;
}
responses.push(response
.then(() => {
checkCancelled(params.token);
// Consider the file uploaded once the server sends OK back.
result.uploaded.push(item.uri.toString(true));
const entry = status.get(item.file);
if (entry) {
entry.uploaded = true;
report();
}
})
.catch(onError)
);
// Have the queue wait for the upload only.
return upload
.catch(onError);
}));
}
});
checkCancelled(params.token);
await Promise.all(uploads);
checkCancelled(params.token);
waitingForResponses = true;
report();
await Promise.all(responses);
} catch (error) {
uploadSemaphore.cancel();
if (!isCancelled(error)) {
this.messageService.error(nls.localize('theia/filesystem/uploadFailed', 'An error occurred while uploading a file. {0}', error.message));
throw error;
}
}
this.onDidUploadEmitter.fire(result.uploaded);
return result;
}
protected async confirmOverwrite(fileUri: URI): Promise<boolean> {
const dialog = new ConfirmDialog({
title: nls.localizeByDefault('Replace'),
msg: nls.localizeByDefault("A file or folder with the name '{0}' already exists in the destination folder. Do you want to replace it?", fileUri.path.base),
ok: nls.localizeByDefault('Replace'),
cancel: Dialog.CANCEL
});
return !!await dialog.open();
}
protected uploadFile(
file: File,
targetUri: URI,
token: CancellationToken,
leaveInTemp: boolean | undefined,
onProgress: (total: number, done: number) => void
): {
/**
* Promise that resolves once the uploading is finished.
*
* Rejects on network error.
* Rejects if status is not OK (200).
* Rejects if cancelled.
*/
upload: Promise<void>
/**
* Promise that resolves after the uploading step, once the server answers back.
*
* Rejects on network error.
* Rejects if status is not OK (200).
* Rejects if cancelled.
*/
response: Promise<void>
} {
const data = new FormData();
data.set('uri', targetUri.toString(true));
data.set('file', file);
if (leaveInTemp) {
data.set('leaveInTemp', 'true');
}
// TODO: Use Fetch API once it supports upload monitoring.
const xhr = new XMLHttpRequest();
token.onCancellationRequested(() => xhr.abort());
const upload = new Promise<void>((resolve, reject) => {
this.registerEvents(xhr.upload, unregister => ({
progress: (event: ProgressEvent<XMLHttpRequestEventTarget>) => {
if (event.total === event.loaded) {
unregister();
resolve();
} else {
onProgress(event.total, event.loaded);
}
},
abort: () => {
unregister();
reject(cancelled());
},
error: () => {
unregister();
reject(new Error('POST upload error'));
},
// `load` fires once the response is received, not when the upload is finished.
// `resolve` should be called earlier within `progress` but this is a safety catch.
load: () => {
unregister();
if (xhr.status === 200) {
resolve();
} else {
reject(new Error(`POST request failed: ${xhr.status} ${xhr.statusText}`));
}
},
}));
});
const response = new Promise<void>((resolve, reject) => {
this.registerEvents(xhr, unregister => ({
abort: () => {
unregister();
reject(cancelled());
},
error: () => {
unregister();
reject(new Error('POST request error'));
},
load: () => {
unregister();
if (xhr.status === 200) {
resolve();
} else if (xhr.status === 500 && xhr.statusText !== xhr.response) {
// internal error with cause message
// see packages/filesystem/src/node/upload/node-file-upload-service.ts
reject(new Error(`Internal server error: ${xhr.response}`));
} else {
reject(new Error(`POST request failed: ${xhr.status} ${xhr.statusText}`));
}
}
}));
});
xhr.open('POST', this.getUploadUrl(), /* async: */ true);
xhr.send(data);
return {
upload,
response
};
}
/**
* Utility function to attach events and get a callback to unregister those.
*
* You may not call `unregister` in the same tick as `register` is invoked.
*/
protected registerEvents(
target: EventTarget,
register: (unregister: () => void) => Record<string, EventListenerOrEventListenerObject>
): void {
const events = register(() => {
for (const [event, fn] of Object.entries(events)) {
target.removeEventListener(event, fn);
}
});
for (const [event, fn] of Object.entries(events)) {
target.addEventListener(event, fn);
}
}
protected async withProgress<T>(
cb: (progress: Progress, token: CancellationToken) => Promise<T>
): Promise<T> {
const cancellationSource = new CancellationTokenSource();
const { token } = cancellationSource;
const text = nls.localize('theia/filesystem/uploadFiles', 'Uploading Files');
const progress = await this.messageService.showProgress(
{ text, options: { cancelable: true } },
() => cancellationSource.cancel()
);
try {
return await cb(progress, token);
} finally {
progress.cancel();
}
}
protected async index(targetUri: URI, source: FileUploadService.Source, context: FileUploadService.Context): Promise<void> {
if (source instanceof FormData) {
await this.indexFormData(targetUri, source, context);
} else if (source instanceof DataTransfer) {
await this.indexDataTransfer(targetUri, source, context);
} else {
await this.indexCustomDataTransfer(targetUri, source, context);
}
}
protected async indexFormData(targetUri: URI, formData: FormData, context: FileUploadService.Context): Promise<void> {
for (const entry of formData.getAll(FileUploadServiceImpl.UPLOAD)) {
if (entry instanceof File) {
await this.indexFile(targetUri, entry, context);
}
}
}
protected async indexDataTransfer(targetUri: URI, dataTransfer: DataTransfer, context: FileUploadService.Context): Promise<void> {
checkCancelled(context.token);
if (dataTransfer.items) {
await this.indexDataTransferItemList(targetUri, dataTransfer.items, context);
} else {
await this.indexFileList(targetUri, dataTransfer.files, context);
}
}
protected async indexCustomDataTransfer(targetUri: URI, dataTransfer: CustomDataTransfer, context: FileUploadService.Context): Promise<void> {
for (const [_, item] of dataTransfer) {
const fileInfo = item.asFile();
if (fileInfo) {
await this.indexFile(targetUri, new File([await fileInfo.data() as BlobPart], fileInfo.id), context);
}
}
}
protected async indexFileList(targetUri: URI, files: FileList, context: FileUploadService.Context): Promise<void> {
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file) {
await this.indexFile(targetUri, file, context);
}
}
}
protected async indexFile(targetUri: URI, file: File, context: FileUploadService.Context): Promise<void> {
await context.accept({
uri: targetUri.resolve(file.name),
file
});
}
protected async indexDataTransferItemList(targetUri: URI, items: DataTransferItemList, context: FileUploadService.Context): Promise<void> {
checkCancelled(context.token);
const entries: WebKitEntry[] = [];
for (let i = 0; i < items.length; i++) {
const entry = items[i].webkitGetAsEntry() as WebKitEntry;
entries.push(entry);
}
await this.indexEntries(targetUri, entries, context);
}
protected async indexEntry(targetUri: URI, entry: WebKitEntry | null, context: FileUploadService.Context): Promise<void> {
checkCancelled(context.token);
if (!entry) {
return;
}
if (entry.isDirectory) {
await this.indexDirectoryEntry(targetUri, entry as WebKitDirectoryEntry, context);
} else {
await this.indexFileEntry(targetUri, entry as WebKitFileEntry, context);
}
}
/**
* Read all entries within a folder by block of 100 files or folders until the
* whole folder has been read.
*/
protected async indexDirectoryEntry(targetUri: URI, entry: WebKitDirectoryEntry, context: FileUploadService.Context): Promise<void> {
checkCancelled(context.token);
const newTargetUri = targetUri.resolve(entry.name);
return new Promise<void>(async (resolve, reject) => {
const reader = entry.createReader();
const getEntries = () => reader.readEntries(async results => {
try {
if (!context.token.isCancellationRequested && results && results.length) {
await this.indexEntries(newTargetUri, results, context);
getEntries(); // loop to read all getEntries
} else {
resolve();
}
} catch (e) {
reject(e);
}
}, reject);
getEntries();
});
}
protected async indexEntries(targetUri: URI, entries: WebKitEntry[], context: FileUploadService.Context): Promise<void> {
checkCancelled(context.token);
for (let i = 0; i < entries.length; i++) {
await this.indexEntry(targetUri, entries[i], context);
}
}
protected async indexFileEntry(targetUri: URI, entry: WebKitFileEntry, context: FileUploadService.Context): Promise<void> {
await new Promise<void>((resolve, reject) => {
try {
entry.file(
file => this.indexFile(targetUri, file, context).then(resolve, reject),
reject,
);
} catch (e) {
reject(e);
}
});
}
}

View File

@@ -0,0 +1,93 @@
// *****************************************************************************
// Copyright (C) 2024 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable } from '@theia/core/shared/inversify';
import { FileServiceContribution, FileService } from './file-service';
import {
FileChange, FileDeleteOptions, FileOverwriteOptions, FilePermission, FileSystemProvider, FileSystemProviderCapabilities, FileType, FileWriteOptions, Stat, WatchOptions
} from '../common/files';
import { Event, URI, Disposable, Emitter } from '@theia/core';
import { JsonSchemaDataStore } from '@theia/core/lib/browser/json-schema-store';
import { BinaryBuffer } from '@theia/core/lib/common/buffer';
@injectable()
export class VSCodeFileSystemProvider implements FileSystemProvider {
readonly capabilities = FileSystemProviderCapabilities.Readonly + FileSystemProviderCapabilities.FileReadWrite;
readonly onDidChangeCapabilities = Event.None;
protected readonly onDidChangeFileEmitter = new Emitter<readonly FileChange[]>();
readonly onDidChangeFile = this.onDidChangeFileEmitter.event;
readonly onFileWatchError = Event.None;
@inject(JsonSchemaDataStore)
protected readonly store: JsonSchemaDataStore;
watch(resource: URI, opts: WatchOptions): Disposable {
return Disposable.NULL;
}
async stat(resource: URI): Promise<Stat> {
if (this.store.hasSchema(resource)) {
const currentTime = Date.now();
return {
type: FileType.File,
permissions: FilePermission.Readonly,
mtime: currentTime,
ctime: currentTime,
size: 0
};
}
throw new Error('Not Found!');
}
mkdir(resource: URI): Promise<void> {
return Promise.resolve();
}
readdir(resource: URI): Promise<[string, FileType][]> {
return Promise.resolve([]);
}
delete(resource: URI, opts: FileDeleteOptions): Promise<void> {
return Promise.resolve();
}
rename(from: URI, to: URI, opts: FileOverwriteOptions): Promise<void> {
return Promise.resolve();
}
async readFile(resource: URI): Promise<Uint8Array> {
if (resource.scheme !== 'vscode') {
throw new Error('Not Supported!');
}
let content: string | undefined;
if (resource.authority === 'schemas') {
content = this.store.getSchema(resource);
}
if (typeof content === 'string') {
return BinaryBuffer.fromString(content).buffer;
}
throw new Error('Not Found!');
}
writeFile(resource: URI, content: Uint8Array, opts: FileWriteOptions): Promise<void> {
throw new Error('Not Supported!');
}
}
@injectable()
export class VSCodeFileServiceContribution implements FileServiceContribution {
@inject(VSCodeFileSystemProvider)
protected readonly provider: VSCodeFileSystemProvider;
registerFileSystemProviders(service: FileService): void {
service.registerProvider('vscode', this.provider);
}
}

View File

@@ -0,0 +1,226 @@
// *****************************************************************************
// Copyright (C) 2020 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import URI from '@theia/core/lib/common/uri';
import { Event, Emitter, CancellationToken } from '@theia/core/lib/common';
import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable';
import {
FileSystemProvider, FileSystemProviderCapabilities, WatchOptions, FileDeleteOptions, FileOverwriteOptions, FileWriteOptions, FileOpenOptions, FileChange, Stat, FileType,
hasReadWriteCapability, hasFileFolderCopyCapability, hasOpenReadWriteCloseCapability, hasAccessCapability, FileUpdateOptions, hasUpdateCapability, FileUpdateResult,
FileReadStreamOptions,
hasFileReadStreamCapability
} from './files';
import type { TextDocumentContentChangeEvent } from '@theia/core/shared/vscode-languageserver-protocol';
import { ReadableStreamEvents } from '@theia/core/lib/common/stream';
export class DelegatingFileSystemProvider implements Required<FileSystemProvider>, Disposable {
private readonly onDidChangeFileEmitter = new Emitter<readonly FileChange[]>();
readonly onDidChangeFile = this.onDidChangeFileEmitter.event;
private readonly onFileWatchErrorEmitter = new Emitter<void>();
readonly onFileWatchError = this.onFileWatchErrorEmitter.event;
constructor(
protected readonly delegate: FileSystemProvider,
protected readonly options: DelegatingFileSystemProvider.Options,
protected readonly toDispose = new DisposableCollection()
) {
this.toDispose.push(this.onDidChangeFileEmitter);
this.toDispose.push(delegate.onDidChangeFile(changes => this.handleFileChanges(changes)));
this.toDispose.push(this.onFileWatchErrorEmitter);
this.toDispose.push(delegate.onFileWatchError(changes => this.onFileWatchErrorEmitter.fire()));
}
dispose(): void {
this.toDispose.dispose();
}
get capabilities(): FileSystemProviderCapabilities {
return this.delegate.capabilities;
}
get onDidChangeCapabilities(): Event<void> {
return this.delegate.onDidChangeCapabilities;
}
watch(resource: URI, opts: WatchOptions): Disposable {
return this.delegate.watch(this.toUnderlyingResource(resource), opts);
}
stat(resource: URI): Promise<Stat> {
return this.delegate.stat(this.toUnderlyingResource(resource));
}
access(resource: URI, mode?: number): Promise<void> {
if (hasAccessCapability(this.delegate)) {
return this.delegate.access(this.toUnderlyingResource(resource), mode);
}
throw new Error('not supported');
}
fsPath(resource: URI): Promise<string> {
if (hasAccessCapability(this.delegate)) {
return this.delegate.fsPath(this.toUnderlyingResource(resource));
}
throw new Error('not supported');
}
mkdir(resource: URI): Promise<void> {
return this.delegate.mkdir(this.toUnderlyingResource(resource));
}
rename(from: URI, to: URI, opts: FileOverwriteOptions): Promise<void> {
return this.delegate.rename(this.toUnderlyingResource(from), this.toUnderlyingResource(to), opts);
}
copy(from: URI, to: URI, opts: FileOverwriteOptions): Promise<void> {
if (hasFileFolderCopyCapability(this.delegate)) {
return this.delegate.copy(this.toUnderlyingResource(from), this.toUnderlyingResource(to), opts);
}
throw new Error('not supported');
}
readFile(resource: URI): Promise<Uint8Array> {
if (hasReadWriteCapability(this.delegate)) {
return this.delegate.readFile(this.toUnderlyingResource(resource));
}
throw new Error('not supported');
}
readFileStream(resource: URI, opts: FileReadStreamOptions, token: CancellationToken): ReadableStreamEvents<Uint8Array> {
if (hasFileReadStreamCapability(this.delegate)) {
return this.delegate.readFileStream(this.toUnderlyingResource(resource), opts, token);
}
throw new Error('not supported');
}
readdir(resource: URI): Promise<[string, FileType][]> {
return this.delegate.readdir(this.toUnderlyingResource(resource));
}
writeFile(resource: URI, content: Uint8Array, opts: FileWriteOptions): Promise<void> {
if (hasReadWriteCapability(this.delegate)) {
return this.delegate.writeFile(this.toUnderlyingResource(resource), content, opts);
}
throw new Error('not supported');
}
open(resource: URI, opts: FileOpenOptions): Promise<number> {
if (hasOpenReadWriteCloseCapability(this.delegate)) {
return this.delegate.open(this.toUnderlyingResource(resource), opts);
}
throw new Error('not supported');
}
close(fd: number): Promise<void> {
if (hasOpenReadWriteCloseCapability(this.delegate)) {
return this.delegate.close(fd);
}
throw new Error('not supported');
}
read(fd: number, pos: number, data: Uint8Array, offset: number, length: number): Promise<number> {
if (hasOpenReadWriteCloseCapability(this.delegate)) {
return this.delegate.read(fd, pos, data, offset, length);
}
throw new Error('not supported');
}
write(fd: number, pos: number, data: Uint8Array, offset: number, length: number): Promise<number> {
if (hasOpenReadWriteCloseCapability(this.delegate)) {
return this.delegate.write(fd, pos, data, offset, length);
}
throw new Error('not supported');
}
delete(resource: URI, opts: FileDeleteOptions): Promise<void> {
return this.delegate.delete(this.toUnderlyingResource(resource), opts);
}
updateFile(resource: URI, changes: TextDocumentContentChangeEvent[], opts: FileUpdateOptions): Promise<FileUpdateResult> {
if (hasUpdateCapability(this.delegate)) {
return this.delegate.updateFile(resource, changes, opts);
}
throw new Error('not supported');
}
protected handleFileChanges(changes: readonly FileChange[]): void {
const delegatingChanges: FileChange[] = [];
for (const change of changes) {
const delegatingResource = this.fromUnderlyingResource(change.resource);
if (delegatingResource) {
delegatingChanges.push({
resource: delegatingResource,
type: change.type
});
}
}
if (delegatingChanges.length) {
this.onDidChangeFileEmitter.fire(delegatingChanges);
}
}
/**
* Converts to an underlying fs provider resource format.
*
* For example converting `user-storage` resources to `file` resources under a user home:
* user-storage:/user/settings.json => file://home/.theia/settings.json
*/
toUnderlyingResource(resource: URI): URI {
const underlying = this.options.uriConverter.to(resource);
if (!underlying) {
throw new Error('invalid resource: ' + resource.toString());
}
return underlying;
}
/**
* Converts from an underlying fs provider resource format.
*
* For example converting `file` resources under a user home to `user-storage` resource:
* - file://home/.theia/settings.json => user-storage:/user/settings.json
* - file://documents/some-document.txt => undefined
*/
fromUnderlyingResource(resource: URI): URI | undefined {
return this.options.uriConverter.from(resource);
}
}
export namespace DelegatingFileSystemProvider {
export interface Options {
uriConverter: URIConverter
}
export interface URIConverter {
/**
* Converts to an underlying fs provider resource format.
* Returns undefined if the given resource is not valid resource.
*
* For example converting `user-storage` resources to `file` resources under a user home:
* user-storage:/user/settings.json => file://home/.theia/settings.json
* user-storage:/settings.json => undefined
*/
to(resource: URI): URI | undefined;
/**
* Converts from an underlying fs provider resource format.
*
* For example converting `file` resources under a user home to `user-storage` resource:
* - file://home/.theia/settings.json => user-storage:/settings.json
* - file://documents/some-document.txt => undefined
*/
from(resource: URI): URI | undefined;
}
}

View File

@@ -0,0 +1,35 @@
# Theia - File Download
Provides the file download contribution to the `Files` navigator.
Supports single and multi file downloads.
1. A single file will be downloaded as is.
2. Folders will be downloaded az tar archives.
3. When downloading multiple files, the name of the closest common parent directory will be used for the archive.
4. When downloading multiple files from multiple disks (for instance: `C:\` and `D:\` on Windows), then we apply rule `3.` per disks and we tar the tars.
### REST API
- To download a single file or folder use the following endpoint: `GET /files/?uri=/encoded/file/uri/to/the/resource`.
- Example: `curl -X GET http://localhost:3000/files/?uri=file:///Users/akos.kitta/git/theia/package.json`.
- To download multiple files (from the same folder) use the `PUT /files/` endpoint with the `application/json` content type header and the following body format:
```json
{
"uri": [
"/encoded/file/uri/to/the/resource",
"/another/encoded/file/uri/to/the/resource"
]
}
```
```
curl -X PUT -H "Content-Type: application/json" -d '{ "uris": ["file:///Users/akos.kitta/git/theia/package.json", "file:///Users/akos.kitta/git/theia/README.md"] }' http://localhost:3000/files/
```
## License
- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/)
- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp)

View File

@@ -0,0 +1,40 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { isObject, type URI } from '@theia/core/lib/common';
export interface FileDownloadData {
readonly uris: string[];
}
export namespace FileDownloadData {
export function is(arg: unknown): arg is FileDownloadData {
return isObject(arg) && 'uris' in arg;
}
}
export namespace FileDownloadService {
export interface DownloadOptions {
// `true` if the download link has to be copied to the clipboard. This will not trigger the actual download. Defaults to `false`.
readonly copyLink?: boolean;
}
}
export const FileDownloadService = Symbol('FileDownloadService');
export interface FileDownloadService {
download(uris: URI[], options?: FileDownloadService.DownloadOptions): Promise<void>;
}

View File

@@ -0,0 +1,17 @@
// *****************************************************************************
// Copyright (C) 2021 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export const HTTP_FILE_UPLOAD_PATH = '/file-upload';

View File

@@ -0,0 +1,51 @@
// *****************************************************************************
// Copyright (C) 2022 Texas Instruments and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { FileChangesEvent, FileChangeType } from './files';
import { expect } from 'chai';
import URI from '@theia/core/lib/common/uri';
describe('FileChangesEvent', () => {
it('deleting parent folder - event contains child', () => {
const parent = new URI('file:///grandparent/parent');
const child = new URI('file:///grandparent/parent/child');
const event = new FileChangesEvent([{ resource: parent, type: FileChangeType.DELETED }]);
expect(event.contains(child, FileChangeType.DELETED)).to.eq(true);
});
it('deleting grandparent folder - event contains grandchild', () => {
const grandparent = new URI('file:///grandparent');
const grandchild = new URI('file:///grandparent/parent/child');
const event = new FileChangesEvent([{ resource: grandparent, type: FileChangeType.DELETED }]);
expect(event.contains(grandchild, FileChangeType.DELETED)).to.eq(true);
});
it('deleting child file - event does not contain parent', () => {
const parent = new URI('file:///grandparent/parent');
const child = new URI('file:///grandparent/parent/child');
const event = new FileChangesEvent([{ resource: child, type: FileChangeType.DELETED }]);
expect(event.contains(parent, FileChangeType.DELETED)).to.eq(false);
});
it('deleting grandchild file - event does not contain grandchild', () => {
const grandparent = new URI('file:///grandparent');
const grandchild = new URI('file:///grandparent/parent/child');
const event = new FileChangesEvent([{ resource: grandchild, type: FileChangeType.DELETED }]);
expect(event.contains(grandparent, FileChangeType.DELETED)).to.eq(false);
});
it('deleting self - event contains self', () => {
const self = new URI('file:///grandparent/parent/self');
const event = new FileChangesEvent([{ resource: self, type: FileChangeType.DELETED }]);
expect(event.contains(self, FileChangeType.DELETED)).to.eq(true);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,145 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { interfaces } from '@theia/core/shared/inversify';
import { createPreferenceProxy, PreferenceProxy } from '@theia/core/lib/common/preferences/preference-proxy';
import { PreferenceScope } from '@theia/core/lib/common/preferences/preference-scope';
import { PreferenceService } from '@theia/core/lib/common/preferences/preference-service';
import { SUPPORTED_ENCODINGS } from '@theia/core/lib/common/supported-encodings';
import { nls } from '@theia/core/lib/common/nls';
import { PreferenceContribution, PreferenceSchema } from '@theia/core/lib/common/preferences/preference-schema';
// See https://github.com/Microsoft/vscode/issues/30180
export const WIN32_MAX_FILE_SIZE_MB = 300; // 300 MB
export const GENERAL_MAX_FILE_SIZE_MB = 16 * 1024; // 16 GB
export const MAX_FILE_SIZE_MB = typeof process === 'object'
? process.arch === 'ia32'
? WIN32_MAX_FILE_SIZE_MB
: GENERAL_MAX_FILE_SIZE_MB
: 32;
export const filesystemPreferenceSchema: PreferenceSchema = {
properties: {
'files.watcherExclude': {
// eslint-disable-next-line max-len
description: nls.localizeByDefault('Configure paths or [glob patterns](https://aka.ms/vscode-glob-patterns) to exclude from file watching. Paths can either be relative to the watched folder or absolute. Glob patterns are matched relative from the watched folder. When you experience the file watcher process consuming a lot of CPU, make sure to exclude large folders that are of less interest (such as build output folders).'),
additionalProperties: {
type: 'boolean'
},
default: {
'**/.git/objects/**': true,
'**/.git/subtree-cache/**': true
},
scope: PreferenceScope.Folder
},
'files.exclude': {
type: 'object',
default: { '**/.git': true, '**/.svn': true, '**/.hg': true, '**/CVS': true, '**/.DS_Store': true },
// eslint-disable-next-line max-len
markdownDescription: nls.localize('theia/filesystem/filesExclude', 'Configure glob patterns for excluding files and folders. For example, the file Explorer decides which files and folders to show or hide based on this setting.'),
scope: PreferenceScope.Folder
},
'files.enableTrash': {
type: 'boolean',
default: true,
description: nls.localizeByDefault('Moves files/folders to the OS trash (recycle bin on Windows) when deleting. Disabling this will delete files/folders permanently.')
},
'files.associations': {
type: 'object',
default: {},
markdownDescription: nls.localizeByDefault(
// eslint-disable-next-line max-len
'Configure [glob patterns](https://aka.ms/vscode-glob-patterns) of file associations to languages (for example `\"*.extension\": \"html\"`). Patterns will match on the absolute path of a file if they contain a path separator and will match on the name of the file otherwise. These have precedence over the default associations of the languages installed.'
)
},
'files.autoGuessEncoding': {
type: 'boolean',
default: false,
// eslint-disable-next-line max-len
description: nls.localizeByDefault('When enabled, the editor will attempt to guess the character set encoding when opening files. This setting can also be configured per language. Note, this setting is not respected by text search. Only {0} is respected.', '`#files.encoding#`'),
scope: PreferenceScope.Folder,
overridable: true,
included: Object.keys(SUPPORTED_ENCODINGS).length > 1
},
'files.participants.timeout': {
type: 'number',
default: 5000,
markdownDescription: nls.localizeByDefault(
'Timeout in milliseconds after which file participants for create, rename, and delete are cancelled. Use `0` to disable participants.'
)
},
'files.maxFileSizeMB': {
type: 'number',
default: MAX_FILE_SIZE_MB,
markdownDescription: nls.localize('theia/filesystem/maxFileSizeMB', 'Controls the max file size in MB which is possible to open.')
},
'files.trimTrailingWhitespace': {
type: 'boolean',
default: false,
description: nls.localizeByDefault('When enabled, will trim trailing whitespace when saving a file.'),
scope: PreferenceScope.Folder,
overridable: true
},
'files.insertFinalNewline': {
type: 'boolean',
default: false,
description: nls.localizeByDefault('When enabled, insert a final new line at the end of the file when saving it.'),
scope: PreferenceScope.Folder,
overridable: true
},
'files.maxConcurrentUploads': {
type: 'integer',
default: 1,
description: nls.localize(
'theia/filesystem/maxConcurrentUploads',
'Maximum number of concurrent files to upload when uploading multiple files. 0 means all files will be uploaded concurrently.'
),
}
}
};
export interface FileSystemConfiguration {
'files.watcherExclude': { [globPattern: string]: boolean }
'files.exclude': { [key: string]: boolean }
'files.enableTrash': boolean
'files.associations': { [filepattern: string]: string }
'files.encoding': string
'files.autoGuessEncoding': boolean
'files.participants.timeout': number
'files.maxFileSizeMB': number
'files.trimTrailingWhitespace': boolean
'files.insertFinalNewline': boolean
'files.maxConcurrentUploads': number
}
export const FileSystemPreferenceContribution = Symbol('FilesystemPreferenceContribution');
export const FileSystemPreferences = Symbol('FileSystemPreferences');
export type FileSystemPreferences = PreferenceProxy<FileSystemConfiguration>;
export function createFileSystemPreferences(preferences: PreferenceService, schema: PreferenceSchema = filesystemPreferenceSchema): FileSystemPreferences {
return createPreferenceProxy(preferences, schema);
}
export function bindFileSystemPreferences(bind: interfaces.Bind): void {
bind(FileSystemPreferences).toDynamicValue(ctx => {
const preferences = ctx.container.get<PreferenceService>(PreferenceService);
const contribution = ctx.container.get<PreferenceContribution>(FileSystemPreferenceContribution);
return createFileSystemPreferences(preferences, contribution.schema);
}).inSingletonScope();
bind(FileSystemPreferenceContribution).toConstantValue({ schema: filesystemPreferenceSchema });
bind(PreferenceContribution).toService(FileSystemPreferenceContribution);
}

View File

@@ -0,0 +1,411 @@
// *****************************************************************************
// Copyright (C) 2022 EclipseSource and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { FileStat } from './files';
import { expect } from 'chai';
import { FileSystemUtils } from './filesystem-utils';
describe('generateUniqueResourceURI', () => {
describe('Target is file', () => {
describe('file without extension', () => {
it('appends index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.file('source');
parent.children = [source];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory);
expect(result.path.base).to.eq('source 1');
});
it('appends first available index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.file('source');
parent.children = [source, FileStat.file('source 1'), FileStat.file('source 3')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory);
expect(result.path.base).to.eq('source 2');
});
it('appends suffix', () => {
const parent = FileStat.dir('parent');
const source = FileStat.file('source');
parent.children = [source];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('source copy');
});
it('appends suffix and index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.file('source');
parent.children = [source, FileStat.file('source copy')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('source copy 1');
});
it('appends suffix and first available index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.file('source');
parent.children = [source, FileStat.file('source copy'), FileStat.file('source copy 1'), FileStat.file('source copy 3')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('source copy 2');
});
it('appends only index when source name already contains suffix', () => {
const parent = FileStat.dir('parent');
const source = FileStat.file('source copy 1');
parent.children = [FileStat.file('source'), source, FileStat.file('source copy 2'), FileStat.file('source copy 3')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('source copy 4');
});
});
describe('file with extension', () => {
it('appends index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.file('source.txt');
parent.children = [source];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory);
expect(result.path.base).to.eq('source 1.txt');
});
it('appends first available index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.file('source.txt');
parent.children = [source, FileStat.file('source 1.txt'), FileStat.file('source 3.txt')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory);
expect(result.path.base).to.eq('source 2.txt');
});
it('appends suffix', () => {
const parent = FileStat.dir('parent');
const source = FileStat.file('source.txt');
parent.children = [source];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('source copy.txt');
});
it('appends suffix and index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.file('source.txt');
parent.children = [source, FileStat.file('source copy.txt')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('source copy 1.txt');
});
it('appends suffix and first available index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.file('source.txt');
parent.children = [source, FileStat.file('source copy.txt'), FileStat.file('source copy 1.txt'), FileStat.file('source copy 3.txt')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('source copy 2.txt');
});
it('appends only index when source name already contains suffix', () => {
const parent = FileStat.dir('parent');
const source = FileStat.file('source copy 1.txt');
parent.children = [FileStat.file('source.txt'), source, FileStat.file('source copy 2.txt'), FileStat.file('source copy 3.txt')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('source copy 4.txt');
});
});
describe('dotfile without extension', () => {
it('appends index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.file('.source');
parent.children = [source];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory);
expect(result.path.base).to.eq('.source 1');
});
it('appends first available index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.file('.source');
parent.children = [source, FileStat.file('.source 1'), FileStat.file('.source 3')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory);
expect(result.path.base).to.eq('.source 2');
});
it('appends suffix', () => {
const parent = FileStat.dir('parent');
const source = FileStat.file('.source');
parent.children = [source];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('.source copy');
});
it('appends suffix and index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.file('.source');
parent.children = [source, FileStat.file('.source copy')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('.source copy 1');
});
it('appends suffix and first available index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.file('.source');
parent.children = [source, FileStat.file('.source copy'), FileStat.file('.source copy 1'), FileStat.file('.source copy 3')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('.source copy 2');
});
it('appends only index when source name already contains suffix', () => {
const parent = FileStat.dir('parent');
const source = FileStat.file('.source copy 1');
parent.children = [FileStat.file('.source'), source, FileStat.file('.source copy 2'), FileStat.file('.source copy 3')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('.source copy 4');
});
});
describe('dotfile with extension', () => {
it('appends index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.file('.source.txt');
parent.children = [source];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory);
expect(result.path.base).to.eq('.source 1.txt');
});
it('appends first available index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.file('.source.txt');
parent.children = [source, FileStat.file('.source 1.txt'), FileStat.file('.source 3.txt')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory);
expect(result.path.base).to.eq('.source 2.txt');
});
it('appends suffix', () => {
const parent = FileStat.dir('parent');
const source = FileStat.file('.source.txt');
parent.children = [source];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('.source copy.txt');
});
it('appends suffix and index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.file('.source.txt');
parent.children = [source, FileStat.file('.source copy.txt')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('.source copy 1.txt');
});
it('appends suffix and first available index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.file('.source.txt');
parent.children = [source, FileStat.file('.source copy.txt'), FileStat.file('.source copy 1.txt'), FileStat.file('.source copy 3.txt')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('.source copy 2.txt');
});
it('appends only index when source name already contains suffix', () => {
const parent = FileStat.dir('parent');
const source = FileStat.file('.source copy 1.txt');
parent.children = [FileStat.file('.source.txt'), source, FileStat.file('.source copy 2.txt'), FileStat.file('.source copy 3.txt')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('.source copy 4.txt');
});
});
});
describe('Target is directory', () => {
describe('directory with path without extension', () => {
it('appends index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.dir('source');
parent.children = [source];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory);
expect(result.path.base).to.eq('source 1');
});
it('appends first available index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.dir('source');
parent.children = [source, FileStat.dir('source 1'), FileStat.dir('source 3')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory);
expect(result.path.base).to.eq('source 2');
});
it('appends suffix', () => {
const parent = FileStat.dir('parent');
const source = FileStat.dir('source');
parent.children = [source];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('source copy');
});
it('appends suffix and index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.dir('source');
parent.children = [source, FileStat.dir('source copy')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('source copy 1');
});
it('appends suffix and first available index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.dir('source');
parent.children = [source, FileStat.dir('source copy'), FileStat.dir('source copy 1'), FileStat.dir('source copy 3')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('source copy 2');
});
it('appends only index when source name already contains suffix', () => {
const parent = FileStat.dir('parent');
const source = FileStat.dir('source copy 1');
parent.children = [FileStat.dir('source'), source, FileStat.dir('source copy 2'), FileStat.dir('source copy 3')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('source copy 4');
});
});
describe('directory with path with extension', () => {
it('appends index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.dir('source.test');
parent.children = [source];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory);
expect(result.path.base).to.eq('source.test 1');
});
it('appends first available index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.dir('source.test');
parent.children = [source, FileStat.dir('source.test 1'), FileStat.dir('source.test 3')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory);
expect(result.path.base).to.eq('source.test 2');
});
it('appends suffix', () => {
const parent = FileStat.dir('parent');
const source = FileStat.dir('source.test');
parent.children = [source];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('source.test copy');
});
it('appends suffix and index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.dir('source.test');
parent.children = [source, FileStat.dir('source.test copy')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('source.test copy 1');
});
it('appends suffix and first available index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.dir('source.test');
parent.children = [source, FileStat.dir('source.test copy'), FileStat.dir('source.test copy 1'), FileStat.dir('source.test copy 3')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('source.test copy 2');
});
it('appends only index when source name already contains suffix', () => {
const parent = FileStat.dir('parent');
const source = FileStat.dir('source.test copy 1');
parent.children = [FileStat.dir('source.test'), source, FileStat.dir('source.test copy 2'), FileStat.dir('source.test copy 3')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('source.test copy 4');
});
});
describe('name starts with . and has path without extension', () => {
it('appends index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.dir('.source');
parent.children = [source];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory);
expect(result.path.base).to.eq('.source 1');
});
it('appends first available index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.dir('.source');
parent.children = [source, FileStat.dir('.source 1'), FileStat.dir('.source 3')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory);
expect(result.path.base).to.eq('.source 2');
});
it('appends suffix', () => {
const parent = FileStat.dir('parent');
const source = FileStat.dir('.source');
parent.children = [source];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('.source copy');
});
it('appends suffix and index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.dir('.source');
parent.children = [source, FileStat.dir('.source copy')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('.source copy 1');
});
it('appends suffix and first available index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.dir('.source');
parent.children = [source, FileStat.dir('.source copy'), FileStat.dir('.source copy 1'), FileStat.dir('.source copy 3')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('.source copy 2');
});
it('appends only index when source name already contains suffix', () => {
const parent = FileStat.dir('parent');
const source = FileStat.dir('.source copy 1');
parent.children = [FileStat.dir('.source'), source, FileStat.dir('.source copy 2'), FileStat.dir('.source copy 3')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('.source copy 4');
});
});
describe('name starts with . and has path with extension', () => {
it('appends index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.dir('.source.test');
parent.children = [source];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory);
expect(result.path.base).to.eq('.source.test 1');
});
it('appends first available index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.dir('.source.test');
parent.children = [source, FileStat.dir('.source.test 1'), FileStat.dir('.source.test 3')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory);
expect(result.path.base).to.eq('.source.test 2');
});
it('appends suffix', () => {
const parent = FileStat.dir('parent');
const source = FileStat.dir('.source.test');
parent.children = [source];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('.source.test copy');
});
it('appends suffix and index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.dir('.source.test');
parent.children = [source, FileStat.dir('.source.test copy')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('.source.test copy 1');
});
it('appends suffix and first available index', () => {
const parent = FileStat.dir('parent');
const source = FileStat.dir('.source.test');
parent.children = [source, FileStat.dir('.source.test copy'), FileStat.dir('.source.test copy 1'), FileStat.dir('.source.test copy 3')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('.source.test copy 2');
});
it('appends only index when source name already contains suffix', () => {
const parent = FileStat.dir('parent');
const source = FileStat.dir('.source.test copy 1');
parent.children = [FileStat.dir('.source.test'), source, FileStat.dir('.source.test copy 2'), FileStat.dir('.source.test copy 3')];
const result = FileSystemUtils.generateUniqueResourceURI(parent, source.resource, source.isDirectory, 'copy');
expect(result.path.base).to.eq('.source.test copy 4');
});
});
});
});

View File

@@ -0,0 +1,64 @@
// *****************************************************************************
// Copyright (C) 2018 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { FileStat } from '../common/files';
import URI from '@theia/core/lib/common/uri';
export namespace FileSystemUtils {
export const FILE_NAME_SEPARATOR = ' ';
/**
* Generate unique URI for a given parent which does not collide
*
* @param parent the `FileStat` of the parent
* @param targetUri the initial URI
* @param isDirectory indicates whether the given targetUri represents a directory
* @param suffix an optional string to append to the file name, in case of collision (e.g. `copy`)
*/
export function generateUniqueResourceURI(parent: FileStat, targetUri: URI, isDirectory: boolean, suffix?: string): URI {
const children = !parent.children ? [] : parent.children!.map(child => child.resource);
let name = targetUri.path.name;
let extension = targetUri.path.ext;
if (!name) {
// special case for dotfiles (e.g. '.foobar'): use the extension as the name
name = targetUri.path.ext;
extension = '';
}
// we want the path base for directories with the source path `foo.bar` to be generated as `foo.bar copy` and not `foo copy.bar` as we do for files
if (isDirectory) {
name = name + extension;
extension = '';
}
let base = name + extension;
// test if the name already contains the suffix or the suffix + index, so we don't add it again
const nameRegex = RegExp(`.*${FileSystemUtils.FILE_NAME_SEPARATOR}${suffix}(${FileSystemUtils.FILE_NAME_SEPARATOR}[0-9]*)?$`);
if (suffix && !nameRegex.test(name) && children.some(child => child.path.base === base)) {
name = name + FILE_NAME_SEPARATOR + suffix;
base = name + extension;
}
if (suffix && nameRegex.test(name)) {
// remove the existing index from the name, so we can generate a new one
name = name.replace(RegExp(`${FILE_NAME_SEPARATOR}[0-9]*$`), '');
}
let index = 0;
while (children.some(child => child.path.base === base)) {
index = index + 1;
base = name + FILE_NAME_SEPARATOR + index + extension;
}
return parent.resource.resolve(base);
}
}

View File

@@ -0,0 +1,96 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { RpcServer } from '@theia/core';
import { FileChangeType } from './files';
export { FileChangeType };
export const FileSystemWatcherService = Symbol('FileSystemWatcherServer2');
/**
* Singleton implementation of the watch server.
*
* Since multiple clients all make requests to this service, we need to track those individually via a `clientId`.
*/
export interface FileSystemWatcherService extends RpcServer<FileSystemWatcherServiceClient> {
/**
* @param clientId arbitrary id used to identify a client.
* @param uri the path to watch.
* @param options optional parameters.
* @returns promise to a unique `number` handle for this request.
*/
watchFileChanges(clientId: number, uri: string, options?: WatchOptions): Promise<number>;
/**
* @param watcherId handle mapping to a previous `watchFileChanges` request.
*/
unwatchFileChanges(watcherId: number): Promise<void>;
}
export interface FileSystemWatcherServiceClient {
/** Listen for change events emitted by the watcher. */
onDidFilesChanged(event: DidFilesChangedParams): void;
/** The watcher can crash in certain conditions. */
onError(event: FileSystemWatcherErrorParams): void;
}
export interface DidFilesChangedParams {
/** Clients to route the events to. */
clients?: number[];
/** FileSystem changes that occurred. */
changes: FileChange[];
}
export interface FileSystemWatcherErrorParams {
/** Clients to route the events to. */
clients: number[];
/** The uri that originated the error. */
uri: string;
}
export const FileSystemWatcherServer = Symbol('FileSystemWatcherServer');
export interface FileSystemWatcherServer extends RpcServer<FileSystemWatcherClient> {
/**
* Start file watching for the given param.
* Resolve when watching is started.
* Return a watcher id.
*/
watchFileChanges(uri: string, options?: WatchOptions): Promise<number>;
/**
* Stop file watching for the given id.
* Resolve when watching is stopped.
*/
unwatchFileChanges(watcherId: number): Promise<void>;
}
export interface FileSystemWatcherClient {
/**
* Notify when files under watched uris are changed.
*/
onDidFilesChanged(event: DidFilesChangedParams): void;
/**
* Notify when unable to watch files because of Linux handle limit.
*/
onError(): void;
}
export interface WatchOptions {
ignored: string[];
}
export interface FileChange {
uri: string;
type: FileChangeType;
}

View File

@@ -0,0 +1,43 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export namespace FileAccess {
export namespace Constants {
/**
* Flag indicating that the file is visible to the calling process.
* This is useful for determining if a file exists, but says nothing about rwx permissions. Default if no mode is specified.
*/
export const F_OK: number = 0;
/**
* Flag indicating that the file can be read by the calling process.
*/
export const R_OK: number = 4;
/**
* Flag indicating that the file can be written by the calling process.
*/
export const W_OK: number = 2;
/**
* Flag indicating that the file can be executed by the calling process.
* This has no effect on Windows (will behave like `FileAccess.F_OK`).
*/
export const X_OK: number = 1;
}
}

View File

@@ -0,0 +1,19 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export * from './filesystem';
export * from './filesystem-utils';
export * from './filesystem-preferences';

View File

@@ -0,0 +1,155 @@
// *****************************************************************************
// Copyright (C) 2020 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// based on https://github.com/microsoft/vscode/blob/04c36be045a94fee58e5f8992d3e3fd980294a84/src/vs/platform/files/common/io.ts
/* eslint-disable max-len */
import URI from '@theia/core/lib/common/uri';
import { BinaryBuffer } from '@theia/core/lib/common//buffer';
import { CancellationToken, cancelled as canceled } from '@theia/core/lib/common/cancellation';
import { FileSystemProviderWithOpenReadWriteCloseCapability, FileReadStreamOptions, ensureFileSystemProviderError, createFileSystemProviderError, FileSystemProviderErrorCode } from './files';
import { WriteableStream, ErrorTransformer, DataTransformer } from '@theia/core/lib/common/stream';
export interface CreateReadStreamOptions extends FileReadStreamOptions {
/**
* The size of the buffer to use before sending to the stream.
*/
bufferSize: number;
/**
* Allows to massage any possibly error that happens during reading.
*/
errorTransformer?: ErrorTransformer;
}
/**
* A helper to read a file from a provider with open/read/close capability into a stream.
*/
export async function readFileIntoStream<T>(
provider: FileSystemProviderWithOpenReadWriteCloseCapability,
resource: URI,
target: WriteableStream<T>,
transformer: DataTransformer<BinaryBuffer, T>,
options: CreateReadStreamOptions,
token: CancellationToken
): Promise<void> {
let error: Error | undefined = undefined;
try {
await doReadFileIntoStream(provider, resource, target, transformer, options, token);
} catch (err) {
error = err;
} finally {
if (error && options.errorTransformer) {
error = options.errorTransformer(error);
}
target.end(error);
}
}
async function doReadFileIntoStream<T>(provider: FileSystemProviderWithOpenReadWriteCloseCapability, resource: URI, target: WriteableStream<T>, transformer: DataTransformer<BinaryBuffer, T>, options: CreateReadStreamOptions, token: CancellationToken): Promise<void> {
// Check for cancellation
throwIfCancelled(token);
// open handle through provider
const handle = await provider.open(resource, { create: false });
// Check for cancellation
try {
throwIfCancelled(token);
} catch (error) {
await provider.close(handle);
throw error;
}
try {
let totalBytesRead = 0;
let bytesRead = 0;
let allowedRemainingBytes = (options && typeof options.length === 'number') ? options.length : undefined;
let buffer = BinaryBuffer.alloc(Math.min(options.bufferSize, typeof allowedRemainingBytes === 'number' ? allowedRemainingBytes : options.bufferSize));
let posInFile = options && typeof options.position === 'number' ? options.position : 0;
let posInBuffer = 0;
do {
// read from source (handle) at current position (pos) into buffer (buffer) at
// buffer position (posInBuffer) up to the size of the buffer (buffer.byteLength).
bytesRead = await provider.read(handle, posInFile, buffer.buffer, posInBuffer, buffer.byteLength - posInBuffer);
posInFile += bytesRead;
posInBuffer += bytesRead;
totalBytesRead += bytesRead;
if (typeof allowedRemainingBytes === 'number') {
allowedRemainingBytes -= bytesRead;
}
// when buffer full, create a new one and emit it through stream
if (posInBuffer === buffer.byteLength) {
await target.write(transformer(buffer));
buffer = BinaryBuffer.alloc(Math.min(options.bufferSize, typeof allowedRemainingBytes === 'number' ? allowedRemainingBytes : options.bufferSize));
posInBuffer = 0;
}
} while (bytesRead > 0 && (typeof allowedRemainingBytes !== 'number' || allowedRemainingBytes > 0) && throwIfCancelled(token) && throwIfTooLarge(totalBytesRead, options));
// wrap up with last buffer (also respect maxBytes if provided)
if (posInBuffer > 0) {
let lastChunkLength = posInBuffer;
if (typeof allowedRemainingBytes === 'number') {
lastChunkLength = Math.min(posInBuffer, allowedRemainingBytes);
}
target.write(transformer(buffer.slice(0, lastChunkLength)));
}
} catch (error) {
throw ensureFileSystemProviderError(error);
} finally {
await provider.close(handle);
}
}
function throwIfCancelled(token: CancellationToken): boolean {
if (token.isCancellationRequested) {
throw canceled();
}
return true;
}
function throwIfTooLarge(totalBytesRead: number, options: CreateReadStreamOptions): boolean {
// Return early if file is too large to load and we have configured limits
if (options?.limits) {
if (typeof options.limits.memory === 'number' && totalBytesRead > options.limits.memory) {
throw createFileSystemProviderError('To open a file of this size, you need to restart and allow it to use more memory', FileSystemProviderErrorCode.FileExceedsMemoryLimit);
}
if (typeof options.limits.size === 'number' && totalBytesRead > options.limits.size) {
throw createFileSystemProviderError('File is too large to open', FileSystemProviderErrorCode.FileTooLarge);
}
}
return true;
}

View File

@@ -0,0 +1,547 @@
// *****************************************************************************
// Copyright (C) 2020 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { Emitter } from '@theia/core/lib/common/event';
import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable';
import { BinaryBuffer } from '@theia/core/lib/common/buffer';
import {
FileWriteOptions, FileOpenOptions, FileChangeType,
FileSystemProviderCapabilities, FileChange, Stat, FileOverwriteOptions, WatchOptions, FileType, FileSystemProvider, FileDeleteOptions,
hasOpenReadWriteCloseCapability, hasFileFolderCopyCapability, hasReadWriteCapability, hasAccessCapability,
FileSystemProviderError, FileSystemProviderErrorCode, FileUpdateOptions, hasUpdateCapability, FileUpdateResult, FileReadStreamOptions, hasFileReadStreamCapability,
ReadOnlyMessageFileSystemProvider
} from './files';
import { RpcServer, RpcProxy, RpcProxyFactory } from '@theia/core/lib/common/messaging/proxy-factory';
import { ApplicationError } from '@theia/core/lib/common/application-error';
import { Deferred } from '@theia/core/lib/common/promise-util';
import type { TextDocumentContentChangeEvent } from '@theia/core/shared/vscode-languageserver-protocol';
import { newWriteableStream, ReadableStreamEvents } from '@theia/core/lib/common/stream';
import { CancellationToken, cancelled } from '@theia/core/lib/common/cancellation';
import { MarkdownString } from '@theia/core/lib/common/markdown-rendering';
export const remoteFileSystemPath = '/services/remote-filesystem';
export const RemoteFileSystemServer = Symbol('RemoteFileSystemServer');
export interface RemoteFileSystemServer extends RpcServer<RemoteFileSystemClient> {
getCapabilities(): Promise<FileSystemProviderCapabilities>
stat(resource: string): Promise<Stat>;
getReadOnlyMessage(): Promise<MarkdownString | undefined>;
access(resource: string, mode?: number): Promise<void>;
fsPath(resource: string): Promise<string>;
open(resource: string, opts: FileOpenOptions): Promise<number>;
close(fd: number): Promise<void>;
read(fd: number, pos: number, length: number): Promise<{ bytes: Uint8Array; bytesRead: number; }>;
readFileStream(resource: string, handle: number, opts: FileReadStreamOptions, token: CancellationToken): Promise<void>;
readFile(resource: string): Promise<Uint8Array>;
write(fd: number, pos: number, data: Uint8Array, offset: number, length: number): Promise<number>;
writeFile(resource: string, content: Uint8Array, opts: FileWriteOptions): Promise<void>;
delete(resource: string, opts: FileDeleteOptions): Promise<void>;
mkdir(resource: string): Promise<void>;
readdir(resource: string): Promise<[string, FileType][]>;
rename(source: string, target: string, opts: FileOverwriteOptions): Promise<void>;
copy(source: string, target: string, opts: FileOverwriteOptions): Promise<void>;
watch(watcher: number, resource: string, opts: WatchOptions): Promise<void>;
unwatch(watcher: number): Promise<void>;
updateFile(resource: string, changes: TextDocumentContentChangeEvent[], opts: FileUpdateOptions): Promise<FileUpdateResult>;
}
export interface RemoteFileChange {
readonly type: FileChangeType;
readonly resource: string;
}
export interface RemoteFileStreamError extends Error {
code?: FileSystemProviderErrorCode
}
export interface RemoteFileSystemClient {
notifyDidChangeFile(event: { changes: RemoteFileChange[] }): void;
notifyFileWatchError(): void;
notifyDidChangeCapabilities(capabilities: FileSystemProviderCapabilities): void;
notifyDidChangeReadOnlyMessage(readOnlyMessage: MarkdownString | undefined): void;
onFileStreamData(handle: number, data: Uint8Array): void;
onFileStreamEnd(handle: number, error: RemoteFileStreamError | undefined): void;
}
export const RemoteFileSystemProviderError = ApplicationError.declare(-33005,
(message: string, data: { code: FileSystemProviderErrorCode, name: string }, stack: string) =>
({ message, data, stack })
);
export class RemoteFileSystemProxyFactory<T extends object> extends RpcProxyFactory<T> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected override serializeError(e: any): any {
if (e instanceof FileSystemProviderError) {
const { code, name } = e;
return super.serializeError(RemoteFileSystemProviderError(e.message, { code, name }, e.stack));
}
return super.serializeError(e);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected override deserializeError(capturedError: Error, e: any): any {
const error = super.deserializeError(capturedError, e);
if (RemoteFileSystemProviderError.is(error)) {
const fileOperationError = new FileSystemProviderError(error.message, error.data.code);
fileOperationError.name = error.data.name;
fileOperationError.stack = error.stack;
return fileOperationError;
}
return e;
}
}
/**
* Frontend component.
*
* Wraps the remote filesystem provider living on the backend.
*/
@injectable()
export class RemoteFileSystemProvider implements Required<FileSystemProvider>, Disposable, ReadOnlyMessageFileSystemProvider {
private readonly onDidChangeFileEmitter = new Emitter<readonly FileChange[]>();
readonly onDidChangeFile = this.onDidChangeFileEmitter.event;
private readonly onFileWatchErrorEmitter = new Emitter<void>();
readonly onFileWatchError = this.onFileWatchErrorEmitter.event;
private readonly onDidChangeCapabilitiesEmitter = new Emitter<void>();
readonly onDidChangeCapabilities = this.onDidChangeCapabilitiesEmitter.event;
private readonly onDidChangeReadOnlyMessageEmitter = new Emitter<MarkdownString | undefined>();
readonly onDidChangeReadOnlyMessage = this.onDidChangeReadOnlyMessageEmitter.event;
private readonly onFileStreamDataEmitter = new Emitter<[number, Uint8Array]>();
private readonly onFileStreamData = this.onFileStreamDataEmitter.event;
private readonly onFileStreamEndEmitter = new Emitter<[number, Error | FileSystemProviderError | undefined]>();
private readonly onFileStreamEnd = this.onFileStreamEndEmitter.event;
protected readonly toDispose = new DisposableCollection(
this.onDidChangeFileEmitter,
this.onDidChangeCapabilitiesEmitter,
this.onDidChangeReadOnlyMessageEmitter,
this.onFileStreamDataEmitter,
this.onFileStreamEndEmitter
);
protected watcherSequence = 0;
/**
* We'll track the currently allocated watchers, in order to re-allocate them
* with the same options once we reconnect to the backend after a disconnection.
*/
protected readonly watchOptions = new Map<number, {
uri: string;
options: WatchOptions
}>();
private _capabilities: FileSystemProviderCapabilities = FileSystemProviderCapabilities.None;
get capabilities(): FileSystemProviderCapabilities { return this._capabilities; }
private _readOnlyMessage: MarkdownString | undefined = undefined;
get readOnlyMessage(): MarkdownString | undefined {
return this._readOnlyMessage;
}
protected readonly readyDeferred = new Deferred<void>();
readonly ready = this.readyDeferred.promise;
protected streamHandleSeq = 0;
/**
* Wrapped remote filesystem.
*/
@inject(RemoteFileSystemServer)
protected readonly server: RpcProxy<RemoteFileSystemServer>;
@postConstruct()
protected init(): void {
this.server.getCapabilities().then(capabilities => {
this._capabilities = capabilities;
this.readyDeferred.resolve();
}, this.readyDeferred.reject);
this.server.getReadOnlyMessage().then(readOnlyMessage => {
this._readOnlyMessage = readOnlyMessage;
});
this.server.setClient({
notifyDidChangeFile: ({ changes }) => {
this.onDidChangeFileEmitter.fire(changes.map(event => ({ resource: new URI(event.resource), type: event.type })));
},
notifyFileWatchError: () => {
this.onFileWatchErrorEmitter.fire();
},
notifyDidChangeCapabilities: capabilities => this.setCapabilities(capabilities),
notifyDidChangeReadOnlyMessage: readOnlyMessage => this.setReadOnlyMessage(readOnlyMessage),
onFileStreamData: (handle, data) => this.onFileStreamDataEmitter.fire([handle, data]),
onFileStreamEnd: (handle, error) => this.onFileStreamEndEmitter.fire([handle, error])
});
const onInitialized = this.server.onDidOpenConnection(() => {
// skip reconnection on the first connection
onInitialized.dispose();
this.toDispose.push(this.server.onDidOpenConnection(() => this.reconnect()));
});
}
dispose(): void {
this.toDispose.dispose();
}
protected setCapabilities(capabilities: FileSystemProviderCapabilities): void {
this._capabilities = capabilities;
this.onDidChangeCapabilitiesEmitter.fire(undefined);
}
protected setReadOnlyMessage(readOnlyMessage: MarkdownString | undefined): void {
this._readOnlyMessage = readOnlyMessage;
this.onDidChangeReadOnlyMessageEmitter.fire(readOnlyMessage);
}
// --- forwarding calls
stat(resource: URI): Promise<Stat> {
return this.server.stat(resource.toString());
}
access(resource: URI, mode?: number): Promise<void> {
return this.server.access(resource.toString(), mode);
}
fsPath(resource: URI): Promise<string> {
return this.server.fsPath(resource.toString());
}
open(resource: URI, opts: FileOpenOptions): Promise<number> {
return this.server.open(resource.toString(), opts);
}
close(fd: number): Promise<void> {
return this.server.close(fd);
}
async read(fd: number, pos: number, data: Uint8Array, offset: number, length: number): Promise<number> {
const { bytes, bytesRead } = await this.server.read(fd, pos, length);
// copy back the data that was written into the buffer on the remote
// side. we need to do this because buffers are not referenced by
// pointer, but only by value and as such cannot be directly written
// to from the other process.
data.set(bytes.slice(0, bytesRead), offset);
return bytesRead;
}
async readFile(resource: URI): Promise<Uint8Array> {
const bytes = await this.server.readFile(resource.toString());
return bytes;
}
readFileStream(resource: URI, opts: FileReadStreamOptions, token: CancellationToken): ReadableStreamEvents<Uint8Array> {
const capturedError = new Error();
const stream = newWriteableStream<Uint8Array>(data => BinaryBuffer.concat(data.map(item => BinaryBuffer.wrap(item))).buffer);
const streamHandle = this.streamHandleSeq++;
const toDispose = new DisposableCollection(
token.onCancellationRequested(() => stream.end(cancelled())),
this.onFileStreamData(([handle, data]) => {
if (streamHandle === handle) {
stream.write(data);
}
}),
this.onFileStreamEnd(([handle, error]) => {
if (streamHandle === handle) {
if (error) {
const code = ('code' in error && error.code) || FileSystemProviderErrorCode.Unknown;
const fileOperationError = new FileSystemProviderError(error.message, code);
fileOperationError.name = error.name;
const capturedStack = capturedError.stack || '';
fileOperationError.stack = `${capturedStack}\nCaused by: ${error.stack}`;
stream.end(fileOperationError);
} else {
stream.end();
}
}
})
);
stream.on('end', () => toDispose.dispose());
this.server.readFileStream(resource.toString(), streamHandle, opts, token).then(() => {
if (token.isCancellationRequested) {
stream.end(cancelled());
}
}, error => stream.end(error));
return stream;
}
write(fd: number, pos: number, data: Uint8Array, offset: number, length: number): Promise<number> {
return this.server.write(fd, pos, data, offset, length);
}
writeFile(resource: URI, content: Uint8Array, opts: FileWriteOptions): Promise<void> {
return this.server.writeFile(resource.toString(), content, opts);
}
delete(resource: URI, opts: FileDeleteOptions): Promise<void> {
return this.server.delete(resource.toString(), opts);
}
mkdir(resource: URI): Promise<void> {
return this.server.mkdir(resource.toString());
}
readdir(resource: URI): Promise<[string, FileType][]> {
return this.server.readdir(resource.toString());
}
rename(resource: URI, target: URI, opts: FileOverwriteOptions): Promise<void> {
return this.server.rename(resource.toString(), target.toString(), opts);
}
copy(resource: URI, target: URI, opts: FileOverwriteOptions): Promise<void> {
return this.server.copy(resource.toString(), target.toString(), opts);
}
updateFile(resource: URI, changes: TextDocumentContentChangeEvent[], opts: FileUpdateOptions): Promise<FileUpdateResult> {
return this.server.updateFile(resource.toString(), changes, opts);
}
watch(resource: URI, options: WatchOptions): Disposable {
const watcherId = this.watcherSequence++;
const uri = resource.toString();
this.watchOptions.set(watcherId, { uri, options });
this.server.watch(watcherId, uri, options);
const toUnwatch = Disposable.create(() => {
this.watchOptions.delete(watcherId);
this.server.unwatch(watcherId);
});
this.toDispose.push(toUnwatch);
return toUnwatch;
}
/**
* When a frontend disconnects (e.g. bad connection) the backend resources will be cleared.
*
* This means that we need to re-allocate the watchers when a frontend reconnects.
*/
protected reconnect(): void {
for (const [watcher, { uri, options }] of this.watchOptions.entries()) {
this.server.watch(watcher, uri, options);
}
}
}
/**
* Backend component.
*
* JSON-RPC server exposing a wrapped file system provider remotely.
*/
@injectable()
export class FileSystemProviderServer implements RemoteFileSystemServer {
private readonly BUFFER_SIZE = 64 * 1024;
/**
* Mapping of `watcherId` to a disposable watcher handle.
*/
protected watchers = new Map<number, Disposable>();
protected readonly toDispose = new DisposableCollection();
dispose(): void {
this.toDispose.dispose();
}
protected client: RemoteFileSystemClient | undefined;
setClient(client: RemoteFileSystemClient | undefined): void {
this.client = client;
}
/**
* Wrapped file system provider.
*/
@inject(FileSystemProvider)
protected readonly provider: FileSystemProvider & Partial<Disposable>;
@postConstruct()
protected init(): void {
if (this.provider.dispose) {
this.toDispose.push(Disposable.create(() => this.provider.dispose!()));
}
this.toDispose.push(this.provider.onDidChangeCapabilities(() => {
if (this.client) {
this.client.notifyDidChangeCapabilities(this.provider.capabilities);
}
}));
if (ReadOnlyMessageFileSystemProvider.is(this.provider)) {
const providerWithReadOnlyMessage: ReadOnlyMessageFileSystemProvider = this.provider;
this.toDispose.push(this.provider.onDidChangeReadOnlyMessage(() => {
if (this.client) {
this.client.notifyDidChangeReadOnlyMessage(providerWithReadOnlyMessage.readOnlyMessage);
}
}));
}
this.toDispose.push(this.provider.onDidChangeFile(changes => {
if (this.client) {
this.client.notifyDidChangeFile({
changes: changes.map(({ resource, type }) => ({ resource: resource.toString(), type }))
});
}
}));
this.toDispose.push(this.provider.onFileWatchError(() => {
if (this.client) {
this.client.notifyFileWatchError();
}
}));
}
async getCapabilities(): Promise<FileSystemProviderCapabilities> {
return this.provider.capabilities;
}
async getReadOnlyMessage(): Promise<MarkdownString | undefined> {
if (ReadOnlyMessageFileSystemProvider.is(this.provider)) {
return this.provider.readOnlyMessage;
} else {
return undefined;
}
}
stat(resource: string): Promise<Stat> {
return this.provider.stat(new URI(resource));
}
access(resource: string, mode?: number): Promise<void> {
if (hasAccessCapability(this.provider)) {
return this.provider.access(new URI(resource), mode);
}
throw new Error('not supported');
}
async fsPath(resource: string): Promise<string> {
if (hasAccessCapability(this.provider)) {
return this.provider.fsPath(new URI(resource));
}
throw new Error('not supported');
}
open(resource: string, opts: FileOpenOptions): Promise<number> {
if (hasOpenReadWriteCloseCapability(this.provider)) {
return this.provider.open(new URI(resource), opts);
}
throw new Error('not supported');
}
close(fd: number): Promise<void> {
if (hasOpenReadWriteCloseCapability(this.provider)) {
return this.provider.close(fd);
}
throw new Error('not supported');
}
async read(fd: number, pos: number, length: number): Promise<{ bytes: Uint8Array; bytesRead: number; }> {
if (hasOpenReadWriteCloseCapability(this.provider)) {
const buffer = BinaryBuffer.alloc(this.BUFFER_SIZE);
const bytes = buffer.buffer;
const bytesRead = await this.provider.read(fd, pos, bytes, 0, length);
return { bytes, bytesRead };
}
throw new Error('not supported');
}
write(fd: number, pos: number, data: Uint8Array, offset: number, length: number): Promise<number> {
if (hasOpenReadWriteCloseCapability(this.provider)) {
return this.provider.write(fd, pos, data, offset, length);
}
throw new Error('not supported');
}
async readFile(resource: string): Promise<Uint8Array> {
if (hasReadWriteCapability(this.provider)) {
return this.provider.readFile(new URI(resource));
}
throw new Error('not supported');
}
writeFile(resource: string, content: Uint8Array, opts: FileWriteOptions): Promise<void> {
if (hasReadWriteCapability(this.provider)) {
return this.provider.writeFile(new URI(resource), content, opts);
}
throw new Error('not supported');
}
delete(resource: string, opts: FileDeleteOptions): Promise<void> {
return this.provider.delete(new URI(resource), opts);
}
mkdir(resource: string): Promise<void> {
return this.provider.mkdir(new URI(resource));
}
readdir(resource: string): Promise<[string, FileType][]> {
return this.provider.readdir(new URI(resource));
}
rename(source: string, target: string, opts: FileOverwriteOptions): Promise<void> {
return this.provider.rename(new URI(source), new URI(target), opts);
}
copy(source: string, target: string, opts: FileOverwriteOptions): Promise<void> {
if (hasFileFolderCopyCapability(this.provider)) {
return this.provider.copy(new URI(source), new URI(target), opts);
}
throw new Error('not supported');
}
updateFile(resource: string, changes: TextDocumentContentChangeEvent[], opts: FileUpdateOptions): Promise<FileUpdateResult> {
if (hasUpdateCapability(this.provider)) {
return this.provider.updateFile(new URI(resource), changes, opts);
}
throw new Error('not supported');
}
async watch(requestedWatcherId: number, resource: string, opts: WatchOptions): Promise<void> {
if (this.watchers.has(requestedWatcherId)) {
throw new Error('watcher id is already allocated!');
}
const watcher = this.provider.watch(new URI(resource), opts);
this.watchers.set(requestedWatcherId, watcher);
this.toDispose.push(Disposable.create(() => this.unwatch(requestedWatcherId)));
}
async unwatch(watcherId: number): Promise<void> {
const watcher = this.watchers.get(watcherId);
if (watcher) {
this.watchers.delete(watcherId);
watcher.dispose();
}
}
async readFileStream(resource: string, handle: number, opts: FileReadStreamOptions, token: CancellationToken): Promise<void> {
if (hasFileReadStreamCapability(this.provider)) {
const stream = this.provider.readFileStream(new URI(resource), opts, token);
stream.on('data', data => this.client?.onFileStreamData(handle, data));
stream.on('error', error => {
const code = error instanceof FileSystemProviderError ? error.code : undefined;
const { name, message, stack } = error;
this.client?.onFileStreamEnd(handle, { code, name, message, stack });
});
stream.on('end', () => this.client?.onFileStreamEnd(handle, undefined));
} else {
throw new Error('not supported');
}
}
}

View File

@@ -0,0 +1,65 @@
// *****************************************************************************
// Copyright (C) 2019 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import URI from '@theia/core/lib/common/uri';
import { CancellationToken } from '@theia/core/lib/common/cancellation';
import { Progress } from '@theia/core/lib/common/message-service-protocol';
import { Event } from '@theia/core/lib/common/event';
export type CustomDataTransfer = Iterable<readonly [string, CustomDataTransferItem]>;
export interface CustomDataTransferItem {
asFile(): {
readonly id: string;
readonly name: string;
data(): Promise<Uint8Array>;
} | undefined
}
export interface FileUploadService {
upload(targetUri: string | URI, params?: FileUploadService.UploadParams): Promise<FileUploadService.UploadResult>;
readonly onDidUpload: Event<string[]>;
}
export namespace FileUploadService {
export type Source = FormData | DataTransfer | CustomDataTransfer;
export interface UploadEntry {
file: File
uri: URI
}
export interface Context {
progress: Progress
token: CancellationToken
accept: (entry: UploadEntry) => Promise<void>
}
export interface Form {
targetInput: HTMLInputElement
fileInput: HTMLInputElement
onDidUpload?: (uri: string) => void
}
export interface UploadParams {
source?: FileUploadService.Source,
progress?: Progress,
token?: CancellationToken,
onDidUpload?: (uri: string) => void,
leaveInTemp?: boolean
}
export interface UploadResult {
uploaded: string[]
}
}
export const FileUploadService = Symbol('FileUploadService');

View File

@@ -0,0 +1,24 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ContainerModule } from '@theia/core/shared/inversify';
import { ElectronFileDialogService } from './electron-file-dialog-service';
import { FileDialogService } from '../../browser';
export default new ContainerModule((bind, _unbind, _isBound, rebind) => {
bind(ElectronFileDialogService).toSelf().inSingletonScope();
rebind(FileDialogService).toService(ElectronFileDialogService);
});

View File

@@ -0,0 +1,165 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { MaybeArray } from '@theia/core/lib/common/types';
import { MessageService } from '@theia/core/lib/common/message-service';
import { FileStat } from '../../common/files';
import { FileAccess } from '../../common/filesystem';
import { DefaultFileDialogService, OpenFileDialogProps, SaveFileDialogProps } from '../../browser/file-dialog';
//
// We are OK to use this here because the electron backend and frontend are on the same host.
// If required, we can move this single service (and its module) to a dedicated Theia extension,
// and at packaging time, clients can decide whether they need the native or the browser-based
// solution.
//
// eslint-disable-next-line @theia/runtime-import-check
import { FileUri } from '@theia/core/lib/common/file-uri';
import { OpenDialogOptions, SaveDialogOptions } from '../../electron-common/electron-api';
import '@theia/core/lib/electron-common/electron-api';
@injectable()
export class ElectronFileDialogService extends DefaultFileDialogService {
@inject(MessageService) protected readonly messageService: MessageService;
override async showOpenDialog(props: OpenFileDialogProps & { canSelectMany: true }, folder?: FileStat): Promise<MaybeArray<URI> | undefined>;
override async showOpenDialog(props: OpenFileDialogProps, folder?: FileStat): Promise<URI | undefined>;
override async showOpenDialog(props: OpenFileDialogProps, folder?: FileStat): Promise<MaybeArray<URI> | undefined> {
if (window.electronTheiaCore.useNativeElements) {
const rootNode = await this.getRootNode(folder);
if (rootNode) {
const filePaths = await window.electronTheiaFilesystem.showOpenDialog(this.toOpenDialogOptions(rootNode.uri, props));
if (!filePaths || filePaths.length === 0) {
return undefined;
}
const uris = filePaths.map(path => FileUri.create(path));
const canAccess = await this.canRead(uris);
const result = canAccess ? uris.length === 1 ? uris[0] : uris : undefined;
return result;
}
return undefined;
}
return super.showOpenDialog(props, folder);
}
override async showSaveDialog(props: SaveFileDialogProps, folder?: FileStat): Promise<URI | undefined> {
if (window.electronTheiaCore.useNativeElements) {
const rootNode = await this.getRootNode(folder);
if (rootNode) {
const filePath = await window.electronTheiaFilesystem.showSaveDialog(this.toSaveDialogOptions(rootNode.uri, props));
if (!filePath) {
return undefined;
}
const uri = FileUri.create(filePath);
const exists = await this.fileService.exists(uri);
if (!exists) {
return uri;
}
const canWrite = await this.canReadWrite(uri);
return canWrite ? uri : undefined;
}
return undefined;
}
return super.showSaveDialog(props, folder);
}
protected async canReadWrite(uris: MaybeArray<URI>): Promise<boolean> {
for (const uri of Array.isArray(uris) ? uris : [uris]) {
if (!(await this.fileService.access(uri, FileAccess.Constants.R_OK | FileAccess.Constants.W_OK))) {
this.messageService.error(`Cannot access resource at ${uri.path}.`);
return false;
}
}
return true;
}
protected async canRead(uris: MaybeArray<URI>): Promise<boolean> {
const resources = Array.isArray(uris) ? uris : [uris];
const unreadableResourcePaths: string[] = [];
await Promise.all(resources.map(async resource => {
if (!await this.fileService.access(resource, FileAccess.Constants.R_OK)) {
unreadableResourcePaths.push(resource.path.toString());
}
}));
if (unreadableResourcePaths.length > 0) {
this.messageService.error(`Cannot read ${unreadableResourcePaths.length} resource(s): ${unreadableResourcePaths.join(', ')}`);
}
return unreadableResourcePaths.length === 0;
}
protected toOpenDialogOptions(uri: URI, props: OpenFileDialogProps): OpenDialogOptions {
const result: OpenDialogOptions = {
path: FileUri.fsPath(uri)
};
result.title = props.title;
result.buttonLabel = props.openLabel;
result.maxWidth = props.maxWidth;
result.modal = props.modal ?? true;
result.openFiles = props.canSelectFiles;
result.openFolders = props.canSelectFolders;
result.selectMany = props.canSelectMany;
if (props.filters) {
result.filters = [];
const filters = Object.entries(props.filters);
for (const [label, extensions] of filters) {
result.filters.push({ name: label, extensions: extensions });
}
if (props.canSelectFiles) {
if (filters.length > 0) {
result.filters.push({ name: 'All Files', extensions: ['*'] });
}
}
}
return result;
}
protected toSaveDialogOptions(uri: URI, props: SaveFileDialogProps): SaveDialogOptions {
if (props.inputValue) {
uri = uri.resolve(props.inputValue);
}
const result: SaveDialogOptions = {
path: FileUri.fsPath(uri)
};
result.title = props.title;
result.buttonLabel = props.saveLabel;
result.maxWidth = props.maxWidth;
result.modal = props.modal ?? true;
if (props.filters) {
result.filters = [];
const filters = Object.entries(props.filters);
for (const [label, extensions] of filters) {
result.filters.push({ name: label, extensions: extensions });
}
}
return result;
}
}

View File

@@ -0,0 +1,31 @@
// *****************************************************************************
// Copyright (C) 2023 STMicroelectronics and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { CHANNEL_SHOW_OPEN, CHANNEL_SHOW_SAVE, OpenDialogOptions, SaveDialogOptions, TheiaFilesystemAPI } from '../electron-common/electron-api';
// eslint-disable-next-line import/no-extraneous-dependencies
import { ipcRenderer, contextBridge } from '@theia/core/electron-shared/electron';
const api: TheiaFilesystemAPI = {
showOpenDialog: (options: OpenDialogOptions) => ipcRenderer.invoke(CHANNEL_SHOW_OPEN, options),
showSaveDialog: (options: SaveDialogOptions) => ipcRenderer.invoke(CHANNEL_SHOW_SAVE, options),
};
export function preload(): void {
console.log('exposing theia filesystem electron api');
contextBridge.exposeInMainWorld('electronTheiaFilesystem', api);
}

View File

@@ -0,0 +1,55 @@
// *****************************************************************************
// Copyright (C) 2023 STMicroelectronics and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export interface FileFilter {
name: string;
extensions: string[];
}
export interface OpenDialogOptions {
title?: string,
maxWidth?: number,
path: string,
buttonLabel?: string,
modal?: boolean,
openFiles?: boolean,
openFolders?: boolean;
selectMany?: boolean;
filters?: FileFilter[];
}
export interface SaveDialogOptions {
title?: string,
maxWidth?: number,
path: string,
buttonLabel?: string,
modal?: boolean,
filters?: FileFilter[];
}
export interface TheiaFilesystemAPI {
showOpenDialog(options: OpenDialogOptions): Promise<string[] | undefined>;
showSaveDialog(options: SaveDialogOptions): Promise<string | undefined>;
}
declare global {
interface Window {
electronTheiaFilesystem: TheiaFilesystemAPI
}
}
export const CHANNEL_SHOW_OPEN = 'ShowOpenDialog';
export const CHANNEL_SHOW_SAVE = 'ShowSaveDialog';

View File

@@ -0,0 +1,78 @@
// *****************************************************************************
// Copyright (C) 2023 STMicroelectronics and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable } from '@theia/core/shared/inversify';
import { ElectronMainApplication, ElectronMainApplicationContribution } from '@theia/core/lib/electron-main/electron-main-application';
import { MaybePromise } from '@theia/core';
import { CHANNEL_SHOW_OPEN, CHANNEL_SHOW_SAVE, OpenDialogOptions, SaveDialogOptions } from '../electron-common/electron-api';
import { ipcMain, OpenDialogOptions as ElectronOpenDialogOptions, SaveDialogOptions as ElectronSaveDialogOptions, BrowserWindow, dialog }
from '@theia/core/electron-shared/electron';
@injectable()
export class ElectronApi implements ElectronMainApplicationContribution {
onStart(application: ElectronMainApplication): MaybePromise<void> {
// dialogs
ipcMain.handle(CHANNEL_SHOW_OPEN, async (event, options: OpenDialogOptions) => {
const properties: ElectronOpenDialogOptions['properties'] = [];
// checking proper combination of file/dir opening is done on the renderer side
if (options.openFiles) {
properties.push('openFile');
}
if (options.openFolders) {
properties.push('openDirectory');
}
if (options.selectMany === true) {
properties.push('multiSelections');
}
const dialogOpts: ElectronOpenDialogOptions = {
defaultPath: options.path,
buttonLabel: options.buttonLabel,
filters: options.filters,
title: options.title,
properties: properties
};
if (options.modal) {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
return (await dialog.showOpenDialog(win, dialogOpts)).filePaths;
}
}
return (await dialog.showOpenDialog(dialogOpts)).filePaths;
});
ipcMain.handle(CHANNEL_SHOW_SAVE, async (event, options: SaveDialogOptions) => {
const dialogOpts: ElectronSaveDialogOptions = {
defaultPath: options.path,
buttonLabel: options.buttonLabel,
filters: options.filters,
title: options.title
};
if (options.modal) {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
return (await dialog.showSaveDialog(win, dialogOpts)).filePath;
}
}
return (await dialog.showSaveDialog(dialogOpts)).filePath;
});
}
}

View File

@@ -0,0 +1,23 @@
// *****************************************************************************
// Copyright (C) 2023 STMicroelectronics and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ContainerModule } from '@theia/core/shared/inversify';
import { ElectronMainApplicationContribution } from '@theia/core/lib/electron-main/electron-main-application';
import { ElectronApi } from './electron-api-main';
export default new ContainerModule(bind => {
bind(ElectronApi).toSelf().inSingletonScope();
bind(ElectronMainApplicationContribution).toService(ElectronApi);
});

View File

@@ -0,0 +1,142 @@
// *****************************************************************************
// Copyright (C) 2023 Arduino SA and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable';
import { EncodingService } from '@theia/core/lib/common/encoding-service';
import { ILogger } from '@theia/core/lib/common/logger';
import { MockLogger } from '@theia/core/lib/common/test/mock-logger';
import { FileUri } from '@theia/core/lib/common/file-uri';
import { IPCConnectionProvider } from '@theia/core/lib/node/messaging/ipc-connection-provider';
import { Container, ContainerModule } from '@theia/core/shared/inversify';
import { equal, fail } from 'assert';
import { promises as fs } from 'fs';
import { join } from 'path';
import * as temp from 'temp';
import { generateUuid } from '@theia/core/lib/common/uuid';
import { FilePermission, FileSystemProviderCapabilities, FileSystemProviderError, FileSystemProviderErrorCode } from '../common/files';
import { DiskFileSystemProvider } from './disk-file-system-provider';
import { bindFileSystemWatcherServer } from './filesystem-backend-module';
const tracked = temp.track();
describe('disk-file-system-provider', () => {
let toDisposeAfter: DisposableCollection;
let fsProvider: DiskFileSystemProvider;
before(() => {
fsProvider = createContainer().get<DiskFileSystemProvider>(
DiskFileSystemProvider
);
toDisposeAfter = new DisposableCollection(
fsProvider,
Disposable.create(() => tracked.cleanupSync())
);
});
after(() => {
toDisposeAfter.dispose();
});
describe('stat', () => {
it("should omit the 'permissions' property of the stat if the file can be both read and write", async () => {
const tempDirPath = tracked.mkdirSync();
const tempFilePath = join(tempDirPath, `${generateUuid()}.txt`);
await fs.writeFile(tempFilePath, 'some content', { encoding: 'utf8' });
let content = await fs.readFile(tempFilePath, { encoding: 'utf8' });
equal(content, 'some content');
await fs.writeFile(tempFilePath, 'other content', { encoding: 'utf8' });
content = await fs.readFile(tempFilePath, { encoding: 'utf8' });
equal(content, 'other content');
const stat = await fsProvider.stat(FileUri.create(tempFilePath));
equal(stat.permissions, undefined);
});
it("should set the 'permissions' property to `Readonly` if the file can be read but not write", async () => {
const tempDirPath = tracked.mkdirSync();
const tempFilePath = join(tempDirPath, `${generateUuid()}.txt`);
await fs.writeFile(tempFilePath, 'readonly content', {
encoding: 'utf8',
});
await fs.chmod(tempFilePath, '444'); // read-only for owner/group/world
try {
await fsProvider.writeFile(FileUri.create(tempFilePath), new Uint8Array(), { create: false, overwrite: true });
fail('Expected an EACCES error for readonly (chmod 444) files');
} catch (err) {
equal(err instanceof FileSystemProviderError, true);
equal((<FileSystemProviderError>err).code, FileSystemProviderErrorCode.NoPermissions);
}
const content = await fs.readFile(tempFilePath, { encoding: 'utf8' });
equal(content, 'readonly content');
const stat = await fsProvider.stat(FileUri.create(tempFilePath));
equal(stat.permissions, FilePermission.Readonly);
});
});
describe('delete', () => {
it('delete is able to delete folder', async () => {
const tempDirPath = tracked.mkdirSync();
const testFolder = join(tempDirPath, 'test');
const folderUri = FileUri.create(testFolder);
for (const recursive of [true, false]) {
// Note: useTrash = true fails on Linux
const useTrash = false;
if ((fsProvider.capabilities & FileSystemProviderCapabilities.Access) === 0 && useTrash) {
continue;
}
await fsProvider.mkdir(folderUri);
if (recursive) {
await fsProvider.writeFile(FileUri.create(join(testFolder, 'test.file')), Buffer.from('test'), { overwrite: false, create: true });
await fsProvider.mkdir(FileUri.create(join(testFolder, 'subFolder')));
}
await fsProvider.delete(folderUri, { recursive, useTrash });
}
});
it('delete is able to delete file', async () => {
const tempDirPath = tracked.mkdirSync();
const testFile = join(tempDirPath, 'test.file');
const testFileUri = FileUri.create(testFile);
for (const recursive of [true, false]) {
for (const useTrash of [true, false]) {
await fsProvider.writeFile(testFileUri, Buffer.from('test'), { overwrite: false, create: true });
await fsProvider.delete(testFileUri, { recursive, useTrash });
}
}
});
});
function createContainer(): Container {
const container = new Container({ defaultScope: 'Singleton' });
const module = new ContainerModule(bind => {
bind(DiskFileSystemProvider).toSelf().inSingletonScope();
bind(EncodingService).toSelf().inSingletonScope();
bindFileSystemWatcherServer(bind);
bind(MockLogger).toSelf().inSingletonScope();
bind(ILogger).toService(MockLogger);
bind(IPCConnectionProvider).toSelf().inSingletonScope();
});
container.load(module);
return container;
}
});

View File

@@ -0,0 +1,914 @@
// *****************************************************************************
// Copyright (C) 2020 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// based on https://github.com/microsoft/vscode/blob/04c36be045a94fee58e5f8992d3e3fd980294a84/src/vs/platform/files/node/diskFileSystemProvider.ts
/* eslint-disable no-null/no-null */
/* eslint-disable @typescript-eslint/no-shadow */
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import { basename, dirname, normalize, join } from 'path';
import { generateUuid } from '@theia/core/lib/common/uuid';
import * as os from 'os';
import * as fs from 'fs';
import {
mkdir, open, close, read, write, fdatasync, Stats,
lstat, stat, readdir, readFile, exists, chmod,
rmdir, unlink, rename, futimes, truncate
} from 'fs';
import { promisify } from 'util';
import URI from '@theia/core/lib/common/uri';
import { Path } from '@theia/core/lib/common/path';
import { FileUri } from '@theia/core/lib/common/file-uri';
import { Event, Emitter } from '@theia/core/lib/common/event';
import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable';
import { OS, isWindows } from '@theia/core/lib/common/os';
import { retry } from '@theia/core/lib/common/promise-util';
import {
FileSystemProviderWithFileReadWriteCapability, FileSystemProviderWithOpenReadWriteCloseCapability, FileSystemProviderWithFileFolderCopyCapability,
FileSystemProviderCapabilities,
Stat,
FileType,
FileWriteOptions,
createFileSystemProviderError,
FileSystemProviderErrorCode,
FileOpenOptions,
FileDeleteOptions,
FileOverwriteOptions,
FileSystemProviderError,
FileChange,
WatchOptions,
FileUpdateOptions, FileUpdateResult, FileReadStreamOptions, FilePermission
} from '../common/files';
import { FileSystemWatcherServer } from '../common/filesystem-watcher-protocol';
import trash = require('trash');
import { TextDocumentContentChangeEvent } from '@theia/core/shared/vscode-languageserver-protocol';
import { TextDocument } from 'vscode-languageserver-textdocument';
import { EncodingService } from '@theia/core/lib/common/encoding-service';
import { BinaryBuffer } from '@theia/core/lib/common/buffer';
import { ReadableStreamEvents, newWriteableStream } from '@theia/core/lib/common/stream';
import { CancellationToken } from '@theia/core/lib/common/cancellation';
import { readFileIntoStream } from '../common/io';
import { Mode } from 'stat-mode';
export namespace DiskFileSystemProvider {
export interface StatAndLink {
// The stats of the file. If the file is a symbolic
// link, the stats will be of that target file and
// not the link itself.
// If the file is a symbolic link pointing to a non
// existing file, the stat will be of the link and
// the `dangling` flag will indicate this.
stat: fs.Stats;
// Will be provided if the resource is a symbolic link
// on disk. Use the `dangling` flag to find out if it
// points to a resource that does not exist on disk.
symbolicLink?: { dangling: boolean };
}
}
@injectable()
export class DiskFileSystemProvider implements Disposable,
FileSystemProviderWithFileReadWriteCapability,
FileSystemProviderWithOpenReadWriteCloseCapability,
FileSystemProviderWithFileFolderCopyCapability {
private readonly BUFFER_SIZE = 64 * 1024;
private readonly onDidChangeFileEmitter = new Emitter<readonly FileChange[]>();
readonly onDidChangeFile = this.onDidChangeFileEmitter.event;
private readonly onFileWatchErrorEmitter = new Emitter<void>();
readonly onFileWatchError = this.onFileWatchErrorEmitter.event;
protected readonly toDispose = new DisposableCollection(
this.onDidChangeFileEmitter
);
@inject(FileSystemWatcherServer)
protected readonly watcher: FileSystemWatcherServer;
@inject(EncodingService)
protected readonly encodingService: EncodingService;
@postConstruct()
protected init(): void {
this.toDispose.push(this.watcher);
this.watcher.setClient({
onDidFilesChanged: params => this.onDidChangeFileEmitter.fire(params.changes.map(({ uri, type }) => ({
resource: new URI(uri),
type
}))),
onError: () => this.onFileWatchErrorEmitter.fire()
});
}
// #region File Capabilities
readonly onDidChangeCapabilities = Event.None;
protected _capabilities: FileSystemProviderCapabilities | undefined;
get capabilities(): FileSystemProviderCapabilities {
if (!this._capabilities) {
this._capabilities =
FileSystemProviderCapabilities.FileReadWrite |
FileSystemProviderCapabilities.FileOpenReadWriteClose |
FileSystemProviderCapabilities.FileReadStream |
FileSystemProviderCapabilities.FileFolderCopy |
FileSystemProviderCapabilities.Access |
FileSystemProviderCapabilities.Trash |
FileSystemProviderCapabilities.Update;
if (OS.type() === OS.Type.Linux) {
this._capabilities |= FileSystemProviderCapabilities.PathCaseSensitive;
}
}
return this._capabilities;
}
// #endregion
// #region File Metadata Resolving
async stat(resource: URI): Promise<Stat> {
try {
const { stat, symbolicLink } = await this.statLink(this.toFilePath(resource)); // cannot use fs.stat() here to support links properly
const mode = new Mode(stat);
return {
type: this.toType(stat, symbolicLink),
ctime: stat.birthtime.getTime(), // intentionally not using ctime here, we want the creation time
mtime: stat.mtime.getTime(),
size: stat.size,
permissions: !mode.owner.write ? FilePermission.Readonly : undefined,
};
} catch (error) {
throw this.toFileSystemProviderError(error);
}
}
async access(resource: URI, mode?: number): Promise<void> {
try {
await promisify(fs.access)(this.toFilePath(resource), mode);
} catch (error) {
throw this.toFileSystemProviderError(error);
}
}
async fsPath(resource: URI): Promise<string> {
return FileUri.fsPath(resource);
}
protected async statLink(path: string): Promise<DiskFileSystemProvider.StatAndLink> {
// First stat the link
let lstats: Stats | undefined;
try {
lstats = await promisify(lstat)(path);
// Return early if the stat is not a symbolic link at all
if (!lstats.isSymbolicLink()) {
return { stat: lstats };
}
} catch (error) {
/* ignore - use stat() instead */
}
// If the stat is a symbolic link or failed to stat, use fs.stat()
// which for symbolic links will stat the target they point to
try {
const stats = await promisify(stat)(path);
return { stat: stats, symbolicLink: lstats?.isSymbolicLink() ? { dangling: false } : undefined };
} catch (error) {
// If the link points to a non-existing file we still want
// to return it as result while setting dangling: true flag
if (error.code === 'ENOENT' && lstats) {
return { stat: lstats, symbolicLink: { dangling: true } };
}
throw error;
}
}
async readdir(resource: URI): Promise<[string, FileType][]> {
try {
const children = await promisify(fs.readdir)(this.toFilePath(resource));
const result: [string, FileType][] = [];
await Promise.all(children.map(async child => {
try {
const stat = await this.stat(resource.resolve(child));
result.push([child, stat.type]);
} catch (error) {
console.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, symbolicLink?: { dangling: boolean }): FileType {
// Signal file type by checking for file / directory, except:
// - symbolic links pointing to non-existing files are FileType.Unknown
// - files that are neither file nor directory are FileType.Unknown
let type: FileType;
if (symbolicLink?.dangling) {
type = FileType.Unknown;
} else if (entry.isFile()) {
type = FileType.File;
} else if (entry.isDirectory()) {
type = FileType.Directory;
} else {
type = FileType.Unknown;
}
// Always signal symbolic link as file type additionally
if (symbolicLink) {
type |= FileType.SymbolicLink;
}
return type;
}
// #endregion
// #region File Reading/Writing
async readFile(resource: URI): Promise<Uint8Array> {
try {
const filePath = this.toFilePath(resource);
return await promisify(readFile)(filePath);
} catch (error) {
throw this.toFileSystemProviderError(error);
}
}
readFileStream(resource: URI, opts: FileReadStreamOptions, token: CancellationToken): ReadableStreamEvents<Uint8Array> {
const stream = newWriteableStream<Uint8Array>(data => BinaryBuffer.concat(data.map(data => BinaryBuffer.wrap(data))).buffer);
readFileIntoStream(this, resource, stream, data => data.buffer, {
...opts,
bufferSize: this.BUFFER_SIZE
}, token);
return stream;
}
async writeFile(resource: URI, content: Uint8Array, opts: FileWriteOptions): Promise<void> {
let handle: number | undefined = undefined;
try {
const filePath = this.toFilePath(resource);
// Validate target unless { create: true, overwrite: true }
if (!opts.create || !opts.overwrite) {
const fileExists = await promisify(exists)(filePath);
if (fileExists) {
if (!opts.overwrite) {
throw createFileSystemProviderError('File already exists', FileSystemProviderErrorCode.FileExists);
}
} else if (!opts.create) {
throw createFileSystemProviderError('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 mapHandleToPos: Map<number, number> = new Map();
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 promisify(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 promisify(truncate)(filePath, 0);
// After a successful truncate() the flag can be set to 'r+' which will not truncate.
flags = 'r+';
} catch (error) {
console.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 this handle to track file position of the handle
// we init the position to 0 since the file descriptor was
// just created and the position was not moved so far (see
// also http://man7.org/linux/man-pages/man2/open.2.html -
// "The file offset is set to the beginning of the file.")
this.mapHandleToPos.set(handle, 0);
// 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 {
// remove this handle from map of positions
this.mapHandleToPos.delete(fd);
// 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;
console.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> {
const normalizedPos = this.normalizePos(fd, pos);
let bytesRead: number | null = null;
try {
const result = await promisify(read)(fd, data, offset, length, normalizedPos);
if (typeof result === 'number') {
bytesRead = result; // node.d.ts fail
} else {
bytesRead = result.bytesRead;
}
return bytesRead;
} catch (error) {
throw this.toFileSystemProviderError(error);
} finally {
this.updatePos(fd, normalizedPos, bytesRead);
}
}
private normalizePos(fd: number, pos: number): number | null {
// when calling fs.read/write we try to avoid passing in the "pos" argument and
// rather prefer to pass in "null" because this avoids an extra seek(pos)
// call that in some cases can even fail (e.g. when opening a file over FTP -
// see https://github.com/microsoft/vscode/issues/73884).
//
// as such, we compare the passed in position argument with our last known
// position for the file descriptor and use "null" if they match.
if (pos === this.mapHandleToPos.get(fd)) {
return null;
}
return pos;
}
private updatePos(fd: number, pos: number | null, bytesLength: number | null): void {
const lastKnownPos = this.mapHandleToPos.get(fd);
if (typeof lastKnownPos === 'number') {
// pos !== null signals that previously a position was used that is
// not null. node.js documentation explains, that in this case
// the internal file pointer is not moving and as such we do not move
// our position pointer.
//
// Docs: "If position is null, data will be read from the current file position,
// and the file position will be updated. If position is an integer, the file position
// will remain unchanged."
if (typeof pos === 'number') {
// do not modify the position
} else if (typeof bytesLength === 'number') {
this.mapHandleToPos.set(fd, lastKnownPos + bytesLength);
} else {
this.mapHandleToPos.delete(fd);
}
}
}
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> {
const normalizedPos = this.normalizePos(fd, pos);
let bytesWritten: number | null = null;
try {
const result = await promisify(write)(fd, data, offset, length, normalizedPos);
if (typeof result === 'number') {
bytesWritten = result; // node.d.ts fail
} else {
bytesWritten = result.bytesWritten;
}
return bytesWritten;
} catch (error) {
throw this.toFileSystemProviderError(error);
} finally {
this.updatePos(fd, normalizedPos, bytesWritten);
}
}
// #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.useTrash) {
if (opts.recursive) {
await this.rimraf(filePath);
} else {
const stat = await promisify(lstat)(filePath);
if (stat.isDirectory() && !stat.isSymbolicLink()) {
await promisify(rmdir)(filePath);
} else {
await promisify(unlink)(filePath);
}
}
} else {
await trash(filePath);
}
}
protected rimraf(path: string): Promise<void> {
if (new Path(path).isRoot) {
throw new Error('rimraf - will refuse to recursively delete root');
}
return this.rimrafMove(path);
}
protected async rimrafMove(path: string): Promise<void> {
try {
const pathInTemp = join(os.tmpdir(), generateUuid());
try {
await promisify(rename)(path, pathInTemp);
} catch (error) {
return this.rimrafUnlink(path); // if rename fails, delete without tmp dir
}
// Delete but do not return as promise
this.rimrafUnlink(pathInTemp);
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
}
protected async rimrafUnlink(path: string): Promise<void> {
try {
const stat = await promisify(lstat)(path);
// Folder delete (recursive) - NOT for symbolic links though!
if (stat.isDirectory() && !stat.isSymbolicLink()) {
// Children
const children = await promisify(readdir)(path);
await Promise.all(children.map(child => this.rimrafUnlink(join(path, child))));
// Folder
await promisify(rmdir)(path);
} else {
// chmod as needed to allow for unlink
const mode = stat.mode;
if (!(mode & 128)) { // 128 === 0200
await promisify(chmod)(path, mode | 128);
}
return promisify(unlink)(path);
}
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
}
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.overwrite);
// Move
await this.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(`Unable to move '${basename(fromFilePath)}' into '${basename(dirname(toFilePath))}' (${error.toString()}).`);
}
throw this.toFileSystemProviderError(error);
}
}
protected async move(source: string, target: string): Promise<void> {
if (source === target) {
return Promise.resolve();
}
async function updateMtime(path: string): Promise<void> {
const stat = await promisify(lstat)(path);
if (stat.isDirectory() || stat.isSymbolicLink()) {
return Promise.resolve(); // only for files
}
const fd = await promisify(open)(path, 'a');
try {
await promisify(futimes)(fd, stat.atime, new Date());
} catch (error) {
// ignore
}
return promisify(close)(fd);
}
try {
await promisify(rename)(source, target);
await updateMtime(target);
} catch (error) {
// In two cases we fallback to classic copy and delete:
//
// 1.) The EXDEV error indicates that source and target are on different devices
// In this case, fallback to using a copy() operation as there is no way to
// rename() between different devices.
//
// 2.) The user tries to rename a file/folder that ends with a dot. This is not
// really possible to move then, at least on UNC devices.
if (source.toLowerCase() !== target.toLowerCase() && error.code === 'EXDEV' || source.endsWith('.')) {
await this.doCopy(source, target);
await this.rimraf(source);
await updateMtime(target);
} else {
throw 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.overwrite);
// Copy
await this.doCopy(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(`Unable to copy '${basename(fromFilePath)}' into '${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 = fromFilePath.toLowerCase() === toFilePath.toLowerCase();
}
if (isSameResourceWithDifferentPathCase && mode === 'copy') {
throw createFileSystemProviderError("'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 promisify(exists)(toFilePath)) {
if (!overwrite) {
throw createFileSystemProviderError('File at target already exists', FileSystemProviderErrorCode.FileExists);
}
// Delete target
await this.delete(to, { recursive: true, useTrash: false });
}
}
protected async doCopy(source: string, target: string, copiedSourcesIn?: { [path: string]: boolean }): Promise<void> {
const copiedSources = copiedSourcesIn ? copiedSourcesIn : Object.create(null);
const fileStat = await promisify(stat)(source);
if (!fileStat.isDirectory()) {
return this.doCopyFile(source, target, fileStat.mode & 511);
}
if (copiedSources[source]) {
return Promise.resolve(); // escape when there are cycles (can happen with symlinks)
}
copiedSources[source] = true; // remember as copied
// Create folder
await this.mkdirp(target, fileStat.mode & 511);
// Copy each file recursively
const files = await promisify(readdir)(source);
for (let i = 0; i < files.length; i++) {
const file = files[i];
await this.doCopy(join(source, file), join(target, file), copiedSources);
}
}
protected async mkdirp(path: string, mode?: number): Promise<void> {
const mkdir = async () => {
try {
await promisify(fs.mkdir)(path, mode);
} catch (error) {
// ENOENT: a parent folder does not exist yet
if (error.code === 'ENOENT') {
throw error;
}
// Any other error: check if folder exists and
// return normally in that case if its a folder
let targetIsFile = false;
try {
const fileStat = await promisify(fs.stat)(path);
targetIsFile = !fileStat.isDirectory();
} catch (statError) {
throw error; // rethrow original error if stat fails
}
if (targetIsFile) {
throw new Error(`'${path}' exists and is not a directory.`);
}
}
};
// stop at root
if (path === dirname(path)) {
return;
}
try {
await mkdir();
} catch (error) {
// ENOENT: a parent folder does not exist yet, continue
// to create the parent folder and then try again.
if (error.code === 'ENOENT') {
await this.mkdirp(dirname(path), mode);
return mkdir();
}
// Any other error
throw error;
}
}
protected doCopyFile(source: string, target: string, mode: number): Promise<void> {
return new Promise((resolve, reject) => {
const reader = fs.createReadStream(source);
const writer = fs.createWriteStream(target, { mode });
let finished = false;
const finish = (error?: Error) => {
if (!finished) {
finished = true;
// in error cases, pass to callback
if (error) {
return reject(error);
}
// we need to explicitly chmod because of https://github.com/nodejs/node/issues/1104
fs.chmod(target, mode, error => error ? reject(error) : resolve());
}
};
// handle errors properly
reader.once('error', error => finish(error));
writer.once('error', error => finish(error));
// we are done (underlying fd has been closed)
writer.once('close', () => finish());
// start piping
reader.pipe(writer);
});
}
// #endregion
// #region File Watching
watch(resource: URI, opts: WatchOptions): Disposable {
const watcherService = this.watcher;
/**
* Disposable handle. Can be disposed early (before the watcher is allocated.)
*/
const handle = {
disposed: false,
watcherId: undefined as number | undefined,
dispose(): void {
if (this.disposed) {
return;
}
if (this.watcherId !== undefined) {
watcherService.unwatchFileChanges(this.watcherId);
}
this.disposed = true;
},
};
watcherService.watchFileChanges(resource.toString(), {
// Convert from `files.WatchOptions` to internal `watcher-protocol.WatchOptions`:
ignored: opts.excludes
}).then(watcherId => {
if (handle.disposed) {
watcherService.unwatchFileChanges(watcherId);
} else {
handle.watcherId = watcherId;
}
});
this.toDispose.push(handle);
return handle;
}
// #endregion
async updateFile(resource: URI, changes: TextDocumentContentChangeEvent[], opts: FileUpdateOptions): Promise<FileUpdateResult> {
try {
const content = await this.readFile(resource);
const decoded = this.encodingService.decode(BinaryBuffer.wrap(content), opts.readEncoding);
const newContent = TextDocument.update(TextDocument.create('', '', 1, decoded), changes, 2).getText();
const encoding = await this.encodingService.toResourceEncoding(opts.writeEncoding, {
overwriteEncoding: opts.overwriteEncoding,
read: async length => {
const fd = await this.open(resource, { create: false });
try {
const data = new Uint8Array(length);
await this.read(fd, 0, data, 0, length);
return data;
} finally {
await this.close(fd);
}
}
});
const encoded = this.encodingService.encode(newContent, encoding);
await this.writeFile(resource, encoded.buffer, { create: false, overwrite: true });
const stat = await this.stat(resource);
return Object.assign(stat, { encoding: encoding.encoding });
} catch (error) {
throw this.toFileSystemProviderError(error);
}
}
// #region Helpers
protected toFilePath(resource: URI): string {
return normalize(FileUri.fsPath(resource));
}
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 'ENOTDIR':
code = FileSystemProviderErrorCode.FileNotADirectory;
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 {
this.toDispose.dispose();
}
}

View File

@@ -0,0 +1,104 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as fs from '@theia/core/shared/fs-extra';
import * as path from 'path';
import * as temp from 'temp';
import { extract } from 'tar-fs';
import { expect } from 'chai';
import URI from '@theia/core/lib/common/uri';
import { MockDirectoryArchiver } from './test/mock-directory-archiver';
import { FileUri } from '@theia/core/lib/common/file-uri';
const track = temp.track();
describe('directory-archiver', () => {
after(() => {
track.cleanupSync();
});
it('should archive a directory', async function (): Promise<unknown> {
this.timeout(20_000);
const fromPath = track.mkdirSync('from');
fs.writeFileSync(path.join(fromPath, 'A.txt'), 'A');
fs.writeFileSync(path.join(fromPath, 'B.txt'), 'B');
expect(fs.readFileSync(path.join(fromPath, 'A.txt'), { encoding: 'utf8' })).to.be.equal('A');
expect(fs.readFileSync(path.join(fromPath, 'B.txt'), { encoding: 'utf8' })).to.be.equal('B');
const toPath = track.mkdirSync('to');
const archiver = new MockDirectoryArchiver();
await archiver.archive(fromPath, path.join(toPath, 'output.tar'));
expect(fs.existsSync(path.join(toPath, 'output.tar'))).to.be.true;
const assertPath = track.mkdirSync('assertPath');
return new Promise<void>(resolve => {
fs.createReadStream(path.join(toPath, 'output.tar')).pipe(extract(assertPath)).on('finish', () => {
expect(fs.readdirSync(assertPath).sort()).to.be.deep.equal(['A.txt', 'B.txt']);
expect(fs.readFileSync(path.join(assertPath, 'A.txt'), { encoding: 'utf8' })).to.be.equal(fs.readFileSync(path.join(fromPath, 'A.txt'), { encoding: 'utf8' }));
expect(fs.readFileSync(path.join(assertPath, 'B.txt'), { encoding: 'utf8' })).to.be.equal(fs.readFileSync(path.join(fromPath, 'B.txt'), { encoding: 'utf8' }));
resolve();
});
});
});
describe('findCommonParents', () => {
([
{
input: ['/A/B/C/D.txt', '/X/Y/Z.txt'],
expected: new Map([['/A/B/C', ['/A/B/C/D.txt']], ['/X/Y', ['/X/Y/Z.txt']]]),
folders: ['/A', '/A/B', '/A/B/C', '/X', '/X/Y']
},
{
input: ['/A/B/C/D.txt', '/A/B/C/E.txt'],
expected: new Map([['/A/B/C', ['/A/B/C/D.txt', '/A/B/C/E.txt']]]),
folders: ['/A', '/A/B', '/A/B/C']
},
{
input: ['/A', '/A/B/C/D.txt', '/A/B/C/E.txt'],
expected: new Map([['/A', ['/A', '/A/B/C/D.txt', '/A/B/C/E.txt']]]),
folders: ['/A', '/A/B', '/A/B/C']
},
{
input: ['/A/B/C/D.txt', '/A/B/C/E.txt', '/A'],
expected: new Map([['/A', ['/A', '/A/B/C/D.txt', '/A/B/C/E.txt']]]),
folders: ['/A', '/A/B', '/A/B/C']
},
{
input: ['/A/B/C/D.txt', '/A/B/X/E.txt'],
expected: new Map([['/A/B', ['/A/B/C/D.txt', '/A/B/X/E.txt']]]),
folders: ['/A', '/A/B', '/A/B/C', '/A/B/X']
}
] as ({ input: string[], expected: Map<string, string[]>, folders?: string[] })[]).forEach(test => {
const { input, expected, folders } = test;
it(`should find the common parent URIs among [${input.join(', ')}] => [${Array.from(expected.keys()).join(', ')}]`, async () => {
const archiver = new MockDirectoryArchiver(folders ? folders.map(FileUri.create) : []);
const actual = await archiver.findCommonParents(input.map(FileUri.create));
expect(asString(actual)).to.be.equal(asString(expected));
});
});
function asString(map: Map<string, string[]>): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const obj: any = {};
for (const key of Array.from(map.keys()).sort()) {
const values = (map.get(key) || []).sort();
obj[new URI(key).withScheme('file').toString()] = `[${values.map(v => new URI(v).withScheme('file').toString()).join(', ')}]`;
}
return JSON.stringify(obj);
}
});
});

View File

@@ -0,0 +1,126 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable } from '@theia/core/shared/inversify';
import * as fs from '@theia/core/shared/fs-extra';
import { pack } from 'tar-fs';
import URI from '@theia/core/lib/common/uri';
import { FileUri } from '@theia/core/lib/common/file-uri';
@injectable()
export class DirectoryArchiver {
async archive(inputPath: string, outputPath: string, entries?: string[]): Promise<void> {
return new Promise<void>(async (resolve, reject) => {
pack(inputPath, { entries }).pipe(fs.createWriteStream(outputPath)).on('finish', () => resolve()).on('error', e => reject(e));
});
}
async findCommonParents(uris: URI[]): Promise<Map<string, string[]>> {
const map = new Map<string, string[]>();
for (const uri of uris) {
// 1. Get the container if not the URI is not a directory.
const containerUri = (await this.isDir(uri)) ? uri : uri.parent;
let containerUriStr = this.toUriString(containerUri);
// 2. If the container already registered, just append the current URI to it.
if (map.has(containerUriStr)) {
map.set(containerUriStr, [...map.get(containerUriStr)!, this.toUriString(uri)]);
} else {
// 3. Try to find the longest container URI that we can use.
// When we have `/A/B/` and `/A/C` and a file `A/B/C/D.txt` then we need to find `/A/B`. The longest URIs come first.
for (const knownContainerUri of Array.from(map.keys()).sort((left, right) => right.length - left.length)) {
if (uri.toString().startsWith(knownContainerUri)) {
containerUriStr = knownContainerUri;
break;
}
}
const entries = map.get(containerUriStr) || [];
entries.push(this.toUriString(uri));
map.set(containerUriStr, entries);
}
// 4. Collapse the hierarchy by finding the closest common parents for the entries, if any.
let collapsed = false;
collapseLoop: while (!collapsed) {
const knownContainerUris = Array.from(map.keys()).sort((left, right) => right.length - left.length);
if (knownContainerUris.length > 1) {
for (let i = 0; i < knownContainerUris.length; i++) {
for (let j = i + 1; j < knownContainerUris.length; j++) {
const left = knownContainerUris[i];
const right = knownContainerUris[j];
const commonParent = this.closestCommonParentUri(new URI(left), new URI(right));
if (commonParent && !commonParent.path.isRoot) {
const leftEntries = map.get(left) || [];
const rightEntries = map.get(right) || [];
map.delete(left);
map.delete(right);
map.set(this.toUriString(commonParent), [...leftEntries, ...rightEntries]);
break collapseLoop;
}
}
}
}
collapsed = true;
}
}
return map;
}
protected closestCommonParentUri(left: URI, right: URI): URI | undefined {
if (left.scheme !== right.scheme) {
return undefined;
}
const allLeft = left.allLocations;
const allRight = right.allLocations;
for (const leftUri of allLeft) {
for (const rightUri of allRight) {
if (this.equal(leftUri, rightUri)) {
return leftUri;
}
}
}
return undefined;
}
protected async isDir(uri: URI): Promise<boolean> {
try {
const stat = await fs.stat(FileUri.fsPath(uri));
return stat.isDirectory();
} catch {
return false;
}
}
protected equal(left: URI | URI[], right: URI | URI[]): boolean {
if (Array.isArray(left) && Array.isArray(right)) {
if (left === right) {
return true;
}
if (left.length !== right.length) {
return false;
}
return left.map(this.toUriString).sort().toString() === right.map(this.toUriString).sort().toString();
} else if (left instanceof URI && right instanceof URI) {
return this.toUriString(left) === this.toUriString(right);
}
return false;
}
protected toUriString(uri: URI): string {
const raw = uri.toString();
return raw.endsWith('/') ? raw.slice(0, -1) : raw;
}
}

View File

@@ -0,0 +1,32 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ContainerModule } from '@theia/core/shared/inversify';
import { BackendApplicationContribution } from '@theia/core/lib/node/backend-application';
import { FileDownloadEndpoint } from './file-download-endpoint';
import { FileDownloadHandler, SingleFileDownloadHandler, MultiFileDownloadHandler, DownloadLinkHandler } from './file-download-handler';
import { DirectoryArchiver } from './directory-archiver';
import { FileDownloadCache } from './file-download-cache';
export default new ContainerModule(bind => {
bind(FileDownloadEndpoint).toSelf().inSingletonScope();
bind(BackendApplicationContribution).toService(FileDownloadEndpoint);
bind(FileDownloadCache).toSelf().inSingletonScope();
bind(FileDownloadHandler).to(SingleFileDownloadHandler).inSingletonScope().whenTargetNamed(FileDownloadHandler.SINGLE);
bind(FileDownloadHandler).to(MultiFileDownloadHandler).inSingletonScope().whenTargetNamed(FileDownloadHandler.MULTI);
bind(FileDownloadHandler).to(DownloadLinkHandler).inSingletonScope().whenTargetNamed(FileDownloadHandler.DOWNLOAD_LINK);
bind(DirectoryArchiver).toSelf().inSingletonScope();
});

View File

@@ -0,0 +1,86 @@
// *****************************************************************************
// Copyright (C) 2019 Bitsler and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject } from '@theia/core/shared/inversify';
import { ILogger } from '@theia/core/lib/common/logger';
import { rimraf } from 'rimraf';
export interface DownloadStorageItem {
file: string;
root?: string;
size: number;
remove: boolean;
expire?: number;
}
@injectable()
export class FileDownloadCache {
@inject(ILogger)
protected readonly logger: ILogger;
protected readonly downloads = new Map<string, DownloadStorageItem>();
protected readonly expireTimeInMinutes: number = 1;
addDownload(id: string, downloadInfo: DownloadStorageItem): void {
downloadInfo.file = encodeURIComponent(downloadInfo.file);
if (downloadInfo.root) {
downloadInfo.root = encodeURIComponent(downloadInfo.root);
}
// expires in 1 minute enough for parallel connections to be connected.
downloadInfo.expire = Date.now() + (this.expireTimeInMinutes * 600000);
this.downloads.set(id, downloadInfo);
}
getDownload(id: string): DownloadStorageItem | undefined {
this.expireDownloads();
const downloadInfo = this.downloads.get(id);
if (downloadInfo) {
downloadInfo.file = decodeURIComponent(downloadInfo.file);
if (downloadInfo.root) {
downloadInfo.root = decodeURIComponent(downloadInfo.root);
}
}
return downloadInfo;
}
deleteDownload(id: string): void {
const downloadInfo = this.downloads.get(id);
if (downloadInfo && downloadInfo.remove) {
this.deleteRecursively(downloadInfo.root || downloadInfo.file);
}
this.downloads.delete(id);
}
values(): { [key: string]: DownloadStorageItem } {
this.expireDownloads();
return [...this.downloads.entries()].reduce((downloads, [key, value]) => ({ ...downloads, [key]: value }), {});
}
protected deleteRecursively(pathToDelete: string): void {
rimraf(pathToDelete).catch(error => {
this.logger.warn(`An error occurred while deleting the temporary data from the disk. Cannot clean up: ${pathToDelete}.`, error);
});
}
protected expireDownloads(): void {
const time = Date.now();
for (const [id, download] of this.downloads.entries()) {
if (download.expire && download.expire <= time) {
this.deleteDownload(id);
}
}
}
}

View File

@@ -0,0 +1,63 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as url from 'url';
import { injectable, inject, named } from '@theia/core/shared/inversify';
import { json } from 'body-parser';
import { Application, Router } from '@theia/core/shared/express';
import { BackendApplicationContribution } from '@theia/core/lib/node/backend-application';
import { FileUri } from '@theia/core/lib/common/file-uri';
import { FileDownloadHandler } from './file-download-handler';
@injectable()
export class FileDownloadEndpoint implements BackendApplicationContribution {
protected static PATH = '/files';
@inject(FileDownloadHandler)
@named(FileDownloadHandler.SINGLE)
protected readonly singleFileDownloadHandler: FileDownloadHandler;
@inject(FileDownloadHandler)
@named(FileDownloadHandler.MULTI)
protected readonly multiFileDownloadHandler: FileDownloadHandler;
@inject(FileDownloadHandler)
@named(FileDownloadHandler.DOWNLOAD_LINK)
protected readonly downloadLinkHandler: FileDownloadHandler;
configure(app: Application): void {
const router = Router();
router.get('/download', (request, response) => this.downloadLinkHandler.handle(request, response));
router.get('/', (request, response) => this.singleFileDownloadHandler.handle(request, response));
router.put('/', (request, response) => this.multiFileDownloadHandler.handle(request, response));
// Content-Type: application/json
app.use(json());
app.use(FileDownloadEndpoint.PATH, router);
app.get('/file', (request, response) => {
const uri = url.parse(request.url).query;
if (!uri) {
response.status(400).send('invalid uri');
return;
}
const fsPath = FileUri.fsPath(decodeURIComponent(uri));
response.sendFile(fsPath);
});
}
}

View File

@@ -0,0 +1,304 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as os from 'os';
import * as fs from '@theia/core/shared/fs-extra';
import * as path from 'path';
import { generateUuid } from '@theia/core/lib/common/uuid';
import { Request, Response } from '@theia/core/shared/express';
import { inject, injectable } from '@theia/core/shared/inversify';
import { OK, BAD_REQUEST, METHOD_NOT_ALLOWED, NOT_FOUND, INTERNAL_SERVER_ERROR, REQUESTED_RANGE_NOT_SATISFIABLE, PARTIAL_CONTENT } from 'http-status-codes';
import URI from '@theia/core/lib/common/uri';
import { isEmpty } from '@theia/core/lib/common/objects';
import { ILogger } from '@theia/core/lib/common/logger';
import { FileUri } from '@theia/core/lib/common/file-uri';
import { DirectoryArchiver } from './directory-archiver';
import { FileDownloadData } from '../../common/download/file-download';
import { FileDownloadCache, DownloadStorageItem } from './file-download-cache';
interface PrepareDownloadOptions {
filePath: string;
downloadId: string;
remove: boolean;
root?: string;
}
@injectable()
export abstract class FileDownloadHandler {
@inject(ILogger)
protected readonly logger: ILogger;
@inject(DirectoryArchiver)
protected readonly directoryArchiver: DirectoryArchiver;
@inject(FileDownloadCache)
protected readonly fileDownloadCache: FileDownloadCache;
public abstract handle(request: Request, response: Response): Promise<void>;
/**
* Prepares the file and the link for download
*/
protected async prepareDownload(request: Request, response: Response, options: PrepareDownloadOptions): Promise<void> {
const name = path.basename(options.filePath);
try {
await fs.access(options.filePath, fs.constants.R_OK);
const stat = await fs.stat(options.filePath);
this.fileDownloadCache.addDownload(options.downloadId, { file: options.filePath, remove: options.remove, size: stat.size, root: options.root });
// do not send filePath but instead use the downloadId
const data = { name, id: options.downloadId };
response.status(OK).send(data).end();
} catch (e) {
this.handleError(response, e, INTERNAL_SERVER_ERROR);
}
}
protected async download(request: Request, response: Response, downloadInfo: DownloadStorageItem, id: string): Promise<void> {
const filePath = downloadInfo.file;
const statSize = downloadInfo.size;
// this sets the content-disposition and content-type automatically
response.attachment(filePath);
try {
await fs.access(filePath, fs.constants.R_OK);
response.setHeader('Accept-Ranges', 'bytes');
// parse range header and combine multiple ranges
const range = this.parseRangeHeader(request.headers['range'], statSize);
if (!range) {
response.setHeader('Content-Length', statSize);
this.streamDownload(OK, response, fs.createReadStream(filePath), id);
} else {
const rangeStart = range.start;
const rangeEnd = range.end;
if (rangeStart >= statSize || rangeEnd >= statSize) {
response.setHeader('Content-Range', `bytes */${statSize}`);
// Return the 416 'Requested Range Not Satisfiable'.
response.status(REQUESTED_RANGE_NOT_SATISFIABLE).end();
return;
}
response.setHeader('Content-Range', `bytes ${rangeStart}-${rangeEnd}/${statSize}`);
response.setHeader('Content-Length', rangeStart === rangeEnd ? 0 : (rangeEnd - rangeStart + 1));
response.setHeader('Cache-Control', 'no-cache');
this.streamDownload(PARTIAL_CONTENT, response, fs.createReadStream(filePath, { start: rangeStart, end: rangeEnd }), id);
}
} catch (e) {
this.fileDownloadCache.deleteDownload(id);
this.handleError(response, e, INTERNAL_SERVER_ERROR);
}
}
/**
* Streams the file and pipe it to the Response to avoid any OOM issues
*/
protected streamDownload(status: number, response: Response, stream: fs.ReadStream, id: string): void {
response.status(status);
stream.on('error', error => {
this.fileDownloadCache.deleteDownload(id);
this.handleError(response, error, INTERNAL_SERVER_ERROR);
});
response.on('error', error => {
this.fileDownloadCache.deleteDownload(id);
this.handleError(response, error, INTERNAL_SERVER_ERROR);
});
response.on('close', () => {
stream.destroy();
});
stream.pipe(response);
}
protected parseRangeHeader(range: string | string[] | undefined, statSize: number): { start: number, end: number } | undefined {
if (!range || range.length === 0 || Array.isArray(range)) {
return;
}
const index = range.indexOf('=');
if (index === -1) {
return;
}
const rangeType = range.slice(0, index);
if (rangeType !== 'bytes') {
return;
}
const [start, end] = range.slice(index + 1).split('-').map(r => parseInt(r, 10));
return {
start: isNaN(start) ? 0 : start,
end: (isNaN(end) || end > statSize - 1) ? (statSize - 1) : end
};
}
protected async archive(inputPath: string, outputPath: string = path.join(os.tmpdir(), generateUuid()), entries?: string[]): Promise<string> {
await this.directoryArchiver.archive(inputPath, outputPath, entries);
return outputPath;
}
protected async createTempDir(downloadId: string = generateUuid()): Promise<string> {
const outputPath = path.join(os.tmpdir(), downloadId);
await fs.mkdir(outputPath);
return outputPath;
}
protected async handleError(response: Response, reason: string | Error, status: number = INTERNAL_SERVER_ERROR): Promise<void> {
this.logger.error(reason);
response.status(status).send('Unable to download file.').end();
}
}
export namespace FileDownloadHandler {
export const SINGLE: symbol = Symbol('single');
export const MULTI: symbol = Symbol('multi');
export const DOWNLOAD_LINK: symbol = Symbol('download');
}
@injectable()
export class DownloadLinkHandler extends FileDownloadHandler {
async handle(request: Request, response: Response): Promise<void> {
const { method, query } = request;
if (method !== 'GET' && method !== 'HEAD') {
this.handleError(response, `Unexpected HTTP method. Expected GET got '${method}' instead.`, METHOD_NOT_ALLOWED);
return;
}
if (query === undefined || query.id === undefined || typeof query.id !== 'string') {
this.handleError(response, `Cannot access the 'id' query from the request. The query was: ${JSON.stringify(query)}.`, BAD_REQUEST);
return;
}
const cancelDownload = query.cancel;
const downloadInfo = this.fileDownloadCache.getDownload(query.id);
if (!downloadInfo) {
this.handleError(response, `Cannot find the file from the request. The query was: ${JSON.stringify(query)}.`, NOT_FOUND);
return;
}
// allow head request to determine the content length for parallel downloaders
if (method === 'HEAD') {
response.setHeader('Content-Length', downloadInfo.size);
response.status(OK).end();
return;
}
if (!cancelDownload) {
this.download(request, response, downloadInfo, query.id);
} else {
this.logger.info('Download', query.id, 'has been cancelled');
this.fileDownloadCache.deleteDownload(query.id);
}
}
}
@injectable()
export class SingleFileDownloadHandler extends FileDownloadHandler {
async handle(request: Request, response: Response): Promise<void> {
const { method, body, query } = request;
if (method !== 'GET') {
this.handleError(response, `Unexpected HTTP method. Expected GET got '${method}' instead.`, METHOD_NOT_ALLOWED);
return;
}
if (body !== undefined && !isEmpty(body)) {
this.handleError(response, `The request body must either undefined or empty when downloading a single file. The body was: ${JSON.stringify(body)}.`, BAD_REQUEST);
return;
}
if (query === undefined || query.uri === undefined || typeof query.uri !== 'string') {
this.handleError(response, `Cannot access the 'uri' query from the request. The query was: ${JSON.stringify(query)}.`, BAD_REQUEST);
return;
}
const uri = new URI(query.uri).toString(true);
const filePath = FileUri.fsPath(uri);
let stat: fs.Stats;
try {
stat = await fs.stat(filePath);
} catch {
this.handleError(response, `The file does not exist. URI: ${uri}.`, NOT_FOUND);
return;
}
try {
const downloadId = generateUuid();
const options: PrepareDownloadOptions = { filePath, downloadId, remove: false };
if (!stat.isDirectory()) {
await this.prepareDownload(request, response, options);
} else {
const outputRootPath = await this.createTempDir(downloadId);
const outputPath = path.join(outputRootPath, `${path.basename(filePath)}.tar`);
await this.archive(filePath, outputPath);
options.filePath = outputPath;
options.remove = true;
options.root = outputRootPath;
await this.prepareDownload(request, response, options);
}
} catch (e) {
this.handleError(response, e, INTERNAL_SERVER_ERROR);
}
}
}
@injectable()
export class MultiFileDownloadHandler extends FileDownloadHandler {
async handle(request: Request, response: Response): Promise<void> {
const { method, body } = request;
if (method !== 'PUT') {
this.handleError(response, `Unexpected HTTP method. Expected PUT got '${method}' instead.`, METHOD_NOT_ALLOWED);
return;
}
if (body === undefined) {
this.handleError(response, 'The request body must be defined when downloading multiple files.', BAD_REQUEST);
return;
}
if (!FileDownloadData.is(body)) {
this.handleError(response, `Unexpected body format. Cannot extract the URIs from the request body. Body was: ${JSON.stringify(body)}.`, BAD_REQUEST);
return;
}
if (body.uris.length === 0) {
this.handleError(response, `Insufficient body format. No URIs were defined by the request body. Body was: ${JSON.stringify(body)}.`, BAD_REQUEST);
return;
}
for (const uri of body.uris) {
try {
await fs.access(FileUri.fsPath(uri));
} catch {
this.handleError(response, `The file does not exist. URI: ${uri}.`, NOT_FOUND);
return;
}
}
try {
const downloadId = generateUuid();
const outputRootPath = await this.createTempDir(downloadId);
const distinctUris = Array.from(new Set(body.uris.map(uri => new URI(uri))));
const tarPaths = [];
// We should have one key in the map per FS drive.
for (const [rootUri, uris] of (await this.directoryArchiver.findCommonParents(distinctUris)).entries()) {
const rootPath = FileUri.fsPath(rootUri);
const entries = uris.map(FileUri.fsPath).map(p => path.relative(rootPath, p));
const outputPath = path.join(outputRootPath, `${path.basename(rootPath)}.tar`);
await this.archive(rootPath, outputPath, entries);
tarPaths.push(outputPath);
}
const options: PrepareDownloadOptions = { filePath: '', downloadId, remove: true, root: outputRootPath };
if (tarPaths.length === 1) {
// tslint:disable-next-line:whitespace
const [outputPath,] = tarPaths;
options.filePath = outputPath;
await this.prepareDownload(request, response, options);
} else {
// We need to tar the tars.
const outputPath = path.join(outputRootPath, `theia-archive-${Date.now()}.tar`);
options.filePath = outputPath;
await this.archive(outputRootPath, outputPath, tarPaths.map(p => path.relative(outputRootPath, p)));
await this.prepareDownload(request, response, options);
}
} catch (e) {
this.handleError(response, e, INTERNAL_SERVER_ERROR);
}
}
}

View File

@@ -0,0 +1,30 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { DirectoryArchiver } from '../directory-archiver';
import URI from '@theia/core/lib/common/uri';
export class MockDirectoryArchiver extends DirectoryArchiver {
constructor(private folders?: URI[]) {
super();
}
protected override async isDir(uri: URI): Promise<boolean> {
return !!this.folders && this.folders.map(u => u.toString()).indexOf(uri.toString()) !== -1;
}
}

View File

@@ -0,0 +1,110 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as assert from 'assert';
import { FileUri } from '@theia/core/lib/common/file-uri';
import { FileChangeCollection } from './file-change-collection';
import { FileChangeType } from '../common/files';
describe('FileChangeCollection', () => {
assertChanges({
changes: [FileChangeType.ADDED, FileChangeType.ADDED],
expected: FileChangeType.ADDED
});
assertChanges({
changes: [FileChangeType.ADDED, FileChangeType.UPDATED],
expected: FileChangeType.ADDED
});
assertChanges({
changes: [FileChangeType.ADDED, FileChangeType.DELETED],
expected: [FileChangeType.ADDED, FileChangeType.DELETED]
});
assertChanges({
changes: [FileChangeType.UPDATED, FileChangeType.ADDED],
expected: FileChangeType.UPDATED
});
assertChanges({
changes: [FileChangeType.UPDATED, FileChangeType.UPDATED],
expected: FileChangeType.UPDATED
});
assertChanges({
changes: [FileChangeType.UPDATED, FileChangeType.DELETED],
expected: FileChangeType.DELETED
});
assertChanges({
changes: [FileChangeType.DELETED, FileChangeType.ADDED],
expected: FileChangeType.UPDATED
});
assertChanges({
changes: [FileChangeType.DELETED, FileChangeType.UPDATED],
expected: FileChangeType.UPDATED
});
assertChanges({
changes: [FileChangeType.DELETED, FileChangeType.DELETED],
expected: FileChangeType.DELETED
});
assertChanges({
changes: [FileChangeType.ADDED, FileChangeType.UPDATED, FileChangeType.DELETED],
expected: [FileChangeType.ADDED, FileChangeType.DELETED]
});
assertChanges({
changes: [FileChangeType.ADDED, FileChangeType.UPDATED, FileChangeType.DELETED, FileChangeType.ADDED],
expected: [FileChangeType.ADDED]
});
assertChanges({
changes: [FileChangeType.ADDED, FileChangeType.UPDATED, FileChangeType.DELETED, FileChangeType.UPDATED],
expected: [FileChangeType.ADDED]
});
assertChanges({
changes: [FileChangeType.ADDED, FileChangeType.UPDATED, FileChangeType.DELETED, FileChangeType.DELETED],
expected: [FileChangeType.ADDED, FileChangeType.DELETED]
});
function assertChanges({ changes, expected }: {
changes: FileChangeType[],
expected: FileChangeType[] | FileChangeType
}): void {
const expectedTypes = Array.isArray(expected) ? expected : [expected];
const expectation = expectedTypes.map(type => typeAsString(type)).join(' + ');
it(`${changes.map(type => typeAsString(type)).join(' + ')} => ${expectation}`, () => {
const collection = new FileChangeCollection();
const uri = FileUri.create('/root/foo/bar.txt').toString();
for (const type of changes) {
collection.push({ uri, type });
}
const actual = collection.values().map(({ type }) => typeAsString(type)).join(' + ');
assert.deepStrictEqual(expectation, actual);
});
}
function typeAsString(type: FileChangeType): string {
return type === FileChangeType.UPDATED ? 'UPDATED' : type === FileChangeType.ADDED ? 'ADDED' : 'DELETED';
}
});

View File

@@ -0,0 +1,78 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { FileChange, FileChangeType } from '../common/filesystem-watcher-protocol';
/**
* A file change collection guarantees that only one change is reported for each URI.
*
* Changes are normalized according following rules:
* - ADDED + ADDED => ADDED
* - ADDED + UPDATED => ADDED
* - ADDED + DELETED => [ADDED, DELETED]
* - UPDATED + ADDED => UPDATED
* - UPDATED + UPDATED => UPDATED
* - UPDATED + DELETED => DELETED
* - DELETED + ADDED => UPDATED
* - DELETED + UPDATED => UPDATED
* - DELETED + DELETED => DELETED
*/
export class FileChangeCollection {
protected readonly changes = new Map<string, FileChange[]>();
push(change: FileChange): void {
const changes = this.changes.get(change.uri) || [];
this.normalize(changes, change);
this.changes.set(change.uri, changes);
}
protected normalize(changes: FileChange[], change: FileChange): void {
let currentType;
let nextType: FileChangeType | [FileChangeType, FileChangeType] = change.type;
do {
const current = changes.pop();
currentType = current && current.type;
nextType = this.reduce(currentType, nextType);
} while (!Array.isArray(nextType) && currentType !== undefined && currentType !== nextType);
const uri = change.uri;
if (Array.isArray(nextType)) {
changes.push(...nextType.map(type => ({ uri, type })));
} else {
changes.push({ uri, type: nextType });
}
}
protected reduce(current: FileChangeType | undefined, change: FileChangeType): FileChangeType | [FileChangeType, FileChangeType] {
if (current === undefined) {
return change;
}
if (current === FileChangeType.ADDED) {
if (change === FileChangeType.DELETED) {
return [FileChangeType.ADDED, FileChangeType.DELETED];
}
return FileChangeType.ADDED;
}
if (change === FileChangeType.DELETED) {
return FileChangeType.DELETED;
}
return FileChangeType.UPDATED;
}
values(): FileChange[] {
return Array.from(this.changes.values()).reduce((acc, val) => acc.concat(val), []);
}
}

View File

@@ -0,0 +1,142 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as path from 'path';
import { ContainerModule, interfaces } from '@theia/core/shared/inversify';
import { ConnectionHandler, RpcConnectionHandler, ILogger } from '@theia/core/lib/common';
import { FileSystemWatcherServer, FileSystemWatcherService } from '../common/filesystem-watcher-protocol';
import { FileSystemWatcherServerClient } from './filesystem-watcher-client';
import { ParcelFileSystemWatcherService, ParcelFileSystemWatcherServerOptions } from './parcel-watcher/parcel-filesystem-service';
import { NodeFileUploadService } from './upload/node-file-upload-service';
import { ParcelWatcherOptions } from './parcel-watcher/parcel-options';
import { DiskFileSystemProvider } from './disk-file-system-provider';
import {
remoteFileSystemPath, RemoteFileSystemServer, RemoteFileSystemClient, FileSystemProviderServer, RemoteFileSystemProxyFactory
} from '../common/remote-file-system-provider';
import { FileSystemProvider } from '../common/files';
import { EncodingService } from '@theia/core/lib/common/encoding-service';
import { BackendApplicationContribution, IPCConnectionProvider } from '@theia/core/lib/node';
import { RpcProxyFactory, ConnectionErrorHandler } from '@theia/core';
import { FileSystemWatcherServiceDispatcher } from './filesystem-watcher-dispatcher';
import { bindFileSystemPreferences } from '../common';
export const WATCHER_SINGLE_THREADED = process.argv.includes('--no-cluster');
export const WATCHER_VERBOSE = process.argv.includes('--watcher-verbose');
export const FileSystemWatcherServiceProcessOptions = Symbol('FileSystemWatcherServiceProcessOptions');
/**
* Options to control the way the `ParcelFileSystemWatcherService` process is spawned.
*/
export interface FileSystemWatcherServiceProcessOptions {
/**
* Path to the script that will run the `ParcelFileSystemWatcherService` in a new process.
*/
entryPoint: string;
}
export default new ContainerModule(bind => {
bind(EncodingService).toSelf().inSingletonScope();
bindFileSystemWatcherServer(bind);
bind(DiskFileSystemProvider).toSelf();
bind(FileSystemProvider).toService(DiskFileSystemProvider);
bind(FileSystemProviderServer).toSelf();
bind(RemoteFileSystemServer).toService(FileSystemProviderServer);
bind(ConnectionHandler).toDynamicValue(ctx =>
new RpcConnectionHandler<RemoteFileSystemClient>(remoteFileSystemPath, client => {
const server = ctx.container.get<RemoteFileSystemServer>(RemoteFileSystemServer);
server.setClient(client);
client.onDidCloseConnection(() => server.dispose());
return server;
}, RemoteFileSystemProxyFactory)
).inSingletonScope();
bind(NodeFileUploadService).toSelf().inSingletonScope();
bind(BackendApplicationContribution).toService(NodeFileUploadService);
bindFileSystemPreferences(bind);
});
export function bindFileSystemWatcherServer(bind: interfaces.Bind): void {
bind<ParcelWatcherOptions>(ParcelWatcherOptions).toConstantValue({});
bind(FileSystemWatcherServiceDispatcher).toSelf().inSingletonScope();
bind(FileSystemWatcherServerClient).toSelf();
bind(FileSystemWatcherServer).toService(FileSystemWatcherServerClient);
bind<FileSystemWatcherServiceProcessOptions>(FileSystemWatcherServiceProcessOptions).toDynamicValue(ctx => ({
entryPoint: path.join(__dirname, 'parcel-watcher'),
})).inSingletonScope();
bind<ParcelFileSystemWatcherServerOptions>(ParcelFileSystemWatcherServerOptions).toDynamicValue(ctx => {
const logger = ctx.container.get<ILogger>(ILogger);
const watcherOptions = ctx.container.get<ParcelWatcherOptions>(ParcelWatcherOptions);
return {
parcelOptions: watcherOptions,
verbose: WATCHER_VERBOSE,
info: (message, ...args) => logger.info(message, ...args),
error: (message, ...args) => logger.error(message, ...args),
};
}).inSingletonScope();
bind<FileSystemWatcherService>(FileSystemWatcherService).toDynamicValue(
ctx => WATCHER_SINGLE_THREADED
? createParcelFileSystemWatcherService(ctx)
: spawnParcelFileSystemWatcherServiceProcess(ctx)
).inSingletonScope();
}
/**
* Run the watch server in the current process.
*/
export function createParcelFileSystemWatcherService(ctx: interfaces.Context): FileSystemWatcherService {
const options = ctx.container.get<ParcelFileSystemWatcherServerOptions>(ParcelFileSystemWatcherServerOptions);
const dispatcher = ctx.container.get<FileSystemWatcherServiceDispatcher>(FileSystemWatcherServiceDispatcher);
const server = new ParcelFileSystemWatcherService(options);
server.setClient(dispatcher);
return server;
}
/**
* Run the watch server in a child process.
* Return a proxy forwarding calls to the child process.
*/
export function spawnParcelFileSystemWatcherServiceProcess(ctx: interfaces.Context): FileSystemWatcherService {
const options = ctx.container.get<FileSystemWatcherServiceProcessOptions>(FileSystemWatcherServiceProcessOptions);
const dispatcher = ctx.container.get<FileSystemWatcherServiceDispatcher>(FileSystemWatcherServiceDispatcher);
const serverName = 'parcel-watcher';
const logger = ctx.container.get<ILogger>(ILogger);
const watcherOptions = ctx.container.get<ParcelWatcherOptions>(ParcelWatcherOptions);
const ipcConnectionProvider = ctx.container.get<IPCConnectionProvider>(IPCConnectionProvider);
const proxyFactory = new RpcProxyFactory<FileSystemWatcherService>();
const serverProxy = proxyFactory.createProxy();
// We need to call `.setClient` before listening, else the JSON-RPC calls won't go through.
serverProxy.setClient(dispatcher);
const args: string[] = [
`--watchOptions=${JSON.stringify(watcherOptions)}`
];
if (WATCHER_VERBOSE) {
args.push('--verbose');
}
ipcConnectionProvider.listen({
serverName,
entryPoint: options.entryPoint,
errorHandler: new ConnectionErrorHandler({
serverName,
logger,
}),
env: process.env,
args,
}, connection => proxyFactory.listen(connection));
return serverProxy;
}

View File

@@ -0,0 +1,70 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject } from '@theia/core/shared/inversify';
import { FileSystemWatcherServer, WatchOptions, FileSystemWatcherClient, FileSystemWatcherService } from '../common/filesystem-watcher-protocol';
import { FileSystemWatcherServiceDispatcher } from './filesystem-watcher-dispatcher';
/**
* Wraps the watcher singleton service for each frontend.
*/
@injectable()
export class FileSystemWatcherServerClient implements FileSystemWatcherServer {
protected static clientIdSequence = 0;
/**
* Track allocated watcherIds to de-allocate on disposal.
*/
protected watcherIds = new Set<number>();
/**
* @todo make this number precisely map to one specific frontend.
*/
protected readonly clientId = FileSystemWatcherServerClient.clientIdSequence++;
@inject(FileSystemWatcherServiceDispatcher)
protected readonly watcherDispatcher: FileSystemWatcherServiceDispatcher;
@inject(FileSystemWatcherService)
protected readonly watcherService: FileSystemWatcherService;
async watchFileChanges(uri: string, options?: WatchOptions): Promise<number> {
const watcherId = await this.watcherService.watchFileChanges(this.clientId, uri, options);
this.watcherIds.add(watcherId);
return watcherId;
}
async unwatchFileChanges(watcherId: number): Promise<void> {
this.watcherIds.delete(watcherId);
return this.watcherService.unwatchFileChanges(watcherId);
}
setClient(client: FileSystemWatcherClient | undefined): void {
if (client !== undefined) {
this.watcherDispatcher.registerClient(this.clientId, client);
} else {
this.watcherDispatcher.unregisterClient(this.clientId);
}
}
dispose(): void {
this.setClient(undefined);
for (const watcherId of this.watcherIds) {
this.unwatchFileChanges(watcherId);
}
}
}

View File

@@ -0,0 +1,82 @@
// *****************************************************************************
// Copyright (C) 2020 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable } from '@theia/core/shared/inversify';
import {
FileSystemWatcherClient, FileSystemWatcherServiceClient,
DidFilesChangedParams, FileSystemWatcherErrorParams
} from '../common/filesystem-watcher-protocol';
/**
* This component routes watch events to the right clients.
*/
@injectable()
export class FileSystemWatcherServiceDispatcher implements FileSystemWatcherServiceClient {
/**
* Mapping of `clientId` to actual clients.
*/
protected readonly clients = new Map<number, FileSystemWatcherClient>();
onDidFilesChanged(event: DidFilesChangedParams): void {
for (const client of this.iterRegisteredClients(event.clients)) {
client.onDidFilesChanged(event);
}
}
onError(event: FileSystemWatcherErrorParams): void {
for (const client of this.iterRegisteredClients(event.clients)) {
client.onError();
}
}
/**
* Listen for events targeted at `clientId`.
*/
registerClient(clientId: number, client: FileSystemWatcherClient): void {
if (this.clients.has(clientId)) {
console.warn(`FileSystemWatcherServer2Dispatcher: a client was already registered! clientId=${clientId}`);
}
this.clients.set(clientId, client);
}
unregisterClient(clientId: number): void {
if (!this.clients.has(clientId)) {
console.warn(`FileSystemWatcherServer2Dispatcher: tried to remove unknown client! clientId=${clientId}`);
}
this.clients.delete(clientId);
}
/**
* Only yield registered clients for the given `clientIds`.
*
* If clientIds is empty, will return all clients.
*/
protected *iterRegisteredClients(clientIds?: number[]): Iterable<FileSystemWatcherClient> {
if (!Array.isArray(clientIds) || clientIds.length === 0) {
// If we receive an event targeted to "no client",
// interpret that as notifying all clients:
yield* this.clients.values();
} else {
for (const clientId of clientIds) {
const client = this.clients.get(clientId);
if (client !== undefined) {
yield client;
}
}
}
}
}

View File

@@ -0,0 +1,45 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as yargs from '@theia/core/shared/yargs';
import { RpcProxyFactory } from '@theia/core';
import { FileSystemWatcherServiceClient } from '../../common/filesystem-watcher-protocol';
import { ParcelFileSystemWatcherService } from './parcel-filesystem-service';
import { IPCEntryPoint } from '@theia/core/lib/node/messaging/ipc-protocol';
/* eslint-disable @typescript-eslint/no-explicit-any */
const options: {
verbose: boolean
} = yargs
.option('verbose', {
default: false,
alias: 'v',
type: 'boolean'
})
.option('watchOptions', {
alias: 'o',
type: 'string',
coerce: JSON.parse
})
.argv as any;
export default <IPCEntryPoint>(connection => {
const server = new ParcelFileSystemWatcherService(options);
const factory = new RpcProxyFactory<FileSystemWatcherServiceClient>(server);
server.setClient(factory.createProxy());
factory.listen(connection);
});

View File

@@ -0,0 +1,482 @@
// *****************************************************************************
// Copyright (C) 2017-2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import path = require('path');
import { promises as fsp } from 'fs';
import { Minimatch } from 'minimatch';
import { FileUri } from '@theia/core/lib/common/file-uri';
import {
FileChangeType, FileSystemWatcherService, FileSystemWatcherServiceClient, WatchOptions
} from '../../common/filesystem-watcher-protocol';
import { FileChangeCollection } from '../file-change-collection';
import { Deferred, timeout } from '@theia/core/lib/common/promise-util';
import { subscribe, Options, AsyncSubscription, Event } from '@theia/core/shared/@parcel/watcher';
import { isOSX, isWindows } from '@theia/core';
export interface ParcelWatcherOptions {
ignored: Minimatch[]
}
export const ParcelFileSystemWatcherServerOptions = Symbol('ParcelFileSystemWatcherServerOptions');
export interface ParcelFileSystemWatcherServerOptions {
verbose: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
info: (message: string, ...args: any[]) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: (message: string, ...args: any[]) => void;
parcelOptions: Options;
}
/**
* This is a flag value passed around upon disposal.
*/
export const WatcherDisposal = Symbol('WatcherDisposal');
/**
* Because URIs can be watched by different clients, we'll track
* how many are listening for a given URI.
*
* This component wraps the whole start/stop process given some
* reference count.
*
* Once there are no more references the handle
* will wait for some time before destroying its resources.
*/
export class ParcelWatcher {
protected static debugIdSequence = 0;
protected disposed = false;
/**
* Used for debugging to keep track of the watchers.
*/
protected debugId = ParcelWatcher.debugIdSequence++;
/**
* When this field is set, it means the watcher instance was successfully started.
*/
protected watcher: AsyncSubscription | undefined;
/**
* When the ref count hits zero, we schedule this watch handle to be disposed.
*/
protected deferredDisposalTimer: NodeJS.Timeout | undefined;
/**
* This deferred only rejects with `WatcherDisposal` and never resolves.
*/
protected readonly deferredDisposalDeferred = new Deferred<never>();
/**
* We count each reference made to this watcher, per client.
*
* We do this to know where to send events via the network.
*
* An entry should be removed when its value hits zero.
*/
protected readonly refsPerClient = new Map<number, { value: number }>();
/**
* Ensures that events are processed in the order they are emitted,
* despite being processed async.
*/
protected parcelEventProcessingQueue: Promise<void> = Promise.resolve();
/**
* Resolves once this handle disposed itself and its resources. Never throws.
*/
readonly whenDisposed: Promise<void> = this.deferredDisposalDeferred.promise.catch(() => undefined);
/**
* Promise that resolves when the watcher is fully started, or got disposed.
*
* Will reject if an error occurred while starting.
*
* @returns `true` if successfully started, `false` if disposed early.
*/
readonly whenStarted: Promise<boolean>;
// copied from https://github.com/microsoft/vscode/blob/e3a5acfb517a443235981655413d566533107e92/src/vs/platform/files/node/watcher/parcel/parcelWatcher.ts#L158
private static readonly PARCEL_WATCHER_BACKEND = isWindows ? 'windows' : isOSX ? 'fs-events' : 'inotify';
constructor(
/** Initial reference to this handle. */
initialClientId: number,
/** Filesystem path to be watched. */
readonly fsPath: string,
/** Watcher-specific options */
readonly watcherOptions: ParcelWatcherOptions,
/** Logging and parcel watcher options */
protected readonly parcelFileSystemWatchServerOptions: ParcelFileSystemWatcherServerOptions,
/** The client to forward events to. */
protected readonly fileSystemWatcherClient: FileSystemWatcherServiceClient,
/** Amount of time in ms to wait once this handle is not referenced anymore. */
protected readonly deferredDisposalTimeout = 10_000,
) {
this.refsPerClient.set(initialClientId, { value: 1 });
this.whenStarted = this.start().then(() => true, error => {
if (error === WatcherDisposal) {
return false;
}
this._dispose();
this.fireError();
throw error;
});
this.debug('NEW', `initialClientId=${initialClientId}`);
}
addRef(clientId: number): void {
let refs = this.refsPerClient.get(clientId);
if (typeof refs === 'undefined') {
this.refsPerClient.set(clientId, refs = { value: 1 });
} else {
refs.value += 1;
}
const totalRefs = this.getTotalReferences();
// If it was zero before, 1 means we were revived:
const revived = totalRefs === 1;
if (revived) {
this.onRefsRevive();
}
this.debug('REF++', `clientId=${clientId}, clientRefs=${refs.value}, totalRefs=${totalRefs}. revived=${revived}`);
}
removeRef(clientId: number): void {
const refs = this.refsPerClient.get(clientId);
if (typeof refs === 'undefined') {
this.info('WARN REF--', `removed one too many reference: clientId=${clientId}`);
return;
}
refs.value -= 1;
// We must remove the key from `this.clientReferences` because
// we list active clients by reading the keys of this map.
if (refs.value === 0) {
this.refsPerClient.delete(clientId);
}
const totalRefs = this.getTotalReferences();
const dead = totalRefs === 0;
if (dead) {
this.onRefsReachZero();
}
this.debug('REF--', `clientId=${clientId}, clientRefs=${refs.value}, totalRefs=${totalRefs}, dead=${dead}`);
}
/**
* All clients with at least one active reference.
*/
getClientIds(): number[] {
return Array.from(this.refsPerClient.keys());
}
/**
* Add the references for each client together.
*/
getTotalReferences(): number {
let total = 0;
for (const refs of this.refsPerClient.values()) {
total += refs.value;
}
return total;
}
/**
* Returns true if at least one client listens to this handle.
*/
isInUse(): boolean {
return this.refsPerClient.size > 0;
}
/**
* @throws with {@link WatcherDisposal} if this instance is disposed.
*/
protected assertNotDisposed(): void {
if (this.disposed) {
throw WatcherDisposal;
}
}
/**
* When starting a watcher, we'll first check and wait for the path to exists
* before running a parcel watcher.
*/
protected async start(): Promise<void> {
while (await fsp.stat(this.fsPath).then(() => false, () => true)) {
await timeout(500);
this.assertNotDisposed();
}
this.assertNotDisposed();
const watcher = await this.createWatcher();
this.assertNotDisposed();
this.debug('STARTED', `disposed=${this.disposed}`);
// The watcher could be disposed while it was starting, make sure to check for this:
if (this.disposed) {
await this.stopWatcher(watcher);
throw WatcherDisposal;
}
this.watcher = watcher;
}
/**
* Given a started parcel watcher instance, gracefully shut it down.
*/
protected async stopWatcher(watcher: AsyncSubscription): Promise<void> {
await watcher.unsubscribe()
.then(() => 'success=true', error => error)
.then(status => this.debug('STOPPED', status));
}
protected async createWatcher(): Promise<AsyncSubscription> {
let fsPath = await fsp.realpath(this.fsPath);
if ((await fsp.stat(fsPath)).isFile()) {
fsPath = path.dirname(fsPath);
}
return subscribe(fsPath, (err, events) => {
if (err) {
if (err.message && err.message.includes('File system must be re-scanned')) {
console.log(`FS Events were dropped on watcher ${fsp}`);
} else {
console.error(`Watcher service error on "${fsPath}":`, err);
this._dispose();
this.fireError();
return;
}
}
if (events) {
this.handleWatcherEvents(events);
}
}, {
backend: ParcelWatcher.PARCEL_WATCHER_BACKEND,
...this.parcelFileSystemWatchServerOptions.parcelOptions
});
}
protected handleWatcherEvents(events: Event[]): void {
// Only process events if someone is listening.
if (this.isInUse()) {
// This callback is async, but parcel won't wait for it to finish before firing the next one.
// We will use a lock/queue to make sure everything is processed in the order it arrives.
this.parcelEventProcessingQueue = this.parcelEventProcessingQueue.then(async () => {
const fileChangeCollection = new FileChangeCollection();
for (const event of events) {
const filePath = event.path;
if (event.type === 'create') {
this.pushFileChange(fileChangeCollection, FileChangeType.ADDED, filePath);
} else if (event.type === 'delete') {
this.pushFileChange(fileChangeCollection, FileChangeType.DELETED, filePath);
} else if (event.type === 'update') {
this.pushFileChange(fileChangeCollection, FileChangeType.UPDATED, filePath);
}
}
const changes = fileChangeCollection.values();
// If all changes are part of the ignored files, the collection will be empty.
if (changes.length > 0) {
this.fileSystemWatcherClient.onDidFilesChanged({
clients: this.getClientIds(),
changes,
});
}
}, console.error);
}
}
protected async resolveEventPath(directory: string, file: string): Promise<string> {
// parcel already resolves symlinks, the paths should be clean already:
return path.resolve(directory, file);
}
protected pushFileChange(changes: FileChangeCollection, type: FileChangeType, filePath: string): void {
if (!this.isIgnored(filePath)) {
const uri = FileUri.create(filePath).toString();
changes.push({ type, uri });
}
}
protected fireError(): void {
this.fileSystemWatcherClient.onError({
clients: this.getClientIds(),
uri: this.fsPath,
});
}
/**
* When references hit zero, we'll schedule disposal for a bit later.
*
* This allows new references to reuse this watcher instead of creating a new one.
*
* e.g. A frontend disconnects for a few milliseconds before reconnecting again.
*/
protected onRefsReachZero(): void {
this.deferredDisposalTimer = setTimeout(() => this._dispose(), this.deferredDisposalTimeout);
}
/**
* If we get new references after hitting zero, let's unschedule our disposal and keep watching.
*/
protected onRefsRevive(): void {
if (this.deferredDisposalTimer) {
clearTimeout(this.deferredDisposalTimer);
this.deferredDisposalTimer = undefined;
}
}
protected isIgnored(filePath: string): boolean {
return this.watcherOptions.ignored.length > 0
&& this.watcherOptions.ignored.some(m => m.match(filePath));
}
/**
* Internal disposal mechanism.
*/
protected async _dispose(): Promise<void> {
if (!this.disposed) {
this.disposed = true;
this.deferredDisposalDeferred.reject(WatcherDisposal);
if (this.watcher) {
this.stopWatcher(this.watcher);
this.watcher = undefined;
}
this.debug('DISPOSED');
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected info(prefix: string, ...params: any[]): void {
this.parcelFileSystemWatchServerOptions.info(`${prefix} ParcelWatcher(${this.debugId} at "${this.fsPath}"):`, ...params);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected debug(prefix: string, ...params: any[]): void {
if (this.parcelFileSystemWatchServerOptions.verbose) {
this.info(prefix, ...params);
}
}
}
/**
* Each time a client makes a watchRequest, we generate a unique watcherId for it.
*
* This watcherId will map to this handle type which keeps track of the clientId that made the request.
*/
export interface PacelWatcherHandle {
clientId: number;
watcher: ParcelWatcher;
}
export class ParcelFileSystemWatcherService implements FileSystemWatcherService {
protected client: FileSystemWatcherServiceClient | undefined;
protected watcherId = 0;
protected readonly watchers = new Map<string, ParcelWatcher>();
protected readonly watcherHandles = new Map<number, PacelWatcherHandle>();
protected readonly options: ParcelFileSystemWatcherServerOptions;
/**
* `this.client` is undefined until someone sets it.
*/
protected readonly maybeClient: FileSystemWatcherServiceClient = {
onDidFilesChanged: event => this.client?.onDidFilesChanged(event),
onError: event => this.client?.onError(event),
};
constructor(options?: Partial<ParcelFileSystemWatcherServerOptions>) {
this.options = {
parcelOptions: {},
verbose: false,
info: (message, ...args) => console.info(message, ...args),
error: (message, ...args) => console.error(message, ...args),
...options
};
}
setClient(client: FileSystemWatcherServiceClient | undefined): void {
this.client = client;
}
/**
* A specific client requests us to watch a given `uri` according to some `options`.
*
* We internally re-use all the same `(uri, options)` pairs.
*/
async watchFileChanges(clientId: number, uri: string, options?: WatchOptions): Promise<number> {
const resolvedOptions = this.resolveWatchOptions(options);
const watcherKey = this.getWatcherKey(uri, resolvedOptions);
let watcher = this.watchers.get(watcherKey);
if (watcher === undefined) {
const fsPath = FileUri.fsPath(uri);
watcher = this.createWatcher(clientId, fsPath, resolvedOptions);
watcher.whenDisposed.then(() => this.watchers.delete(watcherKey));
this.watchers.set(watcherKey, watcher);
} else {
watcher.addRef(clientId);
}
const watcherId = this.watcherId++;
this.watcherHandles.set(watcherId, { clientId, watcher });
watcher.whenDisposed.then(() => this.watcherHandles.delete(watcherId));
return watcherId;
}
protected createWatcher(clientId: number, fsPath: string, options: WatchOptions): ParcelWatcher {
const watcherOptions: ParcelWatcherOptions = {
ignored: options.ignored
.map(pattern => new Minimatch(pattern, { dot: true })),
};
return new ParcelWatcher(clientId, fsPath, watcherOptions, this.options, this.maybeClient);
}
async unwatchFileChanges(watcherId: number): Promise<void> {
const handle = this.watcherHandles.get(watcherId);
if (handle === undefined) {
console.warn(`tried to de-allocate a disposed watcher: watcherId=${watcherId}`);
} else {
this.watcherHandles.delete(watcherId);
handle.watcher.removeRef(handle.clientId);
}
}
/**
* Given some `URI` and some `WatchOptions`, generate a unique key.
*/
protected getWatcherKey(uri: string, options: WatchOptions): string {
return [
uri,
options.ignored.slice(0).sort().join() // use a **sorted copy** of `ignored` as part of the key
].join();
}
/**
* Return fully qualified options.
*/
protected resolveWatchOptions(options?: WatchOptions): WatchOptions {
return {
ignored: [],
...options,
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected debug(message: string, ...params: any[]): void {
if (this.options.verbose) {
this.options.info(message, ...params);
}
}
dispose(): void {
// Singletons shouldn't be disposed...
}
}

View File

@@ -0,0 +1,175 @@
// *****************************************************************************
// Copyright (C) 2018 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as temp from 'temp';
import * as chai from 'chai';
import * as cp from 'child_process';
import * as fs from '@theia/core/shared/fs-extra';
import * as assert from 'assert';
import URI from '@theia/core/lib/common/uri';
import { FileUri } from '@theia/core/lib/node';
import { ParcelFileSystemWatcherService } from './parcel-filesystem-service';
import { DidFilesChangedParams, FileChange, FileChangeType } from '../../common/filesystem-watcher-protocol';
const expect = chai.expect;
const track = temp.track();
describe('parcel-filesystem-watcher', function (): void {
let root: URI;
let watcherService: ParcelFileSystemWatcherService;
let watcherId: number;
this.timeout(100000);
beforeEach(async () => {
let tempPath = temp.mkdirSync('node-fs-root');
// Sometimes tempPath will use some Windows 8.3 short name in its path. This is a problem
// since parcel always returns paths with long names. We need to convert here.
// See: https://stackoverflow.com/a/34473971/7983255
if (process.platform === 'win32') {
tempPath = cp.execSync(`powershell "(Get-Item -LiteralPath '${tempPath}').FullName"`, {
encoding: 'utf8',
}).trim();
}
root = FileUri.create(fs.realpathSync(tempPath));
watcherService = createParcelFileSystemWatcherService();
watcherId = await watcherService.watchFileChanges(0, root.toString());
await sleep(200);
});
afterEach(async () => {
track.cleanupSync();
watcherService.dispose();
});
it('Should receive file changes events from in the workspace by default.', async function (): Promise<void> {
const actualUris = new Set<string>();
const watcherClient = {
onDidFilesChanged(event: DidFilesChangedParams): void {
event.changes.forEach(c => actualUris.add(c.uri.toString()));
},
onError(): void {
}
};
watcherService.setClient(watcherClient);
const expectedUris = [
root.resolve('foo').toString(),
root.withPath(root.path.join('foo', 'bar')).toString(),
root.withPath(root.path.join('foo', 'bar', 'baz.txt')).toString()
];
fs.mkdirSync(FileUri.fsPath(root.resolve('foo')));
expect(fs.statSync(FileUri.fsPath(root.resolve('foo'))).isDirectory()).to.be.true;
await sleep(200);
fs.mkdirSync(FileUri.fsPath(root.resolve('foo').resolve('bar')));
expect(fs.statSync(FileUri.fsPath(root.resolve('foo').resolve('bar'))).isDirectory()).to.be.true;
await sleep(200);
fs.writeFileSync(FileUri.fsPath(root.resolve('foo').resolve('bar').resolve('baz.txt')), 'baz');
expect(fs.readFileSync(FileUri.fsPath(root.resolve('foo').resolve('bar').resolve('baz.txt')), 'utf8')).to.be.equal('baz');
await sleep(200);
assert.deepStrictEqual([...actualUris], expectedUris);
});
it('Should not receive file changes events from in the workspace by default if unwatched', async function (): Promise<void> {
const actualUris = new Set<string>();
const watcherClient = {
onDidFilesChanged(event: DidFilesChangedParams): void {
event.changes.forEach(c => actualUris.add(c.uri.toString()));
},
onError(): void {
}
};
watcherService.setClient(watcherClient);
/* Unwatch root */
await watcherService.unwatchFileChanges(watcherId);
fs.mkdirSync(FileUri.fsPath(root.resolve('foo')));
expect(fs.statSync(FileUri.fsPath(root.resolve('foo'))).isDirectory()).to.be.true;
await sleep(200);
fs.mkdirSync(FileUri.fsPath(root.resolve('foo').resolve('bar')));
expect(fs.statSync(FileUri.fsPath(root.resolve('foo').resolve('bar'))).isDirectory()).to.be.true;
await sleep(200);
fs.writeFileSync(FileUri.fsPath(root.resolve('foo').resolve('bar').resolve('baz.txt')), 'baz');
expect(fs.readFileSync(FileUri.fsPath(root.resolve('foo').resolve('bar').resolve('baz.txt')), 'utf8')).to.be.equal('baz');
await sleep(200);
assert.deepStrictEqual(actualUris.size, 0);
});
it('Renaming should emit a DELETED and ADDED event', async function (): Promise<void> {
const file_txt = root.resolve('file.txt');
const FILE_txt = root.resolve('FILE.txt');
const changes: FileChange[] = [];
watcherService.setClient({
onDidFilesChanged: event => event.changes.forEach(change => changes.push(change)),
onError: console.error
});
await fs.promises.writeFile(
FileUri.fsPath(file_txt),
'random content\n'
);
await sleep(200);
await fs.promises.rename(
FileUri.fsPath(file_txt),
FileUri.fsPath(FILE_txt)
);
await sleep(200);
// The order of DELETED and ADDED is not deterministic
try {
expect(changes).deep.eq([
// initial file creation change event:
{ type: FileChangeType.ADDED, uri: file_txt.toString() },
// rename change events:
{ type: FileChangeType.DELETED, uri: file_txt.toString() },
{ type: FileChangeType.ADDED, uri: FILE_txt.toString() },
]);
} catch {
expect(changes).deep.eq([
// initial file creation change event:
{ type: FileChangeType.ADDED, uri: file_txt.toString() },
// rename change events:
{ type: FileChangeType.ADDED, uri: FILE_txt.toString() },
{ type: FileChangeType.DELETED, uri: file_txt.toString() },
]);
}
});
function createParcelFileSystemWatcherService(): ParcelFileSystemWatcherService {
return new ParcelFileSystemWatcherService({
verbose: true
});
}
function sleep(time: number): Promise<unknown> {
return new Promise(resolve => setTimeout(resolve, time));
}
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
process.on('unhandledRejection', (reason: any) => {
console.error('Unhandled promise rejection: ' + reason);
});

View File

@@ -0,0 +1,23 @@
// *****************************************************************************
// Copyright (C) 2017-2020 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { Options } from '@theia/core/shared/@parcel/watcher';
/**
* Inversify service identifier allowing extensions to override options passed to parcel by the file watcher.
*/
export const ParcelWatcherOptions = Symbol('ParcelWatcherOptions');
export type ParcelWatcherOptions = Options;

View File

@@ -0,0 +1,87 @@
// *****************************************************************************
// Copyright (C) 2019 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import multer = require('multer');
import path = require('path');
import os = require('os');
import express = require('@theia/core/shared/express');
import fs = require('@theia/core/shared/fs-extra');
import { BackendApplicationContribution, FileUri } from '@theia/core/lib/node';
import { injectable } from '@theia/core/shared/inversify';
import { HTTP_FILE_UPLOAD_PATH } from '../../common/file-upload';
@injectable()
export class NodeFileUploadService implements BackendApplicationContribution {
private static readonly UPLOAD_DIR = 'theia_upload';
async configure(app: express.Application): Promise<void> {
const [dest, http_path] = await Promise.all([
this.getTemporaryUploadDest(),
this.getHttpFileUploadPath()
]);
console.debug(`HTTP file upload URL path: ${http_path}`);
console.debug(`Backend file upload cache path: ${dest}`);
app.post(
http_path,
// `multer` handles `multipart/form-data` containing our file to upload.
multer({ dest }).single('file'),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(request: any, response: any, next: any) => this.handleFileUpload(request, response)
);
}
/**
* @returns URL path on which to accept file uploads.
*/
protected async getHttpFileUploadPath(): Promise<string> {
return HTTP_FILE_UPLOAD_PATH;
}
/**
* @returns Path to a folder where to temporarily store uploads.
*/
protected async getTemporaryUploadDest(): Promise<string> {
return path.join(os.tmpdir(), NodeFileUploadService.UPLOAD_DIR);
}
protected async handleFileUpload(request: express.Request, response: express.Response): Promise<void> {
const fields = request.body;
if (!request.file || typeof fields !== 'object' || typeof fields.uri !== 'string') {
response.sendStatus(400); // bad request
return;
}
try {
const target = FileUri.fsPath(fields.uri);
if (!fields.leaveInTemp) {
await fs.move(request.file.path, target, { overwrite: true });
} else {
// leave the file where it is, just rename it to its original name
fs.rename(request.file.path, request.file.path.replace(request.file.filename, request.file.originalname));
}
response.status(200).send(target); // ok
} catch (error) {
console.error(error);
if (error.message) {
// internal server error with error message as response
response.status(500).send(error.message);
} else {
// default internal server error
response.sendStatus(500);
}
}
}
}

View File

@@ -0,0 +1,77 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
/* eslint-disable no-var */
type WebKitEntriesCallback = ((entries: WebKitEntry[]) => void) | { handleEvent(entries: WebKitEntry[]): void; };
type WebKitErrorCallback = ((err: DOMError) => void) | { handleEvent(err: DOMError): void; };
type WebKitFileCallback = ((file: File) => void) | { handleEvent(file: File): void; };
interface WebKitDirectoryEntry extends WebKitEntry {
createReader(): WebKitDirectoryReader;
}
declare var WebKitDirectoryEntry: {
prototype: WebKitDirectoryEntry;
new(): WebKitDirectoryEntry;
};
interface WebKitDirectoryReader {
readEntries(successCallback: WebKitEntriesCallback, errorCallback?: WebKitErrorCallback): void;
}
declare var WebKitDirectoryReader: {
prototype: WebKitDirectoryReader;
new(): WebKitDirectoryReader;
};
interface WebKitEntry {
readonly filesystem: WebKitFileSystem;
readonly fullPath: string;
readonly isDirectory: boolean;
readonly isFile: boolean;
readonly name: string;
}
declare var WebKitEntry: {
prototype: WebKitEntry;
new(): WebKitEntry;
};
interface WebKitFileEntry extends WebKitEntry {
file(successCallback: WebKitFileCallback, errorCallback?: WebKitErrorCallback): void;
}
declare var WebKitFileEntry: {
prototype: WebKitFileEntry;
new(): WebKitFileEntry;
};
interface WebKitFileSystem {
readonly name: string;
readonly root: WebKitDirectoryEntry;
}
declare var WebKitFileSystem: {
prototype: WebKitFileSystem;
new(): WebKitFileSystem;
};
declare interface DataTransferItem {
webkitGetAsEntry(): WebKitEntry | null;
}

View File

@@ -0,0 +1,21 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export = mv;
declare module mv { }
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type MvOptions = { mkdirp?: boolean, clobber?: boolean, limit?: number };
declare function mv(sourcePath: string, targetPath: string, options?: MvOptions, cb?: (error: NodeJS.ErrnoException) => void): void;

View File

@@ -0,0 +1,20 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export = trash;
declare module trash { }
declare function trash(paths: Iterable<string>): Promise<void>;

View File

@@ -0,0 +1,24 @@
{
"extends": "../../configs/base.tsconfig",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib",
"paths": {
"mv": [
"./src/typings/mv"
],
"trash": [
"./src/typings/trash"
]
}
},
"include": [
"src"
],
"references": [
{
"path": "../core"
}
]
}