diff --git a/.debug b/.debug
new file mode 100644
index 0000000..50f5c18
--- /dev/null
+++ b/.debug
@@ -0,0 +1,2 @@
+Debugging file flag for development.
+Placing this file in the root of the repository will activate "debug" logging in Push.
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
new file mode 100644
index 0000000..6d32ba7
--- /dev/null
+++ b/.github/CONTRIBUTING.md
@@ -0,0 +1,62 @@
+# Contributing
+
+Firstly, thank you for considering a contribution to Push!
+
+Push uses Git flow, in that the tip of the `master` branch is considered to be the current latest release of Push. Any and all contributions should be merged into `develop`.
+
+## Environment
+
+Push is a VS Code extension. If you are unfamiliar with the VS Code development ecosystem, please do read up on the basics before getting involved:
+
+ 1. [Build your own extension](https://code.visualstudio.com/docs/extensions/overview)
+ 2. [Developing extensions](https://code.visualstudio.com/docs/extensions/developing-extensions)
+ 3. [VS Code API](https://code.visualstudio.com/docs/extensionAPI/vscode-api)
+
+## Starting up
+
+If you haven't already created a fork of Push, feel free to do so now. You will need a Github account and a recent (8+) version of Node JS & NPM in order to do this.
+
+Once you have the fork and have cloned it to your local development environment, you will then need to run the following command to trigger NPM to install the appropriate required components for Push:
+
+```
+npm install
+```
+
+Once they are installed, you should be able to then use the debugger/launcher within VS Code to launch and test an instance of Push.
+
+## Contributing changes
+
+There are a few points to be aware of before working on Push:
+
+### 1. Will it make Push better for everyone, or just you?
+If the change you are considering may only be useful for yourself, or a small subset of users, the benefit of the change may not be worth the complexity it will add to Push. If you would like to make the change anyway, feel free to, but it may not be accepted as a pull request into Push.
+
+### 2. Has it been tested by at least one user who hasn't had any experience with the new functionality at all?
+If you are able to test new functionality on a user who is as yet unfamiliar with it, then please do so. Especially if the functionality has a user experience impact.
+
+### 3. Are any new UI strings translated or at least translateable within the language files?
+If you are presenting strings of English text to the user, please do so within the translation framework that Push provides. This is a simple case of:
+
+ 1. Add the string to (at minimum) the `src/lang/en_gb.js` file, checking first that an identical string does not exist.
+ 2. Reference the string by its name using the i18n tools within Push (`i18n#t`, `i18n#o`, etc).
+
+### 4. Have you avoided any new dependencies and if any, checked them for potential node/vscode exploits?
+In the event that your change requires new NPM/JS dependencies, please ensure you have checked and reviewed them for potential exploits and/or incompatibilities with VS Code.
+
+### 5. Does the issue you are trying to solve already reported, and if so, have you announced that you will take responsibility for fixing it?
+Please check that the problem you are attempting to solve hasn't already been reported, and consider announcing responsibility if you would like to take on the challenge of fixing it.
+
+## Creating a development environment
+
+If you've thought about, and are happy with all of these points, then feel free to continue with the contribution! A brief step-by-step to contributing is as follows:
+
+ 1. Fork/clone and `npm install` Push locally.
+ 2. Create a branch for the feature (`feature/featurename`).
+ 3. Create, test and review your addition.
+ 4. Create a pull request from your branch to `push/tree/develop`.
+
+Once it's been reviewed, it can then be merged and will be set for release in the next version.
+
+## If you'd rather not contribute directly
+
+Remember, Push is open source and available under the Apache 2.0 license! You can make a copy if you like, and I won't mind. Should you desire to do so, I would be grateful if you named your version something other than "Push" before submitting it to the VS Code extension marketplace.
diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md
index f8283f4..dd87fed 100644
--- a/.github/ISSUE_TEMPLATE.md
+++ b/.github/ISSUE_TEMPLATE.md
@@ -1,9 +1,8 @@
-Thank you for reporting an issue! Please make sure you get the following information before submitting:
-
+
- Push version: `#.#.#`
- VS Code version: `#.#.#`
- Have you disabled all other extensions and can replicate: [Yes|No]
**Steps to replicate:**
-**Any other information?**
\ No newline at end of file
+**Any other information?**
diff --git a/.gitignore b/.gitignore
index 869998b..716ce4a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
node_modules
*.vsix
/coverage
+/docs
+/.rsync.json
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..833932c
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule ".ide"]
+ path = .ide
+ url = https://github.com/njpanderson/ide-assets.git
diff --git a/.ide b/.ide
new file mode 160000
index 0000000..7a27b34
--- /dev/null
+++ b/.ide
@@ -0,0 +1 @@
+Subproject commit 7a27b34e710b0c04f42b4b382efc80198805e7dd
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..8d85f20
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,9 @@
+language: node_js
+node_js:
+ - "node"
+cache:
+ directories:
+ - "node_modules"
+notifications:
+ email:
+ on_success: never
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 201cada..cee20f9 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -42,4 +42,4 @@
"internalConsoleOptions": "openOnSessionStart"
}
]
-}
\ No newline at end of file
+}
diff --git a/.vscodeignore b/.vscodeignore
index 499648c..3a6c387 100644
--- a/.vscodeignore
+++ b/.vscodeignore
@@ -5,3 +5,4 @@ test/**
jsconfig.json
vsc-extension-quickstart.md
.eslintrc.json
+.debug
diff --git a/API-README.md b/API-README.md
new file mode 100644
index 0000000..5c97f5f
--- /dev/null
+++ b/API-README.md
@@ -0,0 +1 @@
+This is the API documentation for Push, a Visual Code Extension. Please note that full documentation and examples of use are available on [GitHub](https://github.com/njpanderson/push)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c47ebf9..97a9aba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,19 @@
-# Change Log
-All notable changes to Push will be documented in this file.
+# Push Changelog
+All notable changes to Push will be documented in this file. If this file has appeared within Visual Studio Code, it means that Push has had a notable update. You can easily disable this feature by setting the `njpPush.showChangelog` to `false`.
+
+## 0.5.0
+ - Add support for environments within service configs. This is a big feature, please check the README!
+ - Altered the settingsFile configuration somewhat. New service settings files will be named `.push.settings.jsonc` to signify that they can contain comments. Your current service files will still work with the old filename, but if you have customised the filename, please take note of this setting and its partner setting, `settingsFileGlob`.
+ - Add persistent watchers. Up to 50 watchers will now be retained between restarts.
+ - Add command to create a new service config, regardless of whether one exists.
+ - Add Travis integration (mainly to improve automated testing and doc generation).
+ - Fixed the default status notice colour - it should now be visible!
+ - Fixed deleted handling within the transfer logic for both File and SFTP.
+ - Modified error reporting (a lot). Errors should now be clearer and less vague.
+ - Modified on demand handling to remove uploaded files from the upload queue.
+ - Altered service settings files to comment out optional items by default.
+ - Added an option to show this changelog when minor or major releases occur.
+ - Various other bugfixes and stability improvements.
## 0.4.61
- Fix issue with service files not being read correctly. (Thanks, all those that reported.)
@@ -14,6 +28,7 @@ All notable changes to Push will be documented in this file.
- Fix issues with downloading folders from File sources.
- Better local symlink handling.
- Add "followSymlinks" option for uploading — this effectively disables the symlink following behaviour by default.
+ - Improved some language strings
## 0.4.4
- Fix issues with transfers to some File service locations on windows.
diff --git a/LICENSE b/LICENSE
index 2bb9ad2..d9a10c0 100644
--- a/LICENSE
+++ b/LICENSE
@@ -173,4 +173,4 @@
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
- END OF TERMS AND CONDITIONS
\ No newline at end of file
+ END OF TERMS AND CONDITIONS
diff --git a/README.md b/README.md
index ed63922..25516d9 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,13 @@
-# Push
+[![](https://vsmarketplacebadge.apphb.com/version-short/njp-anderson.push.svg)](https://marketplace.visualstudio.com/items?itemName=njp-anderson.push)
+[![](https://vsmarketplacebadge.apphb.com/installs-short/njp-anderson.push.svg)](https://marketplace.visualstudio.com/items?itemName=njp-anderson.push)
+[![](https://vsmarketplacebadge.apphb.com/rating-short/njp-anderson.push.svg)](https://marketplace.visualstudio.com/items?itemName=njp-anderson.push)
+[![master build Status](https://travis-ci.org/njpanderson/push.svg?branch=master)](https://travis-ci.org/njpanderson/push)
-Push is a file transfer extension. It is inspired in part by Sublime's fantastic SFTP plugin as well as Coda's workflow features, and provides you with a tool to upload and download files within a workspace.
+
+
+
+
+> Push is a file transfer extension built with with the goal of being both easy to use and reliable, providing developers with features they need.
## Features
@@ -12,16 +19,19 @@ It currently provides:
- Watching of files within the project.
- SFTP gateway support - connect via an SSH gateway/bastion to your SFTP server.
+
+
## ⚡️ Quick setup
Push supports many options and configuration modes. The most common of which is a single SFTP setup for an active workspace. The following steps will help you get set up in no time:
1. Install Push from the [VS Code extension marketplace](https://marketplace.visualstudio.com/items?itemName=njp-anderson.push).
2. In the command palette, choose **Create/Edit Push configuration** and confirm the location (usually your workspace root), then choose the **SFTP** template.
- 3. Fill in the missing details within the settings file. At minimum, you will need a `host`, `username`, a `password` if not using keys, and the `root` path which will contain the workspace files, starting at the root defined by the location of the `push.settings.json` file.
+ 3. Fill in the missing details within the settings file. At minimum, you will need a `host`, `username`, a `password` if not using keys, and the `root` path which will contain the workspace files, starting at the root defined by the location of the `push.settings.jsonc` file.
4. You should then be able to upload files within the workspace by using the explorer menu, title bars, or command palette.
For more complete setup and configuration details, feel free to read on.
+
## Extension Settings
@@ -30,48 +40,63 @@ This extension contributes the following settings:
| Setting | Default | Description |
| --- | --- | --- |
| `locale` | `en-gb` | Language to use. See "Push in your language".
-| `settingsFilename` | `.push.settings.json` | Settings file name. Defaults to `.push.settings.json`. |
-| `debugMode` | `false` | Enable debug mode for more logging. Useful if reporting errors. |
+| `settingsFilename` | `.push.settings.jsonc` | Settings file name. Defaults to `.push.settings.jsonc`. |
+| `settingsFileGlob` | `.push.settings.json*` | A glob used to find settings files based on their potential name. Defaults to `.push.settings.json*`. This glob *must* match files named with `settingsFilename`. |
| `privateSSHKey` | (Empty) | Set the location of your private .ssh key. Will attempt to locate within the local .ssh folder by default. |
| `privateSSHKeyPassphrase` | (Empty) | If you're using a private key with a passphrase, enter it here. |
| `uploadQueue` | `true` | If enabled, uses an upload queue, allowing you to upload all saved files since the last upload. |
| `ignoreGlobs` | `**/.DS_Store`,
`**/Thumbs.db`,
`**/desktop.ini`,
`**/.git/\*`,
`**/.svn/*` | A list of file globs to ignore when uploading. |
| `queueCompleteMessageType` | `status` | Choose how to be notified on queue completion. Either `status`, or `message` for a permanent reminder. |
-| `statusMessageColor` | `notification.`
`infoBackground` | Choose the colour of the queue completion status message. |
+| `statusMessageColor` | `statusBar.`
`foreground` | Choose the colour of the queue completion status message. |
| `queueWatchedFiles` | `false` | When set to `true`, Push will queue watched files with changes detected instead of immediately uploading them. |
| `autoUploadQueue` | `false` | When set to `true`, Push will automatically upload files that enter the queue. This allows for changes within VS Code to be uploaded on save, while not uploading changes from outside VS Code (like a watcher would). |
+| `persistWatchers` | `false` | When test to `true`, Push will retain up to 50 watchers between a restart of VS Code. See [Watcher Persistence](#watcher-persistence) |
+| `useEnvLabel` | `true` | Set `false` to disable the currently active environment label within the status bar. |
+| `envColours` | `dev: #62defd`
`stage: #ffd08a`
`prod: #f7ed00` | The currently defined transfer environments (and their status bar colours). VS Code's [theme colours](https://code.visualstudio.com/docs/getstarted/theme-color-reference) can also be used here. |
## Using Push
-Push has three main modes of operation: 1) As a standard, on-demand uploader, 2) as a queue-based uploader on save, or 3) as a file watching uploader. All three can be combined or ignored as your preferences dictate.
+Push has three main modes of operation: 1) As a standard, on-demand uploader, 2) as a queue-based uploader on save, or 3) as a file watching uploader. All three methods may be combined as your preferences dictate.
-### How does Push upload?
-When Push uploads a file within the workspace, it does a few things to make sure the file gets into the right place on your remote location - regardless of which service is used:
+### How does Push transfer files?
+When Push transfers a file within the workspace, it does a few things to make sure the file gets into the right place — regardless of which service is used:
- 1. Find the nearest `.push.settings.json` (or equivalent) to the file being uploaded. Push will look upwards along the ancestor tree of the file to find this.
+ 1. Find the nearest `.push.settings.jsonc` (or equivalent) to the file being transferred. Push will look upwards along the ancestor tree of the file to find this.
2. Connect to the service required and find the root path as defined.
- 3. Use the root path as a basis for uploading the file at its own path, relative to the workspace.
- 4. Upload the file, optionally presenting overwrite options to the user.
+ 3. Use the root path as a basis for transferring the file at its own path, relative to the workspace.
+ 4. Transfer the file, optionally presenting overwrite options to the user.
#### Root path resolving
Root path resolving can be a tricky concept if you've not used it before. Simply put, it is a method by which Push figures out where the files should go, relative to where they are in your project.
-If, for instance, an SFTP connection has been defined in the settings file for your workspace, and it has a `root` of `/home/myaccount/public`, files will be uploaded there as a base path.
+For example: an SFTP connection has been defined in the `.push.settings.jsonc` file for your workspace folder, and it has a `root` setting of `/home/myaccount/public`.
-If your workspace root was `/Users/myusername/Projects/myproject/`, the `push.settings.json` file was in the root of this workspace, and the file you uploaded was at `/contact/index.php`, then it would end up being uploaded to `/home/myaccount/public/contact/index.php`.
+If your **workspace** root was `/Users/myusername/Projects/myproject/`, the `push.settings.jsonc` file was directly within `myproject/`, and the file you uploaded was at `/contact/index.php`, then it would end up being uploaded to `/home/myaccount/public/contact/index.php`.
-### On demand uploading
-There are a few methods you can use to upload on demand. Two of which are the command palette, and the context menu in the file explorer, seen below:
+### On demand transfers
+There are a few methods you can use to transfer files on demand. Two of which are the command palette, and the context menu in the file explorer, seen below:
**Command palette:**
-![Uploading with the command Palette](https://raw.github.com/njpanderson/push/master/img/command-palette-upload.png)
+
**Context menu:**
-
+Right click on a file or folder within the explorer to see the following options:
+
+
The same two methods can be used to perform downloads, as well as most of the other features of Push.
+#### Environment labels
+
+A benefit of using the on demand transfers feature combined with environment aware service configurations is that the currently active environment will show in the status bar. For example, the following environments are configured by default with Push:
+
+
+
+When a file is being edited, Push will remind you of the environment to which the open file would be transferred should you use on demand transfers. **Note** - This does not affect queued uploading or file watchers. The environment they are uploaded to will be determined by the individual file during the upload process.
+
+The default environments are optimised to work well with the VS Code default "blue" status bar, and if you would like to use your own colours, or even your own environment labels, the setting `envColours` can be edited.
+
### Queued uploading
Another great feature of Push is that it will keep a list of all files you have edited or are being watched within VS Code and let you upload them with a single shortcut. This defaults to `cmd-alt-p` (or `ctrl-alt-p` on Windows).
@@ -83,7 +108,7 @@ Use the above shortcut, or select **Upload queued items** in the command palette
### File watching
A third method of uploading files is to use the watch tool. This can be accessed from the explorer context menu:
-
+
Selecting this option will create a watcher for the file, or in the case of a folder, all of the files within it. Whenever any one of them is altered or created by either VS Code or another app, Push will attempt to upload them.
@@ -93,42 +118,94 @@ If `queueWatchedFiles` is set to `true`, then Push will instead queue the file f
If you loose track of which files and folders are being watched, either click on the ![Watching](https://raw.github.com/njpanderson/push/master/img/watching.png) icon in the status bar, or use the explorer window to check the currently watched files as well as the current upload queue.
-![Watch file list output](https://raw.github.com/njpanderson/push/master/img/explorer-window.png)
+
+
You can also remove items from the watch list or the upload queue from within this window, or clear the upload queue entirely.
+#### Watcher persistence
+
+If desired, watchers can persist across sessions of vscode. This means that when a watch is created, it will be recalled in its previous state if vscode is restarted or launched. To enable this feature, see the Push's `persistWatchers` setting.
+
+When enabled, up to 50 watchers are stored. If this limit is reached, watchers created or used the least recently will be removed and will need to be recreated as needed.
+
+To clear the entire list of stored watchers, see the **Purge all stored watchers** command in the palette.
+
## Service settings files
-To customise the server settings for a workspace, either use the context menu in the file explorer and choose **Create/edit Push configuration**, or add a file (by default, called `.push.settings.json`) to your workspace with the following format:
+To customise the server settings for a workspace, either use the context menu in the file explorer and choose **Create/edit Push configuration**, or add a file (by default, called `.push.settings.jsonc`) to your workspace with the following format:
```javascript
{
- "service": "[ServiceName]",
- "[ServiceName]": {
+ "env": "[active_env_name]",
+ "[env_name]": {
+ ...
+ },
+ "[another_env_name]": {
...
}
}
```
-Each available service has its own set of settings which are within the `[ServiceName]` object on the main server settings object. For instance, if using the `SFTP` service, your config might look something like this:
+Each available service has its own set of settings which are within the `[options]` object within a single environment object. For instance, if using the `SFTP` service, your config might look something like this:
```javascript
{
- // Service name here is "SFTP"
- "service": "SFTP",
- // "SFTP" here matches the service name
- "SFTP": {
- // SFTP Specific options
- "host": "upload.bobssite.com",
- "username": "bob"
- "password": "xxxxxxx",
- "root": "/home/bob",
- // Global service options
- "collisionUploadAction": "overwrite"
+ "env": "dev",
+ "dev": {
+ // "SFTP" here matches the service name
+ "service": "SFTP",
+ "options": {
+ // SFTP Specific options
+ "host": "upload.bobssite.com",
+ "username": "bob"
+ "password": "xxxxxxx",
+ "root": "/home/bob",
+ // Global service options
+ "collisionUploadAction": "overwrite"
+ }
}
}
```
+### Environments
+
+Service settings files support the concept of *environments*, i.e. multiple services and options to use in a single settings file. This means that you can change the active environment by altering the `env` property, avoiding the need to rewrite the settings file each time.
+
+For example, a service settings file may have two environments - `dev` and `prod`. This can be defined in the following manner:
+
+```javascript
+{
+ "env": "dev",
+ "dev": {
+ "service": "SFTP",
+ "options": {
+ // ... settings
+ }
+ },
+ "prod": {
+ "service": "File",
+ "options": {
+ // ... settings
+ }
+ }
+}
+```
+
+In the above example, the `dev` environment is active, and any files transferred within the scope of these settings will use the options defined within the `dev` object. Change the `env` property to `prod`, and files will then be transferred using the `prod` object.
+
+As with the above example, the service does not have to be the same for all of the environments. You could, for instance, upload to an SFTP server in `dev`, and to a File location in `prod`.
+
+#### Uploading with the queue or watchers
+
+The upload queue and watchers ultimately use the same process as the on demand uploader, including resolving a service settings file and using its data to perform a file transfer. The only thing to keep in mind is that if you have the environment label within the status bar enabled, it only applies to the currently edited file.
+
+If you don't need the environment label, or would prefer not to have it, it can be removed by altering the `useEnvLabel` setting.
+
+#### Changing the active environment
+
+To change the active environment for a service settings scope, simply choose "Push: Set service environment" from within the command menu. You can also edit the settings file manually.
+
### Multiple service settings files
When defining a server settings file, placing it in the root of your workspace will define those settings for the whole workspace. Push also supports adding server settings files to sub-diretories within your workspace. When uploading files from within any directory, Push will look for the nearest server settins file and use it for server-specific settings.
@@ -148,7 +225,7 @@ This is a very powerful feature which means multiple settings files can be defin
In the scenario above, if `filename.txt` and `filename2.txt` were both edited, the upload queue would have 2 items in it, and both would be uploaded using their individual settings files.
-**Note:** While this is a very useful feature, it does have one drawback - you cannot upload a path containing more than one settings file at a time. I.e. if a folder `base` has two subfolders, each with their own `.push.settings.json` file, the `base` folder cannot not be uploaded via the context menus.
+**Note:** While this is a very useful feature, it does have one drawback - you cannot upload a path containing more than one settings file at a time. I.e. if a folder `base` has two subfolders, each with their own `.push.settings.jsonc` file, the `base` folder cannot not be uploaded via the context menus.
## Available services
@@ -169,6 +246,16 @@ The SFTP service will upload files to remote SSH/SFTP servers.
| `sshGateway` | | If you can't connect directly to an SSH server, and must instead connect via an intermediary server — commonly known as a Gateway host — you can enter its details in here. The properties available are detailed below. |
| `debug` | `false` | In debug mode, extra information is sent from the underlying SSH client to the console. |
+#### Using password/key combinations for accounts that require it
+
+Please note that the underlying SSH library does not support configurable authentication orders, which means that it is currently fixed. An order mismatch may prevent successful connections. For reference, the order is:
+
+ - `password`
+ - `publickey`
+ - `keyboard-interactive` (not currently supported)
+
+This is the order in which authentication methods should be defined within the SSH daemon configuration, if possible.
+
#### `fileMode` as an array
The `fileMode` setting of the SFTP service can also be expressed as an array of glob strings and modes required. For instance:
@@ -212,7 +299,7 @@ The following settings are available:
The settings within `sshGateway` work in a similar way to the general SFTP settings.
-#### Settnig the `privateKey` while using `sshGateway`
+#### Setting the `privateKey` while using `sshGateway`
The `privateKey` setting for the parent SFTP object is assumed to be a file **on the gateway itself**. That is, when connecting to the gateway using the `sshGateway` settings, a connection is then made to the server using the parent SFTP settings. For instance:
@@ -267,9 +354,26 @@ The following options are available to all services:
Found a bug? Great! Let me know about it in the [Github issue tracker](https://github.com/njpanderson/push/issues) and I'll try to get back to you within a few days. It's a personal project of mine so I can't always reply quickly, but I'll do my best.
-### Help! Push deleted all my files, wiped my server and/or made my wife leave me!
+
+
+### **Help! Push deleted all my files, wiped my server and/or made my wife leave me!**
+
+First of all, that's terrible and of course I wouldn’t wish this on anyone. Secondly, if you do have a method by which I can replicate the problem, do let me know in a bug report and I will give it priority over any new features.
+
+Thirdly, please understand that I am not liable for any potential data loss on your server should you use this plugin. Push is not designed or coded to perform deletions of files (except for when it overwrites a file with a new one), and I have tested this plugin constantly during development, but there may still be bugs which could potentially cause data loss.
+
+If you are working in a production environment or have sensitive or mission critical files, it is *always* recommended to either use a dedicated file transfer application or a fixed, peer reviewed deployment process.
+
+
+## Contributing
+
+If you would like to contribute, Huzzah! Thank you, and please check out the [Contributing Guide](https://github.com/njpanderson/push/blob/develop/.github/CONTRIBUTING.md) first
+
+### Build status
-First of all, that's terrible and of course I wouldn’t wish this on anyone. Secondly, if you do have a method by which I can replicate the problem, do let me know in a bug report and I will give it priority over any new features. Thirdly, please understand that I am not liable for any potential data loss on your server should you use this plugin. Push is not designed or coded to perform deletions of files (except for when it overwrites a file with a new one), and I have tested this plugin constantly during development, but there may still be bugs which could potentially cause data loss.
+| `master` | `develop`
+| --- | --- |
+| [![master build Status](https://travis-ci.org/njpanderson/push.svg?branch=master)](https://travis-ci.org/njpanderson/push) | [![develop build Status](https://travis-ci.org/njpanderson/push.svg?branch=develop)](https://travis-ci.org/njpanderson/push) |
## Push in your language
@@ -281,6 +385,6 @@ Currently, Push supports the following languages which can be selected within th
| 🇯🇵 Japanese | `ja` | Poor | (Built in) |
| 🇮🇹 Italian | `it` | Low-Medum | (Built in) |
-If you’d like to help improve the quality of the existing translations, or add your own translation, please let me know and I would be happy to accommodate you. There are around 70 strings currently set into Push, and can be translated in a few hours by a native speaker.
+If you'd like to help improve the quality of the existing translations, or add your own translation, please let me know and I would be happy to accommodate you. There are around 70 strings currently set into Push, and can be translated in a few hours by a native speaker.
-Get in touch via the issues if you’re interested in helping to localise Push.
+Get in touch via the issues if you're interested in helping to localise Push.
diff --git a/extension.js b/extension.js
index 95180d5..3c736c6 100644
--- a/extension.js
+++ b/extension.js
@@ -21,7 +21,7 @@ let ui;
exports.activate = (context) => {
let subscriptions, sub;
- ui = new UI();
+ ui = new UI(context);
subscriptions = {
'push.upload': 'upload',
@@ -44,7 +44,10 @@ exports.activate = (context) => {
'push.startWatch': 'startWatch',
'push.stopWatch': 'stopWatch',
'push.clearWatchers': 'clearWatchers',
+ 'push.purgeStoredWatchers': 'purgeStoredWatchers',
'push.editServiceConfig': 'editServiceConfig',
+ 'push.createServiceConfig': 'createServiceConfig',
+ 'push.setServiceEnv': 'setServiceEnv',
'push.importConfig': 'importConfig'
};
diff --git a/img/env-status.png b/img/env-status.png
new file mode 100644
index 0000000..cd012bd
Binary files /dev/null and b/img/env-status.png differ
diff --git a/img/icon-with-label.png b/img/icon-with-label.png
new file mode 100644
index 0000000..1405bb1
Binary files /dev/null and b/img/icon-with-label.png differ
diff --git a/package-lock.json b/package-lock.json
index 38c10c3..cc20720 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
{
"name": "push",
- "version": "0.4.61",
+ "version": "0.5.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -13,8 +13,7 @@
"@types/node": {
"version": "6.0.106",
"resolved": "https://registry.npmjs.org/@types/node/-/node-6.0.106.tgz",
- "integrity": "sha512-U4Zv5fx7letrisRv6HgSSPSY00FZM4NMIkilt+IAExvQLuNa6jYVwCKcnSs2NqTN4+KDl9PskvcCiMce9iePCA==",
- "dev": true
+ "integrity": "sha512-U4Zv5fx7letrisRv6HgSSPSY00FZM4NMIkilt+IAExvQLuNa6jYVwCKcnSs2NqTN4+KDl9PskvcCiMce9iePCA=="
},
"abbrev": {
"version": "1.0.9",
@@ -134,7 +133,6 @@
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
- "dev": true,
"requires": {
"sprintf-js": "~1.0.2"
}
@@ -197,6 +195,12 @@
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
"integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU="
},
+ "assertion-error": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
+ "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
+ "dev": true
+ },
"assign-symbols": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz",
@@ -263,6 +267,12 @@
}
}
},
+ "babylon": {
+ "version": "7.0.0-beta.19",
+ "resolved": "https://registry.npmjs.org/babylon/-/babylon-7.0.0-beta.19.tgz",
+ "integrity": "sha512-Vg0C9s/REX6/WIXN37UKpv5ZhRi6A4pjHlpkE34+8/a6c2W1Q692n3hmc+SZG5lKRnaExLUbxtJ1SVT+KaCQ/A==",
+ "dev": true
+ },
"balanced-match": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
@@ -335,6 +345,17 @@
"inherits": "~2.0.0"
}
},
+ "bluebird": {
+ "version": "3.5.1",
+ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.1.tgz",
+ "integrity": "sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA==",
+ "dev": true
+ },
+ "boolbase": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+ "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24="
+ },
"brace-expansion": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.8.tgz",
@@ -429,6 +450,15 @@
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw="
},
+ "catharsis": {
+ "version": "0.8.9",
+ "resolved": "https://registry.npmjs.org/catharsis/-/catharsis-0.8.9.tgz",
+ "integrity": "sha1-mMyJDKZS3S7w5ws3klMQ/56Q/Is=",
+ "dev": true,
+ "requires": {
+ "underscore-contrib": "~0.3.0"
+ }
+ },
"center-align": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz",
@@ -440,6 +470,20 @@
"lazy-cache": "^1.0.3"
}
},
+ "chai": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-4.1.2.tgz",
+ "integrity": "sha1-D2RYS6ZC8PKs4oBiefTwbKI61zw=",
+ "dev": true,
+ "requires": {
+ "assertion-error": "^1.0.1",
+ "check-error": "^1.0.1",
+ "deep-eql": "^3.0.0",
+ "get-func-name": "^2.0.0",
+ "pathval": "^1.0.0",
+ "type-detect": "^4.0.0"
+ }
+ },
"chalk": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz",
@@ -483,6 +527,25 @@
"integrity": "sha1-tUc7M9yXxCTl2Y3IfVXU2KKci/I=",
"dev": true
},
+ "check-error": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz",
+ "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=",
+ "dev": true
+ },
+ "cheerio": {
+ "version": "1.0.0-rc.2",
+ "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.2.tgz",
+ "integrity": "sha1-S59TqBsn5NXawxwP/Qz6A8xoMNs=",
+ "requires": {
+ "css-select": "~1.2.0",
+ "dom-serializer": "~0.1.0",
+ "entities": "~1.1.1",
+ "htmlparser2": "^3.9.1",
+ "lodash": "^4.15.0",
+ "parse5": "^3.0.1"
+ }
+ },
"circular-json": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/circular-json/-/circular-json-0.3.3.tgz",
@@ -645,8 +708,7 @@
"commander": {
"version": "2.15.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz",
- "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==",
- "dev": true
+ "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag=="
},
"component-emitter": {
"version": "1.2.1",
@@ -728,6 +790,22 @@
"which": "^1.2.9"
}
},
+ "css-select": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz",
+ "integrity": "sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg=",
+ "requires": {
+ "boolbase": "~1.0.0",
+ "css-what": "2.1",
+ "domutils": "1.5.1",
+ "nth-check": "~1.0.1"
+ }
+ },
+ "css-what": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-2.1.0.tgz",
+ "integrity": "sha1-lGfQMsOM+u+58teVASUwYvh/ob0="
+ },
"dashdash": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
@@ -764,6 +842,15 @@
"is-obj": "^1.0.0"
}
},
+ "deep-eql": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz",
+ "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==",
+ "dev": true,
+ "requires": {
+ "type-detect": "^4.0.0"
+ }
+ },
"deep-is": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz",
@@ -827,6 +914,11 @@
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
},
+ "denodeify": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/denodeify/-/denodeify-1.2.1.tgz",
+ "integrity": "sha1-OjYof1A05pnnV3kBBSwubJQlFjE="
+ },
"diff": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz",
@@ -842,6 +934,44 @@
"esutils": "^2.0.2"
}
},
+ "dom-serializer": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.0.tgz",
+ "integrity": "sha1-BzxpdUbOB4DOI75KKOKT5AvDDII=",
+ "requires": {
+ "domelementtype": "~1.1.1",
+ "entities": "~1.1.1"
+ },
+ "dependencies": {
+ "domelementtype": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz",
+ "integrity": "sha1-vSh3PiZCiBrsUVRJJCmcXNgiGFs="
+ }
+ }
+ },
+ "domelementtype": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.0.tgz",
+ "integrity": "sha1-sXrtguirWeUt2cGbF1bg/BhyBMI="
+ },
+ "domhandler": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz",
+ "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==",
+ "requires": {
+ "domelementtype": "1"
+ }
+ },
+ "domutils": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz",
+ "integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=",
+ "requires": {
+ "dom-serializer": "0",
+ "domelementtype": "1"
+ }
+ },
"duplexer": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz",
@@ -909,6 +1039,11 @@
"once": "^1.4.0"
}
},
+ "entities": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.1.tgz",
+ "integrity": "sha1-blwtClYhtdra7O+AuQ7ftc13cvA="
+ },
"escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
@@ -1415,6 +1550,12 @@
"integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",
"dev": true
},
+ "get-func-name": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz",
+ "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=",
+ "dev": true
+ },
"get-value": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz",
@@ -1940,6 +2081,48 @@
"resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz",
"integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0="
},
+ "htmlparser2": {
+ "version": "3.9.2",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.9.2.tgz",
+ "integrity": "sha1-G9+HrMoPP55T+k/M6w9LTLsAszg=",
+ "requires": {
+ "domelementtype": "^1.3.0",
+ "domhandler": "^2.3.0",
+ "domutils": "^1.5.1",
+ "entities": "^1.1.1",
+ "inherits": "^2.0.1",
+ "readable-stream": "^2.0.2"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
+ },
+ "readable-stream": {
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
+ "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ }
+ }
+ },
"http-signature": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz",
@@ -2319,12 +2502,41 @@
"esprima": "^4.0.0"
}
},
+ "js2xmlparser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/js2xmlparser/-/js2xmlparser-3.0.0.tgz",
+ "integrity": "sha1-P7YOqgicVED5MZ9RdgzNB+JJlzM=",
+ "dev": true,
+ "requires": {
+ "xmlcreate": "^1.0.1"
+ }
+ },
"jsbn": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
"integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=",
"optional": true
},
+ "jsdoc": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-3.5.5.tgz",
+ "integrity": "sha512-6PxB65TAU4WO0Wzyr/4/YhlGovXl0EVYfpKbpSroSj0qBxT4/xod/l40Opkm38dRHRdQgdeY836M0uVnJQG7kg==",
+ "dev": true,
+ "requires": {
+ "babylon": "7.0.0-beta.19",
+ "bluebird": "~3.5.0",
+ "catharsis": "~0.8.9",
+ "escape-string-regexp": "~1.0.5",
+ "js2xmlparser": "~3.0.0",
+ "klaw": "~2.0.0",
+ "marked": "~0.3.6",
+ "mkdirp": "~0.5.1",
+ "requizzle": "~0.2.1",
+ "strip-json-comments": "~2.0.1",
+ "taffydb": "2.6.2",
+ "underscore": "~1.8.3"
+ }
+ },
"json-schema": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz",
@@ -2355,9 +2567,9 @@
"integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus="
},
"jsonc-parser": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-1.0.3.tgz",
- "integrity": "sha512-hk/69oAeaIzchq/v3lS50PXuzn5O2ynldopMC+SWBql7J2WtdptfB9dy8Y7+Og5rPkTCpn83zTiO8FMcqlXJ/g=="
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.0.1.tgz",
+ "integrity": "sha512-9w/QyN9qF1dTlffzkmyITa6qAYt6sDArlVZqaP+CXnRh66V73wImQGG8GIBkuas0XLAxddWEWYQ8PPFoK5KNQA=="
},
"jsonify": {
"version": "0.0.0",
@@ -2380,6 +2592,15 @@
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz",
"integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA=="
},
+ "klaw": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/klaw/-/klaw-2.0.0.tgz",
+ "integrity": "sha1-WcEo4Nxc5BAgEVEZTuucv4WGUPY=",
+ "dev": true,
+ "requires": {
+ "graceful-fs": "^4.1.9"
+ }
+ },
"lazy-cache": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz",
@@ -2439,11 +2660,18 @@
"type-check": "~0.3.2"
}
},
+ "linkify-it": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.0.3.tgz",
+ "integrity": "sha1-2UpGSPmxwXnWT6lykSaL22zpQ08=",
+ "requires": {
+ "uc.micro": "^1.0.1"
+ }
+ },
"lodash": {
"version": "4.17.10",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz",
- "integrity": "sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg==",
- "dev": true
+ "integrity": "sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg=="
},
"lodash.isequal": {
"version": "4.5.0",
@@ -2484,11 +2712,34 @@
"object-visit": "^1.0.0"
}
},
+ "markdown-it": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-8.4.2.tgz",
+ "integrity": "sha512-GcRz3AWTqSUphY3vsUqQSFMbgR38a4Lh3GWlHRh/7MRwz8mcu9n2IO7HOh+bXHrR9kOPDl5RNCaEsrneb+xhHQ==",
+ "requires": {
+ "argparse": "^1.0.7",
+ "entities": "~1.1.1",
+ "linkify-it": "^2.0.0",
+ "mdurl": "^1.0.1",
+ "uc.micro": "^1.0.5"
+ }
+ },
+ "marked": {
+ "version": "0.3.19",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-0.3.19.tgz",
+ "integrity": "sha512-ea2eGWOqNxPcXv8dyERdSr/6FmzvWwzjMxpfGB/sbMccXoct+xY+YukPD+QTUZwyvK7BZwcr4m21WBOW41pAkg==",
+ "dev": true
+ },
"math-random": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/math-random/-/math-random-1.0.1.tgz",
"integrity": "sha1-izqsWIuKZuSXXjzepn97sylgH6w="
},
+ "mdurl": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
+ "integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4="
+ },
"merge-stream": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-1.0.1.tgz",
@@ -2551,6 +2802,11 @@
"to-regex": "^3.0.2"
}
},
+ "mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="
+ },
"mime-db": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz",
@@ -2685,8 +2941,7 @@
"mute-stream": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz",
- "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=",
- "dev": true
+ "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s="
},
"nanomatch": {
"version": "1.2.9",
@@ -2738,6 +2993,14 @@
"remove-trailing-separator": "^1.0.1"
}
},
+ "nth-check": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.1.tgz",
+ "integrity": "sha1-mSms32KPwsQQmN6rgqxYDPFJquQ=",
+ "requires": {
+ "boolbase": "~1.0.0"
+ }
+ },
"oauth-sign": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz",
@@ -2893,11 +3156,25 @@
}
}
},
+ "os-homedir": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
+ "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M="
+ },
"os-tmpdir": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
"integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ="
},
+ "osenv": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz",
+ "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==",
+ "requires": {
+ "os-homedir": "^1.0.0",
+ "os-tmpdir": "^1.0.0"
+ }
+ },
"parse-glob": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/parse-glob/-/parse-glob-3.0.4.tgz",
@@ -2924,6 +3201,22 @@
}
}
},
+ "parse-semver": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/parse-semver/-/parse-semver-1.1.1.tgz",
+ "integrity": "sha1-mkr9bfBj3Egm+T+6SpnPIj9mbLg=",
+ "requires": {
+ "semver": "^5.1.0"
+ }
+ },
+ "parse5": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-3.0.3.tgz",
+ "integrity": "sha512-rgO9Zg5LLLkfJF9E6CCmXlSE4UVceloys8JrFqCcHloC3usd/kJCyPDwH2SOlzix2j3xaP9sUX3e8+kvkuleAA==",
+ "requires": {
+ "@types/node": "*"
+ }
+ },
"pascalcase": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz",
@@ -2945,6 +3238,12 @@
"integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=",
"dev": true
},
+ "pathval": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz",
+ "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=",
+ "dev": true
+ },
"pause-stream": {
"version": "0.0.11",
"resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz",
@@ -3050,8 +3349,7 @@
"process-nextick-args": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz",
- "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==",
- "dev": true
+ "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw=="
},
"progress": {
"version": "2.0.0",
@@ -3070,6 +3368,11 @@
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
"integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4="
},
+ "q": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz",
+ "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc="
+ },
"qs": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
@@ -3105,6 +3408,14 @@
}
}
},
+ "read": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz",
+ "integrity": "sha1-s9oZvQUkMal2cdRKQmNK33ELQMQ=",
+ "requires": {
+ "mute-stream": "~0.0.4"
+ }
+ },
"readable-stream": {
"version": "1.0.34",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz",
@@ -3201,6 +3512,23 @@
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8="
},
+ "requizzle": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/requizzle/-/requizzle-0.2.1.tgz",
+ "integrity": "sha1-aUPDUwxNmn5G8c3dUcFY/GcM294=",
+ "dev": true,
+ "requires": {
+ "underscore": "~1.6.0"
+ },
+ "dependencies": {
+ "underscore": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.6.0.tgz",
+ "integrity": "sha1-izixDKze9jM3uLJOT/htRa6lKag=",
+ "dev": true
+ }
+ }
+ },
"resolve": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz",
@@ -3521,8 +3849,7 @@
"sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
- "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
- "dev": true
+ "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
},
"ssh2": {
"version": "0.4.15",
@@ -3733,6 +4060,12 @@
"string-width": "^2.1.1"
}
},
+ "taffydb": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/taffydb/-/taffydb-2.6.2.tgz",
+ "integrity": "sha1-fLy2S1oUG2ou/CxdLGe04VCyomg=",
+ "dev": true
+ },
"tar": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz",
@@ -3878,6 +4211,11 @@
"punycode": "^1.4.1"
}
},
+ "tunnel": {
+ "version": "0.0.4",
+ "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.4.tgz",
+ "integrity": "sha1-LTeFoVjBdMmhbcLARuxfxfF0IhM="
+ },
"tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
@@ -3901,6 +4239,21 @@
"prelude-ls": "~1.1.2"
}
},
+ "type-detect": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
+ "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
+ "dev": true
+ },
+ "typed-rest-client": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-0.9.0.tgz",
+ "integrity": "sha1-92jMDcP06VDwbgSCXDaz54NKofI=",
+ "requires": {
+ "tunnel": "0.0.4",
+ "underscore": "1.8.3"
+ }
+ },
"typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
@@ -3913,6 +4266,11 @@
"integrity": "sha512-K7g15Bb6Ra4lKf7Iq2l/I5/En+hLIHmxWZGq3D4DIRNFxMNV6j2SHSvDOqs2tGd4UvD/fJvrwopzQXjLrT7Itw==",
"dev": true
},
+ "uc.micro": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.5.tgz",
+ "integrity": "sha512-JoLI4g5zv5qNyT09f4YAvEZIIV1oOjqnewYg5D38dkQljIzpPT296dbIGvKro3digYI1bkb7W6EP1y4uDlmzLg=="
+ },
"uglify-js": {
"version": "2.8.29",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.8.29.tgz",
@@ -3932,6 +4290,28 @@
"dev": true,
"optional": true
},
+ "underscore": {
+ "version": "1.8.3",
+ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.8.3.tgz",
+ "integrity": "sha1-Tz+1OxBuYJf8+ctBCfKl6b36UCI="
+ },
+ "underscore-contrib": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/underscore-contrib/-/underscore-contrib-0.3.0.tgz",
+ "integrity": "sha1-ZltmwkeD+PorGMn4y7Dix9SMJsc=",
+ "dev": true,
+ "requires": {
+ "underscore": "1.6.0"
+ },
+ "dependencies": {
+ "underscore": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.6.0.tgz",
+ "integrity": "sha1-izixDKze9jM3uLJOT/htRa6lKag=",
+ "dev": true
+ }
+ }
+ },
"union-value": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.0.tgz",
@@ -4019,10 +4399,15 @@
"resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz",
"integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI="
},
+ "url-join": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/url-join/-/url-join-1.1.0.tgz",
+ "integrity": "sha1-dBxsL0WWxIMNZxhGCSDQySIC3Hg="
+ },
"url-parse": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.4.1.tgz",
- "integrity": "sha512-x95Td74QcvICAA0+qERaVkRpTGKyBHHYdwL2LXZm5t/gBtCB9KQSO/0zQgSTYEV1p0WcvSg79TLNPSvd5IDJMQ==",
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.4.3.tgz",
+ "integrity": "sha512-rh+KuAW36YKo0vClhQzLLveoj8FwPJNu65xLb7Mrt+eZht0IPT0IXgSv8gcMegZ6NvjJUALf6Mf25POlMwD1Fw==",
"requires": {
"querystringify": "^2.0.0",
"requires-port": "^1.0.0"
@@ -4165,6 +4550,40 @@
"vinyl": "^0.4.3"
}
},
+ "vsce": {
+ "version": "1.46.0",
+ "resolved": "https://registry.npmjs.org/vsce/-/vsce-1.46.0.tgz",
+ "integrity": "sha512-cNQru5mXBPUtMDgwRNoespaR0gjdL09hV1KWktT5wkmTZfv0dSaAqqGAfr+2UI0aJTGttCcO3xKFQqtIcJpczA==",
+ "requires": {
+ "cheerio": "^1.0.0-rc.1",
+ "commander": "^2.8.1",
+ "denodeify": "^1.2.1",
+ "glob": "^7.0.6",
+ "lodash": "^4.17.10",
+ "markdown-it": "^8.3.1",
+ "mime": "^1.3.4",
+ "minimatch": "^3.0.3",
+ "osenv": "^0.1.3",
+ "parse-semver": "^1.1.1",
+ "read": "^1.0.7",
+ "semver": "^5.1.0",
+ "tmp": "0.0.29",
+ "url-join": "^1.1.0",
+ "vso-node-api": "6.1.2-preview",
+ "yauzl": "^2.3.1",
+ "yazl": "^2.2.2"
+ },
+ "dependencies": {
+ "tmp": {
+ "version": "0.0.29",
+ "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.29.tgz",
+ "integrity": "sha1-8lEl/w3Z2jzLDC3Tce4SiLuRKMA=",
+ "requires": {
+ "os-tmpdir": "~1.0.1"
+ }
+ }
+ }
+ },
"vscode": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/vscode/-/vscode-1.1.18.tgz",
@@ -4236,6 +4655,17 @@
}
}
},
+ "vso-node-api": {
+ "version": "6.1.2-preview",
+ "resolved": "https://registry.npmjs.org/vso-node-api/-/vso-node-api-6.1.2-preview.tgz",
+ "integrity": "sha1-qrNUbfJFHs2JTgcbuZtd8Zxfp48=",
+ "requires": {
+ "q": "^1.0.1",
+ "tunnel": "0.0.4",
+ "typed-rest-client": "^0.9.0",
+ "underscore": "^1.8.3"
+ }
+ },
"which": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
@@ -4272,6 +4702,12 @@
"mkdirp": "^0.5.1"
}
},
+ "xmlcreate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/xmlcreate/-/xmlcreate-1.0.2.tgz",
+ "integrity": "sha1-+mv3YqYKQT+z3Y9LA8WyaSONMI8=",
+ "dev": true
+ },
"xtend": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz",
diff --git a/package.json b/package.json
index e6c27fa..2d18977 100644
--- a/package.json
+++ b/package.json
@@ -2,13 +2,12 @@
"name": "push",
"displayName": "Push",
"description": "SFTP and File based uploading",
- "version": "0.4.61",
- "preview": true,
+ "version": "0.5.0",
"publisher": "njp-anderson",
"engines": {
"vscode": "^1.22.0"
},
- "license": "SEE LICENSE IN ",
+ "license": "SEE LICENSE IN LICENSE",
"icon": "img/icon.png",
"keywords": [
"sftp",
@@ -24,6 +23,10 @@
"categories": [
"Other"
],
+ "galleryBanner": {
+ "color": "#305875",
+ "theme": "dark"
+ },
"activationEvents": [
"*"
],
@@ -44,17 +47,17 @@
"njpPush.locale": {
"type": "string",
"default": "en_gb",
- "description": "Locale to use (Only en_gb supported at the moment.)"
+ "description": "Locale to use (See supported locales in the README)."
},
"njpPush.settingsFilename": {
"type": "string",
- "default": ".push.settings.json",
+ "default": ".push.settings.jsonc",
"description": "Defines the push settings JSON filename."
},
- "njpPush.debugMode": {
- "type": "boolean",
- "default": false,
- "description": "Enables debug mode, with more reporting."
+ "njpPush.settingsFileGlob": {
+ "type": "string",
+ "default": ".push.settings.json*",
+ "description": "Defines the glob used to find push settings JSON files."
},
"njpPush.privateSSHKey": {
"type": "string",
@@ -89,7 +92,7 @@
},
"njpPush.statusMessageColor": {
"type": "string",
- "default": "notification.infoBackground",
+ "default": "statusBar.foreground",
"description": "Set the colour of a status message (except progress). See https://code.visualstudio.com/docs/getstarted/theme-color-reference"
},
"njpPush.queueWatchedFiles": {
@@ -97,22 +100,56 @@
"default": false,
"description": "Set true to queue watched files on change instead of uploading them."
},
+ "njpPush.persistWatchers": {
+ "type": "boolean",
+ "default": false,
+ "description": "Set true to persist watchers across sessions of vscode."
+ },
"njpPush.autoUploadQueue": {
"type": "boolean",
"default": false,
"description": "Set true to automatically upload files that enter the queue. This allows for changes within VS Code to be upload on save, while not uploading changes from outside VS Code (like a watcher would)."
+ },
+ "njpPush.useEnvLabel": {
+ "type": "boolean",
+ "default": true,
+ "description": "Set true to show the currently active environment for the opened editor in the status bar."
+ },
+ "njpPush.envColours": {
+ "type": "object",
+ "default": {
+ "dev": "#62defd",
+ "stage": "#ffd08a",
+ "prod": "#f7ed00"
+ },
+ "description": "Defines the colours for specific environment names within the service settings file. The colour names as well as their values can be customised, and matching names will be coloured accordingly."
+ },
+ "njpPush.showChangelog": {
+ "type": "boolean",
+ "default": true,
+ "description": "Shows a changelog if Push upgrades either its minor or major version."
}
}
},
"commands": [
+ {
+ "command": "push.createServiceConfig",
+ "title": "Create Push service configuration",
+ "category": "Push"
+ },
{
"command": "push.editServiceConfig",
- "title": "Create/edit Push configuration",
+ "title": "Edit Push service configuration",
"category": "Push"
},
{
"command": "push.importConfig",
- "title": "Import Push configuration",
+ "title": "Import Push service configuration",
+ "category": "Push"
+ },
+ {
+ "command": "push.setServiceEnv",
+ "title": "Set service environment for current file",
"category": "Push"
},
{
@@ -230,6 +267,11 @@
"command": "push.clearWatchers",
"title": "Clear all Push watchers",
"category": "Push"
+ },
+ {
+ "command": "push.purgeStoredWatchers",
+ "title": "Purge all stored watchers (and clear current list)",
+ "category": "Push"
}
],
"menus": {
@@ -345,6 +387,10 @@
}
],
"commandPalette": [
+ {
+ "command": "push.setServiceEnv",
+ "when": "editorIsOpen && push:hasServiceContext"
+ },
{
"command": "push.uploadFolder",
"when": "false"
@@ -459,19 +505,24 @@
"postinstall": "node ./node_modules/vscode/bin/install",
"t": "mocha --reporter dot",
"tc": "istanbul cover --root ./src --dir ./coverage _mocha",
- "test": "mocha"
+ "test": "mocha",
+ "docs": "jsdoc -c .ide/jsdoc.json && node .ide/package-docs.js",
+ "publish": "npm run t && vsce publish",
+ "package": "vsce package"
},
"devDependencies": {
"@types/mocha": "^2.2.48",
"@types/node": "^6.0.106",
+ "chai": "^4.1.2",
"eslint": "^4.19.1",
"glob": "^7.1.2",
"istanbul": "^0.4.5",
- "typescript": "^2.8.3",
- "mocha": "^5.2.0"
+ "jsdoc": "^3.5.5",
+ "mocha": "^5.2.0",
+ "typescript": "^2.8.3"
},
"dependencies": {
- "jsonc-parser": "^1.0.3",
+ "jsonc-parser": "^2.0.1",
"micromatch": "^3.1.10",
"mkdirp": "^0.5.1",
"mockery": "^2.1.0",
@@ -479,6 +530,7 @@
"ssh2-sftp-client": "^1.1.0",
"tmp": "^0.0.33",
"url-parse": "^1.4.1",
+ "vsce": "^1.46.0",
"vscode": "^1.1.18"
}
}
diff --git a/src/Push.js b/src/Push.js
index 20a1e6c..53e31c9 100644
--- a/src/Push.js
+++ b/src/Push.js
@@ -1,6 +1,7 @@
const vscode = require('vscode');
+const semver = require('semver');
-const ServiceSettings = require('./lib/ServiceSettings');
+const packageJson = require('../package.json');
const Service = require('./lib/Service');
const PushBase = require('./lib/PushBase');
const Explorer = require('./lib/explorer/Explorer');
@@ -12,15 +13,34 @@ const SCM = require('./lib/SCM');
const channel = require('./lib/channel');
const utils = require('./lib/utils');
const i18n = require('./lang/i18n');
-
+const {
+ STATUS_PRIORITIES,
+ QUEUE_LOG_TYPES,
+ ENV_DEFAULT_STATUS_COLOR
+} = require('./lib/constants');
+
+/**
+ * Provides the main controller for Push.
+ */
class Push extends PushBase {
- constructor() {
+ constructor(context) {
super();
+ this.context = context;
+
this.didSaveTextDocument = this.didSaveTextDocument.bind(this);
this.setContexts = this.setContexts.bind(this);
- this.setEditorState = this.setEditorState.bind(this);
- this.refreshExplorerWatchList = this.refreshExplorerWatchList.bind(this);
+ this.didChangeActiveTextEditor = this.didChangeActiveTextEditor.bind(this);
+ this.onWatchUpdate = this.onWatchUpdate.bind(this);
+ this.onWatchChange = this.onWatchChange.bind(this);
+
+ // Rate limit functions
+ this.setEnvStatus = this.rateLimit(
+ Push.globals.ENV_TIMER_ID,
+ 500,
+ this.setEnvStatus,
+ this
+ );
this.initService();
@@ -28,34 +48,92 @@ class Push extends PushBase {
this.explorer = new Explorer(this.config);
this.scm = new SCM();
- this.watch = new Watch();
- this.watch.onWatchUpdate = this.refreshExplorerWatchList;
+ // Create watch class and set initial watchers
+ this.watch = new Watch(this.context.globalState);
+ this.watch.onWatchUpdate = this.onWatchUpdate;
+ this.watch.onChange = this.onWatchChange;
+ this.watch.recallByWorkspaceFolders(vscode.workspace.workspaceFolders);
this.queues = {};
// Set initial contexts
this.setContexts(true);
- this.setEditorState(vscode.window.activeTextEditor);
+
+ this.event('onDidChangeActiveTextEditor', vscode.window.activeTextEditor);
// Create event handlers
- vscode.workspace.onDidSaveTextDocument(this.didSaveTextDocument);
- vscode.workspace.onDidChangeConfiguration(this.setContexts);
- vscode.window.onDidChangeActiveTextEditor(this.setEditorState);
+ vscode.workspace.onDidSaveTextDocument((textDocument) => {
+ this.event('onDidSaveTextDocument', textDocument);
+ });
+
+ vscode.window.onDidChangeActiveTextEditor((textEditor) => {
+ this.event('onDidChangeActiveTextEditor', textEditor);
+ });
+
+ // Once initialised, do the new version check
+ this.checkNewVersion();
}
/**
- * Localised setConfig, also sets context and explorer config
+ * @description
+ * Checks for a major/minor version, and if found, loads the changelog,
+ * based on the users preferences.
*/
- setConfig() {
- super.setConfig();
+ checkNewVersion() {
+ const currentVersion = this.context.globalState.get(
+ Push.globals.VERSION_STORE,
+ '0.0.0'
+ );
- if (this.setContexts) {
- this.setContexts();
+ if (
+ ['major', 'minor'].indexOf(
+ semver.diff(currentVersion, packageJson.version)
+ ) !== -1
+ ) {
+ // Major or minor version mismatch
+ if (this.config.showChangelog) {
+ // Load the changelog
+ this.showChangelog();
+ }
+
+ // Display a small notice
+ vscode.window.showInformationMessage(
+ i18n.t('push_upgraded', packageJson.version),
+ (!this.config.showChangelog ? {
+ isCloseAffordance: true,
+ id: 'show_changelog',
+ title: i18n.t('show_changelog')
+ } : null)
+ ).then((option) => {
+ if (option && option.id === 'show_changelog') {
+ this.showChangelog();
+ }
+ });
}
- if (this.explorer) {
- this.explorer.setConfig(this.config);
- this.explorer.refresh();
+ // Retain next version
+ this.context.globalState.update(
+ Push.globals.VERSION_STORE,
+ packageJson.version
+ );
+ }
+
+ /**
+ * Shows the Push changelog in a markdown preview.
+ */
+ showChangelog() {
+ vscode.commands.executeCommand(
+ 'markdown.showPreview',
+ vscode.Uri.file(this.context.extensionPath + '/CHANGELOG.md')
+ );
+ }
+
+ /**
+ * Handle global/workspace configuration changes.
+ */
+ onDidChangeConfiguration() {
+ if (this.setContexts) {
+ this.setContexts();
}
}
@@ -67,6 +145,7 @@ class Push extends PushBase {
if (initial === true) {
this.setContext(Push.contexts.initialised, true);
+ this.setContext(Push.contexts.hasServiceContext, false);
}
}
@@ -74,10 +153,12 @@ class Push extends PushBase {
* Initialises the service class.
*/
initService() {
- this.settings = new ServiceSettings();
this.service = new Service({
onDisconnect: (hadError) => {
this.stopCancellableQueues(!!hadError, !!hadError);
+ },
+ onServiceFileUpdate: (uri) => {
+ this.event('onServiceFileUpdate', uri);
}
});
}
@@ -106,32 +187,89 @@ class Push extends PushBase {
});
}
+ /**
+ * Handle generic events (with a uri) and return settings.
+ * @param {string} eventType - Type of event, mainly for logging.
+ * @param {*} data
+ * @returns {object} settings object, obtained from the uri.
+ */
+ event(eventType, data) {
+ let uri, method, args, settings;
+
+ switch (eventType) {
+ case 'onDidSaveTextDocument':
+ uri = data && data.uri;
+ method = 'didSaveTextDocument';
+ args = [data];
+ break;
+
+ case 'onDidChangeActiveTextEditor':
+ if (!data) {
+ data = vscode.window.activeTextEditor;
+ }
+
+ uri = (data && data.document && data.document.uri);
+ method = 'didChangeActiveTextEditor'
+ args = [data];
+ break;
+
+ case 'onServiceFileUpdate':
+ uri = data;
+ break;
+
+ default:
+ throw new Error('Unrecognised event type');
+ }
+
+ utils.trace('Push#event', eventType);
+
+ if (!uri) {
+ // Bail if there's no uri
+ this.setEnvStatus(false);
+ return;
+ }
+
+ // Get current server settings for the editor
+ settings = this.service.settings.getServerJSON(
+ uri,
+ this.config.settingsFileGlob,
+ true,
+ true
+ );
+
+ if (this.config.useEnvLabel && settings && settings.data.env) {
+ // Check env state and add to status
+ this.setEnvStatus(settings.data.env);
+ } else {
+ this.setEnvStatus(false);
+ }
+
+ if (method) {
+ this[method].apply(this, args.concat([settings]));
+ }
+ }
+
/**
* Handle text document save events
* @param {textDocument} textDocument
*/
- didSaveTextDocument(textDocument) {
- let settings;
-
+ didSaveTextDocument(textDocument, settings) {
if (!textDocument.uri || !this.paths.isValidScheme(textDocument.uri)) {
// Empty or invalid URI
return false;
}
- this.settings.clear();
-
- settings = this.settings.getServerJSON(
- textDocument.uri,
- this.config.settingsFilename,
- true
+ utils.trace(
+ 'Push#didSaveTextDocument',
+ `Text document saved at ${textDocument.uri.fsPath}`
);
+ this.service.settings.clear();
+
if (this.config.uploadQueue && settings) {
// File being changed is within a service context - queue for uploading
this.queueForUpload(textDocument.uri)
.then(() => {
- this.setEditorState();
-
if (this.config.autoUploadQueue) {
this.execUploadQueue();
}
@@ -139,19 +277,24 @@ class Push extends PushBase {
}
}
- setEditorState(textEditor) {
+ didChangeActiveTextEditor(textEditor, settings) {
let uploadQueue = this.getQueue(Push.queueDefs.upload, false);
- if (!textEditor) {
- // Ensure textEditor defaults
- textEditor = vscode.window.activeTextEditor;
- }
-
if (!textEditor || !textEditor.document) {
// Bail if there's still no editor, or no document
return;
}
+ utils.trace(
+ 'Push#didChangeActiveTextEditor',
+ `Editor switched to ${textEditor.document.uri.fsPath}`
+ );
+
+ this.setContext(
+ Push.contexts.hasServiceContext,
+ !!settings
+ );
+
if (
uploadQueue &&
(uploadQueue.tasks.length > 0 && uploadQueue.tasks.length < 100)
@@ -170,9 +313,9 @@ class Push extends PushBase {
* @param {uri} uriContext
*/
configWithServiceSettings(uriContext) {
- const settings = this.settings.getServerJSON(
+ const settings = this.service.settings.getServerJSON(
uriContext,
- this.config.settingsFilename
+ this.config.settingsFileGlob
);
// Make a duplicate to avoid changing the original config
@@ -180,26 +323,7 @@ class Push extends PushBase {
if (settings) {
// Settings retrieved from JSON file within context
- if (!settings.data.service) {
- // No service defined
- channel.appendLocalisedError(
- 'service_not_defined',
- this.config.settingsFilename
- );
-
- return false;
- }
-
- if (!settings.data[settings.data.service]) {
- // Service defined but no config object found
- channel.appendLocalisedError(
- 'service_defined_but_no_config_exists',
- this.config.settingsFilename
- );
-
- return false;
- }
-
+ newConfig.env = settings.data.env;
newConfig.serviceName = settings.data.service;
newConfig.serviceFilename = settings.file,
newConfig.service = settings.data[newConfig.serviceName];
@@ -212,8 +336,7 @@ class Push extends PushBase {
return newConfig;
} else {
- // No settings for this context - show an error
- channel.appendLocalisedError('no_service_file', this.config.settingsFilename);
+ // No settings for this context
return false;
}
}
@@ -244,8 +367,11 @@ class Push extends PushBase {
) {
const queue = this.getQueue(queueDef, queueOptions);
+ utils.trace('Push#queue', 'Adding {tasks.length} task(s)');
+
// Add initial init to a new queue
if (queue.tasks.length === 0 && !queue.running) {
+ utils.trace('Push#queue', 'Adding initial queue task');
queue.addTask(new QueueTask(() => {
return this.service.activeService &&
this.service.activeService.init(queue.tasks.length);
@@ -278,6 +404,8 @@ class Push extends PushBase {
if (typeof task.onTaskComplete === 'function') {
task.onTaskComplete.call(this, result);
}
+
+ return result;
});
}
}),
@@ -316,7 +444,7 @@ class Push extends PushBase {
return Promise.all(uris.map(uri => {
let remotePath;
- remotePath = this.service.exec(
+ remotePath = this.service.execSync(
'convertUriToRemote',
this.configWithServiceSettings(uri),
[uri]
@@ -328,20 +456,25 @@ class Push extends PushBase {
return;
}
- this.queue([{
- method: 'put',
- actionTaken: 'uploaded',
- uriContext: uri,
- args: [uri, remotePath],
- id: remotePath + this.paths.getNormalPath(uri)
- }], false, Push.queueDefs.upload, {
+ this.queue(
+ [{
+ method: 'put',
+ actionTaken: 'uploaded',
+ uriContext: uri,
+ args: [uri, remotePath],
+ id: remotePath + this.paths.getNormalPath(uri)
+ }],
+ false,
+ Push.queueDefs.upload,
+ {
showStatus: true,
statusToolTip: (num) => {
return i18n.t('num_to_upload', num);
},
statusCommand: 'push.uploadQueuedItems',
emptyOnFail: false
- });
+ }
+ );
});
}));
}
@@ -385,36 +518,39 @@ class Push extends PushBase {
* @param {Uri} uri - Uri of the local file to compare.
*/
diffRemote(uri) {
- let config, tmpFile, remotePath;
+ return new Promise((resolve, reject) => {
+ let config, tmpFile, remotePath;
- tmpFile = utils.getTmpFile();
- config = this.configWithServiceSettings(uri);
- remotePath = this.service.exec(
- 'convertUriToRemote',
- config,
- [uri]
- );
+ tmpFile = utils.getTmpFile();
+ config = this.configWithServiceSettings(uri);
- // Use the queue to get a file then diff it
- return this.queue([{
- method: 'get',
- actionTaken: 'downloaded',
- uriContext: uri,
- args: [
- tmpFile,
- remotePath,
- 'overwrite'
- ],
- id: tmpFile + remotePath,
- onTaskComplete: () => {
- vscode.commands.executeCommand(
- 'vscode.diff',
+ remotePath = this.service.execSync(
+ 'convertUriToRemote',
+ config,
+ [uri]
+ );
+
+ // Use the queue to get a file then diff it
+ this.queue([{
+ method: 'get',
+ actionTaken: 'downloaded',
+ uriContext: uri,
+ args: [
tmpFile,
- uri,
- 'Diff: ' + this.paths.getBaseName(uri)
- );
- }
- }], true, Push.queueDefs.diff);
+ remotePath,
+ 'overwrite'
+ ],
+ id: tmpFile + remotePath,
+ onTaskComplete: () => {
+ vscode.commands.executeCommand(
+ 'vscode.diff',
+ tmpFile,
+ uri,
+ 'Diff: ' + this.paths.getBaseName(uri)
+ );
+ }
+ }], true, Push.queueDefs.diff).then(resolve, reject);
+ })
}
/**
@@ -452,7 +588,9 @@ class Push extends PushBase {
const queue = this.getQueue(queueDef);
if (queue.running) {
- return Promise.reject('Queue running.');
+ // Reject now - not necessarily a problem but remind functions not to
+ // start the queue while it's running
+ return Promise.reject(`The queue "${queue.id}" is already running.`);
}
// TODO: make channel clearing an option to turn on
@@ -461,8 +599,24 @@ class Push extends PushBase {
// Fetch and execute queue
return queue
.exec(this.service.getStateProgress)
- .then(() => {
- this.refreshExplorerQueues();
+ .then((result) => {
+ let log;
+
+ if (
+ result &&
+ (log = result[QUEUE_LOG_TYPES.success]) &&
+ (log.uploaded && log.uploaded.length)
+ ) {
+ // Clear result items from "upload" queue
+ this.remoteQueuedItemsByTransfer(
+ Push.queueDefs.upload,
+ log.uploaded
+ );
+ } else {
+ this.refreshExplorerQueues();
+ }
+
+ return result;
})
.catch((error) => {
this.refreshExplorerQueues();
@@ -488,14 +642,29 @@ class Push extends PushBase {
} else {
channel.appendLocalisedInfo('no_current_upload_queue');
}
+
+ }
+
+ /**
+ * Removes items from a queue matching the TransferResult paths.
+ * @param {object} queueDef - One of the Push.queueDefs items.
+ * @param {TransferResult[]} transferResults - Results to draw Uris from.
+ */
+ remoteQueuedItemsByTransfer(queueDef, transferResults) {
+ let queue = this.getQueue(queueDef, false);
+
+ if (queue) {
+ transferResults.forEach(result => queue.removeTaskByUri(result.src));
+ this.refreshExplorerQueues();
+ }
}
/**
* Removes a single item from a queue by its Uri.
* @param {object} queueDef - One of the Push.queueDefs items.
- * @param {*} uri - Uri of the item to remove.
+ * @param {uri} uri - Uri of the item to remove.
*/
- removeQueuedItem(queueDef, uri) {
+ removeQueuedUri(queueDef, uri) {
let queue = this.getQueue(queueDef, false);
if (queue) {
@@ -527,22 +696,71 @@ class Push extends PushBase {
/**
* Refresh the Push explorer watch list data.
*/
- refreshExplorerWatchList(watchList) {
+ onWatchUpdate(watchList) {
this.explorer.refresh({
watchList: watchList
});
}
+ /**
+ * Handles a general Watch change event
+ * @param {Uri} uri - The Uri of the changed file.
+ */
+ onWatchChange(uri) {
+ if (this.config.queueWatchedFiles) {
+ this.queueForUpload(uri);
+ } else {
+ this.upload(uri);
+ }
+ }
+
/**
* Sets the VS Code context for general Push states
* @param {string} context - Context item name
* @param {mixed} value - Context value
*/
setContext(context, value) {
+ utils.trace('Push#setContext', context, value);
vscode.commands.executeCommand('setContext', `push:${context}`, value);
return this;
}
+ /**
+ * Set the environment status message in the status bar
+ * @param {*} env
+ */
+ setEnvStatus(env = '') {
+ utils.trace('Push#setEnvStatus', `Setting environment label to ${env}`);
+
+ this.clearTimedExecution(Push.globals.ENV_TIMER_ID);
+
+ if (env) {
+ if (!this.statusEnv) {
+ // Create status for active environment
+ this.statusEnv = vscode.window.createStatusBarItem(
+ vscode.StatusBarAlignment.Left,
+ STATUS_PRIORITIES.ENV
+ );
+ }
+
+ this.statusEnv.text = '$(versions) ' + env;
+ this.statusEnv.tooltip = i18n.t('env_tooltip', env);
+
+ if (this.config.envColours[env]) {
+ this.statusEnv.color = this.config.envColours[env];
+ } else {
+ this.statusEnv.color = ENV_DEFAULT_STATUS_COLOR;
+ }
+
+ this.statusEnv.show();
+ } else {
+ if (this.statusEnv) {
+ // Hide the env status label, if it's been created
+ this.statusEnv.hide();
+ }
+ }
+ }
+
/**
* Stop any current queue operations.
* @param {boolean} force - Set `true` to force any current operations to stop.
@@ -576,6 +794,8 @@ class Push extends PushBase {
* stopping the queue.
*/
stopQueue(queueDef, force = false, silent = false) {
+ let queue = this.getQueue(queueDef);
+
return vscode.window.withProgress({
location: vscode.ProgressLocation.Window,
title: 'Push'
@@ -585,10 +805,24 @@ class Push extends PushBase {
timer;
progress.report({ message: i18n.t('stopping') });
+ utils.trace('Push#stopQueue', 'Stopping queue');
if (force) {
// Give X seconds to stop or force by restarting the active service
+ utils.trace('Push#stopQueue', 'Starting queue force stop');
+
+ // Set up timer for force stopping the queue after x seconds
timer = setTimeout(() => {
+ // Force stop the queue
+ utils.trace(
+ 'Push#stopQueue',
+ Push.globals.FORCE_STOP_TIMEOUT +
+ ' second(s) elapsed. Force stopping queue'
+ );
+
+ // Silently force complete the queue
+ queue.stop(true, true, true);
+
// Force restart the active service
this.service.restartServiceInstance();
@@ -606,8 +840,10 @@ class Push extends PushBase {
// Stop the queue
tasks.push(
- this.getQueue(queueDef).stop()
+ queue.stop()
.then((result) => {
+ utils.trace('Push#stopQueue', 'Queue stop resolve');
+
!silent && channel.appendLocalisedInfo(
'queue_cancelled',
queueDef.id
@@ -616,12 +852,14 @@ class Push extends PushBase {
return result;
})
.catch((error) => {
+ utils.trace('Push#stopQueue', 'Queue stop reject');
reject(error);
})
);
if (force) {
// Stop the service as well
+ utils.trace('Push#stopQueue', 'Adding force stop task');
tasks.push(
this.service.stop()
);
@@ -632,10 +870,12 @@ class Push extends PushBase {
// by the timeout (see above).
Promise.all(tasks)
.then((results) => {
+ utils.trace('Push#stopQueue', 'Queue stop tasks complete');
clearTimeout(timer);
resolve(results[0]);
})
.catch((error) => {
+ utils.trace('Push#stopQueue', 'Queue stop tasks failed');
clearTimeout(timer);
reject(error);
});
@@ -654,7 +894,7 @@ class Push extends PushBase {
tasks = [],
action, actionTaken;
- this.settings.clear();
+ this.service.settings.clear();
if (typeof uris === 'undefined') {
throw new Error('No files defined.');
@@ -701,15 +941,18 @@ class Push extends PushBase {
}
// Filter and add to the queue
- tasks.push(
- this.paths.filterUriByGlobs(uri, ignoreGlobs)
- .then((filteredUri) => {
- let config, remotePath;
+ tasks.push(((uri) => {
+ let config, remotePath;
- if (filteredUri !== false) {
- config = this.configWithServiceSettings(filteredUri);
+ if (!(config = this.configWithServiceSettings(uri))) {
+ return false;
+ }
- remotePath = this.service.exec(
+ return this.paths.filterUriByGlobs(uri, ignoreGlobs)
+ .then((filteredUri) => {
+ if (filteredUri !== false) {
+ // Uri is not being ignored. Continue...
+ remotePath = this.service.execSync(
'convertUriToRemote',
config,
[filteredUri]
@@ -736,8 +979,8 @@ class Push extends PushBase {
this.paths.getBaseName(uri)
);
}
- })
- );
+ });
+ })(uri));
});
if (tasks.length) {
@@ -780,12 +1023,14 @@ class Push extends PushBase {
ignoreGlobs = this.config.ignoreGlobs;
config = this.configWithServiceSettings(uri);
- remoteUri = this.service.exec(
+ remoteUri = this.service.execSync(
'convertUriToRemote',
config,
[uri]
);
+ utils.trace('Push#transferDirectory', `Transfering ${uri.fsPath} (${method})`);
+
if (method === 'put') {
// Recursively list local files and transfer each one
return this.paths.getDirectoryContentsAsFiles(
@@ -794,12 +1039,17 @@ class Push extends PushBase {
config.service.followSymlinks
)
.then((files) => {
+ utils.trace(
+ 'Push#transferDirectory',
+ `Found ${files.length} file(s) on local`
+ );
+
let tasks = files.map((uri) => {
let remotePath;
uri = vscode.Uri.file(uri);
- remotePath = this.service.exec(
+ remotePath = this.service.execSync(
'convertUriToRemote',
config,
[uri]
@@ -824,11 +1074,16 @@ class Push extends PushBase {
// Recursively list remote files and transfer each one
return this.service.exec('listRecursiveFiles', config, [remoteUri, ignoreGlobs])
.then((files) => {
+ utils.trace(
+ 'Push#transferDirectory',
+ 'Found ${files.length} file(s) on remote'
+ );
+
let tasks = files.map((file) => {
let uri;
file = file.pathName || file;
- uri = this.service.exec(
+ uri = this.service.execSync(
'convertRemoteToUri',
config,
[file]
@@ -858,7 +1113,7 @@ class Push extends PushBase {
ensureSingleService(uri) {
return new Promise((resolve, reject) => {
this.paths.getDirectoryContentsAsFiles(
- `${this.paths.getNormalPath(uri)}/**/${this.config.settingsFilename}`
+ `${this.paths.getNormalPath(uri)}/**/${this.config.settingsFileGlob}`
)
.then((files) => {
if (files.length > 1) {
@@ -914,13 +1169,16 @@ Push.queueDefs = {
};
Push.globals = {
- FORCE_STOP_TIMEOUT: 5 // In seconds
+ FORCE_STOP_TIMEOUT: 5, // In seconds
+ ENV_TIMER_ID: 'env-switch',
+ VERSION_STORE: 'Push:version'
};
Push.contexts = {
hasUploadQueue: 'hasUploadQueue',
initialised: 'initialised',
- activeEditorInUploadQueue: 'activeEditorInUploadQueue'
+ activeEditorInUploadQueue: 'activeEditorInUploadQueue',
+ hasServiceContext: 'hasServiceContext'
};
module.exports = Push;
diff --git a/src/UI.js b/src/UI.js
index 36aef36..ce8ddb5 100644
--- a/src/UI.js
+++ b/src/UI.js
@@ -29,7 +29,7 @@ class UI extends Push {
uri = this.paths.getFileSrc(context);
}
- super.removeQueuedItem(Push.queueDefs.upload, uri);
+ super.removeQueuedUri(Push.queueDefs.upload, uri);
}
queueGitChangedFiles() {
@@ -60,11 +60,11 @@ class UI extends Push {
if (this.paths.isDirectory(uri)) {
return this.ensureSingleService(uri)
.then(() => {
- return this.transferDirectory(uri, 'put');
+ return this.transferDirectory(uri, 'put').catch(this.catchError);
});
}
- return this.transfer(uri, 'put');
+ return this.transfer(uri, 'put').catch(this.catchError);
}
/**
@@ -79,11 +79,11 @@ class UI extends Push {
if (this.paths.isDirectory(uri)) {
return this.ensureSingleService(uri)
.then(() => {
- return this.transferDirectory(uri, 'get');
+ return this.transferDirectory(uri, 'get').catch(this.catchError);
});
}
- return this.transfer(uri, 'get');
+ return this.transfer(uri, 'get').catch(this.catchError);
}
/**
@@ -94,7 +94,7 @@ class UI extends Push {
*/
diff(uri) {
if ((uri = this.getValidUri(uri))) {
- this.diffRemote(uri);
+ return this.diffRemote(uri).catch(this.catchError);
}
}
@@ -109,13 +109,7 @@ class UI extends Push {
return false;
}
- this.watch.add(uri, (uri) => {
- if (this.config.queueWatchedFiles) {
- this.queueForUpload(uri);
- } else {
- this.upload(uri);
- }
- });
+ this.watch.add(uri);
}
/**
@@ -162,6 +156,13 @@ class UI extends Push {
this.watch.clear();
}
+ /**
+ * Purges all stored watchers within the contextual storage
+ */
+ purgeStoredWatchers() {
+ this.watch.purge();
+ }
+
cancelQueues() {
this.stopCancellableQueues();
}
@@ -170,12 +171,31 @@ class UI extends Push {
this.stopCancellableQueues(true);
}
+ /**
+ * @see Service#createServiceConfig
+ */
+ createServiceConfig(uri) {
+ if ((uri = this.getValidUri(uri))) {
+ this.service.editServiceConfig(uri, true);
+ } else {
+ utils.showLocalisedWarning('no_servicefile_context');
+ }
+ }
+
/**
* @see Service#editServiceConfig
*/
editServiceConfig(uri) {
if ((uri = this.getValidUri(uri))) {
- this.service.editServiceConfig(uri);
+ return this.service.editServiceConfig(uri);
+ } else {
+ utils.showLocalisedWarning('no_servicefile_context');
+ }
+ }
+
+ setServiceEnv(uri) {
+ if ((uri = this.getValidUri(uri))) {
+ return this.service.setConfigEnv(uri).catch(this.catchError);
} else {
utils.showLocalisedWarning('no_servicefile_context');
}
diff --git a/src/lang/en_gb.js b/src/lang/en_gb.js
index c811bfb..6668cf9 100644
--- a/src/lang/en_gb.js
+++ b/src/lang/en_gb.js
@@ -1,10 +1,10 @@
module.exports = {
- 'comm_push_settings1': 'Push settings file - generated on ${1}\n',
- 'comm_push_settings2': 'Note: Comments are supported within Push settings files\n',
+ 'comm_push_settings1': 'Push settings file - first generated on ${1}\n',
+ 'comm_push_settings2': 'Note: Optional items are commented out and can be uncommented if you wish to set them\n',
'comm_add_service_config': 'Add service configuration here...',
'comm_settings_imported': 'Settings imported from ${1}\n',
'no_locale': 'Locale "${1}" does not exist. Has a .js file been created for it?',
- 'upload_queue_disabled': 'Upload queue is disabled - check workspace settings.',
+ 'upload_queue_disabled': 'Upload queue is disabled. Please check workspace your current settings for njpPush.uploadQueue.',
'upload_queue_cleared': 'Upload queue cleared',
'select_workspace_root': 'Select a workspace root path:',
'running_tasks_in_queue': 'Running ${1} p{1:task:tasks} in queue...',
@@ -14,15 +14,17 @@ module.exports = {
'queue_running': 'Queue is running.',
'stopping_queue': 'Stopping queue...',
'queue_complete': 'Queue complete.',
- 'queue_items_failed': '${1} p{1:item:items} failed.',
- 'queue_items_actioned': '${1} p{1:item:items} ${2}.',
+ 'queue_items_fail': '${1} p{1:item:items} failed.',
+ 'queue_items_skip': '${1} p{1:item:items} skipped.',
+ 'queue_items_success': '${1} p{1:item:items} ${2}.',
'cancel': 'Cancel',
'enter_service_settings_filename': 'Enter a filename for the service settings file:',
'empty': 'Empty',
'empty_template': 'Empty template',
'select_service_type_template': 'Select a service type template.',
- 'filename_exists': 'The file ${1} already exists and appears to be newer.',
- 'filename_exists_mismatch': 'The file ${1} already exists and is of a different type.',
+ 'filename_exists': 'The file "${1}" already exists and appears to be newer.',
+ 'filename_exists_ignore_times': 'The file "${1}" already exists.',
+ 'filename_exists_mismatch': 'The file "${1}" already exists and is of a different type.',
'skip': 'Skip',
'stop': 'Stop',
'skip_uploading_default': 'Skip uploading the file (default)',
@@ -38,11 +40,15 @@ module.exports = {
'rename_all': 'Rename all',
'keep_all_existing_by_renaming_uploaded': 'Keep all existing files by renaming the uploaded files',
'transfer_cancelled': 'Transfer cancelled',
- 'no_service_file': 'A settings file could not be found within your project. Have you created a file with the name "${1}" yet?',
+ 'no_service_file': 'A settings file could not be found within your project. Have you created a file matching "${1}" yet?',
'service_not_defined': 'A transfer service was not defined within the settings file at "${1}".',
'service_defined_but_no_config_exists': 'A transfer service was defined but no configuration could be found within the file at "${1}".',
+ 'active_service_not_found': 'The active service "${1}" could not be found within the settings file at "${2}"',
+ 'no_service_env': 'The service file at "${1}" isn’t configured for environments. Please see the README on configuring services for multiple environments.',
+ 'single_env': 'The service file at "${1}" only contains one environment (${2}). It cannot be switched.',
'multiple_service_files_no_transfer': 'More than one service settings file was found within the selected directory. The transfer could not be completed.',
- 'service_setting_missing': 'Service setting file for type ${1} missing required setting: "${2}".',
+ 'service_name_invalid': 'The service name "${1}" within settings file "${2}" does not Exist. Please choose a valid service name.',
+ 'service_setting_missing': 'The service file at "${1}" for the "${2}" environment is missing the required ${3} option: "${4}". Push cannot continue without this setting.',
'cannot_action_ignored_file': 'Cannot ${1} file "${2}" - It matches one of the defined ignoreGlobs filters.',
'no_import_file': 'Config file not specified. Please either run this command from within a configuration file or from the explorer context menu.',
'import_file_not_supported': 'Configuration file format is not supported. Currently, only the Sublime SFTP format is supported.',
@@ -51,6 +57,7 @@ module.exports = {
'added_watch_for': 'Added watch for ${1}',
'removed_watch_for': 'Removed watch for ${1}',
'cleared_all_watchers': 'Cleared all watchers.',
+ 'purged_all_watchers': 'Purged all watchers from storage.',
'watched_paths': 'Watched paths:',
'path_with_trigger_count': '${1} (fired ${2} p{2:time:times})',
'no_paths_watched': 'No paths watched.',
@@ -63,14 +70,13 @@ module.exports = {
'sftp_could_not_connect_server': 'Could not connect to server host ${1}:${2}',
'sftp_client_connected': 'SFTP client connected to host ${1}:${2}',
'sftp_client_connected_gateway': 'SFTP client connected to host ${1}:${2} via gateway ${3}:${4}',
- 'sftp_missing_root': 'SFTP could not find or access the root path. Please check the "${1}" settings file.',
+ 'service_missing_root': 'Push could not find or access the root path. Please check the "${1}" settings file.',
'sftp_disconnected': 'SFTP client disconnected from host ${1}:${2}',
'sftp_enter_ssh_pass': 'Enter SSH password (will not be saved)',
'stopping': 'Stopping...',
'queue_force_stopped': 'Queue ${1} task force stopped after ${2} p{2:second:seconds}.',
'queue_cancelled': 'Queue ${1} cancelled',
'num_to_upload': '${1} p{1:item:items} to upload',
- 'upload_queue_disabled': 'Upload queue is disabled - check workspace settings.',
'items_queued_for_upload': 'Items queued for upload:',
'no_current_upload_queue': 'No current upload queue items',
'key_file_not_found': 'The private key file "${1}" could not be found. Does it exist in this location? Remember — shell path shortcuts like "~" cannot be used.',
@@ -83,5 +89,13 @@ module.exports = {
'error_from_gateway': 'Error occured on SSH gateway: "${1}"',
'invalid_uri_scheme': 'Sorry, the filesystem scheme "${1}" is not supported. Push cannot work on files of this type.',
'invalid_path': 'The path "${1}" does not appear to be valid and has been skipped.',
- 'no_servicefile_context': 'A folder/path context for creating a service file could not be found. Please either open a workspace or select "Create/edit Push configuration" from a folder or open file.'
+ 'no_servicefile_context': 'A folder/path context for creating a service file could not be found. Please either open a workspace or select "Create/edit Push configuration" from a folder or open file.',
+ 'env_tooltip': 'The "${1}" environment is active for the currently open file.',
+ 'select_env': 'Select an environment to activate',
+ 'error_writing_json': 'There was an error writing to the service JSON file. Error: "${1}".',
+ 'no_error': 'No error message.',
+ 'dir_read_error': 'The directory "${1}" could not be read.',
+ 'dir_read_error_with_error': 'The directory "${1}" could not be read. The full error was: "${2}".',
+ 'push_upgraded': 'Push has been upgraded to version ${1}.',
+ 'show_changelog': 'Show Changelog'
};
diff --git a/src/lang/i18n.js b/src/lang/i18n.js
index fb5bc61..e592b7b 100644
--- a/src/lang/i18n.js
+++ b/src/lang/i18n.js
@@ -1,6 +1,7 @@
const vscode = require('vscode');
const config = require('../lib/config');
+const PushError = require('../lib/PushError');
/**
* Internationalisation (i18n) class.
@@ -38,7 +39,7 @@ class i18n {
this._locale = locale;
return;
} catch(e) {
- throw new Error(this.t('no_locale', locale));
+ throw new PushError(this.t('no_locale', locale));
}
}
diff --git a/src/lang/it.js b/src/lang/it.js
index 9a5cf4e..b4e5f82 100644
--- a/src/lang/it.js
+++ b/src/lang/it.js
@@ -14,16 +14,18 @@ module.exports = {
'queue_running': 'La coda è in esecuzione.',
'stopping_queue': 'Arresto della coda ...',
'queue_complete': 'Coda completa.',
- 'queue_items_failed': '${1}p{1:elemento non riuscito.:articoli falliti.}',
- 'queue_items_actioned': '${1} p{1:oggetto:articoli} ${2}.',
+ 'queue_items_fail': '${1} p{1:elemento non riuscito.:articoli falliti.}',
+ 'queue_items_skip': '${1} p{1:oggetto:articoli} omettere.',
+ 'queue_items_success': '${1} p{1:oggetto:articoli} ${2}.',
'overwrite': 'Sovrascrivere',
'cancel': 'Annulla',
'enter_service_settings_filename': 'Immettere un nome file per il file delle impostazioni del servizio:',
'empty': 'Vuoto',
'empty_template': 'Modello vuoto',
'select_service_type_template': 'Seleziona un modello di tipo di servizio.',
- 'filename_exists': 'Il file ${1} esiste già.',
- 'filename_exists_mismatch': 'Il file ${1} esiste già ed è di un tipo diverso.',
+ 'filename_exists': 'Il file "${1}" esiste già.',
+ 'filename_exists_ignore_times': 'Il file "$ {1}" esiste già e sembra essere più recente.',
+ 'filename_exists_mismatch': 'Il file "${1}" esiste già ed è di un tipo diverso.',
'skip': 'Trascurare',
'stop': 'Fermare',
'skip_uploading_default': 'Salta il caricamento del file (predefinito)',
@@ -41,7 +43,7 @@ module.exports = {
'no_service_file': 'Non è stato possibile trovare un file di impostazioni nel tuo progetto. Hai già creato un file con il nome "${1}"?',
'service_not_defined': 'Un servizio di trasferimento non è stato definito all’interno del file delle impostazioni in "${1}".',
'multiple_service_files_no_transfer': 'Più di un file di impostazioni del servizio è stato trovato all’interno della directory selezionata. Il trasferimento non può essere completato.',
- 'service_setting_missing': 'File di impostazione del servizio per l’impostazione ${1} mancante richiesta: "${2}".',
+ 'service_setting_missing': 'Il file di servizio in "${1}" per l\'ambiente "${2}" manca l\'opzione ${3} richiesta: "${4}". Push non può continuare senza questa impostazione.',
'cannot_action_ignored_file': 'Impossibile ${1} il file "${2}" - Corrisponde a uno dei filtri ignoreGlobs definiti.',
'no_import_file': 'File di configurazione non specificato. Si prega di eseguire questo comando da un file di configurazione o dal menu di scelta rapida di Explorer.',
'import_file_not_supported': 'Il formato del file di configurazione non è supportato. Attualmente, è supportato solo il formato SFTP Sublime.',
@@ -60,7 +62,7 @@ module.exports = {
'sftp_class_description': 'Trasferimenti di file SFTP / SSH',
'sftp_could_not_connect_server': 'Impossibile connettersi all’host ${1}:${2} del server',
'sftp_client_connected': 'Client SFTP connesso all’host ${1}:${2}',
- 'sftp_missing_root': 'SFTP non ha potuto trovare o accedere al percorso root. Controllare il file delle impostazioni "${1}".',
+ 'service_missing_root': 'Push non ha potuto trovare o accedere al percorso root. Controllare il file delle impostazioni "${1}".',
'sftp_disconnected': 'Client SFTP disconnesso dall’host ${1}:${2}',
'sftp_enter_ssh_pass': 'Inserisci la password SSH (non verrà mantenuta)',
'stopping': 'Arresto ...',
@@ -71,5 +73,5 @@ module.exports = {
'items_queued_for_upload': 'Elementi in coda per il caricamento:',
'no_current_upload_queue': 'Nessun elemento di coda di caricamento corrente',
'key_file_not_found': 'Non è stato possibile trovare il file di chiave privata "${1}". Esiste in questa posizione? Ricorda: le scorciatoie del percorso della shell come "~" non possono essere utilizzate.',
- 'key_file_not_working': 'Il file della chiave privata in "${1}" non ha autenticato l’utente ${2}. Sei sicuro che questo sia il file chiave corretto e gli è stato dato l'accesso?'
+ 'key_file_not_working': 'Il file della chiave privata in "${1}" non ha autenticato l’utente ${2}. Sei sicuro che questo sia il file chiave corretto e gli è stato dato l\'accesso?'
};
diff --git a/src/lang/ja.js b/src/lang/ja.js
index 49eb68a..2a0d011 100644
--- a/src/lang/ja.js
+++ b/src/lang/ja.js
@@ -14,15 +14,17 @@ module.exports = {
'queue_running': 'キューは操作可能です。',
'stopping_queue': 'キューを停止しています...',
'queue_complete': 'キューが完了しました。',
- 'queue_items_failed': '${1}p{1:項目:件}が失敗しました',
- 'queue_items_actioned': '${1}p{1:項目:件}${2}',
+ 'queue_items_fail': '${1}p{1:項目:件}が失敗しました。',
+ 'queue_items_skip': '${1} p{1:項目:件}合格者。',
+ 'queue_items_success': '${1}p{1:項目:件}${2}。',
'cancel': 'キャンセル',
'enter_service_settings_filename': 'サービス設定ファイルのファイル名を入力します。',
'empty': '空の',
'empty_template': '空のテンプレート',
'select_service_type_template': 'サービスタイプテンプレートを選択します。',
- 'filename_exists': 'ファイル${1}は既に存在します。',
- 'filename_exists_mismatch': 'ファイル${1}はすでに存在し、異なるタイプです。',
+ 'filename_exists': 'ファイル"${1}"は既に存在します。',
+ 'filename_exists_ignore_times': 'ファイル"${1}"はすでに存在します。',
+ 'filename_exists_mismatch': 'ファイル"${1}"はすでに存在し、異なるタイプです。',
'skip': 'パス',
'stop': 'やめる',
'skip_uploading_default': 'ファイルのアップロードを渡す(デフォルト)',
@@ -41,7 +43,7 @@ module.exports = {
'no_service_file': 'プロジェクト内で設定ファイルが見つかりませんでした。 "${1}"という名前のファイルを作成しましたか?',
'service_not_defined': '転送ファイルが設定ファイル内で "${1}"に定義されていませんでした。',
'multiple_service_files_no_transfer': '選択したディレクトリ内に複数のサービス設定ファイルが見つかりました。 転送が完了できませんでした。',
- 'service_setting_missing': 'タイプ${1}のサービス設定ファイルに必要な設定がありません:${2}',
+ 'service_setting_missing': '"${2}"環境の "${1}"のサービスファイルに、必要な${3}オプション "${4}"がありません。 Pushはこの設定を行わないと続行できません。',
'cannot_action_ignored_file': 'ファイルを${1}できません${2} - 定義されたignoreGlobsフィルターの1つと一致します。',
'no_import_file': '設定ファイルが指定されていません。 このコマンドは、設定ファイルまたはエクスプローラのコンテキストメニューから実行してください。',
'import_file_not_supported': '構成ファイル形式はサポートされていません。 現在、Sublime SFTP形式のみがサポートされています。',
@@ -60,7 +62,7 @@ module.exports = {
'sftp_class_description': 'SFTP / SSHファイル転送',
'sftp_could_not_connect_server': 'サーバーホスト${1}:${2}に接続できませんでした',
'sftp_client_connected': 'ホスト${1}:${2}に接続されたSFTPクライアント',
- 'sftp_missing_root': 'SFTPがルートパスを見つけられない、またはアクセスできませんでした。 "${1}"設定ファイルを確認してください。',
+ 'service_missing_root': 'Pushがルートパスを見つけられない、またはアクセスできませんでした。 "${1}"設定ファイルを確認してください。',
'sftp_disconnected': 'ホスト${1}:${2}から切断されたSFTPクライアント',
'sftp_enter_ssh_pass': 'SSHパスワードを入力してください(保存されません)',
'stopping': '停止しています...',
diff --git a/src/lib/Configurable.js b/src/lib/Configurable.js
new file mode 100644
index 0000000..5664e4d
--- /dev/null
+++ b/src/lib/Configurable.js
@@ -0,0 +1,38 @@
+const vscode = require('vscode');
+
+const workspaceConfig = require('./config');
+
+class Configurable {
+ constructor() {
+ this.setConfig = this.setConfig.bind(this);
+
+ // Create event handlers
+ vscode.workspace.onDidChangeConfiguration(() => this.setConfig());
+
+ // Set the initial config
+ this.setConfig();
+ }
+
+ setConfig(config) {
+ let oldConfig;
+
+ this.config && (oldConfig = Object.assign({}, this.config));
+ this.config = config || workspaceConfig.get();
+
+ if (oldConfig) {
+ // Old config exists, fire onDidChange method.
+ this.onDidChangeConfiguration(this.config, oldConfig);
+ }
+ }
+
+ /**
+ * Fired whenever the config changes in any way.
+ * @param {object} config - The new, current configuration.
+ * @param {object|undefined} oldConfig - The previous configuration.
+ */
+ onDidChangeConfiguration(config, oldConfig) {
+ // Does nothing in the base class
+ }
+}
+
+module.exports = Configurable;
diff --git a/src/lib/Paths.js b/src/lib/Paths.js
index dfbc3f0..2a7b832 100644
--- a/src/lib/Paths.js
+++ b/src/lib/Paths.js
@@ -6,10 +6,11 @@ const glob = require('glob');
const ExtendedStream = require('./ExtendedStream');
const PathCache = require('../lib/PathCache');
+const PushError = require('../lib/PushError');
class Paths {
fileExists(file) {
- file = this.getNormalPath(file);
+ file = this.getNormalPath(file, 'file');
return fs.existsSync(file);
}
@@ -21,6 +22,19 @@ class Paths {
return this.pathCache;
}
+ /**
+ * Tests whether the first Uri or path is within the second.
+ * @param {Uri|string} path - Uri/Path to find.
+ * @param {Uri|string} rootUri - Uri/Path to find within.
+ */
+ pathInUri(path, rootUri) {
+ if (!path || !rootUri) {
+ return false;
+ }
+
+ return this.getNormalPath(path).startsWith(this.getNormalPath(rootUri));
+ }
+
/**
* Retrieves the current workspace root path from the active workspace.
*/
@@ -137,12 +151,27 @@ class Paths {
}, extend);
}
- listDirectory(dir, src = PathCache.sources.LOCAL, cache) {
+ /**
+ * @description
+ * List the contents of a single filesystem-accessible directory.
+ *
+ * `loc` provides a mechanism for distinguishing between multiple "sets" of
+ * caches. The distinction is arbitrary but generally recommended to use one
+ * of the `PathCache.sources` options for consistent recall.
+ *
+ * An existing `cache` instance (of PathCache) may be supplied. Otherwise,
+ * an instance specific to the Paths class is created.
+ * @param {string} dir - Directory to list
+ * @param {number} [loc=PathCache.sources.LOCAL] - `PathCache.sources` locations.
+ * @param {class} [cache] - Cache class instance
+ */
+ listDirectory(dir, loc = PathCache.sources.LOCAL, cache) {
// Use supplied cache or fall back to class instance
+ dir = this.stripTrailingSlash(dir);
cache = cache || this.getPathCache();
- if (cache.dirIsCached(src, dir)) {
- return Promise.resolve(cache.getDir(src, dir));
+ if (cache.dirIsCached(loc, dir)) {
+ return Promise.resolve(cache.getDir(loc, dir));
} else {
return new Promise((resolve, reject) => {
fs.readdir(dir, (error, list) => {
@@ -155,14 +184,14 @@ class Paths {
stats = fs.statSync(pathname);
cache.addCachedFile(
- src,
+ loc,
pathname,
(stats.mtime.getTime() / 1000),
(stats.isDirectory() ? 'd' : 'f')
);
});
- resolve(cache.getDir(src, dir));
+ resolve(cache.getDir(loc, dir));
});
});
}
@@ -258,24 +287,34 @@ class Paths {
/**
* Attempts to look for a file within a directory, recursing up through the path until
* the root of the active workspace is reached.
- * @param {string} file - The filename to look for.
+ * @param {string} file - The filename to look for. Supports globs.
* @param {string} startDir - The directory to start looking in.
+ * @returns {string|null} - Either the matched filename, or `null`.
*/
findFileInAncestors(file, startDir) {
- let loop = 0,
- rootPaths = this.getWorkspaceRootPaths();
-
- while (!fs.existsSync(startDir + path.sep + file)) {
+ let matches,
+ loop = 0,
+ rootPaths = this.getWorkspaceRootPaths(),
+ globOptions = {
+ matchBase: true,
+ follow: false,
+ nosort: true
+ };
+
+ // while (!fs.existsSync(startDir + path.sep + file)) {
+ while (!(matches = (glob.sync(startDir + path.sep + file, globOptions))).length) {
+ // console.log(glob.sync(startDir + path.sep + file));
if (rootPaths.indexOf(startDir) !== -1 || loop === 50) {
// dir matches any root paths or hard loop limit reached
return null;
}
+ // Strip off directory basename
startDir = startDir.substring(0, startDir.lastIndexOf(path.sep));
loop += 1;
}
- return path.join(startDir + path.sep + file);
+ return matches[0];
}
/**
diff --git a/src/lib/PushBase.js b/src/lib/PushBase.js
index 4d49c91..1618c8d 100644
--- a/src/lib/PushBase.js
+++ b/src/lib/PushBase.js
@@ -1,27 +1,17 @@
const vscode = require('vscode');
+const Configurable = require('./Configurable');
const Paths = require('./Paths');
+const PushError = require('./PushError');
const channel = require('./channel');
-const config = require('./config');
const i18n = require('../lang/i18n');
-class PushBase {
+class PushBase extends Configurable {
constructor() {
- this.paths = new Paths();
- this.setConfig = this.setConfig.bind(this);
-
- // Set initial config
- this.setConfig();
+ super();
- // Create event handlers
- vscode.workspace.onDidChangeConfiguration(this.setConfig);
- }
-
- /**
- * Sets the current configuration for the active workspace.
- */
- setConfig() {
- this.config = config.get();
+ this.paths = new Paths();
+ this.timers = {};
}
/**
@@ -63,27 +53,23 @@ class PushBase {
}
}
+ /**
+ * Writes content to a file and then opens it for editing.
+ * @param {string} content - Content to write to the file.
+ * @param {string} fileName - Filename to write to.
+ */
writeAndOpen(content, fileName) {
- // Write a file then open it
- if (typeof content !== 'string' && content.constructor === Object) {
- // Parse pure object content to JSON string
- content = JSON.stringify(content, null, '\t');
- }
-
- // Add comment to the top
- content =
- '// ' + i18n.t('comm_push_settings1', (new Date()).toString()) +
- '// ' + i18n.t('comm_push_settings2') +
- content;
-
- this.paths.writeFile(
+ // Write the file...
+ return this.paths.writeFile(
content,
fileName
)
.then((fileName) => {
+ // Open it
this.openDoc(fileName);
})
.catch((error) => {
+ // Append the error
channel.appendError(error);
});
}
@@ -114,6 +100,85 @@ class PushBase {
}
});
}
+
+ /**
+ * Catches (and potentially throws) general errors.
+ * @param {Error} error - Any object, inheriting from Error.
+ */
+ catchError(error) {
+ if (error instanceof PushError) {
+ // This is an expected exception, generated for user display.
+ channel.appendError(error);
+ } else if (typeof error !== 'undefined') {
+ // This is an unexpected or uncaught exception.
+ console.error(error);
+ throw error;
+ }
+ }
+
+ /**
+ * Converts a function to a rate-limited version of itself.
+ * @param {string} id
+ * @param {number} timeout
+ * @param {function} fn
+ * @param {*} context
+ * @see PushBase#setTimedExecution
+ */
+ rateLimit(id, timeout, fn, context = null) {
+ // Arguments supplied to new function will be used for eventual execution
+ return function() {
+ this.setTimedExecution.apply(this, [
+ id,
+ timeout,
+ fn,
+ context
+ ].concat([...arguments]));
+ }.bind(context);
+ }
+
+ /**
+ * @param {string} id - Identifier.
+ * @param {number} timeout - Timeout, in milliseconds.
+ * @param {function} fn - Function to call.
+ * @param {*} context - Context to apply to the function, if necessary.
+ * @param {...*} mixed - Arguments to provide to the function
+ * @description
+ * Will call the provided function within the provided context after `timeout`
+ * milliseconds. If called again with the same `id` before `timeout` has elapsed,
+ * the original request is cancelled and a new one is made.
+ * @returns {number} Timer id.
+ */
+ setTimedExecution(id, timeout, fn, context = null) {
+ let args = [];
+
+ // Clear any previously set timers with this id
+ this.clearTimedExecution(id);
+
+ if (arguments.length > 4) {
+ // Add arguments for calling
+ args = [...arguments].slice(4);
+ }
+
+ // Set a timer
+ this.timers[id] = setTimeout(() => {
+ // Call function with context and arguments
+ fn.apply(context, args)
+ }, timeout);
+
+ return this.timers[id];
+ }
+
+ /**
+ * Clears a previous set timed execution.
+ * @param {string} id - Identifier, as passed to {@link PushBase#setTimedExecution}.
+ */
+ clearTimedExecution(id) {
+ if (this.timers[id]) {
+ // Clear timer and delete the timer id
+ clearTimeout(this.timers[id]);
+ delete this.timers[id];
+ }
+ }
};
module.exports = PushBase;
diff --git a/src/lib/PushError.js b/src/lib/PushError.js
new file mode 100644
index 0000000..57fec04
--- /dev/null
+++ b/src/lib/PushError.js
@@ -0,0 +1,15 @@
+/**
+ * @description
+ * Push specific errors. Contain human readable (and user legible) errors only.
+ * For other error types (e.g. unexpected runtime errors) use the Error base.
+ */
+class PushError extends Error {
+ constructor(message = '') {
+ super();
+
+ this.name = 'PushError';
+ this.message = message;
+ }
+}
+
+module.exports = PushError;
diff --git a/src/lib/SCM.js b/src/lib/SCM.js
index 132fd89..f9cb9d3 100644
--- a/src/lib/SCM.js
+++ b/src/lib/SCM.js
@@ -73,7 +73,7 @@ class SCM {
}
});
} else {
- return Promise.reject(Error(`Unknown method ${method}`));
+ return Promise.reject(new Error(`Unknown method ${method}`));
}
}
@@ -186,4 +186,4 @@ SCM.errorStrings = [{
}
}];
-module.exports = SCM;
\ No newline at end of file
+module.exports = SCM;
diff --git a/src/lib/Service.js b/src/lib/Service.js
index 6bafd2d..ae8923d 100644
--- a/src/lib/Service.js
+++ b/src/lib/Service.js
@@ -3,11 +3,15 @@ const vscode = require('vscode');
const ServiceBase = require('../services/Base');
const ServiceSFTP = require('../services/SFTP');
const ServiceFile = require('../services/File');
+const ServiceSettings = require('./ServiceSettings');
+const ServiceType = require('./ServiceType');
const PushBase = require('./PushBase');
const Paths = require('./Paths');
+const PushError = require('./PushError');
const config = require('./config');
const channel = require('./channel');
const constants = require('./constants');
+const utils = require('./utils');
const i18n = require('../lang/i18n');
class Service extends PushBase {
@@ -16,6 +20,11 @@ class Service extends PushBase {
this.setOptions(options);
+ // Create ServiceSettings instance for managing the files
+ this.settings = new ServiceSettings({
+ onServiceFileUpdate: this.options.onServiceFileUpdate
+ });
+
this.getStateProgress = this.getStateProgress.bind(this);
this.services = {
@@ -29,18 +38,19 @@ class Service extends PushBase {
/**
* Edits (or creates) a server configuration file
- * @param {Uri} uri - Uri to start looking for a configuration file
+ * @param {Uri} uri - Uri to start looking for a configuration file.
+ * @param {boolean} forceCreate - Force servicefile creation. Has no effect
+ * if the service file is level with the contextual file.
*/
- editServiceConfig(uri) {
- let rootPaths, dirName, settingsFile,
- settingsFilename = this.config.settingsFilename;
+ editServiceConfig(uri, forceCreate) {
+ let rootPaths, dirName, settingsFile;
uri = this.paths.getFileSrc(uri);
dirName = this.paths.getDirName(uri, true);
// Find the nearest settings file
settingsFile = this.paths.findFileInAncestors(
- settingsFilename,
+ this.config.settingsFileGlob,
dirName
);
@@ -53,20 +63,30 @@ class Service extends PushBase {
rootPaths = this.paths.getWorkspaceRootPaths();
}
- if (settingsFile) {
+ /**
+ * If a settings file is found but forceCreate is true, then the file name
+ * prompt path is chosen.
+ *
+ * In the case that a service file exists in the _same_ location as the
+ * contextual file, it will still be edited (due to the logic within
+ * getFileNamePromp() based on resolving immediately unless `forceDialog`
+ * is true. This is intended behaviour as two service files should not
+ * exist within the same folder.
+ */
+ if (settingsFile && !forceCreate) {
// Edit the settings file found
- this.openDoc(settingsFile);
+ return this.openDoc(settingsFile);
} else {
// Produce a prompt to create a new settings file
- this.getFileNamePrompt(settingsFilename, rootPaths)
+ return this.getFileNamePrompt(this.config.settingsFilename, rootPaths)
.then((file) => {
if (file.exists) {
- this.openDoc(file.fileName);
+ return this.openDoc(file.fileName);
} else {
- this.writeAndOpen(
- (file.serviceType.label !== 'Empty' ?
- file.serviceType.settingsPayload :
- constants.DEFAULT_SERVICE_CONFIG
+ // Create the file
+ return this.writeAndOpen(
+ this.settings.createServerFileContents(
+ file.serviceType
),
file.fileName
);
@@ -75,6 +95,15 @@ class Service extends PushBase {
}
}
+ /**
+ * Sets the current server config environment.
+ * @param {Uri} uri - Contextual Uri for the related file.
+ * @see ServiceSettings#setConfigEnv
+ */
+ setConfigEnv(uri) {
+ return this.settings.setConfigEnv(uri, this.config.settingsFilename);
+ }
+
/**
* Imports a configuration file.
* @param {Uri} uri - Uri to start looking for a configuration file
@@ -216,30 +245,35 @@ class Service extends PushBase {
*/
setOptions(options) {
this.options = Object.assign({}, {
- onDisconnect: null
+ onDisconnect: null,
+ onServiceFileUpdate: null
}, options);
}
/**
- * Produce a list of the services available.
- * @return {array} List of the services.
+ * Produce a list of the services available, for use within a QuickPick dialog.
+ * @return {ServiceType[]} List of the services, including default settings payloads.
*/
getList() {
let options = [], service, settingsPayload;
for (service in this.services) {
settingsPayload = {
- service
+ 'env': 'default',
+ 'default': {
+ service
+ }
};
- settingsPayload[service] = this.getServiceDefaults(service);
+ settingsPayload.default.options = this.getServiceDefaults(service);
- options.push({
- label: service,
- description: this.services[service].description,
- detail: this.services[service].detail,
- settingsPayload
- });
+ options.push(new ServiceType(
+ service,
+ this.services[service].description,
+ this.services[service].detail,
+ settingsPayload,
+ this.services[service].required
+ ));
}
return options;
@@ -261,6 +295,25 @@ class Service extends PushBase {
configObject.serviceName !== this.config.serviceName
);
+ utils.trace(
+ 'Service#setConfig',
+ `Service config setting${restart ? ' (restarting service)' : ''}`
+ );
+
+ /**
+ * Check serviceName is correct.
+ * Done here instead of within ServiceSettings as this class knows
+ * more about the available services.
+ */
+ if (configObject && !this.services[configObject.serviceName]) {
+ // Service doesn't exist - return null and produce error
+ throw new PushError(i18n.t(
+ 'service_name_invalid',
+ configObject.serviceName,
+ configObject.serviceFilename
+ ));
+ }
+
this.config = Object.assign({}, config.get(), configObject);
if (restart) {
@@ -289,10 +342,35 @@ class Service extends PushBase {
this.activeService.setConfig(this.config);
// Run the service method with supplied arguments
- return this.activeService[method].apply(
+ let result = this.activeService[method].apply(
this.activeService,
args
);
+
+ if (!(result instanceof Promise)) {
+ throw new Error(
+ `Method ${method} does not return a Promise. This method cannot ` +
+ `be used with exec(). Try execSync()?`
+ );
+ }
+
+ return result;
+ }
+ }
+
+ execSync(method, config, args = []) {
+ // Set the current service configuration
+ this.setConfig(config);
+
+ if (this.activeService) {
+ // Set the active service's config
+ this.activeService.setConfig(this.config);
+
+ // Run the service method with supplied arguments
+ return this.activeService[method].apply(
+ this.activeService,
+ args
+ )
}
}
@@ -327,17 +405,18 @@ class Service extends PushBase {
startServiceInstance() {
if (this.config.serviceName && this.config.service) {
- console.log(`Instantiating service provider "${this.config.serviceName}"`);
-
- // Instantiate
- this.activeService = new this.services[this.config.serviceName]({
- onDisconnect: this.options.onDisconnect
- }, this.getServiceDefaults(this.config.serviceName));
+ utils.trace(
+ 'Service#startServiceInstance',
+ `Instantiating service provider "${this.config.serviceName}"`
+ );
- // Invoke settings validation
- this.activeService.validateServiceSettings(
- this.activeService.serviceValidation,
- this.config.service
+ // Instantiate service
+ this.activeService = new this.services[this.config.serviceName](
+ {
+ onDisconnect: this.options.onDisconnect
+ },
+ this.getServiceDefaults(this.config.serviceName),
+ this.services[this.config.serviceName].required
);
}
}
diff --git a/src/lib/ServiceSettings.js b/src/lib/ServiceSettings.js
index 2605334..8c87e9f 100644
--- a/src/lib/ServiceSettings.js
+++ b/src/lib/ServiceSettings.js
@@ -1,16 +1,34 @@
+const vscode = require('vscode');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const jsonc = require('jsonc-parser');
+const ServiceType = require('./ServiceType');
+const channel = require('../lib/channel');
+const PushError = require('../lib/PushError');
const Paths = require('../lib/Paths');
+const utils = require('../lib/utils');
+const i18n = require('../lang/i18n');
+const constants = require('./constants');
class ServiceSettings {
- constructor() {
+ constructor(options) {
+ this.setOptions(options);
this.settingsCache = {};
this.paths = new Paths();
}
+ /**
+ * Set class-specific options (Which have nothing to do with the config).
+ * @param {object} options
+ */
+ setOptions(options) {
+ this.options = Object.assign({}, {
+ onServiceFileUpdate: null
+ }, options);
+ }
+
/**
* Completely clear the cache by trashing the old object.
*/
@@ -18,68 +36,336 @@ class ServiceSettings {
this.settingsCache = {};
}
+ /**
+ * Creates the contents for a server settings file.
+ * @param {ServiceType} serviceType - Service type settings instance.
+ * @returns {string} - The file contents.
+ */
+ createServerFileContents(serviceType) {
+ let content, serviceJSON, serviceJSONLines,
+ defaults, requiredOptions, re,
+ state, prevState;
+
+ if (!(serviceType instanceof ServiceType)) {
+ throw new Error('serviceType argument is not an instance of ServiceType');
+ }
+
+ if (serviceType.label !== 'Empty') {
+ // Get string based JSON payload from service
+ serviceJSON = JSON.stringify(serviceType.settingsPayload, null, '\t');
+
+ // Format according to service defaults
+ serviceJSONLines = serviceJSON.split('\n');
+ defaults = Object.keys(serviceType.settingsPayload.default.options);
+ requiredOptions = Object.keys(serviceType.requiredOptions);
+
+ re = {
+ indents: /\t/g,
+ indentedLine: /^([\t]+)(.*)$/,
+ optionItem: /^([\t]*)(\s*\"(.+?)\":\s[^$]*)/,
+ optionCloser: /\t{3,}(]|})/
+ };
+
+ state = prevState = {
+ line: '',
+ indentLevel: 0,
+ inOption: false,
+ optionCommented: false,
+ inCommentedOption: false
+ };
+
+ /* Find each line and figure out whether to comment out default options
+ * This isn't the best way of doing this, as it relies heavily on indent
+ * levels and a consistent JSON format, but it works for all of the current
+ * use cases.
+ * TODO: improve with a proper AST parser or the node-jsonc-parser when available.
+ */
+ serviceJSONLines = serviceJSONLines.map((line) => {
+ let match = line.match(re.optionItem);
+
+ // Set various state options
+ state.line = line;
+ state.indentLevel = (line.match(re.indents) || []).length;
+ state.inOption = state.indentLevel >= 4;
+ state.optionCommented = false;
+
+ state.inCommentedOption = (
+ state.inOption && prevState.inCommentedOption || (
+ prevState.inCommentedOption && re.optionCloser.test(line)
+ )
+ );
+
+ if ((
+ state.indentLevel === 3 &&
+ match !== null &&
+ defaults.indexOf(match[3]) !== -1 &&
+ requiredOptions.indexOf(match[3]) === -1
+ ) || state.inCommentedOption) {
+ // Option is a default and it is not required
+ line = line.replace(re.indentedLine, '$1// $2');
+ state.optionCommented = true;
+ state.inCommentedOption = true;
+ }
+
+ prevState = Object.assign({}, state);
+
+ return line;
+ });
+ }
+
+ // Add comment to the top, then the contents
+ content =
+ '// ' + i18n.t('comm_push_settings1', (new Date()).toString()) +
+ '// ' + i18n.t('comm_push_settings2') +
+ ((serviceJSON && serviceJSONLines.join('\n')) || constants.DEFAULT_SERVICE_CONFIG);
+
+ return content;
+ }
+
+ getServerFile(dir, settingsFilename) {
+ // Find the settings file
+ let file = this.paths.findFileInAncestors(
+ settingsFilename,
+ dir
+ );
+
+ if (file !== '' && fs.existsSync(file)) {
+ // File isn't empty and exists - read and set into cache
+ return {
+ file,
+ contents: (fs.readFileSync(file, "UTF-8")).toString().trim()
+ };
+ }
+
+ return null;
+ }
+
/**
* @description
* Attempts to retrieve a server settings JSON file from the supplied URI,
* eventually ascending the directory tree to the root of the project.
* @param {object} uri - URI of the path in which to start looking.
* @param {string} settingsFilename - Name of the settings file.
+ * @param {boolean} [quiet=false] - Produce no errors if a settings file couldn't be
+ * found. (Will not affect subsequent errors.)
+ * @param {boolean} [refresh=false] - Set `true` to ensure a fresh copy of the JSON.
*/
- getServerJSON(uri, settingsFilename) {
+ getServerJSON(uri, settingsFilename, quiet = false, refresh = false) {
let uriPath = this.paths.getNormalPath(uri),
- file, fileContents, newFile, hash, digest;
+ settings, newFile, data, hash, digest;
// If the path isn't a directory, get its directory name
if (!this.paths.isDirectory(uriPath)) {
uriPath = path.dirname(uriPath);
}
- // Use a cached version, if it exists
- if (this.settingsCache[uriPath]) {
+ if (!refresh && this.settingsCache[uriPath]) {
+ // Return a cached version
+ utils.trace('ServiceSettings#getServerJSON', `Using cached settings for ${uriPath}`);
this.settingsCache[uriPath].newFile = false;
return this.settingsCache[uriPath];
}
- // Find the settings file
- file = this.paths.findFileInAncestors(
- settingsFilename,
- uriPath
- );
-
- if (file !== '' && fs.existsSync(file)) {
+ if (settings = this.getServerFile(uriPath, settingsFilename)) {
// File isn't empty and exists - read and set into cache
- fileContents = (fs.readFileSync(file, "UTF-8")).toString().trim();
-
- if (fileContents !== '') {
- try {
- hash = crypto.createHash('sha256');
- hash.update(file + '\n' + fileContents);
- digest = hash.digest('hex');
-
- // Check file is new by comparing existing hash
- newFile = (
- !this.settingsCache[uriPath] ||
- digest !== this.settingsCache[uriPath].hash
- );
+ try {
+ data = this.normalise(
+ jsonc.parse(settings.contents),
+ settings.file
+ );
- // Cache entry
- this.settingsCache[uriPath] = {
- file,
- fileContents,
- newFile,
- data: jsonc.parse(fileContents),
- hash: digest
+ hash = crypto.createHash('sha256');
+ hash.update(settings.file + '\n' + settings.contents);
+ digest = hash.digest('hex');
+
+ // Check file is new by comparing existing hash
+ newFile = (
+ !this.settingsCache[uriPath] ||
+ digest !== this.settingsCache[uriPath].hash
+ );
+
+ // Cache entry
+ this.settingsCache[uriPath] = {
+ file: settings.file,
+ fileContents: settings.contents,
+ newFile,
+ data,
+ hash: digest
+ };
+
+ return this.settingsCache[uriPath];
+ } catch(error) {
+ channel.appendError(error.message);
+ return null;
+ }
+ }
+
+ if (!quiet) {
+ channel.appendLocalisedError('no_service_file', settingsFilename);
+ }
+
+ return null;
+ }
+
+ setConfigEnv(uri, settingsFilename) {
+ return new Promise((resolve, reject) => {
+ let uriPath = this.paths.getNormalPath(uri),
+ environments, settings, jsonData;
+
+ // If the path isn't a directory, get its directory name
+ if (!this.paths.isDirectory(uriPath)) {
+ uriPath = path.dirname(uriPath);
+ }
+
+ // Find the nearest settings file
+ if (settings = this.getServerFile(uriPath, settingsFilename)) {
+ // Get JSON from file (as the actual JSON is required, not the computed settings)
+ jsonData = jsonc.parse(settings.contents);
+
+ if (!jsonData.env) {
+ channel.appendLocalisedError('no_service_env', settings.file);
+ reject();
+ }
+
+ // Produce prompt for new env
+ environments = Object.keys(jsonData).filter(
+ (key) => (key !== 'env')
+ ).map((key) => {
+ return {
+ label: key,
+ description: (key === jsonData.env ? '(selected)' : ''),
+ detail: this.getServerEnvDetail(jsonData, key)
};
+ });
+
+ if (!environments.length) {
+ channel.appendLocalisedError('no_service_env', settings.file);
+ return reject();
+ }
+
+ if (environments.length === 1) {
+ channel.appendLocalisedError(
+ 'single_env',
+ settings.file,
+ environments[0]
+ );
- return this.settingsCache[uriPath];
- } catch(e) {
- return null;
+ return reject();
}
+
+ return vscode.window.showQuickPick(
+ environments,
+ {
+ placeHolder: i18n.t('select_env')
+ }
+ ).then((env) => {
+ if (env === undefined || env.label === '') {
+ return;
+ }
+
+ try {
+ // Modify JSON document & Write changes
+ settings.newContents = jsonc.applyEdits(
+ settings.contents,
+ jsonc.modify(
+ settings.contents,
+ ['env'],
+ env.label,
+ {
+ formattingOptions: {
+ tabSize: 4
+ }
+ }
+ )
+ );
+
+ // Write back to the file
+ fs.writeFileSync(
+ settings.file,
+ settings.newContents,
+ 'UTF-8'
+ );
+
+ if (this.options.onServiceFileUpdate) {
+ this.options.onServiceFileUpdate(vscode.Uri.file(settings.file))
+ }
+ } catch(error) {
+ throw new PushError(i18n.t(
+ 'error_writing_json',
+ (error && error.message) || i18n.t('no_error')
+ ))
+ }
+ });
}
+ });
+ }
+
+ getServerEnvDetail(jsonData, key) {
+ switch (jsonData[key].service) {
+ case "SFTP":
+ return (jsonData[key].options && (
+ jsonData[key].options.host + (
+ (jsonData[key].options.port ? ':' + jsonData[key].options.port : '')
+ )
+ )) || '';
+
+ case "File":
+ return (jsonData[key].options && jsonData[key].options.root) || '';
}
+ }
- return null;
+ /**
+ * Normalises server data into a consistent format.
+ * @param {object} settings - Settings data as retrieved by JSON/C files
+ */
+ normalise(settings, filename) {
+ let serviceData, variant;
+
+ if (!settings.service) {
+ if (settings.env) {
+ // env exists - new style of service
+ if ((serviceData = settings[settings.env])) {
+ settings.service = serviceData.service;
+ settings[settings.service] = serviceData.options;
+
+ // Strip out service variants (to ensure they don't make it to Push)
+ for (variant in settings) {
+ if (
+ settings.hasOwnProperty(variant) &&
+ variant !== 'service' &&
+ variant !== settings.service &&
+ variant !== "env"
+ ) {
+ delete settings[variant];
+ }
+ }
+ } else {
+ // env defined, but it doesn't exist
+ throw new PushError(i18n.t(
+ 'active_service_not_found',
+ settings.env,
+ filename
+ ));
+ }
+ } else {
+ // No service defined
+ throw new PushError(i18n.t(
+ 'service_not_defined',
+ filename
+ ));
+ }
+ }
+
+ if (!settings[settings.service]) {
+ // Service defined but no config object found
+ throw new PushError(i18n.t(
+ 'service_defined_but_no_config_exists',
+ filename
+ ));
+ }
+
+ return settings;
}
}
-module.exports = ServiceSettings;
\ No newline at end of file
+module.exports = ServiceSettings;
diff --git a/src/lib/ServiceType.js b/src/lib/ServiceType.js
new file mode 100644
index 0000000..35253cc
--- /dev/null
+++ b/src/lib/ServiceType.js
@@ -0,0 +1,11 @@
+class ServiceType {
+ constructor(label, description, detail, settingsPayload, requiredOptions) {
+ this.label = label;
+ this.description = description;
+ this.detail = detail;
+ this.settingsPayload = settingsPayload;
+ this.requiredOptions = requiredOptions;
+ }
+}
+
+module.exports = ServiceType;
diff --git a/src/lib/Watch.js b/src/lib/Watch.js
index d8ecb61..42e0d6b 100644
--- a/src/lib/Watch.js
+++ b/src/lib/Watch.js
@@ -1,25 +1,43 @@
const vscode = require('vscode');
const WatchListItem = require('./WatchListItem');
+const Configurable = require('./Configurable');
const Paths = require('./Paths');
const channel = require('./channel');
const constants = require('./constants');
+const utils = require('./utils');
const i18n = require('../lang/i18n');
-class Watch {
+class Watch extends Configurable {
/**
* Class constructor
* @param {OutputChannel} channel - Channel for outputting information
*/
- constructor() {
+ constructor(stateStorage) {
+ super();
+
+ this.watchByWorkspaceFolders = this.recallByWorkspaceFolders.bind(this);
+
this.watchList = [];
this.paths = new Paths();
+ this.stateStorage = stateStorage;
+
+ vscode.workspace.onDidChangeWorkspaceFolders((event) => {
+ this.recallByWorkspaceFolders(event.added, event.removed);
+ });
/**
* Invoked when a watch list updates
+ * @event
*/
this.onWatchUpdate = null;
+ /**
+ * Invoced every time a watched Uri is triggered
+ * @event
+ */
+ this.onChange = null;
+
this.status = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Left,
constants.STATUS_PRIORITIES.WATCH
@@ -28,22 +46,114 @@ class Watch {
this.status.command = 'push.listWatchers';
}
+ onDidChangeConfiguration(config, oldConfig) {
+ if (config.persistWatchers && !oldConfig.persistWatchers) {
+ // persistWatchers is being turned on - add current watchers
+ this.watchList.forEach((item) => {
+ this.setInWatchStore(item.uri, true, !!(item.watcher));
+ });
+ }
+ }
+
+ /**
+ * @description
+ * Adds or removes stored watchers found to exist within the defined workspace
+ * folder(s). Use `add` to add watchers from the folders, `remove` to remove them.
+ * @param {WorkspaceFolder[]} add - Watchers matching paths within this workspace
+ * will be added.
+ * @param {WorkspaceFolder[]} remove - Watchers matching paths within this
+ * workspace will be removed.
+ */
+ recallByWorkspaceFolders() {
+ let watchList = this.stateStorage.get(Watch.constants.WATCH_STORE, []),
+ args = [...arguments || []];
+
+ if (!this.config.persistWatchers) {
+ return;
+ }
+
+ ['add', 'remove'].forEach((action, i) => {
+ args[i] && args[i].forEach((folder) => {
+ // Filter watch list by Uris in folder root then add watches
+ watchList
+ .filter(item => this.paths.pathInUri(
+ item.path,
+ folder.uri
+ ))
+ .forEach(item => this[action].apply(this, [
+ vscode.Uri.file(item.path),
+ item.enabled
+ ]));
+ });
+ });
+ }
+
+ setInWatchStore(uri, add, enabled = true) {
+ let watchList, path, index;
+
+ if (!this.config.persistWatchers) {
+ return;
+ }
+
+ watchList = this.stateStorage.get(Watch.constants.WATCH_STORE, []);
+ path = this.paths.getNormalPath(uri);
+ index = watchList.findIndex(item => item.path === path);
+
+ if (add) {
+ // Add/update an item
+ if (index !== -1) {
+ // Just update timestamp
+ watchList[index].date = Date.now();
+ watchList[index].enabled = enabled;
+ } else {
+ watchList.unshift({
+ path: this.paths.getNormalPath(uri),
+ date: (Date.now()),
+ enabled
+ });
+ }
+
+ // Make sure watchList isn't over max items
+ if (watchList.length > Watch.constants.WATCH_STORE_MAXLEN) {
+ utils.trace(
+ 'Watch#setInWatchStore',
+ `Watch list trunacated (${watchList.length})...`
+ );
+
+ // Order by date, descending
+ watchList.sort((a, b) => {
+ return b.date - a.date;
+ });
+
+ // Splice
+ watchList.splice((Watch.constants.WATCH_STORE_MAXLEN));
+ }
+ } else if (index !== -1) {
+ // Remove an item (so long as it exists)
+ watchList.splice(index, 1);
+ }
+
+ this.stateStorage.update(Watch.constants.WATCH_STORE, watchList);
+ }
+
/**
* Add (and activate) a new watcher.
* @param {Uri} uri - Uri to start watching.
- * @param {function} callback - Callback to fire on change event.
+ * @param {bool} [enabled=true] - Whether to enable the watcher.
*/
- add(uri, callback) {
+ add(uri, enabled = true) {
let item;
if ((item = this.find(uri)) === -1) {
// Watch doesn't already exist - add a new one
- this.watchList.push(this._createWatch(uri, callback));
+ this.watchList.push(this._createWatch(uri, enabled));
} else {
// Watch for this Uri already exists - re-instantiate the watcher
this.watchList[item].initWatcher();
}
+ this.setInWatchStore(uri, true);
+
channel.appendLocalisedInfo('added_watch_for', this.paths.getNormalPath(uri));
this._updateStatus();
}
@@ -61,6 +171,8 @@ class Watch {
channel.appendLocalisedInfo('removed_watch_for', this.paths.getNormalPath(uri));
}
+ this.setInWatchStore(uri, false);
+
this._updateStatus();
}
@@ -84,22 +196,33 @@ class Watch {
} else {
item.removeWatcher();
}
+
+ this.setInWatchStore(item.uri, true, on);
});
this._updateStatus();
}
/**
- * Clear the watchList and their watchers
+ * Clear the watch list and their watchers
*/
clear() {
- this.watchList.forEach((item) => item.removeWatcher());
+ this.watchList.forEach((item) => this.remove(item.uri));
this.watchList = [];
channel.appendLocalisedInfo('cleared_all_watchers');
this._updateStatus();
}
+ /**
+ * Purge all stored watchers (and clear the local watch list)
+ */
+ purge() {
+ this.clear();
+ this.stateStorage.update(Watch.constants.WATCH_STORE, []);
+ channel.appendLocalisedInfo('purged_all_watchers');
+ }
+
/**
* List all current watchers.
*/
@@ -124,12 +247,14 @@ class Watch {
/**
* Create a watch list item instance.
* @param {Uri} uri - Uri to watch.
+ * @param {bool} enable - Whether to enable the watcher item.
* @private
*/
- _createWatch(uri, callback) {
+ _createWatch(uri, enable) {
return new WatchListItem(
uri,
- callback
+ this.onChange,
+ enable
);
}
@@ -178,6 +303,11 @@ class Watch {
}
};
+Watch.constants = {
+ WATCH_STORE: 'Watch:watchList',
+ WATCH_STORE_MAXLEN: 50
+}
+
Watch.contexts = {
hasRunningWatchers: 'hasRunningWatchers',
hasStoppedWatchers: 'hasStoppedWatchers',
diff --git a/src/lib/WatchListItem.js b/src/lib/WatchListItem.js
index f2e2925..0c870af 100644
--- a/src/lib/WatchListItem.js
+++ b/src/lib/WatchListItem.js
@@ -4,7 +4,14 @@ const Paths = require('./Paths');
const paths = new Paths();
-const WatchListItem = function (uri, callback) {
+/**
+ * Creates a new WatchListItem item.
+ * @param {Uri} uri - Uri to watch. Can be either a file or directory.
+ * @param {function} callback - Callback function to run on file change.
+ * @param {boolean} enable - `true` to enable immediately, `false` otherwise.
+ * @constructor
+ */
+const WatchListItem = function (uri, callback, enable = true) {
this.uri = uri;
this.path = paths.getNormalPath(uri);
this.glob = this._createWatchGlob(uri);
@@ -13,9 +20,12 @@ const WatchListItem = function (uri, callback) {
};
this.callback = callback;
- this.initWatcher();
+ enable && this.initWatcher();
}
+/**
+ * Starts the internal watch process for this item.
+ */
WatchListItem.prototype.initWatcher = function () {
this.watcher = vscode.workspace.createFileSystemWatcher(
this.glob,
@@ -37,6 +47,9 @@ WatchListItem.prototype._watcherChangeApplied = function (uri) {
this.callback(uri);
}
+/**
+ * Removes the internal watch process
+ */
WatchListItem.prototype.removeWatcher = function () {
if (this.watcher) {
this.watcher.dispose();
@@ -44,6 +57,10 @@ WatchListItem.prototype.removeWatcher = function () {
}
}
+/**
+ * Creates a watch glob given the provided Uri.
+ * @param {Uri} uri - Uri to parse.
+ */
WatchListItem.prototype._createWatchGlob = function (uri) {
if (paths.isDirectory(uri)) {
return paths.stripTrailingSlash(paths.getNormalPath(uri)) +
diff --git a/src/lib/channel.js b/src/lib/channel.js
index cdc97b1..9924cb7 100644
--- a/src/lib/channel.js
+++ b/src/lib/channel.js
@@ -1,12 +1,15 @@
const vscode = require('vscode');
-const utils = require('./utils');
const configService = require('./config');
const i18n = require('../lang/i18n');
+const { TRANSFER_TYPES } = require('./constants');
+const Paths = require('./Paths');
+const PushError = require('./PushError');
class Channel {
constructor(name) {
this.channel = vscode.window.createOutputChannel(name);
+ this.paths = new Paths();
}
/**
@@ -17,20 +20,57 @@ class Channel {
return this.channel.appendLine.apply(this.channel, arguments);
}
+ /**
+ * @param {TransferResult} result - TransferResult instance.
+ * @description
+ * Produces a channel line to update users on the status of a single file.
+ * used throughout processes like uploading/downloading, etc.
+ */
+ appendTransferResult(result) {
+ let icon = this.getTransferIcon(result.type),
+ srcLabel;
+
+ if (result.options.srcLabel) {
+ srcLabel = result.options.srcLabel;
+ } else {
+ srcLabel = this.paths.getNormalPath(result.src);
+ }
+
+ if (result.error) {
+ return this.appendError(
+ // "!" is for errors
+ `${icon}! ${srcLabel} ` +
+ `(${result.error.message})`
+ );
+ }
+
+ if (result.status === true || result.status === false) {
+ return this.appendLine(
+ // The "icon" repeated is for confirmed, '~' is for skipped
+ `${(result.status ? icon + icon : '~~')} ` +
+ srcLabel
+ );
+ }
+ }
+
+ /**
+ * Returns an "icon" given one of the TRANSFER_TYPES types.
+ * @param {number} type - One of the {@link TRANSFER_TYPES} types.
+ */
+ getTransferIcon(type) {
+ return Channel.transferTypesMap[type] || '';
+ }
+
/**
* Produces a line formatted as an error (and also shows the output window).
- * @param {string} error - Error or string to show.
+ * @param {string|PushError} error - PushError or string to show.
*/
appendError(error) {
let message, config;
- if (error instanceof Error) {
+ if (error instanceof PushError) {
config = configService.get();
message = error.message;
-
- if (config.debugMode && error.fileName && error.lineNumber) {
- message += ` (${error.fileName}:${error.lineNumber})`;
- }
} else {
message = error;
}
@@ -44,20 +84,16 @@ class Channel {
* @description
* Produces a line formatted as an error (and also shows the output window).
* Uses localisation.
- * @param {string} error - Localised string key to show.
+ * @param {string|PushError} error - Localised string or PushError key to show.
* @param {...mixed} $2 - Replacement arguments as needed.
*/
appendLocalisedError(error) {
let message, config,
placeHolders = [...arguments].slice(1);
- if (error instanceof Error) {
+ if (error instanceof PushError) {
config = config.get();
message = i18n.t.apply(i18n, [error.message].concat(placeHolders));
-
- if (config.debugMode && error.fileName && error.lineNumber) {
- message += ` (${error.fileName}:${error.lineNumber})`;
- }
} else {
message = i18n.t.apply(i18n, [error].concat(placeHolders));
}
@@ -99,4 +135,9 @@ class Channel {
}
}
-module.exports = new Channel('Push');
\ No newline at end of file
+Channel.transferTypesMap = {
+ [TRANSFER_TYPES.PUT]: '>',
+ [TRANSFER_TYPES.GET]: '<'
+};
+
+module.exports = new Channel('Push');
diff --git a/src/lib/config.js b/src/lib/config.js
index f1f5fdf..9fafbb1 100644
--- a/src/lib/config.js
+++ b/src/lib/config.js
@@ -20,9 +20,8 @@ module.exports = {
// Augment configuration with computed settings
if ((!item || item === 'ignoreGlobs') && Array.isArray(config.ignoreGlobs)) {
- settingsGlob = `**/${config.settingsFilename}`;
+ settingsGlob = `**/${config.settingsFileGlob}`;
config.ignoreGlobs.push(settingsGlob);
-
// Ensure glob list only contains unique values
config.ignoreGlobs = tools.uniqArray(config.ignoreGlobs);
}
@@ -33,4 +32,4 @@ module.exports = {
return config;
}
-};
\ No newline at end of file
+};
diff --git a/src/lib/constants.js b/src/lib/constants.js
index f255e4b..17ed93b 100644
--- a/src/lib/constants.js
+++ b/src/lib/constants.js
@@ -7,18 +7,35 @@ module.exports = {
DEFAULT_SERVICE_CONFIG: [
'{',
- '\t"service": "[ServiceName]",',
- '\t"[ServiceName]": {',
- '\t\t\/\/ ' + i18n.t('comm_add_service_config'),
+ '\t"env": "default",',
+ '\t"default": {',
+ '\t\t"service": "[ServiceName]",',
+ '\t\t"options": {',
+ '\t\t\t\/\/ ' + i18n.t('comm_add_service_config'),
+ '\t\t}',
'\t}',
'}'
].join('\n'),
STATUS_PRIORITIES: {
- UPLOAD_QUEUE: 1,
- WATCH: 2,
- UPLOAD_STATUS: 3
+ ENV: 1,
+ UPLOAD_QUEUE: 2,
+ WATCH: 3,
+ UPLOAD_STATUS: 4
+ },
+
+ ENV_DEFAULT_STATUS_COLOR: 'statusBar.foreground',
+
+ TRANSFER_TYPES: {
+ PUT: 0,
+ GET: 1
+ },
+
+ QUEUE_LOG_TYPES: {
+ success: 0,
+ fail: 1,
+ skip: 2
},
TMP_FILE_PREFIX: 'vscode-push-tmp-'
-};
\ No newline at end of file
+};
diff --git a/src/lib/explorer/Explorer.js b/src/lib/explorer/Explorer.js
index 17a3216..6fc2edf 100644
--- a/src/lib/explorer/Explorer.js
+++ b/src/lib/explorer/Explorer.js
@@ -1,13 +1,15 @@
const vscode = require('vscode');
const Item = require('./Item');
+const Configurable = require('../Configurable');
const Paths = require('../Paths');
-class Explorer {
- constructor(config) {
+class Explorer extends Configurable {
+ constructor() {
+ super();
+
this.getChildren = this.getChildren.bind(this);
- this.setConfig(config);
this.data = {};
this.paths = new Paths();
@@ -15,16 +17,18 @@ class Explorer {
this.onDidChangeTreeData = this._onDidChangeTreeData.event;
}
- setConfig(config) {
- this.config = config;
+ onDidChangeConfiguration() {
+ this.refresh();
}
/**
* Refresh by firing the change event - will run getChildren etc again
*/
refresh(data) {
- this.data = Object.assign(this.data, data);
- this._onDidChangeTreeData.fire();
+ if (this.data && data) {
+ this.data = Object.assign(this.data, data);
+ this._onDidChangeTreeData.fire();
+ }
}
/**
diff --git a/src/lib/queue/Queue.js b/src/lib/queue/Queue.js
index a55ce8d..9162e89 100644
--- a/src/lib/queue/Queue.js
+++ b/src/lib/queue/Queue.js
@@ -4,8 +4,12 @@ const QueueTask = require('./QueueTask');
const utils = require('../utils');
const config = require('../config');
const channel = require('../channel');
-const constants = require('../constants');
+const TransferResult = require('../../services/TransferResult');
const i18n = require('../../lang/i18n');
+const {
+ STATUS_PRIORITIES,
+ QUEUE_LOG_TYPES
+} = require('../constants');
class Queue {
/**
@@ -30,7 +34,7 @@ class Queue {
this.status = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Left,
- constants.STATUS_PRIORITIES.UPLOAD_QUEUE
+ STATUS_PRIORITIES.UPLOAD_QUEUE
);
if (typeof this.options.statusCommand === 'string') {
@@ -164,10 +168,13 @@ class Queue {
this._setContext(Queue.contexts.running, true);
- if (this._tasks && this._tasks.length) {
+ if (this._tasks && this._tasks.length > 1) {
// Always report one less item (as there's an #init task added by default)
channel.appendLine(i18n.t('running_tasks_in_queue', (this._tasks.length - 1)));
+ // Initialise the results object
+ this._initQueueResults();
+
// Start progress interface
return vscode.window.withProgress({
location: vscode.ProgressLocation.Window,
@@ -219,7 +226,7 @@ class Queue {
}, 10);
// Execute all queue items in serial
- this.execQueueItems(
+ this._execQueueItems(
(results) => {
clearInterval(this.progressInterval);
this._updateStatus(false);
@@ -235,20 +242,12 @@ class Queue {
/**
* Executes all items within a queue in serial and invokes the callback on completion.
- * @param {function} fnCallback - Callback to invoke once the queue is empty
- * @param {array} results - Results object, populated by queue tasks
+ * @param {function} fnCallback - Callback to invoke once the queue is empty.
+ * @private
*/
- execQueueItems(fnCallback, results) {
+ _execQueueItems(fnCallback) {
let task;
- // Initialise the results object
- if (!results) {
- results = {
- success: {},
- fail: {}
- };
- }
-
if (this._tasks.length) {
// Further tasks to process
this.running = true;
@@ -261,113 +260,125 @@ class Queue {
.then((result) => {
this.currentTask = null;
- // Function/Promise was resolved
- if (result !== false) {
- // Add to success list if the result from the function is anything
- // other than `false`
- if (task.data.actionTaken) {
- if (!results.success[task.data.actionTaken]) {
- results.success[task.data.actionTaken] = [];
- }
+ if (result instanceof TransferResult) {
+ // Result is a TransferResult instance - log the transfer in channel
+ channel.appendTransferResult(result);
+ }
- results.success[task.data.actionTaken].push(result);
+ if (task.data.actionTaken) {
+ // Log the queue result if there is actionTaken data
+ if (result instanceof TransferResult) {
+ this.logQueueResult(
+ result.logType,
+ task.data.actionTaken,
+ result
+ );
+ } else {
+ this.logQueueResult(
+ (result !== false ? QUEUE_LOG_TYPES.success : QUEUE_LOG_TYPES.fail),
+ task.data.actionTaken,
+ result
+ );
}
}
// Loop
- this._loop(fnCallback, results);
+ this._loop(fnCallback);
})
.catch((error) => {
- // Function/Promise was rejected
this.currentTask = null;
- if (error instanceof Error) {
- // Thrown Errors will stop the queue as well as alerting the user
- channel.appendError(error);
- channel.show();
-
- // Stop queue
- this.stop(true, this.options.emptyOnFail)
- .then(() => {
- this.complete(results, fnCallback);
- });
-
- throw error;
- } else if (typeof error === 'string') {
- // String based errors add to fail list, but don't stop
- if (!results.fail[task.data.actionTaken]) {
- results.fail[task.data.actionTaken] = [];
- }
-
- results.fail[task.data.actionTaken].push(error);
+ // Thrown Errors will stop the queue as well as alerting the user
+ channel.appendError(error);
+ channel.show();
- channel.appendError(error);
+ // Stop queue
+ this.stop(true, this.options.emptyOnFail)
+ .then(() => this.complete())
+ .then(fnCallback);
- // Loop
- this._loop(fnCallback, results);
- }
+ throw error;
});
} else {
// Complete queue
- this.complete(results, fnCallback);
- this.reportQueueResults(results);
+ this.complete()
+ .then(fnCallback);
+ this.reportQueueResults();
}
}
/**
- * Looper function for #execQueueItems
- * @param {function} fnCallback - Callback function, as supplied to #execQueueItems.
- * @param {object} results - Results object, as supplied to #execQueueItems
+ * Looper function for #_execQueueItems
+ * @param {function} fnCallback - Callback function, as supplied to #_execQueueItems.
+ * @param {object} results - Results object, as supplied to #_execQueueItems.
+ * @private
*/
- _loop(fnCallback, results) {
+ _loop(fnCallback) {
this._tasks.shift();
- this.execQueueItems(fnCallback, results);
+ this._execQueueItems(fnCallback);
}
/**
* Stops a queue by removing all items from it.
+ * @param {boolean} [silent=false] - Use to prevent messaging channel.
+ * @param {boolean} [clearQueue=true] - Empty the queue of all its tasks.
+ * @param {boolean} [force=false] - Force stop, instead of waiting for a currently running task.
* @returns {promise} - A promise, eventually resolving when the current task
* has completed, or immediately resolved if there is no current task.
*/
- stop(silent = false, clearQueue = true) {
+ stop(silent = false, clearQueue = true, force = false) {
if (this.running) {
+ utils.trace('Queue#stop', `Stopping queue (silent: ${silent}, clear: ${clearQueue})`);
+
if (!silent) {
- // If this stop isn't an intentional use action, let's allow
- // for silence here.
channel.appendInfo(i18n.t('stopping_queue'));
}
if (clearQueue) {
// Remove all pending tasks from this queue
+ utils.trace('Queue#stop', 'Emptying tasks');
this.empty();
}
if (this.progressInterval) {
// Stop the status progress monitor timer
+ utils.trace('Queue#stop', 'Stopping progress monitor timer');
clearInterval(this.progressInterval);
}
if (this.execProgressReject) {
// Reject the globally assigned progress promise (if set)
+ utils.trace('Queue#stop', 'Rejecting global progress promise');
this.execProgressReject();
}
}
- return this.currentTask || Promise.resolve();
+ if (force) {
+ return this.complete();
+ }
+
+ if (this.currentTask && this.currentTask instanceof Promise) {
+ utils.trace('Queue#stop', 'Returning current task promise');
+ return this.currentTask;
+ }
+
+ utils.trace('Queue#stop', 'Returning immediately resolving promise');
+ return Promise.resolve();
}
/**
* Invoked on queue completion (regardless of success).
- * @param {mixed} results - Result data from the queue process
- * @param {function} fnCallback - Callback function to invoke
+ * @returns {Promise} - Resolved on completion.
*/
- complete(results, fnCallback) {
- this.running = false;
- this._setContext(Queue.contexts.running, false);
+ complete() {
+ return new Promise((resolve) => {
+ utils.trace('Queue#complete', 'Queue completion');
- if (typeof fnCallback === 'function') {
- fnCallback(results);
- }
+ this.running = false;
+ this._setContext(Queue.contexts.running, false);
+
+ resolve(this.results);
+ });
}
/**
@@ -383,33 +394,61 @@ class Queue {
return false;
}
+ /**
+ * Initialises the queue results object. Used once per queue run.
+ * @private
+ */
+ _initQueueResults() {
+ this.results = {
+ [QUEUE_LOG_TYPES.success]: {},
+ [QUEUE_LOG_TYPES.fail]: {},
+ [QUEUE_LOG_TYPES.skip]: {}
+ };
+ }
+
+ /**
+ * Logs a single queue result.
+ * @param {String} log - One of the QUEUE_LOG_TYPES logs.
+ * @param {String} actionTaken - Localised verb describing the action taken. e.g. 'uploaded'.
+ * @param {*} result - The result data.
+ */
+ logQueueResult(log, actionTaken, result) {
+ if (!this.results[log][actionTaken]) {
+ // Create empty log as none yet exists
+ this.results[log][actionTaken] = [];
+ }
+
+ // Push result
+ this.results[log][actionTaken].push(
+ result || null
+ );
+ }
+
/**
* Shows a message, reporting on the queue state once completed.
- * @param {object} results
*/
- reportQueueResults(results) {
+ reportQueueResults() {
let actionTaken,
+ log,
extra = [
i18n.t('queue_complete')
];
- for (actionTaken in results.fail) {
- if (results.fail[actionTaken].length) {
- channel.show(true);
+ for (log in QUEUE_LOG_TYPES) {
+ for (actionTaken in this.results[QUEUE_LOG_TYPES[log]]) {
+ if (this.results[QUEUE_LOG_TYPES[log]][actionTaken].length) {
+ channel.show(true);
+ }
+
+ extra.push(i18n.t(
+ 'queue_items_' + log,
+ this.results[QUEUE_LOG_TYPES[log]][actionTaken].length,
+ actionTaken
+ ));
}
-
- extra.push(i18n.t('queue_items_failed', results.fail[actionTaken].length));
}
- for (actionTaken in results.success) {
- extra.push(i18n.t(
- 'queue_items_actioned',
- results.success[actionTaken].length,
- actionTaken
- ));
- }
-
- if ((Object.keys(results.fail)).length) {
+ if ((Object.keys(this.results[QUEUE_LOG_TYPES.fail])).length) {
// Show a warning in a message window
utils.showWarning(extra.join(' '));
} else {
@@ -420,7 +459,6 @@ class Queue {
// Show completion in a message window
utils.showMessage(extra.join(' '));
}
-
}
}
@@ -470,6 +508,6 @@ class Queue {
Queue.contexts = {
itemCount: 'itemCount',
running: 'running'
-}
+};
module.exports = Queue;
diff --git a/src/lib/utils.js b/src/lib/utils.js
index a0a84f7..6397fab 100644
--- a/src/lib/utils.js
+++ b/src/lib/utils.js
@@ -1,14 +1,17 @@
const vscode = require('vscode');
const tmp = require('tmp');
const fs = require('fs');
+const path = require('path');
const config = require('./config');
const constants = require('./constants');
+const PushError = require('./PushError');
const i18n = require('../lang/i18n');
const utils = {
_timeouts: {},
_sb: null,
+ _debug: fs.existsSync(path.dirname(path.dirname(__dirname)) + path.sep + '.debug'),
/**
* Show an informational message using the VS Code interface
@@ -70,7 +73,8 @@ const utils = {
}
this._sb = new vscode.window.createStatusBarItem(
- vscode.StatusBarAlignment.left
+ vscode.StatusBarAlignment.left,
+ 1
);
this._sb.text = message;
@@ -83,7 +87,7 @@ const utils = {
}
this._timeouts.sb = setTimeout(() => {
- this._sb.hide();
+ this.hideStatusMessage();
this._timeouts.sb = null;
}, (removeAfter * 1000));
}
@@ -96,7 +100,7 @@ const utils = {
*/
hideStatusMessage() {
if (this._sb) {
- this._sb.hide();
+ this._sb.dispose();
}
},
@@ -117,14 +121,20 @@ const utils = {
}
},
- showFileCollisionPicker(name, callback, queueLength = 0) {
+ showFileCollisionPicker(
+ name,
+ callback,
+ queueLength = 0,
+ placeHolder
+ ) {
let options = [
utils.collisionOpts.skip,
utils.collisionOpts.rename,
utils.collisionOpts.stop,
utils.collisionOpts.overwrite,
- ],
- placeHolder = i18n.t('filename_exists', name);
+ ];
+
+ placeHolder = placeHolder || i18n.t('filename_exists', name);
if (queueLength > 1) {
// Add "all" options if there's more than one item in the current queue
@@ -285,6 +295,15 @@ const utils = {
}
return tmpobj.name;
+ },
+
+ trace(id) {
+ if (this._debug) {
+ console.log(
+ (new Date).toLocaleTimeString() +
+ `[${id}] - "${[...arguments].slice(1).join(', ')}"`
+ );
+ }
}
};
@@ -317,7 +336,7 @@ utils.collisionOptsAll = {
};
utils.errors = {
- stop: new Error(i18n.t('transfer_cancelled'))
+ stop: new PushError(i18n.t('transfer_cancelled'))
};
module.exports = utils;
diff --git a/src/services/Base.js b/src/services/Base.js
index 76ca7a3..1515cb6 100644
--- a/src/services/Base.js
+++ b/src/services/Base.js
@@ -5,16 +5,23 @@ const tmp = require('tmp');
const utils = require('../lib/utils');
const Paths = require('../lib/Paths');
const channel = require('../lib/channel');
+const PushError = require('../lib/PushError');
const i18n = require('../lang/i18n');
+/**
+ * Base service class. Intended to be extended by any new service
+ * @param {object} options - Service options.
+ * @param {object} serviceDefaults - Default service options.
+ */
class ServiceBase {
- constructor(options, serviceDefaults) {
+ constructor(options, serviceDefaults, serviceRequired) {
this.setOptions(options);
this.type = '';
this.queueLength = 0;
this.progress = null;
this.serviceDefaults = serviceDefaults;
+ this.serviceRequired = serviceRequired;
this.config = {};
this.persistCollisionOptions = {};
this.channel = channel;
@@ -47,6 +54,8 @@ class ServiceBase {
* @param {object} config
*/
setConfig(config) {
+ let validation;
+
this.config = config;
if (this.config.service) {
@@ -54,6 +63,22 @@ class ServiceBase {
this.config.service = this.mergeWithDefaults(
this.config.service
);
+
+ if (this.serviceRequired) {
+ // Invoke basic settings validation on required fields
+ if ((validation = this.validateServiceSettings(
+ this.serviceRequired,
+ this.config.service
+ )) !== true) {
+ throw new PushError(i18n.t(
+ 'service_setting_missing',
+ this.config.serviceFilename,
+ this.config.env,
+ this.type,
+ validation
+ ));
+ };
+ }
}
}
@@ -77,13 +102,7 @@ class ServiceBase {
for (key in spec) {
if (spec.hasOwnProperty(key)) {
if (!settings[key]) {
- channel.appendLocalisedError(
- 'service_setting_missing',
- this.type,
- key
- );
-
- return false;
+ return key;
}
}
}
@@ -171,8 +190,10 @@ class ServiceBase {
* Run intial tasks - executed once before a subsequent commands in a new queue.
*/
init(queueLength) {
+ utils.trace('ServiceBase#init', `Initialising`);
this.persistCollisionOptions = {};
this.queueLength = queueLength;
+
return Promise.resolve(true);
}
@@ -243,7 +264,11 @@ class ServiceBase {
* collision actions.
*/
checkCollision(source, dest, defaultCollisionOption) {
- let collisionType, timediff;
+ let collisionType, hasCollision, timediff;
+
+ if (!source) {
+ throw new Error('Source file must exist');
+ }
// Dest file exists - get time difference
if (dest) {
@@ -253,11 +278,12 @@ class ServiceBase {
);
}
- if (dest &&
- (
- (this.config.service.testCollisionTimeDiffs && timediff < 0) ||
- !this.config.service.testCollisionTimeDiffs
- )) {
+ hasCollision = (
+ (this.config.service.testCollisionTimeDiffs && timediff < 0) ||
+ !this.config.service.testCollisionTimeDiffs
+ );
+
+ if (dest && hasCollision) {
// Destination file exists and difference means source file is older
if (dest.type === source.type) {
collisionType = 'normal';
@@ -282,7 +308,12 @@ class ServiceBase {
return utils.showFileCollisionPicker(
source.name,
this.persistCollisionOptions.normal,
- this.queueLength
+ this.queueLength,
+ (
+ (!this.config.service.testCollisionTimeDiffs) ?
+ i18n.t('filename_exists_ignore_times', source.name) :
+ null
+ )
);
}
} else {
diff --git a/src/services/File.js b/src/services/File.js
index f47c1e8..5797807 100644
--- a/src/services/File.js
+++ b/src/services/File.js
@@ -3,47 +3,74 @@ const fs = require('fs');
const path = require('path');
const ServiceBase = require('./Base');
+const TransferResult = require('./TransferResult');
const utils = require('../lib/utils');
const ExtendedStream = require('../lib/ExtendedStream');
const PathCache = require('../lib/PathCache');
+const PushError = require('../lib/PushError');
const i18n = require('../lang/i18n');
+const { TRANSFER_TYPES } = require('../lib/constants');
const SRC_REMOTE = PathCache.sources.REMOTE;
const SRC_LOCAL = PathCache.sources.LOCAL;
+/**
+ * Filesystem based uploading.
+ */
class File extends ServiceBase {
- constructor(options, defaults) {
- super(options, defaults);
+ constructor(options, defaults, required) {
+ super(options, defaults, required);
this.mkDir = this.mkDir.bind(this);
+ this.checkServiceRoot = this.checkServiceRoot.bind(this);
this.type = 'File';
this.pathCache = new PathCache();
this.writeStream = null;
this.readStream = null;
-
- // Define File validation rules
- this.serviceValidation = {
- root: true
- };
}
init(queueLength) {
return super.init(queueLength)
+ .then(this.checkServiceRoot)
.then(() => {
return this.pathCache.clear();
});
}
+ /**
+ * Attempt to list the root path to ensure it exists
+ * @param {SFTP} client - SFTP client object.
+ * @param {function} resolve - Promise resolver function.
+ */
+ checkServiceRoot() {
+ if (fs.existsSync(this.config.service.root)) {
+ return true;
+ }
+
+ throw new PushError(
+ i18n.t('service_missing_root', this.config.settingsFilename)
+ );
+ }
+
/**
* Put a single file to the remote location.
* @param {uri} local - Local Uri or Readable stream instance.
* @param {uri} remote - Remote Uri.
*/
put(local, remote) {
+ if (!this.paths.fileExists(local)) {
+ // Local file doesn't exist. Immediately resolve with failing TransferResult
+ return Promise.resolve(new TransferResult(
+ local,
+ new PushError(i18n.t('file_not_found', this.paths.getBaseName(local))),
+ TRANSFER_TYPES.PUT
+ ));
+ }
+
// Perform transfer from local to remote, setting root as defined by service
return this.transfer(
- File.transferTypes.PUT,
+ TRANSFER_TYPES.PUT,
local,
vscode.Uri.file(remote),
vscode.Uri.file(this.config.service.root),
@@ -62,9 +89,18 @@ class File extends ServiceBase {
collisionAction = collisionAction ||
this.config.service.collisionDownloadAction;
+ if (!this.paths.fileExists(remote)) {
+ // Remote file doesn't exist. Immediately resolve with failing TransferResult
+ return Promise.resolve(new TransferResult(
+ remote,
+ new PushError(i18n.t('file_not_found', this.paths.getBaseName(remote))),
+ TRANSFER_TYPES.PUT
+ ));
+ }
+
// Perform transfer from remote to local, setting root as base of service file
return this.transfer(
- File.transferTypes.GET,
+ TRANSFER_TYPES.GET,
vscode.Uri.file(remote),
local,
vscode.Uri.file(path.dirname(this.config.serviceFilename)),
@@ -74,7 +110,7 @@ class File extends ServiceBase {
/**
* Transfers a single file from location to another.
- * @param {number} transferType - One of the {@link File.transferTypes} types.
+ * @param {number} transferType - One of the {@link TRANSFER_TYPES} types.
* @param {Uri} src - Source Uri.
* @param {Uri} dest - Destination Uri.
* @param {Uri} root - Root directory. Used for validation.
@@ -84,40 +120,41 @@ class File extends ServiceBase {
destDir = path.dirname(destPath),
destFilename = path.basename(destPath),
rootDir = this.paths.getNormalPath(root),
- logPrefix = (transferType === File.transferTypes.PUT ? '>> ' : '<< '),
- srcType = (transferType === File.transferTypes.PUT ? SRC_REMOTE : SRC_LOCAL);
+ srcType = (transferType === TRANSFER_TYPES.PUT ? SRC_REMOTE : SRC_LOCAL);
this.setProgress(`${destFilename}...`);
return this.mkDirRecursive(destDir, rootDir, this.mkDir, ServiceBase.pathSep)
.then(() => this.getFileStats(
- (transferType === File.transferTypes.PUT) ? src : dest,
- (transferType === File.transferTypes.PUT) ? dest : src,
+ (transferType === TRANSFER_TYPES.PUT ? src : dest),
+ (transferType === TRANSFER_TYPES.PUT ? dest : src)
))
- .then((stats) => super.checkCollision(
- (transferType === File.transferTypes.PUT) ? stats.local : stats.remote,
- (transferType === File.transferTypes.PUT) ? stats.remote : stats.local,
- collisionAction
- ))
- .then((result) => {
+ .then((stats) => {
+ return super.checkCollision(
+ (transferType === TRANSFER_TYPES.PUT) ? stats.local : stats.remote,
+ (transferType === TRANSFER_TYPES.PUT) ? stats.remote : stats.local,
+ collisionAction
+ );
+ })
+ .then((collision) => {
// Figure out what to do based on the collision (if any)
- if (result === false) {
+ if (collision === false) {
// No collision, just keep going
- this.channel.appendLine(`${logPrefix}${destPath}`);
- return this.copy(src, destPath);
+ return this.copy(src, destPath, transferType);
} else {
- this.setCollisionOption(result);
+ this.setCollisionOption(collision);
- switch (result.option) {
+ switch (collision.option) {
case utils.collisionOpts.stop:
throw utils.errors.stop;
case utils.collisionOpts.skip:
- return false;
+ return new TransferResult(src, false, transferType, {
+ srcLabel: destPath
+ });
case utils.collisionOpts.overwrite:
- this.channel.appendLine(`${logPrefix}${destPath}`);
- return this.copy(src, destPath);
+ return this.copy(src, destPath, transferType);
case utils.collisionOpts.rename:
return this.list(destDir, srcType)
@@ -129,10 +166,10 @@ class File extends ServiceBase {
);
return this.transfer(
+ transferType,
src,
destPath,
- rootDir,
- logPrefix
+ rootDir
);
});
}
@@ -199,7 +236,7 @@ class File extends ServiceBase {
});
});
} else if (existing.type === 'f') {
- return Promise.reject(new Error(
+ return Promise.reject(new PushError(
i18n.t('directory_not_created_remote_mismatch', dir)
));
}
@@ -209,10 +246,10 @@ class File extends ServiceBase {
/**
* Return a list of the remote directory.
* @param {string} dir - Remote directory to list
- * @param {string} srcType - One of the {@link PathCache.sources} types.
+ * @param {string} loc - One of the {@link PathCache.sources} types.
*/
- list(dir, srcType = SRC_REMOTE) {
- return this.paths.listDirectory(dir, srcType, this.pathCache);
+ list(dir, loc = SRC_REMOTE) {
+ return this.paths.listDirectory(dir, loc, this.pathCache);
}
/**
@@ -260,8 +297,9 @@ class File extends ServiceBase {
* Copies a file or stream from one location to another.
* @param {*} src - Either a source Uri or a readable stream.
* @param {string} dest - Destination filename.
+ * @param {number} transferType - One of the TRANSFER_TYPES types.
*/
- copy(src, dest) {
+ copy(src, dest, transferType) {
return new Promise((resolve, reject) => {
function fnError(error) {
this.stop(() => reject(error));
@@ -269,8 +307,18 @@ class File extends ServiceBase {
// Create write stream & attach events
this.writeStream = fs.createWriteStream(dest);
+
this.writeStream.on('error', fnError.bind(this));
- this.writeStream.on('finish', resolve);
+
+ this.writeStream.on('finish', () => {
+ resolve(new TransferResult(
+ src,
+ true,
+ transferType, {
+ srcLabel: dest
+ }
+ ));
+ });
if (src instanceof vscode.Uri || typeof src === 'string') {
// Source is a VSCode Uri - create a read stream
@@ -297,9 +345,8 @@ File.defaults = {
root: '/'
};
-File.transferTypes = {
- PUT: 0,
- GET: 1
+File.required = {
+ root: true
};
module.exports = File;
diff --git a/src/services/SFTP.js b/src/services/SFTP.js
index 1d8f12e..7c5ab86 100644
--- a/src/services/SFTP.js
+++ b/src/services/SFTP.js
@@ -7,17 +7,23 @@ const homedir = require('os').homedir;
const micromatch = require("micromatch");
const ServiceBase = require('./Base');
+const TransferResult = require('./TransferResult');
const utils = require('../lib/utils');
+const PushError = require('../lib/PushError');
const PathCache = require('../lib/PathCache');
const channel = require('../lib/channel');
const i18n = require('../lang/i18n');
+const { TRANSFER_TYPES } = require('../lib/constants');
const SRC_REMOTE = PathCache.sources.REMOTE;
// const SRC_LOCAL = PathCache.sources.LOCAL;
-class ServiceSFTP extends ServiceBase {
- constructor(options, defaults) {
- super(options, defaults);
+/**
+ * SFTP transfers.
+ */
+class SFTP extends ServiceBase {
+ constructor(options, defaults, required) {
+ super(options, defaults, required);
this.mkDir = this.mkDir.bind(this);
@@ -33,13 +39,6 @@ class ServiceSFTP extends ServiceBase {
dot: true,
nocase: true
};
-
- // Define SFTP validation rules
- this.serviceValidation = {
- host: true,
- username: true,
- root: true
- };
}
/**
@@ -64,7 +63,7 @@ class ServiceSFTP extends ServiceBase {
if (newSettings.sshGateway) {
newSettings.sshGateway = Object.assign(
{},
- ServiceSFTP.gatewayDefaults,
+ SFTP.gatewayDefaults,
newSettings.sshGateway
)
}
@@ -109,7 +108,7 @@ class ServiceSFTP extends ServiceBase {
// Catch the native error and throw a better one
if (error.code === 'ENOTFOUND' && error.level === 'client-socket') {
// This is likely the error that means the client couldn't connect
- throw new Error(
+ throw new PushError(
i18n.t(
'sftp_could_not_connect_server',
this.config.service.host,
@@ -244,18 +243,19 @@ class ServiceSFTP extends ServiceBase {
// Offer to use a password
this.requestAuthentication()
.then((result) => {
- if (result) {
+ if (typeof result !== 'undefined') {
// Temporarily set the password in the service config
- this.config.service.password = result;
+ client.options.password = result;
resolve(this.openConnection(client, client.options));
} else {
+ // No password provided (escaped)
+ this.destroyClient(client);
reject(error);
}
});
} else {
// This likely ain't happening - let's just ditch the client and reject
this.destroyClient(client);
-
reject(error);
}
});
@@ -273,8 +273,8 @@ class ServiceSFTP extends ServiceBase {
return client.sftp;
})
.catch(() => {
- throw new Error(
- i18n.t('sftp_missing_root', this.config.settingsFilename)
+ throw new PushError(
+ i18n.t('service_missing_root', this.config.settingsFilename)
);
});
}
@@ -315,15 +315,27 @@ class ServiceSFTP extends ServiceBase {
* @param {object} client - Client connection object.
*/
destroyClient(client) {
- if (client.sftp) {
- client.sftp.end();
- client.sftp = null;
+ let tasks = [];
+
+ if (client.sftp && client.sftp.end) {
+ utils.trace('SFTP#destroyClient', 'Ending client connection');
+ tasks.push(client.sftp.end());
}
- if (client.gateway) {
- client.gateway.end();
- client.gateway = null;
+ if (client.gateway && client.gateway.end) {
+ utils.trace('SFTP#destroyClient', 'Ending client gateway connection');
+ tasks.push(client.gateway.end());
}
+
+ return Promise.all(tasks)
+ .then(() => {
+ client.sftp = null;
+ client.gateway = null;
+ })
+ .catch(() => {
+ client.sftp = null;
+ client.gateway = null;
+ });
}
/**
@@ -464,15 +476,22 @@ class ServiceSFTP extends ServiceBase {
* @param {string} hash
*/
removeClient(hash) {
- if (this.clients[hash] && this.clients[hash].sftp) {
+ let client;
+
+ if (!(client = this.clients[hash])) {
+ return Promise.reject(`Could not find client with supplied hash (${hash})`);
+ }
+
+ if (client && client.sftp) {
channel.appendLocalisedInfo(
'sftp_disconnected',
- this.clients[hash].options.host,
- this.clients[hash].options.port
+ client.options.host,
+ client.options.port
);
- return this.clients[hash].sftp.end()
+ return this.destroyClient(client)
.then(() => {
+ // Nullify and delete the object
this.clients[hash] = null;
delete this.clients[hash];
});
@@ -505,15 +524,14 @@ class ServiceSFTP extends ServiceBase {
/**
* Put a single file to the SFTP server.
- * @param {uri} local - Local source Uri.
+ * @param {Uri} local - Local source Uri.
* @param {string} remote - Remote destination pathname.
* @param {string} [collisionAction] - What to do on file collision. Use one
* of the utils.collisionOpts collision actions.
*/
put(local, remote, collisionAction) {
let remoteDir = path.dirname(remote),
- remoteFilename = path.basename(remote),
- localPath = this.paths.getNormalPath(local);
+ remoteFilename = path.basename(remote);
collisionAction = collisionAction ||
this.config.service.collisionUploadAction;
@@ -531,6 +549,18 @@ class ServiceSFTP extends ServiceBase {
return this.getFileStats(remote, local);
})
.then((stats) => {
+ if (!stats.local) {
+ // No local file! return TransferResult error
+ return new TransferResult(
+ local,
+ new PushError(i18n.t(
+ 'file_not_found',
+ this.paths.getBaseName(local)
+ )),
+ TRANSFER_TYPES.PUT
+ );
+ }
+
return super.checkCollision(
stats.local,
stats.remote,
@@ -538,11 +568,15 @@ class ServiceSFTP extends ServiceBase {
);
})
.then((result) => {
+ if (result instanceof TransferResult) {
+ // Pass through TransferResult results
+ return result;
+ }
+
// Figure out what to do based on the collision (if any)
if (result === false) {
// No collision, just keep going
- this.channel.appendLine(`>> ${remote}`);
- return this.clientPut(localPath, remote);
+ return this.clientPut(local, remote);
} else {
this.setCollisionOption(result);
@@ -551,23 +585,20 @@ class ServiceSFTP extends ServiceBase {
throw utils.errors.stop;
case utils.collisionOpts.skip:
- return false;
+ return new TransferResult(local, false, TRANSFER_TYPES.PUT);
case utils.collisionOpts.overwrite:
- this.channel.appendLine(`>> ${remote}`);
- return this.clientPut(localPath, remote);
+ return this.clientPut(local, remote);
case utils.collisionOpts.rename:
return this.list(remoteDir)
.then((dirContents) => {
- let remotePath = remoteDir + '/' + this.getNonCollidingName(
- remoteFilename,
- dirContents
- );
-
return this.put(
local,
- remotePath
+ remoteDir + '/' + this.getNonCollidingName(
+ remoteFilename,
+ dirContents
+ )
);
});
@@ -577,7 +608,8 @@ class ServiceSFTP extends ServiceBase {
}
})
.then((result) => {
- if (result !== false) {
+ if ((result instanceof TransferResult) && !result.error) {
+ // Transfer occured with no errors - set the remote file mode
return this.setRemotePathMode(remote, this.config.service.fileMode)
.then(() => result);
}
@@ -616,7 +648,7 @@ class ServiceSFTP extends ServiceBase {
// List the source directory in order to cache the file data
return this.list(remoteDir)
.catch((error) => {
- throw new Error(
+ throw new PushError(
i18n.t('cannot_list_directory', remoteDir, error.message)
);
});
@@ -624,7 +656,14 @@ class ServiceSFTP extends ServiceBase {
.then(() => this.getFileStats(remote, local))
.then((stats) => {
if (!stats.remote) {
- throw(i18n.t('remote_file_not_found', remote));
+ return new TransferResult(
+ local,
+ new PushError(i18n.t(
+ 'remote_file_not_found',
+ this.paths.getBaseName(remote)
+ )),
+ TRANSFER_TYPES.GET
+ );
}
return super.checkCollision(
@@ -633,43 +672,46 @@ class ServiceSFTP extends ServiceBase {
collisionAction
);
})
- .then((result) => {
+ .then((collision) => {
// Figure out what to do based on the collision (if any)
let localDir, localFilename;
- if (result === false) {
+ if (collision instanceof TransferResult) {
+ return collision;
+ }
+
+ if (collision === false) {
// No collision, just keep going
- this.channel.appendLine(`<< ${localPath}`);
- return this.clientGetByStream(localPath, remote);
+ return this.clientGetByStream(local, remote);
} else {
- this.setCollisionOption(result);
+ this.setCollisionOption(collision);
- switch (result.option) {
+ switch (collision.option) {
case utils.collisionOpts.stop:
throw utils.errors.stop;
case utils.collisionOpts.skip:
case undefined:
- return false;
+ return new TransferResult(local, false, TRANSFER_TYPES.GET);
case utils.collisionOpts.overwrite:
- this.channel.appendLine(`<< ${localPath}`);
- return this.clientGetByStream(localPath, remote);
+ return this.clientGetByStream(local, remote);
case utils.collisionOpts.rename:
localDir = path.dirname(localPath);
localFilename = path.basename(localPath);
+ // Rename (non-colliding) and get
return this.paths.listDirectory(localDir)
.then((dirContents) => {
- let localPath = localDir + '/' + this.getNonCollidingName(
+ let localPath = localDir + '/' +
+ this.getNonCollidingName(
localFilename,
dirContents
);
- this.channel.appendLine(`<< ${localPath}`);
return this.clientGetByStream(
- localPath,
+ vscode.Uri.file(localPath),
remote
);
});
@@ -680,7 +722,6 @@ class ServiceSFTP extends ServiceBase {
}
})
.catch((error) => {
- console.log(`error! ${error.message || error}`);
this.setProgress(false);
throw(error);
});
@@ -771,15 +812,40 @@ class ServiceSFTP extends ServiceBase {
});
}
+ /**
+ * Uploads a single file using the SSH library.
+ * @param {Uri} local - Local Uri to put to the server.
+ * @param {string} remote - Remote path to replace upload to.
+ */
clientPut(local, remote) {
+ let localPath = this.paths.getNormalPath(local);
+
return new Promise((resolve, reject) => {
this.globalReject = reject;
this.connect().then((client) => {
- client.put(local, remote)
- .then(resolve)
+ client.put(localPath, remote)
+ .then(() => {
+ resolve(new TransferResult(
+ local,
+ true,
+ TRANSFER_TYPES.PUT, {
+ srcLabel: remote
+ }
+ ));
+ })
.catch((error) => {
- reject(new Error(`${remote}: ${error.message}`));
+ if (error.code === 'ENOENT') {
+ // File no longer exists - skip (but don't stop)
+ return resolve(new TransferResult(
+ local,
+ new PushError(i18n.t('file_not_found', localPath)),
+ TRANSFER_TYPES.PUT
+ ));
+ }
+
+ // Other errors
+ reject(new PushError(`${remote}: ${error.message}`));
});
});
});
@@ -787,22 +853,19 @@ class ServiceSFTP extends ServiceBase {
/**
* Retrieves a file from the server using its get stream method.
- * @param {string} local - Local pathname.
+ * @param {Uri} local - Local Uri.
* @param {string} remote - Remote pathname.
*/
clientGetByStream(local, remote) {
- let client;
+ let client,
+ localPath = this.paths.getNormalPath(local);
return this.connect()
.then((connection) => {
client = connection;
})
- .then(() => {
- return this.paths.ensureDirExists(path.dirname(local));
- })
- .then(() => {
- return this.getMimeCharset(remote);
- })
+ .then(() => this.paths.ensureDirExists(path.dirname(localPath)))
+ .then(() => this.getMimeCharset(remote))
.then((charset) => {
return new Promise((resolve, reject) => {
// Get file with client#get and stream to local pathname
@@ -810,11 +873,23 @@ class ServiceSFTP extends ServiceBase {
client.get(remote, true, charset === 'binary' ? null : 'utf8')
.then((stream) => {
- utils.writeFileFromStream(stream, local, remote)
- .then(resolve, reject)
+ utils.writeFileFromStream(stream, localPath, remote)
+ .then(() => {
+ resolve(new TransferResult(
+ local,
+ true,
+ TRANSFER_TYPES.GET
+ ));
+ }, (error) => {
+ resolve(new TransferResult(
+ local,
+ new PushError(error),
+ TRANSFER_TYPES.GET
+ ));
+ })
})
.catch((error) => {
- throw new Error(`${remote}: ${error && error.message}`);
+ throw new PushError(`${remote}: ${error && error.message}`);
});
});
});
@@ -870,13 +945,18 @@ class ServiceSFTP extends ServiceBase {
if (this.pathCache.dirIsCached(SRC_REMOTE, dir)) {
// Retrieve cached path list
// TODO: Allow ignoreGlobs option on this route
+ utils.trace('SFTP#list', `Using cached path for ${dir}`);
return Promise.resolve(this.pathCache.getDir(SRC_REMOTE, dir));
} else {
// Get path list interactively and cache
return this.connect()
.then((connection) => {
+ utils.trace('SFTP#list', `Getting path for ${dir}`);
+
return connection.list(dir)
.then((list) => {
+ utils.trace('SFTP#list', `${list.length} item(s) found`);
+
list.forEach((item) => {
let match,
pathName = utils.addTrailingSeperator(dir) + item.name;
@@ -929,7 +1009,8 @@ class ServiceSFTP extends ServiceBase {
dir
));
}
- }).catch(reject)
+ })
+ .catch(reject);
});
}
@@ -952,6 +1033,7 @@ class ServiceSFTP extends ServiceBase {
.then((dirContents) => {
let dirs;
+ // Increment counter scanned (which will eventually meet counter.total)
counter.scanned += 1;
if (dirContents !== null) {
@@ -971,6 +1053,27 @@ class ServiceSFTP extends ServiceBase {
});
}
+ callback(counter);
+ })
+ .catch((error) => {
+ // The directory couldn't be scanned for some reason - increment anyway...
+ counter.scanned += 1;
+
+ // ... And show an error
+ if (error instanceof Error) {
+ channel.appendError(i18n.t(
+ 'dir_read_error_with_error',
+ dir,
+ error && error.message
+ ));
+ } else {
+ channel.appendError(i18n.t(
+ 'dir_read_error',
+ dir,
+ error && error.message
+ ));
+ }
+
callback(counter);
});
}
@@ -1000,7 +1103,7 @@ class ServiceSFTP extends ServiceBase {
return result;
})
.catch((error) => {
- throw new Error(
+ throw new PushError(
i18n.t('cannot_list_directory', remoteDir, error.message)
);
})
@@ -1098,7 +1201,7 @@ class ServiceSFTP extends ServiceBase {
getBasicMimeCharset(file) {
const ext = path.extname(file);
- if (ServiceSFTP.encodingByExtension.utf8.indexOf(ext) !== -1) {
+ if (SFTP.encodingByExtension.utf8.indexOf(ext) !== -1) {
return 'utf8';
}
@@ -1117,9 +1220,9 @@ class ServiceSFTP extends ServiceBase {
}
};
-ServiceSFTP.description = i18n.t('sftp_class_description');
+SFTP.description = i18n.t('sftp_class_description');
-ServiceSFTP.defaults = {
+SFTP.defaults = {
host: '',
port: 22,
username: '',
@@ -1133,7 +1236,13 @@ ServiceSFTP.defaults = {
sshGateway: null
};
-ServiceSFTP.gatewayDefaults = {
+SFTP.required = {
+ host: true,
+ username: true,
+ root: true
+};
+
+SFTP.gatewayDefaults = {
host: '',
port: 22,
username: '',
@@ -1144,7 +1253,7 @@ ServiceSFTP.gatewayDefaults = {
debug: false
};
-ServiceSFTP.encodingByExtension = {
+SFTP.encodingByExtension = {
'utf8': [
'.txt', '.html', '.shtml', '.js', '.jsx', '.css', '.less', '.sass',
'.php', '.asp', '.aspx', '.svg', '.sql', '.rb', '.py', '.log', '.sh', '.bat',
@@ -1152,4 +1261,4 @@ ServiceSFTP.encodingByExtension = {
]
};
-module.exports = ServiceSFTP;
+module.exports = SFTP;
diff --git a/src/services/TransferResult.js b/src/services/TransferResult.js
new file mode 100644
index 0000000..f4f3c5d
--- /dev/null
+++ b/src/services/TransferResult.js
@@ -0,0 +1,46 @@
+const vscode = require('vscode');
+
+const { TRANSFER_TYPES, QUEUE_LOG_TYPES } = require('../lib/constants');
+
+/**
+ * @typedef {object} TransferResultOptions
+ * @property {srcLabel} - Override the `src` label with this custom string.
+ */
+
+/**
+ * @param {Url} src - The source file Uri being transferred.
+ * @param {boolean|Error} status - The status of the transfer. Either a basic
+ * boolean `true` for success or `false` for skipped, or `Error` for a more
+ * detailed error.
+ * @param {number} type - One of the {@link TRANSFER_TYPES} types.
+ * @param {TransferResultOptions} [options] - Transfer result options.
+ * @description
+ * Create a Transfer result instance.
+ *
+ * If an `Error` object is provided as `status`, then its error message will
+ * be written to the channel. Do not use errors which may confuse the user
+ * and/or are not localised. (I.e. do not use errors directly from APIs).
+ */
+class TransferResult {
+ constructor(src, status = true, type = TRANSFER_TYPES.PUT, options = {}) {
+ if (!src instanceof vscode.Uri) {
+ throw new Error('src must be an instance of vscode.Uri');
+ }
+
+ this.src = src;
+ this.status = (status === true ? status : false);
+ this.type = type;
+ this.options = options;
+ this.error = ((status instanceof Error) ? status : null);
+
+ if (this.error) {
+ this.logType = QUEUE_LOG_TYPES.fail;
+ } else if (this.status === false) {
+ this.logType = QUEUE_LOG_TYPES.skip;
+ } else {
+ this.logType = QUEUE_LOG_TYPES.success;
+ }
+ }
+}
+
+module.exports = TransferResult;
diff --git a/test/fixtures/general.js b/test/fixtures/general.js
index c4a0875..674d4ce 100644
--- a/test/fixtures/general.js
+++ b/test/fixtures/general.js
@@ -1,12 +1,17 @@
const vscode = require('../mocks/node/vscode');
module.exports = {
+ mockFolder: __dirname + '/transfer/test-folder/',
mockUriFile: vscode.Uri.file(__dirname + '/transfer/test-file.txt'),
mockUriFile2: vscode.Uri.file(__dirname + '/transfer/test-file-2.txt'),
- mockForeignSchemeFile: new vscode.Uri('foreign-scheme', '', 'foreign-scheme-file.txt'),
+ mockForeignSchemeFile: new vscode.Uri('foreign-scheme', '', __dirname + '/transfer/test-file.txt'),
mockUriFolder: vscode.Uri.file(__dirname + '/transfer/test-folder/'),
+ mockUriSubFile: vscode.Uri.file(__dirname + '/transfer/test-folder/test-subfile.txt'),
mockUriMissingFile: vscode.Uri.file(__dirname + '/transfer/nofile.txt'),
mockUriIgnoredFile: vscode.Uri.file(__dirname + '/transfer/desktop.ini'),
+ mockWorkspace: {
+ rootPath: __dirname + '/transfer'
+ },
servers: {
SFTP: {
diff --git a/test/fixtures/transfer/test-folder/.gitkeep b/test/fixtures/transfer/test-folder/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/test/fixtures/transfer/test-folder/.hidden-file b/test/fixtures/transfer/test-folder/.hidden-file
new file mode 100644
index 0000000..ee30d8c
--- /dev/null
+++ b/test/fixtures/transfer/test-folder/.hidden-file
@@ -0,0 +1 @@
+This is a hidden file
diff --git a/test/fixtures/transfer/test-folder/another-test-subfile.txt b/test/fixtures/transfer/test-folder/another-test-subfile.txt
new file mode 100644
index 0000000..1914013
--- /dev/null
+++ b/test/fixtures/transfer/test-folder/another-test-subfile.txt
@@ -0,0 +1 @@
+Another test sub-file.
diff --git a/test/fixtures/transfer/test-folder/test-subfile.txt b/test/fixtures/transfer/test-folder/test-subfile.txt
new file mode 100644
index 0000000..ef7a889
--- /dev/null
+++ b/test/fixtures/transfer/test-folder/test-subfile.txt
@@ -0,0 +1 @@
+This is a test file within test-folder
diff --git a/test/mocks/lib/PushBase.js b/test/mocks/lib/PushBase.js
index d9c0443..a7ac35b 100644
--- a/test/mocks/lib/PushBase.js
+++ b/test/mocks/lib/PushBase.js
@@ -4,6 +4,14 @@ class PushBase {
constructor() {
this.config = config;
}
+
+ rateLimit(id, timeout, fn) {
+ return fn;
+ }
+
+ clearTimedExecution() {
+
+ }
}
-module.exports = PushBase;
\ No newline at end of file
+module.exports = PushBase;
diff --git a/test/mocks/lib/Service.js b/test/mocks/lib/Service.js
index 66599f9..7be5c70 100644
--- a/test/mocks/lib/Service.js
+++ b/test/mocks/lib/Service.js
@@ -1,14 +1,20 @@
const counter = require('../../helpers/counter');
+const ServiceSettings = require('./ServiceSettings');
class Service {
constructor() {
this.restartServiceInstance = counter.create('Service#restartServiceInstance');
+ this.settings = new ServiceSettings.sftp();
}
exec(method, config, args) {
return this[method].apply(this, args);
}
+ execSync(method, config, args) {
+ return this[method].apply(this, args);
+ }
+
convertUriToRemote(uri) {
return uri;
}
diff --git a/test/mocks/lib/Watch.js b/test/mocks/lib/Watch.js
index 5845b5b..2d04bbe 100644
--- a/test/mocks/lib/Watch.js
+++ b/test/mocks/lib/Watch.js
@@ -1,5 +1,7 @@
class Watch {
+ recallByWorkspaceFolders() {
+ }
}
-module.exports = Watch;
\ No newline at end of file
+module.exports = Watch;
diff --git a/test/mocks/lib/utils.js b/test/mocks/lib/utils.js
index f6e6d03..0d6eff4 100644
--- a/test/mocks/lib/utils.js
+++ b/test/mocks/lib/utils.js
@@ -1,7 +1,11 @@
const counter = require('../../helpers/counter');
const utils = {
- showWarning: counter.create('utils.showWarning')
+ showWarning: counter.create('utils.showWarning'),
+
+ trace: function() {
+ // Into the ether...
+ }
};
module.exports = utils;
diff --git a/test/mocks/node/vscode.js b/test/mocks/node/vscode.js
index d31dcb5..a19ea90 100644
--- a/test/mocks/node/vscode.js
+++ b/test/mocks/node/vscode.js
@@ -12,6 +12,26 @@ Uri.file = (uri) => {
return new Uri('file', '', uri);
}
+class ExtensionContext {
+ constructor() {
+ this.globalState = new StateMachine();
+ }
+}
+
+class StateMachine {
+ constructor() {
+ this.store = {};
+ }
+
+ get(key, defaultValue = undefined) {
+ return this.store[key] || defaultValue;
+ }
+
+ update(key, value) {
+ this.store[key] = value;
+ }
+}
+
module.exports = {
commands: {
executeCommand: counter.attach('vscode.commands.executeCommand')
@@ -32,7 +52,10 @@ module.exports = {
});
},
this
- )
+ ),
+ showInformationMessage: () => {
+ return Promise.resolve('');
+ }
},
workspace: {
@@ -44,5 +67,6 @@ module.exports = {
window: 0
},
- Uri
+ Uri,
+ ExtensionContext
};
diff --git a/test/spec/test.Paths.js b/test/spec/test.Paths.js
index aa02f03..2dd117c 100644
--- a/test/spec/test.Paths.js
+++ b/test/spec/test.Paths.js
@@ -7,6 +7,7 @@
// The module 'assert' provides assertion methods from node
const assert = require('assert');
+const expect = require('chai').expect;
// You can import and use all API from the 'vscode' module
// as well as import your extension to test it
@@ -14,14 +15,17 @@ const useMockery = require('../helpers/mockery');
const counter = require('../helpers/counter');
const fixtures = require('../fixtures/general');
+// Mocks
+const vscode = require('../mocks/node/vscode');
+
// Defines a Mocha test suite to group tests of similar kind together
describe('Paths', function() {
- let Paths;
+ let Paths, paths;
useMockery(() => {
useMockery
.registerMultiple({
- 'vscode': require('../mocks/node/vscode')
+ 'vscode': vscode
});
});
@@ -31,38 +35,110 @@ describe('Paths', function() {
beforeEach(() => {
counter.reset();
+ paths = new Paths();
});
// Defines a Mocha unit test
describe('#fileExists', () => {
- it('should return whether a Uri exists');
- it('should return false if a non-file Uri is passed');
+ it('should return whether a Uri exists', () => {
+ assert(paths.fileExists(fixtures.mockUriFile) === true);
+ });
+ it('should return false if a non-file Uri is passed', () => {
+ assert(paths.fileExists(fixtures.mockForeignSchemeFile) === false);
+ });
+ });
+
+ describe('#pathInUri', () => {
+ it('should return true for a Uri within a Uri', () => {
+ assert(paths.pathInUri(
+ fixtures.mockUriSubFile,
+ fixtures.mockUriFolder
+ ) === true);
+ });
+
+ it('should return false for a Uri not within a Uri', () => {
+ assert(paths.pathInUri(
+ fixtures.mockUriFile2,
+ fixtures.mockUriFolder
+ ) === false);
+ });
});
+
describe('#getNormalPath', () => {
- it('should return a normalised (string) version of a Uri');
+ it('should return a normalised (string) version of a Uri', () => {
+ assert(typeof paths.getNormalPath(
+ fixtures.mockUriFile
+ ) === 'string');
+
+ assert(/test-file.txt/.test(paths.getNormalPath(
+ fixtures.mockUriFile
+ )));
+ });
});
describe('#getPathWithoutWorkspace', () => {
- it('should return a Uri normalised without workspace path');
+ it('should return a Uri normalised without workspace path', () => {
+ assert(paths.getPathWithoutWorkspace(
+ fixtures.mockUriFile,
+ fixtures.mockWorkspace
+ ) === '/test-file.txt')
+ });
});
describe('#stripTrailingSlash', () => {
- it('should strip a trailing slash');
- it('should ignore no trailing slash exists');
+ it('should strip a trailing slash', () => {
+ assert(paths.stripTrailingSlash(
+ '/path/with/trailing/slash/'
+ ) === '/path/with/trailing/slash');
+ });
+
+ it('should ignore if no trailing slash exists', () => {
+ assert(paths.stripTrailingSlash(
+ '/path/without/trailing/slash'
+ ) === '/path/without/trailing/slash');
+ });
});
describe('#addTrailingSlash', () => {
- it('should add exactly one trailing slash');
- it('should ignore if a trailing slash exists');
+ it('should add exactly one trailing slash', () => {
+ assert(paths.addTrailingSlash(
+ '/path/without/trailing/slash'
+ ) === '/path/without/trailing/slash/');
+ });
+
+ it('should ignore if a trailing slash exists', () => {
+ assert(paths.addTrailingSlash(
+ '/path/with/trailing/slash/'
+ ) === '/path/with/trailing/slash/');
+ });
});
describe('#isDirectory', () => {
- it('should confirm a string directory');
- it('should confirm a Uri directory');
+ it('should not fault with a non-existent directory, but return false', () => {
+ assert(paths.isDirectory(
+ '/utter/nonsense/directory/flj3232joisdfosdjgkljhurfs/'
+ ) === false);
+ });
});
- describe('#listDirectory (tests pending documentation)', () => {
+ describe('#listDirectory', () => {
+ it('should list a directory', () => {
+ return paths.listDirectory(fixtures.mockFolder)
+ .then((list) => {
+ expect(list[0].name).to.equal('.hidden-file');
+ expect(list[0].pathName).to.have.string('/transfer/test-folder/.hidden-file');
+ expect(list[0].type).to.equal('f');
+
+ expect(list[1].name).to.equal('another-test-subfile.txt');
+ expect(list[1].pathName).to.have.string('/transfer/test-folder/another-test-subfile.txt');
+ expect(list[1].type).to.equal('f');
+
+ expect(list[2].name).to.equal('test-subfile.txt');
+ expect(list[2].pathName).to.have.string('/transfer/test-folder/test-subfile.txt');
+ expect(list[2].type).to.equal('f');
+ });
+ });
});
describe('#getDirectoryContentsAsFiles', () => {
@@ -93,4 +169,4 @@ describe('Paths', function() {
it('should find a file');
it('return null if no file is found');
});
-});
\ No newline at end of file
+});
diff --git a/test/spec/test.Push.js b/test/spec/test.Push.js
index ed55b07..c13af08 100644
--- a/test/spec/test.Push.js
+++ b/test/spec/test.Push.js
@@ -7,6 +7,7 @@
// The module 'assert' provides assertion methods from node
const assert = require('assert');
+const expect = require('chai').expect;
// You can import and use all API from the 'vscode' module
// as well as import your extension to test it
@@ -14,7 +15,9 @@ const useMockery = require('../helpers/mockery');
const counter = require('../helpers/counter');
const fixtures = require('../fixtures/general');
+// Mocks
const Queue = require('../mocks/lib/Queue');
+const vscode = require('../mocks/node/vscode');
// Defines a Mocha test suite to group tests of similar kind together
describe('Push', function() {
@@ -23,8 +26,7 @@ describe('Push', function() {
useMockery(() => {
useMockery
.registerMultiple({
- 'vscode': require('../mocks/node/vscode'),
- './lib/ServiceSettings': require('../mocks/lib/ServiceSettings').sftp,
+ 'vscode': vscode,
'./lib/Service': require('../mocks/lib/Service'),
'./lib/explorer/Explorer': require('../mocks/lib/Explorer'),
// './lib/Paths': require('../mocks/lib/Paths'),
@@ -35,7 +37,8 @@ describe('Push', function() {
'./lib/channel': require('../mocks/lib/channel'),
'./lib/utils': require('../mocks/lib/utils'),
'./lib/PushBase': require('../mocks/lib/PushBase'),
- './lang/i18n': require('../mocks/lib/i18n')
+ './lang/i18n': require('../mocks/lib/i18n'),
+ '../lang/i18n': require('../mocks/lib/i18n')
});
});
@@ -45,7 +48,7 @@ describe('Push', function() {
});
beforeEach(() => {
- push = new Push();
+ push = new Push(new vscode.ExtensionContext);
counter.reset();
});
@@ -68,9 +71,12 @@ describe('Push', function() {
() => Promise.resolve()
);
- push.didSaveTextDocument({
- uri: fixtures.mockUriFile
- });
+ push.event(
+ 'onDidSaveTextDocument',
+ {
+ uri: fixtures.mockUriFile
+ }
+ );
assert(counter.getCount('Push#queueForUpload') === 1);
});
@@ -200,7 +206,7 @@ describe('Push', function() {
return push.execQueue(Push.queueDefs.default)
.catch((message) => {
- assert(message === 'Queue running.');
+ assert(/already running/.test(message));
});
});
});
@@ -305,18 +311,18 @@ describe('Push', function() {
if (Array.isArray(test.files)) {
let args = counter.getArgs('Push#queue');
- assert(counter.getCount('Push#queue') === test.files.length);
+ expect(counter.getCount('Push#queue')).to.equal(test.files.length);
test.files.forEach((file, index) => {
- assert(args[index][0][0].method === test.method);
- assert.deepEqual(args[index][0][0].uriContext, file);
+ expect(args[index][0][0].method).to.equal(test.method);
+ expect(args[index][0][0].uriContext).to.eql(file);
});
} else {
args = counter.getArgs('Push#queue', 1, 0);
- assert(counter.getCount('Push#queue') === 1);
- assert(args[0].method === test.method);
- assert.deepEqual(args[0].uriContext, test.files);
+ expect(counter.getCount('Push#queue')).to.be.equal(1);
+ expect(args[0].method).to.equal(test.method);
+ expect(args[0].uriContext).to.eql(test.files);
}
});
});
@@ -408,6 +414,10 @@ describe('Push', function() {
) === false);
});
- it('throws if the path is not a directory');
+ it('throws if the path is not a directory', () => {
+ assert.throws(() => push.transferDirectory(
+ fixtures.mockUriFile
+ ), /Path is a single file/);
+ });
})
});
diff --git a/test/spec/test.ServiceSettings.js b/test/spec/test.ServiceSettings.js
index aef55da..29db29d 100644
--- a/test/spec/test.ServiceSettings.js
+++ b/test/spec/test.ServiceSettings.js
@@ -18,24 +18,15 @@ const fixtures = require('../fixtures/general');
describe('ServiceSettings', function() {
let ServiceSettings;
- // useMockery(() => {
- // useMockery
- // .registerMultiple({
- // 'vscode': require('../mocks/node/vscode'),
- // './lib/ServiceSettings': require('../mocks/lib/ServiceSettings').sftp,
- // './lib/Service': require('../mocks/lib/Service'),
- // './lib/explorer/Explorer': require('../mocks/lib/Explorer'),
- // // './lib/Paths': require('../mocks/lib/Paths'),
- // './lib/queue/Queue': {},
- // './lib/queue/QueueTask': {},
- // './lib/Watch': require('../mocks/lib/Watch'),
- // './lib/SCM': require('../mocks/lib/SCM'),
- // './lib/channel': {},
- // './lib/utils': {},
- // './lib/PushBase': require('../mocks/lib/PushBase'),
- // './lang/i18n': require('../mocks/lib/i18n')
- // });
- // });
+ useMockery(() => {
+ useMockery
+ .registerMultiple({
+ 'vscode': require('../mocks/node/vscode'),
+ '../lib/channel': {},
+ '../lib/utils': {},
+ '../lang/i18n': require('../mocks/lib/i18n')
+ });
+ });
before(() => {
ServiceSettings = require('../../src/lib/ServiceSettings');
@@ -55,4 +46,4 @@ describe('ServiceSettings', function() {
it('should use a cached file if it exists');
it('should read and set a new file into cache');
});
-});
\ No newline at end of file
+});