diff --git a/tree-view-sample/package-lock.json b/tree-view-sample/package-lock.json index d5d1d3c2..616908b1 100644 --- a/tree-view-sample/package-lock.json +++ b/tree-view-sample/package-lock.json @@ -296,8 +296,7 @@ }, "duplexer": { "version": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz", - "integrity": "sha1-rOb/gIwc5mtX0ev5eXessCM0z8E=", - "dev": true + "integrity": "sha1-rOb/gIwc5mtX0ev5eXessCM0z8E=" }, "duplexer2": { "version": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.0.2.tgz", @@ -552,6 +551,37 @@ } } }, + "ftp-response-parser": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ftp-response-parser/-/ftp-response-parser-1.0.1.tgz", + "integrity": "sha1-O50z+O3V+45HALj3eMRi5bFYH4k=", + "requires": { + "readable-stream": "1.1.14" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + }, + "readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", + "requires": { + "core-util-is": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "inherits": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "isarray": "0.0.1", + "string_decoder": "0.10.31" + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" + } + } + }, "generate-function": { "version": "https://registry.npmjs.org/generate-function/-/generate-function-2.0.0.tgz", "integrity": "sha1-aFj+fAlpt9TpCTM3ZHrHn2DfvnQ=", @@ -1205,6 +1235,43 @@ "dev": true, "optional": true }, + "jsftp": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/jsftp/-/jsftp-2.1.3.tgz", + "integrity": "sha512-r79EVB8jaNAZbq8hvanL8e8JGu2ZNr2bXdHC4ZdQhRImpSPpnWwm5DYVzQ5QxJmtGtKhNNuvqGgbNaFl604fEQ==", + "requires": { + "debug": "3.1.0", + "ftp-response-parser": "1.0.1", + "once": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "parse-listing": "1.1.3", + "stream-combiner": "0.2.2", + "unorm": "1.4.1" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "stream-combiner": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.2.2.tgz", + "integrity": "sha1-rsjLrBd7Vrb0+kec7YwZEs7lKFg=", + "requires": { + "duplexer": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz", + "through": "https://registry.npmjs.org/through/-/through-2.3.8.tgz" + } + } + } + }, "json-schema": { "version": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", @@ -1590,7 +1657,6 @@ "once": { "version": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, "requires": { "wrappy": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" } @@ -1630,6 +1696,11 @@ } } }, + "parse-listing": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/parse-listing/-/parse-listing-1.1.3.tgz", + "integrity": "sha1-qlRvV/3BKc+/mUXNS3V7FLBhgt0=" + }, "path-dirname": { "version": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", "integrity": "sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=", @@ -1970,8 +2041,7 @@ }, "through": { "version": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", - "dev": true + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" }, "through2": { "version": "https://registry.npmjs.org/through2/-/through2-2.0.3.tgz", @@ -2037,6 +2107,11 @@ "through2-filter": "https://registry.npmjs.org/through2-filter/-/through2-filter-2.0.0.tgz" } }, + "unorm": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unorm/-/unorm-1.4.1.tgz", + "integrity": "sha1-NkIA1fE2RsqLzURJAnEzVhR5IwA=" + }, "util-deprecate": { "version": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", @@ -2192,8 +2267,7 @@ }, "wrappy": { "version": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, "xregexp": { "version": "https://registry.npmjs.org/xregexp/-/xregexp-2.0.0.tgz", diff --git a/tree-view-sample/package.json b/tree-view-sample/package.json index e9ce4dad..79915c9e 100644 --- a/tree-view-sample/package.json +++ b/tree-view-sample/package.json @@ -63,9 +63,13 @@ } }, { - "command": "openFtpResource", + "command": "ftpExplorer.openFtpResource", "title": "Open FTP Resource" }, + { + "command": "ftpExplorer.revealResource", + "title": "Reveal in FTP View" + }, { "command": "jsonOutline.refresh", "title": "Refresh", @@ -88,6 +92,11 @@ } ], "menus": { + "commandPalette": [ + { + "command": "ftpExplorer.revealResource" + } + ], "view/title": [ { "command": "jsonOutline.refresh", @@ -149,6 +158,7 @@ }, "dependencies": { "jsonc-parser": "^0.4.2", - "ftp": "^0.3.10" + "ftp": "^0.3.10", + "jsftp": "^2.0.0" } -} +} \ No newline at end of file diff --git a/tree-view-sample/src/extension.ts b/tree-view-sample/src/extension.ts index e07ac7c3..787b169d 100644 --- a/tree-view-sample/src/extension.ts +++ b/tree-view-sample/src/extension.ts @@ -4,14 +4,13 @@ import * as vscode from 'vscode'; import { DepNodeProvider } from './nodeDependencies' import { JsonOutlineProvider } from './jsonOutline' -import { FtpTreeDataProvider, FtpNode } from './ftpExplorer' +import { FtpTreeDataProvider, FtpNode, FtpExplorer } from './ftpExplorer' export function activate(context: vscode.ExtensionContext) { const rootPath = vscode.workspace.rootPath; const nodeDependenciesProvider = new DepNodeProvider(rootPath); const jsonOutlineProvider = new JsonOutlineProvider(context); - const ftpExplorerProvider = new FtpTreeDataProvider(); vscode.window.registerTreeDataProvider('nodeDependencies', nodeDependenciesProvider); vscode.commands.registerCommand('nodeDependencies.refreshEntry', () => nodeDependenciesProvider.refresh()); @@ -25,13 +24,5 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand('jsonOutline.renameNode', offset => jsonOutlineProvider.rename(offset)); vscode.commands.registerCommand('extension.openJsonSelection', range => jsonOutlineProvider.select(range)); - vscode.window.registerTreeDataProvider('ftpExplorer', ftpExplorerProvider); - vscode.commands.registerCommand('ftpExplorer.refresh', () => ftpExplorerProvider.refresh()); - vscode.commands.registerCommand('openFtpResource', (node: FtpNode) => { - vscode.workspace.openTextDocument(node.resource).then(document => { - vscode.window.showTextDocument(document); - }); - }); - - + new FtpExplorer(context); } diff --git a/tree-view-sample/src/ftpExplorer.fileSystemProvider.ts b/tree-view-sample/src/ftpExplorer.fileSystemProvider.ts new file mode 100644 index 00000000..5c0717ea --- /dev/null +++ b/tree-view-sample/src/ftpExplorer.fileSystemProvider.ts @@ -0,0 +1,280 @@ +import { ExtensionContext, TreeDataProvider, EventEmitter, TreeItem, Event, window, TreeItemCollapsibleState, Uri, commands, workspace, TextDocumentContentProvider, CancellationToken, ProviderResult, TreeView } from 'vscode'; +import * as vscode from 'vscode'; +import * as Client from 'ftp'; +import { basename, dirname, join } from 'path'; +import { Socket } from 'net'; +import * as JSFtp from 'jsftp'; + +class FtpFileSystemProvider implements vscode.FileSystemProvider { + + readonly root: vscode.Uri; + + private readonly _user: string; + private readonly _pass: string; + private _connection: JSFtp; + private _pending: { resolve: Function, reject: Function, func: keyof JSFtp, args: any[] }[] = []; + + constructor( + root: vscode.Uri, + user: string, + pass: string + ) { + this.root = root; + this._user = user; + this._pass = pass; + } + + private _withConnection(func: keyof JSFtp, ...args: any[]): Promise { + return new Promise((resolve, reject) => { + this._pending.push({ resolve, reject, func, args }); + this._nextRequest(); + }); + } + + private _nextRequest(): void { + if (this._pending.length === 0) { + return; + } + + if (this._connection === void 0) { + // ensure connection first + const candidate = new JSFtp({ + host: this.root.authority + }); + candidate.keepAlive(1000 * 5); + candidate.auth(this._user, this._pass, (err) => { + this._connection = err ? null : candidate; + this._nextRequest(); + }); + + return; + } + + if (this._connection === null) { + // permanently failed + const request = this._pending.shift(); + request.reject(new Error('no connection')) + + } else { + // connected + const { func, args, resolve, reject } = this._pending.shift(); + (this._connection[func]).apply(this._connection, args.concat([function (err, res) { + if (err) { + reject(err); + } else { + resolve(res); + } + }])); + } + + this._nextRequest(); + } + + dispose(): void { + this._withConnection('raw', 'QUIT'); + } + + utimes(resource: vscode.Uri, mtime: number): Promise { + return this._withConnection('raw', 'NOOP') + .then(() => this.stat(resource)); + } + + stat(resource: vscode.Uri): Promise { + const { path } = resource; + if (path === '/' || path === '') { + // root directory + return Promise.resolve({ + type: vscode.FileType.Dir, + id: null, + mtime: 0, + size: 0 + }); + } + + const name = basename(path); + const dir = dirname(path); + return this._withConnection('ls', dir).then(entries => { + for (const entry of entries) { + if (entry.name === name) { + return { + id: null, + mtime: entry.time, + size: entry.size, + type: entry.type + }; + } + } + return Promise.reject(new Error(`ENOENT, ${resource.toString(true)}`)); + }, err => { + return Promise.reject(new Error(`ENOENT, ${resource.toString(true)}`)); + }); + } + + readdir(dir: vscode.Uri): Promise<[vscode.Uri, vscode.FileStat][]> { + return this._withConnection('ls', dir.path).then(entries => { + const result: [vscode.Uri, vscode.FileStat][] = []; + for (let entry of entries) { + const resource = dir.with({ path: join(dir.path, entry.name) }); + const stat: vscode.FileStat = { + id: resource.toString(), + mtime: entry.time, + size: entry.size, + type: entry.type + } + result.push([resource, stat]); + } + return result; + }); + } + + read(resource: vscode.Uri, offset: number = 0, len: number, progress: vscode.Progress): Promise { + + return this._withConnection('raw', 'REST', [offset]).then(() => { + + return this._withConnection('get', resource.path) + + }).then(socket => { + + let bytesRead = 0; + + return new Promise((resolve, reject) => { + socket.on('data', buffer => { + progress.report(buffer); + bytesRead += buffer.length; + if (len > 0 && bytesRead > len) { + socket.destroy(); + } + }); + socket.on('close', hadErr => { + if (hadErr) { + reject(hadErr); + } else { + resolve(bytesRead); + } + }); + socket.resume(); + }); + }); + } + + write(resource: vscode.Uri, content: Uint8Array): Promise { + return this._withConnection('put', content, resource.path); + } + + rmdir(resource: vscode.Uri): Promise { + return this._withConnection('raw', 'RMD', [resource.path]); + } + + mkdir(resource: vscode.Uri): Promise { + return this._withConnection('raw', 'MKD', [resource.path]) + .then(() => this.stat(resource)); + } + + unlink(resource: vscode.Uri): Promise { + return this._withConnection('raw', 'DELE', [resource.path]); + } + + move(resource: vscode.Uri, target: vscode.Uri): Promise { + return this._withConnection('raw', 'RNFR', [resource.path]).then(() => { + return this._withConnection('raw', 'RNTO', [target.path]); + }).then(() => { + return this.stat(target); + }); + } +} + +export interface FtpNode { + + resource: vscode.Uri; + isDirectory: boolean; + +} + + + +export class FtpTreeDataProvider implements TreeDataProvider { + + private _onDidChange: vscode.EventEmitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChange.event; + + constructor(private readonly fileSystemProvider: FtpFileSystemProvider) { } + + refresh() { + this._onDidChange.fire(); + } + + public getTreeItem(element: FtpNode): TreeItem { + return { + id: element.resource.fsPath, + resourceUri: element.resource, + collapsibleState: element.isDirectory ? TreeItemCollapsibleState.Collapsed : void 0, + command: element.isDirectory ? void 0 : { + command: 'ftpExplorer.openFtpResource', + arguments: [element.resource], + title: 'Open FTP Resource' + } + }; + } + + public getChildren(element?: FtpNode): FtpNode[] | Thenable { + return this.fileSystemProvider.readdir(element ? element.resource : this.fileSystemProvider.root) + .then((result: [vscode.Uri, vscode.FileStat][]) => this.sort(result.map(r => ({ resource: r[0], isDirectory: r[1].type !== vscode.FileType.File })))); + } + + public getParent(element: FtpNode): FtpNode { + const parent = vscode.Uri.parse(dirname(element.resource.fsPath)); + return parent.fsPath !== this.fileSystemProvider.root.fsPath ? { resource: parent, isDirectory: true } : null; + } + + private sort(nodes: FtpNode[]): FtpNode[] { + return nodes.sort((n1, n2) => { + if (n1.isDirectory && !n2.isDirectory) { + return -1; + } + + if (!n1.isDirectory && n2.isDirectory) { + return 1; + } + + return basename(n1.resource.fsPath).localeCompare(basename(n2.resource.fsPath)); + }); + } +} + + +export class FtpExplorer { + + private ftpViewer: TreeView; + + constructor(context: vscode.ExtensionContext) { + const fileProvider = new FtpFileSystemProvider(vscode.Uri.parse('ftp://mirror.switch.ch/'), 'anonymous', 'anonymous@anonymous.de') + const treeDataProvider = new FtpTreeDataProvider(fileProvider); + context.subscriptions.push(vscode.workspace.registerFileSystemProvider('ftp', fileProvider)); + + this.ftpViewer = vscode.window.registerTreeDataProvider('ftpExplorer', treeDataProvider); + + vscode.commands.registerCommand('ftpExplorer.refresh', () => treeDataProvider.refresh()); + vscode.commands.registerCommand('ftpExplorer.openFtpResource', resource => this.openResource(resource)); + vscode.commands.registerCommand('ftpExplorer.revealResource', () => this.reveal()); + } + + private openResource(resource: vscode.Uri): void { + vscode.workspace.openTextDocument(resource).then(document => vscode.window.showTextDocument(document)); + } + + private reveal(): void { + const node = this.getNode(); + if (node) { + this.ftpViewer.reveal(node); + } + } + + private getNode(): FtpNode { + if (vscode.window.activeTextEditor) { + if (vscode.window.activeTextEditor.document.uri.scheme === 'ftp') { + return { resource: vscode.window.activeTextEditor.document.uri, isDirectory: false }; + } + } + return null; + } +} \ No newline at end of file diff --git a/tree-view-sample/src/ftpExplorer.textDocumentContentProvider.ts b/tree-view-sample/src/ftpExplorer.textDocumentContentProvider.ts new file mode 100644 index 00000000..f3ece297 --- /dev/null +++ b/tree-view-sample/src/ftpExplorer.textDocumentContentProvider.ts @@ -0,0 +1,193 @@ +import { ExtensionContext, EventEmitter, TreeItem, Event, window, TreeItemCollapsibleState, Uri, commands, workspace, TextDocumentContentProvider, CancellationToken, ProviderResult, TreeView } from 'vscode'; +import * as vscode from 'vscode'; +import * as Client from 'ftp'; +import { basename, dirname, join } from 'path'; +import { Socket } from 'net'; +import * as JSFtp from 'jsftp'; +import { TreeDataProvider } from 'vscode'; + +interface IEntry { + name: string; + type: string; +} + +export interface FtpNode { + + resource: vscode.Uri; + isDirectory: boolean; + +} + +export class FtpModel { + + private nodes: Map = new Map(); + + constructor(readonly host: string, private user: string, private password: string) { + } + + public connect(): Thenable { + return new Promise((c, e) => { + const client = new Client(); + client.on('ready', () => { + c(client); + }); + + client.on('error', error => { + e('Error while connecting: ' + error.message); + }) + + client.connect({ + host: this.host, + username: this.user, + password: this.password + }); + }); + } + + public get roots(): Thenable { + return this.connect().then(client => { + return new Promise((c, e) => { + client.list((err, list) => { + if (err) { + return e(err); + } + + client.end(); + + return c(this.sort(list.map(entry => ({ resource: Uri.parse(`ftp://${this.host}///${entry.name}`), isDirectory: entry.type === 'd' })))); + }); + }); + }); + } + + public getChildren(node: FtpNode): Thenable { + return this.connect().then(client => { + return new Promise((c, e) => { + client.list(node.resource.fsPath, (err, list) => { + if (err) { + return e(err); + } + + client.end(); + + return c(this.sort(list.map(entry => ({ resource: Uri.parse(`${node.resource.fsPath}/${entry.name}`), isDirectory: entry.type === 'd' })))); + }); + }); + }); + } + + private sort(nodes: FtpNode[]): FtpNode[] { + return nodes.sort((n1, n2) => { + if (n1.isDirectory && !n2.isDirectory) { + return -1; + } + + if (!n1.isDirectory && n2.isDirectory) { + return 1; + } + + return basename(n1.resource.fsPath).localeCompare(basename(n2.resource.fsPath)); + }); + } + + public getContent(resource: Uri): Thenable { + return this.connect().then(client => { + return new Promise((c, e) => { + client.get(resource.path.substr(2), (err, stream) => { + if (err) { + return e(err); + } + + let string = '' + stream.on('data', function (buffer) { + if (buffer) { + var part = buffer.toString(); + string += part; + } + }); + + stream.on('end', function () { + client.end(); + c(string); + }); + }); + }); + }); + } +} + +export class FtpTreeDataProvider implements TreeDataProvider, TextDocumentContentProvider { + + private _onDidChangeTreeData: EventEmitter = new EventEmitter(); + readonly onDidChangeTreeData: Event = this._onDidChangeTreeData.event; + + constructor(private readonly model: FtpModel) { } + + public refresh(): any { + this._onDidChangeTreeData.fire(); + } + + + public getTreeItem(element: FtpNode): TreeItem { + return { + id: element.resource.fsPath, + resourceUri: element.resource, + collapsibleState: element.isDirectory ? TreeItemCollapsibleState.Collapsed : void 0, + command: element.isDirectory ? void 0 : { + command: 'ftpExplorer.openFtpResource', + arguments: [element.resource], + title: 'Open FTP Resource' + } + }; + } + + public getChildren(element?: FtpNode): FtpNode[] | Thenable { + return element ? this.model.getChildren(element) : this.model.roots; + } + + public getParent(element: FtpNode): FtpNode { + const parent = vscode.Uri.parse(dirname(element.resource.fsPath)); + return parent.fsPath !== this.model.host ? { resource: parent, isDirectory: true } : null; + } + + public provideTextDocumentContent(uri: Uri, token: CancellationToken): ProviderResult { + return this.model.getContent(uri).then(content => content); + } +} + +export class FtpExplorer { + + private ftpViewer: TreeView; + + constructor(context: vscode.ExtensionContext) { + const ftpModel = new FtpModel('mirror.switch.ch', 'anonymous', 'anonymous@anonymous.de'); + const treeDataProvider = new FtpTreeDataProvider(ftpModel); + context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider('ftp', treeDataProvider)); + + this.ftpViewer = vscode.window.registerTreeDataProvider('ftpExplorer', treeDataProvider); + + vscode.commands.registerCommand('ftpExplorer.refresh', () => treeDataProvider.refresh()); + vscode.commands.registerCommand('ftpExplorer.openFtpResource', resource => this.openResource(resource)); + vscode.commands.registerCommand('ftpExplorer.revealResource', () => this.reveal()); + } + + private openResource(resource: vscode.Uri): void { + vscode.window.showTextDocument(resource); + } + + private reveal(): void { + const node = this.getNode(); + if (node) { + this.ftpViewer.reveal(node); + } + } + + private getNode(): FtpNode { + if (vscode.window.activeTextEditor) { + if (vscode.window.activeTextEditor.document.uri.scheme === 'ftp') { + return { resource: vscode.window.activeTextEditor.document.uri, isDirectory: false }; + } + } + return null; + } +} \ No newline at end of file diff --git a/tree-view-sample/src/ftpExplorer.ts b/tree-view-sample/src/ftpExplorer.ts deleted file mode 100644 index 2ee7caa1..00000000 --- a/tree-view-sample/src/ftpExplorer.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { ExtensionContext, TreeDataProvider, EventEmitter, TreeItem, Event, window, TreeItemCollapsibleState, Uri, commands, workspace, TextDocumentContentProvider, CancellationToken, ProviderResult } from 'vscode'; -import * as Client from 'ftp'; -import * as path from 'path'; - -interface IEntry { - name: string; - type: string; -} - -export class FtpNode { - private _resource: Uri; - - constructor(private entry: IEntry, private host: string, private _parent: string) { - this._resource = Uri.parse(`ftp://${host}/${_parent}/${entry.name}`); - } - - public get resource(): Uri { - return this._resource; - } - - public get path(): string { - return path.join(this._parent, this.name); - } - - public get name(): string { - return this.entry.name; - } - - public get isFolder(): boolean { - return this.entry.type === 'd' || this.entry.type === 'l'; - } -} - -export class FtpModel { - - constructor(private host: string, private user: string, private password: string) { - } - - public connect(): Thenable { - return new Promise((c, e) => { - const client = new Client(); - client.on('ready', () => { - c(client); - }); - - client.on('error', error => { - e('Error while connecting: ' + error.message); - }) - - client.connect({ - host: this.host, - username: this.user, - password: this.password - }); - }); - } - - public get roots(): Thenable { - return this.connect().then(client => { - return new Promise((c, e) => { - client.list((err, list) => { - if (err) { - return e(err); - } - - client.end(); - - return c(this.sort(list.map(entry => new FtpNode(entry, this.host, '/')))); - }); - }); - }); - } - - public getChildren(node: FtpNode): Thenable { - return this.connect().then(client => { - return new Promise((c, e) => { - client.list(node.path, (err, list) => { - if (err) { - return e(err); - } - - client.end(); - - return c(this.sort(list.map(entry => new FtpNode(entry, this.host, node.path)))); - }); - }); - }); - } - - private sort(nodes: FtpNode[]): FtpNode[] { - return nodes.sort((n1, n2) => { - if (n1.isFolder && !n2.isFolder) { - return -1; - } - - if (!n1.isFolder && n2.isFolder) { - return 1; - } - - return n1.name.localeCompare(n2.name); - }); - } - - public getContent(resource: Uri): Thenable { - return this.connect().then(client => { - return new Promise((c, e) => { - client.get(resource.path.substr(2), (err, stream) => { - if (err) { - return e(err); - } - - let string = '' - stream.on('data', function (buffer) { - if (buffer) { - var part = buffer.toString(); - string += part; - } - }); - - stream.on('end', function () { - client.end(); - c(string); - }); - }); - }); - }); - } -} - -export class FtpTreeDataProvider implements TreeDataProvider, TextDocumentContentProvider { - - private _onDidChangeTreeData: EventEmitter = new EventEmitter(); - readonly onDidChangeTreeData: Event = this._onDidChangeTreeData.event; - - private model: FtpModel; - - refresh(): any { - this._onDidChangeTreeData.fire(); - } - - public getTreeItem(element: FtpNode): TreeItem { - return { - resourceUri: element.resource, - collapsibleState: element.isFolder ? TreeItemCollapsibleState.Collapsed : TreeItemCollapsibleState.None, - command: element.isFolder ? void 0 : { - command: 'openFtpResource', - arguments: [element.resource], - title: 'Open FTP Resource' - } - }; - } - - public getChildren(element?: FtpNode): FtpNode[] | Thenable { - if (!element) { - if (!this.model) { - this.model = new FtpModel('mirror.switch.ch', 'anonymous', 'anonymous@anonymous.de'); - } - - return this.model.roots; - } - - return this.model.getChildren(element); - } - - public provideTextDocumentContent(uri: Uri, token: CancellationToken): ProviderResult { - return this.model.getContent(uri); - } -} \ No newline at end of file diff --git a/tree-view-sample/src/jsftp.d.ts b/tree-view-sample/src/jsftp.d.ts new file mode 100644 index 00000000..6d23834d --- /dev/null +++ b/tree-view-sample/src/jsftp.d.ts @@ -0,0 +1,48 @@ + + +import { Readable } from 'stream'; +import { EventEmitter } from 'events'; + +declare namespace JSFtp { + + + interface JSFtpOptions { + host: string; + port?: number | 21; + user?: string | 'anonymous'; + pass?: string | '@anonymous'; + useList?: boolean + } + + interface Callback { + (err: any, result: T): void; + } + + + interface Entry { + name: string; + size: number; + time: number; + type: 0 | 1; + } +} + +interface JSFtp extends EventEmitter { + auth(user: string, password: string, callback: JSFtp.Callback): void + keepAlive(wait?: number): void; + ls(path: string, callback: JSFtp.Callback): void; + list(path: string, callback: JSFtp.Callback): void; + put(buffer: Buffer, path: string, callback: JSFtp.Callback): void; + get(path: string, callback: JSFtp.Callback): void; + setType(type: 'A' | 'AN' | 'AT' | 'AC' | 'E' | 'I' | 'L', callback: JSFtp.Callback): void; + raw(command: string, args: any[], callback: JSFtp.Callback): void; + raw(command: string, args: any[], callback: JSFtp.Callback): void; +} + +interface JSFtpConstructor { + new(options: JSFtp.JSFtpOptions): JSFtp; +} + +declare const JSFtp: JSFtpConstructor; + +export = JSFtp; diff --git a/tree-view-sample/src/nodeDependencies.ts b/tree-view-sample/src/nodeDependencies.ts index 60f9a724..0a8e1078 100644 --- a/tree-view-sample/src/nodeDependencies.ts +++ b/tree-view-sample/src/nodeDependencies.ts @@ -46,23 +46,23 @@ export class DepNodeProvider implements vscode.TreeDataProvider { if (this.pathExists(packageJsonPath)) { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); - const toDep = (moduleName: string): Dependency => { + const toDep = (moduleName: string, version: string): Dependency => { if (this.pathExists(path.join(this.workspaceRoot, 'node_modules', moduleName))) { - return new Dependency(moduleName, vscode.TreeItemCollapsibleState.Collapsed); + return new Dependency(moduleName, version, vscode.TreeItemCollapsibleState.Collapsed); } else { - return new Dependency(moduleName, vscode.TreeItemCollapsibleState.None, { + return new Dependency(moduleName, version, vscode.TreeItemCollapsibleState.None, { command: 'extension.openPackageOnNpm', title: '', - arguments: [moduleName], + arguments: [moduleName] }); } } const deps = packageJson.dependencies - ? Object.keys(packageJson.dependencies).map(toDep) + ? Object.keys(packageJson.dependencies).map(dep => toDep(dep, packageJson.dependencies[dep])) : []; const devDeps = packageJson.devDependencies - ? Object.keys(packageJson.devDependencies).map(toDep) + ? Object.keys(packageJson.devDependencies).map(dep => toDep(dep, packageJson.devDependencies[dep])) : []; return deps.concat(devDeps); } else { @@ -85,12 +85,17 @@ class Dependency extends vscode.TreeItem { constructor( public readonly label: string, + private version: string, public readonly collapsibleState: vscode.TreeItemCollapsibleState, public readonly command?: vscode.Command ) { super(label, collapsibleState); } + get tooltip(): string { + return `${this.label}-${this.version}` + } + iconPath = { light: path.join(__filename, '..', '..', '..', 'resources', 'light', 'dependency.svg'), dark: path.join(__filename, '..', '..', '..', 'resources', 'dark', 'dependency.svg') diff --git a/tree-view-sample/vscode.proposed.d.ts b/tree-view-sample/vscode.proposed.d.ts new file mode 100644 index 00000000..0086481a --- /dev/null +++ b/tree-view-sample/vscode.proposed.d.ts @@ -0,0 +1,641 @@ +import { ProviderResult } from "vscode"; + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// This is the place for API experiments and proposal. + +declare module 'vscode' { + + export class FoldingRangeList { + + /** + * The folding ranges. + */ + ranges: FoldingRange[]; + + /** + * Creates mew folding range list. + * + * @param ranges The folding ranges + */ + constructor(ranges: FoldingRange[]); + } + + + export class FoldingRange { + + /** + * The start line number (0-based) + */ + startLine: number; + + /** + * The end line number (0-based) + */ + endLine: number; + + /** + * The actual color value for this color range. + */ + type?: FoldingRangeType | string; + + /** + * Creates a new folding range. + * + * @param startLineNumber The first line of the fold + * @param type The last line of the fold + */ + constructor(startLineNumber: number, endLineNumber: number, type?: FoldingRangeType); + } + + export enum FoldingRangeType { + /** + * Folding range for a comment + */ + Comment = 'comment', + /** + * Folding range for a imports or includes + */ + Imports = 'imports', + /** + * Folding range for a region (e.g. `#region`) + */ + Region = 'region' + } + + // export enum FileErrorCodes { + // /** + // * Not owner. + // */ + // EPERM = 1, + // /** + // * No such file or directory. + // */ + // ENOENT = 2, + // /** + // * I/O error. + // */ + // EIO = 5, + // /** + // * Permission denied. + // */ + // EACCES = 13, + // /** + // * File exists. + // */ + // EEXIST = 17, + // /** + // * Not a directory. + // */ + // ENOTDIR = 20, + // /** + // * Is a directory. + // */ + // EISDIR = 21, + // /** + // * File too large. + // */ + // EFBIG = 27, + // /** + // * No space left on device. + // */ + // ENOSPC = 28, + // /** + // * Directory is not empty. + // */ + // ENOTEMPTY = 66, + // /** + // * Invalid file handle. + // */ + // ESTALE = 70, + // /** + // * Illegal NFS file handle. + // */ + // EBADHANDLE = 10001, + // } + + export enum FileChangeType { + Updated = 0, + Added = 1, + Deleted = 2 + } + + export interface FileChange { + type: FileChangeType; + resource: Uri; + } + + export enum FileType { + File = 0, + Dir = 1, + Symlink = 2 + } + + export interface FileStat { + id: number | string; + mtime: number; + // atime: number; + size: number; + type: FileType; + } + + export interface TextSearchQuery { + pattern: string; + isRegex?: boolean; + isCaseSensitive?: boolean; + isWordMatch?: boolean; + } + + export interface TextSearchOptions { + includes: GlobPattern[]; + excludes: GlobPattern[]; + } + + export interface TextSearchResult { + uri: Uri; + range: Range; + preview: { leading: string, matching: string, trailing: string }; + } + + // todo@joh discover files etc + // todo@joh CancellationToken everywhere + // todo@joh add open/close calls? + export interface FileSystemProvider { + + readonly onDidChange?: Event; + + // todo@joh - remove this + readonly root?: Uri; + + // more... + // + utimes(resource: Uri, mtime: number, atime: number): Thenable; + + stat(resource: Uri): Thenable; + + read(resource: Uri, offset: number, length: number, progress: Progress): Thenable; + + // todo@joh - have an option to create iff not exist + // todo@remote + // offset - byte offset to start + // count - number of bytes to write + // Thenable - number of bytes actually written + write(resource: Uri, content: Uint8Array): Thenable; + + // todo@remote + // Thenable + move(resource: Uri, target: Uri): Thenable; + + // todo@remote + // helps with performance bigly + // copy?(from: Uri, to: Uri): Thenable; + + // todo@remote + // Thenable + mkdir(resource: Uri): Thenable; + + readdir(resource: Uri): Thenable<[Uri, FileStat][]>; + + // todo@remote + // ? merge both + // ? recursive del + rmdir(resource: Uri): Thenable; + unlink(resource: Uri): Thenable; + + // todo@remote + // create(resource: Uri): Thenable; + + // find files by names + // todo@joh, move into its own provider + findFiles?(query: string, progress: Progress, token: CancellationToken): Thenable; + provideTextSearchResults?(query: TextSearchQuery, options: TextSearchOptions, progress: Progress, token: CancellationToken): Thenable; + } + + export namespace workspace { + export function registerFileSystemProvider(scheme: string, provider: FileSystemProvider): Disposable; + + /** + * This method replaces `deleteCount` [workspace folders](#workspace.workspaceFolders) starting at index `start` + * by an optional set of `workspaceFoldersToAdd` on the `vscode.workspace.workspaceFolders` array. This "splice" + * behavior can be used to add, remove and change workspace folders in a single operation. + * + * If the first workspace folder is added, removed or changed, the currently executing extensions (including the + * one that called this method) will be terminated and restarted so that the (deprecated) `rootPath` property is + * updated to point to the first workspace folder. + * + * Use the [`onDidChangeWorkspaceFolders()`](#onDidChangeWorkspaceFolders) event to get notified when the + * workspace folders have been updated. + * + * **Example:** adding a new workspace folder at the end of workspace folders + * ```typescript + * workspace.updateWorkspaceFolders(workspace.workspaceFolders ? workspace.workspaceFolders.length : 0, null, { uri: ...}); + * ``` + * + * **Example:** removing the first workspace folder + * ```typescript + * workspace.updateWorkspaceFolders(0, 1); + * ``` + * + * **Example:** replacing an existing workspace folder with a new one + * ```typescript + * workspace.updateWorkspaceFolders(0, 1, { uri: ...}); + * ``` + * + * It is valid to remove an existing workspace folder and add it again with a different name + * to rename that folder. + * + * **Note:** it is not valid to call [updateWorkspaceFolders()](#updateWorkspaceFolders) multiple times + * without waiting for the [`onDidChangeWorkspaceFolders()`](#onDidChangeWorkspaceFolders) to fire. + * + * @param start the zero-based location in the list of currently opened [workspace folders](#WorkspaceFolder) + * from which to start deleting workspace folders. + * @param deleteCount the optional number of workspace folders to remove. + * @param workspaceFoldersToAdd the optional variable set of workspace folders to add in place of the deleted ones. + * Each workspace is identified with a mandatory URI and an optional name. + * @return true if the operation was successfully started and false otherwise if arguments were used that would result + * in invalid workspace folder state (e.g. 2 folders with the same URI). + */ + export function updateWorkspaceFolders(start: number, deleteCount: number, ...workspaceFoldersToAdd: { uri: Uri, name?: string }[]): boolean; + } + + export namespace window { + + export function sampleFunction(): Thenable; + } + + /** + * The contiguous set of modified lines in a diff. + */ + export interface LineChange { + readonly originalStartLineNumber: number; + readonly originalEndLineNumber: number; + readonly modifiedStartLineNumber: number; + readonly modifiedEndLineNumber: number; + } + + export namespace commands { + + /** + * Registers a diff information command that can be invoked via a keyboard shortcut, + * a menu item, an action, or directly. + * + * Diff information commands are different from ordinary [commands](#commands.registerCommand) as + * they only execute when there is an active diff editor when the command is called, and the diff + * information has been computed. Also, the command handler of an editor command has access to + * the diff information. + * + * @param command A unique identifier for the command. + * @param callback A command handler function with access to the [diff information](#LineChange). + * @param thisArg The `this` context used when invoking the handler function. + * @return Disposable which unregisters this command on disposal. + */ + export function registerDiffInformationCommand(command: string, callback: (diff: LineChange[], ...args: any[]) => any, thisArg?: any): Disposable; + } + + //#region decorations + + //todo@joh -> make class + export interface DecorationData { + priority?: number; + title?: string; + bubble?: boolean; + abbreviation?: string; + color?: ThemeColor; + source?: string; + } + + export interface SourceControlResourceDecorations { + source?: string; + letter?: string; + color?: ThemeColor; + } + + export interface DecorationProvider { + onDidChangeDecorations: Event; + provideDecoration(uri: Uri, token: CancellationToken): ProviderResult; + } + + export namespace window { + export function registerDecorationProvider(provider: DecorationProvider): Disposable; + } + + //#endregion + + /** + * Represents a debug adapter executable and optional arguments passed to it. + */ + export class DebugAdapterExecutable { + /** + * The command path of the debug adapter executable. + * A command must be either an absolute path or the name of an executable looked up via the PATH environment variable. + * The special value 'node' will be mapped to VS Code's built-in node runtime. + */ + readonly command: string; + + /** + * Optional arguments passed to the debug adapter executable. + */ + readonly args: string[]; + + /** + * Create a new debug adapter specification. + */ + constructor(command: string, args?: string[]); + } + + export interface DebugConfigurationProvider { + /** + * This optional method is called just before a debug adapter is started to determine its excutable path and arguments. + * Registering more than one debugAdapterExecutable for a type results in an error. + * @param folder The workspace folder from which the configuration originates from or undefined for a folderless setup. + * @param token A cancellation token. + * @return a [debug adapter's executable and optional arguments](#DebugAdapterExecutable) or undefined. + */ + debugAdapterExecutable?(folder: WorkspaceFolder | undefined, token?: CancellationToken): ProviderResult; + } + + /** + * The severity level of a log message + */ + export enum LogLevel { + Trace = 1, + Debug = 2, + Info = 3, + Warning = 4, + Error = 5, + Critical = 6, + Off = 7 + } + + /** + * A logger for writing to an extension's log file, and accessing its dedicated log directory. + */ + export interface Logger { + readonly onDidChangeLogLevel: Event; + readonly currentLevel: LogLevel; + readonly logDirectory: Thenable; + + trace(message: string, ...args: any[]): void; + debug(message: string, ...args: any[]): void; + info(message: string, ...args: any[]): void; + warn(message: string, ...args: any[]): void; + error(message: string | Error, ...args: any[]): void; + critical(message: string | Error, ...args: any[]): void; + } + + export interface ExtensionContext { + /** + * This extension's logger + */ + logger: Logger; + } + + export interface RenameInitialValue { + range: Range; + text?: string; + } + + export namespace languages { + + /** + * Register a folding provider. + * + * Multiple folding can be registered for a language. In that case providers are sorted + * by their [score](#languages.match) and the best-matching provider is used. Failure + * of the selected provider will cause a failure of the whole operation. + * + * @param selector A selector that defines the documents this provider is applicable to. + * @param provider A folding provider. + * @return A [disposable](#Disposable) that unregisters this provider when being disposed. + */ + export function registerFoldingProvider(selector: DocumentSelector, provider: FoldingProvider): Disposable; + + export interface RenameProvider2 extends RenameProvider { + resolveInitialRenameValue?(document: TextDocument, position: Position, token: CancellationToken): ProviderResult; + } + } + export interface FoldingProvider { + provideFoldingRanges(document: TextDocument, token: CancellationToken): ProviderResult; + } + + /** + * Represents the validation type of the Source Control input. + */ + export enum SourceControlInputBoxValidationType { + + /** + * Something not allowed by the rules of a language or other means. + */ + Error = 0, + + /** + * Something suspicious but allowed. + */ + Warning = 1, + + /** + * Something to inform about but not a problem. + */ + Information = 2 + } + + export interface SourceControlInputBoxValidation { + + /** + * The validation message to display. + */ + readonly message: string; + + /** + * The validation type. + */ + readonly type: SourceControlInputBoxValidationType; + } + + /** + * Represents the input box in the Source Control viewlet. + */ + export interface SourceControlInputBox { + + /** + * A validation function for the input box. It's possible to change + * the validation provider simply by setting this property to a different function. + */ + validateInput?(value: string, cursorPosition: number): ProviderResult; + } + + /** + * Content settings for a webview. + */ + export interface WebviewOptions { + /** + * Should scripts be enabled in the webview content? + * + * Defaults to false (scripts-disabled). + */ + readonly enableScripts?: boolean; + + /** + * Should command uris be enabled in webview content? + * + * Defaults to false. + */ + readonly enableCommandUris?: boolean; + + /** + * Should the webview content be kept arount even when the webview is no longer visible? + * + * Normally a webview content is created when the webview becomes visible + * and destroyed when the webview is hidden. Apps that have complex state + * or UI can set the `keepAlive` property to make VS Code keep the webview + * content around, even when the webview itself is no longer visible. When + * the webview becomes visible again, the content is automatically restored + * in the exact same state it was in originally + * + * `keepAlive` has a high memory overhead and should only be used if your + * webview content cannot be quickly saved and restored. + */ + readonly keepAlive?: boolean; + + /** + * Root paths from which the webview can load local (filesystem) resources using the `vscode-workspace-resource:` scheme. + * + * Default to the root folders of the current workspace. + * + * Pass in an empty array to disallow access to any local resources. + */ + readonly localResourceRoots?: Uri[]; + } + + /** + * A webview is an editor with html content, like an iframe. + */ + export interface Webview { + /** + * Title of the webview. + */ + title: string; + + /** + * Contents of the webview. + */ + html: string; + + /** + * Content settings for the webview. + */ + options: WebviewOptions; + + /** + * The column in which the webview is showing. + */ + readonly viewColumn?: ViewColumn; + + /** + * Fired when the webview content posts a message. + */ + readonly onMessage: Event; + + /** + * Fired when the webview becomes the active editor. + */ + readonly onBecameActive: Event; + + /** + * Fired when the webview stops being the active editor + */ + readonly onBecameInactive: Event; + + /** + * Post a message to the webview content. + * + * Messages are only develivered if the webview is visible. + * + * @param message Body of the message. + */ + postMessage(message: any): Thenable; + + /** + * Dispose the webview. + */ + dispose(): any; + } + + namespace window { + /** + * Create and show a new webview. + * + * @param title Title of the webview. + * @param column Editor column to show the new webview in. + * @param options Webview content options. + */ + export function createWebview(title: string, column: ViewColumn, options: WebviewOptions): Webview; + } + + export namespace window { + + /** + * Register a [TreeDataProvider](#TreeDataProvider) for the view contributed using the extension point `views`. + * @param viewId Id of the view contributed using the extension point `views`. + * @param treeDataProvider A [TreeDataProvider](#TreeDataProvider) that provides tree data for the view + * @return handle to the [treeview](#TreeView) that can be disposable. + */ + export function registerTreeDataProvider(viewId: string, treeDataProvider: TreeDataProvider): TreeView; + + } + + /** + * Represents a Tree view + */ + export interface TreeView extends Disposable { + + /** + * Reveal an element. By default revealed element is selected. + * + * In order to not to select, set the option `donotSelect` to `true`. + */ + reveal(element: T, options?: { donotSelect?: boolean }): Thenable; + } + + /** + * A data provider that provides tree data + */ + export interface TreeDataProvider { + /** + * An optional event to signal that an element or root has changed. + * This will trigger the view to update the changed element/root and its children recursively (if shown). + * To signal that root has changed, do not pass any argument or pass `undefined` or `null`. + */ + onDidChangeTreeData?: Event; + + /** + * Get [TreeItem](#TreeItem) representation of the `element` + * + * @param element The element for which [TreeItem](#TreeItem) representation is asked for. + * @return [TreeItem](#TreeItem) representation of the element + */ + getTreeItem(element: T): TreeItem | Thenable; + + /** + * Get the children of `element` or root if no element is passed. + * + * @param element The element from which the provider gets children. Can be `undefined`. + * @return Children of `element` or root if no element is passed. + */ + getChildren(element?: T): ProviderResult; + + /** + * Optional method to return the parent of `element`. + * Return `null` or `undefined` if `element` is a child of root. + * + * **NOTE:** This method should be implemented in order to use [TreeVie](#TreeView) API. + * + * @param element The element for which the parent has to be returned. + * @return Parent of `element`. + */ + getParent?(element: T): ProviderResult; + + }